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
@@ -13,6 +13,8 @@ import {
13
13
  interface SlashSubagentRequest {
14
14
  requestId: string;
15
15
  params: SubagentParamsLike;
16
+ /** Optional requester context for in-process extension bridge calls. */
17
+ ctx?: ExtensionContext;
16
18
  }
17
19
 
18
20
  export interface SlashSubagentResponse {
@@ -77,7 +79,7 @@ export function registerSlashSubagentBridge(options: SlashBridgeOptions): {
77
79
  if (typeof request.requestId !== "string" || !request.params) return;
78
80
  const { requestId, params } = request as SlashSubagentRequest;
79
81
 
80
- const ctx = options.getContext();
82
+ const ctx = request.ctx ?? options.getContext();
81
83
  if (!ctx) {
82
84
  const response: SlashSubagentResponse = {
83
85
  requestId,
@@ -245,7 +245,7 @@ async function requestSlashRun(
245
245
  next();
246
246
  };
247
247
 
248
- pi.events.emit(SLASH_SUBAGENT_REQUEST_EVENT, { requestId, params });
248
+ pi.events.emit(SLASH_SUBAGENT_REQUEST_EVENT, { requestId, params, ctx });
249
249
 
250
250
  // Bridge emits STARTED synchronously during REQUEST emit.
251
251
  // If not started, no bridge received the request.
package/src/tui/render.ts CHANGED
@@ -222,22 +222,11 @@ function firstOutputLine(text: string): string {
222
222
  return text.split("\n").find((line) => line.trim())?.trim() ?? "";
223
223
  }
224
224
 
225
- function formatAcceptanceStatus(result: Details["results"][number]): string | undefined {
226
- const acceptance = result.acceptance;
227
- if (!acceptance?.status || acceptance.status === "not-required") return undefined;
228
- const finalization = acceptance.finalization
229
- ? ` · finalization: ${acceptance.finalization.status} after ${acceptance.finalization.turns.length}/${acceptance.finalization.maxTurns} turns`
230
- : "";
231
- return `acceptance: ${acceptance.status}${finalization}`;
232
- }
233
-
234
225
  function resultStatusLine(result: Details["results"][number], output: string): string {
235
226
  if (result.detached) return result.detachedReason ? `Detached: ${result.detachedReason}` : "Detached";
236
- if (result.timedOut) return `Timed out${result.error ? `: ${result.error}` : ""}`;
237
227
  if (result.interrupted) return "Paused";
238
228
  if (result.exitCode !== 0) return `Error: ${result.error ?? (firstOutputLine(output) || `exit ${result.exitCode}`)}`;
239
- const acceptance = formatAcceptanceStatus(result);
240
- if (acceptance) return `Done · ${acceptance}`;
229
+ if (result.acceptance?.status && result.acceptance.status !== "not-required") return `Done · acceptance: ${result.acceptance.status}`;
241
230
  if (hasEmptyTextOutputWithoutOutputTarget(result.task, output)) return "Done (no text output)";
242
231
  return "Done";
243
232
  }
@@ -245,7 +234,6 @@ function resultStatusLine(result: Details["results"][number], output: string): s
245
234
  function resultGlyph(result: Details["results"][number], output: string, theme: Theme, running = result.progress?.status === "running", seed = progressRunningSeed(result.progress ?? result.progressSummary)): string {
246
235
  if (running) return theme.fg("accent", runningGlyph(seed));
247
236
  if (result.detached) return theme.fg("warning", "■");
248
- if (result.timedOut) return theme.fg("error", "✗");
249
237
  if (result.interrupted) return theme.fg("warning", "■");
250
238
  if (result.exitCode !== 0) return theme.fg("error", "✗");
251
239
  if (hasEmptyTextOutputWithoutOutputTarget(result.task, output)) return theme.fg("warning", "✓");
@@ -365,19 +353,18 @@ function widgetStatusGlyph(job: AsyncJobState, theme: Theme): string {
365
353
  return theme.fg("error", "✗");
366
354
  }
367
355
 
368
- function widgetStepGlyph(status: AsyncJobStep["status"] | WorkflowNodeStatus, theme: Theme, seed?: number): string {
356
+ function widgetStepGlyph(status: AsyncJobStep["status"], theme: Theme, seed?: number): string {
369
357
  if (status === "running") return theme.fg("accent", runningGlyph(seed));
370
358
  if (status === "complete" || status === "completed") return theme.fg("success", "✓");
371
- if (status === "failed" || status === "timed-out") return theme.fg("error", "✗");
359
+ if (status === "failed") return theme.fg("error", "✗");
372
360
  if (status === "paused") return theme.fg("warning", "■");
373
361
  return theme.fg("muted", "◦");
374
362
  }
375
363
 
376
- function widgetStepStatus(status: AsyncJobStep["status"] | WorkflowNodeStatus, theme: Theme): string {
364
+ function widgetStepStatus(status: AsyncJobStep["status"], theme: Theme): string {
377
365
  if (status === "running") return theme.fg("accent", "running");
378
366
  if (status === "complete" || status === "completed") return theme.fg("success", "complete");
379
367
  if (status === "failed") return theme.fg("error", "failed");
380
- if (status === "timed-out") return theme.fg("error", "timed out");
381
368
  if (status === "paused") return theme.fg("warning", "paused");
382
369
  return theme.fg("dim", status);
383
370
  }
@@ -514,7 +501,7 @@ function isDoneResult(result: Details["results"][number]): boolean {
514
501
  const status = result.progress?.status;
515
502
  if (status === "completed") return true;
516
503
  if (status === "running" || status === "pending") return false;
517
- if (result.interrupted || result.detached || result.timedOut) return false;
504
+ if (result.interrupted || result.detached) return false;
518
505
  return result.exitCode === 0;
519
506
  }
520
507
 
@@ -586,7 +573,7 @@ function buildMultiProgressLabel(details: Pick<Details, "mode" | "results" | "pr
586
573
 
587
574
  if (details.mode === "parallel") {
588
575
  const totalCount = details.totalSteps ?? details.results.length;
589
- const statuses = new Array(totalCount).fill("pending") as WorkflowNodeStatus[];
576
+ const statuses = new Array(totalCount).fill("pending") as Array<"pending" | "running" | "completed" | "failed" | "detached">;
590
577
  for (const progress of details.progress ?? []) {
591
578
  if (progress.index >= 0 && progress.index < totalCount) statuses[progress.index] = progress.status;
592
579
  }
@@ -597,13 +584,11 @@ function buildMultiProgressLabel(details: Pick<Details, "mode" | "results" | "pr
597
584
  const index = result.progress?.index ?? progressFromArray?.index ?? i;
598
585
  if (index < 0 || index >= totalCount) continue;
599
586
  const status = result.progress?.status
600
- ?? (result.timedOut
601
- ? "timed-out"
602
- : result.interrupted || result.detached
603
- ? "detached"
604
- : result.exitCode === 0
605
- ? "completed"
606
- : "failed");
587
+ ?? (result.interrupted || result.detached
588
+ ? "detached"
589
+ : result.exitCode === 0
590
+ ? "completed"
591
+ : "failed");
607
592
  statuses[index] = status;
608
593
  }
609
594
  const running = statuses.filter((status) => status === "running").length;
@@ -1065,7 +1050,7 @@ function renderMultiCompact(d: Details, theme: Theme): Component {
1065
1050
  || d.results.some((r) => r.progress?.status === "running")
1066
1051
  || workflowGraphHasStatus(d, ["running"]);
1067
1052
  const failed = d.results.some((r) => r.exitCode !== 0 && r.progress?.status !== "running")
1068
- || workflowGraphHasStatus(d, ["failed", "timed-out"]);
1053
+ || workflowGraphHasStatus(d, ["failed"]);
1069
1054
  const paused = d.results.some((r) => (r.interrupted || r.detached) && r.progress?.status !== "running")
1070
1055
  || workflowGraphHasStatus(d, ["paused", "detached"]);
1071
1056
  let totalSummary = d.progressSummary;
@@ -1141,7 +1126,7 @@ function renderMultiCompact(d: Details, theme: Theme): Component {
1141
1126
  const activity = compactCurrentActivity(rProg);
1142
1127
  c.addChild(new Text(truncLine(theme.fg("dim", ` ⎿ ${activity}`), width), 0, 0));
1143
1128
  c.addChild(new Text(truncLine(theme.fg("accent", " Press Ctrl+O for live detail"), width), 0, 0));
1144
- } else if (!rPending && (r.exitCode !== 0 || r.interrupted || r.detached || r.timedOut || hasEmptyTextOutputWithoutOutputTarget(r.task, output))) {
1129
+ } else if (!rPending && (r.exitCode !== 0 || r.interrupted || r.detached || hasEmptyTextOutputWithoutOutputTarget(r.task, output))) {
1145
1130
  c.addChild(new Text(truncLine(theme.fg(r.exitCode !== 0 ? "error" : "dim", ` ⎿ ${resultStatusLine(r, output)}`), width), 0, 0));
1146
1131
  }
1147
1132
  const outputTarget = extractOutputTarget(r.task);
@@ -1278,7 +1263,7 @@ export function renderSubagentResult(
1278
1263
  && r.progress?.status !== "running"
1279
1264
  && hasEmptyTextOutputWithoutOutputTarget(r.task, getSingleResultOutput(r)),
1280
1265
  );
1281
- const hasWorkflowFailure = workflowGraphHasStatus(d, ["failed", "timed-out"]);
1266
+ const hasWorkflowFailure = workflowGraphHasStatus(d, ["failed"]);
1282
1267
  const hasWorkflowPause = workflowGraphHasStatus(d, ["paused", "detached"]);
1283
1268
  const icon = hasRunning
1284
1269
  ? theme.fg("warning", "running")
@@ -1,318 +0,0 @@
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
- }