@hcflabs/pi-subagent 0.0.0-stage → 27.10.8

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.
Files changed (66) hide show
  1. package/CONTEXT.md +77 -0
  2. package/LICENSE +22 -0
  3. package/README.md +282 -2
  4. package/dist/composition.d.ts +11 -0
  5. package/dist/composition.js +104 -0
  6. package/dist/direct-herdr.d.ts +71 -0
  7. package/dist/direct-herdr.js +527 -0
  8. package/dist/discard.d.ts +51 -0
  9. package/dist/discard.js +218 -0
  10. package/dist/display-text.d.ts +5 -0
  11. package/dist/display-text.js +9 -0
  12. package/dist/ephemeral.d.ts +79 -0
  13. package/dist/ephemeral.js +916 -0
  14. package/dist/git-process.d.ts +8 -0
  15. package/dist/git-process.js +25 -0
  16. package/dist/git-runtime.d.ts +115 -0
  17. package/dist/git-runtime.js +489 -0
  18. package/dist/herdr-runtime.d.ts +147 -0
  19. package/dist/herdr-runtime.js +1366 -0
  20. package/dist/index.d.ts +170 -0
  21. package/dist/index.js +589 -0
  22. package/dist/integration-git.d.ts +63 -0
  23. package/dist/integration-git.js +504 -0
  24. package/dist/launch-runtime.d.ts +54 -0
  25. package/dist/launch-runtime.js +178 -0
  26. package/dist/mcp-role.d.ts +12 -0
  27. package/dist/mcp-role.js +152 -0
  28. package/dist/process-lease.d.ts +5 -0
  29. package/dist/process-lease.js +43 -0
  30. package/dist/process.d.ts +9 -0
  31. package/dist/process.js +11 -0
  32. package/dist/prune.d.ts +78 -0
  33. package/dist/prune.js +205 -0
  34. package/dist/review-evidence.d.ts +17 -0
  35. package/dist/review-evidence.js +221 -0
  36. package/dist/runner.d.ts +431 -0
  37. package/dist/runner.js +3380 -0
  38. package/dist/schema.d.ts +2560 -0
  39. package/dist/schema.js +1365 -0
  40. package/dist/store.d.ts +66 -0
  41. package/dist/store.js +394 -0
  42. package/dist/worktree.d.ts +65 -0
  43. package/dist/worktree.js +288 -0
  44. package/docs/orchestration-architecture.html +138 -0
  45. package/docs/orchestration-architecture.svg +135 -0
  46. package/docs/orchestration.md +577 -0
  47. package/docs/worker-lifecycle.html +97 -0
  48. package/docs/worker-lifecycle.svg +86 -0
  49. package/example.png +0 -0
  50. package/examples/roles/implementer.md +25 -0
  51. package/examples/roles/reviewer.md +25 -0
  52. package/examples/roles/scout.md +25 -0
  53. package/extensions/admission.ts +82 -0
  54. package/extensions/config.ts +19 -0
  55. package/extensions/git-read.ts +126 -0
  56. package/extensions/isolated.ts +945 -0
  57. package/extensions/model-class-policy.ts +1 -0
  58. package/extensions/result-transport.ts +236 -0
  59. package/extensions/role-mcp.ts +14 -0
  60. package/extensions/role-tools.ts +178 -0
  61. package/extensions/subagent-command.ts +699 -0
  62. package/extensions/subagent.ts +958 -0
  63. package/extensions/workflow.ts +336 -0
  64. package/package.json +84 -4
  65. package/prompts/review.md +20 -0
  66. package/skills/pi-subagent/SKILL.md +41 -0
