@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
@@ -21,9 +21,10 @@ import type {
21
21
  RoleTurnRequest,
22
22
  RoleTurnResult,
23
23
  } from "../host-contracts.ts";
24
- import { ExplicitInternalActivationError } from "../host-contracts.ts";
25
- import { applyEngineChildEnv } from "../engine-detour.ts";
24
+ import { ExplicitInternalActivationError, isOfficerReviewSeat } from "../host-contracts.ts";
25
+ import { applyEngineChildEnv, ENGINE_MODEL_FLAG_NAME, normalizeEngineName } from "../engine-detour.ts";
26
26
  import { projectActivationFlags } from "../role-activation-flags.ts";
27
+ import { encodeUserDialogueStdin } from "../user-dialogue-stdin.ts";
27
28
 
28
29
 
29
30
  /** Package-relative Internal role entrypoint (ADR 0052; same path as public-cli registry). */
@@ -119,7 +120,7 @@ export function applyPiNativeSkillInvocation(
119
120
  * Pi last-hop argv after `--no-extensions -e entry` (#819).
120
121
  * Activation flag membership comes from middle-layer projectActivationFlags;
121
122
  * this function only renders session coords, controlled constants, and pairs.
122
- * Single forced method → Pi-native `/skill:` on the argv prompt (#822).
123
+ * User dialogue is not an argv element (#879): it rides spawn stdin.
123
124
  */
124
125
  export function buildPiTurnExtraArgs(
125
126
  request: RoleTurnRequest,
@@ -127,14 +128,6 @@ export function buildPiTurnExtraArgs(
127
128
  extraPiArgs: readonly string[] = [],
128
129
  ): string[] {
129
130
  const { sessionFile, sessionDirectory } = authority.decode(request.principal);
130
- const rawPrompt =
131
- request.continuation.kind === "initial" || request.continuation.kind === "resume"
132
- ? request.continuation.prompt
133
- : (() => {
134
- const _exhaustive: never = request.continuation;
135
- return _exhaustive;
136
- })();
137
- const prompt = applyPiNativeSkillInvocation(request.methods, rawPrompt);
138
131
  return [
139
132
  "--no-skills",
140
133
  ...buildMethodArgs(request.methods),
@@ -148,13 +141,31 @@ export function buildPiTurnExtraArgs(
148
141
  ...extraPiArgs,
149
142
  // Envelope assembly = projectActivationFlags; pi only renders argv pairs.
150
143
  ...activationFlagsToPiArgv(projectActivationFlags(request)),
144
+ ...piEngineModelArgs(request),
151
145
  "--mode",
152
146
  "json",
153
147
  ...buildSeatModelCliArgs(request.model),
154
- prompt,
155
148
  ];
156
149
  }
157
150
 
151
+ /** Engine name stays on child env; model has no env fallback (#883 / #879). */
152
+ function piEngineModelArgs(request: RoleTurnRequest): string[] {
153
+ const model = normalizeEngineName(request.engineModel);
154
+ if (model === undefined) return [];
155
+ return [`--${ENGINE_MODEL_FLAG_NAME}`, model];
156
+ }
157
+
158
+ function piUserDialogueBody(request: RoleTurnRequest): string {
159
+ const rawPrompt =
160
+ request.continuation.kind === "initial" || request.continuation.kind === "resume"
161
+ ? request.continuation.prompt
162
+ : (() => {
163
+ const _exhaustive: never = request.continuation;
164
+ return _exhaustive;
165
+ })();
166
+ return applyPiNativeSkillInvocation(request.methods, rawPrompt);
167
+ }
168
+
158
169
  export type PiSpawnRunner = (
159
170
  args: readonly string[],
160
171
  options: {
@@ -163,6 +174,8 @@ export type PiSpawnRunner = (
163
174
  timeoutMs?: number;
164
175
  /** Parent cancellation; the child gets the same graceful SIGTERM as a budget. */
165
176
  signal?: AbortSignal;
177
+ /** User dialogue body; omitted from argv so execve cannot E2BIG (#879). */
178
+ stdin?: string;
166
179
  },
167
180
  ) => Promise<{
168
181
  code: number | null;
@@ -271,11 +284,22 @@ export function createDefaultPiSpawnRunner(options: {
271
284
  const child = spawn(piIdentity.executable, [...args], {
272
285
  cwd: spawnOptions.cwd,
273
286
  env: spawnOptions.env,
274
- stdio: ["ignore", "ignore", "pipe"],
287
+ stdio: ["pipe", "ignore", "pipe"],
275
288
  });
289
+ if (child.stdin === null) {
290
+ throw new Error("Pi child stdin pipe was not created");
291
+ }
276
292
  if (child.stderr === null) {
277
293
  throw new Error("Pi child stderr pipe was not created");
278
294
  }
295
+ let stdinDeliveryError: Error | undefined;
296
+ child.stdin.on("error", (error) => {
297
+ stdinDeliveryError ??= error;
298
+ });
299
+ if (spawnOptions.stdin !== undefined) {
300
+ child.stdin.write(spawnOptions.stdin);
301
+ }
302
+ child.stdin.end();
279
303
  let stderr = "";
280
304
  let timedOut = false;
281
305
  // No default wall clock. Only an explicit caller budget arms a timer (ADR 0010).
@@ -344,6 +368,10 @@ export function createDefaultPiSpawnRunner(options: {
344
368
  reject(executionError);
345
369
  return;
346
370
  }
371
+ if (stdinDeliveryError !== undefined) {
372
+ reject(stdinDeliveryError);
373
+ return;
374
+ }
347
375
  resolveResult({
348
376
  code,
349
377
  stderr,
@@ -375,9 +403,13 @@ export function createPiRoleTurnHost(config: PiRoleTurnHostConfig): RoleTurnHost
375
403
  async executeTurn(request: RoleTurnRequest): Promise<RoleTurnResult> {
376
404
  // #617 DK-7: Pi argv gets projected native paths once; never record bytes.
377
405
  // Pi already owns its own session file, so only sitian prior volume rides in.
406
+ // #879: station-child officer dialogue keeps peer words — do not splice
407
+ // host-transition priorNativePaths into the review prompt body.
378
408
  let turnRequest = request;
409
+ const officerStationChild =
410
+ request.stationChild === true && isOfficerReviewSeat(request.activation.role);
379
411
  const paths =
380
- request.hostTransition?.priorNativeKind === "sitian"
412
+ !officerStationChild && request.hostTransition?.priorNativeKind === "sitian"
381
413
  ? request.hostTransition.priorNativePaths
382
414
  : undefined;
383
415
  if (
@@ -400,6 +432,7 @@ export function createPiRoleTurnHost(config: PiRoleTurnHostConfig): RoleTurnHost
400
432
  config.extraPiArgs ?? [],
401
433
  );
402
434
  const args = buildExplicitInternalActivationArgs(roleEntry, extraArgs);
435
+ const stdin = encodeUserDialogueStdin(piUserDialogueBody(turnRequest));
403
436
  // Shared envelope isolates this call's court identity: omitting courtAttemptId
404
437
  // must not inherit a parent process.env.AK_ROLE_COURT_ATTEMPT (#637).
405
438
  const env: NodeJS.ProcessEnv = {
@@ -444,6 +477,7 @@ export function createPiRoleTurnHost(config: PiRoleTurnHostConfig): RoleTurnHost
444
477
  return await spawnRunner(args, {
445
478
  cwd: request.cwd,
446
479
  env,
480
+ stdin,
447
481
  ...(timeoutMs === undefined ? {} : { timeoutMs }),
448
482
  ...(request.signal === undefined ? {} : { signal: request.signal }),
449
483
  });
@@ -5,13 +5,31 @@
5
5
  * 只读已有案卷:不刷新、不生成、不校验内容、不新增拒收或停工条件。
6
6
  * 机器文本仅中立标识材料(ADR 0073),用途说明归角色材料所有。
7
7
  *
8
- * 递送挂载点唯一:`post-admission` 在 beforeDispatch 之后为每个公共入口追加本段。
8
+ * 递送挂载点唯一:`post-admission` 在 beforeDispatch 之后为每个公共入口挂载。
9
+ * 普通入口把本段追加进 continuation;station-child 审核轮次走 attachments
10
+ * 冻结 + role-runtime `loadCaseDossierReadingMaterial` → readingMaterial →
11
+ * systemPrompt.materials fold(#879:对话 instruction 保持父腿 payload 原文;
12
+ * 起居录作独立附件面,不新造 RoleTurnRequest.materials)。
9
13
  */
14
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
15
+ import { tmpdir } from "node:os";
16
+ import { join } from "node:path";
17
+
10
18
  import { resolveTicketProvenanceVolume } from "../ticket-provenance.ts";
19
+ import {
20
+ freezeAttachmentsIntoRun,
21
+ type FrozenAttachment,
22
+ } from "./invocation.ts";
11
23
 
12
24
  /** Section heading of the system-delivered dossier pointer (presentation only). */
13
25
  const CASE_DOSSIER_SECTION_HEADING = "## 本票起居录(系统随案提供)" as const;
14
26
 
27
+ /** Stable freeze key under run/attachments/ for station-child 0081 delivery. */
28
+ const CASE_DOSSIER_ATTACH_KEY = "case-dossier" as const;
29
+
30
+ /** Leaf name of the frozen pointer section (content = purpose + paths). */
31
+ const CASE_DOSSIER_ATTACH_FILE = "case-dossier-pointer.md" as const;
32
+
15
33
  /** Pointer only — presence/absence is for the role to observe at the path. */
16
34
  function describeDossierFile(path: string): string {
17
35
  return path;
@@ -42,3 +60,70 @@ export async function projectCaseDossierPointerSection(input: {
42
60
  `记录卷宗:${describeDossierFile(volume.recordFile)}`,
43
61
  ].join("\n");
44
62
  }
63
+
64
+ /**
65
+ * ADR 0081 delivery for station-child officer turns (#879): freeze the same
66
+ * pointer section through the existing attachments seam. Role-runtime loads
67
+ * that freeze via loadCaseDossierReadingMaterial onto readingMaterial; the
68
+ * envelope then folds it into systemPrompt.materials. Peer dialogue instruction
69
+ * stays the parent payload; never RoleTurnRequest.materials.
70
+ * Returns frozen attachments, or undefined when unbound (no dossier).
71
+ */
72
+ export async function deliverCaseDossierAsAttachment(input: {
73
+ readonly ticketNumber: number | undefined;
74
+ readonly projectRoot: string;
75
+ readonly home: string;
76
+ readonly runDirectory: string;
77
+ }): Promise<readonly FrozenAttachment[] | undefined> {
78
+ const section = await projectCaseDossierPointerSection({
79
+ ticketNumber: input.ticketNumber,
80
+ projectRoot: input.projectRoot,
81
+ home: input.home,
82
+ });
83
+ if (section === undefined) return undefined;
84
+ // Stage in OS temp only — never leave a run-local .case-dossier-stage copy.
85
+ const stagingDir = await mkdtemp(join(tmpdir(), "ak-case-dossier-"));
86
+ try {
87
+ const stagingPath = join(stagingDir, CASE_DOSSIER_ATTACH_FILE);
88
+ await writeFile(stagingPath, `${section}\n`, "utf8");
89
+ return await freezeAttachmentsIntoRun(
90
+ [stagingPath],
91
+ input.runDirectory,
92
+ CASE_DOSSIER_ATTACH_KEY,
93
+ );
94
+ } finally {
95
+ await rm(stagingDir, { recursive: true, force: true });
96
+ }
97
+ }
98
+
99
+ /** Typed reading-material face for a frozen station-child 0081 attachment. */
100
+ export type CaseDossierReadingMaterial = {
101
+ readonly kind: "case-dossier-pointer";
102
+ readonly frozenPath: string;
103
+ readonly section: string;
104
+ };
105
+
106
+ /**
107
+ * Load a previously frozen case-dossier attachment as reading material
108
+ * (existing agent-start / systemPrompt.materials fold — not dialogue prompt,
109
+ * not RoleTurnRequest.materials). Undefined when the run has no such freeze.
110
+ */
111
+ export async function loadCaseDossierReadingMaterial(
112
+ runDirectory: string,
113
+ ): Promise<CaseDossierReadingMaterial | undefined> {
114
+ const frozenPath = join(
115
+ runDirectory,
116
+ "attachments",
117
+ CASE_DOSSIER_ATTACH_KEY,
118
+ `00-${CASE_DOSSIER_ATTACH_FILE}`,
119
+ );
120
+ let section: string;
121
+ try {
122
+ section = await readFile(frozenPath, "utf8");
123
+ } catch (error) {
124
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
125
+ throw error;
126
+ }
127
+ if (section.trim() === "") return undefined;
128
+ return { kind: "case-dossier-pointer", frozenPath, section };
129
+ }
@@ -12,6 +12,7 @@ import { tryResumeSameTicketSeatRun } from "./seat-ticket-binding.ts";
12
12
  import {
13
13
  admitInspectorInvocation,
14
14
  buildInspectorTransportPrompt,
15
+ persistAdmittedSourceRunPath,
15
16
  type AdmittedInspectorInvocation,
16
17
  type ParseInspectorArgvResult,
17
18
  } from "./invocation.ts";
@@ -50,18 +51,35 @@ export type InspectorRunEnv = PostAdmissionEnv & {
50
51
  * Never a public CLI argv — must not pollute the 卷宗指针 parentRunPath key (#747).
51
52
  */
52
53
  reviewReask?: string;
54
+ /**
55
+ * #879 gate summons: verbatim parent-submission body for officer dialogue content.
56
+ * Rides summons.instruction / first-mint prompt when reviewReask is absent.
57
+ * Argv 卷宗指针 stays the binding/lookup key only.
58
+ */
59
+ gateReviewInstruction?: string;
53
60
  };
54
61
 
62
+ function inspectorParentRunPath(
63
+ admitted: AdmittedInspectorInvocation,
64
+ ): string | undefined {
65
+ if (typeof admitted.sourceRunPath === "string" && admitted.sourceRunPath.trim() !== "") {
66
+ return admitted.sourceRunPath;
67
+ }
68
+ return parentRunPathFromGatePointerInstruction(admitted.instruction);
69
+ }
70
+
55
71
  /** Project admitted invocation onto the host-neutral turn request. */
56
72
  export function buildInspectorTurnRequest(
57
73
  admitted: AdmittedInspectorInvocation,
58
74
  options: RoleTurnRequestProjectionOptions,
59
75
  ): RoleTurnRequest {
76
+ const sourceRun = inspectorParentRunPath(admitted);
60
77
  return projectRoleTurnRequest(
61
78
  admitted,
62
79
  {
63
80
  activation: {
64
81
  role: "inspector" as const,
82
+ ...(sourceRun === undefined ? {} : { sourceRun }),
65
83
  },
66
84
  },
67
85
  options,
@@ -97,7 +115,7 @@ export async function runPublicInspector(
97
115
  // #753/#786: gate re-ask / verbatim submission body ride summons.instruction on resume.
98
116
  const parentRunPath = parentRunPathFromGatePointerInstruction(parsed.instruction);
99
117
  if (parentRunPath !== undefined) {
100
- const resumeInstruction = env.reviewReask;
118
+ const resumeInstruction = env.reviewReask ?? env.gateReviewInstruction;
101
119
  const summons: SameTicketSummonsMaterials = {
102
120
  sourceRunPath: parentRunPath,
103
121
  ...(resumeInstruction === undefined
@@ -147,6 +165,10 @@ export async function runPublicInspector(
147
165
  }
148
166
 
149
167
  await markRunAdmitted(admitted, env.principalAuthority);
168
+ if (parentRunPath !== undefined) {
169
+ await persistAdmittedSourceRunPath(admitted, parentRunPath);
170
+ admitted = { ...admitted, sourceRunPath: parentRunPath };
171
+ }
150
172
 
151
173
  const engineMaterial = engineSessionMaterialFromOptions({
152
174
  ...pickEngineAxis(env),
@@ -164,7 +186,9 @@ export async function runPublicInspector(
164
186
  : { correlationId: env.correlationId }),
165
187
  continuation: {
166
188
  kind: "initial",
167
- prompt: buildInspectorTransportPrompt(admitted, engineMaterial),
189
+ // #879: gate first mint uses parent payload as content; binding stays typed activation.
190
+ prompt: (env.reviewReask ?? env.gateReviewInstruction)
191
+ ?? buildInspectorTransportPrompt(admitted, engineMaterial),
168
192
  },
169
193
  });
170
194
 
@@ -69,6 +69,11 @@ export type InstructionSeatRunEnv = PostAdmissionEnv & {
69
69
  * Never a public CLI argv — must not pollute the --source-run parent lookup key (#747).
70
70
  */
71
71
  reviewReask?: string;
72
+ /**
73
+ * #879 gate summons: verbatim parent-submission body for officer dialogue content.
74
+ * Rides summons.instruction / first-mint prompt when reviewReask is absent.
75
+ */
76
+ gateReviewInstruction?: string;
72
77
  };
73
78
 
74
79
  /** Project an admitted instruction-seat invocation onto the host-neutral turn request. */
@@ -291,8 +296,9 @@ export async function runPublicInstructionSeat(
291
296
  }
292
297
  }
293
298
 
294
- // #756/#786: auditor reask / verbatim submission body ride summons.instruction on resume.
295
- const resumeInstruction = env.reviewReask;
299
+ // #756/#879: auditor reask / verbatim submission body ride summons.instruction.
300
+ // Binding pointer stays --source-run / argv 卷宗指针; content is body or reask.
301
+ const resumeInstruction = env.reviewReask ?? env.gateReviewInstruction;
296
302
  const summons: SameTicketSummonsMaterials = {
297
303
  ...(resumeInstruction === undefined
298
304
  ? {
@@ -372,13 +378,15 @@ export async function runPublicInstructionSeat(
372
378
  : { correlationId: env.correlationId }),
373
379
  continuation: {
374
380
  kind: "initial",
375
- prompt: buildInstructionTransportPrompt(
376
- admitted,
377
- engineSessionMaterialFromOptions({
378
- ...pickEngineAxis(env),
379
- packageRoot: env.packageRoot,
380
- }),
381
- ),
381
+ // #879: gate first mint uses parent payload as content when present.
382
+ prompt: (env.reviewReask ?? env.gateReviewInstruction)
383
+ ?? buildInstructionTransportPrompt(
384
+ admitted,
385
+ engineSessionMaterialFromOptions({
386
+ ...pickEngineAxis(env),
387
+ packageRoot: env.packageRoot,
388
+ }),
389
+ ),
382
390
  },
383
391
  });
384
392
 
@@ -131,6 +131,8 @@ export type AdmittedGleanerLeftInvocation = AdmittedRoleInvocationBase & {
131
131
 
132
132
  export type AdmittedInspectorInvocation = AdmittedRoleInvocationBase & {
133
133
  readonly role: "inspector";
134
+ /** Parent run directory (#747 / #879); independent of dialogue instruction. */
135
+ readonly sourceRunPath?: string;
134
136
  };
135
137
 
136
138
  export type AdmittedGatekeeperInvocation = AdmittedRoleInvocationBase & {
@@ -52,6 +52,12 @@ export type NotaryRunEnv = PostAdmissionEnv & {
52
52
  * Never a public CLI argv — external callers still have zero prompt.
53
53
  */
54
54
  reviewReask?: string;
55
+ /**
56
+ * #879 gate summons: verbatim parent-submission body for officer dialogue content.
57
+ * Rides summons.instruction / first-mint prompt when reviewReask is absent.
58
+ * Binding pointer stays --source-run (independent machine material).
59
+ */
60
+ gateReviewInstruction?: string;
55
61
  };
56
62
 
57
63
  /** Project admitted invocation onto the host-neutral turn request. */
@@ -115,10 +121,10 @@ export async function runPublicNotary(
115
121
  throw error;
116
122
  }
117
123
  // #747: officer resume key is this parent source-run path (not ticket number).
118
- // #753/#786: gate re-ask and verbatim submission body share summons.instruction
119
- // (reask wins when both present; no parallel stack).
124
+ // #753/#879: gate re-ask and verbatim submission body share summons.instruction
125
+ // (reask wins when both present; no parallel stack). Binding stays sourceRunPath.
120
126
  {
121
- const resumeInstruction = env.reviewReask;
127
+ const resumeInstruction = env.reviewReask ?? env.gateReviewInstruction;
122
128
  const summons: SameTicketSummonsMaterials = {
123
129
  sourceRunPath: source.runDirectory,
124
130
  sourceRun: source,
@@ -181,7 +187,10 @@ export async function runPublicNotary(
181
187
  : { correlationId: env.correlationId }),
182
188
  continuation: {
183
189
  kind: "initial",
184
- prompt: buildNotaryTransportPrompt(admitted, engineMaterial),
190
+ // #879: gate path first mint carries parent payload as dialogue content;
191
+ // external zero-prompt kickoff unchanged when no body/reask.
192
+ prompt: (env.reviewReask ?? env.gateReviewInstruction)
193
+ ?? buildNotaryTransportPrompt(admitted, engineMaterial),
185
194
  },
186
195
  });
187
196
 
@@ -11,7 +11,6 @@ import { isAbsolute, join, resolve } from "node:path";
11
11
 
12
12
  import {
13
13
  buildResumeContinuationPrompt,
14
- GATE_DOSSIER_POINTER_PREFIX,
15
14
  RESUME_TRANSPORT_ENVELOPE,
16
15
  type PublicResumeRequest,
17
16
  type SameTicketSummonsMaterials,
@@ -37,7 +36,11 @@ import type {
37
36
  RoleTurnResult,
38
37
  SessionCustomEntryAppender,
39
38
  } from "../host-contracts.ts";
40
- import { projectCaseDossierPointerSection } from "./case-dossier-delivery.ts";
39
+ import { isOfficerReviewSeat } from "../host-contracts.ts";
40
+ import {
41
+ deliverCaseDossierAsAttachment,
42
+ projectCaseDossierPointerSection,
43
+ } from "./case-dossier-delivery.ts";
41
44
 
42
45
  /** Original error bytes, never relabeled — a secondary fact riding beside a classified cause. */
43
46
  function describeCaughtError(error: unknown): { name?: string; message: string; code?: string | number } {
@@ -58,6 +61,18 @@ function appendContinuationSection(
58
61
  ? { kind: "initial", prompt }
59
62
  : { kind: "resume", prompt };
60
63
  }
64
+
65
+ /**
66
+ * Nested gate summons (station child) on an officer seat: dialogue content is
67
+ * peer words only (#879). ADR 0081 case dossier still hangs via the existing
68
+ * attachments freeze seam — never into the peer body, never RoleTurnRequest.materials.
69
+ */
70
+ function isStationChildOfficerDialogue(
71
+ role: string,
72
+ env: { readonly stationChild?: boolean },
73
+ ): boolean {
74
+ return env.stationChild === true && isOfficerReviewSeat(role);
75
+ }
61
76
  import { projectHostTransitionPriorNative } from "../host-transition-prior-native.ts";
62
77
  import type { CredentialProviders, SeatModelConfig } from "./config.ts";
63
78
  import {
@@ -654,9 +669,9 @@ export async function dispatchPostAdmissionTurn<
654
669
  // Turn request is assembled after beforeDispatch so this turn sees whatever it
655
670
  // settled — the seat's ticket bind re-projection and any court diarist station
656
671
  // writes (#742). Case dossier delivery (ADR 0081 / #709) rides here once for
657
- // every public entry: first call, same-ticket re-summons and manual resume
658
- // alike. System refs append their own neutral section; caller frozen
659
- // attachments and the seat's own prompt bytes are never rewritten.
672
+ // every public entry. #879: station-child officer dialogue keeps peer body
673
+ // intact — 起居录 hangs via existing attachments freeze (not prompt wrap,
674
+ // not RoleTurnRequest.materials). Other entries keep the neutral prompt section.
660
675
  let turnRequest: RoleTurnRequest =
661
676
  env.signal === undefined ? request : { ...request, signal: env.signal };
662
677
  if (env.stationChild !== undefined) {
@@ -665,19 +680,33 @@ export async function dispatchPostAdmissionTurn<
665
680
  if (hostTransition !== undefined) {
666
681
  turnRequest = { ...turnRequest, hostTransition };
667
682
  }
668
- const dossierSection = await projectCaseDossierPointerSection({
669
- ticketNumber: admitted.ticketNumber,
670
- projectRoot: admitted.projectRoot,
671
- home: env.home,
672
- });
673
- if (dossierSection !== undefined) {
674
- turnRequest = {
675
- ...turnRequest,
676
- continuation: appendContinuationSection(
677
- turnRequest.continuation,
678
- dossierSection,
679
- ),
680
- };
683
+ if (isStationChildOfficerDialogue(admitted.role, env)) {
684
+ // 0081 non-body face: freeze pointer section under run/attachments/.
685
+ // Peer dialogue continuation.prompt stays parent payload only — the seat
686
+ // consumes the freeze via loadCaseDossierReadingMaterial → existing
687
+ // agent-start readingMaterial / systemPrompt.materials fold (not prompt splice,
688
+ // not RoleTurnRequest.materials).
689
+ await deliverCaseDossierAsAttachment({
690
+ ticketNumber: admitted.ticketNumber,
691
+ projectRoot: admitted.projectRoot,
692
+ home: env.home,
693
+ runDirectory: admitted.runDirectory,
694
+ });
695
+ } else {
696
+ const dossierSection = await projectCaseDossierPointerSection({
697
+ ticketNumber: admitted.ticketNumber,
698
+ projectRoot: admitted.projectRoot,
699
+ home: env.home,
700
+ });
701
+ if (dossierSection !== undefined) {
702
+ turnRequest = {
703
+ ...turnRequest,
704
+ continuation: appendContinuationSection(
705
+ turnRequest.continuation,
706
+ dossierSection,
707
+ ),
708
+ };
709
+ }
681
710
  }
682
711
 
683
712
  // Authoritative host write happens here, at the real dispatch boundary —
@@ -1065,16 +1094,16 @@ export async function dispatchPostAdmissionTurn<
1065
1094
  }
1066
1095
 
1067
1096
  /**
1068
- * Shared resume continuation projection (#471 / #600 / #633 / #637 / #755):
1097
+ * Shared resume continuation projection (#471 / #600 / #633 / #637 / #755 / #879):
1069
1098
  * seat-table model/engine/timeout axes, restored correlation, and either
1070
1099
  * - manual resume (no same-ticket summons): package envelope / optional caller
1071
1100
  * message, with engine-axis handbook via buildResumeContinuationPrompt, or
1072
1101
  * - same-ticket summons (审核循环续话): caller/peer words + optional frozen
1073
- * attachment paths only — no「重新读」、no engine handbook packaging
1074
- * (#750/#755), whether or not attachments are present.
1102
+ * attachment paths only — no「请重读」、no code-authored content substitute,
1103
+ * no engine handbook packaging (#750/#755/#879).
1075
1104
  * Caller message wins as prompt base when supplied (bytes unchanged, including
1076
- * blank/whitespace); else summons instruction. Attachment projection must not
1077
- * re-interpret the caller message as instructionEmpty.
1105
+ * blank/whitespace); else summons instruction (parent payload or reask).
1106
+ * Binding pointer stays on summons.sourceRunPath / activation — not dialogue content.
1078
1107
  * Seats add only their activation projection. Call prepareSummonsResumeMaterials
1079
1108
  * first when request.summons carries instruction or attachment paths.
1080
1109
  */
@@ -1088,32 +1117,25 @@ export function resumeTurnRequestProjectionOptions(
1088
1117
  readonly attachments: readonly { frozenPath: string }[];
1089
1118
  },
1090
1119
  ): RoleTurnRequestProjectionOptions {
1091
- const officerSourcePath = request.summons?.sourceRunPath;
1092
- const withReread = (body: string): string => {
1093
- if (
1094
- officerSourcePath === undefined
1095
- || (admitted.role !== "notary"
1096
- && admitted.role !== "inspector"
1097
- && admitted.role !== "auditor")
1098
- ) {
1099
- return body;
1100
- }
1101
- if (body.startsWith("请重读")) return body;
1102
- return `请重读\n${body}`;
1103
- };
1120
+ // #879: officer dialogue content is caller/peer words only — no「请重读」,
1121
+ // no code-authored constant substitute, no attachment-list wrap of peer body.
1122
+ // Binding pointer stays on summons sourceRunPath / activation / attachments.
1123
+ const officerDialogue = isStationChildOfficerDialogue(admitted.role, env);
1104
1124
  let prompt: string;
1105
1125
  if (request.message !== undefined) {
1106
1126
  if (summonsPrepared !== undefined) {
1107
- // #755: same-ticket review / open-court — caller words + optional paths.
1108
- // Attachments are not a gate: message-only summons must stay plain too.
1109
- prompt = withReread(buildInstructionTransportPrompt({
1110
- instruction: request.message,
1111
- instructionEmpty: false,
1112
- attachments: summonsPrepared.attachments,
1113
- }));
1127
+ // #755: same-ticket review / open-court — caller words.
1128
+ // #879 station-child officer: words only (attachments are independent freeze).
1129
+ prompt = officerDialogue
1130
+ ? request.message
1131
+ : buildInstructionTransportPrompt({
1132
+ instruction: request.message,
1133
+ instructionEmpty: false,
1134
+ attachments: summonsPrepared.attachments,
1135
+ });
1114
1136
  } else if (request.summons !== undefined) {
1115
1137
  // #755: same-ticket summons without prepared materials — caller words only.
1116
- prompt = withReread(request.message);
1138
+ prompt = request.message;
1117
1139
  } else {
1118
1140
  // Bare manual resume — outsourcing engine axis keeps handbook (#600/#736).
1119
1141
  prompt = buildResumeContinuationPrompt({
@@ -1123,28 +1145,15 @@ export function resumeTurnRequestProjectionOptions(
1123
1145
  });
1124
1146
  }
1125
1147
  } else if (summonsPrepared !== undefined) {
1126
- // #755: same-ticket review summons — instruction/attachments only.
1127
- prompt = withReread(buildInstructionTransportPrompt(summonsPrepared));
1148
+ // #879 station-child officer: instruction bytes === peer body/reask (no wrap).
1149
+ // Other seats keep #755 instruction + optional attachment path listing.
1150
+ prompt = officerDialogue
1151
+ ? (summonsPrepared.instructionEmpty ? "" : summonsPrepared.instruction)
1152
+ : buildInstructionTransportPrompt(summonsPrepared);
1128
1153
  } else if (request.summons !== undefined) {
1129
- // #755: same-ticket summons with no instruction/attachments (e.g. notary
1130
- // source-run pointer). #836: officer resume opening adds「请重读」+ path pointer.
1131
- const path = request.summons.sourceRunPath;
1132
- if (
1133
- path !== undefined &&
1134
- (admitted.role === "notary" ||
1135
- admitted.role === "inspector" ||
1136
- admitted.role === "auditor")
1137
- ) {
1138
- prompt = `请重读\n${GATE_DOSSIER_POINTER_PREFIX}${path}`;
1139
- } else if (
1140
- admitted.role === "notary" ||
1141
- admitted.role === "inspector" ||
1142
- admitted.role === "auditor"
1143
- ) {
1144
- prompt = "请重读";
1145
- } else {
1146
- prompt = "";
1147
- }
1154
+ // #879: same-ticket summons with no instruction (e.g. notary source-run binding
1155
+ // only). Pointer is activation/sourceRun material — not dialogue content.
1156
+ prompt = "";
1148
1157
  } else {
1149
1158
  // Bare manual resume — outsourcing engine axis keeps handbook (#600/#736).
1150
1159
  prompt = buildResumeContinuationPrompt({
@@ -2007,6 +2007,9 @@ export async function loadResumableInspectorRun(
2007
2007
  const admitted: AdmittedInspectorInvocation = {
2008
2008
  role: "inspector",
2009
2009
  ...resumedBaseAdmitted(loaded),
2010
+ ...(loaded.admittedFields.sourceRunPath === undefined
2011
+ ? {}
2012
+ : { sourceRunPath: loaded.admittedFields.sourceRunPath }),
2010
2013
  };
2011
2014
  return seatLoadedResult(loaded, admitted);
2012
2015
  }