@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,4 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { readPageSync, RUN_STATE_FILE } from "./run-dossier.ts";
2
3
  import { readFileSync, writeSync } from "node:fs";
3
4
  import { join } from "node:path";
4
5
  import {
@@ -12,12 +13,14 @@ import {
12
13
 
13
14
  import { Value } from "typebox/value";
14
15
  import { sitianReport } from "./sitian-facade.ts";
16
+ import { sitianReportSafe } from "./host-session-record.ts";
15
17
  import { createSubmissionLedgerHost, sealAcceptedSubmission } from "./submission-ledger.ts";
16
18
  import { registerFiledSubmissionTool, type FiledSubmissionBeforeAccept } from "./filed-submission.ts";
17
19
  import type { RoleSubmissionDeclaration } from "./role-submission-declarations.ts";
18
20
 
19
21
  import { activationTraceRecordSchema, namedActivationCause, type ActivationTraceRecord, type ActivationTraceWriter } from "./activation-trace.ts";
20
22
  import { homeFromRunDirectory } from "./activation-ledger-topology.ts";
23
+ import { isSafePositiveTicketNumber, parseTicketNumber } from "./run-ticket-number.ts";
21
24
  import {
22
25
  durableSessionPointer,
23
26
  resolveBookKeyFromGit,
@@ -87,7 +90,6 @@ import {
87
90
  } from "./diarist-contracts.ts";
88
91
  import { commitDiaristProjection } from "./diarist.ts";
89
92
  import { TicketProvenanceInputError } from "./ticket-provenance.ts";
90
- import { bindTicketNumberOnRunDirectory } from "./public-cli/invocation.ts";
91
93
  import {
92
94
  GATEKEEPER_TOOL_SPEC,
93
95
  type GatekeeperRuntimeDependencies,
@@ -207,12 +209,9 @@ function decodeReviewerAdmittedInputs(getFlag: (name: string) => unknown): Revie
207
209
  authorityRefs = Object.freeze(parsed as string[]);
208
210
  }
209
211
 
210
- let ticketNumber: number | undefined;
211
212
  const rawTicketNumber = getFlag("ak-review-ticket-number");
212
213
  // Shape-invalid flag values do not abort: omit typed candidate; branch→commit→degrade continues.
213
- if (typeof rawTicketNumber === "string" && /^[1-9]\d*$/.test(rawTicketNumber)) {
214
- ticketNumber = Number(rawTicketNumber);
215
- }
214
+ const ticketNumber = parseTicketNumber(rawTicketNumber);
216
215
 
217
216
  const baseRevision = getFlag("ak-review-base");
218
217
  if (typeof baseRevision !== "string" || !baseRevision.trim()) {
@@ -760,15 +759,8 @@ function readDiaristTicketAssertion(
760
759
  }
761
760
  const raw = submitted.ticketNumber;
762
761
  if (raw === null) return { kind: "true-unbound" };
763
- if (typeof raw === "number" && Number.isSafeInteger(raw) && raw >= 1) {
764
- return { kind: "ticket", ticketNumber: raw };
765
- }
766
- if (typeof raw === "string" && /^[1-9]\d*$/.test(raw)) {
767
- const n = Number(raw);
768
- if (Number.isSafeInteger(n) && n >= 1) {
769
- return { kind: "ticket", ticketNumber: n };
770
- }
771
- }
762
+ const ticketNumber = parseTicketNumber(raw);
763
+ if (ticketNumber !== undefined) return { kind: "ticket", ticketNumber };
772
764
  return { kind: "invalid" };
773
765
  }
774
766
 
@@ -781,10 +773,9 @@ function readRoleRunCoordinates(ctx: HostContext, label: string): {
781
773
  } {
782
774
  const runDirectory = runDirectoryFromHostContext(ctx);
783
775
  if (runDirectory === undefined) throw new Error(`${label} requires AK_ROLE_RUN_DIR`);
784
- const admittedPath = join(runDirectory, "admitted-request.json");
785
- const admitted = JSON.parse(readFileSync(admittedPath, "utf8")) as Record<string, unknown>;
786
- if (typeof admitted.projectRoot !== "string" || admitted.projectRoot.trim() === "") {
787
- throw new Error(`${label} admitted-request missing projectRoot (${admittedPath})`);
776
+ const admitted = readPageSync(runDirectory, "admitted");
777
+ if (typeof admitted?.projectRoot !== "string" || admitted.projectRoot.trim() === "") {
778
+ throw new Error(`${label} state.jsonl admitted-request row missing projectRoot (${join(runDirectory, RUN_STATE_FILE)})`);
788
779
  }
789
780
  return {
790
781
  runDirectory,
@@ -802,12 +793,9 @@ function readDiaristRunCoordinates(ctx: HostContext): {
802
793
  readonly boundTicketNumber?: number;
803
794
  } {
804
795
  const coordinates = readRoleRunCoordinates(ctx, "diarist accept");
805
- const bound =
806
- typeof coordinates.admitted.ticketNumber === "number" &&
807
- Number.isSafeInteger(coordinates.admitted.ticketNumber) &&
808
- coordinates.admitted.ticketNumber >= 1
809
- ? coordinates.admitted.ticketNumber
810
- : undefined;
796
+ const bound = isSafePositiveTicketNumber(coordinates.admitted.ticketNumber)
797
+ ? coordinates.admitted.ticketNumber
798
+ : undefined;
811
799
  return {
812
800
  runDirectory: coordinates.runDirectory,
813
801
  projectRoot: coordinates.projectRoot,
@@ -849,11 +837,8 @@ export function createDiaristRoleRuntime(
849
837
  const coords = readDiaristRunCoordinates(ctx);
850
838
  // #836 7.3: pre-bound ticket is material for the LLM, not an override.
851
839
  const ticketNumber = assertion.kind === "ticket" ? assertion.ticketNumber : undefined;
852
- if (ticketNumber !== undefined) {
853
- if (coords.boundTicketNumber === undefined) {
854
- await bindTicketNumberOnRunDirectory(coords.runDirectory, ticketNumber);
855
- }
856
- }
840
+ // The ticket binds in the public call's own process after the turn, from the sealed
841
+ // ticketNumber (settlement seam); this leg never writes the run's current.json.
857
842
  // Strict-schema hosts emit ticketSessions: null for a single ticket.
858
843
  const multiTicket = submitted?.ticketSessions != null;
859
844
  const singleSessions = submitted && !multiTicket
@@ -1344,16 +1329,14 @@ export function createRoleRuntimeExtension(
1344
1329
  attemptPointer: receiptAttemptPointer(runPointer, ctx.invocationScopeId),
1345
1330
  });
1346
1331
  envelopeHost.appendEntry(NO_RECEIPT_LIFECYCLE_ENTRY_TYPE, facts);
1347
- try {
1348
- sitianReport({
1349
- level: "event",
1350
- kind: "no-receipt-lifecycle",
1351
- cwd: ctx.cwd,
1352
- sessionParent: ctx.sessionManager.getSessionFile(),
1353
- payload: facts,
1354
- source: "role-runtime",
1355
- });
1356
- } catch {}
1332
+ sitianReportSafe({
1333
+ level: "event",
1334
+ kind: "no-receipt-lifecycle",
1335
+ cwd: ctx.cwd,
1336
+ sessionParent: ctx.sessionManager.getSessionFile(),
1337
+ payload: facts,
1338
+ source: "role-runtime",
1339
+ });
1357
1340
  }
1358
1341
  }
1359
1342
  return;
@@ -1367,16 +1350,14 @@ export function createRoleRuntimeExtension(
1367
1350
  RECEIPT_DELIVERY_REQUEST_ENTRY,
1368
1351
  scopeId.length > 0 ? { invocationScopeId: scopeId } : undefined,
1369
1352
  );
1370
- try {
1371
- sitianReport({
1372
- level: "event",
1373
- kind: "receipt-delivery",
1374
- cwd: ctx.cwd,
1375
- sessionParent: ctx.sessionManager.getSessionFile(),
1376
- payload: { type: "ak-receipt-delivery-request" },
1377
- source: "role-runtime",
1378
- });
1379
- } catch {}
1353
+ sitianReportSafe({
1354
+ level: "event",
1355
+ kind: "receipt-delivery",
1356
+ cwd: ctx.cwd,
1357
+ sessionParent: ctx.sessionManager.getSessionFile(),
1358
+ payload: { type: "ak-receipt-delivery-request" },
1359
+ source: "role-runtime",
1360
+ });
1380
1361
  envelopeHost.sendMessage({
1381
1362
  customType: "ak-receipt-delivery-prompt",
1382
1363
  content: JSON.stringify(receiptDelivery.deliveryState()),
@@ -1391,16 +1372,14 @@ export function createRoleRuntimeExtension(
1391
1372
  attemptPointer: receiptAttemptPointer(runPointer, ctx.invocationScopeId),
1392
1373
  });
1393
1374
  envelopeHost.appendEntry(NO_RECEIPT_LIFECYCLE_ENTRY_TYPE, facts);
1394
- try {
1395
- sitianReport({
1396
- level: "event",
1397
- kind: "no-receipt-lifecycle",
1398
- cwd: ctx.cwd,
1399
- sessionParent: ctx.sessionManager.getSessionFile(),
1400
- payload: facts,
1401
- source: "role-runtime",
1402
- });
1403
- } catch {}
1375
+ sitianReportSafe({
1376
+ level: "event",
1377
+ kind: "no-receipt-lifecycle",
1378
+ cwd: ctx.cwd,
1379
+ sessionParent: ctx.sessionManager.getSessionFile(),
1380
+ payload: facts,
1381
+ source: "role-runtime",
1382
+ });
1404
1383
  }
1405
1384
  }
1406
1385
  });
@@ -1765,16 +1744,14 @@ export function createRoleRuntimeExtension(
1765
1744
  subjectKey: work.subjectKey,
1766
1745
  };
1767
1746
  envelopeHost.appendEntry(NAVIGATOR_INVOCATION_ENTRY, data);
1768
- try {
1769
- sitianReport({
1770
- level: "event",
1771
- kind: "attendance",
1772
- cwd: ctx.cwd,
1773
- sessionParent: ctx.sessionManager.getSessionFile(),
1774
- payload: { type: NAVIGATOR_INVOCATION_ENTRY, ...data },
1775
- source: "role-runtime",
1776
- });
1777
- } catch {}
1747
+ sitianReportSafe({
1748
+ level: "event",
1749
+ kind: "attendance",
1750
+ cwd: ctx.cwd,
1751
+ sessionParent: ctx.sessionManager.getSessionFile(),
1752
+ payload: { type: NAVIGATOR_INVOCATION_ENTRY, ...data },
1753
+ source: "role-runtime",
1754
+ });
1778
1755
  }
1779
1756
  const activation = navigatorActivation;
1780
1757
  navigatorAttendance = await dependencies.createNavigatorAttendance({
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Names in a run directory (docs/dossier-topology.md, #1161). Constants only, so
3
+ * the appender and the `current.json` renderer can both import them.
4
+ */
5
+ export const RUN_CURRENT_FILE = "current.json" as const;
6
+ export const RUN_HISTORY_FILE = "history.jsonl" as const;
7
+ export const RUN_LOG_FILE = "log.jsonl" as const;
8
+ export const RUN_STATE_FILE = "state.jsonl" as const;
9
+
10
+ /**
11
+ * Record kinds that make a leg's history: they land in `history.jsonl`. The
12
+ * original submission ledger and attempt history, the prompt/schema each turn was
13
+ * started with, and officer pointers.
14
+ */
15
+ export const RUN_HISTORY_KINDS: ReadonlySet<string> = new Set([
16
+ "candidate", "roundContext", "outcome", "sealed", "post-seal-anomaly",
17
+ "attempt-history", "turn-delivery", "officer-pointer",
18
+ ]);
19
+
20
+ /** History record kind of a booked officer pointer (appender routes it to history.jsonl). */
21
+ export const OFFICER_POINTER_RECORD_KIND = "officer-pointer" as const;
22
+
23
+ /**
24
+ * The facts a run cannot go on without land in `state.jsonl`: the four whole pages only the
25
+ * public call knows, and the native session id a host binds (a resume needs it). A
26
+ * different volume than the submission ledger and than the auxiliary stream, as the
27
+ * files they replaced were (a page write failure throws; a ledger volume that cannot
28
+ * be written does not stop a resume; a stream line that cannot be written is noted).
29
+ * Every other kind lands in `log.jsonl`.
30
+ */
31
+ export const RUN_STATE_KINDS: ReadonlySet<string> = new Set([
32
+ "invocation", "admitted-request", "run-state", "terminal", "host-session-id",
33
+ ]);
@@ -0,0 +1,357 @@
1
+ /**
2
+ * The per-leg dossier files (docs/dossier-topology.md, #1161): `current.json`,
3
+ * `history.jsonl` (the submission ledger and attempt history), `state.jsonl` (the four
4
+ * whole pages) and `log.jsonl` (the rest), all append-only and written through the sitian appender, and the one host original.
5
+ *
6
+ * Facts are rows first. The four pages only the public call knows — identity
7
+ * (`invocation`), admission (`admitted-request`), lifecycle (`run-state`) and the
8
+ * terminal — are each rewritten whole by appending one `state.jsonl` row whose
9
+ * payload is that page. `current.json` is a rendering: the last row of each kind
10
+ * in the three row files, read at the moment of writing, never a value
11
+ * carried from an earlier snapshot or from the previous `current.json`.
12
+ *
13
+ * Two public calls may exist on one leg (the live leg and a manual resume) and
14
+ * both write `current.json`; there is no lock. After writing, a writer checks that
15
+ * the row files are still the size it rendered from and, if rows were appended
16
+ * meanwhile, renders again — so whichever writer finishes last has rendered from
17
+ * every row, and the file ends equal to a fresh rendering.
18
+ */
19
+ import { randomUUID } from "node:crypto";
20
+ import { readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
21
+ import { basename, join } from "node:path";
22
+
23
+ import { sessionFileOf } from "./role-run-placement.ts";
24
+ import { RUN_CURRENT_FILE, RUN_HISTORY_FILE, RUN_LOG_FILE, RUN_STATE_FILE, OFFICER_POINTER_RECORD_KIND } from "./run-dossier-files.ts";
25
+ import { appendSitianRecord } from "./sitian-appender.ts";
26
+ import { parseSitianRecordText } from "./sitian-reader.ts";
27
+ import { isMissingPathError, isRecord } from "./unknown-value.ts";
28
+
29
+ export { RUN_CURRENT_FILE, RUN_HISTORY_FILE, RUN_LOG_FILE, RUN_STATE_FILE };
30
+
31
+ /** Sections of `current.json` that are a whole page appended as one row. */
32
+ export type CurrentSection = "invocation" | "admitted" | "runState" | "terminal";
33
+ /** Sections rendered from rows other seams append. */
34
+ export type DerivedSection = "host" | "submission" | "officers";
35
+
36
+ /** The row kind of each whole-page section: the name of the file it replaced. */
37
+ const PAGE_ROW_KIND: Readonly<Record<CurrentSection, string>> = {
38
+ invocation: "invocation",
39
+ admitted: "admitted-request",
40
+ runState: "run-state",
41
+ terminal: "terminal",
42
+ };
43
+
44
+ export function runCurrentPath(runDirectory: string): string {
45
+ return join(runDirectory, RUN_CURRENT_FILE);
46
+ }
47
+
48
+ /** The whole `current.json`, or undefined when absent (ENOENT only). */
49
+ export function readCurrentSync(runDirectory: string): Record<string, unknown> | undefined {
50
+ let raw: string;
51
+ try {
52
+ raw = readFileSync(runCurrentPath(runDirectory), "utf8");
53
+ } catch (error) {
54
+ if (isMissingPathError(error)) return undefined;
55
+ throw error;
56
+ }
57
+ const parsed: unknown = JSON.parse(raw);
58
+ if (!isRecord(parsed)) throw new TypeError(`${RUN_CURRENT_FILE} is not an object: ${runCurrentPath(runDirectory)}`);
59
+ return parsed;
60
+ }
61
+
62
+ /** One section of `current.json` (the rendering); undefined when the file or the section is absent. */
63
+ export function readSectionSync(
64
+ runDirectory: string,
65
+ section: CurrentSection | DerivedSection,
66
+ ): Record<string, unknown> | undefined {
67
+ const value = readCurrentSync(runDirectory)?.[section];
68
+ if (value === undefined) return undefined;
69
+ if (!isRecord(value)) {
70
+ throw new TypeError(`${RUN_CURRENT_FILE} ${section} section is not an object: ${runCurrentPath(runDirectory)}`);
71
+ }
72
+ return value;
73
+ }
74
+
75
+ type RowFile = {
76
+ readonly rows: Record<string, unknown>[];
77
+ /** Physical on-disk byte length of this read — never a re-encoded string length. */
78
+ readonly bytes: number;
79
+ /** The error code of a read that failed for a reason other than the file being absent. */
80
+ readonly fault?: string;
81
+ /** Canonical malformed-line diagnostics from the sole Sitian decoder. */
82
+ readonly diagnostics?: readonly { readonly line: number; readonly error: string }[];
83
+ };
84
+
85
+ /**
86
+ * The rows of one run file as of this read, with the physical byte length they came from.
87
+ * Missing file → empty. IO fault → no rows + fault code. Damaged lines keep the sole
88
+ * Sitian decoder's diagnostics and do not invent a second skip parser.
89
+ */
90
+ function readRowFile(path: string): RowFile {
91
+ let buffer: Buffer;
92
+ try {
93
+ buffer = readFileSync(path);
94
+ } catch (error) {
95
+ if (isMissingPathError(error)) return { rows: [], bytes: 0 };
96
+ return { rows: [], bytes: 0, fault: String((error as NodeJS.ErrnoException).code ?? (error as Error).name) };
97
+ }
98
+ const { records, diagnostics } = parseSitianRecordText(buffer.toString("utf8"));
99
+ return {
100
+ rows: [...records] as Record<string, unknown>[],
101
+ bytes: buffer.length,
102
+ ...(diagnostics.length > 0
103
+ ? { diagnostics: diagnostics.map((d) => ({ line: d.line, error: d.error })) }
104
+ : {}),
105
+ };
106
+ }
107
+
108
+ function fileBytes(path: string): number | undefined {
109
+ try {
110
+ return statSync(path).size;
111
+ } catch (error) {
112
+ return isMissingPathError(error) ? 0 : undefined;
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Payload of the last row of `kind`, or undefined when no such row exists.
118
+ * Non-object payloads are returned as-is: a later illegal whole page must not
119
+ * be washed into an older valid page or into absence.
120
+ */
121
+ function lastPayloadOfKind(rows: readonly Record<string, unknown>[], kind: string): unknown {
122
+ for (let index = rows.length - 1; index >= 0; index -= 1) {
123
+ const row = rows[index]!;
124
+ if (row.kind === kind) return row.payload;
125
+ }
126
+ return undefined;
127
+ }
128
+
129
+ /** Last whole-page payload as an object, or undefined when absent; illegal values throw. */
130
+ function lastPagePayload(
131
+ rows: readonly Record<string, unknown>[],
132
+ kind: string,
133
+ runDirectory: string,
134
+ ): Record<string, unknown> | undefined {
135
+ const payload = lastPayloadOfKind(rows, kind);
136
+ if (payload === undefined) return undefined;
137
+ if (!isRecord(payload)) {
138
+ throw new TypeError(`${RUN_STATE_FILE} ${kind} payload is not an object: ${runDirectory}`);
139
+ }
140
+ return payload;
141
+ }
142
+
143
+ /**
144
+ * Latest officer-pointer payload per officer from already-decoded history rows.
145
+ * Sole reducer for the "last booking wins per officer" rule (#753 / #1161 officers).
146
+ * A booked officer-pointer row with a non-object payload or non-string officer is
147
+ * damaged topology — never silently omitted into lawful zero rounds (#1161 O1).
148
+ * Absence of any officer-pointer row remains lawful empty.
149
+ * Invalid input is a TypeError (same class as other dossier illegal payloads).
150
+ * Control readers (`readBookedOfficerPointers`) let this throw into the existing
151
+ * auditor-roles unreadable seam; `render` catches only that TypeError and marks
152
+ * `history.jsonl` unreadable so other reachable facts stay in current.json.
153
+ */
154
+ export function latestOfficerPointersFromRecords(
155
+ records: readonly { readonly kind?: unknown; readonly payload?: unknown }[],
156
+ ): Readonly<Record<string, unknown>> {
157
+ const latest: Record<string, unknown> = {};
158
+ for (const record of records) {
159
+ if (record.kind !== OFFICER_POINTER_RECORD_KIND) continue;
160
+ if (!isRecord(record.payload) || typeof record.payload.officer !== "string") {
161
+ throw new TypeError("officer-pointer payload is not a record with string officer");
162
+ }
163
+ latest[record.payload.officer] = record.payload;
164
+ }
165
+ return latest;
166
+ }
167
+
168
+ /** `current.json` as the rows say right now. */
169
+ function render(
170
+ runDirectory: string,
171
+ history: readonly Record<string, unknown>[],
172
+ state: readonly Record<string, unknown>[],
173
+ log: readonly Record<string, unknown>[],
174
+ unreadable: Record<string, string>,
175
+ ): Record<string, unknown> {
176
+ const whole: Record<string, unknown> = {};
177
+ for (const section of ["invocation", "admitted", "runState", "terminal"] as const) {
178
+ const page = lastPayloadOfKind(state, PAGE_ROW_KIND[section]);
179
+ if (page !== undefined) whole[section] = page;
180
+ }
181
+ let latest: Record<string, unknown> | undefined;
182
+ for (const row of history) {
183
+ const payload = isRecord(row.payload) ? row.payload : undefined;
184
+ if (row.kind === "sealed" && payload !== undefined) {
185
+ latest = { toolCallId: payload.toolCallId, role: payload.role, accepted: payload.accepted, at: row.timestamp };
186
+ }
187
+ }
188
+ // Control readers still fail closed via latestOfficerPointersFromRecords.
189
+ // Render must keep other reachable facts and surface damage on the existing
190
+ // file unreadable seam — never abort the whole current.json (#1161 O1).
191
+ // Catch only the reducer's known TypeError; unknown throws stay unknown
192
+ // (never wash into malformed by free-text message).
193
+ let officers: Readonly<Record<string, unknown>> = {};
194
+ try {
195
+ officers = latestOfficerPointersFromRecords(history);
196
+ } catch (error) {
197
+ if (!(error instanceof TypeError)) throw error;
198
+ if (unreadable[RUN_HISTORY_FILE] === undefined) unreadable[RUN_HISTORY_FILE] = "malformed";
199
+ }
200
+ const sessions: Record<string, unknown> = {};
201
+ for (const row of state) {
202
+ const payload = isRecord(row.payload) ? row.payload : undefined;
203
+ if (row.kind === "host-session-id" && payload !== undefined && typeof payload.host === "string") {
204
+ sessions[payload.host] = payload.sessionId;
205
+ }
206
+ }
207
+ let original: unknown;
208
+ for (const row of log) {
209
+ const payload = isRecord(row.payload) ? row.payload : undefined;
210
+ if (row.kind === "host-session" && payload?.type === "native-session-copy" && typeof payload.landingPath === "string") {
211
+ original = payload.landingPath;
212
+ }
213
+ }
214
+ // The copy row keeps the absolute path it was written to; a run that was filed under its ticket
215
+ // since lives elsewhere. The original is projected from where this run directory is now.
216
+ if (typeof original === "string") original = join(runDirectory, "session", basename(original));
217
+ whole.host = { sessions, ...(original === undefined ? {} : { original }) };
218
+ whole.submission = latest === undefined ? {} : { latest };
219
+ whole.officers = officers;
220
+ if (Object.keys(unreadable).length > 0) whole.unreadable = unreadable;
221
+ return whole;
222
+ }
223
+
224
+ function writeWhole(runDirectory: string, whole: Record<string, unknown>): void {
225
+ const destination = runCurrentPath(runDirectory);
226
+ const body = `${JSON.stringify(whole, null, 2)}\n`;
227
+ // Same-directory temp + rename: a concurrent reader (the analyst scans live
228
+ // runs) never sees a torn file. A run directory that cannot take a new entry
229
+ // (settlement after a chmod) still accepts an in-place overwrite of the
230
+ // writable file, so that case degrades instead of failing.
231
+ const temporary = join(runDirectory, `.${RUN_CURRENT_FILE}-${randomUUID()}.tmp`);
232
+ try {
233
+ writeFileSync(temporary, body, "utf8");
234
+ renameSync(temporary, destination);
235
+ } catch (error) {
236
+ rmSync(temporary, { force: true });
237
+ const code = (error as NodeJS.ErrnoException).code;
238
+ if (code !== "EACCES" && code !== "EPERM" && code !== "EROFS") throw error;
239
+ writeFileSync(destination, body, "utf8");
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Process-local once-declare for unreadable row files (Node.js emitWarning
245
+ * "Avoiding duplicate warnings": once per process, not keyed off the durable
246
+ * rendering). Key = runDirectory + file name; value = last declared code.
247
+ */
248
+ const declaredUnreadable = new Map<string, string>();
249
+
250
+ /**
251
+ * Write `current.json` from the rows as read now; then, if a row file grew since that read,
252
+ * render again. The last appender renders after its own append, so the loop ends when the
253
+ * appends stop. A row file that cannot be read is rendered as having no rows and named in
254
+ * the rendering's `unreadable` marker (file to error code), so a stale or partial rendering
255
+ * is never silent. Declaration does not read the previous rendering: a damaged
256
+ * `current.json` must not block fact append, re-render, or public resume.
257
+ */
258
+ export function renderCurrentSync(runDirectory: string): void {
259
+ const paths = [RUN_HISTORY_FILE, RUN_STATE_FILE, RUN_LOG_FILE].map((name) => join(runDirectory, name));
260
+ for (;;) {
261
+ const [history, state, log] = paths.map(readRowFile) as [RowFile, RowFile, RowFile];
262
+ const unreadable: Record<string, string> = {};
263
+ for (const [name, file] of [[RUN_HISTORY_FILE, history], [RUN_STATE_FILE, state], [RUN_LOG_FILE, log]] as const) {
264
+ if (file.fault !== undefined) unreadable[name] = file.fault;
265
+ else if (file.diagnostics !== undefined && file.diagnostics.length > 0) unreadable[name] = "malformed";
266
+ }
267
+ // Render may add officer-pointer TypeError damage onto the same unreadable map;
268
+ // declare after render so that cause enters the existing diagnostics/unreadable seam.
269
+ const whole = render(runDirectory, history.rows, state.rows, log.rows, unreadable);
270
+ for (const [name, code] of Object.entries(unreadable)) {
271
+ const key = `${runDirectory}\0${name}`;
272
+ if (declaredUnreadable.get(key) === code) continue;
273
+ const detail = code === "malformed"
274
+ ? `${name} has malformed row(s); current.json keeps reachable rows and retains the diagnostic`
275
+ : `${name} cannot be read (${code}); current.json is rendered without it`;
276
+ process.stderr.write(`[run-dossier] ${detail}: ${runDirectory}\n`);
277
+ declaredUnreadable.set(key, code);
278
+ }
279
+ writeWhole(runDirectory, whole);
280
+ // Growth uses physical bytes; a malformed diagnostic must not skip the check.
281
+ const grew = [history, state, log].some((file, index) => file.fault === undefined && fileBytes(paths[index]!) !== file.bytes);
282
+ if (!grew) return;
283
+ }
284
+ }
285
+
286
+ function appendPageRow(runDirectory: string, section: CurrentSection, page: Record<string, unknown>): void {
287
+ appendSitianRecord({
288
+ level: "event",
289
+ kind: PAGE_ROW_KIND[section],
290
+ sessionParent: sessionFileOf(runDirectory),
291
+ source: "run-dossier",
292
+ payload: page,
293
+ });
294
+ }
295
+
296
+ /**
297
+ * The fact itself: a whole-page section as its last row says. The public call
298
+ * reads its own facts here, not from the rendering, so a rendering that is stale
299
+ * or refused changes nothing. Absence of a row is absence — never a `current.json`
300
+ * fallback. A row file that cannot be read fails closed. An illegal (non-object)
301
+ * latest payload keeps its failure identity; it is not filtered into absence.
302
+ */
303
+ export function readPageSync(runDirectory: string, section: CurrentSection): Record<string, unknown> | undefined {
304
+ const state = readRowFile(join(runDirectory, RUN_STATE_FILE));
305
+ if (state.fault !== undefined) throw new Error(`${RUN_STATE_FILE} is unreadable (${state.fault}): ${runDirectory}`);
306
+ // Control plane fails closed on damaged rows: reachable older pages must not
307
+ // silently drive lifecycle (#1161 C3). Rendering still keeps reachable facts.
308
+ if (state.diagnostics !== undefined && state.diagnostics.length > 0) {
309
+ throw new Error(
310
+ `${RUN_STATE_FILE} has malformed row(s); refusing stale control-plane page: ${runDirectory}`,
311
+ );
312
+ }
313
+ return lastPagePayload(state.rows, PAGE_ROW_KIND[section], runDirectory);
314
+ }
315
+
316
+ /** Every row of history.jsonl, in order; a file that cannot be read throws. */
317
+ export function readHistoryRowsSync(runDirectory: string): readonly Record<string, unknown>[] {
318
+ const history = readRowFile(join(runDirectory, RUN_HISTORY_FILE));
319
+ if (history.fault !== undefined) throw new Error(`${RUN_HISTORY_FILE} is unreadable (${history.fault}): ${runDirectory}`);
320
+ return history.rows;
321
+ }
322
+
323
+ /** Every row of state.jsonl, in order; a file that cannot be read throws. */
324
+ export function readStateRowsSync(runDirectory: string): readonly Record<string, unknown>[] {
325
+ const state = readRowFile(join(runDirectory, RUN_STATE_FILE));
326
+ if (state.fault !== undefined) throw new Error(`${RUN_STATE_FILE} is unreadable (${state.fault}): ${runDirectory}`);
327
+ return state.rows;
328
+ }
329
+
330
+ /** Rewrite one section whole: append its row, then render `current.json`. */
331
+ export function writeSectionSync(
332
+ runDirectory: string,
333
+ section: CurrentSection,
334
+ value: Record<string, unknown>,
335
+ ): void {
336
+ appendPageRow(runDirectory, section, value);
337
+ renderCurrentSync(runDirectory);
338
+ }
339
+
340
+ /**
341
+ * Let the caller keep its field rules for one existing section: the update
342
+ * receives the last row's page. `undefined` means no change. A missing section
343
+ * is an error: admission writes it first.
344
+ */
345
+ export function updateSectionSync(
346
+ runDirectory: string,
347
+ section: CurrentSection,
348
+ update: (current: Record<string, unknown>) => Record<string, unknown> | undefined,
349
+ ): void {
350
+ const current = readPageSync(runDirectory, section);
351
+ if (current === undefined) {
352
+ throw new Error(`${RUN_STATE_FILE} has no ${PAGE_ROW_KIND[section]} row: ${runDirectory}`);
353
+ }
354
+ const next = update(current);
355
+ if (next === undefined) return;
356
+ writeSectionSync(runDirectory, section, next);
357
+ }