@akagilnc/pi-workflow-roles 0.1.1751

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 (192) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +104 -0
  3. package/README.zh-CN.md +133 -0
  4. package/THIRD_PARTY_NOTICES.md +60 -0
  5. package/dist/activation-ledger-git.js +68 -0
  6. package/dist/activation-ledger-session.js +120 -0
  7. package/dist/activation-ledger-topology.js +239 -0
  8. package/dist/activation-reconciliation.js +61 -0
  9. package/dist/audit-escalation.js +108 -0
  10. package/dist/auditor-dossier-tool.js +35 -0
  11. package/dist/canonical-json.js +78 -0
  12. package/dist/compliance-transport.js +77 -0
  13. package/dist/doctor-contracts.js +172 -0
  14. package/dist/dossier-resolution.js +103 -0
  15. package/dist/evidence-child-executor.js +661 -0
  16. package/dist/exact-utf8.js +12 -0
  17. package/dist/git-object-id.js +7 -0
  18. package/dist/in-process-session.js +50 -0
  19. package/dist/merger-contracts.js +76 -0
  20. package/dist/navigator-attendance.js +995 -0
  21. package/dist/navigator-invocation-identity.js +220 -0
  22. package/dist/open-tool-schema.js +39 -0
  23. package/dist/package-contracts/collector-output.js +50 -0
  24. package/dist/package-contracts/fixer-output.js +72 -0
  25. package/dist/package-contracts/fixer-packet.js +77 -0
  26. package/dist/package-contracts/judge-output.js +17 -0
  27. package/dist/package-contracts/reviewer-output.js +82 -0
  28. package/dist/package-contracts/terminating-tools.js +173 -0
  29. package/dist/package-contracts/worker-output.js +13 -0
  30. package/dist/package-owned-tool-idle.js +104 -0
  31. package/dist/packaged-role-registry.js +34 -0
  32. package/dist/public-cli/main.js +23867 -0
  33. package/dist/public-command-renderer.js +20 -0
  34. package/dist/reviewer-agent.js +93 -0
  35. package/dist/reviewer-child-executor.js +23 -0
  36. package/dist/reviewer-construction.js +95 -0
  37. package/dist/reviewer-dispatch.js +77 -0
  38. package/dist/reviewer-execution-ledger.js +160 -0
  39. package/dist/reviewer-failure-diagnostic.js +17 -0
  40. package/dist/reviewer-git-snapshot.js +38 -0
  41. package/dist/reviewer-pinned-git.js +146 -0
  42. package/dist/reviewer-preflight-error.js +15 -0
  43. package/dist/reviewer-prompt-identity.js +10 -0
  44. package/dist/reviewer-scope-prompt.js +21 -0
  45. package/dist/reviewer-workspace.js +151 -0
  46. package/dist/sha256.js +5 -0
  47. package/dist/sitian-record-entry.js +33 -0
  48. package/dist/stderr-jsonl.js +26 -0
  49. package/dist/stream-idle-guard.js +75 -0
  50. package/dist/tool-execution-observation.js +141 -0
  51. package/dist/uuidv7.js +21 -0
  52. package/dist/work-subject-identity.js +53 -0
  53. package/extensions/role-runtime.ts +303 -0
  54. package/package.json +69 -0
  55. package/packets/fixer-prerequisites.json +6 -0
  56. package/packets/fixer-repair.md +5 -0
  57. package/packets/judge-apply.md +77 -0
  58. package/packets/judge-authority.md +64 -0
  59. package/packets/judge-plan.md +55 -0
  60. package/packets/judge-review.md +49 -0
  61. package/packets/judge-submission.md +34 -0
  62. package/resources/methods/code-review/SKILL.md +92 -0
  63. package/resources/methods/code-review/agents/openai.yaml +3 -0
  64. package/resources/methods/code-review/provenance.json +26 -0
  65. package/resources/methods/diagnosing-bugs/SKILL.md +134 -0
  66. package/resources/methods/diagnosing-bugs/agents/openai.yaml +3 -0
  67. package/resources/methods/diagnosing-bugs/provenance.json +31 -0
  68. package/resources/methods/diagnosing-bugs/scripts/hitl-loop.template.sh +41 -0
  69. package/resources/methods/resolving-merge-conflicts/SKILL.md +14 -0
  70. package/resources/methods/resolving-merge-conflicts/agents/openai.yaml +3 -0
  71. package/resources/methods/resolving-merge-conflicts/provenance.json +26 -0
  72. package/resources/methods/tdd/SKILL.md +38 -0
  73. package/resources/methods/tdd/agents/openai.yaml +3 -0
  74. package/resources/methods/tdd/mocking.md +59 -0
  75. package/resources/methods/tdd/provenance.json +36 -0
  76. package/resources/methods/tdd/tests.md +77 -0
  77. package/resources/navigator-route-playbook.md +32 -0
  78. package/schemas/tool-execution-observation.schema.json +107 -0
  79. package/scripts/build-package.mjs +65 -0
  80. package/scripts/generate-tool-execution-observation-schema.ts +7 -0
  81. package/souls/coder.md +10 -0
  82. package/souls/collector.md +11 -0
  83. package/souls/doctor-auditor.md +23 -0
  84. package/souls/doctor.md +8 -0
  85. package/souls/fixer-auditor.md +33 -0
  86. package/souls/fixer.md +13 -0
  87. package/souls/judge-auditor.md +33 -0
  88. package/souls/judge.md +74 -0
  89. package/souls/merger.md +5 -0
  90. package/souls/navigator.md +5 -0
  91. package/souls/reviewer-auditor.md +25 -0
  92. package/souls/reviewer.md +11 -0
  93. package/src/activation-ledger-git.ts +96 -0
  94. package/src/activation-ledger-session.ts +188 -0
  95. package/src/activation-ledger-topology.ts +301 -0
  96. package/src/activation-ledger.ts +240 -0
  97. package/src/activation-reconciliation.ts +163 -0
  98. package/src/activation-trace.ts +38 -0
  99. package/src/audit-escalation.ts +177 -0
  100. package/src/auditor-dossier-tool.ts +48 -0
  101. package/src/auditor-soul.ts +28 -0
  102. package/src/canonical-json.ts +74 -0
  103. package/src/canonical-skill-binding.ts +107 -0
  104. package/src/collector-config.ts +89 -0
  105. package/src/collector-evidence.ts +461 -0
  106. package/src/collector-github.ts +656 -0
  107. package/src/collector-identity.ts +161 -0
  108. package/src/collector-ledger.ts +827 -0
  109. package/src/collector-receipt.ts +87 -0
  110. package/src/collector-role.ts +592 -0
  111. package/src/collector-tool-schemas.ts +19 -0
  112. package/src/compliance-transport.ts +130 -0
  113. package/src/doctor-auditor.ts +53 -0
  114. package/src/doctor-contracts.ts +166 -0
  115. package/src/doctor-evidence.ts +47 -0
  116. package/src/doctor-role.ts +18 -0
  117. package/src/dossier-resolution.ts +137 -0
  118. package/src/evidence-child-executor.ts +775 -0
  119. package/src/exact-utf8.ts +9 -0
  120. package/src/factory-board.ts +1822 -0
  121. package/src/git-object-id.ts +11 -0
  122. package/src/human-format.ts +65 -0
  123. package/src/in-process-session.ts +78 -0
  124. package/src/judge-auditor.ts +55 -0
  125. package/src/judge-recording-anti-forge.ts +53 -0
  126. package/src/judge-role.ts +160 -0
  127. package/src/merger-contracts.ts +71 -0
  128. package/src/merger-git-state.ts +76 -0
  129. package/src/merger-role.ts +60 -0
  130. package/src/navigator-attendance.ts +1254 -0
  131. package/src/navigator-invocation-identity.ts +446 -0
  132. package/src/open-tool-schema.ts +46 -0
  133. package/src/package-contracts/collector-output.ts +109 -0
  134. package/src/package-contracts/fixer-output.ts +81 -0
  135. package/src/package-contracts/fixer-packet.ts +93 -0
  136. package/src/package-contracts/judge-output.ts +39 -0
  137. package/src/package-contracts/reviewer-output.ts +115 -0
  138. package/src/package-contracts/terminating-tools.ts +259 -0
  139. package/src/package-contracts/worker-output.ts +36 -0
  140. package/src/package-owned-tool-idle.ts +134 -0
  141. package/src/package-resources/method-skill-binding.ts +87 -0
  142. package/src/package-resources/method-skill.ts +358 -0
  143. package/src/packaged-role-registry.ts +36 -0
  144. package/src/public-cli/cli-errors.ts +11 -0
  145. package/src/public-cli/cli-io.ts +4 -0
  146. package/src/public-cli/cli.ts +912 -0
  147. package/src/public-cli/coder-run.ts +575 -0
  148. package/src/public-cli/collector-run.ts +375 -0
  149. package/src/public-cli/command-renderer.ts +8 -0
  150. package/src/public-cli/config.ts +346 -0
  151. package/src/public-cli/doctor-run.ts +355 -0
  152. package/src/public-cli/explicit-internal.ts +274 -0
  153. package/src/public-cli/fixer-run.ts +587 -0
  154. package/src/public-cli/host-pi-runtime.ts +112 -0
  155. package/src/public-cli/invocation.ts +1958 -0
  156. package/src/public-cli/judge-run.ts +507 -0
  157. package/src/public-cli/main.ts +15 -0
  158. package/src/public-cli/merger-run.ts +681 -0
  159. package/src/public-cli/public-run-credentials.ts +71 -0
  160. package/src/public-cli/registry.ts +153 -0
  161. package/src/public-cli/reviewer-run.ts +561 -0
  162. package/src/public-cli/run-lifecycle.ts +884 -0
  163. package/src/public-cli/settlement.ts +3765 -0
  164. package/src/public-cli/terminal.ts +325 -0
  165. package/src/public-command-renderer.ts +43 -0
  166. package/src/reviewer-agent.ts +94 -0
  167. package/src/reviewer-auditor.ts +53 -0
  168. package/src/reviewer-child-executor.ts +31 -0
  169. package/src/reviewer-construction.ts +137 -0
  170. package/src/reviewer-dispatch.ts +94 -0
  171. package/src/reviewer-execution-ledger.ts +206 -0
  172. package/src/reviewer-failure-diagnostic.ts +18 -0
  173. package/src/reviewer-git-snapshot.ts +53 -0
  174. package/src/reviewer-pinned-git.ts +144 -0
  175. package/src/reviewer-preflight-error.ts +14 -0
  176. package/src/reviewer-prompt-identity.ts +17 -0
  177. package/src/reviewer-role.ts +193 -0
  178. package/src/reviewer-scope-prompt.ts +24 -0
  179. package/src/reviewer-settlement.ts +63 -0
  180. package/src/reviewer-workspace.ts +111 -0
  181. package/src/role-runtime.ts +884 -0
  182. package/src/sha256.ts +6 -0
  183. package/src/sitian-record-entry.ts +57 -0
  184. package/src/stderr-jsonl.ts +28 -0
  185. package/src/stream-idle-guard.ts +98 -0
  186. package/src/ticket-snapshot.ts +662 -0
  187. package/src/ticket-trajectory.ts +1000 -0
  188. package/src/tool-execution-observation.ts +168 -0
  189. package/src/uuidv7.ts +1 -0
  190. package/src/work-subject-identity.ts +94 -0
  191. package/src/worker-role.ts +434 -0
  192. package/src/worker-submission-gates.ts +225 -0
