@akagilnc/pi-workflow-roles 0.1.1751

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 (192) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +104 -0
  3. package/README.zh-CN.md +133 -0
  4. package/THIRD_PARTY_NOTICES.md +60 -0
  5. package/dist/activation-ledger-git.js +68 -0
  6. package/dist/activation-ledger-session.js +120 -0
  7. package/dist/activation-ledger-topology.js +239 -0
  8. package/dist/activation-reconciliation.js +61 -0
  9. package/dist/audit-escalation.js +108 -0
  10. package/dist/auditor-dossier-tool.js +35 -0
  11. package/dist/canonical-json.js +78 -0
  12. package/dist/compliance-transport.js +77 -0
  13. package/dist/doctor-contracts.js +172 -0
  14. package/dist/dossier-resolution.js +103 -0
  15. package/dist/evidence-child-executor.js +661 -0
  16. package/dist/exact-utf8.js +12 -0
  17. package/dist/git-object-id.js +7 -0
  18. package/dist/in-process-session.js +50 -0
  19. package/dist/merger-contracts.js +76 -0
  20. package/dist/navigator-attendance.js +995 -0
  21. package/dist/navigator-invocation-identity.js +220 -0
  22. package/dist/open-tool-schema.js +39 -0
  23. package/dist/package-contracts/collector-output.js +50 -0
  24. package/dist/package-contracts/fixer-output.js +72 -0
  25. package/dist/package-contracts/fixer-packet.js +77 -0
  26. package/dist/package-contracts/judge-output.js +17 -0
  27. package/dist/package-contracts/reviewer-output.js +82 -0
  28. package/dist/package-contracts/terminating-tools.js +173 -0
  29. package/dist/package-contracts/worker-output.js +13 -0
  30. package/dist/package-owned-tool-idle.js +104 -0
  31. package/dist/packaged-role-registry.js +34 -0
  32. package/dist/public-cli/main.js +23867 -0
  33. package/dist/public-command-renderer.js +20 -0
  34. package/dist/reviewer-agent.js +93 -0
  35. package/dist/reviewer-child-executor.js +23 -0
  36. package/dist/reviewer-construction.js +95 -0
  37. package/dist/reviewer-dispatch.js +77 -0
  38. package/dist/reviewer-execution-ledger.js +160 -0
  39. package/dist/reviewer-failure-diagnostic.js +17 -0
  40. package/dist/reviewer-git-snapshot.js +38 -0
  41. package/dist/reviewer-pinned-git.js +146 -0
  42. package/dist/reviewer-preflight-error.js +15 -0
  43. package/dist/reviewer-prompt-identity.js +10 -0
  44. package/dist/reviewer-scope-prompt.js +21 -0
  45. package/dist/reviewer-workspace.js +151 -0
  46. package/dist/sha256.js +5 -0
  47. package/dist/sitian-record-entry.js +33 -0
  48. package/dist/stderr-jsonl.js +26 -0
  49. package/dist/stream-idle-guard.js +75 -0
  50. package/dist/tool-execution-observation.js +141 -0
  51. package/dist/uuidv7.js +21 -0
  52. package/dist/work-subject-identity.js +53 -0
  53. package/extensions/role-runtime.ts +303 -0
  54. package/package.json +69 -0
  55. package/packets/fixer-prerequisites.json +6 -0
  56. package/packets/fixer-repair.md +5 -0
  57. package/packets/judge-apply.md +77 -0
  58. package/packets/judge-authority.md +64 -0
  59. package/packets/judge-plan.md +55 -0
  60. package/packets/judge-review.md +49 -0
  61. package/packets/judge-submission.md +34 -0
  62. package/resources/methods/code-review/SKILL.md +92 -0
  63. package/resources/methods/code-review/agents/openai.yaml +3 -0
  64. package/resources/methods/code-review/provenance.json +26 -0
  65. package/resources/methods/diagnosing-bugs/SKILL.md +134 -0
  66. package/resources/methods/diagnosing-bugs/agents/openai.yaml +3 -0
  67. package/resources/methods/diagnosing-bugs/provenance.json +31 -0
  68. package/resources/methods/diagnosing-bugs/scripts/hitl-loop.template.sh +41 -0
  69. package/resources/methods/resolving-merge-conflicts/SKILL.md +14 -0
  70. package/resources/methods/resolving-merge-conflicts/agents/openai.yaml +3 -0
  71. package/resources/methods/resolving-merge-conflicts/provenance.json +26 -0
  72. package/resources/methods/tdd/SKILL.md +38 -0
  73. package/resources/methods/tdd/agents/openai.yaml +3 -0
  74. package/resources/methods/tdd/mocking.md +59 -0
  75. package/resources/methods/tdd/provenance.json +36 -0
  76. package/resources/methods/tdd/tests.md +77 -0
  77. package/resources/navigator-route-playbook.md +32 -0
  78. package/schemas/tool-execution-observation.schema.json +107 -0
  79. package/scripts/build-package.mjs +65 -0
  80. package/scripts/generate-tool-execution-observation-schema.ts +7 -0
  81. package/souls/coder.md +10 -0
  82. package/souls/collector.md +11 -0
  83. package/souls/doctor-auditor.md +23 -0
  84. package/souls/doctor.md +8 -0
  85. package/souls/fixer-auditor.md +33 -0
  86. package/souls/fixer.md +13 -0
  87. package/souls/judge-auditor.md +33 -0
  88. package/souls/judge.md +74 -0
  89. package/souls/merger.md +5 -0
  90. package/souls/navigator.md +5 -0
  91. package/souls/reviewer-auditor.md +25 -0
  92. package/souls/reviewer.md +11 -0
  93. package/src/activation-ledger-git.ts +96 -0
  94. package/src/activation-ledger-session.ts +188 -0
  95. package/src/activation-ledger-topology.ts +301 -0
  96. package/src/activation-ledger.ts +240 -0
  97. package/src/activation-reconciliation.ts +163 -0
  98. package/src/activation-trace.ts +38 -0
  99. package/src/audit-escalation.ts +177 -0
  100. package/src/auditor-dossier-tool.ts +48 -0
  101. package/src/auditor-soul.ts +28 -0
  102. package/src/canonical-json.ts +74 -0
  103. package/src/canonical-skill-binding.ts +107 -0
  104. package/src/collector-config.ts +89 -0
  105. package/src/collector-evidence.ts +461 -0
  106. package/src/collector-github.ts +656 -0
  107. package/src/collector-identity.ts +161 -0
  108. package/src/collector-ledger.ts +827 -0
  109. package/src/collector-receipt.ts +87 -0
  110. package/src/collector-role.ts +592 -0
  111. package/src/collector-tool-schemas.ts +19 -0
  112. package/src/compliance-transport.ts +130 -0
  113. package/src/doctor-auditor.ts +53 -0
  114. package/src/doctor-contracts.ts +166 -0
  115. package/src/doctor-evidence.ts +47 -0
  116. package/src/doctor-role.ts +18 -0
  117. package/src/dossier-resolution.ts +137 -0
  118. package/src/evidence-child-executor.ts +775 -0
  119. package/src/exact-utf8.ts +9 -0
  120. package/src/factory-board.ts +1822 -0
  121. package/src/git-object-id.ts +11 -0
  122. package/src/human-format.ts +65 -0
  123. package/src/in-process-session.ts +78 -0
  124. package/src/judge-auditor.ts +55 -0
  125. package/src/judge-recording-anti-forge.ts +53 -0
  126. package/src/judge-role.ts +160 -0
  127. package/src/merger-contracts.ts +71 -0
  128. package/src/merger-git-state.ts +76 -0
  129. package/src/merger-role.ts +60 -0
  130. package/src/navigator-attendance.ts +1254 -0
  131. package/src/navigator-invocation-identity.ts +446 -0
  132. package/src/open-tool-schema.ts +46 -0
  133. package/src/package-contracts/collector-output.ts +109 -0
  134. package/src/package-contracts/fixer-output.ts +81 -0
  135. package/src/package-contracts/fixer-packet.ts +93 -0
  136. package/src/package-contracts/judge-output.ts +39 -0
  137. package/src/package-contracts/reviewer-output.ts +115 -0
  138. package/src/package-contracts/terminating-tools.ts +259 -0
  139. package/src/package-contracts/worker-output.ts +36 -0
  140. package/src/package-owned-tool-idle.ts +134 -0
  141. package/src/package-resources/method-skill-binding.ts +87 -0
  142. package/src/package-resources/method-skill.ts +358 -0
  143. package/src/packaged-role-registry.ts +36 -0
  144. package/src/public-cli/cli-errors.ts +11 -0
  145. package/src/public-cli/cli-io.ts +4 -0
  146. package/src/public-cli/cli.ts +912 -0
  147. package/src/public-cli/coder-run.ts +575 -0
  148. package/src/public-cli/collector-run.ts +375 -0
  149. package/src/public-cli/command-renderer.ts +8 -0
  150. package/src/public-cli/config.ts +346 -0
  151. package/src/public-cli/doctor-run.ts +355 -0
  152. package/src/public-cli/explicit-internal.ts +274 -0
  153. package/src/public-cli/fixer-run.ts +587 -0
  154. package/src/public-cli/host-pi-runtime.ts +112 -0
  155. package/src/public-cli/invocation.ts +1958 -0
  156. package/src/public-cli/judge-run.ts +507 -0
  157. package/src/public-cli/main.ts +15 -0
  158. package/src/public-cli/merger-run.ts +681 -0
  159. package/src/public-cli/public-run-credentials.ts +71 -0
  160. package/src/public-cli/registry.ts +153 -0
  161. package/src/public-cli/reviewer-run.ts +561 -0
  162. package/src/public-cli/run-lifecycle.ts +884 -0
  163. package/src/public-cli/settlement.ts +3765 -0
  164. package/src/public-cli/terminal.ts +325 -0
  165. package/src/public-command-renderer.ts +43 -0
  166. package/src/reviewer-agent.ts +94 -0
  167. package/src/reviewer-auditor.ts +53 -0
  168. package/src/reviewer-child-executor.ts +31 -0
  169. package/src/reviewer-construction.ts +137 -0
  170. package/src/reviewer-dispatch.ts +94 -0
  171. package/src/reviewer-execution-ledger.ts +206 -0
  172. package/src/reviewer-failure-diagnostic.ts +18 -0
  173. package/src/reviewer-git-snapshot.ts +53 -0
  174. package/src/reviewer-pinned-git.ts +144 -0
  175. package/src/reviewer-preflight-error.ts +14 -0
  176. package/src/reviewer-prompt-identity.ts +17 -0
  177. package/src/reviewer-role.ts +193 -0
  178. package/src/reviewer-scope-prompt.ts +24 -0
  179. package/src/reviewer-settlement.ts +63 -0
  180. package/src/reviewer-workspace.ts +111 -0
  181. package/src/role-runtime.ts +884 -0
  182. package/src/sha256.ts +6 -0
  183. package/src/sitian-record-entry.ts +57 -0
  184. package/src/stderr-jsonl.ts +28 -0
  185. package/src/stream-idle-guard.ts +98 -0
  186. package/src/ticket-snapshot.ts +662 -0
  187. package/src/ticket-trajectory.ts +1000 -0
  188. package/src/tool-execution-observation.ts +168 -0
  189. package/src/uuidv7.ts +1 -0
  190. package/src/work-subject-identity.ts +94 -0
  191. package/src/worker-role.ts +434 -0
  192. package/src/worker-submission-gates.ts +225 -0
