@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,1958 @@
1
+ /**
2
+ * Public Invocation request admission: optional opaque instruction, frozen
3
+ * Attachments, project default/override (ADR 0052 / #106).
4
+ */
5
+ import { execFileSync } from "node:child_process";
6
+ import {
7
+ lstat,
8
+ mkdir,
9
+ readFile,
10
+ realpath,
11
+ writeFile,
12
+ } from "node:fs/promises";
13
+ import { basename, isAbsolute, join, resolve, sep } from "node:path";
14
+
15
+ import {
16
+ activationBookDirectory,
17
+ ensureRealDirectoryTree,
18
+ pathContainedIn,
19
+ resolveActivationLedgerHome,
20
+ } from "../activation-ledger-topology.ts";
21
+ import { resolveBookKeyFromGit } from "../activation-ledger-git.ts";
22
+ import {
23
+ loadDoctorCase,
24
+ } from "../doctor-evidence.ts";
25
+ import type { DoctorCaseIdentity } from "../doctor-contracts.ts";
26
+ import {
27
+ COLLECTOR_FIXED_KICKOFF,
28
+ emptyCollectorManifest,
29
+ loadCollectorManifest,
30
+ parseCollectorPrNumber,
31
+ parseCollectorRepository,
32
+ type CollectorRepository,
33
+ } from "../collector-config.ts";
34
+ import {
35
+ FixerPacketValidationError,
36
+ parseFixerPrerequisites,
37
+ type FixerPrerequisite,
38
+ } from "../package-contracts/fixer-packet.ts";
39
+ import type { FixerPhase } from "../package-contracts/fixer-output.ts";
40
+ import { createProductionMergerGitState } from "../merger-git-state.ts";
41
+ import type { MergerGitState } from "../merger-git-state.ts";
42
+ import {
43
+ validateMergerInput,
44
+ type MergerInput,
45
+ } from "../merger-contracts.ts";
46
+ import { sha256Hex } from "../sha256.ts";
47
+ import { uuidv7 } from "../uuidv7.ts";
48
+ import { CliUsageError } from "./cli-errors.ts";
49
+
50
+ export type FrozenAttachment = {
51
+ /** Original caller path retained only as provenance. */
52
+ readonly provenancePath: string;
53
+ /** Absolute path of the admitted frozen snapshot bytes. */
54
+ readonly frozenPath: string;
55
+ readonly byteLength: number;
56
+ readonly sha256: string;
57
+ readonly mediaKind: "regular-file";
58
+ };
59
+
60
+ /** Durable Pi session file principal name under a Role run's private session directory. */
61
+ export const ROLE_RUN_SESSION_FILE_NAME = "session.jsonl" as const;
62
+
63
+ /** Exact Pi session file principal path for a Role run session directory. */
64
+ export function roleRunSessionFile(sessionDirectory: string): string {
65
+ return join(sessionDirectory, ROLE_RUN_SESSION_FILE_NAME);
66
+ }
67
+
68
+ /** Shared admitted Role run identity (#106 common Invocation + #109 Coder). */
69
+ export type AdmittedRoleInvocationBase = {
70
+ readonly runId: string;
71
+ readonly bookKey: string;
72
+ readonly projectRoot: string;
73
+ /** Opaque instruction bytes as submitted. */
74
+ readonly instruction: string;
75
+ /** True when the caller supplied no nonblank instruction. */
76
+ readonly instructionEmpty: boolean;
77
+ readonly attachments: readonly FrozenAttachment[];
78
+ readonly runDirectory: string;
79
+ readonly sessionDirectory: string;
80
+ /** Exact Pi session file principal (bound at admission; reopened on resume). */
81
+ readonly sessionFile: string;
82
+ readonly admittedRequestPath: string;
83
+ };
84
+
85
+ export type AdmittedJudgeInvocation = AdmittedRoleInvocationBase & {
86
+ readonly role: "judge";
87
+ };
88
+
89
+ export type CoderPhase = "plan" | "apply";
90
+
91
+ export type AdmittedCoderInvocation = AdmittedRoleInvocationBase & {
92
+ readonly role: "coder";
93
+ /** Explicit plan or default apply — preserved through admission and continuation. */
94
+ readonly phase: CoderPhase;
95
+ /** Durable task file path consumed by internal --ak-coder-task. */
96
+ readonly taskPath: string;
97
+ };
98
+
99
+ export type AdmittedFixerInvocation = AdmittedRoleInvocationBase & {
100
+ readonly role: "fixer";
101
+ /** Explicit plan or default apply — preserved through admission and continuation. */
102
+ readonly phase: FixerPhase;
103
+ /** Durable opaque instruction path consumed by internal --ak-fix-packet. */
104
+ readonly packetPath: string;
105
+ /** Optional durable prerequisites JSON path for --ak-fixer-prerequisites. */
106
+ readonly prerequisitesPath?: string;
107
+ /** Structurally validated prerequisite declarations frozen at admission. */
108
+ readonly prerequisites: readonly FixerPrerequisite[];
109
+ };
110
+
111
+ export type AdmittedCollectorInvocation = AdmittedRoleInvocationBase & {
112
+ readonly role: "collector";
113
+ readonly prNumber: number;
114
+ readonly repository: CollectorRepository;
115
+ readonly requestManifestPath?: string;
116
+ readonly manifestDigest: string;
117
+ };
118
+
119
+ export type AdmittedDoctorInvocation = AdmittedRoleInvocationBase & {
120
+ readonly role: "doctor";
121
+ /** Positive Issue number that owns the retained single-case evidence. */
122
+ readonly issueNumber: number;
123
+ /** Absolute retained runs root passed to internal --ak-doctor-case. */
124
+ readonly caseRunsPath: string;
125
+ /** Structurally exact case identity from loadDoctorCase (no second packet). */
126
+ readonly caseIdentity: DoctorCaseIdentity;
127
+ };
128
+
129
+ export type AdmittedReviewerInvocation = AdmittedRoleInvocationBase & {
130
+ readonly role: "reviewer";
131
+ /** Required fixed base revision for the pinned review target (ADR 0037). */
132
+ readonly baseRevision: string;
133
+ };
134
+
135
+ /** Mechanical envelope derived from the active ordinary two-parent merge. */
136
+ export type DerivedMergerEnvelope = {
137
+ readonly targetObjectId: string;
138
+ readonly sourceObjectId: string;
139
+ readonly automaticMergeTreeId: string;
140
+ readonly expectedConflictPaths: readonly string[];
141
+ readonly resolutionScope: readonly string[];
142
+ };
143
+
144
+ export type AdmittedMergerInvocation = AdmittedRoleInvocationBase & {
145
+ readonly role: "merger";
146
+ /** Durable internal merger-input JSON path for --ak-merger-input. */
147
+ readonly mergerInputPath: string;
148
+ /** Adapter-derived mechanical facts (not public packet fields). */
149
+ readonly derived: DerivedMergerEnvelope;
150
+ };
151
+
152
+ export type AdmittedRoleInvocation =
153
+ | AdmittedJudgeInvocation
154
+ | AdmittedCoderInvocation
155
+ | AdmittedFixerInvocation
156
+ | AdmittedCollectorInvocation
157
+ | AdmittedDoctorInvocation
158
+ | AdmittedReviewerInvocation
159
+ | AdmittedMergerInvocation;
160
+
161
+ type RoleInvocationLedgerSource = Pick<
162
+ AdmittedRoleInvocationBase,
163
+ "runId" | "bookKey" | "projectRoot" | "runDirectory" | "sessionDirectory" | "sessionFile"
164
+ >;
165
+
166
+ /**
167
+ * Persist one `invocation.json` identity page for the public run.
168
+ * Admission is the sole source for every field; this is the only identity
169
+ * projection and callers never provide an independent ledger shape.
170
+ */
171
+ async function writeRoleInvocationLedger(
172
+ source: RoleInvocationLedgerSource,
173
+ role: AdmittedRoleInvocation["role"],
174
+ ): Promise<void> {
175
+ const identity = {
176
+ role,
177
+ runId: source.runId,
178
+ bookKey: source.bookKey,
179
+ projectRoot: source.projectRoot,
180
+ runDirectory: source.runDirectory,
181
+ sessionDirectory: source.sessionDirectory,
182
+ sessionFile: source.sessionFile,
183
+ };
184
+ await writeFile(
185
+ join(source.runDirectory, "invocation.json"),
186
+ `${JSON.stringify(identity, null, 2)}\n`,
187
+ "utf8",
188
+ );
189
+ }
190
+
191
+ /** Add the identity returned by the production Pi launch seam to its existing ledger page. */
192
+ export async function recordLaunchedPiIdentity(
193
+ runDirectory: string,
194
+ identity: { executable: string; version: string },
195
+ ): Promise<void> {
196
+ const ledgerPath = join(runDirectory, "invocation.json");
197
+ const current = JSON.parse(await readFile(ledgerPath, "utf8")) as Record<string, unknown>;
198
+ await writeFile(
199
+ ledgerPath,
200
+ `${JSON.stringify({
201
+ ...current,
202
+ piExecutable: identity.executable,
203
+ piVersion: identity.version,
204
+ }, null, 2)}\n`,
205
+ "utf8",
206
+ );
207
+ }
208
+
209
+ export type ParseJudgeArgvResult = {
210
+ instruction: string;
211
+ attachmentPaths: string[];
212
+ project?: string;
213
+ };
214
+
215
+ export type ParseCoderArgvResult = {
216
+ phase: CoderPhase;
217
+ instruction: string;
218
+ attachmentPaths: string[];
219
+ project?: string;
220
+ };
221
+
222
+ export type ParseFixerArgvResult = {
223
+ phase: FixerPhase;
224
+ instruction: string;
225
+ attachmentPaths: string[];
226
+ /** Optional path to structurally valid prerequisite JSON array. */
227
+ prerequisitesPath?: string;
228
+ project?: string;
229
+ };
230
+
231
+ export type ParseCollectorArgvResult = {
232
+ prNumber: number;
233
+ instruction: string;
234
+ attachmentPaths: string[];
235
+ project?: string;
236
+ repo?: string;
237
+ requestManifestPath?: string;
238
+ };
239
+
240
+ export type ParseDoctorArgvResult = {
241
+ issueNumber: number;
242
+ /** Optional project-relative retained runs root override. */
243
+ runs?: string;
244
+ instruction: string;
245
+ attachmentPaths: string[];
246
+ project?: string;
247
+ };
248
+
249
+ export type ParseReviewerArgvResult = {
250
+ /** Optional caller prose retained only as admitted provenance. */
251
+ instruction: string;
252
+ attachmentPaths: string[];
253
+ /** Required fixed base revision for the pinned review target. */
254
+ baseRevision: string;
255
+ project?: string;
256
+ };
257
+
258
+ export type ParseMergerArgvResult = {
259
+ instruction: string;
260
+ attachmentPaths: string[];
261
+ project?: string;
262
+ };
263
+
264
+ /** Honest activation-class failure while deriving the active-merge envelope. */
265
+ export class MergerEnvelopeDerivationError extends Error {
266
+ readonly code = "merger-envelope-derivation" as const;
267
+ /** Typed cause for #107 classifyPostAdmissionFailure (isTypedActivationError). */
268
+ readonly knownCause = "activation" as const;
269
+ constructor(message: string, options?: { cause?: unknown }) {
270
+ super(message, options);
271
+ this.name = "MergerEnvelopeDerivationError";
272
+ }
273
+ }
274
+
275
+ /** Reject missing/blank path values so empty overrides cannot silently degrade. */
276
+ function requireOptionPath(
277
+ flag: "--project" | "--attach" | "--prerequisites" | "--request-manifest" | "--base",
278
+ value: string | undefined,
279
+ ): string {
280
+ if (value === undefined || value.trim() === "") {
281
+ throw new CliUsageError(
282
+ flag === "--base"
283
+ ? `${flag} requires a nonempty revision`
284
+ : `${flag} requires a path`,
285
+ );
286
+ }
287
+ return value;
288
+ }
289
+
290
+ /**
291
+ * Parse Judge-specific argv after the `judge` token.
292
+ * Rejects any public burden selector/hint and unknown flags.
293
+ */
294
+ export function parseJudgeArgv(args: readonly string[]): ParseJudgeArgvResult {
295
+ const attachmentPaths: string[] = [];
296
+ let project: string | undefined;
297
+ const positional: string[] = [];
298
+ const tokens = [...args];
299
+
300
+ while (tokens.length > 0) {
301
+ const token = tokens.shift()!;
302
+ if (token === "--") {
303
+ positional.push(...tokens);
304
+ break;
305
+ }
306
+ if (token === "--attach") {
307
+ attachmentPaths.push(requireOptionPath("--attach", tokens.shift()));
308
+ continue;
309
+ }
310
+ if (token.startsWith("--attach=")) {
311
+ attachmentPaths.push(
312
+ requireOptionPath("--attach", token.slice("--attach=".length)),
313
+ );
314
+ continue;
315
+ }
316
+ if (token === "--project") {
317
+ project = requireOptionPath("--project", tokens.shift());
318
+ continue;
319
+ }
320
+ if (token.startsWith("--project=")) {
321
+ project = requireOptionPath("--project", token.slice("--project=".length));
322
+ continue;
323
+ }
324
+ // Judge owns burden inference — no public burden selector or hint.
325
+ if (
326
+ token === "--burden" ||
327
+ token.startsWith("--burden=") ||
328
+ token === "--ak-judge-burden" ||
329
+ token.startsWith("--ak-judge-burden=") ||
330
+ token === "--judge-burden" ||
331
+ token.startsWith("--judge-burden=")
332
+ ) {
333
+ throw new CliUsageError(
334
+ "judge does not accept a public burden selector; Judge infers its own burden",
335
+ );
336
+ }
337
+ if (token.startsWith("-") && token !== "-") {
338
+ throw new CliUsageError(`unknown judge option: ${token}`);
339
+ }
340
+ positional.push(token);
341
+ }
342
+
343
+ return {
344
+ instruction: positional.join(" "),
345
+ attachmentPaths,
346
+ ...(project === undefined ? {} : { project }),
347
+ };
348
+ }
349
+
350
+ /**
351
+ * Parse Coder-specific argv after the `coder` token.
352
+ * Phase defaults to apply; explicit `plan` or `apply` as the first positional is preserved.
353
+ * Common Invocation flags: --attach / --project.
354
+ */
355
+ export function parseCoderArgv(args: readonly string[]): ParseCoderArgvResult {
356
+ const attachmentPaths: string[] = [];
357
+ let project: string | undefined;
358
+ const positional: string[] = [];
359
+ const tokens = [...args];
360
+
361
+ while (tokens.length > 0) {
362
+ const token = tokens.shift()!;
363
+ if (token === "--") {
364
+ positional.push(...tokens);
365
+ break;
366
+ }
367
+ if (token === "--attach") {
368
+ attachmentPaths.push(requireOptionPath("--attach", tokens.shift()));
369
+ continue;
370
+ }
371
+ if (token.startsWith("--attach=")) {
372
+ attachmentPaths.push(
373
+ requireOptionPath("--attach", token.slice("--attach=".length)),
374
+ );
375
+ continue;
376
+ }
377
+ if (token === "--project") {
378
+ project = requireOptionPath("--project", tokens.shift());
379
+ continue;
380
+ }
381
+ if (token.startsWith("--project=")) {
382
+ project = requireOptionPath("--project", token.slice("--project=".length));
383
+ continue;
384
+ }
385
+ if (token.startsWith("-") && token !== "-") {
386
+ throw new CliUsageError(`unknown coder option: ${token}`);
387
+ }
388
+ positional.push(token);
389
+ }
390
+
391
+ let phase: CoderPhase = "apply";
392
+ if (positional[0] === "plan" || positional[0] === "apply") {
393
+ phase = positional.shift() as CoderPhase;
394
+ }
395
+
396
+ return {
397
+ phase,
398
+ instruction: positional.join(" "),
399
+ attachmentPaths,
400
+ ...(project === undefined ? {} : { project }),
401
+ };
402
+ }
403
+
404
+ /**
405
+ * Parse Fixer-specific argv after the `fixer` token.
406
+ * Phase defaults to apply; explicit `plan` or `apply` as the first positional is preserved.
407
+ * Common Invocation flags: --attach / --project. Role-specific: optional --prerequisites.
408
+ */
409
+ export function parseFixerArgv(args: readonly string[]): ParseFixerArgvResult {
410
+ const attachmentPaths: string[] = [];
411
+ let project: string | undefined;
412
+ let prerequisitesPath: string | undefined;
413
+ const positional: string[] = [];
414
+ const tokens = [...args];
415
+
416
+ while (tokens.length > 0) {
417
+ const token = tokens.shift()!;
418
+ if (token === "--") {
419
+ positional.push(...tokens);
420
+ break;
421
+ }
422
+ if (token === "--attach") {
423
+ attachmentPaths.push(requireOptionPath("--attach", tokens.shift()));
424
+ continue;
425
+ }
426
+ if (token.startsWith("--attach=")) {
427
+ attachmentPaths.push(
428
+ requireOptionPath("--attach", token.slice("--attach=".length)),
429
+ );
430
+ continue;
431
+ }
432
+ if (token === "--project") {
433
+ project = requireOptionPath("--project", tokens.shift());
434
+ continue;
435
+ }
436
+ if (token.startsWith("--project=")) {
437
+ project = requireOptionPath("--project", token.slice("--project=".length));
438
+ continue;
439
+ }
440
+ if (token === "--prerequisites") {
441
+ prerequisitesPath = requireOptionPath("--prerequisites", tokens.shift());
442
+ continue;
443
+ }
444
+ if (token.startsWith("--prerequisites=")) {
445
+ prerequisitesPath = requireOptionPath(
446
+ "--prerequisites",
447
+ token.slice("--prerequisites=".length),
448
+ );
449
+ continue;
450
+ }
451
+ if (token.startsWith("-") && token !== "-") {
452
+ throw new CliUsageError(`unknown fixer option: ${token}`);
453
+ }
454
+ positional.push(token);
455
+ }
456
+
457
+ let phase: FixerPhase = "apply";
458
+ if (positional[0] === "plan" || positional[0] === "apply") {
459
+ phase = positional.shift() as FixerPhase;
460
+ }
461
+
462
+ return {
463
+ phase,
464
+ instruction: positional.join(" "),
465
+ attachmentPaths,
466
+ ...(prerequisitesPath === undefined ? {} : { prerequisitesPath }),
467
+ ...(project === undefined ? {} : { project }),
468
+ };
469
+ }
470
+
471
+ async function freezeRegularFileAttachment(
472
+ sourcePath: string,
473
+ destinationDir: string,
474
+ index: number,
475
+ ): Promise<FrozenAttachment> {
476
+ const absolute = isAbsolute(sourcePath) ? sourcePath : resolve(sourcePath);
477
+ let st;
478
+ try {
479
+ st = await lstat(absolute);
480
+ } catch (error) {
481
+ throw new CliUsageError(
482
+ `attachment is not a readable regular file: ${sourcePath}`,
483
+ { cause: error },
484
+ );
485
+ }
486
+ if (!st.isFile() || st.isSymbolicLink()) {
487
+ throw new CliUsageError(
488
+ `attachment must be a regular file (not a directory or symlink): ${sourcePath}`,
489
+ );
490
+ }
491
+ const bytes = await readFile(absolute);
492
+ const name = `${String(index).padStart(2, "0")}-${basename(absolute)}`;
493
+ const frozenPath = join(destinationDir, name);
494
+ await writeFile(frozenPath, bytes);
495
+ return {
496
+ provenancePath: absolute,
497
+ frozenPath,
498
+ byteLength: bytes.byteLength,
499
+ sha256: sha256Hex(bytes),
500
+ mediaKind: "regular-file",
501
+ };
502
+ }
503
+
504
+ export type AdmitJudgeInvocationOptions = {
505
+ home: string;
506
+ cwd: string;
507
+ instruction: string;
508
+ attachmentPaths: readonly string[];
509
+ project?: string;
510
+ /** Injectable clock/id for tests. */
511
+ createRunId?: () => string;
512
+ };
513
+
514
+ /**
515
+ * Atomically admit a Judge Role run: freeze Attachments, persist the request,
516
+ * and reserve session placement under the #78 ledger book.
517
+ */
518
+ export async function admitJudgeInvocation(
519
+ options: AdmitJudgeInvocationOptions,
520
+ ): Promise<AdmittedJudgeInvocation> {
521
+ // Empty project override must not reach resolve("") → cwd (silent default).
522
+ if (options.project !== undefined) {
523
+ requireOptionPath("--project", options.project);
524
+ }
525
+ const projectRoot = resolve(options.project ?? options.cwd);
526
+ const bookKey = resolveBookKeyFromGit(projectRoot);
527
+ const ledgerHome = resolveActivationLedgerHome(() => options.home);
528
+ const runId = (options.createRunId ?? uuidv7)();
529
+ const runDirectory = join(
530
+ activationBookDirectory(ledgerHome, bookKey),
531
+ "runs",
532
+ `${runId}@judge`,
533
+ );
534
+ const sessionDirectory = join(runDirectory, "session");
535
+ const sessionFile = roleRunSessionFile(sessionDirectory);
536
+ const attachmentsDirectory = join(runDirectory, "attachments");
537
+ ensureRealDirectoryTree(ledgerHome, sessionDirectory);
538
+ ensureRealDirectoryTree(ledgerHome, attachmentsDirectory);
539
+
540
+ const attachments: FrozenAttachment[] = [];
541
+ for (let i = 0; i < options.attachmentPaths.length; i += 1) {
542
+ attachments.push(
543
+ await freezeRegularFileAttachment(
544
+ options.attachmentPaths[i]!,
545
+ attachmentsDirectory,
546
+ i,
547
+ ),
548
+ );
549
+ }
550
+
551
+ const instruction = options.instruction;
552
+ const instructionEmpty = instruction.trim() === "";
553
+ const admitted = {
554
+ role: "judge" as const,
555
+ runId,
556
+ bookKey,
557
+ projectRoot,
558
+ runDirectory,
559
+ sessionDirectory,
560
+ sessionFile,
561
+ instruction,
562
+ instructionEmpty,
563
+ attachments: attachments.map((a) => ({
564
+ provenancePath: a.provenancePath,
565
+ frozenPath: a.frozenPath,
566
+ byteLength: a.byteLength,
567
+ sha256: a.sha256,
568
+ mediaKind: a.mediaKind,
569
+ })),
570
+ };
571
+ const admittedRequestPath = join(runDirectory, "admitted-request.json");
572
+ await writeFile(admittedRequestPath, `${JSON.stringify(admitted, null, 2)}\n`, "utf8");
573
+ await writeRoleInvocationLedger(admitted, admitted.role);
574
+
575
+ return {
576
+ role: "judge",
577
+ runId,
578
+ bookKey,
579
+ projectRoot,
580
+ instruction,
581
+ instructionEmpty,
582
+ attachments,
583
+ runDirectory,
584
+ sessionDirectory,
585
+ sessionFile,
586
+ admittedRequestPath,
587
+ };
588
+ }
589
+
590
+ /** Build the Pi prompt transport for an admitted Judge request. */
591
+ export function buildJudgeTransportPrompt(
592
+ admitted: AdmittedJudgeInvocation,
593
+ ): string {
594
+ const lines: string[] = [admitted.instructionEmpty ? "" : admitted.instruction];
595
+ if (admitted.attachments.length > 0) {
596
+ lines.push("");
597
+ lines.push("Admitted Attachments (frozen snapshot paths; read these bytes):");
598
+ for (const attachment of admitted.attachments) {
599
+ lines.push(`- ${attachment.frozenPath}`);
600
+ }
601
+ }
602
+ return lines.join("\n");
603
+ }
604
+
605
+ /** Load admitted-request.json written at admission (Navigator work-context seam). */
606
+ export async function loadAdmittedJudgeRequest(
607
+ runDirectory: string,
608
+ ): Promise<{
609
+ instruction: string;
610
+ instructionEmpty: boolean;
611
+ attachments: readonly FrozenAttachment[];
612
+ } | undefined> {
613
+ try {
614
+ const raw = JSON.parse(
615
+ await readFile(join(runDirectory, "admitted-request.json"), "utf8"),
616
+ ) as unknown;
617
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return undefined;
618
+ const record = raw as Record<string, unknown>;
619
+ if (record.role !== "judge") return undefined;
620
+ if (typeof record.instruction !== "string") return undefined;
621
+ if (typeof record.instructionEmpty !== "boolean") return undefined;
622
+ if (!Array.isArray(record.attachments)) return undefined;
623
+ return {
624
+ instruction: record.instruction,
625
+ instructionEmpty: record.instructionEmpty,
626
+ attachments: record.attachments as FrozenAttachment[],
627
+ };
628
+ } catch {
629
+ return undefined;
630
+ }
631
+ }
632
+
633
+ export async function ensureRunArtifactsDir(runDirectory: string): Promise<string> {
634
+ const dir = join(runDirectory, "artifacts");
635
+ await mkdir(dir, { recursive: true });
636
+ return dir;
637
+ }
638
+
639
+ export type AdmitCoderInvocationOptions = {
640
+ home: string;
641
+ cwd: string;
642
+ phase: CoderPhase;
643
+ instruction: string;
644
+ attachmentPaths: readonly string[];
645
+ project?: string;
646
+ createRunId?: () => string;
647
+ };
648
+
649
+ /**
650
+ * Admit a Coder Role run on the common Invocation request.
651
+ * Nonblank task remains authoritative: blank instruction is a structural reject.
652
+ * Phase (default apply / explicit plan) is frozen into the admitted request.
653
+ */
654
+ export async function admitCoderInvocation(
655
+ options: AdmitCoderInvocationOptions,
656
+ ): Promise<AdmittedCoderInvocation> {
657
+ if (options.project !== undefined) {
658
+ requireOptionPath("--project", options.project);
659
+ }
660
+ const instruction = options.instruction;
661
+ if (instruction.trim() === "") {
662
+ throw new CliUsageError(
663
+ "coder requires a nonblank task instruction",
664
+ );
665
+ }
666
+ if (options.phase !== "plan" && options.phase !== "apply") {
667
+ throw new CliUsageError("coder phase must be plan or apply");
668
+ }
669
+
670
+ const projectRoot = resolve(options.project ?? options.cwd);
671
+ const bookKey = resolveBookKeyFromGit(projectRoot);
672
+ const ledgerHome = resolveActivationLedgerHome(() => options.home);
673
+ const runId = (options.createRunId ?? uuidv7)();
674
+ const runDirectory = join(
675
+ activationBookDirectory(ledgerHome, bookKey),
676
+ "runs",
677
+ `${runId}@coder`,
678
+ );
679
+ const sessionDirectory = join(runDirectory, "session");
680
+ const sessionFile = roleRunSessionFile(sessionDirectory);
681
+ const attachmentsDirectory = join(runDirectory, "attachments");
682
+ ensureRealDirectoryTree(ledgerHome, sessionDirectory);
683
+ ensureRealDirectoryTree(ledgerHome, attachmentsDirectory);
684
+
685
+ const attachments: FrozenAttachment[] = [];
686
+ for (let i = 0; i < options.attachmentPaths.length; i += 1) {
687
+ attachments.push(
688
+ await freezeRegularFileAttachment(
689
+ options.attachmentPaths[i]!,
690
+ attachmentsDirectory,
691
+ i,
692
+ ),
693
+ );
694
+ }
695
+
696
+ const taskPath = join(runDirectory, "task.md");
697
+ await writeFile(taskPath, instruction, "utf8");
698
+
699
+ const admitted = {
700
+ role: "coder" as const,
701
+ phase: options.phase,
702
+ runId,
703
+ bookKey,
704
+ projectRoot,
705
+ runDirectory,
706
+ sessionDirectory,
707
+ sessionFile,
708
+ instruction,
709
+ instructionEmpty: false,
710
+ taskPath,
711
+ attachments: attachments.map((a) => ({
712
+ provenancePath: a.provenancePath,
713
+ frozenPath: a.frozenPath,
714
+ byteLength: a.byteLength,
715
+ sha256: a.sha256,
716
+ mediaKind: a.mediaKind,
717
+ })),
718
+ };
719
+ const admittedRequestPath = join(runDirectory, "admitted-request.json");
720
+ await writeFile(admittedRequestPath, `${JSON.stringify(admitted, null, 2)}\n`, "utf8");
721
+ await writeRoleInvocationLedger(admitted, admitted.role);
722
+
723
+ return {
724
+ role: "coder",
725
+ phase: options.phase,
726
+ runId,
727
+ bookKey,
728
+ projectRoot,
729
+ instruction,
730
+ instructionEmpty: false,
731
+ attachments,
732
+ runDirectory,
733
+ sessionDirectory,
734
+ sessionFile,
735
+ admittedRequestPath,
736
+ taskPath,
737
+ };
738
+ }
739
+
740
+ /**
741
+ * Build the Pi prompt transport for an admitted Coder request.
742
+ * Task bytes already live at taskPath for --ak-coder-task; the prompt carries
743
+ * the same instruction plus frozen Attachment paths.
744
+ */
745
+ export function buildCoderTransportPrompt(
746
+ admitted: AdmittedCoderInvocation,
747
+ ): string {
748
+ const lines: string[] = [admitted.instruction];
749
+ if (admitted.attachments.length > 0) {
750
+ lines.push("");
751
+ lines.push("Admitted Attachments (frozen snapshot paths; read these bytes):");
752
+ for (const attachment of admitted.attachments) {
753
+ lines.push(`- ${attachment.frozenPath}`);
754
+ }
755
+ }
756
+ return lines.join("\n");
757
+ }
758
+
759
+ export type AdmitFixerInvocationOptions = {
760
+ home: string;
761
+ cwd: string;
762
+ phase: FixerPhase;
763
+ instruction: string;
764
+ attachmentPaths: readonly string[];
765
+ /** Optional caller path to prerequisite JSON array; malformed grammar rejects here. */
766
+ prerequisitesPath?: string;
767
+ project?: string;
768
+ createRunId?: () => string;
769
+ };
770
+
771
+ /**
772
+ * Admit a Fixer Role run on the common Invocation request plus optional prerequisites.
773
+ * Nonblank instruction remains authoritative. Phase defaults to apply at parse time.
774
+ * Prerequisite grammar is structural; unmet/insufficient prerequisites stay Fixer judgments.
775
+ */
776
+ export async function admitFixerInvocation(
777
+ options: AdmitFixerInvocationOptions,
778
+ ): Promise<AdmittedFixerInvocation> {
779
+ if (options.project !== undefined) {
780
+ requireOptionPath("--project", options.project);
781
+ }
782
+ const instruction = options.instruction;
783
+ if (instruction.trim() === "") {
784
+ throw new CliUsageError(
785
+ "fixer requires a nonblank repair instruction",
786
+ );
787
+ }
788
+ if (options.phase !== "plan" && options.phase !== "apply") {
789
+ throw new CliUsageError("fixer phase must be plan or apply");
790
+ }
791
+
792
+ const projectRoot = resolve(options.project ?? options.cwd);
793
+ const bookKey = resolveBookKeyFromGit(projectRoot);
794
+ const ledgerHome = resolveActivationLedgerHome(() => options.home);
795
+ const runId = (options.createRunId ?? uuidv7)();
796
+ const runDirectory = join(
797
+ activationBookDirectory(ledgerHome, bookKey),
798
+ "runs",
799
+ `${runId}@fixer`,
800
+ );
801
+ const sessionDirectory = join(runDirectory, "session");
802
+ const sessionFile = roleRunSessionFile(sessionDirectory);
803
+ const attachmentsDirectory = join(runDirectory, "attachments");
804
+ ensureRealDirectoryTree(ledgerHome, sessionDirectory);
805
+ ensureRealDirectoryTree(ledgerHome, attachmentsDirectory);
806
+
807
+ const attachments: FrozenAttachment[] = [];
808
+ for (let i = 0; i < options.attachmentPaths.length; i += 1) {
809
+ attachments.push(
810
+ await freezeRegularFileAttachment(
811
+ options.attachmentPaths[i]!,
812
+ attachmentsDirectory,
813
+ i,
814
+ ),
815
+ );
816
+ }
817
+
818
+ let prerequisites: readonly FixerPrerequisite[] = Object.freeze([]);
819
+ let prerequisitesPath: string | undefined;
820
+ if (options.prerequisitesPath !== undefined) {
821
+ const absolutePrereq = isAbsolute(options.prerequisitesPath)
822
+ ? options.prerequisitesPath
823
+ : resolve(options.prerequisitesPath);
824
+ let source: string;
825
+ try {
826
+ source = await readFile(absolutePrereq, "utf8");
827
+ } catch (error) {
828
+ throw new CliUsageError(
829
+ `fixer prerequisites path is unreadable: ${options.prerequisitesPath}`,
830
+ { cause: error },
831
+ );
832
+ }
833
+ try {
834
+ prerequisites = parseFixerPrerequisites(source);
835
+ } catch (error) {
836
+ if (error instanceof FixerPacketValidationError) {
837
+ throw new CliUsageError(error.message, { cause: error });
838
+ }
839
+ throw error;
840
+ }
841
+ prerequisitesPath = join(runDirectory, "prerequisites.json");
842
+ await writeFile(
843
+ prerequisitesPath,
844
+ `${JSON.stringify(prerequisites, null, 2)}\n`,
845
+ "utf8",
846
+ );
847
+ }
848
+
849
+ const packetPath = join(runDirectory, "fix-packet.md");
850
+ await writeFile(packetPath, instruction, "utf8");
851
+
852
+ const admitted = {
853
+ role: "fixer" as const,
854
+ phase: options.phase,
855
+ runId,
856
+ bookKey,
857
+ projectRoot,
858
+ runDirectory,
859
+ sessionDirectory,
860
+ sessionFile,
861
+ instruction,
862
+ instructionEmpty: false,
863
+ packetPath,
864
+ ...(prerequisitesPath === undefined ? {} : { prerequisitesPath }),
865
+ prerequisites: prerequisites.map((entry) => ({
866
+ id: entry.id,
867
+ requirement: entry.requirement,
868
+ })),
869
+ attachments: attachments.map((a) => ({
870
+ provenancePath: a.provenancePath,
871
+ frozenPath: a.frozenPath,
872
+ byteLength: a.byteLength,
873
+ sha256: a.sha256,
874
+ mediaKind: a.mediaKind,
875
+ })),
876
+ };
877
+ const admittedRequestPath = join(runDirectory, "admitted-request.json");
878
+ await writeFile(admittedRequestPath, `${JSON.stringify(admitted, null, 2)}\n`, "utf8");
879
+ await writeRoleInvocationLedger(admitted, admitted.role);
880
+
881
+ return {
882
+ role: "fixer",
883
+ phase: options.phase,
884
+ runId,
885
+ bookKey,
886
+ projectRoot,
887
+ instruction,
888
+ instructionEmpty: false,
889
+ attachments,
890
+ runDirectory,
891
+ sessionDirectory,
892
+ sessionFile,
893
+ admittedRequestPath,
894
+ packetPath,
895
+ ...(prerequisitesPath === undefined ? {} : { prerequisitesPath }),
896
+ prerequisites,
897
+ };
898
+ }
899
+
900
+ /**
901
+ * Build the Pi prompt transport for an admitted Fixer request.
902
+ * Instruction bytes live at packetPath; prerequisites at optional path.
903
+ * Diagnosis method is available via package --skill, not forced into this prompt.
904
+ */
905
+ export function buildFixerTransportPrompt(
906
+ admitted: AdmittedFixerInvocation,
907
+ ): string {
908
+ const lines: string[] = [admitted.instruction];
909
+ if (admitted.attachments.length > 0) {
910
+ lines.push("");
911
+ lines.push("Admitted Attachments (frozen snapshot paths; read these bytes):");
912
+ for (const attachment of admitted.attachments) {
913
+ lines.push(`- ${attachment.frozenPath}`);
914
+ }
915
+ }
916
+ return lines.join("\n");
917
+ }
918
+
919
+ function parsePositivePrOption(raw: string | undefined): number {
920
+ if (raw === undefined || raw.trim() === "") throw new CliUsageError("--pr requires a positive pull request number");
921
+ try { return parseCollectorPrNumber(raw); } catch (error) { throw new CliUsageError(error instanceof Error ? error.message : String(error), { cause: error }); }
922
+ }
923
+ function parseRepoOption(raw: string | undefined): string {
924
+ if (raw === undefined || raw.trim() === "") throw new CliUsageError("--repo requires owner/repo");
925
+ return raw;
926
+ }
927
+ export function parseCollectorArgv(args: readonly string[]): ParseCollectorArgvResult {
928
+ const attachmentPaths: string[] = [];
929
+ let project: string | undefined;
930
+ let repo: string | undefined;
931
+ let prNumber: number | undefined;
932
+ let requestManifestPath: string | undefined;
933
+ const positional: string[] = [];
934
+ const tokens = [...args];
935
+ while (tokens.length > 0) {
936
+ const token = tokens.shift()!;
937
+ if (token === "--") { positional.push(...tokens); break; }
938
+ if (token === "--attach") { attachmentPaths.push(requireOptionPath("--attach", tokens.shift())); continue; }
939
+ if (token.startsWith("--attach=")) { attachmentPaths.push(requireOptionPath("--attach", token.slice(9))); continue; }
940
+ if (token === "--project") { project = requireOptionPath("--project", tokens.shift()); continue; }
941
+ if (token.startsWith("--project=")) { project = requireOptionPath("--project", token.slice(10)); continue; }
942
+ if (token === "--pr") { prNumber = parsePositivePrOption(tokens.shift()); continue; }
943
+ if (token.startsWith("--pr=")) { prNumber = parsePositivePrOption(token.slice(5)); continue; }
944
+ if (token === "--repo") { repo = parseRepoOption(tokens.shift()); continue; }
945
+ if (token.startsWith("--repo=")) { repo = parseRepoOption(token.slice(7)); continue; }
946
+ if (token === "--request-manifest") { requestManifestPath = requireOptionPath("--request-manifest", tokens.shift()); continue; }
947
+ if (token.startsWith("--request-manifest=")) { requestManifestPath = requireOptionPath("--request-manifest", token.slice(19)); continue; }
948
+ if (token.startsWith("-") && token !== "-") throw new CliUsageError(`unknown collector option: ${token}`);
949
+ positional.push(token);
950
+ }
951
+ if (prNumber === undefined) throw new CliUsageError("collector requires --pr <positive-integer>");
952
+ return { prNumber, instruction: positional.join(" "), attachmentPaths, ...(project === undefined ? {} : { project }), ...(repo === undefined ? {} : { repo }), ...(requestManifestPath === undefined ? {} : { requestManifestPath }) };
953
+ }
954
+
955
+ /**
956
+ * Resolve owner/repo from the project's `origin` remote (github.com only).
957
+ * Supports https and SSH GitHub URL shapes; never scrapes instruction prose.
958
+ */
959
+ export function resolveGitHubRemoteRepository(
960
+ projectRoot: string,
961
+ ): CollectorRepository {
962
+ let remoteUrl: string;
963
+ try {
964
+ remoteUrl = execFileSync("git", ["remote", "get-url", "origin"], {
965
+ cwd: projectRoot,
966
+ encoding: "utf8",
967
+ stdio: ["ignore", "pipe", "pipe"],
968
+ }).trim();
969
+ } catch (error) {
970
+ throw new CliUsageError(
971
+ "collector requires a github.com origin remote or an explicit --repo owner/repo",
972
+ { cause: error },
973
+ );
974
+ }
975
+ if (remoteUrl.length === 0) {
976
+ throw new CliUsageError(
977
+ "collector requires a github.com origin remote or an explicit --repo owner/repo",
978
+ );
979
+ }
980
+
981
+ const ownerRepo = ownerRepoFromGitHubRemoteUrl(remoteUrl);
982
+ if (ownerRepo === undefined) {
983
+ throw new CliUsageError(
984
+ `collector origin remote must be a github.com owner/repo URL, got ${remoteUrl}`,
985
+ );
986
+ }
987
+ try {
988
+ return parseCollectorRepository(ownerRepo);
989
+ } catch (error) {
990
+ const detail = error instanceof Error ? error.message : String(error);
991
+ throw new CliUsageError(detail, { cause: error });
992
+ }
993
+ }
994
+
995
+ function ownerRepoFromGitHubRemoteUrl(remoteUrl: string): string | undefined {
996
+ const trimmed = remoteUrl.trim();
997
+ // git@github.com:owner/repo.git — exact owner/repo identity only.
998
+ const scp = /^git@github\.com:([^/\s]+)\/([^/\s]+?)(?:\.git)?$/i.exec(trimmed);
999
+ if (scp) {
1000
+ return `${scp[1]}/${stripGitSuffix(scp[2]!)}`;
1001
+ }
1002
+ // ssh://git@github.com/owner/repo(.git) — exact owner/repo identity only.
1003
+ const ssh = /^ssh:\/\/git@github\.com\/([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/i.exec(
1004
+ trimmed,
1005
+ );
1006
+ if (ssh) {
1007
+ return `${ssh[1]}/${stripGitSuffix(ssh[2]!)}`;
1008
+ }
1009
+ // https://github.com/owner/repo(.git) and git://github.com/... — exact two-segment path.
1010
+ let parsed: URL;
1011
+ try {
1012
+ parsed = new URL(trimmed);
1013
+ } catch {
1014
+ return undefined;
1015
+ }
1016
+ if (!/^github\.com$/i.test(parsed.hostname)) return undefined;
1017
+ // Non-identity URL material (query/hash/extra path) is not a repository remote.
1018
+ if (parsed.search !== "" || parsed.hash !== "") return undefined;
1019
+ const parts = parsed.pathname.split("/").filter((p) => p.length > 0);
1020
+ if (parts.length !== 2) return undefined;
1021
+ return `${parts[0]}/${stripGitSuffix(parts[1]!)}`;
1022
+ }
1023
+
1024
+ function stripGitSuffix(name: string): string {
1025
+ return name.toLowerCase().endsWith(".git") ? name.slice(0, -4) : name;
1026
+ }
1027
+
1028
+ export type AdmitCollectorInvocationOptions = {
1029
+ home: string;
1030
+ cwd: string;
1031
+ prNumber: number;
1032
+ instruction?: string;
1033
+ attachmentPaths?: readonly string[];
1034
+ project?: string;
1035
+ /** Explicit owner/repo override; defaults from project origin remote. */
1036
+ repo?: string;
1037
+ /** Optional public request configuration; copied into the admitted run. */
1038
+ requestManifestPath?: string;
1039
+ createRunId?: () => string;
1040
+ };
1041
+
1042
+ /**
1043
+ * Admit a Collector Role run: assemble the retained leg manifest from typed
1044
+ * declarations, resolve repository identity, and place the session under #78.
1045
+ * Does not preflight PR/author existence against GitHub.
1046
+ */
1047
+ export async function admitCollectorInvocation(
1048
+ options: AdmitCollectorInvocationOptions,
1049
+ ): Promise<AdmittedCollectorInvocation> {
1050
+ if (options.project !== undefined) {
1051
+ requireOptionPath("--project", options.project);
1052
+ }
1053
+ let prNumber: number;
1054
+ try {
1055
+ prNumber = parseCollectorPrNumber(options.prNumber);
1056
+ } catch (error) {
1057
+ const detail = error instanceof Error ? error.message : String(error);
1058
+ throw new CliUsageError(detail, { cause: error });
1059
+ }
1060
+
1061
+ const projectRoot = resolve(options.project ?? options.cwd);
1062
+ let repository: CollectorRepository;
1063
+ if (options.repo !== undefined) {
1064
+ try {
1065
+ repository = parseCollectorRepository(options.repo);
1066
+ } catch (error) {
1067
+ const detail = error instanceof Error ? error.message : String(error);
1068
+ throw new CliUsageError(detail, { cause: error });
1069
+ }
1070
+ } else {
1071
+ repository = resolveGitHubRemoteRepository(projectRoot);
1072
+ }
1073
+
1074
+ const bookKey = resolveBookKeyFromGit(projectRoot);
1075
+ const ledgerHome = resolveActivationLedgerHome(() => options.home);
1076
+ const runId = (options.createRunId ?? uuidv7)();
1077
+ const runDirectory = join(
1078
+ activationBookDirectory(ledgerHome, bookKey),
1079
+ "runs",
1080
+ `${runId}@collector`,
1081
+ );
1082
+ const sessionDirectory = join(runDirectory, "session");
1083
+ const sessionFile = roleRunSessionFile(sessionDirectory);
1084
+ const attachmentsDirectory = join(runDirectory, "attachments");
1085
+ ensureRealDirectoryTree(ledgerHome, sessionDirectory);
1086
+ ensureRealDirectoryTree(ledgerHome, attachmentsDirectory);
1087
+
1088
+ const attachments: FrozenAttachment[] = [];
1089
+ const attachmentPaths = options.attachmentPaths ?? [];
1090
+ for (let i = 0; i < attachmentPaths.length; i += 1) {
1091
+ attachments.push(
1092
+ await freezeRegularFileAttachment(
1093
+ attachmentPaths[i]!,
1094
+ attachmentsDirectory,
1095
+ i,
1096
+ ),
1097
+ );
1098
+ }
1099
+
1100
+ let manifest = emptyCollectorManifest();
1101
+ let requestManifestPath: string | undefined;
1102
+ if (options.requestManifestPath !== undefined) {
1103
+ try {
1104
+ manifest = await loadCollectorManifest(options.requestManifestPath);
1105
+ } catch (error) {
1106
+ throw new CliUsageError(error instanceof Error ? error.message : String(error), { cause: error });
1107
+ }
1108
+ requestManifestPath = join(runDirectory, "request-manifest.json");
1109
+ await writeFile(requestManifestPath, manifest.canonicalJson, "utf8");
1110
+ }
1111
+ const manifestDigest = manifest.digest;
1112
+
1113
+ const instruction = options.instruction ?? "";
1114
+ const instructionEmpty = instruction.trim() === "";
1115
+ const admitted = {
1116
+ role: "collector" as const,
1117
+ runId,
1118
+ bookKey,
1119
+ projectRoot,
1120
+ runDirectory,
1121
+ sessionDirectory,
1122
+ sessionFile,
1123
+ instruction,
1124
+ instructionEmpty,
1125
+ prNumber,
1126
+ repository: repository.canonical,
1127
+ repositoryDisplay: repository.display,
1128
+ ...(requestManifestPath === undefined ? {} : { requestManifestPath }),
1129
+ manifestDigest,
1130
+ attachments: attachments.map((a) => ({
1131
+ provenancePath: a.provenancePath,
1132
+ frozenPath: a.frozenPath,
1133
+ byteLength: a.byteLength,
1134
+ sha256: a.sha256,
1135
+ mediaKind: a.mediaKind,
1136
+ })),
1137
+ };
1138
+ const admittedRequestPath = join(runDirectory, "admitted-request.json");
1139
+ await writeFile(
1140
+ admittedRequestPath,
1141
+ `${JSON.stringify(admitted, null, 2)}\n`,
1142
+ "utf8",
1143
+ );
1144
+ await writeRoleInvocationLedger(admitted, admitted.role);
1145
+
1146
+ return {
1147
+ role: "collector",
1148
+ runId,
1149
+ bookKey,
1150
+ projectRoot,
1151
+ instruction,
1152
+ instructionEmpty,
1153
+ attachments,
1154
+ runDirectory,
1155
+ sessionDirectory,
1156
+ sessionFile,
1157
+ admittedRequestPath,
1158
+ prNumber,
1159
+ repository,
1160
+ ...(requestManifestPath === undefined ? {} : { requestManifestPath }),
1161
+ manifestDigest,
1162
+ };
1163
+ }
1164
+
1165
+ /**
1166
+ * Collector always consumes the fixed packaged kickoff (one-shot observation).
1167
+ * Optional public instruction is retained only in the admitted request.
1168
+ */
1169
+ export function buildCollectorTransportPrompt(
1170
+ _admitted: AdmittedCollectorInvocation,
1171
+ ): string {
1172
+ return COLLECTOR_FIXED_KICKOFF;
1173
+ }
1174
+
1175
+ /** Positive Issue number grammar shared with Doctor case path identity. */
1176
+ const DOCTOR_ISSUE_NUMBER_PATTERN = /^[1-9]\d*$/;
1177
+
1178
+ /** Match retained Doctor case runs roots (ADR 0017 / loadDoctorCase). */
1179
+ const DOCTOR_CASE_RUNS_PATH_PATTERN =
1180
+ /\/\.ak-roles\/books\/[^/]+\/issues\/([1-9]\d*)\/runs$/;
1181
+
1182
+ /**
1183
+ * Parse a positive Issue number for public Doctor admission.
1184
+ * Leading zeros and non-integers are structural rejects.
1185
+ */
1186
+ export function parseDoctorIssueNumber(raw: string): number {
1187
+ const trimmed = raw.trim();
1188
+ if (!DOCTOR_ISSUE_NUMBER_PATTERN.test(trimmed)) {
1189
+ throw new CliUsageError(
1190
+ `doctor --issue must be a positive integer, got ${raw}`,
1191
+ );
1192
+ }
1193
+ return Number(trimmed);
1194
+ }
1195
+
1196
+ /**
1197
+ * Parse Doctor-specific argv after the `doctor` token.
1198
+ * Requires --issue; optional confined --runs override; common --attach/--project.
1199
+ */
1200
+ export function parseDoctorArgv(args: readonly string[]): ParseDoctorArgvResult {
1201
+ const attachmentPaths: string[] = [];
1202
+ let project: string | undefined;
1203
+ let issueRaw: string | undefined;
1204
+ let runs: string | undefined;
1205
+ const positional: string[] = [];
1206
+ const tokens = [...args];
1207
+
1208
+ while (tokens.length > 0) {
1209
+ const token = tokens.shift()!;
1210
+ if (token === "--") {
1211
+ positional.push(...tokens);
1212
+ break;
1213
+ }
1214
+ if (token === "--issue") {
1215
+ const value = tokens.shift();
1216
+ if (value === undefined || value.trim() === "") {
1217
+ throw new CliUsageError("doctor --issue requires a positive integer");
1218
+ }
1219
+ issueRaw = value;
1220
+ continue;
1221
+ }
1222
+ if (token.startsWith("--issue=")) {
1223
+ issueRaw = token.slice("--issue=".length);
1224
+ if (issueRaw.trim() === "") {
1225
+ throw new CliUsageError("doctor --issue requires a positive integer");
1226
+ }
1227
+ continue;
1228
+ }
1229
+ if (token === "--runs") {
1230
+ const value = tokens.shift();
1231
+ if (value === undefined || value.trim() === "") {
1232
+ throw new CliUsageError("doctor --runs requires a path");
1233
+ }
1234
+ runs = value;
1235
+ continue;
1236
+ }
1237
+ if (token.startsWith("--runs=")) {
1238
+ const value = token.slice("--runs=".length);
1239
+ if (value.trim() === "") {
1240
+ throw new CliUsageError("doctor --runs requires a path");
1241
+ }
1242
+ runs = value;
1243
+ continue;
1244
+ }
1245
+ if (token === "--attach") {
1246
+ attachmentPaths.push(requireOptionPath("--attach", tokens.shift()));
1247
+ continue;
1248
+ }
1249
+ if (token.startsWith("--attach=")) {
1250
+ attachmentPaths.push(
1251
+ requireOptionPath("--attach", token.slice("--attach=".length)),
1252
+ );
1253
+ continue;
1254
+ }
1255
+ if (token === "--project") {
1256
+ project = requireOptionPath("--project", tokens.shift());
1257
+ continue;
1258
+ }
1259
+ if (token.startsWith("--project=")) {
1260
+ project = requireOptionPath("--project", token.slice("--project=".length));
1261
+ continue;
1262
+ }
1263
+ if (token.startsWith("-") && token !== "-") {
1264
+ throw new CliUsageError(`unknown doctor option: ${token}`);
1265
+ }
1266
+ positional.push(token);
1267
+ }
1268
+
1269
+ if (issueRaw === undefined) {
1270
+ throw new CliUsageError("doctor requires --issue <positive-integer>");
1271
+ }
1272
+ const issueNumber = parseDoctorIssueNumber(issueRaw);
1273
+
1274
+ if (runs !== undefined && runs.trim() === "") {
1275
+ throw new CliUsageError("doctor --runs requires a path");
1276
+ }
1277
+
1278
+ return {
1279
+ issueNumber,
1280
+ instruction: positional.join(" "),
1281
+ attachmentPaths,
1282
+ ...(project === undefined ? {} : { project }),
1283
+ ...(runs === undefined ? {} : { runs }),
1284
+ };
1285
+ }
1286
+
1287
+ export type AdmitDoctorInvocationOptions = {
1288
+ home: string;
1289
+ cwd: string;
1290
+ issueNumber: number;
1291
+ /** Optional project-relative retained runs root override. */
1292
+ runs?: string;
1293
+ instruction?: string;
1294
+ attachmentPaths?: readonly string[];
1295
+ project?: string;
1296
+ createRunId?: () => string;
1297
+ };
1298
+
1299
+ /**
1300
+ * Resolve the retained Doctor case runs root from Issue identity.
1301
+ * Default is the #78 book locator; optional --runs must stay project-confined
1302
+ * and match Doctor case grammar for the same issue number.
1303
+ */
1304
+ export async function resolveDoctorCaseRunsPath(options: {
1305
+ home: string;
1306
+ projectRoot: string;
1307
+ bookKey: string;
1308
+ issueNumber: number;
1309
+ runs?: string;
1310
+ }): Promise<string> {
1311
+ const ledgerHome = resolveActivationLedgerHome(() => options.home);
1312
+ const defaultRuns = join(
1313
+ activationBookDirectory(ledgerHome, options.bookKey),
1314
+ "issues",
1315
+ String(options.issueNumber),
1316
+ "runs",
1317
+ );
1318
+
1319
+ if (options.runs === undefined) {
1320
+ return defaultRuns;
1321
+ }
1322
+
1323
+ const raw = options.runs.trim();
1324
+ if (raw === "") {
1325
+ throw new CliUsageError("doctor --runs requires a path");
1326
+ }
1327
+ // Project-relative only — absolute overrides would bypass confinement.
1328
+ if (isAbsolute(raw)) {
1329
+ throw new CliUsageError(
1330
+ "doctor --runs must be a project-relative path",
1331
+ );
1332
+ }
1333
+ const resolved = resolve(options.projectRoot, raw);
1334
+ if (
1335
+ resolved !== options.projectRoot &&
1336
+ !pathContainedIn(options.projectRoot, resolved)
1337
+ ) {
1338
+ throw new CliUsageError(
1339
+ "doctor --runs escapes the project root",
1340
+ );
1341
+ }
1342
+
1343
+ let real: string;
1344
+ try {
1345
+ real = await realpath(resolved);
1346
+ } catch (error) {
1347
+ const detail = error instanceof Error ? error.message : String(error);
1348
+ throw new CliUsageError(
1349
+ `doctor --runs is not a readable retained runs root: ${detail}`,
1350
+ { cause: error },
1351
+ );
1352
+ }
1353
+
1354
+ const normalized = real.split(sep).join("/");
1355
+ const match = normalized.match(DOCTOR_CASE_RUNS_PATH_PATTERN);
1356
+ if (!match) {
1357
+ throw new CliUsageError(
1358
+ "doctor --runs must be an .ak-roles/books/<book>/issues/<n>/runs directory",
1359
+ );
1360
+ }
1361
+ if (Number(match[1]) !== options.issueNumber) {
1362
+ throw new CliUsageError(
1363
+ `doctor --runs issue ${match[1]} does not match --issue ${options.issueNumber}`,
1364
+ );
1365
+ }
1366
+ return real;
1367
+ }
1368
+
1369
+ /**
1370
+ * Admit a Doctor Role run: resolve Issue → retained runs root via #78 (or a
1371
+ * confined override), construct the structurally exact case identity through
1372
+ * loadDoctorCase, and place the Doctor session under the book runs lane.
1373
+ * Does not copy session content into a second store.
1374
+ */
1375
+ export async function admitDoctorInvocation(
1376
+ options: AdmitDoctorInvocationOptions,
1377
+ ): Promise<AdmittedDoctorInvocation> {
1378
+ if (options.project !== undefined) {
1379
+ requireOptionPath("--project", options.project);
1380
+ }
1381
+ if (
1382
+ !Number.isInteger(options.issueNumber) ||
1383
+ options.issueNumber < 1 ||
1384
+ !DOCTOR_ISSUE_NUMBER_PATTERN.test(String(options.issueNumber))
1385
+ ) {
1386
+ throw new CliUsageError(
1387
+ `doctor --issue must be a positive integer, got ${options.issueNumber}`,
1388
+ );
1389
+ }
1390
+
1391
+ const projectRoot = resolve(options.project ?? options.cwd);
1392
+ const bookKey = resolveBookKeyFromGit(projectRoot);
1393
+ const ledgerHome = resolveActivationLedgerHome(() => options.home);
1394
+
1395
+ let caseRunsPath: string;
1396
+ try {
1397
+ caseRunsPath = await resolveDoctorCaseRunsPath({
1398
+ home: options.home,
1399
+ projectRoot,
1400
+ bookKey,
1401
+ issueNumber: options.issueNumber,
1402
+ ...(options.runs === undefined ? {} : { runs: options.runs }),
1403
+ });
1404
+ } catch (error) {
1405
+ if (error instanceof CliUsageError) throw error;
1406
+ const detail = error instanceof Error ? error.message : String(error);
1407
+ throw new CliUsageError(detail, { cause: error });
1408
+ }
1409
+
1410
+ // Default #78 locator may not exist yet — ensure the empty runs root so
1411
+ // loadDoctorCase can form an empty case and Doctor's refusal owns insufficiency.
1412
+ if (options.runs === undefined) {
1413
+ ensureRealDirectoryTree(ledgerHome, caseRunsPath);
1414
+ }
1415
+
1416
+ let caseIdentity: DoctorCaseIdentity;
1417
+ try {
1418
+ const patient = await loadDoctorCase(caseRunsPath);
1419
+ if (patient.identity.issueNumber !== options.issueNumber) {
1420
+ throw new CliUsageError(
1421
+ `doctor case issue ${patient.identity.issueNumber} does not match --issue ${options.issueNumber}`,
1422
+ );
1423
+ }
1424
+ caseIdentity = patient.identity;
1425
+ caseRunsPath = await realpath(caseRunsPath);
1426
+ } catch (error) {
1427
+ if (error instanceof CliUsageError) throw error;
1428
+ const detail = error instanceof Error ? error.message : String(error);
1429
+ throw new CliUsageError(
1430
+ `doctor case could not be constructed from retained evidence: ${detail}`,
1431
+ { cause: error },
1432
+ );
1433
+ }
1434
+
1435
+ const runId = (options.createRunId ?? uuidv7)();
1436
+ const runDirectory = join(
1437
+ activationBookDirectory(ledgerHome, bookKey),
1438
+ "runs",
1439
+ `${runId}@doctor`,
1440
+ );
1441
+ const sessionDirectory = join(runDirectory, "session");
1442
+ const sessionFile = roleRunSessionFile(sessionDirectory);
1443
+ const attachmentsDirectory = join(runDirectory, "attachments");
1444
+ ensureRealDirectoryTree(ledgerHome, sessionDirectory);
1445
+ ensureRealDirectoryTree(ledgerHome, attachmentsDirectory);
1446
+
1447
+ const attachments: FrozenAttachment[] = [];
1448
+ const attachmentPaths = options.attachmentPaths ?? [];
1449
+ for (let i = 0; i < attachmentPaths.length; i += 1) {
1450
+ attachments.push(
1451
+ await freezeRegularFileAttachment(
1452
+ attachmentPaths[i]!,
1453
+ attachmentsDirectory,
1454
+ i,
1455
+ ),
1456
+ );
1457
+ }
1458
+
1459
+ const instruction = options.instruction ?? "";
1460
+ const instructionEmpty = instruction.trim() === "";
1461
+ const admitted = {
1462
+ role: "doctor" as const,
1463
+ runId,
1464
+ bookKey,
1465
+ projectRoot,
1466
+ runDirectory,
1467
+ sessionDirectory,
1468
+ sessionFile,
1469
+ instruction,
1470
+ instructionEmpty,
1471
+ issueNumber: options.issueNumber,
1472
+ caseRunsPath,
1473
+ caseIdentity,
1474
+ attachments: attachments.map((a) => ({
1475
+ provenancePath: a.provenancePath,
1476
+ frozenPath: a.frozenPath,
1477
+ byteLength: a.byteLength,
1478
+ sha256: a.sha256,
1479
+ mediaKind: a.mediaKind,
1480
+ })),
1481
+ };
1482
+ const admittedRequestPath = join(runDirectory, "admitted-request.json");
1483
+ await writeFile(
1484
+ admittedRequestPath,
1485
+ `${JSON.stringify(admitted, null, 2)}\n`,
1486
+ "utf8",
1487
+ );
1488
+ await writeRoleInvocationLedger(admitted, admitted.role);
1489
+
1490
+ return {
1491
+ role: "doctor",
1492
+ runId,
1493
+ bookKey,
1494
+ projectRoot,
1495
+ instruction,
1496
+ instructionEmpty,
1497
+ attachments,
1498
+ runDirectory,
1499
+ sessionDirectory,
1500
+ sessionFile,
1501
+ admittedRequestPath,
1502
+ issueNumber: options.issueNumber,
1503
+ caseRunsPath,
1504
+ caseIdentity,
1505
+ };
1506
+ }
1507
+
1508
+ /** Build the Pi prompt transport for an admitted Doctor request. */
1509
+ export function buildDoctorTransportPrompt(
1510
+ admitted: AdmittedDoctorInvocation,
1511
+ ): string {
1512
+ const lines: string[] = [admitted.instructionEmpty ? "" : admitted.instruction];
1513
+ if (admitted.attachments.length > 0) {
1514
+ lines.push("");
1515
+ lines.push("Admitted Attachments (frozen snapshot paths; read these bytes):");
1516
+ for (const attachment of admitted.attachments) {
1517
+ lines.push(`- ${attachment.frozenPath}`);
1518
+ }
1519
+ }
1520
+ return lines.join("\n");
1521
+ }
1522
+
1523
+ /**
1524
+ * Parse Reviewer-specific argv after the `reviewer` token.
1525
+ * Public flags: --project and required --base (the non-interactive CLI cannot answer the canonical fixed-point question).
1526
+ * Reviewer gathers its own evidence; users submit neither attachments nor capability packets.
1527
+ */
1528
+ export function parseReviewerArgv(
1529
+ args: readonly string[],
1530
+ ): ParseReviewerArgvResult {
1531
+ const attachmentPaths: string[] = [];
1532
+ let project: string | undefined;
1533
+ let baseRevision: string | undefined;
1534
+ const positional: string[] = [];
1535
+ const tokens = [...args];
1536
+
1537
+ while (tokens.length > 0) {
1538
+ const token = tokens.shift()!;
1539
+ if (token === "--") {
1540
+ positional.push(...tokens);
1541
+ break;
1542
+ }
1543
+ if (token === "--project") {
1544
+ project = requireOptionPath("--project", tokens.shift());
1545
+ continue;
1546
+ }
1547
+ if (token.startsWith("--project=")) {
1548
+ project = requireOptionPath("--project", token.slice("--project=".length));
1549
+ continue;
1550
+ }
1551
+ if (token === "--base") {
1552
+ baseRevision = requireOptionPath("--base", tokens.shift());
1553
+ continue;
1554
+ }
1555
+ if (token.startsWith("--base=")) {
1556
+ baseRevision = requireOptionPath("--base", token.slice("--base=".length));
1557
+ continue;
1558
+ }
1559
+ if (token.startsWith("-") && token !== "-") {
1560
+ throw new CliUsageError(`unknown reviewer option: ${token}`);
1561
+ }
1562
+ positional.push(token);
1563
+ }
1564
+
1565
+ if (baseRevision === undefined) {
1566
+ throw new CliUsageError("reviewer requires --base <revision>; canonical code-review requires the caller to select a fixed point");
1567
+ }
1568
+ return {
1569
+ instruction: positional.join(" "),
1570
+ attachmentPaths,
1571
+ baseRevision,
1572
+ ...(project === undefined ? {} : { project }),
1573
+ };
1574
+ }
1575
+
1576
+ export type AdmitReviewerInvocationOptions = {
1577
+ home: string;
1578
+ cwd: string;
1579
+ /** Optional caller prose retained only as admitted provenance — never semantic control. */
1580
+ instruction: string;
1581
+ attachmentPaths: readonly string[];
1582
+ baseRevision: string;
1583
+ project?: string;
1584
+ createRunId?: () => string;
1585
+ };
1586
+
1587
+ /**
1588
+ * Admit a Reviewer Role run on the fixed base only.
1589
+ * Caller instruction is optional provenance; Reviewer acquires issue/authority independently.
1590
+ */
1591
+ export async function admitReviewerInvocation(
1592
+ options: AdmitReviewerInvocationOptions,
1593
+ ): Promise<AdmittedReviewerInvocation> {
1594
+ if (options.project !== undefined) {
1595
+ requireOptionPath("--project", options.project);
1596
+ }
1597
+ if (options.baseRevision.trim() === "") {
1598
+ throw new CliUsageError("--base requires a nonempty revision");
1599
+ }
1600
+
1601
+ const projectRoot = resolve(options.project ?? options.cwd);
1602
+ const bookKey = resolveBookKeyFromGit(projectRoot);
1603
+ const ledgerHome = resolveActivationLedgerHome(() => options.home);
1604
+ const runId = (options.createRunId ?? uuidv7)();
1605
+ const runDirectory = join(
1606
+ activationBookDirectory(ledgerHome, bookKey),
1607
+ "runs",
1608
+ `${runId}@reviewer`,
1609
+ );
1610
+ const sessionDirectory = join(runDirectory, "session");
1611
+ const sessionFile = roleRunSessionFile(sessionDirectory);
1612
+ const attachmentsDirectory = join(runDirectory, "attachments");
1613
+ ensureRealDirectoryTree(ledgerHome, sessionDirectory);
1614
+ ensureRealDirectoryTree(ledgerHome, attachmentsDirectory);
1615
+
1616
+ // Public parse already rejects attachments; keep freeze loop for structural symmetry.
1617
+ const attachments: FrozenAttachment[] = [];
1618
+ for (let i = 0; i < options.attachmentPaths.length; i += 1) {
1619
+ attachments.push(
1620
+ await freezeRegularFileAttachment(
1621
+ options.attachmentPaths[i]!,
1622
+ attachmentsDirectory,
1623
+ i,
1624
+ ),
1625
+ );
1626
+ }
1627
+
1628
+ const instruction = options.instruction;
1629
+ const instructionEmpty = instruction.trim() === "";
1630
+ const admitted = {
1631
+ role: "reviewer" as const,
1632
+ runId,
1633
+ bookKey,
1634
+ projectRoot,
1635
+ runDirectory,
1636
+ sessionDirectory,
1637
+ sessionFile,
1638
+ instruction,
1639
+ instructionEmpty,
1640
+ baseRevision: options.baseRevision,
1641
+ attachments: attachments.map((a) => ({
1642
+ provenancePath: a.provenancePath,
1643
+ frozenPath: a.frozenPath,
1644
+ byteLength: a.byteLength,
1645
+ sha256: a.sha256,
1646
+ mediaKind: a.mediaKind,
1647
+ })),
1648
+ };
1649
+ const admittedRequestPath = join(runDirectory, "admitted-request.json");
1650
+ await writeFile(
1651
+ admittedRequestPath,
1652
+ `${JSON.stringify(admitted, null, 2)}\n`,
1653
+ "utf8",
1654
+ );
1655
+ await writeRoleInvocationLedger(admitted, admitted.role);
1656
+
1657
+ return {
1658
+ role: "reviewer",
1659
+ runId,
1660
+ bookKey,
1661
+ projectRoot,
1662
+ instruction,
1663
+ instructionEmpty,
1664
+ attachments,
1665
+ runDirectory,
1666
+ sessionDirectory,
1667
+ sessionFile,
1668
+ admittedRequestPath,
1669
+ baseRevision: options.baseRevision,
1670
+ };
1671
+ }
1672
+
1673
+ /**
1674
+ * Build the Pi prompt transport for an admitted Reviewer request.
1675
+ * Semantic input is fixed base only — caller instruction stays provenance on disk.
1676
+ */
1677
+ export function buildReviewerTransportPrompt(
1678
+ admitted: AdmittedReviewerInvocation,
1679
+ ): string {
1680
+ return [
1681
+ `Base revision for the fixed review target: ${admitted.baseRevision}`,
1682
+ "Use this exact revision as the fixed review point.",
1683
+ ].join("\n");
1684
+ }
1685
+
1686
+ /**
1687
+ * Parse Merger-specific argv after the `merger` token.
1688
+ * Common Invocation flags only: --attach / --project.
1689
+ * Parents, conflicts, scope, and internal merger-input are never public fields.
1690
+ */
1691
+ export function parseMergerArgv(args: readonly string[]): ParseMergerArgvResult {
1692
+ const attachmentPaths: string[] = [];
1693
+ let project: string | undefined;
1694
+ const positional: string[] = [];
1695
+ const tokens = [...args];
1696
+
1697
+ while (tokens.length > 0) {
1698
+ const token = tokens.shift()!;
1699
+ if (token === "--") {
1700
+ positional.push(...tokens);
1701
+ break;
1702
+ }
1703
+ if (token === "--attach") {
1704
+ attachmentPaths.push(requireOptionPath("--attach", tokens.shift()));
1705
+ continue;
1706
+ }
1707
+ if (token.startsWith("--attach=")) {
1708
+ attachmentPaths.push(
1709
+ requireOptionPath("--attach", token.slice("--attach=".length)),
1710
+ );
1711
+ continue;
1712
+ }
1713
+ if (token === "--project") {
1714
+ project = requireOptionPath("--project", tokens.shift());
1715
+ continue;
1716
+ }
1717
+ if (token.startsWith("--project=")) {
1718
+ project = requireOptionPath("--project", token.slice("--project=".length));
1719
+ continue;
1720
+ }
1721
+ // Reject internal / mechanical packet fields on the public face.
1722
+ if (
1723
+ token === "--ak-merger-input" ||
1724
+ token.startsWith("--ak-merger-input=") ||
1725
+ token === "--targetObjectId" ||
1726
+ token.startsWith("--targetObjectId=") ||
1727
+ token === "--sourceObjectId" ||
1728
+ token.startsWith("--sourceObjectId=") ||
1729
+ token === "--expectedConflictPaths" ||
1730
+ token.startsWith("--expectedConflictPaths=") ||
1731
+ token === "--resolutionScope" ||
1732
+ token.startsWith("--resolutionScope=")
1733
+ ) {
1734
+ throw new CliUsageError(
1735
+ "merger does not accept public packet fields; the adapter derives the active-merge envelope",
1736
+ );
1737
+ }
1738
+ if (token.startsWith("-") && token !== "-") {
1739
+ throw new CliUsageError(`unknown merger option: ${token}`);
1740
+ }
1741
+ positional.push(token);
1742
+ }
1743
+
1744
+ return {
1745
+ instruction: positional.join(" "),
1746
+ attachmentPaths,
1747
+ ...(project === undefined ? {} : { project }),
1748
+ };
1749
+ }
1750
+
1751
+ function mergerMaterialFromUtf8(text: string): MergerInput["materials"]["task"] {
1752
+ const bytes = Buffer.from(text, "utf8");
1753
+ return Object.freeze({
1754
+ bytesBase64: bytes.toString("base64"),
1755
+ sha256: sha256Hex(bytes),
1756
+ });
1757
+ }
1758
+
1759
+ /**
1760
+ * Derive the mechanical Merger envelope from an already-active ordinary merge.
1761
+ * Uses the production Git seam (HEAD, sole MERGE_HEAD, AUTO_MERGE, unmerged set).
1762
+ * Failures are activation-class facts — not CLI semantic guesses.
1763
+ */
1764
+ export async function deriveMergerEnvelopeFromActiveMerge(
1765
+ projectRoot: string,
1766
+ gitState: MergerGitState = createProductionMergerGitState(projectRoot),
1767
+ ): Promise<DerivedMergerEnvelope> {
1768
+ let state;
1769
+ try {
1770
+ state = await gitState.activeMerge();
1771
+ } catch (error) {
1772
+ const message =
1773
+ error instanceof Error && error.message.trim() !== ""
1774
+ ? error.message
1775
+ : "Assigned repository does not have one ordinary in-progress merge";
1776
+ throw new MergerEnvelopeDerivationError(message, { cause: error });
1777
+ }
1778
+ if (state.unmergedPaths.length === 0) {
1779
+ throw new MergerEnvelopeDerivationError(
1780
+ "Assigned repository does not have one ordinary in-progress merge with a complete conflict set",
1781
+ );
1782
+ }
1783
+ const expectedConflictPaths = Object.freeze([...state.unmergedPaths]);
1784
+ // Scope is derived as the complete conflict set; the role may not broaden it.
1785
+ const resolutionScope = Object.freeze([...state.unmergedPaths]);
1786
+ return Object.freeze({
1787
+ targetObjectId: state.targetObjectId,
1788
+ sourceObjectId: state.sourceObjectId,
1789
+ automaticMergeTreeId: state.automaticMergeTreeId,
1790
+ expectedConflictPaths,
1791
+ resolutionScope,
1792
+ });
1793
+ }
1794
+
1795
+ export type AdmitMergerInvocationOptions = {
1796
+ home: string;
1797
+ cwd: string;
1798
+ instruction: string;
1799
+ attachmentPaths: readonly string[];
1800
+ project?: string;
1801
+ createRunId?: () => string;
1802
+ /** Test seam; production binds createProductionMergerGitState(projectRoot). */
1803
+ gitState?: MergerGitState;
1804
+ };
1805
+
1806
+ /**
1807
+ * Admit a Merger Role run on the common Invocation request.
1808
+ * Mechanical envelope (parents, AUTO_MERGE, conflicts, scope) is derived from
1809
+ * the active merge — callers never supply public packet fields for those facts.
1810
+ */
1811
+ export async function admitMergerInvocation(
1812
+ options: AdmitMergerInvocationOptions,
1813
+ ): Promise<AdmittedMergerInvocation> {
1814
+ if (options.project !== undefined) {
1815
+ requireOptionPath("--project", options.project);
1816
+ }
1817
+ const instruction = options.instruction;
1818
+ if (instruction.trim() === "") {
1819
+ throw new CliUsageError("merger requires a nonblank task instruction");
1820
+ }
1821
+
1822
+ const projectRoot = resolve(options.project ?? options.cwd);
1823
+ // Derive mechanical envelope before placing a run identity so no-merge/drift
1824
+ // fails honestly without orphan ledger rows or guessed packet fields.
1825
+ const derived = await deriveMergerEnvelopeFromActiveMerge(
1826
+ projectRoot,
1827
+ options.gitState ?? createProductionMergerGitState(projectRoot),
1828
+ );
1829
+
1830
+ const bookKey = resolveBookKeyFromGit(projectRoot);
1831
+ const ledgerHome = resolveActivationLedgerHome(() => options.home);
1832
+ const runId = (options.createRunId ?? uuidv7)();
1833
+ const runDirectory = join(
1834
+ activationBookDirectory(ledgerHome, bookKey),
1835
+ "runs",
1836
+ `${runId}@merger`,
1837
+ );
1838
+ const sessionDirectory = join(runDirectory, "session");
1839
+ const sessionFile = roleRunSessionFile(sessionDirectory);
1840
+ const attachmentsDirectory = join(runDirectory, "attachments");
1841
+ ensureRealDirectoryTree(ledgerHome, sessionDirectory);
1842
+ ensureRealDirectoryTree(ledgerHome, attachmentsDirectory);
1843
+
1844
+ const attachments: FrozenAttachment[] = [];
1845
+ for (let i = 0; i < options.attachmentPaths.length; i += 1) {
1846
+ attachments.push(
1847
+ await freezeRegularFileAttachment(
1848
+ options.attachmentPaths[i]!,
1849
+ attachmentsDirectory,
1850
+ i,
1851
+ ),
1852
+ );
1853
+ }
1854
+
1855
+ // Intent materials seed primary-source investigation; the method owns the work.
1856
+ const targetIntent = mergerMaterialFromUtf8(
1857
+ `Investigate primary sources for target parent ${derived.targetObjectId}. Do not invent intent.`,
1858
+ );
1859
+ const sourceIntent = mergerMaterialFromUtf8(
1860
+ `Investigate primary sources for source parent ${derived.sourceObjectId}. Do not invent intent.`,
1861
+ );
1862
+ const taskMaterial = mergerMaterialFromUtf8(instruction);
1863
+ const authorityMaterial = mergerMaterialFromUtf8(instruction);
1864
+
1865
+ const mergerInput = validateMergerInput({
1866
+ version: 1,
1867
+ attemptId: runId,
1868
+ targetObjectId: derived.targetObjectId,
1869
+ sourceObjectId: derived.sourceObjectId,
1870
+ materials: {
1871
+ task: taskMaterial,
1872
+ authority: authorityMaterial,
1873
+ targetIntent,
1874
+ sourceIntent,
1875
+ },
1876
+ expectedConflictPaths: [...derived.expectedConflictPaths],
1877
+ resolutionScope: [...derived.resolutionScope],
1878
+ // Authorized checks remain available on the assignment; default none.
1879
+ authorizedChecks: [],
1880
+ });
1881
+
1882
+ const mergerInputPath = join(runDirectory, "merger-input.json");
1883
+ await writeFile(
1884
+ mergerInputPath,
1885
+ `${JSON.stringify(mergerInput, null, 2)}\n`,
1886
+ "utf8",
1887
+ );
1888
+
1889
+ const admitted = {
1890
+ role: "merger" as const,
1891
+ runId,
1892
+ bookKey,
1893
+ projectRoot,
1894
+ runDirectory,
1895
+ sessionDirectory,
1896
+ sessionFile,
1897
+ instruction,
1898
+ instructionEmpty: false,
1899
+ mergerInputPath,
1900
+ derived: {
1901
+ targetObjectId: derived.targetObjectId,
1902
+ sourceObjectId: derived.sourceObjectId,
1903
+ automaticMergeTreeId: derived.automaticMergeTreeId,
1904
+ expectedConflictPaths: [...derived.expectedConflictPaths],
1905
+ resolutionScope: [...derived.resolutionScope],
1906
+ },
1907
+ attachments: attachments.map((a) => ({
1908
+ provenancePath: a.provenancePath,
1909
+ frozenPath: a.frozenPath,
1910
+ byteLength: a.byteLength,
1911
+ sha256: a.sha256,
1912
+ mediaKind: a.mediaKind,
1913
+ })),
1914
+ };
1915
+ const admittedRequestPath = join(runDirectory, "admitted-request.json");
1916
+ await writeFile(
1917
+ admittedRequestPath,
1918
+ `${JSON.stringify(admitted, null, 2)}\n`,
1919
+ "utf8",
1920
+ );
1921
+ await writeRoleInvocationLedger(admitted, admitted.role);
1922
+
1923
+ return {
1924
+ role: "merger",
1925
+ runId,
1926
+ bookKey,
1927
+ projectRoot,
1928
+ instruction,
1929
+ instructionEmpty: false,
1930
+ attachments,
1931
+ runDirectory,
1932
+ sessionDirectory,
1933
+ sessionFile,
1934
+ admittedRequestPath,
1935
+ mergerInputPath,
1936
+ derived: admitted.derived,
1937
+ };
1938
+ }
1939
+
1940
+ /**
1941
+ * Build the Pi prompt transport for an admitted Merger request.
1942
+ * Every invocation forces package merge-only method expansion before conflict work.
1943
+ */
1944
+ export function buildMergerTransportPrompt(
1945
+ admitted: AdmittedMergerInvocation,
1946
+ ): string {
1947
+ const lines: string[] = [
1948
+ `/skill:resolving-merge-conflicts ${admitted.instruction}`,
1949
+ ];
1950
+ if (admitted.attachments.length > 0) {
1951
+ lines.push("");
1952
+ lines.push("Admitted Attachments (frozen snapshot paths; read these bytes):");
1953
+ for (const attachment of admitted.attachments) {
1954
+ lines.push(`- ${attachment.frozenPath}`);
1955
+ }
1956
+ }
1957
+ return lines.join("\n");
1958
+ }