From e4790baca88b60d4b779888466e05f6cf4a13755 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roch=C3=A9=20Compaan?= Date: Sat, 18 Jul 2026 12:26:30 +0200 Subject: [PATCH 1/9] docs(workflow): materialize issue 90 artifacts --- ...026-07-15-patchmill-doctor-pi-resources.md | 1450 +++++++++++++++++ ...15-patchmill-doctor-pi-resources-design.md | 147 ++ 2 files changed, 1597 insertions(+) create mode 100644 docs/plans/2026-07-15-patchmill-doctor-pi-resources.md create mode 100644 docs/specs/2026-07-15-patchmill-doctor-pi-resources-design.md diff --git a/docs/plans/2026-07-15-patchmill-doctor-pi-resources.md b/docs/plans/2026-07-15-patchmill-doctor-pi-resources.md new file mode 100644 index 0000000..ed168b7 --- /dev/null +++ b/docs/plans/2026-07-15-patchmill-doctor-pi-resources.md @@ -0,0 +1,1450 @@ +# Patchmill Doctor Pi Resources Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `patchmill doctor` print compact, profile-specific Pi resource summaries without mutating package state, writing trust decisions, or executing extension code. + +**Architecture:** Extract Patchmill Pi invocation resource profiles into shared code used by both runtime argument builders and doctor. Doctor uses a static, non-mutating Pi resource resolver built from Pi's exported `DefaultPackageManager`, `SettingsManager`, `ProjectTrustStore`, `loadSkills()`, and context-file helpers; it never calls `DefaultResourceLoader.reload()` for resource listing. + +**Tech Stack:** TypeScript, Node.js built-in test runner, Pi SDK exports from `@earendil-works/pi-coding-agent`, existing Patchmill config and skill-resolution helpers. + +## Global Constraints + +- Do not call `DefaultResourceLoader.reload()` for doctor resource listing. +- Do not install missing npm/git Pi package sources during doctor resource discovery; skip and report them. +- Do not execute extension modules merely to list resources. +- Match non-interactive Pi project-trust behavior without prompting and without writing `trust.json`. +- Report named profiles instead of one vague resource set: run-once planning, run-once development-environment, run-once implementation, and triage. +- `patchmill doctor --quiet` suppresses resource-only output when no required readiness check fails. +- Resource-summary warnings must not make `doctor` fail unless an existing required check also fails. +- Unit tests must stub doctor resource loading and must not assert the exact contents of a developer's machine-specific Pi configuration. +- No dependency changes are planned; if dependency files change unexpectedly, rerun the Nix build before completion. + +--- + +## File Structure + +- Create `src/pi/resource-profiles.ts` for shared Patchmill Pi invocation resource profiles and argument helpers. +- Create `src/pi/resource-profiles.test.ts` for deterministic profile tests. +- Modify runtime Pi invocation files to consume `src/pi/resource-profiles.ts` instead of rebuilding profile-specific skill/context/extension arguments inline: + - `src/cli/commands/run-once/pi.ts` + - `src/pi/runner.ts` + - `src/cli/commands/run-once/development-environment-stage.ts` + - `src/cli/commands/run-once/stage-advancement.ts` + - `src/cli/commands/run-once/pipeline.ts` + - `src/cli/commands/triage/dry-run-agent.ts` + - `src/cli/commands/triage/execute-agent.ts` +- Create `src/cli/commands/doctor/pi-resources.ts` for static non-mutating discovery, compact formatting, trust parity, and warning conversion. +- Create `src/cli/commands/doctor/pi-resources.test.ts` for resource resolver and formatter behavior. +- Modify `src/cli/commands/doctor/reporting.ts` and `src/cli/commands/doctor/reporting.test.ts` to prepend profile resource blocks while preserving existing report semantics. +- Modify `src/cli/commands/doctor/main.ts` and `src/cli/commands/doctor/main.test.ts` to collect resources safely, append warning checks, and centralize quiet behavior. + +--- + +### Task 1: Extract Shared Pi Resource Profiles + +**Files:** + +- Create: `src/pi/resource-profiles.ts` +- Create: `src/pi/resource-profiles.test.ts` +- Modify: `src/cli/commands/run-once/pi.ts` +- Modify: `src/pi/runner.ts` +- Modify: `src/cli/commands/run-once/development-environment-stage.ts` +- Modify: `src/cli/commands/run-once/stage-advancement.ts` +- Modify: `src/cli/commands/run-once/pipeline.ts` +- Modify: `src/cli/commands/triage/dry-run-agent.ts` +- Modify: `src/cli/commands/triage/execute-agent.ts` + +**Interfaces:** + +- Consumes: `PatchmillSkillsConfig`, `PATCHMILL_SKILL_KEYS`, and `skillInvocationPaths()` from `src/workflow/skills.ts`. +- Produces: + - `PatchmillPiResourceProfile` + - `runOncePlanningPiProfile(skills, repoRoot)` + - `runOnceDevelopmentEnvironmentPiProfile(skills, repoRoot)` + - `runOnceImplementationPiProfile(skills, repoRoot)` + - `triagePiProfile(skills, repoRoot)` + - `doctorPiResourceProfiles(skills, repoRoot)` + - `profileSkillArgs(profile)` + - `profileExtensionArgs(profile)` + - `profileContextArgs(profile)` + +- [ ] **Step 1: Write failing profile tests** + +Create `src/pi/resource-profiles.test.ts`: + +```typescript +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; +import { test } from "node:test"; +import { + doctorPiResourceProfiles, + profileContextArgs, + profileExtensionArgs, + profileSkillArgs, + runOnceImplementationPiProfile, + runOncePlanningPiProfile, + triagePiProfile, +} from "./resource-profiles.ts"; +import type { PatchmillSkillsConfig } from "../workflow/skills.ts"; + +async function withRepo(fn: (repoRoot: string) => Promise): Promise { + const repoRoot = await mkdtemp(join(tmpdir(), "patchmill-profile-")); + try { + return await fn(repoRoot); + } finally { + await rm(repoRoot, { recursive: true, force: true }); + } +} + +const skills: PatchmillSkillsConfig = { + triage: "./skills/triage", + planning: "./skills/planning", + implementation: "./skills/implementation", + developmentEnvironment: "./skills/development-environment", + toolchain: "./skills/toolchain", + review: "./skills/review", + visualEvidence: "./skills/visual-evidence", + landing: "./skills/landing", +}; + +test("run-once planning profile includes context and Patchmill run-once extensions", async () => { + await withRepo(async (repoRoot) => { + const profile = runOncePlanningPiProfile(skills, repoRoot); + + assert.equal(profile.id, "run-once-planning"); + assert.equal(profile.noContextFiles, false); + assert.equal(profile.noPromptTemplates, false); + assert.equal(profile.additionalExtensionPaths.length, 2); + assert.equal(basename(profile.additionalExtensionPaths[0] ?? ""), "pi-subagents"); + assert.equal( + profile.additionalExtensionPaths[1]?.replaceAll("\\", "/").endsWith("/extensions/todos.ts"), + true, + ); + assert.deepEqual(profile.additionalSkillPaths, [ + join(repoRoot, "skills", "planning", "SKILL.md"), + ]); + }); +}); + +test("run-once implementation profile includes every implementation-stage skill slot", async () => { + await withRepo(async (repoRoot) => { + assert.deepEqual( + runOnceImplementationPiProfile(skills, repoRoot).additionalSkillPaths, + [ + join(repoRoot, "skills", "toolchain", "SKILL.md"), + join(repoRoot, "skills", "implementation", "SKILL.md"), + join(repoRoot, "skills", "review", "SKILL.md"), + join(repoRoot, "skills", "visual-evidence", "SKILL.md"), + join(repoRoot, "skills", "landing", "SKILL.md"), + ], + ); + }); +}); + +test("triage profile mirrors triage agents", async () => { + await withRepo(async (repoRoot) => { + const profile = triagePiProfile(skills, repoRoot); + + assert.equal(profile.id, "triage"); + assert.equal(profile.noContextFiles, true); + assert.deepEqual(profile.additionalExtensionPaths, []); + assert.deepEqual(profileContextArgs(profile), ["--no-context-files"]); + assert.deepEqual(profileSkillArgs(profile), [ + "--skill", + join(repoRoot, "skills", "triage", "SKILL.md"), + ]); + }); +}); + +test("profile argument helpers render extension and skill flags", async () => { + await withRepo(async (repoRoot) => { + const profile = runOncePlanningPiProfile(skills, repoRoot); + + assert.deepEqual(profileExtensionArgs(profile), [ + "-e", + profile.additionalExtensionPaths[0], + "-e", + profile.additionalExtensionPaths[1], + ]); + assert.deepEqual(profileSkillArgs(profile), [ + "--skill", + join(repoRoot, "skills", "planning", "SKILL.md"), + ]); + }); +}); + +test("doctorPiResourceProfiles returns every reported profile in stable order", async () => { + await withRepo(async (repoRoot) => { + assert.deepEqual( + doctorPiResourceProfiles(skills, repoRoot).map((profile) => profile.id), + [ + "run-once-planning", + "run-once-development-environment", + "run-once-implementation", + "triage", + ], + ); + }); +}); +``` + +- [ ] **Step 2: Run profile tests to verify they fail** + +Run: + +```bash +node --test src/pi/resource-profiles.test.ts +``` + +Expected: FAIL because `src/pi/resource-profiles.ts` does not exist. + +- [ ] **Step 3: Implement shared profiles and argument helpers** + +Create `src/pi/resource-profiles.ts`: + +```typescript +import { createRequire } from "node:module"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + skillInvocationPaths, + type PatchmillSkillsConfig, +} from "../workflow/skills.ts"; + +const require = createRequire(import.meta.url); +const PI_SUBAGENTS_PACKAGE_ROOT = dirname( + require.resolve("pi-subagents/package.json"), +); +const PATCHMILL_PACKAGE_ROOT = resolve( + dirname(fileURLToPath(import.meta.url)), + "../..", +); +const PATCHMILL_TODOS_EXTENSION = join( + PATCHMILL_PACKAGE_ROOT, + "extensions", + "todos.ts", +); + +export type PatchmillPiResourceProfileId = + | "run-once-planning" + | "run-once-development-environment" + | "run-once-implementation" + | "triage"; + +export type PatchmillPiResourceProfile = { + id: PatchmillPiResourceProfileId; + label: string; + noContextFiles: boolean; + noPromptTemplates: boolean; + additionalExtensionPaths: string[]; + additionalSkillPaths: string[]; +}; + +function runOnceExtensionPaths(): string[] { + return [PI_SUBAGENTS_PACKAGE_ROOT, PATCHMILL_TODOS_EXTENSION]; +} + +function profile( + input: Omit & { + skills: Array; + repoRoot: string; + }, +): PatchmillPiResourceProfile { + return { + id: input.id, + label: input.label, + noContextFiles: input.noContextFiles, + noPromptTemplates: input.noPromptTemplates, + additionalExtensionPaths: input.additionalExtensionPaths, + additionalSkillPaths: skillInvocationPaths(input.skills, input.repoRoot), + }; +} + +export function runOncePlanningPiProfile( + skills: PatchmillSkillsConfig, + repoRoot: string, +): PatchmillPiResourceProfile { + return profile({ + id: "run-once-planning", + label: "run-once planning", + noContextFiles: false, + noPromptTemplates: false, + additionalExtensionPaths: runOnceExtensionPaths(), + skills: [skills.planning], + repoRoot, + }); +} + +export function runOnceDevelopmentEnvironmentPiProfile( + skills: PatchmillSkillsConfig, + repoRoot: string, +): PatchmillPiResourceProfile { + return profile({ + id: "run-once-development-environment", + label: "run-once development-environment", + noContextFiles: false, + noPromptTemplates: false, + additionalExtensionPaths: runOnceExtensionPaths(), + skills: [skills.developmentEnvironment], + repoRoot, + }); +} + +export function runOnceImplementationPiProfile( + skills: PatchmillSkillsConfig, + repoRoot: string, +): PatchmillPiResourceProfile { + return profile({ + id: "run-once-implementation", + label: "run-once implementation", + noContextFiles: false, + noPromptTemplates: false, + additionalExtensionPaths: runOnceExtensionPaths(), + skills: [ + skills.toolchain, + skills.implementation, + skills.review, + skills.visualEvidence, + skills.landing, + ], + repoRoot, + }); +} + +export function triagePiProfile( + skills: PatchmillSkillsConfig, + repoRoot: string, +): PatchmillPiResourceProfile { + return profile({ + id: "triage", + label: "triage", + noContextFiles: true, + noPromptTemplates: false, + additionalExtensionPaths: [], + skills: [skills.triage], + repoRoot, + }); +} + +export function doctorPiResourceProfiles( + skills: PatchmillSkillsConfig, + repoRoot: string, +): PatchmillPiResourceProfile[] { + return [ + runOncePlanningPiProfile(skills, repoRoot), + runOnceDevelopmentEnvironmentPiProfile(skills, repoRoot), + runOnceImplementationPiProfile(skills, repoRoot), + triagePiProfile(skills, repoRoot), + ]; +} + +export function profileContextArgs( + profile: PatchmillPiResourceProfile, +): string[] { + return profile.noContextFiles ? ["--no-context-files"] : []; +} + +export function profilePromptTemplateArgs( + profile: PatchmillPiResourceProfile, +): string[] { + return profile.noPromptTemplates ? ["--no-prompt-templates"] : []; +} + +export function profileExtensionArgs( + profile: PatchmillPiResourceProfile, +): string[] { + return profile.additionalExtensionPaths.flatMap((path) => ["-e", path]); +} + +export function profileSkillArgs(profile: PatchmillPiResourceProfile): string[] { + return profile.additionalSkillPaths.flatMap((path) => ["--skill", path]); +} +``` + +- [ ] **Step 4: Refactor run-once argument generation to use profile extension args** + +Modify `src/cli/commands/run-once/pi.ts`: + +```typescript +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { profileExtensionArgs } from "../../../pi/resource-profiles.ts"; +``` + +Replace the private `createRequire`, `PI_SUBAGENTS_PACKAGE_ROOT`, `PATCHMILL_PACKAGE_ROOT`, and `PATCHMILL_TODOS_EXTENSION` constants with a local lightweight profile argument parameter: + +```typescript +function piPromptArgs( + promptPath: string, + sessionDir?: string, + skillPaths: string[] = [], + extensionArgs: string[] = [], +): string[] { + const skillArgs = skillPaths.flatMap((path) => ["--skill", path]); + const baseArgs = [...extensionArgs, ...skillArgs, "-p"]; + return sessionDir + ? [...baseArgs, "--session-dir", sessionDir, `@${promptPath}`] + : [...baseArgs, `@${promptPath}`]; +} +``` + +Add `extensionArgs?: string[]` to `RunPiPromptOptions`. In the `runner.run()` call, pass: + +```typescript +piPromptArgs( + promptPath, + sessionDir, + options?.skillPaths, + options?.extensionArgs, +), +``` + +Do not call `profileExtensionArgs()` here directly. Runtime callers supply the profile-specific extension args so doctor and invocation call sites share the same profile builder. + +- [ ] **Step 5: Refactor runtime call sites to consume profile builders** + +Update each call site so the profile is constructed once and its `additionalSkillPaths` and `profileExtensionArgs(profile)` values are passed to Pi. + +In `src/pi/runner.ts`, import profile builders: + +```typescript +import { + profileExtensionArgs, + runOnceImplementationPiProfile, + runOncePlanningPiProfile, +} from "./resource-profiles.ts"; +``` + +In `plan()`, before `runPiPrompt()`: + +```typescript +const profile = runOncePlanningPiProfile( + input.skills ?? DEFAULT_PATCHMILL_SKILLS, + input.repoRoot, +); +``` + +Use: + +```typescript +skillPaths: profile.additionalSkillPaths, +extensionArgs: profileExtensionArgs(profile), +``` + +In `implementation()`, construct: + +```typescript +const profile = runOnceImplementationPiProfile( + input.skills ?? DEFAULT_PATCHMILL_SKILLS, + input.repoRoot, +); +``` + +Use the same `skillPaths` and `extensionArgs` properties. + +For `src/cli/commands/run-once/development-environment-stage.ts`, `src/cli/commands/run-once/stage-advancement.ts`, and `src/cli/commands/run-once/pipeline.ts`, replace direct `skillInvocationPaths(...)` calls for run-once Pi prompts with the appropriate `runOnceDevelopmentEnvironmentPiProfile()`, `runOncePlanningPiProfile()`, or `runOnceImplementationPiProfile()` call. Pass both: + +```typescript +skillPaths: profile.additionalSkillPaths, +extensionArgs: profileExtensionArgs(profile), +``` + +For `src/cli/commands/triage/dry-run-agent.ts` and `src/cli/commands/triage/execute-agent.ts`, import: + +```typescript +import { + profileContextArgs, + profileSkillArgs, + triagePiProfile, +} from "../../../pi/resource-profiles.ts"; +``` + +Replace the local `skillArgs` construction with: + +```typescript +const profile = triagePiProfile(skills, repoRoot); +``` + +Replace the hardcoded context and skill flag segment with: + +```typescript +...profileContextArgs(profile), +...sessionArgs, +...profileSkillArgs(profile), +``` + +- [ ] **Step 6: Run focused runtime profile tests** + +Run: + +```bash +node --test src/pi/resource-profiles.test.ts src/cli/commands/run-once/pi.test.ts src/pi/runner.test.ts src/cli/commands/triage/dry-run-agent.test.ts src/cli/commands/triage/execute-agent.test.ts +``` + +Expected: PASS. Existing argument tests should still observe the same Pi command flags, now sourced from shared profile builders. + +- [ ] **Step 7: Commit Task 1** + +Run: + +```bash +git add src/pi/resource-profiles.ts src/pi/resource-profiles.test.ts src/cli/commands/run-once/pi.ts src/pi/runner.ts src/cli/commands/run-once/development-environment-stage.ts src/cli/commands/run-once/stage-advancement.ts src/cli/commands/run-once/pipeline.ts src/cli/commands/triage/dry-run-agent.ts src/cli/commands/triage/execute-agent.ts +git commit -m "refactor(pi): share resource profiles" +``` + +--- + +### Task 2: Implement Non-Mutating Doctor Resource Discovery + +**Files:** + +- Create: `src/cli/commands/doctor/pi-resources.ts` +- Create: `src/cli/commands/doctor/pi-resources.test.ts` + +**Interfaces:** + +- Consumes: shared profiles from Task 1, `loadPatchmillConfigState()`, `localPiAgentDir()`, Pi `DefaultPackageManager`, `SettingsManager`, `ProjectTrustStore`, `hasTrustRequiringProjectResources()`, `loadSkills()`, and `loadProjectContextFiles()`. +- Produces: + - `DoctorPiResourceBlock` + - `DoctorPiResourceReport` + - `DoctorPiResourceProvider` + - `loadDoctorPiResources(repoRoot, env?)` + - `formatPiResourceBlocks(blocks)` + - `piResourceDiscoveryFailureCheck(error)` + +- [ ] **Step 1: Write failing discovery/formatting tests** + +Create `src/cli/commands/doctor/pi-resources.test.ts`: + +```typescript +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { + compactProfileBlock, + formatPiResourceBlocks, + piResourceDiscoveryFailureCheck, + piResourceWarningCheck, +} from "./pi-resources.ts"; + +const repoRoot = "/repo/project"; + +test("compactProfileBlock builds sorted compact sections", () => { + const block = compactProfileBlock({ + label: "run-once planning", + contextFiles: [join(repoRoot, "AGENTS.md")], + skillNames: ["github", "brainstorming"], + promptNames: ["review-loop", "parallel-review"], + extensionPaths: [ + join(repoRoot, "extensions", "todos.ts"), + join(repoRoot, "extensions", "pi-remote", "extension", "index.ts"), + ], + repoRoot, + }); + + assert.deepEqual(block, { + label: "run-once planning", + sections: [ + { heading: "Context", items: ["AGENTS.md"] }, + { heading: "Skills", items: ["brainstorming", "github"] }, + { heading: "Prompts", items: ["/parallel-review", "/review-loop"] }, + { heading: "Extensions", items: ["extension", "todos.ts"] }, + ], + }); +}); + +test("formatPiResourceBlocks prints profile blocks", () => { + assert.deepEqual( + formatPiResourceBlocks([ + { + label: "run-once planning", + sections: [ + { heading: "Context", items: ["AGENTS.md"] }, + { heading: "Skills", items: ["github"] }, + ], + }, + ]), + [ + "[Pi resources: run-once planning]", + "", + "[Context]", + " AGENTS.md", + "", + "[Skills]", + " github", + ], + ); +}); + +test("compactProfileBlock omits empty categories", () => { + assert.deepEqual( + compactProfileBlock({ + label: "triage", + contextFiles: [], + skillNames: [], + promptNames: ["review-loop"], + extensionPaths: [], + repoRoot, + }), + { + label: "triage", + sections: [{ heading: "Prompts", items: ["/review-loop"] }], + }, + ); +}); + +test("piResourceWarningCheck returns undefined without warnings", () => { + assert.equal(piResourceWarningCheck([]), undefined); +}); + +test("piResourceWarningCheck reports skipped packages without failing", () => { + assert.deepEqual(piResourceWarningCheck(["skipped missing package npm:@acme/pi-tools"]), { + name: "pi resources", + status: "warn", + message: "skipped missing package npm:@acme/pi-tools", + remediation: [ + "Patchmill doctor listed Pi resources without installing missing packages or executing extensions.", + "Install or update the listed Pi package sources outside doctor if you want those resources loaded, then rerun:", + " patchmill doctor", + ], + }); +}); + +test("piResourceDiscoveryFailureCheck creates a non-failing warning", () => { + assert.deepEqual(piResourceDiscoveryFailureCheck(new Error("boom")), { + name: "pi resources", + status: "warn", + message: "could not list Pi resources: boom", + remediation: [ + "Patchmill doctor could not list Pi's startup resources.", + "The readiness checks still ran; fix the Pi resource discovery error, then rerun:", + " patchmill doctor", + ], + }); +}); + +test("non-mutating discovery skips missing configured package sources", async () => { + const tmp = await mkdtemp(join(tmpdir(), "patchmill-doctor-resources-")); + try { + await writeFile( + join(tmp, "patchmill.config.json"), + JSON.stringify({ host: { provider: "forgejo-tea", repo: "OWNER/repo" } }), + "utf8", + ); + await mkdir(join(tmp, ".patchmill", "pi-agent"), { recursive: true }); + await writeFile( + join(tmp, ".patchmill", "pi-agent", "settings.json"), + JSON.stringify({ packages: ["npm:@missing/package@1.0.0"] }), + "utf8", + ); + + const { loadDoctorPiResources } = await import("./pi-resources.ts"); + const report = await loadDoctorPiResources(tmp, {}); + + assert.equal(report.check?.status, "warn"); + assert.match(report.check?.message ?? "", /skipped missing package npm:@missing\/package@1\.0\.0/); + } finally { + await rm(tmp, { recursive: true, force: true }); + } +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: + +```bash +node --test src/cli/commands/doctor/pi-resources.test.ts +``` + +Expected: FAIL because `src/cli/commands/doctor/pi-resources.ts` does not exist. + +- [ ] **Step 3: Implement compact block formatting and warning helpers** + +Create `src/cli/commands/doctor/pi-resources.ts` with these exports: + +```typescript +import { homedir } from "node:os"; +import { + basename, + dirname, + isAbsolute, + join, + parse, + relative, + resolve, +} from "node:path"; +import { + DefaultPackageManager, + hasTrustRequiringProjectResources, + loadProjectContextFiles, + loadSkills, + ProjectTrustStore, + SettingsManager, + type MissingSourceAction, + type ResolvedResource, +} from "@earendil-works/pi-coding-agent"; +import { loadPatchmillConfigState } from "../../../config/load.ts"; +import { + doctorPiResourceProfiles, + type PatchmillPiResourceProfile, +} from "../../../pi/resource-profiles.ts"; +import { localPiAgentDir } from "../init/pi-agent-settings.ts"; +import type { DoctorCheckResult } from "./checks.ts"; + +export type DoctorPiResourceSection = { + heading: "Context" | "Skills" | "Prompts" | "Extensions"; + items: string[]; +}; + +export type DoctorPiResourceBlock = { + label: string; + sections: DoctorPiResourceSection[]; +}; + +export type DoctorPiResourceReport = { + blocks: DoctorPiResourceBlock[]; + check?: DoctorCheckResult; +}; + +export type DoctorPiResourceProvider = ( + repoRoot: string, +) => Promise; +``` + +Add path/format helpers: + +```typescript +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function uniqueSorted(values: string[]): string[] { + return [...new Set(values.filter((value) => value.trim().length > 0))].sort( + (a, b) => a.localeCompare(b), + ); +} + +function isInside(parent: string, child: string): boolean { + const rel = relative(parent, child); + return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); +} + +function slashPath(path: string): string { + return path.replaceAll("\\", "/"); +} + +function displayPath(path: string, repoRoot: string): string { + const resolvedPath = resolve(path); + const resolvedRepoRoot = resolve(repoRoot); + if (isInside(resolvedRepoRoot, resolvedPath)) { + const rel = relative(resolvedRepoRoot, resolvedPath); + return slashPath(rel || basename(resolvedPath)); + } + + const home = homedir(); + if (isInside(home, resolvedPath)) { + return slashPath(join("~", relative(home, resolvedPath))); + } + + return slashPath(path); +} + +function compactExtensionLabel(path: string): string { + const normalizedPath = slashPath(path); + const parsed = parse(normalizedPath); + return parsed.base === "index.ts" || parsed.base === "index.js" + ? basename(dirname(normalizedPath)) + : parsed.base; +} +``` + +Add block formatting exports: + +```typescript +export function compactProfileBlock(input: { + label: string; + contextFiles: string[]; + skillNames: string[]; + promptNames: string[]; + extensionPaths: string[]; + repoRoot: string; +}): DoctorPiResourceBlock { + const sections: DoctorPiResourceSection[] = []; + + const context = input.contextFiles.map((path) => displayPath(path, input.repoRoot)); + if (context.length > 0) sections.push({ heading: "Context", items: context }); + + const skills = uniqueSorted(input.skillNames); + if (skills.length > 0) sections.push({ heading: "Skills", items: skills }); + + const prompts = uniqueSorted(input.promptNames.map((name) => `/${name}`)); + if (prompts.length > 0) sections.push({ heading: "Prompts", items: prompts }); + + const extensions = uniqueSorted(input.extensionPaths.map(compactExtensionLabel)); + if (extensions.length > 0) { + sections.push({ heading: "Extensions", items: extensions }); + } + + return { label: input.label, sections }; +} + +export function formatPiResourceBlocks( + blocks: DoctorPiResourceBlock[], +): string[] { + return blocks.flatMap((block, blockIndex) => [ + ...(blockIndex === 0 ? [] : [""]), + `[Pi resources: ${block.label}]`, + "", + ...block.sections.flatMap((section, sectionIndex) => [ + ...(sectionIndex === 0 ? [] : [""]), + `[${section.heading}]`, + ` ${section.items.join(", ")}`, + ]), + ]); +} + +export function piResourceWarningCheck( + warnings: string[], +): DoctorCheckResult | undefined { + if (warnings.length === 0) return undefined; + return { + name: "pi resources", + status: "warn", + message: warnings.join("; "), + remediation: [ + "Patchmill doctor listed Pi resources without installing missing packages or executing extensions.", + "Install or update the listed Pi package sources outside doctor if you want those resources loaded, then rerun:", + " patchmill doctor", + ], + }; +} + +export function piResourceDiscoveryFailureCheck( + error: unknown, +): DoctorCheckResult { + return { + name: "pi resources", + status: "warn", + message: `could not list Pi resources: ${errorMessage(error)}`, + remediation: [ + "Patchmill doctor could not list Pi's startup resources.", + "The readiness checks still ran; fix the Pi resource discovery error, then rerun:", + " patchmill doctor", + ], + }; +} +``` + +- [ ] **Step 4: Implement trust parity and non-mutating static discovery** + +Append these helpers and `loadDoctorPiResources()` to `src/cli/commands/doctor/pi-resources.ts`: + +```typescript +function projectTrustedForResourceListing( + repoRoot: string, + agentDir: string, +): boolean { + if (!hasTrustRequiringProjectResources(repoRoot)) return true; + + const savedDecision = new ProjectTrustStore(agentDir).get(repoRoot); + if (savedDecision !== null) return savedDecision; + + const globalOnlySettings = SettingsManager.create(repoRoot, agentDir, { + projectTrusted: false, + }); + return globalOnlySettings.getDefaultProjectTrust() === "always"; +} + +function enabledPaths(resources: ResolvedResource[]): string[] { + return resources.filter((resource) => resource.enabled).map((resource) => resource.path); +} + +function promptName(path: string): string { + return basename(path).replace(/\.md$/u, ""); +} + +async function resolveStaticResources(input: { + repoRoot: string; + agentDir: string; + profile: PatchmillPiResourceProfile; + warnings: string[]; +}): Promise<{ + contextFiles: string[]; + skillNames: string[]; + promptNames: string[]; + extensionPaths: string[]; +}> { + const settingsManager = SettingsManager.create(input.repoRoot, input.agentDir, { + projectTrusted: projectTrustedForResourceListing(input.repoRoot, input.agentDir), + }); + const packageManager = new DefaultPackageManager({ + cwd: input.repoRoot, + agentDir: input.agentDir, + settingsManager, + }); + + const onMissing = async (source: string): Promise => { + input.warnings.push(`skipped missing package ${source}`); + return "skip"; + }; + + const resolved = await packageManager.resolve(onMissing); + const baseSkillPaths = enabledPaths(resolved.skills); + const basePromptPaths = input.profile.noPromptTemplates + ? [] + : enabledPaths(resolved.prompts); + const baseExtensionPaths = enabledPaths(resolved.extensions); + + const skills = loadSkills({ + cwd: input.repoRoot, + agentDir: input.agentDir, + includeDefaults: false, + skillPaths: [...baseSkillPaths, ...input.profile.additionalSkillPaths], + }); + input.warnings.push( + ...skills.diagnostics.map((diagnostic) => + diagnostic.path + ? `skills: ${diagnostic.message} (${diagnostic.path})` + : `skills: ${diagnostic.message}`, + ), + ); + + return { + contextFiles: input.profile.noContextFiles + ? [] + : loadProjectContextFiles({ + cwd: input.repoRoot, + agentDir: input.agentDir, + }).map((file) => file.path), + skillNames: skills.skills.map((skill) => skill.name), + promptNames: basePromptPaths.map(promptName), + extensionPaths: [...baseExtensionPaths, ...input.profile.additionalExtensionPaths], + }; +} + +export async function loadDoctorPiResources( + repoRoot: string, + env: Record = process.env, +): Promise { + const agentDir = localPiAgentDir(repoRoot); + const warnings: string[] = []; + + try { + const loaded = await loadPatchmillConfigState(repoRoot, env, []); + const blocks = []; + for (const profile of doctorPiResourceProfiles(loaded.config.skills, repoRoot)) { + const resources = await resolveStaticResources({ + repoRoot, + agentDir, + profile, + warnings, + }); + const block = compactProfileBlock({ + label: profile.label, + repoRoot, + ...resources, + }); + if (block.sections.length > 0) blocks.push(block); + } + + return { blocks, check: piResourceWarningCheck(warnings) }; + } catch (error) { + return { blocks: [], check: piResourceDiscoveryFailureCheck(error) }; + } +} +``` + +- [ ] **Step 5: Run resource tests and TypeScript lint** + +Run: + +```bash +node --test src/cli/commands/doctor/pi-resources.test.ts +npm run lint:ts +``` + +Expected: PASS. If `DefaultPackageManager` construction types differ, adapt to the exported `.d.ts` shape rather than using private subpath imports. + +- [ ] **Step 6: Commit Task 2** + +Run: + +```bash +git add src/cli/commands/doctor/pi-resources.ts src/cli/commands/doctor/pi-resources.test.ts +git commit -m "feat(doctor): resolve Pi resources read-only" +``` + +--- + +### Task 3: Integrate Resource Blocks into Doctor Reporting + +**Files:** + +- Modify: `src/cli/commands/doctor/reporting.ts` +- Modify: `src/cli/commands/doctor/reporting.test.ts` +- Modify: `src/cli/commands/doctor/main.ts` +- Modify: `src/cli/commands/doctor/main.test.ts` + +**Interfaces:** + +- Consumes: `DoctorPiResourceProvider`, `DoctorPiResourceReport`, `formatPiResourceBlocks()`, `loadDoctorPiResources()`, and `piResourceDiscoveryFailureCheck()` from Task 2. +- Produces: `formatDoctorReport(results, resourceBlocks?)` and centralized quiet handling in `runDoctor()`. + +- [ ] **Step 1: Add reporting tests for resource blocks** + +Append to `src/cli/commands/doctor/reporting.test.ts`: + +```typescript +test("formatDoctorReport prepends Pi resource blocks", () => { + assert.deepEqual( + formatDoctorReport(passing, [ + { + label: "run-once planning", + sections: [ + { heading: "Context", items: ["AGENTS.md"] }, + { heading: "Skills", items: ["github"] }, + ], + }, + ]), + [ + "[Pi resources: run-once planning]", + "", + "[Context]", + " AGENTS.md", + "", + "[Skills]", + " github", + "", + "Patchmill doctor", + "", + "✓ config: patchmill.config.json", + "✓ git: clean worktree", + "", + "Ready for safe dry runs.", + "", + "Next:", + " patchmill triage --dry-run", + ], + ); +}); +``` + +- [ ] **Step 2: Run reporting tests to verify failure** + +Run: + +```bash +node --test src/cli/commands/doctor/reporting.test.ts +``` + +Expected: FAIL because `formatDoctorReport()` does not accept resource blocks yet. + +- [ ] **Step 3: Update report formatting** + +Modify `src/cli/commands/doctor/reporting.ts` imports: + +```typescript +import { + formatPiResourceBlocks, + type DoctorPiResourceBlock, +} from "./pi-resources.ts"; +import type { DoctorCheckResult } from "./checks.ts"; +``` + +Replace `formatDoctorReport()` initialization with: + +```typescript +export function formatDoctorReport( + results: DoctorCheckResult[], + resourceBlocks: DoctorPiResourceBlock[] = [], +): string[] { + const resourceLines = formatPiResourceBlocks(resourceBlocks); + const lines = [ + ...resourceLines, + ...(resourceLines.length > 0 ? [""] : []), + "Patchmill doctor", + "", + ]; +``` + +Keep the existing checklist, remediation, and success footer behavior below that initialization. + +- [ ] **Step 4: Add main-flow tests with stubbed resource provider** + +Modify `src/cli/commands/doctor/main.test.ts` imports: + +```typescript +import type { DoctorPiResourceReport } from "./pi-resources.ts"; +``` + +Add near the existing runner: + +```typescript +const emptyResources: DoctorPiResourceReport = { blocks: [] }; +``` + +Update existing non-help `runDoctor()` option objects to include: + +```typescript +loadPiResources: async () => emptyResources, +``` + +Append tests: + +```typescript +test("runDoctor prints Pi resource blocks before checks", async () => { + const stdout: string[] = []; + + assert.equal( + await runDoctor( + [], + "/repo", + { stdout: (line) => stdout.push(line), stderr: () => undefined }, + { + runner, + loadPiResources: async () => ({ + blocks: [ + { + label: "run-once planning", + sections: [ + { heading: "Context", items: ["AGENTS.md"] }, + { heading: "Skills", items: ["github"] }, + ], + }, + ], + }), + runChecks: async () => [ + { name: "config", status: "pass", message: "patchmill.config.json" }, + ], + }, + ), + 0, + ); + + assert.match( + stdout.join("\n"), + /^\[Pi resources: run-once planning\]\n\n\[Context\]\n AGENTS\.md\n\n\[Skills\]\n github\n\nPatchmill doctor/m, + ); +}); + +test("runDoctor adds Pi resource warnings without failing", async () => { + const stdout: string[] = []; + + assert.equal( + await runDoctor( + [], + "/repo", + { stdout: (line) => stdout.push(line), stderr: () => undefined }, + { + runner, + loadPiResources: async () => ({ + blocks: [], + check: { + name: "pi resources", + status: "warn", + message: "skipped missing package npm:@acme/pi-tools", + }, + }), + runChecks: async () => [ + { name: "config", status: "pass", message: "patchmill.config.json" }, + ], + }, + ), + 0, + ); + + assert.match(stdout.join("\n"), /! pi resources: skipped missing package/); + assert.match(stdout.join("\n"), /Ready for safe dry runs/); +}); + +test("runDoctor converts thrown Pi resource provider errors to warnings", async () => { + const stdout: string[] = []; + + assert.equal( + await runDoctor( + [], + "/repo", + { stdout: (line) => stdout.push(line), stderr: () => undefined }, + { + runner, + loadPiResources: async () => { + throw new Error("resource load exploded"); + }, + runChecks: async () => [ + { name: "config", status: "pass", message: "patchmill.config.json" }, + ], + }, + ), + 0, + ); + + assert.match( + stdout.join("\n"), + /! pi resources: could not list Pi resources: resource load exploded/, + ); +}); + +test("runDoctor --quiet suppresses resource-only output", async () => { + const stdout: string[] = []; + + assert.equal( + await runDoctor( + ["--quiet"], + "/repo", + { stdout: (line) => stdout.push(line), stderr: () => undefined }, + { + runner, + loadPiResources: async () => ({ + blocks: [ + { + label: "run-once planning", + sections: [{ heading: "Skills", items: ["github"] }], + }, + ], + }), + runChecks: async () => [ + { name: "config", status: "pass", message: "patchmill.config.json" }, + ], + }, + ), + 0, + ); + + assert.deepEqual(stdout, []); +}); + +test("runDoctor --quiet prints resources when a required check fails", async () => { + const stdout: string[] = []; + + assert.equal( + await runDoctor( + ["--quiet"], + "/repo", + { stdout: (line) => stdout.push(line), stderr: () => undefined }, + { + runner, + loadPiResources: async () => ({ + blocks: [ + { + label: "run-once planning", + sections: [{ heading: "Skills", items: ["github"] }], + }, + ], + }), + runChecks: async () => [ + { name: "config", status: "fail", message: "missing" }, + ], + }, + ), + 1, + ); + + assert.match(stdout.join("\n"), /\[Pi resources: run-once planning\]/); + assert.match(stdout.join("\n"), /✗ config: missing/); +}); +``` + +- [ ] **Step 5: Run main tests to verify failure** + +Run: + +```bash +node --test src/cli/commands/doctor/main.test.ts +``` + +Expected: FAIL because `runDoctor()` does not accept `loadPiResources` yet. + +- [ ] **Step 6: Integrate safe resource loading into `runDoctor()`** + +Modify imports in `src/cli/commands/doctor/main.ts`: + +```typescript +import { + loadDoctorPiResources, + piResourceDiscoveryFailureCheck, + type DoctorPiResourceProvider, + type DoctorPiResourceReport, +} from "./pi-resources.ts"; +``` + +Add to the `options` type: + +```typescript + loadPiResources?: DoctorPiResourceProvider; +``` + +Add helper above `runDoctor()`: + +```typescript +async function safeLoadPiResources( + provider: DoctorPiResourceProvider, + repoRoot: string, +): Promise { + try { + return await provider(repoRoot); + } catch (error) { + return { blocks: [], check: piResourceDiscoveryFailureCheck(error) }; + } +} +``` + +After the optional `--fix` block and before `runChecks`, add: + +```typescript + const piResources = await safeLoadPiResources( + options.loadPiResources ?? loadDoctorPiResources, + config.repoRoot, + ); +``` + +Replace result assembly with: + +```typescript + const checkResults = await (options.runChecks ?? runDoctorChecks)(runner, { + repoRoot: config.repoRoot, + }); + const results = [ + ...(piResources.check ? [piResources.check] : []), + ...checkResults, + ]; +``` + +Keep quiet policy centralized: + +```typescript + const failed = hasDoctorFailures(results); + if (!config.quiet || failed) { + output.stdout(formatDoctorReport(results, piResources.blocks).join("\n")); + } + return failed ? 1 : 0; +``` + +- [ ] **Step 7: Run doctor unit tests** + +Run: + +```bash +node --test src/cli/commands/doctor/pi-resources.test.ts src/cli/commands/doctor/reporting.test.ts src/cli/commands/doctor/main.test.ts +node --test src/cli/commands/doctor/*.test.ts +``` + +Expected: PASS. + +- [ ] **Step 8: Commit Task 3** + +Run: + +```bash +git add src/cli/commands/doctor/reporting.ts src/cli/commands/doctor/reporting.test.ts src/cli/commands/doctor/main.ts src/cli/commands/doctor/main.test.ts +git commit -m "feat(doctor): print Pi resource profiles" +``` + +--- + +### Task 4: Verify End-to-End Behavior + +**Files:** + +- Modify only if verification exposes a specific defect in files touched by Tasks 1-3. + +**Interfaces:** + +- Consumes: all production and test interfaces from Tasks 1-3. +- Produces: final verified implementation with passing tests and lint. + +- [ ] **Step 1: Run focused tests** + +Run: + +```bash +node --test src/pi/resource-profiles.test.ts src/cli/commands/run-once/pi.test.ts src/pi/runner.test.ts src/cli/commands/triage/dry-run-agent.test.ts src/cli/commands/triage/execute-agent.test.ts src/cli/commands/doctor/*.test.ts +``` + +Expected: PASS. + +- [ ] **Step 2: Run the full test suite** + +Run: + +```bash +npm test +``` + +Expected: PASS. + +- [ ] **Step 3: Run lint** + +Run: + +```bash +npm run lint +``` + +Expected: PASS. If Prettier reports formatting changes, run `npm run format`, inspect the diff, then rerun `npm run lint`. + +- [ ] **Step 4: Manually inspect doctor output shape** + +Run: + +```bash +node bin/patchmill.ts doctor --quiet +``` + +Expected when readiness checks pass: no output. Expected when a required check fails in the local environment: report prints resource blocks and the failure checklist. + +Run: + +```bash +node bin/patchmill.ts doctor +``` + +Expected: resource profile blocks print first, followed by the existing `Patchmill doctor` checklist. If environment-specific host or provider checks fail, confirm the resource blocks still printed before the failure checklist. + +- [ ] **Step 5: Verify doctor did not mutate Pi package state** + +Before and after running `node bin/patchmill.ts doctor`, compare local package state paths that should not change: + +```bash +git status --short +find .patchmill/pi-agent -maxdepth 3 -type f 2>/dev/null | sort +``` + +Expected: no new git-tracked changes and no package install/update side effects caused by doctor resource discovery. Existing local state files unrelated to this run may already exist; do not delete or modify them. + +- [ ] **Step 6: Review the final diff** + +Run: + +```bash +git status --short +git diff --stat HEAD~3..HEAD +git diff HEAD~3..HEAD -- src/pi src/cli/commands/run-once src/cli/commands/triage src/cli/commands/doctor +``` + +Expected: The diff is limited to shared Pi resource profiles, runtime call-site refactors, doctor resource discovery/formatting, and tests. No package dependency files changed. + +- [ ] **Step 7: Commit verification fixes if needed** + +If Step 1-6 required code or test fixes after Task 3's commit, run: + +```bash +git add src/pi src/cli/commands/run-once src/cli/commands/triage src/cli/commands/doctor +git commit -m "fix(doctor): stabilize Pi resource profiles" +``` + +If no fixes were needed, leave the branch at the Task 3 commit. + +--- + +## Plan Self-Review Checklist + +- Spec coverage: Task 1 covers profile specificity and drift prevention; Task 2 covers read-only discovery, missing package skips, no extension execution, and trust parity; Task 3 covers report formatting and quiet behavior; Task 4 covers verification and mutation checks. +- Placeholder scan: The plan uses concrete file paths, function names, commands, expected results, and code snippets for every code-producing step. +- Type consistency: `PatchmillPiResourceProfile`, `DoctorPiResourceBlock`, `DoctorPiResourceReport`, `DoctorPiResourceProvider`, `formatPiResourceBlocks()`, `loadDoctorPiResources()`, and `piResourceDiscoveryFailureCheck()` are defined before they are consumed. diff --git a/docs/specs/2026-07-15-patchmill-doctor-pi-resources-design.md b/docs/specs/2026-07-15-patchmill-doctor-pi-resources-design.md new file mode 100644 index 0000000..28922ee --- /dev/null +++ b/docs/specs/2026-07-15-patchmill-doctor-pi-resources-design.md @@ -0,0 +1,147 @@ +# `patchmill doctor` Pi resource summary design + +## Goal + +`patchmill doctor` should show the Pi resources Patchmill will use, in a compact style similar to Pi's interactive startup resource summary: + +```text +[Pi resources: run-once planning] +[Context] + AGENTS.md + +[Skills] + writing-plans + +[Prompts] + /review-loop + +[Extensions] + pi-subagents, todos.ts +``` + +The report helps users diagnose why Patchmill-powered Pi runs see a specific context file, skill, prompt template, or extension before they start a workflow. + +## Non-goals + +- Do not add a new flag. The sections are part of normal non-quiet `patchmill doctor` output. +- Do not reproduce Pi's expanded interactive grouping UI. `doctor` prints compact, terminal-friendly lists only. +- Do not make `doctor` mutate Pi settings, project trust, labels, git state, or package installations while collecting resources. +- Do not execute extension code merely to list resources. +- Do not replace the existing readiness checks. The resource summary is additive. + +## Profiles to report + +Patchmill has more than one Pi invocation shape, so doctor must not report a single vague resource set. It should report these named resource profiles: + +- `run-once planning`: mirrors the plan/spec prompt path through `runPiPrompt()` with normal context files, prompt templates, auto-discovered non-mutating resources, the Patchmill-injected `pi-subagents` and `todos.ts` extensions, and the configured planning skill. +- `run-once development-environment`: mirrors the development-environment prompt path through `runPiPrompt()` with the same context/extension behavior and the configured development-environment skill when present. +- `run-once implementation`: mirrors the implementation prompt path through `runPiPrompt()` with the same context/extension behavior and configured toolchain, implementation, review, visual-evidence, and landing skills when present. +- `triage`: mirrors triage dry-run and execute agents with `--no-context-files`, no Patchmill-injected run-once extensions, and the configured triage skill. Prompt templates, trusted auto-discovered skills, and trusted auto-discovered extensions still appear when Pi would load them for that invocation. + +The provider smoke test remains a readiness check rather than a resource profile because it is intentionally minimal and already reports as `pi provider`. + +To prevent drift, define these profiles in shared code used by both doctor discovery and the actual Pi invocation argument builders. If a future Patchmill command adds a distinct Pi invocation shape, add a named profile beside the invocation code and include it in doctor output. + +## Non-mutating discovery boundary + +Do not call `DefaultResourceLoader.reload()` for doctor resource listing. In Pi's current implementation that path resolves package resources, may install missing npm/git package sources, and loads extension modules. That violates doctor's read-only contract. + +Instead, resource discovery should use Pi's public non-mutating pieces directly: + +- Construct a `SettingsManager` with the same trusted/untrusted state a non-interactive Pi run would use, without prompting and without writing trust decisions. +- Use `DefaultPackageManager.resolve(onMissing)` with `onMissing` returning `"skip"` so missing npm/git package sources are reported but not installed. +- Use `DefaultPackageManager.resolveExtensionSources()` only for Patchmill's known local extension paths that are already passed to Pi by the runtime profile. Do not use it for arbitrary package sources unless missing installs are also skipped. +- Use Pi's exported `loadSkills()` for enabled skill paths because it scans skill files without executing extensions. +- Derive prompt commands from enabled prompt-template file paths and their filenames. If Pi later exports a non-mutating prompt-template loader from the package root, prefer that API. +- Derive extension labels from enabled extension paths and metadata. Do not import or execute extension modules. + +Because extension code is not executed, doctor will not list resources dynamically contributed by `resources_discover` handlers. Report this limitation as part of the profile summary diagnostics only when such dynamic resources cannot be known; do not execute extensions to discover them. + +## Project trust parity + +Doctor resource discovery must match non-interactive Pi trust behavior as closely as possible without prompting or writing trust state: + +1. If the cwd has no trust-requiring project resources, treat project resources as trusted. +2. If `ProjectTrustStore(agentDir).get(repoRoot)` has a saved decision, use that decision. +3. Otherwise, load global settings only and honor `defaultProjectTrust`: `"always"` trusts project resources; `"ask"` and `"never"` do not. +4. Do not call the interactive trust prompt and do not write `trust.json`. + +When project resources are untrusted, omit project `.pi` resources and ancestor `.agents/skills` resources exactly as Pi's package/resource discovery does with `projectTrusted: false`. + +## Formatting + +For each named profile with at least one resource, print: + +```text +[Pi resources: ] + +[Context] + item, item + +[Skills] + item, item + +[Prompts] + /item, /item + +[Extensions] + item, item +``` + +Formatting rules: + +- `[Context]`: comma-separated display paths for loaded context files, omitted for profiles with `--no-context-files`. Use repository-relative labels when possible, such as `AGENTS.md`. +- `[Skills]`: comma-separated skill names, sorted alphabetically. +- `[Prompts]`: comma-separated slash commands, sorted alphabetically, for example `/parallel-review`. +- `[Extensions]`: comma-separated compact extension labels. For ordinary paths use the filename, or the parent directory when the extension is `index.ts`/`index.js`. For package-backed paths, include a concise package/source label. + +Only print resource categories that contain resources. Keep blank lines between profile blocks, resource sections, and the existing doctor checklist. + +## Quiet behavior + +`patchmill doctor --quiet` keeps its existing meaning: suppress successful non-failure output. Therefore: + +- If all readiness checks pass and resource discovery only produced sections or warnings, `--quiet` prints nothing. +- If any required readiness check fails, `--quiet` prints the same report as non-quiet mode, including resource sections and `pi resources` warnings. +- Resource-only warnings do not make `doctor` fail and do not force output under `--quiet`. + +Keep this policy centralized in the doctor report/output path rather than scattering special-case conditionals across resource discovery. + +## Diagnostics and failures + +Resource discovery must not prevent doctor from running existing checks. + +If non-mutating discovery skips missing package sources, add a warning check named `pi resources` that lists the skipped sources and tells the user to run Pi package installation/update outside doctor if they want those resources loaded. + +If discovery succeeds but Pi reports static resource diagnostics, add a warning check named `pi resources` with a concise summary and remediation to inspect or fix the affected Pi resource. + +If discovery throws unexpectedly, add a warning check named `pi resources` saying Patchmill could not list Pi resources, include the error message, and continue with existing doctor checks. This remains a warning because the separate `pi` and `pi provider` checks determine whether Patchmill can invoke Pi. + +Existing required failures keep the current exit-code behavior. Resource-summary warnings do not make `doctor` fail unless they also surface through an existing required check. + +## Tests + +Add focused tests around profile construction, non-mutating discovery, formatting, trust parity, and doctor integration: + +- profile builders match the actual run-once and triage Pi invocation arguments; +- missing package sources are skipped and reported rather than installed; +- project resources are included or omitted according to saved trust/defaultProjectTrust parity; +- compact profile sections are included in normal doctor output when resource discovery returns context, skills, prompts, and extensions; +- empty resource categories are omitted; +- discovery warnings are rendered as a `pi resources` warning without changing successful doctor exit behavior; +- discovery exceptions are rendered as a warning and do not skip existing checks; +- `--quiet` suppresses resource-only output when no required check fails; +- existing doctor failure behavior remains unchanged when a readiness check fails. + +Use stubs for the resource summary provider in doctor output tests so unit tests do not load real user Pi resources. Avoid tests that assert the exact contents of a developer's machine-specific Pi configuration. + +## Verification + +Run the existing doctor tests and full TypeScript test suite after implementation: + +```bash +npm test -- src/cli/commands/doctor/*.test.ts +npm test +``` + +Run `npm run lint` for formatting and type/lint validation. No dependency changes are planned, so the Nix build requirement for dependency changes does not apply. From a9c57cacb2826f65adb65143e7fcc726aa001da2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roch=C3=A9=20Compaan?= Date: Sat, 18 Jul 2026 12:36:46 +0200 Subject: [PATCH 2/9] refactor(pi): share resource profiles --- .../run-once/development-environment-stage.ts | 15 +- src/cli/commands/run-once/pi.test.ts | 19 ++- src/cli/commands/run-once/pi.ts | 33 +--- .../run-once/pipeline-implementation.ts | 21 ++- .../commands/run-once/stage-advancement.ts | 22 ++- src/cli/commands/triage/dry-run-agent.ts | 14 +- src/cli/commands/triage/execute-agent.ts | 14 +- src/pi/resource-profiles.test.ts | 120 +++++++++++++ src/pi/resource-profiles.ts | 158 ++++++++++++++++++ src/pi/runner.ts | 34 ++-- 10 files changed, 372 insertions(+), 78 deletions(-) create mode 100644 src/pi/resource-profiles.test.ts create mode 100644 src/pi/resource-profiles.ts diff --git a/src/cli/commands/run-once/development-environment-stage.ts b/src/cli/commands/run-once/development-environment-stage.ts index ac10a3d..a0a2a92 100644 --- a/src/cli/commands/run-once/development-environment-stage.ts +++ b/src/cli/commands/run-once/development-environment-stage.ts @@ -1,5 +1,8 @@ import { join } from "node:path"; -import { skillInvocationPaths } from "../../../workflow/skills.ts"; +import { + profileExtensionArgs, + runOnceDevelopmentEnvironmentPiProfile, +} from "../../../pi/resource-profiles.ts"; import type { IssueHostProvider } from "../../../host/types.ts"; import { planLabelChange } from "../triage/labels.ts"; import { parseDevelopmentEnvironmentResult, runPiPrompt } from "./pi.ts"; @@ -153,6 +156,10 @@ export async function runDevelopmentEnvironmentStage( } const worktreeRoot = join(options.config.repoRoot, options.worktreePath); + const profile = runOnceDevelopmentEnvironmentPiProfile( + options.config.skills, + options.config.repoRoot, + ); const result = await options.runStep( "development environment", async (): Promise => { @@ -177,10 +184,8 @@ export async function runDevelopmentEnvironmentStage( progress: options.progressReporter, stage: "pi-development-environment", parseResult: parseDevelopmentEnvironmentResult, - skillPaths: skillInvocationPaths( - [options.config.skills.toolchain, developmentEnvironmentSkill], - options.config.repoRoot, - ), + skillPaths: profile.additionalSkillPaths, + extensionArgs: profileExtensionArgs(profile), streamOutput: options.streamPiOutput, issueNumber: options.issue.number, repoRoot: worktreeRoot, diff --git a/src/cli/commands/run-once/pi.test.ts b/src/cli/commands/run-once/pi.test.ts index 6c773b6..c7c80e1 100644 --- a/src/cli/commands/run-once/pi.test.ts +++ b/src/cli/commands/run-once/pi.test.ts @@ -66,6 +66,13 @@ function promptPath(args: string[]): string { return promptArg.slice(1); } +const runOnceExtensionArgs = [ + "-e", + "/repo/node_modules/pi-subagents", + "-e", + "/repo/extensions/todos.ts", +]; + async function writeTodo( repoRoot: string, id: string, @@ -94,7 +101,10 @@ test("runPiPrompt writes the prompt to a temp file and surfaces nonzero pi failu }); await assert.rejects( - () => runPiPrompt(runner, "/repo/worktree", "prompt body"), + () => + runPiPrompt(runner, "/repo/worktree", "prompt body", { + extensionArgs: runOnceExtensionArgs, + }), /pi failed: pi exploded/, ); }); @@ -113,7 +123,10 @@ test("runPiPrompt loads bundled Pi extensions before the prompt argument", async }; }); - await runPiPrompt(runner, "/repo", "prompt", { stage: "pi-plan" }); + await runPiPrompt(runner, "/repo", "prompt", { + stage: "pi-plan", + extensionArgs: runOnceExtensionArgs, + }); }); test("runPiPrompt can parse development environment results", async () => { @@ -170,6 +183,7 @@ test("runPiPrompt passes configured skill files before the prompt argument", asy "/repo/.patchmill/skills/writing-plans/SKILL.md", "/repo/.patchmill/skills/review/SKILL.md", ], + extensionArgs: runOnceExtensionArgs, }); }); @@ -363,6 +377,7 @@ test("runPiPrompt streams messages appended to the prompted pi session JSONL", a const result = await runPiPrompt(runner, "/repo", "prompt", { stage: "pi-plan", + extensionArgs: runOnceExtensionArgs, streamOutput: (chunk) => streamed.push(chunk), }); diff --git a/src/cli/commands/run-once/pi.ts b/src/cli/commands/run-once/pi.ts index 26ec491..4c9e8b9 100644 --- a/src/cli/commands/run-once/pi.ts +++ b/src/cli/commands/run-once/pi.ts @@ -1,8 +1,6 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; -import { createRequire } from "node:module"; import { tmpdir } from "node:os"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; +import { join } from "node:path"; import { DEFAULT_PI_TASK_CONTRACT, type PatchmillPiTaskContract, @@ -31,32 +29,13 @@ import type { ProgressReporter, } from "./types.ts"; -const require = createRequire(import.meta.url); -const PI_SUBAGENTS_PACKAGE_ROOT = dirname( - require.resolve("pi-subagents/package.json"), -); -const PATCHMILL_PACKAGE_ROOT = resolve( - dirname(fileURLToPath(import.meta.url)), - "../../../..", -); -const PATCHMILL_TODOS_EXTENSION = join( - PATCHMILL_PACKAGE_ROOT, - "extensions", - "todos.ts", -); - function piPromptArgs( promptPath: string, sessionDir?: string, skillPaths: string[] = [], + extensionArgs: string[] = [], ): string[] { const skillArgs = skillPaths.flatMap((path) => ["--skill", path]); - const extensionArgs = [ - "-e", - PI_SUBAGENTS_PACKAGE_ROOT, - "-e", - PATCHMILL_TODOS_EXTENSION, - ]; const baseArgs = [...extensionArgs, ...skillArgs, "-p"]; return sessionDir ? [...baseArgs, "--session-dir", sessionDir, `@${promptPath}`] @@ -296,6 +275,7 @@ export type RunPiPromptOptions = { stage: RunPiPromptStage; parseResult?: (stdout: string) => Result; skillPaths?: string[]; + extensionArgs?: string[]; heartbeatMs?: number; streamOutput?: (chunk: string) => void; issueNumber?: number; @@ -511,7 +491,12 @@ export async function runPiPrompt( piCommand.command, piCommandArgs( piCommand, - piPromptArgs(promptPath, sessionDir, options?.skillPaths), + piPromptArgs( + promptPath, + sessionDir, + options?.skillPaths, + options?.extensionArgs, + ), ), { cwd, diff --git a/src/cli/commands/run-once/pipeline-implementation.ts b/src/cli/commands/run-once/pipeline-implementation.ts index 33db225..f87a3e6 100644 --- a/src/cli/commands/run-once/pipeline-implementation.ts +++ b/src/cli/commands/run-once/pipeline-implementation.ts @@ -1,5 +1,8 @@ import { join } from "node:path"; -import { skillInvocationPaths } from "../../../workflow/skills.ts"; +import { + profileExtensionArgs, + runOnceImplementationPiProfile, +} from "../../../pi/resource-profiles.ts"; import { runDevelopmentEnvironmentStage } from "./development-environment-stage.ts"; import { assertIssueTodosComplete, @@ -355,6 +358,10 @@ export async function runPipelineImplementationStage( targetBranch: worktreeStrategy.baseBranch, }, }; + const profile = runOnceImplementationPiProfile( + config.skills, + config.repoRoot, + ); piResult = await runPiPrompt( runner, @@ -380,16 +387,8 @@ export async function runPipelineImplementationStage( { progress: progressReporter, stage: "pi-implementation", - skillPaths: skillInvocationPaths( - [ - config.skills.toolchain, - config.skills.implementation, - config.skills.review, - config.skills.visualEvidence, - config.skills.landing, - ], - config.repoRoot, - ), + skillPaths: profile.additionalSkillPaths, + extensionArgs: profileExtensionArgs(profile), streamOutput: runOptions.streamPiOutput, issueNumber: issue.number, repoRoot: worktreeRoot, diff --git a/src/cli/commands/run-once/stage-advancement.ts b/src/cli/commands/run-once/stage-advancement.ts index 71ded33..f9b9e6c 100644 --- a/src/cli/commands/run-once/stage-advancement.ts +++ b/src/cli/commands/run-once/stage-advancement.ts @@ -1,6 +1,9 @@ import { isAbsolute, join, relative } from "node:path"; import type { IssueHostProvider } from "../../../host/types.ts"; -import { skillInvocationPaths } from "../../../workflow/skills.ts"; +import { + profileExtensionArgs, + runOncePlanningPiProfile, +} from "../../../pi/resource-profiles.ts"; import { planLabelChange } from "../triage/labels.ts"; import type { ResolvedIssueArtifactSources } from "./artifact-sources.ts"; import { ensureAutomationLabel } from "./automation-labels.ts"; @@ -338,6 +341,11 @@ export async function advancePlanningStages({ if (specCreated) checkpoints.specCreated = true; } + const planningProfile = runOncePlanningPiProfile( + config.skills, + planningRepoRoot, + ); + if (!spec.exists && !preexistingPlan.exists) { if (!specPath) throw new Error("Spec path was not resolved"); const createdSpecPath = specPath; @@ -359,10 +367,8 @@ export async function advancePlanningStages({ { progress: runOptions.progress, stage: "pi-plan", - skillPaths: skillInvocationPaths( - [config.skills.planning], - planningRepoRoot, - ), + skillPaths: planningProfile.additionalSkillPaths, + extensionArgs: profileExtensionArgs(planningProfile), streamOutput: runOptions.streamPiOutput, issueNumber: issue.number, repoRoot: planningRepoRoot, @@ -535,10 +541,8 @@ export async function advancePlanningStages({ { progress: runOptions.progress, stage: "pi-plan", - skillPaths: skillInvocationPaths( - [config.skills.planning], - planningRepoRoot, - ), + skillPaths: planningProfile.additionalSkillPaths, + extensionArgs: profileExtensionArgs(planningProfile), streamOutput: runOptions.streamPiOutput, issueNumber: issue.number, repoRoot: planningRepoRoot, diff --git a/src/cli/commands/triage/dry-run-agent.ts b/src/cli/commands/triage/dry-run-agent.ts index 8dbf13f..6e7eb3c 100644 --- a/src/cli/commands/triage/dry-run-agent.ts +++ b/src/cli/commands/triage/dry-run-agent.ts @@ -12,9 +12,13 @@ import { TRIAGE_CANONICAL_BUCKETS } from "../../../policy/triage-state.ts"; import type { PatchmillProjectPolicy } from "../../../policy/types.ts"; import { DEFAULT_PATCHMILL_SKILLS, - skillInvocationPaths, type PatchmillSkillsConfig, } from "../../../workflow/skills.ts"; +import { + profileContextArgs, + profileSkillArgs, + triagePiProfile, +} from "../../../pi/resource-profiles.ts"; import type { CommandRunner, IssueSummary, @@ -376,9 +380,7 @@ export async function runTriageDryRunAgent( ): Promise { const prompt = buildTriageDryRunPrompt(input); const skills = input.skills ?? DEFAULT_PATCHMILL_SKILLS; - const skillArgs = skillInvocationPaths([skills.triage], repoRoot).flatMap( - (path) => ["--skill", path], - ); + const profile = triagePiProfile(skills, repoRoot); const thinking = input.thinking ?? "high"; return withPromptFile("agent-triage-dry-run-", prompt, async (promptPath) => runWithToolCallObservation(input.onToolCall, async (sessionDir) => { @@ -391,9 +393,9 @@ export async function runTriageDryRunAgent( piCommandArgs(piCommand, [ "--tools", "read,grep,find,ls", - "--no-context-files", + ...profileContextArgs(profile), ...sessionArgs, - ...skillArgs, + ...profileSkillArgs(profile), "--thinking", thinking, "-p", diff --git a/src/cli/commands/triage/execute-agent.ts b/src/cli/commands/triage/execute-agent.ts index aa7e9b6..b4760f1 100644 --- a/src/cli/commands/triage/execute-agent.ts +++ b/src/cli/commands/triage/execute-agent.ts @@ -12,9 +12,13 @@ import type { PatchmillTriageStateMap } from "../../../policy/triage-state.ts"; import type { PatchmillProjectPolicy } from "../../../policy/types.ts"; import { DEFAULT_PATCHMILL_SKILLS, - skillInvocationPaths, type PatchmillSkillsConfig, } from "../../../workflow/skills.ts"; +import { + profileContextArgs, + profileSkillArgs, + triagePiProfile, +} from "../../../pi/resource-profiles.ts"; import type { CommandRunner, IssueSummary, @@ -113,9 +117,7 @@ export async function runTriageExecuteAgent( ): Promise { const prompt = buildTriageExecutePrompt(input); const skills = input.skills ?? DEFAULT_PATCHMILL_SKILLS; - const skillArgs = skillInvocationPaths([skills.triage], repoRoot).flatMap( - (path) => ["--skill", path], - ); + const profile = triagePiProfile(skills, repoRoot); const thinking = input.thinking ?? "high"; await withPromptFile("agent-triage-execute-", prompt, async (promptPath) => runWithToolCallObservation(input.onToolCall, async (sessionDir) => { @@ -126,9 +128,9 @@ export async function runTriageExecuteAgent( const result = await runner.run( piCommand.command, piCommandArgs(piCommand, [ - "--no-context-files", + ...profileContextArgs(profile), ...sessionArgs, - ...skillArgs, + ...profileSkillArgs(profile), "--thinking", thinking, "-p", diff --git a/src/pi/resource-profiles.test.ts b/src/pi/resource-profiles.test.ts new file mode 100644 index 0000000..6f72710 --- /dev/null +++ b/src/pi/resource-profiles.test.ts @@ -0,0 +1,120 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; +import { test } from "node:test"; +import { + doctorPiResourceProfiles, + profileContextArgs, + profileExtensionArgs, + profileSkillArgs, + runOnceImplementationPiProfile, + runOncePlanningPiProfile, + triagePiProfile, +} from "./resource-profiles.ts"; +import type { PatchmillSkillsConfig } from "../workflow/skills.ts"; + +async function withRepo(fn: (repoRoot: string) => Promise): Promise { + const repoRoot = await mkdtemp(join(tmpdir(), "patchmill-profile-")); + try { + return await fn(repoRoot); + } finally { + await rm(repoRoot, { recursive: true, force: true }); + } +} + +const skills: PatchmillSkillsConfig = { + triage: "./skills/triage", + planning: "./skills/planning", + implementation: "./skills/implementation", + developmentEnvironment: "./skills/development-environment", + toolchain: "./skills/toolchain", + review: "./skills/review", + visualEvidence: "./skills/visual-evidence", + landing: "./skills/landing", +}; + +test("run-once planning profile includes context and Patchmill run-once extensions", async () => { + await withRepo(async (repoRoot) => { + const profile = runOncePlanningPiProfile(skills, repoRoot); + + assert.equal(profile.id, "run-once-planning"); + assert.equal(profile.noContextFiles, false); + assert.equal(profile.noPromptTemplates, false); + assert.equal(profile.additionalExtensionPaths.length, 2); + assert.equal( + basename(profile.additionalExtensionPaths[0] ?? ""), + "pi-subagents", + ); + assert.equal( + profile.additionalExtensionPaths[1] + ?.replaceAll("\\", "/") + .endsWith("/extensions/todos.ts"), + true, + ); + assert.deepEqual(profile.additionalSkillPaths, [ + join(repoRoot, "skills", "planning", "SKILL.md"), + ]); + }); +}); + +test("run-once implementation profile includes every implementation-stage skill slot", async () => { + await withRepo(async (repoRoot) => { + assert.deepEqual( + runOnceImplementationPiProfile(skills, repoRoot).additionalSkillPaths, + [ + join(repoRoot, "skills", "toolchain", "SKILL.md"), + join(repoRoot, "skills", "implementation", "SKILL.md"), + join(repoRoot, "skills", "review", "SKILL.md"), + join(repoRoot, "skills", "visual-evidence", "SKILL.md"), + join(repoRoot, "skills", "landing", "SKILL.md"), + ], + ); + }); +}); + +test("triage profile mirrors triage agents", async () => { + await withRepo(async (repoRoot) => { + const profile = triagePiProfile(skills, repoRoot); + + assert.equal(profile.id, "triage"); + assert.equal(profile.noContextFiles, true); + assert.deepEqual(profile.additionalExtensionPaths, []); + assert.deepEqual(profileContextArgs(profile), ["--no-context-files"]); + assert.deepEqual(profileSkillArgs(profile), [ + "--skill", + join(repoRoot, "skills", "triage", "SKILL.md"), + ]); + }); +}); + +test("profile argument helpers render extension and skill flags", async () => { + await withRepo(async (repoRoot) => { + const profile = runOncePlanningPiProfile(skills, repoRoot); + + assert.deepEqual(profileExtensionArgs(profile), [ + "-e", + profile.additionalExtensionPaths[0], + "-e", + profile.additionalExtensionPaths[1], + ]); + assert.deepEqual(profileSkillArgs(profile), [ + "--skill", + join(repoRoot, "skills", "planning", "SKILL.md"), + ]); + }); +}); + +test("doctorPiResourceProfiles returns every reported profile in stable order", async () => { + await withRepo(async (repoRoot) => { + assert.deepEqual( + doctorPiResourceProfiles(skills, repoRoot).map((profile) => profile.id), + [ + "run-once-planning", + "run-once-development-environment", + "run-once-implementation", + "triage", + ], + ); + }); +}); diff --git a/src/pi/resource-profiles.ts b/src/pi/resource-profiles.ts new file mode 100644 index 0000000..201d1c1 --- /dev/null +++ b/src/pi/resource-profiles.ts @@ -0,0 +1,158 @@ +import { createRequire } from "node:module"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + skillInvocationPaths, + type PatchmillSkillsConfig, +} from "../workflow/skills.ts"; + +const require = createRequire(import.meta.url); +const PI_SUBAGENTS_PACKAGE_ROOT = dirname( + require.resolve("pi-subagents/package.json"), +); +const PATCHMILL_PACKAGE_ROOT = resolve( + dirname(fileURLToPath(import.meta.url)), + "../..", +); +const PATCHMILL_TODOS_EXTENSION = join( + PATCHMILL_PACKAGE_ROOT, + "extensions", + "todos.ts", +); + +export type PatchmillPiResourceProfileId = + | "run-once-planning" + | "run-once-development-environment" + | "run-once-implementation" + | "triage"; + +export type PatchmillPiResourceProfile = { + id: PatchmillPiResourceProfileId; + label: string; + noContextFiles: boolean; + noPromptTemplates: boolean; + additionalExtensionPaths: string[]; + additionalSkillPaths: string[]; +}; + +function runOnceExtensionPaths(): string[] { + return [PI_SUBAGENTS_PACKAGE_ROOT, PATCHMILL_TODOS_EXTENSION]; +} + +function profile( + input: Omit & { + skills: Array; + repoRoot: string; + }, +): PatchmillPiResourceProfile { + return { + id: input.id, + label: input.label, + noContextFiles: input.noContextFiles, + noPromptTemplates: input.noPromptTemplates, + additionalExtensionPaths: input.additionalExtensionPaths, + additionalSkillPaths: skillInvocationPaths(input.skills, input.repoRoot), + }; +} + +export function runOncePlanningPiProfile( + skills: PatchmillSkillsConfig, + repoRoot: string, +): PatchmillPiResourceProfile { + return profile({ + id: "run-once-planning", + label: "run-once planning", + noContextFiles: false, + noPromptTemplates: false, + additionalExtensionPaths: runOnceExtensionPaths(), + skills: [skills.planning], + repoRoot, + }); +} + +export function runOnceDevelopmentEnvironmentPiProfile( + skills: PatchmillSkillsConfig, + repoRoot: string, +): PatchmillPiResourceProfile { + return profile({ + id: "run-once-development-environment", + label: "run-once development-environment", + noContextFiles: false, + noPromptTemplates: false, + additionalExtensionPaths: runOnceExtensionPaths(), + skills: [skills.toolchain, skills.developmentEnvironment], + repoRoot, + }); +} + +export function runOnceImplementationPiProfile( + skills: PatchmillSkillsConfig, + repoRoot: string, +): PatchmillPiResourceProfile { + return profile({ + id: "run-once-implementation", + label: "run-once implementation", + noContextFiles: false, + noPromptTemplates: false, + additionalExtensionPaths: runOnceExtensionPaths(), + skills: [ + skills.toolchain, + skills.implementation, + skills.review, + skills.visualEvidence, + skills.landing, + ], + repoRoot, + }); +} + +export function triagePiProfile( + skills: PatchmillSkillsConfig, + repoRoot: string, +): PatchmillPiResourceProfile { + return profile({ + id: "triage", + label: "triage", + noContextFiles: true, + noPromptTemplates: false, + additionalExtensionPaths: [], + skills: [skills.triage], + repoRoot, + }); +} + +export function doctorPiResourceProfiles( + skills: PatchmillSkillsConfig, + repoRoot: string, +): PatchmillPiResourceProfile[] { + return [ + runOncePlanningPiProfile(skills, repoRoot), + runOnceDevelopmentEnvironmentPiProfile(skills, repoRoot), + runOnceImplementationPiProfile(skills, repoRoot), + triagePiProfile(skills, repoRoot), + ]; +} + +export function profileContextArgs( + profile: PatchmillPiResourceProfile, +): string[] { + return profile.noContextFiles ? ["--no-context-files"] : []; +} + +export function profilePromptTemplateArgs( + profile: PatchmillPiResourceProfile, +): string[] { + return profile.noPromptTemplates ? ["--no-prompt-templates"] : []; +} + +export function profileExtensionArgs( + profile: PatchmillPiResourceProfile, +): string[] { + return profile.additionalExtensionPaths.flatMap((path) => ["-e", path]); +} + +export function profileSkillArgs( + profile: PatchmillPiResourceProfile, +): string[] { + return profile.additionalSkillPaths.flatMap((path) => ["--skill", path]); +} diff --git a/src/pi/runner.ts b/src/pi/runner.ts index ae87831..4e8cac6 100644 --- a/src/pi/runner.ts +++ b/src/pi/runner.ts @@ -12,7 +12,12 @@ import type { PiPromptContracts, PlanPiInput, } from "./types.ts"; -import { skillInvocationPaths } from "../workflow/skills.ts"; +import { DEFAULT_PATCHMILL_SKILLS } from "../workflow/skills.ts"; +import { + profileExtensionArgs, + runOnceImplementationPiProfile, + runOncePlanningPiProfile, +} from "./resource-profiles.ts"; function defaultImplementationPolicy( baseBranch: string, @@ -35,6 +40,11 @@ export class PiRunner implements PiPromptContracts { plan(input: PlanPiInput) { const projectPolicy = input.projectPolicy ?? DEFAULT_PATCHMILL_POLICY; + const profile = runOncePlanningPiProfile( + input.skills ?? DEFAULT_PATCHMILL_SKILLS, + input.repoRoot, + ); + return runPiPrompt( this.runner, input.repoRoot, @@ -49,10 +59,8 @@ export class PiRunner implements PiPromptContracts { { ...input.runOptions, stage: "pi-plan", - skillPaths: skillInvocationPaths( - [input.skills?.planning], - input.repoRoot, - ), + skillPaths: profile.additionalSkillPaths, + extensionArgs: profileExtensionArgs(profile), issueNumber: input.issue.number, repoRoot: input.repoRoot, taskContract: projectPolicy.pi.taskContract, @@ -64,6 +72,10 @@ export class PiRunner implements PiPromptContracts { const worktreeRoot = resolve(input.repoRoot, input.worktreePath); const projectPolicy = input.projectPolicy ?? defaultImplementationPolicy(input.git.baseBranch); + const profile = runOnceImplementationPiProfile( + input.skills ?? DEFAULT_PATCHMILL_SKILLS, + input.repoRoot, + ); return runPiPrompt( this.runner, @@ -81,16 +93,8 @@ export class PiRunner implements PiPromptContracts { { ...input.runOptions, stage: "pi-implementation", - skillPaths: skillInvocationPaths( - [ - input.skills?.toolchain, - input.skills?.implementation, - input.skills?.review, - input.skills?.visualEvidence, - input.skills?.landing, - ], - input.repoRoot, - ), + skillPaths: profile.additionalSkillPaths, + extensionArgs: profileExtensionArgs(profile), issueNumber: input.issue.number, repoRoot: worktreeRoot, taskContract: projectPolicy.pi.taskContract, From 3aac290755d374d3f9ecd71cfc90d6f2136fee27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roch=C3=A9=20Compaan?= Date: Sat, 18 Jul 2026 12:37:24 +0200 Subject: [PATCH 3/9] feat(doctor): resolve Pi resources read-only --- src/cli/commands/doctor/pi-resources.test.ts | 138 ++++++++ src/cli/commands/doctor/pi-resources.ts | 312 +++++++++++++++++++ 2 files changed, 450 insertions(+) create mode 100644 src/cli/commands/doctor/pi-resources.test.ts create mode 100644 src/cli/commands/doctor/pi-resources.ts diff --git a/src/cli/commands/doctor/pi-resources.test.ts b/src/cli/commands/doctor/pi-resources.test.ts new file mode 100644 index 0000000..460fd0f --- /dev/null +++ b/src/cli/commands/doctor/pi-resources.test.ts @@ -0,0 +1,138 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { + compactProfileBlock, + formatPiResourceBlocks, + loadDoctorPiResources, + piResourceDiscoveryFailureCheck, + piResourceWarningCheck, +} from "./pi-resources.ts"; + +const repoRoot = "/repo/project"; + +test("compactProfileBlock builds sorted compact sections", () => { + const block = compactProfileBlock({ + label: "run-once planning", + contextFiles: [join(repoRoot, "AGENTS.md")], + skillNames: ["github", "brainstorming"], + promptNames: ["review-loop", "parallel-review"], + extensionPaths: [ + join(repoRoot, "extensions", "todos.ts"), + join(repoRoot, "extensions", "pi-remote", "extension", "index.ts"), + ], + repoRoot, + }); + + assert.deepEqual(block, { + label: "run-once planning", + sections: [ + { heading: "Context", items: ["AGENTS.md"] }, + { heading: "Skills", items: ["brainstorming", "github"] }, + { heading: "Prompts", items: ["/parallel-review", "/review-loop"] }, + { heading: "Extensions", items: ["extension", "todos.ts"] }, + ], + }); +}); + +test("formatPiResourceBlocks prints profile blocks", () => { + assert.deepEqual( + formatPiResourceBlocks([ + { + label: "run-once planning", + sections: [ + { heading: "Context", items: ["AGENTS.md"] }, + { heading: "Skills", items: ["github"] }, + ], + }, + ]), + [ + "[Pi resources: run-once planning]", + "", + "[Context]", + " AGENTS.md", + "", + "[Skills]", + " github", + ], + ); +}); + +test("compactProfileBlock omits empty categories", () => { + assert.deepEqual( + compactProfileBlock({ + label: "triage", + contextFiles: [], + skillNames: [], + promptNames: ["review-loop"], + extensionPaths: [], + repoRoot, + }), + { + label: "triage", + sections: [{ heading: "Prompts", items: ["/review-loop"] }], + }, + ); +}); + +test("piResourceWarningCheck returns undefined without warnings", () => { + assert.equal(piResourceWarningCheck([]), undefined); +}); + +test("piResourceWarningCheck reports skipped packages without failing", () => { + assert.deepEqual( + piResourceWarningCheck(["skipped missing package npm:@acme/pi-tools"]), + { + name: "pi resources", + status: "warn", + message: "skipped missing package npm:@acme/pi-tools", + remediation: [ + "Patchmill doctor listed Pi resources without installing missing packages or executing extensions.", + "Install or update the listed Pi package sources outside doctor if you want those resources loaded, then rerun:", + " patchmill doctor", + ], + }, + ); +}); + +test("piResourceDiscoveryFailureCheck creates a non-failing warning", () => { + assert.deepEqual(piResourceDiscoveryFailureCheck(new Error("boom")), { + name: "pi resources", + status: "warn", + message: "could not list Pi resources: boom", + remediation: [ + "Patchmill doctor could not list Pi's startup resources.", + "The readiness checks still ran; fix the Pi resource discovery error, then rerun:", + " patchmill doctor", + ], + }); +}); + +test("non-mutating discovery skips missing configured package sources", async () => { + const tmp = await mkdtemp(join(tmpdir(), "patchmill-doctor-resources-")); + try { + await writeFile( + join(tmp, "patchmill.config.json"), + JSON.stringify({ host: { provider: "forgejo-tea", repo: "OWNER/repo" } }), + "utf8", + ); + await mkdir(join(tmp, ".patchmill", "pi-agent"), { recursive: true }); + await writeFile( + join(tmp, ".patchmill", "pi-agent", "settings.json"), + JSON.stringify({ packages: ["npm:@missing/package@1.0.0"] }), + "utf8", + ); + + const report = await loadDoctorPiResources(tmp, {}); + + assert.equal(report.check?.status, "warn"); + assert.match( + report.check?.message ?? "", + /skipped missing package npm:@missing\/package@1\.0\.0/, + ); + } finally { + await rm(tmp, { recursive: true, force: true }); + } +}); diff --git a/src/cli/commands/doctor/pi-resources.ts b/src/cli/commands/doctor/pi-resources.ts new file mode 100644 index 0000000..a86b1a2 --- /dev/null +++ b/src/cli/commands/doctor/pi-resources.ts @@ -0,0 +1,312 @@ +import { homedir } from "node:os"; +import { + basename, + dirname, + isAbsolute, + join, + parse, + relative, + resolve, +} from "node:path"; +import { + DefaultPackageManager, + hasTrustRequiringProjectResources, + loadProjectContextFiles, + loadSkills, + ProjectTrustStore, + SettingsManager, + type MissingSourceAction, + type ResolvedResource, +} from "@earendil-works/pi-coding-agent"; +import { loadPatchmillConfigState } from "../../../config/load.ts"; +import { + doctorPiResourceProfiles, + type PatchmillPiResourceProfile, +} from "../../../pi/resource-profiles.ts"; +import { localPiAgentDir } from "../init/pi-agent-settings.ts"; +import type { DoctorCheckResult } from "./checks.ts"; + +export type DoctorPiResourceSection = { + heading: "Context" | "Skills" | "Prompts" | "Extensions"; + items: string[]; +}; + +export type DoctorPiResourceBlock = { + label: string; + sections: DoctorPiResourceSection[]; +}; + +export type DoctorPiResourceReport = { + blocks: DoctorPiResourceBlock[]; + check?: DoctorCheckResult; +}; + +export type DoctorPiResourceProvider = ( + repoRoot: string, +) => Promise; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function uniqueSorted(values: string[]): string[] { + return [...new Set(values.filter((value) => value.trim().length > 0))].sort( + (a, b) => a.localeCompare(b), + ); +} + +function isInside(parent: string, child: string): boolean { + const rel = relative(parent, child); + return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); +} + +function slashPath(path: string): string { + return path.replaceAll("\\", "/"); +} + +function displayPath(path: string, repoRoot: string): string { + const resolvedPath = resolve(path); + const resolvedRepoRoot = resolve(repoRoot); + if (isInside(resolvedRepoRoot, resolvedPath)) { + const rel = relative(resolvedRepoRoot, resolvedPath); + return slashPath(rel || basename(resolvedPath)); + } + + const home = homedir(); + if (isInside(home, resolvedPath)) { + return slashPath(join("~", relative(home, resolvedPath))); + } + + return slashPath(path); +} + +function compactExtensionLabel(path: string): string { + const normalizedPath = slashPath(path); + const parsed = parse(normalizedPath); + return parsed.base === "index.ts" || parsed.base === "index.js" + ? basename(dirname(normalizedPath)) + : parsed.base; +} + +export function compactProfileBlock(input: { + label: string; + contextFiles: string[]; + skillNames: string[]; + promptNames: string[]; + extensionPaths: string[]; + repoRoot: string; +}): DoctorPiResourceBlock { + const sections: DoctorPiResourceSection[] = []; + + const context = input.contextFiles.map((path) => + displayPath(path, input.repoRoot), + ); + if (context.length > 0) sections.push({ heading: "Context", items: context }); + + const skills = uniqueSorted(input.skillNames); + if (skills.length > 0) sections.push({ heading: "Skills", items: skills }); + + const prompts = uniqueSorted(input.promptNames.map((name) => `/${name}`)); + if (prompts.length > 0) sections.push({ heading: "Prompts", items: prompts }); + + const extensions = uniqueSorted( + input.extensionPaths.map(compactExtensionLabel), + ); + if (extensions.length > 0) { + sections.push({ heading: "Extensions", items: extensions }); + } + + return { label: input.label, sections }; +} + +export function formatPiResourceBlocks( + blocks: DoctorPiResourceBlock[], +): string[] { + return blocks.flatMap((block, blockIndex) => [ + ...(blockIndex === 0 ? [] : [""]), + `[Pi resources: ${block.label}]`, + "", + ...block.sections.flatMap((section, sectionIndex) => [ + ...(sectionIndex === 0 ? [] : [""]), + `[${section.heading}]`, + ` ${section.items.join(", ")}`, + ]), + ]); +} + +export function piResourceWarningCheck( + warnings: string[], +): DoctorCheckResult | undefined { + const uniqueWarnings = uniqueSorted(warnings); + if (uniqueWarnings.length === 0) return undefined; + return { + name: "pi resources", + status: "warn", + message: uniqueWarnings.join("; "), + remediation: [ + "Patchmill doctor listed Pi resources without installing missing packages or executing extensions.", + "Install or update the listed Pi package sources outside doctor if you want those resources loaded, then rerun:", + " patchmill doctor", + ], + }; +} + +export function piResourceDiscoveryFailureCheck( + error: unknown, +): DoctorCheckResult { + return { + name: "pi resources", + status: "warn", + message: `could not list Pi resources: ${errorMessage(error)}`, + remediation: [ + "Patchmill doctor could not list Pi's startup resources.", + "The readiness checks still ran; fix the Pi resource discovery error, then rerun:", + " patchmill doctor", + ], + }; +} + +function projectTrustedForResourceListing( + repoRoot: string, + agentDir: string, +): boolean { + if (!hasTrustRequiringProjectResources(repoRoot)) return true; + + const savedDecision = new ProjectTrustStore(agentDir).get(repoRoot); + if (savedDecision !== null) return savedDecision; + + const globalOnlySettings = SettingsManager.create(repoRoot, agentDir, { + projectTrusted: false, + }); + return globalOnlySettings.getDefaultProjectTrust() === "always"; +} + +function enabledPaths(resources: ResolvedResource[]): string[] { + return resources + .filter((resource) => resource.enabled) + .map((resource) => resource.path); +} + +function promptName(path: string): string { + return basename(path).replace(/\.md$/u, ""); +} + +function contextFilePaths(input: { + repoRoot: string; + agentDir: string; + projectTrusted: boolean; +}): string[] { + const files = loadProjectContextFiles({ + cwd: input.repoRoot, + agentDir: input.agentDir, + }).map((file) => file.path); + + return input.projectTrusted + ? files + : files.filter((file) => isInside(resolve(input.agentDir), resolve(file))); +} + +async function resolveStaticResources(input: { + repoRoot: string; + agentDir: string; + profile: PatchmillPiResourceProfile; + warnings: string[]; +}): Promise<{ + contextFiles: string[]; + skillNames: string[]; + promptNames: string[]; + extensionPaths: string[]; +}> { + const projectTrusted = projectTrustedForResourceListing( + input.repoRoot, + input.agentDir, + ); + const settingsManager = SettingsManager.create( + input.repoRoot, + input.agentDir, + { + projectTrusted, + }, + ); + const packageManager = new DefaultPackageManager({ + cwd: input.repoRoot, + agentDir: input.agentDir, + settingsManager, + }); + + const onMissing = async (source: string): Promise => { + input.warnings.push(`skipped missing package ${source}`); + return "skip"; + }; + + const resolved = await packageManager.resolve(onMissing); + const baseSkillPaths = enabledPaths(resolved.skills); + const basePromptPaths = input.profile.noPromptTemplates + ? [] + : enabledPaths(resolved.prompts); + const baseExtensionPaths = enabledPaths(resolved.extensions); + + const skills = loadSkills({ + cwd: input.repoRoot, + agentDir: input.agentDir, + includeDefaults: false, + skillPaths: [...baseSkillPaths, ...input.profile.additionalSkillPaths], + }); + input.warnings.push( + ...skills.diagnostics.map((diagnostic) => + diagnostic.path + ? `skills: ${diagnostic.message} (${diagnostic.path})` + : `skills: ${diagnostic.message}`, + ), + ); + + return { + contextFiles: input.profile.noContextFiles + ? [] + : contextFilePaths({ + repoRoot: input.repoRoot, + agentDir: input.agentDir, + projectTrusted, + }), + skillNames: skills.skills.map((skill) => skill.name), + promptNames: basePromptPaths.map(promptName), + extensionPaths: [ + ...baseExtensionPaths, + ...input.profile.additionalExtensionPaths, + ], + }; +} + +export async function loadDoctorPiResources( + repoRoot: string, + env: Record = process.env, +): Promise { + const agentDir = localPiAgentDir(repoRoot); + const warnings: string[] = []; + + try { + const loaded = await loadPatchmillConfigState(repoRoot, env, []); + const blocks: DoctorPiResourceBlock[] = []; + for (const profile of doctorPiResourceProfiles( + loaded.config.skills, + repoRoot, + )) { + const resources = await resolveStaticResources({ + repoRoot, + agentDir, + profile, + warnings, + }); + const block = compactProfileBlock({ + label: profile.label, + repoRoot, + ...resources, + }); + if (block.sections.length > 0) blocks.push(block); + } + + return { blocks, check: piResourceWarningCheck(warnings) }; + } catch (error) { + return { blocks: [], check: piResourceDiscoveryFailureCheck(error) }; + } +} From 5fa45fd0b70e0c8adc691bf3df1ecb02c055ce57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roch=C3=A9=20Compaan?= Date: Sat, 18 Jul 2026 12:37:38 +0200 Subject: [PATCH 4/9] feat(doctor): print Pi resource profiles --- src/cli/commands/doctor/main.test.ts | 168 +++++++++++++++++++++- src/cli/commands/doctor/main.ts | 30 +++- src/cli/commands/doctor/reporting.test.ts | 33 +++++ src/cli/commands/doctor/reporting.ts | 17 ++- 4 files changed, 242 insertions(+), 6 deletions(-) diff --git a/src/cli/commands/doctor/main.test.ts b/src/cli/commands/doctor/main.test.ts index f1f5a68..43efb60 100644 --- a/src/cli/commands/doctor/main.test.ts +++ b/src/cli/commands/doctor/main.test.ts @@ -3,6 +3,7 @@ import { test } from "node:test"; import { HELP_TEXT, runDoctor } from "./main.ts"; import type { CommandRunner } from "../triage/types.ts"; import type { DoctorCheckResult } from "./checks.ts"; +import type { DoctorPiResourceReport } from "./pi-resources.ts"; const runner: CommandRunner = { async run() { @@ -10,6 +11,8 @@ const runner: CommandRunner = { }, }; +const emptyResources: DoctorPiResourceReport = { blocks: [] }; + test("runDoctor prints help", async () => { const stdout: string[] = []; const stderr: string[] = []; @@ -42,7 +45,11 @@ test("runDoctor returns zero when checks pass", async () => { [], "/repo", { stdout: (line) => stdout.push(line), stderr: () => undefined }, - { runner, runChecks: async () => checks }, + { + runner, + loadPiResources: async () => emptyResources, + runChecks: async () => checks, + }, ), 0, ); @@ -60,7 +67,11 @@ test("runDoctor returns one when any check fails", async () => { [], "/repo", { stdout: (line) => stdout.push(line), stderr: () => undefined }, - { runner, runChecks: async () => checks }, + { + runner, + loadPiResources: async () => emptyResources, + runChecks: async () => checks, + }, ), 1, ); @@ -78,6 +89,7 @@ test("runDoctor --fix runs label setup and prints its review", async () => { { stdout: (line) => stdout.push(line), stderr: () => undefined }, { runner, + loadPiResources: async () => emptyResources, runChecks: async () => [ { name: "labels", status: "pass", message: "ok" }, ], @@ -111,6 +123,7 @@ test("runDoctor --fix --yes passes assumeYes", async () => { { stdout: () => undefined, stderr: () => undefined }, { runner, + loadPiResources: async () => emptyResources, runChecks: async () => [], setupLabels: async (options) => { assumeYes = options.assumeYes; @@ -128,3 +141,154 @@ test("runDoctor --fix --yes passes assumeYes", async () => { assert.equal(assumeYes, true); }); + +test("runDoctor prints Pi resource blocks before checks", async () => { + const stdout: string[] = []; + + assert.equal( + await runDoctor( + [], + "/repo", + { stdout: (line) => stdout.push(line), stderr: () => undefined }, + { + runner, + loadPiResources: async () => ({ + blocks: [ + { + label: "run-once planning", + sections: [ + { heading: "Context", items: ["AGENTS.md"] }, + { heading: "Skills", items: ["github"] }, + ], + }, + ], + }), + runChecks: async () => [ + { name: "config", status: "pass", message: "patchmill.config.json" }, + ], + }, + ), + 0, + ); + + assert.match( + stdout.join("\n"), + /^\[Pi resources: run-once planning\]\n\n\[Context\]\n {2}AGENTS\.md\n\n\[Skills\]\n {2}github\n\nPatchmill doctor/m, + ); +}); + +test("runDoctor adds Pi resource warnings without failing", async () => { + const stdout: string[] = []; + + assert.equal( + await runDoctor( + [], + "/repo", + { stdout: (line) => stdout.push(line), stderr: () => undefined }, + { + runner, + loadPiResources: async () => ({ + blocks: [], + check: { + name: "pi resources", + status: "warn", + message: "skipped missing package npm:@acme/pi-tools", + }, + }), + runChecks: async () => [ + { name: "config", status: "pass", message: "patchmill.config.json" }, + ], + }, + ), + 0, + ); + + assert.match(stdout.join("\n"), /! pi resources: skipped missing package/); + assert.match(stdout.join("\n"), /Ready for safe dry runs/); +}); + +test("runDoctor converts thrown Pi resource provider errors to warnings", async () => { + const stdout: string[] = []; + + assert.equal( + await runDoctor( + [], + "/repo", + { stdout: (line) => stdout.push(line), stderr: () => undefined }, + { + runner, + loadPiResources: async () => { + throw new Error("resource load exploded"); + }, + runChecks: async () => [ + { name: "config", status: "pass", message: "patchmill.config.json" }, + ], + }, + ), + 0, + ); + + assert.match( + stdout.join("\n"), + /! pi resources: could not list Pi resources: resource load exploded/, + ); +}); + +test("runDoctor --quiet suppresses resource-only output", async () => { + const stdout: string[] = []; + + assert.equal( + await runDoctor( + ["--quiet"], + "/repo", + { stdout: (line) => stdout.push(line), stderr: () => undefined }, + { + runner, + loadPiResources: async () => ({ + blocks: [ + { + label: "run-once planning", + sections: [{ heading: "Skills", items: ["github"] }], + }, + ], + }), + runChecks: async () => [ + { name: "config", status: "pass", message: "patchmill.config.json" }, + ], + }, + ), + 0, + ); + + assert.deepEqual(stdout, []); +}); + +test("runDoctor --quiet prints resources when a required check fails", async () => { + const stdout: string[] = []; + + assert.equal( + await runDoctor( + ["--quiet"], + "/repo", + { stdout: (line) => stdout.push(line), stderr: () => undefined }, + { + runner, + loadPiResources: async () => ({ + blocks: [ + { + label: "run-once planning", + sections: [{ heading: "Skills", items: ["github"] }], + }, + ], + }), + runChecks: async () => [ + { name: "config", status: "fail", message: "missing" }, + ], + }, + ), + 1, + ); + + assert.match(stdout.join("\n"), /\[Pi resources: run-once planning\]/); + assert.match(stdout.join("\n"), /✗ config: missing/); +}); diff --git a/src/cli/commands/doctor/main.ts b/src/cli/commands/doctor/main.ts index dd959b4..c6f83c2 100644 --- a/src/cli/commands/doctor/main.ts +++ b/src/cli/commands/doctor/main.ts @@ -10,6 +10,12 @@ import { createCommandRunner } from "../triage/command.ts"; import { parseArgs } from "./args.ts"; import { runDoctorChecks, type DoctorCheckResult } from "./checks.ts"; import { formatDoctorReport, hasDoctorFailures } from "./reporting.ts"; +import { + loadDoctorPiResources, + piResourceDiscoveryFailureCheck, + type DoctorPiResourceProvider, + type DoctorPiResourceReport, +} from "./pi-resources.ts"; import type { CommandRunner } from "../triage/types.ts"; import { loadPatchmillConfigState } from "../../../config/load.ts"; import { createIssueHostProvider } from "../../../host/factory.ts"; @@ -83,6 +89,17 @@ async function runDoctorLabelSetup( }); } +async function safeLoadPiResources( + provider: DoctorPiResourceProvider, + repoRoot: string, +): Promise { + try { + return await provider(repoRoot); + } catch (error) { + return { blocks: [], check: piResourceDiscoveryFailureCheck(error) }; + } +} + export async function runDoctor( args: string[], repoRoot = cwd(), @@ -96,6 +113,7 @@ export async function runDoctor( runner: CommandRunner, options: { repoRoot: string }, ) => Promise; + loadPiResources?: DoctorPiResourceProvider; } = {}, ): Promise { const config = parseArgs(args, repoRoot); @@ -121,12 +139,20 @@ export async function runDoctor( ); } } - const results = await (options.runChecks ?? runDoctorChecks)(runner, { + const piResources = await safeLoadPiResources( + options.loadPiResources ?? loadDoctorPiResources, + config.repoRoot, + ); + const checkResults = await (options.runChecks ?? runDoctorChecks)(runner, { repoRoot: config.repoRoot, }); + const results = [ + ...(piResources.check ? [piResources.check] : []), + ...checkResults, + ]; const failed = hasDoctorFailures(results); if (!config.quiet || failed) { - output.stdout(formatDoctorReport(results).join("\n")); + output.stdout(formatDoctorReport(results, piResources.blocks).join("\n")); } return failed ? 1 : 0; } diff --git a/src/cli/commands/doctor/reporting.test.ts b/src/cli/commands/doctor/reporting.test.ts index f176fd4..fc59508 100644 --- a/src/cli/commands/doctor/reporting.test.ts +++ b/src/cli/commands/doctor/reporting.test.ts @@ -72,3 +72,36 @@ test("formatDoctorReport prints warnings but keeps next command", () => { ); assert.match(lines.join("\n"), /Ready for safe dry runs/); }); + +test("formatDoctorReport prepends Pi resource blocks", () => { + assert.deepEqual( + formatDoctorReport(passing, [ + { + label: "run-once planning", + sections: [ + { heading: "Context", items: ["AGENTS.md"] }, + { heading: "Skills", items: ["github"] }, + ], + }, + ]), + [ + "[Pi resources: run-once planning]", + "", + "[Context]", + " AGENTS.md", + "", + "[Skills]", + " github", + "", + "Patchmill doctor", + "", + "✓ config: patchmill.config.json", + "✓ git: clean worktree", + "", + "Ready for safe dry runs.", + "", + "Next:", + " patchmill triage --dry-run", + ], + ); +}); diff --git a/src/cli/commands/doctor/reporting.ts b/src/cli/commands/doctor/reporting.ts index 73f501f..c858b45 100644 --- a/src/cli/commands/doctor/reporting.ts +++ b/src/cli/commands/doctor/reporting.ts @@ -1,4 +1,8 @@ import type { DoctorCheckResult } from "./checks.ts"; +import { + formatPiResourceBlocks, + type DoctorPiResourceBlock, +} from "./pi-resources.ts"; function prefix(status: DoctorCheckResult["status"]): string { if (status === "pass") return "✓"; @@ -10,8 +14,17 @@ export function hasDoctorFailures(results: DoctorCheckResult[]): boolean { return results.some((result) => result.status === "fail"); } -export function formatDoctorReport(results: DoctorCheckResult[]): string[] { - const lines = ["Patchmill doctor", ""]; +export function formatDoctorReport( + results: DoctorCheckResult[], + resourceBlocks: DoctorPiResourceBlock[] = [], +): string[] { + const resourceLines = formatPiResourceBlocks(resourceBlocks); + const lines = [ + ...resourceLines, + ...(resourceLines.length > 0 ? [""] : []), + "Patchmill doctor", + "", + ]; lines.push( ...results.map( (result) => `${prefix(result.status)} ${result.name}: ${result.message}`, From 37e258670125a81e5ed710af529e4ab2cf9c1827 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roch=C3=A9=20Compaan?= Date: Sat, 18 Jul 2026 12:44:59 +0200 Subject: [PATCH 5/9] fix(doctor): clarify Pi resource diagnostics --- src/cli/commands/doctor/pi-resources.test.ts | 2 +- src/cli/commands/doctor/pi-resources.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cli/commands/doctor/pi-resources.test.ts b/src/cli/commands/doctor/pi-resources.test.ts index 460fd0f..17fb461 100644 --- a/src/cli/commands/doctor/pi-resources.test.ts +++ b/src/cli/commands/doctor/pi-resources.test.ts @@ -90,7 +90,7 @@ test("piResourceWarningCheck reports skipped packages without failing", () => { message: "skipped missing package npm:@acme/pi-tools", remediation: [ "Patchmill doctor listed Pi resources without installing missing packages or executing extensions.", - "Install or update the listed Pi package sources outside doctor if you want those resources loaded, then rerun:", + "Install or update skipped Pi package sources outside doctor, and inspect or fix the listed static Pi resource diagnostics, then rerun:", " patchmill doctor", ], }, diff --git a/src/cli/commands/doctor/pi-resources.ts b/src/cli/commands/doctor/pi-resources.ts index a86b1a2..9b1b590 100644 --- a/src/cli/commands/doctor/pi-resources.ts +++ b/src/cli/commands/doctor/pi-resources.ts @@ -145,7 +145,7 @@ export function piResourceWarningCheck( message: uniqueWarnings.join("; "), remediation: [ "Patchmill doctor listed Pi resources without installing missing packages or executing extensions.", - "Install or update the listed Pi package sources outside doctor if you want those resources loaded, then rerun:", + "Install or update skipped Pi package sources outside doctor, and inspect or fix the listed static Pi resource diagnostics, then rerun:", " patchmill doctor", ], }; From 4c3516ae057ecdad9ebd0dc23fa1172cf4ba3f2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roch=C3=A9=20Compaan?= Date: Sat, 18 Jul 2026 12:50:19 +0200 Subject: [PATCH 6/9] fix(doctor): mirror Pi context resource loading --- src/cli/commands/doctor/pi-resources.test.ts | 25 ++++++++++++++++++++ src/cli/commands/doctor/pi-resources.ts | 8 +------ 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/src/cli/commands/doctor/pi-resources.test.ts b/src/cli/commands/doctor/pi-resources.test.ts index 17fb461..e786109 100644 --- a/src/cli/commands/doctor/pi-resources.test.ts +++ b/src/cli/commands/doctor/pi-resources.test.ts @@ -136,3 +136,28 @@ test("non-mutating discovery skips missing configured package sources", async () await rm(tmp, { recursive: true, force: true }); } }); + +test("context files mirror Pi even when project package resources are untrusted", async () => { + const tmp = await mkdtemp(join(tmpdir(), "patchmill-doctor-context-")); + try { + await writeFile( + join(tmp, "patchmill.config.json"), + JSON.stringify({ host: { provider: "forgejo-tea", repo: "OWNER/repo" } }), + "utf8", + ); + await writeFile(join(tmp, "AGENTS.md"), "# project instructions\n", "utf8"); + await mkdir(join(tmp, ".agents", "skills"), { recursive: true }); + + const report = await loadDoctorPiResources(tmp, {}); + const planning = report.blocks.find( + (block) => block.label === "run-once planning", + ); + const context = planning?.sections.find( + (section) => section.heading === "Context", + ); + + assert.ok(context?.items.includes("AGENTS.md")); + } finally { + await rm(tmp, { recursive: true, force: true }); + } +}); diff --git a/src/cli/commands/doctor/pi-resources.ts b/src/cli/commands/doctor/pi-resources.ts index 9b1b590..83c12b0 100644 --- a/src/cli/commands/doctor/pi-resources.ts +++ b/src/cli/commands/doctor/pi-resources.ts @@ -194,16 +194,11 @@ function promptName(path: string): string { function contextFilePaths(input: { repoRoot: string; agentDir: string; - projectTrusted: boolean; }): string[] { - const files = loadProjectContextFiles({ + return loadProjectContextFiles({ cwd: input.repoRoot, agentDir: input.agentDir, }).map((file) => file.path); - - return input.projectTrusted - ? files - : files.filter((file) => isInside(resolve(input.agentDir), resolve(file))); } async function resolveStaticResources(input: { @@ -266,7 +261,6 @@ async function resolveStaticResources(input: { : contextFilePaths({ repoRoot: input.repoRoot, agentDir: input.agentDir, - projectTrusted, }), skillNames: skills.skills.map((skill) => skill.name), promptNames: basePromptPaths.map(promptName), From 204f4888c30fb19fa242319717c5955d9bc3face Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roch=C3=A9=20Compaan?= Date: Sat, 18 Jul 2026 12:58:14 +0200 Subject: [PATCH 7/9] fix(doctor): simplify Pi resource projection --- src/cli/commands/doctor/pi-resources.test.ts | 47 +++++++- src/cli/commands/doctor/pi-resources.ts | 109 ++++++++++++++----- 2 files changed, 126 insertions(+), 30 deletions(-) diff --git a/src/cli/commands/doctor/pi-resources.test.ts b/src/cli/commands/doctor/pi-resources.test.ts index e786109..a61a9d0 100644 --- a/src/cli/commands/doctor/pi-resources.test.ts +++ b/src/cli/commands/doctor/pi-resources.test.ts @@ -19,9 +19,17 @@ test("compactProfileBlock builds sorted compact sections", () => { contextFiles: [join(repoRoot, "AGENTS.md")], skillNames: ["github", "brainstorming"], promptNames: ["review-loop", "parallel-review"], - extensionPaths: [ - join(repoRoot, "extensions", "todos.ts"), - join(repoRoot, "extensions", "pi-remote", "extension", "index.ts"), + extensionResources: [ + { path: join(repoRoot, "extensions", "todos.ts") }, + { + path: join( + repoRoot, + "extensions", + "pi-remote", + "extension", + "index.ts", + ), + }, ], repoRoot, }); @@ -67,7 +75,7 @@ test("compactProfileBlock omits empty categories", () => { contextFiles: [], skillNames: [], promptNames: ["review-loop"], - extensionPaths: [], + extensionResources: [], repoRoot, }), { @@ -77,6 +85,37 @@ test("compactProfileBlock omits empty categories", () => { ); }); +test("compactProfileBlock labels package-backed extensions with their source", () => { + const block = compactProfileBlock({ + label: "run-once planning", + contextFiles: [], + skillNames: [], + promptNames: [], + extensionResources: [ + { + path: join( + repoRoot, + ".pi", + "packages", + "acme", + "extension", + "index.ts", + ), + metadata: { source: "npm:@acme/pi-tools@1.0.0", origin: "package" }, + }, + { path: join(repoRoot, "extensions", "todos.ts") }, + ], + repoRoot, + }); + + assert.deepEqual(block.sections, [ + { + heading: "Extensions", + items: ["npm:@acme/pi-tools@1.0.0: extension", "todos.ts"], + }, + ]); +}); + test("piResourceWarningCheck returns undefined without warnings", () => { assert.equal(piResourceWarningCheck([]), undefined); }); diff --git a/src/cli/commands/doctor/pi-resources.ts b/src/cli/commands/doctor/pi-resources.ts index 83c12b0..6791103 100644 --- a/src/cli/commands/doctor/pi-resources.ts +++ b/src/cli/commands/doctor/pi-resources.ts @@ -36,6 +36,11 @@ export type DoctorPiResourceBlock = { sections: DoctorPiResourceSection[]; }; +export type DoctorPiExtensionResource = { + path: string; + metadata?: Pick; +}; + export type DoctorPiResourceReport = { blocks: DoctorPiResourceBlock[]; check?: DoctorCheckResult; @@ -80,7 +85,7 @@ function displayPath(path: string, repoRoot: string): string { return slashPath(path); } -function compactExtensionLabel(path: string): string { +function compactExtensionPathLabel(path: string): string { const normalizedPath = slashPath(path); const parsed = parse(normalizedPath); return parsed.base === "index.ts" || parsed.base === "index.js" @@ -88,12 +93,19 @@ function compactExtensionLabel(path: string): string { : parsed.base; } +function compactExtensionLabel(resource: DoctorPiExtensionResource): string { + const pathLabel = compactExtensionPathLabel(resource.path); + return resource.metadata?.origin === "package" + ? `${resource.metadata.source}: ${pathLabel}` + : pathLabel; +} + export function compactProfileBlock(input: { label: string; contextFiles: string[]; skillNames: string[]; promptNames: string[]; - extensionPaths: string[]; + extensionResources: DoctorPiExtensionResource[]; repoRoot: string; }): DoctorPiResourceBlock { const sections: DoctorPiResourceSection[] = []; @@ -110,7 +122,7 @@ export function compactProfileBlock(input: { if (prompts.length > 0) sections.push({ heading: "Prompts", items: prompts }); const extensions = uniqueSorted( - input.extensionPaths.map(compactExtensionLabel), + input.extensionResources.map(compactExtensionLabel), ); if (extensions.length > 0) { sections.push({ heading: "Extensions", items: extensions }); @@ -187,6 +199,24 @@ function enabledPaths(resources: ResolvedResource[]): string[] { .map((resource) => resource.path); } +function enabledExtensionResources( + resources: ResolvedResource[], +): DoctorPiExtensionResource[] { + return resources + .filter((resource) => resource.enabled) + .map((resource) => ({ + path: resource.path, + metadata: { + source: resource.metadata.source, + origin: resource.metadata.origin, + }, + })); +} + +function localExtensionResources(paths: string[]): DoctorPiExtensionResource[] { + return paths.map((path) => ({ path })); +} + function promptName(path: string): string { return basename(path).replace(/\.md$/u, ""); } @@ -201,17 +231,18 @@ function contextFilePaths(input: { }).map((file) => file.path); } -async function resolveStaticResources(input: { +type StaticBasePiResources = { + contextFiles: string[]; + skillPaths: string[]; + promptNames: string[]; + extensionResources: DoctorPiExtensionResource[]; +}; + +async function resolveStaticBaseResources(input: { repoRoot: string; agentDir: string; - profile: PatchmillPiResourceProfile; warnings: string[]; -}): Promise<{ - contextFiles: string[]; - skillNames: string[]; - promptNames: string[]; - extensionPaths: string[]; -}> { +}): Promise { const projectTrusted = projectTrustedForResourceListing( input.repoRoot, input.agentDir, @@ -235,17 +266,38 @@ async function resolveStaticResources(input: { }; const resolved = await packageManager.resolve(onMissing); - const baseSkillPaths = enabledPaths(resolved.skills); - const basePromptPaths = input.profile.noPromptTemplates - ? [] - : enabledPaths(resolved.prompts); - const baseExtensionPaths = enabledPaths(resolved.extensions); + return { + contextFiles: contextFilePaths({ + repoRoot: input.repoRoot, + agentDir: input.agentDir, + }), + skillPaths: enabledPaths(resolved.skills), + promptNames: enabledPaths(resolved.prompts).map(promptName), + extensionResources: enabledExtensionResources(resolved.extensions), + }; +} + +function profileStaticResources(input: { + repoRoot: string; + agentDir: string; + profile: PatchmillPiResourceProfile; + baseResources: StaticBasePiResources; + warnings: string[]; +}): { + contextFiles: string[]; + skillNames: string[]; + promptNames: string[]; + extensionResources: DoctorPiExtensionResource[]; +} { const skills = loadSkills({ cwd: input.repoRoot, agentDir: input.agentDir, includeDefaults: false, - skillPaths: [...baseSkillPaths, ...input.profile.additionalSkillPaths], + skillPaths: [ + ...input.baseResources.skillPaths, + ...input.profile.additionalSkillPaths, + ], }); input.warnings.push( ...skills.diagnostics.map((diagnostic) => @@ -258,15 +310,14 @@ async function resolveStaticResources(input: { return { contextFiles: input.profile.noContextFiles ? [] - : contextFilePaths({ - repoRoot: input.repoRoot, - agentDir: input.agentDir, - }), + : input.baseResources.contextFiles, skillNames: skills.skills.map((skill) => skill.name), - promptNames: basePromptPaths.map(promptName), - extensionPaths: [ - ...baseExtensionPaths, - ...input.profile.additionalExtensionPaths, + promptNames: input.profile.noPromptTemplates + ? [] + : input.baseResources.promptNames, + extensionResources: [ + ...input.baseResources.extensionResources, + ...localExtensionResources(input.profile.additionalExtensionPaths), ], }; } @@ -280,15 +331,21 @@ export async function loadDoctorPiResources( try { const loaded = await loadPatchmillConfigState(repoRoot, env, []); + const baseResources = await resolveStaticBaseResources({ + repoRoot, + agentDir, + warnings, + }); const blocks: DoctorPiResourceBlock[] = []; for (const profile of doctorPiResourceProfiles( loaded.config.skills, repoRoot, )) { - const resources = await resolveStaticResources({ + const resources = profileStaticResources({ repoRoot, agentDir, profile, + baseResources, warnings, }); const block = compactProfileBlock({ From 280112c18cfa74644e7d23a902116fb80c74c1b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roch=C3=A9=20Compaan?= Date: Sat, 18 Jul 2026 13:04:06 +0200 Subject: [PATCH 8/9] fix(doctor): read Pi trust state without writes --- src/cli/commands/doctor/pi-resources.test.ts | 19 +++++++++ src/cli/commands/doctor/pi-resources.ts | 45 +++++++++++++++++++- 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/src/cli/commands/doctor/pi-resources.test.ts b/src/cli/commands/doctor/pi-resources.test.ts index a61a9d0..d010c5d 100644 --- a/src/cli/commands/doctor/pi-resources.test.ts +++ b/src/cli/commands/doctor/pi-resources.test.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { existsSync } from "node:fs"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -200,3 +201,21 @@ test("context files mirror Pi even when project package resources are untrusted" await rm(tmp, { recursive: true, force: true }); } }); + +test("resource discovery does not create local Pi agent trust state", async () => { + const tmp = await mkdtemp(join(tmpdir(), "patchmill-doctor-trust-")); + try { + await writeFile( + join(tmp, "patchmill.config.json"), + JSON.stringify({ host: { provider: "forgejo-tea", repo: "OWNER/repo" } }), + "utf8", + ); + await mkdir(join(tmp, ".agents", "skills"), { recursive: true }); + + await loadDoctorPiResources(tmp, {}); + + assert.equal(existsSync(join(tmp, ".patchmill")), false); + } finally { + await rm(tmp, { recursive: true, force: true }); + } +}); diff --git a/src/cli/commands/doctor/pi-resources.ts b/src/cli/commands/doctor/pi-resources.ts index 6791103..732fbe0 100644 --- a/src/cli/commands/doctor/pi-resources.ts +++ b/src/cli/commands/doctor/pi-resources.ts @@ -1,3 +1,4 @@ +import { existsSync, readFileSync, realpathSync } from "node:fs"; import { homedir } from "node:os"; import { basename, @@ -13,7 +14,6 @@ import { hasTrustRequiringProjectResources, loadProjectContextFiles, loadSkills, - ProjectTrustStore, SettingsManager, type MissingSourceAction, type ResolvedResource, @@ -178,13 +178,54 @@ export function piResourceDiscoveryFailureCheck( }; } +function canonicalPath(path: string): string { + const resolvedPath = resolve(path); + try { + return realpathSync(resolvedPath); + } catch { + return resolvedPath; + } +} + +function readSavedProjectTrustDecision( + repoRoot: string, + agentDir: string, +): boolean | null { + const trustPath = join(resolve(agentDir), "trust.json"); + if (!existsSync(trustPath)) return null; + + const parsed = JSON.parse(readFileSync(trustPath, "utf8")) as unknown; + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new Error(`Invalid trust store ${trustPath}: expected an object`); + } + + const data = parsed as Record; + for (const [key, value] of Object.entries(data)) { + if (value !== true && value !== false && value !== null) { + throw new Error( + `Invalid trust store ${trustPath}: value for ${JSON.stringify(key)} must be true, false, or null`, + ); + } + } + + let currentDir = canonicalPath(repoRoot); + while (true) { + const value = data[currentDir]; + if (value === true || value === false) return value; + + const parentDir = dirname(currentDir); + if (parentDir === currentDir) return null; + currentDir = parentDir; + } +} + function projectTrustedForResourceListing( repoRoot: string, agentDir: string, ): boolean { if (!hasTrustRequiringProjectResources(repoRoot)) return true; - const savedDecision = new ProjectTrustStore(agentDir).get(repoRoot); + const savedDecision = readSavedProjectTrustDecision(repoRoot, agentDir); if (savedDecision !== null) return savedDecision; const globalOnlySettings = SettingsManager.create(repoRoot, agentDir, { From 4e2491a756eb9fd4bb41e1b93c0d87584dbf7c8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roch=C3=A9=20Compaan?= Date: Sat, 18 Jul 2026 21:46:44 +0200 Subject: [PATCH 9/9] docs(doctor): format Pi resource design documents --- ...026-07-15-patchmill-doctor-pi-resources.md | 272 ++++++++++++------ ...15-patchmill-doctor-pi-resources-design.md | 181 ++++++++---- 2 files changed, 317 insertions(+), 136 deletions(-) diff --git a/docs/plans/2026-07-15-patchmill-doctor-pi-resources.md b/docs/plans/2026-07-15-patchmill-doctor-pi-resources.md index ed168b7..e2f9e58 100644 --- a/docs/plans/2026-07-15-patchmill-doctor-pi-resources.md +++ b/docs/plans/2026-07-15-patchmill-doctor-pi-resources.md @@ -1,32 +1,54 @@ # Patchmill Doctor Pi Resources Implementation Plan -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make `patchmill doctor` print compact, profile-specific Pi resource summaries without mutating package state, writing trust decisions, or executing extension code. - -**Architecture:** Extract Patchmill Pi invocation resource profiles into shared code used by both runtime argument builders and doctor. Doctor uses a static, non-mutating Pi resource resolver built from Pi's exported `DefaultPackageManager`, `SettingsManager`, `ProjectTrustStore`, `loadSkills()`, and context-file helpers; it never calls `DefaultResourceLoader.reload()` for resource listing. - -**Tech Stack:** TypeScript, Node.js built-in test runner, Pi SDK exports from `@earendil-works/pi-coding-agent`, existing Patchmill config and skill-resolution helpers. +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> superpowers:subagent-driven-development (recommended) or +> superpowers:executing-plans to implement this plan task-by-task. Steps use +> checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `patchmill doctor` print compact, profile-specific Pi resource +summaries without mutating package state, writing trust decisions, or executing +extension code. + +**Architecture:** Extract Patchmill Pi invocation resource profiles into shared +code used by both runtime argument builders and doctor. Doctor uses a static, +non-mutating Pi resource resolver built from Pi's exported +`DefaultPackageManager`, `SettingsManager`, `ProjectTrustStore`, `loadSkills()`, +and context-file helpers; it never calls `DefaultResourceLoader.reload()` for +resource listing. + +**Tech Stack:** TypeScript, Node.js built-in test runner, Pi SDK exports from +`@earendil-works/pi-coding-agent`, existing Patchmill config and +skill-resolution helpers. ## Global Constraints - Do not call `DefaultResourceLoader.reload()` for doctor resource listing. -- Do not install missing npm/git Pi package sources during doctor resource discovery; skip and report them. +- Do not install missing npm/git Pi package sources during doctor resource + discovery; skip and report them. - Do not execute extension modules merely to list resources. -- Match non-interactive Pi project-trust behavior without prompting and without writing `trust.json`. -- Report named profiles instead of one vague resource set: run-once planning, run-once development-environment, run-once implementation, and triage. -- `patchmill doctor --quiet` suppresses resource-only output when no required readiness check fails. -- Resource-summary warnings must not make `doctor` fail unless an existing required check also fails. -- Unit tests must stub doctor resource loading and must not assert the exact contents of a developer's machine-specific Pi configuration. -- No dependency changes are planned; if dependency files change unexpectedly, rerun the Nix build before completion. +- Match non-interactive Pi project-trust behavior without prompting and without + writing `trust.json`. +- Report named profiles instead of one vague resource set: run-once planning, + run-once development-environment, run-once implementation, and triage. +- `patchmill doctor --quiet` suppresses resource-only output when no required + readiness check fails. +- Resource-summary warnings must not make `doctor` fail unless an existing + required check also fails. +- Unit tests must stub doctor resource loading and must not assert the exact + contents of a developer's machine-specific Pi configuration. +- No dependency changes are planned; if dependency files change unexpectedly, + rerun the Nix build before completion. --- ## File Structure -- Create `src/pi/resource-profiles.ts` for shared Patchmill Pi invocation resource profiles and argument helpers. +- Create `src/pi/resource-profiles.ts` for shared Patchmill Pi invocation + resource profiles and argument helpers. - Create `src/pi/resource-profiles.test.ts` for deterministic profile tests. -- Modify runtime Pi invocation files to consume `src/pi/resource-profiles.ts` instead of rebuilding profile-specific skill/context/extension arguments inline: +- Modify runtime Pi invocation files to consume `src/pi/resource-profiles.ts` + instead of rebuilding profile-specific skill/context/extension arguments + inline: - `src/cli/commands/run-once/pi.ts` - `src/pi/runner.ts` - `src/cli/commands/run-once/development-environment-stage.ts` @@ -34,10 +56,16 @@ - `src/cli/commands/run-once/pipeline.ts` - `src/cli/commands/triage/dry-run-agent.ts` - `src/cli/commands/triage/execute-agent.ts` -- Create `src/cli/commands/doctor/pi-resources.ts` for static non-mutating discovery, compact formatting, trust parity, and warning conversion. -- Create `src/cli/commands/doctor/pi-resources.test.ts` for resource resolver and formatter behavior. -- Modify `src/cli/commands/doctor/reporting.ts` and `src/cli/commands/doctor/reporting.test.ts` to prepend profile resource blocks while preserving existing report semantics. -- Modify `src/cli/commands/doctor/main.ts` and `src/cli/commands/doctor/main.test.ts` to collect resources safely, append warning checks, and centralize quiet behavior. +- Create `src/cli/commands/doctor/pi-resources.ts` for static non-mutating + discovery, compact formatting, trust parity, and warning conversion. +- Create `src/cli/commands/doctor/pi-resources.test.ts` for resource resolver + and formatter behavior. +- Modify `src/cli/commands/doctor/reporting.ts` and + `src/cli/commands/doctor/reporting.test.ts` to prepend profile resource blocks + while preserving existing report semantics. +- Modify `src/cli/commands/doctor/main.ts` and + `src/cli/commands/doctor/main.test.ts` to collect resources safely, append + warning checks, and centralize quiet behavior. --- @@ -57,7 +85,8 @@ **Interfaces:** -- Consumes: `PatchmillSkillsConfig`, `PATCHMILL_SKILL_KEYS`, and `skillInvocationPaths()` from `src/workflow/skills.ts`. +- Consumes: `PatchmillSkillsConfig`, `PATCHMILL_SKILL_KEYS`, and + `skillInvocationPaths()` from `src/workflow/skills.ts`. - Produces: - `PatchmillPiResourceProfile` - `runOncePlanningPiProfile(skills, repoRoot)` @@ -118,9 +147,14 @@ test("run-once planning profile includes context and Patchmill run-once extensio assert.equal(profile.noContextFiles, false); assert.equal(profile.noPromptTemplates, false); assert.equal(profile.additionalExtensionPaths.length, 2); - assert.equal(basename(profile.additionalExtensionPaths[0] ?? ""), "pi-subagents"); assert.equal( - profile.additionalExtensionPaths[1]?.replaceAll("\\", "/").endsWith("/extensions/todos.ts"), + basename(profile.additionalExtensionPaths[0] ?? ""), + "pi-subagents", + ); + assert.equal( + profile.additionalExtensionPaths[1] + ?.replaceAll("\\", "/") + .endsWith("/extensions/todos.ts"), true, ); assert.deepEqual(profile.additionalSkillPaths, [ @@ -359,12 +393,15 @@ export function profileExtensionArgs( return profile.additionalExtensionPaths.flatMap((path) => ["-e", path]); } -export function profileSkillArgs(profile: PatchmillPiResourceProfile): string[] { +export function profileSkillArgs( + profile: PatchmillPiResourceProfile, +): string[] { return profile.additionalSkillPaths.flatMap((path) => ["--skill", path]); } ``` -- [ ] **Step 4: Refactor run-once argument generation to use profile extension args** +- [ ] **Step 4: Refactor run-once argument generation to use profile extension + args** Modify `src/cli/commands/run-once/pi.ts`: @@ -375,7 +412,9 @@ import { join } from "node:path"; import { profileExtensionArgs } from "../../../pi/resource-profiles.ts"; ``` -Replace the private `createRequire`, `PI_SUBAGENTS_PACKAGE_ROOT`, `PATCHMILL_PACKAGE_ROOT`, and `PATCHMILL_TODOS_EXTENSION` constants with a local lightweight profile argument parameter: +Replace the private `createRequire`, `PI_SUBAGENTS_PACKAGE_ROOT`, +`PATCHMILL_PACKAGE_ROOT`, and `PATCHMILL_TODOS_EXTENSION` constants with a local +lightweight profile argument parameter: ```typescript function piPromptArgs( @@ -392,7 +431,8 @@ function piPromptArgs( } ``` -Add `extensionArgs?: string[]` to `RunPiPromptOptions`. In the `runner.run()` call, pass: +Add `extensionArgs?: string[]` to `RunPiPromptOptions`. In the `runner.run()` +call, pass: ```typescript piPromptArgs( @@ -403,11 +443,15 @@ piPromptArgs( ), ``` -Do not call `profileExtensionArgs()` here directly. Runtime callers supply the profile-specific extension args so doctor and invocation call sites share the same profile builder. +Do not call `profileExtensionArgs()` here directly. Runtime callers supply the +profile-specific extension args so doctor and invocation call sites share the +same profile builder. - [ ] **Step 5: Refactor runtime call sites to consume profile builders** -Update each call site so the profile is constructed once and its `additionalSkillPaths` and `profileExtensionArgs(profile)` values are passed to Pi. +Update each call site so the profile is constructed once and its +`additionalSkillPaths` and `profileExtensionArgs(profile)` values are passed to +Pi. In `src/pi/runner.ts`, import profile builders: @@ -446,14 +490,20 @@ const profile = runOnceImplementationPiProfile( Use the same `skillPaths` and `extensionArgs` properties. -For `src/cli/commands/run-once/development-environment-stage.ts`, `src/cli/commands/run-once/stage-advancement.ts`, and `src/cli/commands/run-once/pipeline.ts`, replace direct `skillInvocationPaths(...)` calls for run-once Pi prompts with the appropriate `runOnceDevelopmentEnvironmentPiProfile()`, `runOncePlanningPiProfile()`, or `runOnceImplementationPiProfile()` call. Pass both: +For `src/cli/commands/run-once/development-environment-stage.ts`, +`src/cli/commands/run-once/stage-advancement.ts`, and +`src/cli/commands/run-once/pipeline.ts`, replace direct +`skillInvocationPaths(...)` calls for run-once Pi prompts with the appropriate +`runOnceDevelopmentEnvironmentPiProfile()`, `runOncePlanningPiProfile()`, or +`runOnceImplementationPiProfile()` call. Pass both: ```typescript skillPaths: profile.additionalSkillPaths, extensionArgs: profileExtensionArgs(profile), ``` -For `src/cli/commands/triage/dry-run-agent.ts` and `src/cli/commands/triage/execute-agent.ts`, import: +For `src/cli/commands/triage/dry-run-agent.ts` and +`src/cli/commands/triage/execute-agent.ts`, import: ```typescript import { @@ -485,7 +535,8 @@ Run: node --test src/pi/resource-profiles.test.ts src/cli/commands/run-once/pi.test.ts src/pi/runner.test.ts src/cli/commands/triage/dry-run-agent.test.ts src/cli/commands/triage/execute-agent.test.ts ``` -Expected: PASS. Existing argument tests should still observe the same Pi command flags, now sourced from shared profile builders. +Expected: PASS. Existing argument tests should still observe the same Pi command +flags, now sourced from shared profile builders. - [ ] **Step 7: Commit Task 1** @@ -507,7 +558,10 @@ git commit -m "refactor(pi): share resource profiles" **Interfaces:** -- Consumes: shared profiles from Task 1, `loadPatchmillConfigState()`, `localPiAgentDir()`, Pi `DefaultPackageManager`, `SettingsManager`, `ProjectTrustStore`, `hasTrustRequiringProjectResources()`, `loadSkills()`, and `loadProjectContextFiles()`. +- Consumes: shared profiles from Task 1, `loadPatchmillConfigState()`, + `localPiAgentDir()`, Pi `DefaultPackageManager`, `SettingsManager`, + `ProjectTrustStore`, `hasTrustRequiringProjectResources()`, `loadSkills()`, + and `loadProjectContextFiles()`. - Produces: - `DoctorPiResourceBlock` - `DoctorPiResourceReport` @@ -604,16 +658,19 @@ test("piResourceWarningCheck returns undefined without warnings", () => { }); test("piResourceWarningCheck reports skipped packages without failing", () => { - assert.deepEqual(piResourceWarningCheck(["skipped missing package npm:@acme/pi-tools"]), { - name: "pi resources", - status: "warn", - message: "skipped missing package npm:@acme/pi-tools", - remediation: [ - "Patchmill doctor listed Pi resources without installing missing packages or executing extensions.", - "Install or update the listed Pi package sources outside doctor if you want those resources loaded, then rerun:", - " patchmill doctor", - ], - }); + assert.deepEqual( + piResourceWarningCheck(["skipped missing package npm:@acme/pi-tools"]), + { + name: "pi resources", + status: "warn", + message: "skipped missing package npm:@acme/pi-tools", + remediation: [ + "Patchmill doctor listed Pi resources without installing missing packages or executing extensions.", + "Install or update the listed Pi package sources outside doctor if you want those resources loaded, then rerun:", + " patchmill doctor", + ], + }, + ); }); test("piResourceDiscoveryFailureCheck creates a non-failing warning", () => { @@ -648,7 +705,10 @@ test("non-mutating discovery skips missing configured package sources", async () const report = await loadDoctorPiResources(tmp, {}); assert.equal(report.check?.status, "warn"); - assert.match(report.check?.message ?? "", /skipped missing package npm:@missing\/package@1\.0\.0/); + assert.match( + report.check?.message ?? "", + /skipped missing package npm:@missing\/package@1\.0\.0/, + ); } finally { await rm(tmp, { recursive: true, force: true }); } @@ -778,7 +838,9 @@ export function compactProfileBlock(input: { }): DoctorPiResourceBlock { const sections: DoctorPiResourceSection[] = []; - const context = input.contextFiles.map((path) => displayPath(path, input.repoRoot)); + const context = input.contextFiles.map((path) => + displayPath(path, input.repoRoot), + ); if (context.length > 0) sections.push({ heading: "Context", items: context }); const skills = uniqueSorted(input.skillNames); @@ -787,7 +849,9 @@ export function compactProfileBlock(input: { const prompts = uniqueSorted(input.promptNames.map((name) => `/${name}`)); if (prompts.length > 0) sections.push({ heading: "Prompts", items: prompts }); - const extensions = uniqueSorted(input.extensionPaths.map(compactExtensionLabel)); + const extensions = uniqueSorted( + input.extensionPaths.map(compactExtensionLabel), + ); if (extensions.length > 0) { sections.push({ heading: "Extensions", items: extensions }); } @@ -844,7 +908,8 @@ export function piResourceDiscoveryFailureCheck( - [ ] **Step 4: Implement trust parity and non-mutating static discovery** -Append these helpers and `loadDoctorPiResources()` to `src/cli/commands/doctor/pi-resources.ts`: +Append these helpers and `loadDoctorPiResources()` to +`src/cli/commands/doctor/pi-resources.ts`: ```typescript function projectTrustedForResourceListing( @@ -863,7 +928,9 @@ function projectTrustedForResourceListing( } function enabledPaths(resources: ResolvedResource[]): string[] { - return resources.filter((resource) => resource.enabled).map((resource) => resource.path); + return resources + .filter((resource) => resource.enabled) + .map((resource) => resource.path); } function promptName(path: string): string { @@ -881,9 +948,16 @@ async function resolveStaticResources(input: { promptNames: string[]; extensionPaths: string[]; }> { - const settingsManager = SettingsManager.create(input.repoRoot, input.agentDir, { - projectTrusted: projectTrustedForResourceListing(input.repoRoot, input.agentDir), - }); + const settingsManager = SettingsManager.create( + input.repoRoot, + input.agentDir, + { + projectTrusted: projectTrustedForResourceListing( + input.repoRoot, + input.agentDir, + ), + }, + ); const packageManager = new DefaultPackageManager({ cwd: input.repoRoot, agentDir: input.agentDir, @@ -925,7 +999,10 @@ async function resolveStaticResources(input: { }).map((file) => file.path), skillNames: skills.skills.map((skill) => skill.name), promptNames: basePromptPaths.map(promptName), - extensionPaths: [...baseExtensionPaths, ...input.profile.additionalExtensionPaths], + extensionPaths: [ + ...baseExtensionPaths, + ...input.profile.additionalExtensionPaths, + ], }; } @@ -939,7 +1016,10 @@ export async function loadDoctorPiResources( try { const loaded = await loadPatchmillConfigState(repoRoot, env, []); const blocks = []; - for (const profile of doctorPiResourceProfiles(loaded.config.skills, repoRoot)) { + for (const profile of doctorPiResourceProfiles( + loaded.config.skills, + repoRoot, + )) { const resources = await resolveStaticResources({ repoRoot, agentDir, @@ -970,7 +1050,8 @@ node --test src/cli/commands/doctor/pi-resources.test.ts npm run lint:ts ``` -Expected: PASS. If `DefaultPackageManager` construction types differ, adapt to the exported `.d.ts` shape rather than using private subpath imports. +Expected: PASS. If `DefaultPackageManager` construction types differ, adapt to +the exported `.d.ts` shape rather than using private subpath imports. - [ ] **Step 6: Commit Task 2** @@ -994,8 +1075,11 @@ git commit -m "feat(doctor): resolve Pi resources read-only" **Interfaces:** -- Consumes: `DoctorPiResourceProvider`, `DoctorPiResourceReport`, `formatPiResourceBlocks()`, `loadDoctorPiResources()`, and `piResourceDiscoveryFailureCheck()` from Task 2. -- Produces: `formatDoctorReport(results, resourceBlocks?)` and centralized quiet handling in `runDoctor()`. +- Consumes: `DoctorPiResourceProvider`, `DoctorPiResourceReport`, + `formatPiResourceBlocks()`, `loadDoctorPiResources()`, and + `piResourceDiscoveryFailureCheck()` from Task 2. +- Produces: `formatDoctorReport(results, resourceBlocks?)` and centralized quiet + handling in `runDoctor()`. - [ ] **Step 1: Add reporting tests for resource blocks** @@ -1044,7 +1128,8 @@ Run: node --test src/cli/commands/doctor/reporting.test.ts ``` -Expected: FAIL because `formatDoctorReport()` does not accept resource blocks yet. +Expected: FAIL because `formatDoctorReport()` does not accept resource blocks +yet. - [ ] **Step 3: Update report formatting** @@ -1074,7 +1159,8 @@ export function formatDoctorReport( ]; ``` -Keep the existing checklist, remediation, and success footer behavior below that initialization. +Keep the existing checklist, remediation, and success footer behavior below that +initialization. - [ ] **Step 4: Add main-flow tests with stubbed resource provider** @@ -1298,32 +1384,32 @@ async function safeLoadPiResources( After the optional `--fix` block and before `runChecks`, add: ```typescript - const piResources = await safeLoadPiResources( - options.loadPiResources ?? loadDoctorPiResources, - config.repoRoot, - ); +const piResources = await safeLoadPiResources( + options.loadPiResources ?? loadDoctorPiResources, + config.repoRoot, +); ``` Replace result assembly with: ```typescript - const checkResults = await (options.runChecks ?? runDoctorChecks)(runner, { - repoRoot: config.repoRoot, - }); - const results = [ - ...(piResources.check ? [piResources.check] : []), - ...checkResults, - ]; +const checkResults = await (options.runChecks ?? runDoctorChecks)(runner, { + repoRoot: config.repoRoot, +}); +const results = [ + ...(piResources.check ? [piResources.check] : []), + ...checkResults, +]; ``` Keep quiet policy centralized: ```typescript - const failed = hasDoctorFailures(results); - if (!config.quiet || failed) { - output.stdout(formatDoctorReport(results, piResources.blocks).join("\n")); - } - return failed ? 1 : 0; +const failed = hasDoctorFailures(results); +if (!config.quiet || failed) { + output.stdout(formatDoctorReport(results, piResources.blocks).join("\n")); +} +return failed ? 1 : 0; ``` - [ ] **Step 7: Run doctor unit tests** @@ -1352,7 +1438,8 @@ git commit -m "feat(doctor): print Pi resource profiles" **Files:** -- Modify only if verification exposes a specific defect in files touched by Tasks 1-3. +- Modify only if verification exposes a specific defect in files touched by + Tasks 1-3. **Interfaces:** @@ -1387,7 +1474,8 @@ Run: npm run lint ``` -Expected: PASS. If Prettier reports formatting changes, run `npm run format`, inspect the diff, then rerun `npm run lint`. +Expected: PASS. If Prettier reports formatting changes, run `npm run format`, +inspect the diff, then rerun `npm run lint`. - [ ] **Step 4: Manually inspect doctor output shape** @@ -1397,7 +1485,9 @@ Run: node bin/patchmill.ts doctor --quiet ``` -Expected when readiness checks pass: no output. Expected when a required check fails in the local environment: report prints resource blocks and the failure checklist. +Expected when readiness checks pass: no output. Expected when a required check +fails in the local environment: report prints resource blocks and the failure +checklist. Run: @@ -1405,18 +1495,23 @@ Run: node bin/patchmill.ts doctor ``` -Expected: resource profile blocks print first, followed by the existing `Patchmill doctor` checklist. If environment-specific host or provider checks fail, confirm the resource blocks still printed before the failure checklist. +Expected: resource profile blocks print first, followed by the existing +`Patchmill doctor` checklist. If environment-specific host or provider checks +fail, confirm the resource blocks still printed before the failure checklist. - [ ] **Step 5: Verify doctor did not mutate Pi package state** -Before and after running `node bin/patchmill.ts doctor`, compare local package state paths that should not change: +Before and after running `node bin/patchmill.ts doctor`, compare local package +state paths that should not change: ```bash git status --short find .patchmill/pi-agent -maxdepth 3 -type f 2>/dev/null | sort ``` -Expected: no new git-tracked changes and no package install/update side effects caused by doctor resource discovery. Existing local state files unrelated to this run may already exist; do not delete or modify them. +Expected: no new git-tracked changes and no package install/update side effects +caused by doctor resource discovery. Existing local state files unrelated to +this run may already exist; do not delete or modify them. - [ ] **Step 6: Review the final diff** @@ -1428,7 +1523,9 @@ git diff --stat HEAD~3..HEAD git diff HEAD~3..HEAD -- src/pi src/cli/commands/run-once src/cli/commands/triage src/cli/commands/doctor ``` -Expected: The diff is limited to shared Pi resource profiles, runtime call-site refactors, doctor resource discovery/formatting, and tests. No package dependency files changed. +Expected: The diff is limited to shared Pi resource profiles, runtime call-site +refactors, doctor resource discovery/formatting, and tests. No package +dependency files changed. - [ ] **Step 7: Commit verification fixes if needed** @@ -1445,6 +1542,13 @@ If no fixes were needed, leave the branch at the Task 3 commit. ## Plan Self-Review Checklist -- Spec coverage: Task 1 covers profile specificity and drift prevention; Task 2 covers read-only discovery, missing package skips, no extension execution, and trust parity; Task 3 covers report formatting and quiet behavior; Task 4 covers verification and mutation checks. -- Placeholder scan: The plan uses concrete file paths, function names, commands, expected results, and code snippets for every code-producing step. -- Type consistency: `PatchmillPiResourceProfile`, `DoctorPiResourceBlock`, `DoctorPiResourceReport`, `DoctorPiResourceProvider`, `formatPiResourceBlocks()`, `loadDoctorPiResources()`, and `piResourceDiscoveryFailureCheck()` are defined before they are consumed. +- Spec coverage: Task 1 covers profile specificity and drift prevention; Task 2 + covers read-only discovery, missing package skips, no extension execution, and + trust parity; Task 3 covers report formatting and quiet behavior; Task 4 + covers verification and mutation checks. +- Placeholder scan: The plan uses concrete file paths, function names, commands, + expected results, and code snippets for every code-producing step. +- Type consistency: `PatchmillPiResourceProfile`, `DoctorPiResourceBlock`, + `DoctorPiResourceReport`, `DoctorPiResourceProvider`, + `formatPiResourceBlocks()`, `loadDoctorPiResources()`, and + `piResourceDiscoveryFailureCheck()` are defined before they are consumed. diff --git a/docs/specs/2026-07-15-patchmill-doctor-pi-resources-design.md b/docs/specs/2026-07-15-patchmill-doctor-pi-resources-design.md index 28922ee..968764d 100644 --- a/docs/specs/2026-07-15-patchmill-doctor-pi-resources-design.md +++ b/docs/specs/2026-07-15-patchmill-doctor-pi-resources-design.md @@ -2,7 +2,8 @@ ## Goal -`patchmill doctor` should show the Pi resources Patchmill will use, in a compact style similar to Pi's interactive startup resource summary: +`patchmill doctor` should show the Pi resources Patchmill will use, in a compact +style similar to Pi's interactive startup resource summary: ```text [Pi resources: run-once planning] @@ -19,54 +20,97 @@ pi-subagents, todos.ts ``` -The report helps users diagnose why Patchmill-powered Pi runs see a specific context file, skill, prompt template, or extension before they start a workflow. +The report helps users diagnose why Patchmill-powered Pi runs see a specific +context file, skill, prompt template, or extension before they start a workflow. ## Non-goals -- Do not add a new flag. The sections are part of normal non-quiet `patchmill doctor` output. -- Do not reproduce Pi's expanded interactive grouping UI. `doctor` prints compact, terminal-friendly lists only. -- Do not make `doctor` mutate Pi settings, project trust, labels, git state, or package installations while collecting resources. +- Do not add a new flag. The sections are part of normal non-quiet + `patchmill doctor` output. +- Do not reproduce Pi's expanded interactive grouping UI. `doctor` prints + compact, terminal-friendly lists only. +- Do not make `doctor` mutate Pi settings, project trust, labels, git state, or + package installations while collecting resources. - Do not execute extension code merely to list resources. -- Do not replace the existing readiness checks. The resource summary is additive. +- Do not replace the existing readiness checks. The resource summary is + additive. ## Profiles to report -Patchmill has more than one Pi invocation shape, so doctor must not report a single vague resource set. It should report these named resource profiles: - -- `run-once planning`: mirrors the plan/spec prompt path through `runPiPrompt()` with normal context files, prompt templates, auto-discovered non-mutating resources, the Patchmill-injected `pi-subagents` and `todos.ts` extensions, and the configured planning skill. -- `run-once development-environment`: mirrors the development-environment prompt path through `runPiPrompt()` with the same context/extension behavior and the configured development-environment skill when present. -- `run-once implementation`: mirrors the implementation prompt path through `runPiPrompt()` with the same context/extension behavior and configured toolchain, implementation, review, visual-evidence, and landing skills when present. -- `triage`: mirrors triage dry-run and execute agents with `--no-context-files`, no Patchmill-injected run-once extensions, and the configured triage skill. Prompt templates, trusted auto-discovered skills, and trusted auto-discovered extensions still appear when Pi would load them for that invocation. - -The provider smoke test remains a readiness check rather than a resource profile because it is intentionally minimal and already reports as `pi provider`. - -To prevent drift, define these profiles in shared code used by both doctor discovery and the actual Pi invocation argument builders. If a future Patchmill command adds a distinct Pi invocation shape, add a named profile beside the invocation code and include it in doctor output. +Patchmill has more than one Pi invocation shape, so doctor must not report a +single vague resource set. It should report these named resource profiles: + +- `run-once planning`: mirrors the plan/spec prompt path through `runPiPrompt()` + with normal context files, prompt templates, auto-discovered non-mutating + resources, the Patchmill-injected `pi-subagents` and `todos.ts` extensions, + and the configured planning skill. +- `run-once development-environment`: mirrors the development-environment prompt + path through `runPiPrompt()` with the same context/extension behavior and the + configured development-environment skill when present. +- `run-once implementation`: mirrors the implementation prompt path through + `runPiPrompt()` with the same context/extension behavior and configured + toolchain, implementation, review, visual-evidence, and landing skills when + present. +- `triage`: mirrors triage dry-run and execute agents with `--no-context-files`, + no Patchmill-injected run-once extensions, and the configured triage skill. + Prompt templates, trusted auto-discovered skills, and trusted auto-discovered + extensions still appear when Pi would load them for that invocation. + +The provider smoke test remains a readiness check rather than a resource profile +because it is intentionally minimal and already reports as `pi provider`. + +To prevent drift, define these profiles in shared code used by both doctor +discovery and the actual Pi invocation argument builders. If a future Patchmill +command adds a distinct Pi invocation shape, add a named profile beside the +invocation code and include it in doctor output. ## Non-mutating discovery boundary -Do not call `DefaultResourceLoader.reload()` for doctor resource listing. In Pi's current implementation that path resolves package resources, may install missing npm/git package sources, and loads extension modules. That violates doctor's read-only contract. +Do not call `DefaultResourceLoader.reload()` for doctor resource listing. In +Pi's current implementation that path resolves package resources, may install +missing npm/git package sources, and loads extension modules. That violates +doctor's read-only contract. Instead, resource discovery should use Pi's public non-mutating pieces directly: -- Construct a `SettingsManager` with the same trusted/untrusted state a non-interactive Pi run would use, without prompting and without writing trust decisions. -- Use `DefaultPackageManager.resolve(onMissing)` with `onMissing` returning `"skip"` so missing npm/git package sources are reported but not installed. -- Use `DefaultPackageManager.resolveExtensionSources()` only for Patchmill's known local extension paths that are already passed to Pi by the runtime profile. Do not use it for arbitrary package sources unless missing installs are also skipped. -- Use Pi's exported `loadSkills()` for enabled skill paths because it scans skill files without executing extensions. -- Derive prompt commands from enabled prompt-template file paths and their filenames. If Pi later exports a non-mutating prompt-template loader from the package root, prefer that API. -- Derive extension labels from enabled extension paths and metadata. Do not import or execute extension modules. - -Because extension code is not executed, doctor will not list resources dynamically contributed by `resources_discover` handlers. Report this limitation as part of the profile summary diagnostics only when such dynamic resources cannot be known; do not execute extensions to discover them. +- Construct a `SettingsManager` with the same trusted/untrusted state a + non-interactive Pi run would use, without prompting and without writing trust + decisions. +- Use `DefaultPackageManager.resolve(onMissing)` with `onMissing` returning + `"skip"` so missing npm/git package sources are reported but not installed. +- Use `DefaultPackageManager.resolveExtensionSources()` only for Patchmill's + known local extension paths that are already passed to Pi by the runtime + profile. Do not use it for arbitrary package sources unless missing installs + are also skipped. +- Use Pi's exported `loadSkills()` for enabled skill paths because it scans + skill files without executing extensions. +- Derive prompt commands from enabled prompt-template file paths and their + filenames. If Pi later exports a non-mutating prompt-template loader from the + package root, prefer that API. +- Derive extension labels from enabled extension paths and metadata. Do not + import or execute extension modules. + +Because extension code is not executed, doctor will not list resources +dynamically contributed by `resources_discover` handlers. Report this limitation +as part of the profile summary diagnostics only when such dynamic resources +cannot be known; do not execute extensions to discover them. ## Project trust parity -Doctor resource discovery must match non-interactive Pi trust behavior as closely as possible without prompting or writing trust state: +Doctor resource discovery must match non-interactive Pi trust behavior as +closely as possible without prompting or writing trust state: -1. If the cwd has no trust-requiring project resources, treat project resources as trusted. -2. If `ProjectTrustStore(agentDir).get(repoRoot)` has a saved decision, use that decision. -3. Otherwise, load global settings only and honor `defaultProjectTrust`: `"always"` trusts project resources; `"ask"` and `"never"` do not. +1. If the cwd has no trust-requiring project resources, treat project resources + as trusted. +2. If `ProjectTrustStore(agentDir).get(repoRoot)` has a saved decision, use that + decision. +3. Otherwise, load global settings only and honor `defaultProjectTrust`: + `"always"` trusts project resources; `"ask"` and `"never"` do not. 4. Do not call the interactive trust prompt and do not write `trust.json`. -When project resources are untrusted, omit project `.pi` resources and ancestor `.agents/skills` resources exactly as Pi's package/resource discovery does with `projectTrusted: false`. +When project resources are untrusted, omit project `.pi` resources and ancestor +`.agents/skills` resources exactly as Pi's package/resource discovery does with +`projectTrusted: false`. ## Formatting @@ -90,58 +134,91 @@ For each named profile with at least one resource, print: Formatting rules: -- `[Context]`: comma-separated display paths for loaded context files, omitted for profiles with `--no-context-files`. Use repository-relative labels when possible, such as `AGENTS.md`. +- `[Context]`: comma-separated display paths for loaded context files, omitted + for profiles with `--no-context-files`. Use repository-relative labels when + possible, such as `AGENTS.md`. - `[Skills]`: comma-separated skill names, sorted alphabetically. -- `[Prompts]`: comma-separated slash commands, sorted alphabetically, for example `/parallel-review`. -- `[Extensions]`: comma-separated compact extension labels. For ordinary paths use the filename, or the parent directory when the extension is `index.ts`/`index.js`. For package-backed paths, include a concise package/source label. +- `[Prompts]`: comma-separated slash commands, sorted alphabetically, for + example `/parallel-review`. +- `[Extensions]`: comma-separated compact extension labels. For ordinary paths + use the filename, or the parent directory when the extension is + `index.ts`/`index.js`. For package-backed paths, include a concise + package/source label. -Only print resource categories that contain resources. Keep blank lines between profile blocks, resource sections, and the existing doctor checklist. +Only print resource categories that contain resources. Keep blank lines between +profile blocks, resource sections, and the existing doctor checklist. ## Quiet behavior -`patchmill doctor --quiet` keeps its existing meaning: suppress successful non-failure output. Therefore: +`patchmill doctor --quiet` keeps its existing meaning: suppress successful +non-failure output. Therefore: -- If all readiness checks pass and resource discovery only produced sections or warnings, `--quiet` prints nothing. -- If any required readiness check fails, `--quiet` prints the same report as non-quiet mode, including resource sections and `pi resources` warnings. -- Resource-only warnings do not make `doctor` fail and do not force output under `--quiet`. +- If all readiness checks pass and resource discovery only produced sections or + warnings, `--quiet` prints nothing. +- If any required readiness check fails, `--quiet` prints the same report as + non-quiet mode, including resource sections and `pi resources` warnings. +- Resource-only warnings do not make `doctor` fail and do not force output under + `--quiet`. -Keep this policy centralized in the doctor report/output path rather than scattering special-case conditionals across resource discovery. +Keep this policy centralized in the doctor report/output path rather than +scattering special-case conditionals across resource discovery. ## Diagnostics and failures Resource discovery must not prevent doctor from running existing checks. -If non-mutating discovery skips missing package sources, add a warning check named `pi resources` that lists the skipped sources and tells the user to run Pi package installation/update outside doctor if they want those resources loaded. +If non-mutating discovery skips missing package sources, add a warning check +named `pi resources` that lists the skipped sources and tells the user to run Pi +package installation/update outside doctor if they want those resources loaded. -If discovery succeeds but Pi reports static resource diagnostics, add a warning check named `pi resources` with a concise summary and remediation to inspect or fix the affected Pi resource. +If discovery succeeds but Pi reports static resource diagnostics, add a warning +check named `pi resources` with a concise summary and remediation to inspect or +fix the affected Pi resource. -If discovery throws unexpectedly, add a warning check named `pi resources` saying Patchmill could not list Pi resources, include the error message, and continue with existing doctor checks. This remains a warning because the separate `pi` and `pi provider` checks determine whether Patchmill can invoke Pi. +If discovery throws unexpectedly, add a warning check named `pi resources` +saying Patchmill could not list Pi resources, include the error message, and +continue with existing doctor checks. This remains a warning because the +separate `pi` and `pi provider` checks determine whether Patchmill can invoke +Pi. -Existing required failures keep the current exit-code behavior. Resource-summary warnings do not make `doctor` fail unless they also surface through an existing required check. +Existing required failures keep the current exit-code behavior. Resource-summary +warnings do not make `doctor` fail unless they also surface through an existing +required check. ## Tests -Add focused tests around profile construction, non-mutating discovery, formatting, trust parity, and doctor integration: +Add focused tests around profile construction, non-mutating discovery, +formatting, trust parity, and doctor integration: - profile builders match the actual run-once and triage Pi invocation arguments; - missing package sources are skipped and reported rather than installed; -- project resources are included or omitted according to saved trust/defaultProjectTrust parity; -- compact profile sections are included in normal doctor output when resource discovery returns context, skills, prompts, and extensions; +- project resources are included or omitted according to saved + trust/defaultProjectTrust parity; +- compact profile sections are included in normal doctor output when resource + discovery returns context, skills, prompts, and extensions; - empty resource categories are omitted; -- discovery warnings are rendered as a `pi resources` warning without changing successful doctor exit behavior; -- discovery exceptions are rendered as a warning and do not skip existing checks; +- discovery warnings are rendered as a `pi resources` warning without changing + successful doctor exit behavior; +- discovery exceptions are rendered as a warning and do not skip existing + checks; - `--quiet` suppresses resource-only output when no required check fails; -- existing doctor failure behavior remains unchanged when a readiness check fails. +- existing doctor failure behavior remains unchanged when a readiness check + fails. -Use stubs for the resource summary provider in doctor output tests so unit tests do not load real user Pi resources. Avoid tests that assert the exact contents of a developer's machine-specific Pi configuration. +Use stubs for the resource summary provider in doctor output tests so unit tests +do not load real user Pi resources. Avoid tests that assert the exact contents +of a developer's machine-specific Pi configuration. ## Verification -Run the existing doctor tests and full TypeScript test suite after implementation: +Run the existing doctor tests and full TypeScript test suite after +implementation: ```bash npm test -- src/cli/commands/doctor/*.test.ts npm test ``` -Run `npm run lint` for formatting and type/lint validation. No dependency changes are planned, so the Nix build requirement for dependency changes does not apply. +Run `npm run lint` for formatting and type/lint validation. No dependency +changes are planned, so the Nix build requirement for dependency changes does +not apply.