@akagilnc/pi-workflow-roles 0.1.3682 → 0.1.3700

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dist/acp-host/production-host.js +519 -561
  2. package/dist/analyst-gate-cycles-read.js +11 -1
  3. package/dist/archivist-record-entry.js +1 -1
  4. package/dist/audit-escalation.js +4 -3
  5. package/dist/auditor-dossier-tool.js +12 -0
  6. package/dist/doctor-role.js +2 -1
  7. package/dist/dossier-resolution.js +0 -35
  8. package/dist/gatekeeper-pass-envelope.js +39 -34
  9. package/dist/gatekeeper-role.js +70 -76
  10. package/dist/judge-auditor.js +5 -24
  11. package/dist/judge-role.js +39 -41
  12. package/dist/notary-contracts.js +2 -17
  13. package/dist/notary-role.js +3 -6
  14. package/dist/package-contracts/judge-output.js +0 -2
  15. package/dist/package-contracts/terminating-tools.js +2 -2
  16. package/dist/public-cli/inspector-run.js +16 -2
  17. package/dist/public-cli/instruction-seat-run.js +14 -2
  18. package/dist/public-cli/main.js +198 -213
  19. package/dist/public-cli/notary-run.js +23 -20
  20. package/dist/public-cli/post-admission.js +30 -23
  21. package/dist/public-cli/run-lifecycle.js +6 -14
  22. package/dist/public-role-summons.js +38 -11
  23. package/dist/readable-gate-item.js +16 -0
  24. package/dist/role-runtime.js +28 -3
  25. package/dist/submission-correctable-error.js +2 -1
  26. package/dist/submission-errors.js +26 -8
  27. package/dist/submission-ledger.js +7 -23
  28. package/extensions/role-runtime.ts +0 -2
  29. package/package.json +1 -1
  30. package/souls/countersign.md +3 -1
  31. package/souls/notary.md +5 -0
  32. package/src/acp-host/production-host.ts +1 -4
  33. package/src/acp-host/role-envelope.ts +1 -1
  34. package/src/analyst-gate-cycles-read.ts +10 -1
  35. package/src/archivist-record-entry.ts +7 -3
  36. package/src/audit-escalation.ts +4 -3
  37. package/src/auditor-dossier-tool.ts +21 -0
  38. package/src/doctor-role.ts +2 -1
  39. package/src/dossier-resolution.ts +0 -39
  40. package/src/gatekeeper-pass-envelope.ts +59 -43
  41. package/src/gatekeeper-role.ts +114 -112
  42. package/src/host-contracts.ts +3 -3
  43. package/src/judge-auditor.ts +5 -43
  44. package/src/judge-role.ts +41 -53
  45. package/src/notary-contracts.ts +8 -27
  46. package/src/notary-role.ts +2 -7
  47. package/src/package-contracts/judge-output.ts +0 -1
  48. package/src/package-contracts/terminating-tools.ts +2 -2
  49. package/src/public-cli/inspector-run.ts +32 -2
  50. package/src/public-cli/instruction-seat-run.ts +30 -2
  51. package/src/public-cli/notary-run.ts +48 -16
  52. package/src/public-cli/post-admission.ts +31 -37
  53. package/src/public-cli/run-lifecycle.ts +6 -17
  54. package/src/public-role-summons.ts +76 -12
  55. package/src/readable-gate-item.ts +20 -0
  56. package/src/role-runtime.ts +34 -9
  57. package/src/submission-correctable-error.ts +2 -0
  58. package/src/submission-errors.ts +28 -8
  59. package/src/submission-ledger.ts +8 -32
  60. package/dist/shape-unreadable-failure.js +0 -34
@@ -3,6 +3,7 @@ import type { Usage } from "@earendil-works/pi-ai";
3
3
  import type {
4
4
  ComplianceDecision,
5
5
  } from "./compliance-transport.ts";
6
+ import { readableGateItem } from "./readable-gate-item.ts";
6
7
 
7
8
  export const AUDIT_ESCALATION_KIND = "audit_escalation" as const;
8
9
 
@@ -126,17 +127,17 @@ function humanDecisionText(
126
127
  lines.push(`Reason: ${result.reason}`);
127
128
  }
128
129
  if (Array.isArray(result.conflicts)) {
129
- lines.push("Conflicts:", ...result.conflicts.map((conflict) => `- ${conflict}`));
130
+ lines.push("Conflicts:", ...result.conflicts.map((conflict) => `- ${readableGateItem(conflict)}`));
130
131
  }
131
132
  if (officer !== undefined && Array.isArray(result.findings) && result.findings.length > 0) {
132
- lines.push("Findings:", ...result.findings.map((finding) => `- ${finding}`));
133
+ lines.push("Findings:", ...result.findings.map((finding) => `- ${readableGateItem(finding)}`));
133
134
  }
134
135
  const gate = result.auditDecisionGate;
135
136
  if (gate !== null && typeof gate === "object" && !Array.isArray(gate)) {
136
137
  const record = gate as Record<string, unknown>;
137
138
  if (typeof record.question === "string") lines.push(`Question: ${record.question}`);
138
139
  if (Array.isArray(record.options)) {
139
- lines.push("Options:", ...record.options.map((option) => `- ${option}`));
140
+ lines.push("Options:", ...record.options.map((option) => `- ${readableGateItem(option)}`));
140
141
  }
141
142
  }
