@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
@@ -1,6 +1,6 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { basename } from "node:path";
3
- import { sitianReport } from "./sitian-facade.js";
3
+ import { sitianReportSafe } from "./host-session-record.js";
4
4
  import {
5
5
  NavigatorUnavailableError,
6
6
  navigatorProviderFailureFromPublicTerminal,
@@ -9,6 +9,7 @@ import {
9
9
  parseNavigatorModelSetting
10
10
  } from "./navigator-session-contracts.js";
11
11
  import { runDirectoryFromHostContext } from "./host-contracts.js";
12
+ import { parseRunLeaf } from "./role-run-placement.js";
12
13
  import { CliUsageError } from "./public-cli/cli-errors.js";
13
14
  import { isNavigatorSeat } from "./packaged-role-registry.js";
14
15
  import {
@@ -32,11 +33,9 @@ async function resolveNavigatorLedgerHome(context) {
32
33
  }
33
34
  const NAVIGATOR_HOST_RUN_POINTER_ENTRY = "ak-navigator-host-run";
34
35
  function runIdFromNavigatorDirectory(runDirectory) {
35
- const entry = basename(runDirectory);
36
- const suffix = "@navigator";
37
- if (!entry.endsWith(suffix)) return void 0;
38
- const runId = entry.slice(0, entry.length - suffix.length);
39
- return runId.length > 0 ? runId : void 0;
36
+ const parsed = parseRunLeaf(basename(runDirectory));
37
+ if (parsed === void 0 || parsed.role !== "navigator") return void 0;
38
+ return parsed.runId;
40
39
  }
41
40
  function readNavigatorHostRunPointer(entries) {
42
41
  for (let index = entries.length - 1; index >= 0; index -= 1) {
@@ -188,17 +187,14 @@ function createNativeNavigatorSessionFactory(deps) {
188
187
  routePlaybookReadFailure: () => routePlaybookReadFailure,
189
188
  appendEntry: (customType, data) => {
190
189
  sessionManager.appendCustomEntry(customType, data);
191
- try {
192
- sitianReport({
193
- level: "event",
194
- kind: "attendance",
195
- cwd: context.cwd,
196
- sessionParent: sessionManager.getSessionFile(),
197
- payload: { customType, data },
198
- source: "navigator-public-session"
199
- });
200
- } catch {
201
- }
190
+ sitianReportSafe({
191
+ level: "event",
192
+ kind: "attendance",
193
+ cwd: context.cwd,
194
+ sessionParent: sessionManager.getSessionFile(),
195
+ payload: { customType, data },
196
+ source: "navigator-public-session"
197
+ });
202
198
  },
203
199
  entries: () => sessionManager.getEntries(),
204
200
  setModel: async (next, nextThinking) => {
@@ -8,6 +8,7 @@ import { lstat, realpath } from "node:fs/promises";
8
8
  import { resolveBookKeyFromGit } from "./activation-ledger-git.js";
9
9
  import { activationBookDirectory, physicalPathIdentity, resolveActivationLedgerHome, } from "./activation-ledger-topology.js";
10
10
  import { findRunDirectoryById, readRoleRunIdentity } from "./public-cli/run-lifecycle.js";
11
+ import { formatRunLeaf, parseRunLeaf } from "./role-run-placement.js";
11
12
  async function readRetainedIdentity(runDirectory) {
12
13
  try {
13
14
  return await readRoleRunIdentity(runDirectory);
@@ -20,21 +21,12 @@ async function readRetainedIdentity(runDirectory) {
20
21
  });
21
22
  }
22
23
  }
23
- // Run directory leaf: <runId>@<role>. Production uses uuidv7 runIds; offline tracers
24
- // may use deterministic non-uuid leaves. Role token stays identifier-shaped.
25
- const RUN_DIR_NAME = /^([A-Za-z0-9][A-Za-z0-9._-]*)@([A-Za-z][A-Za-z0-9_-]*)$/;
26
24
  export class NotarySourceRunError extends Error {
27
25
  constructor(message, options) {
28
26
  super(message, options);
29
27
  this.name = "NotarySourceRunError";
30
28
  }
31
29
  }
32
- function parseRunDirectoryName(name) {
33
- const match = RUN_DIR_NAME.exec(name);
34
- if (match === null)
35
- return undefined;
36
- return { runId: match[1], role: match[2] };
37
- }
38
30
  async function requireRunDirectory(candidate, display) {
39
31
  let real;
40
32
  try {
@@ -53,7 +45,7 @@ async function requireRunDirectory(candidate, display) {
53
45
  if (!stat.isDirectory()) {
54
46
  throw new NotarySourceRunError(`notary --source-run must be a run directory: ${display}`);
55
47
  }
56
- const identity = parseRunDirectoryName(basename(real));
48
+ const identity = parseRunLeaf(basename(real));
57
49
  if (identity === undefined) {
58
50
  throw new NotarySourceRunError(`notary --source-run must be named <runId>@<role>: ${basename(real)}`);
59
51
  }
@@ -78,14 +70,14 @@ export async function resolveNotarySourceRunLocator(options) {
78
70
  const bookDirectory = activationBookDirectory(ledgerHome, bookKey);
79
71
  const bookRunsRoot = join(bookDirectory, "runs");
80
72
  let candidate;
81
- const bare = parseRunDirectoryName(raw);
73
+ const bare = parseRunLeaf(raw);
82
74
  if (bare !== undefined && !raw.includes("/") && !raw.includes("\\")) {
83
75
  candidate = (await findRunDirectoryById(options.home, bare.runId, bookKey, bare.role))
84
- ?? join(bookRunsRoot, `${bare.runId}@${bare.role}`);
76
+ ?? join(bookRunsRoot, formatRunLeaf(bare.runId, bare.role));
85
77
  }
86
78
  else {
87
79
  candidate = isAbsolute(raw) ? raw : resolve(options.projectRoot, raw);
88
- const identity = parseRunDirectoryName(basename(candidate));
80
+ const identity = parseRunLeaf(basename(candidate));
89
81
  const subjectDirectory = dirname(dirname(candidate));
90
82
  const isLegacyUnboundLocator = identity !== undefined &&
91
83
  basename(dirname(candidate)) === "runs" &&
@@ -96,7 +88,7 @@ export async function resolveNotarySourceRunLocator(options) {
96
88
  }
97
89
  }
98
90
  const real = await requireRunDirectory(candidate, raw);
99
- const identity = parseRunDirectoryName(basename(real));
91
+ const identity = parseRunLeaf(basename(real));
100
92
  const bookIdentity = physicalPathIdentity(bookDirectory);
101
93
  const parentIdentity = physicalPathIdentity(dirname(real));
102
94
  const subjectBookIdentity = physicalPathIdentity(dirname(dirname(dirname(real))));
@@ -128,7 +120,7 @@ export async function resolveNotarySourceRunLocator(options) {
128
120
  /** Internal activation loader: path is the admitted absolute source run directory. */
129
121
  export async function loadNotarySourceRunLocator(path) {
130
122
  const real = await requireRunDirectory(path, path);
131
- const identity = parseRunDirectoryName(basename(real));
123
+ const identity = parseRunLeaf(basename(real));
132
124
  const runState = await readRetainedIdentity(real);
133
125
  if (runState === undefined) {
134
126
  throw new NotarySourceRunError("notary source-run lacks retained run-state identity");
@@ -297,8 +297,6 @@ const PUBLIC_ROLE_RECORDS = [
297
297
  sourceRunStored: "raw",
298
298
  argvResult: "source-run",
299
299
  sameParent: "source-locator",
300
- /** Infrastructure failure at this stage has no accepted gate cycle to project. */
301
- skipGateOnInfrastructureStage: true,
302
300
  /** 符宝郎 is a review officer and inherits the gatekeeper model. */
303
301
  reviewOfficer: true,
304
302
  provinceConfig: true,
@@ -406,8 +404,6 @@ const PUBLIC_ROLE_RECORDS = [
406
404
  sameParent: "gate-pointer",
407
405
  /** Resume restores an optional source-run path onto the admitted face. */
408
406
  resumeSourcePath: true,
409
- /** Infrastructure failure at this stage has no accepted gate cycle to project. */
410
- skipGateOnInfrastructureStage: true,
411
407
  /** 台院 is a review officer and inherits the gatekeeper model. */
412
408
  reviewOfficer: true,
413
409
  provinceConfig: true,
@@ -439,8 +435,6 @@ const PUBLIC_ROLE_RECORDS = [
439
435
  presentSettled: "always",
440
436
  summonResume: true,
441
437
  admission: "instruction",
442
- /** Infrastructure failure at this stage has no accepted gate cycle to project. */
443
- skipGateOnInfrastructureStage: true,
444
438
  sameParent: "none",
445
439
  /** Province model root. Officers name this seat via modelInheritsFrom. */
446
440
  provinceConfig: true,
@@ -608,11 +602,6 @@ function packagedAuditToolName(role) {
608
602
  if (record === void 0 || !("auditTool" in record)) return void 0;
609
603
  return record.auditTool;
610
604
  }
611
- function packagedSkipsGateOnInfrastructureStage(stage) {
612
- return typeof stage === "string" && PUBLIC_ROLE_RECORDS.some(
613
- (record) => "skipGateOnInfrastructureStage" in record && record.skipGateOnInfrastructureStage === true && record.role === stage
614
- );
615
- }
616
605
  function packagedDurableOfficerEntry(officer) {
617
606
  return typeof officer === "string" && PUBLIC_ROLE_RECORDS.some(
618
607
  (record) => "durableOfficerEntry" in record && record.durableOfficerEntry === true && record.role === officer
@@ -699,7 +688,6 @@ export {
699
688
  packagedRoleOutputTool,
700
689
  packagedRolePhaseFlag,
701
690
  packagedRoleSessionMaterials,
702
- packagedSkipsGateOnInfrastructureStage,
703
691
  packagedStoresSourceRunRaw,
704
692
  packagedSubjectChoices
705
693
  };
@@ -1,7 +1,6 @@
1
- import { join } from "node:path";
2
1
  import { resolveBookKeyFromGit } from "../activation-ledger-git.js";
3
2
  import { resolveActivationLedgerHome } from "../activation-ledger-topology.js";
4
- import { roleRunPlacement } from "../role-run-placement.js";
3
+ import { roleRunPlacement, sessionFileIn } from "../role-run-placement.js";
5
4
  function encode(coordinates) {
6
5
  return coordinates;
7
6
  }
@@ -47,7 +46,7 @@ export const piDurablePrincipalAuthority = {
47
46
  sessionDirectory: record.sessionDirectory,
48
47
  sessionFile: typeof record.sessionFile === "string" && record.sessionFile.trim() !== ""
49
48
  ? record.sessionFile
50
- : join(record.sessionDirectory, "session.jsonl"),
49
+ : sessionFileIn(record.sessionDirectory),
51
50
  };
52
51
  },
53
52
  };
@@ -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";
@@ -14,6 +14,7 @@ import { ExplicitInternalActivationError } from "../host-contracts.js";
14
14
  import { applyEngineChildEnv, ENGINE_MODEL_FLAG_NAME, normalizeEngineName } from "../engine-detour.js";
15
15
  import { projectActivationFlags } from "../role-activation-flags.js";
16
16
  import { encodeUserDialogueStdin } from "../user-dialogue-stdin.js";
17
+ import { readStrictPiSessionJsonl } from "../ledger-session-read.js";
17
18
  import { projectThrownFailureLeaf, retainPackageFault } from "../public-cli/settlement.js";
18
19
  import { describeErrorIdentity } from "../public-cli/run-lifecycle.js";
19
20
  import { RECEIPT_DELIVERY_LIMIT_ENV } from "../receipt-delivery-policy.js";
@@ -361,28 +362,41 @@ export function createPiRoleTurnHost(config) {
361
362
  };
362
363
  }
363
364
  import { sitianReport } from "../sitian-facade.js";
365
+ /** Last non-session entry id. Session headers are not parents of custom lines. */
366
+ export function piSessionCustomParentId(entries) {
367
+ let parentId = null;
368
+ for (const entry of entries) {
369
+ if (typeof entry.id === "string" && entry.type !== "session")
370
+ parentId = entry.id;
371
+ }
372
+ return parentId;
373
+ }
374
+ /** One Pi custom JSONL line: type, customType, data, id, parentId, timestamp. */
375
+ export function formatPiSessionCustomEntry(input) {
376
+ return `${JSON.stringify({
377
+ type: "custom",
378
+ customType: input.customType,
379
+ data: input.data,
380
+ id: randomUUID(),
381
+ parentId: input.parentId,
382
+ timestamp: input.timestamp,
383
+ })}\n`;
384
+ }
364
385
  /**
365
386
  * Append one custom JSONL entry to the durable principal's session file.
366
387
  * Pi session codec only — AK artifact O_EXCL retention stays in public-cli.
388
+ * Read is raw JSON.parse (SyntaxError propagates). Sitian full-traversal stays elsewhere.
367
389
  */
368
390
  export async function appendPiSessionCustomEntry(authority, principal, customType, data) {
369
391
  const { sessionFile } = authority.decode(principal);
370
- const text = await readFile(sessionFile, "utf8");
371
- let parentId = null;
372
- for (const line of text.trim().split("\n").filter(Boolean)) {
373
- const entry = JSON.parse(line);
374
- if (typeof entry.id === "string" && entry.type !== "session")
375
- parentId = entry.id;
376
- }
392
+ const entries = await readStrictPiSessionJsonl(sessionFile);
377
393
  const timestamp = new Date().toISOString();
378
- const pointerLine = `${JSON.stringify({
379
- type: "custom",
394
+ const pointerLine = formatPiSessionCustomEntry({
380
395
  customType,
381
396
  data,
382
- id: randomUUID(),
383
- parentId,
397
+ parentId: piSessionCustomParentId(entries),
384
398
  timestamp,
385
- })}\n`;
399
+ });
386
400
  await appendFile(sessionFile, pointerLine, "utf8");
387
401
  sitianReport({
388
402
  level: "event",
@@ -33,7 +33,7 @@ import { presentControlledFailure, packageFaultNoteFor, prepareSummonsResumeMate
33
33
  import { presentTerminal } from "./auto-resume.js";
34
34
  import { buildAutoResumeContinuationPrompt, loadResumablePublicRole, markRunAdmitted, parentRunPathFromGatePointerInstruction, readCurrentCourt, } from "./run-lifecycle.js";
35
35
  import { tryResumeSameTicketSeatRun } from "./seat-ticket-binding.js";
36
- import { presentStructuralRejection, readBoundSessionEntries, attachPostAuditProjection, trySettlePublicSeat, } from "./settlement.js";
36
+ import { presentStructuralRejection, readBoundSessionEntries, attachPostAuditCountersignFact, trySettlePublicSeat, } from "./settlement.js";
37
37
  import { warnMissingMethodSkills } from "./machine-method-skills.js";
38
38
  import { formatTerminalResult, isLawfulTypedTerminalOutcome, } from "./terminal.js";
39
39
  import { admittedSeatTurnDetails, projectRoleTurnRequest, requiredMethodSkills, } from "./turn-request.js";
@@ -1151,11 +1151,11 @@ async function auditSubmittedRole(turn, env, io, resumedOfficer, passedReceipt)
1151
1151
  }
1152
1152
  }
1153
1153
  // The turn already settled this court (payloads, usage, autoResumeCount, history).
1154
- // Audit only adds gate rounds and, for 中书省, the officer fact. A second
1155
- // settle would republish the same attempt.
1154
+ // Audit only adds, for 中书省, the officer fact. A second settle would
1155
+ // republish the same attempt.
1156
1156
  if (turn.terminal === undefined)
1157
1157
  throw new Error(`audited ${admitted.role} submission did not settle`);
1158
- const terminal = await attachPostAuditProjection(admitted, env.principalAuthority, turn.terminal, packageFaultScope(admitted, env, io));
1158
+ const terminal = await attachPostAuditCountersignFact(admitted, env.principalAuthority, turn.terminal, packageFaultScope(admitted, env, io));
1159
1159
  turn = { ...turn, terminal };
1160
1160
  await presentTerminal(terminal, { ...io, omitFailureStderrDiagnostic: true }, admitted.runDirectory);
1161
1161
  return turn;
@@ -9,9 +9,9 @@ import { tmpdir } from "node:os";
9
9
  import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
10
10
  import { activationBookDirectory, ensureRealDirectoryTree, homeFromRunDirectory, pathContainedIn, resolveActivationLedgerHome, } from "../activation-ledger-topology.js";
11
11
  import { resolveBookKeyFromGit } from "../activation-ledger-git.js";
12
- import { ensureRoleRunDirectory, ensureRoleRunPlacement, isUnboundRunDirectory, listBookRunDirectories, roleRunArtifactsDirectory, roleRunPlacement, } from "../role-run-placement.js";
12
+ import { ensureRoleRunDirectory, ensureRoleRunPlacement, formatRunLeaf, isUnboundRunDirectory, listBookRunDirectories, roleRunArtifactsDirectory, roleRunPlacement, } from "../role-run-placement.js";
13
13
  import { packagedAdmittedSubject, packagedArgvResult, packagedBareToken, packagedBaseIsRevision, packagedEmitsAuthorityRefs, packagedPublicInstructionSubject, packagedRoleMetadata, packagedStoresSourceRunRaw, packagedSubjectChoices, } from "../packaged-role-registry.js";
14
- import { isSafePositiveTicketNumber, readBoardTicketNumber, requireSafePositiveTicketNumber, } from "../run-ticket-number.js";
14
+ import { isSafePositiveTicketNumber, parseTicketNumber, readBoardTicketNumber, requireSafePositiveTicketNumber, } from "../run-ticket-number.js";
15
15
  import { rewriteRunDirectoryPathFields, rewriteRunDirectoryPathValue, } from "../role-run-relocation.js";
16
16
  import { loadDoctorCase, } from "../doctor-evidence.js";
17
17
  import { emptyCollectorManifest, loadCollectorManifest, parseCollectorPrNumber, parseCollectorRepository, } from "../collector-config.js";
@@ -230,7 +230,7 @@ export async function relocateAdmittedRunToTicket(admitted, authority, heldLease
230
230
  for (const childRunId of Array.isArray(childRunIds) ? childRunIds : []) {
231
231
  if (typeof childRunId !== "string")
232
232
  continue;
233
- const childDirectory = join(dirname(oldRunDirectory), `${childRunId}@diarist`);
233
+ const childDirectory = join(dirname(oldRunDirectory), formatRunLeaf(childRunId, "diarist"));
234
234
  // A child that already filed under a ticket keeps its own assertion.
235
235
  if (!existsSync(childDirectory))
236
236
  continue;
@@ -334,17 +334,16 @@ export async function recordLaunchedRolePackageIdentity(runDirectory, identity)
334
334
  entryMode: identity.entryMode,
335
335
  });
336
336
  }
337
+ /** Trim, then the shared ticket spelling. Callers keep their own diagnostic text. */
338
+ function rejectUnlessTicketNumber(raw, message) {
339
+ const parsed = parseTicketNumber(raw.trim());
340
+ if (parsed === undefined)
341
+ throw new CliUsageError(message);
342
+ return parsed;
343
+ }
337
344
  /** Positive ticket number for analyst query-scope face (and shared integer parse). */
338
345
  export function parsePositiveTicketNumber(raw, flag) {
339
- const trimmed = raw.trim();
340
- if (!ANALYST_TICKET_NUMBER_PATTERN.test(trimmed)) {
341
- throw new CliUsageError(`${flag} must be a positive integer, got ${raw}`);
342
- }
343
- const value = Number(trimmed);
344
- if (!Number.isSafeInteger(value) || value < 1) {
345
- throw new CliUsageError(`${flag} must be a positive integer, got ${raw}`);
346
- }
347
- return value;
346
+ return rejectUnlessTicketNumber(raw, `${flag} must be a positive integer, got ${raw}`);
348
347
  }
349
348
  /**
350
349
  * One token scan for public argv.
@@ -957,7 +956,7 @@ export async function admitPublicRole(role, parsed, env, override) {
957
956
  }
958
957
  let caseIdentity;
959
958
  try {
960
- const patient = await loadDoctorCase(caseRunsPath);
959
+ const patient = await loadDoctorCase(caseRunsPath, parsed.runs === undefined ? undefined : placed.projectRoot);
961
960
  if (patient.identity.issueNumber !== issueNumber) {
962
961
  throw new CliUsageError(`doctor case issue ${patient.identity.issueNumber} does not match --issue ${issueNumber}`);
963
962
  }
@@ -1411,26 +1410,17 @@ function isGitRemoteMissing(error) {
1411
1410
  const status = error.status;
1412
1411
  return status === 2;
1413
1412
  }
1414
- /** Positive Issue number grammar shared with Doctor case path identity. */
1415
- const DOCTOR_ISSUE_NUMBER_PATTERN = /^[1-9]\d*$/;
1416
- /** Match retained Doctor case runs roots (ADR 0017 / loadDoctorCase). */
1417
- /** Canonical Doctor case: `<book>/<ticket>/runs`. Legacy `issues/<n>/runs` is read-only compat. */
1418
- const DOCTOR_CASE_RUNS_PATH_PATTERN = /\/\.ak-roles\/books\/[^/]+\/(?:issues\/)?([1-9]\d*)\/runs$/;
1419
1413
  /**
1420
1414
  * Parse a positive Issue number for public Doctor admission.
1421
1415
  * Leading zeros and non-integers are structural rejects.
1422
1416
  */
1423
1417
  export function parseDoctorIssueNumber(raw) {
1424
- const trimmed = raw.trim();
1425
- if (!DOCTOR_ISSUE_NUMBER_PATTERN.test(trimmed)) {
1426
- throw new CliUsageError(`doctor --issue must be a positive integer, got ${raw}`);
1427
- }
1428
- return Number(trimmed);
1418
+ return rejectUnlessTicketNumber(raw, `doctor --issue must be a positive integer, got ${raw}`);
1429
1419
  }
1430
1420
  /**
1431
1421
  * Resolve the retained Doctor case runs root from Issue identity.
1432
- * Default is the #78 book locator; optional --runs must stay project-confined
1433
- * and match Doctor case grammar for the same issue number.
1422
+ * Default is the #78 book locator; optional --runs must stay project-confined.
1423
+ * loadDoctorCase owns retained-root grammar at IO; admission matches its case identity.
1434
1424
  */
1435
1425
  export async function resolveDoctorCaseRunsPath(options) {
1436
1426
  const ledgerHome = resolveActivationLedgerHome(options.home);
@@ -1460,14 +1450,6 @@ export async function resolveDoctorCaseRunsPath(options) {
1460
1450
  const detail = errorText(error);
1461
1451
  throw new CliUsageError(`doctor --runs is not a readable retained runs root: ${detail}`, { cause: error });
1462
1452
  }
1463
- const normalized = real.split(sep).join("/");
1464
- const match = normalized.match(DOCTOR_CASE_RUNS_PATH_PATTERN);
1465
- if (!match) {
1466
- throw new CliUsageError("doctor --runs must be an .ak-roles/books/<book>/<n>/runs directory");
1467
- }
1468
- if (Number(match[1]) !== options.issueNumber) {
1469
- throw new CliUsageError(`doctor --runs issue ${match[1]} does not match --issue ${options.issueNumber}`);
1470
- }
1471
1453
  return real;
1472
1454
  }
1473
1455
  /** Frozen Skill arg projection shared by initial and resume (never reverse-parsed). */
@@ -1501,23 +1483,13 @@ export async function deriveMergerEnvelopeFromActiveMerge(projectRoot, gitState
1501
1483
  resolutionScope,
1502
1484
  });
1503
1485
  }
1504
- const ANALYST_TICKET_NUMBER_PATTERN = /^[1-9]\d*$/;
1505
1486
  /**
1506
1487
  * Parse a positive ticket / issue number for public analyst admission.
1507
1488
  * Leading zeros and non-integers are structural rejects (same face as #176).
1508
1489
  * `flag` names the actual argv face in diagnostics (cohort group lists reuse this).
1509
1490
  */
1510
1491
  export function parseAnalystTicketNumber(raw, flag = "--ticket") {
1511
- const trimmed = raw.trim();
1512
- if (!ANALYST_TICKET_NUMBER_PATTERN.test(trimmed)) {
1513
- throw new CliUsageError(`analyst ${flag} must be a positive integer, got ${raw}`);
1514
- }
1515
- const value = Number(trimmed);
1516
- // Digit-only strings beyond MAX_SAFE_INTEGER round or become Infinity — reject.
1517
- if (!Number.isSafeInteger(value) || value < 1) {
1518
- throw new CliUsageError(`analyst ${flag} must be a positive integer, got ${raw}`);
1519
- }
1520
- return value;
1492
+ return rejectUnlessTicketNumber(raw, `analyst ${flag} must be a positive integer, got ${raw}`);
1521
1493
  }
1522
1494
  /**
1523
1495
  * Parse one cohort issue token: bare positive integer or `book:N`.
@@ -1532,7 +1504,7 @@ export function parseAnalystCohortIssueToken(raw, flag) {
1532
1504
  const sep = trimmed.lastIndexOf(":");
1533
1505
  if (sep > 0) {
1534
1506
  const rhs = trimmed.slice(sep + 1);
1535
- if (ANALYST_TICKET_NUMBER_PATTERN.test(rhs)) {
1507
+ if (parseTicketNumber(rhs) !== undefined) {
1536
1508
  const bookKey = trimmed.slice(0, sep);
1537
1509
  if (bookKey.trim() === "") {
1538
1510
  throw new CliUsageError(`${flag} book:N requires a non-empty book key, got ${raw}`);