@akagilnc/pi-workflow-roles 0.1.5614 → 0.1.5650

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 (35) hide show
  1. package/dist/acp-host/production-host.js +960 -465
  2. package/dist/activation-ledger-topology.js +12 -0
  3. package/dist/external-host-turn-loop.js +281 -0
  4. package/dist/headless-host/production-host.js +900 -406
  5. package/dist/migrate-book-topology.js +8 -5
  6. package/dist/pi/role-turn-host.js +33 -3
  7. package/dist/public-cli/instruction-seat-run.js +177 -46
  8. package/dist/public-cli/invocation.js +5 -12
  9. package/dist/public-cli/main.js +541 -165
  10. package/dist/public-cli/post-admission.js +171 -18
  11. package/dist/public-cli/settlement.js +20 -10
  12. package/dist/report-ticket-tool.js +136 -0
  13. package/dist/role-run-placement.js +1 -2
  14. package/dist/role-run-relocation.js +24 -4
  15. package/package.json +1 -1
  16. package/resources/engine-dispatch.md +6 -3
  17. package/resources/engines/claude-code.md +4 -5
  18. package/resources/missing-ticket-reask.md +1 -0
  19. package/src/acp-host/role-turn-host.ts +41 -31
  20. package/src/activation-ledger-topology.ts +16 -0
  21. package/src/external-host-turn-loop.ts +103 -12
  22. package/src/headless-host/role-turn-host.ts +41 -25
  23. package/src/pi/role-turn-host.ts +37 -3
  24. package/src/public-cli/instruction-seat-run.ts +248 -50
  25. package/src/public-cli/invocation.ts +15 -27
  26. package/src/public-cli/post-admission.ts +250 -33
  27. package/src/public-cli/run-lifecycle.ts +5 -5
  28. package/src/public-cli/settlement.ts +38 -11
  29. package/src/public-cli/terminal.ts +6 -0
  30. package/src/report-ticket-tool.ts +176 -0
  31. package/src/role-envelope.ts +28 -3
  32. package/src/role-run-placement.ts +0 -2
  33. package/src/role-run-relocation.ts +45 -4
  34. package/src/role-runtime.ts +6 -0
  35. package/src/worker-submission-gates.ts +26 -3
@@ -6,6 +6,7 @@
6
6
  * Role runners supply only turn request projection and narrow settlement adapters.
7
7
  */
8
8
  import { randomUUID } from "node:crypto";
9
+ import { existsSync } from "node:fs";
9
10
  import { writeFile } from "node:fs/promises";
10
11
 
