omkit 0.8.17 → 0.8.19

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
@@ -5,241 +5,372 @@
5
5
 
6
6
  **The operational-model kit.** _Humans orchestrate. Runs narrate. AI assistants follow along._
7
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**).
8
+ ![An omkit run report: the timeline of a dev stack, with each action's call site, tags, events and the calls actions made on each other](https://raw.githubusercontent.com/mitk0936/tskb/main/references/omkit/report-timeline.png)
9
9
 
10
- ## Migrating from om(name, body)
10
+ omkit is a small TypeScript runtime for the workflows **around** your code: start the API, wait until it actually answers, seed it, open the app in a real browser, check what a user would see, and tear it all down together. You write that orchestration as ordinary TypeScript (`await`, `if`, loops, variables). omkit runs each step as a supervised, typed **action** and writes down everything the run did: what launched, what came up, what each step handed the next, what failed, and what the world looked like at the time.
11
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:
12
+ That record is the point. A human gets a report they can open from disk. An AI assistant gets files it can read and verify against, instead of guessing from interleaved terminal output. 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**).
13
13
 
14
- ```diff
15
- -om("dev", async (ctx) => {
16
- +om("dev").run(async (ctx) => {
17
- // ...
18
- });
14
+ **What you get**
15
+
16
+ - **Orchestration as code.** Define an action once and launch it by calling it. No YAML, no DSL.
17
+ - **Typed handoffs between steps.** A step can publish a live value (a port, a client, a Playwright `Page`) and the next step receives it, typed. Nothing is scraped from stdout.
18
+ - **Gates that fail instead of hanging.** `.result`, `.ref` and `.once(event)` reject when the action fails or is cancelled.
19
+ - **Structured supervision.** A failure nobody is watching tears the whole run down, so a crashed dev server fails the run instead of leaving it wedged.
20
+ - **A durable run record.** Every run gets a folder with logs, a machine-readable tree, snapshots, artifacts and a self-contained HTML report.
21
+ - **Built for assistants.** An MCP server and a generated skill file let Claude (or any MCP client) discover, run and read your workflows.
22
+
23
+ ## Contents
24
+
25
+ - [Install](#install)
26
+ - [Quick start](#quick-start)
27
+ - [Concepts](#concepts)
28
+ - [API reference](#api-reference): [`om`](#omname) · [`action`](#actionname) · [Activity](#activity) · [contexts](#contexts) · [`step`](#stepname-fn) · [errors](#errors)
29
+ - [Supervision and teardown](#supervision-and-teardown)
30
+ - [The run record](#the-run-record)
31
+ - [Batteries (`omkit/actions`)](#batteries-omkitactions)
32
+ - [Recipes](#recipes)
33
+ - [CLI](#cli)
34
+ - [MCP: let an assistant drive your oms](#mcp-let-an-assistant-drive-your-oms)
35
+ - [Skill file](#skill-file)
36
+ - [Migrating from `om(name, body)`](#migrating-from-omname-body)
37
+
38
+ ## Install
39
+
40
+ ```sh
41
+ npm install omkit
19
42
  ```
20
43
 
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.
44
+ Requires Node ≥ 20.11. ESM only. Batteries that drive a browser use `playwright-core` and need a local Chrome or Chromium.
22
45
 
23
- ## The problem
46
+ ## Quick start
24
47
 
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.
48
+ Scaffold a project, or add the files by hand:
26
49
 
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.
50
+ ```sh
51
+ npx omkit init # tsconfig.omkit.json, oms/dev.ts, actions/hello.ts
52
+ ```
28
53
 
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.
54
+ An **om** is one runnable workflow. This one starts two dev servers, waits until the app really answers, then opens it in Chrome and hands the live page to the next step:
30
55
 
31
56
  ```ts
57
+ // oms/dev.ts
32
58
  import { om } from "omkit";
33
- import { command, healthcheck, browser, chromePage } from "omkit/actions";
59
+ import { command, healthcheck, browser, chromePage, drivePage } from "omkit/actions";
34
60
 
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");
61
+ om("dev")
62
+ .describe({ summary: "Start the API and web app, then check the home page." })
63
+ .run(async ({ assert }) => {
64
+ // Daemons: they keep running until the run is torn down.
65
+ command("npm run dev", { cwd: "api" }).tag("api");
66
+ command("npm run dev", { cwd: "web" }).tag("web");
39
67
 
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
- }
68
+ // A gate: resolves when the app answers, rejects if it never does.
69
+ await healthcheck({ url: "http://localhost:3000" }).tag("web:ready").result;
46
70
 
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()}`);
71
+ // A typed handoff: `.ref` is the live Browser, then the live Page.
72
+ const chrome = browser({ headless: false });
73
+ const page = chromePage("app", await chrome.ref, { url: "http://localhost:3000" });
51
74
 
52
- console.log("stack is up — press Ctrl+C to stop");
53
- });
75
+ const home = await drivePage({ target: await page.ref, js: "document.title", screenshot: true })
76
+ .result;
77
+ assert(home.title.length > 0, "the home page has a title");
78
+
79
+ console.log("stack is up, press Ctrl+C to stop");
80
+ });
54
81
  ```
