@akagilnc/pi-workflow-roles 0.1.4035 → 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 (54) hide show
  1. package/dist/acp-host/production-host.js +476 -250
  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 +503 -264
  7. package/dist/host-contracts.js +16 -0
  8. package/dist/inspector-contracts.js +8 -0
  9. package/dist/packaged-role-registry.js +1 -1
  10. package/dist/pi/role-turn-host.js +43 -13
  11. package/dist/public-cli/case-dossier-delivery.js +60 -1
  12. package/dist/public-cli/inspector-run.js +17 -3
  13. package/dist/public-cli/instruction-seat-run.js +9 -6
  14. package/dist/public-cli/main.js +239 -112
  15. package/dist/public-cli/notary-run.js +7 -4
  16. package/dist/public-cli/post-admission.js +66 -59
  17. package/dist/public-cli/run-lifecycle.js +3 -0
  18. package/dist/public-cli/settlement.js +24 -7
  19. package/dist/public-role-summons.js +8 -0
  20. package/dist/submission-ledger.js +30 -8
  21. package/dist/user-dialogue-stdin.js +33 -0
  22. package/extensions/role-runtime.ts +1 -0
  23. package/package.json +1 -1
  24. package/resources/836-deleted-machine-instruction-inventory.md +1 -1
  25. package/src/doctor-auditor.ts +1 -1
  26. package/src/dossier-resolution.ts +26 -8
  27. package/src/external-host-turn-loop.ts +9 -0
  28. package/src/gatekeeper-pass-envelope.ts +6 -0
  29. package/src/gatekeeper-role.ts +47 -28
  30. package/src/headless-host/description.ts +8 -11
  31. package/src/headless-host/role-turn-host.ts +20 -5
  32. package/src/host-contracts.ts +22 -3
  33. package/src/inspector-contracts.ts +7 -0
  34. package/src/judge-role.ts +4 -0
  35. package/src/navigator-work-context.ts +3 -4
  36. package/src/notary-role.ts +1 -0
  37. package/src/packaged-role-registry.ts +1 -1
  38. package/src/pi/adapter.ts +19 -4
  39. package/src/pi/role-turn-host.ts +48 -14
  40. package/src/public-cli/case-dossier-delivery.ts +86 -1
  41. package/src/public-cli/inspector-run.ts +26 -2
  42. package/src/public-cli/instruction-seat-run.ts +17 -9
  43. package/src/public-cli/invocation.ts +2 -0
  44. package/src/public-cli/notary-run.ts +13 -4
  45. package/src/public-cli/post-admission.ts +74 -65
  46. package/src/public-cli/run-lifecycle.ts +3 -0
  47. package/src/public-cli/settlement.ts +32 -6
  48. package/src/public-role-summons.ts +31 -7
  49. package/src/role-envelope.ts +9 -30
  50. package/src/role-runtime-dependencies.ts +1 -0
  51. package/src/role-runtime.ts +95 -19
  52. package/src/submission-ledger.ts +48 -13
  53. package/src/user-dialogue-stdin.ts +37 -0
  54. package/src/worker-role.ts +4 -0
@@ -15,6 +15,7 @@ import { readSitianRecords, resolveSitianRecordPath, sitianReport } from "../sit
15
15
 
16
16
  import {
17
17
  hasFreshAttemptSubmission,
18
+ readAttemptScopedSubmissionRows,
18
19
  readRecordedSubmissionRows,
19
20
  readRecordedSubmissions,
20
21
  } from "../submission-ledger.ts";
