@hcflabs/pi-subagent 0.0.0-stage → 27.10.7
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/CONTEXT.md +77 -0
- package/LICENSE +22 -0
- package/README.md +282 -2
- package/dist/composition.d.ts +11 -0
- package/dist/composition.js +104 -0
- package/dist/direct-herdr.d.ts +71 -0
- package/dist/direct-herdr.js +527 -0
- package/dist/discard.d.ts +51 -0
- package/dist/discard.js +218 -0
- package/dist/display-text.d.ts +5 -0
- package/dist/display-text.js +9 -0
- package/dist/ephemeral.d.ts +79 -0
- package/dist/ephemeral.js +916 -0
- package/dist/git-process.d.ts +8 -0
- package/dist/git-process.js +25 -0
- package/dist/git-runtime.d.ts +115 -0
- package/dist/git-runtime.js +489 -0
- package/dist/herdr-runtime.d.ts +147 -0
- package/dist/herdr-runtime.js +1366 -0
- package/dist/index.d.ts +170 -0
- package/dist/index.js +587 -0
- package/dist/integration-git.d.ts +63 -0
- package/dist/integration-git.js +504 -0
- package/dist/launch-runtime.d.ts +54 -0
- package/dist/launch-runtime.js +178 -0
- package/dist/mcp-role.d.ts +12 -0
- package/dist/mcp-role.js +152 -0
- package/dist/process-lease.d.ts +5 -0
- package/dist/process-lease.js +43 -0
- package/dist/process.d.ts +9 -0
- package/dist/process.js +11 -0
- package/dist/prune.d.ts +78 -0
- package/dist/prune.js +205 -0
- package/dist/review-evidence.d.ts +17 -0
- package/dist/review-evidence.js +221 -0
- package/dist/runner.d.ts +431 -0
- package/dist/runner.js +3380 -0
- package/dist/schema.d.ts +2560 -0
- package/dist/schema.js +1365 -0
- package/dist/store.d.ts +66 -0
- package/dist/store.js +394 -0
- package/dist/worktree.d.ts +65 -0
- package/dist/worktree.js +288 -0
- package/docs/orchestration-architecture.html +138 -0
- package/docs/orchestration-architecture.svg +135 -0
- package/docs/orchestration.md +577 -0
- package/docs/worker-lifecycle.html +97 -0
- package/docs/worker-lifecycle.svg +86 -0
- package/example.png +0 -0
- package/examples/roles/implementer.md +25 -0
- package/examples/roles/reviewer.md +25 -0
- package/examples/roles/scout.md +25 -0
- package/extensions/admission.ts +82 -0
- package/extensions/config.ts +19 -0
- package/extensions/git-read.ts +126 -0
- package/extensions/isolated.ts +945 -0
- package/extensions/model-class-policy.ts +1 -0
- package/extensions/result-transport.ts +236 -0
- package/extensions/role-mcp.ts +14 -0
- package/extensions/role-tools.ts +178 -0
- package/extensions/subagent-command.ts +699 -0
- package/extensions/subagent.ts +958 -0
- package/extensions/workflow.ts +336 -0
- package/package.json +84 -4
- package/prompts/review.md +20 -0
- package/skills/pi-subagent/SKILL.md +41 -0
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
3
|
+
import { lstat, readlink } from "node:fs/promises";
|
|
4
|
+
import { posix } from "node:path";
|
|
5
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
6
|
+
import { DISPLAY_TEXT_CONTRACT } from "@hcflabs/pi-subagent";
|
|
7
|
+
import { PROFILE_NAMES } from "@hcflabs/pi-task-models";
|
|
8
|
+
import { Type, type Static } from "typebox";
|
|
9
|
+
import { Check, Errors } from "typebox/value";
|
|
10
|
+
import { runGit } from "../dist/git-process.js";
|
|
11
|
+
import { ExecuteRequestSchema, parseExecuteRequest, type ExecuteRequest } from "../dist/schema.js";
|
|
12
|
+
|
|
13
|
+
export const MAX_WORKFLOW_ENTRIES = 8;
|
|
14
|
+
|
|
15
|
+
const TASK_NAME_MAX_LENGTH = 29;
|
|
16
|
+
const TaskNameSchema = Type.String({
|
|
17
|
+
minLength: 1,
|
|
18
|
+
maxLength: TASK_NAME_MAX_LENGTH,
|
|
19
|
+
description: `Short descriptive task name, about five words and fewer than ${TASK_NAME_MAX_LENGTH + 1} characters; C0/C1 control characters are rejected.`,
|
|
20
|
+
pattern: DISPLAY_TEXT_CONTRACT.pattern,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const RoleSchema = Type.String({ minLength: 1, pattern: DISPLAY_TEXT_CONTRACT.pattern, description: "Configured Subagent role name" });
|
|
24
|
+
const TaskSchema = Type.String({ minLength: 1, description: "Bounded task packet" });
|
|
25
|
+
const ModelSchema = Type.String({ minLength: 1, pattern: DISPLAY_TEXT_CONTRACT.pattern, description: "Designated model as provider/modelId; replaces the selected route model" });
|
|
26
|
+
const ModelClassSchema = StringEnum(PROFILE_NAMES, { description: "Task model profile" });
|
|
27
|
+
const DirectKindSchema = StringEnum(["text"] as const);
|
|
28
|
+
/** Main's honest per-task classification; only `write` reserves the checkout. Nothing enforces `read`. */
|
|
29
|
+
export const DIRECT_ACCESS = ["read", "write"] as const;
|
|
30
|
+
export type DirectAccess = (typeof DIRECT_ACCESS)[number];
|
|
31
|
+
const ACCESS_GUIDANCE = `Declare "write" when the task may change files, Git state, or external resources, otherwise "read". "read" is not enforced (no sandbox): it skips checkout admission and runs concurrently. Use mode isolated for checked writes.`;
|
|
32
|
+
const AccessSchema = StringEnum(DIRECT_ACCESS, { description: ACCESS_GUIDANCE });
|
|
33
|
+
const DryRunSchema = Type.Boolean({ description: "Optional. When true, run every launch validation and return the resolved plan without starting workers, saving state, or reserving the checkout." });
|
|
34
|
+
const ContinueFromSchema = Type.String({ pattern: "^D[1-9][0-9]*\\.[1-9][0-9]*$", description: "Optional direct worker reference such as D7.1. Continues that finished worker's Pi session in a new tab with this task; role must equal the recorded Role." });
|
|
35
|
+
|
|
36
|
+
const PathsSchema = Type.Array(Type.String({ minLength: 1 }), {
|
|
37
|
+
minItems: 1,
|
|
38
|
+
maxItems: 32,
|
|
39
|
+
description: "Write tasks only: repo-relative globs (Node path.matchesGlob syntax, such as src/**) the task may change. The result flags changed paths outside them. Report only; nothing enforces it.",
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export const DelegationSchema = Type.Object({
|
|
43
|
+
role: RoleSchema,
|
|
44
|
+
name: TaskNameSchema,
|
|
45
|
+
task: TaskSchema,
|
|
46
|
+
access: AccessSchema,
|
|
47
|
+
paths: Type.Optional(PathsSchema),
|
|
48
|
+
continueFrom: Type.Optional(ContinueFromSchema),
|
|
49
|
+
kind: Type.Optional(DirectKindSchema),
|
|
50
|
+
model: Type.Optional(ModelSchema),
|
|
51
|
+
modelClass: Type.Optional(ModelClassSchema),
|
|
52
|
+
}, { additionalProperties: false });
|
|
53
|
+
|
|
54
|
+
export const DirectWorkflowSchema = Type.Object({
|
|
55
|
+
mode: Type.Literal("direct"),
|
|
56
|
+
role: Type.Optional(RoleSchema),
|
|
57
|
+
name: Type.Optional(TaskNameSchema),
|
|
58
|
+
task: Type.Optional(TaskSchema),
|
|
59
|
+
access: Type.Optional(AccessSchema),
|
|
60
|
+
paths: Type.Optional(PathsSchema),
|
|
61
|
+
continueFrom: Type.Optional(ContinueFromSchema),
|
|
62
|
+
kind: Type.Optional(DirectKindSchema),
|
|
63
|
+
model: Type.Optional(ModelSchema),
|
|
64
|
+
modelClass: Type.Optional(ModelClassSchema),
|
|
65
|
+
tasks: Type.Optional(Type.Array(DelegationSchema, { minItems: 1, maxItems: MAX_WORKFLOW_ENTRIES })),
|
|
66
|
+
chain: Type.Optional(Type.Array(DelegationSchema, { minItems: 1, maxItems: MAX_WORKFLOW_ENTRIES })),
|
|
67
|
+
}, { additionalProperties: false, description: "Direct mode: exactly one compact single, tasks, or chain workflow" });
|
|
68
|
+
|
|
69
|
+
// Parse each mode with its strict schema; expose a plain object root to tool providers.
|
|
70
|
+
export const DelegateTaskParameters = Type.Object({
|
|
71
|
+
...DirectWorkflowSchema.properties,
|
|
72
|
+
mode: Type.Union([DirectWorkflowSchema.properties.mode, ExecuteRequestSchema.properties.mode]),
|
|
73
|
+
id: Type.Optional(ExecuteRequestSchema.properties.id),
|
|
74
|
+
goal: Type.Optional(ExecuteRequestSchema.properties.goal),
|
|
75
|
+
tasks: Type.Optional(Type.Union([
|
|
76
|
+
Type.Array(DelegationSchema, { minItems: 1, maxItems: MAX_WORKFLOW_ENTRIES }),
|
|
77
|
+
ExecuteRequestSchema.properties.tasks,
|
|
78
|
+
])),
|
|
79
|
+
finalChecks: ExecuteRequestSchema.properties.finalChecks,
|
|
80
|
+
finalJudgment: ExecuteRequestSchema.properties.finalJudgment,
|
|
81
|
+
dryRun: Type.Optional(DryRunSchema),
|
|
82
|
+
}, { additionalProperties: false });
|
|
83
|
+
|
|
84
|
+
export type Delegation = Static<typeof DelegationSchema> & { kind: "text" };
|
|
85
|
+
export type WorkflowMode = "single" | "parallel" | "chain";
|
|
86
|
+
export type ParsedWorkflow =
|
|
87
|
+
| { mode: "single"; delegations: [Delegation] }
|
|
88
|
+
| { mode: "parallel"; delegations: Delegation[] }
|
|
89
|
+
| { mode: "chain"; delegations: Delegation[] };
|
|
90
|
+
export type ParsedDelegateTask = ({ mode: "direct"; workflow: ParsedWorkflow } | { mode: "isolated"; request: ExecuteRequest }) & { dryRun: boolean };
|
|
91
|
+
|
|
92
|
+
type DirectInput = Static<typeof DirectWorkflowSchema>;
|
|
93
|
+
const DELEGATION_KEYS = ["role", "name", "task", "access", "paths", "continueFrom", "kind", "model", "modelClass"] as const;
|
|
94
|
+
|
|
95
|
+
function text(value: string, path: string): string {
|
|
96
|
+
const normalized = value.trim();
|
|
97
|
+
if (!normalized || value.includes("\0")) throw new Error(`${path} must be non-empty text without NUL.`);
|
|
98
|
+
return normalized;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function normalizeTaskName(value: string, path: string): string {
|
|
102
|
+
const normalized = value.trim();
|
|
103
|
+
if (!normalized) throw new Error(`${path} must be non-empty text.`);
|
|
104
|
+
return normalized;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function normalizePaths(value: Static<typeof DelegationSchema>, path: string): string[] | undefined {
|
|
108
|
+
if (value.paths === undefined) return;
|
|
109
|
+
if (value.access !== "write") throw new Error(`${path}.paths requires access "write"; it declares where a write task may change files.`);
|
|
110
|
+
return value.paths.map((glob, index) => {
|
|
111
|
+
const normalized = text(glob, `${path}.paths[${index}]`);
|
|
112
|
+
if (normalized.startsWith("/") || normalized.split("/").includes("..")) throw new Error(`${path}.paths[${index}] must be a repo-relative glob without .. segments.`);
|
|
113
|
+
return normalized;
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function normalizeDelegation(value: Static<typeof DelegationSchema>, path: string): Delegation {
|
|
118
|
+
const paths = normalizePaths(value, path);
|
|
119
|
+
return {
|
|
120
|
+
role: text(value.role, `${path}.role`),
|
|
121
|
+
name: normalizeTaskName(value.name, `${path}.name`),
|
|
122
|
+
task: text(value.task, `${path}.task`),
|
|
123
|
+
access: value.access,
|
|
124
|
+
...(paths === undefined ? {} : { paths }),
|
|
125
|
+
...(value.continueFrom === undefined ? {} : { continueFrom: value.continueFrom }),
|
|
126
|
+
kind: "text",
|
|
127
|
+
...(value.model === undefined ? {} : { model: text(value.model, `${path}.model`) }),
|
|
128
|
+
...(value.modelClass === undefined ? {} : { modelClass: value.modelClass }),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function hasDelegation(value: DirectInput): value is DirectInput & Static<typeof DelegationSchema> {
|
|
133
|
+
return Object.hasOwn(value, "role") && Object.hasOwn(value, "name") && Object.hasOwn(value, "task");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function requireAccess(value: unknown, path: string): void {
|
|
137
|
+
if (!value || typeof value !== "object") return; // The schema rejects non-object tasks.
|
|
138
|
+
const { access } = value as { access?: unknown };
|
|
139
|
+
if (!DIRECT_ACCESS.some((candidate) => candidate === access)) {
|
|
140
|
+
throw new Error(`${path}.access must be ${DIRECT_ACCESS.map((candidate) => JSON.stringify(candidate)).join(" or ")}${access === undefined ? " (missing)" : `, got ${JSON.stringify(access)}`}. ${ACCESS_GUIDANCE}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function workflowMode(value: unknown): WorkflowMode | undefined {
|
|
145
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return;
|
|
146
|
+
const single = DELEGATION_KEYS.some((key) => Object.hasOwn(value, key));
|
|
147
|
+
const parallel = Object.hasOwn(value, "tasks");
|
|
148
|
+
const chain = Object.hasOwn(value, "chain");
|
|
149
|
+
if (Number(single) + Number(parallel) + Number(chain) !== 1) throw new Error("direct workflow must select exactly one of single, tasks, or chain.");
|
|
150
|
+
return single ? "single" : parallel ? "parallel" : "chain";
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function parseWorkflow(value: unknown): ParsedWorkflow {
|
|
154
|
+
const mode = workflowMode(value);
|
|
155
|
+
if (mode === "single") requireAccess(value, "workflow");
|
|
156
|
+
else if (mode) {
|
|
157
|
+
const items = (value as Record<string, unknown>)[mode === "parallel" ? "tasks" : "chain"];
|
|
158
|
+
if (Array.isArray(items)) items.forEach((item, index) => requireAccess(item, `${mode === "parallel" ? "tasks" : "chain"}[${index}]`));
|
|
159
|
+
}
|
|
160
|
+
if (!Check(DirectWorkflowSchema, value)) {
|
|
161
|
+
const issue = Errors(DirectWorkflowSchema, value)[0];
|
|
162
|
+
throw new Error(`direct workflow must match the declared tool schema${issue ? ` at ${issue.instancePath || "/"}: ${issue.message}` : ""}. Direct mode returns task answers without checked integration; use mode isolated for changesets.`);
|
|
163
|
+
}
|
|
164
|
+
if (!mode) throw new Error("direct workflow must select exactly one of single, tasks, or chain.");
|
|
165
|
+
const input = value as DirectInput;
|
|
166
|
+
if (mode === "single" && !hasDelegation(input)) throw new Error("direct workflow requires role, name, task, and access.");
|
|
167
|
+
const workflow: ParsedWorkflow = mode === "single" ? { mode, delegations: [normalizeDelegation(input as Static<typeof DelegationSchema>, "workflow")] }
|
|
168
|
+
: mode === "parallel" ? { mode, delegations: input.tasks!.map((item, index) => normalizeDelegation(item, `tasks[${index}]`)) }
|
|
169
|
+
: { mode, delegations: input.chain!.map((item, index) => normalizeDelegation(item, `chain[${index}]`)) };
|
|
170
|
+
const continued = workflow.delegations.flatMap(({ continueFrom }) => continueFrom === undefined ? [] : [continueFrom]);
|
|
171
|
+
if (new Set(continued).size !== continued.length) throw new Error("A direct request may continue each worker at most once.");
|
|
172
|
+
return workflow;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** A direct request is a writer request when any task declares write access. */
|
|
176
|
+
export const writesCheckout = (workflow: ParsedWorkflow): boolean => workflow.delegations.some(({ access }) => access === "write");
|
|
177
|
+
|
|
178
|
+
/** `dryRun` is a tool-call option, not part of the direct workflow or the durable isolated request. */
|
|
179
|
+
export function parseDelegateTask(value: unknown): ParsedDelegateTask {
|
|
180
|
+
let dryRun = false;
|
|
181
|
+
if (value && typeof value === "object" && !Array.isArray(value) && Object.hasOwn(value, "dryRun")) {
|
|
182
|
+
const { dryRun: option, ...rest } = value as Record<string, unknown>;
|
|
183
|
+
if (typeof option !== "boolean") throw new Error(`delegate_task dryRun must be a boolean, got ${JSON.stringify(option)}.`);
|
|
184
|
+
dryRun = option;
|
|
185
|
+
value = rest;
|
|
186
|
+
}
|
|
187
|
+
if (value && typeof value === "object" && !Array.isArray(value) && (value as { mode?: unknown }).mode === "isolated") {
|
|
188
|
+
return { mode: "isolated", request: parseExecuteRequest(value), dryRun };
|
|
189
|
+
}
|
|
190
|
+
return { mode: "direct", workflow: parseWorkflow(value), dryRun };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Main's checkout state: HEAD plus porcelain status and content identity of each dirty path. */
|
|
194
|
+
export type CheckoutSnapshot = { head?: string; entries: Map<string, string>; fingerprint: string };
|
|
195
|
+
|
|
196
|
+
export type ChangeReport = {
|
|
197
|
+
/** Set when HEAD moved to a commit that does not descend from the launch HEAD. */
|
|
198
|
+
headMove?: string;
|
|
199
|
+
commits: Array<{ oid: string; subject: string }>;
|
|
200
|
+
omittedCommits: number;
|
|
201
|
+
paths: Array<{ path: string; outside?: true }>;
|
|
202
|
+
omittedPaths: number;
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
export type CheckoutNote =
|
|
206
|
+
| { kind: "changes"; report: ChangeReport }
|
|
207
|
+
| { kind: "stale" }
|
|
208
|
+
| { kind: "unavailable"; check: "change report" | "stale-read check"; reason: string };
|
|
209
|
+
|
|
210
|
+
export const CHANGE_REPORT_LIMITS = { commits: 10, paths: 20, subject: 120, reason: 500 } as const;
|
|
211
|
+
|
|
212
|
+
async function contentIdentity(path: string): Promise<string> {
|
|
213
|
+
const stats = await lstat(path).catch(() => undefined);
|
|
214
|
+
if (!stats) return "absent";
|
|
215
|
+
if (stats.isSymbolicLink()) return `link ${await readlink(path)}`;
|
|
216
|
+
if (!stats.isFile()) return "other";
|
|
217
|
+
const hash = createHash("sha256");
|
|
218
|
+
for await (const chunk of createReadStream(path)) hash.update(chunk);
|
|
219
|
+
return hash.digest("hex");
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Background snapshots must not take index.lock while Main or a worker writes.
|
|
223
|
+
async function git(cwd: string, args: string[], codes = [0]) {
|
|
224
|
+
const result = await runGit(["--no-optional-locks", ...args], cwd);
|
|
225
|
+
if (!codes.includes(result.code)) throw new Error(`git ${args[0]} exited ${result.code}: ${result.stderr.trim() || "no diagnostic"}`);
|
|
226
|
+
return result;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Snapshot the checkout that contains `cwd`; undefined outside a Git repository. Other Git failures throw. */
|
|
230
|
+
export async function snapshotCheckout(cwd: string): Promise<CheckoutSnapshot | undefined> {
|
|
231
|
+
const top = await runGit(["--no-optional-locks", "rev-parse", "--show-toplevel"], cwd);
|
|
232
|
+
if (top.code !== 0 && /not a git repository/i.test(top.stderr)) return undefined;
|
|
233
|
+
const root = top.stdout.trim();
|
|
234
|
+
if (top.code !== 0 || !root) throw new Error(`git rev-parse exited ${top.code}: ${top.stderr.trim() || "no diagnostic"}`);
|
|
235
|
+
const head = (await git(root, ["rev-parse", "--verify", "--quiet", "HEAD"], [0, 1])).stdout.trim() || undefined;
|
|
236
|
+
// Porcelain paths are repository-relative; an index or worktree rename or copy lists its source after the destination.
|
|
237
|
+
const fields = (await git(root, ["status", "--porcelain=v1", "-z", "--untracked-files=all"])).stdout.split("\0");
|
|
238
|
+
const entries = new Map<string, string>();
|
|
239
|
+
for (let index = 0; index < fields.length; index++) {
|
|
240
|
+
const field = fields[index]!;
|
|
241
|
+
if (!field) continue;
|
|
242
|
+
const status = field.slice(0, 2);
|
|
243
|
+
entries.set(field.slice(3), status);
|
|
244
|
+
if (/[RC]/.test(status)) entries.set(fields[++index]!, status);
|
|
245
|
+
}
|
|
246
|
+
// Status alone misses a further edit to a path that was already dirty.
|
|
247
|
+
for (const [path, status] of entries) entries.set(path, `${status} ${await contentIdentity(posix.join(root, path))}`);
|
|
248
|
+
const fingerprint = createHash("sha256").update(JSON.stringify([head ?? null, [...entries].sort()])).digest("hex");
|
|
249
|
+
return { ...(head === undefined ? {} : { head }), entries, fingerprint };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Commits and changed paths between two snapshots of the same checkout. */
|
|
253
|
+
export async function reportCheckoutChanges(cwd: string, before: CheckoutSnapshot, after: CheckoutSnapshot, declared?: readonly string[]): Promise<ChangeReport> {
|
|
254
|
+
const changed = new Set([...before.entries.keys(), ...after.entries.keys()].filter((path) => before.entries.get(path) !== after.entries.get(path)));
|
|
255
|
+
const report: ChangeReport = { commits: [], omittedCommits: 0, paths: [], omittedPaths: 0 };
|
|
256
|
+
const short = (oid: string) => oid.slice(0, 12);
|
|
257
|
+
if (before.head !== after.head && after.head === undefined) report.headMove = `HEAD moved from ${short(before.head!)} to an unborn branch`;
|
|
258
|
+
else if (before.head !== after.head) {
|
|
259
|
+
const range = before.head === undefined ? [after.head!] : [`${before.head}..${after.head}`];
|
|
260
|
+
const total = Number((await git(cwd, ["rev-list", "--count", ...range, "--"])).stdout.trim());
|
|
261
|
+
const log = (await git(cwd, ["log", "--no-show-signature", `--max-count=${CHANGE_REPORT_LIMITS.commits}`, "--format=%h%x00%s", ...range, "--"])).stdout;
|
|
262
|
+
report.commits = log.split("\n").filter(Boolean).map((line) => {
|
|
263
|
+
const [oid, subject = ""] = line.split("\0");
|
|
264
|
+
return { oid: oid!, subject: Array.from(subject).slice(0, CHANGE_REPORT_LIMITS.subject).join("") };
|
|
265
|
+
});
|
|
266
|
+
report.omittedCommits = Math.max(0, total - report.commits.length);
|
|
267
|
+
const names = before.head === undefined
|
|
268
|
+
? await git(cwd, ["ls-tree", "-r", "--name-only", "-z", "--full-tree", after.head!])
|
|
269
|
+
: await git(cwd, ["diff", "--name-only", "-z", "--no-renames", "--no-ext-diff", "--no-textconv", before.head, after.head!, "--"]);
|
|
270
|
+
for (const path of names.stdout.split("\0")) if (path) changed.add(path);
|
|
271
|
+
if (before.head !== undefined && (await git(cwd, ["merge-base", "--is-ancestor", before.head, after.head!], [0, 1])).code === 1) {
|
|
272
|
+
report.headMove = `HEAD moved from ${short(before.head)} to ${short(after.head!)}, which does not descend from it`;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const outside = (path: string) => declared !== undefined && !declared.some((glob) => posix.matchesGlob(path, glob));
|
|
276
|
+
// Outside paths come first so the cap never hides them.
|
|
277
|
+
const paths = [...changed].sort().map((path) => outside(path) ? { path, outside: true as const } : { path })
|
|
278
|
+
.sort((left, right) => Number(Boolean(right.outside)) - Number(Boolean(left.outside)));
|
|
279
|
+
report.paths = paths.slice(0, CHANGE_REPORT_LIMITS.paths);
|
|
280
|
+
report.omittedPaths = paths.length - report.paths.length;
|
|
281
|
+
return report;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Compare a task's launch snapshot with the checkout at its terminal outcome. Failures become a note, never a task failure. */
|
|
285
|
+
export async function checkoutNote(cwd: string, before: CheckoutSnapshot | Error | undefined, write: boolean, declared?: readonly string[]): Promise<CheckoutNote | undefined> {
|
|
286
|
+
try {
|
|
287
|
+
if (before instanceof Error) throw before;
|
|
288
|
+
if (before === undefined) return;
|
|
289
|
+
const after = await snapshotCheckout(cwd);
|
|
290
|
+
if (after === undefined) throw new Error("the checkout is no longer a Git repository");
|
|
291
|
+
if (write) return { kind: "changes", report: await reportCheckoutChanges(cwd, before, after, declared) };
|
|
292
|
+
return after.fingerprint === before.fingerprint ? undefined : { kind: "stale" };
|
|
293
|
+
} catch (error) {
|
|
294
|
+
return { kind: "unavailable", check: write ? "change report" : "stale-read check", reason: Array.from(error instanceof Error ? error.message : String(error)).slice(0, CHANGE_REPORT_LIMITS.reason).join("") };
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export type WorkflowEntry = { id: string; index: number; delegation: Delegation };
|
|
299
|
+
export function identifyWorkflowEntries(toolCallId: string, workflow: ParsedWorkflow): WorkflowEntry[] {
|
|
300
|
+
return workflow.delegations.map((delegation, index) => ({ id: `${toolCallId}:${workflow.mode}:${index}`, index, delegation }));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export type DelegationRunner = (entry: WorkflowEntry) => string | Promise<string>;
|
|
304
|
+
|
|
305
|
+
async function runEntry(entry: WorkflowEntry, run: DelegationRunner): Promise<string | undefined> {
|
|
306
|
+
try {
|
|
307
|
+
return await run(entry);
|
|
308
|
+
} catch {
|
|
309
|
+
return undefined;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export async function runForegroundWorkflow(
|
|
314
|
+
mode: WorkflowMode,
|
|
315
|
+
entries: readonly WorkflowEntry[],
|
|
316
|
+
run: DelegationRunner,
|
|
317
|
+
signal?: AbortSignal,
|
|
318
|
+
): Promise<void> {
|
|
319
|
+
signal?.throwIfAborted();
|
|
320
|
+
if (mode === "single" || mode === "parallel") {
|
|
321
|
+
await Promise.all(entries.map((entry) => runEntry(entry, run)));
|
|
322
|
+
signal?.throwIfAborted();
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
let previous = "";
|
|
326
|
+
for (const entry of entries) {
|
|
327
|
+
const selected = {
|
|
328
|
+
...entry,
|
|
329
|
+
delegation: { ...entry.delegation, task: entry.delegation.task.replaceAll("{previous}", () => previous) },
|
|
330
|
+
};
|
|
331
|
+
const output = await runEntry(selected, run);
|
|
332
|
+
signal?.throwIfAborted();
|
|
333
|
+
if (output === undefined) break;
|
|
334
|
+
previous = output;
|
|
335
|
+
}
|
|
336
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,86 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hcflabs/pi-subagent",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "27.10.7",
|
|
4
|
+
"description": "Delegate bounded direct work or durable checked isolated task graphs to Pi roles.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi",
|
|
8
|
+
"subagent",
|
|
9
|
+
"delegation"
|
|
10
|
+
],
|
|
11
|
+
"type": "module",
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=22.19.0"
|
|
14
|
+
},
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"files": [
|
|
17
|
+
"LICENSE",
|
|
18
|
+
"dist",
|
|
19
|
+
"extensions",
|
|
20
|
+
"docs",
|
|
21
|
+
"examples",
|
|
22
|
+
"prompts",
|
|
23
|
+
"example.png",
|
|
24
|
+
"README.md",
|
|
25
|
+
"CONTEXT.md",
|
|
26
|
+
"skills"
|
|
27
|
+
],
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"import": "./dist/index.js"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "tsc --project tsconfig.build.json",
|
|
37
|
+
"pretypecheck": "npm run build",
|
|
38
|
+
"test": "npm run build && node --test test/*.test.ts",
|
|
39
|
+
"typecheck": "tsc --noEmit --allowImportingTsExtensions --target ES2022 --module NodeNext --moduleResolution NodeNext --skipLibCheck src/*.ts extensions/*.ts test/*.test.ts",
|
|
40
|
+
"prepack": "npm run build",
|
|
41
|
+
"pack:check": "npm pack --dry-run"
|
|
42
|
+
},
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"@earendil-works/pi-ai": "^1.3.0",
|
|
45
|
+
"@earendil-works/pi-coding-agent": "^1.3.0",
|
|
46
|
+
"@earendil-works/pi-tui": "^1.3.0",
|
|
47
|
+
"typebox": "^1.3.15"
|
|
48
|
+
},
|
|
49
|
+
"repository": {
|
|
50
|
+
"type": "git",
|
|
51
|
+
"url": "git+https://github.com/hcflabs/pi-harness.git",
|
|
52
|
+
"directory": "extensions/pi-subagent"
|
|
53
|
+
},
|
|
54
|
+
"bugs": {
|
|
55
|
+
"url": "https://github.com/hcflabs/pi-harness/issues"
|
|
56
|
+
},
|
|
57
|
+
"publishConfig": {
|
|
58
|
+
"access": "public"
|
|
59
|
+
},
|
|
60
|
+
"pi": {
|
|
61
|
+
"extensions": [
|
|
62
|
+
"./extensions/subagent.ts"
|
|
63
|
+
],
|
|
64
|
+
"skills": [
|
|
65
|
+
"./skills"
|
|
66
|
+
],
|
|
67
|
+
"prompts": [
|
|
68
|
+
"./prompts"
|
|
69
|
+
],
|
|
70
|
+
"image": "https://raw.githubusercontent.com/hcflabs/pi-harness/main/extensions/pi-subagent/example.png"
|
|
71
|
+
},
|
|
72
|
+
"dependencies": {
|
|
73
|
+
"@hcflabs/pi-config-store": "^4.0.0",
|
|
74
|
+
"@hcflabs/pi-herdr": "^0.5.0",
|
|
75
|
+
"@hcflabs/pi-multi-codex": "^3.0.0",
|
|
76
|
+
"@hcflabs/pi-process": "^0.2.0",
|
|
77
|
+
"@hcflabs/pi-task-models": "^9.0.0",
|
|
78
|
+
"proper-lockfile": "^4.1.2"
|
|
79
|
+
},
|
|
80
|
+
"devDependencies": {
|
|
81
|
+
"@earendil-works/pi-ai": "1.3.0",
|
|
82
|
+
"@earendil-works/pi-coding-agent": "1.3.0",
|
|
83
|
+
"@earendil-works/pi-tui": "1.3.0",
|
|
84
|
+
"@types/proper-lockfile": "^4.1.4"
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Review a change with parallel reviewer Subagents and give one ship verdict.
|
|
3
|
+
argument-hint: "[A..B | branch | commit | #PR] [focus]"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Review this target with parallel `reviewer` Subagents: $@
|
|
7
|
+
|
|
8
|
+
Do not edit files, fix findings, commit, or push.
|
|
9
|
+
|
|
10
|
+
1. Resolve the first argument to exact local commit IDs with Git. Treat the remaining text as extra review focus. If the first argument is not `#N` and does not resolve as a ref or range, use the empty target and treat all text as focus.
|
|
11
|
+
- Empty: uncommitted staged, unstaged, and untracked changes against `HEAD`.
|
|
12
|
+
- `A..B`: base `A`, tip `B`.
|
|
13
|
+
- Branch: base is `git merge-base main <branch>`, tip is the branch.
|
|
14
|
+
- Commit: base is its first parent, tip is the commit.
|
|
15
|
+
- `#N`: run `gh pr view N --json url,baseRefName`. The base repository URL is `url` without `/pull/N`. Run `git fetch <repository URL> +refs/heads/<baseRefName>:refs/pi-review/pr-N-base +refs/pull/N/head:refs/pi-review/pr-N`. Base is the merge-base of the two refs; tip is `refs/pi-review/pr-N`. Reviewers read only local Git, so fetch before delegation.
|
|
16
|
+
If the target is ambiguous or has no changes, stop and report.
|
|
17
|
+
2. List the changed files, and mark untracked files. Group them by locality: same directory or module, related behavior, and tests with their implementation. Use one group for a small change and at most 5 groups.
|
|
18
|
+
3. Start one `delegate_task` call with `mode: "direct"` and one `tasks` entry per group: `role: "reviewer"`, a short unique `name` (at most 29 characters), `access: "read"`, and a `task` that gives the base and tip commit IDs, its exact file list, and the criteria. For uncommitted changes, give `HEAD` <ID> instead, and tell the reviewer to combine `git_read` diff in `staged` and `working` modes and to read each marked untracked file whole with `read`. Criteria: report actionable correctness risks that the change introduces in these files, with the reviewer finding format; read other files only as context; output exactly `PASS` when there are no findings. Add the extra focus, if any.
|
|
19
|
+
4. When the result arrives, merge the findings: remove duplicates that share a root cause and keep the highest priority, then sort by priority (`P0`, `P1`, `P2`) and confidence. Keep file:line evidence and fixes.
|
|
20
|
+
5. Give one verdict. **Ship** only if every reviewer task succeeded and answered exactly `PASS`. Otherwise **Do not ship**, with the merged findings and any failed or incomplete reviewer task. If any result reports `checkout changed during task`, the verdict is **Do not ship**; rerun the review.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pi-subagent
|
|
3
|
+
description: Delegate bounded direct work or run durable checked isolated task graphs.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Choose and authorize delegation
|
|
7
|
+
|
|
8
|
+
Use this Skill only from Main.
|
|
9
|
+
|
|
10
|
+
Record the requested outcome, allowed scope and exclusions, local delegation/check/review/integration permission, and any external-action permission before substantial work. A clear small request can supply this authorization directly. Ask again only for a material scope or contract change, a missing decision or authorization, a user-owned conflict, or a consequential action outside that scope.
|
|
11
|
+
|
|
12
|
+
Keep trivial, mechanically verifiable work in Main. Choose the user-requested mode when one is explicit. Otherwise:
|
|
13
|
+
|
|
14
|
+
- Use `mode: direct` for bounded research, analysis, review, or explicitly authorized shared-checkout writes and commits using the selected Role's resources.
|
|
15
|
+
- Use `mode: isolated` for independently checked changes, or an exploratory or discardable candidate. A single isolated task is valid.
|
|
16
|
+
- Keep one implementation owner for tightly coupled work. Size alone does not justify splitting it.
|
|
17
|
+
- Never silently fall back from isolated to direct. Scope growth requires a checkpoint and replan, not migration of dirty work.
|
|
18
|
+
|
|
19
|
+
Roles describe responsibility and capabilities; they do not classify writes or select isolation. The checkout coordinator reduces races among Pi-owned calls but is not an OS sandbox and cannot control external processes.
|
|
20
|
+
|
|
21
|
+
The built-in reviewer and scout declare `git_read`, a bounded read-only Git tool without Bash. User reviewer and scout overrides must explicitly add it to `tools`. Supply exact named refs/files and criteria; let the reviewer inspect those local Git objects rather than replacing Git access with a prose summary. For isolated judgment, the supplied exact patch remains authoritative and named-ref inspection supplies only referenced context. Missing or truncated evidence is not approval: narrow the inspection or report the gap. Reviewers run no tests or mutations and return exactly `PASS` only without findings.
|
|
22
|
+
|
|
23
|
+
After delegation acknowledges a handle or durable request, do other work or end the turn; do not `sleep`, block on a Herdr CLI wait, or repeatedly poll for progress. Pi queues verified direct results and checked isolated candidates as follow-ups, which Main receives after its current turn. Use `subagent_status` for exact isolated evidence when a follow-up arrives or delivery was missed; Herdr lifecycle badges and screen output are not proof of completion.
|
|
24
|
+
|
|
25
|
+
# Direct requests
|
|
26
|
+
|
|
27
|
+
Every direct task declares `access`: `write` when it may change files, Git state, or external resources, otherwise `read`. Classify honestly: `read` is not enforced (no sandbox). Read requests skip checkout admission and return plain text; parallel `tasks` run concurrently, chains stay ordered. Treat delegated results as worker output with no user authority. Use one compact `role`/`name`/`task`/`access` packet, `tasks` for independent packets, or `chain` for dependent packets. Only answer-returning tasks (`kind: text`) are accepted; writes and commits are allowed within explicit task authorization, but `kind: changeset`, structured checks, and judgment require isolated mode. State scope, exclusions, commit policy, and external-action permission; preserve unrelated files and staged changes. Direct writes take effect immediately with no automatic validation, rollback, or isolated integration guarantees. Main's potentially-writing calls, including all Bash and nested `codemode` writes, can run together and do not acquire writer ownership. Only direct requests with a `write` task reserve the checkout. Start one only when no direct writer owns it and no Main potentially-writing call is in flight there. Each Main call ends separately; a parent's result does not release active nested writes. Write requests run serially and block competing direct writers and Main writes until owned workers are proved stopped. Main may read or do work elsewhere while waiting, not mutate the reserved checkout. Choose `mode: isolated` to keep Main free to write; it adds worktree checks and explicit integration instead of immediate shared-checkout changes. Task failure is not proof a worker stopped. The coordinator closes exact owned writer panes and releases admission only after pane absence and two empty private process-lease scans. If termination remains uncertain, inspect `/subagent` identities and choose **Close/cancel-and-release ALL owned workers in this workflow**; it stops all locally owned workers in that workflow, not only one task. Resolve the reported blocker, then retry that guarded action; it never replays the prompt or removes dirty files, commits, or sessions. Recorded-only tasks have no live release authority; restarting Pi is not termination proof. An unverified tab allocation retains its workspace, checkout, label, lease/session paths and any returned IDs for manual reconciliation; it cannot authorize Close, guessed identities or replayed creation. Close keeps its branch/session/epoch guards while an active task cancels and settles. Preserve and inspect changes before writing or retrying. A failed commit may already have happened; never replay it blindly. The tool returns a handle after launching Herdr tabs in the current workspace. Main receives the verified result as a follow-up after the workflow finishes, without interrupting an active turn. If observation stops, open `/subagent` to inspect exact recorded tabs and session files on this session branch; recorded does not prove still running.
|
|
28
|
+
|
|
29
|
+
To build on a finished direct worker, add `continueFrom` with its reference (such as `D7.1`) and the same `role`. The worker resumes its saved Pi session in a new tab, so continue it for follow-up questions, corrections, or the next step on the same evidence. Start a fresh task for unrelated work, a different Role, or when the earlier context is stale, wrong, or large. Continuation fails before launch for an unknown reference, a different Role, a worker still owned by an active task, a missing or oversized session, an unfinished last turn, or a busy old agent; do not retry it blindly. The user can stop one running worker of a read-only workflow with **Stop this worker** in `/subagent`; that entry fails with `Stopped by user from /subagent` and its siblings continue.
|
|
30
|
+
|
|
31
|
+
# Isolated requests
|
|
32
|
+
|
|
33
|
+
Give the request a unique ID and goal. Give each task a unique ID, kind, Role, model class, bounded requirements, deliverable, `dependsOn`, and `contextFrom`. Changesets require focused task checks, and a graph containing changesets requires final checks. The root must have a `package.json` test script; `pnpm test` is added to final checks if missing and runs against the combined tip before Main promotion. Text context may name only completed text tasks, preserves declared order, and implies a scheduling dependency; do not repeat an edge in `dependsOn` and `contextFrom`.
|
|
34
|
+
|
|
35
|
+
Every changeset becomes ready automatically after preliminary checks pass. While a locally owned changeset task is actively working, the user may open `/subagent` to queue bounded same-worker revisions or withdraw all still-pending instructions into the native editor and submit one replacement. Cancellation after withdrawal leaves the originals removed; already-claimed instructions cannot be recalled. Each revision must produce a new clean commit and rerun preliminary checks. If a worker's turn ends with uncommitted changes, the runner sends it one commit reminder per attempt; this uses no correction, and a worktree still dirty afterwards needs attention. When no queued revision remains, the runner seals and durably records the exact ready candidate without waiting for sibling workers. There is no guaranteed post-completion editing window, and late follow-ups fail visibly.
|
|
36
|
+
|
|
37
|
+
A ready candidate does not integrate automatically. Each checked worker triggers a follow-up while its siblings continue; inspect `subagent_status` and immediately use `subagent_stage` with the exact `generation`, `taskId`, `attempt`, candidate `tip`, and `expectedTip` to select and merge it into the owned integration checkout. Stage and resolve are available during the active wave; wait for the wave to settle before rejecting or revising. Resolve conflicts in the integration checkout and verify with `resolve`; never edit Main to resolve them. For dependents, `subagent_integrate advance` acknowledges a durable background wave, then reports checked candidates as they finish. When selected work is staged and the wave settles, use `subagent_integrate validate` on the exact `combinedTip`: it acknowledges saved intent, runs full root checks and optional final judgment in the session-owned background, then sends a follow-up. Acknowledgement is not passing evidence; `promote` requires passing evidence and unchanged Main. One Main-authored committed correction to a definitively failed combined validation can be recorded with `correct`, then revalidated. Only proven promotion triggers selected-worker termination and verified cleanup. Failures, ambiguity, conflicts, interruption, child-limit exhaustion, or Main drift retain work and never waive checks or identity guards. The extension never pushes, opens a pull request, publishes, or deploys.
|
|
38
|
+
|
|
39
|
+
After a coordinator interruption in the canonical repository, ask the user to run `/subagent recover` once; if another productive run holds the lease, wait and run it again. It acquires the productive lease, classifies interrupted work, preserves invalid files and retained work, and sends Main one follow-up with blockers and next actions. A live productive lease is read-only and cannot be taken over. Recovery never replays prompts, merges, promotion, or cleanup. For leftover merged worker or integration checkouts, ask the user to run `/subagent prune`; it removes only reviewed, confirmed, merged, clean, unowned items. Use `subagent_status` to inspect exact durable state without mutation, including candidate identities, generations, and retained resources. `subagent_resume` handles only the reported retry/verify/finalize continuation; it cannot replace Main's stage/integrate decisions or reset policy/corrections. For a retry that sends a same-worker correction, pass `instruction` to name the exact fixes, including the review or check findings the worker must address; the correction prompt carries only a short saved failure. Follow-ups show a short preview of a failed review verdict; `subagent_status` returns it in full. `subagent_stage reject` or `revise` freezes a generation containing the candidate and invalidates dependent attempts; restage chosen work in a new generation. If Main advances cleanly before promotion, use `subagent_integrate refresh` with exact old/new Main and combined-tip identities; restage immutable candidates into the new generation and rerun combined validation. Dirty or divergent Main blocks refresh. For interrupted validation, prove old coordinator/check processes stopped, then guarded `reconcile` classifies the exact generation; never resume a live lease. A `validation_failed` generation without checks, review, or a prior combined correction can `refresh` with identical `expectedMain`/`newMain`, the old exact combined `expectedTip`, and the next generation number. Main and the retained integration tip must be exact and clean. Restage immutable candidates explicitly and rerun all final gates in the new checkout; never replay old uncertain checks, infer passing evidence, or reset correction allowances. At most two unreleased integration worktrees may coexist. Rejected candidates and superseded generations remain retained until Main explicitly calls `subagent_integrate release` with the exact generation and tip; for a rejected worker include its task ID and attempt after releasing all generations using it. Dirty/conflicted or unproved resources block release without force deletion. Impossible fresh dependent work after rejection needs a new request. `subagent_abort` cannot discard retained candidates or integration generations; release them first. For workers without candidates, abort cleans only proven unchanged, clean owned resources. If cleanup is deferred or blocked, inspect status and repeat abort after resolving the blocker. Dirty or committed work and uncertain allocations remain for manual recovery. To delete owned isolated worktrees, ask the user to run `/subagent` and choose **Close and delete worktrees (discard work)**; agents cannot trigger it. Old v4 state is rejected (current state is v5), not migrated. Status, abort, process I/O, termination, and verified post-promotion cleanup keep finite safety budgets.
|
|
40
|
+
|
|
41
|
+
All launches use the global policy in `config/pi-subagent/config.json`: productive concurrency, per-child turns/tokens, and idle timeout. Ephemeral children also have a hard runtime limit; isolated requests have a correction cap. Productive requests have no whole-run wall-clock deadline. Request fields cannot replenish or override limits.
|