55
82
 
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.
83
+ Run it:
57
84
 
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
85
+ ```sh
86
+ npx omkit # interactive picker: browse, run, watch milestones live
87
+ npx omkit run dev # or run one om directly
68
88
  ```
69
89
 
70
- ## Who it's for
90
+ Every run writes a folder such as `logs/dev-1a2b3c4d/2026-10-24/18-53-10/`, with `report.html`, `result.json`, `raw.jsonl` and one `.log` per action. See [The run record](#the-run-record).
71
91
 
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?".
92
+ ## Concepts
75
93
 
76
- ## Core concepts
94
+ | Term | What it is |
95
+ | -------------- | --------------------------------------------------------------------------------------------------------- |
96
+ | **om** | One runnable workflow, `om(name).run(body)`. Its body is the root of a run. |
97
+ | **action** | A named, typed unit of work, `action(name).run(exec)`. Defining it doesn't run it. |
98
+ | **Activity** | What you get by **calling** an action: the live, running instance you observe and configure. |
99
+ | **step** | An inline, one-off action, `step(name, fn)`, with no reusable definition. |
100
+ | **ref** | A value an action publishes with `attach(...)` (a port, a client, a page). Others receive it from `.ref`. |
101
+ | **event** | A typed signal an action emits (`healthy`, `ready`). Observe it with `.on` or wait for it with `.once`. |
102
+ | **tag** | A label on an activity (`api`, `web:ready`), shown in logs, the report and the skill file. |
103
+ | **run folder** | The on-disk record of one run, keyed by the om's name and the file that defines it. |
77
104
 
78
- ### Actions & Activities
105
+ Activities run in parallel. The om body stays in flight while you `await`, so the run never idles shut between steps.
79
106
 
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).
107
+ ## API reference
81
108
 
82
- ```ts
83
- import { action } from "omkit";
109
+ ### `om(name)`
84
110
 
85
- const build = action("build").run(({ proc }) => proc("tsc")`tsc -b`);
111
+ ```ts
112
+ import { om } from "omkit";
86
113
 
87
- const activity = build(); // calling launches; returns the live Activity
114
+ om(name: string): OmBuilder
88
115
  ```
89
116
 
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.)
117
+ Returns a builder. Chain configuration, then finish with `.run(body)`, which launches the run.
118
+
119
+ | Method | Purpose |
120
+ | ------------------------ | ------------------------------------------------------------------------------------------------------------- |
121
+ | `.describe({ summary })` | A one-line summary, shown by `omkit ls --describe`, the skill file, MCP and the report. |
122
+ | `.args(schema)` | A zod schema for the run's inputs. **Resolved at runtime**; see below. |
123
+ | `.mcp({ mode? })` | Expose this om over MCP. `mode` is `"settling"` (default) or `"long-lived"`. |
124
+ | `.run(body)` | Launch the run. `body(ctx, args?)` receives an [`OmContext`](#contexts) and, after `.args()`, the typed args. |
125
+
126
+ `.run()` returns a promise that resolves once the run has torn down and written its record. It never rejects: failures are recorded in the run and set the process exit code. Whatever the body returns is recorded as the run's `value` in `result.json`.
127
+
128
+ The run's identity is its name plus the file that defines it. Logs land in `logs/<name>-<hash8>/…`, so same-named oms in different files never share a folder, and moving the call within its file doesn't change the hash.
129
+
130
+ #### Run arguments
91
131
 
92
132
  ```ts
93
133
  import { z } from "zod";
94
134
 
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 */
135
+ om("seed")
136
+ .args(z.object({ rows: z.number(), truncate: z.boolean().default(false) }))
137
+ .run(async (ctx, { rows, truncate }) => {
138
+ await command(`./seed.sh --rows ${rows}${truncate ? " --truncate" : ""}`).result;
100
139
  });
101
-
102
- seed({ rows: 500 }); // calling launches it; the shape is pinned, not checked at runtime
103
140
  ```
104
141
 
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.
142
+ Each field is filled from the first source that can answer:
106
143
 
107
- ### Run arguments — `om(name).args(schema)`
144
+ 1. **Supplied:** `OMKIT_ARGS`, a JSON object in the environment. It is read once at run start and then cleared, so a subprocess doesn't inherit one om's args and resolve them against another's schema.
145
+ 2. **Defaults:** anything the schema defaults is never asked about.
146
+ 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 re-asks rather than coercing. Resolution gives up after three rounds.
147
+ 4. **Fail:** if nobody can be asked (no supervising picker and no TTY), the run fails, naming every unresolved field.
108
148
 
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.
149
+ Resolution happens inside the run, so prompts and answers land on the run's timeline, and the resolved values are recorded in `result.json` and the `main.log` header.
150
+
151
+ ### `action(name)`
110
152
 
111
153
  ```ts
