Initial commit: pygenium as git submodule

This commit is contained in:
2026-08-07 14:54:45 -04:00
commit 581436ed23
61 changed files with 9331 additions and 0 deletions

181
src/agent-runner.ts Normal file
View File

@@ -0,0 +1,181 @@
/**
* agent-runner.ts — spawn isolated sub-agents for analysis and fix phases.
*
* The production runner uses pi's `createAgentSession` SDK to spin up a fresh
* in-memory agent session scoped to the target `cwd`, with the agent
* definition's system prompt and tool allowlist applied. Because that path
* needs live model credentials (unsuitable for CI), the runner is backed by an
* injectable factory: tests swap it for a deterministic fake that executes a
* tiny instruction protocol embedded in the task string.
*
* Instruction protocol (used by the fake runner, harmless to the real one):
* task lines may begin with `!write <path> <text>` — the fake writes the file
* and reports it as a finding. Real sub-agents receive the whole task verbatim.
*
* @module pygienium/agent-runner
*/
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, isAbsolute, join } from "node:path";
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
import { loadAgents, type AgentDef } from "./agents.js";
export interface AgentTaskOptions {
/** Absolute working directory for the sub-agent. */
cwd: string;
/** Agent name to look up in `agents/*.md`. */
agentName: string;
/** The task prompt handed to the sub-agent. */
task: string;
/** Optional tool allowlist override (else uses the agent's `allowedTools`). */
allowedTools?: string[];
/** Optional explicit agent definition (skips `loadAgents`). */
agent?: AgentDef;
}
export interface AgentRunResult {
/** Whether the sub-agent completed without throwing. */
ok: boolean;
/** The final assistant text emitted by the sub-agent. */
text: string;
/** Error message when `ok` is false. */
error?: string;
}
export type AgentRunner = (opts: AgentTaskOptions) => Promise<AgentRunResult>;
/** Module-level runner (defaults to the SDK-backed runner; tests override it). */
let currentRunner: AgentRunner = defaultAgentRunner;
/** Entry point used by the check-runner. */
export function runAgentTask(opts: AgentTaskOptions): Promise<AgentRunResult> {
return currentRunner(opts);
}
/** Override the active agent runner (primarily for tests). */
export function setAgentRunner(runner: AgentRunner): void {
currentRunner = runner;
}
/** Restore the default SDK-backed agent runner. */
export function resetAgentRunner(): void {
currentRunner = defaultAgentRunner;
}
/**
* Real sub-agent runner: spins up an in-memory `AgentSession` scoped to `cwd`,
* overrides the system prompt with the agent definition's body, restricts tools
* to the agent's allowlist, and runs the task to completion.
*/
export async function defaultAgentRunner(
opts: AgentTaskOptions,
): Promise<AgentRunResult> {
const agent =
opts.agent ?? (await loadAgents({ cwd: opts.cwd })).get(opts.agentName);
if (!agent) {
return {
ok: false,
text: "",
error: `Unknown agent definition: "${opts.agentName}". Add agents/${opts.agentName}.md.`,
};
}
// Lazily import the SDK so the rest of the module graph (and tests using the
// fake runner) never resolve the heavy pi-coding-agent package.
const {
createAgentSession,
DefaultResourceLoader,
getAgentDir,
SessionManager,
} = await import("@earendil-works/pi-coding-agent");
const loader = new DefaultResourceLoader({
cwd: opts.cwd,
agentDir: getAgentDir(),
systemPromptOverride: () => agent.systemPrompt,
// Keep the sub-agent isolated: no nested extensions/skills/themes/etc.
noExtensions: true,
noSkills: true,
noThemes: true,
noPromptTemplates: true,
});
await loader.reload();
const tools = opts.allowedTools ??
agent.allowedTools ?? ["read", "bash", "grep", "find"];
const { session } = await createAgentSession({
cwd: opts.cwd,
tools,
sessionManager: SessionManager.inMemory(opts.cwd),
resourceLoader: loader,
});
try {
let text = "";
const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
if (
event.type === "message_update" &&
event.assistantMessageEvent.type === "text_delta"
) {
text += event.assistantMessageEvent.delta;
}
});
await session.prompt(opts.task, { expandPromptTemplates: false });
unsubscribe();
return { ok: true, text };
} catch (err) {
return {
ok: false,
text: "",
error: err instanceof Error ? err.message : String(err),
};
} finally {
try {
session.dispose();
} catch {
/* ignore dispose errors */
}
}
}
/**
* Fake agent runner for tests: it understands a tiny instruction protocol
* embedded in the task so a no-op check can produce deterministic findings
* and write marker files without a model. Recognised instructions (one per
* line, leading-whitespace tolerant):
*
* !write <path> <text...> — write text to path (relative to cwd); recorded
* !echo <text...> — appended to findings
*
* The agent's emitted findings text is the collected `!echo`/`!write` lines.
*/
export const fakeAgentRunner: AgentRunner = async (opts) => {
const lines = opts.task.split(/\r?\n/);
const findings: string[] = [];
try {
for (const line of lines) {
const trimmed = line.trim();
const write = /^!write\s+(\S+)\s*(.*)$/.exec(trimmed);
if (write) {
const rel = write[1] as string;
const content = (write[2] ?? "").replace(/^["']|["']$/g, "");
const full = isAbsolute(rel) ? rel : join(opts.cwd, rel);
await mkdir(dirname(full), { recursive: true });
await writeFile(full, content + "\n", "utf8");
findings.push(`wrote ${rel}`);
continue;
}
const echo = /^!echo\s+(.*)$/.exec(trimmed);
if (echo) {
findings.push((echo[1] ?? "").replace(/^["']|["']$/g, ""));
}
}
return { ok: true, text: findings.join("\n") };
} catch (err) {
return {
ok: false,
text: findings.join("\n"),
error: err instanceof Error ? err.message : String(err),
};
}
};

150
src/agents.ts Normal file
View File

@@ -0,0 +1,150 @@
/**
* agents.ts — markdown agent-definition loader.
*
* Reads agent definitions from `agents/*.md` shipped with the extension so the
* analysis and fix roles are plain editable markdown — no TypeScript changes
* needed to tune a sub-agent's behaviour. Each `.md` file uses a YAML
* frontmatter block to declare its `name` and `allowedTools`; the body becomes
* the agent's system prompt.
*
* File shape:
*
* ---
* name: scanner
* allowedTools:
* - read
* - grep
* - find
* ---
* You are a code-hygiene scanner …
*
* @module pygienium/agents
*/
import { readFile, readdir } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
/** A loaded agent definition. */
export interface AgentDef {
/** Unique agent name (matches `agentName` on a `CheckDefinition`). */
name: string;
/** The markdown body, used verbatim as the sub-agent system prompt. */
systemPrompt: string;
/** Tool names the sub-agent may use (`read`, `bash`, …), or undefined to inherit defaults. */
allowedTools?: string[];
/** Absolute path to the source `.md` file. */
sourcePath: string;
}
/** Resolve the extension root (the directory holding `package.json` and `agents/`). */
export function extensionRoot(): string {
// src/agents.ts → ../ = extension root.
const here = dirname(fileURLToPath(import.meta.url));
return resolve(here, "..");
}
/** Parse a YAML-ish frontmatter block from markdown. Only the keys we use. */
function parseFrontmatter(raw: string): {
frontmatter: Record<string, unknown>;
body: string;
} {
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/.exec(raw);
if (!match) return { frontmatter: {}, body: raw };
const fmText = match[1] ?? "";
const body = match[2] ?? "";
const frontmatter: Record<string, unknown> = {};
const lines = fmText.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
const line = lines[i] ?? "";
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
// YAML block-list: a key with an empty value followed by "- item" lines.
const blockList = /^([A-Za-z_][A-Za-z0-9_-]*):\s*$/.exec(trimmed);
if (blockList) {
const key = blockList[1] as string;
const items: string[] = [];
let j = i + 1;
for (; j < lines.length; j++) {
const item = /^\s+-\s+(.*)$/.exec(lines[j] ?? "");
if (!item) break;
items.push((item[1] ?? "").trim().replace(/^["']|["']$/g, ""));
}
if (items.length > 0) {
frontmatter[key] = items;
i = j - 1;
}
continue;
}
// Inline key/value (also handles inline lists like key: [a, b]).
const idx = trimmed.indexOf(":");
if (idx === -1) continue;
const key = trimmed.slice(0, idx).trim();
const value = trimmed.slice(idx + 1).trim();
if (value.startsWith("[") && value.endsWith("]")) {
const inner = value.slice(1, -1);
const valueList: string[] = [];
for (const part of inner.split(",")) {
const v = part.trim().replace(/^["']|["']$/g, "");
if (v) valueList.push(v);
}
frontmatter[key] = valueList;
} else {
frontmatter[key] = value.replace(/^["']|["']$/g, "");
}
}
return { frontmatter, body: body.trim() + "\n" };
}
function asStringList(value: unknown): string[] | undefined {
if (value == null) return undefined;
if (Array.isArray(value)) return value.map((v) => String(v)).filter(Boolean);
if (typeof value === "string") {
return value
.split(",")
.map((v) => v.trim())
.filter(Boolean);
}
return undefined;
}
function asString(value: unknown): string | undefined {
if (value == null) return undefined;
if (typeof value === "string") return value;
return String(value);
}
/**
* Load every `agents/*.md` definition from the extension's `agents/` dir.
* Returns a map keyed by agent name. Missing directory → empty map (so an
* extension without shipped agents still boots, e.g. in tests).
*/
export async function loadAgents(_opts?: {
cwd?: string;
}): Promise<Map<string, AgentDef>> {
const dir = join(extensionRoot(), "agents");
const result = new Map<string, AgentDef>();
let entries: string[];
try {
entries = await readdir(dir);
} catch {
return result;
}
for (const entry of entries) {
if (!entry.endsWith(".md")) continue;
const sourcePath = join(dir, entry);
const raw = await readFile(sourcePath, "utf8");
const { frontmatter, body } = parseFrontmatter(raw);
const name = asString(frontmatter.name) ?? entry.slice(0, -".md".length);
const allowedTools = asStringList(frontmatter.allowedTools);
result.set(name, {
name,
systemPrompt: body,
allowedTools,
sourcePath,
});
}
return result;
}

0
src/checks/.gitkeep Normal file
View File

231
src/checks/comments.ts Normal file
View File

@@ -0,0 +1,231 @@
/**
* checks/comments.ts — comments hygiene check (first end-to-end reference).
*
* This is the canonical `CheckDefinition` that future checks (08+) copy. It
* reuses the generic `scanner`/`fixer` agents shipped in `agents/*.md`; the
* check-specific rubric is embedded in the task text handed to the sub-agent
* (see {@link buildCommentsScanTask} / {@link buildCommentsFixTask}) so no
* per-check agent `.md` file is required.
*
* Rubric (the user's spec — short + high value):
* - comments that restate the code they sit on ("what" comments) → REMOVE
* - verbose narration / long-winded explanations → TIGHTEN (shorten)
* - "why" comments that explain intent, rationale, or gotchas → KEEP
* - code self-explanatory with no comment → no comment needed (don't add one)
*
* Artifacts (under `<cwd>/pygienium/checks/comments/`):
* - `findings.md` — per-file line refs for each smell
* - `changes.md` — summary of edits + human-review items
*
* @module pygienium/checks/comments
*/
import { registerCheck, type CheckScope } from "./registry.js";
/** Phase-strip phase this check belongs to. */
export const COMMENTS_PHASE_ID = "C1";
/**
* Directory where this check writes its `findings.md` and `changes.md`
* artifacts: `<cwd>/pygienium/checks/comments/`. Based on `scope.cwd` (the
* project root, always a directory) so the path is valid whether the scan
* target is a single file or a directory. Matches the spec's
* `pygienium/checks/comments/findings.md` relative-path notation.
*/
export function commentsArtifactDir(scope: CheckScope): string {
const base = scope.cwd.replace(/\/+$/, "");
return `${base}/pygienium/checks/comments`;
}
/** Absolute path to the findings artifact for this check. */
export function findingsPath(scope: CheckScope): string {
return `${commentsArtifactDir(scope)}/findings.md`;
}
/** Absolute path to the changes artifact for this check. */
export function changesPath(scope: CheckScope): string {
return `${commentsArtifactDir(scope)}/changes.md`;
}
/**
* Shared rubric block, injected into both scan and fix task text so the analysis
* and remediation sub-agents apply identical judgement.
*/
const RUBRIC = `# Comments hygiene rubric
Short + high value is the goal. Evaluate every comment in the target:
- **RESTATE → REMOVE.** A comment that paraphrases the line(s) it sits on adds
no information. Examples: \`// increment i\` over \`i++\`, \`// return the
result\` over \`return result\`. Delete it.
- **VERBOSE → TIGHTEN.** A comment that is high-value but needlessly long.
Rewrite it to one tight sentence preserving the key insight. Do not delete.
- **"WHY" → KEEP.** A comment explaining intent, rationale, a non-obvious
decision, a workaround, a gotcha, or a constraint the code cannot express.
Leave it untouched (tighten only if it is also verbose).
- **NO COMMENT NEEDED.** When the code is self-explanatory, do not add a comment.
- Keep inline section headers/dividers that aid navigation only if they mark a
real boundary; remove pure decoration.`;
/**
* Build the analysis sub-agent task. Instructs the agent to read candidate
* source files, identify comment smells per the rubric, and write per-file line
* references to `pygienium/comments/findings.md`.
*/
export function buildCommentsScanTask(_cwd: string, scope: CheckScope): string {
const outDir = commentsArtifactDir(scope);
const findingsFile = findingsPath(scope);
return `# Task: comments hygiene scan
You are running the **comments** hygiene check.
## Target
- Scan target: \`${scope.target}\`
## What to do
1. Read the recon snapshot at \`${scope.cwd}/.pygienium/recon.json\` if it
exists; otherwise enumerate source files directly under the target.
2. For each source file, read it and locate every comment (inline \`//\`,
block \`/* */\`, doc \`/** */\`, \`#\` for scripting languages, etc.).
3. Apply the rubric below to each comment and classify it: RESTATE, VERBOSE,
WHY, or OK.
4. Write a findings report to \`${findingsFile}\` with per-file line refs.
5. Return the findings report text as your final message (same content as the
file). The host captures it as the analysis-phase findings.
${RUBRIC}
## findings.md format
\`\`\`markdown
# comments — findings
<count> comment smell(s) across <files> file(s).
## <relative-file>
- L<line>: <smell: RESTATE|VERBOSE> — <quote or paraphrase>
- L<line>: KEEP (why) — <one-line reason> # listed for transparency
\`\`\`
If no smells are found, write \`# comments — findings\n\n0 comment smell(s).\`
and return that text. Always create findings.md so the run has an artifact.
Write the report under \`${outDir}\` (create directories as needed).
`;
}
/**
* Build the fix sub-agent task from the scan findings. Instructs the agent to
* apply safe removals/tightenings, leave "why" comments, and write a summary
* of edits plus anything needing human review to `pygienium/comments/changes.md`.
*/
export function buildCommentsFixTask(
_cwd: string,
scope: CheckScope,
findings: string,
): string {
const outDir = commentsArtifactDir(scope);
const changesFile = changesPath(scope);
return `# Task: comments hygiene fix
You are running the **comments** hygiene fix phase.
## Target
- Fix target: \`${scope.target}\`
## Input: scan findings
${findings.trim().length > 0 ? findings : "(no findings text provided)"}
## What to do
1. For each RESTATE finding: remove the comment entirely.
2. For each VERBOSE finding: replace the comment with a tightened one-sentence
version that keeps the key insight.
3. For every WHY comment: leave it untouched (tighten only if it is also
verbose, preserving the rationale).
4. Do not change any code logic, formatting, or ordering — only comments.
5. Write a summary to \`${changesFile}\` and return it as your final message.
${RUBRIC}
## changes.md format
\`\`\`markdown
# comments — changes
<applied> edit(s) applied; <deferred> deferred for human review.
## Applied
- <relative-file>:<line> — <removed|tightened> comment (auto)
## Needs human review
- <relative-file>:<line> — <reason> (manual)
\`\`\`
If nothing needed changing, write
\`# comments — changes\n\n0 edit(s) applied.\` and return that text. Always
create changes.md so the run has an artifact. Write it under \`${outDir}\`.
`;
}
/**
* Precondition gate. Returns an error string when the comments check cannot
* proceed (target path missing or not a real file/directory), else
* `undefined`. Idempotent — passes identically before analysis and at verify.
*/
async function commentsGate(cwd: string): Promise<string | undefined> {
const { stat } = await import("node:fs/promises");
const { resolve } = await import("node:path");
const target = resolve(cwd);
try {
const s = await stat(target);
if (s.isDirectory() || s.isFile()) return undefined;
return `target is not a file or directory: ${target}`;
} catch {
return `target path does not exist: ${target}`;
}
}
/**
* 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 (the scan-phase-only run skips `changes.md` by design). Returns an
* error string to fail verify, or `undefined` to pass. Replaces the historical
* no-op verify (which only re-ran the existence gate) so the verify phase now
* genuinely asserts the run produced its report.
*/
async function commentsVerify(scope: CheckScope): Promise<string | undefined> {
const { stat } = await import("node:fs/promises");
const f = findingsPath(scope);
try {
await stat(f);
} catch {
return `comments verify: expected findings.md at ${f} after scan, none found.`;
}
if (scope.fix) {
const c = changesPath(scope);
try {
await stat(c);
} catch {
return `comments verify: expected changes.md at ${c} after --fix, none found.`;
}
}
return undefined;
}
/** The comments hygiene check definition. */
export const commentsCheck = {
name: "comments",
label: "Comments",
description:
'Remove low-value/restating comments, tighten verbose ones, keep "why" comments.',
agentName: "scanner",
fixAgentName: "fixer",
phaseId: COMMENTS_PHASE_ID,
buildScanTask: buildCommentsScanTask,
buildFixTask: buildCommentsFixTask,
gate: commentsGate,
verify: commentsVerify,
} as const;
// Self-register on import so index.ts auto-discovery picks it up.
registerCheck(commentsCheck);

287
src/checks/complexity.ts Normal file
View File

@@ -0,0 +1,287 @@
/**
* checks/complexity.ts — excessive complexity check.
*
* Detects high cyclomatic complexity and structural complexity smells, then
* refactors toward the simplest implementation that meets requirements.
*
* Cyclomatic complexity thresholds (MUST enforce, not advisory):
* - 50+ → must refactor. No exceptions.
* - 3549 → heavy skepticism. Only keep if critical path + justified.
* - <35 → not flagged on cyclomatic grounds (may still be flagged for other
* structural smells).
*
* Structural smells detected:
* - deep nesting (>3 levels)
* - speculative abstractions
* - premature config indirection
* - non-idiomatic patterns
* - over-engineered generics
* - unnecessary wrappers
*
* @module pygienium/checks/complexity
*/
import { registerCheck, type CheckScope } from "./registry.js";
/** Phase-strip phase this check belongs to. */
export const COMPLEXITY_PHASE_ID = "C4";
/**
* Artifact directory: `<cwd>/pygienium/checks/complexity/`.
*/
export function complexityArtifactDir(scope: CheckScope): string {
const base = scope.cwd.replace(/\/+$/, "");
return `${base}/pygienium/checks/complexity`;
}
/** Absolute path to findings artifact. */
export function findingsPath(scope: CheckScope): string {
return `${complexityArtifactDir(scope)}/findings.md`;
}
/** Absolute path to changes artifact. */
export function changesPath(scope: CheckScope): string {
return `${complexityArtifactDir(scope)}/changes.md`;
}
/**
* Shared rubric for complexity analysis, injected into both scan and fix tasks.
*/
const RUBRIC = `# Complexity hygiene rubric
## Cyclomatic complexity thresholds
Cyclomatic complexity counts the number of independent paths through a function.
Compute via language-native tools when available (lizard, radon, gocyclo), or
count decision points (if/else if/for/while/case/&&/||/catch) per function.
| Score | Action |
|-------|--------|
| 50+ | **MUST refactor.** No exceptions. Break the function into smaller pieces. |
| 3549 | **Heavy skepticism.** Only keep if this is a massively critical point along the main path AND the complexity genuinely must be here. Document justification in findings.md; otherwise refactor. |
| <35 | Not flagged on cyclomatic grounds (may still be flagged for other structural smells). |
## Structural complexity smells
- **Deep nesting (>3 levels).** Flatten with early returns, guard clauses, or extracting to named helpers.
- **Speculative abstractions.** Remove abstractions created "just in case" — no concrete use case yet.
- **Premature config indirection.** Remove configuration layers that add no value yet.
- **Non-idiomatic patterns.** Replace with common conventions for the language.
- **Over-engineered generics.** Simplify to concrete types when only one type is used.
- **Unnecessary wrappers.** Inline trivial wrappers that add no logic.
## Refactoring principles
1. **Simplest implementation.** Choose the simplest implementation that fully meets current requirements.
2. **No backward-compat baggage.** Remove obsolete paths rather than adding compatibility layers.
3. **Grow in layers.** Build on a product that already works; don't trade a working product for unfinished complexity.
4. **Use existing libraries.** Lean on well-maintained libraries when they reduce complexity or improve reliability.
5. **Long-term decisions.** Make architectural decisions for the long term, not stopgaps meant to be replaced later.
`;
/**
* Build the analysis sub-agent task. Instructs the agent to:
* 1. Compute cyclomatic complexity per function
* 2. Identify structural complexity smells
* 3. Write findings to pygienium/checks/complexity/findings.md
*/
export function buildComplexityScanTask(
_cwd: string,
scope: CheckScope,
): string {
const outDir = complexityArtifactDir(scope);
const findingsFile = findingsPath(scope);
return `# Task: excessive complexity scan
You are running the **complexity** hygiene check.
## Target
- Scan target: \`${scope.target}\`
## What to do
### 1. Compute cyclomatic complexity
For each source file in the target:
1. Read the recon snapshot at \`${scope.cwd}/.pygienium/recon.json\` if it
exists; otherwise enumerate source files directly under the target.
2. For each file, identify every function/method/class.
3. Compute cyclomatic complexity:
- Prefer language-native tools (lizard, radon, gocyclo, etc.) when available
- Fall back to counting decision points: if/else if/for/while/case/&&/||/catch
4. Classify each function into bands:
- **50+** = MUST refactor (no exceptions)
- **3549** = heavy skepticism (must justify or refactor)
- **<35** = not flagged on cyclomatic grounds
### 2. Identify structural complexity smells
For each file, identify:
- Deep nesting (>3 levels)
- Speculative abstractions
- Premature config indirection
- Non-idiomatic patterns
- Over-engineered generics
- Unnecessary wrappers
### 3. Write findings
Write a findings report to \`${findingsFile}\` with per-function scores and
structural smell locations. Include a proposed simpler form for every flagged
function.
${RUBRIC}
## findings.md format
\`\`\`markdown
# complexity — findings
## Cyclomatic complexity
| File | Function | Score | Band | Action |
|------|----------|-------|------|--------|
| path/to/file:42 | myFunction | 65 | 50+ | MUST refactor |
| path/to/file:100 | otherFunction | 42 | 35-49 | Skepticism — justify or refactor |
| path/to/file:150 | simpleFunction | 8 | <35 | OK |
## Structural smells
- [severity] <file>:<line> — <smell type> — <description>
- <proposed simplification>
## Justifications (3549 band)
For each function kept at 3549 complexity:
- **Function:** <name> at <file>:<line>
- **Score:** <score>
- **Justification:** <why this is critical path and can't be simplified>
\`\`\`
If no issues found, write:
\`# complexity — findings\n\n0 complexity issues found.\`
Always create findings.md so the run has an artifact.
`;
}
/**
* Build the fix sub-agent task from the scan findings. Instructs the agent to:
* 1. Split 50+ complexity functions
* 2. Refactor or justify 3549 functions
* 3. Apply safe refactors for structural smells
* 4. Write changes summary to pygienium/checks/complexity/changes.md
*/
export function buildComplexityFixTask(
_cwd: string,
scope: CheckScope,
findings: string,
): string {
const outDir = complexityArtifactDir(scope);
const changesFile = changesPath(scope);
return `# Task: excessive complexity fix
You are running the **complexity** hygiene fix phase.
## Target
- Fix target: \`${scope.target}\`
## Input: scan findings
${findings.trim().length > 0 ? findings : "(no findings text provided)"}
## What to do
### 1. Handle 50+ functions (MUST refactor)
For each function with cyclomatic complexity ≥ 50:
- Split into smaller, focused functions
- Extract complex conditional branches into named helper functions
- Use early returns and guard clauses to reduce nesting
- Preserve behavior after refactoring
### 2. Handle 3549 functions
For each function in the 3549 band:
- If no justified critical-path reason exists, refactor
- If kept, ensure justification is documented in findings.md
- Prefer refactoring over keeping
### 3. Apply structural refactors
- Flatten deep nesting (>3 levels)
- Remove speculative abstractions
- Inline trivial wrappers
- Replace non-idiomatic patterns with conventional ones
- Simplify over-engineered generics to concrete types
### 4. Write changes summary
Write a summary to \`${changesFile}\` and return it as your final message.
${RUBRIC}
## changes.md format
\`\`\`markdown
# complexity — changes
<applied> refactoring(s) applied; <deferred> deferred for human review.
## Applied
- <file>:<line> — <function> split (was <score>, now <scores>)
- <file>:<line> — nested conditionals flattened
- <file>:<line> — trivial wrapper inlined
- <file>:<line> — speculative abstraction removed
## Deferred (needs human review)
- <file>:<line> — <function> — <reason> (manual)
## Justified (kept at 3549)
- <file>:<line> — <function> (<score>) — <justification>
\`\`\`
If nothing needed changing, write:
\`# complexity — changes\n\n0 refactoring(s) applied.\`
Always create changes.md so the run has an artifact. Write it under \`${outDir}\`.
`;
}
/**
* Precondition gate. Returns an error string when the complexity check cannot
* proceed (target path missing or not a real file/directory), else
* `undefined`.
*/
async function complexityGate(cwd: string): Promise<string | undefined> {
const { stat } = await import("node:fs/promises");
const { resolve } = await import("node:path");
const target = resolve(cwd);
try {
const s = await stat(target);
if (s.isDirectory() || s.isFile()) return undefined;
return `target is not a file or directory: ${target}`;
} catch {
return `target path does not exist: ${target}`;
}
}
/** The excessive complexity check definition. */
export const complexityCheck = {
name: "complexity",
label: "Complexity",
description:
"Detect and refactor excessive complexity: high cyclomatic complexity (50+ must refactor, 35-49 needs justification), deep nesting, and speculative abstractions.",
agentName: "scanner",
fixAgentName: "fixer",
phaseId: COMPLEXITY_PHASE_ID,
buildScanTask: buildComplexityScanTask,
buildFixTask: buildComplexityFixTask,
gate: complexityGate,
} as const;
// Self-register on import so index.ts auto-discovery picks it up.
registerCheck(complexityCheck);

1157
src/checks/dead-code.ts Normal file

File diff suppressed because it is too large Load Diff

181
src/checks/deep-modules.ts Normal file
View File

@@ -0,0 +1,181 @@
/**
* checks/deep-modules.ts — "deep modules, not shallow ones" check.
*
* Detects modules with shallow abstractions (thin pass-throughs, one-line
* re-export barrels, trivial getter classes, unnecessary adapter layers) and
* recommends/applies consolidation. The rubric encodes John Ousterhout's
* "deep modules" definition from *A Philosophy of Software Design*: a module
* is valuable when it hides a substantial implementation behind a small
* interface; a shallow one exposes as much complexity as it hides, so its
* indirection adds cost without abstraction payoff.
*
* Lifecycle:
* gate (need source files) → recon (shared) → scan sub-agent writes
* `<cwd>/pygienium/checks/deep-modules/findings.md` → [with --fix] fix
* sub-agent writes `changes.md`, inlines safe pass-throughs, and lists
* risky consolidations (external importers / public API) for human review.
*
* Registering this file is the ONLY wiring needed: `index.ts` auto-discovers
* `src/checks/*.ts`, so dropping this file exposes `/pygienium-deep-modules`.
*
* @module pygienium/checks/deep-modules
*/
import { readdirSync } from "node:fs";
import { join } from "node:path";
import {
registerCheck,
type CheckDefinition,
type CheckScope,
} from "./registry.js";
/** Output directory for this check's persistent reports. */
export function deepModulesOutputDir(cwd: string): string {
return join(cwd, "pygienium", "checks", "deep-modules");
}
/** `findings.md` path for this check. */
export function findingsPath(cwd: string): string {
return join(deepModulesOutputDir(cwd), "findings.md");
}
/** `changes.md` path for this check. */
export function changesPath(cwd: string): string {
return join(deepModulesOutputDir(cwd), "changes.md");
}
/** Source extensions this check inspects. */
const DEEP_MODULES_EXTENSIONS = new Set([
".ts",
".tsx",
".js",
".jsx",
".mjs",
".cjs",
".py",
".rb",
".go",
".rs",
".java",
".kt",
".swift",
".php",
".cs",
".lua",
]);
/**
* Gate: skip when the cwd has no inspectable source files at all. A workspace
* with zero source files gives the scanner nothing to classify.
*/
function deepModulesGate(cwd: string): string | undefined {
let found = false;
try {
const entries = readdirSync(cwd);
for (const entry of entries) {
const dot = entry.lastIndexOf(".");
const ext = dot === -1 ? "" : entry.slice(dot).toLowerCase();
if (DEEP_MODULES_EXTENSIONS.has(ext)) {
found = true;
break;
}
}
} catch {
// unreadable cwd → let the agent decide; don't block.
return undefined;
}
if (!found) {
return "no source files found to inspect";
}
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
* structured findings report to `findings.md`. The output path is passed into
* the task so both the real agent (which uses its `write` tool) and the
* deterministic fake runner (which understands `!write <path> <text>`) persist
* the report to the same location.
*
* Note: the `!write`/`!echo` lines are the deterministic fallback the fake
* runner executes for tests/smoke runs; a real model-driven agent receives the
* whole prompt and writes a real analysis.
*/
function buildDeepScanTask(cwd: string, scope: CheckScope): string {
const findings = findingsPath(cwd);
const target = scope.target;
// The expected findings document shape, shown to a real model-driven agent
// as the format spec. The `!write`/`!echo` lines below are the deterministic
// fallback the fake runner executes for tests/smoke runs.
return [
`Inspect the target "${target}" (cwd: ${cwd}) for shallow modules.`,
`Classify every source module by abstraction depth (see your rubric).`,
`Write your full findings report to: ${findings}.`,
`findings.md must list each flagged module with: kind, evidence, importer`,
`count, recommendation, and risk (low if no external importers, high else).`,
`Then emit a one-line summary referencing the findings path.`,
"",
`# Deterministic fallback (executed by the fake runner in tests):`,
`!write ${findings} # Deep-modules findings | summary: 1 shallow module(s) flagged | ## 1. ${target}/wrapper.ts | kind: pass-through-wrapper | evidence: forwards every argument to a single library call with no added logic | importers: 0 | recommendation: inline-and-remove | risk: low`,
`!echo deep-modules: 1 issue — see ${findings}`,
].join("\n");
}
/**
* Build the fix task. The fixer consumes the scan findings and applies ONLY safe
* consolidations: inline-and-remove pass-through wrappers that have **zero**
* external importers. Risky consolidations (any importer, or unclear
* ownership) are listed in `changes.md` as `review-manual` and NOT applied.
* Every action — applied or deferred — is recorded in `changes.md`.
*/
function buildDeepFixTask(
cwd: string,
scope: CheckScope,
findings: string,
): string {
const changes = changesPath(cwd);
const findingsFile = findingsPath(cwd);
const target = scope.target;
return [
`Consolidate shallow modules found in the scan.`,
`cwd: ${cwd} target: ${target}`,
`Findings report (also persisted at ${findingsFile}):`,
`---`,
findings,
`---`,
``,
`Rules:`,
`- Apply ONLY safe consolidations: a pass-through wrapper with zero external`,
` importers may be inlined at its single use site and the wrapper removed.`,
`- NEVER auto-delete or rewrite a module with any external importer — list it`,
` for human review instead.`,
`- Preserve public API boundaries; when in doubt, defer to manual review.`,
`- Write changes.md to ${changes} describing every action (auto | manual) with`,
` the file, the finding, and the disposition.`,
``,
`# Deterministic consolidation (executed by the fake runner in tests):`,
`# Safe: zero-importer pass-through rewritten/removed (auto).`,
`# Risky: external-importer adapter left in place (manual).`,
`!write ${target}/wrapper.ts // Consolidated by pygienium-deep-modules: pass-through wrapper removed; callers now use the underlying implementation directly.`,
`!write ${changes} # Deep-modules changes | 1. ${target}/wrapper.ts — pass-through-wrapper — consolidated: inlined the underlying call at the use site and removed the wrapper module (auto) | 2. ${target}/risky-adapter.ts — adapter-layer — 2 external importer(s): left in place; listed for review (manual)`,
`!echo deep-modules: 1 auto-applied, 1 deferred to review — see ${changes}`,
].join("\n");
}
/** The check definition; registers itself on import. */
const deepModulesCheck: CheckDefinition = {
name: "deep-modules",
label: "Deep modules",
description:
"Detect shallow modules (pass-throughs, trivial wrappers, re-export barrels) and consolidate the safe ones.",
agentName: "deep-modules",
phaseId: "analysis",
buildScanTask: buildDeepScanTask,
buildFixTask: buildDeepFixTask,
gate: deepModulesGate,
};
registerCheck(deepModulesCheck);
export { deepModulesCheck };

View File

@@ -0,0 +1,211 @@
/**
* checks/defensive-guards.ts — "redundant defensive guarding" check.
*
* Detects defensive code that guards invariants the type system or an
* upstream validation already guarantees, and removes the redundant guards
* while preserving guards that protect genuine external boundaries (user
* input, IO, parsing, untrusted data). The rubric encodes the engineering rule:
* no compatibility layers or fallbacks meant to be "replaced later" — remove
* them outright rather than layering over them.
*
* Flagged smells (non-exhaustive):
* - redundant-null-check — null/undefined check on a value whose declared
* type is already non-nullable.
* - swallowing-try-catch — try/catch that silently discards the error
* (empty catch, catch that only logs, or catch returning a fallback that
* hides the failure).
* - rethrow-only-try-catch — try/catch whose body only rethrows the exact
* error, adding nothing.
* - error-masking-fallback — `return defaultValue` / `|| fallback` in a
* catch that masks a real failure with a plausible-but-wrong value.
* - defensive-guard-on-validated-input — re-checking input that a caller or
* parser already validated (e.g. asserting a parsed enum is in range).
* - compatibility-fallback — a fallback branch kept "for now" / "to be
* replaced later" (engineering rule: remove, don't layer).
*
* Kept (legitimate boundary guards):
* - untrusted input (HTTP params, CLI args, env vars, files on disk).
* - IO (network, filesystem, subprocess) where failures are expected.
* - parsing (`JSON.parse`, `parseInt`, `Date.parse`, schema decoders).
*
* Lifecycle:
* gate (need source files) → recon (shared) → scan sub-agent writes
* `<cwd>/pygienium/checks/defensive-guards/findings.md` separating redundant
* guards from boundary guards → [with --fix] fix sub-agent removes redundant
* guards, preserves boundary guards, and writes `changes.md` distinguishing
* removed vs kept-with-reason.
*
* Registering this file is the ONLY wiring needed: `index.ts` auto-discovers
* `src/checks/*.ts`, so dropping this file exposes `/pygienium-defensive-guards`.
*
* @module pygienium/checks/defensive-guards
*/
import { readdirSync } from "node:fs";
import { join } from "node:path";
import {
registerCheck,
type CheckDefinition,
type CheckScope,
} from "./registry.js";
/** Output directory for this check's persistent reports. */
export function defensiveGuardsOutputDir(cwd: string): string {
return join(cwd, "pygienium", "checks", "defensive-guards");
}
/** `findings.md` path for this check. */
export function findingsPath(cwd: string): string {
return join(defensiveGuardsOutputDir(cwd), "findings.md");
}
/** `changes.md` path for this check. */
export function changesPath(cwd: string): string {
return join(defensiveGuardsOutputDir(cwd), "changes.md");
}
/** Source extensions this check inspects. */
const DEFENSIVE_GUARDS_EXTENSIONS = new Set([
".ts",
".tsx",
".js",
".jsx",
".mjs",
".cjs",
".py",
".rb",
".go",
".rs",
".java",
".kt",
".swift",
".php",
".cs",
".lua",
]);
/**
* Gate: skip when the cwd has no inspectable source files at all — a workspace
* with zero source files gives the scanner nothing to analyse.
*/
function defensiveGuardsGate(cwd: string): string | undefined {
let found = false;
try {
const entries = readdirSync(cwd);
for (const entry of entries) {
const dot = entry.lastIndexOf(".");
const ext = dot === -1 ? "" : entry.slice(dot).toLowerCase();
if (DEFENSIVE_GUARDS_EXTENSIONS.has(ext)) {
found = true;
break;
}
}
} catch {
// unreadable cwd → let the agent decide; don't block.
return undefined;
}
if (!found) {
return "no source files found to inspect";
}
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
* rubric, and writes a structured findings report to `findings.md`. The output
* path is passed into the task so both the real agent (which uses its `write`
* tool) and the deterministic fake runner (which understands `!write <path>
* <text>`) persist the report to the same location.
*
* The `!write`/`!echo` lines are the deterministic fallback the fake runner
* executes for tests/smoke runs; a real model-driven agent receives the whole
* prompt and writes a real analysis.
*/
function buildDefensiveGuardsScanTask(cwd: string, scope: CheckScope): string {
const findings = findingsPath(cwd);
const target = scope.target;
return [
`Inspect the target "${target}" (cwd: ${cwd}) for redundant defensive guarding.`,
`Classify every guard (null check, try/catch, fallback) against your rubric as`,
`either REDUNDANT (remove) or BOUNDARY (keep). Boundary guards protect real`,
`external boundaries: untrusted input, IO, and parsing. Redundant guards protect`,
`invariants the type system or upstream validation already guarantees.`,
`Write your full findings report to: ${findings}.`,
`findings.md must separate redundant guards from legitimate boundary guards,`,
`listing each with: kind, evidence, disposition (remove | keep-boundary), and`,
`reason.`,
`Then emit a one-line summary referencing the findings path.`,
``,
`# Deterministic fallback (executed by the fake runner in tests):`,
`!write ${findings} # Defensive-guards findings | summary: 2 redundant guard(s) flagged, 1 boundary guard kept | ## 1. ${target}/noise.ts:2 | kind: redundant-null-check | evidence: \`if (name === null)\` on \`name\` whose declared type is \`string\` (non-nullable) | disposition: remove | reason: type system already guarantees non-null | ## 2. ${target}/noise.ts:7 | kind: swallowing-try-catch | evidence: try/catch around doThing() discards the error silently (empty catch body) | disposition: remove | reason: masks bugs; no error mapping or recovery logic | ## 3. ${target}/boundary.ts:2 | kind: parsing-guard | evidence: try/catch around JSON.parse(input) | disposition: keep-boundary | reason: protects an external parsing boundary (JSON.parse of untrusted input)`,
`!echo defensive-guards: 2 redundant, 1 boundary kept — see ${findings}`,
].join("\n");
}
/**
* Build the fix task. The fixer consumes the scan findings and removes ONLY
* redundant guards — those whose protected invariant is already guaranteed by
* the type system or upstream validation. Boundary guards (IO, parsing,
* untrusted input) are preserved untouched. Every action — removed or kept —
* is recorded in `changes.md`, distinguishing removed (auto) from kept with a
* reason (boundary).
*
* The fixer rewrites the affected source files with the redundant guards
* excised; compatibility fallbacks are removed outright (engineering rule:
* remove, don't layer), never left behind as a transitional shim.
*/
function buildDefensiveGuardsFixTask(
cwd: string,
scope: CheckScope,
findings: string,
): string {
const changes = changesPath(cwd);
const findingsFile = findingsPath(cwd);
const target = scope.target;
return [
`Remove redundant defensive guards found in the scan.`,
`cwd: ${cwd} target: ${target}`,
`Findings report (also persisted at ${findingsFile}):`,
`---`,
findings,
`---`,
``,
`Rules:`,
`- Remove ONLY redundant guards: null/undefined checks on non-nullable types,`,
` try/catch that only rethrows or swallows, fallback values that hide errors,`,
` defensive guards on already-validated input, and compatibility fallbacks.`,
`- PRESERVE boundary guards: anything protecting untrusted input, IO, or parsing`,
` (e.g. JSON.parse, network, filesystem, subprocess errors). Do not touch them.`,
`- No compatibility layers: remove fallbacks outright — never leave a shim meant`,
` to be "replaced later".`,
`- Apply the smallest diff that removes the guard without changing behaviour for`,
` the happy path. Preserve tests and existing conventions.`,
`- Write changes.md to ${changes} distinguishing removed (auto) from kept`,
` (boundary — with reason) for every finding.`,
``,
`# Deterministic removal (executed by the fake runner in tests):`,
`# Redundant null check + swallowing try/catch removed from noise.ts (auto).`,
`# JSON.parse boundary guard in boundary.ts preserved (boundary).`,
`!write ${target}/noise.ts // Cleaned by pygienium-defensive-guards: removed redundant null check on non-nullable \`name\` and the swallowing try/catch around doThing(). export function greet(name: string) { return \`hello \${name}\`; } export function swallow() { doThing(); } function doThing() {}`,
`!write ${changes} # Defensive-guards changes | summary: 2 removed, 1 kept (boundary) | ## Removed (auto) | 1. ${target}/noise.ts:2 — redundant-null-check — removed \`if (name === null) return ""\`; type system guarantees non-null | 2. ${target}/noise.ts:7 — swallowing-try-catch — removed the try/catch around doThing(); the error is no longer silently swallowed | ## Kept (boundary — with reason) | 1. ${target}/boundary.ts:2 — parsing-guard — kept: try/catch around JSON.parse protects an external parsing boundary (untrusted input)`,
`!echo defensive-guards: 2 removed, 1 kept (boundary) — see ${changes}`,
].join("\n");
}
/** The check definition; registers itself on import. */
const defensiveGuardsCheck: CheckDefinition = {
name: "defensive-guards",
label: "Defensive guards",
description:
"Remove redundant defensive guarding (null checks on non-nullable types, swallowing try/catch, masking fallbacks) while keeping boundary guards (IO, parsing, untrusted input).",
agentName: "defensive-guards",
phaseId: "analysis",
buildScanTask: buildDefensiveGuardsScanTask,
buildFixTask: buildDefensiveGuardsFixTask,
gate: defensiveGuardsGate,
};
registerCheck(defensiveGuardsCheck);
export { defensiveGuardsCheck };

121
src/checks/noop.ts Normal file
View File

@@ -0,0 +1,121 @@
/**
* checks/noop.ts — the reference check and living extensibility template.
*
* This is the smallest complete `CheckDefinition`: it self-registers on import,
* passes its gate for any real file/dir target, and asks the scanner/fixer
* sub-agents to write empty `findings.md`/`changes.md` artifacts. It exists so
* that:
*
* 1. The "add a check = one file in `checks/` + one `registerCheck()` call,
* zero `index.ts` changes" claim has a verifiable witness — `index.ts`
* auto-discovers every `checks/*.ts` (except the registry barrel), so this
* file makes `/pygienium-noop` appear with no wiring edits.
* 2. New check authors have a copy-paste starting point: clone this file,
* rename, swap the rubric, ship.
*
* Artifacts (under `<cwd>/pygienium/checks/noop/`):
* - `findings.md` — `noop: 0 issues`
* - `changes.md` — `noop: 0 edits`
*
* @module pygienium/checks/noop
*/
import { registerCheck, type CheckScope } from "./registry.js";
/** Directory this check writes its artifacts to. */
function noopArtifactDir(scope: CheckScope): string {
return `${scope.cwd.replace(/\/+$/, "")}/pygienium/checks/noop`;
}
/**
* Analysis task: scan nothing meaningful, write a zero-issue findings report.
* Mirrors the structure a real check's scan task uses so this file reads as a
* faithful template.
*/
function buildNoopScanTask(_cwd: string, scope: CheckScope): string {
const outDir = noopArtifactDir(scope);
return `# Task: noop scan
You are running the **noop** hygiene check (a no-op reference check).
## Target
- Scan target: \`${scope.target}\`
## What to do
1. Confirm the target is reachable (no real analysis is needed).
2. Write a findings report to \`${outDir}/findings.md\` with the content:
\`\`\`markdown
# noop — findings
noop: 0 issues
\`\`\`
3. Return that report text as your final message so the host records it as the
analysis-phase findings.
If the target is missing, write \`noop: target missing\` to findings.md instead.
`;
}
/** Fix task: apply zero edits, write an empty changes report. */
function buildNoopFixTask(
_cwd: string,
scope: CheckScope,
_findings: string,
): string {
const outDir = noopArtifactDir(scope);
return `# Task: noop fix
You are running the **noop** hygiene fix phase.
## Target
- Fix target: \`${scope.target}\`
## What to do
1. Make no source edits (this is a no-op check).
2. Write a changes report to \`${outDir}/changes.md\` with the content:
\`\`\`markdown
# noop — changes
noop: 0 edits
\`\`\`
3. Return that report text as your final message.
`;
}
/**
* Precondition gate: pass when the target path exists as a file or directory.
* Idempotent — used for both the pre-analysis check and the post-fix verify.
*/
async function noopGate(cwd: string): Promise<string | undefined> {
const { stat } = await import("node:fs/promises");
const { resolve } = await import("node:path");
const target = resolve(cwd);
try {
const s = await stat(target);
if (s.isDirectory() || s.isFile()) return undefined;
return `target is not a file or directory: ${target}`;
} catch {
return `target path does not exist: ${target}`;
}
}
/** The noop check definition. Self-registers on import. */
export const noopCheck = {
name: "noop",
label: "No-op",
description:
"Reference/template check — performs no analysis, writes zero-issue artifacts. Clone it to start a new check.",
agentName: "scanner",
fixAgentName: "fixer",
phaseId: "noop",
buildScanTask: buildNoopScanTask,
buildFixTask: buildNoopFixTask,
gate: noopGate,
} as const;
// Self-register on import so index.ts auto-discovery picks it up — no wiring.
registerCheck(noopCheck);

139
src/checks/registry.ts Normal file
View File

@@ -0,0 +1,139 @@
/**
* checks/registry.ts — pluggable check registry.
*
* A `CheckDefinition` describes one hygiene check (e.g. `comments`, `complexity`).
* The registry is a module-level `Map` so that adding a check only requires a
* new file in `src/checks/` plus one `registerCheck(def)` call — no changes to
* `index.ts` command wiring. At startup, `index.ts` iterates the registry and
* auto-registers a `/pygienium-<name>` command per definition.
*
* Lifecycle of a single check run (orchestrated by `src/modes/check-runner.ts`):
* Q0 recon (shared) → analysis sub-agent (buildScanTask) →
* fix sub-agent (buildFixTask, only with --fix) → verify gate → cleanup.
*
* @module pygienium/checks/registry
*/
/**
* Scope passed to scan/fix task builders. Resolved from the command args:
* a positional path (absolute or relative to `cwd`) plus parsed flags.
*/
export interface CheckScope {
/** Absolute working directory the check operates on. */
cwd: string;
/** Target path (absolute) the check scans; defaults to `cwd` when none given. */
target: string;
/** Whether fixes should be applied (the `--fix` flag). */
fix: boolean;
/** Remaining raw tokens after flag parsing, for check-specific use. */
rest: string[];
}
/**
* Verifies preconditions before a check runs its analysis phase. Returns an
* error string when the check cannot proceed (e.g. no source files match),
* or `undefined` when the gate passes. Implemented per-check so generic
* checks can bail early without spawning an agent.
*/
export type CheckGate = (
cwd: string,
) => Promise<string | undefined> | string | undefined;
/**
* Optional post-analysis (+ optional fix) verify hook. Confirms the check
* actually produced its artifacts (e.g. `findings.md`/`changes.md`). Returns an
* error string to fail the verify phase, or `undefined` to pass. When omitted,
* the verify phase falls back to re-running {@link CheckDefinition.gate},
* preserving the historical behaviour for checks that have nothing to verify.
*/
export type CheckVerify = (
scope: CheckScope,
) => Promise<string | undefined> | string | undefined;
/**
* The structured task string handed to a sub-agent. `buildScanTask` produces
* the analysis prompt; `buildFixTask` consumes the findings text the scan
* agent emitted and produces a fix prompt.
*
* Task builders may be async: a check can pre-compute deterministic candidates
* (e.g. an import-graph scan) before assembling the prompt, so the sub-agent's
* job is to verify/refine rather than re-derive everything from scratch.
*/
export type BuildScanTask = (
cwd: string,
scope: CheckScope,
) => string | Promise<string>;
export type BuildFixTask = (
cwd: string,
scope: CheckScope,
findings: string,
) => string | Promise<string>;
/**
* Definition of a single pluggable hygiene check.
*/
export interface CheckDefinition {
/** Lowercase kebab command suffix → `/pygienium-<name>`. Must be unique. */
name: string;
/** Human label shown in help and status strips. */
label: string;
/** One-line description for `/pygienium-help`. */
description: string;
/**
* Name of the agent definition (from `agents/*.md`) used for the analysis
* phase. The fix phase uses the `fixer` agent unless `fixAgentName`
* overrides it.
*/
agentName: string;
/** Optional override for the fix-phase agent (defaults to `fixer`). */
fixAgentName?: string;
/** Identifier of the phase-strip phase this check belongs to (task 05). */
phaseId: string;
/** Builds the analysis sub-agent task. */
buildScanTask: BuildScanTask;
/** Builds the fix sub-agent task from scan findings. */
buildFixTask: BuildFixTask;
/**
* Precondition gate. Returning a string skips the check (recorded as
* `skipped`); returning `undefined` proceeds normally.
*/
gate: CheckGate;
/**
* Optional verify hook confirming artifacts landed (see {@link CheckVerify}).
* Falls back to re-running `gate` when omitted.
*/
verify?: CheckVerify;
}
const registry = new Map<string, CheckDefinition>();
/**
* Register a check. Throws on duplicate names so wiring mistakes surface
* loudly at startup rather than silently shadowing a command.
*/
export function registerCheck(def: CheckDefinition): void {
if (!def.name || !/^[a-z0-9][a-z0-9-]*$/.test(def.name)) {
throw new Error(
`Invalid check name "${def.name}": must be lowercase kebab (e.g. "comments").`,
);
}
if (registry.has(def.name)) {
throw new Error(`Duplicate pygienium check name: "${def.name}".`);
}
registry.set(def.name, def);
}
/** Look up a registered check by name. */
export function getCheck(name: string): CheckDefinition | undefined {
return registry.get(name);
}
/** All registered checks in insertion order. */
export function getAllChecks(): CheckDefinition[] {
return [...registry.values()];
}
/** Test-only: reset the registry between tests. */
export function clearChecks(): void {
registry.clear();
}

325
src/commands.ts Normal file
View File

@@ -0,0 +1,325 @@
/**
* 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,
ExtensionUIContext,
} from "@earendil-works/pi-coding-agent";
import { resolve } from "node:path";
import {
getAllChecks,
getCheck,
type CheckDefinition,
} from "./checks/registry.js";
import { runCheck, parseCheckArgs } 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,
shouldRunOnResume,
} from "./run-state.js";
import { formatRunStatus } from "./status.js";
import {
exportRun,
parseExportFilters,
exportBundlePath,
type ExportFilters,
} from "./export.js";
/** Narrow context slice handlers need (a subset of `ExtensionCommandContext`). */
export type PygieniumCtx = Pick<
ExtensionCommandContext,
"cwd" | "mode" | "hasUI" | "ui"
>;
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 a leading flag token (`--fix`) from args, returning the remainder. */
function splitFlags(args: string): { fix: boolean; rest: string } {
const tokens = args.trim().length > 0 ? args.trim().split(/\s+/) : [];
const fix = tokens.includes("--fix");
const rest = tokens.filter((t) => t !== "--fix").join(" ");
return { fix, rest };
}
/**
* 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 } {
const tokens = args.trim().length > 0 ? args.trim().split(/\s+/) : [];
const fresh = tokens.includes("--fresh");
const positional = tokens.find((t) => !t.startsWith("--"));
const cwd = positional ? resolve(ctxCwd, positional) : ctxCwd;
return { cwd, fresh };
}
/** `/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.
*/
export async function handleCheckCommand(
check: CheckDefinition,
args: string,
ctx: PygieniumCtx,
): Promise<void> {
const { fix, rest } = splitFlags(args);
const target = resolveCwd(rest, ctx.cwd);
const scope = parseCheckArgs(fix ? `--fix ${rest}` : rest, ctx.cwd);
const outcome = await runCheck({
check,
cwd: ctx.cwd,
scope: { ...scope, cwd: ctx.cwd, target },
ui: ctx.ui,
hasUI: ctx.hasUI,
});
print(
ctx,
`pygienium ${check.label}: ${outcome.status}${outcome.error ? `${outcome.error}` : ""}`,
);
}
/**
* `/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,
only: parsed.only,
ui: ctx.ui,
hasUI: ctx.hasUI,
});
print(
ctx,
`pygienium: all-run ${outcome.status}${outcome.ran.length} ran, ${outcome.skipped.length} skipped; summary → ${allSummaryPath(ctx.cwd)} (${runStatePath(ctx.cwd)})`,
);
}
/** `/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 } = 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;
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 = await runCheck({
check: def,
cwd,
scope: { cwd, target: cwd, fix: entry.fix, rest: [] },
ui: ctx.ui,
hasUI: ctx.hasUI,
existingState: state,
});
state = outcome.state;
ran++;
print(ctx, `pygienium ${def.label}: ${outcome.status}`);
}
markRunStatus(state, reconcileRunStatus(state));
await saveRunState(state);
print(
ctx,
`pygienium: resume done — re-dispatched ${ran}, skipped ${skipped}; run ${state.status} (${runStatePath(cwd)})`,
);
}
/**
* `/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}`.
*/
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,
});
}
/** Re-export for index.ts convenience. */
export type { ExtensionUIContext };

269
src/export.ts Normal file
View File

@@ -0,0 +1,269 @@
/**
* export.ts — bundle find/changed artifacts for a pygienium run.
*
* `/pygienium-export` walks each check's artifact directory (where
* `findings.md` and `changes.md` live), applies `--check=` / `--status=`
* filters, and writes a single bundle to `pygienium/export.{md|json}`.
*
* Artifact root: `<cwd>/pygienium/checks/<name>/` — the single canonical
* location every shipped check writes to.
*
* Statuses for `--status=` filtering come from the run-state; a check dir
* present on disk but absent from run-state is reported as `unknown`.
*
* @module pygienium/export
*/
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import type { RunState } from "./run-state.js";
export type ExportFormat = "md" | "json";
/** Directory name (relative to cwd) that holds `checks/` and `export.md`. */
export const PYGIENIUM_ARTIFACT_DIR = "pygienium";
/** Subdirectory holding per-check `findings.md`/`changes.md`. */
export const CHECKS_SUBDIR = "checks";
/** Base filename for the bundle (`export.md` / `export.json`). */
export const EXPORT_FILENAME_BASE = "export";
/** Resolve `<cwd>/pygienium/` (the artifact root). */
export function pygieniumArtifactDir(cwd: string): string {
return join(cwd, PYGIENIUM_ARTIFACT_DIR);
}
/** Resolve `<cwd>/pygienium/checks/`. */
export function canonicalChecksRoot(cwd: string): string {
return join(pygieniumArtifactDir(cwd), CHECKS_SUBDIR);
}
/** Resolve `<cwd>/pygienium/export.<format>`. */
export function exportBundlePath(cwd: string, format: ExportFormat): string {
return join(pygieniumArtifactDir(cwd), `${EXPORT_FILENAME_BASE}.${format}`);
}
/** A single gathered check artifact entry (post-filter). */
export interface ExportEntry {
/** Check name (the directory under `checks/`). */
name: string;
/** Status from run-state, or `unknown` when not present there. */
status: string;
/** `findings.md` contents, when present on disk. */
findings?: string;
/** `changes.md` contents, when present on disk. */
changes?: string;
/** Absolute path to `findings.md`, when read from disk. */
findingsPath?: string;
/** Absolute path to `changes.md`, when read from disk. */
changesPath?: string;
}
/** Parsed `--check=` / `--status=` / `--out=` filters. */
export interface ExportFilters {
/** Check-name allowlist (comma-separated); undefined = all. */
check?: string[];
/** Status allowlist (comma-separated), matched against run-state statuses. */
status?: string[];
/** Output format. Defaults to `md`. */
out?: ExportFormat;
}
/** Result of {@link exportRun}. */
export interface ExportResult {
/** Format used. */
format: ExportFormat;
/** Absolute path the bundle was written to. */
path: string;
/** Entries included after filtering (in alphabetical order). */
entries: ExportEntry[];
/** Bundle size in bytes. */
bytes: number;
}
const FLAG_CHECK = "--check=";
const FLAG_STATUS = "--status=";
const FLAG_OUT = "--out=";
/** Parse export flags from the raw arg string (flags + optional positional). */
export function parseExportFilters(args: string): ExportFilters {
const filters: ExportFilters = { out: "md" };
const tokens = args.trim().length > 0 ? args.trim().split(/\s+/) : [];
for (const tok of tokens) {
if (tok.startsWith(FLAG_CHECK)) {
filters.check = tok
.slice(FLAG_CHECK.length)
.split(",")
.map((s) => s.trim())
.filter(Boolean);
} else if (tok.startsWith(FLAG_STATUS)) {
filters.status = tok
.slice(FLAG_STATUS.length)
.split(",")
.map((s) => s.trim())
.filter(Boolean);
} else if (tok.startsWith(FLAG_OUT)) {
const v = tok.slice(FLAG_OUT.length).toLowerCase().trim();
if (v === "json" || v === "md") {
filters.out = v;
}
}
}
return filters;
}
async function readArtifact(path: string): Promise<string | undefined> {
try {
return await readFile(path, "utf8");
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") return undefined;
throw err;
}
}
async function gatherFromRoot(
root: string,
state: RunState | undefined,
merged: Map<string, ExportEntry>,
): Promise<void> {
let entries: import("node:fs").Dirent[];
try {
entries = await readdir(root, { withFileTypes: true });
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") return;
throw err;
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const name = entry.name;
const dir = join(root, name);
const fpath = join(dir, "findings.md");
const cpath = join(dir, "changes.md");
const findings = await readArtifact(fpath);
const changes = await readArtifact(cpath);
const checkState = state?.checks[name];
merged.set(name, {
name,
status: checkState?.status ?? "unknown",
findings,
changes,
findingsPath: findings != null ? fpath : undefined,
changesPath: changes != null ? cpath : undefined,
});
}
}
/**
* Gather artifact entries from the canonical `<cwd>/pygienium/checks/` root,
* one entry per check directory. Entries are sorted alphabetically. Marks an
* entry `unknown` when its check is absent from `state`.
*/
export async function gatherExportEntries(
cwd: string,
state?: RunState,
): Promise<ExportEntry[]> {
const merged = new Map<string, ExportEntry>();
await gatherFromRoot(canonicalChecksRoot(cwd), state, merged);
return [...merged.values()].sort((a, b) => a.name.localeCompare(b.name));
}
/** Apply `--check=` / `--status=` filters to gathered entries. */
export function filterExportEntries(
entries: ExportEntry[],
filters: ExportFilters,
): ExportEntry[] {
return entries.filter((e) => {
if (filters.check && !filters.check.includes(e.name)) return false;
if (filters.status && !filters.status.includes(e.status)) return false;
return true;
});
}
/** Render the markdown bundle. */
export function renderExportMarkdown(
state: RunState | undefined,
entries: ExportEntry[],
): string {
const lines: string[] = [];
lines.push("# Pygienium export");
if (state) {
lines.push("");
lines.push(`- status: ${state.status}`);
lines.push(`- started: ${new Date(state.startedAt).toISOString()}`);
lines.push(`- updated: ${new Date(state.updatedAt).toISOString()}`);
lines.push(`- cwd: ${state.cwd}`);
lines.push(`- recon: ${state.recon.complete ? "complete" : "pending"}`);
}
lines.push(`- checks: ${entries.length}`);
lines.push("");
for (const e of entries) {
lines.push(`## ${e.name} (${e.status})`);
if (e.findings != null) {
lines.push("");
lines.push("### findings");
lines.push("");
lines.push(e.findings.replace(/\s+$/, ""));
}
if (e.changes != null) {
lines.push("");
lines.push("### changes");
lines.push("");
lines.push(e.changes.replace(/\s+$/, ""));
}
if (e.findings == null && e.changes == null) {
lines.push("");
lines.push("_(no findings.md or changes.md on disk)_");
}
lines.push("");
}
return lines.join("\n") + "\n";
}
/** Render the JSON bundle. */
export function renderExportJson(
state: RunState | undefined,
entries: ExportEntry[],
): string {
const payload = {
status: state?.status ?? "unknown",
startedAt: state?.startedAt ?? null,
updatedAt: state?.updatedAt ?? null,
cwd: state?.cwd ?? null,
recon: state ? state.recon.complete : null,
checks: entries.map((e) => ({
name: e.name,
status: e.status,
findings: e.findings ?? null,
findingsPath: e.findingsPath ?? null,
changes: e.changes ?? null,
changesPath: e.changesPath ?? null,
})),
};
return JSON.stringify(payload, null, 2) + "\n";
}
/**
* Gather, filter, and write the export bundle. Returns the (would-be) path
* and the included entries. When there are no entries, no file is written —
* the caller reports "nothing to export" and we avoid leaving an empty
* `export.{md|json}` on disk.
*/
export async function exportRun(
cwd: string,
state: RunState | undefined,
filters: ExportFilters,
): Promise<ExportResult> {
const all = await gatherExportEntries(cwd, state);
const entries = filterExportEntries(all, filters);
const format: ExportFormat = filters.out ?? "md";
const path = exportBundlePath(cwd, format);
if (entries.length === 0) {
return { format, path, entries, bytes: 0 };
}
const body =
format === "json"
? renderExportJson(state, entries)
: renderExportMarkdown(state, entries);
await mkdir(dirname(path), { recursive: true });
await writeFile(path, body, "utf8");
return { format, path, entries, bytes: Buffer.byteLength(body) };
}

185
src/help.ts Normal file
View File

@@ -0,0 +1,185 @@
/**
* help.ts — command + flag help builder (single source of truth for
* `/pygienium-help`).
*
* `COMMANDS` and `CLI_FLAGS` are static arrays describing every operator
* command and flag actually implemented across tasks 0613. The per-check
* command family (`/pygienium-<check>`) is a single generic entry because the
* concrete check commands come from the live registry — `buildPygieniumHelpLines`
* appends one row per registered `CheckDefinition`, so a newly registered check
* appears in `/pygienium-help` with zero edits here. This is what backs the
* "add a check = one file + registerCheck, no index.ts changes" guarantee.
*
* @module pygienium/help
*/
import { getAllChecks } from "./checks/registry.js";
/** A flag row shown in the help output. */
export interface HelpFlag {
/** Flag token exactly as typed on the command line. */
name: string;
/** Which commands accept this flag. */
scope: string;
/** What the flag does. */
description: string;
}
/** A command row shown in the help output. */
export interface HelpCommand {
/** Command invocation (without the leading `/`). */
usage: string;
/** One-line description of what it does. */
description: string;
/** Concrete example call. */
example: string;
}
/**
* Flags supported by `/pygienium-<check>` and the operator commands.
* Mirrors the arg parsing in `commands.ts` / `modes/all.ts` / `export.ts`
* exactly.
*/
export const CLI_FLAGS: HelpFlag[] = [
{
name: "[path]",
scope: "all check commands",
description: "Target file or directory to scan (default: current dir).",
},
{
name: "--fix",
scope: "<check>, all, resume",
description: "Apply fixes (default: scan-only; emits findings only).",
},
{
name: "--fresh",
scope: "all, resume",
description:
"Re-dispatch completed checks too — reset their run-state entries and re-run.",
},
{
name: "--only=",
scope: "all",
description: "Comma-separated check names to run (subset of the registry).",
},
{
name: "--check=",
scope: "export",
description: "Comma-separated check names to include in the bundle.",
},
{
name: "--status=",
scope: "export",
description:
"Comma-separated statuses to include (e.g. complete,failed,skipped).",
},
{
name: "--out=",
scope: "export",
description: "Bundle format: `md` (default) or `json`.",
},
];
/**
* The operator commands (the per-check `/pygienium-<check>` family is rendered
* dynamically from the registry below). Each entry carries a usage, a
* one-line description, and an example so `/pygienium-help` is self-contained.
*/
export const COMMANDS: HelpCommand[] = [
{
usage: "pygienium-help",
description: "Show every command, shipped check, and flag (this block).",
example: "/pygienium-help",
},
{
usage: "pygienium-<check> [path] [--fix]",
description:
"Run one isolated sub-agent that scans a target, applies fixes with --fix, and emits a findings+changes report.",
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.",
example: "/pygienium-all --fix",
},
{
usage: "pygienium-status [path]",
description:
"Show per-check progress, captured findings/changes line counts, and errors for the latest run.",
example: "/pygienium-status",
},
{
usage: "pygienium-resume [path] [--fresh]",
description:
"Resume the latest in-progress/failed/partial run, re-dispatching each non-terminal check (complete/skipped skip unless --fresh).",
example: "/pygienium-resume --fresh",
},
{
usage: "pygienium-export [path] [--check=] [--status=] [--out=md|json]",
description:
"Bundle every check's findings.md + changes.md into pygienium/export.{md|json}.",
example: "/pygienium-export --out=json",
},
];
/** Back-compat alias for the flag array. */
export const PYGIENIUM_FLAGS = CLI_FLAGS;
/** Right-pad a string to `width` (no-op when already longer). */
function pad(s: string, width: number): string {
return s.length >= width ? s : s + " ".repeat(width - s.length);
}
/**
* Build the full `/pygienium-help` text. Layout (one string per line):
*
* header
* Commands: (one entry per COMMANDS row: usage, description, example)
* Checks (N): (one row per registered check, registry-driven)
* Flags: (one row per CLI_FLAGS entry)
* Adding a check: (one-file + registerCheck note)
*/
export function buildPygieniumHelpLines(): string[] {
const lines: string[] = [];
lines.push("Pygienium — code hygiene for pi", "");
lines.push("Commands:");
const usageWidth = Math.max(...COMMANDS.map((c) => c.usage.length)) + 2;
for (const cmd of COMMANDS) {
lines.push(` /${pad(cmd.usage, usageWidth)}${cmd.description}`);
lines.push(` ${pad("", usageWidth)}e.g. ${cmd.example}`);
}
lines.push("");
const checks = getAllChecks();
lines.push(`Checks (${checks.length}):`);
if (checks.length === 0) {
lines.push(
" (none registered — drop a file in src/checks/ and add one registerCheck() entry)",
);
} else {
const nameWidth = Math.max(...checks.map((c) => c.name.length)) + 2;
for (const c of checks) {
lines.push(` /pygienium-${pad(c.name, nameWidth)}${c.description}`);
}
}
lines.push("");
lines.push("Flags:");
const flagNameWidth = Math.max(...CLI_FLAGS.map((f) => f.name.length)) + 2;
const flagScopeWidth =
Math.max(...CLI_FLAGS.map((f) => `[${f.scope}]`.length)) + 2;
for (const f of CLI_FLAGS) {
lines.push(
` ${pad(f.name, flagNameWidth)}${pad(`[${f.scope}]`, flagScopeWidth)}${f.description}`,
);
}
lines.push("");
lines.push(
"Adding a check: drop a file in src/checks/ and add one registerCheck() entry.",
);
lines.push("No index.ts command-wiring changes are required.");
return lines;
}

472
src/hygiene-state.ts Normal file
View File

@@ -0,0 +1,472 @@
/**
* 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;

76
src/index.ts Normal file
View File

@@ -0,0 +1,76 @@
/**
* pygienium — code hygiene extension for pi.
*
* Entry point. Registers `/pygienium-help`, auto-registers one
* `/pygienium-<check>` command per registered `CheckDefinition`, plus the
* `all`/`resume`/`status`/`export` commands. Adding a check requires ONLY a new
* file in `src/checks/` plus one `registerCheck(def)` call — no changes here.
*
* Check files in `src/checks/` are auto-discovered (every `.ts` except the
* registry barrel), so they self-register at load time before commands bind.
*
* Pi loads this file via jiti at runtime (see `pi.extensions` in package.json).
* The default export runs once per session; the factory is async so check
* modules finish registering before command wiring.
*
* @module pygienium/index
*/
import { readdir } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import type {
ExtensionAPI,
ExtensionContext,
SessionStartEvent,
} from "@earendil-works/pi-coding-agent";
import { registerPygieniumCommands } from "./commands.js";
/** Startup hint mirrored after piolium's convention. */
export const PYGIENIUM_STARTUP_HINT =
"Pygienium loaded. Run /pygienium-help for available checks and flags.";
export { buildPygieniumHelpLines } from "./help.js";
/**
* Import every `checks/*.ts` module (except the registry barrel) so each check
* file's top-level `registerCheck(def)` call runs before command binding. This
* is what makes adding a check require zero index.ts changes — drop a file,
* it self-registers.
*/
async function loadCheckModules(): Promise<void> {
const dir = join(dirname(fileURLToPath(import.meta.url)), "checks");
let entries: string[];
try {
entries = await readdir(dir);
} catch {
return; // no checks dir (e.g. minimal install)
}
for (const entry of entries) {
if (!entry.endsWith(".ts")) continue;
if (entry === "registry.ts" || entry === "load.ts") continue;
await import(`./checks/${entry}`);
}
}
export default async function pygieniumExtension(
pi: ExtensionAPI,
): Promise<void> {
// Self-register every shipped check before wiring commands.
await loadCheckModules();
registerPygieniumCommands((name, options) => {
pi.registerCommand(name, {
description: options.description,
handler: options.handler,
});
});
pi.on(
"session_start",
async (_event: SessionStartEvent, ctx: ExtensionContext) => {
if (!ctx.hasUI) return;
ctx.ui.notify(PYGIENIUM_STARTUP_HINT, "info");
},
);
}

0
src/modes/.gitkeep Normal file
View File

375
src/modes/all.ts Normal file
View File

@@ -0,0 +1,375 @@
/**
* 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 "@earendil-works/pi-coding-agent";
import { getAllChecks, type CheckDefinition } from "../checks/registry.js";
import { runCheck } from "./check-runner.js";
import { createPhaseStrip } from "../phases.js";
import { runRecon } from "../recon.js";
import {
applyPhaseStatus,
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;
/** UI context (optional; null in print mode). */
ui?: ExtensionUIContext;
/** Whether dialog-capable UI is available. */
hasUI?: boolean;
}
/** 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[];
}
/**
* 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;
only: string[];
} {
const tokens = args.trim().length > 0 ? args.trim().split(/\s+/) : [];
let fix = false;
let fresh = false;
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.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, 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("");
if (entry?.error) {
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`;
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;
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: [] };
}
// --- 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 listing all check names -----------------------
const strip = createPhaseStrip({
ui: opts.ui,
hasUI,
statusKey: "pygienium-all",
});
setAllPhase(strip, selected, 0, "recon");
// --- 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");
const entry = state.checks[check.name];
// Resumability: skip terminal checks unless --fresh.
if (entry && !shouldRunOnResume(entry, fresh)) {
skipped.push(check.name);
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,
});
state = outcome.state;
ran.push(check.name);
strip.log(`pygienium ${check.label}: ${outcome.status}`);
}
// --- Finalize -----------------------------------------------------------
markRunStatus(state, reconcileRunStatus(state));
await saveRunState(state);
setAllPhase(strip, selected, selected.length - 1, "cleanup");
strip.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;
}

310
src/modes/check-runner.ts Normal file
View File

@@ -0,0 +1,310 @@
/**
* 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 "@earendil-works/pi-coding-agent";
import type { CheckDefinition, CheckScope } from "../checks/registry.js";
import { runAgentTask } from "../agent-runner.js";
import { runRecon } from "../recon.js";
import { createPhaseStrip } from "../phases.js";
import {
applyPhaseStatus,
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";
/** Parsed command args handed to a `/pygienium-<check>` handler. */
export interface CheckRunnerArgs {
/** Raw arg string from the slash command. */
raw: string;
}
/** 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;
}
/** Outcome of a single check run. */
export interface CheckRunOutcome {
/** Final check status. */
status: "complete" | "failed" | "skipped";
/** 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.
*
* 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 { 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,
});
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";
}
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);
applyPhaseStatus(state, check.name, PHASE_RECON, "in_progress");
await saveRunState(state);
const snapshot = await runRecon(cwd);
state.recon = {
complete: true,
path: join(stateDir(cwd), "recon.json"),
finishedAt: snapshot.createdAt,
};
applyPhaseStatus(state, check.name, PHASE_RECON, "complete");
await saveRunState(state);
} else {
// Recon already done this run — mark this check's recon complete.
applyPhaseStatus(state, check.name, PHASE_RECON, "complete");
}
// --- Phase: analysis -------------------------------------------------
strip.setPhase(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,
});
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);
// --- Phase: fix (only with --fix) -----------------------------------
if (scope.fix) {
strip.setPhase(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,
});
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);
}
// --- Phase: verify ---------------------------------------------------
strip.setPhase(PHASE_VERIFY);
applyPhaseStatus(state, check.name, PHASE_VERIFY, "in_progress");
await saveRunState(state);
// 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,
};
}
applyPhaseStatus(state, check.name, PHASE_VERIFY, "complete");
await saveRunState(state);
// --- Phase: cleanup --------------------------------------------------
strip.setPhase(PHASE_CLEANUP);
applyPhaseStatus(state, check.name, PHASE_CLEANUP, "in_progress");
await saveRunState(state);
await cleanupTransientArtifacts(cwd, check.name);
applyPhaseStatus(state, check.name, PHASE_CLEANUP, "complete");
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();
}
}
/**
* 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(
() => {},
);
}
}
}

83
src/phases.ts Normal file
View File

@@ -0,0 +1,83 @@
/**
* phases.ts — phase-strip status UI helper.
*
* Renders the active phase of a check run into pi's footer status bar and
* forwards plain-text progress lines to stdout (so `print` mode `-p` also
* shows progress). The strip is a small, self-contained adapter over
* `ExtensionUIContext.setStatus` — simplified from piolium's phase-strip
* command UI to the subset pygienium needs: a status key, the current phase,
* and a clear on completion.
*
* @module pygienium/phases
*/
import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
/** Phase display metadata for a check run's phases. */
export const PHASE_LABELS: Record<string, string> = {
recon: "Recon",
analysis: "Scanning",
fix: "Fixing",
verify: "Verifying",
cleanup: "Cleaning up",
};
export interface PhaseStripOptions {
/** Footer status key (defaults to "pygienium"). */
statusKey?: string;
/** Check label shown alongside the phase, e.g. "comments". */
checkLabel?: string;
/** Whether dialog-capable UI is available. */
hasUI?: boolean;
/** UI context to drive the footer status bar. */
ui?: ExtensionUIContext;
}
/**
* A handle that renders phase progress and clears on completion. Created by
* {@link createPhaseStrip}; pass the result to the check-runner.
*/
export interface PhaseStrip {
/** Set the current phase id (e.g. "analysis"). */
setPhase(phaseId: string): void;
/** Append a plain-text progress line (forwarded to stdout). */
log(line: string): void;
/** Clear the footer status bar. Call once the run is terminal. */
done(): void;
}
/** Create a phase-strip UI adapter. */
export function createPhaseStrip(opts: PhaseStripOptions): PhaseStrip {
const statusKey = opts.statusKey ?? "pygienium";
const ui = opts.ui;
const hasUI = opts.hasUI ?? false;
const checkLabel = opts.checkLabel;
function render(phaseId: string): string {
const label = PHASE_LABELS[phaseId] ?? phaseId;
return checkLabel
? `pygienium ${checkLabel}: ${label}`
: `pygienium: ${label}`;
}
return {
setPhase(phaseId) {
const text = render(phaseId);
if (hasUI && ui?.setStatus) {
ui.setStatus(statusKey, text);
}
// In print/json modes (no TUI) write progress to stdout. In TUI mode
// the status bar is the render surface — raw stdout writes would splice
// into the ink renderer, so they are suppressed.
if (!hasUI) process.stdout.write(`${text}\n`);
},
log(line) {
if (!hasUI) process.stdout.write(`${line}\n`);
},
done() {
if (hasUI && ui?.setStatus) {
ui.setStatus(statusKey, undefined);
}
},
};
}

128
src/recon.ts Normal file
View File

@@ -0,0 +1,128 @@
/**
* recon.ts — shared Q0 reconnaissance phase.
*
* Runs once per hygiene run (before any check's analysis phase) and writes a
* project snapshot to `<cwd>/.pygienium/recon.json`. Each check can read this
* snapshot so the recon work isn't repeated per check. The snapshot is minimal
* and dependency-free (git state + source-file inventory) — real checks layer
* their own analysis on top via sub-agents.
*
* @module pygienium/recon
*/
import { exec } from "node:child_process";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { promisify } from "node:util";
import { join } from "node:path";
import { stateDir, RECON_FILENAME } from "./run-state.js";
const execAsync = promisify(exec);
export interface ReconSnapshot {
cwd: string;
createdAt: number;
gitBranch?: string;
gitDirty?: boolean;
/** Count of source files by extension. */
fileCounts: Record<string, number>;
totalSourceFiles: number;
}
/** Source extensions worth inventorying for hygiene checks. */
const SOURCE_EXTENSIONS = new Set([
".ts",
".tsx",
".js",
".jsx",
".mjs",
".cjs",
".py",
".rb",
".go",
".rs",
".java",
".kt",
".swift",
".php",
".cs",
".lua",
]);
/** Run `git ls-files` when possible to get a clean source inventory. */
async function listSourceFiles(cwd: string): Promise<string[]> {
try {
const { stdout } = await execAsync(
`git -C ${JSON.stringify(cwd)} ls-files --cached --others --exclude-standard`,
{ maxBuffer: 64 * 1024 * 1024 },
);
return stdout
.split("\n")
.map((l) => l.trim())
.filter((l) => l.length > 0)
.filter((l) => {
const dot = l.lastIndexOf(".");
if (dot === -1) return false;
return SOURCE_EXTENSIONS.has(l.slice(dot).toLowerCase());
});
} catch {
/* not a git repo or git unavailable — empty inventory */
return [];
}
}
/** Run the shared recon phase for `cwd`, writing the snapshot if missing. */
export async function runRecon(cwd: string): Promise<ReconSnapshot> {
const dir = stateDir(cwd);
const path = join(dir, RECON_FILENAME);
// Reuse a fresh-enough snapshot (< 5 min) when present.
try {
const raw = await readFile(path, "utf8");
const existing = JSON.parse(raw) as ReconSnapshot;
if (
existing.createdAt &&
Date.now() - existing.createdAt < 5 * 60 * 1000 &&
existing.cwd === cwd
) {
return existing;
}
} catch {
/* no existing snapshot */
}
const files = await listSourceFiles(cwd);
const fileCounts: Record<string, number> = {};
for (const f of files) {
const dot = f.lastIndexOf(".");
const ext = dot === -1 ? "" : f.slice(dot).toLowerCase();
fileCounts[ext] = (fileCounts[ext] ?? 0) + 1;
}
let gitBranch: string | undefined;
let gitDirty: boolean | undefined;
try {
const branchOut = await execAsync(
`git -C ${JSON.stringify(cwd)} rev-parse --abbrev-ref HEAD`,
);
gitBranch = branchOut.stdout.trim() || undefined;
const statusOut = await execAsync(
`git -C ${JSON.stringify(cwd)} status --porcelain`,
);
gitDirty = statusOut.stdout.trim().length > 0;
} catch {
/* not a git repo */
}
const snapshot: ReconSnapshot = {
cwd,
createdAt: Date.now(),
gitBranch,
gitDirty,
fileCounts,
totalSourceFiles: files.length,
};
await mkdir(dir, { recursive: true });
await writeFile(path, JSON.stringify(snapshot, null, 2) + "\n", "utf8");
return snapshot;
}

286
src/run-state.ts Normal file
View File

@@ -0,0 +1,286 @@
/**
* run-state.ts — persistent hygiene-run state.
*
* Tracks per-check phase progress so `/pygienium-status`, `/pygienium-resume`,
* and `/pygienium-export` work, and so `/pygienium-all` is resumable. State is
* a single JSON file at `<cwd>/.pygienium/run-state.json` so it is trivial to
* inspect and is naturally session-scoped to the target directory.
*
* @module pygienium/run-state
*/
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
export const RUN_STATE_DIRNAME = ".pygienium";
export const RUN_STATE_FILENAME = "run-state.json";
export const RECON_FILENAME = "recon.json";
/** Resolve the pygienium state directory for a given cwd. */
export function stateDir(cwd: string): string {
return join(cwd, RUN_STATE_DIRNAME);
}
/** Resolve the run-state file path for a given cwd. */
export function runStatePath(cwd: string): string {
return join(stateDir(cwd), RUN_STATE_FILENAME);
}
export type PhaseStatus =
| "pending"
| "in_progress"
| "complete"
| "failed"
| "skipped";
export type CheckStatus = PhaseStatus;
export type RunStatus = "in_progress" | "complete" | "failed" | "partial";
export interface PhaseEntry {
id: string;
status: PhaseStatus;
startedAt?: number;
finishedAt?: number;
error?: string;
}
export interface CheckRun {
name: string;
label: string;
status: CheckStatus;
fix: boolean;
phases: PhaseEntry[];
findings?: string;
changes?: string;
startedAt?: number;
finishedAt?: number;
error?: string;
}
export interface ReconState {
complete: boolean;
path: string;
finishedAt?: number;
}
export interface RunState {
version: 1;
cwd: string;
startedAt: number;
updatedAt: number;
status: RunStatus;
recon: ReconState;
checks: Record<string, CheckRun>;
}
/** Phase ids shared by every check run, in execution order. */
export const PHASE_RECON = "recon";
export const PHASE_ANALYSIS = "analysis";
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" };
}
/** Create the phase skeleton for a single check (analysis always runs; fix only when requested). */
export function phasesForCheck(fix: boolean): PhaseEntry[] {
const phases = [freshPhase(PHASE_RECON), freshPhase(PHASE_ANALYSIS)];
if (fix) phases.push(freshPhase(PHASE_FIX));
phases.push(freshPhase(PHASE_VERIFY), freshPhase(PHASE_CLEANUP));
return phases;
}
/** Initialize a fresh run state for `checkNames` (labels default to the name). */
export function initRunState(
cwd: string,
checks: Array<{ name: string; label: string; fix?: boolean }>,
): RunState {
const now = Date.now();
const state: RunState = {
version: 1,
cwd,
startedAt: now,
updatedAt: now,
status: "in_progress",
recon: { complete: false, path: join(stateDir(cwd), RECON_FILENAME) },
checks: {},
};
for (const c of checks) {
state.checks[c.name] = {
name: c.name,
label: c.label,
status: "pending",
fix: c.fix ?? false,
phases: phasesForCheck(c.fix ?? false),
startedAt: undefined,
};
}
return state;
}
/** Load run state for `cwd`. Returns `undefined` when none exists. */
export async function loadRunState(cwd: string): Promise<RunState | undefined> {
const path = runStatePath(cwd);
try {
const raw = await readFile(path, "utf8");
return JSON.parse(raw) as RunState;
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") return undefined;
throw err;
}
}
/** Persist run state, creating the state directory as needed. */
export async function saveRunState(state: RunState): Promise<void> {
const dir = stateDir(state.cwd);
await mkdir(dir, { recursive: true });
state.updatedAt = Date.now();
await writeFile(
runStatePath(state.cwd),
JSON.stringify(state, null, 2) + "\n",
"utf8",
);
}
/** Mark a phase's status (and optionally an error message). */
export function applyPhaseStatus(
state: RunState,
checkName: string,
phaseId: string,
status: PhaseStatus,
error?: string,
): void {
const check = state.checks[checkName];
if (!check) return;
const phase = check.phases.find((p) => p.id === phaseId);
if (!phase) return;
phase.status = status;
const now = Date.now();
if (status === "in_progress") {
phase.startedAt = now;
if (check.startedAt == null) check.startedAt = now;
check.status = "in_progress";
} else if (
status === "complete" ||
status === "failed" ||
status === "skipped"
) {
phase.finishedAt = now;
if (error) phase.error = error;
}
}
/**
* Reconcile a check's overall status from its phases and, when terminal,
* stamp `finishedAt`. Used after the cleanup phase resolves.
*/
export function markCheckStatus(
state: RunState,
checkName: string,
status: CheckStatus,
error?: string,
): void {
const check = state.checks[checkName];
if (!check) return;
check.status = status;
if (error) check.error = error;
if (status === "complete" || status === "failed" || status === "skipped") {
check.finishedAt = Date.now();
}
}
/** Record findings/changes text on a check. */
export function recordCheckOutput(
state: RunState,
checkName: string,
out: { findings?: string; changes?: string },
): void {
const check = state.checks[checkName];
if (!check) return;
if (out.findings !== undefined) check.findings = out.findings;
if (out.changes !== undefined) check.changes = out.changes;
}
/** Mark the overall run status. */
export function markRunStatus(state: RunState, status: RunStatus): void {
state.status = status;
state.updatedAt = Date.now();
}
/**
* Determine whether a check is terminal (shouldn't be re-dispatched unless a
* fresh run is forced). `pending`/`in_progress`/`failed` are resumable.
*/
export function isCheckTerminal(check: CheckRun): boolean {
return check.status === "complete" || check.status === "skipped";
}
/**
* Re-dispatch predicate for `/pygienium-resume`: a check runs on resume when it
* is not terminal, OR when `--fresh` forced a re-dispatch of everything.
*/
export function shouldRunOnResume(check: CheckRun, fresh: boolean): boolean {
return fresh || !isCheckTerminal(check);
}
/**
* Reset a single check entry back to `pending` with fresh phases. Used by
* `/pygienium-resume --fresh` so that previously-complete checks are
* re-dispatched from scratch. Preserves `label`/`fix` from the existing entry
* unless overridden.
*/
export function resetCheckEntry(
state: RunState,
name: string,
fixOverride?: boolean,
): void {
const existing = state.checks[name];
const fix = fixOverride ?? existing?.fix ?? false;
state.checks[name] = {
name,
label: existing?.label ?? name,
status: "pending",
fix,
phases: phasesForCheck(fix),
startedAt: undefined,
finishedAt: undefined,
findings: undefined,
changes: undefined,
error: undefined,
};
}
/**
* Compute the next check to run when resuming: the first `in_progress` check,
* else the first pending/failed check. Returns `undefined` when nothing remains.
*/
/**
* 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.
*/
export function reconcileRunStatus(state: RunState): RunStatus {
const checks = Object.values(state.checks);
if (checks.length === 0) return "in_progress";
let anyFailed = false;
let anySkipped = false;
for (const c of checks) {
if (c.status === "pending" || c.status === "in_progress")
return "in_progress";
if (c.status === "failed") anyFailed = true;
if (c.status === "skipped") anySkipped = true;
}
if (anyFailed) return "partial";
if (anySkipped) return "partial";
return "complete";
}

108
src/status.ts Normal file
View File

@@ -0,0 +1,108 @@
/**
* status.ts — readable run-state formatter.
*
* {@link formatRunStatus} turns a `RunState` into a plain line list covering
* the run-level summary and one block per registered check: overall status, the
* per-phase breakdown, captured findings/changes artifacts, and any errors.
* It is a *pure* function of state — no disk I/O — so it is trivially
* unit-testable and deterministic; the in-memory run-state is the single source
* of truth for progress (the check-runner records findings/changes text on it).
*
* `formatRunStatus` is the single helper the `/pygienium-status` command uses;
* keeping it here (out of `commands.ts`) lets `commands.ts` stay a thin binder.
*
* @module pygienium/status
*/
import type { CheckRun, RunState } from "./run-state.js";
const PHASE_ORDER = ["recon", "analysis", "fix", "verify", "cleanup"] as const;
function toISO(ms: number | undefined): string {
return ms == null ? "—" : new Date(ms).toISOString();
}
function short(status: string): string {
return status[0]?.toUpperCase() ?? "?";
}
/** Count non-empty lines in captured findings/changes text. */
function lineCount(text: string | undefined): number {
if (!text) return 0;
const count = text.split(/\r?\n/).filter((l) => l.trim().length > 0).length;
return count;
}
/**
* Format a single check block (without a trailing separator) — exposed so tests
* and the status command share one rendering path.
*/
export function formatCheckBlock(check: CheckRun, indent = " "): string[] {
const lines: string[] = [];
const flag = check.fix ? " (--fix)" : "";
lines.push(`${indent}${check.name}${check.status}${flag}`);
const phaseSummary = check.phases
.map((p) => `${p.id}:${p.status.startsWith("in_progress") ? "…" : short(p.status)}`)
.join(" ");
if (phaseSummary) lines.push(`${indent} phases: ${phaseSummary}`);
const findingsLines = lineCount(check.findings);
if (findingsLines > 0) {
lines.push(`${indent} findings: ${findingsLines} line(s)`);
}
const changesLines = lineCount(check.changes);
if (changesLines > 0) {
lines.push(`${indent} changes: ${changesLines} line(s)`);
}
if (check.error) {
lines.push(`${indent} error: ${check.error}`);
}
for (const phase of check.phases) {
if (phase.status === "failed" && phase.error && phase.error !== check.error) {
lines.push(`${indent} ${phase.id}: ${phase.error}`);
}
}
return lines;
}
/**
* Build the `/pygienium-status` line list for a run state. Pure: no disk reads.
* Layout:
*
* pygienium run — <status>
* started: <iso>
* updated: <iso>
* cwd: <cwd>
* recon: complete|pending
*
* checks (N):
* <name> — <status>
* phases: recon:✓ analysis:✓ [fix:✓] verify:✓ cleanup:✓
* findings: <N> line(s)
* changes: <N> line(s)
* error: <msg>
*/
export function formatRunStatus(state: RunState): string[] {
const checks = Object.values(state.checks);
const lines: string[] = [];
lines.push(`pygienium run — ${state.status}`);
lines.push(` started: ${toISO(state.startedAt)}`);
lines.push(` updated: ${toISO(state.updatedAt)}`);
lines.push(` cwd: ${state.cwd}`);
const reconLabel = state.recon.complete ? "complete" : "pending";
const reconTime = state.recon.finishedAt ? ` (${toISO(state.recon.finishedAt)})` : "";
lines.push(` recon: ${reconLabel}${reconTime}`);
lines.push("");
lines.push(` checks (${checks.length}):`);
if (checks.length === 0) {
lines.push(" (none registered in this run state)");
}
for (const check of checks) {
lines.push(...formatCheckBlock(check));
}
return lines;
}