@akagilnc/pi-workflow-roles 0.1.4062 → 0.1.4196

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 (72) hide show
  1. package/dist/acp-host/production-host.js +2075 -1088
  2. package/dist/archivist-record-entry.js +204 -20
  3. package/dist/archivist-record-topology.js +36 -16
  4. package/dist/auditor-dossier-tool.js +2 -1
  5. package/dist/diarist-contracts.js +96 -0
  6. package/dist/doctor-evidence.js +3 -2
  7. package/dist/headless-host/production-host.js +2215 -1228
  8. package/dist/migrate-book-topology.js +278917 -0
  9. package/dist/navigator-public-session.js +22 -25
  10. package/dist/notary-source-run.js +7 -4
  11. package/dist/pi/durable-principal.js +9 -6
  12. package/dist/public-cli/auto-resume.js +2 -5
  13. package/dist/public-cli/diarist-run.js +27 -25
  14. package/dist/public-cli/instruction-seat-run.js +8 -11
  15. package/dist/public-cli/invocation.js +127 -63
  16. package/dist/public-cli/main.js +1507 -645
  17. package/dist/public-cli/option-definitions.js +2 -2
  18. package/dist/public-cli/post-admission.js +47 -12
  19. package/dist/public-cli/run-lifecycle.js +206 -58
  20. package/dist/public-cli/seat-ticket-binding.js +3 -6
  21. package/dist/public-cli/settlement.js +5 -3
  22. package/dist/role-run-placement.js +84 -0
  23. package/dist/role-run-relocation.js +358 -0
  24. package/dist/run-terminal-artifacts.js +2 -1
  25. package/dist/run-ticket-number.js +51 -32
  26. package/dist/sitian-appender.js +11 -26
  27. package/dist/submission-ledger.js +78 -19
  28. package/dist/ticket-provenance.js +3 -1
  29. package/package.json +3 -2
  30. package/scripts/build-package.mjs +34 -2
  31. package/souls/countersign.md +2 -2
  32. package/souls/fixer.md +3 -2
  33. package/src/analyst-ledger.ts +14 -15
  34. package/src/archivist-record-entry.ts +285 -38
  35. package/src/archivist-record-topology.ts +43 -18
  36. package/src/auditor-dossier-tool.ts +2 -1
  37. package/src/book-topology-migration-placement.ts +493 -0
  38. package/src/book-topology-migration.ts +220 -0
  39. package/src/book-topology-migrators-t11.ts +856 -0
  40. package/src/book-topology-mixed-volume-migrators.ts +470 -0
  41. package/src/book-topology-partition-migrators.ts +25 -0
  42. package/src/book-topology-record-class-migrators.ts +779 -0
  43. package/src/book-topology-runs-migrator.ts +244 -0
  44. package/src/diarist-contracts.ts +116 -0
  45. package/src/diarist-role.ts +6 -0
  46. package/src/diarist.ts +2 -0
  47. package/src/doctor-evidence.ts +4 -1
  48. package/src/doctor-role.ts +1 -1
  49. package/src/navigator-public-session.ts +33 -30
  50. package/src/notary-source-run.ts +9 -4
  51. package/src/pi/durable-principal.ts +9 -13
  52. package/src/public-cli/auto-resume.ts +2 -6
  53. package/src/public-cli/countersign-run.ts +182 -32
  54. package/src/public-cli/diarist-run.ts +34 -26
  55. package/src/public-cli/instruction-seat-run.ts +7 -11
  56. package/src/public-cli/invocation.ts +192 -58
  57. package/src/public-cli/option-definitions.ts +2 -2
  58. package/src/public-cli/post-admission.ts +73 -13
  59. package/src/public-cli/run-lifecycle.ts +255 -65
  60. package/src/public-cli/seat-ticket-binding.ts +3 -6
  61. package/src/public-cli/settlement.ts +6 -4
  62. package/src/role-run-placement.ts +128 -0
  63. package/src/role-run-relocation.ts +491 -0
  64. package/src/role-runtime.ts +11 -5
  65. package/src/run-terminal-artifacts.ts +3 -1
  66. package/src/run-ticket-number.ts +80 -20
  67. package/src/sitian-appender.ts +25 -28
  68. package/src/submission-ledger.ts +102 -19
  69. package/src/ticket-provenance.ts +4 -1
  70. package/src/ticket-trajectory.ts +22 -35
  71. package/src/worker-role.ts +4 -4
  72. package/src/worker-submission-gates.ts +6 -6
