@akagilnc/pi-workflow-roles 0.1.5478 → 0.1.5504

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 (79) hide show
  1. package/dist/acp-host/production-host.js +4945 -4980
  2. package/dist/analyst-gate-cycles-read.js +11 -29
  3. package/dist/analyst-ledger.js +10 -12
  4. package/dist/archivist-record-entry.js +4 -3
  5. package/dist/archivist-record-pointer.js +59 -32
  6. package/dist/atomic-write.js +2 -2
  7. package/dist/auditor-dossier-tool.js +8 -4
  8. package/dist/auditor-soul.js +2 -1
  9. package/dist/collector-config.js +6 -5
  10. package/dist/doctor-evidence.js +5 -1
  11. package/dist/engine-detour-usage.js +4 -10
  12. package/dist/headless-host/description.js +251 -0
  13. package/dist/headless-host/production-host.js +2787 -2658
  14. package/dist/ledger-session-read.js +5 -0
  15. package/dist/migrate-book-topology.js +129 -100
  16. package/dist/navigator-public-session.js +13 -17
  17. package/dist/notary-source-run.js +7 -15
  18. package/dist/packaged-role-registry.js +0 -12
  19. package/dist/pi/durable-principal.js +2 -3
  20. package/dist/pi/role-turn-host.js +27 -13
  21. package/dist/public-cli/instruction-seat-run.js +4 -4
  22. package/dist/public-cli/invocation.js +17 -45
  23. package/dist/public-cli/main.js +1074 -1085
  24. package/dist/public-cli/run-lifecycle.js +17 -47
  25. package/dist/public-cli/settlement.js +28 -128
  26. package/dist/public-cli/terminal.js +0 -12
  27. package/dist/role-run-placement.js +52 -5
  28. package/dist/role-run-relocation.js +39 -2
  29. package/dist/run-terminal-artifacts.js +2 -6
  30. package/dist/run-ticket-number.js +33 -4
  31. package/dist/session-assistant-usage.js +9 -18
  32. package/dist/session-identity.js +3 -4
  33. package/dist/sitian-appender.js +20 -6
  34. package/dist/submission-ledger.js +4 -4
  35. package/dist/ticket-provenance-contracts.js +2 -8
  36. package/dist/ticket-provenance.js +5 -4
  37. package/package.json +1 -1
  38. package/src/acp-host/role-turn-host.ts +2 -2
  39. package/src/analyst-gate-cycles-read.ts +11 -34
  40. package/src/analyst-ledger.ts +14 -14
  41. package/src/archivist-record-entry.ts +4 -3
  42. package/src/archivist-record-pointer.ts +70 -34
  43. package/src/atomic-write.ts +3 -1
  44. package/src/auditor-dossier-tool.ts +8 -4
  45. package/src/auditor-soul.ts +2 -1
  46. package/src/collector-config.ts +5 -3
  47. package/src/doctor-evidence.ts +5 -1
  48. package/src/engine-detour-usage.ts +10 -13
  49. package/src/factory-board.ts +16 -81
  50. package/src/headless-host/description.ts +248 -1
  51. package/src/headless-host/production-host.ts +1 -2
  52. package/src/headless-host/role-turn-host.ts +10 -9
  53. package/src/ledger-session-read.ts +6 -0
  54. package/src/navigator-public-session.ts +13 -18
  55. package/src/navigator-work-context.ts +3 -1
  56. package/src/notary-role.ts +2 -1
  57. package/src/notary-source-run.ts +7 -17
  58. package/src/packaged-role-registry.ts +0 -15
  59. package/src/pi/durable-principal.ts +2 -4
  60. package/src/pi/role-turn-host.ts +35 -12
  61. package/src/public-cli/instruction-seat-run.ts +4 -4
  62. package/src/public-cli/invocation.ts +29 -57
  63. package/src/public-cli/run-lifecycle.ts +20 -43
  64. package/src/public-cli/settlement.ts +30 -173
  65. package/src/public-cli/terminal.ts +0 -60
  66. package/src/role-envelope.ts +4 -2
  67. package/src/role-run-placement.ts +63 -4
  68. package/src/role-run-relocation.ts +39 -2
  69. package/src/role-runtime.ts +40 -59
  70. package/src/run-terminal-artifacts.ts +3 -5
  71. package/src/run-ticket-number.ts +41 -3
  72. package/src/session-assistant-usage.ts +8 -20
  73. package/src/session-identity.ts +3 -4
  74. package/src/sitian-appender.ts +20 -12
  75. package/src/submission-ledger.ts +4 -4
  76. package/src/ticket-provenance-contracts.ts +3 -6
  77. package/src/ticket-provenance.ts +5 -6
  78. package/src/ticket-snapshot.ts +3 -2
  79. package/src/ticket-trajectory.ts +85 -99
