@akagilnc/pi-workflow-roles 0.1.4021 → 0.1.4062

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 (74) hide show
  1. package/dist/acp-host/production-host.js +682 -355
  2. package/dist/doctor-auditor.js +1 -1
  3. package/dist/dossier-resolution.js +21 -8
  4. package/dist/gatekeeper-role.js +34 -27
  5. package/dist/headless-host/description.js +7 -8
  6. package/dist/headless-host/production-host.js +709 -369
  7. package/dist/host-contracts.js +16 -0
  8. package/dist/inspector-contracts.js +8 -0
  9. package/dist/navigator-attendance.js +2 -0
  10. package/dist/navigator-public-session.js +18 -34
  11. package/dist/navigator-session-contracts.js +19 -0
  12. package/dist/packaged-role-registry.js +1 -1
  13. package/dist/pi/known-failure.js +3 -4
  14. package/dist/pi/role-turn-host.js +43 -13
  15. package/dist/public-cli/auto-resume.js +20 -7
  16. package/dist/public-cli/case-dossier-delivery.js +60 -1
  17. package/dist/public-cli/diarist-run.js +9 -23
  18. package/dist/public-cli/inspector-run.js +17 -3
  19. package/dist/public-cli/instruction-seat-run.js +12 -7
  20. package/dist/public-cli/judge-run.js +3 -1
  21. package/dist/public-cli/main.js +440 -222
  22. package/dist/public-cli/notary-run.js +7 -4
  23. package/dist/public-cli/post-admission.js +66 -59
  24. package/dist/public-cli/run-lifecycle.js +3 -0
  25. package/dist/public-cli/settlement.js +78 -58
  26. package/dist/public-cli/terminal.js +1 -11
  27. package/dist/public-role-summons.js +8 -0
  28. package/dist/submission-ledger.js +139 -19
  29. package/dist/user-dialogue-stdin.js +33 -0
  30. package/extensions/role-runtime.ts +1 -0
  31. package/package.json +1 -1
  32. package/resources/836-deleted-machine-instruction-inventory.md +1 -1
  33. package/src/doctor-auditor.ts +1 -1
  34. package/src/dossier-resolution.ts +26 -8
  35. package/src/external-host-turn-loop.ts +9 -0
  36. package/src/gatekeeper-pass-envelope.ts +6 -0
  37. package/src/gatekeeper-role.ts +47 -28
  38. package/src/headless-host/description.ts +8 -11
  39. package/src/headless-host/role-turn-host.ts +20 -5
  40. package/src/host-contracts.ts +30 -7
  41. package/src/inspector-contracts.ts +7 -0
  42. package/src/judge-role.ts +4 -0
  43. package/src/navigator-attendance.ts +2 -0
  44. package/src/navigator-public-session.ts +19 -55
  45. package/src/navigator-session-contracts.ts +39 -0
  46. package/src/navigator-work-context.ts +3 -4
  47. package/src/notary-role.ts +1 -0
  48. package/src/packaged-role-registry.ts +1 -1
  49. package/src/pi/adapter.ts +19 -4
  50. package/src/pi/known-failure.ts +3 -4
  51. package/src/pi/role-turn-host.ts +48 -14
  52. package/src/public-cli/auto-resume.ts +57 -20
  53. package/src/public-cli/case-dossier-delivery.ts +86 -1
  54. package/src/public-cli/cli.ts +2 -2
  55. package/src/public-cli/collector-run.ts +3 -1
  56. package/src/public-cli/countersign-run.ts +34 -27
  57. package/src/public-cli/diarist-run.ts +10 -27
  58. package/src/public-cli/inspector-run.ts +26 -2
  59. package/src/public-cli/instruction-seat-run.ts +20 -10
  60. package/src/public-cli/invocation.ts +2 -0
  61. package/src/public-cli/judge-run.ts +3 -1
  62. package/src/public-cli/notary-run.ts +13 -4
  63. package/src/public-cli/post-admission.ts +74 -65
  64. package/src/public-cli/reviewer-run.ts +3 -1
  65. package/src/public-cli/run-lifecycle.ts +3 -0
  66. package/src/public-cli/settlement.ts +105 -80
  67. package/src/public-cli/terminal.ts +7 -16
  68. package/src/public-role-summons.ts +31 -7
  69. package/src/role-envelope.ts +9 -30
  70. package/src/role-runtime-dependencies.ts +1 -0
  71. package/src/role-runtime.ts +95 -19
  72. package/src/submission-ledger.ts +195 -26
  73. package/src/user-dialogue-stdin.ts +37 -0
  74. package/src/worker-role.ts +4 -0
