heedvane 0.2.0 → 0.3.3

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/README.md CHANGED
@@ -7,8 +7,8 @@ checks and team memory for Claude Code, Codex, Cursor, Gemini CLI, Hermes, OpenC
7
7
  npx heedvane
8
8
  ```
9
9
 
10
- The installer shows the agents it found on your machine. Tick the ones you want, choose what Heedvane
11
- adds and does, confirm. It installs, starts Heedvane in the background and signs you in through the
10
+ The installer shows the agents it found on your machine. Tick the ones you want, choose how much leaves
11
+ your machine, choose what Heedvane adds and does, confirm. It installs, starts Heedvane in the background and signs you in through the
12
12
  browser. It needs Node.js 22 or newer.
13
13
 
14
14
  Your dashboard's Agents page has the same command with your server and organization filled in:
@@ -17,12 +17,27 @@ Your dashboard's Agents page has the same command with your server and organizat
17
17
  npx heedvane connect --server https://app.heedvane.com --org <organization id>
18
18
  ```
19
19
 
20
+ ## How much leaves your machine
21
+
22
+ | Choice | What leaves your machine |
23
+ | --- | --- |
24
+ | **Look but don't keep** (default) | Heedvane's guardrail checks read the prompt or tool call, then keep only the verdict, never the text. |
25
+ | **Metrics only** | Only counts and names. Your organization's fixed rules still run on your machine; Heedvane's own checks and team memory are off. |
26
+
27
+ Scripts choose with `--mode look-but-dont-keep` or `--mode metrics-only`. The choice is saved in
28
+ `heed.yaml` as `heedvane.dataMode`.
29
+
20
30
  ## What it changes
21
31
 
22
32
  - Writes `~/.heed/heed.yaml`, your control file, with your answers. Run `npx heedvane` again to change
23
33
  them.
