@akagilnc/pi-workflow-roles 0.1.3671 → 0.1.3682

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.
@@ -35,11 +35,7 @@ import {
35
35
  type PublicResumeRequest,
36
36
  type SameTicketSummonsMaterials,
37
37
  } from "./run-lifecycle.ts";
38
- import {
39
- bindReusedTicketNumber,
40
- resolveKnownTicketNumber,
41
- tryResumeSameTicketSeatRun,
42
- } from "./seat-ticket-binding.ts";
38
+ import { tryResumeSameTicketSeatRun } from "./seat-ticket-binding.ts";
43
39
  import {
44
40
  presentStructuralRejection,
45
41
  readEngineDetourInfrastructureFailure,
@@ -246,11 +242,11 @@ export async function runPublicInstructionSeat(
246
242
  let auditorSubject: "judge" | "doctor" | undefined;
247
243
  let auditorSourceRun: string | undefined;
248
244
  let auditorSourceTicket: number | undefined;
249
- let reusedTicketNumber: number | undefined;
250
245
 
251
- // #637 / #747: resume prior seat run with this summons' materials.
252
- // Auditor (#747): parent --source-run path is the lookup key.
253
- // Other instruction seats: reuse known ticket (#709). No bare catch→fresh.
246
+ // #637 / #747 / #771: resume prior seat run with this summons' materials.
247
+ // Auditor (#747): parent --source-run path is the lookup key (typed inherit).
248
+ // Other instruction seats: no mechanical ticket match against instruction text.
249
+ // No bare catch→fresh.
254
250
  const projectRoot = parsed.project ?? env.cwd;
255
251
  if (role === "auditor") {
256
252
  if (parsed.subject !== "judge" && parsed.subject !== "doctor") {
@@ -285,12 +281,6 @@ export async function runPublicInstructionSeat(
285
281
  );
286
282
  return { exitCode: 2 };
287
283
  }
288
- } else {
289
- reusedTicketNumber = await resolveKnownTicketNumber({
290
- instruction: parsed.instruction,
291
- projectRoot,
292
- home: env.home,
293
- });
294
284
  }
295
285
 
296
286
  const summons: SameTicketSummonsMaterials = {
@@ -319,22 +309,6 @@ export async function runPublicInstructionSeat(
319
309
  }),
320
310
  });
321
311
  if (resumed !== undefined) return resumed;
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;
338
312
  }
339
313
 
340
314
  let admitted: AdmittedInstructionSeatInvocation;
