/** * 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; 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 = {}; 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 agent definitions: the extension's `agents/*.md` baseline, plus any * repo-local `agents/*.md` at `/agents/` (when `cwd` is given). Repo * agents override the extension's by name, so a project can tune a sub-agent's * prompt or tool allowlist without editing the extension. Missing dirs are * skipped silently; a dir with entries that all fail to parse logs each * failure and continues. */ export async function loadAgents(opts?: { cwd?: string; }): Promise> { const extRoot = extensionRoot(); const result = new Map(); // Extension-shipped agents are the baseline. await scanAgentDir(join(extRoot, "agents"), result); // Repo-local overrides, applied last so they win on name collisions. if (opts?.cwd) { await scanAgentDir(join(opts.cwd, "agents"), result, true); } return result; } /** * Load every `agents/*.md` in `dir` into `result` (later dirs win on name * collisions). `repoDir` suppresses the missing-dir warning: `/agents/` * legitimately doesn't exist in most scanned projects. */ async function scanAgentDir( dir: string, result: Map, repoDir = false, ): Promise { let entries: string[]; try { entries = await readdir(dir); } catch (err) { if (!repoDir) { console.error( `[pygienium] agent loading: could not read agents dir at ${dir}: ${err instanceof Error ? err.message : String(err)}`, ); } return; } for (const entry of entries) { if (!entry.endsWith(".md")) continue; const sourcePath = join(dir, entry); try { 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, }); } catch (err) { console.error( `[pygienium] agent loading: failed to load ${entry} from ${dir}: ${err instanceof Error ? err.message : String(err)}`, ); // Continue loading other agents even if one fails. } } if (!repoDir && result.size === 0 && entries.length > 0) { console.error( `[pygienium] agent loading: found ${entries.length} entries in ${dir} but loaded 0 agents`, ); } }