24
- - Adds Heedvane's hooks, MCP server entry and skill to each agent you ticked. A config file it edits is
25
- backed up first.
34
+ - Adds Heedvane's hooks and skill to each agent you ticked. A config file it edits is backed up first.
35
+ - With `--org` (the dashboard's command has it), also adds Heedvane's MCP connection, named `heedvane`, for
36
+ your organization (findings, reports and agent feedback) in every agent you ticked. Claude Desktop gets it
37
+ through the `mcp-remote` bridge, run with the Node.js that installed it (run `npx heedvane connect` again after
38
+ removing that Node version). The agent signs in
39
+ through the browser the first time it uses it. It replaces the `heedvane` and
40
+ `heedvane-findings` entries an earlier version added.
26
41
  - Starts Heedvane's local listener, which answers the hooks. After a restart the hooks start it again by
27
42
  themselves, at most once a minute. Set `HEED_LISTENER_AUTOSTART=0` to turn that off.
28
43
 
@@ -49,7 +64,7 @@ own lines go to stderr and `~/.heed/listener.log`.
49
64
 
50
65
  - Claude Code runs the hooks in print mode (`claude -p`), but not with `--bare`.
51
66
  - Codex runs hooks only after they are trusted.
52
- - Pi and Flue agents cannot be connected yet.
67
+ - The installer does not set up Pi or Flue. Connect them with a hook of your own that sends each action to `POST /api/agent/steering` with the organization key; Heedvane answers whether to refuse it.
53
68
 
54
69
  ## Everyday commands
55
70
 
package/dist/cli.d.ts CHANGED
@@ -25,5 +25,6 @@ declare function main(argv: string[], opts?: MainOptions): Promise<number>;
25
25
  * The command that opens `url` in the default browser. Windows goes through rundll32, not `cmd /c start`:
26
26
  * cmd would cut the sign-in URL at its first `&`.
27
27
  */
28
- export declare function browserOpener(platform: NodeJS.Platform, url: string): [string, string[]];
28
+ /** The command that opens `url`: `$BROWSER` when set (a no-op such as `true` keeps captures and tests off a real browser), else the platform's opener. */
29
+ export declare function browserOpener(platform: NodeJS.Platform, url: string, browser?: string): [string, string[]];
29
30
  export { main };
package/dist/cli.js CHANGED
@@ -7,20 +7,21 @@
7
7
  import { createWriteStream, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
8
8
  import { spawn } from "node:child_process";
9
9
  import { constants as osConstants, homedir, platform } from "node:os";
10
- import { dirname, join } from "node:path";
10
+ import { join } from "node:path";
11
11
  import { supportedClients } from "./clients/definitions.js";
12
12
  import { installHermesPlugin } from "./clients/hermes-plugin.js";
13
13
  import { applyEntry } from "./apply.js";
14
- import { DEFAULT_HEED_YAML_TEMPLATE, heedvaneConfigAt, loadHeedFile, loadHeedFileAt, withInstallChoices, withServerUrl } from "./heed-file.js";
15
- import { planActions, desiredEntriesFor, isClientDetected } from "./plan.js";
14
+ import { DATA_MODES, DEFAULT_HEED_YAML_TEMPLATE, heedvaneConfigAt, loadHeedFile, loadHeedFileAt, withInstallChoices, withSettings } from "./heed-file.js";
15
+ import { planActions, desiredEntriesFor, heedvaneConnector, installingNodeDir, isClientDetected } from "./plan.js";
16
16
  import { appliedFailed, capabilitySummary, renderApplied, renderDetection, renderPlan, wouldInstallSummary } from "./render.js";
17
17
  import { runDoctor } from "./doctor.js";
18
18
  import { installSkill } from "./skill/bundle.js";
19
19
  import { canonicalSkillFiles, skillDigest } from "./skill/content.js";
20
20
  import { emptyState, readState, writeStateAtomic } from "./state.js";
21
21
  import { writeDispatcherScript } from "./dispatchers/posix.js";
22
- import { removeDeferredHookGroups, removeStaleHookGroups, uninstall } from "./uninstall.js";
23
- import { statusText, runMcpStdio } from "./mcp-server.js";
22
+ import { removeDeferredHookGroups, removeLegacyMcpEntries, removeStaleHookGroups, uninstall } from "./uninstall.js";
23
+ import { statusText } from "./status.js";
24
+ import { MCP_NEEDS_ORG } from "./mcp.js";
24
25
  import { errorMessage } from "./guards.js";
25
26
  import { ensureEndpoint } from "./listener/endpoint.js";
26
27
  import { listenerLogPath, startListenerInBackground } from "./listener/background.js";
@@ -29,7 +30,7 @@ import { cmdAuth, cmdLogin, cmdSkills } from "./commands/heedvane.js";
29
30
  import { cmdMemory } from "./commands/memory.js";
30
31
  import { backendAt } from "./backend/credentials.js";
31
32
  import { readLogin } from "./backend/login.js";
32
- import { agentLines, askChoices, brandIntro, confirmInstall, installCancelled, installFinished, installWithSpinner, listenerStep, promptLine, showInstalled } from "./installer.js";
33
+ import { agentLines, askChoices, brandIntro, confirmInstall, installCancelled, installDetails, installFinished, installWithSpinner, listenerStep, promptLine, showInstalled } from "./installer.js";
33
34
  const USAGE = ` heedvane-hook — cross-agent steering, guardrails & analysis
34
35
 
35
36
  Usage: npx heedvane [command] [flags] (installed: heedvane <command>)
@@ -48,11 +49,11 @@ const USAGE = ` heedvane-hook — cross-agent steering, guardrails & analysis
48
49
  skills sync install the organization's approved skills and cache the list hooks enforce
49
50
  memory list|show|search browse this repository's team memory (a folder, one memory, or a question)
50
51
  status quick state summary
51
- mcp run the MCP stdio server (what agents register)
52
52
 
53
53
  Flags: --dry-run --yes --json --agent <id> (repeatable) --client <id> --purge-config --project
54
54
  --server <url> (connect/init: the Heedvane app to use, saved as heedvane.url; --url also works)
55
- --org <id> (connect/init: the organization the sign-in must belong to)`;
55
+ --org <id> (connect/init: the organization the sign-in must belong to; also registers Heedvane's MCP connection)
56
+ --mode <look-but-dont-keep|metrics-only> (connect/init: what leaves this machine, saved as heedvane.dataMode)`;
56
57
  function resolveHome(env) {
57
58
  return env.HEED_HOME ?? homedir();
58
59
  }
@@ -98,17 +99,64 @@ function parseInvocation(args) {
98
99
  clients: parseRepeatedFlag(args, "--client"),
99
100
  server: flagValue(args, "--server") ?? flagValue(args, "--url"),
100
101
  org: flagValue(args, "--org"),
101
- positional: positionalArgs(args, ["--agent", "--client", "--folder", "--offset", "--url", "--server", "--org", "--token"]),
102
+ mode: flagValue(args, "--mode"),
103
+ positional: positionalArgs(args, VALUE_FLAGS),
102
104
  };
103
105
  }
106
+ /** Flags without a value. */
107
+ const SWITCH_FLAGS = ["--dry-run", "--yes", "--json", "--purge-config", "--project", "--help", "-h"];
108
+ /** Flags followed by a value. */
109
+ const VALUE_FLAGS = ["--agent", "--client", "--folder", "--offset", "--url", "--server", "--org", "--mode", "--token"];
110
+ /** Heedvane's own arguments: everything before `--`, after which `run` passes the rest to the agent. */
111
+ function ownArgs(args) {
112
+ const separator = args.indexOf("--");
113
+ return separator < 0 ? args : args.slice(0, separator);
114
+ }
115
+ /** The first argument that looks like a flag but is not one of ours (values of value flags skipped). */
116
+ function unknownFlag(args) {
117
+ for (let i = 0; i < args.length; i++) {
118
+ if (VALUE_FLAGS.includes(args[i]))
119
+ i += 1;
120
+ else if (args[i].startsWith("-") && !SWITCH_FLAGS.includes(args[i]))
121
+ return args[i];
122
+ }
123
+ return undefined;
124
+ }
125
+ /** One command's usage line from USAGE plus the flags, or the whole USAGE for no or an unknown command. */
126
+ function helpFor(command) {
127
+ const lines = USAGE.split("\n");
128
+ const own = command === undefined ? [] : lines.filter((line) => line.trim().startsWith(`${command} `));
129
+ if (own.length === 0)
130
+ return USAGE;
131
+ const flags = lines.slice(lines.findIndex((line) => line.trim().startsWith("Flags:")));
132
+ return [` Usage: npx heedvane ${command} [flags]`, "", ...own, "", ...flags].join("\n");
133
+ }
134
+ /** The command help is asked about: `help <command>`, `<command> --help`, or none for `-h` alone. */
135
+ function helpTopic([first, second]) {
136
+ if (first === "help")
137
+ return second;
138
+ return first === "-h" ? undefined : first;
139
+ }
104
140
  async function main(argv, opts = {}) {
105
141
  const ctx = commandContext(argv, opts);
106
142
  const command = ctx.positional[0];
143
+ const own = ownArgs(ctx.args);
144
+ // Help never runs anything: `uninstall --help` must not uninstall.
145
+ if (command === "help" || own.includes("--help") || own.includes("-h")) {
146
+ ctx.out(helpFor(helpTopic(ctx.positional)));
147
+ return 0;
148
+ }
149
+ const unknown = unknownFlag(own);
150
+ if (unknown !== undefined) {
151
+ ctx.out(` ✗ unknown flag: ${unknown} (nothing was changed)`);
152
+ ctx.out(helpFor(command));
153
+ return 2;
154
+ }
107
155
  if (command === undefined && takesInstaller(ctx))
108
156
  return cmdInstall(ctx);
109
- if (!command || command === "help" || command === "--help") {
157
+ if (!command) {
110
158
  ctx.out(USAGE);
111
- return command ? 0 : 2;
159
+ return 2;
112
160
  }
113
161
  return routeCommand(command, ctx);
114
162
  }
@@ -122,7 +170,7 @@ function commandContext(argv, opts) {
122
170
  out: opts.write ?? ((line) => console.log(line)),
123
171
  args,
124
172
  readStdin: opts.readStdin ?? readProcessStdin,
125
- openBrowser: opts.openBrowser ?? openSystemBrowser,
173
+ openBrowser: opts.openBrowser ?? ((url) => openSystemBrowser(url, env.BROWSER)),
126
174
  interactive: opts.interactive ?? (process.stdin.isTTY === true && process.stdout.isTTY === true),
127
175
  cwd: opts.cwd ?? process.cwd(),
128
176
  prompts: opts.prompts ?? {},
@@ -151,10 +199,6 @@ async function routeServiceCommand(command, ctx) {
151
199
  ctx.out(await statusText());
152
200
  return 0;
153
201
  }
154
- case "mcp": {
155
- runMcpStdio({ write: (line) => ctx.out(line) });
156
- return 0;
157
- }
158
202
  case "listener": {
159
203
  return cmdListener({ home: ctx.home, out: ctx.out, rest: ctx.args.slice(ctx.args.indexOf("listener") + 1) });
160
204
  }
@@ -214,11 +258,11 @@ async function cmdInit(ctx) {
214
258
  }
215
259
  let serverEdit;
216
260
  try {
217
- serverEdit = ctx.server === undefined ? null : withServer(home, loaded, ctx.server);
261
+ serverEdit = withFlags(home, loaded, ctx);
218
262
  }
219
263
  catch (err) {
220
264
  out("");
221
- out(` ✗ --server ${ctx.server} cannot be used: ${errorMessage(err)}`);
265
+ out(` ✗ ${errorMessage(err)}`);
222
266
  return 1;
223
267
  }
224
268
  const { heedFile, existed } = serverEdit ?? loaded;
@@ -243,11 +287,27 @@ async function cmdInit(ctx) {
243
287
  // --yes: first non-dry run has approval; proceed to write.
244
288
  return applyAndReport(ctx, { heedFile, agents, existed, ...(serverEdit === null ? {} : { text: serverEdit.text }) });
245
289
  }
246
- /** heed.yaml (or the template) with `--server` as `heedvane.url`; null when it already names that server. Throws on an unusable URL. */
247
- function withServer(home, loaded, server) {
248
- const text = withServerUrl(heedText(home, loaded.existed), server);
249
- const heedFile = loadHeedFile(text);
250
- if (loaded.existed && heedFile.heedvane.url === loaded.heedFile.heedvane.url)
290
+ /** The heed.yaml settings `--server`, `--org` and `--mode` name; a refusal when a value cannot be used. */
291
+ function flagSettings({ server, org, mode }) {
292
+ if (mode !== undefined && !DATA_MODES.some((known) => known === mode))
293
+ throw new Error(`--mode ${mode} is not one of ${DATA_MODES.join(", ")}; nothing was changed`);
294
+ const settings = [[["heedvane", "url"], server], [["heedvane", "org"], org], [["heedvane", "dataMode"], mode]];
295
+ return settings.filter((setting) => setting[1] !== undefined);
296
+ }
297
+ /** heed.yaml (or the template) with the flags' settings; null when they change nothing. Throws on a value it cannot use. */
298
+ function withFlags(home, loaded, flags) {
299
+ const settings = flagSettings(flags);
300
+ if (settings.length === 0)
301
+ return null;
302
+ const text = withSettings(heedText(home, loaded.existed), settings);
303
+ let heedFile;
304
+ try {
305
+ heedFile = loadHeedFile(text);
306
+ }
307
+ catch (err) {
308
+ throw new Error(`${flags.server === undefined ? "a flag" : `--server ${flags.server}`} cannot be used: ${errorMessage(err)}`, { cause: err });
309
+ }
310
+ if (loaded.existed && JSON.stringify(heedFile.heedvane) === JSON.stringify(loaded.heedFile.heedvane))
251
311
  return null;
252
312
  return { heedFile, existed: loaded.existed, text };
253
313
  }
@@ -259,7 +319,9 @@ async function applyAndReport(ctx, spec) {
259
319
  if (!flags.dryRun)
260
320
  await signInIfNeeded(ctx, spec.heedFile.heedvane.url);
261
321
  out("");
262
- out(" Next: open a new agent session and run /heedvane-status.");
322
+ out(" Next: open a new agent session; Heedvane joins it at session start.");
323
+ if (spec.heedFile.install.mcp && heedvaneConnector(spec.heedFile) === undefined)
324
+ out(` ${MCP_NEEDS_ORG}`);
263
325
  out(" Re-run `npx heedvane doctor` after any heed.yaml edit.");
264
326
  const failed = applied.some(appliedFailed);
265
327
  if (flags.json)
@@ -272,7 +334,13 @@ function writeControlFile({ home, out }, { heedFile, existed, text }) {
272
334
  return;
273
335
  mkdirSync(join(home, ".heed"), { recursive: true, mode: 0o700 });
274
336
  writeFileSync(join(home, ".heed", "heed.yaml"), text ?? DEFAULT_HEED_YAML_TEMPLATE, { mode: 0o600 });
275
- out(existed ? ` ✓ Set heedvane.url in ~/.heed/heed.yaml to ${heedFile.heedvane.url}` : ` ✓ Wrote ~/.heed/heed.yaml (your control file — edit any time)`);
337
+ if (!existed)
338
+ return out(` ✓ Wrote ~/.heed/heed.yaml (your control file — edit any time)`);
339
+ const { url, org, dataMode } = heedFile.heedvane;
340
+ for (const [name, value] of [["url", url], ["org", org], ["dataMode", dataMode]]) {
341
+ if (value !== undefined)
342
+ out(` ✓ Set heedvane.${name} in ~/.heed/heed.yaml to ${value}`);
343
+ }
276
344
  }
277
345
  /** Without a sign-in to `url` (and to `--org`, when named), sign in now when a person is at the terminal, else say how. Never fails setup. */
278
346
  /** Writes heed.yaml, applies the plan, issues the listener endpoint and records the machine state. Prints only what applying prints. */
@@ -285,9 +353,9 @@ async function installPlan(ctx, spec) {
285
353
  const dispatcher = {
286
354
  denySkillsWhenUnchecked: heedFile.heedvane.skills.enforce && heedFile.heedvane.skills.whenUnknown === "deny",
287
355
  gating: heedFile.gating.mode,
288
- nodeDir: dirname(process.execPath),
356
+ nodeDir: installingNodeDir(),
289
357
  };
290
- const applied = await applyPlan(actions, { home, env, out, dispatcher });
358
+ const applied = await applyPlan(actions, { home, env, out, dispatcher, connector: heedvaneConnector(heedFile) });
291
359
  // The listener port and token every dispatcher reads; kept once issued.
292
360
  await ensureEndpoint(join(home, ".heed"));
293
361
  // Machine state: CLI-owned; preserve an existing endpoint.
@@ -351,9 +419,9 @@ async function cmdSetup(ctx) {
351
419
  }
352
420
  return applyAndReport(ctx, { heedFile, agents: ctx.agents, existed: true });
353
421
  }
354
- /** A person at a terminal who did not ask for the one-shot path (`--yes`, `--dry-run`, `--json`) or for help. */
422
+ /** A person at a terminal who did not ask for the one-shot path (`--yes`, `--dry-run`, `--json`). */
355
423
  function takesInstaller(ctx) {
356
- return ctx.interactive && !ctx.flags.yes && !ctx.flags.dryRun && !ctx.flags.json && !ctx.args.includes("--help");
424
+ return ctx.interactive && !ctx.flags.yes && !ctx.flags.dryRun && !ctx.flags.json;
357
425
  }
358
426
  /** heed.yaml's text, or the template when there is none yet. */
359
427
  function heedText(home, existed) {
@@ -375,7 +443,7 @@ async function cmdInstall(ctx) {
375
443
  const spec = { heedFile: loadHeedFile(text), agents: choices.agents === "auto" ? [] : choices.agents, existed: base.existed, text };
376
444
  const names = new Map(detectionRows(ctx.home, ctx.env).map((row) => [row.client.id, row.client.displayName]));
377
445
  const planned = agentLines(planFor({ home: ctx.home, env: ctx.env, ...spec }), names);
378
- if (!(await confirmInstall(planned, prompts)))
446
+ if (!(await confirmInstall(planned, installDetails(spec.heedFile, heedvaneConnector(spec.heedFile)), prompts)))
379
447
  return installCancelled(prompts);
380
448
  const notes = [];
381
449
  const { actions, applied } = await installWithSpinner(planned.length, () => installPlan({ ...ctx, out: (line) => notes.push(line) }, spec), prompts);
@@ -383,29 +451,29 @@ async function cmdInstall(ctx) {
383
451
  showInstalled(agentLines(actions, names, applied), prompts);
384
452
  const listening = await listenerStep(() => ctx.startListener(ctx.home), listenerLogPath(join(ctx.home, ".heed")), prompts);
385
453
  await signInIfNeeded({ ...ctx, out: promptLine(prompts) }, spec.heedFile.heedvane.url);
386
- installFinished(nextSteps(listening, spec.heedFile.heedvane.url), prompts);
454
+ installFinished(nextSteps(listening, spec.heedFile), prompts);
387
455
  return applied.some(appliedFailed) ? 1 : 0;
388
456
  }
389
- /** heed.yaml's current text (or the template) with `--server` applied, parsed; a message when either cannot be used. */
390
- function installerBase({ home, server }) {
391
- const loaded = loadOrTemplate(home);
457
+ /** heed.yaml's current text (or the template) with `--server`, `--org` and `--mode` applied, parsed; a message when it cannot be used. */
458
+ function installerBase(ctx) {
459
+ const loaded = loadOrTemplate(ctx.home);
392
460
  if (loaded.error)
393
461
  return `~/.heed/heed.yaml does not parse: ${loaded.error}`;
394
462
  try {
395
- const current = heedText(home, loaded.existed);
396
- const text = server === undefined ? current : withServerUrl(current, server);
397
- return { heedFile: loadHeedFile(text), existed: loaded.existed, text };
463
+ return withFlags(ctx.home, loaded, ctx) ?? { heedFile: loaded.heedFile, existed: loaded.existed, text: heedText(ctx.home, loaded.existed) };
398
464
  }
399
465
  catch (err) {
400
- return `--server ${server} cannot be used: ${errorMessage(err)}`;
466
+ return errorMessage(err);
401
467
  }
402
468
  }
403
469
  function agentOptions({ home, env }) {
404
470
  return detectionRows(home, env).map(({ client, detected }) => ({ id: client.id, name: client.displayName, detected, capability: capabilitySummary(client) }));
405
471
  }
406
- function nextSteps(listening, server) {
472
+ function nextSteps(listening, heedFile) {
473
+ const server = heedFile.heedvane.url;
407
474
  return [
408
475
  "Open a new agent session: Heedvane joins it at session start.",
476
+ ...(heedFile.install.mcp && heedvaneConnector(heedFile) === undefined ? [MCP_NEEDS_ORG] : []),
409
477
  `Watch your sessions: ${server}`,
410
478
  ...(listening ? [] : ["Heedvane starts by itself with your next agent session. To see why it did not start now: npx heedvane listener start"]),
411
479
  "Check the install: npx heedvane doctor",
@@ -421,24 +489,38 @@ async function applyHermesPlugin(client, ctx) {
421
489
  }
422
490
  return { clientId: client.id, kind: "hooks", status: status === "installed" ? "create" : "unchanged", path };
423
491
  }
492
+ /** Folds removals into an apply result: a failed removal becomes the result, any other makes an unchanged one an update. */
493
+ function withRemovals(items, result) {
494
+ let worst = result;
495
+ for (const item of items) {
496
+ if (item.status === "failed")
497
+ return { status: "failed", path: item.path, reason: item.detail };
498
+ if (worst.status === "unchanged")
499
+ worst = { status: "update", path: item.path };
500
+ }
501
+ return worst;
502
+ }
424
503
  /** Drop our groups under event keys this client no longer installs; a failed removal becomes the result. */
425
504
  async function withoutStaleHookGroups(client, writes, result) {
426
505
  let worst = result;
427
506
  for (const { target, containerKey } of writes) {
428
507
  if (containerKey !== "hooks")
429
508
  continue;
430
- for (const item of await removeStaleHookGroups(client, target)) {
431
- if (item.status === "failed")
432
- return { status: "failed", path: item.path, reason: item.detail };
433
- if (worst.status === "unchanged")
434
- worst = { status: "update", path: item.path };
435
- }
509
+ worst = withRemovals(await removeStaleHookGroups(client, target), worst);
510
+ if (worst.status === "failed")
511
+ return worst;
436
512
  }
437
513
  return worst;
438
514
  }
515
+ /** After the writes: our hook groups under events no longer installed, and what an earlier version registered as MCP (the local helper, the connection's earlier name), with or without a connection to write. */
516
+ async function withoutLeftovers(kind, client, done) {
517
+ if (kind === "hooks")
518
+ return withoutStaleHookGroups(client, done.writes, done.result);
519
+ return kind === "mcp" ? withRemovals(await removeLegacyMcpEntries(client), done.result) : done.result;
520
+ }
439
521
  /** Patch every desired entry into the client's config files, then refresh the dispatcher script. */
440
522
  async function applyConfigEntries(action, client, ctx) {
441
- const desireds = desiredEntriesFor(client, action.kind === "hooks" ? "hooks" : "mcp", { home: ctx.home, env: ctx.env });
523
+ const desireds = desiredEntriesFor(client, action.kind === "hooks" ? "hooks" : "mcp", { home: ctx.home, env: ctx.env, connector: ctx.connector });
442
524
  // One write per file and container: every hook event lands (and is backed up) together.
443
525
  const writes = new Map();
444
526
  for (const d of desireds) {
@@ -457,9 +539,8 @@ async function applyConfigEntries(action, client, ctx) {
457
539
  if (res.status !== "unchanged")
458
540
  worst = res;
459
541
  }
460
- if (action.kind === "hooks" && worst.status !== "failed" && worst.status !== "invalid") {
461
- worst = await withoutStaleHookGroups(client, writes.values(), worst);
462
- }
542
+ if (worst.status !== "failed" && worst.status !== "invalid")
543
+ worst = await withoutLeftovers(action.kind, client, { writes: writes.values(), result: worst });
463
544
  // The dispatcher script the snippet delegates to.
464
545
  await writeDispatcherScript(ctx.home, client.id, ctx.dispatcher);
465
546
  return {
@@ -569,10 +650,10 @@ async function cmdListener(ctx) {
569
650
  }
570
651
  const started = await serveHooks(ctx.home, ctx.out);
571
652
  ctx.out(` ✓ listener on 127.0.0.1:${started.port}, ${started.replayed} spooled event(s) replayed (Ctrl-C to stop)`);
653
+ // Ends on Ctrl-C, or when uninstall asks it to stop (POST /shutdown).
572
654
  await new Promise((resolve) => {
573
- process.on("SIGINT", () => {
574
- started.server.close(() => resolve());
575
- });
655
+ started.server.on("close", resolve);
656
+ process.on("SIGINT", () => started.server.close());
576
657
  });
577
658
  return 0;
578
659
  }
@@ -630,12 +711,15 @@ function backendCommandContext(ctx) {
630
711
  * The command that opens `url` in the default browser. Windows goes through rundll32, not `cmd /c start`:
631
712
  * cmd would cut the sign-in URL at its first `&`.
632
713
  */
633
- export function browserOpener(platform, url) {
714
+ /** The command that opens `url`: `$BROWSER` when set (a no-op such as `true` keeps captures and tests off a real browser), else the platform's opener. */
715
+ export function browserOpener(platform, url, browser) {
716
+ if (browser)
717
+ return [browser, [url]];
634
718
  const openers = { darwin: ["open", [url]], win32: ["rundll32", ["url.dll,FileProtocolHandler", url]] };
635
719
  return openers[platform] ?? ["xdg-open", [url]];
636
720
  }
637
- function openSystemBrowser(url) {
638
- const [command, args] = browserOpener(process.platform, url);
721
+ function openSystemBrowser(url, browser) {
722
+ const [command, args] = browserOpener(process.platform, url, browser);
639
723
  const child = spawn(command, args, { detached: true, stdio: "ignore" });
640
724
  child.on("error", () => undefined); // no browser opener: the printed URL is the way in
641
725
  child.unref();
@@ -34,7 +34,7 @@ export interface ResolveContext {
34
34
  /** Root a client's directory or detection marker is resolved against. */
35
35
  export type ClientPathBase = "home" | "xdg-config" | "platform-config";
36
36
  /** Base a writable config target is resolved against. */
37
- export type PathBase = "client" | "home" | "platform-config";
37
+ export type PathBase = "client" | "home" | "platform-config" | "xdg-config";
38
38
  /** One writable config file for a client. */
39
39
  export interface ConfigTarget {
40
40
  /** Path segments relative to {@link PathBase}. */
@@ -155,13 +155,13 @@ const SUPPORTED_CLIENTS = [
155
155
  base: "client",
156
156
  format: "json",
157
157
  purpose: "mcp",
158
- limitation: "Warp has no native hook events (hooks unavailable); ~/.warp/.mcp.json is project-level, and user-level MCP requires explicit in-app approval of the import.",
158
+ limitation: "Warp has no native hook events (hooks unavailable); ~/.warp/.mcp.json is Warp's global MCP file, and a remote server signs in through the browser when it first starts.",
159
159
  },
160
160
  ],
161
161
  capabilities: ["mcp", "skill"],
162
162
  events: [],
163
163
  mcpServersKey: "mcpServers",
164
- limitation: "Warp exposes no native hook events — policies ship via MCP only, and the MCP import needs explicit user approval in Warp.",
164
+ limitation: "Warp exposes no native hook events, so Heedvane adds only its MCP connection and the skill there.",
165
165
  },
166
166
  {
167
167
  id: "opencode",
@@ -227,11 +227,12 @@ const SUPPORTED_CLIENTS = [
227
227
  homeOverrideEnv: "KIMI_CODE_HOME",
228
228
  configs: [
229
229
  { segments: ["config.toml"], base: "client", format: "toml", purpose: "hooks" },
230
- { segments: ["config.toml"], base: "client", format: "toml", purpose: "mcp" },
230
+ // MCP servers live in mcp.json (Kimi Code's MCP docs); config.toml is listed with hooks, so cleanup still finds the helper 0.3.0 and earlier wrote there.
231
+ { segments: ["mcp.json"], base: "client", format: "json", purpose: "mcp" },
231
232
  ],
232
233
  capabilities: ["hooks", "mcp", "skill"],
233
234
  events: KIMI_EVENTS,
234
- mcpServersKey: "mcp_servers",
235
+ mcpServersKey: "mcpServers",
235
236
  },
236
237
  {
237
238
  id: "droid",
@@ -253,6 +254,8 @@ const SUPPORTED_CLIENTS = [
253
254
  base: "home",
254
255
  configs: [
255
256
  { segments: [".gemini", "config", "hooks.json"], base: "home", format: "json", purpose: "hooks" },
257
+ // Antigravity's documented global MCP file; the second is where 0.3.0 and earlier wrote, kept only so cleanup and uninstall reach it.
258
+ { segments: [".gemini", "config", "mcp_config.json"], base: "home", format: "json", purpose: "mcp" },
256
259
  { segments: ["mcp_config.json"], base: "client", format: "json", purpose: "mcp" },
257
260
  ],
258
261
  capabilities: ["hooks", "mcp", "skill"],
@@ -266,6 +269,8 @@ const SUPPORTED_CLIENTS = [
266
269
  base: "home",
267
270
  homeOverrideEnv: "COPILOT_HOME",
268
271
  configs: [
272
+ // Copilot CLI's documented MCP file; the second is where 0.3.0 and earlier wrote, kept only so cleanup and uninstall reach it.
273
+ { segments: ["mcp-config.json"], base: "client", format: "json", purpose: "mcp" },
269
274
  { segments: ["mcp.json"], base: "client", format: "json", purpose: "mcp" },
270
275
  ],
271
276
  capabilities: ["mcp", "skill"],
@@ -276,8 +281,12 @@ const SUPPORTED_CLIENTS = [
276
281
  id: "windsurf",
277
282
  displayName: "Windsurf",
278
283
  markerSegments: [".codeium", "windsurf"],
284
+ // Devin Desktop's folder: $XDG_CONFIG_HOME/devin, %APPDATA%\devin on Windows.
285
+ altMarkers: [{ segments: ["devin"], base: "xdg-config" }],
279
286
  base: "home",
280
287
  configs: [
288
+ // The file Windsurf's docs (now Devin Desktop's) name; the second is where 0.3.1 and earlier wrote, kept only so cleanup and uninstall reach it.
289
+ { segments: ["devin", "mcp_config.json"], base: "xdg-config", format: "json", purpose: "mcp" },
281
290
  { segments: ["mcp_config.json"], base: "client", format: "json", purpose: "mcp" },
282
291
  ],
283
292
  capabilities: ["mcp"],
@@ -324,14 +333,16 @@ function platformConfigDir(def, ctx, p) {
324
333
  switch (ctx.platform) {
325
334
  case "darwin":
326
335
  return p.join(ctx.home, "Library", "Application Support", appName);
327
- case "win32": {
328
- const appData = ctx.env["APPDATA"] ?? p.join(ctx.home, "AppData", "Roaming");
329
- return p.join(absoluteOrHome(appData, ctx, p), appName);
330
- }
336
+ case "win32":
337
+ return p.join(appDataRoot(ctx, p), appName);
331
338
  default:
332
339
  return p.join(xdgConfigRoot(ctx, p), appName);
333
340
  }
334
341
  }
342
+ /** %APPDATA%, else the default roaming folder under the home. */
343
+ function appDataRoot(ctx, p) {
344
+ return absoluteOrHome(ctx.env["APPDATA"] ?? p.join(ctx.home, "AppData", "Roaming"), ctx, p);
345
+ }
335
346
  /** Treat a relative env override as home-relative so resolution never throws. */
336
347
  function absoluteOrHome(value, ctx, p) {
337
348
  return p.isAbsolute(value) ? value : p.join(ctx.home, value);
@@ -357,7 +368,8 @@ function resolveMarkerBase(target, ctx, p) {
357
368
  case "home":
358
369
  return p.join(ctx.home, ...segments);
359
370
  case "xdg-config":
360
- return p.join(xdgConfigRoot(ctx, p), ...segments);
371
+ // As for config targets: %APPDATA% on Windows (Devin Desktop's folder for Windsurf).
372
+ return p.join(ctx.platform === "win32" ? appDataRoot(ctx, p) : xdgConfigRoot(ctx, p), ...segments);
361
373
  case "platform-config":
362
374
  return p.join(platformConfigDir(def, ctx, p), ...segments);
363
375
  }
@@ -388,6 +400,9 @@ function resolveConfigTarget(def, target, resolve) {
388
400
  case "platform-config":
389
401
  path = p.join(platformConfigDir(def, ctx, p), ...target.segments);
390
402
  break;
403
+ case "xdg-config":
404
+ path = p.join(ctx.platform === "win32" ? appDataRoot(ctx, p) : xdgConfigRoot(ctx, p), ...target.segments);
405
+ break;
391
406
  }
392
407
  return target.limitation === undefined
393
408
  ? { path, format: target.format, purpose: target.purpose }
@@ -37,7 +37,12 @@ async function ask(ctx, request) {
37
37
  return 1;
38
38
  }
39
39
  const root = join(ctx.home, ".heed");
40
- const backend = await backendAt(root, heedvaneConfigAt(root).url);
40
+ const config = heedvaneConfigAt(root);
41
+ if (config.dataMode === "metrics-only") {
42
+ ctx.out(" ✗ team memory is off on this machine: heed.yaml heedvane.dataMode is metrics-only, so no question or repository leaves it");
43
+ return 1;
44
+ }
45
+ const backend = await backendAt(root, config.url);
41
46
  if (backend === null) {
42
47
  ctx.out(" ✗ not signed in — run: npx heedvane login (or, headless: npx heedvane auth org < key-file)");
43
48
  return 1;
package/dist/doctor.js CHANGED
@@ -8,7 +8,8 @@ import { join } from "node:path";
8
8
  import { supportedClients } from "./clients/definitions.js";
9
9
  import { entryValue, parseDoc, readEntry, valuesEqual } from "./config-doc.js";
10
10
  import { loadHeedFileAt } from "./heed-file.js";
11
- import { desiredEntriesFor, hooksDeferredTo, isClientDetected } from "./plan.js";
11
+ import { desiredEntriesFor, heedvaneConnector, hooksDeferredTo, isClientDetected, legacyMcpEntries } from "./plan.js";
12
+ import { MCP_NEEDS_ORG, connectorEntry } from "./mcp.js";
12
13
  import { readInstalledSkillDigest } from "./skill/bundle.js";
13
14
  import { HeedStateCorruptError, readState } from "./state.js";
14
15
  import { readApprovedSkills } from "./skills/approved.js";
@@ -109,13 +110,14 @@ void checkState;
109
110
  function scopeSet(clients) {
110
111
  return clients && clients.length > 0 ? new Set(clients) : null;
111
112
  }
112
- /** The pure-planner desired-entry lookup, resolving each client id against home/env. */
113
- function defaultDesiredLookup(home, env) {
113
+ /** The pure-planner desired-entry lookup, resolving each client id against home/env and heed.yaml's organization. */
114
+ function defaultDesiredLookup(home, env, heedFile) {
115
+ const connector = heedFile === undefined ? undefined : heedvaneConnector(heedFile);
114
116
  return (clientId, capability) => {
115
117
  const client = supportedClients({ home, platform: process.platform, env }).find((c) => c.id === clientId);
116
118
  if (!client)
117
119
  return undefined;
118
- return desiredEntriesFor(client, capability, { home, env }).map((d) => ({
120
+ return desiredEntriesFor(client, capability, { home, env, connector }).map((d) => ({
119
121
  path: d.path,
120
122
  format: d.format,
121
123
  containerKey: d.containerKey,
@@ -160,13 +162,27 @@ function collectCapabilityStates(client, ctx) {
160
162
  const supported = client.capabilities.includes(capability) || (capability === "hooks" && client.id === "hermes" && client.capabilities.includes("plugin-install"));
161
163
  if (!supported || !heedFileWants(client, capability, ctx))
162
164
  continue;
163
- const desireds = ctx.desired(client.id, capability);
164
- if (!desireds || desireds.length === 0)
165
- continue;
166
- states.push({ capability, result: registrationState(desireds, ctx.home) });
165
+ const desireds = ctx.desired(client.id, capability) ?? [];
166
+ const result = capability === "mcp" ? mcpConnectionState(client, desireds, ctx.home) : hooksState(desireds, ctx.home);
167
+ if (result !== undefined)
168
+ states.push({ capability, result });
167
169
  }
168
170
  return states;
169
171
  }
172
+ /** The hooks' registration verdict, when this client has hooks to register. */
173
+ function hooksState(desireds, home) {
174
+ return desireds.length > 0 ? registrationState(desireds, home) : undefined;
175
+ }
176
+ /** Heedvane's MCP connection: stale while an earlier version's entry is left, missing without an organization, else the registration verdict. */
177
+ function mcpConnectionState(client, desireds, home) {
178
+ const legacy = legacyMcpEntries(client);
179
+ if (legacy !== undefined)
180
+ return { state: "stale", detail: `${legacy.names.join(", ")} in ${legacy.path} is an earlier version's entry; npx heedvane connect replaces it` };
181
+ if (desireds.length > 0)
182
+ return registrationState(desireds, home);
183
+ // Agents the connection is not registered in have nothing to check.
184
+ return connectorEntry(client.id, "") === undefined ? undefined : { state: "missing", detail: MCP_NEEDS_ORG };
185
+ }
170
186
  /** One detected client's registration check (aligned / stale / missing with per-entry detail). */
171
187
  function pushRegistrationChecks(checks, client, ctx) {
172
188
  const detected = isClientDetected(client);
@@ -233,7 +249,7 @@ export async function runDoctor(input) {
233
249
  const root = join(home, ".heed");
234
250
  const state = await readStateOrReport(root, checks);
235
251
  // 4. Desired-entry lookup: injected, falling back to the pure planner.
236
- const desired = input.desired ?? defaultDesiredLookup(home, env);
252
+ const desired = input.desired ?? defaultDesiredLookup(home, env, heedFile);
237
253
  // 5. Per detected client: registration state aligned | stale | missing.
238
254
  const scope = scopeSet(input.clients);
239
255
  const allClients = supportedClients({ home, platform: process.platform, env });