@@ -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
 
@@ -1,6 +1,7 @@
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
 
@@ -38,8 +39,11 @@ 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");
@@ -17,9 +17,15 @@
17
17
  */
18
18
  import { randomUUID } from "node:crypto";
19
19
  import { readFileSync } from "node:fs";
20
- import { dirname, join } from "node:path";
20
+ import { join } from "node:path";
21
+
22
+ export {
23
+ runDirectoryFromSessionDirectory,
24
+ sessionFileIn as sessionFileFromSessionDirectory,
25
+ } from "./role-run-placement.ts";
21
26
 
22
27
  import { ENGINE_DETOUR_TOOL_NAME } from "./engine-detour.ts";
28
+ import { sitianRunVolumeDirectory, sitianVolumeRecordsFile } from "./sitian-appender.ts";
23
29
  import {
24
30
  readSitianRecords,
25
31
  resolveSitianRecordPath,
@@ -40,8 +46,9 @@ export const ENGINE_DETOUR_TOOL_USAGE_FACT_KEY = "engineDetourToolUsage" as cons
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 = sitianVolumeRecordsFile(
50
+ sitianRunVolumeDirectory("", ENGINE_DETOUR_CALL_KIND),
51
+ );
45
52
 
46
53
  /** stdout UTF-8 byte length (ticket-frozen metric; empty stdout is real 0). */
47
54
  export function engineDetourStdoutByteLength(stdout: string): number {
@@ -288,16 +295,6 @@ export function withEngineDetourToolUsageFact<
288
295
  };
289
296
  }
290
297
 
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
298
  /**
302
299
  * Mint one public-invocation scope id.
303
300
  * Call once at the public-entry boundary — never inside the auto-resume loop.
@@ -16,9 +16,7 @@
16
16
  * output path outside every ledger and declares the bound; one-shot write does
17
17
  * not advertise refresh. Snapshot bindings stay fixed; each tick re-reads ledgers.
18
18
  */
19
- import { randomUUID } from "node:crypto";
20
- import { lstat, mkdir, realpath, rename, rm, writeFile } from "node:fs/promises";
21
- import { basename, dirname, join, relative, resolve, sep } from "node:path";
19
+ import { resolve } from "node:path";
22
20
 
23
21
  import { packagedBoardPlacement } from "./packaged-role-registry.ts";
