pi-claude-supervisor 0.5.1 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/verifier.ts CHANGED
@@ -1,4 +1,7 @@
1
1
  import { execFile } from "node:child_process";
2
+ import { constants as fsConstants } from "node:fs";
3
+ import { lstat, open, realpath } from "node:fs/promises";
4
+ import { isAbsolute, relative, resolve, sep } from "node:path";
2
5
  import { promisify } from "node:util";
3
6
  import type { AcceptanceCheck, AcceptanceCheckResult, AcceptanceReport, VerificationResult } from "./types.ts";
4
7
  import { assertSafeWorkerCommand } from "./policy.ts";
@@ -6,15 +9,37 @@ import { workerEnvironment } from "./worker/environment.ts";
6
9
 
7
10
  const execFileAsync = promisify(execFile);
8
11
  const MAX_OUTPUT_BYTES = 256 * 1024;
12
+ const MAX_EXEC_BUFFER_BYTES = 8 * 1024 * 1024;
13
+ const MAX_UNTRACKED_FILE_BYTES = 64 * 1024;
14
+ const MAX_UNTRACKED_FILES = 128;
15
+
16
+ function throwIfAborted(signal?: AbortSignal): void {
17
+ if (!signal?.aborted) return;
18
+ const error = new Error("verification aborted");
19
+ error.name = "AbortError";
20
+ throw error;
21
+ }
9
22
 
10
23
  export interface VerificationCommand {
11
24
  command: string;
12
25
  args?: string[];
13
26
  }
14
27
 
28
+ export interface VerificationOptions {
29
+ signal?: AbortSignal;
30
+ onCheck?: (info: { check: AcceptanceCheck; result: AcceptanceCheckResult; index: number; total: number }) => void | Promise<void>;
31
+ }
32
+
15
33
  export interface RepositoryEvidence {
16
34
  status: string;
35
+ /** HEAD-relative diff for tracked staged and unstaged changes. */
17
36
  diff: string;
37
+ /** Bounded content for untracked regular files. */
38
+ untracked?: string;
39
+ /** False when a command/file could not be safely or completely collected. */
40
+ complete?: boolean;
41
+ /** True when any evidence field was bounded or omitted. */
42
+ truncated?: boolean;
18
43
  collectedAt: string;
19
44
  }
20
45
 
