@akagilnc/pi-workflow-roles 0.1.5478 → 0.1.5504

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 (79) hide show
  1. package/dist/acp-host/production-host.js +4945 -4980
  2. package/dist/analyst-gate-cycles-read.js +11 -29
  3. package/dist/analyst-ledger.js +10 -12
  4. package/dist/archivist-record-entry.js +4 -3
  5. package/dist/archivist-record-pointer.js +59 -32
  6. package/dist/atomic-write.js +2 -2
  7. package/dist/auditor-dossier-tool.js +8 -4
  8. package/dist/auditor-soul.js +2 -1
  9. package/dist/collector-config.js +6 -5
  10. package/dist/doctor-evidence.js +5 -1
  11. package/dist/engine-detour-usage.js +4 -10
  12. package/dist/headless-host/description.js +251 -0
  13. package/dist/headless-host/production-host.js +2787 -2658
  14. package/dist/ledger-session-read.js +5 -0
  15. package/dist/migrate-book-topology.js +129 -100
  16. package/dist/navigator-public-session.js +13 -17
  17. package/dist/notary-source-run.js +7 -15
  18. package/dist/packaged-role-registry.js +0 -12
  19. package/dist/pi/durable-principal.js +2 -3
  20. package/dist/pi/role-turn-host.js +27 -13
  21. package/dist/public-cli/instruction-seat-run.js +4 -4
  22. package/dist/public-cli/invocation.js +17 -45
  23. package/dist/public-cli/main.js +1074 -1085
  24. package/dist/public-cli/run-lifecycle.js +17 -47
  25. package/dist/public-cli/settlement.js +28 -128
  26. package/dist/public-cli/terminal.js +0 -12
  27. package/dist/role-run-placement.js +52 -5
  28. package/dist/role-run-relocation.js +39 -2
  29. package/dist/run-terminal-artifacts.js +2 -6
  30. package/dist/run-ticket-number.js +33 -4
  31. package/dist/session-assistant-usage.js +9 -18
  32. package/dist/session-identity.js +3 -4
  33. package/dist/sitian-appender.js +20 -6
  34. package/dist/submission-ledger.js +4 -4
  35. package/dist/ticket-provenance-contracts.js +2 -8
  36. package/dist/ticket-provenance.js +5 -4
  37. package/package.json +1 -1
  38. package/src/acp-host/role-turn-host.ts +2 -2
  39. package/src/analyst-gate-cycles-read.ts +11 -34
  40. package/src/analyst-ledger.ts +14 -14
  41. package/src/archivist-record-entry.ts +4 -3
  42. package/src/archivist-record-pointer.ts +70 -34
  43. package/src/atomic-write.ts +3 -1
  44. package/src/auditor-dossier-tool.ts +8 -4
  45. package/src/auditor-soul.ts +2 -1
  46. package/src/collector-config.ts +5 -3
  47. package/src/doctor-evidence.ts +5 -1
  48. package/src/engine-detour-usage.ts +10 -13
  49. package/src/factory-board.ts +16 -81
  50. package/src/headless-host/description.ts +248 -1
  51. package/src/headless-host/production-host.ts +1 -2
  52. package/src/headless-host/role-turn-host.ts +10 -9
  53. package/src/ledger-session-read.ts +6 -0
  54. package/src/navigator-public-session.ts +13 -18
  55. package/src/navigator-work-context.ts +3 -1
  56. package/src/notary-role.ts +2 -1
  57. package/src/notary-source-run.ts +7 -17
  58. package/src/packaged-role-registry.ts +0 -15
  59. package/src/pi/durable-principal.ts +2 -4
  60. package/src/pi/role-turn-host.ts +35 -12
  61. package/src/public-cli/instruction-seat-run.ts +4 -4
  62. package/src/public-cli/invocation.ts +29 -57
  63. package/src/public-cli/run-lifecycle.ts +20 -43
  64. package/src/public-cli/settlement.ts +30 -173
  65. package/src/public-cli/terminal.ts +0 -60
  66. package/src/role-envelope.ts +4 -2
  67. package/src/role-run-placement.ts +63 -4
  68. package/src/role-run-relocation.ts +39 -2
  69. package/src/role-runtime.ts +40 -59
  70. package/src/run-terminal-artifacts.ts +3 -5
  71. package/src/run-ticket-number.ts +41 -3
  72. package/src/session-assistant-usage.ts +8 -20
  73. package/src/session-identity.ts +3 -4
  74. package/src/sitian-appender.ts +20 -12
  75. package/src/submission-ledger.ts +4 -4
  76. package/src/ticket-provenance-contracts.ts +3 -6
  77. package/src/ticket-provenance.ts +5 -6
  78. package/src/ticket-snapshot.ts +3 -2
  79. package/src/ticket-trajectory.ts +85 -99