24
22
  import {
@@ -29,6 +27,8 @@ import {
29
27
  } from "./human-format.ts";
30
28
  import {
31
29
  DEFAULT_REFRESH_BOUNDARY_SECONDS,
30
+ assertOutputOutsideLedgers,
31
+ writePageOutsideLedgers,
32
32
  buildTicketTrajectoryBookIndex,
33
33
  loadTicketTrajectoryRuns,
34
34
  loadUnboundTrajectoryRuns,
@@ -41,7 +41,7 @@ import {
41
41
  } from "./ticket-trajectory.ts";
42
42
  import type { BoardSnapshot, SnapshotTicket, TicketIssueState } from "./ticket-snapshot.ts";
43
43
 
44
- import { isRecord, isMissingPathError } from "./unknown-value.ts";
44
+ import { isRecord } from "./unknown-value.ts";
45
45
 
46
46
  /** Unaccepted latest-run mtime bands (page-visible thresholds). */
47
47
  export const UNACCEPTED_FLYING_MS = 2 * 60 * 1000;
@@ -322,11 +322,6 @@ function attr(value: string): string {
322
322
  return escapeHtml(value);
323
323
  }
324
324
 
325
- function isPathInside(parent: string, child: string): boolean {
326
- const rel = relative(parent, child);
327
- return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !rel.startsWith(".."));
328
- }
329
-
330
325
  function activeBlockedBy(ticket: SnapshotTicket): number[] {
331
326
  if (ticket.state === "closed") return [];
332
327
  return ticket.blockedBy
@@ -1654,76 +1649,7 @@ ${boardPageScript()}
1654
1649
  `;
1655
1650
  }
1656
1651
 
1657
- async function assertOutputOutsideAllLedgers(
1658
- books: readonly FactoryBoardBook[],
1659
- outputPath: string,
1660
- ): Promise<{ ledgerRoots: string[]; outputAbsolute: string }> {
1661
- const outputAbsolute = resolve(outputPath);
1662
- const ledgerRoots: string[] = [];
1663
- for (const book of books) {
1664
- const ledgerResolved = resolve(book.ledgerDir);
1665
- let ledgerRoot: string;
1666
- try {
1667
- ledgerRoot = await realpath(ledgerResolved);
1668
- } catch (error) {
1669
- if (!isMissingPathError(error)) throw error;
1670
- ledgerRoot = ledgerResolved;
1671
- }
1672
- ledgerRoots.push(ledgerRoot);
1673
-
1674
- const missingTail: string[] = [];
1675
- let cursor = outputAbsolute;
1676
- for (;;) {
1677
- try {
1678
- await lstat(cursor);
1679
- break;
1680
- } catch (error) {
1681
- if (!isMissingPathError(error)) throw error;
1682
- const parent = dirname(cursor);
1683
- if (parent === cursor) break;
1684
- missingTail.push(basename(cursor));
1685
- cursor = parent;
1686
- }
1687
- }
1688
- let realPrefix: string;
1689
- try {
1690
- realPrefix = await realpath(cursor);
1691
- } catch (error) {
1692
- if (!isMissingPathError(error)) throw error;
1693
- realPrefix = resolve(cursor);
1694
- }
1695
- const prospectiveReal =
1696
- missingTail.length === 0 ? realPrefix : resolve(realPrefix, ...missingTail.reverse());
1697
- if (
1698
- isPathInside(ledgerRoot, prospectiveReal) ||
1699
- isPathInside(ledgerRoot, realPrefix) ||
1700
- isPathInside(ledgerRoot, outputAbsolute)
1701
- ) {
1702
- throw new Error("factory board outputPath must be outside every ledger directory");
1703
- }
1704
- }
1705
- return { ledgerRoots, outputAbsolute };
1706
- }
1707
-
1708
- async function writeHtmlAtomically(outputAbsolute: string, html: string, ledgerRoots: string[]): Promise<string> {
1709
- const parent = dirname(outputAbsolute);
1710
- await mkdir(parent, { recursive: true });
1711
- const parentReal = await realpath(parent);
1712
- for (const root of ledgerRoots) {
1713
- if (isPathInside(root, parentReal) || isPathInside(root, resolve(parentReal, basename(outputAbsolute)))) {
1714
- throw new Error("factory board outputPath must be outside every ledger directory");
1715
- }
1716
- }
1717
- const temporary = join(parent, `.factory-board-${randomUUID()}.html.tmp`);
1718
- try {
1719
- await writeFile(temporary, html, "utf8");
1720
- await rename(temporary, outputAbsolute);
1721
- } catch (error) {
1722
- await rm(temporary, { force: true }).catch(() => undefined);
1723
- throw error;
1724
- }
1725
- return realpath(outputAbsolute);
1726
- }
1652
+ const BOARD_OUTPUT_REFUSAL = "factory board outputPath must be outside every ledger directory";
1727
1653
 
1728
1654
  export async function writeFactoryBoardPage(input: {
1729
1655
  books: readonly FactoryBoardBook[];
@@ -1732,7 +1658,11 @@ export async function writeFactoryBoardPage(input: {
1732
1658
  outputPath: string;
1733
1659
  refreshBoundarySeconds?: number;
1734
1660
  }): Promise<{ outputPath: string; html: string }> {
1735
- const gate = await assertOutputOutsideAllLedgers(input.books, input.outputPath);
1661
+ const gate = await assertOutputOutsideLedgers(
1662
+ input.books.map((book) => book.ledgerDir),
1663
+ input.outputPath,
1664
+ BOARD_OUTPUT_REFUSAL,
1665
+ );
1736
1666
  const html = await renderFactoryBoardHtml(
1737
1667
  input.books,
1738
1668
  input.view,
@@ -1741,7 +1671,12 @@ export async function writeFactoryBoardPage(input: {
1741
1671
  ? { refreshBoundarySeconds: input.refreshBoundarySeconds }
1742
1672
  : undefined,
1743
1673
  );
1744
- const outputPath = await writeHtmlAtomically(gate.outputAbsolute, html, gate.ledgerRoots);
1674
+ const outputPath = await writePageOutsideLedgers(
1675
+ gate.ledgerRoots,
1676
+ gate.outputAbsolute,
1677
+ html,
1678
+ BOARD_OUTPUT_REFUSAL,
1679
+ );
1745
1680
  return { outputPath, html };
1746
1681
  }
1747
1682
 
@@ -130,6 +130,253 @@ function codexTomlStringTable(entries: Readonly<Record<string, string>>): string
130
130
  return `{${parts.join(",")}}`;
131
131
  }
132
132
 
133
+ /**
134
+ * Free-form JSON leaf for Type.Unknown under Codex strict transport.
135
+ * Declared contract objects still close with additionalProperties:false.
136
+ * For Unknown, a $defs anyOf of JSON values with nested additionalProperties
137
+ * as $ref is accepted by Codex and keeps non-empty object receipts expressible
138
+ * (native probe). Package code still does not validate or reject the receipt.
139
+ */
140
+ const CODEX_JSON_VALUE_DEF = "codexJsonValue";
141
+ const CODEX_JSON_VALUE_REF = `#/$defs/${CODEX_JSON_VALUE_DEF}`;
142
+ const CODEX_JSON_VALUE_SCHEMA = Object.freeze({
143
+ anyOf: Object.freeze([
144
+ Object.freeze({ type: "string" }),
145
+ Object.freeze({ type: "number" }),
146
+ Object.freeze({ type: "boolean" }),
147
+ Object.freeze({ type: "null" }),
148
+ Object.freeze({ type: "array", items: Object.freeze({ $ref: CODEX_JSON_VALUE_REF }) }),
149
+ Object.freeze({
150
+ type: "object",
151
+ properties: Object.freeze({}),
152
+ required: Object.freeze([] as string[]),
153
+ additionalProperties: Object.freeze({ $ref: CODEX_JSON_VALUE_REF }),
154
+ }),
155
+ ]),
156
+ });
157
+
158
+ /**
159
+ * Derive a Codex/OpenAI-strict transport schema from the package open schema.
160
+ * Legal open schema is untouched; this is a host-only transmission projection
161
+ * (#1148 / #646 / ADR 0057 / ADR 0054): every object closes, every property is
162
+ * required, originally-optional fields become a null union (official guidance),
163
+ * and originally-required declared nullability stays. Nested open-tool anyOf
164
+ * wrappers are flattened so every branch carries `type`, keeping descriptions.
165
+ * Type.Unknown / description-only leaves become the closed JSON-value $ref.
166
+ * Package code still does not validate or reject the receipt.
167
+ */
168
+ export function closeJsonSchemaForCodex(
169
+ schema: Readonly<Record<string, unknown>>,
170
+ ): Record<string, unknown> {
171
+ const closed = closeSchemaNode(schema) as Record<string, unknown>;
172
+ const existingDefs = isRecord(closed.$defs)
173
+ ? (closed.$defs as Record<string, unknown>)
174
+ : {};
175
+ return {
176
+ ...closed,
177
+ $defs: {
178
+ ...existingDefs,
179
+ [CODEX_JSON_VALUE_DEF]: CODEX_JSON_VALUE_SCHEMA,
180
+ },
181
+ };
182
+ }
183
+
184
+ /** Pure null leaf only — composite type|null is stripped in-leaf, not dropped whole. */
185
+ function isPureNullTypeSchema(value: unknown): boolean {
186
+ if (!isRecord(value)) return false;
187
+ if (value.type === "null") return true;
188
+ if (Array.isArray(value.type) && value.type.length > 0 && value.type.every((t) => t === "null")) {
189
+ return true;
190
+ }
191
+ return false;
192
+ }
193
+
194
+ /** Declared nullability on the open schema (type|null, anyOf null leaf, or pure null). */
195
+ function schemaDeclaresNull(schema: unknown): boolean {
196
+ if (!isRecord(schema)) return false;
197
+ if (isPureNullTypeSchema(schema)) return true;
198
+ if (Array.isArray(schema.type) && schema.type.includes("null")) return true;
199
+ if (Array.isArray(schema.anyOf) && schema.anyOf.some(schemaDeclaresNull)) return true;
200
+ return false;
201
+ }
202
+
203
+ /**
204
+ * Within a leaf, remove null members from type arrays.
205
+ * Null is re-added when the union or property edge originally declared it.
206
+ */
207
+ function stripNullFromLeafType(leaf: unknown): unknown {
208
+ if (!isRecord(leaf) || !Array.isArray(leaf.type)) return leaf;
209
+ const nonNull = leaf.type.filter((t) => t !== "null");
210
+ if (nonNull.length === leaf.type.length) return leaf;
211
+ if (nonNull.length === 0) return { ...leaf, type: "null" };
212
+ if (nonNull.length === 1) return { ...leaf, type: nonNull[0] };
213
+ return { ...leaf, type: nonNull };
214
+ }
215
+
216
+ /**
217
+ * Flatten nested anyOf wrappers into concrete leaf schemas.
218
+ * Open-tool unions often wrap leaves in description-only anyOf shells that
219
+ * lack `type`; strict structured output rejects those intermediate nodes.
220
+ * Nested shell descriptions attach to leaves that lack their own (not onto $ref).
221
+ * The outermost union keeps its description on the closed node via the caller.
222
+ */
223
+ function flattenUnionLeaves(schema: unknown, attachShellDescription = false): unknown[] {
224
+ if (!isRecord(schema)) return [schema];
225
+ if (!Array.isArray(schema.anyOf)) return [schema];
226
+ const shellDescription =
227
+ attachShellDescription && typeof schema.description === "string"
228
+ ? schema.description
229
+ : undefined;
230
+ return schema.anyOf.flatMap((branch) => {
231
+ const leaves = flattenUnionLeaves(branch, true);
232
+ if (shellDescription === undefined) return leaves;
233
+ return leaves.map((leaf) => {
234
+ if (!isRecord(leaf) || typeof leaf.$ref === "string") return leaf;
235
+ if (typeof leaf.description === "string") return leaf;
236
+ return { ...leaf, description: shellDescription };
237
+ });
238
+ });
239
+ }
240
+
241
+ /** Drop pure-null leaves after in-leaf null stripping; property edge may re-add null. */
242
+ function nonNullLeaves(schema: unknown): unknown[] {
243
+ return flattenUnionLeaves(schema)
244
+ .map(stripNullFromLeafType)
245
+ .filter((leaf) => !isPureNullTypeSchema(leaf));
246
+ }
247
+
248
+ function withDescription(closed: unknown, description: Readonly<{ description?: string }>): unknown {
249
+ if (description.description === undefined) return closed;
250
+ // Codex strict does not allow sibling keys on $ref; wrap so guidance is kept.
251
+ if (isRecord(closed) && typeof closed.$ref === "string") {
252
+ return { anyOf: [closed], ...description };
253
+ }
254
+ // Prefer a leaf's own description (nested anyOf shell) over the outer copy.
255
+ if (isRecord(closed) && typeof closed.description === "string") return closed;
256
+ return isRecord(closed) ? { ...closed, ...description } : closed;
257
+ }
258
+
259
+ /**
260
+ * Property edge under strict transport.
261
+ * Optional → closed leaf(s) + null. Required with declared nullability keeps null.
262
+ * Required without declared null stays non-nullable.
263
+ */
264
+ function closePropertySchema(schema: unknown, optional: boolean): unknown {
265
+ const description =
266
+ isRecord(schema) && typeof schema.description === "string"
267
+ ? { description: schema.description }
268
+ : {};
269
+ const keepNull = optional || schemaDeclaresNull(schema);
270
+ const leaves = nonNullLeaves(schema).map(closeSchemaNode);
271
+ if (leaves.length === 0) return { type: "null", ...description };
272
+ if (!keepNull) {
273
+ const closed = leaves.length === 1 ? leaves[0] : { anyOf: leaves };
274
+ return withDescription(closed, description);
275
+ }
276
+ return { anyOf: [...leaves, { type: "null" }], ...description };
277
+ }
278
+
279
+ /**
280
+ * Strict generators require every schema node to declare `type` (or a $ref).
281
+ * Type.Unknown / description-only leaves → free JSON $ref (not string): array
282
+ * and object receipts stay expressible under --output-schema.
283
+ */
284
+ function ensureTypedLeaf(schema: Record<string, unknown>): Record<string, unknown> {
285
+ if (schema.type !== undefined) return schema;
286
+ if (typeof schema.$ref === "string") return schema;
287
+ if (isRecord(schema.properties) || schema.additionalProperties !== undefined) {
288
+ return { ...schema, type: "object" };
289
+ }
290
+ if (schema.items !== undefined) {
291
+ return { ...schema, type: "array" };
292
+ }
293
+ if (schema.const !== undefined) {
294
+ const value = schema.const;
295
+ if (value === null) return { ...schema, type: "null" };
296
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
297
+ return { ...schema, type: typeof value };
298
+ }
299
+ }
300
+ if (Array.isArray(schema.enum) && schema.enum.length > 0) {
301
+ const sample = schema.enum.find((item) => item !== null);
302
+ if (typeof sample === "string" || typeof sample === "number" || typeof sample === "boolean") {
303
+ return { ...schema, type: typeof sample };
304
+ }
305
+ }
306
+ // Free JSON via $defs. $ref must stand alone (strict rejects sibling keys).
307
+ return { $ref: CODEX_JSON_VALUE_REF };
308
+ }
309
+
310
+ function originalRequiredNames(node: Record<string, unknown>): ReadonlySet<string> {
311
+ if (!Array.isArray(node.required)) return new Set();
312
+ return new Set(node.required.filter((item): item is string => typeof item === "string"));
313
+ }
314
+
315
+ function closeSchemaNode(node: unknown): unknown {
316
+ if (!isRecord(node)) return node;
317
+
318
+ // Already a ref (free-JSON leaf or pre-existing) — do not retype.
319
+ if (typeof node.$ref === "string") return node;
320
+
321
+ // Union node: flatten then close each concrete leaf (do not keep untyped shells).
322
+ // Declared null stays — including nested unions such as array items.
323
+ if (Array.isArray(node.anyOf)) {
324
+ const description =
325
+ typeof node.description === "string" ? { description: node.description } : {};
326
+ const keepNull = schemaDeclaresNull(node);
327
+ const leaves = nonNullLeaves(node).map(closeSchemaNode);
328
+ if (leaves.length === 0) return { type: "null", ...description };
329
+ if (!keepNull) {
330
+ if (leaves.length === 1) return withDescription(leaves[0], description);
331
+ return { anyOf: leaves, ...description };
332
+ }
333
+ return { anyOf: [...leaves, { type: "null" }], ...description };
334
+ }
335
+
336
+ let out: Record<string, unknown> = { ...node };
337
+
338
+ if (node.items !== undefined) {
339
+ out.items = closeSchemaNode(node.items);
340
+ }
341
+ if (isRecord(node.$defs)) {
342
+ out.$defs = Object.fromEntries(
343
+ Object.entries(node.$defs).map(([key, value]) => [key, closeSchemaNode(value)]),
344
+ );
345
+ }
346
+
347
+ const hasProperties = isRecord(node.properties);
348
+ const isObjectType =
349
+ node.type === "object"
350
+ || (Array.isArray(node.type) && node.type.includes("object"))
351
+ || hasProperties
352
+ || node.additionalProperties !== undefined;
353
+
354
+ if (hasProperties) {
355
+ const props = node.properties as Record<string, unknown>;
356
+ const wasRequired = originalRequiredNames(node);
357
+ const closedProps: Record<string, unknown> = {};
358
+ const required: string[] = [];
359
+ for (const [name, propSchema] of Object.entries(props)) {
360
+ required.push(name);
361
+ closedProps[name] = closePropertySchema(propSchema, !wasRequired.has(name));
362
+ }
363
+ out.properties = closedProps;
364
+ out.required = required;
365
+ out.additionalProperties = false;
366
+ if (out.type === undefined) out.type = "object";
367
+ // Object nodes must not also carry residual anyOf from the open copy.
368
+ delete out.anyOf;
369
+ } else if (isObjectType) {
370
+ out.additionalProperties = false;
371
+ if (!Array.isArray(out.required)) out.required = [];
372
+ if (out.type === undefined) out.type = "object";
373
+ } else {
374
+ out = ensureTypedLeaf(out);
375
+ }
376
+
377
+ return out;
378
+ }
379
+
133
380
  /**
134
381
  * Project shared-envelope MCP rows into `codex -c mcp_servers.<name>.*` argv pairs.
135
382
  * Dot-path + TOML values per official config-advanced; spawn argv (no shell).
@@ -185,7 +432,7 @@ export function codexTurnArgs(options: {
185
432
  readonly systemPromptPath: string;
186
433
  /**
187
434
  * Absolute path for the native `--output-schema` flag.
188
- * Pass the declaration unchanged; native strict output cannot promise an open contract.
435
+ * File holds the Codex-strict transport projection of the open declaration (#1148).
189
436
  * Optional for prose-exit seats (#959 navigator) so agent_message stays free text.
190
437
  */
191
438
  readonly outputSchemaPath?: string;
@@ -7,8 +7,7 @@
7
7
  * Intermediate AK tools ride the shared envelope MCP relay (Claude `--mcp-config`,
8
8
  * codex `-c mcp_servers.*`). The terminating receipt is the host-native schema
9
9
  * channel only (#750 submission-tool-is-schema-channel) — terminating tool is not
10
- * listed on MCP (Claude `--json-schema`, codex `--output-schema` receive the declaration unchanged).
11
- * Codex native strict output cannot promise an open contract; the adapter reports that gap.
10
+ * listed on MCP (Claude `--json-schema`, codex `--output-schema` closed projection).
12
11
  */
13
12
  import { randomUUID } from "node:crypto";
14
13
 
@@ -10,6 +10,7 @@ import { writeFile } from "node:fs/promises";
10
10
  import { dirname, isAbsolute, join, resolve } from "node:path";
11
11
 
12
12
  import type { RoleTurnHost, RoleTurnRequest, RoleTurnResult } from "../host-contracts.ts";
13
+ import { sessionDirectoryOf } from "../role-run-placement.ts";
13
14
  import {
14
15
  createSerializedRoleTurnHost,
15
16
  driveExternalRoleTurnRounds,
@@ -32,6 +33,7 @@ import {
32
33
  recordNativeSessionPointer,
33
34
  } from "../host-session-record.ts";
34
35
  import {
36
+ closeJsonSchemaForCodex,
35
37
  codexTurnArgs,
36
38
  headlessMcpConfigDocument,
37
39
  headlessTurnArgs,
@@ -465,16 +467,15 @@ export function createHeadlessRoleTurnHost(config: HeadlessRoleTurnHostConfig):
465
467
  let mcpConfigPath: string | undefined;
466
468
  let outputSchemaPath: string | undefined;
467
469
  if (codex) {
468
- // Send the declaration unchanged. Any native rejection belongs to Codex;
469
- // do not close the schema or substitute a different receipt outlet.
470
+ // #1148: Codex --output-schema requires a native strict transport schema.
471
+ // Project from the unique open declaration; do not alter package receipt rules.
470
472
  if (prepared.terminatingToolName !== NAVIGATOR_OUTPUT_TOOL_NAME) {
471
473
  outputSchemaPath = join(request.runDirectory, "headless-output-schema.json");
472
- await writeFile(outputSchemaPath, `${JSON.stringify(prepared.jsonSchema, null, 2)}\n`, "utf8");
473
- await retainPackageFault({
474
- runDirectory: request.runDirectory,
475
- diagnostic:
476
- "codex --output-schema cannot promise this open contract: native strict output requires all properties and additionalProperties:false; the declaration is sent unchanged",
477
- });
474
+ await writeFile(
475
+ outputSchemaPath,
476
+ `${JSON.stringify(closeJsonSchemaForCodex(prepared.jsonSchema), null, 2)}\n`,
477
+ "utf8",
478
+ );
478
479
  }
479
480
  } else {
480
481
  mcpConfigPath = join(request.runDirectory, "headless-mcp-config.json");
@@ -717,7 +718,7 @@ export function createHeadlessRoleTurnHost(config: HeadlessRoleTurnHostConfig):
717
718
  try {
718
719
  if (exitedSessionId !== undefined && exitedSessionId !== "") copyAndRecordHostDossier({
719
720
  host: config.hostName, sessionId: exitedSessionId, cwd: request.cwd,
720
- sessionDirectory: join(request.runDirectory, "session"), sessionParent,
721
+ sessionDirectory: sessionDirectoryOf(request.runDirectory), sessionParent,
721
722
  continuation: request.continuation,
722
723
  ...(request.model !== undefined ? { model: request.model } : {}),
723
724
  ...(request.home !== undefined ? { home: request.home } : {}),
@@ -8,6 +8,12 @@ import { isRecord } from "./unknown-value.ts";
8
8
 
9
9
  export type LedgerSessionRow = Record<string, unknown>;
10
10
 
11
+ /** Complete Pi volume decoding: malformed EOF is an error, with no row-shape filtering. */
12
+ export async function readStrictPiSessionJsonl(path: string): Promise<unknown[]> {
13
+ const text = await readFile(path, "utf8");
14
+ return text.trim().split("\n").filter(Boolean).map((line) => JSON.parse(line) as unknown);
15
+ }
16
+
11
17
  /**
12
18
  * Loud JSONL failure that still retains rows parsed before the bad line.
13
19
  * Callers that only need the throw keep catching Error; owners that must
@@ -9,7 +9,7 @@
9
9
  import { existsSync } from "node:fs";
10
10
  import { basename } from "node:path";
11
11
 
12
- import { sitianReport } from "./sitian-facade.ts";
12
+ import { sitianReportSafe } from "./host-session-record.ts";
13
13
  import {
14
14
  NavigatorUnavailableError,
15
15
  navigatorProviderFailureFromPublicTerminal,
@@ -21,6 +21,7 @@ import {
21
21
  } from "./navigator-session-contracts.ts";
22
22
  import type { NoReceiptLifecycleFacts } from "./receipt-delivery-policy.ts";
23
23
  import { runDirectoryFromHostContext, type HostContext } from "./host-contracts.ts";
24
+ import { parseRunLeaf } from "./role-run-placement.ts";
24
25
  import type { PublicSummonResult } from "./public-role-summons.ts";
25
26
  import { CliUsageError } from "./public-cli/cli-errors.ts";
26
27
  import { isNavigatorSeat } from "./packaged-role-registry.ts";
@@ -57,11 +58,9 @@ async function resolveNavigatorLedgerHome(context: HostContext): Promise<string
57
58
  export const NAVIGATOR_HOST_RUN_POINTER_ENTRY = "ak-navigator-host-run";
58
59
 
59
60
  export function runIdFromNavigatorDirectory(runDirectory: string): string | undefined {
60
- const entry = basename(runDirectory);
61
- const suffix = "@navigator";
62
- if (!entry.endsWith(suffix)) return undefined;
63
- const runId = entry.slice(0, entry.length - suffix.length);
64
- return runId.length > 0 ? runId : undefined;
61
+ const parsed = parseRunLeaf(basename(runDirectory));
62
+ if (parsed === undefined || parsed.role !== "navigator") return undefined;
63
+ return parsed.runId;
65
64
  }
66
65
 
67
66
  export function readNavigatorHostRunPointer(entries: readonly unknown[]): string | undefined {
@@ -285,18 +284,14 @@ export function createNativeNavigatorSessionFactory(deps?: {
285
284
  routePlaybookReadFailure: () => routePlaybookReadFailure,
286
285
  appendEntry: (customType, data) => {
287
286
  sessionManager.appendCustomEntry(customType, data);
288
- try {
289
- sitianReport({
290
- level: "event",
291
- kind: "attendance",
292
- cwd: context.cwd,
293
- sessionParent: sessionManager.getSessionFile(),
294
- payload: { customType, data },
295
- source: "navigator-public-session",
296
- });
297
- } catch {
298
- // best-effort
299
- }
287
+ sitianReportSafe({
288
+ level: "event",
289
+ kind: "attendance",
290
+ cwd: context.cwd,
291
+ sessionParent: sessionManager.getSessionFile(),
292
+ payload: { customType, data },
293
+ source: "navigator-public-session",
294
+ });
300
295
  },
301
296
  entries: () => sessionManager.getEntries(),
302
297
  setModel: async (next, nextThinking) => {
@@ -5,6 +5,8 @@
5
5
  import { readFile } from "node:fs/promises";
6
6
  import { resolve } from "node:path";
7
7
 
8
+ import { sessionDirectoryOf } from "./role-run-placement.ts";
9
+
8
10
  import { loadDoctorCase } from "./doctor-evidence.ts";
9
11
  import { isEnoent } from "./unknown-value.ts";
10
12
  import { runDirectoryFromHostContext, type HostContext } from "./host-contracts.ts";
@@ -69,7 +71,7 @@ export async function loadNavigatorWorkContext(
69
71
  const publicRunDir = runDirectoryFromHostContext(options.context);
70
72
  const currentSessionDir = options.context.sessionManager.getSessionDir();
71
73
  const isBoundPublicRun = publicRunDir !== undefined
72
- && resolve(currentSessionDir) === resolve(publicRunDir, "session");
74
+ && resolve(currentSessionDir) === resolve(sessionDirectoryOf(publicRunDir));
73
75
  if (subjectMode === "public-instruction" && isBoundPublicRun) {
74
76
  let admitted;
75
77
  try {
@@ -1,6 +1,7 @@
1
1
  import type { RoleHost, HostContext } from "./host-contracts.ts";
2
2
  import { registerFiledSubmissionTool } from "./filed-submission.ts";
3
3
  import { roleSubmissionDeclaration } from "./role-submission-declarations.ts";
4
+ import { isSafePositiveTicketNumber } from "./run-ticket-number.ts";
4
5
  /**
5
6
  * Public Notary role runtime — direct officer seat (not through Gatekeeper province).
6
7
  * Caller supplies only a source-run locator; Notary self-fetches authoritative materials.
@@ -44,7 +45,7 @@ export function readNotaryTicketFlag(flag: unknown): number | undefined {
44
45
  }
45
46
  if (flag.trim() === "") return undefined;
46
47
  const n = Number(flag);
47
- if (!Number.isSafeInteger(n) || n < 1) {
48
+ if (!isSafePositiveTicketNumber(n)) {
48
49
  throw new Error(
49
50
  "Notary ak-notary-ticket-number is present but not a safe positive integer string",
50
51
  );