@akagilnc/pi-workflow-roles 0.1.5504 → 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 (89) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/dist/acp-host/production-host.js +5965 -5994
  4. package/dist/analyst-gate-cycles-read.js +23 -55
  5. package/dist/analyst-ledger.js +21 -35
  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-pointer.js +38 -30
  9. package/dist/auditor-dossier-tool.js +4 -3
  10. package/dist/auditor-soul.js +3 -11
  11. package/dist/doctor-evidence.js +3 -3
  12. package/dist/engine-detour-usage.js +6 -14
  13. package/dist/headless-host/production-host.js +5316 -5324
  14. package/dist/host-descriptions.js +0 -4
  15. package/dist/host-session-record.js +266 -267
  16. package/dist/migrate-book-topology.js +1314 -696
  17. package/dist/notary-source-run.js +1 -1
  18. package/dist/packaged-role-registry.js +5 -44
  19. package/dist/pi/role-turn-host.js +53 -3
  20. package/dist/public-cli/auto-resume.js +8 -47
  21. package/dist/public-cli/instruction-seat-run.js +5 -3
  22. package/dist/public-cli/invocation.js +51 -56
  23. package/dist/public-cli/main.js +1916 -1748
  24. package/dist/public-cli/post-admission.js +96 -55
  25. package/dist/public-cli/run-lifecycle.js +27 -53
  26. package/dist/public-cli/settlement.js +157 -302
  27. package/dist/role-run-placement.js +1 -11
  28. package/dist/role-run-relocation.js +72 -50
  29. package/dist/run-dossier-files.js +30 -0
  30. package/dist/run-dossier.js +322 -0
  31. package/dist/run-terminal-artifacts.js +68 -197
  32. package/dist/run-ticket-number.js +16 -3
  33. package/dist/session-identity.js +49 -18
  34. package/dist/sitian-appender.js +13 -16
  35. package/dist/sitian-facade.js +11 -0
  36. package/dist/sitian-reader.js +44 -29
  37. package/dist/submission-gate.js +1 -1
  38. package/dist/ticket-provenance.js +22 -7
  39. package/package.json +1 -1
  40. package/scripts/build-package.mjs +2 -0
  41. package/src/acp-host/production-host.ts +1 -1
  42. package/src/acp-host/role-turn-host.ts +9 -2
  43. package/src/analyst-gate-cycles-read.ts +26 -60
  44. package/src/analyst-ledger.ts +27 -42
  45. package/src/analyst-metric-families/acceptance-success-rework.ts +24 -1
  46. package/src/analyst-metric-families/gate-cycles.ts +1 -1
  47. package/src/analyst-metric-families/round-timeline.ts +2 -5
  48. package/src/analyst-page.ts +3 -3
  49. package/src/archivist-record-entry.ts +2 -2
  50. package/src/archivist-record-pointer.ts +50 -36
  51. package/src/auditor-dossier-tool.ts +7 -5
  52. package/src/auditor-soul.ts +3 -9
  53. package/src/book-topology-migration-placement.ts +16 -3
  54. package/src/book-topology-record-class-migrators.ts +4 -2
  55. package/src/book-topology-runs-migrator.ts +6 -3
  56. package/src/doctor-contracts.ts +1 -1
  57. package/src/doctor-evidence.ts +2 -2
  58. package/src/engine-detour-tool.ts +1 -1
  59. package/src/engine-detour-usage.ts +6 -16
  60. package/src/external-host-turn-loop.ts +25 -0
  61. package/src/headless-host/production-host.ts +1 -4
  62. package/src/headless-host/role-turn-host.ts +54 -20
  63. package/src/host-descriptions.ts +0 -6
  64. package/src/host-session-record.ts +224 -140
  65. package/src/notary-source-run.ts +1 -1
  66. package/src/packaged-role-registry.ts +1 -50
  67. package/src/pi/adapter.ts +50 -7
  68. package/src/pi/role-turn-host.ts +51 -3
  69. package/src/public-cli/auto-resume.ts +7 -43
  70. package/src/public-cli/instruction-seat-run.ts +9 -2
  71. package/src/public-cli/invocation.ts +61 -87
  72. package/src/public-cli/post-admission.ts +111 -72
  73. package/src/public-cli/run-lifecycle.ts +27 -72
  74. package/src/public-cli/settlement.ts +193 -418
  75. package/src/public-cli/terminal.ts +1 -1
  76. package/src/role-run-placement.ts +2 -13
  77. package/src/role-run-relocation.ts +71 -68
  78. package/src/role-runtime.ts +6 -10
  79. package/src/run-dossier-files.ts +33 -0
  80. package/src/run-dossier.ts +357 -0
  81. package/src/run-terminal-artifacts.ts +83 -237
  82. package/src/run-ticket-number.ts +41 -11
  83. package/src/session-identity.ts +52 -20
  84. package/src/sitian-appender.ts +17 -18
  85. package/src/sitian-facade.ts +22 -0
  86. package/src/sitian-reader.ts +62 -32
  87. package/src/submission-gate.ts +1 -1
  88. package/src/ticket-provenance.ts +20 -7
  89. package/src/ticket-trajectory.ts +10 -10
