pi-subagents 0.25.0 → 0.27.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 (38) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +129 -17
  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 +32 -17
  7. package/src/agents/agent-management.ts +57 -15
  8. package/src/agents/agent-serializer.ts +3 -2
  9. package/src/agents/agents.ts +47 -16
  10. package/src/agents/chain-serializer.ts +120 -0
  11. package/src/extension/fanout-child.ts +1 -0
  12. package/src/extension/index.ts +1 -0
  13. package/src/extension/schemas.ts +138 -5
  14. package/src/runs/background/async-execution.ts +84 -6
  15. package/src/runs/background/async-status.ts +11 -1
  16. package/src/runs/background/run-status.ts +10 -1
  17. package/src/runs/background/subagent-runner.ts +600 -31
  18. package/src/runs/foreground/chain-execution.ts +325 -118
  19. package/src/runs/foreground/execution.ts +222 -10
  20. package/src/runs/foreground/subagent-executor.ts +67 -0
  21. package/src/runs/shared/acceptance-contract.ts +291 -0
  22. package/src/runs/shared/acceptance-evaluation.ts +221 -0
  23. package/src/runs/shared/acceptance-finalization.ts +161 -0
  24. package/src/runs/shared/acceptance-reports.ts +127 -0
  25. package/src/runs/shared/acceptance.ts +22 -0
  26. package/src/runs/shared/chain-outputs.ts +101 -0
  27. package/src/runs/shared/completion-guard.ts +26 -3
  28. package/src/runs/shared/dynamic-fanout.ts +293 -0
  29. package/src/runs/shared/parallel-utils.ts +31 -1
  30. package/src/runs/shared/pi-args.ts +11 -0
  31. package/src/runs/shared/structured-output.ts +77 -0
  32. package/src/runs/shared/subagent-prompt-runtime.ts +53 -3
  33. package/src/runs/shared/workflow-graph.ts +206 -0
  34. package/src/shared/formatters.ts +2 -2
  35. package/src/shared/settings.ts +53 -4
  36. package/src/shared/types.ts +250 -0
  37. package/src/slash/slash-commands.ts +41 -3
  38. package/src/tui/render.ts +162 -34