@@ -172,6 +172,8 @@ export async function prepareRoleEnvelope(options: {
172
172
  cwd: request.cwd,
173
173
  mode: "print",
174
174
  model: request.model === undefined ? undefined : { provider: request.model.provider },
175
+ runDirectory: request.runDirectory,
176
+ ...(request.courtAttemptId === undefined ? {} : { courtAttemptId: request.courtAttemptId }),
175
177
  sessionManager: {
176
178
  getLeafEntry: () => sessionEntries.at(-1) as ReturnType<HostContext["sessionManager"]["getLeafEntry"]>,
177
179
  getLeafId: () => runId,
@@ -239,6 +241,7 @@ export async function prepareRoleEnvelope(options: {
239
241
  bindSubmissionNonPass: options.hostActions.bindSubmissionNonPass,
240
242
  },
241
243
  toolCallId: options.toolCallId,
244
+ ...(options.submission === undefined ? {} : { submission: options.submission }),
242
245
  });
243
246
  },
244
247
  on(...registration: HostEventRegistration) {
@@ -527,22 +530,8 @@ export async function prepareRoleEnvelope(options: {
527
530
  const relay = fileURLToPath(new URL("./mcp-relay.mjs", import.meta.url));
528
531
  await listen(server, options.socketPath);
529
532
  let disposed = false;
530
- // Tools execute in this process (relay is protocol-only). Mirror Pi's child-env
531
- // AK_ROLE_RUN_DIR / AK_ROLE_COURT_ATTEMPT injection onto the parent so ledger
532
- // identity sees the same signals. Engine axis is request-scoped via RoleHost
533
- // flag — never process.env (#818 P1). Run-dir may wait
534
- // until prepare succeeds. Dispose restores including unset.
535
- let priorAkRoleRunDir: string | undefined;
536
- let priorAkRoleCourtAttempt: string | undefined;
537
- let runDirInjected = false;
538
- const restoreAkRoleRunEnv = (): void => {
539
- if (!runDirInjected) return;
540
- runDirInjected = false;
541
- if (priorAkRoleRunDir === undefined) delete process.env.AK_ROLE_RUN_DIR;
542
- else process.env.AK_ROLE_RUN_DIR = priorAkRoleRunDir;
543
- if (priorAkRoleCourtAttempt === undefined) delete process.env.AK_ROLE_COURT_ATTEMPT;
544
- else process.env.AK_ROLE_COURT_ATTEMPT = priorAkRoleCourtAttempt;
545
- };
533
+ // Per-turn run/court identity lives on HostContext (request-scoped). Engine
534
+ // axis is already a RoleHost flag — never process.env (#818 P1 / #879).
546
535
  const dispose = async (): Promise<void> => {
547
536
  if (disposed) return;
548
537
  disposed = true;
@@ -552,11 +541,6 @@ export async function prepareRoleEnvelope(options: {
552
541
  } catch (error) {
553
542
  cleanupFailures.push(error);
554
543
  }
555
- try {
556
- restoreAkRoleRunEnv();
557
- } catch (error) {
558
- cleanupFailures.push(error);
559
- }
560
544
  try {
561
545
  const closeAll = (server as unknown as { closeAllConnections?: () => void }).closeAllConnections;
562
546
  if (typeof closeAll === "function") closeAll.call(server);
@@ -640,6 +624,8 @@ export async function prepareRoleEnvelope(options: {
640
624
  }
641
625
  // Method notes only here — role before_agent_start injects soul once.
642
626
  // Preloading session materials duplicated soul under the role tag (#632).
627
+ // #879: case-dossier owner reads runDirectory from this turn's HostContext.
628
+
643
629
  const methodPrompt = (await Promise.all(request.methods.map(({ path }) => readFile(path, "utf8")))).join("\n\n");
644
630
  const promptResults = await emit("before_agent_start", {
645
631
  prompt,
@@ -652,8 +638,8 @@ export async function prepareRoleEnvelope(options: {
652
638
  return [value.systemPrompt];
653
639
  });
654
640
  const systemPromptBody = systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : methodPrompt;
655
- // Typed reading materials from agent-start handlers (machine face; independent of prompt bytes).
656
- // Folded into the provider-visible systemPrompt by the adapter at the send boundary.
641
+ // Typed reading materials from agent-start handlers (incl. single shared
642
+ // case-dossier owner). Folded into provider-visible systemPrompt at send.
657
643
  const readingMaterials: unknown[] = [];
658
644
  for (const value of promptResults) {
659
645
  if (typeof value !== "object" || value === null) continue;
@@ -662,13 +648,6 @@ export async function prepareRoleEnvelope(options: {
662
648
  if (material !== undefined) readingMaterials.push(material);
663
649
  }
664
650
 
665
- priorAkRoleRunDir = process.env.AK_ROLE_RUN_DIR;
666
- priorAkRoleCourtAttempt = process.env.AK_ROLE_COURT_ATTEMPT;
667
- process.env.AK_ROLE_RUN_DIR = request.runDirectory;
668
- if (request.courtAttemptId === undefined) delete process.env.AK_ROLE_COURT_ATTEMPT;
669
- else process.env.AK_ROLE_COURT_ATTEMPT = request.courtAttemptId;
670
- runDirInjected = true;
671
-
672
651
  const terminating = tools.get(terminatingToolName);
673
652
  if (terminating === undefined) {
674
653
  throw new Error(`terminating tool not registered after activation: ${terminatingToolName}`);
@@ -26,6 +26,7 @@ export function createRoleRuntimeDependencies(packageRoot: string): RoleRuntimeD
26
26
  const doctorAuditor = createPiDoctorAuditor();
27
27
  const navigatorSessionFactory = createNativeNavigatorSessionFactory();
28
28
  return {
29
+ packageRoot,
29
30
  loadJudgeSoul: () => loadMainRoleSessionMaterials("judge"),
30
31
  loadFixerSoul: () => loadMainRoleSessionMaterials("fixer"),
31
32
  loadFixPacket: (path) => readFile(path, "utf8"),
@@ -2,6 +2,8 @@ import { readFileSync, writeSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import {
4
4
  ExplicitInternalActivationError,
5
+ isOfficerReviewSeat,
6
+ runDirectoryFromHostContext,
5
7
  type HostContext,
6
8
  type HostToolResult,
7
9
  type RoleEnvelopeHost,
@@ -36,7 +38,13 @@ import {
36
38
  writeToolExecutionObservationRecord,
37
39
  type ToolExecutionObservationWriter,
38
40
  } from "./tool-execution-observation.ts";
39
- import { ENGINE_DETOUR_TOOL_NAME } from "./engine-detour.ts";
41
+ import {
42
+ ENGINE_DETOUR_TOOL_NAME,
43
+ ENGINE_MODEL_FLAG_NAME,
44
+ resolveEngineModel,
45
+ resolveEngineName,
46
+ } from "./engine-detour.ts";
47
+ import { engineSessionMaterialFromOptions } from "./package-resources/engine-material.ts";
40
48
  import { registerEngineDetourTool } from "./engine-detour-tool.ts";
41
49
  import { createReceiptDeliveryPolicy, NO_RECEIPT_LIFECYCLE_ENTRY_TYPE, RECEIPT_DELIVERY_PROMPT } from "./receipt-delivery-policy.ts";
42
50
  import type { AnyCanonicalSkillBinding } from "./canonical-skill-binding.ts";
@@ -71,7 +79,7 @@ import {
71
79
  INSPECTOR_TOOL_SPEC,
72
80
  type InspectorRuntimeDependencies,
73
81
  } from "./inspector-role.ts";
74
- import { INSPECTOR_ACCEPTED_TEXT } from "./inspector-contracts.ts";
82
+ import { INSPECTOR_ACCEPTED_TEXT, INSPECTOR_SOURCE_RUN_FLAG } from "./inspector-contracts.ts";
75
83
  import {
76
84
  DIARIST_TOOL_SPEC,
77
85
  type DiaristRuntimeDependencies,
@@ -566,6 +574,8 @@ type NavigatorAttendanceDependency = Omit<NavigatorAttendance, "knownRoutePlaybo
566
574
  Partial<Pick<NavigatorAttendance, "knownRoutePlaybookReadFailure">>;
567
575
 
568
576
  export type RoleRuntimeDependencies = {
577
+ /** Package root for packaged engine-note resolution (#879). */
578
+ packageRoot?: string;
569
579
  loadJudgeSoul(): Promise<string>;
570
580
  loadFixerSoul?(): Promise<string>;
571
581
  loadFixPacket?(path: string): Promise<string>;
@@ -898,14 +908,14 @@ function readDiaristTicketAssertion(
898
908
  }
899
909
 
900
910
  /** Run coordinates + optional pre-bound ticket from durable pages (#779). */
901
- function readDiaristRunCoordinates(): {
911
+ function readDiaristRunCoordinates(ctx: HostContext): {
902
912
  readonly runDirectory: string;
903
913
  readonly projectRoot: string;
904
914
  readonly home: string;
905
915
  readonly boundTicketNumber?: number;
906
916
  } {
907
- const runDirectory = process.env.AK_ROLE_RUN_DIR;
908
- if (typeof runDirectory !== "string" || runDirectory.trim() === "") {
917
+ const runDirectory = runDirectoryFromHostContext(ctx);
918
+ if (runDirectory === undefined) {
909
919
  throw new Error("diarist accept requires AK_ROLE_RUN_DIR");
910
920
  }
911
921
  const admittedPath = join(runDirectory, "admitted-request.json");
@@ -948,13 +958,13 @@ export function createDiaristRoleRuntime(
948
958
  tool: DIARIST_TOOL_SPEC,
949
959
  acceptedText: DIARIST_ACCEPTED_TEXT,
950
960
  soulTag: "diarist",
951
- beforeAccept: async ({ parameters }) => {
961
+ beforeAccept: async ({ parameters, ctx }) => {
952
962
  const submitted =
953
963
  parameters !== null && typeof parameters === "object" && !Array.isArray(parameters)
954
964
  ? (parameters as Record<string, unknown>)
955
965
  : undefined;
956
966
  const assertion = readDiaristTicketAssertion(submitted);
957
- const coords = readDiaristRunCoordinates();
967
+ const coords = readDiaristRunCoordinates(ctx);
958
968
  // #836 7.3: pre-bound ticket is material for the LLM, not an override.
959
969
  const ticketNumber = assertion.kind === "ticket" ? assertion.ticketNumber : undefined;
960
970
  if (ticketNumber !== undefined) {
@@ -1020,6 +1030,8 @@ export function createCountersignRoleRuntime(
1020
1030
  ...(signal === undefined ? {} : { signal }),
1021
1031
  hostActions,
1022
1032
  toolCallId,
1033
+ // #879: this-turn typed payload — identity-bound at submit site.
1034
+ submission: parameters,
1023
1035
  });
1024
1036
  }
1025
1037
  : undefined;
@@ -1057,6 +1069,10 @@ export function createRoleRuntimeExtension(
1057
1069
  for (const flag of NOTARY_TRANSPORT_FLAGS) {
1058
1070
  roleHost.registerFlag(flag.name, flag.definition);
1059
1071
  }
1072
+ roleHost.registerFlag(
1073
+ INSPECTOR_SOURCE_RUN_FLAG.name,
1074
+ INSPECTOR_SOURCE_RUN_FLAG.definition,
1075
+ );
1060
1076
  for (const flag of GLEANER_LEFT_TRANSPORT_FLAGS) {
1061
1077
  roleHost.registerFlag(flag.name, flag.definition);
1062
1078
  }
@@ -1066,6 +1082,13 @@ export function createRoleRuntimeExtension(
1066
1082
  }
1067
1083
  // Station-child identity (#840): omit navigator attendance. One flag.
1068
1084
  roleHost.registerFlag(STATION_CHILD_FLAG.name, STATION_CHILD_FLAG.definition);
1085
+ // Register model only. Pi never sets ak-engine — resolveEngineName must
1086
+ // fall through to child-process env. An empty default would block that.
1087
+ roleHost.registerFlag(ENGINE_MODEL_FLAG_NAME, {
1088
+ description: "本次劳务引擎模型",
1089
+ type: "string",
1090
+ default: "",
1091
+ });
1069
1092
 
1070
1093
  let admitted = false;
1071
1094
  let selectedRole: string | undefined;
@@ -1163,6 +1186,7 @@ export function createRoleRuntimeExtension(
1163
1186
  settleNavigatorProjection,
1164
1187
  );
1165
1188
  roleHost.on("input", (event) => {
1189
+ const text = event.text;
1166
1190
  const role = roleHost.getFlag(ROLE_FLAG.name);
1167
1191
  if (role !== undefined && !admitted) return { action: "handled" as const };
1168
1192
  // Reviewer: recover original request; Pi argv may already carry native form.
@@ -1175,14 +1199,19 @@ export function createRoleRuntimeExtension(
1175
1199
  reviewerOriginalRequest =
1176
1200
  roleHost.capabilities?.skillOriginalRequest?.(
1177
1201
  activeReviewerParent.skillBinding.name,
1178
- event.text,
1179
- ) ?? event.text;
1180
- return { action: "continue" as const };
1202
+ text,
1203
+ ) ?? text;
1204
+ return text === event.text
1205
+ ? { action: "continue" as const }
1206
+ : { action: "transform" as const, text };
1181
1207
  }
1182
- return { action: "continue" as const };
1208
+ return text === event.text
1209
+ ? { action: "continue" as const }
1210
+ : { action: "transform" as const, text };
1183
1211
  });
1184
1212
  roleHost.on("before_agent_start", async (event, ctx) => {
1185
1213
  const role = roleHost.getFlag(ROLE_FLAG.name);
1214
+ const prompt = event.prompt;
1186
1215
  if (role === undefined) return;
1187
1216
  if (!admitted || selectedRole !== role) {
1188
1217
  failInfrastructure(new ActivationBarrierError(role), ctx);
@@ -1207,7 +1236,7 @@ export function createRoleRuntimeExtension(
1207
1236
  // output payload (#836 targets the latter): the human's own raw
1208
1237
  // prompt bytes, copied verbatim into Navigator's work context when
1209
1238
  // nothing else has supplied a subject yet.
1210
- const subject = event.prompt.trim();
1239
+ const subject = prompt.trim();
1211
1240
  if (subject !== "") {
1212
1241
  const root = subjectPath(ctx.sessionManager.getSessionDir(), ctx.cwd);
1213
1242
  const subjectProvenance = "user_prompt" satisfies NavigatorSubjectProvenance;
@@ -1231,7 +1260,7 @@ export function createRoleRuntimeExtension(
1231
1260
  if (!reviewerExpansionCaptured) {
1232
1261
  if (reviewerOriginalRequest !== undefined) {
1233
1262
  activeReviewerParent.skillBinding.captureExpansion(
1234
- roleHost.capabilities?.skillExpansion(event.prompt),
1263
+ roleHost.capabilities?.skillExpansion(prompt),
1235
1264
  reviewerOriginalRequest,
1236
1265
  );
1237
1266
  }
@@ -1255,6 +1284,54 @@ export function createRoleRuntimeExtension(
1255
1284
  };
1256
1285
  }
1257
1286
  });
1287
+ // #879: engine coordinates ride readingMaterial only when station-child
1288
+ // officer dialogue replaced the ordinary transport prompt (no double fold).
1289
+ roleHost.on("before_agent_start", () => {
1290
+ const role = selectedRole ?? roleHost.getFlag(ROLE_FLAG.name);
1291
+ if (typeof role !== "string" || !isOfficerReviewSeat(role)) return;
1292
+ if (roleHost.getFlag(STATION_CHILD_FLAG.name) !== true) return;
1293
+ const engine = resolveEngineName((name) => roleHost.getFlag(name));
1294
+ if (engine === undefined || dependencies.packageRoot === undefined) return;
1295
+ const engineModel = resolveEngineModel((name) => roleHost.getFlag(name));
1296
+ const material = engineSessionMaterialFromOptions({
1297
+ engine,
1298
+ ...(engineModel === undefined ? {} : { engineModel }),
1299
+ packageRoot: dependencies.packageRoot,
1300
+ });
1301
+ if (material === undefined) return;
1302
+ return {
1303
+ readingMaterial: {
1304
+ kind: "engine-session-material" as const,
1305
+ name: material.name,
1306
+ ...(material.model === undefined ? {} : { model: material.model }),
1307
+ ...(material.materialPath === undefined ? {} : { materialPath: material.materialPath }),
1308
+ },
1309
+ };
1310
+ });
1311
+ roleHost.on("before_agent_start", () => {
1312
+ const path = roleHost.getFlag(INSPECTOR_SOURCE_RUN_FLAG.name);
1313
+ if (typeof path !== "string" || path.trim() === "") return;
1314
+ return {
1315
+ readingMaterial: {
1316
+ kind: "inspector-parent-binding" as const,
1317
+ sourceRunPath: path,
1318
+ },
1319
+ };
1320
+ });
1321
+ // #879: single shared owner of station-child 0081 case-dossier → readingMaterial fold.
1322
+ // post-admission freezes under run/attachments/case-dossier/; this handler alone
1323
+ // projects it onto the existing agent-start materials face (Pi + envelope collect).
1324
+ // Role modules must not re-read the freeze (ADR 0018 lifecycle; no duplicate fold).
1325
+ roleHost.on("before_agent_start", async (_event, ctx) => {
1326
+ const runDir = runDirectoryFromHostContext(ctx);
1327
+ if (runDir === undefined) return;
1328
+ const { loadCaseDossierReadingMaterial } = await import(
1329
+ "./public-cli/case-dossier-delivery.ts"
1330
+ );
1331
+ const caseDossier = await loadCaseDossierReadingMaterial(runDir);
1332
+ if (caseDossier === undefined) return;
1333
+ return { readingMaterial: caseDossier };
1334
+ });
1258
1335
  roleHost.on("tool_result", async (event) => {
1259
1336
  const role = selectedRole;
1260
1337
  if (role === undefined) return;
@@ -1341,7 +1418,7 @@ export function createRoleRuntimeExtension(
1341
1418
  display: false,
1342
1419
  }, { triggerTurn: true, deliverAs: "followUp" });
1343
1420
  } else if (receiptDelivery.nextAction() === "no-receipt" && !noReceiptRecorded) {
1344
- const runPointer = process.env.AK_ROLE_RUN_DIR;
1421
+ const runPointer = runDirectoryFromHostContext(ctx);
1345
1422
  if (runPointer !== undefined) {
1346
1423
  noReceiptRecorded = true;
1347
1424
  const facts = receiptDelivery.facts({ runPointer, attemptPointer: `current:${runPointer}` });
@@ -1750,8 +1827,8 @@ export function createRoleRuntimeExtension(
1750
1827
  provider: string,
1751
1828
  ctx: HostContext,
1752
1829
  ): Promise<void> => {
1753
- const runDir = process.env.AK_ROLE_RUN_DIR;
1754
- if (typeof runDir !== "string" || runDir.trim() === "") return;
1830
+ const runDir = runDirectoryFromHostContext(ctx);
1831
+ if (runDir === undefined) return;
1755
1832
  try {
1756
1833
  await recordTypedProviderHttpStatus(runDir, { httpStatus: status, provider });
1757
1834
  } catch (error) {
@@ -1785,10 +1862,9 @@ export function createRoleRuntimeExtension(
1785
1862
  const underlying = priorFetch;
1786
1863
  globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
1787
1864
  const response = await underlying(input, init);
1788
- const runDir = process.env.AK_ROLE_RUN_DIR;
1865
+ const runDir = runDirectoryFromHostContext(ctx);
1789
1866
  if (
1790
- typeof runDir === "string"
1791
- && runDir.trim() !== ""
1867
+ runDir !== undefined
1792
1868
  && typeof response?.status === "number"
1793
1869
  && (response.status < 200 || response.status >= 300)
1794
1870
  ) {
@@ -2,7 +2,13 @@ import {
2
2
  resolveActivationLedgerHome,
3
3
  tryHomeFromAkRolesPath,
4
4
  } from "./activation-ledger-topology.ts";
5
- import type { HostContext, HostToolResult, RoleHost } from "./host-contracts.ts";
5
+ import {
6
+ courtAttemptIdFromHostContext,
7
+ runDirectoryFromHostContext,
8
+ type HostContext,
9
+ type HostToolResult,
10
+ type RoleHost,
11
+ } from "./host-contracts.ts";
6
12
  import { isAuditEscalationProjection } from "./audit-escalation.ts";
7
13
 
8
14
 
@@ -24,6 +30,8 @@ export type SubmissionLedgerEvent =
24
30
  readonly toolCallId: string;
25
31
  readonly toolName: string;
26
32
  readonly sequence: number;
33
+ /** Seat identity — machine fact beside the payload (ADR 0042 / #881). */
34
+ readonly role?: TerminalRoleName;
27
35
  /** LLM tool-call params at call time (#836 原话). */
28
36
  readonly params?: unknown;
29
37
  }
@@ -55,8 +63,8 @@ export type SubmissionLedgerEvent =
55
63
  * otherwise session header id. Never a shared "unbound" bucket.
56
64
  */
57
65
  function runIdentity(context: HostContext): string {
58
- const directory = process.env.AK_ROLE_RUN_DIR;
59
- if (typeof directory === "string" && directory.length > 0) {
66
+ const directory = runDirectoryFromHostContext(context);
67
+ if (directory !== undefined) {
60
68
  const fromDir = runIdFromRunDirectory(directory);
61
69
  if (fromDir !== undefined) return fromDir;
62
70
  }
@@ -72,16 +80,26 @@ function runIdentity(context: HostContext): string {
72
80
  export const COURT_ATTEMPT_ENV = "AK_ROLE_COURT_ATTEMPT" as const;
73
81
 
74
82
  function attemptIdentity(context: HostContext, runId: string): string {
75
- const courtAttempt = process.env[COURT_ATTEMPT_ENV];
76
- if (typeof courtAttempt === "string" && courtAttempt.length > 0) return courtAttempt;
83
+ const courtAttempt = courtAttemptIdFromHostContext(context);
84
+ if (courtAttempt !== undefined) return courtAttempt;
77
85
  return context.sessionManager.getHeader?.()?.id ?? context.sessionManager.getLeafId?.() ?? `${runId}:initial`;
78
86
  }
79
87
 
80
88
  /** One recorded role submission as stored — original payload plus host identity. */
81
89
  export type RecordedSubmissionRow = {
82
- readonly role: TerminalRoleName;
83
- readonly kind: "accepted" | "audit-escalation";
90
+ /**
91
+ * Seat identity when known. Historical correctable/infrastructure rows may omit it;
92
+ * payloads still project (#881). Terminal acceptance kind still requires a role match.
93
+ */
94
+ readonly role?: TerminalRoleName;
95
+ /**
96
+ * Recording class on the ledger. Terminal acceptance kind still only follows
97
+ * `accepted` / `audit-escalation`; every class carries the original payload (#881).
98
+ */
99
+ readonly kind: "accepted" | "audit-escalation" | "correctable-rejection" | "infrastructure" | "candidate";
84
100
  readonly accepted: unknown;
101
+ /** Present when the ledger row names the tool call — used to dedupe candidate+outcome. */
102
+ readonly toolCallId?: string;
85
103
  };
86
104
 
87
105
  /** Closed-submission callback: original payload, no status/facts projection (#836). */
@@ -191,44 +209,192 @@ function recordedRole(payload: { role?: unknown; projection?: { role?: unknown }
191
209
  return undefined;
192
210
  }
193
211
 
212
+ /** Prefer a more complete row for the same tool call without inventing a sole winner across calls. */
213
+ function rowRank(kind: RecordedSubmissionRow["kind"]): number {
214
+ switch (kind) {
215
+ case "accepted":
216
+ return 4;
217
+ case "audit-escalation":
218
+ return 3;
219
+ case "correctable-rejection":
220
+ case "infrastructure":
221
+ return 2;
222
+ case "candidate":
223
+ return 1;
224
+ }
225
+ }
226
+
194
227
  /**
195
- * All recorded role submissions in ledger order (#836 multi-submit).
228
+ * Same-call identity for reader pairing only (#881 / #836).
229
+ * attemptId is already on the record (subject/payload); bare toolCallId alone
230
+ * collapses distinct court attempts that reused a host call id.
231
+ */
232
+ function submissionCallKey(attemptId: string | undefined, toolCallId: string): string {
233
+ return `${attemptId ?? ""}\0${toolCallId}`;
234
+ }
235
+
236
+ function rowFromPayload(
237
+ kind: RecordedSubmissionRow["kind"],
238
+ payload: {
239
+ role?: unknown;
240
+ projection?: { role?: unknown };
241
+ accepted?: unknown;
242
+ params?: unknown;
243
+ toolCallId?: unknown;
244
+ },
245
+ accepted: unknown,
246
+ roleFallback?: TerminalRoleName,
247
+ ): RecordedSubmissionRow {
248
+ const role = recordedRole(payload) ?? roleFallback;
249
+ return {
250
+ ...(role === undefined ? {} : { role }),
251
+ kind,
252
+ accepted,
253
+ ...(typeof payload.toolCallId === "string" && payload.toolCallId.length > 0
254
+ ? { toolCallId: payload.toolCallId }
255
+ : {}),
256
+ };
257
+ }
258
+
259
+ /**
260
+ * All recorded role submissions in ledger order (#836 multi-submit / #881).
261
+ * Projects every original payload — sealed, audit-escalation, correctable-rejection,
262
+ * infrastructure, and bare candidate — without outcome-class filtering.
263
+ * Same call (candidate + outcome both carrying params) appears once — keyed by
264
+ * recorded attemptId + toolCallId so distinct court attempts stay distinct (#881).
196
265
  * `accepted` is the original payload; never rebuilt from a status/facts envelope.
197
266
  */
198
- export async function readRecordedSubmissionRows(
199
- cwd: string,
200
- runId: string,
201
- homeOrScope?: string | SubmissionLedgerReadScope,
202
- ): Promise<readonly RecordedSubmissionRow[]> {
203
- const scope = resolveReadScope(homeOrScope);
204
- const { owned } = await readOwnedSubmissionRecords(cwd, runId, scope.home);
205
- const scoped = recordsForAttempt(owned, scope.attemptId);
267
+ function mapOwnedToSubmissionRows(
268
+ owned: readonly { kind?: unknown; subject?: unknown; payload?: unknown }[],
269
+ ): readonly RecordedSubmissionRow[] {
270
+ const scoped = owned;
206
271
  const out: RecordedSubmissionRow[] = [];
272
+ const indexByCall = new Map<string, number>();
273
+ // Recover seat identity for historical non-sealed rows that omitted role (#881).
274
+ const roleByCall = new Map<string, TerminalRoleName>();
275
+ for (const record of scoped) {
276
+ const payload = record.payload as {
277
+ toolCallId?: unknown;
278
+ role?: unknown;
279
+ projection?: { role?: unknown };
280
+ } | undefined;
281
+ if (typeof payload?.toolCallId !== "string" || payload.toolCallId.length === 0) continue;
282
+ const role = recordedRole(payload);
283
+ if (role !== undefined) {
284
+ roleByCall.set(submissionCallKey(recordAttemptId(record), payload.toolCallId), role);
285
+ }
286
+ }
287
+
288
+ const take = (row: RecordedSubmissionRow, callKey: string | undefined): void => {
289
+ const toolCallId = row.toolCallId;
290
+ if (toolCallId !== undefined && callKey !== undefined) {
291
+ const existingIndex = indexByCall.get(callKey);
292
+ if (existingIndex !== undefined) {
293
+ const existing = out[existingIndex]!;
294
+ if (rowRank(row.kind) >= rowRank(existing.kind)) {
295
+ out[existingIndex] = {
296
+ ...row,
297
+ toolCallId,
298
+ // Keep a previously recovered role when the upgraded row still omits it.
299
+ ...(row.role === undefined && existing.role !== undefined ? { role: existing.role } : {}),
300
+ };
301
+ } else if (existing.role === undefined && row.role !== undefined) {
302
+ out[existingIndex] = { ...existing, role: row.role };
303
+ }
304
+ return;
305
+ }
306
+ indexByCall.set(callKey, out.length);
307
+ }
308
+ out.push(row);
309
+ };
310
+
207
311
  for (const record of scoped) {
312
+ const attemptId = recordAttemptId(record);
313
+ if (record.kind === "candidate") {
314
+ const payload = record.payload as Partial<Extract<SubmissionLedgerEvent, { type: "candidate" }>> & {
315
+ projection?: { role?: unknown };
316
+ } | undefined;
317
+ if (payload?.type !== "candidate" || payload.params === undefined) continue;
318
+ const callKey =
319
+ typeof payload.toolCallId === "string"
320
+ ? submissionCallKey(attemptId, payload.toolCallId)
321
+ : undefined;
322
+ const fallback = callKey !== undefined ? roleByCall.get(callKey) : undefined;
323
+ take(rowFromPayload("candidate", payload, payload.params, fallback), callKey);
324
+ continue;
325
+ }
208
326
  if (record.kind === "sealed") {
209
327
  const payload = record.payload as Partial<Extract<SubmissionLedgerEvent, { type: "sealed" }>> & {
210
328
  projection?: { role?: unknown };
211
329
  } | undefined;
212
330
  if (payload?.type !== "sealed" || payload.accepted === undefined) continue;
213
- const role = recordedRole(payload);
214
- if (role === undefined) continue;
215
- out.push({ role, kind: "accepted", accepted: payload.accepted });
331
+ const callKey =
332
+ typeof payload.toolCallId === "string"
333
+ ? submissionCallKey(attemptId, payload.toolCallId)
334
+ : undefined;
335
+ const fallback = callKey !== undefined ? roleByCall.get(callKey) : undefined;
336
+ take(rowFromPayload("accepted", payload, payload.accepted, fallback), callKey);
216
337
  continue;
217
338
  }
218
339
  if (record.kind !== "outcome") continue;
219
340
  const payload = record.payload as Partial<Extract<SubmissionLedgerEvent, { type: "outcome" }>> & {
220
341
  projection?: { role?: unknown };
221
342
  } | undefined;
222
- if (payload?.type !== "outcome" || payload.outcome !== "audit-escalation" || payload.accepted === undefined) {
223
- continue;
224
- }
225
- const role = recordedRole(payload);
226
- if (role === undefined) continue;
227
- out.push({ role, kind: "audit-escalation", accepted: payload.accepted });
343
+ if (payload?.type !== "outcome" || payload.accepted === undefined) continue;
344
+ const outcome = payload.outcome;
345
+ const kind: RecordedSubmissionRow["kind"] |
346
+ undefined =
347
+ outcome === "audit-escalation"
348
+ ? "audit-escalation"
349
+ : outcome === "correctable-rejection"
350
+ ? "correctable-rejection"
351
+ : outcome === "infrastructure"
352
+ ? "infrastructure"
353
+ : undefined;
354
+ if (kind === undefined) continue;
355
+ const callKey =
356
+ typeof payload.toolCallId === "string"
357
+ ? submissionCallKey(attemptId, payload.toolCallId)
358
+ : undefined;
359
+ const fallback = callKey !== undefined ? roleByCall.get(callKey) : undefined;
360
+ take(rowFromPayload(kind, payload, payload.accepted, fallback), callKey);
228
361
  }
229
362
  return out;
230
363
  }
231
364
 
365
+ /**
366
+ * All recorded role submissions in ledger order (#836 multi-submit).
367
+ * `accepted` is the original payload; never rebuilt from a status/facts envelope.
368
+ * Presentation stays run-scoped: attemptId on the read scope is a recording tag,
369
+ * not a visibility gate (#836).
370
+ */
371
+ export async function readRecordedSubmissionRows(
372
+ cwd: string,
373
+ runId: string,
374
+ homeOrScope?: string | SubmissionLedgerReadScope,
375
+ ): Promise<readonly RecordedSubmissionRow[]> {
376
+ const scope = resolveReadScope(homeOrScope);
377
+ const { owned } = await readOwnedSubmissionRecords(cwd, runId, scope.home);
378
+ const scoped = recordsForAttempt(owned, scope.attemptId);
379
+ return mapOwnedToSubmissionRows(scoped);
380
+ }
381
+
382
+ /**
383
+ * Settlement-only this-court rows (#879 return path).
384
+ * Filters by attemptId for court-scoped roleOutcome; does not replace the
385
+ * run-scoped presentation API above (#836 visibility gate stays pass-through).
386
+ */
387
+ export async function readAttemptScopedSubmissionRows(
388
+ cwd: string,
389
+ runId: string,
390
+ attemptId: string,
391
+ home?: string,
392
+ ): Promise<readonly RecordedSubmissionRow[]> {
393
+ if (attemptId.length === 0) return [];
394
+ const { owned } = await readOwnedSubmissionRecords(cwd, runId, home);
395
+ return mapOwnedToSubmissionRows(owned.filter((record) => recordAttemptId(record) === attemptId));
396
+ }
397
+
232
398
  /**
233
399
  * All raw role payloads in ledger order (#836 multi-submit).
234
400
  * Returns `accepted` bytes as stored — unknown, never re-wrapped.
@@ -241,7 +407,7 @@ export async function readRecordedSubmissions(
241
407
  return (await readRecordedSubmissionRows(cwd, runId, homeOrScope)).map((row) => row.accepted);
242
408
  }
243
409
 
244
- /** True when the run has at least one recorded accepted or audit-escalation payload. */
410
+ /** True when the run has at least one recorded original payload (any outcome class). */
245
411
  export async function hasRecordedSubmission(
246
412
  cwd: string,
247
413
  runId: string,
@@ -364,6 +530,7 @@ export function createSubmissionLedgerHost(
364
530
  toolCallId,
365
531
  toolName: tool.name,
366
532
  sequence: ++state.sequence,
533
+ role,
367
534
  params,
368
535
  });
369
536
  let result: HostToolResult<unknown>;
@@ -389,6 +556,7 @@ export function createSubmissionLedgerHost(
389
556
  outcome: "correctable-rejection",
390
557
  code: "typed-bounce",
391
558
  diagnostic: error instanceof Error ? error.message : String(error),
559
+ role,
392
560
  accepted: params,
393
561
  });
394
562
  throw error;
@@ -399,6 +567,7 @@ export function createSubmissionLedgerHost(
399
567
  toolCallId,
400
568
  outcome: "infrastructure",
401
569
  diagnostic: error instanceof Error ? error.message : String(error),
570
+ role,
402
571
  accepted: params,
403
572
  });
404
573
  throw error;