@akagilnc/pi-workflow-roles 0.1.3652 → 0.1.3661

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.
@@ -36,11 +36,9 @@ import {
36
36
  type SameTicketSummonsMaterials,
37
37
  } from "./run-lifecycle.ts";
38
38
  import {
39
- applyInstructionTicketProbe,
40
- probeInstructionTicket,
41
- ticketNumberFromProbe,
39
+ bindReusedTicketNumber,
40
+ resolveKnownTicketNumber,
42
41
  tryResumeSameTicketSeatRun,
43
- type InstructionTicketProbe,
44
42
  } from "./seat-ticket-binding.ts";
45
43
  import {
46
44
  presentStructuralRejection,
@@ -248,11 +246,11 @@ export async function runPublicInstructionSeat(
248
246
  let auditorSubject: "judge" | "doctor" | undefined;
249
247
  let auditorSourceRun: string | undefined;
250
248
  let auditorSourceTicket: number | undefined;
251
- let ticketProbe: InstructionTicketProbe | undefined;
249
+ let reusedTicketNumber: number | undefined;
252
250
 
253
251
  // #637 / #747: resume prior seat run with this summons' materials.
254
252
  // Auditor (#747): parent --source-run path is the lookup key.
255
- // Other instruction seats: ticket probe (inspector face). No bare catch→fresh.
253
+ // Other instruction seats: reuse known ticket (#709). No bare catch→fresh.
256
254
  const projectRoot = parsed.project ?? env.cwd;
257
255
  if (role === "auditor") {
258
256
  if (parsed.subject !== "judge" && parsed.subject !== "doctor") {
@@ -288,12 +286,11 @@ export async function runPublicInstructionSeat(
288
286
  return { exitCode: 2 };
289
287
  }
290
288
  } else {
291
- // Inspector face: unconditional probe (same seam as inspector-run.ts).
292
- ticketProbe = await probeInstructionTicket(
293
- parsed.instruction,
289
+ reusedTicketNumber = await resolveKnownTicketNumber({
290
+ instruction: parsed.instruction,
294
291
  projectRoot,
295
- env,
296
- );
292
+ home: env.home,
293
+ });
297
294
  }
298
295
 
299
296
  const summons: SameTicketSummonsMaterials = {
@@ -322,26 +319,22 @@ export async function runPublicInstructionSeat(
322
319
  }),
323
320
  });
324
321
  if (resumed !== undefined) return resumed;
325
- } else {
326
- const probedTicketNumber =
327
- ticketProbe === undefined ? undefined : ticketNumberFromProbe(ticketProbe);
328
- if (probedTicketNumber !== undefined) {
329
- const resumed = await tryResumeSameTicketSeatRun({
330
- home: env.home,
331
- projectRoot,
332
- role,
333
- ticketNumber: probedTicketNumber,
334
- freshSummons: env.freshSummons,
335
- summons,
336
- resume: (runId, materials) =>
337
- runPublicInstructionSeatResume(
338
- { runId, ...(materials === undefined ? {} : { summons: materials }) },
339
- env,
340
- io,
341
- ),
342
- });
343
- if (resumed !== undefined) return resumed;
344
- }
322
+ } else if (reusedTicketNumber !== undefined) {
323
+ const resumed = await tryResumeSameTicketSeatRun({
324
+ home: env.home,
325
+ projectRoot,
326
+ role,
327
+ ticketNumber: reusedTicketNumber,
328
+ freshSummons: env.freshSummons,
329
+ summons,
330
+ resume: (runId, materials) =>
331
+ runPublicInstructionSeatResume(
332
+ { runId, ...(materials === undefined ? {} : { summons: materials }) },
333
+ env,
334
+ io,
335
+ ),
336
+ });
337
+ if (resumed !== undefined) return resumed;
345
338
  }
346
339
 
347
340
  let admitted: AdmittedInstructionSeatInvocation;
