initial import: @mikefreno/omp-pygenium (omp port)
This commit is contained in:
0
src/modes/.gitkeep
Normal file
0
src/modes/.gitkeep
Normal file
463
src/modes/all.ts
Normal file
463
src/modes/all.ts
Normal file
@@ -0,0 +1,463 @@
|
||||
/**
|
||||
* modes/all.ts — `/pygienium-all` master orchestrator.
|
||||
*
|
||||
* Runs every registered check in sequence as ordered phases under a unified
|
||||
* status strip, with resumable state and a final summary report. This is the
|
||||
* piolium "balanced"/"deep" mode analogue, but simpler: one run, sequential
|
||||
* phases, shared recon (no scheduler — checks run one after another).
|
||||
*
|
||||
* Pipeline:
|
||||
* init single run (mode "all") → run shared recon once →
|
||||
* for each registered check (in registry order): call `runCheck` with the
|
||||
* SHARED run-state record (not a fresh one per check) → reconcile run
|
||||
* status → write `.pygienium/all-summary.md`.
|
||||
*
|
||||
* Resumability: terminal checks (`complete`/`skipped`) are skipped on resume;
|
||||
* `in_progress`/`failed`/`pending` checks re-run. `--fresh` resets every check
|
||||
* entry and re-runs the lot. `--only=comments,complexity` narrows the candidate
|
||||
* set to a named subset (registration order preserved).
|
||||
*
|
||||
* @module pygienium/modes/all
|
||||
*/
|
||||
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import type { ExtensionUIContext } from "@oh-my-pi/pi-coding-agent";
|
||||
import { getAllChecks, type CheckDefinition } from "../checks/registry.js";
|
||||
import { runCheck } from "./check-runner.js";
|
||||
import type { AgentSessionEvent } from "@oh-my-pi/pi-coding-agent";
|
||||
import { createPhaseStrip, type SendChatMessage } from "../phases.js";
|
||||
import { createPipelineFooter, type ItemStatus } from "../footer.js";
|
||||
import { runRecon } from "../recon.js";
|
||||
import {
|
||||
applyPhaseStatus,
|
||||
ensureRunStateIgnored,
|
||||
initRunState,
|
||||
loadRunState,
|
||||
markRunStatus,
|
||||
PHASE_RECON,
|
||||
reconcileRunStatus,
|
||||
resetCheckEntry,
|
||||
saveRunState,
|
||||
shouldRunOnResume,
|
||||
stateDir,
|
||||
type RunState,
|
||||
} from "../run-state.js";
|
||||
|
||||
/** Artifact directory name (relative to cwd) that holds `all-summary.md`. */
|
||||
export const ALL_ARTIFACT_DIR = ".pygienium";
|
||||
/** Filename for the unified per-check summary report. */
|
||||
export const ALL_SUMMARY_FILENAME = "all-summary.md";
|
||||
|
||||
/** Resolve `<cwd>/.pygienium/all-summary.md`. */
|
||||
export function allSummaryPath(cwd: string): string {
|
||||
return join(cwd, ALL_ARTIFACT_DIR, ALL_SUMMARY_FILENAME);
|
||||
}
|
||||
|
||||
export interface AllRunOptions {
|
||||
/** Working directory (from `ctx.cwd`). */
|
||||
cwd: string;
|
||||
/** Target path to scan (absolute; defaults to `cwd`). */
|
||||
target?: string;
|
||||
/** Whether fixes should be applied (`--fix`). */
|
||||
fix?: boolean;
|
||||
/** Subset of check names to run (`--only=comments,complexity`). */
|
||||
only?: string[];
|
||||
/** Reset and re-run every check, ignoring prior terminal state (`--fresh`). */
|
||||
fresh?: boolean;
|
||||
/**
|
||||
* Ensure `<cwd>/.gitignore` excludes `.pygienium/` before this run writes
|
||||
* state/artifacts (default true). Set false with `--no-gitignore`.
|
||||
*/
|
||||
gitignore?: boolean;
|
||||
/** UI context (optional; null in print mode). */
|
||||
ui?: ExtensionUIContext;
|
||||
/** Whether dialog-capable UI is available. */
|
||||
hasUI?: boolean;
|
||||
/** Optional callback to post completion messages into the chat. */
|
||||
sendChatMessage?: SendChatMessage;
|
||||
/** Optional callback forwarding raw sub-agent events to the chat stream. */
|
||||
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;
|
||||
}
|
||||
|
||||
/** Outcome of {@link runAllChecks}. */
|
||||
export interface AllRunOutcome {
|
||||
/** Final run status. */
|
||||
status: RunState["status"];
|
||||
/** The updated run state. */
|
||||
state: RunState;
|
||||
/** Absolute path the summary was written to. */
|
||||
summaryPath: string;
|
||||
/** Checks that were actually dispatched (ran `runCheck`). */
|
||||
ran: string[];
|
||||
/** Checks skipped because they were already terminal. */
|
||||
skipped: string[];
|
||||
/** True when this run appended `.pygienium/` to the repo's .gitignore. */
|
||||
gitignoreAppended?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter the registry to the `only` subset, preserving insertion order.
|
||||
* Unknown names are silently dropped (a typo shouldn't abort an all-run).
|
||||
*/
|
||||
export function selectChecks(only?: string[]): CheckDefinition[] {
|
||||
const all = getAllChecks();
|
||||
if (!only || only.length === 0) return all;
|
||||
const set = new Set(only);
|
||||
return all.filter((c) => set.has(c.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `/pygienium-all` args: an optional `[path]` positional plus the
|
||||
* `--fix`, `--fresh`, and `--only=<a>,<b>` flags.
|
||||
*/
|
||||
export function parseAllArgs(
|
||||
args: string,
|
||||
cwd: string,
|
||||
): {
|
||||
target: string;
|
||||
fix: boolean;
|
||||
fresh: boolean;
|
||||
gitignore: boolean;
|
||||
only: string[];
|
||||
} {
|
||||
const tokens = args.trim().length > 0 ? args.trim().split(/\s+/) : [];
|
||||
let fix = false;
|
||||
let fresh = false;
|
||||
let gitignore = true;
|
||||
let target = cwd;
|
||||
const only: string[] = [];
|
||||
for (const tok of tokens) {
|
||||
if (tok === "--fix") {
|
||||
fix = true;
|
||||
} else if (tok === "--fresh") {
|
||||
fresh = true;
|
||||
} else if (tok === "--no-gitignore") {
|
||||
gitignore = false;
|
||||
} else if (tok.startsWith("--only=")) {
|
||||
for (const name of tok.slice("--only=".length).split(",")) {
|
||||
const trimmed = name.trim();
|
||||
if (trimmed) only.push(trimmed);
|
||||
}
|
||||
} else if (!tok.startsWith("--")) {
|
||||
target = tok;
|
||||
}
|
||||
}
|
||||
return { target: resolve(cwd, target), fix, fresh, gitignore, only };
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
function toISO(ms: number | undefined): string {
|
||||
return ms == null ? "—" : new Date(ms).toISOString();
|
||||
}
|
||||
|
||||
function short(status: string): string {
|
||||
return status[0]?.toUpperCase() ?? "?";
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the unified summary markdown from run-state. Lists per-check
|
||||
* outcomes: status, artifact paths (when present on disk), findings/changes
|
||||
* line counts, phase breakdown, and errors.
|
||||
*/
|
||||
export function renderAllSummary(
|
||||
state: RunState,
|
||||
selected: CheckDefinition[],
|
||||
): string {
|
||||
const lines: string[] = [];
|
||||
lines.push("# Pygienium all-run summary");
|
||||
lines.push("");
|
||||
lines.push(`- status: ${state.status}`);
|
||||
lines.push(`- started: ${toISO(state.startedAt)}`);
|
||||
lines.push(`- updated: ${toISO(state.updatedAt)}`);
|
||||
lines.push(`- cwd: ${state.cwd}`);
|
||||
lines.push(`- recon: ${state.recon.complete ? "complete" : "pending"}`);
|
||||
lines.push(`- checks: ${selected.length}`);
|
||||
lines.push("");
|
||||
|
||||
for (const check of selected) {
|
||||
const entry = state.checks[check.name];
|
||||
const fixTag = entry?.fix ? " (--fix)" : "";
|
||||
lines.push(`## ${check.name} — ${entry?.status ?? "pending"}${fixTag}`);
|
||||
lines.push("");
|
||||
|
||||
// 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: `.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) {
|
||||
lines.push(`- findings: ${findingsPath} (${fLines} line(s))`);
|
||||
}
|
||||
if (cLines > 0) {
|
||||
lines.push(`- changes: ${changesPath} (${cLines} line(s))`);
|
||||
}
|
||||
|
||||
// Phase breakdown for transparency.
|
||||
if (entry?.phases?.length) {
|
||||
const phaseSummary = entry.phases
|
||||
.map(
|
||||
(p) =>
|
||||
`${p.id}:${
|
||||
p.status.startsWith("in_progress") ? "…" : short(p.status)
|
||||
}`,
|
||||
)
|
||||
.join(" ");
|
||||
lines.push(`- phases: ${phaseSummary}`);
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Run every registered check in sequence under a unified status strip.
|
||||
*
|
||||
* Resumable: terminal checks skip on resume unless `fresh` resets them.
|
||||
*/
|
||||
export async function runAllChecks(
|
||||
opts: AllRunOptions,
|
||||
): Promise<AllRunOutcome> {
|
||||
const { cwd } = opts;
|
||||
const target = opts.target ?? cwd;
|
||||
const fix = opts.fix ?? false;
|
||||
const fresh = opts.fresh ?? false;
|
||||
const hasUI = opts.hasUI ?? false;
|
||||
|
||||
// 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(cwd);
|
||||
|
||||
const selected = selectChecks(opts.only);
|
||||
if (selected.length === 0) {
|
||||
// `--only` selected nothing (or no checks registered). Still produce a
|
||||
// summary so the caller has an artifact.
|
||||
const state = (await loadRunState(cwd)) ?? initRunState(cwd, []);
|
||||
markRunStatus(state, reconcileRunStatus(state));
|
||||
await saveRunState(state);
|
||||
const summaryPath = await writeAllSummary(state, []);
|
||||
return {
|
||||
status: state.status,
|
||||
state,
|
||||
summaryPath,
|
||||
ran: [],
|
||||
skipped: [],
|
||||
gitignoreAppended,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Init / resume the single shared run-state --------------------------
|
||||
let state =
|
||||
(await loadRunState(cwd)) ??
|
||||
initRunState(
|
||||
cwd,
|
||||
selected.map((c) => ({ name: c.name, label: c.label, fix })),
|
||||
);
|
||||
|
||||
// Ensure every selected check has an entry (adds any missing on resume).
|
||||
for (const check of selected) {
|
||||
if (!state.checks[check.name]) {
|
||||
state.checks[check.name] = {
|
||||
name: check.name,
|
||||
label: check.label,
|
||||
status: "pending",
|
||||
fix,
|
||||
phases: [
|
||||
{ id: "recon", status: "pending" },
|
||||
{ id: "analysis", status: "pending" },
|
||||
...(fix ? [{ id: "fix", status: "pending" as const }] : []),
|
||||
{ id: "verify", status: "pending" },
|
||||
{ id: "cleanup", status: "pending" },
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
await saveRunState(state);
|
||||
|
||||
// --- Unified phase strip logging all check names -----------------------
|
||||
const strip = createPhaseStrip({
|
||||
ui: opts.ui,
|
||||
hasUI,
|
||||
});
|
||||
setAllPhase(strip, selected, 0, "recon");
|
||||
|
||||
// Pipeline-overview footer: a multi-line widget listing every check with the
|
||||
// cursor on the active one and what's to come. Detail lives in the chat
|
||||
// (per-check completion trees); the footer is the overview. Inner
|
||||
// `runCheck` calls pass `footer: false` so two overviews never compete.
|
||||
const allFooter = createPipelineFooter({
|
||||
ui: opts.ui,
|
||||
hasUI,
|
||||
statusKey: "pygienium-all",
|
||||
});
|
||||
const allItems = selected.map((c) => ({
|
||||
label: c.label,
|
||||
status: "pending" as ItemStatus,
|
||||
}));
|
||||
allFooter.setPipeline("pygienium: all", allItems, 0);
|
||||
const footerSettle = (name: string, status: ItemStatus): void => {
|
||||
const idx = selected.findIndex((c) => c.name === name);
|
||||
if (idx >= 0) allFooter.setItem(idx, status);
|
||||
};
|
||||
|
||||
// --- Shared recon (run once before any check) ---------------------------
|
||||
if (!state.recon.complete) {
|
||||
const snapshot = await runRecon(cwd);
|
||||
state.recon = {
|
||||
complete: true,
|
||||
path: join(stateDir(cwd), "recon.json"),
|
||||
finishedAt: snapshot.createdAt,
|
||||
};
|
||||
// Mark recon complete for every selected check that hasn't run it yet.
|
||||
for (const check of selected) {
|
||||
applyPhaseStatus(state, check.name, PHASE_RECON, "complete");
|
||||
}
|
||||
await saveRunState(state);
|
||||
}
|
||||
|
||||
const ran: string[] = [];
|
||||
const skipped: string[] = [];
|
||||
|
||||
// --- Per-check loop (shared run-state, registry order) ------------------
|
||||
for (let i = 0; i < selected.length; i++) {
|
||||
const check = selected[i]!;
|
||||
setAllPhase(strip, selected, i, "analysis");
|
||||
allFooter.setCursor(i);
|
||||
|
||||
const entry = state.checks[check.name];
|
||||
// Resumability: skip terminal checks unless --fresh.
|
||||
if (entry && !shouldRunOnResume(entry, fresh)) {
|
||||
skipped.push(check.name);
|
||||
footerSettle(
|
||||
check.name,
|
||||
entry.status === "complete" ? "skipped" : "skipped",
|
||||
);
|
||||
strip.log(
|
||||
`pygienium: ${check.label} — already ${entry.status}, skipping`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (fresh && entry) {
|
||||
resetCheckEntry(state, check.name, fix);
|
||||
await saveRunState(state);
|
||||
}
|
||||
|
||||
strip.log(`pygienium: running ${check.label}…`);
|
||||
const outcome = await runCheck({
|
||||
check,
|
||||
cwd,
|
||||
scope: { cwd, target, fix, rest: [] },
|
||||
ui: opts.ui,
|
||||
hasUI,
|
||||
existingState: state,
|
||||
sendChatMessage: opts.sendChatMessage,
|
||||
onAgentEvent: opts.onAgentEvent,
|
||||
sendPhaseLine: opts.sendPhaseLine,
|
||||
// The all-run footer already owns the pipeline-overview widget
|
||||
// slot; suppress the per-check footer so two overviews never
|
||||
// compete over the same `belowEditor` area.
|
||||
footer: false,
|
||||
// Inner runs must not prime the .gitignore twice — the outer all-run
|
||||
// already ensured it. (ensureRunStateIgnored is memoized per cwd,
|
||||
// so this is belt-and-braces.)
|
||||
gitignore: opts.gitignore,
|
||||
});
|
||||
state = outcome.state;
|
||||
ran.push(check.name);
|
||||
footerSettle(
|
||||
check.name,
|
||||
outcome.status === "complete"
|
||||
? "complete"
|
||||
: outcome.status === "skipped"
|
||||
? "skipped"
|
||||
: "failed",
|
||||
);
|
||||
strip.log(`pygienium ${check.label}: ${outcome.status}`);
|
||||
}
|
||||
|
||||
// --- Finalize -----------------------------------------------------------
|
||||
markRunStatus(state, reconcileRunStatus(state));
|
||||
await saveRunState(state);
|
||||
|
||||
setAllPhase(strip, selected, selected.length - 1, "cleanup");
|
||||
// Mark the final check's footer status terminal; the cursor started at 0
|
||||
// and the loop advanced it, so the last selected item is the live one.
|
||||
if (selected.length > 0) {
|
||||
footerSettle(
|
||||
selected[selected.length - 1]!.name,
|
||||
state.checks[selected[selected.length - 1]!.name]?.status === "failed"
|
||||
? "failed"
|
||||
: "complete",
|
||||
);
|
||||
}
|
||||
strip.done();
|
||||
allFooter.done();
|
||||
|
||||
const summaryPath = await writeAllSummary(state, selected);
|
||||
|
||||
return {
|
||||
status: state.status,
|
||||
state,
|
||||
summaryPath,
|
||||
ran,
|
||||
skipped,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the unified strip to reflect which check is active and its phase.
|
||||
* Renders `pygienium: all [i/N] <check-label>: <phase>` so every check name is
|
||||
* surfaced in the strip over the course of the run.
|
||||
*/
|
||||
function setAllPhase(
|
||||
strip: ReturnType<typeof createPhaseStrip>,
|
||||
selected: CheckDefinition[],
|
||||
index: number,
|
||||
phaseId: string,
|
||||
): void {
|
||||
const check = selected[index];
|
||||
const label = check?.label ?? "(none)";
|
||||
const total = selected.length;
|
||||
const pos = String(index + 1);
|
||||
const phaseLabel = PHASE_ALL_LABELS[phaseId] ?? phaseId;
|
||||
strip.setPhase(`all [${pos}/${total}] ${label}: ${phaseLabel}`);
|
||||
}
|
||||
|
||||
const PHASE_ALL_LABELS: Record<string, string> = {
|
||||
recon: "Recon",
|
||||
analysis: "Scanning",
|
||||
fix: "Fixing",
|
||||
verify: "Verifying",
|
||||
cleanup: "Done",
|
||||
};
|
||||
|
||||
/** Write the all-summary.md report, creating the directory as needed. */
|
||||
async function writeAllSummary(
|
||||
state: RunState,
|
||||
selected: CheckDefinition[],
|
||||
): Promise<string> {
|
||||
const path = allSummaryPath(state.cwd);
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await writeFile(path, renderAllSummary(state, selected), "utf8");
|
||||
return path;
|
||||
}
|
||||
525
src/modes/check-runner.ts
Normal file
525
src/modes/check-runner.ts
Normal file
@@ -0,0 +1,525 @@
|
||||
/**
|
||||
* 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 `<cwd>/.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<CheckRunOutcome> {
|
||||
const startMs = Date.now();
|
||||
const outcome = await runCheckImpl(opts);
|
||||
postCheckCompletion(opts, outcome, Date.now() - startMs);
|
||||
return outcome;
|
||||
}
|
||||
|
||||
async function runCheckImpl(opts: RunCheckOptions): Promise<CheckRunOutcome> {
|
||||
// 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<CheckRunOutcome> {
|
||||
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));
|
||||
await saveRunState(state);
|
||||
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 `<cwd>/.pygienium/<check>-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<void> {
|
||||
const dir = stateDir(cwd);
|
||||
// Best-effort: remove any `*-tmp-<check>` 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(
|
||||
() => {},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user