omkit 0.4.7 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/CHANGELOG.md +153 -0
  2. package/README.md +170 -13
  3. package/dist/actions/browser.d.ts +1 -1
  4. package/dist/actions/browser.js +1 -1
  5. package/dist/actions/chrome-page.d.ts +46 -4
  6. package/dist/actions/chrome-page.js +81 -33
  7. package/dist/actions/drive-page.d.ts +50 -0
  8. package/dist/actions/drive-page.js +68 -0
  9. package/dist/actions/index.d.ts +7 -3
  10. package/dist/actions/index.js +3 -1
  11. package/dist/actions/port-free.d.ts +29 -0
  12. package/dist/actions/port-free.js +78 -0
  13. package/dist/actions/prompt.d.ts +32 -5
  14. package/dist/actions/prompt.js +92 -8
  15. package/dist/cli/commands/help.js +6 -0
  16. package/dist/cli/commands/init.js +1 -1
  17. package/dist/cli/commands/ls.d.ts +11 -4
  18. package/dist/cli/commands/ls.js +35 -15
  19. package/dist/cli/commands/mcp.d.ts +10 -0
  20. package/dist/cli/commands/mcp.js +13 -0
  21. package/dist/cli/commands/skill.d.ts +17 -0
  22. package/dist/cli/commands/skill.js +56 -0
  23. package/dist/cli/index.d.ts +8 -0
  24. package/dist/cli/index.js +71 -29
  25. package/dist/client/discover-child.d.ts +12 -0
  26. package/dist/client/discover-child.js +71 -0
  27. package/dist/client/discovery.d.ts +21 -1
  28. package/dist/client/discovery.js +139 -2
  29. package/dist/client/index.d.ts +2 -1
  30. package/dist/client/index.js +3 -1
  31. package/dist/client/outline.d.ts +35 -0
  32. package/dist/client/outline.js +100 -0
  33. package/dist/client/registry.d.ts +59 -0
  34. package/dist/client/runner.d.ts +12 -0
  35. package/dist/client/runner.js +16 -7
  36. package/dist/client/types.d.ts +9 -1
  37. package/dist/core/ActionRun.d.ts +5 -0
  38. package/dist/core/ActionRun.js +9 -0
  39. package/dist/core/ExecutionTree.d.ts +40 -2
  40. package/dist/core/ExecutionTree.js +66 -2
  41. package/dist/core/action.d.ts +2 -1
  42. package/dist/core/action.js +93 -9
  43. package/dist/core/args.d.ts +50 -0
  44. package/dist/core/args.js +166 -0
  45. package/dist/core/discovery-mode.d.ts +37 -0
  46. package/dist/core/discovery-mode.js +54 -0
  47. package/dist/core/interaction.d.ts +3 -1
  48. package/dist/core/om.d.ts +11 -9
  49. package/dist/core/om.js +182 -13
  50. package/dist/core/schema-hint.d.ts +20 -0
  51. package/dist/core/schema-hint.js +73 -0
  52. package/dist/core/schema-json.d.ts +12 -0
  53. package/dist/core/schema-json.js +12 -0
  54. package/dist/core/types.d.ts +113 -1
  55. package/dist/foundation/LogEntry.d.ts +1 -1
  56. package/dist/foundation/canonicalPath.d.ts +26 -0
  57. package/dist/foundation/canonicalPath.js +34 -0
  58. package/dist/index.d.ts +2 -2
  59. package/dist/index.js +1 -1
  60. package/dist/mcp/action-host.d.ts +1 -0
  61. package/dist/mcp/action-host.js +65 -0
  62. package/dist/mcp/action-identity.d.ts +23 -0
  63. package/dist/mcp/action-identity.js +35 -0
  64. package/dist/mcp/progress.d.ts +61 -0
  65. package/dist/mcp/progress.js +92 -0
  66. package/dist/mcp/resources.d.ts +55 -0
  67. package/dist/mcp/resources.js +208 -0
  68. package/dist/mcp/runs.d.ts +37 -0
  69. package/dist/mcp/runs.js +60 -0
  70. package/dist/mcp/server.d.ts +19 -0
  71. package/dist/mcp/server.js +38 -0
  72. package/dist/mcp/tail.d.ts +37 -0
  73. package/dist/mcp/tail.js +64 -0
  74. package/dist/mcp/tools.d.ts +54 -0
  75. package/dist/mcp/tools.js +374 -0
  76. package/dist/mcp/validate.d.ts +14 -0
  77. package/dist/mcp/validate.js +54 -0
  78. package/dist/mcp/verdict.d.ts +31 -0
  79. package/dist/mcp/verdict.js +47 -0
  80. package/dist/output/artifact/ArtifactStore.d.ts +24 -0
  81. package/dist/output/artifact/ArtifactStore.js +49 -0
  82. package/dist/output/folder/RunFolder.d.ts +23 -1
  83. package/dist/output/folder/RunFolder.js +55 -11
  84. package/dist/output/writers/views.d.ts +10 -0
  85. package/dist/skill/file.d.ts +26 -0
  86. package/dist/skill/file.js +58 -0
  87. package/dist/skill/index.d.ts +11 -0
  88. package/dist/skill/index.js +9 -0
  89. package/dist/skill/model.d.ts +55 -0
  90. package/dist/skill/model.js +116 -0
  91. package/dist/skill/render.d.ts +18 -0
  92. package/dist/skill/render.js +133 -0
  93. package/package.json +5 -2
