@akagilnc/pi-workflow-roles 0.1.3412 → 0.1.3439

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 (67) hide show
  1. package/dist/archivist-record-entry.js +21 -12
  2. package/dist/archivist-record-topology.js +20 -0
  3. package/dist/auditor-dossier-tool.js +67 -76
  4. package/dist/canonical-json.js +66 -69
  5. package/dist/compliance-transport.js +99 -86
  6. package/dist/countersign-contracts.js +23 -25
  7. package/dist/doctor-contracts.js +169 -145
  8. package/dist/engine-detour-tool.js +83 -87
  9. package/dist/engine-detour.js +148 -183
  10. package/dist/evidence-child-executor.js +10 -1
  11. package/dist/exact-utf8.js +11 -10
  12. package/dist/git-object-id.js +10 -5
  13. package/dist/gleaner-left-contracts.js +33 -35
  14. package/dist/grok/production-host.js +229 -118
  15. package/dist/inspector-contracts.js +28 -31
  16. package/dist/institutional-resolution.js +101 -104
  17. package/dist/ledger-session-read.js +241 -0
  18. package/dist/merger-contracts.js +66 -56
  19. package/dist/notary-contracts.js +91 -100
  20. package/dist/open-tool-schema.js +32 -31
  21. package/dist/package-contracts/collector-output.js +56 -43
  22. package/dist/package-contracts/fixer-output.js +52 -50
  23. package/dist/package-contracts/fixer-packet.js +67 -64
  24. package/dist/package-contracts/gatekeeper-output.js +48 -56
  25. package/dist/package-contracts/judge-output.js +23 -20
  26. package/dist/package-contracts/navigator-output.js +35 -40
  27. package/dist/package-contracts/reviewer-output.js +94 -110
  28. package/dist/package-contracts/terminating-infrastructure.js +62 -90
  29. package/dist/package-contracts/worker-output.js +30 -11
  30. package/dist/package-resources/engine-material.js +58 -94
  31. package/dist/public-cli/config.js +420 -484
  32. package/dist/public-cli/main.js +1219 -524
  33. package/dist/public-cli/registry.js +107 -114
  34. package/dist/receipt-delivery-policy.js +74 -76
  35. package/dist/session-opening-materials.js +44 -47
  36. package/dist/sha256.js +5 -3
  37. package/dist/sitian-appender.js +204 -210
  38. package/dist/sitian-contracts.js +15 -19
  39. package/dist/sitian-facade.js +5 -10
  40. package/dist/sitian-reader.js +37 -46
  41. package/dist/stream-idle-guard.js +63 -71
  42. package/dist/ticket-seat-memory.js +244 -0
  43. package/package.json +1 -1
  44. package/scripts/build-package.mjs +41 -0
  45. package/souls/audit-law.md +4 -0
  46. package/souls/countersign.md +1 -1
  47. package/souls/gleaner-left.md +1 -1
  48. package/souls/inspector.md +1 -1
  49. package/souls/notary.md +1 -1
  50. package/souls/reviewer.md +1 -1
  51. package/src/analyst-gate-cycles-read.ts +152 -83
  52. package/src/analyst-ledger.ts +170 -43
  53. package/src/archivist-record-entry.ts +32 -11
  54. package/src/archivist-record-topology.ts +39 -0
  55. package/src/evidence-child-executor.ts +14 -1
  56. package/src/grok/production-host.ts +44 -9
  57. package/src/host-contracts.ts +18 -0
  58. package/src/host-transition-prior-native.ts +8 -1
  59. package/src/ledger-session-read.ts +56 -0
  60. package/src/pi/durable-principal.ts +6 -0
  61. package/src/public-cli/inspector-run.ts +21 -10
  62. package/src/public-cli/invocation.ts +4 -20
  63. package/src/public-cli/notary-run.ts +21 -4
  64. package/src/public-cli/post-admission.ts +333 -42
  65. package/src/public-cli/settlement.ts +266 -81
  66. package/src/session-opening-materials.ts +4 -2
  67. package/src/ticket-seat-memory.ts +454 -0
@@ -33,9 +33,12 @@ import {
33
33
  } from "./compliance-transport.ts";
34
34
  import {
35
35
  extractSessionTimestampSpan,
36
+ intervalRowsAroundAnchor,
37
+ LedgerSessionJsonlError,
36
38
  readLedgerSessionJsonl,
37
39
  type LedgerSessionRow,
38
40
  } from "./ledger-session-read.ts";
41
+ import { isTicketSeatRunBindingRow } from "./ticket-seat-memory.ts";
39
42
 
40
43
  /** One completed gate round: direct officer receipt or historical province/officer pair. */
41
44
  /** Honest origin discriminant: direct summons vs historical province dispatch. */