142
143
  return lines.join("\n");
@@ -62,6 +62,27 @@ export function gateSubmissionCandidatePath(runDirectory: string): string {
62
62
  return join(runDirectory, "artifacts", GATE_SUBMISSION_CANDIDATE_FILE);
63
63
  }
64
64
 
65
+ /**
66
+ * Human-readable materials for gate-officer same-parent resume (#753 / #750).
67
+ * Pointers only — no body embed (ADR 0079/0081); no duty/handbook lines (#755).
68
+ * Three pairs share this face: countersign↔notary, judge↔auditor, worker↔inspector.
69
+ * Code delivers the pointers; it does not judge whether the officer read them.
70
+ */
71
+ export function buildGateOfficerReviewInstruction(input: {
72
+ readonly sourceRunDirectory: string;
73
+ readonly submissionCandidatePath?: string;
74
+ }): string {
75
+ const lines = [
76
+ "本轮父席交卷待审。",
77
+ `来源 run:${input.sourceRunDirectory}`,
78
+ ];
79
+ const candidate = input.submissionCandidatePath?.trim();
80
+ if (candidate !== undefined && candidate.length > 0) {
81
+ lines.push(`交卷候选(冻结快照):${candidate}`);
82
+ }
83
+ return lines.join("\n");
84
+ }
85
+
65
86
  /**
66
87
  * Persist the in-flight tool-call leaf as a run-directory artifact so pointer-only
67
88
  * officer summons resolve on hosts whose session.jsonl is header-only (#632 / DK-4).
@@ -1,6 +1,7 @@
1
1
  import type { RoleHost, HostContext, HostToolResult } from "./host-contracts.ts";
2
2
  import { disposeComplianceDecision } from "./audit-escalation.ts";
3
3
  import { ComplianceResponseRetentionError, type ComplianceDecision } from "./compliance-transport.ts";
4
+ import { joinReadableGateItems } from "./readable-gate-item.ts";
4
5
  import { DOCTOR_CANDIDATE_ENTRY_TYPE } from "./dossier-resolution.ts";
5
6
  import { DOCTOR_ACCEPTED_AUDIT_NO_RECEIPT_TEXT, DOCTOR_ACCEPTED_TEXT, DOCTOR_EVIDENCE_TOOL_NAME, DOCTOR_OUTPUT_TOOL_DESCRIPTION, DOCTOR_OUTPUT_TOOL_NAME, DoctorEvidenceStore, doctorEvidenceReadSchema, doctorSubmissionSchema, validateDoctorOutput, type DoctorCase } from "./doctor-contracts.ts";
6
7
 
@@ -35,7 +36,7 @@ export function createDoctorRoleRuntime(pi: RoleHost, dependencies: DoctorRoleDe
35
36
  let activation: { soul: string; patient: DoctorCase; store: DoctorEvidenceStore } | undefined; let registered = false; pi.registerFlag(DOCTOR_CASE_FLAG.name, DOCTOR_CASE_FLAG.definition);
36
37
  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) };
37
38
  if (!registered) { registered = true; pi.registerTool({ name: DOCTOR_EVIDENCE_TOOL_NAME, label: "太医署证据", description: "分页读取留存的 Pi session 字节。", parameters: doctorEvidenceReadSchema, async execute(_id: string, params: { evidenceId: string; offset?: number; limit?: number }) { if (!activation) throw new Error("太医署未激活"); const details = activation.store.read(params.evidenceId, params.offset, params.limit); return { content: [{ type: "text" as const, text: JSON.stringify(details) }], details }; } });
38
- pi.registerTool({ name: DOCTOR_OUTPUT_TOOL_NAME, label: "太医署输出", description: DOCTOR_OUTPUT_TOOL_DESCRIPTION, parameters: doctorSubmissionSchema, async execute(id: string, params: unknown, signal: AbortSignal | undefined, _update: unknown, ctx: HostContext): Promise<HostToolResult<unknown>> { if (!activation) throw new Error("太医署未激活"); const testimony = validateDoctorOutput(params, activation.patient, activation.store); try { appendCandidate(ctx, { version: 1, testimony, readRecord: activation.store.readRecord(), patientIdentity: activation.patient.identity }); } 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 = details; return disposeComplianceDecision<HostToolResult<unknown>>(audit, { pass: (usage) => ({ content: [{ type: "text" as const, text: DOCTOR_ACCEPTED_TEXT }], details: acceptedDetails, terminate: true as const, ...(usage === undefined ? {} : { usage }) }), noReceipt: (auditNoReceipt, usageProjection) => ({ content: [{ type: "text" as const, text: DOCTOR_ACCEPTED_AUDIT_NO_RECEIPT_TEXT }], details: { ...acceptedDetails, auditNoReceipt }, terminate: true as const, ...usageProjection }), received: (auditReceived, usageProjection) => ({ content: [{ type: "text" as const, text: DOCTOR_ACCEPTED_TEXT }], details: { ...acceptedDetails, audit: auditReceived.reply }, terminate: true as const, ...usageProjection }), bounce: (violations) => { throw new Error(`太医署回执违 soul:${violations.join("; ")}`); }, escalate: (result) => result }, acceptedDetails); } });
39
+ pi.registerTool({ name: DOCTOR_OUTPUT_TOOL_NAME, label: "太医署输出", description: DOCTOR_OUTPUT_TOOL_DESCRIPTION, parameters: doctorSubmissionSchema, async execute(id: string, params: unknown, signal: AbortSignal | undefined, _update: unknown, ctx: HostContext): Promise<HostToolResult<unknown>> { if (!activation) throw new Error("太医署未激活"); const testimony = validateDoctorOutput(params, activation.patient, activation.store); try { appendCandidate(ctx, { version: 1, testimony, readRecord: activation.store.readRecord(), patientIdentity: activation.patient.identity }); } 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 = details; return disposeComplianceDecision<HostToolResult<unknown>>(audit, { pass: (usage) => ({ content: [{ type: "text" as const, text: DOCTOR_ACCEPTED_TEXT }], details: acceptedDetails, terminate: true as const, ...(usage === undefined ? {} : { usage }) }), noReceipt: (auditNoReceipt, usageProjection) => ({ content: [{ type: "text" as const, text: DOCTOR_ACCEPTED_AUDIT_NO_RECEIPT_TEXT }], details: { ...acceptedDetails, auditNoReceipt }, terminate: true as const, ...usageProjection }), received: (auditReceived, usageProjection) => ({ content: [{ type: "text" as const, text: DOCTOR_ACCEPTED_TEXT }], details: { ...acceptedDetails, audit: auditReceived.reply }, terminate: true as const, ...usageProjection }), bounce: (violations) => { throw new Error(`太医署回执违 soul:${joinReadableGateItems(violations)}`); }, escalate: (result) => result }, acceptedDetails); } });
39
40
  pi.on("before_agent_start", (event) => { if (!activation) throw new Error("太医署未激活"); const catalog = { version: activation.patient.version, identity: activation.patient.identity, admittedMetrics: { provenance: "由留存 session 字节推导,封入受理回执。", 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>` }; }); }
40
41
  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"); } };
41
42
  }
@@ -5,9 +5,6 @@
5
5
  import { existsSync, statSync } from "node:fs";
6
6
  import { resolve } from "node:path";
7
7
 
8
- import { JUDGE_OUTPUT_TOOL_NAME as JUDGE_OUTPUT_TOOL } from "./package-contracts/judge-output.ts";
9
-
10
- export const JUDGE_OUTPUT_TOOL_NAME = JUDGE_OUTPUT_TOOL;
11
8
  export const AUDIT_RUN_DIR_ENV = "AK_ROLE_RUN_DIR" as const;
12
9
  export const DOCTOR_CANDIDATE_ENTRY_TYPE = "ak_doctor_audit_candidate" as const;
13
10
 
@@ -65,44 +62,8 @@ function isRecord(value: unknown): value is Record<string, unknown> {
65
62
  return typeof value === "object" && value !== null && !Array.isArray(value);
66
63
  }
67
64
 
68
- /**
69
- * Judge subjects must already be on the parent session books before audit starts:
70
- * assignment (user message) + candidate verdict (sole judge output tool call).
71
- */
72
65
  type AuditSubjectContext = { sessionManager: { getEntries?(): Iterable<unknown> } };
