@lyric_dev/data-loom 0.2.4

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.
@@ -0,0 +1,3 @@
1
+ // MCP topology model. Only sanitized data here ever reaches the browser —
2
+ // ProbeTarget (with real command/url) stays inside the daemon.
3
+ export {};
@@ -0,0 +1,234 @@
1
+ // data_loom as an MCP server: exposes the open proposals (read) and a
2
+ // dependency-writing action (write `## Depends On`) so an MCP client — the
3
+ // user's own authenticated Claude — can determine and apply the order.
4
+ // Holds no credentials; tools carry only proposal text and change names.
5
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
6
+ import { homedir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
9
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
10
+ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
11
+ import { OpenSpecClient } from "./openspecClient.js";
12
+ import { deriveModel } from "./derive.js";
13
+ // Advertised to the client on connect. The "confirm before writing" gate lives
14
+ // here, in the agent's behavior — the server cannot verify a human approved.
15
+ const INSTRUCTIONS = `This server exposes a project's open OpenSpec proposals and lets you record the dependency order between them. It holds no model and cannot infer anything on its own — the judgment is yours and the decision is the user's.
16
+
17
+ When you connect, call list_open_proposals and look at each proposal's "dependencyReview" field. For every proposal whose state is "pending" (it has no \`## Depends On\` declaration yet):
18
+ 1. Read its proposal text alongside the others and reason about which changes it should be implemented after.
19
+ 2. PROPOSE the dependencies — or that it is independent — to the user in plain language, with your reasoning.
20
+ 3. Only AFTER the user confirms, record the result: call set_dependency(from, to) for each confirmed edge, or mark_independent(change) for a change that genuinely depends on nothing.
21
+
22
+ Never write a dependency the user has not confirmed. Proposals already marked "declared" need no action.
23
+
24
+ Tip: call install_weave_skill once to add a \`/loom:weave\` command (written to the user's global Claude config) that runs this whole review in one step; the user reloads Claude Code to pick it up.`;
25
+ // The `/loom:weave` slash command this server installs into the user's global
26
+ // Claude commands dir. Static content that only orchestrates this server's tools.
27
+ const WEAVE_COMMAND = `---
28
+ name: "Loom: Weave"
29
+ description: "Review the open proposals and weave their dependency order via the data-loom MCP server"
30
+ category: Workflow
31
+ tags: [loom, dependencies, mcp, review]
32
+ ---
33
+
34
+ Weave the dependency order of this project's open OpenSpec proposals, using the **data-loom** MCP server.
35
+
36
+ **Prerequisite:** the data-loom MCP server must be registered in this session — its tools are \`list_open_proposals\`, \`set_dependency\`, and \`mark_independent\`. If those tools are not available, tell the user to register it (\`claude mcp add data-loom -- npx data-loom mcp "<project path>"\`) and stop.
37
+
38
+ Steps:
39
+
40
+ 1. Call \`list_open_proposals\` and note each proposal's \`dependencyReview\` state.
41
+ 2. Focus on the proposals whose state is \`pending\` (no \`## Depends On\` declaration yet). Proposals already \`declared\` need no action — leave them alone.
42
+ 3. Read the pending proposals alongside the others. Reason about which change should be implemented **after** which, from their capabilities and content: a change that extends or modifies what another change introduces depends on it; changes that touch disjoint areas are independent.
43
+ 4. **Propose** your conclusion to the user in plain language — for each pending proposal, the dependencies you would record (or that it is independent), each with a one-line reason. Do not write anything yet.
44
+ 5. **Wait for the user to confirm.** Never write a dependency the user has not approved.
45
+ 6. After the user confirms, record it:
46
+ - \`set_dependency(from, to)\` for each confirmed edge (the dependent is \`from\`).
47
+ - \`mark_independent(change)\` for a proposal the user confirms depends on nothing.
48
+ 7. Call \`list_open_proposals\` again and report the resulting phases — what moved, and what is now ready versus blocked.
49
+
50
+ The reasoning stays the user's to approve: you surface and apply, the user decides.
51
+ `;
52
+ export async function runMcpServer(project) {
53
+ const client = new OpenSpecClient(project);
54
+ const changesDir = join(project, "openspec", "changes");
55
+ const server = new Server({ name: "data-loom", version: "0.2.4" }, { capabilities: { tools: {} }, instructions: INSTRUCTIONS });
56
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
57
+ tools: [
58
+ {
59
+ name: "list_open_proposals",
60
+ description: "List the open (non-archived) OpenSpec proposals for this project, each with its proposal text (Why / What Changes / Capabilities) and currently derived dependencies, phase, and readiness. Use this to reason about which proposals should be implemented after which.",
61
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
62
+ },
63
+ {
64
+ name: "set_dependency",
65
+ description: "Declare that one open proposal depends on (should be implemented after) another by writing a `## Depends On` entry into the dependent's proposal. The roadmap then recomputes deterministically from that file.",
66
+ inputSchema: {
67
+ type: "object",
68
+ properties: {
69
+ from: { type: "string", description: "The dependent change (gets the `## Depends On` entry)" },
70
+ to: { type: "string", description: "The change it depends on" },
71
+ },
72
+ required: ["from", "to"],
73
+ additionalProperties: false,
74
+ },
75
+ },
76
+ {
77
+ name: "mark_independent",
78
+ description: "Record that an open proposal depends on nothing by writing an empty `## Depends On` declaration into it. Use this — only after the user confirms — for a change that is genuinely parallel, so it moves from `pending` to `declared` dependency review without adding any edge. Validates the change is a known open change and is idempotent.",
79
+ inputSchema: {
80
+ type: "object",
81
+ properties: {
82
+ change: { type: "string", description: "The open change to mark as having no dependencies" },
83
+ },
84
+ required: ["change"],
85
+ additionalProperties: false,
86
+ },
87
+ },
88
+ {
89
+ name: "install_weave_skill",
90
+ description: "Install the `/loom:weave` slash command into the user's global Claude commands directory (~/.claude/commands/loom/weave.md), so this dependency review can be run as a single command from any project where this server is registered. Writes a static command file only (no project data or secrets) and overwrites any existing copy. Run once, then the user reloads Claude Code.",
91
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
92
+ },
93
+ ],
94
+ }));
95
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
96
+ const { name, arguments: args } = req.params;
97
+ try {
98
+ if (name === "list_open_proposals") {
99
+ return ok(await listOpenProposals(client, changesDir));
100
+ }
101
+ if (name === "set_dependency") {
102
+ const from = String(args?.from ?? "");
103
+ const to = String(args?.to ?? "");
104
+ return ok(await setDependency(client, changesDir, from, to));
105
+ }
106
+ if (name === "mark_independent") {
107
+ const change = String(args?.change ?? "");
108
+ return ok(await markIndependent(client, changesDir, change));
109
+ }
110
+ if (name === "install_weave_skill") {
111
+ return ok(await installWeaveSkill());
112
+ }
113
+ return err(`unknown tool: ${name}`);
114
+ }
115
+ catch (e) {
116
+ return err(e instanceof Error ? e.message : String(e));
117
+ }
118
+ });
119
+ await server.connect(new StdioServerTransport());
120
+ // Logs go to stderr so they don't corrupt the stdio JSON-RPC channel.
121
+ console.error(`[data-loom mcp] serving project ${project}`);
122
+ }
123
+ function ok(data) {
124
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
125
+ }
126
+ function err(message) {
127
+ return { content: [{ type: "text", text: `Error: ${message}` }], isError: true };
128
+ }
129
+ async function openChanges(client) {
130
+ const model = await deriveModel(client);
131
+ return model.changes.filter((c) => !c.archived);
132
+ }
133
+ async function listOpenProposals(client, changesDir) {
134
+ const changes = await openChanges(client);
135
+ const proposals = [];
136
+ for (const c of changes) {
137
+ let proposal = "";
138
+ try {
139
+ proposal = await readFile(join(changesDir, c.name, "proposal.md"), "utf8");
140
+ }
141
+ catch {
142
+ /* no proposal yet */
143
+ }
144
+ proposals.push({
145
+ name: c.name,
146
+ phase: c.phase,
147
+ readiness: c.readiness,
148
+ dependencyReview: c.dependencyReview, // "pending" until a `## Depends On` block exists
149
+ dependsOn: c.dependsOn,
150
+ newCapabilities: c.newCapabilities,
151
+ modifiedCapabilities: c.modifiedCapabilities,
152
+ proposal, // proposal text only — never config, env, or secrets
153
+ });
154
+ }
155
+ return { proposals };
156
+ }
157
+ async function setDependency(client, changesDir, from, to) {
158
+ if (!from || !to)
159
+ throw new Error("both 'from' and 'to' are required");
160
+ if (from === to)
161
+ throw new Error("a change cannot depend on itself");
162
+ const names = new Set((await openChanges(client)).map((c) => c.name));
163
+ if (!names.has(from))
164
+ throw new Error(`unknown open change: ${from}`);
165
+ if (!names.has(to))
166
+ throw new Error(`unknown open change: ${to}`);
167
+ const path = join(changesDir, from, "proposal.md");
168
+ const text = await readFile(path, "utf8");
169
+ const updated = addDependsOn(text, to);
170
+ const written = updated !== text;
171
+ if (written)
172
+ await writeFile(path, updated);
173
+ return { from, to, written, dependsOn: await client.readProposalDependsOn(from) };
174
+ }
175
+ async function markIndependent(client, changesDir, change) {
176
+ if (!change)
177
+ throw new Error("'change' is required");
178
+ const names = new Set((await openChanges(client)).map((c) => c.name));
179
+ if (!names.has(change))
180
+ throw new Error(`unknown open change: ${change}`);
181
+ const path = join(changesDir, change, "proposal.md");
182
+ const text = await readFile(path, "utf8");
183
+ const updated = ensureDependsOnSection(text);
184
+ const written = updated !== text; // already declared -> idempotent no-op
185
+ if (written)
186
+ await writeFile(path, updated);
187
+ return { change, written, dependencyReview: "declared" };
188
+ }
189
+ /**
190
+ * Provision the `/loom:weave` command into the user's GLOBAL Claude commands
191
+ * dir, so the review runs as one command from any project where this server is
192
+ * registered. Writes only the static command file; overwrites in place.
193
+ */
194
+ async function installWeaveSkill() {
195
+ const dir = join(homedir(), ".claude", "commands", "loom");
196
+ const path = join(dir, "weave.md");
197
+ await mkdir(dir, { recursive: true });
198
+ await writeFile(path, WEAVE_COMMAND);
199
+ return {
200
+ installed: true,
201
+ command: "/loom:weave",
202
+ path,
203
+ note: "Reload Claude Code (restart the session) to pick up the new /loom:weave command.",
204
+ };
205
+ }
206
+ /** Add `- <dep>` under a `## Depends On` section, creating the section if absent. */
207
+ function addDependsOn(text, dep) {
208
+ const lines = text.split("\n");
209
+ const headerIdx = lines.findIndex((l) => /^##\s+Depends On\b/.test(l));
210
+ const bullet = `- ${dep}`;
211
+ if (headerIdx === -1) {
212
+ return `${text.replace(/\s*$/, "")}\n\n## Depends On\n${bullet}\n`;
213
+ }
214
+ let end = headerIdx + 1;
215
+ while (end < lines.length && !/^#{1,6}\s/.test(lines[end]))
216
+ end++;
217
+ const escaped = dep.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
218
+ const present = lines
219
+ .slice(headerIdx + 1, end)
220
+ .some((l) => new RegExp(`^-\\s+\`?${escaped}\`?\\s*$`).test(l.trim()));
221
+ if (present)
222
+ return text;
223
+ let insert = end;
224
+ while (insert > headerIdx + 1 && lines[insert - 1].trim() === "")
225
+ insert--;
226
+ lines.splice(insert, 0, bullet);
227
+ return lines.join("\n");
228
+ }
229
+ /** Ensure an empty `## Depends On` section exists. Idempotent: unchanged if a heading is already present. */
230
+ function ensureDependsOnSection(text) {
231
+ if (/(?:^|\n)##\s+Depends On\b/.test(text))
232
+ return text;
233
+ return `${text.replace(/\s*$/, "")}\n\n## Depends On\n`;
234
+ }
@@ -0,0 +1,145 @@
1
+ // Thin wrapper around the `openspec` CLI plus the small amount of structured
2
+ // proposal metadata the CLI does not expose (capability ownership).
3
+ import { execFile } from "node:child_process";
4
+ import { promisify } from "node:util";
5
+ import { readFile, readdir } from "node:fs/promises";
6
+ import { join } from "node:path";
7
+ const execFileP = promisify(execFile);
8
+ export class OpenSpecClient {
9
+ repoRoot;
10
+ constructor(repoRoot) {
11
+ this.repoRoot = repoRoot;
12
+ }
13
+ openspecDir() {
14
+ return join(this.repoRoot, "openspec");
15
+ }
16
+ /** On Windows the CLI is a `.cmd` shim, so it must be invoked through a shell. */
17
+ async run(args) {
18
+ const { stdout } = await execFileP("openspec", args, {
19
+ cwd: this.repoRoot,
20
+ shell: process.platform === "win32",
21
+ maxBuffer: 16 * 1024 * 1024,
22
+ });
23
+ return stdout;
24
+ }
25
+ async runJson(args) {
26
+ return parseLooseJson(await this.run(args));
27
+ }
28
+ /** Resolves to the CLI version string, or throws if the CLI is missing. */
29
+ async checkAvailable() {
30
+ return (await this.run(["--version"])).trim();
31
+ }
32
+ async listChanges() {
33
+ const data = await this.runJson(["list", "--json"]);
34
+ return data.changes ?? [];
35
+ }
36
+ /** Distinct (capability, operation) pairs for a change, from the CLI. */
37
+ async showDeltas(name) {
38
+ const data = await this.runJson([
39
+ "show",
40
+ name,
41
+ "--json",
42
+ ]);
43
+ const seen = new Set();
44
+ const out = [];
45
+ for (const d of data.deltas ?? []) {
46
+ const key = `${d.spec}|${d.operation}`;
47
+ if (!seen.has(key)) {
48
+ seen.add(key);
49
+ out.push({ spec: d.spec, operation: d.operation });
50
+ }
51
+ }
52
+ return out;
53
+ }
54
+ /** Capability ownership — read from the proposal, since the CLI does not expose it. */
55
+ async readProposalCaps(name) {
56
+ const path = join(this.openspecDir(), "changes", name, "proposal.md");
57
+ let text;
58
+ try {
59
+ text = await readFile(path, "utf8");
60
+ }
61
+ catch {
62
+ return { newCaps: [], modifiedCaps: [] };
63
+ }
64
+ return {
65
+ newCaps: capsInSection(text, "New Capabilities"),
66
+ modifiedCaps: capsInSection(text, "Modified Capabilities"),
67
+ };
68
+ }
69
+ /** Explicit cross-proposal dependencies declared under a `## Depends On` section. */
70
+ async readProposalDependsOn(name) {
71
+ const path = join(this.openspecDir(), "changes", name, "proposal.md");
72
+ try {
73
+ return dependsOnInSection(await readFile(path, "utf8"));
74
+ }
75
+ catch {
76
+ return [];
77
+ }
78
+ }
79
+ /**
80
+ * Whether the proposal declares a `## Depends On` section at all — even an
81
+ * empty one. Distinct from {@link readProposalDependsOn}, which reports the
82
+ * entries: a present-but-empty section means "reviewed, depends on nothing".
83
+ */
84
+ async hasDependsOnSection(name) {
85
+ const path = join(this.openspecDir(), "changes", name, "proposal.md");
86
+ try {
87
+ return DEPENDS_ON_HEADING.test(await readFile(path, "utf8"));
88
+ }
89
+ catch {
90
+ return false;
91
+ }
92
+ }
93
+ /** Archived change names (completed nodes shown in the done band). */
94
+ async listArchived() {
95
+ return this.listDirNames(join(this.openspecDir(), "changes", "archive"));
96
+ }
97
+ /** Settled capabilities already in the spec baseline. */
98
+ async listBaselineCapabilities() {
99
+ return this.listDirNames(join(this.openspecDir(), "specs"));
100
+ }
101
+ async listDirNames(dir) {
102
+ try {
103
+ const entries = await readdir(dir, { withFileTypes: true });
104
+ return entries.filter((e) => e.isDirectory()).map((e) => e.name);
105
+ }
106
+ catch {
107
+ return [];
108
+ }
109
+ }
110
+ }
111
+ /** Parse JSON that may be preceded by CLI warning lines on stdout. */
112
+ function parseLooseJson(s) {
113
+ const start = s.search(/[[{]/);
114
+ if (start < 0) {
115
+ throw new Error(`No JSON found in openspec CLI output: ${s.slice(0, 160)}`);
116
+ }
117
+ return JSON.parse(s.slice(start));
118
+ }
119
+ /** The `## Depends On` section heading — used for both detection and parsing. */
120
+ const DEPENDS_ON_HEADING = /(?:^|\n)##\s+Depends On\b/;
121
+ /** Extract `- <change-name>` bullets under a `## Depends On` heading. */
122
+ function dependsOnInSection(text) {
123
+ const m = /(?:^|\n)##\s+Depends On\s*([\s\S]*?)(?:\n##\s|\n#\s|$)/.exec(text);
124
+ if (!m)
125
+ return [];
126
+ const out = [];
127
+ const bullet = /^-\s+`?([a-z0-9-]+)`?/gm;
128
+ let mm;
129
+ while ((mm = bullet.exec(m[1])) !== null)
130
+ out.push(mm[1]);
131
+ return out;
132
+ }
133
+ /** Extract `- \`cap-name\`: ...` bullets under a `### <heading>` block. */
134
+ function capsInSection(text, heading) {
135
+ const re = new RegExp(`###\\s+${heading}([\\s\\S]*?)(?:\\n###\\s|\\n##\\s|$)`);
136
+ const m = re.exec(text);
137
+ if (!m)
138
+ return [];
139
+ const caps = [];
140
+ const bullet = /^-\s+`([a-z0-9-]+)`/gm;
141
+ let mm;
142
+ while ((mm = bullet.exec(m[1])) !== null)
143
+ caps.push(mm[1]);
144
+ return caps;
145
+ }
@@ -0,0 +1,46 @@
1
+ // Discover selectable projects from Claude Code's known projects, limited to
2
+ // those that actually contain an openspec/ workspace.
3
+ import { readFile } from "node:fs/promises";
4
+ import { existsSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { join, basename } from "node:path";
7
+ /** True when the directory contains an openspec/ workspace. */
8
+ export function isViewableProject(path) {
9
+ return existsSync(join(path, "openspec"));
10
+ }
11
+ export async function discoverProjects(active) {
12
+ const byNorm = new Map();
13
+ const add = (path) => {
14
+ const key = normalizePath(path);
15
+ if (byNorm.has(key))
16
+ return;
17
+ if (!isViewableProject(path))
18
+ return;
19
+ byNorm.set(key, { path, name: basename(path) || path });
20
+ };
21
+ // Claude Code known projects
22
+ try {
23
+ const cj = JSON.parse(await readFile(join(homedir(), ".claude.json"), "utf8"));
24
+ for (const p of Object.keys(cj.projects ?? {}))
25
+ add(p);
26
+ }
27
+ catch {
28
+ /* no config -> only the active project */
29
+ }
30
+ // Include the active project (if any) only when it is itself viewable, so the
31
+ // picker never offers a non-openspec directory. `current` is "" when no
32
+ // project is active. Reported as the candidate's exact path string so the
33
+ // client's selected-option comparison works regardless of slash/case.
34
+ let current = "";
35
+ if (active && isViewableProject(active)) {
36
+ const key = normalizePath(active);
37
+ if (!byNorm.has(key)) {
38
+ byNorm.set(key, { path: active, name: basename(active) || active });
39
+ }
40
+ current = byNorm.get(key).path;
41
+ }
42
+ return { current, candidates: [...byNorm.values()] };
43
+ }
44
+ function normalizePath(p) {
45
+ return p.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
46
+ }
package/dist/server.js ADDED
@@ -0,0 +1,92 @@
1
+ // Serve the SPA on loopback and push roadmap + MCP + project state over a websocket.
2
+ import { createServer } from "node:http";
3
+ import { extname } from "node:path";
4
+ import { WebSocketServer, WebSocket } from "ws";
5
+ import { loadAsset } from "./assets.js";
6
+ const MIME = {
7
+ ".html": "text/html; charset=utf-8",
8
+ ".js": "text/javascript; charset=utf-8",
9
+ ".css": "text/css; charset=utf-8",
10
+ ".json": "application/json; charset=utf-8",
11
+ ".svg": "image/svg+xml",
12
+ };
13
+ export async function startServer(opts) {
14
+ const { publicDir, host, port, getRoadmap, getMcp, checkMcp, getProjects, selectProject } = opts;
15
+ let wss;
16
+ const broadcast = (msg) => {
17
+ const data = JSON.stringify(msg);
18
+ for (const ws of wss.clients) {
19
+ if (ws.readyState === WebSocket.OPEN)
20
+ ws.send(data);
21
+ }
22
+ };
23
+ const http = createServer(async (req, res) => {
24
+ try {
25
+ const url = new URL(req.url ?? "/", `http://${host}`);
26
+ if (url.pathname === "/api/model")
27
+ return sendJson(res, getRoadmap());
28
+ if (url.pathname === "/api/mcp")
29
+ return sendJson(res, getMcp());
30
+ if (url.pathname === "/api/projects")
31
+ return sendJson(res, await getProjects());
32
+ if (url.pathname === "/api/mcp/check" && req.method === "POST") {
33
+ const server = await checkMcp(url.searchParams.get("name") ?? "");
34
+ if (!server)
35
+ return sendError(res, 404, "unknown server");
36
+ broadcast({ type: "mcpServer", server });
37
+ return sendJson(res, server);
38
+ }
39
+ if (url.pathname === "/api/project/select" && req.method === "POST") {
40
+ const path = url.searchParams.get("path") ?? "";
41
+ try {
42
+ return sendJson(res, await selectProject(path));
43
+ }
44
+ catch (err) {
45
+ return sendError(res, 400, err instanceof Error ? err.message : "invalid project");
46
+ }
47
+ }
48
+ // static assets (served from public/ on the filesystem)
49
+ const rel = url.pathname === "/" ? "index.html" : url.pathname.replace(/^\/+/, "");
50
+ if (rel.includes(".."))
51
+ return sendError(res, 403, "forbidden");
52
+ const file = await loadAsset(rel, publicDir);
53
+ if (!file)
54
+ return sendError(res, 404, "not found");
55
+ res.writeHead(200, { "content-type": MIME[extname(rel)] ?? "application/octet-stream" });
56
+ res.end(file);
57
+ }
58
+ catch {
59
+ sendError(res, 500, "error");
60
+ }
61
+ });
62
+ wss = new WebSocketServer({ server: http });
63
+ wss.on("connection", async (ws) => {
64
+ const roadmap = getRoadmap();
65
+ if (roadmap)
66
+ ws.send(JSON.stringify({ type: "model", model: roadmap }));
67
+ ws.send(JSON.stringify({ type: "mcp", mcp: getMcp() }));
68
+ try {
69
+ ws.send(JSON.stringify({ type: "project", project: await getProjects() }));
70
+ }
71
+ catch {
72
+ /* project list optional */
73
+ }
74
+ });
75
+ await new Promise((resolve) => http.listen(port, host, resolve));
76
+ return {
77
+ port,
78
+ broadcast,
79
+ close: () => {
80
+ wss.close();
81
+ http.close();
82
+ },
83
+ };
84
+ }
85
+ function sendJson(res, body) {
86
+ res.writeHead(200, { "content-type": MIME[".json"] });
87
+ res.end(JSON.stringify(body));
88
+ }
89
+ function sendError(res, code, message) {
90
+ res.writeHead(code, { "content-type": MIME[".json"] });
91
+ res.end(JSON.stringify({ error: message }));
92
+ }
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ // Shared model types — the contract between the daemon and the browser view.
2
+ export {};
@@ -0,0 +1,26 @@
1
+ // Watch the openspec/ directory and fire a debounced callback on any change.
2
+ import { watch } from "node:fs";
3
+ import { join } from "node:path";
4
+ export function watchOpenspec(repoRoot, onChange, debounceMs = 200) {
5
+ const dir = join(repoRoot, "openspec");
6
+ let timer;
7
+ const trigger = () => {
8
+ if (timer)
9
+ clearTimeout(timer);
10
+ timer = setTimeout(onChange, debounceMs);
11
+ };
12
+ let watcher;
13
+ try {
14
+ // Recursive watching is supported on Windows and macOS.
15
+ watcher = watch(dir, { recursive: true }, trigger);
16
+ }
17
+ catch {
18
+ // Fallback: shallow watch (e.g. Linux without recursive support).
19
+ watcher = watch(dir, trigger);
20
+ }
21
+ return () => {
22
+ if (timer)
23
+ clearTimeout(timer);
24
+ watcher.close();
25
+ };
26
+ }
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@lyric_dev/data-loom",
3
+ "version": "0.2.4",
4
+ "description": "Local dashboard for spec-driven development: a phased OpenSpec roadmap (WHAT) and an MCP topology (HOW).",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "homepage": "https://github.com/groscy/data-loom#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/groscy/data-loom.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/groscy/data-loom/issues"
14
+ },
15
+ "keywords": [
16
+ "openspec",
17
+ "mcp",
18
+ "dashboard",
19
+ "roadmap",
20
+ "spec-driven-development",
21
+ "claude-code"
22
+ ],
23
+ "bin": {
24
+ "data-loom": "dist/index.js"
25
+ },
26
+ "files": [
27
+ "dist",
28
+ "public"
29
+ ],
30
+ "engines": {
31
+ "node": ">=20"
32
+ },
33
+ "scripts": {
34
+ "build": "tsc",
35
+ "start": "node dist/index.js",
36
+ "dev": "tsc && node --watch dist/index.js",
37
+ "prepublishOnly": "npm run build"
38
+ },
39
+ "publishConfig": {
40
+ "access": "public"
41
+ },
42
+ "dependencies": {
43
+ "@modelcontextprotocol/sdk": "^1.29.0",
44
+ "ws": "^8.18.0"
45
+ },
46
+ "devDependencies": {
47
+ "@types/node": "^22.7.0",
48
+ "@types/ws": "^8.5.12",
49
+ "typescript": "^5.6.2"
50
+ }
51
+ }