@@ -91,17 +94,17 @@ function isRecord(value: unknown): value is Record<string, unknown> {
91
94
  return typeof value === "object" && value !== null && !Array.isArray(value);
92
95
  }
93
96
 
94
- /** Typed parent-attempt id from ak_auditor_parent_attempt_binding — durable invocation association. */
95
- function extractAttemptEntryId(rows: readonly LedgerSessionRow[]): string | undefined {
96
- for (const row of rows) {
97
- if (row.type !== "custom" || row.customType !== AUDITOR_PARENT_ATTEMPT_BINDING_ENTRY_TYPE) continue;
98
- if (!isRecord(row.data) || !isRecord(row.data.parent)) continue;
99
- const id = row.data.parent.attemptEntryId;
100
- if (typeof id === "string" && id.length > 0) return id;
101
- }
102
- return undefined;
97
+ function isParentAttemptBindingRow(row: LedgerSessionRow): boolean {
98
+ return row.type === "custom" && row.customType === AUDITOR_PARENT_ATTEMPT_BINDING_ENTRY_TYPE;
103
99
  }
104
100
 
101
+ /** One summons' interval via shared #636 binding-slice (parent-attempt markers). */
102
+ function intervalRowsForGateCall(
103
+ rows: readonly LedgerSessionRow[],
104
+ callRowIndex: number,
105
+ ): readonly LedgerSessionRow[] {
106
+ return intervalRowsAroundAnchor(rows, callRowIndex, isParentAttemptBindingRow).rows;
107
+ }
105
108
 
106
109
  /** Only true absence (ENOENT). ENOTDIR is damaged topology — must stay loud. */
107
110
  function isMissingDirectoryError(error: unknown): boolean {
@@ -160,44 +163,6 @@ function acceptedGateReceiptIds(
160
163
  return accepted;
161
164
  }
162
165
 
163
- /**
164
- * Last accepted gate terminating toolCall on a nested volume (dispatch or officer).
165
- * Preference: keep the last accepted receipt; fall back to a rejected call only
166
- * when no accepted receipt exists (so classify can lawfully omit). Identity only
167
- * — typed args are validated after recognition so unusable facts fail loud
168
- * instead of being skipped as "not a gate volume". Soul-audit and other non-gate
169
- * tools never qualify.
170
- */
171
- function extractLastGateToolCall(
172
- rows: readonly LedgerSessionRow[],
173
- ): GateToolCall | undefined {
174
- const acceptedIds = acceptedGateReceiptIds(rows);
175
- let lastAccepted: GateToolCall | undefined;
176
- let lastRejected: GateToolCall | undefined;
177
- for (const row of rows) {
178
- const message = isRecord(row.message) ? row.message : undefined;
179
- if (message?.role !== "assistant" || !Array.isArray(message.content)) continue;
180
- for (const part of message.content) {
181
- if (!isRecord(part) || part.type !== "toolCall") continue;
182
- if (typeof part.id !== "string" || part.id.length === 0) continue;
183
- if (typeof part.name !== "string" || part.name.length === 0) continue;
184
- if (!isGateTerminatingToolName(part.name)) continue;
185
- const call: GateToolCall = {
186
- toolName: part.name,
187
- args: isRecord(part.arguments) ? part.arguments : undefined,
188
- accepted: acceptedIds.has(part.id),
189
- };
190
- if (call.accepted) {
191
- lastAccepted = call;
192
- } else if (lastAccepted === undefined) {
193
- // Only retain rejected while no accepted receipt has appeared yet.
194
- lastRejected = call;
195
- }
196
- }
197
- }
198
- return lastAccepted ?? lastRejected;
199
- }
200
-
201
166
  function requireAcceptedGateStatus(
202
167
  args: Record<string, unknown> | undefined,
203
168
  filePath: string,
@@ -243,6 +208,8 @@ type ClassifiedVolume =
243
208
  readonly reason?: string;
244
209
  /** Present only when the volume carries ak_auditor_parent_attempt_binding. */
245
210
  readonly attemptEntryId?: string;
211
+ /** Binding parent session file when present (#636 multi-parent memory filter). */
212
+ readonly parentSessionFile?: string;
246
213
  }
247
214
  | {
248
215
  readonly kind: "officer";
@@ -254,30 +221,81 @@ type ClassifiedVolume =
254
221
  readonly findingsCount: number;
255
222
  readonly officerWallMs: number;
256
223
  readonly attemptEntryId?: string;
224
+ readonly parentSessionFile?: string;
257
225
  };
258
226
 
259
- async function classifyAuditorVolume(
260
- filePath: string,
261
- ): Promise<ClassifiedVolume | undefined> {
262
- // Canonical JSONL errors propagate — failure honesty (never wash to fewer rounds).
263
- const rows = await readLedgerSessionJsonl(filePath);
264
- // Recognize gate tool first. Non-gate volumes stay omitted; rejected gate
265
- // receipts omit before accepted-only span/status validation; once an accepted
266
- // receipt is present, required typed facts must not silently under-count.
267
- const call = extractLastGateToolCall(rows);
268
- if (call === undefined) return undefined;
227
+ /** Nearest preceding attempt binding (id + parent session file) before row index. */
228
+ function nearestAttemptBindingBefore(
229
+ rows: readonly LedgerSessionRow[],
230
+ beforeIndex: number,
231
+ ): { readonly attemptEntryId?: string; readonly parentSessionFile?: string } {
232
+ for (let i = beforeIndex - 1; i >= 0; i -= 1) {
233
+ const row = rows[i]!;
234
+ if (!isParentAttemptBindingRow(row)) continue;
235
+ if (!isRecord(row.data) || !isRecord(row.data.parent)) continue;
236
+ const id = row.data.parent.attemptEntryId;
237
+ const sessionFile = row.data.parent.sessionFile;
238
+ return {
239
+ ...(typeof id === "string" && id.length > 0 ? { attemptEntryId: id } : {}),
240
+ ...(typeof sessionFile === "string" && sessionFile.length > 0
241
+ ? { parentSessionFile: sessionFile }
242
+ : {}),
243
+ };
244
+ }
245
+ return {};
246
+ }
269
247
 
270
- const attemptEntryId = extractAttemptEntryId(rows);
271
- if (!call.accepted) {
272
- // Rejected historical dispatch is not a pairing key — omit it so a later
273
- // independent direct officer summons remains its own round. Span is not
274
- // validated here: accepted-only contract must not throw on rejected volumes.
275
- return undefined;
248
+ /**
249
+ * All accepted gate terminating toolCalls on a volume (dispatch or officer),
250
+ * each with nearest preceding attempt binding. Ticket-seat continuous memory
251
+ * (#636) may carry multiple parent summons in one file — last-only would drop rounds.
252
+ */
253
+ function extractAllAcceptedGateToolCalls(
254
+ rows: readonly LedgerSessionRow[],
255
+ ): ReadonlyArray<GateToolCall & { readonly rowIndex: number; readonly attemptEntryId?: string; readonly parentSessionFile?: string }> {
256
+ const acceptedIds = acceptedGateReceiptIds(rows);
257
+ const out: Array<GateToolCall & { readonly rowIndex: number; readonly attemptEntryId?: string; readonly parentSessionFile?: string }> = [];
258
+ for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) {
259
+ const row = rows[rowIndex]!;
260
+ const message = isRecord(row.message) ? row.message : undefined;
261
+ if (message?.role !== "assistant" || !Array.isArray(message.content)) continue;
262
+ for (const part of message.content) {
263
+ if (!isRecord(part) || part.type !== "toolCall") continue;
264
+ if (typeof part.id !== "string" || part.id.length === 0) continue;
265
+ if (typeof part.name !== "string" || part.name.length === 0) continue;
266
+ if (!isGateTerminatingToolName(part.name)) continue;
267
+ if (!acceptedIds.has(part.id)) continue;
268
+ const binding = nearestAttemptBindingBefore(rows, rowIndex);
269
+ out.push({
270
+ toolName: part.name,
271
+ args: isRecord(part.arguments) ? part.arguments : undefined,
272
+ accepted: true,
273
+ rowIndex,
274
+ ...binding,
275
+ });
276
+ }
276
277
  }
277
- const span = requireAcceptedGateSpan(rows, filePath);
278
+ return out;
279
+ }
280
+
281
+ function projectAcceptedGateCall(
282
+ filePath: string,
283
+ rows: readonly LedgerSessionRow[],
284
+ call: GateToolCall & {
285
+ readonly rowIndex: number;
286
+ readonly attemptEntryId?: string;
287
+ readonly parentSessionFile?: string;
288
+ },
289
+ ): ClassifiedVolume | undefined {
290
+ // Continuous memory volumes carry many summons; span only this binding's interval.
291
+ const span = requireAcceptedGateSpan(intervalRowsForGateCall(rows, call.rowIndex), filePath);
278
292
  const status = requireAcceptedGateStatus(call.args, filePath);
279
293
  const findings = asStringFindings(call.args?.findings);
280
294
  const findingsCount = findings.length;
295
+ const bindingFields = {
296
+ ...(call.attemptEntryId === undefined ? {} : { attemptEntryId: call.attemptEntryId }),
297
+ ...(call.parentSessionFile === undefined ? {} : { parentSessionFile: call.parentSessionFile }),
298
+ };
281
299
 
282
300
  if (DISPATCH_TOOLS.has(call.toolName)) {
283
301
  // Lawful province non-dispatch release — opens no round, never unreadable (#597).
@@ -303,7 +321,7 @@ async function classifyAuditorVolume(
303
321
  officer,
304
322
  status,
305
323
  ...(reason === undefined ? {} : { reason }),
306
- ...(attemptEntryId === undefined ? {} : { attemptEntryId }),
324
+ ...bindingFields,
307
325
  };
308
326
  }
309
327
 
@@ -323,10 +341,41 @@ async function classifyAuditorVolume(
323
341
  findings,
324
342
  findingsCount,
325
343
  officerWallMs: span.wallMs,
326
- ...(attemptEntryId === undefined ? {} : { attemptEntryId }),
344
+ ...bindingFields,
327
345
  };
328
346
  }
329
347
 
348
+ async function classifyAuditorVolume(
349
+ filePath: string,
350
+ ): Promise<readonly ClassifiedVolume[]> {
351
+ // Canonical JSONL errors propagate — failure honesty (never wash to fewer rounds).
352
+ // Shared ticket-seat main volumes are the exception: later-run damage after a
353
+ // closed binding must not erase earlier gate rounds (same ownership rule as
354
+ // timeline attribution). Pure nested gate volumes without run bindings stay loud.
355
+ let rows: readonly LedgerSessionRow[];
356
+ try {
357
+ rows = await readLedgerSessionJsonl(filePath);
358
+ } catch (error) {
359
+ if (
360
+ error instanceof LedgerSessionJsonlError
361
+ && error.prefixRows.some((row) => isTicketSeatRunBindingRow(row))
362
+ ) {
363
+ rows = error.prefixRows;
364
+ } else {
365
+ throw error;
366
+ }
367
+ }
368
+ const accepted = extractAllAcceptedGateToolCalls(rows);
369
+ // Rejected-only / non-gate volumes omit (empty accepted set).
370
+ if (accepted.length === 0) return [];
371
+ const volumes: ClassifiedVolume[] = [];
372
+ for (const call of accepted) {
373
+ const projected = projectAcceptedGateCall(filePath, rows, call);
374
+ if (projected !== undefined) volumes.push(projected);
375
+ }
376
+ return volumes;
377
+ }
378
+
330
379
  function pairGateRounds(
331
380
  volumes: readonly ClassifiedVolume[],
332
381
  ): readonly AnalystGateCycleRound[] {
@@ -402,29 +451,49 @@ function pairGateRounds(
402
451
  }
403
452
 
404
453
  /**
405
- * Read and pair gate-cycle rounds from a run's session/auditor-roles directory.
454
+ * Read and pair gate-cycle rounds from one or more auditor-roles directories.
406
455
  * ENOENT (directory truly absent) → []. ENOTDIR and other errors propagate
407
456
  * (failure honesty — damaged topology must not wash to zero rounds).
457
+ *
458
+ * #636 ticket-seat memory may place officer volumes under book-level subject
459
+ * digests shared across parent runs; pass `parentSessionFile` to keep only
460
+ * rounds whose attempt binding names that parent.
408
461
  */
409
462
  export async function readAnalystGateCyclesFromAuditorRoles(
410
- auditorRolesDirectory: string,
463
+ auditorRolesDirectory: string | readonly string[],
464
+ options: {
465
+ readonly parentSessionFile?: string;
466
+ } = {},
411
467
  ): Promise<readonly AnalystGateCycleRound[]> {
412
- let names: string[];
413
- try {
414
- const entries = await readdir(auditorRolesDirectory, { withFileTypes: true });
415
- names = entries
416
- .filter((e) => e.isFile() && e.name.endsWith(".jsonl"))
417
- .map((e) => e.name)
418
- .sort();
419
- } catch (error) {
420
- if (isMissingDirectoryError(error)) return [];
421
- throw error;
422
- }
423
-
468
+ const directories = typeof auditorRolesDirectory === "string"
469
+ ? [auditorRolesDirectory]
470
+ : auditorRolesDirectory;
424
471
  const volumes: ClassifiedVolume[] = [];
425
- for (const name of names) {
426
- const classified = await classifyAuditorVolume(join(auditorRolesDirectory, name));
427
- if (classified !== undefined) volumes.push(classified);
472
+ for (const directory of directories) {
473
+ let names: string[];
474
+ try {
475
+ const entries = await readdir(directory, { withFileTypes: true });
476
+ names = entries
477
+ .filter((e) => e.isFile() && e.name.endsWith(".jsonl"))
478
+ .map((e) => e.name)
479
+ .sort();
480
+ } catch (error) {
481
+ if (isMissingDirectoryError(error)) continue;
482
+ throw error;
483
+ }
484
+ for (const name of names) {
485
+ const classified = await classifyAuditorVolume(join(directory, name));
486
+ for (const volume of classified) {
487
+ if (
488
+ options.parentSessionFile !== undefined &&
489
+ volume.parentSessionFile !== undefined &&
490
+ volume.parentSessionFile !== options.parentSessionFile
491
+ ) {
492
+ continue;
493
+ }
494
+ volumes.push(volume);
495
+ }
496
+ }
428
497
  }
429
498
  return pairGateRounds(volumes);
430
499
  }
@@ -17,6 +17,7 @@ import { join } from "node:path";
17
17
 
18
18
  import { resolveBookKeyFromGit } from "./activation-ledger-git.ts";
19
19
  import {
20
+ pathContainedIn,
20
21
  physicalPathIdentity,
21
22
  resolveActivationLedgerHome,
22
23
  } from "./activation-ledger-topology.ts";
@@ -24,8 +25,10 @@ import {
24
25
  extractSessionModelSequence,
25
26
  extractSessionTimestampSpan,
26
27
  extractSessionToolIntervals,
28
+ intervalRowsForMatchingBinding,
27
29
  LedgerSessionJsonlError,
28
30
  readLedgerSessionJsonl,
31
+ type LedgerSessionRow,
29
32
  type SessionToolInterval,
30
33
  } from "./ledger-session-read.ts";
31
34
  import {
@@ -43,6 +46,11 @@ import {
43
46
  readAnalystGateCyclesFromAuditorRoles,
44
47
  type AnalystGateCycleRound,
45
48
  } from "./analyst-gate-cycles-read.ts";
49
+ import {
50
+ isTicketSeatRunBindingRow,
51
+ resolveTicketSeatMemoryNestDirectories,
52
+ ticketSeatRunBindingRunId,
53
+ } from "./ticket-seat-memory.ts";
46
54
 
47
55
  export type { AnalystGateCycleRound } from "./analyst-gate-cycles-read.ts";
48
56
 
@@ -201,6 +209,103 @@ async function resolveSessionFile(
201
209
  return join(runDirectory, "session", "session.jsonl");
202
210
  }
203
211
 
212
+ /**
213
+ * Attribute session rows to one run (#636 D).
214
+ * Private run volumes (session under runDirectory) keep the whole file.
215
+ * Shared ticket-seat main volumes must carry this run's binding interval;
216
+ * whole-volume stats would let later continuations pollute earlier runs.
217
+ * Unbounded external volumes stay honest missing — never silent full ownership.
218
+ *
219
+ * `closed` on shared intervals: a later binding ended ownership before EOF of
220
+ * the provided rows. On damaged-prefix recovery, only closed intervals keep
221
+ * full frame/model/tool facts — open intervals may have lost rows to the bad line.
222
+ * Private volumes are never closed against a prefix (the whole file is this run).
223
+ */
224
+ function attributeSessionRowsForRun(input: {
225
+ readonly rows: readonly LedgerSessionRow[];
226
+ readonly sessionFile: string;
227
+ readonly runDirectory: string;
228
+ readonly runId: string;
229
+ }):
230
+ | {
231
+ readonly kind: "attributed";
232
+ readonly rows: readonly LedgerSessionRow[];
233
+ readonly closed: boolean;
234
+ }
235
+ | { readonly kind: "missing-boundary"; readonly reason: string } {
236
+ if (pathContainedIn(input.runDirectory, input.sessionFile)) {
237
+ return { kind: "attributed", rows: input.rows, closed: false };
238
+ }
239
+ const interval = intervalRowsForMatchingBinding(
240
+ input.rows,
241
+ isTicketSeatRunBindingRow,
242
+ (row) => ticketSeatRunBindingRunId(row) === input.runId,
243
+ );
244
+ if (interval === undefined) {
245
+ return {
246
+ kind: "missing-boundary",
247
+ reason:
248
+ "shared session volume has no ticket-seat run binding for this run",
249
+ };
250
+ }
251
+ return { kind: "attributed", rows: interval.rows, closed: interval.closed };
252
+ }
253
+
254
+ /**
255
+ * Admit frame span (and partial edges) from already-attributed session rows.
256
+ * Shared by the clean parse path and closed-interval damage recovery.
257
+ */
258
+ function admitFrameSpanFromRows(rows: readonly LedgerSessionRow[]): {
259
+ readonly frameSpan?: AnalystRunFrameSpan;
260
+ readonly missingReason?: string;
261
+ readonly partialFirstFrameAt: AnalystFirstFrameAt;
262
+ readonly partialLastFrameAt: AnalystOptionalTimestamp;
263
+ readonly models: readonly string[];
264
+ } {
265
+ const models = extractSessionModelSequence(rows);
266
+ const span = extractSessionTimestampSpan(rows);
267
+ let partialFirstFrameAt: AnalystFirstFrameAt = { status: "absent" };
268
+ let partialLastFrameAt: AnalystOptionalTimestamp = { status: "absent" };
269
+ if (span.startedAt === undefined || span.endedAt === undefined) {
270
+ if (span.startedAt !== undefined) {
271
+ partialFirstFrameAt = { status: "present", at: span.startedAt };
272
+ }
273
+ if (span.endedAt !== undefined) {
274
+ partialLastFrameAt = { status: "present", at: span.endedAt };
275
+ }
276
+ return {
277
+ models,
278
+ partialFirstFrameAt,
279
+ partialLastFrameAt,
280
+ missingReason: "session timeline has no usable timestamps",
281
+ };
282
+ }
283
+ const startedMs = Date.parse(span.startedAt);
284
+ const endedMs = Date.parse(span.endedAt);
285
+ if (!Number.isFinite(startedMs) || !Number.isFinite(endedMs)) {
286
+ return {
287
+ models,
288
+ partialFirstFrameAt: { status: "present", at: span.startedAt },
289
+ partialLastFrameAt: { status: "present", at: span.endedAt },
290
+ missingReason: "session timeline timestamps are not parseable instants",
291
+ };
292
+ }
293
+ if (endedMs < startedMs) {
294
+ return {
295
+ models,
296
+ partialFirstFrameAt: { status: "present", at: span.startedAt },
297
+ partialLastFrameAt: { status: "present", at: span.endedAt },
298
+ missingReason: "session timeline end is earlier than start",
299
+ };
300
+ }
301
+ return {
302
+ models,
303
+ frameSpan: { startedAt: span.startedAt, endedAt: span.endedAt },
304
+ partialFirstFrameAt: { status: "present", at: span.startedAt },
305
+ partialLastFrameAt: { status: "present", at: span.endedAt },
306
+ };
307
+ }
308
+
204
309
  /** Session first/last usable timestamps retained for B-wave wall-clock kernels. */
205
310
  export type AnalystRunFrameSpan = {
206
311
  readonly startedAt: string;
@@ -277,55 +382,67 @@ async function classifyScopedRun(input: {
277
382
  let partialFirstFrameAt: AnalystFirstFrameAt = { status: "absent" };
278
383
  let partialLastFrameAt: AnalystOptionalTimestamp = { status: "absent" };
279
384
 
280
- // 1) session timeline
385
+ // 1) session timeline — shared ticket-seat volumes slice to this run's binding.
386
+ // Damage after a closed binding interval must not erase that run's full facts.
281
387
  const sessionFile = await resolveSessionFile(input.runDirectory);
282
- let rows: Awaited<ReturnType<typeof readLedgerSessionJsonl>> | undefined;
388
+ let rows: readonly LedgerSessionRow[] | undefined;
389
+ const admitAttributed = (
390
+ attributedRows: readonly LedgerSessionRow[],
391
+ ): void => {
392
+ rows = attributedRows;
393
+ const admitted = admitFrameSpanFromRows(attributedRows);
394
+ models = admitted.models;
395
+ partialFirstFrameAt = admitted.partialFirstFrameAt;
396
+ partialLastFrameAt = admitted.partialLastFrameAt;
397
+ if (admitted.frameSpan !== undefined) {
398
+ frameSpan = admitted.frameSpan;
399
+ } else if (admitted.missingReason !== undefined) {
400
+ missingSources.push("session-timeline");
401
+ reasons.push(admitted.missingReason);
402
+ }
403
+ };
283
404
  try {
284
- rows = await readLedgerSessionJsonl(sessionFile);
285
- models = extractSessionModelSequence(rows);
286
- const span = extractSessionTimestampSpan(rows);
287
- if (span.startedAt === undefined || span.endedAt === undefined) {
405
+ const rawRows = await readLedgerSessionJsonl(sessionFile);
406
+ const attributed = attributeSessionRowsForRun({
407
+ rows: rawRows,
408
+ sessionFile,
409
+ runDirectory: input.runDirectory,
410
+ runId: input.runId,
411
+ });
412
+ if (attributed.kind === "missing-boundary") {
288
413
  missingSources.push("session-timeline");
289
- reasons.push("session timeline has no usable timestamps");
290
- // Span incomplete, but any usable timestamp remains a typed partial fact.
291
- if (span.startedAt !== undefined) {
292
- partialFirstFrameAt = { status: "present", at: span.startedAt };
293
- }
294
- if (span.endedAt !== undefined) {
295
- partialLastFrameAt = { status: "present", at: span.endedAt };
296
- }
414
+ reasons.push(attributed.reason);
297
415
  } else {
298
- // frameSpan gate: both edges must parse and end must not precede start.
299
- // Damaged spans stay page-local unreadable — never throw or emit negative/NaN wallMs.
300
- const startedMs = Date.parse(span.startedAt);
301
- const endedMs = Date.parse(span.endedAt);
302
- if (!Number.isFinite(startedMs) || !Number.isFinite(endedMs)) {
303
- missingSources.push("session-timeline");
304
- reasons.push("session timeline timestamps are not parseable instants");
305
- partialFirstFrameAt = { status: "present", at: span.startedAt };
306
- partialLastFrameAt = { status: "present", at: span.endedAt };
307
- } else if (endedMs < startedMs) {
308
- missingSources.push("session-timeline");
309
- reasons.push("session timeline end is earlier than start");
310
- partialFirstFrameAt = { status: "present", at: span.startedAt };
311
- partialLastFrameAt = { status: "present", at: span.endedAt };
312
- } else {
313
- frameSpan = { startedAt: span.startedAt, endedAt: span.endedAt };
314
- }
416
+ admitAttributed(attributed.rows);
315
417
  }
316
418
  } catch (error) {
317
- missingSources.push("session-timeline");
318
- reasons.push(errorText(error));
319
- // Single parse kernel: recover first/last frame and models from rows read before the loud line.
419
+ // Single parse kernel: attribute the prefix, then decide by binding closure.
420
+ // Closed shared interval → full frame/model/tool (damage belongs to a later run).
421
+ // Open interval / private volume / missing boundary → honest missing + partial edges.
320
422
  if (error instanceof LedgerSessionJsonlError) {
321
- const span = extractSessionTimestampSpan(error.prefixRows);
322
- if (span.startedAt !== undefined) {
323
- partialFirstFrameAt = { status: "present", at: span.startedAt };
324
- }
325
- if (span.endedAt !== undefined) {
326
- partialLastFrameAt = { status: "present", at: span.endedAt };
423
+ const attributed = attributeSessionRowsForRun({
424
+ rows: error.prefixRows,
425
+ sessionFile,
426
+ runDirectory: input.runDirectory,
427
+ runId: input.runId,
428
+ });
429
+ if (attributed.kind === "attributed" && attributed.closed) {
430
+ admitAttributed(attributed.rows);
431
+ } else if (attributed.kind === "attributed") {
432
+ missingSources.push("session-timeline");
433
+ reasons.push(errorText(error));
434
+ const admitted = admitFrameSpanFromRows(attributed.rows);
435
+ models = admitted.models;
436
+ partialFirstFrameAt = admitted.partialFirstFrameAt;
437
+ partialLastFrameAt = admitted.partialLastFrameAt;
438
+ } else {
439
+ missingSources.push("session-timeline");
440
+ reasons.push(errorText(error));
441
+ // missing-boundary on damaged prefix: no partial ownership of sibling runs.
327
442
  }
328
- models = extractSessionModelSequence(error.prefixRows);
443
+ } else {
444
+ missingSources.push("session-timeline");
445
+ reasons.push(errorText(error));
329
446
  }
330
447
  }
331
448
 
@@ -399,11 +516,21 @@ async function classifyScopedRun(input: {
399
516
  // Nested auditor-roles gate pairs stay inside the sole scan (families must
400
517
  // not readdir this tree again). Missing directory → []. Damaged discovered
401
518
  // nested JSONL is page-local unreadable — never silently under-count rounds.
519
+ // #636: ticket-seat memory nests via sole discovery helper.
402
520
  let gateCycles: readonly AnalystGateCycleRound[];
403
521
  try {
404
- gateCycles = await readAnalystGateCyclesFromAuditorRoles(
522
+ const memoryNests = await resolveTicketSeatMemoryNestDirectories({
523
+ runDirectory: input.runDirectory,
524
+ seats: ["inspector", "notary"],
525
+ });
526
+ const directories = [
405
527
  join(input.runDirectory, "session", "auditor-roles"),
406
- );
528
+ ...memoryNests,
529
+ ];
530
+ const parentSessionFile = join(input.runDirectory, "session", "session.jsonl");
531
+ gateCycles = await readAnalystGateCyclesFromAuditorRoles(directories, {
532
+ parentSessionFile,
533
+ });
407
534
  } catch (error) {
408
535
  return {
409
536
  kind: "unreadable",
@@ -3,7 +3,6 @@
3
3
  * 调用方只声明自己是谁的什么;落点由候簿拓扑算出,签名不含任何落点/路径参数。
4
4
  * 「谁调了谁」复用 Pi parentSession + ADR 0047 correlation,不新增 caller 字段。
5
5
  */
6
- import { createHash } from "node:crypto";
7
6
  import { existsSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
8
7
  import { dirname, resolve, join } from "node:path";
9
8
  import { SessionManager } from "@earendil-works/pi-coding-agent";
@@ -18,6 +17,9 @@ import {
18
17
  physicallyContainedIn,
19
18
  resolveActivationLedgerHomeForPath,
20
19
  } from "./activation-ledger-topology.ts";
20
+ import { subjectKeyedRecordDirectory } from "./archivist-record-topology.ts";
21
+
22
+ export { subjectKeyedRecordDirectory } from "./archivist-record-topology.ts";
21
23
 
22
24
  const CURRENT_SESSION_LEDGER = "current-session.json";
23
25
 
@@ -120,6 +122,13 @@ function assertRecentFinalFileUnderSessionDir(
120
122
  }
121
123
  }
122
124
 
125
+ /** Open result including the sole resumed fact (nest existed before this open). */
126
+ export type RecordSessionOpen = {
127
+ readonly session: SessionManager;
128
+ /** True only when an existing same-nest volume was reopened (subject/gate path). */
129
+ readonly resumed: boolean;
130
+ };
131
+
123
132
  /**
124
133
  * Sole package entry that constructs a durable Pi session record (ADR 0065).
125
134
  * No destination/path parameters — location is computed from ledger topology only.
@@ -133,8 +142,10 @@ function assertRecentFinalFileUnderSessionDir(
133
142
  * a sibling volume selected only by kind/cwd/mtime.
134
143
  * New persisted principals materialize their deferred session header before return so
135
144
  * custom-entry-only writers do not need a parallel delayed-header helper.
145
+ *
146
+ * `resumed` is the sole open-or-continue fact — callers must not re-probe nest existence.
136
147
  */
137
- export function createRecordSession(options: CreateRecordSessionOptions): SessionManager {
148
+ export function createRecordSessionOpen(options: CreateRecordSessionOptions): RecordSessionOpen {
138
149
  const cwd = options.cwd;
139
150
  const parentFile = options.parent?.getSessionFile();
140
151
  // Path → ledger home is owned by topology (explicit env.home nests via parent path).
@@ -144,16 +155,18 @@ export function createRecordSession(options: CreateRecordSessionOptions): Sessio
144
155
  let parentSession: string | undefined;
145
156
 
146
157
  if (options.subject !== undefined) {
147
- const digest = createHash("sha256").update(options.subject).digest("hex").slice(0, 32);
148
- sessionDir = join(
149
- activationBookDirectory(ledgerHome, resolveBookKeyFromGit(cwd)),
150
- options.kind,
151
- digest,
152
- );
158
+ sessionDir = subjectKeyedRecordDirectory({
159
+ cwd,
160
+ kind: options.kind,
161
+ subject: options.subject,
162
+ ...(parentFile === undefined || parentFile.length === 0
163
+ ? {}
164
+ : { parentSessionFile: parentFile }),
165
+ });
153
166
  parentSession = parentFile && parentFile.length > 0 ? parentFile : undefined;
154
167
  } else if (parentFile === undefined || parentFile.length === 0) {
155
168
  // No durable parent principal — preserve prior in-memory child behavior.
156
- return SessionManager.inMemory(cwd);
169
+ return { session: SessionManager.inMemory(cwd), resumed: false };
157
170
  } else {
158
171
  const parentResolved = resolve(parentFile);
159
172
  // Nest under parent only when the parent record already lives under the package home.
@@ -177,7 +190,10 @@ export function createRecordSession(options: CreateRecordSessionOptions): Sessio
177
190
  if (mayResumeSameNest && nestAlreadyExists) {
178
191
  const recentFile = readCurrentSession(sessionDir);
179
192
  assertRecentFinalFileUnderSessionDir(sessionDir, recentFile);
180
- return SessionManager.open(recentFile, sessionDir, cwd);
193
+ return {
194
+ session: SessionManager.open(recentFile, sessionDir, cwd),
195
+ resumed: true,
196
+ };
181
197
  }
182
198
 
183
199
  const session = SessionManager.create(
@@ -202,5 +218,10 @@ export function createRecordSession(options: CreateRecordSessionOptions): Sessio
202
218
  writeCurrentSession(sessionDir, file);
203
219
  }
204
220
  }
205
- return session;
221
+ return { session, resumed: false };
222
+ }
223
+
224
+ /** Session-only facade — most callers only need the manager. */
225
+ export function createRecordSession(options: CreateRecordSessionOptions): SessionManager {
226
+ return createRecordSessionOpen(options).session;
206
227
  }