@akagilnc/pi-workflow-roles 0.1.1794 → 0.1.1813

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.
@@ -32,6 +32,7 @@ import {
32
32
  import { wrapPackageOwnedToolDefinition } from "./package-owned-tool-idle.ts";
33
33
  import type { ReviewerPromptText } from "./reviewer-prompt-identity.ts";
34
34
  import { createStreamIdleGuard, isStreamIdleTimeoutError } from "./stream-idle-guard.ts";
35
+ import { createReceiptDeliveryPolicy, NO_RECEIPT_LIFECYCLE_ENTRY_TYPE, RECEIPT_DELIVERY_PROMPT } from "./receipt-delivery-policy.ts";
35
36
 
36
37
  // ── shared constants / types ──────────────────────────────────────────────
37
38
 
@@ -551,6 +552,7 @@ export async function executeAuditorChild(
551
552
  let decisionSubmitted = false;
552
553
  let decisionCallId: string | undefined;
553
554
  let decisionToolFailure: unknown;
555
+ const decisionToolFailures = new Map<string, unknown>();
554
556
  const tool = wrapPackageOwnedToolDefinition({
555
557
  ...options.tool,
556
558
  label: options.roleLabel,
@@ -562,10 +564,13 @@ export async function executeAuditorChild(
562
564
  const result = await options.tool.execute(...args);
563
565
  decision = args[1];
564
566
  decisionCallId = args[0];
567
+ decisionToolFailure = undefined;
568
+ decisionToolFailures.delete(args[0]);
565
569
  decisionSubmitted = true;
566
570
  return result;
567
571
  } catch (error) {
568
572
  decisionToolFailure = error;
573
+ decisionToolFailures.set(args[0], error);
569
574
  throw error;
570
575
  }
571
576
  },
@@ -612,6 +617,8 @@ export async function executeAuditorChild(
612
617
  let boundaryResponse: AssistantMessage | undefined;
613
618
  let retentionFailure: unknown;
614
619
  let retainedResponse: AssistantMessage | undefined;
620
+ let rejectedDecisionResponse: AssistantMessage | undefined;
621
+ let promptNeighboringFailure: unknown;
615
622
  const registeredToolNames = new Set(session.getAllTools().map((entry) => entry.name));
616
623
  const evidenceToolFailures = new Map<string, unknown>();
617
624
  for (const name of registeredToolNames) {
@@ -643,21 +650,38 @@ export async function executeAuditorChild(
643
650
  turns += 1;
644
651
  retainedResponse = event.message;
645
652
  try { options.retainResponse?.(event.message); } catch (error) { retentionFailure = error; }
653
+ // A tool call in assistant output is only an observation. Preserve its
654
+ // candidate for typed malformed-decision settlement, but the wrapped
655
+ // execute path above is the sole owner of accepted-receipt state; a
656
+ // rejected execution must remain retryable in this same session.
646
657
  for (const part of event.message.content) {
647
- if (part.type !== "toolCall" || part.name !== tool.name) continue;
648
- if (!decisionSubmitted) {
649
- decision = part.arguments;
650
- decisionCallId = part.id;
651
- decisionSubmitted = true;
652
- } else if (decisionCallId !== part.id) {
653
- decisionToolFailure = new Error("Auditor decision was submitted more than once");
658
+ if (part.type === "toolCall" && part.name === tool.name) {
659
+ rejectedDecisionResponse = event.message;
660
+ if (decision === undefined) {
661
+ decision = part.arguments;
662
+ decisionCallId = part.id;
663
+ // Pi can reject malformed root arguments before invoking execute;
664
+ // that remains the existing typed audit-incomplete candidate path.
665
+ if (part.arguments === undefined) decisionSubmitted = true;
666
+ }
654
667
  }
655
668
  }
656
669
  if (turns >= AUDITOR_TURN_LIMIT) boundaryResponse = event.message;
657
670
  }
658
- if (event.type === "turn_end" &&
659
- (decisionSubmitted || boundaryResponse !== undefined || retentionFailure !== undefined)) {
660
- void session.abort();
671
+ if (event.type === "turn_end") {
672
+ if (rejectedDecisionResponse !== undefined) {
673
+ promptNeighboringFailure = findToolFailure(rejectedDecisionResponse);
674
+ const rejectedCall = rejectedDecisionResponse.content.find((part) =>
675
+ part.type === "toolCall" && part.name === tool.name && decisionToolFailures.has(part.id));
676
+ if (rejectedCall?.type === "toolCall") {
677
+ decisionToolFailure = decisionToolFailures.get(rejectedCall.id);
678
+ decisionToolFailures.delete(rejectedCall.id);
679
+ }
680
+ }
681
+ if (decisionSubmitted || promptNeighboringFailure !== undefined
682
+ || boundaryResponse !== undefined || retentionFailure !== undefined) {
683
+ void session.abort();
684
+ }
661
685
  }
662
686
  });
663
687
  const abort = () => { void session.abort(); };
@@ -666,7 +690,59 @@ export async function executeAuditorChild(
666
690
 
667
691
  try {
668
692
  try {
669
- await session.prompt(options.prompt);
693
+ const delivery = createReceiptDeliveryPolicy();
694
+ const promptAllowingRejectedDecision = async (prompt: string) => {
695
+ rejectedDecisionResponse = undefined;
696
+ promptNeighboringFailure = undefined;
697
+ decisionToolFailure = undefined;
698
+ let promptFailure: unknown;
699
+ try {
700
+ await session.prompt(prompt);
701
+ } catch (error) {
702
+ promptFailure = error;
703
+ }
704
+ // Prefer turn_end correlation, but Pi may reject prompt() before that
705
+ // event. In that case correlate against this prompt's captured decision
706
+ // response and call-id maps at the catch boundary.
707
+ const correlatedResponse = rejectedDecisionResponse as AssistantMessage | undefined;
708
+ if (correlatedResponse !== undefined) {
709
+ promptNeighboringFailure ??= findToolFailure(correlatedResponse);
710
+ const rejectedCall = correlatedResponse.content.find((part) =>
711
+ part.type === "toolCall" && part.name === tool.name && decisionToolFailures.has(part.id));
712
+ if (rejectedCall?.type === "toolCall") {
713
+ decisionToolFailure = decisionToolFailures.get(rejectedCall.id);
714
+ decisionToolFailures.delete(rejectedCall.id);
715
+ }
716
+ }
717
+ // An adjacent failure outranks correctable decision feedback.
718
+ if (promptNeighboringFailure !== undefined) throw promptNeighboringFailure;
719
+ if (decisionToolFailure !== undefined) return;
720
+ if (promptFailure !== undefined) throw promptFailure;
721
+ };
722
+ await promptAllowingRejectedDecision(options.prompt);
723
+ while (!decisionSubmitted && boundaryResponse === undefined && inherited.streamFailure === undefined
724
+ && delivery.nextAction() === "request-delivery") {
725
+ if (decisionToolFailure !== undefined) {
726
+ delivery.recordRejected(decisionToolFailure instanceof Error ? decisionToolFailure.message : String(decisionToolFailure));
727
+ decisionToolFailure = undefined;
728
+ if (delivery.nextAction() === "request-delivery") {
729
+ await promptAllowingRejectedDecision(RECEIPT_DELIVERY_PROMPT);
730
+ }
731
+ } else {
732
+ delivery.recordDeliveryRequest();
733
+ await promptAllowingRejectedDecision(RECEIPT_DELIVERY_PROMPT);
734
+ }
735
+ }
736
+ if (!decisionSubmitted && boundaryResponse === undefined && inherited.streamFailure === undefined
737
+ && delivery.nextAction() === "no-receipt") {
738
+ const runPointer = options.context.sessionManager.getSessionFile() ?? options.context.cwd ?? process.cwd();
739
+ const attemptPointer = binding.parent.attemptEntryId ?? binding.parent.sessionId ?? `current:${runPointer}`;
740
+ decision = delivery.facts({ runPointer, attemptPointer });
741
+ // Late turn_end feedback cannot overturn a lifecycle that has already
742
+ // charged this prompt to the exhausted shared budget.
743
+ decisionToolFailure = undefined;
744
+ auditorSessionManager.appendCustomEntry(NO_RECEIPT_LIFECYCLE_ENTRY_TYPE, decision);
745
+ }
670
746
  } catch (error) {
671
747
  if (options.signal?.aborted) throw options.signal.reason;
672
748
  if (inherited.streamFailure !== undefined) throw inherited.streamFailure;
@@ -761,7 +837,7 @@ export async function executeAuditorChild(
761
837
  response === undefined
762
838
  || response.stopReason === "error"
763
839
  || response.stopReason === "aborted"
764
- || !decisionSubmitted
840
+ || (!decisionSubmitted && decision === undefined)
765
841
  ) {
766
842
  throw new Error(`${options.roleLabel} exited without a readable decision receipt`);
767
843
  }
package/src/judge-role.ts CHANGED
@@ -135,6 +135,11 @@ export function createJudgeRoleRuntime(
135
135
  terminate: true as const,
136
136
  ...(usage === undefined ? {} : { usage }),
137
137
  }),
138
+ noReceipt: (auditNoReceipt) => ({
139
+ content: [{ type: "text" as const, text: "Judge verdict accepted; compliance audit produced no receipt" }],
140
+ details: { ...verdict, auditNoReceipt },
141
+ terminate: true as const,
142
+ }),
138
143
  revise: (violations) => {
139
144
  throw new Error(
140
145
  `Judge verdict violates its soul: ${violations.join("; ")}`,
@@ -19,6 +19,7 @@ import { openInProcessAgentSession } from "./in-process-session.ts";
19
19
  import { renderPublicAkRoleCommand } from "./public-command-renderer.ts";
20
20
  import { issueRoot, subjectPath } from "./work-subject-identity.ts";
21
21
  import { wrapPackageOwnedToolDefinition } from "./package-owned-tool-idle.ts";
22
+ import { createReceiptDeliveryPolicy, NO_RECEIPT_LIFECYCLE_ENTRY_TYPE, RECEIPT_DELIVERY_PROMPT } from "./receipt-delivery-policy.ts";
22
23
 
23
24
  export const NAVIGATOR_EVENT_TYPE = "ak-navigator-attendance" as const;
24
25
  export const NAVIGATOR_PREPARE_TOOL_NAME = "ak_navigator_prepare" as const;
@@ -278,6 +279,36 @@ function unavailableKey(value: unknown): NavigatorUnavailableKey | undefined {
278
279
  function exactRecord(value: unknown): value is Record<string, unknown> {
279
280
  return typeof value === "object" && value !== null && !Array.isArray(value);
280
281
  }
282
+
283
+ /** Correlate one rejected prepare call/result inside the just-finished prompt. */
284
+ function rejectedPrepareReason(entries: readonly unknown[], start: number): string | undefined {
285
+ const recent = entries.slice(start);
286
+ const prepareCalls = new Set<string>();
287
+ for (const entry of recent) {
288
+ if (!exactRecord(entry) || entry.type !== "message" || !exactRecord(entry.message)
289
+ || entry.message.role !== "assistant" || !Array.isArray(entry.message.content)) continue;
290
+ for (const part of entry.message.content) {
291
+ if (exactRecord(part) && part.type === "toolCall" && part.name === NAVIGATOR_PREPARE_TOOL_NAME
292
+ && typeof part.id === "string") prepareCalls.add(part.id);
293
+ }
294
+ }
295
+ let reason: string | undefined;
296
+ for (const entry of recent) {
297
+ if (!exactRecord(entry) || entry.type !== "message" || !exactRecord(entry.message)
298
+ || entry.message.role !== "toolResult" || entry.message.isError !== true) continue;
299
+ const callId = entry.message.toolCallId;
300
+ if (entry.message.toolName !== NAVIGATOR_PREPARE_TOOL_NAME
301
+ || typeof callId !== "string" || !prepareCalls.has(callId)) {
302
+ return undefined;
303
+ }
304
+ const content = entry.message.content;
305
+ const text = Array.isArray(content)
306
+ ? content.flatMap((part) => exactRecord(part) && typeof part.text === "string" ? [part.text] : []).join("")
307
+ : typeof content === "string" ? content : "";
308
+ if (text.trim() !== "") reason = text.trim();
309
+ }
310
+ return reason;
311
+ }
281
312
  function targetIsValid(value: unknown): value is NavigatorRouteTarget {
282
313
  if (!exactRecord(value) || !targetRoles.has(String(value.role))) return false;
283
314
  const metadata = packagedRoleMetadata(String(value.role));
@@ -567,6 +598,7 @@ export function createNavigatorAttendance(options: NavigatorAttendanceOptions) {
567
598
  let settlementTail: Promise<void> = Promise.resolve();
568
599
  let settlementFailure: unknown;
569
600
  let preparationFailure: unknown;
601
+ let preparationNoReceipt = false;
570
602
  let routePlaybookReadFailure: string | undefined;
571
603
  let disposed = false;
572
604
  /** One-shot live-help warm; consumed by the next prepare so later prepares reread live help. */
@@ -770,7 +802,38 @@ export function createNavigatorAttendance(options: NavigatorAttendanceOptions) {
770
802
  try {
771
803
  try {
772
804
  if (disposed) throw navigatorUnavailableError("session", new Error("Navigator attendance was disposed"));
773
- await activeSession.prompt(request);
805
+ const delivery = createReceiptDeliveryPolicy();
806
+ const promptAllowingRejectedPrepare = async (text: string, deliveryRequest: boolean) => {
807
+ const entryStart = activeSession.entries().length;
808
+ let promptFailure: unknown;
809
+ try {
810
+ await activeSession.prompt(text);
811
+ } catch (error) {
812
+ promptFailure = error;
813
+ }
814
+ const providerFailure = activeSession.providerFailure?.();
815
+ if (providerFailure !== undefined) {
816
+ throw navigatorUnavailableError(providerFailure.source, promptFailure ?? "Navigator provider failure", providerFailure.cause);
817
+ }
818
+ const rejectedReason = rejectedPrepareReason(activeSession.entries(), entryStart);
819
+ if (rejectedReason !== undefined) {
820
+ delivery.recordRejected(rejectedReason);
821
+ return;
822
+ }
823
+ if (promptFailure !== undefined) throw promptFailure;
824
+ if (deliveryRequest && output === undefined) delivery.recordDeliveryRequest();
825
+ };
826
+ await promptAllowingRejectedPrepare(request, false);
827
+ while (output === undefined && delivery.nextAction() === "request-delivery") {
828
+ await promptAllowingRejectedPrepare(RECEIPT_DELIVERY_PROMPT, true);
829
+ }
830
+ if (output === undefined && delivery.nextAction() === "no-receipt" && activeSession.providerFailure?.() === undefined) {
831
+ const facts = delivery.facts({ runPointer: sessionDir, attemptPointer: invocationId });
832
+ activeSession.appendEntry(NO_RECEIPT_LIFECYCLE_ENTRY_TYPE, facts);
833
+ preparationNoReceipt = true;
834
+ candidates = [];
835
+ return candidates;
836
+ }
774
837
  } catch (error) {
775
838
  throw error instanceof NavigatorUnavailableError ? error : navigatorUnavailableError("transport", error);
776
839
  }
@@ -907,10 +970,13 @@ export function createNavigatorAttendance(options: NavigatorAttendanceOptions) {
907
970
  throw new Error("Navigator advice contradicts the accepted settlement");
908
971
  }
909
972
  }
910
- // Usable model/authority next only — never invent from settlement role/status, prior absence, or prose.
911
- if (selected?.next === undefined) {
973
+ // Budget exhaustion is affirmative typed no-advice; malformed submitted
974
+ // advice remains the existing unavailable path.
975
+ if (selected?.next === undefined && preparationNoReceipt) {
976
+ report = { disposition: "no-advice" };
977
+ } else if (selected?.next === undefined) {
912
978
  throw new Error("Navigator prepared no machine-usable next direction");
913
- }
979
+ } else {
914
980
  const selectedRoute = selected.route;
915
981
  const routeChanged = selectedRoute !== undefined && !routeEqual(previousRoute, selectedRoute);
916
982
  // Single owner: public registry renderer (ADR 0052). Model command prose is never authority.
@@ -926,6 +992,7 @@ export function createNavigatorAttendance(options: NavigatorAttendanceOptions) {
926
992
  previousRoute = selectedRoute;
927
993
  session?.appendEntry(ROUTE_ENTRY, { invocationId, subjectKey, route: selectedRoute });
928
994
  }
995
+ }
929
996
  // Contract: README.md#Navigator-attendance — Navigator failures become typed unavailable without invalidating the role Receipt; retain the original cause in the unavailable report.
930
997
  } catch (error) {
931
998
  report = unavailable(invocationId, error);
@@ -964,6 +1031,7 @@ export function createNavigatorAttendance(options: NavigatorAttendanceOptions) {
964
1031
  sessionReady = undefined;
965
1032
  candidates = undefined;
966
1033
  preparationFailure = undefined;
1034
+ preparationNoReceipt = false;
967
1035
  routePlaybookSettlement = undefined;
968
1036
  routePlaybookReadFailure = undefined;
969
1037
  }
@@ -74,6 +74,7 @@ import {
74
74
  type InvocationMarkerIdentity,
75
75
  } from "../navigator-invocation-identity.ts";
76
76
  import type { NavigatorPhase } from "../navigator-attendance.ts";
77
+ import { NO_RECEIPT_LIFECYCLE_ENTRY_TYPE, parseNoReceiptLifecycleFacts, type NoReceiptLifecycleFacts } from "../receipt-delivery-policy.ts";
77
78
  import { packagedRoleMetadata } from "../packaged-role-registry.ts";
78
79
  import {
79
80
  workSubjectKeyFromProjectRoot,
@@ -856,11 +857,21 @@ function safelyRead(object: object, key: string): { readable: true; value: unkno
856
857
  }
857
858
  }
858
859
 
860
+ function auditNoReceiptDecisiveFact(candidate: object): Record<string, unknown> {
861
+ const projected = safelyRead(candidate, "auditNoReceipt");
862
+ if (!projected.readable || projected.value === undefined) return {};
863
+ try {
864
+ return { auditNoReceipt: parseNoReceiptLifecycleFacts(projected.value) };
865
+ } catch {
866
+ return {};
867
+ }
868
+ }
869
+
859
870
  function judgeDecisiveFacts(
860
871
  verdict: object,
861
872
  judgeStatus: JudgeVerdict["judgeStatus"],
862
873
  ): Record<string, unknown> {
863
- const facts: Record<string, unknown> = { judgeStatus };
874
+ const facts: Record<string, unknown> = { judgeStatus, ...auditNoReceiptDecisiveFact(verdict) };
864
875
  if (judgeStatus === "continue") {
865
876
  const fix = safelyRead(verdict, "fix");
866
877
  if (fix.readable && isRecord(fix.value)) {
@@ -1015,7 +1026,7 @@ function collectorDecisiveFacts(
1015
1026
  function doctorDecisiveFacts(output: DoctorOutput): Record<string, unknown> {
1016
1027
  const candidate = output as unknown as object;
1017
1028
  const status = safelyRead(candidate, "status");
1018
- const facts: Record<string, unknown> = {};
1029
+ const facts: Record<string, unknown> = { ...auditNoReceiptDecisiveFact(candidate) };
1019
1030
  if (status.readable && typeof status.value === "string") facts.doctorStatus = status.value;
1020
1031
  if (status.readable && status.value === "refused") {
1021
1032
  const reason = safelyRead(candidate, "reason");
@@ -1058,6 +1069,7 @@ function reviewerDecisiveFacts(
1058
1069
  axes,
1059
1070
  reportAxes,
1060
1071
  acceptedBatchPresent: acceptedBatch.readable && acceptedBatch.value !== undefined,
1072
+ ...auditNoReceiptDecisiveFact(candidate),
1061
1073
  };
1062
1074
  if (status.readable && typeof status.value === "string") facts.reviewerStatus = status.value;
1063
1075
  const diagnostic = safelyRead(candidate, "diagnostic");
@@ -3668,6 +3680,33 @@ export async function settleFailureTerminalResult(
3668
3680
  failure: ControlledFailure,
3669
3681
  options: { readonly resume?: TerminalResume } = {},
3670
3682
  ): Promise<TerminalResult> {
3683
+ // #288 is lawful only when the lifecycle owner persisted an exhausted,
3684
+ // current-attempt fact. Transcript reconstruction must not turn arbitrary output
3685
+ // failures (or bytes retained from a prior resume attempt) into exit zero.
3686
+ if (failure.cause === "output") {
3687
+ const entries = await readBoundSessionEntries(admitted.sessionFile).catch(() => undefined);
3688
+ if (entries !== undefined) {
3689
+ let attemptStart = 0;
3690
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
3691
+ if (entries[index]?.type === "message" && entries[index]?.message?.role === "user") { attemptStart = index; break; }
3692
+ }
3693
+ const lifecycleEntry = entries.slice(attemptStart).reverse().find((entry: SessionEntry) =>
3694
+ entry.customType === NO_RECEIPT_LIFECYCLE_ENTRY_TYPE || entry.message?.customType === NO_RECEIPT_LIFECYCLE_ENTRY_TYPE);
3695
+ const raw = lifecycleEntry?.data ?? lifecycleEntry?.message?.details;
3696
+ if (raw !== undefined) {
3697
+ try {
3698
+ const facts = parseNoReceiptLifecycleFacts(raw);
3699
+ if (facts.runPointer === admitted.runDirectory && facts.attemptPointer === `current:${admitted.runDirectory}`) {
3700
+ const decisiveFacts: NoReceiptLifecycleFacts = facts;
3701
+ return {
3702
+ roleOutcome: { kind: "no_receipt", role: admitted.role, status: "no-accepted-receipt", ...facts, decisiveFacts },
3703
+ navigator: await extractNavigatorFactFromAdmittedSession(admitted), artifacts: [], runId: admitted.runId,
3704
+ };
3705
+ }
3706
+ } catch { /* malformed lifecycle bytes remain the existing nonzero output failure */ }
3707
+ }
3708
+ }
3709
+ }
3671
3710
  // Exact-session attendance only — never infer no-advice from caller omission.
3672
3711
  const navigator = await extractNavigatorFactFromAdmittedSession(admitted);
3673
3712
  // Private durable artifacts retain the original diagnostic identity (including run ID).
@@ -3741,16 +3780,16 @@ export function presentFailureTerminal(
3741
3780
  terminal: TerminalResult,
3742
3781
  io: { stdout: (text: string) => void; stderr: (text: string) => void },
3743
3782
  ): void {
3744
- if (terminal.roleOutcome.kind !== "failure") {
3745
- throw new TypeError("presentFailureTerminal requires a failure role outcome");
3783
+ if (terminal.roleOutcome.kind !== "failure" && terminal.roleOutcome.kind !== "no_receipt") {
3784
+ throw new TypeError("presentFailureTerminal requires a failure or no-receipt role outcome");
3746
3785
  }
3747
3786
  io.stdout(formatTerminalResult(terminal));
3748
- io.stderr(
3749
- formatFailureStderrDiagnostic({
3787
+ if (terminal.roleOutcome.kind === "failure") {
3788
+ io.stderr(formatFailureStderrDiagnostic({
3750
3789
  cause: terminal.roleOutcome.cause,
3751
3790
  diagnostic: terminal.roleOutcome.diagnostic,
3752
- }),
3753
- );
3791
+ }));
3792
+ }
3754
3793
  }
3755
3794
 
3756
3795
  /**
@@ -8,6 +8,7 @@
8
8
  import { renderPublicAkRoleCommand } from "./command-renderer.ts";
9
9
  import type { ComplianceAuditIncomplete } from "../compliance-transport.ts";
10
10
  import type { NavigatorPhase } from "../navigator-attendance.ts";
11
+ import type { NoReceiptLifecycleFacts } from "../receipt-delivery-policy.ts";
11
12
 
12
13
  /** Encode one free-text Terminal cell. JSON string form cannot embed raw tab/newline. */
13
14
  export function encodeTerminalField(value: string): string {
@@ -79,6 +80,13 @@ export function jsonSafeComplianceCandidate(value: unknown): unknown {
79
80
  return value === undefined ? JSON_SAFE_UNDEFINED_ARGUMENT : value;
80
81
  }
81
82
 
83
+ export type NoReceiptTerminalOutcome = NoReceiptLifecycleFacts & {
84
+ kind: "no_receipt";
85
+ role: TerminalRoleName;
86
+ status: "no-accepted-receipt";
87
+ decisiveFacts: NoReceiptLifecycleFacts & Readonly<Record<string, unknown>>;
88
+ };
89
+
82
90
  export type TerminalRoleOutcome =
83
91
  | {
84
92
  kind: "accepted";
@@ -95,6 +103,7 @@ export type TerminalRoleOutcome =
95
103
  }
96
104
  | AuditIncompleteTerminalOutcome
97
105
  | ResidualIncompleteTerminalOutcome
106
+ | NoReceiptTerminalOutcome
98
107
  | {
99
108
  kind: "failure";
100
109
  role: TerminalRoleName;
@@ -111,7 +120,7 @@ export type TerminalRoleOutcome =
111
120
  export function isLawfulTypedTerminalOutcome(
112
121
  outcome: TerminalRoleOutcome,
113
122
  ): boolean {
114
- return outcome.kind === "accepted" || outcome.kind === "audit_escalation";
123
+ return outcome.kind === "accepted" || outcome.kind === "audit_escalation" || outcome.kind === "no_receipt";
115
124
  }
116
125
 
117
126
  export function exitCodeForTerminalOutcome(
@@ -0,0 +1,89 @@
1
+ /** Shared accepted-receipt delivery budget for role, auditor, and Navigator sessions (#288). */
2
+ export const RECEIPT_DELIVERY_TURN_LIMIT = 2 as const;
3
+ export const RECEIPT_DELIVERY_PROMPT = "本 session 尚无已接受的 typed 回执。请现在调用具名终局工具交卷;若先前被打回,请按拒因修正后重交。";
4
+
5
+ export const NO_RECEIPT_LIFECYCLE_ENTRY_TYPE = "ak-no-receipt-lifecycle" as const;
6
+
7
+ /** The sole schema shared by lifecycle owners and Terminal projections. */
8
+ export type NoReceiptLifecycleFacts = {
9
+ terminalToolCalled: boolean;
10
+ rejectedReceipts: readonly { reason: string }[];
11
+ deliveryTurns: typeof RECEIPT_DELIVERY_TURN_LIMIT;
12
+ sessionCompletion: "settled-without-accepted-receipt";
13
+ runPointer: string;
14
+ attemptPointer: string;
15
+ acceptedReceipt: false;
16
+ };
17
+
18
+ function isRecord(value: unknown): value is Record<string, unknown> {
19
+ return typeof value === "object" && value !== null && !Array.isArray(value);
20
+ }
21
+
22
+ /** Read only the facts required by Terminal consumers; persisted extensions are ignored. */
23
+ export function parseNoReceiptLifecycleFacts(input: unknown): NoReceiptLifecycleFacts {
24
+ if (!isRecord(input)
25
+ || typeof input.terminalToolCalled !== "boolean"
26
+ || input.deliveryTurns !== RECEIPT_DELIVERY_TURN_LIMIT
27
+ || input.sessionCompletion !== "settled-without-accepted-receipt"
28
+ || input.acceptedReceipt !== false
29
+ || typeof input.runPointer !== "string" || input.runPointer.trim() === ""
30
+ || typeof input.attemptPointer !== "string" || input.attemptPointer.trim() === ""
31
+ || !Array.isArray(input.rejectedReceipts)
32
+ || !input.rejectedReceipts.every((item) => isRecord(item)
33
+ && typeof item.reason === "string" && item.reason.trim() !== "")) {
34
+ throw new TypeError("malformed no-receipt lifecycle facts");
35
+ }
36
+ return {
37
+ terminalToolCalled: input.terminalToolCalled,
38
+ rejectedReceipts: input.rejectedReceipts.map((item) => ({ reason: item.reason as string })),
39
+ deliveryTurns: RECEIPT_DELIVERY_TURN_LIMIT,
40
+ sessionCompletion: "settled-without-accepted-receipt",
41
+ runPointer: input.runPointer,
42
+ attemptPointer: input.attemptPointer,
43
+ acceptedReceipt: false,
44
+ };
45
+ }
46
+
47
+ export function noReceiptLifecycleFacts(
48
+ input: Omit<NoReceiptLifecycleFacts, "deliveryTurns" | "sessionCompletion" | "acceptedReceipt"> & { deliveryTurns: number },
49
+ ): NoReceiptLifecycleFacts {
50
+ if (input.deliveryTurns !== RECEIPT_DELIVERY_TURN_LIMIT) {
51
+ throw new TypeError("no-receipt lifecycle requires an exhausted delivery budget");
52
+ }
53
+ return {
54
+ terminalToolCalled: input.terminalToolCalled,
55
+ rejectedReceipts: input.rejectedReceipts.map(({ reason }) => ({ reason })),
56
+ deliveryTurns: RECEIPT_DELIVERY_TURN_LIMIT,
57
+ sessionCompletion: "settled-without-accepted-receipt",
58
+ runPointer: input.runPointer,
59
+ attemptPointer: input.attemptPointer,
60
+ acceptedReceipt: false,
61
+ };
62
+ }
63
+
64
+ export function createReceiptDeliveryPolicy() {
65
+ let accepted = false;
66
+ let terminalToolCalled = false;
67
+ let deliveryTurns = 0;
68
+ const rejectedReceipts: { reason: string }[] = [];
69
+ return {
70
+ recordAccepted() { accepted = true; terminalToolCalled = true; },
71
+ /** Infrastructure owns terminality and must never trigger receipt催交. */
72
+ stopForInfrastructure() { accepted = true; },
73
+ recordRejected(reason: string) {
74
+ terminalToolCalled = true;
75
+ rejectedReceipts.push({ reason });
76
+ deliveryTurns = Math.min(RECEIPT_DELIVERY_TURN_LIMIT, deliveryTurns + 1);
77
+ },
78
+ recordDeliveryRequest() {
79
+ deliveryTurns = Math.min(RECEIPT_DELIVERY_TURN_LIMIT, deliveryTurns + 1);
80
+ },
81
+ nextAction(): "accepted" | "request-delivery" | "no-receipt" {
82
+ if (accepted) return "accepted";
83
+ return deliveryTurns < RECEIPT_DELIVERY_TURN_LIMIT ? "request-delivery" : "no-receipt";
84
+ },
85
+ facts(binding: { runPointer: string; attemptPointer: string }): NoReceiptLifecycleFacts {
86
+ return noReceiptLifecycleFacts({ terminalToolCalled, rejectedReceipts: [...rejectedReceipts], deliveryTurns, ...binding });
87
+ },
88
+ };
89
+ }
@@ -163,6 +163,10 @@ export function createReviewerRoleRuntime(pi: ExtensionAPI, dependencies: Review
163
163
  try { await dependencies.shutdownAgent?.(); } catch (error) { hostActions.failInfrastructure(ledger.recordInfrastructureFailure(error), toolCtx, id); }
164
164
  return { content: [{ type: "text" as const, text: "Reviewer report accepted" }], details: candidate, terminate: true as const, ...(usage === undefined ? {} : { usage }) };
165
165
  },
166
+ noReceipt: async (auditNoReceipt) => {
167
+ try { await dependencies.shutdownAgent?.(); } catch (error) { hostActions.failInfrastructure(ledger.recordInfrastructureFailure(error), toolCtx, id); }
168
+ return { content: [{ type: "text" as const, text: "Reviewer report accepted; compliance audit produced no receipt" }], details: { ...candidate, auditNoReceipt }, terminate: true as const };
169
+ },
166
170
  revise: (violations) => {
167
171
  throw new AggregateError([], `Reviewer receipt rejected:\n${violations.join("\n")}`, { cause: Object.freeze([...violations]) });
168
172
  },
@@ -23,6 +23,7 @@ import {
23
23
  } from "./tool-execution-observation.ts";
24
24
  import { installPackageOwnedToolRegistration } from "./package-owned-tool-idle.ts";
25
25
  import { installWorkerGitHooks } from "./worker-submission-gates.ts";
26
+ import { createReceiptDeliveryPolicy, NO_RECEIPT_LIFECYCLE_ENTRY_TYPE, RECEIPT_DELIVERY_PROMPT } from "./receipt-delivery-policy.ts";
26
27
 
27
28
  import type { AnyCanonicalSkillBinding } from "./canonical-skill-binding.ts";
28
29
  import type { CollectorClock } from "./collector-evidence.ts";
@@ -426,6 +427,10 @@ export function createRoleRuntimeExtension(
426
427
  let pendingNavigatorSettlement: Promise<void> | undefined;
427
428
  let navigatorWorkContext: NavigatorWorkContext | undefined;
428
429
  const pendingInfrastructureToolCallIds = new Set<string>();
430
+ // #288 primary-session thin adapter. The policy is the sole budget owner;
431
+ // terminating-tool rejections and mechanical delivery requests share two turns.
432
+ let receiptDelivery = createReceiptDeliveryPolicy();
433
+ let noReceiptRecorded = false;
429
434
  pi.on("input", () => {
430
435
  const role = pi.getFlag(ROLE_FLAG.name);
431
436
  if (role !== undefined && !admitted) return { action: "handled" as const };
@@ -467,7 +472,7 @@ export function createRoleRuntimeExtension(
467
472
  });
468
473
  pi.on("tool_result", async (event) => {
469
474
  const role = selectedRole;
470
- if (role === undefined || navigatorAttendance === undefined) return;
475
+ if (role === undefined) return;
471
476
  const isRoleInfrastructureFailure = pendingInfrastructureToolCallIds.delete(event.toolCallId);
472
477
  // Overlay typed infra fact so live settlement and durable session entry agree.
473
478
  const infrastructureDetails = isRoleInfrastructureFailure
@@ -476,6 +481,19 @@ export function createRoleRuntimeExtension(
476
481
  const classified = infrastructureDetails === undefined
477
482
  ? event
478
483
  : { ...event, details: infrastructureDetails };
484
+ const isOutputTool = event.toolName === navigatorOutputTool(role);
485
+ const outputClassification = isOutputTool ? classifyPackagedRoleTerminalResult(classified) : undefined;
486
+ if (isRoleInfrastructureFailure || outputClassification?.kind === "infrastructure") {
487
+ receiptDelivery.stopForInfrastructure();
488
+ } else if (outputClassification?.kind === "accepted") {
489
+ receiptDelivery.recordAccepted();
490
+ } else if (isOutputTool && outputClassification?.kind === "nonterminal" && event.isError) {
491
+ const reason = (event.content ?? [])
492
+ .map((part) => part.type === "text" ? part.text : "")
493
+ .join("")
494
+ .trim() || "terminating tool rejected";
495
+ receiptDelivery.recordRejected(reason);
496
+ }
479
497
  const settlement = publicNavigatorSettlement(
480
498
  role,
481
499
  navigatorPhase(pi, role),
@@ -483,8 +501,9 @@ export function createRoleRuntimeExtension(
483
501
  );
484
502
  if (settlement !== undefined) {
485
503
  const attendance = navigatorAttendance;
486
- const workContext = navigatorWorkContext;
487
- const pending = (async () => {
504
+ if (attendance !== undefined) {
505
+ const workContext = navigatorWorkContext;
506
+ const pending = (async () => {
488
507
  // Accepted role terminal starts the post-role Navigator grace (#101/#106).
489
508
  if (settlement.kind !== "accepted") {
490
509
  await attendance.settle(settlement);
@@ -522,8 +541,9 @@ export function createRoleRuntimeExtension(
522
541
  void settlePromise.catch(() => undefined);
523
542
  }
524
543
  })();
525
- pendingNavigatorSettlement = pending;
526
- await pending;
544
+ pendingNavigatorSettlement = pending;
545
+ await pending;
546
+ }
527
547
  }
528
548
  // Persist typed infrastructure-failure fact onto the role session toolResult so
529
549
  // exact-session restart shares the same durable completion classification.
@@ -541,6 +561,39 @@ export function createRoleRuntimeExtension(
541
561
  content: decorated.content as typeof event.content,
542
562
  };
543
563
  });
564
+ // Queue receipt delivery before `agent_settled`: that event means Pi has
565
+ // already decided no queued continuation will run, so a triggerTurn there is
566
+ // too late for print/json sessions. `agent_end` is the last production seam
567
+ // whose queued next turn is consumed before settlement.
568
+ pi.on("agent_end", (event) => {
569
+ const lastMessage = event.messages.at(-1);
570
+ if (lastMessage?.role === "assistant" && lastMessage.stopReason === "error") {
571
+ // Provider failure has no tool_result event; classify it here so the
572
+ // receipt policy cannot turn infrastructure death into an exit-0 lifecycle.
573
+ receiptDelivery.stopForInfrastructure();
574
+ return;
575
+ }
576
+ if (receiptDelivery.nextAction() === "request-delivery") {
577
+ receiptDelivery.recordDeliveryRequest();
578
+ // Keep the package-owned continuation off the public input lifecycle:
579
+ // one-shot roles must not mistake this delivery request for later caller input.
580
+ pi.appendEntry("ak-receipt-delivery-request");
581
+ pi.sendMessage({
582
+ customType: "ak-receipt-delivery-prompt",
583
+ content: RECEIPT_DELIVERY_PROMPT,
584
+ display: false,
585
+ }, { triggerTurn: true, deliverAs: "followUp" });
586
+ } else if (receiptDelivery.nextAction() === "no-receipt" && !noReceiptRecorded) {
587
+ const runPointer = process.env.AK_ROLE_RUN_DIR;
588
+ if (runPointer !== undefined) {
589
+ noReceiptRecorded = true;
590
+ pi.appendEntry(
591
+ NO_RECEIPT_LIFECYCLE_ENTRY_TYPE,
592
+ receiptDelivery.facts({ runPointer, attemptPointer: `current:${runPointer}` }),
593
+ );
594
+ }
595
+ }
596
+ });
544
597
  pi.on("agent_settled", async () => {
545
598
  if (pendingNavigatorSettlement !== undefined) {
546
599
  await pendingNavigatorSettlement;
@@ -774,6 +827,8 @@ export function createRoleRuntimeExtension(
774
827
  pi.on("session_start", async (event, ctx) => {
775
828
  admitted = false;
776
829
  selectedRole = undefined;
830
+ receiptDelivery = createReceiptDeliveryPolicy();
831
+ noReceiptRecorded = false;
777
832
  observationFace.reset();
778
833
  pendingNavigatorPresentation = undefined;
779
834
  pendingNavigatorSettlement = undefined;