@akagilnc/pi-workflow-roles 0.1.4586 → 0.1.4631

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 (63) hide show
  1. package/README.md +2 -2
  2. package/README.zh-CN.md +3 -3
  3. package/dist/acp-host/production-host.js +1184 -717
  4. package/dist/activation-ledger-topology.js +0 -4
  5. package/dist/archivist-record-topology.js +27 -35
  6. package/dist/headless-host/description.js +7 -4
  7. package/dist/headless-host/production-host.js +998 -595
  8. package/dist/migrate-book-topology.js +3 -6
  9. package/dist/navigator-attendance.js +146 -241
  10. package/dist/navigator-public-session.js +81 -15
  11. package/dist/package-contracts/navigator-output.js +30 -12
  12. package/dist/pi/role-turn-host.js +0 -3
  13. package/dist/public-cli/auto-resume.js +26 -0
  14. package/dist/public-cli/judge-run.js +2 -2
  15. package/dist/public-cli/main.js +1119 -870
  16. package/dist/public-cli/option-definitions.js +1 -1
  17. package/dist/public-cli/post-admission.js +103 -9
  18. package/dist/public-cli/process-cancel.js +57 -0
  19. package/dist/public-cli/run-lifecycle.js +29 -7
  20. package/dist/public-cli/settlement.js +46 -32
  21. package/dist/public-cli/terminal.js +8 -27
  22. package/dist/public-role-summons.js +22 -4
  23. package/dist/submission-ledger.js +56 -23
  24. package/package.json +1 -1
  25. package/resources/836-deleted-machine-instruction-inventory.md +2 -0
  26. package/resources/navigator-route-playbook.md +9 -0
  27. package/scripts/build-package.mjs +6 -2
  28. package/src/acp-host/role-turn-host.ts +129 -13
  29. package/src/activation-ledger-topology.ts +1 -6
  30. package/src/activation-ledger.ts +5 -236
  31. package/src/analyst-entry.ts +19 -2
  32. package/src/analyst-ledger.ts +111 -6
  33. package/src/archivist-record-entry.ts +1 -1
  34. package/src/headless-host/description.ts +17 -7
  35. package/src/headless-host/role-turn-host.ts +42 -11
  36. package/src/navigator-attendance.ts +290 -425
  37. package/src/navigator-public-session.ts +126 -25
  38. package/src/navigator-role.ts +2 -2
  39. package/src/package-contracts/navigator-output.ts +41 -25
  40. package/src/pi/role-turn-host.ts +0 -3
  41. package/src/public-cli/auto-resume.ts +34 -0
  42. package/src/public-cli/cli.ts +30 -0
  43. package/src/public-cli/coder-run.ts +2 -2
  44. package/src/public-cli/fixer-run.ts +2 -2
  45. package/src/public-cli/judge-run.ts +2 -2
  46. package/src/public-cli/main.ts +25 -3
  47. package/src/public-cli/merger-run.ts +2 -2
  48. package/src/public-cli/option-definitions.ts +1 -1
  49. package/src/public-cli/post-admission.ts +175 -7
  50. package/src/public-cli/process-cancel.ts +75 -0
  51. package/src/public-cli/run-lifecycle.ts +37 -7
  52. package/src/public-cli/settlement.ts +42 -33
  53. package/src/public-cli/terminal.ts +12 -37
  54. package/src/public-role-summons.ts +34 -5
  55. package/src/role-envelope.ts +89 -4
  56. package/src/role-runtime.ts +78 -65
  57. package/src/submission-ledger.ts +63 -21
  58. package/dist/activation-reconciliation.js +0 -61
  59. package/dist/public-cli/command-renderer.js +0 -5
  60. package/dist/public-command-renderer.js +0 -29
  61. package/src/activation-reconciliation.ts +0 -163
  62. package/src/public-cli/command-renderer.ts +0 -9
  63. package/src/public-command-renderer.ts +0 -55
@@ -10,6 +10,7 @@ import { writeFile } from "node:fs/promises";
10
10
  import { isAbsolute, join, resolve } from "node:path";
11
11
 
