@akagilnc/pi-workflow-roles 0.1.4035 → 0.1.4193

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/dist/acp-host/production-host.js +2368 -1155
  2. package/dist/archivist-record-entry.js +204 -20
  3. package/dist/archivist-record-topology.js +36 -16
  4. package/dist/auditor-dossier-tool.js +2 -1
  5. package/dist/diarist-contracts.js +96 -0
  6. package/dist/doctor-auditor.js +1 -1
  7. package/dist/doctor-evidence.js +3 -2
  8. package/dist/dossier-resolution.js +21 -8
  9. package/dist/gatekeeper-role.js +34 -27
  10. package/dist/headless-host/description.js +7 -8
  11. package/dist/headless-host/production-host.js +2375 -1149
  12. package/dist/host-contracts.js +16 -0
  13. package/dist/inspector-contracts.js +8 -0
  14. package/dist/migrate-book-topology.js +278917 -0
  15. package/dist/navigator-public-session.js +22 -25
  16. package/dist/notary-source-run.js +7 -4
  17. package/dist/packaged-role-registry.js +1 -1
  18. package/dist/pi/durable-principal.js +9 -6
  19. package/dist/pi/role-turn-host.js +43 -13
  20. package/dist/public-cli/auto-resume.js +2 -5
  21. package/dist/public-cli/case-dossier-delivery.js +60 -1
  22. package/dist/public-cli/diarist-run.js +27 -25
  23. package/dist/public-cli/inspector-run.js +17 -3
  24. package/dist/public-cli/instruction-seat-run.js +17 -17
  25. package/dist/public-cli/invocation.js +127 -63
  26. package/dist/public-cli/main.js +1685 -696
  27. package/dist/public-cli/notary-run.js +7 -4
  28. package/dist/public-cli/option-definitions.js +2 -2
  29. package/dist/public-cli/post-admission.js +113 -71
  30. package/dist/public-cli/run-lifecycle.js +209 -58
  31. package/dist/public-cli/seat-ticket-binding.js +3 -6
  32. package/dist/public-cli/settlement.js +29 -10
  33. package/dist/public-role-summons.js +8 -0
  34. package/dist/role-run-placement.js +84 -0
  35. package/dist/role-run-relocation.js +358 -0
  36. package/dist/run-terminal-artifacts.js +2 -1
  37. package/dist/run-ticket-number.js +51 -32
  38. package/dist/sitian-appender.js +11 -26
  39. package/dist/submission-ledger.js +106 -25
  40. package/dist/ticket-provenance.js +3 -1
  41. package/dist/user-dialogue-stdin.js +33 -0
  42. package/extensions/role-runtime.ts +1 -0
  43. package/package.json +3 -2
  44. package/resources/836-deleted-machine-instruction-inventory.md +1 -1
  45. package/scripts/build-package.mjs +34 -2
  46. package/souls/fixer.md +3 -2
  47. package/src/analyst-ledger.ts +14 -15
  48. package/src/archivist-record-entry.ts +285 -38
  49. package/src/archivist-record-topology.ts +43 -18
  50. package/src/auditor-dossier-tool.ts +2 -1
  51. package/src/book-topology-migration-placement.ts +493 -0
  52. package/src/book-topology-migration.ts +220 -0
  53. package/src/book-topology-migrators-t11.ts +856 -0
  54. package/src/book-topology-mixed-volume-migrators.ts +470 -0
  55. package/src/book-topology-partition-migrators.ts +25 -0
  56. package/src/book-topology-record-class-migrators.ts +779 -0
  57. package/src/book-topology-runs-migrator.ts +244 -0
  58. package/src/diarist-contracts.ts +116 -0
  59. package/src/diarist-role.ts +6 -0
  60. package/src/diarist.ts +2 -0
  61. package/src/doctor-auditor.ts +1 -1
  62. package/src/doctor-evidence.ts +4 -1
  63. package/src/doctor-role.ts +1 -1
  64. package/src/dossier-resolution.ts +26 -8
  65. package/src/external-host-turn-loop.ts +9 -0
  66. package/src/gatekeeper-pass-envelope.ts +6 -0
  67. package/src/gatekeeper-role.ts +47 -28
  68. package/src/headless-host/description.ts +8 -11
  69. package/src/headless-host/role-turn-host.ts +20 -5
  70. package/src/host-contracts.ts +22 -3
  71. package/src/inspector-contracts.ts +7 -0
  72. package/src/judge-role.ts +4 -0
  73. package/src/navigator-public-session.ts +33 -30
  74. package/src/navigator-work-context.ts +3 -4
  75. package/src/notary-role.ts +1 -0
  76. package/src/notary-source-run.ts +9 -4
  77. package/src/packaged-role-registry.ts +1 -1
  78. package/src/pi/adapter.ts +19 -4
  79. package/src/pi/durable-principal.ts +9 -13
  80. package/src/pi/role-turn-host.ts +48 -14
  81. package/src/public-cli/auto-resume.ts +2 -6
  82. package/src/public-cli/case-dossier-delivery.ts +86 -1
  83. package/src/public-cli/countersign-run.ts +182 -32
  84. package/src/public-cli/diarist-run.ts +34 -26
  85. package/src/public-cli/inspector-run.ts +26 -2
  86. package/src/public-cli/instruction-seat-run.ts +24 -20
  87. package/src/public-cli/invocation.ts +194 -58
  88. package/src/public-cli/notary-run.ts +13 -4
  89. package/src/public-cli/option-definitions.ts +2 -2
  90. package/src/public-cli/post-admission.ts +147 -78
  91. package/src/public-cli/run-lifecycle.ts +258 -65
  92. package/src/public-cli/seat-ticket-binding.ts +3 -6
  93. package/src/public-cli/settlement.ts +38 -10
  94. package/src/public-role-summons.ts +31 -7
  95. package/src/role-envelope.ts +9 -30
  96. package/src/role-run-placement.ts +128 -0
  97. package/src/role-run-relocation.ts +491 -0
  98. package/src/role-runtime-dependencies.ts +1 -0
  99. package/src/role-runtime.ts +106 -24
  100. package/src/run-terminal-artifacts.ts +3 -1
  101. package/src/run-ticket-number.ts +80 -20
  102. package/src/sitian-appender.ts +25 -28
  103. package/src/submission-ledger.ts +148 -30
  104. package/src/ticket-provenance.ts +4 -1
  105. package/src/ticket-trajectory.ts +22 -35
  106. package/src/user-dialogue-stdin.ts +37 -0
  107. package/src/worker-role.ts +8 -4
  108. package/src/worker-submission-gates.ts +6 -6