@@ -0,0 +1,49 @@
1
+ import path from "node:path";
2
+ const MIME_BY_EXT = {
3
+ ".json": "application/json",
4
+ ".jsonl": "application/x-ndjson",
5
+ ".log": "text/plain",
6
+ ".txt": "text/plain",
7
+ ".md": "text/markdown",
8
+ ".html": "text/html",
9
+ ".csv": "text/csv",
10
+ ".png": "image/png",
11
+ ".jpg": "image/jpeg",
12
+ ".jpeg": "image/jpeg",
13
+ ".gif": "image/gif",
14
+ ".webp": "image/webp",
15
+ ".svg": "image/svg+xml",
16
+ ".pdf": "application/pdf",
17
+ ".zip": "application/zip",
18
+ };
19
+ /**
20
+ * The run's curated artifacts — the files an om labelled via `ctx.artifact`, in
21
+ * registration order. Holds no I/O: the om already wrote the file, this records what it
22
+ * means. Unregistered files stay in the run folder and stay listed by anything
23
+ * enumerating it; curation raises signal, it does not gate access.
24
+ */
25
+ export class ArtifactStore {
26
+ records = [];
27
+ register(name, file, nodeId, opts = {}) {
28
+ // `file` is documented absolute; a caller that passes a relative path still gets
29
+ // one back — resolved against process.cwd(), same base `path.resolve` uses by
30
+ // default — so every consumer (the return value, the timeline message, the
31
+ // on-disk rollups) carries a path that means the same thing without them.
32
+ const resolved = path.resolve(file);
33
+ const record = {
34
+ name,
35
+ file: resolved,
36
+ nodeId,
37
+ mime: opts.mime ?? mimeOf(resolved),
38
+ ...(opts.description === undefined ? {} : { description: opts.description }),
39
+ };
40
+ this.records.push(record);
41
+ return record;
42
+ }
43
+ all() {
44
+ return this.records;
45
+ }
46
+ }
47
+ function mimeOf(file) {
48
+ return MIME_BY_EXT[path.extname(file).toLowerCase()] ?? "application/octet-stream";
49
+ }
@@ -12,10 +12,32 @@ export declare class RunFolder {
12
12
  constructor(runName: string, hash: string);
13
13
  /** `<name>-<hash8>` — the run's folder name, unique per (defining file, name). */
14
14
  name(): string;
15
- /** The absolute run directory, computed once and created on first access. */
15
+ /**
16
+ * The absolute run directory — claimed on first access, stable thereafter.
17
+ *
18
+ * Claiming and knowing the path are the same act: the directory is what makes a run's
19
+ * identity exclusive, so there is no meaningful "what would the path be" to answer
20
+ * before it exists.
21
+ */
16
22
  path(): string;
17
23
  /** Absolute path to `name` inside the run folder; ensures the folder exists. */
18
24
  file(name: string): string;
19
25
  /** Create the run folder synchronously (idempotent). */
20
26
  ensure(): void;
27
+ /**
28
+ * Take exclusive ownership of a `<date>/<time>` directory.
29
+ *
30
+ * `hms` has second resolution, so two runs of the same om starting within one second
31
+ * would otherwise share a folder — and that is not merely "the later one wins":
32
+ * `RawStream` opens `raw.jsonl` with `flags: "w"`, so two live runs both truncate it and
33
+ * then write at their own offsets, leaving bytes that are not valid JSONL. The MCP
34
+ * server makes this easy to reach, since `start_om` can put two children in flight.
35
+ *
36
+ * Finer timestamps would only lower the odds. `mkdirSync` without `recursive` throws
37
+ * `EEXIST` **atomically**, and that atomicity holds between processes — so the directory
38
+ * creation is itself the lock. The common case keeps a clean `16-39-29`; only a real
39
+ * collision produces `16-39-29-02`, zero-padded so lexical order stays chronological
40
+ * (which is what `latest` relies on).
41
+ */
42
+ private claim;
21
43
  }
@@ -1,7 +1,10 @@
1
1
  import { mkdirSync } from "node:fs";
