omkit 0.4.8 → 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 (89) hide show
  1. package/CHANGELOG.md +153 -0
  2. package/README.md +168 -12
  3. package/dist/actions/chrome-page.d.ts +41 -0
  4. package/dist/actions/chrome-page.js +77 -29
  5. package/dist/actions/drive-page.d.ts +50 -0
  6. package/dist/actions/drive-page.js +68 -0
  7. package/dist/actions/index.d.ts +5 -3
  8. package/dist/actions/index.js +2 -1
  9. package/dist/actions/prompt.d.ts +32 -5
  10. package/dist/actions/prompt.js +92 -8
  11. package/dist/cli/commands/help.js +6 -0
  12. package/dist/cli/commands/init.js +1 -1
  13. package/dist/cli/commands/ls.d.ts +11 -4
  14. package/dist/cli/commands/ls.js +35 -15
  15. package/dist/cli/commands/mcp.d.ts +10 -0
  16. package/dist/cli/commands/mcp.js +13 -0
  17. package/dist/cli/commands/skill.d.ts +17 -0
  18. package/dist/cli/commands/skill.js +56 -0
  19. package/dist/cli/index.d.ts +8 -0
  20. package/dist/cli/index.js +71 -29
  21. package/dist/client/discover-child.d.ts +12 -0
  22. package/dist/client/discover-child.js +71 -0
  23. package/dist/client/discovery.d.ts +21 -1
  24. package/dist/client/discovery.js +139 -2
  25. package/dist/client/index.d.ts +2 -1
  26. package/dist/client/index.js +3 -1
  27. package/dist/client/outline.d.ts +35 -0
  28. package/dist/client/outline.js +100 -0
  29. package/dist/client/registry.d.ts +59 -0
  30. package/dist/client/runner.d.ts +12 -0
  31. package/dist/client/runner.js +16 -7
  32. package/dist/client/types.d.ts +9 -1
  33. package/dist/core/ActionRun.d.ts +5 -0
  34. package/dist/core/ActionRun.js +9 -0
  35. package/dist/core/ExecutionTree.d.ts +40 -2
  36. package/dist/core/ExecutionTree.js +66 -2
  37. package/dist/core/action.d.ts +2 -1
  38. package/dist/core/action.js +93 -9
  39. package/dist/core/args.d.ts +50 -0
  40. package/dist/core/args.js +166 -0
  41. package/dist/core/discovery-mode.d.ts +37 -0
  42. package/dist/core/discovery-mode.js +54 -0
  43. package/dist/core/interaction.d.ts +3 -1
  44. package/dist/core/om.d.ts +11 -9
  45. package/dist/core/om.js +182 -13
  46. package/dist/core/schema-hint.d.ts +20 -0
  47. package/dist/core/schema-hint.js +73 -0
  48. package/dist/core/schema-json.d.ts +12 -0
  49. package/dist/core/schema-json.js +12 -0
  50. package/dist/core/types.d.ts +113 -1
  51. package/dist/foundation/LogEntry.d.ts +1 -1
  52. package/dist/foundation/canonicalPath.d.ts +26 -0
  53. package/dist/foundation/canonicalPath.js +34 -0
  54. package/dist/index.d.ts +2 -2
  55. package/dist/index.js +1 -1
  56. package/dist/mcp/action-host.d.ts +1 -0
  57. package/dist/mcp/action-host.js +65 -0
  58. package/dist/mcp/action-identity.d.ts +23 -0
  59. package/dist/mcp/action-identity.js +35 -0
  60. package/dist/mcp/progress.d.ts +61 -0
  61. package/dist/mcp/progress.js +92 -0
  62. package/dist/mcp/resources.d.ts +55 -0
  63. package/dist/mcp/resources.js +208 -0
  64. package/dist/mcp/runs.d.ts +37 -0
  65. package/dist/mcp/runs.js +60 -0
  66. package/dist/mcp/server.d.ts +19 -0
  67. package/dist/mcp/server.js +38 -0
  68. package/dist/mcp/tail.d.ts +37 -0
  69. package/dist/mcp/tail.js +64 -0
  70. package/dist/mcp/tools.d.ts +54 -0
  71. package/dist/mcp/tools.js +374 -0
  72. package/dist/mcp/validate.d.ts +14 -0
  73. package/dist/mcp/validate.js +54 -0
  74. package/dist/mcp/verdict.d.ts +31 -0
  75. package/dist/mcp/verdict.js +47 -0
  76. package/dist/output/artifact/ArtifactStore.d.ts +24 -0
  77. package/dist/output/artifact/ArtifactStore.js +49 -0
  78. package/dist/output/folder/RunFolder.d.ts +23 -1
  79. package/dist/output/folder/RunFolder.js +55 -11
  80. package/dist/output/writers/views.d.ts +10 -0
  81. package/dist/skill/file.d.ts +26 -0
  82. package/dist/skill/file.js +58 -0
  83. package/dist/skill/index.d.ts +11 -0
  84. package/dist/skill/index.js +9 -0
  85. package/dist/skill/model.d.ts +55 -0
  86. package/dist/skill/model.js +116 -0
  87. package/dist/skill/render.d.ts +18 -0
  88. package/dist/skill/render.js +133 -0
  89. package/package.json +5 -2
