pi-subagents 0.34.0 → 0.35.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 (98) hide show
  1. package/CHANGELOG.md +78 -9
  2. package/README.md +213 -32
  3. package/index.ts +1 -0
  4. package/install.mjs +1 -1
  5. package/package.json +23 -8
  6. package/prompts/review-loop.md +3 -1
  7. package/skills/pi-subagents/SKILL.md +87 -25
  8. package/src/agents/agent-management.ts +82 -15
  9. package/src/agents/agent-serializer.ts +19 -0
  10. package/src/agents/agents.ts +91 -49
  11. package/src/agents/frontmatter.ts +67 -13
  12. package/src/agents/skills.ts +25 -12
  13. package/src/api/background-work.ts +197 -0
  14. package/src/api/delegation.ts +158 -0
  15. package/src/extension/chain-validation.ts +165 -0
  16. package/src/extension/doctor.ts +15 -0
  17. package/src/extension/fanout-child.ts +3 -1
  18. package/src/extension/index.ts +65 -124
  19. package/src/extension/rpc.ts +10 -2
  20. package/src/extension/schemas.ts +18 -14
  21. package/src/extension/steering-notices.ts +35 -0
  22. package/src/extension/tool-description.ts +20 -9
  23. package/src/intercom/intercom-bridge.ts +3 -2
  24. package/src/intercom/native-supervisor-channel.ts +9 -1
  25. package/src/intercom/result-intercom.ts +4 -0
  26. package/src/runs/background/async-execution.ts +293 -44
  27. package/src/runs/background/async-job-tracker.ts +56 -9
  28. package/src/runs/background/async-resume.ts +159 -52
  29. package/src/runs/background/async-status.ts +25 -18
  30. package/src/runs/background/auto-drain.ts +67 -0
  31. package/src/runs/background/chain-root-attachment.ts +16 -8
  32. package/src/runs/background/control-channel.ts +260 -13
  33. package/src/runs/background/fleet-view.ts +23 -2
  34. package/src/runs/background/notify.ts +79 -10
  35. package/src/runs/background/result-watcher.ts +12 -9
  36. package/src/runs/background/run-id-resolver.ts +14 -2
  37. package/src/runs/background/run-status.ts +23 -15
  38. package/src/runs/background/scheduled-runs.ts +3 -0
  39. package/src/runs/background/stale-run-reconciler.ts +32 -10
  40. package/src/runs/background/steering.ts +237 -0
  41. package/src/runs/background/subagent-runner.ts +898 -236
  42. package/src/runs/background/subagent-wait.ts +484 -0
  43. package/src/runs/background/top-level-async.ts +2 -1
  44. package/src/runs/background/wait-config.ts +36 -0
  45. package/src/runs/background/wait-tool.ts +26 -0
  46. package/src/runs/foreground/async-steering-action.ts +230 -0
  47. package/src/runs/foreground/chain-clarify.ts +22 -6
  48. package/src/runs/foreground/chain-execution.ts +50 -32
  49. package/src/runs/foreground/execution.ts +308 -94
  50. package/src/runs/foreground/subagent-executor.ts +592 -268
  51. package/src/runs/shared/acceptance.ts +355 -97
  52. package/src/runs/shared/child-protocol.ts +121 -0
  53. package/src/runs/shared/completion-guard.ts +8 -127
  54. package/src/runs/shared/dynamic-fanout.ts +6 -4
  55. package/src/runs/shared/model-fallback.ts +36 -0
  56. package/src/runs/shared/nested-events.ts +9 -4
  57. package/src/runs/shared/nested-render.ts +4 -1
  58. package/src/runs/shared/parallel-utils.ts +7 -0
  59. package/src/runs/shared/pi-args.ts +34 -7
  60. package/src/runs/shared/pi-spawn.ts +18 -12
  61. package/src/runs/shared/session-lease.ts +279 -0
  62. package/src/runs/shared/single-output.ts +61 -6
  63. package/src/runs/shared/spawn-budget.ts +128 -0
  64. package/src/runs/shared/subagent-control.ts +10 -6
  65. package/src/runs/shared/subagent-prompt-runtime.ts +127 -26
  66. package/src/runs/shared/task-intent.ts +176 -0
  67. package/src/runs/shared/tool-availability.ts +65 -0
  68. package/src/runs/shared/turn-budget.ts +49 -4
  69. package/src/shared/atomic-json.ts +4 -1
  70. package/src/shared/fork-context.ts +28 -3
  71. package/src/shared/model-info.ts +7 -4
  72. package/src/shared/status-format.ts +7 -1
  73. package/src/shared/types.ts +203 -25
  74. package/src/shared/utils.ts +35 -7
  75. package/src/slash/delegation-adapters.ts +457 -0
  76. package/src/slash/delegation-request.ts +103 -0
  77. package/src/slash/prompt-template-bridge.ts +167 -344
  78. package/src/slash/slash-commands.ts +239 -6
  79. package/src/slash/subagents-admin.ts +428 -0
  80. package/src/slash/subagents-editor.ts +86 -0
  81. package/src/tui/fleet.ts +405 -0
  82. package/src/tui/render.ts +90 -16
  83. package/src/watchdog/change-signature.ts +127 -0
  84. package/src/watchdog/child-status.ts +205 -0
  85. package/src/watchdog/emission-guard.ts +123 -0
  86. package/src/watchdog/lsp-diagnostics.ts +532 -0
  87. package/src/watchdog/model-selection.ts +167 -0
  88. package/src/watchdog/register-child.ts +117 -0
  89. package/src/watchdog/register-main.ts +433 -0
  90. package/src/watchdog/render.ts +54 -0
  91. package/src/watchdog/review.ts +293 -0
  92. package/src/watchdog/runtime.ts +712 -0
  93. package/src/watchdog/settings.ts +528 -0
  94. package/src/watchdog/tool-actions.ts +155 -0
  95. package/src/watchdog/turn-delta.ts +161 -0
  96. package/src/watchdog/types.ts +188 -0
  97. package/src/watchdog/warning-format.ts +73 -0
  98. package/src/runs/background/wait.ts +0 -394
