@akagilnc/pi-workflow-roles 0.1.5483 → 0.1.5571

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 (112) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/dist/acp-host/production-host.js +6312 -6376
  4. package/dist/analyst-gate-cycles-read.js +28 -78
  5. package/dist/analyst-ledger.js +26 -42
  6. package/dist/analyst-metric-families/acceptance-success-rework.js +25 -1
  7. package/dist/analyst-metric-families/round-timeline.js +2 -5
  8. package/dist/archivist-record-entry.js +4 -3
  9. package/dist/archivist-record-pointer.js +71 -36
  10. package/dist/atomic-write.js +2 -2
  11. package/dist/auditor-dossier-tool.js +11 -6
  12. package/dist/auditor-soul.js +5 -12
  13. package/dist/collector-config.js +6 -5
  14. package/dist/doctor-evidence.js +8 -4
  15. package/dist/engine-detour-usage.js +8 -22
  16. package/dist/headless-host/production-host.js +5969 -6011
  17. package/dist/host-descriptions.js +0 -4
  18. package/dist/host-session-record.js +266 -267
  19. package/dist/ledger-session-read.js +5 -0
  20. package/dist/migrate-book-topology.js +1369 -722
  21. package/dist/navigator-public-session.js +13 -17
  22. package/dist/notary-source-run.js +8 -16
  23. package/dist/packaged-role-registry.js +5 -56
  24. package/dist/pi/durable-principal.js +2 -3
  25. package/dist/pi/role-turn-host.js +79 -15
  26. package/dist/public-cli/auto-resume.js +8 -47
  27. package/dist/public-cli/instruction-seat-run.js +9 -7
  28. package/dist/public-cli/invocation.js +67 -100
  29. package/dist/public-cli/main.js +2689 -2532
  30. package/dist/public-cli/post-admission.js +96 -55
  31. package/dist/public-cli/run-lifecycle.js +44 -100
  32. package/dist/public-cli/settlement.js +184 -429
  33. package/dist/public-cli/terminal.js +0 -12
  34. package/dist/role-run-placement.js +53 -16
  35. package/dist/role-run-relocation.js +111 -52
  36. package/dist/run-dossier-files.js +30 -0
  37. package/dist/run-dossier.js +322 -0
  38. package/dist/run-terminal-artifacts.js +69 -202
  39. package/dist/run-ticket-number.js +49 -7
  40. package/dist/session-assistant-usage.js +9 -18
  41. package/dist/session-identity.js +49 -19
  42. package/dist/sitian-appender.js +29 -18
  43. package/dist/sitian-facade.js +11 -0
  44. package/dist/sitian-reader.js +44 -29
  45. package/dist/submission-gate.js +1 -1
  46. package/dist/submission-ledger.js +4 -4
  47. package/dist/ticket-provenance-contracts.js +2 -8
  48. package/dist/ticket-provenance.js +25 -9
  49. package/package.json +1 -1
  50. package/scripts/build-package.mjs +2 -0
  51. package/src/acp-host/production-host.ts +1 -1
  52. package/src/acp-host/role-turn-host.ts +11 -4
  53. package/src/analyst-gate-cycles-read.ts +31 -88
  54. package/src/analyst-ledger.ts +36 -51
  55. package/src/analyst-metric-families/acceptance-success-rework.ts +24 -1
  56. package/src/analyst-metric-families/gate-cycles.ts +1 -1
  57. package/src/analyst-metric-families/round-timeline.ts +2 -5
  58. package/src/analyst-page.ts +3 -3
  59. package/src/archivist-record-entry.ts +6 -5
  60. package/src/archivist-record-pointer.ts +92 -42
  61. package/src/atomic-write.ts +3 -1
  62. package/src/auditor-dossier-tool.ts +14 -8
  63. package/src/auditor-soul.ts +5 -10
  64. package/src/book-topology-migration-placement.ts +16 -3
  65. package/src/book-topology-record-class-migrators.ts +4 -2
  66. package/src/book-topology-runs-migrator.ts +6 -3
  67. package/src/collector-config.ts +5 -3
  68. package/src/doctor-contracts.ts +1 -1
  69. package/src/doctor-evidence.ts +7 -3
  70. package/src/engine-detour-tool.ts +1 -1
  71. package/src/engine-detour-usage.ts +12 -25
  72. package/src/external-host-turn-loop.ts +25 -0
  73. package/src/factory-board.ts +16 -81
  74. package/src/headless-host/production-host.ts +1 -4
  75. package/src/headless-host/role-turn-host.ts +56 -21
  76. package/src/host-descriptions.ts +0 -6
  77. package/src/host-session-record.ts +224 -140
  78. package/src/ledger-session-read.ts +6 -0
  79. package/src/navigator-public-session.ts +13 -18
  80. package/src/navigator-work-context.ts +3 -1
  81. package/src/notary-role.ts +2 -1
  82. package/src/notary-source-run.ts +8 -18
  83. package/src/packaged-role-registry.ts +1 -65
  84. package/src/pi/adapter.ts +50 -7
  85. package/src/pi/durable-principal.ts +2 -4
  86. package/src/pi/role-turn-host.ts +85 -14
  87. package/src/public-cli/auto-resume.ts +7 -43
  88. package/src/public-cli/instruction-seat-run.ts +13 -6
  89. package/src/public-cli/invocation.ts +90 -144
  90. package/src/public-cli/post-admission.ts +111 -72
  91. package/src/public-cli/run-lifecycle.ts +47 -115
  92. package/src/public-cli/settlement.ts +221 -589
  93. package/src/public-cli/terminal.ts +1 -61
  94. package/src/role-envelope.ts +4 -2
  95. package/src/role-run-placement.ts +65 -17
  96. package/src/role-run-relocation.ts +110 -70
  97. package/src/role-runtime.ts +46 -69
  98. package/src/run-dossier-files.ts +33 -0
  99. package/src/run-dossier.ts +357 -0
  100. package/src/run-terminal-artifacts.ts +84 -240
  101. package/src/run-ticket-number.ts +82 -14
  102. package/src/session-assistant-usage.ts +8 -20
  103. package/src/session-identity.ts +52 -21
  104. package/src/sitian-appender.ts +33 -26
  105. package/src/sitian-facade.ts +22 -0
  106. package/src/sitian-reader.ts +62 -32
  107. package/src/submission-gate.ts +1 -1
  108. package/src/submission-ledger.ts +4 -4
  109. package/src/ticket-provenance-contracts.ts +3 -6
  110. package/src/ticket-provenance.ts +23 -11
  111. package/src/ticket-snapshot.ts +3 -2
  112. package/src/ticket-trajectory.ts +95 -109
