omkit 0.2.0 → 0.2.1

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,14 +1,16 @@
1
1
  # omkit
2
2
 
3
- **The operational-model kit.** _Typed actions. One run. One log._
3
+ **The operational-model kit.** _Humans orchestrate. Runs narrate. AI assistants follow along._
4
4
 
5
- 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. Where [tskb](https://www.npmjs.com/package/tskb) is the _knowledge_ layer, `omkit` is the _operational_ one: the running, typed model of your system in motion.
5
+ 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**).
6
6
 
7
7
  ## The problem
8
8
 
9
9
  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.
10
10
 
11
- `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.
11
+ 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.
12
+
13
+ `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.
12
14
 
13
15
  ```ts
14
16
  import { om } from "omkit";
@@ -18,7 +20,7 @@ import { command, healthcheck, chromePage } from "omkit/actions";
18
20
  const api = command("npm run dev", { cwd: "api" });
19
21
  const web = command("npm run dev", { cwd: "web" });
20
22
 
21
- om(async () => {
23
+ om("dev", async () => {
22
24
  api().tag("api"); // start both dev servers as daemons…
23
25
  web().tag("web");
24
26
 
@@ -47,6 +49,12 @@ om(async () => {
47
49
  your smoke test
48
50
  ```
49
51
 
52
+ ## Who it's for
53
+
54
+ - **Humans orchestrate.** Pipelines are ordinary TypeScript — `await`, `if`, loops, variables — not YAML, not a DSL. Define an action once; launch it by calling it.
55
+ - **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.
56
+ - **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?".
57
+
50
58
  ## Core concepts
51
59
 
52
60
  ### Actions & Activities
@@ -61,7 +69,7 @@ const build = action("build").run(({ proc }) => proc("tsc")`tsc -b`);
61
69
  const activity = build(); // calling launches; returns the live Activity
62
70
  ```
63
71
 
64
- `om(async (ctx) => …)` hosts the orchestration as the root of a run. 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 on an Activity before your first `await` (like `withCache`) applies before its body runs. For a one-off inline step, `step(name, fn)` runs `fn` as its own node without a reusable definition.
72
+ `om(name, async (ctx) => …)` hosts the orchestration as the root of a run. 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 on an Activity before your first `await` (like `withCache`) applies before its body runs. For a one-off inline step, `step(name, fn)` runs `fn` as its own node without a reusable definition.
65
73
 
66
74
  ### Typed capabilities
67
75
 
@@ -78,7 +86,7 @@ const server = action("server")
78
86
 
79
87
  const migrate = action("migrate").run((_ctx, port: Promise<number>) => runMigrations(port));
80
88
 
81
- om(async () => {
89
+ om("migrate", async () => {
82
90
  const s = server();
83
91
  migrate(s.ref); // migrate receives the port the moment the server attaches it — typed
84
92
  });
@@ -114,7 +122,7 @@ flakyBackgroundJob().handleFailure((e) => console.warn("job failed, carrying on"
114
122
 
115
123
  **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.
116
124
 
117
- ### One log, on disk
125
+ ### One log, on disk — a run an assistant can follow
118
126
 
119
127
  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:
120
128
 
@@ -125,6 +133,12 @@ Every action's output, events, asserts, snapshots, and lifecycle land on **one t
125
133
 
126
134
  `ctx` also gives each action `assert(cond, msg)` (tallies into the run's verdict) and `snapshot(name, value)` (captures JSON state to the run folder) — so a run is an inspectable artifact, not just an exit code.
127
135
 
136
+ Run folders have a **stable identity**: `logs/<name>-<hash8>/<date>/<time>/`, keyed by the om's name and the file that defines it. 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.
137
+
138
+ ### Observing the real world, not assuming it
139
+
140
+ 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.
141
+
128
142
  ## Common recipes
129
143
 
130
144
  **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`):
@@ -132,7 +146,7 @@ Every action's output, events, asserts, snapshots, and lifecycle land on **one t
132
146
  ```ts
133
147
  const build = action("build").run(({ proc }) => proc("tsc")`tsc -b`);
134
148
 
135
- om(async () => {
149
+ om("build", async () => {
136
150
  const out = await build().withCache(`${process.cwd()}/src`).result;
137
151
  if (out.ok && out.value === undefined) console.log("no changes — skipped the build");
138
152
  });
@@ -143,7 +157,7 @@ om(async () => {
143
157
  ```ts
144
158
  import { watchDir } from "omkit/actions";
145
159
 