@@ -14,6 +14,7 @@ import {
14
14
  } from "./activation-ledger-topology.ts";
15
15
  import type { NotarySourceRunLocator } from "./notary-contracts.ts";
16
16
  import { findRunDirectoryById, readRoleRunIdentity } from "./public-cli/run-lifecycle.ts";
17
+ import { formatRunLeaf, parseRunLeaf } from "./role-run-placement.ts";
17
18
 
18
19
  async function readRetainedIdentity(runDirectory: string) {
19
20
  try {
@@ -26,11 +27,6 @@ async function readRetainedIdentity(runDirectory: string) {
26
27
  }
27
28
  }
28
29
 
29
- // Run directory leaf: <runId>@<role>. Production uses uuidv7 runIds; offline tracers
30
- // may use deterministic non-uuid leaves. Role token stays identifier-shaped.
31
- const RUN_DIR_NAME =
32
- /^([A-Za-z0-9][A-Za-z0-9._-]*)@([A-Za-z][A-Za-z0-9_-]*)$/;
33
-
34
30
  export class NotarySourceRunError extends Error {
35
31
  constructor(message: string, options?: ErrorOptions) {
36
32
  super(message, options);
@@ -38,12 +34,6 @@ export class NotarySourceRunError extends Error {
38
34
  }
39
35
  }
40
36
 
41
- function parseRunDirectoryName(name: string): { runId: string; role: string } | undefined {
42
- const match = RUN_DIR_NAME.exec(name);
43
- if (match === null) return undefined;
44
- return { runId: match[1]!, role: match[2]! };
45
- }
46
-
47
37
  async function requireRunDirectory(candidate: string, display: string): Promise<string> {
48
38
  let real: string;
49
39
  try {
@@ -68,7 +58,7 @@ async function requireRunDirectory(candidate: string, display: string): Promise<
68
58
  `notary --source-run must be a run directory: ${display}`,
69
59
  );
70
60
  }
71
- const identity = parseRunDirectoryName(basename(real));
61
+ const identity = parseRunLeaf(basename(real));
72
62
  if (identity === undefined) {
73
63
  throw new NotarySourceRunError(
74
64
  `notary --source-run must be named <runId>@<role>: ${basename(real)}`,
@@ -102,13 +92,13 @@ export async function resolveNotarySourceRunLocator(options: {
102
92
  const bookRunsRoot = join(bookDirectory, "runs");
103
93
 
104
94
  let candidate: string;
105
- const bare = parseRunDirectoryName(raw);
95
+ const bare = parseRunLeaf(raw);
106
96
  if (bare !== undefined && !raw.includes("/") && !raw.includes("\\")) {
107
97
  candidate = (await findRunDirectoryById(options.home, bare.runId, bookKey, bare.role))
108
- ?? join(bookRunsRoot, `${bare.runId}@${bare.role}`);
98
+ ?? join(bookRunsRoot, formatRunLeaf(bare.runId, bare.role));
109
99
  } else {
110
100
  candidate = isAbsolute(raw) ? raw : resolve(options.projectRoot, raw);
111
- const identity = parseRunDirectoryName(basename(candidate));
101
+ const identity = parseRunLeaf(basename(candidate));
112
102
  const subjectDirectory = dirname(dirname(candidate));
113
103
  const isLegacyUnboundLocator =
114
104
  identity !== undefined &&
@@ -126,7 +116,7 @@ export async function resolveNotarySourceRunLocator(options: {
126
116
  }
127
117
 
128
118
  const real = await requireRunDirectory(candidate, raw);
129
- const identity = parseRunDirectoryName(basename(real))!;
119
+ const identity = parseRunLeaf(basename(real))!;
130
120
 
131
121
  const bookIdentity = physicalPathIdentity(bookDirectory);
132
122
  const parentIdentity = physicalPathIdentity(dirname(real));
@@ -176,7 +166,7 @@ export async function loadNotarySourceRunLocator(
176
166
  path: string,
177
167
  ): Promise<NotarySourceRunLocator> {
178
168
  const real = await requireRunDirectory(path, path);
179
- const identity = parseRunDirectoryName(basename(real))!;
169
+ const identity = parseRunLeaf(basename(real))!;
180
170
  const runState = await readRetainedIdentity(real);
181
171
  if (runState === undefined) {
182
172
  throw new NotarySourceRunError(
@@ -334,8 +334,6 @@ export const PUBLIC_ROLE_RECORDS = [
334
334
  sourceRunStored: "raw",
335
335
  argvResult: "source-run",
336
336
  sameParent: "source-locator",
337
- /** Infrastructure failure at this stage has no accepted gate cycle to project. */
338
- skipGateOnInfrastructureStage: true,
339
337
  /** 符宝郎 is a review officer and inherits the gatekeeper model. */
340
338
  reviewOfficer: true,
341
339
  provinceConfig: true,
@@ -443,8 +441,6 @@ export const PUBLIC_ROLE_RECORDS = [
443
441
  sameParent: "gate-pointer",
444
442
  /** Resume restores an optional source-run path onto the admitted face. */
445
443
  resumeSourcePath: true,
446
- /** Infrastructure failure at this stage has no accepted gate cycle to project. */
447
- skipGateOnInfrastructureStage: true,
448
444
  /** 台院 is a review officer and inherits the gatekeeper model. */
449
445
  reviewOfficer: true,
450
446
  provinceConfig: true,
@@ -476,8 +472,6 @@ export const PUBLIC_ROLE_RECORDS = [
476
472
  presentSettled: "always",
477
473
  summonResume: true,
478
474
  admission: "instruction",
479
- /** Infrastructure failure at this stage has no accepted gate cycle to project. */
480
- skipGateOnInfrastructureStage: true,
481
475
  sameParent: "none",
482
476
  /** Province model root. Officers name this seat via modelInheritsFrom. */
483
477
  provinceConfig: true,
@@ -725,15 +719,6 @@ export function packagedAuditToolName(role: string): string | undefined {
725
719
  return record.auditTool;
726
720
  }
727
721
 
728
- /** Infrastructure-failure stage that must not be re-read as an accepted gate cycle. */
729
- export function packagedSkipsGateOnInfrastructureStage(stage: unknown): boolean {
730
- return typeof stage === "string" && PUBLIC_ROLE_RECORDS.some((record) =>
731
- "skipGateOnInfrastructureStage" in record
732
- && record.skipGateOnInfrastructureStage === true
733
- && record.role === stage
734
- );
735
- }
736
-
737
722
  /** Durable officer entry whose name is the seat role. */
738
723
  export function packagedDurableOfficerEntry(officer: unknown): boolean {
739
724
  return typeof officer === "string" && PUBLIC_ROLE_RECORDS.some((record) =>
@@ -1,5 +1,3 @@
1
- import { join } from "node:path";
2
-
3
1
  import type {
4
2
  DurablePrincipal,
5
3
  DurablePrincipalAuthority,
@@ -8,7 +6,7 @@ import type {
8
6
  } from "../host-contracts.ts";
9
7
  import { resolveBookKeyFromGit } from "../activation-ledger-git.ts";
10
8
  import { resolveActivationLedgerHome } from "../activation-ledger-topology.ts";
11
- import { roleRunPlacement } from "../role-run-placement.ts";
9
+ import { roleRunPlacement, sessionFileIn } from "../role-run-placement.ts";
12
10
 
13
11
  type PiDurablePrincipal = DurablePrincipal & {
14
12
  readonly sessionDirectory: string;
@@ -69,7 +67,7 @@ export const piDurablePrincipalAuthority: DurablePrincipalAuthority = {
69
67
  sessionFile:
70
68
  typeof record.sessionFile === "string" && record.sessionFile.trim() !== ""
71
69
  ? record.sessionFile
72
- : join(record.sessionDirectory, "session.jsonl"),
70
+ : sessionFileIn(record.sessionDirectory),
73
71
  };
74
72
  },
75
73
  };
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import { execFile, spawn } from "node:child_process";
7
7
  import { constants } from "node:fs";
8
- import { access, appendFile, readFile, realpath } from "node:fs/promises";
8
+ import { access, appendFile, realpath } from "node:fs/promises";
9
9
  import { delimiter, dirname, isAbsolute, join, resolve } from "node:path";
10
10
  import { platform } from "node:process";
11
11
  import { promisify } from "node:util";
@@ -25,6 +25,7 @@ import { ExplicitInternalActivationError } from "../host-contracts.ts";
25
25
  import { applyEngineChildEnv, ENGINE_MODEL_FLAG_NAME, normalizeEngineName } from "../engine-detour.ts";
26
26
  import { projectActivationFlags } from "../role-activation-flags.ts";
27
27
  import { encodeUserDialogueStdin } from "../user-dialogue-stdin.ts";
28
+ import { readStrictPiSessionJsonl } from "../ledger-session-read.ts";
28
29
  import { projectThrownFailureLeaf, retainPackageFault } from "../public-cli/settlement.ts";
29
30
  import { describeErrorIdentity } from "../public-cli/run-lifecycle.ts";
30
31
  import { RECEIPT_DELIVERY_LIMIT_ENV } from "../receipt-delivery-policy.ts";
@@ -462,9 +463,38 @@ export function createPiRoleTurnHost(config: PiRoleTurnHostConfig): RoleTurnHost
462
463
 
463
464
  import { sitianReport } from "../sitian-facade.ts";
464
465
 
466
+ /** Last non-session entry id. Session headers are not parents of custom lines. */
467
+ export function piSessionCustomParentId(
468
+ entries: readonly { id?: unknown; type?: unknown }[],
469
+ ): string | null {
470
+ let parentId: string | null = null;
471
+ for (const entry of entries) {
472
+ if (typeof entry.id === "string" && entry.type !== "session") parentId = entry.id;
473
+ }
474
+ return parentId;
475
+ }
476
+
477
+ /** One Pi custom JSONL line: type, customType, data, id, parentId, timestamp. */
478
+ export function formatPiSessionCustomEntry(input: {
479
+ readonly customType: string;
480
+ readonly data: unknown;
481
+ readonly parentId: string | null;
482
+ readonly timestamp: string;
483
+ }): string {
484
+ return `${JSON.stringify({
485
+ type: "custom",
486
+ customType: input.customType,
487
+ data: input.data,
488
+ id: randomUUID(),
489
+ parentId: input.parentId,
490
+ timestamp: input.timestamp,
491
+ })}\n`;
492
+ }
493
+
465
494
  /**
466
495
  * Append one custom JSONL entry to the durable principal's session file.
467
496
  * Pi session codec only — AK artifact O_EXCL retention stays in public-cli.
497
+ * Read is raw JSON.parse (SyntaxError propagates). Sitian full-traversal stays elsewhere.
468
498
  */
469
499
  export async function appendPiSessionCustomEntry(
470
500
  authority: DurablePrincipalAuthority,
@@ -473,21 +503,14 @@ export async function appendPiSessionCustomEntry(
473
503
  data: unknown,
474
504
  ): Promise<void> {
475
505
  const { sessionFile } = authority.decode(principal);
476
- const text = await readFile(sessionFile, "utf8");
477
- let parentId: string | null = null;
478
- for (const line of text.trim().split("\n").filter(Boolean)) {
479
- const entry = JSON.parse(line) as { id?: unknown; type?: unknown };
480
- if (typeof entry.id === "string" && entry.type !== "session") parentId = entry.id;
481
- }
506
+ const entries = await readStrictPiSessionJsonl(sessionFile) as { id?: unknown; type?: unknown }[];
482
507
  const timestamp = new Date().toISOString();
483
- const pointerLine = `${JSON.stringify({
484
- type: "custom",
508
+ const pointerLine = formatPiSessionCustomEntry({
485
509
  customType,
486
510
  data,
487
- id: randomUUID(),
488
- parentId,
511
+ parentId: piSessionCustomParentId(entries),
489
512
  timestamp,
490
- })}\n`;
513
+ });
491
514
  await appendFile(sessionFile, pointerLine, "utf8");
492
515
  sitianReport({
493
516
  level: "event",
@@ -83,7 +83,7 @@ import { tryResumeSameTicketSeatRun } from "./seat-ticket-binding.ts";
83
83
  import {
84
84
  presentStructuralRejection,
85
85
  readBoundSessionEntries,
86
- attachPostAuditProjection,
86
+ attachPostAuditCountersignFact,
87
87
  trySettlePublicSeat,
88
88
  type SettlementCourtScope,
89
89
  } from "./settlement.ts";
@@ -1399,10 +1399,10 @@ async function auditSubmittedRole(
1399
1399
  }
1400
1400
  }
1401
1401
  // The turn already settled this court (payloads, usage, autoResumeCount, history).
1402
- // Audit only adds gate rounds and, for 中书省, the officer fact. A second
1403
- // settle would republish the same attempt.
1402
+ // Audit only adds, for 中书省, the officer fact. A second settle would
1403
+ // republish the same attempt.
1404
1404
  if (turn.terminal === undefined) throw new Error(`audited ${admitted.role} submission did not settle`);
1405
- const terminal = await attachPostAuditProjection(
1405
+ const terminal = await attachPostAuditCountersignFact(
1406
1406
  admitted,
1407
1407
  env.principalAuthority,
1408
1408
  turn.terminal,
@@ -27,6 +27,7 @@ import { resolveBookKeyFromGit } from "../activation-ledger-git.ts";
27
27
  import {
28
28
  ensureRoleRunDirectory,
29
29
  ensureRoleRunPlacement,
30
+ formatRunLeaf,
30
31
  isUnboundRunDirectory,
31
32
  listBookRunDirectories,
32
33
  roleRunArtifactsDirectory,
@@ -51,6 +52,7 @@ import {
51
52
  } from "../packaged-role-registry.ts";
52
53
  import {
53
54
  isSafePositiveTicketNumber,
55
+ parseTicketNumber,
54
56
  readBoardTicketNumber,
55
57
  requireSafePositiveTicketNumber,
56
58
  } from "../run-ticket-number.ts";
@@ -600,7 +602,7 @@ export async function relocateAdmittedRunToTicket(
600
602
  const childRunIds = parentPage.childDiaristRunIds;
601
603
  for (const childRunId of Array.isArray(childRunIds) ? childRunIds : []) {
602
604
  if (typeof childRunId !== "string") continue;
603
- const childDirectory = join(dirname(oldRunDirectory), `${childRunId}@diarist`);
605
+ const childDirectory = join(dirname(oldRunDirectory), formatRunLeaf(childRunId, "diarist"));
604
606
  // A child that already filed under a ticket keeps its own assertion.
605
607
  if (!existsSync(childDirectory)) continue;
606
608
  const childTarget = roleRunPlacement(ledgerHome, {
@@ -759,20 +761,22 @@ export async function recordLaunchedRolePackageIdentity(
759
761
  });
760
762
  }
761
763
 
764
+ /** Trim, then the shared ticket spelling. Callers keep their own diagnostic text. */
765
+ function rejectUnlessTicketNumber(raw: string, message: string): number {
766
+ const parsed = parseTicketNumber(raw.trim());
767
+ if (parsed === undefined) throw new CliUsageError(message);
768
+ return parsed;
769
+ }
770
+
762
771
  /** Positive ticket number for analyst query-scope face (and shared integer parse). */
763
772
  export function parsePositiveTicketNumber(
764
773
  raw: string,
765
774
  flag: string,
766
775
  ): number {
767
- const trimmed = raw.trim();
768
- if (!ANALYST_TICKET_NUMBER_PATTERN.test(trimmed)) {
769
- throw new CliUsageError(`${flag} must be a positive integer, got ${raw}`);
770
- }
771
- const value = Number(trimmed);
772
- if (!Number.isSafeInteger(value) || value < 1) {
773
- throw new CliUsageError(`${flag} must be a positive integer, got ${raw}`);
774
- }
775
- return value;
776
+ return rejectUnlessTicketNumber(
777
+ raw,
778
+ `${flag} must be a positive integer, got ${raw}`,
779
+ );
776
780
  }
777
781
 
778
782
  /**
@@ -1650,7 +1654,10 @@ export async function admitPublicRole(
1650
1654
  }
1651
1655
  let caseIdentity: DoctorCaseIdentity;
1652
1656
  try {
1653
- const patient = await loadDoctorCase(caseRunsPath);
1657
+ const patient = await loadDoctorCase(
1658
+ caseRunsPath,
1659
+ parsed.runs === undefined ? undefined : placed.projectRoot,
1660
+ );
1654
1661
  if (patient.identity.issueNumber !== issueNumber) {
1655
1662
  throw new CliUsageError(
1656
1663
  `doctor case issue ${patient.identity.issueNumber} does not match --issue ${issueNumber}`,
@@ -2311,32 +2318,21 @@ function isGitRemoteMissing(error: unknown): boolean {
2311
2318
  return status === 2;
2312
2319
  }
2313
2320
 
2314
- /** Positive Issue number grammar shared with Doctor case path identity. */
2315
- const DOCTOR_ISSUE_NUMBER_PATTERN = /^[1-9]\d*$/;
2316
-
2317
- /** Match retained Doctor case runs roots (ADR 0017 / loadDoctorCase). */
2318
- /** Canonical Doctor case: `<book>/<ticket>/runs`. Legacy `issues/<n>/runs` is read-only compat. */
2319
- const DOCTOR_CASE_RUNS_PATH_PATTERN =
2320
- /\/\.ak-roles\/books\/[^/]+\/(?:issues\/)?([1-9]\d*)\/runs$/;
2321
-
2322
2321
  /**
2323
2322
  * Parse a positive Issue number for public Doctor admission.
2324
2323
  * Leading zeros and non-integers are structural rejects.
2325
2324
  */
2326
2325
  export function parseDoctorIssueNumber(raw: string): number {
2327
- const trimmed = raw.trim();
2328
- if (!DOCTOR_ISSUE_NUMBER_PATTERN.test(trimmed)) {
2329
- throw new CliUsageError(
2330
- `doctor --issue must be a positive integer, got ${raw}`,
2331
- );
2332
- }
2333
- return Number(trimmed);
2326
+ return rejectUnlessTicketNumber(
2327
+ raw,
2328
+ `doctor --issue must be a positive integer, got ${raw}`,
2329
+ );
2334
2330
  }
2335
2331
 
2336
2332
  /**
2337
2333
  * Resolve the retained Doctor case runs root from Issue identity.
2338
- * Default is the #78 book locator; optional --runs must stay project-confined
2339
- * and match Doctor case grammar for the same issue number.
2334
+ * Default is the #78 book locator; optional --runs must stay project-confined.
2335
+ * loadDoctorCase owns retained-root grammar at IO; admission matches its case identity.
2340
2336
  */
2341
2337
  export async function resolveDoctorCaseRunsPath(options: {
2342
2338
  home: string;
@@ -2388,18 +2384,6 @@ export async function resolveDoctorCaseRunsPath(options: {
2388
2384
  );
2389
2385
  }
2390
2386
 
2391
- const normalized = real.split(sep).join("/");
2392
- const match = normalized.match(DOCTOR_CASE_RUNS_PATH_PATTERN);
2393
- if (!match) {
2394
- throw new CliUsageError(
2395
- "doctor --runs must be an .ak-roles/books/<book>/<n>/runs directory",
2396
- );
2397
- }
2398
- if (Number(match[1]) !== options.issueNumber) {
2399
- throw new CliUsageError(
2400
- `doctor --runs issue ${match[1]} does not match --issue ${options.issueNumber}`,
2401
- );
2402
- }
2403
2387
  return real;
2404
2388
  }
2405
2389
 
@@ -2477,8 +2461,6 @@ export type AdmitMergerInvocationOptions = {
2477
2461
  assertedTicketNumber?: number;
2478
2462
  };
2479
2463
 
2480
- const ANALYST_TICKET_NUMBER_PATTERN = /^[1-9]\d*$/;
2481
-
2482
2464
  /**
2483
2465
  * Parse a positive ticket / issue number for public analyst admission.
2484
2466
  * Leading zeros and non-integers are structural rejects (same face as #176).
@@ -2488,20 +2470,10 @@ export function parseAnalystTicketNumber(
2488
2470
  raw: string,
2489
2471
  flag: string = "--ticket",
2490
2472
  ): number {
2491
- const trimmed = raw.trim();
2492
- if (!ANALYST_TICKET_NUMBER_PATTERN.test(trimmed)) {
2493
- throw new CliUsageError(
2494
- `analyst ${flag} must be a positive integer, got ${raw}`,
2495
- );
2496
- }
2497
- const value = Number(trimmed);
2498
- // Digit-only strings beyond MAX_SAFE_INTEGER round or become Infinity — reject.
2499
- if (!Number.isSafeInteger(value) || value < 1) {
2500
- throw new CliUsageError(
2501
- `analyst ${flag} must be a positive integer, got ${raw}`,
2502
- );
2503
- }
2504
- return value;
2473
+ return rejectUnlessTicketNumber(
2474
+ raw,
2475
+ `analyst ${flag} must be a positive integer, got ${raw}`,
2476
+ );
2505
2477
  }
2506
2478
 
2507
2479
  /**
@@ -2535,7 +2507,7 @@ export function parseAnalystCohortIssueToken(
2535
2507
  const sep = trimmed.lastIndexOf(":");
2536
2508
  if (sep > 0) {
2537
2509
  const rhs = trimmed.slice(sep + 1);
2538
- if (ANALYST_TICKET_NUMBER_PATTERN.test(rhs)) {
2510
+ if (parseTicketNumber(rhs) !== undefined) {
2539
2511
  const bookKey = trimmed.slice(0, sep);
2540
2512
  if (bookKey.trim() === "") {
2541
2513
  throw new CliUsageError(
@@ -16,7 +16,7 @@ import {
16
16
  activationBookDirectory,
17
17
  resolveActivationLedgerHome,
18
18
  } from "../activation-ledger-topology.ts";
19
- import { listBookRunDirectories } from "../role-run-placement.ts";
19
+ import { findRoleRunDirectory, listBookRunDirectories, parseRunLeaf, sessionFileIn } from "../role-run-placement.ts";
20
20
  import { isSafePositiveTicketNumber } from "../run-ticket-number.ts";
21
21
  import { CliUsageError } from "./cli-errors.ts";
22
22
  import type { FixerPhase } from "../package-contracts/fixer-output.ts";
@@ -25,6 +25,7 @@ import { parseCollectorRepository } from "../collector-config.ts";
25
25
  import type { DoctorCaseIdentity } from "../doctor-contracts.ts";
26
26
  import type { NotarySourceRunLocator } from "../notary-contracts.ts";
27
27
  import {
28
+ retainedRunPathsMatch,
28
29
  rewriteAdmittedRoleRunPage,
29
30
  rewriteRunDirectoryPathValue,
30
31
  } from "../role-run-relocation.ts";
@@ -915,31 +916,12 @@ export async function findRunDirectoryById(
915
916
  if (errorCodeOf(error) === "ENOENT") return undefined;
916
917
  throw error;
917
918
  }
918
- const matches: string[] = [];
919
- for (const bookKey of bookKeys) {
920
- if (onlyBookKey !== undefined && bookKey !== onlyBookKey) continue;
921
- const bookDir = activationBookDirectory(ledgerHome, bookKey);
922
- let runDirectories: string[];
923
- try {
924
- runDirectories = await listBookRunDirectories(bookDir);
925
- } catch (error) {
926
- if (errorCodeOf(error) === "ENOENT") continue;
927
- throw error;
928
- }
929
- for (const runDirectory of runDirectories) {
930
- const entry = basename(runDirectory);
931
- if (
932
- (onlyRole === undefined && (entry === `${runId}@judge` || entry.startsWith(`${runId}@`))) ||
933
- entry === `${runId}@${onlyRole}`
934
- ) {
935
- matches.push(runDirectory);
936
- }
937
- }
938
- }
939
- if (matches.length === 0) return undefined;
940
- if (matches.length === 1) return matches[0];
941
- throw new Error(
942
- `ambiguous role run id ${runId}: ${matches.join(", ")}`,
919
+ return findRoleRunDirectory(
920
+ bookKeys
921
+ .filter((bookKey) => onlyBookKey === undefined || bookKey === onlyBookKey)
922
+ .map((bookKey) => activationBookDirectory(ledgerHome, bookKey)),
923
+ runId,
924
+ onlyRole,
943
925
  );
944
926
  }
945
927
 
@@ -999,7 +981,7 @@ async function runHasFormedSessionPrincipal(runDirectory: string): Promise<boole
999
981
  typeof disk.principalWire.sessionFile === "string" &&
1000
982
  disk.principalWire.sessionFile.trim() !== ""
1001
983
  ? disk.principalWire.sessionFile
1002
- : join(disk.principalWire.sessionDirectory, "session.jsonl");
984
+ : sessionFileIn(disk.principalWire.sessionDirectory);
1003
985
  try {
1004
986
  const stat = await lstat(sessionFile);
1005
987
  return stat.isFile() && !stat.isSymbolicLink();
@@ -1040,17 +1022,14 @@ export async function findLatestRunIdForSeatTicket(input: {
1040
1022
  if (errorCodeOf(error) === "ENOENT") return undefined;
1041
1023
  throw error;
1042
1024
  }
1043
- const suffix = `@${input.role}`;
1044
1025
  let best: string | undefined;
1045
1026
  for (const runDirectory of runDirectories) {
1046
- const entry = basename(runDirectory);
1047
- if (!entry.endsWith(suffix)) continue;
1048
- const runId = entry.slice(0, entry.length - suffix.length);
1049
- if (runId.length === 0) continue;
1027
+ const parsed = parseRunLeaf(basename(runDirectory));
1028
+ if (parsed === undefined || parsed.role !== input.role) continue;
1029
+ const runId = parsed.runId;
1050
1030
  const parentPath = await readRunParentPath(runDirectory);
1051
- // The parent's ticket can change after this child was admitted. Within one
1052
- // book the run leaf is its stable identity; directory placement is not.
1053
- if (parentPath !== input.parentRunPath && basename(parentPath ?? "") !== basename(input.parentRunPath)) continue;
1031
+ // Follow the parent's retained placement without collapsing distinct book/subject bindings.
1032
+ if (!await retainedRunPathsMatch(parentPath, input.parentRunPath, input.parentRunPath)) continue;
1054
1033
  // Durable fact: never resume-select a provisional that never formed principal.
1055
1034
  if (!(await runHasFormedSessionPrincipal(runDirectory))) continue;
1056
1035
  if (best === undefined || runId > best) best = runId;
@@ -1226,7 +1205,7 @@ async function loadResumableRunRecord(
1226
1205
  lens = record.lens;
1227
1206
  }
1228
1207
  // Collector — admitted repository/PR identity (#633 resume).
1229
- if (typeof record.prNumber === "number" && Number.isSafeInteger(record.prNumber) && record.prNumber >= 1) {
1208
+ if (isSafePositiveTicketNumber(record.prNumber)) {
1230
1209
  prNumber = record.prNumber;
1231
1210
  }
1232
1211
  if (typeof record.repository === "string" && record.repository.trim() !== "") {
@@ -1245,7 +1224,7 @@ async function loadResumableRunRecord(
1245
1224
  waitWindowMs = record.waitWindowMs;
1246
1225
  }
1247
1226
  // Doctor — admitted single-case identity (#633 resume).
1248
- if (typeof record.issueNumber === "number" && Number.isSafeInteger(record.issueNumber) && record.issueNumber >= 1) {
1227
+ if (isSafePositiveTicketNumber(record.issueNumber)) {
1249
1228
  issueNumber = record.issueNumber;
1250
1229
  }
1251
1230
  if (typeof record.caseRunsPath === "string" && record.caseRunsPath.trim() !== "") {
@@ -1256,9 +1235,7 @@ async function loadResumableRunRecord(
1256
1235
  ) {
1257
1236
  const ci = record.caseIdentity as Record<string, unknown>;
1258
1237
  if (
1259
- typeof ci.issueNumber === "number" &&
1260
- Number.isSafeInteger(ci.issueNumber) &&
1261
- ci.issueNumber >= 1 &&
1238
+ isSafePositiveTicketNumber(ci.issueNumber) &&
1262
1239
  typeof ci.runsPath === "string" &&
1263
1240
  ci.runsPath.trim() !== ""
1264
1241
  ) {
@@ -1376,9 +1353,9 @@ async function loadResumableRunRecord(
1376
1353
  );
1377
1354
  }
1378
1355
  }
1379
- const sourceRunLeaf = basename(sourceRunPath ?? "").split("@");
1380
- const referencedRunId = sourceRun?.runId ?? sourceRunLeaf[0];
1381
- const referencedRole = sourceRun?.role ?? sourceRunLeaf[1];
1356
+ const sourceRunLeaf = parseRunLeaf(basename(sourceRunPath ?? ""));
1357
+ const referencedRunId = sourceRun?.runId ?? sourceRunLeaf?.runId;
1358
+ const referencedRole = sourceRun?.role ?? sourceRunLeaf?.role;
1382
1359
  if (referencedRunId !== undefined && referencedRunId !== "") {
1383
1360
  const currentSourceRunDirectory = await findRunDirectoryById(
1384
1361
  home,