omkit 0.8.9 → 0.8.10

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/README.md CHANGED
@@ -1,410 +1,411 @@
1
- # omkit
2
-
3
- [![npm version](https://badge.fury.io/js/omkit.svg)](https://www.npmjs.com/package/omkit)
4
- [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
5
-
6
- **The operational-model kit.** _Humans orchestrate. Runs narrate. AI assistants follow along._
7
-
8
- A tiny runtime for the workflows _around_ your code — start servers, wait for health checks, build, watch, drive a browser, read state back, and tear it all down together. You write the orchestration as ordinary TypeScript; the run **narrates itself** into a structured, on-disk record — what launched, what came up, what attached, what failed, what the world actually looked like — that an AI assistant can read instead of guessing from terminal scrollback. Where [tskb](https://www.npmjs.com/package/tskb) is the _knowledge_ layer (what your system **is**), `omkit` is the _operational_ one (what it's **doing right now**).
9
-
10
- ## Migrating from om(name, body)
11
-
12
- `om(name, body)` was removed in 0.5.0. `om(name)` now returns a builder, and `.run(body)` takes the place of the second argument:
13
-
14
- ```diff
15
- -om("dev", async (ctx) => {
16
- +om("dev").run(async (ctx) => {
17
- // ...
18
- });
19
- ```
20
-
21
- Run folders are unaffected — a run's identity is its name plus its defining file, and line numbers are not part of the hash, so migrating a call site keeps its history.
22
-
23
- ## The problem
24
-
25
- You know the script. Start the API, start the web server, wait until they're _actually_ serving, open the app, maybe run a smoke check — then Ctrl+C and hope everything shut down. Most of us glue this together with `concurrently`, `wait-on`, a shell script, and a pile of opaque interleaved output. When it breaks, you're grepping stdout to find out which process died.
26
-
27
- Now hand that same script to an AI assistant. It has it even worse: interleaved stdout is all it can see, so it _infers_ — "the server probably started", "the build likely passed" — and acts on guesses. The productivity you wanted from the assistant leaks away into re-running things, misreading logs, and asserting success that never happened.
28
-
29
- `omkit` models the workflow instead. Each step is a typed **action** in one run. Actions don't share globals — they hand each other typed **capabilities** (a port, a client, a live browser page), emit typed **events**, and land on **one log**. Bring the whole thing down together, and get a structured record of what happened — one a human can skim and an assistant can verify against.
30
-
31
- ```ts
32
- import { om } from "omkit";
33
- import { command, healthcheck, browser, chromePage } from "omkit/actions";
34
-
35
- om("dev").run(async () => {
36
- // `command` names the action after the command and launches it — a normal action call.
37
- command("npm run dev", { cwd: "api" }).tag("api"); // start both dev servers as daemons…
38
- command("npm run dev", { cwd: "web" }).tag("web");
39
-
40
- // …wait until the app actually serves (not just "process started")…
41
- try {
42
- await healthcheck({ url: "http://localhost:3000" }).result;
43
- } catch {
44
- return; // never came up — the servers stay up so you can debug them
45
- }
46
-
47
- // …then launch a real browser and hand its live page downstream — typed, not scraped.
48
- const chrome = browser({ headless: false }); // a visible Chromium, closed on teardown
49
- const page = chromePage("app", await chrome.ref, { url: "http://localhost:3000" });
50
- console.log(`opened ${(await page.ref).url()}`);
51
-
52
- console.log("stack is up — press Ctrl+C to stop");
53
- });
54
- ```
55
-
56
- `page.ref` isn't a URL scraped from stdout — it's the live Playwright `Page`, typed, that the next step drives directly. Few workflow tools pass anything but strings between steps.
57
-
58
- ```
59
- om()
60
- │
61
- ┌───────────┬────┴──────┬──────────────────┐
62
- ▼ ▼ ▼ ▼
63
- command healthcheck browser ──ref──▶ chromePage
64
- api · web (gate) (launch) .ref → live Page
65
- (daemons) │
66
- ▼
67
- your smoke test
68
- ```
69
-
70
- ## Who it's for
71
-
72
- - **Humans orchestrate.** Pipelines are ordinary TypeScript — `await`, `if`, loops, variables — not YAML, not a DSL. Define an action once; launch it by calling it.
73
- - **AI assistants follow runs.** The run folder (`result.json`, `raw.jsonl`, per-action logs, snapshots) is a machine-readable account of what really happened — an assistant can verify outcomes, cite evidence, and pick up where a run left off.
74
- - **Together, faster.** You express intent; the run records ground truth; the assistant acts on the record instead of re-running things to guess. Fewer cycles lost to "did that actually work?".
75
-
76
- ## Core concepts
77
-
78
- ### Actions & Activities
79
-
80
- An **Action** is a named, typed description of work. **Calling it launches** it and returns a live **Activity** — the handle you configure (`withCache`, `tag`) and observe (`result`, `ref`, events).
81
-
82
- ```ts
83
- import { action } from "omkit";
84
-
85
- const build = action("build").run(({ proc }) => proc("tsc")`tsc -b`);
86
-
87
- const activity = build(); // calling launches; returns the live Activity
88
- ```
89
-
90
- Chain `.describe({ summary })` before `.run(...)` to attach a human-readable summary — stored on the definition, for `omkit ls` and future tooling to read; nothing reads it yet, so today it is documentation that travels with the code. Same shape as `om`'s, below. `.args(schema)` pins the type of `.run`'s second parameter to a zod schema's inferred type. In code it's **type-level only**: launching neither resolves, prompts for, nor validates the value — an action launched from an om body is passed its arguments directly in code, so the schema types that call site rather than gating it. The exception is an action run as an MCP tool: the JSON an assistant sends is resolved through this same schema before the body runs, so `.default()`s are filled and bad values are refused — the default `list_oms` advertises is the one the body gets. (Prompting for arguments stays an `om`-level concern.)
91
-
92
- ```ts
93
- import { z } from "zod";
94
-
95
- const seed = action("seed")
96
- .describe({ summary: "Seeds the database" })
97
- .args(z.object({ rows: z.number() }))
98
- .run(async (_ctx, { rows }) => {
99
- /* rows: number — typed; validated only when run as an MCP tool */
100
- });
101
-
102
- seed({ rows: 500 }); // calling launches it; the shape is pinned, not checked at runtime
103
- ```
104
-
105
- `om(name).run(async (ctx) => …)` hosts the orchestration as the root of a run. `om(name)` returns a builder: chain `.describe({ summary })` to attach a human-readable summary (stored on the run; nothing reads it yet) and/or `.args(schema)` to declare what the run needs, then finish with `.run(body)`, which launches it. The name plus the file it's defined in identify the run — logs land in `logs/<name>-<hash8>/…`, so same-named oms in different files never share a folder. You write ordinary `await` / `if` / loops / variables; the Activities you launch keep running in parallel, and because the body stays in-flight while you `await`, the run never idles shut between steps. Config chained synchronously on an Activity right after launching it (like `withCache`) applies before its body runs — the body commits one microtask later, so chain it in the same tick, before you `await`. For a one-off inline step, `step(name, fn)` runs `fn` as its own node without a reusable definition.
106
-
107
- ### Run arguments — `om(name).args(schema)`
108
-
109
- An om declares what it needs, and omkit fills it in. Unlike `action`'s `.args()`, this one is **resolved at runtime**: `.run`'s body receives the resolved, validated values as its second parameter, typed by the schema.
110
-
111
- ```ts
112
- import { z } from "zod";
113
-
114
- om("seed")
115
- .args(z.object({ rows: z.number(), truncate: z.boolean().default(false) }))
116
- .run(async (ctx, args) => {
117
- // args: { rows: number; truncate: boolean } — already resolved and validated
118
- await command(`./seed.sh --rows ${args.rows}`).result;
119
- });
120
- ```
121
-
122
- Each field is filled from the first of these that can answer:
123
-
124
- 1. **Supplied** — `OMKIT_ARGS`, a JSON object in the environment. It is read once at run start and then cleared, so a subprocess the run spawns doesn't inherit one om's args and resolve them against another's schema.
125
- 2. **Defaults** — anything the schema defaults, which is therefore never asked about.
126
- 3. **Prompt** — whatever is still missing, one field at a time, with a one-line type sketch (`rows (number)`). Objects and arrays are asked for as a multiline JSON block — paste it, or answer with the path to a JSON file. A blank answer is _no answer_, not a value: it re-asks rather than coercing (`Number("")` is `0`). Resolution gives up after three rounds.
127
- 4. **Fail** — if nobody can be asked (no supervising picker and no TTY), the run fails naming every unresolved field at once, instead of prompting into the void.
128
-
129
- Resolution happens **inside** the run, so a prompt and its answer land on the run's own timeline, and the values it settled on are recorded in `result.json` and the `main.log` header — a later reader can see exactly what the run was given.
130
-
131
- ### Typed capabilities
132
-
133
- An action can `attach` a value — a port, a client, a page — that downstream actions receive by awaiting `activity.ref`. Not a file descriptor scraped from a log: a typed runtime handoff, so steps chain without globals or string-parsing. The first `attach` wins; later ones are ignored. Unlike `.result`, `.ref` **rejects** on failure or cancellation — a consumer waiting on a capability that never arrives fails instead of hanging — and an action that publishes nothing still resolves its `.ref` with `undefined` on success, so awaiting it never wedges.
134
-
135
- ```ts
136
- const server = action("server")
137
- .ref<number>() // this action publishes a port
138
- .run(async ({ attach, signal }) => {
139
- const port = await listen(); // start listening…
140
- attach(port); // …publish the port to downstream actions…
141
- await until(signal); // …and stay up until teardown
142
- });
143
-
144
- const migrate = action("migrate").run((_ctx, port: Promise<number>) => runMigrations(port));
145
-
146
- om("migrate").run(async () => {
147
- const s = server();
148
- migrate(s.ref); // migrate receives the port the moment the server attaches it — typed
149
- });
150
- ```
151
-
152
- ### Results — one promise that rejects on failure
153
-
154
- An Activity's **`.result`** resolves the value on success and **rejects** on failure — the same reject-on-failure shape as `.ref` and `.once`. A failure you want to branch on is a `try/catch`; one you want to note without stopping is a `.catch`. Reading `.result` also _observes_ the Activity (see teardown), so a handled failure doesn't tear the run down:
155
-
156
- ```ts
157
- try {
158
- await command("npm test").result; // resolves on green, throws on red
159
- } catch (err) {
160
- // red tests are an expected outcome here — decide what to do
161
- }
162
-
163
- // …or handle it inline, without stopping:
164
- await command("npm test").result.catch((err) => report(err));
165
- ```
166
-
167
- A cancelled Activity rejects with `CancelledError` — `isCancelled(err)` tells an intentional stop apart from a real fault.
168
-
169
- ### Failure & teardown
170
-
171
- omkit uses **structured supervision**: a failure that **nobody is watching** tears the whole run down and marks it failed. An Activity is "watched" if — before it fails — you awaited its `.result`/`.ref`/`.once`, attached a `.result.catch`, or added an `on("error")` listener.
172
-
173
- - **Await it** (`await task().result`) → a failure throws at the `await`, in your body; wrap it in `try/catch` to branch. The run stays green because you observed it.
174
- - **Fire-and-forget it** (`task()`, never awaited) → an unobserved crash tears the run down. This is the guardrail for daemons: a dev server that dies fails the run instead of leaving it wedged.
175
- - **`.result.catch(fn)`** → own a fire-and-forget Activity's failure: reading `.result` observes it, and `fn(error)` runs instead of tearing the run down (it also fires on cancel — guard with `isCancelled` if that matters).
176
-
177
- ```ts
178
- command("npm run dev", { cwd: "web" }).tag("web"); // if it crashes, the run tears down — you'll know
179
- flakyBackgroundJob().result.catch((e) => console.warn("job failed, carrying on", e));
180
- ```
181
-
182
- **Ctrl+C** and **`ctx.cancel()`** tear everything down gracefully — procs are killed, waits unblock, the log is always written. And **success is keep-alive**: when your `om` body returns, the daemons it started keep running until Ctrl+C or `cancel()`, so wiring things up doesn't kill the servers you just started.
183
-
184
- ### One log, on disk — a run an assistant can follow
185
-
186
- Every action's output, events, asserts, snapshots, and lifecycle land on **one timeline** — streamed live to the terminal (curated milestones) and written to a per-run folder:
187
-
188
- - `result.json` — the run's tree, per-node status, verdict, and curated artifacts.
189
- - `raw.jsonl` — every entry, machine-readable.
190
- - `main.log` + one `.log` per action — the human-readable timelines.
191
- - `events.log` / `asserts.log` / `snapshots.log` / `artifacts.log` — cross-cutting rollups.
192
-
193
- `ctx` also gives each action `assert(cond, msg)` (tallies into the run's verdict), `snapshot(name, value)` (captures JSON state to the run folder), and `artifact(name, file, opts?)` (labels a file the action wrote — name, optional description, MIME inferred from the extension — and returns its absolute path) — so a run is an inspectable artifact, not just an exit code. Write the files you label under `ctx.artifactsFolder`, the run's own output folder.
194
-
195
- Run folders have a **stable identity**: `logs/<name>-<hash8>/<date>/<time>/`, keyed by the om's name and the file that defines it, under the project that owns the config — not under whichever directory the run happened to start in. An assistant (or a script) can always find "the latest `tskb-dev` run" without parsing scrollback, diff two runs of the same pipeline, or answer questions with evidence instead of inference: _did the server actually pass its healthcheck? which process died first? what config did the build run with?_ It's all in the folder — attributed per action, timestamped, with a verdict.
196
-
197
- ### Observing the real world, not assuming it
198
-
199
- The batteries are built around **ground truth**. `healthcheck` gates on the service actually answering, not on "process started". `chromePage` hands downstream steps a live browser page — real DOM, real network — so a smoke check inspects the app a user would see. `snapshot` freezes the inputs and state a run saw, and `assert` turns observations into a tallied verdict. For a human, that means less babysitting; for an AI assistant, it means the run folder **is** its view of the world — it follows what happened rather than imagining it.
200
-
201
- ## Common recipes
202
-
203
- **Skip work that's already done.** `withCache` fingerprints inputs and skips the body when nothing changed since the last successful run (a hit resolves `undefined`):
204
-
205
- ```ts
206
- const build = action("build").run(({ proc }) => proc("tsc")`tsc -b`);
207
-
208
- om("build").run(async () => {
209
- const out = await build().withCache(`${process.cwd()}/src`).result;
210
- if (out === undefined) console.log("no changes — skipped the build");
211
- });
212
- ```
213
-
214
- **Watch and react.** Launch a watcher, subscribe to its events, and let the run stay alive:
215
-
216
- ```ts
217
- import { watchDir } from "omkit/actions";
218
-
219
- om("watch").run(async () => {
220
- const watcher = watchDir("src").tag("watch");
221
- watcher.on("update", (file) => console.log(`changed: ${file}`));
222
- // body returns, but the watcher keeps the run alive until Ctrl+C
223
- });
224
- ```
225
-
226
- **Gate a deploy on green tests.** A red test rejects at the `await`; catch it to branch:
227
-
228
- ```ts
229
- om("ship").run(async () => {
230
- try {
231
- await command("npm test").result; // resolves on green, throws on red
232
- } catch {
233
- return; // red → stop; the run stays green because you observed the failure
234
- }
235
- await command("./deploy.sh").result;
236
- });
237
- ```
238
-
239
- **Keep a human in the loop.** `prompt` asks the terminal and falls back to a default when unattended, so it never blocks CI or an agent-driven run:
240
-
241
- ```ts
242
- import { prompt } from "omkit/actions";
243
-
244
- om("deploy").run(async () => {
245
- const answer = await prompt({
246
- kind: "choice",
247
- message: "Deploy to production?",
248
- choices: ["no", "yes"],
249
- default: "no",
250
- timeoutMs: 10_000, // no answer in 10s → "no"
251
- }).result;
252
- if (answer === "yes") await command("./deploy.sh").result;
253
- });
254
- ```
255
-
256
- **Take a block of text.** `kind: "multiline"` reads lines until `until` says stop: parseable JSON (the default), a sentinel line, or a predicate over the text so far. The JSON default is self-terminating, so a pretty-printed blob can be pasted straight in with nothing to explain:
257
-
258
- ```ts
259
- om("seed").run(async () => {
260
- const blob = await prompt({
261
- kind: "multiline",
262
- message: "Paste the service config",
263
- hint: "{ host: string; port: number }", // a one-line type sketch, shown under the message
264
- until: "json", // the default; or "." to end on that line, or (text) => text.length > 500
265
- }).result;
266
- const config = JSON.parse(blob);
267
- await command(`./seed.sh --host ${config.host}`).result;
268
- });
269
- ```
270
-
271
- ## Events
272
-
273
- An Activity exposes its declared events plus the lifecycle ones (`done`, `error`, `attached`):
274
-
275
- - **`activity.on(event, handler)`** — subscribe to every emit.
276
- - **`activity.once(event)`** — a **promise** for the next emit. Resolves the payload when it fires; if the Activity settles first it resolves `undefined` on success and **rejects** on failure/cancel — so a gate on an event surfaces a failure instead of hanging. A retained snapshot (like `healthy`) is replayed immediately even if you subscribe late.
277
- - **`activity.once("done")`** is the outcome itself — identical to `.result` (resolve the value, reject on failure). `await step().once("done")` and `await step().result` are the same hard gate.
278
-
279
- ```ts
280
- const probe = healthcheck({ url: "http://localhost:3000" });
281
- probe.on("healthy", (r) => console.log(`up after ${r.attempts} tries`));
282
- ```
283
-
284
- ## Batteries — `omkit/actions`
285
-
286
- Reusable actions built on the core engine. For anything beyond these, drop down to `action(...).run(...)`.
287
-
288
- - **`command`** — run a shell command (or, with `args`, an executable) as an action named after it; output streams to the log, killed on teardown.
289
- - **`watch`** / **`watchDir`** — watch a file or directory for changes (survives wipe + recreate).
290
- - **`untilLog`** — gate on the first log entry matching a predicate.
291
- - **`tailLog`** — tail a file another process writes, folding its lines into the combined log.
292
- - **`healthcheck`** — poll a URL/port until the status (and optionally body) matches.
293
- - **`portFree`** — the mirror of `healthcheck`: poll a TCP port until nothing is listening, so a restart can rebind without racing the old process.
294
- - **`prompt`** — ask the terminal for input, a choice, or a multiline block, with a timeout that falls back to a default; the answer is its handle.
295
- - **`browser`** — launch a Chromium browser with Playwright and expose the live `Browser` as a handle (defaults to the installed Chrome); closed on teardown. Feed its `.ref` to `chromePage`.
296
- - **`chromePage`** — attach to Chrome over CDP, or to an existing Playwright `Page`/`Browser`/`Context` (including a `browser` handle or an Electron window), and expose the live `Page` as a handle.
297
-
298
- ## CLI
299
-
300
- Point the `omkit` bin at a project — a `tsconfig.omkit.json` that lists your `oms/` and `actions/` — and it discovers, typechecks, and runs them. (Override the config path with `--tsconfig`.)
301
-
302
- - **`omkit init`** — scaffold a starter project: `tsconfig.omkit.json`, a sample `oms/dev.ts`, and `actions/hello.ts`.
303
- - **`omkit run`** — with no argument, open the interactive picker: browse and search your oms, run one, and watch its milestones stream live — answering any `prompt` in-console. A bare `omkit` does the same (`run` is the default command).
304
- - **`omkit run <om>`** — run one om directly by name or file path, inheriting the terminal.
305
- - **`omkit ls`** — list the discovered oms and actions. Add `--describe` to show each one's `.describe({ summary })` and whether it's exposed; without it, `ls` is a static scan that never imports your code.
306
- - **`omkit check`** — typecheck the project (`tsc --noEmit`) and report diagnostics.
307
- - **`omkit skill`** — generate `.claude/skills/omkit-runs/SKILL.md`, a map of what's runnable (see below).
308
- - **`omkit mcp`** — serve the project over the Model Context Protocol on stdio (see below).
309
- - **`omkit help`** — print the command overview (also `--help` / `-h`).
310
-
311
- Both paths run the om in its own child process and narrate it to a run folder (see above). The picker **supervises** the child — piping its output into the live view and answering prompts for it — while `omkit run <om>` runs it **bare**, handing the om your terminal directly.
312
-
313
- ## MCP — let an assistant drive your oms
314
-
315
- `omkit mcp` serves a project over the [Model Context Protocol](https://modelcontextprotocol.io), so Claude and other assistants can discover your oms, run one, and read what the run produced — instead of shelling out and scraping stdout.
316
-
317
- **Nothing is exposed unless you say so.** Add `.mcp()` to an om or action; without it the server neither lists nor runs it. `.describe()` and `.args()` alone expose nothing.
318
-
319
- ```ts
320
- om("smoke-test")
321
- .describe({ summary: "Boot the app and run the smoke suite" })
322
- .args(z.object({ headless: z.boolean().default(true) }))
323
- .mcp() // ← now an assistant can run it
324
- .run(async (ctx, { headless }) => {
325
- /* … */
326
- });
327
- ```
328
-
329
- `mode` defaults to `"settling"` — a run that finishes on its own. Declare `.mcp({ mode: "long-lived" })` for a server or a watcher: MCP has no way to express "this never finishes", so omkit does. `run_om` refuses a long-lived entry and points you at `start_om`.
330
-
331
- Point your client at the project:
332
-
333
- ```json
334
- {
335
- "mcpServers": {
336
- "omkit": {
337
- "command": "npx",
338
- "args": ["--no", "--", "omkit", "mcp"]
339
- }
340
- }
341
- }
342
- ```
343
-
344
- The server takes its **project root from the config it was given** — the directory holding your `tsconfig.omkit.json` — and resolves `logs/` under it, so the runs it starts are the runs its resources can serve regardless of which directory your client launched it in. `omkit run` uses the same root, so a run you start in the terminal and a run an assistant starts land in one tree and each can read the other's record. If the om project sits in a subfolder, name the config rather than relying on a `cwd` field your client may not support: `["--no", "--", "omkit", "mcp", "--tsconfig", "om/tsconfig.omkit.json"]`.
345
-
346
- ### Inspecting it
347
-
348
- The [MCP Inspector](https://github.com/modelcontextprotocol/inspector) drives the same binary, so you can exercise every tool by hand before pointing an assistant at it:
349
-
350
- ```sh
351
- npx -y @modelcontextprotocol/inspector npx --no -- omkit mcp
352
- ```
353
-
354
- It serves a UI on `localhost:6274` and prints an auth token to paste in. Because omkit declares real input schemas, you get form fields per tool — and the history pane shows the full request and response for each call, `structuredContent` and resource links included. That is usually all the debugging you need: it is a client, so it shows you what _you_ sent, not what an assistant sent.
355
-
356
- Two things to know. On Windows the Inspector cannot spawn a `.cmd` shim directly, so if `npx` fails there, point it at the binary instead — `node node_modules/omkit/dist/cli/index.js mcp`. And the Inspector owns the runs it starts: closing it tears down anything `start_om` left running, the same rule that applies to any client.
357
-
358
- **Six tools**, however many oms you have — adding one changes what `list_oms` returns, never the tool list:
359
-
360
- | Tool | What it does |
361
- | ----------------------- | ------------------------------------------------------------------------------- |
362
- | `list_oms` | Exposed oms and actions: summary, mode, and the JSON Schema for their arguments |
363
- | `run_om(name, args)` | Runs a settling entry to completion and returns its verdict and assert tally |
364
- | `start_om(name, args)` | Starts anything and returns a handle immediately |
365
- | `get_run(run)` | Status, verdict once settled, and links to the files the run produced |
366
- | `tail_run(run, cursor)` | Log lines since `cursor` — live while running, from `raw.jsonl` once settled |
367
- | `cancel_run(runId)` | Tears a run down; it still finishes writing its run folder |
368
-
369
- Call `list_oms` first — `run_om`'s `args` is a plain object, so its schema comes from the listing rather than from the tool definition. That is the cost of a tool list that doesn't churn every time you edit a file.
370
-
371
- **Run folders are resources.** `omkit://runs/{run}/latest/{file}` reads from the newest run of an om, and `omkit://runs/{run}/{date}/{time}/{file}` from one specific run. Reads are confined under the project's `logs/`, size-capped, and read-only. Files a run labelled with `ctx.artifact` come back named and described.
372
-
373
- **While a run is going**, milestones arrive as progress notifications, cancelling the request tears the run down (and it still writes its record), and a `prompt` raised mid-run reaches you as an elicitation — the client is the supervisor.
374
-
375
- ## A skill file — the map, before the first call
376
-
377
- `list_oms` answers "what can I run here?" at runtime, but charges a round trip for it: an assistant has to call it before it can build arguments for anything. `omkit skill` writes the same answer to disk, where it's already in context:
378
-
379
- ```sh
380
- omkit skill # writes .claude/skills/omkit-runs/SKILL.md
381
- omkit skill --check # exits non-zero when it no longer matches the project
382
- ```
383
-
384
- Each exposed workflow gets its summary, its argument shape, the path to its source, and an outline of what it calls — read statically from the body, with the `.tag("…")` names you already wrote:
385
-
386
- ```
387
- ### `tskb:build` — settling
388
-
389
- Rebuild the tskb knowledge graph from this repo's .tskb.tsx docs.
390
-
391
- - **Defined in:** `om/oms/tskb-build.ts`
392
- - **Args:** `{ verbose?: boolean, projectName?: string }`
393
- - **Calls:** `watchDir` [watch:build:daemon] → `buildDocs` [build]
394
- ```
395
-
396
- Generating it runs nothing — it uses the same discovery fork as `list_oms`, where `.run(body)` reports what it declares instead of launching. The file documents the shell invocation as well as the MCP one, so it's useful with no MCP wiring at all.
397
-
398
- It's meant to be committed. A `registry-hash` in the header covers the project's declarations rather than the rendered markdown, so `--check` catches an edited om but not a reformatted file — put it in a pre-commit hook or CI. The one field you should edit by hand is the frontmatter `description`, which decides when an assistant loads the file; regeneration preserves it.
399
-
400
- ## Install
401
-
402
- ```sh
403
- npm install omkit
404
- ```
405
-
406
- Requires Node ≥ 20.11. ESM only.
407
-
408
- ## License
409
-
410
- MIT
1
+ # omkit
2
+
3
+ [![npm version](https://badge.fury.io/js/omkit.svg)](https://www.npmjs.com/package/omkit)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
5
+
6
+ **The operational-model kit.** _Humans orchestrate. Runs narrate. AI assistants follow along._
7
+
8
+ A tiny runtime for the workflows _around_ your code — start servers, wait for health checks, build, watch, drive a browser, read state back, and tear it all down together. You write the orchestration as ordinary TypeScript; the run **narrates itself** into a structured, on-disk record — what launched, what came up, what attached, what failed, what the world actually looked like — that an AI assistant can read instead of guessing from terminal scrollback. Where [tskb](https://www.npmjs.com/package/tskb) is the _knowledge_ layer (what your system **is**), `omkit` is the _operational_ one (what it's **doing right now**).
9
+
10
+ ## Migrating from om(name, body)
11
+
12
+ `om(name, body)` was removed in 0.5.0. `om(name)` now returns a builder, and `.run(body)` takes the place of the second argument:
13
+
14
+ ```diff
15
+ -om("dev", async (ctx) => {
16
+ +om("dev").run(async (ctx) => {
17
+ // ...
18
+ });
19
+ ```
20
+
21
+ Run folders are unaffected — a run's identity is its name plus its defining file, and line numbers are not part of the hash, so migrating a call site keeps its history.
22
+
23
+ ## The problem
24
+
25
+ You know the script. Start the API, start the web server, wait until they're _actually_ serving, open the app, maybe run a smoke check — then Ctrl+C and hope everything shut down. Most of us glue this together with `concurrently`, `wait-on`, a shell script, and a pile of opaque interleaved output. When it breaks, you're grepping stdout to find out which process died.
26
+
27
+ Now hand that same script to an AI assistant. It has it even worse: interleaved stdout is all it can see, so it _infers_ — "the server probably started", "the build likely passed" — and acts on guesses. The productivity you wanted from the assistant leaks away into re-running things, misreading logs, and asserting success that never happened.
28
+
29
+ `omkit` models the workflow instead. Each step is a typed **action** in one run. Actions don't share globals — they hand each other typed **capabilities** (a port, a client, a live browser page), emit typed **events**, and land on **one log**. Bring the whole thing down together, and get a structured record of what happened — one a human can skim and an assistant can verify against.
30
+
31
+ ```ts
32
+ import { om } from "omkit";
33
+ import { command, healthcheck, browser, chromePage } from "omkit/actions";
34
+
35
+ om("dev").run(async () => {
36
+ // `command` names the action after the command and launches it — a normal action call.
37
+ command("npm run dev", { cwd: "api" }).tag("api"); // start both dev servers as daemons…
38
+ command("npm run dev", { cwd: "web" }).tag("web");
39
+
40
+ // …wait until the app actually serves (not just "process started")…
41
+ try {
42
+ await healthcheck({ url: "http://localhost:3000" }).result;
43
+ } catch {
44
+ return; // never came up — the servers stay up so you can debug them
45
+ }
46
+
47
+ // …then launch a real browser and hand its live page downstream — typed, not scraped.
48
+ const chrome = browser({ headless: false }); // a visible Chromium, closed on teardown
49
+ const page = chromePage("app", await chrome.ref, { url: "http://localhost:3000" });
50
+ console.log(`opened ${(await page.ref).url()}`);
51
+
52
+ console.log("stack is up — press Ctrl+C to stop");
53
+ });
54
+ ```
55
+
56
+ `page.ref` isn't a URL scraped from stdout — it's the live Playwright `Page`, typed, that the next step drives directly. Few workflow tools pass anything but strings between steps.
57
+
58
+ ```
59
+ om()
60
+ │
61
+ ┌───────────┬────┴──────┬──────────────────┐
62
+ ▼ ▼ ▼ ▼
63
+ command healthcheck browser ──ref──▶ chromePage
64
+ api · web (gate) (launch) .ref → live Page
65
+ (daemons) │
66
+ ▼
67
+ your smoke test
68
+ ```
69
+
70
+ ## Who it's for
71
+
72
+ - **Humans orchestrate.** Pipelines are ordinary TypeScript — `await`, `if`, loops, variables — not YAML, not a DSL. Define an action once; launch it by calling it.
73
+ - **AI assistants follow runs.** The run folder (`result.json`, `raw.jsonl`, per-action logs, snapshots) is a machine-readable account of what really happened — an assistant can verify outcomes, cite evidence, and pick up where a run left off.
74
+ - **Together, faster.** You express intent; the run records ground truth; the assistant acts on the record instead of re-running things to guess. Fewer cycles lost to "did that actually work?".
75
+
76
+ ## Core concepts
77
+
78
+ ### Actions & Activities
79
+
80
+ An **Action** is a named, typed description of work. **Calling it launches** it and returns a live **Activity** — the handle you configure (`withCache`, `tag`) and observe (`result`, `ref`, events).
81
+
82
+ ```ts
83
+ import { action } from "omkit";
84
+
85
+ const build = action("build").run(({ proc }) => proc("tsc")`tsc -b`);
86
+
87
+ const activity = build(); // calling launches; returns the live Activity
88
+ ```
89
+
90
+ Chain `.describe({ summary })` before `.run(...)` to attach a human-readable summary — stored on the definition, for `omkit ls` and future tooling to read; nothing reads it yet, so today it is documentation that travels with the code. Same shape as `om`'s, below. `.args(schema)` pins the type of `.run`'s second parameter to a zod schema's inferred type. In code it's **type-level only**: launching neither resolves, prompts for, nor validates the value — an action launched from an om body is passed its arguments directly in code, so the schema types that call site rather than gating it. The exception is an action run as an MCP tool: the JSON an assistant sends is resolved through this same schema before the body runs, so `.default()`s are filled and bad values are refused — the default `list_oms` advertises is the one the body gets. (Prompting for arguments stays an `om`-level concern.)
91
+
92
+ ```ts
93
+ import { z } from "zod";
94
+
95
+ const seed = action("seed")
96
+ .describe({ summary: "Seeds the database" })
97
+ .args(z.object({ rows: z.number() }))
98
+ .run(async (_ctx, { rows }) => {
99
+ /* rows: number — typed; validated only when run as an MCP tool */
100
+ });
101
+
102
+ seed({ rows: 500 }); // calling launches it; the shape is pinned, not checked at runtime
103
+ ```
104
+
105
+ `om(name).run(async (ctx) => …)` hosts the orchestration as the root of a run. `om(name)` returns a builder: chain `.describe({ summary })` to attach a human-readable summary (stored on the run; nothing reads it yet) and/or `.args(schema)` to declare what the run needs, then finish with `.run(body)`, which launches it. The name plus the file it's defined in identify the run — logs land in `logs/<name>-<hash8>/…`, so same-named oms in different files never share a folder. You write ordinary `await` / `if` / loops / variables; the Activities you launch keep running in parallel, and because the body stays in-flight while you `await`, the run never idles shut between steps. Config chained synchronously on an Activity right after launching it (like `withCache`) applies before its body runs — the body commits one microtask later, so chain it in the same tick, before you `await`. For a one-off inline step, `step(name, fn)` runs `fn` as its own node without a reusable definition.
106
+
107
+ ### Run arguments — `om(name).args(schema)`
108
+
109
+ An om declares what it needs, and omkit fills it in. Unlike `action`'s `.args()`, this one is **resolved at runtime**: `.run`'s body receives the resolved, validated values as its second parameter, typed by the schema.
110
+
111
+ ```ts
112
+ import { z } from "zod";
113
+
114
+ om("seed")
115
+ .args(z.object({ rows: z.number(), truncate: z.boolean().default(false) }))
116
+ .run(async (ctx, args) => {
117
+ // args: { rows: number; truncate: boolean } — already resolved and validated
118
+ await command(`./seed.sh --rows ${args.rows}`).result;
119
+ });
120
+ ```
121
+
122
+ Each field is filled from the first of these that can answer:
123
+
124
+ 1. **Supplied** — `OMKIT_ARGS`, a JSON object in the environment. It is read once at run start and then cleared, so a subprocess the run spawns doesn't inherit one om's args and resolve them against another's schema.
125
+ 2. **Defaults** — anything the schema defaults, which is therefore never asked about.
126
+ 3. **Prompt** — whatever is still missing, one field at a time, with a one-line type sketch (`rows (number)`). Objects and arrays are asked for as a multiline JSON block — paste it, or answer with the path to a JSON file. A blank answer is _no answer_, not a value: it re-asks rather than coercing (`Number("")` is `0`). Resolution gives up after three rounds.
127
+ 4. **Fail** — if nobody can be asked (no supervising picker and no TTY), the run fails naming every unresolved field at once, instead of prompting into the void.
128
+
129
+ Resolution happens **inside** the run, so a prompt and its answer land on the run's own timeline, and the values it settled on are recorded in `result.json` and the `main.log` header — a later reader can see exactly what the run was given.
130
+
131
+ ### Typed capabilities
132
+
133
+ An action can `attach` a value — a port, a client, a page — that downstream actions receive by awaiting `activity.ref`. Not a file descriptor scraped from a log: a typed runtime handoff, so steps chain without globals or string-parsing. The first `attach` wins; later ones are ignored. Unlike `.result`, `.ref` **rejects** on failure or cancellation — a consumer waiting on a capability that never arrives fails instead of hanging — and an action that publishes nothing still resolves its `.ref` with `undefined` on success, so awaiting it never wedges.
134
+
135
+ ```ts
136
+ const server = action("server")
137
+ .ref<number>() // this action publishes a port
138
+ .run(async ({ attach, signal }) => {
139
+ const port = await listen(); // start listening…
140
+ attach(port); // …publish the port to downstream actions…
141
+ await until(signal); // …and stay up until teardown
142
+ });
143
+
144
+ const migrate = action("migrate").run((_ctx, port: Promise<number>) => runMigrations(port));
145
+
146
+ om("migrate").run(async () => {
147
+ const s = server();
148
+ migrate(s.ref); // migrate receives the port the moment the server attaches it — typed
149
+ });
150
+ ```
151
+
152
+ ### Results — one promise that rejects on failure
153
+
154
+ An Activity's **`.result`** resolves the value on success and **rejects** on failure — the same reject-on-failure shape as `.ref` and `.once`. A failure you want to branch on is a `try/catch`; one you want to note without stopping is a `.catch`. Reading `.result` also _observes_ the Activity (see teardown), so a handled failure doesn't tear the run down:
155
+
156
+ ```ts
157
+ try {
158
+ await command("npm test").result; // resolves on green, throws on red
159
+ } catch (err) {
160
+ // red tests are an expected outcome here — decide what to do
161
+ }
162
+
163
+ // …or handle it inline, without stopping:
164
+ await command("npm test").result.catch((err) => report(err));
165
+ ```
166
+
167
+ A cancelled Activity rejects with `CancelledError` — `isCancelled(err)` tells an intentional stop apart from a real fault.
168
+
169
+ ### Failure & teardown
170
+
171
+ omkit uses **structured supervision**: a failure that **nobody is watching** tears the whole run down and marks it failed. An Activity is "watched" if — before it fails — you awaited its `.result`/`.ref`/`.once`, attached a `.result.catch`, or added an `on("error")` listener.
172
+
173
+ - **Await it** (`await task().result`) → a failure throws at the `await`, in your body; wrap it in `try/catch` to branch. The run stays green because you observed it.
174
+ - **Fire-and-forget it** (`task()`, never awaited) → an unobserved crash tears the run down. This is the guardrail for daemons: a dev server that dies fails the run instead of leaving it wedged.
175
+ - **`.result.catch(fn)`** → own a fire-and-forget Activity's failure: reading `.result` observes it, and `fn(error)` runs instead of tearing the run down (it also fires on cancel — guard with `isCancelled` if that matters).
176
+
177
+ ```ts
178
+ command("npm run dev", { cwd: "web" }).tag("web"); // if it crashes, the run tears down — you'll know
179
+ flakyBackgroundJob().result.catch((e) => console.warn("job failed, carrying on", e));
180
+ ```
181
+
182
+ **Ctrl+C** and **`ctx.cancel()`** tear everything down gracefully — procs are killed, waits unblock, the log is always written. And **success is keep-alive**: when your `om` body returns, the daemons it started keep running until Ctrl+C or `cancel()`, so wiring things up doesn't kill the servers you just started.
183
+
184
+ ### One log, on disk — a run an assistant can follow
185
+
186
+ Every action's output, events, asserts, snapshots, and lifecycle land on **one timeline** — streamed live to the terminal (curated milestones) and written to a per-run folder:
187
+
188
+ - `result.json` — the run's tree, per-node status, verdict, and curated artifacts.
189
+ - `raw.jsonl` — every entry, machine-readable.
190
+ - `main.log` + one `.log` per action — the human-readable timelines.
191
+ - `events.log` / `asserts.log` / `snapshots.log` / `artifacts.log` — cross-cutting rollups.
192
+ - `report.html` — the whole run as one self-contained page: a timeline of every action with its events, a flame graph, a flow graph of the tree, per-action details, logs, assertions, and the run's snapshots and images. Everything is inlined (data, base64 images, d3), so it opens straight from disk and survives being zipped or attached.
193
+
194
+ `ctx` also gives each action `assert(cond, msg)` (tallies into the run's verdict), `snapshot(name, value)` (captures JSON state to the run folder), and `artifact(name, file, opts?)` (labels a file the action wrote — name, optional description, MIME inferred from the extension — and returns its absolute path) — so a run is an inspectable artifact, not just an exit code. Write the files you label under `ctx.artifactsFolder`, the run's own output folder.
195
+
196
+ Run folders have a **stable identity**: `logs/<name>-<hash8>/<date>/<time>/`, keyed by the om's name and the file that defines it, under the project that owns the config — not under whichever directory the run happened to start in. An assistant (or a script) can always find "the latest `tskb-dev` run" without parsing scrollback, diff two runs of the same pipeline, or answer questions with evidence instead of inference: _did the server actually pass its healthcheck? which process died first? what config did the build run with?_ It's all in the folder — attributed per action, timestamped, with a verdict.
197
+
198
+ ### Observing the real world, not assuming it
199
+
200
+ The batteries are built around **ground truth**. `healthcheck` gates on the service actually answering, not on "process started". `chromePage` hands downstream steps a live browser page — real DOM, real network — so a smoke check inspects the app a user would see. `snapshot` freezes the inputs and state a run saw, and `assert` turns observations into a tallied verdict. For a human, that means less babysitting; for an AI assistant, it means the run folder **is** its view of the world — it follows what happened rather than imagining it.
201
+
202
+ ## Common recipes
203
+
204
+ **Skip work that's already done.** `withCache` fingerprints inputs and skips the body when nothing changed since the last successful run (a hit resolves `undefined`):
205
+
206
+ ```ts
207
+ const build = action("build").run(({ proc }) => proc("tsc")`tsc -b`);
208
+
209
+ om("build").run(async () => {
210
+ const out = await build().withCache(`${process.cwd()}/src`).result;
211
+ if (out === undefined) console.log("no changes — skipped the build");
212
+ });
213
+ ```
214
+
215
+ **Watch and react.** Launch a watcher, subscribe to its events, and let the run stay alive:
216
+
217
+ ```ts
218
+ import { watchDir } from "omkit/actions";
219
+
220
+ om("watch").run(async () => {
221
+ const watcher = watchDir("src").tag("watch");
222
+ watcher.on("update", (file) => console.log(`changed: ${file}`));
223
+ // body returns, but the watcher keeps the run alive until Ctrl+C
224
+ });
225
+ ```
226
+
227
+ **Gate a deploy on green tests.** A red test rejects at the `await`; catch it to branch:
228
+
229
+ ```ts
230
+ om("ship").run(async () => {
231
+ try {
232
+ await command("npm test").result; // resolves on green, throws on red
233
+ } catch {
234
+ return; // red → stop; the run stays green because you observed the failure
235
+ }
236
+ await command("./deploy.sh").result;
237
+ });
238
+ ```
239
+
240
+ **Keep a human in the loop.** `prompt` asks the terminal and falls back to a default when unattended, so it never blocks CI or an agent-driven run:
241
+
242
+ ```ts
243
+ import { prompt } from "omkit/actions";
244
+
245
+ om("deploy").run(async () => {
246
+ const answer = await prompt({
247
+ kind: "choice",
248
+ message: "Deploy to production?",
249
+ choices: ["no", "yes"],
250
+ default: "no",
251
+ timeoutMs: 10_000, // no answer in 10s → "no"
252
+ }).result;
253
+ if (answer === "yes") await command("./deploy.sh").result;
254
+ });
255
+ ```
256
+
257
+ **Take a block of text.** `kind: "multiline"` reads lines until `until` says stop: parseable JSON (the default), a sentinel line, or a predicate over the text so far. The JSON default is self-terminating, so a pretty-printed blob can be pasted straight in with nothing to explain:
258
+
259
+ ```ts
260
+ om("seed").run(async () => {
261
+ const blob = await prompt({
262
+ kind: "multiline",
263
+ message: "Paste the service config",
264
+ hint: "{ host: string; port: number }", // a one-line type sketch, shown under the message
265
+ until: "json", // the default; or "." to end on that line, or (text) => text.length > 500
266
+ }).result;
267
+ const config = JSON.parse(blob);
268
+ await command(`./seed.sh --host ${config.host}`).result;
269
+ });
270
+ ```
271
+
272
+ ## Events
273
+
274
+ An Activity exposes its declared events plus the lifecycle ones (`done`, `error`, `attached`):
275
+
276
+ - **`activity.on(event, handler)`** — subscribe to every emit.
277
+ - **`activity.once(event)`** — a **promise** for the next emit. Resolves the payload when it fires; if the Activity settles first it resolves `undefined` on success and **rejects** on failure/cancel — so a gate on an event surfaces a failure instead of hanging. A retained snapshot (like `healthy`) is replayed immediately even if you subscribe late.
278
+ - **`activity.once("done")`** is the outcome itself — identical to `.result` (resolve the value, reject on failure). `await step().once("done")` and `await step().result` are the same hard gate.
279
+
280
+ ```ts
281
+ const probe = healthcheck({ url: "http://localhost:3000" });
282
+ probe.on("healthy", (r) => console.log(`up after ${r.attempts} tries`));
283
+ ```
284
+
285
+ ## Batteries — `omkit/actions`
286
+
287
+ Reusable actions built on the core engine. For anything beyond these, drop down to `action(...).run(...)`.
288
+
289
+ - **`command`** — run a shell command (or, with `args`, an executable) as an action named after it; output streams to the log, killed on teardown.
290
+ - **`watch`** / **`watchDir`** — watch a file or directory for changes (survives wipe + recreate).
291
+ - **`untilLog`** — gate on the first log entry matching a predicate.
292
+ - **`tailLog`** — tail a file another process writes, folding its lines into the combined log.
293
+ - **`healthcheck`** — poll a URL/port until the status (and optionally body) matches.
294
+ - **`portFree`** — the mirror of `healthcheck`: poll a TCP port until nothing is listening, so a restart can rebind without racing the old process.
295
+ - **`prompt`** — ask the terminal for input, a choice, or a multiline block, with a timeout that falls back to a default; the answer is its handle.
296
+ - **`browser`** — launch a Chromium browser with Playwright and expose the live `Browser` as a handle (defaults to the installed Chrome); closed on teardown. Feed its `.ref` to `chromePage`.
297
+ - **`chromePage`** — attach to Chrome over CDP, or to an existing Playwright `Page`/`Browser`/`Context` (including a `browser` handle or an Electron window), and expose the live `Page` as a handle.
298
+
299
+ ## CLI
300
+
301
+ Point the `omkit` bin at a project — a `tsconfig.omkit.json` that lists your `oms/` and `actions/` — and it discovers, typechecks, and runs them. (Override the config path with `--tsconfig`.)
302
+
303
+ - **`omkit init`** — scaffold a starter project: `tsconfig.omkit.json`, a sample `oms/dev.ts`, and `actions/hello.ts`.
304
+ - **`omkit run`** — with no argument, open the interactive picker: browse and search your oms, run one, and watch its milestones stream live — answering any `prompt` in-console. A bare `omkit` does the same (`run` is the default command).
305
+ - **`omkit run <om>`** — run one om directly by name or file path, inheriting the terminal.
306
+ - **`omkit ls`** — list the discovered oms and actions. Add `--describe` to show each one's `.describe({ summary })` and whether it's exposed; without it, `ls` is a static scan that never imports your code.
307
+ - **`omkit check`** — typecheck the project (`tsc --noEmit`) and report diagnostics.
308
+ - **`omkit skill`** — generate `.claude/skills/omkit-runs/SKILL.md`, a map of what's runnable (see below).
309
+ - **`omkit mcp`** — serve the project over the Model Context Protocol on stdio (see below).
310
+ - **`omkit help`** — print the command overview (also `--help` / `-h`).
311
+
312
+ Both paths run the om in its own child process and narrate it to a run folder (see above). The picker **supervises** the child — piping its output into the live view and answering prompts for it — while `omkit run <om>` runs it **bare**, handing the om your terminal directly.
313
+
314
+ ## MCP — let an assistant drive your oms
315
+
316
+ `omkit mcp` serves a project over the [Model Context Protocol](https://modelcontextprotocol.io), so Claude and other assistants can discover your oms, run one, and read what the run produced — instead of shelling out and scraping stdout.
317
+
318
+ **Nothing is exposed unless you say so.** Add `.mcp()` to an om or action; without it the server neither lists nor runs it. `.describe()` and `.args()` alone expose nothing.
319
+
320
+ ```ts
321
+ om("smoke-test")
322
+ .describe({ summary: "Boot the app and run the smoke suite" })
323
+ .args(z.object({ headless: z.boolean().default(true) }))
324
+ .mcp() // ← now an assistant can run it
325
+ .run(async (ctx, { headless }) => {
326
+ /* … */
327
+ });
328
+ ```
329
+
330
+ `mode` defaults to `"settling"` — a run that finishes on its own. Declare `.mcp({ mode: "long-lived" })` for a server or a watcher: MCP has no way to express "this never finishes", so omkit does. `run_om` refuses a long-lived entry and points you at `start_om`.
331
+
332
+ Point your client at the project:
333
+
334
+ ```json
335
+ {
336
+ "mcpServers": {
337
+ "omkit": {
338
+ "command": "npx",
339
+ "args": ["--no", "--", "omkit", "mcp"]
340
+ }
341
+ }
342
+ }
343
+ ```
344
+
345
+ The server takes its **project root from the config it was given** — the directory holding your `tsconfig.omkit.json` — and resolves `logs/` under it, so the runs it starts are the runs its resources can serve regardless of which directory your client launched it in. `omkit run` uses the same root, so a run you start in the terminal and a run an assistant starts land in one tree and each can read the other's record. If the om project sits in a subfolder, name the config rather than relying on a `cwd` field your client may not support: `["--no", "--", "omkit", "mcp", "--tsconfig", "om/tsconfig.omkit.json"]`.
346
+
347
+ ### Inspecting it
348
+
349
+ The [MCP Inspector](https://github.com/modelcontextprotocol/inspector) drives the same binary, so you can exercise every tool by hand before pointing an assistant at it:
350
+
351
+ ```sh
352
+ npx -y @modelcontextprotocol/inspector npx --no -- omkit mcp
353
+ ```
354
+
355
+ It serves a UI on `localhost:6274` and prints an auth token to paste in. Because omkit declares real input schemas, you get form fields per tool — and the history pane shows the full request and response for each call, `structuredContent` and resource links included. That is usually all the debugging you need: it is a client, so it shows you what _you_ sent, not what an assistant sent.
356
+
357
+ Two things to know. On Windows the Inspector cannot spawn a `.cmd` shim directly, so if `npx` fails there, point it at the binary instead — `node node_modules/omkit/dist/cli/index.js mcp`. And the Inspector owns the runs it starts: closing it tears down anything `start_om` left running, the same rule that applies to any client.
358
+
359
+ **Six tools**, however many oms you have — adding one changes what `list_oms` returns, never the tool list:
360
+
361
+ | Tool | What it does |
362
+ | ----------------------- | ------------------------------------------------------------------------------- |
363
+ | `list_oms` | Exposed oms and actions: summary, mode, and the JSON Schema for their arguments |
364
+ | `run_om(name, args)` | Runs a settling entry to completion and returns its verdict and assert tally |
365
+ | `start_om(name, args)` | Starts anything and returns a handle immediately |
366
+ | `get_run(run)` | Status, verdict once settled, and links to the files the run produced |
367
+ | `tail_run(run, cursor)` | Log lines since `cursor` — live while running, from `raw.jsonl` once settled |
368
+ | `cancel_run(runId)` | Tears a run down; it still finishes writing its run folder |
369
+
370
+ Call `list_oms` first — `run_om`'s `args` is a plain object, so its schema comes from the listing rather than from the tool definition. That is the cost of a tool list that doesn't churn every time you edit a file.
371
+
372
+ **Run folders are resources.** `omkit://runs/{run}/latest/{file}` reads from the newest run of an om, and `omkit://runs/{run}/{date}/{time}/{file}` from one specific run. Reads are confined under the project's `logs/`, size-capped, and read-only. Files a run labelled with `ctx.artifact` come back named and described.
373
+
374
+ **While a run is going**, milestones arrive as progress notifications, cancelling the request tears the run down (and it still writes its record), and a `prompt` raised mid-run reaches you as an elicitation — the client is the supervisor.
375
+
376
+ ## A skill file — the map, before the first call
377
+
378
+ `list_oms` answers "what can I run here?" at runtime, but charges a round trip for it: an assistant has to call it before it can build arguments for anything. `omkit skill` writes the same answer to disk, where it's already in context:
379
+
380
+ ```sh
381
+ omkit skill # writes .claude/skills/omkit-runs/SKILL.md
382
+ omkit skill --check # exits non-zero when it no longer matches the project
383
+ ```
384
+
385
+ Each exposed workflow gets its summary, its argument shape, the path to its source, and an outline of what it calls — read statically from the body, with the `.tag("…")` names you already wrote:
386
+
387
+ ```
388
+ ### `tskb:build` — settling
389
+
390
+ Rebuild the tskb knowledge graph from this repo's .tskb.tsx docs.
391
+
392
+ - **Defined in:** `om/oms/tskb-build.ts`
393
+ - **Args:** `{ verbose?: boolean, projectName?: string }`
394
+ - **Calls:** `watchDir` [watch:build:daemon] → `buildDocs` [build]
395
+ ```
396
+
397
+ Generating it runs nothing — it uses the same discovery fork as `list_oms`, where `.run(body)` reports what it declares instead of launching. The file documents the shell invocation as well as the MCP one, so it's useful with no MCP wiring at all.
398
+
399
+ It's meant to be committed. A `registry-hash` in the header covers the project's declarations rather than the rendered markdown, so `--check` catches an edited om but not a reformatted file — put it in a pre-commit hook or CI. The one field you should edit by hand is the frontmatter `description`, which decides when an assistant loads the file; regeneration preserves it.
400
+
401
+ ## Install
402
+
403
+ ```sh
404
+ npm install omkit
405
+ ```
406
+
407
+ Requires Node ≥ 20.11. ESM only.
408
+
409
+ ## License
410
+
411
+ MIT