/** * modes/check-runner.ts — orchestrates a single check run. * * Pipeline: * init/resolve run-state → Q0 recon (shared, once) → * analysis sub-agent (buildScanTask) → fix sub-agent (buildFixTask, only * with --fix) → verify gate → cleanup transient artifacts. * * Every phase is recorded on the persisted run-state via `run-state.ts`, so * `/pygienium-status`, `/pygienium-resume`, and `/pygienium-export` reflect * real progress. The check-runner is check-agnostic: a `CheckDefinition` * supplies the task builders and gate; this module only wires the phases * together. * * @module pygienium/modes/check-runner */ import { rm } from "node:fs/promises"; import { resolve, join } from "node:path"; import type { ExtensionUIContext } from "@oh-my-pi/pi-coding-agent"; import type { CheckDefinition, CheckScope } from "../checks/registry.js"; import { runAgentTask } from "../agent-runner.js"; import { runRecon } from "../recon.js"; import { createPhaseStrip, type SendChatMessage, type CheckCompletionDetails, type PhaseLogEntry, type PhaseLogStatus, PHASE_LABELS, } from "../phases.js"; import type { AgentSessionEvent } from "@oh-my-pi/pi-coding-agent"; import { createPipelineFooter, footerPhaseItems } from "../footer.js"; import { applyPhaseStatus, ensureRunStateIgnored, initRunState, loadRunState, markCheckStatus, markRunStatus, PHASE_ANALYSIS, PHASE_CLEANUP, PHASE_FIX, PHASE_RECON, PHASE_VERIFY, phasesForCheck, recordCheckOutput, reconcileRunStatus, saveRunState, stateDir, type RunState, } from "../run-state.js"; /** Resolve a raw arg string into a check scope (target path + flags). */ export function parseCheckArgs(raw: string, cwd: string): CheckScope { const tokens = raw.trim().length > 0 ? raw.trim().split(/\s+/) : []; let fix = false; let target = cwd; const rest: string[] = []; for (const tok of tokens) { if (tok === "--fix") { fix = true; } else if (tok.startsWith("--")) { rest.push(tok); } else { target = tok; } } // Absolute-ize target against cwd. target = resolve(cwd, target); return { cwd, target, fix, rest }; } export interface RunCheckOptions { /** The check definition to run. */ check: CheckDefinition; /** Working directory (from `ctx.cwd`). */ cwd: string; /** Parsed scope (target + flags). When omitted, derived from `rawArgs`. */ scope?: CheckScope; /** Raw command args, used when `scope` is omitted. */ rawArgs?: string; /** UI context (optional; null in print mode). */ ui?: ExtensionUIContext; /** Whether dialog-capable UI is available. */ hasUI?: boolean; /** Pre-existing run state to update (for `/pygienium-all` and resume). */ existingState?: RunState; /** Optional callback to post completion messages into the chat. */ sendChatMessage?: SendChatMessage; /** * Render the per-check live widget (default true). Set false when an outer * strip (e.g. `/pygienium-all`) already shows this check's phase, so two * spinners don't fight over the widget area. */ widget?: boolean; /** * Render the pipeline-overview footer status line (default true). Set false * when an outer run (e.g. `/pygienium-all`) already owns the footer, so two * overviews never compete over the same status slot. */ footer?: boolean; /** Optional callback that forwards raw sub-agent events to the chat * stream (see `pygienium-stream` in `index.ts`). */ onAgentEvent?: (phase: string, event: AgentSessionEvent) => void; /** Optional callback to emit synthetic progress lines during non-agent * phases (verify, cleanup, recon) into the chat stream. */ sendPhaseLine?: (phase: string, text: string) => void; /** * Ensure `/.gitignore` excludes `.pygienium/` before this run writes * state/artifacts (default true). Set false with `--no-gitignore`. */ gitignore?: boolean; } /** Outcome of a single check run. */ export interface CheckRunOutcome { /** Final check status. */ status: "complete" | "failed" | "skipped"; /** True when this run appended `.pygienium/` to the repo's .gitignore. */ gitignoreAppended?: boolean; /** Findings text from the analysis phase. */ findings?: string; /** Changes text from the fix phase (when run with --fix). */ changes?: string; /** Error message on failure. */ error?: string; /** The updated run state. */ state: RunState; } /** * Run a single check end-to-end, persisting progress to run-state, and post a * ralpi-style completion message into the chat (header + expandable phase * tree) when a `sendChatMessage` callback is supplied. * * Resumable: if `existingState` already has terminal-ish progress for this * check, the runner resumes the last in-progress phase rather than restarting. */ export async function runCheck( opts: RunCheckOptions, ): Promise { const startMs = Date.now(); const outcome = await runCheckImpl(opts); try { postCheckCompletion(opts, outcome, Date.now() - startMs); } catch { // Completion posting is best-effort chat UI: a renderer or send // failure must never reject the run after its state was persisted. } return outcome; } async function runCheckImpl(opts: RunCheckOptions): Promise { // Keep pygienium's own output out of the scanned repo's git index unless // the caller opted out with --no-gitignore. const gitignoreAppended = opts.gitignore === false ? false : await ensureRunStateIgnored(opts.cwd); const outcome = await runCheckImplInner(opts); return { ...outcome, gitignoreAppended }; } async function runCheckImplInner( opts: RunCheckOptions, ): Promise { const { check, cwd } = opts; const scope = opts.scope ?? parseCheckArgs(opts.rawArgs ?? "", cwd); // Resolve or init the run state, recording this check on first sight. const state: RunState = opts.existingState ?? (await loadRunState(cwd)) ?? initRunState(cwd, []); if (!state.checks[check.name]) { state.checks[check.name] = { name: check.name, label: check.label, status: "pending", fix: scope.fix, phases: phasesForCheck(scope.fix), }; } await saveRunState(state); const strip = createPhaseStrip({ ui: opts.ui, hasUI: opts.hasUI ?? false, checkLabel: check.label, }); /** Phase tag combining check label + phase label for stream lines. */ const phaseTag = (phaseId: string): string => `${check.label}: ${PHASE_LABELS[phaseId] ?? phaseId}`; /** Forward a raw agent event tagged with the current phase. */ const forward = (phaseId: string) => (event: AgentSessionEvent) => opts.onAgentEvent?.(phaseTag(phaseId), event); /** Emit a synthetic stream line for non-agent phases (verify/cleanup/recon). */ const phaseLine = (phaseId: string, text: string): void => opts.sendPhaseLine?.(phaseTag(phaseId), text); // Pipeline-overview footer: a static one-line view of the full phase list // with the cursor on the current phase and what's to come. Detail lives in // the chat (phase strip + completion tree); the footer is the overview. // Disabled (no-op) when an outer run owns the footer, e.g. /pygienium-all. const phaseIds = ( state.checks[check.name]?.phases ?? phasesForCheck(scope.fix) ).map((p) => p.id); const footerIdx = new Map(phaseIds.map((id, i) => [id, i] as const)); const footer = createPipelineFooter({ ui: opts.ui, hasUI: opts.hasUI ?? false, enabled: opts.footer ?? true, }); footer.setPipeline( `pygienium ${check.label}`, footerPhaseItems(phaseIds, PHASE_LABELS), ); const footerEnter = (phaseId: string): void => { const i = footerIdx.get(phaseId); if (i !== undefined) footer.setCursor(i); }; const footerComplete = (phaseId: string): void => { const i = footerIdx.get(phaseId); if (i !== undefined) footer.setItem(i, "complete"); }; let findings = ""; let changes = ""; let error: string | undefined; try { // --- Phase: gate ----------------------------------------------------- const gateResult = await Promise.resolve(check.gate(cwd)); if (gateResult) { // Skip this check entirely (no agent work). for (const phase of state.checks[check.name]?.phases ?? []) { if (phase.status === "pending") phase.status = "skipped"; } for (let i = 0; i < phaseIds.length; i++) footer.setItem(i, "skipped"); markCheckStatus(state, check.name, "skipped", gateResult); markRunStatus(state, reconcileRunStatus(state)); await saveRunState(state); strip.setPhase(PHASE_CLEANUP); strip.done(); return { status: "skipped", error: gateResult, state }; } // --- Phase: recon (shared, run once per run) ------------------------- if (!state.recon.complete) { strip.setPhase(PHASE_RECON); footerEnter(PHASE_RECON); applyPhaseStatus(state, check.name, PHASE_RECON, "in_progress"); await saveRunState(state); phaseLine(PHASE_RECON, "scanning project structure…"); const snapshot = await runRecon(cwd); state.recon = { complete: true, path: join(stateDir(cwd), "recon.json"), finishedAt: snapshot.createdAt, }; phaseLine(PHASE_RECON, "✓ recon complete"); applyPhaseStatus(state, check.name, PHASE_RECON, "complete"); await saveRunState(state); footerComplete(PHASE_RECON); } else { // Recon already done this run — mark this check's recon complete. applyPhaseStatus(state, check.name, PHASE_RECON, "complete"); footerComplete(PHASE_RECON); } // --- Phase: analysis ------------------------------------------------- strip.setPhase(PHASE_ANALYSIS); footerEnter(PHASE_ANALYSIS); applyPhaseStatus(state, check.name, PHASE_ANALYSIS, "in_progress"); await saveRunState(state); const scanTask = await check.buildScanTask(cwd, scope); const scanResult = await runAgentTask({ cwd: scope.target, agentName: check.agentName, task: scanTask, onEvent: forward(PHASE_ANALYSIS), }); findings = scanResult.text; recordCheckOutput(state, check.name, { findings }); if (!scanResult.ok) { applyPhaseStatus( state, check.name, PHASE_ANALYSIS, "failed", scanResult.error, ); markCheckStatus(state, check.name, "failed", scanResult.error); markRunStatus(state, reconcileRunStatus(state)); await saveRunState(state); return { status: "failed", error: scanResult.error, findings, state }; } applyPhaseStatus(state, check.name, PHASE_ANALYSIS, "complete"); await saveRunState(state); footerComplete(PHASE_ANALYSIS); // --- Phase: fix (only with --fix) ----------------------------------- if (scope.fix) { strip.setPhase(PHASE_FIX); footerEnter(PHASE_FIX); applyPhaseStatus(state, check.name, PHASE_FIX, "in_progress"); await saveRunState(state); const fixTask = await check.buildFixTask(cwd, scope, findings); const fixResult = await runAgentTask({ cwd: scope.target, agentName: check.fixAgentName ?? "fixer", task: fixTask, onEvent: forward(PHASE_FIX), }); changes = fixResult.text; recordCheckOutput(state, check.name, { changes }); if (!fixResult.ok) { applyPhaseStatus( state, check.name, PHASE_FIX, "failed", fixResult.error, ); markCheckStatus(state, check.name, "failed", fixResult.error); markRunStatus(state, reconcileRunStatus(state)); await saveRunState(state); return { status: "failed", error: fixResult.error, findings, changes, state, }; } applyPhaseStatus(state, check.name, PHASE_FIX, "complete"); await saveRunState(state); footerComplete(PHASE_FIX); } // --- Phase: verify --------------------------------------------------- strip.setPhase(PHASE_VERIFY); footerEnter(PHASE_VERIFY); applyPhaseStatus(state, check.name, PHASE_VERIFY, "in_progress"); await saveRunState(state); phaseLine(PHASE_VERIFY, "checking artifacts…"); // Verify is a lightweight self-check. A check may supply a dedicated // `verify` hook to confirm its artifacts were produced (e.g. // findings.md / changes.md exist). When absent, fall back to re-running // the gate — unchanged from the historical behaviour. const verifyResult = await Promise.resolve( check.verify ? check.verify(scope) : check.gate(cwd), ); if (verifyResult) { applyPhaseStatus(state, check.name, PHASE_VERIFY, "failed", verifyResult); markCheckStatus(state, check.name, "failed", verifyResult); markRunStatus(state, reconcileRunStatus(state)); await saveRunState(state); return { status: "failed", error: verifyResult, findings, changes, state, }; } phaseLine(PHASE_VERIFY, "✓ artifacts confirmed"); applyPhaseStatus(state, check.name, PHASE_VERIFY, "complete"); await saveRunState(state); footerComplete(PHASE_VERIFY); // --- Phase: cleanup -------------------------------------------------- strip.setPhase(PHASE_CLEANUP); footerEnter(PHASE_CLEANUP); applyPhaseStatus(state, check.name, PHASE_CLEANUP, "in_progress"); await saveRunState(state); phaseLine(PHASE_CLEANUP, "removing transient artifacts…"); await cleanupTransientArtifacts(cwd, check.name); phaseLine(PHASE_CLEANUP, "✓ done"); applyPhaseStatus(state, check.name, PHASE_CLEANUP, "complete"); footerComplete(PHASE_CLEANUP); markCheckStatus(state, check.name, "complete"); markRunStatus(state, reconcileRunStatus(state)); await saveRunState(state); return { status: "complete", findings, changes, state }; } catch (err) { error = err instanceof Error ? err.message : String(err); markCheckStatus(state, check.name, "failed", error); markRunStatus(state, reconcileRunStatus(state)); try { await saveRunState(state); } catch (saveErr) { // A failing state save inside the error path must not mask the // original error or escape as an unhandled rejection (which would // kill the run with nothing persisted or reported). error += `; (also failed to persist run-state: ${ saveErr instanceof Error ? saveErr.message : String(saveErr) })`; } return { status: "failed", error, findings, changes, state }; } finally { strip.done(); footer.done(); } } /** Map a run-state `PhaseStatus` to a completion-log status. */ function phaseLogStatus(status: string | undefined): PhaseLogStatus { switch (status) { case "complete": return "complete"; case "failed": return "failed"; case "skipped": return "skipped"; default: return "running"; } } /** Glyph for a check's terminal status. */ function statusGlyph(status: CheckRunOutcome["status"]): string { switch (status) { case "complete": return "✓"; case "failed": return "✗"; default: return "-"; } } /** Count non-empty lines in captured findings/changes text. */ function lineCount(text: string | undefined): number { if (!text) return 0; return text.split(/\r?\n/).filter((l) => l.trim().length > 0).length; } /** Format a duration in milliseconds as `1m 2s` / `5s` / `320ms`. */ function formatDuration(ms: number): string { const s = Math.floor(ms / 1000); if (s < 1) return `${ms}ms`; if (s < 60) return `${s}s`; const m = Math.floor(s / 60); const rem = s % 60; return rem ? `${m}m ${rem}s` : `${m}m`; } /** Build the expandable phase tree carried in the completion message. */ function buildPhaseLog( state: RunState, checkName: string, findings?: string, changes?: string, ): PhaseLogEntry[] { const phases = state.checks[checkName]?.phases ?? []; return phases.map((p) => { const entry: PhaseLogEntry = { id: p.id, label: PHASE_LABELS[p.id] ?? p.id, status: phaseLogStatus(p.status), }; if (p.id === PHASE_ANALYSIS && findings) { entry.note = `findings: ${lineCount(findings)} lines`; } else if (p.id === PHASE_FIX && changes) { entry.note = `changes: ${lineCount(changes)} lines`; } else if (p.error) { entry.note = p.error; } return entry; }); } /** * Post a single ralpi-style completion message (header + phase tree) into the * chat via `sendChatMessage`. No-op when no callback is wired (print/json * modes). Mirrors ralpi's per-loop completion message. */ function postCheckCompletion( opts: RunCheckOptions, outcome: CheckRunOutcome, durationMs: number, ): void { const send = opts.sendChatMessage; if (!send) return; const check = opts.check; const status = outcome.status; const glyph = statusGlyph(status); const fix = Boolean( (opts.scope ?? parseCheckArgs(opts.rawArgs ?? "", opts.cwd)).fix, ); const fixTag = fix ? " --fix" : ""; const header = `${glyph} pygienium ${check.label}${fixTag} · ${status} (${formatDuration(durationMs)})`; const details: CheckCompletionDetails = { checkLabel: check.label, status, fix, durationMs, phases: buildPhaseLog( outcome.state, check.name, outcome.findings, outcome.changes, ), error: outcome.error, }; send(header, { phase: "complete", completion: details }); } /** * Remove transient per-check scratch artifacts (e.g. agent-extracted * manifests) written under `/.pygienium/-tmp-*`. Findings and * changes are kept in run-state, not these scratch files, so removing them is * safe. */ async function cleanupTransientArtifacts( cwd: string, checkName: string, ): Promise { const dir = stateDir(cwd); // Best-effort: remove any `*-tmp-` entries created by agents. const { readdir } = await import("node:fs/promises"); let entries: string[]; try { entries = await readdir(dir); } catch { return; } for (const entry of entries) { if (entry.includes(`-tmp-${checkName}`)) { await rm(join(dir, entry), { recursive: true, force: true }).catch( () => {}, ); } } }