From 47e752a7c1bb79a97b9d993a83d22a4afb5df8cd Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 18 Aug 2026 19:48:38 +0900 Subject: [PATCH 1/2] refactor(types): move value clusters to types/tools and types/wire leaves --- src/types.ts | 234 +++++---------------------------------------- src/types/tools.ts | 131 +++++++++++++++++++++++++ src/types/wire.ts | 80 ++++++++++++++++ 3 files changed, 234 insertions(+), 211 deletions(-) create mode 100644 src/types/tools.ts create mode 100644 src/types/wire.ts diff --git a/src/types.ts b/src/types.ts index c3e4a4eaca..e341ea6366 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,6 @@ import type { KiroOAuthMetadata } from "./oauth/types"; +import type { OcxTool, OcxToolChoice } from "./types/tools"; +import type { UpstreamHttpVersion, ReasoningSummaryDelivery, CodexAccountMode } from "./types/wire"; /** Exact provider/credential namespace for process-local reasoning replay. */ export interface OcxReasoningReplayIdentity { @@ -214,137 +216,17 @@ export interface OcxProviderOpaqueToolCallMetadata { export type OcxAssistantContentPart = OcxTextContent | OcxThinkingContent | OcxToolCall; -export interface OcxTool { - name: string; - description: string; - parameters: Record; - strict?: boolean; - /** MCP namespace (e.g. "mcp__context7") for tools flattened out of a Responses "namespace" tool. */ - namespace?: string; - /** Freeform/custom tool (e.g. apply_patch): the model's call must be relayed as a custom_tool_call. */ - freeform?: boolean; - /** Client-executed tool discovery (tool_search): the model's call must be relayed as a tool_search_call. */ - toolSearch?: boolean; - /** Tool definition restored from a prior tool_search output; transports may prioritize it when catalogs are bounded. */ - loadedFromToolSearch?: boolean; - /** Cursor-only synthetic exact-match edit tool; never inferred from the wire name. */ - cursorStructuredEdit?: true; - /** Synthetic web_search tool: the model's call is executed by the gpt-5.4-mini sidecar, not relayed to Codex. */ - webSearch?: boolean; - /** Synthetic image_gen tool: the model's call is executed by the xAI image bridge sidecar, not relayed to Codex. */ - imageGeneration?: boolean; - /** Synthetic video_gen tool: executed by the xAI video bridge sidecar. */ - videoGeneration?: boolean; -} - -/** - * Wire name a chat model sees for a tool. Namespaced (MCP) tools are flattened to - * "__" so they survive the chat-completions function-tool format; - * the proxy maps this back to {namespace, name} on the return trip (Codex routes MCP - * calls by an explicit `namespace` field, not by parsing the name). - */ -export function namespacedToolName(namespace: string | undefined, name: string): string { - return namespace ? `${namespace}__${name}` : name; -} - -export function toolChoiceAliases(tool: Pick): string[] { - const wireName = namespacedToolName(tool.namespace, tool.name); - return tool.namespace ? [wireName, `${tool.namespace}.${tool.name}`] : [wireName]; -} - -function sameToolIdentity( - left: Pick, - right: Pick, -): boolean { - return left.namespace === right.namespace && left.name === right.name; -} - -/** - * All tools that could be selected by one client-facing name. Bare logical names are included - * here because they are a compatibility selector for namespaced tools, while wire and dotted - * aliases come from `toolChoiceAliases`. A selector with more than one candidate is invalid. - */ -export function toolChoiceCandidates( - tools: readonly Pick[] | undefined, - name: string, -): Pick[] { - if (!tools) return []; - const candidates: Pick[] = []; - for (const tool of tools) { - if (tool.name !== name && !toolChoiceAliases(tool).includes(name)) continue; - if (!candidates.some(candidate => sameToolIdentity(candidate, tool))) candidates.push(tool); - } - return candidates; -} - -/** - * Newer Codex clients can select a tool nested in a namespace by its bare name. Resolve that - * shorthand only when the request contains one tool with the logical name, so an ambiguous name - * cannot authorize a tool from an unintended namespace. - */ -export function toolAllowedByChoice( - tool: Pick, - allowedTools: ReadonlySet, - tools?: readonly Pick[], -): boolean { - if (!tools) return toolChoiceAliases(tool).some(name => allowedTools.has(name)); - for (const name of [...toolChoiceAliases(tool), tool.name]) { - if (!allowedTools.has(name)) continue; - const candidates = toolChoiceCandidates(tools, name); - if (candidates.length === 1 && sameToolIdentity(candidates[0], tool)) return true; - } - return false; -} - -export function resolveToolChoiceWireName(tools: readonly Pick[] | undefined, name: string): string { - const candidates = toolChoiceCandidates(tools, name); - if (candidates.length === 1) { - const match = candidates[0]; - return namespacedToolName(match.namespace, match.name); - } - // Keep unknown/ambiguous names unchanged for callers that only serialize a selector. The - // catalog-aware predicate rejects them, and parseRequest rejects ambiguous request selectors. - return name; -} - -/** - * Whether `modelId` is in a per-provider classification list (e.g. `noVisionModels`). Matches the full - * id, OR — for Ollama-style ids — the family before the ":size" tag, so a `gpt-oss` entry covers - * `gpt-oss:120b`/`gpt-oss:20b`. Colon-less ids (e.g. `grok-build-0.1`) still match exactly only. - */ -export function modelInList(list: string[] | undefined, modelId: string): boolean { - if (!list || list.length === 0) return false; - if (list.includes(modelId)) return true; - const colon = modelId.indexOf(":"); - return colon > 0 && list.includes(modelId.slice(0, colon)); -} - -export type OcxToolChoice = - | "auto" - | "none" - | "required" - | { name: string } - | { allowedTools: string[]; mode: "auto" | "required" }; - -export function isAllowedToolChoice(value: OcxToolChoice | undefined): value is { allowedTools: string[]; mode: "auto" | "required" } { - return typeof value === "object" && value !== null && "allowedTools" in value; -} - -/** Compile the request's tool-choice policy into a reusable advertisement/restoration predicate. */ -export function toolChoiceToolPredicate( - choice: OcxToolChoice | undefined, - tools?: readonly Pick[], -): (tool: Pick) => boolean { - if (!choice || choice === "auto" || choice === "required") return () => true; - if (choice === "none") return () => false; - if (isAllowedToolChoice(choice)) { - const allowed = new Set(choice.allowedTools); - return tool => toolAllowedByChoice(tool, allowed, tools); - } - if (!tools) return tool => toolChoiceAliases(tool).includes(choice.name); - const candidates = toolChoiceCandidates(tools, choice.name); - return tool => candidates.length === 1 && sameToolIdentity(candidates[0], tool); -} +export type { OcxTool, OcxToolChoice } from "./types/tools"; +export { + namespacedToolName, + toolChoiceAliases, + toolChoiceCandidates, + toolAllowedByChoice, + resolveToolChoiceWireName, + modelInList, + isAllowedToolChoice, + toolChoiceToolPredicate, +} from "./types/tools"; export interface OcxRequestOptions { maxOutputTokens?: number; @@ -1825,86 +1707,16 @@ export interface OcxProviderConfig { nativeLocalExec?: "off" | "codex-sandbox" | "on"; } -/** - * Accepted values for the per-provider upstream HTTP-version pin (#1668). Shared by the - * zod load schema, the management write boundary (POST/PATCH), and the fetch runtime, so - * a value that one boundary accepts can never be rejected by another. - */ -export const UPSTREAM_HTTP_VERSION_VALUES = [ - "auto", - "http1.1", - "h1", - "http2", - "h2", -] as const; - -export type UpstreamHttpVersion = (typeof UPSTREAM_HTTP_VERSION_VALUES)[number]; - -export const REASONING_SUMMARY_DELIVERY_VALUES = [ - "sequential", - "sequential_cutoff", - "concurrent", - "concurrent_cutoff", -] as const; - -export type ReasoningSummaryDelivery = typeof REASONING_SUMMARY_DELIVERY_VALUES[number]; - -/** Trusted runtime ownership for Codex-account credentials. Never persisted per provider. */ -export type CodexAccountMode = "direct" | "pool"; - -export const OPENAI_PROVIDER_TIER_VERSION = 2 as const; - -/** - * Wires that a per-model `modelAdapters` override may select. - * - * Deliberately narrow: provider-specific adapters (cursor, kiro, google, ...) carry - * their own credential and base-URL semantics, so exposing them here would widen the - * auth boundary rather than pick a wire. Widening this set needs a per-adapter - * credential threat model first (#404). - */ -export const MODEL_ADAPTER_OVERRIDE_ALLOWED: ReadonlySet = new Set([ - "openai-chat", - "openai-responses", -]); - -/** - * Providers whose listed model ids must be driven over the Anthropic wire even when - * the provider's configured adapter says otherwise — the upstream only speaks - * Anthropic for these models. - */ -const ANTHROPIC_WIRE_MODELS: Record> = { - "opencode-go": new Set(["minimax-m2.5", "minimax-m2.7", "minimax-m3"]), -}; - -function anthropicWireModelsForProvider(providerName: string): ReadonlySet | undefined { - return Object.hasOwn(ANTHROPIC_WIRE_MODELS, providerName) - ? ANTHROPIC_WIRE_MODELS[providerName] - : undefined; -} - -/** Detached provider-local hard-pin table for pure wire-policy resolution. */ -export function captureWireAdapterHardPins(providerName: string): Readonly> { - const models = anthropicWireModelsForProvider(providerName); - if (!models) return Object.freeze({}); - return Object.freeze(Object.fromEntries([...models].map(modelId => [modelId, "anthropic"]))); -} - -/** - * True when the upstream speaks exactly one wire for this model, so a configured - * override must not apply. - * - * Deliberately independent of the provider's current adapter: the wire resolver runs - * more than once per request, and a check phrased as "pin differs from the current - * adapter" would pass on the first pass and then let the override win on the second. - */ -export function isWirePinnedModel(providerName: string, modelId: string): boolean { - return anthropicWireModelsForProvider(providerName)?.has(modelId) ?? false; -} - -/** The wire a pinned model must use, or undefined when the model is not pinned. */ -export function pinnedWireAdapter(providerName: string, modelId: string): string | undefined { - return isWirePinnedModel(providerName, modelId) ? "anthropic" : undefined; -} +export type { UpstreamHttpVersion, ReasoningSummaryDelivery, CodexAccountMode } from "./types/wire"; +export { + UPSTREAM_HTTP_VERSION_VALUES, + REASONING_SUMMARY_DELIVERY_VALUES, + OPENAI_PROVIDER_TIER_VERSION, + MODEL_ADAPTER_OVERRIDE_ALLOWED, + captureWireAdapterHardPins, + isWirePinnedModel, + pinnedWireAdapter, +} from "./types/wire"; export interface CodexAccount { id: string; diff --git a/src/types/tools.ts b/src/types/tools.ts new file mode 100644 index 0000000000..9e3dc37fd0 --- /dev/null +++ b/src/types/tools.ts @@ -0,0 +1,131 @@ +export interface OcxTool { + name: string; + description: string; + parameters: Record; + strict?: boolean; + /** MCP namespace (e.g. "mcp__context7") for tools flattened out of a Responses "namespace" tool. */ + namespace?: string; + /** Freeform/custom tool (e.g. apply_patch): the model's call must be relayed as a custom_tool_call. */ + freeform?: boolean; + /** Client-executed tool discovery (tool_search): the model's call must be relayed as a tool_search_call. */ + toolSearch?: boolean; + /** Tool definition restored from a prior tool_search output; transports may prioritize it when catalogs are bounded. */ + loadedFromToolSearch?: boolean; + /** Cursor-only synthetic exact-match edit tool; never inferred from the wire name. */ + cursorStructuredEdit?: true; + /** Synthetic web_search tool: the model's call is executed by the gpt-5.4-mini sidecar, not relayed to Codex. */ + webSearch?: boolean; + /** Synthetic image_gen tool: the model's call is executed by the xAI image bridge sidecar, not relayed to Codex. */ + imageGeneration?: boolean; + /** Synthetic video_gen tool: executed by the xAI video bridge sidecar. */ + videoGeneration?: boolean; +} + +/** + * Wire name a chat model sees for a tool. Namespaced (MCP) tools are flattened to + * "__" so they survive the chat-completions function-tool format; + * the proxy maps this back to {namespace, name} on the return trip (Codex routes MCP + * calls by an explicit `namespace` field, not by parsing the name). + */ +export function namespacedToolName(namespace: string | undefined, name: string): string { + return namespace ? `${namespace}__${name}` : name; +} + +export function toolChoiceAliases(tool: Pick): string[] { + const wireName = namespacedToolName(tool.namespace, tool.name); + return tool.namespace ? [wireName, `${tool.namespace}.${tool.name}`] : [wireName]; +} + +function sameToolIdentity( + left: Pick, + right: Pick, +): boolean { + return left.namespace === right.namespace && left.name === right.name; +} + +/** + * All tools that could be selected by one client-facing name. Bare logical names are included + * here because they are a compatibility selector for namespaced tools, while wire and dotted + * aliases come from `toolChoiceAliases`. A selector with more than one candidate is invalid. + */ +export function toolChoiceCandidates( + tools: readonly Pick[] | undefined, + name: string, +): Pick[] { + if (!tools) return []; + const candidates: Pick[] = []; + for (const tool of tools) { + if (tool.name !== name && !toolChoiceAliases(tool).includes(name)) continue; + if (!candidates.some(candidate => sameToolIdentity(candidate, tool))) candidates.push(tool); + } + return candidates; +} + +/** + * Newer Codex clients can select a tool nested in a namespace by its bare name. Resolve that + * shorthand only when the request contains one tool with the logical name, so an ambiguous name + * cannot authorize a tool from an unintended namespace. + */ +export function toolAllowedByChoice( + tool: Pick, + allowedTools: ReadonlySet, + tools?: readonly Pick[], +): boolean { + if (!tools) return toolChoiceAliases(tool).some(name => allowedTools.has(name)); + for (const name of [...toolChoiceAliases(tool), tool.name]) { + if (!allowedTools.has(name)) continue; + const candidates = toolChoiceCandidates(tools, name); + if (candidates.length === 1 && sameToolIdentity(candidates[0], tool)) return true; + } + return false; +} + +export function resolveToolChoiceWireName(tools: readonly Pick[] | undefined, name: string): string { + const candidates = toolChoiceCandidates(tools, name); + if (candidates.length === 1) { + const match = candidates[0]; + return namespacedToolName(match.namespace, match.name); + } + // Keep unknown/ambiguous names unchanged for callers that only serialize a selector. The + // catalog-aware predicate rejects them, and parseRequest rejects ambiguous request selectors. + return name; +} + +/** + * Whether `modelId` is in a per-provider classification list (e.g. `noVisionModels`). Matches the full + * id, OR — for Ollama-style ids — the family before the ":size" tag, so a `gpt-oss` entry covers + * `gpt-oss:120b`/`gpt-oss:20b`. Colon-less ids (e.g. `grok-build-0.1`) still match exactly only. + */ +export function modelInList(list: string[] | undefined, modelId: string): boolean { + if (!list || list.length === 0) return false; + if (list.includes(modelId)) return true; + const colon = modelId.indexOf(":"); + return colon > 0 && list.includes(modelId.slice(0, colon)); +} + +export type OcxToolChoice = + | "auto" + | "none" + | "required" + | { name: string } + | { allowedTools: string[]; mode: "auto" | "required" }; + +export function isAllowedToolChoice(value: OcxToolChoice | undefined): value is { allowedTools: string[]; mode: "auto" | "required" } { + return typeof value === "object" && value !== null && "allowedTools" in value; +} + +/** Compile the request's tool-choice policy into a reusable advertisement/restoration predicate. */ +export function toolChoiceToolPredicate( + choice: OcxToolChoice | undefined, + tools?: readonly Pick[], +): (tool: Pick) => boolean { + if (!choice || choice === "auto" || choice === "required") return () => true; + if (choice === "none") return () => false; + if (isAllowedToolChoice(choice)) { + const allowed = new Set(choice.allowedTools); + return tool => toolAllowedByChoice(tool, allowed, tools); + } + if (!tools) return tool => toolChoiceAliases(tool).includes(choice.name); + const candidates = toolChoiceCandidates(tools, choice.name); + return tool => candidates.length === 1 && sameToolIdentity(candidates[0], tool); +} diff --git a/src/types/wire.ts b/src/types/wire.ts new file mode 100644 index 0000000000..0800bca428 --- /dev/null +++ b/src/types/wire.ts @@ -0,0 +1,80 @@ +/** + * Accepted values for the per-provider upstream HTTP-version pin (#1668). Shared by the + * zod load schema, the management write boundary (POST/PATCH), and the fetch runtime, so + * a value that one boundary accepts can never be rejected by another. + */ +export const UPSTREAM_HTTP_VERSION_VALUES = [ + "auto", + "http1.1", + "h1", + "http2", + "h2", +] as const; + +export type UpstreamHttpVersion = (typeof UPSTREAM_HTTP_VERSION_VALUES)[number]; + +export const REASONING_SUMMARY_DELIVERY_VALUES = [ + "sequential", + "sequential_cutoff", + "concurrent", + "concurrent_cutoff", +] as const; + +export type ReasoningSummaryDelivery = typeof REASONING_SUMMARY_DELIVERY_VALUES[number]; + +/** Trusted runtime ownership for Codex-account credentials. Never persisted per provider. */ +export type CodexAccountMode = "direct" | "pool"; + +export const OPENAI_PROVIDER_TIER_VERSION = 2 as const; + +/** + * Wires that a per-model `modelAdapters` override may select. + * + * Deliberately narrow: provider-specific adapters (cursor, kiro, google, ...) carry + * their own credential and base-URL semantics, so exposing them here would widen the + * auth boundary rather than pick a wire. Widening this set needs a per-adapter + * credential threat model first (#404). + */ +export const MODEL_ADAPTER_OVERRIDE_ALLOWED: ReadonlySet = new Set([ + "openai-chat", + "openai-responses", +]); + +/** + * Providers whose listed model ids must be driven over the Anthropic wire even when + * the provider's configured adapter says otherwise — the upstream only speaks + * Anthropic for these models. + */ +const ANTHROPIC_WIRE_MODELS: Record> = { + "opencode-go": new Set(["minimax-m2.5", "minimax-m2.7", "minimax-m3"]), +}; + +function anthropicWireModelsForProvider(providerName: string): ReadonlySet | undefined { + return Object.hasOwn(ANTHROPIC_WIRE_MODELS, providerName) + ? ANTHROPIC_WIRE_MODELS[providerName] + : undefined; +} + +/** Detached provider-local hard-pin table for pure wire-policy resolution. */ +export function captureWireAdapterHardPins(providerName: string): Readonly> { + const models = anthropicWireModelsForProvider(providerName); + if (!models) return Object.freeze({}); + return Object.freeze(Object.fromEntries([...models].map(modelId => [modelId, "anthropic"]))); +} + +/** + * True when the upstream speaks exactly one wire for this model, so a configured + * override must not apply. + * + * Deliberately independent of the provider's current adapter: the wire resolver runs + * more than once per request, and a check phrased as "pin differs from the current + * adapter" would pass on the first pass and then let the override win on the second. + */ +export function isWirePinnedModel(providerName: string, modelId: string): boolean { + return anthropicWireModelsForProvider(providerName)?.has(modelId) ?? false; +} + +/** The wire a pinned model must use, or undefined when the model is not pinned. */ +export function pinnedWireAdapter(providerName: string, modelId: string): string | undefined { + return isWirePinnedModel(providerName, modelId) ? "anthropic" : undefined; +} From 3ddae409598f2db58b1bf4b2b8731e3fb6592adf Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 18 Aug 2026 19:48:38 +0900 Subject: [PATCH 2/2] docs(devlog): WP1 plan + split-program risk assessment --- .../010_wp1_types_value_leaves.md | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 devlog/_plan/260818_megafile_split_program/010_wp1_types_value_leaves.md diff --git a/devlog/_plan/260818_megafile_split_program/010_wp1_types_value_leaves.md b/devlog/_plan/260818_megafile_split_program/010_wp1_types_value_leaves.md new file mode 100644 index 0000000000..653f95d991 --- /dev/null +++ b/devlog/_plan/260818_megafile_split_program/010_wp1_types_value_leaves.md @@ -0,0 +1,104 @@ +# WP1 — types.ts value-leaf extraction (stacked PR 1 of the split program) + +Unit: devlog/_plan/260818_megafile_split_program. Risk basis: 000_risk_assessment.md. +Branch: codex/split-wp1-types on dev @ b04cd26e7 (post FastWire B0/B1 merge). +Class: C2 (mechanical move, shared-runtime file, full-suite gate). + +## Loop spec + +- Archetype: pure-move refactor, zero behavior change. +- Trigger: split program WP1, lowest-risk opener. +- Goal: src/types.ts stops carrying runtime value code; values live in leaves; + every existing import keeps working via re-export. +- Non-goals: NO type-cluster split yet (OcxConfig/OcxProviderConfig stay), + NO consumer retargeting to leaf paths, NO behavior or signature change. +- Verifier: bun run typecheck && bun run test (full — shared runtime file). +- Stop: both green + core-lab-boundary green; PR opened against dev. +- Memory artifact: this doc + ledger attests. + +## Scope (IN) + +Extract the two VALUE clusters from src/types.ts (1867 lines) into leaves: + +1. src/types/tools.ts — lines ~236-292: + namespacedToolName, toolChoiceAliases, toolAllowedByChoice, + resolveToolChoiceWireName, modelInList, OcxToolChoice (type), + isAllowedToolChoice, toolChoiceToolPredicate. + Needs `import type { OcxTool } from "../types"` — type-only, erased at + runtime, so the types.ts -> tools.ts re-export is NOT a runtime cycle. +2. src/types/wire.ts — lines ~1760-1839: + UPSTREAM_HTTP_VERSION_VALUES, UpstreamHttpVersion, + REASONING_SUMMARY_DELIVERY_VALUES, ReasoningSummaryDelivery, + CodexAccountMode, OPENAI_PROVIDER_TIER_VERSION, + MODEL_ADAPTER_OVERRIDE_ALLOWED, ANTHROPIC_WIRE_MODELS (internal), + anthropicWireModelsForProvider (internal), captureWireAdapterHardPins, + isWirePinnedModel, pinnedWireAdapter. Self-contained, no imports. + +src/types.ts keeps every current export via `export ... from "./types/..."`; +type-only names re-exported with `export type`. + +## Scope (OUT) + +- All interface/type clusters stay in types.ts this PR. +- No import-path changes anywhere else in src/ or tests/. +- No lab imports anywhere new (types is on the protected graph as a value + import from responses/core.ts: modelInList, namespacedToolName). + +## File change map + +- ADD src/types/tools.ts (~60 lines incl. docs) +- ADD src/types/wire.ts (~85 lines incl. docs) +- EDIT src/types.ts: delete moved bodies, add two re-export blocks at the + same positions; net -120 lines. + +## Accept criteria + +1. bun run typecheck exit 0. +2. bun run test full suite: same pass count as base (13k+), 0 fail. +3. tests/core-lab-boundary.test.ts green (covers the new static edges + types.ts -> types/tools.ts, types/wire.ts on the protected walk). +4. rg confirms no consumer file changed: git diff --stat touches exactly 3 + files. +5. Value identity preserved: MODEL_ADAPTER_OVERRIDE_ALLOWED still a single + ReadonlySet instance (only one declaration site, re-export not re-create). + +Activation grounding: criterion 3's scenario is the existing boundary test +run; criterion 5's scenario is the full suite (service-tier tests compare +set membership through both import paths). + +## Verifier reality (PLAN-VERIFIER-REAL-01) + +- bun run typecheck: exists in package.json, reads src/ via tsconfig + include ["src"] — observes both new files. To be run in C. +- bun run test: tests/ suite imports ../src/types in 400 files — observes + the barrel; core-lab-boundary walks the import graph from the three + protected roots which reach types.ts — observes the new edges. + +## Stacked-PR plan (DEV-STACK-01) + +PR 1 (this): value leaves + barrel. Target: dev. +PR 2 (next cycle): type-cluster split (request/config/provider/accounts) +stacked on PR 1's head branch. +Later cycles per 000_risk_assessment.md order (config leaves, registry, ...). + +## Audit amendments (A-phase, 2 auditors: grok-4.6 NEAR-PASS / gpt-5.6-sol FAIL->fixed) + +1. CYCLE FIX (sol blocker): OcxTool (lines 211-232) moves INTO types/tools.ts. + tools.ts imports NOTHING from ../types — dependency is strictly one-way + (types.ts -> types/tools.ts). types.ts re-exports OcxTool as a type. +2. RECIPE FIX (grok finding 7): `export type { X } from` does not BIND X in + the barrel. types.ts still uses OcxTool (line 106), OcxToolChoice (299), + UpstreamHttpVersion (1455), CodexAccountMode (1470), + ReasoningSummaryDelivery (1574) — so the barrel adds a local + `import type { OcxTool, OcxToolChoice } from "./types/tools"` and + `import type { UpstreamHttpVersion, ReasoningSummaryDelivery, + CodexAccountMode } from "./types/wire"` next to the Kiro import. +3. OcxToolChoice + its guards travel with tools.ts (they are one cluster). +4. Extensionless specifiers only (lab walker resolves `${base}.ts`). +5. AC4 corrected: scope proof = `git diff --stat ..HEAD -- src tests` + showing exactly 3 src files; devlog/plan files are committed separately. +6. AC5 proof corrected: identity is preserved by ESM re-export semantics + (single declaration site); drop the false 'both import paths' claim. +7. Protected-roots note corrected: PROTECTED has 4 files; only + responses/core.ts puts types.ts on the runtime graph (core.ts:63). +