@@ -1,226 +1,97 @@
1
1
  /**
2
- * Canonical layout and reader for run-directory typed terminal artifacts.
3
- * Settlement publish*Artifacts uses these names (report.json / error.json /
4
- * audit-incomplete.json under artifacts/, plus durable failure fallbacks).
5
- * The reader only checks presence and structural
2
+ * The leg's terminal: the `terminal` section of current.json (#1161), written
3
+ * whole by settlement, last write wins. `report` = an accepted / escalated
4
+ * receipt, `error` = a controlled failure, `no_receipt` = the leg ended with no
5
+ * accepted receipt (#836). The reader only checks presence and structural
6
6
  * readability — it does not re-derive role outcomes or invent a second
7
7
  * candidate algorithm.
8
8
  */
9
- import { randomUUID } from "node:crypto";
10
- import { readdir, readFile } from "node:fs/promises";
11
- import { basename, dirname, join } from "node:path";
12
- import { parseRunLeaf, roleRunArtifactsDirectory } from "./role-run-placement.js";
13
- import { isRecord, errorText, isMissingPathError } from "./unknown-value.js";
14
- export const RUN_TERMINAL_REPORT_FILE = "report.json";
15
- export const RUN_TERMINAL_ERROR_FILE = "error.json";
16
- export const RUN_TERMINAL_EVIDENCE_FILE = "evidence.json";
17
- export const RUN_TERMINAL_ERROR_SETTLEMENT_FILE = "error.settlement.json";
18
- export const RUN_TERMINAL_ARTIFACT_FILES = [
19
- RUN_TERMINAL_REPORT_FILE,
20
- RUN_TERMINAL_ERROR_FILE,
21
- "audit-incomplete.json",
22
- ];
9
+ import { basename } from "node:path";
10
+ import { parseRunLeaf } from "./role-run-placement.js";
11
+ import { readHistoryRowsSync, readSectionSync, writeSectionSync } from "./run-dossier.js";
12
+ import { isRecord } from "./unknown-value.js";
23
13
  /**
24
- * Fixed durable failure paths publishFailureArtifacts may settle when the
25
- * conventional artifacts/error.json name cannot be written. Shared face so the
26
- * reader follows the publisher — not a parallel search algorithm.
27
- * Relative to the run directory.
14
+ * Terminal body field naming the `attempt-history` row this settlement appended.
15
+ * Readers resolve submitted payloads by this identity (#1161 甲); they must not
16
+ * pick the last history row of the same role.
28
17
  */
