@akagilnc/pi-workflow-roles 0.1.5015 → 0.1.5037

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.
@@ -1,10 +1,9 @@
1
1
  /**
2
2
  * Shared same-parent resume seam for gate / officer seats (#635 / #637 / #747 / #987).
3
3
  *
4
- * Lookup key is parent run path only (#747 / #987 Result 7). Public seats no
5
- * longer select a prior run by ticket number — callers use explicit
6
- * `ak-role resume <runId>`. Officer and gate countersign same-parent re-summons
7
- * keep this seam. Lookup/resume failures propagate (失败诚实) — never wash into
4
+ * Lookup uses the parent run path; a typed ticket narrows same-ticket gate
5
+ * re-summons, never creates an issue identity. Callers can also use explicit
6
+ * `ak-role resume <runId>`. Lookup/resume failures propagate (失败诚实) — never wash into
8
7
  * a fresh mint. Returns undefined when the caller declared an explicit fresh
9
8
  * summons (`ak-role new`) or when no prior run exists; both mint new.
10
9
  * freshSummons is required so no seat can drift back into its own skip branch.
@@ -13,7 +12,7 @@ import { resolveBookKeyFromGit } from "../activation-ledger-git.js";
13
12
  import { findLatestRunIdForSeatTicket, } from "./run-lifecycle.js";
14
13
  /**
15
14
  * Sole same-seat → resume decision by parent run path (#637 / #724 / #747 / #987).
16
- * A typed ticket number is a bind key, not a resume key. When a prior run is
15
+ * A supplied typed ticket only narrows the prior-run search. When a prior run is
17
16
  * found, resume carries this summons' materials. Lookup/resume failures
18
17
  * propagate (失败诚实) — never wash into a fresh mint. Returns undefined when
19
18
  * the caller declared an explicit fresh summons (`ak-role new`), the parent
@@ -30,9 +29,7 @@ export async function tryResumeSameTicketSeatRun(input) {
30
29
  bookKey: resolveBookKeyFromGit(input.projectRoot),
31
30
  role: input.role,
32
31
  parentRunPath: input.parentRunPath,
33
- ...(input.ticketNumber === undefined
34
- ? {}
35
- : { ticketNumber: input.ticketNumber }),
32
+ ...(input.ticketNumber === undefined ? {} : { ticketNumber: input.ticketNumber }),
36
33
  });
37
34
  if (previousRunId === undefined)
38
35
  return undefined;
@@ -274,7 +274,10 @@ async function summonPublicRole(options) {
274
274
  result = stepped.ok;
275
275
  }
276
276
  const stderr = captured?.stderrText();
277
- const runDirectory = result.admitted?.runDirectory;
277
+ const runDirectory = result.terminal !== void 0 && result.terminal.roleOutcome.role !== options.role && typeof result.terminal.runId === "string" ? await (await import("./public-cli/run-lifecycle.js")).findRunDirectoryById(
278
+ home,
279
+ result.terminal.runId
280
+ ) : result.admitted?.runDirectory;
278
281
  return {
279
282
  exitCode: result.exitCode,
280
283
  ...result.terminal === void 0 ? {} : { terminal: result.terminal },
@@ -478,8 +481,10 @@ async function summonGateOfficer(options) {
478
481
  `countersign gate summon requires parent runId from sourceRunDirectory: ${options.sourceRunDirectory}`
479
482
  );
480
483
  }
481
- const { readBoardTicketNumber } = await import("./run-ticket-number.js");
484
+ const { isSafePositiveTicketNumber, readBoardTicketNumber } = await import("./run-ticket-number.js");
482
485
  const parentTicket = await readBoardTicketNumber(options.sourceRunDirectory);
486
+ const submittedTicket = options.submission !== null && typeof options.submission === "object" && !Array.isArray(options.submission) ? options.submission.ticketNumber : void 0;
487
+ const courtTicket = isSafePositiveTicketNumber(submittedTicket) ? submittedTicket : parentTicket;
483
488
  const instruction = options.reask ?? gateReviewInstruction ?? "";
484
489
  return summonPublicRole({
485
490
  role: options.officer,
@@ -487,7 +492,7 @@ async function summonGateOfficer(options) {
487
492
  ...common,
488
493
  correlationId,
489
494
  parentRunPath: options.sourceRunDirectory,
490
- ...parentTicket === void 0 ? {} : { boundTicketNumber: parentTicket }
495
+ ...courtTicket === void 0 ? {} : { boundTicketNumber: courtTicket }
491
496
  });
492
497
  }
493
498
  return summonPublicRole({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akagilnc/pi-workflow-roles",
3
- "version": "0.1.5015",
3
+ "version": "0.1.5037",
4
4
  "description": "Soul-bound workflow roles for Pi",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -0,0 +1,5 @@
1
+ # 给事中票面审读方法
2
+
3
+ 中书省交卷时,以交卷原文所指的实际 GitHub issue 为审读对象;传召附带的绑定票号只用于绑卷,不替中书省决定票号。先在当前项目的 GitHub 仓库用宿主已有 CLI 读取该 issue 的现行正文(例如 `gh issue view <票号> --json body`);不得只看中书省交卷文字或本地草稿,就声称已审 GitHub 票面。对照原交卷和实际 issue 正文,按《票面法》审读。
4
+
5
+ 中书省以 `converged` 交卷却没有可审的实际 GitHub issue 身份时,封驳退回中书省补齐;不得以无号草稿署票。此类封驳走现有重交通道,不由代码按回执形状拒收。issue 不存在或正文读取失败时,如实说明现场失败,不把未读到的票面判作已审。交卷与绑卷票号冲突也须说明,不猜测替代票号。
@@ -33,6 +33,7 @@
33
33
 
34
34
  - 认得出本庭对象:`status: completed` 且 `ticketNumber: N`,并交边界。
35
35
  - 真无票对象(方案/派单/处置案等):`status: completed` 且 `ticketNumber: null`(或省略)。真无票是合法结果,不入录。
36
+ - 本庭明确要新建票、但 issue 尚未建号:不是认票失败。以 `status: completed`、`ticketNumber: null` 交出已辨明的本案对话边界;起居录先随无号 run 暂存,建号后由既有归卷流程补归该号。不要借用旁票号。
36
37
  - 认不出本庭对象是哪一张:`status: escalate` 并写明 `reason`。识别不了就上抛,不得省略票号假装无录,也不得猜一个号。
37
38
  - 不得从旁及的他票或任何猜测里挑一个号顶替认不出的身份。
38
39
  - 传召文自然会提轮次、决定编号、commit sha、邻票号;你认的是本庭对象,不是文中出现的每一个数字。
@@ -0,0 +1,15 @@
1
+ # 中书省票身份与落票方法
2
+
3
+ 本方法适用于公开中书省调用。票面范围、审读职责和交卷形状仍以各自法源为准。
4
+
5
+ ## 先辨票,再改票
6
+
7
+ 调用者可以给票号,也可以不给。给了票号,先读取并核对该 issue,再在原 issue 上更新票面;不要另建一票。没有给票号,不等于新票:先结合本次对话、本票起居录和当前 GitHub 仓库中的 issue 判断是否已有对应票。找到已有票就更新那张;只有确认没有对应票,才在当前仓库创建 issue。不得仅凭内容相似就认定已有票,也不得因没找到就忽略搜索范围或不确定性。
8
+
9
+ 票面改定后,应将实际要执行的正文落到对应 GitHub issue。不能确认已有票还是新票、无法读取或更新目标 issue,或创建 issue 失败时,不得把草稿当成已落票结果送庭;可上呈或重交,并说明现场观察到的真实失败原因。若信息不足以区分多个候选 issue,可上呈请裁定,不猜票号、不另建重复票。
10
+
11
+ ## 落地后送庭
12
+
13
+ 只有确认目标 issue 存在且正文已更新为本票最终票面后,才以 `converged` 交卷,并携带该 issue 的实际票号。沿用既有交卷闸,将交卷原文交给给事中审这张有票号 issue;不以无号草稿替代 issue 审读。不另造传召或记账机制。
14
+
15
+ 执行 GitHub 操作时使用当前宿主实际提供的 CLI / 能力;不在包内另造 GitHub 适配层。记录足以核对身份和落地结果的证据,不把模型自报冒充为现场观察。
@@ -391,7 +391,7 @@ export const PUBLIC_ROLE_RECORDS = [
391
391
  activationStage: "load-and-install",
392
392
  receiptStatusKey: "status",
393
393
  // #924: 公用《票面法》三席同装
394
- sessionMaterials: ["CLAUDE.md", "souls/countersign.md", "souls/ticket-law.md"],
394
+ sessionMaterials: ["CLAUDE.md", "souls/countersign.md", "souls/ticket-law.md", "resources/countersign-ticket-issue.md"],
395
395
  },
396
396
  {
397
397
  role: "secretariat",
@@ -399,7 +399,7 @@ export const PUBLIC_ROLE_RECORDS = [
399
399
  presentSettled: "always",
400
400
  summonResume: false,
401
401
  admission: "instruction",
402
- sameParent: "court-diarist",
402
+ sameParent: "none",
403
403
  phases: [null],
404
404
  outputTool: SECRETARIAT_OUTPUT_TOOL_NAME,
405
405
  settlement: "accepted",
@@ -415,6 +415,7 @@ export const PUBLIC_ROLE_RECORDS = [
415
415
  "CLAUDE.md",
416
416
  "souls/secretariat.md",
417
417
  "souls/ticket-law.md",
418
+ "resources/secretariat-ticket-identity.md",
418
419
  "souls/countersign.md",
419
420
  "souls/notary.md",
420
421
  ],
@@ -77,7 +77,8 @@ import {
77
77
  type PublicRoleOptionOwner,
78
78
  type TypedOptionConsumer,
79
79
  } from "./option-definitions.ts";
80
- import { runPublicInstructionSeat, runPublicInstructionSeatResume } from "./instruction-seat-run.ts";
80
+ import { continueParentAfterChild, runPublicInstructionSeat, runPublicInstructionSeatResume } from "./instruction-seat-run.ts";
81
+ import { courtDiaristEscalated } from "./countersign-run.ts";
81
82
  import { runPublicAnalyst } from "./analyst-run.ts";
82
83
  import {
83
84
  AUTO_RESUME_LIMIT,
@@ -1229,7 +1230,22 @@ export async function runAkRole(
1229
1230
  }),
1230
1231
  io,
1231
1232
  );
1232
- return cliResultFromRoleRun(result);
1233
+ let current = result;
1234
+ while (
1235
+ current.exitCode === 0 && current.admitted?.correlationId !== undefined
1236
+ && current.terminal?.roleOutcome.kind === "accepted"
1237
+ && !courtDiaristEscalated(current.terminal.roleOutcome)
1238
+ ) {
1239
+ const parentRunId = current.admitted.correlationId;
1240
+ const parentRole = await peekRoleRunRole(home, parentRunId);
1241
+ if (parentRole === undefined) break;
1242
+ const parentSeat = resolveEffectiveSeat(config, parentRole, credentials, invocationFromParsed(parsed));
1243
+ const parentEnv = createRoleEnvironment(env, {
1244
+ role: parentRole, home, agentDir, cwd, credentials, seat: parentSeat, config,
1245
+ });
1246
+ current = await continueParentAfterChild(parentRunId, current.admitted, parentEnv, io);
1247
+ }
1248
+ return cliResultFromRoleRun(current);
1233
1249
  }
1234
1250
 
1235
1251
  if (
@@ -45,8 +45,8 @@ export type CountersignRunEnv = PostAdmissionEnv & {
45
45
  */