@@ -198,6 +199,22 @@ async function sealedLedgerOutcome(
198
199
  role: TerminalRoleName,
199
200
  scope?: SettlementCourtScope,
200
201
  ): Promise<Extract<TerminalRoleOutcome, { kind: "accepted" | "audit_escalation" }> | undefined> {
202
+ const home = sealedLedgerHome(admitted);
203
+ // #879: when court scope is present, roleOutcome is this-court original only —
204
+ // never last-wins over an undivided historical array, and never falls back to
205
+ // full-run history when this court sealed zero rows. #836 presentation of full
206
+ // history stays on attachRecordedSubmissions / terminal.submissions (run-scoped).
207
+ if (scope?.courtAttemptId !== undefined && scope.courtAttemptId.length > 0) {
208
+ const thisCourt = await readAttemptScopedSubmissionRows(
209
+ admitted.projectRoot,
210
+ admitted.runId,
211
+ scope.courtAttemptId,
212
+ home,
213
+ );
214
+ // Scope present + zero this-court rows → no this-court outcome (not run history).
215
+ return roleOutcomeFromRows(role, thisCourt);
216
+ }
217
+ // No court scope: keep run-scoped ledger face (#836).
201
218
  const rows = await readRecordedSubmissionRows(
202
219
  admitted.projectRoot,
203
220
  admitted.runId,
@@ -247,12 +264,14 @@ export function withSubmissions<T extends TerminalResult>(
247
264
  ): T {
248
265
  if (submissions.length === 0) return terminal;
249
266
  const roleOutcome = terminal.roleOutcome;
250
- // Full ledger sequence always wins the role-result block (#881): do not keep a
251
- // narrower pre-filtered payloads array when attach brings the complete set.
267
+ // #879 keeps an already scoped this-court result; #881 full run history is
268
+ // presented independently on terminal.submissions. Only fill a missing result payload.
252
269
  const withPayloads =
253
- roleOutcome.kind === "accepted" || roleOutcome.kind === "audit_escalation" || roleOutcome.kind === "failure"
254
- ? { ...roleOutcome, payloads: submissions }
255
- : roleOutcome;
270
+ roleOutcome.kind === "accepted" || roleOutcome.kind === "audit_escalation"
271
+ ? { ...roleOutcome, payloads: (roleOutcome.payloads ?? []).length > 0 ? roleOutcome.payloads : submissions }
272
+ : roleOutcome.kind === "failure"
273
+ ? { ...roleOutcome, payloads: roleOutcome.payloads ?? submissions }
274
+ : roleOutcome;
256
275
  return { ...terminal, roleOutcome: withPayloads, submissions };
257
276
  }
258
277
 
@@ -262,7 +281,14 @@ export async function attachRecordedSubmissions<T extends TerminalResult>(
262
281
  terminal: T,
263
282
  scope?: SettlementCourtScope,
264
283
  ): Promise<T> {
265
- return withSubmissions(terminal, await recordedSubmissionPayloads(admitted, scope));
284
+ // #836 / #879: submissions presentation is always run-scoped history. Do not
285
+ // pass courtAttemptId here — roleOutcome already carries this-court payloads
286
+ // from sealedLedgerOutcome when scoped; withSubmissions keeps them.
287
+ void scope;
288
+ return withSubmissions(
289
+ terminal,
290
+ await recordedSubmissionPayloads(admitted, undefined),
291
+ );
266
292
  }
267
293
 
268
294
  /**
@@ -62,6 +62,12 @@ export type PublicSummonRequest = {
62
62
  * summons.instruction. Never folded into argv / parent-run lookup keys.
63
63
  */
64
64
  readonly reviewReask?: string;
65
+ /**
66
+ * #879 same-parent (and first-mint) gate summons: verbatim parent-submission body.
67
+ * Rides summons.instruction / first-mint prompt when reviewReask is absent.
68
+ * Never folded into argv / parent-run lookup keys (binding pointer stays pure).
69
+ */
70
+ readonly gateReviewInstruction?: string;
65
71
  /**
66
72
  * Pi-adapter inject (tests). Used only as the `pi` row when composing the
67
73
  * adapter table — never as an override of the child seat's selected host.
@@ -302,6 +308,10 @@ export async function summonPublicRole(
302
308
  ...(options.signal === undefined ? {} : { signal: options.signal }),
303
309
  // #753: reask rides the existing notary same-ticket resume summons.instruction.
304
310
  ...(options.reviewReask === undefined ? {} : { reviewReask: options.reviewReask }),
311
+ // #879: verbatim submission body on officer dialogue content channel.
312
+ ...(options.gateReviewInstruction === undefined
313
+ ? {}
314
+ : { gateReviewInstruction: options.gateReviewInstruction }),
305
315
  ...(options.boundTicketNumber === undefined
306
316
  ? {}
307
317
  : { boundTicketNumber: options.boundTicketNumber }),
@@ -430,6 +440,12 @@ export async function summonGateOfficer(options: {
430
440
  * Never concatenated into argv (inspector parentRunPath is the pure 卷宗指针).
431
441
  */
432
442
  readonly reask?: string;
443
+ /**
444
+ * In-flight parent 交卷 body (tool-call arguments). Relayed verbatim as officer
445
+ * dialogue content when reask is absent (#879). Binding pointer stays on
446
+ * --source-run / 卷宗指针 argv — never a content substitute.
447
+ */
448
+ readonly submission?: unknown;
433
449
  /** Pi-adapter inject — forwarded to summonPublicRole (not a parent-host override). */
434
450
  readonly roleTurnHost?: RoleTurnHost;
435
451
  readonly hostAdapters?: readonly NamedRoleTurnHostAdapter[];
@@ -444,8 +460,13 @@ export async function summonGateOfficer(options: {
444
460
  }
445
461
  // Officer host is seat-owned only (#821). Parent invocation.json.host stays a
446
462
  // hostTransition consumer in post-admission — not an officer override channel.
447
- // #836: officers get the whole parent run directory pointer; code does not
448
- // inject a picked toolCall leaf as the resume instruction.
463
+ // #879: binding pointer = parent run directory; dialogue content = submission body.
464
+ // Conclusion re-ask keeps sole ownership of reviewReask when present.
465
+ let gateReviewInstruction: string | undefined;
466
+ if (options.reask === undefined && options.submission !== undefined) {
467
+ const { readableGateItem } = await import("./readable-gate-item.ts");
468
+ gateReviewInstruction = readableGateItem(options.submission);
469
+ }
449
470
  const common = {
450
471
  cwd: options.cwd,
451
472
  ...(home === undefined ? {} : { home }),
@@ -453,13 +474,16 @@ export async function summonGateOfficer(options: {
453
474
  ...(options.io === undefined ? {} : { io: options.io }),
454
475
  ...(options.signal === undefined ? {} : { signal: options.signal }),
455
476
  ...(options.reask === undefined ? {} : { reviewReask: options.reask }),
477
+ ...(gateReviewInstruction === undefined
478
+ ? {}
479
+ : { gateReviewInstruction }),
456
480
  ...(options.roleTurnHost === undefined ? {} : { roleTurnHost: options.roleTurnHost }),
457
481
  ...(options.hostAdapters === undefined ? {} : { hostAdapters: options.hostAdapters }),
458
482
  ...(options.createRunId === undefined ? {} : { createRunId: options.createRunId }),
459
483
  } as const;
460
484
  if (options.officer === "notary") {
461
- // #753: reask rides summonPublicRole → runPublicNotary summons.instruction
462
- // (same resume seam as ordinary same-parent re-summons — no parallel stack).
485
+ // #753/#879: reask or verbatim body rides summons.instruction / first-mint prompt.
486
+ // Binding stays --source-run (never folded into content).
463
487
  return summonPublicRole({
464
488
  role: "notary",
465
489
  argv: ["--source-run", options.sourceRunDirectory, "--project", options.cwd],
@@ -468,7 +492,7 @@ export async function summonGateOfficer(options: {
468
492
  }
469
493
  if (options.officer === "auditor") {
470
494
  // #756: judge compliance path — same queue law as notary/inspector.
471
- // #836: auditor kickoff is path pointer (同察院), not「本 run 卷宗已就绪」.
495
+ // Binding pointer on argv; dialogue content rides gateReviewInstruction.
472
496
  return summonPublicRole({
473
497
  role: "auditor",
474
498
  argv: [
@@ -481,8 +505,8 @@ export async function summonGateOfficer(options: {
481
505
  ...common,
482
506
  });
483
507
  }
484
- // Inspector: argv stays pure 卷宗指针 (#747 parentRunPath); reask rides env only.
485
- // #836 resume 请重读 is added on the resume projection face, not here.
508
+ // Inspector: argv stays pure 卷宗指针 (#747 parentRunPath lookup key).
509
+ // Content (body/reask) rides env → summons.instruction / first-mint prompt.
486
510
  return summonPublicRole({
487
511
  role: "inspector",
488
512
  argv: [`卷宗指针:${options.sourceRunDirectory}`],
@@ -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
 
@@ -57,8 +63,8 @@ export type SubmissionLedgerEvent =
57
63
  * otherwise session header id. Never a shared "unbound" bucket.
58
64
  */
59
65
  function runIdentity(context: HostContext): string {
60
- const directory = process.env.AK_ROLE_RUN_DIR;
61
- if (typeof directory === "string" && directory.length > 0) {
66
+ const directory = runDirectoryFromHostContext(context);
67
+ if (directory !== undefined) {
62
68
  const fromDir = runIdFromRunDirectory(directory);
63
69
  if (fromDir !== undefined) return fromDir;
64
70
  }
@@ -74,8 +80,8 @@ function runIdentity(context: HostContext): string {
74
80
  export const COURT_ATTEMPT_ENV = "AK_ROLE_COURT_ATTEMPT" as const;
75
81
 
76
82
  function attemptIdentity(context: HostContext, runId: string): string {
77
- const courtAttempt = process.env[COURT_ATTEMPT_ENV];
78
- if (typeof courtAttempt === "string" && courtAttempt.length > 0) return courtAttempt;
83
+ const courtAttempt = courtAttemptIdFromHostContext(context);
84
+ if (courtAttempt !== undefined) return courtAttempt;
79
85
  return context.sessionManager.getHeader?.()?.id ?? context.sessionManager.getLeafId?.() ?? `${runId}:initial`;
80
86
  }
81
87
 
@@ -258,14 +264,10 @@ function rowFromPayload(
258
264
  * recorded attemptId + toolCallId so distinct court attempts stay distinct (#881).
259
265
  * `accepted` is the original payload; never rebuilt from a status/facts envelope.
260
266
  */
261
- export async function readRecordedSubmissionRows(
262
- cwd: string,
263
- runId: string,
264
- homeOrScope?: string | SubmissionLedgerReadScope,
265
- ): Promise<readonly RecordedSubmissionRow[]> {
266
- const scope = resolveReadScope(homeOrScope);
267
- const { owned } = await readOwnedSubmissionRecords(cwd, runId, scope.home);
268
- 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;
269
271
  const out: RecordedSubmissionRow[] = [];
270
272
  const indexByCall = new Map<string, number>();
271
273
  // Recover seat identity for historical non-sealed rows that omitted role (#881).
@@ -360,6 +362,39 @@ export async function readRecordedSubmissionRows(
360
362
  return out;
361
363
  }
362
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
+
363
398
  /**
364
399
  * All raw role payloads in ledger order (#836 multi-submit).
365
400
  * Returns `accepted` bytes as stored — unknown, never re-wrapped.
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Typed user-dialogue stdin transport (#879 / #471).
3
+ * Pi's piped-stdin reader trims the wrapper; the body field keeps original bytes.
4
+ * No size cap and no path/pointer substitute — the whole dialogue rides the envelope.
5
+ */
6
+ export const USER_DIALOGUE_STDIN_KIND = "ak-user-dialogue" as const;
7
+
8
+ export type UserDialogueStdinEnvelope = {
9
+ readonly kind: typeof USER_DIALOGUE_STDIN_KIND;
10
+ readonly body: string;
11
+ };
12
+
13
+ export function encodeUserDialogueStdin(body: string): string {
14
+ return JSON.stringify({ kind: USER_DIALOGUE_STDIN_KIND, body } satisfies UserDialogueStdinEnvelope);
15
+ }
16
+
17
+ function isUserDialogueEnvelope(value: unknown): value is UserDialogueStdinEnvelope {
18
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
19
+ const record = value as { kind?: unknown; body?: unknown };
20
+ return record.kind === USER_DIALOGUE_STDIN_KIND && typeof record.body === "string";
21
+ }
22
+
23
+ /**
24
+ * Host read: recover original dialogue after a trim-capable pipe.
25
+ * Non-envelope text (interactive Pi, officer JSON, headless raw body) is unchanged.
26
+ */
27
+ export function readUserDialogueStdin(raw: string): string {
28
+ const trimmed = raw.trim();
29
+ if (!trimmed.startsWith("{")) return raw;
30
+ try {
31
+ const parsed: unknown = JSON.parse(trimmed);
32
+ if (isUserDialogueEnvelope(parsed)) return parsed.body;
33
+ } catch {
34
+ // Not our envelope — leave caller bytes intact.
35
+ }
36
+ return raw;
37
+ }
@@ -290,6 +290,8 @@ export function createFixerRoleRuntime(
290
290
  ...(_signal === undefined ? {} : { signal: _signal }),
291
291
  hostActions,
292
292
  toolCallId,
293
+ // #879: this-turn typed payload — identity-bound at submit site.
294
+ submission: output,
293
295
  });
294
296
  }
295
297
  const acceptedDetails = output;
@@ -428,6 +430,8 @@ export function createCoderRoleRuntime(
428
430
  ...(_signal === undefined ? {} : { signal: _signal }),
429
431
  hostActions,
430
432
  toolCallId,
433
+ // #879: this-turn typed payload — identity-bound at submit site.
434
+ submission: output,
431
435
  });
432
436
  }
433
437
  const acceptedDetails = output;