29
- export const RUN_TERMINAL_ERROR_FALLBACK_RELATIVE_PATHS = [
30
- `artifacts/${RUN_TERMINAL_ERROR_SETTLEMENT_FILE}`,
31
- RUN_TERMINAL_ERROR_SETTLEMENT_FILE,
32
- ];
33
- /** Publisher and reader share the open-ended error face stem. */
34
- export const UNIQUE_ERROR_FALLBACK_STEM = "error";
35
- export function uniqueErrorFallbackName() {
36
- return `${UNIQUE_ERROR_FALLBACK_STEM}.${randomUUID()}.json`;
18
+ export const ATTEMPT_HISTORY_IDENTITY_FIELD = "attemptHistoryIdentity";
19
+ /**
20
+ * Terminal body field naming the court that wrote this terminal (#1161 R2).
21
+ * Historical `state.jsonl` terminal rows keep it so a later re-projection of the
22
+ * same court can recover that court's `attemptHistoryIdentity` without reading
23
+ * the leg's latest terminal or guessing by payload.
24
+ */
25
+ export const TERMINAL_COURT_ATTEMPT_FIELD = "courtAttemptId";
26
+ /** Replace the leg's terminal. */
27
+ export function writeRunTerminal(runDirectory, face, body) {
28
+ writeSectionSync(runDirectory, "terminal", { face, at: new Date().toISOString(), body });
37
29
  }
38
- const UNIQUE_ERROR_FALLBACK_NAME = new RegExp(`^${UNIQUE_ERROR_FALLBACK_STEM}\\.[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\\.json$`, "i");
39
30
  /**
40
- * Minimum producer-owned face shared by settlement terminal artifacts
41
- * (report / error / audit-incomplete). Consumer-driven: enough to identify a
42
- * usable typed terminal artifact; null, arrays, primitives, and role-less
43
- * objects are unreadable (ADR 0043).
31
+ * Read the leg's terminal. Minimum producer-owned face: a typed object body
32
+ * with a nonblank `role` (ADR 0043); anything else is unreadable. Absence is a
33
+ * valid no-receipt state. A damaged current.json throws.
44
34
  */
45
- function readUsableTerminalArtifactBody(body) {
46
- if (body === null) {
47
- return { ok: false, reason: "terminal artifact JSON value is null" };
35
+ export function readRunTerminal(runDirectory) {
36
+ const terminal = readSectionSync(runDirectory, "terminal");
37
+ if (terminal === undefined)
38
+ return { status: "absent" };
39
+ const { face, body } = terminal;
40
+ if (face !== "report" && face !== "error" && face !== "no_receipt") {
41
+ return { status: "unreadable", reason: "terminal section has no known face" };
48
42
  }
49
43
  if (!isRecord(body)) {
50
- return {
51
- ok: false,
52
- reason: `terminal artifact JSON value is not a typed object (${Array.isArray(body) ? "array" : typeof body})`,
53
- };
44
+ return { status: "unreadable", reason: "terminal body is not a typed object" };
54
45
  }
55
46
  if (typeof body.role !== "string" || body.role.trim() === "") {
56
- return {
57
- ok: false,
58
- reason: "terminal artifact missing nonblank producer-owned role field",
59
- };
47
+ return { status: "unreadable", reason: "terminal body missing nonblank producer-owned role field" };
60
48
  }
61
- return { ok: true, body };
49
+ if (face !== "report")
50
+ return { status: "present", face, body };
51
+ const resolved = withSubmittedPayloads(runDirectory, body);
52
+ if (resolved.status === "unreadable")
53
+ return resolved;
54
+ return { status: "present", face, body: resolved.body };
62
55
  }
63
- async function readTerminalArtifactAtPath(path, file) {
64
- let raw;
65
- try {
66
- raw = await readFile(path, "utf8");
67
- }
68
- catch (error) {
69
- if (isMissingPathError(error))
70
- return undefined;
71
- return {
72
- status: "unreadable",
73
- file,
74
- path,
75
- reason: errorText(error),
76
- };
77
- }
78
- let parsed;
79
- try {
80
- parsed = JSON.parse(raw);
81
- }
82
- catch (error) {
83
- return {
84
- status: "unreadable",
85
- file,
86
- path,
87
- reason: error instanceof Error
88
- ? error.message
89
- : `terminal artifact JSON parse failed: ${String(error)}`,
90
- };
56
+ /**
57
+ * The report's outcome carries the verdict facts only; the role's submitted payloads are not
58
+ * stored a second time. A reader that wants them takes them from the attempt-history row this
59
+ * settlement named on the terminal body (`attemptHistoryIdentity`). When payloads are absent
60
+ * and that identity is missing or cannot supply them, the gap is unreadable — never a silent
61
+ * present body (#1161 R2-gap).
62
+ */
63
+ function withSubmittedPayloads(runDirectory, body) {
64
+ const outcome = body.outcome;
65
+ if (!isRecord(outcome) || outcome.payloads !== undefined) {
66
+ return { status: "present", body };
91
67
  }
92
- const usable = readUsableTerminalArtifactBody(parsed);
93
- if (!usable.ok) {
68
+ const identity = body[ATTEMPT_HISTORY_IDENTITY_FIELD];
69
+ if (typeof identity !== "string" || identity.trim() === "") {
94
70
  return {
95
71
  status: "unreadable",
96
- file,
97
- path,
98
- reason: usable.reason,
72
+ reason: "terminal report has no payloads and no attemptHistoryIdentity",
99
73
  };
100
74
  }
101
- return { status: "present", file, path, body: usable.body };
102
- }
103
- async function listUniqueErrorFallbackPaths(directories) {
104
- const found = [];
105
- for (const dir of directories) {
106
- let names;
107
- try {
108
- names = await readdir(dir);
109
- }
110
- catch (error) {
111
- if (isMissingPathError(error))
112
- continue;
113
- throw error;
114
- }
115
- for (const name of names.sort((a, b) => a.localeCompare(b))) {
116
- if (!UNIQUE_ERROR_FALLBACK_NAME.test(name))
117
- continue;
118
- found.push(join(dir, name));
75
+ for (const row of readHistoryRowsSync(runDirectory)) {
76
+ if (row.kind !== "attempt-history" || row.identity !== identity || !isRecord(row.payload))
77
+ continue;
78
+ const attempt = row.payload.outcome;
79
+ if (isRecord(attempt) && Array.isArray(attempt.payloads)) {
80
+ return {
81
+ status: "present",
82
+ body: { ...body, outcome: { ...outcome, payloads: attempt.payloads } },
83
+ };
119
84
  }
120
85
  }
121
- return found;
86
+ return {
87
+ status: "unreadable",
88
+ reason: `terminal attemptHistoryIdentity ${identity} has no readable attempt-history payloads`,
89
+ };
122
90
  }
123
91
  /**
124
- * Publisher run-directory face is `<runId>@<role>`. Parent-directory unique
125
- * fallbacks are shared across sibling runs, so binding uses this runId only.
92
+ * Run-directory face is `<runId>@<role>`.
126
93
  * Sole authority for runDirectory → runId (last `@` split).
127
94
  */
128
95
  export function runIdFromRunDirectory(runDirectory) {
129
96
  return parseRunLeaf(basename(runDirectory))?.runId;
130
97
  }
131
- /**
132
- * Shared parent-directory unique fallback may be adopted only when the
133
- * publisher-owned body.runId equals this run directory's runId. Same-run
134
- * artifactsDir / runDirectory candidates keep path ownership and skip this.
135
- * expectedRunId undefined (unparseable run dir) → never bound.
136
- */
137
- function presentUniqueFallbackBoundToRun(body, expectedRunId) {
138
- if (expectedRunId === undefined)
139
- return false;
140
- return typeof body.runId === "string" && body.runId === expectedRunId;
141
- }
142
- /**
143
- * Sole authority for seam-owned unique error.<uuid>.json candidates.
144
- * Used by both clearOpposite (settlement) and readRunTerminalArtifact — do not
145
- * re-enumerate the same-run / parent unique set elsewhere.
146
- * Ownership:
147
- * - same-run dirs (artifacts/, runDir): path ownership — all unique names
148
- * - parent runs/: only body.runId-bound faces; unparseable runId or unreadable body → none
149
- */
150
- export async function listSeamOwnedUniqueErrorFacePaths(runDirectory) {
151
- const artifactsDir = roleRunArtifactsDirectory(runDirectory);
152
- const owned = await listUniqueErrorFallbackPaths([
153
- artifactsDir,
154
- runDirectory,
155
- ]);
156
- const expectedRunId = runIdFromRunDirectory(runDirectory);
157
- for (const path of await listUniqueErrorFallbackPaths([dirname(runDirectory)])) {
158
- const read = await readTerminalArtifactAtPath(path, "error.json");
159
- if (read === undefined || read.status !== "present")
160
- continue;
161
- if (!presentUniqueFallbackBoundToRun(read.body, expectedRunId))
162
- continue;
163
- owned.push(path);
164
- }
165
- return owned;
166
- }
167
- /**
168
- * Publish contract (#953): only failure publish continues after clearOpposite
169
- * failure, so a multi-class residue (residual report/audit beside a new error
170
- * face) means the current settlement is failure. Prefer failure-class faces
171
- * over success/audit — never filesystem mtime (copy/restore/utimes can lie).
172
- */
173
- function failureClassRank(file) {
174
- return file === "error.json" ? 1 : 0;
175
- }
176
- /**
177
- * Read the current typed terminal artifact for a run directory.
178
- *
179
- * Candidate set (publisher-owned faces only):
180
- * 1) conventional artifacts/{report,error,audit-incomplete}.json
181
- * 2) publisher fixed failure fallbacks (error.settlement.json faces)
182
- * 3) seam-owned unique error.<uuid>.json via listSeamOwnedUniqueErrorFacePaths
183
- * (same-run path ownership + parent body.runId binding — shared with clear)
184
- *
185
- * Invariant: when more than one present face remains (e.g. clearOpposite failed
186
- * during failure publish and a fallback was settled beside a residual report),
187
- * adopt failure-class over success/audit by the publish contract — not mtime.
188
- * Same-class ties keep candidate enumeration order (conventional before
189
- * fallbacks before unique).
190
- *
191
- * Unreadable faces never outrank a present face. Parent unique unreadable
192
- * files never enter the shared enumerator (cannot prove run identity).
193
- * Absence of every known durable face is a valid no-receipt state.
194
- */
195
- export async function readRunTerminalArtifact(runDirectory) {
196
- const artifactsDir = roleRunArtifactsDirectory(runDirectory);
197
- const present = [];
198
- const unreadable = [];
199
- const consider = (read) => {
200
- if (read === undefined || read.status === "absent")
201
- return;
202
- if (read.status === "present") {
203
- present.push(read);
204
- return;
205
- }
206
- unreadable.push(read);
207
- };
208
- for (const file of RUN_TERMINAL_ARTIFACT_FILES) {
209
- consider(await readTerminalArtifactAtPath(join(artifactsDir, file), file));
210
- }
211
- for (const relative of RUN_TERMINAL_ERROR_FALLBACK_RELATIVE_PATHS) {
212
- consider(await readTerminalArtifactAtPath(join(runDirectory, relative), "error.json"));
213
- }
214
- // Unique same-run + parent-bound faces: one enumerator shared with clear.
215
- for (const path of await listSeamOwnedUniqueErrorFacePaths(runDirectory)) {
216
- consider(await readTerminalArtifactAtPath(path, "error.json"));
217
- }
218
- if (present.length > 0) {
219
- present.sort((a, b) => failureClassRank(b.file) - failureClassRank(a.file));
220
- return present[0];
221
- }
222
- if (unreadable.length > 0) {
223
- return unreadable[0];
224
- }
225
- return { status: "absent" };
226
- }
@@ -1,3 +1,4 @@
1
+ import { readPageSync } from "./run-dossier.js";
1
2
  import { readFile } from "node:fs/promises";
2
3
  import { join } from "node:path";
3
4
  import { isEnoent, isRecord } from "./unknown-value.js";
@@ -61,13 +62,24 @@ async function readJsonObject(path) {
61
62
  throw error;
62
63
  }
63
64
  }
64
- async function readBoardPageTicketNumber(runDirectory, page) {
65
- const record = await readJsonObject(join(runDirectory, page));
65
+ const LEGACY_BOARD_PAGE = {
66
+ admitted: "admitted-request.json",
67
+ invocation: "invocation.json"
68
+ };
69
+ function readBoardPageTicketNumberFromRows(runDirectory, section) {
70
+ const fromRows = readPageSync(runDirectory, section);
71
+ return fromRows === void 0 ? void 0 : ticketFromRecord(fromRows);
72
+ }
73
+ async function readBoardPageTicketNumberFromLegacy(runDirectory, section) {
74
+ const record = await readJsonObject(join(runDirectory, LEGACY_BOARD_PAGE[section]));
66
75
  if (record === void 0) return void 0;
67
76
  return ticketFromRecord(record);
68
77
  }
69
78
  async function readBoardTicketNumber(runDirectory) {
70
- return await readBoardPageTicketNumber(runDirectory, "admitted-request.json") ?? await readBoardPageTicketNumber(runDirectory, "invocation.json");
79
+ return readBoardPageTicketNumberFromRows(runDirectory, "admitted") ?? readBoardPageTicketNumberFromRows(runDirectory, "invocation");
80
+ }
81
+ async function readMigrationBoardTicketNumber(runDirectory) {
82
+ return await readBoardTicketNumber(runDirectory) ?? await readBoardPageTicketNumberFromLegacy(runDirectory, "admitted") ?? await readBoardPageTicketNumberFromLegacy(runDirectory, "invocation");
71
83
  }
72
84
  async function readMigrationDerivedTicketNumber(runDirectory) {
73
85
  const record = await readJsonObject(
@@ -88,6 +100,7 @@ export {
88
100
  parseTicketNumber,
89
101
  readBoardTicketNumber,
90
102
  readDeclaredTicketNumber,
103
+ readMigrationBoardTicketNumber,
91
104
  readMigrationDerivedTicketNumber,
92
105
  readRunTicketNumber,
93
106
  requireSafePositiveTicketNumber,
@@ -1,32 +1,63 @@
1
- import { mkdir, readFile } from "node:fs/promises";
2
1
  import { dirname, join } from "node:path";
3
- import { writeFileAtomically } from "./atomic-write.js";
4
2
  import { DEFAULT_ROLE_TURN_HOST, lookupHeadlessHostDescription, lookupHostDescription, } from "./host-descriptions.js";
5
- /** Durable session binding stored beside the host-owned session principal. */
6
- export function createSessionIdentityAuthority(authority, sessionBindingFile) {
7
- const bindingPath = (principal) => join(authority.decode(principal).sessionDirectory, sessionBindingFile);
3
+ import { renderCurrentSync, RUN_STATE_FILE } from "./run-dossier.js";
4
+ import { readSitianRecords, reportRunRecord } from "./sitian-facade.js";
5
+ import { errorText, isRecord } from "./unknown-value.js";
6
+ /** Log record kind of one host-session binding (a host's native session id for this run). */
7
+ export const HOST_SESSION_ID_RECORD_KIND = "host-session-id";
8
+ /**
9
+ * Durable session binding: one state row per bind, scoped by host name — a run
10
+ * that changes hosts on resume must never hand one host's native session id to
11
+ * another. The latest record for the host wins. The public call projects
12
+ * `current.json` at the bind so host.sessions appears before the host CLI
13
+ * starts; a refused projection does not undo the binding fact or become a
14
+ * host/session failure (facts stay in state.jsonl; settlement still renders).
15
+ */
16
+ export function createSessionIdentityAuthority(authority, hostName) {
17
+ const runDirectoryOf = (principal) => dirname(authority.decode(principal).sessionDirectory);
8
18
  return {
9
19
  resolveSessionFile(principal) {
10
20
  return authority.decode(principal).sessionFile;
11
21
  },
12
22
  async load(principal) {
13
- try {
14
- const value = JSON.parse(await readFile(bindingPath(principal), "utf8"));
15
- if (typeof value !== "object" || value === null || typeof value.sessionId !== "string") {
23
+ const runDirectory = runDirectoryOf(principal);
24
+ const { records, diagnostics } = await readSitianRecords(join(runDirectory, RUN_STATE_FILE));
25
+ // Control path fails closed on damaged rows — same class as readPageSync.
26
+ // Rendering may keep reachable facts; dispatch must not reuse a prior binding
27
+ // after syntax damage (#1161 C3).
28
+ if (diagnostics.length > 0) {
29
+ throw new Error(`${RUN_STATE_FILE} has ${diagnostics.length} malformed row(s); refusing stale session binding: ${runDirectory}`);
30
+ }
31
+ let bound;
32
+ for (const record of records) {
33
+ if (record.kind !== HOST_SESSION_ID_RECORD_KIND)
34
+ continue;
35
+ // Non-object / cannot name a host: unattributable damage — refuse
36
+ // (settled C3-payload). Never treat as a skippable "other host" row.
37
+ if (!isRecord(record.payload) || typeof record.payload.host !== "string") {
16
38
  throw new Error("durable session binding is invalid");
17
39
  }
18
- return value.sessionId;
19
- }
20
- catch (error) {
21
- if (error.code === "ENOENT")
22
- return undefined;
23
- throw error;
40
+ // Explicit other host only: that host's bad id must not block this one.
41
+ if (record.payload.host !== hostName)
42
+ continue;
43
+ // This host's null / non-string sessionId: refuse — do not reuse a prior
44
+ // binding for the same host (#1161 C3).
45
+ if (typeof record.payload.sessionId !== "string") {
46
+ throw new Error("durable session binding is invalid");
47
+ }
48
+ bound = record.payload.sessionId;
24
49
  }
50
+ return bound;
25
51
  },
26
52
  async bind(principal, sessionId) {
27
- const target = bindingPath(principal);
28
- await mkdir(dirname(target), { recursive: true });
29
- await writeFileAtomically(target, `${JSON.stringify({ sessionId })}\n`, { mode: 0o600 });
53
+ const runDirectory = runDirectoryOf(principal);
54
+ reportRunRecord(runDirectory, HOST_SESSION_ID_RECORD_KIND, { host: hostName, sessionId }, "session-identity");
55
+ try {
56
+ renderCurrentSync(runDirectory);
57
+ }
58
+ catch (error) {
59
+ process.stderr.write(`[session-identity] current.json render refused after host-session-id; binding fact kept: ${runDirectory}: ${errorText(error)}\n`);
60
+ }
30
61
  },
31
62
  };
32
63
  }
@@ -41,7 +72,7 @@ export async function readStoredHostSessionId(host, authority, principal) {
41
72
  const description = lookupHostDescription(host) ?? lookupHeadlessHostDescription(host);
42
73
  if (description === undefined)
43
74
  return undefined;
44
- const sessionId = await createSessionIdentityAuthority(authority, description.sessionBindingFile).load(principal);
75
+ const sessionId = await createSessionIdentityAuthority(authority, host).load(principal);
45
76
  if (sessionId === undefined || sessionId === "")
46
77
  return undefined;
47
78
  return sessionId;
@@ -1,7 +1,8 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { appendFileSync } from "node:fs";
3
- import { dirname, join } from "node:path";
4
- import { sessionDirectoryOf } from "./role-run-placement.js";
3
+ import { basename, dirname, join } from "node:path";
4
+ import { parseRunLeaf, sessionDirectoryOf } from "./role-run-placement.js";
5
+ import { RUN_HISTORY_FILE, RUN_HISTORY_KINDS, RUN_LOG_FILE, RUN_STATE_FILE, RUN_STATE_KINDS } from "./run-dossier-files.js";
5
6
  import { ticketNumberFromSitianSubject } from "./run-ticket-number.js";
6
7
  import { resolveBookKeyFromGit } from "./activation-ledger-git.js";
7
8
  import {
@@ -29,17 +30,7 @@ function appendSitianRecordBlock(input, block) {
29
30
  );
30
31
  }
31
32
  }
32
- const S4_SUBMISSION_LEDGER_KINDS = /* @__PURE__ */ new Set([
33
- "candidate",
34
- "roundContext",
35
- "outcome",
36
- "sealed",
37
- "post-seal-anomaly"
38
- ]);
39
33
  function resolveSitianVolumeCategory(kind) {
40
- if (S4_SUBMISSION_LEDGER_KINDS.has(kind)) {
41
- return "submission-ledger";
42
- }
43
34
  return kind;
44
35
  }
45
36
  const SITIAN_RECORDS_LEAF = "records.jsonl";
@@ -71,13 +62,20 @@ function resolveSitianRecordPathInLedger(input, ledgerHome) {
71
62
  recordFile = paths.recordFile;
72
63
  } else if (category === "ticket-provenance" && input.runDirectory !== void 0) {
73
64
  sessionDir = input.runDirectory;
74
- recordFile = sitianVolumeRecordsFile(sessionDir);
65
+ recordFile = join(sessionDir, RUN_HISTORY_FILE);
75
66
  } else {
76
67
  if (input.sessionParent === void 0 || input.sessionParent.length === 0 || !physicallyContainedIn(ledgerHome, input.sessionParent)) {
77
68
  throw new Error("Sitian record ownership requires a parent session inside the ledger home");
78
69
  }
79
- sessionDir = sitianVolumeDirectory(dirname(input.sessionParent), category);
80
- recordFile = sitianVolumeRecordsFile(sessionDir);
70
+ const sessionParentDir = dirname(input.sessionParent);
71
+ const runDirectory = dirname(sessionParentDir);
72
+ if (basename(sessionParentDir) === "session" && parseRunLeaf(basename(runDirectory)) !== void 0) {
73
+ sessionDir = runDirectory;
74
+ recordFile = join(runDirectory, RUN_HISTORY_KINDS.has(input.kind) ? RUN_HISTORY_FILE : RUN_STATE_KINDS.has(input.kind) ? RUN_STATE_FILE : RUN_LOG_FILE);
75
+ } else {
76
+ sessionDir = sitianVolumeDirectory(sessionParentDir, category);
77
+ recordFile = sitianVolumeRecordsFile(sessionDir);
78
+ }
81
79
  }
82
80
  if (!physicallyContainedIn(ledgerHome, sessionDir)) {
83
81
  throw new Error("Sitian record ownership requires a directory inside the ledger home");
@@ -126,7 +124,6 @@ function appendSitianRecord(input) {
126
124
  }
127
125
  }
128
126
  export {
129
- S4_SUBMISSION_LEDGER_KINDS,
130
127
  appendSitianRecord,
131
128
  appendSitianRecordBlock,
132
129
  resolveSitianRecordPath,
@@ -1,3 +1,4 @@
1
+ import { sessionFileOf } from "./role-run-placement.js";
1
2
  import { appendSitianRecord } from "./sitian-appender.js";
2
3
  export * from "./sitian-contracts.js";
3
4
  export * from "./sitian-appender.js";
@@ -5,6 +6,16 @@ export * from "./sitian-reader.js";
5
6
  function sitianReport(input) {
6
7
  return appendSitianRecord(input);
7
8
  }
9
+ function reportRunRecord(runDirectory, kind, payload, source) {
10
+ return appendSitianRecord({
11
+ level: "event",
12
+ kind,
13
+ sessionParent: sessionFileOf(runDirectory),
14
+ source,
15
+ payload
16
+ });
17
+ }
8
18
  export {
19
+ reportRunRecord,
9
20
  sitianReport
10
21
  };
@@ -1,41 +1,56 @@
1
- import { existsSync } from "node:fs";
2
1
  import { readFile } from "node:fs/promises";
3
- import { isRecord, errorText } from "./unknown-value.js";
4
- async function readSitianRecords(recordFile) {
5
- if (!existsSync(recordFile)) {
6
- return { records: [], diagnostics: [] };
2
+ import { isRecord, errorText, isEnoent } from "./unknown-value.js";
3
+ function decodeSitianRecordLine(line, lineNumber) {
4
+ if (!line.trim()) return void 0;
5
+ try {
6
+ const parsed = JSON.parse(line);
7
+ if (isRecord(parsed)) {
8
+ return { ok: true, record: parsed };
9
+ }
10
+ const typeDesc = parsed === null ? "null" : Array.isArray(parsed) ? "array" : typeof parsed;
11
+ return {
12
+ ok: false,
13
+ diagnostic: {
14
+ kind: "malformed",
15
+ line: lineNumber,
16
+ raw: line,
17
+ error: `expected JSON object, got ${typeDesc}`
18
+ }
19
+ };
20
+ } catch (error) {
21
+ return {
22
+ ok: false,
23
+ diagnostic: {
24
+ kind: "malformed",
25
+ line: lineNumber,
26
+ raw: line,
27
+ error: errorText(error)
28
+ }
29
+ };
7
30
  }
8
- const text = await readFile(recordFile, "utf8");
31
+ }
32
+ function parseSitianRecordText(text) {
9
33
  const lines = text.split("\n");
10
34
  const records = [];
11
35
  const diagnostics = [];
12
36
  for (let index = 0; index < lines.length; index += 1) {
13
- const line = lines[index];
14
- if (!line.trim()) continue;
15
- try {
16
- const parsed = JSON.parse(line);
17
- if (isRecord(parsed)) {
18
- records.push(parsed);
19
- } else {
20
- const typeDesc = parsed === null ? "null" : Array.isArray(parsed) ? "array" : typeof parsed;
21
- diagnostics.push({
22
- kind: "malformed",
23
- line: index + 1,
24
- raw: line,
25
- error: `expected JSON object, got ${typeDesc}`
26
- });
27
- }
28
- } catch (error) {
29
- diagnostics.push({
30
- kind: "malformed",
31
- line: index + 1,
32
- raw: line,
33
- error: errorText(error)
34
- });
35
- }
37
+ const decoded = decodeSitianRecordLine(lines[index], index + 1);
38
+ if (decoded === void 0) continue;
39
+ if (decoded.ok) records.push(decoded.record);
40
+ else diagnostics.push(decoded.diagnostic);
36
41
  }
37
42
  return { records, diagnostics };
38
43
  }
44
+ async function readSitianRecords(recordFile) {
45
+ try {
46
+ return parseSitianRecordText(await readFile(recordFile, "utf8"));
47
+ } catch (error) {
48
+ if (isEnoent(error)) return { records: [], diagnostics: [] };
49
+ throw error;
50
+ }
51
+ }
39
52
  export {
53
+ decodeSitianRecordLine,
54
+ parseSitianRecordText,
40
55
  readSitianRecords
41
56
  };
@@ -52,7 +52,7 @@ export function createDefaultGateOfficerSummon(options) {
52
52
  };
53
53
  }
54
54
  /**
55
- * Book a typed pointer under parent session/auditor-roles (archivist-owned write).
55
+ * Book a typed pointer in the parent run's current.json officers section.
56
56
  * Offline mocks without a real session leave no nested volume (lawful zero).
57
57
  */
58
58
  function bookDirectOfficerPointer(context, officer, result, summoned, toolCallId) {