@@ -1,18 +1,35 @@
1
- import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
1
+ import {
2
+ closeSync,
3
+ existsSync,
4
+ mkdirSync,
5
+ openSync,
6
+ readdirSync,
7
+ readFileSync,
8
+ readSync,
9
+ realpathSync,
10
+ statSync,
11
+ writeFileSync
12
+ } from "node:fs";
2
13
  import { dirname, resolve, join } from "node:path";
14
+ import { StringDecoder } from "node:string_decoder";
3
15
  import { SessionManager } from "@earendil-works/pi-coding-agent";
4
- import { resolveBookKeyFromGit } from "./activation-ledger-git.js";
5
16
  import {
6
17
  ActivationLedgerError,
7
- activationBookDirectory,
8
18
  ensureRealDirectoryTree,
19
+ errnoCode,
9
20
  errorText,
10
21
  pathContainedIn,
11
22
  physicallyContainedIn,
12
23
  resolveActivationLedgerHomeForPath
13
24
  } from "./activation-ledger-topology.js";
14
- import { subjectKeyedRecordDirectory } from "./archivist-record-topology.js";
15
- import { subjectKeyedRecordDirectory as subjectKeyedRecordDirectory2 } from "./archivist-record-topology.js";
25
+ import {
26
+ NAVIGATOR_RECORD_KIND,
27
+ resolveNavigatorWorkSubjectPlacement
28
+ } from "./archivist-record-topology.js";
29
+ import {
30
+ NAVIGATOR_RECORD_KIND as NAVIGATOR_RECORD_KIND2,
31
+ resolveNavigatorWorkSubjectPlacement as resolveNavigatorWorkSubjectPlacement2
32
+ } from "./archivist-record-topology.js";
16
33
  const CURRENT_SESSION_LEDGER = "current-session.json";
17
34
  function readCurrentSession(sessionDir) {
18
35
  const ledger = join(sessionDir, CURRENT_SESSION_LEDGER);
@@ -41,6 +58,151 @@ function writeCurrentSession(sessionDir, sessionFile) {
41
58
  );
42
59
  }
43
60
  }
