pi-subagents 0.28.0 → 0.30.0

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.
Files changed (47) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +26 -62
  3. package/package.json +1 -1
  4. package/skills/pi-subagents/SKILL.md +29 -35
  5. package/src/agents/agent-management.ts +29 -22
  6. package/src/agents/agent-selection.ts +2 -0
  7. package/src/agents/agent-serializer.ts +5 -10
  8. package/src/agents/agents.ts +339 -47
  9. package/src/agents/chain-serializer.ts +4 -9
  10. package/src/agents/proactive-skills.ts +191 -0
  11. package/src/extension/doctor.ts +4 -3
  12. package/src/extension/fanout-child.ts +1 -3
  13. package/src/extension/index.ts +6 -9
  14. package/src/extension/schemas.ts +63 -26
  15. package/src/intercom/intercom-bridge.ts +11 -1
  16. package/src/intercom/result-intercom.ts +0 -5
  17. package/src/runs/background/async-execution.ts +186 -74
  18. package/src/runs/background/async-resume.ts +53 -5
  19. package/src/runs/background/async-status.ts +4 -1
  20. package/src/runs/background/chain-append.ts +282 -0
  21. package/src/runs/background/chain-root-attachment.ts +161 -0
  22. package/src/runs/background/run-status.ts +2 -7
  23. package/src/runs/background/subagent-runner.ts +160 -219
  24. package/src/runs/foreground/chain-execution.ts +62 -58
  25. package/src/runs/foreground/execution.ts +39 -343
  26. package/src/runs/foreground/subagent-executor.ts +316 -111
  27. package/src/runs/shared/acceptance.ts +605 -22
  28. package/src/runs/shared/chain-outputs.ts +23 -8
  29. package/src/runs/shared/completion-guard.ts +3 -26
  30. package/src/runs/shared/dynamic-fanout.ts +1 -1
  31. package/src/runs/shared/model-fallback.ts +38 -0
  32. package/src/runs/shared/parallel-utils.ts +13 -10
  33. package/src/runs/shared/pi-args.ts +3 -2
  34. package/src/runs/shared/subagent-control.ts +8 -11
  35. package/src/runs/shared/subagent-prompt-runtime.ts +3 -2
  36. package/src/runs/shared/workflow-graph.ts +2 -6
  37. package/src/shared/atomic-json.ts +68 -11
  38. package/src/shared/settings.ts +1 -0
  39. package/src/shared/types.ts +20 -49
  40. package/src/shared/utils.ts +2 -8
  41. package/src/slash/slash-bridge.ts +3 -1
  42. package/src/slash/slash-commands.ts +1 -1
  43. package/src/tui/render.ts +14 -29
  44. package/src/runs/shared/acceptance-contract.ts +0 -318
  45. package/src/runs/shared/acceptance-evaluation.ts +0 -221
  46. package/src/runs/shared/acceptance-finalization.ts +0 -173
  47. package/src/runs/shared/acceptance-reports.ts +0 -127
