pi-subagents 0.25.0 → 0.28.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 (40) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +175 -19
  3. package/package.json +1 -1
  4. package/prompts/parallel-context-build.md +3 -1
  5. package/prompts/parallel-handoff-plan.md +3 -1
  6. package/skills/pi-subagents/SKILL.md +60 -17
  7. package/src/agents/agent-management.ts +71 -15
  8. package/src/agents/agent-serializer.ts +13 -2
  9. package/src/agents/agents.ts +88 -17
  10. package/src/agents/chain-serializer.ts +120 -0
  11. package/src/extension/fanout-child.ts +2 -0
  12. package/src/extension/index.ts +5 -2
  13. package/src/extension/schemas.ts +132 -6
  14. package/src/intercom/result-intercom.ts +5 -0
  15. package/src/runs/background/async-execution.ts +88 -6
  16. package/src/runs/background/async-status.ts +11 -1
  17. package/src/runs/background/run-status.ts +10 -1
  18. package/src/runs/background/subagent-runner.ts +665 -39
  19. package/src/runs/foreground/chain-execution.ts +369 -118
  20. package/src/runs/foreground/execution.ts +392 -19
  21. package/src/runs/foreground/subagent-executor.ts +126 -3
  22. package/src/runs/shared/acceptance-contract.ts +318 -0
  23. package/src/runs/shared/acceptance-evaluation.ts +221 -0
  24. package/src/runs/shared/acceptance-finalization.ts +173 -0
  25. package/src/runs/shared/acceptance-reports.ts +127 -0
  26. package/src/runs/shared/acceptance.ts +22 -0
  27. package/src/runs/shared/chain-outputs.ts +101 -0
  28. package/src/runs/shared/completion-guard.ts +26 -3
  29. package/src/runs/shared/dynamic-fanout.ts +293 -0
  30. package/src/runs/shared/parallel-utils.ts +33 -1
  31. package/src/runs/shared/pi-args.ts +11 -0
  32. package/src/runs/shared/structured-output.ts +77 -0
  33. package/src/runs/shared/subagent-prompt-runtime.ts +53 -3
  34. package/src/runs/shared/workflow-graph.ts +210 -0
  35. package/src/shared/formatters.ts +2 -2
  36. package/src/shared/settings.ts +53 -4
  37. package/src/shared/types.ts +265 -1
  38. package/src/shared/utils.ts +7 -0
  39. package/src/slash/slash-commands.ts +41 -3
  40. package/src/tui/render.ts +178 -45
