@lyric_dev/data-loom 0.5.0 → 0.7.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.
@@ -0,0 +1,144 @@
1
+ // MCP stdio shim: spawned by a client (typically Claude Code's stdio
2
+ // registration), it ensures the daemon is running, then proxies MCP traffic
3
+ // between its own stdio and the daemon's loopback HTTP `/mcp` endpoint for the
4
+ // life of the client session.
5
+ //
6
+ // Pure proxy — no tools, prompts, or sessions of its own. The daemon remains
7
+ // the single MCP server and source of truth (including per-call project
8
+ // resolution); this file only launches (if needed) and forwards raw JSON-RPC
9
+ // messages both ways.
10
+ import { readFile } from "node:fs/promises";
11
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
12
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
13
+ import { isInitializeRequest, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
14
+ import * as lifecycle from "./lifecycle.js";
15
+ import { mcpUrl, logFile } from "./paths.js";
16
+ const LAUNCH_TIMEOUT_MS = 10_000;
17
+ const POLL_INTERVAL_MS = 300;
18
+ const delay = (ms) => new Promise((r) => setTimeout(r, ms));
19
+ /**
20
+ * Ensure the daemon answers on the loopback port, starting it (through the
21
+ * normal detached lifecycle path) if it does not. Bounded wait — never hangs.
22
+ * A concurrent second shim racing to start is harmless: the daemon's own
23
+ * single-instance guard (the port probe in lifecycle.start) means at most one
24
+ * daemon results, and both shims' polls succeed against it.
25
+ */
26
+ async function ensureDaemonUp() {
27
+ if (await lifecycle.isRunning())
28
+ return { ok: true };
29
+ await lifecycle.start();
30
+ const deadline = Date.now() + LAUNCH_TIMEOUT_MS;
31
+ while (Date.now() < deadline) {
32
+ if (await lifecycle.isRunning())
33
+ return { ok: true };
34
+ await delay(POLL_INTERVAL_MS);
35
+ }
36
+ return { ok: false, diagnosis: await diagnoseFailure() };
37
+ }
38
+ /** Best-effort explanation of why the daemon never came up, from its own log tail. */
39
+ async function diagnoseFailure() {
40
+ let tail = "";
41
+ try {
42
+ tail = (await readFile(logFile(), "utf8")).slice(-4000);
43
+ }
44
+ catch {
45
+ /* no log yet — daemon likely never got far enough to write one */
46
+ }
47
+ if (/openspec.*was not found/i.test(tail)) {
48
+ return ("DataLoom could not start: the `openspec` CLI is missing. Install it (e.g. `npm i -g openspec`), then retry. " +
49
+ `Log: ${logFile()}`);
50
+ }
51
+ return ("DataLoom's daemon did not come up in time. Run `data-loom status` to check what's wrong, and see the log at " +
52
+ logFile());
53
+ }
54
+ function extractId(msg) {
55
+ return "id" in msg ? msg.id : undefined;
56
+ }
57
+ function errorResponse(id, message) {
58
+ return {
59
+ jsonrpc: "2.0",
60
+ id,
61
+ error: { code: ErrorCode.InternalError, message },
62
+ };
63
+ }
64
+ /**
65
+ * stdout is the MCP transport's wire — reserved exclusively for framed
66
+ * JSON-RPC messages. Reused helpers here (lifecycle.start/isRunning) predate
67
+ * the shim and log human-readable status with console.log, which writes to
68
+ * that same stdout; left alone, those lines interleave with JSON-RPC frames
69
+ * and corrupt the client's stream. Redirect the stdout-writing console
70
+ * methods to stderr for this process's whole lifetime (console.warn/error
71
+ * already go to stderr).
72
+ */
73
+ function keepStdoutForTransportOnly() {
74
+ console.log = console.error;
75
+ console.info = console.error;
76
+ console.debug = console.error;
77
+ }
78
+ /**
79
+ * Entry point for `data-loom mcp-shim`. Never resolves until the client
80
+ * session ends (or the launch fails and the handshake has been answered) —
81
+ * the caller (index.ts) just awaits and lets the process exit naturally.
82
+ */
83
+ export async function runShim() {
84
+ keepStdoutForTransportOnly();
85
+ const stdio = new StdioServerTransport();
86
+ // Buffer whatever arrives while the daemon is (maybe) still starting, so
87
+ // nothing sent before the proxy is wired up is lost.
88
+ const pending = [];
89
+ stdio.onmessage = (msg) => {
90
+ pending.push(msg);
91
+ };
92
+ stdio.onerror = (err) => console.error("[data-loom mcp-shim] stdio error:", err);
93
+ await stdio.start();
94
+ const readiness = await ensureDaemonUp();
95
+ if (!readiness.ok) {
96
+ const initMsg = pending.find((m) => isInitializeRequest(m));
97
+ const id = initMsg ? extractId(initMsg) : undefined;
98
+ if (id !== undefined) {
99
+ await stdio.send(errorResponse(id, readiness.diagnosis));
100
+ }
101
+ else {
102
+ // No handshake to answer yet — still surface the failure, just not as
103
+ // an MCP response. Never exit without saying why.
104
+ console.error(`[data-loom mcp-shim] ${readiness.diagnosis}`);
105
+ }
106
+ await stdio.close();
107
+ process.exitCode = 1;
108
+ return;
109
+ }
110
+ const client = new StreamableHTTPClientTransport(new URL(mcpUrl()));
111
+ let closing = false;
112
+ // The shim's whole job ends with its client session — one side closing
113
+ // (client disconnects, or the daemon connection drops) tears down the
114
+ // other, but the daemon itself is never touched.
115
+ const closeBoth = () => {
116
+ if (closing)
117
+ return;
118
+ closing = true;
119
+ void stdio.close();
120
+ void client.close();
121
+ process.exit(0);
122
+ };
123
+ client.onmessage = (msg) => {
124
+ void stdio.send(msg);
125
+ };
126
+ client.onerror = (err) => console.error("[data-loom mcp-shim] daemon connection error:", err);
127
+ client.onclose = closeBoth;
128
+ stdio.onerror = (err) => console.error("[data-loom mcp-shim] stdio error:", err);
129
+ stdio.onclose = closeBoth;
130
+ // StdioServerTransport only ever calls onclose when *we* call close() — it
131
+ // does not itself listen for stdin ending. The client process closing its
132
+ // end of the pipe (the real "session ended" signal) only shows up as
133
+ // stdin's own 'end'/'close' event, so that is what actually has to trigger
134
+ // teardown here.
135
+ process.stdin.on("end", closeBoth);
136
+ process.stdin.on("close", closeBoth);
137
+ await client.start();
138
+ for (const msg of pending)
139
+ await client.send(msg);
140
+ // From here on, every message from the client goes straight through.
141
+ stdio.onmessage = (msg) => {
142
+ void client.send(msg);
143
+ };
144
+ }
package/dist/paths.js CHANGED
@@ -31,6 +31,12 @@ export function stateDir() {
31
31
  }
32
32
  export const pidFile = () => join(stateDir(), "daemon.pid");
33
33
  export const logFile = () => join(stateDir(), "daemon.log");
34
+ /**
35
+ * The stable launcher OS supervisors invoke (Scheduled Task / LaunchAgent /
36
+ * systemd unit) — a fixed path that never changes across Node version or
37
+ * package-location churn, unlike the `node` + script paths it wraps.
38
+ */
39
+ export const launcherFile = () => join(stateDir(), process.platform === "win32" ? "daemon.cmd" : "daemon.sh");
34
40
  /** Create the state dir on demand; safe to call repeatedly. */
35
41
  export async function ensureStateDir() {
36
42
  const dir = stateDir();
package/dist/server.js CHANGED
@@ -15,7 +15,7 @@ const MIME = {
15
15
  ".svg": "image/svg+xml",
16
16
  };
17
17
  export async function startServer(opts) {
18
- const { publicDir, host, port, getRoadmap, getMcp, checkMcp, getProjects, selectProject, getCurrentProject } = opts;
18
+ const { publicDir, host, port, getRoadmap, getMcp, checkMcp, getProjects, selectProject, getCurrentProject, onShutdown } = opts;
19
19
  let wss;
20
20
  const broadcast = (msg) => {
21
21
  const data = JSON.stringify(msg);
@@ -117,6 +117,16 @@ export async function startServer(opts) {
117
117
  sendError(res, 413, "request body too large");
118
118
  return;
119
119
  }
120
+ if (e instanceof InvalidJsonError) {
121
+ // A body that never parsed is the caller's encoding fault — answer with
122
+ // a JSON-RPC parse error, not a 500. The classic trigger is a Windows
123
+ // path with unescaped backslashes ("D:\projects\…" — \p is not a valid
124
+ // JSON escape), so name that in the hint. The detail only ever echoes
125
+ // the parser's complaint about the caller's own body, never host state.
126
+ if (!res.headersSent)
127
+ sendRpcParseError(res, e.message);
128
+ return;
129
+ }
120
130
  // Unexpected failures stay in the server log; the client gets nothing
121
131
  // about the host (no paths, no stack).
122
132
  console.error("[data-loom] mcp request error:", e);
@@ -137,6 +147,15 @@ export async function startServer(opts) {
137
147
  return sendJson(res, getMcp());
138
148
  if (url.pathname === "/api/projects")
139
149
  return sendJson(res, await getProjects());
150
+ if (url.pathname === "/api/shutdown" && req.method === "POST") {
151
+ // Loopback-only (isAllowed already ran) graceful stop. Answer first,
152
+ // then trigger shutdown once the response has flushed so the caller
153
+ // gets its 200 before the process exits.
154
+ res.writeHead(200, { "content-type": MIME[".json"] });
155
+ res.end(JSON.stringify({ stopping: true }));
156
+ res.on("finish", () => setImmediate(() => onShutdown?.()));
157
+ return;
158
+ }
140
159
  if (url.pathname === "/api/mcp/check" && req.method === "POST") {
141
160
  const server = await checkMcp(url.searchParams.get("name") ?? "");
142
161
  if (!server)
@@ -174,6 +193,16 @@ export async function startServer(opts) {
174
193
  server: http,
175
194
  verifyClient: (info) => isAllowed(info.req),
176
195
  });
196
+ // ws re-emits the underlying server's 'error'; without a listener a bind
197
+ // failure (EADDRINUSE) becomes an unhandled 'error' event that crashes the
198
+ // process. The listen() promise below surfaces startup bind errors to the
199
+ // caller, so ignore those here (avoids a scary duplicate stack on the normal
200
+ // "already running" path); log only unexpected post-startup socket errors.
201
+ wss.on("error", (err) => {
202
+ if (err.code !== "EADDRINUSE") {
203
+ console.error("[data-loom] websocket error:", err);
204
+ }
205
+ });
177
206
  wss.on("connection", async (ws) => {
178
207
  const roadmap = getRoadmap();
179
208
  if (roadmap)
@@ -186,7 +215,18 @@ export async function startServer(opts) {
186
215
  /* project list optional */
187
216
  }
188
217
  });
189
- await new Promise((ready) => http.listen(port, host, ready));
218
+ // Reject (rather than crash) if the port is taken — the caller decides how to
219
+ // report an already-running instance. After a clean listen, later server
220
+ // errors are logged, not fatal.
221
+ await new Promise((resolve, reject) => {
222
+ const onError = (err) => reject(err);
223
+ http.once("error", onError);
224
+ http.listen(port, host, () => {
225
+ http.removeListener("error", onError);
226
+ http.on("error", (err) => console.error("[data-loom] http server error:", err));
227
+ resolve();
228
+ });
229
+ });
190
230
  const actualPort = http.address()?.port ?? port;
191
231
  setAllowed(actualPort);
192
232
  return {
@@ -207,9 +247,29 @@ function sendError(res, code, message) {
207
247
  res.writeHead(code, { "content-type": MIME[".json"] });
208
248
  res.end(JSON.stringify({ error: message }));
209
249
  }
250
+ /**
251
+ * Answer a request whose body was not valid JSON with a JSON-RPC parse error
252
+ * (-32700) over HTTP 400, per the Streamable-HTTP spec. `detail` is the JSON
253
+ * parser's message about the caller's own payload — safe to echo, and the hint
254
+ * names the most common cause on Windows (unescaped path backslashes).
255
+ */
256
+ function sendRpcParseError(res, detail) {
257
+ res.writeHead(400, { "content-type": MIME[".json"] });
258
+ res.end(JSON.stringify({
259
+ jsonrpc: "2.0",
260
+ error: {
261
+ code: -32700,
262
+ message: `Parse error: request body is not valid JSON (${detail}). If the payload contains Windows paths, JSON-escape the backslashes ("D:\\\\projects\\\\…") or use forward slashes.`,
263
+ },
264
+ id: null,
265
+ }));
266
+ }
210
267
  /** Raised when a request body exceeds the cap; mapped to HTTP 413. */
211
268
  class PayloadTooLargeError extends Error {
212
269
  }
270
+ /** Raised when a request body is not parseable JSON; mapped to HTTP 400 / -32700. */
271
+ class InvalidJsonError extends Error {
272
+ }
213
273
  /** Largest request body we will buffer (JSON-RPC tool calls are tiny). */
214
274
  const MAX_BODY_BYTES = 4 * 1024 * 1024;
215
275
  /** Read and JSON-parse a request body; returns undefined for an empty body. */
@@ -239,7 +299,7 @@ function readJsonBody(req) {
239
299
  resolve(JSON.parse(data));
240
300
  }
241
301
  catch (e) {
242
- reject(e instanceof Error ? e : new Error("invalid JSON body"));
302
+ reject(new InvalidJsonError(e instanceof Error ? e.message : "invalid JSON body"));
243
303
  }
244
304
  });
245
305
  req.on("error", reject);
package/dist/tray.js ADDED
@@ -0,0 +1,142 @@
1
+ // System-tray presence for the running daemon — a glanceable "is DataLoom
2
+ // running?" signal for the detached/background mode that has no console or
3
+ // window. Windows-first and dependency-free: a hidden PowerShell helper draws
4
+ // the DataLoom mark, shows a tooltip, and offers Open / Copy / Stop; menu
5
+ // clicks arrive back as stdout lines. Any failure (non-Windows, no PowerShell,
6
+ // headless/session-0, DATA_LOOM_NO_TRAY) is a silent no-op, so the tray never
7
+ // affects the daemon's ability to serve (mirror-not-launcher, DataLoom-only).
8
+ import { spawn } from "node:child_process";
9
+ const NOOP = { dispose: () => { } };
10
+ /**
11
+ * Show a tray icon for the running daemon. Returns a disposer. Never throws:
12
+ * on any unsupported/headless host or failure it returns a no-op tray so the
13
+ * caller can wire it unconditionally.
14
+ */
15
+ export function initTray(opts) {
16
+ if (process.env.DATA_LOOM_NO_TRAY)
17
+ return NOOP;
18
+ // Windows-first: elsewhere (and headless) we degrade to no tray.
19
+ if (process.platform !== "win32")
20
+ return NOOP;
21
+ try {
22
+ return startWindowsTray(opts);
23
+ }
24
+ catch (err) {
25
+ opts.log?.(`[data-loom] tray unavailable (continuing without it): ${err instanceof Error ? err.message : err}`);
26
+ return NOOP;
27
+ }
28
+ }
29
+ function startWindowsTray(opts) {
30
+ const script = buildScript(opts.url, process.pid);
31
+ // -EncodedCommand takes base64 of the UTF-16LE script, sidestepping all shell
32
+ // quoting. powershell.exe (Windows PowerShell 5.1) runs STA, which WinForms
33
+ // / NotifyIcon require.
34
+ const encoded = Buffer.from(script, "utf16le").toString("base64");
35
+ const child = spawn("powershell.exe", ["-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-ExecutionPolicy", "Bypass", "-EncodedCommand", encoded], { windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
36
+ let disposed = false;
37
+ child.stdout?.setEncoding("utf8");
38
+ let buf = "";
39
+ child.stdout?.on("data", (chunk) => {
40
+ buf += chunk;
41
+ let nl;
42
+ while ((nl = buf.indexOf("\n")) >= 0) {
43
+ const line = buf.slice(0, nl).trim();
44
+ buf = buf.slice(nl + 1);
45
+ if (!line)
46
+ continue;
47
+ if (line === "open")
48
+ opts.onOpen();
49
+ else if (line === "copy")
50
+ opts.onCopy();
51
+ else if (line === "stop")
52
+ opts.onStop();
53
+ }
54
+ });
55
+ child.stderr?.setEncoding("utf8");
56
+ child.stderr?.on("data", (chunk) => {
57
+ // PowerShell serializes its progress/verbose streams to stderr as CLIXML
58
+ // when piped (e.g. "Preparing modules for first use") — benign noise. Only
59
+ // surface genuine error records.
60
+ if (!/S="error"/i.test(chunk))
61
+ return;
62
+ opts.log?.(`[data-loom] tray helper error: ${chunk.trim()}`);
63
+ });
64
+ child.on("error", (err) => opts.log?.(`[data-loom] tray helper error: ${err.message}`));
65
+ return {
66
+ dispose() {
67
+ if (disposed)
68
+ return;
69
+ disposed = true;
70
+ // Intentionally not killed here: the helper polls for its parent (this
71
+ // daemon) and, once we exit, disposes its own NotifyIcon and quits — a
72
+ // clean icon removal. A hard kill would skip that and leave a ghost icon
73
+ // in the tray until the user hovers it. dispose() is only called during
74
+ // shutdown, which exits the process immediately afterward.
75
+ try {
76
+ child.stdout?.removeAllListeners();
77
+ child.stderr?.removeAllListeners();
78
+ }
79
+ catch {
80
+ /* nothing to clean up */
81
+ }
82
+ },
83
+ };
84
+ }
85
+ /**
86
+ * The PowerShell helper. Built as joined lines (no backticks, so it survives a
87
+ * JS template context) with the URL and parent PID inlined. It draws the woven
88
+ * DataLoom mark, shows a NotifyIcon with a tooltip and a context menu, reports
89
+ * menu clicks on stdout, and self-terminates (removing its icon) as soon as the
90
+ * parent daemon is gone.
91
+ */
92
+ function buildScript(url, parentPid) {
93
+ const safeUrl = url.replace(/'/g, "''"); // single-quote escape for PS literal
94
+ return [
95
+ "$ErrorActionPreference = 'Stop'",
96
+ "$ProgressPreference = 'SilentlyContinue'",
97
+ "Add-Type -AssemblyName System.Windows.Forms",
98
+ "Add-Type -AssemblyName System.Drawing",
99
+ "$url = '" + safeUrl + "'",
100
+ "$parent = " + String(parentPid),
101
+ // Draw the woven-lattice mark (faint horizontals + accent-blue verticals).
102
+ "$bmp = New-Object System.Drawing.Bitmap 32,32",
103
+ "$g = [System.Drawing.Graphics]::FromImage($bmp)",
104
+ "$g.SmoothingMode = 'AntiAlias'",
105
+ "$g.Clear([System.Drawing.Color]::Transparent)",
106
+ "$accent = New-Object System.Drawing.Pen ([System.Drawing.Color]::FromArgb(255,37,99,184)), 2.4",
107
+ "$faint = New-Object System.Drawing.Pen ([System.Drawing.Color]::FromArgb(255,144,163,190)), 2.4",
108
+ "foreach ($y in 7,16,25) { $g.DrawLine($faint, 6, $y, 26, $y) }",
109
+ "foreach ($x in 8,16,24) { $g.DrawLine($accent, $x, 6, $x, 26) }",
110
+ "$g.Dispose()",
111
+ "$icon = [System.Drawing.Icon]::FromHandle($bmp.GetHicon())",
112
+ // Tray icon + tooltip.
113
+ "$notify = New-Object System.Windows.Forms.NotifyIcon",
114
+ "$notify.Icon = $icon",
115
+ "$notify.Text = 'DataLoom - running - ' + $url",
116
+ "$notify.Visible = $true",
117
+ // Context menu — DataLoom-only actions, reported to the daemon on stdout.
118
+ "$menu = New-Object System.Windows.Forms.ContextMenuStrip",
119
+ "$mOpen = $menu.Items.Add('Open Dashboard')",
120
+ "$mOpen.add_Click({ try { [Console]::Out.WriteLine('open'); [Console]::Out.Flush() } catch {} })",
121
+ "$mCopy = $menu.Items.Add('Copy URL')",
122
+ "$mCopy.add_Click({ try { [Console]::Out.WriteLine('copy'); [Console]::Out.Flush() } catch {} })",
123
+ "$mStop = $menu.Items.Add('Stop DataLoom')",
124
+ "$mStop.add_Click({ try { [Console]::Out.WriteLine('stop'); [Console]::Out.Flush() } catch {} })",
125
+ "$notify.ContextMenuStrip = $menu",
126
+ // Clean removal of the icon, however we exit.
127
+ "$cleanup = { try { $notify.Visible = $false } catch {}; try { $notify.Dispose() } catch {}; try { $icon.Dispose() } catch {}; try { $bmp.Dispose() } catch {} }",
128
+ // Pump WinForms messages (so the icon shows and the menu responds) while the
129
+ // parent daemon is alive; when it goes, remove our icon and quit. A manual
130
+ // DoEvents loop keeps $parent/$cleanup in the main scope, avoiding the scope
131
+ // pitfalls of .NET event-handler scriptblocks. The menu handlers above use
132
+ // only string literals, so they capture nothing.
133
+ "try {",
134
+ " while (Get-Process -Id $parent -ErrorAction SilentlyContinue) {",
135
+ " [System.Windows.Forms.Application]::DoEvents()",
136
+ " Start-Sleep -Milliseconds 150",
137
+ " }",
138
+ "} finally {",
139
+ " & $cleanup",
140
+ "}",
141
+ ].join("\n");
142
+ }
@@ -0,0 +1,18 @@
1
+ // The advertised package version — read from the manifest shipped next to the
2
+ // compiled code, so it can never drift from the released version again (it was
3
+ // hardcoded in mcpServer.ts and stuck at 0.4.1 through the 0.5.0 release).
4
+ // Shared by the MCP server (server version), the weave alias (version stamp),
5
+ // and the startup refresh (comparing stamps).
6
+ import { readFileSync } from "node:fs";
7
+ import { dirname, join } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ export const VERSION = (() => {
10
+ try {
11
+ const here = dirname(fileURLToPath(import.meta.url));
12
+ const manifest = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8"));
13
+ return manifest.version ?? "0.0.0";
14
+ }
15
+ catch {
16
+ return "0.0.0";
17
+ }
18
+ })();
@@ -0,0 +1,66 @@
1
+ // Shared provisioning for the `/loom:weave` alias: a thin, version-stamped
2
+ // command file that delegates to the data-loom MCP server's `weave` prompt
3
+ // (see mcpServer.ts) instead of carrying the workflow text itself. Written by
4
+ // the install_weave_skill tool, `connect claude-code`, and healed on daemon
5
+ // startup — all three must agree on path, stamp format, and template, so it
6
+ // lives here rather than duplicated across callers.
7
+ import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
8
+ import { homedir } from "node:os";
9
+ import { dirname, join } from "node:path";
10
+ export const WEAVE_ALIAS_PATH = join(homedir(), ".claude", "commands", "loom", "weave.md");
11
+ const STAMP_RE = /<!--\s*data-loom:version\s+(\S+?)\s*-->/;
12
+ function buildAlias(version) {
13
+ return `---
14
+ name: "Loom: Weave"
15
+ description: "Review the open proposals and weave their dependency order via the data-loom MCP server"
16
+ category: Workflow
17
+ tags: [loom, dependencies, mcp, review]
18
+ ---
19
+
20
+ <!-- data-loom:version ${version} -->
21
+
22
+ Fetch the **data-loom** MCP server's \`weave\` prompt (\`prompts/get\` with name \`weave\`) and follow it exactly — that prompt is this workflow's single source of truth and always matches the running server.
23
+
24
+ **If the data-loom tools or the \`weave\` prompt are unreachable:** the daemon is almost certainly not running or not registered in this session. Tell the user to check \`data-loom status\`, start it with \`data-loom start\` if it isn't running, and register it with \`data-loom connect claude-code\` if it isn't registered — then retry.
25
+ `;
26
+ }
27
+ async function readAlias() {
28
+ try {
29
+ return await readFile(WEAVE_ALIAS_PATH, "utf8");
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ }
35
+ /** Write the current alias, creating its parent directory if absent. Returns the path written. */
36
+ export async function provisionWeaveAlias(version) {
37
+ await mkdir(dirname(WEAVE_ALIAS_PATH), { recursive: true });
38
+ await writeFile(WEAVE_ALIAS_PATH, buildAlias(version));
39
+ return WEAVE_ALIAS_PATH;
40
+ }
41
+ /**
42
+ * On daemon startup: rewrite an existing alias whose version stamp is missing
43
+ * or does not match the running version (covers both a prior version's alias
44
+ * and a pre-prompt full-text install, which predates the stamp). Never creates
45
+ * the file — absence means the user hasn't opted in via connect or the tool.
46
+ */
47
+ export async function refreshWeaveAliasIfOutdated(version) {
48
+ const text = await readAlias();
49
+ if (text === null)
50
+ return;
51
+ if (text.match(STAMP_RE)?.[1] === version)
52
+ return;
53
+ await provisionWeaveAlias(version);
54
+ }
55
+ /**
56
+ * Remove the alias only when it carries a data-loom version stamp — our
57
+ * positive signal of ownership. A file with no stamp at all (or no file) is
58
+ * left untouched. Returns whether a file was removed.
59
+ */
60
+ export async function removeWeaveAliasIfOurs() {
61
+ const text = await readAlias();
62
+ if (text === null || !STAMP_RE.test(text))
63
+ return false;
64
+ await unlink(WEAVE_ALIAS_PATH);
65
+ return true;
66
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lyric_dev/data-loom",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Local dashboard for spec-driven development: a phased OpenSpec roadmap (WHAT) and an MCP topology (HOW).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -34,7 +34,9 @@
34
34
  "build": "tsc",
35
35
  "start": "node dist/index.js",
36
36
  "dev": "tsc && node --watch dist/index.js",
37
- "prepublishOnly": "npm run build"
37
+ "prepublishOnly": "npm run build",
38
+ "sync-global": "node scripts/sync-global.mjs",
39
+ "postpublish": "npm run sync-global"
38
40
  },
39
41
  "publishConfig": {
40
42
  "access": "public"