@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
@@ -0,0 +1,493 @@
1
+ /**
2
+ * Shared placement helpers for book-topology migrators (#865 / #866).
3
+ * Placement ticket → board pages then migration-derived then worktree basename;
4
+ * destination path → roleRunPlacement. Identity writers use board only.
5
+ * Worktree basename “所含票号” is the single #852/#865/#866 rule.
6
+ * Shared ENOENT / book-key / dirent enumeration authority for migration modules.
7
+ */
8
+ import type { Dirent } from "node:fs";
9
+ import { readdir, readFile } from "node:fs/promises";
10
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
11
+
12
+ import { pathContainedIn } from "./activation-ledger-topology.ts";
13
+ import { roleRunPlacement } from "./role-run-placement.ts";
14
+ import {
15
+ MIGRATION_TICKET_DERIVATION_PAGE,
16
+ readBoardTicketNumber,
17
+ readMigrationDerivedTicketNumber,
18
+ } from "./run-ticket-number.ts";
19
+
20
+ const TICKET_NUMBER_RE = /^[1-9][0-9]*$/;
21
+ const RUN_DIR_NAME_RE = /^([^@]+)@([^@]+)$/;
22
+
23
+ function isRecord(value: unknown): value is Record<string, unknown> {
24
+ return typeof value === "object" && value !== null && !Array.isArray(value);
25
+ }
26
+
27
+ /** Sole migration ENOENT projection — non-ENOENT must propagate. */
28
+ export function isMigrationEnoent(error: unknown): boolean {
29
+ return (
30
+ error instanceof Error &&
31
+ "code" in error &&
32
+ (error as NodeJS.ErrnoException).code === "ENOENT"
33
+ );
34
+ }
35
+
36
+ /** Book-key directories under a books root; missing root → []. */
37
+ export async function listMigrationBookKeys(
38
+ booksDirectory: string,
39
+ ): Promise<string[]> {
40
+ try {
41
+ const entries = await readdir(booksDirectory, { withFileTypes: true });
42
+ return entries
43
+ .filter((entry) => entry.isDirectory())
44
+ .map((entry) => entry.name)
45
+ .sort((a, b) => a.localeCompare(b));
46
+ } catch (error) {
47
+ if (isMigrationEnoent(error)) return [];
48
+ throw error;
49
+ }
50
+ }
51
+
52
+ /** Directory entries sorted by name; missing directory → []. */
53
+ export async function listMigrationDirents(
54
+ directory: string,
55
+ ): Promise<readonly Dirent[]> {
56
+ try {
57
+ const entries = await readdir(directory, { withFileTypes: true });
58
+ return [...entries].sort((a, b) => a.name.localeCompare(b.name));
59
+ } catch (error) {
60
+ if (isMigrationEnoent(error)) return [];
61
+ throw error;
62
+ }
63
+ }
64
+
65
+ export function ticketNumberFromUnknown(value: unknown): number | undefined {
66
+ if (typeof value === "number" && Number.isSafeInteger(value) && value > 0) return value;
67
+ if (typeof value === "string" && TICKET_NUMBER_RE.test(value)) return Number(value);
68
+ return undefined;
69
+ }
70
+
71
+ export function isTicketNumberString(value: string): boolean {
72
+ return TICKET_NUMBER_RE.test(value);
73
+ }
74
+
75
+ /**
76
+ * Ticket number contained in a worktree path's final segment.
77
+ * #852: 「工作树路径末段所含票号」— shared by #865 and #866.
78
+ * Exactly one digit run that is itself a safe positive ticket spelling;
79
+ * multiple or zero digit runs are undecidable → undefined (unbound).
80
+ */
81
+ export function ticketNumberFromWorktreeBasename(
82
+ pathBasename: string,
83
+ ): number | undefined {
84
+ const matches = pathBasename.match(/\d+/g);
85
+ if (matches === null || matches.length !== 1) return undefined;
86
+ const spelling = matches[0]!;
87
+ if (!TICKET_NUMBER_RE.test(spelling)) return undefined;
88
+ const ticketNumber = Number(spelling);
89
+ if (!Number.isSafeInteger(ticketNumber) || ticketNumber < 1) return undefined;
90
+ return ticketNumber;
91
+ }
92
+
93
+ /**
94
+ * projectRoot from a retained run's durable pages.
95
+ * ENOENT → try next page / undefined; bad JSON and non-ENOENT IO propagate.
96
+ */
97
+ async function readProjectRootFromRun(
98
+ runDirectory: string,
99
+ ): Promise<{ readonly projectRoot: string; readonly sourcePage: string } | undefined> {
100
+ for (const page of ["admitted-request.json", "invocation.json", "run-state.json"] as const) {
101
+ const path = join(runDirectory, page);
102
+ try {
103
+ const raw: unknown = JSON.parse(await readFile(path, "utf8"));
104
+ if (!isRecord(raw)) continue;
105
+ const projectRoot = raw.projectRoot;
106
+ if (typeof projectRoot === "string" && projectRoot.length > 0) {
107
+ return { projectRoot, sourcePage: page };
108
+ }
109
+ } catch (error) {
110
+ if (isMigrationEnoent(error)) continue;
111
+ throw error;
112
+ }
113
+ }
114
+ return undefined;
115
+ }
116
+
117
+ export type MigratingRunTicketDerivation =
118
+ | { readonly method: "board"; readonly source: string }
119
+ | {
120
+ readonly method: "project-root-basename";
121
+ readonly source: string;
122
+ readonly sourcePage: string;
123
+ };
124
+
125
+ /**
126
+ * Ticket binding for a retained run directory (#852 / #865 / #866):
127
+ * effective placement ticket (board pages, else migration-derived page) first,
128
+ * then worktree basename containment when the leaf holds a ticket number.
129
+ * Migration placement may use derived facts; LLM/board identity writers must not.
130
+ */
131
+ export async function resolveMigratingRunTicket(runDirectory: string): Promise<{
132
+ readonly ticketNumber: number | undefined;
133
+ readonly derivation: MigratingRunTicketDerivation | undefined;
134
+ }> {
135
+ const boardTicket = await readBoardTicketNumber(runDirectory);
136
+ if (boardTicket !== undefined) {
137
+ return {
138
+ ticketNumber: boardTicket,
139
+ derivation: { method: "board", source: runDirectory },
140
+ };
141
+ }
142
+
143
+ const derivedTicket = await readMigrationDerivedTicketNumber(runDirectory);
144
+ if (derivedTicket !== undefined) {
145
+ return {
146
+ ticketNumber: derivedTicket,
147
+ derivation: {
148
+ method: "project-root-basename",
149
+ source: runDirectory,
150
+ sourcePage: MIGRATION_TICKET_DERIVATION_PAGE,
151
+ },
152
+ };
153
+ }
154
+
155
+ const project = await readProjectRootFromRun(runDirectory);
156
+ if (project === undefined) return { ticketNumber: undefined, derivation: undefined };
157
+ const leaf = basename(project.projectRoot.replace(/\\/g, "/"));
158
+ const ticketNumber = ticketNumberFromWorktreeBasename(leaf);
159
+ if (ticketNumber === undefined) return { ticketNumber: undefined, derivation: undefined };
160
+ return {
161
+ ticketNumber,
162
+ derivation: {
163
+ method: "project-root-basename",
164
+ source: project.projectRoot,
165
+ sourcePage: project.sourcePage,
166
+ },
167
+ };
168
+ }
169
+
170
+ export function isUnboundRunDirectory(runDirectory: string): boolean {
171
+ return runDirectory.replaceAll("\\", "/").includes("/unbound/runs/");
172
+ }
173
+
174
+ export function bookHistoricalRoots(
175
+ booksDirectory: string,
176
+ backupBooksDirectory: string,
177
+ bookKey: string,
178
+ ): readonly string[] {
179
+ return [join(booksDirectory, bookKey), join(backupBooksDirectory, bookKey)];
180
+ }
181
+
182
+ export type BoundRunRef = {
183
+ readonly leaf: string;
184
+ readonly sourceRelative: string;
185
+ };
186
+
187
+ function runLeafFromRelative(path: string): string | undefined {
188
+ const segments = path.replaceAll("\\", "/").split("/").filter((segment) => segment.length > 0);
189
+ const runsIndex = segments.lastIndexOf("runs");
190
+ if (runsIndex < 0 || runsIndex + 1 >= segments.length) return undefined;
191
+ const leaf = segments[runsIndex + 1];
192
+ if (leaf === undefined || leaf === "." || leaf === "..") return undefined;
193
+ return leaf;
194
+ }
195
+
196
+ /** Bind a path to this book's historical roots, then take complete leaf + source-relative run dir. */
197
+ export function runRefFromBoundPath(
198
+ path: string,
199
+ bookRoots: readonly string[],
200
+ ): BoundRunRef | undefined {
201
+ for (const root of bookRoots) {
202
+ const rootResolved = resolve(root);
203
+ const candidate = isAbsolute(path) ? resolve(path) : resolve(rootResolved, path);
204
+ if (candidate === rootResolved || !pathContainedIn(rootResolved, candidate)) continue;
205
+ const rel = relative(rootResolved, candidate).split(sep).join("/");
206
+ const leaf = runLeafFromRelative(rel);
207
+ if (leaf === undefined) continue;
208
+ const parts = rel.split("/").filter((part) => part.length > 0);
209
+ const runsIndex = parts.indexOf("runs");
210
+ if (runsIndex < 0 || runsIndex + 1 >= parts.length) continue;
211
+ return { leaf, sourceRelative: parts.slice(0, runsIndex + 2).join("/") };
212
+ }
213
+ return undefined;
214
+ }
215
+
216
+ export type BackupRunLeaf = {
217
+ readonly relativePath: string;
218
+ readonly sourcePath: string;
219
+ readonly leafName: string;
220
+ readonly isDirectory: boolean;
221
+ readonly layout: "flat" | "ticket" | "unbound" | "issues";
222
+ };
223
+
224
+ async function listRunLeafEntries(
225
+ runsDirectory: string,
226
+ ): Promise<readonly { readonly name: string; readonly isDirectory: boolean }[]> {
227
+ const entries = await listMigrationDirents(runsDirectory);
228
+ return entries.map((entry) => ({
229
+ name: entry.name,
230
+ isDirectory: entry.isDirectory(),
231
+ }));
232
+ }
233
+
234
+ /**
235
+ * Every retained run tree under one backup book: legacy flat `runs/`,
236
+ * already-canonical `<ticket>/runs/`, `unbound/runs/`, and legacy
237
+ * `issues/<ticket>/runs/`.
238
+ */
239
+ export async function listBackupRunLeaves(
240
+ backupBookDirectory: string,
241
+ ): Promise<readonly BackupRunLeaf[]> {
242
+ const leaves: BackupRunLeaf[] = [];
243
+ const collect = async (
244
+ relativeDir: string,
245
+ layout: BackupRunLeaf["layout"],
246
+ ): Promise<void> => {
247
+ const runsDirectory = join(backupBookDirectory, ...relativeDir.split("/"));
248
+ for (const entry of await listRunLeafEntries(runsDirectory)) {
249
+ leaves.push({
250
+ relativePath: `${relativeDir}/${entry.name}`,
251
+ sourcePath: join(runsDirectory, entry.name),
252
+ leafName: entry.name,
253
+ isDirectory: entry.isDirectory,
254
+ layout,
255
+ });
256
+ }
257
+ };
258
+ await collect("runs", "flat");
259
+ await collect("unbound/runs", "unbound");
260
+ for (const subject of await listMigrationDirents(backupBookDirectory)) {
261
+ if (!subject.isDirectory() || !isTicketNumberString(subject.name)) continue;
262
+ await collect(`${subject.name}/runs`, "ticket");
263
+ }
264
+ for (const issue of await listMigrationDirents(join(backupBookDirectory, "issues"))) {
265
+ if (!issue.isDirectory() || !isTicketNumberString(issue.name)) continue;
266
+ await collect(`issues/${issue.name}/runs`, "issues");
267
+ }
268
+ return leaves;
269
+ }
270
+
271
+ async function listExactPlacedRunPaths(
272
+ bookDir: string,
273
+ leafName: string,
274
+ ): Promise<string[]> {
275
+ const matches: string[] = [];
276
+ const subjectEntries = await listMigrationDirents(bookDir);
277
+ const runsDirs = [
278
+ join(bookDir, "runs"),
279
+ ...subjectEntries.filter((entry) => entry.isDirectory()).map((entry) => join(bookDir, entry.name, "runs")),
280
+ ];
281
+ for (const runsDir of runsDirs) {
282
+ const entries = await listMigrationDirents(runsDir);
283
+ if (entries.some((entry) => entry.name === leafName)) {
284
+ matches.push(join(runsDir, leafName));
285
+ }
286
+ }
287
+ return matches;
288
+ }
289
+
290
+ function destPathFromSourceRelative(
291
+ booksDirectory: string,
292
+ bookKey: string,
293
+ sourceRelative: string,
294
+ ): string | undefined {
295
+ const parts = sourceRelative.replaceAll("\\", "/").split("/").filter((part) => part.length > 0);
296
+ const runsIndex = parts.indexOf("runs");
297
+ if (runsIndex < 0 || runsIndex + 1 >= parts.length) return undefined;
298
+ const leaf = parts[runsIndex + 1];
299
+ const before = parts.slice(0, runsIndex);
300
+ if (leaf === undefined) return undefined;
301
+ // Legacy issues/<ticket>/runs/<leaf> → canonical <ticket>/runs/<leaf>.
302
+ if (
303
+ before.length === 2
304
+ && before[0] === "issues"
305
+ && before[1] !== undefined
306
+ && isTicketNumberString(before[1])
307
+ ) {
308
+ return join(booksDirectory, bookKey, before[1], "runs", leaf);
309
+ }
310
+ if (before.length !== 1) return undefined;
311
+ const subject = before[0];
312
+ if (subject === undefined) return undefined;
313
+ if (subject !== "unbound" && !isTicketNumberString(subject)) return undefined;
314
+ return join(booksDirectory, bookKey, subject, "runs", leaf);
315
+ }
316
+
317
+ function placedRunFromPath(runDirectory: string): {
318
+ readonly runDirectory: string;
319
+ readonly disposition: "placed" | "unbound";
320
+ } {
321
+ return {
322
+ runDirectory,
323
+ disposition: isUnboundRunDirectory(runDirectory) ? "unbound" : "placed",
324
+ };
325
+ }
326
+
327
+ async function listPrincipalPlacedRunPaths(
328
+ bookDir: string,
329
+ runId: string,
330
+ ): Promise<string[]> {
331
+ const matches = [...await listExactPlacedRunPaths(bookDir, runId)];
332
+ const subjectEntries = await listMigrationDirents(bookDir);
333
+ const runsDirs = [
334
+ join(bookDir, "runs"),
335
+ ...subjectEntries.filter((entry) => entry.isDirectory()).map((entry) => join(bookDir, entry.name, "runs")),
336
+ ];
337
+ const prefix = `${runId}@`;
338
+ for (const runsDir of runsDirs) {
339
+ for (const entry of await listMigrationDirents(runsDir)) {
340
+ const name = entry.name;
341
+ if (!name.startsWith(prefix) || name.slice(runId.length + 1).includes("@")) continue;
342
+ const path = join(runsDir, name);
343
+ if (!matches.includes(path)) matches.push(path);
344
+ }
345
+ }
346
+ return matches;
347
+ }
348
+
349
+ function uniquePlacedRun(leafName: string, matches: readonly string[]): {
350
+ readonly runDirectory: string;
351
+ readonly disposition: "placed" | "unbound";
352
+ } | undefined {
353
+ if (matches.length === 0) return undefined;
354
+ if (matches.length === 1) return placedRunFromPath(matches[0]!);
355
+ throw new Error(
356
+ `book topology migration cannot uniquely place run ${leafName}: ${matches.join(", ")}`,
357
+ );
358
+ }
359
+
360
+ /** Locate an exact run leaf under bookDir/runs, bookDir/<subject>/runs, bookDir/unbound/runs. */
361
+ export async function findBookRunDirectory(
362
+ bookDir: string,
363
+ runId: string,
364
+ role?: string,
365
+ ): Promise<{ readonly runDirectory: string; readonly role: string } | undefined> {
366
+ if (runId.trim() === "") return undefined;
367
+ const leafName = role !== undefined && role.length > 0 ? `${runId}@${role}` : runId;
368
+ const matches = role !== undefined && role.length > 0
369
+ ? await listExactPlacedRunPaths(bookDir, leafName)
370
+ : await listPrincipalPlacedRunPaths(bookDir, runId);
371
+ const unique = uniquePlacedRun(leafName, matches);
372
+ if (unique === undefined) return undefined;
373
+ const foundRole = unique.runDirectory.split(/[/\\]/).pop()?.split("@")[1] ?? role ?? "";
374
+ return { runDirectory: unique.runDirectory, role: foundRole };
375
+ }
376
+
377
+ /** Historical flat `runs/<leaf>` alias under a book root (not ticket/unbound). */
378
+ export function isFlatRunsRelative(sourceRelative: string, leafName: string): boolean {
379
+ const parts = sourceRelative.replaceAll("\\", "/").split("/").filter((part) => part.length > 0);
380
+ return parts.length === 2 && parts[0] === "runs" && parts[1] === leafName;
381
+ }
382
+
383
+ /** Destination run already placed by T9. Follow complete leaf; bind source path when given. */
384
+ export async function findPlacedMigratingRun(
385
+ booksDirectory: string,
386
+ bookKey: string,
387
+ leafName: string,
388
+ sourceRelative?: string,
389
+ ): Promise<
390
+ | { readonly runDirectory: string; readonly disposition: "placed" | "unbound" }
391
+ | undefined
392
+ > {
393
+ if (leafName.trim() === "") return undefined;
394
+ const bookDir = join(booksDirectory, bookKey);
395
+ const matches = await listExactPlacedRunPaths(bookDir, leafName);
396
+ if (sourceRelative !== undefined && sourceRelative.length > 0) {
397
+ const preferred = destPathFromSourceRelative(booksDirectory, bookKey, sourceRelative);
398
+ if (preferred !== undefined) {
399
+ const hit = matches.find((path) => path === preferred);
400
+ return hit === undefined ? undefined : placedRunFromPath(hit);
401
+ }
402
+ // Historical flat `runs/<leaf>` alias after T9 nested the unique complete leaf.
403
+ if (isFlatRunsRelative(sourceRelative, leafName)) {
404
+ return uniquePlacedRun(leafName, matches);
405
+ }
406
+ return undefined;
407
+ }
408
+ return uniquePlacedRun(leafName, matches);
409
+ }
410
+
411
+ /** True when this book retains exactly one directory named leafName across layouts. */
412
+ export async function uniqueRunLeafExistsInBook(
413
+ bookDir: string,
414
+ leafName: string,
415
+ ): Promise<boolean> {
416
+ if (leafName.trim() === "") return false;
417
+ const matches = await listExactPlacedRunPaths(bookDir, leafName);
418
+ return matches.length === 1;
419
+ }
420
+
421
+ /**
422
+ * No path / role evidence: follow runId only when exactly one `runId@role`
423
+ * (or bare runId) exists across this book's layouts. Zero or multi → undefined.
424
+ */
425
+ export async function findUniquePrincipalPlacedRun(
426
+ booksDirectory: string,
427
+ bookKey: string,
428
+ runId: string,
429
+ ): Promise<
430
+ | { readonly runDirectory: string; readonly disposition: "placed" | "unbound" }
431
+ | undefined
432
+ > {
433
+ if (runId.trim() === "") return undefined;
434
+ const matches = await listPrincipalPlacedRunPaths(join(booksDirectory, bookKey), runId);
435
+ if (matches.length !== 1) return undefined;
436
+ return placedRunFromPath(matches[0]!);
437
+ }
438
+
439
+ /**
440
+ * Destination run directory via the sole live placement authority (roleRunPlacement).
441
+ * booksDirectory is the books/ root; ledger home is its parent.
442
+ */
443
+ export function destinationRunDirectory(
444
+ booksDirectory: string,
445
+ bookKey: string,
446
+ ticketNumber: number | undefined,
447
+ runId: string,
448
+ role: string,
449
+ ): string {
450
+ const ledgerHome = dirname(booksDirectory);
451
+ const subject =
452
+ ticketNumber !== undefined
453
+ ? ({ ticketNumber } as const)
454
+ : ({ unbound: true } as const);
455
+ return roleRunPlacement(ledgerHome, {
456
+ bookKey,
457
+ subject,
458
+ runId,
459
+ role,
460
+ }).runDirectory;
461
+ }
462
+
463
+ /** Extract runId@role from a sessionParent path when it points at a run session. */
464
+ export function runCoordsFromSessionParent(
465
+ sessionParent: unknown,
466
+ ): { readonly runId: string; readonly role: string } | undefined {
467
+ if (typeof sessionParent !== "string" || sessionParent.length === 0) return undefined;
468
+ const normalized = sessionParent.replace(/\\/g, "/");
469
+ const marker = "/runs/";
470
+ const index = normalized.lastIndexOf(marker);
471
+ if (index < 0) return undefined;
472
+ const after = normalized.slice(index + marker.length);
473
+ const leaf = after.split("/")[0] ?? "";
474
+ const match = RUN_DIR_NAME_RE.exec(leaf);
475
+ if (match === null) return undefined;
476
+ return { runId: match[1]!, role: match[2]! };
477
+ }
478
+
479
+ export function runIdFromSubject(subject: unknown): string | undefined {
480
+ if (typeof subject === "string" && subject.length > 0) return subject;
481
+ if (isRecord(subject) && typeof subject.runId === "string" && subject.runId.length > 0) {
482
+ return subject.runId;
483
+ }
484
+ return undefined;
485
+ }
486
+
487
+ export function ticketNumberFromSubject(subject: unknown): number | undefined {
488
+ if (typeof subject === "string" || typeof subject === "number") {
489
+ return ticketNumberFromUnknown(subject);
490
+ }
491
+ if (isRecord(subject)) return ticketNumberFromUnknown(subject.ticketNumber);
492
+ return undefined;
493
+ }