@akagilnc/pi-workflow-roles 0.1.4062 → 0.1.4196

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/dist/acp-host/production-host.js +2075 -1088
  2. package/dist/archivist-record-entry.js +204 -20
  3. package/dist/archivist-record-topology.js +36 -16
  4. package/dist/auditor-dossier-tool.js +2 -1
  5. package/dist/diarist-contracts.js +96 -0
  6. package/dist/doctor-evidence.js +3 -2
  7. package/dist/headless-host/production-host.js +2215 -1228
  8. package/dist/migrate-book-topology.js +278917 -0
  9. package/dist/navigator-public-session.js +22 -25
  10. package/dist/notary-source-run.js +7 -4
  11. package/dist/pi/durable-principal.js +9 -6
  12. package/dist/public-cli/auto-resume.js +2 -5
  13. package/dist/public-cli/diarist-run.js +27 -25
  14. package/dist/public-cli/instruction-seat-run.js +8 -11
  15. package/dist/public-cli/invocation.js +127 -63
  16. package/dist/public-cli/main.js +1507 -645
  17. package/dist/public-cli/option-definitions.js +2 -2
  18. package/dist/public-cli/post-admission.js +47 -12
  19. package/dist/public-cli/run-lifecycle.js +206 -58
  20. package/dist/public-cli/seat-ticket-binding.js +3 -6
  21. package/dist/public-cli/settlement.js +5 -3
  22. package/dist/role-run-placement.js +84 -0
  23. package/dist/role-run-relocation.js +358 -0
  24. package/dist/run-terminal-artifacts.js +2 -1
  25. package/dist/run-ticket-number.js +51 -32
  26. package/dist/sitian-appender.js +11 -26
  27. package/dist/submission-ledger.js +78 -19
  28. package/dist/ticket-provenance.js +3 -1
  29. package/package.json +3 -2
  30. package/scripts/build-package.mjs +34 -2
  31. package/souls/countersign.md +2 -2
  32. package/souls/fixer.md +3 -2
  33. package/src/analyst-ledger.ts +14 -15
  34. package/src/archivist-record-entry.ts +285 -38
  35. package/src/archivist-record-topology.ts +43 -18
  36. package/src/auditor-dossier-tool.ts +2 -1
  37. package/src/book-topology-migration-placement.ts +493 -0
  38. package/src/book-topology-migration.ts +220 -0
  39. package/src/book-topology-migrators-t11.ts +856 -0
  40. package/src/book-topology-mixed-volume-migrators.ts +470 -0
  41. package/src/book-topology-partition-migrators.ts +25 -0
  42. package/src/book-topology-record-class-migrators.ts +779 -0
  43. package/src/book-topology-runs-migrator.ts +244 -0
  44. package/src/diarist-contracts.ts +116 -0
  45. package/src/diarist-role.ts +6 -0
  46. package/src/diarist.ts +2 -0
  47. package/src/doctor-evidence.ts +4 -1
  48. package/src/doctor-role.ts +1 -1
  49. package/src/navigator-public-session.ts +33 -30
  50. package/src/notary-source-run.ts +9 -4
  51. package/src/pi/durable-principal.ts +9 -13
  52. package/src/public-cli/auto-resume.ts +2 -6
  53. package/src/public-cli/countersign-run.ts +182 -32
  54. package/src/public-cli/diarist-run.ts +34 -26
  55. package/src/public-cli/instruction-seat-run.ts +7 -11
  56. package/src/public-cli/invocation.ts +192 -58
  57. package/src/public-cli/option-definitions.ts +2 -2
  58. package/src/public-cli/post-admission.ts +73 -13
  59. package/src/public-cli/run-lifecycle.ts +255 -65
  60. package/src/public-cli/seat-ticket-binding.ts +3 -6
  61. package/src/public-cli/settlement.ts +6 -4
  62. package/src/role-run-placement.ts +128 -0
  63. package/src/role-run-relocation.ts +491 -0
  64. package/src/role-runtime.ts +11 -5
  65. package/src/run-terminal-artifacts.ts +3 -1
  66. package/src/run-ticket-number.ts +80 -20
  67. package/src/sitian-appender.ts +25 -28
  68. package/src/submission-ledger.ts +102 -19
  69. package/src/ticket-provenance.ts +4 -1
  70. package/src/ticket-trajectory.ts +22 -35
  71. package/src/worker-role.ts +4 -4
  72. package/src/worker-submission-gates.ts +6 -6
@@ -1,11 +1,17 @@
1
1
  /**
2
2
  * Typed ticketNumber reader for a retained run's durable pages.
3
- * admitted-request.json first, invocation.json fallback. Missing page (ENOENT)
4
- * → try next / undefined; damage and non-ENOENT IO failures propagate.
3
+ * Board pages first (admitted-request, then invocation); migration derivation
4
+ * page last so worktree-derived placement stays readable without forging a
5
+ * board assertion. Missing page (ENOENT) → try next / undefined; damage and
6
+ * non-ENOENT IO failures propagate.
5
7
  */
