docks-kit 0.16.17 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -31,6 +31,8 @@ launcher can fall back to Bun source.
31
31
  | `docks-kit` / `docks-kit.ps1` | POSIX and Windows CLI launchers. On supported hosts, each runs the matching binary in `cli/dist/` only when its `--version` matches `package.json`. Otherwise it runs Bun-from-source and auto-installs Bun plus `node_modules`. Hosts outside the support matrix fail before source fallback. The standalone platform release binary provides no-Bun recovery. |
32
32
  | `cli/src/engine-native/` | EngineNative implementation for `sync`, `model`, and `toolchain`; idempotent, flag-gated for destructive reconciliation |
33
33
  | `cli/src/engine-native/ompSync.ts` | omp file deployment, marketplace registration, and plugin synchronization |
34
+ | `cli/src/commands/omp.ts` | `docks-kit omp` session launcher: renders the free-model run overlay and forwards args to omp |
35
+ | `cli/src/engine-native/ompOverlay.ts` | Free-model overlay render plus catalog parse and thinking-ceiling helpers |
34
36
  | `cli/` | Effect 4 RC CLI + bundled docs topics |
35
37
  | `SoT/models.json` | Kit-verified Claude and Codex model catalog |
36
38
  | `SoT/toolchain.json` | Toolchain floors manifest (verified pins consumed by EngineNative) |
@@ -72,6 +74,8 @@ omp SoT notes:
72
74
  - Sync registers the `docks` marketplace. It installs or upgrades `docks@docks` and `plan-lifecycle@docks` at user scope.
73
75
  - Sync installs `pi-intercom` at the verified version from `SoT/toolchain.json`.
74
76
  - The omp CLI is upstream-owned and self-updating through `omp update`. Sync never installs or upgrades the CLI.
77
+ - `docks-kit omp [--model <selector>|--pick] [args...]` starts one interactive omp session on a single free model. It renders a run overlay to `~/.cache/docks-kit/omp-free-<model>-<digest>.yml` (mode 0600) and passes it through omp's repeatable `--config` flag. `ompOverlay.ts overlayFileName` gives each model its own file, because omp can re-read the overlay during a live session and a second launcher on another model must not rewrite it. Deployed `~/.omp/agent/` files stay untouched, so the next plain `omp` run uses the paid configuration again. Remaining arguments forward verbatim to omp.
78
+ - The session model persists per machine in `~/.docks-kit/state.json` under `ompSession`, next to `harnesses`. The default is `opencode-zen/muse-spark-1.3-contributor-free` at `xhigh`. `ompOverlay.ts ladderCeiling, advisorLevelFor` derive both the session level and the advisor level from the ladder of the chosen model, never from a fixed list: free ladders are not uniform, three free models publish no `medium`, and two publish no ladder, which the overlay renders as bare selectors. The picker lists only zero-cost catalog models.
75
79
 
76
80
  For per-tool SoT layouts (`SoT/.claude/`, `SoT/.codex/`, `SoT/.omp/`), see the matching SoT directory.
77
81
 
@@ -83,7 +87,7 @@ For per-tool SoT layouts (`SoT/.claude/`, `SoT/.codex/`, `SoT/.omp/`), see the m
83
87
  - **Effect 4 CLI stack.** The CLI pins `effect@4.0.0-rc.115` (including `effect/unstable/cli`), `@effect/platform-bun@4.0.0-rc.115` (`BunServices.layer`, `BunRuntime.runMain`), `@effect/vitest@4.0.0-rc.115`, and `vitest@5.0.0` (inside the `@effect/vitest` peer range `>=5.0.0 <6.0.0`; 5.0.0 is past the 5.x patch point for GHSA-82fw-gwwq-j7x9, fixed in 5.0.0-rc.2). `@effect/cli` and `@effect/platform` are removed and must not be reintroduced.
84
88
  - **Effect skill routing.** Effect work in this checkout must verify migration and API call shapes against the installed declarations under `node_modules/effect/dist/unstable/cli/`, never from memory or a mutable dist-tag. The `effect-ts-setup`, `effect-ts-port`, and `effect-ts-specialist` skills target Effect 3.x and do not apply.
85
89
  - **Targeted syncs.** `./docks-kit sync` accepts positional targets: `claude`, `codex`, `agents`, and `omp`. Use the narrowest target that matches the SoT change (for example, `./docks-kit sync omp` for omp-only config edits); targets can be combined with `--dry-run`, `--skip-bubblewrap` (skip optional bubblewrap bootstrap for the Codex Linux sandbox), `--skip-plugin-refresh` (install missing plugins without refreshing existing caches), `--reconcile`, `--prune`, and the deploy-time modifiers `--claude-compact-window=<tokens>` / `--claude-permissive` / `--claude-model=<m>` / `--claude-effort=<level>` / `--claude-advisor=<on|off|default>` / `--codex-model=<m>` / `--codex-effort=<level>` (see `CLAUDE.md` § Deploy-time modifiers).
86
- - **Per-machine harness selection.** `~/.docks-kit/state.json` drives a flag-less sync. A missing file selects `claude`, `codex`, and `agents`; it never selects `omp` implicitly. `sync` never prompts and never writes the selection file. `docks-kit harnesses` is the only command that writes the selection.
90
+ - **Per-machine harness selection.** `~/.docks-kit/state.json` drives a flag-less sync. A missing file selects `claude`, `codex`, and `agents`; it never selects `omp` implicitly. `sync` never prompts and never writes the selection file. `docks-kit harnesses` is the only command that writes the `harnesses` selection. The same state file also carries the independent `ompSession` key, which only `docks-kit omp` writes; every writer merges over the stored record, so neither key can drop the other.
87
91
  - **Additive by default.** Keys present in deployed config but absent from SoT are preserved on default sync. This protects user-only additions, but means drift accumulates — neither flag-less reset can clean it up. The one exception is the Claude `removed` manifest (`claudeSync.ts REMOVED_MANIFEST, baseline removal inventory`), a curated list of unambiguous kit-owned artifacts that `claudeSync.ts syncRemovals, baseline artifact prune` force-prunes on every sync, including the home-relative `~/.local/bin/session-relay` artifact installed outside `~/.claude`; see `CLAUDE.md` § Pruning stale artifacts.
88
92
  - **`--reconcile` / `--prune` are the kit-owned reconcile flags.** Orthogonal — `--reconcile` reconciles the settings layer (SoT-declared keys/tables/arrays win; user-only keys and nested objects are preserved; permissions arrays are replaced wholesale by SoT). `--prune` uninstalls kit-managed installations not in the SoT (plugins, marketplaces, and `~/.agents/skills/*` entries tracked in `~/.agents/.kit-managed-skills`). Combine for a full reset to SoT's kit-managed scope. User-only additions outside the kit's scope (custom env vars, mcpServers, manually-installed skills, third-party plugins not declared in SoT) are always preserved. Each tool's per-tool file documents the specific paths and diff recipes.
