@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
@@ -2,9 +2,12 @@
2
2
  * Navigator attendance session factory (#675 r3).
3
3
  * Each prepare turn uses the public navigator activation path (summonPublicRole) —
4
4
  * same seat table and shared envelope as `ak-role navigator`.
5
- * Archivist createRecordSession only books route-memory nest under the parent;
6
- * no openPiInProcessSession second lifecycle / no agentDir patch on the old path.
5
+ * Archivist createRecordSession books the attendance nest under the parent;
6
+ * host conversation continuity uses CLI resume (`resumeRunId`), not a package
7
+ * advice ledger (development-closure: do not invent package-level memory).
7
8
  */
9
+ import { basename } from "node:path";
10
+
8
11
  import { sitianReport } from "./sitian-facade.ts";
9
12
  import {
10
13
  NavigatorUnavailableError,
@@ -17,6 +20,8 @@ import {
17
20
  } from "./navigator-session-contracts.ts";
18
21
  import type { NoReceiptLifecycleFacts } from "./receipt-delivery-policy.ts";
19
22
  import { runDirectoryFromHostContext, type HostContext } from "./host-contracts.ts";
23
+ import type { PublicSummonResult } from "./public-role-summons.ts";
24
+ import { CliUsageError } from "./public-cli/cli-errors.ts";
20
25
 
21
26
  /**
22
27
  * Ledger process home for navigator attendance: admitted HostContext.runDirectory
@@ -40,13 +45,73 @@ async function resolveNavigatorLedgerHome(context: HostContext): Promise<string
40
45
  return undefined;
41
46
  }
42
47
 
43
- export function createNativeNavigatorSessionFactory(): NavigatorSessionFactory {
48
+ /** Resume key only — points at a host run principal, never stores advice prose. */
49
+ export const NAVIGATOR_HOST_RUN_POINTER_ENTRY = "ak-navigator-host-run";
50
+
51
+ function exactRecord(value: unknown): value is Record<string, unknown> {
52
+ return typeof value === "object" && value !== null && !Array.isArray(value);
53
+ }
54
+
55
+ export function runIdFromNavigatorDirectory(runDirectory: string): string | undefined {
56
+ const entry = basename(runDirectory);
57
+ const suffix = "@navigator";
58
+ if (!entry.endsWith(suffix)) return undefined;
59
+ const runId = entry.slice(0, entry.length - suffix.length);
60
+ return runId.length > 0 ? runId : undefined;
61
+ }
62
+
63
+ export function readNavigatorHostRunPointer(entries: readonly unknown[]): string | undefined {
64
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
65
+ const entry = entries[index];
66
+ if (!exactRecord(entry)) continue;
67
+ if (entry.type === "custom" && entry.customType === NAVIGATOR_HOST_RUN_POINTER_ENTRY) {
68
+ const data = entry.data;
69
+ if (exactRecord(data) && typeof data.runId === "string" && data.runId.trim() !== "") {
70
+ return data.runId.trim();
71
+ }
72
+ }
73
+ }
74
+ return undefined;
75
+ }
76
+
77
+ /**
78
+ * Typed preflight via public CLI load path: can this run reopen as navigator?
79
+ * CliUsageError (code AK_ROLE_USAGE) from loadResumableInstructionSeatRun means
80
+ * unknown id / principal unavailable / non-instruction seat — not resumable.
81
+ * Other throws propagate. Never reads stderr prose.
82
+ */
83
+ export async function navigatorHostRunResumable(home: string, runId: string): Promise<boolean> {
84
+ const { piDurablePrincipalAuthority } = await import("./pi/durable-principal.ts");
85
+ const { loadResumableInstructionSeatRun } = await import("./public-cli/run-lifecycle.ts");
86
+ try {
87
+ const loaded = await loadResumableInstructionSeatRun(home, runId, piDurablePrincipalAuthority);
88
+ return loaded.admitted.role === "navigator";
89
+ } catch (error) {
90
+ if (error instanceof CliUsageError) return false;
91
+ throw error;
92
+ }
93
+ }
94
+
95
+ export type NavigatorPublicSummon = (options: {
96
+ readonly role: "navigator";
97
+ readonly argv: readonly string[];
98
+ readonly cwd: string;
99
+ readonly home?: string;
100
+ readonly resumeRunId?: string;
101
+ }) => Promise<PublicSummonResult>;
102
+
103
+ export function createNativeNavigatorSessionFactory(deps?: {
104
+ /** Test/composition inject — production leaves unset and uses public-role-summons. */
105
+ readonly summonPublicRole?: NavigatorPublicSummon;
106
+ /** Test inject — production uses navigatorHostRunResumable (typed CLI load). */
107
+ readonly hostRunResumable?: (home: string, runId: string) => Promise<boolean>;
108
+ }): NavigatorSessionFactory {
44
109
  return async ({ context, subject, tool }) => {
45
110
  // Model is enforced at prepare (attendance seat resolve) and at prompt (summon).
46
111
  // Factory open only books the archivist nest — no package default, no eager seat read.
47
112
  let thinkingLevel: string | undefined;
48
113
 
49
- // Archivist nest for attendance route memory only (ADR 0018 / 0065) — not a session open.
114
+ // Archivist nest for attendance bookkeeping (ADR 0018 / 0065) — not a host session open.
50
115
  // #852: navigator/<work-subject> is the sole book-top exception; always pass subject so
51
116
  // unmaterialized/missing parent still gets a durable nest instead of silent in-memory.
52
117
  // Home comes from HostContext.runDirectory (or ledger parent path) — never context.home.
@@ -64,6 +129,14 @@ export function createNativeNavigatorSessionFactory(): NavigatorSessionFactory {
64
129
  let noReceipt: NoReceiptLifecycleFacts | undefined;
65
130
  let disposed = false;
66
131
  let inFlightPrompt: Promise<unknown> | undefined;
132
+ /** In-factory host run id for CLI resume; durable pointer also lives on the nest. */
133
+ let hostRunId = readNavigatorHostRunPointer(sessionManager.getEntries() as readonly unknown[]);
134
+
135
+ const summon: NavigatorPublicSummon = deps?.summonPublicRole
136
+ ?? (async (options) => {
137
+ const { summonPublicRole } = await import("./public-role-summons.ts");
138
+ return summonPublicRole(options);
139
+ });
67
140
 
68
141
  return {
69
142
  prompt: async (text) => {
@@ -74,15 +147,28 @@ export function createNativeNavigatorSessionFactory(): NavigatorSessionFactory {
74
147
  noReceipt = undefined;
75
148
  const run = (async () => {
76
149
  try {
77
- const { summonPublicRole } = await import("./public-role-summons.ts");
78
150
  const summonHome = await resolveNavigatorLedgerHome(context);
79
- // Public activation — same face as `ak-role navigator <instruction>` (#675).
80
- const summoned = await summonPublicRole({
81
- role: "navigator",
82
- argv: [text],
151
+ const resumeRunId = hostRunId;
152
+
153
+ const baseSummon = {
154
+ role: "navigator" as const,
155
+ argv: [text] as const,
83
156
  cwd: context.cwd,
84
157
  ...(summonHome === undefined ? {} : { home: summonHome }),
85
- });
158
+ };
159
+
160
+ // Prefer host CLI resume so prior advice stays on the host session.
161
+ // Fresh mint only when typed load says the principal cannot reopen.
162
+ const resumable = deps?.hostRunResumable ?? navigatorHostRunResumable;
163
+ let summoned: PublicSummonResult;
164
+ if (resumeRunId === undefined || summonHome === undefined) {
165
+ summoned = await summon(baseSummon);
166
+ } else if (await resumable(summonHome, resumeRunId)) {
167
+ summoned = await summon({ ...baseSummon, resumeRunId });
168
+ } else {
169
+ hostRunId = undefined;
170
+ summoned = await summon(baseSummon);
171
+ }
86
172
 
87
173
  const outcome = summoned.terminal?.roleOutcome;
88
174
  if (outcome === undefined) {
@@ -109,23 +195,40 @@ export function createNativeNavigatorSessionFactory(): NavigatorSessionFactory {
109
195
  noReceipt = outcome;
110
196
  return;
111
197
  }
198
+
199
+ // Pin host run for the next attendance prompt on this subject (CLI resume key).
200
+ const runDirectory = summoned.runDirectory
201
+ ?? (typeof summoned.admitted?.runDirectory === "string"
202
+ ? summoned.admitted.runDirectory
203
+ : undefined);
204
+ if (typeof runDirectory === "string" && runDirectory.trim() !== "") {
205
+ const nextRunId = runIdFromNavigatorDirectory(runDirectory);
206
+ if (nextRunId !== undefined) {
207
+ hostRunId = nextRunId;
208
+ sessionManager.appendCustomEntry(NAVIGATOR_HOST_RUN_POINTER_ENTRY, { runId: nextRunId });
209
+ }
210
+ }
211
+
112
212
  if (outcome.kind !== "accepted") {
113
- // Non-accepted kinds (e.g. audit_escalation): no route advice this turn.
213
+ // Non-accepted kinds (e.g. audit_escalation): no prose advice this turn.
114
214
  // Not a shape-unusable judgment on the navigator reply (#757).
115
215
  return;
116
216
  }
217
+ // #959: present navigator words as prose. No candidates/next parsing.
218
+ const { navigatorProseFromUnknown } = await import("./package-contracts/navigator-output.ts");
219
+ const proseParts: string[] = [];
117
220
  for (const payload of outcome.payloads ?? []) {
118
- if (typeof payload !== "object" || payload === null || Array.isArray(payload)) continue;
119
- const candidates = (payload as { candidates?: unknown }).candidates;
120
- if (!Array.isArray(candidates)) continue;
121
- await tool.execute(
122
- "navigator-public-prepare",
123
- { candidates },
124
- undefined,
125
- undefined,
126
- context as never,
127
- );
221
+ const prose = navigatorProseFromUnknown(payload);
222
+ if (prose !== undefined) proseParts.push(prose);
128
223
  }
224
+ if (proseParts.length === 0) return;
225
+ await tool.execute(
226
+ "navigator-public-prepare",
227
+ { prose: proseParts.join("\n\n") },
228
+ undefined,
229
+ undefined,
230
+ context as never,
231
+ );
129
232
  } catch (error) {
130
233
  if (error instanceof NavigatorUnavailableError) throw error;
131
234
  const fact = navigatorProviderFailureFromError(error);
@@ -166,10 +269,8 @@ export function createNativeNavigatorSessionFactory(): NavigatorSessionFactory {
166
269
  } catch (error) {
167
270
  throw navigatorUnavailableError("model", error);
168
271
  }
169
- // No live provider session is pinned here: every prompt is an independent
170
- // public summon whose nested CLI reads the current seat table (#675 验收②
171
- // / #617 DK-3). A seat edit between prepares therefore applies on the next
172
- // summon — it is never an unavailable session.
272
+ // Resume and fresh mint both read the live seat table (#675 / #617 DK-3).
273
+ // A seat edit between prepares applies on the next host turn.
173
274
  thinkingLevel = nextThinking ?? nextParsed.thinkingLevel;
174
275
  },
175
276
  getThinkingLevel: () => thinkingLevel,
@@ -2,12 +2,12 @@ import { NAVIGATOR_OUTPUT_TOOL_NAME, navigatorOutputSchema } from "./package-con
2
2
 
3
3
  /**
4
4
  * 决定工具规格。生命周期装配归注册信封 owner——src/role-runtime.ts(ADR 0018)。
5
- * Candidates share the route-advice shape the attendance prepare tool owns.
5
+ * #959: navigator speaks free-form prose; tool is a lifecycle vehicle only.
6
6
  */
7
7
  export const NAVIGATOR_TOOL_SPEC = {
8
8
  name: NAVIGATOR_OUTPUT_TOOL_NAME,
9
9
  label: "游奕使建议",
10
- description: "游奕使终局回执:排好序的下一步角色路线建议。",
10
+ description: "游奕使终局回执:散文建议,原样呈现。",
11
11
  promptSnippet: "游奕使建议",
12
12
  parameters: navigatorOutputSchema,
13
13
  } as const;
@@ -1,7 +1,8 @@
1
1
  /**
2
- * Public Navigator (游奕使) terminating receipt contracts (#639).
3
- * Direct public seat submits route advice with the same candidate shape the
4
- * attendance prepare tool owns — one shape authority for navigator advice.
2
+ * Public Navigator (游奕使) terminating receipt contracts (#639 / #959).
3
+ * Navigator speaks free-form prose. The tool is only a lifecycle vehicle so the
4
+ * host can seal a turn; the model-facing content is prose, not structured route
5
+ * candidates. Code does not parse, rank, or judge the advice.
5
6
  * No usable result is infrastructure failure via public settlement, not a judgment status (#475).
6
7
  */
7
8
  import { Type } from "typebox";
@@ -15,42 +16,57 @@ export const NAVIGATOR_ACCEPTED_TEXT = "游奕使建议已受理";
15
16
  export const navigatorOutputSchema = withTerminatingOutputDeclarations(
16
17
  openToolObject(
17
18
  Type.Object({
18
- status: Type.Unknown({
19
- description: "advice — 形状指引,非 schema 闸",
20
- }),
21
- candidates: Type.Unknown({
22
- description: "排好序的路线建议数组,元素含 next/phase/reason — 形状指引,非 schema 闸",
19
+ prose: Type.Unknown({
20
+ description: "游奕使散文建议,原样呈现。不要求 candidates/next 等结构化字段。形状指引,非 schema 闸",
23
21
  }),
24
22
  }),
25
23
  ),
26
24
  );
27
25
 
28
- function isRecord(value: unknown): value is Record<string, unknown> {
29
- return typeof value === "object" && value !== null && !Array.isArray(value);
30
- }
31
-
32
- export type NavigatorAdvice = { readonly status: "advice"; readonly candidates: readonly unknown[] };
26
+ export type NavigatorAdvice = { readonly prose: string };
33
27
 
34
28
  /**
35
- * Project one lawful explicit Navigator advice receipt: candidates array,
36
- * each candidate a record. Broken ancillary fields are preserved as submitted —
37
- * shape is not an admission gate (ADR 0055 / 第 0 条).
29
+ * Project one Navigator advice receipt as prose.
30
+ * Accepts { prose }, a bare string, or any object — objects stringify as the
31
+ * prose body so historical / free-form submissions still present. Shape is not
32
+ * an admission gate (ADR 0055 / 第 0 条 / #959).
38
33
  */
39
34
  export function projectLawfulNavigatorOutput(value: unknown): NavigatorAdvice | undefined {
40
- // #836: no field drop
41
- return (typeof value === "object" && value !== null && !Array.isArray(value))
42
- ? (value as NavigatorAdvice)
43
- : undefined;
35
+ if (value === undefined || value === null) return undefined;
36
+ if (typeof value === "string") return { prose: value };
37
+ if (typeof value === "object" && !Array.isArray(value)) {
38
+ const record = value as Record<string, unknown>;
39
+ if (typeof record.prose === "string") return { prose: record.prose };
40
+ if (record.prose !== undefined && record.prose !== null) {
41
+ return { prose: typeof record.prose === "string" ? record.prose : String(record.prose) };
42
+ }
43
+ // Free-form object submission: present the whole body as prose when it has content.
44
+ const keys = Object.keys(record);
45
+ if (keys.length === 0) return { prose: "" };
46
+ try {
47
+ return { prose: JSON.stringify(record) };
48
+ } catch {
49
+ return { prose: String(record) };
50
+ }
51
+ }
52
+ return { prose: String(value) };
44
53
  }
45
54
 
46
55
  /**
47
- * Settlement/recording path: only lawful recorded candidate arrays.
48
- * Does not gate role admission — callers must not use this to reject a submission.
56
+ * Settlement/recording path: project prose. Does not gate role admission.
49
57
  */
50
58
  export function validateRecordedNavigatorOutput(value: unknown): NavigatorAdvice {
51
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
52
- throw new Error("Navigator output is not an object");
59
+ const projected = projectLawfulNavigatorOutput(value);
60
+ if (projected === undefined) {
61
+ throw new Error("Navigator output is empty");
53
62
  }
54
- return value as NavigatorAdvice;
63
+ return projected;
55
64
  }
56
65
 
66
+ /** Extract display prose from any navigator payload / attendance body. */
67
+ export function navigatorProseFromUnknown(value: unknown): string | undefined {
68
+ const projected = projectLawfulNavigatorOutput(value);
69
+ if (projected === undefined) return undefined;
70
+ const trimmed = projected.prose.trim();
71
+ return trimmed === "" ? undefined : projected.prose;
72
+ }
@@ -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,
@@ -22,7 +22,7 @@ import {
22
22
  type AdmittedCoderInvocation,
23
23
  } from "./invocation.ts";
24
24
  import {
25
- buildResumeContinuationPrompt,
25
+ buildAutoResumeContinuationPrompt,
26
26
  loadResumableCoderRun,
27
27
  markRunAdmitted,
28
28
  type PublicResumeRequest,
@@ -198,7 +198,7 @@ export async function runPublicCoder(
198
198
  : { correlationId: admitted.correlationId ?? env.correlationId }),
199
199
  continuation: {
200
200
  kind: "resume",
201
- prompt: buildResumeContinuationPrompt({
201
+ prompt: buildAutoResumeContinuationPrompt({
202
202
  packageRoot: env.packageRoot,
203
203
  ...pickEngineAxis(env),
204
204
  }),
@@ -25,7 +25,7 @@ import {
25
25
  type AdmittedFixerInvocation,
26
26
  } from "./invocation.ts";
27
27
  import {
28
- buildResumeContinuationPrompt,
28
+ buildAutoResumeContinuationPrompt,
29
29
  loadResumableFixerRun,
30
30
  markRunAdmitted,
31
31
  type PublicResumeRequest,
@@ -213,7 +213,7 @@ export async function runPublicFixer(
213
213
  : { correlationId: admitted.correlationId ?? env.correlationId }),
214
214
  continuation: {
215
215
  kind: "resume",
216
- prompt: buildResumeContinuationPrompt({
216
+ prompt: buildAutoResumeContinuationPrompt({
217
217
  packageRoot: env.packageRoot,
218
218
  ...pickEngineAxis(env),
219
219
  }),
@@ -18,7 +18,7 @@ import {
18
18
  import {
19
19
  loadResumableJudgeRun,
20
20
  markRunAdmitted,
21
- buildResumeContinuationPrompt,
21
+ buildAutoResumeContinuationPrompt,
22
22
  type PublicResumeRequest,
23
23
  } from "./run-lifecycle.ts";
24
24
  import {
@@ -152,7 +152,7 @@ export async function runPublicJudge(
152
152
  : { correlationId: admitted.correlationId ?? env.correlationId }),
153
153
  continuation: {
154
154
  kind: "resume",
155
- prompt: buildResumeContinuationPrompt({
155
+ prompt: buildAutoResumeContinuationPrompt({
156
156
  packageRoot: env.packageRoot,
157
157
  ...pickEngineAxis(env),
158
158
  }),
@@ -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
+ }
@@ -24,7 +24,7 @@ import {
24
24
  import {
25
25
  loadResumableMergerRun,
26
26
  markRunAdmitted,
27
- buildResumeContinuationPrompt,
27
+ buildAutoResumeContinuationPrompt,
28
28
  type PublicResumeRequest,
29
29
  } from "./run-lifecycle.ts";
30
30
  import {
@@ -241,7 +241,7 @@ export async function runPublicMerger(
241
241
  : { correlationId: admitted.correlationId ?? env.correlationId }),
242
242
  continuation: {
243
243
  kind: "resume",
244
- prompt: buildResumeContinuationPrompt({
244
+ prompt: buildAutoResumeContinuationPrompt({
245
245
  packageRoot: env.packageRoot,
246
246
  ...pickEngineAxis(env),
247
247
  }),
@@ -1280,7 +1280,7 @@ const ROLE_COMMAND_HELP = {
1280
1280
  },
1281
1281
  navigator: {
1282
1282
  command: "navigator",
1283
- summary: "Direct Navigator (游奕使) route advice: ordered next-role candidates.",
1283
+ summary: "Direct Navigator (游奕使) free-form prose route advice.",
1284
1284
  usage: ["ak-role navigator [options] [instruction]"],
1285
1285
  examples: [
1286
1286
  'ak-role navigator "刚完成 coder apply 收敛,下一步?"',