@akagilnc/pi-workflow-roles 0.1.4035 → 0.1.4193

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 (108) hide show
  1. package/dist/acp-host/production-host.js +2368 -1155
  2. package/dist/archivist-record-entry.js +204 -20
  3. package/dist/archivist-record-topology.js +36 -16
  4. package/dist/auditor-dossier-tool.js +2 -1
  5. package/dist/diarist-contracts.js +96 -0
  6. package/dist/doctor-auditor.js +1 -1
  7. package/dist/doctor-evidence.js +3 -2
  8. package/dist/dossier-resolution.js +21 -8
  9. package/dist/gatekeeper-role.js +34 -27
  10. package/dist/headless-host/description.js +7 -8
  11. package/dist/headless-host/production-host.js +2375 -1149
  12. package/dist/host-contracts.js +16 -0
  13. package/dist/inspector-contracts.js +8 -0
  14. package/dist/migrate-book-topology.js +278917 -0
  15. package/dist/navigator-public-session.js +22 -25
  16. package/dist/notary-source-run.js +7 -4
  17. package/dist/packaged-role-registry.js +1 -1
  18. package/dist/pi/durable-principal.js +9 -6
  19. package/dist/pi/role-turn-host.js +43 -13
  20. package/dist/public-cli/auto-resume.js +2 -5
  21. package/dist/public-cli/case-dossier-delivery.js +60 -1
  22. package/dist/public-cli/diarist-run.js +27 -25
  23. package/dist/public-cli/inspector-run.js +17 -3
  24. package/dist/public-cli/instruction-seat-run.js +17 -17
  25. package/dist/public-cli/invocation.js +127 -63
  26. package/dist/public-cli/main.js +1685 -696
  27. package/dist/public-cli/notary-run.js +7 -4
  28. package/dist/public-cli/option-definitions.js +2 -2
  29. package/dist/public-cli/post-admission.js +113 -71
  30. package/dist/public-cli/run-lifecycle.js +209 -58
  31. package/dist/public-cli/seat-ticket-binding.js +3 -6
  32. package/dist/public-cli/settlement.js +29 -10
  33. package/dist/public-role-summons.js +8 -0
  34. package/dist/role-run-placement.js +84 -0
  35. package/dist/role-run-relocation.js +358 -0
  36. package/dist/run-terminal-artifacts.js +2 -1
  37. package/dist/run-ticket-number.js +51 -32
  38. package/dist/sitian-appender.js +11 -26
  39. package/dist/submission-ledger.js +106 -25
  40. package/dist/ticket-provenance.js +3 -1
  41. package/dist/user-dialogue-stdin.js +33 -0
  42. package/extensions/role-runtime.ts +1 -0
  43. package/package.json +3 -2
  44. package/resources/836-deleted-machine-instruction-inventory.md +1 -1
  45. package/scripts/build-package.mjs +34 -2
  46. package/souls/fixer.md +3 -2
  47. package/src/analyst-ledger.ts +14 -15
  48. package/src/archivist-record-entry.ts +285 -38
  49. package/src/archivist-record-topology.ts +43 -18
  50. package/src/auditor-dossier-tool.ts +2 -1
  51. package/src/book-topology-migration-placement.ts +493 -0
  52. package/src/book-topology-migration.ts +220 -0
  53. package/src/book-topology-migrators-t11.ts +856 -0
  54. package/src/book-topology-mixed-volume-migrators.ts +470 -0
  55. package/src/book-topology-partition-migrators.ts +25 -0
  56. package/src/book-topology-record-class-migrators.ts +779 -0
  57. package/src/book-topology-runs-migrator.ts +244 -0
  58. package/src/diarist-contracts.ts +116 -0
  59. package/src/diarist-role.ts +6 -0
  60. package/src/diarist.ts +2 -0
  61. package/src/doctor-auditor.ts +1 -1
  62. package/src/doctor-evidence.ts +4 -1
  63. package/src/doctor-role.ts +1 -1
  64. package/src/dossier-resolution.ts +26 -8
  65. package/src/external-host-turn-loop.ts +9 -0
  66. package/src/gatekeeper-pass-envelope.ts +6 -0
  67. package/src/gatekeeper-role.ts +47 -28
  68. package/src/headless-host/description.ts +8 -11
  69. package/src/headless-host/role-turn-host.ts +20 -5
  70. package/src/host-contracts.ts +22 -3
  71. package/src/inspector-contracts.ts +7 -0
  72. package/src/judge-role.ts +4 -0
  73. package/src/navigator-public-session.ts +33 -30
  74. package/src/navigator-work-context.ts +3 -4
  75. package/src/notary-role.ts +1 -0
  76. package/src/notary-source-run.ts +9 -4
  77. package/src/packaged-role-registry.ts +1 -1
  78. package/src/pi/adapter.ts +19 -4
  79. package/src/pi/durable-principal.ts +9 -13
  80. package/src/pi/role-turn-host.ts +48 -14
  81. package/src/public-cli/auto-resume.ts +2 -6
  82. package/src/public-cli/case-dossier-delivery.ts +86 -1
  83. package/src/public-cli/countersign-run.ts +182 -32
  84. package/src/public-cli/diarist-run.ts +34 -26
  85. package/src/public-cli/inspector-run.ts +26 -2
  86. package/src/public-cli/instruction-seat-run.ts +24 -20
  87. package/src/public-cli/invocation.ts +194 -58
  88. package/src/public-cli/notary-run.ts +13 -4
  89. package/src/public-cli/option-definitions.ts +2 -2
  90. package/src/public-cli/post-admission.ts +147 -78
  91. package/src/public-cli/run-lifecycle.ts +258 -65
  92. package/src/public-cli/seat-ticket-binding.ts +3 -6
  93. package/src/public-cli/settlement.ts +38 -10
  94. package/src/public-role-summons.ts +31 -7
  95. package/src/role-envelope.ts +9 -30
  96. package/src/role-run-placement.ts +128 -0
  97. package/src/role-run-relocation.ts +491 -0
  98. package/src/role-runtime-dependencies.ts +1 -0
  99. package/src/role-runtime.ts +106 -24
  100. package/src/run-terminal-artifacts.ts +3 -1
  101. package/src/run-ticket-number.ts +80 -20
  102. package/src/sitian-appender.ts +25 -28
  103. package/src/submission-ledger.ts +148 -30
  104. package/src/ticket-provenance.ts +4 -1
  105. package/src/ticket-trajectory.ts +22 -35
  106. package/src/user-dialogue-stdin.ts +37 -0
  107. package/src/worker-role.ts +8 -4
  108. package/src/worker-submission-gates.ts +6 -6
