@akagilnc/pi-workflow-roles 0.1.4035 → 0.1.4193

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 (108) hide show
  1. package/dist/acp-host/production-host.js +2368 -1155
  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-auditor.js +1 -1
  7. package/dist/doctor-evidence.js +3 -2
  8. package/dist/dossier-resolution.js +21 -8
  9. package/dist/gatekeeper-role.js +34 -27
  10. package/dist/headless-host/description.js +7 -8
  11. package/dist/headless-host/production-host.js +2375 -1149
  12. package/dist/host-contracts.js +16 -0
  13. package/dist/inspector-contracts.js +8 -0
  14. package/dist/migrate-book-topology.js +278917 -0
  15. package/dist/navigator-public-session.js +22 -25
  16. package/dist/notary-source-run.js +7 -4
  17. package/dist/packaged-role-registry.js +1 -1
  18. package/dist/pi/durable-principal.js +9 -6
  19. package/dist/pi/role-turn-host.js +43 -13
  20. package/dist/public-cli/auto-resume.js +2 -5
  21. package/dist/public-cli/case-dossier-delivery.js +60 -1
  22. package/dist/public-cli/diarist-run.js +27 -25
  23. package/dist/public-cli/inspector-run.js +17 -3
  24. package/dist/public-cli/instruction-seat-run.js +17 -17
  25. package/dist/public-cli/invocation.js +127 -63
  26. package/dist/public-cli/main.js +1685 -696
  27. package/dist/public-cli/notary-run.js +7 -4
  28. package/dist/public-cli/option-definitions.js +2 -2
  29. package/dist/public-cli/post-admission.js +113 -71
  30. package/dist/public-cli/run-lifecycle.js +209 -58
  31. package/dist/public-cli/seat-ticket-binding.js +3 -6
  32. package/dist/public-cli/settlement.js +29 -10
  33. package/dist/public-role-summons.js +8 -0
  34. package/dist/role-run-placement.js +84 -0
  35. package/dist/role-run-relocation.js +358 -0
  36. package/dist/run-terminal-artifacts.js +2 -1
  37. package/dist/run-ticket-number.js +51 -32
  38. package/dist/sitian-appender.js +11 -26
  39. package/dist/submission-ledger.js +106 -25
  40. package/dist/ticket-provenance.js +3 -1
  41. package/dist/user-dialogue-stdin.js +33 -0
  42. package/extensions/role-runtime.ts +1 -0
  43. package/package.json +3 -2
  44. package/resources/836-deleted-machine-instruction-inventory.md +1 -1
  45. package/scripts/build-package.mjs +34 -2
  46. package/souls/fixer.md +3 -2
  47. package/src/analyst-ledger.ts +14 -15
  48. package/src/archivist-record-entry.ts +285 -38
  49. package/src/archivist-record-topology.ts +43 -18
  50. package/src/auditor-dossier-tool.ts +2 -1
  51. package/src/book-topology-migration-placement.ts +493 -0
  52. package/src/book-topology-migration.ts +220 -0
  53. package/src/book-topology-migrators-t11.ts +856 -0
  54. package/src/book-topology-mixed-volume-migrators.ts +470 -0
  55. package/src/book-topology-partition-migrators.ts +25 -0
  56. package/src/book-topology-record-class-migrators.ts +779 -0
  57. package/src/book-topology-runs-migrator.ts +244 -0
  58. package/src/diarist-contracts.ts +116 -0
  59. package/src/diarist-role.ts +6 -0
  60. package/src/diarist.ts +2 -0
  61. package/src/doctor-auditor.ts +1 -1
  62. package/src/doctor-evidence.ts +4 -1
  63. package/src/doctor-role.ts +1 -1
  64. package/src/dossier-resolution.ts +26 -8
  65. package/src/external-host-turn-loop.ts +9 -0
  66. package/src/gatekeeper-pass-envelope.ts +6 -0
  67. package/src/gatekeeper-role.ts +47 -28
  68. package/src/headless-host/description.ts +8 -11
  69. package/src/headless-host/role-turn-host.ts +20 -5
  70. package/src/host-contracts.ts +22 -3
  71. package/src/inspector-contracts.ts +7 -0
  72. package/src/judge-role.ts +4 -0
  73. package/src/navigator-public-session.ts +33 -30
  74. package/src/navigator-work-context.ts +3 -4
  75. package/src/notary-role.ts +1 -0
  76. package/src/notary-source-run.ts +9 -4
  77. package/src/packaged-role-registry.ts +1 -1
  78. package/src/pi/adapter.ts +19 -4
  79. package/src/pi/durable-principal.ts +9 -13
  80. package/src/pi/role-turn-host.ts +48 -14
  81. package/src/public-cli/auto-resume.ts +2 -6
  82. package/src/public-cli/case-dossier-delivery.ts +86 -1
  83. package/src/public-cli/countersign-run.ts +182 -32
  84. package/src/public-cli/diarist-run.ts +34 -26
  85. package/src/public-cli/inspector-run.ts +26 -2
  86. package/src/public-cli/instruction-seat-run.ts +24 -20
  87. package/src/public-cli/invocation.ts +194 -58
  88. package/src/public-cli/notary-run.ts +13 -4
  89. package/src/public-cli/option-definitions.ts +2 -2
  90. package/src/public-cli/post-admission.ts +147 -78
  91. package/src/public-cli/run-lifecycle.ts +258 -65
  92. package/src/public-cli/seat-ticket-binding.ts +3 -6
  93. package/src/public-cli/settlement.ts +38 -10
  94. package/src/public-role-summons.ts +31 -7
  95. package/src/role-envelope.ts +9 -30
  96. package/src/role-run-placement.ts +128 -0
  97. package/src/role-run-relocation.ts +491 -0
  98. package/src/role-runtime-dependencies.ts +1 -0
  99. package/src/role-runtime.ts +106 -24
  100. package/src/run-terminal-artifacts.ts +3 -1
  101. package/src/run-ticket-number.ts +80 -20
  102. package/src/sitian-appender.ts +25 -28
  103. package/src/submission-ledger.ts +148 -30
  104. package/src/ticket-provenance.ts +4 -1
  105. package/src/ticket-trajectory.ts +22 -35
  106. package/src/user-dialogue-stdin.ts +37 -0
  107. package/src/worker-role.ts +8 -4
  108. package/src/worker-submission-gates.ts +6 -6