@@ -0,0 +1,54 @@
1
+ const typeName = (value) => Array.isArray(value) ? "array" : value === null ? "null" : typeof value;
2
+ /** True when `value` satisfies a JSON Schema `type` keyword. Unknown types always pass. */
3
+ function matches(type, value) {
4
+ switch (type) {
5
+ case "string":
6
+ return typeof value === "string";
7
+ case "number":
8
+ return typeof value === "number";
9
+ case "integer":
10
+ return typeof value === "number" && Number.isInteger(value);
11
+ case "boolean":
12
+ return typeof value === "boolean";
13
+ case "array":
14
+ return Array.isArray(value);
15
+ case "object":
16
+ return typeof value === "object" && value !== null && !Array.isArray(value);
17
+ default:
18
+ return true;
19
+ }
20
+ }
21
+ /** Required fields the caller left out. `null` counts as supplied — and wrongly typed. */
22
+ function missingRequired(schema, args) {
23
+ const required = Array.isArray(schema.required) ? schema.required : [];
24
+ return required
25
+ .filter((key) => args[key] === undefined)
26
+ .map((key) => `missing required argument "${key}"`);
27
+ }
28
+ /** Supplied fields whose value contradicts the schema's `type` keyword. */
29
+ function wrongTypes(schema, args) {
30
+ const properties = (schema.properties ?? {});
31
+ const problems = [];
32
+ for (const [key, definition] of Object.entries(properties)) {
33
+ const value = args[key];
34
+ if (value === undefined)
35
+ continue;
36
+ const type = definition?.type;
37
+ if (typeof type !== "string")
38
+ continue; // unions, $ref, anyOf — the child's problem
39
+ if (!matches(type, value)) {
40
+ problems.push(`argument "${key}" must be a ${type}, got ${typeName(value)}`);
41
+ }
42
+ }
43
+ return problems;
44
+ }
45
+ /** Every problem found, in schema order. Empty means "worth spawning". */
46
+ export function checkArgs(schema, args) {
47
+ if (!schema)
48
+ return [];
49
+ if (typeof args !== "object" || args === null || Array.isArray(args)) {
50
+ return ["arguments must be a JSON object"];
51
+ }
52
+ const supplied = args;
53
+ return [...missingRequired(schema, supplied), ...wrongTypes(schema, supplied)];
54
+ }
@@ -0,0 +1,31 @@
1
+ import type { Verdict } from "../client/types.ts";
2
+ /**
3
+ * The structured outcome of a settled run — the verdict a client can act on.
4
+ *
5
+ * A `type`, not an `interface`, on purpose: the SDK types `structuredContent` as
6
+ * `{ [x: string]: unknown }`, and TypeScript gives an implicit index signature to type
7
+ * aliases but not to interfaces. As an interface this is not assignable and the tool
8
+ * cannot return it.
9
+ */
10
+ export type RunVerdict = {
11
+ readonly ok: boolean;
12
+ /** Absolute path of the run folder, or "" when the child died before settling. */
13
+ readonly folder: string;
14
+ readonly assertions: {
15
+ readonly passed: number;
16
+ readonly failed: number;
17
+ };
18
+ readonly failures: ReadonlyArray<{
19
+ action: string;
20
+ error: string;
21
+ }>;
22
+ /** The recap block the terminal would have printed. */
23
+ readonly summary: readonly string[];
24
+ };
25
+ export declare function readVerdict(verdict: Verdict): Promise<RunVerdict>;
26
+ /**
27
+ * The verdict of a run this process never supervised — read entirely from disk. There is
28
+ * no channel to trust here, so `ok` comes from the file; a folder without a readable
29
+ * `result.json` is reported as not ok, because a run that never wrote one did not finish.
30
+ */
31
+ export declare function readVerdictAt(folder: string): Promise<RunVerdict>;
@@ -0,0 +1,47 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ /** The run's own `result.json`, or undefined when there isn't a readable one. */
4
+ async function readResult(folder) {
5
+ try {
6
+ return JSON.parse(await readFile(path.join(folder, "result.json"), "utf8"));
7
+ }
8
+ catch {
9
+ return undefined;
10
+ }
11
+ }
12
+ export async function readVerdict(verdict) {
13
+ const base = {
14
+ // The channel's `ok` wins over the file's: it also covers a child that died before
15
+ // writing one, which `result.json` by definition cannot report.
16
+ ok: verdict.ok,
17
+ folder: verdict.folder,
18
+ summary: verdict.summary,
19
+ assertions: { passed: 0, failed: 0 },
20
+ failures: [],
21
+ };
22
+ if (!verdict.folder)
23
+ return base;
24
+ const parsed = await readResult(verdict.folder);
25
+ if (!parsed)
26
+ return base;
27
+ return {
28
+ ...base,
29
+ assertions: parsed.assertions ?? base.assertions,
30
+ failures: parsed.failures ?? base.failures,
31
+ };
32
+ }
33
+ /**
34
+ * The verdict of a run this process never supervised — read entirely from disk. There is
35
+ * no channel to trust here, so `ok` comes from the file; a folder without a readable
36
+ * `result.json` is reported as not ok, because a run that never wrote one did not finish.
37
+ */
38
+ export async function readVerdictAt(folder) {
39
+ const parsed = await readResult(folder);
40
+ return {
41
+ ok: parsed?.ok ?? false,
42
+ folder,
43
+ summary: [],
44
+ assertions: parsed?.assertions ?? { passed: 0, failed: 0 },
45
+ failures: parsed?.failures ?? [],
46
+ };
47
+ }
@@ -0,0 +1,24 @@
1
+ /** One curated artifact — a file the om chose to label. */
2
+ export interface ArtifactRecord {
3
+ readonly name: string;
4
+ /** Absolute path to the file. */
5
+ readonly file: string;
6
+ readonly description?: string;
7
+ readonly mime: string;
8
+ /** The node that registered it. */
9
+ readonly nodeId: string;
10
+ }
11
+ /**
12
+ * The run's curated artifacts — the files an om labelled via `ctx.artifact`, in
13
+ * registration order. Holds no I/O: the om already wrote the file, this records what it
14
+ * means. Unregistered files stay in the run folder and stay listed by anything
15
+ * enumerating it; curation raises signal, it does not gate access.
16
+ */
17
+ export declare class ArtifactStore {
18
+ private readonly records;
19
+ register(name: string, file: string, nodeId: string, opts?: {
20
+ description?: string;
21
+ mime?: string;
22
+ }): ArtifactRecord;
23
+ all(): readonly ArtifactRecord[];
24
+ }
@@ -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;