@akagilnc/pi-workflow-roles 0.1.5663 → 0.1.5671

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.
@@ -392,7 +392,7 @@ function terminalFromSpawned(
392
392
  function buildTurnArgs(options: {
393
393
  readonly description: HeadlessHostDescription;
394
394
  readonly systemPromptPath: string;
395
- /** Open schema; omit for #959 navigator prose-exit seats. Codex does not receive a narrowed copy. */
395
+ /** Open schema for Claude --json-schema; omit for #959 navigator prose-exit seats. */
396
396
  readonly jsonSchema?: Readonly<Record<string, unknown>>;
397
397
  readonly mcpServers: readonly Readonly<Record<string, unknown>>[];
398
398
  readonly mcpConfigPath?: string;
@@ -408,7 +408,7 @@ function buildTurnArgs(options: {
408
408
  if (options.sessionKind === "resume" && !options.sessionId) {
409
409
  throw new Error("codex resume requires a bound thread_id");
410
410
  }
411
- // #959: prose-exit seats (navigator) omit --output-schema; other seats still require it.
411
+ // Codex always mounts --output-schema when the path is supplied (incl. navigator byStatus #1160).
412
412
  return codexTurnArgs({
413
413
  systemPromptPath: options.systemPromptPath,
414
414
  ...(options.outputSchemaPath === undefined ? {} : { outputSchemaPath: options.outputSchemaPath }),
@@ -443,7 +443,12 @@ export function createHeadlessRoleTurnHost(config: HeadlessRoleTurnHostConfig):
443
443
  return createSerializedRoleTurnHost(async (request): Promise<RoleTurnResult> => {
444
444
  const prepared = await config.prepare(request);
445
445
  const systemPrompt = renderSystemPromptOverride(prepared.systemPrompt);
446
- const codex = isCodexExecDescription(config.description);
446
+ const codex = isCodexExecDescription(config.description);
447
+ // #1160: one exit-mode decision per turn — direct navigator free prose vs structured.
448
+ // Attendance sets request.navigatorByStatusPrepare; direct ak-role navigator does not.
449
+ const navigatorProseExit =
450
+ prepared.terminatingToolName === NAVIGATOR_OUTPUT_TOOL_NAME
451
+ && request.navigatorByStatusPrepare !== true;
447
452
  let outcome: RoleTurnResult = failure("session", "HeadlessNoOutcome", "no-outcome");
448
453
  try {
449
454
  // Claude mints a package UUID for --session-id; codex waits for thread.started.
@@ -485,7 +490,7 @@ export function createHeadlessRoleTurnHost(config: HeadlessRoleTurnHostConfig):
485
490
  if (codex) {
486
491
  // #1148: Codex --output-schema requires a native strict transport schema.
487
492
  // Project from the unique open declaration; do not alter package receipt rules.
488
- if (prepared.terminatingToolName !== NAVIGATOR_OUTPUT_TOOL_NAME) {
493
+ if (!navigatorProseExit) {
489
494
  outputSchemaPath = join(inputsDirectory, "output-schema.json");
490
495
  deliveredSchema = closeJsonSchemaForCodex(prepared.jsonSchema);
491
496
  await writeFile(outputSchemaPath, `${JSON.stringify(deliveredSchema, null, 2)}\n`, "utf8");
@@ -497,8 +502,7 @@ export function createHeadlessRoleTurnHost(config: HeadlessRoleTurnHostConfig):
497
502
  `${JSON.stringify(headlessMcpConfigDocument(prepared.mcpServers), null, 2)}\n`,
498
503
  "utf8",
499
504
  );
500
- // #959: navigator prose exit — no closed JSON schema on claude either.
501
- if (prepared.terminatingToolName !== NAVIGATOR_OUTPUT_TOOL_NAME) deliveredSchema = prepared.jsonSchema;
505
+ if (!navigatorProseExit) deliveredSchema = prepared.jsonSchema;
502
506
  }
503
507
  startedWithSchema = deliveredSchema;
504
508
  } catch (setupError) {
@@ -526,10 +530,8 @@ export function createHeadlessRoleTurnHost(config: HeadlessRoleTurnHostConfig):
526
530
  args = buildTurnArgs({
527
531
  description: config.description,
528
532
  systemPromptPath,
529
- // #959: navigator prose exit — no closed JSON schema on claude either.
530
- ...(prepared.terminatingToolName === NAVIGATOR_OUTPUT_TOOL_NAME
531
- ? {}
532
- : { jsonSchema: prepared.jsonSchema }),
533
+ // Direct navigator prose exit omits --json-schema; byStatus prepare mounts it.
534
+ ...(navigatorProseExit ? {} : { jsonSchema: prepared.jsonSchema }),
533
535
  mcpServers: prepared.mcpServers,
534
536
  ...(mcpConfigPath === undefined ? {} : { mcpConfigPath }),
535
537
  ...(outputSchemaPath === undefined ? {} : { outputSchemaPath }),
@@ -665,14 +667,15 @@ export function createHeadlessRoleTurnHost(config: HeadlessRoleTurnHostConfig):
665
667
  });
666
668
  }
667
669
 
668
- const navigator = prepared.terminatingToolName === NAVIGATOR_OUTPUT_TOOL_NAME;
669
670
  const deliverReply = async (): Promise<ReturnType<typeof deliveredFromSpawned> | undefined> => {
670
671
  if (!observation.turnCompleted || observation.finalMessage === undefined) return undefined;
671
- if (navigator) {
672
+ if (navigatorProseExit) {
673
+ // Direct ak-role navigator: free-form agent_message is the prose body.
672
674
  if (observation.finalMessage.trim() === "") return undefined;
673
675
  await prepared.ingestStructuredOutput({ prose: observation.finalMessage });
674
676
  return deliveredFromSpawned(spawned);
675
677
  }
678
+ // Structured seats (incl. #1160 byStatus prepare): native JSON receipt.
676
679
  let receipt: unknown;
677
680
  try {
678
681
  receipt = JSON.parse(observation.finalMessage);
@@ -721,12 +724,12 @@ export function createHeadlessRoleTurnHost(config: HeadlessRoleTurnHostConfig):
721
724
  if (envelope.structured_output !== undefined) {
722
725
  await prepared.ingestStructuredOutput(envelope.structured_output);
723
726
  } else if (
724
- prepared.terminatingToolName === NAVIGATOR_OUTPUT_TOOL_NAME
727
+ navigatorProseExit
725
728
  && typeof envelope.result === "string"
726
729
  && envelope.result.trim() !== ""
727
730
  ) {
728
- // #959: claude prose exit — free-form result text is the receipt body
729
- // when structured_output is absent (json-schema omitted for navigator).
731
+ // #959: direct navigator prose exit — free-form result text is the body
732
+ // when structured_output is absent (json-schema omitted).
730
733
  await prepared.ingestStructuredOutput({ prose: envelope.result });
731
734
  }
732
735
  return deliveredFromSpawned(spawned);
@@ -193,6 +193,12 @@ export type RoleTurnRequest = {
193
193
  readonly host?: string;
194
194
  /** Station child role run (#840): omit automatic navigator attendance. */
195
195
  readonly stationChild?: boolean;
196
+ /**
197
+ * #1160 attendance auto-prepare: host mounts native structured schema so
198
+ * byStatus returns as structured fields. Direct `ak-role navigator` omits
199
+ * this and keeps the free-form prose exit (#959).
200
+ */
201
+ readonly navigatorByStatusPrepare?: boolean;
196
202
  /**
197
203
  * #1132: the effective delivery-request ceiling resolved once by the caller
198
204
  * from the single configured `autoResumeLimit` value and projected here, so
@@ -3,7 +3,7 @@ import { homedir } from "node:os";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
 
5
5
  import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
6
- import { Type, type Static } from "typebox";
6
+ import { type Static } from "typebox";
7
7
 
8
8
  import {
9
9
  NAVIGATOR_INVOCATION_ENTRY,
@@ -60,7 +60,14 @@ export { createNativeNavigatorSessionFactory };
60
60
  export { resolveNavigatorSeatSelection };
61
61
  import { issueRoot, subjectPath } from "./work-subject-identity.ts";
62
62
  import { createReceiptDeliveryPolicy } from "./receipt-delivery-policy.ts";
63
- import { navigatorProseFromUnknown } from "./package-contracts/navigator-output.ts";
63
+ import {
64
+ byStatusToPlainObject,
65
+ mergePreparedAdvice,
66
+ navigatorAdviceBodySchema,
67
+ pickPreparedProse,
68
+ preparedAdviceFromUnknown,
69
+ type PreparedNavigatorAdvice,
70
+ } from "./package-contracts/navigator-output.ts";
64
71
  import { sha256Hex } from "./sha256.ts";
65
72
  import { isRecord } from "./unknown-value.ts";
66
73
 
@@ -133,13 +140,10 @@ export type NavigatorEvent = {
133
140
  arrivalMessage?: string;
134
141
  };
135
142
 
136
- // #959: prepare is a prose vehicle. Object root only (ADR 0060); nested shape is
137
- // never a gate — every object root reaches execute exactly once (Rule 0).
138
- const prepareSchema = Type.Object({
139
- prose: Type.Optional(Type.Unknown({
140
- description: "游奕使散文建议,原样呈现。不要求 candidates/next 结构。",
141
- })),
142
- }, { additionalProperties: true });
143
+ // #959 / #1160: prepare reuses the single navigator advice field owner.
144
+ // Object root only (ADR 0060); nested shape is never a gate — every object root
145
+ // reaches execute exactly once (Rule 0).
146
+ const prepareSchema = navigatorAdviceBodySchema;
143
147
  type PrepareOutput = Static<typeof prepareSchema>;
144
148
 
145
149
  export type NavigatorAttendanceOptions = {
@@ -200,13 +204,13 @@ function rejectedPrepareReason(entries: readonly unknown[], start: number): stri
200
204
  }
201
205
 
202
206
  /**
203
- * #959: extract prose from any prepare submission shape. Missing/empty → undefined.
204
- * Never a rejection — shape is not an admission gate.
205
- * Production prose arrives via nested public summon → prepare tool.execute only;
207
+ * #959 / #1160: extract prepared advice from any prepare submission shape.
208
+ * Missing/empty → undefined. Never a rejection — shape is not an admission gate.
209
+ * Production payload arrives via nested public summon → prepare tool.execute only;
206
210
  * archivist entries() never carries assistant message text, so no parallel harvest.
207
211
  */
208
- function normalizePrepareProse(value: unknown): string | undefined {
209
- return navigatorProseFromUnknown(value);
212
+ function normalizePreparedAdvice(value: unknown): PreparedNavigatorAdvice | undefined {
213
+ return preparedAdviceFromUnknown(value);
210
214
  }
211
215
 
212
216
  export function navigatorSubjectKey(
@@ -268,7 +272,7 @@ export function formatNavigatorReport(report: NavigatorReport): string {
268
272
  }
269
273
 
270
274
  export function createNavigatorAttendance(options: NavigatorAttendanceOptions) {
271
- let preparation: Promise<string | undefined> | undefined;
275
+ let preparation: Promise<PreparedNavigatorAdvice | undefined> | undefined;
272
276
  let preparationInFlight = false;
273
277
  let sessionReady: Promise<NavigatorPreparationSession> | undefined;
274
278
  let session: NavigatorPreparationSession | undefined;
@@ -276,7 +280,8 @@ export function createNavigatorAttendance(options: NavigatorAttendanceOptions) {
276
280
  let subject = options.subject;
277
281
  let authority = options.authority;
278
282
  let contextError = options.contextError;
279
- let preparedProse: string | undefined;
283
+ /** Parallel-prepare result: status-keyed prose picked at settle (#1160). */
284
+ let preparedAdvice: PreparedNavigatorAdvice | undefined;
280
285
  // Shared lifecycle owns the principal when supplied; otherwise mint once per attendance.
281
286
  const invocationPrincipal = options.invocationId ?? mintNavigatorInvocationId();
282
287
  let activeInvocationId: string | undefined = invocationPrincipal;
@@ -311,11 +316,10 @@ export function createNavigatorAttendance(options: NavigatorAttendanceOptions) {
311
316
  };
312
317
  };
313
318
  /**
314
- * Settlement is the only model round. Standby records attendance and does not prompt.
319
+ * #1160: prepare runs the model in parallel from parent start and stores byStatus.
320
+ * settle picks by settlement.status — no second model round.
315
321
  * Soul and route playbook stay on the navigator system prompt, not this user turn.
316
322
  */
317
- let prepareBoundSettlement: NavigatorSettlement | undefined;
318
- /** Session only — no host prompt (standby attendance, or cold settle book-before-feed). */
319
323
  const loadMaterialsAndSession = async (invocationId: string): Promise<NavigatorPreparationSession> => {
320
324
  if (contextError !== undefined) throw navigatorUnavailableError("context", contextError);
321
325
  if (typeof authority !== "string" || authority.trim() === "") {
@@ -384,133 +388,127 @@ export function createNavigatorAttendance(options: NavigatorAttendanceOptions) {
384
388
  if (session === undefined) throw new Error("Navigator session was not created");
385
389
  return session;
386
390
  };
387
- const prepare = async (existingSession?: NavigatorPreparationSession): Promise<string | undefined> => {
391
+ const prepare = async (): Promise<PreparedNavigatorAdvice | undefined> => {
388
392
  // Exact principal is owned by shared lifecycle (or one mint per attendance).
389
393
  // Model/tool/advice paths cannot override it; role-session persistence is
390
394
  // pi.appendEntry at lifecycle start — not optional sessionManager probing.
391
- const boundSettlement = prepareBoundSettlement;
392
- prepareBoundSettlement = undefined;
393
395
  const invocationId = invocationPrincipal;
394
396
  activeInvocationId = invocationId;
395
397
  let output: PrepareOutput | undefined;
396
398
  let prepareBatchRejected = false;
397
399
  outputSink = (value) => {
398
- // #836: extra prepare calls keep the first prose; later calls append as more prose.
400
+ // #836 / #1160: extra prepare calls merge byStatus (same key concatenates) and prose.
401
+ const next = normalizePreparedAdvice(value);
399
402
  if (output === undefined) {
400
403
  output = value;
401
404
  return;
402
405
  }
403
- const prior = normalizePrepareProse(output) ?? "";
404
- const next = normalizePrepareProse(value) ?? "";
405
- const merged = [prior, next].filter((part) => part.trim() !== "").join("\n\n");
406
- output = { prose: merged };
406
+ const merged = mergePreparedAdvice(normalizePreparedAdvice(output), next);
407
+ output = {
408
+ ...(merged?.prose === undefined ? {} : { prose: merged.prose }),
409
+ ...(merged === undefined || merged.byStatus.size === 0
410
+ ? {}
411
+ : { byStatus: byStatusToPlainObject(merged.byStatus) }),
412
+ };
407
413
  };
408
- const activeSession = existingSession ?? await loadMaterialsAndSession(invocationId);
409
- // Standby records the invocation entry and does not call the model.
410
- if (boundSettlement === undefined) return undefined;
411
- const request = JSON.stringify({
412
- ...boundSettlement,
413
- invocationId,
414
- subjectKey,
415
- });
414
+ const activeSession = await loadMaterialsAndSession(invocationId);
415
+ // #1160: parallel prepare from parent start — model runs now; settle only picks.
416
+ // Pass already-loaded subject/authority so the prepare run can judge progress
417
+ // (ticket text lives in those materials when present — no path invention).
418
+ const request = JSON.stringify({
419
+ kind: "prepare",
420
+ role: options.role,
421
+ phase: options.phase,
422
+ invocationId,
423
+ subjectKey,
424
+ subject,
425
+ authority,
426
+ });
427
+ try {
416
428
  try {
417
- try {
418
- if (disposed) throw navigatorUnavailableError("session", new Error("Navigator attendance was disposed"));
419
- // #1132: the ceiling this turn already resolved. Absent = package default.
420
- const delivery = createReceiptDeliveryPolicy(options.deliveryRequestLimit);
421
- // Production prose arrives only via nested summon → prepare tool.execute
422
- // (navigator-public-session). No assistant-entry harvest — entries() is
423
- // archivist custom-only on the wired factory (#959).
424
- const promptAllowingRejectedPrepare = async (text: string) => {
425
- const entryStart = activeSession.entries().length;
426
- prepareBatchRejected = false;
427
- let promptFailure: unknown;
428
- try {
429
- await activeSession.prompt(text);
430
- } catch (error) {
431
- promptFailure = error;
432
- }
433
- const providerFailure = activeSession.providerFailure?.();
434
- if (providerFailure !== undefined) {
435
- throw navigatorUnavailableError(providerFailure.source, promptFailure ?? "Navigator provider failure", providerFailure.cause);
436
- }
437
- const rejectedReason = rejectedPrepareReason(activeSession.entries(), entryStart);
438
- if (rejectedReason !== undefined) {
439
- // A rejected call makes every provisional output from this prompt
440
- // ineligible for publication before the correction turn starts.
441
- output = undefined;
442
- prepareBatchRejected = true;
443
- // The correction prompt below is the one spend for this rejection.
444
- delivery.recordRejected(rejectedReason);
445
- return;
446
- }
447
- if (promptFailure !== undefined) throw promptFailure;
448
- const sessionNoReceipt = activeSession.noReceipt?.();
449
- if (sessionNoReceipt !== undefined) {
450
- // This session already settled without an accepted receipt. Keep the
451
- // larger issued count and stop; a nested zero must not wipe prompts
452
- // this layer already sent (#675 / #1132).
453
- delivery.recordNestedNoReceipt(sessionNoReceipt);
454
- return;
455
- }
456
- };
457
- await promptAllowingRejectedPrepare(request);
458
- // Bound output only: correction after rejected prepare. Early ready-wait
459
- // does not 催交 final advice (owner: prepare then wait for settlement feed).
460
- if (boundSettlement !== undefined) {
461
- while (output === undefined && prepareBatchRejected && delivery.nextAction() === "request-delivery") {
462
- delivery.recordDeliveryRequest();
463
- await promptAllowingRejectedPrepare(JSON.stringify(delivery.deliveryState()));
464
- }
465
- if (output === undefined && delivery.nextAction() === "request-delivery") {
466
- delivery.closeBudget();
467
- }
468
- if (output === undefined && delivery.nextAction() === "no-receipt" && activeSession.providerFailure?.() === undefined) {
469
- // Nested @navigator run already holds lawful no_receipt + runPointer when
470
- // present. Do not write a parallel parent lifecycle off a dead side-branch
471
- // pointer (#1178). 催交 / nested settle / no-advice delivery stay above.
472
- preparedProse = undefined;
473
- return undefined;
474
- }
475
- }
476
- } catch (error) {
477
- throw error instanceof NavigatorUnavailableError ? error : navigatorUnavailableError("transport", error);
478
- }
479
- if (output === undefined) {
480
- // Early host round: ready and wait — no final prose until settlement is fed.
481
- if (boundSettlement === undefined) {
482
- preparedProse = undefined;
483
- return undefined;
429
+ if (disposed) throw navigatorUnavailableError("session", new Error("Navigator attendance was disposed"));
430
+ // #1132: the ceiling this turn already resolved. Absent = package default.
431
+ const delivery = createReceiptDeliveryPolicy(options.deliveryRequestLimit);
432
+ // Production payload arrives only via nested summon → prepare tool.execute
433
+ // (navigator-public-session). No assistant-entry harvest — entries() is
434
+ // archivist custom-only on the wired factory (#959).
435
+ const promptAllowingRejectedPrepare = async (text: string) => {
436
+ const entryStart = activeSession.entries().length;
437
+ prepareBatchRejected = false;
438
+ let promptFailure: unknown;
439
+ try {
440
+ await activeSession.prompt(text);
441
+ } catch (error) {
442
+ promptFailure = error;
484
443
  }
485
- const nativeFailure = [...activeSession.entries()].reverse().find((entry: unknown) => {
486
- if (!isRecord(entry) || entry.type !== "message" || !isRecord(entry.message)) return false;
487
- return entry.message.role === "assistant" && typeof entry.message.errorMessage === "string" && entry.message.errorMessage.trim() !== "";
488
- });
489
- const nativeMessage = isRecord(nativeFailure) && isRecord(nativeFailure.message) ? nativeFailure.message : undefined;
490
- const errorMessage = nativeMessage !== undefined && typeof nativeMessage.errorMessage === "string"
491
- ? nativeMessage.errorMessage
492
- : "Navigator did not submit direction advice";
493
- // Classification originates only at the native provider stream seam.
494
- // AssistantMessage metadata is a human diagnostic surface, not an acceptance oracle.
495
444
  const providerFailure = activeSession.providerFailure?.();
496
- const source = providerFailure?.source ?? "unknown";
497
- const cause = providerFailure?.cause ?? source;
498
- throw navigatorUnavailableError(source, errorMessage, cause);
445
+ if (providerFailure !== undefined) {
446
+ throw navigatorUnavailableError(providerFailure.source, promptFailure ?? "Navigator provider failure", providerFailure.cause);
447
+ }
448
+ const rejectedReason = rejectedPrepareReason(activeSession.entries(), entryStart);
449
+ if (rejectedReason !== undefined) {
450
+ // A rejected call makes every provisional output from this prompt
451
+ // ineligible for publication before the correction turn starts.
452
+ output = undefined;
453
+ prepareBatchRejected = true;
454
+ // The correction prompt below is the one spend for this rejection.
455
+ delivery.recordRejected(rejectedReason);
456
+ return;
457
+ }
458
+ if (promptFailure !== undefined) throw promptFailure;
459
+ const sessionNoReceipt = activeSession.noReceipt?.();
460
+ if (sessionNoReceipt !== undefined) {
461
+ // This session already settled without an accepted receipt. Keep the
462
+ // larger issued count and stop; a nested zero must not wipe prompts
463
+ // this layer already sent (#675 / #1132).
464
+ delivery.recordNestedNoReceipt(sessionNoReceipt);
465
+ return;
466
+ }
467
+ };
468
+ await promptAllowingRejectedPrepare(request);
469
+ // Correction after rejected prepare on the sole model round (#1160).
470
+ while (output === undefined && prepareBatchRejected && delivery.nextAction() === "request-delivery") {
471
+ delivery.recordDeliveryRequest();
472
+ await promptAllowingRejectedPrepare(JSON.stringify(delivery.deliveryState()));
473
+ }
474
+ if (output === undefined && delivery.nextAction() === "request-delivery") {
475
+ delivery.closeBudget();
499
476
  }
500
- // Early tool/prose is not final advice — discard; settlement feed owns output.
501
- if (boundSettlement === undefined) {
502
- preparedProse = undefined;
477
+ if (output === undefined && delivery.nextAction() === "no-receipt" && activeSession.providerFailure?.() === undefined) {
478
+ // Nested @navigator run already holds lawful no_receipt + runPointer when
479
+ // present. Do not write a parallel parent lifecycle off a dead side-branch
480
+ // pointer (#1178). Honest empty prep → settle reports no-advice.
481
+ preparedAdvice = undefined;
503
482
  return undefined;
504
483
  }
505
- preparedProse = normalizePrepareProse(output);
506
- return preparedProse;
507
- } finally {
508
- const playbookFailure = activeSession.routePlaybookReadFailure?.();
509
- observedRoutePlaybookFailure = typeof playbookFailure === "string" && playbookFailure.trim() !== ""
510
- ? playbookFailure
511
- : undefined;
512
- outputSink = undefined;
484
+ } catch (error) {
485
+ throw error instanceof NavigatorUnavailableError ? error : navigatorUnavailableError("transport", error);
486
+ }
487
+ if (output === undefined) {
488
+ const nativeFailure = [...activeSession.entries()].reverse().find((entry: unknown) => {
489
+ if (!isRecord(entry) || entry.type !== "message" || !isRecord(entry.message)) return false;
490
+ return entry.message.role === "assistant" && typeof entry.message.errorMessage === "string" && entry.message.errorMessage.trim() !== "";
491
+ });
492
+ const nativeMessage = isRecord(nativeFailure) && isRecord(nativeFailure.message) ? nativeFailure.message : undefined;
493
+ const errorMessage = nativeMessage !== undefined && typeof nativeMessage.errorMessage === "string"
494
+ ? nativeMessage.errorMessage
495
+ : "Navigator did not submit direction advice";
496
+ // Classification originates only at the native provider stream seam.
497
+ // AssistantMessage metadata is a human diagnostic surface, not an acceptance oracle.
498
+ const providerFailure = activeSession.providerFailure?.();
499
+ const source = providerFailure?.source ?? "unknown";
500
+ const cause = providerFailure?.cause ?? source;
501
+ throw navigatorUnavailableError(source, errorMessage, cause);
513
502
  }
503
+ preparedAdvice = normalizePreparedAdvice(output);
504
+ return preparedAdvice;
505
+ } finally {
506
+ const playbookFailure = activeSession.routePlaybookReadFailure?.();
507
+ observedRoutePlaybookFailure = typeof playbookFailure === "string" && playbookFailure.trim() !== ""
508
+ ? playbookFailure
509
+ : undefined;
510
+ outputSink = undefined;
511
+ }
514
512
  };
515
513
 
516
514
  return {
@@ -530,8 +528,9 @@ export function createNavigatorAttendance(options: NavigatorAttendanceOptions) {
530
528
  prepare(): void {
531
529
  if (disposed || preparation !== undefined) return;
532
530
  preparationFailure = undefined;
531
+ preparedAdvice = undefined;
533
532
  // Track live in-flight only — a resolved preparation still held until settle
534
- // drain must not read as preparing (test helpers release early ready-wait by this).
533
+ // drain must not read as preparing (test helpers release by this).
535
534
  preparationInFlight = true;
536
535
  preparation = prepare().finally(() => {
537
536
  preparationInFlight = false;
@@ -575,22 +574,9 @@ export function createNavigatorAttendance(options: NavigatorAttendanceOptions) {
575
574
  async function settleOnce(settlement: NavigatorSettlement): Promise<void> {
576
575
  // Dispose during post-role grace must ignore late completion entirely (#675).
577
576
  if (disposed) return;
578
- observedRoutePlaybookFailure = undefined;
577
+ // Keep playbook failure observed on the prepare round; settle does not re-prompt (#1160).
579
578
  const invocationId = activeInvocationId ?? invocationPrincipal;
580
579
  let report: NavigatorReport;
581
- // Drain in-flight standby attendance (record only). Then feed the typed
582
- // settlement as the only model round on the same host session.
583
- if (sessionReady !== undefined) {
584
- try { await sessionReady; } catch (error) { preparationFailure ??= error; }
585
- }
586
- if (preparation !== undefined) {
587
- try {
588
- await preparation;
589
- } catch (error) {
590
- preparationFailure ??= error;
591
- }
592
- preparation = undefined;
593
- }
594
580
  const settlementFact = {
595
581
  invocationId,
596
582
  subjectKey,
@@ -601,9 +587,11 @@ export function createNavigatorAttendance(options: NavigatorAttendanceOptions) {
601
587
  ? { status: settlement.status }
602
588
  : {}),
603
589
  };
604
- // Arrival is presentation-only: never start soul/model/help/session just to book.
605
- let drainedProse: string | undefined;
590
+ // Arrival is presentation-only: wait only for nest open, never for the model round.
606
591
  if (settlement.kind === "arrival") {
592
+ if (sessionReady !== undefined) {
593
+ try { await sessionReady; } catch (error) { preparationFailure ??= error; }
594
+ }
607
595
  session?.appendEntry(SETTLEMENT_ENTRY, settlementFact);
608
596
  if (preparationFailure !== undefined) {
609
597
  report = unavailable(invocationId, preparationFailure);
@@ -614,45 +602,34 @@ export function createNavigatorAttendance(options: NavigatorAttendanceOptions) {
614
602
  };
615
603
  }
616
604
  } else {
617
- // Feed settlement; navigator speaks. Clear early-path failure only when a
618
- // session already exists so the output prompt can run (owner: 准备好了就等着,
619
- // 结果出来喂给它让它自己输出).
620
- if (session !== undefined) preparationFailure = undefined;
621
- // Cold settle: session only (no unbound host prompt), then book + feed.
622
- // Pass the session into prepare so setModel/INVOCATION run once.
623
- let coldSession: NavigatorPreparationSession | undefined;
624
- if (session === undefined && preparationFailure === undefined) {
625
- try {
626
- coldSession = await loadMaterialsAndSession(invocationId);
627
- } catch (error) {
628
- preparationFailure = error;
629
- }
605
+ // Drain in-flight parallel prepare, then pick by status — no second model round (#1160).
606
+ if (sessionReady !== undefined) {
607
+ try { await sessionReady; } catch (error) { preparationFailure ??= error; }
630
608
  }
631
- session?.appendEntry(SETTLEMENT_ENTRY, settlementFact);
632
- if (preparationFailure === undefined) {
633
- prepareBoundSettlement = settlement;
634
- preparation = prepare(coldSession);
635
- void preparation.catch((error) => { preparationFailure = error; });
609
+ if (preparation !== undefined) {
636
610
  try {
637
- drainedProse = await preparation;
611
+ await preparation;
638
612
  } catch (error) {
639
613
  preparationFailure ??= error;
640
614
  }
641
615
  preparation = undefined;
642
616
  }
617
+ session?.appendEntry(SETTLEMENT_ENTRY, settlementFact);
643
618
  if (preparationFailure !== undefined) {
644
619
  // Contract: README.md#Navigator-attendance — failed attendance is typed
645
620
  // unavailable without invalidating the role Receipt; retain the cause.
646
621
  report = unavailable(invocationId, preparationFailure);
647
- } else if (typeof drainedProse === "string" && drainedProse.trim() !== "") {
648
- // #959: present prose as-is on every parent outcome — including
649
- // human_decision / escalate. Old path wiped prose on escalate and left
650
- // auto-attendance looking empty after a successful nested summon.
651
- // Prior words stay on the host session (CLI resume); no package advice ledger.
652
- report = { disposition: "advice", prose: drainedProse };
653
622
  } else {
654
- // Empty/no-receipt prepare → affirmative no-advice (never inferred later).
655
- report = { disposition: "no-advice" };
623
+ const status = "status" in settlement ? settlement.status : undefined;
624
+ const picked = pickPreparedProse(preparedAdvice, status);
625
+ if (typeof picked === "string" && picked.trim() !== "") {
626
+ // #959 / #1160: present the pre-written prose as-is on every parent outcome
627
+ // including human_decision / escalate. No settlement model round.
628
+ report = { disposition: "advice", prose: picked };
629
+ } else {
630
+ // Missing key / empty prepare / no prior prepare → affirmative no-advice.
631
+ report = { disposition: "no-advice" };
632
+ }
656
633
  }
657
634
  }
658
635
  if (observedRoutePlaybookFailure !== undefined) {
@@ -685,8 +662,9 @@ export function createNavigatorAttendance(options: NavigatorAttendanceOptions) {
685
662
  }
686
663
  preparation = undefined;
687
664
  sessionReady = undefined;
688
- preparedProse = undefined;
665
+ preparedAdvice = undefined;
689
666
  preparationFailure = undefined;
667
+ observedRoutePlaybookFailure = undefined;
690
668
  }
691
669
  }
692
670