112
- import { z } from "zod";
154
+ import { action } from "omkit";
113
155
 
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;
156
+ action(name: string): ActionBuilder
157
+ ```
158
+
159
+ | Method | Purpose |
160
+ | ------------------------ | ------------------------------------------------------------------------------------- |
161
+ | `.describe({ summary })` | A one-line summary, carried onto every run of this action. |
162
+ | `.emits<Events>()` | Declare the events this action emits, as a type: `{ ready: number; error: string }`. |
163
+ | `.ref<Handle>()` | Declare the type of the value this action publishes with `attach`. |
164
+ | `.args(schema)` | Pin `.run`'s argument to a zod schema's type. **Type-level only** in code; see below. |
165
+ | `.mcp({ mode? })` | Expose this action over MCP as a runnable tool. |
166
+ | `.run(exec)` | Bind the implementation. Returns an **Action**: a function you call to launch it. |
167
+
168
+ `exec(ctx, ...args)` receives an [`ActionContext`](#contexts) and the arguments the action was called with. Its return value is the activity's result.
169
+
170
+ ```ts
171
+ import { action } from "omkit";
172
+
173
+ export const server = action("server")
174
+ .describe({ summary: "Serves the API and publishes its port." })
175
+ .emits<{ listening: number }>()
176
+ .ref<number>()
177
+ .run(async ({ attach, emit, signal }, port: number) => {
178
+ await listen(port); // start serving…
179
+ emit("listening", port); // …announce it…
180
+ attach(port); // …publish the port to whoever awaits `.ref`…
181
+ await new Promise((r) => signal.addEventListener("abort", r)); // …and stay up until teardown
119
182
  });
183
+
184
+ const s = server(4000); // calling launches it and returns the live Activity
120
185
  ```
121
186
 
122
- Each field is filled from the first of these that can answer:
187
+ `.args(schema)` types the call site; launching an action in code neither validates nor prompts. The exception is an action run as an MCP tool: the JSON an assistant sends is parsed through the same schema, so `.default()`s are filled and bad values are refused before the body runs. Prompting for arguments is an om-level feature.
188
+
189
+ An Action also exposes `actionName`, `definedAt` (`file:line` of its `.run`), `description` and `mcp`, for tooling.
190
+
191
+ ### Activity
123
192
 
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.
193
+ Calling an action returns an `Activity<Result, Events, Handle>`:
128
194
 
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.
195
+ | Member | Type / behavior |
196
+ | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
197
+ | `id` | The node's id in the run, e.g. `server_9f3c1a2b`. |
198
+ | `result` | `Promise<Result>`. Resolves with the return value; **rejects** with the error on failure, or `CancelledError` on cancel. |
199
+ | `ref` | `Promise<Handle>`. Resolves when the action calls `attach`; **rejects** on failure or cancel. Resolves `undefined` on success if nothing was attached. |
200
+ | `once(event)` | `Promise<payload>`. The next emit of `event`. If the activity settles first, resolves `undefined` on success and **rejects** on failure. `once("done")` is `.result`. |
201
+ | `on(event, handler)` | Subscribe to every emit, including the system events `done`, `error` and `attached`. |
202
+ | `tag(name)` | Add a label. Chainable. |
203
+ | `withCache(...paths)` | Skip the body when these input paths are unchanged since the last success (the result is then `undefined`). Chain it before the first `await` after launching. |
204
+ | `cancel()` | Abort this activity and everything it launched. |
130
205
 
131
- ### Typed capabilities
206
+ The body starts one microtask after the call, so `.tag(...)` and `.withCache(...)` chained in the same expression apply before it runs.
132
207
 
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.
208
+ **Events replay their latest value.** An event's most recent payload (like `healthy`) is replayed to a listener that subscribes late, so `await probe.once("healthy")` works even after the probe passed.
134
209
 
135
- The handle `.ref` resolves is a thin, shallow wrapper over the attached value: every reader gets the same one, methods run bound to the real object (private fields work), and each public method call another action makes on it is recorded in the run — who called, from which line, how long it took — so the report can show it. Return values are not wrapped, and recording stops after 500 calls per handle.
210
+ **About `.ref`.** The value `.ref` resolves to is a thin, shallow wrapper over what the action attached:
211
+
212
+ - Every reader gets the same wrapper.
213
+ - Methods run bound to the real object, so private fields keep working.
214
+ - Each public method call that another action makes on it is recorded in the run: who called, from which line, and how long it took.
215
+ - Return values are not wrapped, and recording stops after 500 calls per handle.
216
+
217
+ The wrapper is not `===` to the original object, so code that compares it against a reference obtained some other way will see two different objects.
218
+
219
+ ### Contexts
220
+
221
+ The action `exec` receives an `ActionContext`; the om body receives an `OmContext`.
222
+
223
+ | Member | `ActionContext` | `OmContext` | Purpose |
224
+ | ----------------------------- | :-------------: | :---------: | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
225
+ | `signal` | ✓ | ✓ | `AbortSignal` that fires on cancel or teardown. Honor it to stop early. |
226
+ | `logs` | ✓ | ✓ | The run's combined log, read-only: `logs.subscribe({ replay? })` is an async iterable of entries. |
227
+ | `tag(name)` | ✓ | ✓ | Tag this node. |
228
+ | `assert(cond, message)` | ✓ | ✓ | Record a pass or fail in the run's verdict. Never throws. |
229
+ | `snapshot(name, value)` | ✓ | ✓ | Write `value` as JSON into the run folder; resolves the file's path. |
230
+ | `artifact(name, file, opts?)` | ✓ | ✓ | Label a file you wrote (optional `description`, MIME inferred). It appears in `result.json` and the report. |
231
+ | `artifactsFolder` | ✓ | ✓ | Absolute path of the run's output folder. Write artifacts here. |
232
+ | `emit(event, payload)` | ✓ | | Emit one of the action's declared events. |
233
+ | `attach(handle)` | ✓ | | Publish the action's handle and resolve its `.ref`. The first call wins. |
234
+ | `proc(name, opts?)` | ✓ | | Spawn a child process: ``proc("tsc")`tsc -b` `` ([zx](https://github.com/google/zx) syntax). Its output goes to this action's log, and it is killed on teardown. |
235
+ | `cancel()` | | ✓ | Tear the whole run down. |
236
+
237
+ `console.*` inside an action or the om body is captured and attributed to that node's log.
238
+
239
+ ### `step(name, fn)`
136
240
 
137
241
  ```ts
