@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.
- 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 +589 -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
package/dist/prune.js
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
2
|
+
import { basename, join, sep } from "node:path";
|
|
3
|
+
import { createHerdrClient } from "@hcflabs/pi-herdr";
|
|
4
|
+
import { runGit } from "./git-process.js";
|
|
5
|
+
import { runProcess } from "./process.js";
|
|
6
|
+
import { inspectWorktreeDirty, OWNED_BRANCH_PATTERN, WORKTREES_DIRNAME } from "./worktree.js";
|
|
7
|
+
const HERDR_TIMEOUT_MS = 30_000;
|
|
8
|
+
const IN_PROGRESS_MARKERS = ["MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD", "REBASE_HEAD", "BISECT_LOG", "rebase-merge", "rebase-apply", "sequencer"];
|
|
9
|
+
/** Blockers that a separately confirmed force removal of an unowned item may override; every other kind is hard. */
|
|
10
|
+
export const FORCEABLE_PRUNE_BLOCKERS = ["unmerged", "changes", "operation", "submodules"];
|
|
11
|
+
const forceable = (kind) => FORCEABLE_PRUNE_BLOCKERS.includes(kind);
|
|
12
|
+
export async function requireGit(run, args, cwd) {
|
|
13
|
+
const result = await run(args, cwd);
|
|
14
|
+
if (result.code !== 0)
|
|
15
|
+
throw new Error(`git ${args[0]} failed (exit ${result.code}): ${result.stderr.trim().slice(0, 200)}`);
|
|
16
|
+
return result.stdout.trimEnd();
|
|
17
|
+
}
|
|
18
|
+
export const within = (directory, path) => path === directory || path.startsWith(`${directory}${sep}`);
|
|
19
|
+
/** Every pane in every Herdr workspace with its shell and foreground working directories. */
|
|
20
|
+
export async function listHerdrPanes(cwd) {
|
|
21
|
+
const response = await createHerdrClient(runProcess).json(["pane", "list"], { cwd, timeoutMs: HERDR_TIMEOUT_MS });
|
|
22
|
+
const result = response.result;
|
|
23
|
+
if (result?.type !== "pane_list" || !Array.isArray(result.panes))
|
|
24
|
+
throw new Error("Herdr pane list response is malformed.");
|
|
25
|
+
return result.panes.map((pane) => {
|
|
26
|
+
const cwds = [pane.cwd, pane.foreground_cwd].filter((cwd) => typeof cwd === "string");
|
|
27
|
+
if (typeof pane.pane_id !== "string" || !cwds.length)
|
|
28
|
+
throw new Error("Herdr listed a pane without an ID or working directory.");
|
|
29
|
+
return { paneId: pane.pane_id, cwds };
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
async function snapshot(root, mainCwd, run) {
|
|
33
|
+
const checkouts = [];
|
|
34
|
+
let current;
|
|
35
|
+
for (const field of (await requireGit(run, ["worktree", "list", "--porcelain", "-z"], root)).split("\0")) {
|
|
36
|
+
if (field.startsWith("worktree "))
|
|
37
|
+
checkouts.push(current = { path: field.slice("worktree ".length), locked: false, missing: false });
|
|
38
|
+
else if (current && field.startsWith("branch refs/heads/"))
|
|
39
|
+
current.branch = field.slice("branch refs/heads/".length);
|
|
40
|
+
else if (current && (field === "locked" || field.startsWith("locked ")))
|
|
41
|
+
current.locked = true;
|
|
42
|
+
else if (current && (field === "prunable" || field.startsWith("prunable ")))
|
|
43
|
+
current.missing = true;
|
|
44
|
+
}
|
|
45
|
+
if (!checkouts[0])
|
|
46
|
+
throw new Error("Primary Git worktree is unavailable.");
|
|
47
|
+
const branches = new Map();
|
|
48
|
+
for (const line of (await requireGit(run, ["for-each-ref", "--format=%(refname:lstrip=2) %(objectname)", "refs/heads/"], root)).split("\n")) {
|
|
49
|
+
const [branch, tip] = line.split(" ");
|
|
50
|
+
if (branch && tip && OWNED_BRANCH_PATTERN.test(branch))
|
|
51
|
+
branches.set(branch, tip);
|
|
52
|
+
}
|
|
53
|
+
const targets = [{ label: "Main HEAD", head: await requireGit(run, ["rev-parse", "--verify", "HEAD^{commit}"], mainCwd) }];
|
|
54
|
+
const origin = await run(["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"], root);
|
|
55
|
+
if (origin.code === 0) {
|
|
56
|
+
const ref = origin.stdout.trim();
|
|
57
|
+
targets.push({ label: ref.replace(/^refs\/remotes\//, ""), head: await requireGit(run, ["rev-parse", "--verify", `${ref}^{commit}`], root) });
|
|
58
|
+
}
|
|
59
|
+
return { primary: checkouts[0].path, checkouts, branches, targets };
|
|
60
|
+
}
|
|
61
|
+
async function classify(snap, branch, mainCwd, ownership, panes, run, root) {
|
|
62
|
+
const tip = snap.branches.get(branch);
|
|
63
|
+
const name = basename(branch);
|
|
64
|
+
const ownedPath = join(snap.primary, WORKTREES_DIRNAME, name);
|
|
65
|
+
// A detached or switched owned checkout no longer names the branch; find it by its owned path too.
|
|
66
|
+
const checkout = snap.checkouts.find((item) => item.branch === branch) ?? snap.checkouts.find((item) => item.path === ownedPath);
|
|
67
|
+
const blockers = [];
|
|
68
|
+
const block = (kind, text) => blockers.push({ kind, text });
|
|
69
|
+
if (checkout && checkout.branch !== branch)
|
|
70
|
+
block("hard", `owned checkout is ${checkout.branch ? `on branch ${checkout.branch}` : "detached"}; inspect it manually`);
|
|
71
|
+
let ignored = [];
|
|
72
|
+
const owners = ownership.owners(name);
|
|
73
|
+
for (const { label } of owners)
|
|
74
|
+
block("owner", label);
|
|
75
|
+
if (ownership.lease)
|
|
76
|
+
block("lease", ownership.lease);
|
|
77
|
+
let merged;
|
|
78
|
+
for (const target of snap.targets) {
|
|
79
|
+
const result = await run(["merge-base", "--is-ancestor", tip, target.head], root);
|
|
80
|
+
if (result.code === 0) {
|
|
81
|
+
merged = target.label;
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
if (result.code !== 1)
|
|
85
|
+
throw new Error(`git merge-base failed (exit ${result.code}): ${result.stderr.trim().slice(0, 200)}`);
|
|
86
|
+
}
|
|
87
|
+
if (!merged)
|
|
88
|
+
block("unmerged", `branch has commits not in ${snap.targets.map(({ label }) => label).join(" or ")}`);
|
|
89
|
+
if (checkout) {
|
|
90
|
+
const path = checkout.path;
|
|
91
|
+
if (path !== ownedPath)
|
|
92
|
+
block("hard", "checkout is not at its owned .worktrees path");
|
|
93
|
+
if (checkout.locked)
|
|
94
|
+
block("hard", "checkout is locked");
|
|
95
|
+
if (checkout.missing || !existsSync(path))
|
|
96
|
+
block("hard", "checkout directory is missing; inspect it, then run git worktree prune");
|
|
97
|
+
else {
|
|
98
|
+
const real = realpathSync.native(path);
|
|
99
|
+
if (within(real, realpathSync.native(mainCwd)))
|
|
100
|
+
block("hard", "Main runs inside the checkout");
|
|
101
|
+
const gitDir = await requireGit(run, ["rev-parse", "--absolute-git-dir"], path);
|
|
102
|
+
const markers = IN_PROGRESS_MARKERS.filter((marker) => existsSync(join(gitDir, marker)));
|
|
103
|
+
if (markers.length)
|
|
104
|
+
block("operation", `Git operation in progress (${markers.join(", ")})`);
|
|
105
|
+
const dirty = await inspectWorktreeDirty(path, run, false);
|
|
106
|
+
if (dirty.failure)
|
|
107
|
+
block("hard", `checkout could not be inspected: ${dirty.failure}`);
|
|
108
|
+
else if (dirty.dirty)
|
|
109
|
+
block("changes", "checkout has tracked or untracked changes");
|
|
110
|
+
else if (dirty.initializedSubmodules)
|
|
111
|
+
block("submodules", "checkout has initialized submodules; Git cannot remove it without force");
|
|
112
|
+
if (typeof panes === "string")
|
|
113
|
+
block("hard", `Herdr unavailable (${panes}); cannot prove no pane uses the checkout`);
|
|
114
|
+
else {
|
|
115
|
+
const used = panes.filter((pane) => pane.cwds.some((cwd) => within(path, cwd) || within(real, cwd)));
|
|
116
|
+
if (used.length)
|
|
117
|
+
block("hard", `Herdr pane ${used.map(({ paneId }) => paneId).join(", ")} works inside the checkout`);
|
|
118
|
+
}
|
|
119
|
+
ignored = (await requireGit(run, ["status", "--porcelain=v1", "-z", "--ignored=matching", "--untracked-files=normal"], path))
|
|
120
|
+
.split("\0").filter((entry) => entry.startsWith("!! ")).map((entry) => entry.slice(3));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
// The owning request's discard review owns its other blockers; a lease or any second owner keeps the item.
|
|
124
|
+
const owner = owners.length === 1 ? owners[0].request : undefined;
|
|
125
|
+
const override = !blockers.length ? undefined : owner && !blockers.some(({ kind }) => kind === "lease") ? "discard"
|
|
126
|
+
: blockers.every(({ kind }) => forceable(kind)) ? "force" : undefined;
|
|
127
|
+
return {
|
|
128
|
+
branch, tip, ...(checkout ? { path: checkout.path } : {}), prunable: !blockers.length, ignored, blockers,
|
|
129
|
+
...(owner ? { owner } : {}), ...(override ? { override } : {}),
|
|
130
|
+
reason: blockers.length ? blockers.map(({ text }) => text).join("; ") : `merged into ${merged}; ${checkout ? "clean checkout" : "no checkout"}`,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
const existing = (snap) => snap.checkouts.filter((item) => !item.missing && existsSync(item.path)).map(({ path }) => path);
|
|
134
|
+
async function evidence(root, mainCwd, guards, run) {
|
|
135
|
+
const snap = await snapshot(root, mainCwd, run);
|
|
136
|
+
const ownership = await guards.ownership(existing(snap));
|
|
137
|
+
let panes;
|
|
138
|
+
try {
|
|
139
|
+
panes = await guards.panes();
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
panes = (error instanceof Error ? error.message : String(error)).slice(0, 200);
|
|
143
|
+
}
|
|
144
|
+
return { snap, ownership, panes };
|
|
145
|
+
}
|
|
146
|
+
/** Read-only review of every pi-subagent worker or integration branch and its checkout. */
|
|
147
|
+
export async function inspectPruneCandidates(root, mainCwd, guards, run = runGit) {
|
|
148
|
+
const { snap, ownership, panes } = await evidence(root, mainCwd, guards, run);
|
|
149
|
+
const items = [];
|
|
150
|
+
for (const branch of [...snap.branches.keys()].sort())
|
|
151
|
+
items.push(await classify(snap, branch, mainCwd, ownership, panes, run, root));
|
|
152
|
+
return items;
|
|
153
|
+
}
|
|
154
|
+
async function removeOne(root, mainCwd, reviewed, allowIgnored, force, guards, run) {
|
|
155
|
+
const skip = (reason) => ({ outcome: "skipped", item: reviewed, reason });
|
|
156
|
+
const fail = (reason) => ({ outcome: "failed", item: reviewed, reason });
|
|
157
|
+
const { snap, ownership, panes } = await evidence(root, mainCwd, guards, run);
|
|
158
|
+
if (snap.branches.get(reviewed.branch) !== reviewed.tip)
|
|
159
|
+
return skip("branch was deleted or moved since review");
|
|
160
|
+
const fresh = await classify(snap, reviewed.branch, mainCwd, ownership, panes, run, root);
|
|
161
|
+
// Force applies only to an item reviewed as forceable, and only while no blocker of a new or hard kind appears.
|
|
162
|
+
const forced = force && reviewed.override === "force";
|
|
163
|
+
if (forced ? !fresh.blockers.every(({ kind }) => forceable(kind) && reviewed.blockers.some((item) => item.kind === kind))
|
|
164
|
+
: !fresh.prunable)
|
|
165
|
+
return skip(fresh.reason);
|
|
166
|
+
if (fresh.path !== reviewed.path)
|
|
167
|
+
return skip("checkout registration changed since review");
|
|
168
|
+
if (fresh.ignored.some((entry) => !allowIgnored || !reviewed.ignored.includes(entry)))
|
|
169
|
+
return skip("ignored content was not approved or changed since review");
|
|
170
|
+
const stale = () => guards.current !== undefined && !guards.current();
|
|
171
|
+
if (fresh.path) {
|
|
172
|
+
if (stale())
|
|
173
|
+
return skip("session or branch changed before removal; reopen /subagent prune");
|
|
174
|
+
// Git removes ignored content without --force; the checks above approved it. Force also deletes reviewed changes.
|
|
175
|
+
const removed = await run(["worktree", "remove", ...(forced ? ["--force"] : []), fresh.path], root);
|
|
176
|
+
if (removed.code !== 0)
|
|
177
|
+
return fail(`git worktree remove failed: ${removed.stderr.trim().slice(0, 200)}`);
|
|
178
|
+
if (existsSync(fresh.path))
|
|
179
|
+
return fail("git worktree remove succeeded but the checkout remains");
|
|
180
|
+
}
|
|
181
|
+
if (stale())
|
|
182
|
+
return skip(`${fresh.path ? "checkout removed; " : ""}session or branch changed before branch deletion; branch kept`);
|
|
183
|
+
const deleted = await run(["update-ref", "-d", `refs/heads/${reviewed.branch}`, reviewed.tip], root);
|
|
184
|
+
if (deleted.code !== 0)
|
|
185
|
+
return fail(`${fresh.path ? "checkout removed; " : ""}branch kept: ${deleted.stderr.trim().slice(0, 200)}`);
|
|
186
|
+
return { outcome: "removed", item: reviewed, reason: fresh.reason };
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Re-verify each reviewed prunable item immediately before removing its checkout, then delete its
|
|
190
|
+
* branch only at the reviewed tip. Ignored entries are deleted only when `allowIgnored` and all of
|
|
191
|
+
* them were in the review. With `force`, a reviewed forceable item is removed with `--force` while
|
|
192
|
+
* every fresh blocker is forceable and of a reviewed kind. Returns the repository checkouts that still exist.
|
|
193
|
+
*/
|
|
194
|
+
export async function removePruneCandidates(root, mainCwd, reviewed, allowIgnored, force, guards, run = runGit) {
|
|
195
|
+
const removals = [];
|
|
196
|
+
for (const item of reviewed) {
|
|
197
|
+
try {
|
|
198
|
+
removals.push(await removeOne(root, mainCwd, item, allowIgnored, force, guards, run));
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
removals.push({ outcome: "failed", item, reason: (error instanceof Error ? error.message : String(error)).slice(0, 300) });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return { removals, checkouts: existing(await snapshot(root, mainCwd, run)) };
|
|
205
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export declare const REVIEW_MAX_PATHS = 1000;
|
|
2
|
+
export declare const REVIEW_MAX_PATCH_BYTES: number;
|
|
3
|
+
export interface PrepareExactReviewEvidenceInput {
|
|
4
|
+
base: string;
|
|
5
|
+
tip: string;
|
|
6
|
+
worktree: string;
|
|
7
|
+
}
|
|
8
|
+
export interface PreparedReviewEvidence {
|
|
9
|
+
base: string;
|
|
10
|
+
tip: string;
|
|
11
|
+
worktree: string;
|
|
12
|
+
changedPaths: string[];
|
|
13
|
+
patchPath: string;
|
|
14
|
+
cleanup: () => Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
/** Prepare one exact private patch for caller-owned review. */
|
|
17
|
+
export declare function prepareExactReviewEvidence(request: PrepareExactReviewEvidenceInput, signal?: AbortSignal): Promise<PreparedReviewEvidence>;
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { isAbsolute, join } from "node:path";
|
|
5
|
+
import { inspectIndexFlags } from "./worktree.js";
|
|
6
|
+
import { runGit as runCapturedGit } from "./git-process.js";
|
|
7
|
+
const GIT_TIMEOUT_MS = 30_000;
|
|
8
|
+
export const REVIEW_MAX_PATHS = 1_000;
|
|
9
|
+
export const REVIEW_MAX_PATCH_BYTES = 512 * 1024;
|
|
10
|
+
const STDERR_LIMIT = 200;
|
|
11
|
+
function failure(args, result) {
|
|
12
|
+
const detail = result.stderr.slice(0, STDERR_LIMIT).trim();
|
|
13
|
+
return new Error(`git ${args.join(" ")} failed with exit ${result.code}${detail ? `: ${detail}` : ""}`);
|
|
14
|
+
}
|
|
15
|
+
async function runGit(args, cwd, signal) {
|
|
16
|
+
signal?.throwIfAborted();
|
|
17
|
+
const result = await runCapturedGit(args, cwd, signal);
|
|
18
|
+
signal?.throwIfAborted();
|
|
19
|
+
if (result.code !== 0)
|
|
20
|
+
throw failure(args, result);
|
|
21
|
+
return result.stdout;
|
|
22
|
+
}
|
|
23
|
+
function line(value, field) {
|
|
24
|
+
const result = value.replace(/\r?\n$/, "");
|
|
25
|
+
if (!result || /[\r\n\0]/.test(result))
|
|
26
|
+
throw new Error(`Git returned malformed ${field}.`);
|
|
27
|
+
return result;
|
|
28
|
+
}
|
|
29
|
+
function oid(value, field) {
|
|
30
|
+
const result = line(value, field);
|
|
31
|
+
if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(result))
|
|
32
|
+
throw new Error(`Git returned invalid ${field}.`);
|
|
33
|
+
return result;
|
|
34
|
+
}
|
|
35
|
+
function requestText(value, field) {
|
|
36
|
+
if (typeof value !== "string" || !value || value.includes("\0"))
|
|
37
|
+
throw new TypeError(`${field} must be non-empty text without NUL bytes.`);
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
async function resolveCommit(reference, cwd, signal) {
|
|
41
|
+
requestText(reference, "review ref");
|
|
42
|
+
return oid(await runGit(["rev-parse", "--verify", "--end-of-options", `${reference}^{commit}`], cwd, signal), `commit for ${reference}`);
|
|
43
|
+
}
|
|
44
|
+
function validatePath(path) {
|
|
45
|
+
if (!path || path.includes("\0") || path.includes("�") || isAbsolute(path) || path.split(/[\\/]/).some((part) => !part || part === "." || part === "..")) {
|
|
46
|
+
throw new Error(`Git returned malformed or unsupported path: ${JSON.stringify(path)}`);
|
|
47
|
+
}
|
|
48
|
+
return path;
|
|
49
|
+
}
|
|
50
|
+
function parsePaths(value) {
|
|
51
|
+
if (!value)
|
|
52
|
+
return [];
|
|
53
|
+
if (!value.endsWith("\0") || value.includes("�"))
|
|
54
|
+
throw new Error("Git returned malformed changed paths.");
|
|
55
|
+
const paths = value.slice(0, -1).split("\0");
|
|
56
|
+
if (paths.length > REVIEW_MAX_PATHS)
|
|
57
|
+
throw new Error(`Review evidence exceeds ${REVIEW_MAX_PATHS} paths; split the review.`);
|
|
58
|
+
return paths.map(validatePath);
|
|
59
|
+
}
|
|
60
|
+
function assertSupportedChanges(value) {
|
|
61
|
+
if (!value)
|
|
62
|
+
return;
|
|
63
|
+
if (!value.endsWith("\0") || value.includes("�"))
|
|
64
|
+
throw new Error("Git returned malformed raw diff.");
|
|
65
|
+
const fields = value.slice(0, -1).split("\0");
|
|
66
|
+
for (let index = 0; index < fields.length;) {
|
|
67
|
+
const header = fields[index++];
|
|
68
|
+
const match = /^:(\d{6}) (\d{6}) [0-9a-f]+ [0-9a-f]+ ([A-Z][0-9]*)$/.exec(header);
|
|
69
|
+
if (!match)
|
|
70
|
+
throw new Error("Git returned malformed raw diff.");
|
|
71
|
+
for (const mode of [match[1], match[2]]) {
|
|
72
|
+
if (mode === "160000")
|
|
73
|
+
throw new Error("Review evidence rejects changed gitlinks.");
|
|
74
|
+
if (!new Set(["000000", "100644", "100755", "120000"]).has(mode)) {
|
|
75
|
+
throw new Error(`Review evidence rejects unsupported file mode: ${mode}.`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const paths = /^[RC]/.test(match[3]) ? 2 : 1;
|
|
79
|
+
for (let count = 0; count < paths; count++) {
|
|
80
|
+
if (fields[index] === undefined)
|
|
81
|
+
throw new Error("Git returned malformed raw diff paths.");
|
|
82
|
+
validatePath(fields[index++]);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function registeredWorktrees(value) {
|
|
87
|
+
if (!value.endsWith("\0") || value.includes("�"))
|
|
88
|
+
throw new Error("Git returned malformed worktree list.");
|
|
89
|
+
const result = [];
|
|
90
|
+
let record = {};
|
|
91
|
+
for (const field of [...value.slice(0, -1).split("\0"), ""]) {
|
|
92
|
+
if (!field) {
|
|
93
|
+
if (Object.keys(record).length)
|
|
94
|
+
result.push(record);
|
|
95
|
+
record = {};
|
|
96
|
+
}
|
|
97
|
+
else if (field.startsWith("worktree "))
|
|
98
|
+
record.path = field.slice("worktree ".length);
|
|
99
|
+
else if (field.startsWith("HEAD "))
|
|
100
|
+
record.head = oid(`${field.slice("HEAD ".length)}\n`, "worktree HEAD");
|
|
101
|
+
}
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
104
|
+
async function assertCleanRegisteredWorktree(worktree, tip, signal) {
|
|
105
|
+
const root = await fs.realpath(line(await runGit(["rev-parse", "--show-toplevel"], worktree, signal), "worktree root"));
|
|
106
|
+
if (root !== worktree)
|
|
107
|
+
throw new Error("Review evidence requires a registered worktree root.");
|
|
108
|
+
const registrations = registeredWorktrees(await runGit(["worktree", "list", "--porcelain", "-z"], worktree, signal));
|
|
109
|
+
const registered = await Promise.all(registrations
|
|
110
|
+
.filter((entry) => entry.path === worktree)
|
|
111
|
+
.map(async (entry) => ({ ...entry, path: await fs.realpath(entry.path) })));
|
|
112
|
+
if (!registered.some((entry) => entry.head === tip)) {
|
|
113
|
+
throw new Error("Review evidence worktree is not registered at the requested tip.");
|
|
114
|
+
}
|
|
115
|
+
if (oid(await runGit(["rev-parse", "HEAD"], worktree, signal), "worktree HEAD") !== tip) {
|
|
116
|
+
throw new Error("Review evidence worktree moved from the requested tip.");
|
|
117
|
+
}
|
|
118
|
+
if (await runGit(["status", "--porcelain=v1", "--untracked-files=all", "--ignore-submodules=none"], worktree, signal)) {
|
|
119
|
+
throw new Error("Review evidence worktree is not clean.");
|
|
120
|
+
}
|
|
121
|
+
const flags = await inspectIndexFlags(worktree, runCapturedGit, signal);
|
|
122
|
+
if (flags.failure)
|
|
123
|
+
throw new Error(`Review evidence index inspection failed: ${flags.failure}`);
|
|
124
|
+
if (flags.hidden)
|
|
125
|
+
throw new Error("Review evidence rejects assume-unchanged or skip-worktree index entries.");
|
|
126
|
+
}
|
|
127
|
+
async function streamPatch(base, tip, worktree, path, signal) {
|
|
128
|
+
const file = await fs.open(path, "wx", 0o600);
|
|
129
|
+
try {
|
|
130
|
+
await fs.chmod(path, 0o600);
|
|
131
|
+
signal?.throwIfAborted();
|
|
132
|
+
const args = ["--no-pager", "diff", "--no-ext-diff", "--no-textconv", "--ignore-submodules=none", "--binary", base, tip];
|
|
133
|
+
const child = spawn("git", args, { cwd: worktree, signal, timeout: GIT_TIMEOUT_MS, stdio: ["ignore", "pipe", "pipe"] });
|
|
134
|
+
let stderr = Buffer.alloc(0);
|
|
135
|
+
let childError;
|
|
136
|
+
child.stderr.on("data", (chunk) => {
|
|
137
|
+
if (stderr.length < STDERR_LIMIT)
|
|
138
|
+
stderr = Buffer.concat([stderr, chunk.subarray(0, STDERR_LIMIT - stderr.length)]);
|
|
139
|
+
});
|
|
140
|
+
child.once("error", (error) => { childError = error; });
|
|
141
|
+
const closed = new Promise((resolve) => child.once("close", resolve));
|
|
142
|
+
let bytes = 0;
|
|
143
|
+
let overflow = false;
|
|
144
|
+
let outputError;
|
|
145
|
+
try {
|
|
146
|
+
for await (const chunk of child.stdout) {
|
|
147
|
+
const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
148
|
+
const available = REVIEW_MAX_PATCH_BYTES - bytes;
|
|
149
|
+
if (data.length > available) {
|
|
150
|
+
if (available > 0)
|
|
151
|
+
await file.writeFile(data.subarray(0, available));
|
|
152
|
+
bytes += Math.max(available, 0);
|
|
153
|
+
overflow = true;
|
|
154
|
+
child.kill();
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
await file.writeFile(data);
|
|
158
|
+
bytes += data.length;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
outputError = error;
|
|
163
|
+
child.kill();
|
|
164
|
+
}
|
|
165
|
+
const code = await closed;
|
|
166
|
+
signal?.throwIfAborted();
|
|
167
|
+
if (overflow)
|
|
168
|
+
throw new Error(`Review evidence exceeds ${REVIEW_MAX_PATCH_BYTES} bytes; split the review.`);
|
|
169
|
+
if (outputError)
|
|
170
|
+
throw outputError;
|
|
171
|
+
if (childError)
|
|
172
|
+
throw childError;
|
|
173
|
+
if (code !== 0)
|
|
174
|
+
throw failure(args, { code: code ?? -1, stdout: "", stderr: stderr.toString() });
|
|
175
|
+
}
|
|
176
|
+
finally {
|
|
177
|
+
await file.close();
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
async function makeEvidenceDirectory() {
|
|
181
|
+
const directory = await fs.mkdtemp(join(tmpdir(), "pi-subagent-review-"));
|
|
182
|
+
try {
|
|
183
|
+
await fs.chmod(directory, 0o700);
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
await fs.rm(directory, { recursive: true, force: true });
|
|
187
|
+
throw error;
|
|
188
|
+
}
|
|
189
|
+
let cleanup;
|
|
190
|
+
return {
|
|
191
|
+
directory,
|
|
192
|
+
cleanup: () => cleanup ??= fs.rm(directory, { recursive: true, force: true }),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
/** Prepare one exact private patch for caller-owned review. */
|
|
196
|
+
export async function prepareExactReviewEvidence(request, signal) {
|
|
197
|
+
const requestedWorktree = requestText(request.worktree, "review worktree");
|
|
198
|
+
const worktree = await fs.realpath(requestedWorktree);
|
|
199
|
+
const [base, tip] = await Promise.all([
|
|
200
|
+
resolveCommit(request.base, worktree, signal),
|
|
201
|
+
resolveCommit(request.tip, worktree, signal),
|
|
202
|
+
]);
|
|
203
|
+
await assertCleanRegisteredWorktree(worktree, tip, signal);
|
|
204
|
+
const changedPaths = parsePaths(await runGit([
|
|
205
|
+
"diff", "--no-ext-diff", "--no-textconv", "--ignore-submodules=none", "--name-only", "-z", base, tip,
|
|
206
|
+
], worktree, signal));
|
|
207
|
+
assertSupportedChanges(await runGit([
|
|
208
|
+
"diff", "--no-ext-diff", "--no-textconv", "--ignore-submodules=none", "--raw", "-z", base, tip,
|
|
209
|
+
], worktree, signal));
|
|
210
|
+
const owned = await makeEvidenceDirectory();
|
|
211
|
+
const patchPath = join(owned.directory, "review.patch");
|
|
212
|
+
try {
|
|
213
|
+
await streamPatch(base, tip, worktree, patchPath, signal);
|
|
214
|
+
await assertCleanRegisteredWorktree(worktree, tip, signal);
|
|
215
|
+
return { base, tip, worktree, changedPaths, patchPath, cleanup: owned.cleanup };
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
await owned.cleanup();
|
|
219
|
+
throw error;
|
|
220
|
+
}
|
|
221
|
+
}
|