@@ -8,10 +8,8 @@ import type {
8
8
  NewDurablePrincipalRequest,
9
9
  } from "../host-contracts.ts";
10
10
  import { resolveBookKeyFromGit } from "../activation-ledger-git.ts";
11
- import {
12
- activationBookDirectory,
13
- resolveActivationLedgerHome,
14
- } from "../activation-ledger-topology.ts";
11
+ import { resolveActivationLedgerHome } from "../activation-ledger-topology.ts";
12
+ import { roleRunPlacement } from "../role-run-placement.ts";
15
13
 
16
14
  type PiDurablePrincipal = DurablePrincipal & {
17
15
  readonly sessionDirectory: string;
@@ -31,18 +29,16 @@ export function issuePiDurablePrincipalCoordinates(
31
29
  } {
32
30
  const ledgerHome = resolveActivationLedgerHome(request.home);
33
31
  const bookKey = resolveBookKeyFromGit(request.cwd);
34
- const runDirectory = join(
35
- activationBookDirectory(ledgerHome, bookKey),
36
- "runs",
37
- `${request.runId}@${request.role}`,
38
- );
39
- const sessionDirectory = join(runDirectory, "session");
32
+ const placement = roleRunPlacement(ledgerHome, {
33
+ bookKey,
34
+ subject: { unbound: true },
35
+ runId: request.runId,
36
+ role: request.role,
37
+ });
40
38
  return {
41
39
  ledgerHome,
42
40
  bookKey,
43
- runDirectory,
44
- sessionDirectory,
45
- sessionFile: join(sessionDirectory, "session.jsonl"),
41
+ ...placement,
46
42
  };
47
43
  }
48
44
 
@@ -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
  });
@@ -16,6 +16,7 @@ import { randomUUID } from "node:crypto";
16
16
  import { lstat, mkdir, open } from "node:fs/promises";
17
17
  import { join } from "node:path";
18
18
 
