@akagilnc/pi-workflow-roles 0.1.4620 → 0.1.4641

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 (41) hide show
  1. package/README.zh-CN.md +1 -1
  2. package/dist/acp-host/production-host.js +962 -547
  3. package/dist/activation-ledger-topology.js +0 -4
  4. package/dist/gatekeeper-role.js +76 -6
  5. package/dist/headless-host/production-host.js +923 -508
  6. package/dist/migrate-book-topology.js +9 -0
  7. package/dist/pi/role-turn-host.js +0 -3
  8. package/dist/public-cli/auto-resume.js +26 -0
  9. package/dist/public-cli/countersign-run.js +31 -10
  10. package/dist/public-cli/main.js +1456 -1068
  11. package/dist/public-cli/post-admission.js +101 -7
  12. package/dist/public-cli/process-cancel.js +57 -0
  13. package/dist/public-cli/settlement.js +95 -2
  14. package/dist/public-role-summons.js +19 -0
  15. package/dist/secretariat-contracts.js +4 -0
  16. package/package.json +1 -1
  17. package/scripts/build-package.mjs +0 -1
  18. package/src/activation-ledger-topology.ts +1 -6
  19. package/src/activation-ledger.ts +5 -236
  20. package/src/analyst-entry.ts +19 -2
  21. package/src/analyst-ledger.ts +111 -6
  22. package/src/archivist-record-entry.ts +3 -3
  23. package/src/audit-escalation.ts +1 -1
  24. package/src/gatekeeper-pass-envelope.ts +37 -5
  25. package/src/gatekeeper-role.ts +125 -20
  26. package/src/host-contracts.ts +22 -2
  27. package/src/pi/adapter.ts +5 -1
  28. package/src/pi/role-turn-host.ts +0 -3
  29. package/src/public-cli/auto-resume.ts +34 -0
  30. package/src/public-cli/cli.ts +30 -0
  31. package/src/public-cli/countersign-run.ts +50 -13
  32. package/src/public-cli/main.ts +25 -3
  33. package/src/public-cli/post-admission.ts +173 -6
  34. package/src/public-cli/process-cancel.ts +75 -0
  35. package/src/public-cli/settlement.ts +117 -1
  36. package/src/public-role-summons.ts +30 -4
  37. package/src/role-envelope.ts +27 -10
  38. package/src/role-runtime.ts +146 -55
  39. package/src/secretariat-contracts.ts +18 -0
  40. package/dist/activation-reconciliation.js +0 -61
  41. package/src/activation-reconciliation.ts +0 -163
@@ -12,30 +12,49 @@ import {
12
12
  } from "./package-contracts/gatekeeper-output.ts";
13
13
  import type { PublicSummonResult } from "./public-role-summons.ts";
14
14
  import type { TerminalResult } from "./public-cli/terminal.ts";
15
+ import { runIdFromRunDirectory } from "./run-terminal-artifacts.ts";
15
16
  export const INSPECTOR_OUTPUT_TOOL = INSPECTOR_OUTPUT_TOOL_NAME;
16
17
  export const NOTARY_OUTPUT_TOOL = "ak_notary_output";
17
18
 
18
- /** Gate review officers — 台院 / 符宝郎 / 审刑院 (#753 / #756). */
19
- export type GateOfficer = "inspector" | "notary" | "auditor";
19
+ /** Gate review officers — 台院 / 符宝郎 / 审刑院 / 给事中 (#753 / #756 / #969). */
20
+ export type GateOfficer = "inspector" | "notary" | "auditor" | "countersign";
20
21
 
21
22
  /** Officer routing only — content is self-fetched via the shared run-dossier tool (#632). */
22
23
  export type GatekeeperSubject =
23
24
  | { readonly kind: "worker_completion" }
24
25
  | { readonly kind: "judge_draft" }
25
26
  | { readonly kind: "judge_compliance" }
26
- | { readonly kind: "countersign_verdict" };
27
+ | { readonly kind: "countersign_verdict" }
28
+ | { readonly kind: "secretariat_verdict" };
27
29
 
28
30
  /**
29
31
  * Gate projection for the review queue (#753 / #750).
30
32
  * Code only reads the conclusion field for queueing. Officer words ride as
31
33
  * `receipt` unchanged — no findings rewrite, no unreadable/unusable label,
32
34
  * no next-step selection for the parent.
35
+ * `runId` is the nested officer run id when known (summoned.runDirectory /
36
+ * terminal.runId) — public terminal projection consumes it (#969).
33
37
  */