2
2
  import path from "node:path";
3
- import { ymd, hms } from "../../foundation/format.js";
3
+ import { ymd, hms, pad } from "../../foundation/format.js";
4
4
  import { fsSafe } from "../../foundation/fsSafe.js";
5
+ import { newUuid, shortId } from "../../foundation/ids.js";
6
+ /** Suffixed attempts (`-02` … `-99`) before falling back to a random, certainly-unique one. */
7
+ const MAX_NUMBERED_ATTEMPTS = 99;
5
8
  /**
6
9
  * Owns this run's single output directory — `logs/<name>-<hash>/<date>/<time>/` —
7
10
  * where `raw.jsonl`, the per-action `.log` files, rollups, and `result.json` all
@@ -21,24 +24,65 @@ export class RunFolder {
21
24
  name() {
22
25
  return `${fsSafe(this.runName)}-${this.hash}`;
23
26
  }
24
- /** The absolute run directory, computed once and created on first access. */
27
+ /**
28
+ * The absolute run directory — claimed on first access, stable thereafter.
29
+ *
30
+ * Claiming and knowing the path are the same act: the directory is what makes a run's
31
+ * identity exclusive, so there is no meaningful "what would the path be" to answer
32
+ * before it exists.
33
+ */
25
34
  path() {
26
- if (this.dir)
27
- return this.dir;
28
- const now = new Date();
29
- this.dir = path.resolve("logs", this.name(), ymd(now), hms(now, "-"));
35
+ if (!this.dir)
36
+ this.claim();
30
37
  return this.dir;
31
38
  }
32
39
  /** Absolute path to `name` inside the run folder; ensures the folder exists. */
33
40
  file(name) {
34
- this.ensure();
35
41
  return path.join(this.path(), name);
36
42
  }
37
43
  /** Create the run folder synchronously (idempotent). */
38
44
  ensure() {
39
- if (this.ensured)
40
- return;
41
- mkdirSync(this.path(), { recursive: true });
42
- this.ensured = true;
45
+ this.path();
46
+ }
47
+ /**
48
+ * Take exclusive ownership of a `<date>/<time>` directory.
49
+ *
50
+ * `hms` has second resolution, so two runs of the same om starting within one second
51
+ * would otherwise share a folder — and that is not merely "the later one wins":
52
+ * `RawStream` opens `raw.jsonl` with `flags: "w"`, so two live runs both truncate it and
53
+ * then write at their own offsets, leaving bytes that are not valid JSONL. The MCP
54
+ * server makes this easy to reach, since `start_om` can put two children in flight.
55
+ *
56
+ * Finer timestamps would only lower the odds. `mkdirSync` without `recursive` throws
57
+ * `EEXIST` **atomically**, and that atomicity holds between processes — so the directory
58
+ * creation is itself the lock. The common case keeps a clean `16-39-29`; only a real
59
+ * collision produces `16-39-29-02`, zero-padded so lexical order stays chronological
60
+ * (which is what `latest` relies on).
61
+ */
62
+ claim() {
63
+ const now = new Date();
64
+ const parent = path.resolve("logs", this.name(), ymd(now));
65
+ mkdirSync(parent, { recursive: true });
66
+ const base = hms(now, "-");
67
+ for (let attempt = 0; attempt <= MAX_NUMBERED_ATTEMPTS; attempt += 1) {
68
+ // A random suffix as the last resort: bounded loops beat an unbounded one, and a
69
+ // hundred collisions inside one second means something stranger is going on.
70
+ const suffix = attempt === 0
71
+ ? ""
72
+ : attempt === MAX_NUMBERED_ATTEMPTS
73
+ ? `-${shortId(newUuid())}`
74
+ : `-${pad(attempt + 1)}`;
75
+ const candidate = path.join(parent, `${base}${suffix}`);
76
+ try {
77
+ mkdirSync(candidate); // deliberately not recursive — EEXIST is the lock
78
+ this.dir = candidate;
79
+ this.ensured = true;
80
+ return;
81
+ }
82
+ catch (e) {
83
+ if (e.code !== "EEXIST")
84
+ throw e;
85
+ }
86
+ }
43
87
  }
44
88
  }
@@ -21,6 +21,14 @@ export interface NodeView {
21
21
  readonly logFile: string;
22
22
  readonly children: readonly NodeView[];
23
23
  }