19
+ import { roleRunArtifactsDirectory } from "../role-run-placement.ts";
19
20
  import type {
20
21
  DurablePrincipal,
21
22
  DurablePrincipalAuthority,
@@ -143,11 +144,6 @@ export class TurnDispatchedFailure extends Error {
143
144
  /** Session custom-entry type carrying the pointer to one dispatch error file. */
144
145
  export const DISPATCH_ERROR_RETENTION_ENTRY_TYPE = "ak_run_dispatch_error_retention" as const;
145
146
 
146
- /** Artifacts subdirectory of a run directory (established run-artifacts location). */
147
- function runArtifactsDirectory(runDirectory: string): string {
148
- return join(runDirectory, "artifacts");
149
- }
150
-
151
147
  /**
152
148
  * #182-A hardened path identity, mirrored from settlement.ts's
153
149
  * ensureAuditEvidenceDirectory: a planted symlink at the run directory or the
@@ -163,7 +159,7 @@ export async function ensureRealArtifactsDirectory(runDirectory: string): Promis
163
159
  if (runStat.isSymbolicLink() || !runStat.isDirectory()) {
164
160
  throw new Error("run artifact retention: run directory is not a real directory");
165
161
  }
166
- const artifactsDir = runArtifactsDirectory(runDirectory);
162
+ const artifactsDir = roleRunArtifactsDirectory(runDirectory);
167
163
  try {
168
164
  const existing = await lstat(artifactsDir);
169
165
  if (existing.isSymbolicLink() || !existing.isDirectory()) {
@@ -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
+ }
@@ -25,10 +25,16 @@
25
25
  import type { DurablePrincipalAuthority, RoleTurnRequest } from "../host-contracts.ts";
26
26
  import { engineSessionMaterialFromOptions, pickEngineAxis } from "../package-resources/engine-material.ts";
27
27
  import { CliUsageError } from "./cli-errors.ts";
28
+ import {
29
+ projectCourtTicketNumbers,
30
+ } from "../diarist-contracts.ts";
31
+ import { isSafePositiveTicketNumber } from "../run-ticket-number.ts";
28
32
  import {
29
33
  admitCountersignInvocation,
30
34
  bindAdmittedTicketNumber,
35
+ bindCourtTicketNumbersOnAdmitted,
31
36
  buildCountersignTransportPrompt,
37
+ relocateAdmittedRunToTicket,
32
38
  type AdmittedCountersignInvocation,
33
39
  type ParseCountersignArgvResult,
34
40
  } from "./invocation.ts";
@@ -46,6 +52,7 @@ import {
46
52
  markRunAdmitted,
47
53
  markRunTerminal,
48
54
  type PublicResumeRequest,
55
+ type RunWriterLease,
49
56
  type SameTicketSummonsMaterials,
50
57
  } from "./run-lifecycle.ts";
51
58
  import { tryResumeSameTicketSeatRun } from "./seat-ticket-binding.ts";
@@ -70,6 +77,12 @@ export type CountersignRunEnv = PostAdmissionEnv & {
70
77
  runCourtDiaristStation?: (
71
78
  admitted: AdmittedCountersignInvocation,
72
79
  ) => Promise<void>;
80
+ /**
81
+ * #871: typed co-review set from the identity 起居郎 turn, applied onto the
82
+ * resumed run before bound refresh (same-ticket re-summons path). Whole-set
83
+ * replace — never union. Production leaves unset outside that handoff.
84
+ */
85
+ pendingCourtTicketNumbers?: readonly number[];
73
86
  };
74
87
 
75
88
  /** Project admitted invocation onto the host-neutral turn request. */
@@ -94,7 +107,16 @@ export function buildCountersignTurnRequest(
94
107
 
95
108
  /** 起居郎 identity outcome — escalate stays distinct from missing terminal. */
96
109
  type CourtDiaristIdentity =
97
- | { readonly kind: "ticket"; readonly ticketNumber: number }
110
+ | {
111
+ readonly kind: "ticket";
112
+ readonly ticketNumber: number;
113
+ /**
114
+ * #871 typed co-review set when the LLM explicitly submitted one.
115
+ * Absent means "no new set this turn" — resume must keep the stored run fact
116
+ * (never silently degrade a multi-ticket set to [main]).
117
+ */
118
+ readonly courtTicketNumbers?: readonly number[];
119
+ }
98
120
  | { readonly kind: "unbound" }
99
121
  | { readonly kind: "escalate" };
100
122
 
@@ -103,6 +125,53 @@ type CourtDiaristInvocationResult = {
103
125
  readonly failedWithoutEscalate?: { readonly diagnostic: string };
104
126
  };
105
127
 
128
+ /**
129
+ * Read #871 set from preserved diarist payloads.
130
+ * - Field absent / null / non-array → undefined (no new set; resume keeps store).
131
+ * - Explicit [] → single-ticket [main] whole-set replace (signed empty-set contract).
132
+ * - Non-empty array with zero lawful typed members after projection → not a new set
133
+ * (do not impersonate explicit empty and wipe a stored multi-ticket fact).
134
+ * - Non-empty array with ≥1 lawful member → sole type/dedupe projection + principal guarantee.
135
+ * - Multiple submissions: last qualifying set wins.
136
+ * Live path never shape-rejects the role turn; durable damage is a separate seam.
137
+ */
138
+ function courtTicketNumbersFromOutcome(
139
+ roleOutcome: TerminalRoleOutcome | undefined,
140
+ principalTicket: number,
141
+ ): readonly number[] | undefined {
142
+ if (roleOutcome === undefined) return undefined;
143
+ const payloads =
144
+ roleOutcome.kind === "accepted" ||
145
+ roleOutcome.kind === "audit_escalation" ||
146
+ roleOutcome.kind === "failure"
147
+ ? roleOutcome.payloads ?? []
148
+ : [];
149
+ let latest: readonly number[] | undefined;
150
+ for (const payload of payloads) {
151
+ if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
152
+ continue;
153
+ }
154
+ const record = payload as Record<string, unknown>;
155
+ if (!Object.hasOwn(record, "courtTicketNumbers")) continue;
156
+ const raw = record.courtTicketNumbers;
157
+ // Only a real array is a candidate set. Non-array is not explicit empty.
158
+ if (!Array.isArray(raw)) continue;
159
+ // Members only — do not inject principal yet, or all-invalid non-empty becomes [main].
160
+ const members = projectCourtTicketNumbers(raw);
161
+ if (members === null) continue;
162
+ if (members.length === 0) {
163
+ // Literal [] is the intentional principal-only replace; non-empty garbage is not.
164
+ if (raw.length === 0) {
165
+ latest = projectCourtTicketNumbers([], { principalTicket }) ?? undefined;
166
+ }
167
+ continue;
168
+ }
169
+ latest =
170
+ projectCourtTicketNumbers(raw, { principalTicket }) ?? undefined;
171
+ }
172
+ return latest;
173
+ }
174
+
106
175
  /** Routing boolean over the child's own typed sequence — does not pick or rewrite a sole row. */
107
176
  function courtDiaristEscalated(roleOutcome: TerminalRoleOutcome | undefined): boolean {
108
177
  if (roleOutcome === undefined) return false;
@@ -181,13 +250,14 @@ async function invokeCourtDiarist(input: {
181
250
  }
182
251
 
183
252
  const asserted = (result.admitted as { ticketNumber?: number } | undefined)?.ticketNumber;
184
- if (
185
- typeof asserted === "number" &&
186
- Number.isSafeInteger(asserted) &&
187
- asserted >= 1
188
- ) {
253
+ if (isSafePositiveTicketNumber(asserted)) {
254
+ const courtTicketNumbers = courtTicketNumbersFromOutcome(roleOutcome, asserted);
189
255
  return {
190
- identity: { kind: "ticket", ticketNumber: asserted },
256
+ identity: {
257
+ kind: "ticket",
258
+ ticketNumber: asserted,
259
+ ...(courtTicketNumbers === undefined ? {} : { courtTicketNumbers }),
260
+ },
191
261
  };
192
262
  }
193
263
  return {
@@ -219,25 +289,45 @@ export async function runCountersignCourtDiaristStation(
219
289
  // First-entry unbound identity is owned by runPublicCountersign.
220
290
  if (admitted.ticketNumber === undefined) return;
221
291
 
222
- const outcome = await invokeCourtDiarist(
223
- {
224
- instruction: `整理 #${admitted.ticketNumber} 的本案依据。`,
225
- projectRoot: admitted.projectRoot,
226
- failureLabel: `ticket #${admitted.ticketNumber}`,
227
- // Refresh holds a typed key — hand it off so identity is bound before turn.
228
- boundTicketNumber: admitted.ticketNumber,
229
- },
230
- env,
231
- io,
232
- );
292
+ // #871: refresh every member of the typed co-review set; single-ticket face
293
+ // is just the set [main]. Present-but-empty / missing-principal is damage —
294
+ // never silently fall back to main-only (legacy absent field still may).
295
+ let refreshTickets: readonly number[];
296
+ if (admitted.courtTicketNumbers !== undefined) {
297
+ if (
298
+ admitted.courtTicketNumbers.length === 0 ||
299
+ !admitted.courtTicketNumbers.includes(admitted.ticketNumber)
300
+ ) {
301
+ throw new StationChildExhaustedError(
302
+ `court diarist station: courtTicketNumbers is damaged (empty or missing principal #${admitted.ticketNumber})`,
303
+ );
304
+ }
305
+ refreshTickets = admitted.courtTicketNumbers;
306
+ } else {
307
+ refreshTickets = [admitted.ticketNumber];
308
+ }
233
309
 
234
- if (outcome.identity.kind === "escalate") {
235
- throw new StationChildExhaustedError(
236
- "court diarist station escalated (cannot identify court target)",
310
+ for (const ticketNumber of refreshTickets) {
311
+ const outcome = await invokeCourtDiarist(
312
+ {
313
+ instruction: `整理 #${ticketNumber} 的本案依据。`,
314
+ projectRoot: admitted.projectRoot,
315
+ failureLabel: `ticket #${ticketNumber}`,
316
+ // Refresh holds a typed key — hand it off so identity is bound before turn.
317
+ boundTicketNumber: ticketNumber,
318
+ },
319
+ env,
320
+ io,
237
321
  );
238
- }
239
- if (outcome.failedWithoutEscalate !== undefined) {
240
- throw new StationChildExhaustedError(outcome.failedWithoutEscalate.diagnostic);
322
+
323
+ if (outcome.identity.kind === "escalate") {
324
+ throw new StationChildExhaustedError(
325
+ "court diarist station escalated (cannot identify court target)",
326
+ );
327
+ }
328
+ if (outcome.failedWithoutEscalate !== undefined) {
329
+ throw new StationChildExhaustedError(outcome.failedWithoutEscalate.diagnostic);
330
+ }
241
331
  }
242
332
  }
243
333
 
@@ -293,6 +383,7 @@ export async function runPublicCountersign(
293
383
  // hook failures stay on the parent call-local budget, exhausted nested
294
384
  // station children still skip parent auto-resume (#840 父子不层叠).
295
385
  let typedTicket: number | undefined;
386
+ let typedCourtTicketNumbers: readonly number[] | undefined;
296
387
  let identityDiaristRan = false;
297
388
 
298
389
  if (env.runCourtDiaristStation === undefined) {
@@ -347,7 +438,10 @@ export async function runPublicCountersign(
347
438
  // unbound and continue (true-unbound face). Escalate / typed failure above;
348
439
  // bound refresh still fails honest via runCountersignCourtDiaristStation.
349
440
  if (outcome.identity.kind === "ticket") {
350
- typedTicket = outcome.identity.ticketNumber;
441
+ const assertedTicket = outcome.identity.ticketNumber;
442
+ typedTicket = assertedTicket;
443
+ // #871: identity may hand a co-review set; absent field defaults to [main].
444
+ typedCourtTicketNumbers = outcome.identity.courtTicketNumbers;
351
445
  const summons: SameTicketSummonsMaterials = {
352
446
  instruction: parsed.instruction,
353
447
  instructionEmpty: parsed.instruction.trim() === "",
@@ -361,16 +455,26 @@ export async function runPublicCountersign(
361
455
  freshSummons: env.freshSummons,
362
456
  summons,
363
457
  resume: async (runId, materials) => {
364
- // Resume selected: abandon mint first so a throw cannot leave it admitted.
458
+ // Resume selected: file the provisional run under the asserted ticket
459
+ // before abandoning it, so typed identity never leaves an unbound row.
460
+ await bindAdmittedTicketNumber(admitted, assertedTicket);
461
+ await relocateAdmittedRunToTicket(admitted, env.principalAuthority);
365
462
  await markRunTerminal(admitted.runDirectory);
366
463
  // Identity 起居郎 asserted unbound (no issue face). Resume still runs
367
464
  // the bound refresh station under the typed key (refresh-every-court).
465
+ // #871: hand the identity set so resume can whole-replace the run fact
466
+ // when this summons produced a new typed set (never union).
368
467
  return await runPublicCountersignResume(
369
468
  {
370
469
  runId,
371
470
  ...(materials === undefined ? {} : { summons: materials }),
372
471
  },
373
- env,
472
+ {
473
+ ...env,
474
+ ...(typedCourtTicketNumbers === undefined
475
+ ? {}
476
+ : { pendingCourtTicketNumbers: typedCourtTicketNumbers }),
477
+ },
374
478
  io,
375
479
  );
376
480
  },
@@ -381,6 +485,33 @@ export async function runPublicCountersign(
381
485
  }
382
486
  }
383
487
 
488
+ if (identityDiaristRan && typedTicket !== undefined) {
489
+ try {
490
+ await bindAdmittedTicketNumber(admitted, typedTicket);
491
+ await relocateAdmittedRunToTicket(admitted, env.principalAuthority);
492
+ // First court: explicit set wins; omitted field → single-ticket face [main].
493
+ // Set persistence is outside beforeDispatch — route write failures into the
494
+ // same controlled-failure settlement as station children (#871 B7).
495
+ await bindCourtTicketNumbersOnAdmitted(
496
+ admitted,
497
+ typedCourtTicketNumbers ?? [typedTicket],
498
+ );
499
+ } catch (error) {
500
+ return await presentControlledFailure(
501
+ admitted,
502
+ {
503
+ timedOut: false,
504
+ code: null,
505
+ stderr: "",
506
+ thrown: error,
507
+ },
508
+ countersignAdapters(),
509
+ env.principalAuthority,
510
+ io,
511
+ );
512
+ }
513
+ }
514
+
384
515
  const turnProjection: RoleTurnRequestProjectionOptions = {
385
516
  packageRoot: env.packageRoot,
386
517
  home: env.home,
@@ -405,23 +536,25 @@ export async function runPublicCountersign(
405
536
  // Mutable shell: ticket bind re-projects activation before executeTurn.
406
537
  const turnRequest = buildCountersignTurnRequest(admitted, turnProjection);
407
538
 
408
- return await runPostAdmissionOneShot({
539
+ const result = await runPostAdmissionOneShot({
409
540
  admitted,
410
541
  env,
411
542
  io,
412
543
  request: turnRequest,
413
544
  adapters: countersignAdapters({
414
- beforeDispatch: async (admittedSeat) => {
545
+ beforeDispatch: async (admittedSeat, lease) => {
415
546
  // Dossier pointer delivery rides post-admission after this hook (#709).
416
547
  if (!identityDiaristRan) {
417
548
  // Test seam (or any deferred identity): station owns assert + bind.
418
549
  await runCountersignCourtDiaristStation(admittedSeat, env, io);
419
550
  } else if (typedTicket !== undefined) {
420
- // Production identity asserted unbound; bind typed key, then bound
421
- // refresh so freeze loads issue face (typed handoff, not prose match).
422
- await bindAdmittedTicketNumber(admittedSeat, typedTicket);
423
551
  await runCountersignCourtDiaristStation(admittedSeat, env, io);
424
552
  }
553
+ await relocateAdmittedRunToTicket(
554
+ admittedSeat,
555
+ env.principalAuthority,
556
+ lease,
557
+ );
425
558
  Object.assign(
426
559
  turnRequest,
427
560
  buildCountersignTurnRequest(admittedSeat, turnProjection),
@@ -430,11 +563,14 @@ export async function runPublicCountersign(
430
563
  }),
431
564
  ...(env.engine === undefined ? {} : { effectiveEngine: env.engine }),
432
565
  });
566
+ await relocateAdmittedRunToTicket(admitted, env.principalAuthority);
567
+ return result;
433
568
  }
434
569
 
435
570
  function countersignAdapters(options?: {
436
571
  beforeDispatch?: (
437
572
  admitted: AdmittedCountersignInvocation,
573
+ lease: RunWriterLease,
438
574
  ) => void | Promise<void>;
439
575
  }) {
440
576
  return {
@@ -496,6 +632,20 @@ export async function runPublicCountersignResume(
496
632
  },
497
633
  adapters: countersignAdapters({
498
634
  beforeDispatch: async (admitted) => {
635
+ // #871 B7: durable set damage already identified at load — settle as
636
+ // station-child exhausted → presentControlledFailure (not structural exit 2).
637
+ if (admitted.courtTicketNumbersDamage !== undefined) {
638
+ throw new StationChildExhaustedError(admitted.courtTicketNumbersDamage);
639
+ }
640
+ // #871: same-ticket re-summons may hand a fresh typed set from identity.
641
+ // Whole-set replace onto this run fact; no new set → keep stored set.
642
+ // Manual resume (no pending) keeps the durable set and never invents one.
643
+ if (env.pendingCourtTicketNumbers !== undefined) {
644
+ await bindCourtTicketNumbersOnAdmitted(
645
+ admitted,
646
+ env.pendingCourtTicketNumbers,
647
+ );
648
+ }
499
649
  await runCountersignCourtDiaristStation(admitted, env, io);
500
650
  },
501
651
  }),