@@ -406,13 +399,13 @@ export async function runPublicInstructionSeat(
406
399
  request: turnRequest,
407
400
  adapters: instructionSeatAdapters({
408
401
  beforeDispatch: async (admittedSeat) => {
409
- // #635/#637: ticket bind inside controlled-failure boundary.
402
+ // #635/#709: ticket bind inside controlled-failure boundary.
410
403
  // Auditor inherits source-run ticket (notary face).
411
- // Other seats: same applyInstructionTicketProbe as inspector-run.
404
+ // Other seats: reuse known ticket identity.
412
405
  if (auditorSourceTicket !== undefined) {
413
406
  await bindAdmittedTicketNumber(admittedSeat, auditorSourceTicket);
414
- } else if (ticketProbe !== undefined) {
415
- await applyInstructionTicketProbe(admittedSeat, ticketProbe);
407
+ } else {
408
+ await bindReusedTicketNumber(admittedSeat, reusedTicketNumber);
416
409
  }
417
410
  },
418
411
  }),
@@ -105,17 +105,10 @@ export type AdmittedRoleInvocationBase = {
105
105
  */
106
106
  readonly correlationId?: string;
107
107
  /**
108
- * Typed ticketNumber after seat LLM bind or notary source-run inheritance (#635).
109
- * Admission does not bind from CLI flag or attachment frontmatter.
108
+ * Typed ticketNumber after known-identity reuse or notary source-run inheritance
109
+ * (#635 / #709). Admission does not bind from CLI flag or attachment frontmatter.
110
110
  */
111
111
  readonly ticketNumber?: number;
112
- /**
113
- * Seat LLM ticket resolution settled as true-unbound (#635).
114
- * Distinct from unbound-not-yet-resolved (field absent). Mutually exclusive
115
- * with ticketNumber; both are durable one-shot conclusions so auto-resume
116
- * and manual resume never re-enter hermes resolution.
117
- */
118
- readonly ticketResolution?: "true-unbound";
119
112
  /** Effective model from invocation identity; restored on resume when no CLI model is given. */
120
113
  readonly model?: InvocationEffectiveModel;
121
114
  };
@@ -464,8 +457,8 @@ export async function persistAdmittedSourceRunPath(
464
457
  /**
465
458
  * Bind a post-admission resolved ticketNumber onto the in-memory admitted
466
459
  * object and both durable pages (invocation.json + admitted-request.json).
467
- * Used by seat LLM ticket binding when admission was unbound
468
- * (#635 / #582 diarist-resolves-ticket-llm-layer). Never clears an existing binding.
460
+ * Used by known-ticket reuse when admission was unbound (#635 / #709).
461
+ * Never clears an existing binding.
469
462
  */
470
463
  export async function bindAdmittedTicketNumber(
471
464
  admitted: AdmittedRoleInvocation,
@@ -474,11 +467,6 @@ export async function bindAdmittedTicketNumber(
474
467
  if (!Number.isSafeInteger(ticketNumber) || ticketNumber < 1) {
475
468
  throw new Error(`bindAdmittedTicketNumber requires a safe positive integer, got ${String(ticketNumber)}`);
476
469
  }
477
- if (admitted.ticketResolution === "true-unbound") {
478
- throw new Error(
479
- "bindAdmittedTicketNumber refuses to replace durable true-unbound ticket resolution",
480
- );
481
- }
482
470
  if (admitted.ticketNumber !== undefined) {
483
471
  if (admitted.ticketNumber === ticketNumber) return;
484
472
  throw new Error(
@@ -499,36 +487,6 @@ export async function bindAdmittedTicketNumber(
499
487
  );
500
488
  }
501
489
 
502
- /**
503
- * Persist seat LLM true-unbound conclusion onto in-memory admitted + both durable
504
- * pages so later auto-resume attempts and manual resume never re-enter hermes (#635).
505
- */
506
- export async function recordTrueUnboundTicketResolution(
507
- admitted: AdmittedRoleInvocation,
508
- ): Promise<void> {
509
- if (admitted.ticketNumber !== undefined) {
510
- throw new Error(
511
- `recordTrueUnboundTicketResolution refuses to overwrite ticket #${admitted.ticketNumber}`,
512
- );
513
- }
514
- if (admitted.ticketResolution === "true-unbound") return;
515
- (admitted as { ticketResolution?: "true-unbound" }).ticketResolution =
516
- "true-unbound";
517
- await mergeInvocationIdentityPage(admitted.runDirectory, {
518
- ticketResolution: "true-unbound",
519
- });
520
- const admittedPath = admitted.admittedRequestPath;
521
- const current = JSON.parse(await readFile(admittedPath, "utf8")) as Record<
522
- string,
523
- unknown
524
- >;
525
- await writeFile(
526
- admittedPath,
527
- `${JSON.stringify({ ...current, ticketResolution: "true-unbound" }, null, 2)}\n`,
528
- "utf8",
529
- );
530
- }
531
-
532
490
  /** Add the identity returned by the production Pi launch seam to its existing ledger page. */
533
491
  export async function recordLaunchedPiIdentity(
534
492
  runDirectory: string,
@@ -27,12 +27,25 @@ import type {
27
27
  ControlledFailureCause,
28
28
  DurablePrincipal,
29
29
  DurablePrincipalAuthority,
30
+ RoleTurnContinuation,
30
31
  RoleTurnHost,
31
32
  RoleTurnKnownFailure,
32
33
  RoleTurnRequest,
33
34
  RoleTurnResult,
34
35
  SessionCustomEntryAppender,
35
36
  } from "../host-contracts.ts";
37
+ import { projectCaseDossierPointerSection } from "./case-dossier-delivery.ts";
38
+
39
+ /** Append one system section to a continuation prompt, keeping its kind. */
40
+ function appendContinuationSection(
41
+ continuation: RoleTurnContinuation,
42
+ section: string,
43
+ ): RoleTurnContinuation {
44
+ const prompt = `${continuation.prompt}\n\n${section}`;
45
+ return continuation.kind === "initial"
46
+ ? { kind: "initial", prompt }
47
+ : { kind: "resume", prompt };
48
+ }
36
49
  import { projectHostTransitionPriorNative } from "../host-transition-prior-native.ts";
37
50
  import type { CredentialProviders, SeatModelConfig } from "./config.ts";
38
51
  import {
@@ -297,10 +310,10 @@ export async function dispatchPostAdmissionTurn<
297
310
  admitted.principal === undefined
298
311
  ? undefined
299
312
  : env.principalAuthority.decode(admitted.principal);
300
- let turnRequest: RoleTurnRequest;
313
+ let hostTransition: RoleTurnRequest["hostTransition"];
301
314
  try {
302
315
  previousHost = await readInvocationHost(admitted.runDirectory);
303
- const hostTransition =
316
+ hostTransition =
304
317
  previousHost !== undefined && liveHost !== undefined && principalCoordinates !== undefined
305
318
  ? await projectHostTransitionPriorNative({
306
319
  previousHost,
@@ -308,10 +321,6 @@ export async function dispatchPostAdmissionTurn<
308
321
  piSessionFile: principalCoordinates.sessionFile,
309
322
  })
310
323
  : undefined;
311
- turnRequest = env.signal === undefined ? request : { ...request, signal: env.signal };
312
- if (hostTransition !== undefined) {
313
- turnRequest = { ...turnRequest, hostTransition };
314
- }
315
324
  } catch (error) {
316
325
  // prior-native IO is on the public one-shot path — controlled failure, not bare throw.
317
326
  return (await presentControlledFailure(
@@ -351,6 +360,32 @@ export async function dispatchPostAdmissionTurn<
351
360
  }
352
361
  }
353
362
 
363
+ // Turn request is assembled after beforeDispatch so this turn sees whatever it
364
+ // settled — the seat's ticket bind re-projection. Case dossier delivery
365
+ // (ADR 0081) rides here for every public entry: first call, same-ticket
366
+ // re-summons and manual resume alike. System refs append their own neutral
367
+ // section; caller frozen attachments and the seat's own prompt bytes are
368
+ // never rewritten.
369
+ let turnRequest: RoleTurnRequest =
370
+ env.signal === undefined ? request : { ...request, signal: env.signal };
371
+ if (hostTransition !== undefined) {
372
+ turnRequest = { ...turnRequest, hostTransition };
373
+ }
374
+ const dossierSection = await projectCaseDossierPointerSection({
375
+ ticketNumber: admitted.ticketNumber,
376
+ projectRoot: admitted.projectRoot,
377
+ home: env.home,
378
+ });
379
+ if (dossierSection !== undefined) {
380
+ turnRequest = {
381
+ ...turnRequest,
382
+ continuation: appendContinuationSection(
383
+ turnRequest.continuation,
384
+ dossierSection,
385
+ ),
386
+ };
387
+ }
388
+
354
389
  let result: RoleTurnResult;
355
390
  try {
356
391
  result = await env.roleTurnHost.executeTurn(turnRequest);
@@ -1128,6 +1128,34 @@ export async function findLatestRunIdForSeatTicket(input: {
1128
1128
  return best;
1129
1129
  }
1130
1130
 
1131
+ /**
1132
+ * Ticket identities this book's retained runs already record (#709).
1133
+ * Read-only reuse surface for seat ticket binding — no ticket number is minted here.
1134
+ */
1135
+ export async function collectBookRunTicketNumbers(input: {
1136
+ readonly home: string;
1137
+ readonly bookKey: string;
1138
+ }): Promise<ReadonlySet<number>> {
1139
+ const ledgerHome = resolveActivationLedgerHome(input.home);
1140
+ const runsDir = join(
1141
+ activationBookDirectory(ledgerHome, input.bookKey),
1142
+ "runs",
1143
+ );
1144
+ let entries: string[];
1145
+ try {
1146
+ entries = await readdir(runsDir);
1147
+ } catch (error) {
1148
+ if (errorCodeOf(error) === "ENOENT") return new Set();
1149
+ throw error;
1150
+ }
1151
+ const known = new Set<number>();
1152
+ for (const entry of entries) {
1153
+ const ticketNumber = await readRunTicketNumber(join(runsDir, entry));
1154
+ if (ticketNumber !== undefined) known.add(ticketNumber);
1155
+ }
1156
+ return known;
1157
+ }
1158
+
1131
1159
  type LoadedAdmittedRequestFields = {
1132
1160
  readonly instruction: string;
1133
1161
  readonly instructionEmpty: boolean;
@@ -1143,8 +1171,6 @@ type LoadedAdmittedRequestFields = {
1143
1171
  readonly derived?: DerivedMergerEnvelope;
1144
1172
  readonly correlationId?: string;
1145
1173
  readonly ticketNumber?: number;
1146
- /** Durable seat LLM true-unbound conclusion (#635); restored on resume. */
1147
- readonly ticketResolution?: "true-unbound";
1148
1174
  /** Collector — admitted repository/PR identity restored on resume (#633). */
1149
1175
  readonly prNumber?: number;
1150
1176
  readonly repository?: string;
@@ -1168,7 +1194,6 @@ function parsePersistedTicketIdentity(
1168
1194
  ): {
1169
1195
  correlationId?: string;
1170
1196
  ticketNumber?: number;
1171
- ticketResolution?: "true-unbound";
1172
1197
  } {
1173
1198
  const correlationId =
1174
1199
  typeof record.correlationId === "string" && record.correlationId.trim() !== ""
@@ -1180,26 +1205,19 @@ function parsePersistedTicketIdentity(
1180
1205
  record.ticketNumber >= 1
1181
1206
  ? record.ticketNumber
1182
1207
  : undefined;
1183
- const ticketResolution =
1184
- record.ticketResolution === "true-unbound" ? "true-unbound" : undefined;
1185
1208
  return {
1186
1209
  ...(correlationId === undefined ? {} : { correlationId }),
1187
1210
  ...(ticketNumber === undefined ? {} : { ticketNumber }),
1188
- ...(ticketResolution === undefined ? {} : { ticketResolution }),
1189
1211
  };
1190
1212
  }
1191
1213
 
1192
1214
  function restoredTicketFields(fields: LoadedAdmittedRequestFields): {
1193
1215
  correlationId?: string;
1194
1216
  ticketNumber?: number;
1195
- ticketResolution?: "true-unbound";
1196
1217
  } {
1197
1218
  return {
1198
1219
  ...(fields.correlationId === undefined ? {} : { correlationId: fields.correlationId }),
1199
1220
  ...(fields.ticketNumber === undefined ? {} : { ticketNumber: fields.ticketNumber }),
1200
- ...(fields.ticketResolution === undefined
1201
- ? {}
1202
- : { ticketResolution: fields.ticketResolution }),
1203
1221
  };
1204
1222
  }
1205
1223
 
@@ -1249,7 +1267,6 @@ async function loadResumableRunRecord(
1249
1267
  let derived: DerivedMergerEnvelope | undefined;
1250
1268
  let correlationId: string | undefined;
1251
1269
  let ticketNumber: number | undefined;
1252
- let ticketResolution: "true-unbound" | undefined;
1253
1270
  let prNumber: number | undefined;
1254
1271
  let repository: string | undefined;
1255
1272
  let repositoryDisplay: string | undefined;
@@ -1409,7 +1426,6 @@ async function loadResumableRunRecord(
1409
1426
  const fromAdmitted = parsePersistedTicketIdentity(record);
1410
1427
  correlationId = fromAdmitted.correlationId;
1411
1428
  ticketNumber = fromAdmitted.ticketNumber;
1412
- ticketResolution = fromAdmitted.ticketResolution;
1413
1429
  }
1414
1430
  } catch (error) {
1415
1431
  // Preserve unique --authority-ref grammar failures; do not collapse to unreadable.
@@ -1439,17 +1455,10 @@ async function loadResumableRunRecord(
1439
1455
  : {}),
1440
1456
  };
1441
1457
  }
1442
- if (
1443
- correlationId === undefined ||
1444
- ticketNumber === undefined ||
1445
- ticketResolution === undefined
1446
- ) {
1458
+ if (correlationId === undefined || ticketNumber === undefined) {
1447
1459
  const fromInvocation = parsePersistedTicketIdentity(rec);
1448
1460
  if (correlationId === undefined) correlationId = fromInvocation.correlationId;
1449
1461
  if (ticketNumber === undefined) ticketNumber = fromInvocation.ticketNumber;
1450
- if (ticketResolution === undefined) {
1451
- ticketResolution = fromInvocation.ticketResolution;
1452
- }
1453
1462
  }
1454
1463
  }
1455
1464
  } catch (error) {
@@ -1485,7 +1494,6 @@ async function loadResumableRunRecord(
1485
1494
  ...(derived === undefined ? {} : { derived }),
1486
1495
  ...(correlationId === undefined ? {} : { correlationId }),
1487
1496
  ...(ticketNumber === undefined ? {} : { ticketNumber }),
1488
- ...(ticketResolution === undefined ? {} : { ticketResolution }),
1489
1497
  ...(prNumber === undefined ? {} : { prNumber }),
1490
1498
  ...(repository === undefined ? {} : { repository }),
1491
1499
  ...(repositoryDisplay === undefined ? {} : { repositoryDisplay }),
@@ -1,151 +1,155 @@
1
1
  /**
2
- * Shared LLM ticket binding for public court seats (#635).
3
- * One path: unbound admission → diarist instruction assertion → bindAdmittedTicketNumber.
2
+ * Shared ticket identity seam for public court seats (#635 / #637 / #709 / #747).
3
+ * One path: unbound admission → reuse a ticket number this book already records
4
+ * (retained run pages + 起居录 volumes) → bindAdmittedTicketNumber.
5
+ * ADR 0081 `reuse-case-ticket-without-extra-llm`: no seat-side model call, no
6
+ * second ticket-number source of truth, no minting a number from human titles.
7
+ * Nothing matched is not 真无票 — the run simply stays unbound, which is lawful.
4
8
  * No CLI --ticket and no attachment frontmatter binding.
5
- * #637: same-ticket prior-run lookup → resume decision also lives here (shared seam).
9
+ * #747: officer same-parent resume also lives here (shared seam).
6
10
  */
7
- import { resolveBookKeyFromGit } from "../activation-ledger-git.ts";
11
+ import { existsSync } from "node:fs";
12
+
8
13
  import {
9
- createGhTicketExistenceChecker,
10
- createHermesDiaristTicketResolver,
11
- DiaristTicketResolutionError,
12
- resolveDiaristTicketFromInstruction,
13
- type DiaristTicketResolution,
14
- } from "../diarist-ticket-resolution.ts";
15
- import { resolveDiaristGithubOrigin } from "../diarist.ts";
14
+ ActivationGitRepositoryRequiredError,
15
+ resolveBookKeyFromGit,
16
+ } from "../activation-ledger-git.ts";
17
+ import { resolveTicketProvenanceVolume } from "../ticket-provenance.ts";
16
18
  import {
17
19
  bindAdmittedTicketNumber,
18
- recordTrueUnboundTicketResolution,
19
20
  type AdmittedRoleInvocation,
20
21
  } from "./invocation.ts";
21
22
  import {
23
+ collectBookRunTicketNumbers,
22
24
  findLatestRunIdForSeatTicket,
23
25
  type RoleRunRecord,
24
26
  type SameTicketSummonsMaterials,
25
27
  } from "./run-lifecycle.ts";
26
28
 
27
29
  export type SeatTicketBindingEnv = {
28
- readonly packageRoot?: string;
30
+ readonly home: string;
29
31
  };
30
32
 
31
33
  /**
32
- * Pre-admit instruction ticket probe (#635 / #637).
33
- * Success carries the resolution for same-ticket resume + post-admit bind.
34
- * DiaristTicketResolutionError is captured (not thrown) so the seat can still
35
- * admit and settle the failure inside the post-admission controlled path —
36
- * bare throw before admit would skip terminal settlement (失败诚实, no wash).
37
- */
38
- export type InstructionTicketProbe =
39
- | { readonly kind: "resolved"; readonly resolution: DiaristTicketResolution }
40
- | { readonly kind: "failed"; readonly error: DiaristTicketResolutionError };
41
-
42
- /**
43
- * Instruction → ticket resolution without admission (#635 / #637).
44
- * Used to locate a prior same-ticket run before minting a new one.
45
- * Throws on resolution failure — prefer probeInstructionTicket at seat entry.
34
+ * Complete decimal ticket tokens in an instruction.
35
+ * A digit run is one token: `82` inside `#582` is not 82, and a leading zero
36
+ * makes the run a different literal than the ticket number it would parse to.
46
37
  */
47
- export async function resolveInstructionTicket(
38
+ export function instructionTicketTokens(
48
39
  instruction: string,
49
- projectRoot: string,
50
- env: SeatTicketBindingEnv = {},
51
- ): Promise<DiaristTicketResolution> {
52
- const resolver = createHermesDiaristTicketResolver({
53
- ...(env.packageRoot === undefined ? {} : { packageRoot: env.packageRoot }),
54
- cwd: projectRoot,
55
- });
56
- const checkExistence = createGhTicketExistenceChecker();
57
- const origin = resolveDiaristGithubOrigin(projectRoot);
58
- return resolveDiaristTicketFromInstruction({
59
- instruction,
60
- origin,
61
- resolver,
62
- checkExistence,
63
- });
40
+ ): readonly number[] {
41
+ const tokens = new Set<number>();
42
+ for (const run of instruction.match(/\d+/g) ?? []) {
43
+ if (run.startsWith("0")) continue;
44
+ const parsed = Number(run);
45
+ if (!Number.isSafeInteger(parsed) || parsed < 1) continue;
46
+ tokens.add(parsed);
47
+ }
48
+ return [...tokens];
64
49
  }
65
50
 
66
51
  /**
67
- * Pre-admit probe: resolution success or captured DiaristTicketResolutionError.
68
- * Other errors still propagate (launch/infrastructure outside this class).
52
+ * Ticket identity this summons reuses, or undefined when none is unambiguous.
53
+ * Known identities come only from records this book already holds: 起居录 volume
54
+ * partitions and ticket numbers on retained run pages. Zero or several known
55
+ * tokens in the instruction leave the run unbound — never guess, never fail.
69
56
  */
70
- export async function probeInstructionTicket(
71
- instruction: string,
72
- projectRoot: string,
73
- env: SeatTicketBindingEnv = {},
74
- ): Promise<InstructionTicketProbe> {
75
- try {
76
- const resolution = await resolveInstructionTicket(
77
- instruction,
78
- projectRoot,
79
- env,
57
+ export async function resolveKnownTicketNumber(input: {
58
+ readonly instruction: string;
59
+ readonly projectRoot: string;
60
+ readonly home: string;
61
+ readonly bookKey?: string;
62
+ }): Promise<number | undefined> {
63
+ const tokens = instructionTicketTokens(input.instruction);
64
+ if (tokens.length === 0) return undefined;
65
+ const known: number[] = [];
66
+ let runTickets: ReadonlySet<number> | undefined;
67
+ for (const token of tokens) {
68
+ const volume = resolveTicketProvenanceVolume(
69
+ token,
70
+ input.projectRoot,
71
+ input.home,
80
72
  );
81
- return { kind: "resolved", resolution };
82
- } catch (error) {
83
- if (error instanceof DiaristTicketResolutionError) {
84
- return { kind: "failed", error };
73
+ if (existsSync(volume.volumeDir)) {
74
+ known.push(token);
75
+ continue;
85
76
  }
86
- throw error;
77
+ if (runTickets === undefined) {
78
+ runTickets = await readBookRunTickets(input);
79
+ }
80
+ if (runTickets.has(token)) known.push(token);
87
81
  }
82
+ return known.length === 1 ? known[0] : undefined;
88
83
  }
89
84
 
90
- /** Ticket number from a successful ticket probe; undefined for unbound/failed. */
91
- export function ticketNumberFromProbe(
92
- probe: InstructionTicketProbe,
93
- ): number | undefined {
94
- if (probe.kind !== "resolved") return undefined;
95
- if (probe.resolution.kind !== "ticket") return undefined;
96
- return probe.resolution.ticketNumber;
85
+ /**
86
+ * Diarist first-summons identity (ADR 0081 `initial-court-ticket-supplied`).
87
+ * Prefer a book-known reuse; when the book has no record yet, the sole complete
88
+ * decimal token in the caller's dispatch is the supplied ticket — the working
89
+ * 起居郎 round establishes the volume. Several tokens or none stay unbound.
90
+ * Not a second ticket-number source of truth and not an extra recognizer call.
91
+ */
92
+ export async function resolveDiaristSummonsTicketNumber(input: {
93
+ readonly instruction: string;
94
+ readonly projectRoot: string;
95
+ readonly home: string;
96
+ readonly bookKey?: string;
97
+ }): Promise<number | undefined> {
98
+ const known = await resolveKnownTicketNumber(input);
99
+ if (known !== undefined) return known;
100
+ const tokens = instructionTicketTokens(input.instruction);
101
+ return tokens.length === 1 ? tokens[0] : undefined;
97
102
  }
98
103
 
99
104
  /**
100
- * Sole disposition of a resolved ticket onto an unbound admission (#635 / #637).
101
- * Countersign / inspector / resolveSeatTicketBinding share this — no parallel branches.
105
+ * Ticket numbers on this book's retained runs.
106
+ * A directory with no book (not a git repository) simply holds no run history —
107
+ * admission owns that rejection face, this lookup does not pre-empt it.
102
108
  */
103
- export async function applyTicketResolution(
104
- admitted: AdmittedRoleInvocation,
105
- resolution: DiaristTicketResolution,
106
- ): Promise<void> {
107
- if (resolution.kind === "ticket") {
108
- await bindAdmittedTicketNumber(admitted, resolution.ticketNumber);
109
- } else {
110
- await recordTrueUnboundTicketResolution(admitted);
109
+ async function readBookRunTickets(input: {
110
+ readonly projectRoot: string;
111
+ readonly home: string;
112
+ readonly bookKey?: string;
113
+ }): Promise<ReadonlySet<number>> {
114
+ let bookKey: string;
115
+ try {
116
+ bookKey = input.bookKey ?? resolveBookKeyFromGit(input.projectRoot);
117
+ } catch (error) {
118
+ if (error instanceof ActivationGitRepositoryRequiredError) return new Set();
119
+ throw error;
111
120
  }
121
+ return await collectBookRunTicketNumbers({ home: input.home, bookKey });
112
122
  }
113
123
 
114
- /**
115
- * Apply a pre-admit probe onto an unbound admission.
116
- * Failed probes rethrow — caller must be inside the controlled-failure boundary
117
- * (post-admission beforeDispatch), never before admit/markRunning.
118
- */
119
- export async function applyInstructionTicketProbe(
124
+ /** Bind a reused ticket number onto an admission that is still unbound. */
125
+ export async function bindReusedTicketNumber(
120
126
  admitted: AdmittedRoleInvocation,
121
- probe: InstructionTicketProbe,
127
+ ticketNumber: number | undefined,
122
128
  ): Promise<void> {
123
- if (probe.kind === "failed") throw probe.error;
124
- await applyTicketResolution(admitted, probe.resolution);
129
+ if (ticketNumber === undefined) return;
130
+ if (admitted.ticketNumber !== undefined) return;
131
+ await bindAdmittedTicketNumber(admitted, ticketNumber);
125
132
  }
126
133
 
127
134
  /**
128
- * Bind ticketNumber onto an unbound admitted seat via LLM instruction recognition.
129
- * Already-settled admissions (ticketNumber or durable true-unbound) short-circuit —
130
- * no re-entry into hermes on auto-resume attempts or manual resume (#635).
131
- * true-unbound is persisted once; failed verification throws.
135
+ * Sole seat disposition for an unbound admission (#635 / #709).
136
+ * Already-bound admissions short-circuit — resume keeps the identity it has.
137
+ * Used by seats that do not pre-resolve for same-ticket resume; the seats that
138
+ * do (countersign / inspector / diarist) reuse the number they already resolved.
132
139
  */
133
140
  export async function resolveSeatTicketBinding(
134
141
  admitted: AdmittedRoleInvocation,
135
- env: SeatTicketBindingEnv = {},
136
- ): Promise<DiaristTicketResolution | undefined> {
137
- if (admitted.ticketNumber !== undefined) return undefined;
138
- if (admitted.ticketResolution === "true-unbound") {
139
- return { kind: "true-unbound" };
140
- }
141
-
142
- const resolution = await resolveInstructionTicket(
143
- admitted.instruction,
144
- admitted.projectRoot,
145
- env,
146
- );
147
- await applyTicketResolution(admitted, resolution);
148
- return resolution;
142
+ env: SeatTicketBindingEnv,
143
+ ): Promise<number | undefined> {
144
+ if (admitted.ticketNumber !== undefined) return admitted.ticketNumber;
145
+ const ticketNumber = await resolveKnownTicketNumber({
146
+ instruction: admitted.instruction,
147
+ projectRoot: admitted.projectRoot,
148
+ home: env.home,
149
+ bookKey: admitted.bookKey,
150
+ });
151
+ await bindReusedTicketNumber(admitted, ticketNumber);
152
+ return ticketNumber;
149
153
  }
150
154
 
151
155
  /**