@akagilnc/pi-workflow-roles 0.1.2383 → 0.1.2397

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akagilnc/pi-workflow-roles",
3
- "version": "0.1.2383",
3
+ "version": "0.1.2397",
4
4
  "description": "Soul-bound workflow roles for Pi",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -16,10 +16,9 @@
16
16
  * An accepted gate terminating receipt (isError:false pair on dispatch/officer
17
17
  * tool) whose required typed facts are unusable — status, dispatch officer, or
18
18
  * first/last span missing/unknown/unparseable/inverted — also fails loudly via
19
- * the same throw→ledger `auditor-roles` unreadable seam. Lawful Gatekeeper
20
- * typed `incomplete` is a recognizable terminal (omit from pairing, leg stays
21
- * readable) — only unknown/non-contract dispatch status stays loud. True
22
- * non-gate volumes (soul-audit noise, etc.) stay omitted from pairing.
19
+ * the same throw→ledger `auditor-roles` unreadable seam. Unknown/non-contract
20
+ * dispatch status stays loud (#475 abolished Gatekeeper incomplete special-case).
21
+ * True non-gate volumes (soul-audit noise, etc.) stay omitted from pairing.
23
22
  */
24
23
  import { readdir } from "node:fs/promises";
25
24
  import { join } from "node:path";
@@ -36,7 +35,7 @@ export type AnalystGateCycleRound = {
36
35
  readonly roundIndex: number;
37
36
  /** Current English officer face after historical alias fold. */
38
37
  readonly officer: "inspector" | "notary";
39
- /** Typed officer terminal status (pass / bounce / incomplete / …). */
38
+ /** Typed officer terminal status (pass / bounce / …). */
40
39
  readonly status: string;
41
40
  /** Officer subsession first→last usable timestamp delta (ms). */
42
41
  readonly officerWallMs: number;
@@ -237,9 +236,7 @@ async function classifyAuditorVolume(
237
236
  const findingsCount = findings.length;
238
237
 
239
238
  if (DISPATCH_TOOLS.has(call.toolName)) {
240
- // Gatekeeper contract terminals: dispatch | incomplete. Incomplete is a
241
- // lawful zero-pair terminal (#434-436 / #458) — never wash the parent leg.
242
- if (status === "incomplete") return undefined;
239
+ // Gatekeeper contract terminal is dispatch only (#475).
243
240
  if (status !== "dispatch") {
244
241
  throw new Error(
245
242
  `accepted dispatch receipt has non-dispatch status ${JSON.stringify(status)} in ${filePath}`,
@@ -1,7 +1,6 @@
1
1
  import type { Usage } from "@earendil-works/pi-ai";
2
2
 
3
3
  import type {
4
- ComplianceAuditIncomplete,
5
4
  ComplianceDecision,
6
5
  } from "./compliance-transport.ts";
7
6
 
@@ -35,13 +34,6 @@ export type AuditEscalationToolResult = {
35
34
  usage?: Usage;
36
35
  };
37
36
 
38
- export type AuditIncompleteToolResult = {
39
- content: [{ type: "text"; text: string }];
40
- details: ComplianceAuditIncomplete;
41
- terminate: true;
42
- usage?: Usage;
43
- };
44
-
45
37
  /**
46
38
  * Build the escalation delivery face.
47
39
  * Role-delivered fields ride under the escalation discriminator (ADR 0055).
@@ -119,17 +111,6 @@ export function projectAuditEscalation(
119
111
  };
120
112
  }
121
113
 
122
- export function projectAuditIncomplete(
123
- decision: ComplianceAuditIncomplete,
124
- ): AuditIncompleteToolResult {
125
- return {
126
- content: [{ type: "text", text: "Compliance audit incomplete; no role receipt was formed." }],
127
- details: decision,
128
- terminate: true,
129
- ...(decision.usage === undefined ? {} : { usage: decision.usage }),
130
- };
131
- }
132
-
133
114
  /**
134
115
  * Discriminator-only recognition (ADR 0040). Shape of conflicts/options/gate
135
116
  * is not a reject gate — element types and cardinality are delivery content.
@@ -152,7 +133,6 @@ export type ComplianceDecisionHandlers<T> = {
152
133
  ) => T | PromiseLike<T>;
153
134
  revise: (violations: readonly unknown[]) => T | PromiseLike<T>;
154
135
  escalate: (result: AuditEscalationToolResult) => T | PromiseLike<T>;
155
- auditIncomplete?: (result: AuditIncompleteToolResult) => T | PromiseLike<T>;
156
136
  };
157
137
 
158
138
  /**
@@ -183,10 +163,5 @@ export async function disposeComplianceDecision<T>(
183
163
  return await handlers.escalate(
184
164
  projectAuditEscalation(decision, deliveredOutput),
185
165
  );
186
- case "audit-incomplete":
187
- if (handlers.auditIncomplete === undefined) {
188
- throw new Error("Compliance audit-incomplete handler is unavailable");
189
- }
190
- return await handlers.auditIncomplete(projectAuditIncomplete(decision));
191
166
  }
192
167
  }
@@ -15,9 +15,30 @@ export type ComplianceAuditObservation =
15
15
  | { kind: "non-object-arguments"; type: ComplianceArgumentRootType }
16
16
  | { kind: "object-status-unreadable"; status: "missing" | "unknown" }
17
17
  | DossierObservation;
18
- export type ComplianceAuditIncomplete = { status: "audit-incomplete"; observation: ComplianceAuditObservation; candidate: unknown; usage?: Usage };
19
18
  export type ComplianceNoReceipt = NoReceiptLifecycleFacts & { status: "no-receipt"; usage?: Usage };
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;
19
+ export type ComplianceDecision = { status: "pass"; usage?: Usage } | { status: "revise"; violations: readonly unknown[]; usage?: Usage } | { status: "escalate"; conflicts?: unknown; decisionGate?: unknown; usage?: Usage } | ComplianceNoReceipt;
20
+
21
+ /** Unreadable compliance candidate — infrastructure failure, not a judgment status (#475). */
22
+ export class ComplianceCandidateUnreadableError extends Error {
23
+ readonly observation: ComplianceAuditObservation;
24
+ readonly candidate: unknown;
25
+ readonly usage?: Usage;
26
+ constructor(observation: ComplianceAuditObservation, candidate: unknown, usage?: Usage) {
27
+ const detail =
28
+ observation.kind === "non-object-arguments"
29
+ ? `${observation.kind}:${observation.type}`
30
+ : observation.kind === "object-status-unreadable"
31
+ ? `${observation.kind}:${observation.status}`
32
+ : observation.kind === "missing-subject"
33
+ ? `${observation.kind}:${observation.subject}`
34
+ : observation.kind;
35
+ super(`Compliance candidate unreadable: ${detail}`);
36
+ this.name = "ComplianceCandidateUnreadableError";
37
+ this.observation = observation;
38
+ this.candidate = candidate;
39
+ if (usage !== undefined) this.usage = usage;
40
+ }
41
+ }
21
42
  export type ComplianceDispatch = { model: Model<Api>; auth: { apiKey?: string; headers?: Record<string, string | null>; env?: Record<string, string> } };
22
43
 
23
44
  /** Zero-projection kickoff — soul already carries dossier-fetch duty; no hand-delivered materials. */
@@ -25,8 +46,9 @@ export const AUDITOR_DOSSIER_PROMPT = "Audit the current run dossier." as const;
25
46
 
26
47
  const nonblank = Type.String({ minLength: 1, pattern: "\\S" });
27
48
  const decisionGateSchema = Type.Object({ question: nonblank, options: Type.Array(nonblank, { minItems: 1 }) }, { additionalProperties: false });
28
- // Transport must retain malformed candidates so they can settle as typed
29
- // audit-incomplete outcomes; status values are guidance, not a schema gate.
49
+ // Transport retains malformed candidates on ComplianceCandidateUnreadableError so
50
+ // the existing failure channel can publish observation + candidate (#475).
51
+ // Status values are guidance, not a schema gate.
30
52
  export const complianceDecisionSchema = Type.Object({ status: Type.Unknown({ description: "Auditor decision status." }), violations: Type.Array(nonblank, { description: "Observed compliance violations." }), conflicts: Type.Array(nonblank, { description: "Unresolved authority or execution conflicts." }), decisionGate: Type.Union([decisionGateSchema, Type.Null()], { description: "Escalation question and available options." }) }, { additionalProperties: true, required: [] });
31
53
 
32
54
  export function createComplianceDecisionTool(name: string, description: string) {
@@ -93,12 +115,22 @@ function retainComplianceResponse(context: ExtensionContext, response: Assistant
93
115
  }
94
116
  function readListField(value: unknown): readonly unknown[] { return Array.isArray(value) ? value : value === undefined ? [] : [value]; }
95
117
  export function readComplianceCandidate(arguments_: unknown, usage?: Usage): ComplianceDecision {
96
- if (typeof arguments_ !== "object" || arguments_ === null || Array.isArray(arguments_)) return { status: "audit-incomplete", observation: { kind: "non-object-arguments", type: arguments_ === null ? "null" : Array.isArray(arguments_) ? "array" : typeof arguments_ as ComplianceArgumentRootType }, candidate: arguments_, ...(usage === undefined ? {} : { usage }) };
118
+ if (typeof arguments_ !== "object" || arguments_ === null || Array.isArray(arguments_)) {
119
+ throw new ComplianceCandidateUnreadableError(
120
+ { kind: "non-object-arguments", type: arguments_ === null ? "null" : Array.isArray(arguments_) ? "array" : typeof arguments_ as ComplianceArgumentRootType },
121
+ arguments_,
122
+ usage,
123
+ );
124
+ }
97
125
  const args = arguments_ as Record<string, unknown>; const status = args.status;
98
126
  if (status === "pass") return { status, ...(usage === undefined ? {} : { usage }) };
99
127
  if (status === "revise") return { status, violations: readListField(args.violations), ...(usage === undefined ? {} : { usage }) };
100
128
  if (status === "escalate") return { status, ...(Object.hasOwn(args, "conflicts") ? { conflicts: args.conflicts } : {}), ...(Object.hasOwn(args, "decisionGate") ? { decisionGate: args.decisionGate } : {}), ...(usage === undefined ? {} : { usage }) };
101
- return { status: "audit-incomplete", observation: { kind: "object-status-unreadable", status: status === undefined ? "missing" : "unknown" }, candidate: arguments_, ...(usage === undefined ? {} : { usage }) };
129
+ throw new ComplianceCandidateUnreadableError(
130
+ { kind: "object-status-unreadable", status: status === undefined ? "missing" : "unknown" },
131
+ arguments_,
132
+ usage,
133
+ );
102
134
  }
103
135
 
104
136
  export type RunComplianceAuditOptions = {
@@ -10,8 +10,8 @@ import {
10
10
  } from "./compliance-transport.ts";
11
11
  import {
12
12
  readDoctorAuditSubjects,
13
+ requireAuditMaterials,
13
14
  resolveAuditDossier,
14
- toAuditIncomplete,
15
15
  } from "./dossier-resolution.ts";
16
16
 
17
17
  export const DOCTOR_AUDIT_TOOL_NAME = "ak_doctor_audit_decision";
@@ -35,9 +35,9 @@ export function createPiDoctorAuditor(
35
35
  ): (options: DoctorAuditOptions) => Promise<ComplianceDecision> {
36
36
  return async (options) => {
37
37
  const dossier = resolveAuditDossier();
38
- if (dossier.status === "incomplete") return toAuditIncomplete(dossier.observation);
38
+ requireAuditMaterials(dossier);
39
39
  const subjects = readDoctorAuditSubjects(options.context);
40
- if (subjects.status === "incomplete") return toAuditIncomplete(subjects.observation);
40
+ requireAuditMaterials(subjects);
41
41
 
42
42
  return runComplianceAudit({
43
43
  tool,
@@ -16,7 +16,7 @@ export function createDoctorRoleRuntime(pi: ExtensionAPI, dependencies: DoctorRo
16
16
  let activation: { soul: string; patient: DoctorCase; store: DoctorEvidenceStore } | undefined; let registered = false; pi.registerFlag(DOCTOR_CASE_FLAG.name, DOCTOR_CASE_FLAG.definition);
17
17
  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) };
18
18
  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 }; } });
19
- 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; const acceptedDetails = withEngineLaborFallbackField(details, readActivationEngineLaborFallbackField()); return disposeComplianceDecision<AgentToolResult<unknown>>(audit, { pass: (usage) => ({ content: [{ type: "text" as const, text: "Doctor output accepted" }], details: acceptedDetails, 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: { ...acceptedDetails, auditNoReceipt }, terminate: true as const, ...usageProjection }), revise: (violations) => { throw new Error(`Doctor output violates its soul: ${violations.join("; ")}`); }, escalate: (result) => result, auditIncomplete: (result) => result }, acceptedDetails); } });
19
+ 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; const acceptedDetails = withEngineLaborFallbackField(details, readActivationEngineLaborFallbackField()); return disposeComplianceDecision<AgentToolResult<unknown>>(audit, { pass: (usage) => ({ content: [{ type: "text" as const, text: "Doctor output accepted" }], details: acceptedDetails, 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: { ...acceptedDetails, auditNoReceipt }, terminate: true as const, ...usageProjection }), revise: (violations) => { throw new Error(`Doctor output violates its soul: ${violations.join("; ")}`); }, escalate: (result) => result }, acceptedDetails); } });
20
20
  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>` }; }); }
21
21
  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"); } };
22
22
  }
@@ -130,8 +130,29 @@ export function readDoctorAuditSubjects(context: ExtensionContext): SubjectResol
130
130
  return { status: "incomplete", observation: { kind: "missing-subject", subject: "candidate-testimony" } };
131
131
  }
132
132
 
133
- export function toAuditIncomplete<TObservation extends DossierObservation>(
134
- observation: TObservation,
135
- ): { status: "audit-incomplete"; observation: TObservation; candidate: undefined } {
136
- return { status: "audit-incomplete", observation, candidate: undefined };
133
+ /**
134
+ * Missing dossier/subject is infrastructure failure, not a judgment status (#475).
135
+ * Observation + empty candidate ride the existing failInfrastructure → error artifact path.
136
+ */
137
+ export class AuditMaterialsUnavailableError extends Error {
138
+ readonly observation: DossierObservation;
139
+ readonly candidate: undefined;
140
+ constructor(observation: DossierObservation) {
141
+ const detail =
142
+ observation.kind === "missing-subject"
143
+ ? `${observation.kind}:${observation.subject}`
144
+ : observation.kind;
145
+ super(`Audit materials unavailable: ${detail}`);
146
+ this.name = "AuditMaterialsUnavailableError";
147
+ this.observation = observation;
148
+ this.candidate = undefined;
149
+ }
150
+ }
151
+
152
+ export function requireAuditMaterials(
153
+ resolution: DossierResolution | SubjectResolution,
154
+ ): asserts resolution is DossierOk | SubjectOk {
155
+ if (resolution.status === "incomplete") {
156
+ throw new AuditMaterialsUnavailableError(resolution.observation);
157
+ }
137
158
  }
@@ -927,7 +927,7 @@ export async function executeAuditorChild(
927
927
  decision = part.arguments;
928
928
  decisionCallId = part.id;
929
929
  // Pi can reject malformed root arguments before invoking execute;
930
- // that remains the existing typed audit-incomplete candidate path.
930
+ // that remains the existing unreadable-candidate failure path.
931
931
  if (part.arguments === undefined) decisionSubmitted = true;
932
932
  }
933
933
  }
@@ -24,18 +24,18 @@ export type GatekeeperResult =
24
24
  readonly findings: readonly string[];
25
25
  readonly submission: unknown;
26
26
  }
27
+ | { readonly status: "no_receipt"; readonly stage: "gatekeeper" | "inspector" | "notary"; readonly reason: string; readonly facts: NoReceiptLifecycleFacts }
27
28
  | {
28
- readonly status: "incomplete";
29
+ readonly status: "transport_failure";
29
30
  readonly stage: "gatekeeper" | "inspector" | "notary";
30
31
  readonly reason: string;
32
+ /** Original unusable submission retained for the failure channel. */
31
33
  readonly submission?: unknown;
32
- }
33
- | { readonly status: "no_receipt"; readonly stage: "gatekeeper" | "inspector" | "notary"; readonly reason: string; readonly facts: NoReceiptLifecycleFacts }
34
- | { readonly status: "transport_failure"; readonly stage: "gatekeeper" | "inspector" | "notary"; readonly reason: string };
34
+ };
35
35
 
36
36
  export type GatekeeperNonPassResult = Extract<
37
37
  GatekeeperResult,
38
- { status: "bounce" | "incomplete" | "no_receipt" }
38
+ { status: "bounce" | "no_receipt" }
39
39
  >;
40
40
 
41
41
  function nonPassMessage(result: GatekeeperNonPassResult): string {
@@ -74,15 +74,13 @@ export type GatekeeperPassHostActions = {
74
74
  // Unknown fields so wrong types/spellings still reach projection (ADR 0055/0057; 仓第 0 条).
75
75
  // Opening goes through the sole openToolObject owner — no parallel transport helper.
76
76
  const officerDecisionSchema = openToolObject(Type.Object({
77
- status: Type.Unknown({ description: "pass | bounce | incomplete — guidance, not a schema gate." }),
77
+ status: Type.Unknown({ description: "pass | bounce — guidance, not a schema gate." }),
78
78
  findings: Type.Unknown({ description: "string[] findings retained with pass or bounce." }),
79
- reason: Type.Unknown({ description: "Why the officer decision is incomplete." }),
80
79
  }));
81
80
 
82
81
  const gatekeeperDecisionSchema = openToolObject(Type.Object({
83
- status: Type.Unknown({ description: "dispatch | incomplete — guidance, not a schema gate." }),
82
+ status: Type.Unknown({ description: "dispatch — guidance, not a schema gate." }),
84
83
  officer: Type.Unknown({ description: "inspector | notary when status is dispatch." }),
85
- reason: Type.Unknown({ description: "Why Gatekeeper dispatch is incomplete." }),
86
84
  }));
87
85
 
88
86
  const INVOCATION_OVERLAY = "取证工具不受白名单限制;若取证产生临时副作用,取证结束后须自行恢复。";
@@ -104,7 +102,7 @@ function subjectTool(subject: GatekeeperSubject): AuditorDecisionTool {
104
102
  export function createOfficerDecisionTool(name: string): AuditorDecisionTool {
105
103
  return {
106
104
  name,
107
- description: "Submit one typed pass, bounce, or incomplete decision.",
105
+ description: "Submit one typed pass or bounce decision.",
108
106
  parameters: officerDecisionSchema,
109
107
  async execute(_id, args) { return result(`accepted ${String((args as { status?: unknown })?.status)}`, args); },
110
108
  };
@@ -114,7 +112,7 @@ export function createOfficerDecisionTool(name: string): AuditorDecisionTool {
114
112
  export function createGatekeeperOutputTool(): AuditorDecisionTool {
115
113
  return {
116
114
  name: GATEKEEPER_OUTPUT_TOOL,
117
- description: "Dispatch the admitted subject to one officer, or report incomplete.",
115
+ description: "Dispatch the admitted subject to one officer.",
118
116
  parameters: gatekeeperDecisionSchema,
119
117
  async execute(_id, args) { return result(`accepted ${String((args as { status?: unknown })?.status)}`, args); },
120
118
  };
@@ -143,15 +141,18 @@ function retainedSubmission(decision: unknown): unknown {
143
141
  return decision === undefined ? MISSING_ARGUMENTS_SUBMISSION : decision;
144
142
  }
145
143
 
146
- /** Neutral bookkeeping when no explicit release path is present — no format judgment. */
147
- function noExplicitReleaseIncomplete(
144
+ /**
145
+ * No usable explicit release is infrastructure failure, not a judgment status.
146
+ * Original submission is retained for the failure channel (#475).
147
+ */
148
+ function noUsableReleaseFailure(
148
149
  stage: "gatekeeper" | "inspector" | "notary",
149
150
  decision: unknown,
150
- ): Extract<GatekeeperResult, { status: "incomplete" }> {
151
+ ): Extract<GatekeeperResult, { status: "transport_failure" }> {
151
152
  return {
152
- status: "incomplete",
153
+ status: "transport_failure",
153
154
  stage,
154
- reason: stage === "gatekeeper" ? "decision 无显式 dispatch" : "decision 无显式 pass",
155
+ reason: stage === "gatekeeper" ? "decision 无显式 dispatch" : "decision 无显式 pass/bounce",
155
156
  submission: retainedSubmission(decision),
156
157
  };
157
158
  }
@@ -163,19 +164,11 @@ function readRecord(value: unknown): Record<string, unknown> | undefined {
163
164
 
164
165
  function projectProvinceDecision(decision: unknown): GatekeeperResult | { status: "dispatch"; officer: "inspector" | "notary" } {
165
166
  const record = readRecord(decision);
166
- if (record === undefined) return noExplicitReleaseIncomplete("gatekeeper", decision);
167
- if (record.status === "incomplete") {
168
- const reason = record.reason;
169
- if (typeof reason === "string" && reason.trim() !== "") {
170
- // Role's own incomplete reason is kept as-is; machine does not rewrite it.
171
- return { status: "incomplete", stage: "gatekeeper", reason, submission: retainedSubmission(decision) };
172
- }
173
- return noExplicitReleaseIncomplete("gatekeeper", decision);
174
- }
167
+ if (record === undefined) return noUsableReleaseFailure("gatekeeper", decision);
175
168
  if (record.status === "dispatch" && (record.officer === "inspector" || record.officer === "notary")) {
176
169
  return { status: "dispatch", officer: record.officer };
177
170
  }
178
- return noExplicitReleaseIncomplete("gatekeeper", decision);
171
+ return noUsableReleaseFailure("gatekeeper", decision);
179
172
  }
180
173
 
181
174
  function projectOfficerDecision(
@@ -183,15 +176,7 @@ function projectOfficerDecision(
183
176
  decision: unknown,
184
177
  ): GatekeeperResult {
185
178
  const record = readRecord(decision);
186
- if (record === undefined) return noExplicitReleaseIncomplete(officer, decision);
187
- if (record.status === "incomplete") {
188
- const reason = record.reason;
189
- if (typeof reason === "string" && reason.trim() !== "") {
190
- // Role's own incomplete reason is kept as-is; machine does not rewrite it.
191
- return { status: "incomplete", stage: officer, reason, submission: retainedSubmission(decision) };
192
- }
193
- return noExplicitReleaseIncomplete(officer, decision);
194
- }
179
+ if (record === undefined) return noUsableReleaseFailure(officer, decision);
195
180
  if (record.status === "bounce") {
196
181
  return {
197
182
  status: "bounce",
@@ -208,7 +193,7 @@ function projectOfficerDecision(
208
193
  findings: asStringArray(record.findings),
209
194
  };
210
195
  }
211
- return noExplicitReleaseIncomplete(officer, decision);
196
+ return noUsableReleaseFailure(officer, decision);
212
197
  }
213
198
 
214
199
  export async function runGatekeeper(options: RunGatekeeperOptions): Promise<GatekeeperResult> {
@@ -221,7 +206,7 @@ export async function runGatekeeper(options: RunGatekeeperOptions): Promise<Gate
221
206
  roleLabel: "Gatekeeper",
222
207
  menxiaSeat: "gatekeeper",
223
208
  systemPrompt: `${await loadSoul("gatekeeper")}\n\n${INVOCATION_OVERLAY}`,
224
- prompt: "Read the admitted subject with ak_gatekeeper_subject, then dispatch it or submit typed incomplete.",
209
+ prompt: "Read the admitted subject with ak_gatekeeper_subject, then dispatch it to one officer.",
225
210
  tool: createGatekeeperOutputTool(),
226
211
  dossierTool: subjectTool(options.subject),
227
212
  ...(options.signal === undefined ? {} : { signal: options.signal }),
@@ -259,7 +244,7 @@ export async function runGatekeeper(options: RunGatekeeperOptions): Promise<Gate
259
244
  }
260
245
  }
261
246
 
262
- /** Project GatekeeperResult onto a submit path: transport→failInfrastructure; bounce/incomplete/no_receipt→typed throw; pass silent. */
247
+ /** Project GatekeeperResult onto a submit path: transport→failInfrastructure; bounce/no_receipt→typed throw; pass silent. */
263
248
  export async function requireGatekeeperPass(options: {
264
249
  readonly context: ExtensionContext;
265
250
  readonly subject: GatekeeperSubject;
@@ -274,11 +259,16 @@ export async function requireGatekeeperPass(options: {
274
259
  });
275
260
  if (gatekeeper.status === "pass") return;
276
261
  if (gatekeeper.status === "transport_failure") {
277
- options.hostActions.failInfrastructure(
278
- new Error(`Gatekeeper transport failure at ${gatekeeper.stage}: ${gatekeeper.reason}`),
279
- options.context,
280
- options.toolCallId,
281
- );
262
+ // Typed stage/reason/submission ride failInfrastructure → durable tool_result (#475).
263
+ const error = new Error(`Gatekeeper transport failure at ${gatekeeper.stage}: ${gatekeeper.reason}`) as Error & {
264
+ stage: typeof gatekeeper.stage;
265
+ reason: string;
266
+ submission?: unknown;
267
+ };
268
+ error.stage = gatekeeper.stage;
269
+ error.reason = gatekeeper.reason;
270
+ if (gatekeeper.submission !== undefined) error.submission = gatekeeper.submission;
271
+ options.hostActions.failInfrastructure(error, options.context, options.toolCallId);
282
272
  }
283
273
  // Envelope owns the execute→tool_result bridge; this module only projects + throws.
284
274
  options.hostActions.bindGatekeeperNonPass(options.toolCallId, gatekeeper);
@@ -10,8 +10,8 @@ import { auditorRunDirectory } from "./auditor-dossier-tool.ts";
10
10
  import { loadAuditorSoul } from "./auditor-soul.ts";
11
11
  import {
12
12
  readJudgeAuditSubjects,
13
+ requireAuditMaterials,
13
14
  resolveAuditDossier,
14
- toAuditIncomplete,
15
15
  } from "./dossier-resolution.ts";
16
16
 
17
17
  export const JUDGE_AUDIT_TOOL_NAME = "ak_soul_audit_decision";
@@ -37,9 +37,9 @@ export function createPiJudgeAuditor(
37
37
  ): (options: JudgeAuditOptions) => Promise<ComplianceDecision> {
38
38
  return async (options) => {
39
39
  const dossier = resolveAuditDossier();
40
- if (dossier.status === "incomplete") return toAuditIncomplete(dossier.observation);
40
+ requireAuditMaterials(dossier);
41
41
  const subjects = readJudgeAuditSubjects(options.context);
42
- if (subjects.status === "incomplete") return toAuditIncomplete(subjects.observation);
42
+ requireAuditMaterials(subjects);
43
43
 
44
44
  return runComplianceAudit({
45
45
  tool: auditDecisionTool,
package/src/judge-role.ts CHANGED
@@ -161,7 +161,6 @@ export function createJudgeRoleRuntime(
161
161
  );
162
162
  },
163
163
  escalate: (result) => result,
164
- auditIncomplete: (result) => result,
165
164
  },
166
165
  // #380: escalate deliveredOutput must carry the same mechanical projection.
167
166
  acceptedDetails,
@@ -15,7 +15,7 @@
15
15
  * One shared typed terminal classifier owns durable completion for lifecycle,
16
16
  * publicNavigatorSettlement, and every public CLI Receipt extractor:
17
17
  * - accepted/human: isError exactly false and no infrastructure-failure fact
18
- * - infrastructure: isError exactly true plus exact closed infrastructure fact
18
+ * - infrastructure: isError exactly true plus base kind/source/reasonCode identity
19
19
  * - retryable/missing/nonboolean/contradictory/malformed: nonterminal
20
20
  *
21
21
  * One truth table also owns marker↔terminal cardinality:
@@ -33,13 +33,25 @@ export const NAVIGATOR_INVOCATION_ENTRY = "ak-navigator-invocation" as const;
33
33
  /** Typed durable infrastructure-failure fact on a packaged role output toolResult. */
34
34
  export const NAVIGATOR_INFRASTRUCTURE_FAILURE_KIND = "role_infrastructure_failure" as const;
35
35
 
36
- /** Closed fact keys — extras/missing/wrong keys fail closed. */
36
+ /** Required base identity keys for infrastructure-failure recognition. */
37
37
  const NAVIGATOR_INFRASTRUCTURE_FAILURE_KEYS = [
38
38
  "kind",
39
39
  "source",
40
40
  "reasonCode",
41
41
  ] as const;
42
42
 
43
+ /**
44
+ * Known typed failure evidence keys projected onto durable infrastructure details (#475).
45
+ * Extraction whitelist only — classification does not reject unknown extras (ADR 0040).
46
+ */
47
+ export const NAVIGATOR_INFRASTRUCTURE_FAILURE_EVIDENCE_KEYS = [
48
+ "observation",
49
+ "candidate",
50
+ "submission",
51
+ "stage",
52
+ "reason",
53
+ ] as const;
54
+
43
55
  export type NavigatorInfrastructureFailureFact = {
44
56
  kind: typeof NAVIGATOR_INFRASTRUCTURE_FAILURE_KIND;
45
57
  source: "shared-role-lifecycle";
@@ -55,16 +67,13 @@ export function buildNavigatorInfrastructureFailureFact(): NavigatorInfrastructu
55
67
  }
56
68
 
57
69
  /**
58
- * Exact closed infrastructure-failure fact.
59
- * Rejects extras, missing keys, wrong values/types, and non-objects.
70
+ * Base infrastructure-failure identity on durable details.
71
+ * Only kind/source/reasonCode discriminate; extra fields are allowed and retained
72
+ * (ADR 0040 — discriminators select the branch, they do not ban extras).
60
73
  */
61
- export function isNavigatorInfrastructureFailureFact(
62
- value: unknown,
63
- ): value is NavigatorInfrastructureFailureFact {
74
+ export function hasNavigatorInfrastructureFailureBase(value: unknown): boolean {
64
75
  if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
65
76
  const record = value as Record<string, unknown>;
66
- const keys = Object.keys(record);
67
- if (keys.length !== NAVIGATOR_INFRASTRUCTURE_FAILURE_KEYS.length) return false;
68
77
  for (const key of NAVIGATOR_INFRASTRUCTURE_FAILURE_KEYS) {
69
78
  if (!Object.hasOwn(record, key)) return false;
70
79
  }
@@ -75,6 +84,18 @@ export function isNavigatorInfrastructureFailureFact(
75
84
  );
76
85
  }
77
86
 
87
+ /**
88
+ * Exact closed infrastructure-failure fact (no evidence extensions).
89
+ * Classifier uses {@link hasNavigatorInfrastructureFailureBase} so enriched
90
+ * durable details still complete as infrastructure (#475).
91
+ */
92
+ export function isNavigatorInfrastructureFailureFact(
93
+ value: unknown,
94
+ ): value is NavigatorInfrastructureFailureFact {
95
+ if (!hasNavigatorInfrastructureFailureBase(value)) return false;
96
+ return Object.keys(value as object).length === NAVIGATOR_INFRASTRUCTURE_FAILURE_KEYS.length;
97
+ }
98
+
78
99
  const PACKAGED_ROLE_OUTPUT_TOOLS: ReadonlyMap<string, string> = new Map(
79
100
  PACKAGED_ROLE_REGISTRY.map((entry) => [entry.outputTool, entry.role]),
80
101
  );
@@ -191,11 +212,11 @@ export function classifyPackagedRoleTerminalResult(
191
212
  if (typeof message.toolName !== "string") return { kind: "nonterminal" };
192
213
  if (!PACKAGED_ROLE_OUTPUT_TOOLS.has(message.toolName)) return { kind: "nonterminal" };
193
214
 
194
- const infraFact = isNavigatorInfrastructureFailureFact(message.details)
195
- ? message.details
196
- : undefined;
215
+ // Base identity is enough; durable details may carry typed failure evidence (#475).
216
+ const hasInfraBase = hasNavigatorInfrastructureFailureBase(message.details);
217
+ const infraFact = hasInfraBase ? buildNavigatorInfrastructureFailureFact() : undefined;
197
218
 
198
- // Infrastructure completion: exact isError === true + exact closed infra fact.
219
+ // Infrastructure completion: exact isError === true + infra base identity.
199
220
  if (message.isError === true) {
200
221
  if (infraFact === undefined) return { kind: "nonterminal" };
201
222
  return { kind: "infrastructure", fact: infraFact };
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Public Notary (符宝郎) terminating receipt contracts.
3
- * Lawful explicit releases: pass | bounce | incomplete(with non-empty reason).
4
- * Residual incomplete (no explicit release) is projected by public settlement, not here.
3
+ * Lawful explicit releases: pass | bounce.
4
+ * No usable result is infrastructure failure via public settlement, not a judgment status (#475).
5
5
  */
6
6
  import { Type } from "typebox";
7
7
 
@@ -31,14 +31,11 @@ export const NOTARY_FIXED_KICKOFF =
31
31
  export const notaryOutputSchema = openToolObject(
32
32
  Type.Object({
33
33
  status: Type.Unknown({
34
- description: "pass | bounce | incomplete — guidance, not a schema gate.",
34
+ description: "pass | bounce — guidance, not a schema gate.",
35
35
  }),
36
36
  findings: Type.Unknown({
37
37
  description: "string[] findings retained with pass or bounce.",
38
38
  }),
39
- reason: Type.Unknown({
40
- description: "Why the notary decision is incomplete.",
41
- }),
42
39
  }),
43
40
  );
44
41
 
@@ -54,8 +51,7 @@ type NotaryOutputClean =
54
51
  readonly status: "bounce";
55
52
  readonly disposition: "rewrite";
56
53
  readonly findings: readonly string[];
57
- }
58
- | { readonly status: "incomplete"; readonly reason: string };
54
+ };
59
55
 
60
56
  /** Clean submission or seat-fallback tainted accepted receipt (ADR 0071). */
61
57
  export type NotaryOutput =
@@ -73,7 +69,7 @@ function asStringArray(value: unknown): readonly string[] {
73
69
 
74
70
  /**
75
71
  * Project one explicit Notary release. Throws when there is no lawful explicit
76
- * pass / bounce / incomplete(reason) — callers map that to residual incomplete.
72
+ * pass / bounce — callers map that to the existing non-zero failure channel.
77
73
  */
78
74
  export function validateNotaryOutput(value: unknown): NotaryOutput {
79
75
  if (!isRecord(value)) {
@@ -84,13 +80,6 @@ export function validateNotaryOutput(value: unknown): NotaryOutput {
84
80
  throw new Error("Notary output has no recognized execution discriminator");
85
81
  }
86
82
  const status = seatFallbackBaseStatus(statusRaw);
87
- if (status === "incomplete") {
88
- const reason = value.reason;
89
- if (typeof reason !== "string" || reason.trim() === "") {
90
- throw new Error("Notary incomplete requires a non-empty reason");
91
- }
92
- return structuredClone(value) as NotaryOutput;
93
- }
94
83
  if (status === "bounce") {
95
84
  const clone = structuredClone(value) as Record<string, unknown>;
96
85
  if (clone.disposition === undefined) clone.disposition = "rewrite";
@@ -127,8 +116,5 @@ export function notaryDecisiveFacts(output: NotaryOutput): Record<string, unknow
127
116
  if (status === "bounce") {
128
117
  facts.disposition = "rewrite";
129
118
  }
130
- if (status === "incomplete") {
131
- facts.reason = (output as { reason: string }).reason;
132
- }
133
119
  return facts;
134
120
  }
@@ -86,7 +86,7 @@ export function createNotaryRoleRuntime(
86
86
  name: NOTARY_OUTPUT_TOOL_NAME,
87
87
  label: "Notary Output",
88
88
  description:
89
- "Submit one typed pass, bounce, or incomplete decision on quote fidelity and ticket alignment.",
89
+ "Submit one typed pass or bounce decision on quote fidelity and ticket alignment.",
90
90
  promptSnippet: "Submit the Notary decision",
91
91
  promptGuidelines: [
92
92
  `Use ${NOTARY_OUTPUT_TOOL_NAME} as the sole final action.`,
@@ -109,7 +109,7 @@ export function createNotaryRoleRuntime(
109
109
  output = validateNotaryOutput(parameters);
110
110
  } catch (error) {
111
111
  // Non-explicit release stays a rejected terminating call so public
112
- // settlement can project residual incomplete (layer ②).
112
+ // settlement can map it to the existing non-zero failure channel (#475).
113
113
  throw error instanceof Error
114
114
  ? error
115
115
  : new Error(String(error));