@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,884 @@
1
+ /**
2
+ * Durable Role run lifecycle for public CLI (ADR 0052 / #11 / #108).
3
+ * States: admitted → running → resumable | terminal.
4
+ * v1 resume is limited to an observed typed HTTP 429 on Codex/xAI with no
5
+ * lawful role terminal result. Prose is never regex-classified as quota evidence.
6
+ */
7
+ import { lstat, open, readdir, readFile, unlink, writeFile } from "node:fs/promises";
8
+ import { join } from "node:path";
9
+
10
+ import {
11
+ activationBookDirectory,
12
+ resolveActivationLedgerHome,
13
+ } from "../activation-ledger-topology.ts";
14
+ import { CliUsageError } from "./cli-errors.ts";
15
+ import type { FixerPhase } from "../package-contracts/fixer-output.ts";
16
+ import type { FixerPrerequisite } from "../package-contracts/fixer-packet.ts";
17
+ import {
18
+ roleRunSessionFile,
19
+ type AdmittedCoderInvocation,
20
+ type AdmittedFixerInvocation,
21
+ type AdmittedJudgeInvocation,
22
+ type AdmittedMergerInvocation,
23
+ type AdmittedReviewerInvocation,
24
+ type AdmittedRoleInvocation,
25
+ type CoderPhase,
26
+ type DerivedMergerEnvelope,
27
+ type FrozenAttachment,
28
+ } from "./invocation.ts";
29
+
30
+ /** Providers eligible for v1 typed-429 resume (Codex / xAI only). */
31
+ export const V1_RESUMABLE_PROVIDERS = ["openai-codex", "xai"] as const;
32
+ export type V1ResumableProvider = (typeof V1_RESUMABLE_PROVIDERS)[number];
33
+
34
+ export type RoleRunState = "admitted" | "running" | "resumable" | "terminal";
35
+
36
+ export type TypedHttp429Observation = {
37
+ readonly httpStatus: 429;
38
+ readonly provider: V1ResumableProvider;
39
+ };
40
+
41
+ export type RoleRunRecord = {
42
+ readonly runId: string;
43
+ readonly role:
44
+ | "judge"
45
+ | "coder"
46
+ | "fixer"
47
+ | "collector"
48
+ | "doctor"
49
+ | "reviewer"
50
+ | "merger";
51
+ readonly state: RoleRunState;
52
+ readonly bookKey: string;
53
+ readonly projectRoot: string;
54
+ readonly sessionDirectory: string;
55
+ /** Exact Pi session file principal reopened on resume (not directory-latest). */
56
+ readonly sessionFile: string;
57
+ readonly runDirectory: string;
58
+ readonly admittedRequestPath: string;
59
+ /** Coder/Fixer — preserved for resume continuation. */
60
+ readonly phase?: CoderPhase | FixerPhase;
61
+ /** Present only while state === "resumable". */
62
+ readonly resumable?: TypedHttp429Observation;
63
+ };
64
+
65
+ /** Package-owned turn trigger for resume. Not caller instruction and not semantic task content. */
66
+ export const RESUME_TRANSPORT_ENVELOPE = "[ak-role:resume-continue]" as const;
67
+
68
+ const RUN_STATE_FILE = "run-state.json";
69
+ const TYPED_HTTP_FILE = "typed-provider-http.json";
70
+ const WRITER_LOCK_FILE = "writer.lock";
71
+
72
+ export function isV1ResumableProvider(
73
+ provider: string,
74
+ ): provider is V1ResumableProvider {
75
+ return (V1_RESUMABLE_PROVIDERS as readonly string[]).includes(provider);
76
+ }
77
+
78
+ function typedProviderHttpPath(runDirectory: string): string {
79
+ return join(runDirectory, TYPED_HTTP_FILE);
80
+ }
81
+
82
+ /**
83
+ * Clear any prior attempt's typed provider HTTP observation.
84
+ * Each initial/resume dispatch must start without inherited 429 evidence so
85
+ * only the current attempt can qualify v1 resume.
86
+ */
87
+ export async function clearTypedProviderHttpObservation(
88
+ runDirectory: string,
89
+ ): Promise<void> {
90
+ try {
91
+ await unlink(typedProviderHttpPath(runDirectory));
92
+ } catch (error) {
93
+ if (
94
+ error instanceof Error &&
95
+ "code" in error &&
96
+ (error as { code?: unknown }).code === "ENOENT"
97
+ ) {
98
+ return;
99
+ }
100
+ throw error;
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Record a typed provider HTTP status observation for the admitted run.
106
+ * The latest observation is authoritative: only a current HTTP 429 from
107
+ * Codex/xAI is retained for v1 resume; any other status or provider clears
108
+ * prior within-attempt 429 evidence. Never inspects diagnostic prose.
109
+ */
110
+ export async function recordTypedProviderHttpStatus(
111
+ runDirectory: string,
112
+ observation: { readonly httpStatus: number; readonly provider: string },
113
+ ): Promise<void> {
114
+ if (
115
+ observation.httpStatus === 429 &&
116
+ isV1ResumableProvider(observation.provider)
117
+ ) {
118
+ const body: TypedHttp429Observation = {
119
+ httpStatus: 429,
120
+ provider: observation.provider,
121
+ };
122
+ await writeFile(
123
+ typedProviderHttpPath(runDirectory),
124
+ `${JSON.stringify(body)}\n`,
125
+ "utf8",
126
+ );
127
+ return;
128
+ }
129
+ // Non-qualifying latest response supersedes any earlier 429 in this attempt.
130
+ await clearTypedProviderHttpObservation(runDirectory);
131
+ }
132
+
133
+ /**
134
+ * Read a durable typed HTTP 429 observation. Returns undefined unless both
135
+ * httpStatus === 429 and provider is a v1-resumable provider are present as
136
+ * typed fields (never inferred from prose).
137
+ */
138
+ export async function readTypedHttp429Observation(
139
+ runDirectory: string,
140
+ ): Promise<TypedHttp429Observation | undefined> {
141
+ try {
142
+ const raw: unknown = JSON.parse(
143
+ await readFile(typedProviderHttpPath(runDirectory), "utf8"),
144
+ );
145
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
146
+ return undefined;
147
+ }
148
+ const record = raw as Record<string, unknown>;
149
+ if (record.httpStatus !== 429) return undefined;
150
+ if (typeof record.provider !== "string") return undefined;
151
+ if (!isV1ResumableProvider(record.provider)) return undefined;
152
+ return { httpStatus: 429, provider: record.provider };
153
+ } catch {
154
+ return undefined;
155
+ }
156
+ }
157
+
158
+ /**
159
+ * True only when a typed HTTP 429 was observed and no lawful terminal exists.
160
+ * Callers must pass the lawful-terminal fact from settlement, not re-infer it.
161
+ */
162
+ export function isV1ResumableFailure(input: {
163
+ readonly hasLawfulTerminalResult: boolean;
164
+ readonly typedHttp429?: TypedHttp429Observation;
165
+ }): boolean {
166
+ if (input.hasLawfulTerminalResult) return false;
167
+ return input.typedHttp429 !== undefined;
168
+ }
169
+
170
+ /** Complete public resume command. Run ID is revealed only through this command text. */
171
+ export function renderResumeCommand(runId: string): string {
172
+ return `ak-role resume ${runId}`;
173
+ }
174
+
175
+ export async function writeRoleRunState(
176
+ runDirectory: string,
177
+ record: Omit<RoleRunRecord, "runDirectory">,
178
+ ): Promise<void> {
179
+ const payload: RoleRunRecord = { ...record, runDirectory };
180
+ await writeFile(
181
+ join(runDirectory, RUN_STATE_FILE),
182
+ `${JSON.stringify(payload, null, 2)}\n`,
183
+ "utf8",
184
+ );
185
+ }
186
+
187
+ export async function readRoleRunState(
188
+ runDirectory: string,
189
+ ): Promise<RoleRunRecord | undefined> {
190
+ try {
191
+ const raw: unknown = JSON.parse(
192
+ await readFile(join(runDirectory, RUN_STATE_FILE), "utf8"),
193
+ );
194
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
195
+ return undefined;
196
+ }
197
+ const record = raw as Record<string, unknown>;
198
+ if (typeof record.runId !== "string" || record.runId.trim() === "") {
199
+ return undefined;
200
+ }
201
+ if (
202
+ record.role !== "judge" &&
203
+ record.role !== "coder" &&
204
+ record.role !== "fixer" &&
205
+ record.role !== "collector" &&
206
+ record.role !== "doctor" &&
207
+ record.role !== "reviewer" &&
208
+ record.role !== "merger"
209
+ ) {
210
+ return undefined;
211
+ }
212
+ if (
213
+ record.state !== "admitted" &&
214
+ record.state !== "running" &&
215
+ record.state !== "resumable" &&
216
+ record.state !== "terminal"
217
+ ) {
218
+ return undefined;
219
+ }
220
+ if (typeof record.bookKey !== "string") return undefined;
221
+ if (typeof record.projectRoot !== "string") return undefined;
222
+ if (typeof record.sessionDirectory !== "string") return undefined;
223
+ if (typeof record.admittedRequestPath !== "string") return undefined;
224
+ const runDir =
225
+ typeof record.runDirectory === "string" && record.runDirectory.trim() !== ""
226
+ ? record.runDirectory
227
+ : runDirectory;
228
+ // Prefer durable principal; fall back only for in-progress records that
229
+ // predate the field but still own a private session directory.
230
+ const sessionFile =
231
+ typeof record.sessionFile === "string" && record.sessionFile.trim() !== ""
232
+ ? record.sessionFile
233
+ : roleRunSessionFile(record.sessionDirectory);
234
+ let resumable: TypedHttp429Observation | undefined;
235
+ if (record.resumable !== undefined && record.resumable !== null) {
236
+ if (
237
+ typeof record.resumable === "object" &&
238
+ !Array.isArray(record.resumable)
239
+ ) {
240
+ const r = record.resumable as Record<string, unknown>;
241
+ if (
242
+ r.httpStatus === 429 &&
243
+ typeof r.provider === "string" &&
244
+ isV1ResumableProvider(r.provider)
245
+ ) {
246
+ resumable = { httpStatus: 429, provider: r.provider };
247
+ }
248
+ }
249
+ }
250
+ const phase =
251
+ record.phase === "plan" || record.phase === "apply"
252
+ ? record.phase
253
+ : undefined;
254
+ return {
255
+ runId: record.runId,
256
+ role: record.role,
257
+ state: record.state,
258
+ bookKey: record.bookKey,
259
+ projectRoot: record.projectRoot,
260
+ sessionDirectory: record.sessionDirectory,
261
+ sessionFile,
262
+ runDirectory: runDir,
263
+ admittedRequestPath: record.admittedRequestPath,
264
+ ...(phase === undefined ? {} : { phase }),
265
+ ...(resumable === undefined ? {} : { resumable }),
266
+ };
267
+ } catch {
268
+ return undefined;
269
+ }
270
+ }
271
+
272
+ export async function markRunAdmitted(
273
+ admitted: AdmittedRoleInvocation,
274
+ ): Promise<void> {
275
+ await writeRoleRunState(admitted.runDirectory, {
276
+ runId: admitted.runId,
277
+ role: admitted.role,
278
+ state: "admitted",
279
+ bookKey: admitted.bookKey,
280
+ projectRoot: admitted.projectRoot,
281
+ sessionDirectory: admitted.sessionDirectory,
282
+ sessionFile: admitted.sessionFile,
283
+ admittedRequestPath: admitted.admittedRequestPath,
284
+ ...(
285
+ admitted.role === "coder" || admitted.role === "fixer"
286
+ ? { phase: admitted.phase }
287
+ : {}
288
+ ),
289
+ });
290
+ }
291
+
292
+ export async function markRunRunning(runDirectory: string): Promise<void> {
293
+ const current = await readRoleRunState(runDirectory);
294
+ if (current === undefined) {
295
+ throw new Error("cannot mark running: run state missing");
296
+ }
297
+ // Omit resumable while a writer is active.
298
+ await writeRoleRunState(runDirectory, {
299
+ runId: current.runId,
300
+ role: current.role,
301
+ state: "running",
302
+ bookKey: current.bookKey,
303
+ projectRoot: current.projectRoot,
304
+ sessionDirectory: current.sessionDirectory,
305
+ sessionFile: current.sessionFile,
306
+ admittedRequestPath: current.admittedRequestPath,
307
+ ...(current.phase === undefined ? {} : { phase: current.phase }),
308
+ });
309
+ }
310
+
311
+ export async function markRunResumable(
312
+ runDirectory: string,
313
+ observation: TypedHttp429Observation,
314
+ ): Promise<void> {
315
+ const current = await readRoleRunState(runDirectory);
316
+ if (current === undefined) {
317
+ throw new Error("cannot mark resumable: run state missing");
318
+ }
319
+ await writeRoleRunState(runDirectory, {
320
+ ...current,
321
+ state: "resumable",
322
+ resumable: observation,
323
+ });
324
+ }
325
+
326
+ export async function markRunTerminal(runDirectory: string): Promise<void> {
327
+ const current = await readRoleRunState(runDirectory);
328
+ if (current === undefined) {
329
+ throw new Error("cannot mark terminal: run state missing");
330
+ }
331
+ await writeRoleRunState(runDirectory, {
332
+ runId: current.runId,
333
+ role: current.role,
334
+ state: "terminal",
335
+ bookKey: current.bookKey,
336
+ projectRoot: current.projectRoot,
337
+ sessionDirectory: current.sessionDirectory,
338
+ sessionFile: current.sessionFile,
339
+ admittedRequestPath: current.admittedRequestPath,
340
+ ...(current.phase === undefined ? {} : { phase: current.phase }),
341
+ });
342
+ }
343
+
344
+ /**
345
+ * True when the durable Pi session file principal exists as a regular file.
346
+ * Resume must reopen this exact principal; directory-latest is not identity.
347
+ */
348
+ export async function isSessionPrincipalAvailable(
349
+ sessionFile: string,
350
+ ): Promise<boolean> {
351
+ if (sessionFile.trim() === "") return false;
352
+ try {
353
+ const st = await lstat(sessionFile);
354
+ return st.isFile() && !st.isSymbolicLink();
355
+ } catch {
356
+ return false;
357
+ }
358
+ }
359
+
360
+ export class RunWriterLeaseHeldError extends Error {
361
+ readonly code = "AK_RUN_WRITER_LEASE_HELD" as const;
362
+ constructor(message = "role run writer lease is already held") {
363
+ super(message);
364
+ this.name = "RunWriterLeaseHeldError";
365
+ }
366
+ }
367
+
368
+ export type RunWriterLease = {
369
+ readonly lockPath: string;
370
+ release(): Promise<void>;
371
+ };
372
+
373
+ /**
374
+ * Acquire the one-writer lease for a Role run. Concurrent acquire rejects
375
+ * without dispatch. Exclusive create — no second writer.
376
+ */
377
+ export async function acquireRunWriterLease(
378
+ runDirectory: string,
379
+ ): Promise<RunWriterLease> {
380
+ const lockPath = join(runDirectory, WRITER_LOCK_FILE);
381
+ try {
382
+ const handle = await open(lockPath, "wx");
383
+ try {
384
+ await handle.writeFile(`${process.pid}\n`, "utf8");
385
+ } catch (error) {
386
+ await handle.close().catch(() => undefined);
387
+ await unlink(lockPath).catch(() => undefined);
388
+ throw error;
389
+ }
390
+ let released = false;
391
+ return {
392
+ lockPath,
393
+ async release() {
394
+ if (released) return;
395
+ released = true;
396
+ await handle.close().catch(() => undefined);
397
+ await unlink(lockPath).catch(() => undefined);
398
+ },
399
+ };
400
+ } catch (error) {
401
+ if (
402
+ error instanceof Error &&
403
+ "code" in error &&
404
+ (error as { code?: unknown }).code === "EEXIST"
405
+ ) {
406
+ throw new RunWriterLeaseHeldError();
407
+ }
408
+ throw error;
409
+ }
410
+ }
411
+
412
+ /**
413
+ * Locate a Role run directory by run ID under the ledger books home.
414
+ * Returns undefined when the ID is unknown.
415
+ */
416
+ export async function findRunDirectoryById(
417
+ home: string,
418
+ runId: string,
419
+ ): Promise<string | undefined> {
420
+ if (runId.trim() === "") return undefined;
421
+ const ledgerHome = resolveActivationLedgerHome(() => home);
422
+ const booksRoot = join(ledgerHome, "books");
423
+ let bookKeys: string[];
424
+ try {
425
+ bookKeys = await readdir(booksRoot);
426
+ } catch {
427
+ return undefined;
428
+ }
429
+ for (const bookKey of bookKeys) {
430
+ const runsDir = join(activationBookDirectory(ledgerHome, bookKey), "runs");
431
+ let entries: string[];
432
+ try {
433
+ entries = await readdir(runsDir);
434
+ } catch {
435
+ continue;
436
+ }
437
+ for (const entry of entries) {
438
+ if (entry === `${runId}@judge` || entry.startsWith(`${runId}@`)) {
439
+ return join(runsDir, entry);
440
+ }
441
+ }
442
+ }
443
+ return undefined;
444
+ }
445
+
446
+ type LoadedAdmittedRequestFields = {
447
+ readonly instruction: string;
448
+ readonly instructionEmpty: boolean;
449
+ readonly attachments: FrozenAttachment[];
450
+ readonly phase?: CoderPhase | FixerPhase;
451
+ readonly taskPath?: string;
452
+ readonly packetPath?: string;
453
+ readonly prerequisitesPath?: string;
454
+ readonly prerequisites?: readonly FixerPrerequisite[];
455
+ readonly baseRevision?: string;
456
+ readonly mergerInputPath?: string;
457
+ readonly derived?: DerivedMergerEnvelope;
458
+ };
459
+
460
+ async function loadResumableRunRecord(
461
+ home: string,
462
+ runId: string,
463
+ ): Promise<{
464
+ readonly run: RoleRunRecord;
465
+ readonly observation: TypedHttp429Observation;
466
+ readonly admittedFields: LoadedAdmittedRequestFields;
467
+ }> {
468
+ const runDirectory = await findRunDirectoryById(home, runId);
469
+ if (runDirectory === undefined) {
470
+ throw new CliUsageError(`unknown role run id: ${runId}`);
471
+ }
472
+ const run = await readRoleRunState(runDirectory);
473
+ if (run === undefined) {
474
+ throw new CliUsageError(`unknown role run id: ${runId}`);
475
+ }
476
+ if (run.state === "terminal") {
477
+ throw new CliUsageError(`role run is already terminal: ${runId}`);
478
+ }
479
+ if (run.state !== "resumable" || run.resumable === undefined) {
480
+ throw new CliUsageError(`role run is not resumable: ${runId}`);
481
+ }
482
+ // Exact Pi session principal must be present before resume dispatches.
483
+ if (!(await isSessionPrincipalAvailable(run.sessionFile))) {
484
+ throw new CliUsageError(
485
+ `role run Pi session principal is unavailable: ${runId}`,
486
+ );
487
+ }
488
+ // Reconstruct admitted identity from durable run record + admitted-request.json.
489
+ let instruction = "";
490
+ let instructionEmpty = true;
491
+ let attachments: FrozenAttachment[] = [];
492
+ let phase: CoderPhase | FixerPhase | undefined;
493
+ let taskPath: string | undefined;
494
+ let packetPath: string | undefined;
495
+ let prerequisitesPath: string | undefined;
496
+ let prerequisites: readonly FixerPrerequisite[] | undefined;
497
+ let baseRevision: string | undefined;
498
+ let mergerInputPath: string | undefined;
499
+ let derived: DerivedMergerEnvelope | undefined;
500
+ try {
501
+ const raw: unknown = JSON.parse(
502
+ await readFile(run.admittedRequestPath, "utf8"),
503
+ );
504
+ if (raw !== null && typeof raw === "object" && !Array.isArray(raw)) {
505
+ const record = raw as Record<string, unknown>;
506
+ if (typeof record.instruction === "string") {
507
+ instruction = record.instruction;
508
+ }
509
+ if (typeof record.instructionEmpty === "boolean") {
510
+ instructionEmpty = record.instructionEmpty;
511
+ }
512
+ if (Array.isArray(record.attachments)) {
513
+ attachments = record.attachments as FrozenAttachment[];
514
+ }
515
+ if (record.phase === "plan" || record.phase === "apply") {
516
+ phase = record.phase;
517
+ }
518
+ if (typeof record.taskPath === "string" && record.taskPath.trim() !== "") {
519
+ taskPath = record.taskPath;
520
+ }
521
+ if (typeof record.packetPath === "string" && record.packetPath.trim() !== "") {
522
+ packetPath = record.packetPath;
523
+ }
524
+ if (
525
+ typeof record.prerequisitesPath === "string" &&
526
+ record.prerequisitesPath.trim() !== ""
527
+ ) {
528
+ prerequisitesPath = record.prerequisitesPath;
529
+ }
530
+ if (Array.isArray(record.prerequisites)) {
531
+ prerequisites = record.prerequisites as FixerPrerequisite[];
532
+ }
533
+ if (
534
+ typeof record.baseRevision === "string" &&
535
+ record.baseRevision.trim() !== ""
536
+ ) {
537
+ baseRevision = record.baseRevision;
538
+ }
539
+ if (
540
+ typeof record.mergerInputPath === "string" &&
541
+ record.mergerInputPath.trim() !== ""
542
+ ) {
543
+ mergerInputPath = record.mergerInputPath;
544
+ }
545
+ if (
546
+ record.derived !== null &&
547
+ typeof record.derived === "object" &&
548
+ !Array.isArray(record.derived)
549
+ ) {
550
+ const d = record.derived as Record<string, unknown>;
551
+ if (
552
+ typeof d.targetObjectId === "string" &&
553
+ typeof d.sourceObjectId === "string" &&
554
+ typeof d.automaticMergeTreeId === "string" &&
555
+ Array.isArray(d.expectedConflictPaths) &&
556
+ Array.isArray(d.resolutionScope) &&
557
+ d.expectedConflictPaths.every((p) => typeof p === "string") &&
558
+ d.resolutionScope.every((p) => typeof p === "string")
559
+ ) {
560
+ derived = {
561
+ targetObjectId: d.targetObjectId,
562
+ sourceObjectId: d.sourceObjectId,
563
+ automaticMergeTreeId: d.automaticMergeTreeId,
564
+ expectedConflictPaths: d.expectedConflictPaths as string[],
565
+ resolutionScope: d.resolutionScope as string[],
566
+ };
567
+ }
568
+ }
569
+ }
570
+ } catch {
571
+ throw new CliUsageError(
572
+ `role run admitted request is unreadable: ${runId}`,
573
+ );
574
+ }
575
+ return {
576
+ run,
577
+ observation: run.resumable,
578
+ admittedFields: {
579
+ instruction,
580
+ instructionEmpty,
581
+ attachments,
582
+ ...(phase === undefined ? {} : { phase }),
583
+ ...(taskPath === undefined ? {} : { taskPath }),
584
+ ...(packetPath === undefined ? {} : { packetPath }),
585
+ ...(prerequisitesPath === undefined ? {} : { prerequisitesPath }),
586
+ ...(prerequisites === undefined ? {} : { prerequisites }),
587
+ ...(baseRevision === undefined ? {} : { baseRevision }),
588
+ ...(mergerInputPath === undefined ? {} : { mergerInputPath }),
589
+ ...(derived === undefined ? {} : { derived }),
590
+ },
591
+ };
592
+ }
593
+
594
+ export type LoadedResumableJudgeRun = {
595
+ readonly admitted: AdmittedJudgeInvocation;
596
+ readonly run: RoleRunRecord;
597
+ readonly observation: TypedHttp429Observation;
598
+ };
599
+
600
+ export type LoadedResumableCoderRun = {
601
+ readonly admitted: AdmittedCoderInvocation;
602
+ readonly run: RoleRunRecord;
603
+ readonly observation: TypedHttp429Observation;
604
+ };
605
+
606
+ export type LoadedResumableFixerRun = {
607
+ readonly admitted: AdmittedFixerInvocation;
608
+ readonly run: RoleRunRecord;
609
+ readonly observation: TypedHttp429Observation;
610
+ };
611
+
612
+ export type LoadedResumableReviewerRun = {
613
+ readonly admitted: AdmittedReviewerInvocation;
614
+ readonly run: RoleRunRecord;
615
+ readonly observation: TypedHttp429Observation;
616
+ };
617
+
618
+ /**
619
+ * Load a resumable Judge run for resume. Rejects unknown, terminal,
620
+ * non-resumable, and non-Judge IDs without replaying dispatch.
621
+ */
622
+ export async function loadResumableJudgeRun(
623
+ home: string,
624
+ runId: string,
625
+ ): Promise<LoadedResumableJudgeRun> {
626
+ const loaded = await loadResumableRunRecord(home, runId);
627
+ if (loaded.run.role !== "judge") {
628
+ throw new CliUsageError(
629
+ `role run ${runId} belongs to ${loaded.run.role}, not judge`,
630
+ );
631
+ }
632
+ const admitted: AdmittedJudgeInvocation = {
633
+ role: "judge",
634
+ runId: loaded.run.runId,
635
+ bookKey: loaded.run.bookKey,
636
+ projectRoot: loaded.run.projectRoot,
637
+ instruction: loaded.admittedFields.instruction,
638
+ instructionEmpty: loaded.admittedFields.instructionEmpty,
639
+ attachments: loaded.admittedFields.attachments,
640
+ runDirectory: loaded.run.runDirectory,
641
+ sessionDirectory: loaded.run.sessionDirectory,
642
+ sessionFile: loaded.run.sessionFile,
643
+ admittedRequestPath: loaded.run.admittedRequestPath,
644
+ };
645
+ return {
646
+ admitted,
647
+ run: loaded.run,
648
+ observation: loaded.observation,
649
+ };
650
+ }
651
+
652
+ /**
653
+ * Load a resumable Coder run for resume. Phase and task path are restored from
654
+ * the admitted request so continuation stays role-correct (#109).
655
+ */
656
+ export async function loadResumableCoderRun(
657
+ home: string,
658
+ runId: string,
659
+ ): Promise<LoadedResumableCoderRun> {
660
+ const loaded = await loadResumableRunRecord(home, runId);
661
+ if (loaded.run.role !== "coder") {
662
+ throw new CliUsageError(
663
+ `role run ${runId} belongs to ${loaded.run.role}, not coder`,
664
+ );
665
+ }
666
+ const phase = loaded.admittedFields.phase ?? loaded.run.phase;
667
+ if (phase !== "plan" && phase !== "apply") {
668
+ throw new CliUsageError(
669
+ `role run admitted coder phase is missing: ${runId}`,
670
+ );
671
+ }
672
+ const taskPath = loaded.admittedFields.taskPath;
673
+ if (taskPath === undefined) {
674
+ throw new CliUsageError(
675
+ `role run admitted coder task path is missing: ${runId}`,
676
+ );
677
+ }
678
+ if (loaded.admittedFields.instruction.trim() === "") {
679
+ throw new CliUsageError(
680
+ `role run admitted coder task is blank: ${runId}`,
681
+ );
682
+ }
683
+ const admitted: AdmittedCoderInvocation = {
684
+ role: "coder",
685
+ phase,
686
+ runId: loaded.run.runId,
687
+ bookKey: loaded.run.bookKey,
688
+ projectRoot: loaded.run.projectRoot,
689
+ instruction: loaded.admittedFields.instruction,
690
+ instructionEmpty: false,
691
+ attachments: loaded.admittedFields.attachments,
692
+ runDirectory: loaded.run.runDirectory,
693
+ sessionDirectory: loaded.run.sessionDirectory,
694
+ sessionFile: loaded.run.sessionFile,
695
+ admittedRequestPath: loaded.run.admittedRequestPath,
696
+ taskPath,
697
+ };
698
+ return {
699
+ admitted,
700
+ run: loaded.run,
701
+ observation: loaded.observation,
702
+ };
703
+ }
704
+
705
+ /**
706
+ * Load a resumable Fixer run for resume. Phase, packet, and prerequisites are
707
+ * restored from the admitted request so continuation stays role-correct (#110).
708
+ */
709
+ export async function loadResumableFixerRun(
710
+ home: string,
711
+ runId: string,
712
+ ): Promise<LoadedResumableFixerRun> {
713
+ const loaded = await loadResumableRunRecord(home, runId);
714
+ if (loaded.run.role !== "fixer") {
715
+ throw new CliUsageError(
716
+ `role run ${runId} belongs to ${loaded.run.role}, not fixer`,
717
+ );
718
+ }
719
+ const phase = loaded.admittedFields.phase ?? loaded.run.phase;
720
+ if (phase !== "plan" && phase !== "apply") {
721
+ throw new CliUsageError(
722
+ `role run admitted fixer phase is missing: ${runId}`,
723
+ );
724
+ }
725
+ const packetPath = loaded.admittedFields.packetPath;
726
+ if (packetPath === undefined) {
727
+ throw new CliUsageError(
728
+ `role run admitted fixer packet path is missing: ${runId}`,
729
+ );
730
+ }
731
+ if (loaded.admittedFields.instruction.trim() === "") {
732
+ throw new CliUsageError(
733
+ `role run admitted fixer instruction is blank: ${runId}`,
734
+ );
735
+ }
736
+ const prerequisites = loaded.admittedFields.prerequisites ?? Object.freeze([]);
737
+ const admitted: AdmittedFixerInvocation = {
738
+ role: "fixer",
739
+ phase,
740
+ runId: loaded.run.runId,
741
+ bookKey: loaded.run.bookKey,
742
+ projectRoot: loaded.run.projectRoot,
743
+ instruction: loaded.admittedFields.instruction,
744
+ instructionEmpty: false,
745
+ attachments: loaded.admittedFields.attachments,
746
+ runDirectory: loaded.run.runDirectory,
747
+ sessionDirectory: loaded.run.sessionDirectory,
748
+ sessionFile: loaded.run.sessionFile,
749
+ admittedRequestPath: loaded.run.admittedRequestPath,
750
+ packetPath,
751
+ ...(loaded.admittedFields.prerequisitesPath === undefined
752
+ ? {}
753
+ : { prerequisitesPath: loaded.admittedFields.prerequisitesPath }),
754
+ prerequisites,
755
+ };
756
+ return {
757
+ admitted,
758
+ run: loaded.run,
759
+ observation: loaded.observation,
760
+ };
761
+ }
762
+
763
+ /**
764
+ * Peek the durable role of a run id without enforcing resumable state.
765
+ * Used by public resume dispatch to pick the role-correct seat and path.
766
+ */
767
+ /**
768
+ * Load a resumable Reviewer run for resume. Fixed base is restored from the
769
+ * admitted request; caller instruction remains optional provenance only.
770
+ */
771
+ export async function loadResumableReviewerRun(
772
+ home: string,
773
+ runId: string,
774
+ ): Promise<LoadedResumableReviewerRun> {
775
+ const loaded = await loadResumableRunRecord(home, runId);
776
+ if (loaded.run.role !== "reviewer") {
777
+ throw new CliUsageError(
778
+ `role run ${runId} belongs to ${loaded.run.role}, not reviewer`,
779
+ );
780
+ }
781
+ const baseRevision = loaded.admittedFields.baseRevision;
782
+ if (baseRevision === undefined || baseRevision.trim() === "") {
783
+ throw new CliUsageError(
784
+ `role run admitted reviewer base revision is missing: ${runId}`,
785
+ );
786
+ }
787
+ const admitted: AdmittedReviewerInvocation = {
788
+ role: "reviewer",
789
+ runId: loaded.run.runId,
790
+ bookKey: loaded.run.bookKey,
791
+ projectRoot: loaded.run.projectRoot,
792
+ instruction: loaded.admittedFields.instruction,
793
+ instructionEmpty: loaded.admittedFields.instructionEmpty,
794
+ attachments: loaded.admittedFields.attachments,
795
+ runDirectory: loaded.run.runDirectory,
796
+ sessionDirectory: loaded.run.sessionDirectory,
797
+ sessionFile: loaded.run.sessionFile,
798
+ admittedRequestPath: loaded.run.admittedRequestPath,
799
+ baseRevision,
800
+ };
801
+ return {
802
+ admitted,
803
+ run: loaded.run,
804
+ observation: loaded.observation,
805
+ };
806
+ }
807
+
808
+ export type LoadedResumableMergerRun = {
809
+ readonly admitted: AdmittedMergerInvocation;
810
+ readonly run: RoleRunRecord;
811
+ readonly observation: TypedHttp429Observation;
812
+ };
813
+
814
+ /**
815
+ * Load a resumable Merger run for resume. Derived envelope + internal input path
816
+ * are restored from the admitted request (#114).
817
+ */
818
+ export async function loadResumableMergerRun(
819
+ home: string,
820
+ runId: string,
821
+ ): Promise<LoadedResumableMergerRun> {
822
+ const loaded = await loadResumableRunRecord(home, runId);
823
+ if (loaded.run.role !== "merger") {
824
+ throw new CliUsageError(
825
+ `role run ${runId} belongs to ${loaded.run.role}, not merger`,
826
+ );
827
+ }
828
+ const mergerInputPath = loaded.admittedFields.mergerInputPath;
829
+ if (mergerInputPath === undefined) {
830
+ throw new CliUsageError(
831
+ `role run admitted merger input path is missing: ${runId}`,
832
+ );
833
+ }
834
+ const derived = loaded.admittedFields.derived;
835
+ if (derived === undefined) {
836
+ throw new CliUsageError(
837
+ `role run admitted merger envelope is missing: ${runId}`,
838
+ );
839
+ }
840
+ if (loaded.admittedFields.instruction.trim() === "") {
841
+ throw new CliUsageError(
842
+ `role run admitted merger task is blank: ${runId}`,
843
+ );
844
+ }
845
+ const admitted: AdmittedMergerInvocation = {
846
+ role: "merger",
847
+ runId: loaded.run.runId,
848
+ bookKey: loaded.run.bookKey,
849
+ projectRoot: loaded.run.projectRoot,
850
+ instruction: loaded.admittedFields.instruction,
851
+ instructionEmpty: false,
852
+ attachments: loaded.admittedFields.attachments,
853
+ runDirectory: loaded.run.runDirectory,
854
+ sessionDirectory: loaded.run.sessionDirectory,
855
+ sessionFile: loaded.run.sessionFile,
856
+ admittedRequestPath: loaded.run.admittedRequestPath,
857
+ mergerInputPath,
858
+ derived,
859
+ };
860
+ return {
861
+ admitted,
862
+ run: loaded.run,
863
+ observation: loaded.observation,
864
+ };
865
+ }
866
+
867
+ export async function peekRoleRunRole(
868
+ home: string,
869
+ runId: string,
870
+ ): Promise<
871
+ | "judge"
872
+ | "coder"
873
+ | "fixer"
874
+ | "collector"
875
+ | "doctor"
876
+ | "reviewer"
877
+ | "merger"
878
+ | undefined
879
+ > {
880
+ const runDirectory = await findRunDirectoryById(home, runId);
881
+ if (runDirectory === undefined) return undefined;
882
+ const run = await readRoleRunState(runDirectory);
883
+ return run?.role;
884
+ }