89
93
  - **SOLID-aligned modules.** `cli/src/engine-native/parseArgs.ts` owns flag parsing and validation. `toolchain.ts` owns verified-version floor reporting over `SoT/toolchain.json`; `bun.ts` owns the shared, memoized Bun bootstrap; `claudeRuntime.ts` owns Claude settings materialization. `claudeSync.ts`, `codexSync.ts`, and `skillsSync.ts` own their tool-specific sync logic. `ompSync.ts` owns omp file and plugin sync. `ompYaml.ts` owns the omp YAML merge. `harnesses.ts` owns per-machine harness selection. `index.ts` is the thin orchestrator. The public CLI seam is `cli/src/engine.ts`.
package/cli/docs/flags.md CHANGED
@@ -52,6 +52,27 @@ Bare model, effort, or advisor modifiers print the relevant valid-value catalog
52
52
  and exit 2. A modifier for a target not selected by the positional arguments is
53
53
  ignored with a warning; Claude modifiers never touch Codex config and vice versa.
54
54
 
55
+ ## `omp` session flags
56
+
57
+ | Flag | Effect |
58
+ |------|--------|
59
+ | `--model <selector>` | Session model for this run and later runs; rejected unless the live catalog reports zero input and output cost; recorded in `~/.docks-kit/state.json` under `ompSession` |
60
+ | `--pick` | Interactive picker over free catalog models; records the choice the same way |
61
+
62
+ Remaining arguments forward verbatim to omp after the launcher flags.
63
+ `docks-kit omp -p "..."` runs one prompt. `docks-kit omp --continue` resumes
64
+ the previous session. A bare `docks-kit omp` opens an interactive session.
65
+ The launcher changes no deployed file and never invokes `sync`.
66
+ The boundary opens at the first token that is neither a declared launcher
67
+ flag nor a declared global flag. Declared flags stay with docks-kit and
68
+ never reach omp:
69
+
70
+ - `--model`, `--pick`
71
+ - `--help`, `--version`, `--log-level`, `--wizard`, `--completions`
72
+
73
+ The same names on omp stay reachable behind an explicit delimiter. Use
74
+ `docks-kit omp -- --help` to ask omp for help.
75
+
55
76
  ## Renamed legacy flags (pre-CLI sync.sh)
56
77
 
57
78
  Old flags exit with a rename hint — there is no compat behavior.
@@ -211,6 +211,65 @@ omp's 272k context window is the `/extended-context off` window for Astra.
211
211
  `/extended-context on` uses the 922k input window, 1.05M total, which matches
212
212
  AA's 1M.
213
213
 
