@fradser/pi-agent-teams 0.9.2 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/agents.ts DELETED
@@ -1,296 +0,0 @@
1
- /**
2
- * Declarative agent definitions: Markdown files with YAML-style frontmatter.
3
- *
4
- * Discovery scopes (later scopes override earlier ones for the same name):
5
- * 1. user — ~/.pi/agent/agents/ (respects PI_CODING_AGENT_DIR)
6
- * 2. project — <cwd>/.pi/agents/<name>.md
7
- * 3. project-local — <cwd>/.pi/agents/<name>.local.md (personal override)
8
- *
9
- * There are no built-in roles: references/agent-roles.md ships template
10
- * shapes that the leader consults when generating a new definition on demand.
11
- *
12
- * Frontmatter fields:
13
- * name — unique agent id (required)
14
- * description — routing contract: when the leader should choose this agent
15
- * tools — comma-separated list or YAML list of Pi tool ids
16
- * model — optional provider/model pin, or "inherit" for the leader's
17
- * current model at spawn time
18
- * verify — optional role-default completion gate: a review prompt a
19
- * fresh one-shot reviewer answers with VERDICT: PASS/FAIL
20
- * worktree — optional role-default Git worktree isolation (true/false)
21
- * The Markdown body is the worker's role prompt.
22
- */
23
-
24
- import * as fs from "node:fs";
25
- import * as path from "node:path";
26
- import { fileURLToPath } from "node:url";
27
- import { getAgentDir } from "@earendil-works/pi-coding-agent";
28
-
29
- export type AgentScope = "user" | "project" | "project-local" | "session";
30
-
31
- export interface AgentDefinition {
32
- name: string;
33
- description: string;
34
- tools: string[];
35
- model?: string;
36
- /** Role-default completion gate; task-level verify overrides this. */
37
- verify?: string;
38
- /** Whether this role always receives a dedicated Git worktree. */
39
- worktree: boolean;
40
- /** The Markdown body, trimmed; used as the teammate's role prompt. */
41
- prompt: string;
42
- /** Scope that supplied this definition (precedence: project-local > project > user > bundled). */
43
- scope: AgentScope;
44
- /** True only for the shared project scope that is expected to be git-managed.
45
- * user and project-local definitions are machine/personal by definition. */
46
- gitManaged: boolean;
47
- /** Absolute path of the definition file; absent for session-only roles. */
48
- source?: string;
49
- }
50
-
51
- export interface AgentDefinitionInput {
52
- name: string;
53
- description: string;
54
- tools: string[];
55
- model?: string;
56
- verify?: string;
57
- worktree?: boolean;
58
- prompt: string;
59
- }
60
-
61
- /** Reserved model value meaning "use the leader session's current model",
62
- * resolved at spawn time (Claude-Code-style inherit). */
63
- export const MODEL_INHERIT_ALIAS = "inherit";
64
-
65
- const sessionAgents = new Map<string, AgentDefinition>();
66
- const AGENT_NAME_PATTERN = /^[a-z][a-z0-9._-]{0,63}$/i;
67
-
68
- function normalizeAgentInput(input: AgentDefinitionInput): AgentDefinitionInput {
69
- const name = input.name.trim();
70
- if (!AGENT_NAME_PATTERN.test(name)) {
71
- throw new Error(`Invalid agent name "${name}". Use letters, digits, dots, dashes, underscores.`);
72
- }
73
- const prompt = input.prompt.trim();
74
- if (!prompt) throw new Error(`Agent "${name}" prompt must not be empty.`);
75
- return {
76
- ...input,
77
- name,
78
- description: input.description.trim(),
79
- tools: input.tools.map((tool) => tool.trim()).filter(Boolean),
80
- model: input.model?.trim() || undefined,
81
- verify: input.verify?.trim() || undefined,
82
- worktree: input.worktree ?? false,
83
- prompt,
84
- };
85
- }
86
-
87
- /** Register a generated role for this session without writing a definition file. */
88
- export function registerSessionAgent(input: AgentDefinitionInput): AgentDefinition {
89
- const normalized = normalizeAgentInput(input);
90
- const definition: AgentDefinition = {
91
- ...normalized,
92
- worktree: normalized.worktree ?? false,
93
- scope: "session",
94
- gitManaged: false,
95
- source: undefined,
96
- };
97
- sessionAgents.set(definition.name, definition);
98
- return definition;
99
- }
100
-
101
- /** Persist a generated role only when the user explicitly requests it. */
102
- export function persistAgentDefinition(
103
- input: AgentDefinitionInput,
104
- scope: "project" | "project-local",
105
- cwd = process.cwd(),
106
- ): AgentDefinition {
107
- const normalized = normalizeAgentInput(input);
108
- const directory = agentsDir("project", cwd);
109
- fs.mkdirSync(directory, { recursive: true });
110
- const filename = `${normalized.name}${scope === "project-local" ? LOCAL_DEFINITION_SUFFIX : ".md"}`;
111
- const source = path.join(directory, filename);
112
- const frontmatter = [
113
- "---",
114
- `name: ${normalized.name}`,
115
- `description: ${normalized.description.replace(/[\\r\\n]+/g, " ")}`,
116
- `tools: ${normalized.tools.join(", ")}`,
117
- ...(normalized.model ? [`model: ${normalized.model}`] : []),
118
- ...(normalized.verify ? [`verify: ${normalized.verify.replace(/[\\r\\n]+/g, " ")}`] : []),
119
- `worktree: ${normalized.worktree ? "true" : "false"}`,
120
- "---",
121
- normalized.prompt,
122
- "",
123
- ].join("\n");
124
- fs.writeFileSync(source, frontmatter, { encoding: "utf8", mode: 0o600 });
125
- sessionAgents.delete(normalized.name);
126
- return {
127
- ...normalized,
128
- worktree: normalized.worktree ?? false,
129
- scope,
130
- gitManaged: scope === "project",
131
- source,
132
- };
133
- }
134
-
135
- /** Drop generated session roles; persistent user/project definitions remain discoverable. */
136
- export function clearSessionAgents(): void {
137
- sessionAgents.clear();
138
- }
139
-
140
- /** Shipped role templates: reference material for generating new definitions
141
- * on demand — never discovered and never spawnable directly. */
142
- export const AGENT_REFERENCE_PATH = path.resolve(
143
- path.dirname(fileURLToPath(import.meta.url)), "..", "references", "agent-roles.md",
144
- );
145
-
146
- /** Files named `xxx.local.md` in the project agents directory are personal
147
- * overrides: same teammate name as `xxx.md`, project-local scope, never git-
148
- * managed by convention. */
149
- export const LOCAL_DEFINITION_SUFFIX = ".local.md";
150
-
151
- function agentsDir(scope: "user" | "project", cwd?: string): string {
152
- if (scope === "user") {
153
- return path.join(getAgentDir(), "agents");
154
- }
155
- return path.join(cwd || process.cwd(), ".pi", "agents");
156
- }
157
-
158
- function parseFrontmatter(raw: string): { fields: Record<string, string | string[]>; body: string } {
159
- const fields: Record<string, string | string[]> = {};
160
- const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(raw);
161
- if (!match) return { fields, body: raw.trim() };
162
- const lines = match[1].split("\n");
163
- for (let index = 0; index < lines.length; index++) {
164
- const line = lines[index];
165
- const separator = line.indexOf(":");
166
- if (separator <= 0) continue;
167
- const key = line.slice(0, separator).trim();
168
- // Strip inline YAML comments (# ...) and surrounding whitespace/quotes.
169
- let value = line.slice(separator + 1).replace(/\s+#.*$/, "").trim();
170
- if (key === "tools") {
171
- if (value === "") {
172
- // YAML block sequence: "- item" lines, flush-left or indented,
173
- // possibly interleaved with blank or comment-only lines.
174
- const items: string[] = [];
175
- index += 1;
176
- while (index < lines.length) {
177
- const trimmed = lines[index].trim();
178
- if (trimmed === "" || trimmed.startsWith("#")) {
179
- index += 1;
180
- continue;
181
- }
182
- if (!/^-.+/.test(trimmed)) break;
183
- const rawItem = trimmed.replace(/^-\s*/, "");
184
- const item = rawItem
185
- .replace(/\s+#.*$/, "")
186
- .trim()
187
- .replace(/^["']|["']$/g, "");
188
- // "- # comment" is a comment-only entry, not a tool.
189
- if (item && !rawItem.trimStart().startsWith("#")) items.push(item);
190
- index += 1;
191
- }
192
- index -= 1; // The for-loop steps past the last consumed line.
193
- fields.tools = items;
194
- continue;
195
- }
196
- const tools = value
197
- .replace(/^\[|\]$/g, "")
198
- .split(",")
199
- .map((tool) => tool.trim().replace(/^["']|["']$/g, ""))
200
- .filter(Boolean);
201
- fields.tools = tools;
202
- continue;
203
- }
204
- fields[key] = value.replace(/^["']|["']$/g, "");
205
- }
206
- return { fields, body: match[2].trim() };
207
- }
208
-
209
- function loadDir(baseScope: AgentScope, dir: string, agents: Map<string, AgentDefinition>): void {
210
- let entries: fs.Dirent[];
211
- try {
212
- entries = fs.readdirSync(dir, { withFileTypes: true });
213
- } catch {
214
- return; // A missing agents directory is not an error.
215
- }
216
- // Dedup by name within the directory: plain .md first so a same-name
217
- // xxx.local.md (personal override) overwrites it in the map.
218
- const ordered = [...entries].sort((left, right) =>
219
- Number(left.name.endsWith(LOCAL_DEFINITION_SUFFIX)) - Number(right.name.endsWith(LOCAL_DEFINITION_SUFFIX))
220
- || left.name.localeCompare(right.name),
221
- );
222
- for (const entry of ordered) {
223
- if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
224
- const source = path.join(dir, entry.name);
225
- let raw: string;
226
- try {
227
- raw = fs.readFileSync(source, "utf8");
228
- } catch {
229
- continue;
230
- }
231
- const { fields, body } = parseFrontmatter(raw);
232
- const isLocal = baseScope === "project" && entry.name.endsWith(LOCAL_DEFINITION_SUFFIX);
233
- const baseName = isLocal
234
- ? entry.name.slice(0, -LOCAL_DEFINITION_SUFFIX.length)
235
- : entry.name.replace(/\.md$/, "");
236
- const name = typeof fields.name === "string" ? fields.name : baseName;
237
- if (!name || !body) continue;
238
- agents.set(name, {
239
- name,
240
- description: typeof fields.description === "string" ? fields.description : "",
241
- tools: Array.isArray(fields.tools) ? fields.tools : [],
242
- model: typeof fields.model === "string" && fields.model ? fields.model : undefined,
243
- verify: typeof fields.verify === "string" && fields.verify ? fields.verify : undefined,
244
- worktree: fields.worktree === "true",
245
- prompt: body,
246
- scope: isLocal ? "project-local" : baseScope,
247
- gitManaged: scopeIsGitManaged(isLocal ? "project-local" : baseScope),
248
- source,
249
- });
250
- }
251
- }
252
-
253
- /** Only the shared project layer is expected to be git-managed. */
254
- function scopeIsGitManaged(scope: AgentScope): boolean {
255
- return scope === "project";
256
- }
257
-
258
- /**
259
- * Resolve all agent definitions visible from the given cwd, with later scopes
260
- * overriding earlier ones per name: project-local > project > user.
261
- */
262
- export function discoverAgents(cwd?: string): Map<string, AgentDefinition> {
263
- const agents = new Map<string, AgentDefinition>();
264
- loadDir("user", agentsDir("user", cwd), agents);
265
- loadDir("project", agentsDir("project", cwd), agents);
266
- // Generated roles fill gaps only: filesystem scopes keep precedence over
267
- // session memory.
268
- for (const [name, definition] of sessionAgents) if (!agents.has(name)) agents.set(name, definition);
269
- return agents;
270
- }
271
-
272
- /** Resolve one agent by name, or undefined when no scope defines it. */
273
- export function resolveAgent(name: string, cwd?: string): AgentDefinition | undefined {
274
- return discoverAgents(cwd).get(name);
275
- }
276
-
277
- /**
278
- * Format discovered agents as Markdown for prompt injection in before_agent_start.
279
- */
280
- export function formatAgentGuidance(cwd?: string): string {
281
- const agents = discoverAgents(cwd);
282
- if (agents.size === 0) return "(none defined; pass an inline definition when delegating)";
283
- const lines: string[] = [];
284
- for (const agent of agents.values()) {
285
- const extras = [
286
- agent.model ? `Model: ${agent.model}` : "",
287
- agent.verify ? `Verify: ${agent.verify}` : "",
288
- agent.worktree ? "Worktree: true" : "",
289
- agent.scope === "session" ? "in-memory" : agent.gitManaged ? "git-managed" : "local",
290
- ].filter(Boolean);
291
- lines.push(`- **${agent.name}** (${agent.scope})`);
292
- if (agent.description) lines.push(` ${agent.description}`);
293
- lines.push(` Tools: ${agent.tools.join(", ") || "(role defaults)"}${extras.length > 0 ? ` | ${extras.join(" | ")}` : ""}`);
294
- }
295
- return lines.join("\n");
296
- }
@@ -1,92 +0,0 @@
1
- export const TEAMMATE_REPORT_MESSAGE_TYPE = "agent-teams-report";
2
- export const TEAMMATE_HARNESS_MESSAGE_TYPE = "agent-teams-harness";
3
-
4
- export interface LeaderReport {
5
- teammate?: string;
6
- agent?: string;
7
- spawnId?: string;
8
- assignmentId?: string;
9
- workId?: string;
10
- body: string;
11
- /** Worker-authored reports are the default; harness events use a separate envelope. */
12
- origin?: "teammate" | "harness";
13
- harnessEvent?: {
14
- type: string;
15
- subject: string;
16
- };
17
- finished?: boolean;
18
- /** Original append-only outbox event identifier, retained for session forensics. */
19
- eventId?: string;
20
- /** Original worker status, including omitted status, retained for session forensics. */
21
- status?: "in_progress" | "completed" | "failed" | "inform" | "request" | "handoff";
22
- runId?: string;
23
- /** Wall-clock time the message was authored, not when Pi consumed it. */
24
- timestamp?: number;
25
- deliveredAt?: number;
26
- processStoppedAt?: number;
27
- deliveredAfterStop?: boolean;
28
- }
29
-
30
- export interface LeaderReportGroup {
31
- teammate: string;
32
- reports: LeaderReport[];
33
- }
34
-
35
- export function groupReportsByTeammate(reports: LeaderReport[]): LeaderReportGroup[] {
36
- const groups = new Map<string, LeaderReportGroup>();
37
- for (const report of reports) {
38
- const teammate = report.teammate ?? report.agent ?? "teammate";
39
- if (report.origin === "harness" || report.harnessEvent) {
40
- groups.set(`harness:${groups.size}:${teammate}`, { teammate, reports: [report] });
41
- continue;
42
- }
43
- const key = `message:${teammate}`;
44
- const group = groups.get(key);
45
- if (group) {
46
- group.reports.push(report);
47
- } else {
48
- groups.set(key, { teammate, reports: [report] });
49
- }
50
- }
51
- return [...groups.values()];
52
- }
53
-
54
- export function annotateReportDelivery(report: LeaderReport, deliveredAt: number, processStoppedAt?: number): LeaderReport {
55
- if (processStoppedAt === undefined || deliveredAt < processStoppedAt) return { ...report, deliveredAt };
56
- return { ...report, deliveredAt, processStoppedAt, deliveredAfterStop: true };
57
- }
58
-
59
- function timestampAttribute(name: string, timestamp: number | undefined): string {
60
- return timestamp !== undefined && Number.isFinite(timestamp)
61
- ? ` ${name}="${new Date(timestamp).toISOString()}"`
62
- : "";
63
- }
64
-
65
- export function formatReports(reports: LeaderReport[]): string {
66
- return reports
67
- .map((report) => {
68
- const { teammate, body, timestamp, origin, harnessEvent } = report;
69
- const at = timestampAttribute("at", timestamp);
70
- if (origin === "harness" || harnessEvent) {
71
- const type = escapeAttribute(harnessEvent?.type ?? "event");
72
- const subject = escapeAttribute(harnessEvent?.subject ?? "Agent Teams event");
73
- return `<harness-event type="${type}" subject="${subject}"${at}>\n${body}\n</harness-event>`;
74
- }
75
- const name = teammate ?? "teammate";
76
- const delivery = timestampAttribute("delivered-at", report.deliveredAt);
77
- const stopped = timestampAttribute("process-stopped-at", report.processStoppedAt);
78
- const late = report.deliveredAfterStop ? ' delivery="after-stop"' : "";
79
- const work = report.workId ? ` work="${escapeAttribute(report.workId)}"` : "";
80
- const assignment = report.assignmentId ? ` assignment="${escapeAttribute(report.assignmentId)}"` : "";
81
- const status = report.status ? ` status="${escapeAttribute(report.status)}"` : "";
82
- const note = report.deliveredAfterStop
83
- ? "Delivery note: this report was queued before shutdown and delivered after the process stopped. It does not reopen the assignment.\n\n"
84
- : "";
85
- return `<agent-message from="${escapeAttribute(name)}"${at}${delivery}${stopped}${late}${work}${assignment}${status}>\n${note}${body}\n</agent-message>`;
86
- })
87
- .join("\n\n");
88
- }
89
-
90
- function escapeAttribute(value: string): string {
91
- return value.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
92
- }