146
- om(async () => {
160
+ om("watch", async () => {
147
161
  const watcher = watchDir("src").tag("watch");
148
162
  watcher.on("update", (file) => console.log(`changed: ${file}`));
149
163
  // body returns, but the watcher keeps the run alive until Ctrl+C
@@ -153,19 +167,19 @@ om(async () => {
153
167
  **Gate a deploy on green tests.** Observe the outcome and branch — no try/catch:
154
168
 
155
169
  ```ts
156
- om(async () => {
170
+ om("ship", async () => {
157
171
  const tests = await command("npm test")().result;
158
172
  if (!tests.ok) return; // red → stop; the run stays green because you observed it
159
173
  await command("./deploy.sh")().result;
160
174
  });
161
175
  ```
162
176
 
163
- **Ask before doing something risky.** `prompt` falls back to a default when unattended, so it never blocks CI:
177
+ **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:
164
178
 
165
179
  ```ts
166
180
  import { prompt } from "omkit/actions";
167
181
 
168
- om(async () => {
182
+ om("deploy", async () => {
169
183
  const answer = await prompt({
170
184
  kind: "choice",
171
185
  message: "Deploy to production?",
@@ -31,7 +31,7 @@ export function command(cmdOrFile, options = {}) {
31
31
  const args = options.args;
32
32
  const name = [cmdOrFile, ...args].join(" ");
33
33
  return action(name).run(async (ctx) => {
34
- const inherit = options.inheritDebugger ?? Boolean(process.env.OMKIT_INHERIT_DEBUGGER);
34
+ const inherit = options.inheritDebugger;
35
35
  const env = inherit
36
36
  ? (options.env ?? process.env)
37
37
  : debuggerFreeEnv(options.env ?? process.env);
@@ -39,7 +39,7 @@ export declare class ExecutionTree {
39
39
  private readonly onSigint;
40
40
  private readonly onUncaught;
41
41
  private readonly onUnhandled;
42
- constructor(definedAt?: string);
42
+ constructor(name: string, definedAt?: string);
43
43
  /** Launch an instance under the ambient current node (or the root). */
44
44
  launch(spec: LaunchSpec): ActionRun;
45
45
  private append;
@@ -1,6 +1,7 @@
1
1
  import path from "node:path";
2
- import { newUuid, makeId } from "../foundation/ids.js";
2
+ import { newUuid, makeId, omHash } from "../foundation/ids.js";
3
3
  import { fsSafe } from "../foundation/fsSafe.js";
4
+ import { siteFile } from "../foundation/callsite.js";
4
5
  import { defer } from "../foundation/Deferred.js";
5
6
  import { LogStore } from "../output/log/LogStore.js";
6
7
  import { RunFolder } from "../output/folder/RunFolder.js";
@@ -36,11 +37,11 @@ export class ExecutionTree {
36
37
  ExecutionTree.current = null;
37
38
  }
38
39
  store = new LogStore();
39
- folder = new RunFolder();
40
+ folder;
40
41
  root;
41
42
  rawStream;
42
43
  consoleCapture;
43
- snapshotStore = new SnapshotStore(this.folder);
44
+ snapshotStore;
44
45
  registry = [];
45
46
  unsettled = new Set();
46
47
  originalLog = console.log.bind(console);
@@ -62,7 +63,11 @@ export class ExecutionTree {
62
63
  // Process hooks — installed at run start, removed at finalize (they must not outlive the run).
63
64
  onUncaught = (e) => this.onFatal("uncaughtException", e);
64
65
  onUnhandled = (e) => this.onFatal("unhandledRejection", e);
65
- constructor(definedAt) {
66
+ constructor(name, definedAt) {
67
+ // The run's identity: its name + where om() is written (not how it was launched),
68
+ // so same-named oms in different files get distinct log folders.
69
+ this.folder = new RunFolder(name, omHash(name, siteFile(definedAt)));
70
+ this.snapshotStore = new SnapshotStore(this.folder);
66
71
  this.folder.ensure();
67
72
  this.root = new ActionRun({
68
73
  ...this.nodeDeps(),
package/dist/core/om.d.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  import type { Awaitable, OmContext } from "./types.ts";
2
2
  /**
3
3
  * Runs a linear orchestration as the root of a fresh {@link ExecutionTree}. The
4
- * body runs inside the root node's ambient scope, so any action call / `step(...)` /
5
- * `console.*` it reaches attributes correctly. Resolves once the run
6
- * has torn down and produced its artifacts; never rejects (failures are recorded in
7
- * the tree and set the exit code).
4
+ * required `name` plus the absolute path of the file calling `om()` form the run's
5
+ * identity — its log folder is `logs/<name>-<hash8>/…`, so same-named oms defined
6
+ * in different files never share a folder. The body runs inside the root node's
7
+ * ambient scope, so any action call / `step(...)` / `console.*` it reaches
8
+ * attributes correctly. Resolves once the run has torn down and produced its
9
+ * artifacts; never rejects (failures are recorded in the tree and set the exit code).
8
10
  */
9
- export declare function om(body: (ctx: OmContext) => Awaitable<void>): Promise<void>;
11
+ export declare function om(name: string, body: (ctx: OmContext) => Awaitable<void>): Promise<void>;
package/dist/core/om.js CHANGED
@@ -2,16 +2,21 @@ import { ExecutionTree } from "./ExecutionTree.js";
2
2
  import { callerSite } from "../foundation/callsite.js";
3
3
  /**
4
4
  * Runs a linear orchestration as the root of a fresh {@link ExecutionTree}. The
5
- * body runs inside the root node's ambient scope, so any action call / `step(...)` /
6
- * `console.*` it reaches attributes correctly. Resolves once the run
7
- * has torn down and produced its artifacts; never rejects (failures are recorded in
8
- * the tree and set the exit code).
5
+ * required `name` plus the absolute path of the file calling `om()` form the run's
6
+ * identity — its log folder is `logs/<name>-<hash8>/…`, so same-named oms defined
7
+ * in different files never share a folder. The body runs inside the root node's
8
+ * ambient scope, so any action call / `step(...)` / `console.*` it reaches
9
+ * attributes correctly. Resolves once the run has torn down and produced its
10
+ * artifacts; never rejects (failures are recorded in the tree and set the exit code).
9
11
  */
10
- export function om(body) {
12
+ export function om(name, body) {
13
+ if (typeof name !== "string" || name.trim() === "") {
14
+ throw new Error("om(name, body) requires a non-empty name — it identifies the run in logs/");
15
+ }
11
16
  if (ExecutionTree.current) {
12
17
  throw new Error("an om() run is already active in this process");
13
18
  }
14
- const tree = new ExecutionTree(callerSite()); // where om() was called — the run's script
19
+ const tree = new ExecutionTree(name, callerSite()); // callerSite: where om() was called — the run's script
15
20
  ExecutionTree.current = tree;
16
21
  const rootBody = (ctx) => body({
17
22
  signal: ctx.signal,
@@ -4,4 +4,9 @@
4
4
  * where `.run(...)` was written, and `om(...)` records where the run was launched from. At
5
5
  * runtime that's the built file (e.g. `dist/actions/chrome-page.js`). `undefined` with no stack.
6
6
  */
7
+ /**
8
+ * The file part of a `file:line` site — the run-identity input (the line is dropped
9
+ * so moving a call within its file doesn't change the run's identity).
10
+ */
11
+ export declare const siteFile: (site: string | undefined) => string | undefined;
7
12
  export declare const callerSite: () => string | undefined;
@@ -22,6 +22,11 @@ const nativePath = (file) => {
22
22
  * where `.run(...)` was written, and `om(...)` records where the run was launched from. At
23
23
  * runtime that's the built file (e.g. `dist/actions/chrome-page.js`). `undefined` with no stack.
24
24
  */
25
+ /**
26
+ * The file part of a `file:line` site — the run-identity input (the line is dropped
27
+ * so moving a call within its file doesn't change the run's identity).
28
+ */
29
+ export const siteFile = (site) => site?.replace(/:\d+$/, "");
25
30
  export const callerSite = () => {
26
31
  const stack = new Error().stack;
27
32
  if (!stack)
@@ -7,3 +7,11 @@ export declare const shortId: (uuid: string) => string;
7
7
  * plain name (`main`) and skips this.
8
8
  */
9
9
  export declare const makeId: (name: string, uuid: string) => string;
10
+ /**
11
+ * A run's stable identity: first 8 hex of sha256 over the defining file's absolute
12
+ * path + the om name. Same file + name ⇒ same hash on every run; the same name
13
+ * defined in two different files diverges (consumers may define oms in same-named
14
+ * files across folders). With no file (no stack), the name alone still hashes
15
+ * deterministically.
16
+ */
17
+ export declare const omHash: (name: string, file?: string) => string;
@@ -1,4 +1,4 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { createHash, randomUUID } from "node:crypto";
2
2
  /** A fresh v4 uuid for a node. */
3
3
  export const newUuid = () => randomUUID();
4
4
  /** The short form used in ids/paths — first 8 hex of the uuid. */
@@ -8,3 +8,14 @@ export const shortId = (uuid) => uuid.slice(0, 8);
8
8
  * plain name (`main`) and skips this.
9
9
  */
10
10
  export const makeId = (name, uuid) => `${name}_${shortId(uuid)}`;
11
+ /**
12
+ * A run's stable identity: first 8 hex of sha256 over the defining file's absolute
13
+ * path + the om name. Same file + name ⇒ same hash on every run; the same name
14
+ * defined in two different files diverges (consumers may define oms in same-named
15
+ * files across folders). With no file (no stack), the name alone still hashes
16
+ * deterministically.
17
+ */
18
+ export const omHash = (name, file) => createHash("sha256")
19
+ .update(`${file ?? ""}\0${name}`)
20
+ .digest("hex")
21
+ .slice(0, 8);
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * omkit (refactored core) — the ExecutionTree model.
3
3
  *
4
- * A curated public surface. `om(body)` runs a linear orchestration as the root of
4
+ * A curated public surface. `om(name, body)` runs a linear orchestration as the root of
5
5
  * one run; `action(...)` defines typed units you launch by **calling** them;
6
6
  * `step(name, fn)` is the inline anonymous action. The run's introspectable output
7
7
  * is the on-disk `result.json` / `raw.jsonl` / `.log` files — the ExecutionTree
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * omkit (refactored core) — the ExecutionTree model.
3
3
  *
4
- * A curated public surface. `om(body)` runs a linear orchestration as the root of
4
+ * A curated public surface. `om(name, body)` runs a linear orchestration as the root of
5
5
  * one run; `action(...)` defines typed units you launch by **calling** them;
6
6
  * `step(name, fn)` is the inline anonymous action. The run's introspectable output
7
7
  * is the on-disk `result.json` / `raw.jsonl` / `.log` files — the ExecutionTree
@@ -1,13 +1,16 @@
1
1
  /**
2
- * Owns this run's single output directory — `logs/<name>/<date>/<time>/` — where
3
- * `raw.jsonl`, the per-action `.log` files, rollups, and `result.json` all live.
4
- * The path is computed once, lazily, and always returned absolute so links written
5
- * into logs resolve regardless of cwd.
2
+ * Owns this run's single output directory — `logs/<name>-<hash>/<date>/<time>/` —
3
+ * where `raw.jsonl`, the per-action `.log` files, rollups, and `result.json` all
4
+ * live. The path is computed once, lazily, and always returned absolute so links
5
+ * written into logs resolve regardless of cwd.
6
6
  */
7
7
  export declare class RunFolder {
8
+ private readonly runName;
9
+ private readonly hash;
8
10
  private dir;
9
11
  private ensured;
10
- /** The entry script's base name — the run's folder name. */
12
+ constructor(runName: string, hash: string);
13
+ /** `<name>-<hash8>` — the run's folder name, unique per (defining file, name). */
11
14
  name(): string;
12
15
  /** The absolute run directory, computed once and created on first access. */
13
16
  path(): string;
@@ -1,19 +1,25 @@
1
1
  import { mkdirSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { ymd, hms } from "../../foundation/format.js";
4
+ import { fsSafe } from "../../foundation/fsSafe.js";
4
5
  /**
5
- * Owns this run's single output directory — `logs/<name>/<date>/<time>/` — where
6
- * `raw.jsonl`, the per-action `.log` files, rollups, and `result.json` all live.
7
- * The path is computed once, lazily, and always returned absolute so links written
8
- * into logs resolve regardless of cwd.
6
+ * Owns this run's single output directory — `logs/<name>-<hash>/<date>/<time>/` —
7
+ * where `raw.jsonl`, the per-action `.log` files, rollups, and `result.json` all
8
+ * live. The path is computed once, lazily, and always returned absolute so links
9
+ * written into logs resolve regardless of cwd.
9
10
  */
10
11
  export class RunFolder {
12
+ runName;
13
+ hash;
11
14
  dir;
12
15
  ensured = false;
13
- /** The entry script's base name — the run's folder name. */
16
+ constructor(runName, hash) {
17
+ this.runName = runName;
18
+ this.hash = hash;
19
+ }
20
+ /** `<name>-<hash8>` — the run's folder name, unique per (defining file, name). */
14
21
  name() {
15
- const entry = process.argv[1];
16
- return entry ? path.basename(entry, path.extname(entry)) : "om";
22
+ return `${fsSafe(this.runName)}-${this.hash}`;
17
23
  }
18
24
  /** The absolute run directory, computed once and created on first access. */
19
25
  path() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omkit",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "A tiny runtime for developer workflows: typed actions that pass runtime capabilities and emit typed events, under one run and one shared log. The operational/runtime layer to tskb's knowledge layer.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",