61
+ function isCurrentSessionClaimRace(error) {
62
+ if (!(error instanceof ActivationLedgerError)) return errnoCode(error) === "EEXIST";
63
+ return errnoCode(error.cause) === "EEXIST";
64
+ }
65
+ function currentSessionLedgerPath(sessionDir) {
66
+ return join(sessionDir, CURRENT_SESSION_LEDGER);
67
+ }
68
+ const SESSION_HEADER_CHUNK_BYTES = 4096;
69
+ const MAX_SESSION_HEADER_SCAN_BYTES = 1024 * 1024;
70
+ function readBoundedSessionHeader(filePath) {
71
+ const fd = openSync(filePath, "r");
72
+ try {
73
+ const buffer = Buffer.allocUnsafe(SESSION_HEADER_CHUNK_BYTES);
74
+ const decoder = new StringDecoder("utf8");
75
+ let pending = "";
76
+ let scanned = 0;
77
+ const consume = (chunk) => {
78
+ pending += chunk;
79
+ let lineStart = 0;
80
+ let newlineIndex = pending.indexOf("\n", lineStart);
81
+ while (newlineIndex !== -1) {
82
+ const line = pending.slice(lineStart, newlineIndex);
83
+ lineStart = newlineIndex + 1;
84
+ newlineIndex = pending.indexOf("\n", lineStart);
85
+ if (line.trim() === "") continue;
86
+ pending = pending.slice(lineStart);
87
+ const header = parseSessionHeaderLine(line);
88
+ return header === void 0 ? { kind: "rejected" } : { kind: "accepted", header };
89
+ }
90
+ pending = pending.slice(lineStart);
91
+ return { kind: "incomplete" };
92
+ };
93
+ const finishPending = (tail) => {
94
+ const result = consume(tail);
95
+ if (result.kind === "accepted") return result.header;
96
+ if (result.kind === "rejected") return void 0;
97
+ if (pending.trim() === "") return void 0;
98
+ const header = parseSessionHeaderLine(pending);
99
+ pending = "";
100
+ return header;
101
+ };
102
+ while (scanned < MAX_SESSION_HEADER_SCAN_BYTES) {
103
+ const toRead = Math.min(buffer.length, MAX_SESSION_HEADER_SCAN_BYTES - scanned);
104
+ const bytesRead = readSync(fd, buffer, 0, toRead, null);
105
+ if (bytesRead === 0) {
106
+ return finishPending(decoder.end());
107
+ }
108
+ scanned += bytesRead;
109
+ const found = consume(decoder.write(buffer.subarray(0, bytesRead)));
110
+ if (found.kind === "accepted") return found.header;
111
+ if (found.kind === "rejected") return void 0;
112
+ }
113
+ const probe = Buffer.allocUnsafe(1);
114
+ if (readSync(fd, probe, 0, 1, null) === 0) {
115
+ return finishPending(decoder.end());
116
+ }
117
+ return void 0;
118
+ } finally {
119
+ closeSync(fd);
120
+ }
121
+ }
122
+ function parseSessionHeaderLine(line) {
123
+ if (line.trim() === "") return void 0;
124
+ let parsed;
125
+ try {
126
+ parsed = JSON.parse(line);
127
+ } catch {
128
+ return void 0;
129
+ }
130
+ if (typeof parsed !== "object" || parsed === null || parsed.type !== "session" || typeof parsed.id !== "string" || parsed.id.length === 0) {
131
+ return void 0;
132
+ }
133
+ const cwd = parsed.cwd;
134
+ return {
135
+ type: "session",
136
+ id: parsed.id,
137
+ ...typeof cwd === "string" ? { cwd } : {}
138
+ };
139
+ }
140
+ function mostRecentNavigatorSessionFile(sessionDir, cwd) {
141
+ const absoluteSessionDir = resolve(sessionDir);
142
+ const absoluteCwd = resolve(cwd);
143
+ let names;
144
+ try {
145
+ names = readdirSync(absoluteSessionDir);
146
+ } catch (error) {
147
+ throw new ActivationLedgerError(
148
+ `archivist navigator nest is not readable (${sessionDir}): ${errorText(error)}`,
149
+ { cause: error }
150
+ );
151
+ }
152
+ const matched = [];
153
+ for (const name of names) {
154
+ if (!name.endsWith(".jsonl")) continue;
155
+ const filePath = join(absoluteSessionDir, name);
156
+ let st;
157
+ try {
158
+ st = statSync(filePath);
159
+ } catch (error) {
160
+ throw new ActivationLedgerError(
161
+ `archivist navigator session file is not stat-able (${filePath}): ${errorText(error)}`,
162
+ { cause: error }
163
+ );
164
+ }
165
+ if (!st.isFile()) continue;
166
+ let header;
167
+ try {
168
+ header = readBoundedSessionHeader(filePath);
169
+ } catch (error) {
170
+ throw new ActivationLedgerError(
171
+ `archivist navigator session header is not readable (${filePath}): ${errorText(error)}`,
172
+ { cause: error }
173
+ );
174
+ }
175
+ if (header === void 0) continue;
176
+ if (typeof header.cwd !== "string" || header.cwd.length === 0) continue;
177
+ if (resolve(header.cwd) !== absoluteCwd) continue;
178
+ matched.push({ path: filePath, mtimeMs: st.mtimeMs });
179
+ }
180
+ if (matched.length === 0) return void 0;
181
+ matched.sort((a, b) => b.mtimeMs - a.mtimeMs);
182
+ return matched[0].path;
183
+ }
184
+ function resolveNavigatorNestContinuation(sessionDir, cwd) {
185
+ const ledgerPath = currentSessionLedgerPath(sessionDir);
186
+ if (existsSync(ledgerPath)) {
187
+ const recentFile = readCurrentSession(sessionDir);
188
+ assertRecentFinalFileUnderSessionDir(sessionDir, recentFile);
189
+ return recentFile;
190
+ }
191
+ const adopted = mostRecentNavigatorSessionFile(sessionDir, cwd);
192
+ if (adopted === void 0) return void 0;
193
+ assertRecentFinalFileUnderSessionDir(sessionDir, adopted);
194
+ try {
195
+ writeCurrentSession(sessionDir, adopted);
196
+ return adopted;
197
+ } catch (error) {
198
+ if (isCurrentSessionClaimRace(error)) {
199
+ const recentFile = readCurrentSession(sessionDir);
200
+ assertRecentFinalFileUnderSessionDir(sessionDir, recentFile);
201
+ return recentFile;
202
+ }
203
+ throw error;
204
+ }
205
+ }
44
206
  const WORKER_SUBMISSION_GATE_KIND = "worker-submission-gate";