@@ -15,6 +15,7 @@ import { readSitianRecords, resolveSitianRecordPath, sitianReport } from "../sit
15
15
 
16
16
  import {
17
17
  hasFreshAttemptSubmission,
18
+ readAttemptScopedSubmissionRows,
18
19
  readRecordedSubmissionRows,
19
20
  readRecordedSubmissions,
20
21
  } from "../submission-ledger.ts";
@@ -125,6 +126,7 @@ import type {
125
126
  DurablePrincipalAuthority,
126
127
  DurablePrincipalCoordinates,
127
128
  } from "../host-contracts.ts";
129
+ import { roleRunArtifactsDirectory } from "../role-run-placement.ts";
128
130
  import {
129
131
  ensureRunArtifactsDir,
130
132
  homeFromRunDirectory,
@@ -161,9 +163,10 @@ export type SettlementCourtScope = {
161
163
  function ledgerReadScope(
162
164
  admitted: Pick<AdmittedRoleInvocation, "runDirectory">,
163
165
  scope?: SettlementCourtScope,
164
- ): { home: string; attemptId?: string } {
166
+ ): { home: string; sessionParent: string; attemptId?: string } {
165
167
  return {
166
168
  home: sealedLedgerHome(admitted),
169
+ sessionParent: join(admitted.runDirectory, "session", "session.jsonl"),
167
170
  ...(scope?.courtAttemptId === undefined || scope.courtAttemptId.length === 0
168
171
  ? {}
169
172
  : { attemptId: scope.courtAttemptId }),
@@ -198,6 +201,22 @@ async function sealedLedgerOutcome(
198
201
  role: TerminalRoleName,
199
202
  scope?: SettlementCourtScope,
200
203
  ): Promise<Extract<TerminalRoleOutcome, { kind: "accepted" | "audit_escalation" }> | undefined> {
204
+ const home = sealedLedgerHome(admitted);
205
+ // #879: when court scope is present, roleOutcome is this-court original only —
206
+ // never last-wins over an undivided historical array, and never falls back to
207
+ // full-run history when this court sealed zero rows. #836 presentation of full
208
+ // history stays on attachRecordedSubmissions / terminal.submissions (run-scoped).
209
+ if (scope?.courtAttemptId !== undefined && scope.courtAttemptId.length > 0) {
210
+ const thisCourt = await readAttemptScopedSubmissionRows(
211
+ admitted.projectRoot,
212
+ admitted.runId,
213
+ scope.courtAttemptId,
214
+ home,
215
+ );
216
+ // Scope present + zero this-court rows → no this-court outcome (not run history).
217
+ return roleOutcomeFromRows(role, thisCourt);
218
+ }
219
+ // No court scope: keep run-scoped ledger face (#836).
201
220
  const rows = await readRecordedSubmissionRows(
202
221
  admitted.projectRoot,
203
222
  admitted.runId,
@@ -225,7 +244,7 @@ export async function attemptProducedFreshSubmission(
225
244
  admitted.projectRoot,
226
245
  admitted.runId,
227
246
  scope.courtAttemptId,
228
- sealedLedgerHome(admitted),
247
+ ledgerReadScope(admitted, scope),
229
248
  );
230
249
  }
231
250
 
@@ -247,12 +266,14 @@ export function withSubmissions<T extends TerminalResult>(
247
266
  ): T {
248
267
  if (submissions.length === 0) return terminal;
249
268
  const roleOutcome = terminal.roleOutcome;
250
- // Full ledger sequence always wins the role-result block (#881): do not keep a
251
- // narrower pre-filtered payloads array when attach brings the complete set.
269
+ // #879 keeps an already scoped this-court result; #881 full run history is
270
+ // presented independently on terminal.submissions. Only fill a missing result payload.
252
271
  const withPayloads =
253
- roleOutcome.kind === "accepted" || roleOutcome.kind === "audit_escalation" || roleOutcome.kind === "failure"
254
- ? { ...roleOutcome, payloads: submissions }
255
- : roleOutcome;
272
+ roleOutcome.kind === "accepted" || roleOutcome.kind === "audit_escalation"
273
+ ? { ...roleOutcome, payloads: (roleOutcome.payloads ?? []).length > 0 ? roleOutcome.payloads : submissions }
274
+ : roleOutcome.kind === "failure"
275
+ ? { ...roleOutcome, payloads: roleOutcome.payloads ?? submissions }
276
+ : roleOutcome;
256
277
  return { ...terminal, roleOutcome: withPayloads, submissions };
257
278
  }
258
279
 
@@ -262,7 +283,14 @@ export async function attachRecordedSubmissions<T extends TerminalResult>(
262
283
  terminal: T,
263
284
  scope?: SettlementCourtScope,
264
285
  ): Promise<T> {
265
- return withSubmissions(terminal, await recordedSubmissionPayloads(admitted, scope));
286
+ // #836 / #879: submissions presentation is always run-scoped history. Do not
287
+ // pass courtAttemptId here — roleOutcome already carries this-court payloads
288
+ // from sealedLedgerOutcome when scoped; withSubmissions keeps them.
289
+ void scope;
290
+ return withSubmissions(
291
+ terminal,
292
+ await recordedSubmissionPayloads(admitted, undefined),
293
+ );
266
294
  }
267
295
 
268
296
  /**
@@ -3818,7 +3846,7 @@ function uniqueFailureFallbackDirs(
3818
3846
  async function resolveFailureArtifactsBase(
3819
3847
  runDirectory: string,
3820
3848
  ): Promise<{ baseDir: string; attempt?: PublicationAttempt }> {
3821
- const artifactsDir = join(runDirectory, "artifacts");
3849
+ const artifactsDir = roleRunArtifactsDirectory(runDirectory);
3822
3850
  try {
3823
3851
  await ensureRunArtifactsDir(runDirectory);
3824
3852
  return { baseDir: artifactsDir };
@@ -3910,7 +3938,7 @@ export async function publishFailureArtifacts(
3910
3938
  // Prefer conventional names; unique fallback dirs keep colliding fixed paths
3911
3939
  // from stranding the original failure outside settlement. Include the ledger
3912
3940
  // runs/ parent so a locked run directory (EACCES) cannot exhaust durability.
3913
- const underArtifacts = baseDir === join(admitted.runDirectory, "artifacts");
3941
+ const underArtifacts = baseDir === roleRunArtifactsDirectory(admitted.runDirectory);
3914
3942
  const uniqueFallbackDirs = uniqueFailureFallbackDirs(
3915
3943
  admitted.runDirectory,
3916
3944
  baseDir,
@@ -62,6 +62,12 @@ export type PublicSummonRequest = {
62
62
  * summons.instruction. Never folded into argv / parent-run lookup keys.
63
63
  */
64
64
  readonly reviewReask?: string;
65
+ /**
66
+ * #879 same-parent (and first-mint) gate summons: verbatim parent-submission body.
67
+ * Rides summons.instruction / first-mint prompt when reviewReask is absent.
68
+ * Never folded into argv / parent-run lookup keys (binding pointer stays pure).
69
+ */
70
+ readonly gateReviewInstruction?: string;
65
71
  /**
66
72
  * Pi-adapter inject (tests). Used only as the `pi` row when composing the
67
73
  * adapter table — never as an override of the child seat's selected host.
@@ -302,6 +308,10 @@ export async function summonPublicRole(
302
308
  ...(options.signal === undefined ? {} : { signal: options.signal }),
303
309
  // #753: reask rides the existing notary same-ticket resume summons.instruction.
304
310
  ...(options.reviewReask === undefined ? {} : { reviewReask: options.reviewReask }),
311
+ // #879: verbatim submission body on officer dialogue content channel.
312
+ ...(options.gateReviewInstruction === undefined
313
+ ? {}
314
+ : { gateReviewInstruction: options.gateReviewInstruction }),
305
315
  ...(options.boundTicketNumber === undefined
306
316
  ? {}
307
317
  : { boundTicketNumber: options.boundTicketNumber }),
@@ -430,6 +440,12 @@ export async function summonGateOfficer(options: {
430
440
  * Never concatenated into argv (inspector parentRunPath is the pure 卷宗指针).
431
441
  */
432
442
  readonly reask?: string;
443
+ /**
444
+ * In-flight parent 交卷 body (tool-call arguments). Relayed verbatim as officer
445
+ * dialogue content when reask is absent (#879). Binding pointer stays on
446
+ * --source-run / 卷宗指针 argv — never a content substitute.
447
+ */
448
+ readonly submission?: unknown;
433
449
  /** Pi-adapter inject — forwarded to summonPublicRole (not a parent-host override). */
434
450
  readonly roleTurnHost?: RoleTurnHost;
435
451
  readonly hostAdapters?: readonly NamedRoleTurnHostAdapter[];
@@ -444,8 +460,13 @@ export async function summonGateOfficer(options: {
444
460
  }
445
461
  // Officer host is seat-owned only (#821). Parent invocation.json.host stays a
446
462
  // hostTransition consumer in post-admission — not an officer override channel.
447
- // #836: officers get the whole parent run directory pointer; code does not
448
- // inject a picked toolCall leaf as the resume instruction.
463
+ // #879: binding pointer = parent run directory; dialogue content = submission body.
464
+ // Conclusion re-ask keeps sole ownership of reviewReask when present.
465
+ let gateReviewInstruction: string | undefined;
466
+ if (options.reask === undefined && options.submission !== undefined) {
467
+ const { readableGateItem } = await import("./readable-gate-item.ts");
468
+ gateReviewInstruction = readableGateItem(options.submission);
469
+ }
449
470
  const common = {
450
471
  cwd: options.cwd,
451
472
  ...(home === undefined ? {} : { home }),
@@ -453,13 +474,16 @@ export async function summonGateOfficer(options: {
453
474
  ...(options.io === undefined ? {} : { io: options.io }),
454
475
  ...(options.signal === undefined ? {} : { signal: options.signal }),
455
476
  ...(options.reask === undefined ? {} : { reviewReask: options.reask }),
477
+ ...(gateReviewInstruction === undefined
478
+ ? {}
479
+ : { gateReviewInstruction }),
456
480
  ...(options.roleTurnHost === undefined ? {} : { roleTurnHost: options.roleTurnHost }),
457
481
  ...(options.hostAdapters === undefined ? {} : { hostAdapters: options.hostAdapters }),
458
482
  ...(options.createRunId === undefined ? {} : { createRunId: options.createRunId }),
459
483
  } as const;
460
484
  if (options.officer === "notary") {
461
- // #753: reask rides summonPublicRole → runPublicNotary summons.instruction
462
- // (same resume seam as ordinary same-parent re-summons — no parallel stack).
485
+ // #753/#879: reask or verbatim body rides summons.instruction / first-mint prompt.
486
+ // Binding stays --source-run (never folded into content).
463
487
  return summonPublicRole({
464
488
  role: "notary",
465
489
  argv: ["--source-run", options.sourceRunDirectory, "--project", options.cwd],
@@ -468,7 +492,7 @@ export async function summonGateOfficer(options: {
468
492
  }
469
493
  if (options.officer === "auditor") {
470
494
  // #756: judge compliance path — same queue law as notary/inspector.
471
- // #836: auditor kickoff is path pointer (同察院), not「本 run 卷宗已就绪」.
495
+ // Binding pointer on argv; dialogue content rides gateReviewInstruction.
472
496
  return summonPublicRole({
473
497
  role: "auditor",
474
498
  argv: [
@@ -481,8 +505,8 @@ export async function summonGateOfficer(options: {
481
505
  ...common,
482
506
  });
483
507
  }
484
- // Inspector: argv stays pure 卷宗指针 (#747 parentRunPath); reask rides env only.
485
- // #836 resume 请重读 is added on the resume projection face, not here.
508
+ // Inspector: argv stays pure 卷宗指针 (#747 parentRunPath lookup key).
509
+ // Content (body/reask) rides env → summons.instruction / first-mint prompt.
486
510
  return summonPublicRole({
487
511
  role: "inspector",
488
512
  argv: [`卷宗指针:${options.sourceRunDirectory}`],
@@ -172,6 +172,8 @@ export async function prepareRoleEnvelope(options: {
172
172
  cwd: request.cwd,
173
173
  mode: "print",
174
174
  model: request.model === undefined ? undefined : { provider: request.model.provider },
175
+ runDirectory: request.runDirectory,
176
+ ...(request.courtAttemptId === undefined ? {} : { courtAttemptId: request.courtAttemptId }),
175
177
  sessionManager: {
176
178
  getLeafEntry: () => sessionEntries.at(-1) as ReturnType<HostContext["sessionManager"]["getLeafEntry"]>,
177
179
  getLeafId: () => runId,
@@ -239,6 +241,7 @@ export async function prepareRoleEnvelope(options: {
239
241
  bindSubmissionNonPass: options.hostActions.bindSubmissionNonPass,
240
242
  },
241
243
  toolCallId: options.toolCallId,
244
+ ...(options.submission === undefined ? {} : { submission: options.submission }),
242
245
  });
243
246
  },
244
247
  on(...registration: HostEventRegistration) {
@@ -527,22 +530,8 @@ export async function prepareRoleEnvelope(options: {
527
530
  const relay = fileURLToPath(new URL("./mcp-relay.mjs", import.meta.url));
528
531
  await listen(server, options.socketPath);
529
532
  let disposed = false;
530
- // Tools execute in this process (relay is protocol-only). Mirror Pi's child-env
531
- // AK_ROLE_RUN_DIR / AK_ROLE_COURT_ATTEMPT injection onto the parent so ledger
532
- // identity sees the same signals. Engine axis is request-scoped via RoleHost
533
- // flag — never process.env (#818 P1). Run-dir may wait
534
- // until prepare succeeds. Dispose restores including unset.
535
- let priorAkRoleRunDir: string | undefined;
536
- let priorAkRoleCourtAttempt: string | undefined;
537
- let runDirInjected = false;
538
- const restoreAkRoleRunEnv = (): void => {
539
- if (!runDirInjected) return;
540
- runDirInjected = false;
541
- if (priorAkRoleRunDir === undefined) delete process.env.AK_ROLE_RUN_DIR;
542
- else process.env.AK_ROLE_RUN_DIR = priorAkRoleRunDir;
543
- if (priorAkRoleCourtAttempt === undefined) delete process.env.AK_ROLE_COURT_ATTEMPT;
544
- else process.env.AK_ROLE_COURT_ATTEMPT = priorAkRoleCourtAttempt;
545
- };
533
+ // Per-turn run/court identity lives on HostContext (request-scoped). Engine
534
+ // axis is already a RoleHost flag — never process.env (#818 P1 / #879).
546
535
  const dispose = async (): Promise<void> => {
547
536
  if (disposed) return;
548
537
  disposed = true;
@@ -552,11 +541,6 @@ export async function prepareRoleEnvelope(options: {
552
541
  } catch (error) {
553
542
  cleanupFailures.push(error);
554
543
  }
555
- try {
556
- restoreAkRoleRunEnv();
557
- } catch (error) {
558
- cleanupFailures.push(error);
559
- }
560
544
  try {
561
545
  const closeAll = (server as unknown as { closeAllConnections?: () => void }).closeAllConnections;
562
546
  if (typeof closeAll === "function") closeAll.call(server);
@@ -640,6 +624,8 @@ export async function prepareRoleEnvelope(options: {
640
624
  }
641
625
  // Method notes only here — role before_agent_start injects soul once.
642
626
  // Preloading session materials duplicated soul under the role tag (#632).
627
+ // #879: case-dossier owner reads runDirectory from this turn's HostContext.
628
+
643
629
  const methodPrompt = (await Promise.all(request.methods.map(({ path }) => readFile(path, "utf8")))).join("\n\n");
644
630
  const promptResults = await emit("before_agent_start", {
645
631
  prompt,
@@ -652,8 +638,8 @@ export async function prepareRoleEnvelope(options: {
652
638
  return [value.systemPrompt];
653
639
  });
654
640
  const systemPromptBody = systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : methodPrompt;
655
- // Typed reading materials from agent-start handlers (machine face; independent of prompt bytes).
656
- // Folded into the provider-visible systemPrompt by the adapter at the send boundary.
641
+ // Typed reading materials from agent-start handlers (incl. single shared
642
+ // case-dossier owner). Folded into provider-visible systemPrompt at send.
657
643
  const readingMaterials: unknown[] = [];
658
644
  for (const value of promptResults) {
659
645
  if (typeof value !== "object" || value === null) continue;
@@ -662,13 +648,6 @@ export async function prepareRoleEnvelope(options: {
662
648
  if (material !== undefined) readingMaterials.push(material);
663
649
  }
664
650
 
665
- priorAkRoleRunDir = process.env.AK_ROLE_RUN_DIR;
666
- priorAkRoleCourtAttempt = process.env.AK_ROLE_COURT_ATTEMPT;
667
- process.env.AK_ROLE_RUN_DIR = request.runDirectory;
668
- if (request.courtAttemptId === undefined) delete process.env.AK_ROLE_COURT_ATTEMPT;
669
- else process.env.AK_ROLE_COURT_ATTEMPT = request.courtAttemptId;
670
- runDirInjected = true;
671
-
672
651
  const terminating = tools.get(terminatingToolName);
673
652
  if (terminating === undefined) {
674
653
  throw new Error(`terminating tool not registered after activation: ${terminatingToolName}`);
@@ -0,0 +1,128 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+
4
+ import {
5
+ activationBookDirectory,
6
+ ensureRealDirectoryTree,
7
+ } from "./activation-ledger-topology.ts";
8
+ import { requireSafePositiveTicketNumber } from "./run-ticket-number.ts";
9
+
10
+ export type RoleRunSubject =
11
+ | { readonly ticketNumber: number }
12
+ | { readonly unbound: true };
13
+
14
+ export type RoleRunPlacement = {
15
+ readonly runDirectory: string;
16
+ readonly sessionDirectory: string;
17
+ readonly sessionFile: string;
18
+ readonly artifactsDirectory: string;
19
+ readonly attachmentsDirectory: string;
20
+ };
21
+
22
+ function isMissingPathError(error: unknown): boolean {
23
+ return (
24
+ error instanceof Error &&
25
+ "code" in error &&
26
+ (error as NodeJS.ErrnoException).code === "ENOENT"
27
+ );
28
+ }
29
+
30
+ /**
31
+ * Sole book-level run directory walk: flat legacy `runs/` plus each
32
+ * `subject/runs/` child (ticket / unbound). One authority for read-side
33
+ * enumeration under a book directory — findRunDirectoryById, analyst scan,
34
+ * and ticket trajectory must not hardcode a second subject-tree walk.
35
+ */
36
+ export async function listBookRunDirectories(bookDir: string): Promise<string[]> {
37
+ const out: string[] = [];
38
+ const seen = new Set<string>();
39
+
40
+ const collect = async (runsDir: string): Promise<void> => {
41
+ let entries;
42
+ try {
43
+ entries = await readdir(runsDir, { withFileTypes: true });
44
+ } catch (error) {
45
+ if (isMissingPathError(error)) return;
46
+ throw error;
47
+ }
48
+ for (const entry of entries) {
49
+ if (!entry.isDirectory()) continue;
50
+ const runDir = join(runsDir, entry.name);
51
+ if (seen.has(runDir)) continue;
52
+ seen.add(runDir);
53
+ out.push(runDir);
54
+ }
55
+ };
56
+
57
+ await collect(join(bookDir, "runs"));
58
+
59
+ let subjects;
60
+ try {
61
+ subjects = await readdir(bookDir, { withFileTypes: true });
62
+ } catch (error) {
63
+ if (isMissingPathError(error)) return out.sort();
64
+ throw error;
65
+ }
66
+ for (const subject of subjects) {
67
+ if (!subject.isDirectory() || subject.name === "runs") continue;
68
+ await collect(join(bookDir, subject.name, "runs"));
69
+ }
70
+ return out.sort();
71
+ }
72
+
73
+ /** The single authority for every path belonging to an admitted role run. */
74
+ export function roleRunPlacement(
75
+ ledgerHome: string,
76
+ input: {
77
+ readonly bookKey: string;
78
+ readonly subject: RoleRunSubject;
79
+ readonly runId: string;
80
+ readonly role: string;
81
+ },
82
+ ): RoleRunPlacement {
83
+ const subjectDirectory = "ticketNumber" in input.subject
84
+ ? String(requireSafePositiveTicketNumber(
85
+ input.subject.ticketNumber,
86
+ "roleRunPlacement subject.ticketNumber",
87
+ ))
88
+ : "unbound";
89
+ const runDirectory = join(
90
+ activationBookDirectory(ledgerHome, input.bookKey),
91
+ subjectDirectory,
92
+ "runs",
93
+ `${input.runId}@${input.role}`,
94
+ );
95
+ const sessionDirectory = join(runDirectory, "session");
96
+ return {
97
+ runDirectory,
98
+ sessionDirectory,
99
+ sessionFile: join(sessionDirectory, "session.jsonl"),
100
+ artifactsDirectory: roleRunArtifactsDirectory(runDirectory),
101
+ attachmentsDirectory: join(runDirectory, "attachments"),
102
+ };
103
+ }
104
+
105
+ /** The single artifacts subpath definition for new and resumed role runs. */
106
+ export function roleRunArtifactsDirectory(runDirectory: string): string {
107
+ return join(runDirectory, "artifacts");
108
+ }
109
+
110
+ /** The placement seam owns creation for both new runs and resumed legacy runs. */
111
+ export function ensureRoleRunDirectory(
112
+ ledgerHome: string,
113
+ directory: string,
114
+ ): string {
115
+ return ensureRealDirectoryTree(ledgerHome, directory);
116
+ }
117
+
118
+ export function ensureRoleRunPlacement(
119
+ ledgerHome: string,
120
+ placement: RoleRunPlacement,
121
+ ): void {
122
+ for (const directory of [
123
+ placement.sessionDirectory,
124
+ placement.attachmentsDirectory,
125
+ ]) {
126
+ ensureRoleRunDirectory(ledgerHome, directory);
127
+ }
128
+ }