From c631debe18cee5b7080cbb13a95992bdddaeefaf Mon Sep 17 00:00:00 2001 From: t Date: Sat, 5 Sep 2026 12:45:54 +0900 Subject: [PATCH 1/7] docs(clients): carry audited split plan for config-export foundation --- .../_plan/260905_now_split_train/000_plan.md | 69 +++ .../003_parent_decisions.md | 186 ++++++++ .../400_clients_config_export_a.md | 421 ++++++++++++++++++ 3 files changed, 676 insertions(+) create mode 100644 devlog/_plan/260905_now_split_train/000_plan.md create mode 100644 devlog/_plan/260905_now_split_train/003_parent_decisions.md create mode 100644 devlog/_plan/260905_now_split_train/400_clients_config_export_a.md diff --git a/devlog/_plan/260905_now_split_train/000_plan.md b/devlog/_plan/260905_now_split_train/000_plan.md new file mode 100644 index 0000000000..ac3dbd96b2 --- /dev/null +++ b/devlog/_plan/260905_now_split_train/000_plan.md @@ -0,0 +1,69 @@ +# 260905 — RESOLVABLE_NOW split train (stacked PRs) + +Date: 2026-09-05. Worktree a2c0, docs branch `codex/260905-modular-debt-ledger-docs` +at 4cc219549 (source basis 980a9fbed; origin/dev tip at unit open 583d6a91b, +6 commits ahead, only one of which touches a NOW file — see 001). Session +01a06e97-b9d8-7250-8204-bb788338c288, goalplan +`.codexclaw/goalplans/reduce-the-68-resolvable-now-modularization-debt/`. +Input ledger: `devlog/_plan/260905_modular_debt_ledger/021_ledger.md` (68 rows +with `RESOLVABLE_NOW`); lane evidence in that unit's 011–016. + +## Objective + +Bring each of the 68 files under the cxc-dev §1 400-line limit by pure-move +splits (leaf modules + barrel re-exports), published as stacked PRs against +`dev`, each layer independently reviewable and mergeable. Zero behavior +change; every existing export stays importable from its original path. +Per-file success is `RESOLVED` or `RESIDUAL-FN` (003 RESIDUAL-ACCOUNTING-01); +the closeout tallies both and only the first counts as resolved. + +## Constraints (binding on every layer) + +- Pure move only. No renames of exported identifiers, no signature changes, + no deletion of exports, no "while I'm here" fixes. A behavior defect found + during a move is recorded in the decade doc and left alone. +- New leaf files ≤400 lines; the residual original file ≤400 lines or the + layer states why a second layer (`#b`) follows (003 INTERMEDIATE-RESIDUAL-01, + RESIDUAL-FN-01). +- The ≤500-line PR cap is measured on the non-move diff for pure-move layers + (003 PURE-MOVE-SIZE-01); non-move diff ≤150 lines. +- Re-export binds nothing locally (260818 WP1 lesson): internal call sites in + the residual file import from the leaf explicitly. +- Text-oracle tests that read a split file as source (001 column + `textoracle`) are retargeted to the leaf **without weakening**; the + decade doc names each and the C phase drives the retargeted guard red once + when it is a guard. +- `tests/lab/core-lab-boundary.test.ts` PROTECTED roots are never edited; + a new leaf imported from a protected root must not reach `src/lab`. +- Verification from WP400 onward: typecheck, focused tests, privacy scan and + full suite run in an isolated checkout on `ssh lidge`; no local suites. +- Git: layer branches `codex/split-`; bottom layer base `dev`, each + upper layer base = the branch below; push + PR creation pre-authorized by + the user for this loop; **merge never** (DEV-STACK-04 ESCALATE). Cascade + with `git rebase --update-refs` + `--force-with-lease` when a lower layer + changes (DEV-STACK-02). +- Open-stack depth cap 5: no stack in 002 exceeds 5 layers, so the cap is + satisfied by construction; 21 stacks run as parallel trains off `dev`. +- From WP400 onward, code and receipts use the existing a2c0 worktree in + place (003 WORKTREE-EVIDENCE-01). Preserve each previous branch before + selecting the next layer branch. Never relocate or recreate a2c0. + +## Work-phase map (dependency-ordered) + +| WP | Deliverable | Depends on | Verifier | +|---|---|---|---| +| wp1 | 000–002 + every layer's decade doc (010…750) at diff level | — | docs checks (numbered only, every layer has a doc, every NOW file appears in exactly one stack); privacy scan | +| wp2… | one layer per work-phase, in 002 order within a stack; stacks are independent and may be interleaved (S01 L1, S02 L1, … then L2s) so that no open stack waits on another | its layer below | per-layer acceptance (003 §"Per-layer gate") | + +Total: 77 implementation layers across 21 stacks (002_layer_map.md; 105 and +625 appended per 003). + +## Out of scope + +The 151 `RESOLVABLE_AFTER` and 19 `ACCEPTED` rows; core.ts / config.ts / +service.ts / auth-api.ts; merges; releases. + +## Terminal outcome expected + +DONE when every layer in 002 has an open PR with a green exact-head CI rollup +recorded in its decade doc. diff --git a/devlog/_plan/260905_now_split_train/003_parent_decisions.md b/devlog/_plan/260905_now_split_train/003_parent_decisions.md new file mode 100644 index 0000000000..39782ad55a --- /dev/null +++ b/devlog/_plan/260905_now_split_train/003_parent_decisions.md @@ -0,0 +1,186 @@ +# 003 — Parent decisions on drafter escalations (binding amendments to 000/002) + +Twenty-one drafters (one per stack) returned 75 decade docs. Fourteen of them +escalated the same conflict and six raised stack-specific questions. Each +decision below is an amendment to 000_plan.md constraints and is what the +A-phase audits and every executor obey. + +## PURE-MOVE-SIZE-01 — the ≤500-line changeset cap for pure-move layers + +Conflict: cxc-dev §1 says "PR changeset >500 lines → split" (DEFAULT class: +exceed only with a stated reason). A pure move of a file that must lose +≥1,000 original lines produces ≥2,000 raw diff lines however it is layered; +adding layers only multiplies fully-gated PRs and leaves intermediate residuals +over 400 with no review benefit (S02, S03, S05, S07, S08, S10, S11, S13, S15, +S16, S19, S20, S21 all showed this arithmetic). + +Decision (stated reason for exceeding): for a layer whose decade doc classes +it as pure-move, the 500-line cap is measured on the **non-move diff** — the +lines that are not a verbatim relocation: re-export blocks, import edits in +the residual and in consumers, test retargets, route-registry metadata. That +non-move diff must stay ≤150 lines per layer. Moved lines are reviewed as +moves: the PR body links `git diff --color-moved=dimmed-zebra` guidance and the +executor's C phase records `git diff -M --stat` plus a symbol-inventory check +(every symbol in the doc's inventory appears exactly once in the tree after +the move). + +Permitted transformations of a moved line (still pure-move): + +1. Adding or removing the `export` modifier on a moved declaration (a leaf must + export what the residual re-exports; a symbol that was module-private and + is now consumed only inside its leaf may stay private). +2. Changing the import specifier path of a moved symbol's own imports. +3. Object-literal method → factory-produced function when an adapter's + returned object literal is split across leaves (S03 Anthropic #b, + `createAnthropicAdapter` returns `{ ...methods }` capturing lexical + `provider`/`toolNames`): the method body is moved verbatim into a + leaf function `makeX(captured…)` whose parameters are exactly the + lexical bindings the body captures, called once inside the original + factory so the returned property becomes `x: makeX(provider, toolNames)`. + Capture identity and invocation lifetime are preserved because the + factory is invoked in the same closure scope the literal was built in. + Evidence: the C phase pastes `git diff --color-moved=dimmed-zebra + --color-moved-ws=allow-indentation-change` for each converted method and + shows the body as a move block; the layer's focused tests cover every + converted method (listed in the doc's Tests section). The same rule + covers a class method split by `this`-fields, should one occur. +4. JSX block → sibling component with verbatim props (GUI-SEAM-01). + +Anything else (reordering statements inside a moved body, renaming, changing +a literal, altering control flow) is not pure-move; the layer falls back to +the literal 500-line cap or is re-sliced. + +The layer count in 002 stands as drafted; no stack is re-sliced for size. + +## RESIDUAL-FN-01 — residual >400 caused by a single function + +S07 L1: `parseRequest` is 464 lines by itself, so `src/responses/parser.ts` +cannot reach ≤400 by moving other symbols. Splitting the function is a +behavior-preserving extraction, not a move, and is out of this train's scope. +Decision: the layer moves everything movable, the residual stays over 400, +and the doc records the function as `RESOLVABLE_AFTER(design:L1-parse-request-extraction)` +for the 021 ledger's next revision. Same rule applies to any other layer that +finds a single >350-line function (none other reported). + +## INTERMEDIATE-RESIDUAL-01 — over-400 residuals inside a multi-part file + +S13 (config-export #a), S18 (IntegrationsOverview), S21 (release-notes #a), +S02 (registry #a/#b): an intermediate residual over 400 is acceptable when a +**bounded successor chain inside the same stack** brings it under 400 and +each doc states the number it hands to the next layer (registry: +3250 → 2429 → 1267 → 219 across #a/#b/#c). S18 had no next layer: **layer +625 (IntegrationsOverview #b)** is appended to 002 and drafted by the same +agent. + +## RESIDUAL-ACCOUNTING-01 — what "done" means for a file + +000's objective is amended: the train's success measure is per file, one of +`RESOLVED` (residual ≤400 and all leaves ≤400), or `RESIDUAL-FN` (residual +>400 solely because of one unsplittable function, recorded per +RESIDUAL-FN-01 with the `design:` id for the ledger). The closeout doc +tallies both; a file in the second bucket is *not* counted as resolved. At +draft time exactly one file is expected there: `src/responses/parser.ts` +(561, `parseRequest`). + +## TYPE-CYCLE-01 — pre-existing type-only cycles + +S04 L1 reports `src/types.ts → src/types/provider.ts → native-exec-desktop.ts +→ native-exec-tools.ts → tool-definitions.ts → src/types.ts`; S02 reports an +Antigravity type cycle. Both pre-exist on `dev` and are erased at runtime. +Decision: a layer must not add a **runtime** cycle and must not add a new +type-only cycle; it may leave existing ones untouched. The audit checks the +delta, not the whole graph. + +S04 is the exception: its new leaves would each join the existing type cycle +(`tool-naming → ../../types → provider → native-exec-desktop → +native-exec-tools → tool-definitions → tool-naming`), which is a *new* cycle +through new files. Decision: the prerequisite the S04 drafter named is +approved and becomes **layer 105 (`codex/split-cursor-desktop-executor-contract`, +base `dev`, new bottom of S04)**: move `DesktopExecutorConfig` +(`src/adapters/cursor/native-exec-desktop.ts:28–37`) to a new dependency-free +`src/adapters/cursor/desktop-executor-contract.ts`, keep it exported from +`native-exec-desktop.ts` via `export type { DesktopExecutorConfig } from +"./desktop-executor-contract"` plus a local `import type`, and retarget the +inline `import("../adapters/cursor/native-exec-desktop").DesktopExecutorConfig` +at `src/types/provider.ts:701` to the contract file. Type-only, zero runtime +effect; breaks the provider → desktop-implementation edge for good. 110's +base becomes `codex/split-cursor-desktop-executor-contract`; S04 depth +becomes 6, the one documented exception to the depth-5 heuristic (the layer +is a 15-line type move; it is not worth a separate stack). + +## COMPANION-EDIT-01 — allowed edits outside the split file + +- S09 L2/L3: `src/server/management/route-registry.ts` module-path metadata + for routes whose handler moves to a leaf — allowed (it is the route table's + pointer to the owning file; the registry test enumerates siblings). +- S02 L3: one `import type` path change for FastWire types — allowed + (type-only, no runtime effect). +- Consumer import edits are only allowed when the doc lists them; default is + that consumers keep importing from the original path via re-export. + +## GUI-SEAM-01 — React component extraction as the seam + +S17 (Storage policy panel) and other gui layers: extracting a JSX block into a +sibling component file with its props passed through verbatim counts as a +pure move for this train when the rendered tree is unchanged. Verification for +such layers adds the GUI checks: `bun run lint:gui`, `bun run build:gui`, and +a before/after screenshot of the affected page attached to the PR (the +`enforce-target` gate requires a screenshot for gui PRs anyway). + +## STACK-INDEPENDENCE-01 — stacks whose layers do not depend on each other + +DEV-STACK-01 says independent parts go as parallel PRs off trunk. The +original 002 chained every stack by directory. Decision, applied **per +layer** to every stack: a layer's base is the nearest lower layer in its +stack that it imports from (001's 47 edges) or that is a `#`-part of the +same file; S04 layers additionally base on the 105 type-contract layer; +otherwise the base is `dev`. 002 is regenerated with this rule (29 chained +layers, 48 `dev`-based). The stack id still groups execution order and PR +stack-map navigation; a `dev`-based layer's PR body still shows its stack's +map but states "base: dev — no dependency on the layers below". Each decade +doc's PR section is the authority for its own base and must match 002. + +## S06-ORACLE-01 — correcting 002 + +002's S06 thesis said "47 text oracles retargeted". The drafter showed the +count came from a broad `index.ts` basename match; no test reads +`src/vision/index.ts` as text. 002 is corrected to "no text oracle; three +recursive source-walk guards must include the new leaves". + +## S10-SIZE-01 — resolved by PURE-MOVE-SIZE-01 + +prompt-layers stays two layers (518 + 913 moved lines) under the pure-move +measure. + +## WORKTREE-EVIDENCE-01 — real implementation and receipt identity + +The original dedicated-worktree execution choice conflicts with the FSM's +checkout-local source identity. Operational audit by Wegener found no +separate supported execution-root binding: `--cwd` selects both state and +source. The main agent amends its own topology choice, not the user's scope. + +From WP400, preserve the docs branch and every completed layer branch, then +create the current layer branch in the same a2c0 directory from its pinned +base. Carry 000, 003 and the current decade doc as tracked layer documentation; +the complete roadmap remains on `codex/260905-modular-debt-ledger-docs` and +can be read by immutable commit/ref. The ignored `.codexclaw` state stays +in a2c0; do not copy, hand-edit or relocate session state. Actual source edits +must occur there during B. Commit the layer before C and preserve that HEAD +through its receipt and C→D. Source changes from another checkout cannot be +represented by a documentation-only delta. + +All tests from WP400 run remotely. Each run uses its own mktemp checkout, +fetches the layer branch, and requires the fetched SHA to equal a2c0 HEAD. +Never switch or reset the shared remote seed checkout. Install root and GUI +dependencies with frozen lockfiles, then typecheck, focused checks, privacy +scan and full suite. Preserve full output and propagate each actual exit +code, including SSH transport failures. Failed or incomplete gates keep the +layer unverified; do not synthesize a passing receipt. Retain temporary +checkouts/evidence until scoped cleanup is authorized. + +Invoke `cxc receipt test --cwd /Users/jun/.codex/worktrees/a2c0/opencodex +--session 01a06e97-b9d8-7250-8204-bb788338c288 -- bash ` +from C. The runner verifies local HEAD, remote HEAD, and branch/CI head agree; +no local Bun test command is allowed. This supersedes shared-checkout recipes +in 002 and older decade docs; each current plan must name the isolated runner +before execution. Remote availability and success require real evidence. diff --git a/devlog/_plan/260905_now_split_train/400_clients_config_export_a.md b/devlog/_plan/260905_now_split_train/400_clients_config_export_a.md new file mode 100644 index 0000000000..5bdd563a09 --- /dev/null +++ b/devlog/_plan/260905_now_split_train/400_clients_config_export_a.md @@ -0,0 +1,421 @@ +# 400 — S13 L1/5: extract low-fanout client formats and dependency foundations + +## Loop spec + +- Archetype: `pure-move`. Bounded delegated **docs-only C3** task; parent owns orchestration, loop and goal state. +- Goal: extract low-fanout client formats and dependency foundations, preserving the original public import path and behavior. +- Non-goals: behavior fixes, exported renames, signature changes, new validation, changed credentials/admission policy, changed config paths, new framework, caller migration, merges or releases. Preserve function bodies verbatim, including >50-line functions; function redesign is not this pure-move train. +- Verifier: `002_layer_map.md` **Per-layer gate**, instantiated below; every layer must pass independently at its actual tip. Full suite on `ssh lidge` only, never locally. +- Stop: exact-tip acceptance evidence recorded; do not merge. This drafting task stops after document checks and runs no tests, code entrypoints, or Git mutations. +- Size gate: the binding `003_parent_decisions.md` PURE-MOVE-SIZE-01 resolves the original 500-line churn conflict. Non-move changes must stay **≤150 lines**, with move-aware diff review and unique-owner evidence for every inventory symbol. Raw added+deleted churn is not claimed to meet 500. Stale source, a leaf >400, any new cycle, any behavioral difference, or non-move changes above the bound stop implementation. + +Basis: task docs HEAD `4cc219549`; code `origin/dev=1362b1a3841b4de20177e5d65865a513dd7936c4`. Read 000, 001, S13 rows/Per-layer gate of 002, and the relevant records in `devlog/_plan/260905_modular_debt_ledger/016_lane_cli_storage_usage_update_lab_scripts.md`. Source was read with `git show origin/dev:`; `git diff origin/dev -- src/clients/config-export.ts src/cli/opencode.ts src/cli/minimax.ts src/integrations/state.ts` was empty. Older tips in 000/001 are historical, not this plan's code basis. + +Structural decision (cxc-dev §1/§5, architecture ARCH-MAP-01/ARCH-DECISION-01): 1990 lines mix distinct concerns. Reject deleting/configuring the feature (does not preserve behavior), and generic helpers/index barrels (do not establish ownership). Reuse every existing algorithm and lower-level dependency; only relocate declarations. Inspected conventions: `src/config/paths.ts`, `src/config/process-state.ts`, `src/cli/launcher-context.ts`, `src/cli/account-extended.ts`, `src/integrations/ownership-policy.ts`. Use the domain subfolder `src/clients/config-export/` without an index barrel. The original remains an existing compatibility boundary, not an internal import shortcut. + +Structural map: 33 direct source/test/fixture consumer files. Production dependents: `src/integrations/state.ts`, `src/integrations/ownership.ts`, `src/integrations/merge.ts`, `src/integrations/registry.ts`, `src/integrations/owned-refresh.ts`, `src/integrations/config-io.ts`, `src/integrations/ownership-policy.ts`, `src/integrations/writer.ts`, `src/server/management/model-routes.ts`, `src/server/management/model-rows.ts`, `src/cli/export-command.ts`, `src/cli/minimax.ts`, `src/cli/opencode.ts`. Current direction is dependents → original → existing imported owners; intended direction is dependents → original → concern leaves → existing owners. Leaf imports are fully enumerated below; no leaf → original edge. Blast radius: client/CLI integration feature, with public consumers unchanged. `structure/09_client-integrations.md:11` identifies builders and classification as single authorities; no parallel implementation is introduced. + +## Symbol inventory + +Exact syntax spans at `origin/dev:src/clients/config-export.ts` (leading comments excluded). Reproduce: `sg run --lang ts --kind 'function_declaration,interface_declaration,type_alias_declaration,lexical_declaration,variable_declaration,class_declaration' --json=compact src/clients/config-export.ts`, filtering declarations enclosed by another declaration. Consumers = distinct direct importer/re-exporter files per symbol, resolved by literal module path then counted with `rg -l -w '' `. Dynamic dispatch destructuring counts too. Private declarations have 0 external consumers, not 0 local calls. Imported bindings are covered by the leaf imports; export-only declarations are noted below. L2 repeats the complete basis inventory and marks L1-owned rows already moved. + +| symbol | kind | lines start–end | exported? | consumers (count from rg) | target leaf | +|---|---|---|---|---:|---| +| `ManagedFragment` | interface | 43–46 | yes | 2 | `src/clients/config-export/contracts.ts` (L1) | +| `ManagedContribution` | interface | 49–52 | yes | 5 | `src/clients/config-export/contracts.ts` (L1) | +| `BuildContribution` | type | 54–54 | yes | 0 | `src/clients/config-export/contracts.ts` (L1) | +| `OpencodeLaunchEnv` | interface | 56–58 | yes | 1 | `src/clients/config-export/contracts.ts` (L1) | +| `OpencodeCatalogModel` | interface | 61–76 | yes | 1 | `src/clients/config-export/contracts.ts` (L1) | +| `OpencodeModelEntry` | interface | 78–81 | yes | 1 | `src/clients/config-export/opencode.ts` (L2; deferred) | +| `OpencodeModelVariant` | interface | 90–93 | yes | 0 | `src/clients/config-export/opencode.ts` (L2; deferred) | +| `OpencodeV2ModelEntry` | interface | 95–97 | yes | 0 | `src/clients/config-export/opencode.ts` (L2; deferred) | +| `OpencodeProviderConnection` | interface | 100–104 | yes | 0 | `src/clients/config-export/opencode.ts` (L2; deferred) | +| `OpencodeProviderBlock` | interface | 107–112 | yes | 1 | `src/clients/config-export/opencode.ts` (L2; deferred) | +| `OpencodeV2ProviderBlock` | interface | 115–120 | yes | 1 | `src/clients/config-export/opencode.ts` (L2; deferred) | +| `OpencodeProviderBlocks` | interface | 127–130 | yes | 1 | `src/clients/config-export/opencode.ts` (L2; deferred) | +| `OpencodeGeneratedConfig` | interface | 132–138 | yes | 4 | `src/clients/config-export/opencode.ts` (L2; deferred) | +| `OPENCODE_PROVIDER_ID` | const | 141–141 | yes | 11 | `src/clients/config-export/constants.ts` (L1) | +| `OPENCODE_CONFIG_SCHEMA` | const | 143–143 | yes | 2 | `src/clients/config-export/constants.ts` (L1) | +| `OPENCODE_PROVIDER_NPM` | const | 149–149 | no | 0 | `src/clients/config-export/opencode.ts` (L2; deferred) | +| `OPENCODE_V2_PROVIDER_PACKAGE` | const | 161–161 | no | 0 | `src/clients/config-export/opencode.ts` (L2; deferred) | +| `OPENCODE_PROVIDER_NAME` | const | 164–164 | no | 0 | `src/clients/config-export/opencode.ts` (L2; deferred) | +| `OPENCODE_API_KEY_ENV` | const | 171–171 | yes | 3 | `src/clients/config-export/constants.ts` (L1) | +| `OPENCODE_API_KEY_ENV_REF` | const | 174–174 | yes | 2 | `src/clients/config-export/constants.ts` (L1) | +| `HERMES_API_KEY_ENV` | const | 180–180 | yes | 0 | `src/clients/config-export/constants.ts` (L1) | +| `HERMES_API_KEY_ENV_REF` | const | 181–181 | yes | 2 | `src/clients/config-export/constants.ts` (L1) | +| `OPENCLAW_API_KEY_ENV` | const | 184–184 | yes | 0 | `src/clients/config-export/constants.ts` (L1) | +| `OPENCLAW_API_KEY_ENV_REF` | const | 185–185 | yes | 2 | `src/clients/config-export/constants.ts` (L1) | +| `LOOPBACK_API_KEY_PLACEHOLDER` | const | 193–193 | yes | 9 | `src/clients/config-export/constants.ts` (L1) | +| `GAJAE_API_KEY_ENV` | const | 200–200 | yes | 2 | `src/clients/config-export/constants.ts` (L1) | +| `PI_API_DIALECT` | const | 203–203 | no | 0 | `src/clients/config-export/constants.ts` (L1) | +| `SCHEMA_REQUIRED_OUTPUT_BUDGET` | const | 217–217 | yes | 2 | `src/clients/config-export/constants.ts` (L1) | +| `OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG` | const | 220–225 | yes | 1 | `src/clients/config-export/constants.ts` (L1) | +| `opencodeGlobalConfigPath` | function | 231–237 | yes | 3 | `src/clients/config-export/paths.ts` (L2; deferred) | +| `OMP_PROFILE_NAME_RE` | const | 239–239 | no | 0 | `src/clients/config-export/paths.ts` (L2; deferred) | +| `OMP_WINDOWS_RESERVED_PROFILE_RE` | const | 240–240 | no | 0 | `src/clients/config-export/paths.ts` (L2; deferred) | +| `ompProfileName` | function | 242–258 | no | 0 | `src/clients/config-export/paths.ts` (L2; deferred) | +| `piAgentDir` | function | 270–274 | yes | 2 | `src/clients/config-export/paths.ts` (L2; deferred) | +| `piConfigPath` | function | 277–279 | yes | 2 | `src/clients/config-export/paths.ts` (L2; deferred) | +| `ompAgentDir` | function | 282–293 | yes | 1 | `src/clients/config-export/paths.ts` (L2; deferred) | +| `ompModelsConfigPath` | function | 296–301 | yes | 4 | `src/clients/config-export/paths.ts` (L2; deferred) | +| `opencodeProxyBaseUrl` | function | 304–316 | yes | 4 | `src/clients/config-export/opencode.ts` (L2; deferred) | +| `hermesHomeDir` | function | 322–330 | yes | 1 | `src/clients/config-export/paths.ts` (L2; deferred) | +| `hermesConfigPath` | function | 332–334 | yes | 2 | `src/clients/config-export/paths.ts` (L2; deferred) | +| `ClientPathError` | class | 350–350 | yes | 12 | `src/clients/config-export/paths.ts` (L2; deferred) | +| `absoluteClientPath` | function | 352–363 | no | 0 | `src/clients/config-export/paths.ts` (L2; deferred) | +| `openclawEffectiveHome` | function | 372–375 | no | 0 | `src/clients/config-export/openclaw-paths.ts` (L2; deferred) | +| `openclawHomeDir` | function | 393–413 | yes | 2 | `src/clients/config-export/openclaw-paths.ts` (L2; deferred) | +| `openclawConfigPath` | function | 427–457 | yes | 2 | `src/clients/config-export/openclaw-paths.ts` (L2; deferred) | +| `kimiHomeDir` | function | 459–462 | yes | 1 | `src/clients/config-export/paths.ts` (L2; deferred) | +| `kimiConfigPath` | function | 464–466 | yes | 2 | `src/clients/config-export/paths.ts` (L2; deferred) | +| `gajaeHomeDir` | function | 468–470 | yes | 1 | `src/clients/config-export/paths.ts` (L2; deferred) | +| `gajaeConfigPath` | function | 472–474 | yes | 2 | `src/clients/config-export/paths.ts` (L2; deferred) | +| `dshHomeDir` | function | 477–492 | yes | 2 | `src/clients/config-export/paths.ts` (L2; deferred) | +| `dshConfigPath` | function | 494–496 | yes | 2 | `src/clients/config-export/paths.ts` (L2; deferred) | +| `mcodeHomeDir` | function | 503–509 | yes | 2 | `src/clients/config-export/paths.ts` (L2; deferred) | +| `mcodeConfigPath` | function | 511–513 | yes | 3 | `src/clients/config-export/paths.ts` (L2; deferred) | +| `zcodeHomeDir` | function | 521–525 | yes | 2 | `src/clients/config-export/paths.ts` (L2; deferred) | +| `zcodeConfigPath` | function | 527–529 | yes | 2 | `src/clients/config-export/paths.ts` (L2; deferred) | +| `primeAgentDir` | function | 540–544 | yes | 2 | `src/clients/config-export/paths.ts` (L2; deferred) | +| `primeConfigPath` | function | 547–549 | yes | 2 | `src/clients/config-export/paths.ts` (L2; deferred) | +| `asideHomeDir` | function | 558–560 | yes | 1 | `src/clients/config-export/aside-paths.ts` (L2; deferred) | +| `asideCurrentAccountId` | function | 584–612 | no | 0 | `src/clients/config-export/aside-paths.ts` (L2; deferred) | +| `asideAccountDir` | function | 619–622 | yes | 2 | `src/clients/config-export/aside-paths.ts` (L2; deferred) | +| `asideConfigPath` | function | 625–627 | yes | 2 | `src/clients/config-export/aside-paths.ts` (L2; deferred) | +| `ExportModel` | interface | 634–647 | yes | 18 | `src/clients/config-export/contracts.ts` (L1) | +| `ExportContext` | interface | 649–658 | yes | 8 | `src/clients/config-export/contracts.ts` (L1) | +| `ExportClientId` | type | 660–672 | yes | 3 | `src/clients/config-export/contracts.ts` (L1) | +| `ExportClientSpec` | interface | 674–713 | yes | 0 | `src/clients/config-export/contracts.ts` (L1) | +| `authoritativeContextWindow` | function | 719–725 | no | 0 | `src/clients/config-export/model-metadata.ts` (L1) | +| `outputBudgetFor` | function | 728–730 | no | 0 | `src/clients/config-export/model-metadata.ts` (L1) | +| `CLIENT_INPUT_MODALITIES` | const | 761–764 | no | 0 | `src/clients/config-export/model-metadata.ts` (L1) | +| `inputModalitiesForClient` | function | 767–779 | no | 0 | `src/clients/config-export/model-metadata.ts` (L1) | +| `dshInputModalities` | function | 782–791 | no | 0 | `src/clients/config-export/dsh.ts` (L1) | +| `exportModelLabel` | function | 798–805 | no | 0 | `src/clients/config-export/model-metadata.ts` (L1) | +| `opencodeProviderConnection` | function | 808–818 | no | 0 | `src/clients/config-export/opencode.ts` (L2; deferred) | +| `opencodeEffortVariants` | function | 833–840 | no | 0 | `src/clients/config-export/opencode.ts` (L2; deferred) | +| `opencodeProviderBlocks` | function | 855–894 | yes | 1 | `src/clients/config-export/opencode.ts` (L2; deferred) | +| `opencodeProviderBlock` | function | 897–903 | no | 0 | `src/clients/config-export/opencode.ts` (L2; deferred) | +| `opencodeV2ProviderBlock` | function | 906–912 | yes | 1 | `src/clients/config-export/opencode.ts` (L2; deferred) | +| `buildOpencodeProviderBlockFromCatalog` | function | 919–926 | yes | 1 | `src/clients/config-export/opencode.ts` (L2; deferred) | +| `normalizeExportModels` | function | 934–943 | yes | 2 | `src/clients/config-export/model-metadata.ts` (L1) | +| `buildOpencodeClientConfig` | function | 953–962 | no | 0 | `src/clients/config-export/opencode.ts` (L2; deferred) | +| `PiModelEntry` | interface | 964–979 | yes | 0 | `src/clients/config-export/contracts.ts` (L1) | +| `PiProviderBlock` | interface | 981–986 | yes | 0 | `src/clients/config-export/pi.ts` (L2; deferred) | +| `PiGeneratedConfig` | interface | 988–990 | yes | 6 | `src/clients/config-export/pi.ts` (L2; deferred) | +| `OmpModelEntry` | interface | 997–1006 | yes | 0 | `src/clients/config-export/omp.ts` (L1) | +| `OmpProviderBlock` | interface | 1008–1013 | yes | 0 | `src/clients/config-export/omp.ts` (L1) | +| `OmpGeneratedConfig` | interface | 1015–1017 | yes | 0 | `src/clients/config-export/omp.ts` (L1) | +| `OMP_EFFORT_VOCABULARY` | const | 1023–1023 | no | 0 | `src/clients/config-export/omp.ts` (L1) | +| `ompEfforts` | function | 1025–1034 | no | 0 | `src/clients/config-export/omp.ts` (L1) | +| `HermesProviderBlock` | interface | 1041–1049 | yes | 0 | `src/clients/config-export/hermes-openclaw.ts` (L2; deferred) | +| `HermesModelEntry` | interface | 1052–1054 | yes | 0 | `src/clients/config-export/hermes-openclaw.ts` (L2; deferred) | +| `HermesGeneratedConfig` | interface | 1056–1058 | yes | 4 | `src/clients/config-export/hermes-openclaw.ts` (L2; deferred) | +| `OpenclawModelEntry` | interface | 1060–1064 | yes | 0 | `src/clients/config-export/hermes-openclaw.ts` (L2; deferred) | +| `OpenclawProviderBlock` | interface | 1066–1072 | yes | 0 | `src/clients/config-export/hermes-openclaw.ts` (L2; deferred) | +| `OpenclawGeneratedConfig` | interface | 1075–1080 | yes | 2 | `src/clients/config-export/hermes-openclaw.ts` (L2; deferred) | +| `KimiProviderBlock` | interface | 1082–1086 | yes | 0 | `src/clients/config-export/kimi-gajae.ts` (L2; deferred) | +| `KimiModelBlock` | interface | 1095–1100 | yes | 0 | `src/clients/config-export/kimi-gajae.ts` (L2; deferred) | +| `KimiGeneratedConfig` | interface | 1102–1105 | yes | 2 | `src/clients/config-export/kimi-gajae.ts` (L2; deferred) | +| `GajaeModelEntry` | interface | 1107–1113 | yes | 0 | `src/clients/config-export/kimi-gajae.ts` (L2; deferred) | +| `GajaeProviderBlock` | interface | 1116–1121 | yes | 0 | `src/clients/config-export/kimi-gajae.ts` (L2; deferred) | +| `GajaeGeneratedConfig` | interface | 1123–1125 | yes | 3 | `src/clients/config-export/kimi-gajae.ts` (L2; deferred) | +| `DshReasoningEffort` | type | 1127–1127 | yes | 0 | `src/clients/config-export/dsh.ts` (L1) | +| `DshWireReasoningEffort` | type | 1128–1128 | yes | 0 | `src/clients/config-export/dsh.ts` (L1) | +| `DshModelEntry` | interface | 1130–1136 | yes | 0 | `src/clients/config-export/dsh.ts` (L1) | +| `DshProviderBlock` | interface | 1138–1144 | yes | 0 | `src/clients/config-export/dsh.ts` (L1) | +| `DshGeneratedConfig` | interface | 1146–1150 | yes | 2 | `src/clients/config-export/dsh.ts` (L1) | +| `McodeProviderBlock` | interface | 1152–1163 | yes | 0 | `src/clients/config-export/mcode.ts` (L1) | +| `McodeModelEntry` | interface | 1165–1170 | yes | 0 | `src/clients/config-export/mcode.ts` (L1) | +| `McodeGeneratedConfig` | interface | 1172–1174 | yes | 2 | `src/clients/config-export/mcode.ts` (L1) | +| `ZcodeModelEntry` | interface | 1183–1187 | yes | 0 | `src/clients/config-export/zcode.ts` (L1) | +| `ZcodeProviderBlock` | interface | 1189–1200 | yes | 0 | `src/clients/config-export/zcode.ts` (L1) | +| `ZcodeGeneratedConfig` | interface | 1202–1204 | yes | 1 | `src/clients/config-export/zcode.ts` (L1) | +| `buildPiClientConfig` | function | 1229–1276 | no | 0 | `src/clients/config-export/pi.ts` (L2; deferred) | +| `buildOmpClientConfig` | function | 1283–1321 | no | 0 | `src/clients/config-export/omp.ts` (L1) | +| `proxyAdmissionHeaders` | function | 1324–1326 | no | 0 | `src/clients/config-export/model-metadata.ts` (L1) | +| `buildHermesClientConfig` | function | 1328–1349 | no | 0 | `src/clients/config-export/hermes-openclaw.ts` (L2; deferred) | +| `buildOpenclawClientConfig` | function | 1351–1375 | no | 0 | `src/clients/config-export/hermes-openclaw.ts` (L2; deferred) | +| `kimiModelAlias` | function | 1378–1380 | yes | 1 | `src/clients/config-export/kimi-gajae.ts` (L2; deferred) | +| `buildKimiClientConfig` | function | 1382–1407 | no | 0 | `src/clients/config-export/kimi-gajae.ts` (L2; deferred) | +| `buildGajaeClientConfig` | function | 1409–1438 | no | 0 | `src/clients/config-export/kimi-gajae.ts` (L2; deferred) | +| `DSH_EFFORT_ORDER` | const | 1440–1440 | no | 0 | `src/clients/config-export/dsh.ts` (L1) | +| `dshReasoningEfforts` | function | 1442–1462 | no | 0 | `src/clients/config-export/dsh.ts` (L1) | +| `isKnownSafeDshCombo` | function | 1464–1483 | no | 0 | `src/clients/config-export/dsh.ts` (L1) | +| `buildDshClientConfig` | function | 1485–1516 | no | 0 | `src/clients/config-export/dsh.ts` (L1) | +| `buildMcodeClientConfig` | function | 1527–1559 | no | 0 | `src/clients/config-export/mcode.ts` (L1) | +| `buildZcodeClientConfig` | function | 1570–1606 | no | 0 | `src/clients/config-export/zcode.ts` (L1) | +| `summarizeOpencode` | function | 1614–1617 | no | 0 | `src/clients/config-export/opencode.ts` (L2; deferred) | +| `summarizePi` | function | 1619–1622 | no | 0 | `src/clients/config-export/pi.ts` (L2; deferred) | +| `summarizeOmp` | function | 1624–1627 | no | 0 | `src/clients/config-export/omp.ts` (L1) | +| `summarizeHermes` | function | 1629–1633 | no | 0 | `src/clients/config-export/hermes-openclaw.ts` (L2; deferred) | +| `summarizeOpenclaw` | function | 1635–1638 | no | 0 | `src/clients/config-export/hermes-openclaw.ts` (L2; deferred) | +| `summarizeKimi` | function | 1640–1645 | no | 0 | `src/clients/config-export/kimi-gajae.ts` (L2; deferred) | +| `summarizeGajae` | function | 1647–1650 | no | 0 | `src/clients/config-export/kimi-gajae.ts` (L2; deferred) | +| `summarizeDsh` | function | 1652–1655 | no | 0 | `src/clients/config-export/dsh.ts` (L1) | +| `summarizeMcode` | function | 1657–1660 | no | 0 | `src/clients/config-export/mcode.ts` (L1) | +| `summarizeZcode` | function | 1662–1665 | no | 0 | `src/clients/config-export/zcode.ts` (L1) | +| `singleFragment` | function | 1668–1670 | no | 0 | `src/clients/config-export/model-metadata.ts` (L1) | +| `buildOpencodeContribution` | function | 1672–1684 | no | 0 | `src/clients/config-export/opencode.ts` (L2; deferred) | +| `buildPiContribution` | function | 1686–1689 | no | 0 | `src/clients/config-export/pi.ts` (L2; deferred) | +| `buildOmpContribution` | function | 1691–1694 | no | 0 | `src/clients/config-export/omp.ts` (L1) | +| `buildHermesContribution` | function | 1696–1699 | no | 0 | `src/clients/config-export/hermes-openclaw.ts` (L2; deferred) | +| `buildOpenclawContribution` | function | 1701–1704 | no | 0 | `src/clients/config-export/hermes-openclaw.ts` (L2; deferred) | +| `buildKimiContribution` | function | 1711–1720 | no | 0 | `src/clients/config-export/kimi-gajae.ts` (L2; deferred) | +| `buildGajaeContribution` | function | 1722–1725 | no | 0 | `src/clients/config-export/kimi-gajae.ts` (L2; deferred) | +| `buildDshContribution` | function | 1727–1730 | no | 0 | `src/clients/config-export/dsh.ts` (L1) | +| `buildMcodeContribution` | function | 1732–1735 | no | 0 | `src/clients/config-export/mcode.ts` (L1) | +| `buildZcodeContribution` | function | 1737–1740 | no | 0 | `src/clients/config-export/zcode.ts` (L1) | +| `buildPrimeContribution` | function | 1755–1758 | no | 0 | `src/clients/config-export/pi.ts` (L2; deferred) | +| `buildAsideContribution` | function | 1778–1781 | no | 0 | `src/clients/config-export/pi.ts` (L2; deferred) | +| `EXPORT_CLIENTS` | const | 1783–1954 | yes | 15 | `src/clients/config-export.ts` (residual) | +| `EXPORT_CLIENT_IDS` | const | 1956–1956 | yes | 7 | `src/clients/config-export.ts` (residual) | +| `isExportClientId` | function | 1958–1960 | yes | 3 | `src/clients/config-export.ts` (residual) | +| `buildClientConfig` | function | 1963–1965 | yes | 9 | `src/clients/config-export.ts` (residual) | +| `buildClientConfigText` | function | 1973–1985 | yes | 8 | `src/clients/config-export.ts` (residual) | +| `buildClientContribution` | function | 1988–1990 | yes | 5 | `src/clients/config-export.ts` (residual) | + +Export-only declaration: `ConfigFormat` at `src/clients/config-export.ts:32` remains forwarded from `../integrations/serialize`, not redefined. + +## Leaf partition + +Part a moves the lowest-fanout format leaves first: `omp` (sum of external symbol consumers 0), `zcode` (1), `dsh` (2), `mcode` (2). Part b takes the higher-fanout families and paths. The three shared foundations move with part a because even its lowest-fanout clients need them: leaving types/constants/model rules in the original would create facade back-imports. No external caller changes paths. PiModelEntry (0 consumers) moves with shared contracts because OmpModelEntry extends it. The larger Pi document type/builders remain for part b. + +Line-budget convention: each declaration carries immediately preceding comments/whitespace, from previous declaration end+1. One explicit exception: the blank separator at original line 33, immediately after the import/export header, stays in the facade; the first moved block starts at line 34. This gives 707 moved original lines and the contracts projection below. Moving line 33 as well would instead give 708 moved lines and a 151-line contracts leaf. Counts include those blocks, the exact one-line imports shown, one header line and one separator. These are projected implementation counts, not measurements of files already written. Do not discard comments to meet limits. Adding an export keyword does not add a line. All new files are ≤400. + +### `src/clients/config-export/contracts.ts` — expected 150 lines + +Symbols: `ManagedFragment`, `ManagedContribution`, `BuildContribution`, `OpencodeLaunchEnv`, `OpencodeCatalogModel`, `ExportModel`, `ExportContext`, `ExportClientId`, `ExportClientSpec`, `PiModelEntry`. + +Own imports: + +```ts +import type { OcxConfig } from "../../types"; +import type { ConfigFormat } from "../../integrations/serialize"; +``` + +Leaf exports: `ManagedFragment`, `ManagedContribution`, `BuildContribution`, `OpencodeLaunchEnv`, `OpencodeCatalogModel`, `ExportModel`, `ExportContext`, `ExportClientId`, `ExportClientSpec`, `PiModelEntry`. Other listed declarations remain private. Only previously public symbols are forwarded from the original path; newly exposed internal symbols serve production registry/sibling calls, not tests. + +### `src/clients/config-export/constants.ts` — expected 69 lines + +Symbols: `OPENCODE_PROVIDER_ID`, `OPENCODE_CONFIG_SCHEMA`, `OPENCODE_API_KEY_ENV`, `OPENCODE_API_KEY_ENV_REF`, `HERMES_API_KEY_ENV`, `HERMES_API_KEY_ENV_REF`, `OPENCLAW_API_KEY_ENV`, `OPENCLAW_API_KEY_ENV_REF`, `LOOPBACK_API_KEY_PLACEHOLDER`, `GAJAE_API_KEY_ENV`, `PI_API_DIALECT`, `SCHEMA_REQUIRED_OUTPUT_BUDGET`, `OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG`. + +Own imports: + +```ts +import type { OcxConfig } from "../../types"; +``` + +Leaf exports: `OPENCODE_PROVIDER_ID`, `OPENCODE_CONFIG_SCHEMA`, `OPENCODE_API_KEY_ENV`, `OPENCODE_API_KEY_ENV_REF`, `HERMES_API_KEY_ENV`, `HERMES_API_KEY_ENV_REF`, `OPENCLAW_API_KEY_ENV`, `OPENCLAW_API_KEY_ENV_REF`, `LOOPBACK_API_KEY_PLACEHOLDER`, `GAJAE_API_KEY_ENV`, `PI_API_DIALECT`, `SCHEMA_REQUIRED_OUTPUT_BUDGET`, `OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG`. Other listed declarations remain private. Only previously public symbols are forwarded from the original path; newly exposed internal symbols serve production registry/sibling calls, not tests. + +### `src/clients/config-export/model-metadata.ts` — expected 113 lines + +Symbols: `authoritativeContextWindow`, `outputBudgetFor`, `CLIENT_INPUT_MODALITIES`, `inputModalitiesForClient`, `exportModelLabel`, `normalizeExportModels`, `proxyAdmissionHeaders`, `singleFragment`. + +Own imports: + +```ts +import { SCHEMA_REQUIRED_OUTPUT_BUDGET } from "./constants"; +import type { OpencodeCatalogModel, ExportModel, ExportClientId, ManagedContribution } from "./contracts"; +import type { OcxConfig } from "../../types"; +import { shouldInjectApiAuthHeader } from "../../codex/inject"; +``` + +Leaf exports: `authoritativeContextWindow`, `outputBudgetFor`, `inputModalitiesForClient`, `exportModelLabel`, `normalizeExportModels`, `proxyAdmissionHeaders`, `singleFragment`. Other listed declarations remain private. Only previously public symbols are forwarded from the original path; newly exposed internal symbols serve production registry/sibling calls, not tests. + +### `src/clients/config-export/omp.ts` — expected 104 lines + +Symbols: `OmpModelEntry`, `OmpProviderBlock`, `OmpGeneratedConfig`, `OMP_EFFORT_VOCABULARY`, `ompEfforts`, `buildOmpClientConfig`, `summarizeOmp`, `buildOmpContribution`. + +Own imports: + +```ts +import type { PiModelEntry, ExportModel, ExportContext, ManagedContribution } from "./contracts"; +import { PI_API_DIALECT, OPENCODE_PROVIDER_ID, LOOPBACK_API_KEY_PLACEHOLDER } from "./constants"; +import { normalizeExportModels, inputModalitiesForClient, exportModelLabel, authoritativeContextWindow, outputBudgetFor, singleFragment } from "./model-metadata"; +``` + +Leaf exports: `OmpModelEntry`, `OmpProviderBlock`, `OmpGeneratedConfig`, `buildOmpClientConfig`, `summarizeOmp`, `buildOmpContribution`. Other listed declarations remain private. Only previously public symbols are forwarded from the original path; newly exposed internal symbols serve production registry/sibling calls, not tests. + +### `src/clients/config-export/zcode.ts` — expected 92 lines + +Symbols: `ZcodeModelEntry`, `ZcodeProviderBlock`, `ZcodeGeneratedConfig`, `buildZcodeClientConfig`, `summarizeZcode`, `buildZcodeContribution`. + +Own imports: + +```ts +import type { ExportContext, ManagedContribution } from "./contracts"; +import { normalizeExportModels, inputModalitiesForClient, exportModelLabel, authoritativeContextWindow, singleFragment } from "./model-metadata"; +import { OPENCODE_PROVIDER_ID, LOOPBACK_API_KEY_PLACEHOLDER } from "./constants"; +``` + +Leaf exports: `ZcodeModelEntry`, `ZcodeProviderBlock`, `ZcodeGeneratedConfig`, `buildZcodeClientConfig`, `summarizeZcode`, `buildZcodeContribution`. Other listed declarations remain private. Only previously public symbols are forwarded from the original path; newly exposed internal symbols serve production registry/sibling calls, not tests. + +### `src/clients/config-export/dsh.ts` — expected 132 lines + +Symbols: `dshInputModalities`, `DshReasoningEffort`, `DshWireReasoningEffort`, `DshModelEntry`, `DshProviderBlock`, `DshGeneratedConfig`, `DSH_EFFORT_ORDER`, `dshReasoningEfforts`, `isKnownSafeDshCombo`, `buildDshClientConfig`, `summarizeDsh`, `buildDshContribution`. + +Own imports: + +```ts +import type { ExportModel, ExportContext, ManagedContribution } from "./contracts"; +import type { OcxConfig } from "../../types"; +import { providerCodexAccountMode } from "../../providers/registry"; +import { normalizeExportModels, authoritativeContextWindow, exportModelLabel, singleFragment } from "./model-metadata"; +import { OPENCODE_PROVIDER_ID } from "./constants"; +``` + +Leaf exports: `DshReasoningEffort`, `DshWireReasoningEffort`, `DshModelEntry`, `DshProviderBlock`, `DshGeneratedConfig`, `buildDshClientConfig`, `summarizeDsh`, `buildDshContribution`. Other listed declarations remain private. Only previously public symbols are forwarded from the original path; newly exposed internal symbols serve production registry/sibling calls, not tests. + +### `src/clients/config-export/mcode.ts` — expected 83 lines + +Symbols: `McodeProviderBlock`, `McodeModelEntry`, `McodeGeneratedConfig`, `buildMcodeClientConfig`, `summarizeMcode`, `buildMcodeContribution`. + +Own imports: + +```ts +import type { ExportContext, ManagedContribution } from "./contracts"; +import { normalizeExportModels, authoritativeContextWindow, singleFragment } from "./model-metadata"; +import { sanitizeCodexReasoningEfforts } from "../../reasoning-effort"; +import { OPENCODE_PROVIDER_ID, LOOPBACK_API_KEY_PLACEHOLDER } from "./constants"; +``` + +Leaf exports: `McodeProviderBlock`, `McodeModelEntry`, `McodeGeneratedConfig`, `buildMcodeClientConfig`, `summarizeMcode`, `buildMcodeContribution`. Other listed declarations remain private. Only previously public symbols are forwarded from the original path; newly exposed internal symbols serve production registry/sibling calls, not tests. + +Residual `src/clients/config-export.ts`: expected **1299 lines**. It remains >400 intentionally; **410 / S13 L2 / #b** takes all deferred inventory rows. + +Retained declarations after this layer: `OpencodeModelEntry`, `OpencodeModelVariant`, `OpencodeV2ModelEntry`, `OpencodeProviderConnection`, `OpencodeProviderBlock`, `OpencodeV2ProviderBlock`, `OpencodeProviderBlocks`, `OpencodeGeneratedConfig`, `OPENCODE_PROVIDER_NPM`, `OPENCODE_V2_PROVIDER_PACKAGE`, `OPENCODE_PROVIDER_NAME`, `opencodeGlobalConfigPath`, `OMP_PROFILE_NAME_RE`, `OMP_WINDOWS_RESERVED_PROFILE_RE`, `ompProfileName`, `piAgentDir`, `piConfigPath`, `ompAgentDir`, `ompModelsConfigPath`, `opencodeProxyBaseUrl`, `hermesHomeDir`, `hermesConfigPath`, `ClientPathError`, `absoluteClientPath`, `openclawEffectiveHome`, `openclawHomeDir`, `openclawConfigPath`, `kimiHomeDir`, `kimiConfigPath`, `gajaeHomeDir`, `gajaeConfigPath`, `dshHomeDir`, `dshConfigPath`, `mcodeHomeDir`, `mcodeConfigPath`, `zcodeHomeDir`, `zcodeConfigPath`, `primeAgentDir`, `primeConfigPath`, `asideHomeDir`, `asideCurrentAccountId`, `asideAccountDir`, `asideConfigPath`, `opencodeProviderConnection`, `opencodeEffortVariants`, `opencodeProviderBlocks`, `opencodeProviderBlock`, `opencodeV2ProviderBlock`, `buildOpencodeProviderBlockFromCatalog`, `buildOpencodeClientConfig`, `PiProviderBlock`, `PiGeneratedConfig`, `HermesProviderBlock`, `HermesModelEntry`, `HermesGeneratedConfig`, `OpenclawModelEntry`, `OpenclawProviderBlock`, `OpenclawGeneratedConfig`, `KimiProviderBlock`, `KimiModelBlock`, `KimiGeneratedConfig`, `GajaeModelEntry`, `GajaeProviderBlock`, `GajaeGeneratedConfig`, `buildPiClientConfig`, `buildHermesClientConfig`, `buildOpenclawClientConfig`, `kimiModelAlias`, `buildKimiClientConfig`, `buildGajaeClientConfig`, `summarizeOpencode`, `summarizePi`, `summarizeHermes`, `summarizeOpenclaw`, `summarizeKimi`, `summarizeGajae`, `buildOpencodeContribution`, `buildPiContribution`, `buildHermesContribution`, `buildOpenclawContribution`, `buildKimiContribution`, `buildGajaeContribution`, `buildPrimeContribution`, `buildAsideContribution`, `EXPORT_CLIENTS`, `EXPORT_CLIENT_IDS`, `isExportClientId`, `buildClientConfig`, `buildClientConfigText`, `buildClientContribution`. + +Projection before unused-import pruning: 1990 original − 707 cumulative moved original lines + 16 facade glue = 1299. Across a/b: 707 + 1,041 = 1,748 moved body/trivia lines; 242 retained original lines; 1,748 + 242 = 1,990. The projected final glue is 31 lines, giving 273; L1's 16 glue lines are replaced by L2's 31, not both counted. These are estimates, not acceptance measurements: remove the now-unused provider import and measure actual import/forward/separator lines in B/C, recording the reconciled residual and leaf counts before advancing. + +## Re-export block + +Exact forwards in the original path follow. Other public declarations remain exported in place. No wildcard, alias, wrapper, signature change or duplicate definition. + +```ts +export type { ConfigFormat } from "../integrations/serialize"; +export type { ManagedFragment, ManagedContribution, BuildContribution, OpencodeLaunchEnv, OpencodeCatalogModel, ExportModel, ExportContext, ExportClientId, ExportClientSpec, PiModelEntry } from "./config-export/contracts"; +export { OPENCODE_PROVIDER_ID, OPENCODE_CONFIG_SCHEMA, OPENCODE_API_KEY_ENV, OPENCODE_API_KEY_ENV_REF, HERMES_API_KEY_ENV, HERMES_API_KEY_ENV_REF, OPENCLAW_API_KEY_ENV, OPENCLAW_API_KEY_ENV_REF, LOOPBACK_API_KEY_PLACEHOLDER, GAJAE_API_KEY_ENV, SCHEMA_REQUIRED_OUTPUT_BUDGET, OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG } from "./config-export/constants"; +export { normalizeExportModels } from "./config-export/model-metadata"; +export type { OmpModelEntry, OmpProviderBlock, OmpGeneratedConfig } from "./config-export/omp"; +export type { ZcodeModelEntry, ZcodeProviderBlock, ZcodeGeneratedConfig } from "./config-export/zcode"; +export type { DshReasoningEffort, DshWireReasoningEffort, DshModelEntry, DshProviderBlock, DshGeneratedConfig } from "./config-export/dsh"; +export type { McodeProviderBlock, McodeModelEntry, McodeGeneratedConfig } from "./config-export/mcode"; +``` + +Explicit residual local imports (re-export binds nothing locally): + +```ts +import type { OpencodeLaunchEnv, OpencodeCatalogModel, ExportContext, PiModelEntry, ManagedContribution, ManagedFragment, ExportClientId, ExportClientSpec } from "./config-export/contracts"; +import { OPENCODE_API_KEY_ENV_REF, OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG, OPENCODE_CONFIG_SCHEMA, OPENCODE_PROVIDER_ID, PI_API_DIALECT, LOOPBACK_API_KEY_PLACEHOLDER, HERMES_API_KEY_ENV_REF, OPENCLAW_API_KEY_ENV_REF, GAJAE_API_KEY_ENV, OPENCODE_API_KEY_ENV, HERMES_API_KEY_ENV, OPENCLAW_API_KEY_ENV } from "./config-export/constants"; +import { exportModelLabel, authoritativeContextWindow, outputBudgetFor, normalizeExportModels, inputModalitiesForClient, proxyAdmissionHeaders, singleFragment } from "./config-export/model-metadata"; +import { buildOmpClientConfig, summarizeOmp, buildOmpContribution } from "./config-export/omp"; +import { buildDshClientConfig, summarizeDsh, buildDshContribution } from "./config-export/dsh"; +import { buildMcodeClientConfig, summarizeMcode, buildMcodeContribution } from "./config-export/mcode"; +import { buildZcodeClientConfig, summarizeZcode, buildZcodeContribution } from "./config-export/zcode"; +``` + +Retain original external imports still used by the residual; prune only proven-unused bindings. Specifically remove the `providerCodexAccountMode` import and remove only `sanitizeCodexReasoningEfforts` from the reasoning-effort import, retaining `canonicalizeReasoningEfforts`. Keep both existing imports from `../codex/inject`: the residual still uses `shouldInjectApiAuthHeader` and `standaloneCodexRoutingTarget`. New leaves import one another directly. + +## Module-level state and cycles + +`CLIENT_INPUT_MODALITIES` at `src/clients/config-export.ts:761–764` owns two allowlist Sets in `config-export/model-metadata.ts`; never copy them into each client. `OMP_EFFORT_VOCABULARY` at `:1023` belongs only to `config-export/omp.ts`. No top-level let, Map, WeakMap, timer or lock exists. Function-local seen/offered Sets remain per-call. The exported default-config object at `:220–225` moves once to constants.ts; preserve object identity. `EXPORT_CLIENTS` at `:1783–1954` and derived `EXPORT_CLIENT_IDS` at `:1956` remain initialized once in the residual; preserve order. + +Lane 016's AST import BFS found no return path through the original. The partition avoids new return imports, including type-only ones. Risk: original → client leaf → original. Shared contracts/constants/model rules therefore move down in L1. `contracts.ts → ../../integrations/serialize` preserves ConfigFormat's actual owner; do not substitute config-io (which imports the original facade). OpenClaw/Aside paths import paths.ts for the single constructor/absolute-path rule; paths.ts imports no path sibling. Only the residual registry composes all client builders. Private builders/summarizers become explicit leaf exports for that production registry; no duplicated closures. + +Coupling classification: existing config-schema coupling stays with format owners; sequential/functional coupling is explicit through parameters. No new common mutable state or temporal startup constraint. Existing auth/ownership checks are moved verbatim. Before execution rerun lane 016 method G against the actual layer base (relative static imports, re-exports, type-only edges and literal dynamic imports); any new return path is escalation, not permission for a lazy-import workaround. + +## Tests + +Discovery: `rg -l 'src/clients/config-export' tests --glob '*.ts'`, followed by import/source-read inspection. Every direct test/fixture importer is listed below, with disposition **unchanged** (old public path): + +- `tests/ci-workflows/dsh-path-contract.test.ts` — unchanged. +- `tests/ci-workflows/dsh-writer-lock.test.ts` — unchanged. +- `tests/cli/cli-help.test.ts` — unchanged. +- `tests/clients/client-export-modality-enum.test.ts` — unchanged. +- `tests/clients/integrations-state.test.ts` — unchanged. +- `tests/clients/integrations-writer.test.ts` — unchanged. +- `tests/clients/omp-path-contract.test.ts` — unchanged. +- `tests/clients/pi-path-contract.test.ts` — unchanged. +- `tests/clients/prime-client.test.ts` — unchanged. +- `tests/clients/sync-client-integrations.test.ts` — unchanged. +- `tests/config/client-config-export-new-clients.test.ts` — unchanged. +- `tests/config/client-config-export.test.ts` — unchanged. +- `tests/config/client-config-new-clients.test.ts` — unchanged. +- `tests/gui/integrations-invariants.test.ts` — unchanged. +- `tests/providers/aside-client.test.ts` — unchanged. +- `tests/providers/minimax-clients.test.ts` — unchanged. +- `tests/providers/zcode-client.test.ts` — unchanged. +- `tests/server/management-client-config-route.test.ts` — unchanged. +- `tests/server/management-integration-journal-delete.test.ts` — unchanged. +- `tests/server/management-integration-routes.test.ts` — unchanged. + +No source-text reader of src/clients/config-export.ts was found. `tests/config/client-config-export.test.ts:58` and `tests/server/management-client-config-route.test.ts:416` mention it in comments, not source reads. No retarget-to-leaf or add-leaf-to-scan-list action. Preserve baked serialized fixtures unchanged. + +C-phase red proof: temporarily treat incompatible audio-only input as text in the moved metadata function and observe `tests/clients/client-export-modality-enum.test.ts:96` fail; restore. Temporarily retain none in the moved MCode effort list and observe `tests/providers/minimax-clients.test.ts:117` fail; restore. + +These are future implementation checks, not tests run by this docs author. No new test file is required. Facade/leaf identity assertions may be added in an existing focused test; if a new test file is required, parent must explicitly expand scope to include both test-layout registry files (`scripts/test-layout/layout.json`, `tests/fixtures/test-layout-expected.json`). Never commit red-proof mutations. + +## Verification + +Future implementation gate only, in the dedicated layer worktree at its actual tip. Domains: ci-workflows, cli, clients, config, gui, providers, server. Explicit source-reader and subprocess coverage is not replaced by test:changed. + +```sh +bun run typecheck +bun test tests/ci-workflows/dsh-path-contract.test.ts tests/ci-workflows/dsh-writer-lock.test.ts tests/cli/cli-help.test.ts tests/clients/client-export-modality-enum.test.ts tests/clients/integrations-state.test.ts tests/clients/integrations-writer.test.ts tests/clients/omp-path-contract.test.ts tests/clients/pi-path-contract.test.ts tests/clients/prime-client.test.ts tests/clients/sync-client-integrations.test.ts tests/config/client-config-export-new-clients.test.ts tests/config/client-config-export.test.ts tests/config/client-config-new-clients.test.ts tests/gui/integrations-invariants.test.ts tests/providers/aside-client.test.ts tests/providers/minimax-clients.test.ts tests/providers/zcode-client.test.ts tests/server/management-client-config-route.test.ts tests/server/management-integration-journal-delete.test.ts tests/server/management-integration-routes.test.ts tests/cli/cli-export-command.test.ts +bun run privacy:scan +wc -l src/clients/config-export/contracts.ts src/clients/config-export/constants.ts src/clients/config-export/model-metadata.ts src/clients/config-export/omp.ts src/clients/config-export/zcode.ts src/clients/config-export/dsh.ts src/clients/config-export/mcode.ts src/clients/config-export.ts +# Compare resolved old-path consumer identities/counts with the list in this plan +rg -n 'clients/config-export' src gui/src scripts tests +# Full suite on lidge only; parent serializes access to this shared remote checkout +ssh lidge 'cd ~/ocx-ci/opencodex && git fetch origin codex/split-clients-config-export-a && git checkout -q FETCH_HEAD && bun install --frozen-lockfile >/dev/null && bun run test' +``` + +The remote command intentionally keeps bun run test last, preserving its exit code instead of masking failure behind tail. Parent records remote HEAD and full output. Every command exits 0; focused/full tests report 0 failures. Delivery requires a green exact-head GitHub CI rollup, not an empty required-check list. + +Per 002, `bun test tests/lab/core-lab-boundary.test.ts` is conditional on source edits under `src/server|src/router|src/lib`: **not applicable** to this approved layer touch set. Do not edit its PROTECTED roots. If implementation expands into those directories, parent must approve scope and run that guard explicitly. Preserve the 33 original direct consumer files; new facade-to-leaf imports are not caller churn. The grep is a discovery list, not by itself a proof of consumer identity: resolve relative and dynamic paths as in the inventory method. Repeat lane 016 method G on the final imports to prove zero new cycles; typecheck alone is not a cycle detector. + +Drafting verification is document-only: required heading order, complete symbol ranges/ownership, projected line arithmetic, export coverage, referenced test paths, unique leaf paths and assigned-file scope. No test, typecheck, privacy scan or remote command above was executed in this drafting task. + +## Accept criteria + +1. Apply PURE-MOVE-SIZE-01: ≤150 non-move changed lines, move-aware diff evidence, and exactly one implementation owner for each inventory symbol. No claim that literal added+deleted churn meets 500. +2. Every inventory declaration has exactly one implementation owner. Preserve all original export names/signatures and value/type importability; do not extract L1 declarations a second time. +3. Every new leaf is ≤400 lines. Residual target is 1299, with the sole >400 carry explicitly assigned to 410 / #b. Measure actual files and explain drift before proceeding. +4. Preserve function bodies, branch order, literals, serialized bytes/key order, class/object identity and state initialization. Only moves, explicit imports and named forwards change source structure. +5. Old-path consumers and assertions remain intact. Record the exact red/restored-green evidence named under Tests; no guard deletion, skipping, weakened assertions or empty-facade source scans. +6. Singleton state/allowlists each have one owner; no leaf imports the original even for types; resolved static/re-export/type/dynamic-literal graph has no new cycles. +7. Typecheck, focused checks, privacy, remote full suite and exact-head CI pass at this layer tip independently of later layers. No full local suite and no merge. +8. Diff stays within the original/new leaves and genuinely required existing focused tests. New tests, SoT edits, new topology or unrelated code require parent scope approval. + +## PR + +Title: `refactor(clients): extract low-fanout client formats and dependency foundations (split S13 L1/5)` + +Branch: `codex/split-clients-config-export-a`. Base: `dev`. Closes: none. + +Use all sections of `.github/PULL_REQUEST_TEMPLATE.md` (Summary, Verification, Checklist), including the size-gate disposition and DEV-STACK-03 map below. This draft creates no PR; placeholder PR numbers are intentional. + +| # | PR | Layer | Branch | Base | Review focus | +|---|---|---|---|---|---| +| 1 | #TBD-S13-L1 | 400 — this layer | `codex/split-clients-config-export-a` | `dev` | extract low-fanout client formats and dependency foundations | +| 2 | #TBD-S13-L2 | 410 | `codex/split-clients-config-export-b` | `codex/split-clients-config-export-a` | finish client path and format partitions | +| 3 | #TBD-S13-L3 | 420 | `codex/split-cli-opencode` | `codex/split-clients-config-export-b` | separate OpenCode config and catalog from launch | +| 4 | #TBD-S13-L4 | 430 | `codex/split-cli-minimax` | `codex/split-cli-opencode` | isolate MMX protocol and termination owners | +| 5 | #TBD-S13-L5 | 440 | `codex/split-integrations-state` | `codex/split-clients-config-export-b` | separate classification from state reads | + +Bottom layer; no parent PR. Review this layer's diff only. This layer tracks `dev` directly and has no parent-layer cascade; re-verify its tip/base ref after a base update while preserving checkout ownership. Bottom-up merging remains a separate user-authorized action and is out of scope. + +## P stale-check (2026-09-05, wp400) + +origin/dev 3191fe1aa; config-export.ts unchanged since 445742966 (1990 lines). Base `dev` (S13 bottom; 410 #b, 420, 430, 440 chain on it). src/clients has no subdirectory today; the plan's `src/clients/config-export/` mirrors the src/codex/prompt-layers/ precedent from L300 — the audit confirms. 003 INTERMEDIATE-RESIDUAL-01 applies (1990 → 1299 → 273 after #b). Note: origin/dev currently carries 4 upstream test failures from #3588 (management-route-registry ×3, quota-reset-notify ×1); they are unrelated to this layer and will appear in the lidge receipt until dev is fixed. Executor rules: no bun run test; OCX_TEST_NO_QUEUE=1; CI hygiene requires a test change. + +## A audit synthesis (2026-09-05, wp400) + +Execution basis is now pinned to `850afb2e9f84979c87e914b248de482f44b34cd6`. Hooke rechecked the eight-source-file delta from `3191fe1aa`: config-export.ts and its required declarations are unchanged, and traversal including inline/type/re-export edges found no return cycle. Final verdict: PASS. The complete preserved roadmap is at immutable commit `dc44b08cafbbd45da81f940f1e8c00a9e5f61ce1` on `codex/260905-modular-debt-ledger-docs`; use `git show :devlog/_plan/260905_now_split_train/` for roadmap documents not carried in this layer's PR. The current a2c0 branch is `codex/split-clients-config-export-a`, created in place from that pinned basis; no managed worktree or session-state relocation occurred. Remote preflight found `/usr/local/bin/bun`, the expected origin URL and a clean shared seed; it did not run tests or switch the seed checkout. + +Hooke (`01a06f9f-f57f-7fc3-9261-b07f291929be`, requested gpt-6-astra high) returned GO-WITH-FIXES with zero blockers, then PASS after the two documentation corrections above. The read-only audit matched all 153 inventory ranges, assigned all 63 moved declarations uniquely, checked seven leaf and seven facade import lists, and preserved 96 public exports (47 types, 49 values). Its dependency traversal reported no return path from the external owners to the facade at base `3191fe1aa`. These are plan-audit results, not implementation or test results. + +Accepted findings: replace the stale raw-churn escalation with the binding ≤150 non-move gate; explicitly retain original blank line 33 and mark projected line counts as pre-pruning estimates. No blocker was rebutted. Re-review confirmed both closures at docs HEAD `38ad3cf5a` plus the working diff. `git diff --check` exited 0 after those edits; no local test suite was run. + +Operational audit by Wegener (`01a06fa6-5e3c-7840-8172-8587e853dcc7`, explicitly `model=gpt-6-astra`, `reasoning_effort=high`) found two blockers: checkout-local source identity was incompatible with the prior separate execution tree, and the remote recipe switched a shared checkout. Both were accepted and folded into 003 WORKTREE-EVIDENCE-01 and 000. Re-audit returned PASS, with no blocker to entering B. A documentation-only delta must not stand in for implementation evidence from another checkout. The shared-remote command above is superseded and must not be executed. Pre-C hold: independently review the actual isolated runner, exact-SHA and clean-tree checks, and failure propagation before running it. Approval of the plan is not proof that remote verification passed. From 1c6e7b557e3e242fa0e08529f925963e7f6a3b9d Mon Sep 17 00:00:00 2001 From: t Date: Sat, 5 Sep 2026 12:52:57 +0900 Subject: [PATCH 2/7] refactor(clients): extract low-fanout config export formats --- .../400_clients_config_export_a.md | 24 +- src/clients/config-export.ts | 724 +----------------- src/clients/config-export/constants.ts | 69 ++ src/clients/config-export/contracts.ts | 150 ++++ src/clients/config-export/dsh.ts | 132 ++++ src/clients/config-export/mcode.ts | 83 ++ src/clients/config-export/model-metadata.ts | 113 +++ src/clients/config-export/omp.ts | 104 +++ src/clients/config-export/zcode.ts | 92 +++ tests/config/client-config-export.test.ts | 52 ++ 10 files changed, 834 insertions(+), 709 deletions(-) create mode 100644 src/clients/config-export/constants.ts create mode 100644 src/clients/config-export/contracts.ts create mode 100644 src/clients/config-export/dsh.ts create mode 100644 src/clients/config-export/mcode.ts create mode 100644 src/clients/config-export/model-metadata.ts create mode 100644 src/clients/config-export/omp.ts create mode 100644 src/clients/config-export/zcode.ts diff --git a/devlog/_plan/260905_now_split_train/400_clients_config_export_a.md b/devlog/_plan/260905_now_split_train/400_clients_config_export_a.md index 5bdd563a09..76236e349f 100644 --- a/devlog/_plan/260905_now_split_train/400_clients_config_export_a.md +++ b/devlog/_plan/260905_now_split_train/400_clients_config_export_a.md @@ -408,7 +408,7 @@ Bottom layer; no parent PR. Review this layer's diff only. This layer tracks `de ## P stale-check (2026-09-05, wp400) -origin/dev 3191fe1aa; config-export.ts unchanged since 445742966 (1990 lines). Base `dev` (S13 bottom; 410 #b, 420, 430, 440 chain on it). src/clients has no subdirectory today; the plan's `src/clients/config-export/` mirrors the src/codex/prompt-layers/ precedent from L300 — the audit confirms. 003 INTERMEDIATE-RESIDUAL-01 applies (1990 → 1299 → 273 after #b). Note: origin/dev currently carries 4 upstream test failures from #3588 (management-route-registry ×3, quota-reset-notify ×1); they are unrelated to this layer and will appear in the lidge receipt until dev is fixed. Executor rules: no bun run test; OCX_TEST_NO_QUEUE=1; CI hygiene requires a test change. +Historical stale check at origin/dev 3191fe1aa: config-export.ts unchanged since 445742966 (1990 lines). Base `dev` (S13 bottom; 410 #b, 420, 430, 440 chain on it). The planned subdirectory mirrors the src/codex/prompt-layers/ precedent from L300. 003 INTERMEDIATE-RESIDUAL-01 applies. Known upstream failures were management-route-registry ×3 and quota-reset-notify ×1. The earlier OCX_TEST_NO_QUEUE=1 instruction is withdrawn: it contaminates lock tests and must be unset for remote verification. No local suites; CI hygiene requires a test change. ## A audit synthesis (2026-09-05, wp400) @@ -419,3 +419,25 @@ Hooke (`01a06f9f-f57f-7fc3-9261-b07f291929be`, requested gpt-6-astra high) retur Accepted findings: replace the stale raw-churn escalation with the binding ≤150 non-move gate; explicitly retain original blank line 33 and mark projected line counts as pre-pruning estimates. No blocker was rebutted. Re-review confirmed both closures at docs HEAD `38ad3cf5a` plus the working diff. `git diff --check` exited 0 after those edits; no local test suite was run. Operational audit by Wegener (`01a06fa6-5e3c-7840-8172-8587e853dcc7`, explicitly `model=gpt-6-astra`, `reasoning_effort=high`) found two blockers: checkout-local source identity was incompatible with the prior separate execution tree, and the remote recipe switched a shared checkout. Both were accepted and folded into 003 WORKTREE-EVIDENCE-01 and 000. Re-audit returned PASS, with no blocker to entering B. A documentation-only delta must not stand in for implementation evidence from another checkout. The shared-remote command above is superseded and must not be executed. Pre-C hold: independently review the actual isolated runner, exact-SHA and clean-tree checks, and failure propagation before running it. Approval of the plan is not proof that remote verification passed. + +## B implementation record (2026-09-05) + +Franklin (`01a06fac-95ee-77a0-8916-f7546c2b8996`, explicitly gpt-6-astra high) implemented only the approved source/test paths in a2c0 and handed them back without Git mutations or local tests. Main inspected the diff and measured all leaves. Source owner search and the A inventory were reused; no new algorithm or parallel implementation was introduced. + +| File | Change and impact | Measured lines | +|---|---|---:| +| `src/clients/config-export.ts` | Retains dispatch/compatibility exports, imports moved owners; caller paths unchanged | 1298 | +| `src/clients/config-export/contracts.ts` | Canonical shared types, no runtime behavior | 150 | +| `src/clients/config-export/constants.ts` | Single constant/default-object owner | 69 | +| `src/clients/config-export/model-metadata.ts` | Existing normalization/modality/admission helpers moved intact | 113 | +| `src/clients/config-export/omp.ts` | Existing OMP builder, summary and owned fragment | 104 | +| `src/clients/config-export/zcode.ts` | Existing ZCode builder, summary and owned fragment | 92 | +| `src/clients/config-export/dsh.ts` | Existing DSH builder, summary and owned fragment | 132 | +| `src/clients/config-export/mcode.ts` | Existing MCode builder, summary and owned fragment | 83 | +| `tests/config/client-config-export.test.ts` | Adds identity and independent fixed-byte/fragment assertions; original assertions/fixtures unchanged | 919 | + +The worker's AST inventory reports 153 unique owners (63 moved, 90 retained), 96 public exports (47 types, 49 values), 707 moved original lines and 109 non-move lines (36 leaf glue + 18 facade additions + 3 removals + 52 test additions). Actual facade size is one below the estimate because the unused provider import was removed. `git diff --check` passed. The existing test file was already over 400; its scoped extension is not a claim to resolve test-file debt. Final independent graph/syntax and runtime gates remain pending; the worker's combined graph/syntax command hit an AST no-match exit and did not establish a pass. + +Verification runner: `.codexclaw/evidence/01a06e97-b9d8-7250-8204-bb788338c288/wp400-check.sh` invokes the reviewed `wp400-remote-check.sh` only over SSH. Wegener closed the pre-C hold after four clean-tree substitutions were changed to standalone Git-status assignments and the no-queue override was removed. Both scripts pass `bash -n`; runtime success is not implied. + +Baseline evidence: isolated remote `/tmp/ocx-wp400.4dKWtB/repo`, exact base `850afb2e9f84979c87e914b248de482f44b34cd6`; typecheck, 440 focused tests across 21 files and privacy scan passed. Full suite exited 1. The initial runner mistakenly exported OCX_TEST_NO_QUEUE=1, inducing four lock-test failures in addition to the known upstream route-registry/rollover failures. That run is contaminated and cannot certify all gates. The variable is now explicitly unset; corrected verification is required. Full baseline output is retained as `wp400-base-check.log` in the same evidence directory. No local suite was run. diff --git a/src/clients/config-export.ts b/src/clients/config-export.ts index ea1b623f31..7aaf556f2b 100644 --- a/src/clients/config-export.ts +++ b/src/clients/config-export.ts @@ -24,56 +24,28 @@ import { existsSync, readFileSync } from "node:fs"; import { isAbsolute, join, resolve } from "node:path"; import { shouldInjectApiAuthHeader, standaloneCodexRoutingTarget } from "../codex/inject"; import { FORMAT_MEDIA_TYPE, serializeDocument, type ConfigFormat } from "../integrations/serialize"; -import { providerCodexAccountMode } from "../providers/registry"; -import { canonicalizeReasoningEfforts, sanitizeCodexReasoningEfforts } from "../reasoning-effort"; +import { canonicalizeReasoningEfforts } from "../reasoning-effort"; import { probeHostname } from "../server/proxy-liveness"; import type { OcxConfig } from "../types"; -export type { ConfigFormat }; +export type { ConfigFormat } from "../integrations/serialize"; +export type { ManagedFragment, ManagedContribution, BuildContribution, OpencodeLaunchEnv, OpencodeCatalogModel, ExportModel, ExportContext, ExportClientId, ExportClientSpec, PiModelEntry } from "./config-export/contracts"; +export { OPENCODE_PROVIDER_ID, OPENCODE_CONFIG_SCHEMA, OPENCODE_API_KEY_ENV, OPENCODE_API_KEY_ENV_REF, HERMES_API_KEY_ENV, HERMES_API_KEY_ENV_REF, OPENCLAW_API_KEY_ENV, OPENCLAW_API_KEY_ENV_REF, LOOPBACK_API_KEY_PLACEHOLDER, GAJAE_API_KEY_ENV, SCHEMA_REQUIRED_OUTPUT_BUDGET, OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG } from "./config-export/constants"; +export { normalizeExportModels } from "./config-export/model-metadata"; +export type { OmpModelEntry, OmpProviderBlock, OmpGeneratedConfig } from "./config-export/omp"; +export type { ZcodeModelEntry, ZcodeProviderBlock, ZcodeGeneratedConfig } from "./config-export/zcode"; +export type { DshReasoningEffort, DshWireReasoningEffort, DshModelEntry, DshProviderBlock, DshGeneratedConfig } from "./config-export/dsh"; +export type { McodeProviderBlock, McodeModelEntry, McodeGeneratedConfig } from "./config-export/mcode"; -/** - * One entry opencodex owns inside a client's config: the JSON path to it and - * the value we put there. - * - * A path list rather than a single provider key because ownership is not - * always one entry — Kimi owns its provider block AND one model entry per - * model, and a writer that only knew about the provider would strand the rest - * (devlog 260802 006 §2). - */ -export interface ManagedFragment { - path: readonly string[]; - value: unknown; -} +import type { OpencodeLaunchEnv, OpencodeCatalogModel, ExportContext, PiModelEntry, ManagedContribution, ManagedFragment, ExportClientId, ExportClientSpec } from "./config-export/contracts"; +import { OPENCODE_API_KEY_ENV_REF, OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG, OPENCODE_CONFIG_SCHEMA, OPENCODE_PROVIDER_ID, PI_API_DIALECT, LOOPBACK_API_KEY_PLACEHOLDER, HERMES_API_KEY_ENV_REF, OPENCLAW_API_KEY_ENV_REF, GAJAE_API_KEY_ENV, OPENCODE_API_KEY_ENV, HERMES_API_KEY_ENV, OPENCLAW_API_KEY_ENV } from "./config-export/constants"; +import { exportModelLabel, authoritativeContextWindow, outputBudgetFor, normalizeExportModels, inputModalitiesForClient, proxyAdmissionHeaders, singleFragment } from "./config-export/model-metadata"; +import { buildOmpClientConfig, summarizeOmp, buildOmpContribution } from "./config-export/omp"; +import { buildDshClientConfig, summarizeDsh, buildDshContribution } from "./config-export/dsh"; +import { buildMcodeClientConfig, summarizeMcode, buildMcodeContribution } from "./config-export/mcode"; +import { buildZcodeClientConfig, summarizeZcode, buildZcodeContribution } from "./config-export/zcode"; -/** Everything opencodex contributes to one client's config, as one unit. */ -export interface ManagedContribution { - clientId: ExportClientId; - fragments: readonly ManagedFragment[]; -} - -export type BuildContribution = (ctx: ExportContext) => ManagedContribution; - -export interface OpencodeLaunchEnv { - [key: string]: string | undefined; -} -/** Visible catalog entry keyed by the proxy's canonical namespaced selector. */ -export interface OpencodeCatalogModel { - namespaced: string; - native?: boolean; - provider?: string; - id?: string; - contextWindow?: number; - displayName?: string; - /** Declared effort ladder. Exported as opencode model variants where the client reads them. */ - reasoningEfforts?: readonly string[]; - /** - * Declared default effort. Carried so every client export reads one deduped, visibility- - * filtered ladder per model. The opencode serializer deliberately does NOT turn it into a - * model-level setting — see {@link opencodeEffortVariants} for why. - */ - defaultReasoningEffort?: string; -} export interface OpencodeModelEntry { name: string; @@ -137,11 +109,6 @@ export interface OpencodeGeneratedConfig { providers: Record; } -/** Provider key owned by this project; the only key any exporter ever emits. */ -export const OPENCODE_PROVIDER_ID = "opencodex"; - -export const OPENCODE_CONFIG_SCHEMA = "https://opencode.ai/config.json"; - /** * The proxy speaks the OpenAI-compatible shape at /v1, which opencode reaches through * the AI SDK's openai-compatible package (the same wiring users hand-write today). @@ -163,67 +130,6 @@ const OPENCODE_V2_PROVIDER_PACKAGE = "@opencode-ai/ai/providers/openai-compatibl /** Display name for the provider block, identical in both generations. */ const OPENCODE_PROVIDER_NAME = "OpenCodex"; -/** - * Env var carrying the proxy admission key to opencode. The config only ever holds the - * `{env:...}` reference, so the secret never lands on disk. opencode substitutes it at - * load time. - */ -export const OPENCODE_API_KEY_ENV = "OPENCODEX_OPENCODE_API_KEY"; - -/** Env reference shared by apiKey and the dedicated proxy admission header. */ -export const OPENCODE_API_KEY_ENV_REF = `{env:${OPENCODE_API_KEY_ENV}}`; - -/** - * Hermes interpolates `${VAR}` anywhere in config.yaml, so the credential stays - * in the environment exactly as it does for OpenCode. - */ -export const HERMES_API_KEY_ENV = "OPENCODEX_HERMES_API_KEY"; -export const HERMES_API_KEY_ENV_REF = `\${${HERMES_API_KEY_ENV}}`; - -/** OpenClaw interpolates `${UPPERCASE_VAR}` and fails closed when it is unset. */ -export const OPENCLAW_API_KEY_ENV = "OPENCODEX_OPENCLAW_API_KEY"; -export const OPENCLAW_API_KEY_ENV_REF = `\${${OPENCLAW_API_KEY_ENV}}`; - -/** - * Placeholder credential for loopback-only clients (Kimi, Pi). A loopback - * bind needs no real admission key, so we emit the same placeholder the Grok - * managed block uses rather than a user secret. Pi resolves `apiKey` before - * building its model list and hides the provider when an env reference is unset. - */ -export const LOOPBACK_API_KEY_PLACEHOLDER = "opencodex-loopback"; - -/** - * Gajae's `apiKeyEnv` is env-name-only and fail-closed. Its sibling `apiKey` - * falls back to treating the literal text as the token when the variable is - * unset, which would silently ship a bogus credential — so we never emit it. - */ -export const GAJAE_API_KEY_ENV = "OPENCODEX_GAJAE_API_KEY"; - -/** Pi's wire-dialect selector for an OpenAI-compatible endpoint. */ -const PI_API_DIALECT = "openai-completions"; - -/** - * opencode's config schema rejects a `limit` block that carries `context` without - * `output`, but CatalogModel has no authoritative per-model output field. Dropping - * `limit` entirely would also throw away the authoritative context window we DO have, - * so the block is emitted with this budget standing in for the missing half. - * - * The value matches REASONING_MAX_TOKENS_CEILING in src/adapters/anthropic.ts — the - * project's existing "safe ceiling across current models" figure. It is a ceiling for - * schema validity, NOT a claim about any specific model's true maximum, and it is - * clamped to the context window so a small-context model can never be emitted with - * output > context. Pi's `maxTokens` uses the same stand-in and the same clamp. - */ -export const SCHEMA_REQUIRED_OUTPUT_BUDGET = 32_000; - -/** Deterministic loopback default for exported provider-block helpers in tests. */ -export const OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG: OcxConfig = { - port: 10100, - hostname: "127.0.0.1", - defaultProvider: "mock", - providers: { mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1/v1" } }, -} as OcxConfig; - /** * Resolve the user's global opencode config path. opencode uses the XDG layout on every * platform (including Windows, where it is %USERPROFILE%\.config\opencode). @@ -626,184 +532,6 @@ export function asideConfigPath(env: OpencodeLaunchEnv = process.env, home: stri return join(asideAccountDir(env, home), "models.json"); } -/** - * One proxy-routed model destined for a client config. Deliberately narrower than - * `CatalogModel` so a serializer cannot reach for a field that does not survive the - * `/api/models` boundary. - */ -export interface ExportModel { - /** Canonical proxy selector: `provider/id`, or bare slug for native. */ - namespaced: string; - provider: string; - id: string; - /** Native OpenAI entry. Read by the shared label rule. */ - native?: boolean; - displayName?: string; - contextWindow?: number; - inputModalities?: string[]; - /** Optional effort ladder exported only to clients that support it. */ - reasoningEfforts?: string[]; - defaultReasoningEffort?: string; -} - -export interface ExportContext { - /** `http://host:port/v1` — the OpenAI-compatible surface the client dials. */ - baseUrl: string; - models: readonly ExportModel[]; - /** - * Live proxy config. Only the OpenCode path reads it: a non-loopback bind moves - * admission from `apiKey` to the `x-opencodex-api-key` header. - */ - config?: OcxConfig; -} - -export type ExportClientId = - | "opencode" - | "pi" - | "omp" - | "hermes" - | "openclaw" - | "kimi" - | "gajae" - | "dsh" - | "mcode" - | "zcode" - | "prime" - | "aside"; - -export interface ExportClientSpec { - id: ExportClientId; - /** Download filename; matches the destination file's own name (003 §5). */ - filename: string; - /** Canonical destination for humans. Never written to. */ - destination: (env: NodeJS.ProcessEnv) => string; - /** Env var the config references; the value is never serialized. */ - apiKeyEnv: string; - /** Shell line the user runs before launching the client. */ - exportHint: string; - build: (ctx: ExportContext) => unknown; - /** - * Text format of the client's config file. `filename` already carries the - * extension; this drives serialization and the download media type so no - * consumer has to infer either from the name. - */ - format: ConfigFormat; - /** - * Count models in THIS client's document shape. Required so a new client - * cannot be added without teaching the summarizer about it — the old - * "anything that is not OpenCode must be Pi" branch was a latent bug. - */ - summarize: (document: unknown) => { modelCount: number; modelsWithoutLimits: number }; - /** - * The fragments opencodex owns inside this client's config. Only the builder - * knows where a client keeps our entries, so ownership paths originate here - * rather than being re-derived by the writer. - */ - buildContribution: BuildContribution; - /** - * True when the generated integration deliberately supports loopback only. - * - * `/v1/chat/completions` rejects bearer credentials and requires the - * dedicated `x-opencodex-api-key` header (AUTH_MATRIX in - * src/server/auth-cors.ts). If this exporter cannot safely emit that header, - * it refuses a remote bind rather than generating a config that 401s. Same - * reasoning as the Grok managed block's non-loopback refusal. - */ - loopbackOnly: boolean; -} - -/** - * Authoritative context window, or undefined. Never guesses: a missing, non-finite, or - * non-positive value means the serializer omits every context-derived field. - */ -function authoritativeContextWindow(contextWindow: number | undefined): number | undefined { - if (typeof contextWindow === "number" && Number.isFinite(contextWindow) && contextWindow > 0) { - const integer = Math.floor(contextWindow); - return integer > 0 ? integer : undefined; - } - return undefined; -} - -/** Schema-required output budget for a known context window. */ -function outputBudgetFor(context: number): number { - return Math.min(SCHEMA_REQUIRED_OUTPUT_BUDGET, context); -} - -/** - * Modalities a given client's schema will actually accept. - * - * Our internal vocabulary is `text | image | audio` (ALLOWED_INPUT_MODALITIES in - * src/server/management/model-routes.ts). Pi and Gajae accept only - * `text | image`, and both reject the WHOLE config file over one out-of-enum - * value — Gajae reports `/providers/opencodex/models/N/input/2: Invalid option` - * and falls back to its built-in list, Pi returns an empty model config. So a - * single `audio` model takes every routed model down with it. That is not - * hypothetical: zenmux/meta-muse-spark-1.1 advertises audio and did exactly - * this. It is also the same defect the Codex catalog had with `video`, where - * the app showed zero apps (tests/codex-integration/catalog-input-modality-enum.test.ts). - * - * UNKNOWN and INCOMPATIBLE are different inputs, and the Codex fix could - * conflate them safely only because its enum is wider. A model with nothing - * declared is unknown, and `text` is the honest floor — every routed model takes - * prompts. A model declaring `["audio"]` and nothing else is incompatible with a - * text|image client, and rewriting it to `["text"]` would advertise a capability - * it does not have. That input is reachable three ways: `ocx models add - * --modalities audio`, `/api/custom-models`, and provider discovery. - * - * So unknown falls back to text and incompatible returns null, which drops the - * row. Omitting a model costs the user a line in a picker; fabricating `text` - * costs them a model that fails at call time with no explanation. - * - * Deliberately NOT applied in `ExportModel` construction: the management and CLI - * boundaries carry catalog modalities verbatim on purpose, and stripping `audio` - * globally would destroy valid metadata before the destination is known. - */ -const CLIENT_INPUT_MODALITIES: Record<"pi" | "gajae", ReadonlySet> = { - pi: new Set(["text", "image"]), - gajae: new Set(["text", "image"]), -}; - -/** `null` means the model cannot be represented for this client — drop the row. */ -function inputModalitiesForClient( - client: "pi" | "gajae", - modalities: readonly string[] | undefined, -): string[] | null { - const declared = modalities ?? []; - if (declared.length === 0) return ["text"]; - const accepted = CLIENT_INPUT_MODALITIES[client]; - const kept: string[] = []; - for (const value of declared) { - if (accepted.has(value) && !kept.includes(value)) kept.push(value); - } - return kept.length > 0 ? kept : null; -} - -/** DSH rc.6 accepts text/image; unknown values degrade to text, while audio-only cannot be represented. */ -function dshInputModalities(modalities: readonly string[] | undefined): string[] | null { - const declared = modalities ?? []; - if (declared.length === 0) return ["text"]; - const kept: string[] = []; - for (const value of declared) { - if ((value === "text" || value === "image") && !kept.includes(value)) kept.push(value); - } - if (kept.length > 0) return kept; - return declared.every(value => value === "audio") ? null : ["text"]; -} - -/** - * Label shared by every client: `" ()"`. The - * provider suffix is what makes two same-named models from different upstreams - * distinguishable in a client's model picker. - */ -function exportModelLabel(model: OpencodeCatalogModel): string { - const providerLabel = model.native ? "native" : (model.provider ?? "routed"); - const id = model.id ?? model.namespaced; - if (model.displayName && model.displayName.length > 0) { - return `${model.displayName} (${providerLabel})`; - } - return `${id} (${providerLabel})`; -} - /** Endpoint plus admission, identical for the V1 `options` and V2 `settings` field. */ function opencodeProviderConnection(baseURL: string, config: OcxConfig): OpencodeProviderConnection { const options: OpencodeProviderConnection = { baseURL }; @@ -925,23 +653,6 @@ export function buildOpencodeProviderBlockFromCatalog( return opencodeProviderBlock(opencodeProxyBaseUrl(port, hostname), catalogModels, config); } -/** - * Shared precondition for every serializer: drop duplicate `namespaced` (first wins, - * native rows lead `/api/models`) and sort by `namespaced` so two calls with the same - * models produce identical bytes. Stability matters because the GUI shows a diffable - * preview and agents may checksum the payload. - */ -export function normalizeExportModels(models: readonly ExportModel[]): ExportModel[] { - const seen = new Set(); - const unique: ExportModel[] = []; - for (const model of models) { - if (seen.has(model.namespaced)) continue; - seen.add(model.namespaced); - unique.push(model); - } - return unique.sort((a, b) => (a.namespaced < b.namespaced ? -1 : a.namespaced > b.namespaced ? 1 : 0)); -} - /** * OpenCode document: both provider generations plus `$schema`, and nothing else. * @@ -961,23 +672,6 @@ function buildOpencodeClientConfig(ctx: ExportContext): OpencodeGeneratedConfig }; } -export interface PiModelEntry { - id: string; - name: string; - input: string[]; - contextWindow?: number; - maxTokens?: number; - /** Advertised when the catalog row carries a non-empty effort ladder. */ - reasoning?: true; - /** - * Constrains pi's own level scale (minimal..max) to the declared ladder: members map to - * themselves, everything else is hidden (`null`). Without it pi would offer levels the - * ladder does not contain — harmless for provider-config ladders (the proxy clamps those - * at the wire) but a real 400 risk for custom-row ladders, which are advertisement-only. - */ - thinkingLevelMap?: Record; -} - export interface PiProviderBlock { baseUrl: string; api: string; @@ -989,50 +683,6 @@ export interface PiGeneratedConfig { providers: Record; } -/** - * omp accepts a model-level API override. Keep the provider on Chat - * Completions so routed providers retain their established wire format, while - * native OpenAI models can use the lossless Responses surface. - */ -export interface OmpModelEntry extends PiModelEntry { - api?: "openai-responses"; - /** omp requires this flag before it honors a thinking block. */ - reasoning?: true; - thinking?: { - mode: "effort"; - efforts: string[]; - defaultLevel?: string; - }; -} - -export interface OmpProviderBlock { - baseUrl: string; - api: typeof PI_API_DIALECT; - apiKey: string; - models: OmpModelEntry[]; -} - -export interface OmpGeneratedConfig { - providers: Record; -} - -/** - * omp validates model entries strictly. These are its documented effort - * values; omit an unknown value rather than invalidating the whole provider. - */ -const OMP_EFFORT_VOCABULARY = new Set(["minimal", "low", "medium", "high", "xhigh", "max"]); - -function ompEfforts(model: ExportModel): string[] { - const efforts: string[] = []; - for (const effort of model.reasoningEfforts ?? []) { - const normalized = effort.trim().toLowerCase(); - if (OMP_EFFORT_VOCABULARY.has(normalized) && !efforts.includes(normalized)) { - efforts.push(normalized); - } - } - return efforts; -} - /** * Hermes `~/.hermes/config.yaml`. We emit ONLY the provider entry — never * `model.default` — because hijacking the user's main model is not what a @@ -1124,85 +774,6 @@ export interface GajaeGeneratedConfig { providers: Record; } -export type DshReasoningEffort = "low" | "medium" | "high" | "xhigh" | "max"; -export type DshWireReasoningEffort = DshReasoningEffort | "ultra"; - -export interface DshModelEntry { - id: string; - name: string; - input: string[]; - contextWindow?: number; - reasoningEfforts?: Partial>; -} - -export interface DshProviderBlock { - displayName: "OpenCodex"; - api: "openai-responses"; - baseURL: string; - headers: { Authorization: "Bearer ocx_data_dsh" }; - models: DshModelEntry[]; -} - -export interface DshGeneratedConfig { - "llm-pi-ai": { - providers: Record; - }; -} - -export interface McodeProviderBlock { - name: "OpenCodex"; - kind: "custom"; - enabled: true; - api: "anthropic-messages"; - options: { - apiKey: string; - baseURL: string; - authMode: "api-key"; - }; - models: Record; -} - -export interface McodeModelEntry { - /** MCode uses this value for context accounting and compaction. */ - limit?: { context: number }; - /** MCode exposes these exact levels in `/model` and sends the selected effort. */ - thinking?: { effortOptions: string[] }; -} - -export interface McodeGeneratedConfig { - custom_provider: Record; -} - -/** - * ZCode's `~/.zcode/v2/config.json` provider entry (observed schema, validated - * live against ZCode 3.7.7 / 3.8.1). `kind: "openai-compatible"` selects the - * OpenAI Chat Completions protocol, which the proxy serves at `/v1/chat/completions`. - * `apiKeyRequired` keeps ZCode's UI from prompting for a key it does not need on - * loopback; the serialized key is always the non-secret loopback placeholder. - */ -export interface ZcodeModelEntry { - name?: string; - limit?: { context: number; output?: number }; - modalities: { input: string[]; output: string[] }; -} - -export interface ZcodeProviderBlock { - name: "OpenCodex"; - kind: "openai-compatible"; - enabled: true; - source: "custom"; - options: { - apiKey: string; - baseURL: string; - apiKeyRequired: true; - }; - models: Record; -} - -export interface ZcodeGeneratedConfig { - provider: Record; -} - /** * Pi's `~/.pi/agent/models.json` shape. `models` is an ARRAY (identity lives in `id`), * unlike OpenCode's keyed object. @@ -1275,56 +846,6 @@ function buildPiClientConfig(ctx: ExportContext): PiGeneratedConfig { }; } -/** - * omp's models.yml is Pi-like, but it supports effort metadata and a per-model - * API dialect. Native OpenAI models use Responses; all routed models inherit - * the provider's existing Chat Completions dialect. - */ -function buildOmpClientConfig(ctx: ExportContext): OmpGeneratedConfig { - const models: OmpModelEntry[] = []; - for (const model of normalizeExportModels(ctx.models)) { - const input = inputModalitiesForClient("pi", model.inputModalities); - if (input === null) continue; - const entry: OmpModelEntry = { - id: model.namespaced, - name: exportModelLabel(model), - input, - ...(model.native && model.provider === "openai" ? { api: "openai-responses" } : {}), - }; - const context = authoritativeContextWindow(model.contextWindow); - if (context !== undefined) { - entry.contextWindow = context; - entry.maxTokens = outputBudgetFor(context); - } - const efforts = ompEfforts(model); - if (efforts.length > 0) { - const defaultLevel = model.defaultReasoningEffort?.trim().toLowerCase(); - entry.reasoning = true; - entry.thinking = { - mode: "effort", - efforts, - ...(defaultLevel && efforts.includes(defaultLevel) ? { defaultLevel } : {}), - }; - } - models.push(entry); - } - return { - providers: { - [OPENCODE_PROVIDER_ID]: { - baseUrl: ctx.baseUrl, - api: PI_API_DIALECT, - apiKey: LOOPBACK_API_KEY_PLACEHOLDER, - models, - }, - }, - }; -} - -/** Extra headers a non-loopback bind needs, or nothing on loopback. */ -function proxyAdmissionHeaders(config: OcxConfig | undefined, envRef: string): Record | undefined { - return shouldInjectApiAuthHeader(config) ? { "x-opencodex-api-key": envRef } : undefined; -} - function buildHermesClientConfig(ctx: ExportContext): HermesGeneratedConfig { const models: Record = {}; for (const model of normalizeExportModels(ctx.models)) { @@ -1437,174 +958,6 @@ function buildGajaeClientConfig(ctx: ExportContext): GajaeGeneratedConfig { }; } -const DSH_EFFORT_ORDER: readonly DshReasoningEffort[] = ["low", "medium", "high", "xhigh", "max"]; - -function dshReasoningEfforts(model: ExportModel): DshModelEntry["reasoningEfforts"] { - const offered = new Set(); - for (const raw of model.reasoningEfforts ?? []) { - const effort = raw.trim().toLowerCase(); - if (effort === "ultra" || DSH_EFFORT_ORDER.includes(effort as DshReasoningEffort)) offered.add(effort); - } - if (offered.size === 0) return undefined; - const entries: Array<[DshReasoningEffort, DshWireReasoningEffort]> = []; - for (const effort of DSH_EFFORT_ORDER) { - if (effort !== "max") { - if (offered.has(effort)) entries.push([effort, effort]); - continue; - } - // DSH's key is the selectable level; the value is what it sends on the - // wire. Preserve OpenCodex's `ultra` spelling when that is the only - // highest effort, exactly like the rc.6 `max: ultra` contract. - if (offered.has("max")) entries.push(["max", "max"]); - else if (offered.has("ultra")) entries.push(["max", "ultra"]); - } - return Object.fromEntries(entries); -} - -function isKnownSafeDshCombo(model: ExportModel, config: OcxConfig): boolean { - const combos = (config as { combos?: unknown }).combos; - if (typeof combos !== "object" || combos === null || Array.isArray(combos)) return false; - const combo = (combos as Record)[model.id]; - if (typeof combo !== "object" || combo === null || Array.isArray(combo)) return false; - const targets = (combo as { targets?: unknown }).targets; - if (!Array.isArray(targets) || targets.length === 0) return false; - return targets.every(target => { - if (typeof target !== "object" || target === null || Array.isArray(target)) return false; - const provider = (target as { provider?: unknown }).provider; - const modelId = (target as { model?: unknown }).model; - return typeof provider === "string" - && provider.length > 0 - && provider === provider.trim() - && provider !== "openai" - && typeof modelId === "string" - && modelId.length > 0 - && modelId === modelId.trim(); - }); -} - -function buildDshClientConfig(ctx: ExportContext): DshGeneratedConfig { - const direct = providerCodexAccountMode("openai", ctx.config?.providers?.openai) === "direct"; - const models: DshModelEntry[] = []; - for (const model of normalizeExportModels(ctx.models)) { - if (direct && (model.native === true || model.provider === "openai")) continue; - if (direct && model.provider === "combo" && (!ctx.config || !isKnownSafeDshCombo(model, ctx.config))) continue; - const input = dshInputModalities(model.inputModalities); - if (input === null) continue; - const contextWindow = authoritativeContextWindow(model.contextWindow); - const reasoningEfforts = dshReasoningEfforts(model); - models.push({ - id: model.namespaced, - name: exportModelLabel(model), - input, - ...(contextWindow !== undefined ? { contextWindow } : {}), - ...(reasoningEfforts ? { reasoningEfforts } : {}), - }); - } - return { - "llm-pi-ai": { - providers: { - [OPENCODE_PROVIDER_ID]: { - displayName: "OpenCodex", - api: "openai-responses", - baseURL: ctx.baseUrl, - headers: { Authorization: "Bearer ocx_data_dsh" }, - models, - }, - }, - }, - }; -} - -/** - * MiniMax Code's `provider add` command persists custom providers under - * `custom_provider.`. Its current model schema reads `limit.context` for - * context accounting and `thinking.effortOptions` for the `/model` effort - * control. Do not emit the removed `thinking.effort` / `defaultEffort` fields: - * MCode 0.1.6 migrates those into options and keeps the selected effort in the - * session. Do not emit `defaultModel` either: connecting a client must not - * silently replace the user's current model selection. - */ -function buildMcodeClientConfig(ctx: ExportContext): McodeGeneratedConfig { - const models: Record = {}; - for (const model of normalizeExportModels(ctx.models)) { - const entry: McodeModelEntry = {}; - const context = authoritativeContextWindow(model.contextWindow); - if (context !== undefined) entry.limit = { context }; - // `none` is an internal Codex catalog sentinel, not an MCode effort. MCode - // forwards every option as `output_config.effort` while keeping adaptive - // thinking enabled, and the Anthropic ingress deliberately accepts only - // minimal..ultra. Advertising `none` would therefore create a selectable - // value that cannot disable reasoning and is not forwarded as an effort. - const efforts = sanitizeCodexReasoningEfforts(model.reasoningEfforts) - ?.filter(effort => effort !== "none"); - if (efforts && efforts.length > 0) entry.thinking = { effortOptions: efforts }; - models[model.namespaced] = entry; - } - return { - custom_provider: { - [OPENCODE_PROVIDER_ID]: { - name: "OpenCodex", - kind: "custom", - enabled: true, - api: "anthropic-messages", - options: { - apiKey: LOOPBACK_API_KEY_PLACEHOLDER, - baseURL: ctx.baseUrl.replace(/\/v1\/?$/, ""), - authMode: "api-key", - }, - models, - }, - }, - }; -} - -/** - * ZCode dials the OpenAI Chat Completions surface (`openai-compatible`), which - * appends `/chat/completions` to `baseURL`. We supply `baseURL` with the `/v1` - * suffix so requests land on `/v1/chat/completions`. Model ids are the proxy's canonical - * `provider/id` selectors, which `/v1/chat/completions` resolves directly. Context - * limits follow the authoritative-window rule: a model without one ships - * without `limit` rather than guessing. Modalities are ZCode's observed - * `text`-floor vocabulary; image-capable rows advertise image input. - */ -function buildZcodeClientConfig(ctx: ExportContext): ZcodeGeneratedConfig { - const models: Record = {}; - for (const model of normalizeExportModels(ctx.models)) { - const input = inputModalitiesForClient("pi", model.inputModalities); - if (input === null) continue; - const entry: ZcodeModelEntry = { - name: exportModelLabel(model), - modalities: { input, output: ["text"] }, - }; - // `limit.context` follows the authoritative-window rule. `output` is - // deliberately absent: ZCode's schema makes it optional and we have no - // authoritative output budget to assert (reviewer finding: an emitted - // stand-in would be a guessed capability, exactly what "no metadata is - // guessed" forbids). - const context = authoritativeContextWindow(model.contextWindow); - if (context !== undefined) { - entry.limit = { context }; - } - models[model.namespaced] = entry; - } - return { - provider: { - [OPENCODE_PROVIDER_ID]: { - name: "OpenCodex", - kind: "openai-compatible", - enabled: true, - source: "custom", - options: { - apiKey: LOOPBACK_API_KEY_PLACEHOLDER, - baseURL: ctx.baseUrl.replace(/\/v1\/?$/, "") + "/v1", - apiKeyRequired: true, - }, - models, - }, - }, - }; -} - /** * Per-client model counts, read back off the SERIALIZED document rather than * recomputed from the input rows: `modelsWithoutLimits` drives a GUI line about @@ -1621,11 +974,6 @@ function summarizePi(document: unknown): { modelCount: number; modelsWithoutLimi return { modelCount: models.length, modelsWithoutLimits: models.filter(model => model.contextWindow === undefined).length }; } -function summarizeOmp(document: unknown): { modelCount: number; modelsWithoutLimits: number } { - const models = (document as OmpGeneratedConfig | undefined)?.providers?.[OPENCODE_PROVIDER_ID]?.models ?? []; - return { modelCount: models.length, modelsWithoutLimits: models.filter(model => model.contextWindow === undefined).length }; -} - function summarizeHermes(document: unknown): { modelCount: number; modelsWithoutLimits: number } { const models = (document as HermesGeneratedConfig | undefined)?.providers?.[OPENCODE_PROVIDER_ID]?.models ?? {}; // Hermes carries capability metadata but no per-model limit to be missing. @@ -1649,26 +997,6 @@ function summarizeGajae(document: unknown): { modelCount: number; modelsWithoutL return { modelCount: models.length, modelsWithoutLimits: models.filter(model => model.contextWindow === undefined).length }; } -function summarizeDsh(document: unknown): { modelCount: number; modelsWithoutLimits: number } { - const models = (document as DshGeneratedConfig | undefined)?.["llm-pi-ai"]?.providers?.[OPENCODE_PROVIDER_ID]?.models ?? []; - return { modelCount: models.length, modelsWithoutLimits: models.filter(model => model.contextWindow === undefined).length }; -} - -function summarizeMcode(document: unknown): { modelCount: number; modelsWithoutLimits: number } { - const models = Object.values((document as McodeGeneratedConfig | undefined)?.custom_provider?.[OPENCODE_PROVIDER_ID]?.models ?? {}); - return { modelCount: models.length, modelsWithoutLimits: models.filter(model => !model.limit).length }; -} - -function summarizeZcode(document: unknown): { modelCount: number; modelsWithoutLimits: number } { - const models = Object.values((document as ZcodeGeneratedConfig | undefined)?.provider?.[OPENCODE_PROVIDER_ID]?.models ?? {}); - return { modelCount: models.length, modelsWithoutLimits: models.filter(model => !model.limit).length }; -} - -/** One fragment at `path`, built from this client's own document. */ -function singleFragment(clientId: ExportClientId, path: readonly string[], value: unknown): ManagedContribution { - return { clientId, fragments: [{ path, value }] }; -} - function buildOpencodeContribution(ctx: ExportContext): ManagedContribution { const doc = buildOpencodeClientConfig(ctx); return { @@ -1688,11 +1016,6 @@ function buildPiContribution(ctx: ExportContext): ManagedContribution { return singleFragment("pi", ["providers", OPENCODE_PROVIDER_ID], doc.providers[OPENCODE_PROVIDER_ID]); } -function buildOmpContribution(ctx: ExportContext): ManagedContribution { - const doc = buildOmpClientConfig(ctx); - return singleFragment("omp", ["providers", OPENCODE_PROVIDER_ID], doc.providers[OPENCODE_PROVIDER_ID]); -} - function buildHermesContribution(ctx: ExportContext): ManagedContribution { const doc = buildHermesClientConfig(ctx); return singleFragment("hermes", ["providers", OPENCODE_PROVIDER_ID], doc.providers[OPENCODE_PROVIDER_ID]); @@ -1724,21 +1047,6 @@ function buildGajaeContribution(ctx: ExportContext): ManagedContribution { return singleFragment("gajae", ["providers", OPENCODE_PROVIDER_ID], doc.providers[OPENCODE_PROVIDER_ID]); } -function buildDshContribution(ctx: ExportContext): ManagedContribution { - const doc = buildDshClientConfig(ctx); - return singleFragment("dsh", ["llm-pi-ai", "providers", OPENCODE_PROVIDER_ID], doc["llm-pi-ai"].providers[OPENCODE_PROVIDER_ID]); -} - -function buildMcodeContribution(ctx: ExportContext): ManagedContribution { - const doc = buildMcodeClientConfig(ctx); - return singleFragment("mcode", ["custom_provider", OPENCODE_PROVIDER_ID], doc.custom_provider[OPENCODE_PROVIDER_ID]); -} - -function buildZcodeContribution(ctx: ExportContext): ManagedContribution { - const doc = buildZcodeClientConfig(ctx); - return singleFragment("zcode", ["provider", OPENCODE_PROVIDER_ID], doc.provider[OPENCODE_PROVIDER_ID]); -} - /** * Prime Agent (PrimeIntellect) is the pi coding agent shipped under a different * brand rather than a lookalike: its package declares a `piConfig` block, and diff --git a/src/clients/config-export/constants.ts b/src/clients/config-export/constants.ts new file mode 100644 index 0000000000..a37f87f0d5 --- /dev/null +++ b/src/clients/config-export/constants.ts @@ -0,0 +1,69 @@ +// Shared client export constants. +import type { OcxConfig } from "../../types"; + + +/** Provider key owned by this project; the only key any exporter ever emits. */ +export const OPENCODE_PROVIDER_ID = "opencodex"; + +export const OPENCODE_CONFIG_SCHEMA = "https://opencode.ai/config.json"; + +/** + * Env var carrying the proxy admission key to opencode. The config only ever holds the + * `{env:...}` reference, so the secret never lands on disk. opencode substitutes it at + * load time. + */ +export const OPENCODE_API_KEY_ENV = "OPENCODEX_OPENCODE_API_KEY"; + +/** Env reference shared by apiKey and the dedicated proxy admission header. */ +export const OPENCODE_API_KEY_ENV_REF = `{env:${OPENCODE_API_KEY_ENV}}`; + +/** + * Hermes interpolates `${VAR}` anywhere in config.yaml, so the credential stays + * in the environment exactly as it does for OpenCode. + */ +export const HERMES_API_KEY_ENV = "OPENCODEX_HERMES_API_KEY"; +export const HERMES_API_KEY_ENV_REF = `\${${HERMES_API_KEY_ENV}}`; + +/** OpenClaw interpolates `${UPPERCASE_VAR}` and fails closed when it is unset. */ +export const OPENCLAW_API_KEY_ENV = "OPENCODEX_OPENCLAW_API_KEY"; +export const OPENCLAW_API_KEY_ENV_REF = `\${${OPENCLAW_API_KEY_ENV}}`; + +/** + * Placeholder credential for loopback-only clients (Kimi, Pi). A loopback + * bind needs no real admission key, so we emit the same placeholder the Grok + * managed block uses rather than a user secret. Pi resolves `apiKey` before + * building its model list and hides the provider when an env reference is unset. + */ +export const LOOPBACK_API_KEY_PLACEHOLDER = "opencodex-loopback"; + +/** + * Gajae's `apiKeyEnv` is env-name-only and fail-closed. Its sibling `apiKey` + * falls back to treating the literal text as the token when the variable is + * unset, which would silently ship a bogus credential — so we never emit it. + */ +export const GAJAE_API_KEY_ENV = "OPENCODEX_GAJAE_API_KEY"; + +/** Pi's wire-dialect selector for an OpenAI-compatible endpoint. */ +export const PI_API_DIALECT = "openai-completions"; + +/** + * opencode's config schema rejects a `limit` block that carries `context` without + * `output`, but CatalogModel has no authoritative per-model output field. Dropping + * `limit` entirely would also throw away the authoritative context window we DO have, + * so the block is emitted with this budget standing in for the missing half. + * + * The value matches REASONING_MAX_TOKENS_CEILING in src/adapters/anthropic.ts — the + * project's existing "safe ceiling across current models" figure. It is a ceiling for + * schema validity, NOT a claim about any specific model's true maximum, and it is + * clamped to the context window so a small-context model can never be emitted with + * output > context. Pi's `maxTokens` uses the same stand-in and the same clamp. + */ +export const SCHEMA_REQUIRED_OUTPUT_BUDGET = 32_000; + +/** Deterministic loopback default for exported provider-block helpers in tests. */ +export const OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG: OcxConfig = { + port: 10100, + hostname: "127.0.0.1", + defaultProvider: "mock", + providers: { mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1/v1" } }, +} as OcxConfig; diff --git a/src/clients/config-export/contracts.ts b/src/clients/config-export/contracts.ts new file mode 100644 index 0000000000..3edd91eb5e --- /dev/null +++ b/src/clients/config-export/contracts.ts @@ -0,0 +1,150 @@ +// Shared client export contracts. +import type { OcxConfig } from "../../types"; +import type { ConfigFormat } from "../../integrations/serialize"; + +/** + * One entry opencodex owns inside a client's config: the JSON path to it and + * the value we put there. + * + * A path list rather than a single provider key because ownership is not + * always one entry — Kimi owns its provider block AND one model entry per + * model, and a writer that only knew about the provider would strand the rest + * (devlog 260802 006 §2). + */ +export interface ManagedFragment { + path: readonly string[]; + value: unknown; +} + +/** Everything opencodex contributes to one client's config, as one unit. */ +export interface ManagedContribution { + clientId: ExportClientId; + fragments: readonly ManagedFragment[]; +} + +export type BuildContribution = (ctx: ExportContext) => ManagedContribution; + +export interface OpencodeLaunchEnv { + [key: string]: string | undefined; +} + +/** Visible catalog entry keyed by the proxy's canonical namespaced selector. */ +export interface OpencodeCatalogModel { + namespaced: string; + native?: boolean; + provider?: string; + id?: string; + contextWindow?: number; + displayName?: string; + /** Declared effort ladder. Exported as opencode model variants where the client reads them. */ + reasoningEfforts?: readonly string[]; + /** + * Declared default effort. Carried so every client export reads one deduped, visibility- + * filtered ladder per model. The opencode serializer deliberately does NOT turn it into a + * model-level setting — see {@link opencodeEffortVariants} for why. + */ + defaultReasoningEffort?: string; +} + +/** + * One proxy-routed model destined for a client config. Deliberately narrower than + * `CatalogModel` so a serializer cannot reach for a field that does not survive the + * `/api/models` boundary. + */ +export interface ExportModel { + /** Canonical proxy selector: `provider/id`, or bare slug for native. */ + namespaced: string; + provider: string; + id: string; + /** Native OpenAI entry. Read by the shared label rule. */ + native?: boolean; + displayName?: string; + contextWindow?: number; + inputModalities?: string[]; + /** Optional effort ladder exported only to clients that support it. */ + reasoningEfforts?: string[]; + defaultReasoningEffort?: string; +} + +export interface ExportContext { + /** `http://host:port/v1` — the OpenAI-compatible surface the client dials. */ + baseUrl: string; + models: readonly ExportModel[]; + /** + * Live proxy config. Only the OpenCode path reads it: a non-loopback bind moves + * admission from `apiKey` to the `x-opencodex-api-key` header. + */ + config?: OcxConfig; +} + +export type ExportClientId = + | "opencode" + | "pi" + | "omp" + | "hermes" + | "openclaw" + | "kimi" + | "gajae" + | "dsh" + | "mcode" + | "zcode" + | "prime" + | "aside"; + +export interface ExportClientSpec { + id: ExportClientId; + /** Download filename; matches the destination file's own name (003 §5). */ + filename: string; + /** Canonical destination for humans. Never written to. */ + destination: (env: NodeJS.ProcessEnv) => string; + /** Env var the config references; the value is never serialized. */ + apiKeyEnv: string; + /** Shell line the user runs before launching the client. */ + exportHint: string; + build: (ctx: ExportContext) => unknown; + /** + * Text format of the client's config file. `filename` already carries the + * extension; this drives serialization and the download media type so no + * consumer has to infer either from the name. + */ + format: ConfigFormat; + /** + * Count models in THIS client's document shape. Required so a new client + * cannot be added without teaching the summarizer about it — the old + * "anything that is not OpenCode must be Pi" branch was a latent bug. + */ + summarize: (document: unknown) => { modelCount: number; modelsWithoutLimits: number }; + /** + * The fragments opencodex owns inside this client's config. Only the builder + * knows where a client keeps our entries, so ownership paths originate here + * rather than being re-derived by the writer. + */ + buildContribution: BuildContribution; + /** + * True when the generated integration deliberately supports loopback only. + * + * `/v1/chat/completions` rejects bearer credentials and requires the + * dedicated `x-opencodex-api-key` header (AUTH_MATRIX in + * src/server/auth-cors.ts). If this exporter cannot safely emit that header, + * it refuses a remote bind rather than generating a config that 401s. Same + * reasoning as the Grok managed block's non-loopback refusal. + */ + loopbackOnly: boolean; +} + +export interface PiModelEntry { + id: string; + name: string; + input: string[]; + contextWindow?: number; + maxTokens?: number; + /** Advertised when the catalog row carries a non-empty effort ladder. */ + reasoning?: true; + /** + * Constrains pi's own level scale (minimal..max) to the declared ladder: members map to + * themselves, everything else is hidden (`null`). Without it pi would offer levels the + * ladder does not contain — harmless for provider-config ladders (the proxy clamps those + * at the wire) but a real 400 risk for custom-row ladders, which are advertisement-only. + */ + thinkingLevelMap?: Record; +} diff --git a/src/clients/config-export/dsh.ts b/src/clients/config-export/dsh.ts new file mode 100644 index 0000000000..e140d543ef --- /dev/null +++ b/src/clients/config-export/dsh.ts @@ -0,0 +1,132 @@ +// DSH config export. +import type { ExportModel, ExportContext, ManagedContribution } from "./contracts"; +import type { OcxConfig } from "../../types"; +import { providerCodexAccountMode } from "../../providers/registry"; +import { normalizeExportModels, authoritativeContextWindow, exportModelLabel, singleFragment } from "./model-metadata"; +import { OPENCODE_PROVIDER_ID } from "./constants"; + + +/** DSH rc.6 accepts text/image; unknown values degrade to text, while audio-only cannot be represented. */ +function dshInputModalities(modalities: readonly string[] | undefined): string[] | null { + const declared = modalities ?? []; + if (declared.length === 0) return ["text"]; + const kept: string[] = []; + for (const value of declared) { + if ((value === "text" || value === "image") && !kept.includes(value)) kept.push(value); + } + if (kept.length > 0) return kept; + return declared.every(value => value === "audio") ? null : ["text"]; +} + +export type DshReasoningEffort = "low" | "medium" | "high" | "xhigh" | "max"; +export type DshWireReasoningEffort = DshReasoningEffort | "ultra"; + +export interface DshModelEntry { + id: string; + name: string; + input: string[]; + contextWindow?: number; + reasoningEfforts?: Partial>; +} + +export interface DshProviderBlock { + displayName: "OpenCodex"; + api: "openai-responses"; + baseURL: string; + headers: { Authorization: "Bearer ocx_data_dsh" }; + models: DshModelEntry[]; +} + +export interface DshGeneratedConfig { + "llm-pi-ai": { + providers: Record; + }; +} + +const DSH_EFFORT_ORDER: readonly DshReasoningEffort[] = ["low", "medium", "high", "xhigh", "max"]; + +function dshReasoningEfforts(model: ExportModel): DshModelEntry["reasoningEfforts"] { + const offered = new Set(); + for (const raw of model.reasoningEfforts ?? []) { + const effort = raw.trim().toLowerCase(); + if (effort === "ultra" || DSH_EFFORT_ORDER.includes(effort as DshReasoningEffort)) offered.add(effort); + } + if (offered.size === 0) return undefined; + const entries: Array<[DshReasoningEffort, DshWireReasoningEffort]> = []; + for (const effort of DSH_EFFORT_ORDER) { + if (effort !== "max") { + if (offered.has(effort)) entries.push([effort, effort]); + continue; + } + // DSH's key is the selectable level; the value is what it sends on the + // wire. Preserve OpenCodex's `ultra` spelling when that is the only + // highest effort, exactly like the rc.6 `max: ultra` contract. + if (offered.has("max")) entries.push(["max", "max"]); + else if (offered.has("ultra")) entries.push(["max", "ultra"]); + } + return Object.fromEntries(entries); +} + +function isKnownSafeDshCombo(model: ExportModel, config: OcxConfig): boolean { + const combos = (config as { combos?: unknown }).combos; + if (typeof combos !== "object" || combos === null || Array.isArray(combos)) return false; + const combo = (combos as Record)[model.id]; + if (typeof combo !== "object" || combo === null || Array.isArray(combo)) return false; + const targets = (combo as { targets?: unknown }).targets; + if (!Array.isArray(targets) || targets.length === 0) return false; + return targets.every(target => { + if (typeof target !== "object" || target === null || Array.isArray(target)) return false; + const provider = (target as { provider?: unknown }).provider; + const modelId = (target as { model?: unknown }).model; + return typeof provider === "string" + && provider.length > 0 + && provider === provider.trim() + && provider !== "openai" + && typeof modelId === "string" + && modelId.length > 0 + && modelId === modelId.trim(); + }); +} + +export function buildDshClientConfig(ctx: ExportContext): DshGeneratedConfig { + const direct = providerCodexAccountMode("openai", ctx.config?.providers?.openai) === "direct"; + const models: DshModelEntry[] = []; + for (const model of normalizeExportModels(ctx.models)) { + if (direct && (model.native === true || model.provider === "openai")) continue; + if (direct && model.provider === "combo" && (!ctx.config || !isKnownSafeDshCombo(model, ctx.config))) continue; + const input = dshInputModalities(model.inputModalities); + if (input === null) continue; + const contextWindow = authoritativeContextWindow(model.contextWindow); + const reasoningEfforts = dshReasoningEfforts(model); + models.push({ + id: model.namespaced, + name: exportModelLabel(model), + input, + ...(contextWindow !== undefined ? { contextWindow } : {}), + ...(reasoningEfforts ? { reasoningEfforts } : {}), + }); + } + return { + "llm-pi-ai": { + providers: { + [OPENCODE_PROVIDER_ID]: { + displayName: "OpenCodex", + api: "openai-responses", + baseURL: ctx.baseUrl, + headers: { Authorization: "Bearer ocx_data_dsh" }, + models, + }, + }, + }, + }; +} + +export function summarizeDsh(document: unknown): { modelCount: number; modelsWithoutLimits: number } { + const models = (document as DshGeneratedConfig | undefined)?.["llm-pi-ai"]?.providers?.[OPENCODE_PROVIDER_ID]?.models ?? []; + return { modelCount: models.length, modelsWithoutLimits: models.filter(model => model.contextWindow === undefined).length }; +} + +export function buildDshContribution(ctx: ExportContext): ManagedContribution { + const doc = buildDshClientConfig(ctx); + return singleFragment("dsh", ["llm-pi-ai", "providers", OPENCODE_PROVIDER_ID], doc["llm-pi-ai"].providers[OPENCODE_PROVIDER_ID]); +} diff --git a/src/clients/config-export/mcode.ts b/src/clients/config-export/mcode.ts new file mode 100644 index 0000000000..7cd004982d --- /dev/null +++ b/src/clients/config-export/mcode.ts @@ -0,0 +1,83 @@ +// MiniMax Code config export. +import type { ExportContext, ManagedContribution } from "./contracts"; +import { normalizeExportModels, authoritativeContextWindow, singleFragment } from "./model-metadata"; +import { sanitizeCodexReasoningEfforts } from "../../reasoning-effort"; +import { OPENCODE_PROVIDER_ID, LOOPBACK_API_KEY_PLACEHOLDER } from "./constants"; + + +export interface McodeProviderBlock { + name: "OpenCodex"; + kind: "custom"; + enabled: true; + api: "anthropic-messages"; + options: { + apiKey: string; + baseURL: string; + authMode: "api-key"; + }; + models: Record; +} + +export interface McodeModelEntry { + /** MCode uses this value for context accounting and compaction. */ + limit?: { context: number }; + /** MCode exposes these exact levels in `/model` and sends the selected effort. */ + thinking?: { effortOptions: string[] }; +} + +export interface McodeGeneratedConfig { + custom_provider: Record; +} + +/** + * MiniMax Code's `provider add` command persists custom providers under + * `custom_provider.`. Its current model schema reads `limit.context` for + * context accounting and `thinking.effortOptions` for the `/model` effort + * control. Do not emit the removed `thinking.effort` / `defaultEffort` fields: + * MCode 0.1.6 migrates those into options and keeps the selected effort in the + * session. Do not emit `defaultModel` either: connecting a client must not + * silently replace the user's current model selection. + */ +export function buildMcodeClientConfig(ctx: ExportContext): McodeGeneratedConfig { + const models: Record = {}; + for (const model of normalizeExportModels(ctx.models)) { + const entry: McodeModelEntry = {}; + const context = authoritativeContextWindow(model.contextWindow); + if (context !== undefined) entry.limit = { context }; + // `none` is an internal Codex catalog sentinel, not an MCode effort. MCode + // forwards every option as `output_config.effort` while keeping adaptive + // thinking enabled, and the Anthropic ingress deliberately accepts only + // minimal..ultra. Advertising `none` would therefore create a selectable + // value that cannot disable reasoning and is not forwarded as an effort. + const efforts = sanitizeCodexReasoningEfforts(model.reasoningEfforts) + ?.filter(effort => effort !== "none"); + if (efforts && efforts.length > 0) entry.thinking = { effortOptions: efforts }; + models[model.namespaced] = entry; + } + return { + custom_provider: { + [OPENCODE_PROVIDER_ID]: { + name: "OpenCodex", + kind: "custom", + enabled: true, + api: "anthropic-messages", + options: { + apiKey: LOOPBACK_API_KEY_PLACEHOLDER, + baseURL: ctx.baseUrl.replace(/\/v1\/?$/, ""), + authMode: "api-key", + }, + models, + }, + }, + }; +} + +export function summarizeMcode(document: unknown): { modelCount: number; modelsWithoutLimits: number } { + const models = Object.values((document as McodeGeneratedConfig | undefined)?.custom_provider?.[OPENCODE_PROVIDER_ID]?.models ?? {}); + return { modelCount: models.length, modelsWithoutLimits: models.filter(model => !model.limit).length }; +} + +export function buildMcodeContribution(ctx: ExportContext): ManagedContribution { + const doc = buildMcodeClientConfig(ctx); + return singleFragment("mcode", ["custom_provider", OPENCODE_PROVIDER_ID], doc.custom_provider[OPENCODE_PROVIDER_ID]); +} diff --git a/src/clients/config-export/model-metadata.ts b/src/clients/config-export/model-metadata.ts new file mode 100644 index 0000000000..4f3038efac --- /dev/null +++ b/src/clients/config-export/model-metadata.ts @@ -0,0 +1,113 @@ +// Shared client export model metadata. +import { SCHEMA_REQUIRED_OUTPUT_BUDGET } from "./constants"; +import type { OpencodeCatalogModel, ExportModel, ExportClientId, ManagedContribution } from "./contracts"; +import type { OcxConfig } from "../../types"; +import { shouldInjectApiAuthHeader } from "../../codex/inject"; + + +/** + * Authoritative context window, or undefined. Never guesses: a missing, non-finite, or + * non-positive value means the serializer omits every context-derived field. + */ +export function authoritativeContextWindow(contextWindow: number | undefined): number | undefined { + if (typeof contextWindow === "number" && Number.isFinite(contextWindow) && contextWindow > 0) { + const integer = Math.floor(contextWindow); + return integer > 0 ? integer : undefined; + } + return undefined; +} + +/** Schema-required output budget for a known context window. */ +export function outputBudgetFor(context: number): number { + return Math.min(SCHEMA_REQUIRED_OUTPUT_BUDGET, context); +} + +/** + * Modalities a given client's schema will actually accept. + * + * Our internal vocabulary is `text | image | audio` (ALLOWED_INPUT_MODALITIES in + * src/server/management/model-routes.ts). Pi and Gajae accept only + * `text | image`, and both reject the WHOLE config file over one out-of-enum + * value — Gajae reports `/providers/opencodex/models/N/input/2: Invalid option` + * and falls back to its built-in list, Pi returns an empty model config. So a + * single `audio` model takes every routed model down with it. That is not + * hypothetical: zenmux/meta-muse-spark-1.1 advertises audio and did exactly + * this. It is also the same defect the Codex catalog had with `video`, where + * the app showed zero apps (tests/codex-integration/catalog-input-modality-enum.test.ts). + * + * UNKNOWN and INCOMPATIBLE are different inputs, and the Codex fix could + * conflate them safely only because its enum is wider. A model with nothing + * declared is unknown, and `text` is the honest floor — every routed model takes + * prompts. A model declaring `["audio"]` and nothing else is incompatible with a + * text|image client, and rewriting it to `["text"]` would advertise a capability + * it does not have. That input is reachable three ways: `ocx models add + * --modalities audio`, `/api/custom-models`, and provider discovery. + * + * So unknown falls back to text and incompatible returns null, which drops the + * row. Omitting a model costs the user a line in a picker; fabricating `text` + * costs them a model that fails at call time with no explanation. + * + * Deliberately NOT applied in `ExportModel` construction: the management and CLI + * boundaries carry catalog modalities verbatim on purpose, and stripping `audio` + * globally would destroy valid metadata before the destination is known. + */ +const CLIENT_INPUT_MODALITIES: Record<"pi" | "gajae", ReadonlySet> = { + pi: new Set(["text", "image"]), + gajae: new Set(["text", "image"]), +}; + +/** `null` means the model cannot be represented for this client — drop the row. */ +export function inputModalitiesForClient( + client: "pi" | "gajae", + modalities: readonly string[] | undefined, +): string[] | null { + const declared = modalities ?? []; + if (declared.length === 0) return ["text"]; + const accepted = CLIENT_INPUT_MODALITIES[client]; + const kept: string[] = []; + for (const value of declared) { + if (accepted.has(value) && !kept.includes(value)) kept.push(value); + } + return kept.length > 0 ? kept : null; +} + +/** + * Label shared by every client: `" ()"`. The + * provider suffix is what makes two same-named models from different upstreams + * distinguishable in a client's model picker. + */ +export function exportModelLabel(model: OpencodeCatalogModel): string { + const providerLabel = model.native ? "native" : (model.provider ?? "routed"); + const id = model.id ?? model.namespaced; + if (model.displayName && model.displayName.length > 0) { + return `${model.displayName} (${providerLabel})`; + } + return `${id} (${providerLabel})`; +} + +/** + * Shared precondition for every serializer: drop duplicate `namespaced` (first wins, + * native rows lead `/api/models`) and sort by `namespaced` so two calls with the same + * models produce identical bytes. Stability matters because the GUI shows a diffable + * preview and agents may checksum the payload. + */ +export function normalizeExportModels(models: readonly ExportModel[]): ExportModel[] { + const seen = new Set(); + const unique: ExportModel[] = []; + for (const model of models) { + if (seen.has(model.namespaced)) continue; + seen.add(model.namespaced); + unique.push(model); + } + return unique.sort((a, b) => (a.namespaced < b.namespaced ? -1 : a.namespaced > b.namespaced ? 1 : 0)); +} + +/** Extra headers a non-loopback bind needs, or nothing on loopback. */ +export function proxyAdmissionHeaders(config: OcxConfig | undefined, envRef: string): Record | undefined { + return shouldInjectApiAuthHeader(config) ? { "x-opencodex-api-key": envRef } : undefined; +} + +/** One fragment at `path`, built from this client's own document. */ +export function singleFragment(clientId: ExportClientId, path: readonly string[], value: unknown): ManagedContribution { + return { clientId, fragments: [{ path, value }] }; +} diff --git a/src/clients/config-export/omp.ts b/src/clients/config-export/omp.ts new file mode 100644 index 0000000000..e31d9bc59f --- /dev/null +++ b/src/clients/config-export/omp.ts @@ -0,0 +1,104 @@ +// Oh My Pi config export. +import type { PiModelEntry, ExportModel, ExportContext, ManagedContribution } from "./contracts"; +import { PI_API_DIALECT, OPENCODE_PROVIDER_ID, LOOPBACK_API_KEY_PLACEHOLDER } from "./constants"; +import { normalizeExportModels, inputModalitiesForClient, exportModelLabel, authoritativeContextWindow, outputBudgetFor, singleFragment } from "./model-metadata"; + + +/** + * omp accepts a model-level API override. Keep the provider on Chat + * Completions so routed providers retain their established wire format, while + * native OpenAI models can use the lossless Responses surface. + */ +export interface OmpModelEntry extends PiModelEntry { + api?: "openai-responses"; + /** omp requires this flag before it honors a thinking block. */ + reasoning?: true; + thinking?: { + mode: "effort"; + efforts: string[]; + defaultLevel?: string; + }; +} + +export interface OmpProviderBlock { + baseUrl: string; + api: typeof PI_API_DIALECT; + apiKey: string; + models: OmpModelEntry[]; +} + +export interface OmpGeneratedConfig { + providers: Record; +} + +/** + * omp validates model entries strictly. These are its documented effort + * values; omit an unknown value rather than invalidating the whole provider. + */ +const OMP_EFFORT_VOCABULARY = new Set(["minimal", "low", "medium", "high", "xhigh", "max"]); + +function ompEfforts(model: ExportModel): string[] { + const efforts: string[] = []; + for (const effort of model.reasoningEfforts ?? []) { + const normalized = effort.trim().toLowerCase(); + if (OMP_EFFORT_VOCABULARY.has(normalized) && !efforts.includes(normalized)) { + efforts.push(normalized); + } + } + return efforts; +} + +/** + * omp's models.yml is Pi-like, but it supports effort metadata and a per-model + * API dialect. Native OpenAI models use Responses; all routed models inherit + * the provider's existing Chat Completions dialect. + */ +export function buildOmpClientConfig(ctx: ExportContext): OmpGeneratedConfig { + const models: OmpModelEntry[] = []; + for (const model of normalizeExportModels(ctx.models)) { + const input = inputModalitiesForClient("pi", model.inputModalities); + if (input === null) continue; + const entry: OmpModelEntry = { + id: model.namespaced, + name: exportModelLabel(model), + input, + ...(model.native && model.provider === "openai" ? { api: "openai-responses" } : {}), + }; + const context = authoritativeContextWindow(model.contextWindow); + if (context !== undefined) { + entry.contextWindow = context; + entry.maxTokens = outputBudgetFor(context); + } + const efforts = ompEfforts(model); + if (efforts.length > 0) { + const defaultLevel = model.defaultReasoningEffort?.trim().toLowerCase(); + entry.reasoning = true; + entry.thinking = { + mode: "effort", + efforts, + ...(defaultLevel && efforts.includes(defaultLevel) ? { defaultLevel } : {}), + }; + } + models.push(entry); + } + return { + providers: { + [OPENCODE_PROVIDER_ID]: { + baseUrl: ctx.baseUrl, + api: PI_API_DIALECT, + apiKey: LOOPBACK_API_KEY_PLACEHOLDER, + models, + }, + }, + }; +} + +export function summarizeOmp(document: unknown): { modelCount: number; modelsWithoutLimits: number } { + const models = (document as OmpGeneratedConfig | undefined)?.providers?.[OPENCODE_PROVIDER_ID]?.models ?? []; + return { modelCount: models.length, modelsWithoutLimits: models.filter(model => model.contextWindow === undefined).length }; +} + +export function buildOmpContribution(ctx: ExportContext): ManagedContribution { + const doc = buildOmpClientConfig(ctx); + return singleFragment("omp", ["providers", OPENCODE_PROVIDER_ID], doc.providers[OPENCODE_PROVIDER_ID]); +} diff --git a/src/clients/config-export/zcode.ts b/src/clients/config-export/zcode.ts new file mode 100644 index 0000000000..bcd522a389 --- /dev/null +++ b/src/clients/config-export/zcode.ts @@ -0,0 +1,92 @@ +// ZCode config export. +import type { ExportContext, ManagedContribution } from "./contracts"; +import { normalizeExportModels, inputModalitiesForClient, exportModelLabel, authoritativeContextWindow, singleFragment } from "./model-metadata"; +import { OPENCODE_PROVIDER_ID, LOOPBACK_API_KEY_PLACEHOLDER } from "./constants"; + + +/** + * ZCode's `~/.zcode/v2/config.json` provider entry (observed schema, validated + * live against ZCode 3.7.7 / 3.8.1). `kind: "openai-compatible"` selects the + * OpenAI Chat Completions protocol, which the proxy serves at `/v1/chat/completions`. + * `apiKeyRequired` keeps ZCode's UI from prompting for a key it does not need on + * loopback; the serialized key is always the non-secret loopback placeholder. + */ +export interface ZcodeModelEntry { + name?: string; + limit?: { context: number; output?: number }; + modalities: { input: string[]; output: string[] }; +} + +export interface ZcodeProviderBlock { + name: "OpenCodex"; + kind: "openai-compatible"; + enabled: true; + source: "custom"; + options: { + apiKey: string; + baseURL: string; + apiKeyRequired: true; + }; + models: Record; +} + +export interface ZcodeGeneratedConfig { + provider: Record; +} + +/** + * ZCode dials the OpenAI Chat Completions surface (`openai-compatible`), which + * appends `/chat/completions` to `baseURL`. We supply `baseURL` with the `/v1` + * suffix so requests land on `/v1/chat/completions`. Model ids are the proxy's canonical + * `provider/id` selectors, which `/v1/chat/completions` resolves directly. Context + * limits follow the authoritative-window rule: a model without one ships + * without `limit` rather than guessing. Modalities are ZCode's observed + * `text`-floor vocabulary; image-capable rows advertise image input. + */ +export function buildZcodeClientConfig(ctx: ExportContext): ZcodeGeneratedConfig { + const models: Record = {}; + for (const model of normalizeExportModels(ctx.models)) { + const input = inputModalitiesForClient("pi", model.inputModalities); + if (input === null) continue; + const entry: ZcodeModelEntry = { + name: exportModelLabel(model), + modalities: { input, output: ["text"] }, + }; + // `limit.context` follows the authoritative-window rule. `output` is + // deliberately absent: ZCode's schema makes it optional and we have no + // authoritative output budget to assert (reviewer finding: an emitted + // stand-in would be a guessed capability, exactly what "no metadata is + // guessed" forbids). + const context = authoritativeContextWindow(model.contextWindow); + if (context !== undefined) { + entry.limit = { context }; + } + models[model.namespaced] = entry; + } + return { + provider: { + [OPENCODE_PROVIDER_ID]: { + name: "OpenCodex", + kind: "openai-compatible", + enabled: true, + source: "custom", + options: { + apiKey: LOOPBACK_API_KEY_PLACEHOLDER, + baseURL: ctx.baseUrl.replace(/\/v1\/?$/, "") + "/v1", + apiKeyRequired: true, + }, + models, + }, + }, + }; +} + +export function summarizeZcode(document: unknown): { modelCount: number; modelsWithoutLimits: number } { + const models = Object.values((document as ZcodeGeneratedConfig | undefined)?.provider?.[OPENCODE_PROVIDER_ID]?.models ?? {}); + return { modelCount: models.length, modelsWithoutLimits: models.filter(model => !model.limit).length }; +} + +export function buildZcodeContribution(ctx: ExportContext): ManagedContribution { + const doc = buildZcodeClientConfig(ctx); + return singleFragment("zcode", ["provider", OPENCODE_PROVIDER_ID], doc.provider[OPENCODE_PROVIDER_ID]); +} diff --git a/tests/config/client-config-export.test.ts b/tests/config/client-config-export.test.ts index 19f1edc8da..ec969a8455 100644 --- a/tests/config/client-config-export.test.ts +++ b/tests/config/client-config-export.test.ts @@ -24,6 +24,13 @@ import { import { buildOpencodeProviderBlockFromCatalog, opencodeGlobalConfigPath } from "../../src/cli/opencode"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import * as facade from "../../src/clients/config-export"; +import { OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG as leafDefaultConfig } from "../../src/clients/config-export/constants"; +import { normalizeExportModels as leafNormalizeExportModels } from "../../src/clients/config-export/model-metadata"; +import * as omp from "../../src/clients/config-export/omp"; +import * as dsh from "../../src/clients/config-export/dsh"; +import * as mcode from "../../src/clients/config-export/mcode"; +import * as zcode from "../../src/clients/config-export/zcode"; /** * Fixture covering the four rows that exercise every emission branch: native, @@ -82,6 +89,51 @@ function dshConfig(context: ExportContext = ctx()): DshGeneratedConfig { return buildClientConfig("dsh", context) as DshGeneratedConfig; } +describe("split config-export public facade", () => { + test("keeps canonical singleton and registry function identities", () => { + expect(facade.OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG).toBe(leafDefaultConfig); + expect(facade.normalizeExportModels).toBe(leafNormalizeExportModels); + const leaves = [ + ["omp", omp.buildOmpClientConfig, omp.summarizeOmp, omp.buildOmpContribution], + ["dsh", dsh.buildDshClientConfig, dsh.summarizeDsh, dsh.buildDshContribution], + ["mcode", mcode.buildMcodeClientConfig, mcode.summarizeMcode, mcode.buildMcodeContribution], + ["zcode", zcode.buildZcodeClientConfig, zcode.summarizeZcode, zcode.buildZcodeContribution], + ] as const; + for (const [id, build, summarize, contribute] of leaves) { + expect(EXPORT_CLIENTS[id].build).toBe(build); + expect(EXPORT_CLIENTS[id].summarize).toBe(summarize); + expect(EXPORT_CLIENTS[id].buildContribution).toBe(contribute); + } + }); + + test("preserves each moved format's serialized fields, order and owned fragment", () => { + const context = ctx({ models: [{ + namespaced: "test/known", provider: "test", id: "known", contextWindow: 8192, + inputModalities: ["text", "image"], reasoningEfforts: ["none", "high"], + }] }); + const cases = [ + ["omp", ["providers", "opencodex"], '{"providers":{"opencodex":{"baseUrl":"http://127.0.0.1:10100/v1","api":"openai-completions","apiKey":"opencodex-loopback","models":[{"id":"test/known","name":"known (test)","input":["text","image"],"contextWindow":8192,"maxTokens":8192,"reasoning":true,"thinking":{"mode":"effort","efforts":["high"]}}]}}}'], + ["dsh", ["llm-pi-ai", "providers", "opencodex"], '{"llm-pi-ai":{"providers":{"opencodex":{"displayName":"OpenCodex","api":"openai-responses","baseURL":"http://127.0.0.1:10100/v1","headers":{"Authorization":"Bearer ocx_data_dsh"},"models":[{"id":"test/known","name":"known (test)","input":["text","image"],"contextWindow":8192,"reasoningEfforts":{"high":"high"}}]}}}}'], + ["mcode", ["custom_provider", "opencodex"], '{"custom_provider":{"opencodex":{"name":"OpenCodex","kind":"custom","enabled":true,"api":"anthropic-messages","options":{"apiKey":"opencodex-loopback","baseURL":"http://127.0.0.1:10100","authMode":"api-key"},"models":{"test/known":{"limit":{"context":8192},"thinking":{"effortOptions":["high"]}}}}}}'], + ["zcode", ["provider", "opencodex"], '{"provider":{"opencodex":{"name":"OpenCodex","kind":"openai-compatible","enabled":true,"source":"custom","options":{"apiKey":"opencodex-loopback","baseURL":"http://127.0.0.1:10100/v1","apiKeyRequired":true},"models":{"test/known":{"name":"known (test)","modalities":{"input":["text","image"],"output":["text"]},"limit":{"context":8192}}}}}}'], + ] as const; + for (const [id, path, expectedBytes] of cases) { + const built = buildClientConfigText(id, context); + expect(JSON.stringify(built.document)).toBe(expectedBytes); + expect(EXPORT_CLIENTS[id].summarize(built.document)).toEqual({ modelCount: 1, modelsWithoutLimits: 0 }); + const expectedDocument = JSON.parse(expectedBytes); + const expectedValue = path.reduce((value, key) => value[key], expectedDocument); + expect(facade.buildClientContribution(id, context)).toEqual({ + clientId: id, fragments: [{ path, value: expectedValue }], + }); + if (id === "zcode") { + expect(built.format).toBe("json"); + expect(built.text).toBe(JSON.stringify(expectedDocument, null, 2) + "\n"); + } + } + }); +}); + describe("relocated OpenCode serializer (accept criterion 1)", () => { test("the moved builder reproduces the pre-refactor golden byte-for-byte", () => { From fc9a5c8dc6f007072ee03898d2e816fd255cab43 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 5 Sep 2026 13:23:25 +0900 Subject: [PATCH 3/7] docs(clients): declare quota verification prerequisite for split layer --- .../003_parent_decisions.md | 2 ++ .../400_clients_config_export_a.md | 18 +++++++++++++++--- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/devlog/_plan/260905_now_split_train/003_parent_decisions.md b/devlog/_plan/260905_now_split_train/003_parent_decisions.md index 39782ad55a..b466fc44d1 100644 --- a/devlog/_plan/260905_now_split_train/003_parent_decisions.md +++ b/devlog/_plan/260905_now_split_train/003_parent_decisions.md @@ -154,6 +154,8 @@ measure. ## WORKTREE-EVIDENCE-01 — real implementation and receipt identity +WP400 replan exception: PR #3610 (`codex/win-7-postmerge-stability`) is an explicit verification prerequisite. WP400's PR targets that open head branch, pinned to `afdd38ff43c64696153372fc2e27a38aff208c73` for the replan. This is not a runtime dependency or a change to other stacks. Replay only WP400's own commits; do not modify, carry as our own, merge or enable auto-merge for #3610. See400 for the audited ancestry tradeoff and fresh-proof requirement. When the prerequisite lands, perform the normal scoped restack/retarget to dev and reverify affected heads. + The original dedicated-worktree execution choice conflicts with the FSM's checkout-local source identity. Operational audit by Wegener found no separate supported execution-root binding: `--cwd` selects both state and diff --git a/devlog/_plan/260905_now_split_train/400_clients_config_export_a.md b/devlog/_plan/260905_now_split_train/400_clients_config_export_a.md index 76236e349f..74c705efaa 100644 --- a/devlog/_plan/260905_now_split_train/400_clients_config_export_a.md +++ b/devlog/_plan/260905_now_split_train/400_clients_config_export_a.md @@ -392,19 +392,19 @@ Drafting verification is document-only: required heading order, complete symbol Title: `refactor(clients): extract low-fanout client formats and dependency foundations (split S13 L1/5)` -Branch: `codex/split-clients-config-export-a`. Base: `dev`. Closes: none. +Branch: `codex/split-clients-config-export-a`. Replanned base: `codex/win-7-postmerge-stability` (open prerequisite PR #3610; pinned `afdd38ff43c64696153372fc2e27a38aff208c73`). Closes: none. Use all sections of `.github/PULL_REQUEST_TEMPLATE.md` (Summary, Verification, Checklist), including the size-gate disposition and DEV-STACK-03 map below. This draft creates no PR; placeholder PR numbers are intentional. | # | PR | Layer | Branch | Base | Review focus | |---|---|---|---|---|---| -| 1 | #TBD-S13-L1 | 400 — this layer | `codex/split-clients-config-export-a` | `dev` | extract low-fanout client formats and dependency foundations | +| 1 | #3611 | 400 — this layer | `codex/split-clients-config-export-a` | `codex/win-7-postmerge-stability` (#3610) | extract low-fanout client formats and dependency foundations | | 2 | #TBD-S13-L2 | 410 | `codex/split-clients-config-export-b` | `codex/split-clients-config-export-a` | finish client path and format partitions | | 3 | #TBD-S13-L3 | 420 | `codex/split-cli-opencode` | `codex/split-clients-config-export-b` | separate OpenCode config and catalog from launch | | 4 | #TBD-S13-L4 | 430 | `codex/split-cli-minimax` | `codex/split-cli-opencode` | isolate MMX protocol and termination owners | | 5 | #TBD-S13-L5 | 440 | `codex/split-integrations-state` | `codex/split-clients-config-export-b` | separate classification from state reads | -Bottom layer; no parent PR. Review this layer's diff only. This layer tracks `dev` directly and has no parent-layer cascade; re-verify its tip/base ref after a base update while preserving checkout ownership. Bottom-up merging remains a separate user-authorized action and is out of scope. +Bottom S13 layer, with an explicit external verification prerequisite #3610. Review this layer's diff only. No S13 child has been published yet. After a base change, re-verify the layer tip and parent-relative diff; after the prerequisite lands, restack/retarget to dev. Merging remains out of scope. ## P stale-check (2026-09-05, wp400) @@ -412,6 +412,18 @@ Historical stale check at origin/dev 3191fe1aa: config-export.ts unchanged since ## A audit synthesis (2026-09-05, wp400) +### C→P replan on user-requested continuation + +The previous C result remains failed, not completed. The safe public contract split is preserved at244663568. Full remote checks failed on the four baseline quota/route tests; current GitHub CI additionally reports a quota-window fixture mismatch, under separate read-only RCA. These results cannot certify a new head. + +Decision: use #3610 as an explicit verification prerequisite while keeping its fixes and this module split in separate PRs. Pin `afdd38ff43c64696153372fc2e27a38aff208c73`, not a moving ref. Read-only fetch and `git diff 850afb2e9 -- src/clients/config-export.ts` show the source being split is byte-identical. + +Ancestry disposition: the parent's merge base with850 is593978db0. It lacks3191fe1aa,45045623b,f8ba644f3,850afb2e9, including changes across eight catalog/provider/router source files. Main explicitly accepts this older verification foundation for this dependent draft PR; no claim is made that it is equivalent to850. Those commits are not replayed into our parent-relative diff. Original850-based results remain historical, and neither their graph proof nor runtime results substitute for the new basis. Audit required imports and the entire reachable candidate graph against the pinned parent before B. + +Build action: in the same a2c0 worktree, rebase only this branch's own commits after850 onto the pinned parent, preserving original244663568 in git history/references. No other branch/worktree is rebased, reset, overwritten or merged. Inspect the resulting parent-relative diff for exactly the approved split/test/document paths. Publish with an exact-old-head force-with-lease, keeping #3611 draft and retargeting only it to the open prerequisite branch. No S13 upper branches exist to cascade yet. + +Check action: independently review the resulting interdiff/base, then run the reviewed isolated remote verifier with the new40-character head. Require fresh typecheck, focused tests, privacy, full-suite receipt and exact-head CI. Restore failures by diagnosis, never by skips or reduced assertions. The earlier mutation proof may be cited only if the mutated source and relevant test blobs remain byte-identical; otherwise repeat it remotely. After prerequisite landing, the normal restack/retarget and exact-head checks still apply. + Execution basis is now pinned to `850afb2e9f84979c87e914b248de482f44b34cd6`. Hooke rechecked the eight-source-file delta from `3191fe1aa`: config-export.ts and its required declarations are unchanged, and traversal including inline/type/re-export edges found no return cycle. Final verdict: PASS. The complete preserved roadmap is at immutable commit `dc44b08cafbbd45da81f940f1e8c00a9e5f61ce1` on `codex/260905-modular-debt-ledger-docs`; use `git show :devlog/_plan/260905_now_split_train/` for roadmap documents not carried in this layer's PR. The current a2c0 branch is `codex/split-clients-config-export-a`, created in place from that pinned basis; no managed worktree or session-state relocation occurred. Remote preflight found `/usr/local/bin/bun`, the expected origin URL and a clean shared seed; it did not run tests or switch the seed checkout. Hooke (`01a06f9f-f57f-7fc3-9261-b07f291929be`, requested gpt-6-astra high) returned GO-WITH-FIXES with zero blockers, then PASS after the two documentation corrections above. The read-only audit matched all 153 inventory ranges, assigned all 63 moved declarations uniquely, checked seven leaf and seven facade import lists, and preserved 96 public exports (47 types, 49 values). Its dependency traversal reported no return path from the external owners to the facade at base `3191fe1aa`. These are plan-audit results, not implementation or test results. From 607b12da69f4e26fbd87e31f8a7eb0895f87cfe1 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 5 Sep 2026 13:25:06 +0900 Subject: [PATCH 4/7] docs(clients): label superseded verification basis as historical --- .../_plan/260905_now_split_train/400_clients_config_export_a.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devlog/_plan/260905_now_split_train/400_clients_config_export_a.md b/devlog/_plan/260905_now_split_train/400_clients_config_export_a.md index 74c705efaa..cf7decefe8 100644 --- a/devlog/_plan/260905_now_split_train/400_clients_config_export_a.md +++ b/devlog/_plan/260905_now_split_train/400_clients_config_export_a.md @@ -424,7 +424,7 @@ Build action: in the same a2c0 worktree, rebase only this branch's own commits a Check action: independently review the resulting interdiff/base, then run the reviewed isolated remote verifier with the new40-character head. Require fresh typecheck, focused tests, privacy, full-suite receipt and exact-head CI. Restore failures by diagnosis, never by skips or reduced assertions. The earlier mutation proof may be cited only if the mutated source and relevant test blobs remain byte-identical; otherwise repeat it remotely. After prerequisite landing, the normal restack/retarget and exact-head checks still apply. -Execution basis is now pinned to `850afb2e9f84979c87e914b248de482f44b34cd6`. Hooke rechecked the eight-source-file delta from `3191fe1aa`: config-export.ts and its required declarations are unchanged, and traversal including inline/type/re-export edges found no return cycle. Final verdict: PASS. The complete preserved roadmap is at immutable commit `dc44b08cafbbd45da81f940f1e8c00a9e5f61ce1` on `codex/260905-modular-debt-ledger-docs`; use `git show :devlog/_plan/260905_now_split_train/` for roadmap documents not carried in this layer's PR. The current a2c0 branch is `codex/split-clients-config-export-a`, created in place from that pinned basis; no managed worktree or session-state relocation occurred. Remote preflight found `/usr/local/bin/bun`, the expected origin URL and a clean shared seed; it did not run tests or switch the seed checkout. +Historical pre-replan execution basis was pinned to `850afb2e9f84979c87e914b248de482f44b34cd6`. Hooke rechecked the eight-source-file delta from `3191fe1aa`: config-export.ts and its required declarations are unchanged, and traversal including inline/type/re-export edges found no return cycle. Final verdict: PASS. The complete preserved roadmap is at immutable commit `dc44b08cafbbd45da81f940f1e8c00a9e5f61ce1` on `codex/260905-modular-debt-ledger-docs`; use `git show :devlog/_plan/260905_now_split_train/` for roadmap documents not carried in this layer's PR. The current a2c0 branch is `codex/split-clients-config-export-a`, created in place from that pinned basis; no managed worktree or session-state relocation occurred. Remote preflight found `/usr/local/bin/bun`, the expected origin URL and a clean shared seed; it did not run tests or switch the seed checkout. Hooke (`01a06f9f-f57f-7fc3-9261-b07f291929be`, requested gpt-6-astra high) returned GO-WITH-FIXES with zero blockers, then PASS after the two documentation corrections above. The read-only audit matched all 153 inventory ranges, assigned all 63 moved declarations uniquely, checked seven leaf and seven facade import lists, and preserved 96 public exports (47 types, 49 values). Its dependency traversal reported no return path from the external owners to the facade at base `3191fe1aa`. These are plan-audit results, not implementation or test results. From e21d46f6635bad27fc6cb67f3664560930c51c8d Mon Sep 17 00:00:00 2001 From: t Date: Sat, 5 Sep 2026 13:28:39 +0900 Subject: [PATCH 5/7] docs(clients): record verified prerequisite and rebased review anchor --- .../260905_now_split_train/400_clients_config_export_a.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/devlog/_plan/260905_now_split_train/400_clients_config_export_a.md b/devlog/_plan/260905_now_split_train/400_clients_config_export_a.md index cf7decefe8..ff535ee51a 100644 --- a/devlog/_plan/260905_now_split_train/400_clients_config_export_a.md +++ b/devlog/_plan/260905_now_split_train/400_clients_config_export_a.md @@ -434,6 +434,14 @@ Operational audit by Wegener (`01a06fa6-5e3c-7840-8172-8587e853dcc7`, explicitly ## B implementation record (2026-09-05) +### Replanned stack checkpoint + +The scoped rebase completed at review anchor `7953e6d4e18b0e7c90c0c5cdb0a4256c22a25dd0`, with integration base `afdd38ff43c64696153372fc2e27a38aff208c73`. PR #3611 now targets the open `codex/win-7-postmerge-stability` branch (#3610). Only this branch was rebased/pushed; exact-old-head lease244663568 protected publication, and `--no-update-refs` preserved the old244 and audit67 snapshot branches. + +Independent reviewer Heisenberg confirmed identical blob IDs for all nine source/test paths versus244, exactly those nine paths plus three documents in the parent-relative diff, and an actual-tree traversal of4979edges/349facade-reachable files with no new return cycle or unresolved reachable import. Static verdict PASS; not a runtime-pass claim. This documentation checkpoint adds no source/test changes after that review anchor. + +The prerequisite itself was separately verified in remote `/tmp/ocx-wp400.T036h8/repo` at exactafdd38ff: typecheck0,440focusedpass/0fail, privacy0, full suite SUITE_EXIT=0 and final HEAD/clean-tree checks passed. Full output: `wp400-prerequisite-check.log` in the session evidence directory. No local suite ran. Fresh resulting-head verification and GitHub CI for #3611 are still required. + Franklin (`01a06fac-95ee-77a0-8916-f7546c2b8996`, explicitly gpt-6-astra high) implemented only the approved source/test paths in a2c0 and handed them back without Git mutations or local tests. Main inspected the diff and measured all leaves. Source owner search and the A inventory were reused; no new algorithm or parallel implementation was introduced. | File | Change and impact | Measured lines | From 412dcba4d617bd2c6c5961a1ada9484b859d700f Mon Sep 17 00:00:00 2001 From: t Date: Sat, 5 Sep 2026 13:43:26 +0900 Subject: [PATCH 6/7] docs(clients): restore dev base after verification prerequisite landed --- .../_plan/260905_now_split_train/003_parent_decisions.md | 2 ++ .../260905_now_split_train/400_clients_config_export_a.md | 8 ++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/devlog/_plan/260905_now_split_train/003_parent_decisions.md b/devlog/_plan/260905_now_split_train/003_parent_decisions.md index b466fc44d1..536103b30e 100644 --- a/devlog/_plan/260905_now_split_train/003_parent_decisions.md +++ b/devlog/_plan/260905_now_split_train/003_parent_decisions.md @@ -154,6 +154,8 @@ measure. ## WORKTREE-EVIDENCE-01 — real implementation and receipt identity +The WP400 external prerequisite has now landed. Its temporary #3610 parent exception is historical: #3611 targetsdev again, pinned tobe81013fab6d83ff630ca5f38e7881678a303871 for the resumed restack. All original verification requirements remain; user authorization now explicitly covers continuing scoped CI reruns and repair work without repeat permission questions. + WP400 replan exception: PR #3610 (`codex/win-7-postmerge-stability`) is an explicit verification prerequisite. WP400's PR targets that open head branch, pinned to `afdd38ff43c64696153372fc2e27a38aff208c73` for the replan. This is not a runtime dependency or a change to other stacks. Replay only WP400's own commits; do not modify, carry as our own, merge or enable auto-merge for #3610. See400 for the audited ancestry tradeoff and fresh-proof requirement. When the prerequisite lands, perform the normal scoped restack/retarget to dev and reverify affected heads. The original dedicated-worktree execution choice conflicts with the FSM's diff --git a/devlog/_plan/260905_now_split_train/400_clients_config_export_a.md b/devlog/_plan/260905_now_split_train/400_clients_config_export_a.md index ff535ee51a..1c799e036d 100644 --- a/devlog/_plan/260905_now_split_train/400_clients_config_export_a.md +++ b/devlog/_plan/260905_now_split_train/400_clients_config_export_a.md @@ -392,13 +392,13 @@ Drafting verification is document-only: required heading order, complete symbol Title: `refactor(clients): extract low-fanout client formats and dependency foundations (split S13 L1/5)` -Branch: `codex/split-clients-config-export-a`. Replanned base: `codex/win-7-postmerge-stability` (open prerequisite PR #3610; pinned `afdd38ff43c64696153372fc2e27a38aff208c73`). Closes: none. +Branch: `codex/split-clients-config-export-a`. Current replanned base: `dev`, pinned `be81013fab6d83ff630ca5f38e7881678a303871` after prerequisite #3610 landed. Closes: none. Use all sections of `.github/PULL_REQUEST_TEMPLATE.md` (Summary, Verification, Checklist), including the size-gate disposition and DEV-STACK-03 map below. This draft creates no PR; placeholder PR numbers are intentional. | # | PR | Layer | Branch | Base | Review focus | |---|---|---|---|---|---| -| 1 | #3611 | 400 — this layer | `codex/split-clients-config-export-a` | `codex/win-7-postmerge-stability` (#3610) | extract low-fanout client formats and dependency foundations | +| 1 | #3611 | 400 — this layer | `codex/split-clients-config-export-a` | `dev` | extract low-fanout client formats and dependency foundations | | 2 | #TBD-S13-L2 | 410 | `codex/split-clients-config-export-b` | `codex/split-clients-config-export-a` | finish client path and format partitions | | 3 | #TBD-S13-L3 | 420 | `codex/split-cli-opencode` | `codex/split-clients-config-export-b` | separate OpenCode config and catalog from launch | | 4 | #TBD-S13-L4 | 430 | `codex/split-cli-minimax` | `codex/split-cli-opencode` | isolate MMX protocol and termination owners | @@ -414,6 +414,10 @@ Historical stale check at origin/dev 3191fe1aa: config-export.ts unchanged since ### C→P replan on user-requested continuation +Latest replan: the user authorized continuing verification without further permission stops. #3610 was externally merged as5ab8aa9a2; #3611 auto-retargeted todev. Pin the fetched integration tip `be81013fab6d83ff630ca5f38e7881678a303871`, which containsafdd and the previously omitted dev changes, including #3622 quota-fixture reconciliation and #3623 failure diagnostics. The unsplit config-export source and original focused test remain byte-identical betweenafdd and this newbase. This supersedes the temporary older-foundation choice below. + +Replay only our commits afterafdd ontobe810 in the existing a2c0 worktree, with --no-update-refs and a preserved7d4 reference. Candidate graph/import audit must usebe810 dependencies plus the unchanged split overlay; after rebase, compare all nine source/test blobs and the parent-relative path set. Require new resulting-head remote receipt and CI. The previously cancelled CI retry was authorized and started, then cancelled by Main as obsolete once the merged-base change was confirmed. Do not treat that cancellation as another missing permission. Future scoped check reruns are authorized; no local suite or merge is part of this action. + The previous C result remains failed, not completed. The safe public contract split is preserved at244663568. Full remote checks failed on the four baseline quota/route tests; current GitHub CI additionally reports a quota-window fixture mismatch, under separate read-only RCA. These results cannot certify a new head. Decision: use #3610 as an explicit verification prerequisite while keeping its fixes and this module split in separate PRs. Pin `afdd38ff43c64696153372fc2e27a38aff208c73`, not a moving ref. Read-only fetch and `git diff 850afb2e9 -- src/clients/config-export.ts` show the source being split is byte-identical. From bbf8d3cd25ccf70eb595bc7982f63528d060c1bd Mon Sep 17 00:00:00 2001 From: t Date: Sat, 5 Sep 2026 14:22:15 +0900 Subject: [PATCH 7/7] docs(clients): reconcile stack depth and isolated verification instructions --- .../_plan/260905_now_split_train/000_plan.md | 9 +- .../003_parent_decisions.md | 33 ++++-- .../400_clients_config_export_a.md | 103 ++++++++++++------ 3 files changed, 96 insertions(+), 49 deletions(-) diff --git a/devlog/_plan/260905_now_split_train/000_plan.md b/devlog/_plan/260905_now_split_train/000_plan.md index ac3dbd96b2..180a85abb2 100644 --- a/devlog/_plan/260905_now_split_train/000_plan.md +++ b/devlog/_plan/260905_now_split_train/000_plan.md @@ -42,8 +42,11 @@ the closeout tallies both and only the first counts as resolved. the user for this loop; **merge never** (DEV-STACK-04 ESCALATE). Cascade with `git rebase --update-refs` + `--force-with-lease` when a lower layer changes (DEV-STACK-02). -- Open-stack depth cap 5: no stack in 002 exceeds 5 layers, so the cap is - satisfied by construction; 21 stacks run as parallel trains off `dev`. +- Open-stack depth cap: 5 dependent PRs. S04 contains six total layers, + including prerequisite layer 105, but STACK-INDEPENDENCE-01 replaced the initial + six-deep linear proposal: its longest current base chain is 3. Across the + 77-layer map, the longest planned chain is 4. The former S04 depth-six + exception is historical, not permission to create a six-deep stack now. - From WP400 onward, code and receipts use the existing a2c0 worktree in place (003 WORKTREE-EVIDENCE-01). Preserve each previous branch before selecting the next layer branch. Never relocate or recreate a2c0. @@ -53,7 +56,7 @@ the closeout tallies both and only the first counts as resolved. | WP | Deliverable | Depends on | Verifier | |---|---|---|---| | wp1 | 000–002 + every layer's decade doc (010…750) at diff level | — | docs checks (numbered only, every layer has a doc, every NOW file appears in exactly one stack); privacy scan | -| wp2… | one layer per work-phase, in 002 order within a stack; stacks are independent and may be interleaved (S01 L1, S02 L1, … then L2s) so that no open stack waits on another | its layer below | per-layer acceptance (003 §"Per-layer gate") | +| wp2… | one layer per work-phase, dependency-ordered by the base edges in 002; independent groups may be interleaved | its declared base layer, if any | the current decade document's Verification and Accept criteria sections | Total: 77 implementation layers across 21 stacks (002_layer_map.md; 105 and 625 appended per 003). diff --git a/devlog/_plan/260905_now_split_train/003_parent_decisions.md b/devlog/_plan/260905_now_split_train/003_parent_decisions.md index 536103b30e..20ea08717e 100644 --- a/devlog/_plan/260905_now_split_train/003_parent_decisions.md +++ b/devlog/_plan/260905_now_split_train/003_parent_decisions.md @@ -104,9 +104,11 @@ base `dev`, new bottom of S04)**: move `DesktopExecutorConfig` inline `import("../adapters/cursor/native-exec-desktop").DesktopExecutorConfig` at `src/types/provider.ts:701` to the contract file. Type-only, zero runtime effect; breaks the provider → desktop-implementation edge for good. 110's -base becomes `codex/split-cursor-desktop-executor-contract`; S04 depth -becomes 6, the one documented exception to the depth-5 heuristic (the layer -is a 15-line type move; it is not worth a separate stack). +base becomes `codex/split-cursor-desktop-executor-contract`. S04 has six +members including 105. The original linear proposal called that depth 6 and +made an exception; STACK-INDEPENDENCE-01 below superseded that topology. +Current planned parents are 105→dev, 110/120/130→105, 140→130, 150→110. +Thus S04's maximum dependent depth is 3, and the five-layer cap still applies. ## COMPANION-EDIT-01 — allowed edits outside the split file @@ -154,9 +156,18 @@ measure. ## WORKTREE-EVIDENCE-01 — real implementation and receipt identity -The WP400 external prerequisite has now landed. Its temporary #3610 parent exception is historical: #3611 targetsdev again, pinned tobe81013fab6d83ff630ca5f38e7881678a303871 for the resumed restack. All original verification requirements remain; user authorization now explicitly covers continuing scoped CI reruns and repair work without repeat permission questions. +Current WP400 authority: branch `codex/split-clients-config-export-a`, PR #3611, +base `dev` at `be81013fab6d83ff630ca5f38e7881678a303871`. This is the base +commit, not the tested layer head; the verifier derives the latter from the +clean current branch and matches it to the fetched remote branch. #3610 has +landed as `5ab8aa9a2d9d2a3926469f9d8c82387b43c6d0e9`; it is no longer an +open prerequisite or a retargeting destination. Scoped CI reruns and repair +work are authorized; no local suite or merge is requested. -WP400 replan exception: PR #3610 (`codex/win-7-postmerge-stability`) is an explicit verification prerequisite. WP400's PR targets that open head branch, pinned to `afdd38ff43c64696153372fc2e27a38aff208c73` for the replan. This is not a runtime dependency or a change to other stacks. Replay only WP400's own commits; do not modify, carry as our own, merge or enable auto-merge for #3610. See400 for the audited ancestry tradeoff and fresh-proof requirement. When the prerequisite lands, perform the normal scoped restack/retarget to dev and reverify affected heads. +Historical only: WP400 temporarily used #3610 at +`afdd38ff43c64696153372fc2e27a38aff208c73` to separate a verification fix +from the split. That older basis and its open-parent workflow are retired. +The historical evidence remains in400; do not execute it as the current plan. The original dedicated-worktree execution choice conflicts with the FSM's checkout-local source identity. Operational audit by Wegener found no @@ -182,9 +193,9 @@ code, including SSH transport failures. Failed or incomplete gates keep the layer unverified; do not synthesize a passing receipt. Retain temporary checkouts/evidence until scoped cleanup is authorized. -Invoke `cxc receipt test --cwd /Users/jun/.codex/worktrees/a2c0/opencodex ---session 01a06e97-b9d8-7250-8204-bb788338c288 -- bash ` -from C. The runner verifies local HEAD, remote HEAD, and branch/CI head agree; -no local Bun test command is allowed. This supersedes shared-checkout recipes -in 002 and older decade docs; each current plan must name the isolated runner -before execution. Remote availability and success require real evidence. +Use the complete isolated Bash recipe in400's Verification section from C. +It checks the clean local layer HEAD, fetched remote HEAD and final remote +state, while preserving output and failures inside the receipt command. +No local Bun test command is allowed. Older shared-checkout recipes must not +be reused; each current plan must supply its isolated verifier. Availability +and success require real execution evidence. diff --git a/devlog/_plan/260905_now_split_train/400_clients_config_export_a.md b/devlog/_plan/260905_now_split_train/400_clients_config_export_a.md index 1c799e036d..cec6014089 100644 --- a/devlog/_plan/260905_now_split_train/400_clients_config_export_a.md +++ b/devlog/_plan/260905_now_split_train/400_clients_config_export_a.md @@ -2,14 +2,14 @@ ## Loop spec -- Archetype: `pure-move`. Bounded delegated **docs-only C3** task; parent owns orchestration, loop and goal state. +- Archetype: `pure-move`, C3 implementation with explicit security regression review for the relocated admission/credential helpers. Main owns orchestration and goal state. - Goal: extract low-fanout client formats and dependency foundations, preserving the original public import path and behavior. - Non-goals: behavior fixes, exported renames, signature changes, new validation, changed credentials/admission policy, changed config paths, new framework, caller migration, merges or releases. Preserve function bodies verbatim, including >50-line functions; function redesign is not this pure-move train. -- Verifier: `002_layer_map.md` **Per-layer gate**, instantiated below; every layer must pass independently at its actual tip. Full suite on `ssh lidge` only, never locally. -- Stop: exact-tip acceptance evidence recorded; do not merge. This drafting task stops after document checks and runs no tests, code entrypoints, or Git mutations. +- Verifier: this document's authoritative Verification section; every layer must pass independently at its actual tip. All tests run on `ssh lidge`, never locally. +- Stop: exact-tip acceptance evidence and CI recorded, then close the work phase; do not merge. The earlier docs-only drafting pass is historical. - Size gate: the binding `003_parent_decisions.md` PURE-MOVE-SIZE-01 resolves the original 500-line churn conflict. Non-move changes must stay **≤150 lines**, with move-aware diff review and unique-owner evidence for every inventory symbol. Raw added+deleted churn is not claimed to meet 500. Stale source, a leaf >400, any new cycle, any behavioral difference, or non-move changes above the bound stop implementation. -Basis: task docs HEAD `4cc219549`; code `origin/dev=1362b1a3841b4de20177e5d65865a513dd7936c4`. Read 000, 001, S13 rows/Per-layer gate of 002, and the relevant records in `devlog/_plan/260905_modular_debt_ledger/016_lane_cli_storage_usage_update_lab_scripts.md`. Source was read with `git show origin/dev:`; `git diff origin/dev -- src/clients/config-export.ts src/cli/opencode.ts src/cli/minimax.ts src/integrations/state.ts` was empty. Older tips in 000/001 are historical, not this plan's code basis. +Inventory basis (historical): task docs HEAD `4cc219549`; code `origin/dev=1362b1a3841b4de20177e5d65865a513dd7936c4`. The drafting pass read 000, 001, S13 rows/Per-layer gate of 002, and the relevant records in `devlog/_plan/260905_modular_debt_ledger/016_lane_cli_storage_usage_update_lab_scripts.md`. Source was read with `git show origin/dev:`; `git diff origin/dev -- src/clients/config-export.ts src/cli/opencode.ts src/cli/minimax.ts src/integrations/state.ts` was empty. Current execution uses the dev base in the PR section; the inventoried source bytes were checked unchanged. Structural decision (cxc-dev §1/§5, architecture ARCH-MAP-01/ARCH-DECISION-01): 1990 lines mix distinct concerns. Reject deleting/configuring the feature (does not preserve behavior), and generic helpers/index barrels (do not establish ownership). Reuse every existing algorithm and lower-level dependency; only relocate declarations. Inspected conventions: `src/config/paths.ts`, `src/config/process-state.ts`, `src/cli/launcher-context.ts`, `src/cli/account-extended.ts`, `src/integrations/ownership-policy.ts`. Use the domain subfolder `src/clients/config-export/` without an index barrel. The original remains an existing compatibility boundary, not an internal import shortcut. @@ -340,7 +340,7 @@ Discovery: `rg -l 'src/clients/config-export' tests --glob '*.ts'`, followed by - `tests/clients/prime-client.test.ts` — unchanged. - `tests/clients/sync-client-integrations.test.ts` — unchanged. - `tests/config/client-config-export-new-clients.test.ts` — unchanged. -- `tests/config/client-config-export.test.ts` — unchanged. +- `tests/config/client-config-export.test.ts` — original import/assertions retained; facade identity and fixed-byte regressions added. - `tests/config/client-config-new-clients.test.ts` — unchanged. - `tests/gui/integrations-invariants.test.ts` — unchanged. - `tests/providers/aside-client.test.ts` — unchanged. @@ -354,28 +354,67 @@ No source-text reader of src/clients/config-export.ts was found. `tests/config/c C-phase red proof: temporarily treat incompatible audio-only input as text in the moved metadata function and observe `tests/clients/client-export-modality-enum.test.ts:96` fail; restore. Temporarily retain none in the moved MCode effort list and observe `tests/providers/minimax-clients.test.ts:117` fail; restore. -These are future implementation checks, not tests run by this docs author. No new test file is required. Facade/leaf identity assertions may be added in an existing focused test; if a new test file is required, parent must explicitly expand scope to include both test-layout registry files (`scripts/test-layout/layout.json`, `tests/fixtures/test-layout-expected.json`). Never commit red-proof mutations. +The implementation uses the existing focused test file; no new test file was needed. Original assertions remain intact. The recorded red/restored-green proof applies to the unchanged tested source and test blobs; repeat it if those change. Never commit mutation probes. A future new test file requires both layout registry entries. ## Verification -Future implementation gate only, in the dedicated layer worktree at its actual tip. Domains: ci-workflows, cli, clients, config, gui, providers, server. Explicit source-reader and subprocess coverage is not replaced by test:changed. - -```sh +Run the following Bash recipe from this session's bound checkout while its FSM is at C, after committing and publishing the layer head. The session identifier below belongs to this task; another task must use its own latest SessionStart binding. All Bun commands run on `lidge`, inside a fresh temporary clone. No shared seed checkout is switched. The local commands only validate identity, transport the verifier and retain output. + +```bash +set -euo pipefail +wp400_root=$(git rev-parse --show-toplevel) +wp400_expected=$(git rev-parse HEAD) +wp400_status=$(git status --porcelain) +test -z "$wp400_status" +wp400_log="$wp400_root/.codexclaw/evidence/01a06e97-b9d8-7250-8204-bb788338c288/wp400-remote-check-$wp400_expected.log" +mkdir -p "$(dirname "$wp400_log")" +cxc receipt test --cwd "$wp400_root" --session 01a06e97-b9d8-7250-8204-bb788338c288 -- bash -c ' +set -euo pipefail +test "$(git rev-parse HEAD)" = "$1" +local_status=$(git status --porcelain) +test -z "$local_status" +ssh lidge bash -s -- "$1" 2>&1 | tee "$2" +test "$(git rev-parse HEAD)" = "$1" +local_status=$(git status --porcelain) +test -z "$local_status" +' -- "$wp400_expected" "$wp400_log" <<'REMOTE' +set -euo pipefail +expected=${1:?expected SHA required} +[[ "$expected" =~ ^[0-9a-f]{40}$ ]] +run_dir=$(mktemp -d /tmp/ocx-wp400.XXXXXX) +printf 'RETAINED_RUN_DIR=%s\n' "$run_dir" +git clone --no-checkout https://github.com/lidge-jun/opencodex.git "$run_dir/repo" +cd "$run_dir/repo" +git fetch origin refs/heads/codex/split-clients-config-export-a +test "$(git rev-parse FETCH_HEAD)" = "$expected" +git checkout --detach "$expected" +bun --version +bun install --frozen-lockfile +(cd gui && bun install --frozen-lockfile) +tree_status=$(git status --porcelain) +test -z "$tree_status" +printf 'CHECKOUT=%s\nHEAD=%s\n' "$PWD" "$(git rev-parse HEAD)" +unset OCX_TEST_NO_QUEUE bun run typecheck bun test tests/ci-workflows/dsh-path-contract.test.ts tests/ci-workflows/dsh-writer-lock.test.ts tests/cli/cli-help.test.ts tests/clients/client-export-modality-enum.test.ts tests/clients/integrations-state.test.ts tests/clients/integrations-writer.test.ts tests/clients/omp-path-contract.test.ts tests/clients/pi-path-contract.test.ts tests/clients/prime-client.test.ts tests/clients/sync-client-integrations.test.ts tests/config/client-config-export-new-clients.test.ts tests/config/client-config-export.test.ts tests/config/client-config-new-clients.test.ts tests/gui/integrations-invariants.test.ts tests/providers/aside-client.test.ts tests/providers/minimax-clients.test.ts tests/providers/zcode-client.test.ts tests/server/management-client-config-route.test.ts tests/server/management-integration-journal-delete.test.ts tests/server/management-integration-routes.test.ts tests/cli/cli-export-command.test.ts bun run privacy:scan -wc -l src/clients/config-export/contracts.ts src/clients/config-export/constants.ts src/clients/config-export/model-metadata.ts src/clients/config-export/omp.ts src/clients/config-export/zcode.ts src/clients/config-export/dsh.ts src/clients/config-export/mcode.ts src/clients/config-export.ts -# Compare resolved old-path consumer identities/counts with the list in this plan -rg -n 'clients/config-export' src gui/src scripts tests -# Full suite on lidge only; parent serializes access to this shared remote checkout -ssh lidge 'cd ~/ocx-ci/opencodex && git fetch origin codex/split-clients-config-export-a && git checkout -q FETCH_HEAD && bun install --frozen-lockfile >/dev/null && bun run test' +if bun run test; then + test_rc=0 +else + test_rc=$? +fi +printf 'SUITE_EXIT=%s\n' "$test_rc" +if [ "$test_rc" -ne 0 ]; then exit "$test_rc"; fi +test "$(git rev-parse HEAD)" = "$expected" +tree_status=$(git status --porcelain) +test -z "$tree_status" +printf 'VERIFIED_HEAD=%s\n' "$expected" +REMOTE ``` -The remote command intentionally keeps bun run test last, preserving its exit code instead of masking failure behind tail. Parent records remote HEAD and full output. Every command exits 0; focused/full tests report 0 failures. Delivery requires a green exact-head GitHub CI rollup, not an empty required-check list. - -Per 002, `bun test tests/lab/core-lab-boundary.test.ts` is conditional on source edits under `src/server|src/router|src/lib`: **not applicable** to this approved layer touch set. Do not edit its PROTECTED roots. If implementation expands into those directories, parent must approve scope and run that guard explicitly. Preserve the 33 original direct consumer files; new facade-to-leaf imports are not caller churn. The grep is a discovery list, not by itself a proof of consumer identity: resolve relative and dynamic paths as in the inventory method. Repeat lane 016 method G on the final imports to prove zero new cycles; typecheck alone is not a cycle detector. +The local pipeline propagates SSH and log-write failure to the receipt producer. Remote commands stop on failure; full-suite status is printed and returned. Final remote HEAD and Git status must still match the clean layer head. Keep the temporary clone and full output as evidence. A receipt proves the command actually run, not this prose; require fresh current-head CI and independent review before closure. -Drafting verification is document-only: required heading order, complete symbol ranges/ownership, projected line arithmetic, export coverage, referenced test paths, unique leaf paths and assigned-file scope. No test, typecheck, privacy scan or remote command above was executed in this drafting task. +Local read-only structural checks are `git diff --check`, `wc -l` for the eight source files, and importer discovery with `rg -n 'clients/config-export' src gui/src scripts tests`. Resolve actual import edges when comparing consumers; a grep count alone is insufficient. New leaves must have no facade return edge, including type-only and literal dynamic imports. The full suite includes the core/Lab guard; do not weaken its protected roots. This layer's own source delta does not touch those protected files. ## Accept criteria @@ -394,7 +433,7 @@ Title: `refactor(clients): extract low-fanout client formats and dependency foun Branch: `codex/split-clients-config-export-a`. Current replanned base: `dev`, pinned `be81013fab6d83ff630ca5f38e7881678a303871` after prerequisite #3610 landed. Closes: none. -Use all sections of `.github/PULL_REQUEST_TEMPLATE.md` (Summary, Verification, Checklist), including the size-gate disposition and DEV-STACK-03 map below. This draft creates no PR; placeholder PR numbers are intentional. +Use all sections of `.github/PULL_REQUEST_TEMPLATE.md` (Summary, Verification, Checklist), including the size-gate disposition and DEV-STACK-03 map below. This layer is PR #3611; placeholder rows refer only to future layers. | # | PR | Layer | Branch | Base | Review focus | |---|---|---|---|---|---| @@ -404,7 +443,7 @@ Use all sections of `.github/PULL_REQUEST_TEMPLATE.md` (Summary, Verification, C | 4 | #TBD-S13-L4 | 430 | `codex/split-cli-minimax` | `codex/split-cli-opencode` | isolate MMX protocol and termination owners | | 5 | #TBD-S13-L5 | 440 | `codex/split-integrations-state` | `codex/split-clients-config-export-b` | separate classification from state reads | -Bottom S13 layer, with an explicit external verification prerequisite #3610. Review this layer's diff only. No S13 child has been published yet. After a base change, re-verify the layer tip and parent-relative diff; after the prerequisite lands, restack/retarget to dev. Merging remains out of scope. +Bottom S13 layer against `dev`. The former prerequisite #3610 has landed and the retarget/restack is complete. Review this layer's diff only. No S13 child had been published at this checkpoint. Future base changes require normal scoped restack and fresh verification; no open-prerequisite retargeting action remains. Merging stays out of scope. ## P stale-check (2026-09-05, wp400) @@ -412,35 +451,29 @@ Historical stale check at origin/dev 3191fe1aa: config-export.ts unchanged since ## A audit synthesis (2026-09-05, wp400) -### C→P replan on user-requested continuation - -Latest replan: the user authorized continuing verification without further permission stops. #3610 was externally merged as5ab8aa9a2; #3611 auto-retargeted todev. Pin the fetched integration tip `be81013fab6d83ff630ca5f38e7881678a303871`, which containsafdd and the previously omitted dev changes, including #3622 quota-fixture reconciliation and #3623 failure diagnostics. The unsplit config-export source and original focused test remain byte-identical betweenafdd and this newbase. This supersedes the temporary older-foundation choice below. - -Replay only our commits afterafdd ontobe810 in the existing a2c0 worktree, with --no-update-refs and a preserved7d4 reference. Candidate graph/import audit must usebe810 dependencies plus the unchanged split overlay; after rebase, compare all nine source/test blobs and the parent-relative path set. Require new resulting-head remote receipt and CI. The previously cancelled CI retry was authorized and started, then cancelled by Main as obsolete once the merged-base change was confirmed. Do not treat that cancellation as another missing permission. Future scoped check reruns are authorized; no local suite or merge is part of this action. - -The previous C result remains failed, not completed. The safe public contract split is preserved at244663568. Full remote checks failed on the four baseline quota/route tests; current GitHub CI additionally reports a quota-window fixture mismatch, under separate read-only RCA. These results cannot certify a new head. +### Current execution authority -Decision: use #3610 as an explicit verification prerequisite while keeping its fixes and this module split in separate PRs. Pin `afdd38ff43c64696153372fc2e27a38aff208c73`, not a moving ref. Read-only fetch and `git diff 850afb2e9 -- src/clients/config-export.ts` show the source being split is byte-identical. +The current branch is `codex/split-clients-config-export-a`, PR #3611, base `dev` at `be81013fab6d83ff630ca5f38e7881678a303871`. That SHA is the integration base, not the layer head. The restack has already been performed. For current verification, use the clean layer HEAD and the isolated recipe above; do not repeat the retired parent rebase below. #3610 has landed as `5ab8aa9a2d9d2a3926469f9d8c82387b43c6d0e9`. -Ancestry disposition: the parent's merge base with850 is593978db0. It lacks3191fe1aa,45045623b,f8ba644f3,850afb2e9, including changes across eight catalog/provider/router source files. Main explicitly accepts this older verification foundation for this dependent draft PR; no claim is made that it is equivalent to850. Those commits are not replayed into our parent-relative diff. Original850-based results remain historical, and neither their graph proof nor runtime results substitute for the new basis. Audit required imports and the entire reachable candidate graph against the pinned parent before B. +### Historical replan record — not executable -Build action: in the same a2c0 worktree, rebase only this branch's own commits after850 onto the pinned parent, preserving original244663568 in git history/references. No other branch/worktree is rebased, reset, overwritten or merged. Inspect the resulting parent-relative diff for exactly the approved split/test/document paths. Publish with an exact-old-head force-with-lease, keeping #3611 draft and retargeting only it to the open prerequisite branch. No S13 upper branches exist to cascade yet. +The original `244663568` split on the `850afb2e9` foundation passed focused checks but failed the full suite on four inherited quota/route cases. A temporary stack used the then-open #3610 head `afdd38ff43c64696153372fc2e27a38aff208c73`; its older foundation omitted four intervening dev commits, an explicitly recorded and audited tradeoff. The historical review anchors `7953e6d4` and `7d4a37544` belong to that retired parent arrangement. -Check action: independently review the resulting interdiff/base, then run the reviewed isolated remote verifier with the new40-character head. Require fresh typecheck, focused tests, privacy, full-suite receipt and exact-head CI. Restore failures by diagnosis, never by skips or reduced assertions. The earlier mutation proof may be cited only if the mutated source and relevant test blobs remain byte-identical; otherwise repeat it remotely. After prerequisite landing, the normal restack/retarget and exact-head checks still apply. +After #3610 landed, the user authorized continued scoped verification and repair. Main replayed only its own commits onto the current dev base, preserving old refs and using `--no-update-refs` plus an exact-old-head lease. The actual `412dcba4` tree was identity-checked against the audited dev-base overlay. Its remote gates passed. Subsequent documentation repairs require a new current-head receipt; the historical hashes here are evidence anchors, never checkout or retargeting instructions. -Historical pre-replan execution basis was pinned to `850afb2e9f84979c87e914b248de482f44b34cd6`. Hooke rechecked the eight-source-file delta from `3191fe1aa`: config-export.ts and its required declarations are unchanged, and traversal including inline/type/re-export edges found no return cycle. Final verdict: PASS. The complete preserved roadmap is at immutable commit `dc44b08cafbbd45da81f940f1e8c00a9e5f61ce1` on `codex/260905-modular-debt-ledger-docs`; use `git show :devlog/_plan/260905_now_split_train/` for roadmap documents not carried in this layer's PR. The current a2c0 branch is `codex/split-clients-config-export-a`, created in place from that pinned basis; no managed worktree or session-state relocation occurred. Remote preflight found `/usr/local/bin/bun`, the expected origin URL and a clean shared seed; it did not run tests or switch the seed checkout. +The complete initial roadmap remains in the preserved documentation branch, at immutable local commit `dc44b08cafbbd45da81f940f1e8c00a9e5f61ce1`. Only this layer's governing documents are carried in its PR; other roadmap documents are read from that preserved ref. Implementation and receipts remain in the existing a2c0 directory, never in a replacement managed worktree. Hooke (`01a06f9f-f57f-7fc3-9261-b07f291929be`, requested gpt-6-astra high) returned GO-WITH-FIXES with zero blockers, then PASS after the two documentation corrections above. The read-only audit matched all 153 inventory ranges, assigned all 63 moved declarations uniquely, checked seven leaf and seven facade import lists, and preserved 96 public exports (47 types, 49 values). Its dependency traversal reported no return path from the external owners to the facade at base `3191fe1aa`. These are plan-audit results, not implementation or test results. Accepted findings: replace the stale raw-churn escalation with the binding ≤150 non-move gate; explicitly retain original blank line 33 and mark projected line counts as pre-pruning estimates. No blocker was rebutted. Re-review confirmed both closures at docs HEAD `38ad3cf5a` plus the working diff. `git diff --check` exited 0 after those edits; no local test suite was run. -Operational audit by Wegener (`01a06fa6-5e3c-7840-8172-8587e853dcc7`, explicitly `model=gpt-6-astra`, `reasoning_effort=high`) found two blockers: checkout-local source identity was incompatible with the prior separate execution tree, and the remote recipe switched a shared checkout. Both were accepted and folded into 003 WORKTREE-EVIDENCE-01 and 000. Re-audit returned PASS, with no blocker to entering B. A documentation-only delta must not stand in for implementation evidence from another checkout. The shared-remote command above is superseded and must not be executed. Pre-C hold: independently review the actual isolated runner, exact-SHA and clean-tree checks, and failure propagation before running it. Approval of the plan is not proof that remote verification passed. +Operational audit by Wegener (`01a06fa6-5e3c-7840-8172-8587e853dcc7`, explicitly `model=gpt-6-astra`, `reasoning_effort=high`) found two blockers: checkout-local source identity was incompatible with the prior separate execution tree, and the remote recipe switched a shared checkout. Both were accepted and folded into 003 WORKTREE-EVIDENCE-01 and 000. Re-audit returned PASS, with no blocker to entering B. A documentation-only delta must not stand in for implementation evidence from another checkout. The unsafe shared-checkout command has been removed; Verification now contains the isolated recipe. The actual runner received independent exact-SHA, clean-tree and failure-propagation review; changes to that recipe require the same checks. Approval of the plan is not proof that remote verification passed. ## B implementation record (2026-09-05) -### Replanned stack checkpoint +### Historical temporary-stack checkpoint -The scoped rebase completed at review anchor `7953e6d4e18b0e7c90c0c5cdb0a4256c22a25dd0`, with integration base `afdd38ff43c64696153372fc2e27a38aff208c73`. PR #3611 now targets the open `codex/win-7-postmerge-stability` branch (#3610). Only this branch was rebased/pushed; exact-old-head lease244663568 protected publication, and `--no-update-refs` preserved the old244 and audit67 snapshot branches. +At historical review anchor `7953e6d4e18b0e7c90c0c5cdb0a4256c22a25dd0`, the integration base was `afdd38ff43c64696153372fc2e27a38aff208c73` and PR #3611 temporarily targeted the then-open #3610 branch. That arrangement is retired. Only our branch was rebased/pushed; the exact-old-head lease protected publication and `--no-update-refs` preserved the old snapshots. Independent reviewer Heisenberg confirmed identical blob IDs for all nine source/test paths versus244, exactly those nine paths plus three documents in the parent-relative diff, and an actual-tree traversal of4979edges/349facade-reachable files with no new return cycle or unresolved reachable import. Static verdict PASS; not a runtime-pass claim. This documentation checkpoint adds no source/test changes after that review anchor.