@akagilnc/pi-workflow-roles 0.1.5478 → 0.1.5483

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.
@@ -54,6 +54,257 @@ function codexTomlStringTable(entries) {
54
54
  const parts = Object.entries(entries).map(([key, value]) => `${key}=${codexTomlString(value)}`);
55
55
  return `{${parts.join(",")}}`;
56
56
  }
57
+ /**
58
+ * Free-form JSON leaf for Type.Unknown under Codex strict transport.
59
+ * Declared contract objects still close with additionalProperties:false.
60
+ * For Unknown, a $defs anyOf of JSON values with nested additionalProperties
61
+ * as $ref is accepted by Codex and keeps non-empty object receipts expressible
62
+ * (native probe). Package code still does not validate or reject the receipt.
63
+ */
64
+ const CODEX_JSON_VALUE_DEF = "codexJsonValue";
65
+ const CODEX_JSON_VALUE_REF = `#/$defs/${CODEX_JSON_VALUE_DEF}`;
66
+ const CODEX_JSON_VALUE_SCHEMA = Object.freeze({
67
+ anyOf: Object.freeze([
68
+ Object.freeze({ type: "string" }),
69
+ Object.freeze({ type: "number" }),
70
+ Object.freeze({ type: "boolean" }),
71
+ Object.freeze({ type: "null" }),
72
+ Object.freeze({ type: "array", items: Object.freeze({ $ref: CODEX_JSON_VALUE_REF }) }),
73
+ Object.freeze({
74
+ type: "object",
75
+ properties: Object.freeze({}),
76
+ required: Object.freeze([]),
77
+ additionalProperties: Object.freeze({ $ref: CODEX_JSON_VALUE_REF }),
78
+ }),
79
+ ]),
80
+ });
81
+ /**
82
+ * Derive a Codex/OpenAI-strict transport schema from the package open schema.
83
+ * Legal open schema is untouched; this is a host-only transmission projection
84
+ * (#1148 / #646 / ADR 0057 / ADR 0054): every object closes, every property is
85
+ * required, originally-optional fields become a null union (official guidance),
86
+ * and originally-required declared nullability stays. Nested open-tool anyOf
87
+ * wrappers are flattened so every branch carries `type`, keeping descriptions.
88
+ * Type.Unknown / description-only leaves become the closed JSON-value $ref.
89
+ * Package code still does not validate or reject the receipt.
90
+ */
91
+ export function closeJsonSchemaForCodex(schema) {
92
+ const closed = closeSchemaNode(schema);
93
+ const existingDefs = isRecord(closed.$defs)
94
+ ? closed.$defs
95
+ : {};
96
+ return {
97
+ ...closed,
98
+ $defs: {
99
+ ...existingDefs,
100
+ [CODEX_JSON_VALUE_DEF]: CODEX_JSON_VALUE_SCHEMA,
101
+ },
102
+ };
103
+ }
104
+ /** Pure null leaf only — composite type|null is stripped in-leaf, not dropped whole. */
105
+ function isPureNullTypeSchema(value) {
106
+ if (!isRecord(value))
107
+ return false;
108
+ if (value.type === "null")
109
+ return true;
110
+ if (Array.isArray(value.type) && value.type.length > 0 && value.type.every((t) => t === "null")) {
111
+ return true;
112
+ }
113
+ return false;
114
+ }
115
+ /** Declared nullability on the open schema (type|null, anyOf null leaf, or pure null). */
116
+ function schemaDeclaresNull(schema) {
117
+ if (!isRecord(schema))
118
+ return false;
119
+ if (isPureNullTypeSchema(schema))
120
+ return true;
121
+ if (Array.isArray(schema.type) && schema.type.includes("null"))
122
+ return true;
123
+ if (Array.isArray(schema.anyOf) && schema.anyOf.some(schemaDeclaresNull))
124
+ return true;
125
+ return false;
126
+ }
127
+ /**
128
+ * Within a leaf, remove null members from type arrays.
129
+ * Null is re-added when the union or property edge originally declared it.
130
+ */
131
+ function stripNullFromLeafType(leaf) {
132
+ if (!isRecord(leaf) || !Array.isArray(leaf.type))
133
+ return leaf;
134
+ const nonNull = leaf.type.filter((t) => t !== "null");
135
+ if (nonNull.length === leaf.type.length)
136
+ return leaf;
137
+ if (nonNull.length === 0)
138
+ return { ...leaf, type: "null" };
139
+ if (nonNull.length === 1)
140
+ return { ...leaf, type: nonNull[0] };
141
+ return { ...leaf, type: nonNull };
142
+ }
143
+ /**
144
+ * Flatten nested anyOf wrappers into concrete leaf schemas.
145
+ * Open-tool unions often wrap leaves in description-only anyOf shells that
146
+ * lack `type`; strict structured output rejects those intermediate nodes.
147
+ * Nested shell descriptions attach to leaves that lack their own (not onto $ref).
148
+ * The outermost union keeps its description on the closed node via the caller.
149
+ */
150
+ function flattenUnionLeaves(schema, attachShellDescription = false) {
151
+ if (!isRecord(schema))
152
+ return [schema];
153
+ if (!Array.isArray(schema.anyOf))
154
+ return [schema];
155
+ const shellDescription = attachShellDescription && typeof schema.description === "string"
156
+ ? schema.description
157
+ : undefined;
158
+ return schema.anyOf.flatMap((branch) => {
159
+ const leaves = flattenUnionLeaves(branch, true);
160
+ if (shellDescription === undefined)
161
+ return leaves;
162
+ return leaves.map((leaf) => {
163
+ if (!isRecord(leaf) || typeof leaf.$ref === "string")
164
+ return leaf;
165
+ if (typeof leaf.description === "string")
166
+ return leaf;
167
+ return { ...leaf, description: shellDescription };
168
+ });
169
+ });
170
+ }
171
+ /** Drop pure-null leaves after in-leaf null stripping; property edge may re-add null. */
172
+ function nonNullLeaves(schema) {
173
+ return flattenUnionLeaves(schema)
174
+ .map(stripNullFromLeafType)
175
+ .filter((leaf) => !isPureNullTypeSchema(leaf));
176
+ }
177
+ function withDescription(closed, description) {
178
+ if (description.description === undefined)
179
+ return closed;
180
+ // Codex strict does not allow sibling keys on $ref; wrap so guidance is kept.
181
+ if (isRecord(closed) && typeof closed.$ref === "string") {
182
+ return { anyOf: [closed], ...description };
183
+ }
184
+ // Prefer a leaf's own description (nested anyOf shell) over the outer copy.
185
+ if (isRecord(closed) && typeof closed.description === "string")
186
+ return closed;
187
+ return isRecord(closed) ? { ...closed, ...description } : closed;
188
+ }
189
+ /**
190
+ * Property edge under strict transport.
191
+ * Optional → closed leaf(s) + null. Required with declared nullability keeps null.
192
+ * Required without declared null stays non-nullable.
193
+ */
194
+ function closePropertySchema(schema, optional) {
195
+ const description = isRecord(schema) && typeof schema.description === "string"
196
+ ? { description: schema.description }
197
+ : {};
198
+ const keepNull = optional || schemaDeclaresNull(schema);
199
+ const leaves = nonNullLeaves(schema).map(closeSchemaNode);
200
+ if (leaves.length === 0)
201
+ return { type: "null", ...description };
202
+ if (!keepNull) {
203
+ const closed = leaves.length === 1 ? leaves[0] : { anyOf: leaves };
204
+ return withDescription(closed, description);
205
+ }
206
+ return { anyOf: [...leaves, { type: "null" }], ...description };
207
+ }
208
+ /**
209
+ * Strict generators require every schema node to declare `type` (or a $ref).
210
+ * Type.Unknown / description-only leaves → free JSON $ref (not string): array
211
+ * and object receipts stay expressible under --output-schema.
212
+ */
213
+ function ensureTypedLeaf(schema) {
214
+ if (schema.type !== undefined)
215
+ return schema;
216
+ if (typeof schema.$ref === "string")
217
+ return schema;
218
+ if (isRecord(schema.properties) || schema.additionalProperties !== undefined) {
219
+ return { ...schema, type: "object" };
220
+ }
221
+ if (schema.items !== undefined) {
222
+ return { ...schema, type: "array" };
223
+ }
224
+ if (schema.const !== undefined) {
225
+ const value = schema.const;
226
+ if (value === null)
227
+ return { ...schema, type: "null" };
228
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
229
+ return { ...schema, type: typeof value };
230
+ }
231
+ }
232
+ if (Array.isArray(schema.enum) && schema.enum.length > 0) {
233
+ const sample = schema.enum.find((item) => item !== null);
234
+ if (typeof sample === "string" || typeof sample === "number" || typeof sample === "boolean") {
235
+ return { ...schema, type: typeof sample };
236
+ }
237
+ }
238
+ // Free JSON via $defs. $ref must stand alone (strict rejects sibling keys).
239
+ return { $ref: CODEX_JSON_VALUE_REF };
240
+ }
241
+ function originalRequiredNames(node) {
242
+ if (!Array.isArray(node.required))
243
+ return new Set();
244
+ return new Set(node.required.filter((item) => typeof item === "string"));
245
+ }
246
+ function closeSchemaNode(node) {
247
+ if (!isRecord(node))
248
+ return node;
249
+ // Already a ref (free-JSON leaf or pre-existing) — do not retype.
250
+ if (typeof node.$ref === "string")
251
+ return node;
252
+ // Union node: flatten then close each concrete leaf (do not keep untyped shells).
253
+ // Declared null stays — including nested unions such as array items.
254
+ if (Array.isArray(node.anyOf)) {
255
+ const description = typeof node.description === "string" ? { description: node.description } : {};
256
+ const keepNull = schemaDeclaresNull(node);
257
+ const leaves = nonNullLeaves(node).map(closeSchemaNode);
258
+ if (leaves.length === 0)
259
+ return { type: "null", ...description };
260
+ if (!keepNull) {
261
+ if (leaves.length === 1)
262
+ return withDescription(leaves[0], description);
263
+ return { anyOf: leaves, ...description };
264
+ }
265
+ return { anyOf: [...leaves, { type: "null" }], ...description };
266
+ }
267
+ let out = { ...node };
268
+ if (node.items !== undefined) {
269
+ out.items = closeSchemaNode(node.items);
270
+ }
271
+ if (isRecord(node.$defs)) {
272
+ out.$defs = Object.fromEntries(Object.entries(node.$defs).map(([key, value]) => [key, closeSchemaNode(value)]));
273
+ }
274
+ const hasProperties = isRecord(node.properties);
275
+ const isObjectType = node.type === "object"
276
+ || (Array.isArray(node.type) && node.type.includes("object"))
277
+ || hasProperties
278
+ || node.additionalProperties !== undefined;
279
+ if (hasProperties) {
280
+ const props = node.properties;
281
+ const wasRequired = originalRequiredNames(node);
282
+ const closedProps = {};
283
+ const required = [];
284
+ for (const [name, propSchema] of Object.entries(props)) {
285
+ required.push(name);
286
+ closedProps[name] = closePropertySchema(propSchema, !wasRequired.has(name));
287
+ }
288
+ out.properties = closedProps;
289
+ out.required = required;
290
+ out.additionalProperties = false;
291
+ if (out.type === undefined)
292
+ out.type = "object";
293
+ // Object nodes must not also carry residual anyOf from the open copy.
294
+ delete out.anyOf;
295
+ }
296
+ else if (isObjectType) {
297
+ out.additionalProperties = false;
298
+ if (!Array.isArray(out.required))
299
+ out.required = [];
300
+ if (out.type === undefined)
301
+ out.type = "object";
302
+ }
303
+ else {
304
+ out = ensureTypedLeaf(out);
305
+ }
306
+ return out;
307
+ }
57
308
  /**
58
309
  * Project shared-envelope MCP rows into `codex -c mcp_servers.<name>.*` argv pairs.
59
310
  * Dot-path + TOML values per official config-advanced; spawn argv (no shell).
@@ -25039,6 +25039,169 @@ function codexTomlStringTable(entries) {
25039
25039
  );
25040
25040
  return `{${parts.join(",")}}`;
25041
25041
  }
25042
+ var CODEX_JSON_VALUE_DEF = "codexJsonValue";
25043
+ var CODEX_JSON_VALUE_REF = `#/$defs/${CODEX_JSON_VALUE_DEF}`;
25044
+ var CODEX_JSON_VALUE_SCHEMA = Object.freeze({
25045
+ anyOf: Object.freeze([
25046
+ Object.freeze({ type: "string" }),
25047
+ Object.freeze({ type: "number" }),
25048
+ Object.freeze({ type: "boolean" }),
25049
+ Object.freeze({ type: "null" }),
25050
+ Object.freeze({ type: "array", items: Object.freeze({ $ref: CODEX_JSON_VALUE_REF }) }),
25051
+ Object.freeze({
25052
+ type: "object",
25053
+ properties: Object.freeze({}),
25054
+ required: Object.freeze([]),
25055
+ additionalProperties: Object.freeze({ $ref: CODEX_JSON_VALUE_REF })
25056
+ })
25057
+ ])
25058
+ });
25059
+ function closeJsonSchemaForCodex(schema) {
25060
+ const closed = closeSchemaNode(schema);
25061
+ const existingDefs = isRecord(closed.$defs) ? closed.$defs : {};
25062
+ return {
25063
+ ...closed,
25064
+ $defs: {
25065
+ ...existingDefs,
25066
+ [CODEX_JSON_VALUE_DEF]: CODEX_JSON_VALUE_SCHEMA
25067
+ }
25068
+ };
25069
+ }
25070
+ function isPureNullTypeSchema(value) {
25071
+ if (!isRecord(value)) return false;
25072
+ if (value.type === "null") return true;
25073
+ if (Array.isArray(value.type) && value.type.length > 0 && value.type.every((t) => t === "null")) {
25074
+ return true;
25075
+ }
25076
+ return false;
25077
+ }
25078
+ function schemaDeclaresNull(schema) {
25079
+ if (!isRecord(schema)) return false;
25080
+ if (isPureNullTypeSchema(schema)) return true;
25081
+ if (Array.isArray(schema.type) && schema.type.includes("null")) return true;
25082
+ if (Array.isArray(schema.anyOf) && schema.anyOf.some(schemaDeclaresNull)) return true;
25083
+ return false;
25084
+ }
25085
+ function stripNullFromLeafType(leaf) {
25086
+ if (!isRecord(leaf) || !Array.isArray(leaf.type)) return leaf;
25087
+ const nonNull = leaf.type.filter((t) => t !== "null");
25088
+ if (nonNull.length === leaf.type.length) return leaf;
25089
+ if (nonNull.length === 0) return { ...leaf, type: "null" };
25090
+ if (nonNull.length === 1) return { ...leaf, type: nonNull[0] };
25091
+ return { ...leaf, type: nonNull };
25092
+ }
25093
+ function flattenUnionLeaves(schema, attachShellDescription = false) {
25094
+ if (!isRecord(schema)) return [schema];
25095
+ if (!Array.isArray(schema.anyOf)) return [schema];
25096
+ const shellDescription = attachShellDescription && typeof schema.description === "string" ? schema.description : void 0;
25097
+ return schema.anyOf.flatMap((branch) => {
25098
+ const leaves = flattenUnionLeaves(branch, true);
25099
+ if (shellDescription === void 0) return leaves;
25100
+ return leaves.map((leaf) => {
25101
+ if (!isRecord(leaf) || typeof leaf.$ref === "string") return leaf;
25102
+ if (typeof leaf.description === "string") return leaf;
25103
+ return { ...leaf, description: shellDescription };
25104
+ });
25105
+ });
25106
+ }
25107
+ function nonNullLeaves(schema) {
25108
+ return flattenUnionLeaves(schema).map(stripNullFromLeafType).filter((leaf) => !isPureNullTypeSchema(leaf));
25109
+ }
25110
+ function withDescription(closed, description) {
25111
+ if (description.description === void 0) return closed;
25112
+ if (isRecord(closed) && typeof closed.$ref === "string") {
25113
+ return { anyOf: [closed], ...description };
25114
+ }
25115
+ if (isRecord(closed) && typeof closed.description === "string") return closed;
25116
+ return isRecord(closed) ? { ...closed, ...description } : closed;
25117
+ }
25118
+ function closePropertySchema(schema, optional) {
25119
+ const description = isRecord(schema) && typeof schema.description === "string" ? { description: schema.description } : {};
25120
+ const keepNull = optional || schemaDeclaresNull(schema);
25121
+ const leaves = nonNullLeaves(schema).map(closeSchemaNode);
25122
+ if (leaves.length === 0) return { type: "null", ...description };
25123
+ if (!keepNull) {
25124
+ const closed = leaves.length === 1 ? leaves[0] : { anyOf: leaves };
25125
+ return withDescription(closed, description);
25126
+ }
25127
+ return { anyOf: [...leaves, { type: "null" }], ...description };
25128
+ }
25129
+ function ensureTypedLeaf(schema) {
25130
+ if (schema.type !== void 0) return schema;
25131
+ if (typeof schema.$ref === "string") return schema;
25132
+ if (isRecord(schema.properties) || schema.additionalProperties !== void 0) {
25133
+ return { ...schema, type: "object" };
25134
+ }
25135
+ if (schema.items !== void 0) {
25136
+ return { ...schema, type: "array" };
25137
+ }
25138
+ if (schema.const !== void 0) {
25139
+ const value = schema.const;
25140
+ if (value === null) return { ...schema, type: "null" };
25141
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
25142
+ return { ...schema, type: typeof value };
25143
+ }
25144
+ }
25145
+ if (Array.isArray(schema.enum) && schema.enum.length > 0) {
25146
+ const sample = schema.enum.find((item) => item !== null);
25147
+ if (typeof sample === "string" || typeof sample === "number" || typeof sample === "boolean") {
25148
+ return { ...schema, type: typeof sample };
25149
+ }
25150
+ }
25151
+ return { $ref: CODEX_JSON_VALUE_REF };
25152
+ }
25153
+ function originalRequiredNames(node) {
25154
+ if (!Array.isArray(node.required)) return /* @__PURE__ */ new Set();
25155
+ return new Set(node.required.filter((item) => typeof item === "string"));
25156
+ }
25157
+ function closeSchemaNode(node) {
25158
+ if (!isRecord(node)) return node;
25159
+ if (typeof node.$ref === "string") return node;
25160
+ if (Array.isArray(node.anyOf)) {
25161
+ const description = typeof node.description === "string" ? { description: node.description } : {};
25162
+ const keepNull = schemaDeclaresNull(node);
25163
+ const leaves = nonNullLeaves(node).map(closeSchemaNode);
25164
+ if (leaves.length === 0) return { type: "null", ...description };
25165
+ if (!keepNull) {
25166
+ if (leaves.length === 1) return withDescription(leaves[0], description);
25167
+ return { anyOf: leaves, ...description };
25168
+ }
25169
+ return { anyOf: [...leaves, { type: "null" }], ...description };
25170
+ }
25171
+ let out = { ...node };
25172
+ if (node.items !== void 0) {
25173
+ out.items = closeSchemaNode(node.items);
25174
+ }
25175
+ if (isRecord(node.$defs)) {
25176
+ out.$defs = Object.fromEntries(
25177
+ Object.entries(node.$defs).map(([key, value]) => [key, closeSchemaNode(value)])
25178
+ );
25179
+ }
25180
+ const hasProperties = isRecord(node.properties);
25181
+ const isObjectType = node.type === "object" || Array.isArray(node.type) && node.type.includes("object") || hasProperties || node.additionalProperties !== void 0;
25182
+ if (hasProperties) {
25183
+ const props = node.properties;
25184
+ const wasRequired = originalRequiredNames(node);
25185
+ const closedProps = {};
25186
+ const required = [];
25187
+ for (const [name, propSchema] of Object.entries(props)) {
25188
+ required.push(name);
25189
+ closedProps[name] = closePropertySchema(propSchema, !wasRequired.has(name));
25190
+ }
25191
+ out.properties = closedProps;
25192
+ out.required = required;
25193
+ out.additionalProperties = false;
25194
+ if (out.type === void 0) out.type = "object";
25195
+ delete out.anyOf;
25196
+ } else if (isObjectType) {
25197
+ out.additionalProperties = false;
25198
+ if (!Array.isArray(out.required)) out.required = [];
25199
+ if (out.type === void 0) out.type = "object";
25200
+ } else {
25201
+ out = ensureTypedLeaf(out);
25202
+ }
25203
+ return out;
25204
+ }
25042
25205
  function stringEnvironment(value) {
25043
25206
  const entries = Array.isArray(value) ? value.flatMap((item) => {
25044
25207
  if (!isRecord(item) || typeof item.name !== "string" || typeof item.value !== "string") return [];
@@ -25420,12 +25583,12 @@ function createHeadlessRoleTurnHost(config) {
25420
25583
  if (codex) {
25421
25584
  if (prepared.terminatingToolName !== NAVIGATOR_OUTPUT_TOOL_NAME) {
25422
25585
  outputSchemaPath = join44(request.runDirectory, "headless-output-schema.json");
25423
- await writeFile9(outputSchemaPath, `${JSON.stringify(prepared.jsonSchema, null, 2)}
25424
- `, "utf8");
25425
- await retainPackageFault({
25426
- runDirectory: request.runDirectory,
25427
- diagnostic: "codex --output-schema cannot promise this open contract: native strict output requires all properties and additionalProperties:false; the declaration is sent unchanged"
25428
- });
25586
+ await writeFile9(
25587
+ outputSchemaPath,
25588
+ `${JSON.stringify(closeJsonSchemaForCodex(prepared.jsonSchema), null, 2)}
25589
+ `,
25590
+ "utf8"
25591
+ );
25429
25592
  }
25430
25593
  } else {
25431
25594
  mcpConfigPath = join44(request.runDirectory, "headless-mcp-config.json");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akagilnc/pi-workflow-roles",
3
- "version": "0.1.5478",
3
+ "version": "0.1.5483",
4
4
  "description": "Soul-bound workflow roles for Pi",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -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
 
@@ -32,6 +32,7 @@ import {
32
32
  recordNativeSessionPointer,
33
33
  } from "../host-session-record.ts";
34
34
  import {
35
+ closeJsonSchemaForCodex,
35
36
  codexTurnArgs,
36
37
  headlessMcpConfigDocument,
37
38
  headlessTurnArgs,
@@ -465,16 +466,15 @@ export function createHeadlessRoleTurnHost(config: HeadlessRoleTurnHostConfig):
465
466
  let mcpConfigPath: string | undefined;
466
467
  let outputSchemaPath: string | undefined;
467
468
  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.
469
+ // #1148: Codex --output-schema requires a native strict transport schema.
470
+ // Project from the unique open declaration; do not alter package receipt rules.
470
471
  if (prepared.terminatingToolName !== NAVIGATOR_OUTPUT_TOOL_NAME) {
471
472
  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
- });
473
+ await writeFile(
474
+ outputSchemaPath,
475
+ `${JSON.stringify(closeJsonSchemaForCodex(prepared.jsonSchema), null, 2)}\n`,
476
+ "utf8",
477
+ );
478
478
  }
479
479
  } else {
480
480
  mcpConfigPath = join(request.runDirectory, "headless-mcp-config.json");