@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.
package/dist/index.js CHANGED
@@ -12,10 +12,15 @@ import { discover } from "./mcp/discovery.js";
12
12
  import { checkServer } from "./mcp/availability.js";
13
13
  import { discoverProjects, isViewableProject } from "./projects.js";
14
14
  import { resolvePublicDir } from "./assets.js";
15
- import { HOST, PORT } from "./paths.js";
15
+ import { HOST, PORT, baseUrl } from "./paths.js";
16
16
  import * as lifecycle from "./lifecycle.js";
17
17
  import * as autostart from "./autostart.js";
18
18
  import * as claudeDesktop from "./claudeDesktop.js";
19
+ import * as claudeCode from "./claudeCode.js";
20
+ import { runShim } from "./mcpShim.js";
21
+ import { refreshWeaveAliasIfOutdated } from "./weaveAlias.js";
22
+ import { VERSION } from "./version.js";
23
+ import { initTray } from "./tray.js";
19
24
  const host = HOST;
20
25
  const port = PORT;
21
26
  // Initial project: CLI argument, then DATA_LOOM_ROOT, then cwd.
@@ -33,6 +38,14 @@ async function main() {
33
38
  "data_loom does not bundle it — install it separately, e.g. `npm i -g openspec`, then retry.");
34
39
  process.exit(1);
35
40
  }
41
+ // Heal an existing `/loom:weave` alias left by an older version (or a
42
+ // pre-stamp install) — never creates the file when the user hasn't opted in.
43
+ try {
44
+ await refreshWeaveAliasIfOutdated(VERSION);
45
+ }
46
+ catch (err) {
47
+ console.warn(`[data-loom] could not refresh the /loom:weave command (continuing): ${err instanceof Error ? err.message : err}`);
48
+ }
36
49
  // Resolve a viewable project: the launch dir, else the first discovered one,
37
50
  // else none. Never exit just because the launch dir isn't a project.
38
51
  let active = isViewableProject(initialProject) ? initialProject : null;
@@ -46,28 +59,70 @@ async function main() {
46
59
  else {
47
60
  console.log("[data-loom] no openspec project found at launch — open the dashboard and pick one");
48
61
  }
49
- server = await startServer({
50
- publicDir: resolvePublicDir(),
51
- host,
52
- port,
53
- getRoadmap: () => session?.model ?? null,
54
- getMcp,
55
- checkMcp,
56
- getProjects,
57
- selectProject,
58
- getCurrentProject: () => session?.project ?? null,
59
- });
60
- console.log(`[data-loom] dashboard ready at http://${host}:${server.port}`);
62
+ // Forward reference so the loopback /api/shutdown route can invoke the same
63
+ // graceful shutdown() defined below (it's declared after startServer).
64
+ let shutdownRef = () => process.exit(0);
65
+ try {
66
+ server = await startServer({
67
+ publicDir: resolvePublicDir(),
68
+ host,
69
+ port,
70
+ getRoadmap: () => session?.model ?? null,
71
+ getMcp,
72
+ checkMcp,
73
+ getProjects,
74
+ selectProject,
75
+ getCurrentProject: () => session?.project ?? null,
76
+ onShutdown: () => shutdownRef(),
77
+ });
78
+ }
79
+ catch (err) {
80
+ if (err.code === "EADDRINUSE") {
81
+ // A DataLoom daemon is already on this port (e.g. the background one).
82
+ // That's fine — point at the running instance instead of crashing.
83
+ console.log(`[data-loom] already running at http://${host}:${port} — using that instance.`);
84
+ openBrowser(`http://${host}:${port}`);
85
+ session?.stopWatch(); // release the watcher started for this aborted launch
86
+ return;
87
+ }
88
+ throw err;
89
+ }
90
+ const url = `http://${host}:${server.port}`;
91
+ console.log(`[data-loom] dashboard ready at ${url}`);
61
92
  if (session)
62
93
  console.log(`[data-loom] project: ${session.project}`);
63
- openBrowser(`http://${host}:${server.port}`);
94
+ openBrowser(url);
95
+ // When launched supervised (an OS login supervisor execs this directly, in
96
+ // the foreground, with DATA_LOOM_DETACHED=1), there is no parent `start`
97
+ // spawn to record our PID — write it ourselves so `status`/`stop` work the
98
+ // same as when `start` launched us detached.
99
+ if (process.env.DATA_LOOM_DETACHED) {
100
+ await lifecycle.writeSelfPid().catch(() => { });
101
+ }
102
+ // Tray icon: an ambient "DataLoom is running" indicator (essential in the
103
+ // detached mode, which has no console). Guarded no-op where unavailable.
104
+ let tray = { dispose: () => { } };
105
+ // Every deliberate shutdown path below (SIGINT, SIGTERM, tray Stop) funnels
106
+ // through here and exits 0. OS supervisors (Scheduled Task restart-on-
107
+ // failure, launchd KeepAlive, systemd Restart=on-failure) treat a clean
108
+ // exit 0 as "stopped on purpose" and do NOT restart it — only a crash or
109
+ // non-zero exit does. Do not change a deliberate stop to any other exit code.
64
110
  const shutdown = () => {
111
+ tray.dispose();
65
112
  session?.stopWatch();
66
113
  server?.close();
67
114
  process.exit(0);
68
115
  };
116
+ shutdownRef = shutdown; // route the loopback /api/shutdown POST here too
69
117
  process.on("SIGINT", shutdown);
70
118
  process.on("SIGTERM", shutdown);
119
+ tray = initTray({
120
+ url,
121
+ onOpen: () => launchBrowser(url),
122
+ onCopy: () => copyToClipboard(url),
123
+ onStop: shutdown,
124
+ log: (msg) => console.error(msg),
125
+ });
71
126
  }