@@ -1,7 +1,13 @@
1
- import { mkdirSync, writeFileSync } from "node:fs";
2
- import { readFile } from "node:fs/promises";
3
- import { dirname, join } from "node:path";
1
+ import { basename, join } from "node:path";
4
2
  import { isRecord } from "./unknown-value.ts";
3
+ import { latestOfficerPointersFromRecords, RUN_HISTORY_FILE } from "./run-dossier.ts";
4
+ import { OFFICER_POINTER_RECORD_KIND } from "./run-dossier-files.ts";
5
+ import { readSitianRecords, reportRunRecord } from "./sitian-facade.ts";
6
+
7
+ import { homeFromRunDirectory } from "./activation-ledger-topology.ts";
8
+ import { findRunDirectoryById, readRoleRunIdentity } from "./public-cli/run-lifecycle.ts";
9
+ import { rewriteRunDirectoryPathValue } from "./role-run-relocation.ts";
10
+ import { parseRunLeaf, runDirectoryOfSessionFile } from "./role-run-placement.ts";
5
11
 
6
12
  /** Typed parent-side pointer to an independent officer run 正本 (ADR 0079 / #675). */
7
13
  export const DIRECT_OFFICER_RUN_POINTER_KIND = "direct-officer-run-pointer" as const;
@@ -21,14 +27,17 @@ export type DirectOfficerRunPointer = {
21
27
  readonly submissionToolCallId?: string;
22
28
  };
23
29
 
30
+ /** History record kind of a booked officer pointer (appender routes it to history.jsonl). */
31
+ export { OFFICER_POINTER_RECORD_KIND };
32
+
24
33
  /**
25
- * Book a typed pointer under parent session/auditor-roles (same nest owner as
26
- * createRecordSession). Never fabricates user/assistant/toolResult rows (#675).
27
- * Directory placement stays with the archivist record entry (ADR 0018 / 0065).
34
+ * Book a typed pointer as one history record of the parent run; the settlement
35
+ * seam projects the latest per officer into current.json `officers`.
36
+ * Never fabricates user/assistant/toolResult rows (#675).
28
37
  *
29
- * Stable leaf per officer under one parent (#753 gate-round accounting):
30
- * same-parent re-summons upsert the same pointer instead of minting N files that
31
- * each re-scan the full officer session and multiply terminal gate-round counts.
38
+ * Latest record wins per officer under one parent (#753 gate-round accounting):
39
+ * a same-parent re-summons supersedes the earlier pointer instead of adding a
40
+ * second one that re-scans the full officer session and multiplies gate-cycle counts.
32
41
  */