73
66
 
74
- export function readJudgeAuditSubjects(context: AuditSubjectContext): SubjectResolution {
75
- const entries = context.sessionManager.getEntries?.() ?? [];
76
- let hasAssignment = false;
77
- let hasCandidate = false;
78
- for (const entry of entries) {
79
- if (!isRecord(entry) || entry.type !== "message" || !isRecord(entry.message)) continue;
80
- const message = entry.message;
81
- if (message.role === "user") {
82
- const text = typeof message.content === "string"
83
- ? message.content
84
- : Array.isArray(message.content)
85
- ? message.content.map((part) => (isRecord(part) && part.type === "text" && typeof part.text === "string" ? part.text : "")).join("")
86
- : "";
87
- if (text.trim().length > 0) hasAssignment = true;
88
- }
89
- if (message.role === "assistant" && Array.isArray(message.content)) {
90
- for (const part of message.content) {
91
- if (isRecord(part) && part.type === "toolCall" && part.name === JUDGE_OUTPUT_TOOL_NAME && isRecord(part.arguments)) {
92
- hasCandidate = true;
93
- }
94
- }
95
- }
96
- }
97
- if (!hasAssignment) {
98
- return { status: "incomplete", observation: { kind: "missing-subject", subject: "assignment" } };
99
- }
100
- if (!hasCandidate) {
101
- return { status: "incomplete", observation: { kind: "missing-subject", subject: "candidate-verdict" } };
102
- }
103
- return { status: "ok" };
104
- }
105
-
106
67
  /**
107
68
  * Doctor candidate testimony must be recorded before audit.
108
69
  */