214
+ ## Free session launcher (`docks-kit omp`)
215
+
216
+ `docks-kit omp [--model <selector>|--pick] [args...]` starts one interactive
217
+ omp session on a single free model. All 12 model roles resolve to that model.
218
+ All 9 retry fallback chains are empty, so a retry cannot reach a paid model.
219
+ The overlay also sets `defaultThinkingLevel` and `task.maxEffort`, except for
220
+ a model that publishes no thinking ladder, where both keys are omitted and the
221
+ deployed values apply.
222
+
223
+ The launcher renders a run overlay to
224
+ `~/.cache/docks-kit/omp-free-<model>-<digest>.yml` at mode 0600 and passes it
225
+ through omp's repeatable `--config` flag. Each model gets its own file,
226
+ because omp can re-read the overlay during a live session, and a second
227
+ launcher on another model must not rewrite that file. The name sanitizes the
228
+ selector for the file system and appends a digest of the exact selector, so
229
+ two selectors that differ only in separator characters stay apart. The
230
+ launcher never reads or writes `~/.omp/agent/config.yml` or `models.yml`. It
231
+ never touches the SoT. The next plain `omp` run uses the paid configuration
232
+ again.
233
+
234
+ Every argument after the launcher flags forwards verbatim to omp. `docks-kit
235
+ omp -p "..."` runs one prompt. `docks-kit omp --continue` resumes the
236
+ previous session. A bare `docks-kit omp` opens an interactive session.
237
+
238
+ The default model is `opencode-zen/muse-spark-1.3-contributor-free` ("Muse
239
+ Spark 1.3 Free", 1,048,576-token context) at `xhigh`. `xhigh` is the ceiling
240
+ of that model ladder: `minimal, low, medium, high, xhigh`. The paid
241
+ `muse-spark-1.3` sibling also offers `max`. The free variant does not.
242
+
243
+ Both levels come from the ladder of the chosen model, never from a fixed
244
+ list. The session level is the ceiling of that ladder. The advisor level is
245
+ the highest level at or below `medium`, because the advisor performs quick
246
+ review passes. When every level of a ladder is above `medium`, the advisor
247
+ takes the lowest level, so it never becomes the most expensive role.
248
+
249
+ Free ladders are not uniform. On 2026-09-11 the 26 free models published six
250
+ distinct ladders: 21 include `medium`, 3 stop at `low, high, max` or
251
+ `high, max`, and 2 publish no ladder at all. A model without a ladder gets
252
+ bare selectors with no `:level` suffix, because an invented level makes omp
253
+ fail when the session starts.
254
+
255
+ The choice persists per machine in `~/.docks-kit/state.json` under
256
+ `ompSession`, next to `harnesses`. It survives across sessions. It is never
257
+ committed. `--model <selector>` records one selector. `--pick` opens an
258
+ interactive picker.
259
+
260
+ The picker lists only models the live `omp models --json` catalog reports at
261
+ zero input and output cost (26 entries on 2026-09-11). This path cannot start
262
+ a paid session. The catalog can advertise a free model that the account
263
+ cannot call; omp reports that provider error unchanged.
264
+
265
+ An omp login is required. The kit owns no login flow. It surfaces omp's own
266
+ authentication error unchanged.
267
+
268
+ When the catalog renames the free variant, change only the default selector
269
+ constant. A ladder change needs no code change, because both levels come from
270
+ the catalog row of the chosen model. Refresh the recorded default and the
271
+ ladder counts in these docs in the same commit.
272
+
214
273
  ## Maintenance
215
274
 
216
275
  - Refresh the snapshot from the AA release page of each family
@@ -16,7 +16,7 @@ AI-assisted dev environment on every machine.
16
16
  | `SoT/models.json` | Kit-verified model catalog (see `docks-kit docs models`) |
17
17
  | `SoT/toolchain.json` | Verified-version floors for external tools (see `docks-kit docs toolchain`) |
18
18
  | `cli/src/generated/sotPayload.ts` | Deterministic generated payload embedded in standalone/npm execution |
19
- | `cli/src/engine-native/` | EngineNative mutation logic for sync/model/toolchain |
19
+ | `cli/src/engine-native/` | EngineNative mutation logic for sync/model/toolchain and the `docks-kit omp` session overlay |
20
20
  | `cli/` | This CLI (Effect 4 RC on Bun) plus bundled docs |
21
21
  | `docks-kit` / `docks-kit.ps1` | POSIX and Windows launchers: version-matching compiled binary → Bun-from-source, with Bun auto-install |
22
22
 
package/cli/src/argv.ts CHANGED
@@ -8,6 +8,7 @@ import { statusCommand } from "./commands/status"
8
8
  import { syncCommand } from "./commands/sync"
9
9
  import { toolchainCommand } from "./commands/toolchain"
10
10
  import { updateCommand } from "./commands/update"
11
+ import { ompCommand } from "./commands/omp"
11
12
  import {
12
13
  advisorCatalog,
13
14
  advisorFlagGrammar,
@@ -161,7 +162,8 @@ const COMMANDS: ReadonlyArray<CommandValue> = [
161
162
  statusCommand,
162
163
  pluginsCommand,
163
164
  skillsCommand,
164
- docsCommand
165
+ docsCommand,
166
+ ompCommand
165
167
  ]
166
168
 
167
169
  // A Map, not a plain object: an object literal answers `toString` and friends from
@@ -373,11 +375,68 @@ const missingModifierValue = (flag: string): string | undefined => {
373
375
  }
374
376
  }
375
377
 
378
+ // Without the injected `--`, Effect 4 rejects a forwarded flag such as `-p` or
379
+ // `--mode` as unrecognized. The omp launcher therefore owns a passthrough
380
+ // boundary: the first token after the `omp` word that is neither a flag
381
+ // declared on the omp or global surface nor a value consumed by such a flag
382
+ // starts the verbatim tail forwarded to omp. An undeclared long flag joins the
383
+ // tail too, because most omp flags are long (`--mode`, `--continue`,
384
+ // `--models`), and omp itself reports an unrecognized one accurately. Use
385
+ // `docks-kit omp -- --model x` to reach omp's own same-named flag.
386
+ const spliceOmpBoundary = (args: ReadonlyArray<string>): ReadonlyArray<string> => {
387
+ const surface = COMMAND_SURFACES.get("omp")
388
+ let ompIndex = -1
389
+ for (let index = 0; index < args.length; index++) {
390
+ const token = args[index] as string
391
+ if (token === "--") return args
392
+ const name = flagNameOf(token)
393
+ if (token === name && takesValueInAnySurface(name)) {
394
+ const next = args[index + 1]
395
+ if (next === "--") return args
396
+ if (next !== undefined && !(next.startsWith("-") && declaredInAnySurface(flagNameOf(next)))) {
397
+ index++
398
+ continue
399
+ }
400
+ }
401
+ if (!token.startsWith("-")) {
402
+ ompIndex = index
403
+ break
404
+ }
405
+ }
406
+ if (ompIndex === -1 || args[ompIndex] !== "omp") return args
407
+ for (let index = ompIndex + 1; index < args.length; index++) {
408
+ const token = args[index] as string
409
+ // An explicit delimiter already marks the tail; a second one would be
410
+ // forwarded to omp as a literal argument.
411
+ if (token === "--") return args
412
+ if (!token.startsWith("-")) {
413
+ return [...args.slice(0, index), "--", ...args.slice(index)]
414
+ }
415
+ const name = flagNameOf(token)
416
+ const canonical = declaredFlagName(name, surface)
417
+ if (canonical === undefined) return [...args.slice(0, index), "--", ...args.slice(index)]
418
+ const takesValue =
419
+ surface?.valueFlags.includes(canonical) === true ||
420
+ GLOBAL_SURFACE.valueFlags.includes(canonical)
421
+ if (takesValue && !token.includes("=")) {
422
+ const next = args[index + 1]
423
+ if (
424
+ next !== undefined &&
425
+ !(next.startsWith("-") && declaredFlagName(flagNameOf(next), surface) !== undefined)
426
+ ) {
427
+ index++
428
+ }
429
+ }
430
+ }
431
+ return args
432
+ }
433
+
376
434
  /** Validate the argument list, then hand back the arguments Effect 4 should parse. */
377
435
  export const prepareArgv = (args: ReadonlyArray<string>): ArgvOutcome => {
378
- const subcommand = subcommandName(args)
436
+ const boundaryArgs = subcommandName(args) === "omp" ? spliceOmpBoundary(args) : args
437
+ const subcommand = subcommandName(boundaryArgs)
379
438
  const commandSurface = subcommand === undefined ? undefined : COMMAND_SURFACES.get(subcommand)
380
- const { flags, normalizations } = scanArgv(args, commandSurface)
439
+ const { flags, normalizations } = scanArgv(boundaryArgs, commandSurface)
381
440
  const unknownCommandWouldMisdiagnoseFlag =
382
441
  subcommand !== undefined &&
383
442
  commandSurface === undefined &&
@@ -430,5 +489,5 @@ export const prepareArgv = (args: ReadonlyArray<string>): ArgvOutcome => {
430
489
  }
431
490
  }
432
491
 
433
- return { kind: "accept", args: normalizeArgv(args, normalizations) }
492
+ return { kind: "accept", args: normalizeArgv(boundaryArgs, normalizations) }
434
493
  }