@@ -2,6 +2,8 @@ import { readFileSync, writeSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import {
4
4
  ExplicitInternalActivationError,
5
+ isOfficerReviewSeat,
6
+ runDirectoryFromHostContext,
5
7
  type HostContext,
6
8
  type HostToolResult,
7
9
  type RoleEnvelopeHost,
@@ -36,7 +38,13 @@ import {
36
38
  writeToolExecutionObservationRecord,
37
39
  type ToolExecutionObservationWriter,
38
40
  } from "./tool-execution-observation.ts";
39
- import { ENGINE_DETOUR_TOOL_NAME } from "./engine-detour.ts";
41
+ import {
42
+ ENGINE_DETOUR_TOOL_NAME,
43
+ ENGINE_MODEL_FLAG_NAME,
44
+ resolveEngineModel,
45
+ resolveEngineName,
46
+ } from "./engine-detour.ts";
47
+ import { engineSessionMaterialFromOptions } from "./package-resources/engine-material.ts";
40
48
  import { registerEngineDetourTool } from "./engine-detour-tool.ts";
41
49
  import { createReceiptDeliveryPolicy, NO_RECEIPT_LIFECYCLE_ENTRY_TYPE, RECEIPT_DELIVERY_PROMPT } from "./receipt-delivery-policy.ts";
42
50
  import type { AnyCanonicalSkillBinding } from "./canonical-skill-binding.ts";
@@ -71,7 +79,7 @@ import {
71
79
  INSPECTOR_TOOL_SPEC,
72
80
  type InspectorRuntimeDependencies,
73
81
  } from "./inspector-role.ts";
74
- import { INSPECTOR_ACCEPTED_TEXT } from "./inspector-contracts.ts";
82
+ import { INSPECTOR_ACCEPTED_TEXT, INSPECTOR_SOURCE_RUN_FLAG } from "./inspector-contracts.ts";
75
83
  import {
76
84
  DIARIST_TOOL_SPEC,
77
85
  type DiaristRuntimeDependencies,
@@ -414,7 +422,7 @@ export type { MergerRoleDependencies } from "./merger-role.ts";
414
422
 
415
423
  type WorkerArmable = {
416
424
  activate(context?: HostContext): Promise<void>;
417
- armSubmissionGate(cwd: string, parent?: { getSessionFile(): string | undefined }): void;
425
+ armSubmissionGate(cwd: string, parent: { getSessionFile(): string | undefined }): void;
418
426
  };
419
427
 
420
428
  type ActivationRuntime = {
@@ -566,6 +574,8 @@ type NavigatorAttendanceDependency = Omit<NavigatorAttendance, "knownRoutePlaybo
566
574
  Partial<Pick<NavigatorAttendance, "knownRoutePlaybookReadFailure">>;
567
575
 
568
576
  export type RoleRuntimeDependencies = {
577
+ /** Package root for packaged engine-note resolution (#879). */
578
+ packageRoot?: string;
569
579
  loadJudgeSoul(): Promise<string>;
570
580
  loadFixerSoul?(): Promise<string>;
571
581
  loadFixPacket?(path: string): Promise<string>;
@@ -898,14 +908,14 @@ function readDiaristTicketAssertion(
898
908
  }
899
909
 
900
910
  /** Run coordinates + optional pre-bound ticket from durable pages (#779). */
901
- function readDiaristRunCoordinates(): {
911
+ function readDiaristRunCoordinates(ctx: HostContext): {
902
912
  readonly runDirectory: string;
903
913
  readonly projectRoot: string;
904
914
  readonly home: string;
905
915
  readonly boundTicketNumber?: number;
906
916
  } {
907
- const runDirectory = process.env.AK_ROLE_RUN_DIR;
908
- if (typeof runDirectory !== "string" || runDirectory.trim() === "") {
917
+ const runDirectory = runDirectoryFromHostContext(ctx);
918
+ if (runDirectory === undefined) {
909
919
  throw new Error("diarist accept requires AK_ROLE_RUN_DIR");
910
920
  }
911
921
  const admittedPath = join(runDirectory, "admitted-request.json");
@@ -948,13 +958,13 @@ export function createDiaristRoleRuntime(
948
958
  tool: DIARIST_TOOL_SPEC,
949
959
  acceptedText: DIARIST_ACCEPTED_TEXT,
950
960
  soulTag: "diarist",
951
- beforeAccept: async ({ parameters }) => {
961
+ beforeAccept: async ({ parameters, ctx }) => {
952
962
  const submitted =
953
963
  parameters !== null && typeof parameters === "object" && !Array.isArray(parameters)
954
964
  ? (parameters as Record<string, unknown>)
955
965
  : undefined;
956
966
  const assertion = readDiaristTicketAssertion(submitted);
957
- const coords = readDiaristRunCoordinates();
967
+ const coords = readDiaristRunCoordinates(ctx);
958
968
  // #836 7.3: pre-bound ticket is material for the LLM, not an override.
959
969
  const ticketNumber = assertion.kind === "ticket" ? assertion.ticketNumber : undefined;
960
970
  if (ticketNumber !== undefined) {
@@ -964,6 +974,7 @@ export function createDiaristRoleRuntime(
964
974
  await commitDiaristEntries({
965
975
  ticketNumber,
966
976
  cwd: coords.projectRoot,
977
+ sessionParent: join(coords.runDirectory, "session", "session.jsonl"),
967
978
  home: coords.home,
968
979
  entries: projectDiaristEntries(parameters),
969
980
  });
@@ -1020,6 +1031,8 @@ export function createCountersignRoleRuntime(
1020
1031
  ...(signal === undefined ? {} : { signal }),
1021
1032
  hostActions,
1022
1033
  toolCallId,
1034
+ // #879: this-turn typed payload — identity-bound at submit site.
1035
+ submission: parameters,
1023
1036
  });
1024
1037
  }
1025
1038
  : undefined;
@@ -1057,6 +1070,10 @@ export function createRoleRuntimeExtension(
1057
1070
  for (const flag of NOTARY_TRANSPORT_FLAGS) {
1058
1071
  roleHost.registerFlag(flag.name, flag.definition);
1059
1072
  }
1073
+ roleHost.registerFlag(
1074
+ INSPECTOR_SOURCE_RUN_FLAG.name,
1075
+ INSPECTOR_SOURCE_RUN_FLAG.definition,
1076
+ );
1060
1077
  for (const flag of GLEANER_LEFT_TRANSPORT_FLAGS) {
1061
1078
  roleHost.registerFlag(flag.name, flag.definition);
1062
1079
  }
@@ -1066,6 +1083,13 @@ export function createRoleRuntimeExtension(
1066
1083
  }
1067
1084
  // Station-child identity (#840): omit navigator attendance. One flag.
1068
1085
  roleHost.registerFlag(STATION_CHILD_FLAG.name, STATION_CHILD_FLAG.definition);
1086
+ // Register model only. Pi never sets ak-engine — resolveEngineName must
1087
+ // fall through to child-process env. An empty default would block that.
1088
+ roleHost.registerFlag(ENGINE_MODEL_FLAG_NAME, {
1089
+ description: "本次劳务引擎模型",
1090
+ type: "string",
1091
+ default: "",
1092
+ });
1069
1093
 
1070
1094
  let admitted = false;
1071
1095
  let selectedRole: string | undefined;
@@ -1079,6 +1103,8 @@ export function createRoleRuntimeExtension(
1079
1103
  let pendingNavigatorPresentation: { event: import("./navigator-attendance.ts").NavigatorEvent; report: import("./navigator-attendance.ts").NavigatorReport } | undefined;
1080
1104
  let pendingNavigatorSettlement: Promise<void> | undefined;
1081
1105
  let navigatorWorkContext: NavigatorWorkContext | undefined;
1106
+ let navigatorSessionParent: string | undefined;
1107
+ let navigatorCwd: string | undefined;
1082
1108
  /** toolCallId → fact+evidence; one-shot projected onto durable tool_result (#475). */
1083
1109
  const pendingInfrastructureFailures = new Map<string, PendingInfrastructureFailure>();
1084
1110
  // Envelope-owned execute→tool_result bridge for submission non-pass (ADR 0018 / #525).
@@ -1134,18 +1160,19 @@ export function createRoleRuntimeExtension(
1134
1160
  void Promise.resolve(attendance.dispose()).then(
1135
1161
  undefined,
1136
1162
  (error) => {
1163
+ const diagnostic = error instanceof Error ? error.message : String(error);
1137
1164
  try {
1138
1165
  sitianReport({
1139
1166
  level: "event",
1140
1167
  kind: "navigator-dispose-failure",
1141
- payload: {
1142
- diagnostic: error instanceof Error ? error.message : String(error),
1143
- },
1168
+ cwd: navigatorCwd,
1169
+ sessionParent: navigatorSessionParent,
1170
+ payload: { diagnostic },
1144
1171
  source: "role-runtime",
1145
1172
  });
1146
1173
  } catch (recordError) {
1147
1174
  envelopeHost.appendEntry?.("ak-navigator-dispose-failure", {
1148
- diagnostic: error instanceof Error ? error.message : String(error),
1175
+ diagnostic,
1149
1176
  recordFailure: recordError instanceof Error ? recordError.message : String(recordError),
1150
1177
  });
1151
1178
  }
@@ -1163,6 +1190,7 @@ export function createRoleRuntimeExtension(
1163
1190
  settleNavigatorProjection,
1164
1191
  );
1165
1192
  roleHost.on("input", (event) => {
1193
+ const text = event.text;
1166
1194
  const role = roleHost.getFlag(ROLE_FLAG.name);
1167
1195
  if (role !== undefined && !admitted) return { action: "handled" as const };
1168
1196
  // Reviewer: recover original request; Pi argv may already carry native form.
@@ -1175,14 +1203,19 @@ export function createRoleRuntimeExtension(
1175
1203
  reviewerOriginalRequest =
1176
1204
  roleHost.capabilities?.skillOriginalRequest?.(
1177
1205
  activeReviewerParent.skillBinding.name,
1178
- event.text,
1179
- ) ?? event.text;
1180
- return { action: "continue" as const };
1206
+ text,
1207
+ ) ?? text;
1208
+ return text === event.text
1209
+ ? { action: "continue" as const }
1210
+ : { action: "transform" as const, text };
1181
1211
  }
1182
- return { action: "continue" as const };
1212
+ return text === event.text
1213
+ ? { action: "continue" as const }
1214
+ : { action: "transform" as const, text };
1183
1215
  });
1184
1216
  roleHost.on("before_agent_start", async (event, ctx) => {
1185
1217
  const role = roleHost.getFlag(ROLE_FLAG.name);
1218
+ const prompt = event.prompt;
1186
1219
  if (role === undefined) return;
1187
1220
  if (!admitted || selectedRole !== role) {
1188
1221
  failInfrastructure(new ActivationBarrierError(role), ctx);
@@ -1207,7 +1240,7 @@ export function createRoleRuntimeExtension(
1207
1240
  // output payload (#836 targets the latter): the human's own raw
1208
1241
  // prompt bytes, copied verbatim into Navigator's work context when
1209
1242
  // nothing else has supplied a subject yet.
1210
- const subject = event.prompt.trim();
1243
+ const subject = prompt.trim();
1211
1244
  if (subject !== "") {
1212
1245
  const root = subjectPath(ctx.sessionManager.getSessionDir(), ctx.cwd);
1213
1246
  const subjectProvenance = "user_prompt" satisfies NavigatorSubjectProvenance;
@@ -1231,7 +1264,7 @@ export function createRoleRuntimeExtension(
1231
1264
  if (!reviewerExpansionCaptured) {
1232
1265
  if (reviewerOriginalRequest !== undefined) {
1233
1266
  activeReviewerParent.skillBinding.captureExpansion(
1234
- roleHost.capabilities?.skillExpansion(event.prompt),
1267
+ roleHost.capabilities?.skillExpansion(prompt),
1235
1268
  reviewerOriginalRequest,
1236
1269
  );
1237
1270
  }
@@ -1255,6 +1288,54 @@ export function createRoleRuntimeExtension(
1255
1288
  };
1256
1289
  }
1257
1290
  });
1291
+ // #879: engine coordinates ride readingMaterial only when station-child
1292
+ // officer dialogue replaced the ordinary transport prompt (no double fold).
1293
+ roleHost.on("before_agent_start", () => {
1294
+ const role = selectedRole ?? roleHost.getFlag(ROLE_FLAG.name);
1295
+ if (typeof role !== "string" || !isOfficerReviewSeat(role)) return;
1296
+ if (roleHost.getFlag(STATION_CHILD_FLAG.name) !== true) return;
1297
+ const engine = resolveEngineName((name) => roleHost.getFlag(name));
1298
+ if (engine === undefined || dependencies.packageRoot === undefined) return;
1299
+ const engineModel = resolveEngineModel((name) => roleHost.getFlag(name));
1300
+ const material = engineSessionMaterialFromOptions({
1301
+ engine,
1302
+ ...(engineModel === undefined ? {} : { engineModel }),
1303
+ packageRoot: dependencies.packageRoot,
1304
+ });
1305
+ if (material === undefined) return;
1306
+ return {
1307
+ readingMaterial: {
1308
+ kind: "engine-session-material" as const,
1309
+ name: material.name,
1310
+ ...(material.model === undefined ? {} : { model: material.model }),
1311
+ ...(material.materialPath === undefined ? {} : { materialPath: material.materialPath }),
1312
+ },
1313
+ };
1314
+ });
1315
+ roleHost.on("before_agent_start", () => {
1316
+ const path = roleHost.getFlag(INSPECTOR_SOURCE_RUN_FLAG.name);
1317
+ if (typeof path !== "string" || path.trim() === "") return;
1318
+ return {
1319
+ readingMaterial: {
1320
+ kind: "inspector-parent-binding" as const,
1321
+ sourceRunPath: path,
1322
+ },
1323
+ };
1324
+ });
1325
+ // #879: single shared owner of station-child 0081 case-dossier → readingMaterial fold.
1326
+ // post-admission freezes under run/attachments/case-dossier/; this handler alone
1327
+ // projects it onto the existing agent-start materials face (Pi + envelope collect).
1328
+ // Role modules must not re-read the freeze (ADR 0018 lifecycle; no duplicate fold).
1329
+ roleHost.on("before_agent_start", async (_event, ctx) => {
1330
+ const runDir = runDirectoryFromHostContext(ctx);
1331
+ if (runDir === undefined) return;
1332
+ const { loadCaseDossierReadingMaterial } = await import(
1333
+ "./public-cli/case-dossier-delivery.ts"
1334
+ );
1335
+ const caseDossier = await loadCaseDossierReadingMaterial(runDir);
1336
+ if (caseDossier === undefined) return;
1337
+ return { readingMaterial: caseDossier };
1338
+ });
1258
1339
  roleHost.on("tool_result", async (event) => {
1259
1340
  const role = selectedRole;
1260
1341
  if (role === undefined) return;
@@ -1341,7 +1422,7 @@ export function createRoleRuntimeExtension(
1341
1422
  display: false,
1342
1423
  }, { triggerTurn: true, deliverAs: "followUp" });
1343
1424
  } else if (receiptDelivery.nextAction() === "no-receipt" && !noReceiptRecorded) {
1344
- const runPointer = process.env.AK_ROLE_RUN_DIR;
1425
+ const runPointer = runDirectoryFromHostContext(ctx);
1345
1426
  if (runPointer !== undefined) {
1346
1427
  noReceiptRecorded = true;
1347
1428
  const facts = receiptDelivery.facts({ runPointer, attemptPointer: `current:${runPointer}` });
@@ -1750,8 +1831,8 @@ export function createRoleRuntimeExtension(
1750
1831
  provider: string,
1751
1832
  ctx: HostContext,
1752
1833
  ): Promise<void> => {
1753
- const runDir = process.env.AK_ROLE_RUN_DIR;
1754
- if (typeof runDir !== "string" || runDir.trim() === "") return;
1834
+ const runDir = runDirectoryFromHostContext(ctx);
1835
+ if (runDir === undefined) return;
1755
1836
  try {
1756
1837
  await recordTypedProviderHttpStatus(runDir, { httpStatus: status, provider });
1757
1838
  } catch (error) {
@@ -1785,10 +1866,9 @@ export function createRoleRuntimeExtension(
1785
1866
  const underlying = priorFetch;
1786
1867
  globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
1787
1868
  const response = await underlying(input, init);
1788
- const runDir = process.env.AK_ROLE_RUN_DIR;
1869
+ const runDir = runDirectoryFromHostContext(ctx);
1789
1870
  if (
1790
- typeof runDir === "string"
1791
- && runDir.trim() !== ""
1871
+ runDir !== undefined
1792
1872
  && typeof response?.status === "number"
1793
1873
  && (response.status < 200 || response.status >= 300)
1794
1874
  ) {
@@ -1887,6 +1967,8 @@ export function createRoleRuntimeExtension(
1887
1967
  && entry.role !== "navigator"
1888
1968
  && !isStationChild
1889
1969
  ) {
1970
+ navigatorSessionParent = ctx.sessionManager.getSessionFile();
1971
+ navigatorCwd = ctx.cwd;
1890
1972
  let work: NavigatorWorkContext;
1891
1973
  let contextError: unknown;
1892
1974
  if (dependencies.loadNavigatorWorkContext === undefined) {
@@ -9,6 +9,8 @@
9
9
  import { readdir, readFile } from "node:fs/promises";
10
10
  import { basename, dirname, join } from "node:path";
11
11
 
12
+ import { roleRunArtifactsDirectory } from "./role-run-placement.ts";
13
+
12
14
  export const RUN_TERMINAL_ARTIFACT_FILES = [
13
15
  "report.json",
14
16
  "error.json",
@@ -191,7 +193,7 @@ function presentUniqueFallbackBoundToRun(
191
193
  export async function readRunTerminalArtifact(
192
194
  runDirectory: string,
193
195
  ): Promise<RunTerminalArtifactRead> {
194
- const artifactsDir = join(runDirectory, "artifacts");
196
+ const artifactsDir = roleRunArtifactsDirectory(runDirectory);
195
197
  for (const file of RUN_TERMINAL_ARTIFACT_FILES) {
196
198
  const path = join(artifactsDir, file);
197
199
  const read = await readTerminalArtifactAtPath(path, file);
@@ -1,11 +1,17 @@
1
1
  /**
2
2
  * Typed ticketNumber reader for a retained run's durable pages.
3
- * admitted-request.json first, invocation.json fallback. Missing page (ENOENT)
4
- * → try next / undefined; damage and non-ENOENT IO failures propagate.
3
+ * Board pages first (admitted-request, then invocation); migration derivation
4
+ * page last so worktree-derived placement stays readable without forging a
5
+ * board assertion. Missing page (ENOENT) → try next / undefined; damage and
6
+ * non-ENOENT IO failures propagate.
5
7
  */
6
8
  import { readFile } from "node:fs/promises";
7
9
  import { join } from "node:path";
8
10
 
11
+ /** Sole on-disk page for worktree-basename ticket derivation (#865). */
12
+ export const MIGRATION_TICKET_DERIVATION_PAGE =
13
+ "migration-ticket-derivation.json" as const;
14
+
9
15
  function isEnoent(error: unknown): boolean {
10
16
  return (
11
17
  error instanceof Error &&
@@ -14,40 +20,94 @@ function isEnoent(error: unknown): boolean {
14
20
  );
15
21
  }
16
22
 
17
- function ticketFromRecord(record: Record<string, unknown>): number | undefined {
18
- const ticketNumber = record.ticketNumber;
19
- if (
20
- typeof ticketNumber === "number" &&
21
- Number.isSafeInteger(ticketNumber) &&
22
- ticketNumber >= 1
23
- ) {
24
- return ticketNumber;
23
+ /** Sole safe-positive ticket invariant (bind / admission / placement / readers). */
24
+ export function isSafePositiveTicketNumber(value: unknown): value is number {
25
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 1;
26
+ }
27
+
28
+ /** Loud reject for non-safe-positive ticket numbers before placement or bind. */
29
+ export function requireSafePositiveTicketNumber(
30
+ ticketNumber: number,
31
+ context = "ticketNumber",
32
+ ): number {
33
+ if (!isSafePositiveTicketNumber(ticketNumber)) {
34
+ throw new Error(
35
+ `${context} requires a safe positive integer, got ${String(ticketNumber)}`,
36
+ );
25
37
  }
26
- return undefined;
38
+ return ticketNumber;
27
39
  }
28
40
 
29
- async function readPageTicketNumber(
30
- runDirectory: string,
31
- page: "admitted-request.json" | "invocation.json",
32
- ): Promise<number | undefined> {
41
+ function ticketFromRecord(record: Record<string, unknown>): number | undefined {
42
+ return isSafePositiveTicketNumber(record.ticketNumber)
43
+ ? record.ticketNumber
44
+ : undefined;
45
+ }
46
+
47
+ async function readJsonObject(
48
+ path: string,
49
+ ): Promise<Record<string, unknown> | undefined> {
33
50
  try {
34
- const raw = JSON.parse(await readFile(join(runDirectory, page), "utf8")) as unknown;
51
+ const raw: unknown = JSON.parse(await readFile(path, "utf8"));
35
52
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
36
53
  return undefined;
37
54
  }
38
- return ticketFromRecord(raw as Record<string, unknown>);
55
+ return raw as Record<string, unknown>;
39
56
  } catch (error) {
40
57
  if (isEnoent(error)) return undefined;
41
58
  throw error;
42
59
  }
43
60
  }
44
61
 
45
- /** Sole reader of ticketNumber from a retained run's admitted then invocation pages. */
62
+ async function readBoardPageTicketNumber(
63
+ runDirectory: string,
64
+ page: "admitted-request.json" | "invocation.json",
65
+ ): Promise<number | undefined> {
66
+ const record = await readJsonObject(join(runDirectory, page));
67
+ if (record === undefined) return undefined;
68
+ return ticketFromRecord(record);
69
+ }
70
+
71
+ /**
72
+ * Board-only ticketNumber (admitted → invocation). Placement attribution and
73
+ * any caller that must ignore derivation pages use this sole projection.
74
+ */
75
+ export async function readBoardTicketNumber(
76
+ runDirectory: string,
77
+ ): Promise<number | undefined> {
78
+ return (
79
+ (await readBoardPageTicketNumber(runDirectory, "admitted-request.json")) ??
80
+ (await readBoardPageTicketNumber(runDirectory, "invocation.json"))
81
+ );
82
+ }
83
+
84
+ /**
85
+ * Worktree-derivation ticket only — never a board assertion. Callers that must
86
+ * distinguish board vs derived use this; effective readers use readRunTicketNumber.
87
+ */
88
+ export async function readMigrationDerivedTicketNumber(
89
+ runDirectory: string,
90
+ ): Promise<number | undefined> {
91
+ const record = await readJsonObject(
92
+ join(runDirectory, MIGRATION_TICKET_DERIVATION_PAGE),
93
+ );
94
+ if (record === undefined) return undefined;
95
+ if (record.derivation !== "worktree-path-basename") return undefined;
96
+ return ticketFromRecord(record);
97
+ }
98
+
99
+ /**
100
+ * Display / historical-placement ticketNumber for a retained run:
101
+ * board admitted → board invocation → migration derivation.
102
+ * Derivation is last so board wins. Callers that mint, resume, inherit, or
103
+ * otherwise write ticket identity must use readBoardTicketNumber instead —
104
+ * derived placement is never a board assertion.
105
+ */
46
106
  export async function readRunTicketNumber(
47
107
  runDirectory: string,
48
108
  ): Promise<number | undefined> {
49
109
  return (
50
- (await readPageTicketNumber(runDirectory, "admitted-request.json")) ??
51
- (await readPageTicketNumber(runDirectory, "invocation.json"))
110
+ (await readBoardTicketNumber(runDirectory)) ??
111
+ (await readMigrationDerivedTicketNumber(runDirectory))
52
112
  );
53
113
  }
@@ -11,7 +11,7 @@ import {
11
11
  unlinkSync,
12
12
  writeFileSync,
13
13
  } from "node:fs";
14
- import { basename, dirname, join, resolve } from "node:path";
14
+ import { dirname, join } from "node:path";
15
15
 
16
16
  import { resolveBookKeyFromGit } from "./activation-ledger-git.ts";
17
17
  import {
@@ -204,14 +204,6 @@ export function resolveSitianVolumeCategory(kind: string): string {
204
204
  return kind;
205
205
  }
206
206
 
207
- function safeBookKey(cwd: string): string {
208
- try {
209
- return resolveBookKeyFromGit(cwd);
210
- } catch {
211
- return basename(resolve(cwd)) || "default";
212
- }
213
- }
214
-
215
207
  type SitianRecordPath = {
216
208
  readonly sessionDir: string;
217
209
  readonly recordFile: string;
@@ -223,30 +215,35 @@ export function resolveSitianRecordPathInLedger(
223
215
  input: SitianRecordInput,
224
216
  ledgerHome: string,
225
217
  ): SitianRecordPath {
226
- const cwd = input.cwd ?? process.cwd();
227
218
  const category = resolveSitianVolumeCategory(input.kind);
219
+ const ticketNumber = category === "ticket-provenance"
220
+ ? typeof input.subject === "string" && /^[1-9][0-9]*$/.test(input.subject)
221
+ ? input.subject
222
+ : typeof input.subject === "object"
223
+ && typeof input.subject.ticketNumber === "number"
224
+ && Number.isSafeInteger(input.subject.ticketNumber)
225
+ && input.subject.ticketNumber > 0
226
+ ? String(input.subject.ticketNumber)
227
+ : undefined
228
+ : undefined;
228
229
 
229
230
  let sessionDir: string;
230
-
231
- if (input.sessionParent !== undefined && input.sessionParent.length > 0 && physicallyContainedIn(ledgerHome, input.sessionParent)) {
232
- sessionDir = join(dirname(input.sessionParent), category);
231
+ if (ticketNumber !== undefined) {
232
+ // docs/dossier-topology.md: ticket dir holds records.jsonl + 起居录.md directly.
233
+ const bookDir = activationBookDirectory(
234
+ ledgerHome,
235
+ resolveBookKeyFromGit(input.cwd ?? process.cwd()),
236
+ );
237
+ sessionDir = join(bookDir, ticketNumber);
233
238
  } else {
234
- const bookKey = safeBookKey(cwd);
235
- const bookDir = activationBookDirectory(ledgerHome, bookKey);
236
- if (input.subject !== undefined) {
237
- let subjectStr: string;
238
- if (typeof input.subject === "string") {
239
- subjectStr = input.subject;
240
- } else if (typeof input.subject.runId === "string" && input.subject.runId.length > 0) {
241
- subjectStr = input.subject.runId;
242
- } else {
243
- subjectStr = JSON.stringify(input.subject);
244
- }
245
- const digest = createHash("sha256").update(subjectStr).digest("hex").slice(0, 32);
246
- sessionDir = join(bookDir, category, digest);
247
- } else {
248
- sessionDir = join(bookDir, category);
239
+ if (
240
+ input.sessionParent === undefined
241
+ || input.sessionParent.length === 0
242
+ || !physicallyContainedIn(ledgerHome, input.sessionParent)
243
+ ) {
244
+ throw new Error("Sitian record ownership requires a parent session inside the ledger home");
249
245
  }
246
+ sessionDir = join(dirname(input.sessionParent), category);
250
247
  }
251
248
 
252
249
  const recordFile = join(sessionDir, "records.jsonl");