138
- const server = action("server")
139
- .ref<number>() // this action publishes a port
140
- .run(async ({ attach, signal }) => {
141
- const port = await listen(); // start listening…
142
- attach(port); // …publish the port to downstream actions…
143
- await until(signal); // …and stay up until teardown
144
- });
242
+ import { step } from "omkit";
145
243
 
146
- const migrate = action("migrate").run((_ctx, port: Promise<number>) => runMigrations(port));
244
+ const rows = await step("seed catalog", async (ctx) => db.seed(40));
245
+ ```
147
246
 
148
- om("migrate").run(async () => {
149
- const s = server();
150
- migrate(s.ref); // migrate receives the port the moment the server attaches it — typed
151
- });
247
+ Runs `fn(ctx)` as its own node, with its own log, tags and error boundary, without a reusable definition. It returns a promise of `fn`'s result that rejects on failure or cancel, and its source location is where you wrote it.
248
+
249
+ ### Errors
250
+
251
+ ```ts
252
+ import { CancelledError, isCancelled } from "omkit";
152
253
  ```
153
254
 
154
- ### Results — one promise that rejects on failure
255
+ A cancelled activity rejects `.result`, `.ref` and `.once` with `CancelledError`. `isCancelled(err)` tells an intentional stop apart from a real fault.
155
256
 
156
- 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:
257
+ ## Supervision and teardown
258
+
259
+ 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` or `.once`, attached `.result.catch(...)`, or added an `on("error")` listener.
157
260
 
158
261
  ```ts
262
+ // Awaited: a failure throws here. Catch it to branch; the run stays green.
159
263
  try {
160
- await command("npm test").result; // resolves on green, throws on red
161
- } catch (err) {
162
- // red tests are an expected outcome here — decide what to do
264
+ await command("npm test").result;
265
+ } catch {
266
+ return;
163
267
  }
164
268
 
165
- // …or handle it inline, without stopping:
166
- await command("npm test").result.catch((err) => report(err));
269
+ // Fire-and-forget: if the dev server crashes, the run tears down. That's the guardrail.
270
+ command("npm run dev", { cwd: "web" }).tag("web");
271
+
272
+ // Owned: note the failure and carry on. (`fn` also runs on cancel; check `isCancelled`.)
273
+ flakyJob().result.catch((err) => console.warn("job failed, carrying on", err));
167
274
  ```
168
275
 
169
- A cancelled Activity rejects with `CancelledError` — `isCancelled(err)` tells an intentional stop apart from a real fault.
276
+ - **Success keeps the stack alive.** When the om body returns, the daemons it started keep running until Ctrl+C or `ctx.cancel()`.
277
+ - **Teardown is graceful.** Ctrl+C or `cancel()` aborts every signal, kills processes, unblocks waits, and always writes the run record. Nodes get a 5-second grace period to settle.
278
+ - **Write abort-aware daemons.** A daemon should stop when `signal` fires. Check `signal.aborted` before adding the listener, or a cancel that lands earlier is missed.
170
279
 
171
- ### Failure & teardown
280
+ ## The run record
172
281
 
173
- 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.
282
+ Every run writes a folder under the project that owns the config: `logs/<name>-<hash8>/<date>/<time>/`.
174
283
 