@@ -0,0 +1,189 @@
1
+ import { Argument, Command, Flag, Prompt } from "effect/unstable/cli"
2
+ import { Console, Effect, Option } from "effect"
3
+ import { chmodSync, mkdirSync, writeFileSync } from "node:fs"
4
+ import { bail } from "../engine"
5
+ import { capture, p, spawnHost, which } from "../engine-native/exec"
6
+ import {
7
+ DEFAULT_OMP_SESSION_MODEL,
8
+ engineHome,
9
+ readOmpSessionModel,
10
+ writeOmpSessionModel,
11
+ type OmpSessionModel
12
+ } from "../engine-native/harnesses"
13
+ import {
14
+ advisorLevelFor,
15
+ buildOmpArgs,
16
+ ladderCeiling,
17
+ overlayFileName,
18
+ parseFreeModels,
19
+ renderFreeOverlay,
20
+ type CatalogModel
21
+ } from "../engine-native/ompOverlay"
22
+
23
+ const model = Flag.String("model").pipe(
24
+ Flag.withDescription("Free model selector to use and remember for omp sessions"),
25
+ Flag.optional
26
+ )
27
+ const pick = Flag.Boolean("pick").pipe(
28
+ Flag.withDescription("Choose the free model interactively"),
29
+ Flag.withDefault(false)
30
+ )
31
+ const args: Argument.Argument<ReadonlyArray<string>> = Argument.String("args").pipe(
32
+ Argument.withDescription("Arguments forwarded verbatim to omp"),
33
+ Argument.variadic()
34
+ )
35
+
36
+ export type FreeSelectorResolution =
37
+ | { readonly ok: true; readonly model: CatalogModel }
38
+ | { readonly ok: false; readonly message: string }
39
+
40
+ // Pure selector matching so the picker rule stays testable without spawning omp.
41
+ // An exact selector always wins; otherwise one case-insensitive substring hit
42
+ // across the free selectors resolves, because a longer exact selector is easy
43
+ // to mistype and the catalog is small enough to disambiguate safely.
44
+ export function resolveFreeSelector(
45
+ input: string,
46
+ free: ReadonlyArray<CatalogModel>
47
+ ): FreeSelectorResolution {
48
+ const trimmed = input.trim()
49
+ if (trimmed === "") {
50
+ return { ok: false, message: "Model selector must not be empty or blank" }
51
+ }
52
+ const exact = free.find((candidate) => candidate.selector === trimmed)
53
+ if (exact !== undefined) return { ok: true, model: exact }
54
+ const lowered = trimmed.toLowerCase()
55
+ const matches = free.filter((candidate) => candidate.selector.toLowerCase().includes(lowered))
56
+ const list = free.map((candidate) => candidate.selector).join(", ")
57
+ if (matches.length === 0) {
58
+ return {
59
+ ok: false,
60
+ message: `Model '${trimmed}' is not in the free catalog. Free models: ${list}`
61
+ }
62
+ }
63
+ const only = matches[0]
64
+ if (matches.length === 1 && only !== undefined) return { ok: true, model: only }
65
+ return {
66
+ ok: false,
67
+ message: `Model '${trimmed}' is ambiguous; matches: ${matches.map((candidate) => candidate.selector).join(", ")}. Pass the exact selector.`
68
+ }
69
+ }
70
+
71
+ const loadFreeCatalog = (omp: string): Effect.Effect<ReadonlyArray<CatalogModel>> =>
72
+ Effect.gen(function* () {
73
+ const raw = yield* Effect.promise(() => capture(omp, ["models", "--json"]))
74
+ if (raw === "") {
75
+ return yield* bail(
76
+ "'omp models --json' returned no output; verify omp runs on this host, then retry"
77
+ )
78
+ }
79
+ let parsed: unknown
80
+ try {
81
+ parsed = JSON.parse(raw) as unknown
82
+ } catch {
83
+ return yield* bail(
84
+ "'omp models --json' returned output that is not JSON; verify the omp version, then retry"
85
+ )
86
+ }
87
+ const free = parseFreeModels(parsed)
88
+ if (free.length === 0) {
89
+ return yield* bail("'omp models --json' listed no free models; verify the omp login, then retry")
90
+ }
91
+ return free
92
+ })
93
+
94
+ export const ompCommand = Command.make("omp", { model, pick, args }, (config) =>
95
+ Effect.gen(function* () {
96
+ if (Option.isSome(config.model) && config.pick) {
97
+ return yield* bail("Pass either --model <selector> or --pick, not both")
98
+ }
99
+
100
+ const omp = yield* Effect.sync(() => which("omp"))
101
+ if (omp === "") {
102
+ return yield* bail("omp not found on PATH; install omp, then retry", 1)
103
+ }
104
+
105
+ const home = engineHome(process.env)
106
+ if (Option.isSome(config.model)) {
107
+ const free = yield* loadFreeCatalog(omp)
108
+ const resolved = resolveFreeSelector(config.model.value, free)
109
+ if (!resolved.ok) return yield* bail(resolved.message)
110
+ // Compute both levels once from the catalog row so a plain launch
111
+ // never needs the catalog again.
112
+ const thinking = ladderCeiling(resolved.model.thinking)
113
+ const advisorThinking = advisorLevelFor(resolved.model.thinking)
114
+ yield* Effect.sync(() =>
115
+ writeOmpSessionModel(home, { selector: resolved.model.selector, thinking, advisorThinking })
116
+ )
117
+ }
118
+
119
+ if (config.pick) {
120
+ // Check the terminal before the catalog spawn, so a non-interactive run
121
+ // fails with the actionable message instead of an omp subprocess first.
122
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
123
+ return yield* bail("Picking needs a terminal; pass --model <selector> instead")
124
+ }
125
+ const free = yield* loadFreeCatalog(omp)
126
+ const chosen = yield* Prompt.Select({
127
+ message: "Choose the free model for this omp session",
128
+ choices: free.map((candidate) => {
129
+ const ceiling = ladderCeiling(candidate.thinking)
130
+ return {
131
+ title: `${candidate.name} — ${candidate.selector}`,
132
+ value: candidate.selector,
133
+ description:
134
+ ceiling === undefined
135
+ ? `model default thinking; ${candidate.contextWindow} context`
136
+ : `thinking to ${ceiling}; ${candidate.contextWindow} context`
137
+ }
138
+ })
139
+ })
140
+ const match = free.find((candidate) => candidate.selector === chosen)
141
+ if (match === undefined) return yield* bail(`Model '${chosen}' is not in the free catalog`)
142
+ const thinking = ladderCeiling(match.thinking)
143
+ const advisorThinking = advisorLevelFor(match.thinking)
144
+ yield* Effect.sync(() => writeOmpSessionModel(home, { selector: match.selector, thinking, advisorThinking }))
145
+ }
146
+
147
+ const session: OmpSessionModel = (yield* Effect.sync(() => readOmpSessionModel(home))) ??
148
+ DEFAULT_OMP_SESSION_MODEL
149
+
150
+ // A stable cache path, not a temp file deleted at exit, because omp may
151
+ // re-read the overlay during a live session. The name carries the model,
152
+ // so a second launcher on another model cannot rewrite the file a running
153
+ // session still reads.
154
+ const directory = p(home, ".cache", "docks-kit")
155
+ const overlayPath = p(directory, overlayFileName(session.selector))
156
+ yield* Effect.sync(() => {
157
+ // `mode` applies only when mkdir creates the path, so an existing
158
+ // permissive cache directory keeps its mode until the chmod below.
159
+ mkdirSync(directory, { recursive: true, mode: 0o700 })
160
+ chmodSync(directory, 0o700)
161
+ writeFileSync(overlayPath, renderFreeOverlay(session), { mode: 0o600 })
162
+ chmodSync(overlayPath, 0o600)
163
+ })
164
+
165
+ // A level-free model states the model default so the line never shows
166
+ // an undefined level.
167
+ const hasLevel = typeof session.thinking === "string" && session.thinking.trim() !== ""
168
+ yield* Console.error(
169
+ hasLevel
170
+ ? `Starting omp with ${session.selector} at ${session.thinking} thinking (session only; deployed config unchanged)`
171
+ : `Starting omp with ${session.selector} at the model default thinking level (session only; deployed config unchanged)`
172
+ )
173
+
174
+ const child = yield* Effect.sync(() =>
175
+ spawnHost("omp", buildOmpArgs(overlayPath, config.args), { stdio: "inherit" })
176
+ )
177
+ if (child.error !== undefined) {
178
+ return yield* bail(
179
+ `Failed to start omp: ${child.error instanceof Error ? child.error.message : String(child.error)}`
180
+ )
181
+ }
182
+ // A signalled child reports a null status, which must not look successful.
183
+ yield* Effect.sync(() => process.exit(child.status ?? 1))
184
+ })
185
+ ).pipe(
186
+ Command.withDescription(
187
+ "Start omp with every model role overridden to the remembered free model for this run only (no deployed configuration is changed; --model or --pick remembers a new free default)."
188
+ )
189
+ )
@@ -1,63 +1,19 @@
1
1
  import { Command, Flag } from "effect/unstable/cli"
