@akagilnc/pi-workflow-roles 0.1.3673 → 0.1.3685

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 (42) hide show
  1. package/dist/acp-host/production-host.js +297 -166
  2. package/dist/audit-escalation.js +4 -3
  3. package/dist/diarist-contracts.js +4 -4
  4. package/dist/diarist-role.js +10 -4
  5. package/dist/diarist.js +121 -17
  6. package/dist/doctor-role.js +2 -1
  7. package/dist/gatekeeper-role.js +3 -2
  8. package/dist/judge-role.js +2 -1
  9. package/dist/package-contracts/terminating-tools.js +1 -1
  10. package/dist/public-cli/inspector-run.js +3 -13
  11. package/dist/public-cli/instruction-seat-run.js +6 -31
  12. package/dist/public-cli/invocation.js +32 -0
  13. package/dist/public-cli/judge-run.js +1 -7
  14. package/dist/public-cli/main.js +714 -728
  15. package/dist/public-cli/run-lifecycle.js +0 -28
  16. package/dist/public-cli/seat-ticket-binding.js +15 -104
  17. package/dist/readable-gate-item.js +16 -0
  18. package/dist/role-runtime.js +89 -10
  19. package/dist/submission-errors.js +4 -1
  20. package/package.json +1 -1
  21. package/resources/diarist-collect.md +10 -0
  22. package/src/audit-escalation.ts +4 -3
  23. package/src/diarist-contracts.ts +21 -9
  24. package/src/diarist-role.ts +15 -4
  25. package/src/diarist.ts +192 -22
  26. package/src/doctor-role.ts +2 -1
  27. package/src/gatekeeper-role.ts +3 -2
  28. package/src/judge-role.ts +2 -1
  29. package/src/package-contracts/terminating-tools.ts +1 -1
  30. package/src/public-cli/coder-run.ts +1 -7
  31. package/src/public-cli/countersign-run.ts +238 -86
  32. package/src/public-cli/diarist-run.ts +83 -28
  33. package/src/public-cli/fixer-run.ts +1 -7
  34. package/src/public-cli/inspector-run.ts +3 -17
  35. package/src/public-cli/instruction-seat-run.ts +6 -36
  36. package/src/public-cli/invocation.ts +52 -0
  37. package/src/public-cli/judge-run.ts +1 -7
  38. package/src/public-cli/run-lifecycle.ts +0 -31
  39. package/src/public-cli/seat-ticket-binding.ts +14 -138
  40. package/src/readable-gate-item.ts +20 -0
  41. package/src/role-runtime.ts +107 -9
  42. package/src/submission-errors.ts +4 -1
@@ -1,34 +1,41 @@
1
1
  /**
2
2
  * Public Countersign Role run: admit ticket materials → court-pipeline prior
3
3
  * station (起居郎) → shared post-admission coordinator → settle Terminal result
4
- * (#572 / ADR 0074 / ADR 0075 / #742). #599: manual resume continues the exact
5
- * session. Unbound admission reuses a known ticket identity (#709) before the
6
- * diary station.
4
+ * (#572 / ADR 0074 / ADR 0075 / #742 / #771). #599: manual resume continues the
5
+ * exact session. ADR 0079: same-ticket re-summons resume the seat's previous run
6
+ * (`ticket-seat-memory-countersign-principal`); explicit `ak-role new` mints fresh
7
+ * (`explicit-fresh-summons`).
7
8
  *
8
- * Court admission auto-runs 起居郎 first, then the countersign body — caller
9
- * adds no diarist argv (L66672). This is the admission pipeline's prior station,
10
- * not the countersign role calling 起居郎 (L66958 / L66966). Who may call 起居郎
11
- * and in what order is not written into law (ADR 0075 `no-call-rule`); the
12
- * present admission effect is what this seat currently does (L108315 目前是这样用的).
13
- * 起居录 path delivery is owned once by post-admission (#709 / ADR 0081).
9
+ * Court admission auto-runs 起居郎 so the 起居郎 LLM asserts the court target;
10
+ * mechanical layer only verifies; countersign reuses that typed identity for bind
11
+ * and same-ticket resume lookup (ADR 0075 / 0081 / 0079). Code never matches
12
+ * instruction text against book-known numbers. Who may call 起居郎 and in what
13
+ * order is not written into law (ADR 0075 `no-call-rule`); the present admission
14
+ * effect is what this seat currently does. 起居录 path delivery is owned once by
15
+ * post-admission (#709 / ADR 0081).
16
+ *
17
+ * Wiring (#771): admit first so the countersign run exists; resolve typed identity
18
+ * from 起居郎 after admit; same-ticket resume uses that typed key (abandon the
19
+ * unused mint when resuming). 起居郎 escalate (认不出) and typed failure terminals
20
+ * (incl. verification failure) settle as countersign controlled failure — never
21
+ * wash into 真无票. Only a true missing lawful typed terminal stays unbound and
22
+ * continues the body (r5 unbound-continue). Bound refresh hands the typed key to
23
+ * 起居郎 so freeze loads issue face (`refresh-every-court` / typed handoff).
14
24
  */