33
42
  export function bookDirectOfficerRunPointer(options: {
34
43
  readonly parentSessionFile: string;
@@ -37,8 +46,6 @@ export function bookDirectOfficerRunPointer(options: {
37
46
  readonly runDirectory?: string;
38
47
  readonly submissionToolCallId?: string;
39
48
  }): DirectOfficerRunPointer {
40
- const nest = join(dirname(options.parentSessionFile), "auditor-roles");
41
- mkdirSync(nest, { recursive: true });
42
49
  const submissionToolCallId = options.submissionToolCallId?.trim() ?? "";
43
50
  const pointer: DirectOfficerRunPointer = {
44
51
  version: 1,
@@ -50,47 +57,90 @@ export function bookDirectOfficerRunPointer(options: {
50
57
  : {}),
51
58
  ...(submissionToolCallId === "" ? {} : { submissionToolCallId }),
52
59
  };
53
- writeFileSync(
54
- join(nest, `${options.officer}.pointer.json`),
55
- `${JSON.stringify(pointer)}\n`,
56
- "utf8",
57
- );
60
+ reportRunRecord(runDirectoryOfSessionFile(options.parentSessionFile), OFFICER_POINTER_RECORD_KIND, pointer, "submission-gate");
58
61
  return pointer;
59
62
  }
60
63
 
64
+ /** Shared strict pointer IO; internal audit wraps unreadability as a re-summons. */
65
+ export async function resolveOfficerPointer(
66
+ raw: unknown,
67
+ parentRunDirectory: string,
68
+ ): Promise<{
69
+ sessionFile: string;
70
+ officer?: DirectOfficerRunPointer["officer"];
71
+ runDirectory?: string;
72
+ submissionToolCallId?: string;
73
+ }> {
74
+ const where = `${parentRunDirectory} officers`;
75
+ if (!isRecord(raw) || raw.kind !== DIRECT_OFFICER_RUN_POINTER_KIND || raw.version !== 1) {
76
+ throw new Error(`direct officer run pointer has unknown shape in ${where}`);
77
+ }
78
+ if (typeof raw.sessionFile !== "string" || raw.sessionFile.trim() === "") {
79
+ throw new Error(`direct officer run pointer missing sessionFile in ${where}`);
80
+ }
81
+ const officer = raw.officer === "inspector" || raw.officer === "notary"
82
+ || raw.officer === "auditor" || raw.officer === "countersign" ? raw.officer : undefined;
83
+ let runDirectory = typeof raw.runDirectory === "string" && raw.runDirectory.trim() !== ""
84
+ ? raw.runDirectory : undefined;
85
+ let sessionFile = raw.sessionFile;
86
+ const recordedRunDirectory = runDirectory ?? runDirectoryOfSessionFile(sessionFile);
87
+ const leaf = parseRunLeaf(basename(recordedRunDirectory));
88
+ const parentIdentity = await readRoleRunIdentity(parentRunDirectory);
89
+ if (leaf !== undefined && parentIdentity !== undefined) {
90
+ const currentRunDirectory = await findRunDirectoryById(
91
+ homeFromRunDirectory(parentRunDirectory), leaf.runId, parentIdentity.bookKey, leaf.role,
92
+ );
93
+ if (currentRunDirectory !== undefined) {
94
+ sessionFile = rewriteRunDirectoryPathValue(sessionFile, recordedRunDirectory, currentRunDirectory) as string;
95
+ if (runDirectory !== undefined) runDirectory = currentRunDirectory;
96
+ }
97
+ }
98
+ const submissionToolCallId = typeof raw.submissionToolCallId === "string"
99
+ && raw.submissionToolCallId.trim() !== "" ? raw.submissionToolCallId : undefined;
100
+ return {
101
+ sessionFile,
102
+ ...(officer === undefined ? {} : { officer }),
103
+ ...(runDirectory === undefined ? {} : { runDirectory }),
104
+ ...(submissionToolCallId === undefined ? {} : { submissionToolCallId }),
105
+ };
106
+ }
107
+
61
108
  /**
62
- * Read the one officer pointer. Missing or unreadable is not a pass:
63
- * the caller re-runs the gate.
109
+ * The latest pointer booked per officer on one run, as stored. History absent → empty.
110
+ * Canonical malformed diagnostics are not discarded: a damaged officer source must
111
+ * not wash into lawful zero rounds (#1161 O1).
64
112
  */
113
+ export async function readBookedOfficerPointers(parentRunDirectory: string): Promise<Readonly<Record<string, unknown>>> {
114
+ const { records, diagnostics } = await readSitianRecords(join(parentRunDirectory, RUN_HISTORY_FILE));
115
+ if (diagnostics.length > 0) {
116
+ throw new Error(
117
+ `${RUN_HISTORY_FILE} has ${diagnostics.length} malformed row(s); officer pointers unreadable: ${parentRunDirectory}`,
118
+ );
119
+ }
120
+ return latestOfficerPointersFromRecords(records);
121
+ }
122
+
123
+ /** Every pointer booked on one run, in officer-name order. No pointers → []. */
124
+ export async function readOfficerPointers(parentRunDirectory: string): Promise<readonly Awaited<ReturnType<typeof resolveOfficerPointer>>[]> {
125
+ const officers = await readBookedOfficerPointers(parentRunDirectory);
126
+ return Promise.all(
127
+ Object.keys(officers).sort().map((name) => resolveOfficerPointer(officers[name], parentRunDirectory)),
128
+ );
129
+ }
130
+
131
+ /** Missing or unreadable is not a pass: the internal audit caller re-runs the gate. */
65
132
  export async function readDirectOfficerRunPointer(
66
133
  parentSessionFile: string,
67
134
  officer: DirectOfficerRunPointer["officer"],
68
135
  ): Promise<DirectOfficerRunPointer | undefined> {
69
136
  try {
70
- const raw: unknown = JSON.parse(await readFile(
71
- join(dirname(parentSessionFile), "auditor-roles", `${officer}.pointer.json`),
72
- "utf8",
73
- ));
74
- if (!isRecord(raw) || raw.kind !== DIRECT_OFFICER_RUN_POINTER_KIND || raw.version !== 1) {
75
- return undefined;
76
- }
77
- if (raw.officer !== officer) return undefined;
78
- if (typeof raw.sessionFile !== "string" || raw.sessionFile.trim() === "") return undefined;
79
- const runDirectory = typeof raw.runDirectory === "string" && raw.runDirectory.trim() !== ""
80
- ? raw.runDirectory
81
- : undefined;
82
- const submissionToolCallId = typeof raw.submissionToolCallId === "string"
83
- && raw.submissionToolCallId.trim() !== ""
84
- ? raw.submissionToolCallId
85
- : undefined;
86
- return {
87
- version: 1,
88
- kind: DIRECT_OFFICER_RUN_POINTER_KIND,
89
- officer,
90
- sessionFile: raw.sessionFile,
91
- ...(runDirectory === undefined ? {} : { runDirectory }),
92
- ...(submissionToolCallId === undefined ? {} : { submissionToolCallId }),
93
- };
137
+ const parentRunDirectory = runDirectoryOfSessionFile(parentSessionFile);
138
+ const pointer = await resolveOfficerPointer(
139
+ (await readBookedOfficerPointers(parentRunDirectory))[officer],
140
+ parentRunDirectory,
141
+ );
142
+ if (pointer.officer !== officer) return undefined;
143
+ return { ...pointer, officer, version: 1, kind: DIRECT_OFFICER_RUN_POINTER_KIND };
94
144
  } catch {
95
145
  return undefined;
96
146
  }
@@ -7,17 +7,19 @@
7
7
  * ledger home own confinement via ensureRealDirectoryTree (ADR 0038).
8
8
  */
9
9
  import { randomUUID } from "node:crypto";
10
+ import type { Mode } from "node:fs";
10
11
  import { rename, rm, writeFile } from "node:fs/promises";
11
12
  import { dirname, join } from "node:path";
12
13
 
13
14
  export async function writeFileAtomically(
14
15
  destination: string,
15
16
  contents: string | Uint8Array,
17
+ options?: { readonly mode?: Mode },
16
18
  ): Promise<void> {
17
19
  const parent = dirname(destination);
18
20
  const temporary = join(parent, `.atomic-write-${randomUUID()}.tmp`);
19
21
  try {
20
- await writeFile(temporary, contents);
22
+ await writeFile(temporary, contents, options);
21
23
  await rename(temporary, destination);
22
24
  } catch (error) {
23
25
  await rm(temporary, { force: true }).catch(() => undefined);
@@ -1,18 +1,23 @@
1
- import { dirname, join, resolve } from "node:path";
1
+ import { join, resolve } from "node:path";
2
2
 
3
+ import { RUN_HISTORY_FILE, RUN_LOG_FILE, runCurrentPath } from "./run-dossier.ts";
3
4
  import type { AgentToolResult, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
5
  import type { HostContext } from "./host-contracts.ts";
5
- import { roleRunArtifactsDirectory } from "./role-run-placement.ts";
6
+ import {
7
+ runDirectoryOfSessionFile,
8
+ sessionFileOf,
9
+ } from "./role-run-placement.ts";
6
10
  import { Type } from "typebox";
7
11
 
8
12
  export const AUDITOR_DOSSIER_TOOL_NAME = "ak_get_run_dossier" as const;
9
13
 
10
14
  export type AuditorDossierLocation = {
11
15
  readonly runDirectory: string;
12
- readonly admittedRequest: string;
16
+ readonly current: string;
13
17
  readonly parentSessionCandidate: string;
14
18
  readonly attachments: string;
15
- readonly artifacts: string;
19
+ readonly log: string;
20
+ readonly history: string;
16
21
  };
17
22
 
18
23
  /** Resolve the exact run binding already carried by the parent record session. */
@@ -20,7 +25,7 @@ export function auditorRunDirectory(context: ExtensionContext | HostContext): st
20
25
  const sessionFile = context.sessionManager?.getSessionFile?.();
21
26
  if (sessionFile === undefined) return undefined;
22
27
  // Sole layout: <run>/session/session.jsonl → climb two levels to the run directory.
23
- return resolve(dirname(dirname(sessionFile)));
28
+ return resolve(runDirectoryOfSessionFile(sessionFile));
24
29
  }
25
30
 
26
31
  /** The one shared, run-bound dossier locator exposed to every auditor seat. */
@@ -43,10 +48,11 @@ export function createAuditorDossierTool(
43
48
  // #836: whole run directory pointer — officer finds materials; no leaf preference (A7.2).
44
49
  const details: AuditorDossierLocation = {
45
50
  runDirectory,
46
- admittedRequest: join(runDirectory, "admitted-request.json"),
47
- parentSessionCandidate: join(runDirectory, "session", "session.jsonl"),
51
+ current: runCurrentPath(runDirectory),
52
+ parentSessionCandidate: sessionFileOf(runDirectory),
48
53
  attachments: join(runDirectory, "attachments"),
49
- artifacts: roleRunArtifactsDirectory(runDirectory),
54
+ log: join(runDirectory, RUN_LOG_FILE),
55
+ history: join(runDirectory, RUN_HISTORY_FILE),
50
56
  };
51
57
  return {
52
58
  content: [{ type: "text", text: JSON.stringify(details) }],
@@ -1,6 +1,8 @@
1
1
  import { readFile } from "node:fs/promises";
2
+ import { readPageSync } from "./run-dossier.ts";
2
3
  import { basename, join } from "node:path";
3
4
 
5
+ import { parseRunLeaf } from "./role-run-placement.ts";
4
6
  import { isRecord } from "./unknown-value.ts";
5
7
 
6
8
  import {
@@ -109,7 +111,7 @@ export function loadAuditorReferenceMaterialsFromSubjectInput(raw?: string): Pro
109
111
  }
110
112
 
111
113
  function subjectFromSourceDirectory(sourceRunDirectory: string): AuditorSoulRole | undefined {
112
- const role = basename(sourceRunDirectory).split("@")[1];
114
+ const role = parseRunLeaf(basename(sourceRunDirectory))?.role;
113
115
  return isAuditorSoulRole(role) ? role : undefined;
114
116
  }
115
117
 
@@ -121,15 +123,8 @@ export async function readAuditorResumeBinding(runDirectory: string): Promise<
121
123
  | { readonly subject: AuditorSoulRole; readonly sourceRunDirectory: string }
122
124
  | undefined
123
125
  > {
124
- let raw: unknown;
125
- try {
126
- raw = JSON.parse(await readFile(join(runDirectory, "admitted-request.json"), "utf8")) as unknown;
127
- } catch (error) {
128
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
129
- throw error;
130
- }
131
- if (!isRecord(raw)) return undefined;
132
- const record = raw as { sourceRunPath?: unknown; auditorSubject?: unknown };
126
+ const record = readPageSync(runDirectory, "admitted");
127
+ if (record === undefined) return undefined;
133
128
  if (typeof record.sourceRunPath !== "string" || record.sourceRunPath.trim() === "") return undefined;
134
129
  const subject = isAuditorSoulRole(record.auditorSubject)
135
130
  ? record.auditorSubject
@@ -15,9 +15,10 @@ import { isUnboundRunDirectory, roleRunPlacement } from "./role-run-placement.ts
15
15
  export { isUnboundRunDirectory };
16
16
  import {
17
17
  MIGRATION_TICKET_DERIVATION_PAGE,
18
- readBoardTicketNumber,
18
+ readMigrationBoardTicketNumber,
19
19
  readMigrationDerivedTicketNumber,
20
20
  } from "./run-ticket-number.ts";
21
+ import { readPageSync } from "./run-dossier.ts";
21
22
 
22
23
  import { isRecord, isEnoent } from "./unknown-value.ts";
23
24
 
@@ -83,11 +84,23 @@ export function ticketNumberFromWorktreeBasename(
83
84
 
84
85
  /**
85
86
  * projectRoot from a retained run's durable pages.
86
- * ENOENT → try next page / undefined; bad JSON and non-ENOENT IO propagate.
87
+ * New落点 (state rows) first, then pre-#1161 page files at the migration boundary.
88
+ * ENOENT → try next / undefined; bad JSON and non-ENOENT IO propagate.
87
89
  */
88
90
  async function readProjectRootFromRun(
89
91
  runDirectory: string,
90
92
  ): Promise<{ readonly projectRoot: string; readonly sourcePage: string } | undefined> {
93
+ for (const section of ["admitted", "invocation", "runState"] as const) {
94
+ try {
95
+ const page = readPageSync(runDirectory, section);
96
+ const projectRoot = page?.projectRoot;
97
+ if (typeof projectRoot === "string" && projectRoot.length > 0) {
98
+ return { projectRoot, sourcePage: `state.jsonl:${section}` };
99
+ }
100
+ } catch (error) {
101
+ if (!isEnoent(error)) throw error;
102
+ }
103
+ }
91
104
  for (const page of ["admitted-request.json", "invocation.json", "run-state.json"] as const) {
92
105
  const path = join(runDirectory, page);
93
106
  try {
@@ -123,7 +136,7 @@ export async function resolveMigratingRunTicket(runDirectory: string): Promise<{
123
136
  readonly ticketNumber: number | undefined;
124
137
  readonly derivation: MigratingRunTicketDerivation | undefined;
125
138
  }> {
126
- const boardTicket = await readBoardTicketNumber(runDirectory);
139
+ const boardTicket = await readMigrationBoardTicketNumber(runDirectory);
127
140
  if (boardTicket !== undefined) {
128
141
  return {
129
142
  ticketNumber: boardTicket,
@@ -31,12 +31,14 @@ import {
31
31
  type BookTopologyPartitionMigrator,
32
32
  type MigrationItemOutcome,
33
33
  } from "./book-topology-migration.ts";
34
- import { S4_SUBMISSION_LEDGER_KINDS } from "./sitian-appender.ts";
35
34
  import { projectTicketProvenanceHeader } from "./ticket-provenance-contracts.ts";
36
35
 
37
36
  import { sha256Hex } from "./sha256.ts";
38
37
  import { isRecord, isEnoent } from "./unknown-value.ts";
39
38
 
39
+ /** Kinds the retired #852-era submission-ledger volume held (migration of stored runs only). */
40
+ const LEGACY_SUBMISSION_LEDGER_KINDS = new Set(["candidate", "roundContext", "outcome", "sealed", "post-seal-anomaly"]);
41
+
40
42
  const TICKET_PROVENANCE = "ticket-provenance";
41
43
  const SUBMISSION_LEDGER = "submission-ledger";
42
44
  const ATTEMPT_HISTORY = "attempt-history";
@@ -210,7 +212,7 @@ function runIdFromPayload(payload: unknown): string | undefined {
210
212
  function recordClassOfKind(kind: unknown): RecordClass | undefined {
211
213
  if (kind === TICKET_PROVENANCE) return TICKET_PROVENANCE;
212
214
  if (kind === ATTEMPT_HISTORY) return ATTEMPT_HISTORY;
213
- if (typeof kind === "string" && S4_SUBMISSION_LEDGER_KINDS.has(kind)) return SUBMISSION_LEDGER;
215
+ if (typeof kind === "string" && LEGACY_SUBMISSION_LEDGER_KINDS.has(kind)) return SUBMISSION_LEDGER;
214
216
  return undefined;
215
217
  }
216
218
 
@@ -26,9 +26,10 @@ import {
26
26
  rewriteRoleRunDurablePages,
27
27
  type RunDirectoryPathRewrite,
28
28
  } from "./role-run-relocation.ts";
29
+ import { renderCurrentSync } from "./run-dossier.ts";
29
30
  import {
30
31
  MIGRATION_TICKET_DERIVATION_PAGE,
31
- readBoardTicketNumber,
32
+ readMigrationBoardTicketNumber,
32
33
  } from "./run-ticket-number.ts";
33
34
 
34
35
  import { isEnoent } from "./unknown-value.ts";
@@ -151,7 +152,7 @@ async function planBookMoves(
151
152
  // #863: board typed ticket on an unbound leaf → place under that ticket.
152
153
  // No board ticket → stay unbound. Never invent from prose or derivation.
153
154
  const boardTicket = leaf.isDirectory
154
- ? await readBoardTicketNumber(leaf.sourcePath)
155
+ ? await readMigrationBoardTicketNumber(leaf.sourcePath)
155
156
  : undefined;
156
157
  if (parsed !== undefined && boardTicket !== undefined) {
157
158
  targetPath = destinationRunDirectory(
@@ -318,7 +319,7 @@ export async function relocateBoardBoundUnboundRunsInBook(
318
319
  const parsed = parseRunLeaf(entry.name);
319
320
  if (parsed === undefined) continue;
320
321
  const sourcePath = join(unboundRuns, entry.name);
321
- const boardTicket = await readBoardTicketNumber(sourcePath);
322
+ const boardTicket = await readMigrationBoardTicketNumber(sourcePath);
322
323
  if (boardTicket === undefined) continue;
323
324
  const targetPath = destinationRunDirectory(
324
325
  booksDirectory,
@@ -367,6 +368,8 @@ export async function relocateBoardBoundUnboundRunsInBook(
367
368
  for (const move of planned) {
368
369
  await mkdir(dirname(move.targetPath), { recursive: true });
369
370
  await rename(move.sourcePath, move.targetPath);
371
+ // Same live relocate seam: derived host.original follows the new run directory.
372
+ renderCurrentSync(move.targetPath);
370
373
  }
371
374
 
372
375
  return planned.map((move) => ({
@@ -2,6 +2,7 @@ import { readFile } from "node:fs/promises";
2
2
 
3
3
  import { sha256Hex } from "./sha256.ts";
4
4
  import { isRecord } from "./unknown-value.ts";
5
+ import { isTicketNumberString, parseTicketNumber } from "./run-ticket-number.ts";
5
6
 
6
7
  export const COLLECTOR_OWNER_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/;
7
8
  export const COLLECTOR_REPO_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,98}[A-Za-z0-9])?$/;
@@ -46,10 +47,11 @@ export function parseCollectorRepository(raw: unknown): CollectorRepository {
46
47
  }
47
48
 
48
49
  export function parseCollectorPrNumber(raw: unknown): number {
49
- if (typeof raw === "string" && !/^[1-9][0-9]*$/.test(raw)) fail("Collector pull request number must be a positive safe integer string");
50
50
  if (typeof raw !== "string" && typeof raw !== "number") fail("Collector pull request number is required");
51
- const value = Number(raw);
52
- if (!Number.isSafeInteger(value) || value < 1) fail("Collector pull request number must be a positive safe integer");
51
+ const value = parseTicketNumber(raw);
52
+ if (value === undefined) fail(typeof raw === "string" && !isTicketNumberString(raw)
53
+ ? "Collector pull request number must be a positive safe integer string"
54
+ : "Collector pull request number must be a positive safe integer");
53
55
  return value;
54
56
  }
55
57
 
@@ -49,7 +49,7 @@ export type DoctorSubmission =
49
49
  // #836: runtime cost is a fact beside the role payload, never merged into it —
50
50
  // DoctorOutput is the accepted payload itself, identical to DoctorSubmission.
51
51
  export type DoctorOutput = DoctorSubmission;
52
- export type DoctorEvidenceEntry = { id: string; kind: "session" | "stderr"; byteLength: number; contentLength: number; sha256: string; content: string };
52
+ export type DoctorEvidenceEntry = { id: string; kind: "session" | "log"; byteLength: number; contentLength: number; sha256: string; content: string };
53
53
  export type DoctorCase = { version: 1; identity: DoctorCaseIdentity; evidence: DoctorEvidenceEntry[]; cost: DoctorCaseCost };
54
54
 
55
55
  // status 的合法词写在 description。交卷原样入账(validateDoctorSubmissionShape),
@@ -1,12 +1,13 @@
1
1
  import { readdir, readFile, realpath, stat } from "node:fs/promises";
2
2
  import { dirname, relative, resolve, sep } from "node:path";
3
3
  import { sha256Hex } from "./sha256.ts";
4
+ import { physicallyContainedIn } from "./activation-ledger-topology.ts";
4
5
  import type { DoctorCase, DoctorCaseCost, DoctorCount, DoctorEvidenceEntry } from "./doctor-contracts.ts";
5
6
  import { AcceptedDetailsContractError, acceptedFacts, isTerminatingToolName, validateAcceptedDetails } from "./package-contracts/terminating-tools.ts";
6
7
 
7
8
  import { isRecord, isMissingPathError } from "./unknown-value.ts";
8
9
 
9
- async function discoverCaseFiles(root: string): Promise<string[]> { const found: string[] = []; async function walk(dir: string, depth: number) { for (const item of await readdir(dir, { withFileTypes: true })) { const path = resolve(dir, item.name); if (item.isDirectory()) { await walk(path, depth + 1); } else if (item.isFile() && (item.name.endsWith(".jsonl") || (item.name === "stderr.log" && depth === 1))) found.push(path); } } await walk(root, 0); return found.sort(); }
10
+ async function discoverCaseFiles(root: string): Promise<string[]> { const found: string[] = []; async function walk(dir: string, depth: number) { for (const item of await readdir(dir, { withFileTypes: true })) { const path = resolve(dir, item.name); if (item.isDirectory()) { await walk(path, depth + 1); } else if (item.isFile() && ((item.name.endsWith(".jsonl") && item.name !== "state.jsonl" && !(item.name === "log.jsonl" && depth === 1)) || (item.name === "log.jsonl" && depth === 1))) found.push(path); } } await walk(root, 0); return found.sort(); }
10
11
  function sourceList(count: number, sources: string[]) { return { count, sources: [...new Set(sources)].sort() }; }
11
12
  function accumulate(metric: DoctorCount, value: number, source: string) { metric.count += value; if (value) metric.sources.push(source); }
12
13
  function timestamp(row: Record<string, unknown>) { return typeof row.timestamp === "string" && Number.isFinite(Date.parse(row.timestamp)) ? row.timestamp : undefined; }
@@ -38,14 +39,17 @@ function deriveSession(content: string, id: string): SessionDerivation {
38
39
  }
39
40
 
40
41
  /** Read Pi's retained session directory as the sole raw material for one case. */
41
- export async function loadDoctorCase(runsPath: string): Promise<DoctorCase> {
42
+ export async function loadDoctorCase(runsPath: string, projectRoot?: string): Promise<DoctorCase> {
42
43
  const root = await realpath(runsPath);
44
+ if (projectRoot !== undefined && !physicallyContainedIn(projectRoot, root)) {
45
+ throw new Error("doctor --runs escapes the project root");
46
+ }
43
47
  // Canonical <book>/<ticket>/runs; legacy issues/<n>/runs remains read-only compatible.
44
48
  const match = root.split(sep).join("/").match(/\/\.ak-roles\/books\/[^/]+\/(?:issues\/)?([1-9]\d*)\/runs$/);
45
49
  if (!match) throw new Error("Doctor case must be an .ak-roles/books/<book>/<n>/runs directory");
46
50
  const evidence: DoctorEvidenceEntry[] = [], sessions: DoctorCaseCost["sessions"] = [], statuses: DoctorCaseCost["statuses"] = [], commits: DoctorCaseCost["commits"] = [];
47
51
  const turns: DoctorCount = { count: 0, sources: [] }, calls: DoctorCount = { count: 0, sources: [] }, tokens: DoctorCount = { count: 0, sources: [] };
48
- for (const path of await discoverCaseFiles(root)) { const id = relative(root, path).split(sep).join("/"); const bytes = await readFile(path); const content = bytes.toString("utf8"); const kind = id.endsWith(".jsonl") ? "session" : "stderr"; evidence.push({ id, kind, byteLength: bytes.byteLength, contentLength: content.length, sha256: sha256Hex(bytes), content }); if (kind === "stderr") continue; const result = deriveSession(content, id); sessions.push(result.session); statuses.push(...result.statuses); commits.push(...result.commits); accumulate(turns, result.turns, id); accumulate(calls, result.calls, id); accumulate(tokens, result.tokens, id); }
52
+ for (const path of await discoverCaseFiles(root)) { const id = relative(root, path).split(sep).join("/"); const bytes = await readFile(path); const content = bytes.toString("utf8"); const kind = id.split("/").length === 2 && id.endsWith("/log.jsonl") ? "log" : "session"; evidence.push({ id, kind, byteLength: bytes.byteLength, contentLength: content.length, sha256: sha256Hex(bytes), content }); if (kind === "log") continue; const result = deriveSession(content, id); sessions.push(result.session); statuses.push(...result.statuses); commits.push(...result.commits); accumulate(turns, result.turns, id); accumulate(calls, result.calls, id); accumulate(tokens, result.tokens, id); }
49
53
  const runDirs = (await readdir(root, { withFileTypes: true })).filter((item) => item.isDirectory()).map((item) => item.name).sort(); const legs = evidence.filter((entry) => entry.kind === "session").map((entry) => entry.id); const retryDirs = runDirs.filter((name) => /(?:^|[-_])retry(?:[-_]|$)/i.test(name)); const rawBytes = evidence.filter((entry) => entry.kind === "session").reduce((sum, entry) => sum + entry.byteLength, 0);
50
54
  const cost: DoctorCaseCost = { invocations: sourceList(runDirs.length, runDirs), legs: sourceList(legs.length, legs), modelApiTurns: sourceList(turns.count, turns.sources), outputTokens: sourceList(tokens.count, tokens.sources), toolCalls: sourceList(calls.count, calls.sources), retries: { ...sourceList(retryDirs.length, retryDirs), evidence: "literal run-dir naming" }, statuses, commits, sessions, outputBytes: { ...sourceList(rawBytes, legs), payload: "raw JSONL bytes", providerWireBytes: "unavailable" } };
51
55
  return { version: 1, identity: { issueNumber: Number(match[1]), runsPath: await stableRunsIdentity(root) }, evidence, cost };
@@ -125,7 +125,7 @@ export function createEngineDetourToolDefinition(input: {
125
125
  : undefined;
126
126
  const runId = runDirectory === undefined ? undefined : runIdFromRunDirectory(runDirectory);
127
127
  // Public-invocation scope + selected host from Host envelope only — never
128
- // courtAttemptId, never sidecar file, never pre-spawn invocation.json I/O.
128
+ // courtAttemptId, never sidecar file, never pre-spawn current.json I/O.
129
129
  const invocationScopeId =
130
130
  typeof ctx.invocationScopeId === "string" && ctx.invocationScopeId.trim() !== ""
131
131
  ? ctx.invocationScopeId.trim()
@@ -15,9 +15,15 @@
15
15
  * `ak-role resume`) mints a new one. Never courtAttemptId, never Pi session
16
16
  * toolResult join keys, and never a detour-owned sidecar file.
17
17
  */
18
+ import { readPageSync, RUN_LOG_FILE } from "./run-dossier.ts";
18
19
  import { randomUUID } from "node:crypto";
19
20
  import { readFileSync } from "node:fs";
20
- import { dirname, join } from "node:path";
21
+ import { join } from "node:path";
22
+
23
+ export {
24
+ runDirectoryFromSessionDirectory,
25
+ sessionFileIn as sessionFileFromSessionDirectory,
26
+ } from "./role-run-placement.ts";
21
27
 
22
28
  import { ENGINE_DETOUR_TOOL_NAME } from "./engine-detour.ts";
23
29
  import {
@@ -36,12 +42,11 @@ export const ENGINE_DETOUR_CALL_KIND = "engine-detour-call" as const;
36
42
  export const ENGINE_DETOUR_TOOL_USAGE_FACT_KEY = "engineDetourToolUsage" as const;
37
43
 
38
44
  /**
39
- * Run-relative sitian volume path for one detour call.
45
+ * Run-relative path of the log that holds one detour call's record.
40
46
  * Openable once the run directory is known from the terminal's run id.
41
47
  * Contains no runId bytes itself (#108 + #537).
42
48
  */
43
- export const ENGINE_DETOUR_CALL_RECORD_FILE_RELATIVE =
44
- `session/${ENGINE_DETOUR_CALL_KIND}/records.jsonl` as const;
49
+ export const ENGINE_DETOUR_CALL_RECORD_FILE_RELATIVE = RUN_LOG_FILE;
45
50
 
46
51
  /** stdout UTF-8 byte length (ticket-frozen metric; empty stdout is real 0). */
47
52
  export function engineDetourStdoutByteLength(stdout: string): number {
@@ -246,22 +251,14 @@ export async function readEngineDetourToolUsage(input: {
246
251
  return { callCount: calls.length, calls };
247
252
  }
248
253
 
249
- /** One shared invocation.json reader (ENOENT → undefined; other errors propagate). */
254
+ /** One shared state.jsonl invocation-row reader (absent → undefined; other errors propagate). */
250
255
  function readInvocationRecord(
251
256
  runDirectory: string,
252
257
  ): Record<string, unknown> | undefined {
253
- try {
254
- const raw = JSON.parse(
255
- readFileSync(join(runDirectory, "invocation.json"), "utf8"),
256
- ) as unknown;
257
- return isRecord(raw) ? raw : undefined;
258
- } catch (error) {
259
- if ((error as NodeJS.ErrnoException)?.code === "ENOENT") return undefined;
260
- throw error;
261
- }
258
+ return readPageSync(runDirectory, "invocation");
262
259
  }
263
260
 
264
- /** True when invocation.json carries a non-empty engine axis. */
261
+ /** True when the invocation section carries a non-empty engine axis. */
265
262
  export async function readInvocationEngineMounted(
266
263
  runDirectory: string,
267
264
  ): Promise<boolean> {
@@ -288,16 +285,6 @@ export function withEngineDetourToolUsageFact<
288
285
  };
289
286
  }
290
287
 
291
- /** runDirectory owning a session directory (.../runs/<id>@role/session). */
292
- export function runDirectoryFromSessionDirectory(sessionDirectory: string): string {
293
- return dirname(sessionDirectory);
294
- }
295
-
296
- /** session.jsonl under a session directory. */
297
- export function sessionFileFromSessionDirectory(sessionDirectory: string): string {
298
- return join(sessionDirectory, "session.jsonl");
299
- }
300
-
301
288
  /**
302
289
  * Mint one public-invocation scope id.
303
290
  * Call once at the public-entry boundary — never inside the auto-resume loop.
@@ -13,8 +13,33 @@ import type { PreparedRoleTurn } from "./prepared-role-turn.ts";
13
13
  import { projectThrownFailureLeaf, retainPackageFault } from "./public-cli/settlement.ts";
14
14
  import { describeErrorIdentity } from "./public-cli/run-lifecycle.ts";
15
15
  import { deliveryLimitFromConfig } from "./receipt-delivery-policy.ts";
16
+ import { reportRunRecord } from "./sitian-facade.ts";
16
17
  import { isOneShotWorkerReminderCode } from "./submission-errors.ts";
17
18
 
19
+ /**
20
+ * What one host start was given: one `turn-delivery` row of history.jsonl per start. History is
21
+ * the volume whose write failure does not stop a run (#833), so a row that cannot be
22
+ * written is noted and the host still starts.
23
+ */
24
+ export async function recordTurnDelivery(
25
+ runDirectory: string,
26
+ delivered: { readonly systemPrompt: string; readonly outputSchema?: unknown },
27
+ source: string,
28
+ ): Promise<void> {
29
+ try {
30
+ reportRunRecord(runDirectory, "turn-delivery", {
31
+ systemPrompt: delivered.systemPrompt,
32
+ ...(delivered.outputSchema === undefined ? {} : { outputSchema: delivered.outputSchema }),
33
+ }, source);
34
+ } catch (error) {
35
+ await retainPackageFault({
36
+ runDirectory,
37
+ diagnostic: `turn delivery record failed beside host start: ${describeErrorIdentity(error)}`,
38
+ error,
39
+ });
40
+ }
41
+ }
42
+
18
43
  export type ExternalPreparedTurn = Pick<PreparedRoleTurn, "prompt" | "abortSignal" | "closeRound">;
19
44
 
20
45
  export type ExternalHostRoundOutcome =