6
8
  import { readFile } from "node:fs/promises";
7
9
  import { join } from "node:path";
8
10
 
11
+ /** Sole on-disk page for worktree-basename ticket derivation (#865). */
12
+ export const MIGRATION_TICKET_DERIVATION_PAGE =
13
+ "migration-ticket-derivation.json" as const;
14
+
9
15
  function isEnoent(error: unknown): boolean {
10
16
  return (
11
17
  error instanceof Error &&
@@ -14,40 +20,94 @@ function isEnoent(error: unknown): boolean {
14
20
  );
15
21
  }
16
22
 
17
- function ticketFromRecord(record: Record<string, unknown>): number | undefined {
18
- const ticketNumber = record.ticketNumber;
19
- if (
20
- typeof ticketNumber === "number" &&
21
- Number.isSafeInteger(ticketNumber) &&
22
- ticketNumber >= 1
23
- ) {
24
- return ticketNumber;
23
+ /** Sole safe-positive ticket invariant (bind / admission / placement / readers). */
24
+ export function isSafePositiveTicketNumber(value: unknown): value is number {
25
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 1;
26
+ }
27
+
28
+ /** Loud reject for non-safe-positive ticket numbers before placement or bind. */
29
+ export function requireSafePositiveTicketNumber(
30
+ ticketNumber: number,
31
+ context = "ticketNumber",
32
+ ): number {
33
+ if (!isSafePositiveTicketNumber(ticketNumber)) {
34
+ throw new Error(
35
+ `${context} requires a safe positive integer, got ${String(ticketNumber)}`,
36
+ );
25
37
  }
26
- return undefined;
38
+ return ticketNumber;
27
39
  }
28
40
 
29
- async function readPageTicketNumber(
30
- runDirectory: string,
31
- page: "admitted-request.json" | "invocation.json",
32
- ): Promise<number | undefined> {
41
+ function ticketFromRecord(record: Record<string, unknown>): number | undefined {
42
+ return isSafePositiveTicketNumber(record.ticketNumber)
43
+ ? record.ticketNumber
44
+ : undefined;
45
+ }
46
+
47
+ async function readJsonObject(
48
+ path: string,
49
+ ): Promise<Record<string, unknown> | undefined> {
33
50
  try {
34
- const raw = JSON.parse(await readFile(join(runDirectory, page), "utf8")) as unknown;
51
+ const raw: unknown = JSON.parse(await readFile(path, "utf8"));
35
52
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
36
53
  return undefined;
37
54
  }
38
- return ticketFromRecord(raw as Record<string, unknown>);
55
+ return raw as Record<string, unknown>;
39
56
  } catch (error) {
40
57
  if (isEnoent(error)) return undefined;
41
58
  throw error;
42
59
  }
43
60
  }
44
61
 
45
- /** Sole reader of ticketNumber from a retained run's admitted then invocation pages. */
62
+ async function readBoardPageTicketNumber(
63
+ runDirectory: string,
64
+ page: "admitted-request.json" | "invocation.json",
65
+ ): Promise<number | undefined> {
66
+ const record = await readJsonObject(join(runDirectory, page));
67
+ if (record === undefined) return undefined;
68
+ return ticketFromRecord(record);
69
+ }
70
+
71
+ /**
72
+ * Board-only ticketNumber (admitted → invocation). Placement attribution and
73
+ * any caller that must ignore derivation pages use this sole projection.
74
+ */
75
+ export async function readBoardTicketNumber(
76
+ runDirectory: string,
77
+ ): Promise<number | undefined> {
78
+ return (
79
+ (await readBoardPageTicketNumber(runDirectory, "admitted-request.json")) ??
80
+ (await readBoardPageTicketNumber(runDirectory, "invocation.json"))
81
+ );
82
+ }
83
+
84
+ /**
85
+ * Worktree-derivation ticket only — never a board assertion. Callers that must
86
+ * distinguish board vs derived use this; effective readers use readRunTicketNumber.
87
+ */
88
+ export async function readMigrationDerivedTicketNumber(
89
+ runDirectory: string,
90
+ ): Promise<number | undefined> {
91
+ const record = await readJsonObject(
92
+ join(runDirectory, MIGRATION_TICKET_DERIVATION_PAGE),
93
+ );
94
+ if (record === undefined) return undefined;
95
+ if (record.derivation !== "worktree-path-basename") return undefined;
96
+ return ticketFromRecord(record);
97
+ }
98
+
99
+ /**
100
+ * Display / historical-placement ticketNumber for a retained run:
101
+ * board admitted → board invocation → migration derivation.
102
+ * Derivation is last so board wins. Callers that mint, resume, inherit, or
103
+ * otherwise write ticket identity must use readBoardTicketNumber instead —
104
+ * derived placement is never a board assertion.
105
+ */
46
106
  export async function readRunTicketNumber(
47
107
  runDirectory: string,
48
108
  ): Promise<number | undefined> {
49
109
  return (
50
- (await readPageTicketNumber(runDirectory, "admitted-request.json")) ??
51
- (await readPageTicketNumber(runDirectory, "invocation.json"))
110
+ (await readBoardTicketNumber(runDirectory)) ??
111
+ (await readMigrationDerivedTicketNumber(runDirectory))
52
112
  );
53
113
  }
@@ -11,7 +11,7 @@ import {
11
11
  unlinkSync,
12
12
  writeFileSync,
13
13
  } from "node:fs";
14
- import { basename, dirname, join, resolve } from "node:path";
14
+ import { dirname, join } from "node:path";
15
15
 
16
16
  import { resolveBookKeyFromGit } from "./activation-ledger-git.ts";
17
17
  import {
@@ -204,14 +204,6 @@ export function resolveSitianVolumeCategory(kind: string): string {
204
204
  return kind;
205
205
  }
206
206
 
207
- function safeBookKey(cwd: string): string {
208
- try {
209
- return resolveBookKeyFromGit(cwd);
210
- } catch {
211
- return basename(resolve(cwd)) || "default";
212
- }
213
- }
214
-
215
207
  type SitianRecordPath = {
216
208
  readonly sessionDir: string;
217
209
  readonly recordFile: string;
@@ -223,30 +215,35 @@ export function resolveSitianRecordPathInLedger(
223
215
  input: SitianRecordInput,
224
216
  ledgerHome: string,
225
217
  ): SitianRecordPath {
226
- const cwd = input.cwd ?? process.cwd();
227
218
  const category = resolveSitianVolumeCategory(input.kind);
219
+ const ticketNumber = category === "ticket-provenance"
220
+ ? typeof input.subject === "string" && /^[1-9][0-9]*$/.test(input.subject)
221
+ ? input.subject
222
+ : typeof input.subject === "object"
223
+ && typeof input.subject.ticketNumber === "number"
224
+ && Number.isSafeInteger(input.subject.ticketNumber)
225
+ && input.subject.ticketNumber > 0
226
+ ? String(input.subject.ticketNumber)
227
+ : undefined
228
+ : undefined;
228
229
 
229
230
  let sessionDir: string;
230
-
231
- if (input.sessionParent !== undefined && input.sessionParent.length > 0 && physicallyContainedIn(ledgerHome, input.sessionParent)) {
232
- sessionDir = join(dirname(input.sessionParent), category);
231
+ if (ticketNumber !== undefined) {
232
+ // docs/dossier-topology.md: ticket dir holds records.jsonl + 起居录.md directly.
233
+ const bookDir = activationBookDirectory(
234
+ ledgerHome,
235
+ resolveBookKeyFromGit(input.cwd ?? process.cwd()),
236
+ );
237
+ sessionDir = join(bookDir, ticketNumber);
233
238
  } else {
234
- const bookKey = safeBookKey(cwd);
235
- const bookDir = activationBookDirectory(ledgerHome, bookKey);
236
- if (input.subject !== undefined) {
237
- let subjectStr: string;
238
- if (typeof input.subject === "string") {
239
- subjectStr = input.subject;
240
- } else if (typeof input.subject.runId === "string" && input.subject.runId.length > 0) {
241
- subjectStr = input.subject.runId;
242
- } else {
243
- subjectStr = JSON.stringify(input.subject);
244
- }
245
- const digest = createHash("sha256").update(subjectStr).digest("hex").slice(0, 32);
246
- sessionDir = join(bookDir, category, digest);
247
- } else {
248
- sessionDir = join(bookDir, category);
239
+ if (
240
+ input.sessionParent === undefined
241
+ || input.sessionParent.length === 0
242
+ || !physicallyContainedIn(ledgerHome, input.sessionParent)
243
+ ) {
244
+ throw new Error("Sitian record ownership requires a parent session inside the ledger home");
249
245
  }
246
+ sessionDir = join(dirname(input.sessionParent), category);
250
247
  }
251
248
 
252
249
  const recordFile = join(sessionDir, "records.jsonl");
@@ -1,3 +1,5 @@
1
+ import { join } from "node:path";
2
+
1
3
  import {
2
4
  resolveActivationLedgerHome,
3
5
  tryHomeFromAkRolesPath,
@@ -14,6 +16,8 @@ import { isAuditEscalationProjection } from "./audit-escalation.ts";
14
16
 
15
17
  import { runIdFromRunDirectory } from "./run-terminal-artifacts.ts";
16
18
  import { readSitianRecords, resolveSitianRecordPathInLedger, sitianReport, type RecordPointer } from "./sitian-facade.ts";
19
+ import type { SitianRecord } from "./sitian-contracts.ts";
20
+ import { findRunDirectoryById } from "./public-cli/run-lifecycle.ts";
17
21
  import type { TerminalRoleName } from "./public-cli/terminal.ts";
18
22
  import { isCorrectableExecuteError } from "./submission-correctable-error.ts";
19
23
  import { failOnInfrastructureFailureDeclaration } from "./package-contracts/terminating-infrastructure.ts";
@@ -111,28 +115,64 @@ export type ClosedSubmission = {
111
115
 
112
116
  export type ClosedSubmissionProjection = ClosedSubmission;
113
117
 
114
- function submissionRecordFile(cwd: string, runId: string, home?: string): string {
115
- const ledgerHome = resolveActivationLedgerHome(home);
118
+ /**
119
+ * Resolve the submission-ledger record file for a run.
120
+ * Unknown run (no sessionParent and no discoverable directory) → undefined so
121
+ * read APIs return their empty set at the read boundary. Write paths always
122
+ * supply sessionParent and keep the ownership gate.
123
+ */
124
+ async function submissionRecordFile(cwd: string, runId: string, scope: SubmissionLedgerReadScope): Promise<string | undefined> {
125
+ const ledgerHome = resolveActivationLedgerHome(scope.home);
126
+ let sessionParent = scope.sessionParent;
127
+ if (sessionParent === undefined) {
128
+ const discoveredRun = await findRunDirectoryById(scope.home, runId);
129
+ if (discoveredRun === undefined) return undefined;
130
+ sessionParent = join(discoveredRun, "session", "session.jsonl");
131
+ }
116
132
  return resolveSitianRecordPathInLedger({
117
133
  level: "event",
118
134
  kind: "candidate",
119
135
  subject: { runId },
120
136
  cwd,
137
+ sessionParent,
121
138
  }, ledgerHome).recordFile;
122
139
  }
123
140
 
124
- async function readOwnedSubmissionRecords(cwd: string, runId: string, home?: string) {
125
- const file = submissionRecordFile(cwd, runId, home);
141
+ /**
142
+ * Discriminated owned-row view:
143
+ * - unknown run → file absent + empty owned (read APIs only)
144
+ * - located ledger → file is string; prior/write consumers may build RecordPointer
145
+ */
146
+ type OwnedSubmissionRecords =
147
+ | { readonly file: undefined; readonly owned: readonly [] }
148
+ | { readonly file: string; readonly owned: readonly SitianRecord[] };
149
+
150
+ async function readOwnedSubmissionRecords(
151
+ cwd: string,
152
+ runId: string,
153
+ scope: SubmissionLedgerReadScope = {},
154
+ ): Promise<OwnedSubmissionRecords> {
155
+ const file = await submissionRecordFile(cwd, runId, scope);
156
+ if (file === undefined) {
157
+ return { file: undefined, owned: [] };
158
+ }
126
159
  const { records } = await readSitianRecords(file);
127
160
  return {
128
161
  file,
129
- owned: records.filter((record) => typeof record.subject === "object" && record.subject !== null && (record.subject as { runId?: string }).runId === runId),
162
+ owned: records.filter(
163
+ (record) =>
164
+ typeof record.subject === "object"
165
+ && record.subject !== null
166
+ && (record.subject as { runId?: string }).runId === runId,
167
+ ),
130
168
  };
131
169
  }
132
170
 
133
171
  /** Optional court-turn scope for settlement reads (#637). */
134
172
  export type SubmissionLedgerReadScope = {
135
173
  readonly home?: string;
174
+ /** Durable run principal; the direct ownership coordinate when already known. */
175
+ readonly sessionParent?: string;
136
176
  /**
137
177
  * Recording tag for this court. Presentation of original payloads is run-scoped
138
178
  * (#836: attempt/latest must not hide already-recorded rows).
@@ -184,9 +224,9 @@ export async function hasFreshAttemptSubmission(
184
224
  cwd: string,
185
225
  runId: string,
186
226
  attemptId: string,
187
- home?: string,
227
+ homeOrScope?: string | SubmissionLedgerReadScope,
188
228
  ): Promise<boolean> {
189
- const { owned } = await readOwnedSubmissionRecords(cwd, runId, home);
229
+ const { owned } = await readOwnedSubmissionRecords(cwd, runId, resolveReadScope(homeOrScope));
190
230
  return owned.some((record) => {
191
231
  if (recordAttemptId(record) !== attemptId) return false;
192
232
  if (record.kind === "sealed") return true;
@@ -374,7 +414,7 @@ export async function readRecordedSubmissionRows(
374
414
  homeOrScope?: string | SubmissionLedgerReadScope,
375
415
  ): Promise<readonly RecordedSubmissionRow[]> {
376
416
  const scope = resolveReadScope(homeOrScope);
377
- const { owned } = await readOwnedSubmissionRecords(cwd, runId, scope.home);
417
+ const { owned } = await readOwnedSubmissionRecords(cwd, runId, scope);
378
418
  const scoped = recordsForAttempt(owned, scope.attemptId);
379
419
  return mapOwnedToSubmissionRows(scoped);
380
420
  }
@@ -391,7 +431,11 @@ export async function readAttemptScopedSubmissionRows(
391
431
  home?: string,
392
432
  ): Promise<readonly RecordedSubmissionRow[]> {
393
433
  if (attemptId.length === 0) return [];
394
- const { owned } = await readOwnedSubmissionRecords(cwd, runId, home);
434
+ const { owned } = await readOwnedSubmissionRecords(
435
+ cwd,
436
+ runId,
437
+ home === undefined ? {} : { home },
438
+ );
395
439
  return mapOwnedToSubmissionRows(owned.filter((record) => recordAttemptId(record) === attemptId));
396
440
  }
397
441
 
@@ -425,7 +469,7 @@ export async function readLatestSubmissionOutcome(
425
469
  homeOrScope?: string | SubmissionLedgerReadScope,
426
470
  ): Promise<LatestSubmissionOutcome | undefined> {
427
471
  const scope = resolveReadScope(homeOrScope);
428
- const { owned } = await readOwnedSubmissionRecords(cwd, runId, scope.home);
472
+ const { owned } = await readOwnedSubmissionRecords(cwd, runId, scope);
429
473
  const scoped = recordsForAttempt(owned, scope.attemptId);
430
474
  for (let index = scoped.length - 1; index >= 0; index -= 1) {
431
475
  const record = scoped[index];
@@ -440,14 +484,35 @@ export async function readLatestSubmissionOutcome(
440
484
 
441
485
  type LedgerState = { prior?: RecordPointer; sequence: number };
442
486
 
443
- async function restoreState(cwd: string, runId: string, home?: string): Promise<LedgerState> {
444
- const { file, owned } = await readOwnedSubmissionRecords(cwd, runId, home);
487
+ async function restoreState(cwd: string, runId: string, scope: SubmissionLedgerReadScope): Promise<LedgerState> {
488
+ const located = await readOwnedSubmissionRecords(cwd, runId, scope);
489
+ // Unknown-run empty set: no prior chain. Owned rows without a file are an internal bug.
490
+ if (located.file === undefined) {
491
+ if (located.owned.length !== 0) {
492
+ throw new Error(
493
+ `submission ledger invariant: owned rows present without recordFile for run ${runId}`,
494
+ );
495
+ }
496
+ return { sequence: 0 };
497
+ }
498
+ const { file, owned } = located;
445
499
  const last = owned.at(-1);
446
500
  return {
447
- ...(last === undefined ? {} : { prior: { identity: last.identity, recordFile: file, kind: last.kind, level: last.level } }),
501
+ ...(last === undefined
502
+ ? {}
503
+ : {
504
+ prior: {
505
+ identity: last.identity,
506
+ recordFile: file,
507
+ kind: last.kind,
508
+ level: last.level,
509
+ },
510
+ }),
448
511
  sequence: owned.reduce((maximum, record) => {
449
512
  const payload = record.payload as Partial<SubmissionLedgerEvent> | undefined;
450
- return payload?.type === "candidate" && typeof payload.sequence === "number" ? Math.max(maximum, payload.sequence) : maximum;
513
+ return payload?.type === "candidate" && typeof payload.sequence === "number"
514
+ ? Math.max(maximum, payload.sequence)
515
+ : maximum;
451
516
  }, 0),
452
517
  };
453
518
  }
@@ -466,21 +531,38 @@ export function createSubmissionLedgerHost(
466
531
  options?: { home?: string },
467
532
  ): RoleHost {
468
533
  const states = new Map<string, Promise<LedgerState>>();
534
+ /** Sole HostContext-derived run coordinate for restore and append (never process.env). */
535
+ const sessionParentFromContext = (context: HostContext): string | undefined => {
536
+ const runDirectory = runDirectoryFromHostContext(context);
537
+ if (runDirectory !== undefined) {
538
+ return join(runDirectory, "session", "session.jsonl");
539
+ }
540
+ const sessionFile = context.sessionManager.getSessionFile?.();
541
+ if (typeof sessionFile === "string" && sessionFile.length > 0) return sessionFile;
542
+ const sessionDir = context.sessionManager.getSessionDir?.();
543
+ if (typeof sessionDir === "string" && sessionDir.length > 0) {
544
+ return join(sessionDir, "session.jsonl");
545
+ }
546
+ return undefined;
547
+ };
469
548
  const resolveHomeFromContext = (context: HostContext): string | undefined => {
470
549
  if (options?.home !== undefined) return options.home;
471
- const sessionFile = context.sessionManager.getSessionFile?.() || context.sessionManager.getSessionDir?.();
472
- return typeof sessionFile === "string" && sessionFile.length > 0
473
- ? tryHomeFromAkRolesPath(sessionFile)
474
- : undefined;
550
+ const sessionParent = sessionParentFromContext(context);
551
+ return sessionParent !== undefined ? tryHomeFromAkRolesPath(sessionParent) : undefined;
475
552
  };
476
553
  const stateFor = (context: HostContext, runId: string) => states.get(runId) ?? (() => {
477
554
  const home = resolveHomeFromContext(context);
478
- const pending = restoreState(context.cwd, runId, home);
555
+ const sessionParent = sessionParentFromContext(context);
556
+ const pending = restoreState(context.cwd, runId, {
557
+ ...(home === undefined ? {} : { home }),
558
+ ...(sessionParent === undefined ? {} : { sessionParent }),
559
+ });
479
560
  states.set(runId, pending);
480
561
  return pending;
481
562
  })();
482
563
  const appendFor = (state: LedgerState, context: HostContext, runId: string, attemptId: string, event: SubmissionLedgerEvent): RecordPointer => {
483
564
  const home = resolveHomeFromContext(context);
565
+ const sessionParent = sessionParentFromContext(context);
484
566
  const pointer = sitianReport({
485
567
  level: "event",
486
568
  kind: event.type,
@@ -490,6 +572,7 @@ export function createSubmissionLedgerHost(
490
572
  source: "role-runtime",
491
573
  cwd: context.cwd,
492
574
  ...(home !== undefined ? { home } : {}),
575
+ ...(sessionParent === undefined ? {} : { sessionParent }),
493
576
  });
494
577
  state.prior = pointer;
495
578
  return pointer;
@@ -13,6 +13,7 @@ import {
13
13
  type RecordPointer,
14
14
  type SitianRecord,
15
15
  } from "./sitian-facade.ts";
16
+ import { isSafePositiveTicketNumber } from "./run-ticket-number.ts";
16
17
  import {
17
18
  TICKET_PROVENANCE_HUMAN_VIEW,
18
19
  TICKET_PROVENANCE_KIND,
@@ -25,7 +26,7 @@ import {
25
26
 
26
27
  /** Subject string for ticket-keyed volumes — history follows the ticket. */
27
28
  export function ticketProvenanceSubject(ticketNumber: number): string {
28
- if (!Number.isSafeInteger(ticketNumber) || ticketNumber < 1) {
29
+ if (!isSafePositiveTicketNumber(ticketNumber)) {
29
30
  throw new Error(`ticket-provenance subject requires a positive ticket number, got ${String(ticketNumber)}`);
30
31
  }
31
32
  return String(ticketNumber);
@@ -57,6 +58,7 @@ export function ticketProvenanceEntryIdentity(
57
58
  export type AppendTicketProvenanceInput = {
58
59
  readonly ticketNumber: number;
59
60
  readonly cwd: string;
61
+ readonly sessionParent: string;
60
62
  /** Original diarist row (or `{ original, unprojected: true }` envelope). */
61
63
  readonly payload: unknown;
62
64
  /** Explicit package home (tests / admitted run); never process.env.HOME (#604). */
@@ -107,6 +109,7 @@ export function appendTicketProvenanceEntry(
107
109
  identity,
108
110
  subject,
109
111
  cwd: input.cwd,
112
+ sessionParent: input.sessionParent,
110
113
  ...(input.home === undefined ? {} : { home: input.home }),
111
114
  host: input.host ?? "diarist",
112
115
  source: input.source ?? "diarist",
@@ -29,6 +29,8 @@ import {
29
29
  formatTokensCompact,
30
30
  formatUsdPrecise,
31
31
  } from "./human-format.ts";
32
+ import { listBookRunDirectories } from "./role-run-placement.ts";
33
+ import { readRunTicketNumber } from "./run-ticket-number.ts";
32
34
  import {
33
35
  extractSessionTimestampSpan,
34
36
  readLedgerSessionJsonl,
@@ -302,16 +304,12 @@ async function readInvocation(runDir: string): Promise<InvocationInfo | undefine
302
304
  info.model = rawModel;
303
305
  }
304
306
  }
305
- if (
306
- typeof parsed.ticketNumber === "number" &&
307
- Number.isInteger(parsed.ticketNumber) &&
308
- parsed.ticketNumber >= 1
309
- ) {
310
- info.ticketNumber = parsed.ticketNumber;
311
- }
312
307
  if (typeof parsed.correlationId === "string" && parsed.correlationId.trim() !== "") {
313
308
  info.correlationId = parsed.correlationId;
314
309
  }
310
+ // Display placement: board pages first, then migration-derived (never forges board).
311
+ const ticketNumber = await readRunTicketNumber(runDir);
312
+ if (ticketNumber !== undefined) info.ticketNumber = ticketNumber;
315
313
  return info;
316
314
  } catch (error) {
317
315
  // Only genuine absence activates the invocation fallback. Malformed JSON and
@@ -656,56 +654,47 @@ ${stationBlocks || "<p data-empty=\"true\">no runs</p>"}
656
654
  * Load one ticket's runs via the S1 tracer path (read-only ledger scan).
657
655
  * Factory board reuses this — no parallel receipt parser.
658
656
  *
659
- * Flat-run attribution (#176 option A): read typed `ticketNumber` from each
660
- * `runs/<run>@<role>/invocation.json`. Legacy `issues/<N>/runs` remains a
661
- * read-only compatibility entrance. Unbound flat runs never join a ticket.
657
+ * Book-run attribution (#176 / #859): read typed `ticketNumber` from each run's
658
+ * invocation.json under listBookRunDirectories (flat legacy `runs/` + subject-tree
659
+ * `<ticket|unbound>/runs/`). Legacy `issues/<N>/runs` remains a read-only
660
+ * compatibility entrance. Unbound runs never join a ticket.
662
661
  */
663
662
 
664
- /** One flat run's typed binding projection from invocation.json. */
663
+ /** One book run's typed binding projection from invocation.json. */
665
664
  export type FlatRunTicketBinding = {
666
665
  readonly runDir: string;
667
666
  readonly runFolder: string;
667
+ /** Book-relative coord (e.g. `runs/id@role` or `582/runs/id@role`). */
668
+ readonly ledgerCoord: string;
668
669
  readonly ticketNumber?: number;
669
670
  readonly role?: string;
670
671
  readonly correlationId?: string;
671
672
  };
672
673
 
673
674
  /**
674
- * Book-level flat-run index built once per lane/render and reused per ticket.
675
- * Pure derived view of flat runs' invocation.json files — not a parallel ledger.
675
+ * Book-level run index built once per lane/render and reused per ticket.
676
+ * Pure derived view of book runs' invocation.json files — not a parallel ledger.
676
677
  */
677
678
  export type TicketTrajectoryBookIndex = {
678
- /** ticket number → flat runs bound via invocation.json ticketNumber. */
679
+ /** ticket number → runs bound via invocation.json ticketNumber. */
679
680
  readonly runsByTicket: ReadonlyMap<number, readonly FlatRunTicketBinding[]>;
680
- /** Flat runs with no typed ticketNumber (unknown/unbound seam). */
681
+ /** Runs with no typed ticketNumber (unknown/unbound seam). */
681
682
  readonly unboundRuns: readonly FlatRunTicketBinding[];
682
683
  };
683
684
 
684
- async function listFlatRunDirectories(ledgerDir: string): Promise<string[]> {
685
- const runsDir = join(resolve(ledgerDir), "runs");
686
- try {
687
- const entries = await readdir(runsDir, { withFileTypes: true });
688
- return entries
689
- .filter((entry) => entry.isDirectory())
690
- .map((entry) => join(runsDir, entry.name))
691
- .sort();
692
- } catch (error) {
693
- if (isMissingPathError(error)) return [];
694
- throw error;
695
- }
696
- }
697
-
698
685
  export async function buildTicketTrajectoryBookIndex(
699
686
  ledgerDir: string,
700
687
  ): Promise<TicketTrajectoryBookIndex> {
701
688
  const root = resolve(ledgerDir);
702
689
  const runsByTicket = new Map<number, FlatRunTicketBinding[]>();
703
690
  const unboundRuns: FlatRunTicketBinding[] = [];
704
- for (const runDir of await listFlatRunDirectories(root)) {
691
+ for (const runDir of await listBookRunDirectories(root)) {
705
692
  const invocation = await readInvocation(runDir);
693
+ const rel = relative(root, runDir).split(sep).join("/");
706
694
  const binding: FlatRunTicketBinding = {
707
695
  runDir,
708
696
  runFolder: basename(runDir),
697
+ ledgerCoord: rel,
709
698
  ...(invocation?.ticketNumber !== undefined
710
699
  ? { ticketNumber: invocation.ticketNumber }
711
700
  : {}),
@@ -744,8 +733,7 @@ export async function loadUnboundTrajectoryRuns(
744
733
  const index = bookIndex ?? (await buildTicketTrajectoryBookIndex(root));
745
734
  const out: UnboundTrajectoryRun[] = [];
746
735
  for (const binding of index.unboundRuns) {
747
- const ledgerCoord = ["runs", binding.runFolder].join("/");
748
- const run = await parseRunDirectory(binding.runDir, ledgerCoord);
736
+ const run = await parseRunDirectory(binding.runDir, binding.ledgerCoord);
749
737
  out.push({
750
738
  run,
751
739
  role: binding.role ?? run.station,
@@ -781,14 +769,13 @@ export async function loadTicketTrajectoryRuns(
781
769
  runs.push(parsed);
782
770
  }
783
771
 
784
- // Flat runs bound by typed invocation.json ticketNumber.
772
+ // Book runs (flat + subject-tree) bound by typed invocation.json ticketNumber.
785
773
  const flatBindings = index.runsByTicket.get(issueNumber) ?? [];
786
774
  for (const binding of flatBindings) {
787
775
  const resolvedRunDir = resolve(binding.runDir);
788
776
  if (seenRunDirs.has(resolvedRunDir)) continue;
789
777
  seenRunDirs.add(resolvedRunDir);
790
- const ledgerCoord = ["runs", binding.runFolder].join("/");
791
- runs.push(await parseRunDirectory(binding.runDir, ledgerCoord));
778
+ runs.push(await parseRunDirectory(binding.runDir, binding.ledgerCoord));
792
779
  }
793
780
 
794
781
  return runs;
@@ -145,8 +145,8 @@ export type CoderRoleDependencies = {
145
145
 
146
146
  export type WorkerRoleRuntime = {
147
147
  activate(ctx?: HostContext): Promise<void>;
148
- /** Arm gate ① baseline after envelope places the worktree (coder/fixer). Parent feeds archivist durability. */
149
- armSubmissionGate(cwd: string, parent?: { getSessionFile(): string | undefined }): void;
148
+ /** Arm gate ① baseline after envelope places the worktree (coder/fixer). Durable parent required (#857). */
149
+ armSubmissionGate(cwd: string, parent: { getSessionFile(): string | undefined }): void;
150
150
  };
151
151
 
152
152
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -328,7 +328,7 @@ export function createFixerRoleRuntime(
328
328
  });
329
329
  }
330
330
  },
331
- armSubmissionGate(cwd: string, parent?: { getSessionFile(): string | undefined }) {
331
+ armSubmissionGate(cwd: string, parent: { getSessionFile(): string | undefined }) {
332
332
  submissionGate.arm(cwd, parent);
333
333
  },
334
334
  };
@@ -477,7 +477,7 @@ export function createCoderRoleRuntime(
477
477
  });
478
478
  }
479
479
  },
480
- armSubmissionGate(cwd: string, parent?: { getSessionFile(): string | undefined }) {
480
+ armSubmissionGate(cwd: string, parent: { getSessionFile(): string | undefined }) {
481
481
  submissionGate.arm(cwd, parent);
482
482
  },
483
483
  };
@@ -221,9 +221,8 @@ function reliableWindow(
221
221
 
222
222
  export type CreateWorkerSubmissionGateOptions = {
223
223
  /**
224
- * Explicit package home for sitian writes when no parent session is armed.
225
- * Existing injection point on sitianReport — does not change fallback when omitted
226
- * (path-derive from parent, else package machine home). #604 Scope 2.
224
+ * Explicit package home for sitian writes (tests / admitted run).
225
+ * Path still derives from required parent session; home only pins ledger root. #604 Scope 2.
227
226
  */
228
227
  readonly home?: string;
229
228
  };
@@ -231,7 +230,8 @@ export type CreateWorkerSubmissionGateOptions = {
231
230
  export function createWorkerSubmissionGate(
232
231
  options: CreateWorkerSubmissionGateOptions = {},
233
232
  ): {
234
- arm(cwd: string, parent?: WorkerSubmissionGateParent): void;
233
+ /** Durable parent is required — ownership must be known at arm (#857 loud failure). */
234
+ arm(cwd: string, parent: WorkerSubmissionGateParent): void;
235
235
  assertAcceptable(status: string, details?: unknown): void;
236
236
  } {
237
237
  let baseline: string | null | undefined;
@@ -267,11 +267,11 @@ export function createWorkerSubmissionGate(
267
267
  arm(cwd, parent) {
268
268
  uninstallPackageWorkerHooks(cwd);
269
269
  root = cwd;
270
- sessionParent = parent?.getSessionFile();
270
+ sessionParent = parent.getSessionFile();
271
271
  record = createRecordSession({
272
272
  cwd,
273
273
  kind: WORKER_SUBMISSION_GATE_RECORD_KIND,
274
- ...(parent === undefined ? {} : { parent }),
274
+ parent,
275
275
  });
276
276
  const prior = readGateState(record);
277
277
  if (prior.baseline !== undefined) {