34
38
  export type GatekeeperResult =
35
- | { readonly status: "pass"; readonly officer: GateOfficer; readonly receipt: unknown }
39
+ | {
40
+ readonly status: "pass";
41
+ readonly officer: GateOfficer;
42
+ readonly receipt: unknown;
43
+ readonly runId?: string;
44
+ }
36
45
  /** bounce | escalate: both return the officer receipt to the parent (#753 / #756). */
37
- | { readonly status: "bounce"; readonly officer: GateOfficer; readonly receipt: unknown }
38
- | { readonly status: "escalate"; readonly officer: GateOfficer; readonly receipt: unknown }
46
+ | {
47
+ readonly status: "bounce";
48
+ readonly officer: GateOfficer;
49
+ readonly receipt: unknown;
50
+ readonly runId?: string;
51
+ }
52
+ | {
53
+ readonly status: "escalate";
54
+ readonly officer: GateOfficer;
55
+ readonly receipt: unknown;
56
+ readonly runId?: string;
57
+ }
39
58
  | {
40
59
  /**
41
60
  * Accepted reply whose conclusion is not pass|bounce|escalate.
@@ -45,6 +64,7 @@ export type GatekeeperResult =
45
64
  readonly status: "needs_reask";
46
65
  readonly officer: GateOfficer;
47
66
  readonly receipt: unknown;
67
+ readonly runId?: string;
48
68
  }
49
69
  | { readonly status: "no_receipt"; readonly stage: GateOfficer; readonly reason: string; readonly facts: NoReceiptLifecycleFacts }
50
70
  | {
@@ -64,13 +84,15 @@ export type GatekeeperNonPassResult = Extract<
64
84
  function gateSeatLabel(stage: GateOfficer): string {
65
85
  if (stage === "inspector") return "台院";
66
86
  if (stage === "auditor") return "审刑院";
87
+ if (stage === "countersign") return "给事中";
67
88
  return "符宝郎";
68
89
  }
69
90
 
70
- /** Subject kind → review officer (#753 countersign/notary, #756 judge/auditor + worker/inspector). */
91
+ /** Subject kind → review officer (#753 countersign/notary, #756 judge/auditor + worker/inspector, #969 secretariat/countersign). */
71
92
  export function gateOfficerForSubject(subject: GatekeeperSubject): GateOfficer {
72
93
  if (subject.kind === "worker_completion") return "inspector";
73
94
  if (subject.kind === "judge_compliance") return "auditor";
95
+ if (subject.kind === "secretariat_verdict") return "countersign";
74
96
  return "notary";
75
97
  }
76
98
 
@@ -183,6 +205,14 @@ function readRecord(value: unknown): Record<string, unknown> | undefined {
183
205
  * `fallbackStatus` is the terminal outcome.status when the receipt body has no status key
184
206
  * (keeps missing-args sentinel intact as the receipt).
185
207
  */
208
+ /** Map 给事中 countersignStatus onto the shared gate queue words (#969). */
209
+ function gateStatusFromCountersign(status: string): string | undefined {
210
+ if (status === "converged") return "pass";
211
+ if (status === "continue") return "bounce";
212
+ if (status === "escalate") return "escalate";
213
+ return undefined;
214
+ }
215
+
186
216
  function projectOfficerDecision(
187
217
  officer: GateOfficer,
188
218
  decision: unknown,
@@ -190,6 +220,26 @@ function projectOfficerDecision(
190
220
  ): GatekeeperResult {
191
221
  const receipt = retainedReceipt(decision);
192
222
  const record = readRecord(decision);
223
+ // 给事中: only countersignStatus, strict three-word map; never generic status,
224
+ // never unmapped raw word, never fallbackStatus (#969 / ADR 0040/0055).
225
+ if (officer === "countersign") {
226
+ const countersignStatus =
227
+ record !== undefined && typeof record.countersignStatus === "string"
228
+ ? record.countersignStatus
229
+ : undefined;
230
+ const status =
231
+ typeof countersignStatus === "string"
232
+ ? gateStatusFromCountersign(countersignStatus)
233
+ : undefined;
234
+ if (status === "pass") {
235
+ return { status: "pass", officer, receipt };
236
+ }
237
+ if (status === "bounce" || status === "escalate") {
238
+ return { status, officer, receipt };
239
+ }
240
+ return { status: "needs_reask", officer, receipt };
241
+ }
242
+ // inspector / notary / auditor: shared gate words on generic status.
193
243
  const status =
194
244
  (record !== undefined && typeof record.status === "string" ? record.status : undefined)
195
245
  ?? fallbackStatus;
@@ -240,6 +290,41 @@ function projectOfficerPayloads(
240
290
  return projectOfficerDecision(officer, payloads[payloads.length - 1], fallbackStatus);
241
291
  }
242
292
 
293
+ /** Nested officer runId from summoned.runDirectory, else terminal.runId (#969). */
294
+ export function officerRunIdFromSummoned(summoned: PublicSummonResult): string | undefined {
295
+ if (typeof summoned.runDirectory === "string" && summoned.runDirectory.trim() !== "") {
296
+ const fromDir = runIdFromRunDirectory(summoned.runDirectory);
297
+ if (fromDir !== undefined) return fromDir;
298
+ }
299
+ const terminal = summoned.terminal;
300
+ if (
301
+ terminal !== undefined
302
+ && typeof terminal.runId === "string"
303
+ && terminal.runId.trim() !== ""
304
+ ) {
305
+ return terminal.runId;
306
+ }
307
+ return undefined;
308
+ }
309
+
310
+ function withOfficerRunId(
311
+ result: GatekeeperResult,
312
+ summoned: PublicSummonResult,
313
+ ): GatekeeperResult {
314
+ if (
315
+ result.status !== "pass"
316
+ && result.status !== "bounce"
317
+ && result.status !== "escalate"
318
+ && result.status !== "needs_reask"
319
+ ) {
320
+ return result;
321
+ }
322
+ if (typeof result.runId === "string" && result.runId.trim() !== "") return result;
323
+ const runId = officerRunIdFromSummoned(summoned);
324
+ if (runId === undefined) return result;
325
+ return { ...result, runId };
326
+ }
327
+
243
328
  function projectOfficerTerminal(
244
329
  officer: GateOfficer,
245
330
  summoned: PublicSummonResult,
@@ -279,25 +364,34 @@ function projectOfficerTerminal(
279
364
  };
280
365
  }
281
366
  if (outcome.kind === "audit_escalation") {
282
- return {
283
- status: "escalate",
284
- officer,
285
- // This-court receipt only (#879) — historical rows remain on terminal.submissions.
286
- receipt: thisCourt.length > 0 ? thisCourt[thisCourt.length - 1] : retainedReceipt(outcome),
287
- };
367
+ return withOfficerRunId(
368
+ {
369
+ status: "escalate",
370
+ officer,
371
+ // This-court receipt only (#879) — historical rows remain on terminal.submissions.
372
+ receipt: thisCourt.length > 0 ? thisCourt[thisCourt.length - 1] : retainedReceipt(outcome),
373
+ },
374
+ summoned,
375
+ );
288
376
  }
289
377
  if (outcome.kind === "accepted") {
290
378
  // outcome.status is the fixture/compat leaf: production settlement leaves
291
379
  // it undefined once payloads are recorded, so this only matters when a
292
380
  // caller still supplies status without any recorded payload (#836 hang).
293
- return projectOfficerPayloads(officer, thisCourt, outcome.status);
381
+ return withOfficerRunId(
382
+ projectOfficerPayloads(officer, thisCourt, outcome.status),
383
+ summoned,
384
+ );
294
385
  }
295
- return {
296
- status: "needs_reask",
297
- officer,
298
- // This-court receipt only (#879).
299
- receipt: thisCourt.length > 0 ? thisCourt[thisCourt.length - 1] : retainedReceipt(outcome),
300
- };
386
+ return withOfficerRunId(
387
+ {
388
+ status: "needs_reask",
389
+ officer,
390
+ // This-court receipt only (#879).
391
+ receipt: thisCourt.length > 0 ? thisCourt[thisCourt.length - 1] : retainedReceipt(outcome),
392
+ },
393
+ summoned,
394
+ );
301
395
  }
302
396
 
303
397
  /** Projection carrier for the shared submit envelope (ADR 0018). No lifecycle book here. */
@@ -311,10 +405,21 @@ export type GatekeeperProjection = {
311
405
  /**
312
406
  * Plain-language re-ask when the officer conclusion is not pass|bounce|escalate.
313
407
  * Not a packaged engine handbook line (#755 exception for 读不出三态).
408
+ * inspector / notary / auditor keep shared gate words; 给事中 uses own three-state
409
+ * field and words so reask does not invite non-contract values (#969 / ADR 0055).
314
410
  */
315
411
  export const OFFICER_CONCLUSION_REASK =
316
412
  "上次交卷的结论不是 pass、bounce、escalate 三态之一。请重新输出,结论字段写明其一;打回或上呈的话就是给对方看的原文。" as const;
317
413
 
414
+ /** 给事中 re-ask: countersignStatus converged|continue|escalate only (#969). */
415
+ export const COUNTERSIGN_CONCLUSION_REASK =
416
+ "上次交卷的 countersignStatus 不是 converged、continue、escalate 三态之一。请重新输出,countersignStatus 写明其一;封驳或上呈的话就是给对方看的原文。" as const;
417
+
418
+ /** Pick the re-ask line for the officer under review. */
419
+ export function officerConclusionReask(officer: GateOfficer): string {
420
+ return officer === "countersign" ? COUNTERSIGN_CONCLUSION_REASK : OFFICER_CONCLUSION_REASK;
421
+ }
422
+
318
423
  /**
319
424
  * Summon (via injected seam) + project. Default summonGateOfficer drive lives
320
425
  * on the shared envelope (`gatekeeper-pass-envelope.ts`, ADR 0018 / #675) —
@@ -337,7 +337,12 @@ type HostEventHandler<K extends keyof HostEventMap> = (event: HostEventMap[K], c
337
337
  export type HostEventRegistration = { [K in keyof HostEventMap]: [event: K, handler: HostEventHandler<K>] }[keyof HostEventMap];
338
338
 
339
339
  type HostGatekeeperSubject = {
340
- readonly kind: "worker_completion" | "judge_draft" | "judge_compliance" | "countersign_verdict";
340
+ readonly kind:
341
+ | "worker_completion"
342
+ | "judge_draft"
343
+ | "judge_compliance"
344
+ | "countersign_verdict"
345
+ | "secretariat_verdict";
341
346
  };
342
347
  /** Gatekeeper non-pass faces returned to parent (#836 includes transport_failure; never kill leg). */
343
348
  type HostGatekeeperNonPass = { readonly status: "bounce" | "escalate" | "no_receipt" | "transport_failure" } & Record<string, unknown>;
@@ -388,7 +393,22 @@ export interface RoleHost {
388
393
  getAllTools(): Array<{ name: string; sourceInfo?: { path?: string } }>;
389
394
  setActiveTools(names: string[]): void;
390
395
  getActiveTools(): string[];
391
- requireGatekeeperPass?(options: { context: HostContext; subject: HostGatekeeperSubject; signal?: AbortSignal; hostActions: HostGatekeeperActions; toolCallId: string; submission?: unknown }): Promise<void>;
396
+ /**
397
+ * Shared gate envelope. On pass may return officer snapshot (receipt + nested
398
+ * runId) for seat public-terminal projection (#969); callers may ignore it.
399
+ */
400
+ requireGatekeeperPass?(options: {
401
+ context: HostContext;
402
+ subject: HostGatekeeperSubject;
403
+ signal?: AbortSignal;
404
+ hostActions: HostGatekeeperActions;
405
+ toolCallId: string;
406
+ submission?: unknown;
407
+ }): Promise<void | {
408
+ readonly officer: string;
409
+ readonly receipt: unknown;
410
+ readonly runId?: string;
411
+ }>;
392
412
  on(event: "before_agent_start", handler: HostEventHandler<"before_agent_start">): void;
393
413
  on(event: "input", handler: HostEventHandler<"input">): void;
394
414
  on(event: "tool_call", handler: HostEventHandler<"tool_call">): void;
package/src/pi/adapter.ts CHANGED
@@ -92,7 +92,11 @@ function requirePiGatekeeperPass(options: {
92
92
  hostActions: HostGatekeeperActions;
93
93
  toolCallId: string;
94
94
  submission?: unknown;
95
- }): Promise<void> {
95
+ }): Promise<void | {
96
+ readonly officer: string;
97
+ readonly receipt: unknown;
98
+ readonly runId?: string;
99
+ }> {
96
100
  return requireGatekeeperPass({
97
101
  context: options.context,
98
102
  subject: options.subject,
@@ -470,9 +470,6 @@ export function createPiRoleTurnHost(config: PiRoleTurnHostConfig): RoleTurnHost
470
470
  ) {
471
471
  env.AK_ROLE_AUDITOR_SUBJECT = process.env.AK_ROLE_AUDITOR_SUBJECT;
472
472
  }
473
- if (request.correlationId !== undefined && request.correlationId.trim() !== "") {
474
- env.AK_CORRELATION_ID = request.correlationId;
475
- }
476
473
  if (
477
474
  config.recordLaunchedRolePackageIdentity !== undefined &&
478
475
  config.observeLaunchedRolePackageIdentity !== undefined
@@ -32,6 +32,7 @@ import {
32
32
  type RunWriterLease,
33
33
  } from "./run-lifecycle.ts";
34
34
  import { parseAutoResumeLimit } from "./config.ts";
35
+ import { processCancelSignalName } from "./process-cancel.ts";
35
36
  import { isLawfulTypedTerminalOutcome, formatTerminalResult, type TerminalArtifactRef, type TerminalResult, type TerminalRoleName } from "./terminal.ts";
36
37
  import {
37
38
  attachRecordedSubmissions,
@@ -473,6 +474,11 @@ export async function runWithAutoResumeLoop<
473
474
  * before the first dispatch instead of silently bypassing the ceiling comparison.
474
475
  */
475
476
  autoResumeLimit?: number | undefined;
477
+ /**
478
+ * #855: process-cancel signal. Once aborted by a catchable process signal,
479
+ * the loop must not dispatch another turn or spawn another child.
480
+ */
481
+ signal?: AbortSignal;
476
482
  buildInitialPayload: () => TPayload;
477
483
  buildResumePayload: () => TPayload;
478
484
  dispatch: (payload: TPayload, lease: RunWriterLease, isFirst: boolean, attemptIo: CliIo) => Promise<T>;
@@ -578,6 +584,11 @@ export async function runWithAutoResumeLoop<
578
584
  if (terminal !== undefined) presentTerminal(terminal, options.io);
579
585
  return result;
580
586
  }
587
+ // #855: process cancel — stop even if dispatch forgot skipAutoResume.
588
+ if (processCancelSignalName(options.signal) !== undefined) {
589
+ if (terminal !== undefined) presentTerminal(terminal, options.io);
590
+ return result;
591
+ }
581
592
  }
582
593
 
583
594
  if (result !== undefined) {
@@ -616,6 +627,29 @@ export async function runWithAutoResumeLoop<
616
627
  terminal,
617
628
  } as T;
618
629
  }
630
+ // #855: process cancel on the throw path — do not re-dispatch; name the signal.
631
+ const cancelName = processCancelSignalName(options.signal);
632
+ if (cancelName !== undefined) {
633
+ const terminal = await attachDispatchExceptionTerminal(
634
+ options.admitted,
635
+ dispatchExceptionFailureTerminal({
636
+ role: options.admitted.role,
637
+ runId: options.admitted.runId,
638
+ causeError: lastThrownError,
639
+ errorFiles: retainedErrorFiles,
640
+ autoResumeAttempts,
641
+ endReason: `ak-role terminated by ${cancelName}`,
642
+ everyAttemptThrew,
643
+ }),
644
+ options.io,
645
+ );
646
+ await finalizeExceptionRunBestEffort(options.admitted.runDirectory, options.io);
647
+ presentTerminal(terminal, options.io);
648
+ return {
649
+ exitCode: 1,
650
+ terminal,
651
+ } as T;
652
+ }
619
653
  if (!(await isPrincipalAvailable(options.admitted.principal))) {
620
654
  const terminal = await attachDispatchExceptionTerminal(
621
655
  options.admitted,
@@ -305,6 +305,11 @@ export type CliEnv = {
305
305
  * skip same-ticket auto-resume. Not a public flag — the verb is the choice.
306
306
  */
307
307
  freshSummons?: true;
308
+ /**
309
+ * #855: process-level cancel (SIGTERM/SIGINT/SIGHUP). Nested turns receive
310
+ * the same signal so hosts can gracefully stop children.
311
+ */
312
+ signal?: AbortSignal;
308
313
  };
309
314
 
310
315
 
@@ -413,6 +418,7 @@ function createRoleEnvironment(
413
418
  ? {}
414
419
  : { autoResumeLimit: options.config.autoResumeLimit }),
415
420
  ...(env.freshSummons === true ? { freshSummons: true as const } : {}),
421
+ ...(env.signal === undefined ? {} : { signal: env.signal }),
416
422
  };
417
423
  }
418
424
 
@@ -1168,6 +1174,30 @@ async function runConfigCommand(
1168
1174
  throw new CliUsageError(`unknown config subcommand: ${args[0]}`);
1169
1175
  }
1170
1176
 
1177
+ /**
1178
+ * #855: process-cancel handlers only for commands that run a role turn
1179
+ * (callable roles / resume / new). analyst/roles/config/help keep Node's
1180
+ * default SIGINT/SIGTERM termination — installing handlers would swallow Ctrl+C.
1181
+ */
1182
+ export function commandNeedsProcessCancel(argv: readonly string[]): boolean {
1183
+ // Decision-time parse must not escape the CLI usage boundary. Malformed
1184
+ // global flags throw CliUsageError here; return false and let runAkRole
1185
+ // present the same structured usage reject (exit 2). Handlers are moot
1186
+ // when parse fails — install-or-not has no subsequent effect.
1187
+ let parsed: ParsedGlobal;
1188
+ try {
1189
+ parsed = parseArgv(argv);
1190
+ } catch (error) {
1191
+ if (error instanceof CliUsageError) return false;
1192
+ throw error;
1193
+ }
1194
+ if (parsed.help || parsed.command === undefined || parsed.command === "help") {
1195
+ return false;
1196
+ }
1197
+ if (parsed.command === "resume" || parsed.command === "new") return true;
1198
+ return isPublicCallableRole(parsed.command);
1199
+ }
1200
+
1171
1201
  export async function runAkRole(
1172
1202
  argv: readonly string[],
1173
1203
  env: CliEnv,
@@ -88,6 +88,22 @@ export type CountersignRunEnv = PostAdmissionEnv & {
88
88
  * replace — never union. Production leaves unset outside that handoff.
89
89
  */
90
90
  pendingCourtTicketNumbers?: readonly number[];
91
+ /**
92
+ * #969 gate path: plain-language re-ask when prior 给事中 reply was not three-state.
93
+ * Wins over gateReviewInstruction when both present (notary/inspector precedent).
94
+ */
95
+ reviewReask?: string;
96
+ /**
97
+ * #969/#879 Secretariat submission-gate body: verbatim parent payload as dialogue
98
+ * content when reviewReask is absent. Binding stays ticket / argv instruction.
99
+ */
100
+ gateReviewInstruction?: string;
101
+ /**
102
+ * #969 gate path: parent run durable board ticket handoff. Same-ticket resume
103
+ * key and identity 起居郎 bind under this key — never re-derived from receipt
104
+ * ticketNumber (optional / omit-able payload field).
105
+ */
106
+ boundTicketNumber?: number;
91
107
  };
92
108
 
93
109
  /** Project admitted invocation onto the host-neutral turn request. */
@@ -470,6 +486,10 @@ export async function runPublicCountersign(
470
486
  instruction: parsed.instruction,
471
487
  projectRoot: admitted.projectRoot,
472
488
  failureLabel: "unbound summons",
489
+ // #969: parent durable ticket is the resume/bind key (ADR 0079).
490
+ ...(env.boundTicketNumber === undefined
491
+ ? {}
492
+ : { boundTicketNumber: env.boundTicketNumber }),
473
493
  },
474
494
  env,
475
495
  io,
@@ -528,16 +548,30 @@ export async function runPublicCountersign(
528
548
  }
529
549
 
530
550
  // Missing lawful 起居郎 terminal is not countersign body failure: leave
531
- // unbound and continue (true-unbound face). Escalate / typed failure above;
532
- // bound refresh still fails honest via runCountersignCourtDiaristStation.
551
+ // unbound and continue (true-unbound face) — unless parent gate handoff
552
+ // already carries the durable board ticket (#969). Escalate / typed
553
+ // failure above; bound refresh still fails honest via station.
533
554
  if (outcome.identity.kind === "ticket") {
534
- const assertedTicket = outcome.identity.ticketNumber;
535
- typedTicket = assertedTicket;
555
+ // #969: parent durable handoff wins over 起居郎 re-assert when both
556
+ // present (receipt/assert mismatch → parent resume key).
557
+ typedTicket = isSafePositiveTicketNumber(env.boundTicketNumber)
558
+ ? env.boundTicketNumber
559
+ : outcome.identity.ticketNumber;
536
560
  // #871: identity may hand a co-review set; absent field defaults to [main].
537
561
  typedCourtTicketNumbers = outcome.identity.courtTicketNumbers;
562
+ } else if (isSafePositiveTicketNumber(env.boundTicketNumber)) {
563
+ // Parent board handoff present; 起居郎 returned true-unbound — still
564
+ // resume/bind under the parent key (omitted receipt ticketNumber path).
565
+ typedTicket = env.boundTicketNumber;
566
+ }
567
+ if (typedTicket !== undefined) {
568
+ // #969/#879: gate re-ask / parent submission body share summons.instruction
569
+ // (reask wins; argv instruction remains the 起居郎 identity face above).
570
+ const resumeInstruction =
571
+ env.reviewReask ?? env.gateReviewInstruction ?? parsed.instruction;
538
572
  const summons: SameTicketSummonsMaterials = {
539
- instruction: parsed.instruction,
540
- instructionEmpty: parsed.instruction.trim() === "",
573
+ instruction: resumeInstruction,
574
+ instructionEmpty: resumeInstruction.trim() === "",
541
575
  };
542
576
  const resumed = await tryResumeSameTicketSeatRun({
543
577
  home: env.home,
@@ -641,13 +675,16 @@ export async function runPublicCountersign(
641
675
  : { correlationId: env.correlationId }),
642
676
  continuation: {
643
677
  kind: "initial",
644
- prompt: buildCountersignTransportPrompt(
645
- admitted,
646
- engineSessionMaterialFromOptions({
647
- ...pickEngineAxis(env),
648
- packageRoot: env.packageRoot,
649
- }),
650
- ),
678
+ // #969/#879: gate path first mint carries parent payload as dialogue content;
679
+ // ordinary public entry keeps package transport prompt.
680
+ prompt: (env.reviewReask ?? env.gateReviewInstruction)
681
+ ?? buildCountersignTransportPrompt(
682
+ admitted,
683
+ engineSessionMaterialFromOptions({
684
+ ...pickEngineAxis(env),
685
+ packageRoot: env.packageRoot,
686
+ }),
687
+ ),
651
688
  },
652
689
  };
653
690
  // Mutable shell: ticket bind re-projects activation before executeTurn.
@@ -34,6 +34,28 @@ process.env.AK_ROLE_PACKAGE_ROOT = packageRoot;
34
34
  // The host-provided runtime must be resolvable before the CLI module graph loads it.
35
35
  ensureHostPiRuntimeResolvable(packageRoot);
36
36
 
37
- const { runAkRole } = await import("./cli.ts");
38
- const result = await runAkRole(process.argv.slice(2), { packageRoot });
39
- process.exitCode = result.exitCode;
37
+ const { runAkRole, commandNeedsProcessCancel } = await import("./cli.ts");
38
+ const { installProcessCancelHandlers } = await import("./process-cancel.ts");
39
+
40
+ // #855: catchable signals only for role-turn commands. analyst/roles/config/help
41
+ // keep Node default termination — global handlers would swallow Ctrl+C.
42
+ const argv = process.argv.slice(2);
43
+ const processCancel = commandNeedsProcessCancel(argv)
44
+ ? installProcessCancelHandlers()
45
+ : undefined;
46
+ try {
47
+ const result = await runAkRole(argv, {
48
+ packageRoot,
49
+ ...(processCancel === undefined ? {} : { signal: processCancel.signal }),
50
+ });
51
+ // Signal termination is never a successful public exit, even if a seat
52
+ // somehow returned zero before settlement folded the abort.
53
+ process.exitCode =
54
+ processCancel !== undefined &&
55
+ processCancel.receivedSignal() !== undefined &&
56
+ result.exitCode === 0
57
+ ? 1
58
+ : result.exitCode;
59
+ } finally {
60
+ processCancel?.dispose();
61
+ }