@akagilnc/pi-workflow-roles 0.1.5406 → 0.1.5475

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 (93) hide show
  1. package/CLAUDE.md +1 -1
  2. package/README.md +42 -55
  3. package/README.zh-CN.md +59 -73
  4. package/THIRD_PARTY_NOTICES.md +4 -7
  5. package/dist/acp-host/production-host.js +8261 -9073
  6. package/dist/collector-tool-schemas.js +1 -1
  7. package/dist/compliance-transport.js +2 -5
  8. package/dist/engine-detour-usage.js +3 -30
  9. package/dist/gatekeeper-role.js +4 -18
  10. package/dist/headless-host/description.js +0 -209
  11. package/dist/headless-host/production-host.js +6973 -8035
  12. package/dist/host-contracts.js +8 -0
  13. package/dist/migrate-book-topology.js +30 -27
  14. package/dist/packaged-role-registry.js +1 -9
  15. package/dist/pi/role-turn-host.js +37 -19
  16. package/dist/public-cli/auto-resume.js +29 -66
  17. package/dist/public-cli/countersign-run.js +4 -18
  18. package/dist/public-cli/instruction-seat-run.js +59 -76
  19. package/dist/public-cli/main.js +9844 -10720
  20. package/dist/public-cli/option-definitions.js +1 -1
  21. package/dist/public-cli/post-admission.js +204 -285
  22. package/dist/public-cli/process-cancel.js +8 -0
  23. package/dist/public-cli/run-lifecycle.js +24 -94
  24. package/dist/public-cli/settlement.js +614 -1524
  25. package/dist/public-cli/terminal.js +23 -53
  26. package/dist/public-role-summons.js +3 -8
  27. package/dist/receipt-delivery-policy.js +11 -12
  28. package/dist/run-terminal-artifacts.js +19 -14
  29. package/dist/serialize-thrown-value.js +7 -1
  30. package/dist/submission-ledger.js +21 -89
  31. package/package.json +1 -1
  32. package/packets/fixer-repair.md +3 -1
  33. package/packets/judge-apply.md +7 -16
  34. package/packets/judge-authority.md +7 -17
  35. package/packets/judge-plan.md +7 -18
  36. package/packets/judge-review.md +8 -22
  37. package/packets/judge-submission.md +4 -7
  38. package/resources/collector-bot-handbook.md +2 -2
  39. package/resources/diarist-collect.md +2 -2
  40. package/resources/engine-dispatch.md +2 -2
  41. package/resources/navigator-route-playbook.md +1 -1
  42. package/resources/secretariat-ticket-identity.md +1 -1
  43. package/scripts/build-package.mjs +0 -2
  44. package/souls/coder-output-guide.md +3 -3
  45. package/souls/coder.md +2 -3
  46. package/souls/doctor-auditor.md +0 -3
  47. package/souls/fixer-auditor.md +0 -2
  48. package/souls/fixer-output-guide.md +3 -3
  49. package/souls/fixer.md +2 -2
  50. package/souls/gate-output-guide.md +1 -1
  51. package/souls/quality-law.md +2 -0
  52. package/src/acp-host/role-turn-host.ts +168 -75
  53. package/src/collector-tool-schemas.ts +1 -1
  54. package/src/compliance-transport.ts +3 -6
  55. package/src/engine-detour-usage.ts +3 -33
  56. package/src/external-host-turn-loop.ts +81 -36
  57. package/src/gatekeeper-role.ts +5 -20
  58. package/src/headless-host/description.ts +4 -214
  59. package/src/headless-host/production-host.ts +2 -1
  60. package/src/headless-host/role-turn-host.ts +153 -186
  61. package/src/host-contracts.ts +13 -10
  62. package/src/navigator-public-session.ts +1 -1
  63. package/src/navigator-session-contracts.ts +1 -1
  64. package/src/package-contracts/gatekeeper-output.ts +1 -1
  65. package/src/package-contracts/navigator-output.ts +1 -1
  66. package/src/package-contracts/terminating-infrastructure.ts +1 -1
  67. package/src/packaged-role-registry.ts +2 -10
  68. package/src/pi/adapter.ts +0 -3
  69. package/src/pi/role-turn-host.ts +42 -28
  70. package/src/prepared-role-turn.ts +2 -0
  71. package/src/public-cli/auto-resume.ts +27 -66
  72. package/src/public-cli/countersign-run.ts +4 -22
  73. package/src/public-cli/instruction-seat-run.ts +79 -80
  74. package/src/public-cli/option-definitions.ts +1 -1
  75. package/src/public-cli/post-admission.ts +264 -340
  76. package/src/public-cli/process-cancel.ts +9 -0
  77. package/src/public-cli/run-lifecycle.ts +25 -126
  78. package/src/public-cli/settlement.ts +792 -1912
  79. package/src/public-cli/terminal.ts +29 -69
  80. package/src/public-role-summons.ts +3 -8
  81. package/src/receipt-delivery-policy.ts +11 -12
  82. package/src/role-envelope.ts +16 -9
  83. package/src/role-runtime.ts +14 -81
  84. package/src/run-terminal-artifacts.ts +21 -17
  85. package/src/serialize-thrown-value.ts +7 -1
  86. package/src/submission-ledger.ts +30 -116
  87. package/dist/audit-escalation.js +0 -7
  88. package/dist/pi/known-failure.js +0 -51
  89. package/dist/typed-provider-http.js +0 -57
  90. package/dist/upstream-error-testimony.js +0 -19
  91. package/src/pi/known-failure.ts +0 -76
  92. package/src/typed-provider-http.ts +0 -89
  93. package/src/upstream-error-testimony.ts +0 -45
@@ -4,92 +4,61 @@
4
4
  * Controlled failures and audit human decisions settle here without washing causes.
5
5
  */
6
6
  import { randomUUID } from "node:crypto";
7
- import { appendFile, lstat, readFile, readdir, rm, unlink, writeFile } from "node:fs/promises";
8
7
  import { dirname, join } from "node:path";
8
+ import { lstat, mkdir, readFile, rm, writeFile } from "node:fs/promises";
9
9
 
10
+ import { writeHardenedArtifactFile } from "./auto-resume.ts";
10
11
  import {
11
12
  readAnalystGateCyclesFromAuditorRoles,
12
13
  type AnalystGateCycleRound,
13
14
  } from "../analyst-gate-cycles-read.ts";
14
- import { readSitianRecords, resolveSitianRecordPath, sitianReport } from "../sitian-facade.ts";
15
+ import { sitianReport } from "../sitian-facade.ts";
15
16
 
16
17
  import {
17
- hasFreshAttemptSubmission,
18
18
  readAttemptScopedSubmissionRows,
19
19
  readRecordedSubmissionRows,
20
20
  readRecordedSubmissions,
21
21
  } from "../submission-ledger.ts";
22
22
 
23
- import { isAuditEscalationResult } from "../audit-escalation.ts";
24
- import { AUDITOR_SOUL_ROLES } from "../auditor-soul.ts";
25
- import type { RoleTurnKnownFailure } from "../host-contracts.ts";
26
- import { knownFailureFromProviderStop } from "../pi/known-failure.ts";
27
- import { serializeThrownValue } from "../serialize-thrown-value.ts";
28
- import {
29
- isV1ResumableProvider,
30
- readLatestTypedProviderHttpObservation,
31
- readTypedHttp429Observation,
32
- type TypedHttp429Observation,
33
- type TypedProviderHttpObservation,
34
- } from "./run-lifecycle.ts";
23
+ import { CONTROLLED_FAILURE_CAUSES, type RoleTurnKnownFailure } from "../host-contracts.ts";
35
24
  import {
36
- AUDITOR_COMPLIANCE_FAILURE_ENTRY_TYPE,
37
- AUDITOR_PARENT_ATTEMPT_BINDING_ENTRY_TYPE,
38
- COMPLIANCE_RESPONSE_ENTRY_TYPE,
39
- readComplianceCandidate,
40
- type ComplianceDecision,
41
- } from "../compliance-transport.ts";
42
- // COMPLIANCE_RESPONSE_ENTRY_TYPE remains for boundRetainedAuditResponse (call/result
43
- // interval binding on historical session bytes). Provider-stop retain authority is Sitian.
44
- import {
45
- ENGINE_DETOUR_TOOL_NAME,
46
- } from "../engine-detour.ts";
25
+ childSignalDeathDiagnostic,
26
+ processCancelDiagnostic,
27
+ type CatchableProcessSignal,
28
+ } from "./process-cancel.ts";
29
+ import { serializeThrownValue } from "../serialize-thrown-value.ts";
30
+ import { describeErrorIdentity } from "./run-lifecycle.ts";
47
31
  import {
48
- projectEngineDetourToolUsageForPublicTerminal,
49
32
  readEngineDetourToolUsage,
50
33
  readInvocationEngineMounted,
51
34
  runDirectoryFromSessionDirectory,
52
35
  sessionFileFromSessionDirectory,
53
36
  withEngineDetourToolUsageFact,
54
37
  } from "../engine-detour-usage.ts";
55
- import {
56
- JUDGE_OUTPUT_TOOL_NAME,
57
- type JudgeVerdict,
58
- } from "../package-contracts/judge-output.ts";
59
- import {
60
- CODER_OUTPUT_TOOL_NAME,
61
- FIXER_OUTPUT_TOOL_NAME,
62
- } from "../package-contracts/worker-output.ts";
63
38
 
64
- import {
65
- DOCTOR_OUTPUT_TOOL_NAME,
66
- type DoctorCaseCost,
67
- } from "../doctor-contracts.ts";
39
+ import type { DoctorCaseCost } from "../doctor-contracts.ts";
68
40
  import { DOCTOR_CANDIDATE_ENTRY_TYPE } from "../dossier-resolution.ts";
69
41
  import {
70
- REVIEWER_OUTPUT_TOOL_NAME,
71
- } from "../package-contracts/reviewer-output.ts";
72
- import {
73
- packagedAuditToolName,
74
- packagedDurableOfficerEntry,
75
- packagedRoleAcceptedOutputTool,
76
- packagedRoleMetadata,
77
- packagedSkipsGateOnInfrastructureStage,
78
- type PackagedArtifactFace,
79
- type PackagedArtifactLeaf,
80
- type PackagedRole,
81
- } from "../packaged-role-registry.ts";
42
+ ensureRunArtifactsDir,
43
+ homeFromRunDirectory,
44
+ type AdmittedRoleInvocation,
45
+ } from "./invocation.ts";
82
46
  import {
83
47
  SECRETARIAT_COUNTERSIGN_TERMINAL_FACT_KEY,
84
48
  SECRETARIAT_GATE_OFFICER_ENTRY_TYPE,
85
49
  } from "../secretariat-contracts.ts";
86
50
  import {
87
- classifyPackagedRoleTerminalResult,
88
51
  findLatestDurablePackagedRoleTerminal,
89
- hasNavigatorInfrastructureFailureBase,
90
- isAcceptedPackagedRoleTerminalResult,
91
52
  NAVIGATOR_ROUTE_PLAYBOOK_FAILURE_ENTRY,
92
53
  } from "../navigator-invocation-identity.ts";