46
46
  gateReviewInstruction?: string;
47
47
  /**
48
- * #969 / #987 gate path: parent run durable board ticket handoff for bind only.
49
- * Resume lookup uses parentRunPath — never this ticket number (#987 Result 7).
48
+ * #969 / #987 gate path: parent run durable board ticket handoff for bind.
49
+ * Same-ticket resume lookup narrows the parent path by this typed number.
50
50
  */
51
51
  boundTicketNumber?: number;
52
52
  /**
@@ -79,6 +79,7 @@ export type CourtDiaristIdentity =
79
79
  export type CourtDiaristInvocationResult = {
80
80
  readonly identity: CourtDiaristIdentity;
81
81
  readonly admitted?: import("./invocation.ts").AdmittedRoleInvocation;
82
+ readonly terminal?: import("./terminal.ts").TerminalResult;
82
83
  readonly failedWithoutEscalate?: { readonly diagnostic: string };
83
84
  };
84
85
 
@@ -161,7 +162,7 @@ export type CourtDiaristSummonEnv = Pick<
161
162
  * - Multiple submissions: last qualifying set wins.
162
163
  * Live path never shape-rejects the role turn; durable damage is a separate seam.
163
164
  */
164
- function courtTicketNumbersFromOutcome(
165
+ export function courtTicketNumbersFromOutcome(
165
166
  roleOutcome: TerminalRoleOutcome | undefined,
166
167
  principalTicket: number,
167
168
  submissions?: readonly unknown[],
@@ -199,7 +200,7 @@ function courtTicketNumbersFromOutcome(
199
200
  }
200
201
 
201
202
  /** Routing boolean over the child's own typed sequence — does not pick or rewrite a sole row. */
202
- function courtDiaristEscalated(
203
+ export function courtDiaristEscalated(
203
204
  roleOutcome: TerminalRoleOutcome | undefined,
204
205
  ): boolean {
205
206
  if (roleOutcome === undefined) return false;
@@ -280,6 +281,7 @@ export async function invokeCourtDiarist(
280
281
  diagnostic: courtDiaristEscalateDiagnostic(roleOutcome, submissions),
281
282
  },
282
283
  ...(result.admitted === undefined ? {} : { admitted: result.admitted }),
284
+ ...(result.terminal === undefined ? {} : { terminal: result.terminal }),
283
285
  };
284
286
  }
285
287
 
@@ -328,14 +330,15 @@ export async function invokeCourtDiarist(
328
330
  * Missing ticketNumber (true-unbound / identity deferred) skips the refresh
329
331
  * station — no diary is minted for a true-unbound run. First-entry identity
330
332
  * lives on the shared countersign entry (typed 起居郎 key for bind). Bound refresh:
331
- * 起居郎 failure propagates (失败诚实).
333
+ * technical failure propagates; LLM escalation remains a recorded conclusion.
332
334
  * Path delivery onto materials is not this station's job — post-admission owns it.
333
335
  */
334
336
  export async function runCountersignCourtDiaristStation(
335
337
  admitted: AdmittedCountersignInvocation,
336
338
  env: CountersignRunEnv,
337
339
  io: CliIo,
338
- ): Promise<void> {
340
+ afterTicketNumber?: number,
341
+ ): Promise<{ readonly terminal: import("./terminal.ts").TerminalResult; readonly childRunId?: string } | undefined> {
339
342
  if (env.runCourtDiaristStation !== undefined) {
340
343
  await env.runCourtDiaristStation(admitted);
341
344
  return;
@@ -362,7 +365,8 @@ export async function runCountersignCourtDiaristStation(
362
365
  refreshTickets = [admitted.ticketNumber];
363
366
  }
364
367
 
365
- for (const ticketNumber of refreshTickets) {
368
+ const completedIndex = afterTicketNumber === undefined ? -1 : refreshTickets.indexOf(afterTicketNumber);
369
+ for (const ticketNumber of refreshTickets.slice(completedIndex + 1)) {
366
370
  const outcome = await invokeCourtDiarist(
367
371
  {
368
372
  instruction: `整理 #${ticketNumber} 的本案依据。`,
@@ -376,13 +380,16 @@ export async function runCountersignCourtDiaristStation(
376
380
  io,
377
381
  );
378
382
 
379
- if (outcome.identity.kind === "escalate") {
380
- throw new StationChildExhaustedError(outcome.identity.diagnostic);
381
- }
382
383
  if (outcome.failedWithoutEscalate !== undefined) {
383
384
  throw new StationChildExhaustedError(
384
385
  outcome.failedWithoutEscalate.diagnostic,
385
386
  );
386
387
  }
388
+ if (outcome.identity.kind === "escalate" && outcome.terminal !== undefined) {
389
+ return {
390
+ terminal: outcome.terminal,
391
+ ...(outcome.admitted === undefined ? {} : { childRunId: outcome.admitted.runId }),
392
+ };
393
+ }
387
394
  }
388
395
  }
@@ -388,9 +388,7 @@ async function runCountersignBody(
388
388
  projectRoot: resolve(parsed.project ?? env.cwd),
389
389
  role: "countersign",
390
390
  parentRunPath: gateParentRunPath,
391
- ...(env.boundTicketNumber === undefined
392
- ? {}
393
- : { ticketNumber: env.boundTicketNumber }),
391
+ ...(isSafePositiveTicketNumber(env.boundTicketNumber) ? { ticketNumber: env.boundTicketNumber } : {}),
394
392
  freshSummons: env.freshSummons,
395
393
  summons: {
396
394
  sourceRunPath: gateParentRunPath,
@@ -430,15 +428,17 @@ async function runCountersignBody(
430
428
  let typedTicket: number | undefined;
431
429
  let typedCourtTicketNumbers: readonly number[] | undefined;
432
430
  let identityDiaristRan = false;
433
- let unboundDiaristRunId: string | undefined;
431
+ let childDiaristRunId: string | undefined;
432
+ let pausedDiaristTerminal: TerminalResult | undefined;
434
433
 
435
- if (env.runCourtDiaristStation === undefined) {
434
+ if (gateParentRunPath === undefined && env.runCourtDiaristStation === undefined) {
436
435
  let outcome: Awaited<ReturnType<typeof invokeCourtDiarist>>;
437
436
  try {
438
437
  outcome = await invokeCourtDiarist({
439
438
  instruction: parsed.instruction ?? "",
440
439
  projectRoot: admitted.projectRoot,
441
440
  failureLabel: "unbound summons",
441
+ correlationId: admitted.runId,
442
442
  ...(env.boundTicketNumber === undefined ? {} : { boundTicketNumber: env.boundTicketNumber }),
443
443
  }, env, io);
444
444
  } catch (error) {
@@ -454,17 +454,16 @@ async function runCountersignBody(
454
454
  }, seatAdapters(admitted, env), env.principalAuthority, io) as SeatRunResult;
455
455
  }
456
456
  identityDiaristRan = true;
457
+ if (outcome.identity.kind === "escalate") pausedDiaristTerminal = outcome.terminal;
457
458
  if (outcome.admitted?.runDirectory.includes(`${sep}unbound${sep}runs${sep}`)) {
458
- unboundDiaristRunId = outcome.admitted.runId;
459
+ childDiaristRunId = outcome.admitted.runId;
459
460
  }
460
- if (outcome.identity.kind === "escalate" || outcome.failedWithoutEscalate !== undefined) {
461
- const diagnostic = outcome.identity.kind === "escalate"
462
- ? outcome.identity.diagnostic
463
- : outcome.failedWithoutEscalate?.diagnostic ?? "";
461
+ if (outcome.failedWithoutEscalate !== undefined) {
462
+ const diagnostic = outcome.failedWithoutEscalate.diagnostic;
464
463
  await materializeAdmission(
465
464
  isSafePositiveTicketNumber(env.boundTicketNumber) ? env.boundTicketNumber : undefined,
466
465
  );
467
- if (unboundDiaristRunId !== undefined) await recordChildDiaristRun(admitted, unboundDiaristRunId);
466
+ if (childDiaristRunId !== undefined) await recordChildDiaristRun(admitted, childDiaristRunId);
468
467
  await markRunAdmitted(admitted, env.principalAuthority);
469
468
  return await presentControlledFailure(admitted, {
470
469
  timedOut: false,
@@ -474,19 +473,20 @@ async function runCountersignBody(
474
473
  }, seatAdapters(admitted, env), env.principalAuthority, io) as SeatRunResult;
475
474
  }
476
475
  if (outcome.identity.kind === "ticket") {
477
- typedTicket = isSafePositiveTicketNumber(env.boundTicketNumber)
478
- ? env.boundTicketNumber
479
- : outcome.identity.ticketNumber;
476
+ typedTicket = outcome.identity.ticketNumber;
480
477
  typedCourtTicketNumbers = outcome.identity.courtTicketNumbers;
481
- } else if (isSafePositiveTicketNumber(env.boundTicketNumber)) {
482
- typedTicket = env.boundTicketNumber;
483
478
  }
484
479
  }
485
480
 
486
481
  await materializeAdmission(typedTicket);
487
482
  await markRunAdmitted(admitted, env.principalAuthority);
488
- if (unboundDiaristRunId !== undefined) {
489
- await recordChildDiaristRun(admitted, unboundDiaristRunId);
483
+ if (childDiaristRunId !== undefined) {
484
+ await recordChildDiaristRun(admitted, childDiaristRunId);
485
+ }
486
+ if (pausedDiaristTerminal !== undefined) {
487
+ // The child already submitted its own pause; no parent turn exists yet.
488
+ io.stdout(formatTerminalResult(pausedDiaristTerminal));
489
+ return { exitCode: 0, admitted, terminal: pausedDiaristTerminal };
490
490
  }
491
491
  if (gateParentRunPath !== undefined) {
492
492
  await persistAdmittedSourceRunPath(admitted, gateParentRunPath);
@@ -540,7 +540,11 @@ async function runCountersignBody(
540
540
  beforeDispatch: async (admittedSeat, lease) => {
541
541
  if (!packagedAdmitsCountersign(admittedSeat.role)) return;
542
542
  if (!identityDiaristRan || typedTicket !== undefined) {
543
- await runCountersignCourtDiaristStation(admittedSeat, env, io);
543
+ const refresh = await runCountersignCourtDiaristStation(admittedSeat, env, io);
544
+ if (refresh !== undefined) {
545
+ io.stdout(formatTerminalResult(refresh.terminal));
546
+ return refresh.terminal;
547
+ }
544
548
  }
545
549
  await relocateAdmittedRunToTicket(admittedSeat, env.principalAuthority, lease);
546
550
  Object.assign(turnRequest, buildInstructionSeatTurnRequest(admittedSeat, turnProjection));
@@ -628,7 +632,6 @@ export async function runPublicInstructionSeat(
628
632
  projectRoot,
629
633
  role,
630
634
  parentRunPath: sourceDirectory,
631
- ...(auditorTicket === undefined ? {} : { ticketNumber: auditorTicket }),
632
635
  freshSummons: env.freshSummons,
633
636
  summons,
634
637
  resume: (runId, materials) => runPublicInstructionSeatResume(
@@ -657,7 +660,6 @@ export async function runPublicInstructionSeat(
657
660
  projectRoot,
658
661
  role,
659
662
  parentRunPath,
660
- ...(env.boundTicketNumber === undefined ? {} : { ticketNumber: env.boundTicketNumber }),
661
663
  freshSummons: env.freshSummons,
662
664
  summons,
663
665
  resume: (runId, materials) => runPublicInstructionSeatResume(
@@ -686,7 +688,6 @@ export async function runPublicInstructionSeat(
686
688
  throw error;
687
689
  }
688
690
  const resumeInstruction = env.reviewReask ?? env.gateReviewInstruction;
689
- const knownTicket = (await readBoardTicketNumber(source.runDirectory)) ?? env.boundTicketNumber;
690
691
  const summons: SameTicketSummonsMaterials = {
691
692
  sourceRunPath: source.runDirectory,
692
693
  sourceRun: source,
@@ -697,7 +698,6 @@ export async function runPublicInstructionSeat(
697
698
  projectRoot,
698
699
  role,
699
700
  parentRunPath: source.runDirectory,
700
- ...(knownTicket === undefined ? {} : { ticketNumber: knownTicket }),
701
701
  freshSummons: env.freshSummons,
702
702
  summons,
703
703
  resume: (runId, materials) => runPublicInstructionSeatResume(
@@ -737,52 +737,33 @@ export async function runPublicInstructionSeat(
737
737
 
738
738
  const runAdmitted = async (): Promise<SeatRunResult> => {
739
739
  await markRunAdmitted(admitted, env.principalAuthority);
740
- if (record.sameParent === "court-diarist") {
741
- const { invokeCourtDiarist } = await import("./countersign-run.ts");
742
- const outcome = await invokeCourtDiarist({
743
- instruction: parsed.instruction ?? "",
744
- projectRoot: admitted.projectRoot,
745
- failureLabel: "secretariat unbound summons",
746
- attachmentPaths: admitted.attachments.map((attachment) => attachment.frozenPath),
747
- correlationId: admitted.runId,
748
- }, {
749
- cwd: env.cwd,
750
- home: env.home,
751
- agentDir: env.agentDir,
752
- packageRoot: env.packageRoot,
753
- ...(env.credentials === undefined ? {} : { credentials: env.credentials }),
754
- ...(env.signal === undefined ? {} : { signal: env.signal }),
755
- ...(env.hostAdapters === undefined ? {} : { hostAdapters: env.hostAdapters }),
756
- }, io);
740
+ if (role === "secretariat") {
741
+ let outcome: Awaited<ReturnType<typeof invokeCourtDiarist>>;
742
+ try {
743
+ outcome = await invokeCourtDiarist({
744
+ instruction: parsed.instruction ?? "",
745
+ projectRoot: admitted.projectRoot,
746
+ failureLabel: "secretariat unbound summons",
747
+ attachmentPaths: admitted.attachments.map((attachment) => attachment.frozenPath),
748
+ correlationId: admitted.runId,
749
+ }, env, io);
750
+ } catch (error) {
751
+ return await presentControlledFailure(admitted, {
752
+ timedOut: false, code: null, stderr: "", thrown: error,
753
+ }, seatAdapters(admitted, env), env.principalAuthority, io) as SeatRunResult;
754
+ }
757
755
  if (outcome.admitted?.runDirectory.includes(`${sep}unbound${sep}runs${sep}`)) {
758
756
  await recordChildDiaristRun(admitted, outcome.admitted.runId);
759
757
  }
760
- if (outcome.identity.kind === "escalate" || outcome.failedWithoutEscalate !== undefined) {
761
- const diagnostic = outcome.identity.kind === "escalate"
762
- ? outcome.identity.diagnostic
763
- : outcome.failedWithoutEscalate?.diagnostic ?? "";
758
+ if (outcome.failedWithoutEscalate !== undefined) {
764
759
  return await presentControlledFailure(admitted, {
765
- timedOut: false,
766
- code: null,
767
- stderr: "",
768
- thrown: new Error(diagnostic),
760
+ timedOut: false, code: null, stderr: "",
761
+ thrown: new Error(outcome.failedWithoutEscalate.diagnostic),
769
762
  }, seatAdapters(admitted, env), env.principalAuthority, io) as SeatRunResult;
770
763
  }
771
- if (outcome.identity.kind === "ticket") {
772
- try {
773
- if (outcome.admitted === undefined) throw new Error("court diarist ticket has no admitted run");
774
- await bindAdmittedTicketNumber(outcome.admitted, outcome.identity.ticketNumber);
775
- await relocateAdmittedRunToTicket(outcome.admitted, env.principalAuthority);
776
- await bindAdmittedTicketNumber(admitted, outcome.identity.ticketNumber);
777
- await relocateAdmittedRunToTicket(admitted, env.principalAuthority);
778
- } catch (error) {
779
- return await presentControlledFailure(admitted, {
780
- timedOut: false,
781
- code: null,
782
- stderr: "",
783
- thrown: error,
784
- }, seatAdapters(admitted, env), env.principalAuthority, io) as SeatRunResult;
785
- }
764
+ if (outcome.identity.kind === "escalate" && outcome.terminal !== undefined) {
765
+ io.stdout(formatTerminalResult(outcome.terminal));
766
+ return { exitCode: 0, admitted, terminal: outcome.terminal };
786
767
  }
787
768
  }
788
769
  return dispatchAdmitted(admitted, env, io);
@@ -856,3 +837,25 @@ export async function runPublicInstructionSeatResume(
856
837
  ...(env.engine === undefined ? {} : { effectiveEngine: env.engine }),
857
838
  });
858
839
  }
840
+
841
+ /** Continue the parent once any escalated child has submitted. */
842
+ export async function continueParentAfterChild(
843
+ parentRunId: string,
844
+ child: AdmittedRoleInvocation,
845
+ env: InstructionSeatRunEnv,
846
+ io: CliIo,
847
+ ): Promise<SeatRunResult> {
848
+ const loaded = await loadResumablePublicRole(env.home, parentRunId, env.principalAuthority, true);
849
+ if (loaded.run.state !== "admitted") {
850
+ return runPublicInstructionSeatResume({ runId: parentRunId }, env, io);
851
+ }
852
+ const admitted = loaded.admitted;
853
+ if (admitted.role === "countersign" && admitted.ticketNumber !== undefined) {
854
+ const refresh = await runCountersignCourtDiaristStation(admitted, env, io, child.ticketNumber);
855
+ if (refresh !== undefined) {
856
+ io.stdout(formatTerminalResult(refresh.terminal));
857
+ return { exitCode: 0, admitted, terminal: refresh.terminal };
858
+ }
859
+ }
860
+ return dispatchAdmitted(admitted, env, io);
861
+ }
@@ -27,6 +27,7 @@ import { resolveBookKeyFromGit } from "../activation-ledger-git.ts";
27
27
  import {
28
28
  ensureRoleRunDirectory,
29
29
  ensureRoleRunPlacement,
30
+ listBookRunDirectories,
30
31
  roleRunArtifactsDirectory,
31
32
  roleRunPlacement,
32
33
  type RoleRunSubject,
@@ -539,19 +540,13 @@ export async function persistAdmittedSourceRunPath(
539
540
  /**
540
541
  * Bind a post-admission resolved ticketNumber onto the in-memory admitted
541
542
  * object and both durable pages (invocation.json + admitted-request.json).
542
- * Used by known-ticket reuse when admission was unbound (#635 / #709).
543
- * Never clears an existing binding.
543
+ * A later typed role receipt replaces an earlier assertion (#1025).
544
544
  */
545
545
  export async function bindAdmittedTicketNumber(
546
546
  admitted: AdmittedRoleInvocation,
547
547
  ticketNumber: number,
548
548
  ): Promise<void> {
549
- if (admitted.ticketNumber !== undefined) {
550
- if (admitted.ticketNumber === ticketNumber) return;
551
- throw new Error(
552
- `bindAdmittedTicketNumber refuses to replace existing ticket #${admitted.ticketNumber} with #${ticketNumber}`,
553
- );
554
- }
549
+ if (admitted.ticketNumber === ticketNumber) return;
555
550
  await bindTicketNumberOnRunDirectory(admitted.runDirectory, ticketNumber);
556
551
  (admitted as { ticketNumber?: number }).ticketNumber = ticketNumber;
557
552
  }
@@ -642,7 +637,7 @@ export async function bindCourtTicketNumbersOnAdmitted(
642
637
  admitted.courtTicketNumbers = frozen;
643
638
  }
644
639
 
645
- /** Move a settled first-entry run from unbound to its asserted ticket directory. */
640
+ /** File a role run under its latest typed ticket identity. */
646
641
  export async function relocateAdmittedRunToTicket(
647
642
  admitted: AdmittedRoleInvocation,
648
643
  authority: DurablePrincipalAuthority,
@@ -668,6 +663,8 @@ export async function relocateAdmittedRunToTicket(
668
663
  for (const childRunId of Array.isArray(childRunIds) ? childRunIds : []) {
669
664
  if (typeof childRunId !== "string") continue;
670
665
  const childDirectory = join(dirname(oldRunDirectory), `${childRunId}@diarist`);
666
+ // A child that already filed under a ticket keeps its own assertion.
667
+ if (!existsSync(childDirectory)) continue;
671
668
  const childTarget = roleRunPlacement(ledgerHome, {
672
669
  bookKey: admitted.bookKey,
673
670
  subject: { ticketNumber: admitted.ticketNumber },
@@ -675,7 +672,6 @@ export async function relocateAdmittedRunToTicket(
675
672
  role: "diarist",
676
673
  });
677
674
  authority.seal(childTarget);
678
- if (!existsSync(childDirectory) && existsSync(childTarget.runDirectory)) continue;
679
675
  await bindTicketNumberOnRunDirectory(childDirectory, admitted.ticketNumber);
680
676
  await rehomeUnboundTicketProvenance(childDirectory, admitted.ticketNumber, admitted.projectRoot, homeFromRunDirectory(oldRunDirectory));
681
677
  ensureRoleRunDirectory(ledgerHome, dirname(childTarget.runDirectory));
@@ -683,14 +679,13 @@ export async function relocateAdmittedRunToTicket(
683
679
  }
684
680
  }
685
681
 
686
- // An identity diarist can file before its caller obtains a ticket. Both its
687
- // own bind and the caller's later bind use this existing relocation seam.
682
+ // The first ticket assignment moves only an unbound diarist's own diary.
688
683
  if (admitted.role === "diarist") {
689
684
  await rehomeUnboundTicketProvenance(oldRunDirectory, admitted.ticketNumber, admitted.projectRoot, homeFromRunDirectory(oldRunDirectory));
690
685
  }
691
686
 
692
- // Rename commits the run placement. Diary assignment above is an idempotent
693
- // Sitian append, not part of an atomic transaction with this directory move.
687
+ // Rename commits the run placement. Diary assignment above is a Sitian append
688
+ // outside the atomic directory move.
694
689
  // Persisted paths are resolved from typed run identity on read.
695
690
  await rename(oldRunDirectory, target.runDirectory);
696
691
 
@@ -752,34 +747,11 @@ export async function bindTicketNumberOnRunDirectory(
752
747
  "bindTicketNumberOnRunDirectory",
753
748
  );
754
749
  const admittedPath = join(runDirectory, "admitted-request.json");
755
- const invocationPath = join(runDirectory, "invocation.json");
756
750
  const admitted = JSON.parse(await readFile(admittedPath, "utf8")) as Record<
757
751
  string,
758
752
  unknown
759
753
  >;
760
- const existing = admitted.ticketNumber;
761
- if (typeof existing === "number") {
762
- if (existing === ticketNumber) return;
763
- throw new Error(
764
- `bindTicketNumberOnRunDirectory refuses to replace existing ticket #${existing} with #${ticketNumber}`,
765
- );
766
- }
767
- // Conflict guard before any write: crash window of bindAdmittedTicketNumber
768
- // can leave invocation bound while admitted-request is still unbound — refuse
769
- // silent rebind. Read-before-merge; never check the page just overwritten.
770
- if (existsSync(invocationPath)) {
771
- const invocation = JSON.parse(
772
- await readFile(invocationPath, "utf8"),
773
- ) as Record<string, unknown>;
774
- if (
775
- typeof invocation.ticketNumber === "number" &&
776
- invocation.ticketNumber !== ticketNumber
777
- ) {
778
- throw new Error(
779
- `bindTicketNumberOnRunDirectory refuses to replace invocation ticket #${invocation.ticketNumber} with #${ticketNumber}`,
780
- );
781
- }
782
- }
754
+ if (admitted.ticketNumber === ticketNumber) return;
783
755
  await writeFile(
784
756
  admittedPath,
785
757
  `${JSON.stringify({ ...admitted, ticketNumber }, null, 2)}\n`,
@@ -223,8 +223,9 @@ function withOnceSuccessfulBeforeDispatch<
223
223
  ...adapters,
224
224
  beforeDispatch: async (admitted, lease) => {
225
225
  if (succeeded) return;
226
- await hook(admitted, lease);
226
+ const result = await hook(admitted, lease);
227
227
  succeeded = true;
228
+ return result;
228
229
  },
229
230
  };
230
231
  }
@@ -352,7 +353,8 @@ export type PostAdmissionAdapters<
352
353
  result: RoleTurnResult;
353
354
  sessionFile: string;
354
355
  }) => Promise<RoleTurnKnownFailure | undefined>;
355
- beforeDispatch?: (admitted: A, lease?: RunWriterLease) => Promise<void> | void;
356
+ /** A child office may pause this run with its unchanged terminal before a host turn starts. */
357
+ beforeDispatch?: (admitted: A, lease?: RunWriterLease) => Promise<T | void> | T | void;
356
358
  /**
357
359
  * After the host turn settles and before any held writer lease is released.
358
360
  * Post-turn bind/relocate (e.g. Diarist first assert) must run here so a
@@ -709,9 +711,8 @@ export async function dispatchPostAdmissionTurn<
709
711
  };
710
712
  }
711
713
  try {
712
- // #858: an unbound seat may assert its ticket on the existing receipt.
713
- // Read the original accepted payload; do not rewrite it, infer from prose,
714
- // or reject missing/malformed declarations. An existing binding wins.
714
+ // #858: the first typed assertion files an unbound run. Later receipts
715
+ // remain untouched; this seam does not adjudicate a ticket change.
715
716
  if (admitted.ticketNumber === undefined) {
716
717
  const rows = await readRecordedSubmissionRows(
717
718
  admitted.projectRoot,
@@ -825,7 +826,10 @@ export async function dispatchPostAdmissionTurn<
825
826
  // (#840 父子不层叠).
826
827
  if (adapters.beforeDispatch !== undefined) {
827
828
  try {
828
- await adapters.beforeDispatch(admitted, lease);
829
+ const paused = await adapters.beforeDispatch(admitted, lease);
830
+ if (paused !== undefined) {
831
+ return { exitCode: 0, admitted, terminal: paused, skipAutoResume: true as const };
832
+ }
829
833
  } catch (error) {
830
834
  const settled = (await presentControlledFailure(
831
835
  admitted,