omkit 0.4.8 → 0.8.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.
Files changed (92) hide show
  1. package/CHANGELOG.md +188 -0
  2. package/README.md +169 -13
  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 +7 -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 +18 -0
  18. package/dist/cli/commands/skill.js +62 -0
  19. package/dist/cli/index.d.ts +8 -0
  20. package/dist/cli/index.js +78 -30
  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 +15 -5
  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 +14 -1
  33. package/dist/client/types.js +5 -1
  34. package/dist/core/ActionRun.d.ts +5 -0
  35. package/dist/core/ActionRun.js +9 -0
  36. package/dist/core/ExecutionTree.d.ts +40 -2
  37. package/dist/core/ExecutionTree.js +66 -2
  38. package/dist/core/action.d.ts +2 -1
  39. package/dist/core/action.js +93 -9
  40. package/dist/core/args.d.ts +50 -0
  41. package/dist/core/args.js +166 -0
  42. package/dist/core/discovery-mode.d.ts +37 -0
  43. package/dist/core/discovery-mode.js +54 -0
  44. package/dist/core/interaction.d.ts +3 -1
  45. package/dist/core/om.d.ts +11 -9
  46. package/dist/core/om.js +182 -13
  47. package/dist/core/schema-hint.d.ts +20 -0
  48. package/dist/core/schema-hint.js +73 -0
  49. package/dist/core/schema-json.d.ts +12 -0
  50. package/dist/core/schema-json.js +12 -0
  51. package/dist/core/types.d.ts +113 -1
  52. package/dist/foundation/LogEntry.d.ts +1 -1
  53. package/dist/foundation/canonicalPath.d.ts +26 -0
  54. package/dist/foundation/canonicalPath.js +34 -0
  55. package/dist/index.d.ts +2 -2
  56. package/dist/index.js +1 -1
  57. package/dist/mcp/action-host.d.ts +1 -0
  58. package/dist/mcp/action-host.js +65 -0
  59. package/dist/mcp/action-identity.d.ts +23 -0
  60. package/dist/mcp/action-identity.js +35 -0
  61. package/dist/mcp/progress.d.ts +61 -0
  62. package/dist/mcp/progress.js +92 -0
  63. package/dist/mcp/resources.d.ts +55 -0
  64. package/dist/mcp/resources.js +208 -0
  65. package/dist/mcp/runs.d.ts +37 -0
  66. package/dist/mcp/runs.js +60 -0
  67. package/dist/mcp/server.d.ts +19 -0
  68. package/dist/mcp/server.js +38 -0
  69. package/dist/mcp/tail.d.ts +37 -0
  70. package/dist/mcp/tail.js +64 -0
  71. package/dist/mcp/tools.d.ts +54 -0
  72. package/dist/mcp/tools.js +378 -0
  73. package/dist/mcp/validate.d.ts +14 -0
  74. package/dist/mcp/validate.js +54 -0
  75. package/dist/mcp/verdict.d.ts +31 -0
  76. package/dist/mcp/verdict.js +47 -0
  77. package/dist/output/artifact/ArtifactStore.d.ts +24 -0
  78. package/dist/output/artifact/ArtifactStore.js +49 -0
  79. package/dist/output/folder/RunFolder.d.ts +23 -1
  80. package/dist/output/folder/RunFolder.js +70 -11
  81. package/dist/output/writers/views.d.ts +10 -0
  82. package/dist/skill/file.d.ts +26 -0
  83. package/dist/skill/file.js +58 -0
  84. package/dist/skill/index.d.ts +12 -0
  85. package/dist/skill/index.js +10 -0
  86. package/dist/skill/model.d.ts +69 -0
  87. package/dist/skill/model.js +200 -0
  88. package/dist/skill/render.d.ts +18 -0
  89. package/dist/skill/render.js +159 -0
  90. package/dist/skill/root.d.ts +15 -0
  91. package/dist/skill/root.js +27 -0
  92. package/package.json +5 -2