54
+ import {
55
+ packagedDurableOfficerEntry,
56
+ packagedRoleAcceptedOutputTool,
57
+ packagedRoleMetadata,
58
+ packagedSkipsGateOnInfrastructureStage,
59
+ type PackagedArtifactFace,
60
+ type PackagedArtifactLeaf,
61
+ } from "../packaged-role-registry.ts";
93
62
  import {
94
63
  NO_RECEIPT_LIFECYCLE_ENTRY_TYPE,
95
64
  noReceiptLifecycleFacts,
@@ -104,18 +73,17 @@ import type {
104
73
  DurablePrincipalCoordinates,
105
74
  } from "../host-contracts.ts";
106
75
  import { roleRunArtifactsDirectory } from "../role-run-placement.ts";
107
- import { rewriteRunDirectoryPathValue } from "../role-run-relocation.ts";
108
76
  import {
109
77
  listSeamOwnedUniqueErrorFacePaths,
78
+ uniqueErrorFallbackName,
79
+ UNIQUE_ERROR_FALLBACK_STEM,
110
80
  RUN_TERMINAL_ARTIFACT_FILES,
111
81
  RUN_TERMINAL_ERROR_FALLBACK_RELATIVE_PATHS,
82
+ RUN_TERMINAL_REPORT_FILE,
83
+ RUN_TERMINAL_ERROR_FILE,
84
+ RUN_TERMINAL_EVIDENCE_FILE,
85
+ RUN_TERMINAL_ERROR_SETTLEMENT_FILE,
112
86
  } from "../run-terminal-artifacts.ts";
113
- import {
114
- ensureRunArtifactsDir,
115
- homeFromRunDirectory,
116
- type AdmittedJudgeInvocation,
117
- type AdmittedRoleInvocation,
118
- } from "./invocation.ts";
119
87
 
120
88
  /** Ledger reads use the run's machine home — not ambient process HOME (child write vs parent settle). */
121
89
  function sealedLedgerHome(admitted: Pick<AdmittedRoleInvocation, "runDirectory">): string {
@@ -127,12 +95,123 @@ function sealedLedgerHome(admitted: Pick<AdmittedRoleInvocation, "runDirectory">
127
95
  * courtAttemptId tags the new court; recorded payloads stay run-scoped (#836).
128
96
  */
129
97
  export type SettlementCourtScope = {
98
+ /** Only the dispatched host turn may record this attempt; later reads are projections. */
99
+ readonly recordAttemptHistory?: true;
100
+ /** Inspect a candidate without rewriting history or terminal artifact faces. */
101
+ readonly previewOnly?: true;
130
102
  readonly courtAttemptId?: string;
131
103
  /** Public-invocation scope from the shared Host envelope (#537). */
132
104
  readonly invocationScopeId?: string;
105
+ /**
106
+ * Package fault beside an already chosen terminal. The host report stays.
107
+ * Bound by the public court to the one post-dispatch receiver.
108
+ */
109
+ readonly notePackageFault?: (diagnostic: string) => void | Promise<void>;
133
110
  };
134
111
 
135
- function ledgerReadScope(
112
+ /**
113
+ * Hardened artifacts directory. A planted symlink at the run directory or the
114
+ * artifacts path must not receive a durable run artifact.
115
+ */
116
+ export async function ensureRealArtifactsDirectory(runDirectory: string): Promise<string> {
117
+ const runStat = await lstat(runDirectory);
118
+ if (runStat.isSymbolicLink() || !runStat.isDirectory()) {
119
+ throw new Error("run artifact retention: run directory is not a real directory");
120
+ }
121
+ const artifactsDir = roleRunArtifactsDirectory(runDirectory);
122
+ try {
123
+ const existing = await lstat(artifactsDir);
124
+ if (existing.isSymbolicLink() || !existing.isDirectory()) {
125
+ throw new Error("run artifact retention: artifacts path is not a real directory");
126
+ }
127
+ } catch (error) {
128
+ if (!isEnoent(error)) throw error;
129
+ await mkdir(artifactsDir, { recursive: true });
130
+ const created = await lstat(artifactsDir);
131
+ if (created.isSymbolicLink() || !created.isDirectory()) {
132
+ throw new Error("run artifact retention: artifacts directory is not a real directory");
133
+ }
134
+ }
135
+ return artifactsDir;
136
+ }
137
+
138
+ /**
139
+ * One retention path for a package fault beside an already chosen terminal.
140
+ * Session append is first when the caller has that channel. The artifact file
141
+ * is only the fallback after that append throws. Either failure is reported;
142
+ * neither replaces the terminal.
143
+ */
144
+ export async function retainPackageFault(input: {
145
+ readonly runDirectory: string;
146
+ readonly diagnostic: string;
147
+ /** Original package exception, independent of the host report. */
148
+ readonly error?: unknown;
149
+ readonly appendSession?: (payload: {
150
+ readonly diagnostic: string;
151
+ readonly recordedAt: string;
152
+ }) => Promise<void>;
153
+ readonly stderr?: (text: string) => void;
154
+ }): Promise<void> {
155
+ const payload = {
156
+ diagnostic: input.diagnostic,
157
+ recordedAt: new Date().toISOString(),
158
+ ...(Object.hasOwn(input, "error") ? { failure: projectThrownFailureLeaf(input.error) } : {}),
159
+ };
160
+ let retentionFailure: string | undefined;
161
+ const writeArtifact = async (appendFailure?: ControlledFailure): Promise<void> => {
162
+ const artifactsDir = await ensureRealArtifactsDirectory(input.runDirectory);
163
+ await writeHardenedArtifactFile(artifactsDir, "post-admission-diagnostic", {
164
+ version: 1,
165
+ ...payload,
166
+ ...(appendFailure === undefined ? {} : { retentionFailure: appendFailure }),
167
+ });
168
+ };
169
+ if (input.appendSession !== undefined) {
170
+ try {
171
+ await input.appendSession(payload);
172
+ } catch (appendError) {
173
+ retentionFailure =
174
+ `post-dispatch diagnostic session append failed (best-effort continue): dossier=${describeErrorIdentity(appendError)}`;
175
+ try {
176
+ await writeArtifact(projectThrownFailureLeaf(appendError));
177
+ } catch (artifactError) {
178
+ retentionFailure =
179
+ `post-dispatch diagnostic durable retention failed on both channels (best-effort continue): dossier=${describeErrorIdentity(appendError)}; artifact=${describeErrorIdentity(artifactError)}`;
180
+ }
181
+ }
182
+ } else {
183
+ try {
184
+ await writeArtifact();
185
+ } catch (artifactError) {
186
+ retentionFailure =
187
+ `post-dispatch diagnostic durable retention failed (best-effort continue): artifact=${describeErrorIdentity(artifactError)}`;
188
+ }
189
+ }
190
+ const stderr = input.stderr ?? ((text: string) => {
191
+ process.stderr.write(text);
192
+ });
193
+ try {
194
+ stderr(formatCliDiagnostic(input.diagnostic));
195
+ if (retentionFailure !== undefined) stderr(formatCliDiagnostic(retentionFailure));
196
+ } catch {
197
+ // Presentation is best-effort beside an already-formed host terminal.
198
+ }
199
+ }
200
+
201
+ /** Record a package fault without letting the note replace the terminal. */
202
+ export async function noteSettlementFault(
203
+ runDirectory: string,
204
+ scope: SettlementCourtScope | undefined,
205
+ diagnostic: string,
206
+ ): Promise<void> {
207
+ if (scope?.notePackageFault !== undefined) {
208
+ await scope.notePackageFault(diagnostic);
209
+ return;
210
+ }
211
+ await retainPackageFault({ runDirectory, diagnostic });
212
+ }
213
+
214
+ export function ledgerReadScope(
136
215
  admitted: Pick<AdmittedRoleInvocation, "runDirectory">,
137
216
  scope?: SettlementCourtScope,
138
217
  ): { home: string; sessionParent: string; attemptId?: string } {
@@ -208,29 +287,6 @@ async function sealedLedgerOutcome(
208
287
  return roleOutcomeFromRows(role, rows);
209
288
  }
210
289
 
211
- /**
212
- * True when this court/attempt itself already produced a sealed or
213
- * audit-escalation submission (#836 r12 class 2). Presentation
214
- * (submissions/payloads) always stays run-scoped and unfiltered — this is a
215
- * control-flow signal only, consumed to keep a stale prior-attempt
216
- * acceptance from outranking this attempt's own real host-turn failure.
217
- * Absent a courtAttemptId scope there is no distinct prior attempt to stale
218
- * against, so this defaults true (unchanged behavior for ordinary,
219
- * non-court dispatches).
220
- */
221
- export async function attemptProducedFreshSubmission(
222
- admitted: AdmittedRoleInvocation,
223
- scope?: SettlementCourtScope,
224
- ): Promise<boolean> {
225
- if (scope?.courtAttemptId === undefined || scope.courtAttemptId.length === 0) return true;
226
- return hasFreshAttemptSubmission(
227
- admitted.projectRoot,
228
- admitted.runId,
229
- scope.courtAttemptId,
230
- ledgerReadScope(admitted, scope),
231
- );
232
- }
233
-
234
290
  /** #836: every raw role payload in settle scope (调几次记几次). */
235
291
  export async function recordedSubmissionPayloads(
236
292
  admitted: Pick<AdmittedRoleInvocation, "projectRoot" | "runId" | "runDirectory">,
@@ -248,23 +304,8 @@ export function withSubmissions<T extends TerminalResult>(
248
304
  submissions: readonly unknown[],
249
305
  ): T {
250
306
  if (submissions.length === 0) return terminal;
251
- const roleOutcome = terminal.roleOutcome;
252
- // #881 / #836: full run history always lands on terminal.submissions.
253
- // #879: accepted/audit_escalation may still fill a missing this-court
254
- // payloads face from that history. #953: failure must not — prior receipts
255
- // stay on the historical carrier only, never as the current failure's
256
- // ordinary payloads/receipt face.
257
- if (roleOutcome.kind === "failure") {
258
- return { ...terminal, submissions };
259
- }
260
- const withPayloads =
261
- roleOutcome.kind === "accepted" || roleOutcome.kind === "audit_escalation"
262
- ? {
263
- ...roleOutcome,
264
- payloads: coalesceSubmissionRows(roleOutcome.payloads, submissions),
265
- }
266
- : roleOutcome;
267
- return { ...terminal, roleOutcome: withPayloads, submissions };
307
+ // Run history is presentation, never a substitute for this court's reply (#879).
308
+ return { ...terminal, submissions };
268
309
  }
269
310
 
270
311
  /** Attach full ledger submissions onto any settled terminal (#836). */
@@ -277,13 +318,19 @@ export async function attachRecordedSubmissions<T extends TerminalResult>(
277
318
  // pass courtAttemptId here — roleOutcome already carries this-court payloads
278
319
  // from sealedLedgerOutcome when scoped; withSubmissions keeps them.
279
320
  void scope;
280
- const result = withSubmissions(
281
- terminal,
282
- await recordedSubmissionPayloads(admitted, undefined),
283
- );
284
- const errorPath = privateFailureErrorPaths.get(terminal);
285
- if (errorPath !== undefined) privateFailureErrorPaths.set(result, errorPath);
286
- return result;
321
+ try {
322
+ return withSubmissions(
323
+ terminal,
324
+ await recordedSubmissionPayloads(admitted, undefined),
325
+ );
326
+ } catch (error) {
327
+ await noteSettlementFault(
328
+ admitted.runDirectory,
329
+ scope,
330
+ `submission history read failed beside host terminal: ${describeErrorIdentity(error)}`,
331
+ );
332
+ return terminal;
333
+ }
287
334
  }
288
335
 
289
336
  /**
@@ -291,66 +338,6 @@ export async function attachRecordedSubmissions<T extends TerminalResult>(
291
338
  * Does not invent an output failure for an empty ledger.
292
339
  * #953: empty public terminal face — clear every reader-adoptable prior face.
293
340
  */
294
- /**
295
- * Lifecycle fact the in-process runtime already wrote for this run and attempt.
296
- * Absent, malformed, or a previous attempt's bytes are not a count.
297
- */
298
- function lifecycleFactsFromEntry(entry: SessionEntry): unknown {
299
- const customType = entry.customType ?? entry.message?.customType;
300
- if (customType !== NO_RECEIPT_LIFECYCLE_ENTRY_TYPE) return undefined;
301
- return entry.data ?? entry.message?.details;
302
- }
303
-
304
- async function readCurrentAttemptNoReceiptFacts(
305
- admitted: AdmittedRoleInvocation,
306
- authority: DurablePrincipalAuthority,
307
- scope?: SettlementCourtScope,
308
- ): Promise<NoReceiptLifecycleFacts | undefined> {
309
- const { sessionFile } = coordinatesFromAdmitted(authority, admitted);
310
- const entries = await readBoundSessionEntries(sessionFile).catch(() => undefined);
311
- if (entries === undefined) return undefined;
312
- const scopeId = scope?.invocationScopeId?.trim() ?? "";
313
- if (scopeId.length > 0) {
314
- const pointer = receiptAttemptPointer(admitted.runDirectory, scopeId);
315
- let selected: NoReceiptLifecycleFacts | undefined;
316
- for (const entry of entries) {
317
- const raw = lifecycleFactsFromEntry(entry);
318
- if (raw === undefined) continue;
319
- try {
320
- const facts = parseNoReceiptLifecycleFacts(raw);
321
- if (facts.runPointer !== admitted.runDirectory) continue;
322
- if (facts.attemptPointer !== pointer) continue;
323
- if (selected === undefined || facts.deliveryTurns >= selected.deliveryTurns) {
324
- selected = facts;
325
- }
326
- } catch {
327
- // A malformed historical entry is not this call's count.
328
- }
329
- }
330
- return selected;
331
- }
332
- let attemptStart = 0;
333
- for (let index = entries.length - 1; index >= 0; index -= 1) {
334
- if (entries[index]?.type === "message" && entries[index]?.message?.role === "user") {
335
- attemptStart = index;
336
- break;
337
- }
338
- }
339
- const lifecycleEntry = entries.slice(attemptStart).reverse().find((entry: SessionEntry) =>
340
- lifecycleFactsFromEntry(entry) !== undefined);
341
- const raw = lifecycleEntry === undefined ? undefined : lifecycleFactsFromEntry(lifecycleEntry);
342
- if (raw === undefined) return undefined;
343
- let facts: NoReceiptLifecycleFacts;
344
- try {
345
- facts = parseNoReceiptLifecycleFacts(raw);
346
- } catch {
347
- return undefined;
348
- }
349
- if (facts.runPointer !== admitted.runDirectory) return undefined;
350
- if (facts.attemptPointer !== receiptAttemptPointer(admitted.runDirectory)) return undefined;
351
- return facts;
352
- }
353
-
354
341
  export async function settleHostEndedNoReceipt(
355
342
  admitted: AdmittedRoleInvocation,
356
343
  authority: DurablePrincipalAuthority,
@@ -363,48 +350,135 @@ export async function settleHostEndedNoReceipt(
363
350
  */
364
351
  issuedDeliveryRequests = 0,
365
352
  ): Promise<TerminalResult> {
366
- const scopeId = scope?.invocationScopeId?.trim() ?? "";
367
- const persisted = scopeId.length > 0
368
- ? undefined
369
- : await readCurrentAttemptNoReceiptFacts(admitted, authority, scope);
370
- let terminalToolCalled = persisted?.terminalToolCalled ?? false;
371
- let rejectedReceipts: readonly { reason: string }[] = persisted?.rejectedReceipts ?? [];
372
- let recordedTurns = persisted?.deliveryTurns ?? 0;
373
- if (scopeId.length > 0) {
374
- const { sessionFile } = coordinatesFromAdmitted(authority, admitted);
375
- const entries = await readBoundSessionEntries(sessionFile).catch(() => undefined);
376
- if (entries !== undefined) {
377
- const prior = priorReceiptContinuation(entries, scopeId);
378
- terminalToolCalled = prior.terminalToolCalled;
379
- rejectedReceipts = prior.rejectedReceipts;
380
- recordedTurns = prior.deliveryTurns;
381
- }
382
- }
383
- const facts = noReceiptLifecycleFacts({
384
- terminalToolCalled,
385
- rejectedReceipts,
386
- deliveryTurns: Math.max(issuedDeliveryRequests, recordedTurns),
353
+ const coordinates = coordinatesFromAdmitted(authority, admitted);
354
+ const binding = {
387
355
  runPointer: admitted.runDirectory,
388
356
  attemptPointer: receiptAttemptPointer(admitted.runDirectory, scope?.invocationScopeId),
389
- });
390
- const coordinates = coordinatesFromAdmitted(authority, admitted);
391
- await clearOppositeTerminalArtifactFace(admitted.runDirectory);
392
- return withOptionalGateProjection(
393
- {
394
- roleOutcome: {
395
- kind: "no_receipt",
396
- role: admitted.role,
397
- status: "no-accepted-receipt",
398
- ...facts,
399
- decisiveFacts: facts,
400
- },
401
- navigator: await extractNavigatorFactFromAdmittedSession(coordinates.sessionFile),
402
- artifacts: [],
403
- runId: admitted.runId,
357
+ };
358
+ // The delivery owner records what actually happened this attempt — whether the
359
+ // terminal tool was called and what was rejected. Present those facts; a fixed
360
+ // `false` / `[]` would assert an empty delivery that never happened
361
+ // (#1032: 无回执如实呈 no_receipt; 单一真源).
362
+ const recorded = await readRecordedNoReceiptFacts(coordinates.sessionFile, binding, scope?.invocationScopeId);
363
+ if (recorded instanceof LifecycleReadFailure) {
364
+ // A present record that cannot be read is not an empty delivery. Omit the
365
+ // facts and keep the real error beside the no_receipt terminal.
366
+ await noteSettlementFault(
367
+ admitted.runDirectory,
368
+ scope,
369
+ `no-receipt lifecycle record could not be read: ${describeErrorIdentity(recorded.error ?? recorded)}`,
370
+ );
371
+ return settleNoReceiptTerminal(admitted, authority, scope, undefined);
372
+ }
373
+ return settleNoReceiptTerminal(
374
+ admitted,
375
+ authority,
376
+ scope,
377
+ noReceiptLifecycleFacts({
378
+ terminalToolCalled: recorded?.terminalToolCalled ?? false,
379
+ rejectedReceipts: recorded?.rejectedReceipts ?? [],
380
+ deliveryTurns: Math.max(issuedDeliveryRequests, recorded?.deliveryTurns ?? 0),
381
+ ...binding,
382
+ }),
383
+ );
384
+ }
385
+
386
+ /** A lifecycle record that exists but could not be read or parsed. */
387
+ export class LifecycleReadFailure extends Error {
388
+ /** The underlying read or parse failure, kept whole. */
389
+ readonly error: unknown;
390
+ constructor(message: string, options?: { cause?: unknown }) {
391
+ super(message);
392
+ this.name = "LifecycleReadFailure";
393
+ this.error = options?.cause;
394
+ }
395
+ }
396
+
397
+ /**
398
+ * The delivery owner's own lifecycle record for this attempt.
399
+ * - undefined: the owner wrote no record (genuine absence).
400
+ * - LifecycleReadFailure: a record exists but could not be read or parsed.
401
+ */
402
+ async function readRecordedNoReceiptFacts(
403
+ sessionFile: string,
404
+ binding: { runPointer: string; attemptPointer: string },
405
+ invocationScopeId?: string,
406
+ ): Promise<NoReceiptLifecycleFacts | undefined | LifecycleReadFailure> {
407
+ let entries: readonly SessionEntry[];
408
+ try {
409
+ entries = await readBoundSessionEntries(sessionFile);
410
+ } catch (error) {
411
+ // A session that was never written is genuine absence; anything else is a
412
+ // real read failure about this run.
413
+ if (isEnoent(error)) return undefined;
414
+ return new LifecycleReadFailure(
415
+ `session transcript unreadable: ${errorText(error)}`,
416
+ { cause: error },
417
+ );
418
+ }
419
+ const scoped = invocationScopeId !== undefined && invocationScopeId.trim() !== "";
420
+ const entry = (scoped ? entries : entries.slice(currentAttemptStartIndex(entries))).slice().reverse().find(
421
+ (item: SessionEntry) => {
422
+ if (item.customType !== NO_RECEIPT_LIFECYCLE_ENTRY_TYPE
423
+ && item.message?.customType !== NO_RECEIPT_LIFECYCLE_ENTRY_TYPE) return false;
424
+ const raw = item.data ?? item.message?.details;
425
+ return !scoped || !isRecord(raw) || raw.attemptPointer === undefined
426
+ || raw.attemptPointer === binding.attemptPointer;
404
427
  },
405
- coordinates.sessionDirectory,
406
- detourGateContext(admitted, scope),
407
428
  );
429
+ const raw = entry?.data ?? entry?.message?.details;
430
+ if (raw === undefined) {
431
+ if (!scoped) return undefined;
432
+ const prior = priorReceiptContinuation(entries, invocationScopeId!);
433
+ return noReceiptLifecycleFacts({ ...prior, ...binding });
434
+ }
435
+ try {
436
+ const facts = parseNoReceiptLifecycleFacts(raw);
437
+ return facts.runPointer === binding.runPointer
438
+ && facts.attemptPointer === binding.attemptPointer
439
+ ? (scoped ? noReceiptLifecycleFacts({ ...priorReceiptContinuation(entries, invocationScopeId!), ...binding }) : facts)
440
+ : undefined;
441
+ } catch (error) {
442
+ return new LifecycleReadFailure(
443
+ `no-receipt lifecycle record is malformed: ${errorText(error)}`,
444
+ { cause: error },
445
+ );
446
+ }
447
+ }
448
+
449
+ async function settleNoReceiptTerminal(
450
+ admitted: AdmittedRoleInvocation,
451
+ authority: DurablePrincipalAuthority,
452
+ scope: SettlementCourtScope | undefined,
453
+ facts: NoReceiptLifecycleFacts | undefined,
454
+ ): Promise<TerminalResult> {
455
+ const coordinates = coordinatesFromAdmitted(authority, admitted);
456
+ const roleOutcome: TerminalRoleOutcome = facts === undefined
457
+ ? {
458
+ kind: "no_receipt", role: admitted.role, status: "no-accepted-receipt",
459
+ decisiveFacts: {},
460
+ }
461
+ : {
462
+ kind: "no_receipt", role: admitted.role, status: "no-accepted-receipt",
463
+ ...facts, decisiveFacts: facts,
464
+ };
465
+ if (scope?.recordAttemptHistory === true) {
466
+ await appendRunAttemptHistory(
467
+ { role: admitted.role, runId: admitted.runId, sessionFile: coordinates.sessionFile },
468
+ roleOutcome,
469
+ );
470
+ }
471
+ if (scope?.previewOnly !== true) await clearOppositeTerminalArtifactFace(admitted.runDirectory);
472
+ return withOptionalGateProjection({
473
+ roleOutcome,
474
+ navigator: await extractNavigatorFactFromAdmittedSession(
475
+ coordinates.sessionFile,
476
+ admitted.runDirectory,
477
+ scope,
478
+ ),
479
+ artifacts: [],
480
+ runId: admitted.runId,
481
+ }, coordinates.sessionDirectory, detourGateContext(admitted, scope));
408
482
  }
409
483
 
410
484
  /** Transitional host-session reads remain only for non-sealed failure and audit evidence. */
@@ -415,7 +489,6 @@ function coordinatesFromAdmitted(
415
489
  return authority.decode(admitted.principal);
416
490
  }
417
491
  import {
418
- coalesceSubmissionRows,
419
492
  exitCodeForTerminalOutcome,
420
493
  formatTerminalResult,
421
494
  isLawfulTypedTerminalOutcome,
@@ -426,12 +499,11 @@ import {
426
499
  type TerminalGateSeat,
427
500
  type TerminalNavigatorFact,
428
501
  type TerminalResult,
429
- type TerminalResume,
430
502
  type TerminalRoleName,
431
503
  type TerminalRoleOutcome,
432
504
  } from "./terminal.ts";
433
505
 
434
- import { isEnoent, isMissingPathError, isRecord } from "../unknown-value.ts";
506
+ import { isEnoent, isMissingPathError, isRecord, errorText } from "../unknown-value.ts";
435
507
 
436
508
  export type { ControlledFailureCause };
437
509
 
@@ -441,39 +513,55 @@ export {
441
513
  isLawfulTypedTerminalOutcome,
442
514
  };
443
515
 
444
- /** Preserved post-admission failure (not a role Receipt). */
445
- export type ControlledFailure = {
446
- /** Typed class only when confirmed; omitted when unknown (#881 — no fabricated label). */
447
- readonly cause?: ControlledFailureCause;
516
+ /**
517
+ * Preserved post-admission failure (not a role Receipt). The host contract owns
518
+ * the shape; the only difference settlement requires is that a settled failure
519
+ * always carries a diagnostic to present, so `cause` and `identity` stay
520
+ * omitted when no typed fact confirms them (#881 — no fabricated label).
521
+ */
522
+ export type ControlledFailure = RoleTurnKnownFailure & {
448
523
  readonly diagnostic: string;
449
- readonly identity?: {
450
- readonly name?: string;
451
- readonly code?: string | number;
452
- };
453
- readonly details?: Readonly<Record<string, unknown>>;
524
+ /**
525
+ * Real facts this package observed while handling the call, kept beside the
526
+ * host's report rather than inside its open `details`. `details` belongs to
527
+ * the host: a key written there can collide with a key the host itself
528
+ * carries, and the host's value would be the one lost (host-contracts.ts:41
529
+ * — an open record with no reserved keys).
530
+ */
531
+ readonly packageFact?: PackageSideFact;
532
+ /**
533
+ * Host stderr that is not already this failure's diagnostic. A stderr.log
534
+ * or artifact write can fail; the original bytes stay on this object.
535
+ */
536
+ readonly stderr?: string;
454
537
  };
455
538
 
456
- // A resumable public Terminal omits artifact paths (#108); its actual error
457
- // publication remains available only to the CLI's resume presentation seam.
458
- const privateFailureErrorPaths = new WeakMap<TerminalResult, string>();
459
-
460
- export function publishedFailureErrorPath(terminal: TerminalResult): string | undefined {
461
- return privateFailureErrorPaths.get(terminal);
462
- }
463
-
464
- export function rewritePublishedFailureErrorPath(
465
- terminal: TerminalResult,
466
- oldRunDirectory: string,
467
- newRunDirectory: string,
468
- ): void {
469
- const errorPath = privateFailureErrorPaths.get(terminal);
470
- if (errorPath !== undefined) {
471
- privateFailureErrorPaths.set(
472
- terminal,
473
- rewriteRunDirectoryPathValue(errorPath, oldRunDirectory, newRunDirectory) as string,
474
- );
475
- }
476
- }
539
+ /**
540
+ * Facts the package itself established, each kept whole. None of them is ever
541
+ * promoted into the host's cause, diagnostic or details.
542
+ */
543
+ export type PackageSideFact = {
544
+ /**
545
+ * This package's own view of the host's exit for this call. Kept beside the
546
+ * host's `details`, never inside it.
547
+ */
548
+ readonly exitCode?: number | null;
549
+ readonly timedOut?: boolean;
550
+ /**
551
+ * The signal that ended the host child on this call, when the host reported
552
+ * one. Beside the host's details, never inside them.
553
+ */
554
+ readonly signal?: string;
555
+ /**
556
+ * Catchable process signal this call actually received. Beside the host's
557
+ * report, never a replacement for it.
558
+ */
559
+ readonly cancelName?: string;
560
+ /** An exception caught after the host already reported this call. */
561
+ readonly thrown?: RoleTurnKnownFailure & { readonly diagnostic: string };
562
+ /** A durable stderr.log write that failed while handling this call. */
563
+ readonly stderrLogWriteFailure?: unknown;
564
+ };
477
565
 
478
566
  /**
479
567
  * Host stderr as recorded — full bytes, no flood filter, no char clip (#836).
@@ -490,14 +578,13 @@ export function formatCliDiagnostic(message: string): string {
490
578
  return `ak-role: ${message}\n`;
491
579
  }
492
580
 
493
- /**
494
- * One concise stderr line for humans. Durable Error Artifact / Terminal keep the
495
- * full original diagnostic — presentation collapses newlines and flood frames.
496
- */
497
581
  /** #836: full diagnostic on stderr — no first-line clip / flood filter. */
498
582
  export function formatFailureStderrDiagnostic(failure: ControlledFailure): string {
499
583
  const text = failure.diagnostic.trim().length > 0 ? failure.diagnostic : "failure";
500
- return formatCliDiagnostic(text);
584
+ const head = formatCliDiagnostic(text);
585
+ const beside = failure.stderr;
586
+ if (beside === undefined || beside.length === 0 || beside === failure.diagnostic) return head;
587
+ return beside.endsWith("\n") ? `${head}${beside}` : `${head}${beside}\n`;
501
588
  }
502
589
 
503
590
  /**
@@ -541,42 +628,27 @@ export function presentControlledFailure(
541
628
  io.stderr(formatFailureStderrDiagnostic(failure));
542
629
  }
543
630
 
544
- /** Session readiness after an admitted activation attempt. */
545
- export type SessionReadiness =
546
- | { readonly state: "missing" }
547
- | { readonly state: "unreadable"; readonly diagnostic: string }
548
- | { readonly state: "present" };
549
-
550
- export async function inspectJudgeSession(
551
- sessionFile: string,
552
- ): Promise<SessionReadiness> {
553
- try {
554
- await readFile(sessionFile, "utf8");
555
- return { state: "present" };
556
- } catch (error) {
557
- if (isEnoent(error)) return { state: "missing" };
558
- return {
559
- state: "unreadable",
560
- diagnostic:
561
- error instanceof Error
562
- ? error.message || error.name
563
- : String(error),
564
- };
565
- }
566
- }
631
+ /**
632
+ * A caught error's identity, read structurally off the thrown value: its name
633
+ * and its `code` when it carries one (an OS errno, typically). The errno
634
+ * travels as data so a consumer asserts the identity instead of recognising
635
+ * the system error by its message wording (质量法: 机器只咬契约,不咬呈现).
636
+ */
637
+ export type ThrownErrorIdentity = {
638
+ readonly name: string;
639
+ readonly code?: string | number;
640
+ };
567
641
 
568
- function thrownIdentity(error: Error): {
569
- name?: string;
570
- code?: string | number;
571
- } {
572
- const identity: { name?: string; code?: string | number } = {
642
+ function thrownIdentity(error: Error): ThrownErrorIdentity {
643
+ const code = (error as { code?: unknown }).code;
644
+ return {
573
645
  name: error.name,
646
+ ...(typeof code === "string" || typeof code === "number" ? { code } : {}),
574
647
  };
575
- const code = (error as { code?: unknown }).code;
576
- if (typeof code === "string" || typeof code === "number") {
577
- identity.code = code;
578
- }
579
- return identity;
648
+ }
649
+
650
+ function isControlledFailureCause(cause: unknown): cause is ControlledFailureCause {
651
+ return CONTROLLED_FAILURE_CAUSES.some((known) => known === cause);
580
652
  }
581
653
 
582
654
  /** Production-owned typed thrown failure (explicit-internal channel). */
@@ -589,48 +661,43 @@ function isTypedActivationError(
589
661
  } {
590
662
  if (!(error instanceof Error)) return false;
591
663
  const cause = (error as { knownCause?: unknown }).knownCause;
592
- return (
593
- cause === "provider" ||
594
- cause === "activation" ||
595
- cause === "session" ||
596
- cause === "output" ||
597
- cause === "timeout"
598
- );
599
- }
600
-
601
- /** Flatten nested AggregateError leaves; non-aggregate values stay as one fact. */
602
- function flattenThrownFailureLeaves(error: unknown): unknown[] {
603
- if (!(error instanceof AggregateError)) {
604
- return [error];
605
- }
606
- const leaves: unknown[] = [];
607
- for (const item of error.errors) {
608
- leaves.push(...flattenThrownFailureLeaves(item));
609
- }
610
- return leaves;
664
+ return isControlledFailureCause(cause);
611
665
  }
612
666
 
613
667
  /**
614
668
  * Project one thrown value into a ControlledFailure leaf.
615
669
  * Sole owner for thrown-leaf identity/diagnostic mapping.
616
- * AggregateError nesting is handled by classifyThrownFailure.
670
+ * Aggregate shells and their direct children remain distinct facts.
617
671
  */
618
672
  export function projectThrownFailureLeaf(error: unknown): ControlledFailure {
673
+ const originalDetails = error !== null && typeof error === "object"
674
+ ? (error as { details?: Readonly<Record<string, unknown>> }).details
675
+ : undefined;
676
+ const testimony = {
677
+ error: serializeThrownValue(error),
678
+ ...(error instanceof AggregateError
679
+ ? { concurrentFailures: error.errors.map(projectThrownFailureLeaf) }
680
+ : {}),
681
+ };
682
+ const details = originalDetails === undefined ? testimony : originalDetails;
683
+ const packageFact = originalDetails === undefined ? {} : {
684
+ packageFact: {
685
+ thrown: {
686
+ diagnostic: error instanceof Error ? error.message || error.name || "exception" : "non-Error throw",
687
+ details: testimony,
688
+ },
689
+ },
690
+ };
619
691
  if (isTypedActivationError(error)) {
620
692
  const identity = thrownIdentity(error);
621
- if (error.failureCode !== undefined && identity.code === undefined) {
622
- identity.code = error.failureCode;
623
- }
624
693
  return {
625
694
  cause: error.knownCause,
626
695
  diagnostic: error.message || error.name || "exception",
627
- identity,
628
- details: {
629
- ...(typeof error.details === "object" && error.details !== null
630
- ? error.details as Record<string, unknown>
631
- : error.details === undefined ? {} : { priorDetails: error.details }),
632
- error: serializeThrownValue(error),
633
- },
696
+ identity: error.failureCode !== undefined && identity.code === undefined
697
+ ? { ...identity, code: error.failureCode }
698
+ : identity,
699
+ details,
700
+ ...packageFact,
634
701
  };
635
702
  }
636
703
  if (error instanceof Error) {
@@ -639,72 +706,39 @@ export function projectThrownFailureLeaf(error: unknown): ControlledFailure {
639
706
  return {
640
707
  diagnostic: error.message || error.name || "exception",
641
708
  identity,
642
- details: { error: serializeThrownValue(error) },
709
+ details,
710
+ ...packageFact,
643
711
  };
644
712
  }
645
713
  return {
646
714
  diagnostic: error !== null && typeof error === "object" ? "non-Error throw" : String(error),
647
- details: { error: serializeThrownValue(error) },
715
+ details,
716
+ ...packageFact,
648
717
  };
649
718
  }
650
719
 
651
720
  /**
652
- * Concurrent thrown failures (host + cleanup, etc.):
653
- * primary leaf owns cause/diagnostic/identity; remaining leaves stay as
654
- * details.concurrentFailures so neither fact covers the other.
721
+ * Merge caller-owned secondary evidence into a classified failure without
722
+ * washing path facts. The package's own facts ride along on every branch, so
723
+ * no fallback path silently drops them.
655
724
  */
656
- function classifyThrownFailure(error: unknown): ControlledFailure {
657
- if (!(error instanceof AggregateError)) {
658
- return projectThrownFailureLeaf(error);
659
- }
660
- const leaves = flattenThrownFailureLeaves(error);
661
- if (leaves.length === 0) {
662
- // Empty aggregate — retain the aggregate shell rather than invent a cause.
663
- return projectThrownFailureLeaf(error);
664
- }
665
- const primary = projectThrownFailureLeaf(leaves[0]);
666
- if (leaves.length === 1) {
667
- return primary;
668
- }
669
- const priorConcurrent = Array.isArray(primary.details?.concurrentFailures)
670
- ? primary.details.concurrentFailures
671
- : [];
672
- const concurrentFailures = [
673
- ...priorConcurrent,
674
- ...leaves.slice(1).map((leaf) => {
675
- const secondary = projectThrownFailureLeaf(leaf);
676
- return {
677
- ...(secondary.cause === undefined ? {} : { cause: secondary.cause }),
678
- diagnostic: secondary.diagnostic,
679
- ...(secondary.identity === undefined ? {} : { identity: secondary.identity }),
680
- ...(secondary.details === undefined ? {} : { details: secondary.details }),
681
- };
682
- }),
683
- ];
684
- return {
685
- ...(primary.cause === undefined ? {} : { cause: primary.cause }),
686
- diagnostic: primary.diagnostic,
687
- ...(primary.identity === undefined ? {} : { identity: primary.identity }),
688
- details: {
689
- ...(primary.details ?? {}),
690
- concurrentFailures,
691
- },
692
- };
693
- }
694
-
695
- /** Merge caller-owned secondary evidence into a classified failure without washing path facts. */
696
725
  function withKnownDetails(
697
726
  failure: ControlledFailure,
698
727
  knownDetails: Readonly<Record<string, unknown>> | undefined,
728
+ packageFact?: PackageSideFact,
699
729
  ): ControlledFailure {
700
- if (knownDetails === undefined) return failure;
701
- const { timedOut: _knownTimedOut, ...rest } = knownDetails;
730
+ const facts = packageFact === undefined || Object.keys(packageFact).length === 0
731
+ ? undefined
732
+ : packageFact;
733
+ // The host's own details win outright: this helper must not displace a key the
734
+ // host carried. Only when the host supplied no record at all does the
735
+ // classified failure keep the details it derived from the host's exit.
702
736
  return {
703
737
  ...failure,
704
- details: {
705
- ...rest,
706
- ...(failure.details ?? {}),
707
- },
738
+ ...(facts === undefined ? {} : { packageFact: facts }),
739
+ // The host's own record replaces the derived one when it supplied one; when
740
+ // it supplied none, the failure keeps what it derived from the host's exit.
741
+ ...(knownDetails === undefined ? {} : { details: knownDetails }),
708
742
  };
709
743
  }
710
744
 
@@ -712,11 +746,49 @@ function withKnownDetails(
712
746
  * Classify a controlled post-admission failure without washing original identities.
713
747
  * Cause classes are closed; diagnostic text retains the original identity when known.
714
748
  *
715
- * Order: thrown → knownCause → timeout → activation (nonzero) → session → output.
749
+ * Order: thrown → knownCause → timeout → activation (nonzero) → clean exit.
716
750
  * knownCause precedes timeout so a co-present typed provider/session identity is not
717
751
  * washed when the child also timed out. Cause is never inferred from stderr wording.
718
- * AggregateError concurrent leaves keep primary identity and secondary facts in details.
752
+ * AggregateError shells, direct children and raw testimony stay distinct.
753
+ */
754
+ /**
755
+ * Wording for a failure the host reported without a diagnostic of its own. The
756
+ * host's signal or cancellation is named when it gave one; otherwise the exit
757
+ * is. Only ever a fallback — a host that reported a diagnostic keeps it
758
+ * (owner 4743ade7: 代码凭什么要去决定cli的失败原因?).
719
759
  */
760
+ function hostReportedFallbackOf(input: {
761
+ readonly timedOut?: boolean;
762
+ readonly code?: number | null;
763
+ readonly signal?: string;
764
+ readonly cancelName?: string;
765
+ }): string {
766
+ if (input.cancelName !== undefined) return processCancelDiagnostic(input.cancelName as CatchableProcessSignal);
767
+ if (input.signal !== undefined) return childSignalDeathDiagnostic(input.signal);
768
+ if (input.timedOut === true) return "role run timed out";
769
+ return `role run failed with exit ${input.code ?? "null"}`;
770
+ }
771
+
772
+ /** This call's package-side facts, or undefined when there are none. */
773
+ function packageFactsOf(input: {
774
+ readonly packageFact?: PackageSideFact;
775
+ readonly code?: number | null;
776
+ readonly timedOut?: boolean;
777
+ readonly signal?: string;
778
+ readonly cancelName?: string;
779
+ }): PackageSideFact | undefined {
780
+ const facts: PackageSideFact = {
781
+ ...(input.code === undefined ? {} : { exitCode: input.code }),
782
+ ...(input.timedOut === true ? { timedOut: true } : {}),
783
+ ...(input.signal === undefined || input.signal.length === 0 ? {} : { signal: input.signal }),
784
+ ...(input.cancelName === undefined || input.cancelName.length === 0
785
+ ? {}
786
+ : { cancelName: input.cancelName }),
787
+ ...(input.packageFact ?? {}),
788
+ };
789
+ return Object.keys(facts).length === 0 ? undefined : facts;
790
+ }
791
+
720
792
  export function classifyPostAdmissionFailure(input: {
721
793
  timedOut: boolean;
722
794
  code: number | null;
@@ -727,7 +799,6 @@ export function classifyPostAdmissionFailure(input: {
727
799
  * being washed into activation/null-exit paths that treat missing thrown as absence.
728
800
  */
729
801
  thrown?: unknown;
730
- session?: SessionReadiness;
731
802
  /** Upstream-typed cause when the failure origin is already known. */
732
803
  knownCause?: ControlledFailureCause;
733
804
  /** Optional identity paired with knownCause (production channel). */
@@ -742,71 +813,112 @@ export function classifyPostAdmissionFailure(input: {
742
813
  knownDiagnostic?: string;
743
814
  /** Secondary evidence already carried by the typed production failure. */
744
815
  knownDetails?: Readonly<Record<string, unknown>>;
816
+ /** The signal that killed the host child, when one did (a non-normal exit). */
817
+ signal?: string;
818
+ /**
819
+ * A catchable process-signal cancellation the host reported. Like `signal`,
820
+ * it only supplies fallback wording: the host's own diagnostic always wins.
821
+ */
822
+ readonly cancelName?: string;
823
+ /** Further package-side facts merged under `packageFact`, never the cause. */
824
+ packageFact?: PackageSideFact;
745
825
  }): ControlledFailure {
746
826
  // Own-key presence, not value: `throw undefined` is a real caught exception.
827
+ // An exception caught after the host already reported its own failure carries
828
+ // both real facts: the host's report stays the cause, and the later exception
829
+ // is kept beside it (失败诚实宪法:接住可以,洗白不行).
747
830
  if (Object.hasOwn(input, "thrown")) {
748
- return classifyThrownFailure(input.thrown);
749
- }
750
- if (input.knownCause !== undefined) {
751
- const fallback =
752
- input.knownCause === "provider"
753
- ? "provider failure"
754
- : input.knownCause === "session"
755
- ? "session unreadable"
756
- : input.knownCause === "output"
757
- ? "role run completed without a lawful typed terminal result"
758
- : `role run failed (${input.knownCause})`;
759
- const diagnostic =
760
- input.knownDiagnostic !== undefined && input.knownDiagnostic.trim() !== ""
761
- ? input.knownDiagnostic
762
- : conciseChildDiagnostic(input.stderr, fallback);
763
- const { timedOut: _knownTimedOut, ...knownDetails } =
764
- input.knownDetails ?? {};
765
- const remoteCode = knownDetails.code;
831
+ const thrown = projectThrownFailureLeaf(input.thrown);
832
+ // A host that carried a details record reported *something* for this call,
833
+ // even with no typed class: that record is its own and must not be
834
+ // displaced by the exception.
835
+ const hostReported = input.knownCause !== undefined
836
+ || (input.knownDiagnostic !== undefined && input.knownDiagnostic.trim() !== "")
837
+ || input.knownIdentity !== undefined
838
+ || input.knownDetails !== undefined;
839
+ // The host reported no *typed* class, but it did report this call: a
840
+ // nonzero exit, a timeout, or a signal death are its own facts. A later
841
+ // settlement exception does not replace them — it is a different failure
842
+ // and rides beside them (owner 4743ade7: 代码凭什么要去决定cli的失败原因?).
843
+ if (!hostReported) {
844
+ // A number is the host having reported an exit. A null code with a signal
845
+ // is its native signal-death report. Anything else means the host produced
846
+ // no result at all, and then the exception really is the only fact there
847
+ // is — there is nothing of the host's to preserve over it.
848
+ const hostFailed = input.timedOut
849
+ || input.signal !== undefined
850
+ || (typeof input.code === "number" && input.code !== 0);
851
+ if (!hostFailed) {
852
+ const facts = packageFactsOf(input);
853
+ if (facts?.cancelName === undefined && facts?.signal === undefined) return thrown;
854
+ return {
855
+ ...thrown,
856
+ packageFact: { ...facts, ...(thrown.packageFact ?? {}) },
857
+ };
858
+ }
859
+ const facts = packageFactsOf(input);
860
+ const packageFact = facts === undefined ? { thrown } : { ...facts, thrown };
861
+ return {
862
+ ...(input.timedOut ? { cause: "timeout" as const } : {}),
863
+ ...(input.signal === undefined ? {} : { identity: { name: "ChildSignalDeath", code: input.signal } }),
864
+ diagnostic: conciseChildDiagnostic(
865
+ input.stderr,
866
+ hostReportedFallbackOf(input),
867
+ ),
868
+ // The host's record is untouched; this package's view of the exit and
869
+ // the caught exception both ride under `packageFact`.
870
+ packageFact,
871
+ };
872
+ }
873
+ // The host's report is presented exactly as it gave it — a field it left out
874
+ // is not filled in from the exception, which is a different failure. The
875
+ // exception is kept whole beside it under its own key, so neither erases
876
+ // the other (失败诚实宪法:接住可以,洗白不行).
766
877
  return {
767
- cause: input.knownCause,
768
- diagnostic,
769
- details: {
770
- ...knownDetails,
771
- ...(remoteCode === undefined ? {} : { code: remoteCode }),
772
- exitCode: input.code,
773
- ...(input.timedOut ? { timedOut: true as const } : {}),
774
- },
878
+ ...(input.knownCause === undefined ? {} : { cause: input.knownCause }),
879
+ // A settled failure always presents a diagnostic. The host's own is used
880
+ // when it gave one; when it gave none, this call's own stderr supplies the
881
+ // text. The exception is a different failure and never fills this field —
882
+ // it rides whole under `packageFact` (owner 4743ade7: 代码凭什么要去决定cli的失败原因?).
883
+ diagnostic: input.knownDiagnostic !== undefined && input.knownDiagnostic.trim() !== ""
884
+ ? input.knownDiagnostic
885
+ : conciseChildDiagnostic(input.stderr, hostReportedFallbackOf(input)),
775
886
  ...(input.knownIdentity === undefined
776
887
  ? {}
777
888
  : { identity: input.knownIdentity }),
889
+ // The host's details are handed back exactly as given.
890
+ ...(input.knownDetails === undefined ? {} : { details: input.knownDetails }),
891
+ // Every fact this package captured on this call — the caught exception and
892
+ // any earlier auxiliary read failure — is reported, none of them dropped.
893
+ packageFact: { ...packageFactsOf(input), thrown },
778
894
  };
779
895
  }
780
- // #881: original failure testimony without a typed class — keep diagnostic/identity;
781
- // do not fall through to activation/output wash that would mint a substitute class.
782
- // Bare knownDetails alone is secondary evidence for later branches (withKnownDetails),
783
- // not a stand-in primary failure record.
784
- if (
896
+ // #881: untyped original testimony retains diagnostic/identity, not a
897
+ // fabricated activation/output cause. Bare details remain secondary evidence.
898
+ if (input.knownCause !== undefined ||
785
899
  (input.knownDiagnostic !== undefined && input.knownDiagnostic.trim() !== "") ||
786
- input.knownIdentity !== undefined
787
- ) {
788
- const diagnostic =
789
- input.knownDiagnostic !== undefined && input.knownDiagnostic.trim() !== ""
790
- ? input.knownDiagnostic
791
- : conciseChildDiagnostic(input.stderr, "role run failed");
792
- const { timedOut: _knownTimedOut, ...knownDetails } =
793
- input.knownDetails ?? {};
794
- const remoteCode = knownDetails.code;
795
- return withKnownDetails(
796
- {
797
- diagnostic,
798
- details: {
799
- ...knownDetails,
800
- ...(remoteCode === undefined ? {} : { code: remoteCode }),
801
- exitCode: input.code,
802
- ...(input.timedOut ? { timedOut: true as const } : {}),
803
- },
804
- ...(input.knownIdentity === undefined
805
- ? {}
806
- : { identity: input.knownIdentity }),
807
- },
808
- undefined,
809
- );
900
+ input.knownIdentity !== undefined) {
901
+ const fallback =
902
+ input.knownCause === "provider" ? "provider failure"
903
+ : input.knownCause === "session" ? "session unreadable"
904
+ : input.knownCause === "output" ? "role run completed without a lawful typed terminal result"
905
+ : input.knownCause === undefined ? "role run failed"
906
+ : `role run failed (${input.knownCause})`;
907
+ const diagnostic = input.knownDiagnostic !== undefined && input.knownDiagnostic.trim() !== ""
908
+ ? input.knownDiagnostic
909
+ : conciseChildDiagnostic(input.stderr, fallback);
910
+ const packageFact = packageFactsOf(input);
911
+ return {
912
+ ...(input.knownCause === undefined ? {} : { cause: input.knownCause }),
913
+ diagnostic,
914
+ // The host's details are handed back byte-for-byte. This package's own
915
+ // view of the exit rides beside them — writing `exitCode` in there would
916
+ // overwrite a value the host itself carried (host-contracts.ts:41: an
917
+ // open read-only record with no reserved keys).
918
+ ...(input.knownDetails === undefined ? {} : { details: input.knownDetails }),
919
+ ...(packageFact === undefined ? {} : { packageFact }),
920
+ ...(input.knownIdentity === undefined ? {} : { identity: input.knownIdentity }),
921
+ };
810
922
  }
811
923
  if (input.timedOut) {
812
924
  return withKnownDetails(
@@ -816,46 +928,38 @@ export function classifyPostAdmissionFailure(input: {
816
928
  details: { timedOut: true, exitCode: input.code },
817
929
  },
818
930
  input.knownDetails,
931
+ packageFactsOf(input),
819
932
  );
820
933
  }
821
934
  if (input.code !== 0) {
822
- const fallback = `role run failed with exit ${input.code ?? "null"}`;
935
+ // A nonzero exit says the run failed, not why. No typed fact confirmed a
936
+ // class, so `cause` stays absent and the CLI's own diagnostic and exit code
937
+ // are what remains (host contract: omit cause, keep the original). A signal
938
+ // or cancellation the host did report is named, not restated as an exit.
939
+ const fallback = hostReportedFallbackOf(input);
823
940
  return withKnownDetails(
824
941
  {
825
- cause: "activation",
826
942
  diagnostic: conciseChildDiagnostic(input.stderr, fallback),
827
943
  details: { exitCode: input.code },
828
944
  },
829
945
  input.knownDetails,
946
+ packageFactsOf(input),
830
947
  );
831
948
  }
832
- if (input.session?.state === "missing") {
833
- return withKnownDetails(
834
- {
835
- cause: "session",
836
- diagnostic: "role run left no readable session transcript",
837
- details: { exitCode: input.code, session: "missing" },
838
- },
839
- input.knownDetails,
840
- );
841
- }
842
- if (input.session?.state === "unreadable") {
843
- return withKnownDetails(
844
- {
845
- cause: "session",
846
- diagnostic: input.session.diagnostic,
847
- details: { exitCode: input.code, session: "unreadable" },
848
- },
849
- input.knownDetails,
850
- );
851
- }
949
+ // No typed fact confirmed a class, so none is asserted: the host exited
950
+ // cleanly, and the package has nothing to add (host-contracts.ts:19–42: omit
951
+ // cause and keep the original when no typed confirmation exists). A caller
952
+ // that must present a failure for a clean run settles it on the no_receipt
953
+ // path, not by minting a class here.
852
954
  return withKnownDetails(
853
955
  {
854
- cause: "output",
855
- diagnostic: "role run completed without a lawful typed terminal result",
856
- details: { exitCode: input.code },
956
+ diagnostic: conciseChildDiagnostic(
957
+ input.stderr,
958
+ "role run completed without a lawful typed terminal result",
959
+ ),
857
960
  },
858
961
  input.knownDetails,
962
+ packageFactsOf(input),
859
963
  );
860
964
  }
861
965
 
@@ -914,998 +1018,28 @@ type SessionEntry = {
914
1018
  data?: unknown;
915
1019
  timestamp?: string;
916
1020
  /** Session principal id from the durable header entry. */
917
- id?: string;
918
- /** Session cwd from the durable header entry. */
919
- cwd?: string;
920
- /** Parent session principal on durable child session headers. */
921
- parentSession?: string;
922
- };
923
-
924
- /**
925
- * Preserve session-read failure identity as a typed session cause.
926
- * SyntaxError keeps its name so durable settlement does not wash malformed JSONL
927
- * into generic output absence.
928
- */
929
- function sessionReadFailure(
930
- error: unknown,
931
- fallbackMessage: string,
932
- ): Error & {
933
- knownCause: ControlledFailureCause;
934
- failureCode?: string | number;
935
- } {
936
- if (error instanceof SyntaxError) {
937
- const failed = new SyntaxError(
938
- error.message || fallbackMessage,
939
- ) as SyntaxError & {
940
- knownCause: ControlledFailureCause;
941
- failureCode?: string | number;
942
- };
943
- failed.knownCause = "session";
944
- return failed;
945
- }
946
- if (error instanceof Error) {
947
- const failed = new Error(
948
- error.message || error.name || fallbackMessage,
949
- ) as Error & {
950
- knownCause: ControlledFailureCause;
951
- failureCode?: string | number;
952
- code?: string | number;
953
- };
954
- failed.name = error.name || "Error";
955
- failed.knownCause = "session";
956
- const code = (error as { code?: unknown }).code;
957
- if (typeof code === "string" || typeof code === "number") {
958
- failed.failureCode = code;
959
- failed.code = code;
960
- }
961
- return failed;
962
- }
963
- const failed = new Error(String(error)) as Error & {
964
- knownCause: ControlledFailureCause;
965
- failureCode?: string | number;
966
- };
967
- failed.knownCause = "session";
968
- return failed;
969
- }
970
-
971
- /**
972
- * Read the exact bound Pi session file principal.
973
- * Does not scan the session directory for "latest" — resume identity is the file.
974
- */
975
- export async function readBoundSessionEntries(
976
- sessionFile: string,
977
- ): Promise<SessionEntry[]> {
978
- const text = await readFile(sessionFile, "utf8");
979
- const entries: SessionEntry[] = [];
980
- for (const line of text.trim().split("\n").filter(Boolean)) {
981
- try {
982
- entries.push(JSON.parse(line) as SessionEntry);
983
- } catch (error) {
984
- throw sessionReadFailure(error, "malformed session JSONL");
985
- }
986
- }
987
- return entries;
988
- }
989
-
990
- /**
991
- * Latest native assistant provider-stop in a session (stopReason error|aborted).
992
- * Only the final assistant turn decides terminality — an older error followed by a
993
- * later non-error stop is not a provider failure (would wash a no-lawful-output path).
994
- * Typed production source for provider cause — not child stderr prose.
995
- */
996
- type SessionProviderStop = {
997
- stopReason: "error" | "aborted";
998
- errorMessage?: string;
999
- provider?: string;
1000
- model?: string;
1001
- api?: string;
1002
- rawStopReason?: string;
1003
- diagnostics?: unknown;
1004
- httpStatus?: number;
1005
- body?: unknown;
1006
- code?: unknown;
1007
- errno?: unknown;
1008
- };
1009
-
1010
- function typedHttpStatusFromMessage(message: SessionMessage): number | undefined {
1011
- for (const candidate of [message.httpStatus, message.statusCode, message.status]) {
1012
- if (typeof candidate === "number" && (candidate < 200 || candidate >= 300)) return candidate;
1013
- }
1014
- return undefined;
1015
- }
1016
-
1017
- function sessionProviderStopFromAssistant(message: SessionMessage | undefined): SessionProviderStop | undefined {
1018
- if (message?.role !== "assistant") return undefined;
1019
- if (message.stopReason !== "error" && message.stopReason !== "aborted") return undefined;
1020
- const httpStatus = typedHttpStatusFromMessage(message);
1021
- return {
1022
- stopReason: message.stopReason,
1023
- // Preserve held errorMessage bytes — emptiness check must not rewrite.
1024
- ...(typeof message.errorMessage === "string" && message.errorMessage.trim() !== ""
1025
- ? { errorMessage: message.errorMessage }
1026
- : {}),
1027
- ...(typeof message.provider === "string" && message.provider.trim() !== ""
1028
- ? { provider: message.provider }
1029
- : {}),
1030
- ...(typeof message.model === "string" && message.model.trim() !== ""
1031
- ? { model: message.model }
1032
- : {}),
1033
- ...(typeof message.api === "string" && message.api.trim() !== ""
1034
- ? { api: message.api }
1035
- : {}),
1036
- ...(typeof message.rawStopReason === "string" && message.rawStopReason.trim() !== ""
1037
- ? { rawStopReason: message.rawStopReason }
1038
- : {}),
1039
- ...(message.diagnostics === undefined ? {} : { diagnostics: message.diagnostics }),
1040
- ...(httpStatus === undefined ? {} : { httpStatus }),
1041
- ...(message.body === undefined ? {} : { body: message.body }),
1042
- ...(message.code === undefined ? {} : { code: message.code }),
1043
- ...(message.errno === undefined ? {} : { errno: message.errno }),
1044
- };
1045
- }
1046
-
1047
- export function extractSessionProviderStop(
1048
- entries: readonly SessionEntry[],
1049
- ): SessionProviderStop | undefined {
1050
- // A resumed dispatch appends a typed top-level user turn to the same session.
1051
- // Older attempt native stops must not replace the newer attempt's stop.
1052
- // Sessions without a user turn are the initial attempt.
1053
- // Auditor retained responses live in Sitian (kind=auditor); see readSessionProviderStop.
1054
- let attemptStart = 0;
1055
- for (let i = entries.length - 1; i >= 0; i -= 1) {
1056
- const entry = entries[i];
1057
- if (entry?.type === "message" && entry.message?.role === "user") {
1058
- attemptStart = i;
1059
- break;
1060
- }
1061
- }
1062
-
1063
- for (let i = entries.length - 1; i >= attemptStart; i -= 1) {
1064
- const entry = entries[i];
1065
- if (entry?.type !== "message") continue;
1066
- const message = entry.message;
1067
- if (message?.role !== "assistant") continue;
1068
- // Latest assistant in the current attempt only.
1069
- return sessionProviderStopFromAssistant(message);
1070
- }
1071
- return undefined;
1072
- }
1073
-
1074
- /**
1075
- * Latest Sitian-retained auditor response stop for this parent session principal.
1076
- * Writer: retainComplianceResponse → sitianReport(kind=auditor, payload={version,response}).
1077
- * Payload stopReason is preserved as retained — aborted stays aborted; no 500/error wash here.
1078
- */
1079
- async function readSitianRetainedAuditorProviderStop(
1080
- sessionFile: string,
1081
- ): Promise<SessionProviderStop | undefined> {
1082
- try {
1083
- const { recordFile } = resolveSitianRecordPath({
1084
- level: "event",
1085
- kind: "auditor",
1086
- sessionParent: sessionFile,
1087
- // Path is driven by sessionParent when under ledger home; cwd is a fallback only.
1088
- cwd: dirname(sessionFile),
1089
- });
1090
- const { records } = await readSitianRecords(recordFile);
1091
- for (let i = records.length - 1; i >= 0; i -= 1) {
1092
- const payload = records[i]?.payload;
1093
- if (!isRecord(payload) || !isRecord(payload.response)) continue;
1094
- // Lifecycle events carry `type` (binding / compliance_failure); retain does not.
1095
- if (typeof payload.type === "string") continue;
1096
- const stop = sessionProviderStopFromAssistant(payload.response as SessionMessage);
1097
- if (stop !== undefined) return stop;
1098
- // Latest retain exists but is not a provider-stop — do not scan older retains
1099
- // (mirrors former session COMPLIANCE_RESPONSE preference break).
1100
- break;
1101
- }
1102
- } catch {
1103
- // Missing volume or unreadable path is absence, not a settlement failure.
1104
- }
1105
- return undefined;
1106
- }
1107
-
1108
- /** Read retained auditor stop (Sitian) then native session assistant stop, if any. */
1109
- export async function readSessionProviderStop(
1110
- sessionFile: string,
1111
- ): Promise<SessionProviderStop | undefined> {
1112
- const retained = await readSitianRetainedAuditorProviderStop(sessionFile);
1113
- if (retained !== undefined) return retained;
1114
- try {
1115
- const entries = await readBoundSessionEntries(sessionFile);
1116
- return extractSessionProviderStop(entries);
1117
- } catch {
1118
- return undefined;
1119
- }
1120
- }
1121
-
1122
- type BoundAuditorVolume = {
1123
- readonly entries: SessionEntry[];
1124
- readonly attemptEntryId?: string;
1125
- readonly parentId: string;
1126
- readonly sessionFile: string;
1127
- };
1128
-
1129
- async function loadBoundAuditorVolumes(
1130
- sessionFile: string,
1131
- ): Promise<readonly BoundAuditorVolume[] | undefined> {
1132
- let parentEntries: SessionEntry[];
1133
- try {
1134
- parentEntries = await readBoundSessionEntries(sessionFile);
1135
- } catch (error) {
1136
- if (isEnoent(error)) return undefined;
1137
- throw sessionReadFailure(error, "failed to read parent session for auditor binding");
1138
- }
1139
- const parentId = parentEntries.find((entry) => entry.type === "session")?.id;
1140
- if (parentId === undefined) return undefined;
1141
- const childDirectories = [join(dirname(sessionFile), "auditor-roles")];
1142
- const valid: BoundAuditorVolume[] = [];
1143
- let sawAnyDirectory = false;
1144
- for (const childDirectory of childDirectories) {
1145
- let names: string[];
1146
- try {
1147
- names = await readdir(childDirectory);
1148
- sawAnyDirectory = true;
1149
- } catch (error) {
1150
- if (isEnoent(error)) continue;
1151
- throw sessionReadFailure(error, "failed to read bound auditor session directory");
1152
- }
1153
- for (const file of names.filter((name) => name.endsWith(".jsonl")).sort().reverse()) {
1154
- let entries: SessionEntry[];
1155
- try {
1156
- entries = await readBoundSessionEntries(join(childDirectory, file));
1157
- } catch (error) {
1158
- throw sessionReadFailure(error, "failed to read discovered auditor session");
1159
- }
1160
- const header = entries.find((entry) => entry.type === "session");
1161
- if (!isRecord(header)) continue;
1162
- // Parent-attempt binding owns its interval on multi-attempt volumes
1163
- // (never whole-volume provider/compliance).
1164
- const bindingIndexes: number[] = [];
1165
- for (let i = 0; i < entries.length; i += 1) {
1166
- const entry = entries[i];
1167
- if (entry?.type === "custom" && entry.customType === AUDITOR_PARENT_ATTEMPT_BINDING_ENTRY_TYPE) {
1168
- bindingIndexes.push(i);
1169
- }
1170
- }
1171
- const bindingPasses: Array<{ entry: SessionEntry | undefined; start: number; end: number }> =
1172
- bindingIndexes.length > 0
1173
- ? bindingIndexes.map((start, idx) => ({
1174
- entry: entries[start],
1175
- start,
1176
- end: idx + 1 < bindingIndexes.length ? bindingIndexes[idx + 1]! : entries.length,
1177
- }))
1178
- : [{ entry: undefined, start: 0, end: entries.length }];
1179
- for (const { entry: bindingEntry, start, end } of bindingPasses) {
1180
- const bindingParent =
1181
- bindingEntry !== undefined &&
1182
- isRecord(bindingEntry.data) &&
1183
- isRecord(bindingEntry.data.parent)
1184
- ? bindingEntry.data.parent
1185
- : undefined;
1186
- const attemptEntryId =
1187
- typeof bindingParent?.attemptEntryId === "string"
1188
- ? bindingParent.attemptEntryId
1189
- : undefined;
1190
- const boundSessionFile =
1191
- typeof bindingParent?.sessionFile === "string"
1192
- ? bindingParent.sessionFile
1193
- : typeof header.parentSession === "string"
1194
- ? header.parentSession
1195
- : undefined;
1196
- if (boundSessionFile !== sessionFile) continue;
1197
- if (bindingParent !== undefined && bindingParent.sessionId !== parentId) continue;
1198
- if (bindingParent === undefined && header.parentSession !== sessionFile) continue;
1199
- valid.push({
1200
- entries: entries.slice(start, end),
1201
- parentId,
1202
- sessionFile,
1203
- ...(attemptEntryId === undefined ? {} : { attemptEntryId }),
1204
- });
1205
- // Keep every interval bound to this parent. Auditor payload is relayed as
1206
- // recorded; code does not expire it from later user-message shape (#858).
1207
- }
1208
- }
1209
- }
1210
- if (!sawAnyDirectory && valid.length === 0) return undefined;
1211
- return valid;
1212
- }
1213
-
1214
- function complianceFailureFromAuditorVolumes(
1215
- volumes: readonly BoundAuditorVolume[],
1216
- ): RoleTurnKnownFailure | undefined {
1217
- for (const { entries, attemptEntryId, parentId, sessionFile } of volumes) {
1218
- const stop = extractSessionProviderStop(entries);
1219
- if (stop === undefined) continue;
1220
- for (let i = entries.length - 1; i >= 0; i -= 1) {
1221
- const entry = entries[i];
1222
- if (entry?.type !== "custom" || entry.customType !== AUDITOR_COMPLIANCE_FAILURE_ENTRY_TYPE || !isRecord(entry.data)) continue;
1223
- const parent = isRecord(entry.data.parent) ? entry.data.parent : undefined;
1224
- const failure = isRecord(entry.data.failure) ? entry.data.failure : undefined;
1225
- if (parent?.sessionId !== parentId || parent.sessionFile !== sessionFile || parent.attemptEntryId !== attemptEntryId) continue;
1226
- // #881: keep the recorded failure as written — typed cause when present, else raw diagnostic only.
1227
- if (failure === undefined) continue;
1228
- const identity = isRecord(failure.identity) ? failure.identity : undefined;
1229
- const typedCause =
1230
- failure.cause === "provider" ||
1231
- failure.cause === "activation" ||
1232
- failure.cause === "session" ||
1233
- failure.cause === "output" ||
1234
- failure.cause === "timeout"
1235
- ? (failure.cause as ControlledFailureCause)
1236
- : undefined;
1237
- return {
1238
- ...(typedCause === undefined ? {} : { cause: typedCause }),
1239
- ...(identity === undefined ? {} : { identity: {
1240
- ...(typeof identity.name === "string" ? { name: identity.name } : {}),
1241
- ...(typeof identity.code === "string" || typeof identity.code === "number" ? { code: identity.code } : {}),
1242
- } }),
1243
- ...(typeof failure.diagnostic === "string" ? { diagnostic: failure.diagnostic } : {}),
1244
- ...(isRecord(failure.details) ? { details: failure.details } : {}),
1245
- };
1246
- }
1247
- }
1248
- return undefined;
1249
- }
1250
-
1251
- function providerStopFallbackFromAuditorVolumes(
1252
- volumes: readonly BoundAuditorVolume[],
1253
- ): RoleTurnKnownFailure | undefined {
1254
- for (const { entries } of volumes) {
1255
- const stop = extractSessionProviderStop(entries);
1256
- if (stop === undefined) continue;
1257
- const primary = knownFailureFromProviderStop(stop)!;
1258
- return {
1259
- ...primary,
1260
- details: {
1261
- ...(primary.details ?? {}),
1262
- secondaryEvidence: "unavailable",
1263
- },
1264
- };
1265
- }
1266
- return undefined;
1267
- }
1268
-
1269
- /** Strong auditor tier only — retained compliance-failure entries, no provider-stop fallback. */
1270
- async function readBoundAuditorComplianceFailure(
1271
- sessionFile: string,
1272
- ): Promise<RoleTurnKnownFailure | undefined> {
1273
- const volumes = await loadBoundAuditorVolumes(sessionFile);
1274
- if (volumes === undefined) return undefined;
1275
- return complianceFailureFromAuditorVolumes(volumes);
1276
- }
1277
-
1278
- /** Weaker auditor tier: provider stop without a retained compliance-failure entry. */
1279
- async function readBoundAuditorProviderStopFallback(
1280
- sessionFile: string,
1281
- ): Promise<RoleTurnKnownFailure | undefined> {
1282
- const volumes = await loadBoundAuditorVolumes(sessionFile);
1283
- if (volumes === undefined) return undefined;
1284
- return providerStopFallbackFromAuditorVolumes(volumes);
1285
- }
1286
-
1287
- function typedFailedTerminatingToolKnownFailure(
1288
- entries: readonly SessionEntry[],
1289
- ): RoleTurnKnownFailure | undefined {
1290
- let attemptStart = 0;
1291
- for (let i = entries.length - 1; i >= 0; i -= 1) {
1292
- if (entries[i]?.type === "message" && entries[i]?.message?.role === "user") {
1293
- attemptStart = i;
1294
- break;
1295
- }
1296
- }
1297
- const attemptEntries = entries.slice(attemptStart);
1298
- for (let i = attemptEntries.length - 1; i >= 0; i -= 1) {
1299
- const message = attemptEntries[i]?.message;
1300
- if (attemptEntries[i]?.type !== "message" || message?.role !== "toolResult") continue;
1301
- const classification = classifyPackagedRoleTerminalResult(message);
1302
- if (classification.kind !== "infrastructure") continue;
1303
- if (typeof message.toolCallId !== "string" || typeof message.toolName !== "string") continue;
1304
- if (boundRoleToolCallForResult(attemptEntries, i, message, message.toolName) === undefined) continue;
1305
- const textPart = Array.isArray(message.content)
1306
- ? message.content.find((part) => isRecord(part) && part.type === "text" && typeof part.text === "string")
1307
- : undefined;
1308
- const diagnostic = isRecord(textPart) ? textPart.text : undefined;
1309
- // Durable details already carry fact + typed evidence from envelope one-shot projection (#475).
1310
- // Do not re-parse retained compliance responses here.
1311
- // Host infrastructure must NOT map to cause=output — that cause is reserved for
1312
- // role-output failures (isError residual / no sealed receipt), not host infra.
1313
- const details = isRecord(message.details) ? message.details : classification.fact;
1314
- return {
1315
- cause: "activation",
1316
- identity: { name: message.toolName, code: message.toolCallId },
1317
- ...(typeof diagnostic === "string" && diagnostic.trim() !== "" ? { diagnostic } : {}),
1318
- details,
1319
- };
1320
- }
1321
- return undefined;
1322
- }
1323
-
1324
- /**
1325
- * One audited-runner resolution: knownFailure plus the typed-HTTP sidecar outcome
1326
- * from the same read. Callers that also decide v1 resume must consume this once —
1327
- * never re-read the sidecar in presentControlledFailure.
1328
- */
1329
- export type AuditedRunnerFailureResolution = {
1330
- readonly knownFailure?: RoleTurnKnownFailure;
1331
- /** Successful sidecar read (not absence). */
1332
- readonly typedHttpObservation?: TypedProviderHttpObservation;
1333
- /**
1334
- * True when this resolution already performed the typed-HTTP sidecar read
1335
- * (success, absence, or non-absence failure folded into knownFailure).
1336
- * False when an earlier evidence tier short-circuited before the sidecar.
1337
- */
1338
- readonly typedHttpObservationSettled: boolean;
1339
- };
1340
-
1341
- function resolutionOf(
1342
- knownFailure: RoleTurnKnownFailure | undefined,
1343
- typedHttp: {
1344
- readonly settled: boolean;
1345
- readonly observation?: TypedProviderHttpObservation;
1346
- } = { settled: false },
1347
- ): AuditedRunnerFailureResolution {
1348
- return {
1349
- ...(knownFailure === undefined ? {} : { knownFailure }),
1350
- ...(typedHttp.observation === undefined ? {} : { typedHttpObservation: typedHttp.observation }),
1351
- typedHttpObservationSettled: typedHttp.settled,
1352
- };
1353
- }
1354
-
1355
- /** Sole evidence-priority owner for public runners with Soul auditors. */
1356
- export async function resolveAuditedRunnerFailureResolution(input: {
1357
- runner: RoleTurnKnownFailure | undefined;
1358
- sessionFile: string;
1359
- credential: RoleTurnKnownFailure | undefined;
1360
- /** Optional run directory for typed provider HTTP observation (resume/429). */
1361
- runDirectory?: string;
1362
- }): Promise<AuditedRunnerFailureResolution> {
1363
- if (input.runner !== undefined) return resolutionOf(input.runner);
1364
- // Bound auditor compliance-failure retention outranks a parent failure that the
1365
- // auditor path itself caused (retention EISDIR race). A typed terminating-tool
1366
- // host failure is next — it outranks weaker auditor provider-stop fallback so
1367
- // parent failInfrastructure abort pollution cannot wash a real diagnostic (#475).
1368
- try {
1369
- const auditorCompliance = await readBoundAuditorComplianceFailure(input.sessionFile);
1370
- if (auditorCompliance !== undefined) return resolutionOf(auditorCompliance);
1371
- } catch (error) {
1372
- const failure = sessionReadFailure(error, "failed to recover bound auditor failure");
1373
- return resolutionOf({
1374
- cause: "session",
1375
- identity: thrownIdentity(failure),
1376
- diagnostic: failure.message || failure.name,
1377
- });
1378
- }
1379
- try {
1380
- const terminatingFailure = typedFailedTerminatingToolKnownFailure(
1381
- await readBoundSessionEntries(input.sessionFile),
1382
- );
1383
- if (terminatingFailure !== undefined) return resolutionOf(terminatingFailure);
1384
- } catch (error) {
1385
- if (!isEnoent(error)) {
1386
- const failure = sessionReadFailure(error, "failed to recover typed terminating-tool failure");
1387
- return resolutionOf({
1388
- cause: "session",
1389
- identity: thrownIdentity(failure),
1390
- diagnostic: failure.message || failure.name,
1391
- });
1392
- }
1393
- }
1394
- try {
1395
- const auditorStop = await readBoundAuditorProviderStopFallback(input.sessionFile);
1396
- if (auditorStop !== undefined) return resolutionOf(auditorStop);
1397
- } catch (error) {
1398
- const failure = sessionReadFailure(error, "failed to recover bound auditor provider stop");
1399
- return resolutionOf({
1400
- cause: "session",
1401
- identity: thrownIdentity(failure),
1402
- diagnostic: failure.message || failure.name,
1403
- });
1404
- }
1405
- // Parent session provider-stop is next; credential is last.
1406
- const parentStop = await readSessionProviderStop(input.sessionFile);
1407
- // Typed HTTP observation: ENOENT=absence; other read/parse/shape failures keep real cause.
1408
- // This is the single sidecar read for both knownFailure projection and v1 resume.
1409
- let httpObservation: TypedProviderHttpObservation | undefined;
1410
- if (input.runDirectory !== undefined) {
1411
- try {
1412
- httpObservation = await readLatestTypedProviderHttpObservation(input.runDirectory);
1413
- } catch (error) {
1414
- const failure = error instanceof Error ? error : new Error(String(error));
1415
- return resolutionOf(
1416
- {
1417
- cause: "session",
1418
- identity: thrownIdentity(failure),
1419
- diagnostic: failure.message || failure.name,
1420
- },
1421
- { settled: true },
1422
- );
1423
- }
1424
- }
1425
- const typedHttp = {
1426
- settled: input.runDirectory !== undefined,
1427
- ...(httpObservation === undefined ? {} : { observation: httpObservation }),
1428
- };
1429
- if (parentStop === undefined) {
1430
- if (input.credential !== undefined) return resolutionOf(input.credential, typedHttp);
1431
- if (httpObservation === undefined) return resolutionOf(undefined, typedHttp);
1432
- // Project the HTTP observation's status + provider/source association.
1433
- return resolutionOf(
1434
- knownFailureFromProviderStop({
1435
- stopReason: "error",
1436
- httpStatus: httpObservation.httpStatus,
1437
- provider: httpObservation.provider,
1438
- }),
1439
- typedHttp,
1440
- );
1441
- }
1442
- return resolutionOf(
1443
- knownFailureFromProviderStop({
1444
- ...parentStop,
1445
- ...(httpObservation === undefined
1446
- ? {}
1447
- : {
1448
- httpStatus: httpObservation.httpStatus,
1449
- // Observation association outranks session-configured provider name alone.
1450
- provider: httpObservation.provider,
1451
- }),
1452
- }),
1453
- typedHttp,
1454
- );
1455
- }
1456
-
1457
- /** Sole evidence-priority owner for public runners with Soul auditors. */
1458
- export async function resolveAuditedRunnerKnownFailure(input: {
1459
- runner: RoleTurnKnownFailure | undefined;
1460
- sessionFile: string;
1461
- credential: RoleTurnKnownFailure | undefined;
1462
- /** Optional run directory for typed provider HTTP observation (resume/429). */
1463
- runDirectory?: string;
1464
- }): Promise<RoleTurnKnownFailure | undefined> {
1465
- return (await resolveAuditedRunnerFailureResolution(input)).knownFailure;
1466
- }
1467
-
1468
- /**
1469
- * v1 resume observation for controlled-failure settlement — at most one sidecar read.
1470
- * Prefer the pre-resolved outcome from resolveAuditedRunnerFailureResolution.
1471
- * Non-absence failures never throw: they return observationReadFailure for the
1472
- * existing controlled-failure → error.json chain.
1473
- */
1474
- export async function resolveControlledFailureResumeObservation(input: {
1475
- readonly runDirectory: string;
1476
- readonly typedHttpObservationSettled?: boolean;
1477
- readonly typedHttpObservation?: TypedProviderHttpObservation;
1478
- }): Promise<{
1479
- readonly typedHttp429?: TypedHttp429Observation;
1480
- readonly observationReadFailure?: RoleTurnKnownFailure;
1481
- }> {
1482
- if (input.typedHttpObservationSettled === true) {
1483
- const observation = input.typedHttpObservation;
1484
- if (
1485
- observation !== undefined &&
1486
- observation.httpStatus === 429 &&
1487
- isV1ResumableProvider(observation.provider)
1488
- ) {
1489
- return {
1490
- typedHttp429: { httpStatus: 429, provider: observation.provider },
1491
- };
1492
- }
1493
- return {};
1494
- }
1495
- try {
1496
- const typedHttp429 = await readTypedHttp429Observation(input.runDirectory);
1497
- return typedHttp429 === undefined ? {} : { typedHttp429 };
1498
- } catch (error) {
1499
- const failure = error instanceof Error ? error : new Error(String(error));
1500
- return {
1501
- observationReadFailure: {
1502
- cause: "session",
1503
- identity: thrownIdentity(failure),
1504
- diagnostic: failure.message || failure.name,
1505
- },
1506
- };
1507
- }
1508
- }
1509
-
1510
- /** Spread into presentControlledFailure failureInput from one audited resolution. */
1511
- export function controlledFailureInputFromResolution(
1512
- resolution: AuditedRunnerFailureResolution,
1513
- ): {
1514
- knownFailure?: RoleTurnKnownFailure;
1515
- typedHttpObservationSettled?: true;
1516
- typedHttpObservation?: TypedProviderHttpObservation;
1517
- } {
1518
- return {
1519
- ...(resolution.knownFailure === undefined ? {} : { knownFailure: resolution.knownFailure }),
1520
- ...(resolution.typedHttpObservationSettled
1521
- ? {
1522
- typedHttpObservationSettled: true as const,
1523
- ...(resolution.typedHttpObservation === undefined
1524
- ? {}
1525
- : { typedHttpObservation: resolution.typedHttpObservation }),
1526
- }
1527
- : {}),
1528
- };
1529
- }
1530
-
1531
- function safelyRead(object: object, key: string): { readable: true; value: unknown } | { readable: false } {
1532
- try {
1533
- return { readable: true, value: (object as Record<string, unknown>)[key] };
1534
- } catch {
1535
- return { readable: false };
1536
- }
1537
- }
1538
-
1539
- function toolResultText(message: SessionMessage): string {
1540
- const content = message.content;
1541
- if (typeof content === "string") return content.trim();
1542
- if (!Array.isArray(content)) return "";
1543
- return content
1544
- .map((part) => {
1545
- if (
1546
- isRecord(part) &&
1547
- typeof (part as { text?: unknown }).text === "string"
1548
- ) {
1549
- return (part as { text: string }).text;
1550
- }
1551
- return "";
1552
- })
1553
- .join("")
1554
- .trim();
1555
- }
1556
-
1557
- type BoundErroredToolCandidate = {
1558
- candidate: unknown;
1559
- diagnostic: string;
1560
- callIndex: number;
1561
- };
1562
-
1563
- function boundErroredToolCandidate(
1564
- entries: readonly SessionEntry[],
1565
- resultIndex: number,
1566
- message: SessionMessage,
1567
- toolName: string,
1568
- ): BoundErroredToolCandidate | undefined {
1569
- if (message.toolName !== toolName || message.isError !== true) return undefined;
1570
- const bound = boundRoleToolCallForResult(entries, resultIndex, message, toolName);
1571
- const diagnostic = toolResultText(message);
1572
- return bound === undefined || diagnostic === ""
1573
- ? undefined
1574
- : { candidate: bound.candidate, diagnostic, callIndex: bound.callIndex };
1575
- }
1576
-
1577
- /** Shared match/cause/identity knobs for session-principal infrastructure failures. */
1578
- type InfrastructureFailureSpec = Readonly<{
1579
- matchTool: (toolName: string) => boolean;
1580
- cause: ControlledFailureCause;
1581
- identityName: string;
1582
- /**
1583
- * Errored results only count as infrastructure when the durable details carry
1584
- * the typed navigator fact.
1585
- */
1586
- requireInfrastructureFact?: (toolName: string) => boolean;
1587
- }>;
1588
-
1589
- const ENGINE_DETOUR_INFRASTRUCTURE_FAILURE_SPEC: InfrastructureFailureSpec = {
1590
- matchTool: (toolName) => toolName === ENGINE_DETOUR_TOOL_NAME,
1591
- cause: "output",
1592
- identityName: "EngineDetourInfrastructureError",
1593
- };
1594
-
1595
- /**
1596
- * Prefer a real infrastructure tool failure already on the session principal
1597
- * over a later secondary provider-stop (failure-honesty).
1598
- * Tool match + cause + identity are call-site parameters — one extraction body.
1599
- */
1600
- function extractInfrastructureToolFailure(
1601
- entries: readonly SessionEntry[],
1602
- spec: InfrastructureFailureSpec,
1603
- ): ControlledFailure | undefined {
1604
- for (let i = entries.length - 1; i >= 0; i -= 1) {
1605
- const entry = entries[i];
1606
- if (entry?.type !== "message") continue;
1607
- const message = entry.message;
1608
- if (message?.role !== "toolResult") continue;
1609
- if (message.isError !== true) continue;
1610
- if (
1611
- typeof message.toolName !== "string" ||
1612
- !spec.matchTool(message.toolName)
1613
- ) {
1614
- continue;
1615
- }
1616
- if (spec.requireInfrastructureFact?.(message.toolName) === true) {
1617
- if (!hasNavigatorInfrastructureFailureBase(message.details)) continue;
1618
- }
1619
- const diagnostic = toolResultText(message);
1620
- if (diagnostic.length === 0) continue;
1621
- return {
1622
- cause: spec.cause,
1623
- diagnostic,
1624
- identity: { name: spec.identityName },
1625
- };
1626
- }
1627
- return undefined;
1628
- }
1629
-
1630
- /**
1631
- * Read the bound session principal for a parameterized infrastructure tool failure.
1632
- * `currentAttemptOnly` bounds the reverse scan to the latest top-level user turn
1633
- * so a prior attempt's residual cannot mask the current failure (#633).
1634
- */
1635
- async function readInfrastructureToolFailure(
1636
- sessionFile: string,
1637
- spec: InfrastructureFailureSpec,
1638
- options: { readonly currentAttemptOnly?: boolean } = {},
1639
- ): Promise<ControlledFailure | undefined> {
1640
- try {
1641
- let entries = await readBoundSessionEntries(sessionFile);
1642
- if (options.currentAttemptOnly === true) {
1643
- entries = entries.slice(currentAttemptStartIndex(entries));
1644
- }
1645
- return extractInfrastructureToolFailure(entries, spec);
1646
- } catch {
1647
- return undefined;
1648
- }
1649
- }
1650
-
1651
- /**
1652
- * Prefer a real engine-detour infrastructure tool failure already on the session
1653
- * principal over a later secondary provider-stop (failure-honesty / #357 T2).
1654
- * Cause stays `output` — labor leg failed before accepted typed Receipt.
1655
- */
1656
- export function extractEngineDetourInfrastructureFailure(
1657
- entries: readonly SessionEntry[],
1658
- ): ControlledFailure | undefined {
1659
- return extractInfrastructureToolFailure(
1660
- entries,
1661
- ENGINE_DETOUR_INFRASTRUCTURE_FAILURE_SPEC,
1662
- );
1663
- }
1664
-
1665
- /** Read the bound session principal for an engine-detour infrastructure failure. */
1666
- export async function readEngineDetourInfrastructureFailure(
1667
- sessionFile: string,
1668
- ): Promise<ControlledFailure | undefined> {
1669
- return readInfrastructureToolFailure(
1670
- sessionFile,
1671
- ENGINE_DETOUR_INFRASTRUCTURE_FAILURE_SPEC,
1672
- );
1673
- }
1674
-
1675
- function knownFailureFromControlled(failure: ControlledFailure): RoleTurnKnownFailure {
1676
- return {
1677
- ...(failure.cause === undefined ? {} : { cause: failure.cause }),
1678
- diagnostic: failure.diagnostic,
1679
- ...(failure.identity === undefined ? {} : { identity: failure.identity }),
1680
- };
1681
- }
1682
-
1683
- const RUNNER_FAILURE_POLICY = {
1684
- "engine-detour-known-first": {
1685
- read: readEngineDetourInfrastructureFailure,
1686
- rank: "known-first",
1687
- },
1688
- "engine-detour-record-first": {
1689
- read: readEngineDetourInfrastructureFailure,
1690
- rank: "record-first",
1691
- },
1692
- } as const;
1693
-
1694
- /**
1695
- * Runner-failure rank for one seat. The composition-root `runnerFailure`
1696
- * leaf is the only seat difference; absent means the runner knownFailure stands.
1697
- */
1698
- export function seatKnownFailureResolver(
1699
- role: PackagedRole,
1700
- ): ((input: {
1701
- result: { knownFailure?: RoleTurnKnownFailure };
1702
- sessionFile: string;
1703
- }) => Promise<RoleTurnKnownFailure | undefined>) | undefined {
1704
- const record = packagedRoleMetadata(role);
1705
- if (record === undefined || !("runnerFailure" in record)) return undefined;
1706
- const policy = RUNNER_FAILURE_POLICY[record.runnerFailure];
1707
- return async ({ result, sessionFile }) => {
1708
- const infrastructureFailure = await policy.read(sessionFile);
1709
- if (policy.rank === "record-first") {
1710
- return infrastructureFailure === undefined
1711
- ? result.knownFailure
1712
- : knownFailureFromControlled(infrastructureFailure);
1713
- }
1714
- return result.knownFailure ?? (
1715
- infrastructureFailure === undefined ? undefined : knownFailureFromControlled(infrastructureFailure)
1716
- );
1717
- };
1718
- }
1719
-
1720
- function auditToolNameForRole(
1721
- role: (typeof AUDITOR_SOUL_ROLES)[number],
1722
- ): string {
1723
- const name = packagedAuditToolName(role);
1724
- if (name === undefined) {
1725
- throw new Error(`audit tool is not declared for ${role}`);
1726
- }
1727
- return name;
1728
- }
1729
-
1730
- type BoundRoleToolCall = {
1731
- callIndex: number;
1732
- candidate: unknown;
1733
- };
1734
-
1735
- function boundRoleToolCallForResult(
1736
- entries: readonly SessionEntry[],
1737
- resultIndex: number,
1738
- message: SessionMessage,
1739
- outputToolName: string,
1740
- ): BoundRoleToolCall | undefined {
1741
- const callId = message.toolCallId;
1742
- if (typeof callId !== "string" || callId.trim() === "") return undefined;
1743
-
1744
- const calls: BoundRoleToolCall[] = [];
1745
- let resultCount = 0;
1746
- let matchingResultIndex = -1;
1747
- for (let index = 0; index < entries.length; index += 1) {
1748
- const candidateMessage = entries[index]?.message;
1749
- if (
1750
- candidateMessage?.role === "assistant" &&
1751
- Array.isArray(candidateMessage.content)
1752
- ) {
1753
- for (const part of candidateMessage.content) {
1754
- if (!isRecord(part) || part.type !== "toolCall" || part.id !== callId) {
1755
- continue;
1756
- }
1757
- if (part.name !== outputToolName) return undefined;
1758
- calls.push({ callIndex: index, candidate: part.arguments });
1759
- }
1760
- }
1761
- if (
1762
- candidateMessage?.role === "toolResult" &&
1763
- candidateMessage.toolCallId === callId
1764
- ) {
1765
- resultCount += 1;
1766
- if (candidateMessage.toolName !== outputToolName) return undefined;
1767
- matchingResultIndex = index;
1768
- }
1769
- }
1770
-
1771
- // A binding is an event-bound one-to-one relation, not a reverse lookup of
1772
- // whichever result happens to be last in the session.
1773
- return calls.length === 1 && resultCount === 1 && matchingResultIndex === resultIndex
1774
- && calls[0]!.callIndex < resultIndex
1775
- ? calls[0]
1776
- : undefined;
1777
- }
1778
-
1779
- type BoundRetainedAuditResponse = {
1780
- candidate: unknown;
1781
- };
1782
-
1783
- type BoundAuditEscalation = {
1784
- decision: Extract<ComplianceDecision, { status: "escalate" }>;
1785
- details: Record<string, unknown>;
1021
+ id?: string;
1022
+ /** Session cwd from the durable header entry. */
1023
+ cwd?: string;
1024
+ /** Parent session principal on durable child session headers. */
1025
+ parentSession?: string;
1786
1026
  };
1787
1027
 
1788
- function sameAuditValue(left: unknown, right: unknown): boolean {
1789
- if (Object.is(left, right)) return true;
1790
- if (Array.isArray(left) && Array.isArray(right)) {
1791
- return left.length === right.length && left.every((value, index) =>
1792
- sameAuditValue(value, right[index]),
1793
- );
1794
- }
1795
- if (isRecord(left) && isRecord(right)) {
1796
- const leftKeys = Object.keys(left);
1797
- const rightKeys = Object.keys(right);
1798
- return leftKeys.length === rightKeys.length &&
1799
- leftKeys.every((key) => Object.hasOwn(right, key) && sameAuditValue(left[key], right[key]));
1800
- }
1801
- return false;
1802
- }
1803
-
1804
- /** Snapshot the exact enumerable string face that final Terminal projection uses. */
1805
- function snapshotAuditDetails(details: Record<string, unknown>): Record<string, unknown> {
1806
- const snapshot: Record<string, unknown> = Object.create(null);
1807
- for (const key of Object.keys(details)) {
1808
- Object.defineProperty(snapshot, key, {
1809
- value: details[key],
1810
- enumerable: true,
1811
- configurable: true,
1812
- writable: true,
1813
- });
1814
- }
1815
- return snapshot;
1816
- }
1817
-
1818
1028
  /**
1819
- * Bind the public escalation face to the one retained response that sits inside
1820
- * the same role output call/result interval. A `kind` field alone is never a
1821
- * terminal identity; the retained response must be this seat's real escalate
1822
- * decision and its projected audit-owned fields must agree with it.
1029
+ * Read the exact bound Pi session file principal.
1030
+ * Does not scan the session directory for "latest" — resume identity is the file.
1031
+ * A malformed line keeps the parser's own error. Callers that already hold a
1032
+ * terminal note it; they do not relabel it as a host session cause.
1823
1033
  */
1824
- // Historical pre-#1057 session artifacts may still contain this old projection;
1825
- // no current submission path writes it, but resume/settlement must read it.
1826
- function boundAuditEscalationForResult(
1827
- entries: readonly SessionEntry[],
1828
- resultIndex: number,
1829
- message: SessionMessage,
1830
- role: (typeof AUDITOR_SOUL_ROLES)[number],
1831
- outputToolName: string,
1832
- ): BoundAuditEscalation | undefined {
1833
- const roleCall = boundRoleToolCallForResult(
1834
- entries,
1835
- resultIndex,
1836
- message,
1837
- outputToolName,
1838
- );
1839
- if (roleCall === undefined) return undefined;
1840
- const retained = boundRetainedAuditResponse(
1841
- entries,
1842
- roleCall.callIndex,
1843
- resultIndex,
1844
- auditToolNameForRole(role),
1845
- );
1846
- if (retained === undefined) return undefined;
1847
- try {
1848
- const decision = readComplianceCandidate(retained.candidate);
1849
- if (decision.status !== "escalate") return undefined;
1850
- const details = message.details;
1851
- if (!isAuditEscalationResult(details) || !isRecord(details)) return undefined;
1852
-
1853
- // Read the public face exactly once. Besides making key enumeration and
1854
- // getters fail closed, this prevents a stateful accessor from authenticating
1855
- // one value and yielding another during final Terminal projection.
1856
- const projectedDetails = snapshotAuditDetails(details);
1857
- const hasDecisionConflicts = Object.hasOwn(decision, "conflicts");
1858
- const hasDetailsConflicts = Object.hasOwn(projectedDetails, "conflicts");
1859
- if (hasDecisionConflicts !== hasDetailsConflicts) return undefined;
1860
- if (hasDecisionConflicts && !sameAuditValue(projectedDetails.conflicts, decision.conflicts)) return undefined;
1861
- const hasDecisionGate = Object.hasOwn(decision, "decisionGate");
1862
- const hasDetailsGate = Object.hasOwn(projectedDetails, "auditDecisionGate");
1863
- if (hasDecisionGate !== hasDetailsGate) return undefined;
1864
- if (hasDecisionGate && !sameAuditValue(projectedDetails.auditDecisionGate, decision.decisionGate)) return undefined;
1865
- return { decision, details: projectedDetails };
1866
- } catch {
1867
- // Retained/public own-key enumeration, property reads, recursive equality,
1868
- // and projection are all untrusted session evidence.
1869
- return undefined;
1870
- }
1871
- }
1872
-
1873
- function isUnboundAuditEscalationFace(details: unknown): boolean {
1874
- try {
1875
- if (isAuditEscalationResult(details)) return true;
1876
- } catch {
1877
- // Hostile access is not authentic escalation evidence.
1878
- }
1879
- if (!isRecord(details)) return false;
1880
- const kind = safelyRead(details, "kind");
1881
- return kind.readable && kind.value === "audit_escalation";
1882
- }
1883
-
1884
- function boundRetainedAuditResponse(
1885
- entries: readonly SessionEntry[],
1886
- callIndex: number,
1887
- resultIndex: number,
1888
- auditToolName: string,
1889
- ): BoundRetainedAuditResponse | undefined {
1890
- const matches: BoundRetainedAuditResponse[] = [];
1891
- for (let index = callIndex + 1; index < resultIndex; index += 1) {
1892
- const entry = entries[index];
1893
- if (entry?.type !== "custom" || entry.customType !== COMPLIANCE_RESPONSE_ENTRY_TYPE) {
1894
- continue;
1895
- }
1896
- if (!isRecord(entry.data) || !isRecord(entry.data.response)) continue;
1897
- const response = entry.data.response;
1898
- if (!Array.isArray(response.content)) continue;
1899
- const calls = response.content.filter(
1900
- (part): part is Record<string, unknown> =>
1901
- isRecord(part) && part.type === "toolCall",
1902
- );
1903
- if (calls.length !== 1 || calls[0]?.name !== auditToolName) continue;
1904
- matches.push({ candidate: calls[0]?.arguments });
1034
+ export async function readBoundSessionEntries(
1035
+ sessionFile: string,
1036
+ ): Promise<SessionEntry[]> {
1037
+ const text = await readFile(sessionFile, "utf8");
1038
+ const entries: SessionEntry[] = [];
1039
+ for (const line of text.trim().split("\n").filter(Boolean)) {
1040
+ entries.push(JSON.parse(line) as SessionEntry);
1905
1041
  }
1906
- // Unique seat-bound match binds even when multi-turn investigation retained
1907
- // intermediate non-decision responses in the same call/result interval.
1908
- return matches.length === 1 ? matches[0] : undefined;
1042
+ return entries;
1909
1043
  }
1910
1044
 
1911
1045
  /**
@@ -1927,57 +1061,19 @@ type AttemptHistorySource = {
1927
1061
  readonly sessionFile: string;
1928
1062
  };
1929
1063
 
1930
- /**
1931
- * Append one attempt's complete result to the run's session principal.
1932
- * Append failure throws — callers must not overwrite a pointer artifact when
1933
- * the history entry backing the overwrite did not land (fail closed).
1934
- */
1064
+ /** Append the complete attempt to the package ledger before overwriting pointer artifacts (#419). */
1935
1065
  export async function appendRunAttemptHistory(
1936
1066
  source: AttemptHistorySource,
1937
1067
  outcome: AttemptHistoryOutcome,
1938
1068
  ): Promise<void> {
1939
- const entries = await readBoundSessionEntries(source.sessionFile);
1940
- let parentId: string | null = null;
1941
- let priorEntries = 0;
1942
- for (const entry of entries) {
1943
- if (typeof entry.id === "string" && entry.type !== "session") parentId = entry.id;
1944
- if (
1945
- entry.type === "custom" &&
1946
- entry.customType === ATTEMPT_HISTORY_ENTRY_TYPE
1947
- ) {
1948
- priorEntries += 1;
1949
- }
1950
- }
1951
- const timestamp = new Date().toISOString();
1952
- const attemptData = {
1953
- sequence: priorEntries + 1,
1954
- role: source.role,
1955
- runId: source.runId,
1956
- recordedAt: timestamp,
1957
- outcome,
1958
- };
1959
- const line = `${JSON.stringify({
1960
- type: "custom",
1961
- customType: ATTEMPT_HISTORY_ENTRY_TYPE,
1962
- data: attemptData,
1963
- id: randomUUID(),
1964
- parentId,
1965
- timestamp,
1966
- })}\n`;
1967
- await appendFile(source.sessionFile, line, "utf8");
1968
- try {
1969
- sitianReport({
1970
- level: "event",
1971
- kind: "attempt-history",
1972
- subject: { runId: source.runId },
1973
- sessionParent: source.sessionFile,
1974
- payload: {
1975
- type: ATTEMPT_HISTORY_ENTRY_TYPE,
1976
- ...attemptData,
1977
- },
1978
- source: "settlement",
1979
- });
1980
- } catch {}
1069
+ sitianReport({
1070
+ level: "event",
1071
+ kind: "attempt-history",
1072
+ subject: { runId: source.runId },
1073
+ sessionParent: source.sessionFile,
1074
+ payload: { type: ATTEMPT_HISTORY_ENTRY_TYPE, role: source.role, runId: source.runId, outcome },
1075
+ source: "settlement",
1076
+ });
1981
1077
  }
1982
1078
 
1983
1079
  function parseNavigatorAttendanceDetails(
@@ -2118,10 +1214,9 @@ export async function extractGateFactFromSessionDirectory(
2118
1214
  /**
2119
1215
  * Attach optional gate projection onto a settled Terminal base.
2120
1216
  * Shared by every settle path so auditor-roles is scanned once here only.
2121
- * `runId` is not required — resumable failures omit it by contract.
2122
- * Gate read damage propagates with its real identity (never washed to no-gate
2123
- * or swallowed); callers that already hold a controlled failure still surface the
2124
- * JSONL/session cause rather than pretend the gate was absent.
1217
+ * `runId` is optional only for a batch projection that has no parent run.
1218
+ * Gate read damage is a package note beside the host terminal. It does not
1219
+ * replace that terminal and it does not omit the gate without a trace.
2125
1220
  */
2126
1221
  /**
2127
1222
  * #537: project this-invocation ak_engine_detour usage onto decisiveFacts.
@@ -2132,7 +1227,7 @@ export async function extractGateFactFromSessionDirectory(
2132
1227
  * therefore cannot replace an already-formed roleOutcome (no_receipt / failure).
2133
1228
  */
2134
1229
  async function attachEngineDetourToolUsage<
2135
- T extends { roleOutcome: TerminalRoleOutcome; resume?: TerminalResume },
1230
+ T extends { roleOutcome: TerminalRoleOutcome },
2136
1231
  >(
2137
1232
  base: T,
2138
1233
  sessionDirectory: string,
@@ -2140,13 +1235,26 @@ async function attachEngineDetourToolUsage<
2140
1235
  readonly runDirectory?: string;
2141
1236
  readonly courtAttemptId?: string;
2142
1237
  readonly invocationScopeId?: string;
1238
+ readonly notePackageFault?: SettlementCourtScope["notePackageFault"];
2143
1239
  } = {},
2144
1240
  ): Promise<T> {
2145
1241
  const runDirectory =
2146
1242
  typeof gateContext.runDirectory === "string" && gateContext.runDirectory.length > 0
2147
1243
  ? gateContext.runDirectory
2148
1244
  : runDirectoryFromSessionDirectory(sessionDirectory);
2149
- const engineMounted = await readInvocationEngineMounted(runDirectory);
1245
+ let engineMounted = false;
1246
+ try {
1247
+ engineMounted = await readInvocationEngineMounted(runDirectory);
1248
+ } catch (error) {
1249
+ await noteSettlementFault(
1250
+ runDirectory,
1251
+ gateContext.notePackageFault === undefined
1252
+ ? undefined
1253
+ : { notePackageFault: gateContext.notePackageFault },
1254
+ `engine mount read failed beside host terminal: ${describeErrorIdentity(error)}`,
1255
+ );
1256
+ return base;
1257
+ }
2150
1258
  if (!engineMounted) return base;
2151
1259
 
2152
1260
  // Public-invocation scope from the shared Host envelope (settlement scope), never
@@ -2158,22 +1266,27 @@ async function attachEngineDetourToolUsage<
2158
1266
  : undefined;
2159
1267
 
2160
1268
  const sessionFile = sessionFileFromSessionDirectory(sessionDirectory);
2161
- const usage = await readEngineDetourToolUsage({
2162
- sessionParent: sessionFile,
2163
- engineMounted: true,
2164
- ...(invocationScopeId === undefined ? {} : { invocationScopeId }),
2165
- cwd: runDirectory,
2166
- });
2167
- const projected =
2168
- usage === undefined
2169
- ? undefined
2170
- : projectEngineDetourToolUsageForPublicTerminal(usage, {
2171
- // Resumable Terminal: run ID only in resume.command — relative openable path.
2172
- discloseRecordFile: base.resume === undefined,
2173
- });
1269
+ let usage: Awaited<ReturnType<typeof readEngineDetourToolUsage>>;
1270
+ try {
1271
+ usage = await readEngineDetourToolUsage({
1272
+ sessionParent: sessionFile,
1273
+ engineMounted: true,
1274
+ ...(invocationScopeId === undefined ? {} : { invocationScopeId }),
1275
+ cwd: runDirectory,
1276
+ });
1277
+ } catch (error) {
1278
+ await noteSettlementFault(
1279
+ runDirectory,
1280
+ gateContext.notePackageFault === undefined
1281
+ ? undefined
1282
+ : { notePackageFault: gateContext.notePackageFault },
1283
+ `engine detour read failed beside host terminal: ${describeErrorIdentity(error)}`,
1284
+ );
1285
+ return base;
1286
+ }
2174
1287
  return {
2175
1288
  ...base,
2176
- roleOutcome: withEngineDetourToolUsageFact(base.roleOutcome, projected),
1289
+ roleOutcome: withEngineDetourToolUsageFact(base.roleOutcome, usage),
2177
1290
  };
2178
1291
  }
2179
1292
 
@@ -2194,6 +1307,7 @@ function detourGateContext(
2194
1307
  ...(scope?.invocationScopeId === undefined || scope.invocationScopeId.length === 0
2195
1308
  ? {}
2196
1309
  : { invocationScopeId: scope.invocationScopeId }),
1310
+ ...(scope?.notePackageFault === undefined ? {} : { notePackageFault: scope.notePackageFault }),
2197
1311
  };
2198
1312
  }
2199
1313
 
@@ -2202,7 +1316,6 @@ async function withOptionalGateProjection<
2202
1316
  roleOutcome: TerminalRoleOutcome;
2203
1317
  navigator: TerminalNavigatorFact;
2204
1318
  artifacts: readonly TerminalArtifactRef[];
2205
- resume?: TerminalResume;
2206
1319
  },
2207
1320
  >(
2208
1321
  base: T,
@@ -2212,6 +1325,7 @@ async function withOptionalGateProjection<
2212
1325
  readonly parentSessionFile?: string;
2213
1326
  readonly courtAttemptId?: string;
2214
1327
  readonly invocationScopeId?: string;
1328
+ readonly notePackageFault?: SettlementCourtScope["notePackageFault"];
2215
1329
  } = {},
2216
1330
  ): Promise<T & { gate?: TerminalGateFact }> {
2217
1331
  // A gate transport failure is already represented by typed evidence and has no
@@ -2227,9 +1341,19 @@ async function withOptionalGateProjection<
2227
1341
 
2228
1342
  let next: T & { gate?: TerminalGateFact } = base;
2229
1343
  if (!skipGate) {
2230
- // Defaults live solely in extractGateFactFromSessionDirectory — do not re-derive.
2231
- const gate = await extractGateFactFromSessionDirectory(sessionDirectory, gateContext);
2232
- if (gate !== undefined) next = { ...base, gate };
1344
+ try {
1345
+ const gate = await extractGateFactFromSessionDirectory(sessionDirectory, gateContext);
1346
+ if (gate !== undefined) next = { ...base, gate };
1347
+ } catch (error) {
1348
+ const runDirectory = gateContext.runDirectory ?? runDirectoryFromSessionDirectory(sessionDirectory);
1349
+ await noteSettlementFault(
1350
+ runDirectory,
1351
+ gateContext.notePackageFault === undefined
1352
+ ? undefined
1353
+ : { notePackageFault: gateContext.notePackageFault },
1354
+ `gate read failed beside host terminal: ${describeErrorIdentity(error)}`,
1355
+ );
1356
+ }
2233
1357
  }
2234
1358
 
2235
1359
  return attachEngineDetourToolUsage(next, sessionDirectory, gateContext);
@@ -2292,6 +1416,8 @@ function extractNavigatorAttendanceFact(
2292
1416
  */
2293
1417
  async function extractNavigatorFactFromAdmittedSession(
2294
1418
  sessionFile: string,
1419
+ runDirectory: string,
1420
+ scope: SettlementCourtScope | undefined,
2295
1421
  ): Promise<TerminalNavigatorFact> {
2296
1422
  try {
2297
1423
  const entries = await readBoundSessionEntries(sessionFile);
@@ -2304,6 +1430,11 @@ async function extractNavigatorFactFromAdmittedSession(
2304
1430
  reason: "Navigator attendance is missing from the session",
2305
1431
  };
2306
1432
  }
1433
+ await noteSettlementFault(
1434
+ runDirectory,
1435
+ scope,
1436
+ `navigator session read failed beside host terminal: ${sessionFile}: ${describeErrorIdentity(error)}`,
1437
+ );
2307
1438
  return {
2308
1439
  disposition: "unavailable",
2309
1440
  source: "unknown",
@@ -2393,31 +1524,26 @@ function acceptedArtifactAttachmentRefs(
2393
1524
  * Seat-specific structured fields stay in callers; do not fork this flow.
2394
1525
  */
2395
1526
  async function publishAcceptedTerminalArtifacts(
2396
- admitted: {
2397
- readonly role: TerminalRoleName;
2398
- readonly runId: string;
2399
- readonly runDirectory: string;
2400
- },
1527
+ admitted: AdmittedRoleInvocation,
2401
1528
  roleOutcome: TerminalRoleOutcome,
2402
1529
  coordinates: DurablePrincipalCoordinates,
1530
+ recordAttemptHistory: boolean,
2403
1531
  bodies: {
2404
1532
  readonly report: Record<string, unknown>;
2405
1533
  readonly evidence: Record<string, unknown>;
2406
1534
  },
2407
1535
  ): Promise<TerminalArtifactRef[]> {
2408
- // #419: history first — report/evidence stay last-write-wins views only
2409
- // because every attempt's complete result has already been appended.
2410
- await appendRunAttemptHistory(
2411
- {
2412
- role: admitted.role,
2413
- runId: admitted.runId,
2414
- sessionFile: coordinates.sessionFile,
2415
- },
2416
- roleOutcome,
2417
- );
1536
+ // #419: a dispatched turn appends before rewriting last-write-wins views;
1537
+ // a later projection may refresh views but must not invent another attempt.
1538
+ if (recordAttemptHistory) {
1539
+ await appendRunAttemptHistory(
1540
+ { role: admitted.role, runId: admitted.runId, sessionFile: coordinates.sessionFile },
1541
+ roleOutcome,
1542
+ );
1543
+ }
2418
1544
  const artifactsDir = await ensureTerminalArtifactFace(admitted.runDirectory);
2419
- const reportPath = join(artifactsDir, "report.json");
2420
- const evidencePath = join(artifactsDir, "evidence.json");
1545
+ const reportPath = join(artifactsDir, RUN_TERMINAL_REPORT_FILE);
1546
+ const evidencePath = join(artifactsDir, RUN_TERMINAL_EVIDENCE_FILE);
2421
1547
  await writeFile(
2422
1548
  reportPath,
2423
1549
  `${JSON.stringify(bodies.report, null, 2)}\n`,
@@ -2434,140 +1560,78 @@ async function publishAcceptedTerminalArtifacts(
2434
1560
  ];
2435
1561
  }
2436
1562
 
2437
- /** Publish one accepted seat's report/evidence face from the shared leaf table. */
2438
- export async function publishSeatAcceptedArtifacts(
2439
- admitted: AdmittedRoleInvocation,
2440
- roleOutcome: TerminalRoleOutcome,
2441
- coordinates: DurablePrincipalCoordinates,
2442
- entries: readonly SessionEntry[] = [],
2443
- ): Promise<TerminalArtifactRef[]> {
2444
- return publishDeclaredSeatArtifacts(admitted, roleOutcome, coordinates, entries);
2445
- }
1563
+ type LawfulSessionRead =
1564
+ | { readonly kind: "entries"; readonly entries: SessionEntry[] }
1565
+ | { readonly kind: "absent" }
1566
+ | { readonly kind: "fault"; readonly error: unknown };
2446
1567
 
2447
1568
  /**
2448
- * Read session entries for lawful settlement. Missing path → undefined (absence).
2449
- * Malformed JSONL / other read failures throw with knownCause=session.
1569
+ * Read session entries for lawful settlement.
1570
+ * Missing path is absence. A present file that cannot be read is a fault,
1571
+ * not a host cause and not an empty transcript.
2450
1572
  */
2451
1573
  async function readLawfulSettlementEntries(
2452
1574
  sessionFile: string,
2453
- ): Promise<SessionEntry[] | undefined> {
1575
+ ): Promise<LawfulSessionRead> {
2454
1576
  try {
2455
- return await readBoundSessionEntries(sessionFile);
1577
+ return { kind: "entries", entries: await readBoundSessionEntries(sessionFile) };
2456
1578
  } catch (error) {
2457
- // Missing path is absence of a lawful outcome; callers classify via session inspect.
2458
- if (isEnoent(error)) return undefined;
2459
- // Malformed JSONL and other read failures keep typed session identity.
2460
- throw error instanceof Error &&
2461
- (error as { knownCause?: unknown }).knownCause === "session"
2462
- ? error
2463
- : sessionReadFailure(error, "session unreadable");
1579
+ if (isEnoent(error)) return { kind: "absent" };
1580
+ return { kind: "fault", error };
2464
1581
  }
2465
1582
  }
2466
1583
 
2467
- /**
2468
- * Sealed-ledger seats that publish on a lawful outcome and do not scan a
2469
- * residual tool or attach run-scoped submission history.
2470
- * Absence stays undefined. Session-read and publication errors keep their identity.
2471
- * Outcome is read before publication so a later write error cannot erase it.
2472
- */
2473
- async function settleSealedLedgerTerminal(
2474
- admitted: AdmittedRoleInvocation,
2475
- authority: DurablePrincipalAuthority,
2476
- scope: SettlementCourtScope | undefined,
2477
- publish: (
2478
- roleOutcome: Extract<TerminalRoleOutcome, { kind: "accepted" | "audit_escalation" }>,
2479
- coordinates: DurablePrincipalCoordinates,
2480
- entries: readonly SessionEntry[],
2481
- ) => Promise<TerminalArtifactRef[]>,
2482
- accept?: (
2483
- roleOutcome: Extract<TerminalRoleOutcome, { kind: "accepted" | "audit_escalation" }>,
2484
- ) => boolean,
2485
- ): Promise<TerminalResult | undefined> {
2486
- const roleOutcome = await sealedLedgerOutcome(admitted, admitted.role, scope);
2487
- if (roleOutcome === undefined) return undefined;
2488
- if (accept !== undefined && !accept(roleOutcome)) return undefined;
2489
- const coordinates = coordinatesFromAdmitted(authority, admitted);
2490
- const entries = await readLawfulSettlementEntries(coordinates.sessionFile) ?? [];
2491
- const artifacts = await publish(roleOutcome, coordinates, entries);
2492
- return withOptionalGateProjection(
2493
- {
2494
- roleOutcome,
2495
- navigator: extractNavigatorFact(entries),
2496
- artifacts,
2497
- runId: admitted.runId,
2498
- },
2499
- coordinates.sessionDirectory,
2500
- detourGateContext(admitted, scope),
2501
- );
1584
+ function entriesOf(read: LawfulSessionRead): SessionEntry[] {
1585
+ return read.kind === "entries" ? read.entries : [];
2502
1586
  }
2503
1587
 
2504
- /**
2505
- * Sealed accepted outcome publishes. Any other ledger face falls through to
2506
- * the latest bound errored tool result in the scan window, which settles as
2507
- * output failure. Both faces attach run-scoped submissions. Absence of both
2508
- * stays undefined. Session is read first so a read failure keeps its identity
2509
- * even when the ledger has no accepted row.
2510
- */
2511
- async function settleSealedAcceptedOrToolResidual(
1588
+ /** Sealed seats present the ledger. A session tool error does not invent a host failure. */
1589
+ async function settleSealedSeat(
2512
1590
  admitted: AdmittedRoleInvocation,
2513
1591
  authority: DurablePrincipalAuthority,
2514
1592
  scope: SettlementCourtScope | undefined,
2515
- role: TerminalRoleName,
2516
- residualToolName: string,
2517
- scan: "current-attempt" | "session",
2518
- publish: (
2519
- roleOutcome: Extract<TerminalRoleOutcome, { kind: "accepted" }>,
2520
- coordinates: DurablePrincipalCoordinates,
2521
- entries: readonly SessionEntry[],
2522
- ) => Promise<TerminalArtifactRef[]>,
1593
+ options: {
1594
+ acceptedOnly: boolean;
1595
+ },
2523
1596
  ): Promise<TerminalResult | undefined> {
2524
1597
  const coordinates = coordinatesFromAdmitted(authority, admitted);
2525
- const { sessionDirectory, sessionFile } = coordinates;
2526
- const entries = await readLawfulSettlementEntries(sessionFile) ?? [];
2527
- const roleOutcome = await sealedLedgerOutcome(admitted, role, scope);
2528
- if (roleOutcome?.role !== role || roleOutcome.kind !== "accepted") {
2529
- const scanStart = scan === "current-attempt" ? currentAttemptStartIndex(entries) : 0;
2530
- for (let index = entries.length - 1; index >= scanStart; index -= 1) {
2531
- const message = entries[index]?.message;
2532
- if (message?.role !== "toolResult") continue;
2533
- const residual = boundErroredToolCandidate(entries, index, message, residualToolName);
2534
- if (residual === undefined) continue;
2535
- const candidate = residual.candidate;
2536
- const details = isRecord(candidate) ? candidate : { candidate };
2537
- const failed = await settleFailureTerminalResult(
2538
- admitted,
2539
- {
2540
- cause: "output",
2541
- diagnostic: residual.diagnostic,
2542
- details,
2543
- },
2544
- authority,
2545
- scope ?? {},
2546
- );
2547
- return attachRecordedSubmissions(admitted, failed, scope);
2548
- }
1598
+ const roleOutcome = await sealedLedgerOutcome(admitted, admitted.role, scope);
1599
+ if (roleOutcome === undefined || (options.acceptedOnly && roleOutcome.kind !== "accepted")) {
2549
1600
  return undefined;
2550
1601
  }
2551
- const artifacts = await publish(roleOutcome, coordinates, entries);
2552
- return attachRecordedSubmissions(
2553
- admitted,
2554
- await withOptionalGateProjection(
2555
- {
2556
- roleOutcome,
2557
- navigator: extractNavigatorFact(entries),
2558
- artifacts,
2559
- runId: admitted.runId,
2560
- },
2561
- sessionDirectory,
2562
- detourGateContext(admitted, scope),
2563
- ),
2564
- scope,
2565
- );
1602
+ const read = await readLawfulSettlementEntries(coordinates.sessionFile);
1603
+ if (read.kind === "fault") {
1604
+ await noteSettlementFault(
1605
+ admitted.runDirectory,
1606
+ scope,
1607
+ `session read failed beside lawful terminal: ${describeErrorIdentity(read.error)}`,
1608
+ );
1609
+ }
1610
+ return finishLawfulSeat(admitted, coordinates, entriesOf(read), roleOutcome, scope);
1611
+ }
1612
+
1613
+ async function finishLawfulSeat(
1614
+ admitted: AdmittedRoleInvocation,
1615
+ coordinates: DurablePrincipalCoordinates,
1616
+ entries: readonly SessionEntry[],
1617
+ roleOutcome: TerminalRoleOutcome,
1618
+ scope: SettlementCourtScope | undefined,
1619
+ ): Promise<TerminalResult> {
1620
+ const artifacts = scope?.previewOnly === true
1621
+ ? []
1622
+ : await publishDeclaredSeatArtifacts(admitted, roleOutcome, coordinates, entries, scope?.recordAttemptHistory === true);
1623
+ const terminal = await withOptionalGateProjection({
1624
+ roleOutcome,
1625
+ navigator: extractNavigatorFact(entries),
1626
+ artifacts,
1627
+ runId: admitted.runId,
1628
+ }, coordinates.sessionDirectory, detourGateContext(admitted, scope));
1629
+ return attachRecordedSubmissions(admitted, terminal, scope);
2566
1630
  }
2567
1631
 
2568
1632
  /**
2569
1633
  * One settlement for every registered seat. The registry `settlement` leaf
2570
- * picks sealed ledger, sealed-or-residual, or the accepted-tool scan.
1634
+ * picks the sealed ledger or the accepted-tool ledger.
2571
1635
  * Seat evidence stays on the artifact face.
2572
1636
  */
2573
1637
  async function settleSeat(
@@ -2582,59 +1646,9 @@ async function settleSeat(
2582
1646
  if (record.settlement === "accepted") {
2583
1647
  return trySettleAcceptedSeatTerminalResult(admitted, authority, scope);
2584
1648
  }
2585
- const publish = (
2586
- roleOutcome: Extract<TerminalRoleOutcome, { kind: "accepted" | "audit_escalation" }>,
2587
- coordinates: DurablePrincipalCoordinates,
2588
- entries: readonly SessionEntry[],
2589
- ) => publishDeclaredSeatArtifacts(admitted, roleOutcome, coordinates, entries);
2590
- if (record.settlement === "residual") {
2591
- return settleSealedAcceptedOrToolResidual(
2592
- admitted,
2593
- authority,
2594
- scope,
2595
- admitted.role,
2596
- record.residualTool,
2597
- record.residualScan,
2598
- publish,
2599
- );
2600
- }
2601
- const accept = "sealedAcceptedOnly" in record && record.sealedAcceptedOnly === true
2602
- ? (
2603
- roleOutcome: Extract<TerminalRoleOutcome, { kind: "accepted" | "audit_escalation" }>,
2604
- ) => roleOutcome.role === admitted.role && roleOutcome.kind === "accepted"
2605
- : undefined;
2606
- return settleSealedLedgerTerminal(admitted, authority, scope, publish, accept);
2607
- }
2608
-
2609
- /**
2610
- * Try to settle any registered seat. Undefined only for genuine absence.
2611
- * Session malformation and publication exceptions keep their typed identity.
2612
- */
2613
- export async function trySettleSeatTerminalResult(
2614
- admitted: AdmittedRoleInvocation,
2615
- authority: DurablePrincipalAuthority,
2616
- scope?: SettlementCourtScope,
2617
- ): Promise<TerminalResult | undefined> {
2618
- return settleSeat(admitted, authority, scope);
2619
- }
2620
-
2621
- /**
2622
- * Settle any registered seat. Throws when no lawful outcome is present.
2623
- * Session-read and publication failures retain their typed identity.
2624
- */
2625
- export async function settleSeatTerminalResult(
2626
- admitted: AdmittedRoleInvocation,
2627
- authority: DurablePrincipalAuthority,
2628
- scope?: SettlementCourtScope,
2629
- ): Promise<TerminalResult> {
2630
- const settled = await settleSeat(admitted, authority, scope);
2631
- if (settled === undefined) {
2632
- const label = admitted.role.charAt(0).toUpperCase() + admitted.role.slice(1);
2633
- throw new Error(
2634
- `${label} Role run completed without a lawful typed terminal result`,
2635
- );
2636
- }
2637
- return settled;
1649
+ return settleSealedSeat(admitted, authority, scope, {
1650
+ acceptedOnly: "sealedAcceptedOnly" in record && record.sealedAcceptedOnly === true,
1651
+ });
2638
1652
  }
2639
1653
 
2640
1654
  /**
@@ -2716,12 +1730,13 @@ async function publishDeclaredSeatArtifacts(
2716
1730
  roleOutcome: TerminalRoleOutcome,
2717
1731
  coordinates: DurablePrincipalCoordinates,
2718
1732
  entries: readonly SessionEntry[],
1733
+ recordAttemptHistory: boolean,
2719
1734
  ): Promise<TerminalArtifactRef[]> {
2720
1735
  const face = seatArtifactFace(admitted.role);
2721
1736
  const phase = face.reportPhase === true
2722
1737
  ? { phase: readAdmittedPath(admitted, "phase") }
2723
1738
  : {};
2724
- return publishAcceptedTerminalArtifacts(admitted, roleOutcome, coordinates, {
1739
+ return publishAcceptedTerminalArtifacts(admitted, roleOutcome, coordinates, recordAttemptHistory, {
2725
1740
  report: {
2726
1741
  role: admitted.role,
2727
1742
  runId: admitted.runId,
@@ -2741,16 +1756,6 @@ async function publishDeclaredSeatArtifacts(
2741
1756
  });
2742
1757
  }
2743
1758
 
2744
- /**
2745
- * Shared accepted-settlement skeleton for seats that scan residual tool
2746
- * candidates then project sealed ledger outcome (#502 DRY).
2747
- * #757: sealed receipts pass through full decisiveFacts — one path, no per-seat projector.
2748
- */
2749
- type SeatAcceptedSettlementSpec = {
2750
- readonly role: TerminalRoleName;
2751
- readonly toolName: string;
2752
- };
2753
-
2754
1759
  /** Latest top-level user message index; 0 when the session has none (initial attempt). */
2755
1760
  function currentAttemptStartIndex(entries: readonly SessionEntry[]): number {
2756
1761
  for (let i = entries.length - 1; i >= 0; i -= 1) {
@@ -2762,93 +1767,8 @@ function currentAttemptStartIndex(entries: readonly SessionEntry[]): number {
2762
1767
  return 0;
2763
1768
  }
2764
1769
 
2765
- async function settleLawfulSeatAcceptedTerminalResult(
2766
- admitted: AdmittedRoleInvocation,
2767
- authority: DurablePrincipalAuthority,
2768
- spec: SeatAcceptedSettlementSpec,
2769
- scope?: SettlementCourtScope,
2770
- ): Promise<TerminalResult | undefined> {
2771
- const coordinates = coordinatesFromAdmitted(authority, admitted);
2772
- const { sessionDirectory, sessionFile } = coordinates;
2773
- const entries = await readLawfulSettlementEntries(sessionFile) ?? [];
2774
- const submissions = await recordedSubmissionPayloads(admitted, scope);
2775
- // #843: collector shape (ledger closed first) plus current user-turn freshness.
2776
- // Only the ledger-owned closure establishes this turn's success. A non-error
2777
- // toolResult may be a candidate whose nested gate continued the conversation.
2778
- // A current-attempt
2779
- // residual without that success is this turn's own failure and must not be
2780
- // masked by run-scoped stale acceptance (bare resume without courtAttemptId).
2781
- // Same-turn accept-then-bounce keeps the success marker: terminal stays
2782
- // accepted; rejection facts remain on payloads/gate (not latest-wins flip).
2783
- const scanStart = currentAttemptStartIndex(entries);
2784
- let thisAttemptHasSeatSuccess = false;
2785
- let residual: BoundErroredToolCandidate | undefined;
2786
- for (let index = entries.length - 1; index >= scanStart; index -= 1) {
2787
- const entry = entries[index];
2788
- if (entry?.type === "custom" && entry.customType === "ak-role-submission-closure"
2789
- && isRecord(entry.data) && entry.data.toolName === spec.toolName
2790
- && classifyPackagedRoleTerminalResult(entry.data).kind === "accepted") {
2791
- thisAttemptHasSeatSuccess = true;
2792
- }
2793
- const message = entries[index]?.message;
2794
- if (message?.role !== "toolResult") continue;
2795
- if (message.toolName !== spec.toolName) continue;
2796
- if (message.isError === false) continue;
2797
- if (residual === undefined) {
2798
- residual = boundErroredToolCandidate(
2799
- entries,
2800
- index,
2801
- message,
2802
- spec.toolName,
2803
- );
2804
- }
2805
- }
2806
- const roleOutcome = await sealedLedgerOutcome(admitted, spec.role as TerminalRoleName, scope);
2807
- if (roleOutcome !== undefined && (
2808
- thisAttemptHasSeatSuccess || residual === undefined || (scope?.courtAttemptId !== undefined && scope.courtAttemptId.length > 0)
2809
- )) {
2810
- const navigator = extractNavigatorFact(entries);
2811
- const artifacts = await publishDeclaredSeatArtifacts(
2812
- admitted,
2813
- roleOutcome,
2814
- coordinates,
2815
- entries,
2816
- );
2817
- return withSubmissions(
2818
- await withOptionalGateProjection(
2819
- {
2820
- roleOutcome,
2821
- navigator,
2822
- artifacts,
2823
- runId: admitted.runId,
2824
- },
2825
- sessionDirectory,
2826
- detourGateContext(admitted, scope),
2827
- ),
2828
- submissions,
2829
- );
2830
- }
2831
- if (residual !== undefined) {
2832
- const details = isRecord(residual.candidate)
2833
- ? residual.candidate
2834
- : { candidate: residual.candidate };
2835
- const failed = await settleFailureTerminalResult(
2836
- admitted,
2837
- {
2838
- cause: "output",
2839
- diagnostic: residual.diagnostic,
2840
- details,
2841
- },
2842
- authority,
2843
- scope ?? {},
2844
- );
2845
- return withSubmissions(failed, submissions);
2846
- }
2847
- return undefined;
2848
- }
2849
-
2850
- /** Accepted-tool seats: one scan, tool name from the composition-root record. */
2851
- export async function trySettleAcceptedSeatTerminalResult(
1770
+ /** Accepted-tool seats retain declaration checks and optional officer projection. */
1771
+ async function trySettleAcceptedSeatTerminalResult(
2852
1772
  admitted: AdmittedRoleInvocation,
2853
1773
  authority: DurablePrincipalAuthority,
2854
1774
  scope?: SettlementCourtScope,
@@ -2857,10 +1777,7 @@ export async function trySettleAcceptedSeatTerminalResult(
2857
1777
  if (toolName === undefined) {
2858
1778
  throw new Error(`accepted-seat settlement is not declared for ${admitted.role}`);
2859
1779
  }
2860
- const settled = await settleLawfulSeatAcceptedTerminalResult(admitted, authority, {
2861
- role: admitted.role,
2862
- toolName,
2863
- }, scope);
1780
+ const settled = await settleSealedSeat(admitted, authority, scope, { acceptedOnly: false });
2864
1781
  const record = packagedRoleMetadata(admitted.role);
2865
1782
  if (
2866
1783
  settled === undefined
@@ -2870,14 +1787,14 @@ export async function trySettleAcceptedSeatTerminalResult(
2870
1787
  ) {
2871
1788
  return settled;
2872
1789
  }
2873
- return applySecretariatCountersignTerminal(admitted, authority, settled);
1790
+ return applySecretariatCountersignTerminal(admitted, authority, settled, scope);
2874
1791
  }
2875
1792
 
2876
1793
  /** One settlement dispatch. The registry `settlement` leaf selects the path. */
2877
1794
  export async function trySettlePublicSeat(
2878
1795
  admitted: AdmittedRoleInvocation,
2879
1796
  authority: DurablePrincipalAuthority,
2880
- scope: { readonly courtAttemptId?: string } | undefined,
1797
+ scope: SettlementCourtScope | undefined,
2881
1798
  ): Promise<TerminalResult | undefined> {
2882
1799
  return settleSeat(admitted, authority, scope);
2883
1800
  }
@@ -2966,9 +1883,22 @@ async function applySecretariatCountersignTerminal(
2966
1883
  admitted: AdmittedRoleInvocation,
2967
1884
  authority: DurablePrincipalAuthority,
2968
1885
  settled: TerminalResult,
1886
+ scope?: SettlementCourtScope,
2969
1887
  ): Promise<TerminalResult> {
2970
1888
  const coordinates = coordinatesFromAdmitted(authority, admitted);
2971
- const entries = await readLawfulSettlementEntries(coordinates.sessionFile) ?? [];
1889
+ let entries: SessionEntry[] = [];
1890
+ try {
1891
+ entries = await readBoundSessionEntries(coordinates.sessionFile);
1892
+ } catch (error) {
1893
+ if (!isEnoent(error)) {
1894
+ await noteSettlementFault(
1895
+ admitted.runDirectory,
1896
+ scope,
1897
+ `secretariat officer read failed beside lawful terminal: ${describeErrorIdentity(error)}`,
1898
+ );
1899
+ }
1900
+ return settled;
1901
+ }
2972
1902
  const officer = countersignTerminalFromEntries(entries);
2973
1903
  if (officer === undefined) return settled;
2974
1904
 
@@ -3011,15 +1941,25 @@ export async function attachPostAuditProjection(
3011
1941
  admitted: AdmittedRoleInvocation,
3012
1942
  authority: DurablePrincipalAuthority,
3013
1943
  terminal: TerminalResult,
1944
+ scope?: SettlementCourtScope,
3014
1945
  ): Promise<TerminalResult> {
3015
1946
  const { sessionDirectory, sessionFile } = coordinatesFromAdmitted(authority, admitted);
3016
- const gate = await extractGateFactFromSessionDirectory(sessionDirectory, {
3017
- runDirectory: admitted.runDirectory,
3018
- parentSessionFile: sessionFile,
3019
- });
1947
+ let gate: TerminalGateFact | undefined;
1948
+ try {
1949
+ gate = await extractGateFactFromSessionDirectory(sessionDirectory, {
1950
+ runDirectory: admitted.runDirectory,
1951
+ parentSessionFile: sessionFile,
1952
+ });
1953
+ } catch (error) {
1954
+ await noteSettlementFault(
1955
+ admitted.runDirectory,
1956
+ scope,
1957
+ `gate read failed beside host terminal: ${describeErrorIdentity(error)}`,
1958
+ );
1959
+ }
3020
1960
  const withGate = gate === undefined ? terminal : { ...terminal, gate };
3021
1961
  if (admitted.role !== "secretariat") return withGate;
3022
- return applySecretariatCountersignTerminal(admitted, authority, withGate);
1962
+ return applySecretariatCountersignTerminal(admitted, authority, withGate, scope);
3023
1963
  }
3024
1964
 
3025
1965
  /** One failed attempt to place a durable failure artifact (path is private layout). */
@@ -3037,17 +1977,10 @@ function publicationAttemptFromError(
3037
1977
  error: unknown,
3038
1978
  ): PublicationAttempt {
3039
1979
  if (error instanceof Error) {
3040
- const identity: { name?: string; code?: string | number } = {
3041
- name: error.name,
3042
- };
3043
- const code = (error as { code?: unknown }).code;
3044
- if (typeof code === "string" || typeof code === "number") {
3045
- identity.code = code;
3046
- }
3047
1980
  return {
3048
1981
  path,
3049
1982
  diagnostic: error.message || error.name || "write failed",
3050
- identity,
1983
+ identity: thrownIdentity(error),
3051
1984
  };
3052
1985
  }
3053
1986
  return { path, diagnostic: String(error) };
@@ -3104,7 +2037,7 @@ async function writeFailureJsonRetainingCause(
3104
2037
  const candidates: string[] = [
3105
2038
  ...preferredCandidates,
3106
2039
  // One unique name per fallback dir — collisions on fixed names cannot exhaust this.
3107
- ...uniqueFallbackDirs.map((dir) => join(dir, `${stem}.${randomUUID()}.json`)),
2040
+ ...uniqueFallbackDirs.map((dir) => join(dir, stem === UNIQUE_ERROR_FALLBACK_STEM ? uniqueErrorFallbackName() : `${stem}.${randomUUID()}.json`)),
3108
2041
  ];
3109
2042
  for (let i = 0; i < candidates.length; i += 1) {
3110
2043
  const path = candidates[i]!;
@@ -3145,6 +2078,7 @@ export async function publishFailureArtifacts(
3145
2078
  failure: ControlledFailure,
3146
2079
  authority: DurablePrincipalAuthority,
3147
2080
  onErrorPublished?: (path: string) => void,
2081
+ recordAttemptHistory = false,
3148
2082
  ): Promise<TerminalArtifactRef[]> {
3149
2083
  const { sessionDirectory, sessionFile } = coordinatesFromAdmitted(authority, admitted);
3150
2084
  const { baseDir, attempt: baseAttempt } = await resolveFailureArtifactsBase(
@@ -3152,33 +2086,26 @@ export async function publishFailureArtifacts(
3152
2086
  );
3153
2087
  const priorIssues: PublicationAttempt[] =
3154
2088
  baseAttempt === undefined ? [] : [baseAttempt];
3155
- // #953: failure face must not leave a prior success report as the durable view.
3156
- // Clear failure must not strand the original controlled failure outside
3157
- // settlement — same as history failure below, it rides publicationIssues
3158
- // and unique fallback still places the durable error (locked artifacts/ +
3159
- // prior face must not abort publishFailureArtifacts).
3160
- try {
3161
- await clearOppositeTerminalArtifactFace(admitted.runDirectory);
3162
- } catch (error) {
3163
- priorIssues.push(
3164
- publicationAttemptFromError(
3165
- roleRunArtifactsDirectory(admitted.runDirectory),
3166
- error,
3167
- ),
3168
- );
2089
+ // #419: a dispatched failure joins history before fixed-name views change.
2090
+ // Re-projecting an existing failure does not append. History write failure
2091
+ // rides publicationIssues rather than stranding the controlled failure.
2092
+ if (recordAttemptHistory) {
2093
+ try {
2094
+ await appendRunAttemptHistory({ role: admitted.role, runId: admitted.runId, sessionFile }, {
2095
+ kind: "failure", role: admitted.role, ...failure,
2096
+ });
2097
+ } catch (error) {
2098
+ priorIssues.push(publicationAttemptFromError(sessionFile, error));
2099
+ }
3169
2100
  }
3170
- // #419: each attempt's complete failure result joins the appended history
3171
- // before any fixed-name artifact view is rewritten. History failure must not
3172
- // strand the original controlled failure outside settlement — it rides
3173
- // publicationIssues instead of aborting durability.
2101
+ // #953: clear the prior success face, but a failure here must not strand
2102
+ // the original controlled cause; preserve the issue beside the fallback.
3174
2103
  try {
3175
- await appendRunAttemptHistory({ role: admitted.role, runId: admitted.runId, sessionFile }, {
3176
- kind: "failure",
3177
- role: admitted.role,
3178
- ...failure,
3179
- });
2104
+ await clearOppositeTerminalArtifactFace(admitted.runDirectory);
3180
2105
  } catch (error) {
3181
- priorIssues.push(publicationAttemptFromError(sessionFile, error));
2106
+ priorIssues.push(publicationAttemptFromError(
2107
+ roleRunArtifactsDirectory(admitted.runDirectory), error,
2108
+ ));
3182
2109
  }
3183
2110
 
3184
2111
  // Prefer conventional names; unique fallback dirs keep colliding fixed paths
@@ -3191,23 +2118,23 @@ export async function publishFailureArtifacts(
3191
2118
  );
3192
2119
  const errorCandidates = underArtifacts
3193
2120
  ? [
3194
- join(baseDir, "error.json"),
3195
- join(baseDir, "error.settlement.json"),
3196
- join(admitted.runDirectory, "error.settlement.json"),
2121
+ join(baseDir, RUN_TERMINAL_ERROR_FILE),
2122
+ join(baseDir, RUN_TERMINAL_ERROR_SETTLEMENT_FILE),
2123
+ join(admitted.runDirectory, RUN_TERMINAL_ERROR_SETTLEMENT_FILE),
3197
2124
  ]
3198
2125
  : [
3199
- join(baseDir, "error.settlement.json"),
3200
- join(baseDir, "error.json"),
2126
+ join(baseDir, RUN_TERMINAL_ERROR_SETTLEMENT_FILE),
2127
+ join(baseDir, RUN_TERMINAL_ERROR_FILE),
3201
2128
  ];
3202
2129
  const evidenceCandidates = underArtifacts
3203
2130
  ? [
3204
- join(baseDir, "evidence.json"),
2131
+ join(baseDir, RUN_TERMINAL_EVIDENCE_FILE),
3205
2132
  join(baseDir, "evidence.settlement.json"),
3206
2133
  join(admitted.runDirectory, "evidence.settlement.json"),
3207
2134
  ]
3208
2135
  : [
3209
2136
  join(baseDir, "evidence.settlement.json"),
3210
- join(baseDir, "evidence.json"),
2137
+ join(baseDir, RUN_TERMINAL_EVIDENCE_FILE),
3211
2138
  ];
3212
2139
 
3213
2140
  const errorPayloadBase: Record<string, unknown> = {
@@ -3218,12 +2145,15 @@ export async function publishFailureArtifacts(
3218
2145
  diagnostic: failure.diagnostic,
3219
2146
  ...(failure.identity === undefined ? {} : { identity: failure.identity }),
3220
2147
  ...(failure.details === undefined ? {} : { details: failure.details }),
2148
+ // This package's own facts, kept beside the host's report.
2149
+ ...(failure.packageFact === undefined ? {} : { packageFact: failure.packageFact }),
2150
+ ...(failure.stderr === undefined ? {} : { stderr: failure.stderr }),
3221
2151
  };
3222
2152
 
3223
2153
  const errorWrite = await writeFailureJsonRetainingCause(
3224
2154
  errorCandidates,
3225
2155
  uniqueFallbackDirs,
3226
- "error",
2156
+ UNIQUE_ERROR_FALLBACK_STEM,
3227
2157
  errorPayloadBase,
3228
2158
  priorIssues,
3229
2159
  );
@@ -3234,12 +2164,7 @@ export async function publishFailureArtifacts(
3234
2164
  sessionDirectory: sessionDirectory,
3235
2165
  sessionFile: sessionFile,
3236
2166
  admittedRequestPath: admitted.admittedRequestPath,
3237
- attachments: admitted.attachments.map((a) => ({
3238
- provenancePath: a.provenancePath,
3239
- frozenPath: a.frozenPath,
3240
- sha256: a.sha256,
3241
- byteLength: a.byteLength,
3242
- })),
2167
+ attachments: acceptedArtifactAttachmentRefs(admitted.attachments),
3243
2168
  ...(failure.cause === undefined ? {} : { failureCause: failure.cause }),
3244
2169
  };
3245
2170
  const evidenceWrite = await writeFailureJsonRetainingCause(
@@ -3266,45 +2191,35 @@ export async function settleFailureTerminalResult(
3266
2191
  failure: ControlledFailure,
3267
2192
  authority: DurablePrincipalAuthority,
3268
2193
  options: SettlementCourtScope & {
3269
- readonly resume?: TerminalResume;
3270
2194
  readonly onErrorPublished?: (path: string) => void;
3271
2195
  } = {},
3272
2196
  ): Promise<TerminalResult> {
3273
2197
  const coordinates = coordinatesFromAdmitted(authority, admitted);
3274
2198
  const { sessionDirectory, sessionFile } = coordinates;
3275
- // #288 is lawful only when the lifecycle owner persisted an exhausted,
3276
- // current-attempt fact. Transcript reconstruction must not turn arbitrary output
3277
- // failures (or bytes retained from a prior resume attempt) into exit zero.
3278
- if (failure.cause === "output") {
3279
- const facts = await readCurrentAttemptNoReceiptFacts(admitted, authority, options);
3280
- if (facts !== undefined) {
3281
- const decisiveFacts: NoReceiptLifecycleFacts & Record<string, unknown> = facts;
3282
- // #478: no_receipt is still a public Terminal — project accepted gate facts.
3283
- // #953: empty public terminal face — clear every reader-adoptable prior face.
3284
- await clearOppositeTerminalArtifactFace(admitted.runDirectory);
3285
- return withOptionalGateProjection(
3286
- {
3287
- roleOutcome: {
3288
- kind: "no_receipt",
3289
- role: admitted.role,
3290
- status: "no-accepted-receipt",
3291
- ...facts,
3292
- decisiveFacts,
3293
- },
3294
- navigator: await extractNavigatorFactFromAdmittedSession(sessionFile),
3295
- artifacts: [],
3296
- runId: admitted.runId,
3297
- },
3298
- sessionDirectory,
3299
- detourGateContext(admitted, options),
2199
+ // Exact-session attendance only — never infer no-advice from caller omission.
2200
+ const navigator = await extractNavigatorFactFromAdmittedSession(
2201
+ sessionFile,
2202
+ admitted.runDirectory,
2203
+ options,
2204
+ );
2205
+ let artifacts: TerminalArtifactRef[] = [];
2206
+ if (options.previewOnly !== true) {
2207
+ try {
2208
+ artifacts = await publishFailureArtifacts(
2209
+ admitted,
2210
+ failure,
2211
+ authority,
2212
+ options.onErrorPublished,
2213
+ options.recordAttemptHistory === true,
2214
+ );
2215
+ } catch (error) {
2216
+ await noteSettlementFault(
2217
+ admitted.runDirectory,
2218
+ options,
2219
+ `failure artifact publication failed beside host terminal: ${describeErrorIdentity(error)}`,
3300
2220
  );
3301
2221
  }
3302
2222
  }
3303
- // Exact-session attendance only — never infer no-advice from caller omission.
3304
- const navigator = await extractNavigatorFactFromAdmittedSession(sessionFile);
3305
- // Private durable artifacts retain the original diagnostic identity (including run ID).
3306
- const artifacts = await publishFailureArtifacts(admitted, failure, authority, options.onErrorPublished);
3307
- const errorPath = artifacts.find((artifact) => artifact.kind === "error")?.path;
3308
2223
  const decisiveFacts: Record<string, unknown> = {
3309
2224
  ...(failure.cause === undefined ? {} : { cause: failure.cause }),
3310
2225
  diagnostic: failure.diagnostic,
@@ -3318,30 +2233,11 @@ export async function settleFailureTerminalResult(
3318
2233
  if (failure.details !== undefined) {
3319
2234
  decisiveFacts.secondaryEvidence = failure.details;
3320
2235
  }
3321
- // Resumable failures: durable artifacts still land under the run directory, but
3322
- // the public Terminal must not re-disclose the run ID via top-level runId,
3323
- // path components, or untrusted free text — only resume.command may carry it
3324
- // (AC2 / #108).
3325
- if (options.resume !== undefined) {
3326
- const roleOutcome: TerminalRoleOutcome = {
3327
- kind: "failure",
3328
- role: admitted.role,
3329
- ...(failure.cause === undefined ? {} : { cause: failure.cause }),
3330
- diagnostic: failure.diagnostic,
3331
- decisiveFacts,
3332
- };
3333
- const terminal = await withOptionalGateProjection(
3334
- {
3335
- roleOutcome,
3336
- navigator,
3337
- artifacts: [],
3338
- resume: options.resume,
3339
- },
3340
- sessionDirectory,
3341
- detourGateContext(admitted, options),
3342
- );
3343
- if (errorPath !== undefined) privateFailureErrorPaths.set(terminal, errorPath);
3344
- return terminal;
2236
+ if (failure.packageFact !== undefined) {
2237
+ decisiveFacts.packageFact = failure.packageFact;
2238
+ }
2239
+ if (failure.stderr !== undefined) {
2240
+ decisiveFacts.stderr = failure.stderr;
3345
2241
  }
3346
2242
  const roleOutcome: TerminalRoleOutcome = {
3347
2243
  kind: "failure",
@@ -3350,31 +2246,13 @@ export async function settleFailureTerminalResult(
3350
2246
  diagnostic: failure.diagnostic,
3351
2247
  decisiveFacts,
3352
2248
  };
3353
- // #478: ordinary controlled failure still surfaces accepted gate facts.
3354
2249
  return withOptionalGateProjection(
3355
- {
3356
- roleOutcome,
3357
- navigator,
3358
- artifacts,
3359
- runId: admitted.runId,
3360
- },
2250
+ { roleOutcome, navigator, artifacts, runId: admitted.runId },
3361
2251
  sessionDirectory,
3362
2252
  detourGateContext(admitted, options),
3363
2253
  );
3364
2254
  }
3365
2255
 
3366
- /** Judge-named alias retained for #107 call sites. */
3367
- export async function settleJudgeFailureTerminalResult(
3368
- admitted: AdmittedJudgeInvocation,
3369
- failure: ControlledFailure,
3370
- authority: DurablePrincipalAuthority,
3371
- options: SettlementCourtScope & {
3372
- readonly resume?: TerminalResume;
3373
- } = {},
3374
- ): Promise<TerminalResult> {
3375
- return settleFailureTerminalResult(admitted, failure, authority, options);
3376
- }
3377
-
3378
2256
  /**
3379
2257
  * Emit one complete failure Terminal on stdout and one concise stderr diagnostic.
3380
2258
  * Artifacts are already durable on the TerminalResult.
@@ -3389,9 +2267,11 @@ export function presentFailureTerminal(
3389
2267
  io.stdout(formatTerminalResult(terminal));
3390
2268
  if (terminal.roleOutcome.kind === "failure") {
3391
2269
  if (io.omitFailureStderrDiagnostic) return;
2270
+ const hostStderr = terminal.roleOutcome.decisiveFacts.stderr;
3392
2271
  io.stderr(formatFailureStderrDiagnostic({
3393
2272
  ...(terminal.roleOutcome.cause === undefined ? {} : { cause: terminal.roleOutcome.cause }),
3394
2273
  diagnostic: terminal.roleOutcome.diagnostic,
2274
+ ...(typeof hostStderr === "string" && hostStderr.length > 0 ? { stderr: hostStderr } : {}),
3395
2275
  }));
3396
2276
  return;
3397
2277
  }