package/dist/index.js ADDED
@@ -0,0 +1,589 @@
1
+ import { readFileSync, readdirSync } from "node:fs";
2
+ import { basename, isAbsolute, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { DefaultPackageManager, getAgentDir, parseFrontmatter, SettingsManager, } from "@earendil-works/pi-coding-agent";
5
+ import { createConfigStore, extensionConfigDir, field, formatConfigIssues, parsers, registerConfigReporting } from "@hcflabs/pi-config-store";
6
+ import { hasDisplayControlCharacters } from "./display-text.js";
7
+ import { DEFAULT_MAX_TURNS } from "./ephemeral.js";
8
+ import { loadRoleMcpConfig, parseRoleMcpAllowlist } from "./mcp-role.js";
9
+ import { loadTaskModelsConfig, modelReference, orderedProfileRoutes, PROFILE_NAMES, resolveConfiguredTaskRoute, resolveTaskModelRoute, } from "@hcflabs/pi-task-models";
10
+ export { DISPLAY_TEXT_CONTRACT, hasDisplayControlCharacters } from "./display-text.js";
11
+ export { loadRoleMcpConfig, parseRoleMcpAllowlist } from "./mcp-role.js";
12
+ export { addUsage, capEphemeralSubagentOutput, createEphemeralSubagentExecutor, DEFAULT_MAX_TURNS, EphemeralSubagentError, EXECUTION_BUDGET_ENV, formatDuration, } from "./ephemeral.js";
13
+ export { createChildWorktree, finalizeChildWorktree, inspectIndexFlags, inspectWorktreeDirty, WorktreeSetupError, } from "./worktree.js";
14
+ export { prepareExactReviewEvidence, REVIEW_MAX_PATCH_BYTES, REVIEW_MAX_PATHS, } from "./review-evidence.js";
15
+ const CODEX_ALIAS = /^openai-codex-(?:[2-9]|[1-9]\d+)$/;
16
+ const MULTI_CODEX_EXTENSION = fileURLToPath(import.meta.resolve("@hcflabs/pi-multi-codex/extensions/multi-codex.ts"));
17
+ const ROLE_MCP_EXTENSION = fileURLToPath(new URL("../extensions/role-mcp.ts", import.meta.url));
18
+ const ROLE_TOOLS_EXTENSION = fileURLToPath(new URL("../extensions/role-tools.ts", import.meta.url));
19
+ const GIT_READ_EXTENSION = fileURLToPath(new URL("../extensions/git-read.ts", import.meta.url));
20
+ export { PI_SUBAGENT_PROCESS_LEASE } from "./process-lease.js";
21
+ export const ROLE_MCP_POLICY_FLAG = "pi-subagent-role-mcps";
22
+ export const ROLE_TOOL_POLICY_FLAG = "pi-subagent-role-tools";
23
+ const CODEMODE_TOOL = "codemode";
24
+ /** The value after a flag that must appear exactly once in a child's argv. */
25
+ function flagValueFromArgv(args, flag) {
26
+ const indexes = args.flatMap((arg, index) => arg === flag ? [index] : []);
27
+ if (indexes.length !== 1)
28
+ throw new Error(`${flag} must appear exactly once.`);
29
+ return args[indexes[0] + 1];
30
+ }
31
+ /** Read child MCP policy before Pi binds registered extension flag values. */
32
+ export function roleMcpAllowlistFromArgv(args) {
33
+ return parseRoleMcpAllowlist(flagValueFromArgv(args, `--${ROLE_MCP_POLICY_FLAG}`));
34
+ }
35
+ /** Parse the Role tool policy flag value: JSON tool names. */
36
+ export function parseRoleToolPolicy(value) {
37
+ if (typeof value !== "string")
38
+ throw new Error(`${ROLE_TOOL_POLICY_FLAG} must be JSON tool names.`);
39
+ let parsed;
40
+ try {
41
+ parsed = JSON.parse(value);
42
+ }
43
+ catch {
44
+ throw new Error(`${ROLE_TOOL_POLICY_FLAG} must be JSON tool names.`);
45
+ }
46
+ if (!Array.isArray(parsed) || parsed.some((name) => typeof name !== "string" || !name.trim() || name.includes("\0"))) {
47
+ throw new Error(`${ROLE_TOOL_POLICY_FLAG} must be JSON tool names.`);
48
+ }
49
+ return [...new Set(parsed.map((name) => name.trim()))];
50
+ }
51
+ /** Read child tool policy before Pi binds registered extension flag values. */
52
+ export function roleToolPolicyFromArgv(args) {
53
+ return parseRoleToolPolicy(flagValueFromArgv(args, `--${ROLE_TOOL_POLICY_FLAG}`));
54
+ }
55
+ /**
56
+ * Whether a tool policy activates `codemode` in the child, so scripts can reach tools with
57
+ * `codemode` or `deferred` exposure. Loading `builtin:codemode` alone registers the tool inactive.
58
+ */
59
+ export function roleActivatesCodemode(tools) {
60
+ return tools.includes(CODEMODE_TOOL);
61
+ }
62
+ export const CHILD_EXCLUDED_TOOL_NAMES = [
63
+ "delegate_task",
64
+ "ask_question",
65
+ "subagent_status",
66
+ "subagent_resume",
67
+ "subagent_stage",
68
+ "subagent_integrate",
69
+ "subagent_abort",
70
+ "herdr_handoff",
71
+ ];
72
+ const SYSTEM_PROMPT_FLAG = "--append-system-prompt";
73
+ const CHILD_IDENTITY_POLICY = "You are a delegated Pi Subagent, not Main. Execute the assigned Role and task directly. Main-only delegation rules do not apply. Recursive delegation is unavailable; do not seek or invoke delegation tools.";
74
+ export const DELEGATE_TASK = {
75
+ id: "pi-subagent/delegateTask",
76
+ label: "Subagent delegation",
77
+ purpose: "Launch direct or checked isolated Pi delegation.",
78
+ defaultProfile: "fast",
79
+ };
80
+ const cleanText = (value, field, source) => {
81
+ if (typeof value !== "string" || !value.trim() || value.includes("\0")) {
82
+ throw new Error(`${source}: ${field} must be non-empty text.`);
83
+ }
84
+ return value.trim();
85
+ };
86
+ const cleanDisplayText = (value, field, source) => {
87
+ if (typeof value === "string" && hasDisplayControlCharacters(value)) {
88
+ throw new Error(`${source}: ${field} must not contain C0/C1 control characters.`);
89
+ }
90
+ return cleanText(value, field, source);
91
+ };
92
+ /** Normalize one arbitrary Role name using the Role configuration contract. */
93
+ export function parseRoleName(value, source = "Role") {
94
+ return cleanDisplayText(value, "name", source);
95
+ }
96
+ const stringList = (value, field, source) => {
97
+ if (value === undefined)
98
+ throw new Error(`${source}: ${field} is required.`);
99
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || !item.trim() || item.includes("\0"))) {
100
+ throw new Error(`${source}: ${field} must be an array of strings.`);
101
+ }
102
+ return value.map((item) => item.trim());
103
+ };
104
+ // Pi built-in extensions (`builtin:<name>`, see Pi's settings docs); Pi exports no list of them.
105
+ // `builtin:mcp` is excluded: it connects every server in mcp.json and would bypass the Role `mcps` allowlist.
106
+ const BUILTIN_EXTENSION_NAMES = ["codemode", "tool-search", "llama.cpp"];
107
+ const BUILTIN_EXTENSION_PREFIX = "builtin:";
108
+ function validateExtension(extension, source) {
109
+ const value = cleanText(extension, "extension", source);
110
+ if (value.startsWith(BUILTIN_EXTENSION_PREFIX)) {
111
+ if (value === `${BUILTIN_EXTENSION_PREFIX}mcp`) {
112
+ throw new Error(`${source} must select MCP servers with mcps instead of loading builtin:mcp directly.`);
113
+ }
114
+ if (!BUILTIN_EXTENSION_NAMES.includes(value.slice(BUILTIN_EXTENSION_PREFIX.length))) {
115
+ throw new Error(`${source}: unknown built-in extension ${value}; use ${BUILTIN_EXTENSION_NAMES.map((name) => `${BUILTIN_EXTENSION_PREFIX}${name}`).join(", ")}.`);
116
+ }
117
+ return value;
118
+ }
119
+ const packageSource = /^(?:npm|git|github|https?|ssh):/.test(value);
120
+ const userPath = isAbsolute(value) || value.startsWith("~/") || value.startsWith("~\\") || value.startsWith("file://");
121
+ if (!packageSource && !userPath) {
122
+ throw new Error(`${source}: extensions entries must be absolute paths, package sources, or builtin:<name>.`);
123
+ }
124
+ return value;
125
+ }
126
+ function extensionList(value, source) {
127
+ return stringList(value, "extensions", source).map((extension) => validateExtension(extension, source));
128
+ }
129
+ function mcpList(value, source) {
130
+ const names = stringList(value ?? [], "mcps", source);
131
+ if (new Set(names).size !== names.length)
132
+ throw new Error(`${source}: mcps contains duplicate MCP server names.`);
133
+ return names;
134
+ }
135
+ function roleToolPolicy(role, additionalTools = []) {
136
+ return [...new Set([...role.tools, ...additionalTools].map((tool) => cleanText(tool, "tool", `Role ${role.name}`)))];
137
+ }
138
+ function namesMcpAdapter(extension) {
139
+ return extension.toLowerCase().split(/[\\/:@]+/).some((component) => component === "pi-mcp-adapter" || component.startsWith("pi-mcp-adapter."));
140
+ }
141
+ const FORBIDDEN_ROLE_PACKAGE_SOURCE_NAMES = ["pi-subagent", "pi-mcp-adapter"];
142
+ function rejectForbiddenRolePackageSource(value, role) {
143
+ const components = value.toLowerCase().split(/[\\/:@]+/);
144
+ if (FORBIDDEN_ROLE_PACKAGE_SOURCE_NAMES.some((name) => components.some((component) => component === name || component.startsWith(`${name}.`)))) {
145
+ throw new Error(`Role ${role.name} extension explicitly names the forbidden ${FORBIDDEN_ROLE_PACKAGE_SOURCE_NAMES.join("/")} source: ${value}`);
146
+ }
147
+ }
148
+ function roleModelClass(value, source) {
149
+ if (value === undefined)
150
+ return;
151
+ if (typeof value !== "string" || !PROFILE_NAMES.includes(value)) {
152
+ throw new Error(`${source}: modelClass must be one of ${PROFILE_NAMES.join(", ")}.`);
153
+ }
154
+ return value;
155
+ }
156
+ function rejectRetiredRoleIsolation(value, source) {
157
+ if (value === undefined)
158
+ return;
159
+ throw new Error(`${source}: Role isolation is retired; select mode \"isolated\" on delegate_task instead.`);
160
+ }
161
+ const ROLE_FRONTMATTER_KEYS = ["name", "description", "modelClass", "tools", "extensions", "skills", "mcps"];
162
+ const ROLE_DEFAULT_KEYS = ["extensions", "tools", "skills"];
163
+ /** Validate `config.json` `roleDefaults` with the same rules as Role fields; `label` prefixes errors. */
164
+ export function parseRoleDefaults(value, label = "roleDefaults") {
165
+ if (!value || typeof value !== "object" || Array.isArray(value))
166
+ throw new Error(`${label} must be a JSON object.`);
167
+ const record = value;
168
+ for (const key of Object.keys(record)) {
169
+ if (!ROLE_DEFAULT_KEYS.includes(key))
170
+ throw new Error(`${label}: unknown key ${JSON.stringify(key)}; expected ${ROLE_DEFAULT_KEYS.join(", ")}.`);
171
+ }
172
+ return {
173
+ ...(record.extensions === undefined ? {} : { extensions: extensionList(record.extensions, label) }),
174
+ ...(record.tools === undefined ? {} : { tools: stringList(record.tools, "tools", label) }),
175
+ ...(record.skills === undefined ? {} : { skills: stringList(record.skills, "skills", label) }),
176
+ };
177
+ }
178
+ /** Per-key `roleDefaults`: an unknown key or invalid list is reported and dropped, so fewer resources load. */
179
+ const roleDefaultsField = (raw, report) => {
180
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
181
+ throw new Error("must be a JSON object");
182
+ const defaults = {};
183
+ for (const [key, value] of Object.entries(raw)) {
184
+ if (!ROLE_DEFAULT_KEYS.includes(key)) {
185
+ report([key], "unknown key", "unknown-key");
186
+ continue;
187
+ }
188
+ try {
189
+ Object.assign(defaults, parseRoleDefaults({ [key]: value }));
190
+ }
191
+ catch (error) {
192
+ report([key], error instanceof Error ? error.message : String(error));
193
+ }
194
+ }
195
+ return defaults;
196
+ };
197
+ export const DEFAULT_SUBAGENT_CONFIG = Object.freeze({
198
+ maxSubagents: 5,
199
+ maxTurns: DEFAULT_MAX_TURNS,
200
+ maxTokens: undefined,
201
+ maxCorrections: 1,
202
+ timeout: Object.freeze({ idleMinutes: 10, maxMinutes: 30 }),
203
+ roleDefaults: Object.freeze({}),
204
+ });
205
+ const timeoutMinutes = parsers.number({ greaterThan: 0, max: 2_147_483_647 / 60_000 });
206
+ const timeoutField = (raw, report) => {
207
+ const timeout = parsers.object({
208
+ idleMinutes: field(timeoutMinutes, DEFAULT_SUBAGENT_CONFIG.timeout.idleMinutes),
209
+ maxMinutes: field(timeoutMinutes, DEFAULT_SUBAGENT_CONFIG.timeout.maxMinutes),
210
+ })(raw, report);
211
+ if (timeout.maxMinutes <= timeout.idleMinutes) {
212
+ throw new Error(`maxMinutes (${timeout.maxMinutes}) must be greater than idleMinutes (${timeout.idleMinutes})`);
213
+ }
214
+ return timeout;
215
+ };
216
+ /**
217
+ * The user `config.json` store shared by the extension and `loadRoleCatalog`.
218
+ * Invalid bounded limits and `roleDefaults` keys fall back to defaults; an
219
+ * invalid `maxTokens` disables the config because its default is unlimited.
220
+ */
221
+ export function createSubagentConfigStore(agentDir = getAgentDir()) {
222
+ return createConfigStore({
223
+ extensionId: "pi-subagent",
224
+ agentDir,
225
+ fields: {
226
+ maxSubagents: field(parsers.integer({ min: 1 }), DEFAULT_SUBAGENT_CONFIG.maxSubagents),
227
+ maxTurns: field(parsers.integer({ min: 1 }), DEFAULT_SUBAGENT_CONFIG.maxTurns),
228
+ maxTokens: field(parsers.integer({ min: 1 }), undefined, { onInvalid: "disable" }),
229
+ maxCorrections: field(parsers.integer({ min: 0 }), DEFAULT_SUBAGENT_CONFIG.maxCorrections),
230
+ timeout: field(timeoutField, DEFAULT_SUBAGENT_CONFIG.timeout),
231
+ roleDefaults: field(roleDefaultsField, DEFAULT_SUBAGENT_CONFIG.roleDefaults),
232
+ },
233
+ });
234
+ }
235
+ /**
236
+ * Read only `roleDefaults`. The extension reports and enforces the execution limits, so a bad limit does not
237
+ * break the Role catalog of other consumers; other keys are not this reader's issues.
238
+ */
239
+ function loadRoleDefaults(agentDir) {
240
+ const store = createConfigStore({
241
+ extensionId: "pi-subagent",
242
+ agentDir,
243
+ fields: { roleDefaults: field(roleDefaultsField, DEFAULT_SUBAGENT_CONFIG.roleDefaults) },
244
+ });
245
+ const loaded = store.loadSync();
246
+ const issues = loaded.issues.filter(({ key }) => !key || /^roleDefaults(?![\w$])/.test(key));
247
+ // A file that cannot load has no trusted `roleDefaults`.
248
+ return issues.some(({ key }) => !key)
249
+ ? { store, loaded: { status: "disabled", source: loaded.source, issues } }
250
+ : { store, loaded: { ...loaded, issues } };
251
+ }
252
+ function readRoleDefaults(agentDir) {
253
+ const { store, loaded } = loadRoleDefaults(agentDir);
254
+ if (loaded.status !== "ok")
255
+ throw new Error(`${store.path}: ${formatConfigIssues(loaded.issues)}`);
256
+ return loaded.value.roleDefaults;
257
+ }
258
+ /**
259
+ * Report `roleDefaults` issues at each session start, and return a function that reports them after a lazy
260
+ * Role catalog load, such as `loadRoleCatalog` or `resolveRoleLaunch`. Every pi-config-store reporter shares
261
+ * one record, so each issue shows one time in each session, also with the pi-subagent extension active.
262
+ */
263
+ export function registerRoleDefaultsReporting(pi, agentDir = getAgentDir()) {
264
+ const target = loadRoleDefaults(agentDir).store;
265
+ const reporter = registerConfigReporting(pi, []);
266
+ const report = (ctx) => reporter.report(ctx, target, loadRoleDefaults(agentDir).loaded);
267
+ pi.on("session_start", (_event, ctx) => report(ctx));
268
+ return report;
269
+ }
270
+ const withDefaults = (defaults, values) => [...new Set([...(defaults ?? []), ...values])];
271
+ function applyRoleDefaults(role, defaults) {
272
+ return {
273
+ ...role,
274
+ tools: withDefaults(defaults.tools, role.tools),
275
+ extensions: withDefaults(defaults.extensions, role.extensions),
276
+ skills: withDefaults(defaults.skills, role.skills),
277
+ };
278
+ }
279
+ // Built-in Roles resolved from the package-shipped Markdown relative to this module.
280
+ const BUILTIN_ROLE_NAMES = ["implementer", "reviewer", "scout"];
281
+ /** Single-file Role parser shared by built-in and user roles. */
282
+ function parseRoleFile(file, raw) {
283
+ let parsed;
284
+ try {
285
+ parsed = parseFrontmatter(raw);
286
+ }
287
+ catch (error) {
288
+ throw new Error(`${file}: ${error instanceof Error ? error.message : String(error)}`);
289
+ }
290
+ const frontmatter = parsed.frontmatter;
291
+ rejectRetiredRoleIsolation(frontmatter.isolation, file);
292
+ for (const key of Object.keys(frontmatter)) {
293
+ if (!ROLE_FRONTMATTER_KEYS.includes(key)) {
294
+ throw new Error(`${file}: unknown Role frontmatter key ${JSON.stringify(key)}; expected ${ROLE_FRONTMATTER_KEYS.join(", ")}.`);
295
+ }
296
+ }
297
+ const modelClass = roleModelClass(frontmatter.modelClass, file);
298
+ return {
299
+ name: parseRoleName(frontmatter.name, file),
300
+ description: cleanDisplayText(frontmatter.description, "description", file),
301
+ ...(modelClass === undefined ? {} : { modelClass }),
302
+ tools: stringList(frontmatter.tools, "tools", file),
303
+ extensions: extensionList(frontmatter.extensions, file),
304
+ skills: stringList(frontmatter.skills, "skills", file),
305
+ mcps: mcpList(frontmatter.mcps, file),
306
+ systemPrompt: cleanText(parsed.body, "system prompt", file),
307
+ };
308
+ }
309
+ function readRoleFile(file) {
310
+ try {
311
+ return parseRoleFile(file, readFileSync(file, "utf8"));
312
+ }
313
+ catch (error) {
314
+ if (error instanceof Error && error.message.startsWith(`${file}: `))
315
+ throw error;
316
+ throw new Error(`${file}: ${error instanceof Error ? error.message : String(error)}`);
317
+ }
318
+ }
319
+ export function loadBuiltinRole(name) {
320
+ return readRoleFile(fileURLToPath(new URL(`../examples/roles/${name}.md`, import.meta.url)));
321
+ }
322
+ function builtinRoles() {
323
+ return BUILTIN_ROLE_NAMES.map(loadBuiltinRole);
324
+ }
325
+ function claimedRoleName(file) {
326
+ try {
327
+ const { name } = parseFrontmatter(readFileSync(file, "utf8")).frontmatter;
328
+ if (typeof name === "string" && name.trim())
329
+ return name.trim();
330
+ }
331
+ catch { /* The file's own error is reported; fall back to its stem. */ }
332
+ return basename(file, ".md");
333
+ }
334
+ /**
335
+ * Package-shipped Roles plus user Roles from `config/pi-subagent`, with
336
+ * `roleDefaults` merged in. A user Role overrides a same-named built-in. Each
337
+ * invalid or duplicate user file is reported and makes its claimed name
338
+ * unavailable without hiding valid Roles. Invalid `roleDefaults` keys are
339
+ * dropped; a `config.json` that cannot load (for example, malformed JSON) throws.
340
+ */
341
+ export function loadRoleCatalog(agentDir = getAgentDir()) {
342
+ const defaults = readRoleDefaults(agentDir);
343
+ const builtins = new Map(builtinRoles().map((role) => [role.name, role]));
344
+ const dir = extensionConfigDir("pi-subagent", agentDir);
345
+ let entries = [];
346
+ try {
347
+ entries = readdirSync(dir, { withFileTypes: true });
348
+ }
349
+ catch (error) {
350
+ if (!(error && typeof error === "object" && "code" in error && error.code === "ENOENT"))
351
+ throw error;
352
+ }
353
+ const user = new Map();
354
+ const errors = [];
355
+ for (const entry of entries
356
+ .filter((candidate) => candidate.name.endsWith(".md") && (candidate.isFile() || candidate.isSymbolicLink()))
357
+ .sort((a, b) => a.name.localeCompare(b.name))) {
358
+ const file = join(dir, entry.name);
359
+ try {
360
+ const role = readRoleFile(file);
361
+ user.set(role.name, [...(user.get(role.name) ?? []), { file, role }]);
362
+ }
363
+ catch (error) {
364
+ errors.push({ file, name: claimedRoleName(file), message: error instanceof Error ? error.message : String(error) });
365
+ }
366
+ }
367
+ for (const [name, files] of user) {
368
+ if (files.length < 2)
369
+ continue;
370
+ for (const { file } of files)
371
+ errors.push({ file, name, message: `${file}: Duplicate Subagent role: ${name} (${files.map((item) => item.file).join(", ")}).` });
372
+ }
373
+ const blocked = new Set(errors.map(({ name }) => name));
374
+ const sources = new Map();
375
+ const byName = new Map();
376
+ for (const [name, role] of builtins) {
377
+ byName.set(name, user.get(name)?.[0]?.role ?? role);
378
+ sources.set(name, user.has(name) ? "user override" : "built-in");
379
+ }
380
+ for (const [name, files] of user) {
381
+ if (builtins.has(name))
382
+ continue;
383
+ byName.set(name, files[0].role);
384
+ sources.set(name, "user");
385
+ }
386
+ for (const name of blocked) {
387
+ byName.delete(name);
388
+ sources.delete(name);
389
+ }
390
+ return { roles: [...byName.values()].map((role) => applyRoleDefaults(role, defaults)), sources, errors };
391
+ }
392
+ /** Select one catalog Role, naming invalid files that claim it. */
393
+ export function findRole(catalog, name) {
394
+ const role = catalog.roles.find((candidate) => candidate.name === name);
395
+ if (role)
396
+ return role;
397
+ const invalid = catalog.errors.filter((error) => error.name === name);
398
+ if (invalid.length)
399
+ throw new Error(`Subagent role ${name} is unavailable: ${invalid.map(({ message }) => message).join("; ")}`);
400
+ throw new Error(`Unknown Subagent role: ${name}. Available roles: ${catalog.roles.map((candidate) => candidate.name).join(", ") || "none"}.${catalog.errors.length ? ` ${catalog.errors.length} invalid Role file(s); run /subagent roles.` : ""}`);
401
+ }
402
+ export function resolveTaskRoute(ctx, profileName, agentDir = getAgentDir()) {
403
+ // A disabled config throws with the path and the problems.
404
+ const config = loadTaskModelsConfig(agentDir).value;
405
+ return resolveConfiguredRoute(ctx, profileName, config.profiles[profileName]);
406
+ }
407
+ function resolveConfiguredRoute(ctx, profileName, profile) {
408
+ if (!profile)
409
+ throw new Error(`No ${profileName} task model profile is configured. Run /task-models.`);
410
+ for (const route of orderedProfileRoutes(profile)) {
411
+ const resolved = resolveTaskModelRoute(ctx, route);
412
+ if (resolved)
413
+ return resolved;
414
+ }
415
+ throw new Error(`No usable ${profileName} task model route. Run /task-models.`);
416
+ }
417
+ export function resolveRoleSkills(pi, role) {
418
+ const skills = new Map(pi.getCommands()
419
+ .filter((command) => command.source === "skill")
420
+ .map((command) => [command.name, command.sourceInfo.path]));
421
+ const paths = [];
422
+ const missing = [];
423
+ for (const name of role.skills) {
424
+ const path = skills.get(`skill:${name}`);
425
+ if (path)
426
+ paths.push(path);
427
+ else
428
+ missing.push(name);
429
+ }
430
+ return { paths, missing };
431
+ }
432
+ function packageManager(ctx) {
433
+ const agentDir = getAgentDir();
434
+ const settingsManager = SettingsManager.create(ctx.cwd, agentDir, { projectTrusted: ctx.isProjectTrusted() });
435
+ return new DefaultPackageManager({ cwd: ctx.cwd, agentDir, settingsManager });
436
+ }
437
+ /** Resolve all enabled package resources selected by one Role's extension sources. */
438
+ export async function resolveRolePackageResources(role, ctx) {
439
+ const sources = role.extensions;
440
+ for (const source of sources)
441
+ rejectForbiddenRolePackageSource(source, role);
442
+ if (!sources.length)
443
+ return { extensions: [], skills: [], prompts: [], themes: [] };
444
+ const resolved = await packageManager(ctx).resolveExtensionSources([...sources]);
445
+ const resourceGroups = [resolved.extensions, resolved.skills, resolved.prompts, resolved.themes]
446
+ .map((resources) => resources.filter((resource) => resource.enabled));
447
+ // Built-in extensions resolve to their `builtin:<name>` path with the bare source `builtin`.
448
+ const resolvedSources = new Set(resourceGroups.flat()
449
+ .map((resource) => resource.metadata.source === "builtin" ? resource.path : resource.metadata.source));
450
+ const missing = sources.filter((source) => !resolvedSources.has(source));
451
+ if (missing.length)
452
+ throw new Error(`Role extension sources resolved no resources: ${missing.join(", ")}.`);
453
+ return {
454
+ extensions: resourceGroups[0].map((resource) => resource.path),
455
+ skills: resourceGroups[1].map((resource) => resource.path),
456
+ prompts: resourceGroups[2].map((resource) => resource.path),
457
+ themes: resourceGroups[3].map((resource) => resource.path),
458
+ };
459
+ }
460
+ export function createRoleLaunch(pi, ctx, input) {
461
+ return finalizeRoleLaunch(prepareRoleLaunchFromSkills(ctx, input, resolveRoleSkills(pi, input.role)));
462
+ }
463
+ /** Build the prompt-free argv and tool policy; the Role prompt stays separate until `finalizeRoleLaunch`. */
464
+ function prepareRoleLaunchFromSkills(ctx, input, skills) {
465
+ const role = input.role;
466
+ const mcps = mcpList(role.mcps, `Role ${role.name}`);
467
+ const tools = roleToolPolicy(role, input.tools);
468
+ const selectedExtensions = [...role.extensions, ...(input.extensions ?? [])]
469
+ .map((extension) => validateExtension(extension, `Role ${role.name}`));
470
+ if (selectedExtensions.some(namesMcpAdapter)) {
471
+ throw new Error(`Role ${role.name} must select MCP servers with mcps instead of loading pi-mcp-adapter directly.`);
472
+ }
473
+ const extensions = [
474
+ ...selectedExtensions,
475
+ ...(CODEX_ALIAS.test(input.route.model.provider) ? [MULTI_CODEX_EXTENSION] : []),
476
+ // Always loaded: it connects the servers that Role extensions register; `mcps` gates only mcp.json.
477
+ ROLE_MCP_EXTENSION,
478
+ ...(role.tools.includes("git_read") ? [GIT_READ_EXTENSION] : []),
479
+ ROLE_TOOLS_EXTENSION,
480
+ ];
481
+ const env = Object.fromEntries(Object.entries(input.env ?? {}).map(([key, value]) => {
482
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
483
+ throw new Error(`Invalid launch environment name: ${key}`);
484
+ if (typeof value !== "string" || value.includes("\0"))
485
+ throw new Error(`Invalid launch environment value: ${key}`);
486
+ return [key, value];
487
+ }));
488
+ const args = ["--no-session", "--no-extensions", "--no-skills", "--exclude-tools", CHILD_EXCLUDED_TOOL_NAMES.join(",")];
489
+ args.push(`--${ROLE_MCP_POLICY_FLAG}`, JSON.stringify(mcps));
490
+ for (const extension of new Set(extensions))
491
+ args.push("--extension", extension);
492
+ for (const skill of skills.paths)
493
+ args.push("--skill", skill);
494
+ args.push(`--${ROLE_TOOL_POLICY_FLAG}`, JSON.stringify(tools));
495
+ args.push("--model", modelReference(input.route.model));
496
+ if (input.route.thinkingLevel)
497
+ args.push("--thinking", input.route.thinkingLevel);
498
+ args.push(ctx.isProjectTrusted() ? "--approve" : "--no-approve");
499
+ return {
500
+ env,
501
+ args,
502
+ model: input.route.model,
503
+ thinkingLevel: input.route.thinkingLevel,
504
+ missingSkills: skills.missing,
505
+ role: role.name,
506
+ tools: Object.freeze(tools),
507
+ systemPrompt: `${CHILD_IDENTITY_POLICY}\n\n${cleanText(role.systemPrompt, "system prompt", `Role ${role.name}`)}`,
508
+ promptArgIndex: args.length,
509
+ };
510
+ }
511
+ function prepareResolvedRoleLaunch(pi, ctx, input) {
512
+ const { task, modelClass, agentDir, ...launchInput } = input;
513
+ const selectedClass = modelClass ?? input.role.modelClass;
514
+ return prepareRoleLaunchFromSkills(ctx, {
515
+ ...launchInput,
516
+ route: selectedClass === undefined
517
+ ? resolveConfiguredTaskRoute(ctx, task, agentDir)
518
+ : resolveTaskRoute(ctx, selectedClass, agentDir),
519
+ }, resolveRoleSkills(pi, input.role));
520
+ }
521
+ export function resolveRoleLaunch(pi, ctx, input) {
522
+ return finalizeRoleLaunch(prepareResolvedRoleLaunch(pi, ctx, input));
523
+ }
524
+ function assertNoMissingRoleSkills(role, launch) {
525
+ if (!launch.missingSkills.length)
526
+ return;
527
+ throw new Error(`Role ${parseRoleName(role.name)} requires missing Skills: ${launch.missingSkills.join(", ")}.`);
528
+ }
529
+ export function prepareRoleLaunch(pi, ctx, input) {
530
+ const role = parseRoleName(input.role.name);
531
+ rejectRetiredRoleIsolation(input.role.isolation, `Role ${role}`);
532
+ const prepared = "route" in input
533
+ ? prepareRoleLaunchFromSkills(ctx, input, resolveRoleSkills(pi, input.role))
534
+ : prepareResolvedRoleLaunch(pi, ctx, input);
535
+ assertNoMissingRoleSkills(input.role, prepared);
536
+ // Validate the mcp.json the child reads: its environment inherits Main's, with launch overrides on top.
537
+ if (input.role.mcps?.length) {
538
+ loadRoleMcpConfig(prepared.env.PI_CODING_AGENT_DIR ?? getAgentDir(), input.role.mcps, { codemode: roleActivatesCodemode(prepared.tools) });
539
+ }
540
+ return { ...prepared, role };
541
+ }
542
+ /** Resolve and prepare a configured Role with its package-owned resources. */
543
+ export async function resolveConfiguredRoleLaunch(pi, ctx, input) {
544
+ const roleName = parseRoleName(input.role);
545
+ if (input.modelClass === undefined)
546
+ throw new Error("Configured Role launch requires an explicit modelClass.");
547
+ const role = findRole(loadRoleCatalog(), roleName);
548
+ if (role.mcps?.length)
549
+ loadRoleMcpConfig(getAgentDir(), role.mcps, { codemode: roleActivatesCodemode(role.tools) });
550
+ const resources = await resolveRolePackageResources(role, ctx);
551
+ const effectiveRole = { ...role, extensions: resources.extensions };
552
+ const namedSkills = resolveRoleSkills(pi, effectiveRole);
553
+ const skills = {
554
+ ...namedSkills,
555
+ paths: [...new Set([...namedSkills.paths, ...resources.skills])],
556
+ };
557
+ const prepared = prepareRoleLaunchFromSkills(ctx, {
558
+ role: effectiveRole,
559
+ route: resolveTaskRoute(ctx, input.modelClass),
560
+ }, skills);
561
+ assertNoMissingRoleSkills(effectiveRole, prepared);
562
+ const args = [
563
+ ...prepared.args,
564
+ "--no-prompt-templates",
565
+ "--no-themes",
566
+ ...resources.prompts.flatMap((path) => ["--prompt-template", path]),
567
+ ...resources.themes.flatMap((path) => ["--theme", path]),
568
+ ];
569
+ return { ...prepared, args, promptArgIndex: args.length };
570
+ }
571
+ /** Insert the system prompt pair after a caller has prepared its launch argv. */
572
+ export function finalizeRoleLaunch(prepared) {
573
+ const args = [...prepared.args];
574
+ args.splice(prepared.promptArgIndex, 0, SYSTEM_PROMPT_FLAG, prepared.systemPrompt);
575
+ return {
576
+ env: prepared.env,
577
+ args,
578
+ model: prepared.model,
579
+ thinkingLevel: prepared.thinkingLevel,
580
+ missingSkills: prepared.missingSkills,
581
+ };
582
+ }
583
+ export * from "./composition.js";
584
+ export * from "./git-runtime.js";
585
+ export * from "./herdr-runtime.js";
586
+ export { ISOLATED_MODEL_TASK, RoleLaunchRuntime } from "./launch-runtime.js";
587
+ export * from "./runner.js";
588
+ export * from "./schema.js";
589
+ export * from "./store.js";
@@ -0,0 +1,63 @@
1
+ import { type WorktreeInfo } from "./worktree.ts";
2
+ import { type CheckBatchEvidence, type CheckCommand, type IntegrationCandidate, type ReviewEvidence, type WorkspaceIdentity } from "./schema.ts";
3
+ export type StageReceipt = {
4
+ previous: WorkspaceIdentity;
5
+ worker: WorkspaceIdentity;
6
+ tip: WorkspaceIdentity;
7
+ };
8
+ export type GitOutcome<T> = {
9
+ outcome: "ready";
10
+ value: T;
11
+ } | {
12
+ outcome: "blocked" | "conflict" | "drift" | "unknown";
13
+ failure: string;
14
+ possibleResources?: string[];
15
+ };
16
+ export declare class IntegrationGit {
17
+ /** Refresh is allowed only for the exact clean checked-out Main and a strict same-branch descendant. */
18
+ inspectMainAdvance(root: string, from: WorkspaceIdentity, to: WorkspaceIdentity, signal: AbortSignal): Promise<void>;
19
+ /** Persist the prepared path/branch/base before Git is allowed to create anything. */
20
+ allocate(root: string, childId: string, base: WorkspaceIdentity, onPrepared: (info: WorktreeInfo) => Promise<void>, signal: AbortSignal, nameParts?: readonly string[]): Promise<GitOutcome<WorktreeInfo>>;
21
+ /** Prove each recorded merge before any new merge or final gate. */
22
+ inspectCombined(root: string, integration: WorktreeInfo, base: WorkspaceIdentity, stages: readonly StageReceipt[], signal: AbortSignal): Promise<WorkspaceIdentity>;
23
+ /** Main's correction is one exact committed child of the checked staged tip. */
24
+ inspectCorrection(root: string, integration: WorktreeInfo, base: WorkspaceIdentity, stages: readonly StageReceipt[], from: WorkspaceIdentity, to: WorkspaceIdentity, signal: AbortSignal): Promise<void>;
25
+ /** Main orders calls. On conflict leave MERGE_HEAD and the index untouched for manual resolution. */
26
+ stage(root: string, integration: WorktreeInfo, base: WorkspaceIdentity, stages: readonly StageReceipt[], worker: WorktreeInfo, candidate: WorkspaceIdentity, signal: AbortSignal): Promise<GitOutcome<StageReceipt>>;
27
+ /** After manual conflict resolution: only the exact two-parent merge is a valid receipt. */
28
+ confirmStage(root: string, integration: WorktreeInfo, base: WorkspaceIdentity, stages: readonly StageReceipt[], worker: WorktreeInfo, candidate: WorkspaceIdentity, signal: AbortSignal): Promise<GitOutcome<StageReceipt>>;
29
+ /** Reconcile an interrupted merge without retrying it or discarding conflict state. */
30
+ reconcileStage(root: string, integration: WorktreeInfo, base: WorkspaceIdentity, stages: readonly StageReceipt[], worker: WorktreeInfo, candidate: WorkspaceIdentity, signal: AbortSignal): Promise<GitOutcome<StageReceipt | "not_started">>;
31
+ inspectWorker(root: string, worker: WorktreeInfo, candidate: WorkspaceIdentity, signal: AbortSignal): Promise<void>;
32
+ /** Read-only reconciliation of a lost promotion response; never repeat the merge. */
33
+ reconcilePromotion(root: string, integration: WorktreeInfo, base: WorkspaceIdentity, stages: readonly StageReceipt[], signal: AbortSignal, correction?: {
34
+ from: WorkspaceIdentity;
35
+ to: WorkspaceIdentity;
36
+ }): Promise<GitOutcome<WorkspaceIdentity>>;
37
+ /** Checks/review must be persisted for this exact combined tip; never move Main via update-ref. */
38
+ promote(input: {
39
+ root: string;
40
+ integration: WorktreeInfo;
41
+ base: WorkspaceIdentity;
42
+ stages: readonly StageReceipt[];
43
+ checks: CheckBatchEvidence;
44
+ commands: readonly CheckCommand[];
45
+ /** Staged candidates that may supply reused final results; omitted accepts no reuse. */
46
+ sources?: readonly IntegrationCandidate[];
47
+ review?: ReviewEvidence;
48
+ criterion?: string;
49
+ correction?: {
50
+ from: WorkspaceIdentity;
51
+ to: WorkspaceIdentity;
52
+ };
53
+ }, signal: AbortSignal): Promise<GitOutcome<WorkspaceIdentity>>;
54
+ /** Read-only proof that an owned checkout, its registration, and its branch are all gone. */
55
+ absent(root: string, info: WorktreeInfo, signal: AbortSignal): Promise<boolean>;
56
+ /** Explicit release of an unselected clean checkout. The branch deletion is an exact ref compare-and-swap. */
57
+ release(root: string, info: WorktreeInfo, expected: WorkspaceIdentity, kind: "worktree" | "branch", signal: AbortSignal): Promise<GitOutcome<"removed" | "absent">>;
58
+ /** Cleanup is deliberately separate per resource; callers persist each proven substep. */
59
+ cleanup(root: string, integration: WorktreeInfo, info: WorktreeInfo, base: WorkspaceIdentity, stages: readonly StageReceipt[], promoted: WorkspaceIdentity, kind: "worktree" | "branch", signal: AbortSignal, correction?: {
60
+ from: WorkspaceIdentity;
61
+ to: WorkspaceIdentity;
62
+ }): Promise<GitOutcome<"removed" | "absent">>;
63
+ }