11
12
  import {
@@ -24,14 +25,15 @@ import {
24
25
  relocateAdmittedRunToTicket,
25
26
  } from "./invocation.ts";
26
27
  import { readRecordedSubmissionRows } from "../submission-ledger.ts";
27
- import { parseTicketNumber, readDeclaredTicketNumber } from "../run-ticket-number.ts";
28
+ import { parseTicketNumber, readBoardTicketNumber, readDeclaredTicketNumber } from "../run-ticket-number.ts";
29
+ import { homeFromRunDirectory } from "../activation-ledger-topology.ts";
28
30
  import { readStoredHostSessionId } from "../session-identity.ts";
29
31
  import { reportRunRecord } from "../sitian-facade.ts";
30
32
  import {
31
33
  createReceiptDeliveryPolicy,
32
34
  deliveryLimitFromConfig,
33
35
  } from "../receipt-delivery-policy.ts";
34
- import { rewriteRunDirectoryPathValue } from "../role-run-relocation.ts";
36
+ import { rewritePrincipalSessionPaths, rewriteRunDirectoryPathValue, rewriteAdmittedRoleRunPage } from "../role-run-relocation.ts";
35
37
 
36
38
  import type {
37
39
  ControlledFailureCause,
@@ -145,6 +147,148 @@ function projectRelocatedTurnIdentity(
145
147
  }
146
148
  }
147
149
 
150
+ /**
151
+ * #1171: after the host returns, re-locate the leg by run id (report-ticket may
152
+ * have moved it mid-turn in the tool process). Settlement and render use the
153
+ * live path; lease cleanup follows when the held path changed.
154
+ * Failure is not best-effort: a missing former path with no replacement must not
155
+ * settle or write back under the vanished unbound leaf (失败诚实宪法).
156
+ */
157
+ export async function refreshAdmittedPlacementAfterHostTurn(
158
+ admitted: AdmittedRoleInvocation,
159
+ authority: DurablePrincipalAuthority,
160
+ heldLease?: { relocate(runDirectory: string): void },
161
+ ): Promise<RunDirectoryRelocation | undefined> {
162
+ const home = homeFromRunDirectory(admitted.runDirectory);
163
+ const found = await findRunDirectoryById(home, admitted.runId, admitted.bookKey, admitted.role);
164
+ if (found === undefined && !existsSync(admitted.runDirectory)) {
165
+ throw new Error(
166
+ `admitted run ${admitted.runId} missing after host turn; not found under ${home}`,
167
+ );
168
+ }
169
+ // Live identity first: later board-ticket read must not leave settlement on the
170
+ // pre-relocate path when facts are damaged (#1171 F4-R1).
171
+ let relocation: RunDirectoryRelocation | undefined;
172
+ if (found !== undefined && found !== admitted.runDirectory) {
173
+ const oldRunDirectory = admitted.runDirectory;
174
+ const coords = { ...authority.decode(admitted.principal) };
175
+ rewritePrincipalSessionPaths(
176
+ coords as Record<string, unknown>,
177
+ oldRunDirectory,
178
+ found,
179
+ );
180
+ const principal = authority.seal(coords);
181
+ rewriteAdmittedRoleRunPage(
182
+ admitted as unknown as Record<string, unknown>,
183
+ [{ oldRunDirectory, newRunDirectory: found }],
184
+ );
185
+ (admitted as { principal: typeof principal }).principal = principal;
186
+ heldLease?.relocate(found);
187
+ relocation = { oldRunDirectory, newRunDirectory: found };
188
+ }
189
+ // Board re-read discovers a ticket written mid-turn while still unbound
190
+ // (report-ticket board page). Already-bound identity needs no discovery —
191
+ // path relocate already used findRunDirectoryById. This is not damage
192
+ // recovery: when unbound, readBoardTicketNumber damage/non-ENOENT IO still
193
+ // propagates (失败诚实; notary: no damaged-control-plane → no-ticket wash).
194
+ if (admitted.ticketNumber === undefined) {
195
+ const boardTicket = await readBoardTicketNumber(found ?? admitted.runDirectory);
196
+ if (boardTicket !== undefined) {
197
+ (admitted as { ticketNumber?: number }).ticketNumber = boardTicket;
198
+ }
199
+ }
200
+ return relocation;
201
+ }
202
+
203
+ /** Apply a successful post-host placement refresh onto the live turn request. */
204
+ function applyRefreshedPlacementToTurn(
205
+ request: RoleTurnRequest,
206
+ turnRequest: RoleTurnRequest,
207
+ admitted: AdmittedRoleInvocation,
208
+ refreshed: RunDirectoryRelocation,
209
+ ): RoleTurnRequest {
210
+ projectRelocatedTurnIdentity(request, {}, admitted, refreshed);
211
+ return {
212
+ ...turnRequest,
213
+ runDirectory: admitted.runDirectory,
214
+ principal: admitted.principal,
215
+ };
216
+ }
217
+
218
+ /**
219
+ * #1171: host throw after a started turn — one live placement refresh, then the
220
+ * one settlement authority (ADR 0080). Shared by first turn and 催交; never
221
+ * settle under a vanished unbound leaf.
222
+ */
223
+ async function settleHostThrowAfterLivePlacementRefresh<
224
+ A extends AdmittedRoleInvocation,
225
+ T extends TerminalResult = TerminalResult,
226
+ >(input: {
227
+ admitted: A;
228
+ hostError: unknown;
229
+ /** False only when executeTurn itself threw; post-success refresh share this catch. */
230
+ hostTurnCompleted: boolean;
231
+ request: RoleTurnRequest;
232
+ turnRequest: RoleTurnRequest;
233
+ lease: RunWriterLease | undefined;
234
+ adapters: PostAdmissionAdapters<A, T>;
235
+ env: PostAdmissionEnv;
236
+ io: CliIo;
237
+ persistRunState: boolean;
238
+ notePackageFault?: (diagnostic: string) => void | Promise<void>;
239
+ courtAttemptId?: string;
240
+ }): Promise<{
241
+ settled: { exitCode: number; admitted: A; terminal: T };
242
+ turnRequest: RoleTurnRequest;
243
+ }> {
244
+ let refreshError: unknown;
245
+ let turnRequest = input.turnRequest;
246
+ try {
247
+ const refreshed = await refreshAdmittedPlacementAfterHostTurn(
248
+ input.admitted,
249
+ input.env.principalAuthority,
250
+ input.lease,
251
+ );
252
+ if (refreshed !== undefined) {
253
+ turnRequest = applyRefreshedPlacementToTurn(
254
+ input.request,
255
+ turnRequest,
256
+ input.admitted,
257
+ refreshed,
258
+ );
259
+ }
260
+ } catch (err) {
261
+ refreshError = err;
262
+ }
263
+ const processCancelName = processCancelSignalName(input.env.signal);
264
+ // Secondary hostThrow only when the host actually threw and refresh also failed.
265
+ // Package-side refresh failure must not borrow that label (#1171 F10).
266
+ const hostThrowSecondary =
267
+ refreshError !== undefined && input.hostTurnCompleted === false
268
+ ? { knownDetails: { hostThrow: describeCaughtError(input.hostError) } }
269
+ : {};
270
+ const settled = await settleAfterTurnStarted(
271
+ input.admitted,
272
+ withEngineDetourInvocationScope({
273
+ timedOut: false,
274
+ code: null,
275
+ stderr: "",
276
+ // Prefer the refresh failure when the live path is gone; otherwise the
277
+ // host throw remains the cause (失败诚实:不在旧 unbound 上结算写回).
278
+ ...(processCancelName === undefined ? {} : { cancelName: processCancelName }),
279
+ thrown: refreshError ?? input.hostError,
280
+ ...hostThrowSecondary,
281
+ }, input.request.invocationScopeId),
282
+ input.adapters,
283
+ input.env.principalAuthority,
284
+ input.io,
285
+ input.persistRunState,
286
+ input.notePackageFault,
287
+ input.courtAttemptId,
288
+ );
289
+ return { settled, turnRequest };
290
+ }
291
+
148
292
  /** #855: process-cancel settlement never re-enters auto-resume. */
149
293
  function withProcessCancelSkipAutoResume<T extends object>(
150
294
  result: T,
@@ -307,6 +451,23 @@ export type PostAdmissionEnv = {
307
451
  * keeps this env's autoResumeLimit; the counter is the only thing that moves.
308
452
  */
309
453
  unreadableReasksSpent?: number;
454
+ /**
455
+ * #1171: missing-ticket soft reasks already issued on this chain. Hard ceiling
456
+ * is one — still no ticket after that stays unbound with the sealed receipt.
457
+ * Budget only across rounds; not the current turn's settlement identity.
458
+ */
459
+ ticketReasksSpent?: number;
460
+ /**
461
+ * #1171 F2-R5: this turn is the one soft reask. Keep the sealed terminal face
462
+ * when it ends without a substitute seal. Not carried into audit continue.
463
+ */
464
+ softTicketReaskTurn?: true;
465
+ /**
466
+ * #1171 F2-R7: live ownership cell for this soft-reask nested chain.
467
+ * Created at soft-reask dispatch; settlement of that turn sets sealedSubstitute.
468
+ * Shared ref survives env spreads — not a second ledger.
469
+ */
470
+ softTicketReaskOwnership?: { sealedSubstitute: boolean };
310
471
  createRunId?: () => string;
311
472
  /**
312
473
  * Parent cancellation for a nested public summon (#675). Every dispatched turn
@@ -1023,6 +1184,8 @@ export async function dispatchPostAdmissionTurn<
1023
1184
  const finishAfterTurn = async (result: DispatchOutcomeFor<A, T>): Promise<DispatchOutcomeFor<A, T>> => {
1024
1185
  if (afterDispatchApplied) return result;
1025
1186
  afterDispatchApplied = true;
1187
+ // #1171: placement refresh runs once after each host return (below), before
1188
+ // settlement — not again here. finishAfterTurn only binds/relocates sealed tickets.
1026
1189
  // #855: re-read cancel before committing the terminal and afterDispatch.
1027
1190
  // A signal after the history-first commit cannot retroactively erase it.
1028
1191
  // skipRunStateWrite: run-state
@@ -1069,15 +1232,35 @@ export async function dispatchPostAdmissionTurn<
1069
1232
  result.terminal === pendingTerminal
1070
1233
  ) {
1071
1234
  try {
1235
+ // #1171 F2-R2 / F2-R3 (#419) / F2-R5: only the soft-reask turn itself
1236
+ // preserves the sealed terminal face. ticketReasksSpent is budget across
1237
+ // rounds — after audit continue, ordinary no-volume turns publish
1238
+ // no_receipt honestly. previewOnly skips the terminal pointer;
1239
+ // recordAttemptHistory still appends.
1240
+ const softTicketReaskNoSeal =
1241
+ pendingSettlement !== "sealed" && env.softTicketReaskTurn === true;
1242
+ const noSealScope = softTicketReaskNoSeal
1243
+ ? {
1244
+ ...courtScope,
1245
+ previewOnly: true as const,
1246
+ recordAttemptHistory: true as const,
1247
+ }
1248
+ : { ...courtScope, recordAttemptHistory: true as const };
1072
1249
  const terminal = pendingSettlement === "sealed"
1073
1250
  ? await adapters.trySettle(admitted, env.principalAuthority,
1074
1251
  { ...courtScope, recordAttemptHistory: true })
1075
1252
  : await attachRecordedSubmissions(admitted,
1076
1253
  await settleHostEndedNoReceipt(admitted, env.principalAuthority,
1077
- { ...courtScope, recordAttemptHistory: true }, receiptDelivery.issuedDeliveryRequests()) as T,
1078
- courtScope);
1254
+ noSealScope, receiptDelivery.issuedDeliveryRequests()) as T,
1255
+ noSealScope);
1079
1256
  if (terminal === undefined) throw new Error("settled host attempt vanished before publication");
1080
1257
  result = { ...result, terminal, exitCode: exitCodeForTerminalOutcome(terminal.roleOutcome) };
1258
+ // #1171 F2-R7: ownership evidence at this soft-reask turn's seal —
1259
+ // mutate the live ownership cell carried on env (survives spreads).
1260
+ if (pendingSettlement === "sealed" && env.softTicketReaskTurn === true
1261
+ && env.softTicketReaskOwnership !== undefined) {
1262
+ env.softTicketReaskOwnership.sealedSubstitute = true;
1263
+ }
1081
1264
  if (pendingSettlement === "sealed" && terminal.roleOutcome.kind === "accepted"
1082
1265
  && request.courtAttemptId !== undefined && request.courtAttemptId.length > 0) {
1083
1266
  try {
@@ -1223,6 +1406,7 @@ export async function dispatchPostAdmissionTurn<
1223
1406
  // auto-resume re-enters this dispatch and must reuse the same scope.
1224
1407
 
1225
1408
  let result: RoleTurnResult;
1409
+ let hostTurnCompleted = false;
1226
1410
  try {
1227
1411
  // A bare `ak-role resume` only forwards the caller's instruction to the
1228
1412
  // host CLI's native resume. Nothing here may gate, redirect or reshape
@@ -1230,29 +1414,41 @@ export async function dispatchPostAdmissionTurn<
1230
1414
  // the authoritative post-turn settlement reads whatever really happened.
1231
1415
  turnDispatched = true;
1232
1416
  result = await env.roleTurnHost.executeTurn(turnRequest);
1417
+ hostTurnCompleted = true;
1418
+ // #1171: one placement refresh before settlement, same try as executeTurn.
1419
+ // Failure shares the catch below (refresh-then-settle + #855 cancel wrap).
1420
+ const refreshed = await refreshAdmittedPlacementAfterHostTurn(
1421
+ admitted,
1422
+ env.principalAuthority,
1423
+ lease,
1424
+ );
1425
+ if (refreshed !== undefined) {
1426
+ turnRequest = applyRefreshedPlacementToTurn(request, turnRequest, admitted, refreshed);
1427
+ }
1233
1428
  } catch (error) {
1234
- const processCancelName = processCancelSignalName(env.signal);
1235
- const settled = await settleAfterTurnStarted(
1236
- admitted,
1237
- withEngineDetourInvocationScope({
1238
- timedOut: false,
1239
- code: null,
1240
- stderr: "",
1241
- // The thrown value is the host's report. A cancel received on this
1242
- // call is a second fact; it must not become a new Error in its place.
1243
- ...(processCancelName === undefined ? {} : { cancelName: processCancelName }),
1244
- thrown: error,
1245
- }, request.invocationScopeId),
1429
+ // #1171: one placement refresh before settlement — failure is a real cause,
1430
+ // never catch-and-continue onto a vanished unbound leaf.
1431
+ // Host throw and post-success refresh throw share this catch (#1171 F9).
1432
+ const thrown = await settleHostThrowAfterLivePlacementRefresh({
1433
+ admitted,
1434
+ hostError: error,
1435
+ hostTurnCompleted,
1436
+ request,
1437
+ turnRequest,
1438
+ lease,
1246
1439
  adapters,
1247
- env.principalAuthority,
1440
+ env,
1248
1441
  io,
1249
1442
  persistRunState,
1250
- courtScope.notePackageFault,
1251
- courtScope.courtAttemptId,
1252
- );
1443
+ notePackageFault: courtScope.notePackageFault,
1444
+ ...(courtScope.courtAttemptId === undefined
1445
+ ? {}
1446
+ : { courtAttemptId: courtScope.courtAttemptId }),
1447
+ });
1448
+ turnRequest = thrown.turnRequest;
1253
1449
  return await finishAfterTurn(
1254
1450
  withProcessCancelSkipAutoResume(
1255
- { ...settled, turnDispatched: true as const, ...deferredPersist },
1451
+ { ...thrown.settled, turnDispatched: true as const, ...deferredPersist },
1256
1452
  env.signal,
1257
1453
  ),
1258
1454
  );
@@ -1289,6 +1485,7 @@ export async function dispatchPostAdmissionTurn<
1289
1485
  // that never reached the host.
1290
1486
  let deliveryResult: RoleTurnResult;
1291
1487
  let deliveryTurnRequest: RoleTurnRequest;
1488
+ let deliveryHostTurnCompleted = false;
1292
1489
  try {
1293
1490
  // Host and the other axes already selected on this turn ride the
1294
1491
  // shared projection. The pre-projection request does not have them.
@@ -1306,28 +1503,48 @@ export async function dispatchPostAdmissionTurn<
1306
1503
  };
1307
1504
  receiptDelivery.recordDeliveryRequest();
1308
1505
  deliveryResult = await env.roleTurnHost.executeTurn(deliveryTurnRequest);
1506
+ deliveryHostTurnCompleted = true;
1507
+ // Same single refresh-before-settle rule as the first turn (#1171):
1508
+ // stay inside this try so cancel wrap is not dropped (#1171 F9 / #855).
1509
+ const refreshed = await refreshAdmittedPlacementAfterHostTurn(
1510
+ admitted,
1511
+ env.principalAuthority,
1512
+ lease,
1513
+ );
1514
+ if (refreshed !== undefined) {
1515
+ turnRequest = applyRefreshedPlacementToTurn(request, turnRequest, admitted, refreshed);
1516
+ deliveryTurnRequest = {
1517
+ ...deliveryTurnRequest,
1518
+ runDirectory: admitted.runDirectory,
1519
+ principal: admitted.principal,
1520
+ };
1521
+ }
1309
1522
  } catch (error) {
1310
1523
  // 催交 cannot substitute for host failure recovery: a real failure with
1311
1524
  // its true cause, through the one settlement authority (ADR 0080).
1312
- // The turn started, so the caller's loop resumes instead of replaying
1313
- // the initial payload. The issued count survives that re-entry.
1314
- const settled = await settleAfterTurnStarted(
1525
+ // Same refresh-before-settle seam as the first turn (#1171 F4-R1).
1526
+ // Host throw and post-success refresh throw share this catch (#1171 F9).
1527
+ const thrown = await settleHostThrowAfterLivePlacementRefresh({
1315
1528
  admitted,
1316
- withEngineDetourInvocationScope(
1317
- { timedOut: false, code: null, stderr: "", thrown: error },
1318
- request.invocationScopeId,
1319
- ),
1529
+ hostError: error,
1530
+ hostTurnCompleted: deliveryHostTurnCompleted,
1531
+ request,
1532
+ turnRequest,
1533
+ lease,
1320
1534
  adapters,
1321
- env.principalAuthority,
1535
+ env,
1322
1536
  io,
1323
1537
  persistRunState,
1324
- courtScope.notePackageFault,
1325
- courtScope.courtAttemptId,
1326
- );
1538
+ notePackageFault: courtScope.notePackageFault,
1539
+ ...(courtScope.courtAttemptId === undefined
1540
+ ? {}
1541
+ : { courtAttemptId: courtScope.courtAttemptId }),
1542
+ });
1543
+ turnRequest = thrown.turnRequest;
1327
1544
  return await finishAfterTurn(
1328
1545
  withProcessCancelSkipAutoResume(
1329
1546
  {
1330
- ...settled,
1547
+ ...thrown.settled,
1331
1548
  turnDispatched: true as const,
1332
1549
  ...deferredPersist,
1333
1550
  },
@@ -53,7 +53,7 @@ import {
53
53
  type CoderPhase,
54
54
  type DerivedMergerEnvelope,
55
55
  normalizeAdmittedAttachment,
56
- type FrozenAttachment,
56
+ type AdmittedAttachment,
57
57
  type InvocationEffectiveModel,
58
58
  type ReviewerLens,
59
59
  } from "./invocation.ts";
@@ -118,7 +118,7 @@ export type CurrentCourtState = {
118
118
 
119
119
  /**
120
120
  * Materials delivered on same-ticket re-summons while reusing the same-run resume seam.
121
- * Instruction seats freeze new attachments into the retained run and ride the transport prompt;
121
+ * Instruction seats pass caller file-flag paths through the transport prompt (ADR 0087);
122
122
  * notary overrides the source-run activation pointer for this turn only.
123
123
  */
124
124
  export type SameTicketSummonsMaterials = {
@@ -974,7 +974,7 @@ export async function findLatestRunIdForSeatTicket(input: {
974
974
  type LoadedAdmittedRequestFields = {
975
975
  readonly instruction: string;
976
976
  readonly instructionEmpty: boolean;
977
- readonly attachments: FrozenAttachment[];
977
+ readonly attachments: AdmittedAttachment[];
978
978
  readonly phase?: CoderPhase | FixerPhase;
979
979
  readonly prerequisitesPath?: string;
980
980
  readonly baseRevision?: string;
@@ -1060,7 +1060,7 @@ async function loadResumableRunRecord(
1060
1060
  // Reconstruct admitted identity from durable run record + the admitted section.
1061
1061
  let instruction = "";
1062
1062
  let instructionEmpty = true;
1063
- let attachments: FrozenAttachment[] = [];
1063
+ let attachments: AdmittedAttachment[] = [];
1064
1064
  let phase: CoderPhase | FixerPhase | undefined;
1065
1065
  let prerequisitesPath: string | undefined;
1066
1066
  let baseRevision: string | undefined;
@@ -1101,7 +1101,7 @@ async function loadResumableRunRecord(
1101
1101
  if (Array.isArray(record.attachments)) {
1102
1102
  attachments = record.attachments
1103
1103
  .map((item) => normalizeAdmittedAttachment(item))
1104
- .filter((item): item is FrozenAttachment => item !== undefined);
1104
+ .filter((item): item is AdmittedAttachment => item !== undefined);
1105
1105
  }
1106
1106
  if (record.phase === "plan" || record.phase === "apply") {
1107
1107
  phase = record.phase;
@@ -189,6 +189,12 @@ export function ledgerReadScope(
189
189
  };
190
190
  }
191
191
 
192
+ type SealedLedgerSettle = {
193
+ readonly outcome: Extract<TerminalRoleOutcome, { kind: "accepted" | "audit_escalation" }>;
194
+ /** Tool-call identity of the latest this-settle terminal row (#1171 F2-R8). */
195
+ readonly submissionToolCallId?: string;
196
+ };
197
+
192
198
  function roleOutcomeFromRows(
193
199
  role: TerminalRoleName,
194
200
  rows: readonly {
@@ -197,8 +203,9 @@ function roleOutcomeFromRows(
197
203
  readonly accepted: unknown;
198
204
  readonly auditReceipt?: unknown;
199
205
  readonly auditOfficer?: unknown;
206
+ readonly toolCallId?: string;
200
207
  }[],
201
- ): Extract<TerminalRoleOutcome, { kind: "accepted" | "audit_escalation" }> | undefined {
208
+ ): SealedLedgerSettle | undefined {
202
209
  const mine = rows.filter((row) => row.role === role);
203
210
  // Terminal acceptance kind still only follows sealed / audit-escalation (#881):
204
211
  // correctable-rejection / infrastructure / candidate stay payloads, not acceptance.
@@ -211,23 +218,32 @@ function roleOutcomeFromRows(
211
218
  // keep that earlier receipt (#1057).
212
219
  const payloads = mine.map((row) => row.accepted);
213
220
  const latest = terminal[terminal.length - 1]!;
221
+ const submissionToolCallId =
222
+ typeof latest.toolCallId === "string" && latest.toolCallId.length > 0
223
+ ? latest.toolCallId
224
+ : undefined;
225
+ const belonging =
226
+ submissionToolCallId === undefined ? {} : { submissionToolCallId };
214
227
  if (latest.kind === "audit-escalation") {
215
228
  return {
216
- kind: "audit_escalation", role, status: "audit_escalation", payloads,
217
- ...(Object.hasOwn(latest, "auditReceipt") ? { decisiveFacts: {
218
- auditEscalationReceipt: latest.auditReceipt,
219
- ...(Object.hasOwn(latest, "auditOfficer") ? { auditEscalationOfficer: latest.auditOfficer } : {}),
220
- } } : {}),
229
+ outcome: {
230
+ kind: "audit_escalation", role, status: "audit_escalation", payloads,
231
+ ...(Object.hasOwn(latest, "auditReceipt") ? { decisiveFacts: {
232
+ auditEscalationReceipt: latest.auditReceipt,
233
+ ...(Object.hasOwn(latest, "auditOfficer") ? { auditEscalationOfficer: latest.auditOfficer } : {}),
234
+ } } : {}),
235
+ },
236
+ ...belonging,
221
237
  };
222
238
  }
223
- return { kind: "accepted", role, payloads };
239
+ return { outcome: { kind: "accepted", role, payloads }, ...belonging };
224
240
  }
225
241
 
226
242
  async function sealedLedgerOutcome(
227
243
  admitted: AdmittedRoleInvocation,
228
244
  role: TerminalRoleName,
229
245
  scope?: SettlementCourtScope,
230
- ): Promise<Extract<TerminalRoleOutcome, { kind: "accepted" | "audit_escalation" }> | undefined> {
246
+ ): Promise<SealedLedgerSettle | undefined> {
231
247
  const home = sealedLedgerHome(admitted);
232
248
  // #879: when court scope is present, roleOutcome is this-court original only —
233
249
  // never last-wins over an undivided historical array, and never falls back to
@@ -1450,8 +1466,8 @@ async function settleSealedSeat(
1450
1466
  },
1451
1467
  ): Promise<TerminalResult | undefined> {
1452
1468
  const coordinates = coordinatesFromAdmitted(authority, admitted);
1453
- const roleOutcome = await sealedLedgerOutcome(admitted, admitted.role, scope);
1454
- if (roleOutcome === undefined || (options.acceptedOnly && roleOutcome.kind !== "accepted")) {
1469
+ const settled = await sealedLedgerOutcome(admitted, admitted.role, scope);
1470
+ if (settled === undefined || (options.acceptedOnly && settled.outcome.kind !== "accepted")) {
1455
1471
  return undefined;
1456
1472
  }
1457
1473
  const read = await readLawfulSettlementEntries(coordinates.sessionFile);
@@ -1462,7 +1478,14 @@ async function settleSealedSeat(
1462
1478
  `session read failed beside lawful terminal: ${describeErrorIdentity(read.error)}`,
1463
1479
  );
1464
1480
  }
1465
- return finishLawfulSeat(admitted, coordinates, entriesOf(read), roleOutcome, scope);
1481
+ return finishLawfulSeat(
1482
+ admitted,
1483
+ coordinates,
1484
+ entriesOf(read),
1485
+ settled.outcome,
1486
+ scope,
1487
+ settled.submissionToolCallId,
1488
+ );
1466
1489
  }
1467
1490
 
1468
1491
  async function finishLawfulSeat(
@@ -1471,6 +1494,7 @@ async function finishLawfulSeat(
1471
1494
  entries: readonly SessionEntry[],
1472
1495
  roleOutcome: TerminalRoleOutcome,
1473
1496
  scope: SettlementCourtScope | undefined,
1497
+ submissionToolCallId?: string,
1474
1498
  ): Promise<TerminalResult> {
1475
1499
  const artifacts = scope?.previewOnly === true
1476
1500
  ? []
@@ -1480,6 +1504,9 @@ async function finishLawfulSeat(
1480
1504
  navigator: extractNavigatorFact(entries),
1481
1505
  artifacts,
1482
1506
  runId: admitted.runId,
1507
+ ...(submissionToolCallId === undefined || submissionToolCallId.length === 0
1508
+ ? {}
1509
+ : { submissionToolCallId }),
1483
1510
  }, coordinates.sessionDirectory, detourUsageContext(admitted, scope));
1484
1511
  return attachRecordedSubmissions(admitted, terminal, scope);
1485
1512
  }
@@ -139,6 +139,12 @@ export type TerminalResult = {
139
139
  * (#953 — not copied onto failure.payloads).
140
140
  */
141
141
  submissions?: readonly unknown[];
142
+ /**
143
+ * Tool-call identity of the sealed submission this terminal settled from
144
+ * (#1171 F2-R8). Carried from court/settlement so audit consumers bind the
145
+ * object in hand — never re-infer from the whole-leg latest accepted row.
146
+ */
147
+ submissionToolCallId?: string;
142
148
  /** Call-local auto-resume observation (0..2) for this single LLM call; read-only, not persisted. */
143
149
  autoResumeCount?: number;
144
150
  } & (