@@ -1,18 +1,28 @@
1
1
  /**
2
- * Shared submit-path envelope for 门下省 gates (ADR 0018 / #675).
3
- * Owns officer-pointer book + host abort/non-pass faces.
2
+ * Shared submit-path envelope for 门下省 gates (ADR 0018 / #675 / #753).
3
+ * Owns officer-pointer book + host abort/non-pass faces + review queue loop.
4
4
  * Role modules only project via projectGatekeeperRun / runGatekeeper — no book, no catch.
5
+ *
6
+ * Queue guarantee only (#753 / #756 / #750):
7
+ * parent submit → summon officer → read conclusion field
8
+ * pass → accept end
9
+ * bounce | escalate → raw officer receipt as tool result back to parent
10
+ * not three-state → resume officer with plain-language re-ask (no round cap)
11
+ * transport / no_receipt → present honestly
12
+ * Three pairs: countersign↔notary, judge↔auditor, worker↔inspector.
13
+ * Code does not judge content, map next-step for parent, or label unreadable/unusable.
5
14
  */
6
15
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
7
16
  import { bookDirectOfficerRunPointer } from "./archivist-record-entry.ts";
8
17
  import type { HostContext } from "./host-contracts.ts";
9
18
  import {
10
19
  GatekeeperDecisionError,
11
- GatekeeperEscalationError,
20
+ OFFICER_CONCLUSION_REASK,
12
21
  projectGatekeeperRun,
13
22
  type GatekeeperPassHostActions,
14
23
  type GatekeeperResult,
15
24
  type GatekeeperSubject,
25
+ type GateOfficer,
16
26
  type GateOfficerSummon,
17
27
  } from "./gatekeeper-role.ts";
18
28
  import type { PublicSummonResult } from "./public-role-summons.ts";
@@ -24,7 +34,7 @@ import { sessionFileFromPublicSummon } from "./session-assistant-usage.ts";
24
34
  */
