From 93fed035f64ff1baae38ec0d12dbdedf6ad9a414 Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 12 Aug 2026 00:58:12 +0200 Subject: [PATCH] feat(coding-agent/cleanse): introduced discovery subagent and tui flows - Added discovery subagent and custom checker specifications for alternative toolings across multiple languages. - Implemented interactive TUI flows for target picking and free-form request discovery. - Added output parsers for popular static analysis and linting tools. - Increased default maximum subagents cap and documented the new capabilities. --- packages/coding-agent/CHANGELOG.md | 7 + packages/coding-agent/src/cleanse/agent.ts | 69 ++++- packages/coding-agent/src/cleanse/checkers.ts | 273 ++++++++++++++++-- packages/coding-agent/src/cleanse/index.ts | 122 +++++--- packages/coding-agent/src/cleanse/parsers.ts | 263 +++++++++++++++-- .../src/cleanse/prompts/discovery.md | 74 +++++ .../coding-agent/src/cli/cleanse-picker.ts | 86 ++++++ packages/coding-agent/src/commands/cleanse.ts | 23 +- packages/coding-agent/test/cleanse.test.ts | 207 ++++++++++++- 9 files changed, 1037 insertions(+), 87 deletions(-) create mode 100644 packages/coding-agent/src/cleanse/prompts/discovery.md create mode 100644 packages/coding-agent/src/cli/cleanse-picker.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 02644b5c1..cbca5579b 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -6,6 +6,13 @@ - Added `omp compress`, a command that rewrites a text file into the dense prompt register through a two-tool agent loop: the agent submits a draft with `rewrite` plus every loss it accepted, the command replies with the measured size and that loss list and asks for a verdict, and only an `approve` on a reviewed draft is written. Reports go to stderr and the approved text to stdout, so `omp compress f.md > out.md` yields just the compressed text; `-o` writes a file, `-i` rewrites in place. The session is deliberately sealed — the default system prompt is replaced rather than appended, and skills, rules, `AGENTS.md` context files, prompt templates, slash commands, extensions, MCP, IRC, and LSP are all disabled — and the source document is quoted as nonce-delimited inert data so the directives it contains are compressed instead of obeyed. - `omp compress` accepts multiple files and glob patterns, compresses up to `-n` of them concurrently (default 4, one isolated session each), and renders the same TTY completion bar as `omp cleanse`; multi-file runs require `-i` since one `--out` cannot hold many files, and a file that fails is reported without cancelling its peers. The bar itself moved to `src/cli/progress-reporter.ts` and is now shared with `omp cleanse` instead of duplicated. +- `omp cleanse` discovers far more tooling: staticcheck and golangci-lint for Go; mypy, pylint, flake8, ty, and basedpyright for Python; oxlint, `deno lint`, stylelint, and vue-tsc (preferred over tsc for roots containing `.vue` files) for the JS/TS ecosystem; plus actionlint for GitHub workflows. Alternative tools without a config marker (staticcheck, actionlint) are skipped silently when the binary is missing instead of cluttering the skip report. +- `omp cleanse ""` (e.g. `omp cleanse "ts errors"`) launches a discovery subagent that inspects the project, works out the exact command(s) and working directory for the request, verifies them by running them once, and maps their output onto a known parser (or gcc-style `generic`) before the normal detect→repair→verify loop runs. +- `omp cleanse` without arguments on a TTY now shows a picker: run all discovered checkers, run one specific checker, or describe what to fix (which routes through the discovery agent). `--all`/`-a` skips the picker; non-TTY runs keep the old run-everything behavior. + +### Changed + +- `omp cleanse` default subagent cap raised from 8 to 32 (`--agents`/`-n` still overrides). ## [17.2.14] - 2026-08-11 diff --git a/packages/coding-agent/src/cleanse/agent.ts b/packages/coding-agent/src/cleanse/agent.ts index 4e521d691..ca3816820 100644 --- a/packages/coding-agent/src/cleanse/agent.ts +++ b/packages/coding-agent/src/cleanse/agent.ts @@ -1,4 +1,4 @@ -import { getProjectDir, prompt } from "@oh-my-pi/pi-utils"; +import { getProjectDir, isRecord, prompt } from "@oh-my-pi/pi-utils"; import { ModelRegistry } from "../config/model-registry"; import { formatModelString, resolveCliModel } from "../config/model-resolver"; import { Settings } from "../config/settings"; @@ -9,7 +9,10 @@ import { mapWithConcurrencyLimitAllSettled } from "../task/parallel"; import { runStructuredSubagent } from "../task/structured-subagent"; import type { ToolSession } from "../tools"; import { EventBus } from "../utils/event-bus"; +import type { CustomCleanseCheckerSpec } from "./checkers"; +import { CLEANSE_PARSER_KINDS } from "./parsers"; import assignmentPrompt from "./prompts/assignment.md" with { type: "text" }; +import discoveryPrompt from "./prompts/discovery.md" with { type: "text" }; import type { CleanseAgentOutcome, CleanseAssignment, @@ -21,6 +24,28 @@ import type { const MAX_DIAGNOSTIC_MESSAGE = 4_000; +/** Structured output contract for the prompted checker-discovery agent. */ +const DISCOVERY_SCHEMA = { + type: "object", + required: ["checkers"], + properties: { + checkers: { + type: "array", + items: { + type: "object", + required: ["label", "command"], + properties: { + label: { type: "string" }, + language: { type: "string" }, + cwd: { type: "string" }, + command: { type: "array", items: { type: "string" }, minItems: 1 }, + parser: { type: "string", enum: [...CLEANSE_PARSER_KINDS] }, + }, + }, + }, + }, +}; + /** Hooks used by the standalone command to render subagent lifecycle progress. */ export interface CleanseAgentHooks { onStart?(name: string, assignment: CleanseAssignment): void; @@ -31,6 +56,8 @@ export interface CleanseAgentHooks { export interface CleanseAgentRuntime { readonly model: string; readonly sessionFile: string; + /** Run one discovery subagent that translates a user request into runnable checker specs. */ + discoverCheckers(request: string, signal?: AbortSignal): Promise; dispatch( assignments: CleanseAssignment[], wave: number, @@ -80,7 +107,7 @@ export async function createCleanseAgentRuntime(options: { getArtifactsDir: () => sessionManager.getArtifactsDir(), getArtifactManager: () => sessionManager.getArtifactManager(), getAgentId: () => MAIN_AGENT_ID, - getSessionSpawns: () => "sonic", + getSessionSpawns: () => "sonic,task", getModelString: () => modelSelector, getActiveModelString: () => modelSelector, getActiveModel: () => resolved.model, @@ -94,6 +121,23 @@ export async function createCleanseAgentRuntime(options: { return { model: modelDisplay, sessionFile, + async discoverCheckers(request: string, signal?: AbortSignal): Promise { + sessionManager.appendCustomEntry("cleanse_discovery", { request }); + const result = await runStructuredSubagent({ + session: toolSession, + invocationKind: "task", + assignment: prompt.render(discoveryPrompt, { request }), + agent: "task", + model: modelSelector, + outputSchema: DISCOVERY_SCHEMA, + identity: { label: "CleanseDiscovery" }, + enableLsp: true, + enableIrc: false, + signal, + }); + if (result.result.error) throw new Error(`Checker discovery failed: ${result.result.error}`); + return parseDiscoverySpecs(result.result.structuredOutput?.data); + }, async dispatch( assignments: CleanseAssignment[], wave: number, @@ -166,6 +210,27 @@ export async function createCleanseAgentRuntime(options: { }; } +/** Defensively validate discovery-agent output into runnable checker specs. */ +function parseDiscoverySpecs(data: unknown): CustomCleanseCheckerSpec[] { + if (!isRecord(data) || !Array.isArray(data.checkers)) return []; + const specs: CustomCleanseCheckerSpec[] = []; + for (const value of data.checkers) { + if (!isRecord(value)) continue; + const command = Array.isArray(value.command) + ? value.command.filter((part): part is string => typeof part === "string" && part.length > 0) + : []; + if (typeof value.label !== "string" || !value.label.trim() || command.length === 0) continue; + specs.push({ + label: value.label.trim(), + language: typeof value.language === "string" ? value.language : undefined, + cwd: typeof value.cwd === "string" ? value.cwd : undefined, + command, + parser: typeof value.parser === "string" ? value.parser : undefined, + }); + } + return specs; +} + function renderAssignment( assignment: CleanseAssignment, allAssignments: readonly CleanseAssignment[], diff --git a/packages/coding-agent/src/cleanse/checkers.ts b/packages/coding-agent/src/cleanse/checkers.ts index 0095093b1..7699fd7ea 100644 --- a/packages/coding-agent/src/cleanse/checkers.ts +++ b/packages/coding-agent/src/cleanse/checkers.ts @@ -2,7 +2,7 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; import { $which, isRecord, ptree, sanitizeText } from "@oh-my-pi/pi-utils"; import * as git from "../utils/git"; -import { type CleanseParserKind, parseCleanseDiagnostics } from "./parsers"; +import { CLEANSE_PARSER_KINDS, type CleanseParserKind, parseCleanseDiagnostics } from "./parsers"; import type { CleanseCheckResult, CleanseDiagnostic, CleanseDiagnosticReport, SkippedCleanseCheck } from "./types"; const IGNORED_DIRECTORIES: Record = { @@ -43,6 +43,8 @@ interface PlanRequest { parser: CleanseParserKind; executable?: string; mutates?: boolean; + /** Skip silently instead of recording a skip when the binary is missing (alternative toolings). */ + optional?: boolean; } interface DiscoveryState { @@ -58,10 +60,21 @@ export interface CleanseDiagnosticSuiteOptions { includeTests?: boolean; } +/** Identity and display metadata for one runnable checker. */ +export interface CleanseCheckerDescriptor { + id: string; + label: string; + language: string; + command: string; +} + /** Re-runnable checker set discovered from one project snapshot. */ export interface CleanseDiagnosticSuite { - readonly checkCount: number; + /** Every discovered checker; unaffected by {@link CleanseDiagnosticSuite.select}. */ + readonly checkers: readonly CleanseCheckerDescriptor[]; readonly skipped: readonly SkippedCleanseCheck[]; + /** Narrow subsequent {@link CleanseDiagnosticSuite.run} calls to the named checker ids. */ + select(ids: readonly string[]): void; run(signal?: AbortSignal): Promise; } @@ -81,7 +94,7 @@ export async function discoverCleanseDiagnosticSuite( }; await discoverRust(state); discoverGo(state); - discoverPython(state); + await discoverPython(state); await discoverJavaScript(state); discoverRuby(state); discoverPhp(state); @@ -96,29 +109,109 @@ export async function discoverCleanseDiagnosticSuite( discoverDotnet(state); discoverZig(state); discoverJvm(state); + discoverGitHubActions(state); - const allowedFiles = new Set(state.files); + return createSuite(resolvedCwd, state.plans, state.skipped, new Set(state.files)); +} + +function createSuite( + projectCwd: string, + plans: readonly CheckerPlan[], + skipped: SkippedCleanseCheck[], + allowedFiles: ReadonlySet, +): CleanseDiagnosticSuite { + let active = [...plans]; return { - checkCount: state.plans.length, - skipped: state.skipped, + checkers: plans.map(plan => ({ id: plan.id, label: plan.label, language: plan.language, command: plan.command })), + skipped, + select(ids: readonly string[]): void { + const wanted = new Set(ids); + active = plans.filter(plan => wanted.has(plan.id)); + }, async run(signal?: AbortSignal): Promise { const mutatingChecks: CleanseCheckResult[] = []; - for (const plan of state.plans) { - if (plan.mutates) mutatingChecks.push(await runChecker(plan, resolvedCwd, allowedFiles, signal)); + for (const plan of active) { + if (plan.mutates) mutatingChecks.push(await runChecker(plan, projectCwd, allowedFiles, signal)); } const parallelChecks = await Promise.all( - state.plans.filter(plan => !plan.mutates).map(plan => runChecker(plan, resolvedCwd, allowedFiles, signal)), + active.filter(plan => !plan.mutates).map(plan => runChecker(plan, projectCwd, allowedFiles, signal)), ); const checks = [...mutatingChecks, ...parallelChecks]; return { checks, diagnostics: deduplicateProjectDiagnostics(checks.flatMap(check => check.diagnostics)), - skipped: [...state.skipped], + skipped: [...skipped], }; }, }; } +/** One checker proposed by the prompted discovery agent. */ +export interface CustomCleanseCheckerSpec { + label: string; + language?: string; + cwd?: string; + command: string[]; + parser?: string; +} + +/** Build a runnable suite from discovery-agent checker specs, dropping unrunnable entries into `skipped`. */ +export async function buildCustomCleanseSuite( + projectCwd: string, + specs: readonly CustomCleanseCheckerSpec[], +): Promise { + const absoluteCwd = path.resolve(projectCwd); + const resolvedCwd = await fs.realpath(absoluteCwd).catch(() => absoluteCwd); + const files = await listProjectFiles(resolvedCwd); + const plans: CheckerPlan[] = []; + const skipped: SkippedCleanseCheck[] = []; + for (const [index, spec] of specs.entries()) { + const [binary, ...args] = spec.command; + const label = spec.label.trim() || `custom checker ${index + 1}`; + const language = spec.language?.trim() || "Custom"; + if (!binary) { + skipped.push({ label, language, reason: "empty command" }); + continue; + } + const root = normalizeCustomRoot(resolvedCwd, spec.cwd); + if (root === undefined) { + skipped.push({ label, language, reason: `working directory escapes the project: ${spec.cwd}` }); + continue; + } + let executable: string | undefined; + if (/[\\/]/.test(binary)) { + const candidate = path.resolve(resolvedCwd, root, binary); + executable = (await Bun.file(candidate).exists()) ? candidate : undefined; + } else { + executable = resolveBinary(resolvedCwd, root, [binary]); + } + if (!executable) { + skipped.push({ label, language, reason: `executable not found: ${binary}` }); + continue; + } + plans.push({ + id: `custom-${index + 1}`, + label, + language, + cwd: path.resolve(resolvedCwd, root), + executable, + args, + parser: CLEANSE_PARSER_KINDS.find(kind => kind === spec.parser) ?? "generic", + command: formatCommand([path.basename(executable), ...args]), + mutates: false, + }); + } + return createSuite(resolvedCwd, plans, skipped, new Set(files)); +} + +function normalizeCustomRoot(projectCwd: string, cwd: string | undefined): string | undefined { + const trimmed = cwd?.trim(); + if (!trimmed || trimmed === ".") return "."; + const relative = path.relative(projectCwd, path.resolve(projectCwd, trimmed)); + if (relative.startsWith("..") || path.isAbsolute(relative)) return undefined; + return relative.split(path.sep).join("/") || "."; +} + async function listProjectFiles(cwd: string): Promise { try { const [tracked, untracked] = await Promise.all([git.ls.files(cwd), git.ls.untracked(cwd)]); @@ -187,8 +280,8 @@ function rootsForPattern(files: readonly string[], pattern: RegExp): string[] { return markerRoots(files, file => pattern.test(file)); } -function resolveBinary(state: DiscoveryState, root: string, names: readonly string[]): string | undefined { - const cwd = path.resolve(state.projectCwd, root); +function resolveBinary(projectCwd: string, root: string, names: readonly string[]): string | undefined { + const cwd = path.resolve(projectCwd, root); const searchDirectories: string[] = []; let current = cwd; while (true) { @@ -198,10 +291,9 @@ function resolveBinary(state: DiscoveryState, root: string, names: readonly stri path.join(current, "venv", process.platform === "win32" ? "Scripts" : "bin"), path.join(current, "vendor", "bin"), ); - if (current === state.projectCwd) break; + if (current === projectCwd) break; const parent = path.dirname(current); - if (parent === current || (!parent.startsWith(`${state.projectCwd}${path.sep}`) && parent !== state.projectCwd)) - break; + if (parent === current || (!parent.startsWith(`${projectCwd}${path.sep}`) && parent !== projectCwd)) break; current = parent; } const searchPath = [...new Set(searchDirectories), Bun.env.PATH ?? ""].filter(Boolean).join(path.delimiter); @@ -214,9 +306,10 @@ function resolveBinary(state: DiscoveryState, root: string, names: readonly stri function addPlan(state: DiscoveryState, request: PlanRequest): void { const cwd = path.resolve(state.projectCwd, request.root); - const executable = request.executable ?? resolveBinary(state, request.root, request.binaries); + const executable = request.executable ?? resolveBinary(state.projectCwd, request.root, request.binaries); const rootLabel = request.root === "." ? "." : request.root; if (!executable) { + if (request.optional) return; state.skipped.push({ label: `${request.label} (${rootLabel})`, language: request.language, @@ -244,7 +337,7 @@ function formatCommand(argv: readonly string[]): string { async function discoverRust(state: DiscoveryState): Promise { for (const root of rootsForBasenames(state.files, { "Cargo.toml": true })) { - const cargo = resolveBinary(state, root, ["cargo"]); + const cargo = resolveBinary(state.projectCwd, root, ["cargo"]); if (!cargo) { addPlan(state, { id: "clippy", @@ -363,6 +456,7 @@ function discoverGo(state: DiscoveryState): void { const workRoots = rootsForBasenames(state.files, { "go.work": true }); const roots = workRoots.length > 0 ? workRoots : rootsForBasenames(state.files, { "go.mod": true }); for (const root of roots) { + const prefix = root === "." ? "" : `${root}/`; addPlan(state, { id: "go-vet", label: "go vet", @@ -372,6 +466,16 @@ function discoverGo(state: DiscoveryState): void { args: ["vet", "-json", "./..."], parser: "go", }); + addPlan(state, { + id: "staticcheck", + label: "staticcheck", + language: "Go", + root, + binaries: ["staticcheck"], + args: ["-f", "json", "./..."], + parser: "staticcheck", + optional: !state.files.includes(`${prefix}staticcheck.conf`), + }); if (state.includeTests) { addPlan(state, { id: "go-test", @@ -384,9 +488,20 @@ function discoverGo(state: DiscoveryState): void { }); } } + for (const root of rootsForPattern(state.files, /(?:^|\/)\.golangci\.(?:ya?ml|toml|json)$/)) { + addPlan(state, { + id: "golangci", + label: "golangci-lint", + language: "Go", + root, + binaries: ["golangci-lint"], + args: ["run"], + parser: "golangci", + }); + } } -function discoverPython(state: DiscoveryState): void { +async function discoverPython(state: DiscoveryState): Promise { if (!containsExtension(state.files, [".py", ".pyi"])) return; const roots = rootsForBasenames(state.files, { ".ruff.toml": true, @@ -423,11 +538,70 @@ function discoverPython(state: DiscoveryState): void { label: "pyright", language: "Python", root, - binaries: ["pyright"], + binaries: ["pyright", "basedpyright"], args: ["--outputjson"], parser: "pyright", }); } + const pyprojectRoots = rootsForBasenames(state.files, { "pyproject.toml": true }); + const pyprojectContent = new Map(); + for (const root of pyprojectRoots) { + const file = root === "." ? "pyproject.toml" : `${root}/pyproject.toml`; + pyprojectContent.set( + root, + await Bun.file(path.resolve(state.projectCwd, file)) + .text() + .catch(() => ""), + ); + } + const withSection = (section: string): string[] => + pyprojectRoots.filter(root => pyprojectContent.get(root)?.includes(section) === true); + const toolRoots = (basenames: Record, section: string): string[] => + topmostRoots([...rootsForBasenames(state.files, basenames), ...withSection(section)]); + for (const root of toolRoots({ "mypy.ini": true, ".mypy.ini": true }, "[tool.mypy")) { + addPlan(state, { + id: "mypy", + label: "mypy", + language: "Python", + root, + binaries: ["mypy"], + args: ["--no-error-summary", "--no-pretty", "."], + parser: "mypy", + }); + } + for (const root of toolRoots({ ".pylintrc": true, pylintrc: true }, "[tool.pylint")) { + addPlan(state, { + id: "pylint", + label: "pylint", + language: "Python", + root, + binaries: ["pylint"], + args: ["--output-format=json", "--recursive=y", "."], + parser: "pylint", + }); + } + for (const root of rootsForBasenames(state.files, { ".flake8": true })) { + addPlan(state, { + id: "flake8", + label: "flake8", + language: "Python", + root, + binaries: ["flake8"], + args: ["."], + parser: "flake8", + }); + } + for (const root of toolRoots({ "ty.toml": true }, "[tool.ty")) { + addPlan(state, { + id: "ty", + label: "ty check", + language: "Python", + root, + binaries: ["ty"], + args: ["check", "--output-format", "concise"], + parser: "ty", + }); + } } async function discoverJavaScript(state: DiscoveryState): Promise { @@ -457,16 +631,53 @@ async function discoverJavaScript(state: DiscoveryState): Promise { }); } for (const root of rootsForBasenames(state.files, { "tsconfig.json": true })) { + const prefix = root === "." ? "" : `${root}/`; + const hasVue = state.files.some(file => file.startsWith(prefix) && file.endsWith(".vue")); addPlan(state, { id: "typescript", - label: "TypeScript", + label: hasVue ? "Vue TypeScript" : "TypeScript", language: "TypeScript", root, - binaries: ["tsgo", "tsc"], + binaries: hasVue ? ["vue-tsc", "tsgo", "tsc"] : ["tsgo", "tsc"], args: ["--noEmit", "--pretty", "false"], parser: "generic", }); } + for (const root of rootsForBasenames(state.files, { ".oxlintrc.json": true })) { + addPlan(state, { + id: "oxlint", + label: "oxlint", + language: "JavaScript/TypeScript", + root, + binaries: ["oxlint"], + args: ["--format=unix"], + parser: "oxlint", + }); + } + for (const root of rootsForBasenames(state.files, { "deno.json": true, "deno.jsonc": true })) { + addPlan(state, { + id: "deno-lint", + label: "deno lint", + language: "JavaScript/TypeScript", + root, + binaries: ["deno"], + args: ["lint", "--json"], + parser: "deno-lint", + }); + } + const stylelintConfig = + /(?:^|\/)(?:\.stylelintrc(?:\.(?:json|ya?ml|js|cjs|mjs))?|stylelint\.config\.(?:js|cjs|mjs|ts))$/; + for (const root of rootsForPattern(state.files, stylelintConfig)) { + addPlan(state, { + id: "stylelint", + label: "stylelint", + language: "CSS", + root, + binaries: ["stylelint"], + args: ["**/*.{css,scss,sass,less}", "--formatter", "json", "--allow-empty-input"], + parser: "stylelint", + }); + } if (state.includeTests) await discoverPackageTests(state); } @@ -905,6 +1116,26 @@ function discoverJvm(state: DiscoveryState): void { } } +function discoverGitHubActions(state: DiscoveryState): void { + const roots = new Set(); + for (const file of state.files) { + const match = /^(?:(.*)\/)?\.github\/workflows\/[^/]+\.ya?ml$/.exec(file); + if (match) roots.add(match[1] ?? "."); + } + for (const root of topmostRoots([...roots])) { + addPlan(state, { + id: "actionlint", + label: "actionlint", + language: "GitHub Actions", + root, + binaries: ["actionlint"], + args: ["-format", "{{json .}}"], + parser: "actionlint", + optional: true, + }); + } +} + async function runChecker( plan: CheckerPlan, projectCwd: string, diff --git a/packages/coding-agent/src/cleanse/index.ts b/packages/coding-agent/src/cleanse/index.ts index acb3b39b3..edcd409e9 100644 --- a/packages/coding-agent/src/cleanse/index.ts +++ b/packages/coding-agent/src/cleanse/index.ts @@ -1,9 +1,10 @@ import { getProjectDir, sanitizeText } from "@oh-my-pi/pi-utils"; +import { pickCleanseTarget, promptCleanseRequest } from "../cli/cleanse-picker"; import { createProgressReporter } from "../cli/progress-reporter"; import { shortenPath } from "../tools/render-utils"; -import { type CleanseAgentRuntime, createCleanseAgentRuntime } from "./agent"; +import { type CleanseAgentHooks, type CleanseAgentRuntime, createCleanseAgentRuntime } from "./agent"; import { groupDiagnosticsByFile } from "./balance"; -import { discoverCleanseDiagnosticSuite } from "./checkers"; +import { buildCustomCleanseSuite, type CleanseDiagnosticSuite, discoverCleanseDiagnosticSuite } from "./checkers"; import { runCleanseLoop } from "./loop"; import type { CleanseAgentOutcome, CleanseAssignment, CleanseDiagnosticReport, CleanseLoopResult } from "./types"; @@ -15,6 +16,10 @@ export interface CleanseCommandOptions { maxAgents?: number; model?: string; includeTests?: boolean; + /** Free-form description handed to a discovery agent instead of built-in checker discovery. */ + request?: string; + /** Run every discovered checker without the interactive picker. */ + all?: boolean; } /** Observable completion state returned to the CLI adapter. */ @@ -27,7 +32,7 @@ export interface CleanseCommandResult { /** Detect project diagnostics, dispatch one bounded repair batch, and verify it. */ export async function runCleanseCommand(options: CleanseCommandOptions = {}): Promise { - const maxAgents = options.maxAgents ?? 8; + const maxAgents = options.maxAgents ?? 32; if (!Number.isInteger(maxAgents) || maxAgents <= 0) throw new Error("--agents must be a positive integer"); const model = options.model?.trim() || DEFAULT_MODEL; const cwd = getProjectDir(); @@ -45,15 +50,85 @@ export async function runCleanseCommand(options: CleanseCommandOptions = {}): Pr interactiveFailuresPrinted = true; for (const outcome of interactiveFailures) printAgentOutcome(outcome); }; + const hooks: CleanseAgentHooks = { + onStart(name, assignment) { + if (progress.interactive) return; + process.stdout.write(`[start] ${name}: ${formatAssignmentFiles(assignment)} (weight ${assignment.weight})\n`); + }, + onFinish(outcome) { + progress.complete(); + if (progress.interactive) { + if (!outcome.success) interactiveFailures.push(outcome); + return; + } + printAgentOutcome(outcome); + }, + }; + const ensureRuntime = async (): Promise => { + if (runtime) return runtime; + process.stdout.write(`Resolving model ${model}...\n`); + runtime = await createCleanseAgentRuntime({ cwd, model, hooks }); + process.stdout.write(`Model: ${runtime.model}\nSession: ${shortenPath(runtime.sessionFile)}\n`); + return runtime; + }; try { - process.stdout.write("Detecting configured project checkers...\n"); - const suite = await discoverCleanseDiagnosticSuite(cwd, { includeTests: options.includeTests }); - if (suite.checkCount === 0) { - const report: CleanseDiagnosticReport = { checks: [], diagnostics: [], skipped: [...suite.skipped] }; + let request = options.request?.trim() || undefined; + let suite: CleanseDiagnosticSuite | undefined; + if (!request) { + process.stdout.write("Detecting configured project checkers...\n"); + suite = await discoverCleanseDiagnosticSuite(cwd, { includeTests: options.includeTests }); + const interactive = options.all !== true && process.stdin.isTTY === true && process.stdout.isTTY === true; + if (interactive) { + if (suite.checkers.length > 0) { + const choice = await pickCleanseTarget(suite.checkers); + if (choice.kind === "cancel") { + process.stderr.write("Cleanse cancelled.\n"); + return { + exitCode: 130, + status: "cancelled", + report: { checks: [], diagnostics: [], skipped: [...suite.skipped] }, + }; + } + if (choice.kind === "checker") suite.select([choice.id]); + if (choice.kind === "request") { + request = choice.request; + suite = undefined; + } + } else { + printSkippedChecks({ checks: [], diagnostics: [], skipped: [...suite.skipped] }); + process.stdout.write("No supported checker with an available executable was found.\n"); + const answer = await promptCleanseRequest(); + if (answer === null) { + return { + exitCode: 1, + status: "unsupported", + report: { checks: [], diagnostics: [], skipped: [...suite.skipped] }, + }; + } + request = answer; + suite = undefined; + } + } + } + if (request) { + const activeRuntime = await ensureRuntime(); + process.stdout.write(`Discovering checkers for "${request}"...\n`); + const specs = await activeRuntime.discoverCheckers(request, abortController.signal); + suite = await buildCustomCleanseSuite(cwd, specs); + for (const checker of suite.checkers) { + process.stdout.write(`[checker] ${checker.label}: ${checker.command}\n`); + } + } + if (!suite || suite.checkers.length === 0) { + const report: CleanseDiagnosticReport = { checks: [], diagnostics: [], skipped: [...(suite?.skipped ?? [])] }; printSkippedChecks(report); - process.stderr.write("No supported checker with an available executable was found.\n"); - return { exitCode: 1, status: "unsupported", report }; + process.stderr.write( + request + ? "Checker discovery produced no runnable command.\n" + : "No supported checker with an available executable was found.\n", + ); + return { exitCode: 1, status: "unsupported", report, sessionFile: runtime?.sessionFile }; } const initialReport = await suite.run(abortController.signal); printCheckReport(initialReport); @@ -61,7 +136,7 @@ export async function runCleanseCommand(options: CleanseCommandOptions = {}): Pr process.stdout.write( `Clean: ${initialReport.checks.length} checker${initialReport.checks.length === 1 ? "" : "s"} passed.\n`, ); - return { exitCode: 0, status: "clean", report: initialReport }; + return { exitCode: 0, status: "clean", report: initialReport, sessionFile: runtime?.sessionFile }; } const assignments = groupDiagnosticsByFile(initialReport.diagnostics); @@ -70,33 +145,12 @@ export async function runCleanseCommand(options: CleanseCommandOptions = {}): Pr process.stdout.write( `Found ${initialReport.diagnostics.length} diagnostic${initialReport.diagnostics.length === 1 ? "" : "s"} across ${fileCount} file${fileCount === 1 ? "" : "s"}; launching ${agentCount} subagent${agentCount === 1 ? "" : "s"}.\n`, ); - process.stdout.write(`Resolving model ${model}...\n`); - const activeRuntime = await createCleanseAgentRuntime({ - cwd, - model, - hooks: { - onStart(name, assignment) { - if (progress.interactive) return; - process.stdout.write( - `[start] ${name}: ${formatAssignmentFiles(assignment)} (weight ${assignment.weight})\n`, - ); - }, - onFinish(outcome) { - progress.complete(); - if (progress.interactive) { - if (!outcome.success) interactiveFailures.push(outcome); - return; - } - printAgentOutcome(outcome); - }, - }, - }); - runtime = activeRuntime; - process.stdout.write(`Model: ${activeRuntime.model}\nSession: ${shortenPath(activeRuntime.sessionFile)}\n`); + const activeRuntime = await ensureRuntime(); + const activeSuite = suite; loopResult = await runCleanseLoop( { maxAgents, initialReport, signal: abortController.signal }, { - collect: signal => suite.run(signal), + collect: signal => activeSuite.run(signal), dispatch: (batch, wave, report, signal) => activeRuntime.dispatch(batch, wave, report, signal), onWave(_wave, batch) { process.stdout.write( diff --git a/packages/coding-agent/src/cleanse/parsers.ts b/packages/coding-agent/src/cleanse/parsers.ts index 290875d0f..7d9172bc4 100644 --- a/packages/coding-agent/src/cleanse/parsers.ts +++ b/packages/coding-agent/src/cleanse/parsers.ts @@ -3,26 +3,40 @@ import { isRecord, sanitizeText } from "@oh-my-pi/pi-utils"; import type { CleanseDiagnostic, CleanseSeverity } from "./types"; /** Machine and fallback output formats understood by cleanse. */ -export type CleanseParserKind = - | "rust" - | "rust-test" - | "go" - | "go-test" - | "ruff" - | "pyright" - | "eslint" - | "biome" - | "rubocop" - | "phpstan" - | "psalm" - | "swiftlint" - | "dart" - | "credo" - | "shellcheck" - | "hlint" - | "terraform" - | "tflint" - | "generic"; +export const CLEANSE_PARSER_KINDS = [ + "rust", + "rust-test", + "go", + "go-test", + "staticcheck", + "golangci", + "ruff", + "pyright", + "mypy", + "pylint", + "flake8", + "ty", + "eslint", + "biome", + "oxlint", + "deno-lint", + "stylelint", + "rubocop", + "phpstan", + "psalm", + "swiftlint", + "dart", + "credo", + "shellcheck", + "hlint", + "terraform", + "tflint", + "actionlint", + "generic", +] as const; + +/** One machine or fallback output format understood by cleanse. */ +export type CleanseParserKind = (typeof CLEANSE_PARSER_KINDS)[number]; /** Captured checker process output passed to a format parser. */ export interface CleanseParserInput { @@ -63,14 +77,32 @@ export function parseCleanseDiagnostics(kind: CleanseParserKind, input: CleanseP return parseGo(input); case "go-test": return parseGoTest(input); + case "staticcheck": + return parseStaticcheck(input); + case "golangci": + return parseGolangci(input); case "ruff": return parseRuff(input); case "pyright": return parsePyright(input); + case "mypy": + return parseGeneric(input); + case "pylint": + return parsePylint(input); + case "flake8": + return parseFlake8(input); + case "ty": + return parseTy(input); case "eslint": return parseEslint(input); case "biome": return parseBiome(input); + case "oxlint": + return parseUnixFormat(input); + case "deno-lint": + return parseDenoLint(input); + case "stylelint": + return parseStylelint(input); case "rubocop": return parseRubocop(input); case "phpstan": @@ -91,6 +123,8 @@ export function parseCleanseDiagnostics(kind: CleanseParserKind, input: CleanseP return parseTerraform(input); case "tflint": return parseTflint(input); + case "actionlint": + return parseActionlint(input); case "generic": return parseGeneric(input); } @@ -326,6 +360,44 @@ function parseGoTest(input: CleanseParserInput): CleanseDiagnostic[] { return diagnostics.length > 0 ? diagnostics : parseGeneric(input); } +function parseStaticcheck(input: CleanseParserInput): CleanseDiagnostic[] { + const diagnostics: CleanseDiagnostic[] = []; + for (const value of allJsonValues(input)) { + const record = toRecord(value); + const location = nestedRecord(record, "location"); + const end = nestedRecord(record, "end"); + addDiagnostic(diagnostics, input, { + file: stringField(location, "file"), + line: numberField(location, "line"), + column: numberField(location, "column"), + endLine: numberField(end, "line"), + endColumn: numberField(end, "column"), + code: stringField(record, "code"), + severity: stringField(record, "severity") ?? "warning", + message: stringField(record, "message"), + }); + } + return diagnostics; +} + +function parseGolangci(input: CleanseParserInput): CleanseDiagnostic[] { + const diagnostics: CleanseDiagnostic[] = []; + const text = sanitizeText(`${input.stdout}\n${input.stderr}`); + for (const line of text.split("\n")) { + const match = /^(.+?):(\d+):(\d+):\s+(.*?)\s+\(([A-Za-z0-9_-]+)\)$/.exec(line.trim()); + if (!match) continue; + addDiagnostic(diagnostics, input, { + file: match[1], + line: Number.parseInt(match[2], 10), + column: Number.parseInt(match[3], 10), + code: match[5], + severity: "warning", + message: match[4], + }); + } + return diagnostics; +} + function parseRuff(input: CleanseParserInput): CleanseDiagnostic[] { const diagnostics: CleanseDiagnostic[] = []; for (const root of allJsonValues(input)) { @@ -374,6 +446,62 @@ function parsePyright(input: CleanseParserInput): CleanseDiagnostic[] { return diagnostics; } +function parsePylint(input: CleanseParserInput): CleanseDiagnostic[] { + const diagnostics: CleanseDiagnostic[] = []; + for (const root of allJsonValues(input)) { + for (const value of toArray(root)) { + const record = toRecord(value); + addDiagnostic(diagnostics, input, { + file: stringField(record, "path"), + line: numberField(record, "line"), + column: zeroBased(numberField(record, "column")), + endLine: numberField(record, "endLine"), + endColumn: zeroBased(numberField(record, "endColumn")), + code: stringField(record, "symbol") ?? stringField(record, "message-id"), + severity: stringField(record, "type"), + message: stringField(record, "message"), + }); + } + } + return diagnostics; +} + +function parseFlake8(input: CleanseParserInput): CleanseDiagnostic[] { + const diagnostics: CleanseDiagnostic[] = []; + const text = sanitizeText(`${input.stdout}\n${input.stderr}`); + for (const line of text.split("\n")) { + const match = /^(.+?):(\d+):(\d+):\s+([A-Z]+\d+)\s+(.*)$/.exec(line.trim()); + if (!match) continue; + addDiagnostic(diagnostics, input, { + file: match[1], + line: Number.parseInt(match[2], 10), + column: Number.parseInt(match[3], 10), + code: match[4], + severity: match[4].startsWith("F") || match[4].startsWith("E9") ? "error" : "warning", + message: match[5], + }); + } + return diagnostics; +} + +function parseTy(input: CleanseParserInput): CleanseDiagnostic[] { + const diagnostics: CleanseDiagnostic[] = []; + const text = sanitizeText(`${input.stdout}\n${input.stderr}`); + for (const line of text.split("\n")) { + const match = /^(error|warning|info)\[([^\]]+)\]\s+(.+?):(\d+):(\d+):\s+(.*)$/.exec(line.trim()); + if (!match) continue; + addDiagnostic(diagnostics, input, { + file: match[3], + line: Number.parseInt(match[4], 10), + column: Number.parseInt(match[5], 10), + code: match[2], + severity: match[1], + message: match[6], + }); + } + return diagnostics; +} + function zeroBased(value: number | undefined): number | undefined { return value === undefined ? undefined : value + 1; } @@ -424,6 +552,83 @@ function parseBiome(input: CleanseParserInput): CleanseDiagnostic[] { return diagnostics; } +function parseUnixFormat(input: CleanseParserInput): CleanseDiagnostic[] { + const diagnostics: CleanseDiagnostic[] = []; + const text = sanitizeText(`${input.stdout}\n${input.stderr}`); + for (const line of text.split("\n")) { + const match = /^(.+?):(\d+):(\d+):\s+(.*?)\s+\[(Error|Warning)\/([^\]]+)\]$/i.exec(line.trim()); + if (!match) continue; + addDiagnostic(diagnostics, input, { + file: match[1], + line: Number.parseInt(match[2], 10), + column: Number.parseInt(match[3], 10), + code: match[6], + severity: match[5], + message: match[4], + }); + } + return diagnostics; +} + +function parseDenoLint(input: CleanseParserInput): CleanseDiagnostic[] { + const diagnostics: CleanseDiagnostic[] = []; + for (const root of allJsonValues(input)) { + const record = toRecord(root); + if (!record) continue; + for (const value of toArray(record.diagnostics)) { + const entry = toRecord(value); + const range = nestedRecord(entry, "range"); + const start = nestedRecord(range, "start"); + const end = nestedRecord(range, "end"); + addDiagnostic(diagnostics, input, { + file: stringField(entry, "filename"), + line: numberField(start, "line"), + column: zeroBased(numberField(start, "col")), + endLine: numberField(end, "line"), + endColumn: zeroBased(numberField(end, "col")), + code: stringField(entry, "code"), + severity: "warning", + message: stringField(entry, "message"), + suggestion: stringField(entry, "hint"), + }); + } + for (const value of toArray(record.errors)) { + const entry = toRecord(value); + addDiagnostic(diagnostics, input, { + file: stringField(entry, "file_path"), + severity: "error", + message: stringField(entry, "message"), + }); + } + } + return diagnostics; +} + +function parseStylelint(input: CleanseParserInput): CleanseDiagnostic[] { + const diagnostics: CleanseDiagnostic[] = []; + for (const root of allJsonValues(input)) { + for (const value of toArray(root)) { + const record = toRecord(value); + const source = stringField(record, "source"); + if (!source) continue; + for (const warning of toArray(record?.warnings)) { + const entry = toRecord(warning); + addDiagnostic(diagnostics, input, { + file: source, + line: numberField(entry, "line"), + column: numberField(entry, "column"), + endLine: numberField(entry, "endLine"), + endColumn: numberField(entry, "endColumn"), + code: stringField(entry, "rule"), + severity: stringField(entry, "severity"), + message: stringField(entry, "text"), + }); + } + } + } + return diagnostics; +} + function parseRubocop(input: CleanseParserInput): CleanseDiagnostic[] { const diagnostics: CleanseDiagnostic[] = []; for (const rootValue of allJsonValues(input)) { @@ -654,6 +859,24 @@ function parseTflint(input: CleanseParserInput): CleanseDiagnostic[] { return diagnostics; } +function parseActionlint(input: CleanseParserInput): CleanseDiagnostic[] { + const diagnostics: CleanseDiagnostic[] = []; + for (const root of allJsonValues(input)) { + for (const value of toArray(root)) { + const record = toRecord(value); + addDiagnostic(diagnostics, input, { + file: stringField(record, "filepath"), + line: numberField(record, "line"), + column: numberField(record, "column"), + code: stringField(record, "kind"), + severity: "error", + message: stringField(record, "message"), + }); + } + } + return diagnostics; +} + function parseGeneric(input: CleanseParserInput): CleanseDiagnostic[] { const diagnostics: CleanseDiagnostic[] = []; const text = sanitizeText(`${input.stdout}\n${input.stderr}`); diff --git a/packages/coding-agent/src/cleanse/prompts/discovery.md b/packages/coding-agent/src/cleanse/prompts/discovery.md new file mode 100644 index 000000000..46c7ebc28 --- /dev/null +++ b/packages/coding-agent/src/cleanse/prompts/discovery.md @@ -0,0 +1,74 @@ + +- You NEVER edit project files; this is a read-and-verify discovery task. +- You MUST verify every command you propose by running it once. +- Your final message MUST be exactly one JSON object matching the schema below — no prose, no code fences. + + +# Checker Discovery + +The user asked `omp cleanse` to detect and repair: **{{request}}** + +Determine which project command(s) surface exactly those diagnostics so the orchestrator can run them, parse their output, and dispatch repair agents. + + +1. Inspect the project (manifests, configs, lockfiles, scripts) to identify the relevant tooling. +2. Determine the exact command and working directory. Prefer project-local binaries (`node_modules/.bin`, `.venv/bin`, wrappers like `gradlew`) over global tools. +3. Prefer a machine-readable output flag matching a known parser below. Otherwise pick a format that prints gcc-style `file:line:col: severity: message` lines and use parser `generic`. +4. Run the command once to verify it executes and its output matches the chosen parser. A non-zero exit with parseable diagnostics is fine; a crash or usage error is not. +5. If several commands are needed to cover the request, return one entry per command. + + +## Known parsers + +| id | expected output | +|---|---| +| `rust` | `cargo … --message-format=json` | +| `rust-test` | `cargo test … --message-format=json` | +| `go` | `go vet -json` | +| `go-test` | `go test -json` | +| `staticcheck` | `staticcheck -f json` | +| `golangci` | golangci-lint default text output | +| `ruff` | `ruff check --output-format=json` | +| `pyright` | `pyright`/`basedpyright` `--outputjson` | +| `mypy` | mypy default text output | +| `pylint` | `pylint --output-format=json` | +| `flake8` | flake8 default text output | +| `ty` | `ty check --output-format concise` | +| `eslint` | `eslint --format=json` | +| `biome` | `biome check --reporter=json` | +| `oxlint` | unix-format lines `file:line:col: message [Error/rule]` (`--format=unix`) | +| `deno-lint` | `deno lint --json` | +| `stylelint` | `stylelint --formatter json` | +| `rubocop` | `rubocop --format json` | +| `phpstan` | `phpstan analyse --error-format=json` | +| `psalm` | `psalm --output-format=json` | +| `swiftlint` | `swiftlint lint --reporter json` | +| `dart` | `dart analyze --format machine` | +| `credo` | `mix credo --format=json` | +| `shellcheck` | `shellcheck --format=json1` | +| `hlint` | `hlint --json` | +| `terraform` | `terraform validate -json` | +| `tflint` | `tflint --format=json` | +| `actionlint` | actionlint with its JSON `-format` template | +| `generic` | gcc-style `file:line:col: severity: message` lines (tsc/tsgo `--pretty false`, mypy, clang, zig, MSVC-style) | + +## Output schema + +```json +{ + "checkers": [ + { + "label": "tsc (packages/app)", + "language": "TypeScript", + "cwd": "packages/app", + "command": ["tsc", "--noEmit", "--pretty", "false"], + "parser": "generic" + } + ] +} +``` + +- `command`: argv array; the first element is a binary name or project-relative path. Never wrap in a shell. +- `cwd`: project-relative working directory; omit for the project root. +- `parser`: one of the known parser ids; omit for `generic`. +- Return `"checkers": []` only when nothing in this project can produce the requested diagnostics. diff --git a/packages/coding-agent/src/cli/cleanse-picker.ts b/packages/coding-agent/src/cli/cleanse-picker.ts new file mode 100644 index 000000000..b81a76a89 --- /dev/null +++ b/packages/coding-agent/src/cli/cleanse-picker.ts @@ -0,0 +1,86 @@ +/** + * Standalone TUI pickers for `omp cleanse`. + * + * Mirrors {@link ./setup-model-picker.ts}: one-shot {@link TUI} instances over a + * {@link SelectList} or {@link Input}, resolved on select/submit/cancel and torn + * down immediately so the command can keep writing plain stdout afterwards. + */ +import { Input, ProcessTerminal, type SelectItem, SelectList, TUI } from "@oh-my-pi/pi-tui"; +import type { CleanseCheckerDescriptor } from "../cleanse/checkers"; +import { getSelectListTheme } from "../modes/theme/theme"; + +/** Outcome of the interactive cleanse target picker. */ +export type CleanseTargetChoice = + | { kind: "all" } + | { kind: "checker"; id: string } + | { kind: "request"; request: string } + | { kind: "cancel" }; + +/** Pick between running every discovered checker, one specific checker, or a free-form request. */ +export async function pickCleanseTarget(checkers: readonly CleanseCheckerDescriptor[]): Promise { + const items: SelectItem[] = [ + { + value: "all", + label: `Run all ${checkers.length} discovered checker${checkers.length === 1 ? "" : "s"}`, + }, + ...checkers.map(checker => ({ + value: `checker:${checker.id}`, + label: checker.label, + description: `${checker.language} — ${checker.command}`, + })), + { + value: "request", + label: "Describe what to fix…", + description: "A discovery agent figures out the command to run", + }, + ]; + const selection = await selectOne("Select what to cleanse:", items); + if (selection === null) return { kind: "cancel" }; + if (selection === "all") return { kind: "all" }; + if (selection === "request") { + const request = await promptCleanseRequest(); + return request === null ? { kind: "cancel" } : { kind: "request", request }; + } + return { kind: "checker", id: selection.slice("checker:".length) }; +} + +/** One-shot text prompt for a free-form cleanse request; `null` when cancelled or left empty. */ +export async function promptCleanseRequest(): Promise { + const { promise, resolve } = Promise.withResolvers(); + const ui = new TUI(new ProcessTerminal()); + let resolved = false; + const finish = (value: string | null): void => { + if (resolved) return; + resolved = true; + ui.stop(); + resolve(value); + }; + const input = new Input(); + input.onSubmit = value => finish(value.trim() || null); + input.onEscape = () => finish(null); + process.stdout.write('Describe what to detect and fix (e.g. "ts errors"):\n'); + ui.addChild(input); + ui.setFocus(input); + ui.start(); + return promise; +} + +async function selectOne(title: string, items: SelectItem[]): Promise { + const { promise, resolve } = Promise.withResolvers(); + const ui = new TUI(new ProcessTerminal()); + let resolved = false; + const finish = (value: string | null): void => { + if (resolved) return; + resolved = true; + ui.stop(); + resolve(value); + }; + const list = new SelectList(items, Math.min(items.length, 12), getSelectListTheme()); + list.onSelect = item => finish(item.value); + list.onCancel = () => finish(null); + process.stdout.write(`${title}\n`); + ui.addChild(list); + ui.setFocus(list); + ui.start(); + return promise; +} diff --git a/packages/coding-agent/src/commands/cleanse.ts b/packages/coding-agent/src/commands/cleanse.ts index cb600a699..205c19d97 100644 --- a/packages/coding-agent/src/commands/cleanse.ts +++ b/packages/coding-agent/src/commands/cleanse.ts @@ -1,16 +1,22 @@ import { postmortem } from "@oh-my-pi/pi-utils"; -import { Command, Flags } from "@oh-my-pi/pi-utils/cli"; +import { Args, Command, Flags } from "@oh-my-pi/pi-utils/cli"; import { runCleanseCommand } from "../cleanse"; import { cleanseHelp as commandHelp } from "../cli/command-help"; import { CliUsageError } from "../cli/usage-error"; export default class Cleanse extends Command { static description = commandHelp.description; + static args = { + request: Args.string({ + description: 'What to detect and fix (e.g. "ts errors"); a discovery agent works out the command', + required: false, + }), + }; static flags = { agents: Flags.integer({ char: "n", description: "Maximum number of file-disjoint subagents", - default: 8, + default: 32, }), model: Flags.string({ char: "m", @@ -22,23 +28,32 @@ export default class Cleanse extends Command { description: "Also run configured project test suites", default: false, }), + all: Flags.boolean({ + char: "a", + description: "Run every discovered checker without the interactive picker", + default: false, + }), }; static examples = [ "omp cleanse", - "omp cleanse -n 4", + "omp cleanse --all", + 'omp cleanse "ts errors"', + "omp cleanse -n 8", "omp cleanse -m opus", "omp cleanse -t", "omp cleanse --agents 12 --model anthropic/claude-opus-4-6", ]; async run(): Promise { - const { flags } = await this.parse(Cleanse); + const { args, flags } = await this.parse(Cleanse); if (flags.agents <= 0) throw new CliUsageError("--agents must be a positive integer"); const result = await runCleanseCommand({ maxAgents: flags.agents, model: flags.model, includeTests: flags.tests, + request: args.request, + all: flags.all, }); await postmortem.quit(result.exitCode); } diff --git a/packages/coding-agent/test/cleanse.test.ts b/packages/coding-agent/test/cleanse.test.ts index 2a30780b5..c606e2942 100644 --- a/packages/coding-agent/test/cleanse.test.ts +++ b/packages/coding-agent/test/cleanse.test.ts @@ -7,7 +7,7 @@ import { balanceDiagnostics } from "@oh-my-pi/pi-coding-agent/cleanse/balance"; import * as cleanseCheckers from "@oh-my-pi/pi-coding-agent/cleanse/checkers"; import { runCleanseCommand } from "@oh-my-pi/pi-coding-agent/cleanse/index"; import { runCleanseLoop } from "@oh-my-pi/pi-coding-agent/cleanse/loop"; -import { parseCleanseDiagnostics } from "@oh-my-pi/pi-coding-agent/cleanse/parsers"; +import { type CleanseParserKind, parseCleanseDiagnostics } from "@oh-my-pi/pi-coding-agent/cleanse/parsers"; import type { CleanseAgentOutcome, CleanseDiagnostic, @@ -99,8 +99,8 @@ describe("cleanse diagnostics", () => { const withTests = await cleanseCheckers.discoverCleanseDiagnosticSuite(root, { includeTests: true }); const report = await withTests.run(); - expect(withoutTests.checkCount).toBe(0); - expect(withTests.checkCount).toBe(1); + expect(withoutTests.checkers).toHaveLength(0); + expect(withTests.checkers).toHaveLength(1); expect(report.checks[0]).toMatchObject({ label: "bun test (.)", exitCode: 3, @@ -122,7 +122,7 @@ describe("cleanse diagnostics", () => { const suite = await cleanseCheckers.discoverCleanseDiagnosticSuite(root, { includeTests: true }); - expect(suite.checkCount).toBe(0); + expect(suite.checkers).toHaveLength(0); } finally { await fs.rm(root, { recursive: true, force: true }); } @@ -169,8 +169,9 @@ describe("cleanse progress", () => { const clean = report([]); let runCount = 0; const suite: cleanseCheckers.CleanseDiagnosticSuite = { - checkCount: 1, + checkers: [{ id: "mock", label: "mock", language: "Test", command: "mock" }], skipped: [], + select() {}, async run() { runCount += 1; return runCount === 1 ? initial : clean; @@ -180,6 +181,9 @@ describe("cleanse progress", () => { const runtime: cleanseAgent.CleanseAgentRuntime = { model: "test/model", sessionFile: "/tmp/cleanse.jsonl", + async discoverCheckers() { + return []; + }, async dispatch(assignments) { return assignments.map((assignment, index) => { const name = `CleanseW1A${index + 1}`; @@ -198,7 +202,7 @@ describe("cleanse progress", () => { }); try { - const result = await runCleanseCommand({ maxAgents: 2 }); + const result = await runCleanseCommand({ maxAgents: 2, all: true }); expect(result.status).toBe("clean"); const updates = output.filter(chunk => chunk.startsWith("\rRepairing [")); @@ -292,6 +296,197 @@ describe("cleanse orchestration", () => { }); }); +describe("cleanse alternative-tooling parsers", () => { + const parse = (kind: CleanseParserKind, stdout: string) => + parseCleanseDiagnostics(kind, { + checker: "checker", + projectCwd: "/repo", + checkerCwd: "/repo", + stdout, + stderr: "", + }); + + test("normalizes staticcheck JSON lines into project-relative diagnostics", () => { + const stdout = `{"code":"S1002","severity":"error","location":{"file":"/repo/main.go","line":5,"column":7},"end":{"line":5,"column":20},"message":"should omit comparison to bool constant"}`; + expect(parse("staticcheck", stdout)).toEqual([ + { + checker: "checker", + file: "main.go", + line: 5, + column: 7, + endLine: 5, + endColumn: 20, + code: "S1002", + severity: "error", + message: "should omit comparison to bool constant", + suggestion: undefined, + }, + ]); + }); + + test("parses golangci-lint text output with the linter name as code", () => { + const diagnostics = parse("golangci", "main.go:10:2: ineffectual assignment to err (ineffassign)\n"); + expect(diagnostics).toMatchObject([ + { file: "main.go", line: 10, column: 2, code: "ineffassign", severity: "warning" }, + ]); + }); + + test("converts pylint JSON zero-based columns to one-based", () => { + const stdout = JSON.stringify([ + { + type: "error", + path: "src/app.py", + line: 3, + column: 0, + endLine: 3, + endColumn: 10, + symbol: "undefined-variable", + "message-id": "E0602", + message: "Undefined variable 'x'", + }, + ]); + expect(parse("pylint", stdout)).toMatchObject([ + { file: "src/app.py", line: 3, column: 1, endColumn: 11, code: "undefined-variable", severity: "error" }, + ]); + }); + + test("classifies flake8 pyflakes codes as errors and style codes as warnings", () => { + const diagnostics = parse( + "flake8", + ["src/app.py:1:1: F401 'os' imported but unused", "src/app.py:2:80: W291 trailing whitespace"].join("\n"), + ); + expect(diagnostics).toMatchObject([ + { file: "src/app.py", line: 1, code: "F401", severity: "error" }, + { file: "src/app.py", line: 2, code: "W291", severity: "warning" }, + ]); + }); + + test("parses ty concise output", () => { + const diagnostics = parse("ty", "error[unresolved-import] src/main.py:1:8: Cannot resolve imported module\n"); + expect(diagnostics).toMatchObject([ + { file: "src/main.py", line: 1, column: 8, code: "unresolved-import", severity: "error" }, + ]); + }); + + test("parses oxlint unix-format lines with bracketed severity and rule", () => { + const diagnostics = parse( + "oxlint", + "src/index.ts:4:10: Variable 'x' is declared but never used. [Warning/no-unused-vars]\n", + ); + expect(diagnostics).toMatchObject([ + { file: "src/index.ts", line: 4, column: 10, code: "no-unused-vars", severity: "warning" }, + ]); + }); + + test("resolves deno lint file URLs and zero-based columns", () => { + const stdout = JSON.stringify({ + diagnostics: [ + { + filename: "file:///repo/mod.ts", + range: { start: { line: 2, col: 4 }, end: { line: 2, col: 9 } }, + code: "no-var", + message: "`var` keyword is not allowed.", + hint: "Use `let` or `const` instead.", + }, + ], + errors: [], + }); + expect(parse("deno-lint", stdout)).toMatchObject([ + { + file: "mod.ts", + line: 2, + column: 5, + code: "no-var", + severity: "warning", + suggestion: "Use `let` or `const` instead.", + }, + ]); + }); + + test("flattens stylelint per-file warnings", () => { + const stdout = JSON.stringify([ + { + source: "/repo/styles/site.css", + warnings: [ + { + line: 7, + column: 3, + rule: "color-no-invalid-hex", + severity: "error", + text: "Unexpected invalid hex color", + }, + ], + }, + ]); + expect(parse("stylelint", stdout)).toMatchObject([ + { file: "styles/site.css", line: 7, column: 3, code: "color-no-invalid-hex", severity: "error" }, + ]); + }); + + test("parses actionlint JSON output", () => { + const stdout = JSON.stringify([ + { + message: "shellcheck reported issue", + filepath: ".github/workflows/ci.yml", + line: 12, + column: 9, + kind: "shellcheck", + }, + ]); + expect(parse("actionlint", stdout)).toMatchObject([ + { file: ".github/workflows/ci.yml", line: 12, column: 9, code: "shellcheck", severity: "error" }, + ]); + }); +}); + +describe("cleanse custom suite", () => { + test("builds runnable plans from discovery specs and skips unrunnable ones", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "omp-cleanse-custom-")); + try { + await Bun.write(path.join(root, "src", "a.ts"), "export const value = 1;\n"); + const suite = await cleanseCheckers.buildCustomCleanseSuite(root, [ + { + label: "fake tsc", + language: "TypeScript", + command: ["bun", "-e", "console.log('src/a.ts:1:1: error: boom'); process.exit(1)"], + }, + { label: "missing tool", command: ["definitely-not-a-real-binary-xyz"] }, + { label: "escaping cwd", cwd: "../outside", command: ["bun", "-e", "1"] }, + ]); + + expect(suite.checkers).toMatchObject([{ id: "custom-1", label: "fake tsc", language: "TypeScript" }]); + expect(suite.skipped).toMatchObject([ + { label: "missing tool", reason: "executable not found: definitely-not-a-real-binary-xyz" }, + { label: "escaping cwd" }, + ]); + + const report = await suite.run(); + expect(report.checks).toHaveLength(1); + expect(report.diagnostics).toMatchObject([ + { checker: "fake tsc", file: "src/a.ts", line: 1, severity: "error", message: "boom" }, + ]); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); + + test("select() narrows which checkers a run executes", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "omp-cleanse-select-")); + try { + const suite = await cleanseCheckers.buildCustomCleanseSuite(root, [ + { label: "first", command: ["bun", "-e", "console.log('ok')"] }, + { label: "second", command: ["bun", "-e", "console.log('ok')"] }, + ]); + suite.select(["custom-2"]); + + const report = await suite.run(); + expect(report.checks.map(check => check.id)).toEqual(["custom-2"]); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); +}); + function fileDiagnostics(file: string, count: number): CleanseDiagnostic[] { return Array.from({ length: count }, (_, index) => ({ checker: "checker",