@@ -0,0 +1,3765 @@
1
+ /**
2
+ * Shared settlement for public Role runs: role outcome + Navigator fact + artifacts
3
+ * into one Terminal result (ADR 0052 / #106 / #107 / #101).
4
+ * Controlled failures and audit human decisions settle here without washing causes.
5
+ */
6
+ import { randomUUID } from "node:crypto";
7
+ import { lstat, mkdir, open, readFile, readdir, writeFile } from "node:fs/promises";
8
+ import { dirname, join } from "node:path";
9
+
10
+ import { isAuditEscalationResult } from "../audit-escalation.ts";
11
+ import { AUDITOR_SOUL_ROLES } from "../auditor-soul.ts";
12
+ import { DOCTOR_AUDIT_TOOL_NAME } from "../doctor-auditor.ts";
13
+ import { JUDGE_AUDIT_TOOL_NAME } from "../judge-auditor.ts";
14
+ import { REVIEWER_AUDIT_TOOL_NAME } from "../reviewer-auditor.ts";
15
+ import { knownFailureFromProviderStop, type ExplicitInternalKnownFailure } from "./explicit-internal.ts";
16
+ import {
17
+ AUDITOR_COMPLIANCE_FAILURE_ENTRY_TYPE,
18
+ AUDITOR_PARENT_ATTEMPT_BINDING_ENTRY_TYPE,
19
+ COMPLIANCE_RESPONSE_ENTRY_TYPE,
20
+ readComplianceCandidate,
21
+ type ComplianceAuditIncomplete,
22
+ type ComplianceDecision,
23
+ } from "../compliance-transport.ts";
24
+ import {
25
+ COLLECTOR_OBSERVE_TOOL,
26
+ COLLECTOR_REQUEST_TOOL,
27
+ COLLECTOR_WAIT_TOOL,
28
+ } from "../collector-ledger.ts";
29
+ import {
30
+ JUDGE_OUTPUT_TOOL_NAME,
31
+ type JudgeVerdict,
32
+ } from "../package-contracts/judge-output.ts";
33
+ import {
34
+ COLLECTOR_OUTPUT_TOOL,
35
+ validateAcceptedCollectorReceipt,
36
+ type CollectorReceipt,
37
+ } from "../package-contracts/collector-output.ts";
38
+ import {
39
+ CODER_OUTPUT_TOOL_NAME,
40
+ FIXER_OUTPUT_TOOL_NAME,
41
+ validateAcceptedCoderDetails,
42
+ validateFixerOutput,
43
+ type CoderOutput,
44
+ type FixerOutput,
45
+ } from "../package-contracts/worker-output.ts";
46
+ import { validateAcceptedDetails } from "../package-contracts/terminating-tools.ts";
47
+ import {
48
+ DOCTOR_OUTPUT_TOOL_NAME,
49
+ validateRecordedDoctorOutput,
50
+ type DoctorOutput,
51
+ } from "../doctor-contracts.ts";
52
+ import {
53
+ REVIEWER_OUTPUT_TOOL_NAME,
54
+ validateRuntimeReviewerReceipt,
55
+ type RuntimeReviewerReceiptV2,
56
+ } from "../package-contracts/reviewer-output.ts";
57
+ import {
58
+ MERGER_OUTPUT_TOOL_NAME,
59
+ validateMergerOutput,
60
+ type MergerOutput,
61
+ } from "../merger-contracts.ts";
62
+ import {
63
+ observePackagedMethodSkillInvocation,
64
+ type ObservedPackagedMethodSkillInvocation,
65
+ type PackagedMethodSkillProvenance,
66
+ } from "../package-resources/method-skill.ts";
67
+ import {
68
+ bindCurrentDurableTerminalToMarker,
69
+ classifyPackagedRoleTerminalResult,
70
+ isAcceptedPackagedRoleTerminalResult,
71
+ isReceiptSettlementBindingClear,
72
+ markerMatchesExpectedIdentity,
73
+ type ExpectedInvocationIdentity,
74
+ type InvocationMarkerIdentity,
75
+ } from "../navigator-invocation-identity.ts";
76
+ import type { NavigatorPhase } from "../navigator-attendance.ts";
77
+ import { packagedRoleMetadata } from "../packaged-role-registry.ts";
78
+ import {
79
+ workSubjectKeyFromProjectRoot,
80
+ workSubjectKeysEqual,
81
+ } from "../work-subject-identity.ts";
82
+ import {
83
+ ensureRunArtifactsDir,
84
+ type AdmittedCoderInvocation,
85
+ type AdmittedCollectorInvocation,
86
+ type AdmittedDoctorInvocation,
87
+ type AdmittedFixerInvocation,
88
+ type AdmittedJudgeInvocation,
89
+ type AdmittedMergerInvocation,
90
+ type AdmittedReviewerInvocation,
91
+ type AdmittedRoleInvocation,
92
+ } from "./invocation.ts";
93
+ import {
94
+ exitCodeForTerminalOutcome,
95
+ formatTerminalResult,
96
+ isLawfulTypedTerminalOutcome,
97
+ recommendationNavigatorFact,
98
+ buildAuditIncompleteTerminalOutcome,
99
+ buildResidualIncompleteTerminalOutcome,
100
+ redactExactRunId,
101
+ type AuditIncompleteResidual,
102
+ type ControlledFailureCause,
103
+ type TerminalArtifactRef,
104
+ type TerminalNavigatorFact,
105
+ type TerminalResult,
106
+ type TerminalResume,
107
+ type TerminalRoleOutcome,
108
+ } from "./terminal.ts";
109
+
110
+ export type { ControlledFailureCause };
111
+
112
+ export {
113
+ exitCodeForTerminalOutcome,
114
+ formatTerminalResult,
115
+ isLawfulTypedTerminalOutcome,
116
+ };
117
+
118
+ /** Preserved post-admission failure cause (not a role Receipt). */
119
+ export type ControlledFailure = {
120
+ readonly cause: ControlledFailureCause;
121
+ readonly diagnostic: string;
122
+ readonly identity?: {
123
+ readonly name?: string;
124
+ readonly code?: string | number;
125
+ };
126
+ readonly details?: Readonly<Record<string, unknown>>;
127
+ };
128
+
129
+ /** Presentation bound for one stderr diagnostic line (durable artifact keeps full text). */
130
+ export const CONCISE_DIAGNOSTIC_MAX_CHARS = 480;
131
+
132
+ /**
133
+ * True when a stderr line is observation/event/token/stack flood rather than a diagnostic.
134
+ * Recognizes both `event:` prefixes and real JSONL records with an `event` key
135
+ * (tool_execution_* observation face).
136
+ */
137
+ export function isChildDiagnosticFloodLine(line: string): boolean {
138
+ if (/^at\s+/.test(line)) return true;
139
+ if (line.startsWith("event:")) return true;
140
+ if (/\btokens?=/.test(line)) return true;
141
+ if (/\btool_calls?=/.test(line)) return true;
142
+ if (line.startsWith("{")) {
143
+ try {
144
+ const parsed: unknown = JSON.parse(line);
145
+ if (
146
+ typeof parsed === "object" &&
147
+ parsed !== null &&
148
+ !Array.isArray(parsed) &&
149
+ typeof (parsed as { event?: unknown }).event === "string"
150
+ ) {
151
+ return true;
152
+ }
153
+ } catch {
154
+ // Not JSON — may still be a real diagnostic.
155
+ }
156
+ }
157
+ return false;
158
+ }
159
+
160
+ /**
161
+ * True when a stderr line is Pi auth/model help scaffolding rather than the failure identity.
162
+ * Real counterexample: multi-line "No API key…" guidance ends with docs/*.md path lines;
163
+ * those footers must not displace the primary diagnostic.
164
+ */
165
+ export function isChildDiagnosticHelpFooterLine(line: string): boolean {
166
+ const trimmed = line.trim();
167
+ if (trimmed.length === 0) return false;
168
+ // Path-only doc references (indented or bare).
169
+ if (/^\S+\.(md|txt)$/i.test(trimmed)) return true;
170
+ // Auth guidance continuations from Pi formatNoApiKeyFoundMessage / getProviderLoginHelp.
171
+ if (/^Use \//i.test(trimmed)) return true;
172
+ if (/^Then use \//i.test(trimmed)) return true;
173
+ if (/^See:\s*$/i.test(trimmed)) return true;
174
+ return false;
175
+ }
176
+
177
+ /** Bound one diagnostic for human stderr presentation; durable evidence stays full. */
178
+ export function boundConciseDiagnostic(
179
+ diagnostic: string,
180
+ maxChars: number = CONCISE_DIAGNOSTIC_MAX_CHARS,
181
+ ): string {
182
+ if (diagnostic.length <= maxChars) return diagnostic;
183
+ if (maxChars <= 1) return "…";
184
+ return `${diagnostic.slice(0, maxChars - 1)}…`;
185
+ }
186
+
187
+ /**
188
+ * Pick one concise diagnostic line from child stderr without stacks/events/tokens/help footers.
189
+ * Prefers the last nonblank line that is not a frame, observation flood, or docs-path footer.
190
+ * Returns the full selected diagnostic (bound only at presentation).
191
+ */
192
+ export function conciseChildDiagnostic(
193
+ stderr: string,
194
+ fallback: string,
195
+ ): string {
196
+ const lines = stderr
197
+ .split(/\r?\n/)
198
+ .map((line) => line.trim())
199
+ .filter((line) => line.length > 0);
200
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
201
+ const line = lines[i]!;
202
+ if (isChildDiagnosticFloodLine(line)) continue;
203
+ if (isChildDiagnosticHelpFooterLine(line)) continue;
204
+ // Strip a leading "Error:" label but keep the message identity.
205
+ return line.replace(/^Error:\s*/i, "").trim() || fallback;
206
+ }
207
+ return fallback;
208
+ }
209
+
210
+ export function formatCliDiagnostic(message: string): string {
211
+ return `ak-role: ${message}\n`;
212
+ }
213
+
214
+ /**
215
+ * One concise stderr line for humans. Durable Error Artifact / Terminal keep the
216
+ * full original diagnostic — presentation collapses newlines and flood frames.
217
+ */
218
+ export function formatFailureStderrDiagnostic(failure: ControlledFailure): string {
219
+ const selected = conciseChildDiagnostic(failure.diagnostic, "failure");
220
+ // conciseChildDiagnostic already returns one split line; defend fallback paths.
221
+ const oneLine =
222
+ selected
223
+ .split(/\r?\n/)
224
+ .map((line) => line.trim())
225
+ .find((line) => line.length > 0) ?? "failure";
226
+ return formatCliDiagnostic(boundConciseDiagnostic(oneLine));
227
+ }
228
+
229
+ /** Pre-admission structural rejection: stderr only, no run, no Terminal. */
230
+ export function presentStructuralRejection(
231
+ error: { message: string },
232
+ io: { stderr: (text: string) => void },
233
+ ): void {
234
+ io.stderr(formatCliDiagnostic(error.message));
235
+ }
236
+
237
+ /** Session readiness after an admitted activation attempt. */
238
+ export type SessionReadiness =
239
+ | { readonly state: "missing" }
240
+ | { readonly state: "unreadable"; readonly diagnostic: string }
241
+ | { readonly state: "present" };
242
+
243
+ export async function inspectJudgeSession(
244
+ sessionFile: string,
245
+ ): Promise<SessionReadiness> {
246
+ try {
247
+ await readFile(sessionFile, "utf8");
248
+ return { state: "present" };
249
+ } catch (error) {
250
+ if (isMissingPathError(error)) return { state: "missing" };
251
+ return {
252
+ state: "unreadable",
253
+ diagnostic:
254
+ error instanceof Error
255
+ ? error.message || error.name
256
+ : String(error),
257
+ };
258
+ }
259
+ }
260
+
261
+ function thrownIdentity(error: Error): {
262
+ name?: string;
263
+ code?: string | number;
264
+ } {
265
+ const identity: { name?: string; code?: string | number } = {
266
+ name: error.name,
267
+ };
268
+ const code = (error as { code?: unknown }).code;
269
+ if (typeof code === "string" || typeof code === "number") {
270
+ identity.code = code;
271
+ }
272
+ return identity;
273
+ }
274
+
275
+ /** Production-owned typed thrown failure (explicit-internal channel). */
276
+ function isTypedActivationError(
277
+ error: unknown,
278
+ ): error is Error & {
279
+ knownCause: ControlledFailureCause;
280
+ failureCode?: string | number;
281
+ details?: Readonly<Record<string, unknown>>;
282
+ } {
283
+ if (!(error instanceof Error)) return false;
284
+ const cause = (error as { knownCause?: unknown }).knownCause;
285
+ return (
286
+ cause === "provider" ||
287
+ cause === "activation" ||
288
+ cause === "session" ||
289
+ cause === "output" ||
290
+ cause === "timeout" ||
291
+ cause === "unrecognized"
292
+ );
293
+ }
294
+
295
+ /**
296
+ * Classify a controlled post-admission failure without washing unrecognized identities.
297
+ * Cause classes are closed; diagnostic text retains the original identity when known.
298
+ *
299
+ * Order: thrown → knownCause → timeout → activation (nonzero) → session → output.
300
+ * knownCause precedes timeout so a co-present typed provider/session identity is not
301
+ * washed when the child also timed out. Cause is never inferred from stderr wording.
302
+ */
303
+ export function classifyPostAdmissionFailure(input: {
304
+ timedOut: boolean;
305
+ code: number | null;
306
+ stderr: string;
307
+ /**
308
+ * Caught post-admission exception. Presence (own key) is distinct from value:
309
+ * JavaScript permits `throw undefined`, which must stay unrecognized rather than
310
+ * being washed into activation/null-exit paths that treat missing thrown as absence.
311
+ */
312
+ thrown?: unknown;
313
+ session?: SessionReadiness;
314
+ /** Upstream-typed cause when the failure origin is already known. */
315
+ knownCause?: ControlledFailureCause;
316
+ /** Optional identity paired with knownCause (production channel). */
317
+ knownIdentity?: {
318
+ readonly name?: string;
319
+ readonly code?: string | number;
320
+ };
321
+ /**
322
+ * Optional diagnostic already owned by a typed production field (session
323
+ * errorMessage, runner knownFailure.diagnostic). Preferred over stderr selection.
324
+ */
325
+ knownDiagnostic?: string;
326
+ /** Secondary evidence already carried by the typed production failure. */
327
+ knownDetails?: Readonly<Record<string, unknown>>;
328
+ }): ControlledFailure {
329
+ // Own-key presence, not value: `throw undefined` is a real caught exception.
330
+ if (Object.hasOwn(input, "thrown")) {
331
+ const error = input.thrown;
332
+ if (isTypedActivationError(error)) {
333
+ const identity = thrownIdentity(error);
334
+ if (error.failureCode !== undefined && identity.code === undefined) {
335
+ identity.code = error.failureCode;
336
+ }
337
+ return {
338
+ cause: error.knownCause,
339
+ diagnostic: error.message || error.name || "unrecognized exception",
340
+ identity,
341
+ ...(error.details === undefined ? {} : { details: error.details }),
342
+ };
343
+ }
344
+ if (error instanceof Error) {
345
+ const identity = thrownIdentity(error);
346
+ return {
347
+ cause: "unrecognized",
348
+ diagnostic: error.message || error.name || "unrecognized exception",
349
+ identity,
350
+ };
351
+ }
352
+ return {
353
+ cause: "unrecognized",
354
+ diagnostic: String(error),
355
+ };
356
+ }
357
+ if (input.knownCause !== undefined) {
358
+ const fallback =
359
+ input.knownCause === "provider"
360
+ ? "provider failure"
361
+ : input.knownCause === "session"
362
+ ? "session unreadable"
363
+ : input.knownCause === "output"
364
+ ? "role run completed without a lawful typed terminal result"
365
+ : `role run failed (${input.knownCause})`;
366
+ const diagnostic =
367
+ input.knownDiagnostic !== undefined && input.knownDiagnostic.trim() !== ""
368
+ ? input.knownDiagnostic
369
+ : conciseChildDiagnostic(input.stderr, fallback);
370
+ const { code: _knownCode, timedOut: _knownTimedOut, ...knownDetails } =
371
+ input.knownDetails ?? {};
372
+ return {
373
+ cause: input.knownCause,
374
+ diagnostic,
375
+ details: {
376
+ ...knownDetails,
377
+ code: input.code,
378
+ ...(input.timedOut ? { timedOut: true as const } : {}),
379
+ },
380
+ ...(input.knownIdentity === undefined
381
+ ? {}
382
+ : { identity: input.knownIdentity }),
383
+ };
384
+ }
385
+ if (input.timedOut) {
386
+ return {
387
+ cause: "timeout",
388
+ diagnostic: "role run timed out",
389
+ details: { timedOut: true, code: input.code },
390
+ };
391
+ }
392
+ if (input.code !== 0) {
393
+ const fallback = `role run failed with exit ${input.code ?? "null"}`;
394
+ return {
395
+ cause: "activation",
396
+ diagnostic: conciseChildDiagnostic(input.stderr, fallback),
397
+ details: { code: input.code },
398
+ };
399
+ }
400
+ if (input.session?.state === "missing") {
401
+ return {
402
+ cause: "session",
403
+ diagnostic: "role run left no readable session transcript",
404
+ details: { code: input.code, session: "missing" },
405
+ };
406
+ }
407
+ if (input.session?.state === "unreadable") {
408
+ return {
409
+ cause: "session",
410
+ diagnostic: input.session.diagnostic,
411
+ details: { code: input.code, session: "unreadable" },
412
+ };
413
+ }
414
+ return {
415
+ cause: "output",
416
+ diagnostic: "role run completed without a lawful typed terminal result",
417
+ details: { code: input.code },
418
+ };
419
+ }
420
+
421
+ /** One projection owner for the four audited public runners. */
422
+ export function explicitInternalKnownFailureClassificationInput(
423
+ failure: ExplicitInternalKnownFailure | undefined,
424
+ ) {
425
+ if (failure === undefined) return {};
426
+ return {
427
+ knownCause: failure.cause,
428
+ ...(failure.identity === undefined ? {} : { knownIdentity: failure.identity }),
429
+ ...(failure.diagnostic === undefined ? {} : { knownDiagnostic: failure.diagnostic }),
430
+ ...(failure.details === undefined ? {} : { knownDetails: failure.details }),
431
+ };
432
+ }
433
+
434
+ /** Post-role Navigator delivery grace (Issue #11 / #101 / #106 / #159). */
435
+ export const NAVIGATOR_POST_ROLE_GRACE_MS = 10_000;
436
+
437
+ type SessionMessage = {
438
+ role?: string;
439
+ toolName?: string;
440
+ toolCallId?: string;
441
+ isError?: boolean;
442
+ details?: unknown;
443
+ content?: unknown;
444
+ customType?: string;
445
+ /** Native provider-stop fields (pi-ai AssistantMessage). */
446
+ stopReason?: string;
447
+ errorMessage?: string | null;
448
+ provider?: string;
449
+ model?: string;
450
+ api?: string;
451
+ };
452
+
453
+ type SessionEntry = {
454
+ type?: string;
455
+ customType?: string;
456
+ message?: SessionMessage;
457
+ /** Custom entry payload (e.g. ak-navigator-invocation principal). */
458
+ data?: unknown;
459
+ timestamp?: string;
460
+ /** Session principal id from the durable header entry. */
461
+ id?: string;
462
+ /** Session cwd from the durable header entry. */
463
+ cwd?: string;
464
+ /** Parent session principal on durable child session headers. */
465
+ parentSession?: string;
466
+ };
467
+
468
+ /** Optional independent identity from admitted/shared lifecycle (not attendance self-fields). */
469
+ export type NavigatorAttendanceIdentity = {
470
+ readonly phase?: NavigatorPhase;
471
+ readonly subjectKey?: string;
472
+ };
473
+
474
+ function isMissingPathError(error: unknown): boolean {
475
+ return (
476
+ error instanceof Error &&
477
+ "code" in error &&
478
+ (error as { code?: unknown }).code === "ENOENT"
479
+ );
480
+ }
481
+
482
+ /**
483
+ * Preserve session-read failure identity as a typed session cause.
484
+ * SyntaxError keeps its name so durable settlement does not wash malformed JSONL
485
+ * into generic output absence.
486
+ */
487
+ function sessionReadFailure(
488
+ error: unknown,
489
+ fallbackMessage: string,
490
+ ): Error & {
491
+ knownCause: ControlledFailureCause;
492
+ failureCode?: string | number;
493
+ } {
494
+ if (error instanceof SyntaxError) {
495
+ const failed = new SyntaxError(
496
+ error.message || fallbackMessage,
497
+ ) as SyntaxError & {
498
+ knownCause: ControlledFailureCause;
499
+ failureCode?: string | number;
500
+ };
501
+ failed.knownCause = "session";
502
+ return failed;
503
+ }
504
+ if (error instanceof Error) {
505
+ const failed = new Error(
506
+ error.message || error.name || fallbackMessage,
507
+ ) as Error & {
508
+ knownCause: ControlledFailureCause;
509
+ failureCode?: string | number;
510
+ code?: string | number;
511
+ };
512
+ failed.name = error.name || "Error";
513
+ failed.knownCause = "session";
514
+ const code = (error as { code?: unknown }).code;
515
+ if (typeof code === "string" || typeof code === "number") {
516
+ failed.failureCode = code;
517
+ failed.code = code;
518
+ }
519
+ return failed;
520
+ }
521
+ const failed = new Error(String(error)) as Error & {
522
+ knownCause: ControlledFailureCause;
523
+ failureCode?: string | number;
524
+ };
525
+ failed.knownCause = "session";
526
+ return failed;
527
+ }
528
+
529
+ /**
530
+ * Read the exact bound Pi session file principal.
531
+ * Does not scan the session directory for "latest" — resume identity is the file.
532
+ */
533
+ async function readBoundSessionEntries(
534
+ sessionFile: string,
535
+ ): Promise<SessionEntry[]> {
536
+ const text = await readFile(sessionFile, "utf8");
537
+ const entries: SessionEntry[] = [];
538
+ for (const line of text.trim().split("\n").filter(Boolean)) {
539
+ try {
540
+ entries.push(JSON.parse(line) as SessionEntry);
541
+ } catch (error) {
542
+ throw sessionReadFailure(error, "malformed session JSONL");
543
+ }
544
+ }
545
+ return entries;
546
+ }
547
+
548
+ /**
549
+ * Latest native assistant provider-stop in a session (stopReason === "error").
550
+ * Only the final assistant turn decides terminality — an older error followed by a
551
+ * later non-error stop is not a provider failure (would wash a no-lawful-output path).
552
+ * Typed production source for provider cause — not child stderr prose.
553
+ */
554
+ export function extractSessionProviderStop(
555
+ entries: readonly SessionEntry[],
556
+ ): {
557
+ stopReason: "error";
558
+ errorMessage?: string;
559
+ provider?: string;
560
+ model?: string;
561
+ } | undefined {
562
+ // A resumed dispatch appends a typed top-level user turn to the same session.
563
+ // Retained audit state from an older attempt must not replace the newer attempt's
564
+ // native provider stop. Sessions without a user turn are the initial attempt.
565
+ let attemptStart = 0;
566
+ for (let i = entries.length - 1; i >= 0; i -= 1) {
567
+ const entry = entries[i];
568
+ if (entry?.type === "message" && entry.message?.role === "user") {
569
+ attemptStart = i;
570
+ break;
571
+ }
572
+ }
573
+
574
+ // The shared auditor is a nested model turn. Within the current attempt its
575
+ // retained typed response is authoritative when failInfrastructure subsequently
576
+ // aborts the parent turn.
577
+ for (let i = entries.length - 1; i >= attemptStart; i -= 1) {
578
+ const entry = entries[i];
579
+ if (entry?.type !== "custom" || entry.customType !== COMPLIANCE_RESPONSE_ENTRY_TYPE) continue;
580
+ const response = isRecord(entry.data) && isRecord(entry.data.response) ? entry.data.response : undefined;
581
+ if (response?.role === "assistant" && response.stopReason === "error") {
582
+ return {
583
+ stopReason: "error",
584
+ ...(typeof response.errorMessage === "string" && response.errorMessage.trim() !== "" ? { errorMessage: response.errorMessage } : {}),
585
+ ...(typeof response.provider === "string" && response.provider.trim() !== "" ? { provider: response.provider } : {}),
586
+ ...(typeof response.model === "string" && response.model.trim() !== "" ? { model: response.model } : {}),
587
+ };
588
+ }
589
+ break;
590
+ }
591
+ for (let i = entries.length - 1; i >= attemptStart; i -= 1) {
592
+ const entry = entries[i];
593
+ if (entry?.type !== "message") continue;
594
+ const message = entry.message;
595
+ if (message?.role !== "assistant") continue;
596
+ // Latest assistant in the current attempt only (reviewer-child-executor lastAssistant pattern).
597
+ if (message.stopReason !== "error") return undefined;
598
+ return {
599
+ stopReason: "error",
600
+ ...(typeof message.errorMessage === "string" && message.errorMessage.trim() !== ""
601
+ ? { errorMessage: message.errorMessage }
602
+ : {}),
603
+ ...(typeof message.provider === "string" && message.provider.trim() !== ""
604
+ ? { provider: message.provider }
605
+ : {}),
606
+ ...(typeof message.model === "string" && message.model.trim() !== ""
607
+ ? { model: message.model }
608
+ : {}),
609
+ };
610
+ }
611
+ return undefined;
612
+ }
613
+
614
+ /** Read the bound session principal and extract a typed provider-stop, if any. */
615
+ export async function readSessionProviderStop(
616
+ sessionFile: string,
617
+ ): Promise<
618
+ | {
619
+ stopReason: "error";
620
+ errorMessage?: string;
621
+ provider?: string;
622
+ model?: string;
623
+ }
624
+ | undefined
625
+ > {
626
+ try {
627
+ const entries = await readBoundSessionEntries(sessionFile);
628
+ return extractSessionProviderStop(entries);
629
+ } catch {
630
+ return undefined;
631
+ }
632
+ }
633
+
634
+ /**
635
+ * Recover a provider stop from Reviewer fixed-axis evidence children bound to this parent.
636
+ * Dispatch runs during activation before the parent model turn; leg failures leave durable
637
+ * stops under session/evidence-children/ and must not wash into generic activation.
638
+ */
639
+ export async function readBoundEvidenceChildKnownFailure(
640
+ sessionFile: string,
641
+ ): Promise<ExplicitInternalKnownFailure | undefined> {
642
+ const childDirectory = join(dirname(sessionFile), "evidence-children");
643
+ let names: string[];
644
+ try {
645
+ names = await readdir(childDirectory);
646
+ } catch (error) {
647
+ if (isMissingPathError(error)) return undefined;
648
+ throw sessionReadFailure(error, "failed to read bound evidence-child session directory");
649
+ }
650
+ for (const file of names.filter((name) => name.endsWith(".jsonl")).sort().reverse()) {
651
+ let entries: SessionEntry[];
652
+ try {
653
+ entries = await readBoundSessionEntries(join(childDirectory, file));
654
+ } catch (error) {
655
+ throw sessionReadFailure(error, "failed to read discovered evidence-child session");
656
+ }
657
+ const header = entries.find((entry) => entry.type === "session");
658
+ if (!isRecord(header) || header.parentSession !== sessionFile) continue;
659
+ const stop = extractSessionProviderStop(entries);
660
+ if (stop === undefined) continue;
661
+ const primary = knownFailureFromProviderStop(stop)!;
662
+ return {
663
+ ...primary,
664
+ details: {
665
+ ...(stop.provider === undefined ? {} : { provider: stop.provider }),
666
+ ...(stop.model === undefined ? {} : { model: stop.model }),
667
+ secondaryEvidence: "evidence-child",
668
+ },
669
+ };
670
+ }
671
+ return undefined;
672
+ }
673
+
674
+ /** Recover a provider stop from the auditor child bound to the current parent attempt. */
675
+ export async function readBoundAuditorKnownFailure(
676
+ sessionFile: string,
677
+ ): Promise<ExplicitInternalKnownFailure | undefined> {
678
+ let parentEntries: SessionEntry[];
679
+ try {
680
+ parentEntries = await readBoundSessionEntries(sessionFile);
681
+ } catch (error) {
682
+ if (isMissingPathError(error)) return undefined;
683
+ throw sessionReadFailure(error, "failed to read parent session for auditor binding");
684
+ }
685
+ const parentId = parentEntries.find((entry) => entry.type === "session")?.id;
686
+ if (parentId === undefined) return undefined;
687
+ let latestParentUserIndex = -1;
688
+ for (let i = parentEntries.length - 1; i >= 0; i -= 1) {
689
+ if (parentEntries[i]?.type === "message" && parentEntries[i]?.message?.role === "user") {
690
+ latestParentUserIndex = i;
691
+ break;
692
+ }
693
+ }
694
+ const childDirectory = join(dirname(sessionFile), "auditor-roles");
695
+ let names: string[];
696
+ try {
697
+ names = await readdir(childDirectory);
698
+ } catch (error) {
699
+ if (isMissingPathError(error)) return undefined;
700
+ throw sessionReadFailure(error, "failed to read bound auditor session directory");
701
+ }
702
+ for (const file of names.filter((name) => name.endsWith(".jsonl")).sort().reverse()) {
703
+ let entries: SessionEntry[];
704
+ try {
705
+ entries = await readBoundSessionEntries(join(childDirectory, file));
706
+ } catch (error) {
707
+ throw sessionReadFailure(error, "failed to read discovered auditor session");
708
+ }
709
+ const header = entries.find((entry) => entry.type === "session");
710
+ if (!isRecord(header) || header.parentSession !== sessionFile) continue;
711
+ const bindingEntry = entries.find((entry) => entry.type === "custom" && entry.customType === AUDITOR_PARENT_ATTEMPT_BINDING_ENTRY_TYPE);
712
+ const bindingParent = isRecord(bindingEntry?.data) && isRecord(bindingEntry.data.parent) ? bindingEntry.data.parent : undefined;
713
+ const attemptEntryId = typeof bindingParent?.attemptEntryId === "string" ? bindingParent.attemptEntryId : undefined;
714
+ const attemptEntryIndex = attemptEntryId === undefined ? -1 : parentEntries.findIndex((entry) => entry.id === attemptEntryId);
715
+ if (bindingParent?.sessionId !== parentId || bindingParent.sessionFile !== sessionFile || attemptEntryIndex < latestParentUserIndex) continue;
716
+
717
+ const stop = extractSessionProviderStop(entries);
718
+ if (stop === undefined) continue;
719
+ for (let i = entries.length - 1; i >= 0; i -= 1) {
720
+ const entry = entries[i];
721
+ if (entry?.type !== "custom" || entry.customType !== AUDITOR_COMPLIANCE_FAILURE_ENTRY_TYPE || !isRecord(entry.data)) continue;
722
+ const parent = isRecord(entry.data.parent) ? entry.data.parent : undefined;
723
+ const failure = isRecord(entry.data.failure) ? entry.data.failure : undefined;
724
+ if (parent?.sessionId !== parentId || parent.sessionFile !== sessionFile || parent.attemptEntryId !== attemptEntryId || failure?.cause !== "provider") continue;
725
+ const identity = isRecord(failure.identity) ? failure.identity : undefined;
726
+ return {
727
+ cause: "provider",
728
+ ...(identity === undefined ? {} : { identity: {
729
+ ...(typeof identity.name === "string" ? { name: identity.name } : {}),
730
+ ...(typeof identity.code === "string" || typeof identity.code === "number" ? { code: identity.code } : {}),
731
+ } }),
732
+ ...(typeof failure.diagnostic === "string" ? { diagnostic: failure.diagnostic } : {}),
733
+ ...(isRecord(failure.details) ? { details: failure.details } : {}),
734
+ };
735
+ }
736
+ const primary = knownFailureFromProviderStop(stop)!;
737
+ return {
738
+ ...primary,
739
+ details: {
740
+ ...(stop.provider === undefined ? {} : { provider: stop.provider }),
741
+ ...(stop.model === undefined ? {} : { model: stop.model }),
742
+ secondaryEvidence: "unavailable",
743
+ },
744
+ };
745
+ }
746
+ return undefined;
747
+ }
748
+
749
+ function typedFailedTerminatingToolKnownFailure(
750
+ entries: readonly SessionEntry[],
751
+ ): ExplicitInternalKnownFailure | undefined {
752
+ let attemptStart = 0;
753
+ for (let i = entries.length - 1; i >= 0; i -= 1) {
754
+ if (entries[i]?.type === "message" && entries[i]?.message?.role === "user") {
755
+ attemptStart = i;
756
+ break;
757
+ }
758
+ }
759
+ const attemptEntries = entries.slice(attemptStart);
760
+ for (let i = attemptEntries.length - 1; i >= 0; i -= 1) {
761
+ const message = attemptEntries[i]?.message;
762
+ if (attemptEntries[i]?.type !== "message" || message?.role !== "toolResult") continue;
763
+ const classification = classifyPackagedRoleTerminalResult(message);
764
+ if (classification.kind !== "infrastructure") continue;
765
+ if (typeof message.toolCallId !== "string" || typeof message.toolName !== "string") continue;
766
+ if (boundRoleToolCallForResult(attemptEntries, i, message, message.toolName) === undefined) continue;
767
+ const textPart = Array.isArray(message.content)
768
+ ? message.content.find((part) => isRecord(part) && part.type === "text" && typeof part.text === "string")
769
+ : undefined;
770
+ const diagnostic = isRecord(textPart) ? textPart.text : undefined;
771
+ return {
772
+ cause: "output",
773
+ identity: { name: message.toolName, code: message.toolCallId },
774
+ ...(typeof diagnostic === "string" && diagnostic.trim() !== "" ? { diagnostic } : {}),
775
+ details: classification.fact,
776
+ };
777
+ }
778
+ return undefined;
779
+ }
780
+
781
+ /** Sole evidence-priority owner for public runners with Soul auditors. */
782
+ export async function resolveAuditedRunnerKnownFailure(input: {
783
+ runner: ExplicitInternalKnownFailure | undefined;
784
+ sessionFile: string;
785
+ credential: ExplicitInternalKnownFailure | undefined;
786
+ }): Promise<ExplicitInternalKnownFailure | undefined> {
787
+ if (input.runner !== undefined) return input.runner;
788
+ // Bound auditor evidence outranks a parent failure that the auditor path itself
789
+ // caused (retention EISDIR race). A typed terminating-tool host failure is next:
790
+ // it outranks provider/credential and nonzero fallbacks, but not its recorded cause.
791
+ try {
792
+ const auditorFailure = await readBoundAuditorKnownFailure(input.sessionFile);
793
+ if (auditorFailure !== undefined) return auditorFailure;
794
+ } catch (error) {
795
+ const failure = sessionReadFailure(error, "failed to recover bound auditor failure");
796
+ return {
797
+ cause: "session",
798
+ identity: thrownIdentity(failure),
799
+ diagnostic: failure.message || failure.name,
800
+ };
801
+ }
802
+ try {
803
+ const terminatingFailure = typedFailedTerminatingToolKnownFailure(
804
+ await readBoundSessionEntries(input.sessionFile),
805
+ );
806
+ if (terminatingFailure !== undefined) return terminatingFailure;
807
+ } catch (error) {
808
+ if (!isMissingPathError(error)) {
809
+ const failure = sessionReadFailure(error, "failed to recover typed terminating-tool failure");
810
+ return { cause: "session", identity: thrownIdentity(failure), diagnostic: failure.message || failure.name };
811
+ }
812
+ }
813
+ // Reviewer axis evidence-children are next: fixed two-axis dispatch fails
814
+ // during activation with only child stops durable. Parent stop remains the
815
+ // fallback; credential is last.
816
+ try {
817
+ const evidenceChildFailure = await readBoundEvidenceChildKnownFailure(input.sessionFile);
818
+ if (evidenceChildFailure !== undefined) return evidenceChildFailure;
819
+ } catch (error) {
820
+ const failure = sessionReadFailure(error, "failed to recover bound evidence-child failure");
821
+ return {
822
+ cause: "session",
823
+ identity: thrownIdentity(failure),
824
+ diagnostic: failure.message || failure.name,
825
+ };
826
+ }
827
+ const parentStop = await readSessionProviderStop(input.sessionFile);
828
+ return parentStop === undefined
829
+ ? input.credential
830
+ : knownFailureFromProviderStop(parentStop);
831
+ }
832
+
833
+ function isRecord(value: unknown): value is Record<string, unknown> {
834
+ return typeof value === "object" && value !== null && !Array.isArray(value);
835
+ }
836
+
837
+ function safelyRead(object: object, key: string): { readable: true; value: unknown } | { readable: false } {
838
+ try {
839
+ return { readable: true, value: (object as Record<string, unknown>)[key] };
840
+ } catch {
841
+ return { readable: false };
842
+ }
843
+ }
844
+
845
+ function judgeDecisiveFacts(
846
+ verdict: object,
847
+ judgeStatus: JudgeVerdict["judgeStatus"],
848
+ ): Record<string, unknown> {
849
+ const facts: Record<string, unknown> = { judgeStatus };
850
+ if (judgeStatus === "continue") {
851
+ const fix = safelyRead(verdict, "fix");
852
+ if (fix.readable && isRecord(fix.value)) {
853
+ const summary = safelyRead(fix.value, "summary");
854
+ if (summary.readable && typeof summary.value === "string") {
855
+ facts.fixSummary = summary.value;
856
+ }
857
+ }
858
+ const classes = safelyRead(verdict, "classes");
859
+ if (classes.readable && Array.isArray(classes.value)) {
860
+ try {
861
+ facts.classes = classes.value.map((entry) => {
862
+ if (!isRecord(entry)) throw new Error("unreadable Judge class");
863
+ return {
864
+ name: entry.name,
865
+ owner: entry.owner,
866
+ boundary: entry.boundary,
867
+ disposition: entry.disposition,
868
+ };
869
+ });
870
+ facts.classCount = classes.value.length;
871
+ } catch {
872
+ // Optional class material is omitted as a unit when any row is unreadable.
873
+ }
874
+ }
875
+ }
876
+ if (judgeStatus === "escalate") {
877
+ const gate = safelyRead(verdict, "decisionGate");
878
+ if (gate.readable && isRecord(gate.value)) {
879
+ const question = safelyRead(gate.value, "question");
880
+ const options = safelyRead(gate.value, "options");
881
+ if (question.readable && typeof question.value === "string") {
882
+ facts.decisionQuestion = question.value;
883
+ }
884
+ if (options.readable && Array.isArray(options.value)) {
885
+ facts.decisionOptions = [...options.value];
886
+ }
887
+ }
888
+ }
889
+ const note = safelyRead(verdict, "note");
890
+ if (note.readable && note.value !== undefined) facts.note = note.value;
891
+ const evidence = safelyRead(verdict, "evidence");
892
+ if (evidence.readable && evidence.value !== undefined) facts.evidence = evidence.value;
893
+ return facts;
894
+ }
895
+
896
+ function coderDecisiveFacts(output: CoderOutput): Record<string, unknown> {
897
+ const candidate = output as unknown as object;
898
+ const status = safelyRead(candidate, "status");
899
+ const facts: Record<string, unknown> = {};
900
+ if (status.readable && typeof status.value === "string") facts.coderStatus = status.value;
901
+ const remainingScope = safelyRead(candidate, "remainingScope");
902
+ if (status.readable && status.value === "unfinished" && remainingScope.readable && typeof remainingScope.value === "string") facts.remainingScope = remainingScope.value;
903
+ const report = safelyRead(candidate, "report");
904
+ if (report.readable && typeof report.value === "string") facts.reportPresent = report.value.trim().length > 0;
905
+ return facts;
906
+ }
907
+
908
+ function fixerDecisiveFacts(output: FixerOutput): Record<string, unknown> {
909
+ const candidate = output as unknown as object;
910
+ const status = safelyRead(candidate, "status");
911
+ const facts: Record<string, unknown> = {};
912
+ if (status.readable && typeof status.value === "string") facts.fixerStatus = status.value;
913
+ const remainingScope = safelyRead(candidate, "remainingScope");
914
+ if (status.readable && (status.value === "unfinished" || status.value === "refused") && remainingScope.readable && typeof remainingScope.value === "string") facts.remainingScope = remainingScope.value;
915
+ const blockerRead = safelyRead(candidate, "blocker");
916
+ if (status.readable && status.value === "refused" && blockerRead.readable && isRecord(blockerRead.value)) {
917
+ const cause = safelyRead(blockerRead.value, "cause");
918
+ if (cause.readable && typeof cause.value === "string") facts.blockerCause = cause.value;
919
+ const prerequisiteId = safelyRead(blockerRead.value, "prerequisiteId");
920
+ if (cause.readable && cause.value === "prerequisite_unmet" && prerequisiteId.readable && typeof prerequisiteId.value === "string") facts.prerequisiteId = prerequisiteId.value;
921
+ }
922
+ const classResults = safelyRead(candidate, "classResults");
923
+ if (classResults.readable && Array.isArray(classResults.value)) {
924
+ const rows: Array<{ name: unknown; disposition: unknown }> = [];
925
+ const blockers: Record<string, unknown>[] = [];
926
+ try {
927
+ for (const entry of classResults.value) {
928
+ if (!isRecord(entry)) throw new Error("unreadable class result");
929
+ const name = safelyRead(entry, "name");
930
+ const disposition = safelyRead(entry, "disposition");
931
+ if (!name.readable || !disposition.readable) throw new Error("unreadable class result");
932
+ rows.push({ name: name.value, disposition: disposition.value });
933
+ const blocker = safelyRead(entry, "blocker");
934
+ if (disposition.value === "refused" && blocker.readable && isRecord(blocker.value)) blockers.push(blocker.value);
935
+ }
936
+ facts.classResultCount = rows.length;
937
+ facts.classDispositions = rows;
938
+ const causes = blockers.flatMap((blocker) => {
939
+ const cause = safelyRead(blocker, "cause");
940
+ return cause.readable && typeof cause.value === "string" ? [cause.value] : [];
941
+ });
942
+ if (causes.length > 0) facts.blockerCauses = causes;
943
+ const prerequisiteIds = blockers.flatMap((blocker) => {
944
+ const cause = safelyRead(blocker, "cause");
945
+ const id = safelyRead(blocker, "prerequisiteId");
946
+ return cause.readable && cause.value === "prerequisite_unmet" && id.readable && typeof id.value === "string" ? [id.value] : [];
947
+ });
948
+ if (prerequisiteIds.length > 0) facts.prerequisiteIds = prerequisiteIds;
949
+ } catch {
950
+ // Optional class projection is omitted as a unit when any row is unreadable.
951
+ }
952
+ }
953
+ const report = safelyRead(candidate, "report");
954
+ if (report.readable && typeof report.value === "string") facts.reportPresent = report.value.trim().length > 0;
955
+ return facts;
956
+ }
957
+
958
+ function collectorDecisiveFacts(
959
+ receipt: CollectorReceipt,
960
+ ): Record<string, unknown> {
961
+ const candidate = receipt as unknown as object;
962
+ const facts: Record<string, unknown> = {};
963
+ for (const key of ["repository", "prNumber", "targetHead", "manifestDigest"] as const) {
964
+ const value = safelyRead(candidate, key);
965
+ if (value.readable && value.value !== undefined) facts[key] = value.value;
966
+ }
967
+ const groups = safelyRead(candidate, "groups");
968
+ if (groups.readable && Array.isArray(groups.value)) {
969
+ try {
970
+ facts.groups = groups.value.map((group) => {
971
+ if (!isRecord(group)) throw new Error("unreadable Collector group");
972
+ const identity = safelyRead(group, "identity");
973
+ const attendance = safelyRead(group, "attendance");
974
+ const materials = safelyRead(group, "materials");
975
+ const findings = safelyRead(group, "findings");
976
+ if (!identity.readable || !attendance.readable ||
977
+ !materials.readable || !Array.isArray(materials.value) ||
978
+ !findings.readable || !Array.isArray(findings.value)) {
979
+ throw new Error("unreadable Collector group");
980
+ }
981
+ return {
982
+ identity: identity.value,
983
+ attendance: attendance.value,
984
+ materialCount: materials.value.length,
985
+ findingCount: findings.value.length,
986
+ };
987
+ });
988
+ } catch { /* omit unreadable optional projection */ }
989
+ }
990
+ return facts;
991
+ }
992
+
993
+ function doctorDecisiveFacts(output: DoctorOutput): Record<string, unknown> {
994
+ const candidate = output as unknown as object;
995
+ const status = safelyRead(candidate, "status");
996
+ const facts: Record<string, unknown> = {};
997
+ if (status.readable && typeof status.value === "string") facts.doctorStatus = status.value;
998
+ if (status.readable && status.value === "refused") {
999
+ const reason = safelyRead(candidate, "reason");
1000
+ if (reason.readable && reason.value !== undefined) facts.reason = reason.value;
1001
+ const missing = safelyRead(candidate, "missingEvidence");
1002
+ if (missing.readable && Array.isArray(missing.value)) facts.missingEvidenceCount = missing.value.length;
1003
+ return facts;
1004
+ }
1005
+ const caseValue = safelyRead(candidate, "case");
1006
+ if (caseValue.readable && isRecord(caseValue.value)) {
1007
+ const issueNumber = safelyRead(caseValue.value, "issueNumber");
1008
+ const runsPath = safelyRead(caseValue.value, "runsPath");
1009
+ if (issueNumber.readable && issueNumber.value !== undefined) facts.issueNumber = issueNumber.value;
1010
+ if (runsPath.readable && runsPath.value !== undefined) facts.runsPath = runsPath.value;
1011
+ }
1012
+ const findings = safelyRead(candidate, "findings");
1013
+ if (findings.readable && Array.isArray(findings.value)) facts.findingsCount = findings.value.length;
1014
+ return facts;
1015
+ }
1016
+
1017
+ function reviewerAxes(value: unknown): readonly ("standards" | "spec")[] {
1018
+ if (!isRecord(value)) return [];
1019
+ return (["standards", "spec"] as const).filter((axis) => {
1020
+ const projected = safelyRead(value, axis);
1021
+ return projected.readable && projected.value !== undefined;
1022
+ });
1023
+ }
1024
+
1025
+ function reviewerDecisiveFacts(
1026
+ output: RuntimeReviewerReceiptV2,
1027
+ ): Record<string, unknown> {
1028
+ const candidate = output as unknown as object;
1029
+ const status = safelyRead(candidate, "status");
1030
+ const outcomes = safelyRead(candidate, "outcomes");
1031
+ const reports = safelyRead(candidate, "reports");
1032
+ const axes = reviewerAxes(outcomes.readable ? outcomes.value : undefined);
1033
+ const reportAxes = reviewerAxes(reports.readable ? reports.value : undefined);
1034
+ const acceptedBatch = safelyRead(candidate, "acceptedBatch");
1035
+ const facts: Record<string, unknown> = {
1036
+ axes,
1037
+ reportAxes,
1038
+ acceptedBatchPresent: acceptedBatch.readable && acceptedBatch.value !== undefined,
1039
+ };
1040
+ if (status.readable && typeof status.value === "string") facts.reviewerStatus = status.value;
1041
+ const diagnostic = safelyRead(candidate, "diagnostic");
1042
+ if (status.readable && status.value === "refused" && diagnostic.readable) {
1043
+ facts.diagnosticPresent = typeof diagnostic.value === "string" && diagnostic.value.trim().length > 0;
1044
+ }
1045
+ return facts;
1046
+ }
1047
+
1048
+ /**
1049
+ * ADR 0037: a shape-valid Collector receipt may still name the wrong live target.
1050
+ * Public success binds receipt identity to this admitted repository/PR/request manifest
1051
+ * at the existing settlement seam — not a second receipt factory or validator.
1052
+ */
1053
+ function collectorReceiptBindingFailure(
1054
+ diagnostic: string,
1055
+ ): Error & { knownCause: ControlledFailureCause } {
1056
+ const error = new Error(diagnostic) as Error & {
1057
+ knownCause: ControlledFailureCause;
1058
+ };
1059
+ error.name = "CollectorReceiptBindingError";
1060
+ error.knownCause = "output";
1061
+ return error;
1062
+ }
1063
+
1064
+ function toolResultText(message: SessionMessage): string {
1065
+ const content = message.content;
1066
+ if (typeof content === "string") return content.trim();
1067
+ if (!Array.isArray(content)) return "";
1068
+ return content
1069
+ .map((part) => {
1070
+ if (
1071
+ typeof part === "object" &&
1072
+ part !== null &&
1073
+ !Array.isArray(part) &&
1074
+ typeof (part as { text?: unknown }).text === "string"
1075
+ ) {
1076
+ return (part as { text: string }).text;
1077
+ }
1078
+ return "";
1079
+ })
1080
+ .join("")
1081
+ .trim();
1082
+ }
1083
+
1084
+ type BoundErroredToolCandidate = {
1085
+ candidate: unknown;
1086
+ diagnostic: string;
1087
+ callIndex: number;
1088
+ };
1089
+
1090
+ function boundErroredToolCandidate(
1091
+ entries: readonly SessionEntry[],
1092
+ resultIndex: number,
1093
+ message: SessionMessage,
1094
+ toolName: string,
1095
+ ): BoundErroredToolCandidate | undefined {
1096
+ if (message.toolName !== toolName || message.isError !== true) return undefined;
1097
+ const bound = boundRoleToolCallForResult(entries, resultIndex, message, toolName);
1098
+ const diagnostic = toolResultText(message);
1099
+ return bound === undefined || diagnostic === ""
1100
+ ? undefined
1101
+ : { candidate: bound.candidate, diagnostic, callIndex: bound.callIndex };
1102
+ }
1103
+
1104
+ /** Collector operational tools that fail closed via host infrastructure abort. */
1105
+ const COLLECTOR_INFRASTRUCTURE_TOOLS = new Set<string>([
1106
+ COLLECTOR_OBSERVE_TOOL,
1107
+ COLLECTOR_REQUEST_TOOL,
1108
+ COLLECTOR_WAIT_TOOL,
1109
+ ]);
1110
+
1111
+ /**
1112
+ * Prefer a real Collector infrastructure tool failure already on the session
1113
+ * principal over a later secondary provider-stop (failure-honesty).
1114
+ * Observe/request/wait host failures keep their diagnostic identity (e.g. HTTP 404).
1115
+ */
1116
+ export function extractCollectorInfrastructureFailure(
1117
+ entries: readonly SessionEntry[],
1118
+ ): ControlledFailure | undefined {
1119
+ for (let i = entries.length - 1; i >= 0; i -= 1) {
1120
+ const entry = entries[i];
1121
+ if (entry?.type !== "message") continue;
1122
+ const message = entry.message;
1123
+ if (message?.role !== "toolResult") continue;
1124
+ if (message.isError !== true) continue;
1125
+ if (
1126
+ typeof message.toolName !== "string" ||
1127
+ !COLLECTOR_INFRASTRUCTURE_TOOLS.has(message.toolName)
1128
+ ) {
1129
+ continue;
1130
+ }
1131
+ const diagnostic = toolResultText(message);
1132
+ if (diagnostic.length === 0) continue;
1133
+ return {
1134
+ cause: "activation",
1135
+ diagnostic,
1136
+ identity: { name: "CollectorInfrastructureError" },
1137
+ };
1138
+ }
1139
+ return undefined;
1140
+ }
1141
+
1142
+ /** Read the bound session principal for a Collector infrastructure tool failure. */
1143
+ export async function readCollectorInfrastructureFailure(
1144
+ sessionFile: string,
1145
+ ): Promise<ControlledFailure | undefined> {
1146
+ try {
1147
+ const entries = await readBoundSessionEntries(sessionFile);
1148
+ return extractCollectorInfrastructureFailure(entries);
1149
+ } catch {
1150
+ return undefined;
1151
+ }
1152
+ }
1153
+
1154
+ /**
1155
+ * Compare a validated receipt with the admitted Collector invocation identity.
1156
+ * Throws a typed output failure when any identity field mismatches.
1157
+ */
1158
+ export function assertCollectorReceiptMatchesAdmitted(
1159
+ receipt: CollectorReceipt,
1160
+ admitted: AdmittedCollectorInvocation,
1161
+ ): void {
1162
+ if (receipt.repository !== admitted.repository.canonical) {
1163
+ throw collectorReceiptBindingFailure(
1164
+ `Collector receipt repository "${receipt.repository}" does not match admitted repository "${admitted.repository.canonical}"`,
1165
+ );
1166
+ }
1167
+ if (receipt.prNumber !== admitted.prNumber) {
1168
+ throw collectorReceiptBindingFailure(
1169
+ `Collector receipt prNumber ${receipt.prNumber} does not match admitted prNumber ${admitted.prNumber}`,
1170
+ );
1171
+ }
1172
+ if (receipt.manifestDigest !== admitted.manifestDigest) {
1173
+ throw collectorReceiptBindingFailure(
1174
+ `Collector receipt manifestDigest does not match admitted manifestDigest`,
1175
+ );
1176
+ }
1177
+ }
1178
+
1179
+ /**
1180
+ * Shared audit-incomplete extraction for the four roles with Soul auditors.
1181
+ * The role submission and retained auditor response are separate evidence faces;
1182
+ * neither is converted into an accepted Receipt.
1183
+ */
1184
+ function isComplianceAuditIncomplete(value: unknown): value is ComplianceAuditIncomplete {
1185
+ if (!isRecord(value) || value.status !== "audit-incomplete") return false;
1186
+ const observation = value.observation;
1187
+ if (!isRecord(observation)) return false;
1188
+ if (observation.kind === "missing-dossier") return true;
1189
+ if (observation.kind === "missing-subject") {
1190
+ return typeof observation.subject === "string" && observation.subject.length > 0;
1191
+ }
1192
+ if (observation.kind === "object-status-unreadable") {
1193
+ return observation.status === "missing" || observation.status === "unknown";
1194
+ }
1195
+ return observation.kind === "non-object-arguments" && [
1196
+ "null",
1197
+ "array",
1198
+ "undefined",
1199
+ "string",
1200
+ "number",
1201
+ "boolean",
1202
+ "bigint",
1203
+ "symbol",
1204
+ "function",
1205
+ ].includes(observation.type as string);
1206
+ }
1207
+
1208
+ function auditToolNameForRole(
1209
+ role: (typeof AUDITOR_SOUL_ROLES)[number],
1210
+ ): string {
1211
+ switch (role) {
1212
+ case "judge":
1213
+ return JUDGE_AUDIT_TOOL_NAME;
1214
+ case "reviewer":
1215
+ return REVIEWER_AUDIT_TOOL_NAME;
1216
+ case "doctor":
1217
+ return DOCTOR_AUDIT_TOOL_NAME;
1218
+ }
1219
+ }
1220
+
1221
+ function outputToolNameForAuditedRole(
1222
+ role: (typeof AUDITOR_SOUL_ROLES)[number],
1223
+ ): string {
1224
+ switch (role) {
1225
+ case "judge":
1226
+ return JUDGE_OUTPUT_TOOL_NAME;
1227
+ case "reviewer":
1228
+ return REVIEWER_OUTPUT_TOOL_NAME;
1229
+ case "doctor":
1230
+ return DOCTOR_OUTPUT_TOOL_NAME;
1231
+ }
1232
+ }
1233
+
1234
+ type BoundRoleToolCall = {
1235
+ callIndex: number;
1236
+ candidate: unknown;
1237
+ };
1238
+
1239
+ function boundRoleToolCallForResult(
1240
+ entries: readonly SessionEntry[],
1241
+ resultIndex: number,
1242
+ message: SessionMessage,
1243
+ outputToolName: string,
1244
+ ): BoundRoleToolCall | undefined {
1245
+ const callId = message.toolCallId;
1246
+ if (typeof callId !== "string" || callId.trim() === "") return undefined;
1247
+
1248
+ const calls: BoundRoleToolCall[] = [];
1249
+ let resultCount = 0;
1250
+ let matchingResultIndex = -1;
1251
+ for (let index = 0; index < entries.length; index += 1) {
1252
+ const candidateMessage = entries[index]?.message;
1253
+ if (
1254
+ candidateMessage?.role === "assistant" &&
1255
+ Array.isArray(candidateMessage.content)
1256
+ ) {
1257
+ for (const part of candidateMessage.content) {
1258
+ if (!isRecord(part) || part.type !== "toolCall" || part.id !== callId) {
1259
+ continue;
1260
+ }
1261
+ if (part.name !== outputToolName) return undefined;
1262
+ calls.push({ callIndex: index, candidate: part.arguments });
1263
+ }
1264
+ }
1265
+ if (
1266
+ candidateMessage?.role === "toolResult" &&
1267
+ candidateMessage.toolCallId === callId
1268
+ ) {
1269
+ resultCount += 1;
1270
+ if (candidateMessage.toolName !== outputToolName) return undefined;
1271
+ matchingResultIndex = index;
1272
+ }
1273
+ }
1274
+
1275
+ // A binding is an event-bound one-to-one relation, not a reverse lookup of
1276
+ // whichever result happens to be last in the session.
1277
+ return calls.length === 1 && resultCount === 1 && matchingResultIndex === resultIndex
1278
+ && calls[0]!.callIndex < resultIndex
1279
+ ? calls[0]
1280
+ : undefined;
1281
+ }
1282
+
1283
+ type BoundRetainedAuditResponse = {
1284
+ candidate: unknown;
1285
+ };
1286
+
1287
+ type BoundAuditEscalation = {
1288
+ decision: Extract<ComplianceDecision, { status: "escalate" }>;
1289
+ details: Record<string, unknown>;
1290
+ };
1291
+
1292
+ function sameAuditValue(left: unknown, right: unknown): boolean {
1293
+ if (Object.is(left, right)) return true;
1294
+ if (Array.isArray(left) && Array.isArray(right)) {
1295
+ return left.length === right.length && left.every((value, index) =>
1296
+ sameAuditValue(value, right[index]),
1297
+ );
1298
+ }
1299
+ if (isRecord(left) && isRecord(right)) {
1300
+ const leftKeys = Object.keys(left);
1301
+ const rightKeys = Object.keys(right);
1302
+ return leftKeys.length === rightKeys.length &&
1303
+ leftKeys.every((key) => Object.hasOwn(right, key) && sameAuditValue(left[key], right[key]));
1304
+ }
1305
+ return false;
1306
+ }
1307
+
1308
+ /** Snapshot the exact enumerable string face that final Terminal projection uses. */
1309
+ function snapshotAuditDetails(details: Record<string, unknown>): Record<string, unknown> {
1310
+ const snapshot: Record<string, unknown> = Object.create(null);
1311
+ for (const key of Object.keys(details)) {
1312
+ Object.defineProperty(snapshot, key, {
1313
+ value: details[key],
1314
+ enumerable: true,
1315
+ configurable: true,
1316
+ writable: true,
1317
+ });
1318
+ }
1319
+ return snapshot;
1320
+ }
1321
+
1322
+ /**
1323
+ * Bind the public escalation face to the one retained response that sits inside
1324
+ * the same role output call/result interval. A `kind` field alone is never a
1325
+ * terminal identity; the retained response must be this seat's real escalate
1326
+ * decision and its projected audit-owned fields must agree with it.
1327
+ */
1328
+ function boundAuditEscalationForResult(
1329
+ entries: readonly SessionEntry[],
1330
+ resultIndex: number,
1331
+ message: SessionMessage,
1332
+ role: (typeof AUDITOR_SOUL_ROLES)[number],
1333
+ outputToolName: string,
1334
+ ): BoundAuditEscalation | undefined {
1335
+ const roleCall = boundRoleToolCallForResult(
1336
+ entries,
1337
+ resultIndex,
1338
+ message,
1339
+ outputToolName,
1340
+ );
1341
+ if (roleCall === undefined) return undefined;
1342
+ const retained = boundRetainedAuditResponse(
1343
+ entries,
1344
+ roleCall.callIndex,
1345
+ resultIndex,
1346
+ auditToolNameForRole(role),
1347
+ );
1348
+ if (retained === undefined) return undefined;
1349
+ try {
1350
+ const decision = readComplianceCandidate(retained.candidate);
1351
+ if (decision.status !== "escalate") return undefined;
1352
+ const details = message.details;
1353
+ if (!isAuditEscalationResult(details) || !isRecord(details)) return undefined;
1354
+
1355
+ // Read the public face exactly once. Besides making key enumeration and
1356
+ // getters fail closed, this prevents a stateful accessor from authenticating
1357
+ // one value and yielding another during final Terminal projection.
1358
+ const projectedDetails = snapshotAuditDetails(details);
1359
+ const hasDecisionConflicts = Object.hasOwn(decision, "conflicts");
1360
+ const hasDetailsConflicts = Object.hasOwn(projectedDetails, "conflicts");
1361
+ if (hasDecisionConflicts !== hasDetailsConflicts) return undefined;
1362
+ if (hasDecisionConflicts && !sameAuditValue(projectedDetails.conflicts, decision.conflicts)) return undefined;
1363
+ const hasDecisionGate = Object.hasOwn(decision, "decisionGate");
1364
+ const hasDetailsGate = Object.hasOwn(projectedDetails, "auditDecisionGate");
1365
+ if (hasDecisionGate !== hasDetailsGate) return undefined;
1366
+ if (hasDecisionGate && !sameAuditValue(projectedDetails.auditDecisionGate, decision.decisionGate)) return undefined;
1367
+ return { decision, details: projectedDetails };
1368
+ } catch {
1369
+ // Retained/public own-key enumeration, property reads, recursive equality,
1370
+ // and projection are all untrusted session evidence.
1371
+ return undefined;
1372
+ }
1373
+ }
1374
+
1375
+ function isUnboundAuditEscalationFace(details: unknown): boolean {
1376
+ try {
1377
+ if (isAuditEscalationResult(details)) return true;
1378
+ } catch {
1379
+ // Hostile access is not authentic escalation evidence.
1380
+ }
1381
+ if (!isRecord(details)) return false;
1382
+ const kind = safelyRead(details, "kind");
1383
+ return kind.readable && kind.value === "audit_escalation";
1384
+ }
1385
+
1386
+ function auditIncompleteFromCandidate(
1387
+ candidate: unknown,
1388
+ ): ComplianceAuditIncomplete | undefined {
1389
+ const decision = readComplianceCandidate(candidate);
1390
+ return decision.status === "audit-incomplete" ? decision : undefined;
1391
+ }
1392
+
1393
+ function boundRetainedAuditResponse(
1394
+ entries: readonly SessionEntry[],
1395
+ callIndex: number,
1396
+ resultIndex: number,
1397
+ auditToolName: string,
1398
+ ): BoundRetainedAuditResponse | undefined {
1399
+ const matches: BoundRetainedAuditResponse[] = [];
1400
+ let retainedResponseCount = 0;
1401
+ for (let index = callIndex + 1; index < resultIndex; index += 1) {
1402
+ const entry = entries[index];
1403
+ if (entry?.type !== "custom" || entry.customType !== COMPLIANCE_RESPONSE_ENTRY_TYPE) {
1404
+ continue;
1405
+ }
1406
+ retainedResponseCount += 1;
1407
+ if (!isRecord(entry.data) || !isRecord(entry.data.response)) continue;
1408
+ const response = entry.data.response;
1409
+ if (!Array.isArray(response.content)) continue;
1410
+ const calls = response.content.filter(
1411
+ (part): part is Record<string, unknown> =>
1412
+ isRecord(part) && part.type === "toolCall",
1413
+ );
1414
+ if (calls.length !== 1 || calls[0]?.name !== auditToolName) continue;
1415
+ matches.push({ candidate: calls[0]?.arguments });
1416
+ }
1417
+ return retainedResponseCount === 1 && matches.length === 1 ? matches[0] : undefined;
1418
+ }
1419
+
1420
+ export function extractComplianceAuditIncompleteRoleOutcome(
1421
+ entries: readonly SessionEntry[],
1422
+ role: (typeof AUDITOR_SOUL_ROLES)[number],
1423
+ outputToolName: string,
1424
+ ): { outcome: ReturnType<typeof buildAuditIncompleteTerminalOutcome> } | undefined {
1425
+ if (outputToolName !== outputToolNameForAuditedRole(role)) return undefined;
1426
+ const auditToolName = auditToolNameForRole(role);
1427
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
1428
+ const message = entries[index]?.message;
1429
+ if (
1430
+ entries[index]?.type !== "message" ||
1431
+ message?.role !== "toolResult" ||
1432
+ message.toolName !== outputToolName ||
1433
+ message.isError === true ||
1434
+ !isComplianceAuditIncomplete(message.details)
1435
+ ) {
1436
+ continue;
1437
+ }
1438
+ const roleCall = boundRoleToolCallForResult(
1439
+ entries,
1440
+ index,
1441
+ message,
1442
+ outputToolName,
1443
+ );
1444
+ if (roleCall === undefined) continue;
1445
+ // Preflight missing-dossier / missing-subject never contacts the provider, so
1446
+ // there is no retained auditor response — the role tool details are the audit.
1447
+ // details already narrowed by isComplianceAuditIncomplete at the loop gate.
1448
+ const details = message.details;
1449
+ if (
1450
+ details.observation.kind === "missing-dossier"
1451
+ || details.observation.kind === "missing-subject"
1452
+ ) {
1453
+ return {
1454
+ outcome: buildAuditIncompleteTerminalOutcome({
1455
+ role,
1456
+ roleCandidate: roleCall.candidate,
1457
+ audit: details,
1458
+ }),
1459
+ };
1460
+ }
1461
+ const retained = boundRetainedAuditResponse(
1462
+ entries,
1463
+ roleCall.callIndex,
1464
+ index,
1465
+ auditToolName,
1466
+ );
1467
+ if (retained === undefined) continue;
1468
+ const audit = auditIncompleteFromCandidate(retained.candidate);
1469
+ if (audit === undefined) continue;
1470
+ return {
1471
+ outcome: buildAuditIncompleteTerminalOutcome({
1472
+ role,
1473
+ roleCandidate: roleCall.candidate,
1474
+ audit,
1475
+ }),
1476
+ };
1477
+ }
1478
+ return undefined;
1479
+ }
1480
+
1481
+ function auditArtifactPublicationError(message: string, code: string): Error & {
1482
+ code: string;
1483
+ } {
1484
+ const error = new Error(message) as Error & { code: string };
1485
+ error.name = "ArtifactPublicationError";
1486
+ error.code = code;
1487
+ return error;
1488
+ }
1489
+
1490
+ async function ensureAuditEvidenceDirectory(runDirectory: string): Promise<string> {
1491
+ const artifactsDir = join(runDirectory, "artifacts");
1492
+ const runStat = await lstat(runDirectory);
1493
+ if (runStat.isSymbolicLink() || !runStat.isDirectory()) {
1494
+ throw auditArtifactPublicationError(
1495
+ "audit evidence run directory is not a real directory",
1496
+ "ELOOP",
1497
+ );
1498
+ }
1499
+ try {
1500
+ const existing = await lstat(artifactsDir);
1501
+ if (existing.isSymbolicLink()) {
1502
+ throw auditArtifactPublicationError(
1503
+ "audit evidence artifacts directory is a symlink",
1504
+ "ELOOP",
1505
+ );
1506
+ }
1507
+ if (!existing.isDirectory()) {
1508
+ throw auditArtifactPublicationError(
1509
+ "audit evidence artifacts path is not a directory",
1510
+ "EEXIST",
1511
+ );
1512
+ }
1513
+ } catch (error) {
1514
+ if (!isMissingPathError(error)) throw error;
1515
+ await mkdir(artifactsDir, { recursive: true });
1516
+ const created = await lstat(artifactsDir);
1517
+ if (created.isSymbolicLink() || !created.isDirectory()) {
1518
+ throw auditArtifactPublicationError(
1519
+ "audit evidence artifacts directory is not a real directory",
1520
+ "ELOOP",
1521
+ );
1522
+ }
1523
+ }
1524
+ return artifactsDir;
1525
+ }
1526
+
1527
+ /** Publish the retained residual with exclusive, complete-write semantics. */
1528
+ export async function publishComplianceAuditIncompleteEvidence(
1529
+ admitted: AdmittedRoleInvocation,
1530
+ outcome: ReturnType<typeof buildAuditIncompleteTerminalOutcome>,
1531
+ ): Promise<TerminalArtifactRef> {
1532
+ const artifactsDir = await ensureAuditEvidenceDirectory(admitted.runDirectory);
1533
+ const evidencePath = join(artifactsDir, "audit-incomplete.json");
1534
+ try {
1535
+ const existing = await lstat(evidencePath);
1536
+ throw auditArtifactPublicationError(
1537
+ existing.isSymbolicLink()
1538
+ ? "audit evidence destination is a symlink"
1539
+ : "audit evidence destination collision",
1540
+ existing.isSymbolicLink() ? "ELOOP" : "EEXIST",
1541
+ );
1542
+ } catch (error) {
1543
+ if (!isMissingPathError(error)) throw error;
1544
+ }
1545
+ const handle = await open(evidencePath, "wx", 0o600);
1546
+ try {
1547
+ await handle.writeFile(`${JSON.stringify(outcome, null, 2)}\n`, "utf8");
1548
+ await handle.sync();
1549
+ } finally {
1550
+ await handle.close();
1551
+ }
1552
+ return { kind: "evidence", path: evidencePath };
1553
+ }
1554
+
1555
+ function auditPublicationFailureTerminal(
1556
+ admitted: AdmittedRoleInvocation,
1557
+ entries: readonly SessionEntry[],
1558
+ outcome: ReturnType<typeof buildAuditIncompleteTerminalOutcome>,
1559
+ error: unknown,
1560
+ ): TerminalResult {
1561
+ const attempt = publicationAttemptFromError(
1562
+ join(admitted.runDirectory, "artifacts", "audit-incomplete.json"),
1563
+ error,
1564
+ );
1565
+ const diagnostic = `audit-incomplete evidence publication failed: ${attempt.diagnostic}`;
1566
+ const decisiveFacts: Record<string, unknown> = {
1567
+ ...outcome.decisiveFacts,
1568
+ cause: "unrecognized",
1569
+ diagnostic,
1570
+ publicationFailure: attempt,
1571
+ };
1572
+ if (attempt.identity?.name !== undefined) decisiveFacts.errorName = attempt.identity.name;
1573
+ if (attempt.identity?.code !== undefined) decisiveFacts.errorCode = attempt.identity.code;
1574
+ const auditResidual: AuditIncompleteResidual = {
1575
+ roleCandidate: outcome.roleCandidate,
1576
+ audit: outcome.audit,
1577
+ acceptedReceipt: false,
1578
+ };
1579
+ return {
1580
+ roleOutcome: {
1581
+ kind: "failure",
1582
+ role: admitted.role,
1583
+ cause: "unrecognized",
1584
+ diagnostic,
1585
+ decisiveFacts,
1586
+ auditResidual,
1587
+ },
1588
+ navigator: extractNavigatorFact(entries),
1589
+ artifacts: [],
1590
+ runId: admitted.runId,
1591
+ };
1592
+ }
1593
+
1594
+ /**
1595
+ * Settle the shared audit-incomplete Terminal for Judge/Fixer/Reviewer/Doctor.
1596
+ * Callers invoke this only after their ordinary lawful extractor found no result,
1597
+ * which preserves the no-other-lawful-result invariant without a second validator.
1598
+ */
1599
+ export async function trySettleComplianceAuditIncompleteTerminalResult(
1600
+ admitted: AdmittedRoleInvocation,
1601
+ ): Promise<TerminalResult | undefined> {
1602
+ if (!(AUDITOR_SOUL_ROLES as readonly string[]).includes(admitted.role)) {
1603
+ return undefined;
1604
+ }
1605
+ const outputToolName =
1606
+ admitted.role === "judge"
1607
+ ? JUDGE_OUTPUT_TOOL_NAME
1608
+ : admitted.role === "reviewer"
1609
+ ? REVIEWER_OUTPUT_TOOL_NAME
1610
+ : DOCTOR_OUTPUT_TOOL_NAME;
1611
+ const entries = await readLawfulSettlementEntries(admitted);
1612
+ if (entries === undefined) return undefined;
1613
+ const extracted = extractComplianceAuditIncompleteRoleOutcome(
1614
+ entries,
1615
+ admitted.role as (typeof AUDITOR_SOUL_ROLES)[number],
1616
+ outputToolName,
1617
+ );
1618
+ if (extracted === undefined) return undefined;
1619
+ try {
1620
+ const evidence = await publishComplianceAuditIncompleteEvidence(
1621
+ admitted,
1622
+ extracted.outcome,
1623
+ );
1624
+ return {
1625
+ roleOutcome: extracted.outcome,
1626
+ navigator: extractNavigatorFact(entries),
1627
+ artifacts: [evidence],
1628
+ runId: admitted.runId,
1629
+ };
1630
+ } catch (error) {
1631
+ // Publication failure is a non-lawful terminal, never an accepted audit result.
1632
+ return auditPublicationFailureTerminal(admitted, entries, extracted.outcome, error);
1633
+ }
1634
+ }
1635
+
1636
+ /** Lawful Judge outcomes extracted from session (never a fabricated failure Receipt). */
1637
+ export type LawfulJudgeRoleOutcome = Extract<
1638
+ TerminalRoleOutcome,
1639
+ { kind: "accepted" } | { kind: "audit_escalation" }
1640
+ >;
1641
+
1642
+ export function extractJudgeRoleOutcome(
1643
+ entries: readonly SessionEntry[],
1644
+ ): LawfulJudgeRoleOutcome | undefined {
1645
+ // Singleton marker↔terminal cardinality — ambiguous multi-terminal fails closed.
1646
+ if (!isReceiptSettlementBindingClear(entries)) return undefined;
1647
+ for (let i = entries.length - 1; i >= 0; i -= 1) {
1648
+ const entry = entries[i];
1649
+ if (entry?.type !== "message") continue;
1650
+ const message = entry.message;
1651
+ if (message?.role !== "toolResult") continue;
1652
+ if (message.toolName !== JUDGE_OUTPUT_TOOL_NAME) continue;
1653
+ // Shared classifier owns accepted/human vs non-Receipt terminal discriminant.
1654
+ if (!isAcceptedPackagedRoleTerminalResult(message)) continue;
1655
+ const details = message.details;
1656
+ const escalation = boundAuditEscalationForResult(
1657
+ entries,
1658
+ i,
1659
+ message,
1660
+ "judge",
1661
+ JUDGE_OUTPUT_TOOL_NAME,
1662
+ );
1663
+ if (escalation !== undefined) {
1664
+ return {
1665
+ kind: "audit_escalation",
1666
+ role: "judge",
1667
+ status: "audit_escalation",
1668
+ decisiveFacts: { ...escalation.details },
1669
+ };
1670
+ }
1671
+ if (isUnboundAuditEscalationFace(details)) continue;
1672
+ // The known discriminator selects the branch; optional presentation material
1673
+ // must not become a second verdict-shape gate (ADR 0040).
1674
+ if (!isRecord(details)) continue;
1675
+ const statusRead = safelyRead(details, "judgeStatus");
1676
+ if (!statusRead.readable) continue;
1677
+ const judgeStatus = statusRead.value;
1678
+ if (judgeStatus !== "converged" && judgeStatus !== "continue" && judgeStatus !== "escalate") continue;
1679
+ return {
1680
+ kind: "accepted",
1681
+ role: "judge",
1682
+ status: judgeStatus,
1683
+ decisiveFacts: judgeDecisiveFacts(details, judgeStatus),
1684
+ };
1685
+ }
1686
+ return undefined;
1687
+ }
1688
+
1689
+ function navigatorPhaseValue(value: unknown): NavigatorPhase {
1690
+ if (value === "plan" || value === "apply") return value;
1691
+ return null;
1692
+ }
1693
+
1694
+ function attendanceIdentityFromAdmitted(
1695
+ admitted: AdmittedRoleInvocation,
1696
+ ): NavigatorAttendanceIdentity {
1697
+ // Public CLI sessions live under the machine ledger; Navigator derives subject from
1698
+ // the project/cwd work identity, not the per-run session directory spelling.
1699
+ const subjectKey = workSubjectKeyFromProjectRoot(admitted.projectRoot);
1700
+ if (admitted.role === "coder" || admitted.role === "fixer") {
1701
+ return { phase: admitted.phase, subjectKey };
1702
+ }
1703
+ return { phase: null, subjectKey };
1704
+ }
1705
+
1706
+ /**
1707
+ * Independent expected role/phase/subject for marker correlation.
1708
+ * Role comes from the durable packaged terminal tool; phase/subject from admitted
1709
+ * lifecycle, Developer session cwd, or registry — never attendance self-fields.
1710
+ */
1711
+ function independentExpectedIdentity(
1712
+ entries: readonly SessionEntry[],
1713
+ terminalRole: string,
1714
+ supplied?: NavigatorAttendanceIdentity,
1715
+ ): ExpectedInvocationIdentity {
1716
+ let subjectKey: string | undefined;
1717
+ for (const entry of entries) {
1718
+ if (entry?.type !== "session") continue;
1719
+ if (typeof entry.cwd === "string" && entry.cwd.trim() !== "") {
1720
+ subjectKey = workSubjectKeyFromProjectRoot(entry.cwd);
1721
+ }
1722
+ break;
1723
+ }
1724
+ if (typeof supplied?.subjectKey === "string") {
1725
+ subjectKey = supplied.subjectKey;
1726
+ }
1727
+
1728
+ let phase: NavigatorPhase | undefined;
1729
+ let allowedPhases: readonly NavigatorPhase[] | undefined;
1730
+ // null is a real phase fact (Judge/Reviewer/…); only omit when not independently known.
1731
+ if (supplied !== undefined && Object.hasOwn(supplied, "phase")) {
1732
+ phase = supplied.phase ?? null;
1733
+ } else {
1734
+ const meta = packagedRoleMetadata(terminalRole);
1735
+ if (meta !== undefined) {
1736
+ if (meta.phases.length === 1) {
1737
+ phase = meta.phases[0] as NavigatorPhase;
1738
+ } else {
1739
+ allowedPhases = meta.phases as readonly NavigatorPhase[];
1740
+ }
1741
+ }
1742
+ }
1743
+
1744
+ return {
1745
+ role: terminalRole,
1746
+ ...(phase !== undefined ? { phase } : {}),
1747
+ ...(allowedPhases !== undefined ? { allowedPhases } : {}),
1748
+ ...(subjectKey !== undefined ? { subjectKey } : {}),
1749
+ };
1750
+ }
1751
+
1752
+ /**
1753
+ * Attendance must match the bound marker identity and current durable terminal role.
1754
+ * Self-shape of attendance fields is not correlation. Marker already matched the
1755
+ * independent expected identity before this check runs.
1756
+ */
1757
+ function navigatorAttendanceCorrelatedWithBoundMarker(
1758
+ details: Record<string, unknown>,
1759
+ attendanceIndex: number,
1760
+ terminal: { index: number; role: string },
1761
+ marker: InvocationMarkerIdentity,
1762
+ ): boolean {
1763
+ if (attendanceIndex <= terminal.index) return false;
1764
+ if (details.version !== 1) return false;
1765
+
1766
+ // Role comes from the packaged terminal tool — compare, do not self-validate.
1767
+ if (details.role !== terminal.role) return false;
1768
+ // Marker role must already equal terminal role (checked by caller); attendance follows marker.
1769
+ if (details.role !== marker.role) return false;
1770
+
1771
+ // Exact current invocation token is the bound marker principal.
1772
+ if (details.invocationId !== marker.invocationId) return false;
1773
+
1774
+ // Phase and subject ride the same marker identity truth table.
1775
+ if (details.phase !== marker.phase) return false;
1776
+ if (typeof details.subjectKey !== "string") return false;
1777
+ if (!workSubjectKeysEqual(details.subjectKey, marker.subjectKey)) return false;
1778
+
1779
+ return true;
1780
+ }
1781
+
1782
+ function parseNavigatorAttendanceDetails(
1783
+ details: Record<string, unknown>,
1784
+ ): TerminalNavigatorFact {
1785
+ const disposition = details.disposition;
1786
+ const advisoryDiagnostic = typeof details.routePlaybookReadFailure === "string"
1787
+ ? { advisoryDiagnostic: details.routePlaybookReadFailure }
1788
+ : {};
1789
+ if (disposition === "recommendation") {
1790
+ const next = details.next;
1791
+ if (!isRecord(next) || typeof next.role !== "string") {
1792
+ return {
1793
+ disposition: "unavailable",
1794
+ source: "unknown",
1795
+ reason: "navigator recommendation missing typed next role",
1796
+ };
1797
+ }
1798
+ const reason = typeof details.reason === "string" ? details.reason : "";
1799
+ const route = Array.isArray(details.route)
1800
+ ? details.route
1801
+ .filter(isRecord)
1802
+ .map((target) => ({
1803
+ role: String(target.role),
1804
+ phase: navigatorPhaseValue(target.phase),
1805
+ }))
1806
+ : undefined;
1807
+ return recommendationNavigatorFact({
1808
+ ...advisoryDiagnostic,
1809
+ next: {
1810
+ role: next.role,
1811
+ phase: navigatorPhaseValue(next.phase),
1812
+ },
1813
+ reason,
1814
+ ...(route === undefined ? {} : { route }),
1815
+ ...(typeof details.command === "string"
1816
+ ? { modelCommand: details.command }
1817
+ : {}),
1818
+ });
1819
+ }
1820
+ if (disposition === "unavailable") {
1821
+ return {
1822
+ disposition: "unavailable",
1823
+ ...advisoryDiagnostic,
1824
+ source:
1825
+ typeof details.unavailableSource === "string"
1826
+ ? details.unavailableSource
1827
+ : "unknown",
1828
+ reason:
1829
+ typeof details.unavailableReason === "string"
1830
+ ? details.unavailableReason
1831
+ : "Navigator unavailable",
1832
+ };
1833
+ }
1834
+ // arrival and legacy silence both mean affirmative lawful no next-role advice.
1835
+ if (disposition === "no-advice" || disposition === "arrival" || disposition === "silence") {
1836
+ return {
1837
+ disposition: "no-advice",
1838
+ ...advisoryDiagnostic,
1839
+ };
1840
+ }
1841
+ return {
1842
+ disposition: "unavailable",
1843
+ source: "unknown",
1844
+ reason: "Navigator attendance disposition is unparseable",
1845
+ };
1846
+ }
1847
+
1848
+ export function extractNavigatorFact(
1849
+ entries: readonly SessionEntry[],
1850
+ identity?: NavigatorAttendanceIdentity,
1851
+ ): TerminalNavigatorFact {
1852
+ // Affirmative attendance only. Missing / uncorrelated / unparseable is never no-advice.
1853
+ // One truth table: durable classifier + singleton marker binding + marker identity match.
1854
+ const binding = bindCurrentDurableTerminalToMarker(entries);
1855
+ if (binding.kind === "absent") {
1856
+ return {
1857
+ disposition: "unavailable",
1858
+ source: "unknown",
1859
+ reason: "Navigator attendance has no durable packaged role terminal",
1860
+ };
1861
+ }
1862
+ if (binding.kind === "ambiguous") {
1863
+ return {
1864
+ disposition: "unavailable",
1865
+ source: "unknown",
1866
+ reason: "Navigator attendance is ambiguous across multiple durable role terminals",
1867
+ };
1868
+ }
1869
+ if (binding.kind === "unbound") {
1870
+ return {
1871
+ disposition: "unavailable",
1872
+ source: "unknown",
1873
+ reason: "Navigator attendance is uncorrelated with session invocation facts",
1874
+ };
1875
+ }
1876
+
1877
+ const { terminal, marker } = binding;
1878
+ // Marker role must match the durable terminal tool's role.
1879
+ if (marker.role !== terminal.role) {
1880
+ return {
1881
+ disposition: "unavailable",
1882
+ source: "unknown",
1883
+ reason: "Navigator attendance is uncorrelated with session invocation facts",
1884
+ };
1885
+ }
1886
+ // Marker phase/subject (and role) must match independently admitted expected identity.
1887
+ const expected = independentExpectedIdentity(entries, terminal.role, identity);
1888
+ if (!markerMatchesExpectedIdentity(marker, expected)) {
1889
+ return {
1890
+ disposition: "unavailable",
1891
+ source: "unknown",
1892
+ reason: "Navigator attendance is uncorrelated with session invocation facts",
1893
+ };
1894
+ }
1895
+
1896
+ for (let i = entries.length - 1; i >= 0; i -= 1) {
1897
+ const entry = entries[i];
1898
+ if (entry?.type === "custom_message" && entry.customType === "ak-navigator-attendance") {
1899
+ const details = entry.message?.details ?? (entry as { details?: unknown }).details;
1900
+ if (!isRecord(details)) {
1901
+ return {
1902
+ disposition: "unavailable",
1903
+ source: "unknown",
1904
+ reason: "Navigator attendance is unparseable",
1905
+ };
1906
+ }
1907
+ if (
1908
+ !navigatorAttendanceCorrelatedWithBoundMarker(
1909
+ details,
1910
+ i,
1911
+ { index: terminal.index, role: terminal.role },
1912
+ marker,
1913
+ )
1914
+ ) {
1915
+ return {
1916
+ disposition: "unavailable",
1917
+ source: "unknown",
1918
+ reason: "Navigator attendance is uncorrelated with session invocation facts",
1919
+ };
1920
+ }
1921
+ return parseNavigatorAttendanceDetails(details);
1922
+ }
1923
+ }
1924
+ // Absence is not successful no-advice — require affirmative typed attendance.
1925
+ return {
1926
+ disposition: "unavailable",
1927
+ source: "unknown",
1928
+ reason: "Navigator attendance is missing from the session",
1929
+ };
1930
+ }
1931
+
1932
+ /**
1933
+ * Exact-session Navigator fact for failure Terminal settlement.
1934
+ * Never infers no-advice from omission; session read failures stay typed unavailable
1935
+ * so the controlled-failure Terminal itself still settles.
1936
+ */
1937
+ async function extractNavigatorFactFromAdmittedSession(
1938
+ admitted: AdmittedRoleInvocation,
1939
+ ): Promise<TerminalNavigatorFact> {
1940
+ try {
1941
+ const entries = await readBoundSessionEntries(admitted.sessionFile);
1942
+ return extractNavigatorFact(entries, attendanceIdentityFromAdmitted(admitted));
1943
+ } catch (error) {
1944
+ if (isMissingPathError(error)) {
1945
+ return {
1946
+ disposition: "unavailable",
1947
+ source: "unknown",
1948
+ reason: "Navigator attendance is missing from the session",
1949
+ };
1950
+ }
1951
+ return {
1952
+ disposition: "unavailable",
1953
+ source: "unknown",
1954
+ reason: "Navigator attendance is unavailable because the session could not be read",
1955
+ };
1956
+ }
1957
+ }
1958
+
1959
+ export async function publishJudgeArtifacts(
1960
+ admitted: AdmittedJudgeInvocation,
1961
+ roleOutcome: TerminalRoleOutcome,
1962
+ sessionDirectory: string,
1963
+ ): Promise<TerminalArtifactRef[]> {
1964
+ const artifactsDir = await ensureRunArtifactsDir(admitted.runDirectory);
1965
+ const reportPath = join(artifactsDir, "report.json");
1966
+ const evidencePath = join(artifactsDir, "evidence.json");
1967
+ await writeFile(
1968
+ reportPath,
1969
+ `${JSON.stringify(
1970
+ {
1971
+ role: "judge",
1972
+ runId: admitted.runId,
1973
+ outcome: roleOutcome,
1974
+ },
1975
+ null,
1976
+ 2,
1977
+ )}\n`,
1978
+ "utf8",
1979
+ );
1980
+ await writeFile(
1981
+ evidencePath,
1982
+ `${JSON.stringify(
1983
+ {
1984
+ runId: admitted.runId,
1985
+ sessionDirectory,
1986
+ sessionFile: admitted.sessionFile,
1987
+ admittedRequestPath: admitted.admittedRequestPath,
1988
+ attachments: admitted.attachments.map((a) => ({
1989
+ provenancePath: a.provenancePath,
1990
+ frozenPath: a.frozenPath,
1991
+ sha256: a.sha256,
1992
+ byteLength: a.byteLength,
1993
+ })),
1994
+ },
1995
+ null,
1996
+ 2,
1997
+ )}\n`,
1998
+ "utf8",
1999
+ );
2000
+ return [
2001
+ { kind: "report", path: reportPath },
2002
+ { kind: "evidence", path: evidencePath },
2003
+ ];
2004
+ }
2005
+
2006
+ /**
2007
+ * Publish lawful Coder success Artifacts on the shared #106 success interface.
2008
+ * Evidence records package method provenance without ambient home Skill paths.
2009
+ */
2010
+ export async function publishCoderArtifacts(
2011
+ admitted: AdmittedCoderInvocation,
2012
+ roleOutcome: TerminalRoleOutcome,
2013
+ sessionDirectory: string,
2014
+ options: {
2015
+ readonly methodProvenance?: PackagedMethodSkillProvenance;
2016
+ readonly coderOutput?: CoderOutput;
2017
+ } = {},
2018
+ ): Promise<TerminalArtifactRef[]> {
2019
+ const artifactsDir = await ensureRunArtifactsDir(admitted.runDirectory);
2020
+ const reportPath = join(artifactsDir, "report.json");
2021
+ const evidencePath = join(artifactsDir, "evidence.json");
2022
+ await writeFile(
2023
+ reportPath,
2024
+ `${JSON.stringify(
2025
+ {
2026
+ role: "coder",
2027
+ runId: admitted.runId,
2028
+ phase: admitted.phase,
2029
+ outcome: roleOutcome,
2030
+ ...(options.coderOutput === undefined
2031
+ ? {}
2032
+ : { receipt: options.coderOutput }),
2033
+ },
2034
+ null,
2035
+ 2,
2036
+ )}\n`,
2037
+ "utf8",
2038
+ );
2039
+ await writeFile(
2040
+ evidencePath,
2041
+ `${JSON.stringify(
2042
+ {
2043
+ runId: admitted.runId,
2044
+ role: "coder",
2045
+ phase: admitted.phase,
2046
+ sessionDirectory,
2047
+ sessionFile: admitted.sessionFile,
2048
+ admittedRequestPath: admitted.admittedRequestPath,
2049
+ taskPath: admitted.taskPath,
2050
+ attachments: admitted.attachments.map((a) => ({
2051
+ provenancePath: a.provenancePath,
2052
+ frozenPath: a.frozenPath,
2053
+ sha256: a.sha256,
2054
+ byteLength: a.byteLength,
2055
+ })),
2056
+ ...(options.methodProvenance === undefined
2057
+ ? {}
2058
+ : { methodProvenance: options.methodProvenance }),
2059
+ },
2060
+ null,
2061
+ 2,
2062
+ )}\n`,
2063
+ "utf8",
2064
+ );
2065
+ return [
2066
+ { kind: "report", path: reportPath },
2067
+ { kind: "evidence", path: evidencePath },
2068
+ ];
2069
+ }
2070
+
2071
+ /** Lawful Coder accepted outcome extracted from session (shared success interface). */
2072
+ export type LawfulCoderRoleOutcome = {
2073
+ kind: "accepted";
2074
+ role: "coder";
2075
+ status: string;
2076
+ decisiveFacts: Readonly<Record<string, unknown>>;
2077
+ };
2078
+
2079
+ export function extractCoderRoleOutcome(
2080
+ entries: readonly SessionEntry[],
2081
+ ): { outcome: LawfulCoderRoleOutcome; output: CoderOutput } | undefined {
2082
+ if (!isReceiptSettlementBindingClear(entries)) return undefined;
2083
+ for (let i = entries.length - 1; i >= 0; i -= 1) {
2084
+ const entry = entries[i];
2085
+ if (entry?.type !== "message") continue;
2086
+ const message = entry.message;
2087
+ if (message?.role !== "toolResult") continue;
2088
+ if (message.toolName !== CODER_OUTPUT_TOOL_NAME) continue;
2089
+ if (!isAcceptedPackagedRoleTerminalResult(message)) continue;
2090
+ try {
2091
+ validateAcceptedDetails(CODER_OUTPUT_TOOL_NAME, message.details);
2092
+ const output = validateAcceptedCoderDetails(message.details);
2093
+ const outcome: LawfulCoderRoleOutcome = {
2094
+ kind: "accepted",
2095
+ role: "coder",
2096
+ status: output.status,
2097
+ decisiveFacts: coderDecisiveFacts(output),
2098
+ };
2099
+ return { output, outcome };
2100
+ } catch {
2101
+ continue;
2102
+ }
2103
+ }
2104
+ return undefined;
2105
+ }
2106
+
2107
+ /**
2108
+ * Read session entries for lawful settlement. Missing path → undefined (absence).
2109
+ * Malformed JSONL / other read failures throw with knownCause=session.
2110
+ */
2111
+ async function readLawfulSettlementEntries(
2112
+ admitted: AdmittedRoleInvocation,
2113
+ ): Promise<SessionEntry[] | undefined> {
2114
+ try {
2115
+ return await readBoundSessionEntries(admitted.sessionFile);
2116
+ } catch (error) {
2117
+ // Missing path is absence of a lawful outcome; callers classify via session inspect.
2118
+ if (isMissingPathError(error)) return undefined;
2119
+ // Malformed JSONL and other read failures keep typed session identity.
2120
+ throw error instanceof Error &&
2121
+ (error as { knownCause?: unknown }).knownCause === "session"
2122
+ ? error
2123
+ : sessionReadFailure(error, "session unreadable");
2124
+ }
2125
+ }
2126
+
2127
+ /**
2128
+ * Lawful Judge outcome presence only — no artifact publication.
2129
+ * Returns undefined for genuine absence (missing path / no lawful verdict).
2130
+ * Session-read failures propagate with typed identity.
2131
+ */
2132
+ export async function readLawfulJudgeRoleOutcome(
2133
+ admitted: AdmittedJudgeInvocation,
2134
+ ): Promise<LawfulJudgeRoleOutcome | undefined> {
2135
+ const entries = await readLawfulSettlementEntries(admitted);
2136
+ if (entries === undefined) return undefined;
2137
+ return extractJudgeRoleOutcome(entries);
2138
+ }
2139
+
2140
+ /**
2141
+ * Independent confirmation that a lawful Judge terminal result is present in session.
2142
+ * Used for resume qualification — must not depend on artifact publication success.
2143
+ * Unreadable sessions are not a confirmed lawful result (returns false).
2144
+ */
2145
+ export async function hasLawfulJudgeTerminalResult(
2146
+ admitted: AdmittedJudgeInvocation,
2147
+ ): Promise<boolean> {
2148
+ try {
2149
+ const outcome = await readLawfulJudgeRoleOutcome(admitted);
2150
+ return outcome !== undefined && isLawfulTypedTerminalOutcome(outcome);
2151
+ } catch {
2152
+ return false;
2153
+ }
2154
+ }
2155
+
2156
+ /**
2157
+ * Single lawful-settlement implementation (session → outcome/Navigator/artifacts).
2158
+ *
2159
+ * - Returns undefined only for genuine absence (missing session path, or no
2160
+ * lawful verdict in an otherwise readable session).
2161
+ * - Malformed JSONL / other session-read failures throw with knownCause=session
2162
+ * and original identity (SyntaxError name retained).
2163
+ * - Artifact publication failures propagate with their original typed identity.
2164
+ * - Lawful outcome presence is decided before publication so a later write error
2165
+ * cannot erase the fact that a lawful result already exists.
2166
+ */
2167
+ async function settleLawfulJudgeTerminalResult(
2168
+ admitted: AdmittedJudgeInvocation,
2169
+ ): Promise<TerminalResult | undefined> {
2170
+ const entries = await readLawfulSettlementEntries(admitted);
2171
+ if (entries === undefined) return undefined;
2172
+ const roleOutcome = extractJudgeRoleOutcome(entries);
2173
+ if (roleOutcome === undefined) {
2174
+ return undefined;
2175
+ }
2176
+ const navigator = extractNavigatorFact(
2177
+ entries,
2178
+ attendanceIdentityFromAdmitted(admitted),
2179
+ );
2180
+ // Lawful outcome exists — artifact publication keeps original errno/name.
2181
+ const artifacts = await publishJudgeArtifacts(
2182
+ admitted,
2183
+ roleOutcome,
2184
+ admitted.sessionDirectory,
2185
+ );
2186
+ return {
2187
+ roleOutcome,
2188
+ navigator,
2189
+ artifacts,
2190
+ runId: admitted.runId,
2191
+ };
2192
+ }
2193
+
2194
+ /**
2195
+ * Settle a lawful typed terminal result from the admitted session.
2196
+ * Throws when no lawful outcome is present (tests/callers that require success).
2197
+ * Session-read and publication failures retain their typed identity.
2198
+ */
2199
+ export async function settleJudgeTerminalResult(
2200
+ admitted: AdmittedJudgeInvocation,
2201
+ ): Promise<TerminalResult> {
2202
+ const settled = await settleLawfulJudgeTerminalResult(admitted);
2203
+ if (settled === undefined) {
2204
+ throw new Error(
2205
+ "Judge Role run completed without a lawful typed terminal result",
2206
+ );
2207
+ }
2208
+ return settled;
2209
+ }
2210
+
2211
+ /**
2212
+ * Try to settle a lawful typed terminal result from the admitted session.
2213
+ * Returns undefined only for genuine absence (no lawful verdict / missing path).
2214
+ * Session malformation and publication exceptions propagate with typed identity.
2215
+ */
2216
+ export async function trySettleJudgeTerminalResult(
2217
+ admitted: AdmittedJudgeInvocation,
2218
+ ): Promise<TerminalResult | undefined> {
2219
+ return settleLawfulJudgeTerminalResult(admitted);
2220
+ }
2221
+
2222
+ async function settleLawfulCoderTerminalResult(
2223
+ admitted: AdmittedCoderInvocation,
2224
+ options: {
2225
+ readonly methodProvenance?: PackagedMethodSkillProvenance;
2226
+ } = {},
2227
+ ): Promise<TerminalResult | undefined> {
2228
+ const entries = await readLawfulSettlementEntries(admitted);
2229
+ if (entries === undefined) return undefined;
2230
+ const extracted = extractCoderRoleOutcome(entries);
2231
+ if (extracted === undefined) return undefined;
2232
+ const navigator = extractNavigatorFact(
2233
+ entries,
2234
+ attendanceIdentityFromAdmitted(admitted),
2235
+ );
2236
+ const artifacts = await publishCoderArtifacts(
2237
+ admitted,
2238
+ extracted.outcome,
2239
+ admitted.sessionDirectory,
2240
+ {
2241
+ coderOutput: extracted.output,
2242
+ ...(options.methodProvenance === undefined
2243
+ ? {}
2244
+ : { methodProvenance: options.methodProvenance }),
2245
+ },
2246
+ );
2247
+ return {
2248
+ roleOutcome: extracted.outcome,
2249
+ navigator,
2250
+ artifacts,
2251
+ runId: admitted.runId,
2252
+ };
2253
+ }
2254
+
2255
+ /** Settle a lawful Coder Terminal from the admitted session (shared #106 success interface). */
2256
+ export async function settleCoderTerminalResult(
2257
+ admitted: AdmittedCoderInvocation,
2258
+ options: {
2259
+ readonly methodProvenance?: PackagedMethodSkillProvenance;
2260
+ } = {},
2261
+ ): Promise<TerminalResult> {
2262
+ const settled = await settleLawfulCoderTerminalResult(admitted, options);
2263
+ if (settled === undefined) {
2264
+ throw new Error(
2265
+ "Coder Role run completed without a lawful typed terminal result",
2266
+ );
2267
+ }
2268
+ return settled;
2269
+ }
2270
+
2271
+ function sessionMessageText(message: SessionMessage | undefined): string {
2272
+ if (message === undefined) return "";
2273
+ if (typeof message.content === "string") return message.content;
2274
+ if (!Array.isArray(message.content)) return "";
2275
+ const parts: string[] = [];
2276
+ for (const part of message.content) {
2277
+ if (
2278
+ typeof part === "object" &&
2279
+ part !== null &&
2280
+ !Array.isArray(part) &&
2281
+ (part as { type?: unknown }).type === "text" &&
2282
+ typeof (part as { text?: unknown }).text === "string"
2283
+ ) {
2284
+ parts.push((part as { text: string }).text);
2285
+ }
2286
+ }
2287
+ return parts.join("\n");
2288
+ }
2289
+
2290
+ /**
2291
+ * Observe optional Fixer diagnosing-bugs Skill expansions from the session.
2292
+ * Availability is always package-bound; invocation is recorded only when observed.
2293
+ */
2294
+ export function extractFixerMethodInvocations(
2295
+ entries: readonly SessionEntry[],
2296
+ options: {
2297
+ readonly allowedLocations: readonly string[];
2298
+ },
2299
+ ): readonly ObservedPackagedMethodSkillInvocation[] {
2300
+ const observed: ObservedPackagedMethodSkillInvocation[] = [];
2301
+ for (const entry of entries) {
2302
+ if (entry?.type !== "message") continue;
2303
+ const message = entry.message;
2304
+ if (message?.role !== "user") continue;
2305
+ const text = sessionMessageText(message);
2306
+ if (text.length === 0) continue;
2307
+ const hit = observePackagedMethodSkillInvocation(text, {
2308
+ name: "diagnosing-bugs",
2309
+ allowedLocations: options.allowedLocations,
2310
+ });
2311
+ if (hit !== undefined) observed.push(hit);
2312
+ }
2313
+ return Object.freeze(observed);
2314
+ }
2315
+
2316
+ /**
2317
+ * Publish lawful Fixer success Artifacts on the shared #106 success interface.
2318
+ * Evidence records package diagnosis provenance and optional observed invocation.
2319
+ */
2320
+ export async function publishFixerArtifacts(
2321
+ admitted: AdmittedFixerInvocation,
2322
+ roleOutcome: TerminalRoleOutcome,
2323
+ sessionDirectory: string,
2324
+ options: {
2325
+ readonly methodProvenance: PackagedMethodSkillProvenance;
2326
+ readonly methodInvocations?: readonly ObservedPackagedMethodSkillInvocation[];
2327
+ readonly fixerOutput?: FixerOutput;
2328
+ },
2329
+ ): Promise<TerminalArtifactRef[]> {
2330
+ const artifactsDir = await ensureRunArtifactsDir(admitted.runDirectory);
2331
+ const reportPath = join(artifactsDir, "report.json");
2332
+ const evidencePath = join(artifactsDir, "evidence.json");
2333
+ await writeFile(
2334
+ reportPath,
2335
+ `${JSON.stringify(
2336
+ {
2337
+ role: "fixer",
2338
+ runId: admitted.runId,
2339
+ phase: admitted.phase,
2340
+ outcome: roleOutcome,
2341
+ ...(options.fixerOutput === undefined
2342
+ ? {}
2343
+ : { receipt: options.fixerOutput }),
2344
+ },
2345
+ null,
2346
+ 2,
2347
+ )}\n`,
2348
+ "utf8",
2349
+ );
2350
+ await writeFile(
2351
+ evidencePath,
2352
+ `${JSON.stringify(
2353
+ {
2354
+ runId: admitted.runId,
2355
+ role: "fixer",
2356
+ phase: admitted.phase,
2357
+ sessionDirectory,
2358
+ sessionFile: admitted.sessionFile,
2359
+ admittedRequestPath: admitted.admittedRequestPath,
2360
+ packetPath: admitted.packetPath,
2361
+ ...(admitted.prerequisitesPath === undefined
2362
+ ? {}
2363
+ : { prerequisitesPath: admitted.prerequisitesPath }),
2364
+ prerequisites: admitted.prerequisites,
2365
+ attachments: admitted.attachments.map((a) => ({
2366
+ provenancePath: a.provenancePath,
2367
+ frozenPath: a.frozenPath,
2368
+ sha256: a.sha256,
2369
+ byteLength: a.byteLength,
2370
+ })),
2371
+ methodProvenance: options.methodProvenance,
2372
+ // Optional diagnosis: availability is package-bound; invocation only when observed.
2373
+ methodInvocationObserved: (options.methodInvocations ?? []).length > 0,
2374
+ methodInvocations: options.methodInvocations ?? [],
2375
+ },
2376
+ null,
2377
+ 2,
2378
+ )}\n`,
2379
+ "utf8",
2380
+ );
2381
+ return [
2382
+ { kind: "report", path: reportPath },
2383
+ { kind: "evidence", path: evidencePath },
2384
+ ];
2385
+ }
2386
+
2387
+ /** Lawful Fixer accepted outcome extracted from session (no LLM auditor after #242). */
2388
+ export type LawfulFixerRoleOutcome = {
2389
+ kind: "accepted";
2390
+ role: "fixer";
2391
+ status: string;
2392
+ decisiveFacts: Readonly<Record<string, unknown>>;
2393
+ };
2394
+
2395
+ export function extractFixerRoleOutcome(
2396
+ entries: readonly SessionEntry[],
2397
+ ): { outcome: LawfulFixerRoleOutcome; output?: FixerOutput } | undefined {
2398
+ if (!isReceiptSettlementBindingClear(entries)) return undefined;
2399
+ for (let i = entries.length - 1; i >= 0; i -= 1) {
2400
+ const entry = entries[i];
2401
+ if (entry?.type !== "message") continue;
2402
+ const message = entry.message;
2403
+ if (message?.role !== "toolResult") continue;
2404
+ if (message.toolName !== FIXER_OUTPUT_TOOL_NAME) continue;
2405
+ if (!isAcceptedPackagedRoleTerminalResult(message)) continue;
2406
+ const details = message.details;
2407
+ // Residual audit_escalation faces are not lawful Fixer terminals after #242.
2408
+ if (isUnboundAuditEscalationFace(details)) continue;
2409
+ try {
2410
+ validateAcceptedDetails(FIXER_OUTPUT_TOOL_NAME, details);
2411
+ const output = validateFixerOutput(details);
2412
+ const outcome: LawfulFixerRoleOutcome = {
2413
+ kind: "accepted",
2414
+ role: "fixer",
2415
+ status: output.status,
2416
+ decisiveFacts: fixerDecisiveFacts(output),
2417
+ };
2418
+ return { output, outcome };
2419
+ } catch {
2420
+ continue;
2421
+ }
2422
+ }
2423
+ return undefined;
2424
+ }
2425
+
2426
+ async function settleLawfulFixerTerminalResult(
2427
+ admitted: AdmittedFixerInvocation,
2428
+ options: {
2429
+ readonly methodProvenance: PackagedMethodSkillProvenance;
2430
+ readonly methodSkillPath: string;
2431
+ readonly methodSkillConfiguredPath: string;
2432
+ },
2433
+ ): Promise<TerminalResult | undefined> {
2434
+ const entries = await readLawfulSettlementEntries(admitted);
2435
+ if (entries === undefined) return undefined;
2436
+ const extracted = extractFixerRoleOutcome(entries);
2437
+ if (extracted === undefined) return undefined;
2438
+ const navigator = extractNavigatorFact(
2439
+ entries,
2440
+ attendanceIdentityFromAdmitted(admitted),
2441
+ );
2442
+ const methodInvocations = extractFixerMethodInvocations(entries, {
2443
+ allowedLocations: [
2444
+ options.methodSkillPath,
2445
+ options.methodSkillConfiguredPath,
2446
+ ],
2447
+ });
2448
+ const artifacts = await publishFixerArtifacts(
2449
+ admitted,
2450
+ extracted.outcome,
2451
+ admitted.sessionDirectory,
2452
+ {
2453
+ ...(extracted.output === undefined ? {} : { fixerOutput: extracted.output }),
2454
+ methodProvenance: options.methodProvenance,
2455
+ methodInvocations,
2456
+ },
2457
+ );
2458
+ return {
2459
+ roleOutcome: extracted.outcome,
2460
+ navigator,
2461
+ artifacts,
2462
+ runId: admitted.runId,
2463
+ };
2464
+ }
2465
+
2466
+ /** Settle a lawful Fixer Terminal from the admitted session (shared #106 success interface). */
2467
+ export async function settleFixerTerminalResult(
2468
+ admitted: AdmittedFixerInvocation,
2469
+ options: {
2470
+ readonly methodProvenance: PackagedMethodSkillProvenance;
2471
+ readonly methodSkillPath: string;
2472
+ readonly methodSkillConfiguredPath: string;
2473
+ },
2474
+ ): Promise<TerminalResult> {
2475
+ const settled = await settleLawfulFixerTerminalResult(admitted, options);
2476
+ if (settled === undefined) {
2477
+ throw new Error(
2478
+ "Fixer Role run completed without a lawful typed terminal result",
2479
+ );
2480
+ }
2481
+ return settled;
2482
+ }
2483
+
2484
+ export async function publishCollectorArtifacts(
2485
+ admitted: AdmittedCollectorInvocation,
2486
+ roleOutcome: TerminalRoleOutcome,
2487
+ sessionDirectory: string,
2488
+ options: {
2489
+ readonly collectorReceipt?: CollectorReceipt;
2490
+ } = {},
2491
+ ): Promise<TerminalArtifactRef[]> {
2492
+ const artifactsDir = await ensureRunArtifactsDir(admitted.runDirectory);
2493
+ const reportPath = join(artifactsDir, "report.json");
2494
+ const evidencePath = join(artifactsDir, "evidence.json");
2495
+ await writeFile(
2496
+ reportPath,
2497
+ `${JSON.stringify(
2498
+ {
2499
+ role: "collector",
2500
+ runId: admitted.runId,
2501
+ outcome: roleOutcome,
2502
+ ...(options.collectorReceipt === undefined
2503
+ ? {}
2504
+ : { receipt: options.collectorReceipt }),
2505
+ },
2506
+ null,
2507
+ 2,
2508
+ )}\n`,
2509
+ "utf8",
2510
+ );
2511
+ await writeFile(
2512
+ evidencePath,
2513
+ `${JSON.stringify(
2514
+ {
2515
+ runId: admitted.runId,
2516
+ role: "collector",
2517
+ prNumber: admitted.prNumber,
2518
+ repository: admitted.repository.canonical,
2519
+ manifestDigest: admitted.manifestDigest,
2520
+ sessionDirectory,
2521
+ sessionFile: admitted.sessionFile,
2522
+ admittedRequestPath: admitted.admittedRequestPath,
2523
+ attachments: admitted.attachments.map((a) => ({
2524
+ provenancePath: a.provenancePath,
2525
+ frozenPath: a.frozenPath,
2526
+ sha256: a.sha256,
2527
+ byteLength: a.byteLength,
2528
+ })),
2529
+ },
2530
+ null,
2531
+ 2,
2532
+ )}\n`,
2533
+ "utf8",
2534
+ );
2535
+ return [
2536
+ { kind: "report", path: reportPath },
2537
+ { kind: "evidence", path: evidencePath },
2538
+ ];
2539
+ }
2540
+
2541
+ /** Lawful Collector accepted outcome extracted from session. */
2542
+ export type LawfulCollectorRoleOutcome = {
2543
+ kind: "accepted";
2544
+ role: "collector";
2545
+ /** Collector has no status leaf — synthesize a stable collected marker. */
2546
+ status: "collected";
2547
+ decisiveFacts: Readonly<Record<string, unknown>>;
2548
+ };
2549
+
2550
+ export function extractCollectorRoleOutcome(
2551
+ entries: readonly SessionEntry[],
2552
+ ): { outcome: LawfulCollectorRoleOutcome; receipt: CollectorReceipt } | undefined {
2553
+ if (!isReceiptSettlementBindingClear(entries)) return undefined;
2554
+ for (let i = entries.length - 1; i >= 0; i -= 1) {
2555
+ const entry = entries[i];
2556
+ if (entry?.type !== "message") continue;
2557
+ const message = entry.message;
2558
+ if (message?.role !== "toolResult") continue;
2559
+ if (message.toolName !== COLLECTOR_OUTPUT_TOOL) continue;
2560
+ if (!isAcceptedPackagedRoleTerminalResult(message)) continue;
2561
+ try {
2562
+ const receipt = validateAcceptedCollectorReceipt(message.details);
2563
+ const outcome: LawfulCollectorRoleOutcome = {
2564
+ kind: "accepted",
2565
+ role: "collector",
2566
+ status: "collected",
2567
+ decisiveFacts: collectorDecisiveFacts(receipt),
2568
+ };
2569
+ return { receipt, outcome };
2570
+ } catch {
2571
+ continue;
2572
+ }
2573
+ }
2574
+ return undefined;
2575
+ }
2576
+
2577
+ async function settleLawfulCollectorTerminalResult(
2578
+ admitted: AdmittedCollectorInvocation,
2579
+ ): Promise<TerminalResult | undefined> {
2580
+ const entries = await readLawfulSettlementEntries(admitted);
2581
+ if (entries === undefined) return undefined;
2582
+ const extracted = extractCollectorRoleOutcome(entries);
2583
+ if (extracted === undefined) {
2584
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
2585
+ const message = entries[index]?.message;
2586
+ if (message?.role !== "toolResult") continue;
2587
+ const residual = boundErroredToolCandidate(entries, index, message, COLLECTOR_WAIT_TOOL);
2588
+ if (residual === undefined) continue;
2589
+ const candidate = residual.candidate;
2590
+ const duration = isRecord(candidate) ? candidate.durationMs : undefined;
2591
+ if (Number.isSafeInteger(duration) && (duration as number) >= 1 && (duration as number) <= 900_000) {
2592
+ continue;
2593
+ }
2594
+ return {
2595
+ roleOutcome: buildResidualIncompleteTerminalOutcome({
2596
+ role: "collector",
2597
+ candidate,
2598
+ diagnostic: residual.diagnostic,
2599
+ }),
2600
+ navigator: { disposition: "no-advice" },
2601
+ artifacts: [],
2602
+ runId: admitted.runId,
2603
+ };
2604
+ }
2605
+ return undefined;
2606
+ }
2607
+ assertCollectorReceiptMatchesAdmitted(extracted.receipt, admitted);
2608
+ const navigator = extractNavigatorFact(
2609
+ entries,
2610
+ attendanceIdentityFromAdmitted(admitted),
2611
+ );
2612
+ const artifacts = await publishCollectorArtifacts(
2613
+ admitted,
2614
+ extracted.outcome,
2615
+ admitted.sessionDirectory,
2616
+ { collectorReceipt: extracted.receipt },
2617
+ );
2618
+ return {
2619
+ roleOutcome: extracted.outcome,
2620
+ navigator,
2621
+ artifacts,
2622
+ runId: admitted.runId,
2623
+ };
2624
+ }
2625
+
2626
+ /** Settle a lawful Collector Terminal from the admitted session. */
2627
+ export async function settleCollectorTerminalResult(
2628
+ admitted: AdmittedCollectorInvocation,
2629
+ ): Promise<TerminalResult> {
2630
+ const settled = await settleLawfulCollectorTerminalResult(admitted);
2631
+ if (settled === undefined) {
2632
+ throw new Error(
2633
+ "Collector Role run completed without a lawful typed terminal result",
2634
+ );
2635
+ }
2636
+ return settled;
2637
+ }
2638
+
2639
+ /** Try to settle a lawful Collector Terminal; undefined only for genuine absence. */
2640
+ export async function trySettleCollectorTerminalResult(
2641
+ admitted: AdmittedCollectorInvocation,
2642
+ ): Promise<TerminalResult | undefined> {
2643
+ return settleLawfulCollectorTerminalResult(admitted);
2644
+ }
2645
+
2646
+ export async function publishDoctorArtifacts(
2647
+ admitted: AdmittedDoctorInvocation,
2648
+ roleOutcome: TerminalRoleOutcome,
2649
+ sessionDirectory: string,
2650
+ options: {
2651
+ readonly doctorOutput?: DoctorOutput;
2652
+ } = {},
2653
+ ): Promise<TerminalArtifactRef[]> {
2654
+ const artifactsDir = await ensureRunArtifactsDir(admitted.runDirectory);
2655
+ const reportPath = join(artifactsDir, "report.json");
2656
+ const evidencePath = join(artifactsDir, "evidence.json");
2657
+ await writeFile(
2658
+ reportPath,
2659
+ `${JSON.stringify(
2660
+ {
2661
+ role: "doctor",
2662
+ runId: admitted.runId,
2663
+ outcome: roleOutcome,
2664
+ ...(options.doctorOutput === undefined
2665
+ ? {}
2666
+ : { receipt: options.doctorOutput }),
2667
+ },
2668
+ null,
2669
+ 2,
2670
+ )}\n`,
2671
+ "utf8",
2672
+ );
2673
+ await writeFile(
2674
+ evidencePath,
2675
+ `${JSON.stringify(
2676
+ {
2677
+ runId: admitted.runId,
2678
+ role: "doctor",
2679
+ issueNumber: admitted.issueNumber,
2680
+ caseRunsPath: admitted.caseRunsPath,
2681
+ caseIdentity: admitted.caseIdentity,
2682
+ sessionDirectory,
2683
+ sessionFile: admitted.sessionFile,
2684
+ admittedRequestPath: admitted.admittedRequestPath,
2685
+ attachments: admitted.attachments.map((a) => ({
2686
+ provenancePath: a.provenancePath,
2687
+ frozenPath: a.frozenPath,
2688
+ sha256: a.sha256,
2689
+ byteLength: a.byteLength,
2690
+ })),
2691
+ },
2692
+ null,
2693
+ 2,
2694
+ )}\n`,
2695
+ "utf8",
2696
+ );
2697
+ return [
2698
+ { kind: "report", path: reportPath },
2699
+ { kind: "evidence", path: evidencePath },
2700
+ ];
2701
+ }
2702
+
2703
+ /** Lawful Doctor accepted/refused/audit_escalation outcome extracted from session. */
2704
+ export type LawfulDoctorRoleOutcome =
2705
+ | {
2706
+ kind: "accepted";
2707
+ role: "doctor";
2708
+ status: string;
2709
+ decisiveFacts: Readonly<Record<string, unknown>>;
2710
+ }
2711
+ | {
2712
+ kind: "audit_escalation";
2713
+ role: "doctor";
2714
+ status: "audit_escalation";
2715
+ decisiveFacts: Readonly<Record<string, unknown>>;
2716
+ };
2717
+
2718
+ export function extractDoctorRoleOutcome(
2719
+ entries: readonly SessionEntry[],
2720
+ ): { outcome: LawfulDoctorRoleOutcome; output?: DoctorOutput } | undefined {
2721
+ if (!isReceiptSettlementBindingClear(entries)) return undefined;
2722
+ for (let i = entries.length - 1; i >= 0; i -= 1) {
2723
+ const entry = entries[i];
2724
+ if (entry?.type !== "message") continue;
2725
+ const message = entry.message;
2726
+ if (message?.role !== "toolResult") continue;
2727
+ if (message.toolName !== DOCTOR_OUTPUT_TOOL_NAME) continue;
2728
+ if (!isAcceptedPackagedRoleTerminalResult(message)) continue;
2729
+ const details = message.details;
2730
+ const escalation = boundAuditEscalationForResult(
2731
+ entries,
2732
+ i,
2733
+ message,
2734
+ "doctor",
2735
+ DOCTOR_OUTPUT_TOOL_NAME,
2736
+ );
2737
+ if (escalation !== undefined) {
2738
+ return {
2739
+ outcome: {
2740
+ kind: "audit_escalation",
2741
+ role: "doctor",
2742
+ status: "audit_escalation",
2743
+ decisiveFacts: { ...escalation.details },
2744
+ },
2745
+ };
2746
+ }
2747
+ if (isUnboundAuditEscalationFace(details)) continue;
2748
+ try {
2749
+ const output = validateRecordedDoctorOutput(details);
2750
+ const outcome: LawfulDoctorRoleOutcome = {
2751
+ kind: "accepted",
2752
+ role: "doctor",
2753
+ status: output.status,
2754
+ decisiveFacts: doctorDecisiveFacts(output),
2755
+ };
2756
+ return { output, outcome };
2757
+ } catch {
2758
+ continue;
2759
+ }
2760
+ }
2761
+ return undefined;
2762
+ }
2763
+
2764
+ async function settleLawfulDoctorTerminalResult(
2765
+ admitted: AdmittedDoctorInvocation,
2766
+ ): Promise<TerminalResult | undefined> {
2767
+ const entries = await readLawfulSettlementEntries(admitted);
2768
+ if (entries === undefined) return undefined;
2769
+ const extracted = extractDoctorRoleOutcome(entries);
2770
+ if (extracted === undefined) return undefined;
2771
+ // Bind completed receipt case identity to the admitted Issue evidence case.
2772
+ if (
2773
+ extracted.output !== undefined &&
2774
+ extracted.output.status === "completed"
2775
+ ) {
2776
+ if (
2777
+ extracted.output.case.issueNumber !== admitted.caseIdentity.issueNumber ||
2778
+ extracted.output.case.runsPath !== admitted.caseIdentity.runsPath
2779
+ ) {
2780
+ const error = new Error(
2781
+ "Doctor receipt case identity does not match admitted case identity",
2782
+ ) as Error & { knownCause: ControlledFailureCause };
2783
+ error.name = "DoctorReceiptBindingError";
2784
+ error.knownCause = "output";
2785
+ throw error;
2786
+ }
2787
+ }
2788
+ const navigator = extractNavigatorFact(
2789
+ entries,
2790
+ attendanceIdentityFromAdmitted(admitted),
2791
+ );
2792
+ const artifacts = await publishDoctorArtifacts(
2793
+ admitted,
2794
+ extracted.outcome,
2795
+ admitted.sessionDirectory,
2796
+ extracted.output === undefined ? {} : { doctorOutput: extracted.output },
2797
+ );
2798
+ return {
2799
+ roleOutcome: extracted.outcome,
2800
+ navigator,
2801
+ artifacts,
2802
+ runId: admitted.runId,
2803
+ };
2804
+ }
2805
+
2806
+ /** Settle a lawful Doctor Terminal from the admitted session. */
2807
+ export async function settleDoctorTerminalResult(
2808
+ admitted: AdmittedDoctorInvocation,
2809
+ ): Promise<TerminalResult> {
2810
+ const settled = await settleLawfulDoctorTerminalResult(admitted);
2811
+ if (settled === undefined) {
2812
+ throw new Error(
2813
+ "Doctor Role run completed without a lawful typed terminal result",
2814
+ );
2815
+ }
2816
+ return settled;
2817
+ }
2818
+
2819
+ /** Try to settle a lawful Doctor Terminal; undefined only for genuine absence. */
2820
+ export async function trySettleDoctorTerminalResult(
2821
+ admitted: AdmittedDoctorInvocation,
2822
+ ): Promise<TerminalResult | undefined> {
2823
+ return settleLawfulDoctorTerminalResult(admitted);
2824
+ }
2825
+
2826
+ /** Try to settle a lawful Coder Terminal; undefined only for genuine absence. */
2827
+ export async function trySettleCoderTerminalResult(
2828
+ admitted: AdmittedCoderInvocation,
2829
+ options: {
2830
+ readonly methodProvenance?: PackagedMethodSkillProvenance;
2831
+ } = {},
2832
+ ): Promise<TerminalResult | undefined> {
2833
+ return settleLawfulCoderTerminalResult(admitted, options);
2834
+ }
2835
+
2836
+ export async function hasLawfulCoderTerminalResult(
2837
+ admitted: AdmittedCoderInvocation,
2838
+ ): Promise<boolean> {
2839
+ try {
2840
+ const entries = await readLawfulSettlementEntries(admitted);
2841
+ if (entries === undefined) return false;
2842
+ const extracted = extractCoderRoleOutcome(entries);
2843
+ return extracted !== undefined && isLawfulTypedTerminalOutcome(extracted.outcome);
2844
+ } catch {
2845
+ return false;
2846
+ }
2847
+ }
2848
+
2849
+ /** Try to settle a lawful Fixer Terminal; undefined only for genuine absence. */
2850
+ export async function trySettleFixerTerminalResult(
2851
+ admitted: AdmittedFixerInvocation,
2852
+ options: {
2853
+ readonly methodProvenance: PackagedMethodSkillProvenance;
2854
+ readonly methodSkillPath: string;
2855
+ readonly methodSkillConfiguredPath: string;
2856
+ },
2857
+ ): Promise<TerminalResult | undefined> {
2858
+ return settleLawfulFixerTerminalResult(admitted, options);
2859
+ }
2860
+
2861
+ export async function hasLawfulFixerTerminalResult(
2862
+ admitted: AdmittedFixerInvocation,
2863
+ ): Promise<boolean> {
2864
+ try {
2865
+ const entries = await readLawfulSettlementEntries(admitted);
2866
+ if (entries === undefined) return false;
2867
+ const extracted = extractFixerRoleOutcome(entries);
2868
+ return extracted !== undefined && isLawfulTypedTerminalOutcome(extracted.outcome);
2869
+ } catch {
2870
+ return false;
2871
+ }
2872
+ }
2873
+
2874
+ /**
2875
+ * Observe forced Reviewer code-review Skill expansions from the session.
2876
+ * Expansion evidence is package-path only; ambient home locations never count.
2877
+ */
2878
+ export function extractReviewerMethodInvocations(
2879
+ entries: readonly SessionEntry[],
2880
+ options: {
2881
+ readonly allowedLocations: readonly string[];
2882
+ },
2883
+ ): readonly ObservedPackagedMethodSkillInvocation[] {
2884
+ const observed: ObservedPackagedMethodSkillInvocation[] = [];
2885
+ for (const entry of entries) {
2886
+ if (entry?.type !== "message") continue;
2887
+ const message = entry.message;
2888
+ if (message?.role !== "user") continue;
2889
+ const text = sessionMessageText(message);
2890
+ if (text.length === 0) continue;
2891
+ const hit = observePackagedMethodSkillInvocation(text, {
2892
+ name: "code-review",
2893
+ allowedLocations: options.allowedLocations,
2894
+ });
2895
+ if (hit !== undefined) observed.push(hit);
2896
+ }
2897
+ return Object.freeze(observed);
2898
+ }
2899
+
2900
+ /**
2901
+ * Publish lawful Reviewer success Artifacts on the shared #106 success interface.
2902
+ * Evidence records package code-review provenance and typed expansion
2903
+ * observation without ambient home Skill paths.
2904
+ */
2905
+ export async function publishReviewerArtifacts(
2906
+ admitted: AdmittedReviewerInvocation,
2907
+ roleOutcome: TerminalRoleOutcome,
2908
+ sessionDirectory: string,
2909
+ options: {
2910
+ readonly methodProvenance: PackagedMethodSkillProvenance;
2911
+ readonly methodInvocations?: readonly ObservedPackagedMethodSkillInvocation[];
2912
+ readonly reviewerReceipt?: RuntimeReviewerReceiptV2;
2913
+ },
2914
+ ): Promise<TerminalArtifactRef[]> {
2915
+ const artifactsDir = await ensureRunArtifactsDir(admitted.runDirectory);
2916
+ const reportPath = join(artifactsDir, "report.json");
2917
+ const evidencePath = join(artifactsDir, "evidence.json");
2918
+ await writeFile(
2919
+ reportPath,
2920
+ `${JSON.stringify(
2921
+ {
2922
+ role: "reviewer",
2923
+ runId: admitted.runId,
2924
+ outcome: roleOutcome,
2925
+ ...(options.reviewerReceipt === undefined
2926
+ ? {}
2927
+ : { receipt: options.reviewerReceipt }),
2928
+ },
2929
+ null,
2930
+ 2,
2931
+ )}\n`,
2932
+ "utf8",
2933
+ );
2934
+ await writeFile(
2935
+ evidencePath,
2936
+ `${JSON.stringify(
2937
+ {
2938
+ runId: admitted.runId,
2939
+ role: "reviewer",
2940
+ sessionDirectory,
2941
+ sessionFile: admitted.sessionFile,
2942
+ admittedRequestPath: admitted.admittedRequestPath,
2943
+ baseRevision: admitted.baseRevision,
2944
+ ...(admitted.instructionEmpty
2945
+ ? {}
2946
+ : { callerProvenance: admitted.instruction }),
2947
+ attachments: admitted.attachments.map((a) => ({
2948
+ provenancePath: a.provenancePath,
2949
+ frozenPath: a.frozenPath,
2950
+ sha256: a.sha256,
2951
+ byteLength: a.byteLength,
2952
+ })),
2953
+ methodProvenance: options.methodProvenance,
2954
+ // Forced package method: availability is package-bound; expansion only when observed.
2955
+ methodInvocationObserved: (options.methodInvocations ?? []).length > 0,
2956
+ methodInvocations: options.methodInvocations ?? [],
2957
+ },
2958
+ null,
2959
+ 2,
2960
+ )}\n`,
2961
+ "utf8",
2962
+ );
2963
+ return [
2964
+ { kind: "report", path: reportPath },
2965
+ { kind: "evidence", path: evidencePath },
2966
+ ];
2967
+ }
2968
+
2969
+ /** Lawful Reviewer accepted outcome extracted from session (shared success interface). */
2970
+ export type LawfulReviewerRoleOutcome =
2971
+ | {
2972
+ kind: "accepted";
2973
+ role: "reviewer";
2974
+ status: string;
2975
+ decisiveFacts: Readonly<Record<string, unknown>>;
2976
+ }
2977
+ | {
2978
+ kind: "audit_escalation";
2979
+ role: "reviewer";
2980
+ status: "audit_escalation";
2981
+ decisiveFacts: Readonly<Record<string, unknown>>;
2982
+ };
2983
+
2984
+ export function extractReviewerRoleOutcome(
2985
+ entries: readonly SessionEntry[],
2986
+ ): { outcome: LawfulReviewerRoleOutcome; receipt?: RuntimeReviewerReceiptV2 } | undefined {
2987
+ if (!isReceiptSettlementBindingClear(entries)) return undefined;
2988
+ for (let i = entries.length - 1; i >= 0; i -= 1) {
2989
+ const entry = entries[i];
2990
+ if (entry?.type !== "message") continue;
2991
+ const message = entry.message;
2992
+ if (message?.role !== "toolResult") continue;
2993
+ if (message.toolName !== REVIEWER_OUTPUT_TOOL_NAME) continue;
2994
+ if (!isAcceptedPackagedRoleTerminalResult(message)) continue;
2995
+ const escalation = boundAuditEscalationForResult(
2996
+ entries,
2997
+ i,
2998
+ message,
2999
+ "reviewer",
3000
+ REVIEWER_OUTPUT_TOOL_NAME,
3001
+ );
3002
+ if (escalation !== undefined) {
3003
+ return {
3004
+ outcome: {
3005
+ kind: "audit_escalation",
3006
+ role: "reviewer",
3007
+ status: "audit_escalation",
3008
+ decisiveFacts: { ...escalation.details },
3009
+ },
3010
+ };
3011
+ }
3012
+ if (isUnboundAuditEscalationFace(message.details)) continue;
3013
+ try {
3014
+ const receipt = validateRuntimeReviewerReceipt(message.details);
3015
+ const outcome: LawfulReviewerRoleOutcome = {
3016
+ kind: "accepted",
3017
+ role: "reviewer",
3018
+ status: receipt.status,
3019
+ decisiveFacts: reviewerDecisiveFacts(receipt),
3020
+ };
3021
+ return { receipt, outcome };
3022
+ } catch {
3023
+ continue;
3024
+ }
3025
+ }
3026
+ return undefined;
3027
+ }
3028
+
3029
+ async function settleLawfulReviewerTerminalResult(
3030
+ admitted: AdmittedReviewerInvocation,
3031
+ options: {
3032
+ readonly methodProvenance: PackagedMethodSkillProvenance;
3033
+ readonly methodSkillPath: string;
3034
+ readonly methodSkillConfiguredPath: string;
3035
+ },
3036
+ ): Promise<TerminalResult | undefined> {
3037
+ const entries = await readLawfulSettlementEntries(admitted);
3038
+ if (entries === undefined) return undefined;
3039
+ const extracted = extractReviewerRoleOutcome(entries);
3040
+ if (extracted === undefined) return undefined;
3041
+ const navigator = extractNavigatorFact(
3042
+ entries,
3043
+ attendanceIdentityFromAdmitted(admitted),
3044
+ );
3045
+ const methodInvocations = extractReviewerMethodInvocations(entries, {
3046
+ allowedLocations: [
3047
+ options.methodSkillPath,
3048
+ options.methodSkillConfiguredPath,
3049
+ ],
3050
+ });
3051
+ const artifacts = await publishReviewerArtifacts(
3052
+ admitted,
3053
+ extracted.outcome,
3054
+ admitted.sessionDirectory,
3055
+ {
3056
+ ...(extracted.receipt === undefined ? {} : { reviewerReceipt: extracted.receipt }),
3057
+ methodProvenance: options.methodProvenance,
3058
+ methodInvocations,
3059
+ },
3060
+ );
3061
+ return {
3062
+ roleOutcome: extracted.outcome,
3063
+ navigator,
3064
+ artifacts,
3065
+ runId: admitted.runId,
3066
+ };
3067
+ }
3068
+
3069
+ /** Settle a lawful Reviewer Terminal from the admitted session (shared #106 success interface). */
3070
+ export async function settleReviewerTerminalResult(
3071
+ admitted: AdmittedReviewerInvocation,
3072
+ options: {
3073
+ readonly methodProvenance: PackagedMethodSkillProvenance;
3074
+ readonly methodSkillPath: string;
3075
+ readonly methodSkillConfiguredPath: string;
3076
+ },
3077
+ ): Promise<TerminalResult> {
3078
+ const settled = await settleLawfulReviewerTerminalResult(admitted, options);
3079
+ if (settled === undefined) {
3080
+ throw new Error(
3081
+ "Reviewer Role run completed without a lawful typed terminal result",
3082
+ );
3083
+ }
3084
+ return settled;
3085
+ }
3086
+
3087
+ /** Try to settle a lawful Reviewer Terminal; undefined only for genuine absence. */
3088
+ export async function trySettleReviewerTerminalResult(
3089
+ admitted: AdmittedReviewerInvocation,
3090
+ options: {
3091
+ readonly methodProvenance: PackagedMethodSkillProvenance;
3092
+ readonly methodSkillPath: string;
3093
+ readonly methodSkillConfiguredPath: string;
3094
+ },
3095
+ ): Promise<TerminalResult | undefined> {
3096
+ return settleLawfulReviewerTerminalResult(admitted, options);
3097
+ }
3098
+
3099
+ export async function hasLawfulReviewerTerminalResult(
3100
+ admitted: AdmittedReviewerInvocation,
3101
+ ): Promise<boolean> {
3102
+ try {
3103
+ const entries = await readLawfulSettlementEntries(admitted);
3104
+ if (entries === undefined) return false;
3105
+ const extracted = extractReviewerRoleOutcome(entries);
3106
+ return extracted !== undefined && isLawfulTypedTerminalOutcome(extracted.outcome);
3107
+ } catch {
3108
+ return false;
3109
+ }
3110
+ }
3111
+
3112
+ function mergerDecisiveFacts(output: MergerOutput): Record<string, unknown> {
3113
+ const candidate = output as unknown as object;
3114
+ const facts: Record<string, unknown> = {};
3115
+ const status = safelyRead(candidate, "status");
3116
+ const attemptId = safelyRead(candidate, "attemptId");
3117
+ if (status.readable && typeof status.value === "string") facts.mergerStatus = status.value;
3118
+ if (attemptId.readable && attemptId.value !== undefined) facts.attemptId = attemptId.value;
3119
+ const decisiveKey = status.readable && status.value === "completed" ? "mergeCommitId" : "diagnosis";
3120
+ const decisive = safelyRead(candidate, decisiveKey);
3121
+ if (decisive.readable && decisive.value !== undefined) facts[decisiveKey] = decisive.value;
3122
+ return facts;
3123
+ }
3124
+
3125
+ /**
3126
+ * Observe forced Merger resolving-merge-conflicts Skill expansions from the session.
3127
+ * Expansion evidence is package-path only; ambient home locations never count.
3128
+ */
3129
+ export function extractMergerMethodInvocations(
3130
+ entries: readonly SessionEntry[],
3131
+ options: {
3132
+ readonly allowedLocations: readonly string[];
3133
+ },
3134
+ ): readonly ObservedPackagedMethodSkillInvocation[] {
3135
+ const observed: ObservedPackagedMethodSkillInvocation[] = [];
3136
+ for (const entry of entries) {
3137
+ if (entry?.type !== "message") continue;
3138
+ const message = entry.message;
3139
+ if (message?.role !== "user") continue;
3140
+ const text = sessionMessageText(message);
3141
+ if (text.length === 0) continue;
3142
+ const hit = observePackagedMethodSkillInvocation(text, {
3143
+ name: "resolving-merge-conflicts",
3144
+ allowedLocations: options.allowedLocations,
3145
+ });
3146
+ if (hit !== undefined) observed.push(hit);
3147
+ }
3148
+ return Object.freeze(observed);
3149
+ }
3150
+
3151
+ /**
3152
+ * Publish lawful Merger success Artifacts on the shared #106 success interface.
3153
+ * Evidence records package method provenance, forced expansion observation, and
3154
+ * adapter-derived mechanical envelope facts without ambient home Skill paths.
3155
+ */
3156
+ export async function publishMergerArtifacts(
3157
+ admitted: AdmittedMergerInvocation,
3158
+ roleOutcome: TerminalRoleOutcome,
3159
+ sessionDirectory: string,
3160
+ options: {
3161
+ readonly methodProvenance: PackagedMethodSkillProvenance;
3162
+ readonly methodInvocations?: readonly ObservedPackagedMethodSkillInvocation[];
3163
+ readonly mergerOutput?: MergerOutput;
3164
+ },
3165
+ ): Promise<TerminalArtifactRef[]> {
3166
+ const artifactsDir = await ensureRunArtifactsDir(admitted.runDirectory);
3167
+ const reportPath = join(artifactsDir, "report.json");
3168
+ const evidencePath = join(artifactsDir, "evidence.json");
3169
+ await writeFile(
3170
+ reportPath,
3171
+ `${JSON.stringify(
3172
+ {
3173
+ role: "merger",
3174
+ runId: admitted.runId,
3175
+ outcome: roleOutcome,
3176
+ ...(options.mergerOutput === undefined
3177
+ ? {}
3178
+ : { receipt: options.mergerOutput }),
3179
+ },
3180
+ null,
3181
+ 2,
3182
+ )}\n`,
3183
+ "utf8",
3184
+ );
3185
+ await writeFile(
3186
+ evidencePath,
3187
+ `${JSON.stringify(
3188
+ {
3189
+ runId: admitted.runId,
3190
+ role: "merger",
3191
+ sessionDirectory,
3192
+ sessionFile: admitted.sessionFile,
3193
+ admittedRequestPath: admitted.admittedRequestPath,
3194
+ mergerInputPath: admitted.mergerInputPath,
3195
+ derived: admitted.derived,
3196
+ attachments: admitted.attachments.map((a) => ({
3197
+ provenancePath: a.provenancePath,
3198
+ frozenPath: a.frozenPath,
3199
+ sha256: a.sha256,
3200
+ byteLength: a.byteLength,
3201
+ })),
3202
+ methodProvenance: options.methodProvenance,
3203
+ methodInvocationObserved: (options.methodInvocations ?? []).length > 0,
3204
+ methodInvocations: options.methodInvocations ?? [],
3205
+ },
3206
+ null,
3207
+ 2,
3208
+ )}\n`,
3209
+ "utf8",
3210
+ );
3211
+ return [
3212
+ { kind: "report", path: reportPath },
3213
+ { kind: "evidence", path: evidencePath },
3214
+ ];
3215
+ }
3216
+
3217
+ /** Lawful Merger accepted outcome extracted from session (shared success interface). */
3218
+ export type LawfulMergerRoleOutcome = {
3219
+ kind: "accepted";
3220
+ role: "merger";
3221
+ status: string;
3222
+ decisiveFacts: Readonly<Record<string, unknown>>;
3223
+ };
3224
+
3225
+ export function extractMergerRoleOutcome(
3226
+ entries: readonly SessionEntry[],
3227
+ ): { outcome: LawfulMergerRoleOutcome; output: MergerOutput } | undefined {
3228
+ if (!isReceiptSettlementBindingClear(entries)) return undefined;
3229
+ for (let i = entries.length - 1; i >= 0; i -= 1) {
3230
+ const entry = entries[i];
3231
+ if (entry?.type !== "message") continue;
3232
+ const message = entry.message;
3233
+ if (message?.role !== "toolResult") continue;
3234
+ if (message.toolName !== MERGER_OUTPUT_TOOL_NAME) continue;
3235
+ if (!isAcceptedPackagedRoleTerminalResult(message)) continue;
3236
+ try {
3237
+ const output = validateMergerOutput(message.details);
3238
+ const outcome: LawfulMergerRoleOutcome = {
3239
+ kind: "accepted",
3240
+ role: "merger",
3241
+ status: output.status,
3242
+ decisiveFacts: mergerDecisiveFacts(output),
3243
+ };
3244
+ return { output, outcome };
3245
+ } catch {
3246
+ continue;
3247
+ }
3248
+ }
3249
+ return undefined;
3250
+ }
3251
+
3252
+ async function settleLawfulMergerTerminalResult(
3253
+ admitted: AdmittedMergerInvocation,
3254
+ options: {
3255
+ readonly methodProvenance: PackagedMethodSkillProvenance;
3256
+ readonly methodSkillPath: string;
3257
+ readonly methodSkillConfiguredPath: string;
3258
+ },
3259
+ ): Promise<TerminalResult | undefined> {
3260
+ const entries = await readLawfulSettlementEntries(admitted);
3261
+ if (entries === undefined) return undefined;
3262
+ const extracted = extractMergerRoleOutcome(entries);
3263
+ if (extracted === undefined) {
3264
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
3265
+ const message = entries[index]?.message;
3266
+ if (message?.role !== "toolResult") continue;
3267
+ const residual = boundErroredToolCandidate(entries, index, message, MERGER_OUTPUT_TOOL_NAME);
3268
+ if (residual === undefined) continue;
3269
+ const callMessage = entries[residual.callIndex]?.message;
3270
+ const calls = callMessage?.role === "assistant" && Array.isArray(callMessage.content)
3271
+ ? callMessage.content.filter((part) => isRecord(part) && part.type === "toolCall")
3272
+ : [];
3273
+ const attemptId = isRecord(residual.candidate)
3274
+ ? safelyRead(residual.candidate, "attemptId")
3275
+ : { readable: true as const, value: undefined };
3276
+ // Mirror the execution boundary's established precedence: ADR 0041 sole-final,
3277
+ // then ADR 0037 admitted-attempt identity, and only then output shape.
3278
+ if (
3279
+ calls.length !== 1 ||
3280
+ calls[0]?.name !== MERGER_OUTPUT_TOOL_NAME ||
3281
+ !attemptId.readable ||
3282
+ attemptId.value !== admitted.runId
3283
+ ) {
3284
+ continue;
3285
+ }
3286
+ try {
3287
+ validateMergerOutput(residual.candidate, admitted.runId);
3288
+ } catch {
3289
+ return {
3290
+ roleOutcome: buildResidualIncompleteTerminalOutcome({
3291
+ role: "merger",
3292
+ candidate: residual.candidate,
3293
+ diagnostic: residual.diagnostic,
3294
+ }),
3295
+ navigator: { disposition: "no-advice" },
3296
+ artifacts: [],
3297
+ runId: admitted.runId,
3298
+ };
3299
+ }
3300
+ }
3301
+ return undefined;
3302
+ }
3303
+ const methodInvocations = extractMergerMethodInvocations(entries, {
3304
+ allowedLocations: [
3305
+ options.methodSkillPath,
3306
+ options.methodSkillConfiguredPath,
3307
+ ],
3308
+ });
3309
+ // Every invocation must expand the merge-only method before conflict work.
3310
+ if (methodInvocations.length === 0) return undefined;
3311
+ const navigator = extractNavigatorFact(
3312
+ entries,
3313
+ attendanceIdentityFromAdmitted(admitted),
3314
+ );
3315
+ const artifacts = await publishMergerArtifacts(
3316
+ admitted,
3317
+ extracted.outcome,
3318
+ admitted.sessionDirectory,
3319
+ {
3320
+ mergerOutput: extracted.output,
3321
+ methodProvenance: options.methodProvenance,
3322
+ methodInvocations,
3323
+ },
3324
+ );
3325
+ return {
3326
+ roleOutcome: extracted.outcome,
3327
+ navigator,
3328
+ artifacts,
3329
+ runId: admitted.runId,
3330
+ };
3331
+ }
3332
+
3333
+ /** Settle a lawful Merger Terminal from the admitted session (shared #106 success interface). */
3334
+ export async function settleMergerTerminalResult(
3335
+ admitted: AdmittedMergerInvocation,
3336
+ options: {
3337
+ readonly methodProvenance: PackagedMethodSkillProvenance;
3338
+ readonly methodSkillPath: string;
3339
+ readonly methodSkillConfiguredPath: string;
3340
+ },
3341
+ ): Promise<TerminalResult> {
3342
+ const settled = await settleLawfulMergerTerminalResult(admitted, options);
3343
+ if (settled === undefined) {
3344
+ throw new Error(
3345
+ "Merger Role run completed without a lawful typed terminal result",
3346
+ );
3347
+ }
3348
+ return settled;
3349
+ }
3350
+
3351
+ /** Try to settle a lawful Merger Terminal; undefined only for genuine absence. */
3352
+ export async function trySettleMergerTerminalResult(
3353
+ admitted: AdmittedMergerInvocation,
3354
+ options: {
3355
+ readonly methodProvenance: PackagedMethodSkillProvenance;
3356
+ readonly methodSkillPath: string;
3357
+ readonly methodSkillConfiguredPath: string;
3358
+ },
3359
+ ): Promise<TerminalResult | undefined> {
3360
+ return settleLawfulMergerTerminalResult(admitted, options);
3361
+ }
3362
+
3363
+ export async function hasLawfulMergerTerminalResult(
3364
+ admitted: AdmittedMergerInvocation,
3365
+ ): Promise<boolean> {
3366
+ try {
3367
+ const entries = await readLawfulSettlementEntries(admitted);
3368
+ if (entries === undefined) return false;
3369
+ const extracted = extractMergerRoleOutcome(entries);
3370
+ return extracted !== undefined && isLawfulTypedTerminalOutcome(extracted.outcome);
3371
+ } catch {
3372
+ return false;
3373
+ }
3374
+ }
3375
+
3376
+ /** One failed attempt to place a durable failure artifact (path is private layout). */
3377
+ type PublicationAttempt = {
3378
+ readonly path: string;
3379
+ readonly diagnostic: string;
3380
+ readonly identity?: {
3381
+ readonly name?: string;
3382
+ readonly code?: string | number;
3383
+ };
3384
+ };
3385
+
3386
+ function publicationAttemptFromError(
3387
+ path: string,
3388
+ error: unknown,
3389
+ ): PublicationAttempt {
3390
+ if (error instanceof Error) {
3391
+ const identity: { name?: string; code?: string | number } = {
3392
+ name: error.name,
3393
+ };
3394
+ const code = (error as { code?: unknown }).code;
3395
+ if (typeof code === "string" || typeof code === "number") {
3396
+ identity.code = code;
3397
+ }
3398
+ return {
3399
+ path,
3400
+ diagnostic: error.message || error.name || "write failed",
3401
+ identity,
3402
+ };
3403
+ }
3404
+ return { path, diagnostic: String(error) };
3405
+ }
3406
+
3407
+ /**
3408
+ * Directories eligible for open-ended unique failure-artifact placement.
3409
+ * Always includes the ledger runs/ parent of the run directory so an
3410
+ * unwritable run tree cannot strand the original controlled failure.
3411
+ */
3412
+ function uniqueFailureFallbackDirs(
3413
+ runDirectory: string,
3414
+ baseDir: string,
3415
+ ): string[] {
3416
+ const dirs: string[] = [];
3417
+ for (const dir of [baseDir, runDirectory, dirname(runDirectory)]) {
3418
+ if (!dirs.includes(dir)) dirs.push(dir);
3419
+ }
3420
+ return dirs;
3421
+ }
3422
+
3423
+ /**
3424
+ * Resolve a writable artifacts base directory. If `artifacts/` cannot be created
3425
+ * (e.g. a file occupies that name), fall back to the run directory itself.
3426
+ */
3427
+ async function resolveFailureArtifactsBase(
3428
+ runDirectory: string,
3429
+ ): Promise<{ baseDir: string; attempt?: PublicationAttempt }> {
3430
+ const artifactsDir = join(runDirectory, "artifacts");
3431
+ try {
3432
+ await ensureRunArtifactsDir(runDirectory);
3433
+ return { baseDir: artifactsDir };
3434
+ } catch (error) {
3435
+ return {
3436
+ baseDir: runDirectory,
3437
+ attempt: publicationAttemptFromError(artifactsDir, error),
3438
+ };
3439
+ }
3440
+ }
3441
+
3442
+ /**
3443
+ * Write JSON across preferred paths, then unique open-ended fallbacks.
3444
+ * Finite fixed names must not be able to exhaust durability and strand the
3445
+ * original controlled failure outside settlement.
3446
+ */
3447
+ async function writeFailureJsonRetainingCause(
3448
+ preferredCandidates: readonly string[],
3449
+ uniqueFallbackDirs: readonly string[],
3450
+ stem: string,
3451
+ basePayload: Readonly<Record<string, unknown>>,
3452
+ priorIssues: readonly PublicationAttempt[],
3453
+ ): Promise<{ path: string; issues: PublicationAttempt[] }> {
3454
+ const issues: PublicationAttempt[] = [...priorIssues];
3455
+ const candidates: string[] = [
3456
+ ...preferredCandidates,
3457
+ // One unique name per fallback dir — collisions on fixed names cannot exhaust this.
3458
+ ...uniqueFallbackDirs.map((dir) => join(dir, `${stem}.${randomUUID()}.json`)),
3459
+ ];
3460
+ for (let i = 0; i < candidates.length; i += 1) {
3461
+ const path = candidates[i]!;
3462
+ const payload =
3463
+ issues.length === 0
3464
+ ? basePayload
3465
+ : { ...basePayload, publicationIssues: issues };
3466
+ try {
3467
+ await writeFile(
3468
+ path,
3469
+ `${JSON.stringify(payload, null, 2)}\n`,
3470
+ "utf8",
3471
+ );
3472
+ return { path, issues };
3473
+ } catch (error) {
3474
+ issues.push(publicationAttemptFromError(path, error));
3475
+ }
3476
+ }
3477
+ const last = issues.at(-1);
3478
+ const error = new Error(
3479
+ last?.diagnostic ?? "unable to write durable failure artifact",
3480
+ ) as Error & {
3481
+ code?: string | number;
3482
+ publicationAttempts?: PublicationAttempt[];
3483
+ };
3484
+ if (last?.identity?.name !== undefined && last.identity.name !== "") {
3485
+ error.name = last.identity.name;
3486
+ }
3487
+ if (last?.identity?.code !== undefined) {
3488
+ error.code = last.identity.code;
3489
+ }
3490
+ error.publicationAttempts = issues;
3491
+ throw error;
3492
+ }
3493
+
3494
+ export async function publishFailureArtifacts(
3495
+ admitted: AdmittedRoleInvocation,
3496
+ failure: ControlledFailure,
3497
+ ): Promise<TerminalArtifactRef[]> {
3498
+ const { baseDir, attempt: baseAttempt } = await resolveFailureArtifactsBase(
3499
+ admitted.runDirectory,
3500
+ );
3501
+ const priorIssues: PublicationAttempt[] =
3502
+ baseAttempt === undefined ? [] : [baseAttempt];
3503
+
3504
+ // Prefer conventional names; unique fallback dirs keep colliding fixed paths
3505
+ // from stranding the original failure outside settlement. Include the ledger
3506
+ // runs/ parent so a locked run directory (EACCES) cannot exhaust durability.
3507
+ const underArtifacts = baseDir === join(admitted.runDirectory, "artifacts");
3508
+ const uniqueFallbackDirs = uniqueFailureFallbackDirs(
3509
+ admitted.runDirectory,
3510
+ baseDir,
3511
+ );
3512
+ const errorCandidates = underArtifacts
3513
+ ? [
3514
+ join(baseDir, "error.json"),
3515
+ join(baseDir, "error.settlement.json"),
3516
+ join(admitted.runDirectory, "error.settlement.json"),
3517
+ ]
3518
+ : [
3519
+ join(baseDir, "error.settlement.json"),
3520
+ join(baseDir, "error.json"),
3521
+ ];
3522
+ const evidenceCandidates = underArtifacts
3523
+ ? [
3524
+ join(baseDir, "evidence.json"),
3525
+ join(baseDir, "evidence.settlement.json"),
3526
+ join(admitted.runDirectory, "evidence.settlement.json"),
3527
+ ]
3528
+ : [
3529
+ join(baseDir, "evidence.settlement.json"),
3530
+ join(baseDir, "evidence.json"),
3531
+ ];
3532
+
3533
+ const errorPayloadBase: Record<string, unknown> = {
3534
+ kind: "error",
3535
+ role: admitted.role,
3536
+ runId: admitted.runId,
3537
+ cause: failure.cause,
3538
+ diagnostic: failure.diagnostic,
3539
+ ...(failure.identity === undefined ? {} : { identity: failure.identity }),
3540
+ ...(failure.details === undefined ? {} : { details: failure.details }),
3541
+ };
3542
+
3543
+ const errorWrite = await writeFailureJsonRetainingCause(
3544
+ errorCandidates,
3545
+ uniqueFallbackDirs,
3546
+ "error",
3547
+ errorPayloadBase,
3548
+ priorIssues,
3549
+ );
3550
+
3551
+ const evidencePayload: Record<string, unknown> = {
3552
+ runId: admitted.runId,
3553
+ sessionDirectory: admitted.sessionDirectory,
3554
+ sessionFile: admitted.sessionFile,
3555
+ admittedRequestPath: admitted.admittedRequestPath,
3556
+ attachments: admitted.attachments.map((a) => ({
3557
+ provenancePath: a.provenancePath,
3558
+ frozenPath: a.frozenPath,
3559
+ sha256: a.sha256,
3560
+ byteLength: a.byteLength,
3561
+ })),
3562
+ failureCause: failure.cause,
3563
+ };
3564
+ const evidenceWrite = await writeFailureJsonRetainingCause(
3565
+ evidenceCandidates,
3566
+ uniqueFallbackDirs,
3567
+ "evidence",
3568
+ evidencePayload,
3569
+ // Evidence records the same publication collisions observed placing the error body.
3570
+ errorWrite.issues,
3571
+ );
3572
+
3573
+ return [
3574
+ { kind: "error", path: errorWrite.path },
3575
+ { kind: "evidence", path: evidenceWrite.path },
3576
+ ];
3577
+ }
3578
+
3579
+ /**
3580
+ * Durably record a controlled failure (Error Artifact first), then return the
3581
+ * Terminal aggregate. Presentation must happen only after this resolves.
3582
+ */
3583
+ /** Redact exact run ID from any string leaves inside decisive facts (arrays/objects included). */
3584
+ function redactDecisiveFactValue(value: unknown, runId: string): unknown {
3585
+ if (typeof value === "string") return redactExactRunId(value, runId);
3586
+ if (Array.isArray(value)) {
3587
+ return value.map((entry) => redactDecisiveFactValue(entry, runId));
3588
+ }
3589
+ if (typeof value === "object" && value !== null) {
3590
+ const out: Record<string, unknown> = {};
3591
+ for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
3592
+ out[key] = redactDecisiveFactValue(child, runId);
3593
+ }
3594
+ return out;
3595
+ }
3596
+ return value;
3597
+ }
3598
+
3599
+ /** Redact exact run ID from decisive facts at the public Terminal boundary. */
3600
+ function redactDecisiveFactsForPublicTerminal(
3601
+ facts: Readonly<Record<string, unknown>>,
3602
+ runId: string,
3603
+ ): Record<string, unknown> {
3604
+ const out: Record<string, unknown> = {};
3605
+ for (const [key, value] of Object.entries(facts)) {
3606
+ out[key] = redactDecisiveFactValue(value, runId);
3607
+ }
3608
+ return out;
3609
+ }
3610
+
3611
+ /** Redact exact run ID from navigator free-text fields (reason only; commands are registry-owned). */
3612
+ function redactNavigatorFactForPublicTerminal(
3613
+ navigator: TerminalNavigatorFact,
3614
+ runId: string,
3615
+ ): TerminalNavigatorFact {
3616
+ const advisoryDiagnostic = navigator.advisoryDiagnostic === undefined
3617
+ ? {}
3618
+ : { advisoryDiagnostic: redactExactRunId(navigator.advisoryDiagnostic, runId) };
3619
+ if (navigator.disposition === "recommendation") {
3620
+ return {
3621
+ ...navigator,
3622
+ ...advisoryDiagnostic,
3623
+ reason: redactExactRunId(navigator.reason, runId),
3624
+ };
3625
+ }
3626
+ if (navigator.disposition === "unavailable") {
3627
+ return {
3628
+ ...navigator,
3629
+ ...advisoryDiagnostic,
3630
+ reason: redactExactRunId(navigator.reason, runId),
3631
+ };
3632
+ }
3633
+ return { ...navigator, ...advisoryDiagnostic };
3634
+ }
3635
+
3636
+ /**
3637
+ * Durably record a controlled failure (Error Artifact first), then return the
3638
+ * Terminal aggregate. Presentation must happen only after this resolves.
3639
+ */
3640
+ /**
3641
+ * Shared controlled-failure Terminal settlement (#107 ownership).
3642
+ * Role identity comes from the admitted run; no new failure classes are introduced here.
3643
+ */
3644
+ export async function settleFailureTerminalResult(
3645
+ admitted: AdmittedRoleInvocation,
3646
+ failure: ControlledFailure,
3647
+ options: { readonly resume?: TerminalResume } = {},
3648
+ ): Promise<TerminalResult> {
3649
+ // Exact-session attendance only — never infer no-advice from caller omission.
3650
+ const navigator = await extractNavigatorFactFromAdmittedSession(admitted);
3651
+ // Private durable artifacts retain the original diagnostic identity (including run ID).
3652
+ const artifacts = await publishFailureArtifacts(admitted, failure);
3653
+ const decisiveFacts: Record<string, unknown> = {
3654
+ cause: failure.cause,
3655
+ diagnostic: failure.diagnostic,
3656
+ };
3657
+ if (failure.identity?.name !== undefined) {
3658
+ decisiveFacts.errorName = failure.identity.name;
3659
+ }
3660
+ if (failure.identity?.code !== undefined) {
3661
+ decisiveFacts.errorCode = failure.identity.code;
3662
+ }
3663
+ if (failure.details !== undefined) {
3664
+ decisiveFacts.secondaryEvidence = failure.details;
3665
+ }
3666
+ // Resumable failures: durable artifacts still land under the run directory, but
3667
+ // the public Terminal must not re-disclose the run ID via top-level runId,
3668
+ // path components, or untrusted free text — only resume.command may carry it
3669
+ // (AC2 / #108).
3670
+ if (options.resume !== undefined) {
3671
+ const publicDiagnostic = redactExactRunId(failure.diagnostic, admitted.runId);
3672
+ const publicFacts = redactDecisiveFactsForPublicTerminal(
3673
+ { ...decisiveFacts, diagnostic: publicDiagnostic },
3674
+ admitted.runId,
3675
+ );
3676
+ const roleOutcome: TerminalRoleOutcome = {
3677
+ kind: "failure",
3678
+ role: admitted.role,
3679
+ cause: failure.cause,
3680
+ diagnostic: publicDiagnostic,
3681
+ decisiveFacts: publicFacts,
3682
+ };
3683
+ return {
3684
+ roleOutcome,
3685
+ navigator: redactNavigatorFactForPublicTerminal(navigator, admitted.runId),
3686
+ artifacts: [],
3687
+ resume: options.resume,
3688
+ };
3689
+ }
3690
+ const roleOutcome: TerminalRoleOutcome = {
3691
+ kind: "failure",
3692
+ role: admitted.role,
3693
+ cause: failure.cause,
3694
+ diagnostic: failure.diagnostic,
3695
+ decisiveFacts,
3696
+ };
3697
+ return {
3698
+ roleOutcome,
3699
+ navigator,
3700
+ artifacts,
3701
+ runId: admitted.runId,
3702
+ };
3703
+ }
3704
+
3705
+ /** Judge-named alias retained for #107 call sites. */
3706
+ export async function settleJudgeFailureTerminalResult(
3707
+ admitted: AdmittedJudgeInvocation,
3708
+ failure: ControlledFailure,
3709
+ options: { readonly resume?: TerminalResume } = {},
3710
+ ): Promise<TerminalResult> {
3711
+ return settleFailureTerminalResult(admitted, failure, options);
3712
+ }
3713
+
3714
+ /**
3715
+ * Emit one complete failure Terminal on stdout and one concise stderr diagnostic.
3716
+ * Artifacts are already durable on the TerminalResult.
3717
+ */
3718
+ export function presentFailureTerminal(
3719
+ terminal: TerminalResult,
3720
+ io: { stdout: (text: string) => void; stderr: (text: string) => void },
3721
+ ): void {
3722
+ if (terminal.roleOutcome.kind !== "failure") {
3723
+ throw new TypeError("presentFailureTerminal requires a failure role outcome");
3724
+ }
3725
+ io.stdout(formatTerminalResult(terminal));
3726
+ io.stderr(
3727
+ formatFailureStderrDiagnostic({
3728
+ cause: terminal.roleOutcome.cause,
3729
+ diagnostic: terminal.roleOutcome.diagnostic,
3730
+ }),
3731
+ );
3732
+ }
3733
+
3734
+ /**
3735
+ * Race a promise against the post-role Navigator grace.
3736
+ * On timeout, returns the timeout sentinel; the caller records unavailable and
3737
+ * ignores or disposes late completion.
3738
+ */
3739
+ export function raceNavigatorGrace<T>(
3740
+ work: Promise<T>,
3741
+ graceMs: number = NAVIGATOR_POST_ROLE_GRACE_MS,
3742
+ sleep: (ms: number) => Promise<void> = (ms) =>
3743
+ new Promise((resolve) => setTimeout(resolve, ms)),
3744
+ ): Promise<{ status: "done"; value: T } | { status: "timeout" }> {
3745
+ return new Promise((resolve, reject) => {
3746
+ let settled = false;
3747
+ void work.then(
3748
+ (value) => {
3749
+ if (settled) return;
3750
+ settled = true;
3751
+ resolve({ status: "done", value });
3752
+ },
3753
+ (error) => {
3754
+ if (settled) return;
3755
+ settled = true;
3756
+ reject(error);
3757
+ },
3758
+ );
3759
+ void sleep(graceMs).then(() => {
3760
+ if (settled) return;
3761
+ settled = true;
3762
+ resolve({ status: "timeout" });
3763
+ });
3764
+ });
3765
+ }