45
207
  function assertRecentFinalFileUnderSessionDir(sessionDir, recentFile) {
46
208
  const absoluteSessionDir = resolve(sessionDir);
@@ -77,34 +239,55 @@ function assertRecentFinalFileUnderSessionDir(sessionDir, recentFile) {
77
239
  function createRecordSessionOpen(options) {
78
240
  const cwd = options.cwd;
79
241
  const parentFile = options.parent?.getSessionFile();
80
- const ledgerHome = resolveActivationLedgerHomeForPath(parentFile);
81
242
  let sessionDir;
82
243
  let parentSession;
83
- if (options.subject !== void 0) {
84
- sessionDir = subjectKeyedRecordDirectory({
244
+ let mayResumeSameNest;
245
+ let ledgerHome;
246
+ if (options.kind === NAVIGATOR_RECORD_KIND && options.subject !== void 0) {
247
+ const placement = resolveNavigatorWorkSubjectPlacement({
85
248
  cwd,
86
- kind: options.kind,
87
249
  subject: options.subject,
88
- ...parentFile === void 0 || parentFile.length === 0 ? {} : { parentSessionFile: parentFile }
250
+ ...parentFile === void 0 || parentFile.length === 0 ? {} : { parentSessionFile: parentFile },
251
+ ...options.home === void 0 ? {} : { home: options.home }
89
252
  });
253
+ sessionDir = placement.sessionDir;
254
+ ledgerHome = placement.ledgerHome;
90
255
  parentSession = parentFile && parentFile.length > 0 ? parentFile : void 0;
256
+ mayResumeSameNest = true;
91
257
  } else if (parentFile === void 0 || parentFile.length === 0) {
92
- return { session: SessionManager.inMemory(cwd), resumed: false };
258
+ if (options.subject === void 0) {
259
+ return { session: SessionManager.inMemory(cwd), resumed: false };
260
+ }
261
+ throw new Error("Durable record ownership requires a parent session inside the ledger home");
93
262
  } else {
263
+ ledgerHome = resolveActivationLedgerHomeForPath(parentFile);
94
264
  const parentResolved = resolve(parentFile);
95
- sessionDir = physicallyContainedIn(ledgerHome, parentResolved) ? join(dirname(parentResolved), options.kind) : join(activationBookDirectory(ledgerHome, resolveBookKeyFromGit(cwd)), options.kind);
265
+ if (!physicallyContainedIn(ledgerHome, parentResolved)) {
266
+ throw new Error("Durable record ownership requires a parent session inside the ledger home");
267
+ }
268
+ sessionDir = join(dirname(parentResolved), options.kind);
96
269
  parentSession = parentFile;
270
+ mayResumeSameNest = options.kind === WORKER_SUBMISSION_GATE_KIND;
97
271
  }
98
272
  const nestAlreadyExists = existsSync(sessionDir);
99
273
  ensureRealDirectoryTree(ledgerHome, sessionDir);
100
- const mayResumeSameNest = options.subject !== void 0 || options.kind === WORKER_SUBMISSION_GATE_KIND;
101
274
  if (mayResumeSameNest && nestAlreadyExists) {
102
- const recentFile = readCurrentSession(sessionDir);
103
- assertRecentFinalFileUnderSessionDir(sessionDir, recentFile);
104
- return {
105
- session: SessionManager.open(recentFile, sessionDir, cwd),
106
- resumed: true
107
- };
275
+ if (options.kind === NAVIGATOR_RECORD_KIND) {
276
+ const recentFile = resolveNavigatorNestContinuation(sessionDir, cwd);
277
+ if (recentFile !== void 0) {
278
+ return {
279
+ session: SessionManager.open(recentFile, sessionDir, cwd),
280
+ resumed: true
281
+ };
282
+ }
283
+ } else {
284
+ const recentFile = readCurrentSession(sessionDir);
285
+ assertRecentFinalFileUnderSessionDir(sessionDir, recentFile);
286
+ return {
287
+ session: SessionManager.open(recentFile, sessionDir, cwd),
288
+ resumed: true
289
+ };
290
+ }
108
291
  }
109
292
  const session = SessionManager.create(
110
293
  cwd,
@@ -151,9 +334,10 @@ function bookDirectOfficerRunPointer(options) {
151
334
  }
152
335
  export {
153
336
  DIRECT_OFFICER_RUN_POINTER_KIND,
337
+ NAVIGATOR_RECORD_KIND2 as NAVIGATOR_RECORD_KIND,
154
338
  WORKER_SUBMISSION_GATE_KIND,
155
339
  bookDirectOfficerRunPointer,
156
340
  createRecordSession,
157
341
  createRecordSessionOpen,
158
- subjectKeyedRecordDirectory2 as subjectKeyedRecordDirectory
342
+ resolveNavigatorWorkSubjectPlacement2 as resolveNavigatorWorkSubjectPlacement
159
343
  };
@@ -1,20 +1,40 @@
1
+ /**
2
+ * Pure archivist record placement topology (ADR 0048 / 0065 / dossier-topology).
3
+ * No SessionManager / pi-coding-agent — cold public-bin and ledger discovery
4
+ * may import this without pulling open-session runtime into the graph.
5
+ *
6
+ * Navigator work-subject is the sole book-top subject partition (#852):
7
+ * books/<book>/navigator/<work-subject-digest>/. Callers must not re-hash
8
+ * subject or re-join book/navigator/digest themselves.
9
+ */
1
10
  import { createHash } from "node:crypto";
2
11
  import { join } from "node:path";
3
12
  import { resolveBookKeyFromGit } from "./activation-ledger-git.js";
4
- import {
5
- activationBookDirectory,
6
- resolveActivationLedgerHome,
7
- resolveActivationLedgerHomeForPath
8
- } from "./activation-ledger-topology.js";
9
- function subjectKeyedRecordDirectory(input) {
10
- const ledgerHome = input.parentSessionFile !== void 0 && input.parentSessionFile.length > 0 ? resolveActivationLedgerHomeForPath(input.parentSessionFile) : resolveActivationLedgerHome(input.home);
11
- const digest = createHash("sha256").update(input.subject).digest("hex").slice(0, 32);
12
- return join(
13
- activationBookDirectory(ledgerHome, resolveBookKeyFromGit(input.cwd)),
14
- input.kind,
15
- digest
16
- );
13
+ import { activationBookDirectory, resolveActivationLedgerHome, tryHomeFromAkRolesPath, } from "./activation-ledger-topology.js";
14
+ /** Book-top partition for cross-ticket navigator route-memory (dossier-topology). */
15
+ export const NAVIGATOR_RECORD_KIND = "navigator";
16
+ /**
17
+ * Sole navigator work-subject placement under the ledger book.
18
+ * Returns ledgerHome and nest once so createRecordSessionOpen does not re-branch
19
+ * parent/home. Work-subject key shape is sha256(subject) hex truncated to 32
20
+ * (#852 out of scope — shape unchanged).
21
+ *
22
+ * parentSessionFile contributes home only when tryHomeFromAkRolesPath succeeds;
23
+ * otherwise the explicit home (from HostContext.runDirectory) is kept. Never
24
+ * invents a second home via passwd/env fallback from a foreign parent path.
25
+ */
26
+ export function resolveNavigatorWorkSubjectPlacement(input) {
27
+ let ledgerHome;
28
+ if (input.parentSessionFile !== undefined && input.parentSessionFile.length > 0) {
29
+ const fromParent = tryHomeFromAkRolesPath(input.parentSessionFile);
30
+ if (fromParent !== undefined && fromParent.length > 0) {
31
+ ledgerHome = resolveActivationLedgerHome(fromParent);
32
+ }
33
+ }
34
+ ledgerHome ??= resolveActivationLedgerHome(input.home);
35
+ const digest = createHash("sha256").update(input.subject).digest("hex").slice(0, 32);
36
+ return {
37
+ ledgerHome,
38
+ sessionDir: join(activationBookDirectory(ledgerHome, resolveBookKeyFromGit(input.cwd)), NAVIGATOR_RECORD_KIND, digest),
39
+ };
17
40
  }
18
- export {
19
- subjectKeyedRecordDirectory
20
- };
@@ -1,4 +1,5 @@
1
1
  import { dirname, join, resolve } from "node:path";
2
+ import { roleRunArtifactsDirectory } from "./role-run-placement.js";
2
3
  import { Type } from "typebox";
3
4
  const AUDITOR_DOSSIER_TOOL_NAME = "ak_get_run_dossier";
4
5
  function auditorRunDirectory(context) {
@@ -25,7 +26,7 @@ function createAuditorDossierTool(runDirectory) {
25
26
  admittedRequest: join(runDirectory, "admitted-request.json"),
26
27
  parentSessionCandidate: join(runDirectory, "session", "session.jsonl"),
27
28
  attachments: join(runDirectory, "attachments"),
28
- artifacts: join(runDirectory, "artifacts")
29
+ artifacts: roleRunArtifactsDirectory(runDirectory)
29
30
  };
30
31
  return {
31
32
  content: [{ type: "text", text: JSON.stringify(details) }],
@@ -7,8 +7,104 @@
7
7
  * #779: no frozen candidate catalog; LLM finds sources and submits whole blocks.
8
8
  * Mechanical layer does not judge LLM output (no quote/ticket/relevance verify).
9
9
  */
10
+ import { isSafePositiveTicketNumber, requireSafePositiveTicketNumber, } from "./run-ticket-number.js";
10
11
  export const DIARIST_OUTPUT_TOOL_NAME = "ak_diarist_output";
11
12
  export const DIARIST_ACCEPTED_TEXT = "起居郎回执已接受";
13
+ /**
14
+ * Sole shape projection for a typed co-review ticket set (#871).
15
+ * Positive safe integers, first-seen order, duplicates dropped. Not content judgment.
16
+ * Returns null when `raw` is not an array (callers classify absent vs durable damage).
17
+ * Returns a possibly empty list when `raw` is an array (unlawful members skipped).
18
+ * When `principalTicket` is set, that ticket is guaranteed as a member (prepended if missing).
19
+ */
20
+ export function projectCourtTicketNumbers(raw, options) {
21
+ if (!Array.isArray(raw))
22
+ return null;
23
+ const out = [];
24
+ const seen = new Set();
25
+ for (const item of raw) {
26
+ if (!isSafePositiveTicketNumber(item))
27
+ continue;
28
+ if (seen.has(item))
29
+ continue;
30
+ seen.add(item);
31
+ out.push(item);
32
+ }
33
+ const principal = options?.principalTicket;
34
+ if (principal === undefined)
35
+ return out;
36
+ requireSafePositiveTicketNumber(principal, "projectCourtTicketNumbers principalTicket");
37
+ if (seen.has(principal))
38
+ return out;
39
+ return [principal, ...out];
40
+ }
41
+ /**
42
+ * Durable-field interpreter for #871 co-review sets.
43
+ * Absent key (old-format run) is not damage. Present but empty / non-array /
44
+ * partially unlawful / missing principal is damage — callers must fail closed,
45
+ * never wash into main-only.
46
+ */
47
+ export function interpretDurableCourtTicketNumbers(record, options) {
48
+ if (!Object.hasOwn(record, "courtTicketNumbers")) {
49
+ return { kind: "absent" };
50
+ }
51
+ const raw = record.courtTicketNumbers;
52
+ if (!Array.isArray(raw)) {
53
+ return {
54
+ kind: "damage",
55
+ reason: "courtTicketNumbers is present but not an array",
56
+ };
57
+ }
58
+ if (raw.length === 0) {
59
+ return {
60
+ kind: "damage",
61
+ reason: "courtTicketNumbers is present but empty",
62
+ };
63
+ }
64
+ for (let i = 0; i < raw.length; i += 1) {
65
+ const item = raw[i];
66
+ if (!isSafePositiveTicketNumber(item)) {
67
+ return {
68
+ kind: "damage",
69
+ reason: `courtTicketNumbers[${i}] is not a safe positive integer`,
70
+ };
71
+ }
72
+ }
73
+ // Members are all lawful numbers; sole projection owns dedupe/order.
74
+ const tickets = projectCourtTicketNumbers(raw);
75
+ if (tickets === null || tickets.length === 0) {
76
+ return {
77
+ kind: "damage",
78
+ reason: "courtTicketNumbers projected empty after lawful members",
79
+ };
80
+ }
81
+ const principal = options?.principalTicket;
82
+ if (principal !== undefined) {
83
+ if (!isSafePositiveTicketNumber(principal)) {
84
+ return {
85
+ kind: "damage",
86
+ reason: "courtTicketNumbers principal ticket is not a safe positive integer",
87
+ };
88
+ }
89
+ if (!tickets.includes(principal)) {
90
+ return {
91
+ kind: "damage",
92
+ reason: `courtTicketNumbers is missing principal ticket #${principal}`,
93
+ };
94
+ }
95
+ }
96
+ return { kind: "ok", tickets: Object.freeze([...tickets]) };
97
+ }
98
+ /** True when two durable #871 sets are byte-identical sequences. */
99
+ export function sameCourtTicketNumbers(left, right) {
100
+ if (left.length !== right.length)
101
+ return false;
102
+ for (let i = 0; i < left.length; i += 1) {
103
+ if (left[i] !== right[i])
104
+ return false;
105
+ }
106
+ return true;
107
+ }
12
108
  export function validateRecordedDiaristOutput(value) {
13
109
  if (value === null || typeof value !== "object" || Array.isArray(value)) {
14
110
  throw new Error("Diarist output has no execution discriminator");
@@ -108,9 +108,10 @@ function deriveSession(content, id) {
108
108
  /** Read Pi's retained session directory as the sole raw material for one case. */
109
109
  export async function loadDoctorCase(runsPath) {
110
110
  const root = await realpath(runsPath);
111
- const match = root.split(sep).join("/").match(/\/\.ak-roles\/books\/[^/]+\/issues\/([1-9]\d*)\/runs$/);
111
+ // Canonical <book>/<ticket>/runs; legacy issues/<n>/runs remains read-only compatible.
112
+ const match = root.split(sep).join("/").match(/\/\.ak-roles\/books\/[^/]+\/(?:issues\/)?([1-9]\d*)\/runs$/);
112
113
  if (!match)
113
- throw new Error("Doctor case must be an .ak-roles/books/<book>/issues/<n>/runs directory");
114
+ throw new Error("Doctor case must be an .ak-roles/books/<book>/<n>/runs directory");
114
115
  const evidence = [], sessions = [], statuses = [], commits = [];
115
116
  const turns = { count: 0, sources: [] }, calls = { count: 0, sources: [] }, tokens = { count: 0, sources: [] };
116
117
  for (const path of await discoverCaseFiles(root)) {