@@ -22,6 +47,7 @@ export async function verify(
22
47
  cwd: string,
23
48
  command: VerificationCommand = { command: "git", args: ["diff", "--check"] },
24
49
  timeoutMs = 120_000,
50
+ options: VerificationOptions = {},
25
51
  ): Promise<VerificationResult> {
26
52
  assertSafeWorkerCommand(command.command, command.args);
27
53
  const check: AcceptanceCheck = {
@@ -32,9 +58,9 @@ export async function verify(
32
58
  required: true,
33
59
  timeoutMs,
34
60
  };
35
- const result = await runCheck(cwd, check);
61
+ const result = await runCheck(cwd, check, options.signal);
36
62
  return {
37
- ok: result.ok,
63
+ ok: result.ok && !options.signal?.aborted,
38
64
  command: [check.command, ...check.args].join(" "),
39
65
  exitCode: result.exitCode,
40
66
  output: result.output,
@@ -42,39 +68,58 @@ export async function verify(
42
68
  };
43
69
  }
44
70
 
45
- export async function verifyAll(cwd: string, checks: readonly AcceptanceCheck[]): Promise<AcceptanceReport> {
71
+ export async function verifyAll(cwd: string, checks: readonly AcceptanceCheck[], options: VerificationOptions = {}): Promise<AcceptanceReport> {
46
72
  if (checks.length === 0) throw new Error("at least one acceptance check is required");
47
73
  const results: AcceptanceCheckResult[] = [];
48
- for (const check of checks) results.push(await runCheck(cwd, check));
49
- const ok = results.every((result) => !result.check.required || result.ok);
74
+ for (const [index, check] of checks.entries()) {
75
+ const result = await runCheck(cwd, check, options.signal);
76
+ results.push(result);
77
+ await options.onCheck?.({ check, result, index, total: checks.length });
78
+ if (options.signal?.aborted) break;
79
+ }
80
+ const cancelled = options.signal?.aborted === true;
81
+ const ok = !cancelled && results.every((result) => !result.check.required || result.ok);
50
82
  const firstFailure = results.find((result) => result.check.required && !result.ok);
51
83
  return {
52
84
  ok,
53
85
  command: "acceptance checks",
54
- exitCode: firstFailure?.exitCode ?? 0,
55
- output: boundOutput(results.map(formatCheckResult).join("\n\n")),
86
+ exitCode: firstFailure?.exitCode ?? (cancelled ? 1 : 0),
87
+ output: boundOutput(`${results.map(formatCheckResult).join("\n\n")}${cancelled ? "\n\n[verification cancelled]" : ""}`),
56
88
  checkedAt: new Date().toISOString(),
57
89
  checks: results,
58
90
  };
59
91
  }
60
92
 
61
93
  /** Collect bounded repository evidence for the independent read-only Reviewer. */
62
- export async function collectRepositoryEvidence(cwd: string): Promise<RepositoryEvidence> {
63
- const [status, diff] = await Promise.all([
64
- readGitEvidence(cwd, ["status", "--short", "--untracked-files=all"]),
65
- readGitEvidence(cwd, ["diff", "--no-ext-diff", "--unified=3"]),
94
+ export async function collectRepositoryEvidence(cwd: string, options: Pick<VerificationOptions, "signal"> = {}): Promise<RepositoryEvidence> {
95
+ throwIfAborted(options.signal);
96
+ const [statusResult, diffResult, untrackedResult] = await Promise.all([
97
+ readGitEvidence(cwd, ["status", "--short", "--untracked-files=all"], options.signal),
98
+ // HEAD-relative diff includes both staged and unstaged changes for tracked files.
99
+ readGitEvidence(cwd, ["diff", "--no-ext-diff", "--unified=3", "HEAD", "--"], options.signal),
100
+ collectUntrackedEvidence(cwd, options.signal),
66
101
  ]);
67
- return { status, diff, collectedAt: new Date().toISOString() };
102
+ throwIfAborted(options.signal);
103
+ return {
104
+ status: statusResult.text,
105
+ diff: diffResult.text,
106
+ untracked: untrackedResult.text,
107
+ complete: statusResult.complete && diffResult.complete && untrackedResult.complete,
108
+ truncated: statusResult.truncated || diffResult.truncated || untrackedResult.truncated,
109
+ collectedAt: new Date().toISOString(),
110
+ };
68
111
  }
69
112
 
70
- async function runCheck(cwd: string, check: AcceptanceCheck): Promise<AcceptanceCheckResult> {
113
+ async function runCheck(cwd: string, check: AcceptanceCheck, signal?: AbortSignal): Promise<AcceptanceCheckResult> {
114
+ throwIfAborted(signal);
71
115
  assertSafeWorkerCommand(check.command, check.args);
72
116
  const startedAt = new Date().toISOString();
73
117
  try {
74
118
  const result = await execFileAsync(check.command, check.args, {
75
119
  cwd,
76
120
  timeout: check.timeoutMs,
77
- maxBuffer: MAX_OUTPUT_BYTES,
121
+ maxBuffer: MAX_EXEC_BUFFER_BYTES,
122
+ signal,
78
123
  env: workerEnvironment(process.env, { GIT_TERMINAL_PROMPT: "0" }),
79
124
  });
80
125
  const finishedAt = new Date().toISOString();
@@ -88,13 +133,14 @@ async function runCheck(cwd: string, check: AcceptanceCheck): Promise<Acceptance
88
133
  finishedAt,
89
134
  };
90
135
  } catch (error) {
91
- const failure = error as { code?: number | string; signal?: string; stdout?: string; stderr?: string; message?: string; killed?: boolean };
136
+ const failure = error as { code?: number | string; signal?: string; stdout?: string; stderr?: string; message?: string; killed?: boolean; name?: string };
92
137
  const finishedAt = new Date().toISOString();
93
- const timedOut = failure.killed === true || failure.signal === "SIGTERM" || failure.code === "ETIMEDOUT";
138
+ const cancelled = signal?.aborted === true || failure.name === "AbortError" || failure.code === "ABORT_ERR";
139
+ const timedOut = !cancelled && (failure.killed === true || failure.signal === "SIGTERM" || failure.code === "ETIMEDOUT");
94
140
  const exitCode = typeof failure.code === "number" ? failure.code : 1;
95
141
  return {
96
142
  check,
97
- status: timedOut ? "timed_out" : "failed",
143
+ status: cancelled ? "cancelled" : timedOut ? "timed_out" : "failed",
98
144
  ok: false,
99
145
  exitCode,
100
146
  output: boundOutput(`${failure.stdout ?? ""}${failure.stderr ?? ""}${failure.message ?? ""}`),
@@ -104,18 +150,119 @@ async function runCheck(cwd: string, check: AcceptanceCheck): Promise<Acceptance
104
150
  }
105
151
  }
106
152
 
107
- async function readGitEvidence(cwd: string, args: string[]): Promise<string> {
153
+ interface EvidencePart {
154
+ text: string;
155
+ complete: boolean;
156
+ truncated: boolean;
157
+ }
158
+
159
+ async function readGitEvidence(cwd: string, args: string[], signal?: AbortSignal): Promise<EvidencePart> {
160
+ throwIfAborted(signal);
108
161
  try {
109
162
  const result = await execFileAsync("git", args, {
110
163
  cwd,
111
164
  timeout: 30_000,
112
165
  maxBuffer: MAX_OUTPUT_BYTES,
166
+ signal,
167
+ env: workerEnvironment(process.env, { GIT_TERMINAL_PROMPT: "0" }),
168
+ });
169
+ const bounded = boundEvidence(`${result.stdout}${result.stderr}`);
170
+ return { ...bounded, text: bounded.text || "(none)" };
171
+ } catch (error) {
172
+ const failure = error as { stdout?: string; stderr?: string; message?: string };
173
+ const bounded = boundEvidence(`${failure.stdout ?? ""}${failure.stderr ?? ""}${failure.message ?? "git evidence unavailable"}`);
174
+ return { ...bounded, complete: false };
175
+ }
176
+ }
177
+
178
+ async function collectUntrackedEvidence(cwd: string, signal?: AbortSignal): Promise<EvidencePart> {
179
+ throwIfAborted(signal);
180
+ let output: string;
181
+ let root: string;
182
+ try {
183
+ root = await realpath(cwd);
184
+ } catch (error) {
185
+ return { text: `[UNTRACKED EVIDENCE UNAVAILABLE] ${error instanceof Error ? error.message : String(error)}`, complete: false, truncated: false };
186
+ }
187
+ try {
188
+ const result = await execFileAsync("git", ["ls-files", "--others", "--exclude-standard", "-z"], {
189
+ cwd,
190
+ timeout: 30_000,
191
+ maxBuffer: MAX_OUTPUT_BYTES,
192
+ signal,
113
193
  env: workerEnvironment(process.env, { GIT_TERMINAL_PROMPT: "0" }),
114
194
  });
115
- return boundOutput(`${result.stdout}${result.stderr}`) || "(none)";
195
+ output = result.stdout;
196
+ throwIfAborted(signal);
116
197
  } catch (error) {
117
198
  const failure = error as { stdout?: string; stderr?: string; message?: string };
118
- return boundOutput(`${failure.stdout ?? ""}${failure.stderr ?? ""}${failure.message ?? "git evidence unavailable"}`);
199
+ return { text: `[UNTRACKED EVIDENCE UNAVAILABLE] ${failure.stderr ?? failure.message ?? "git ls-files failed"}`, complete: false, truncated: false };
200
+ }
201
+ const paths = output.split("\0").filter(Boolean);
202
+ if (paths.length === 0) return { text: "(none)", complete: true, truncated: false };
203
+ let complete = paths.length <= MAX_UNTRACKED_FILES;
204
+ let truncated = false;
205
+ const sections: string[] = [];
206
+ for (const path of paths.slice(0, MAX_UNTRACKED_FILES)) {
207
+ const fullPath = resolve(root, path);
208
+ const relativePath = relative(root, fullPath);
209
+ if (isAbsolute(relativePath) || relativePath === ".." || relativePath.startsWith(`..${"/"}`) || relativePath.startsWith(`..${"\\"}`)) {
210
+ complete = false;
211
+ sections.push(`--- ${JSON.stringify(path)} [unsafe path omitted]`);
212
+ continue;
213
+ }
214
+ let handle: Awaited<ReturnType<typeof open>> | undefined;
215
+ try {
216
+ await assertNoSymlinkComponents(root, relativePath);
217
+ const info = await lstat(fullPath);
218
+ if (info.isSymbolicLink() || !info.isFile()) {
219
+ complete = false;
220
+ sections.push(`--- ${JSON.stringify(path)} [non-regular file omitted]`);
221
+ continue;
222
+ }
223
+ const noFollow = typeof fsConstants.O_NOFOLLOW === "number" ? fsConstants.O_NOFOLLOW : 0;
224
+ handle = await open(fullPath, fsConstants.O_RDONLY | noFollow);
225
+ const opened = await handle.stat();
226
+ if (!opened.isFile()) {
227
+ complete = false;
228
+ sections.push(`--- ${JSON.stringify(path)} [non-regular file omitted]`);
229
+ continue;
230
+ }
231
+ const buffer = Buffer.alloc(MAX_UNTRACKED_FILE_BYTES + 1);
232
+ const read = await handle.read(buffer, 0, buffer.length, 0);
233
+ const bytes = buffer.subarray(0, read.bytesRead);
234
+ if (read.bytesRead > MAX_UNTRACKED_FILE_BYTES) {
235
+ complete = false;
236
+ truncated = true;
237
+ }
238
+ if (bytes.includes(0)) {
239
+ complete = false;
240
+ sections.push(`--- ${JSON.stringify(path)} [binary file omitted]`);
241
+ } else {
242
+ sections.push(`--- ${JSON.stringify(path)}${read.bytesRead > MAX_UNTRACKED_FILE_BYTES ? " [TRUNCATED]" : ""}\n${bytes.toString("utf8")}`);
243
+ }
244
+ } catch (error) {
245
+ complete = false;
246
+ sections.push(`--- ${JSON.stringify(path)} [read failed: ${error instanceof Error ? error.message : String(error)}]`);
247
+ } finally {
248
+ await handle?.close().catch(() => {});
249
+ }
250
+ }
251
+ if (paths.length > MAX_UNTRACKED_FILES) {
252
+ truncated = true;
253
+ sections.push(`[TRUNCATED: ${paths.length - MAX_UNTRACKED_FILES} untracked paths omitted]`);
254
+ }
255
+ const bounded = boundEvidence(sections.join("\n"));
256
+ return { ...bounded, complete: complete && bounded.complete, truncated: truncated || bounded.truncated };
257
+ }
258
+
259
+ async function assertNoSymlinkComponents(root: string, relativePath: string): Promise<void> {
260
+ const parts = relativePath.split(sep).filter(Boolean);
261
+ let current = root;
262
+ for (const part of parts) {
263
+ current = resolve(current, part);
264
+ const info = await lstat(current);
265
+ if (info.isSymbolicLink()) throw new Error("symlink path component is not allowed in repository evidence");
119
266
  }
120
267
  }
121
268
 
@@ -123,6 +270,14 @@ function formatCheckResult(result: AcceptanceCheckResult): string {
123
270
  return `[${result.status}] ${result.check.id}: ${result.check.command} ${result.check.args.join(" ")}\n${result.output || "(no output)"}`;
124
271
  }
125
272
 
273
+ function boundEvidence(value: string): EvidencePart {
274
+ const encoded = Buffer.from(value, "utf8");
275
+ if (encoded.byteLength <= MAX_OUTPUT_BYTES) return { text: value, complete: true, truncated: false };
276
+ const marker = Buffer.from("\n[TRUNCATED]", "utf8");
277
+ const suffix = encoded.subarray(-Math.max(0, MAX_OUTPUT_BYTES - marker.byteLength));
278
+ return { text: `${suffix.toString("utf8")}${marker.toString("utf8")}`, complete: false, truncated: true };
279
+ }
280
+
126
281
  function boundOutput(value: string): string {
127
282
  const encoded = Buffer.from(value, "utf8");
128
283
  if (encoded.byteLength <= MAX_OUTPUT_BYTES) return value;