@@ -1,22 +1,605 @@
1
- export {
2
- acceptanceSelfReviewConfig,
3
- formatAcceptancePrompt,
4
- resolveEffectiveAcceptance,
5
- shouldRunAcceptanceFinalization,
6
- validateAcceptanceInput,
7
- } from "./acceptance-contract.ts";
8
- export {
9
- parseAcceptanceReport,
10
- stripAcceptanceReport,
11
- } from "./acceptance-reports.ts";
12
- export {
13
- acceptanceFailureMessage,
14
- evaluateAcceptance,
15
- } from "./acceptance-evaluation.ts";
16
- export {
17
- attachFinalizationToLedger,
18
- buildFinalizationProcessFailureLedger,
19
- createFinalizationProcessFailureTurn,
20
- createFinalizationTurn,
21
- formatAcceptanceFinalizationPrompt,
22
- } from "./acceptance-finalization.ts";
1
+ import { spawn } from "node:child_process";
2
+ import { spawnSync } from "node:child_process";
3
+ import * as fs from "node:fs";
4
+ import * as path from "node:path";
5
+ import type {
6
+ AcceptanceConfig,
7
+ AcceptanceEvidenceKind,
8
+ AcceptanceInput,
9
+ AcceptanceLedger,
10
+ AcceptanceLevel,
11
+ AcceptanceReport,
12
+ AcceptanceRuntimeCheck,
13
+ AcceptanceReviewResult,
14
+ AcceptanceVerifyCommand,
15
+ AcceptanceVerifyResult,
16
+ ResolvedAcceptanceConfig,
17
+ ResolvedAcceptanceGate,
18
+ SingleResult,
19
+ SubagentRunMode,
20
+ } from "../../shared/types.ts";
21
+
22
+ const LEVEL_RANK: Record<Exclude<AcceptanceLevel, "auto">, number> = {
23
+ none: 0,
24
+ attested: 1,
25
+ checked: 2,
26
+ verified: 3,
27
+ reviewed: 4,
28
+ };
29
+
30
+ const VALID_LEVELS = new Set<AcceptanceLevel>(["auto", "none", "attested", "checked", "verified", "reviewed"]);
31
+ const VALID_EVIDENCE = new Set<AcceptanceEvidenceKind>([
32
+ "changed-files",
33
+ "tests-added",
34
+ "commands-run",
35
+ "validation-output",
36
+ "residual-risks",
37
+ "no-staged-files",
38
+ "diff-summary",
39
+ "review-findings",
40
+ "manual-notes",
41
+ ]);
42
+
43
+ function normalizeLevel(level: AcceptanceLevel | undefined): Exclude<AcceptanceLevel, "auto"> | "auto" {
44
+ return level ?? "auto";
45
+ }
46
+
47
+ function unique<T>(items: T[]): T[] {
48
+ return [...new Set(items)];
49
+ }
50
+
51
+ function requiredEvidenceForLevel(level: Exclude<AcceptanceLevel, "auto">): AcceptanceEvidenceKind[] {
52
+ switch (level) {
53
+ case "none":
54
+ return [];
55
+ case "attested":
56
+ return ["manual-notes", "residual-risks"];
57
+ case "checked":
58
+ return ["changed-files", "tests-added", "commands-run", "residual-risks", "no-staged-files"];
59
+ case "verified":
60
+ case "reviewed":
61
+ return ["changed-files", "tests-added", "commands-run", "validation-output", "residual-risks", "no-staged-files"];
62
+ }
63
+ }
64
+
65
+ function inferLevel(input: {
66
+ agentName: string;
67
+ task?: string;
68
+ mode?: SubagentRunMode;
69
+ async?: boolean;
70
+ dynamic?: boolean;
71
+ dynamicGroup?: boolean;
72
+ }): { level: Exclude<AcceptanceLevel, "auto">; reasons: string[]; criteria: string[]; evidence: AcceptanceEvidenceKind[]; review?: { agent?: string; required?: boolean } } {
73
+ const agent = input.agentName.toLowerCase();
74
+ const task = input.task?.toLowerCase() ?? "";
75
+ const reasons: string[] = [];
76
+ const readOnlyAgent = /\b(?:reviewer|scout|context-builder|researcher|analyst)\b/.test(agent);
77
+ const readOnlyTask = /\b(?:read[- ]only|review[- ]only|do not edit|don't edit|no edits|without edits|inspect|summari[sz]e)\b/.test(task);
78
+ const writeTask = /\b(?:fix|implement|update|write|edit|modify|migrate|release|security|delete|remove|refactor|commit)\b/.test(task)
79
+ || /\bworker\b/.test(agent);
80
+ const risky = Boolean(input.async && writeTask)
81
+ || Boolean(input.dynamic)
82
+ || Boolean(input.dynamicGroup)
83
+ || /\b(?:release|migration|migrate|security|data[- ]loss|destructive|post-review|fix pass)\b/.test(task);
84
+
85
+ if (risky) {
86
+ reasons.push(input.async ? "async write-capable or risky run" : "risky write-capable run");
87
+ if (input.dynamic || input.dynamicGroup) reasons.push("dynamic fanout context");
88
+ return {
89
+ level: "reviewed",
90
+ reasons,
91
+ criteria: ["Implement the requested change without widening scope", "Return evidence sufficient for an independent acceptance review"],
92
+ evidence: requiredEvidenceForLevel("reviewed"),
93
+ review: { agent: "reviewer", required: true },
94
+ };
95
+ }
96
+ if (writeTask && !readOnlyTask) {
97
+ reasons.push("write-capable worker/task");
98
+ return {
99
+ level: "checked",
100
+ reasons,
101
+ criteria: ["Implement the requested change without widening scope"],
102
+ evidence: requiredEvidenceForLevel("checked"),
103
+ };
104
+ }
105
+ if (readOnlyAgent || readOnlyTask) {
106
+ reasons.push(readOnlyAgent ? "read-only/reviewer-style agent" : "read-only task wording");
107
+ return {
108
+ level: "attested",
109
+ reasons,
110
+ criteria: ["Return concrete findings with file paths and severity when applicable"],
111
+ evidence: ["review-findings", "residual-risks"],
112
+ };
113
+ }
114
+ reasons.push("default lightweight attestation");
115
+ return {
116
+ level: "attested",
117
+ reasons,
118
+ criteria: ["Return a concise result and residual risks when applicable"],
119
+ evidence: ["manual-notes", "residual-risks"],
120
+ };
121
+ }
122
+
123
+ export function normalizeAcceptanceInput(input: AcceptanceInput | undefined): AcceptanceConfig {
124
+ if (input === undefined || input === "auto") return { level: "auto" };
125
+ if (input === false) return { level: "none", reason: "disabled by deprecated false shorthand" };
126
+ if (typeof input === "string") return { level: input };
127
+ return { ...input };
128
+ }
129
+
130
+ function explicitAcceptanceCanDisable(explicit: AcceptanceConfig): boolean {
131
+ return explicit.level === "none" && typeof explicit.reason === "string" && explicit.reason.trim().length > 0;
132
+ }
133
+
134
+ export function validateAcceptanceInput(input: unknown, pathLabel = "acceptance"): string[] {
135
+ const errors: string[] = [];
136
+ if (input === undefined) return errors;
137
+ if (input === false) return errors;
138
+ if (typeof input === "string") {
139
+ if (!VALID_LEVELS.has(input as AcceptanceLevel)) errors.push(`${pathLabel} has invalid level '${input}'.`);
140
+ return errors;
141
+ }
142
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
143
+ errors.push(`${pathLabel} must be a string level, false, or an object.`);
144
+ return errors;
145
+ }
146
+ const value = input as Record<string, unknown>;
147
+ if (value.level !== undefined && (typeof value.level !== "string" || !VALID_LEVELS.has(value.level as AcceptanceLevel))) {
148
+ errors.push(`${pathLabel}.level must be one of auto, none, attested, checked, verified, reviewed.`);
149
+ }
150
+ if (value.level === "none" && (typeof value.reason !== "string" || !value.reason.trim())) {
151
+ errors.push(`${pathLabel}.reason is required when level is none.`);
152
+ }
153
+ if (value.criteria !== undefined && !Array.isArray(value.criteria)) errors.push(`${pathLabel}.criteria must be an array.`);
154
+ if (Array.isArray(value.evidence)) {
155
+ for (const [index, item] of value.evidence.entries()) {
156
+ if (typeof item !== "string" || !VALID_EVIDENCE.has(item as AcceptanceEvidenceKind)) {
157
+ errors.push(`${pathLabel}.evidence[${index}] is not a supported evidence kind.`);
158
+ }
159
+ }
160
+ } else if (value.evidence !== undefined) {
161
+ errors.push(`${pathLabel}.evidence must be an array.`);
162
+ }
163
+ if (value.verify !== undefined && !Array.isArray(value.verify)) errors.push(`${pathLabel}.verify must be an array.`);
164
+ if (Array.isArray(value.verify)) {
165
+ for (const [index, command] of value.verify.entries()) {
166
+ if (!command || typeof command !== "object" || Array.isArray(command)) {
167
+ errors.push(`${pathLabel}.verify[${index}] must be an object.`);
168
+ continue;
169
+ }
170
+ const cmd = command as Record<string, unknown>;
171
+ if (typeof cmd.id !== "string" || !cmd.id.trim()) errors.push(`${pathLabel}.verify[${index}].id is required.`);
172
+ if (typeof cmd.command !== "string" || !cmd.command.trim()) errors.push(`${pathLabel}.verify[${index}].command is required.`);
173
+ if (cmd.timeoutMs !== undefined && (typeof cmd.timeoutMs !== "number" || cmd.timeoutMs <= 0)) {
174
+ errors.push(`${pathLabel}.verify[${index}].timeoutMs must be a positive number.`);
175
+ }
176
+ }
177
+ }
178
+ return errors;
179
+ }
180
+
181
+ function normalizeCriteria(criteria: Array<string | { id?: string; must?: string; evidence?: AcceptanceEvidenceKind[]; severity?: "required" | "recommended" }> | undefined, evidence: AcceptanceEvidenceKind[]): ResolvedAcceptanceGate[] {
182
+ return (criteria ?? []).map((criterion, index) => {
183
+ if (typeof criterion === "string") {
184
+ return { id: `criterion-${index + 1}`, must: criterion, evidence, severity: "required" };
185
+ }
186
+ return {
187
+ id: criterion.id?.trim() || `criterion-${index + 1}`,
188
+ must: criterion.must ?? "",
189
+ evidence: criterion.evidence?.filter((item) => VALID_EVIDENCE.has(item)) ?? evidence,
190
+ severity: criterion.severity ?? "required",
191
+ };
192
+ }).filter((criterion) => criterion.must.trim());
193
+ }
194
+
195
+ export function resolveEffectiveAcceptance(input: {
196
+ explicit?: AcceptanceInput;
197
+ agentName: string;
198
+ task?: string;
199
+ mode?: SubagentRunMode;
200
+ async?: boolean;
201
+ dynamic?: boolean;
202
+ dynamicGroup?: boolean;
203
+ }): ResolvedAcceptanceConfig {
204
+ const explicit = normalizeAcceptanceInput(input.explicit);
205
+ const inferred = inferLevel(input);
206
+ const explicitLevel = normalizeLevel(explicit.level);
207
+ const level = explicitAcceptanceCanDisable(explicit)
208
+ ? "none"
209
+ : explicitLevel === "auto"
210
+ ? inferred.level
211
+ : (LEVEL_RANK[explicitLevel] >= LEVEL_RANK[inferred.level] ? explicitLevel : inferred.level);
212
+ const evidence = unique([...(level === inferred.level ? inferred.evidence : requiredEvidenceForLevel(level)), ...(explicit.evidence ?? [])]);
213
+ const criteria = normalizeCriteria(
214
+ (explicit.criteria?.length ? explicit.criteria : inferred.criteria) as Array<string | { id?: string; must?: string; evidence?: AcceptanceEvidenceKind[]; severity?: "required" | "recommended" }>,
215
+ evidence,
216
+ );
217
+ let review = explicit.review !== undefined ? explicit.review : inferred.review;
218
+ if (level === "reviewed" && explicitLevel !== "auto" && explicitLevel !== "reviewed" && explicit.review === undefined && review && review !== false) {
219
+ review = { ...review, required: false };
220
+ }
221
+ return {
222
+ level,
223
+ explicit: input.explicit !== undefined,
224
+ inferredReason: inferred.reasons,
225
+ criteria,
226
+ evidence,
227
+ verify: explicit.verify ?? [],
228
+ review,
229
+ stopRules: explicit.stopRules ?? [],
230
+ reason: explicit.reason,
231
+ };
232
+ }
233
+
234
+ export function formatAcceptancePrompt(acceptance: ResolvedAcceptanceConfig): string {
235
+ if (acceptance.level === "none") return "";
236
+ const lines = [
237
+ "",
238
+ "## Acceptance Contract",
239
+ `Acceptance level: ${acceptance.level}`,
240
+ "Completion is not accepted from prose alone. End with a structured acceptance report.",
241
+ "",
242
+ "Criteria:",
243
+ ...(acceptance.criteria.length ? acceptance.criteria.map((criterion) => `- ${criterion.id}: ${criterion.must}`) : ["- Return the requested result."]),
244
+ "",
245
+ `Required evidence: ${acceptance.evidence.join(", ") || "none"}`,
246
+ ];
247
+ if (acceptance.verify.length > 0) {
248
+ lines.push("", "Runtime verification commands configured by parent:");
249
+ for (const command of acceptance.verify) lines.push(`- ${command.id}: ${command.command}`);
250
+ }
251
+ if (acceptance.review && acceptance.review !== false) {
252
+ lines.push("", `Review gate: ${acceptance.review.required === false ? "optional" : "required"}${acceptance.review.agent ? ` by ${acceptance.review.agent}` : ""}.`);
253
+ if (acceptance.review.focus) lines.push(`Review focus: ${acceptance.review.focus}`);
254
+ }
255
+ if (acceptance.stopRules.length > 0) {
256
+ lines.push("", "Stop rules:", ...acceptance.stopRules.map((rule) => `- ${rule}`));
257
+ }
258
+ lines.push(
259
+ "",
260
+ "Finish with a fenced JSON block tagged `acceptance-report` in this shape:",
261
+ "```acceptance-report",
262
+ JSON.stringify({
263
+ criteriaSatisfied: [{ id: "criterion-1", status: "satisfied", evidence: "specific proof" }],
264
+ changedFiles: [],
265
+ testsAddedOrUpdated: [],
266
+ commandsRun: [{ command: "command", result: "passed", summary: "short result" }],
267
+ validationOutput: [],
268
+ residualRisks: [],
269
+ noStagedFiles: true,
270
+ notes: "anything else the parent should know",
271
+ }, null, 2),
272
+ "```",
273
+ );
274
+ return lines.join("\n");
275
+ }
276
+
277
+ function extractBalancedJson(text: string, start: number): string | undefined {
278
+ let depth = 0;
279
+ let inString = false;
280
+ let escaped = false;
281
+ for (let i = start; i < text.length; i++) {
282
+ const char = text[i]!;
283
+ if (inString) {
284
+ if (escaped) escaped = false;
285
+ else if (char === "\\") escaped = true;
286
+ else if (char === "\"") inString = false;
287
+ continue;
288
+ }
289
+ if (char === "\"") {
290
+ inString = true;
291
+ continue;
292
+ }
293
+ if (char === "{") depth++;
294
+ if (char === "}") {
295
+ depth--;
296
+ if (depth === 0) return text.slice(start, i + 1);
297
+ }
298
+ }
299
+ return undefined;
300
+ }
301
+
302
+ export function parseAcceptanceReport(output: string): { report?: AcceptanceReport; error?: string } {
303
+ const fenced = [...output.matchAll(/```acceptance-report\s*\n([\s\S]*?)```/gi)]
304
+ .map((match) => match[1]?.trim())
305
+ .filter((value): value is string => Boolean(value));
306
+ const parseErrors: string[] = [];
307
+ for (const body of fenced) {
308
+ try {
309
+ const parsed = JSON.parse(body) as unknown;
310
+ const report = (parsed && typeof parsed === "object" && "acceptance" in parsed)
311
+ ? (parsed as { acceptance?: unknown }).acceptance
312
+ : parsed;
313
+ if (isAcceptanceReport(report)) return { report };
314
+ parseErrors.push("acceptance-report block does not contain a valid acceptance report");
315
+ } catch (error) {
316
+ parseErrors.push(error instanceof Error ? error.message : String(error));
317
+ }
318
+ }
319
+ if (parseErrors.length > 0) return { error: `Failed to parse acceptance-report: ${parseErrors.join("; ")}` };
320
+ const markerIndex = output.search(/ACCEPTANCE_REPORT\s*:/i);
321
+ if (markerIndex !== -1) {
322
+ const jsonStart = output.indexOf("{", markerIndex);
323
+ if (jsonStart !== -1) {
324
+ const json = extractBalancedJson(output, jsonStart);
325
+ if (json) {
326
+ try {
327
+ const parsed = JSON.parse(json) as unknown;
328
+ if (isAcceptanceReport(parsed)) return { report: parsed };
329
+ } catch (error) {
330
+ return { error: error instanceof Error ? error.message : String(error) };
331
+ }
332
+ }
333
+ }
334
+ }
335
+ return { error: "Structured acceptance report not found." };
336
+ }
337
+
338
+ export function stripAcceptanceReport(output: string): string {
339
+ return output
340
+ .replace(/\n?```acceptance-report\s*\n[\s\S]*?```\s*$/i, "")
341
+ .replace(/\n?ACCEPTANCE_REPORT\s*:\s*\{[\s\S]*\}\s*$/i, "")
342
+ .trimEnd();
343
+ }
344
+
345
+ function isStringArray(value: unknown): value is string[] {
346
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
347
+ }
348
+
349
+ function isAcceptanceReport(value: unknown): value is AcceptanceReport {
350
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
351
+ const report = value as AcceptanceReport;
352
+ if (report.criteriaSatisfied !== undefined) {
353
+ if (!Array.isArray(report.criteriaSatisfied)) return false;
354
+ for (const item of report.criteriaSatisfied) {
355
+ if (!item || typeof item !== "object" || Array.isArray(item)) return false;
356
+ const criterion = item as { id?: unknown; status?: unknown; evidence?: unknown };
357
+ if (criterion.id !== undefined && typeof criterion.id !== "string") return false;
358
+ if (criterion.status !== "satisfied" && criterion.status !== "not-satisfied" && criterion.status !== "not-applicable") return false;
359
+ if (typeof criterion.evidence !== "string" || !criterion.evidence.trim()) return false;
360
+ }
361
+ }
362
+ return report.criteriaSatisfied !== undefined
363
+ || report.changedFiles !== undefined
364
+ || report.testsAddedOrUpdated !== undefined
365
+ || report.commandsRun !== undefined
366
+ || report.residualRisks !== undefined
367
+ || report.manualNotes !== undefined
368
+ || report.reviewFindings !== undefined;
369
+ }
370
+
371
+ function checkCriteriaSatisfied(criteria: ResolvedAcceptanceGate[], report: AcceptanceReport): AcceptanceRuntimeCheck[] {
372
+ const reports = new Map((report.criteriaSatisfied ?? []).filter((item) => item.id).map((item) => [item.id!, item]));
373
+ return criteria.filter((criterion) => criterion.severity !== "recommended").map((criterion) => {
374
+ const item = reports.get(criterion.id);
375
+ if (!item) return { id: `criterion:${criterion.id}`, status: "failed", message: `Required criterion '${criterion.id}' was not reported.` };
376
+ if (item.status !== "satisfied") return { id: `criterion:${criterion.id}`, status: "failed", message: `Required criterion '${criterion.id}' was reported as ${item.status}.` };
377
+ return { id: `criterion:${criterion.id}`, status: "passed", message: `Required criterion '${criterion.id}' satisfied.` };
378
+ });
379
+ }
380
+
381
+ function reportEvidencePresent(report: AcceptanceReport, kind: AcceptanceEvidenceKind): boolean {
382
+ switch (kind) {
383
+ case "changed-files": return isStringArray(report.changedFiles) && report.changedFiles.length > 0;
384
+ case "tests-added": return isStringArray(report.testsAddedOrUpdated) && report.testsAddedOrUpdated.length > 0;
385
+ case "commands-run": return Array.isArray(report.commandsRun) && report.commandsRun.length > 0;
386
+ case "validation-output": return isStringArray(report.validationOutput) && report.validationOutput.length > 0;
387
+ case "residual-risks": return isStringArray(report.residualRisks);
388
+ case "no-staged-files": return report.noStagedFiles === true;
389
+ case "diff-summary": return typeof report.diffSummary === "string" && report.diffSummary.trim().length > 0;
390
+ case "review-findings": return isStringArray(report.reviewFindings);
391
+ case "manual-notes": return Boolean((report.manualNotes ?? report.notes)?.trim());
392
+ }
393
+ }
394
+
395
+ function checkNoStagedFiles(cwd: string): AcceptanceRuntimeCheck {
396
+ const result = spawnSync("git", ["status", "--short"], { cwd, encoding: "utf-8" });
397
+ if (result.status !== 0) {
398
+ return { id: "no-staged-files", status: "not-applicable", message: "git status unavailable; no staged-files check skipped" };
399
+ }
400
+ const staged = result.stdout.split(/\r?\n/).filter((line) => line.length >= 2 && line[0] !== " " && line[0] !== "?");
401
+ return staged.length === 0
402
+ ? { id: "no-staged-files", status: "passed", message: "No staged files detected." }
403
+ : { id: "no-staged-files", status: "failed", message: `Staged files present: ${staged.join(", ")}` };
404
+ }
405
+
406
+ function runStructuralChecks(acceptance: ResolvedAcceptanceConfig, report: AcceptanceReport, cwd: string): AcceptanceRuntimeCheck[] {
407
+ const checks: AcceptanceRuntimeCheck[] = [];
408
+ for (const kind of acceptance.evidence) {
409
+ const present = reportEvidencePresent(report, kind);
410
+ checks.push({
411
+ id: `evidence:${kind}`,
412
+ status: present ? "passed" : "failed",
413
+ message: present ? `${kind} evidence present.` : `${kind} evidence missing from child report.`,
414
+ });
415
+ }
416
+ if (acceptance.evidence.includes("no-staged-files")) checks.push(checkNoStagedFiles(cwd));
417
+ return checks;
418
+ }
419
+
420
+ function trimOutput(value: string): string | undefined {
421
+ const trimmed = value.trim();
422
+ if (!trimmed) return undefined;
423
+ return trimmed.length > 12_000 ? `${trimmed.slice(0, 12_000)}\n...[truncated]` : trimmed;
424
+ }
425
+
426
+ function uniqueStrings(items: Array<string | undefined>): string[] {
427
+ return unique(items.map((item) => item?.trim()).filter((item): item is string => Boolean(item)));
428
+ }
429
+
430
+ export function aggregateAcceptanceReport(input: {
431
+ results: Array<Pick<SingleResult, "agent" | "acceptance" | "error" | "exitCode">>;
432
+ notes?: string;
433
+ }): AcceptanceReport {
434
+ const childReports = input.results.map((result) => result.acceptance?.childReport).filter((report): report is AcceptanceReport => Boolean(report));
435
+ const blockers = input.results.filter((result) => result.exitCode !== 0 || result.acceptance?.status === "rejected");
436
+ const successfulChildren = input.results.length > 0 && blockers.length === 0;
437
+ return {
438
+ criteriaSatisfied: [
439
+ { id: "criterion-1", status: successfulChildren ? "satisfied" : "not-satisfied", evidence: successfulChildren ? `All ${input.results.length} dynamic child run(s) completed without child or acceptance blockers.` : "Dynamic fanout produced no accepted child evidence." },
440
+ { id: "criterion-2", status: successfulChildren ? "satisfied" : "not-satisfied", evidence: successfulChildren ? "Collected child acceptance evidence for aggregate review." : "Dynamic fanout produced no aggregate review evidence." },
441
+ ...input.results.map((result, index) => ({
442
+ id: `child-${index + 1}`,
443
+ status: result.exitCode === 0 && result.acceptance?.status !== "rejected" ? "satisfied" : "not-satisfied",
444
+ evidence: `${result.agent}: acceptance ${result.acceptance?.status ?? "unreported"}${result.error ? ` (${result.error})` : ""}`,
445
+ })),
446
+ ],
447
+ changedFiles: uniqueStrings(childReports.flatMap((report) => report.changedFiles ?? [])),
448
+ testsAddedOrUpdated: uniqueStrings(childReports.flatMap((report) => report.testsAddedOrUpdated ?? [])),
449
+ commandsRun: childReports.flatMap((report) => report.commandsRun ?? []),
450
+ validationOutput: uniqueStrings(childReports.flatMap((report) => report.validationOutput ?? [])),
451
+ residualRisks: uniqueStrings([
452
+ ...childReports.flatMap((report) => report.residualRisks ?? []),
453
+ ...blockers.map((result) => `${result.agent}: ${result.error ?? "child or acceptance gate failed"}`),
454
+ ]),
455
+ noStagedFiles: childReports.length > 0 && childReports.every((report) => report.noStagedFiles === true),
456
+ reviewFindings: uniqueStrings(childReports.flatMap((report) => report.reviewFindings ?? [])),
457
+ manualNotes: input.notes ?? `Aggregated acceptance evidence from ${input.results.length} dynamic fanout child run(s).`,
458
+ notes: input.notes,
459
+ };
460
+ }
461
+
462
+ function runVerifyCommand(command: AcceptanceVerifyCommand, defaultCwd: string): Promise<AcceptanceVerifyResult> {
463
+ return new Promise((resolve) => {
464
+ const startedAt = Date.now();
465
+ const cwd = command.cwd ? path.resolve(defaultCwd, command.cwd) : defaultCwd;
466
+ let stdout = "";
467
+ let stderr = "";
468
+ let timedOut = false;
469
+ const child = spawn(command.command, {
470
+ cwd,
471
+ env: { ...process.env, ...(command.env ?? {}) },
472
+ shell: true,
473
+ stdio: ["ignore", "pipe", "pipe"],
474
+ windowsHide: true,
475
+ });
476
+ const timeout = setTimeout(() => {
477
+ timedOut = true;
478
+ child.kill("SIGTERM");
479
+ setTimeout(() => child.kill("SIGKILL"), 1000).unref?.();
480
+ }, command.timeoutMs ?? 120_000);
481
+ timeout.unref?.();
482
+ child.stdout.on("data", (chunk: Buffer) => {
483
+ stdout += chunk.toString();
484
+ });
485
+ child.stderr.on("data", (chunk: Buffer) => {
486
+ stderr += chunk.toString();
487
+ });
488
+ child.on("close", (exitCode) => {
489
+ clearTimeout(timeout);
490
+ const durationMs = Date.now() - startedAt;
491
+ const passed = exitCode === 0 && !timedOut;
492
+ resolve({
493
+ id: command.id,
494
+ command: command.command,
495
+ cwd,
496
+ exitCode,
497
+ status: timedOut ? "timed-out" : passed ? "passed" : command.allowFailure ? "allowed-failure" : "failed",
498
+ stdout: trimOutput(stdout),
499
+ stderr: trimOutput(stderr),
500
+ durationMs,
501
+ });
502
+ });
503
+ child.on("error", (error) => {
504
+ clearTimeout(timeout);
505
+ resolve({
506
+ id: command.id,
507
+ command: command.command,
508
+ cwd,
509
+ exitCode: 1,
510
+ status: command.allowFailure ? "allowed-failure" : "failed",
511
+ stderr: error instanceof Error ? error.message : String(error),
512
+ durationMs: Date.now() - startedAt,
513
+ });
514
+ });
515
+ });
516
+ }
517
+
518
+ export async function evaluateAcceptance(input: {
519
+ acceptance: ResolvedAcceptanceConfig;
520
+ output: string;
521
+ cwd: string;
522
+ report?: AcceptanceReport;
523
+ reviewResult?: AcceptanceReviewResult;
524
+ }): Promise<AcceptanceLedger> {
525
+ const acceptance = input.acceptance;
526
+ const ledger: AcceptanceLedger = {
527
+ status: acceptance.level === "none" ? "not-required" : "claimed",
528
+ explicit: acceptance.explicit,
529
+ effectiveAcceptance: acceptance,
530
+ inferredReason: acceptance.inferredReason,
531
+ criteria: acceptance.criteria,
532
+ runtimeChecks: [],
533
+ verifyRuns: [],
534
+ };
535
+ if (acceptance.level === "none") return ledger;
536
+
537
+ const parsed = input.report ? { report: input.report } : parseAcceptanceReport(input.output);
538
+ if (parsed.report) {
539
+ ledger.childReport = parsed.report;
540
+ ledger.status = "attested";
541
+ } else {
542
+ ledger.childReportParseError = parsed.error;
543
+ ledger.runtimeChecks.push({ id: "attestation", status: "failed", message: parsed.error ?? "Structured acceptance report missing." });
544
+ ledger.status = "rejected";
545
+ return ledger;
546
+ }
547
+
548
+ if (LEVEL_RANK[acceptance.level] >= LEVEL_RANK.checked) {
549
+ ledger.runtimeChecks = [
550
+ ...checkCriteriaSatisfied(acceptance.criteria, parsed.report),
551
+ ...runStructuralChecks(acceptance, parsed.report, input.cwd),
552
+ ];
553
+ if (ledger.runtimeChecks.some((check) => check.status === "failed")) {
554
+ ledger.status = "rejected";
555
+ return ledger;
556
+ }
557
+ ledger.status = "checked";
558
+ }
559
+
560
+ if (LEVEL_RANK[acceptance.level] >= LEVEL_RANK.verified && (acceptance.level === "verified" || acceptance.verify.length > 0)) {
561
+ if (acceptance.level === "verified" && acceptance.verify.length === 0) {
562
+ ledger.runtimeChecks.push({ id: "verification-config", status: "failed", message: "verified acceptance requires runtime verify commands." });
563
+ ledger.status = "rejected";
564
+ return ledger;
565
+ }
566
+ ledger.verifyRuns = [];
567
+ for (const command of acceptance.verify) ledger.verifyRuns.push(await runVerifyCommand(command, input.cwd));
568
+ if (ledger.verifyRuns.some((run) => run.status === "failed" || run.status === "timed-out")) {
569
+ ledger.status = "rejected";
570
+ return ledger;
571
+ }
572
+ ledger.status = "verified";
573
+ }
574
+
575
+ if (acceptance.level === "reviewed") {
576
+ if (input.reviewResult) {
577
+ ledger.reviewResult = input.reviewResult;
578
+ ledger.status = input.reviewResult.status === "no-blockers" ? "reviewed" : "rejected";
579
+ } else {
580
+ const optionalReview = acceptance.review && acceptance.review !== false && acceptance.review.required === false;
581
+ ledger.reviewResult = {
582
+ status: "needs-parent-decision",
583
+ findings: [{
584
+ severity: acceptance.explicit && !optionalReview ? "blocker" : "non-blocking",
585
+ issue: "Reviewed acceptance requires an independent reviewer result.",
586
+ rationale: "The run cannot be marked reviewed from child evidence alone.",
587
+ }],
588
+ };
589
+ if (acceptance.review === false || (acceptance.explicit && !optionalReview)) ledger.status = "rejected";
590
+ }
591
+ }
592
+
593
+ return ledger;
594
+ }
595
+
596
+ export function acceptanceFailureMessage(ledger: AcceptanceLedger): string | undefined {
597
+ if (ledger.status !== "rejected") return undefined;
598
+ const failedCheck = ledger.runtimeChecks.find((check) => check.status === "failed");
599
+ if (failedCheck) return `Acceptance rejected: ${failedCheck.message}`;
600
+ const failedVerify = ledger.verifyRuns.find((run) => run.status === "failed" || run.status === "timed-out");
601
+ if (failedVerify) return `Acceptance verification '${failedVerify.id}' ${failedVerify.status}.`;
602
+ if (ledger.reviewResult?.status === "needs-parent-decision") return "Acceptance review required but no automatic reviewer result is available.";
603
+ if (ledger.reviewResult?.status === "blockers") return "Acceptance review found blockers.";
604
+ return "Acceptance rejected.";
605
+ }