2 Commits

Author SHA1 Message Date
299f66c8f2 add AGENTS.md
All checks were successful
port-to-omp / port (push) Successful in 9s
2026-08-21 21:23:52 -04:00
bff187da20 fix: use current selected model
All checks were successful
port-to-omp / port (push) Successful in 10s
publish / publish (push) Successful in 14s
2026-08-21 21:18:15 -04:00
9 changed files with 100 additions and 7 deletions

35
AGENTS.md Normal file
View File

@@ -0,0 +1,35 @@
# AGENTS.md
Pygienium is a `pi` extension: code-hygiene checks that scan a target with isolated sub-agent sessions (analysis), optionally apply fixes, then verify artifacts. Sub-agents are defined as markdown in `agents/*.md` (YAML frontmatter: `name`, `allowedTools`; body = system prompt).
## Commands
- `bun run typecheck``tsc --noEmit` on `src/`
- `bun test` — full suite (bun test)
## Layout
- `src/checks/` — check definitions; self-register on import (`registerCheck`)
- `src/agent-runner.ts` — spawns sub-agent sessions; runner injectable (`setAgentRunner`), tests use `fakeAgentRunner` (no real model calls)
- `src/commands.ts` — slash-command handlers, exported for direct test invocation with a stub `PygieniumCtx`
- `tests/` — bun tests; stub ctx with `{ cwd, mode: "print", hasUI: false, ui: undefined } as PygieniumCtx`
- `port-to-omp.mjs` — regenerates the self-contained omp port (see commit process)
## Conventions
- Checks: `gate` (skip reason), `buildScanTask`, optional `buildFixTask`, optional `verify`. Scan tasks embed `!write`/`!echo` lines as deterministic fallback for the fake runner.
- Sub-agent sessions must be pinned to the invoking session's model: thread `ctx.model` through `PygieniumCtx` → run options → `AgentTaskOptions.model``createAgentSession({ model })`.
- Keep files modular; no compatibility shims.
## Commit process (IMPORTANT)
Commits are gated by the pre-commit hook (enable: `git config core.hooksPath .githooks`). It regenerates the omp port into a temp dir and typechecks it — exactly what CI runs. A failing hook blocks the commit; never bypass with `--no-verify`.
`port-to-omp.mjs` rewrites source via exact-string matchers (`FILE_RULES`). Editing these files **requires** keeping the matching ops in sync or the port regeneration throws and the commit is blocked:
- `src/index.ts` — removes `mode: ctx.mode` from the `pygieniumCtx` literal
- `src/commands.ts` — removes `mode` from the `PygieniumCtx` Pick; swaps `ctx.mode` for `ctx.hasUI` in `print`
- `src/agent-runner.ts` — swaps the SDK import + loader for the omp SDK's `createAgentSession` options (`toolNames`, `systemPrompt`, `agentRegistry`, …)
- `src/agents.ts``find``glob`
After touching any of those, run `bun port-to-omp.mjs --out <tmp>` and `(cd <tmp> && bun run typecheck)` before committing; the hook does the same.

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mikefreno/pygienium", "name": "@mikefreno/pygienium",
"version": "0.1.1", "version": "0.1.2",
"description": "Code hygiene extension for pi — isolated sub-agent checks that scan a target, apply fixes, and emit a findings+changes report.", "description": "Code hygiene extension for pi — isolated sub-agent checks that scan a target, apply fixes, and emit a findings+changes report.",
"keywords": [ "keywords": [
"pi-package", "pi-package",

View File

@@ -256,9 +256,9 @@ const FILE_RULES = {
{ from: '\t\tagent.allowedTools ?? ["read", "bash", "grep", "find"];', to: '\t\tagent.allowedTools ?? ["read", "bash", "grep", "glob"];', label: "default tools" }, { from: '\t\tagent.allowedTools ?? ["read", "bash", "grep", "find"];', to: '\t\tagent.allowedTools ?? ["read", "bash", "grep", "glob"];', label: "default tools" },
{ {
from: from:
"\tconst { session } = await createAgentSession({\n\t\tcwd: opts.cwd,\n\t\ttools,\n\t\tsessionManager: SessionManager.inMemory(opts.cwd),\n\t\tresourceLoader: loader,\n\t});", "\tconst { session } = await createAgentSession({\n\t\tcwd: opts.cwd,\n\t\t// Pin the sub-agent to the model the user has selected in the invoking\n\t\t// session rather than the settings default. Omitted when the caller\n\t\t// has no live session model (print/RPC modes), which keeps the\n\t\t// settings-default fallback.\n\t\t...(opts.model ? { model: opts.model } : {}),\n\t\ttools,\n\t\tsessionManager: SessionManager.inMemory(opts.cwd),\n\t\tresourceLoader: loader,\n\t});",
to: to:
"\tconst { session } = await createAgentSession({\n\t\tcwd: opts.cwd,\n\t\ttoolNames: tools,\n\t\t// `tools` is an allowlist, not a request list.\n\t\trestrictToolNames: true,\n\t\tsessionManager: SessionManager.inMemory(opts.cwd),\n\t\t// Replace the fully rendered default prompt with the agent body.\n\t\tsystemPrompt: agent.systemPrompt,\n\t\t// Keep the sub-agent isolated: no nested extensions/skills/prompts/etc.\n\t\tdisableExtensionDiscovery: true,\n\t\tskills: [],\n\t\tpromptTemplates: [],\n\t\trules: [],\n\t\tcontextFiles: [],\n\t\tenableMCP: false,\n\t\tenableLsp: false,\n\t\t// Private registry: the host session owns the process-global \"Main\"\n\t\t// identity, so a per-run registry keeps these in-process workers\n\t\t// disjoint from the main agent.\n\t\tagentRegistry: new AgentRegistry(),\n\t});", "\tconst { session } = await createAgentSession({\n\t\tcwd: opts.cwd,\n\t\t// Pin the sub-agent to the model the user has selected in the invoking\n\t\t// session rather than the settings default.\n\t\t...(opts.model ? { model: opts.model } : {}),\n\t\ttoolNames: tools,\n\t\t// `tools` is an allowlist, not a request list.\n\t\trestrictToolNames: true,\n\t\tsessionManager: SessionManager.inMemory(opts.cwd),\n\t\t// Replace the fully rendered default prompt with the agent body.\n\t\tsystemPrompt: agent.systemPrompt,\n\t\t// Keep the sub-agent isolated: no nested extensions/skills/prompts/etc.\n\t\tdisableExtensionDiscovery: true,\n\t\tskills: [],\n\t\tpromptTemplates: [],\n\t\trules: [],\n\t\tcontextFiles: [],\n\t\tenableMCP: false,\n\t\tenableLsp: false,\n\t\t// Private registry: the host session owns the process-global \"Main\"\n\t\t// identity, so a per-run registry keeps these in-process workers\n\t\t// disjoint from the main agent.\n\t\tagentRegistry: new AgentRegistry(),\n\t});",
label: "createAgentSession", label: "createAgentSession",
}, },
], ],

View File

@@ -20,6 +20,7 @@ import { dirname, isAbsolute, join } from "node:path";
import type { import type {
AgentSession, AgentSession,
AgentSessionEvent, AgentSessionEvent,
ExtensionCommandContext,
} from "@earendil-works/pi-coding-agent"; } from "@earendil-works/pi-coding-agent";
import { loadAgents, extensionRoot, type AgentDef } from "./agents.js"; import { loadAgents, extensionRoot, type AgentDef } from "./agents.js";
@@ -52,6 +53,11 @@ export interface AgentTaskOptions {
task: string; task: string;
/** Optional tool allowlist override (else uses the agent's `allowedTools`). */ /** Optional tool allowlist override (else uses the agent's `allowedTools`). */
allowedTools?: string[]; allowedTools?: string[];
/**
* The currently selected model from the invoking session. When omitted,
* `createAgentSession` falls back to the settings default model.
*/
model?: ExtensionCommandContext["model"];
/** Optional explicit agent definition (skips `loadAgents`). */ /** Optional explicit agent definition (skips `loadAgents`). */
agent?: AgentDef; agent?: AgentDef;
/** /**
@@ -208,6 +214,11 @@ export async function defaultAgentRunner(
const { session } = await createAgentSession({ const { session } = await createAgentSession({
cwd: opts.cwd, cwd: opts.cwd,
// Pin the sub-agent to the model the user has selected in the invoking
// session rather than the settings default. Omitted when the caller
// has no live session model (print/RPC modes), which keeps the
// settings-default fallback.
...(opts.model ? { model: opts.model } : {}),
tools, tools,
sessionManager: SessionManager.inMemory(opts.cwd), sessionManager: SessionManager.inMemory(opts.cwd),
resourceLoader: loader, resourceLoader: loader,

View File

@@ -48,7 +48,7 @@ import type { SendChatMessage } from "./phases.js";
/** Narrow context slice handlers need (a subset of `ExtensionCommandContext`). */ /** Narrow context slice handlers need (a subset of `ExtensionCommandContext`). */
export type PygieniumCtx = Pick< export type PygieniumCtx = Pick<
ExtensionCommandContext, ExtensionCommandContext,
"cwd" | "mode" | "hasUI" | "ui" "cwd" | "mode" | "hasUI" | "ui" | "model"
> & { > & {
/** Optional callback to post messages to the chat window. */ /** Optional callback to post messages to the chat window. */
sendChatMessage?: SendChatMessage; sendChatMessage?: SendChatMessage;
@@ -176,6 +176,7 @@ export async function handleCheckCommand(
existingState: existing, existingState: existing,
ui: ctx.ui, ui: ctx.ui,
hasUI: ctx.hasUI, hasUI: ctx.hasUI,
model: ctx.model,
sendChatMessage: ctx.sendChatMessage, sendChatMessage: ctx.sendChatMessage,
onAgentEvent: ctx.onAgentEvent, onAgentEvent: ctx.onAgentEvent,
sendPhaseLine: ctx.sendPhaseLine, sendPhaseLine: ctx.sendPhaseLine,
@@ -215,6 +216,7 @@ export async function handleAllCommand(
only: parsed.only, only: parsed.only,
ui: ctx.ui, ui: ctx.ui,
hasUI: ctx.hasUI, hasUI: ctx.hasUI,
model: ctx.model,
sendChatMessage: ctx.sendChatMessage, sendChatMessage: ctx.sendChatMessage,
onAgentEvent: ctx.onAgentEvent, onAgentEvent: ctx.onAgentEvent,
sendPhaseLine: ctx.sendPhaseLine, sendPhaseLine: ctx.sendPhaseLine,
@@ -303,6 +305,7 @@ export async function handleResumeCommand(
scope: { cwd, target: cwd, fix: entry.fix, rest: [] }, scope: { cwd, target: cwd, fix: entry.fix, rest: [] },
ui: ctx.ui, ui: ctx.ui,
hasUI: ctx.hasUI, hasUI: ctx.hasUI,
model: ctx.model,
existingState: state, existingState: state,
sendChatMessage: ctx.sendChatMessage, sendChatMessage: ctx.sendChatMessage,
onAgentEvent: ctx.onAgentEvent, onAgentEvent: ctx.onAgentEvent,

View File

@@ -410,6 +410,7 @@ export default async function pygieniumExtension(
mode: ctx.mode, mode: ctx.mode,
hasUI: ctx.hasUI, hasUI: ctx.hasUI,
ui: ctx.ui, ui: ctx.ui,
model: ctx.model,
sendChatMessage, sendChatMessage,
onAgentEvent, onAgentEvent,
sendPhaseLine, sendPhaseLine,

View File

@@ -22,10 +22,13 @@
import { mkdir, writeFile } from "node:fs/promises"; import { mkdir, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path"; import { dirname, join, resolve } from "node:path";
import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent"; import type {
AgentSessionEvent,
ExtensionCommandContext,
ExtensionUIContext,
} from "@earendil-works/pi-coding-agent";
import { getAllChecks, type CheckDefinition } from "../checks/registry.js"; import { getAllChecks, type CheckDefinition } from "../checks/registry.js";
import { runCheck } from "./check-runner.js"; import { runCheck } from "./check-runner.js";
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
import { createPhaseStrip, type SendChatMessage } from "../phases.js"; import { createPhaseStrip, type SendChatMessage } from "../phases.js";
import { createPipelineFooter, type ItemStatus } from "../footer.js"; import { createPipelineFooter, type ItemStatus } from "../footer.js";
import { runRecon } from "../recon.js"; import { runRecon } from "../recon.js";
@@ -74,6 +77,12 @@ export interface AllRunOptions {
ui?: ExtensionUIContext; ui?: ExtensionUIContext;
/** Whether dialog-capable UI is available. */ /** Whether dialog-capable UI is available. */
hasUI?: boolean; hasUI?: boolean;
/**
* The currently selected model from the invoking session; forwarded to
* each check's sub-agents so scans run on the model the user picked, not
* the settings default.
*/
model?: ExtensionCommandContext["model"];
/** Optional callback to post completion messages into the chat. */ /** Optional callback to post completion messages into the chat. */
sendChatMessage?: SendChatMessage; sendChatMessage?: SendChatMessage;
/** Optional callback forwarding raw sub-agent events to the chat stream. */ /** Optional callback forwarding raw sub-agent events to the chat stream. */
@@ -369,6 +378,7 @@ export async function runAllChecks(
scope: { cwd, target, fix, rest: [] }, scope: { cwd, target, fix, rest: [] },
ui: opts.ui, ui: opts.ui,
hasUI, hasUI,
model: opts.model,
existingState: state, existingState: state,
sendChatMessage: opts.sendChatMessage, sendChatMessage: opts.sendChatMessage,
onAgentEvent: opts.onAgentEvent, onAgentEvent: opts.onAgentEvent,

View File

@@ -17,7 +17,10 @@
import { rm } from "node:fs/promises"; import { rm } from "node:fs/promises";
import { resolve, join } from "node:path"; import { resolve, join } from "node:path";
import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent"; import type {
ExtensionCommandContext,
ExtensionUIContext,
} from "@earendil-works/pi-coding-agent";
import type { CheckDefinition, CheckScope } from "../checks/registry.js"; import type { CheckDefinition, CheckScope } from "../checks/registry.js";
import { runAgentTask } from "../agent-runner.js"; import { runAgentTask } from "../agent-runner.js";
import { runRecon } from "../recon.js"; import { runRecon } from "../recon.js";
@@ -84,6 +87,12 @@ export interface RunCheckOptions {
ui?: ExtensionUIContext; ui?: ExtensionUIContext;
/** Whether dialog-capable UI is available. */ /** Whether dialog-capable UI is available. */
hasUI?: boolean; hasUI?: boolean;
/**
* The currently selected model from the invoking session; forwarded to
* each sub-agent so scans run on the model the user picked, not the
* settings default.
*/
model?: ExtensionCommandContext["model"];
/** Pre-existing run state to update (for `/pygienium-all` and resume). */ /** Pre-existing run state to update (for `/pygienium-all` and resume). */
existingState?: RunState; existingState?: RunState;
/** Optional callback to post completion messages into the chat. */ /** Optional callback to post completion messages into the chat. */
@@ -278,6 +287,7 @@ async function runCheckImplInner(
cwd: scope.target, cwd: scope.target,
agentName: check.agentName, agentName: check.agentName,
task: scanTask, task: scanTask,
model: opts.model,
onEvent: forward(PHASE_ANALYSIS), onEvent: forward(PHASE_ANALYSIS),
}); });
findings = scanResult.text; findings = scanResult.text;
@@ -310,6 +320,7 @@ async function runCheckImplInner(
cwd: scope.target, cwd: scope.target,
agentName: check.fixAgentName ?? "fixer", agentName: check.fixAgentName ?? "fixer",
task: fixTask, task: fixTask,
model: opts.model,
onEvent: forward(PHASE_FIX), onEvent: forward(PHASE_FIX),
}); });
changes = fixResult.text; changes = fixResult.text;

View File

@@ -110,6 +110,28 @@ describe("check-runner integration", () => {
); );
}); });
it("forwards the selected model to every sub-agent", async () => {
const check = smokeCheck();
const selectedModel = {
provider: "test-provider",
id: "test-model",
} as unknown as PygieniumCtx["model"];
const seen: unknown[] = [];
setAgentRunner(async (opts) => {
seen.push(opts.model);
return fakeAgentRunner(opts);
});
await handleCheckCommand(
check,
"--fix",
{ ...stubCtx(cwd), model: selectedModel } as PygieniumCtx,
);
// Analysis + fix phases each dispatch one sub-agent.
expect(seen.length).toBe(2);
for (const m of seen) expect(m).toBe(selectedModel);
});
it("persists run-state.json at the expected path", async () => { it("persists run-state.json at the expected path", async () => {
const check = smokeCheck(); const check = smokeCheck();
await handleCheckCommand(check, "", stubCtx(cwd)); await handleCheckCommand(check, "", stubCtx(cwd));