@@ -0,0 +1,291 @@
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
+ function hasArrayItems(value: unknown): boolean {
38
+ return Array.isArray(value) && value.length > 0;
39
+ }
40
+
41
+ export function validateAcceptanceInput(input: unknown, pathLabel = "acceptance"): string[] {
42
+ const errors: string[] = [];
43
+ if (input === undefined) return errors;
44
+ if (input === false || typeof input === "string") {
45
+ errors.push(`${pathLabel} must be an object. Public acceptance levels and false disables are no longer supported.`);
46
+ return errors;
47
+ }
48
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
49
+ errors.push(`${pathLabel} must be an object.`);
50
+ return errors;
51
+ }
52
+
53
+ const value = input as Record<string, unknown>;
54
+ if (Object.hasOwn(value, "level")) {
55
+ errors.push(`${pathLabel}.level is no longer supported; configure criteria, evidence, verify, and review directly.`);
56
+ }
57
+ if (Object.hasOwn(value, "finalization")) {
58
+ errors.push(`${pathLabel}.finalization is not supported; acceptance contracts always run the self-review loop.`);
59
+ }
60
+ if (Object.hasOwn(value, "reason")) {
61
+ errors.push(`${pathLabel}.reason is not supported because acceptance is disabled by omitting the field.`);
62
+ }
63
+ for (const key of Object.keys(value)) {
64
+ if (!ACCEPTANCE_KEYS.has(key) && !REMOVED_ACCEPTANCE_KEYS.has(key)) errors.push(`${pathLabel}.${key} is not supported.`);
65
+ }
66
+
67
+ if (value.criteria !== undefined) {
68
+ if (!Array.isArray(value.criteria)) {
69
+ errors.push(`${pathLabel}.criteria must be an array.`);
70
+ } else {
71
+ for (const [index, criterion] of value.criteria.entries()) {
72
+ if (typeof criterion === "string") {
73
+ if (!criterion.trim()) errors.push(`${pathLabel}.criteria[${index}] must not be empty.`);
74
+ continue;
75
+ }
76
+ if (!criterion || typeof criterion !== "object" || Array.isArray(criterion)) {
77
+ errors.push(`${pathLabel}.criteria[${index}] must be a string or object.`);
78
+ continue;
79
+ }
80
+ const item = criterion as Record<string, unknown>;
81
+ if (typeof item.id !== "string" || !item.id.trim()) errors.push(`${pathLabel}.criteria[${index}].id is required.`);
82
+ if (typeof item.must !== "string" || !item.must.trim()) errors.push(`${pathLabel}.criteria[${index}].must is required.`);
83
+ if (item.evidence !== undefined && !Array.isArray(item.evidence)) errors.push(`${pathLabel}.criteria[${index}].evidence must be an array.`);
84
+ if (Array.isArray(item.evidence)) {
85
+ for (const [evidenceIndex, evidence] of item.evidence.entries()) {
86
+ if (typeof evidence !== "string" || !VALID_EVIDENCE.has(evidence as AcceptanceEvidenceKind)) {
87
+ errors.push(`${pathLabel}.criteria[${index}].evidence[${evidenceIndex}] is not a supported evidence kind.`);
88
+ }
89
+ }
90
+ }
91
+ if (item.severity !== undefined && item.severity !== "required" && item.severity !== "recommended") {
92
+ errors.push(`${pathLabel}.criteria[${index}].severity must be required or recommended.`);
93
+ }
94
+ }
95
+ }
96
+ }
97
+
98
+ if (Array.isArray(value.evidence)) {
99
+ for (const [index, item] of value.evidence.entries()) {
100
+ if (typeof item !== "string" || !VALID_EVIDENCE.has(item as AcceptanceEvidenceKind)) {
101
+ errors.push(`${pathLabel}.evidence[${index}] is not a supported evidence kind.`);
102
+ }
103
+ }
104
+ } else if (value.evidence !== undefined) {
105
+ errors.push(`${pathLabel}.evidence must be an array.`);
106
+ }
107
+
108
+ if (value.verify !== undefined && !Array.isArray(value.verify)) errors.push(`${pathLabel}.verify must be an array.`);
109
+ if (Array.isArray(value.verify)) {
110
+ for (const [index, command] of value.verify.entries()) {
111
+ if (!command || typeof command !== "object" || Array.isArray(command)) {
112
+ errors.push(`${pathLabel}.verify[${index}] must be an object.`);
113
+ continue;
114
+ }
115
+ const cmd = command as Record<string, unknown>;
116
+ if (typeof cmd.id !== "string" || !cmd.id.trim()) errors.push(`${pathLabel}.verify[${index}].id is required.`);
117
+ if (typeof cmd.command !== "string" || !cmd.command.trim()) errors.push(`${pathLabel}.verify[${index}].command is required.`);
118
+ if (cmd.timeoutMs !== undefined && (!Number.isInteger(cmd.timeoutMs) || Number(cmd.timeoutMs) <= 0)) {
119
+ errors.push(`${pathLabel}.verify[${index}].timeoutMs must be a positive integer.`);
120
+ }
121
+ if (cmd.cwd !== undefined && typeof cmd.cwd !== "string") errors.push(`${pathLabel}.verify[${index}].cwd must be a string.`);
122
+ if (cmd.env !== undefined) {
123
+ if (!cmd.env || typeof cmd.env !== "object" || Array.isArray(cmd.env)) {
124
+ errors.push(`${pathLabel}.verify[${index}].env must be an object with string values.`);
125
+ } else {
126
+ for (const [key, envValue] of Object.entries(cmd.env as Record<string, unknown>)) {
127
+ if (typeof envValue !== "string") errors.push(`${pathLabel}.verify[${index}].env.${key} must be a string.`);
128
+ }
129
+ }
130
+ }
131
+ if (cmd.allowFailure !== undefined && typeof cmd.allowFailure !== "boolean") errors.push(`${pathLabel}.verify[${index}].allowFailure must be a boolean.`);
132
+ }
133
+ }
134
+
135
+ if (value.review !== undefined) {
136
+ if (!value.review || typeof value.review !== "object" || Array.isArray(value.review)) {
137
+ errors.push(`${pathLabel}.review must be an object.`);
138
+ } else {
139
+ const review = value.review as Record<string, unknown>;
140
+ if (review.agent !== undefined && typeof review.agent !== "string") errors.push(`${pathLabel}.review.agent must be a string.`);
141
+ if (review.focus !== undefined && typeof review.focus !== "string") errors.push(`${pathLabel}.review.focus must be a string.`);
142
+ if (review.required !== undefined && typeof review.required !== "boolean") errors.push(`${pathLabel}.review.required must be a boolean.`);
143
+ }
144
+ }
145
+
146
+ if (value.stopRules !== undefined) {
147
+ if (!Array.isArray(value.stopRules)) {
148
+ errors.push(`${pathLabel}.stopRules must be an array.`);
149
+ } else {
150
+ for (const [index, rule] of value.stopRules.entries()) {
151
+ if (typeof rule !== "string" || !rule.trim()) errors.push(`${pathLabel}.stopRules[${index}] must be a non-empty string.`);
152
+ }
153
+ }
154
+ }
155
+
156
+ if (value.maxFinalizationTurns !== undefined) {
157
+ if (!Number.isInteger(value.maxFinalizationTurns) || Number(value.maxFinalizationTurns) < 1 || Number(value.maxFinalizationTurns) > MAX_FINALIZATION_TURNS) {
158
+ errors.push(`${pathLabel}.maxFinalizationTurns must be an integer from 1 to ${MAX_FINALIZATION_TURNS}.`);
159
+ }
160
+ }
161
+
162
+ const hasContract = hasArrayItems(value.criteria)
163
+ || hasArrayItems(value.evidence)
164
+ || hasArrayItems(value.verify)
165
+ || value.review !== undefined
166
+ || hasArrayItems(value.stopRules);
167
+ if (!hasContract) {
168
+ errors.push(`${pathLabel} must include at least one of criteria, evidence, verify, review, or stopRules.`);
169
+ }
170
+
171
+ return errors;
172
+ }
173
+
174
+ function normalizeCriteria(criteria: AcceptanceConfig["criteria"], evidence: AcceptanceEvidenceKind[]): ResolvedAcceptanceGate[] {
175
+ return (criteria ?? []).map((criterion, index) => {
176
+ if (typeof criterion === "string") {
177
+ return { id: `criterion-${index + 1}`, must: criterion, evidence, severity: "required" as const };
178
+ }
179
+ return {
180
+ id: criterion.id.trim(),
181
+ must: criterion.must,
182
+ evidence: criterion.evidence?.filter((item) => VALID_EVIDENCE.has(item)) ?? evidence,
183
+ severity: criterion.severity ?? "required",
184
+ };
185
+ }).filter((criterion) => criterion.must.trim());
186
+ }
187
+
188
+ function deriveAcceptanceLevel(config: AcceptanceConfig): AcceptanceProvenanceLevel {
189
+ if (config.review) return "reviewed";
190
+ if ((config.verify?.length ?? 0) > 0) return "verified";
191
+ return "checked";
192
+ }
193
+
194
+ export function resolveEffectiveAcceptance(input: {
195
+ explicit?: AcceptanceInput;
196
+ agentName: string;
197
+ task?: string;
198
+ mode?: SubagentRunMode;
199
+ async?: boolean;
200
+ dynamic?: boolean;
201
+ dynamicGroup?: boolean;
202
+ }): ResolvedAcceptanceConfig {
203
+ if (input.explicit === undefined) {
204
+ return {
205
+ level: "none",
206
+ explicit: false,
207
+ inferredReason: ["acceptance not configured"],
208
+ criteria: [],
209
+ evidence: [],
210
+ verify: [],
211
+ stopRules: [],
212
+ finalization: { mode: "none", maxTurns: 0 },
213
+ };
214
+ }
215
+
216
+ const validationErrors = validateAcceptanceInput(input.explicit);
217
+ if (validationErrors.length > 0) throw new Error(validationErrors.join(" "));
218
+ const explicit = input.explicit;
219
+ const evidence = [...new Set(explicit.evidence ?? [])];
220
+ const criteria = normalizeCriteria(explicit.criteria, evidence);
221
+ const verify = explicit.verify ?? [];
222
+ const stopRules = explicit.stopRules ?? [];
223
+ return {
224
+ level: deriveAcceptanceLevel(explicit),
225
+ explicit: true,
226
+ inferredReason: ["explicit acceptance contract"],
227
+ criteria,
228
+ evidence,
229
+ verify,
230
+ ...(explicit.review ? { review: explicit.review } : {}),
231
+ stopRules,
232
+ finalization: { mode: "self-review-loop", maxTurns: explicit.maxFinalizationTurns ?? DEFAULT_FINALIZATION_MAX_TURNS },
233
+ };
234
+ }
235
+
236
+ export function shouldRunAcceptanceFinalization(acceptance: ResolvedAcceptanceConfig): boolean {
237
+ return acceptance.explicit && acceptance.finalization.mode === "self-review-loop" && acceptance.finalization.maxTurns > 0;
238
+ }
239
+
240
+ export function acceptanceSelfReviewConfig(acceptance: ResolvedAcceptanceConfig): ResolvedAcceptanceConfig {
241
+ if (!acceptance.review && acceptance.verify.length === 0) return acceptance;
242
+ const { review: _review, verify: _verify, ...selfReview } = acceptance;
243
+ return {
244
+ ...selfReview,
245
+ level: "checked",
246
+ verify: [],
247
+ };
248
+ }
249
+
250
+ export function formatAcceptancePrompt(acceptance: ResolvedAcceptanceConfig): string {
251
+ if (acceptance.level === "none") return "";
252
+ const lines = [
253
+ "",
254
+ "## Acceptance Contract",
255
+ "Completion is not accepted from prose alone. End the initial response with a structured acceptance report.",
256
+ "After the initial response, the runtime will continue this same session for a bounded self-review/repair loop before accepting the run.",
257
+ "",
258
+ "Criteria:",
259
+ ...(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."]),
260
+ "",
261
+ `Required evidence: ${acceptance.evidence.join(", ") || "none explicitly requested"}`,
262
+ ];
263
+ if (acceptance.verify.length > 0) {
264
+ lines.push("", "Runtime verification commands configured by parent:");
265
+ for (const command of acceptance.verify) lines.push(`- ${command.id}: ${command.command}`);
266
+ }
267
+ if (acceptance.review) {
268
+ lines.push("", `Independent review gate: ${acceptance.review.required === false ? "optional" : "required"}${acceptance.review.agent ? ` by ${acceptance.review.agent}` : ""}.`);
269
+ if (acceptance.review.focus) lines.push(`Review focus: ${acceptance.review.focus}`);
270
+ }
271
+ if (acceptance.stopRules.length > 0) {
272
+ lines.push("", "Stop rules:", ...acceptance.stopRules.map((rule) => `- ${rule}`));
273
+ }
274
+ lines.push(
275
+ "",
276
+ "Finish with a fenced JSON block tagged `acceptance-report` in this shape:",
277
+ "```acceptance-report",
278
+ JSON.stringify({
279
+ criteriaSatisfied: [{ id: "criterion-1", status: "satisfied", evidence: "specific proof" }],
280
+ changedFiles: [],
281
+ testsAddedOrUpdated: [],
282
+ commandsRun: [{ command: "command", result: "passed", summary: "short result" }],
283
+ validationOutput: [],
284
+ residualRisks: [],
285
+ noStagedFiles: true,
286
+ notes: "anything else the parent should know",
287
+ }, null, 2),
288
+ "```",
289
+ );
290
+ return lines.join("\n");
291
+ }
@@ -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
+ }
@@ -0,0 +1,161 @@
1
+ import type {
2
+ AcceptanceFinalizationTurn,
3
+ AcceptanceLedger,
4
+ ResolvedAcceptanceConfig,
5
+ } from "../../shared/types.ts";
6
+ import { acceptanceFailureMessage } from "./acceptance-evaluation.ts";
7
+ import { stripAcceptanceReport } from "./acceptance-reports.ts";
8
+
9
+ const INITIAL_OUTPUT_LIMIT = 8_000;
10
+
11
+ function truncateForPrompt(value: string): string {
12
+ const trimmed = stripAcceptanceReport(value).trim();
13
+ if (trimmed.length <= INITIAL_OUTPUT_LIMIT) return trimmed || "(initial output was empty after removing acceptance-report)";
14
+ return `${trimmed.slice(0, INITIAL_OUTPUT_LIMIT)}\n...[truncated]`;
15
+ }
16
+
17
+ function formatReportForPrompt(ledger: AcceptanceLedger): string {
18
+ if (ledger.childReport) return JSON.stringify(ledger.childReport, null, 2);
19
+ return `Missing or malformed acceptance report: ${ledger.childReportParseError ?? "no parse detail"}`;
20
+ }
21
+
22
+ export function formatAcceptanceFinalizationPrompt(input: {
23
+ acceptance: ResolvedAcceptanceConfig;
24
+ initialOutput: string;
25
+ initialLedger: AcceptanceLedger;
26
+ turn: number;
27
+ maxTurns: number;
28
+ previousFailure?: string;
29
+ }): string {
30
+ const lines = [
31
+ "## Acceptance Finalization",
32
+ "You are continuing the same subagent session. Before this run can be accepted, compare the current work to the acceptance contract and the evidence below.",
33
+ `This is finalization turn ${input.turn} of ${input.maxTurns}. The run will be rejected if the contract is still not satisfied after turn ${input.maxTurns}.`,
34
+ "",
35
+ "If a criterion is incomplete and fixable in this session, keep working now before returning the final report.",
36
+ "If a criterion cannot be satisfied in this session, report it as not-satisfied, explain the blocker in residualRisks, and say what input would unblock progress.",
37
+ "Do not claim a criterion is satisfied unless the current work has concrete evidence from files, commands, validation output, or other inspectable artifacts.",
38
+ "",
39
+ "## Acceptance Contract",
40
+ "Criteria:",
41
+ ...(input.acceptance.criteria.length ? input.acceptance.criteria.map((criterion) => `- ${criterion.id}: ${criterion.must}`) : ["- No explicit criteria were configured; satisfy the requested task and required evidence/checks."]),
42
+ "",
43
+ `Required evidence: ${input.acceptance.evidence.join(", ") || "none explicitly requested"}`,
44
+ ];
45
+ if (input.acceptance.verify.length > 0) {
46
+ lines.push("", "Runtime verification commands that must pass:", ...input.acceptance.verify.map((command) => `- ${command.id}: ${command.command}`));
47
+ }
48
+ if (input.acceptance.review) {
49
+ lines.push("", `Independent review gate after self-review: ${input.acceptance.review.required === false ? "optional" : "required"}${input.acceptance.review.agent ? ` by ${input.acceptance.review.agent}` : ""}.`);
50
+ }
51
+ if (input.acceptance.stopRules.length > 0) {
52
+ lines.push("", "Stop rules are hard constraints while deciding whether to continue, stop as blocked, or report success:", ...input.acceptance.stopRules.map((rule) => `- ${rule}`));
53
+ }
54
+ lines.push(
55
+ "",
56
+ "Initial visible output:",
57
+ truncateForPrompt(input.initialOutput),
58
+ "",
59
+ "Initial acceptance report:",
60
+ formatReportForPrompt(input.initialLedger),
61
+ );
62
+ if (input.previousFailure) {
63
+ lines.push("", "Previous finalization failure to address:", input.previousFailure);
64
+ }
65
+ lines.push(
66
+ "",
67
+ "Now do the self-check. If work was missing and you repaired it, report the repaired final state. Finish with exactly one fenced JSON block tagged `acceptance-report`.",
68
+ "```acceptance-report",
69
+ JSON.stringify({
70
+ criteriaSatisfied: [{ id: "criterion-1", status: "satisfied", evidence: "specific proof from the final state" }],
71
+ changedFiles: [],
72
+ testsAddedOrUpdated: [],
73
+ commandsRun: [{ command: "command", result: "passed", summary: "short result" }],
74
+ validationOutput: [],
75
+ residualRisks: [],
76
+ noStagedFiles: true,
77
+ notes: "final self-review summary",
78
+ }, null, 2),
79
+ "```",
80
+ );
81
+ return lines.join("\n");
82
+ }
83
+
84
+ export function createFinalizationTurn(input: {
85
+ turn: number;
86
+ prompt: string;
87
+ rawOutput: string;
88
+ ledger: AcceptanceLedger;
89
+ }): AcceptanceFinalizationTurn {
90
+ const failureMessage = acceptanceFailureMessage(input.ledger);
91
+ return {
92
+ turn: input.turn,
93
+ prompt: input.prompt,
94
+ status: input.ledger.status,
95
+ rawOutput: input.rawOutput,
96
+ ...(input.ledger.childReport ? { report: input.ledger.childReport } : {}),
97
+ ...(input.ledger.childReportParseError ? { parseError: input.ledger.childReportParseError } : {}),
98
+ runtimeChecks: input.ledger.runtimeChecks,
99
+ verifyRuns: input.ledger.verifyRuns,
100
+ ...(failureMessage ? { failureMessage } : {}),
101
+ };
102
+ }
103
+
104
+ export function createFinalizationProcessFailureTurn(input: {
105
+ turn: number;
106
+ prompt: string;
107
+ rawOutput?: string;
108
+ message: string;
109
+ }): AcceptanceFinalizationTurn {
110
+ return {
111
+ turn: input.turn,
112
+ prompt: input.prompt,
113
+ status: "rejected",
114
+ ...(input.rawOutput ? { rawOutput: input.rawOutput } : {}),
115
+ runtimeChecks: [{ id: "finalization-process", status: "failed", message: input.message }],
116
+ verifyRuns: [],
117
+ failureMessage: `Acceptance rejected: ${input.message}`,
118
+ };
119
+ }
120
+
121
+ export function attachFinalizationToLedger(input: {
122
+ initialLedger: AcceptanceLedger;
123
+ authoritativeLedger: AcceptanceLedger;
124
+ turns: AcceptanceFinalizationTurn[];
125
+ status: "completed" | "failed";
126
+ maxTurns: number;
127
+ }): AcceptanceLedger {
128
+ return {
129
+ ...input.authoritativeLedger,
130
+ ...(input.initialLedger.childReport ? { initialChildReport: input.initialLedger.childReport } : {}),
131
+ ...(input.initialLedger.childReportParseError ? { initialChildReportParseError: input.initialLedger.childReportParseError } : {}),
132
+ finalization: {
133
+ mode: "self-review-loop",
134
+ status: input.status,
135
+ maxTurns: input.maxTurns,
136
+ turns: input.turns,
137
+ },
138
+ };
139
+ }
140
+
141
+ export function buildFinalizationProcessFailureLedger(input: {
142
+ initialLedger: AcceptanceLedger;
143
+ turns: AcceptanceFinalizationTurn[];
144
+ maxTurns: number;
145
+ message: string;
146
+ }): AcceptanceLedger {
147
+ return attachFinalizationToLedger({
148
+ initialLedger: input.initialLedger,
149
+ authoritativeLedger: {
150
+ ...input.initialLedger,
151
+ status: "rejected",
152
+ runtimeChecks: [
153
+ ...input.initialLedger.runtimeChecks,
154
+ { id: "finalization-process", status: "failed", message: input.message },
155
+ ],
156
+ },
157
+ turns: input.turns,
158
+ status: "failed",
159
+ maxTurns: input.maxTurns,
160
+ });
161
+ }