@@ -399,13 +373,9 @@ export async function runPublicInstructionSeat(
399
373
  request: turnRequest,
400
374
  adapters: instructionSeatAdapters({
401
375
  beforeDispatch: async (admittedSeat) => {
402
- // #635/#709: ticket bind inside controlled-failure boundary.
403
- // Auditor inherits source-run ticket (notary face).
404
- // Other seats: reuse known ticket identity.
376
+ // Auditor inherits source-run ticket (notary face) — typed, not prose match.
405
377
  if (auditorSourceTicket !== undefined) {
406
378
  await bindAdmittedTicketNumber(admittedSeat, auditorSourceTicket);
407
- } else {
408
- await bindReusedTicketNumber(admittedSeat, reusedTicketNumber);
409
379
  }
410
380
  },
411
381
  }),
@@ -3,6 +3,7 @@
3
3
  * Attachments, project default/override (ADR 0052 / #106).
4
4
  */
5
5
  import { execFileSync } from "node:child_process";
6
+ import { existsSync } from "node:fs";
6
7
  import {
7
8
  lstat,
8
9
  mkdir,
@@ -487,6 +488,57 @@ export async function bindAdmittedTicketNumber(
487
488
  );
488
489
  }
489
490
 
491
+ /**
492
+ * Bind ticket identity onto a run directory's durable pages when the admitted
493
+ * object is not in hand (起居郎 accept hook after LLM assertion, #771).
494
+ * Idempotent when the same number is already on the pages.
495
+ */
496
+ export async function bindTicketNumberOnRunDirectory(
497
+ runDirectory: string,
498
+ ticketNumber: number,
499
+ ): Promise<void> {
500
+ if (!Number.isSafeInteger(ticketNumber) || ticketNumber < 1) {
501
+ throw new Error(
502
+ `bindTicketNumberOnRunDirectory requires a safe positive integer, got ${String(ticketNumber)}`,
503
+ );
504
+ }
505
+ const admittedPath = join(runDirectory, "admitted-request.json");
506
+ const invocationPath = join(runDirectory, "invocation.json");
507
+ const admitted = JSON.parse(await readFile(admittedPath, "utf8")) as Record<
508
+ string,
509
+ unknown
510
+ >;
511
+ const existing = admitted.ticketNumber;
512
+ if (typeof existing === "number") {
513
+ if (existing === ticketNumber) return;
514
+ throw new Error(
515
+ `bindTicketNumberOnRunDirectory refuses to replace existing ticket #${existing} with #${ticketNumber}`,
516
+ );
517
+ }
518
+ // Conflict guard before any write: crash window of bindAdmittedTicketNumber
519
+ // can leave invocation bound while admitted-request is still unbound — refuse
520
+ // silent rebind. Read-before-merge; never check the page just overwritten.
521
+ if (existsSync(invocationPath)) {
522
+ const invocation = JSON.parse(
523
+ await readFile(invocationPath, "utf8"),
524
+ ) as Record<string, unknown>;
525
+ if (
526
+ typeof invocation.ticketNumber === "number" &&
527
+ invocation.ticketNumber !== ticketNumber
528
+ ) {
529
+ throw new Error(
530
+ `bindTicketNumberOnRunDirectory refuses to replace invocation ticket #${invocation.ticketNumber} with #${ticketNumber}`,
531
+ );
532
+ }
533
+ }
534
+ await writeFile(
535
+ admittedPath,
536
+ `${JSON.stringify({ ...admitted, ticketNumber }, null, 2)}\n`,
537
+ "utf8",
538
+ );
539
+ await mergeInvocationIdentityPage(runDirectory, { ticketNumber });
540
+ }
541
+
490
542
  /** Add the identity returned by the production Pi launch seam to its existing ledger page. */
491
543
  export async function recordLaunchedPiIdentity(
492
544
  runDirectory: string,
@@ -15,7 +15,6 @@ import {
15
15
  buildJudgeTransportPrompt,
16
16
  type AdmittedJudgeInvocation,
17
17
  } from "./invocation.ts";
18
- import { resolveSeatTicketBinding } from "./seat-ticket-binding.ts";
19
18
  import {
20
19
  loadResumableJudgeRun,
21
20
  markRunAdmitted,
@@ -156,12 +155,7 @@ export async function runPublicJudge(
156
155
  }),
157
156
  },
158
157
  }),
159
- adapters: {
160
- ...judgeAdapters(),
161
- beforeDispatch: async (admittedSeat) => {
162
- await resolveSeatTicketBinding(admittedSeat, env);
163
- },
164
- },
158
+ adapters: judgeAdapters(),
165
159
  ...(env.engine === undefined ? {} : { effectiveEngine: env.engine }),
166
160
  });
167
161
  }