12
12
  import {
13
+ buildAutoResumeContinuationPrompt,
13
14
  buildResumeContinuationPrompt,
14
15
  RESUME_TRANSPORT_ENVELOPE,
15
16
  type PublicResumeRequest,
@@ -86,6 +87,11 @@ import {
86
87
  type TypedProviderHttpObservation,
87
88
  type WriterLeaseDiagnosticKind,
88
89
  } from "./run-lifecycle.ts";
90
+ import {
91
+ processCancelDiagnostic,
92
+ processCancelSignalName,
93
+ type CatchableProcessSignal,
94
+ } from "./process-cancel.ts";
89
95
  import { homeFromRunDirectory } from "../activation-ledger-topology.ts";
90
96
  import {
91
97
  attemptProducedFreshSubmission,
@@ -118,6 +124,63 @@ import {
118
124
  TurnDispatchedFailure,
119
125
  } from "./auto-resume.ts";
120
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
+
121
184
  /**
122
185
  * Nested station-child role already finished its own call-local loop.
123
186
  * Parent records that failure once and must not auto-resume into a re-summon
@@ -586,6 +649,38 @@ export async function dispatchPostAdmissionTurn<
586
649
  const finishAfterTurn = async (result: DispatchOutcome): Promise<DispatchOutcome> => {
587
650
  if (afterDispatchApplied) return result;
588
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
+ }
589
684
  try {
590
685
  // #858: an unbound seat may assert its ticket on the existing receipt.
591
686
  // Read the original accepted payload; do not rewrite it, infer from prose,
@@ -787,20 +882,29 @@ export async function dispatchPostAdmissionTurn<
787
882
  try {
788
883
  result = await env.roleTurnHost.executeTurn(turnRequest);
789
884
  } catch (error) {
885
+ const processCancelName = processCancelSignalName(env.signal);
790
886
  const settled = await settleAfterTurnStarted(
791
887
  admitted,
792
888
  withEngineDetourInvocationScope({
793
889
  timedOut: false,
794
890
  code: null,
795
891
  stderr: "",
796
- thrown: error,
892
+ thrown:
893
+ processCancelName === undefined
894
+ ? error
895
+ : new Error(processCancelDiagnostic(processCancelName), { cause: error }),
797
896
  }, request.invocationScopeId),
798
897
  adapters,
799
898
  env.principalAuthority,
800
899
  io,
801
900
  persistRunState,
802
901
  );
803
- 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
+ );
804
908
  }
805
909
 
806
910
  // `result.stderr` stays live in memory for the real classification below
@@ -916,11 +1020,13 @@ export async function dispatchPostAdmissionTurn<
916
1020
  // retroactively invalidate an acceptance that already sealed this turn
917
1021
  // — a resolved 429 observed earlier in the same session is exactly
918
1022
  // that.
1023
+ const processCancelName = processCancelSignalName(env.signal);
919
1024
  const directHostFailureSignal =
920
1025
  result.timedOut
921
1026
  || result.knownFailure !== undefined
922
1027
  || runnerKnownFailure !== undefined
923
- || credentialFailure !== undefined;
1028
+ || credentialFailure !== undefined
1029
+ || processCancelName !== undefined;
924
1030
  hostSignalFailed =
925
1031
  directHostFailureSignal
926
1032
  || (result.code !== null && result.code !== 0)
@@ -958,12 +1064,16 @@ export async function dispatchPostAdmissionTurn<
958
1064
  // where a bare nonzero exit / resolution failure must not be outranked
959
1065
  // by someone else's earlier success (#836 r12 class 2).
960
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);
961
1070
  if (
962
1071
  settled !== undefined
963
1072
  && shouldPresent(settled)
964
1073
  && !directHostFailureSignal
965
1074
  && !staleAcceptanceOutranksRealFailure
966
1075
  && stderrLogWriteFailure === undefined
1076
+ && cancelAfterSettle === undefined
967
1077
  ) {
968
1078
  // This court sealed — drop open-court pointer (bare resume no longer continues it).
969
1079
  if (
@@ -1016,6 +1126,24 @@ export async function dispatchPostAdmissionTurn<
1016
1126
  return await finishAfterTurn({ ...settledFailure, turnDispatched: true as const, ...deferredPersist });
1017
1127
  }
1018
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
+ }
1019
1147
  if (persistRunState) {
1020
1148
  try {
1021
1149
  await persistReturnedRunState(admitted, env.principalAuthority, { lawful: true });
@@ -1044,17 +1172,24 @@ export async function dispatchPostAdmissionTurn<
1044
1172
  io,
1045
1173
  persistRunState,
1046
1174
  );
1047
- 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
+ );
1048
1181
  }
1049
1182
  }
1050
1183
  // Lawful persist + present is the caller's stop seam (auto-resume loop /
1051
1184
  // manual resume), not this retried host-turn function.
1185
+ // #855 cancel re-read lives in finishAfterTurn (before afterDispatch).
1052
1186
  return await finishAfterTurn({ ...settledOutcome, ...deferredPersist });
1053
1187
  }
1054
1188
 
1055
1189
  // Host/runner true failure coexists with already-recorded payloads — never wash as accepted.
1056
1190
  if (hostSignalFailed) {
1057
1191
  const resolutionInput = controlledFailureInputFromResolution(resolution!);
1192
+ const processCancelName = processCancelSignalName(env.signal);
1058
1193
  const stderrLogWriteDetails =
1059
1194
  stderrLogWriteFailure === undefined
1060
1195
  ? undefined
@@ -1066,6 +1201,9 @@ export async function dispatchPostAdmissionTurn<
1066
1201
  code: result.code,
1067
1202
  stderr: result.stderr,
1068
1203
  ...resolutionInput,
1204
+ ...(processCancelName === undefined
1205
+ ? {}
1206
+ : { knownDiagnostic: processCancelDiagnostic(processCancelName) }),
1069
1207
  // Secondary fact only — never the cause. Rides on whichever channel
1070
1208
  // classification actually reads (knownFailure.details owns it when
1071
1209
  // a knownFailure exists; the top-level knownDetails otherwise).
@@ -1085,7 +1223,12 @@ export async function dispatchPostAdmissionTurn<
1085
1223
  io,
1086
1224
  persistRunState,
1087
1225
  );
1088
- 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
+ );
1089
1232
  }
1090
1233
 
1091
1234
  // Nothing else was wrong, but the durable stderr mirror itself failed to
@@ -1113,6 +1256,23 @@ export async function dispatchPostAdmissionTurn<
1113
1256
  await settleHostEndedNoReceipt(admitted, env.principalAuthority, courtScope) as T,
1114
1257
  courtScope,
1115
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
+ }
1116
1276
  if (persistRunState) {
1117
1277
  try {
1118
1278
  await persistReturnedRunState(admitted, env.principalAuthority, { lawful: true });
@@ -1136,9 +1296,15 @@ export async function dispatchPostAdmissionTurn<
1136
1296
  io,
1137
1297
  persistRunState,
1138
1298
  );
1139
- 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
+ );
1140
1305
  }
1141
1306
  }
1307
+ // #855 cancel re-read lives in finishAfterTurn (before afterDispatch).
1142
1308
  return await finishAfterTurn({
1143
1309
  exitCode: exitCodeForTerminalOutcome(noReceipt.roleOutcome),
1144
1310
  admitted,
@@ -1511,6 +1677,7 @@ export async function runPostAdmissionSeatResume<
1511
1677
  io: input.io,
1512
1678
  sessionAppender: env.sessionAppender,
1513
1679
  autoResumeLimit: env.autoResumeLimit,
1680
+ ...(env.signal === undefined ? {} : { signal: env.signal }),
1514
1681
  buildInitialPayload: (): StationChildAttempt => ({ resumeTurn: false }),
1515
1682
  buildResumePayload: (): StationChildAttempt => ({ resumeTurn: true }),
1516
1683
  // Same as public manual resume: prior-court sealed acceptance is not a
@@ -1624,7 +1791,7 @@ export async function runPostAdmissionOneShot<
1624
1791
  ...input.request,
1625
1792
  continuation: {
1626
1793
  kind: "resume",
1627
- prompt: buildResumeContinuationPrompt({
1794
+ prompt: buildAutoResumeContinuationPrompt({
1628
1795
  packageRoot: input.env.packageRoot,
1629
1796
  ...pickEngineAxis({
1630
1797
  engine: input.effectiveEngine ?? input.env.engine,
@@ -1677,6 +1844,7 @@ export async function runPostAdmissionResumable<
1677
1844
  io,
1678
1845
  sessionAppender: env.sessionAppender,
1679
1846
  autoResumeLimit: env.autoResumeLimit,
1847
+ ...(env.signal === undefined ? {} : { signal: env.signal }),
1680
1848
  buildInitialPayload: buildScopedInitial,
1681
1849
  buildResumePayload: buildScopedResume,
1682
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
+ }
@@ -127,8 +127,17 @@ export type RoleRunRecord = {
127
127
  readonly resumable?: TypedHttp429Observation;
128
128
  };
129
129
 
130
- /** Package-owned turn trigger for resume. Not caller instruction and not semantic task content. */
131
- export const RESUME_TRANSPORT_ENVELOPE = "[ak-role:resume-continue]" as const;
130
+ /**
131
+ * Package-owned non-empty Chinese neutral resume transport (#959 / ADR 0073).
132
+ * Used by:
133
+ * - auto-resume (all seats via buildAutoResumeContinuationPrompt) — required so
134
+ * hosts that reject empty stdin (codex) still receive a prompt;
135
+ * - reviewer seat manual resume (reviewerResumePrompt) — same non-empty need on
136
+ * that seat's own manual entry.
137
+ * Generic bare `ak-role resume` stays empty-capable via selectResumeContinuationPrompt
138
+ * / buildResumeContinuationPrompt — ADR 0080 keeps auto and generic-manual entries separate.
139
+ */
140
+ export const RESUME_TRANSPORT_ENVELOPE = "继续。" as const;
132
141
 
133
142
  /** Public manual resume request after the unique CLI parser owns runId + optional message. */
134
143
  export type PublicResumeRequest = {
@@ -169,9 +178,11 @@ export type SameTicketSummonsMaterials = {
169
178
  };
170
179
 
171
180
  /**
172
- * Unique continuation-prompt selector for manual/auto engine-axis resume
173
- * (#471 / #600 / #736). Message present → those bytes; absent → engine pointers only.
174
- * #836: no transport-token prompt, no line-by-line 重新读 rewrite.
181
+ * Manual resume continuation selector (#471 / #600 / #736 / ADR 0080).
182
+ * Message present → those bytes; absent → engine pointers only (may be empty).
183
+ * Caller message (including blank) still wins verbatim when supplied.
184
+ * Auto-resume must use buildAutoResumeContinuationPrompt — do not fold the
185
+ * non-empty Chinese envelope into this shared manual selector (#959).
175
186
  */
176
187
  export function selectResumeContinuationPrompt(
177
188
  message?: string,
@@ -182,8 +193,8 @@ export function selectResumeContinuationPrompt(
182
193
  }
183
194
 
184
195
  /**
185
- * Resume continuation with engine material resolved from the seat env (#600).
186
- * Seat table / invocation engine axis rides the same prompt seam as initial runs.
196
+ * Manual resume continuation with engine material from the seat env (#600).
197
+ * Bare manual resume stays empty-prompt-capable; auto-resume is a separate entry.
187
198
  */
188
199
  export function buildResumeContinuationPrompt(options: {
189
200
  packageRoot: string;
@@ -200,6 +211,25 @@ export function buildResumeContinuationPrompt(options: {
200
211
  );
201
212
  }
202
213
 
214
+ /**
215
+ * Auto-resume continuation only (#959 / ADR 0080).
216
+ * Always non-empty: Chinese neutral envelope plus optional engine pointers.
217
+ * Never call this from manual `ak-role resume`.
218
+ */
219
+ export function buildAutoResumeContinuationPrompt(options: {
220
+ packageRoot: string;
221
+ engine?: string;
222
+ engineModel?: string;
223
+ }): string {
224
+ return selectResumeContinuationPrompt(
225
+ RESUME_TRANSPORT_ENVELOPE,
226
+ engineSessionMaterialFromOptions({
227
+ packageRoot: options.packageRoot,
228
+ ...pickEngineAxis(options),
229
+ }),
230
+ );
231
+ }
232
+
203
233
  const RUN_STATE_FILE = "run-state.json";
204
234
  const WRITER_LOCK_FILE = "writer.lock";
205
235
 
@@ -123,7 +123,6 @@ import {
123
123
  parseInvocationMarkerIdentity,
124
124
  type InvocationMarkerIdentity,
125
125
  } from "../navigator-invocation-identity.ts";
126
- import type { NavigatorPhase } from "../navigator-attendance.ts";
127
126
  import {
128
127
  NO_RECEIPT_LIFECYCLE_ENTRY_TYPE,
129
128
  RECEIPT_DELIVERY_TURN_LIMIT,
@@ -360,7 +359,7 @@ import {
360
359
  exitCodeForTerminalOutcome,
361
360
  formatTerminalResult,
362
361
  isLawfulTypedTerminalOutcome,
363
- recommendationNavigatorFact,
362
+ adviceNavigatorFact,
364
363
  type ControlledFailureCause,
365
364
  type TerminalArtifactRef,
366
365
  type TerminalGateFact,
@@ -782,7 +781,13 @@ export function explicitInternalKnownFailureClassificationInput(
782
781
  };
783
782
  }
784
783
 
785
- /** Post-role Navigator delivery grace (Issue #11 / #101 / #106 / #159). */
784
+ /**
785
+ * Post-role Navigator delivery grace (Issue #11 / #101 / #106 / #159).
786
+ * After the parent finishes: wait at most this long for navigator output, then
787
+ * stop waiting. Navigator must already be running from parent start (prepare
788
+ * host round in parallel); this window is only the tail after parent end — not
789
+ * the budget to start a cold full host turn (owner 2026-09-17 #959).
790
+ */
786
791
  export const NAVIGATOR_POST_ROLE_GRACE_MS = 10_000;
787
792
 
788
793
  type SessionMessage = {
@@ -1931,11 +1936,6 @@ export type LawfulJudgeRoleOutcome = Extract<
1931
1936
  TerminalRoleOutcome,
1932
1937
  { kind: "accepted" } | { kind: "audit_escalation" }
1933
1938
  >;
1934
- function navigatorPhaseValue(value: unknown): NavigatorPhase {
1935
- if (value === "plan" || value === "apply") return value;
1936
- return null;
1937
- }
1938
-
1939
1939
  /**
1940
1940
  * Minimal attendance provenance against the bound marker (ADR 0043).
1941
1941
  * Keep only invocationId + post-terminal ordering. Runtime-self-produced
@@ -1960,35 +1960,44 @@ function parseNavigatorAttendanceDetails(
1960
1960
  const advisoryDiagnostic = typeof details.routePlaybookReadFailure === "string"
1961
1961
  ? { advisoryDiagnostic: details.routePlaybookReadFailure }
1962
1962
  : {};
1963
- if (disposition === "recommendation") {
1964
- const next = details.next;
1965
- if (!isRecord(next) || typeof next.role !== "string") {
1963
+ // #959: advice prose is presented as-is. Legacy "recommendation" with next/reason/
1964
+ // command is projected into prose so historical sessions still render — never wash
1965
+ // a real recommendation into no-advice when any advice body is recoverable.
1966
+ if (disposition === "advice" || disposition === "recommendation") {
1967
+ let prose: string | undefined;
1968
+ if (typeof details.prose === "string" && details.prose.trim() !== "") {
1969
+ prose = details.prose;
1970
+ } else {
1971
+ const next = isRecord(details.next) && typeof details.next.role === "string"
1972
+ ? details.next.role
1973
+ : undefined;
1974
+ const reason = typeof details.reason === "string" && details.reason.trim() !== ""
1975
+ ? details.reason
1976
+ : undefined;
1977
+ const command = typeof details.command === "string" && details.command.trim() !== ""
1978
+ ? details.command
1979
+ : undefined;
1980
+ if (reason !== undefined && next !== undefined) {
1981
+ prose = `${reason}(下一步:${next})`;
1982
+ } else if (reason !== undefined) {
1983
+ prose = reason;
1984
+ } else if (next !== undefined) {
1985
+ // Historical recommendation with only typed next — still real advice.
1986
+ prose = `下一步:${next}`;
1987
+ } else if (command !== undefined) {
1988
+ prose = command;
1989
+ }
1990
+ }
1991
+ if (prose === undefined || prose.trim() === "") {
1992
+ // Attended but empty body is affirmative no-advice, not unavailable (#959).
1966
1993
  return {
1967
- disposition: "unavailable",
1968
- source: "unknown",
1969
- reason: "navigator recommendation missing typed next role",
1994
+ disposition: "no-advice",
1995
+ ...advisoryDiagnostic,
1970
1996
  };
1971
1997
  }
1972
- const reason = typeof details.reason === "string" ? details.reason : "";
1973
- const route = Array.isArray(details.route)
1974
- ? details.route
1975
- .filter(isRecord)
1976
- .map((target) => ({
1977
- role: String(target.role),
1978
- phase: navigatorPhaseValue(target.phase),
1979
- }))
1980
- : undefined;
1981
- return recommendationNavigatorFact({
1998
+ return adviceNavigatorFact({
1999
+ prose,
1982
2000
  ...advisoryDiagnostic,
1983
- next: {
1984
- role: next.role,
1985
- phase: navigatorPhaseValue(next.phase),
1986
- },
1987
- reason,
1988
- ...(route === undefined ? {} : { route }),
1989
- ...(typeof details.command === "string"
1990
- ? { modelCommand: details.command }
1991
- : {}),
1992
2001
  });
1993
2002
  }
1994
2003
  if (disposition === "unavailable") {
@@ -5,11 +5,6 @@
5
5
  * Free-text cells are JSON-string encoded so legitimate newlines/tabs cannot forge
6
6
  * extra rows or shift column boundaries (note / fix.summary / decision question / reason).
7
7
  */
8
- import {
9
- isPublicCallableRole,
10
- renderPublicAkRoleCommand,
11
- } from "./command-renderer.ts";
12
- import type { NavigatorPhase } from "../navigator-attendance.ts";
13
8
  import type { NoReceiptLifecycleFacts } from "../receipt-delivery-policy.ts";
14
9
  import type { ControlledFailureCause } from "../host-contracts.ts";
15
10
 
@@ -102,12 +97,9 @@ export function exitCodeForTerminalOutcome(
102
97
 
103
98
  export type TerminalNavigatorFact =
104
99
  | {
105
- disposition: "recommendation";
106
- next: { role: string; phase: NavigatorPhase };
107
- reason: string;
108
- /** Present only when role/phase is sufficient for a truthful public command. */
109
- command?: string;
110
- route?: ReadonlyArray<{ role: string; phase: NavigatorPhase }>;
100
+ /** #959: navigator speaks prose; code does not parse or rank the advice. */
101
+ disposition: "advice";
102
+ prose: string;
111
103
  advisoryDiagnostic?: string;
112
104
  }
113
105
  | {
@@ -227,28 +219,16 @@ export type TerminalResult = {
227
219
  );
228
220
 
229
221
  /**
230
- * Build a recommendation navigator fact. Model command is kept when present;
231
- * registry render is fallback only. Unknown seats stay recommendations (#836 B4.1).
222
+ * Build an advice navigator fact (#959). Prose is presented as submitted;
223
+ * code does not parse route/next or judge usability.
232
224
  */
233
- export function recommendationNavigatorFact(input: {
234
- next: { role: string; phase: NavigatorPhase };
235
- reason: string;
236
- route?: ReadonlyArray<{ role: string; phase: NavigatorPhase }>;
225
+ export function adviceNavigatorFact(input: {
226
+ prose: string;
237
227
  advisoryDiagnostic?: string;
238
- modelCommand?: string;
239
228
  }): TerminalNavigatorFact {
240
- const command =
241
- typeof input.modelCommand === "string" && input.modelCommand.trim() !== ""
242
- ? input.modelCommand
243
- : isPublicCallableRole(input.next.role)
244
- ? renderPublicAkRoleCommand(input.next)
245
- : undefined;
246
229
  return {
247
- disposition: "recommendation",
248
- next: input.next,
249
- reason: input.reason,
250
- ...(command === undefined ? {} : { command }),
251
- ...(input.route === undefined ? {} : { route: input.route }),
230
+ disposition: "advice",
231
+ prose: input.prose,
252
232
  ...(input.advisoryDiagnostic === undefined ? {} : { advisoryDiagnostic: input.advisoryDiagnostic }),
253
233
  };
254
234
  }
@@ -288,14 +268,9 @@ export function formatTerminalResult(result: TerminalResult): string {
288
268
  if (result.navigator.advisoryDiagnostic !== undefined) {
289
269
  lines.push(`navigator-advisory\t${encodeTerminalField(result.navigator.advisoryDiagnostic)}`);
290
270
  }
291
- if (result.navigator.disposition === "recommendation") {
292
- lines.push(
293
- `next\t${result.navigator.next.role}\t${result.navigator.next.phase ?? "none"}`,
294
- );
295
- lines.push(`reason\t${encodeTerminalField(result.navigator.reason)}`);
296
- if (result.navigator.command !== undefined) {
297
- lines.push(`command\t${encodeTerminalField(result.navigator.command)}`);
298
- }
271
+ if (result.navigator.disposition === "advice") {
272
+ // #959: present navigator prose as-is — no next/reason/command parsing.
273
+ lines.push(`prose\t${encodeTerminalField(result.navigator.prose)}`);
299
274
  } else if (result.navigator.disposition === "unavailable") {
300
275
  lines.push(
301
276
  `unavailable\t${result.navigator.source}\t${encodeTerminalField(result.navigator.reason)}`,