@@ -9,7 +9,9 @@ import type {
9
9
  AcceptanceLedger,
10
10
  AcceptanceLevel,
11
11
  AcceptanceReport,
12
+ AcceptanceRole,
12
13
  AcceptanceRuntimeCheck,
14
+ AcceptanceRuntimeCheckStatus,
13
15
  AcceptanceReviewResult,
14
16
  AcceptanceVerifyCommand,
15
17
  AcceptanceVerifyResult,
@@ -18,6 +20,7 @@ import type {
18
20
  SingleResult,
19
21
  SubagentRunMode,
20
22
  } from "../../shared/types.ts";
23
+ import { classifyTaskMutationIntent, taskMayMutate } from "./task-intent.ts";
21
24
 
22
25
  const LEVEL_RANK: Record<Exclude<AcceptanceLevel, "auto">, number> = {
23
26
  none: 0,
@@ -43,6 +46,7 @@ const ACCEPTANCE_CONFIG_KEYS = new Set(["level", "criteria", "evidence", "verify
43
46
  const ACCEPTANCE_GATE_KEYS = new Set(["id", "must", "evidence", "severity"]);
44
47
  const ACCEPTANCE_VERIFY_KEYS = new Set(["id", "command", "timeoutMs", "cwd", "env", "allowFailure"]);
45
48
  const ACCEPTANCE_REVIEW_KEYS = new Set(["agent", "focus", "required"]);
49
+ const EXPLICIT_REVIEWED_UNAVAILABLE = "cannot be requested explicitly because this run cannot supply an independent reviewer result; use checked/verified and orchestrate the reviewer separately, or omit acceptance for read-only review tasks.";
46
50
 
47
51
  function normalizeLevel(level: AcceptanceLevel | undefined): Exclude<AcceptanceLevel, "auto"> | "auto" {
48
52
  return level ?? "auto";
@@ -68,6 +72,7 @@ function requiredEvidenceForLevel(level: Exclude<AcceptanceLevel, "auto">): Acce
68
72
 
69
73
  function inferLevel(input: {
70
74
  agentName: string;
75
+ acceptanceRole?: AcceptanceRole;
71
76
  task?: string;
72
77
  mode?: SubagentRunMode;
73
78
  async?: boolean;
@@ -77,14 +82,27 @@ function inferLevel(input: {
77
82
  const agent = input.agentName.toLowerCase();
78
83
  const task = input.task?.toLowerCase() ?? "";
79
84
  const reasons: string[] = [];
80
- const readOnlyAgent = /\b(?:reviewer|scout|context-builder|researcher|analyst)\b/.test(agent);
81
- const readOnlyTask = /\b(?:read[- ]only|review[- ]only|do not edit|don't edit|no edits|without edits|inspect|summari[sz]e)\b/.test(task);
82
- const writeTask = /\b(?:fix|implement|update|write|edit|modify|migrate|release|security|delete|remove|refactor|commit)\b/.test(task)
83
- || /\bworker\b/.test(agent);
85
+ // Declared roles replace name heuristics, so use the full writer grammar to detect explicit mutation independently of the actual agent name.
86
+ const intent = classifyTaskMutationIntent(input.acceptanceRole ? "worker" : input.agentName, input.task ?? "");
87
+ const readOnlyTask = intent.kind === "read-only"
88
+ || (intent.kind === "unknown" && /\b(?:read[- ]only|review[- ]only|no edits|without edits|inspect|summari[sz]e)\b/.test(task));
89
+ const rolePatchTask = input.acceptanceRole !== undefined
90
+ && intent.kind !== "read-only"
91
+ && !/\b(?:do not|don't|must not)\s+patch\b/.test(task)
92
+ && /\bpatch\s+(?:(?:\.{0,2}[\\/])?(?:[\w.-]+[\\/])+[\w.-]+|[\w.-]+\.[a-z0-9]+\b|(?:the\s+)?parser\b)/.test(task);
93
+ const taskMayWrite = taskMayMutate(input.task ?? "") || intent.kind === "implementation" || rolePatchTask;
94
+ const readOnlyAgent = input.acceptanceRole === "read-only"
95
+ || (input.acceptanceRole === undefined && /\b(?:reviewer|scout|context-builder|researcher|analyst)\b/.test(agent));
96
+ const writeTask = taskMayWrite
97
+ || (input.acceptanceRole === "writer" && !readOnlyTask)
98
+ || (input.acceptanceRole === undefined && /\bworker\b/.test(agent) && !readOnlyTask);
99
+ const inferredReadOnly = readOnlyTask || (input.acceptanceRole === "read-only" && !taskMayWrite);
100
+ const roleResolvesReadOnly = input.acceptanceRole !== undefined && inferredReadOnly;
101
+ const keywordRiskReadOnly = input.acceptanceRole === undefined ? intent.kind === "read-only" : inferredReadOnly;
84
102
  const risky = Boolean(input.async && writeTask)
85
- || Boolean(input.dynamic)
86
- || Boolean(input.dynamicGroup)
87
- || /\b(?:release|migration|migrate|security|data[- ]loss|destructive|post-review|fix pass)\b/.test(task);
103
+ || (Boolean(input.dynamic) && !roleResolvesReadOnly)
104
+ || (Boolean(input.dynamicGroup) && !roleResolvesReadOnly)
105
+ || (!keywordRiskReadOnly && /\b(?:release|migration|migrate|security|data[- ]loss|destructive|post-review|fix pass)\b/.test(task));
88
106
 
89
107
  if (risky) {
90
108
  reasons.push(input.async ? "async write-capable or risky run" : "risky write-capable run");
@@ -98,7 +116,7 @@ function inferLevel(input: {
98
116
  };
99
117
  }
100
118
  if (writeTask && !readOnlyTask) {
101
- reasons.push("write-capable worker/task");
119
+ reasons.push(input.acceptanceRole === "writer" && !taskMayWrite ? "declared writer acceptance role" : "write-capable worker/task");
102
120
  return {
103
121
  level: "checked",
104
122
  reasons,
@@ -107,7 +125,7 @@ function inferLevel(input: {
107
125
  };
108
126
  }
109
127
  if (readOnlyAgent || readOnlyTask) {
110
- reasons.push(readOnlyAgent ? "read-only/reviewer-style agent" : "read-only task wording");
128
+ reasons.push(input.acceptanceRole === "read-only" && !readOnlyTask ? "declared read-only acceptance role" : readOnlyAgent ? "read-only/reviewer-style agent" : "read-only task wording");
111
129
  return {
112
130
  level: "attested",
113
131
  reasons,
@@ -141,6 +159,8 @@ export function validateAcceptanceInput(input: unknown, pathLabel = "acceptance"
141
159
  if (input === false) return errors;
142
160
  if (typeof input === "string") {
143
161
  if (!VALID_LEVELS.has(input as AcceptanceLevel)) errors.push(`${pathLabel} has invalid level '${input}'.`);
162
+ else if (input === "none") errors.push(`${pathLabel} level "none" requires a reason; use { level: "none", reason: "..." }.`);
163
+ else if (input === "reviewed") errors.push(`${pathLabel} ${EXPLICIT_REVIEWED_UNAVAILABLE}`);
144
164
  return errors;
145
165
  }
146
166
  if (!input || typeof input !== "object" || Array.isArray(input)) {
@@ -157,9 +177,11 @@ export function validateAcceptanceInput(input: unknown, pathLabel = "acceptance"
157
177
  if (value.level === "none" && (typeof value.reason !== "string" || !value.reason.trim())) {
158
178
  errors.push(`${pathLabel}.reason is required when level is none.`);
159
179
  }
180
+ if (value.level === "reviewed") errors.push(`${pathLabel}.level ${EXPLICIT_REVIEWED_UNAVAILABLE}`);
160
181
  if (value.reason !== undefined && typeof value.reason !== "string") errors.push(`${pathLabel}.reason must be a string.`);
161
182
  if (value.criteria !== undefined && !Array.isArray(value.criteria)) errors.push(`${pathLabel}.criteria must be an array.`);
162
183
  if (Array.isArray(value.criteria)) {
184
+ const criterionIds = new Set<string>();
163
185
  for (const [index, criterion] of value.criteria.entries()) {
164
186
  if (typeof criterion === "string") continue;
165
187
  const criterionPath = `${pathLabel}.criteria[${index}]`;
@@ -171,7 +193,13 @@ export function validateAcceptanceInput(input: unknown, pathLabel = "acceptance"
171
193
  for (const key of Object.keys(gate)) {
172
194
  if (!ACCEPTANCE_GATE_KEYS.has(key)) errors.push(`${criterionPath}.${key} is not supported.`);
173
195
  }
174
- if (typeof gate.id !== "string" || !gate.id.trim()) errors.push(`${criterionPath}.id is required.`);
196
+ if (typeof gate.id !== "string" || !gate.id.trim()) {
197
+ errors.push(`${criterionPath}.id is required.`);
198
+ } else {
199
+ const normalizedId = normalizedToken(gate.id);
200
+ if (criterionIds.has(normalizedId)) errors.push(`${criterionPath}.id duplicates normalized criterion id '${normalizedId}'.`);
201
+ criterionIds.add(normalizedId);
202
+ }
175
203
  if (typeof gate.must !== "string" || !gate.must.trim()) errors.push(`${criterionPath}.must is required.`);
176
204
  if (gate.evidence !== undefined && !Array.isArray(gate.evidence)) errors.push(`${criterionPath}.evidence must be an array.`);
177
205
  if (Array.isArray(gate.evidence)) {
@@ -248,6 +276,31 @@ export function validateAcceptanceInput(input: unknown, pathLabel = "acceptance"
248
276
  return errors;
249
277
  }
250
278
 
279
+ export function validateExecutionAcceptance(input: {
280
+ acceptance?: unknown;
281
+ tasks?: Array<{ acceptance?: unknown }>;
282
+ chain?: Array<{
283
+ acceptance?: unknown;
284
+ parallel?: Array<{ acceptance?: unknown }> | { acceptance?: unknown };
285
+ }>;
286
+ }): string[] {
287
+ const errors = validateAcceptanceInput(input.acceptance, "acceptance");
288
+ for (const [index, task] of (input.tasks ?? []).entries()) {
289
+ errors.push(...validateAcceptanceInput(task.acceptance, `tasks[${index}].acceptance`));
290
+ }
291
+ for (const [stepIndex, step] of (input.chain ?? []).entries()) {
292
+ errors.push(...validateAcceptanceInput(step.acceptance, `chain[${stepIndex}].acceptance`));
293
+ if (Array.isArray(step.parallel)) {
294
+ for (const [taskIndex, task] of step.parallel.entries()) {
295
+ errors.push(...validateAcceptanceInput(task.acceptance, `chain[${stepIndex}].parallel[${taskIndex}].acceptance`));
296
+ }
297
+ } else if (step.parallel) {
298
+ errors.push(...validateAcceptanceInput(step.parallel.acceptance, `chain[${stepIndex}].parallel.acceptance`));
299
+ }
300
+ }
301
+ return errors;
302
+ }
303
+
251
304
  function normalizeCriteria(criteria: Array<string | { id?: string; must?: string; evidence?: AcceptanceEvidenceKind[]; severity?: "required" | "recommended" }> | undefined, evidence: AcceptanceEvidenceKind[]): ResolvedAcceptanceGate[] {
252
305
  return (criteria ?? []).map((criterion, index) => {
253
306
  if (typeof criterion === "string") {
@@ -265,6 +318,7 @@ function normalizeCriteria(criteria: Array<string | { id?: string; must?: string
265
318
  export function resolveEffectiveAcceptance(input: {
266
319
  explicit?: AcceptanceInput;
267
320
  agentName: string;
321
+ acceptanceRole?: AcceptanceRole;
268
322
  task?: string;
269
323
  mode?: SubagentRunMode;
270
324
  async?: boolean;
@@ -285,7 +339,7 @@ export function resolveEffectiveAcceptance(input: {
285
339
  evidence,
286
340
  );
287
341
  let review = explicit.review !== undefined ? explicit.review : inferred.review;
288
- if (level === "reviewed" && explicitLevel !== "auto" && explicitLevel !== "reviewed" && explicit.review === undefined && review && review !== false) {
342
+ if (level === "reviewed" && input.explicit !== undefined && explicitLevel !== "reviewed" && explicit.review === undefined && review && review !== false) {
289
343
  review = { ...review, required: false };
290
344
  }
291
345
  return {
@@ -329,9 +383,14 @@ export function formatAcceptancePrompt(acceptance: ResolvedAcceptanceConfig): st
329
383
  "",
330
384
  "Finish with a fenced JSON block tagged `acceptance-report` in this shape:",
331
385
  "Use empty arrays when no items apply; array fields contain strings unless object entries are shown.",
386
+ "`criteriaSatisfied[].status` must be exactly one of: satisfied, not-satisfied, not-applicable.",
387
+ "`commandsRun[].result` must be exactly one of: passed, failed, not-run.",
388
+ "`manualNotes` and `notes` are optional strings; an empty string means no note and does not satisfy `manual-notes` evidence.",
332
389
  "```acceptance-report",
333
390
  JSON.stringify({
334
- criteriaSatisfied: [{ id: "criterion-1", status: "satisfied", evidence: "specific proof" }],
391
+ criteriaSatisfied: acceptance.criteria
392
+ .filter((criterion) => criterion.severity !== "recommended")
393
+ .map((criterion) => ({ id: criterion.id, status: "satisfied", evidence: "specific proof" })),
335
394
  changedFiles: ["src/file.ts"],
336
395
  testsAddedOrUpdated: ["test/file.test.ts"],
337
396
  commandsRun: [{ command: "command", result: "passed", summary: "short result" }],
@@ -372,38 +431,166 @@ function extractBalancedJson(text: string, start: number): string | undefined {
372
431
  return undefined;
373
432
  }
374
433
 
375
- function unwrapAcceptanceReport(value: unknown): unknown {
376
- if (!value || typeof value !== "object" || Array.isArray(value)) return value;
377
- const record = value as { acceptance?: unknown; "acceptance-report"?: unknown };
378
- if ("acceptance" in record) return record.acceptance;
379
- if ("acceptance-report" in record) return record["acceptance-report"];
434
+ const ACCEPTANCE_REPORT_WRAPPERS = new Set(["acceptance", "acceptance-report", "acceptance_report", "acceptanceReport"]);
435
+
436
+ const ACCEPTANCE_REPORT_FIELDS: Record<string, keyof AcceptanceReport> = {
437
+ criteriaSatisfied: "criteriaSatisfied",
438
+ criteria_satisfied: "criteriaSatisfied",
439
+ changedFiles: "changedFiles",
440
+ changed_files: "changedFiles",
441
+ testsAddedOrUpdated: "testsAddedOrUpdated",
442
+ tests_added_or_updated: "testsAddedOrUpdated",
443
+ commandsRun: "commandsRun",
444
+ commands_run: "commandsRun",
445
+ validationOutput: "validationOutput",
446
+ validation_output: "validationOutput",
447
+ residualRisks: "residualRisks",
448
+ residual_risks: "residualRisks",
449
+ noStagedFiles: "noStagedFiles",
450
+ no_staged_files: "noStagedFiles",
451
+ diffSummary: "diffSummary",
452
+ diff_summary: "diffSummary",
453
+ reviewFindings: "reviewFindings",
454
+ review_findings: "reviewFindings",
455
+ manualNotes: "manualNotes",
456
+ manual_notes: "manualNotes",
457
+ notes: "notes",
458
+ };
459
+
460
+ const CRITERION_REPORT_FIELDS = new Set(["id", "status", "evidence"]);
461
+ const COMMAND_REPORT_FIELDS = new Set(["command", "result", "summary"]);
462
+
463
+ function normalizedToken(value: string): string {
464
+ return value.trim().toLowerCase().replace(/[\s_]+/g, "-").replace(/-+/g, "-");
465
+ }
466
+
467
+ function normalizeCriterionStatus(value: unknown): unknown {
468
+ if (typeof value !== "string") return value;
469
+ const token = normalizedToken(value);
470
+ if (["satisfied", "met", "complete", "completed", "done", "pass", "passed", "success", "succeeded"].includes(token)) return "satisfied";
471
+ if (["not-satisfied", "not-met", "unmet", "incomplete", "fail", "failed"].includes(token)) return "not-satisfied";
472
+ if (["not-applicable", "n-a", "na", "skip", "skipped"].includes(token)) return "not-applicable";
380
473
  return value;
381
474
  }
382
475
 
383
- function isCommandsRunArray(value: unknown): value is NonNullable<AcceptanceReport["commandsRun"]> {
384
- return Array.isArray(value) && value.every((item) => {
385
- if (!item || typeof item !== "object" || Array.isArray(item)) return false;
386
- const command = item as { command?: unknown; result?: unknown; summary?: unknown };
387
- return typeof command.command === "string"
388
- && (command.result === "passed" || command.result === "failed" || command.result === "not-run")
389
- && typeof command.summary === "string";
390
- });
476
+ function normalizeCommandResult(value: unknown): unknown {
477
+ if (typeof value !== "string") return value;
478
+ const token = normalizedToken(value);
479
+ if (["passed", "pass", "success", "successful", "succeeded", "ok"].includes(token)) return "passed";
480
+ if (["failed", "fail", "failure", "error"].includes(token)) return "failed";
481
+ if (["not-run", "not-executed", "skip", "skipped"].includes(token)) return "not-run";
482
+ return value;
483
+ }
484
+
485
+ function normalizeCriterionReport(value: unknown, pathLabel: string, errors: string[]): unknown {
486
+ if (!value || typeof value !== "object" || Array.isArray(value)) return value;
487
+ const normalized: Record<string, unknown> = {};
488
+ for (const [key, fieldValue] of Object.entries(value as Record<string, unknown>)) {
489
+ if (!CRITERION_REPORT_FIELDS.has(key)) {
490
+ errors.push(`${pathLabel}.${key}: unsupported acceptance criterion field`);
491
+ continue;
492
+ }
493
+ normalized[key] = key === "id" && typeof fieldValue === "string"
494
+ ? normalizedToken(fieldValue)
495
+ : key === "status"
496
+ ? normalizeCriterionStatus(fieldValue)
497
+ : fieldValue;
498
+ }
499
+ return normalized;
500
+ }
501
+
502
+ function normalizeCommandReport(value: unknown, pathLabel: string, errors: string[]): unknown {
503
+ if (!value || typeof value !== "object" || Array.isArray(value)) return value;
504
+ const normalized: Record<string, unknown> = {};
505
+ for (const [key, fieldValue] of Object.entries(value as Record<string, unknown>)) {
506
+ if (!COMMAND_REPORT_FIELDS.has(key)) {
507
+ errors.push(`${pathLabel}.${key}: unsupported acceptance command field`);
508
+ continue;
509
+ }
510
+ normalized[key] = key === "result" ? normalizeCommandResult(fieldValue) : fieldValue;
511
+ }
512
+ return normalized;
513
+ }
514
+
515
+ function normalizeAcceptanceReportValue(value: unknown, pathLabel = ""): { value: unknown; pathLabel: string; errors: string[] } {
516
+ const errors: string[] = [];
517
+ let reportValue = value;
518
+ let reportPath = pathLabel;
519
+ if (reportValue && typeof reportValue === "object" && !Array.isArray(reportValue)) {
520
+ const record = reportValue as Record<string, unknown>;
521
+ const wrapperKeys = Object.keys(record).filter((key) => ACCEPTANCE_REPORT_WRAPPERS.has(key));
522
+ if (wrapperKeys.length > 0) {
523
+ const wrapperKey = wrapperKeys[0]!;
524
+ if (wrapperKeys.length > 1) errors.push(`${pathLabel || "acceptance-report"}: multiple acceptance report wrappers are ambiguous`);
525
+ for (const key of Object.keys(record)) {
526
+ if (key !== wrapperKey) errors.push(`${pathFor(pathLabel, key)}: unsupported alongside acceptance report wrapper '${wrapperKey}'`);
527
+ }
528
+ reportValue = record[wrapperKey];
529
+ reportPath = pathFor(pathLabel, wrapperKey);
530
+ }
531
+ }
532
+ if (!reportValue || typeof reportValue !== "object" || Array.isArray(reportValue)) return { value: reportValue, pathLabel: reportPath, errors };
533
+
534
+ const normalized: Record<string, unknown> = {};
535
+ for (const [key, fieldValue] of Object.entries(reportValue as Record<string, unknown>)) {
536
+ const canonical = ACCEPTANCE_REPORT_FIELDS[key];
537
+ if (!canonical) {
538
+ errors.push(`${pathFor(reportPath, key)}: unsupported acceptance report field`);
539
+ continue;
540
+ }
541
+ if (Object.hasOwn(normalized, canonical)) {
542
+ errors.push(`${pathFor(reportPath, key)}: duplicates normalized field '${canonical}'`);
543
+ continue;
544
+ }
545
+ const fieldPath = pathFor(reportPath, canonical);
546
+ switch (canonical) {
547
+ case "criteriaSatisfied": {
548
+ const items = Array.isArray(fieldValue) ? fieldValue : fieldValue && typeof fieldValue === "object" ? [fieldValue] : fieldValue;
549
+ normalized[canonical] = Array.isArray(items)
550
+ ? items.map((item, index) => normalizeCriterionReport(item, `${fieldPath}[${index}]`, errors))
551
+ : items;
552
+ break;
553
+ }
554
+ case "commandsRun": {
555
+ const items = Array.isArray(fieldValue) ? fieldValue : fieldValue && typeof fieldValue === "object" ? [fieldValue] : fieldValue;
556
+ normalized[canonical] = Array.isArray(items)
557
+ ? items.map((item, index) => normalizeCommandReport(item, `${fieldPath}[${index}]`, errors))
558
+ : items;
559
+ break;
560
+ }
561
+ case "changedFiles":
562
+ case "testsAddedOrUpdated":
563
+ case "validationOutput":
564
+ case "residualRisks":
565
+ case "reviewFindings":
566
+ normalized[canonical] = typeof fieldValue === "string" ? [fieldValue] : fieldValue;
567
+ break;
568
+ case "noStagedFiles": {
569
+ const token = typeof fieldValue === "string" ? fieldValue.trim().toLowerCase() : undefined;
570
+ normalized[canonical] = token === "true" ? true : token === "false" ? false : fieldValue;
571
+ break;
572
+ }
573
+ default:
574
+ normalized[canonical] = fieldValue;
575
+ }
576
+ }
577
+ return { value: normalized, pathLabel: reportPath, errors };
391
578
  }
392
579
 
393
580
  function hasGenericAcceptanceReportSignal(value: unknown): boolean {
394
581
  if (!value || typeof value !== "object" || Array.isArray(value)) return false;
395
582
  const record = value as Record<string, unknown>;
396
- return "criteriaSatisfied" in record && (
397
- isStringArray(record.changedFiles)
398
- || isStringArray(record.testsAddedOrUpdated)
399
- || isCommandsRunArray(record.commandsRun)
400
- || isStringArray(record.validationOutput)
401
- || isStringArray(record.residualRisks)
402
- || typeof record.noStagedFiles === "boolean"
403
- || typeof record.diffSummary === "string"
404
- || isStringArray(record.reviewFindings)
405
- || typeof record.manualNotes === "string"
406
- );
583
+ return "criteriaSatisfied" in record && [
584
+ "changedFiles",
585
+ "testsAddedOrUpdated",
586
+ "commandsRun",
587
+ "validationOutput",
588
+ "residualRisks",
589
+ "noStagedFiles",
590
+ "diffSummary",
591
+ "reviewFindings",
592
+ "manualNotes",
593
+ ].some((key) => key in record);
407
594
  }
408
595
 
409
596
  function parseReportJson(body: string): unknown {
@@ -426,30 +613,29 @@ function fencedBlocks(output: string, tag: string): string[] {
426
613
  .filter((value): value is string => Boolean(value));
427
614
  }
428
615
 
429
- function validationPathLabelForWrapper(value: unknown): string {
430
- if (!value || typeof value !== "object" || Array.isArray(value)) return "";
431
- const record = value as Record<string, unknown>;
432
- if ("acceptance" in record) return "acceptance";
433
- if ("acceptance-report" in record) return "acceptance-report";
434
- return "";
435
- }
436
-
437
616
  function parseAcceptanceReportBody(body: string): { report?: AcceptanceReport; errors: string[] } {
438
- const parsed = parseReportJson(body);
439
- const report = unwrapAcceptanceReport(parsed);
440
- return validateAcceptanceReport(report, validationPathLabelForWrapper(parsed));
617
+ return validateAcceptanceReport(parseReportJson(body));
441
618
  }
442
619
 
443
- function parseGenericJsonAcceptanceReportBody(body: string): AcceptanceReport | undefined {
620
+ function parseGenericJsonAcceptanceReportBody(body: string): { report?: AcceptanceReport; error?: string } {
444
621
  const parsed = parseReportJson(body);
445
- const report = unwrapAcceptanceReport(parsed);
446
- const validation = validateAcceptanceReport(report);
447
- if (!validation.report) return undefined;
448
- return hasGenericAcceptanceReportSignal(validation.report) ? validation.report : undefined;
622
+ const normalized = normalizeAcceptanceReportValue(parsed);
623
+ const hasCriteriaMarker = normalized.value !== null
624
+ && typeof normalized.value === "object"
625
+ && !Array.isArray(normalized.value)
626
+ && "criteriaSatisfied" in normalized.value;
627
+ if (!hasGenericAcceptanceReportSignal(normalized.value) && !(hasCriteriaMarker && normalized.errors.length > 0)) return {};
628
+ const validation = validateAcceptanceReport(parsed);
629
+ return validation.report
630
+ ? { report: validation.report }
631
+ : { error: `Invalid acceptance-report: ${validation.errors.join("; ")}` };
449
632
  }
450
633
 
634
+ export const ACCEPTANCE_REPORT_NOT_FOUND = "Structured acceptance report not found.";
635
+
451
636
  export function parseAcceptanceReport(output: string): { report?: AcceptanceReport; error?: string } {
452
- const fenced = fencedBlocks(output, "acceptance-report");
637
+ const explicitFencePresent = /```acceptance[-_]report\b/i.test(output);
638
+ const fenced = fencedBlocks(output, "acceptance[-_]report");
453
639
  const parseErrors: string[] = [];
454
640
  for (const body of fenced) {
455
641
  try {
@@ -461,38 +647,65 @@ export function parseAcceptanceReport(output: string): { report?: AcceptanceRepo
461
647
  }
462
648
  }
463
649
  if (parseErrors.length > 0) return { error: `Failed to parse acceptance-report: ${parseErrors.join("; ")}` };
650
+ if (explicitFencePresent) {
651
+ return { error: "Failed to parse acceptance-report: Empty or unterminated acceptance-report fence." };
652
+ }
464
653
  for (const body of fencedBlocks(output, "(?:json|jsonc|json5)")) {
465
654
  try {
466
- const report = parseGenericJsonAcceptanceReportBody(body);
467
- if (report) return { report };
655
+ const parsed = parseGenericJsonAcceptanceReportBody(body);
656
+ if (parsed.report) return { report: parsed.report };
657
+ if (parsed.error) return { error: `Failed to parse acceptance-report: ${parsed.error}` };
468
658
  } catch {
469
- // Ignore unrelated or malformed generic JSON fences; only explicit
470
- // acceptance-report fences should turn parse failures into blockers.
659
+ // Ignore unrelated malformed generic JSON. A recognizable report shape
660
+ // returns exact validation errors above instead of being mistaken for prose.
471
661
  }
472
662
  }
473
663
  const markerIndex = output.search(/ACCEPTANCE_REPORT\s*:/i);
474
664
  if (markerIndex !== -1) {
475
665
  const jsonStart = output.indexOf("{", markerIndex);
476
- if (jsonStart !== -1) {
477
- const json = extractBalancedJson(output, jsonStart);
478
- if (json) {
479
- try {
480
- const parsed = JSON.parse(json) as unknown;
481
- const report = unwrapAcceptanceReport(parsed);
482
- const validation = validateAcceptanceReport(report, validationPathLabelForWrapper(parsed));
483
- if (validation.report) return { report: validation.report };
484
- return { error: `Failed to parse acceptance-report: Invalid acceptance-report: ${validation.errors.join("; ")}` };
485
- } catch (error) {
486
- return { error: error instanceof Error ? error.message : String(error) };
487
- }
488
- }
489
- }
666
+ if (jsonStart === -1) {
667
+ return { error: "Failed to parse acceptance-report: Expected a JSON object after ACCEPTANCE_REPORT:." };
490
668
  }
491
- return { error: "Structured acceptance report not found." };
669
+ const json = extractBalancedJson(output, jsonStart);
670
+ if (!json) {
671
+ return { error: "Failed to parse acceptance-report: Unterminated JSON object after ACCEPTANCE_REPORT:." };
672
+ }
673
+ try {
674
+ const parsed = JSON.parse(json) as unknown;
675
+ const validation = validateAcceptanceReport(parsed);
676
+ if (validation.report) return { report: validation.report };
677
+ return { error: `Failed to parse acceptance-report: Invalid acceptance-report: ${validation.errors.join("; ")}` };
678
+ } catch (error) {
679
+ const message = error instanceof Error ? error.message : String(error);
680
+ return { error: `Failed to parse acceptance-report: ${message}` };
681
+ }
682
+ }
683
+ return { error: ACCEPTANCE_REPORT_NOT_FOUND };
684
+ }
685
+
686
+ function parseAcceptanceReportSources(
687
+ output: string,
688
+ fileOutput: { content: string; path: string; authoritative?: boolean } | undefined,
689
+ ): { report?: AcceptanceReport; error?: string } {
690
+ const fromText = () => parseAcceptanceReport(output);
691
+ const fromFile = () => {
692
+ if (!fileOutput) return { error: ACCEPTANCE_REPORT_NOT_FOUND };
693
+ const parsed = parseAcceptanceReport(fileOutput.content);
694
+ return parsed.report || parsed.error === ACCEPTANCE_REPORT_NOT_FOUND
695
+ ? parsed
696
+ : { error: `${parsed.error} (in configured output ${fileOutput.path})` };
697
+ };
698
+ const [primary, secondary] = fileOutput?.authoritative ? [fromFile, fromText] : [fromText, fromFile];
699
+ const first = primary();
700
+ // A malformed report in the primary source is a defect to surface, not a
701
+ // miss to paper over with the secondary source; only a genuinely absent
702
+ // report falls through.
703
+ if (first.report || first.error !== ACCEPTANCE_REPORT_NOT_FOUND) return first;
704
+ return secondary();
492
705
  }
493
706
 
494
707
  export function stripAcceptanceReport(output: string): string {
495
- const trailingFencePattern = /\n?```(acceptance-report|json|jsonc|json5)\s*\n([\s\S]*?)```\s*/gi;
708
+ const trailingFencePattern = /\n?```(acceptance[-_]report|json|jsonc|json5)\s*\n([\s\S]*?)```\s*/gi;
496
709
  let trailingFence: { index: number; tag: string; body: string } | undefined;
497
710
  for (const match of output.matchAll(trailingFencePattern)) {
498
711
  const end = (match.index ?? 0) + match[0].length;
@@ -501,15 +714,15 @@ export function stripAcceptanceReport(output: string): string {
501
714
  }
502
715
  }
503
716
  if (trailingFence) {
504
- if (trailingFence.tag === "acceptance-report") return output.slice(0, trailingFence.index).trimEnd();
717
+ if (trailingFence.tag === "acceptance-report" || trailingFence.tag === "acceptance_report") return output.slice(0, trailingFence.index).trimEnd();
505
718
  try {
506
- if (parseGenericJsonAcceptanceReportBody(trailingFence.body)) return output.slice(0, trailingFence.index).trimEnd();
719
+ if (parseGenericJsonAcceptanceReportBody(trailingFence.body).report) return output.slice(0, trailingFence.index).trimEnd();
507
720
  } catch {
508
721
  // Leave unrelated or malformed generic JSON fences visible.
509
722
  }
510
723
  }
511
724
  return output
512
- .replace(/\n?```acceptance-report\s*\n[\s\S]*?```\s*$/i, "")
725
+ .replace(/\n?```acceptance[-_]report\s*\n[\s\S]*?```\s*$/i, "")
513
726
  .replace(/\n?ACCEPTANCE_REPORT\s*:\s*\{[\s\S]*\}\s*$/i, "")
514
727
  .trimEnd();
515
728
  }
@@ -544,12 +757,15 @@ function validateStringArrayField(errors: string[], value: unknown, pathLabel: s
544
757
  return;
545
758
  }
546
759
  for (const [index, item] of value.entries()) {
547
- if (typeof item !== "string") pushTypeError(errors, `${pathLabel}[${index}]`, "string", item);
760
+ if (typeof item !== "string" || !item.trim()) pushTypeError(errors, `${pathLabel}[${index}]`, "non-empty string", item);
548
761
  }
549
762
  }
550
763
 
551
764
  function validateAcceptanceReport(value: unknown, pathLabel = ""): { report?: AcceptanceReport; errors: string[] } {
552
- const errors: string[] = [];
765
+ const normalized = normalizeAcceptanceReportValue(value, pathLabel);
766
+ value = normalized.value;
767
+ pathLabel = normalized.pathLabel;
768
+ const errors = normalized.errors;
553
769
  if (!value || typeof value !== "object" || Array.isArray(value)) {
554
770
  pushTypeError(errors, pathLabel || "acceptance-report", "object", value);
555
771
  return { errors };
@@ -559,6 +775,7 @@ function validateAcceptanceReport(value: unknown, pathLabel = ""): { report?: Ac
559
775
  if (!Array.isArray(report.criteriaSatisfied)) {
560
776
  pushTypeError(errors, pathFor(pathLabel, "criteriaSatisfied"), "array", report.criteriaSatisfied);
561
777
  } else {
778
+ const criterionIds = new Set<string>();
562
779
  for (const [index, item] of report.criteriaSatisfied.entries()) {
563
780
  const itemPath = `${pathFor(pathLabel, "criteriaSatisfied")}[${index}]`;
564
781
  if (!item || typeof item !== "object" || Array.isArray(item)) {
@@ -566,7 +783,12 @@ function validateAcceptanceReport(value: unknown, pathLabel = ""): { report?: Ac
566
783
  continue;
567
784
  }
568
785
  const criterion = item as { id?: unknown; status?: unknown; evidence?: unknown };
569
- if (criterion.id !== undefined && typeof criterion.id !== "string") pushTypeError(errors, `${itemPath}.id`, "string", criterion.id);
786
+ if (criterion.id !== undefined && typeof criterion.id !== "string") {
787
+ pushTypeError(errors, `${itemPath}.id`, "string", criterion.id);
788
+ } else if (typeof criterion.id === "string" && criterion.id) {
789
+ if (criterionIds.has(criterion.id)) errors.push(`${itemPath}.id: duplicate normalized criterion id '${criterion.id}'`);
790
+ criterionIds.add(criterion.id);
791
+ }
570
792
  if (criterion.status !== "satisfied" && criterion.status !== "not-satisfied" && criterion.status !== "not-applicable") {
571
793
  pushTypeError(errors, `${itemPath}.status`, "one of \"satisfied\", \"not-satisfied\", \"not-applicable\"", criterion.status);
572
794
  }
@@ -591,14 +813,14 @@ function validateAcceptanceReport(value: unknown, pathLabel = ""): { report?: Ac
591
813
  if (command.result !== "passed" && command.result !== "failed" && command.result !== "not-run") {
592
814
  pushTypeError(errors, `${itemPath}.result`, "one of \"passed\", \"failed\", \"not-run\"", command.result);
593
815
  }
594
- if (typeof command.summary !== "string") pushTypeError(errors, `${itemPath}.summary`, "string", command.summary);
816
+ if (typeof command.summary !== "string" || !command.summary.trim()) pushTypeError(errors, `${itemPath}.summary`, "non-empty string", command.summary);
595
817
  }
596
818
  }
597
819
  }
598
820
  if (report.validationOutput !== undefined) validateStringArrayField(errors, report.validationOutput, pathFor(pathLabel, "validationOutput"));
599
821
  if (report.residualRisks !== undefined) validateStringArrayField(errors, report.residualRisks, pathFor(pathLabel, "residualRisks"));
600
822
  if (report.noStagedFiles !== undefined && typeof report.noStagedFiles !== "boolean") pushTypeError(errors, pathFor(pathLabel, "noStagedFiles"), "boolean", report.noStagedFiles);
601
- if (report.diffSummary !== undefined && typeof report.diffSummary !== "string") pushTypeError(errors, pathFor(pathLabel, "diffSummary"), "string", report.diffSummary);
823
+ if (report.diffSummary !== undefined && (typeof report.diffSummary !== "string" || !report.diffSummary.trim())) pushTypeError(errors, pathFor(pathLabel, "diffSummary"), "non-empty string", report.diffSummary);
602
824
  if (report.reviewFindings !== undefined) validateStringArrayField(errors, report.reviewFindings, pathFor(pathLabel, "reviewFindings"));
603
825
  if (report.manualNotes !== undefined && typeof report.manualNotes !== "string") pushTypeError(errors, pathFor(pathLabel, "manualNotes"), "string", report.manualNotes);
604
826
  if (report.notes !== undefined && typeof report.notes !== "string") pushTypeError(errors, pathFor(pathLabel, "notes"), "string", report.notes);
@@ -620,26 +842,30 @@ function validateAcceptanceReport(value: unknown, pathLabel = ""): { report?: Ac
620
842
  }
621
843
 
622
844
  function checkCriteriaSatisfied(criteria: ResolvedAcceptanceGate[], report: AcceptanceReport): AcceptanceRuntimeCheck[] {
623
- const reports = new Map((report.criteriaSatisfied ?? []).filter((item) => item.id).map((item) => [item.id!, item]));
845
+ const reports = new Map((report.criteriaSatisfied ?? []).filter((item) => item.id).map((item) => [normalizedToken(item.id!), item]));
624
846
  return criteria.filter((criterion) => criterion.severity !== "recommended").map((criterion) => {
625
- const item = reports.get(criterion.id);
847
+ const item = reports.get(normalizedToken(criterion.id));
626
848
  if (!item) return { id: `criterion:${criterion.id}`, status: "failed", message: `Required criterion '${criterion.id}' was not reported.` };
627
849
  if (item.status !== "satisfied") return { id: `criterion:${criterion.id}`, status: "failed", message: `Required criterion '${criterion.id}' was reported as ${item.status}.` };
628
850
  return { id: `criterion:${criterion.id}`, status: "passed", message: `Required criterion '${criterion.id}' satisfied.` };
629
851
  });
630
852
  }
631
853
 
632
- function reportEvidencePresent(report: AcceptanceReport, kind: AcceptanceEvidenceKind): boolean {
854
+ function reportEvidenceStatus(report: AcceptanceReport, kind: AcceptanceEvidenceKind): AcceptanceRuntimeCheckStatus {
633
855
  switch (kind) {
634
- case "changed-files": return isStringArray(report.changedFiles) && report.changedFiles.length > 0;
635
- case "tests-added": return isStringArray(report.testsAddedOrUpdated) && report.testsAddedOrUpdated.length > 0;
636
- case "commands-run": return Array.isArray(report.commandsRun) && report.commandsRun.length > 0;
637
- case "validation-output": return isStringArray(report.validationOutput) && report.validationOutput.length > 0;
638
- case "residual-risks": return isStringArray(report.residualRisks);
639
- case "no-staged-files": return report.noStagedFiles === true;
640
- case "diff-summary": return typeof report.diffSummary === "string" && report.diffSummary.trim().length > 0;
641
- case "review-findings": return isStringArray(report.reviewFindings);
642
- case "manual-notes": return Boolean((report.manualNotes ?? report.notes)?.trim());
856
+ case "changed-files":
857
+ if (!isStringArray(report.changedFiles)) return "failed";
858
+ return report.changedFiles.length === 0 ? "not-applicable" : "passed";
859
+ case "tests-added":
860
+ if (!isStringArray(report.testsAddedOrUpdated)) return "failed";
861
+ return report.testsAddedOrUpdated.length === 0 ? "not-applicable" : "passed";
862
+ case "commands-run": return Array.isArray(report.commandsRun) && report.commandsRun.length > 0 ? "passed" : "failed";
863
+ case "validation-output": return isStringArray(report.validationOutput) && report.validationOutput.length > 0 ? "passed" : "failed";
864
+ case "residual-risks": return isStringArray(report.residualRisks) ? "passed" : "failed";
865
+ case "no-staged-files": return report.noStagedFiles === true ? "passed" : "failed";
866
+ case "diff-summary": return typeof report.diffSummary === "string" && report.diffSummary.trim().length > 0 ? "passed" : "failed";
867
+ case "review-findings": return isStringArray(report.reviewFindings) ? "passed" : "failed";
868
+ case "manual-notes": return Boolean((report.manualNotes ?? report.notes)?.trim()) ? "passed" : "failed";
643
869
  }
644
870
  }
645
871
 
@@ -657,11 +883,15 @@ function checkNoStagedFiles(cwd: string): AcceptanceRuntimeCheck {
657
883
  function runStructuralChecks(acceptance: ResolvedAcceptanceConfig, report: AcceptanceReport, cwd: string): AcceptanceRuntimeCheck[] {
658
884
  const checks: AcceptanceRuntimeCheck[] = [];
659
885
  for (const kind of acceptance.evidence) {
660
- const present = reportEvidencePresent(report, kind);
886
+ const status = reportEvidenceStatus(report, kind);
661
887
  checks.push({
662
888
  id: `evidence:${kind}`,
663
- status: present ? "passed" : "failed",
664
- message: present ? `${kind} evidence present.` : `${kind} evidence missing from child report.`,
889
+ status,
890
+ message: status === "passed"
891
+ ? `${kind} evidence present.`
892
+ : status === "not-applicable"
893
+ ? `${kind} evidence explicitly reported as not applicable.`
894
+ : `${kind} evidence missing from child report.`,
665
895
  });
666
896
  }
667
897
  if (acceptance.evidence.includes("no-staged-files")) checks.push(checkNoStagedFiles(cwd));
@@ -788,6 +1018,13 @@ export async function evaluateAcceptance(input: {
788
1018
  acceptance: ResolvedAcceptanceConfig;
789
1019
  output: string;
790
1020
  cwd: string;
1021
+ /**
1022
+ * Content the child sent to its configured output file (from its own write
1023
+ * tool calls, not from disk, so a concurrent writer to the same path cannot
1024
+ * be misattributed). Searched for the acceptance report; searched before
1025
+ * the assistant output when `authoritative` (outputMode "file-only").
1026
+ */
1027
+ fileOutput?: { content: string; path: string; authoritative?: boolean };
791
1028
  report?: AcceptanceReport;
792
1029
  reviewResult?: AcceptanceReviewResult;
793
1030
  signal?: AbortSignal;
@@ -805,7 +1042,14 @@ export async function evaluateAcceptance(input: {
805
1042
  };
806
1043
  if (acceptance.level === "none") return ledger;
807
1044
 
808
- const parsed = input.report ? { report: input.report } : parseAcceptanceReport(input.output);
1045
+ const parsed = input.report
1046
+ ? (() => {
1047
+ const validation = validateAcceptanceReport(input.report);
1048
+ return validation.report
1049
+ ? { report: validation.report }
1050
+ : { error: `Failed to parse acceptance-report: Invalid acceptance-report: ${validation.errors.join("; ")}` };
1051
+ })()
1052
+ : parseAcceptanceReportSources(input.output, input.fileOutput);
809
1053
  if (parsed.report) {
810
1054
  ledger.childReport = parsed.report;
811
1055
  ledger.status = "attested";
@@ -867,6 +1111,20 @@ export async function evaluateAcceptance(input: {
867
1111
  return ledger;
868
1112
  }
869
1113
 
1114
+ export function buildSkippedAcceptanceLedger(acceptance: ResolvedAcceptanceConfig, input: { id: string; message: string }): AcceptanceLedger {
1115
+ return {
1116
+ status: acceptance.level === "none" ? "not-required" : "rejected",
1117
+ explicit: acceptance.explicit,
1118
+ effectiveAcceptance: acceptance,
1119
+ inferredReason: acceptance.inferredReason,
1120
+ criteria: acceptance.criteria,
1121
+ runtimeChecks: acceptance.level === "none"
1122
+ ? []
1123
+ : [{ id: input.id, status: "failed", message: input.message }],
1124
+ verifyRuns: [],
1125
+ };
1126
+ }
1127
+
870
1128
  export function acceptanceFailureMessage(ledger: AcceptanceLedger): string | undefined {
871
1129
  if (ledger.status !== "rejected") return undefined;
872
1130
  const failedCheck = ledger.runtimeChecks.find((check) => check.status === "failed");