@@ -1139,37 +1139,6 @@ export async function findLatestRunIdForSeatTicket(input: {
1139
1139
  return best;
1140
1140
  }
1141
1141
 
1142
- /**
1143
- * Ticket identities this book's retained runs already record (#709).
1144
- * Read-only reuse surface for seat ticket binding — no ticket number is minted here.
1145
- */
1146
- export async function collectBookRunTicketNumbers(input: {
1147
- readonly home: string;
1148
- readonly bookKey: string;
1149
- }): Promise<ReadonlySet<number>> {
1150
- const ledgerHome = resolveActivationLedgerHome(input.home);
1151
- const runsDir = join(
1152
- activationBookDirectory(ledgerHome, input.bookKey),
1153
- "runs",
1154
- );
1155
- let entries: string[];
1156
- try {
1157
- entries = await readdir(runsDir);
1158
- } catch (error) {
1159
- if (errorCodeOf(error) === "ENOENT") return new Set();
1160
- throw error;
1161
- }
1162
- const known = new Set<number>();
1163
- for (const entry of entries) {
1164
- // runs/ also holds non-run files (institutional-resolution.json, Finder
1165
- // .DS_Store); only `<runId>@<role>` directories are runs (#769).
1166
- if (!entry.includes("@")) continue;
1167
- const ticketNumber = await readRunTicketNumber(join(runsDir, entry));
1168
- if (ticketNumber !== undefined) known.add(ticketNumber);
1169
- }
1170
- return known;
1171
- }
1172
-
1173
1142
  type LoadedAdmittedRequestFields = {
1174
1143
  readonly instruction: string;
1175
1144
  readonly instructionEmpty: boolean;
@@ -1,157 +1,26 @@
1
1
  /**
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.
8
- * No CLI --ticket and no attachment frontmatter binding.
9
- * #747: officer same-parent resume also lives here (shared seam).
2
+ * Shared ticket identity seam for public court seats (#635 / #637 / #709 / #747 / #771).
3
+ *
4
+ * ADR 0075 `diarist-resolves-ticket-llm-layer`: the seat's LLM names the court
5
+ * target as a typed assertion; the mechanical layer only verifies that assertion
6
+ * (complete decimal of N appears in the summons; ticket exists). Code never
7
+ * judges which ticket the summons is about — no prose harvest, no first-#N
8
+ * position pick, no matching book-known numbers against instruction text
9
+ * (锚定宪法; owner 2026-09-08: 代码不准做判断). Other seats reuse a typed
10
+ * identity already handed over (起居郎 assertion / source-run / already-bound
11
+ * resume) — they do not re-recognize from instruction. No CLI --ticket, no
12
+ * attachment frontmatter. #747: officer same-parent resume also lives here.
13
+ *
14
+ * Mechanical verify helpers live on diarist.ts / invocation.ts (sole owners).
15
+ * This module owns same-ticket / same-parent resume only.
10
16
  */
11
- import { existsSync } from "node:fs";
12
-
13
- import {
14
- ActivationGitRepositoryRequiredError,
15
- resolveBookKeyFromGit,
16
- } from "../activation-ledger-git.ts";
17
- import { resolveTicketProvenanceVolume } from "../ticket-provenance.ts";
18
- import {
19
- bindAdmittedTicketNumber,
20
- type AdmittedRoleInvocation,
21
- } from "./invocation.ts";
17
+ import { resolveBookKeyFromGit } from "../activation-ledger-git.ts";
22
18
  import {
23
- collectBookRunTicketNumbers,
24
19
  findLatestRunIdForSeatTicket,
25
20
  type RoleRunRecord,
26
21
  type SameTicketSummonsMaterials,
27
22
  } from "./run-lifecycle.ts";
28
23
 
29
- export type SeatTicketBindingEnv = {
30
- readonly home: string;
31
- };
32
-
33
- /**
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.
37
- */
38
- export function instructionTicketTokens(
39
- instruction: string,
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];
49
- }
50
-
51
- /**
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.
56
- */
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,
72
- );
73
- if (existsSync(volume.volumeDir)) {
74
- known.push(token);
75
- continue;
76
- }
77
- if (runTickets === undefined) {
78
- runTickets = await readBookRunTickets(input);
79
- }
80
- if (runTickets.has(token)) known.push(token);
81
- }
82
- return known.length === 1 ? known[0] : undefined;
83
- }
84
-
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;
102
- }
103
-
104
- /**
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.
108
- */
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;
120
- }
121
- return await collectBookRunTicketNumbers({ home: input.home, bookKey });
122
- }
123
-
124
- /** Bind a reused ticket number onto an admission that is still unbound. */
125
- export async function bindReusedTicketNumber(
126
- admitted: AdmittedRoleInvocation,
127
- ticketNumber: number | undefined,
128
- ): Promise<void> {
129
- if (ticketNumber === undefined) return;
130
- if (admitted.ticketNumber !== undefined) return;
131
- await bindAdmittedTicketNumber(admitted, ticketNumber);
132
- }
133
-
134
- /**
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.
139
- */
140
- export async function resolveSeatTicketBinding(
141
- admitted: AdmittedRoleInvocation,
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;
153
- }
154
-
155
24
  /**
156
25
  * Sole same-seat → resume decision (#637 / #724 / #747).
157
26
  * Officer seats (notary/inspector/auditor) look up by parent run path; countersign
@@ -81,9 +81,12 @@ import {
81
81
  } from "./diarist-contracts.ts";
82
82
  import {
83
83
  commitDiaristSelections,
84
+ DiaristTicketVerificationError,
84
85
  loadDiaristSourceCatalog,
86
+ verifyAssertedTicketNumber,
85
87
  type DiaristSourceCatalog,
86
88
  } from "./diarist.ts";
89
+ import { bindTicketNumberOnRunDirectory } from "./public-cli/invocation.ts";
87
90
  import {
88
91
  GATEKEEPER_TOOL_SPEC,
89
92
  type GatekeeperRuntimeDependencies,
@@ -925,12 +928,40 @@ export function createAuditorRoleRuntime(
925
928
  };
926
929
  }
927
930
 
931
+ /**
932
+ * LLM typed court-target assertion from diarist output (ADR 0075).
933
+ * null/absent = true-unbound; positive safe integer = ticket N.
934
+ * Any other shape fails honestly — never washes into unbound.
935
+ */
936
+ function readDiaristTicketAssertion(
937
+ submitted: Record<string, unknown> | undefined,
938
+ ): { kind: "true-unbound" } | { kind: "ticket"; ticketNumber: number } {
939
+ if (submitted === undefined || !("ticketNumber" in submitted)) {
940
+ return { kind: "true-unbound" };
941
+ }
942
+ const raw = submitted.ticketNumber;
943
+ if (raw === null) return { kind: "true-unbound" };
944
+ if (typeof raw === "number" && Number.isSafeInteger(raw) && raw >= 1) {
945
+ return { kind: "ticket", ticketNumber: raw };
946
+ }
947
+ if (typeof raw === "string" && /^[1-9]\d*$/.test(raw)) {
948
+ const n = Number(raw);
949
+ if (Number.isSafeInteger(n) && n >= 1) {
950
+ return { kind: "ticket", ticketNumber: n };
951
+ }
952
+ }
953
+ throw new DiaristTicketVerificationError(
954
+ "assertion-uninterpretable",
955
+ "diarist ticketNumber must be a safe integer >= 1, null, or absent",
956
+ );
957
+ }
958
+
928
959
  /**
929
960
  * #708: 起居郎 public seat on the shared filed-officer envelope.
930
961
  * Semantic collection happened in this role's own turn; the accept hook runs the
931
- * mechanical band (verbatim reverse-verify → idempotent sitian append →
932
- * watermark) and records the resulting sitian facts next to the receipt.
933
- * Machine facts never come from model self-report (锚定宪法).
962
+ * mechanical band (ticket verify when first-summons + verbatim reverse-verify →
963
+ * idempotent sitian append → watermark) and records the resulting sitian facts
964
+ * next to the receipt. Machine facts never come from model self-report (锚定宪法).
934
965
  */