25
35
  function bookDirectOfficerPointer(
26
36
  context: ExtensionContext | HostContext,
27
- officer: "inspector" | "notary",
37
+ officer: GateOfficer,
28
38
  result: GatekeeperResult,
29
39
  summoned: PublicSummonResult,
30
40
  ): void {
@@ -32,7 +42,7 @@ function bookDirectOfficerPointer(
32
42
  result.status !== "pass"
33
43
  && result.status !== "bounce"
34
44
  && result.status !== "escalate"
35
- && result.status !== "unreadable"
45
+ && result.status !== "needs_reask"
36
46
  ) {
37
47
  return;
38
48
  }
@@ -55,7 +65,7 @@ function bookDirectOfficerPointer(
55
65
 
56
66
  /**
57
67
  * Shared envelope: project gate, book officer pointer, map onto host actions.
58
- * unreadable = parent stands (ADR 0055); never mechanical NonPass reject.
68
+ * Review loop has no round cap (#753 no-round-cap).
59
69
  */
60
70
  export async function requireGatekeeperPass(options: {
61
71
  readonly context: ExtensionContext | HostContext;
@@ -66,44 +76,50 @@ export async function requireGatekeeperPass(options: {
66
76
  /** Lowest seam: same as runGatekeeper options.summonOfficer — offline tracers only. */
67
77
  readonly summonOfficer?: GateOfficerSummon;
68
78
  }): Promise<void> {
69
- const projected = await projectGatekeeperRun({
70
- context: options.context,
71
- subject: options.subject,
72
- ...(options.signal === undefined ? {} : { signal: options.signal }),
73
- ...(options.summonOfficer === undefined ? {} : { summonOfficer: options.summonOfficer }),
74
- });
75
- const gatekeeper = projected.result;
76
- // Envelope-owned pointer book. Failure is host infrastructure — single face.
77
- if (projected.summoned !== undefined) {
78
- try {
79
- bookDirectOfficerPointer(
80
- options.context,
81
- projected.officer,
82
- gatekeeper,
83
- projected.summoned,
84
- );
85
- } catch (error) {
79
+ let reask: string | undefined;
80
+ // No round cap — end only on pass, bounce/escalate-to-parent, or real failure.
81
+ for (;;) {
82
+ const projected = await projectGatekeeperRun({
83
+ context: options.context,
84
+ subject: options.subject,
85
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
86
+ ...(options.summonOfficer === undefined ? {} : { summonOfficer: options.summonOfficer }),
87
+ ...(reask === undefined ? {} : { reask }),
88
+ });
89
+ const gatekeeper = projected.result;
90
+ // Envelope-owned pointer book. Failure is host infrastructure — single face.
91
+ if (projected.summoned !== undefined) {
92
+ try {
93
+ bookDirectOfficerPointer(
94
+ options.context,
95
+ projected.officer,
96
+ gatekeeper,
97
+ projected.summoned,
98
+ );
99
+ } catch (error) {
100
+ options.hostActions.failInfrastructure(error, options.context, options.toolCallId);
101
+ }
102
+ }
103
+ if (gatekeeper.status === "pass") return;
104
+ if (gatekeeper.status === "needs_reask") {
105
+ // Resume the speaker with plain-language re-ask — never bounce the parent (#753).
106
+ reask = OFFICER_CONCLUSION_REASK;
107
+ continue;
108
+ }
109
+ if (gatekeeper.status === "transport_failure") {
110
+ const error = new Error(`交卷闸 transport_failure(${gatekeeper.stage}):${gatekeeper.reason}`) as Error & {
111
+ stage: typeof gatekeeper.stage;
112
+ reason: string;
113
+ submission?: unknown;
114
+ };
115
+ error.stage = gatekeeper.stage;
116
+ error.reason = gatekeeper.reason;
117
+ if (gatekeeper.submission !== undefined) error.submission = gatekeeper.submission;
86
118
  options.hostActions.failInfrastructure(error, options.context, options.toolCallId);
87
119
  }
120
+ // bounce | escalate | no_receipt: raw receipt (or lifecycle fact) back to parent.
121
+ // escalate is NOT thrown as parent next-step — parent reads the officer words (#753).
122
+ options.hostActions.bindSubmissionNonPass(options.toolCallId, gatekeeper);
123
+ throw new GatekeeperDecisionError(gatekeeper);
88
124
  }
89
- if (gatekeeper.status === "pass") return;
90
- // ADR 0055 / #675: shape-unreadable officer output must not mechanically reject parent.
91
- if (gatekeeper.status === "unreadable") return;
92
- if (gatekeeper.status === "transport_failure") {
93
- const error = new Error(`交卷闸 transport_failure(${gatekeeper.stage}):${gatekeeper.reason}`) as Error & {
94
- stage: typeof gatekeeper.stage;
95
- reason: string;
96
- submission?: unknown;
97
- };
98
- error.stage = gatekeeper.stage;
99
- error.reason = gatekeeper.reason;
100
- if (gatekeeper.submission !== undefined) error.submission = gatekeeper.submission;
101
- options.hostActions.failInfrastructure(error, options.context, options.toolCallId);
102
- }
103
- if (gatekeeper.status === "escalate") {
104
- throw new GatekeeperEscalationError(gatekeeper);
105
- }
106
- // bounce / no_receipt: typed non-pass — envelope owns execute→tool_result bridge.
107
- options.hostActions.bindSubmissionNonPass(options.toolCallId, gatekeeper);
108
- throw new GatekeeperDecisionError(gatekeeper);
109
125
  }
@@ -15,76 +15,80 @@ import {
15
15
  } from "./package-contracts/gatekeeper-output.ts";
16
16
  import type { PublicSummonResult } from "./public-role-summons.ts";
17
17
  import type { TerminalResult } from "./public-cli/terminal.ts";
18
- import { retainedShapeUnreadable } from "./shape-unreadable-failure.ts";
19
18
  export const INSPECTOR_OUTPUT_TOOL = INSPECTOR_OUTPUT_TOOL_NAME;
20
19
  export const NOTARY_OUTPUT_TOOL = "ak_notary_output";
21
20
 
21
+ /** Gate review officers — 察院 / 符宝郎 / 审刑院 (#753 / #756). */
22
+ export type GateOfficer = "inspector" | "notary" | "auditor";
23
+
22
24
  /** Officer routing only — content is self-fetched via the shared run-dossier tool (#632). */
23
25
  export type GatekeeperSubject =
24
26
  | { readonly kind: "worker_completion" }
25
27
  | { readonly kind: "judge_draft" }
28
+ | { readonly kind: "judge_compliance" }
26
29
  | { readonly kind: "countersign_verdict" };
27
30
 
31
+ /**
32
+ * Gate projection for the review queue (#753 / #750).
33
+ * Code only reads the conclusion field for queueing. Officer words ride as
34
+ * `receipt` unchanged — no findings rewrite, no unreadable/unusable label,
35
+ * no next-step selection for the parent.
36
+ */
28
37
  export type GatekeeperResult =
29
- /**
30
- * Lawful direct-officer release.
31
- */
32
- | { readonly status: "pass"; readonly officer: "inspector" | "notary"; readonly findings: readonly string[] }
33
- | {
34
- readonly status: "bounce";
35
- readonly officer: "inspector" | "notary";
36
- readonly disposition: "rewrite";
37
- readonly findings: readonly string[];
38
- readonly submission: unknown;
39
- }
38
+ | { readonly status: "pass"; readonly officer: GateOfficer; readonly receipt: unknown }
39
+ /** bounce | escalate: both return the officer receipt to the parent (#753 / #756). */
40
+ | { readonly status: "bounce"; readonly officer: GateOfficer; readonly receipt: unknown }
41
+ | { readonly status: "escalate"; readonly officer: GateOfficer; readonly receipt: unknown }
40
42
  | {
41
- readonly status: "escalate";
42
- readonly officer: "inspector" | "notary";
43
- readonly reason?: unknown;
44
- readonly findings: unknown;
45
- readonly submission: unknown;
46
- }
47
- | { readonly status: "no_receipt"; readonly stage: "inspector" | "notary"; readonly reason: string; readonly facts: NoReceiptLifecycleFacts }
48
- | {
49
- /** Shape-unreadable officer output — typed fact, not a forged bounce (ADR 0055 / §0). */
50
- readonly status: "unreadable";
51
- readonly officer: "inspector" | "notary";
52
- readonly reason: string;
53
- readonly submission: unknown;
43
+ /**
44
+ * Accepted reply whose conclusion is not pass|bounce|escalate.
45
+ * Envelope resumes the officer with plain-language re-ask — never parent-stands,
46
+ * never forges bounce (#753 / unreadable-conclusion-resume-speaker).
47
+ */
48
+ readonly status: "needs_reask";
49
+ readonly officer: GateOfficer;
50
+ readonly receipt: unknown;
54
51
  }
52
+ | { readonly status: "no_receipt"; readonly stage: GateOfficer; readonly reason: string; readonly facts: NoReceiptLifecycleFacts }
55
53
  | {
56
54
  readonly status: "transport_failure";
57
- readonly stage: "inspector" | "notary";
55
+ readonly stage: GateOfficer;
58
56
  readonly reason: string;
59
- /** Original unusable submission retained for the failure channel. */
57
+ /** Original transport/process failure payload retained for the failure channel. */
60
58
  readonly submission?: unknown;
61
59
  };
62
60
 
61
+ /** Non-pass faces that bounce the parent session (correctable). */
63
62
  export type GatekeeperNonPassResult = Extract<
64
63
  GatekeeperResult,
65
- { status: "bounce" | "no_receipt" | "unreadable" }
64
+ { status: "bounce" | "escalate" | "no_receipt" }
66
65
  >;
67
66
 
68
- function gateSeatLabel(stage: "inspector" | "notary"): string {
69
- return stage === "inspector" ? "察院" : "符宝郎";
67
+ function gateSeatLabel(stage: GateOfficer): string {
68
+ if (stage === "inspector") return "察院";
69
+ if (stage === "auditor") return "审刑院";
70
+ return "符宝郎";
70
71
  }
71
72
 
72
- export { GatekeeperDecisionError } from "./submission-errors.ts";
73
-
74
- export class GatekeeperEscalationError extends Error {
75
- readonly gatekeeper: Extract<GatekeeperResult, { status: "escalate" }>;
76
- constructor(gatekeeper: Extract<GatekeeperResult, { status: "escalate" }>) {
77
- super(`门下省${gateSeatLabel(gatekeeper.officer)}上呈`);
78
- this.name = "GatekeeperEscalationError";
79
- this.gatekeeper = gatekeeper;
80
- }
73
+ /** Subject kind → review officer (#753 countersign/notary, #756 judge/auditor + worker/inspector). */
74
+ export function gateOfficerForSubject(subject: GatekeeperSubject): GateOfficer {
75
+ if (subject.kind === "worker_completion") return "inspector";
76
+ if (subject.kind === "judge_compliance") return "auditor";
77
+ return "notary";
81
78
  }
82
79
 
80
+ export { GatekeeperDecisionError } from "./submission-errors.ts";
81
+
83
82
  export type GateOfficerSummon = (
84
- officer: "inspector" | "notary",
83
+ officer: GateOfficer,
85
84
  sourceRunDirectory: string,
86
85
  /** Parent cancellation forwarded to the nested activation (#675). */
87
86
  signal?: AbortSignal,
87
+ /**
88
+ * Plain-language re-ask when the prior officer reply was not a three-state
89
+ * conclusion (#753 / #756). Hosted as same-ticket resume instruction.
90
+ */
91
+ reask?: string,
88
92
  ) => Promise<PublicSummonResult>;
89
93
 
90
94
  export type RunGatekeeperOptions = {
@@ -93,6 +97,10 @@ export type RunGatekeeperOptions = {
93
97
  readonly signal?: AbortSignal;
94
98
  /** Run directory of the parent role (pointer-only summons, ADR 0079). */
95
99
  readonly runDirectory?: string;
100
+ /**
101
+ * Plain-language re-ask for this summon (resume speaker after non-three-state).
102
+ */
103
+ readonly reask?: string;
96
104
  /**
97
105
  * Test seam for public-role summons. Production calls the shared public
98
106
  * activation path (#675); inject only in offline tracers.
@@ -144,13 +152,6 @@ function failureReason(error: unknown): string {
144
152
  return error instanceof Error ? `${error.name}: ${error.message}` : String(error);
145
153
  }
146
154
 
147
- function asStringArray(value: unknown): readonly string[] {
148
- if (!Array.isArray(value)) return [];
149
- // Officer findings may be structured objects (category/law/evidence); the
150
- // parent role must see them verbatim, not an empty list (#750 evidence).
151
- return value.map((item) => (typeof item === "string" ? item : JSON.stringify(item)));
152
- }
153
-
154
155
  /** Serializable stand-in when the child tool call had no arguments object. */
155
156
  export const MISSING_ARGUMENTS_SUBMISSION = Object.freeze({ missing: "arguments" as const });
156
157
 
@@ -159,7 +160,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
159
160
  }
160
161
 
161
162
  /** Keep original decision bytes for the next reader; undefined becomes a serializable missing-args fact. */
162
- function retainedSubmission(decision: unknown): unknown {
163
+ function retainedReceipt(decision: unknown): unknown {
163
164
  // undefined must not be stored: JSON drops it and the missing-args fact vanishes.
164
165
  // Through the real provider adapter an undefined root argument arrives as an
165
166
  // empty object after serialization; that must also project a missing-args fact.
@@ -168,66 +169,44 @@ function retainedSubmission(decision: unknown): unknown {
168
169
  : decision;
169
170
  }
170
171
 
171
- /**
172
- * Shape-unreadable officer decision — retain original candidate + typed reason.
173
- * Not a forged bounce, not transport abort (CLAUDE.md §0 / ADR 0055).
174
- * Real provider/engine/disk failures stay transport_failure elsewhere.
175
- */
176
- function shapeUnreadable(
177
- officer: "inspector" | "notary",
178
- decision: unknown,
179
- reason = "decision 无显式 pass/bounce/escalate",
180
- ): Extract<GatekeeperResult, { status: "unreadable" }> {
181
- return {
182
- status: "unreadable",
183
- officer,
184
- reason,
185
- submission: retainedSubmission(decision),
186
- };
187
- }
188
-
189
172
  function readRecord(value: unknown): Record<string, unknown> | undefined {
190
173
  if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
191
174
  return value as Record<string, unknown>;
192
175
  }
193
176
 
177
+ /**
178
+ * Read only the conclusion field for queueing (#753).
179
+ * pass | bounce | escalate → queue signal + raw receipt.
180
+ * Anything else accepted → needs_reask (resume speaker), never unreadable/parent-stand.
181
+ * `fallbackStatus` is the terminal outcome.status when the receipt body has no status key
182
+ * (keeps missing-args sentinel intact as the receipt).
183
+ */
194
184
  function projectOfficerDecision(
195
- officer: "inspector" | "notary",
185
+ officer: GateOfficer,
196
186
  decision: unknown,
187
+ fallbackStatus?: string,
197
188
  ): GatekeeperResult {
189
+ const receipt = retainedReceipt(decision);
198
190
  const record = readRecord(decision);
199
- if (record === undefined) return shapeUnreadable(officer, decision);
200
- if (record.status === "bounce") {
201
- return {
202
- status: "bounce",
203
- officer,
204
- disposition: "rewrite",
205
- findings: asStringArray(record.findings),
206
- submission: retainedSubmission(decision),
207
- };
208
- }
209
- if (record.status === "pass") {
210
- return {
211
- status: "pass",
212
- officer,
213
- findings: asStringArray(record.findings),
214
- };
191
+ const status =
192
+ (record !== undefined && typeof record.status === "string" ? record.status : undefined)
193
+ ?? fallbackStatus;
194
+ if (status === "pass") {
195
+ return { status: "pass", officer, receipt };
215
196
  }
216
- if (record.status === "escalate") {
217
- return {
218
- status: "escalate",
219
- officer,
220
- ...(Object.hasOwn(record, "reason") ? { reason: record.reason } : {}),
221
- findings: record.findings,
222
- submission: retainedSubmission(decision),
223
- };
197
+ if (status === "bounce" || status === "escalate") {
198
+ return { status, officer, receipt };
224
199
  }
225
- return shapeUnreadable(officer, decision);
200
+ return { status: "needs_reask", officer, receipt };
226
201
  }
227
202
 
228
- /** Map a public-role terminal onto the gate officer result surface. */
203
+ /**
204
+ * Project a public-role terminal onto the gate queue surface.
205
+ * Lifecycle facts (no_receipt / transport) stay loud; conclusion reads only
206
+ * the status field. No unusable/unreadable judgment (#753).
207
+ */
229
208
  function projectOfficerTerminal(
230
- officer: "inspector" | "notary",
209
+ officer: GateOfficer,
231
210
  summoned: PublicSummonResult,
232
211
  ): GatekeeperResult {
233
212
  const terminal: TerminalResult | undefined = summoned.terminal;
@@ -252,11 +231,7 @@ function projectOfficerTerminal(
252
231
  };
253
232
  }
254
233
  if (outcome.kind === "failure") {
255
- // Single settlement marker only (ADR 0055 / #675) — no cause=output re-derivation.
256
- const shape = retainedShapeUnreadable(outcome.decisiveFacts);
257
- if (shape !== undefined) {
258
- return shapeUnreadable(officer, shape.candidate, outcome.diagnostic);
259
- }
234
+ // Real provider/engine/disk failure — keep loud. Not a shape judgment.
260
235
  return {
261
236
  status: "transport_failure",
262
237
  stage: officer,
@@ -264,28 +239,47 @@ function projectOfficerTerminal(
264
239
  submission: outcome.decisiveFacts,
265
240
  };
266
241
  }
242
+ if (outcome.kind === "audit_escalation") {
243
+ // Residual/compliance escalate face: queue as escalate, receipt as written.
244
+ // Nested officer escalate itself seals accepted+status escalate (no rewrite).
245
+ return {
246
+ status: "escalate",
247
+ officer,
248
+ receipt: retainedReceipt(outcome.decisiveFacts),
249
+ };
250
+ }
267
251
  if (outcome.kind === "accepted") {
268
- return projectOfficerDecision(officer, {
269
- status: outcome.status,
270
- ...outcome.decisiveFacts,
271
- });
252
+ // Prefer the officer's own decisiveFacts as the receipt body. outcome.status is
253
+ // only a fallback when facts have no status key (missing-args sentinel stays intact).
254
+ const facts = outcome.decisiveFacts;
255
+ if (isRecord(facts) && Object.keys(facts).length > 0) {
256
+ return projectOfficerDecision(officer, facts, outcome.status);
257
+ }
258
+ return projectOfficerDecision(officer, { status: outcome.status });
272
259
  }
260
+ // Unknown terminal kind: still not a shape judgment — ask the speaker again.
273
261
  return {
274
- status: "transport_failure",
275
- stage: officer,
276
- reason: `${gateSeatLabel(officer)} public summon returned unusable terminal kind`,
277
- submission: outcome,
262
+ status: "needs_reask",
263
+ officer,
264
+ receipt: retainedReceipt(outcome),
278
265
  };
279
266
  }
280
267
 
281
268
  /** Projection carrier for the shared submit envelope (ADR 0018). No lifecycle book here. */
282
269
  export type GatekeeperProjection = {
283
- readonly officer: "inspector" | "notary";
270
+ readonly officer: GateOfficer;
284
271
  readonly result: GatekeeperResult;
285
272
  /** Present only when a public summon actually returned (not transport pre-summon failure). */
286
273
  readonly summoned?: PublicSummonResult;
287
274
  };
288
275
 
276
+ /**
277
+ * Plain-language re-ask when the officer conclusion is not pass|bounce|escalate.
278
+ * Not a packaged engine handbook line (#755 exception for 读不出三态).
279
+ */
280
+ export const OFFICER_CONCLUSION_REASK =
281
+ "上次交卷的结论不是 pass、bounce、escalate 三态之一。请重新输出,结论字段写明其一;打回或上呈的话就是给对方看的原文。" as const;
282
+
289
283
  /**
290
284
  * Summon + project only. Lifecycle book and host abort face live on the shared
291
285
  * submit envelope (`gatekeeper-pass-envelope.ts`, ADR 0018 / #675).
@@ -293,7 +287,7 @@ export type GatekeeperProjection = {
293
287
  export async function projectGatekeeperRun(
294
288
  options: RunGatekeeperOptions,
295
289
  ): Promise<GatekeeperProjection> {
296
- const officer = options.subject.kind === "worker_completion" ? "inspector" : "notary";
290
+ const officer = gateOfficerForSubject(options.subject);
297
291
  const runDirectory = options.runDirectory ?? auditorRunDirectory(options.context);
298
292
  if (runDirectory === undefined) {
299
293
  return {
@@ -307,21 +301,29 @@ export async function projectGatekeeperRun(
307
301
  }
308
302
  // Pointer-only summons need a resolvable leaf: Grok session.jsonl is header-only
309
303
  // (#617 DK-4); write the in-memory tool-call candidate as a run artifact first (#632).
310
- persistGateSubmissionCandidate(runDirectory, options.context);
304
+ // Candidate path also rides same-parent officer resume as 人读材料 (#753 / #750).
305
+ const submissionCandidatePath = persistGateSubmissionCandidate(
306
+ runDirectory,
307
+ options.context,
308
+ );
311
309
  let summoned: PublicSummonResult;
312
310
  try {
313
311
  const summon =
314
312
  options.summonOfficer
315
- ?? (async (nextOfficer, sourceRunDirectory, officerSignal) => {
313
+ ?? (async (nextOfficer, sourceRunDirectory, officerSignal, reask) => {
316
314
  const { summonGateOfficer } = await import("./public-role-summons.ts");
317
315
  return summonGateOfficer({
318
316
  officer: nextOfficer,
319
317
  sourceRunDirectory,
320
318
  cwd: options.context.cwd ?? process.cwd(),
321
319
  ...(officerSignal === undefined ? {} : { signal: officerSignal }),
320
+ ...(reask === undefined ? {} : { reask }),
321
+ ...(submissionCandidatePath === undefined
322
+ ? {}
323
+ : { submissionCandidatePath }),
322
324
  });
323
325
  });
324
- summoned = await summon(officer, runDirectory, options.signal);
326
+ summoned = await summon(officer, runDirectory, options.signal, options.reask);
325
327
  } catch (error) {
326
328
  return {
327
329
  officer,
@@ -299,10 +299,10 @@ type HostEventHandler<K extends keyof HostEventMap> = (event: HostEventMap[K], c
299
299
  export type HostEventRegistration = { [K in keyof HostEventMap]: [event: K, handler: HostEventHandler<K>] }[keyof HostEventMap];
300
300
 
301
301
  type HostGatekeeperSubject = {
302
- readonly kind: "worker_completion" | "judge_draft" | "countersign_verdict";
302
+ readonly kind: "worker_completion" | "judge_draft" | "judge_compliance" | "countersign_verdict";
303
303
  };
304
- /** Gatekeeper bounce/no_receipt plus other correct submission rejects share one projection map. */
305
- type HostGatekeeperNonPass = { readonly status: "bounce" | "no_receipt" | "unreadable" } & Record<string, unknown>;
304
+ /** Gatekeeper bounce/escalate/no_receipt plus other correct submission rejects share one projection map. */
305
+ type HostGatekeeperNonPass = { readonly status: "bounce" | "escalate" | "no_receipt" } & Record<string, unknown>;
306
306
  export type HostSubmissionNonPass =
307
307
  | HostGatekeeperNonPass
308
308
  | { readonly code: "coder_skill_expansion_evidence_missing" };