24
+ /** A curated artifact, as projected into `result.json`. Data-only — no core import. */
25
+ export interface ArtifactView {
26
+ readonly name: string;
27
+ /** Absolute path to the file. */
28
+ readonly file: string;
29
+ readonly description?: string;
30
+ readonly mime: string;
31
+ }
24
32
  export interface RunView {
25
33
  readonly ok: boolean;
26
34
  readonly failures: ReadonlyArray<{
@@ -37,4 +45,6 @@ export interface RunView {
37
45
  /** Absolute path to `raw.jsonl`. */
38
46
  readonly rawStream: string;
39
47
  readonly root: NodeView;
48
+ /** Files the run labelled via `ctx.artifact`, in registration order. */
49
+ readonly artifacts: readonly ArtifactView[];
40
50
  }
@@ -0,0 +1,26 @@
1
+ /** Where the skill lands, relative to the project root that owns the tsconfig. */
2
+ export declare const SKILL_RELATIVE_PATH: string;
3
+ /**
4
+ * The `description` written into a file that does not exist yet.
5
+ *
6
+ * This field is the skill's loading trigger, so its wording decides whether an assistant reads
7
+ * the file at the right moment. A generated summary of contents ("lists 2 oms…") would be the
8
+ * wrong shape — the field has to say *when to use this*. So it is authored, and this is only
9
+ * the starting point: {@link readExisting} preserves whatever the author changes it to.
10
+ */
11
+ export declare const DEFAULT_DESCRIPTION = "Runnable workflows in this repo (oms and actions) \u2014 what each does, its arguments, and how to run it. Load before running or debugging any project workflow.";
12
+ /** What a previously generated file still has to say for itself. */
13
+ export interface ExistingSkill {
14
+ /** The `description:` value, verbatim — continuation lines and all. */
15
+ readonly description?: string;
16
+ /** The `registry-hash` from the generated-by marker, when the file carries one. */
17
+ readonly hash?: string;
18
+ }
19
+ /**
20
+ * Read back the two things a regeneration must not lose: the authored description, and the
21
+ * hash to compare against. A missing or unreadable file is not an error — it is the "generate
22
+ * fresh" case, and returns an empty result.
23
+ */
24
+ export declare function readExisting(file: string): ExistingSkill;
25
+ /** Write the skill, creating its directory. The parent may not exist on a first generation. */
26
+ export declare function writeSkill(file: string, content: string): void;
@@ -0,0 +1,58 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { SKILL_NAME } from "./render.js";
4
+ /** Where the skill lands, relative to the project root that owns the tsconfig. */
5
+ export const SKILL_RELATIVE_PATH = path.join(".claude", "skills", SKILL_NAME, "SKILL.md");
6
+ /**
7
+ * The `description` written into a file that does not exist yet.
8
+ *
9
+ * This field is the skill's loading trigger, so its wording decides whether an assistant reads
10
+ * the file at the right moment. A generated summary of contents ("lists 2 oms…") would be the
11
+ * wrong shape — the field has to say *when to use this*. So it is authored, and this is only
12
+ * the starting point: {@link readExisting} preserves whatever the author changes it to.
13
+ */
14
+ export const DEFAULT_DESCRIPTION = "Runnable workflows in this repo (oms and actions) — what each does, its arguments, and how to run it. Load before running or debugging any project workflow.";
15
+ /**
16
+ * Read back the two things a regeneration must not lose: the authored description, and the
17
+ * hash to compare against. A missing or unreadable file is not an error — it is the "generate
18
+ * fresh" case, and returns an empty result.
19
+ */
20
+ export function readExisting(file) {
21
+ let text;
22
+ try {
23
+ text = fs.readFileSync(file, "utf8");
24
+ }
25
+ catch {
26
+ return {};
27
+ }
28
+ const hash = /registry-hash:\s*([0-9a-f]{8})/.exec(text)?.[1];
29
+ const description = frontmatterDescription(text);
30
+ return { ...(description !== undefined ? { description } : {}), ...(hash ? { hash } : {}) };
31
+ }
32
+ /**
33
+ * Pull `description` out of the leading frontmatter, keeping any folded continuation lines
34
+ * exactly as written — they are re-emitted verbatim, so a wrapped description survives
35
+ * regeneration byte-for-byte instead of being reflowed into one long line.
36
+ */
37
+ function frontmatterDescription(text) {
38
+ const block = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text)?.[1];
39
+ if (block === undefined)
40
+ return undefined;
41
+ const lines = block.split(/\r?\n/);
42
+ const start = lines.findIndex((l) => l.startsWith("description:"));
43
+ if (start === -1)
44
+ return undefined;
45
+ const value = [lines[start].slice("description:".length).trimStart()];
46
+ // A continuation is any indented line; the next unindented line starts a new key.
47
+ for (const line of lines.slice(start + 1)) {
48
+ if (!/^\s+\S/.test(line))
49
+ break;
50
+ value.push(line);
51
+ }
52
+ return value.join("\n");
53
+ }
54
+ /** Write the skill, creating its directory. The parent may not exist on a first generation. */
55
+ export function writeSkill(file, content) {
56
+ fs.mkdirSync(path.dirname(file), { recursive: true });
57
+ fs.writeFileSync(file, content, "utf8");
58
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The generated-skill layer: turn a project's registrations into `.claude/skills/omkit-runs/`.
3
+ *
4
+ * Split so that everything except {@link module:./file.ts} is pure — the model and the renderer
5
+ * take data and return data, which is what makes determinism testable without touching a disk.
6
+ */
7
+ export { buildSkillModel, registryHash } from "./model.ts";
8
+ export type { SkillCall, SkillEntry, SkillModel } from "./model.ts";
9
+ export { renderSkill, generatedBy, SKILL_NAME } from "./render.ts";
10
+ export { readExisting, writeSkill, DEFAULT_DESCRIPTION, SKILL_RELATIVE_PATH } from "./file.ts";
11
+ export type { ExistingSkill } from "./file.ts";
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The generated-skill layer: turn a project's registrations into `.claude/skills/omkit-runs/`.
3
+ *
4
+ * Split so that everything except {@link module:./file.ts} is pure — the model and the renderer
5
+ * take data and return data, which is what makes determinism testable without touching a disk.
6
+ */
7
+ export { buildSkillModel, registryHash } from "./model.js";
8
+ export { renderSkill, generatedBy, SKILL_NAME } from "./render.js";
9
+ export { readExisting, writeSkill, DEFAULT_DESCRIPTION, SKILL_RELATIVE_PATH } from "./file.js";
@@ -0,0 +1,55 @@
1
+ import type { McpMode } from "../core/types.ts";
2
+ import type { JsonSchema } from "../core/schema-json.ts";
3
+ import type { OmCall, RegistrationSet, Registry } from "../client/registry.ts";
4
+ /** One step in a rendered outline. Structurally an {@link OmCall}; named for the skill's vocabulary. */
5
+ export type SkillCall = OmCall;
6
+ /** One runnable thing the generated skill documents. */
7
+ export interface SkillEntry {
8
+ readonly kind: "om" | "action";
9
+ readonly name: string;
10
+ /** Project-relative, forward slashes on every platform — so the file does not churn per-OS. */
11
+ readonly file: string;
12
+ readonly mode: McpMode;
13
+ /** The `.describe({ summary })` line. Absent when the author wrote none; never invented. */
14
+ readonly summary?: string;
15
+ /** A one-line sketch of the declared args, from {@link shapeHint}. */
16
+ readonly argHint?: string;
17
+ /** The raw schema, present only when `shapeHint` could not sketch it (per C7). */
18
+ readonly argSchema?: JsonSchema;
19
+ /** The static outline of the body. Absent when the walk found nothing recognisable. */
20
+ readonly calls?: readonly SkillCall[];
21
+ /** Why this entry cannot be called — carried through from discovery, rendered as a warning. */
22
+ readonly unavailable?: string;
23
+ /** Actions only: the exported binding a host om imports. */
24
+ readonly exportName?: string;
25
+ /** Actions only: true when the chain includes `.ref<…>()`, so it publishes a capability. */
26
+ readonly publishesCapability?: boolean;
27
+ }
28
+ /** Everything the renderer needs, plus the hash that detects drift against it. */
29
+ export interface SkillModel {
30
+ readonly oms: readonly SkillEntry[];
31
+ readonly actions: readonly SkillEntry[];
32
+ /** First 8 hex of sha256 over the registry data below — never over the rendered markdown. */
33
+ readonly hash: string;
34
+ }
35
+ /**
36
+ * Fold a project's registrations and its AST scan into the model the skill renders from.
37
+ *
38
+ * The two inputs answer different halves: the fork knows summaries, modes, and arg schemas
39
+ * (values that only exist once a module has been evaluated), while the AST scan knows the
40
+ * body outline and whether an action publishes a capability. Neither alone is enough.
41
+ *
42
+ * Only `.mcp()`-marked entries survive — exposure is opt-in, exactly as it is for the MCP
43
+ * server, so generating a skill can never widen what an assistant will attempt.
44
+ */
45
+ export declare function buildSkillModel(registrations: RegistrationSet, registry: Registry, root: string): SkillModel;
46
+ /**
47
+ * The staleness hash: first 8 hex of sha256 over the registry data, deliberately **not** over
48
+ * the rendered markdown, so reformatting the file does not read as drift.
49
+ *
50
+ * The outline is included even though the spec's list of inputs predates it: an edited om body
51
+ * changes what the file claims the run does, and leaving that undetected is exactly the silent
52
+ * drift the hash exists to catch. Keys are sorted before hashing so a schema serialised in a
53
+ * different property order cannot masquerade as a change.
54
+ */
55
+ export declare function registryHash(entries: readonly SkillEntry[]): string;
@@ -0,0 +1,116 @@
1
+ import path from "node:path";
2
+ import { createHash } from "node:crypto";
3
+ import { shapeHint } from "../core/schema-hint.js";
4
+ /**
5
+ * Fold a project's registrations and its AST scan into the model the skill renders from.
6
+ *
7
+ * The two inputs answer different halves: the fork knows summaries, modes, and arg schemas
8
+ * (values that only exist once a module has been evaluated), while the AST scan knows the
9
+ * body outline and whether an action publishes a capability. Neither alone is enough.
10
+ *
11
+ * Only `.mcp()`-marked entries survive — exposure is opt-in, exactly as it is for the MCP
12
+ * server, so generating a skill can never widen what an assistant will attempt.
13
+ */
14
+ export function buildSkillModel(registrations, registry, root) {
15
+ const outlines = new Map(registry.oms.map((o) => [key(o.name, o.file), o.calls]));
16
+ const capabilities = new Map(registry.actions.map((a) => [key(a.name, a.file), a.publishesCapability]));
17
+ const oms = registrations.oms
18
+ .filter(isExposed)
19
+ .map((om) => omEntry(om, outlines.get(key(om.name, om.file)), root))
20
+ .sort(byName);
21
+ const actions = registrations.actions
22
+ .filter(isExposed)
23
+ .map((a) => actionEntry(a, capabilities.get(key(a.name, a.file)), root))
24
+ .sort(byName);
25
+ return { oms, actions, hash: registryHash([...oms, ...actions]) };
26
+ }
27
+ function isExposed(r) {
28
+ return r.mcp !== undefined;
29
+ }
30
+ /** Identity for cross-referencing the two discovery passes: an om is its name in its file. */
31
+ function key(name, file) {
32
+ return `${name}\0${path.resolve(file)}`;
33
+ }
34
+ /** Alphabetical by name, then by file — so two same-named oms in different files stay ordered. */
35
+ function byName(a, b) {
36
+ return a.name.localeCompare(b.name) || a.file.localeCompare(b.file);
37
+ }
38
+ function omEntry(om, calls, root) {
39
+ return {
40
+ kind: "om",
41
+ name: om.name,
42
+ file: relative(om.file, root),
43
+ mode: om.mcp?.mode ?? "settling",
44
+ ...describeArgs(om.inputSchema),
45
+ ...(om.summary ? { summary: om.summary } : {}),
46
+ ...(calls?.length ? { calls } : {}),
47
+ ...(om.unavailable ? { unavailable: om.unavailable } : {}),
48
+ };
49
+ }
50
+ function actionEntry(action, publishesCapability, root) {
51
+ return {
52
+ kind: "action",
53
+ name: action.name,
54
+ file: relative(action.file, root),
55
+ mode: action.mcp?.mode ?? "settling",
56
+ exportName: action.exportName,
57
+ ...(publishesCapability ? { publishesCapability: true } : {}),
58
+ ...describeArgs(action.inputSchema),
59
+ ...(action.summary ? { summary: action.summary } : {}),
60
+ ...(action.unavailable ? { unavailable: action.unavailable } : {}),
61
+ };
62
+ }
63
+ /**
64
+ * Sketch a schema, falling back to the schema itself. `shapeHint` is all-or-nothing by design,
65
+ * so a `undefined` return means the shape would have been misleading — the honest answer then
66
+ * is the raw JSON Schema the caller actually has to satisfy, not a partial sketch.
67
+ */
68
+ function describeArgs(schema) {
69
+ if (!schema)
70
+ return {};
71
+ const hint = shapeHint(schema);
72
+ return hint === undefined ? { argSchema: schema } : { argHint: hint };
73
+ }
74
+ /** Project-relative with forward slashes; absolute only if the file lies outside the root. */
75
+ function relative(file, root) {
76
+ const rel = path.relative(root, file);
77
+ if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel))
78
+ return posix(file);
79
+ return posix(rel);
80
+ }
81
+ const posix = (p) => p.split(path.sep).join("/").split("\\").join("/");
82
+ /**
83
+ * The staleness hash: first 8 hex of sha256 over the registry data, deliberately **not** over
84
+ * the rendered markdown, so reformatting the file does not read as drift.
85
+ *
86
+ * The outline is included even though the spec's list of inputs predates it: an edited om body
87
+ * changes what the file claims the run does, and leaving that undetected is exactly the silent
88
+ * drift the hash exists to catch. Keys are sorted before hashing so a schema serialised in a
89
+ * different property order cannot masquerade as a change.
90
+ */
91
+ export function registryHash(entries) {
92
+ const material = entries.map((e) => [
93
+ e.kind,
94
+ e.name,
95
+ e.file,
96
+ e.mode,
97
+ e.summary ?? "",
98
+ e.exportName ?? "",
99
+ e.unavailable ?? "",
100
+ e.publishesCapability === true,
101
+ e.argSchema ?? e.argHint ?? "",
102
+ (e.calls ?? []).map((c) => [c.name, c.tag ?? ""]),
103
+ ]);
104
+ return createHash("sha256").update(canonical(material)).digest("hex").slice(0, 8);
105
+ }
106
+ /** JSON with object keys sorted at every depth, so key order can never change the hash. */
107
+ function canonical(value) {
108
+ if (value === null || typeof value !== "object")
109
+ return JSON.stringify(value) ?? "null";
110
+ if (Array.isArray(value))
111
+ return `[${value.map(canonical).join(",")}]`;
112
+ const entries = Object.entries(value)
113
+ .filter(([, v]) => v !== undefined)
114
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
115
+ return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`).join(",")}}`;
116
+ }
@@ -0,0 +1,18 @@
1
+ import type { SkillModel } from "./model.ts";
2
+ /** The skill's directory name, and so the `name:` in its frontmatter. */
3
+ export declare const SKILL_NAME = "omkit-runs";
4
+ /**
5
+ * The marker line that carries the staleness hash. Written by {@link renderSkill} and read
6
+ * back by the file layer, so the two agree on one format rather than two regexes.
7
+ */
8
+ export declare const generatedBy: (hash: string) => string;
9
+ /**
10
+ * Render the model as the skill's markdown.
11
+ *
12
+ * Deterministic by construction: no timestamps, no absolute paths, no iteration over unordered
13
+ * collections — the model arrives sorted. A generated file that is committed and churns on
14
+ * every run is a file people stop reading.
15
+ */
16
+ export declare function renderSkill(model: SkillModel, opts: {
17
+ description: string;
18
+ }): string;
@@ -0,0 +1,133 @@
1
+ /** The skill's directory name, and so the `name:` in its frontmatter. */
2
+ export const SKILL_NAME = "omkit-runs";
3
+ /**
4
+ * The marker line that carries the staleness hash. Written by {@link renderSkill} and read
5
+ * back by the file layer, so the two agree on one format rather than two regexes.
6
+ */
7
+ export const generatedBy = (hash) => `<!-- GENERATED by \`omkit skill\`. Do not edit. registry-hash: ${hash} -->`;
8
+ /**
9
+ * Render the model as the skill's markdown.
10
+ *
11
+ * Deterministic by construction: no timestamps, no absolute paths, no iteration over unordered
12
+ * collections — the model arrives sorted. A generated file that is committed and churns on
13
+ * every run is a file people stop reading.
14
+ */
15
+ export function renderSkill(model, opts) {
16
+ const lines = [
17
+ "---",
18
+ `name: ${SKILL_NAME}`,
19
+ `description: ${opts.description}`,
20
+ "---",
21
+ "",
22
+ "# omkit — runnable workflows",
23
+ "",
24
+ generatedBy(model.hash),
25
+ "",
26
+ ...howToRun(model),
27
+ ...workflows(model),
28
+ ...actions(model),
29
+ ...whereOutputLands(),
30
+ ];
31
+ return `${lines.join("\n")}\n`;
32
+ }
33
+ function howToRun(model) {
34
+ const example = model.oms[0]?.name ?? "<om>";
35
+ const withArgs = model.oms.find((o) => o.argHint !== undefined) ?? model.oms[0];
36
+ return [
37
+ "## How to run",
38
+ "",
39
+ "**Shell — always available:**",
40
+ "",
41
+ "```bash",
42
+ `npx omkit run ${example}`,
43
+ `OMKIT_ARGS='${exampleArgs(withArgs)}' npx omkit run ${withArgs?.name ?? example}`,
44
+ "```",
45
+ "",
46
+ "Arguments travel as one JSON object in `OMKIT_ARGS`. Anything an om declares but does not",
47
+ "receive is filled from the schema's defaults, and only then by asking.",
48
+ "",
49
+ '**MCP — if configured:** `run_om("<name>", { …args })` runs a settling workflow to',
50
+ "completion and returns its verdict; `start_om` starts a long-lived one and returns a run id",
51
+ "to poll with `get_run`, read with `tail_run`, and end with `cancel_run`.",
52
+ "",
53
+ "To wire it up: `claude mcp add omkit -- npx omkit mcp`",
54
+ "",
55
+ "At runtime `list_oms` is authoritative — this file is orientation, and can drift.",
56
+ "",
57
+ ];
58
+ }
59
+ /**
60
+ * A plausible `OMKIT_ARGS` value built from the first declared field, so the example shows the
61
+ * real shape rather than a placeholder. Falls back to an empty object when nothing is declared.
62
+ */
63
+ function exampleArgs(entry) {
64
+ const match = entry?.argHint?.match(/\{ (\w+)\??: (\w+)/);
65
+ if (!match)
66
+ return "{}";
67
+ const [, field, type] = match;
68
+ const value = type === "string" ? '"…"' : type === "boolean" ? "false" : "0";
69
+ return `{"${field}":${value}}`;
70
+ }
71
+ function workflows(model) {
72
+ if (model.oms.length === 0) {
73
+ return ["## Workflows", "", "_None exposed. Mark an om with `.mcp()` to list it here._", ""];
74
+ }
75
+ return ["## Workflows", "", ...model.oms.flatMap((om) => entry(om, omHeading(om)))];
76
+ }
77
+ function actions(model) {
78
+ if (model.actions.length === 0)
79
+ return [];
80
+ return [
81
+ "## Actions",
82
+ "",
83
+ "An action is a step, not a run. It cannot execute on its own — omkit hosts it in a",
84
+ "generated om so it has a run to belong to, which means it gets its own run folder and",
85
+ "verdict like anything else.",
86
+ "",
87
+ ...model.actions.flatMap((a) => entry(a, `### \`${a.name}\``)),
88
+ ];
89
+ }
90
+ function omHeading(om) {
91
+ const mode = om.mode === "long-lived" ? "long-lived · `start_om` only, it does not finish" : "settling";
92
+ return `### \`${om.name}\` — ${mode}`;
93
+ }
94
+ function entry(e, heading) {
95
+ const lines = [heading, ""];
96
+ lines.push(e.summary ?? "_No summary. The author can add one with `.describe({ summary })`._", "");
97
+ lines.push(`- **Defined in:** \`${e.file}\``);
98
+ if (e.kind === "action" && e.exportName)
99
+ lines.push(`- **Exported as:** \`${e.exportName}\``);
100
+ if (e.argHint)
101
+ lines.push(`- **Args:** \`${e.argHint}\``);
102
+ if (e.calls?.length)
103
+ lines.push(`- **Calls:** ${e.calls.map(renderCall).join(" → ")}`);
104
+ if (e.publishesCapability) {
105
+ lines.push("- **Publishes a capability.** It hands a live handle to whatever runs after it. Run on", " its own there is no downstream, so the handle goes nowhere — useful for a smoke test,", " not for the thing the capability was for.");
106
+ }
107
+ if (e.unavailable)
108
+ lines.push(`- **Not callable:** ${e.unavailable}`);
109
+ if (e.argSchema) {
110
+ lines.push("- **Args** (no short form — the schema is the contract):", "", "```json");
111
+ lines.push(JSON.stringify(e.argSchema, null, 2));
112
+ lines.push("```");
113
+ }
114
+ lines.push("");
115
+ return lines;
116
+ }
117
+ const renderCall = (c) => c.tag ? `\`${c.name}\` [${c.tag}]` : `\`${c.name}\``;
118
+ function whereOutputLands() {
119
+ return [
120
+ "## Where output lands",
121
+ "",
122
+ "`logs/<name>-<hash8>/<date>/<time>/` — `main.log`, `result.json`, `artifacts.log`,",
123
+ "`events.log`, `asserts.log`, `raw.jsonl`. A run's identity is its name plus its defining",
124
+ 'file, so "the latest run of X" is always resolvable without parsing scrollback.',
125
+ "",
126
+ "## What the outlines above do and do not tell you",
127
+ "",
128
+ "**Calls** is a static reading of the body, not a trace. A conditional call is listed",
129
+ "unconditionally, a loop is listed once, an action chosen at runtime is not listed at all,",
130
+ 'and a call made inside a helper function is invisible to it. Read it as "roughly what',
131
+ 'happens in here" and read `result.json` for what actually did.',
132
+ ];
133
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omkit",
3
- "version": "0.4.7",
3
+ "version": "0.8.0",
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",
@@ -10,7 +10,8 @@
10
10
  },
11
11
  "files": [
12
12
  "dist",
13
- "LICENSE"
13
+ "LICENSE",
14
+ "CHANGELOG.md"
14
15
  ],
15
16
  "exports": {
16
17
  ".": {
@@ -52,11 +53,13 @@
52
53
  "developer-tools"
53
54
  ],
54
55
  "dependencies": {
56
+ "@modelcontextprotocol/sdk": "^1.30.0",
55
57
  "ink": "^7.1.1",
56
58
  "patch-console": "^2.0.0",
57
59
  "playwright-core": "^1.49.0",
58
60
  "react": "^19.2.8",
59
61
  "tsx": "4.23.1",
62
+ "zod": "^4.4.3",
60
63
  "zx": "^8.8.5"
61
64
  },
62
65
  "devDependencies": {