@lyric_dev/data-loom 0.6.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,11 +12,14 @@ 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
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";
20
23
  import { initTray } from "./tray.js";
21
24
  const host = HOST;
22
25
  const port = PORT;
@@ -35,6 +38,14 @@ async function main() {
35
38
  "data_loom does not bundle it — install it separately, e.g. `npm i -g openspec`, then retry.");
36
39
  process.exit(1);
37
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
+ }
38
49
  // Resolve a viewable project: the launch dir, else the first discovered one,
39
50
  // else none. Never exit just because the launch dir isn't a project.
40
51
  let active = isViewableProject(initialProject) ? initialProject : null;
@@ -48,6 +59,9 @@ async function main() {
48
59
  else {
49
60
  console.log("[data-loom] no openspec project found at launch — open the dashboard and pick one");
50
61
  }
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);
51
65
  try {
52
66
  server = await startServer({
53
67
  publicDir: resolvePublicDir(),
@@ -59,6 +73,7 @@ async function main() {
59
73
  getProjects,
60
74
  selectProject,
61
75
  getCurrentProject: () => session?.project ?? null,
76
+ onShutdown: () => shutdownRef(),
62
77
  });
63
78
  }
64
79
  catch (err) {
@@ -77,15 +92,28 @@ async function main() {
77
92
  if (session)
78
93
  console.log(`[data-loom] project: ${session.project}`);
79
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
+ }
80
102
  // Tray icon: an ambient "DataLoom is running" indicator (essential in the
81
103
  // detached mode, which has no console). Guarded no-op where unavailable.
82
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.
83
110
  const shutdown = () => {
84
111
  tray.dispose();
85
112
  session?.stopWatch();
86
113
  server?.close();
87
114
  process.exit(0);
88
115
  };
116
+ shutdownRef = shutdown; // route the loopback /api/shutdown POST here too
89
117
  process.on("SIGINT", shutdown);
90
118
  process.on("SIGTERM", shutdown);
91
119
  tray = initTray({
@@ -197,16 +225,37 @@ function copyToClipboard(text) {
197
225
  // A leading reserved verb selects a lifecycle/autostart/integration command;
198
226
  // anything else falls through to the foreground daemon (with argv[2] as the
199
227
  // optional project path), preserving the original invocation unchanged.
200
- 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
+ ]);
201
240
  async function runAutostart(rest) {
202
241
  const sub = rest[0];
203
242
  if (sub === "enable") {
204
- await autostart.enable();
205
- console.log("[data-loom] autostart enabled (launches on login)");
206
- // Enabling also brings the daemon up now, so it's running this session too —
207
- // opt out with --no-start.
208
- 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")) {
209
257
  await lifecycle.start();
258
+ }
210
259
  // ...and points Claude Code at it, so the always-on path both hosts and
211
260
  // registers the MCP endpoint. Best-effort: a missing `claude` CLI must not
212
261
  // fail the enable (the login item + daemon already succeeded). Opt out with
@@ -237,10 +286,18 @@ async function runAutostart(rest) {
237
286
  async function runConnect(rest) {
238
287
  const target = rest[0];
239
288
  if (target === "claude-code") {
240
- const registered = await claudeCode.connect();
289
+ const onDemand = rest.includes("--on-demand");
290
+ const { registered, switchedFrom } = await claudeCode.connect({ onDemand });
241
291
  if (registered) {
242
- console.log("[data-loom] registered DataLoom in Claude Code (user scope, native HTTP)");
243
- console.log("[data-loom] start a new Claude Code session (or /mcp reconnect) to pick it up; DataLoom must be running to serve the tools.");
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.");
244
301
  }
245
302
  return;
246
303
  }
@@ -251,7 +308,7 @@ async function runConnect(rest) {
251
308
  console.log("[data-loom] restart Claude Desktop to pick it up; DataLoom must be running to serve the tools.");
252
309
  return;
253
310
  }
254
- throw new Error("usage: data-loom connect <claude-code|claude-desktop [--bridge]>");
311
+ throw new Error("usage: data-loom connect <claude-code [--on-demand]|claude-desktop [--bridge]>");
255
312
  }
256
313
  async function runDisconnect(rest) {
257
314
  const target = rest[0];
@@ -271,9 +328,93 @@ async function runDisconnect(rest) {
271
328
  }
272
329
  throw new Error("usage: data-loom disconnect <claude-code|claude-desktop>");
273
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
+ }
412
+ }
274
413
  async function runCli(argv) {
275
414
  const [verb, ...rest] = argv;
276
415
  switch (verb) {
416
+ case "up":
417
+ return runUp(rest);
277
418
  case "start":
278
419
  return lifecycle.start(rest[0]);
279
420
  case "stop":
@@ -282,12 +423,16 @@ async function runCli(argv) {
282
423
  return lifecycle.restart(rest[0]);
283
424
  case "status":
284
425
  return lifecycle.status();
426
+ case "update":
427
+ return lifecycle.update();
285
428
  case "autostart":
286
429
  return runAutostart(rest);
287
430
  case "connect":
288
431
  return runConnect(rest);
289
432
  case "disconnect":
290
433
  return runDisconnect(rest);
434
+ case "mcp-shim":
435
+ return runShim();
291
436
  default:
292
437
  throw new Error(`unknown command: ${verb}`);
293
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,16 +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 { readFileSync } from "node:fs";
11
- import { mkdir, readFile, writeFile } from "node:fs/promises";
12
- import { homedir } from "node:os";
13
- import { dirname, join, resolve } from "node:path";
14
- import { fileURLToPath } from "node:url";
10
+ import { readFile, writeFile } from "node:fs/promises";
11
+ import { join, resolve } from "node:path";
15
12
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
16
- import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
13
+ import { CallToolRequestSchema, GetPromptRequestSchema, ListPromptsRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
17
14
  import { OpenSpecClient } from "./openspecClient.js";
18
15
  import { deriveModel } from "./derive.js";
19
16
  import { discoverProjects, isViewableProject } from "./projects.js";
17
+ import { provisionWeaveAlias } from "./weaveAlias.js";
18
+ import { VERSION } from "./version.js";
20
19
  /**
21
20
  * A validation error whose message is safe to return to the client because it
22
21
  * only references caller-supplied input (a change name, a path the caller named).
@@ -25,19 +24,6 @@ import { discoverProjects, isViewableProject } from "./projects.js";
25
24
  */
26
25
  class ToolError extends Error {
27
26
  }
28
- // The advertised server version — read from the package manifest shipped next
29
- // to the compiled code, so it can never drift from the released version again
30
- // (it was hardcoded and stuck at 0.4.1 through the 0.5.0 release).
31
- const VERSION = (() => {
32
- try {
33
- const here = dirname(fileURLToPath(import.meta.url));
34
- const manifest = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8"));
35
- return manifest.version ?? "0.0.0";
36
- }
37
- catch {
38
- return "0.0.0";
39
- }
40
- })();
41
27
  // Advertised to the client on connect. The "confirm before writing" gate lives
42
28
  // here, in the agent's behavior — the server cannot verify a human approved.
43
29
  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.
@@ -51,19 +37,15 @@ When you connect, call list_open_proposals (passing the project you are working
51
37
 
52
38
  Never write a dependency the user has not confirmed. Proposals already marked "declared" need no action.
53
39
 
54
- 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`;
55
- // The `/loom:weave` slash command this server installs into the user's global
56
- // Claude commands dir. Static content that only orchestrates this server's tools.
57
- const WEAVE_COMMAND = `---
58
- name: "Loom: Weave"
59
- description: "Review the open proposals and weave their dependency order via the data-loom MCP server"
60
- category: Workflow
61
- tags: [loom, dependencies, mcp, review]
62
- ---
63
-
64
- Weave the dependency order of this project's open OpenSpec proposals, using the **data-loom** MCP server.
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.
65
47
 
66
- **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.
67
49
 
68
50
  Steps:
69
51
 
@@ -90,7 +72,19 @@ const PROJECT_ARG = {
90
72
  * the dashboard selection is the single fallback source of project truth.
91
73
  */
92
74
  export function createMcpServer(deps) {
93
- const server = new Server({ name: "data-loom", version: VERSION }, { 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
+ });
94
88
  // Resolve the target project for a call: explicit arg, then dashboard
95
89
  // selection, then an instructive error. Validated as a real workspace before
96
90
  // any read or write.
@@ -150,7 +144,7 @@ export function createMcpServer(deps) {
150
144
  },
151
145
  {
152
146
  name: "install_weave_skill",
153
- 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.",
154
148
  inputSchema: { type: "object", properties: {}, additionalProperties: false },
155
149
  },
156
150
  ],
@@ -261,15 +255,12 @@ async function markIndependent(client, changesDir, project, change) {
261
255
  return { project, change, written, dependencyReview: "declared" };
262
256
  }
263
257
  /**
264
- * Provision the `/loom:weave` command into the user's GLOBAL Claude commands
265
- * dir, so the review runs as one command from any project. Writes only the
266
- * 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.
267
261
  */
268
262
  async function installWeaveSkill() {
269
- const dir = join(homedir(), ".claude", "commands", "loom");
270
- const path = join(dir, "weave.md");
271
- await mkdir(dir, { recursive: true });
272
- await writeFile(path, WEAVE_COMMAND);
263
+ const path = await provisionWeaveAlias(VERSION);
273
264
  return {
274
265
  installed: true,
275
266
  command: "/loom:weave",