@@ -1,7 +1,25 @@
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;
8
+ /**
9
+ * Where `logs/` goes: the project root a frontend named, or the working directory when nobody
10
+ * named one.
11
+ *
12
+ * A run's cwd is not a statement about which project it belongs to — `omkit run` sets it to the
13
+ * om file's own directory so relative paths in a body resolve the way their author reads them,
14
+ * which meant one project scattered its records across every folder that happened to contain an
15
+ * om, while the MCP server wrote them wherever the server was started. Two names for one thing.
16
+ * The root is passed explicitly and separately so the record's location is a property of the
17
+ * project, and the cwd stays free to mean what it already meant.
18
+ */
19
+ function logsRoot() {
20
+ const root = process.env.OMKIT_ROOT;
21
+ return root ? path.resolve(root, "logs") : path.resolve("logs");
22
+ }
5
23
  /**
6
24
  * Owns this run's single output directory — `logs/<name>-<hash>/<date>/<time>/` —
7
25
  * where `raw.jsonl`, the per-action `.log` files, rollups, and `result.json` all
@@ -21,24 +39,65 @@ export class RunFolder {
21
39
  name() {
22
40
  return `${fsSafe(this.runName)}-${this.hash}`;
23
41
  }
24
- /** The absolute run directory, computed once and created on first access. */
42
+ /**
43
+ * The absolute run directory — claimed on first access, stable thereafter.
44
+ *
45
+ * Claiming and knowing the path are the same act: the directory is what makes a run's
46
+ * identity exclusive, so there is no meaningful "what would the path be" to answer
47
+ * before it exists.
48
+ */
25
49
  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, "-"));
50
+ if (!this.dir)
51
+ this.claim();
30
52
  return this.dir;
31
53
  }
32
54
  /** Absolute path to `name` inside the run folder; ensures the folder exists. */
33
55
  file(name) {
34
- this.ensure();
35
56
  return path.join(this.path(), name);
36
57
  }
37
58
  /** Create the run folder synchronously (idempotent). */
38
59
  ensure() {
39
- if (this.ensured)
40
- return;
41
- mkdirSync(this.path(), { recursive: true });
42
- this.ensured = true;
60
+ this.path();
61
+ }
62
+ /**
63
+ * Take exclusive ownership of a `<date>/<time>` directory.
64
+ *
65
+ * `hms` has second resolution, so two runs of the same om starting within one second
66
+ * would otherwise share a folder — and that is not merely "the later one wins":
67
+ * `RawStream` opens `raw.jsonl` with `flags: "w"`, so two live runs both truncate it and
68
+ * then write at their own offsets, leaving bytes that are not valid JSONL. The MCP
69
+ * server makes this easy to reach, since `start_om` can put two children in flight.
70
+ *
71
+ * Finer timestamps would only lower the odds. `mkdirSync` without `recursive` throws
72
+ * `EEXIST` **atomically**, and that atomicity holds between processes — so the directory
73
+ * creation is itself the lock. The common case keeps a clean `16-39-29`; only a real
74
+ * collision produces `16-39-29-02`, zero-padded so lexical order stays chronological
75
+ * (which is what `latest` relies on).
76
+ */
77
+ claim() {
78
+ const now = new Date();
79
+ const parent = path.resolve(logsRoot(), this.name(), ymd(now));
80
+ mkdirSync(parent, { recursive: true });
81
+ const base = hms(now, "-");
82
+ for (let attempt = 0; attempt <= MAX_NUMBERED_ATTEMPTS; attempt += 1) {
83
+ // A random suffix as the last resort: bounded loops beat an unbounded one, and a
84
+ // hundred collisions inside one second means something stranger is going on.
85
+ const suffix = attempt === 0
86
+ ? ""
87
+ : attempt === MAX_NUMBERED_ATTEMPTS
88
+ ? `-${shortId(newUuid())}`
89
+ : `-${pad(attempt + 1)}`;
90
+ const candidate = path.join(parent, `${base}${suffix}`);
91
+ try {
92
+ mkdirSync(candidate); // deliberately not recursive — EEXIST is the lock
93
+ this.dir = candidate;
94
+ this.ensured = true;
95
+ return;
96
+ }
97
+ catch (e) {
98
+ if (e.code !== "EEXIST")
99
+ throw e;
100
+ }
101
+ }
43
102
  }
44
103
  }