2
2
  import { Console, Effect } from "effect"
3
- import { spawnSync, type SpawnSyncOptions, type SpawnSyncOptionsWithStringEncoding, type SpawnSyncReturns } from "node:child_process"
4
3
  import { existsSync, readFileSync } from "node:fs"
5
4
  import { bail, compiled } from "../engine"
6
5
  import { kitHome } from "../kitHome"
7
- import { p, which } from "../engine-native/exec"
8
- import { hostOs, type HostOs, type Invocation } from "../engine-native/os"
6
+ import { p, spawnHost, which } from "../engine-native/exec"
7
+ import { hostOs, type HostOs } from "../engine-native/os"
9
8
 
10
9
  const noSync = Flag.Boolean("no-sync").pipe(
11
10
  Flag.withDescription("Update the kit only; skip the chained flag-less sync"),
12
11
  Flag.withDefault(false)
13
12
  )
14
13
 
15
- /** A tool this host cannot resolve, shaped like the failed spawn it replaces. */
16
- const notFound = (command: string): SpawnSyncReturns<string> => ({
17
- pid: 0,
18
- output: [],
19
- stdout: "",
20
- stderr: "",
21
- status: null,
22
- signal: null,
23
- error: new Error(`command not found on PATH: ${command}`)
24
- })
25
-
26
- /**
27
- * Every child in this command starts here, because two host facts must never be
28
- * separated from the argv they describe: a Windows shim invocation is only
29
- * correct with the verbatim-arguments flag, and a pathless name would let
30
- * CreateProcess search the parent's current directory before the system one.
31
- */
32
- export const spawnUpdate = (
33
- command: string,
34
- args: ReadonlyArray<string>,
35
- overrides: SpawnSyncOptions = {},
36
- host: HostOs = hostOs()
37
- ): SpawnSyncReturns<string> => {
38
- const resolvesSuffixes = host.executableSuffixes.some((suffix) => suffix !== "")
39
- const executablePath = resolvesSuffixes ? which(command, host.executableSuffixes) : command
40
- if (executablePath === "") return notFound(command)
41
- let invocation: Invocation
42
- try {
43
- invocation = host.invoke(executablePath, args)
44
- } catch (cause) {
45
- // A value this host cannot put on a command line at all. Print the encoder's
46
- // reason and exit, matching how this command reports a failed child.
47
- process.stderr.write(`${cause instanceof Error ? cause.message : String(cause)}\n`)
48
- return process.exit(2)
49
- }
50
- const options: SpawnSyncOptionsWithStringEncoding = {
51
- stdio: ["ignore", "pipe", "pipe"],
52
- ...overrides,
53
- encoding: "utf8",
54
- windowsVerbatimArguments: invocation.windowsVerbatimArguments
55
- }
56
- return spawnSync(invocation.command, [...invocation.args], options)
57
- }
58
14
 
59
15
  const git = (home: string, args: Array<string>): { ok: boolean; out: string } => {
60
- const res = spawnUpdate("git", ["-C", home, ...args])
16
+ const res = spawnHost("git", ["-C", home, ...args])
61
17
  return { ok: res.error === undefined && res.status === 0, out: `${res.stdout ?? ""}${res.stderr ?? ""}`.trim() }
62
18
  }
63
19
 
@@ -65,7 +21,7 @@ const git = (home: string, args: Array<string>): { ok: boolean; out: string } =>
65
21
  * version loaded, so the chained sync must be a new process. */
66
22
  const chainSync = (argv0: string, args: Array<string>): Effect.Effect<void> =>
67
23
  Effect.sync(() => {
68
- const res = spawnUpdate(argv0, args, { stdio: "inherit" })
24
+ const res = spawnHost(argv0, args, { stdio: "inherit" })
69
25
  if (res.error !== undefined || res.status !== 0) process.exit(res.status ?? 1)
70
26
  })
71
27
 
@@ -95,7 +51,7 @@ type CapturePackageRoot = (
95
51
  ) => PackageRootCapture
96
52
 
