@akagilnc/pi-workflow-roles 0.1.1817 → 0.1.1837

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.
@@ -16,12 +16,15 @@ export function parseNoReceiptLifecycleFacts(input) {
16
16
  || typeof input.attemptPointer !== "string" || input.attemptPointer.trim() === ""
17
17
  || !Array.isArray(input.rejectedReceipts)
18
18
  || !input.rejectedReceipts.every((item) => isRecord(item)
19
- && typeof item.reason === "string" && item.reason.trim() !== "")) {
19
+ && typeof item.reason === "string")) {
20
20
  throw new TypeError("malformed no-receipt lifecycle facts");
21
21
  }
22
22
  return {
23
23
  terminalToolCalled: input.terminalToolCalled,
24
- rejectedReceipts: input.rejectedReceipts.map((item) => ({ reason: item.reason })),
24
+ rejectedReceipts: input.rejectedReceipts.map((item) => ({
25
+ reason: item.reason,
26
+ diagnosticAvailable: item.reason.trim() !== "",
27
+ })),
25
28
  deliveryTurns: RECEIPT_DELIVERY_TURN_LIMIT,
26
29
  sessionCompletion: "settled-without-accepted-receipt",
27
30
  runPointer: input.runPointer,
@@ -35,7 +38,10 @@ export function noReceiptLifecycleFacts(input) {
35
38
  }
36
39
  return {
37
40
  terminalToolCalled: input.terminalToolCalled,
38
- rejectedReceipts: input.rejectedReceipts.map(({ reason }) => ({ reason })),
41
+ rejectedReceipts: input.rejectedReceipts.map(({ reason }) => ({
42
+ reason,
43
+ diagnosticAvailable: reason.trim() !== "",
44
+ })),
39
45
  deliveryTurns: RECEIPT_DELIVERY_TURN_LIMIT,
40
46
  sessionCompletion: "settled-without-accepted-receipt",
41
47
  runPointer: input.runPointer,
@@ -49,12 +55,15 @@ export function createReceiptDeliveryPolicy() {
49
55
  let deliveryTurns = 0;
50
56
  const rejectedReceipts = [];
51
57
  return {
52
- recordAccepted() { accepted = true; terminalToolCalled = true; },
58
+ recordAccepted() {
59
+ accepted = true;
60
+ terminalToolCalled = true;
61
+ },
53
62
  /** Infrastructure owns terminality and must never trigger receipt催交. */
54
63
  stopForInfrastructure() { accepted = true; },
55
64
  recordRejected(reason) {
56
65
  terminalToolCalled = true;
57
- rejectedReceipts.push({ reason });
66
+ rejectedReceipts.push({ reason, diagnosticAvailable: reason.trim() !== "" });
58
67
  deliveryTurns = Math.min(RECEIPT_DELIVERY_TURN_LIMIT, deliveryTurns + 1);
59
68
  },
60
69
  recordDeliveryRequest() {
@@ -1,33 +1,28 @@
1
- /**
2
- * 司天台唯一 Pi session 记录落盘入口(ADR 0065)。
3
- * 调用方只声明自己是谁的什么;落点由候簿拓扑算出,签名不含任何落点/路径参数。
4
- * 「谁调了谁」复用 Pi parentSession + ADR 0047 correlation,不新增 caller 字段。
5
- */
6
1
  import { dirname, resolve, join } from "node:path";
7
2
  import { SessionManager } from "@earendil-works/pi-coding-agent";
8
3
  import { resolveBookKeyFromGit } from "./activation-ledger-git.js";
9
- import { activationBookDirectory, ensureRealDirectoryTree, physicallyContainedIn, resolveActivationLedgerHome, } from "./activation-ledger-topology.js";
10
- /**
11
- * Sole package entry that constructs a durable Pi session record (ADR 0065).
12
- * No destination/path parameters — location is computed from ledger topology only.
13
- */
14
- export function createRecordSession(options) {
15
- const cwd = options.cwd;
16
- const parentFile = options.parent?.getSessionFile();
17
- if (parentFile === undefined || parentFile.length === 0) {
18
- // No durable parent principal — preserve prior in-memory child behavior.
19
- return SessionManager.inMemory(cwd);
20
- }
21
- const ledgerHome = resolveActivationLedgerHome();
22
- const parentResolved = resolve(parentFile);
23
- // Nest under parent only when the parent record already lives under the package home.
24
- // Nest base is dirname(parent file) — the durable principal's directory — never a
25
- // separate getSessionDir() that can diverge (empty in-memory dir + durable file).
26
- // Otherwise the book is resolved from cwd (ADR 0048) and the kind sits under that book —
27
- // workspace / foreign parents cannot drag records out of home.
28
- const sessionDir = physicallyContainedIn(ledgerHome, parentResolved)
29
- ? join(dirname(parentResolved), options.kind)
30
- : join(activationBookDirectory(ledgerHome, resolveBookKeyFromGit(cwd)), options.kind);
31
- ensureRealDirectoryTree(ledgerHome, sessionDir);
32
- return SessionManager.create(cwd, sessionDir, { parentSession: parentFile });
4
+ import {
5
+ roleRunSessionCoordinates
6
+ } from "./sitian-role-run-coordinates.js";
7
+ import {
8
+ activationBookDirectory,
9
+ ensureRealDirectoryTree,
10
+ physicallyContainedIn,
11
+ resolveActivationLedgerHome
12
+ } from "./activation-ledger-topology.js";
13
+ function createRecordSession(options) {
14
+ const cwd = options.cwd;
15
+ const parentFile = options.parent?.getSessionFile();
16
+ if (parentFile === void 0 || parentFile.length === 0) {
17
+ return SessionManager.inMemory(cwd);
18
+ }
19
+ const ledgerHome = resolveActivationLedgerHome();
20
+ const parentResolved = resolve(parentFile);
21
+ const sessionDir = physicallyContainedIn(ledgerHome, parentResolved) ? join(dirname(parentResolved), options.kind) : join(activationBookDirectory(ledgerHome, resolveBookKeyFromGit(cwd)), options.kind);
22
+ ensureRealDirectoryTree(ledgerHome, sessionDir);
23
+ return SessionManager.create(cwd, sessionDir, { parentSession: parentFile });
33
24
  }
25
+ export {
26
+ createRecordSession,
27
+ roleRunSessionCoordinates
28
+ };
@@ -0,0 +1,28 @@
1
+ import { join } from "node:path";
2
+ import { resolveBookKeyFromGit } from "./activation-ledger-git.js";
3
+ import {
4
+ activationBookDirectory,
5
+ resolveActivationLedgerHome
6
+ } from "./activation-ledger-topology.js";
7
+ function roleRunSessionCoordinates(options) {
8
+ const ledgerHome = resolveActivationLedgerHome(
9
+ options.home === void 0 ? void 0 : () => options.home
10
+ );
11
+ const bookKey = resolveBookKeyFromGit(options.cwd);
12
+ const runDirectory = join(
13
+ activationBookDirectory(ledgerHome, bookKey),
14
+ "runs",
15
+ `${options.runId}@${options.role}`
16
+ );
17
+ const sessionDirectory = join(runDirectory, "session");
18
+ return {
19
+ ledgerHome,
20
+ bookKey,
21
+ runDirectory,
22
+ sessionDirectory,
23
+ sessionFile: join(sessionDirectory, "session.jsonl")
24
+ };
25
+ }
26
+ export {
27
+ roleRunSessionCoordinates
28
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akagilnc/pi-workflow-roles",
3
- "version": "0.1.1817",
3
+ "version": "0.1.1837",
4
4
  "description": "Soul-bound workflow roles for Pi",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -12,6 +12,8 @@ const entries = [
12
12
  "activation-ledger-git",
13
13
  "activation-ledger-topology",
14
14
  "activation-reconciliation",
15
+ "sitian-role-run-coordinates",
16
+ "sitian-record-entry",
15
17
  ];
16
18
 
17
19
  /**
@@ -148,6 +148,7 @@ export type ComplianceDecisionHandlers<T> = {
148
148
  /** Project the accepted parent candidate beside the typed audit-leg facts. */
149
149
  noReceipt?: (
150
150
  facts: Extract<ComplianceDecision, { status: "no-receipt" }>,
151
+ usageProjection: { usage?: Usage },
151
152
  ) => T | PromiseLike<T>;
152
153
  revise: (violations: readonly unknown[]) => T | PromiseLike<T>;
153
154
  escalate: (result: AuditEscalationToolResult) => T | PromiseLike<T>;
@@ -172,7 +173,10 @@ export async function disposeComplianceDecision<T>(
172
173
  if (handlers.noReceipt === undefined) {
173
174
  throw new Error("Compliance no-receipt projection handler is unavailable");
174
175
  }
175
- return await handlers.noReceipt(decision);
176
+ return await handlers.noReceipt(
177
+ decision,
178
+ decision.usage === undefined ? {} : { usage: decision.usage },
179
+ );
176
180
  case "revise":
177
181
  return await handlers.revise(decision.violations);
178
182
  case "escalate":
@@ -7,7 +7,7 @@ import {
7
7
  } from "./evidence-child-executor.ts";
8
8
  import { createAuditorDossierTool } from "./auditor-dossier-tool.ts";
9
9
  import type { DossierObservation } from "./dossier-resolution.ts";
10
- import { parseNoReceiptLifecycleFacts, type NoReceiptLifecycleFacts } from "./receipt-delivery-policy.ts";
10
+ import type { NoReceiptLifecycleFacts } from "./receipt-delivery-policy.ts";
11
11
 
12
12
  export type ComplianceCompletion = AuditorCompletion;
13
13
  export type ComplianceArgumentRootType = "null" | "array" | "undefined" | "string" | "number" | "boolean" | "bigint" | "symbol" | "function";
@@ -16,7 +16,7 @@ export type ComplianceAuditObservation =
16
16
  | { kind: "object-status-unreadable"; status: "missing" | "unknown" }
17
17
  | DossierObservation;
18
18
  export type ComplianceAuditIncomplete = { status: "audit-incomplete"; observation: ComplianceAuditObservation; candidate: unknown; usage?: Usage };
19
- export type ComplianceNoReceipt = NoReceiptLifecycleFacts & { status: "no-receipt" };
19
+ export type ComplianceNoReceipt = NoReceiptLifecycleFacts & { status: "no-receipt"; usage?: Usage };
20
20
  export type ComplianceDecision = { status: "pass"; usage?: Usage } | { status: "revise"; violations: readonly unknown[]; usage?: Usage } | { status: "escalate"; conflicts?: unknown; decisionGate?: unknown; usage?: Usage } | ComplianceNoReceipt | ComplianceAuditIncomplete;
21
21
  export type ComplianceDispatch = { model: Model<Api>; auth: { apiKey?: string; headers?: Record<string, string | null>; env?: Record<string, string> } };
22
22
 
@@ -128,9 +128,12 @@ export async function runComplianceAudit(options: RunComplianceAuditOptions): Pr
128
128
  ...(options.runCompletion === undefined ? {} : { runCompletion: options.runCompletion }),
129
129
  ...(options.signal === undefined ? {} : { signal: options.signal }),
130
130
  });
131
- try {
132
- return { status: "no-receipt", ...parseNoReceiptLifecycleFacts(receipt.decision) };
133
- } catch {
134
- return readComplianceCandidate(receipt.decision, receipt.response.usage);
131
+ if (receipt.noReceiptLifecycle !== undefined) {
132
+ return {
133
+ status: "no-receipt",
134
+ ...receipt.noReceiptLifecycle,
135
+ ...(receipt.response.usage === undefined ? {} : { usage: receipt.response.usage }),
136
+ };
135
137
  }
138
+ return readComplianceCandidate(receipt.decision, receipt.response.usage);
136
139
  }
@@ -12,7 +12,7 @@ export function createDoctorRoleRuntime(pi: ExtensionAPI, dependencies: DoctorRo
12
12
  let activation: { soul: string; patient: DoctorCase; store: DoctorEvidenceStore } | undefined; let registered = false; pi.registerFlag(DOCTOR_CASE_FLAG.name, DOCTOR_CASE_FLAG.definition);
13
13
  return { async activate() { const path = pi.getFlag(DOCTOR_CASE_FLAG.name); if (typeof path !== "string" || !path.trim()) throw new Error("Doctor requires --ak-doctor-case"); const soul = (await dependencies.loadSoul()).trim(); if (!soul) throw new Error("Doctor soul is empty"); const patient = await dependencies.loadCase(path); activation = { soul, patient, store: new DoctorEvidenceStore(patient) };
14
14
  if (!registered) { registered = true; pi.registerTool({ name: DOCTOR_EVIDENCE_TOOL_NAME, label: "Doctor Evidence", description: "Read retained Pi session bytes with bounded pagination.", parameters: doctorEvidenceReadSchema, async execute(_id, params: { evidenceId: string; offset?: number; limit?: number }) { if (!activation) throw new Error("Doctor is not activated"); const details = activation.store.read(params.evidenceId, params.offset, params.limit); return { content: [{ type: "text" as const, text: JSON.stringify(details) }], details }; } });
15
- pi.registerTool({ name: DOCTOR_OUTPUT_TOOL_NAME, label: "Doctor Output", description: DOCTOR_OUTPUT_TOOL_DESCRIPTION, parameters: doctorSubmissionSchema, async execute(id, params, signal, _update, ctx): Promise<AgentToolResult<unknown>> { if (!activation) throw new Error("Doctor is not activated"); singleton(id, ctx); const testimony = validateDoctorOutput(params, activation.patient, activation.store); try { appendActiveSessionCustomEntry(ctx, DOCTOR_CANDIDATE_ENTRY_TYPE, { version: 1, testimony, readRecord: activation.store.readRecord(), patientIdentity: activation.patient.identity }, { unavailable: "doctor candidate retention is unavailable", failed: "doctor candidate retention failed" }); } catch (error) { host.failInfrastructure(error, ctx, id); } let audit: ComplianceDecision; try { audit = await dependencies.auditCompliance(signal === undefined ? { context: ctx } : { context: ctx, signal }); } catch (error) { host.failInfrastructure(error, ctx, id); } const details = testimony.status === "completed" ? { ...testimony, cost: activation.patient.cost } : testimony; return disposeComplianceDecision<AgentToolResult<unknown>>(audit, { pass: (usage) => ({ content: [{ type: "text" as const, text: "Doctor output accepted" }], details, terminate: true as const, ...(usage === undefined ? {} : { usage }) }), noReceipt: (auditNoReceipt) => ({ content: [{ type: "text" as const, text: "Doctor output accepted; compliance audit produced no receipt" }], details: { ...details, auditNoReceipt }, terminate: true as const }), revise: (violations) => { throw new Error(`Doctor output violates its soul: ${violations.join("; ")}`); }, escalate: (result) => result, auditIncomplete: (result) => result }, details); } });
15
+ pi.registerTool({ name: DOCTOR_OUTPUT_TOOL_NAME, label: "Doctor Output", description: DOCTOR_OUTPUT_TOOL_DESCRIPTION, parameters: doctorSubmissionSchema, async execute(id, params, signal, _update, ctx): Promise<AgentToolResult<unknown>> { if (!activation) throw new Error("Doctor is not activated"); singleton(id, ctx); const testimony = validateDoctorOutput(params, activation.patient, activation.store); try { appendActiveSessionCustomEntry(ctx, DOCTOR_CANDIDATE_ENTRY_TYPE, { version: 1, testimony, readRecord: activation.store.readRecord(), patientIdentity: activation.patient.identity }, { unavailable: "doctor candidate retention is unavailable", failed: "doctor candidate retention failed" }); } catch (error) { host.failInfrastructure(error, ctx, id); } let audit: ComplianceDecision; try { audit = await dependencies.auditCompliance(signal === undefined ? { context: ctx } : { context: ctx, signal }); } catch (error) { host.failInfrastructure(error, ctx, id); } const details = testimony.status === "completed" ? { ...testimony, cost: activation.patient.cost } : testimony; return disposeComplianceDecision<AgentToolResult<unknown>>(audit, { pass: (usage) => ({ content: [{ type: "text" as const, text: "Doctor output accepted" }], details, terminate: true as const, ...(usage === undefined ? {} : { usage }) }), noReceipt: (auditNoReceipt, usageProjection) => ({ content: [{ type: "text" as const, text: "Doctor output accepted; compliance audit produced no receipt" }], details: { ...details, auditNoReceipt }, terminate: true as const, ...usageProjection }), revise: (violations) => { throw new Error(`Doctor output violates its soul: ${violations.join("; ")}`); }, escalate: (result) => result, auditIncomplete: (result) => result }, details); } });
16
16
  pi.on("before_agent_start", (event) => { if (!activation) throw new Error("Doctor is not activated"); const catalog = { version: activation.patient.version, identity: activation.patient.identity, admittedMetrics: { provenance: "runtime-derived from retained session bytes and sealed into the accepted receipt", cost: activation.patient.cost }, lawfulTargetKeys: ["case", ...activation.patient.cost.invocations.sources], evidence: activation.patient.evidence.map(({ id, kind, sha256, byteLength, contentLength }) => ({ id, kind, sha256, byteLength, contentLength })) }; return { systemPrompt: `${event.systemPrompt}\n\n<doctor_soul>\n${activation.soul}\n</doctor_soul>\n\n<doctor_case>\n${JSON.stringify(catalog)}\n</doctor_case>` }; }); }
17
17
  const required = [DOCTOR_EVIDENCE_TOOL_NAME, DOCTOR_OUTPUT_TOOL_NAME]; const names = pi.getAllTools().map((tool) => tool.name); for (const name of required) if (names.filter((item) => item === name).length !== 1) throw new Error(`Doctor required tool collision or missing: ${name}`); pi.setActiveTools(required); const active = pi.getActiveTools?.() ?? required; if (active.length !== 2 || !required.every((name) => active.includes(name))) throw new Error("Doctor active tool narrowing failed"); } };
18
18
  }
@@ -32,7 +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
+ import { createReceiptDeliveryPolicy, NO_RECEIPT_LIFECYCLE_ENTRY_TYPE, RECEIPT_DELIVERY_PROMPT, type NoReceiptLifecycleFacts } from "./receipt-delivery-policy.ts";
36
36
 
37
37
  // ── shared constants / types ──────────────────────────────────────────────
38
38
 
@@ -532,7 +532,7 @@ export type AuditorRoleOptions = {
532
532
  */
533
533
  export async function executeAuditorChild(
534
534
  options: AuditorRoleOptions,
535
- ): Promise<{ decision: unknown; response: AssistantMessage }> {
535
+ ): Promise<{ decision: unknown; response: AssistantMessage; noReceiptLifecycle?: NoReceiptLifecycleFacts }> {
536
536
  const { createRecordSession } = await import("./sitian-record-entry.ts");
537
537
 
538
538
  return withInProcessScratch({ prefix: "ak-auditor-role-" }, async (scratch) => {
@@ -549,10 +549,12 @@ export async function executeAuditorChild(
549
549
  const cwd = options.context.cwd ?? process.cwd();
550
550
 
551
551
  let decision: unknown;
552
+ let noReceiptLifecycle: NoReceiptLifecycleFacts | undefined;
552
553
  let decisionSubmitted = false;
553
554
  let decisionCallId: string | undefined;
554
555
  let decisionToolFailure: unknown;
555
556
  const decisionToolFailures = new Map<string, unknown>();
557
+ const delivery = createReceiptDeliveryPolicy();
556
558
  const tool = wrapPackageOwnedToolDefinition({
557
559
  ...options.tool,
558
560
  label: options.roleLabel,
@@ -560,8 +562,12 @@ export async function executeAuditorChild(
560
562
  if (decisionSubmitted && decisionCallId !== args[0]) {
561
563
  throw new Error("Auditor decision was submitted more than once");
562
564
  }
565
+ // Pi may already have issued several decision calls in one assistant
566
+ // response. Execute every issued call: the budget limits future
567
+ // solicitations, not terminal calls already in flight.
563
568
  try {
564
569
  const result = await options.tool.execute(...args);
570
+ delivery.recordAccepted();
565
571
  decision = args[1];
566
572
  decisionCallId = args[0];
567
573
  decisionToolFailure = undefined;
@@ -614,11 +620,13 @@ export async function executeAuditorChild(
614
620
  auditorSessionManager.appendCustomEntry(AUDITOR_PARENT_ATTEMPT_BINDING_ENTRY_TYPE, binding);
615
621
 
616
622
  let turns = 0;
623
+ const sessionUsage = emptyUsage();
617
624
  let boundaryResponse: AssistantMessage | undefined;
618
625
  let retentionFailure: unknown;
619
626
  let retainedResponse: AssistantMessage | undefined;
620
627
  let rejectedDecisionResponse: AssistantMessage | undefined;
621
628
  let promptNeighboringFailure: unknown;
629
+ let promptDecisionFailures: unknown[] = [];
622
630
  const registeredToolNames = new Set(session.getAllTools().map((entry) => entry.name));
623
631
  const evidenceToolFailures = new Map<string, unknown>();
624
632
  for (const name of registeredToolNames) {
@@ -645,9 +653,18 @@ export async function executeAuditorChild(
645
653
  return [...session.messages].reverse().find((message) =>
646
654
  message.role === "toolResult" && callIdSet.has(message.toolCallId) && message.isError);
647
655
  };
656
+ const drainRejectedDecisionFailures = (response: AssistantMessage) => {
657
+ for (const part of response.content) {
658
+ if (part.type !== "toolCall" || part.name !== tool.name || !decisionToolFailures.has(part.id)) continue;
659
+ decisionToolFailure = decisionToolFailures.get(part.id);
660
+ promptDecisionFailures.push(decisionToolFailure);
661
+ decisionToolFailures.delete(part.id);
662
+ }
663
+ };
648
664
  const unsubscribe = session.subscribe((event) => {
649
665
  if (event.type === "message_end" && event.message.role === "assistant" && boundaryResponse === undefined) {
650
666
  turns += 1;
667
+ addUsage(sessionUsage, event.message.usage);
651
668
  retainedResponse = event.message;
652
669
  try { options.retainResponse?.(event.message); } catch (error) { retentionFailure = error; }
653
670
  // A tool call in assistant output is only an observation. Preserve its
@@ -671,15 +688,11 @@ export async function executeAuditorChild(
671
688
  if (event.type === "turn_end") {
672
689
  if (rejectedDecisionResponse !== undefined) {
673
690
  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
- }
691
+ drainRejectedDecisionFailures(rejectedDecisionResponse);
680
692
  }
681
693
  if (decisionSubmitted || promptNeighboringFailure !== undefined
682
- || boundaryResponse !== undefined || retentionFailure !== undefined) {
694
+ || (boundaryResponse !== undefined && rejectedDecisionResponse === undefined)
695
+ || retentionFailure !== undefined) {
683
696
  void session.abort();
684
697
  }
685
698
  }
@@ -690,11 +703,11 @@ export async function executeAuditorChild(
690
703
 
691
704
  try {
692
705
  try {
693
- const delivery = createReceiptDeliveryPolicy();
694
706
  const promptAllowingRejectedDecision = async (prompt: string) => {
695
707
  rejectedDecisionResponse = undefined;
696
708
  promptNeighboringFailure = undefined;
697
709
  decisionToolFailure = undefined;
710
+ promptDecisionFailures = [];
698
711
  let promptFailure: unknown;
699
712
  try {
700
713
  await session.prompt(prompt);
@@ -707,41 +720,62 @@ export async function executeAuditorChild(
707
720
  const correlatedResponse = rejectedDecisionResponse as AssistantMessage | undefined;
708
721
  if (correlatedResponse !== undefined) {
709
722
  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
- }
723
+ drainRejectedDecisionFailures(correlatedResponse);
716
724
  }
717
725
  // An adjacent failure outranks correctable decision feedback.
718
726
  if (promptNeighboringFailure !== undefined) throw promptNeighboringFailure;
727
+ // An accepted correction in the same response owns the terminal
728
+ // outcome; correlated rejected siblings remain observations, not a
729
+ // stale failure capable of replacing that accepted receipt.
730
+ if (decisionSubmitted) {
731
+ decisionToolFailure = undefined;
732
+ return;
733
+ }
719
734
  if (decisionToolFailure !== undefined) return;
720
735
  if (promptFailure !== undefined) throw promptFailure;
721
736
  };
737
+ const chargeAndClearRejectedDecisionFailures = (failures: unknown[]) => {
738
+ for (const failure of failures) {
739
+ delivery.recordRejected(failure instanceof Error ? failure.message : String(failure));
740
+ }
741
+ decisionToolFailure = undefined;
742
+ promptDecisionFailures = [];
743
+ };
722
744
  await promptAllowingRejectedDecision(options.prompt);
723
- while (!decisionSubmitted && boundaryResponse === undefined && inherited.streamFailure === undefined
724
- && delivery.nextAction() === "request-delivery") {
745
+ while (!decisionSubmitted && (boundaryResponse === undefined || decisionToolFailure !== undefined)
746
+ && inherited.streamFailure === undefined && delivery.nextAction() === "request-delivery") {
725
747
  if (decisionToolFailure !== undefined) {
726
- delivery.recordRejected(decisionToolFailure instanceof Error ? decisionToolFailure.message : String(decisionToolFailure));
727
- decisionToolFailure = undefined;
748
+ const failures = promptDecisionFailures.length === 0
749
+ ? [decisionToolFailure]
750
+ : promptDecisionFailures;
751
+ chargeAndClearRejectedDecisionFailures(failures);
752
+ if (delivery.nextAction() === "no-receipt") boundaryResponse = undefined;
728
753
  if (delivery.nextAction() === "request-delivery") {
729
- await promptAllowingRejectedDecision(RECEIPT_DELIVERY_PROMPT);
754
+ // A rejection and its correction solicitation are one budget unit;
755
+ // recordRejected already charged it.
756
+ if (retainedResponse === rejectedDecisionResponse) {
757
+ await promptAllowingRejectedDecision(RECEIPT_DELIVERY_PROMPT);
758
+ chargeAndClearRejectedDecisionFailures(promptDecisionFailures);
759
+ }
730
760
  }
731
761
  } else {
732
762
  delivery.recordDeliveryRequest();
733
763
  await promptAllowingRejectedDecision(RECEIPT_DELIVERY_PROMPT);
734
764
  }
735
765
  }
736
- if (!decisionSubmitted && boundaryResponse === undefined && inherited.streamFailure === undefined
766
+ if (!decisionSubmitted && inherited.streamFailure === undefined
737
767
  && delivery.nextAction() === "no-receipt") {
738
768
  const runPointer = options.context.sessionManager.getSessionFile() ?? options.context.cwd ?? process.cwd();
739
769
  const attemptPointer = binding.parent.attemptEntryId ?? binding.parent.sessionId ?? `current:${runPointer}`;
740
- decision = delivery.facts({ runPointer, attemptPointer });
770
+ const facts = delivery.facts({ runPointer, attemptPointer });
771
+ decision = facts;
741
772
  // Late turn_end feedback cannot overturn a lifecycle that has already
742
773
  // charged this prompt to the exhausted shared budget.
743
774
  decisionToolFailure = undefined;
744
- auditorSessionManager.appendCustomEntry(NO_RECEIPT_LIFECYCLE_ENTRY_TYPE, decision);
775
+ auditorSessionManager.appendCustomEntry(NO_RECEIPT_LIFECYCLE_ENTRY_TYPE, facts);
776
+ // Provenance is granted only after the lifecycle owner persisted the
777
+ // current child record; accepted model arguments can never set it.
778
+ noReceiptLifecycle = facts;
745
779
  }
746
780
  } catch (error) {
747
781
  if (options.signal?.aborted) throw options.signal.reason;
@@ -750,7 +784,7 @@ export async function executeAuditorChild(
750
784
  }
751
785
  if (options.signal?.aborted) throw options.signal.reason;
752
786
  if (inherited.streamFailure !== undefined) throw inherited.streamFailure;
753
- if (decisionToolFailure !== undefined) throw decisionToolFailure;
787
+ if (!decisionSubmitted && decisionToolFailure !== undefined) throw decisionToolFailure;
754
788
  const relevantResponse = !decisionSubmitted
755
789
  ? boundaryResponse
756
790
  : [...session.messages].reverse().find((message): message is AssistantMessage =>
@@ -760,7 +794,7 @@ export async function executeAuditorChild(
760
794
  if (toolFailure !== undefined) throw toolFailure;
761
795
  }
762
796
  if (retentionFailure !== undefined && retainedResponse?.stopReason !== "error") throw retentionFailure;
763
- if (boundaryResponse !== undefined && !decisionSubmitted) {
797
+ if (boundaryResponse !== undefined && !decisionSubmitted && noReceiptLifecycle === undefined) {
764
798
  const toolNames = boundaryResponse.content.flatMap((part) => part.type === "toolCall" ? [part.name] : []);
765
799
  throw new AuditorTurnLimitError(AUDITOR_TURN_LIMIT, turns, {
766
800
  stopReason: boundaryResponse.stopReason,
@@ -841,7 +875,11 @@ export async function executeAuditorChild(
841
875
  ) {
842
876
  throw new Error(`${options.roleLabel} exited without a readable decision receipt`);
843
877
  }
844
- return { decision, response };
878
+ return {
879
+ decision,
880
+ response: { ...response, usage: sessionUsage },
881
+ ...(noReceiptLifecycle === undefined ? {} : { noReceiptLifecycle }),
882
+ };
845
883
  } finally {
846
884
  options.signal?.removeEventListener("abort", abort);
847
885
  unsubscribe();
package/src/judge-role.ts CHANGED
@@ -135,10 +135,11 @@ export function createJudgeRoleRuntime(
135
135
  terminate: true as const,
136
136
  ...(usage === undefined ? {} : { usage }),
137
137
  }),
138
- noReceipt: (auditNoReceipt) => ({
138
+ noReceipt: (auditNoReceipt, usageProjection) => ({
139
139
  content: [{ type: "text" as const, text: "Judge verdict accepted; compliance audit produced no receipt" }],
140
140
  details: { ...verdict, auditNoReceipt },
141
141
  terminate: true as const,
142
+ ...usageProjection,
142
143
  }),
143
144
  revise: (violations) => {
144
145
  throw new Error(
@@ -704,8 +704,16 @@ export function createNavigatorAttendance(options: NavigatorAttendanceOptions) {
704
704
  }
705
705
  const helpContext = help.map(({ role, help: text }) => `<role_help role="${role}">\n${text}\n</role_help>`).join("\n");
706
706
  let output: PrepareOutput | undefined;
707
+ let prepareBatchRejected = false;
707
708
  outputSink = (value) => {
708
- if (output !== undefined) throw new Error("Navigator preparation must submit exactly one typed candidate batch");
709
+ if (prepareBatchRejected || output !== undefined) {
710
+ // Tool executions in one assistant response are provisional until the
711
+ // whole response is known to contain exactly one submission. A
712
+ // duplicate invalidates the batch, including its first call.
713
+ output = undefined;
714
+ prepareBatchRejected = true;
715
+ throw new Error("Navigator preparation must submit exactly one typed candidate batch");
716
+ }
709
717
  output = value;
710
718
  };
711
719
  const tool = createNavigatorPrepareTool((value) => { outputSink?.(value); });
@@ -805,6 +813,7 @@ export function createNavigatorAttendance(options: NavigatorAttendanceOptions) {
805
813
  const delivery = createReceiptDeliveryPolicy();
806
814
  const promptAllowingRejectedPrepare = async (text: string, deliveryRequest: boolean) => {
807
815
  const entryStart = activeSession.entries().length;
816
+ prepareBatchRejected = false;
808
817
  let promptFailure: unknown;
809
818
  try {
810
819
  await activeSession.prompt(text);
@@ -817,6 +826,10 @@ export function createNavigatorAttendance(options: NavigatorAttendanceOptions) {
817
826
  }
818
827
  const rejectedReason = rejectedPrepareReason(activeSession.entries(), entryStart);
819
828
  if (rejectedReason !== undefined) {
829
+ // A rejected call makes every provisional output from this prompt
830
+ // ineligible for publication before the correction turn starts.
831
+ output = undefined;
832
+ prepareBatchRejected = true;
820
833
  delivery.recordRejected(rejectedReason);
821
834
  return;
822
835
  }