72
127
  async function buildSession(project) {
73
128
  const client = new OpenSpecClient(project);
@@ -132,6 +187,10 @@ function openBrowser(url) {
132
187
  // terminal, so never pop a browser for them (nor when explicitly suppressed).
133
188
  if (process.env.DATA_LOOM_NO_OPEN || process.env.DATA_LOOM_DETACHED)
134
189
  return;
190
+ launchBrowser(url);
191
+ }
192
+ /** Open a URL in the default browser unconditionally (used by the tray). */
193
+ function launchBrowser(url) {
135
194
  try {
136
195
  if (process.platform === "win32") {
137
196
  spawn("cmd", ["/c", "start", "", url], { stdio: "ignore", detached: true }).unref();
@@ -147,20 +206,68 @@ function openBrowser(url) {
147
206
  /* non-fatal — the URL is already logged */
148
207
  }
149
208
  }
209
+ /** Best-effort copy of text to the system clipboard; failure is silent. */
210
+ function copyToClipboard(text) {
211
+ try {
212
+ const proc = process.platform === "win32"
213
+ ? spawn("clip")
214
+ : process.platform === "darwin"
215
+ ? spawn("pbcopy")
216
+ : spawn("xclip", ["-selection", "clipboard"]);
217
+ proc.on("error", () => { });
218
+ proc.stdin?.end(text);
219
+ }
220
+ catch {
221
+ /* no clipboard tool available — non-fatal */
222
+ }
223
+ }
150
224
  // ---- CLI verb dispatch -----------------------------------------------------
151
225
  // A leading reserved verb selects a lifecycle/autostart/integration command;
152
226
  // anything else falls through to the foreground daemon (with argv[2] as the
153
227
  // optional project path), preserving the original invocation unchanged.
154
- const VERBS = new Set(["start", "stop", "restart", "status", "autostart", "connect", "disconnect"]);
228
+ const VERBS = new Set([
229
+ "up",
230
+ "start",
231
+ "stop",
232
+ "restart",
233
+ "status",
234
+ "autostart",
235
+ "connect",
236
+ "disconnect",
237
+ "update",
238
+ "mcp-shim",
239
+ ]);
155
240
  async function runAutostart(rest) {
156
241
  const sub = rest[0];
157
242
  if (sub === "enable") {
158
- await autostart.enable();
159
- console.log("[data-loom] autostart enabled (launches on login)");
160
- // Enabling also brings the daemon up now, so it's running this session too —
161
- // opt out with --no-start.
162
- if (!rest.includes("--no-start"))
243
+ const info = await autostart.enable();
244
+ console.log(`[data-loom] autostart enabled (${info.mechanism})` +
245
+ (info.supervised
246
+ ? " the daemon restarts automatically if it crashes"
247
+ : " — supervision is not available on this host; the daemon will not auto-restart on crash"));
248
+ // Enabling also brings the daemon up now, so it's running this session too.
249
+ // A supervised start-on-register mechanism (systemd `enable --now`, launchd
250
+ // `RunAtLoad`) already launched it — starting again would spawn a second
251
+ // detached instance that races the supervised one for the port, so only
252
+ // start it ourselves when registration didn't. Opt out with --no-start.
253
+ if (info.startedNow) {
254
+ console.log(`[data-loom] the daemon is running now — ${baseUrl()}`);
255
+ }
256
+ else if (!rest.includes("--no-start")) {
163
257
  await lifecycle.start();
258
+ }
259
+ // ...and points Claude Code at it, so the always-on path both hosts and
260
+ // registers the MCP endpoint. Best-effort: a missing `claude` CLI must not
261
+ // fail the enable (the login item + daemon already succeeded). Opt out with
262
+ // --no-connect.
263
+ if (!rest.includes("--no-connect")) {
264
+ try {
265
+ await claudeCode.connect();
266
+ }
267
+ catch (err) {
268
+ console.warn(`[data-loom] could not register with Claude Code (continuing): ${err instanceof Error ? err.message : err}`);
269
+ }
270
+ }
164
271
  return;
165
272
  }
166
273
  if (sub === "disable") {
@@ -174,27 +281,140 @@ async function runAutostart(rest) {
174
281
  : "[data-loom] autostart is not enabled");
175
282
  return;
176
283
  }
177
- throw new Error("usage: data-loom autostart <enable|disable|status> [--no-start]");
284
+ throw new Error("usage: data-loom autostart <enable|disable|status> [--no-start] [--no-connect]");
178
285
  }
179
286
  async function runConnect(rest) {
180
- if (rest[0] !== "claude-desktop")
181
- throw new Error("usage: data-loom connect claude-desktop [--bridge]");
182
- const bridge = rest.includes("--bridge");
183
- const path = await claudeDesktop.connect({ bridge });
184
- console.log(`[data-loom] registered DataLoom in Claude Desktop (${bridge ? "stdio bridge" : "native HTTP"}) — ${path}`);
185
- console.log("[data-loom] restart Claude Desktop to pick it up; DataLoom must be running to serve the tools.");
287
+ const target = rest[0];
288
+ if (target === "claude-code") {
289
+ const onDemand = rest.includes("--on-demand");
290
+ const { registered, switchedFrom } = await claudeCode.connect({ onDemand });
291
+ if (registered) {
292
+ if (switchedFrom) {
293
+ console.log(`[data-loom] switched Claude Code registration from ${switchedFrom === "stdio" ? "on-demand stdio" : "native HTTP"} to ${onDemand ? "on-demand stdio" : "native HTTP"}`);
294
+ }
295
+ console.log(onDemand
296
+ ? "[data-loom] registered DataLoom in Claude Code (user scope, on-demand stdio shim)"
297
+ : "[data-loom] registered DataLoom in Claude Code (user scope, native HTTP)");
298
+ console.log(onDemand
299
+ ? "[data-loom] start a new Claude Code session (or /mcp reconnect) to pick it up; the shim starts DataLoom automatically when needed."
300
+ : "[data-loom] start a new Claude Code session (or /mcp reconnect) to pick it up; DataLoom must be running to serve the tools.");
301
+ }
302
+ return;
303
+ }
304
+ if (target === "claude-desktop") {
305
+ const bridge = rest.includes("--bridge");
306
+ const path = await claudeDesktop.connect({ bridge });
307
+ console.log(`[data-loom] registered DataLoom in Claude Desktop (${bridge ? "stdio bridge" : "native HTTP"}) — ${path}`);
308
+ console.log("[data-loom] restart Claude Desktop to pick it up; DataLoom must be running to serve the tools.");
309
+ return;
310
+ }
311
+ throw new Error("usage: data-loom connect <claude-code [--on-demand]|claude-desktop [--bridge]>");
186
312
  }
187
313
  async function runDisconnect(rest) {
188
- if (rest[0] !== "claude-desktop")
189
- throw new Error("usage: data-loom disconnect claude-desktop");
190
- const { path, removed } = await claudeDesktop.disconnect();
191
- console.log(removed
192
- ? `[data-loom] removed DataLoom from Claude Desktop — ${path}`
193
- : "[data-loom] DataLoom was not registered in Claude Desktop — nothing to remove");
314
+ const target = rest[0];
315
+ if (target === "claude-code") {
316
+ const { removed } = await claudeCode.disconnect();
317
+ console.log(removed
318
+ ? "[data-loom] removed DataLoom from Claude Code"
319
+ : "[data-loom] DataLoom was not registered in Claude Code — nothing to remove");
320
+ return;
321
+ }
322
+ if (target === "claude-desktop") {
323
+ const { path, removed } = await claudeDesktop.disconnect();
324
+ console.log(removed
325
+ ? `[data-loom] removed DataLoom from Claude Desktop — ${path}`
326
+ : "[data-loom] DataLoom was not registered in Claude Desktop — nothing to remove");
327
+ return;
328
+ }
329
+ throw new Error("usage: data-loom disconnect <claude-code|claude-desktop>");
330
+ }
331
+ /** True iff DataLoom is running from an npx / `npm exec` cache rather than a global install. */
332
+ function isNpxInvocation() {
333
+ return /[\\/]_npx[\\/]/.test(process.argv[1] ?? "");
334
+ }
335
+ /**
336
+ * `data-loom up [project]` — the one-command setup. Verifies the openspec
337
+ * prerequisite up front (fail-fast, so a missing prerequisite leaves the
338
+ * system untouched), then runs the same sequence as `autostart enable`
339
+ * (register autostart, start the daemon this session, register Claude Code)
340
+ * and prints a state summary. Idempotent: a re-run on an already-configured
341
+ * host reports each aspect as already in place instead of duplicating it.
342
+ * Honors the `--no-start` and `--no-connect` opt-outs.
343
+ */
344
+ async function runUp(rest) {
345
+ const project = rest.find((a) => !a.startsWith("--"));
346
+ const noStart = rest.includes("--no-start");
347
+ const noConnect = rest.includes("--no-connect");
348
+ // Prerequisite FIRST, before any setup step — a missing openspec must leave
349
+ // the system unchanged (no registration, no start, no connect).
350
+ try {
351
+ const version = await new OpenSpecClient(resolve(project ?? process.cwd())).checkAvailable();
352
+ console.log(`[data-loom] openspec CLI ${version}`);
353
+ }
354
+ catch {
355
+ console.error("[data-loom] ERROR: the `openspec` CLI was not found — nothing was changed.\n" +
356
+ "data_loom does not bundle it; install it separately, then re-run `data-loom up`:\n" +
357
+ " npm install -g openspec");
358
+ process.exit(1);
359
+ }
360
+ // Snapshot prior state so the summary can report what was already in place.
361
+ const wasRunning = await lifecycle.isRunning();
362
+ const priorEnabled = (await autostart.getRegistrationInfo()).enabled;
363
+ // 1. Register autostart (idempotent — overwrites any prior registration).
364
+ const info = await autostart.enable();
365
+ console.log(`[data-loom] autostart ${priorEnabled ? "already enabled — refreshed" : "enabled"} (${info.mechanism})` +
366
+ (info.supervised ? "" : " — no crash supervision on this host"));
367
+ // 2. Bring the daemon up this session — unless the supervisor already did
368
+ // (systemd/launchd), it's already running, or the user opted out.
369
+ let daemonState;
370
+ if (info.startedNow) {
371
+ daemonState = "running (launched by the supervisor)";
372
+ }
373
+ else if (wasRunning) {
374
+ daemonState = "already running";
375
+ }
376
+ else if (noStart) {
377
+ daemonState = "not started (--no-start)";
378
+ }
379
+ else {
380
+ await lifecycle.start(project);
381
+ daemonState = "started";
382
+ }
383
+ // 3. Point Claude Code at the daemon (best-effort; --no-connect skips it).
384
+ let connectState;
385
+ if (noConnect) {
386
+ connectState = "skipped (--no-connect)";
387
+ }
388
+ else {
389
+ try {
390
+ const { registered } = await claudeCode.connect();
391
+ connectState = registered
392
+ ? "registered (user scope, native HTTP)"
393
+ : "not registered — `claude` CLI not found (run the printed command by hand)";
394
+ }
395
+ catch (err) {
396
+ connectState = `not registered — ${err instanceof Error ? err.message : err}`;
397
+ }
398
+ }
399
+ // Summary — doubles as a setup health check when `up` is re-run.
400
+ const running = await lifecycle.isRunning();
401
+ console.log("");
402
+ console.log("[data-loom] setup summary");
403
+ console.log(` dashboard: ${baseUrl()} — ${running ? "running" : "not running"} (${daemonState})`);
404
+ console.log(` autostart: ${info.mechanism}${info.supervised ? " (supervised)" : " (unsupervised)"}`);
405
+ console.log(` claude code: ${connectState}`);
406
+ console.log(" weave: run /loom:weave in a Claude Code session to order proposal dependencies");
407
+ if (isNpxInvocation()) {
408
+ console.log("");
409
+ console.log("[data-loom] you launched via npx — for a durable always-on setup, install globally instead:");
410
+ console.log(" npm install -g @lyric_dev/data-loom");
411
+ }
194
412
  }
195
413
  async function runCli(argv) {
196
414
  const [verb, ...rest] = argv;
197
415
  switch (verb) {
416
+ case "up":
417
+ return runUp(rest);
198
418
  case "start":
199
419
  return lifecycle.start(rest[0]);
200
420
  case "stop":
@@ -203,12 +423,16 @@ async function runCli(argv) {
203
423
  return lifecycle.restart(rest[0]);
204
424
  case "status":
205
425
  return lifecycle.status();
426
+ case "update":
427
+ return lifecycle.update();
206
428
  case "autostart":
207
429
  return runAutostart(rest);
208
430
  case "connect":
209
431
  return runConnect(rest);
210
432
  case "disconnect":
211
433
  return runDisconnect(rest);
434
+ case "mcp-shim":
435
+ return runShim();
212
436
  default:
213
437
  throw new Error(`unknown command: ${verb}`);
214
438
  }
package/dist/lifecycle.js CHANGED
@@ -5,8 +5,9 @@
5
5
  import { spawn } from "node:child_process";
6
6
  import { openSync } from "node:fs";
7
7
  import { readFile, writeFile, rm } from "node:fs/promises";
8
- import { get } from "node:http";
8
+ import { get, request } from "node:http";
9
9
  import { HOST, PORT, baseUrl, logFile, pidFile, ensureStateDir } from "./paths.js";
10
+ import * as autostart from "./autostart.js";
10
11
  /** Probe the loopback endpoint; true iff a daemon answers within the timeout. */
11
12
  export function isRunning() {
12
13
  return new Promise((resolve) => {
@@ -30,7 +31,39 @@ async function readPid() {
30
31
  return undefined;
31
32
  }
32
33
  }
34
+ /**
35
+ * Write this process's own PID file. Used when running supervised (an OS
36
+ * supervisor execs the daemon directly, so there is no parent `start` spawn
37
+ * to record the child's PID) — keeps `status`/`stop` working the same
38
+ * whether the daemon was launched via `start` or by a login supervisor.
39
+ */
40
+ export async function writeSelfPid() {
41
+ await ensureStateDir();
42
+ await writeFile(pidFile(), String(process.pid), "utf8");
43
+ }
33
44
  const delay = (ms) => new Promise((r) => setTimeout(r, ms));
45
+ /**
46
+ * Ask the running daemon to shut itself down through its own graceful exit-0
47
+ * path, via the loopback POST /api/shutdown. This is how a clean stop reaches
48
+ * exit 0 on Windows, where `process.kill(pid, "SIGTERM")` force-terminates
49
+ * (exit 1) without running the shutdown handler — which a supervising
50
+ * Scheduled Task would misread as a crash and restart. Resolves true iff the
51
+ * daemon accepted the request; false lets the caller fall back to a signal.
52
+ */
53
+ function requestGracefulShutdown() {
54
+ return new Promise((resolve) => {
55
+ const req = request({ host: HOST, port: PORT, path: "/api/shutdown", method: "POST", timeout: 2000 }, (res) => {
56
+ res.resume(); // drain
57
+ resolve(res.statusCode === 200);
58
+ });
59
+ req.on("error", () => resolve(false));
60
+ req.on("timeout", () => {
61
+ req.destroy();
62
+ resolve(false);
63
+ });
64
+ req.end();
65
+ });
66
+ }
34
67
  /**
35
68
  * Start the daemon detached from this terminal. If one already answers on the
36
69
  * port, this is a reported no-op (single-instance guard). The child re-runs this
@@ -65,7 +98,21 @@ export async function stop() {
65
98
  console.log("[data-loom] not running — nothing to stop");
66
99
  return;
67
100
  }
68
- if (pid !== undefined) {
101
+ // On Linux, prefer stopping through systemd when the supervised unit is
102
+ // active — keeps the unit's own state consistent with an intentional stop
103
+ // rather than an out-of-band signal it has to interpret after the fact.
104
+ if (await autostart.isSystemdUnitActive()) {
105
+ await autostart.stopSystemdUnit();
106
+ }
107
+ else if (running && (await requestGracefulShutdown())) {
108
+ // The daemon exits 0 through its own shutdown path — the only reliable way
109
+ // to a clean exit on Windows (see requestGracefulShutdown). Nothing more to
110
+ // signal; fall through to the port-free wait below.
111
+ }
112
+ else if (pid !== undefined) {
113
+ // Fallback: no graceful endpoint answered (older daemon, or it's hung).
114
+ // On POSIX this reaches the SIGTERM handler (exit 0); on Windows it force-
115
+ // terminates (exit 1), which is still correct for an already-wedged daemon.
69
116
  try {
70
117
  process.kill(pid, "SIGTERM");
71
118
  }
@@ -98,4 +145,71 @@ export async function status() {
98
145
  else {
99
146
  console.log("[data-loom] not running");
100
147
  }
148
+ const reg = await autostart.getRegistrationInfo();
149
+ if (!reg.enabled) {
150
+ console.log("[data-loom] autostart: not registered");
151
+ }
152
+ else {
153
+ console.log(`[data-loom] autostart: ${reg.mechanism}${reg.supervised ? " (restarts automatically on crash)" : " (no restart supervision)"}`);
154
+ }
155
+ }
156
+ async function isGloballyInstalled() {
157
+ return new Promise((resolve) => {
158
+ const p = spawn("npm", ["ls", "-g", "@lyric_dev/data-loom", "--depth=0"], {
159
+ shell: process.platform === "win32",
160
+ stdio: "ignore",
161
+ });
162
+ p.on("error", () => resolve(false));
163
+ p.on("exit", (code) => resolve(code === 0));
164
+ });
165
+ }
166
+ function npmInstallLatest() {
167
+ return new Promise((resolve, reject) => {
168
+ const p = spawn("npm", ["install", "-g", "@lyric_dev/data-loom@latest"], {
169
+ shell: process.platform === "win32",
170
+ stdio: "inherit",
171
+ });
172
+ p.on("error", reject);
173
+ p.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`npm install exited with code ${code}`))));
174
+ });
175
+ }
176
+ /**
177
+ * Upgrade the globally-installed package, restart a running daemon, and
178
+ * refresh the autostart registration (launcher + task/plist/unit) when one
179
+ * exists — one command to land on the new version with a healthy always-on
180
+ * setup. Aborts before any restart/registration step if the upgrade fails,
181
+ * and never guesses at an upgrade mechanism for a non-global (e.g. npx) run.
182
+ */
183
+ export async function update() {
184
+ if (!(await isGloballyInstalled())) {
185
+ console.log("[data-loom] not installed globally, so it can't self-update.");
186
+ console.log("[data-loom] run: npm install -g @lyric_dev/data-loom@latest");
187
+ console.log("[data-loom] (or re-run npx @lyric_dev/data-loom@latest to pick up the new version)");
188
+ return;
189
+ }
190
+ console.log("[data-loom] upgrading @lyric_dev/data-loom...");
191
+ try {
192
+ await npmInstallLatest();
193
+ }
194
+ catch (err) {
195
+ console.error(`[data-loom] upgrade failed: ${err instanceof Error ? err.message : err}`);
196
+ console.error("[data-loom] no restart or re-registration performed.");
197
+ return;
198
+ }
199
+ console.log("[data-loom] upgrade complete.");
200
+ if (await isRunning()) {
201
+ console.log("[data-loom] restarting the daemon on the new version...");
202
+ await restart();
203
+ }
204
+ else {
205
+ console.log("[data-loom] no daemon running — nothing to restart.");
206
+ }
207
+ if (await autostart.isEnabled()) {
208
+ console.log("[data-loom] refreshing the autostart registration...");
209
+ const info = await autostart.enable();
210
+ console.log(`[data-loom] autostart re-registered (${info.mechanism}${info.supervised ? ", supervised" : ", no restart supervision"})`);
211
+ }
212
+ else {
213
+ console.log("[data-loom] autostart not enabled — nothing to re-register.");
214
+ }
101
215
  }
package/dist/mcpServer.js CHANGED
@@ -7,14 +7,15 @@
7
7
  // server serves every project: the target project is resolved per call from an
8
8
  // explicit `project` argument, falling back to the daemon's current dashboard
9
9
  // selection. The server holds no single project frozen for its lifetime.
10
- import { mkdir, readFile, writeFile } from "node:fs/promises";
11
- import { homedir } from "node:os";
10
+ import { readFile, writeFile } from "node:fs/promises";
12
11
  import { join, resolve } from "node:path";
13
12
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
14
- import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
13
+ import { CallToolRequestSchema, GetPromptRequestSchema, ListPromptsRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
15
14
  import { OpenSpecClient } from "./openspecClient.js";
16
15
  import { deriveModel } from "./derive.js";
17
16
  import { discoverProjects, isViewableProject } from "./projects.js";
17
+ import { provisionWeaveAlias } from "./weaveAlias.js";
18
+ import { VERSION } from "./version.js";
18
19
  /**
19
20
  * A validation error whose message is safe to return to the client because it
20
21
  * only references caller-supplied input (a change name, a path the caller named).
@@ -36,19 +37,15 @@ When you connect, call list_open_proposals (passing the project you are working
36
37
 
37
38
  Never write a dependency the user has not confirmed. Proposals already marked "declared" need no action.
38
39
 
39
- 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. Register this server once, globally, with: claude mcp add --transport http --scope user data-loom http://127.0.0.1:4317/mcp`;
40
- // The `/loom:weave` slash command this server installs into the user's global
41
- // Claude commands dir. Static content that only orchestrates this server's tools.
42
- const WEAVE_COMMAND = `---
43
- name: "Loom: Weave"
44
- description: "Review the open proposals and weave their dependency order via the data-loom MCP server"
45
- category: Workflow
46
- tags: [loom, dependencies, mcp, review]
47
- ---
40
+ Tip: register this server with \`data-loom connect claude-code\` it provisions both the MCP registration and the \`/loom:weave\` command in one step, so one reload picks up both. If you registered another way, call install_weave_skill once to add \`/loom:weave\` yourself.`;
41
+ const WEAVE_PROMPT_NAME = "weave";
42
+ const WEAVE_PROMPT_DESCRIPTION = "Review the open proposals and weave their dependency order via the data-loom MCP server";
43
+ // The full review workflow, served as an MCP prompt (the single source of
44
+ // truth — see design.md) and fetched by the thin `/loom:weave` alias installed
45
+ // on disk. Only orchestrates this server's own tools.
46
+ const WEAVE_PROMPT = `Weave the dependency order of this project's open OpenSpec proposals, using the **data-loom** MCP server.
48
47
 
49
- Weave the dependency order of this project's open OpenSpec proposals, using the **data-loom** MCP server.
50
-
51
- **Prerequisite:** the DataLoom dashboard daemon must be running and the data-loom MCP server registered in this session — its tools are \`list_open_proposals\`, \`set_dependency\`, \`mark_independent\`, and \`list_projects\`. If those tools are not available, the daemon is almost certainly not running: tell the user to **start DataLoom** (\`npx @lyric_dev/data-loom "<project path>"\`) and, if they have not already, register it once with \`claude mcp add --transport http --scope user data-loom http://127.0.0.1:4317/mcp\`, then stop.
48
+ **Prerequisite:** the DataLoom dashboard daemon must be running and the data-loom MCP server registered in this session its tools are \`list_open_proposals\`, \`set_dependency\`, \`mark_independent\`, and \`list_projects\`. If those tools are not available, the daemon is almost certainly not running or not registered: tell the user to check \`data-loom status\`, start it with \`data-loom start\` if needed, and register it with \`data-loom connect claude-code\` if needed, then stop.
52
49
 
53
50
  Steps:
54
51
 
@@ -75,7 +72,19 @@ const PROJECT_ARG = {
75
72
  * the dashboard selection is the single fallback source of project truth.
76
73
  */
77
74
  export function createMcpServer(deps) {
78
- const server = new Server({ name: "data-loom", version: "0.4.1" }, { capabilities: { tools: {} }, instructions: INSTRUCTIONS });
75
+ const server = new Server({ name: "data-loom", version: VERSION }, { capabilities: { tools: {}, prompts: {} }, instructions: INSTRUCTIONS });
76
+ server.setRequestHandler(ListPromptsRequestSchema, async () => ({
77
+ prompts: [{ name: WEAVE_PROMPT_NAME, description: WEAVE_PROMPT_DESCRIPTION }],
78
+ }));
79
+ server.setRequestHandler(GetPromptRequestSchema, async (req) => {
80
+ if (req.params.name !== WEAVE_PROMPT_NAME) {
81
+ throw new ToolError(`unknown prompt: ${req.params.name}`);
82
+ }
83
+ return {
84
+ description: WEAVE_PROMPT_DESCRIPTION,
85
+ messages: [{ role: "user", content: { type: "text", text: WEAVE_PROMPT } }],
86
+ };
87
+ });
79
88
  // Resolve the target project for a call: explicit arg, then dashboard
80
89
  // selection, then an instructive error. Validated as a real workspace before
81
90
  // any read or write.
@@ -135,7 +144,7 @@ export function createMcpServer(deps) {
135
144
  },
136
145
  {
137
146
  name: "install_weave_skill",
138
- 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. Writes a static command file only (no project data or secrets) and overwrites any existing copy. Run once, then the user reloads Claude Code.",
147
+ description: "Install the `/loom:weave` slash command into the user's global Claude commands directory (~/.claude/commands/loom/weave.md) — a thin, version-stamped alias that fetches and follows this server's `weave` prompt, so this dependency review can be run as a single command from any project. `data-loom connect claude-code` already provisions this automatically; use this tool as a fallback when the command registered another way. Writes a static alias file only (no project data or secrets) and overwrites any existing copy. Run once, then the user reloads Claude Code.",
139
148
  inputSchema: { type: "object", properties: {}, additionalProperties: false },
140
149
  },
141
150
  ],
@@ -246,15 +255,12 @@ async function markIndependent(client, changesDir, project, change) {
246
255
  return { project, change, written, dependencyReview: "declared" };
247
256
  }
248
257
  /**
249
- * Provision the `/loom:weave` command into the user's GLOBAL Claude commands
250
- * dir, so the review runs as one command from any project. Writes only the
251
- * static command file; overwrites in place.
258
+ * Provision the `/loom:weave` alias into the user's GLOBAL Claude commands
259
+ * dir, so the review runs as one command from any project. The alias delegates
260
+ * to this server's `weave` prompt; see weaveAlias.ts.
252
261
  */
253
262
  async function installWeaveSkill() {
254
- const dir = join(homedir(), ".claude", "commands", "loom");
255
- const path = join(dir, "weave.md");
256
- await mkdir(dir, { recursive: true });
257
- await writeFile(path, WEAVE_COMMAND);
263
+ const path = await provisionWeaveAlias(VERSION);
258
264
  return {
259
265
  installed: true,
260
266
  command: "/loom:weave",