935
966
  export function createDiaristRoleRuntime(
936
967
  roleHost: RoleHost,
@@ -953,21 +984,88 @@ export function createDiaristRoleRuntime(
953
984
  parameters !== null && typeof parameters === "object" && !Array.isArray(parameters)
954
985
  ? (parameters as Record<string, unknown>)
955
986
  : undefined;
956
- // True-unbound summons: no ticket, no catalog, no diary committed — so
957
- // this turn produced no sitian facts. A self-reported `sitian` is not
958
- // one (锚定宪法); drop it rather than let it ride into decisiveFacts.
959
- // Carrying the field is not a reason to bounce the receipt (第 0 条).
987
+
988
+ // LLM cannot identify the court target — escalate without machine facts.
989
+ // Strip sitian (锚定宪法) and ticketNumber (unverified — must not leak
990
+ // into caller-visible admitted typed key via decisiveFacts mirror).
991
+ if (submitted?.status === "escalate") {
992
+ if (!("sitian" in submitted) && !("ticketNumber" in submitted)) {
993
+ return submitted;
994
+ }
995
+ const stripped = { ...submitted };
996
+ delete stripped.sitian;
997
+ delete stripped.ticketNumber;
998
+ return stripped;
999
+ }
1000
+
1001
+ const assertion = readDiaristTicketAssertion(submitted);
1002
+
1003
+ // No frozen catalog: only true-unbound is lawful without volume work.
1004
+ // A self-reported `sitian` is not machine fact (锚定宪法).
960
1005
  if (catalog === undefined) {
1006
+ if (assertion.kind === "ticket") {
1007
+ throw new DiaristTicketVerificationError(
1008
+ "assertion-uninterpretable",
1009
+ `diarist asserted ticket #${assertion.ticketNumber} but no source catalog was frozen for this turn`,
1010
+ );
1011
+ }
961
1012
  if (submitted === undefined || !("sitian" in submitted)) return undefined;
962
1013
  const stripped = { ...submitted };
963
1014
  delete stripped.sitian;
964
1015
  return stripped;
965
1016
  }
1017
+
1018
+ // Already bound before the turn (typed handoff / resume): skip re-recognition
1019
+ // (ADR 0075 已绑定 ticket 优先) and commit under that identity.
1020
+ if (catalog.ticketNumber !== undefined) {
1021
+ const facts = await commitDiaristSelections({
1022
+ catalog,
1023
+ selections: projectDiaristSelections(parameters),
1024
+ });
1025
+ return {
1026
+ ...(submitted ?? { receipt: parameters }),
1027
+ ticketNumber: catalog.ticketNumber,
1028
+ sitian: facts,
1029
+ };
1030
+ }
1031
+
1032
+ // First summons: LLM typed assertion → mechanical verify → bind → commit.
1033
+ // true-unbound → 无录 (no volume). Verification failure throws (失败诚实).
1034
+ // Cannot-identify must arrive as status=escalate above — never as silent unbound.
1035
+ if (assertion.kind === "true-unbound") {
1036
+ if (submitted === undefined || !("sitian" in submitted)) {
1037
+ return { ...(submitted ?? { receipt: parameters }), ticketNumber: null };
1038
+ }
1039
+ const stripped: Record<string, unknown> = {
1040
+ ...submitted,
1041
+ ticketNumber: null,
1042
+ };
1043
+ delete stripped.sitian;
1044
+ return stripped;
1045
+ }
1046
+
1047
+ await verifyAssertedTicketNumber({
1048
+ ticketNumber: assertion.ticketNumber,
1049
+ instruction: catalog.instruction,
1050
+ projectRoot: catalog.projectRoot,
1051
+ });
1052
+ await bindTicketNumberOnRunDirectory(
1053
+ catalog.runDirectory,
1054
+ assertion.ticketNumber,
1055
+ );
1056
+ const boundCatalog: DiaristSourceCatalog = {
1057
+ ...catalog,
1058
+ ticketNumber: assertion.ticketNumber,
1059
+ };
966
1060
  const facts = await commitDiaristSelections({
967
- catalog,
1061
+ catalog: boundCatalog,
968
1062
  selections: projectDiaristSelections(parameters),
969
1063
  });
970
- return { ...(submitted ?? { receipt: parameters }), sitian: facts };
1064
+ return {
1065
+ ...(submitted ?? { receipt: parameters }),
1066
+ ticketNumber: assertion.ticketNumber,
1067
+ sitian: facts,
1068
+ };
971
1069
  },
972
1070
  },
973
1071
  dependencies,