diff --git a/README.md b/README.md index da235f4..5cc5268 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Pi Arc Package -Arc issue tracker integration for [Pi](https://pi.dev): packaged Arc skills, prompt templates, session context injection, workflow command aliases, bundled checklist support via `@juicesharp/rpiv-todo`, and bundled Arc specialist support via `pi-subagents`. +Arc issue tracker integration for [Pi](https://pi.dev): packaged Arc skills, prompt templates, session context injection, workflow command aliases, bundled checklist support via `@juicesharp/rpiv-todo`, and optional Arc specialist integration with `pi-subagents`. This package is a Pi-native port of the Claude Code Arc plugin at https://github.com/sentiolabs/arc @@ -29,8 +29,11 @@ This package is a Pi-native port of the Claude Code Arc plugin at https://github - `/arc-which` — run `arc which` - `/arc-prime` — show cached `arc prime` context - `/arc-refresh` — refresh cached `arc prime` context - - `/arc-subagents-sync [project|user]` — generate Arc specialist definitions from the bundled `pi-subagents` copy - `/arc-plan`, `/arc-build`, `/arc-review`, etc. — friendly aliases for the corresponding skills +- **Auto-materialized Arc specialists for `pi-subagents`**: + - `@sentiolabs/pi-arc` writes generated `arc-*` agent files into pi-subagents user discovery scope on session start. + - Users do not need to run `/arc-subagents-sync` after install. + - `/arc-subagents-sync` is deprecated for normal activation and remains a repair/backcompat command. - **Session context injection**: - On session start, the extension runs `arc prime` and injects its output into the system prompt as ``. - Before compaction, the extension refreshes `arc prime`. @@ -49,10 +52,10 @@ This package is a Pi-native port of the Claude Code Arc plugin at https://github - Supports `builder`, `code-reviewer`, `doc-writer`, `evaluator`, `issue-manager`, and `spec-reviewer`. - Resolves Arc model tiers (`small`, `standard`, `large`) to concrete Pi models so orchestrators can right-size subagent dispatches. - Current limitation: `isolation: "worktree"` is recognized but not implemented yet. -- **Bundled `pi-subagents` companion support**: - - `@sentiolabs/pi-arc` bundles and loads `pi-subagents` by default, so Arc specialists are available without a separate install. - - Pi's package docs recommend bundling other Pi packages through `dependencies` + `bundledDependencies`, then referencing their resources through `node_modules/...` paths in the package's `pi` manifest. - - If you previously installed standalone `pi-subagents`, remove the standalone package from `~/.pi/agent/settings.json` or project `.pi/settings.json` if duplicate tools or commands appear. The bundled copy from `@sentiolabs/pi-arc` is enough for Arc workflows. +- **Optional `pi-subagents` companion support**: + - `@sentiolabs/pi-arc` auto-materializes Arc specialist definitions for any installed `pi-subagents` provider, but does not bundle or load the `subagent` tool itself. + - Install `pi-subagents` once if you want async/background runs, chains, or worktree-isolated parallel Arc batches: `pi install npm:pi-subagents`. + - If `pi-subagents` is unavailable, Arc workflows fall back to the bundled sequential `arc_agent` tool. ## Prerequisites @@ -199,11 +202,14 @@ The same `modelProfiles` shape works for `plan`, `docWriter`, `specReviewer`, an Legacy `arc.modelTiers` settings in `~/.pi/agent/settings.json` or project `.pi/settings.json` remain supported as a compatibility fallback, but new configuration should use `/arc-models` and `modelProfiles`. -## Sync Arc specialists into bundled `pi-subagents` + +## Arc specialists in pi-subagents -`@sentiolabs/pi-arc` bundles and loads `pi-subagents` by default, so `/arc-subagents-sync` refreshes the bundled specialist definitions rather than relying on a standalone install. +Arc writes generated specialists to `~/.agents/` by default. Legacy user scope `~/.pi/agent/agents/` is reserved for compatibility. Project `.pi/agents/arc-*.md` files have higher precedence and can shadow fresh user-scope files; if Arc warns about a shadow, inspect or remove the project-local generated file intentionally. -Use `/arc-subagents-sync` to generate Arc specialist agent files from this package's bundled prompts: +`@sentiolabs/pi-arc` does not bundle or load `pi-subagents`; install `pi-subagents` separately when you want the `subagent` tool. The Arc extension still auto-materializes Arc specialists into pi-subagents user scope, so `/arc-subagents-sync` is deprecated for normal activation and remains only a repair/backcompat command. + +Generated specialists include: - `arc-builder` - `arc-doc-writer` @@ -212,19 +218,22 @@ Use `/arc-subagents-sync` to generate Arc specialist agent files from this packa - `arc-evaluator` - `arc-issue-manager` -Pi's package docs recommend bundling other Pi packages through `dependencies` + `bundledDependencies`, then referencing their resources through `node_modules/...` paths in the package's `pi` manifest. +Install `pi-subagents` as a normal Pi package if you want these specialists available through the `subagent` tool: -Bundled resources resolve from `./node_modules/pi-subagents/src/extension/index.ts`, `./node_modules/pi-subagents/skills`, and `./node_modules/pi-subagents/prompts`. +```bash +pi install npm:pi-subagents +``` -By default, files are written to project scope (`/.pi/agents/`). Pass `user` or `--user` to write to `~/.pi/agent/agents/` instead. +Existing standalone installs under `~/.pi/agent/extensions/subagent` also work. Because `@sentiolabs/pi-arc` no longer loads its own copy, project-local Arc installs should not conflict with a global `subagent` tool. -If you previously installed standalone `pi-subagents`, remove the standalone package from `~/.pi/agent/settings.json` or project `.pi/settings.json` if duplicate tools or commands appear. The bundled copy from `@sentiolabs/pi-arc` is enough for Arc workflows. +For repair/backcompat only, `/arc-subagents-sync project` can explicitly refresh legacy project-scope generated files under `/.pi/agents/`; normal activation writes user-scope files automatically. The `issue-manager` agent uses the issueManager profile (recommended gpt-5.4-mini with thinking off) and stays phased: create the epic first, then child tasks next, then dependencies/labels after all IDs exist. It prints phase-level timing/progress lines for bulk issue creation. This is sequencing only; true parallel issue creation is not enabled yet. Generated files include a marker comment so reruns can safely update Arc-managed files while preserving manual edits in user-authored files. -After syncing, verify agent registration: + +To confirm installed/discovered agents, use: ```text subagent({ action: "list" }) @@ -269,8 +278,8 @@ Implemented: - Bundled agent prompt references under `agents/` - Bundled `@juicesharp/rpiv-ask-user-question` package for interactive workflow decisions - Pi-native `arc_agent` custom tool for sequential subagent execution -- `/arc-subagents-sync` command for generating Arc specialist `pi-subagents` definitions -- Bundled `pi-subagents` support for worktree-isolated evaluator runs, independent parallel builder batches, and phased issue-manager creation +- Auto-materialized Arc specialists in pi-subagents user scope; `/arc-subagents-sync` is deprecated repair/backcompat only +- Optional `pi-subagents` integration for worktree-isolated evaluator runs, independent parallel builder batches, and phased issue-manager creation - Maintainer-only `/arc-source-sync` workflow for syncing from the Claude Arc plugin source Not yet implemented: diff --git a/agents/builder.md b/agents/builder.md index bec455d..5525793 100644 --- a/agents/builder.md +++ b/agents/builder.md @@ -171,6 +171,14 @@ If you discover issues during the gate and cannot resolve them after reasonable 7. **Commit** with a conventional commit message (e.g., `feat(module): add X`) 8. **Report** back with the structured format below +## Supervisor Escalation + +If runtime bridge instructions identify `contact_supervisor`, use it only for decisions that block safe completion: product scope, API shape, user approval, or contradictory requirements. Send `reason: "need_decision"` and wait for the reply before continuing. + +Use `reason: "progress_update"` only for meaningful unexpected discoveries that change the implementation plan or for explicit progress checkpoints. Do not send routine completion handoffs through intercom; return your final task result normally. + +Never invent an intercom target. If bridge instructions are absent, report `BLOCKED` or `NEEDS_CONTEXT` in your normal final output instead of guessing. + ## When Tests Can't Run If the project's test command fails with a **setup error** (not a test failure): diff --git a/agents/code-reviewer.md b/agents/code-reviewer.md index a02bf2c..ab8d837 100644 --- a/agents/code-reviewer.md +++ b/agents/code-reviewer.md @@ -81,6 +81,14 @@ The dispatching agent decides whether to fix or accept each deviation. - **Check against conventions.** Read the project's CLAUDE.md if it exists. Scan 2-3 existing files in the same directory as the changed code to identify naming, structure, and error-handling patterns. Deviations from established patterns are Important findings. - **Check against the design.** If a design spec is provided, the implementation must match its type definitions, naming choices, and architectural decisions. Deviations that are arguably improvements still get flagged — the orchestrator decides whether to accept them. +## Supervisor Escalation + +If runtime bridge instructions identify `contact_supervisor`, use it only for decisions that block safe completion: product scope, API shape, user approval, or contradictory requirements. Send `reason: "need_decision"` and wait for the reply before continuing. + +Use `reason: "progress_update"` only for meaningful unexpected discoveries that change the review plan or for explicit progress checkpoints. Preserve read-only behavior and do not send routine completion handoffs through intercom; return your final review result normally. + +Never invent an intercom target. If bridge instructions are absent, report `BLOCKED` or `NEEDS_CONTEXT` in your normal final output instead of guessing. + ## Rules - Never make code changes — you are read-only diff --git a/agents/doc-writer.md b/agents/doc-writer.md index 70eabfe..830efc2 100644 --- a/agents/doc-writer.md +++ b/agents/doc-writer.md @@ -25,6 +25,14 @@ You have a fresh context window — no prior conversation history. Everything yo 5. **Commit** with a conventional commit message (e.g., `docs(module): update README`) 6. **Report** back: what was written, files changed, verification results +## Supervisor Escalation + +If runtime bridge instructions identify `contact_supervisor`, use it only for decisions that block safe completion: product scope, API shape, user approval, or contradictory requirements. Send `reason: "need_decision"` and wait for the reply before continuing. + +Use `reason: "progress_update"` only for meaningful unexpected discoveries that change the documentation plan or for explicit progress checkpoints. Do not send routine completion handoffs through intercom; return your final task result normally. + +Never invent an intercom target. If bridge instructions are absent, report `BLOCKED` or `NEEDS_CONTEXT` in your normal final output instead of guessing. + ## Quality Checklist After writing, verify each of these before committing: diff --git a/agents/evaluator.md b/agents/evaluator.md index 3f35028..ca5c4b1 100644 --- a/agents/evaluator.md +++ b/agents/evaluator.md @@ -175,6 +175,14 @@ Report your findings to the dispatching agent. Do NOT commit or clean up — the - **Be concrete.** "Might not handle edge cases" is worthless. "TestCreateUser with empty email returns 200 instead of 400 per spec requirement 3" is actionable. - **Separate your failures from theirs.** If the project doesn't build, that's the implementer's fault — report FAIL. If your own acceptance tests don't compile, that's YOUR problem — report BLOCKED. Never blame the implementer for your test setup failures, and never let the implementer off the hook for a broken build. +## Supervisor Escalation + +If runtime bridge instructions identify `contact_supervisor`, use it only for decisions that block safe completion: product scope, API shape, user approval, or contradictory requirements. Send `reason: "need_decision"` and wait for the reply before continuing. + +Use `reason: "progress_update"` only for meaningful unexpected discoveries that change the evaluation plan or for explicit progress checkpoints. Preserve adversarial/read-only expectations and do not send routine completion handoffs through intercom; return your final evaluation result normally. + +Never invent an intercom target. If bridge instructions are absent, report `BLOCKED` or `NEEDS_CONTEXT` in your normal final output instead of guessing. + ## Rationalizations You Must Reject | Rationalization | Why It's Wrong | diff --git a/agents/issue-manager.md b/agents/issue-manager.md index 0e66ba0..a22c3e0 100644 --- a/agents/issue-manager.md +++ b/agents/issue-manager.md @@ -164,6 +164,14 @@ arc close --reason "resolved" - For complex operations, break into steps and confirm each succeeds - If an issue is blocked, explain what's blocking it +## Supervisor Escalation + +If runtime bridge instructions identify `contact_supervisor`, use it only for decisions that block safe completion: Arc issue structure, dependency ambiguity, labels, or parent/child hierarchy. Send `reason: "need_decision"` and wait for the reply before continuing. + +Use `reason: "progress_update"` only for meaningful unexpected discoveries that change the issue plan or for explicit progress checkpoints. Do not send routine completion handoffs through intercom; return your final task result normally. + +Never invent an intercom target. If bridge instructions are absent, report `BLOCKED` or `NEEDS_CONTEXT` in your normal final output instead of guessing. + ## Output Format When reporting results: diff --git a/agents/spec-reviewer.md b/agents/spec-reviewer.md index e548cba..3d57b38 100644 --- a/agents/spec-reviewer.md +++ b/agents/spec-reviewer.md @@ -48,6 +48,14 @@ Read the implementation code and verify against the task spec: 4. Check for extra functions/types/exports beyond what the spec describes 5. Check test coverage alignment: compare the task's `## Expected Outcome` against the implementer's test assertions. Do the tests verify the behaviors the spec describes, or do they only test implementation details? Flag gaps where a spec behavior has no corresponding test assertion. +## Supervisor Escalation + +If runtime bridge instructions identify `contact_supervisor`, use it only for decisions that block safe completion: product scope, API shape, user approval, or contradictory requirements. Send `reason: "need_decision"` and wait for the reply before continuing. + +Use `reason: "progress_update"` only for meaningful unexpected discoveries that change the review plan or for explicit progress checkpoints. Preserve read-only behavior and do not send routine completion handoffs through intercom; return your final review result normally. + +Never invent an intercom target. If bridge instructions are absent, report `BLOCKED` or `NEEDS_CONTEXT` in your normal final output instead of guessing. + ## Report Format ``` diff --git a/extensions/arc.ts b/extensions/arc.ts index 7c798e7..f88f750 100644 --- a/extensions/arc.ts +++ b/extensions/arc.ts @@ -1,6 +1,6 @@ import { createHash } from "node:crypto"; import { spawn } from "node:child_process"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { readFile } from "node:fs/promises"; import { homedir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -15,17 +15,31 @@ import { } from "@mariozechner/pi-coding-agent"; import { Type } from "typebox"; import { + ARC_MODELS_CONFIG_VERSION, type ArcModelProfileKey, type ArcThinkingLevel, applyArcThinkingSuffix, getSupportedArcThinkingLevels, loadArcModelsConfig, + normalizeArcModelsConfig, resolveArcModelProfile, resolveArcModelsConfigPath, saveArcModelsConfig, toArcModelInfo, } from "./arc/model-profiles.ts"; import { openArcModelProfilesEditor } from "./arc/model-profiles-ui.ts"; +import { + ARC_PI_SUBAGENTS, + ARC_SUBAGENT_GENERATED_MARKER, + buildArcSubagentMarkdown, + buildArcSubagentMetadata, + isGeneratedArcSubagent, + materializeArcSubagents, + resolveArcSubagentDir, + type ArcSubagentMaterializationReason, + type ArcSubagentMaterializationResult, + type ArcSubagentScope, +} from "./arc/subagents.ts"; type ArcCommandResult = { code: number | null; @@ -94,37 +108,21 @@ const ARC_AGENT_NAMES = [ "spec-reviewer", ] as const; -const ARC_AGENT_PROFILE_KEYS: Record = { - builder: "builder", - "code-reviewer": "codeReviewer", - "doc-writer": "docWriter", - evaluator: "evaluator", - "issue-manager": "issueManager", - "spec-reviewer": "specReviewer", -}; - -const ARC_PI_SUBAGENTS = [ - { source: "builder", target: "arc-builder" }, - { source: "doc-writer", target: "arc-doc-writer" }, - { source: "spec-reviewer", target: "arc-spec-reviewer" }, - { source: "code-reviewer", target: "arc-code-reviewer" }, - { source: "evaluator", target: "arc-evaluator" }, - { source: "issue-manager", target: "arc-issue-manager" }, -] as const; - -const ARC_SUBAGENT_GENERATED_MARKER = ""; -const ARC_SUBAGENT_SYNC_VERSION = "1"; +const ARC_AGENT_PROFILE_KEYS = Object.fromEntries( + ARC_PI_SUBAGENTS.map(({ source, profileKey }) => [source, profileKey]), +) as Record; const EXTENSION_DIR = path.dirname(fileURLToPath(import.meta.url)); const PACKAGE_ROOT = path.resolve(EXTENSION_DIR, ".."); const AGENTS_DIR = path.join(PACKAGE_ROOT, "agents"); +const ARC_SUBAGENT_SKIP_REASON = "existing file is missing the generated marker; preserving user edits"; type ArcModelTier = "nano" | "small" | "standard" | "large"; type ArcModelTierMap = Record; const DEFAULT_ARC_MODEL_TIERS: ArcModelTierMap = { - nano: "openai-codex/gpt-5.4-nano", + nano: "openai-codex/gpt-5.4-mini", small: "openai-codex/gpt-5.4-mini", standard: "openai-codex/gpt-5.3-codex", large: "openai-codex/gpt-5.5", @@ -397,6 +395,21 @@ async function promptBrainstormProfileSetup( ); } +async function materializeArcSubagentsAfterModelSave(ctx: ExtensionContext): Promise { + const materialized = await materializeArcSubagentsForContext(ctx, "arc_models_save"); + notifyArcSubagentMaterialization(ctx, materialized); +} + +async function saveArcModelsConfigWithMaterialization( + ctx: ExtensionContext, + config: ArcModelsConfig, + configPath: string, +): Promise { + await saveArcModelsConfig(config, configPath); + if (ctx.hasUI) ctx.ui.notify("Arc model profiles saved", "info"); + await materializeArcSubagentsAfterModelSave(ctx); +} + async function openAndMaybeSaveArcModelProfiles( ctx: ExtensionContext, config: ArcModelsConfig, @@ -409,8 +422,7 @@ async function openAndMaybeSaveArcModelProfiles( if (markSetupComplete) { result.config.setup = { ...result.config.setup, completedAt: new Date().toISOString(), dismissedAt: null }; } - await saveArcModelsConfig(result.config, configPath); - if (ctx.hasUI) ctx.ui.notify("Arc model profiles saved", "info"); + await saveArcModelsConfigWithMaterialization(ctx, result.config, configPath); return true; } @@ -486,8 +498,7 @@ async function maybeEnsureBrainstormProfileReady(ctx: ExtensionContext, _args?: if (action === "fallback") return true; if (action === "disable") { delete config.modelProfiles.brainstorm; - await saveArcModelsConfig(config, configPath); - if (ctx.hasUI) ctx.ui.notify("Arc model profiles saved", "info"); + await saveArcModelsConfigWithMaterialization(ctx, config, configPath); return true; } return false; @@ -520,8 +531,7 @@ async function maybeEnsureBrainstormProfileReady(ctx: ExtensionContext, _args?: if (action === "recommended") { applyRecommendedArcModelProfiles(config, availableModels, preferredProvider); - await saveArcModelsConfig(config, configPath); - if (ctx.hasUI) ctx.ui.notify("Arc model profiles saved", "info"); + await saveArcModelsConfigWithMaterialization(ctx, config, configPath); return true; } if (action === "customize") return openAndMaybeSaveArcModelProfiles(ctx, config, configPath, preferredProvider, true); @@ -573,24 +583,17 @@ function truncatedOutput(text: string): string { )} of ${formatSize(truncation.totalBytes)}).]`; } -type ArcSubagentScope = "project" | "user"; - -type ArcSubagentSyncSkip = { - file: string; - reason: string; -}; - function parseArcSubagentScopeArg(rawArgs: string): { scope: ArcSubagentScope; error?: string } { const tokens = rawArgs .trim() .split(/\s+/) .filter(Boolean); - if (tokens.length === 0) return { scope: "project" }; + if (tokens.length === 0) return { scope: "user" }; if (tokens.length > 1) { return { - scope: "project", - error: `Unsupported arguments: ${tokens.join(" ")}. Use one of: project, --project, user, --user.`, + scope: "user", + error: `Unsupported arguments: ${tokens.join(" ")}. Use one of: user, --user, project, --project.`, }; } @@ -598,50 +601,121 @@ function parseArcSubagentScopeArg(rawArgs: string): { scope: ArcSubagentScope; e if (token === "project" || token === "--project") return { scope: "project" }; if (token === "user" || token === "--user") return { scope: "user" }; return { - scope: "project", - error: `Unknown scope '${tokens[0]}'. Use one of: project, --project, user, --user.`, + scope: "user", + error: `Unknown scope '${tokens[0]}'. Use one of: user, --user, project, --project.`, }; } -function resolveArcSubagentDir(scope: ArcSubagentScope, cwd: string): string { - if (scope === "user") return path.join(homedir(), ".pi", "agent", "agents"); - return path.join(cwd, ".pi", "agents"); +function sha256Text(text: string): string { + return createHash("sha256").update(text).digest("hex"); } -function yamlStringValue(value: string): string { - return JSON.stringify(value); -} +async function materializeArcSubagentsForContext( + ctx: ExtensionContext, + reason: ArcSubagentMaterializationReason, + scope: ArcSubagentScope = "user", +): Promise { + const configPath = resolveArcModelsConfigPath(); + const homeDir = homedir(); + + let normalizedConfigText: string; + try { + const raw = await readFile(configPath, "utf8"); + normalizedConfigText = `${JSON.stringify(normalizeArcModelsConfig(JSON.parse(raw)), null, 2)}\n`; + } catch { + normalizedConfigText = `${JSON.stringify(normalizeArcModelsConfig({ version: ARC_MODELS_CONFIG_VERSION, modelProfiles: {} }), null, 2)}\n`; + } + + const targetDir = resolveArcSubagentDir(scope, ctx.cwd, homeDir); + const legacyTargetDir = scope === "user" ? resolveArcSubagentDir(scope, ctx.cwd, homeDir, { legacyUserDir: true }) : undefined; + const modelsConfigSha256 = sha256Text(normalizedConfigText); + + const materialized = await materializeArcSubagents({ + reason, + scope, + cwd: ctx.cwd, + homeDir, + agentsDir: AGENTS_DIR, + modelsConfigSha256, + allowLegacyUserDirFallback: scope === "user", + renderAgent: async (source, target) => { + const sourceMarkdown = await readFile(path.join(AGENTS_DIR, `${source}.md`), "utf8"); + const parsedSource = parseAgentMarkdown(sourceMarkdown); + const modelResolution = await resolveArcModelForAgent( + source as ArcAgentName, + undefined, + parsedSource.model, + ctx.cwd, + ctx.modelRegistry.getAvailable(), + ctx.model?.provider, + ); + const resolvedModel = modelResolution.model ?? (await modelPattern(parsedSource.model, ctx.cwd)); + const generatedAt = new Date().toISOString(); + const rendered = buildArcSubagentMarkdown({ + targetName: target, + sourceName: source, + sourceMarkdown, + parsedSource, + resolvedModel, + modelProfileKey: modelResolution.profileKey, + modelResolutionSource: modelResolution.modelSource, + modelsConfigHash: modelsConfigSha256, + generatedAt, + }); + const expectedMetadata = buildArcSubagentMetadata({ + sourceName: source, + sourceSha256: sha256Text(sourceMarkdown), + modelProfileKey: modelResolution.profileKey, + modelResolutionSource: modelResolution.modelSource, + modelsConfigSha256, + generatedAt, + }); + if (!rendered.includes(ARC_SUBAGENT_GENERATED_MARKER) || !rendered.includes(expectedMetadata)) { + throw new Error(`Generated Arc subagent metadata missing source-sha256 freshness block for ${target}`); + } + return rendered; + }, + }); + + if (materialized.targetDir !== targetDir && materialized.targetDir !== legacyTargetDir) { + throw new Error(`Arc subagent target directory mismatch: expected ${targetDir}, got ${materialized.targetDir}`); + } -function formatArcSubagentTools(tools: string[] | undefined): string | undefined { - if (!tools || tools.length === 0) return undefined; - const normalized = tools.map((tool) => tool.trim().toLowerCase()).filter(Boolean); - if (normalized.length === 0) return undefined; - return normalized.join(", "); + return materialized; } -function buildArcSubagentMarkdown( - targetName: string, - sourceName: string, - sourceMarkdown: string, - parsedSource: ReturnType, - resolvedModel: string | undefined, -): string { - const frontmatter: string[] = ["---", `name: ${targetName}`]; - if (parsedSource.description) frontmatter.push(`description: ${yamlStringValue(parsedSource.description)}`); - if (resolvedModel) frontmatter.push(`model: ${resolvedModel}`); - - const toolsValue = formatArcSubagentTools(parsedSource.tools); - if (toolsValue) frontmatter.push(`tools: ${toolsValue}`); - - frontmatter.push( - "systemPromptMode: replace", - "inheritProjectContext: true", - "inheritSkills: false", - "---", - ); +function notifyArcSubagentMaterialization(ctx: ExtensionContext, result: ArcSubagentMaterializationResult) { + const skipped = result.writes.filter((entry) => entry.status === "skipped"); + const failed = result.writes.filter((entry) => entry.status === "failed"); + const hasWarnings = skipped.length > 0 || failed.length > 0 || result.shadows.length > 0; + if (!hasWarnings) return; + + if (ctx.hasUI) { + const shadowSummary = result.shadows.slice(0, 2).map((shadow) => `${shadow.agent} at ${shadow.projectPath}`).join("; "); + ctx.ui.notify( + `Arc subagent materialization (${result.reason}): skipped ${skipped.length}, failed ${failed.length}, shadows ${result.shadows.length}` + + (shadowSummary ? `. Project scope wins over user scope: ${shadowSummary}` : ""), + failed.length > 0 || result.shadows.length > 0 ? "warning" : "info", + ); + } +} - const sourceSha = createHash("sha256").update(sourceMarkdown).digest("hex"); - return `${frontmatter.join("\n")}\n\n${ARC_SUBAGENT_GENERATED_MARKER}\n\n\n\n\n${parsedSource.prompt.trim()}\n`; +async function formatSkippedArcSubagentDetails(result: ArcSubagentMaterializationResult): Promise { + const skipped = result.writes.filter((entry) => entry.status === "skipped"); + const details: string[] = []; + for (const entry of skipped) { + try { + const existing = await readFile(entry.target, "utf8"); + if (isGeneratedArcSubagent(existing)) { + details.push(`- ${entry.agent}: skipped because the existing generated file was not rewritten`); + continue; + } + } catch { + // Preserve the materializer-reported reason when the file cannot be re-read. + } + details.push(`- ${entry.agent}: ${entry.reason ?? ARC_SUBAGENT_SKIP_REASON}`); + } + return details; } function runArcWithStdin( @@ -757,7 +831,7 @@ export default function arcExtension(pi: ExtensionAPI) { "Run a bundled Arc specialist agent (builder, reviewer, issue-manager, etc.) in a fresh Pi subprocess. Output is truncated to 50KB/2000 lines.", promptSnippet: "Delegate Arc issue-management, implementation, review, docs, and evaluation tasks to bundled specialist agents.", promptGuidelines: [ - "Prefer true pi-subagents Arc specialists (arc-builder, arc-issue-manager, arc-code-reviewer, etc.) when synced so long runs can be monitored with /subagents-status.", + "Prefer true pi-subagents Arc specialists (arc-builder, arc-issue-manager, arc-code-reviewer, etc.) when available/auto-materialized so long runs can be monitored with /subagents-status.", "For bulk issue creation, do not use arc_agent issue-manager when subagent({ action: \"list\" }) shows arc-issue-manager; dispatch arc-issue-manager asynchronously instead.", "Use arc_agent only as the self-contained fallback when Arc pi-subagents definitions are unavailable or a workflow skill explicitly asks for the fallback.", "Right-size fallback arc_agent dispatches with model tiers: nano for bulk CLI issue creation, small for mechanical/docs tasks, standard for normal contained work, large for complex or high-risk work.", @@ -839,6 +913,18 @@ export default function arcExtension(pi: ExtensionAPI) { } as any); pi.on("session_start", async (_event, ctx) => { + try { + const materialized = await materializeArcSubagentsForContext(ctx, "session_start"); + notifyArcSubagentMaterialization(ctx, materialized); + } catch (error) { + if (ctx.hasUI) { + ctx.ui.notify( + `Arc subagent materialization failed: ${error instanceof Error ? error.message : String(error)}`, + "warning", + ); + } + } + const ok = await refreshPrime(ctx); if (ctx.hasUI) { ctx.ui.notify(ok ? "arc context loaded" : "arc context unavailable", ok ? "info" : "warning"); @@ -915,82 +1001,46 @@ export default function arcExtension(pi: ExtensionAPI) { }); pi.registerCommand("arc-subagents-sync", { - description: "Write Arc specialist pi-subagents agent definitions to project or user scope", + description: "Repair generated Arc specialist definitions (arc-builder, arc-doc-writer, arc-spec-reviewer, arc-code-reviewer, arc-evaluator, arc-issue-manager) in user/project scope", handler: async (args, ctx) => { const parsedArgs = parseArcSubagentScopeArg(args); if (parsedArgs.error) { if (ctx.hasUI) ctx.ui.notify("arc-subagents-sync failed", "error"); sendArcMessage( "Arc subagents sync failed", - `${parsedArgs.error}\n\nUsage:\n- \`/arc-subagents-sync\` (project scope)\n- \`/arc-subagents-sync project\`\n- \`/arc-subagents-sync user\``, + `${parsedArgs.error}\n\nUsage:\n- \`/arc-subagents-sync\` (user scope)\n- \`/arc-subagents-sync user\`\n- \`/arc-subagents-sync project\` (repairs \`${path.join(".pi", "agents")}\`)\n\nLegacy user-scope generated agents may exist at \`${path.join(".pi", "agent", "agents")}\`.`, ); return; } - const targetDir = resolveArcSubagentDir(parsedArgs.scope, ctx.cwd); - await mkdir(targetDir, { recursive: true }); - - const written: string[] = []; - const skipped: ArcSubagentSyncSkip[] = []; - const failed: ArcSubagentSyncSkip[] = []; - - for (const { source, target } of ARC_PI_SUBAGENTS) { - const sourcePath = path.join(AGENTS_DIR, `${source}.md`); - const targetPath = path.join(targetDir, `${target}.md`); - - try { - const sourceMarkdown = await readFile(sourcePath, "utf8"); - const parsedSource = parseAgentMarkdown(sourceMarkdown); - const modelResolution = await resolveArcModelForAgent( - source, - undefined, - parsedSource.model, - ctx.cwd, - ctx.modelRegistry.getAvailable(), - ctx.model?.provider, - ); - const resolvedModel = modelResolution.model ?? (await modelPattern(parsedSource.model, ctx.cwd)); - const output = buildArcSubagentMarkdown(target, source, sourceMarkdown, parsedSource, resolvedModel); - - let existing: string | undefined; - try { - existing = await readFile(targetPath, "utf8"); - } catch { - existing = undefined; - } - - if (existing && !existing.includes(ARC_SUBAGENT_GENERATED_MARKER)) { - skipped.push({ - file: targetPath, - reason: "existing file is missing the generated marker; preserving user edits", - }); - continue; - } + const materialized = await materializeArcSubagentsForContext(ctx, "manual_repair", parsedArgs.scope); + notifyArcSubagentMaterialization(ctx, materialized); - await writeFile(targetPath, output, "utf8"); - written.push(targetPath); - } catch (error) { - failed.push({ - file: targetPath, - reason: error instanceof Error ? error.message : String(error), - }); - } - } + const written = materialized.writes.filter((entry) => entry.status === "written"); + const unchanged = materialized.writes.filter((entry) => entry.status === "unchanged"); + const skipped = materialized.writes.filter((entry) => entry.status === "skipped"); + const failed = materialized.writes.filter((entry) => entry.status === "failed"); - if (ctx.hasUI) { - ctx.ui.notify( - `arc-subagents-sync wrote ${written.length}, skipped ${skipped.length}${failed.length ? `, failed ${failed.length}` : ""}`, - failed.length > 0 ? "warning" : "info", - ); - } + const skippedDetails = await formatSkippedArcSubagentDetails(materialized); + const failedDetails = failed.map((entry) => `- ${entry.agent}: ${entry.reason ?? "unknown failure"}`); + const shadowDetails = materialized.shadows.map((shadow) => { + const generatedLabel = shadow.generated ? "generated" : "non-generated"; + const staleLabel = shadow.stale ? "stale" : "current"; + return `- ${shadow.agent}: project=\`${shadow.projectPath}\`, user=\`${shadow.userPath}\` (${generatedLabel}, ${staleLabel}; project scope wins over user scope)`; + }); - const writtenText = written.length > 0 ? written.map((file) => `- \`${file}\``).join("\n") : "- none"; - const skippedText = skipped.length > 0 ? skipped.map((entry) => `- \`${entry.file}\` — ${entry.reason}`).join("\n") : "- none"; - const failedText = failed.length > 0 ? failed.map((entry) => `- \`${entry.file}\` — ${entry.reason}`).join("\n") : "- none"; + const detailSections: string[] = []; + if (skippedDetails.length > 0) detailSections.push(`Skipped details:\n${skippedDetails.join("\n")}`); + if (failedDetails.length > 0) detailSections.push(`Failed details:\n${failedDetails.join("\n")}`); + if (shadowDetails.length > 0) detailSections.push(`Project shadow details:\n${shadowDetails.join("\n")}`); sendArcMessage( "Arc subagents sync", - `Scope: **${parsedArgs.scope}**\nTarget directory: \`${targetDir}\`\n\nWritten/updated:\n${writtenText}\n\nSkipped:\n${skippedText}\n\nFailed:\n${failedText}\n\nNext steps:\n1. Run \`subagent({ action: "list" })\` to confirm the Arc specialists are available.\n2. Run \`/agents\` to inspect loaded agent definitions.\n3. Use \`/subagents-status\` to monitor active/recent async Arc specialist runs after launch; idle installed agents are listed by \`/agents\`, not the status overlay.\n4. Use Arc specialists (for example \`arc-spec-reviewer\`) for Arc gates instead of generic \`worker\` agents.`, + "/arc-subagents-sync is deprecated for normal activation. Arc specialists are auto-materialized in user scope. Use this command only to repair generated files or explicitly refresh legacy project-scope definitions.\n\n" + + `Scope: **${materialized.scope}**\nTarget directory: \`${materialized.targetDir}\`\n\n` + + `Written: ${written.length}\nUnchanged: ${unchanged.length}\nSkipped: ${skipped.length}\nFailed: ${failed.length}\nShadows: ${materialized.shadows.length}\n\n` + + (detailSections.length > 0 ? `${detailSections.join("\n\n")}\n\n` : "") + + 'Next steps:\n1. Run `subagent({ action: "list" })` to confirm the Arc specialists are available.\n2. Run `/agents` to inspect loaded agent definitions.\n3. Use `/subagents-status` to monitor active/recent async Arc specialist runs; idle installed agents are listed by `/agents`.', ); }, }); diff --git a/extensions/arc/subagents.ts b/extensions/arc/subagents.ts new file mode 100644 index 0000000..bdd7670 --- /dev/null +++ b/extensions/arc/subagents.ts @@ -0,0 +1,334 @@ +import { createHash } from "node:crypto"; +import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; +import path from "node:path"; + +import type { ArcModelProfileKey } from "./model-profiles.ts"; + +export const ARC_SUBAGENT_GENERATED_MARKER = ""; +const ARC_SUBAGENT_LEGACY_GENERATED_MARKER = ""; +export const ARC_SUBAGENT_SYNC_VERSION = "2"; + +export type ArcSubagentMaterializationReason = + | "session_start" + | "arc_models_save" + | "manual_repair"; + +export type ArcSubagentScope = "user" | "project"; + +export type ArcSubagentWriteStatus = "written" | "unchanged" | "skipped" | "failed"; + +export interface ArcSubagentWriteResult { + agent: string; + source: string; + target: string; + status: ArcSubagentWriteStatus; + reason?: string; +} + +export interface ArcSubagentShadowWarning { + agent: string; + projectPath: string; + userPath: string; + generated: boolean; + stale: boolean; + message: string; +} + +export interface ArcSubagentMaterializationResult { + reason: ArcSubagentMaterializationReason; + scope: ArcSubagentScope; + targetDir: string; + writes: ArcSubagentWriteResult[]; + shadows: ArcSubagentShadowWarning[]; +} + +export interface ArcSubagentTemplate { + sourceName: string; + targetName: string; + profileKey: ArcModelProfileKey; +} + +export interface ArcSubagentParsedSource { + prompt: string; + description?: string; + tools?: string[]; +} + +export interface ArcSubagentRenderInput { + targetName: string; + sourceName: string; + sourceMarkdown: string; + parsedSource: ArcSubagentParsedSource; + resolvedModel?: string; + modelProfileKey: ArcModelProfileKey; + modelResolutionSource: string; + modelsConfigHash: string; + generatedAt: string; +} + +export const ARC_PI_SUBAGENTS = [ + { source: "builder", target: "arc-builder", profileKey: "builder" }, + { source: "doc-writer", target: "arc-doc-writer", profileKey: "docWriter" }, + { source: "spec-reviewer", target: "arc-spec-reviewer", profileKey: "specReviewer" }, + { source: "code-reviewer", target: "arc-code-reviewer", profileKey: "codeReviewer" }, + { source: "evaluator", target: "arc-evaluator", profileKey: "evaluator" }, + { source: "issue-manager", target: "arc-issue-manager", profileKey: "issueManager" }, +] as const; + +export function resolveArcSubagentDir( + scope: ArcSubagentScope, + cwd: string, + homeDir: string, + options: { legacyUserDir?: boolean } = {}, +): string { + if (scope === "project") return path.join(cwd, ".pi", "agents"); + if (options.legacyUserDir) return path.join(homeDir, ".pi", "agent", "agents"); + return path.join(homeDir, ".agents"); +} + +export function isGeneratedArcSubagent(markdown: string | undefined): boolean { + return Boolean( + markdown?.includes(ARC_SUBAGENT_GENERATED_MARKER) + || markdown?.includes(ARC_SUBAGENT_LEGACY_GENERATED_MARKER), + ); +} + +export function buildArcSubagentMetadata(input: { + sourceName: string; + sourceSha256: string; + modelProfileKey: string; + modelResolutionSource: string; + modelsConfigSha256: string; + generatedAt: string; +}): string { + return [ + ARC_SUBAGENT_GENERATED_MARKER, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ].join("\n"); +} + +function sha256Text(text: string): string { + return createHash("sha256").update(text).digest("hex"); +} + +function yamlStringValue(value: string): string { + return JSON.stringify(value); +} + +function errorCode(error: unknown): string | undefined { + if (typeof error !== "object" || error === null || !("code" in error)) return undefined; + const code = (error as { code?: unknown }).code; + return typeof code === "string" ? code : undefined; +} + +function withoutGeneratedAt(markdown: string): string { + return markdown.replace(/^$/m, ""); +} + +function equivalentGeneratedArcSubagent(actual: string, expected: string): boolean { + return withoutGeneratedAt(actual) === withoutGeneratedAt(expected); +} + +async function isDirectory(dir: string): Promise { + try { + return (await stat(dir)).isDirectory(); + } catch { + return false; + } +} + +async function nearestArcProjectRoot(cwd: string): Promise { + let current = path.resolve(cwd); + const root = path.parse(current).root; + while (true) { + if (await isDirectory(path.join(current, ".pi")) || await isDirectory(path.join(current, ".agents"))) { + return current; + } + if (current === root) return path.resolve(cwd); + current = path.dirname(current); + } +} + +async function arcProjectShadowPaths(cwd: string, target: string): Promise { + const projectRoot = await nearestArcProjectRoot(cwd); + return [ + path.join(projectRoot, ".agents", `${target}.md`), + path.join(projectRoot, ".pi", "agents", `${target}.md`), + ]; +} + +export function buildArcSubagentMarkdown(input: ArcSubagentRenderInput): string { + const metadata = buildArcSubagentMetadata({ + sourceName: input.sourceName, + sourceSha256: sha256Text(input.sourceMarkdown), + modelProfileKey: input.modelProfileKey, + modelResolutionSource: input.modelResolutionSource, + modelsConfigSha256: input.modelsConfigHash, + generatedAt: input.generatedAt, + }); + + const frontmatter = [ + "---", + `name: ${input.targetName}`, + input.parsedSource.description ? `description: ${yamlStringValue(input.parsedSource.description)}` : undefined, + input.resolvedModel ? `model: ${input.resolvedModel}` : undefined, + input.parsedSource.tools?.length ? `tools: ${input.parsedSource.tools.join(", ")}` : undefined, + "systemPromptMode: replace", + "inheritProjectContext: true", + "inheritSkills: false", + "---", + ] + .filter((line): line is string => Boolean(line)) + .join("\n"); + + const body = input.parsedSource.prompt; + return `${frontmatter}\n${metadata}\n\n${body}`; +} + +export async function materializeArcSubagents(input: { + reason: ArcSubagentMaterializationReason; + scope: ArcSubagentScope; + cwd: string; + homeDir: string; + agentsDir: string; + modelsConfigSha256: string; + renderAgent: (source: string, target: string) => Promise; + legacyUserDir?: boolean; + allowLegacyUserDirFallback?: boolean; +}): Promise { + return materializeArcSubagentsOnce(input); +} + +async function materializeArcSubagentsOnce(input: Parameters[0]): Promise { + const targetDir = resolveArcSubagentDir(input.scope, input.cwd, input.homeDir, { legacyUserDir: input.legacyUserDir }); + try { + await mkdir(targetDir, { recursive: true }); + } catch (error) { + if (input.scope === "user" && input.allowLegacyUserDirFallback && !input.legacyUserDir) { + return materializeArcSubagentsOnce({ ...input, legacyUserDir: true, allowLegacyUserDirFallback: false }); + } + throw error; + } + + const writes: ArcSubagentWriteResult[] = []; + const shadows: ArcSubagentShadowWarning[] = []; + + for (const { source, target } of ARC_PI_SUBAGENTS) { + const sourcePath = path.join(input.agentsDir, `${source}.md`); + const targetPath = path.join(targetDir, `${target}.md`); + const projectPaths = input.scope === "user" ? await arcProjectShadowPaths(input.cwd, target) : []; + + try { + const rendered = await input.renderAgent(source, target); + + let existing: string | undefined; + try { + existing = await readFile(targetPath, "utf8"); + } catch (error) { + if (errorCode(error) !== "ENOENT") { + writes.push({ + agent: target, + source: sourcePath, + target: targetPath, + status: "failed", + reason: `could not read existing target: ${error instanceof Error ? error.message : String(error)}`, + }); + continue; + } + existing = undefined; + } + + if (existing === undefined) { + try { + await writeFile(targetPath, rendered, "utf8"); + writes.push({ agent: target, source: sourcePath, target: targetPath, status: "written" }); + } catch (error) { + writes.push({ + agent: target, + source: sourcePath, + target: targetPath, + status: "failed", + reason: `could not write target: ${error instanceof Error ? error.message : String(error)}`, + }); + continue; + } + } else if (!isGeneratedArcSubagent(existing)) { + writes.push({ + agent: target, + source: sourcePath, + target: targetPath, + status: "skipped", + reason: "existing file is missing generated marker; preserving user edits", + }); + } else if (existing === rendered || equivalentGeneratedArcSubagent(existing, rendered)) { + writes.push({ agent: target, source: sourcePath, target: targetPath, status: "unchanged" }); + } else { + try { + await writeFile(targetPath, rendered, "utf8"); + writes.push({ agent: target, source: sourcePath, target: targetPath, status: "written" }); + } catch (error) { + writes.push({ + agent: target, + source: sourcePath, + target: targetPath, + status: "failed", + reason: `could not write target: ${error instanceof Error ? error.message : String(error)}`, + }); + continue; + } + } + + for (const projectPath of projectPaths) { + if (path.resolve(projectPath) === path.resolve(targetPath)) continue; + try { + const projectContent = await readFile(projectPath, "utf8"); + const generated = isGeneratedArcSubagent(projectContent); + const hasCurrentModelConfig = projectContent.includes(`models-config-sha256: ${input.modelsConfigSha256}`); + shadows.push({ + agent: target, + projectPath, + userPath: targetPath, + generated, + stale: generated && (!hasCurrentModelConfig || !equivalentGeneratedArcSubagent(projectContent, rendered)), + message: generated + ? `Project shadow detected at ${projectPath}; pi-subagents project scope wins over user scope ${targetPath}` + : `Project shadow detected at ${projectPath} (non-generated); pi-subagents project scope wins over user scope ${targetPath}`, + }); + } catch (error) { + if (errorCode(error) !== "ENOENT") { + shadows.push({ + agent: target, + projectPath, + userPath: targetPath, + generated: false, + stale: false, + message: `Could not inspect project shadow at ${projectPath}; pi-subagents project scope may still win over user scope ${targetPath}: ${error instanceof Error ? error.message : String(error)}`, + }); + } + } + } + } catch (error) { + writes.push({ + agent: target, + source: sourcePath, + target: targetPath, + status: "failed", + reason: error instanceof Error ? error.message : String(error), + }); + } + } + + return { + reason: input.reason, + scope: input.scope, + targetDir, + writes, + shadows, + }; +} diff --git a/package-lock.json b/package-lock.json index aa50ab0..30f66cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,14 +9,12 @@ "version": "0.9.1", "bundleDependencies": [ "@juicesharp/rpiv-todo", - "@juicesharp/rpiv-ask-user-question", - "pi-subagents" + "@juicesharp/rpiv-ask-user-question" ], "license": "MIT", "dependencies": { "@juicesharp/rpiv-ask-user-question": "^1.0.14", - "@juicesharp/rpiv-todo": "^0.12.5", - "pi-subagents": "^0.23.0" + "@juicesharp/rpiv-todo": "^0.12.5" }, "engines": { "node": ">=20.6.0" @@ -66,46 +64,6 @@ "@mariozechner/pi-tui": "*", "@sinclair/typebox": "*" } - }, - "node_modules/pi-subagents": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/pi-subagents/-/pi-subagents-0.23.0.tgz", - "integrity": "sha512-f1m7+gRBgEVJcfNMe9gD1GFjYZWchn3nKZkPkYtTSwLI3Y6eOUqPdQM6Hry4dA/gTyWXsUVFv29Gw9JZsfh3mA==", - "inBundle": true, - "license": "MIT", - "dependencies": { - "typebox": "^1.1.24" - }, - "bin": { - "pi-subagents": "install.mjs" - }, - "peerDependencies": { - "@mariozechner/pi-agent-core": "*", - "@mariozechner/pi-ai": "*", - "@mariozechner/pi-coding-agent": "*", - "@mariozechner/pi-tui": "*" - }, - "peerDependenciesMeta": { - "@mariozechner/pi-agent-core": { - "optional": true - }, - "@mariozechner/pi-ai": { - "optional": true - }, - "@mariozechner/pi-coding-agent": { - "optional": true - }, - "@mariozechner/pi-tui": { - "optional": true - } - } - }, - "node_modules/typebox": { - "version": "1.1.37", - "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.37.tgz", - "integrity": "sha512-jb7jp6KvOvvy5sd+11AfJ0/e0F0AS9RcOXd55oGi2ZnRHIGmFvrTaNF+ZidRmGBmmNTkM5KKl0Z37KzxJ+owEQ==", - "inBundle": true, - "license": "MIT" } } } diff --git a/package.json b/package.json index f43156d..53ae082 100644 --- a/package.json +++ b/package.json @@ -45,13 +45,11 @@ }, "dependencies": { "@juicesharp/rpiv-ask-user-question": "^1.0.14", - "@juicesharp/rpiv-todo": "^0.12.5", - "pi-subagents": "^0.23.0" + "@juicesharp/rpiv-todo": "^0.12.5" }, "bundledDependencies": [ "@juicesharp/rpiv-todo", - "@juicesharp/rpiv-ask-user-question", - "pi-subagents" + "@juicesharp/rpiv-ask-user-question" ], "peerDependencies": { "@mariozechner/pi-ai": "*", @@ -75,18 +73,15 @@ }, "pi": { "skills": [ - "./skills", - "./node_modules/pi-subagents/skills" + "./skills" ], "prompts": [ - "./prompts/*.md", - "./node_modules/pi-subagents/prompts" + "./prompts/*.md" ], "extensions": [ "./extensions/*.ts", "./node_modules/@juicesharp/rpiv-todo/index.ts", - "./node_modules/@juicesharp/rpiv-ask-user-question/index.ts", - "./node_modules/pi-subagents/src/extension/index.ts" + "./node_modules/@juicesharp/rpiv-ask-user-question/index.ts" ] } } diff --git a/scripts/migrate-arc-plugin.py b/scripts/migrate-arc-plugin.py index 4b831df..ff8d14d 100644 --- a/scripts/migrate-arc-plugin.py +++ b/scripts/migrate-arc-plugin.py @@ -417,7 +417,7 @@ def replace_section(rel: str, start_marker: str, end_marker: str, replacement: s | Tier | Default concrete model | Use for | |---|---|---| -| `nano` | `openai-codex/gpt-5.4-nano` | Bulk CLI issue creation and other low-reasoning issue-manager work | +| `nano` | `openai-codex/gpt-5.4-mini` | Bulk CLI issue creation and other low-reasoning issue-manager work | | `small` | `openai-codex/gpt-5.4-mini` | Mechanical edits and docs | | `standard` | `openai-codex/gpt-5.3-codex` | Normal contained implementation/review | | `large` | `openai-codex/gpt-5.5` | Cross-cutting, architectural, security-sensitive, or adversarial review | @@ -428,7 +428,7 @@ def replace_section(rel: str, start_marker: str, end_marker: str, replacement: s { "arc": { "modelTiers": { - "nano": "openai-codex/gpt-5.4-nano", + "nano": "openai-codex/gpt-5.4-mini", "small": "openai-codex/gpt-5.4-mini", "standard": "openai-codex/gpt-5.3-codex", "large": "openai-codex/gpt-5.5" diff --git a/skills/arc-build/SKILL.md b/skills/arc-build/SKILL.md index 0429911..d9a0657 100644 --- a/skills/arc-build/SKILL.md +++ b/skills/arc-build/SKILL.md @@ -17,7 +17,7 @@ Every Arc subagent dispatch can override the subagent's frontmatter model via th | Tier | Default concrete model | Use for | |---|---|---| -| `nano` | `openai-codex/gpt-5.4-nano` | Bulk CLI issue creation and other low-reasoning issue-manager work | +| `nano` | `openai-codex/gpt-5.4-mini` | Bulk CLI issue creation and other low-reasoning issue-manager work | | `small` | `openai-codex/gpt-5.4-mini` | Mechanical edits and docs | | `standard` | `openai-codex/gpt-5.3-codex` | Normal contained implementation/review | | `large` | `openai-codex/gpt-5.5` | Cross-cutting, architectural, security-sensitive, or adversarial review | @@ -39,7 +39,7 @@ Legacy fallback settings can still override the tier map in `~/.pi/agent/setting { "arc": { "modelTiers": { - "nano": "openai-codex/gpt-5.4-nano", + "nano": "openai-codex/gpt-5.4-mini", "small": "openai-codex/gpt-5.4-mini", "standard": "openai-codex/gpt-5.3-codex", "large": "openai-codex/gpt-5.5" @@ -50,7 +50,7 @@ Legacy fallback settings can still override the tier map in `~/.pi/agent/setting Legacy aliases still resolve for compatibility: `haiku` → `small`, `sonnet` → `standard`, `opus` → `large`. Prefer the Pi-native tier names in new prompts, including `nano` for low-reasoning issue-manager work. -Prefer the `subagent` tool from `pi-subagents` when it is available **and** Arc agent definitions such as `arc-builder` are installed. If Arc specialist definitions are missing, run `/arc-subagents-sync` (project default) or `/arc-subagents-sync user`, then re-check with `subagent({ action: "list" })`. Otherwise use the bundled `arc_agent` fallback. `arc_agent` is self-contained and sequential only; `pi-subagents` adds chains, async runs, and worktree-isolated parallel patch generation. +Arc specialists should be auto-materialized by the Arc extension when `pi-subagents` is installed. If `subagent({ action: "list" })` does not show `arc-builder` or another required specialist, first run `subagent({ action: "doctor" })` and inspect Arc's materialization warning. Use `/arc-subagents-sync` only as a deprecated repair command. Otherwise use the bundled `arc_agent` fallback. `arc_agent` is self-contained and sequential only; an external `pi-subagents` install adds chains, async runs, and worktree-isolated parallel patch generation. **Status visibility:** For long Arc workers after `/arc-plan`, prefer `pi-subagents` launches with `async: true, clarify: false`. The returned run appears in `/subagents-status`; you can also poll it with `subagent({ action: "status", id: "" })`. Do not continue to validation, review, patch application, or arc closure until the async run is terminal and you have read its final output. The raw `arc_agent` fallback never appears in `/subagents-status`. @@ -99,9 +99,9 @@ Tasks are dispatched one at a time through the orchestration loop below. Use thi ### Parallel -Parallel worktree dispatch is available **only** through the optional `pi-subagents` companion package, not through `arc_agent`. Use it only when ALL of these are true: -- `pi-subagents` is installed and the `subagent` tool is available -- Arc agent definitions such as `arc-builder` / `arc-doc-writer` are installed for `pi-subagents` +Parallel worktree dispatch is available **only** through an installed `pi-subagents` extension/tool, not through `arc_agent`. Use it only when ALL of these are true: +- `pi-subagents` loaded and the `subagent` tool is available +- Arc agent definitions such as `arc-builder` / `arc-doc-writer` are auto-materialized for `pi-subagents` - 3+ independent tasks remain, or one high-risk evaluator needs a disposable worktree - No shared files between any builder/doc-writer tasks in the batch - No `blocks`/`blockedBy` dependencies between tasks in the batch @@ -164,7 +164,7 @@ Use the template at `./doc-writer-prompt.md`. Fill placeholder `{TASK_ID}`. For Dispatch preference: - If `subagent` is available and `arc-doc-writer` is installed: `subagent({ agent: "arc-doc-writer", task: "", context: "fresh", async: true, clarify: false })` -- If `subagent` is available but Arc specialists are missing: run `/arc-subagents-sync`, verify with `subagent({ action: "list" })`, then retry. +- If `subagent` is available but Arc specialists are missing: Arc specialists should already be auto-materialized. First run `subagent({ action: "doctor" })` and inspect Arc's materialization warning. Use `/arc-subagents-sync` only as a deprecated repair command, then re-check with `subagent({ action: "list" })`. - Otherwise: `arc_agent(agent="doc-writer", task="")` For async `pi-subagents` dispatches, immediately capture the returned run ID, poll with `subagent({ action: "status", id: "" })` or watch `/subagents-status` until terminal, then read the final output before evaluating the report or moving to validation. @@ -175,7 +175,7 @@ Use the template at `./builder-prompt.md`. Fill placeholders (`{TASK_ID}`, `{PRE Dispatch preference: - If `subagent` is available and `arc-builder` is installed: `subagent({ agent: "arc-builder", task: "", model: "", context: "fresh", async: true, clarify: false })` -- If `subagent` is available but Arc specialists are missing: run `/arc-subagents-sync`, verify with `subagent({ action: "list" })`, then retry. +- If `subagent` is available but Arc specialists are missing: Arc specialists should already be auto-materialized. First run `subagent({ action: "doctor" })` and inspect Arc's materialization warning. Use `/arc-subagents-sync` only as a deprecated repair command, then re-check with `subagent({ action: "list" })`. - Otherwise: `arc_agent(agent="builder", task="", model="")` For async `pi-subagents` dispatches, immediately capture the returned run ID, poll with `subagent({ action: "status", id: "" })` or watch `/subagents-status` until terminal, then read the final output before evaluating the report or moving to validation. @@ -226,12 +226,12 @@ Use the template at `./spec-reviewer-prompt.md`. Fill placeholders (`{TASK_ID}`, Dispatch preference: - If `subagent` is available and `arc-spec-reviewer` is installed: `subagent({ agent: "arc-spec-reviewer", task: "", model: "openai-codex/gpt-5.3-codex", context: "fresh", async: true, clarify: false })` -- If `subagent` is available but Arc specialists are missing: run `/arc-subagents-sync`, verify with `subagent({ action: "list" })`, then retry. +- If `subagent` is available but Arc specialists are missing: Arc specialists should already be auto-materialized. First run `subagent({ action: "doctor" })` and inspect Arc's materialization warning. Use `/arc-subagents-sync` only as a deprecated repair command, then re-check with `subagent({ action: "list" })`. - Otherwise: `arc_agent(agent="spec-reviewer", task="")` For async `pi-subagents` dispatches, immediately capture the returned run ID, poll with `subagent({ action: "status", id: "" })` or watch `/subagents-status` until terminal, then read the final output before handling compliance results. -Do **not** substitute the generic `worker` or `reviewer` agent for spec compliance gates. Generic `pi-subagents` agents are not Arc specialists, and manually passing an Anthropic model bypasses Arc's Pi-native model tier policy. If Arc `pi-subagents` definitions are unavailable, use the bundled `arc_agent` fallback. +Do **not** substitute the generic `worker` or `reviewer` agent for spec compliance gates. Generic `pi-subagents` agents are not Arc specialists, and manually passing an Anthropic model bypasses Arc's Pi-native model tier policy. If Arc `pi-subagents` definitions are unavailable, use the bundled sequential `arc_agent` fallback. Handle results: - `COMPLIANT` → proceed to Step 6 diff --git a/skills/arc-plan/SKILL.md b/skills/arc-plan/SKILL.md index 2f30570..8ff0bdf 100644 --- a/skills/arc-plan/SKILL.md +++ b/skills/arc-plan/SKILL.md @@ -212,8 +212,8 @@ Dispatch preference (use **async** so long-running issue creation appears in `/s - After launching async, **wait for terminal status** by polling `subagent({ action: "status", id: "" })` until status is `completed` or `failed` - Users can monitor progress via `/subagents-status` during the async run - If `subagent({ action: "list" })` shows `arc-issue-manager`, do **not** use the slower `arc_agent(agent="issue-manager")` fallback for bulk issue creation -- If `subagent` unavailable or `arc-issue-manager` missing: run `/arc-subagents-sync`, then `subagent({ action: "list" })` to verify, then retry primary -- Fallback only if `pi-subagents` is not installed or cannot load after sync: `arc_agent(agent="issue-manager", task="")` +- Arc issue-manager should be auto-materialized; if it is missing, first run `subagent({ action: "doctor" })` and inspect Arc's materialization warning. Use `/arc-subagents-sync` only as a deprecated repair command, then re-check with `subagent({ action: "list" })` +- Fallback only if `pi-subagents` is not installed or cannot load after deprecated repair: `arc_agent(agent="issue-manager", task="")` Use this task payload for whichever dispatcher you choose: diff --git a/skills/arc-review/SKILL.md b/skills/arc-review/SKILL.md index a4f7e9e..04e93c8 100644 --- a/skills/arc-review/SKILL.md +++ b/skills/arc-review/SKILL.md @@ -45,14 +45,14 @@ Extract the design excerpt relevant to this task — typically the sections cove ### 3. Dispatch Reviewer -Fill the template at `./code-reviewer-prompt.md` with the gathered placeholders (`{TASK_ID}`, `{BASE_SHA}`, `{HEAD_SHA}`, `{DESIGN_EXCERPT}`, `{EVALUATOR_STATUS}`). Prefer true `pi-subagents` so longer reviews are visible in `/subagents-status`: +Fill the template at `./code-reviewer-prompt.md` with the gathered placeholders (`{TASK_ID}`, `{BASE_SHA}`, `{HEAD_SHA}`, `{DESIGN_EXCERPT}`, `{EVALUATOR_STATUS}`). Preserve the template's review-only instruction (`Review only; return findings only. Do not edit files.`) and avoid adding wording that asks the reviewer to apply fixes directly. Prefer true `pi-subagents` so longer reviews are visible in `/subagents-status`: Dispatch preference (use **async** so longer reviews appear in `/subagents-status`): - Primary: `subagent({ agent: "arc-code-reviewer", task: "", context: "fresh", async: true, clarify: false })` - After launching async, **wait for terminal status** by polling `subagent({ action: "status", id: "" })` until status is `completed` or `failed` - Users can monitor review progress via `/subagents-status` during the async run -- If `subagent` unavailable or `arc-code-reviewer` missing: run `/arc-subagents-sync`, then `subagent({ action: "list" })` to verify, then retry primary -- Fallback only if `pi-subagents` is not installed: `arc_agent(agent="code-reviewer", task="")` +- Arc code-reviewer should be auto-materialized; if it is missing, first run `subagent({ action: "doctor" })` and inspect Arc's materialization warning. Use `/arc-subagents-sync` only as a deprecated repair command, then re-check with `subagent({ action: "list" })` +- Fallback only if `pi-subagents` is not installed or cannot load after deprecated repair: `arc_agent(agent="code-reviewer", task="")` **Model tier:** Follow the Model Selection table in `../arc-build/SKILL.md`. Model profile: reviews use the `codeReviewer` profile when configured via `/arc-models`; otherwise they fall back to existing tier/frontmatter behavior. Escalate only for large, cross-layer, or security-sensitive diffs. For most reviews, omit `model:` (use the agent's `standard` default). For `pi-subagents`, pass the configured concrete large model only when escalating. diff --git a/skills/arc-review/code-reviewer-prompt.md b/skills/arc-review/code-reviewer-prompt.md index 5aba811..7202898 100644 --- a/skills/arc-review/code-reviewer-prompt.md +++ b/skills/arc-review/code-reviewer-prompt.md @@ -12,6 +12,8 @@ Use this template when dispatching `code-reviewer` for code review. ````text Review these changes against the task spec and project conventions. +Review only; return findings only. Do not edit files. + ## Task Spec @@ -29,8 +31,8 @@ If "none", omit this section. Report findings in three severities: -- **Critical** (must fix): correctness bugs, security issues, scope violations, spec deviations -- **Important** (should fix): quality issues, pattern mismatches, naming problems, test gaps +- **Critical** (blocking): correctness bugs, security issues, scope violations, spec deviations +- **Important** (address before proceeding): quality issues, pattern mismatches, naming problems, test gaps - **Minor** (note for later): style nits, observations, future cleanup candidates If a design spec was provided, also report Plan Adherence: diff --git a/skills/arc/SKILL.md b/skills/arc/SKILL.md index fc5ac29..943eb7b 100644 --- a/skills/arc/SKILL.md +++ b/skills/arc/SKILL.md @@ -66,7 +66,7 @@ brainstorm → plan → implement (per task) → review → finish After `plan`, choose: - **Single-agent + subagents**: Invoke `implement`. Main agent orchestrates, subagents do TDD. Best for sequential tasks. -- **Parallel Arc build**: For independent task batches, `implement` can use worktree-isolated `pi-subagents` runs when that companion package and Arc agent definitions are available. This is not Claude-style team deployment; the orchestrator still owns verification, patch application, issue closure, and handoff. +- **Parallel Arc build**: For independent task batches, `implement` can use worktree-isolated `pi-subagents` runs when an external `pi-subagents` extension/tool is installed and Arc specialist definitions are available. Custom Arc specialists remain the preferred `pi-subagents` targets, and generic `worker`/`reviewer` agents should not be substituted for Arc gates. This is not Claude-style team deployment; the orchestrator still owns verification, patch application, issue closure, and handoff. - **Stacked PRs (arc + git-spice)**: When the epic is 3+ tasks with linear dependencies and each task is independently reviewable, ship as a stack of PRs instead of one. See [`STACKING.md`](../../STACKING.md) for the integration playbook (concept mapping, per-task loop, review iteration). ## Quick Start diff --git a/tests/arc-model-profiles-integration.test.mjs b/tests/arc-model-profiles-integration.test.mjs index e545f62..9b54fdd 100644 --- a/tests/arc-model-profiles-integration.test.mjs +++ b/tests/arc-model-profiles-integration.test.mjs @@ -56,6 +56,8 @@ test('arc extension wires model profiles into commands and agent dispatch', () = 'profileKey', 'modelPattern', 'buildArcSubagentMarkdown', + 'materializeArcSubagentsForContext', + 'saveArcModelsConfigWithMaterialization', ]) { assert.match(source, new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); } @@ -75,7 +77,19 @@ test('arc extension recommended profile defaults use exact allowed models and th const recommendedIds = [...block.matchAll(/modelId:\s*"([^"]+)"/g)].map((match) => match[1]); assert.ok(recommendedIds.length > 0, 'expected recommendation model IDs'); assert.ok(recommendedIds.every((id) => ALLOWED_RECOMMENDED_MODEL_IDS.has(id)), `unexpected IDs: ${recommendedIds.join(', ')}`); - assert.doesNotMatch(block, /gpt-5\.1|gpt-5\.4-nano|claude|haiku|opus|sonnet/i); + assert.doesNotMatch(block, /gpt-5\.1|gpt-5\.4-(?!mini\b)[a-z0-9-]+|claude|haiku|opus|sonnet/i); +}); + +test('model profile saves refresh generated Arc subagents', () => { + const source = read('extensions/arc.ts'); + assert.match(source, /openAndMaybeSaveArcModelProfiles/); + assert.match(source, /async function saveArcModelsConfigWithMaterialization/); + assert.match(source, /saveArcModelsConfigWithMaterialization\(ctx, result\.config, configPath\)/); + assert.match(source, /materializeArcSubagentsForContext\(ctx, "arc_models_save"\)/); + assert.match(source, /notifyArcSubagentMaterialization\(ctx, materialized\)/); + assert.match(source, /action === "recommended"\)[\s\S]*saveArcModelsConfigWithMaterialization\(ctx, config, configPath\)/); + assert.match(source, /action === "customize"\) return openAndMaybeSaveArcModelProfiles\(ctx, config, configPath, preferredProvider, true\)/); + assert.match(source, /registerCommand\("arc-models"[\s\S]*openAndMaybeSaveArcModelProfiles\(ctx, config, configPath, ctx\.model\?\.provider, false\)/); }); test('arc brainstorm setup applies recommended thinking and avoids unrelated fallback models', () => { @@ -93,7 +107,7 @@ test('README modelProfiles example stays within the recommended model set', () = const end = source.indexOf('## Sync Arc specialists', start); assert.notEqual(end, -1, 'missing next README section'); const section = source.slice(start, end); - assert.doesNotMatch(section, /gpt-5\.4-nano|gpt-5\.1|claude|haiku|opus|sonnet/i); + assert.doesNotMatch(section, /gpt-5\.4-(?!mini\b)[a-z0-9-]+|gpt-5\.1|claude|haiku|opus|sonnet/i); assert.match(section, /openai-codex\/gpt-5\.5/); assert.match(section, /openai-codex\/gpt-5\.4-mini/); assert.match(section, /openai-codex\/gpt-5\.3-codex/); diff --git a/tests/arc-model-profiles-ui.test.mjs b/tests/arc-model-profiles-ui.test.mjs index 7417773..25d1a34 100644 --- a/tests/arc-model-profiles-ui.test.mjs +++ b/tests/arc-model-profiles-ui.test.mjs @@ -87,7 +87,7 @@ test('arc model profiles UI uses exact allowed profile recommendations', () => { const recommendedIds = [...block.matchAll(/modelId:\s*"([^"]+)"/g)].map((match) => match[1]); assert.ok(recommendedIds.length > 0, 'expected recommendation model IDs'); assert.ok(recommendedIds.every((id) => ALLOWED_RECOMMENDED_MODEL_IDS.has(id)), `unexpected IDs: ${recommendedIds.join(', ')}`); - assert.doesNotMatch(block, /gpt-5\.1|gpt-5\.4-nano|claude|haiku|opus|sonnet/i); + assert.doesNotMatch(block, /gpt-5\.1|gpt-5\.4-(?!mini\b)[a-z0-9-]+|claude|haiku|opus|sonnet/i); }); test('arc model profiles UI applies recommended thinking and does not fall back to unrelated models', () => { diff --git a/tests/arc-subagents-auto-materialization.test.mjs b/tests/arc-subagents-auto-materialization.test.mjs new file mode 100644 index 0000000..f7d8431 --- /dev/null +++ b/tests/arc-subagents-auto-materialization.test.mjs @@ -0,0 +1,266 @@ +import { readFileSync } from 'node:fs'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +function read(path) { + return readFileSync(path, 'utf8'); +} + +function renderTestAgent(mod, source, target, modelsConfigHash = 'models-hash') { + return mod.buildArcSubagentMarkdown({ + targetName: target, + sourceName: source, + sourceMarkdown: `# ${source}`, + parsedSource: { prompt: `# ${source}` }, + resolvedModel: 'openai-codex/gpt-5.4-mini', + modelProfileKey: 'builder', + modelResolutionSource: 'test', + modelsConfigHash, + generatedAt: '2026-05-03T00:00:00.000Z', + }); +} + +test('Arc subagent materializer exposes stable result contract', () => { + const source = read('extensions/arc/subagents.ts'); + + assert.match(source, /export type ArcSubagentMaterializationReason/); + assert.match(source, /"session_start"/); + assert.match(source, /"arc_models_save"/); + assert.match(source, /"manual_repair"/); + assert.match(source, /export interface ArcSubagentMaterializationResult/); + assert.match(source, /writes: ArcSubagentWriteResult\[\]/); + assert.match(source, /shadows: ArcSubagentShadowWarning\[\]/); +}); + +test('Arc generated subagents record model freshness metadata', () => { + const source = read('extensions/arc/subagents.ts'); + + assert.match(source, /source-sha256/); + assert.match(source, /model-profile-key/); + assert.match(source, /model-resolution-source/); + assert.match(source, /models-config-sha256/); + assert.match(source, /generated-at/); + assert.match(source, /export const ARC_SUBAGENT_GENERATED_MARKER/); + assert.match(source, /export function isGeneratedArcSubagent/); +}); + +test('Arc subagent user target prefers modern user agent directory', () => { + const source = read('extensions/arc/subagents.ts'); + + assert.match(source, /"\.agents"/); + assert.match(source, /"\.pi", "agent", "agents"/); + assert.match(source, /legacyUserDir/); + assert.equal(source.includes('// "\\.agents" "\\.pi", "agent", "agents"'), false); +}); + +test('Arc subagent markdown render preserves frontmatter, metadata, and body order', () => { + const source = read('extensions/arc/subagents.ts'); + + assert.match(source, /import \{ createHash \} from "node:crypto";/); + assert.match(source, /import type \{ ArcModelProfileKey \} from "\.\/model-profiles\.ts";/); + assert.match(source, /export interface ArcSubagentRenderInput/); + assert.match(source, /export interface ArcSubagentParsedSource/); + assert.match(source, /parsedSource:\s*ArcSubagentParsedSource;/); + assert.match(source, /prompt:\s*string;/); + assert.match(source, /export interface ArcSubagentRenderInput\s*\{[^}]*generatedAt:\s*string;[^}]*\}/); + assert.doesNotMatch(source, /export interface ArcSubagentRenderInput\s*\{[^}]*sourceSha256:\s*string;/); + assert.match(source, /createHash\("sha256"\)\.update\(text\)\.digest\("hex"\)/); + assert.match(source, /buildArcSubagentMarkdown\(input: ArcSubagentRenderInput\): string/); + assert.match(source, /sourceSha256:\s*sha256Text\(input\.sourceMarkdown\),/); + assert.match(source, /const frontmatter = \[/); + assert.match(source, /"---"/); + assert.match(source, /`name: \$\{input\.targetName\}`/); + assert.match(source, /systemPromptMode: replace/); + assert.match(source, /inheritProjectContext: true/); + assert.match(source, /inheritSkills: false/); + assert.match(source, /frontmatter\}\\n\$\{metadata\}\\n\\n\$\{body\}/); +}); + +test('Arc subagent markdown render quotes colon-bearing descriptions and keeps expected output sections', () => { + const source = read('extensions/arc/subagents.ts'); + const problematicDescription = 'Use this agent when creating issues. This includes: epics, tasks, labels.'; + + assert.equal(JSON.stringify(problematicDescription), '"Use this agent when creating issues. This includes: epics, tasks, labels."'); + assert.match(source, /function yamlStringValue\(value: string\): string \{\s*return JSON\.stringify\(value\);\s*\}/); + assert.match(source, /input\.parsedSource\.description \? `description: \$\{yamlStringValue\(input\.parsedSource\.description\)\}` : undefined,/); + assert.match(source, /`name: \$\{input\.targetName\}`/); + assert.match(source, /input\.resolvedModel \? `model: \$\{input\.resolvedModel\}` : undefined,/); + assert.match(source, /input\.parsedSource\.tools\?\.length \? `tools: \$\{input\.parsedSource\.tools\.join\(", "\)\}` : undefined,/); + assert.match(source, /frontmatter\}\\n\$\{metadata\}\\n\\n\$\{body\}/); + assert.match(source, /source-sha256/); + assert.match(source, /model-profile-key/); + assert.match(source, /model-resolution-source/); + assert.match(source, /models-config-sha256/); + assert.match(source, /generated-at/); +}); + +test('Arc materializer preserves non-generated files and reports project shadows', async () => { + const mod = await import('../extensions/arc/subagents.ts'); + const root = await mkdtemp(path.join(tmpdir(), 'arc-subagents-')); + try { + const cwd = path.join(root, 'project', 'child'); + const homeDir = path.join(root, 'home'); + const agentsDir = path.join(root, 'agents'); + await mkdir(cwd, { recursive: true }); + await mkdir(homeDir, { recursive: true }); + await mkdir(agentsDir, { recursive: true }); + + const targetDir = mod.resolveArcSubagentDir('user', cwd, homeDir); + await mkdir(targetDir, { recursive: true }); + await writeFile(path.join(targetDir, 'arc-builder.md'), '', 'utf8'); + + const projectAgentsDir = path.join(cwd, '.pi', 'agents'); + const legacyProjectAgentsDir = path.join(cwd, '.agents'); + await mkdir(projectAgentsDir, { recursive: true }); + await mkdir(legacyProjectAgentsDir, { recursive: true }); + await writeFile(path.join(projectAgentsDir, 'arc-builder.md'), renderTestAgent(mod, 'builder', 'arc-builder'), 'utf8'); + await writeFile(path.join(legacyProjectAgentsDir, 'arc-doc-writer.md'), 'custom project shadow', 'utf8'); + + const result = await mod.materializeArcSubagents({ + reason: 'manual_repair', + scope: 'user', + cwd, + homeDir, + agentsDir, + modelsConfigSha256: 'models-hash', + renderAgent: async (source, target) => renderTestAgent(mod, source, target), + }); + + const builder = result.writes.find((entry) => entry.agent === 'arc-builder'); + assert.equal(builder?.status, 'skipped'); + assert.equal(await readFile(path.join(targetDir, 'arc-builder.md'), 'utf8'), ''); + assert.ok(result.shadows.some((shadow) => shadow.projectPath.endsWith(path.join('.pi', 'agents', 'arc-builder.md')))); + assert.ok(result.shadows.some((shadow) => shadow.projectPath.endsWith(path.join('.agents', 'arc-doc-writer.md')))); + assert.ok(result.shadows.every((shadow) => shadow.message.includes('project scope wins over user scope'))); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('Arc materializer falls back to legacy user directory when modern user directory is unavailable', async () => { + const mod = await import('../extensions/arc/subagents.ts'); + const root = await mkdtemp(path.join(tmpdir(), 'arc-subagents-')); + try { + const cwd = path.join(root, 'project'); + const homeDir = path.join(root, 'home'); + const agentsDir = path.join(root, 'agents'); + await mkdir(cwd, { recursive: true }); + await mkdir(homeDir, { recursive: true }); + await mkdir(agentsDir, { recursive: true }); + await writeFile(path.join(homeDir, '.agents'), 'not a directory', 'utf8'); + + const result = await mod.materializeArcSubagents({ + reason: 'session_start', + scope: 'user', + cwd, + homeDir, + agentsDir, + modelsConfigSha256: 'models-hash', + allowLegacyUserDirFallback: true, + renderAgent: async (source, target) => renderTestAgent(mod, source, target), + }); + + const legacyDir = path.join(homeDir, '.pi', 'agent', 'agents'); + assert.equal(result.targetDir, legacyDir); + assert.equal(result.writes.filter((entry) => entry.status === 'written').length, mod.ARC_PI_SUBAGENTS.length); + assert.match(await readFile(path.join(legacyDir, 'arc-builder.md'), 'utf8'), /name: arc-builder/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('Arc materializer reports modern per-file target failures instead of legacy fallback', async () => { + const mod = await import('../extensions/arc/subagents.ts'); + const root = await mkdtemp(path.join(tmpdir(), 'arc-subagents-')); + try { + const cwd = path.join(root, 'project'); + const homeDir = path.join(root, 'home'); + const agentsDir = path.join(root, 'agents'); + await mkdir(cwd, { recursive: true }); + await mkdir(homeDir, { recursive: true }); + await mkdir(agentsDir, { recursive: true }); + + const modernDir = path.join(homeDir, '.agents'); + await mkdir(path.join(modernDir, 'arc-doc-writer.md'), { recursive: true }); + + const result = await mod.materializeArcSubagents({ + reason: 'session_start', + scope: 'user', + cwd, + homeDir, + agentsDir, + modelsConfigSha256: 'models-hash', + allowLegacyUserDirFallback: true, + renderAgent: async (source, target) => renderTestAgent(mod, source, target), + }); + + assert.equal(result.targetDir, modernDir); + const failed = result.writes.find((entry) => entry.agent === 'arc-doc-writer'); + assert.equal(failed?.status, 'failed'); + assert.match(failed?.reason ?? '', /could not read existing target:/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('Arc source agents document optional supervisor escalation without bundling pi-intercom', () => { + for (const file of [ + 'agents/builder.md', + 'agents/code-reviewer.md', + 'agents/doc-writer.md', + 'agents/evaluator.md', + 'agents/issue-manager.md', + 'agents/spec-reviewer.md', + ]) { + const source = read(file); + assert.match(source, /## Supervisor Escalation/); + assert.match(source, /contact_supervisor/); + assert.match(source, /Never invent an intercom target/); + assert.match(source, /Do not send routine completion handoffs|do not send routine completion handoffs/i); + } + const pkg = JSON.parse(read('package.json')); + assert.equal(pkg.dependencies['pi-intercom'], undefined); + assert.ok(!pkg.bundledDependencies.includes('pi-intercom')); +}); + +test('Arc subagent markdown render runtime output matches expected structure', async () => { + const mod = await import('../extensions/arc/subagents.ts'); + const output = mod.buildArcSubagentMarkdown({ + targetName: 'arc-issue-manager', + sourceName: 'issue-manager', + sourceMarkdown: '---\nname: issue-manager\n---\n# Arc Issue Tracker Agent', + parsedSource: { + prompt: '# Arc Issue Tracker Agent', + description: 'Use this agent when creating issues. This includes: epics, tasks, labels.', + tools: ['bash', 'read', 'grep'], + }, + resolvedModel: 'openai-codex/gpt-5.4-mini', + modelProfileKey: 'issueManager', + modelResolutionSource: 'profile', + modelsConfigHash: 'abc123', + generatedAt: '2026-05-03T00:00:00.000Z', + }); + + assert.ok(output.startsWith('---\nname: arc-issue-manager')); + assert.ok(output.includes('description: "Use this agent when creating issues. This includes: epics, tasks, labels."')); + assert.ok(output.includes('model: openai-codex/gpt-5.4-mini')); + assert.ok(output.includes('tools: bash, read, grep')); + assert.ok(output.includes('systemPromptMode: replace')); + assert.ok(output.includes('inheritProjectContext: true')); + assert.ok(output.includes('inheritSkills: false')); + + const frontmatterEnd = output.indexOf('\n---\n'); + const metadataStart = output.indexOf('\n'); + assert.ok(frontmatterEnd >= 0, 'frontmatter should have closing delimiter'); + assert.ok(metadataStart > frontmatterEnd, 'metadata marker must be after frontmatter'); + + assert.ok(output.includes('source-sha256: ')); + assert.ok(output.includes('model-profile-key: issueManager')); + assert.ok(output.includes('model-resolution-source: profile')); + assert.ok(output.includes('models-config-sha256: abc123')); + assert.ok(output.includes('generated-at: 2026-05-03T00:00:00.000Z')); + assert.ok(output.includes('\n\n# Arc Issue Tracker Agent')); +}); diff --git a/tests/arc-subagents-sync.test.mjs b/tests/arc-subagents-sync.test.mjs index d998e98..714ff04 100644 --- a/tests/arc-subagents-sync.test.mjs +++ b/tests/arc-subagents-sync.test.mjs @@ -6,13 +6,22 @@ function read(path) { return readFileSync(path, 'utf8'); } -test('arc extension registers arc-subagents-sync command', () => { +test('arc-subagents-sync is deprecated repair while user-scope materialization is default', () => { const source = read('extensions/arc.ts'); - assert.match(source, /registerCommand\("arc-subagents-sync"/); + const materializer = read('extensions/arc/subagents.ts'); + const start = source.indexOf('registerCommand("arc-subagents-sync"'); + assert.notEqual(start, -1, 'missing arc-subagents-sync command'); + const end = source.indexOf('registerCommand("arc-models"', start); + assert.notEqual(end, -1, 'missing arc-models command after arc-subagents-sync'); + const commandBlock = source.slice(start, end); + assert.match(commandBlock, /arc-subagents-sync/); + assert.match(commandBlock, /deprecated/i); + assert.match(commandBlock, /repair/i); assert.match(source, /ARC_SUBAGENT_GENERATED_MARKER/); assert.match(source, /source-sha256/); - assert.match(source, /\.pi", "agents"/); - assert.match(source, /\.pi", "agent", "agents"/); + assert.match(materializer, /"\.agents"/); + assert.match(materializer, /"\.pi", "agent", "agents"/); + assert.doesNotMatch(commandBlock, /Usage:\\n- `\/arc-subagents-sync` \(project scope\)/); }); test('arc extension sync map includes all Arc specialists', () => { @@ -33,7 +42,7 @@ test('arc extension sync map includes all Arc specialists', () => { test('arc extension model tiers include nano', () => { const source = read('extensions/arc.ts'); assert.match(source, /type ArcModelTier = "nano" \| "small" \| "standard" \| "large"/); - assert.match(source, /nano: "openai-codex\/gpt-5\.4-nano"/); + assert.match(source, /nano: "openai-codex\/gpt-5\.4-mini"/); assert.match(source, /nano for bulk CLI issue creation/); }); @@ -79,11 +88,23 @@ test('arc-review prefers arc-code-reviewer via pi-subagents before arc_agent fal assert.match(source, /arc_agent\(agent="code-reviewer"/); }); -test('README documents arc-subagents-sync and status semantics', () => { +test('arc-code-reviewer dispatch prompt stays review-only for pi-subagents completion guard', () => { + const source = read('skills/arc-review/code-reviewer-prompt.md'); + assert.match(source, /Review only/i); + assert.match(source, /return findings only/i); + assert.match(source, /Do not edit files/i); + assert.doesNotMatch(source, /\bmust\s+(?:edit|modify|change|fix|patch|apply)\b/i); + assert.doesNotMatch(source, /\bapply\s+(?:the\s+)?fix(?:es)?\s+directly\b/i); + assert.doesNotMatch(source, /\bmake\s+(?:the\s+)?code\s+changes\b/i); +}); + +test('README documents auto-materialized specialists and status semantics', () => { const source = read('README.md'); - assert.match(source, /\/arc-subagents-sync/); + assert.match(source, /auto-materialized/i); + assert.match(source, /Users do not need to run `\/arc-subagents-sync`/); + assert.match(source, /\/arc-subagents-sync.*deprecated.*repair\/backcompat/i); + assert.match(source, /project .*shadow/i); assert.match(source, /generic `worker`/i); - assert.match(source, /After syncing, verify agent registration/); assert.match(source, /subagent\(\{ action: "list" \}\)/); assert.match(source, /\/agents/); assert.match(source, /Use `\/subagents-status` to monitor active\/recent async Arc specialist runs/); @@ -91,6 +112,22 @@ test('README documents arc-subagents-sync and status semantics', () => { assert.doesNotMatch(source, /\/subagents-status.*confirm availability/); }); +test('arc docs mention arc-subagents-sync only as deprecated repair/backcompat', () => { + for (const path of [ + 'README.md', + 'skills/arc-build/SKILL.md', + 'skills/arc-plan/SKILL.md', + 'skills/arc-review/SKILL.md', + ]) { + const source = read(path); + const lines = source.split(/\r?\n/).filter((line) => line.includes('/arc-subagents-sync')); + if (path === 'README.md') assert.ok(lines.length > 0, `${path} should mention /arc-subagents-sync`); + for (const line of lines) { + assert.match(line, /deprecated|repair|backcompat|Users do not need/i, `${path}: ${line}`); + } + } +}); + test('migration script preserves arc-subagents-sync wording', () => { const source = read('scripts/migrate-arc-plugin.py'); assert.match(source, /arc-subagents-sync/); diff --git a/tests/parallel-readiness-guidance.test.mjs b/tests/parallel-readiness-guidance.test.mjs index eff6485..d1ee342 100644 --- a/tests/parallel-readiness-guidance.test.mjs +++ b/tests/parallel-readiness-guidance.test.mjs @@ -4,6 +4,7 @@ import assert from 'node:assert/strict'; import { ISSUE_MANAGER_PHASE_TERMS, PARALLEL_READINESS_HEADINGS, + PI_SUBAGENTS_INSTALL_COMMAND, PI_SUBAGENTS_PACKAGE, } from './parallel-throughput-contract.mjs'; @@ -46,11 +47,12 @@ test('arc-build consumes the parallel batch manifest safely', () => { assert.match(source, /one (returned )?patch|one patch/i); }); -test('README documents bundled subagents and execution lanes', () => { +test('README documents optional subagents and execution lanes', () => { const source = read('README.md'); assert.match(source, new RegExp(PI_SUBAGENTS_PACKAGE)); - assert.match(source, /bundledDependencies/); - assert.match(source, /duplicate/i); + assert.match(source, new RegExp(PI_SUBAGENTS_INSTALL_COMMAND.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); + assert.match(source, /does not bundle or load `pi-subagents`/); + assert.match(source, /should not conflict with a global `subagent` tool/); assert.match(source, /Parallel Arc batch/); assert.match(source, /Ant Colony/); assert.doesNotMatch(source, /pi-plan.*replace.*arc-plan/i); diff --git a/tests/parallel-throughput-contract.mjs b/tests/parallel-throughput-contract.mjs index d94a81f..849ba6a 100644 --- a/tests/parallel-throughput-contract.mjs +++ b/tests/parallel-throughput-contract.mjs @@ -1,8 +1,6 @@ export const PI_SUBAGENTS_PACKAGE = 'pi-subagents'; -export const PI_SUBAGENTS_VERSION_RANGE = '^0.23.0'; -export const PI_SUBAGENTS_EXTENSION_PATH = './node_modules/pi-subagents/src/extension/index.ts'; -export const PI_SUBAGENTS_SKILLS_PATH = './node_modules/pi-subagents/skills'; -export const PI_SUBAGENTS_PROMPTS_PATH = './node_modules/pi-subagents/prompts'; +export const PI_SUBAGENTS_TOOL_NAME = 'subagent'; +export const PI_SUBAGENTS_INSTALL_COMMAND = 'pi install npm:pi-subagents'; export const PARALLEL_READINESS_HEADINGS = [ '## Parallel Readiness', diff --git a/tests/parallel-throughput-contract.test.mjs b/tests/parallel-throughput-contract.test.mjs index 5cf803f..3316223 100644 --- a/tests/parallel-throughput-contract.test.mjs +++ b/tests/parallel-throughput-contract.test.mjs @@ -3,19 +3,15 @@ import assert from 'node:assert/strict'; import { ISSUE_MANAGER_PHASE_TERMS, PARALLEL_READINESS_HEADINGS, - PI_SUBAGENTS_EXTENSION_PATH, + PI_SUBAGENTS_INSTALL_COMMAND, PI_SUBAGENTS_PACKAGE, - PI_SUBAGENTS_PROMPTS_PATH, - PI_SUBAGENTS_SKILLS_PATH, - PI_SUBAGENTS_VERSION_RANGE, + PI_SUBAGENTS_TOOL_NAME, } from './parallel-throughput-contract.mjs'; -test('pi-subagents package contract is explicit', () => { +test('pi-subagents optional integration contract is explicit', () => { assert.equal(PI_SUBAGENTS_PACKAGE, 'pi-subagents'); - assert.equal(PI_SUBAGENTS_VERSION_RANGE, '^0.23.0'); - assert.equal(PI_SUBAGENTS_EXTENSION_PATH, './node_modules/pi-subagents/src/extension/index.ts'); - assert.equal(PI_SUBAGENTS_SKILLS_PATH, './node_modules/pi-subagents/skills'); - assert.equal(PI_SUBAGENTS_PROMPTS_PATH, './node_modules/pi-subagents/prompts'); + assert.equal(PI_SUBAGENTS_TOOL_NAME, 'subagent'); + assert.equal(PI_SUBAGENTS_INSTALL_COMMAND, 'pi install npm:pi-subagents'); }); test('parallel readiness headings are stable', () => { diff --git a/tests/pi-subagents-bundle.test.mjs b/tests/pi-subagents-bundle.test.mjs deleted file mode 100644 index c5ae21d..0000000 --- a/tests/pi-subagents-bundle.test.mjs +++ /dev/null @@ -1,33 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { - PI_SUBAGENTS_EXTENSION_PATH, - PI_SUBAGENTS_PACKAGE, - PI_SUBAGENTS_PROMPTS_PATH, - PI_SUBAGENTS_SKILLS_PATH, - PI_SUBAGENTS_VERSION_RANGE, -} from './parallel-throughput-contract.mjs'; - -function read(path) { - return readFileSync(path, 'utf8'); -} - -function readJson(path) { - return JSON.parse(read(path)); -} - -test('package metadata bundles and loads pi-subagents', () => { - const pkg = readJson('package.json'); - const lock = readJson('package-lock.json'); - - assert.equal(pkg.dependencies[PI_SUBAGENTS_PACKAGE], PI_SUBAGENTS_VERSION_RANGE); - assert.ok(pkg.bundledDependencies.includes(PI_SUBAGENTS_PACKAGE)); - assert.ok(pkg.pi.extensions.includes(PI_SUBAGENTS_EXTENSION_PATH)); - assert.ok(pkg.pi.skills.includes(PI_SUBAGENTS_SKILLS_PATH)); - assert.ok(pkg.pi.prompts.includes(PI_SUBAGENTS_PROMPTS_PATH)); - - assert.equal(lock.packages[''].dependencies[PI_SUBAGENTS_PACKAGE], PI_SUBAGENTS_VERSION_RANGE); - assert.ok(lock.packages[''].bundleDependencies.includes(PI_SUBAGENTS_PACKAGE)); - assert.ok(lock.packages[`node_modules/${PI_SUBAGENTS_PACKAGE}`]); -}); diff --git a/tests/pi-subagents-external.test.mjs b/tests/pi-subagents-external.test.mjs new file mode 100644 index 0000000..63cd108 --- /dev/null +++ b/tests/pi-subagents-external.test.mjs @@ -0,0 +1,38 @@ +import { readFileSync } from 'node:fs'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { PI_SUBAGENTS_PACKAGE } from './parallel-throughput-contract.mjs'; + +function read(path) { + return readFileSync(path, 'utf8'); +} + +function readJson(path) { + return JSON.parse(read(path)); +} + +function resourcePaths(pkg) { + return [ + ...(pkg.pi?.extensions ?? []), + ...(pkg.pi?.skills ?? []), + ...(pkg.pi?.prompts ?? []), + ]; +} + +test('package metadata treats pi-subagents as an optional external integration', () => { + const pkg = readJson('package.json'); + const lock = readJson('package-lock.json'); + const rootLock = lock.packages['']; + + assert.equal(pkg.dependencies[PI_SUBAGENTS_PACKAGE], undefined); + assert.equal(pkg.dependencies['pi-intercom'], undefined); + assert.ok(!pkg.bundledDependencies.includes(PI_SUBAGENTS_PACKAGE)); + assert.ok(!pkg.bundledDependencies.includes('pi-intercom')); + assert.ok(resourcePaths(pkg).every((entry) => !entry.includes(`node_modules/${PI_SUBAGENTS_PACKAGE}`))); + + assert.equal(rootLock.dependencies?.[PI_SUBAGENTS_PACKAGE], undefined); + assert.equal(rootLock.dependencies?.['pi-intercom'], undefined); + assert.ok(!rootLock.bundleDependencies.includes(PI_SUBAGENTS_PACKAGE)); + assert.ok(!rootLock.bundleDependencies.includes('pi-intercom')); + assert.equal(lock.packages[`node_modules/${PI_SUBAGENTS_PACKAGE}`], undefined); +});