@akagilnc/pi-workflow-roles 0.1.4586 → 0.1.4620

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 (47) hide show
  1. package/README.md +2 -2
  2. package/README.zh-CN.md +2 -2
  3. package/dist/acp-host/production-host.js +951 -497
  4. package/dist/archivist-record-topology.js +27 -35
  5. package/dist/headless-host/description.js +7 -4
  6. package/dist/headless-host/production-host.js +783 -393
  7. package/dist/migrate-book-topology.js +3 -6
  8. package/dist/navigator-attendance.js +146 -241
  9. package/dist/navigator-public-session.js +81 -15
  10. package/dist/package-contracts/navigator-output.js +30 -12
  11. package/dist/public-cli/judge-run.js +2 -2
  12. package/dist/public-cli/main.js +69 -94
  13. package/dist/public-cli/option-definitions.js +1 -1
  14. package/dist/public-cli/post-admission.js +2 -2
  15. package/dist/public-cli/run-lifecycle.js +29 -7
  16. package/dist/public-cli/settlement.js +46 -32
  17. package/dist/public-cli/terminal.js +8 -27
  18. package/dist/public-role-summons.js +22 -4
  19. package/dist/submission-ledger.js +56 -23
  20. package/package.json +1 -1
  21. package/resources/836-deleted-machine-instruction-inventory.md +2 -0
  22. package/resources/navigator-route-playbook.md +9 -0
  23. package/scripts/build-package.mjs +6 -1
  24. package/src/acp-host/role-turn-host.ts +129 -13
  25. package/src/headless-host/description.ts +17 -7
  26. package/src/headless-host/role-turn-host.ts +42 -11
  27. package/src/navigator-attendance.ts +290 -425
  28. package/src/navigator-public-session.ts +126 -25
  29. package/src/navigator-role.ts +2 -2
  30. package/src/package-contracts/navigator-output.ts +41 -25
  31. package/src/public-cli/coder-run.ts +2 -2
  32. package/src/public-cli/fixer-run.ts +2 -2
  33. package/src/public-cli/judge-run.ts +2 -2
  34. package/src/public-cli/merger-run.ts +2 -2
  35. package/src/public-cli/option-definitions.ts +1 -1
  36. package/src/public-cli/post-admission.ts +2 -1
  37. package/src/public-cli/run-lifecycle.ts +37 -7
  38. package/src/public-cli/settlement.ts +42 -33
  39. package/src/public-cli/terminal.ts +12 -37
  40. package/src/public-role-summons.ts +34 -5
  41. package/src/role-envelope.ts +89 -4
  42. package/src/role-runtime.ts +74 -14
  43. package/src/submission-ledger.ts +63 -21
  44. package/dist/public-cli/command-renderer.js +0 -5
  45. package/dist/public-command-renderer.js +0 -29
  46. package/src/public-cli/command-renderer.ts +0 -9
  47. package/src/public-command-renderer.ts +0 -55
@@ -10,7 +10,7 @@ import {
10
10
  NAVIGATOR_INVOCATION_ENTRY,
11
11
  mintNavigatorInvocationId,
12
12
  } from "./navigator-invocation-identity.ts";
