@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
@@ -1,13 +1,23 @@
1
+ import { join } from "node:path";
2
+
1
3
  import {
2
4
  resolveActivationLedgerHome,
3
5
  tryHomeFromAkRolesPath,
4
6
  } from "./activation-ledger-topology.ts";
5
- import type { HostContext, HostToolResult, RoleHost } from "./host-contracts.ts";
7
+ import {
8
+ courtAttemptIdFromHostContext,
9
+ runDirectoryFromHostContext,
10
+ type HostContext,
11
+ type HostToolResult,
12
+ type RoleHost,
13
+ } from "./host-contracts.ts";
6
14
  import { isAuditEscalationProjection } from "./audit-escalation.ts";
7
15
 
8
16
 
9
17
  import { runIdFromRunDirectory } from "./run-terminal-artifacts.ts";
10
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";
11
21
  import type { TerminalRoleName } from "./public-cli/terminal.ts";
12
22
  import { isCorrectableExecuteError } from "./submission-correctable-error.ts";
13
23
  import { failOnInfrastructureFailureDeclaration } from "./package-contracts/terminating-infrastructure.ts";
@@ -57,8 +67,8 @@ export type SubmissionLedgerEvent =
57
67
  * otherwise session header id. Never a shared "unbound" bucket.
58
68
  */
