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.
- package/CHANGELOG.md +153 -0
- package/README.md +170 -13
- package/dist/actions/browser.d.ts +1 -1
- package/dist/actions/browser.js +1 -1
- package/dist/actions/chrome-page.d.ts +46 -4
- package/dist/actions/chrome-page.js +81 -33
- package/dist/actions/drive-page.d.ts +50 -0
- package/dist/actions/drive-page.js +68 -0
- package/dist/actions/index.d.ts +7 -3
- package/dist/actions/index.js +3 -1
- package/dist/actions/port-free.d.ts +29 -0
- package/dist/actions/port-free.js +78 -0
- package/dist/actions/prompt.d.ts +32 -5
- package/dist/actions/prompt.js +92 -8
- package/dist/cli/commands/help.js +6 -0
- package/dist/cli/commands/init.js +1 -1
- package/dist/cli/commands/ls.d.ts +11 -4
- package/dist/cli/commands/ls.js +35 -15
- package/dist/cli/commands/mcp.d.ts +10 -0
- package/dist/cli/commands/mcp.js +13 -0
- package/dist/cli/commands/skill.d.ts +17 -0
- package/dist/cli/commands/skill.js +56 -0
- package/dist/cli/index.d.ts +8 -0
- package/dist/cli/index.js +71 -29
- package/dist/client/discover-child.d.ts +12 -0
- package/dist/client/discover-child.js +71 -0
- package/dist/client/discovery.d.ts +21 -1
- package/dist/client/discovery.js +139 -2
- package/dist/client/index.d.ts +2 -1
- package/dist/client/index.js +3 -1
- package/dist/client/outline.d.ts +35 -0
- package/dist/client/outline.js +100 -0
- package/dist/client/registry.d.ts +59 -0
- package/dist/client/runner.d.ts +12 -0
- package/dist/client/runner.js +16 -7
- package/dist/client/types.d.ts +9 -1
- package/dist/core/ActionRun.d.ts +5 -0
- package/dist/core/ActionRun.js +9 -0
- package/dist/core/ExecutionTree.d.ts +40 -2
- package/dist/core/ExecutionTree.js +66 -2
- package/dist/core/action.d.ts +2 -1
- package/dist/core/action.js +93 -9
- package/dist/core/args.d.ts +50 -0
- package/dist/core/args.js +166 -0
- package/dist/core/discovery-mode.d.ts +37 -0
- package/dist/core/discovery-mode.js +54 -0
- package/dist/core/interaction.d.ts +3 -1
- package/dist/core/om.d.ts +11 -9
- package/dist/core/om.js +182 -13
- package/dist/core/schema-hint.d.ts +20 -0
- package/dist/core/schema-hint.js +73 -0
- package/dist/core/schema-json.d.ts +12 -0
- package/dist/core/schema-json.js +12 -0
- package/dist/core/types.d.ts +113 -1
- package/dist/foundation/LogEntry.d.ts +1 -1
- package/dist/foundation/canonicalPath.d.ts +26 -0
- package/dist/foundation/canonicalPath.js +34 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/mcp/action-host.d.ts +1 -0
- package/dist/mcp/action-host.js +65 -0
- package/dist/mcp/action-identity.d.ts +23 -0
- package/dist/mcp/action-identity.js +35 -0
- package/dist/mcp/progress.d.ts +61 -0
- package/dist/mcp/progress.js +92 -0
- package/dist/mcp/resources.d.ts +55 -0
- package/dist/mcp/resources.js +208 -0
- package/dist/mcp/runs.d.ts +37 -0
- package/dist/mcp/runs.js +60 -0
- package/dist/mcp/server.d.ts +19 -0
- package/dist/mcp/server.js +38 -0
- package/dist/mcp/tail.d.ts +37 -0
- package/dist/mcp/tail.js +64 -0
- package/dist/mcp/tools.d.ts +54 -0
- package/dist/mcp/tools.js +374 -0
- package/dist/mcp/validate.d.ts +14 -0
- package/dist/mcp/validate.js +54 -0
- package/dist/mcp/verdict.d.ts +31 -0
- package/dist/mcp/verdict.js +47 -0
- package/dist/output/artifact/ArtifactStore.d.ts +24 -0
- package/dist/output/artifact/ArtifactStore.js +49 -0
- package/dist/output/folder/RunFolder.d.ts +23 -1
- package/dist/output/folder/RunFolder.js +55 -11
- package/dist/output/writers/views.d.ts +10 -0
- package/dist/skill/file.d.ts +26 -0
- package/dist/skill/file.js +58 -0
- package/dist/skill/index.d.ts +11 -0
- package/dist/skill/index.js +9 -0
- package/dist/skill/model.d.ts +55 -0
- package/dist/skill/model.js +116 -0
- package/dist/skill/render.d.ts +18 -0
- package/dist/skill/render.js +133 -0
- 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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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.
|
|
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": {
|