13
- import { PACKAGED_ROLE_REGISTRY, type PackagedRole, packagedRoleMetadata } from "./packaged-role-registry.ts";
13
+ import { PACKAGED_ROLE_REGISTRY, type PackagedRole } from "./packaged-role-registry.ts";
14
14
  import {
15
15
  activationBookDirectory,
16
16
  resolveActivationLedgerHome,
@@ -58,9 +58,9 @@ export {
58
58
  };
59
59
  export { createNativeNavigatorSessionFactory };
60
60
  export { resolveNavigatorSeatSelection };
61
- import { renderPublicAkRoleCommand } from "./public-command-renderer.ts";
62
61
  import { issueRoot, subjectPath } from "./work-subject-identity.ts";
63
62
  import { createReceiptDeliveryPolicy, NO_RECEIPT_LIFECYCLE_ENTRY_TYPE, RECEIPT_DELIVERY_PROMPT } from "./receipt-delivery-policy.ts";
63
+ import { navigatorProseFromUnknown } from "./package-contracts/navigator-output.ts";
64
64
 
65
65
  export const NAVIGATOR_EVENT_TYPE = "ak-navigator-attendance" as const;
66
66
  export const NAVIGATOR_PREPARE_ACCEPTED_TEXT = "游奕使准备已接受";
@@ -100,24 +100,15 @@ export type NavigatorWorkContext = {
100
100
  contextError?: unknown;
101
101
  };
102
102
 
103
- export type NavigatorRouteTarget = { role: string; phase: NavigatorPhase };
104
- /** Normalized preparation advice. v1 success needs machine-usable next only. */
105
- export type NavigatorCandidate = {
106
- id?: string;
107
- matches?: { role: string; phase: NavigatorPhase; kind: "accepted"; statuses?: string[] };
108
- route?: NavigatorRouteTarget[];
109
- next?: NavigatorRouteTarget;
110
- reason?: string;
111
- command?: string;
112
- };
113
-
103
+ /**
104
+ * #959: Navigator speaks free-form prose. Code does not parse, rank, or judge
105
+ * the advice — only whether attendance itself failed (host/process).
106
+ */
114
107
  export type NavigatorReport = {
115
108
  /** Affirmative attendance only. Lawful no-advice is typed, never inferred from absence. */
116
- disposition: "recommendation" | "no-advice" | "unavailable" | "arrival";
117
- route?: NavigatorRouteTarget[];
118
- next?: NavigatorRouteTarget;
119
- reason?: string;
120
- command?: string;
109
+ disposition: "advice" | "no-advice" | "unavailable" | "arrival";
110
+ /** Present when disposition is advice — navigator words, presented as-is. */
111
+ prose?: string;
121
112
  unavailableReason?: string;
122
113
  unavailableSource?: NavigatorUnavailableKey;
123
114
  unavailableCause?: NavigatorUnavailableKey;
@@ -132,10 +123,7 @@ export type NavigatorEvent = {
132
123
  role: string;
133
124
  phase: NavigatorPhase;
134
125
  subjectKey: string;
135
- route?: NavigatorRouteTarget[];
136
- next?: NavigatorRouteTarget;
137
- reason?: string;
138
- command?: string;
126
+ prose?: string;
139
127
  unavailableReason?: string;
140
128
  unavailableSource?: NavigatorUnavailableKey;
141
129
  unavailableCause?: NavigatorUnavailableKey;
@@ -149,21 +137,17 @@ export type NavigatorContextProjection = {
149
137
  subject: string;
150
138
  authority: string;
151
139
  currentRole: { role: string; phase: NavigatorPhase };
152
- /** Present only on settlement-bound prepare; speculative prepare omits it. */
140
+ /** Present on the unique settlement-bound advice prompt. */
153
141
  currentSettlement?: NavigatorSettlement;
154
- priorRoute: NavigatorRouteTarget[] | null;
155
142
  publicSettlementHistory: NavigatorSettlementFact[];
156
143
  liveRoleHelp: Array<{ role: NavigatorTargetRole; help: string }>;
157
144
  };
158
145
 
159
- // Provider admission is ADR 0060 object root only. Nested advisory shape
160
- // (candidates/next/route/matches/reason/command) is never a gate — every object
161
- // root reaches the unique execute/normalize path exactly once.
162
- // Field guidance only — candidates shape is never an acceptance gate (Rule 0).
146
+ // #959: prepare is a prose vehicle. Object root only (ADR 0060); nested shape is
147
+ // never a gate — every object root reaches execute exactly once (Rule 0).
163
148
  const prepareSchema = Type.Object({
164
- candidates: Type.Optional(Type.Unknown({
165
- description:
166
- "方向候选;candidates[].next.role 必填,phase 可选,route/matches/reason/command 可选上下文,非受理闸",
149
+ prose: Type.Optional(Type.Unknown({
150
+ description: "游奕使散文建议,原样呈现。不要求 candidates/next 结构。非受理闸",
167
151
  })),
168
152
  }, { additionalProperties: true });
169
153
  type PrepareOutput = Static<typeof prepareSchema>;
@@ -186,11 +170,9 @@ export type NavigatorAttendanceOptions = {
186
170
  onEvent: (event: NavigatorEvent, report: NavigatorReport) => void | Promise<void>;
187
171
  };
188
172
 
189
- const ROUTE_ENTRY = "ak-navigator-route";
190
173
  const CONTEXT_ENTRY = "ak-navigator-context";
191
174
  const INVOCATION_ENTRY = NAVIGATOR_INVOCATION_ENTRY;
192
175
  const SETTLEMENT_ENTRY = "ak-navigator-settlement";
193
- const targetRoles = new Set<string>(NAVIGATOR_TARGETS.map(({ role }) => role));
194
176
  const unavailableKeys = new Set<NavigatorUnavailableKey>(["context", "session", "model", "thinking", "auth", "quota", "transport", "unknown"]);
195
177
 
196
178
  function unavailableKey(value: unknown): NavigatorUnavailableKey | undefined {
@@ -232,85 +214,15 @@ function rejectedPrepareReason(entries: readonly unknown[], start: number): stri
232
214
  }
233
215
  return reason;
234
216
  }
235
- function targetIsValid(value: unknown): value is NavigatorRouteTarget {
236
- if (!exactRecord(value) || !targetRoles.has(String(value.role))) return false;
237
- const metadata = packagedRoleMetadata(String(value.role));
238
- return metadata !== undefined && metadata.phases.includes(value.phase as never);
239
- }
240
-
241
- /** Keep the role/phase the model wrote. Unknown seats stay on the advice. */
242
- function normalizeTarget(value: unknown): NavigatorRouteTarget | undefined {
243
- if (!exactRecord(value)) return undefined;
244
- const role = typeof value.role === "string" ? value.role.trim() : "";
245
- if (role === "") return undefined;
246
- if (value.phase === undefined || value.phase === null) {
247
- return { role, phase: null };
248
- }
249
- if (value.phase === "plan" || value.phase === "apply") {
250
- return { role, phase: value.phase };
251
- }
252
- return { role, phase: null };
253
- }
254
217
 
255
- function normalizeMatches(value: unknown): NavigatorCandidate["matches"] | undefined {
256
- if (!exactRecord(value)) return undefined;
257
- if (typeof value.role !== "string" || value.role.trim() === "") return undefined;
258
- if (value.kind !== "accepted") return undefined;
259
- let phase: NavigatorPhase;
260
- if (value.phase === undefined || value.phase === null) phase = null;
261
- else if (value.phase === "plan" || value.phase === "apply") phase = value.phase;
262
- else return undefined;
263
- if (value.statuses !== undefined) {
264
- if (!Array.isArray(value.statuses) || value.statuses.some((status) => typeof status !== "string" || status.trim() === "")) {
265
- return undefined;
266
- }
267
- return {
268
- role: value.role,
269
- phase,
270
- kind: "accepted",
271
- statuses: [...value.statuses],
272
- };
273
- }
274
- return { role: value.role, phase, kind: "accepted" };
275
- }
276
-
277
- /** Normalize one submitted candidate. Broken ancillary fields are dropped, never a rejection. */
278
- function normalizeCandidate(value: unknown): NavigatorCandidate | undefined {
279
- if (!exactRecord(value)) return undefined;
280
- const next = normalizeTarget(value.next);
281
- const route = Array.isArray(value.route)
282
- ? value.route.map(normalizeTarget).filter((target): target is NavigatorRouteTarget => target !== undefined)
283
- : undefined;
284
- const matches = normalizeMatches(value.matches);
285
- const id = typeof value.id === "string" && value.id.trim() !== "" ? value.id : undefined;
286
- const reason = typeof value.reason === "string" && value.reason.trim() !== "" ? value.reason : undefined;
287
- const command = typeof value.command === "string" ? value.command : undefined;
288
- return {
289
- ...(id === undefined ? {} : { id }),
290
- ...(matches === undefined ? {} : { matches }),
291
- ...(route === undefined || route.length === 0 ? {} : { route }),
292
- ...(next === undefined ? {} : { next }),
293
- ...(reason === undefined ? {} : { reason }),
294
- ...(command === undefined ? {} : { command }),
295
- };
296
- }
297
-
298
- /** Accept any prepare submission shape; missing/malformed candidates become empty advice. */
299
- function normalizePrepareOutput(value: unknown): NavigatorCandidate[] {
300
- if (!exactRecord(value) || !Array.isArray(value.candidates)) return [];
301
- return value.candidates
302
- .map(normalizeCandidate)
303
- .filter((candidate): candidate is NavigatorCandidate => candidate !== undefined);
304
- }
305
-
306
- function routeEqual(a: readonly NavigatorRouteTarget[] | undefined, b: readonly NavigatorRouteTarget[]): boolean {
307
- return a !== undefined && a.length === b.length && a.every((target, index) => target.role === b[index]!.role && target.phase === b[index]!.phase);
308
- }
309
- function routeText(route: readonly NavigatorRouteTarget[]): string {
310
- return route.map((target) => target.phase === null ? target.role : `${target.role} ${target.phase}`).join(" → ");
311
- }
312
- function targetText(target: NavigatorRouteTarget): string {
313
- return target.phase === null ? target.role : `${target.role} ${target.phase}`;
218
+ /**
219
+ * #959: extract prose from any prepare submission shape. Missing/empty → undefined.
220
+ * Never a rejection — shape is not an admission gate.
221
+ * Production prose arrives via nested public summon → prepare tool.execute only;
222
+ * archivist entries() never carries assistant message text, so no parallel harvest.
223
+ */
224
+ function normalizePrepareProse(value: unknown): string | undefined {
225
+ return navigatorProseFromUnknown(value);
314
226
  }
315
227
 
316
228
  export function navigatorSubjectKey(
@@ -346,67 +258,16 @@ export function createNavigatorPrepareTool(onOutput: (value: PrepareOutput) => v
346
258
  return {
347
259
  name: NAVIGATOR_PREPARE_TOOL_NAME,
348
260
  label: "游奕使准备",
349
- description: "提交游奕使方向建议。",
261
+ description: "提交游奕使散文建议。",
350
262
  parameters: prepareSchema,
351
263
  async execute(_id, value) {
352
- // Rule 0: the unique prepare submission is accepted once. Ancillary shape is
353
- // normalized later; never open a format-correction retry loop here.
264
+ // Rule 0: the unique prepare submission is accepted once. Shape is never a gate.
354
265
  onOutput(value as PrepareOutput);
355
266
  return { content: [{ type: "text" as const, text: NAVIGATOR_PREPARE_ACCEPTED_TEXT }], details: value, terminate: true as const };
356
267
  },
357
268
  };
358
269
  }
359
270
 
360
- /**
361
- * Candidate pick plus whether matches keyed it to this settlement.
362
- * Single owner for role/phase/status match truth (selection ranking + stale-context rebind).
363
- * Structural only — never inspects next.role for routing legality.
364
- */
365
- export type NavigatorCandidateSelection = {
366
- readonly candidate: NavigatorCandidate;
367
- /** True when matches keyed this candidate to the settlement (no stale-context rebind). */
368
- readonly matchedToSettlement: boolean;
369
- };
370
-
371
- export function selectNavigatorCandidate(
372
- candidates: readonly NavigatorCandidate[],
373
- settlement: NavigatorSettlement,
374
- ): NavigatorCandidateSelection | undefined {
375
- if (settlement.kind !== "accepted") return undefined;
376
- const usable = candidates.filter((candidate) => candidate.next !== undefined);
377
- if (usable.length === 0) return undefined;
378
- const rolePhaseMatched = usable.filter((candidate) =>
379
- candidate.matches !== undefined
380
- && candidate.matches.role === settlement.role
381
- && candidate.matches.phase === settlement.phase,
382
- );
383
- // Status-specific candidates outrank role/phase generics regardless of declaration order.
384
- if (rolePhaseMatched.length > 0) {
385
- if (settlement.status !== undefined) {
386
- const statusSpecific = rolePhaseMatched.find(
387
- (candidate) =>
388
- candidate.matches?.statuses !== undefined &&
389
- candidate.matches.statuses.includes(settlement.status!),
390
- );
391
- if (statusSpecific !== undefined) {
392
- return { candidate: statusSpecific, matchedToSettlement: true };
393
- }
394
- }
395
- const rolePhaseGeneric = rolePhaseMatched.find(
396
- (candidate) => candidate.matches?.statuses === undefined,
397
- );
398
- if (rolePhaseGeneric !== undefined) {
399
- return { candidate: rolePhaseGeneric, matchedToSettlement: true };
400
- }
401
- return undefined;
402
- }
403
- // v1 direction-only / broken matches: absent match metadata must not drop a usable next.
404
- // Not settlement-keyed → caller may run one stale-context rebind.
405
- const unbound = usable.find((candidate) => candidate.matches === undefined);
406
- if (unbound === undefined) return undefined;
407
- return { candidate: unbound, matchedToSettlement: false };
408
- }
409
-
410
271
  export function formatNavigatorReport(report: NavigatorReport): string {
411
272
  const playbookFailure = report.routePlaybookReadFailure === undefined
412
273
  ? []
@@ -418,54 +279,27 @@ export function formatNavigatorReport(report: NavigatorReport): string {
418
279
  if (report.disposition === "arrival") {
419
280
  return [...playbookFailure, ...(report.arrivalMessage ? [report.arrivalMessage] : [])].join("\n");
420
281
  }
421
- return [
422
- ...playbookFailure,
423
- ...(report.route === undefined ? [] : [`路线:${routeText(report.route)}`]),
424
- ...(report.next === undefined ? [] : [`下一步:${targetText(report.next)}`]),
425
- ...(report.reason === undefined || report.reason.trim() === "" ? [] : [`理由:${report.reason}`]),
426
- ...(report.command === undefined || report.command.trim() === "" ? [] : [`命令:${report.command}`]),
427
- ].join("\n");
428
- }
429
-
430
- export type SettlementNavigation = {
431
- disposition: "recommendation";
432
- route?: NavigatorRouteTarget[];
433
- next: NavigatorRouteTarget;
434
- reason?: string;
435
- command?: string;
436
- };
437
-
438
- /** Recommendation essentials for the one mandatory last-ak_*_output extraction. */
439
- export function settlementNavigationFromEvent(event: NavigatorEvent): SettlementNavigation | undefined {
440
- if (event.disposition !== "recommendation") return undefined;
441
- if (event.next === undefined) return undefined;
442
- return {
443
- disposition: "recommendation",
444
- ...(event.route === undefined ? {} : { route: event.route }),
445
- next: event.next,
446
- ...(event.reason === undefined ? {} : { reason: event.reason }),
447
- ...(event.command === undefined ? {} : { command: event.command }),
448
- };
282
+ // advice — prose as-is
283
+ return [...playbookFailure, ...(report.prose ? [report.prose] : [])].join("\n");
449
284
  }
450
285
 
451
286
  export function createNavigatorAttendance(options: NavigatorAttendanceOptions) {
452
- let preparation: Promise<NavigatorCandidate[]> | undefined;
287
+ let preparation: Promise<string | undefined> | undefined;
288
+ let preparationInFlight = false;
453
289
  let sessionReady: Promise<NavigatorPreparationSession> | undefined;
454
290
  let session: NavigatorPreparationSession | undefined;
455
291
  let subjectKey = options.subjectKey;
456
292
  let subject = options.subject;
457
293
  let authority = options.authority;
458
294
  let contextError = options.contextError;
459
- let candidates: NavigatorCandidate[] | undefined;
295
+ let preparedProse: string | undefined;
460
296
  // Shared lifecycle owns the principal when supplied; otherwise mint once per attendance.
461
297
  const invocationPrincipal = options.invocationId ?? mintNavigatorInvocationId();
462
298
  let activeInvocationId: string | undefined = invocationPrincipal;
463
- let previousRoute: NavigatorRouteTarget[] | undefined;
464
299
  let outputSink: ((value: PrepareOutput) => void) | undefined;
465
300
  let settlementTail: Promise<void> = Promise.resolve();
466
301
  let settlementFailure: unknown;
467
302
  let preparationFailure: unknown;
468
- let preparationNoReceipt = false;
469
303
  let routePlaybookReadFailure: string | undefined;
470
304
  let disposed = false;
471
305
  /** One-shot live-help warm; consumed by the next prepare so later prepares reread live help. */
@@ -496,19 +330,23 @@ export function createNavigatorAttendance(options: NavigatorAttendanceOptions) {
496
330
  };
497
331
  };
498
332
  let routePlaybookSettlement: Promise<void> | undefined;
499
- /** Settlement-bound prepare only; cleared at the start of each prepare body. */
333
+ /**
334
+ * Settlement fed for the output prompt. Early prepare() runs the host round
335
+ * from parent start (ready and wait); settleOnce feeds currentSettlement and
336
+ * lets navigator speak. No candidates/next parse (owner 2026-09-17 #959).
337
+ */
500
338
  let prepareBoundSettlement: NavigatorSettlement | undefined;
501
- const prepare = async (): Promise<NavigatorCandidate[]> => {
502
- // Exact principal is owned by shared lifecycle (or one mint per attendance).
503
- // Model/tool/advice paths cannot override it; role-session persistence is
504
- // pi.appendEntry at lifecycle start — not optional sessionManager probing.
505
- const boundSettlement = prepareBoundSettlement;
506
- prepareBoundSettlement = undefined;
507
- // Each prepare owns the no-receipt flag; a later settlement-bound rebind must
508
- // not inherit a speculative no-receipt outcome.
509
- preparationNoReceipt = false;
510
- const invocationId = invocationPrincipal;
511
- activeInvocationId = invocationId;
339
+ type MaterialsBundle = {
340
+ soul: string;
341
+ routePlaybook: string;
342
+ helpContext: string;
343
+ modelSetting: string;
344
+ model: ReturnType<typeof parseNavigatorModelSetting>;
345
+ help: Array<{ role: NavigatorTargetRole; help: string }>;
346
+ activeSession: NavigatorPreparationSession;
347
+ };
348
+ /** Materials + session only — no host prompt (cold settle book-before-feed). */
349
+ const loadMaterialsAndSession = async (invocationId: string): Promise<MaterialsBundle> => {
512
350
  if (contextError !== undefined) throw navigatorUnavailableError("context", contextError);
513
351
  if (typeof authority !== "string" || authority.trim() === "") {
514
352
  throw navigatorUnavailableError(
@@ -516,161 +354,173 @@ export function createNavigatorAttendance(options: NavigatorAttendanceOptions) {
516
354
  new Error("controlling authority content was not supplied as typed work context"),
517
355
  );
518
356
  }
519
-
520
- // Load soul / model setting / live help in parallel. Live help is N pi --help
521
- // subprocesses; serializing it behind session create made the post-role 10s
522
- // grace cover help-boot under load (ENOENT / unavailable flake). Session
523
- // create still follows context load so tool registration and prompt stay
524
- // one readiness step for callers.
525
- let soul: string;
526
- let modelSetting: string;
527
- let help: Array<{ role: NavigatorTargetRole; help: string }>;
528
- let routePlaybook = "";
529
- routePlaybookReadFailure = undefined;
530
- const soulPromise = (async () => {
357
+ let soul: string;
358
+ let modelSetting: string;
359
+ let help: Array<{ role: NavigatorTargetRole; help: string }>;
360
+ let routePlaybook = "";
361
+ routePlaybookReadFailure = undefined;
362
+ const soulPromise = (async () => {
363
+ try {
364
+ const text = (await options.loadSoul()).trim();
365
+ if (!text) throw new Error("Navigator soul is empty");
366
+ return text;
367
+ } catch (error) {
368
+ throw navigatorUnavailableError("context", error);
369
+ }
370
+ })();
371
+ const routePlaybookPromise = (async () => {
372
+ if (options.loadRoutePlaybook === undefined) return "";
373
+ try {
374
+ return await options.loadRoutePlaybook();
375
+ } catch (error) {
376
+ routePlaybookReadFailure = error instanceof Error ? error.message : String(error);
377
+ return "";
378
+ }
379
+ })();
380
+ routePlaybookSettlement = routePlaybookPromise.then(() => undefined);
381
+ const modelPromise = (async () => {
382
+ try {
383
+ const resolved = await resolveNavigatorSeatSelection(options.context);
384
+ return resolved.configuredLabel;
385
+ } catch (error) {
386
+ if (error instanceof NavigatorUnavailableError) throw error;
387
+ throw navigatorUnavailableError("model", error);
388
+ }
389
+ })();
390
+ const helpPromise = warmedHelp ?? loadLiveHelp();
391
+ warmedHelp = undefined;
392
+ [soul, routePlaybook, modelSetting, help] = await Promise.all([
393
+ soulPromise,
394
+ routePlaybookPromise,
395
+ modelPromise,
396
+ helpPromise,
397
+ ]);
398
+ let model: ReturnType<typeof parseNavigatorModelSetting>;
399
+ try {
400
+ model = parseNavigatorModelSetting(modelSetting);
401
+ } catch (error) {
402
+ throw navigatorUnavailableError("model", error);
403
+ }
404
+ const helpContext = help.map(({ role, help: text }) => `<role_help role="${role}">\n${text}\n</role_help>`).join("\n");
405
+ const tool = createNavigatorPrepareTool((value) => { outputSink?.(value); });
406
+ if (session === undefined) {
407
+ sessionReady = (async () => {
408
+ let created: NavigatorPreparationSession;
531
409
  try {
532
- const text = (await options.loadSoul()).trim();
533
- if (!text) throw new Error("Navigator soul is empty");
534
- return text;
410
+ created = await options.createSession({
411
+ context: options.context,
412
+ subject: subjectKey,
413
+ ...(options.modelSettingPath === undefined ? {} : { modelSettingPath: options.modelSettingPath }),
414
+ tool,
415
+ });
535
416
  } catch (error) {
536
- // Contract: README.md#Navigator-attendance — context-loading failures become typed unavailable reports while retaining the original cause.
537
- throw navigatorUnavailableError("context", error);
417
+ throw navigatorUnavailableError("session", error);
538
418
  }
539
- })();
540
- const routePlaybookPromise = (async () => {
541
- if (options.loadRoutePlaybook === undefined) return "";
542
- try {
543
- return await options.loadRoutePlaybook();
544
- } catch (error) {
545
- routePlaybookReadFailure = error instanceof Error ? error.message : String(error);
546
- return "";
419
+ if (disposed) {
420
+ await created.dispose();
421
+ throw navigatorUnavailableError("session", new Error("Navigator attendance was disposed"));
547
422
  }
548
- })();
549
- // Preparation is fail-fast for its primary dependencies, but settlement
550
- // independently drains this optional diagnostic before emitting attendance.
551
- routePlaybookSettlement = routePlaybookPromise.then(() => undefined);
552
- const modelPromise = (async () => {
553
423
  try {
554
- // Same resolution authority as createNativeNavigatorSessionFactory (#590).
555
- const resolved = await resolveNavigatorSeatSelection(options.context);
556
- return resolved.configuredLabel;
424
+ await created.setModel?.(modelSetting, model.thinkingLevel);
425
+ if (disposed) throw navigatorUnavailableError("session", new Error("Navigator attendance was disposed"));
426
+ created.appendEntry(INVOCATION_ENTRY, { invocationId, role: options.role, phase: options.phase, subjectKey });
427
+ if (disposed) throw navigatorUnavailableError("session", new Error("Navigator attendance was disposed"));
428
+ session = created;
429
+ return created;
557
430
  } catch (error) {
558
- if (error instanceof NavigatorUnavailableError) throw error;
559
- throw navigatorUnavailableError("model", error);
431
+ if (session !== created) await created.dispose();
432
+ throw error instanceof NavigatorUnavailableError ? error : navigatorUnavailableError("session", error);
560
433
  }
561
434
  })();
562
- // Prefer one-shot warm from session_start; clear so the next prepare reloads live.
563
- const helpPromise = warmedHelp ?? loadLiveHelp();
564
- warmedHelp = undefined;
565
- [soul, routePlaybook, modelSetting, help] = await Promise.all([
566
- soulPromise,
567
- routePlaybookPromise,
568
- modelPromise,
569
- helpPromise,
570
- ]);
571
-
572
- let model: ReturnType<typeof parseNavigatorModelSetting>;
435
+ await sessionReady;
436
+ sessionReady = undefined;
437
+ } else {
573
438
  try {
574
- model = parseNavigatorModelSetting(modelSetting);
439
+ await session.setModel?.(modelSetting, model.thinkingLevel);
575
440
  } catch (error) {
576
- throw navigatorUnavailableError("model", error);
441
+ throw error instanceof NavigatorUnavailableError ? error : navigatorUnavailableError("session", error);
577
442
  }
578
- const helpContext = help.map(({ role, help: text }) => `<role_help role="${role}">\n${text}\n</role_help>`).join("\n");
579
- let output: PrepareOutput | undefined;
580
- let prepareBatchRejected = false;
581
- outputSink = (value) => {
582
- // #836: extra prepare calls append; they do not void the first batch.
583
- if (output === undefined) {
584
- output = value;
585
- return;
586
- }
587
- const prior = output.candidates;
588
- const next = value.candidates;
589
- const merged = [
590
- ...(Array.isArray(prior) ? prior : prior === undefined ? [] : [prior]),
591
- ...(Array.isArray(next) ? next : next === undefined ? [] : [next]),
592
- ];
593
- output = { ...value, candidates: merged };
594
- };
595
- const tool = createNavigatorPrepareTool((value) => { outputSink?.(value); });
596
- if (session === undefined) {
597
- sessionReady = (async () => {
598
- let created: NavigatorPreparationSession;
599
- try {
600
- created = await options.createSession({ context: options.context, subject: subjectKey, ...(options.modelSettingPath === undefined ? {} : { modelSettingPath: options.modelSettingPath }), tool });
601
- } catch (error) {
602
- throw navigatorUnavailableError("session", error);
603
- }
604
- if (disposed) {
605
- await created.dispose();
606
- throw navigatorUnavailableError("session", new Error("Navigator attendance was disposed"));
607
- }
608
- try {
609
- await created.setModel?.(modelSetting, model.thinkingLevel);
610
- if (disposed) throw navigatorUnavailableError("session", new Error("Navigator attendance was disposed"));
611
- created.appendEntry(INVOCATION_ENTRY, { invocationId, role: options.role, phase: options.phase, subjectKey });
612
- if (disposed) throw navigatorUnavailableError("session", new Error("Navigator attendance was disposed"));
613
- session = created;
614
- return created;
615
- } catch (error) {
616
- if (session !== created) await created.dispose();
617
- throw error instanceof NavigatorUnavailableError ? error : navigatorUnavailableError("session", error);
618
- }
619
- })();
620
- await sessionReady;
621
- sessionReady = undefined;
622
- } else {
623
- try {
624
- await session.setModel?.(modelSetting, model.thinkingLevel);
625
- } catch (error) {
626
- // Contract: README.md#Navigator-attendance — resumed-session configuration failures remain typed unavailable and retain the original cause.
627
- throw error instanceof NavigatorUnavailableError ? error : navigatorUnavailableError("session", error);
628
- }
629
- session.appendEntry(INVOCATION_ENTRY, { invocationId, role: options.role, phase: options.phase, subjectKey });
630
- }
631
- if (disposed) throw navigatorUnavailableError("session", new Error("Navigator attendance was disposed"));
632
- const activeSession = session;
633
- if (activeSession === undefined) throw new Error("Navigator session was not created");
634
- const prior = activeSession.entries().filter((entry): entry is { type: "custom"; customType: string; data?: unknown } => exactRecord(entry) && entry.type === "custom" && entry.customType === ROUTE_ENTRY && exactRecord(entry.data) && entry.data.subjectKey === subjectKey).at(-1)?.data;
635
- if (exactRecord(prior) && Array.isArray(prior.route) && prior.route.every((target) => targetIsValid(target))) {
636
- previousRoute = prior.route.map((target) => ({ role: target.role as NavigatorTargetRole, phase: target.phase as NavigatorPhase }));
443
+ session.appendEntry(INVOCATION_ENTRY, { invocationId, role: options.role, phase: options.phase, subjectKey });
444
+ }
445
+ if (disposed) throw navigatorUnavailableError("session", new Error("Navigator attendance was disposed"));
446
+ if (session === undefined) throw new Error("Navigator session was not created");
447
+ return { soul, routePlaybook, helpContext, modelSetting, model, help, activeSession: session };
448
+ };
449
+ const prepare = async (materialsBundle?: MaterialsBundle): Promise<string | undefined> => {
450
+ // Exact principal is owned by shared lifecycle (or one mint per attendance).
451
+ // Model/tool/advice paths cannot override it; role-session persistence is
452
+ // pi.appendEntry at lifecycle start — not optional sessionManager probing.
453
+ const boundSettlement = prepareBoundSettlement;
454
+ prepareBoundSettlement = undefined;
455
+ const invocationId = invocationPrincipal;
456
+ activeInvocationId = invocationId;
457
+ let output: PrepareOutput | undefined;
458
+ let prepareBatchRejected = false;
459
+ outputSink = (value) => {
460
+ // #836: extra prepare calls keep the first prose; later calls append as more prose.
461
+ if (output === undefined) {
462
+ output = value;
463
+ return;
637
464
  }
465
+ const prior = normalizePrepareProse(output) ?? "";
466
+ const next = normalizePrepareProse(value) ?? "";
467
+ const merged = [prior, next].filter((part) => part.trim() !== "").join("\n\n");
468
+ output = { prose: merged };
469
+ };
470
+ // Cold settle passes its first-load bundle; early/warm prepare loads here.
471
+ const { soul, routePlaybook, helpContext, help, activeSession } =
472
+ materialsBundle ?? await loadMaterialsAndSession(invocationId);
638
473
  const publicSettlementHistory = activeSession.entries()
639
474
  .filter((entry): entry is { type: "custom"; customType: string; data?: unknown } => exactRecord(entry) && entry.type === "custom" && entry.customType === SETTLEMENT_ENTRY && exactRecord(entry.data))
640
475
  .map((entry) => entry.data as NavigatorSettlementFact);
476
+ // Prior advice is the host session's own history (CLI resume), not a package ledger.
641
477
  const projection: NavigatorContextProjection = {
642
478
  subjectKey,
643
479
  subject,
644
480
  authority,
645
481
  currentRole: { role: options.role, phase: options.phase },
646
482
  ...(boundSettlement === undefined ? {} : { currentSettlement: boundSettlement }),
647
- priorRoute: exactRecord(prior) && Array.isArray(prior.route) && prior.route.every((target) => targetIsValid(target))
648
- ? prior.route.map((target) => ({ role: target.role as NavigatorTargetRole, phase: target.phase as NavigatorPhase }))
649
- : null,
650
483
  publicSettlementHistory,
651
484
  liveRoleHelp: help,
652
485
  };
653
486
  activeSession.appendEntry(CONTEXT_ENTRY, projection);
654
- const request = [
655
- "本次导航材料如下:",
656
- `<navigator_soul>\n${soul}\n</navigator_soul>`,
657
- ...(routePlaybookReadFailure === undefined
658
- ? [`<route_playbook>\n${routePlaybook}\n</route_playbook>`]
659
- : ["可选路线手册未能读取。"]),
660
- `<work_subject>\n${subject}\n</work_subject>`,
661
- `<controlling_authority>\n${authority}\n</controlling_authority>`,
662
- `<current_role>\n${JSON.stringify({ role: options.role, phase: options.phase })}\n</current_role>`,
663
- ...(boundSettlement === undefined
664
- ? []
665
- : [`<current_settlement>\n${JSON.stringify(boundSettlement)}\n</current_settlement>`]),
666
- `<prior_route>\n${JSON.stringify(prior ?? null)}\n</prior_route>`,
667
- `<public_settlement_history>\n${JSON.stringify(projection.publicSettlementHistory)}\n</public_settlement_history>`,
668
- `<live_role_help>\n${helpContext}\n</live_role_help>`,
669
- ].join("\n\n");
487
+ // Early (parent start): full host round — ready and wait. Settlement-bound:
488
+ // feed the result and let navigator speak (owner 2026-09-17 #959).
489
+ // ADR 0073: phase behavior text lives in resources/navigator-route-playbook.md;
490
+ // code only binds a neutral phase pointer + typed materials.
491
+ const request = boundSettlement === undefined
492
+ ? [
493
+ "本轮为待命轮。",
494
+ `<navigator_soul>\n${soul}\n</navigator_soul>`,
495
+ ...(routePlaybookReadFailure === undefined
496
+ ? [`<route_playbook>\n${routePlaybook}\n</route_playbook>`]
497
+ : ["可选路线手册未能读取。"]),
498
+ `<work_subject>\n${subject}\n</work_subject>`,
499
+ `<controlling_authority>\n${authority}\n</controlling_authority>`,
500
+ `<current_role>\n${JSON.stringify({ role: options.role, phase: options.phase })}\n</current_role>`,
501
+ `<public_settlement_history>\n${JSON.stringify(projection.publicSettlementHistory)}\n</public_settlement_history>`,
502
+ `<live_role_help>\n${helpContext}\n</live_role_help>`,
503
+ ].join("\n\n")
504
+ : [
505
+ "本轮为结算投喂轮。",
506
+ `<navigator_soul>\n${soul}\n</navigator_soul>`,
507
+ ...(routePlaybookReadFailure === undefined
508
+ ? [`<route_playbook>\n${routePlaybook}\n</route_playbook>`]
509
+ : ["可选路线手册未能读取。"]),
510
+ `<work_subject>\n${subject}\n</work_subject>`,
511
+ `<controlling_authority>\n${authority}\n</controlling_authority>`,
512
+ `<current_role>\n${JSON.stringify({ role: options.role, phase: options.phase })}\n</current_role>`,
513
+ `<current_settlement>\n${JSON.stringify(boundSettlement)}\n</current_settlement>`,
514
+ `<public_settlement_history>\n${JSON.stringify(projection.publicSettlementHistory)}\n</public_settlement_history>`,
515
+ `<live_role_help>\n${helpContext}\n</live_role_help>`,
516
+ ].join("\n\n");
670
517
  try {
671
518
  try {
672
519
  if (disposed) throw navigatorUnavailableError("session", new Error("Navigator attendance was disposed"));
673
520
  const delivery = createReceiptDeliveryPolicy();
521
+ // Production prose arrives only via nested summon → prepare tool.execute
522
+ // (navigator-public-session). No assistant-entry harvest — entries() is
523
+ // archivist custom-only on the wired factory (#959).
674
524
  const promptAllowingRejectedPrepare = async (text: string, deliveryRequest: boolean) => {
675
525
  const entryStart = activeSession.entries().length;
676
526
  prepareBatchRejected = false;
@@ -705,20 +555,33 @@ export function createNavigatorAttendance(options: NavigatorAttendanceOptions) {
705
555
  if (deliveryRequest && output === undefined) delivery.recordDeliveryRequest();
706
556
  };
707
557
  await promptAllowingRejectedPrepare(request, false);
708
- while (output === undefined && delivery.nextAction() === "request-delivery") {
709
- await promptAllowingRejectedPrepare(RECEIPT_DELIVERY_PROMPT, true);
710
- }
711
- if (output === undefined && delivery.nextAction() === "no-receipt" && activeSession.providerFailure?.() === undefined) {
712
- const facts = delivery.facts({ runPointer: activeSession.recordPointer(), attemptPointer: invocationId });
713
- activeSession.appendEntry(NO_RECEIPT_LIFECYCLE_ENTRY_TYPE, facts);
714
- preparationNoReceipt = true;
715
- candidates = [];
716
- return candidates;
558
+ // Bound output only: correction after rejected prepare. Early ready-wait
559
+ // does not 催交 final advice (owner: prepare then wait for settlement feed).
560
+ if (boundSettlement !== undefined) {
561
+ while (output === undefined && prepareBatchRejected && delivery.nextAction() === "request-delivery") {
562
+ await promptAllowingRejectedPrepare(RECEIPT_DELIVERY_PROMPT, true);
563
+ }
564
+ if (output === undefined && delivery.nextAction() === "request-delivery") {
565
+ while (delivery.nextAction() === "request-delivery") {
566
+ delivery.recordDeliveryRequest();
567
+ }
568
+ }
569
+ if (output === undefined && delivery.nextAction() === "no-receipt" && activeSession.providerFailure?.() === undefined) {
570
+ const facts = delivery.facts({ runPointer: activeSession.recordPointer(), attemptPointer: invocationId });
571
+ activeSession.appendEntry(NO_RECEIPT_LIFECYCLE_ENTRY_TYPE, facts);
572
+ preparedProse = undefined;
573
+ return undefined;
574
+ }
717
575
  }
718
576
  } catch (error) {
719
577
  throw error instanceof NavigatorUnavailableError ? error : navigatorUnavailableError("transport", error);
720
578
  }
721
579
  if (output === undefined) {
580
+ // Early host round: ready and wait — no final prose until settlement is fed.
581
+ if (boundSettlement === undefined) {
582
+ preparedProse = undefined;
583
+ return undefined;
584
+ }
722
585
  const nativeFailure = [...activeSession.entries()].reverse().find((entry: unknown) => {
723
586
  if (!exactRecord(entry) || entry.type !== "message" || !exactRecord(entry.message)) return false;
724
587
  return entry.message.role === "assistant" && typeof entry.message.errorMessage === "string" && entry.message.errorMessage.trim() !== "";
@@ -734,8 +597,13 @@ export function createNavigatorAttendance(options: NavigatorAttendanceOptions) {
734
597
  const cause = providerFailure?.cause ?? source;
735
598
  throw navigatorUnavailableError(source, errorMessage, cause);
736
599
  }
737
- candidates = normalizePrepareOutput(output);
738
- return candidates;
600
+ // Early tool/prose is not final advice — discard; settlement feed owns output.
601
+ if (boundSettlement === undefined) {
602
+ preparedProse = undefined;
603
+ return undefined;
604
+ }
605
+ preparedProse = normalizePrepareProse(output);
606
+ return preparedProse;
739
607
  } finally {
740
608
  outputSink = undefined;
741
609
  }
@@ -747,7 +615,6 @@ export function createNavigatorAttendance(options: NavigatorAttendanceOptions) {
747
615
  if (next.subjectKey !== subjectKey && session !== undefined) {
748
616
  const previous = session;
749
617
  session = undefined;
750
- previousRoute = undefined;
751
618
  closing = previous.dispose();
752
619
  }
753
620
  subjectKey = next.subjectKey;
@@ -770,12 +637,17 @@ export function createNavigatorAttendance(options: NavigatorAttendanceOptions) {
770
637
  prepare(): void {
771
638
  if (disposed || preparation !== undefined) return;
772
639
  preparationFailure = undefined;
773
- preparation = prepare();
640
+ // Track live in-flight only — a resolved preparation still held until settle
641
+ // drain must not read as preparing (test helpers release early ready-wait by this).
642
+ preparationInFlight = true;
643
+ preparation = prepare().finally(() => {
644
+ preparationInFlight = false;
645
+ });
774
646
  // Contract: README.md#Navigator-attendance — background preparation rejection is drained so the later typed settlement can report unavailable; retain the exact cause until settlement.
775
647
  void preparation.catch((error) => { preparationFailure = error; });
776
648
  },
777
649
  isPreparing(): boolean {
778
- return preparation !== undefined || sessionReady !== undefined;
650
+ return preparationInFlight;
779
651
  },
780
652
  knownRoutePlaybookReadFailure(): string | undefined {
781
653
  return routePlaybookReadFailure;
@@ -802,84 +674,81 @@ export function createNavigatorAttendance(options: NavigatorAttendanceOptions) {
802
674
  if (disposed) return;
803
675
  const invocationId = activeInvocationId ?? invocationPrincipal;
804
676
  let report: NavigatorReport;
805
- if (settlement.kind === "human_decision" || settlement.kind === "role_infrastructure_failure") {
806
- // Contract: lawful human/role outcomes emit affirmative typed no-advice when
807
- // preparation completed; rejected preparation remains typed unavailable.
808
- // Drain the in-flight work so the next driver input cannot start a second prompt on the same native session.
809
- if (sessionReady !== undefined) {
810
- try { await sessionReady; } catch (error) { preparationFailure ??= error; }
811
- }
812
- if (preparation !== undefined) {
813
- try { await preparation; } catch (error) { preparationFailure ??= error; }
677
+ // Drain in-flight early prepare (parent-start host round: ready and wait).
678
+ // Then feed settlement for the output prompt on the same host session.
679
+ if (sessionReady !== undefined) {
680
+ try { await sessionReady; } catch (error) { preparationFailure ??= error; }
681
+ }
682
+ if (preparation !== undefined) {
683
+ try {
684
+ await preparation;
685
+ } catch (error) {
686
+ preparationFailure ??= error;
814
687
  }
815
- session?.appendEntry(SETTLEMENT_ENTRY, { invocationId, subjectKey, role: settlement.role, phase: settlement.phase, kind: settlement.kind, ...(settlement.kind === "human_decision" ? { status: settlement.status } : {}) });
688
+ preparation = undefined;
689
+ }
690
+ const settlementFact = {
691
+ invocationId,
692
+ subjectKey,
693
+ role: settlement.role,
694
+ phase: settlement.phase,
695
+ kind: settlement.kind,
696
+ ...("status" in settlement && settlement.status !== undefined
697
+ ? { status: settlement.status }
698
+ : {}),
699
+ };
700
+ // Arrival is presentation-only: never start soul/model/help/session just to book.
701
+ let drainedProse: string | undefined;
702
+ if (settlement.kind === "arrival") {
703
+ session?.appendEntry(SETTLEMENT_ENTRY, settlementFact);
816
704
  if (preparationFailure !== undefined) {
817
705
  report = unavailable(invocationId, preparationFailure);
818
706
  } else {
819
- // Affirmative no-advice attendance — never inferred later from absence.
820
- report = { disposition: "no-advice" };
821
- }
822
- } else if (settlement.kind === "arrival") {
823
- // Contract: README.md#Navigator-attendance — failed attendance is reported as typed unavailable rather than silently discarded.
824
- if (sessionReady !== undefined) {
825
- try { await sessionReady; } catch (error) { preparationFailure ??= error; }
826
- }
827
- if (preparation !== undefined) {
828
- try { await preparation; } catch (error) { preparationFailure ??= error; }
829
- }
830
- report = preparationFailure === undefined
831
- ? { disposition: "arrival", ...(settlement.message === undefined ? {} : { arrivalMessage: settlement.message }) }
832
- : unavailable(invocationId, preparationFailure);
833
- } else if (preparation === undefined) {
834
- report = unavailable(invocationId, "Navigator preparation did not start");
835
- } else {
836
- try {
837
- if (sessionReady !== undefined) await sessionReady;
838
- let prepared = await preparation;
839
- session?.appendEntry(SETTLEMENT_ENTRY, { invocationId, subjectKey, role: settlement.role, phase: settlement.phase, kind: settlement.kind, ...(settlement.status === undefined ? {} : { status: settlement.status }) });
840
- let selected = selectNavigatorCandidate(prepared, settlement);
841
- // Speculative prepare runs before this terminal exists and may treat prior
842
- // history as decisive. When selection provenance says matches did not key
843
- // this candidate to the settlement, rebind once with currentSettlement.
844
- // Stale-context repair only — reachable without any next.role legality
845
- // table. After selection (speculative or rebound), advice is passed
846
- // through as-is (ADR 0010 / ADR 0061: caller may ignore).
847
- if (selected?.candidate.next !== undefined && !selected.matchedToSettlement) {
848
- if (disposed) return;
849
- prepareBoundSettlement = settlement;
850
- prepared = await prepare();
851
- if (disposed) return;
852
- selected = selectNavigatorCandidate(prepared, settlement);
853
- }
854
- // Budget exhaustion is affirmative typed no-advice; malformed submitted
855
- // advice remains the existing unavailable path.
856
- const selectedCandidate = selected?.candidate;
857
- if (selectedCandidate?.next === undefined && preparationNoReceipt) {
858
- report = { disposition: "no-advice" };
859
- } else if (selectedCandidate?.next === undefined) {
860
- throw new Error("Navigator prepared no machine-usable next direction");
861
- } else {
862
- const selectedRoute = selectedCandidate.route;
863
- const routeChanged = selectedRoute !== undefined && !routeEqual(previousRoute, selectedRoute);
864
- const command =
865
- typeof selectedCandidate.command === "string" && selectedCandidate.command.trim() !== ""
866
- ? selectedCandidate.command
867
- : renderPublicAkRoleCommand(selectedCandidate.next);
868
707
  report = {
869
- disposition: "recommendation",
870
- ...(routeChanged ? { route: selectedRoute } : {}),
871
- next: selectedCandidate.next,
872
- ...(selectedCandidate.reason === undefined ? {} : { reason: selectedCandidate.reason }),
873
- ...(command === undefined ? {} : { command }),
708
+ disposition: "arrival",
709
+ ...(settlement.message === undefined ? {} : { arrivalMessage: settlement.message }),
874
710
  };
875
- if (selectedRoute !== undefined) {
876
- previousRoute = selectedRoute;
877
- session?.appendEntry(ROUTE_ENTRY, { invocationId, subjectKey, route: selectedRoute });
711
+ }
712
+ } else {
713
+ // Feed settlement; navigator speaks. Clear early-path failure only when a
714
+ // session already exists so the output prompt can run (owner: 准备好了就等着,
715
+ // 结果出来喂给它让它自己输出).
716
+ if (session !== undefined) preparationFailure = undefined;
717
+ // Cold settle: materials+session only (no unbound host prompt), then book + feed.
718
+ // Pass the bundle into prepare so loadLiveHelp/setModel/INVOCATION run once.
719
+ let coldMaterials: MaterialsBundle | undefined;
720
+ if (session === undefined && preparationFailure === undefined) {
721
+ try {
722
+ coldMaterials = await loadMaterialsAndSession(invocationId);
723
+ } catch (error) {
724
+ preparationFailure = error;
878
725
  }
726
+ }
727
+ session?.appendEntry(SETTLEMENT_ENTRY, settlementFact);
728
+ if (preparationFailure === undefined) {
729
+ prepareBoundSettlement = settlement;
730
+ preparation = prepare(coldMaterials);
731
+ void preparation.catch((error) => { preparationFailure = error; });
732
+ try {
733
+ drainedProse = await preparation;
734
+ } catch (error) {
735
+ preparationFailure ??= error;
879
736
  }
880
- // Contract: README.md#Navigator-attendance — Navigator failures become typed unavailable without invalidating the role Receipt; retain the original cause in the unavailable report.
881
- } catch (error) {
882
- report = unavailable(invocationId, error);
737
+ preparation = undefined;
738
+ }
739
+ if (preparationFailure !== undefined) {
740
+ // Contract: README.md#Navigator-attendance — failed attendance is typed
741
+ // unavailable without invalidating the role Receipt; retain the cause.
742
+ report = unavailable(invocationId, preparationFailure);
743
+ } else if (typeof drainedProse === "string" && drainedProse.trim() !== "") {
744
+ // #959: present prose as-is on every parent outcome — including
745
+ // human_decision / escalate. Old path wiped prose on escalate and left
746
+ // auto-attendance looking empty after a successful nested summon.
747
+ // Prior words stay on the host session (CLI resume); no package advice ledger.
748
+ report = { disposition: "advice", prose: drainedProse };
749
+ } else {
750
+ // Empty/no-receipt prepare → affirmative no-advice (never inferred later).
751
+ report = { disposition: "no-advice" };
883
752
  }
884
753
  }
885
754
  // A primary preparation failure may reject Promise.all before the optional
@@ -896,10 +765,7 @@ export function createNavigatorAttendance(options: NavigatorAttendanceOptions) {
896
765
  role: options.role,
897
766
  phase: options.phase,
898
767
  subjectKey,
899
- ...(report.route === undefined ? {} : { route: report.route }),
900
- ...(report.next === undefined ? {} : { next: report.next }),
901
- ...(report.reason === undefined ? {} : { reason: report.reason }),
902
- ...(report.command === undefined ? {} : { command: report.command }),
768
+ ...(report.prose === undefined ? {} : { prose: report.prose }),
903
769
  ...(report.unavailableReason === undefined ? {} : { unavailableReason: report.unavailableReason }),
904
770
  ...(report.unavailableSource === undefined ? {} : { unavailableSource: report.unavailableSource }),
905
771
  ...(report.unavailableCause === undefined ? {} : { unavailableCause: report.unavailableCause }),
@@ -907,7 +773,7 @@ export function createNavigatorAttendance(options: NavigatorAttendanceOptions) {
907
773
  ...(report.arrivalMessage === undefined ? {} : { arrivalMessage: report.arrivalMessage }),
908
774
  };
909
775
  // Dispose during post-role grace must ignore late completion (ADR 0052 / #106 / #675).
910
- // Every settled disposition (recommendation | no-advice | unavailable | arrival) is affirmative.
776
+ // Every settled disposition (advice | no-advice | unavailable | arrival) is affirmative.
911
777
  // Only the disposed bit is typed authority to drop — never match free-text Error.message.
912
778
  if (disposed) return;
913
779
  try {
@@ -919,9 +785,8 @@ export function createNavigatorAttendance(options: NavigatorAttendanceOptions) {
919
785
  }
920
786
  preparation = undefined;
921
787
  sessionReady = undefined;
922
- candidates = undefined;
788
+ preparedProse = undefined;
923
789
  preparationFailure = undefined;
924
- preparationNoReceipt = false;
925
790
  routePlaybookSettlement = undefined;
926
791
  routePlaybookReadFailure = undefined;
927
792
  }