@@ -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,12 @@
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, SkillProject } from "./model.ts";
9
+ export { skillRoot } from "./root.ts";
10
+ export { renderSkill, generatedBy, SKILL_NAME } from "./render.ts";
11
+ export { readExisting, writeSkill, DEFAULT_DESCRIPTION, SKILL_RELATIVE_PATH } from "./file.ts";
12
+ export type { ExistingSkill } from "./file.ts";
@@ -0,0 +1,10 @@
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 { skillRoot } from "./root.js";
9
+ export { renderSkill, generatedBy, SKILL_NAME } from "./render.js";
10
+ export { readExisting, writeSkill, DEFAULT_DESCRIPTION, SKILL_RELATIVE_PATH } from "./file.js";
@@ -0,0 +1,69 @@
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
+ /** A concrete, complete `OMKIT_ARGS` value. Absent when no honest one can be built. */
20
+ readonly argExample?: Record<string, unknown>;
21
+ /** The static outline of the body. Absent when the walk found nothing recognisable. */
22
+ readonly calls?: readonly SkillCall[];
23
+ /** Why this entry cannot be called — carried through from discovery, rendered as a warning. */
24
+ readonly unavailable?: string;
25
+ /** Actions only: the exported binding a host om imports. */
26
+ readonly exportName?: string;
27
+ /** Actions only: true when the chain includes `.ref<…>()`, so it publishes a capability. */
28
+ readonly publishesCapability?: boolean;
29
+ }
30
+ /** The project the skill describes: where its reader stands, and what config omkit was given. */
31
+ export interface SkillProject {
32
+ /** Directory the file is written for — `.claude/` lives here, and paths are relative to it. */
33
+ readonly root: string;
34
+ /** Absolute path of the tsconfig discovery ran against, when the caller knows it. */
35
+ readonly tsconfig?: string;
36
+ }
37
+ /** Everything the renderer needs, plus the hash that detects drift against it. */
38
+ export interface SkillModel {
39
+ readonly oms: readonly SkillEntry[];
40
+ readonly actions: readonly SkillEntry[];
41
+ /**
42
+ * The `--tsconfig` value every rendered command needs, relative to the root. Absent when the
43
+ * CLI's own default already finds it — the flag would then be noise on every line.
44
+ */
45
+ readonly tsconfig?: string;
46
+ /** First 8 hex of sha256 over the registry data below — never over the rendered markdown. */
47
+ readonly hash: string;
48
+ }
49
+ /**
50
+ * Fold a project's registrations and its AST scan into the model the skill renders from.
51
+ *
52
+ * The two inputs answer different halves: the fork knows summaries, modes, and arg schemas
53
+ * (values that only exist once a module has been evaluated), while the AST scan knows the
54
+ * body outline and whether an action publishes a capability. Neither alone is enough.
55
+ *
56
+ * Only `.mcp()`-marked entries survive — exposure is opt-in, exactly as it is for the MCP
57
+ * server, so generating a skill can never widen what an assistant will attempt.
58
+ */
59
+ export declare function buildSkillModel(registrations: RegistrationSet, registry: Registry, project: SkillProject): SkillModel;
60
+ /**
61
+ * The staleness hash: first 8 hex of sha256 over the registry data, deliberately **not** over
62
+ * the rendered markdown, so reformatting the file does not read as drift.
63
+ *
64
+ * The outline is included even though the spec's list of inputs predates it: an edited om body
65
+ * changes what the file claims the run does, and leaving that undetected is exactly the silent
66
+ * drift the hash exists to catch. Keys are sorted before hashing so a schema serialised in a
67
+ * different property order cannot masquerade as a change.
68
+ */
69
+ export declare function registryHash(entries: readonly SkillEntry[], tsconfig?: string): string;
@@ -0,0 +1,200 @@
1
+ import path from "node:path";
2
+ import { createHash } from "node:crypto";
3
+ import { shapeHint } from "../core/schema-hint.js";
4
+ import { DEFAULT_TSCONFIG } from "../client/types.js";
5
+ /**
6
+ * Fold a project's registrations and its AST scan into the model the skill renders from.
7
+ *
8
+ * The two inputs answer different halves: the fork knows summaries, modes, and arg schemas
9
+ * (values that only exist once a module has been evaluated), while the AST scan knows the
10
+ * body outline and whether an action publishes a capability. Neither alone is enough.
11
+ *
12
+ * Only `.mcp()`-marked entries survive — exposure is opt-in, exactly as it is for the MCP
13
+ * server, so generating a skill can never widen what an assistant will attempt.
14
+ */
15
+ export function buildSkillModel(registrations, registry, project) {
16
+ const { root } = project;
17
+ const outlines = new Map(registry.oms.map((o) => [key(o.name, o.file), o.calls]));
18
+ const capabilities = new Map(registry.actions.map((a) => [key(a.name, a.file), a.publishesCapability]));
19
+ const oms = registrations.oms
20
+ .filter(isExposed)
21
+ .map((om) => omEntry(om, outlines.get(key(om.name, om.file)), root))
22
+ .sort(byName);
23
+ const actions = registrations.actions
24
+ .filter(isExposed)
25
+ .map((a) => actionEntry(a, capabilities.get(key(a.name, a.file)), root))
26
+ .sort(byName);
27
+ const tsconfig = tsconfigFlag(project);
28
+ return {
29
+ oms,
30
+ actions,
31
+ ...(tsconfig ? { tsconfig } : {}),
32
+ hash: registryHash([...oms, ...actions], tsconfig),
33
+ };
34
+ }
35
+ /**
36
+ * What a reader standing at the root must pass as `--tsconfig`, or `undefined` when they need
37
+ * pass nothing. omkit looks for `tsconfig.omkit.json` in the working directory, so a project
38
+ * whose config sits anywhere else — the common case once an om project lives in a subfolder —
39
+ * has commands that only work with the flag. Rendering it is the difference between a file
40
+ * that documents how to run this project and one that documents how to run some other project.
41
+ */
42
+ function tsconfigFlag({ root, tsconfig }) {
43
+ if (tsconfig === undefined)
44
+ return undefined;
45
+ const rel = relative(tsconfig, root);
46
+ return rel === DEFAULT_TSCONFIG ? undefined : rel;
47
+ }
48
+ function isExposed(r) {
49
+ return r.mcp !== undefined;
50
+ }
51
+ /** Identity for cross-referencing the two discovery passes: an om is its name in its file. */
52
+ function key(name, file) {
53
+ return `${name}\0${path.resolve(file)}`;
54
+ }
55
+ /** Alphabetical by name, then by file — so two same-named oms in different files stay ordered. */
56
+ function byName(a, b) {
57
+ return a.name.localeCompare(b.name) || a.file.localeCompare(b.file);
58
+ }
59
+ function omEntry(om, calls, root) {
60
+ return {
61
+ kind: "om",
62
+ name: om.name,
63
+ file: relative(om.file, root),
64
+ mode: om.mcp?.mode ?? "settling",
65
+ ...describeArgs(om.inputSchema),
66
+ ...(om.summary ? { summary: om.summary } : {}),
67
+ ...(calls?.length ? { calls } : {}),
68
+ ...(om.unavailable ? { unavailable: om.unavailable } : {}),
69
+ };
70
+ }
71
+ function actionEntry(action, publishesCapability, root) {
72
+ return {
73
+ kind: "action",
74
+ name: action.name,
75
+ file: relative(action.file, root),
76
+ mode: action.mcp?.mode ?? "settling",
77
+ exportName: action.exportName,
78
+ ...(publishesCapability ? { publishesCapability: true } : {}),
79
+ ...describeArgs(action.inputSchema),
80
+ ...(action.summary ? { summary: action.summary } : {}),
81
+ ...(action.unavailable ? { unavailable: action.unavailable } : {}),
82
+ };
83
+ }
84
+ function describeArgs(schema) {
85
+ if (!schema)
86
+ return {};
87
+ const hint = shapeHint(schema);
88
+ const example = argExample(schema);
89
+ return {
90
+ ...(hint === undefined ? { argSchema: schema } : { argHint: hint }),
91
+ ...(example ? { argExample: example } : {}),
92
+ };
93
+ }
94
+ /**
95
+ * A complete, valid `OMKIT_ARGS` object for this schema, or `undefined` when one cannot be
96
+ * built honestly.
97
+ *
98
+ * "Complete" is the whole point: an example missing a required field does not demonstrate the
99
+ * call, it demonstrates the prompt the reader gets instead. So every required field must be
100
+ * sampleable or there is no example — and when nothing is required, one optional field is
101
+ * enough to show the shape without implying the rest are needed.
102
+ */
103
+ function argExample(schema) {
104
+ const properties = schema.properties;
105
+ if (typeof properties !== "object" || properties === null)
106
+ return undefined;
107
+ const entries = Object.entries(properties);
108
+ const required = new Set(Array.isArray(schema.required) ? schema.required : []);
109
+ const mandatory = entries.filter(([key]) => required.has(key));
110
+ if (mandatory.length > 0) {
111
+ const example = {};
112
+ for (const [key, field] of mandatory) {
113
+ const value = sample(field);
114
+ if (value === undefined)
115
+ return undefined;
116
+ example[key] = value;
117
+ }
118
+ return example;
119
+ }
120
+ for (const [key, field] of entries) {
121
+ const value = sample(field);
122
+ if (value !== undefined)
123
+ return { [key]: value };
124
+ }
125
+ return undefined;
126
+ }
127
+ /**
128
+ * One plausible value for a field, or `undefined` when the type has no short literal form.
129
+ * A declared default wins over an invented value — it is both valid and the author's own
130
+ * choice, which reads better in an example than a zero or an ellipsis.
131
+ */
132
+ function sample(field) {
133
+ if (typeof field !== "object" || field === null)
134
+ return undefined;
135
+ const enumValues = field.enum;
136
+ if (Array.isArray(enumValues) && enumValues.length > 0)
137
+ return enumValues[0];
138
+ if (field.default !== undefined)
139
+ return field.default;
140
+ switch (field.type) {
141
+ case "string":
142
+ return "…";
143
+ case "number":
144
+ case "integer":
145
+ return 0;
146
+ case "boolean":
147
+ return false;
148
+ // Objects and arrays have no one-line stand-in: `{}` and `[]` are as likely to be rejected
149
+ // by the schema as accepted by it, and a rejected example is worse than none.
150
+ default:
151
+ return undefined;
152
+ }
153
+ }
154
+ /** Project-relative with forward slashes; absolute only if the file lies outside the root. */
155
+ function relative(file, root) {
156
+ const rel = path.relative(root, file);
157
+ if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel))
158
+ return posix(file);
159
+ return posix(rel);
160
+ }
161
+ const posix = (p) => p.split(path.sep).join("/").split("\\").join("/");
162
+ /**
163
+ * The staleness hash: first 8 hex of sha256 over the registry data, deliberately **not** over
164
+ * the rendered markdown, so reformatting the file does not read as drift.
165
+ *
166
+ * The outline is included even though the spec's list of inputs predates it: an edited om body
167
+ * changes what the file claims the run does, and leaving that undetected is exactly the silent
168
+ * drift the hash exists to catch. Keys are sorted before hashing so a schema serialised in a
169
+ * different property order cannot masquerade as a change.
170
+ */
171
+ export function registryHash(entries, tsconfig) {
172
+ const declarations = entries.map((e) => [
173
+ e.kind,
174
+ e.name,
175
+ e.file,
176
+ e.mode,
177
+ e.summary ?? "",
178
+ e.exportName ?? "",
179
+ e.unavailable ?? "",
180
+ e.publishesCapability === true,
181
+ e.argSchema ?? e.argHint ?? "",
182
+ (e.calls ?? []).map((c) => [c.name, c.tag ?? ""]),
183
+ ]);
184
+ // The invocation is hashed beside the declarations because it is part of what the file
185
+ // claims: move a project's tsconfig and every command in it becomes wrong, while not one
186
+ // om has changed. Drift is "the file no longer describes this project", not "an om changed".
187
+ const material = [declarations, tsconfig ?? ""];
188
+ return createHash("sha256").update(canonical(material)).digest("hex").slice(0, 8);
189
+ }
190
+ /** JSON with object keys sorted at every depth, so key order can never change the hash. */
191
+ function canonical(value) {
192
+ if (value === null || typeof value !== "object")
193
+ return JSON.stringify(value) ?? "null";
194
+ if (Array.isArray(value))
195
+ return `[${value.map(canonical).join(",")}]`;
196
+ const entries = Object.entries(value)
197
+ .filter(([, v]) => v !== undefined)
198
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
199
+ return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`).join(",")}}`;
200
+ }
@@ -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;