@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
@@ -87,6 +87,11 @@ import {
87
87
  type TypedProviderHttpObservation,
88
88
  type WriterLeaseDiagnosticKind,
89
89
  } from "./run-lifecycle.ts";
90
+ import {
91
+ processCancelDiagnostic,
92
+ processCancelSignalName,
93
+ type CatchableProcessSignal,
94
+ } from "./process-cancel.ts";
90
95
  import { homeFromRunDirectory } from "../activation-ledger-topology.ts";
91
96
  import {
92
97
  attemptProducedFreshSubmission,
@@ -119,6 +124,63 @@ import {
119
124
  TurnDispatchedFailure,
120
125
  } from "./auto-resume.ts";
121
126
 
127
+ /** #855: process-cancel settlement never re-enters auto-resume. */
128
+ function withProcessCancelSkipAutoResume<T extends object>(
129
+ result: T,
130
+ signal: AbortSignal | undefined,
131
+ ): T & { skipAutoResume?: true } {
132
+ if (processCancelSignalName(signal) === undefined) {
133
+ return result as T & { skipAutoResume?: true };
134
+ }
135
+ return { ...result, skipAutoResume: true as const };
136
+ }
137
+
138
+ /**
139
+ * #855: turn already started, then a catchable process signal arrived before
140
+ * lawful seal — settle as named cancel failure and skip auto-resume.
141
+ */
142
+ async function settleProcessCancelAfterTurn<A extends AdmittedRoleInvocation, T extends TerminalResult>(input: {
143
+ admitted: A;
144
+ cancelName: CatchableProcessSignal;
145
+ result: RoleTurnResult;
146
+ adapters: PostAdmissionAdapters<A, T>;
147
+ env: PostAdmissionEnv;
148
+ io: CliIo;
149
+ persistRunState: boolean;
150
+ deferredPersist: { needsPersist?: true } | Record<string, never>;
151
+ invocationScopeId: string | undefined;
152
+ }): Promise<{
153
+ exitCode: number;
154
+ admitted: A;
155
+ terminal?: T;
156
+ skipAutoResume?: true;
157
+ turnDispatched?: true;
158
+ needsPersist?: true;
159
+ }> {
160
+ const failed = await settleAfterTurnStarted(
161
+ input.admitted,
162
+ withEngineDetourInvocationScope(
163
+ {
164
+ timedOut: input.result.timedOut,
165
+ code: input.result.code,
166
+ stderr: input.result.stderr,
167
+ knownDiagnostic: processCancelDiagnostic(input.cancelName),
168
+ },
169
+ input.invocationScopeId,
170
+ ),
171
+ input.adapters,
172
+ input.env.principalAuthority,
173
+ input.io,
174
+ input.persistRunState,
175
+ );
176
+ return {
177
+ ...failed,
178
+ turnDispatched: true as const,
179
+ skipAutoResume: true as const,
180
+ ...input.deferredPersist,
181
+ };
182
+ }
183
+
122
184
  /**
123
185
  * Nested station-child role already finished its own call-local loop.
124
186
  * Parent records that failure once and must not auto-resume into a re-summon
@@ -587,6 +649,38 @@ export async function dispatchPostAdmissionTurn<
587
649
  const finishAfterTurn = async (result: DispatchOutcome): Promise<DispatchOutcome> => {
588
650
  if (afterDispatchApplied) return result;
589
651
  afterDispatchApplied = true;
652
+ // #855: re-read cancel before afterDispatch finalization — never present
653
+ // lawful success after a catchable process signal. skipRunStateWrite: run-state
654
+ // may already be terminal from a prior lawful persist; do not reopen as resumable.
655
+ const cancelBeforeFinish = processCancelSignalName(env.signal);
656
+ if (
657
+ cancelBeforeFinish !== undefined &&
658
+ result.terminal !== undefined &&
659
+ isLawfulTypedTerminalOutcome(result.terminal.roleOutcome)
660
+ ) {
661
+ result = {
662
+ ...(await settleAfterTurnStarted(
663
+ admitted,
664
+ withEngineDetourInvocationScope(
665
+ {
666
+ timedOut: false,
667
+ code: null,
668
+ stderr: "",
669
+ knownDiagnostic: processCancelDiagnostic(cancelBeforeFinish),
670
+ skipRunStateWrite: true,
671
+ },
672
+ request.invocationScopeId,
673
+ ),
674
+ adapters,
675
+ env.principalAuthority,
676
+ io,
677
+ persistRunState,
678
+ )),
679
+ turnDispatched: true as const,
680
+ skipAutoResume: true as const,
681
+ ...deferredPersist,
682
+ };
683
+ }
590
684
  try {
591
685
  // #858: an unbound seat may assert its ticket on the existing receipt.
592
686
  // Read the original accepted payload; do not rewrite it, infer from prose,
@@ -788,20 +882,29 @@ export async function dispatchPostAdmissionTurn<
788
882
  try {
789
883
  result = await env.roleTurnHost.executeTurn(turnRequest);
790
884
  } catch (error) {
885
+ const processCancelName = processCancelSignalName(env.signal);
791
886
  const settled = await settleAfterTurnStarted(
792
887
  admitted,
793
888
  withEngineDetourInvocationScope({
794
889
  timedOut: false,
795
890
  code: null,
796
891
  stderr: "",
797
- thrown: error,
892
+ thrown:
893
+ processCancelName === undefined
894
+ ? error
895
+ : new Error(processCancelDiagnostic(processCancelName), { cause: error }),
798
896
  }, request.invocationScopeId),
799
897
  adapters,
800
898
  env.principalAuthority,
801
899
  io,
802
900
  persistRunState,
803
901
  );
804
- return await finishAfterTurn({ ...settled, turnDispatched: true as const, ...deferredPersist });
902
+ return await finishAfterTurn(
903
+ withProcessCancelSkipAutoResume(
904
+ { ...settled, turnDispatched: true as const, ...deferredPersist },
905
+ env.signal,
906
+ ),
907
+ );
805
908
  }
806
909
 
807
910
  // `result.stderr` stays live in memory for the real classification below
@@ -917,11 +1020,13 @@ export async function dispatchPostAdmissionTurn<
917
1020
  // retroactively invalidate an acceptance that already sealed this turn
918
1021
  // — a resolved 429 observed earlier in the same session is exactly
919
1022
  // that.
1023
+ const processCancelName = processCancelSignalName(env.signal);
920
1024
  const directHostFailureSignal =
921
1025
  result.timedOut
922
1026
  || result.knownFailure !== undefined
923
1027
  || runnerKnownFailure !== undefined
924
- || credentialFailure !== undefined;
1028
+ || credentialFailure !== undefined
1029
+ || processCancelName !== undefined;
925
1030
  hostSignalFailed =
926
1031
  directHostFailureSignal
927
1032
  || (result.code !== null && result.code !== 0)
@@ -959,12 +1064,16 @@ export async function dispatchPostAdmissionTurn<
959
1064
  // where a bare nonzero exit / resolution failure must not be outranked
960
1065
  // by someone else's earlier success (#836 r12 class 2).
961
1066
  const staleAcceptanceOutranksRealFailure = !settledIsFreshThisAttempt && hostSignalFailed;
1067
+ // #855: re-read cancel after trySettle/attach awaits — a signal in this
1068
+ // window must not land as lawful accepted.
1069
+ const cancelAfterSettle = processCancelSignalName(env.signal);
962
1070
  if (
963
1071
  settled !== undefined
964
1072
  && shouldPresent(settled)
965
1073
  && !directHostFailureSignal
966
1074
  && !staleAcceptanceOutranksRealFailure
967
1075
  && stderrLogWriteFailure === undefined
1076
+ && cancelAfterSettle === undefined
968
1077
  ) {
969
1078
  // This court sealed — drop open-court pointer (bare resume no longer continues it).
970
1079
  if (
@@ -1017,6 +1126,24 @@ export async function dispatchPostAdmissionTurn<
1017
1126
  return await finishAfterTurn({ ...settledFailure, turnDispatched: true as const, ...deferredPersist });
1018
1127
  }
1019
1128
  if (settledOutcome !== undefined) {
1129
+ // #855: final cancel re-read before lawful seal — settlement window may
1130
+ // have received SIGTERM/SIGINT/SIGHUP after the earlier snapshot.
1131
+ const lateCancel = processCancelSignalName(env.signal);
1132
+ if (lateCancel !== undefined) {
1133
+ return await finishAfterTurn(
1134
+ await settleProcessCancelAfterTurn({
1135
+ admitted,
1136
+ cancelName: lateCancel,
1137
+ result,
1138
+ adapters,
1139
+ env,
1140
+ io,
1141
+ persistRunState,
1142
+ deferredPersist,
1143
+ invocationScopeId: request.invocationScopeId,
1144
+ }),
1145
+ );
1146
+ }
1020
1147
  if (persistRunState) {
1021
1148
  try {
1022
1149
  await persistReturnedRunState(admitted, env.principalAuthority, { lawful: true });
@@ -1045,17 +1172,24 @@ export async function dispatchPostAdmissionTurn<
1045
1172
  io,
1046
1173
  persistRunState,
1047
1174
  );
1048
- return await finishAfterTurn({ ...failed, turnDispatched: true as const, ...deferredPersist });
1175
+ return await finishAfterTurn(
1176
+ withProcessCancelSkipAutoResume(
1177
+ { ...failed, turnDispatched: true as const, ...deferredPersist },
1178
+ env.signal,
1179
+ ),
1180
+ );
1049
1181
  }
1050
1182
  }
1051
1183
  // Lawful persist + present is the caller's stop seam (auto-resume loop /
1052
1184
  // manual resume), not this retried host-turn function.
1185
+ // #855 cancel re-read lives in finishAfterTurn (before afterDispatch).
1053
1186
  return await finishAfterTurn({ ...settledOutcome, ...deferredPersist });
1054
1187
  }
1055
1188
 
1056
1189
  // Host/runner true failure coexists with already-recorded payloads — never wash as accepted.
1057
1190
  if (hostSignalFailed) {
1058
1191
  const resolutionInput = controlledFailureInputFromResolution(resolution!);
1192
+ const processCancelName = processCancelSignalName(env.signal);
1059
1193
  const stderrLogWriteDetails =
1060
1194
  stderrLogWriteFailure === undefined
1061
1195
  ? undefined
@@ -1067,6 +1201,9 @@ export async function dispatchPostAdmissionTurn<
1067
1201
  code: result.code,
1068
1202
  stderr: result.stderr,
1069
1203
  ...resolutionInput,
1204
+ ...(processCancelName === undefined
1205
+ ? {}
1206
+ : { knownDiagnostic: processCancelDiagnostic(processCancelName) }),
1070
1207
  // Secondary fact only — never the cause. Rides on whichever channel
1071
1208
  // classification actually reads (knownFailure.details owns it when
1072
1209
  // a knownFailure exists; the top-level knownDetails otherwise).
@@ -1086,7 +1223,12 @@ export async function dispatchPostAdmissionTurn<
1086
1223
  io,
1087
1224
  persistRunState,
1088
1225
  );
1089
- return await finishAfterTurn({ ...failed, turnDispatched: true as const, ...deferredPersist });
1226
+ return await finishAfterTurn(
1227
+ withProcessCancelSkipAutoResume(
1228
+ { ...failed, turnDispatched: true as const, ...deferredPersist },
1229
+ env.signal,
1230
+ ),
1231
+ );
1090
1232
  }
1091
1233
 
1092
1234
  // Nothing else was wrong, but the durable stderr mirror itself failed to
@@ -1114,6 +1256,23 @@ export async function dispatchPostAdmissionTurn<
1114
1256
  await settleHostEndedNoReceipt(admitted, env.principalAuthority, courtScope) as T,
1115
1257
  courtScope,
1116
1258
  );
1259
+ // #855: cancel during no_receipt settlement window is not lawful success.
1260
+ const noReceiptCancel = processCancelSignalName(env.signal);
1261
+ if (noReceiptCancel !== undefined) {
1262
+ return await finishAfterTurn(
1263
+ await settleProcessCancelAfterTurn({
1264
+ admitted,
1265
+ cancelName: noReceiptCancel,
1266
+ result,
1267
+ adapters,
1268
+ env,
1269
+ io,
1270
+ persistRunState,
1271
+ deferredPersist,
1272
+ invocationScopeId: request.invocationScopeId,
1273
+ }),
1274
+ );
1275
+ }
1117
1276
  if (persistRunState) {
1118
1277
  try {
1119
1278
  await persistReturnedRunState(admitted, env.principalAuthority, { lawful: true });
@@ -1137,9 +1296,15 @@ export async function dispatchPostAdmissionTurn<
1137
1296
  io,
1138
1297
  persistRunState,
1139
1298
  );
1140
- return await finishAfterTurn({ ...failed, turnDispatched: true as const, ...deferredPersist });
1299
+ return await finishAfterTurn(
1300
+ withProcessCancelSkipAutoResume(
1301
+ { ...failed, turnDispatched: true as const, ...deferredPersist },
1302
+ env.signal,
1303
+ ),
1304
+ );
1141
1305
  }
1142
1306
  }
1307
+ // #855 cancel re-read lives in finishAfterTurn (before afterDispatch).
1143
1308
  return await finishAfterTurn({
1144
1309
  exitCode: exitCodeForTerminalOutcome(noReceipt.roleOutcome),
1145
1310
  admitted,
@@ -1512,6 +1677,7 @@ export async function runPostAdmissionSeatResume<
1512
1677
  io: input.io,
1513
1678
  sessionAppender: env.sessionAppender,
1514
1679
  autoResumeLimit: env.autoResumeLimit,
1680
+ ...(env.signal === undefined ? {} : { signal: env.signal }),
1515
1681
  buildInitialPayload: (): StationChildAttempt => ({ resumeTurn: false }),
1516
1682
  buildResumePayload: (): StationChildAttempt => ({ resumeTurn: true }),
1517
1683
  // Same as public manual resume: prior-court sealed acceptance is not a
@@ -1678,6 +1844,7 @@ export async function runPostAdmissionResumable<
1678
1844
  io,
1679
1845
  sessionAppender: env.sessionAppender,
1680
1846
  autoResumeLimit: env.autoResumeLimit,
1847
+ ...(env.signal === undefined ? {} : { signal: env.signal }),
1681
1848
  buildInitialPayload: buildScopedInitial,
1682
1849
  buildResumePayload: buildScopedResume,
1683
1850
  dispatch: async (request, lease, _isFirst, attemptIo) => {
@@ -0,0 +1,75 @@
1
+ /**
2
+ * #855: catchable process signals for ak-role public entry.
3
+ * SIGTERM / SIGINT / SIGHUP abort the shared controller so hosts can gracefully
4
+ * terminate children (SIGTERM only, never SIGKILL). Uncatchable death (SIGKILL,
5
+ * crash, power loss) is reported via run-state + writer lease on analyst.
6
+ */
7
+
8
+ const CATCHABLE_PROCESS_SIGNALS = ["SIGTERM", "SIGINT", "SIGHUP"] as const;
9
+
10
+ export type CatchableProcessSignal = (typeof CATCHABLE_PROCESS_SIGNALS)[number];
11
+
12
+ export type ProcessCancelHandle = {
13
+ readonly signal: AbortSignal;
14
+ /** Signal name once a catchable signal has been received; else undefined. */
15
+ readonly receivedSignal: () => CatchableProcessSignal | undefined;
16
+ dispose(): void;
17
+ };
18
+
19
+ /**
20
+ * Install once per process entry. First catchable signal wins; later signals
21
+ * are ignored so settlement can finish without re-entry storms.
22
+ */
23
+ export function installProcessCancelHandlers(
24
+ processRef: NodeJS.Process = process,
25
+ ): ProcessCancelHandle {
26
+ const controller = new AbortController();
27
+ let received: CatchableProcessSignal | undefined;
28
+ const listeners = new Map<CatchableProcessSignal, () => void>();
29
+
30
+ for (const sig of CATCHABLE_PROCESS_SIGNALS) {
31
+ const onSignal = (): void => {
32
+ if (received !== undefined) return;
33
+ received = sig;
34
+ try {
35
+ controller.abort(sig);
36
+ } catch {
37
+ // already aborted
38
+ }
39
+ };
40
+ listeners.set(sig, onSignal);
41
+ processRef.on(sig, onSignal);
42
+ }
43
+
44
+ return {
45
+ signal: controller.signal,
46
+ receivedSignal: () => received,
47
+ dispose(): void {
48
+ for (const [sig, onSignal] of listeners) {
49
+ processRef.off(sig, onSignal);
50
+ }
51
+ listeners.clear();
52
+ },
53
+ };
54
+ }
55
+
56
+ /** Signal name when this AbortSignal was aborted by installProcessCancelHandlers. */
57
+ export function processCancelSignalName(
58
+ signal: AbortSignal | undefined,
59
+ ): CatchableProcessSignal | undefined {
60
+ if (signal === undefined || signal.aborted !== true) return undefined;
61
+ const reason = signal.reason;
62
+ if (
63
+ reason === "SIGTERM" ||
64
+ reason === "SIGINT" ||
65
+ reason === "SIGHUP"
66
+ ) {
67
+ return reason;
68
+ }
69
+ return undefined;
70
+ }
71
+
72
+ /** Diagnostic line for a catchable process-signal termination (failure honesty). */
73
+ export function processCancelDiagnostic(signalName: CatchableProcessSignal): string {
74
+ return `ak-role terminated by ${signalName}`;
75
+ }
@@ -94,6 +94,8 @@ import {
94
94
  DIARIST_OUTPUT_TOOL_NAME,
95
95
  } from "../diarist-contracts.ts";
96
96
  import {
97
+ SECRETARIAT_COUNTERSIGN_TERMINAL_FACT_KEY,
98
+ SECRETARIAT_GATE_OFFICER_ENTRY_TYPE,
97
99
  SECRETARIAT_OUTPUT_TOOL_NAME,
98
100
  } from "../secretariat-contracts.ts";
99
101
  import {
@@ -3333,15 +3335,129 @@ export async function trySettleDiaristTerminalResult(
3333
3335
  return settleLawfulDiaristTerminalResult(admitted, authority, scope);
3334
3336
  }
3335
3337
 
3338
+ /**
3339
+ * #969 seat projection sole authority: 给事中 terminal (署|上呈) booked as a
3340
+ * durable custom entry (envelope-safe) — ledger accepted stays LLM params (#836).
3341
+ * Never scan toolResult rows: those are memory-only on headless/ACP (#617/#959).
3342
+ * Receipt bytes stay original; nested runId rides beside them.
3343
+ * Caller must only apply this when the terminal-defining submission actually
3344
+ * produced the officer entry (gate-bound converged, or 给事中 audit_escalation).
3345
+ */
3346
+ function countersignTerminalFromEntries(
3347
+ entries: readonly {
3348
+ type?: string;
3349
+ customType?: string;
3350
+ data?: unknown;
3351
+ }[],
3352
+ ): import("../secretariat-contracts.ts").SecretariatCountersignTerminalFact | undefined {
3353
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
3354
+ const entry = entries[index];
3355
+ if (entry?.type !== "custom") continue;
3356
+ if (entry.customType !== SECRETARIAT_GATE_OFFICER_ENTRY_TYPE) continue;
3357
+ const data =
3358
+ entry.data !== null && typeof entry.data === "object" && !Array.isArray(entry.data)
3359
+ ? (entry.data as Record<string, unknown>)
3360
+ : undefined;
3361
+ if (data?.officer !== "countersign") continue;
3362
+ if (data.receipt === undefined) continue;
3363
+ const runId =
3364
+ typeof data.runId === "string" && data.runId.trim() !== ""
3365
+ ? data.runId
3366
+ : undefined;
3367
+ return {
3368
+ receipt: data.receipt,
3369
+ ...(runId === undefined ? {} : { runId }),
3370
+ };
3371
+ }
3372
+ return undefined;
3373
+ }
3374
+
3375
+ function withCountersignTerminalFact(
3376
+ roleOutcome: Extract<
3377
+ import("./terminal.ts").TerminalRoleOutcome,
3378
+ { kind: "accepted" | "audit_escalation" }
3379
+ >,
3380
+ officer: import("../secretariat-contracts.ts").SecretariatCountersignTerminalFact,
3381
+ ): typeof roleOutcome {
3382
+ const prior =
3383
+ roleOutcome.decisiveFacts !== undefined && isRecord(roleOutcome.decisiveFacts)
3384
+ ? roleOutcome.decisiveFacts
3385
+ : {};
3386
+ return {
3387
+ ...roleOutcome,
3388
+ decisiveFacts: {
3389
+ ...prior,
3390
+ [SECRETARIAT_COUNTERSIGN_TERMINAL_FACT_KEY]: officer,
3391
+ },
3392
+ };
3393
+ }
3394
+
3395
+ /**
3396
+ * Terminal-defining secretariat submission owns officer projection.
3397
+ * Last readable secretariatStatus on accepted payloads decides: only gate-bound
3398
+ * converged projects a prior durable officer entry. Parent escalate bypasses the
3399
+ * gate and must not inherit a stale pass entry from an earlier turn (#969).
3400
+ */
3401
+ function acceptedSecretariatDefinesOfficerProjection(
3402
+ roleOutcome: Extract<
3403
+ import("./terminal.ts").TerminalRoleOutcome,
3404
+ { kind: "accepted" }
3405
+ >,
3406
+ ): boolean {
3407
+ const payloads = roleOutcome.payloads ?? [];
3408
+ for (let index = payloads.length - 1; index >= 0; index -= 1) {
3409
+ const payload = payloads[index];
3410
+ if (!isRecord(payload)) continue;
3411
+ const status = payload.secretariatStatus;
3412
+ if (typeof status !== "string") continue;
3413
+ return status === "converged";
3414
+ }
3415
+ return false;
3416
+ }
3417
+
3336
3418
  async function settleLawfulSecretariatTerminalResult(
3337
3419
  admitted: AdmittedSecretariatInvocation,
3338
3420
  authority: DurablePrincipalAuthority,
3339
3421
  scope?: SettlementCourtScope,
3340
3422
  ): Promise<TerminalResult | undefined> {
3341
- return settleLawfulSeatAcceptedTerminalResult(admitted, authority, {
3423
+ const settled = await settleLawfulSeatAcceptedTerminalResult(admitted, authority, {
3342
3424
  role: "secretariat",
3343
3425
  toolName: SECRETARIAT_OUTPUT_TOOL_NAME,
3344
3426
  }, scope);
3427
+ if (settled === undefined) return undefined;
3428
+ const coordinates = coordinatesFromAdmitted(authority, admitted);
3429
+ const entries = await readLawfulSettlementEntries(coordinates.sessionFile) ?? [];
3430
+ const officer = countersignTerminalFromEntries(entries);
3431
+ if (officer === undefined) return settled;
3432
+
3433
+ // 给事中上呈: public payloads = officer receipt; runId + receipt fact beside.
3434
+ // This kind is produced only when beforeAccept booked the officer entry.
3435
+ if (settled.roleOutcome.kind === "audit_escalation") {
3436
+ return {
3437
+ ...settled,
3438
+ roleOutcome: withCountersignTerminalFact(
3439
+ {
3440
+ ...settled.roleOutcome,
3441
+ payloads: [officer.receipt],
3442
+ },
3443
+ officer,
3444
+ ),
3445
+ };
3446
+ }
3447
+
3448
+ // Pass (署): keep 中书省 payloads; present 给事中 判词 + runId via decisiveFacts.
3449
+ // Parent escalate accepted terminal must not project a stale prior pass entry.
3450
+ if (settled.roleOutcome.kind === "accepted") {
3451
+ if (!acceptedSecretariatDefinesOfficerProjection(settled.roleOutcome)) {
3452
+ return settled;
3453
+ }
3454
+ return {
3455
+ ...settled,
3456
+ roleOutcome: withCountersignTerminalFact(settled.roleOutcome, officer),
3457
+ };
3458
+ }
3459
+
3460
+ return settled;
3345
3461
  }
3346
3462
 
3347
3463
  /** Try to settle a lawful Secretariat Terminal; undefined only for genuine absence. */
@@ -1040,9 +1040,9 @@ export async function summonParallelReviewerLenses(options: {
1040
1040
  return results;
1041
1041
  }
1042
1042
 
1043
- /** Gate officer summons: notary/auditor via --source-run; inspector via pointer instruction. */
1043
+ /** Gate officer summons: notary/auditor via --source-run; inspector via pointer; countersign via ticket (#969). */
1044
1044
  export async function summonGateOfficer(options: {
1045
- readonly officer: "inspector" | "notary" | "auditor";
1045
+ readonly officer: "inspector" | "notary" | "auditor" | "countersign";
1046
1046
  readonly sourceRunDirectory: string;
1047
1047
  readonly cwd: string;
1048
1048
  readonly home?: string;
@@ -1052,14 +1052,14 @@ export async function summonGateOfficer(options: {
1052
1052
  readonly signal?: AbortSignal;
1053
1053
  /**
1054
1054
  * Plain-language re-ask when the prior officer reply was not three-state (#753 / #756).
1055
- * All three officers: reviewReask → same-ticket resume summons.instruction.
1055
+ * Officers: reviewReask → same-ticket resume summons.instruction.
1056
1056
  * Never concatenated into argv (inspector parentRunPath is the pure 卷宗指针).
1057
1057
  */
1058
1058
  readonly reask?: string;
1059
1059
  /**
1060
1060
  * In-flight parent 交卷 body (tool-call arguments). Relayed verbatim as officer
1061
1061
  * dialogue content when reask is absent (#879). Binding pointer stays on
1062
- * --source-run / 卷宗指针 argv — never a content substitute.
1062
+ * --source-run / 卷宗指针 / ticket argv — never a content substitute.
1063
1063
  */
1064
1064
  readonly submission?: unknown;
1065
1065
  /** Pi-adapter inject — forwarded to summonPublicRole (not a parent-host override). */
@@ -1121,6 +1121,32 @@ export async function summonGateOfficer(options: {
1121
1121
  ...common,
1122
1122
  });
1123
1123
  }
1124
+ if (options.officer === "countersign") {
1125
+ // #969: Secretariat submission gate → 给事中.
1126
+ // Dialogue / identity instruction = parent typed payload 原话 (ADR 0079) or reask;
1127
+ // no code-authored summons prose (#924). Ticket identity = parent run durable
1128
+ // board binding only (readBoardTicketNumber) — receipt ticketNumber is payload
1129
+ // content, never the sole identity source; parent true-unbound stays unbound.
1130
+ // correlationId only on this officer — notary/auditor/inspector stay untouched.
1131
+ const { runIdFromRunDirectory } = await import("./run-terminal-artifacts.ts");
1132
+ const correlationId = runIdFromRunDirectory(options.sourceRunDirectory);
1133
+ if (correlationId === undefined || correlationId.trim() === "") {
1134
+ throw new Error(
1135
+ `countersign gate summon requires parent runId from sourceRunDirectory: ${options.sourceRunDirectory}`,
1136
+ );
1137
+ }
1138
+ const { readBoardTicketNumber } = await import("./run-ticket-number.ts");
1139
+ const parentTicket = await readBoardTicketNumber(options.sourceRunDirectory);
1140
+ // Argv instruction = reask or parent payload bytes only (never a fabricated line).
1141
+ const instruction = options.reask ?? gateReviewInstruction ?? "";
1142
+ return summonPublicRole({
1143
+ role: "countersign",
1144
+ argv: ["--project", options.cwd, "--", instruction],
1145
+ ...common,
1146
+ correlationId,
1147
+ ...(parentTicket === undefined ? {} : { boundTicketNumber: parentTicket }),
1148
+ });
1149
+ }
1124
1150
  // Inspector: argv stays pure 卷宗指针 (#747 parentRunPath lookup key).
1125
1151
  // Content (body/reask) rides env → summons.instruction / first-mint prompt.
1126
1152
  return summonPublicRole({
@@ -9,7 +9,10 @@ import {
9
9
  ENGINE_MODEL_FLAG_NAME,
10
10
  normalizeEngineName,
11
11
  } from "./engine-detour.ts";
12
- import { requireGatekeeperPass } from "./gatekeeper-pass-envelope.ts";
12
+ import {
13
+ createDefaultGateOfficerSummon,
14
+ requireGatekeeperPass,
15
+ } from "./gatekeeper-pass-envelope.ts";
13
16
  import type {
14
17
  HostContext,
15
18
  HostEventRegistration,
@@ -229,18 +232,32 @@ export async function prepareRoleEnvelope(options: {
229
232
  // seat remains reachable through MCP.
230
233
  setActiveTools(names) { preferredTools = [...names]; },
231
234
  getActiveTools() { return [...preferredTools]; },
232
- async requireGatekeeperPass(options) {
233
- await requireGatekeeperPass({
234
- context: options.context,
235
- subject: options.subject,
236
- ...(options.signal === undefined ? {} : { signal: options.signal }),
235
+ async requireGatekeeperPass(gateOptions) {
236
+ const packageRoot =
237
+ typeof options.dependencies.packageRoot === "string"
238
+ ? options.dependencies.packageRoot
239
+ : undefined;
240
+ return requireGatekeeperPass({
241
+ context: gateOptions.context,
242
+ subject: gateOptions.subject,
243
+ ...(gateOptions.signal === undefined ? {} : { signal: gateOptions.signal }),
237
244
  hostActions: {
238
245
  failInfrastructure: (error, _context, toolCallId) =>
239
- options.hostActions.failInfrastructure(error, options.context, toolCallId),
240
- bindSubmissionNonPass: options.hostActions.bindSubmissionNonPass,
246
+ gateOptions.hostActions.failInfrastructure(error, gateOptions.context, toolCallId),
247
+ bindSubmissionNonPass: gateOptions.hostActions.bindSubmissionNonPass,
241
248
  },
242
- toolCallId: options.toolCallId,
243
- ...(options.submission === undefined ? {} : { submission: options.submission }),
249
+ toolCallId: gateOptions.toolCallId,
250
+ ...(gateOptions.submission === undefined ? {} : { submission: gateOptions.submission }),
251
+ // #969: package root + home reach nested 给事中/符宝郎 summons.
252
+ // hostAdapters (when set on deps) forward nested seat selection — production unset.
253
+ summonOfficer: createDefaultGateOfficerSummon({
254
+ cwd: gateOptions.context.cwd ?? request.cwd,
255
+ home: request.home,
256
+ ...(packageRoot === undefined ? {} : { packageRoot }),
257
+ ...(options.dependencies.hostAdapters === undefined
258
+ ? {}
259
+ : { hostAdapters: options.dependencies.hostAdapters }),
260
+ }),
244
261
  });
245
262
  },
246
263
  on(...registration: HostEventRegistration) {