97
53
  const capturePackageRoot: CapturePackageRoot = (command, args) => {
98
- const res = spawnUpdate(command, args)
54
+ const res = spawnHost(command, args)
99
55
  return {
100
56
  status: res.status,
101
57
  stdout: res.stdout ?? "",
@@ -191,7 +147,7 @@ export const packageUpdateResult = (
191
147
 
192
148
  const updateCheckout = (home: string, skipSync: boolean) =>
193
149
  Effect.gen(function* () {
194
- if (spawnUpdate("git", ["--version"], { stdio: "ignore" }).status !== 0) {
150
+ if (spawnHost("git", ["--version"], { stdio: "ignore" }).status !== 0) {
195
151
  return yield* bail("git not found - cannot update the kit checkout")
196
152
  }
197
153
  const dirty = git(home, ["status", "--porcelain"])
@@ -217,7 +173,7 @@ const updateCheckout = (home: string, skipSync: boolean) =>
217
173
 
218
174
  const touched = git(home, ["diff", "--name-only", before, after]).out.split("\n")
219
175
  if (touched.includes("bun.lock") || touched.includes("package.json")) {
220
- const res = spawnUpdate("bun", ["install", "--frozen-lockfile"], { cwd: home, stdio: "inherit" })
176
+ const res = spawnHost("bun", ["install", "--frozen-lockfile"], { cwd: home, stdio: "inherit" })
221
177
  if (res.error !== undefined || res.status !== 0) {
222
178
  return yield* bail("dependencies changed but 'bun install --frozen-lockfile' failed - fix that, then run docks-kit sync", 1)
223
179
  }
@@ -245,7 +201,7 @@ const updatePackage = (home: string, skipSync: boolean) =>
245
201
  const updateArgs = manager === "bun"
246
202
  ? ["add", "-g", "docks-kit@latest"]
247
203
  : ["install", "-g", "docks-kit@latest"]
248
- const res = spawnUpdate(manager, updateArgs, { stdio: "inherit" })
204
+ const res = spawnHost(manager, updateArgs, { stdio: "inherit" })
249
205
  if (res.error !== undefined || res.status !== 0) {
250
206
  return yield* bail(
251
207
  `global package update failed (${manager === "bun" ? "bun add -g" : "npm install -g"} docks-kit@latest)`,
@@ -3,10 +3,62 @@
3
3
  * the intended binary with deterministic argv, and capture() mirrors command
4
4
  * substitution: stdout with trailing newlines stripped, empty on failure.
5
5
  */
6
- import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process"
6
+ import {
7
+ spawn,
8
+ spawnSync,
9
+ type ChildProcess,
10
+ type SpawnOptions,
11
+ type SpawnSyncOptions,
12
+ type SpawnSyncOptionsWithStringEncoding,
13
+ type SpawnSyncReturns
14
+ } from "node:child_process"
7
15
  import { accessSync, constants, existsSync, readFileSync, statSync, writeFileSync } from "node:fs"
8
16
  import { delimiter, extname, isAbsolute, join } from "node:path"
9
- import { hostOs, type HostOs } from "./os"
17
+ import { hostOs, type HostOs, type Invocation } from "./os"
18
+
19
+ /** A tool this host cannot resolve, shaped like the failed spawn it replaces. */
20
+ const notFound = (command: string): SpawnSyncReturns<string> => ({
21
+ pid: 0,
22
+ output: [],
23
+ stdout: "",
24
+ stderr: "",
25
+ status: null,
26
+ signal: null,
27
+ error: new Error(`command not found on PATH: ${command}`)
28
+ })
29
+
30
+ /**
31
+ * Every synchronous child starts here, because two host facts must never be
32
+ * separated from the argv they describe: a Windows shim invocation is only
33
+ * correct with the verbatim-arguments flag, and a pathless name would let
34
+ * CreateProcess search the parent's current directory before the system one.
35
+ */
36
+ export const spawnHost = (
37
+ command: string,
38
+ args: ReadonlyArray<string>,
39
+ overrides: SpawnSyncOptions = {},
40
+ host: HostOs = hostOs()
41
+ ): SpawnSyncReturns<string> => {
42
+ const resolvesSuffixes = host.executableSuffixes.some((suffix) => suffix !== "")
43
+ const executablePath = resolvesSuffixes ? which(command, host.executableSuffixes) : command
44
+ if (executablePath === "") return notFound(command)
45
+ let invocation: Invocation
46
+ try {
47
+ invocation = host.invoke(executablePath, args)
48
+ } catch (cause) {
49
+ // A value this host cannot put on a command line at all. Print the encoder's
50
+ // reason and exit, matching how a caller reports a failed child.
51
+ process.stderr.write(`${cause instanceof Error ? cause.message : String(cause)}\n`)
52
+ return process.exit(2)
53
+ }
54
+ const options: SpawnSyncOptionsWithStringEncoding = {
55
+ stdio: ["ignore", "pipe", "pipe"],
56
+ ...overrides,
57
+ encoding: "utf8",
58
+ windowsVerbatimArguments: invocation.windowsVerbatimArguments
59
+ }
60
+ return spawnSync(invocation.command, [...invocation.args], options)
61
+ }
10
62
 
11
63
  /** Keep engine paths slash-separated so rendered output is host-stable. */
12
64
  export function p(...parts: Array<string>): string {
@@ -14,6 +14,21 @@ export type Harness = "claude" | "codex" | "agents" | "omp"
14
14
  export const HARNESSES: ReadonlyArray<Harness> = ["claude", "codex", "agents", "omp"]
15
15
  export const LEGACY_SELECTION: ReadonlyArray<Harness> = ["claude", "codex", "agents"]
16
16
 
17
+ export interface OmpSessionModel {
18
+ readonly selector: string
19
+ // Session ceiling; absent when the model publishes no ladder, so no
20
+ // invented level ever reaches a selector that omp must resolve.
21
+ readonly thinking?: string
22
+ // Advisor level; absent when the model publishes no ladder.
23
+ readonly advisorThinking?: string
24
+ }
25
+
26
+ export const DEFAULT_OMP_SESSION_MODEL: OmpSessionModel = {
27
+ selector: "opencode-zen/muse-spark-1.3-contributor-free",
28
+ thinking: "xhigh",
29
+ advisorThinking: "medium",
30
+ }
31
+
17
32
  function isHarness(value: unknown): value is Harness {
18
33
  return value === "claude" || value === "codex" || value === "agents" || value === "omp"
19
34
  }
@@ -36,8 +51,9 @@ export function harnessStateFile(home: string): string {
36
51
  return p(home, ".docks-kit", "state.json")
37
52
  }
38
53
 
39
- /** Read valid local state without allowing corruption to make sync unusable. */
40
- export function readHarnessSelection(home: string): ReadonlyArray<Harness> | undefined {
54
+ // Read the whole state record so one key writer keeps sibling keys intact.
55
+ // A corrupt file degrades to undefined so callers fall back to defaults.
56
+ function readWholeState(home: string): Record<string, unknown> | undefined {
41
57
  let parsed: unknown
42
58
  try {
43
59
  parsed = JSON.parse(readFileSync(harnessStateFile(home), "utf8")) as unknown
@@ -47,7 +63,30 @@ export function readHarnessSelection(home: string): ReadonlyArray<Harness> | und
47
63
 
48
64
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined
49
65
  const state = parsed as Record<string, unknown>
50
- if (state["version"] !== 1 || !Array.isArray(state["harnesses"])) return undefined
66
+ if (state["version"] !== 1) return undefined
67
+ return state
68
+ }
69
+
70
+ // Merge the patch over the stored record so independent keys never erase
71
+ // each other when only one writer runs.
72
+ function writeWholeState(home: string, patch: Record<string, unknown>): void {
73
+ const existing = readWholeState(home) ?? {}
74
+ const next = { ...existing, ...patch, version: 1 }
75
+ const directory = p(home, ".docks-kit")
76
+ const file = harnessStateFile(home)
77
+ const text = `${JSON.stringify(next, null, 2)}\n`
78
+ // `mode` applies only when mkdir creates the path, so an existing permissive
79
+ // ~/.docks-kit would keep its mode.
80
+ mkdirSync(directory, { recursive: true, mode: 0o700 })
81
+ chmodSync(directory, 0o700)
82
+ writeFileSync(file, text, { mode: 0o600 })
83
+ chmodSync(file, 0o600)
84
+ }
85
+
86
+ /** Read valid local state without allowing corruption to make sync unusable. */
87
+ export function readHarnessSelection(home: string): ReadonlyArray<Harness> | undefined {
88
+ const state = readWholeState(home)
89
+ if (state === undefined || !Array.isArray(state["harnesses"])) return undefined
51
90
 
52
91
  const selection = normalizeHarnesses(state["harnesses"])
53
92
  return selection.length > 0 ? selection : undefined
@@ -63,13 +102,55 @@ export function writeHarnessSelection(home: string, selection: ReadonlyArray<Har
63
102
  throw new Error("Harness selection must contain at least one known harness name")
64
103
  }
65
104
 
66
- const directory = p(home, ".docks-kit")
67
- const file = harnessStateFile(home)
68
- const text = `${JSON.stringify({ version: 1, harnesses }, null, 2)}\n`
69
- // `mode` applies only when mkdir creates the path, so an existing permissive
70
- // ~/.docks-kit would keep its mode.
71
- mkdirSync(directory, { recursive: true, mode: 0o700 })
72
- chmodSync(directory, 0o700)
73
- writeFileSync(file, text, { mode: 0o600 })
74
- chmodSync(file, 0o600)
105
+ writeWholeState(home, { harnesses })
106
+ }
107
+
108
+ function isNonBlankString(value: unknown): value is string {
109
+ return typeof value === "string" && value.trim() !== ""
110
+ }
111
+
112
+ // Read the stored session model without throwing so a corrupt entry falls
113
+ // back to the default instead of breaking sync.
114
+ export function readOmpSessionModel(home: string): OmpSessionModel | undefined {
115
+ const state = readWholeState(home)
116
+ if (state === undefined) return undefined
117
+ const entry = state["ompSession"]
118
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) return undefined
119
+ const record = entry as Record<string, unknown>
120
+ if (!isNonBlankString(record["selector"])) {
121
+ return undefined
122
+ }
123
+ // Each level stands alone, so a level-free model reads back with no
124
+ // levels while a half-corrupt entry keeps the valid level.
125
+ const model: { selector: string; thinking?: string; advisorThinking?: string } = {
126
+ selector: record["selector"],
127
+ }
128
+ if (isNonBlankString(record["thinking"])) {
129
+ model.thinking = record["thinking"]
130
+ }
131
+ if (isNonBlankString(record["advisorThinking"])) {
132
+ model.advisorThinking = record["advisorThinking"]
133
+ }
134
+ return model
135
+ }
136
+
137
+ export function writeOmpSessionModel(home: string, model: OmpSessionModel): void {
138
+ // A blank selector would make omp resolve an arbitrary model, so reject it
139
+ // before anything reaches disk.
140
+ if (!isNonBlankString(model.selector)) {
141
+ throw new Error("Omp session model selector must be a non-empty string")
142
+ }
143
+ // Persist only non-blank levels so a switch to a level-free model leaves
144
+ // no stale level behind in the stored record.
145
+ const entry: { selector: string; thinking?: string; advisorThinking?: string } = {
146
+ selector: model.selector,
147
+ }
148
+ if (isNonBlankString(model.thinking)) {
149
+ entry.thinking = model.thinking
150
+ }
151
+ if (isNonBlankString(model.advisorThinking)) {
152
+ entry.advisorThinking = model.advisorThinking
153
+ }
154
+
155
+ writeWholeState(home, { ompSession: entry })
75
156
  }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Run overlay for `docks-kit omp`. The overlay merges over the deployed omp
3
+ * config for one run only, so every key that can cause paid spend is set here
4
+ * and every other key falls through to the deployed paid config.
5
+ */
6
+ import { createHash } from "node:crypto"
7
+ import type { OmpSessionModel } from "./harnesses"
8
+
9
+ export interface CatalogModel {
10
+ readonly selector: string
11
+ readonly name: string
12
+ readonly thinking: ReadonlyArray<string>
13
+ readonly contextWindow: number
14
+ }
15
+
16
+ /** Thinking levels from lowest to highest effort. */
17
+ export const THINKING_LADDER: ReadonlyArray<string> = [
18
+ "minimal",
19
+ "low",
20
+ "medium",
21
+ "high",
22
+ "xhigh",
23
+ "max",
24
+ ]
25
+
26
+ /**
27
+ * One overlay file per model, so a second launcher on another model cannot
28
+ * rewrite the file a running session still reads. A selector carries a slash
29
+ * and may carry other characters a host rejects in a file name, so unsafe
30
+ * characters become hyphens. That mapping alone is not injective, because
31
+ * `a/b` and `a-b` both fold to `a-b`, so a digest of the exact selector keeps
32
+ * two distinct models on two distinct files.
33
+ */
34
+ export function overlayFileName(selector: string): string {
35
+ const safe = selector.replace(/[^A-Za-z0-9._-]/g, "-")
36
+ const digest = createHash("sha256").update(selector).digest("hex").slice(0, 8)
37
+ return `omp-free-${safe}-${digest}.yml`
38
+ }
39
+
40
+ /** Role keys in the order used by SoT/.omp/config.yml for readable diffs. */
41
+ const ROLE_ORDER: ReadonlyArray<string> = [
42
+ "smol",
43
+ "advisor",
44
+ "designer",
45
+ "plan",
46
+ "commit",
47
+ "task",
48
+ "vision",
49
+ "tiny",
50
+ "default",
51
+ "slow",
52
+ "fable",
53
+ "switch_fable",
54
+ ]
55
+
56
+ /** Retry chain keys in the order used by SoT/.omp/config.yml. */
57
+ const FALLBACK_ORDER: ReadonlyArray<string> = [
58
+ "default",
59
+ "advisor",
60
+ "task",
61
+ "vision",
62
+ "smol",
63
+ "tiny",
64
+ "commit",
65
+ "switch_fable",
66
+ "fable",
67
+ ]
68
+
69
+ /**
70
+ * Collect free models from parsed `omp models --json` output. The function is
71
+ * total because catalog output varies across omp versions, and one malformed
72
+ * row must not discard the remaining picker list.
73
+ */
74
+ export function parseFreeModels(catalogJson: unknown): ReadonlyArray<CatalogModel> {
75
+ if (typeof catalogJson !== "object" || catalogJson === null || Array.isArray(catalogJson)) {
76
+ return []
77
+ }
78
+ const models = (catalogJson as Record<string, unknown>)["models"]
79
+ if (!Array.isArray(models)) return []
80
+ const found: Array<CatalogModel> = []
81
+ for (const row of models) {
82
+ if (typeof row !== "object" || row === null || Array.isArray(row)) continue
83
+ const record = row as Record<string, unknown>
84
+ const selector = record["selector"]
85
+ if (typeof selector !== "string" || selector.length === 0) continue
86
+ const cost = record["cost"]
87
+ if (typeof cost !== "object" || cost === null || Array.isArray(cost)) continue
88
+ const charges = cost as Record<string, unknown>
89
+ if (charges["input"] !== 0 || charges["output"] !== 0) continue
90
+ const rawName = record["name"]
91
+ const name = typeof rawName === "string" && rawName.length > 0 ? rawName : selector
92
+ const rawLevels = record["thinking"]
93
+ // Keep only known ladder levels in ladder order so an unknown future
94
+ // level never reaches a role value that omp would reject.
95
+ const thinking = THINKING_LADDER.filter(
96
+ (level) => Array.isArray(rawLevels) && (rawLevels as Array<unknown>).includes(level),
97
+ )
98
+ const rawWindow = record["contextWindow"]
99
+ const contextWindow =
100
+ typeof rawWindow === "number" && Number.isFinite(rawWindow) ? rawWindow : 0
101
+ found.push({ selector, name, thinking, contextWindow })
102
+ }
103
+ // Sort by selector so the picker list is stable across catalog orderings.
104
+ found.sort((a, b) => (a.selector < b.selector ? -1 : a.selector > b.selector ? 1 : 0))
105
+ return found
106
+ }
107
+
108
+ /**
109
+ * Resolve the highest ladder level a model supports. A model without a
110
+ * recognized ladder yields undefined so callers never invent a level the
111
+ * model would reject at session start.
112
+ */
113
+ export function ladderCeiling(levels: ReadonlyArray<string>): string | undefined {
114
+ for (let index = THINKING_LADDER.length - 1; index >= 0; index -= 1) {
115
+ if (levels.includes(THINKING_LADDER[index] as string)) return THINKING_LADDER[index] as string
116
+ }
117
+ return undefined
118
+ }
119
+
120
+ /**
121
+ * Resolve the advisor level from the model own ladder. The advisor runs
122
+ * quick review passes, so it takes the highest level at or below medium,
123
+ * and the lowest level when the ladder starts above medium. The result
124
+ * never exceeds the ladder ceiling by construction.
125
+ */
126
+ export function advisorLevelFor(levels: ReadonlyArray<string>): string | undefined {
127
+ const known = THINKING_LADDER.filter((level) => levels.includes(level))
128
+ if (known.length === 0) return undefined
129
+ const mediumIndex = THINKING_LADDER.indexOf("medium")
130
+ let advisor: string | undefined
131
+ for (const level of known) {
132
+ if ((THINKING_LADDER.indexOf(level) as number) <= (mediumIndex as number)) advisor = level
133
+ }
134
+ return advisor ?? (known[0] as string)
135
+ }
136
+
137
+ /**
138
+ * Render the run overlay as YAML text. Empty fallback chains keep a retry
139
+ * from falling back onto a paid model. A model without a level renders bare
140
+ * selectors with no thinking keys so the deployed values apply.
141
+ */
142
+ export function renderFreeOverlay(model: OmpSessionModel): string {
143
+ const thinking = typeof model.thinking === "string" && model.thinking.trim() !== "" ? model.thinking : undefined
144
+ const lines: Array<string> = ["# Generated per run by docks-kit omp. Edits are overwritten."]
145
+ if (thinking !== undefined) {
146
+ lines.push(`defaultThinkingLevel: ${thinking}`, "modelRoles:")
147
+ const advisor =
148
+ typeof model.advisorThinking === "string" && model.advisorThinking.trim() !== ""
149
+ ? model.advisorThinking
150
+ : thinking
151
+ for (const role of ROLE_ORDER) {
152
+ lines.push(` ${role}: ${model.selector}:${role === "advisor" ? advisor : thinking}`)
153
+ }
154
+ lines.push("task:", ` maxEffort: ${thinking}`)
155
+ } else {
156
+ lines.push("modelRoles:")
157
+ for (const role of ROLE_ORDER) {
158
+ lines.push(` ${role}: ${model.selector}`)
159
+ }
160
+ }
161
+ lines.push("retry:", " fallbackChains:")
162
+ for (const chain of FALLBACK_ORDER) {
163
+ lines.push(` ${chain}: []`)
164
+ }
165
+ return `${lines.join("\n")}\n`
166
+ }
167
+
168
+ /** Build the omp argv with the overlay flag ahead of the forwarded args. */
169
+ export function buildOmpArgs(
170
+ overlayPath: string,
171
+ passthrough: ReadonlyArray<string>,
172
+ ): ReadonlyArray<string> {
173
+ return ["--config", overlayPath, ...passthrough]
174
+ }
@@ -1,7 +1,7 @@
1
1
  // Generated by cli/scripts/generate-sot-payload.ts. DO NOT EDIT.
2
2
  // Edit SoT/, notification.mp3, or package.json, then run: bun cli/scripts/generate-sot-payload.ts
3
3
 
4
- export const GENERATED_PACKAGE_VERSION = "0.16.17"
4
+ export const GENERATED_PACKAGE_VERSION = "0.17.0"
5
5
 
6
6
  export const GENERATED_PAYLOAD_TEXT = {
7
7
  "SoT/.agents/skills.txt": "# Universal AI-agent skill manifest intentionally empty.\n# Global skill discovery is opt-in: add one <owner>/<repo> slug per line.\n# EngineNative ignores comments and blank lines.\n",
package/cli/src/main.ts CHANGED
@@ -7,6 +7,7 @@ import { docsCommand } from "./commands/docs"
7
7
  import { harnessesCommand } from "./commands/harnesses"
8
8
  import { modelCommand } from "./commands/model"
9
9
  import { modelsCommand } from "./commands/models"
10
+ import { ompCommand } from "./commands/omp"
10
11
  import { pluginsCommand } from "./commands/plugins"
11
12
  import { skillsCommand } from "./commands/skills"
12
13
  import { statusCommand } from "./commands/status"
@@ -32,6 +33,7 @@ const root = Command.make("docks-kit", {}, () =>
32
33
  yield* Console.log(" docks-kit plugins list plugin tri-state")
33
34
  yield* Console.log(" docks-kit skills list universal skills")
34
35
  yield* Console.log(" docks-kit docs [topic] self-documentation")
36
+ yield* Console.log(" docks-kit omp [--model|--pick] [...] free-model omp session")
35
37
  yield* Console.log("")
36
38
  yield* Console.log("Run 'docks-kit --help' for full option listings (also: --wizard, --completions).")
37
39
  yield* Console.log("No-Bun recovery path: use a platform release binary.")
@@ -50,7 +52,8 @@ const root = Command.make("docks-kit", {}, () =>
50
52
  statusCommand,
51
53
  pluginsCommand,
52
54
  skillsCommand,
53
- docsCommand
55
+ docsCommand,
56
+ ompCommand
54
57
  ])
55
58
  )
56
59
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "docks-kit",
3
- "version": "0.16.17",
3
+ "version": "0.17.0",
4
4
  "description": "Portable AI coding agent config kit — SoT sync engine + typed CLI for Claude Code, Codex, and universal agent skills",
5
5
  "type": "module",
6
6
  "license": "MIT",