feat(pi-workflow): reuse standard agent profiles

This commit is contained in:
jb
2026-08-26 22:51:20 -04:00
parent a4784b75c3
commit aa50f0fd16
12 changed files with 606 additions and 54 deletions
@@ -0,0 +1,3 @@
# Reuse standard agents through workflow role overlays
`pi-workflow` will reuse standard `pi-interactive-subagents` definitions instead of duplicating their prompts and model settings in workflow-only agents. Explicit configuration maps standard agent names to workflow roles, while `pi-workflow` retains ownership, tool-intersection, Gate, verification, and reviewer-independence policy. The default maps `scout` to Discovery and `worker` to Implementation; bundled profiles remain fallbacks for roles without a standard mapping. This keeps one source of truth for agent identity without allowing arbitrary prompt prose or agent names to imply mutability or workflow authority.
+20
View File
@@ -35,6 +35,26 @@ The managed flow is:
The assistant must inspect the current run before each state-changing call and obtain the user's explicit Gate Decision. It must not infer approval.
## Agent selection
`pi-workflow` reuses explicitly mapped standard definitions from `pi-interactive-subagents` before falling back to its bundled workflow profiles. By default, `scout` owns Discovery and `worker` owns Implementation; bundled profiles continue to supply Design, Prototype, Verify, and independent Review. Definitions resolve with package, global, then trusted-project precedence and are pinned into the run with their workflow role overlay.
Configure or disable mappings in `~/.pi/agent/pi-workflow.json` or trusted `.pi/pi-workflow.json`:
```json
{
"standardAgents": {
"enabled": true,
"roles": {
"scout": ["discover"],
"worker": ["implement"]
}
}
}
```
A mapping replaces the complete inherited role map at that configuration layer. Agent names must be lowercase kebab case and roles must be workflow roles. Unmapped standard definitions are ignored. Standard definitions provide identity, instructions, model, thinking level, and requested tools, but do not widen permissions: workflow stage allowlists still remove `bash`, recursive subagent tools, and any other unapproved tool.
## Inspect and continue
Use `/workflow` for the drill-down dashboard. TUI mode opens shallow popup overlays for the run list, run actions, Gates, confirmations, questions, and feedback. Plans, reports, prototype evidence, and Assignment transcripts open in a sanitized full-screen reader. Escape returns one level. RPC, print, and JSON modes remain linear and never open custom overlays.
+23 -11
View File
@@ -11,8 +11,8 @@ The extension is based on `extensions/_workflows`, but it is a new extension rat
## Goals
- Load bundled Agent Profiles and trusted project overrides from `.pi/agents/*.md`.
- Select agents automatically using deterministic eligibility followed by planner selection with recorded rationale.
- Load bundled Workflow Agent Profiles plus configured standard subagent definitions from the active `pi-interactive-subagents` package, the global agent directory, and trusted project overrides.
- Adapt standard definitions through explicit workflow role overlays, then select agents automatically using deterministic eligibility followed by planner selection with recorded rationale.
- Pause after discovery for unresolved questions and after design for user approval.
- Require at least three prototype candidates and explicit user acceptance for non-trivial UI or UX work.
- Prevent an implementation agent from reviewing its own work.
@@ -29,7 +29,7 @@ The extension is based on `extensions/_workflows`, but it is a new extension rat
- Supporting arbitrary workflow graphs that can bypass required gates.
- Automatically merging a completed worktree into its source branch.
- Adding a general plugin framework for custom stages, policies, or persistence backends.
- Supporting global Agent Profiles in the first version.
- Inferring workflow roles or mutability from arbitrary standard-agent names or prompt prose.
## Language
@@ -170,10 +170,14 @@ After `start` creates the run lease, every subsequent state-changing parent oper
Profiles are loaded in this order:
1. Bundled profiles from `extensions/pi-workflow/agents/*.md`.
2. Project profiles from the nearest `.pi/agents/*.md`, only when `ctx.isProjectTrusted()` is true.
1. Bundled workflow profiles from `extensions/pi-workflow/agents/*.md`.
2. Configured standard definitions from `${PI_CODING_AGENT_DIR:-~/.pi/agent}/extensions/pi-interactive-subagents/agents/`.
3. Configured standard definitions from `${PI_CODING_AGENT_DIR:-~/.pi/agent}/agents/`.
4. Workflow profiles or configured standard definitions from the nearest `.pi/agents/*.md`, only when `ctx.isProjectTrusted()` is true.
Files are read in lexical order. The first duplicate within one source wins and emits a diagnostic. A project profile replaces a bundled profile with the same normalized name. Provenance and a content digest are retained. Regular files and symlinks are accepted only when their resolved paths remain inside the selected profile root.
Standard definitions become Workflow Agent Profiles only when `standardAgents.roles` explicitly maps their name to one or more workflow roles. Their prompt, description, model, thinking level, and declared tools remain the standard definition's values; workflow policy supplies stages and still intersects tools with the audited stage allowlist. Unmapped standard definitions are ignored rather than assigned a role from their name or prose.
Files are read in lexical order. The first duplicate within one source wins and emits a diagnostic. Later trusted sources replace earlier definitions with the same normalized name. Provenance and a digest over both definition content and role overlay are retained. Regular files and symlinks are accepted only when their resolved paths remain inside the selected profile root. When a configured standard profile is eligible for a role, bundled profiles for that role become explicit fallbacks and are ineligible; bundled profiles continue to supply roles such as Design, Verify, and Review when no standard mapping exists.
Profile contents are pinned into the Workflow Run before selection. Editing a Markdown file does not change an active run.
@@ -263,6 +267,13 @@ Invocation values may change requested concurrency but never the safety ceiling.
"maxAssignmentsPerRun": 128,
"maxReviewCycles": 3,
"analysisTools": ["read", "grep", "find", "ls"],
"standardAgents": {
"enabled": true,
"roles": {
"scout": ["discover"],
"worker": ["implement"]
}
},
"worktrees": {
"root": ".worktrees"
}
@@ -495,7 +506,7 @@ Read-only analysis runs use the parent cwd and do not create worktrees. All chil
Artifacts live under:
```text
~/.pi/agent/pi-workflows/<run-id>/
~/.pi/agent/pi-workflows/runs/<run-id>/
```
Files include:
@@ -543,7 +554,7 @@ The effective model is resolved in this order:
Unknown or ambiguous models fail the Assignment before execution. The effective provider, model, effort, profile digest, child session ID, and context capacity are persisted.
Children cannot access recursive workflow or subagent tools or `ask_user`. `pi_workflow` is added to both the shared child-tool exclusion policy and the separate Pi-subagent child exclusion list alongside the legacy `workflow` name; implementation should consolidate the duplicate list onto the shared policy when possible. Each child tool call retains the shared timeout guard, including tools registered after startup. The first-response watchdog and bounded shutdown behavior from `_workflows` are retained.
Children cannot access recursive workflow or subagent tools or `ask_user`. Standard definitions are execution identities, not permission grants: `worker` may declare `bash` and nested subagents, but mutating workflow Assignments still receive only audited reads plus guarded `edit`/`write`, and read-only Assignments receive only audited reads. `pi_workflow` is added to both the shared child-tool exclusion policy and the separate Pi-subagent child exclusion list alongside the legacy `workflow` name; implementation should consolidate the duplicate list onto the shared policy when possible. Each child tool call retains the shared timeout guard, including tools registered after startup. The first-response watchdog and bounded shutdown behavior from `_workflows` are retained.
## Internal Modules
@@ -581,8 +592,9 @@ Implement the Workflow Run model, configuration resolution, Markdown profile loa
#### Phase 1 Success Criteria
- Trusted project profiles override bundled profiles deterministically, while untrusted project profiles are never read.
- Profile files and symlinks resolving outside the selected bundled or trusted `.pi/agents` root are rejected.
- Configured standard package/global/trusted-project definitions override bundled fallbacks deterministically, while untrusted project profiles are never read.
- Standard definitions require explicit role overlays, preserve their model/prompt/tool identity, and cannot grant tools beyond the workflow stage allowlist.
- Profile files and symlinks resolving outside the selected bundled or trusted agent roots are rejected.
- Malformed profiles produce isolated diagnostics and cannot suppress valid profiles.
- Profile contents, provenance, and effective model settings are pinned by digest.
- Every profile has stage Eligibility Criteria, pre-discovery fallback is deterministic, and irrelevant profiles are excluded with recorded reasons.
@@ -671,7 +683,7 @@ Add the dashboard integration around the Phase 4 `/workflow continue` command, p
## Verification Notes
- Test profile trust, precedence, malformed YAML isolation, and containment of regular files and symlinks.
- Test workflow and standard-profile trust, package/global/project precedence, explicit role overlays, malformed YAML isolation, unmapped-definition omission, and containment of regular files and symlinks.
- Test the exhaustive legal and illegal Stage/Gate transition matrix through the engine interface.
- Test stale revision, duplicate idempotency, lease reconciliation, and notification outbox handling.
- Test process-wide semaphore reservation races, queued cancellation, bounded session shutdown, and interrupted recovery.
+61
View File
@@ -29,6 +29,13 @@ test("configuration defaults to concurrency 8 with immutable ceiling 32", async
});
assert.deepEqual(config, DEFAULT_WORKFLOW_CONFIG);
assert.equal(config.concurrency.ceiling, MAX_CONCURRENCY);
assert.deepEqual(config.standardAgents, {
enabled: true,
roles: {
scout: ["discover"],
worker: ["implement"],
},
});
});
});
@@ -106,6 +113,60 @@ test("untrusted project configuration is never applied", async () => {
});
});
test("trusted configuration can replace or disable standard-agent role overlays", async () => {
await withTempDir(async (directory) => {
const agentDir = path.join(directory, "agent");
await writeJson(path.join(agentDir, "pi-workflow.json"), {
standardAgents: {
roles: {
"codebase-analyzer": ["discover"],
worker: ["implement", "verify"],
},
},
});
let config = await loadWorkflowConfig({
cwd: directory,
agentDir,
projectTrusted: false,
});
assert.deepEqual(config.standardAgents.roles, {
"codebase-analyzer": ["discover"],
worker: ["implement", "verify"],
});
await writeJson(path.join(directory, ".pi", "pi-workflow.json"), {
standardAgents: { enabled: false },
});
config = await loadWorkflowConfig({
cwd: directory,
agentDir,
projectTrusted: true,
});
assert.equal(config.standardAgents.enabled, false);
});
});
test("invalid standard-agent names and roles fail before a run starts", async () => {
await withTempDir(async (directory) => {
const agentDir = path.join(directory, "agent");
await writeJson(path.join(agentDir, "pi-workflow.json"), {
standardAgents: { roles: { Worker: ["implement"] } },
});
await assert.rejects(
loadWorkflowConfig({ cwd: directory, agentDir, projectTrusted: false }),
/lowercase kebab case/,
);
await writeJson(path.join(agentDir, "pi-workflow.json"), {
standardAgents: { roles: { worker: ["remediate"] } },
});
await assert.rejects(
loadWorkflowConfig({ cwd: directory, agentDir, projectTrusted: false }),
/unsupported role remediate/,
);
});
});
test("configuration cannot raise ceilings or activate unaudited tools", async () => {
await withTempDir(async (directory) => {
const agentDir = path.join(directory, "agent");
+65
View File
@@ -7,6 +7,8 @@ import {
MAX_ASSIGNMENTS_PER_RUN,
MAX_CONCURRENCY,
MAX_REVIEW_CYCLES,
PROFILE_ROLES,
type ProfileRole,
type WorkflowConfig,
} from "./domain.ts";
@@ -15,10 +17,13 @@ const TOP_LEVEL_KEYS = new Set([
"maxAssignmentsPerRun",
"maxReviewCycles",
"analysisTools",
"standardAgents",
"worktrees",
]);
const CONCURRENCY_KEYS = new Set(["default", "ceiling"]);
const STANDARD_AGENT_KEYS = new Set(["enabled", "roles"]);
const WORKTREE_KEYS = new Set(["root"]);
const PROFILE_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
export interface LoadWorkflowConfigOptions {
cwd: string;
@@ -97,6 +102,15 @@ function cloneConfig(config: WorkflowConfig): WorkflowConfig {
maxAssignmentsPerRun: config.maxAssignmentsPerRun,
maxReviewCycles: config.maxReviewCycles,
analysisTools: [...config.analysisTools],
standardAgents: {
enabled: config.standardAgents.enabled,
roles: Object.fromEntries(
Object.entries(config.standardAgents.roles).map(([name, roles]) => [
name,
[...roles],
]),
),
},
worktrees: { ...config.worktrees },
};
}
@@ -192,6 +206,57 @@ function applyConfigLayer(
);
}
if (value.standardAgents !== undefined) {
if (!isRecord(value.standardAgents)) {
throw new Error(`${label}.standardAgents must be an object`);
}
assertKnownKeys(
value.standardAgents,
STANDARD_AGENT_KEYS,
`${label}.standardAgents`,
);
if (value.standardAgents.enabled !== undefined) {
if (typeof value.standardAgents.enabled !== "boolean") {
throw new Error(`${label}.standardAgents.enabled must be a boolean`);
}
next.standardAgents.enabled = value.standardAgents.enabled;
}
if (value.standardAgents.roles !== undefined) {
if (!isRecord(value.standardAgents.roles)) {
throw new Error(`${label}.standardAgents.roles must be an object`);
}
const roles: Record<string, ProfileRole[]> = {};
for (const [name, rawRoles] of Object.entries(
value.standardAgents.roles,
)) {
if (!PROFILE_NAME.test(name)) {
throw new Error(
`${label}.standardAgents.roles agent names must be lowercase kebab case`,
);
}
const requested = stringArray(
rawRoles,
`${label}.standardAgents.roles.${name}`,
);
if (requested.length === 0) {
throw new Error(
`${label}.standardAgents.roles.${name} must not be empty`,
);
}
const invalid = requested.filter(
(role) => !PROFILE_ROLES.includes(role as ProfileRole),
);
if (invalid.length > 0) {
throw new Error(
`${label}.standardAgents.roles.${name} contains unsupported role ${invalid.join(", ")}`,
);
}
roles[name] = requested as ProfileRole[];
}
next.standardAgents.roles = roles;
}
}
if (value.worktrees !== undefined) {
if (!isRecord(value.worktrees)) {
throw new Error(`${label}.worktrees must be an object`);
+19 -1
View File
@@ -46,7 +46,12 @@ export interface AgentProfile {
tools?: string[];
selection: ProfileSelection;
instructions: string;
source: "bundled" | "project";
source:
| "bundled"
| "project"
| "standard-package"
| "standard-global"
| "standard-project";
sourcePath: string;
resolvedPath: string;
digest: string;
@@ -446,6 +451,11 @@ export const MAX_CONCURRENCY = 32;
export const MAX_ASSIGNMENTS_PER_RUN = 1_024;
export const MAX_REVIEW_CYCLES = 20;
export interface StandardAgentConfig {
enabled: boolean;
roles: Record<string, ProfileRole[]>;
}
export interface WorkflowConfig {
concurrency: {
default: number;
@@ -454,6 +464,7 @@ export interface WorkflowConfig {
maxAssignmentsPerRun: number;
maxReviewCycles: number;
analysisTools: string[];
standardAgents: StandardAgentConfig;
worktrees: {
root: string;
};
@@ -467,6 +478,13 @@ export const DEFAULT_WORKFLOW_CONFIG: WorkflowConfig = {
maxAssignmentsPerRun: 128,
maxReviewCycles: 3,
analysisTools: [...ANALYSIS_TOOL_ALLOWLIST],
standardAgents: {
enabled: true,
roles: {
scout: ["discover"],
worker: ["implement"],
},
},
worktrees: {
root: ".worktrees",
},
+7 -1
View File
@@ -415,6 +415,12 @@ test("mutating children receive guarded file tools without process execution", a
).profiles;
const profile = profiles.find((candidate) => candidate.roles.includes("implement"));
assert.ok(profile);
const standardWorker = {
...profile,
name: "worker",
source: "standard-package" as const,
tools: [...(profile.tools ?? []), "bash", "subagent"],
};
const inherited = model("provider-a", "parent");
const executor = new PiAssignmentExecutor({
modelRegistry: registry([inherited]),
@@ -476,7 +482,7 @@ test("mutating children receive guarded file tools without process execution", a
runId: "run-mutation",
role: "design",
request: "Generate an owned file",
profile,
profile: standardWorker,
facts: {
stage: "design",
taskKind: "feature",
+1
View File
@@ -61,6 +61,7 @@ export function createWorkflowRuntime(
const catalog = await loadAgentProfiles({
cwd: ctx.cwd,
projectTrusted: ctx.isProjectTrusted(),
standardAgents: config.standardAgents,
});
const executor = new PiAssignmentExecutor({
modelRegistry: ctx.modelRegistry,
+224 -19
View File
@@ -30,6 +30,17 @@ function profileDocument(options: {
return `---\nname: ${options.name}\ndescription: ${options.description ?? options.name}\nroles: ${options.roles ?? "[discover]"}\n${options.extra ?? ""}selection:\n${options.selection ?? " stages: [discover]\n anyTags: []\n allTags: []\n pathGlobs: []\n keywords: []\n"}---\n\n${options.body ?? "Follow the assignment."}\n`;
}
function standardAgentDocument(options: {
name: string;
description?: string;
model?: string;
tools?: string;
thinking?: string;
body?: string;
}) {
return `---\nname: ${options.name}\ndescription: ${options.description ?? options.name}\n${options.model ? `model: ${options.model}\n` : ""}${options.tools ? `tools: ${options.tools}\n` : ""}${options.thinking ? `thinking: ${options.thinking}\n` : ""}subagent_agents: scout, researcher\nsystem-prompt: append\nauto-exit: true\n---\n\n${options.body ?? "Follow the standard assignment."}\n`;
}
async function writeProfile(
root: string,
file: string,
@@ -39,26 +50,197 @@ async function writeProfile(
await writeFile(path.join(root, file), profileDocument(options));
}
async function writeStandardAgent(
root: string,
file: string,
options: Parameters<typeof standardAgentDocument>[0],
) {
await mkdir(root, { recursive: true });
await writeFile(path.join(root, file), standardAgentDocument(options));
}
test("bundled profiles form a complete deterministic catalog", async () => {
const catalog = await loadAgentProfiles({
cwd: process.cwd(),
projectTrusted: false,
await withTempDir(async (directory) => {
const catalog = await loadAgentProfiles({
cwd: directory,
agentDir: path.join(directory, "agent"),
projectTrusted: false,
});
assert.deepEqual(
catalog.profiles.map((profile) => profile.name),
[
"feature-designer",
"feature-implementer",
"feature-verifier",
"general-discovery",
"independent-reviewer",
"ui-prototyper",
],
);
assert.equal(catalog.diagnostics.length, 0);
assert.ok(
catalog.profiles.every((profile) => /^[a-f0-9]{64}$/.test(profile.digest)),
);
});
});
test("standard package, global, and trusted project agents use role overlays and precedence", async () => {
await withTempDir(async (directory) => {
const bundled = path.join(directory, "bundled");
const packageRoot = path.join(directory, "package-agents");
const agentDir = path.join(directory, "agent");
const globalRoot = path.join(agentDir, "agents");
const projectRoot = path.join(directory, ".pi", "agents");
await writeProfile(bundled, "general.md", {
name: "general-discovery",
});
await writeStandardAgent(packageRoot, "scout.md", {
name: "scout",
model: "openai-codex/gpt-test",
tools: "read, grep, find, ls",
thinking: "high",
body: "Package scout.",
});
await writeStandardAgent(packageRoot, "worker.md", {
name: "worker",
tools: "read, write, edit, bash",
body: "Package worker.",
});
await writeStandardAgent(globalRoot, "scout.md", {
name: "scout",
body: "Global scout.",
});
await writeStandardAgent(globalRoot, "unmapped.md", {
name: "unmapped",
});
await writeStandardAgent(projectRoot, "worker.md", {
name: "worker",
body: "Project worker.",
});
const catalog = await loadAgentProfiles({
cwd: directory,
agentDir,
bundledRoot: bundled,
standardPackageRoot: packageRoot,
projectRoot,
projectTrusted: true,
standardAgents: {
enabled: true,
roles: { scout: ["discover"], worker: ["implement"] },
},
});
const scout = catalog.profiles.find((profile) => profile.name === "scout");
assert.equal(scout?.source, "standard-global");
assert.equal(scout?.instructions, "Global scout.");
assert.deepEqual(scout?.roles, ["discover"]);
assert.deepEqual(scout?.selection.stages, ["discover"]);
const worker = catalog.profiles.find((profile) => profile.name === "worker");
assert.equal(worker?.source, "standard-project");
assert.equal(worker?.instructions, "Project worker.");
assert.deepEqual(worker?.roles, ["implement"]);
assert.equal(catalog.profiles.some((profile) => profile.name === "unmapped"), false);
});
});
test("package and global standard definitions cannot self-declare workflow roles", async () => {
await withTempDir(async (directory) => {
const bundled = path.join(directory, "bundled");
const packageRoot = path.join(directory, "package-agents");
await writeProfile(bundled, "general.md", { name: "general-discovery" });
await writeProfile(packageRoot, "worker.md", {
name: "worker",
roles: "[review]",
selection:
" stages: [review]\n anyTags: []\n allTags: []\n pathGlobs: []\n keywords: []\n",
body: "Attempted self-declared reviewer.",
});
await writeProfile(packageRoot, "unmapped.md", {
name: "unmapped",
roles: "[review]",
selection:
" stages: [review]\n anyTags: []\n allTags: []\n pathGlobs: []\n keywords: []\n",
});
const catalog = await loadAgentProfiles({
cwd: directory,
agentDir: path.join(directory, "agent"),
bundledRoot: bundled,
standardPackageRoot: packageRoot,
projectTrusted: false,
standardAgents: {
enabled: true,
roles: { worker: ["implement"] },
},
});
const worker = catalog.profiles.find((profile) => profile.name === "worker");
assert.equal(worker?.source, "standard-package");
assert.deepEqual(worker?.roles, ["implement"]);
assert.deepEqual(worker?.selection.stages, ["implement"]);
assert.equal(catalog.profiles.some((profile) => profile.name === "unmapped"), false);
});
});
test("a trusted workflow profile keeps its declared roles when its name also has a standard overlay", async () => {
await withTempDir(async (directory) => {
const bundled = path.join(directory, "bundled");
const packageRoot = path.join(directory, "package-agents");
const projectRoot = path.join(directory, ".pi", "agents");
await writeProfile(bundled, "general.md", { name: "general-discovery" });
await writeStandardAgent(packageRoot, "scout.md", { name: "scout" });
await writeProfile(projectRoot, "scout.md", {
name: "scout",
roles: "[verify]",
selection:
" stages: [verify]\n anyTags: []\n allTags: []\n pathGlobs: []\n keywords: []\n",
body: "Project verifier.",
});
const catalog = await loadAgentProfiles({
cwd: directory,
agentDir: path.join(directory, "agent"),
bundledRoot: bundled,
standardPackageRoot: packageRoot,
projectRoot,
projectTrusted: true,
standardAgents: {
enabled: true,
roles: { scout: ["discover"] },
},
});
const scout = catalog.profiles.find((profile) => profile.name === "scout");
assert.equal(scout?.source, "project");
assert.deepEqual(scout?.roles, ["verify"]);
assert.deepEqual(scout?.selection.stages, ["verify"]);
assert.equal(scout?.instructions, "Project verifier.");
});
});
test("standard agents are disabled explicitly and ignored for untrusted projects", async () => {
await withTempDir(async (directory) => {
const bundled = path.join(directory, "bundled");
const packageRoot = path.join(directory, "package-agents");
const projectRoot = path.join(directory, ".pi", "agents");
await writeProfile(bundled, "general.md", { name: "general-discovery" });
await writeStandardAgent(packageRoot, "scout.md", { name: "scout" });
await writeStandardAgent(projectRoot, "worker.md", { name: "worker" });
const disabled = await loadAgentProfiles({
cwd: directory,
agentDir: path.join(directory, "agent"),
bundledRoot: bundled,
standardPackageRoot: packageRoot,
projectRoot,
projectTrusted: true,
standardAgents: {
enabled: false,
roles: { scout: ["discover"], worker: ["implement"] },
},
});
assert.deepEqual(disabled.profiles.map((profile) => profile.name), ["general-discovery"]);
});
assert.deepEqual(
catalog.profiles.map((profile) => profile.name),
[
"feature-designer",
"feature-implementer",
"feature-verifier",
"general-discovery",
"independent-reviewer",
"ui-prototyper",
],
);
assert.equal(catalog.diagnostics.length, 0);
assert.ok(
catalog.profiles.every((profile) => /^[a-f0-9]{64}$/.test(profile.digest)),
);
});
test("trusted project profiles override bundled profiles while first duplicate wins", async () => {
@@ -340,6 +522,7 @@ test("active profile contents are pinned by digest", async () => {
function agent(
name: string,
selection: AgentProfile["selection"],
source: AgentProfile["source"] = "bundled",
): AgentProfile {
return {
name,
@@ -347,7 +530,7 @@ function agent(
roles: ["discover"],
selection,
instructions: "Discover.",
source: "bundled",
source,
sourcePath: name,
resolvedPath: name,
digest: name.padEnd(64, "0"),
@@ -414,6 +597,28 @@ test("eligibility records exclusion reasons and selects the most specific discov
);
});
test("an eligible standard agent replaces the bundled fallback for its role", () => {
const emptySelection: AgentProfile["selection"] = {
stages: ["discover"],
anyTags: [],
allTags: [],
pathGlobs: [],
keywords: [],
};
const bundled = agent("general-discovery", emptySelection);
const scout = agent("scout", emptySelection, "standard-package");
const result = selectInitialDiscoveryProfile(
[bundled, scout],
baseFacts,
);
assert.equal(result.selected.name, "scout");
assert.deepEqual(
result.evaluations.find((item) => item.profile.name === "general-discovery")?.reasons,
["eligible standard agent available for role discover"],
);
});
test("eligibility enforces every criterion and uses a code-point name tie-breaker", () => {
const emptySelection: AgentProfile["selection"] = {
stages: ["discover"],
+169 -18
View File
@@ -1,6 +1,7 @@
import { createHash } from "node:crypto";
import { lstat, readdir, readFile, realpath, stat } from "node:fs/promises";
import * as path from "node:path";
import { getAgentDir } from "@earendil-works/pi-coding-agent";
import {
PROFILE_ROLES,
REASONING_EFFORTS,
@@ -11,6 +12,7 @@ import {
type ProfileDiagnostic,
type ProfileRole,
type ProfileSelection,
type StandardAgentConfig,
type WorkflowFacts,
} from "./domain.ts";
@@ -44,11 +46,16 @@ interface LoadSourceResult {
diagnostics: ProfileDiagnostic[];
}
type ProfileSourceMode = "workflow" | "standard" | "project";
export interface LoadAgentProfilesOptions {
cwd: string;
projectTrusted: boolean;
agentDir?: string;
bundledRoot?: string;
standardPackageRoot?: string;
projectRoot?: string;
standardAgents?: StandardAgentConfig;
}
function reason(error: unknown) {
@@ -219,7 +226,7 @@ function parseFrontmatter(
throw new Error(`frontmatter line ${lineIndex + 1}: expected key: value`);
const key = text.slice(0, separator).trim();
const rawValue = text.slice(separator + 1).trim();
if (!/^[a-zA-Z][a-zA-Z0-9]*$/.test(key)) {
if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(key)) {
throw new Error(
`frontmatter line ${lineIndex + 1}: invalid key ${JSON.stringify(key)}`,
);
@@ -341,6 +348,61 @@ function parseSelection(value: unknown) {
return { selection, warnings };
}
function buildStandardProfile(
parsed: ParsedFrontmatter,
contents: string,
sourcePath: string,
resolvedPath: string,
source: AgentProfile["source"],
fallbackName: string,
roles: ProfileRole[],
) {
const name = optionalString(parsed.values.name, "name") ?? fallbackName;
if (!PROFILE_NAME.test(name)) {
throw new Error("name must be lowercase kebab case");
}
const description =
optionalString(parsed.values.description, "description") ?? name;
const model = optionalString(parsed.values.model, "model");
const effort = optionalString(parsed.values.thinking, "thinking");
if (effort && !REASONING_EFFORTS.includes(effort as never)) {
throw new Error(`thinking must be one of ${REASONING_EFFORTS.join(", ")}`);
}
const tools =
parsed.values.tools === undefined
? undefined
: stringList(parsed.values.tools, "tools", true);
if (tools?.some((tool) => !TOOL_NAME.test(tool))) {
throw new Error("tools contains an invalid tool name");
}
if (parsed.body.length === 0) {
throw new Error("agent instructions must not be empty");
}
const selection: ProfileSelection = {
stages: [...roles],
anyTags: [],
allTags: [],
pathGlobs: [],
keywords: [],
};
const digestInput = `${contents}\nworkflow-role-overlay:${JSON.stringify(roles)}`;
const profile: AgentProfile = {
name,
description,
roles: [...roles],
...(model ? { model } : {}),
...(effort ? { effort: effort as AgentProfile["effort"] } : {}),
...(tools ? { tools } : {}),
selection,
instructions: parsed.body,
source,
sourcePath,
resolvedPath,
digest: createHash("sha256").update(digestInput).digest("hex"),
};
return { profile, warnings: [] as string[] };
}
function buildProfile(
contents: string,
sourcePath: string,
@@ -415,6 +477,8 @@ async function loadSource(
root: string,
source: AgentProfile["source"],
required: boolean,
mode: ProfileSourceMode = "workflow",
standardRoles?: Record<string, ProfileRole[]>,
): Promise<LoadSourceResult> {
const diagnostics: ProfileDiagnostic[] = [];
let resolvedRoot: string;
@@ -466,12 +530,30 @@ async function loadSource(
throw new Error("profile is not a regular file");
}
const contents = await readFile(resolvedPath, "utf8");
const { profile, warnings } = buildProfile(
contents,
sourcePath,
resolvedPath,
source,
);
const parsed = parseFrontmatter(contents, sourcePath);
const declaredName =
optionalString(parsed.values.name, "name") ??
entry.name.replace(/\.md$/, "");
const isStandardDefinition =
mode === "standard" ||
(mode === "project" && parsed.values.roles === undefined);
let built;
if (isStandardDefinition) {
const roles = standardRoles?.[declaredName];
if (!roles) continue;
built = buildStandardProfile(
parsed,
contents,
sourcePath,
resolvedPath,
source === "project" ? "standard-project" : source,
entry.name.replace(/\.md$/, ""),
roles,
);
} else {
built = buildProfile(contents, sourcePath, resolvedPath, source);
}
const { profile, warnings } = built;
for (const message of warnings) {
diagnostics.push({ sourcePath, severity: "warning", message });
}
@@ -535,23 +617,73 @@ export async function loadAgentProfiles(
const bundledRoot =
options.bundledRoot ?? path.join(import.meta.dirname, "agents");
const bundled = await loadSource(bundledRoot, "bundled", true);
const standardAgents = options.standardAgents ?? {
enabled: false,
roles: {},
} satisfies StandardAgentConfig;
const agentDir = options.agentDir ?? getAgentDir();
const standardPackageRoot =
options.standardPackageRoot ??
path.join(
agentDir,
"extensions",
"pi-interactive-subagents",
"agents",
);
const standardPackage = standardAgents.enabled
? await loadSource(
standardPackageRoot,
"standard-package",
false,
"standard",
standardAgents.roles,
)
: { profiles: [], diagnostics: [] };
const standardGlobal = standardAgents.enabled
? await loadSource(
path.join(agentDir, "agents"),
"standard-global",
false,
"standard",
standardAgents.roles,
)
: { profiles: [], diagnostics: [] };
const projectRoot = options.projectTrusted
? (options.projectRoot ?? (await findNearestProjectRoot(options.cwd)))
: undefined;
if (projectRoot) await assertProjectProfileAncestry(projectRoot);
const project = projectRoot
? await loadSource(projectRoot, "project", false)
? await loadSource(
projectRoot,
"project",
false,
"project",
standardAgents.enabled ? standardAgents.roles : undefined,
)
: { profiles: [], diagnostics: [] };
const byName = new Map(
bundled.profiles.map((profile) => [profile.name, profile]),
);
for (const profile of project.profiles) byName.set(profile.name, profile);
for (const profile of standardPackage.profiles) {
byName.set(profile.name, profile);
}
for (const profile of standardGlobal.profiles) {
byName.set(profile.name, profile);
}
for (const profile of project.profiles) {
byName.set(profile.name, profile);
}
return {
profiles: [...byName.values()].sort((left, right) =>
compareText(left.name, right.name),
),
diagnostics: [...bundled.diagnostics, ...project.diagnostics],
diagnostics: [
...bundled.diagnostics,
...standardPackage.diagnostics,
...standardGlobal.diagnostics,
...project.diagnostics,
],
};
}
@@ -637,14 +769,33 @@ export function eligibleProfiles(
facts: WorkflowFacts,
role: ProfileRole,
) {
return profiles
.map((profile) => evaluateProfileEligibility(profile, facts, role))
.sort(
(left, right) =>
Number(right.eligible) - Number(left.eligible) ||
right.specificity - left.specificity ||
compareText(left.profile.name, right.profile.name),
);
const evaluations = profiles.map((profile) =>
evaluateProfileEligibility(profile, facts, role),
);
const hasEligibleStandard = evaluations.some(
(evaluation) =>
evaluation.eligible && evaluation.profile.source.startsWith("standard-"),
);
const ranked = hasEligibleStandard
? evaluations.map((evaluation) =>
evaluation.eligible && evaluation.profile.source === "bundled"
? {
...evaluation,
eligible: false,
reasons: [
...evaluation.reasons,
`eligible standard agent available for role ${role}`,
],
}
: evaluation,
)
: evaluations;
return ranked.sort(
(left, right) =>
Number(right.eligible) - Number(left.eligible) ||
right.specificity - left.specificity ||
compareText(left.profile.name, right.profile.name),
);
}
export function selectInitialDiscoveryProfile(
+6
View File
@@ -161,6 +161,8 @@ test("supported version-zero snapshots migrate missing lifecycle collections", a
delete record.affectedUiSurfaces;
delete record.reviewCycles;
delete record.verificationCycles;
delete (legacy.effectiveConfig as Partial<typeof legacy.effectiveConfig>)
.standardAgents;
const runDirectory = path.join(
directory,
"state",
@@ -181,6 +183,10 @@ test("supported version-zero snapshots migrate missing lifecycle collections", a
assert.deepEqual(loaded.prototypeCandidates, []);
assert.deepEqual(loaded.commandReceipts, []);
assert.equal(loaded.prototypeCleanupCompleted, false);
assert.deepEqual(
loaded.effectiveConfig.standardAgents,
DEFAULT_WORKFLOW_CONFIG.standardAgents,
);
});
});
+8 -4
View File
@@ -12,10 +12,11 @@ import {
stat,
} from "node:fs/promises";
import * as path from "node:path";
import type {
PlanArtifact,
WorkflowReportArtifact,
WorkflowRun,
import {
DEFAULT_WORKFLOW_CONFIG,
type PlanArtifact,
type WorkflowReportArtifact,
type WorkflowRun,
} from "./domain.ts";
import { deriveRunLabel } from "./run-label.ts";
@@ -299,6 +300,9 @@ export class WorkflowStore {
run.affectedUiSurfaces ??= [];
run.reviewCycles ??= 0;
run.verificationCycles ??= 0;
run.effectiveConfig.standardAgents ??= structuredClone(
DEFAULT_WORKFLOW_CONFIG.standardAgents,
);
for (const assignment of run.assignments) {
if (assignment.gitBefore) assignment.gitBefore.pathStatuses ??= {};
if (assignment.gitAfter) assignment.gitAfter.pathStatuses ??= {};