@@ -0,0 +1,130 @@
1
+ import type { Api, AssistantMessage, Model, Usage } from "@earendil-works/pi-ai";
2
+ import type { AgentToolResult, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { Type } from "typebox";
4
+ import {
5
+ executeAuditorChild,
6
+ type AuditorCompletion,
7
+ } from "./evidence-child-executor.ts";
8
+ import { createAuditorDossierTool } from "./auditor-dossier-tool.ts";
9
+ import type { DossierObservation } from "./dossier-resolution.ts";
10
+
11
+ export type ComplianceCompletion = AuditorCompletion;
12
+ export type ComplianceArgumentRootType = "null" | "array" | "undefined" | "string" | "number" | "boolean" | "bigint" | "symbol" | "function";
13
+ export type ComplianceAuditObservation =
14
+ | { kind: "non-object-arguments"; type: ComplianceArgumentRootType }
15
+ | { kind: "object-status-unreadable"; status: "missing" | "unknown" }
16
+ | DossierObservation;
17
+ export type ComplianceAuditIncomplete = { status: "audit-incomplete"; observation: ComplianceAuditObservation; candidate: unknown; usage?: Usage };
18
+ export type ComplianceDecision = { status: "pass"; usage?: Usage } | { status: "revise"; violations: readonly unknown[]; usage?: Usage } | { status: "escalate"; conflicts?: unknown; decisionGate?: unknown; usage?: Usage } | ComplianceAuditIncomplete;
19
+ export type ComplianceDispatch = { model: Model<Api>; auth: { apiKey?: string; headers?: Record<string, string | null>; env?: Record<string, string> } };
20
+
21
+ /** Zero-projection kickoff — soul already carries dossier-fetch duty; no hand-delivered materials. */
22
+ export const AUDITOR_DOSSIER_PROMPT = "Audit the current run dossier." as const;
23
+
24
+ const nonblank = Type.String({ minLength: 1, pattern: "\\S" });
25
+ const decisionGateSchema = Type.Object({ question: nonblank, options: Type.Array(nonblank, { minItems: 1 }) }, { additionalProperties: false });
26
+ // Transport must retain malformed candidates so they can settle as typed
27
+ // audit-incomplete outcomes; status values are guidance, not a schema gate.
28
+ 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: [] });
29
+
30
+ export function createComplianceDecisionTool(name: string, description: string) {
31
+ return { name, description, parameters: complianceDecisionSchema, async execute(_id: string, params: unknown): Promise<AgentToolResult<unknown>> { return { content: [{ type: "text", text: "Compliance decision received" }], details: params, terminate: true }; } };
32
+ }
33
+
34
+ export async function prepareComplianceDispatch(model: Model<Api>, context: ExtensionContext, label: string): Promise<ComplianceDispatch> {
35
+ const resolution = await context.modelRegistry.getProviderAuth(model.provider).catch((error: unknown) => { throw new Error(`${label} authentication failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error }); });
36
+ if (resolution === undefined) throw new Error(`${label} authentication failed: provider is not configured: ${model.provider}`);
37
+ const auth = await context.modelRegistry.getApiKeyAndHeaders(model);
38
+ if (!auth.ok) throw new Error(`${label} authentication failed: ${auth.error}`);
39
+ const env = auth.env ?? resolution.env;
40
+ return { model: resolution.auth.baseUrl ? { ...model, baseUrl: resolution.auth.baseUrl } : model, auth: { ...(auth.apiKey === undefined ? {} : { apiKey: auth.apiKey }), ...(auth.headers === undefined ? {} : { headers: auth.headers }), ...(env === undefined ? {} : { env }) } };
41
+ }
42
+
43
+ export const COMPLIANCE_RESPONSE_ENTRY_TYPE = "ak_compliance_response" as const;
44
+ export const AUDITOR_PARENT_ATTEMPT_BINDING_ENTRY_TYPE = "ak_auditor_parent_attempt_binding" as const;
45
+ export const AUDITOR_COMPLIANCE_FAILURE_ENTRY_TYPE = "ak_auditor_compliance_failure" as const;
46
+
47
+ export type AuditorParentAttemptBinding = {
48
+ readonly version: 1;
49
+ readonly parent: {
50
+ readonly sessionId?: string;
51
+ readonly sessionFile?: string;
52
+ readonly attemptEntryId?: string;
53
+ };
54
+ };
55
+ export class ComplianceResponseRetentionError extends Error {
56
+ constructor(message: string, options?: ErrorOptions) { super(message, options); this.name = "ComplianceResponseRetentionError"; }
57
+ }
58
+ export type ActiveSessionResponseAppender = { appendCustomEntry(customType: string, data?: unknown): string };
59
+ /** Unique owner for session custom-entry append with availability check and typed failure. */
60
+ export function appendActiveSessionCustomEntry(
61
+ context: ExtensionContext,
62
+ customType: string,
63
+ data?: unknown,
64
+ labels: { unavailable?: string; failed?: string } = {},
65
+ ): string {
66
+ const manager = context.sessionManager as unknown as Partial<ActiveSessionResponseAppender> | undefined;
67
+ if (typeof manager?.appendCustomEntry !== "function") {
68
+ throw new ComplianceResponseRetentionError(
69
+ labels.unavailable ?? "session custom entry append is unavailable",
70
+ );
71
+ }
72
+ try {
73
+ return manager.appendCustomEntry(customType, data);
74
+ } catch (error) {
75
+ throw new ComplianceResponseRetentionError(
76
+ labels.failed ?? "session custom entry append failed",
77
+ { cause: error },
78
+ );
79
+ }
80
+ }
81
+ function retainComplianceResponse(context: ExtensionContext, response: AssistantMessage): void {
82
+ appendActiveSessionCustomEntry(
83
+ context,
84
+ COMPLIANCE_RESPONSE_ENTRY_TYPE,
85
+ { version: 1, response },
86
+ {
87
+ unavailable: "compliance response retention is unavailable",
88
+ failed: "compliance response retention failed",
89
+ },
90
+ );
91
+ }
92
+ function readListField(value: unknown): readonly unknown[] { return Array.isArray(value) ? value : value === undefined ? [] : [value]; }
93
+ export function readComplianceCandidate(arguments_: unknown, usage?: Usage): ComplianceDecision {
94
+ 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 }) };
95
+ const args = arguments_ as Record<string, unknown>; const status = args.status;
96
+ if (status === "pass") return { status, ...(usage === undefined ? {} : { usage }) };
97
+ if (status === "revise") return { status, violations: readListField(args.violations), ...(usage === undefined ? {} : { usage }) };
98
+ if (status === "escalate") return { status, ...(Object.hasOwn(args, "conflicts") ? { conflicts: args.conflicts } : {}), ...(Object.hasOwn(args, "decisionGate") ? { decisionGate: args.decisionGate } : {}), ...(usage === undefined ? {} : { usage }) };
99
+ return { status: "audit-incomplete", observation: { kind: "object-status-unreadable", status: status === undefined ? "missing" : "unknown" }, candidate: arguments_, ...(usage === undefined ? {} : { usage }) };
100
+ }
101
+
102
+ export type RunComplianceAuditOptions = {
103
+ tool: ReturnType<typeof createComplianceDecisionTool>;
104
+ systemPrompt: string;
105
+ /** @deprecated Fixer-lane hand-delivery only (#242 retires). Prefer omitting for zero-projection auditors. */
106
+ serializedInput?: string;
107
+ roleLabel: string;
108
+ invalidDecisionLabel: string;
109
+ runCompletion?: ComplianceCompletion;
110
+ context: ExtensionContext;
111
+ /** Exact machine-owned run binding; never sourced from AK_ROLE_RUN_DIR. */
112
+ runDirectory?: string | undefined;
113
+ signal?: AbortSignal;
114
+ };
115
+
116
+ export async function runComplianceAudit(options: RunComplianceAuditOptions): Promise<ComplianceDecision> {
117
+ const prompt = options.serializedInput ?? AUDITOR_DOSSIER_PROMPT;
118
+ const receipt = await executeAuditorChild({
119
+ tool: options.tool,
120
+ dossierTool: createAuditorDossierTool(options.runDirectory),
121
+ systemPrompt: options.systemPrompt,
122
+ prompt,
123
+ roleLabel: options.roleLabel,
124
+ context: options.context,
125
+ retainResponse: (response) => retainComplianceResponse(options.context, response),
126
+ ...(options.runCompletion === undefined ? {} : { runCompletion: options.runCompletion }),
127
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
128
+ });
129
+ return readComplianceCandidate(receipt.decision, receipt.response.usage);
130
+ }
@@ -0,0 +1,53 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ import { auditorRunDirectory } from "./auditor-dossier-tool.ts";
4
+ import { loadAuditorSoul } from "./auditor-soul.ts";
5
+ import {
6
+ createComplianceDecisionTool,
7
+ runComplianceAudit,
8
+ type ComplianceCompletion,
9
+ type ComplianceDecision,
10
+ } from "./compliance-transport.ts";
11
+ import {
12
+ readDoctorAuditSubjects,
13
+ resolveAuditDossier,
14
+ toAuditIncomplete,
15
+ } from "./dossier-resolution.ts";
16
+
17
+ export const DOCTOR_AUDIT_TOOL_NAME = "ak_doctor_audit_decision";
18
+
19
+ export type DoctorAuditOptions = {
20
+ context: ExtensionContext;
21
+ signal?: AbortSignal;
22
+ };
23
+
24
+ const tool = createComplianceDecisionTool(
25
+ DOCTOR_AUDIT_TOOL_NAME,
26
+ "Return whether the proposed Doctor testimony demonstrably follows the Doctor Soul and frozen evidence record from the dossier. Completed receipts are later augmented with runtime-owned cost; empty findings are valid.",
27
+ );
28
+
29
+ /**
30
+ * Doctor auditor: zero hand-delivered materials.
31
+ * Candidate testimony must already be on the parent-session books.
32
+ */
33
+ export function createPiDoctorAuditor(
34
+ runCompletion?: ComplianceCompletion,
35
+ ): (options: DoctorAuditOptions) => Promise<ComplianceDecision> {
36
+ return async (options) => {
37
+ const dossier = resolveAuditDossier();
38
+ if (dossier.status === "incomplete") return toAuditIncomplete(dossier.observation);
39
+ const subjects = readDoctorAuditSubjects(options.context);
40
+ if (subjects.status === "incomplete") return toAuditIncomplete(subjects.observation);
41
+
42
+ return runComplianceAudit({
43
+ tool,
44
+ systemPrompt: await loadAuditorSoul("doctor"),
45
+ roleLabel: "Doctor Soul compliance audit",
46
+ invalidDecisionLabel: "invalid Doctor audit decision",
47
+ context: options.context,
48
+ ...(auditorRunDirectory(options.context) === undefined ? {} : { runDirectory: auditorRunDirectory(options.context) }),
49
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
50
+ ...(runCompletion === undefined ? {} : { runCompletion }),
51
+ });
52
+ };
53
+ }
@@ -0,0 +1,166 @@
1
+ import { Type } from "typebox";
2
+ import { canonicalJson } from "./canonical-json.ts";
3
+ import { openToolObjectFromUnion } from "./open-tool-schema.ts";
4
+
5
+ export const DOCTOR_EVIDENCE_TOOL_NAME = "ak_doctor_evidence";
6
+ export const DOCTOR_OUTPUT_TOOL_NAME = "ak_doctor_output";
7
+ export const DOCTOR_OUTPUT_TOOL_DESCRIPTION = "Submit the sole final typed single-case testimony. Use completed when findings is empty or contains only non-prescriptive case observations. The runtime adds its derived case cost to the accepted receipt. Refuse only when the evidence cannot support even truthful case testimony; unavailable reusable-asset or bounded-bite evidence blocks only the corresponding asset prescription.";
8
+ export const DOCTOR_TARGET_KINDS = ["law", "gate", "template", "station", "seat"] as const;
9
+ export type DoctorTargetKind = typeof DOCTOR_TARGET_KINDS[number];
10
+ export type DoctorCaseIdentity = { issueNumber: number; runsPath: string };
11
+ export type DoctorSessionCost =
12
+ | { source: string; startedAt: string; endedAt: string; wallMilliseconds: number; completion: "accepted" }
13
+ | { source: string; startedAt?: string; endedAt?: string; wallMilliseconds?: number; completion: "incomplete"; degradationReason?: string };
14
+ export type DoctorCount = { count: number; sources: string[] };
15
+ export type DoctorCaseCost = {
16
+ invocations: DoctorCount; legs: DoctorCount; modelApiTurns: DoctorCount; outputTokens: DoctorCount; toolCalls: DoctorCount;
17
+ retries: DoctorCount & { evidence: "literal run-dir naming" };
18
+ statuses: Array<{ source: string; status: string }>;
19
+ commits: Array<{ source: string; commit: string }>;
20
+ sessions: DoctorSessionCost[];
21
+ outputBytes: DoctorCount & { payload: "raw JSONL bytes"; providerWireBytes: "unavailable" };
22
+ };
23
+ export type DoctorGuardrailAnswer = { answer: boolean; evidenceIds: string[]; explanation: string };
24
+ export type DoctorLastRealBite =
25
+ | { kind: "actual"; targetKey: string; evidenceId: string }
26
+ | { kind: "noRealBite"; targetKey: string; eligibleEvidenceIds: string[] };
27
+ type DoctorFindingBody = {
28
+ evidenceIds: string[]; disposition: "keep" | "thin" | "delete";
29
+ guardrails: { reproducibleFailure: DoctorGuardrailAnswer; owningSeamOrInvariant: DoctorGuardrailAnswer; deletionOrSimplificationSuffices: DoctorGuardrailAnswer };
30
+ prescription: { kind: "retain" | "delete" | "simplify" | "patch" | "addMechanism"; recommendation: string; necessityExplanation?: string };
31
+ lastRealBite: DoctorLastRealBite;
32
+ };
33
+ type DoctorAssetKind = DoctorTargetKind;
34
+ export type DoctorFinding =
35
+ | { targetKey: string; observation: string; evidenceIds: string[] }
36
+ | (DoctorFindingBody & { targetKey: string; targetKind: DoctorAssetKind; assetEvidence: { targetKey: string; targetKind: DoctorAssetKind; evidenceId: string } });
37
+ export type DoctorSubmission =
38
+ | { status: "completed"; case: DoctorCaseIdentity; findings: DoctorFinding[] }
39
+ | { status: "refused"; reason: string; missingEvidence: Array<{ need: string; targetKeys: string[] }> };
40
+ export type DoctorOutput =
41
+ | { status: "completed"; case: DoctorCaseIdentity; findings: DoctorFinding[]; cost: DoctorCaseCost }
42
+ | Extract<DoctorSubmission, { status: "refused" }>;
43
+ export type DoctorEvidenceEntry = { id: string; kind: "session" | "stderr"; byteLength: number; contentLength: number; sha256: string; content: string };
44
+ export type DoctorCase = { version: 1; identity: DoctorCaseIdentity; evidence: DoctorEvidenceEntry[]; cost: DoctorCaseCost };
45
+
46
+ const nonblank = Type.String({ minLength: 1, pattern: "\\S" });
47
+ const count = Type.Object({ count: Type.Integer({ minimum: 0 }), sources: Type.Array(nonblank) }, { additionalProperties: false });
48
+ const evidenceIds = Type.Array(nonblank, { minItems: 1 });
49
+ const guardrail = Type.Object({ answer: Type.Boolean(), evidenceIds, explanation: nonblank }, { additionalProperties: false });
50
+ const lastRealBite = Type.Union([
51
+ Type.Object({ kind: Type.Literal("actual"), targetKey: nonblank, evidenceId: nonblank }, { additionalProperties: false }),
52
+ Type.Object({ kind: Type.Literal("noRealBite"), targetKey: nonblank, eligibleEvidenceIds: evidenceIds }, { additionalProperties: false }),
53
+ ]);
54
+ const assetKinds = DOCTOR_TARGET_KINDS;
55
+ const findingBody = {
56
+ evidenceIds, disposition: Type.Union([Type.Literal("keep"), Type.Literal("thin"), Type.Literal("delete")]),
57
+ guardrails: Type.Object({ reproducibleFailure: guardrail, owningSeamOrInvariant: guardrail, deletionOrSimplificationSuffices: guardrail }, { additionalProperties: true }),
58
+ prescription: Type.Object({ kind: Type.Union([Type.Literal("retain"), Type.Literal("delete"), Type.Literal("simplify"), Type.Literal("patch"), Type.Literal("addMechanism")]), recommendation: nonblank, necessityExplanation: Type.Optional(nonblank) }, { additionalProperties: false }), lastRealBite,
59
+ };
60
+ const finding = Type.Union([
61
+ Type.Object({ targetKey: nonblank, observation: nonblank, evidenceIds }, { additionalProperties: false }),
62
+ Type.Object({ targetKey: nonblank, targetKind: Type.Union(assetKinds.map((kind) => Type.Literal(kind))), assetEvidence: Type.Object({ targetKey: nonblank, targetKind: Type.Union(assetKinds.map((kind) => Type.Literal(kind))), evidenceId: nonblank }, { additionalProperties: false }), ...findingBody }, { additionalProperties: false }),
63
+ ]);
64
+ const caseIdentity = Type.Object({ issueNumber: Type.Integer({ minimum: 1 }), runsPath: nonblank }, { additionalProperties: false });
65
+ const cost = Type.Object({
66
+ invocations: count, legs: count, modelApiTurns: count, outputTokens: count, toolCalls: count,
67
+ retries: Type.Object({ count: Type.Integer({ minimum: 0 }), sources: Type.Array(nonblank), evidence: Type.Literal("literal run-dir naming") }, { additionalProperties: false }),
68
+ statuses: Type.Array(Type.Object({ source: nonblank, status: nonblank }, { additionalProperties: false })),
69
+ commits: Type.Array(Type.Object({ source: nonblank, commit: nonblank }, { additionalProperties: false })),
70
+ sessions: Type.Array(Type.Union([
71
+ Type.Object({ source: nonblank, startedAt: nonblank, endedAt: nonblank, wallMilliseconds: Type.Number({ minimum: 0 }), completion: Type.Literal("accepted") }, { additionalProperties: false }),
72
+ Type.Object({ source: nonblank, startedAt: Type.Optional(nonblank), endedAt: Type.Optional(nonblank), wallMilliseconds: Type.Optional(Type.Number({ minimum: 0 })), completion: Type.Literal("incomplete"), degradationReason: Type.Optional(nonblank) }, { additionalProperties: false }),
73
+ ])),
74
+ outputBytes: Type.Object({ count: Type.Integer({ minimum: 0 }), sources: Type.Array(nonblank), payload: Type.Literal("raw JSONL bytes"), providerWireBytes: Type.Literal("unavailable") }, { additionalProperties: false }),
75
+ }, { additionalProperties: false });
76
+ const doctorSubmissionVariants = Type.Union([
77
+ Type.Object({
78
+ status: Type.Literal("completed", { description: "Truthful single-case testimony was completed; the runtime adds derived cost to the receipt." }),
79
+ case: Type.Unsafe({ ...caseIdentity, description: "Identity of the retained Doctor case." }),
80
+ findings: Type.Array(finding, { description: "May be empty or contain non-prescriptive case observations. Missing reusable-asset or bounded-bite evidence excludes only the corresponding asset prescription." }),
81
+ }, { additionalProperties: false, description: "Single-case testimony, without requiring any prescription or reusable finding." }),
82
+ Type.Object({
83
+ status: Type.Literal("refused", { description: "Reserved for inability to support truthful case testimony, not for an unavailable prescription axis." }),
84
+ reason: Type.String({ minLength: 1, description: "Reason evidence is insufficient for truthful testimony." }),
85
+ missingEvidence: Type.Array(Type.Object({ need: nonblank, targetKeys: Type.Array(nonblank, { minItems: 1 }) }, { additionalProperties: false }), { minItems: 1, description: "Evidence required before truthful testimony is possible." }),
86
+ }, { additionalProperties: false, description: "Evidence is insufficient for truthful case testimony." }),
87
+ ]);
88
+ export const doctorSubmissionSchema = openToolObjectFromUnion(doctorSubmissionVariants);
89
+ export const doctorOutputSchema = Type.Union([
90
+ Type.Object({ status: Type.Literal("completed"), case: caseIdentity, findings: Type.Array(finding), cost }, { additionalProperties: false }),
91
+ doctorSubmissionVariants.anyOf[1]!,
92
+ ]);
93
+ export const doctorEvidenceReadSchema = Type.Object({ evidenceId: Type.String({ minLength: 1, description: "Identifier of the retained evidence to read." }), offset: Type.Optional(Type.Integer({ minimum: 0, description: "Zero-based byte offset at which to begin reading." })), limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 4096, description: "Maximum number of bytes to return." })) }, { additionalProperties: false });
94
+ export class DoctorSubmissionContractError extends Error { override readonly name = "DoctorSubmissionContractError"; }
95
+ function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }
96
+ function read(value: unknown, key: string): unknown { if (!isRecord(value)) return undefined; try { return value[key]; } catch { return undefined; } }
97
+ export function validateDoctorSubmissionShape(value: unknown): DoctorSubmission {
98
+ const status = read(value, "status");
99
+ if (status !== "completed" && status !== "refused") throw new DoctorSubmissionContractError("Doctor submission has no recognized execution status");
100
+ return value as DoctorSubmission;
101
+ }
102
+ export function validateRecordedDoctorOutput(value: unknown): DoctorOutput {
103
+ const output = validateDoctorSubmissionShape(value);
104
+ if (read(output, "status") === "completed" && read(output, "cost") === undefined) throw new Error("Completed Doctor receipt has no runtime-owned cost testimony");
105
+ return output as DoctorOutput;
106
+ }
107
+
108
+ export class DoctorEvidenceStore {
109
+ readonly entries: Map<string, DoctorEvidenceEntry>; private readonly coverage = new Map<string, Array<[number, number]>>();
110
+ constructor(readonly patient: DoctorCase) { this.entries = new Map(patient.evidence.map((entry) => [entry.id, entry])); }
111
+ read(evidenceId: string, offset = 0, limit = 4096) { const entry = this.entries.get(evidenceId); if (!entry) throw new Error(`Evidence ID is not admitted: ${evidenceId}`); if (!Number.isInteger(offset) || offset < 0 || !Number.isInteger(limit) || limit < 1 || limit > 4096) throw new Error("Invalid evidence pagination"); if (offset > entry.contentLength) throw new Error("Evidence offset exceeds content"); const end = Math.min(entry.contentLength, offset + limit); const ranges = [...(this.coverage.get(evidenceId) ?? []), [offset, end] as [number, number]].sort((a, b) => a[0] - b[0]); const merged: Array<[number, number]> = []; for (const range of ranges) { const prior = merged.at(-1); if (prior && range[0] <= prior[1]) prior[1] = Math.max(prior[1], range[1]); else merged.push([...range]); } this.coverage.set(evidenceId, merged); return { evidenceId, kind: entry.kind, offset, content: entry.content.slice(offset, end), nextOffset: end < entry.contentLength ? end : null, contentLength: entry.contentLength, byteLength: entry.byteLength, sha256: entry.sha256 }; }
112
+ hasRead(id: string) { const entry = this.entries.get(id); const ranges = this.coverage.get(id); return !!entry && ranges?.length === 1 && ranges[0]![0] === 0 && ranges[0]![1] === entry.contentLength; }
113
+ readRecord() { return [...this.coverage.keys()].sort().map((evidenceId) => ({ evidenceId, fullyRead: this.hasRead(evidenceId) })); }
114
+ }
115
+ export function validateDoctorOutput(value: unknown, patient: DoctorCase, store: DoctorEvidenceStore): DoctorSubmission {
116
+ const output = validateDoctorSubmissionShape(value);
117
+ const lawfulTargets = new Set(["case", ...patient.cost.invocations.sources]);
118
+ const assertTarget = (targetKey: unknown) => { if (typeof targetKey === "string" && !lawfulTargets.has(targetKey)) throw new Error(`Target key is not a lawful case target: ${targetKey}`); };
119
+ const readCitations = (ids: unknown, label: string) => { if (!Array.isArray(ids)) return; for (const id of ids) if (typeof id === "string" && (!store.entries.has(id) || !store.hasRead(id))) throw new Error(`${label} must cite admitted/read evidence: ${id}`); };
120
+ if (read(output, "status") === "refused") {
121
+ const missingEvidence = read(output, "missingEvidence");
122
+ if (Array.isArray(missingEvidence)) for (const missing of missingEvidence) {
123
+ const targets = read(missing, "targetKeys");
124
+ if (Array.isArray(targets)) for (const target of targets) assertTarget(target);
125
+ }
126
+ return output;
127
+ }
128
+ const identity = read(output, "case");
129
+ const issueNumber = read(identity, "issueNumber");
130
+ const runsPath = read(identity, "runsPath");
131
+ if ((issueNumber !== undefined && issueNumber !== patient.identity.issueNumber) || (runsPath !== undefined && runsPath !== patient.identity.runsPath)) throw new Error("Doctor submission case must equal the activated case identity");
132
+ const findings = read(output, "findings");
133
+ if (!Array.isArray(findings)) return output;
134
+ for (const finding of findings) {
135
+ const targetKey = read(finding, "targetKey");
136
+ readCitations(read(finding, "evidenceIds"), "finding");
137
+ const assetEvidence = read(finding, "assetEvidence");
138
+ if (!isRecord(assetEvidence)) { assertTarget(targetKey); continue; }
139
+ const assetTargetKey = read(assetEvidence, "targetKey");
140
+ const assetTargetKind = read(assetEvidence, "targetKind");
141
+ const assetEvidenceId = read(assetEvidence, "evidenceId");
142
+ if (typeof assetTargetKey === "string" && assetTargetKey !== targetKey) throw new Error("Typed asset evidence must establish the finding target key");
143
+ if (typeof assetTargetKind === "string" && assetTargetKind !== read(finding, "targetKind")) throw new Error("Typed asset evidence must establish the finding target kind");
144
+ if (typeof assetEvidenceId === "string") readCitations([assetEvidenceId], "asset evidence");
145
+ const guardrails = read(finding, "guardrails");
146
+ for (const key of ["reproducibleFailure", "owningSeamOrInvariant", "deletionOrSimplificationSuffices"]) readCitations(read(read(guardrails, key), "evidenceIds"), "guardrail");
147
+ const bite = read(finding, "lastRealBite");
148
+ const biteKind = read(bite, "kind");
149
+ if (biteKind !== "actual" && biteKind !== "noRealBite") continue;
150
+ if (read(bite, "targetKey") !== targetKey) throw new Error("lastRealBite target mismatch");
151
+ if (biteKind === "actual") {
152
+ const evidenceId = read(bite, "evidenceId");
153
+ const entry = typeof evidenceId === "string" ? store.entries.get(evidenceId) : undefined;
154
+ if (!entry || entry.kind !== "session" || !store.hasRead(entry.id)) throw new Error("actual bite must cite an admitted/read retained session");
155
+ } else {
156
+ const eligible = patient.evidence.map((entry) => entry.id).sort();
157
+ const ids = read(bite, "eligibleEvidenceIds");
158
+ if (Array.isArray(ids)) {
159
+ const claimed = ids.filter((id): id is string => typeof id === "string").sort();
160
+ if (canonicalJson(claimed) !== canonicalJson(eligible)) throw new Error("noRealBite must prove the complete eligible single-case evidence population");
161
+ readCitations(eligible, "noRealBite");
162
+ }
163
+ }
164
+ }
165
+ return output;
166
+ }
@@ -0,0 +1,47 @@
1
+ import { readdir, readFile, realpath, stat } from "node:fs/promises";
2
+ import { dirname, relative, resolve, sep } from "node:path";
3
+ import { sha256Hex } from "./sha256.ts";
4
+ import type { DoctorCase, DoctorCaseCost, DoctorCount, DoctorEvidenceEntry } from "./doctor-contracts.ts";
5
+ import { AcceptedDetailsContractError, acceptedFacts, isTerminatingToolName, validateAcceptedDetails } from "./package-contracts/terminating-tools.ts";
6
+
7
+ function record(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }
8
+ async function discoverCaseFiles(root: string): Promise<string[]> { const found: string[] = []; async function walk(dir: string, depth: number) { for (const item of await readdir(dir, { withFileTypes: true })) { const path = resolve(dir, item.name); if (item.isDirectory()) await walk(path, depth + 1); else if (item.isFile() && (item.name.endsWith(".jsonl") || (item.name === "stderr.log" && depth === 1))) found.push(path); } } await walk(root, 0); return found.sort(); }
9
+ function sourceList(count: number, sources: string[]) { return { count, sources: [...new Set(sources)].sort() }; }
10
+ function accumulate(metric: DoctorCount, value: number, source: string) { metric.count += value; if (value) metric.sources.push(source); }
11
+ function timestamp(row: Record<string, unknown>) { return typeof row.timestamp === "string" && Number.isFinite(Date.parse(row.timestamp)) ? row.timestamp : undefined; }
12
+ function isMissingPathError(error: unknown): boolean { return error instanceof Error && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR"); }
13
+ async function stableRunsIdentity(root: string): Promise<string> { let cursor = root; while (true) { try { const git = await stat(resolve(cursor, ".git")); if (git.isDirectory() || git.isFile()) return relative(cursor, root).split(sep).join("/"); } catch (error) { if (!isMissingPathError(error)) throw error; } const parent = dirname(cursor); if (parent === cursor) return root; cursor = parent; } }
14
+
15
+ type SessionDerivation = { session: DoctorCaseCost["sessions"][number]; turns: number; calls: number; tokens: number; statuses: DoctorCaseCost["statuses"]; commits: DoctorCaseCost["commits"] };
16
+ function deriveSession(content: string, id: string): SessionDerivation {
17
+ const rows: Record<string, unknown>[] = []; const degradationReasons: string[] = [];
18
+ for (const line of content.split("\n")) if (line.trim()) { try { const row: unknown = JSON.parse(line); if (!record(row)) { degradationReasons.push(`non-object session row in ${id}`); break; } rows.push(row); } catch (error) { if (error instanceof SyntaxError) { degradationReasons.push(`malformed JSON tail in ${id}: ${error.message}`); break; } throw error; } }
19
+ const started = rows.find((row) => row.type === "session"); const startedAt = started && timestamp(started); if (!startedAt) degradationReasons.push(`Pi session header is missing: ${id}`);
20
+ let accepted: Record<string, unknown> | undefined, observedCommit: string | undefined, turns = 0, calls = 0, tokens = 0;
21
+ const statuses: DoctorCaseCost["statuses"] = [], commits: DoctorCaseCost["commits"] = [];
22
+ for (const row of rows) {
23
+ const message = record(row.message) ? row.message : undefined;
24
+ if (message?.role === "assistant") { for (const part of Array.isArray(message.content) ? message.content : []) if (record(part) && part.type === "toolCall") calls++; if (typeof message.responseId === "string") { turns++; const usage = record(message.usage) ? message.usage : undefined; if (usage && typeof usage.output === "number") tokens += usage.output; } }
25
+ if (message?.role === "toolResult" && message.isError !== true && typeof message.toolName === "string" && isTerminatingToolName(message.toolName) && record(message.details)) { let details; try { details = validateAcceptedDetails(message.toolName, message.details); } catch (error) { // Contract: README.md#Doctor — non-accepted terminating receipts are expected-negative evidence and are skipped; all other validation failures propagate with their cause.
26
+ if (error instanceof AcceptedDetailsContractError) continue; throw error; } accepted = row; const facts = acceptedFacts(message.toolName, details); if (facts.commit && facts.commit !== observedCommit) { commits.push({ source: id, commit: facts.commit }); observedCommit = facts.commit; } statuses.length = 0; if (facts.status !== undefined) { statuses.push({ source: id, status: facts.status }); } else { statuses.push({ source: id, status: "terminating receipt has no receipt-level status" }); } }
27
+ }
28
+ const acceptedAt = accepted && timestamp(accepted); const final = acceptedAt ? accepted! : rows.at(-1); const endedAt = final && timestamp(final); const wall = startedAt && endedAt ? Date.parse(endedAt) - Date.parse(startedAt) : undefined;
29
+ if (wall !== undefined && wall < 0) degradationReasons.push(`non-monotonic session timestamps in ${id}`);
30
+ const degradationReason = degradationReasons.length ? degradationReasons.join("; ") : undefined;
31
+ const complete = !!acceptedAt && !degradationReason && wall !== undefined && wall >= 0;
32
+ const session = complete
33
+ ? { source: id, startedAt: startedAt!, endedAt: endedAt!, wallMilliseconds: wall!, completion: "accepted" as const }
34
+ : { source: id, ...(startedAt ? { startedAt } : {}), ...(endedAt ? { endedAt } : {}), ...(wall !== undefined && wall >= 0 ? { wallMilliseconds: wall } : {}), completion: "incomplete" as const, ...(degradationReason ? { degradationReason } : {}) };
35
+ return { session, turns, calls, tokens, statuses, commits };
36
+ }
37
+
38
+ /** Read Pi's retained session directory as the sole raw material for one case. */
39
+ export async function loadDoctorCase(runsPath: string): Promise<DoctorCase> {
40
+ const root = await realpath(runsPath); const match = root.split(sep).join("/").match(/\/\.ak-roles\/books\/[^/]+\/issues\/([1-9]\d*)\/runs$/); if (!match) throw new Error("Doctor case must be an .ak-roles/books/<book>/issues/<n>/runs directory");
41
+ const evidence: DoctorEvidenceEntry[] = [], sessions: DoctorCaseCost["sessions"] = [], statuses: DoctorCaseCost["statuses"] = [], commits: DoctorCaseCost["commits"] = [];
42
+ const turns: DoctorCount = { count: 0, sources: [] }, calls: DoctorCount = { count: 0, sources: [] }, tokens: DoctorCount = { count: 0, sources: [] };
43
+ for (const path of await discoverCaseFiles(root)) { const id = relative(root, path).split(sep).join("/"); const bytes = await readFile(path); const content = bytes.toString("utf8"); const kind = id.endsWith(".jsonl") ? "session" : "stderr"; evidence.push({ id, kind, byteLength: bytes.byteLength, contentLength: content.length, sha256: sha256Hex(bytes), content }); if (kind === "stderr") continue; const result = deriveSession(content, id); sessions.push(result.session); statuses.push(...result.statuses); commits.push(...result.commits); accumulate(turns, result.turns, id); accumulate(calls, result.calls, id); accumulate(tokens, result.tokens, id); }
44
+ const runDirs = (await readdir(root, { withFileTypes: true })).filter((item) => item.isDirectory()).map((item) => item.name).sort(); const legs = evidence.filter((entry) => entry.kind === "session").map((entry) => entry.id); const retryDirs = runDirs.filter((name) => /(?:^|[-_])retry(?:[-_]|$)/i.test(name)); const rawBytes = evidence.filter((entry) => entry.kind === "session").reduce((sum, entry) => sum + entry.byteLength, 0);
45
+ const cost: DoctorCaseCost = { invocations: sourceList(runDirs.length, runDirs), legs: sourceList(legs.length, legs), modelApiTurns: sourceList(turns.count, turns.sources), outputTokens: sourceList(tokens.count, tokens.sources), toolCalls: sourceList(calls.count, calls.sources), retries: { ...sourceList(retryDirs.length, retryDirs), evidence: "literal run-dir naming" }, statuses, commits, sessions, outputBytes: { ...sourceList(rawBytes, legs), payload: "raw JSONL bytes", providerWireBytes: "unavailable" } };
46
+ return { version: 1, identity: { issueNumber: Number(match[1]), runsPath: await stableRunsIdentity(root) }, evidence, cost };
47
+ }
@@ -0,0 +1,18 @@
1
+ import type { AgentToolResult, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { disposeComplianceDecision } from "./audit-escalation.ts";
3
+ import { appendActiveSessionCustomEntry, type ComplianceDecision } from "./compliance-transport.ts";
4
+ import { DOCTOR_CANDIDATE_ENTRY_TYPE } from "./dossier-resolution.ts";
5
+ import { DOCTOR_EVIDENCE_TOOL_NAME, DOCTOR_OUTPUT_TOOL_DESCRIPTION, DOCTOR_OUTPUT_TOOL_NAME, DoctorEvidenceStore, doctorEvidenceReadSchema, doctorSubmissionSchema, validateDoctorOutput, type DoctorCase } from "./doctor-contracts.ts";
6
+
7
+ export { DOCTOR_EVIDENCE_TOOL_NAME, DOCTOR_OUTPUT_TOOL_NAME };
8
+ export const DOCTOR_CASE_FLAG = { name: "ak-doctor-case", definition: { description: "Retained .ak-roles/books/<book>/issues/<n>/runs directory", type: "string" as const } } as const;
9
+ export type DoctorRoleDependencies = { loadSoul(): Promise<string>; loadCase(path: string): Promise<DoctorCase>; auditCompliance(options: { context: ExtensionContext; signal?: AbortSignal }): Promise<ComplianceDecision> };
10
+ function singleton(toolCallId: string, ctx: ExtensionContext) { const leaf = ctx.sessionManager.getLeafEntry(); if (leaf?.type !== "message" || leaf.message.role !== "assistant") throw new Error("Doctor output must be the sole final tool call"); const calls = leaf.message.content.filter((part) => part.type === "toolCall"); if (calls.length !== 1 || calls[0]?.id !== toolCallId || calls[0]?.name !== DOCTOR_OUTPUT_TOOL_NAME) throw new Error("Doctor output must be the sole final tool call"); }
11
+ export function createDoctorRoleRuntime(pi: ExtensionAPI, dependencies: DoctorRoleDependencies, host: { failInfrastructure(error: unknown, ctx: ExtensionContext, toolCallId?: string): never }) {
12
+ let activation: { soul: string; patient: DoctorCase; store: DoctorEvidenceStore } | undefined; let registered = false; pi.registerFlag(DOCTOR_CASE_FLAG.name, DOCTOR_CASE_FLAG.definition);
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
+ 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 }) }), revise: (violations) => { throw new Error(`Doctor output violates its soul: ${violations.join("; ")}`); }, escalate: (result) => result, auditIncomplete: (result) => result }, details); } });
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
+ 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
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Unique dossier-resolution seam for 审刑院 (#233).
3
+ * Machine pointers only: cwd + AK_ROLE_RUN_DIR. No latest-run / mtime / global scan.
4
+ */
5
+ import { existsSync, statSync } from "node:fs";
6
+ import { resolve } from "node:path";
7
+
8
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
9
+
10
+ import { JUDGE_OUTPUT_TOOL_NAME as JUDGE_OUTPUT_TOOL } from "./package-contracts/judge-output.ts";
11
+
12
+ export const JUDGE_OUTPUT_TOOL_NAME = JUDGE_OUTPUT_TOOL;
13
+ export const AUDIT_RUN_DIR_ENV = "AK_ROLE_RUN_DIR" as const;
14
+ export const REVIEWER_CANDIDATE_ENTRY_TYPE = "ak_reviewer_audit_candidate" as const;
15
+ export const DOCTOR_CANDIDATE_ENTRY_TYPE = "ak_doctor_audit_candidate" as const;
16
+
17
+ export type MissingDossierObservation = { readonly kind: "missing-dossier" };
18
+ export type MissingSubjectObservation = {
19
+ readonly kind: "missing-subject";
20
+ readonly subject: "assignment" | "candidate-verdict" | "candidate-receipt" | "candidate-testimony" | string;
21
+ };
22
+ export type DossierObservation = MissingDossierObservation | MissingSubjectObservation;
23
+
24
+ export type DossierOk = {
25
+ readonly status: "ok";
26
+ /** Present only when public CLI injected a validated AK_ROLE_RUN_DIR. */
27
+ readonly runDirectory?: string;
28
+ };
29
+ export type DossierIncomplete = {
30
+ readonly status: "incomplete";
31
+ readonly observation: DossierObservation;
32
+ };
33
+ export type DossierResolution = DossierOk | DossierIncomplete;
34
+
35
+ export type SubjectOk = { readonly status: "ok" };
36
+ export type SubjectIncomplete = {
37
+ readonly status: "incomplete";
38
+ readonly observation: MissingSubjectObservation;
39
+ };
40
+ export type SubjectResolution = SubjectOk | SubjectIncomplete;
41
+
42
+ /**
43
+ * Resolve the per-run dossier pointer injected by the public CLI.
44
+ *
45
+ * Absent pointer = bare Pi internal seam (ADR 0052): audit proceeds; the model
46
+ * self-locates the dossier from its own fall-volume position per soul. Public CLI
47
+ * always injects the pointer — only then does the machine validate the path.
48
+ * Concurrent runs stay isolated because a present pointer is per-process.
49
+ */
50
+ export function resolveAuditDossier(env: NodeJS.ProcessEnv = process.env): DossierResolution {
51
+ const raw = env[AUDIT_RUN_DIR_ENV];
52
+ // Bare Pi activation seam: no machine gate when the pointer was never injected.
53
+ if (typeof raw !== "string" || raw.trim() === "") {
54
+ return { status: "ok" };
55
+ }
56
+ const runDirectory = resolve(raw);
57
+ try {
58
+ if (!existsSync(runDirectory) || !statSync(runDirectory).isDirectory()) {
59
+ return { status: "incomplete", observation: { kind: "missing-dossier" } };
60
+ }
61
+ } catch {
62
+ return { status: "incomplete", observation: { kind: "missing-dossier" } };
63
+ }
64
+ return { status: "ok", runDirectory };
65
+ }
66
+
67
+ function isRecord(value: unknown): value is Record<string, unknown> {
68
+ return typeof value === "object" && value !== null && !Array.isArray(value);
69
+ }
70
+
71
+ /**
72
+ * Judge subjects must already be on the parent session books before audit starts:
73
+ * assignment (user message) + candidate verdict (sole judge output tool call).
74
+ */
75
+ export function readJudgeAuditSubjects(context: ExtensionContext): SubjectResolution {
76
+ const entries = context.sessionManager.getEntries?.() ?? [];
77
+ let hasAssignment = false;
78
+ let hasCandidate = false;
79
+ for (const entry of entries) {
80
+ if (entry.type !== "message") continue;
81
+ const message = entry.message;
82
+ if (message.role === "user") {
83
+ const text = typeof message.content === "string"
84
+ ? message.content
85
+ : Array.isArray(message.content)
86
+ ? message.content.map((part) => (part.type === "text" ? part.text : "")).join("")
87
+ : "";
88
+ if (text.trim().length > 0) hasAssignment = true;
89
+ }
90
+ if (message.role === "assistant" && Array.isArray(message.content)) {
91
+ for (const part of message.content) {
92
+ if (part.type === "toolCall" && part.name === JUDGE_OUTPUT_TOOL_NAME && isRecord(part.arguments)) {
93
+ hasCandidate = true;
94
+ }
95
+ }
96
+ }
97
+ }
98
+ if (!hasAssignment) {
99
+ return { status: "incomplete", observation: { kind: "missing-subject", subject: "assignment" } };
100
+ }
101
+ if (!hasCandidate) {
102
+ return { status: "incomplete", observation: { kind: "missing-subject", subject: "candidate-verdict" } };
103
+ }
104
+ return { status: "ok" };
105
+ }
106
+
107
+ /**
108
+ * Reviewer candidate receipt must be recorded before audit (first-record-then-audit).
109
+ */
110
+ export function readReviewerAuditSubjects(context: ExtensionContext): SubjectResolution {
111
+ const entries = context.sessionManager.getEntries?.() ?? [];
112
+ for (const entry of entries) {
113
+ if (entry.type === "custom" && entry.customType === REVIEWER_CANDIDATE_ENTRY_TYPE) {
114
+ return { status: "ok" };
115
+ }
116
+ }
117
+ return { status: "incomplete", observation: { kind: "missing-subject", subject: "candidate-receipt" } };
118
+ }
119
+
120
+ /**
121
+ * Doctor candidate testimony must be recorded before audit.
122
+ */
123
+ export function readDoctorAuditSubjects(context: ExtensionContext): SubjectResolution {
124
+ const entries = context.sessionManager.getEntries?.() ?? [];
125
+ for (const entry of entries) {
126
+ if (entry.type === "custom" && entry.customType === DOCTOR_CANDIDATE_ENTRY_TYPE) {
127
+ return { status: "ok" };
128
+ }
129
+ }
130
+ return { status: "incomplete", observation: { kind: "missing-subject", subject: "candidate-testimony" } };
131
+ }
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 };
137
+ }