175
- - **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.
176
- - **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.
177
- - **`.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).
284
+ | File | Contents |
285
+ | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
286
+ | `report.html` | The whole run as one self-contained page; see below. |
287
+ | `result.json` | The run tree: each node's status, timing, args, tags, returned `value`, `calledAt` and `definedAt` sites, its `.describe()`/`.mcp()`/`.args()` metadata, and the verdict. |
288
+ | `raw.jsonl` | Every log entry, machine-readable, in order. |
289
+ | `main.log`, `<action>_<id>.log` | Human-readable timelines, one per node. |
290
+ | `events.log`, `asserts.log`, `snapshots.log`, `artifacts.log` | Cross-cutting rollups. |
291
+ | `snapshots/` | The JSON written by `snapshot(...)`. |
178
292
 
179
- ```ts
180
- command("npm run dev", { cwd: "web" }).tag("web"); // if it crashes, the run tears down — you'll know
181
- flakyBackgroundJob().result.catch((e) => console.warn("job failed, carrying on", e));
182
- ```
293
+ Because the folder is keyed by the om's name and its defining file, a script or an assistant can always find "the latest `dev` run", diff two runs of the same workflow, and answer questions with evidence: _did the server pass its healthcheck? which process died first? what config did the build run with?_
183
294
 
184
- **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.
295
+ ### `report.html`
185
296
 
186
- ### One log, on disk — a run an assistant can follow
297
+ The report needs no server: the data, images and viewer are all inlined, so it opens straight from disk and survives being zipped or attached.
187
298
 
188
- 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:
299
+ - **Timeline:** every action as a bar. Each row shows how your code called it (`shop = api(…)`), its tags, status and duration, and points for its events, snapshots, assertions and errors.
300
+ - **Links between actions:** who waited on whose event (`once`), who cancelled whom, and the method calls made on another action's `.ref`. These show as points on the caller's row, with a dashed line to the target.
301
+ - **Flow:** the run as a graph of lanes. Snapshots and links ride along as chips.
302
+ - **Details:** for the selected action, its summary, tags and call site, plus sections for errors, args, the returned value, snapshots, events, links, logs and assertions.
303
+ - **Source:** the `<>` button next to an action opens its call site, its definition, its args and its returned value, editor-style.
304
+ - **Errors:** every failure, failed assertion and block of error output, with a switch that highlights where they came from.
189
305
 
190
- - `result.json` — the run's tree, per-node status, verdict, and curated artifacts. Each node also carries what its definition declared about itself: the `.describe()` summary, the `.mcp()` exposure, and the `.args()` schema as JSON Schema. Each node also records what it returned (`value`, or a `valueNote` when it was too large or not JSON) and `calledAt`, the line that launched it, so a built-in step (a `prompt`, a `command`) points back at your code rather than omkit's, and a `step(...)` is defined at the line where you wrote it.
191
- - `raw.jsonl` — every entry, machine-readable.
192
- - `main.log` + one `.log` per action — the human-readable timelines.
193
- - `events.log` / `asserts.log` / `snapshots.log` / `artifacts.log` — cross-cutting rollups.
194
- - `report.html` — the whole run as one self-contained page: a timeline of every action with its events, a flow graph of the tree, per-action details, logs, assertions, the run's snapshots and images, each action's source (the `<>` button next to a block), an Errors tab that collects failures, failed assertions and error output, and can highlight the actions they came from in every graph, and the links between actions: who waited on whose event (`once`), who cancelled whom, and the calls made on another action's `.ref`. Everything is inlined (data, base64 images, d3), so it opens straight from disk and survives being zipped or attached.
306
+ ![The flow view of the same run: actions as cards in lanes, with snapshot and link chips](https://raw.githubusercontent.com/mitk0936/tskb/main/references/omkit/report-flow.png)
195
307
 
196
- `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.
308
+ ![The source popup on an action, on its Args tab](https://raw.githubusercontent.com/mitk0936/tskb/main/references/omkit/report-popup.png)
197
309
 
198
- 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.
310
+ ## Batteries (`omkit/actions`)
199
311
 
200
- ### Observing the real world, not assuming it
312
+ Ready-made actions built on the same engine. For anything else, write your own with `action(...)`.
201
313
 
202
- 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.
314
+ | Battery | Does | Events | `.ref` / result |
315
+ | --------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | -------------------------------------------- |
316
+ | `command(cmd, { args?, cwd?, env? })` | Runs a shell command, or an executable when `args` is given, as an action named after it. Output goes to its log; killed on teardown. | — | Rejects on a non-zero exit. |
317
+ | `healthcheck({ url \| host+port+path, status?, body?, intervalMs?, timeoutMs? })` | Polls until the endpoint answers with a matching status (and body). | `healthy` | Result `{ url, status, attempts }`. |
318
+ | `portFree(port, { host?, intervalMs?, timeoutMs? })` | Polls until nothing listens on the port, so a restart can rebind. | `free` | Result `{ port, host, attempts }`. |
319
+ | `untilLog(matcher, { timeoutMs?, replay? })` | Waits for the first run-log entry matching `matcher(entry)`. | `match` | The matching entry. |
320
+ | `tailLog(file, { fromStart?, encoding? })` | Tails a file another process writes and folds its lines into the run log. | — | — |
321
+ | `watch(file, { onCreate?, onUpdate?, onDelete?, onMissing? })` | Watches one file and survives it being deleted and recreated. | — | — |
322
+ | `watchDir(dir, { debounceMs? })` | Watches a directory tree and survives it being wiped and recreated. | `ready`, `create`, `update`, `delete`, `removed` | — |
323
+ | `prompt({ kind?, message, choices?, default?, timeoutMs?, until? })` | Asks in the terminal (or the picker, or the MCP client). `kind` is `"input"`, `"choice"` or `"multiline"`. Falls back to `default` on timeout. | — | Result: the answer. |
324
+ | `browser({ headless?, channel?, executablePath?, args? })` | Launches Chromium with Playwright (defaults to the installed Chrome); closed on teardown. | — | `.ref`: the live `Browser`. |
325
+ | `chromePage(label, source, { url?, match? })` | Opens or attaches to a page. `source` is a CDP address, a `Browser`, a `BrowserContext` or a `Page` (an Electron window works too). | `navigated`, `console`, `pageerror` | `.ref`: the live `Page`. |
326
+ | `drivePage({ target \| match \| url, js, screenshot? })` | Evaluates `js` in a page and optionally saves a screenshot as an artifact. | — | Result `{ value, title, url, screenshot? }`. |
203
327
 
204
- ## Common recipes
328
+ ## Recipes
205
329
 
206
- **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`):
330
+ **Skip work that's already done.** `withCache` fingerprints the inputs and skips the body when nothing changed since the last success:
207
331
 
208
332
  ```ts
209
333
  const build = action("build").run(({ proc }) => proc("tsc")`tsc -b`);
210
334
 
211
335
  om("build").run(async () => {
212
336
  const out = await build().withCache(`${process.cwd()}/src`).result;
213
- if (out === undefined) console.log("no changes — skipped the build");
337
+ if (out === undefined) console.log("no changes, skipped the build");
214
338
  });
215
339
  ```
216
340
 
217
- **Watch and react.** Launch a watcher, subscribe to its events, and let the run stay alive:
341
+ **Gate on an event.** `once` resolves when the event fires and rejects if the action fails first, so a gate never hangs:
342
+
343
+ ```ts
344
+ const db = database().tag("db");
345
+ await db.once("ready");
346
+ await seed(db.ref).result;
347
+ ```
348
+
349
+ **Watch and react.**
218
350
 
219
351
  ```ts
220
352
  import { watchDir } from "omkit/actions";
221
353
 
222
- om("watch").run(async () => {
223
- const watcher = watchDir("src").tag("watch");
224
- watcher.on("update", (file) => console.log(`changed: ${file}`));
225
- // body returns, but the watcher keeps the run alive until Ctrl+C
354
+ om("watch").run(() => {
355
+ watchDir("src").on("update", (file) => console.log(`changed: ${file}`));
356
+ // the body returns; the watcher keeps the run alive until Ctrl+C
226
357
  });
227
358
  ```
228
359
 
229
- **Gate a deploy on green tests.** A red test rejects at the `await`; catch it to branch:
360
+ **Gate a deploy on green tests.**
230
361
 
231
362
  ```ts
232
363
  om("ship").run(async () => {
233
364
  try {
234
- await command("npm test").result; // resolves on green, throws on red
365
+ await command("npm test").result;
235
366
  } catch {
236
- return; // red → stop; the run stays green because you observed the failure
367
+ return; // red: stop here; the run stays green because the failure was observed
237
368
  }
238
369
  await command("./deploy.sh").result;
239
370
  });
240
371
  ```
241
372
 
242
- **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:
373
+ **Keep a human in the loop.** `prompt` falls back to its default when nobody answers, so it never blocks CI or an agent-driven run:
243
374
 
244
375
  ```ts
245
376
  import { prompt } from "omkit/actions";
@@ -250,141 +381,98 @@ om("deploy").run(async () => {
250
381
  message: "Deploy to production?",
251
382
  choices: ["no", "yes"],
252
383
  default: "no",
253
- timeoutMs: 10_000, // no answer in 10s → "no"
384
+ timeoutMs: 10_000,
254
385
  }).result;
255
386
  if (answer === "yes") await command("./deploy.sh").result;
256
387
  });
257
388
  ```
258
389
 
259
- **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:
390
+ **Take a block of text.** `kind: "multiline"` reads until `until` says stop: parseable JSON (the default), a sentinel line, or a predicate:
260
391
 
261
392
  ```ts
262
- om("seed").run(async () => {
263
- const blob = await prompt({
264
- kind: "multiline",
265
- message: "Paste the service config",
266
- hint: "{ host: string; port: number }", // a one-line type sketch, shown under the message
267
- until: "json", // the default; or "." to end on that line, or (text) => text.length > 500
268
- }).result;
269
- const config = JSON.parse(blob);
270
- await command(`./seed.sh --host ${config.host}`).result;
271
- });
393
+ const blob = await prompt({
394
+ kind: "multiline",
395
+ message: "Paste the service config",
396
+ hint: "{ host: string; port: number }",
397
+ until: "json", // or "." to end on that line, or (text) => text.length > 500
398
+ }).result;
272
399
  ```
273
400
 
274
- ## Events
275
-
276
- An Activity exposes its declared events plus the lifecycle ones (`done`, `error`, `attached`):
277
-
278
- - **`activity.on(event, handler)`** — subscribe to every emit.
279
- - **`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.
280
- - **`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.
281
-
282
- ```ts
283
- const probe = healthcheck({ url: "http://localhost:3000" });
284
- probe.on("healthy", (r) => console.log(`up after ${r.attempts} tries`));
285
- ```
286
-
287
- ## Batteries — `omkit/actions`
288
-
289
- Reusable actions built on the core engine. For anything beyond these, drop down to `action(...).run(...)`.
290
-
291
- - **`command`** — run a shell command (or, with `args`, an executable) as an action named after it; output streams to the log, killed on teardown.
292
- - **`watch`** / **`watchDir`** — watch a file or directory for changes (survives wipe + recreate).
293
- - **`untilLog`** — gate on the first log entry matching a predicate.
294
- - **`tailLog`** — tail a file another process writes, folding its lines into the combined log.
295
- - **`healthcheck`** — poll a URL/port until the status (and optionally body) matches.
296
- - **`portFree`** — the mirror of `healthcheck`: poll a TCP port until nothing is listening, so a restart can rebind without racing the old process.
297
- - **`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.
298
- - **`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`.
299
- - **`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.
300
-
301
401
  ## CLI
302
402
 
303
- 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`.)
403
+ The `omkit` bin works on a project described by a `tsconfig.omkit.json` that includes your `oms/` and `actions/`. Pass `--tsconfig <path>` to use another config.
404
+
405
+ | Command | What it does |
406
+ | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
407
+ | `omkit` / `omkit run` | Interactive picker: search oms by name or summary, see the highlighted one's summary, file and the actions it calls, then run it, watch its milestones live, and answer prompts in place. |
408
+ | `omkit run <om>` | Run one om by name or file path, with your terminal attached. |
409
+ | `omkit ls` | List the discovered oms and actions (a static scan; it never imports your code). `--describe` adds summaries and MCP exposure. |
410
+ | `omkit check` | Typecheck the project (`tsc --noEmit`). |
411
+ | `omkit init` | Scaffold `tsconfig.omkit.json`, `oms/dev.ts` and `actions/hello.ts`. Never overwrites existing files. |
412
+ | `omkit skill` | Generate `.claude/skills/omkit-runs/SKILL.md`; see [Skill file](#skill-file). `--check` fails when it is out of date. |
413
+ | `omkit mcp` | Serve the project over MCP on stdio. |
414
+ | `omkit help` | Print the command overview (also `--help` / `-h`). |
304
415
 
305
- - **`omkit init`** — scaffold a starter project: `tsconfig.omkit.json`, a sample `oms/dev.ts`, and `actions/hello.ts`.
306
- - **`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).
307
- - **`omkit run <om>`** — run one om directly by name or file path, inheriting the terminal.
308
- - **`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.
309
- - **`omkit check`** — typecheck the project (`tsc --noEmit`) and report diagnostics.
310
- - **`omkit skill`** — generate `.claude/skills/omkit-runs/SKILL.md`, a map of what's runnable (see below).
311
- - **`omkit mcp`** — serve the project over the Model Context Protocol on stdio (see below).
312
- - **`omkit help`** — print the command overview (also `--help` / `-h`).
416
+ Both run paths start the om in its own child process. The picker **supervises** it, piping its output into the live view and answering its prompts. `omkit run <om>` runs it **bare**, handing it your terminal.
313
417
 
314
- 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.
418
+ Discovery and `omkit check` type-check the project and keep TypeScript's incremental state in `node_modules/.cache/omkit/`, so a file that hasn't changed isn't checked again on the next start. Set `OMKIT_CACHE_DIR` to keep it elsewhere, or `OMKIT_NO_CACHE=1` to turn it off.
315
419
 
316
- ## MCP — let an assistant drive your oms
420
+ ## MCP: let an assistant drive your oms
317
421
 
318
- `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.
422
+ `omkit mcp` serves a project over the [Model Context Protocol](https://modelcontextprotocol.io). Claude and other clients can discover your oms, run one, and read what the run produced.
319
423
 
320
- **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.
424
+ **Nothing is exposed unless you say so.** Add `.mcp()` to an om or action; without it the server neither lists nor runs it.
321
425
 
322
426
  ```ts
323
427
  om("smoke-test")
324
428
  .describe({ summary: "Boot the app and run the smoke suite" })
325
429
  .args(z.object({ headless: z.boolean().default(true) }))
326
- .mcp() // ← now an assistant can run it
430
+ .mcp() // now an assistant can run it
327
431
  .run(async (ctx, { headless }) => {
328
432
  /* … */
329
433
  });
330
434
  ```
331
435
 
332
- `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`.
436
+ `mode` defaults to `"settling"`, for a run that finishes on its own. Declare `.mcp({ mode: "long-lived" })` for a server or a watcher; `run_om` refuses those and points to `start_om`.
333
437
 
334
438
  Point your client at the project:
335
439
 
336
440
  ```json
337
441
  {
338
442
  "mcpServers": {
339
- "omkit": {
340
- "command": "npx",
341
- "args": ["--no", "--", "omkit", "mcp"]
342
- }
443
+ "omkit": { "command": "npx", "args": ["--no", "--", "omkit", "mcp"] }
343
444
  }
344
445
  }
345
446
  ```
346
447
 
347
- 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"]`.
348
-
349
- ### Inspecting it
350
-
351
- 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:
352
-
353
- ```sh
354
- npx -y @modelcontextprotocol/inspector npx --no -- omkit mcp
355
- ```
356
-
357
- 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.
358
-
359
- 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.
360
-
361
- **Six tools**, however many oms you have — adding one changes what `list_oms` returns, never the tool list:
448
+ If the project sits in a subfolder, name its config: `["--no", "--", "omkit", "mcp", "--tsconfig", "om/tsconfig.omkit.json"]`. The server takes its project root from that config and resolves `logs/` under it, so runs started from the terminal and runs started by an assistant land in the same tree.
362
449
 
363
450
  | Tool | What it does |
364
451
  | ----------------------- | ------------------------------------------------------------------------------- |
365
- | `list_oms` | Exposed oms and actions: summary, mode, and the JSON Schema for their arguments |
366
- | `run_om(name, args)` | Runs a settling entry to completion and returns its verdict and assert tally |
367
- | `start_om(name, args)` | Starts anything and returns a handle immediately |
368
- | `get_run(run)` | Status, verdict once settled, and links to the files the run produced |
369
- | `tail_run(run, cursor)` | Log lines since `cursor` — live while running, from `raw.jsonl` once settled |
370
- | `cancel_run(runId)` | Tears a run down; it still finishes writing its run folder |
452
+ | `list_oms` | Exposed oms and actions: summary, mode, and the JSON Schema of their arguments. |
453
+ | `run_om(name, args)` | Run a settling entry to completion; returns its verdict and assertion tally. |
454
+ | `start_om(name, args)` | Start anything and return a handle immediately. |
455
+ | `get_run(run)` | Status, verdict once settled, and links to the files the run produced. |
456
+ | `tail_run(run, cursor)` | Log lines since `cursor`: live while running, from `raw.jsonl` once settled. |
457
+ | `cancel_run(runId)` | Tear a run down; it still writes its run folder. |
371
458
 
372
- 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.
459
+ The tool list stays at six however many oms you have, so call `list_oms` first to get each entry's argument schema.
373
460
 
374
- **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.
461
+ - **Run folders are resources.** `omkit://runs/{run}/latest/{file}` reads from an om's newest run, and `omkit://runs/{run}/{date}/{time}/{file}` from a specific one. Reads are confined to the project's `logs/`, size-capped and read-only.
462
+ - **While a run is going,** milestones arrive as progress notifications, cancelling the request tears the run down, and a `prompt` raised mid-run reaches the client as an elicitation.
375
463
 
376
- **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.
464
+ To exercise the tools by hand, use the [MCP Inspector](https://github.com/modelcontextprotocol/inspector): `npx -y @modelcontextprotocol/inspector npx --no -- omkit mcp`. On Windows, if it can't spawn the `npx` shim, point it at `node node_modules/omkit/dist/cli/index.js mcp`. Closing the Inspector tears down anything it started.
377
465
 
378
- ## A skill file — the map, before the first call
466
+ ## Skill file
379
467
 
380
- `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:
468
+ `list_oms` answers "what can I run here?" at runtime, at the cost of a round trip. `omkit skill` writes the same answer to a file an assistant already has in context:
381
469
 
382
470
  ```sh
383
471
  omkit skill # writes .claude/skills/omkit-runs/SKILL.md
384
472
  omkit skill --check # exits non-zero when it no longer matches the project
385
473
  ```
386
474
 
387
- 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:
475
+ Each exposed workflow gets its summary, argument shape, source path and an outline of what it calls, read statically with the `.tag(...)` names you wrote:
388
476
 
389
477
  ```
390
478
  ### `tskb:build` — settling
@@ -396,17 +484,20 @@ Rebuild the tskb knowledge graph from this repo's .tskb.tsx docs.
396
484
  - **Calls:** `watchDir` [watch:build:daemon] → `buildDocs` [build]
397
485
  ```
398
486
 
399
- 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.
487
+ Generating it runs nothing. Commit it, and put `--check` in a pre-commit hook or CI. Its `registry-hash` covers your declarations rather than the rendered markdown, so reformatting doesn't trip the check. The one field to edit by hand is the frontmatter `description`, which decides when an assistant loads the file; regeneration keeps it.
400
488
 
401
- 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.
489
+ ## Migrating from `om(name, body)`
402
490
 
403
- ## Install
491
+ `om(name, body)` was removed in 0.5.0. `om(name)` now returns a builder, and `.run(body)` takes the second argument:
404
492
 
405
- ```sh
406
- npm install omkit
493
+ ```diff
494
+ -om("dev", async (ctx) => {
495
+ +om("dev").run(async (ctx) => {
496
+ // ...
497
+ });
407
498
  ```
408
499
 
409
- Requires Node ≥ 20.11. ESM only.
500
+ Run folders are unaffected: a run's identity is its name plus its defining file, and line numbers are not part of the hash.
410
501
 
411
502
  ## License
412
503