pi-claude-supervisor 0.5.2 → 0.5.4

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/policy.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import { lstatSync, realpathSync, statSync } from "node:fs";
2
+ import { isAbsolute, join } from "node:path";
3
+
1
4
  export type PolicyDecision = "allow" | "review" | "deny";
2
5
 
3
6
  export interface PolicyResult {
@@ -5,18 +8,97 @@ export interface PolicyResult {
5
8
  reason: string;
6
9
  }
7
10
 
8
- export function evaluatePermission(toolName: string, input: unknown): PolicyResult {
9
- if (toolName === "AskUserQuestion") return { decision: "review", reason: "Claude requested an interactive product decision" };
10
- if (toolName !== "Bash") return { decision: "review", reason: `unknown Claude tool requires review: ${toolName}` };
11
+ /**
12
+ * Permission decisions are autonomous for local development. A denial is a
13
+ * hard capability boundary, not a request for a human to approve the next
14
+ * turn. AskUserQuestion is denied so the Worker can restate the question as
15
+ * ordinary text and the Decision Worker can answer it from task evidence.
16
+ */
17
+ const allowedWorkerTools = new Set(["Bash", "Edit", "Glob", "Grep", "Read", "Write", "NotebookEdit"]);
18
+
19
+ export function evaluatePermission(toolName: string, input: unknown, cwd = process.cwd()): PolicyResult {
20
+ if (toolName === "AskUserQuestion") return { decision: "deny", reason: "interactive questions are converted to ordinary Worker text" };
21
+ if (!allowedWorkerTools.has(toolName)) return { decision: "deny", reason: `Worker tool is outside the automatic allowlist: ${toolName}` };
22
+ if (toolName === "Edit" || toolName === "Write" || toolName === "NotebookEdit") {
23
+ const paths = fileToolPaths(input);
24
+ if (paths.length === 0) return { decision: "deny", reason: `${toolName} request has no recognizable file path` };
25
+ if (paths.some((path) => isGitMetadataPath(path, cwd))) return { decision: "deny", reason: "Worker cannot write Git metadata or protected branch refs" };
26
+ return { decision: "allow", reason: `local Claude file tool is allowed by the task policy: ${toolName}` };
27
+ }
28
+ if (toolName !== "Bash") return { decision: "allow", reason: `local Claude tool is allowed by the task policy: ${toolName}` };
11
29
  const command = input && typeof input === "object" && typeof (input as { command?: unknown }).command === "string"
12
30
  ? (input as { command: string }).command
13
31
  : "";
14
- if (!command) return { decision: "review", reason: "Bash request has no recognizable command" };
32
+ if (!command) return { decision: "deny", reason: "Bash request has no recognizable command" };
33
+ if (containsNestedClaude(command)) {
34
+ return { decision: "deny", reason: "Worker cannot start a nested Claude or background Reviewer" };
35
+ }
15
36
  return evaluateCommand("bash", ["-lc", command]);
16
37
  }
17
38
 
39
+ function fileToolPaths(input: unknown): string[] {
40
+ if (!input || typeof input !== "object" || Array.isArray(input)) return [];
41
+ const value = input as Record<string, unknown>;
42
+ return ["file_path", "filePath", "path", "notebook_path", "notebookPath"]
43
+ .map((key) => value[key])
44
+ .filter((path): path is string => typeof path === "string" && path.trim().length > 0);
45
+ }
46
+
47
+ function isGitMetadataPath(value: string, cwd: string): boolean {
48
+ if (value.replaceAll("\\", "/").split("/").some((segment) => segment.toLowerCase() === ".git")) return true;
49
+ let root: string;
50
+ try { root = realpathSync(cwd); }
51
+ catch { return true; }
52
+ const raw = value.replaceAll("\\", "/");
53
+ const canonicalRoot = root.replaceAll("\\", "/").replace(/\/+$/u, "") || "/";
54
+ let components: string[];
55
+ if (isAbsolute(value)) {
56
+ const prefix = canonicalRoot === "/" ? "/" : `${canonicalRoot}/`;
57
+ if (raw !== canonicalRoot && !raw.startsWith(prefix)) return true;
58
+ components = raw === canonicalRoot ? [] : raw.slice(prefix.length).split("/");
59
+ } else {
60
+ components = raw.split("/");
61
+ }
62
+ const normalized: string[] = [];
63
+ for (const segment of components) {
64
+ if (!segment || segment === ".") continue;
65
+ if (segment === "..") {
66
+ if (normalized.length === 0) return true;
67
+ normalized.pop();
68
+ continue;
69
+ }
70
+ if (segment.toLowerCase() === ".git") return true;
71
+ const candidate = join(root, ...normalized, segment);
72
+ try {
73
+ const info = lstatSync(candidate);
74
+ if (info.isSymbolicLink()) return true;
75
+ } catch (error) {
76
+ const code = (error as NodeJS.ErrnoException).code;
77
+ if (code !== "ENOENT" && code !== "ENOTDIR") return true;
78
+ }
79
+ normalized.push(segment);
80
+ }
81
+ const absolute = join(root, ...normalized);
82
+ try {
83
+ const info = statSync(absolute);
84
+ // A regular file with multiple links may be an alias for a Git ref or
85
+ // other metadata file even when its pathname contains no `.git` segment.
86
+ if (info.isFile() && info.nlink > 1) return true;
87
+ const resolved = realpathSync(absolute);
88
+ if (resolved.replaceAll("\\", "/").split("/").some((segment) => segment.toLowerCase() === ".git")) return true;
89
+ } catch (error) {
90
+ const code = (error as NodeJS.ErrnoException).code;
91
+ if (code !== "ENOENT" && code !== "ENOTDIR") return true;
92
+ }
93
+ return false;
94
+ }
95
+
18
96
  const deniedPatterns = [
19
- /--dangerously-skip-permissions\b/iu,
97
+ /\b(?:npm|pnpm|yarn)\b[\s\S]*\bpublish\b/iu,
98
+ /\b(?:ssh|scp|sftp|rsync)\b/iu,
99
+ /\b(?:curl|wget)\b[\s\S]*(?:-X\s*(?:POST|PUT|PATCH|DELETE)|--method(?:=|\s+)(?:POST|PUT|PATCH|DELETE)|https?:\/\/(?:api\.)?(?:github|gitlab|bitbucket|registry\.npmjs)\.)/iu,
100
+ /(?:\$\{?[^\s`}]+\}?|`[^`]*`|\$\([^)]*\))[\s\S]*\b(?:push|merge|publish)\b|\b(?:push|merge|publish)\b[\s\S]*(?:\$\{?[^\s`}]+\}?|`[^`]*`|\$\([^)]*\))/iu,
101
+ /--(?:allow-)?dangerously-skip-permissions\b/iu,
20
102
  /--permission-mode\s+(?:bypasspermissions|dontask)\b/iu,
21
103
  /\brm\s+-rf\s+\//iu,
22
104
  /\bmkfs(?:\.|\s)/iu,
@@ -25,37 +107,227 @@ const deniedPatterns = [
25
107
  /\b(shutdown|reboot|poweroff)\b/iu,
26
108
  ];
27
109
 
28
- const reviewPatterns = [
29
- /\bgit\s+push\b/iu,
30
- /\bgit\s+reset\s+--hard\b/iu,
31
- /\bgit\s+(merge|rebase)\b/iu,
32
- /\b(npm|pnpm|yarn)\s+publish\b/iu,
33
- /\b(curl|wget)\b.*\|\s*(?:\/[\w./-]+\/)?(?:sh|bash|zsh)\b/iu,
34
- /\b(chmod|chown)\b/iu,
35
- ];
110
+ interface ShellToken {
111
+ value: string;
112
+ operator: boolean;
113
+ dynamic: boolean;
114
+ }
115
+
116
+ const protectedBranches = new Set(["main", "master", "trunk", "integration", "develop"]);
117
+ const protectedBranchOperations = new Set(["checkout", "switch", "branch", "reset", "restore", "worktree", "update-ref", "symbolic-ref"]);
118
+ const remoteOperations = new Set(["push", "merge", "send-pack", "receive-pack", "update-ref"]);
36
119
 
37
120
  export function evaluateCommand(command: string, args: readonly string[] = []): PolicyResult {
38
- const normalized = [command, ...args].join(" ").trim();
121
+ const normalized = command.trim();
122
+ if (!normalized) return { decision: "deny", reason: "empty command" };
123
+ const lexical = lexShell(normalized);
124
+ if (lexical.error) return { decision: "deny", reason: `command could not be safely parsed: ${lexical.error}` };
125
+ const literalArgs = args.map((value) => ({ value, operator: false, dynamic: false }));
126
+ return evaluateTokens([...lexical.tokens, ...literalArgs], 0);
127
+ }
128
+
129
+ function evaluateRepositoryBoundary(tokens: readonly ShellToken[], canonical: string, depth: number): PolicyResult | undefined {
130
+ const values = tokens.filter((token) => !token.operator).map((token) => token.value);
131
+ const lower = values.map((value) => value.toLowerCase());
132
+ const hasGit = values.some((value) => /(?:^|[\\/])git$/iu.test(value) || /^(?:git-(?:send|receive|upload)-pack)$/iu.test(value));
133
+ const hasGitTransport = lower.some((value) => /^(?:git-(?:send|receive|upload)-pack)$/u.test(value));
134
+ const hasRemoteOperation = lower.some((value) => remoteOperations.has(value));
135
+ const hasGhRemote = lower.some((value) => value === "gh" || value === "glab" || value === "hub")
136
+ && lower.some((value) => value === "api" || value === "release" || value === "pull-request" || value === "pr" || value === "mr");
137
+ const hasPackagePublication = lower.some((value) => value === "npm" || value === "pnpm" || value === "yarn") && lower.includes("publish");
138
+ const hasGitAliasConfiguration = hasGit && lower.some((value) => /^alias\.[^=]*(?:=|$)/u.test(value));
139
+ if (depth < 4) {
140
+ for (const nested of nestedShellCommands(lower, values)) {
141
+ const nestedResult = evaluateCommandInternal(nested, depth + 1);
142
+ if (nestedResult.decision === "deny") return nestedResult;
143
+ }
144
+ }
145
+ const hasDynamicArgument = tokens.some((token) => !token.operator && token.dynamic);
146
+ const hasProtectedBranch = lower.some((value) => protectedBranches.has(value))
147
+ || /(?:^|\s)(?:[^\s]*\.git[\\/]refs[\\/]heads[\\/]|[^\s]*refs[\\/]heads[\\/])(?:main|master|trunk|integration|develop)(?:$|\s)/iu.test(canonical);
148
+ const gitOperation = lower.find((value) => protectedBranchOperations.has(value));
149
+
150
+ if (hasDynamicArgument) {
151
+ return { decision: "deny", reason: "dynamic shell arguments cannot be capability-checked safely" };
152
+ }
153
+ if (/\bgit\b[\s\S]*\b(?:push|merge|send-pack|receive-pack|update-ref)\b/iu.test(canonical)
154
+ || /\bgit-(?:send|receive|upload)-pack\b/iu.test(canonical)
155
+ || /\b(?:gh|glab|hub)\b[\s\S]*\b(?:api|pr|mr|pull-request|release)\b/iu.test(canonical)) {
156
+ return { decision: "deny", reason: "Worker has no remote repository or main/integration merge authority" };
157
+ }
158
+ if (hasGit && (hasRemoteOperation || hasGitTransport) || hasGhRemote) {
159
+ return { decision: "deny", reason: "Worker has no remote repository or main/integration merge authority" };
160
+ }
161
+ if (hasGitAliasConfiguration) {
162
+ return { decision: "deny", reason: "Worker cannot redefine Git command aliases" };
163
+ }
164
+ if (hasGit && gitOperation && hasProtectedBranch) {
165
+ return { decision: "deny", reason: "Worker cannot switch to or mutate a protected integration branch" };
166
+ }
167
+ const directRefWrite = tokens.some((token) => token.operator && (token.value === ">" || token.value === ">>"))
168
+ || ["cp", "echo", "install", "mv", "printf", "sed", "tee"].includes(lower[0] ?? "");
169
+ if (/\.git[\\/](?:HEAD|packed-refs|refs[\\/]heads[\\/])/iu.test(canonical)
170
+ || (directRefWrite && hasProtectedBranch && /refs[\\/]heads[\\/]/iu.test(canonical))) {
171
+ return { decision: "deny", reason: "Worker cannot write protected Git branch refs directly" };
172
+ }
173
+ if (hasPackagePublication) {
174
+ return { decision: "deny", reason: "package publication belongs to the protected release workflow" };
175
+ }
176
+ const unsafePermissionMode = (value: string): boolean => value.replace(/[-_]/gu, "") === "bypasspermissions" || value.replace(/[-_]/gu, "") === "dontask";
177
+ if (lower.some((value) => value.startsWith("--permission-mode=") && unsafePermissionMode(value.slice("--permission-mode=".length)))
178
+ || (lower.includes("--permission-mode") && lower.some((value) => unsafePermissionMode(value)))) {
179
+ return { decision: "deny", reason: "Worker cannot bypass Claude permission prompts" };
180
+ }
181
+ return undefined;
182
+ }
183
+
184
+ function containsNestedClaude(command: string, depth = 0): boolean {
185
+ // Bash can hand an opaque quoted program to Python, Node, eval, env,
186
+ // command, or another interpreter. Once this permission boundary sees a
187
+ // Claude executable reference, fail closed rather than trying to prove which
188
+ // wrapper will eventually exec it. This intentionally rejects harmless prose
189
+ // mentions too; false positives cannot grant a nested Worker capability.
190
+ if (/\b(?:claude(?:\.exe)?|review(?:er)?|code[-_ ]?review|read[-_ ]?only[-_ ]?review|independent[-_ ]?review|codex|cursor(?:-agent)?|aider|opencode|gemini)\b/iu.test(command)) return true;
191
+ if (depth > 4) return false;
192
+ const lexical = lexShell(command);
193
+ if (lexical.error) return true;
194
+ for (const token of lexical.tokens) {
195
+ if (token.operator) continue;
196
+ const executable = token.value.split(/[\\/]/u).at(-1)?.toLowerCase();
197
+ if (executable === "claude" || executable === "claude.exe") return true;
198
+ // Quote concatenation such as c'l'a'u'd'e is normalized by the shell
199
+ // lexer into one token. Recurse into quoted interpreter payloads too.
200
+ if (/\s/u.test(token.value) && containsNestedClaude(token.value, depth + 1)) return true;
201
+ }
202
+ return false;
203
+ }
204
+
205
+ function nestedShellCommands(lower: readonly string[], values: readonly string[]): string[] {
206
+ const nested: string[] = [];
207
+ const shellNames = new Set(["sh", "bash", "dash", "zsh", "fish", "ksh"]);
208
+ for (let index = 0; index < lower.length; index += 1) {
209
+ const executable = lower[index]!.split(/[\\/]/u).at(-1);
210
+ if (executable && shellNames.has(executable)) {
211
+ for (let option = index + 1; option < lower.length; option += 1) {
212
+ if (["-c", "--command"].includes(lower[option]!)) {
213
+ const command = values.slice(option + 1).join(" ").trim();
214
+ if (command) nested.push(command);
215
+ break;
216
+ }
217
+ }
218
+ }
219
+ if (lower[index] === "eval") {
220
+ const command = values.slice(index + 1).join(" ").trim();
221
+ if (command) nested.push(command);
222
+ }
223
+ }
224
+ return nested;
225
+ }
226
+
227
+ function evaluateCommandInternal(command: string, depth: number): PolicyResult {
228
+ const normalized = command.trim();
39
229
  if (!normalized) return { decision: "deny", reason: "empty command" };
40
- if (deniedPatterns.some((pattern) => pattern.test(normalized))) {
230
+ const lexical = lexShell(normalized);
231
+ if (lexical.error) return { decision: "deny", reason: `command could not be safely parsed: ${lexical.error}` };
232
+ return evaluateTokens(lexical.tokens, depth);
233
+ }
234
+
235
+ function evaluateTokens(tokens: readonly ShellToken[], depth: number): PolicyResult {
236
+ const canonical = tokens.map((token) => token.value).join(" ").trim();
237
+ if (!canonical) return { decision: "deny", reason: "empty command" };
238
+ const boundary = evaluateRepositoryBoundary(tokens, canonical, depth);
239
+ if (boundary) return boundary;
240
+ if (deniedPatterns.some((pattern) => pattern.test(canonical))) {
241
+ if (/\b(?:curl|wget)\b[\s\S]*(?:github|gitlab|bitbucket|registry\.npmjs)\b/iu.test(canonical)) {
242
+ return { decision: "deny", reason: "Worker has no remote repository or main/integration merge authority" };
243
+ }
244
+ if (/\bgit\b[\s\S]*\b(?:checkout|switch|branch|reset|restore|worktree|update-ref|symbolic-ref)\b[\s\S]*\b(?:main|master|trunk|integration|develop)\b/iu.test(canonical)) {
245
+ return { decision: "deny", reason: "Worker cannot switch to or mutate a protected integration branch" };
246
+ }
247
+ if (/\b(?:npm|pnpm|yarn)\b[\s\S]*\bpublish\b/iu.test(canonical)) {
248
+ return { decision: "deny", reason: "package publication belongs to the protected release workflow" };
249
+ }
41
250
  return { decision: "deny", reason: "command matches a prohibited destructive pattern" };
42
251
  }
43
- if (reviewPatterns.some((pattern) => pattern.test(normalized))) {
44
- return { decision: "review", reason: "command requires explicit human approval" };
252
+ return { decision: "allow", reason: "command is allowed for unattended local development" };
253
+ }
254
+
255
+ function lexShell(input: string): { tokens: ShellToken[]; error?: string } {
256
+ const tokens: ShellToken[] = [];
257
+ let value = "";
258
+ let dynamic = false;
259
+ let started = false;
260
+ let quote: "single" | "double" | undefined;
261
+ const flush = (): void => {
262
+ if (!started) return;
263
+ tokens.push({ value, operator: false, dynamic });
264
+ value = "";
265
+ dynamic = false;
266
+ started = false;
267
+ };
268
+ const pushOperator = (operator: string): void => {
269
+ flush();
270
+ tokens.push({ value: operator, operator: true, dynamic: false });
271
+ };
272
+ for (let index = 0; index < input.length; index += 1) {
273
+ const character = input[index]!;
274
+ const next = input[index + 1];
275
+ if (quote === "single") {
276
+ if (character === "'") quote = undefined;
277
+ else value += character;
278
+ started = true;
279
+ continue;
280
+ }
281
+ if (quote === "double") {
282
+ if (character === '"') quote = undefined;
283
+ else if (character === "\\" && next === "\n") index += 1;
284
+ else if (character === "\\" && next !== undefined && /[\\"$`]/u.test(next)) { value += next; index += 1; }
285
+ else { value += character; if (character === "$" || character === "`") dynamic = true; }
286
+ started = true;
287
+ continue;
288
+ }
289
+ if (character === "'") { quote = "single"; started = true; continue; }
290
+ if (character === '"') { quote = "double"; started = true; continue; }
291
+ if (character === "\\") {
292
+ if (next === "\n") index += 1;
293
+ else if (next !== undefined) { value += next; index += 1; }
294
+ started = true;
295
+ continue;
296
+ }
297
+ if (/\s/u.test(character)) { flush(); continue; }
298
+ if (character === "$" || character === "`") { dynamic = true; value += character; started = true; continue; }
299
+ // Brace, tilde and pathname expansion can change command names, targets or
300
+ // Git refs after this lexical pass. Treat all unquoted expansion markers as
301
+ // dynamic rather than attempting to model Bash's expansion order.
302
+ if ("*?[]{}~".includes(character)) { dynamic = true; value += character; started = true; continue; }
303
+ if (";&|<>".includes(character)) {
304
+ const operator = next && ((character === "&" && next === "&") || (character === "|" && next === "|") || (character === ">" && next === ">") || (character === "<" && next === "<"))
305
+ ? `${character}${next}`
306
+ : character;
307
+ pushOperator(operator);
308
+ if (operator.length === 2) index += 1;
309
+ continue;
310
+ }
311
+ value += character;
312
+ started = true;
45
313
  }
46
- return { decision: "allow", reason: "command is outside the default high-risk set" };
314
+ if (quote) return { tokens, error: "unterminated quote" };
315
+ flush();
316
+ return { tokens };
47
317
  }
48
318
 
319
+ /**
320
+ * Keep the approval argument for persisted v0.5.x records and embedding API
321
+ * compatibility. It is intentionally not required for local review patterns;
322
+ * only hard-denied capability boundaries fail this assertion.
323
+ */
49
324
  export function assertSafeWorkerCommand(
50
325
  command: string,
51
326
  args: readonly string[] = [],
52
- approval?: { actor: "human"; reason: string },
327
+ _approval?: { actor: "human"; reason: string },
53
328
  ): void {
54
329
  const result = evaluateCommand(command, args);
55
330
  if (result.decision === "deny") {
56
331
  throw new Error(`Worker command blocked by policy (deny): ${result.reason}`);
57
332
  }
58
- if (result.decision === "review" && (!approval || approval.actor !== "human" || !approval.reason.trim())) {
59
- throw new Error(`Worker command blocked by policy (review): ${result.reason}`);
60
- }
61
333
  }
package/src/reviewer.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createAgentSession, DefaultResourceLoader, getAgentDir, SessionManager } from "@earendil-works/pi-coding-agent";
2
+ import { isDeepStrictEqual } from "node:util";
2
3
  import { redactSensitive } from "./redaction.ts";
3
4
  import type { AcceptanceReport, ReviewFinding, ReviewReport, TaskSpec } from "./types.ts";
4
5
  import type { RepositoryEvidence } from "./verifier.ts";
@@ -130,7 +131,16 @@ ${boundedJson(input.acceptance)}
130
131
  REPOSITORY STATUS:
131
132
  ${boundText(redactText(input.evidence.status), 8_000)}
132
133
 
133
- REPOSITORY DIFF (HEAD-RELATIVE, UNTRUSTED):
134
+ REPOSITORY BASE:
135
+ ${redactText(input.evidence.baseRef ?? "(unavailable; review current evidence)")}
136
+
137
+ LOCAL BRANCH:
138
+ ${redactText(input.evidence.branch ?? "(detached or unavailable)")}
139
+
140
+ COMMITS AFTER BASELINE (UNTRUSTED):
141
+ ${boundText(redactText(input.evidence.commits ?? "(none)"), 8_000)}
142
+
143
+ REPOSITORY DIFF (BASELINE-RELATIVE, UNTRUSTED):
134
144
  ${boundText(redactText(input.evidence.diff), 16_000)}
135
145
 
136
146
  UNTRACKED FILE EVIDENCE (UNTRUSTED):
@@ -138,7 +148,7 @@ ${boundText(redactText(input.evidence.untracked ?? "(none)"), 16_000)}
138
148
 
139
149
  EVIDENCE COMPLETE:
140
150
  ${String(input.evidence.complete !== false && input.evidence.truncated !== true)}
141
- If repository evidence is incomplete or truncated, do not return pass; return human and explain which evidence is unavailable.
151
+ DISPLAY NOTE: fields below may end with [DISPLAY_TRUNCATED] because the prompt has a bounded presentation window. This is presentation-only and is not evidence loss. If EVIDENCE COMPLETE is true, do not return human merely because a displayed field is shortened; inspect the repository with the read-only tools instead. If EVIDENCE COMPLETE is false, do not return pass; return human and explain which evidence is unavailable.
142
152
 
143
153
  WORKER OUTPUT (UNTRUSTED):
144
154
  ${boundTailText(redactSensitive(input.workerOutput ?? "(none)"), 8_000)}
@@ -159,20 +169,40 @@ function textFromMessage(content: unknown): string {
159
169
  .join("");
160
170
  }
161
171
 
172
+ export function normalizeReviewReport(value: unknown, round: number): ReviewReport {
173
+ try {
174
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
175
+ return invalidReview("invalid Reviewer output: result must be an object", round, new Date().toISOString());
176
+ }
177
+ const report = value as { verdict?: unknown; summary?: unknown; findings?: unknown };
178
+ if (typeof report.verdict !== "string" || typeof report.summary !== "string" || !report.summary.trim() || !Array.isArray(report.findings)) {
179
+ return invalidReview("invalid Reviewer output: result must include verdict, summary and findings", round, new Date().toISOString());
180
+ }
181
+ const encoded = JSON.stringify(value);
182
+ return parseReview(encoded ?? "", round);
183
+ } catch (error) {
184
+ return invalidReview(`invalid Reviewer output: ${error instanceof Error ? error.message : String(error)}`, round, new Date().toISOString());
185
+ }
186
+ }
187
+
162
188
  export function parseReview(text: string, round: number): ReviewReport {
163
189
  const checkedAt = new Date().toISOString();
164
190
  if (Buffer.byteLength(text, "utf8") > MAX_REVIEW_RESPONSE_BYTES) return invalidReview(`Reviewer response exceeded ${MAX_REVIEW_RESPONSE_BYTES} bytes`, round, checkedAt);
165
- const candidate = text.trim();
166
- if (!candidate) return invalidReview("Reviewer returned no JSON object", round, checkedAt);
191
+ if (!text.trim()) return invalidReview("Reviewer returned no JSON object", round, checkedAt);
167
192
  try {
168
- const value = JSON.parse(candidate) as Record<string, unknown>;
193
+ const objects = extractJsonObjects(text);
194
+ if (objects.length === 0) throw new Error("Reviewer output did not contain a JSON object");
195
+ if (objects.slice(1).some((value) => !isDeepStrictEqual(value, objects[0]))) {
196
+ throw new Error("Reviewer output contained multiple distinct JSON objects");
197
+ }
198
+ const value = objects[0] as Record<string, unknown>;
169
199
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Reviewer output must be a JSON object");
170
200
  const verdict = value.verdict;
171
201
  if (verdict !== "pass" && verdict !== "revise" && verdict !== "human") throw new Error("unsupported verdict");
172
202
  const summary = typeof value.summary === "string" && value.summary.trim() ? value.summary.trim() : "no summary provided";
173
- if (value.findings !== undefined && !Array.isArray(value.findings)) throw new Error("findings must be an array");
174
- if (Array.isArray(value.findings) && value.findings.length > MAX_REVIEW_FINDINGS) throw new Error(`findings exceed the limit of ${MAX_REVIEW_FINDINGS}`);
175
- const findings = (value.findings ?? []).map((finding, index) => parseFinding(finding, index));
203
+ if (!Array.isArray(value.findings)) throw new Error("findings must be an array");
204
+ if (value.findings.length > MAX_REVIEW_FINDINGS) throw new Error(`findings exceed the limit of ${MAX_REVIEW_FINDINGS}`);
205
+ const findings = value.findings.map((finding, index) => parseFinding(finding, index));
176
206
  if (verdict === "revise" && findings.length === 0) throw new Error("revise verdict requires at least one finding");
177
207
  return { verdict, summary: boundText(summary, 4_000), findings, round, checkedAt };
178
208
  } catch (error) {
@@ -180,6 +210,56 @@ export function parseReview(text: string, round: number): ReviewReport {
180
210
  }
181
211
  }
182
212
 
213
+ /**
214
+ * Claude occasionally wraps its answer in a fence, a sentence, or repeats
215
+ * the same JSON object. Extract one bounded object while rejecting conflicting
216
+ * objects: prose is harmless, but ambiguity must remain non-publishable.
217
+ */
218
+ function extractJsonObjects(text: string): unknown[] {
219
+ const objects: unknown[] = [];
220
+ let offset = 0;
221
+ while (offset < text.length) {
222
+ const start = text.indexOf("{", offset);
223
+ if (start < 0) break;
224
+ const end = balancedObjectEnd(text, start);
225
+ if (end < 0) {
226
+ offset = start + 1;
227
+ continue;
228
+ }
229
+ const candidate = text.slice(start, end + 1);
230
+ try {
231
+ const value = JSON.parse(candidate) as unknown;
232
+ if (value && typeof value === "object" && !Array.isArray(value)) objects.push(value);
233
+ offset = end + 1;
234
+ } catch {
235
+ offset = start + 1;
236
+ }
237
+ }
238
+ return objects;
239
+ }
240
+
241
+ function balancedObjectEnd(text: string, start: number): number {
242
+ let depth = 0;
243
+ let inString = false;
244
+ let escaped = false;
245
+ for (let index = start; index < text.length; index += 1) {
246
+ const character = text[index];
247
+ if (inString) {
248
+ if (escaped) escaped = false;
249
+ else if (character === "\\") escaped = true;
250
+ else if (character === '"') inString = false;
251
+ continue;
252
+ }
253
+ if (character === '"') {
254
+ inString = true;
255
+ continue;
256
+ }
257
+ if (character === "{") depth += 1;
258
+ else if (character === "}" && --depth === 0) return index;
259
+ }
260
+ return -1;
261
+ }
262
+
183
263
  function parseFinding(value: unknown, index: number): ReviewFinding {
184
264
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`finding ${index} must be an object`);
185
265
  const source = value as Record<string, unknown>;
@@ -248,11 +328,11 @@ async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: str
248
328
  function boundTailText(value: unknown, maxBytes: number): string {
249
329
  const text = String(value);
250
330
  if (Buffer.byteLength(text, "utf8") <= maxBytes) return text;
251
- return `[TRUNCATED]\n${Buffer.from(text, "utf8").subarray(-maxBytes).toString("utf8")}`;
331
+ return `[DISPLAY_TRUNCATED]\n${Buffer.from(text, "utf8").subarray(-maxBytes).toString("utf8")}`;
252
332
  }
253
333
 
254
334
  function boundText(value: unknown, maxBytes: number): string {
255
335
  const text = String(value);
256
336
  if (Buffer.byteLength(text, "utf8") <= maxBytes) return text;
257
- return `${Buffer.from(text, "utf8").subarray(0, maxBytes).toString("utf8")}\n[TRUNCATED]`;
337
+ return `${Buffer.from(text, "utf8").subarray(0, maxBytes).toString("utf8")}\n[DISPLAY_TRUNCATED]`;
258
338
  }
package/src/state.ts CHANGED
@@ -2,14 +2,15 @@ import type { SupervisorState } from "./types.ts";
2
2
 
3
3
  const transitions: Record<SupervisorState, readonly SupervisorState[]> = {
4
4
  idle: ["starting"],
5
- starting: ["running", "failed", "stopped"],
6
- running: ["waiting", "paused", "verifying", "failed", "stopped"],
7
- waiting: ["running", "paused", "verifying", "failed", "stopped"],
8
- paused: ["running", "stopped", "failed"],
9
- verifying: ["completed", "running", "failed", "stopped"],
5
+ starting: ["running", "blocked", "failed", "stopped"],
6
+ running: ["waiting", "paused", "verifying", "blocked", "failed", "stopped"],
7
+ waiting: ["running", "paused", "verifying", "blocked", "failed", "stopped"],
8
+ paused: ["running", "blocked", "stopped", "failed"],
9
+ verifying: ["completed", "running", "blocked", "failed", "stopped"],
10
10
  completed: ["idle"],
11
+ blocked: ["idle"],
11
12
  failed: ["idle"],
12
- stopped: ["idle"],
13
+ stopped: ["idle", "blocked"],
13
14
  };
14
15
 
15
16
  export class InvalidTransitionError extends Error {