@@ -0,0 +1,318 @@
1
+ import type {
2
+ AcceptanceConfig,
3
+ AcceptanceEvidenceKind,
4
+ AcceptanceInput,
5
+ AcceptanceProvenanceLevel,
6
+ ResolvedAcceptanceConfig,
7
+ ResolvedAcceptanceGate,
8
+ SubagentRunMode,
9
+ } from "../../shared/types.ts";
10
+
11
+ const DEFAULT_FINALIZATION_MAX_TURNS = 3;
12
+ const MAX_FINALIZATION_TURNS = 10;
13
+
14
+ const VALID_EVIDENCE = new Set<AcceptanceEvidenceKind>([
15
+ "changed-files",
16
+ "tests-added",
17
+ "commands-run",
18
+ "validation-output",
19
+ "residual-risks",
20
+ "no-staged-files",
21
+ "diff-summary",
22
+ "review-findings",
23
+ "manual-notes",
24
+ ]);
25
+
26
+ const ACCEPTANCE_KEYS = new Set([
27
+ "criteria",
28
+ "evidence",
29
+ "verify",
30
+ "review",
31
+ "stopRules",
32
+ "maxFinalizationTurns",
33
+ ]);
34
+
35
+ const REMOVED_ACCEPTANCE_KEYS = new Set(["level", "finalization", "reason"]);
36
+
37
+ const EVIDENCE_REPORT_FIELDS: Record<AcceptanceEvidenceKind, string> = {
38
+ "changed-files": "changedFiles: array of changed file paths",
39
+ "tests-added": "testsAddedOrUpdated: array of test files, suites, or cases added/updated",
40
+ "commands-run": "commandsRun: array of commands with result passed/failed/not-run and a short summary",
41
+ "validation-output": "validationOutput: array of relevant validation output summaries",
42
+ "residual-risks": "residualRisks: array of remaining risks or blockers; use [] when none remain",
43
+ "no-staged-files": "noStagedFiles: boolean",
44
+ "diff-summary": "diffSummary: non-empty string summarizing changed behavior and important files",
45
+ "review-findings": "reviewFindings: array of reviewer findings; use [] when no findings remain",
46
+ "manual-notes": "manualNotes: string for manual notes or external evidence",
47
+ };
48
+
49
+ export function formatEvidenceReportFieldMapping(evidence: AcceptanceEvidenceKind[]): string[] {
50
+ return evidence.map((kind) => `- ${kind} -> ${EVIDENCE_REPORT_FIELDS[kind]}`);
51
+ }
52
+
53
+ function hasArrayItems(value: unknown): boolean {
54
+ return Array.isArray(value) && value.length > 0;
55
+ }
56
+
57
+ export function validateAcceptanceInput(input: unknown, pathLabel = "acceptance"): string[] {
58
+ const errors: string[] = [];
59
+ if (input === undefined) return errors;
60
+ if (input === false || typeof input === "string") {
61
+ errors.push(`${pathLabel} must be an object. Public acceptance levels and false disables are no longer supported.`);
62
+ return errors;
63
+ }
64
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
65
+ errors.push(`${pathLabel} must be an object.`);
66
+ return errors;
67
+ }
68
+
69
+ const value = input as Record<string, unknown>;
70
+ if (Object.hasOwn(value, "level")) {
71
+ errors.push(`${pathLabel}.level is no longer supported; configure criteria, evidence, verify, and review directly.`);
72
+ }
73
+ if (Object.hasOwn(value, "finalization")) {
74
+ errors.push(`${pathLabel}.finalization is not supported; acceptance contracts always run the self-review loop.`);
75
+ }
76
+ if (Object.hasOwn(value, "reason")) {
77
+ errors.push(`${pathLabel}.reason is not supported because acceptance is disabled by omitting the field.`);
78
+ }
79
+ for (const key of Object.keys(value)) {
80
+ if (!ACCEPTANCE_KEYS.has(key) && !REMOVED_ACCEPTANCE_KEYS.has(key)) errors.push(`${pathLabel}.${key} is not supported.`);
81
+ }
82
+
83
+ if (value.criteria !== undefined) {
84
+ if (!Array.isArray(value.criteria)) {
85
+ errors.push(`${pathLabel}.criteria must be an array.`);
86
+ } else {
87
+ for (const [index, criterion] of value.criteria.entries()) {
88
+ if (typeof criterion === "string") {
89
+ if (!criterion.trim()) errors.push(`${pathLabel}.criteria[${index}] must not be empty.`);
90
+ continue;
91
+ }
92
+ if (!criterion || typeof criterion !== "object" || Array.isArray(criterion)) {
93
+ errors.push(`${pathLabel}.criteria[${index}] must be a string or object.`);
94
+ continue;
95
+ }
96
+ const item = criterion as Record<string, unknown>;
97
+ if (typeof item.id !== "string" || !item.id.trim()) errors.push(`${pathLabel}.criteria[${index}].id is required.`);
98
+ if (typeof item.must !== "string" || !item.must.trim()) errors.push(`${pathLabel}.criteria[${index}].must is required.`);
99
+ if (item.evidence !== undefined && !Array.isArray(item.evidence)) errors.push(`${pathLabel}.criteria[${index}].evidence must be an array.`);
100
+ if (Array.isArray(item.evidence)) {
101
+ for (const [evidenceIndex, evidence] of item.evidence.entries()) {
102
+ if (typeof evidence !== "string" || !VALID_EVIDENCE.has(evidence as AcceptanceEvidenceKind)) {
103
+ errors.push(`${pathLabel}.criteria[${index}].evidence[${evidenceIndex}] is not a supported evidence kind.`);
104
+ }
105
+ }
106
+ }
107
+ if (item.severity !== undefined && item.severity !== "required" && item.severity !== "recommended") {
108
+ errors.push(`${pathLabel}.criteria[${index}].severity must be required or recommended.`);
109
+ }
110
+ }
111
+ }
112
+ }
113
+
114
+ if (Array.isArray(value.evidence)) {
115
+ for (const [index, item] of value.evidence.entries()) {
116
+ if (typeof item !== "string" || !VALID_EVIDENCE.has(item as AcceptanceEvidenceKind)) {
117
+ errors.push(`${pathLabel}.evidence[${index}] is not a supported evidence kind.`);
118
+ }
119
+ }
120
+ } else if (value.evidence !== undefined) {
121
+ errors.push(`${pathLabel}.evidence must be an array.`);
122
+ }
123
+
124
+ if (value.verify !== undefined && !Array.isArray(value.verify)) errors.push(`${pathLabel}.verify must be an array.`);
125
+ if (Array.isArray(value.verify)) {
126
+ for (const [index, command] of value.verify.entries()) {
127
+ if (!command || typeof command !== "object" || Array.isArray(command)) {
128
+ errors.push(`${pathLabel}.verify[${index}] must be an object.`);
129
+ continue;
130
+ }
131
+ const cmd = command as Record<string, unknown>;
132
+ if (typeof cmd.id !== "string" || !cmd.id.trim()) errors.push(`${pathLabel}.verify[${index}].id is required.`);
133
+ if (typeof cmd.command !== "string" || !cmd.command.trim()) errors.push(`${pathLabel}.verify[${index}].command is required.`);
134
+ if (cmd.timeoutMs !== undefined && (!Number.isInteger(cmd.timeoutMs) || Number(cmd.timeoutMs) <= 0)) {
135
+ errors.push(`${pathLabel}.verify[${index}].timeoutMs must be a positive integer.`);
136
+ }
137
+ if (cmd.cwd !== undefined && typeof cmd.cwd !== "string") errors.push(`${pathLabel}.verify[${index}].cwd must be a string.`);
138
+ if (cmd.env !== undefined) {
139
+ if (!cmd.env || typeof cmd.env !== "object" || Array.isArray(cmd.env)) {
140
+ errors.push(`${pathLabel}.verify[${index}].env must be an object with string values.`);
141
+ } else {
142
+ for (const [key, envValue] of Object.entries(cmd.env as Record<string, unknown>)) {
143
+ if (typeof envValue !== "string") errors.push(`${pathLabel}.verify[${index}].env.${key} must be a string.`);
144
+ }
145
+ }
146
+ }
147
+ if (cmd.allowFailure !== undefined && typeof cmd.allowFailure !== "boolean") errors.push(`${pathLabel}.verify[${index}].allowFailure must be a boolean.`);
148
+ }
149
+ }
150
+
151
+ if (value.review !== undefined) {
152
+ if (!value.review || typeof value.review !== "object" || Array.isArray(value.review)) {
153
+ errors.push(`${pathLabel}.review must be an object.`);
154
+ } else {
155
+ const review = value.review as Record<string, unknown>;
156
+ if (review.agent !== undefined && typeof review.agent !== "string") errors.push(`${pathLabel}.review.agent must be a string.`);
157
+ if (review.focus !== undefined && typeof review.focus !== "string") errors.push(`${pathLabel}.review.focus must be a string.`);
158
+ if (review.required !== undefined && typeof review.required !== "boolean") errors.push(`${pathLabel}.review.required must be a boolean.`);
159
+ }
160
+ }
161
+
162
+ if (value.stopRules !== undefined) {
163
+ if (!Array.isArray(value.stopRules)) {
164
+ errors.push(`${pathLabel}.stopRules must be an array.`);
165
+ } else {
166
+ for (const [index, rule] of value.stopRules.entries()) {
167
+ if (typeof rule !== "string" || !rule.trim()) errors.push(`${pathLabel}.stopRules[${index}] must be a non-empty string.`);
168
+ }
169
+ }
170
+ }
171
+
172
+ if (value.maxFinalizationTurns !== undefined) {
173
+ if (!Number.isInteger(value.maxFinalizationTurns) || Number(value.maxFinalizationTurns) < 1 || Number(value.maxFinalizationTurns) > MAX_FINALIZATION_TURNS) {
174
+ errors.push(`${pathLabel}.maxFinalizationTurns must be an integer from 1 to ${MAX_FINALIZATION_TURNS}.`);
175
+ }
176
+ }
177
+
178
+ const hasContract = hasArrayItems(value.criteria)
179
+ || hasArrayItems(value.evidence)
180
+ || hasArrayItems(value.verify)
181
+ || value.review !== undefined
182
+ || hasArrayItems(value.stopRules);
183
+ if (!hasContract) {
184
+ errors.push(`${pathLabel} must include at least one of criteria, evidence, verify, review, or stopRules.`);
185
+ }
186
+
187
+ return errors;
188
+ }
189
+
190
+ function normalizeCriteria(criteria: AcceptanceConfig["criteria"], evidence: AcceptanceEvidenceKind[]): ResolvedAcceptanceGate[] {
191
+ return (criteria ?? []).map((criterion, index) => {
192
+ if (typeof criterion === "string") {
193
+ return { id: `criterion-${index + 1}`, must: criterion, evidence, severity: "required" as const };
194
+ }
195
+ return {
196
+ id: criterion.id.trim(),
197
+ must: criterion.must,
198
+ evidence: criterion.evidence?.filter((item) => VALID_EVIDENCE.has(item)) ?? evidence,
199
+ severity: criterion.severity ?? "required",
200
+ };
201
+ }).filter((criterion) => criterion.must.trim());
202
+ }
203
+
204
+ function deriveAcceptanceLevel(config: AcceptanceConfig): AcceptanceProvenanceLevel {
205
+ if (config.review) return "reviewed";
206
+ if ((config.verify?.length ?? 0) > 0) return "verified";
207
+ return "checked";
208
+ }
209
+
210
+ export function resolveEffectiveAcceptance(input: {
211
+ explicit?: AcceptanceInput;
212
+ agentName: string;
213
+ task?: string;
214
+ mode?: SubagentRunMode;
215
+ async?: boolean;
216
+ dynamic?: boolean;
217
+ dynamicGroup?: boolean;
218
+ }): ResolvedAcceptanceConfig {
219
+ if (input.explicit === undefined) {
220
+ return {
221
+ level: "none",
222
+ explicit: false,
223
+ inferredReason: ["acceptance not configured"],
224
+ criteria: [],
225
+ evidence: [],
226
+ verify: [],
227
+ stopRules: [],
228
+ finalization: { mode: "none", maxTurns: 0 },
229
+ };
230
+ }
231
+
232
+ const validationErrors = validateAcceptanceInput(input.explicit);
233
+ if (validationErrors.length > 0) throw new Error(validationErrors.join(" "));
234
+ const explicit = input.explicit;
235
+ const evidence = [...new Set(explicit.evidence ?? [])];
236
+ const criteria = normalizeCriteria(explicit.criteria, evidence);
237
+ const verify = explicit.verify ?? [];
238
+ const stopRules = explicit.stopRules ?? [];
239
+ return {
240
+ level: deriveAcceptanceLevel(explicit),
241
+ explicit: true,
242
+ inferredReason: ["explicit acceptance contract"],
243
+ criteria,
244
+ evidence,
245
+ verify,
246
+ ...(explicit.review ? { review: explicit.review } : {}),
247
+ stopRules,
248
+ finalization: { mode: "self-review-loop", maxTurns: explicit.maxFinalizationTurns ?? DEFAULT_FINALIZATION_MAX_TURNS },
249
+ };
250
+ }
251
+
252
+ export function shouldRunAcceptanceFinalization(acceptance: ResolvedAcceptanceConfig): boolean {
253
+ return acceptance.explicit && acceptance.finalization.mode === "self-review-loop" && acceptance.finalization.maxTurns > 0;
254
+ }
255
+
256
+ export function acceptanceSelfReviewConfig(acceptance: ResolvedAcceptanceConfig): ResolvedAcceptanceConfig {
257
+ if (!acceptance.review && acceptance.verify.length === 0) return acceptance;
258
+ const { review: _review, verify: _verify, ...selfReview } = acceptance;
259
+ return {
260
+ ...selfReview,
261
+ level: "checked",
262
+ verify: [],
263
+ };
264
+ }
265
+
266
+ export function formatAcceptancePrompt(acceptance: ResolvedAcceptanceConfig): string {
267
+ if (acceptance.level === "none") return "";
268
+ const lines = [
269
+ "",
270
+ "## Acceptance Contract",
271
+ "Completion is not accepted from prose alone. End the initial response with a structured acceptance report.",
272
+ "After the initial response, the runtime will continue this same session for a bounded self-review/repair loop before accepting the run.",
273
+ "",
274
+ "Criteria:",
275
+ ...(acceptance.criteria.length ? acceptance.criteria.map((criterion) => `- ${criterion.id}: ${criterion.must}`) : ["- No explicit criteria were configured; satisfy the requested task and the required evidence/checks below."]),
276
+ "",
277
+ `Required evidence: ${acceptance.evidence.join(", ") || "none explicitly requested"}`,
278
+ ];
279
+ if (acceptance.evidence.length > 0) {
280
+ lines.push(
281
+ "",
282
+ "Structured evidence must be present in the `acceptance-report` JSON fields. Markdown sections in your visible answer do not satisfy required evidence by themselves. If you already described evidence in prose, copy or summarize it into the matching JSON field.",
283
+ "Evidence field mapping:",
284
+ ...formatEvidenceReportFieldMapping(acceptance.evidence),
285
+ );
286
+ }
287
+ if (acceptance.verify.length > 0) {
288
+ lines.push("", "Runtime verification commands configured by parent:");
289
+ for (const command of acceptance.verify) lines.push(`- ${command.id}: ${command.command}`);
290
+ }
291
+ if (acceptance.review) {
292
+ lines.push("", `Independent review gate: ${acceptance.review.required === false ? "optional" : "required"}${acceptance.review.agent ? ` by ${acceptance.review.agent}` : ""}.`);
293
+ if (acceptance.review.focus) lines.push(`Review focus: ${acceptance.review.focus}`);
294
+ }
295
+ if (acceptance.stopRules.length > 0) {
296
+ lines.push("", "Stop rules:", ...acceptance.stopRules.map((rule) => `- ${rule}`));
297
+ }
298
+ lines.push(
299
+ "",
300
+ "Finish with a fenced JSON block tagged `acceptance-report` in this shape:",
301
+ "```acceptance-report",
302
+ JSON.stringify({
303
+ criteriaSatisfied: [{ id: "criterion-1", status: "satisfied", evidence: "specific proof" }],
304
+ changedFiles: [],
305
+ testsAddedOrUpdated: [],
306
+ commandsRun: [{ command: "command", result: "passed", summary: "short result" }],
307
+ validationOutput: [],
308
+ residualRisks: [],
309
+ noStagedFiles: true,
310
+ diffSummary: "concise summary of changed behavior and important files",
311
+ reviewFindings: [],
312
+ manualNotes: "manual notes or external evidence, if any",
313
+ notes: "anything else the parent should know",
314
+ }, null, 2),
315
+ "```",
316
+ );
317
+ return lines.join("\n");
318
+ }
@@ -0,0 +1,221 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import * as path from "node:path";
3
+ import type {
4
+ AcceptanceEvidenceKind,
5
+ AcceptanceLedger,
6
+ AcceptanceProvenanceLevel,
7
+ AcceptanceReport,
8
+ AcceptanceRuntimeCheck,
9
+ AcceptanceReviewResult,
10
+ AcceptanceVerifyCommand,
11
+ AcceptanceVerifyResult,
12
+ ResolvedAcceptanceConfig,
13
+ ResolvedAcceptanceGate,
14
+ } from "../../shared/types.ts";
15
+ import { parseAcceptanceReport } from "./acceptance-reports.ts";
16
+
17
+ const LEVEL_RANK: Record<AcceptanceProvenanceLevel, number> = {
18
+ none: 0,
19
+ attested: 1,
20
+ checked: 2,
21
+ verified: 3,
22
+ reviewed: 4,
23
+ };
24
+
25
+ function isStringArray(value: unknown): value is string[] {
26
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
27
+ }
28
+
29
+ function checkCriteriaSatisfied(criteria: ResolvedAcceptanceGate[], report: AcceptanceReport): AcceptanceRuntimeCheck[] {
30
+ const reports = new Map((report.criteriaSatisfied ?? []).filter((item) => item.id).map((item) => [item.id!, item]));
31
+ return criteria.filter((criterion) => criterion.severity !== "recommended").map((criterion) => {
32
+ const item = reports.get(criterion.id);
33
+ if (!item) return { id: `criterion:${criterion.id}`, status: "failed", message: `Required criterion '${criterion.id}' was not reported.` };
34
+ if (item.status !== "satisfied") return { id: `criterion:${criterion.id}`, status: "failed", message: `Required criterion '${criterion.id}' was reported as ${item.status}.` };
35
+ return { id: `criterion:${criterion.id}`, status: "passed", message: `Required criterion '${criterion.id}' satisfied.` };
36
+ });
37
+ }
38
+
39
+ function reportEvidencePresent(report: AcceptanceReport, kind: AcceptanceEvidenceKind): boolean {
40
+ switch (kind) {
41
+ case "changed-files": return isStringArray(report.changedFiles) && report.changedFiles.length > 0;
42
+ case "tests-added": return isStringArray(report.testsAddedOrUpdated) && report.testsAddedOrUpdated.length > 0;
43
+ case "commands-run": return Array.isArray(report.commandsRun) && report.commandsRun.length > 0;
44
+ case "validation-output": return isStringArray(report.validationOutput) && report.validationOutput.length > 0;
45
+ case "residual-risks": return isStringArray(report.residualRisks);
46
+ case "no-staged-files": return report.noStagedFiles === true;
47
+ case "diff-summary": return typeof report.diffSummary === "string" && report.diffSummary.trim().length > 0;
48
+ case "review-findings": return isStringArray(report.reviewFindings);
49
+ case "manual-notes": return Boolean((report.manualNotes ?? report.notes)?.trim());
50
+ }
51
+ }
52
+
53
+ function checkNoStagedFiles(cwd: string): AcceptanceRuntimeCheck {
54
+ const result = spawnSync("git", ["status", "--short"], { cwd, encoding: "utf-8" });
55
+ if (result.status !== 0) {
56
+ return { id: "no-staged-files", status: "not-applicable", message: "git status unavailable; no staged-files check skipped" };
57
+ }
58
+ const staged = result.stdout.split(/\r?\n/).filter((line) => line.length >= 2 && line[0] !== " " && line[0] !== "?");
59
+ return staged.length === 0
60
+ ? { id: "no-staged-files", status: "passed", message: "No staged files detected." }
61
+ : { id: "no-staged-files", status: "failed", message: `Staged files present: ${staged.join(", ")}` };
62
+ }
63
+
64
+ function runStructuralChecks(acceptance: ResolvedAcceptanceConfig, report: AcceptanceReport, cwd: string): AcceptanceRuntimeCheck[] {
65
+ const checks: AcceptanceRuntimeCheck[] = [];
66
+ checks.push(...checkCriteriaSatisfied(acceptance.criteria, report));
67
+ for (const kind of acceptance.evidence) {
68
+ const present = reportEvidencePresent(report, kind);
69
+ checks.push({
70
+ id: `evidence:${kind}`,
71
+ status: present ? "passed" : "failed",
72
+ message: present ? `${kind} evidence present.` : `${kind} evidence missing from child report.`,
73
+ });
74
+ }
75
+ if (acceptance.evidence.includes("no-staged-files")) checks.push(checkNoStagedFiles(cwd));
76
+ return checks;
77
+ }
78
+
79
+ function trimOutput(value: string): string | undefined {
80
+ const trimmed = value.trim();
81
+ if (!trimmed) return undefined;
82
+ return trimmed.length > 12_000 ? `${trimmed.slice(0, 12_000)}\n...[truncated]` : trimmed;
83
+ }
84
+
85
+ function runVerifyCommand(command: AcceptanceVerifyCommand, defaultCwd: string): Promise<AcceptanceVerifyResult> {
86
+ return new Promise((resolve) => {
87
+ const startedAt = Date.now();
88
+ const cwd = command.cwd ? path.resolve(defaultCwd, command.cwd) : defaultCwd;
89
+ let stdout = "";
90
+ let stderr = "";
91
+ let timedOut = false;
92
+ const child = spawn(command.command, {
93
+ cwd,
94
+ env: { ...process.env, ...(command.env ?? {}) },
95
+ shell: true,
96
+ stdio: ["ignore", "pipe", "pipe"],
97
+ windowsHide: true,
98
+ });
99
+ const timeout = setTimeout(() => {
100
+ timedOut = true;
101
+ child.kill("SIGTERM");
102
+ setTimeout(() => child.kill("SIGKILL"), 1000).unref?.();
103
+ }, command.timeoutMs ?? 120_000);
104
+ timeout.unref?.();
105
+ child.stdout.on("data", (chunk: Buffer) => {
106
+ stdout += chunk.toString();
107
+ });
108
+ child.stderr.on("data", (chunk: Buffer) => {
109
+ stderr += chunk.toString();
110
+ });
111
+ child.on("close", (exitCode) => {
112
+ clearTimeout(timeout);
113
+ const durationMs = Date.now() - startedAt;
114
+ const passed = exitCode === 0 && !timedOut;
115
+ resolve({
116
+ id: command.id,
117
+ command: command.command,
118
+ cwd,
119
+ exitCode,
120
+ status: timedOut ? "timed-out" : passed ? "passed" : command.allowFailure ? "allowed-failure" : "failed",
121
+ stdout: trimOutput(stdout),
122
+ stderr: trimOutput(stderr),
123
+ durationMs,
124
+ });
125
+ });
126
+ child.on("error", (error) => {
127
+ clearTimeout(timeout);
128
+ resolve({
129
+ id: command.id,
130
+ command: command.command,
131
+ cwd,
132
+ exitCode: 1,
133
+ status: command.allowFailure ? "allowed-failure" : "failed",
134
+ stderr: error instanceof Error ? error.message : String(error),
135
+ durationMs: Date.now() - startedAt,
136
+ });
137
+ });
138
+ });
139
+ }
140
+
141
+ export async function evaluateAcceptance(input: {
142
+ acceptance: ResolvedAcceptanceConfig;
143
+ output: string;
144
+ cwd: string;
145
+ report?: AcceptanceReport;
146
+ reviewResult?: AcceptanceReviewResult;
147
+ }): Promise<AcceptanceLedger> {
148
+ const acceptance = input.acceptance;
149
+ const ledger: AcceptanceLedger = {
150
+ status: acceptance.level === "none" ? "not-required" : "claimed",
151
+ explicit: acceptance.explicit,
152
+ effectiveAcceptance: acceptance,
153
+ inferredReason: acceptance.inferredReason,
154
+ criteria: acceptance.criteria,
155
+ runtimeChecks: [],
156
+ verifyRuns: [],
157
+ };
158
+ if (acceptance.level === "none") return ledger;
159
+
160
+ const parsed = input.report ? { report: input.report } : parseAcceptanceReport(input.output);
161
+ if (parsed.report) {
162
+ ledger.childReport = parsed.report;
163
+ ledger.status = "attested";
164
+ } else {
165
+ ledger.childReportParseError = parsed.error;
166
+ ledger.runtimeChecks.push({ id: "attestation", status: "failed", message: parsed.error ?? "Structured acceptance report missing." });
167
+ ledger.status = "rejected";
168
+ return ledger;
169
+ }
170
+
171
+ if (LEVEL_RANK[acceptance.level] >= LEVEL_RANK.checked) {
172
+ ledger.runtimeChecks = runStructuralChecks(acceptance, parsed.report, input.cwd);
173
+ if (ledger.runtimeChecks.some((check) => check.status === "failed")) {
174
+ ledger.status = "rejected";
175
+ return ledger;
176
+ }
177
+ ledger.status = "checked";
178
+ }
179
+
180
+ if (acceptance.verify.length > 0) {
181
+ ledger.verifyRuns = [];
182
+ for (const command of acceptance.verify) ledger.verifyRuns.push(await runVerifyCommand(command, input.cwd));
183
+ if (ledger.verifyRuns.some((run) => run.status === "failed" || run.status === "timed-out")) {
184
+ ledger.status = "rejected";
185
+ return ledger;
186
+ }
187
+ ledger.status = "verified";
188
+ }
189
+
190
+ if (acceptance.review) {
191
+ if (input.reviewResult) {
192
+ ledger.reviewResult = input.reviewResult;
193
+ ledger.status = input.reviewResult.status === "no-blockers" ? "reviewed" : "rejected";
194
+ } else {
195
+ const optionalReview = acceptance.review.required === false;
196
+ ledger.reviewResult = {
197
+ status: "needs-parent-decision",
198
+ findings: [{
199
+ severity: optionalReview ? "non-blocking" : "blocker",
200
+ issue: "Reviewed acceptance requires an independent reviewer result.",
201
+ rationale: "The run cannot be marked reviewed from child self-review or evidence alone.",
202
+ }],
203
+ };
204
+ if (!optionalReview) ledger.status = "rejected";
205
+ }
206
+ }
207
+
208
+ return ledger;
209
+ }
210
+
211
+
212
+ export function acceptanceFailureMessage(ledger: AcceptanceLedger): string | undefined {
213
+ if (ledger.status !== "rejected") return undefined;
214
+ const failedCheck = ledger.runtimeChecks.find((check) => check.status === "failed");
215
+ if (failedCheck) return `Acceptance rejected: ${failedCheck.message}`;
216
+ const failedVerify = ledger.verifyRuns.find((run) => run.status === "failed" || run.status === "timed-out");
217
+ if (failedVerify) return `Acceptance verification '${failedVerify.id}' ${failedVerify.status}.`;
218
+ if (ledger.reviewResult?.status === "needs-parent-decision") return "Acceptance review required but no automatic reviewer result is available.";
219
+ if (ledger.reviewResult?.status === "blockers") return "Acceptance review found blockers.";
220
+ return "Acceptance rejected.";
221
+ }