59
69
  function runIdentity(context: HostContext): string {
60
- const directory = process.env.AK_ROLE_RUN_DIR;
61
- if (typeof directory === "string" && directory.length > 0) {
70
+ const directory = runDirectoryFromHostContext(context);
71
+ if (directory !== undefined) {
62
72
  const fromDir = runIdFromRunDirectory(directory);
63
73
  if (fromDir !== undefined) return fromDir;
64
74
  }
@@ -74,8 +84,8 @@ function runIdentity(context: HostContext): string {
74
84
  export const COURT_ATTEMPT_ENV = "AK_ROLE_COURT_ATTEMPT" as const;
75
85
 
76
86
  function attemptIdentity(context: HostContext, runId: string): string {
77
- const courtAttempt = process.env[COURT_ATTEMPT_ENV];
78
- if (typeof courtAttempt === "string" && courtAttempt.length > 0) return courtAttempt;
87
+ const courtAttempt = courtAttemptIdFromHostContext(context);
88
+ if (courtAttempt !== undefined) return courtAttempt;
79
89
  return context.sessionManager.getHeader?.()?.id ?? context.sessionManager.getLeafId?.() ?? `${runId}:initial`;
80
90
  }
81
91
 
@@ -105,28 +115,64 @@ export type ClosedSubmission = {
105
115
 
106
116
  export type ClosedSubmissionProjection = ClosedSubmission;
107
117
 
108
- function submissionRecordFile(cwd: string, runId: string, home?: string): string {
109
- 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
+ }
110
132
  return resolveSitianRecordPathInLedger({
111
133
  level: "event",
112
134
  kind: "candidate",
113
135
  subject: { runId },
114
136
  cwd,
137
+ sessionParent,
115
138
  }, ledgerHome).recordFile;
116
139
  }
117
140
 
118
- async function readOwnedSubmissionRecords(cwd: string, runId: string, home?: string) {
119
- 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
+ }
120
159
  const { records } = await readSitianRecords(file);
121
160
  return {
122
161
  file,
123
- 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
+ ),
124
168
  };
125
169
  }
126
170
 
127
171
  /** Optional court-turn scope for settlement reads (#637). */
128
172
  export type SubmissionLedgerReadScope = {
129
173
  readonly home?: string;
174
+ /** Durable run principal; the direct ownership coordinate when already known. */
175
+ readonly sessionParent?: string;
130
176
  /**
131
177
  * Recording tag for this court. Presentation of original payloads is run-scoped
132
178
  * (#836: attempt/latest must not hide already-recorded rows).
@@ -178,9 +224,9 @@ export async function hasFreshAttemptSubmission(
178
224
  cwd: string,
179
225
  runId: string,
180
226
  attemptId: string,
181
- home?: string,
227
+ homeOrScope?: string | SubmissionLedgerReadScope,
182
228
  ): Promise<boolean> {
183
- const { owned } = await readOwnedSubmissionRecords(cwd, runId, home);
229
+ const { owned } = await readOwnedSubmissionRecords(cwd, runId, resolveReadScope(homeOrScope));
184
230
  return owned.some((record) => {
185
231
  if (recordAttemptId(record) !== attemptId) return false;
186
232
  if (record.kind === "sealed") return true;
@@ -258,14 +304,10 @@ function rowFromPayload(
258
304
  * recorded attemptId + toolCallId so distinct court attempts stay distinct (#881).
259
305
  * `accepted` is the original payload; never rebuilt from a status/facts envelope.
260
306
  */
261
- export async function readRecordedSubmissionRows(
262
- cwd: string,
263
- runId: string,
264
- homeOrScope?: string | SubmissionLedgerReadScope,
265
- ): Promise<readonly RecordedSubmissionRow[]> {
266
- const scope = resolveReadScope(homeOrScope);
267
- const { owned } = await readOwnedSubmissionRecords(cwd, runId, scope.home);
268
- const scoped = recordsForAttempt(owned, scope.attemptId);
307
+ function mapOwnedToSubmissionRows(
308
+ owned: readonly { kind?: unknown; subject?: unknown; payload?: unknown }[],
309
+ ): readonly RecordedSubmissionRow[] {
310
+ const scoped = owned;
269
311
  const out: RecordedSubmissionRow[] = [];
270
312
  const indexByCall = new Map<string, number>();
271
313
  // Recover seat identity for historical non-sealed rows that omitted role (#881).
@@ -360,6 +402,43 @@ export async function readRecordedSubmissionRows(
360
402
  return out;
361
403
  }
362
404
 
405
+ /**
406
+ * All recorded role submissions in ledger order (#836 multi-submit).
407
+ * `accepted` is the original payload; never rebuilt from a status/facts envelope.
408
+ * Presentation stays run-scoped: attemptId on the read scope is a recording tag,
409
+ * not a visibility gate (#836).
410
+ */
411
+ export async function readRecordedSubmissionRows(
412
+ cwd: string,
413
+ runId: string,
414
+ homeOrScope?: string | SubmissionLedgerReadScope,
415
+ ): Promise<readonly RecordedSubmissionRow[]> {
416
+ const scope = resolveReadScope(homeOrScope);
417
+ const { owned } = await readOwnedSubmissionRecords(cwd, runId, scope);
418
+ const scoped = recordsForAttempt(owned, scope.attemptId);
419
+ return mapOwnedToSubmissionRows(scoped);
420
+ }
421
+
422
+ /**
423
+ * Settlement-only this-court rows (#879 return path).
424
+ * Filters by attemptId for court-scoped roleOutcome; does not replace the
425
+ * run-scoped presentation API above (#836 visibility gate stays pass-through).
426
+ */
427
+ export async function readAttemptScopedSubmissionRows(
428
+ cwd: string,
429
+ runId: string,
430
+ attemptId: string,
431
+ home?: string,
432
+ ): Promise<readonly RecordedSubmissionRow[]> {
433
+ if (attemptId.length === 0) return [];
434
+ const { owned } = await readOwnedSubmissionRecords(
435
+ cwd,
436
+ runId,
437
+ home === undefined ? {} : { home },
438
+ );
439
+ return mapOwnedToSubmissionRows(owned.filter((record) => recordAttemptId(record) === attemptId));
440
+ }
441
+
363
442
  /**
364
443
  * All raw role payloads in ledger order (#836 multi-submit).
365
444
  * Returns `accepted` bytes as stored — unknown, never re-wrapped.
@@ -390,7 +469,7 @@ export async function readLatestSubmissionOutcome(
390
469
  homeOrScope?: string | SubmissionLedgerReadScope,
391
470
  ): Promise<LatestSubmissionOutcome | undefined> {
392
471
  const scope = resolveReadScope(homeOrScope);
393
- const { owned } = await readOwnedSubmissionRecords(cwd, runId, scope.home);
472
+ const { owned } = await readOwnedSubmissionRecords(cwd, runId, scope);
394
473
  const scoped = recordsForAttempt(owned, scope.attemptId);
395
474
  for (let index = scoped.length - 1; index >= 0; index -= 1) {
396
475
  const record = scoped[index];
@@ -405,14 +484,35 @@ export async function readLatestSubmissionOutcome(
405
484
 
406
485
  type LedgerState = { prior?: RecordPointer; sequence: number };
407
486
 
408
- async function restoreState(cwd: string, runId: string, home?: string): Promise<LedgerState> {
409
- 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;
410
499
  const last = owned.at(-1);
411
500
  return {
412
- ...(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
+ }),
413
511
  sequence: owned.reduce((maximum, record) => {
414
512
  const payload = record.payload as Partial<SubmissionLedgerEvent> | undefined;
415
- 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;
416
516
  }, 0),
417
517
  };
418
518
  }
@@ -431,21 +531,38 @@ export function createSubmissionLedgerHost(
431
531
  options?: { home?: string },
432
532
  ): RoleHost {
433
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
+ };
434
548
  const resolveHomeFromContext = (context: HostContext): string | undefined => {
435
549
  if (options?.home !== undefined) return options.home;
436
- const sessionFile = context.sessionManager.getSessionFile?.() || context.sessionManager.getSessionDir?.();
437
- return typeof sessionFile === "string" && sessionFile.length > 0
438
- ? tryHomeFromAkRolesPath(sessionFile)
439
- : undefined;
550
+ const sessionParent = sessionParentFromContext(context);
551
+ return sessionParent !== undefined ? tryHomeFromAkRolesPath(sessionParent) : undefined;
440
552
  };
441
553
  const stateFor = (context: HostContext, runId: string) => states.get(runId) ?? (() => {
442
554
  const home = resolveHomeFromContext(context);
443
- 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
+ });
444
560
  states.set(runId, pending);
445
561
  return pending;
446
562
  })();
447
563
  const appendFor = (state: LedgerState, context: HostContext, runId: string, attemptId: string, event: SubmissionLedgerEvent): RecordPointer => {
448
564
  const home = resolveHomeFromContext(context);
565
+ const sessionParent = sessionParentFromContext(context);
449
566
  const pointer = sitianReport({
450
567
  level: "event",
451
568
  kind: event.type,
@@ -455,6 +572,7 @@ export function createSubmissionLedgerHost(
455
572
  source: "role-runtime",
456
573
  cwd: context.cwd,
457
574
  ...(home !== undefined ? { home } : {}),
575
+ ...(sessionParent === undefined ? {} : { sessionParent }),
458
576
  });
459
577
  state.prior = pointer;
460
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;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Typed user-dialogue stdin transport (#879 / #471).
3
+ * Pi's piped-stdin reader trims the wrapper; the body field keeps original bytes.
4
+ * No size cap and no path/pointer substitute — the whole dialogue rides the envelope.
5
+ */
6
+ export const USER_DIALOGUE_STDIN_KIND = "ak-user-dialogue" as const;
7
+
8
+ export type UserDialogueStdinEnvelope = {
9
+ readonly kind: typeof USER_DIALOGUE_STDIN_KIND;
10
+ readonly body: string;
11
+ };
12
+
13
+ export function encodeUserDialogueStdin(body: string): string {
14
+ return JSON.stringify({ kind: USER_DIALOGUE_STDIN_KIND, body } satisfies UserDialogueStdinEnvelope);
15
+ }
16
+
17
+ function isUserDialogueEnvelope(value: unknown): value is UserDialogueStdinEnvelope {
18
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
19
+ const record = value as { kind?: unknown; body?: unknown };
20
+ return record.kind === USER_DIALOGUE_STDIN_KIND && typeof record.body === "string";
21
+ }
22
+
23
+ /**
24
+ * Host read: recover original dialogue after a trim-capable pipe.
25
+ * Non-envelope text (interactive Pi, officer JSON, headless raw body) is unchanged.
26
+ */
27
+ export function readUserDialogueStdin(raw: string): string {
28
+ const trimmed = raw.trim();
29
+ if (!trimmed.startsWith("{")) return raw;
30
+ try {
31
+ const parsed: unknown = JSON.parse(trimmed);
32
+ if (isUserDialogueEnvelope(parsed)) return parsed.body;
33
+ } catch {
34
+ // Not our envelope — leave caller bytes intact.
35
+ }
36
+ return raw;
37
+ }
@@ -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> {
@@ -290,6 +290,8 @@ export function createFixerRoleRuntime(
290
290
  ...(_signal === undefined ? {} : { signal: _signal }),
291
291
  hostActions,
292
292
  toolCallId,
293
+ // #879: this-turn typed payload — identity-bound at submit site.
294
+ submission: output,
293
295
  });
294
296
  }
295
297
  const acceptedDetails = output;
@@ -326,7 +328,7 @@ export function createFixerRoleRuntime(
326
328
  });
327
329
  }
328
330
  },
329
- armSubmissionGate(cwd: string, parent?: { getSessionFile(): string | undefined }) {
331
+ armSubmissionGate(cwd: string, parent: { getSessionFile(): string | undefined }) {
330
332
  submissionGate.arm(cwd, parent);
331
333
  },
332
334
  };
@@ -428,6 +430,8 @@ export function createCoderRoleRuntime(
428
430
  ...(_signal === undefined ? {} : { signal: _signal }),
429
431
  hostActions,
430
432
  toolCallId,
433
+ // #879: this-turn typed payload — identity-bound at submit site.
434
+ submission: output,
431
435
  });
432
436
  }
433
437
  const acceptedDetails = output;
@@ -473,7 +477,7 @@ export function createCoderRoleRuntime(
473
477
  });
474
478
  }
475
479
  },
476
- armSubmissionGate(cwd: string, parent?: { getSessionFile(): string | undefined }) {
480
+ armSubmissionGate(cwd: string, parent: { getSessionFile(): string | undefined }) {
477
481
  submissionGate.arm(cwd, parent);
478
482
  },
479
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) {