loom-spec 0.3.0 → 0.5.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/dist/cli/exportHtml.d.ts +22 -0
- package/dist/cli/exportHtml.js +182 -0
- package/dist/cli/exportHtml.js.map +1 -0
- package/dist/cli/index.js +40 -34
- package/dist/cli/index.js.map +1 -1
- package/dist/mcp/server.js +3 -207
- package/dist/mcp/server.js.map +1 -1
- package/dist/server/app.js +2 -50
- package/dist/server/app.js.map +1 -1
- package/dist/server/exportConfig.d.ts +40 -0
- package/dist/server/exportConfig.js +78 -0
- package/dist/server/exportConfig.js.map +1 -0
- package/dist/server/exportFilter.d.ts +52 -0
- package/dist/server/exportFilter.js +138 -0
- package/dist/server/exportFilter.js.map +1 -0
- package/dist/server/fileOps.d.ts +0 -12
- package/dist/server/fileOps.js +0 -51
- package/dist/server/fileOps.js.map +1 -1
- package/dist/server/watch.d.ts +1 -1
- package/dist/server/watch.js +0 -5
- package/dist/server/watch.js.map +1 -1
- package/dist/validate.d.ts +1 -3
- package/dist/validate.js +0 -15
- package/dist/validate.js.map +1 -1
- package/dist/view/assets/index-D8qr-jiw.css +1 -0
- package/dist/view/assets/index-DI0VS0HQ.js +205 -0
- package/dist/view/index.html +2 -2
- package/dist/{view/assets/index-CvyHnPjR.css → view-export/assets/bundle.css} +1 -1
- package/dist/{view/assets/index-Du05xzao.js → view-export/assets/bundle.js} +44 -49
- package/dist/view-export/index.html +24 -0
- package/package.json +3 -2
- package/templates/.claude/skills/loom-spec/SKILL.md +91 -76
- package/templates/.loom/README.md +1 -1
- package/dist/cli/importTrace.d.ts +0 -15
- package/dist/cli/importTrace.js +0 -188
- package/dist/cli/importTrace.js.map +0 -1
- package/dist/server/otel.d.ts +0 -32
- package/dist/server/otel.js +0 -98
- package/dist/server/otel.js.map +0 -1
- package/dist/types/timeline.d.ts +0 -97
- package/dist/types/timeline.js +0 -7
- package/dist/types/timeline.js.map +0 -1
- package/dist/view/assets/TimelineView-DEfpV9mL.js +0 -16
- package/schema/timeline.schema.json +0 -135
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface ExportHtmlArgs {
|
|
2
|
+
/** Output file path. Default: "loom.html" in cwd. */
|
|
3
|
+
out: string;
|
|
4
|
+
/** Working directory root (walked up to find .loom/). */
|
|
5
|
+
root: string;
|
|
6
|
+
/** If set, only this diagram. */
|
|
7
|
+
diagram?: string;
|
|
8
|
+
/** Only export nodes carrying at least one of these tags. */
|
|
9
|
+
includeTags?: string[];
|
|
10
|
+
/** Drop nodes carrying any of these tags. */
|
|
11
|
+
excludeTags?: string[];
|
|
12
|
+
/** Named bundle from .loom/exports.json. Settings from the bundle become
|
|
13
|
+
* defaults; explicit CLI flags override. */
|
|
14
|
+
bundle?: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Used by the bundle-merge logic to tell "user passed --out" from "we are
|
|
18
|
+
* using the default". Exported so the CLI dispatcher can default-init the
|
|
19
|
+
* field once and stay consistent with this module's notion of "default".
|
|
20
|
+
*/
|
|
21
|
+
export declare const DEFAULT_OUT = "loom.html";
|
|
22
|
+
export declare function runExportHtml(args: ExportHtmlArgs): Promise<void>;
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { readFile, writeFile, stat } from "node:fs/promises";
|
|
2
|
+
import { resolve, dirname } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { findLoomRoot } from "../server/findLoomRoot.js";
|
|
5
|
+
import { listDiagrams, readDiagram, readNodeTypes, } from "../server/fileOps.js";
|
|
6
|
+
import { applyFilter } from "../server/exportFilter.js";
|
|
7
|
+
import { loadExportsConfig } from "../server/exportConfig.js";
|
|
8
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
async function fileExists(p) {
|
|
10
|
+
try {
|
|
11
|
+
await stat(p);
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Locate the export-mode view bundle. Tries both layouts:
|
|
20
|
+
* - Production: alongside cli/ in dist/ → ../view-export/
|
|
21
|
+
* - Dev (tsx running from src/cli/): ../../dist/view-export/
|
|
22
|
+
*/
|
|
23
|
+
async function findExportBundle() {
|
|
24
|
+
const candidates = [
|
|
25
|
+
resolve(here, "../view-export"),
|
|
26
|
+
resolve(here, "../../dist/view-export"),
|
|
27
|
+
];
|
|
28
|
+
for (const c of candidates) {
|
|
29
|
+
if (await fileExists(resolve(c, "index.html")))
|
|
30
|
+
return c;
|
|
31
|
+
}
|
|
32
|
+
throw new Error(`Could not find the export view bundle. Looked in:\n - ${candidates.join("\n - ")}\n` +
|
|
33
|
+
`Did you run 'pnpm build' (or 'pnpm build:export') to produce dist/view-export?`);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Used by the bundle-merge logic to tell "user passed --out" from "we are
|
|
37
|
+
* using the default". Exported so the CLI dispatcher can default-init the
|
|
38
|
+
* field once and stay consistent with this module's notion of "default".
|
|
39
|
+
*/
|
|
40
|
+
export const DEFAULT_OUT = "loom.html";
|
|
41
|
+
async function loadAllData(args) {
|
|
42
|
+
const loomRoot = await findLoomRoot(args.root);
|
|
43
|
+
const nodeTypes = await readNodeTypes(loomRoot.loomPath);
|
|
44
|
+
// Diagrams — either one specific or all
|
|
45
|
+
const diagrams = {};
|
|
46
|
+
if (args.diagram) {
|
|
47
|
+
diagrams[args.diagram] = await readDiagram(loomRoot.loomPath, args.diagram);
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
const summaries = await listDiagrams(loomRoot.loomPath);
|
|
51
|
+
for (const s of summaries) {
|
|
52
|
+
diagrams[s.id] = await readDiagram(loomRoot.loomPath, s.id);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
generatedAt: new Date().toISOString(),
|
|
57
|
+
diagrams,
|
|
58
|
+
nodeTypes,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Inline the view bundle (HTML + CSS + JS) and the exported data into a
|
|
63
|
+
* single self-contained HTML string.
|
|
64
|
+
*
|
|
65
|
+
* The export bundle's index.html looks roughly like:
|
|
66
|
+
* <html><head><link rel="stylesheet" href="/assets/bundle.css">
|
|
67
|
+
* <script type="module" crossorigin src="/assets/bundle.js"></script>
|
|
68
|
+
* </head><body><div id="root"></div></body></html>
|
|
69
|
+
*
|
|
70
|
+
* We rewrite the link/script tags to inline contents and inject a
|
|
71
|
+
* <script>window.__LOOM_DATA__ = {...}</script> before the bundle.
|
|
72
|
+
*/
|
|
73
|
+
function buildHtml(bundleHtml, bundleCss, bundleJs, data, meta) {
|
|
74
|
+
// Sanitize the JSON for inlining inside a <script> tag.
|
|
75
|
+
// Replace </script> sequences and <!-- inside string values so they can't
|
|
76
|
+
// close the surrounding script element or confuse parsers.
|
|
77
|
+
const safeJson = JSON.stringify(data)
|
|
78
|
+
.replace(/<\/script/gi, "<\\/script")
|
|
79
|
+
.replace(/<!--/g, "<\\!--");
|
|
80
|
+
const inlineDataTag = `<script>window.__LOOM_DATA__ = ${safeJson};</script>`;
|
|
81
|
+
const inlineCssTag = `<style>${bundleCss}</style>`;
|
|
82
|
+
const inlineJsTag = `<script type="module">${bundleJs}</script>`;
|
|
83
|
+
let out = bundleHtml;
|
|
84
|
+
// CRITICAL: pass replacement as a function rather than a string. With a
|
|
85
|
+
// string second arg, JS treats `$$` as a literal `$`, which mangles any
|
|
86
|
+
// `$$typeof` / `$&` / `$'` in the JS bundle (React's reconciler uses
|
|
87
|
+
// `$$typeof` heavily). Function form bypasses the pattern processing.
|
|
88
|
+
out = out.replace(/<link\s+rel=["']stylesheet["'][^>]*>/i, () => inlineCssTag);
|
|
89
|
+
out = out.replace(/<script\s+type=["']module["'][^>]*><\/script>/i, () => inlineDataTag + inlineJsTag);
|
|
90
|
+
out = out.replace(/<title>[^<]*<\/title>/i, () => `<title>loom-spec export · ${escapeHtml(meta.sourceRoot)}</title>`);
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
function escapeHtml(s) {
|
|
94
|
+
return s
|
|
95
|
+
.replace(/&/g, "&")
|
|
96
|
+
.replace(/</g, "<")
|
|
97
|
+
.replace(/>/g, ">")
|
|
98
|
+
.replace(/"/g, """);
|
|
99
|
+
}
|
|
100
|
+
export async function runExportHtml(args) {
|
|
101
|
+
// If a bundle name was given, resolve its settings from .loom/exports.json
|
|
102
|
+
// first. Explicit CLI args take precedence (for ad-hoc overrides).
|
|
103
|
+
let effective = args;
|
|
104
|
+
if (args.bundle) {
|
|
105
|
+
const loomRoot = await findLoomRoot(args.root);
|
|
106
|
+
const config = await loadExportsConfig(loomRoot.loomPath);
|
|
107
|
+
if (!config) {
|
|
108
|
+
console.error(`export-html: no .loom/exports.json found, but '${args.bundle}' looks like a named bundle.\n` +
|
|
109
|
+
` Create .loom/exports.json with an 'exports.${args.bundle}' entry, or pass ad-hoc flags instead.`);
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
112
|
+
const bundle = config.exports[args.bundle];
|
|
113
|
+
if (!bundle) {
|
|
114
|
+
const available = Object.keys(config.exports).sort().join(", ") || "(none)";
|
|
115
|
+
console.error(`export-html: bundle '${args.bundle}' not found in .loom/exports.json. ` +
|
|
116
|
+
`Available: ${available}.`);
|
|
117
|
+
process.exit(1);
|
|
118
|
+
}
|
|
119
|
+
// Merge: explicit CLI args (in `args`) override config-supplied values.
|
|
120
|
+
effective = {
|
|
121
|
+
...args,
|
|
122
|
+
out: args.out !== DEFAULT_OUT ? args.out : bundle.out ?? args.out,
|
|
123
|
+
diagram: args.diagram ?? bundle.diagram,
|
|
124
|
+
includeTags: args.includeTags ?? bundle.includeTags,
|
|
125
|
+
excludeTags: args.excludeTags ?? bundle.excludeTags,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
const bundleDir = await findExportBundle();
|
|
129
|
+
const [bundleHtml, bundleCss, bundleJs] = await Promise.all([
|
|
130
|
+
readFile(resolve(bundleDir, "index.html"), "utf8"),
|
|
131
|
+
readFile(resolve(bundleDir, "assets/bundle.css"), "utf8"),
|
|
132
|
+
readFile(resolve(bundleDir, "assets/bundle.js"), "utf8"),
|
|
133
|
+
]);
|
|
134
|
+
const data = await loadAllData(effective);
|
|
135
|
+
if (Object.keys(data.diagrams).length === 0) {
|
|
136
|
+
console.error("export-html: no diagrams found — refusing to write an empty export.");
|
|
137
|
+
process.exit(1);
|
|
138
|
+
}
|
|
139
|
+
const filterSpec = {
|
|
140
|
+
includeTags: effective.includeTags,
|
|
141
|
+
excludeTags: effective.excludeTags,
|
|
142
|
+
};
|
|
143
|
+
const { payload: filtered, summary } = applyFilter(data, filterSpec);
|
|
144
|
+
// After filtering, if everything's gone, bail. Better to fail loud than
|
|
145
|
+
// silently emit a blank HTML the user will wonder about.
|
|
146
|
+
const survivingNodes = Object.values(filtered.diagrams).reduce((n, d) => n + d.nodes.length, 0);
|
|
147
|
+
if (survivingNodes === 0) {
|
|
148
|
+
console.error("export-html: filter matched 0 nodes — refusing to write an empty export.");
|
|
149
|
+
if (filterSpec.includeTags?.length || filterSpec.excludeTags?.length) {
|
|
150
|
+
console.error(` filter was: include=[${(filterSpec.includeTags ?? []).join(", ")}], ` +
|
|
151
|
+
`exclude=[${(filterSpec.excludeTags ?? []).join(", ")}]`);
|
|
152
|
+
}
|
|
153
|
+
process.exit(1);
|
|
154
|
+
}
|
|
155
|
+
const finalData = {
|
|
156
|
+
...data,
|
|
157
|
+
diagrams: filtered.diagrams,
|
|
158
|
+
};
|
|
159
|
+
const html = buildHtml(bundleHtml, bundleCss, bundleJs, finalData, {
|
|
160
|
+
sourceRoot: effective.root,
|
|
161
|
+
});
|
|
162
|
+
const outPath = resolve(effective.out);
|
|
163
|
+
await writeFile(outPath, html, "utf8");
|
|
164
|
+
const sizeKb = Math.round(Buffer.byteLength(html, "utf8") / 1024);
|
|
165
|
+
const diagramCount = Object.keys(finalData.diagrams).length;
|
|
166
|
+
console.log(`Wrote ${outPath} (${sizeKb} kB): ` +
|
|
167
|
+
`${diagramCount} diagram${diagramCount === 1 ? "" : "s"}.`);
|
|
168
|
+
const droppedParts = [];
|
|
169
|
+
if (summary.nodesDropped > 0)
|
|
170
|
+
droppedParts.push(`${summary.nodesDropped} nodes`);
|
|
171
|
+
if (summary.edgesDropped > 0)
|
|
172
|
+
droppedParts.push(`${summary.edgesDropped} edges`);
|
|
173
|
+
if (summary.groupsDropped > 0)
|
|
174
|
+
droppedParts.push(`${summary.groupsDropped} groups`);
|
|
175
|
+
if (summary.drillDownsCleared > 0)
|
|
176
|
+
droppedParts.push(`${summary.drillDownsCleared} drill-down refs`);
|
|
177
|
+
if (droppedParts.length > 0) {
|
|
178
|
+
console.log(`Filter dropped: ${droppedParts.join(", ")}.`);
|
|
179
|
+
}
|
|
180
|
+
console.log(`Open it directly in a browser, or drop it into any docs / wiki / GitHub-Pages site.`);
|
|
181
|
+
}
|
|
182
|
+
//# sourceMappingURL=exportHtml.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"exportHtml.js","sourceRoot":"","sources":["../../src/cli/exportHtml.ts"],"names":[],"mappings":"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"}
|
package/dist/cli/index.js
CHANGED
|
@@ -4,7 +4,7 @@ import { runView } from "./view.js";
|
|
|
4
4
|
import { runValidate } from "./validate.js";
|
|
5
5
|
import { runMcp } from "./mcp.js";
|
|
6
6
|
import { runInstallMcp } from "./installMcp.js";
|
|
7
|
-
import {
|
|
7
|
+
import { runExportHtml, DEFAULT_OUT as EXPORT_DEFAULT_OUT } from "./exportHtml.js";
|
|
8
8
|
const HELP = `loom-spec — node-based architecture spec for your repo
|
|
9
9
|
|
|
10
10
|
Usage:
|
|
@@ -28,24 +28,32 @@ Usage:
|
|
|
28
28
|
pre-commit hook.
|
|
29
29
|
|
|
30
30
|
loom-spec mcp [--root <dir>]
|
|
31
|
-
Start a Model Context Protocol server on stdio. Exposes
|
|
32
|
-
for diagrams (loom_list_diagrams, loom_add_node,
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
Start a Model Context Protocol server on stdio. Exposes 10 tools
|
|
32
|
+
for editing diagrams (loom_list_diagrams, loom_add_node,
|
|
33
|
+
loom_add_edge, loom_validate, …). Wire it into Claude Code's
|
|
34
|
+
mcp.json (or any MCP-capable client).
|
|
35
35
|
|
|
36
|
-
loom-spec
|
|
37
|
-
[--
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
36
|
+
loom-spec export-html [<bundle-name>] [--out <path>] [--diagram <id>]
|
|
37
|
+
[--include-tag <comma-list>] [--exclude-tag <comma-list>]
|
|
38
|
+
[--root <dir>]
|
|
39
|
+
Build a standalone interactive HTML file from the spec — pan/zoom,
|
|
40
|
+
drill-down, switch diagrams. Single self-contained file, no server
|
|
41
|
+
needed. Drop it into a manual, wiki, GitHub Pages site, anywhere.
|
|
42
|
+
Output defaults to ./loom.html. With --diagram, only that diagram
|
|
43
|
+
ships. With --include-tag / --exclude-tag, only nodes whose 'tags'
|
|
44
|
+
match survive — edges, groups, and drill-down chevrons that point
|
|
45
|
+
at dropped nodes are cleaned up automatically.
|
|
46
|
+
|
|
47
|
+
Pass a <bundle-name> as the first positional arg to read settings
|
|
48
|
+
from a named bundle in .loom/exports.json. Explicit flags override
|
|
49
|
+
the bundle's values.
|
|
43
50
|
|
|
44
51
|
loom-spec --help
|
|
45
52
|
Print this help.
|
|
46
53
|
`;
|
|
47
54
|
function parseFlags(argv) {
|
|
48
55
|
const flags = {};
|
|
56
|
+
const positional = [];
|
|
49
57
|
for (let i = 0; i < argv.length; i++) {
|
|
50
58
|
const a = argv[i];
|
|
51
59
|
if (!a)
|
|
@@ -61,12 +69,15 @@ function parseFlags(argv) {
|
|
|
61
69
|
flags[key] = true;
|
|
62
70
|
}
|
|
63
71
|
}
|
|
72
|
+
else {
|
|
73
|
+
positional.push(a);
|
|
74
|
+
}
|
|
64
75
|
}
|
|
65
|
-
return flags;
|
|
76
|
+
return { flags, positional };
|
|
66
77
|
}
|
|
67
78
|
async function main() {
|
|
68
79
|
const [, , subcommand, ...rest] = process.argv;
|
|
69
|
-
const flags = parseFlags(rest);
|
|
80
|
+
const { flags, positional } = parseFlags(rest);
|
|
70
81
|
if (!subcommand || subcommand === "--help" || subcommand === "-h") {
|
|
71
82
|
console.log(HELP);
|
|
72
83
|
return;
|
|
@@ -106,27 +117,22 @@ async function main() {
|
|
|
106
117
|
});
|
|
107
118
|
return;
|
|
108
119
|
}
|
|
109
|
-
if (subcommand === "
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
console.error("import-trace: --as <timeline-id> and --diagram <diagram-id> are required");
|
|
121
|
-
process.exit(1);
|
|
122
|
-
}
|
|
123
|
-
await runImportTrace({
|
|
124
|
-
trace,
|
|
125
|
-
asId,
|
|
126
|
-
diagramId,
|
|
127
|
-
map: typeof flags.map === "string" ? flags.map : undefined,
|
|
128
|
-
append: Boolean(flags.append),
|
|
120
|
+
if (subcommand === "export-html") {
|
|
121
|
+
const splitTags = (v) => {
|
|
122
|
+
if (typeof v !== "string" || !v.trim())
|
|
123
|
+
return undefined;
|
|
124
|
+
return v.split(",").map((s) => s.trim()).filter(Boolean);
|
|
125
|
+
};
|
|
126
|
+
// First positional arg, if any, is a named-bundle key from
|
|
127
|
+
// .loom/exports.json (e.g. `loom-spec export-html user-manual`).
|
|
128
|
+
const bundle = positional[0];
|
|
129
|
+
await runExportHtml({
|
|
130
|
+
out: flags.out ?? EXPORT_DEFAULT_OUT,
|
|
129
131
|
root: flags.root ?? process.cwd(),
|
|
132
|
+
diagram: typeof flags.diagram === "string" ? flags.diagram : undefined,
|
|
133
|
+
includeTags: splitTags(flags["include-tag"]),
|
|
134
|
+
excludeTags: splitTags(flags["exclude-tag"]),
|
|
135
|
+
bundle,
|
|
130
136
|
});
|
|
131
137
|
return;
|
|
132
138
|
}
|
package/dist/cli/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/cli/index.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACpC,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACpC,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAC5C,OAAO,EAAE,MAAM,EAAE,MAAM,UAAU,CAAC;AAClC,OAAO,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAChD,OAAO,EAAE,
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/cli/index.ts"],"names":[],"mappings":"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"}
|
package/dist/mcp/server.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
-
import { listDiagrams, readDiagram, writeDiagram, readNodeTypes,
|
|
5
|
-
import { validateDiagram
|
|
4
|
+
import { listDiagrams, readDiagram, writeDiagram, readNodeTypes, } from "../server/fileOps.js";
|
|
5
|
+
import { validateDiagram } from "../validate.js";
|
|
6
6
|
import { runDriftCheck } from "../server/drift.js";
|
|
7
7
|
const STATUSES = ["planned", "implemented", "stale", "deprecated"];
|
|
8
8
|
const EDGE_KINDS = [
|
|
@@ -14,7 +14,6 @@ const EDGE_KINDS = [
|
|
|
14
14
|
"dependency",
|
|
15
15
|
"control",
|
|
16
16
|
];
|
|
17
|
-
const EVENT_KINDS = ["compute", "io", "wait", "error"];
|
|
18
17
|
const codeRefSchema = z.object({
|
|
19
18
|
path: z.string(),
|
|
20
19
|
symbol: z.string().optional(),
|
|
@@ -34,13 +33,6 @@ function uniqueEdgeId(d) {
|
|
|
34
33
|
i++;
|
|
35
34
|
return `e${i}`;
|
|
36
35
|
}
|
|
37
|
-
function uniqueEventId(tl) {
|
|
38
|
-
const ids = new Set(tl.events.map((e) => e.id));
|
|
39
|
-
let i = tl.events.length + 1;
|
|
40
|
-
while (ids.has(`ev${i}`))
|
|
41
|
-
i++;
|
|
42
|
-
return `ev${i}`;
|
|
43
|
-
}
|
|
44
36
|
function jsonText(obj) {
|
|
45
37
|
return { content: [{ type: "text", text: JSON.stringify(obj, null, 2) }] };
|
|
46
38
|
}
|
|
@@ -68,28 +60,8 @@ async function persist(loomPath, id, d) {
|
|
|
68
60
|
await writeDiagram(loomPath, id, d);
|
|
69
61
|
return null;
|
|
70
62
|
}
|
|
71
|
-
async function readTimelineOrError(loomPath, id) {
|
|
72
|
-
try {
|
|
73
|
-
return { ok: true, timeline: await readTimeline(loomPath, id) };
|
|
74
|
-
}
|
|
75
|
-
catch (e) {
|
|
76
|
-
const code = e.code;
|
|
77
|
-
if (code === "ENOENT") {
|
|
78
|
-
return { ok: false, error: errorText(`timeline '${id}' not found`) };
|
|
79
|
-
}
|
|
80
|
-
return { ok: false, error: errorText(`read failed: ${e.message}`) };
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
async function persistTimeline(loomPath, id, tl) {
|
|
84
|
-
const result = await validateTimeline(tl);
|
|
85
|
-
if (!result.ok) {
|
|
86
|
-
return errorText("Validation failed:", result.errors);
|
|
87
|
-
}
|
|
88
|
-
await writeTimeline(loomPath, id, tl);
|
|
89
|
-
return null;
|
|
90
|
-
}
|
|
91
63
|
export function createMcpServer(loomRoot) {
|
|
92
|
-
const server = new McpServer({ name: "loom-spec", version: "0.0.1" }, { instructions: `Use these tools to maintain the architecture spec in ${loomRoot.loomPath}.
|
|
64
|
+
const server = new McpServer({ name: "loom-spec", version: "0.0.1" }, { instructions: `Use these tools to maintain the architecture spec in ${loomRoot.loomPath}. They edit .loom/diagrams/*.flow.json — prefer them over editing the JSON files directly because they validate against the schema before writing.` });
|
|
93
65
|
server.registerTool("loom_list_diagrams", {
|
|
94
66
|
title: "List diagrams",
|
|
95
67
|
description: "List all diagrams in the spec with title and node/edge counts.",
|
|
@@ -303,182 +275,6 @@ export function createMcpServer(loomRoot) {
|
|
|
303
275
|
return err;
|
|
304
276
|
return jsonText({ ok: true });
|
|
305
277
|
});
|
|
306
|
-
// ─── Timelines ───────────────────────────────────────────────────────
|
|
307
|
-
server.registerTool("loom_list_timelines", {
|
|
308
|
-
title: "List timelines",
|
|
309
|
-
description: "List all timelines in the spec with title, referenced diagram, event count, and total duration in ms.",
|
|
310
|
-
inputSchema: {},
|
|
311
|
-
}, async () => {
|
|
312
|
-
const summaries = await listTimelines(loomRoot.loomPath);
|
|
313
|
-
return jsonText(summaries);
|
|
314
|
-
});
|
|
315
|
-
server.registerTool("loom_read_timeline", {
|
|
316
|
-
title: "Read timeline",
|
|
317
|
-
description: "Read a specific timeline's full JSON by id.",
|
|
318
|
-
inputSchema: { id: z.string().describe("Timeline id (e.g. 'todo-completion')") },
|
|
319
|
-
}, async ({ id }) => {
|
|
320
|
-
const r = await readTimelineOrError(loomRoot.loomPath, id);
|
|
321
|
-
if (!r.ok)
|
|
322
|
-
return r.error;
|
|
323
|
-
return jsonText(r.timeline);
|
|
324
|
-
});
|
|
325
|
-
server.registerTool("loom_add_event", {
|
|
326
|
-
title: "Add timeline event",
|
|
327
|
-
description: "Append a new event (clip) to a timeline. The referenced node must exist in the timeline's diagram. Returns the auto-generated event id.",
|
|
328
|
-
inputSchema: {
|
|
329
|
-
timeline: z.string().describe("Timeline id"),
|
|
330
|
-
node: z
|
|
331
|
-
.string()
|
|
332
|
-
.describe("Node id from the timeline's diagram. The event lights up this node when the playhead enters its interval."),
|
|
333
|
-
start_ms: z.number().min(0).describe("Start time in ms from t=0"),
|
|
334
|
-
duration_ms: z
|
|
335
|
-
.number()
|
|
336
|
-
.min(0)
|
|
337
|
-
.describe("Duration in ms (may be 0 for instantaneous events)"),
|
|
338
|
-
track: z
|
|
339
|
-
.string()
|
|
340
|
-
.optional()
|
|
341
|
-
.describe("Track to render this event on. Omit to auto-assign one track per node."),
|
|
342
|
-
label: z.string().max(60).optional().describe("Short text shown inside the clip"),
|
|
343
|
-
description: z.string().optional(),
|
|
344
|
-
kind: z.enum(EVENT_KINDS).optional().describe("compute | io | wait | error"),
|
|
345
|
-
code_refs: z.array(codeRefSchema).optional(),
|
|
346
|
-
triggered_by: z
|
|
347
|
-
.string()
|
|
348
|
-
.optional()
|
|
349
|
-
.describe("Id of another event in this timeline that caused this one"),
|
|
350
|
-
tags: z.array(z.string()).optional(),
|
|
351
|
-
id: z
|
|
352
|
-
.string()
|
|
353
|
-
.optional()
|
|
354
|
-
.describe("Optional explicit id (lowercase kebab). Defaults to ev<n>."),
|
|
355
|
-
},
|
|
356
|
-
}, async ({ timeline, node, start_ms, duration_ms, track, label, description, kind, code_refs, triggered_by, tags, id, }) => {
|
|
357
|
-
const tlRes = await readTimelineOrError(loomRoot.loomPath, timeline);
|
|
358
|
-
if (!tlRes.ok)
|
|
359
|
-
return tlRes.error;
|
|
360
|
-
const tl = tlRes.timeline;
|
|
361
|
-
// Verify the referenced node exists in the underlying diagram.
|
|
362
|
-
const dRes = await readDiagramOrError(loomRoot.loomPath, tl.diagram);
|
|
363
|
-
if (!dRes.ok) {
|
|
364
|
-
return errorText(`timeline '${timeline}' references diagram '${tl.diagram}', which could not be read`);
|
|
365
|
-
}
|
|
366
|
-
if (!dRes.diagram.nodes.some((n) => n.id === node)) {
|
|
367
|
-
return errorText(`node '${node}' does not exist in diagram '${tl.diagram}'. Available: ${dRes.diagram.nodes
|
|
368
|
-
.map((n) => n.id)
|
|
369
|
-
.join(", ")}`);
|
|
370
|
-
}
|
|
371
|
-
// Verify triggered_by, if set, references an existing event.
|
|
372
|
-
if (triggered_by && !tl.events.some((e) => e.id === triggered_by)) {
|
|
373
|
-
return errorText(`triggered_by event '${triggered_by}' not found in timeline`);
|
|
374
|
-
}
|
|
375
|
-
const newId = id ?? uniqueEventId(tl);
|
|
376
|
-
if (tl.events.some((e) => e.id === newId)) {
|
|
377
|
-
return errorText(`event with id '${newId}' already exists`);
|
|
378
|
-
}
|
|
379
|
-
const event = {
|
|
380
|
-
id: newId,
|
|
381
|
-
node,
|
|
382
|
-
start_ms,
|
|
383
|
-
duration_ms,
|
|
384
|
-
track,
|
|
385
|
-
label,
|
|
386
|
-
description,
|
|
387
|
-
kind,
|
|
388
|
-
code_refs,
|
|
389
|
-
triggered_by,
|
|
390
|
-
tags,
|
|
391
|
-
};
|
|
392
|
-
tl.events.push(event);
|
|
393
|
-
const err = await persistTimeline(loomRoot.loomPath, timeline, tl);
|
|
394
|
-
if (err)
|
|
395
|
-
return err;
|
|
396
|
-
return jsonText({ ok: true, id: newId });
|
|
397
|
-
});
|
|
398
|
-
server.registerTool("loom_update_event", {
|
|
399
|
-
title: "Update timeline event",
|
|
400
|
-
description: "Patch fields on an existing event. Only the fields you pass are changed. If you change 'node', the new node must exist in the timeline's diagram.",
|
|
401
|
-
inputSchema: {
|
|
402
|
-
timeline: z.string(),
|
|
403
|
-
id: z.string().describe("Event id"),
|
|
404
|
-
patch: z
|
|
405
|
-
.object({
|
|
406
|
-
node: z.string().optional(),
|
|
407
|
-
start_ms: z.number().min(0).optional(),
|
|
408
|
-
duration_ms: z.number().min(0).optional(),
|
|
409
|
-
track: z.string().nullable().optional(),
|
|
410
|
-
label: z.string().max(60).nullable().optional(),
|
|
411
|
-
description: z.string().nullable().optional(),
|
|
412
|
-
kind: z.enum(EVENT_KINDS).nullable().optional(),
|
|
413
|
-
code_refs: z.array(codeRefSchema).optional(),
|
|
414
|
-
triggered_by: z.string().nullable().optional(),
|
|
415
|
-
tags: z.array(z.string()).optional(),
|
|
416
|
-
})
|
|
417
|
-
.describe("Fields to merge. Pass null to clear an optional field."),
|
|
418
|
-
},
|
|
419
|
-
}, async ({ timeline, id, patch }) => {
|
|
420
|
-
const tlRes = await readTimelineOrError(loomRoot.loomPath, timeline);
|
|
421
|
-
if (!tlRes.ok)
|
|
422
|
-
return tlRes.error;
|
|
423
|
-
const tl = tlRes.timeline;
|
|
424
|
-
const idx = tl.events.findIndex((e) => e.id === id);
|
|
425
|
-
if (idx < 0)
|
|
426
|
-
return errorText(`event '${id}' not found in timeline '${timeline}'`);
|
|
427
|
-
// If node is being changed, verify the new node exists in the diagram.
|
|
428
|
-
if (patch.node !== undefined && patch.node !== tl.events[idx].node) {
|
|
429
|
-
const dRes = await readDiagramOrError(loomRoot.loomPath, tl.diagram);
|
|
430
|
-
if (!dRes.ok)
|
|
431
|
-
return dRes.error;
|
|
432
|
-
if (!dRes.diagram.nodes.some((n) => n.id === patch.node)) {
|
|
433
|
-
return errorText(`node '${patch.node}' does not exist in diagram '${tl.diagram}'`);
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
|
-
// If triggered_by is being set (non-null), verify the target exists.
|
|
437
|
-
if (patch.triggered_by && !tl.events.some((e) => e.id === patch.triggered_by)) {
|
|
438
|
-
return errorText(`triggered_by event '${patch.triggered_by}' not found`);
|
|
439
|
-
}
|
|
440
|
-
const merged = { ...tl.events[idx] };
|
|
441
|
-
for (const [k, v] of Object.entries(patch)) {
|
|
442
|
-
if (v === undefined)
|
|
443
|
-
continue;
|
|
444
|
-
if (v === null) {
|
|
445
|
-
// Clear the optional field.
|
|
446
|
-
delete merged[k];
|
|
447
|
-
}
|
|
448
|
-
else {
|
|
449
|
-
merged[k] = v;
|
|
450
|
-
}
|
|
451
|
-
}
|
|
452
|
-
tl.events[idx] = merged;
|
|
453
|
-
const err = await persistTimeline(loomRoot.loomPath, timeline, tl);
|
|
454
|
-
if (err)
|
|
455
|
-
return err;
|
|
456
|
-
return jsonText({ ok: true });
|
|
457
|
-
});
|
|
458
|
-
server.registerTool("loom_delete_event", {
|
|
459
|
-
title: "Delete timeline event",
|
|
460
|
-
description: "Remove an event by id. Also drops any triggered_by references pointing at it so the timeline stays internally consistent.",
|
|
461
|
-
inputSchema: {
|
|
462
|
-
timeline: z.string(),
|
|
463
|
-
id: z.string(),
|
|
464
|
-
},
|
|
465
|
-
}, async ({ timeline, id }) => {
|
|
466
|
-
const tlRes = await readTimelineOrError(loomRoot.loomPath, timeline);
|
|
467
|
-
if (!tlRes.ok)
|
|
468
|
-
return tlRes.error;
|
|
469
|
-
const tl = tlRes.timeline;
|
|
470
|
-
const before = tl.events.length;
|
|
471
|
-
tl.events = tl.events.filter((e) => e.id !== id);
|
|
472
|
-
if (tl.events.length === before) {
|
|
473
|
-
return errorText(`event '${id}' not found in timeline '${timeline}'`);
|
|
474
|
-
}
|
|
475
|
-
// Scrub dangling triggered_by refs.
|
|
476
|
-
tl.events = tl.events.map((e) => e.triggered_by === id ? { ...e, triggered_by: undefined } : e);
|
|
477
|
-
const err = await persistTimeline(loomRoot.loomPath, timeline, tl);
|
|
478
|
-
if (err)
|
|
479
|
-
return err;
|
|
480
|
-
return jsonText({ ok: true });
|
|
481
|
-
});
|
|
482
278
|
server.registerTool("loom_validate", {
|
|
483
279
|
title: "Validate spec",
|
|
484
280
|
description: "Run the full drift + schema check on every diagram. Reports schema errors, missing code-ref files, and missing symbols.",
|