@henryqw/pi-subagent 27.5.4 → 27.6.1

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.
@@ -1,8 +1,13 @@
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";
1
5
  import { StringEnum } from "@earendil-works/pi-ai";
2
6
  import { DISPLAY_TEXT_CONTRACT } from "@henryqw/pi-subagent";
3
7
  import { PROFILE_NAMES } from "@henryqw/pi-task-models";
4
8
  import { Type, type Static } from "typebox";
5
9
  import { Check, Errors } from "typebox/value";
10
+ import { runGit } from "../dist/git-process.js";
6
11
  import { ExecuteRequestSchema, parseExecuteRequest, type ExecuteRequest } from "../dist/schema.js";
7
12
 
8
13
  export const MAX_WORKFLOW_ENTRIES = 8;
@@ -25,13 +30,21 @@ export const DIRECT_ACCESS = ["read", "write"] as const;
25
30
  export type DirectAccess = (typeof DIRECT_ACCESS)[number];
26
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.`;
27
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." });
28
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." });
29
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
+
30
42
  export const DelegationSchema = Type.Object({
31
43
  role: RoleSchema,
32
44
  name: TaskNameSchema,
33
45
  task: TaskSchema,
34
46
  access: AccessSchema,
47
+ paths: Type.Optional(PathsSchema),
35
48
  continueFrom: Type.Optional(ContinueFromSchema),
36
49
  kind: Type.Optional(DirectKindSchema),
37
50
  model: Type.Optional(ModelSchema),
@@ -44,6 +57,7 @@ export const DirectWorkflowSchema = Type.Object({
44
57
  name: Type.Optional(TaskNameSchema),
45
58
  task: Type.Optional(TaskSchema),
46
59
  access: Type.Optional(AccessSchema),
60
+ paths: Type.Optional(PathsSchema),
47
61
  continueFrom: Type.Optional(ContinueFromSchema),
48
62
  kind: Type.Optional(DirectKindSchema),
49
63
  model: Type.Optional(ModelSchema),
@@ -64,6 +78,7 @@ export const DelegateTaskParameters = Type.Object({
64
78
  ])),
65
79
  finalChecks: ExecuteRequestSchema.properties.finalChecks,
66
80
  finalJudgment: ExecuteRequestSchema.properties.finalJudgment,
81
+ dryRun: Type.Optional(DryRunSchema),
67
82
  }, { additionalProperties: false });
68
83
 
69
84
  export type Delegation = Static<typeof DelegationSchema> & { kind: "text" };
@@ -72,10 +87,10 @@ export type ParsedWorkflow =
72
87
  | { mode: "single"; delegations: [Delegation] }
73
88
  | { mode: "parallel"; delegations: Delegation[] }
74
89
  | { mode: "chain"; delegations: Delegation[] };
75
- export type ParsedDelegateTask = { mode: "direct"; workflow: ParsedWorkflow } | { mode: "isolated"; request: ExecuteRequest };
90
+ export type ParsedDelegateTask = ({ mode: "direct"; workflow: ParsedWorkflow } | { mode: "isolated"; request: ExecuteRequest }) & { dryRun: boolean };
76
91
 
77
92
  type DirectInput = Static<typeof DirectWorkflowSchema>;
78
- const DELEGATION_KEYS = ["role", "name", "task", "access", "continueFrom", "kind", "model", "modelClass"] as const;
93
+ const DELEGATION_KEYS = ["role", "name", "task", "access", "paths", "continueFrom", "kind", "model", "modelClass"] as const;
79
94
 
80
95
  function text(value: string, path: string): string {
81
96
  const normalized = value.trim();
@@ -89,12 +104,24 @@ function normalizeTaskName(value: string, path: string): string {
89
104
  return normalized;
90
105
  }
91
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
+
92
117
  function normalizeDelegation(value: Static<typeof DelegationSchema>, path: string): Delegation {
118
+ const paths = normalizePaths(value, path);
93
119
  return {
94
120
  role: text(value.role, `${path}.role`),
95
121
  name: normalizeTaskName(value.name, `${path}.name`),
96
122
  task: text(value.task, `${path}.task`),
97
123
  access: value.access,
124
+ ...(paths === undefined ? {} : { paths }),
98
125
  ...(value.continueFrom === undefined ? {} : { continueFrom: value.continueFrom }),
99
126
  kind: "text",
100
127
  ...(value.model === undefined ? {} : { model: text(value.model, `${path}.model`) }),
@@ -148,11 +175,124 @@ export function parseWorkflow(value: unknown): ParsedWorkflow {
148
175
  /** A direct request is a writer request when any task declares write access. */
149
176
  export const writesCheckout = (workflow: ParsedWorkflow): boolean => workflow.delegations.some(({ access }) => access === "write");
150
177
 
178
+ /** `dryRun` is a tool-call option, not part of the direct workflow or the durable isolated request. */
151
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
+ }
152
187
  if (value && typeof value === "object" && !Array.isArray(value) && (value as { mode?: unknown }).mode === "isolated") {
153
- return { mode: "isolated", request: parseExecuteRequest(value) };
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("") };
154
295
  }
155
- return { mode: "direct", workflow: parseWorkflow(value) };
156
296
  }
157
297
 
158
298
  export type WorkflowEntry = { id: string; index: number; delegation: Delegation };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-subagent",
3
- "version": "27.5.4",
3
+ "version": "27.6.1",
4
4
  "description": "Delegate bounded direct work or durable checked isolated task graphs to Pi roles.",
5
5
  "keywords": [
6
6
  "pi-package",