15
25
  import type { DurablePrincipalAuthority, RoleTurnRequest } from "../host-contracts.ts";
16
26
  import { engineSessionMaterialFromOptions } from "../package-resources/engine-material.ts";
17
27
  import { CliUsageError } from "./cli-errors.ts";
18
28
  import {
19
29
  admitCountersignInvocation,
30
+ bindAdmittedTicketNumber,
20
31
  buildCountersignTransportPrompt,
21
32
  parseDiaristArgv,
22
33
  type AdmittedCountersignInvocation,
23
34
  type ParseCountersignArgvResult,
24
35
  } from "./invocation.ts";
25
- import {
26
- bindReusedTicketNumber,
27
- resolveKnownTicketNumber,
28
- tryResumeSameTicketSeatRun,
29
- } from "./seat-ticket-binding.ts";
30
36
  import {
31
37
  prepareSummonsResumeMaterials,
38
+ presentControlledFailure,
32
39
  runPostAdmissionOneShot,
33
40
  type PostAdmissionEnv,
34
41
  runPostAdmissionSeatResume,
@@ -37,9 +44,11 @@ import {
37
44
  import {
38
45
  loadResumableCountersignRun,
39
46
  markRunAdmitted,
47
+ markRunTerminal,
40
48
  type PublicResumeRequest,
41
49
  type SameTicketSummonsMaterials,
42
50
  } from "./run-lifecycle.ts";
51
+ import { tryResumeSameTicketSeatRun } from "./seat-ticket-binding.ts";
43
52
  import {
44
53
  presentStructuralRejection,
45
54
  trySettleCountersignTerminalResult,
@@ -83,28 +92,31 @@ export function buildCountersignTurnRequest(
83
92
  );
84
93
  }
85
94
 
95
+ /** 起居郎 identity outcome — escalate stays distinct from missing terminal. */
96
+ type CourtDiaristIdentity =
97
+ | { readonly kind: "ticket"; readonly ticketNumber: number }
98
+ | { readonly kind: "unbound" }
99
+ | { readonly kind: "escalate"; readonly reason: string };
100
+
86
101
  /**
87
- * Court-pipeline prior station: refresh this ticket's 起居录 before the
88
- * countersign body turn (ADR 0075 `refresh-every-court`; #742 restore).
89
- * Caller-invisible — no diarist argv on the countersign command line.
90
- * Missing ticketNumber (true-unbound) skips the station — no diary is minted
91
- * for a true-unbound run.
92
- *
93
- * Invokes the public 起居郎 seat so the book carries an `@diarist` run ahead of
94
- * the countersign body. Station failure propagates (失败诚实 — no wash).
95
- * Path delivery onto materials is not this station's job — post-admission owns it.
102
+ * Invoke public 起居郎 under the court-pipeline quiet face.
103
+ * Returns typed identity: ticket assertion, true-unbound, or escalate.
104
+ * Non-zero exit without escalate status is not rethrown here — caller decides
105
+ * whether refresh must fail or first-entry settles controlled failure.
106
+ * When `boundTicketNumber` is set (typed handoff from countersign), diarist
107
+ * freezes under that key so issue face enters the catalog — never mechanical
108
+ * recognition from prose.
96
109
  */
97
- export async function runCountersignCourtDiaristStation(
98
- admitted: AdmittedCountersignInvocation,
99
- env: CountersignRunEnv,
100
- io: CliIo,
101
- ): Promise<void> {
102
- if (admitted.ticketNumber === undefined) return;
103
- if (env.runCourtDiaristStation !== undefined) {
104
- await env.runCourtDiaristStation(admitted);
105
- return;
106
- }
107
-
110
+ async function invokeCourtDiarist(input: {
111
+ readonly instruction: string;
112
+ readonly projectRoot: string;
113
+ readonly failureLabel: string;
114
+ /** Already-verified typed key from countersign (refresh / post-assert handoff). */
115
+ readonly boundTicketNumber?: number;
116
+ }, env: CountersignRunEnv, io: CliIo): Promise<{
117
+ readonly identity: CourtDiaristIdentity;
118
+ readonly failedWithoutEscalate?: { readonly diagnostic: string };
119
+ }> {
108
120
  // Quiet face: the countersign caller must not see diarist CLI chatter.
109
121
  const quietIo: CliIo = {
110
122
  stdout() {},
@@ -116,11 +128,7 @@ export async function runCountersignCourtDiaristStation(
116
128
 
117
129
  const { runPublicDiarist } = await import("./diarist-run.ts");
118
130
  const result = await runPublicDiarist(
119
- [
120
- "--project",
121
- admitted.projectRoot,
122
- `整理 #${admitted.ticketNumber} 的本案依据。`,
123
- ],
131
+ ["--project", input.projectRoot, input.instruction],
124
132
  {
125
133
  home: env.home,
126
134
  agentDir: env.agentDir,
@@ -136,20 +144,95 @@ export async function runCountersignCourtDiaristStation(
136
144
  ...(env.credentials === undefined ? {} : { credentials: env.credentials }),
137
145
  ...(env.correlationId === undefined ? {} : { correlationId: env.correlationId }),
138
146
  ...(env.signal === undefined ? {} : { signal: env.signal }),
147
+ // Typed handoff only — never derived from summons prose.
148
+ ...(input.boundTicketNumber === undefined
149
+ ? {}
150
+ : { boundTicketNumber: input.boundTicketNumber }),
139
151
  },
140
152
  quietIo,
141
153
  parseDiaristArgv,
142
154
  );
143
155
 
156
+ const roleOutcome = result.terminal?.roleOutcome;
157
+ // Discriminated union: only non-failure arms carry status (failure uses cause).
158
+ if (roleOutcome !== undefined && roleOutcome.kind !== "failure") {
159
+ if (roleOutcome.status === "escalate") {
160
+ const reason =
161
+ typeof roleOutcome.decisiveFacts.reason === "string"
162
+ ? roleOutcome.decisiveFacts.reason
163
+ : "diarist escalated without reason";
164
+ return { identity: { kind: "escalate", reason } };
165
+ }
166
+ }
167
+
144
168
  if (result.exitCode !== 0) {
145
- const detail =
146
- result.terminal?.roleOutcome.kind === "failure"
147
- ? ` (roleOutcome=failure)`
148
- : "";
169
+ const diagnostic =
170
+ roleOutcome?.kind === "failure"
171
+ ? roleOutcome.diagnostic
172
+ : `exit ${result.exitCode}`;
173
+ return {
174
+ identity: { kind: "unbound" },
175
+ failedWithoutEscalate: {
176
+ diagnostic: `court diarist station failed for ${input.failureLabel}: ${diagnostic}`,
177
+ },
178
+ };
179
+ }
180
+
181
+ const asserted = result.admitted?.ticketNumber;
182
+ if (
183
+ typeof asserted === "number" &&
184
+ Number.isSafeInteger(asserted) &&
185
+ asserted >= 1
186
+ ) {
187
+ return { identity: { kind: "ticket", ticketNumber: asserted } };
188
+ }
189
+ return { identity: { kind: "unbound" } };
190
+ }
191
+
192
+ /**
193
+ * Court-pipeline prior station: refresh this ticket's 起居录 before the
194
+ * countersign body turn when already bound (ADR 0075 `refresh-every-court`).
195
+ * Caller-invisible — no diarist argv on the countersign command line.
196
+ *
197
+ * Missing ticketNumber (true-unbound / identity deferred) skips the refresh
198
+ * station — no diary is minted for a true-unbound run. First-entry identity
199
+ * lives on `runPublicCountersign` (typed 起居郎 key for bind + same-ticket
200
+ * resume). Bound refresh: 起居郎 failure propagates (失败诚实).
201
+ * Path delivery onto materials is not this station's job — post-admission owns it.
202
+ */
203
+ export async function runCountersignCourtDiaristStation(
204
+ admitted: AdmittedCountersignInvocation,
205
+ env: CountersignRunEnv,
206
+ io: CliIo,
207
+ ): Promise<void> {
208
+ if (env.runCourtDiaristStation !== undefined) {
209
+ await env.runCourtDiaristStation(admitted);
210
+ return;
211
+ }
212
+ // Production refresh-every-court only under a known ticket identity.
213
+ // First-entry unbound identity is owned by runPublicCountersign.
214
+ if (admitted.ticketNumber === undefined) return;
215
+
216
+ const outcome = await invokeCourtDiarist(
217
+ {
218
+ instruction: `整理 #${admitted.ticketNumber} 的本案依据。`,
219
+ projectRoot: admitted.projectRoot,
220
+ failureLabel: `ticket #${admitted.ticketNumber}`,
221
+ // Refresh holds a typed key — hand it off so freeze loads issue face.
222
+ boundTicketNumber: admitted.ticketNumber,
223
+ },
224
+ env,
225
+ io,
226
+ );
227
+
228
+ if (outcome.identity.kind === "escalate") {
149
229
  throw new Error(
150
- `court diarist station failed for ticket #${admitted.ticketNumber}: exit ${result.exitCode}${detail}`,
230
+ `court diarist station escalated (cannot identify court target): ${outcome.identity.reason}`,
151
231
  );
152
232
  }
233
+ if (outcome.failedWithoutEscalate !== undefined) {
234
+ throw new Error(outcome.failedWithoutEscalate.diagnostic);
235
+ }
153
236
  }
154
237
 
155
238
  export async function runPublicCountersign(
@@ -173,38 +256,6 @@ export async function runPublicCountersign(
173
256
  throw error;
174
257
  }
175
258
 
176
- // #637: same ticket → resume prior countersign run with this summons' materials.
177
- // #709: identity is reused from records this book already holds — no seat model call.
178
- // No bare catch→fresh: lookup/resume failures surface; only true absence mints new.
179
- const projectRoot = parsed.project ?? env.cwd;
180
- const reusedTicketNumber = await resolveKnownTicketNumber({
181
- instruction: parsed.instruction,
182
- projectRoot,
183
- home: env.home,
184
- });
185
- if (reusedTicketNumber !== undefined) {
186
- const summons: SameTicketSummonsMaterials = {
187
- instruction: parsed.instruction,
188
- instructionEmpty: parsed.instruction.trim() === "",
189
- attachmentPaths: parsed.attachmentPaths,
190
- };
191
- const resumed = await tryResumeSameTicketSeatRun({
192
- home: env.home,
193
- projectRoot,
194
- role: "countersign",
195
- ticketNumber: reusedTicketNumber,
196
- freshSummons: env.freshSummons,
197
- summons,
198
- resume: (runId, materials) =>
199
- runPublicCountersignResume(
200
- { runId, ...(materials === undefined ? {} : { summons: materials }) },
201
- env,
202
- io,
203
- ),
204
- });
205
- if (resumed !== undefined) return resumed;
206
- }
207
-
208
259
  let admitted: AdmittedCountersignInvocation;
209
260
  try {
210
261
  admitted = await admitCountersignInvocation({
@@ -228,6 +279,101 @@ export async function runPublicCountersign(
228
279
 
229
280
  await markRunAdmitted(admitted, env.principalAuthority);
230
281
 
282
+ // #637 / #771 / ADR 0079: ticket identity is the 起居郎 LLM typed assertion
283
+ // (never mechanical matching of summons text). Resolve that typed key after
284
+ // admit so the countersign run page exists first; same-ticket re-summons
285
+ // resume the prior run on the typed key (unused mint is abandoned). The test
286
+ // seam `runCourtDiaristStation` defers identity to beforeDispatch and
287
+ // therefore skips auto-resume under the seam alone.
288
+ let typedTicket: number | undefined;
289
+ let identityDiaristRan = false;
290
+
291
+ if (env.runCourtDiaristStation === undefined) {
292
+ const outcome = await invokeCourtDiarist(
293
+ {
294
+ instruction: parsed.instruction,
295
+ projectRoot: admitted.projectRoot,
296
+ failureLabel: "unbound summons",
297
+ },
298
+ env,
299
+ io,
300
+ );
301
+ identityDiaristRan = true;
302
+
303
+ if (outcome.identity.kind === "escalate") {
304
+ // 御批: 识别不了就上抛 — settle on the admitted countersign run.
305
+ return await presentControlledFailure(
306
+ admitted,
307
+ {
308
+ timedOut: false,
309
+ code: null,
310
+ stderr: "",
311
+ thrown: new Error(
312
+ `court diarist station escalated (cannot identify court target): ${outcome.identity.reason}`,
313
+ ),
314
+ },
315
+ countersignAdapters(),
316
+ env.principalAuthority,
317
+ io,
318
+ );
319
+ }
320
+
321
+ // Typed failure terminal (verification / infra / non-zero without escalate)
322
+ // is not 真无票 — settle controlled failure on the admitted run (失败诚实).
323
+ // Only a true missing lawful typed terminal keeps the r5 unbound-continue.
324
+ if (outcome.failedWithoutEscalate !== undefined) {
325
+ return await presentControlledFailure(
326
+ admitted,
327
+ {
328
+ timedOut: false,
329
+ code: null,
330
+ stderr: "",
331
+ thrown: new Error(outcome.failedWithoutEscalate.diagnostic),
332
+ },
333
+ countersignAdapters(),
334
+ env.principalAuthority,
335
+ io,
336
+ );
337
+ }
338
+
339
+ // Missing lawful 起居郎 terminal is not countersign body failure: leave
340
+ // unbound and continue (true-unbound face). Escalate / typed failure above;
341
+ // bound refresh still fails honest via runCountersignCourtDiaristStation.
342
+ if (outcome.identity.kind === "ticket") {
343
+ typedTicket = outcome.identity.ticketNumber;
344
+ const summons: SameTicketSummonsMaterials = {
345
+ instruction: parsed.instruction,
346
+ instructionEmpty: parsed.instruction.trim() === "",
347
+ attachmentPaths: parsed.attachmentPaths,
348
+ };
349
+ const resumed = await tryResumeSameTicketSeatRun({
350
+ home: env.home,
351
+ projectRoot: admitted.projectRoot,
352
+ role: "countersign",
353
+ ticketNumber: typedTicket,
354
+ freshSummons: env.freshSummons,
355
+ summons,
356
+ resume: async (runId, materials) => {
357
+ // Resume selected: abandon mint first so a throw cannot leave it admitted.
358
+ await markRunTerminal(admitted.runDirectory);
359
+ // Identity 起居郎 asserted unbound (no issue face). Resume still runs
360
+ // the bound refresh station under the typed key (refresh-every-court).
361
+ return await runPublicCountersignResume(
362
+ {
363
+ runId,
364
+ ...(materials === undefined ? {} : { summons: materials }),
365
+ },
366
+ env,
367
+ io,
368
+ );
369
+ },
370
+ });
371
+ if (resumed !== undefined) {
372
+ return resumed;
373
+ }
374
+ }
375
+ }
376
+
231
377
  const turnProjection: RoleTurnRequestProjectionOptions = {
232
378
  packageRoot: env.packageRoot,
233
379
  home: env.home,
@@ -259,15 +405,20 @@ export async function runPublicCountersign(
259
405
  request: turnRequest,
260
406
  adapters: countersignAdapters({
261
407
  beforeDispatch: async (admittedSeat) => {
262
- // #635/#709: bind the reused identity inside the controlled-failure boundary.
263
- await bindReusedTicketNumber(admittedSeat, reusedTicketNumber);
408
+ // Dossier pointer delivery rides post-admission after this hook (#709).
409
+ if (!identityDiaristRan) {
410
+ // Test seam (or any deferred identity): station owns assert + bind.
411
+ await runCountersignCourtDiaristStation(admittedSeat, env, io);
412
+ } else if (typedTicket !== undefined) {
413
+ // Production identity asserted unbound; bind typed key, then bound
414
+ // refresh so freeze loads issue face (typed handoff, not prose match).
415
+ await bindAdmittedTicketNumber(admittedSeat, typedTicket);
416
+ await runCountersignCourtDiaristStation(admittedSeat, env, io);
417
+ }
264
418
  Object.assign(
265
419
  turnRequest,
266
420
  buildCountersignTurnRequest(admittedSeat, turnProjection),
267
421
  );
268
- // Court station after bind so the diarist round sees the ticket.
269
- // Dossier pointer delivery rides post-admission after this hook (#709).
270
- await runCountersignCourtDiaristStation(admittedSeat, env, io);
271
422
  },
272
423
  }),
273
424
  ...(env.engine === undefined ? {} : { effectiveEngine: env.engine }),
@@ -295,11 +446,12 @@ function countersignAdapters(options?: {
295
446
 
296
447
  /**
297
448
  * Resume a previously admitted Countersign run (#599 / DK-3 / #637).
298
- * Restores role/ticket/session identity. Every court re-entry runs the diarist
299
- * station first (ADR 0075 `refresh-every-court`). Same-ticket summons deliver this
300
- * turn's instruction + frozen attachments on the resume prompt; manual resume
301
- * keeps package-envelope / caller-message semantics and birth attachments.
302
- * 起居录 path delivery remains post-admission's single mount (#709).
449
+ * Restores role/ticket/session identity. Bound court re-entry runs the diarist
450
+ * refresh station first (ADR 0075 `refresh-every-court`); unbound skips refresh.
451
+ * Same-ticket summons deliver this turn's instruction + frozen attachments on
452
+ * the resume prompt; manual resume keeps package-envelope / caller-message
453
+ * semantics and birth attachments. 起居录 path delivery remains post-admission's
454
+ * single mount (#709).
303
455
  */
304
456
  export async function runPublicCountersignResume(
305
457
  request: PublicResumeRequest,
@@ -19,15 +19,11 @@ import { engineSessionMaterialFromOptions } from "../package-resources/engine-ma
19
19
  import { CliUsageError } from "./cli-errors.ts";
20
20
  import {
21
21
  admitDiaristInvocation,
22
+ bindAdmittedTicketNumber,
22
23
  buildInstructionTransportPrompt,
23
24
  type AdmittedDiaristInvocation,
24
25
  type ParseDiaristArgvResult,
25
26
  } from "./invocation.ts";
26
- import {
27
- bindReusedTicketNumber,
28
- resolveDiaristSummonsTicketNumber,
29
- tryResumeSameTicketSeatRun,
30
- } from "./seat-ticket-binding.ts";
31
27
  import {
32
28
  prepareSummonsResumeMaterials,
33
29
  runPostAdmissionOneShot,
@@ -43,6 +39,7 @@ import {
43
39
  type PublicResumeRequest,
44
40
  type SameTicketSummonsMaterials,
45
41
  } from "./run-lifecycle.ts";
42
+ import { tryResumeSameTicketSeatRun } from "./seat-ticket-binding.ts";
46
43
  import {
47
44
  presentStructuralRejection,
48
45
  trySettleDiaristTerminalResult,
@@ -57,6 +54,13 @@ import {
57
54
  export type DiaristRunEnv = PostAdmissionEnv & {
58
55
  principalAuthority: DurablePrincipalAuthority;
59
56
  createRunId?: () => string;
57
+ /**
58
+ * Typed handoff from a caller that already holds a verified ticket key
59
+ * (countersign refresh / post-assert). Never derived from summons prose.
60
+ * When set: same-ticket resume under that key, else bind-before-freeze so
61
+ * issue face enters the catalog (ADR 0075 / 0081).
62
+ */
63
+ boundTicketNumber?: number;
60
64
  };
61
65
 
62
66
  /** Frozen catalog filename inside the run dossier (durable material, not argv). */
@@ -97,9 +101,12 @@ export function buildDiaristTurnRequest(
97
101
  }
98
102
 
99
103
  /**
100
- * Mechanical source enumeration for a bound summons: establish the per-ticket
101
- * volume, freeze this turn's candidate catalog into the run dossier.
102
- * A true-unbound summons has no ticket, so no diary is minted — undefined.
104
+ * Mechanical source enumeration into a frozen catalog for this turn.
105
+ * Bound summons: establish the per-ticket volume and load the issue face.
106
+ * First-summons (unbound): freeze session candidates + summons text so the LLM
107
+ * turn can assert ticketNumber; accept verifies and mints the volume (ADR 0075).
108
+ * True-unbound is decided by the LLM assertion (null/absent ticketNumber), not
109
+ * by skipping the freeze here.
103
110
  *
104
111
  * A run that has not settled yet (crash-resume, open-court continuation) keeps
105
112
  * the catalog its candidateIndexes were minted against — re-enumerating there
@@ -109,23 +116,30 @@ export function buildDiaristTurnRequest(
109
116
  async function freezeDiaristSourceCatalog(
110
117
  admitted: AdmittedDiaristInvocation,
111
118
  env: Pick<DiaristRunEnv, "cwd" | "home">,
112
- ): Promise<string | undefined> {
113
- if (admitted.ticketNumber === undefined) return undefined;
119
+ ): Promise<string> {
114
120
  const path = join(admitted.runDirectory, DIARIST_SOURCE_CATALOG_FILE);
115
121
  if (existsSync(path)) {
116
122
  const identity = await readRoleRunIdentity(admitted.runDirectory);
117
123
  if (identity !== undefined && identity.state !== "terminal") return path;
118
124
  }
119
- const issueFace = await loadDiaristIssueFace({
120
- ticketNumber: admitted.ticketNumber,
121
- projectRoot: admitted.projectRoot,
122
- home: env.home,
123
- });
125
+ const issueFace =
126
+ admitted.ticketNumber === undefined
127
+ ? undefined
128
+ : await loadDiaristIssueFace({
129
+ ticketNumber: admitted.ticketNumber,
130
+ projectRoot: admitted.projectRoot,
131
+ home: env.home,
132
+ });
124
133
  const catalog = await prepareDiaristSourceCatalog({
125
- ticketNumber: admitted.ticketNumber,
134
+ ...(admitted.ticketNumber === undefined
135
+ ? {}
136
+ : { ticketNumber: admitted.ticketNumber }),
137
+ instruction: admitted.instruction,
138
+ runDirectory: admitted.runDirectory,
139
+ projectRoot: admitted.projectRoot,
126
140
  cwd: admitted.projectRoot,
127
141
  home: env.home,
128
- issueFace,
142
+ ...(issueFace === undefined ? {} : { issueFace }),
129
143
  sessionCwds: [admitted.projectRoot, env.cwd],
130
144
  });
131
145
  await writeFile(path, serializeDiaristSourceCatalog(catalog), "utf8");
@@ -168,16 +182,19 @@ export async function runPublicDiarist(
168
182
  throw error;
169
183
  }
170
184
 
171
- // #637: same ticket → resume this seat's prior run with this summons' materials.
172
- // #709 / #771 / ADR 0081: first `#N` in the dispatch is the court target
173
- // (neighbors/PRs/rN later in the prose do not unbind) — no seat recognizer call.
185
+ // #637 / #771 / ADR 0075: ticket identity is the LLM's typed assertion on this
186
+ // turn (mechanical verify on accept), OR a typed handoff key already held by
187
+ // the caller (countersign refresh). Code never pre-judges the summons text
188
+ // against book-known numbers. First summons without handoff stays unbound
189
+ // until assert.
190
+
174
191
  const projectRoot = parsed.project ?? env.cwd;
175
- const reusedTicketNumber = await resolveDiaristSummonsTicketNumber({
176
- instruction: parsed.instruction,
177
- projectRoot,
178
- home: env.home,
179
- });
180
- if (reusedTicketNumber !== undefined) {
192
+ const handoffTicket = env.boundTicketNumber;
193
+ if (
194
+ typeof handoffTicket === "number" &&
195
+ Number.isSafeInteger(handoffTicket) &&
196
+ handoffTicket >= 1
197
+ ) {
181
198
  const summons: SameTicketSummonsMaterials = {
182
199
  instruction: parsed.instruction,
183
200
  instructionEmpty: parsed.instruction.trim() === "",
@@ -187,7 +204,7 @@ export async function runPublicDiarist(
187
204
  home: env.home,
188
205
  projectRoot,
189
206
  role: "diarist",
190
- ticketNumber: reusedTicketNumber,
207
+ ticketNumber: handoffTicket,
191
208
  freshSummons: env.freshSummons,
192
209
  summons,
193
210
  resume: (runId, materials) =>
@@ -223,6 +240,15 @@ export async function runPublicDiarist(
223
240
 
224
241
  await markRunAdmitted(admitted, env.principalAuthority);
225
242
 
243
+ // Typed handoff: bind before freeze so issue face enters the catalog.
244
+ if (
245
+ typeof handoffTicket === "number" &&
246
+ Number.isSafeInteger(handoffTicket) &&
247
+ handoffTicket >= 1
248
+ ) {
249
+ await bindAdmittedTicketNumber(admitted, handoffTicket);
250
+ }
251
+
226
252
  const turnProjection: RoleTurnRequestProjectionOptions = {
227
253
  packageRoot: env.packageRoot,
228
254
  home: env.home,
@@ -254,7 +280,6 @@ export async function runPublicDiarist(
254
280
  request: turnRequest,
255
281
  adapters: diaristAdapters({
256
282
  beforeDispatch: async (admittedSeat) => {
257
- await bindReusedTicketNumber(admittedSeat, reusedTicketNumber);
258
283
  const sourcesPath = await freezeDiaristSourceCatalog(admittedSeat, env);
259
284
  Object.assign(
260
285
  turnRequest,
@@ -263,6 +288,36 @@ export async function runPublicDiarist(
263
288
  },
264
289
  }),
265
290
  ...(env.engine === undefined ? {} : { effectiveEngine: env.engine }),
291
+ }).then(async (result) => {
292
+ // Accept may have bound ticket onto durable pages after LLM assertion.
293
+ // Mirror only lawful non-escalate accepted terminals — escalate must not
294
+ // leak an unverified ticketNumber into the caller-visible typed key.
295
+ if (admitted.ticketNumber === undefined && result.admitted !== undefined) {
296
+ const roleOutcome = result.terminal?.roleOutcome;
297
+ if (
298
+ roleOutcome !== undefined &&
299
+ roleOutcome.kind === "accepted" &&
300
+ roleOutcome.status !== "escalate"
301
+ ) {
302
+ const facts = roleOutcome.decisiveFacts as {
303
+ ticketNumber?: unknown;
304
+ sitian?: { ticketNumber?: unknown };
305
+ };
306
+ const raw =
307
+ typeof facts.ticketNumber === "number"
308
+ ? facts.ticketNumber
309
+ : typeof facts.sitian?.ticketNumber === "number"
310
+ ? facts.sitian.ticketNumber
311
+ : undefined;
312
+ if (typeof raw === "number" && Number.isSafeInteger(raw) && raw >= 1) {
313
+ (admitted as { ticketNumber?: number }).ticketNumber = raw;
314
+ if (result.admitted.ticketNumber === undefined) {
315
+ (result.admitted as { ticketNumber?: number }).ticketNumber = raw;
316
+ }
317
+ }
318
+ }
319
+ }
320
+ return result;
266
321
  });
267
322
  }
268
323
 
@@ -24,7 +24,6 @@ import {
24
24
  buildFixerTransportPrompt,
25
25
  type AdmittedFixerInvocation,
26
26
  } from "./invocation.ts";
27
- import { resolveSeatTicketBinding } from "./seat-ticket-binding.ts";
28
27
  import {
29
28
  buildResumeContinuationPrompt,
30
29
  loadResumableFixerRun,
@@ -214,12 +213,7 @@ export async function runPublicFixer(
214
213
  }),
215
214
  },
216
215
  }),
217
- adapters: {
218
- ...fixerAdapters(env.packageRoot, methodMaterial),
219
- beforeDispatch: async (admittedSeat) => {
220
- await resolveSeatTicketBinding(admittedSeat, env);
221
- },
222
- },
216
+ adapters: fixerAdapters(env.packageRoot, methodMaterial),
223
217
  ...(env.engine === undefined ? {} : { effectiveEngine: env.engine }),
224
218
  });
225
219
  }
@@ -8,11 +8,7 @@
8
8
  import type { DurablePrincipalAuthority, RoleTurnRequest } from "../host-contracts.ts";
9
9
  import { engineSessionMaterialFromOptions } from "../package-resources/engine-material.ts";
10
10
  import { CliUsageError } from "./cli-errors.ts";
11
- import {
12
- bindReusedTicketNumber,
13
- resolveKnownTicketNumber,
14
- tryResumeSameTicketSeatRun,
15
- } from "./seat-ticket-binding.ts";
11
+ import { tryResumeSameTicketSeatRun } from "./seat-ticket-binding.ts";
16
12
  import {
17
13
  admitInspectorInvocation,
18
14
  buildInspectorTransportPrompt,
@@ -88,14 +84,9 @@ export async function runPublicInspector(
88
84
  }
89
85
 
90
86
  // #747: same parent (卷宗指针) → resume prior inspector run with this summons' materials.
91
- // #709: ticket identity is reused from records this book already holds — no seat model call.
87
+ // #771: no mechanical ticket match against instruction text; parent-run path only.
92
88
  // No bare catch→fresh: lookup/resume failures surface; only true absence mints new.
93
89
  const projectRoot = parsed.project ?? env.cwd;
94
- const reusedTicketNumber = await resolveKnownTicketNumber({
95
- instruction: parsed.instruction,
96
- projectRoot,
97
- home: env.home,
98
- });
99
90
  const parentRunPath = parentRunPathFromGatePointerInstruction(parsed.instruction);
100
91
  if (parentRunPath !== undefined) {
101
92
  const summons: SameTicketSummonsMaterials = {
@@ -168,12 +159,7 @@ export async function runPublicInspector(
168
159
  env,
169
160
  io,
170
161
  request: turnRequest,
171
- adapters: inspectorAdapters({
172
- beforeDispatch: async (admittedSeat) => {
173
- // #635/#709: bind the reused identity inside the controlled-failure boundary.
174
- await bindReusedTicketNumber(admittedSeat, reusedTicketNumber);
175
- },
176
- }),
162
+ adapters: inspectorAdapters(),
177
163
  ...(env.engine === undefined ? {} : { effectiveEngine: env.engine }),
178
164
  });
179
165
  }