heedvane 0.2.1 → 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
 
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,13 +99,14 @@ function parseInvocation(args) {
98
99
  clients: parseRepeatedFlag(args, "--client"),
99
100
  server: flagValue(args, "--server") ?? flagValue(args, "--url"),
100
101
  org: flagValue(args, "--org"),
102
+ mode: flagValue(args, "--mode"),
101
103
  positional: positionalArgs(args, VALUE_FLAGS),
102
104
  };
103
105
  }
104
106
  /** Flags without a value. */
105
107
  const SWITCH_FLAGS = ["--dry-run", "--yes", "--json", "--purge-config", "--project", "--help", "-h"];
106
108
  /** Flags followed by a value. */
107
- const VALUE_FLAGS = ["--agent", "--client", "--folder", "--offset", "--url", "--server", "--org", "--token"];
109
+ const VALUE_FLAGS = ["--agent", "--client", "--folder", "--offset", "--url", "--server", "--org", "--mode", "--token"];
108
110
  /** Heedvane's own arguments: everything before `--`, after which `run` passes the rest to the agent. */
109
111
  function ownArgs(args) {
110
112
  const separator = args.indexOf("--");
@@ -168,7 +170,7 @@ function commandContext(argv, opts) {
168
170
  out: opts.write ?? ((line) => console.log(line)),
169
171
  args,
170
172
  readStdin: opts.readStdin ?? readProcessStdin,
171
- openBrowser: opts.openBrowser ?? openSystemBrowser,
173
+ openBrowser: opts.openBrowser ?? ((url) => openSystemBrowser(url, env.BROWSER)),
172
174
  interactive: opts.interactive ?? (process.stdin.isTTY === true && process.stdout.isTTY === true),
173
175
  cwd: opts.cwd ?? process.cwd(),
174
176
  prompts: opts.prompts ?? {},
@@ -197,10 +199,6 @@ async function routeServiceCommand(command, ctx) {
197
199
  ctx.out(await statusText());
198
200
  return 0;
199
201
  }
200
- case "mcp": {
201
- runMcpStdio({ write: (line) => ctx.out(line) });
202
- return 0;
203
- }
204
202
  case "listener": {
205
203
  return cmdListener({ home: ctx.home, out: ctx.out, rest: ctx.args.slice(ctx.args.indexOf("listener") + 1) });
206
204
  }
@@ -260,11 +258,11 @@ async function cmdInit(ctx) {
260
258
  }
261
259
  let serverEdit;
262
260
  try {
263
- serverEdit = ctx.server === undefined ? null : withServer(home, loaded, ctx.server);
261
+ serverEdit = withFlags(home, loaded, ctx);
264
262
  }
265
263
  catch (err) {
266
264
  out("");
267
- out(` ✗ --server ${ctx.server} cannot be used: ${errorMessage(err)}`);
265
+ out(` ✗ ${errorMessage(err)}`);
268
266
  return 1;
269
267
  }
270
268
  const { heedFile, existed } = serverEdit ?? loaded;
@@ -289,11 +287,27 @@ async function cmdInit(ctx) {
289
287
  // --yes: first non-dry run has approval; proceed to write.
290
288
  return applyAndReport(ctx, { heedFile, agents, existed, ...(serverEdit === null ? {} : { text: serverEdit.text }) });
291
289
  }
292
- /** heed.yaml (or the template) with `--server` as `heedvane.url`; null when it already names that server. Throws on an unusable URL. */
293
- function withServer(home, loaded, server) {
294
- const text = withServerUrl(heedText(home, loaded.existed), server);
295
- const heedFile = loadHeedFile(text);
296
- 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))
297
311
  return null;
298
312
  return { heedFile, existed: loaded.existed, text };
299
313
  }
@@ -305,7 +319,9 @@ async function applyAndReport(ctx, spec) {
305
319
  if (!flags.dryRun)
306
320
  await signInIfNeeded(ctx, spec.heedFile.heedvane.url);
307
321
  out("");
308
- 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}`);
309
325
  out(" Re-run `npx heedvane doctor` after any heed.yaml edit.");
310
326
  const failed = applied.some(appliedFailed);
311
327
  if (flags.json)
@@ -318,7 +334,13 @@ function writeControlFile({ home, out }, { heedFile, existed, text }) {
318
334
  return;
319
335
  mkdirSync(join(home, ".heed"), { recursive: true, mode: 0o700 });
320
336
  writeFileSync(join(home, ".heed", "heed.yaml"), text ?? DEFAULT_HEED_YAML_TEMPLATE, { mode: 0o600 });
321
- 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
+ }
322
344
  }
323
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. */
324
346
  /** Writes heed.yaml, applies the plan, issues the listener endpoint and records the machine state. Prints only what applying prints. */
@@ -331,9 +353,9 @@ async function installPlan(ctx, spec) {
331
353
  const dispatcher = {
332
354
  denySkillsWhenUnchecked: heedFile.heedvane.skills.enforce && heedFile.heedvane.skills.whenUnknown === "deny",
333
355
  gating: heedFile.gating.mode,
334
- nodeDir: dirname(process.execPath),
356
+ nodeDir: installingNodeDir(),
335
357
  };
336
- const applied = await applyPlan(actions, { home, env, out, dispatcher });
358
+ const applied = await applyPlan(actions, { home, env, out, dispatcher, connector: heedvaneConnector(heedFile) });
337
359
  // The listener port and token every dispatcher reads; kept once issued.
338
360
  await ensureEndpoint(join(home, ".heed"));
339
361
  // Machine state: CLI-owned; preserve an existing endpoint.
@@ -421,7 +443,7 @@ async function cmdInstall(ctx) {
421
443
  const spec = { heedFile: loadHeedFile(text), agents: choices.agents === "auto" ? [] : choices.agents, existed: base.existed, text };
422
444
  const names = new Map(detectionRows(ctx.home, ctx.env).map((row) => [row.client.id, row.client.displayName]));
423
445
  const planned = agentLines(planFor({ home: ctx.home, env: ctx.env, ...spec }), names);
424
- if (!(await confirmInstall(planned, prompts)))
446
+ if (!(await confirmInstall(planned, installDetails(spec.heedFile, heedvaneConnector(spec.heedFile)), prompts)))
425
447
  return installCancelled(prompts);
426
448
  const notes = [];
427
449
  const { actions, applied } = await installWithSpinner(planned.length, () => installPlan({ ...ctx, out: (line) => notes.push(line) }, spec), prompts);
@@ -429,29 +451,29 @@ async function cmdInstall(ctx) {
429
451
  showInstalled(agentLines(actions, names, applied), prompts);
430
452
  const listening = await listenerStep(() => ctx.startListener(ctx.home), listenerLogPath(join(ctx.home, ".heed")), prompts);
431
453
  await signInIfNeeded({ ...ctx, out: promptLine(prompts) }, spec.heedFile.heedvane.url);
432
- installFinished(nextSteps(listening, spec.heedFile.heedvane.url), prompts);
454
+ installFinished(nextSteps(listening, spec.heedFile), prompts);
433
455
  return applied.some(appliedFailed) ? 1 : 0;
434
456
  }
435
- /** heed.yaml's current text (or the template) with `--server` applied, parsed; a message when either cannot be used. */
436
- function installerBase({ home, server }) {
437
- 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);
438
460
  if (loaded.error)
439
461
  return `~/.heed/heed.yaml does not parse: ${loaded.error}`;
440
462
  try {
441
- const current = heedText(home, loaded.existed);
442
- const text = server === undefined ? current : withServerUrl(current, server);
443
- 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) };
444
464
  }
445
465
  catch (err) {
446
- return `--server ${server} cannot be used: ${errorMessage(err)}`;
466
+ return errorMessage(err);
447
467
  }
448
468
  }
449
469
  function agentOptions({ home, env }) {
450
470
  return detectionRows(home, env).map(({ client, detected }) => ({ id: client.id, name: client.displayName, detected, capability: capabilitySummary(client) }));
451
471
  }
452
- function nextSteps(listening, server) {
472
+ function nextSteps(listening, heedFile) {
473
+ const server = heedFile.heedvane.url;
453
474
  return [
454
475
  "Open a new agent session: Heedvane joins it at session start.",
476
+ ...(heedFile.install.mcp && heedvaneConnector(heedFile) === undefined ? [MCP_NEEDS_ORG] : []),
455
477
  `Watch your sessions: ${server}`,
456
478
  ...(listening ? [] : ["Heedvane starts by itself with your next agent session. To see why it did not start now: npx heedvane listener start"]),
457
479
  "Check the install: npx heedvane doctor",
@@ -467,24 +489,38 @@ async function applyHermesPlugin(client, ctx) {
467
489
  }
468
490
  return { clientId: client.id, kind: "hooks", status: status === "installed" ? "create" : "unchanged", path };
469
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
+ }
470
503
  /** Drop our groups under event keys this client no longer installs; a failed removal becomes the result. */
471
504
  async function withoutStaleHookGroups(client, writes, result) {
472
505
  let worst = result;
473
506
  for (const { target, containerKey } of writes) {
474
507
  if (containerKey !== "hooks")
475
508
  continue;
476
- for (const item of await removeStaleHookGroups(client, target)) {
477
- if (item.status === "failed")
478
- return { status: "failed", path: item.path, reason: item.detail };
479
- if (worst.status === "unchanged")
480
- worst = { status: "update", path: item.path };
481
- }
509
+ worst = withRemovals(await removeStaleHookGroups(client, target), worst);
510
+ if (worst.status === "failed")
511
+ return worst;
482
512
  }
483
513
  return worst;
484
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
+ }
485
521
  /** Patch every desired entry into the client's config files, then refresh the dispatcher script. */
486
522
  async function applyConfigEntries(action, client, ctx) {
487
- 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 });
488
524
  // One write per file and container: every hook event lands (and is backed up) together.
489
525
  const writes = new Map();
490
526
  for (const d of desireds) {
@@ -503,9 +539,8 @@ async function applyConfigEntries(action, client, ctx) {
503
539
  if (res.status !== "unchanged")
504
540
  worst = res;
505
541
  }
506
- if (action.kind === "hooks" && worst.status !== "failed" && worst.status !== "invalid") {
507
- worst = await withoutStaleHookGroups(client, writes.values(), worst);
508
- }
542
+ if (worst.status !== "failed" && worst.status !== "invalid")
543
+ worst = await withoutLeftovers(action.kind, client, { writes: writes.values(), result: worst });
509
544
  // The dispatcher script the snippet delegates to.
510
545
  await writeDispatcherScript(ctx.home, client.id, ctx.dispatcher);
511
546
  return {
@@ -676,12 +711,15 @@ function backendCommandContext(ctx) {
676
711
  * The command that opens `url` in the default browser. Windows goes through rundll32, not `cmd /c start`:
677
712
  * cmd would cut the sign-in URL at its first `&`.
678
713
  */
679
- 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]];
680
718
  const openers = { darwin: ["open", [url]], win32: ["rundll32", ["url.dll,FileProtocolHandler", url]] };
681
719
  return openers[platform] ?? ["xdg-open", [url]];
682
720
  }
683
- function openSystemBrowser(url) {
684
- const [command, args] = browserOpener(process.platform, url);
721
+ function openSystemBrowser(url, browser) {
722
+ const [command, args] = browserOpener(process.platform, url, browser);
685
723
  const child = spawn(command, args, { detached: true, stdio: "ignore" });
686
724
  child.on("error", () => undefined); // no browser opener: the printed URL is the way in
687
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 });
@@ -27,9 +27,19 @@ export interface HeedSkillsConfig {
27
27
  /** No synced approved list, or no listener to check it: let the skill run with a warning, or deny it. */
28
28
  whenUnknown: "allow" | "deny";
29
29
  }
30
+ /**
31
+ * What leaves this machine (the installer and data modes proposal, "Proposed data modes"). `look-but-dont-keep`:
32
+ * Heedvane's guardrail checks read the prompt or tool call and keep only the verdict. `metrics-only`: only counts and
33
+ * names leave; the organization's fixed rules still run here, Heedvane's own checks and team memory do not.
34
+ */
35
+ export declare const DATA_MODES: readonly ["look-but-dont-keep", "metrics-only"];
36
+ export type DataMode = (typeof DATA_MODES)[number];
30
37
  /** The Heedvane backend the listener asks for steering, approved skills and memory. */
31
38
  export interface HeedvaneConfig {
32
39
  url: string;
40
+ /** The organization `connect --org` named: Heedvane's MCP connection URL carries it. */
41
+ org?: string;
42
+ dataMode: DataMode;
33
43
  steering: boolean;
34
44
  memory: boolean;
35
45
  skills: HeedSkillsConfig;
@@ -47,7 +57,7 @@ export interface HeedFile {
47
57
  * The commented control file written by `npx heedvane init`.
48
58
  * Kept loadable: loadHeedFile(DEFAULT_HEED_YAML_TEMPLATE) yields every default.
49
59
  */
50
- export declare const DEFAULT_HEED_YAML_TEMPLATE = "# ~/.heed/heed.yaml \u2014 Heedvane agent hooks control file (v1)\nschemaVersion: v1\n\n# Where managed scripts and spool live. Default below; one install per machine.\nroot: ~/.heed\n\n# The hook listener (Orca's pattern): loopback HTTP, token-authed, per-agent routes.\nlistener:\n port: 0 # 0 = pick a free port at first start, persist to endpoint.env\n spool: true # spool to disk when listener is unreachable (5 MB cap, 7-day TTL)\n\n# Which agents to manage. `auto` = every detected agent on the machine.\nagents: auto # or an explicit list: [claude, codex, warp]\n\n# Which integrations to install per agent where supported.\ninstall:\n hooks: true # native hook config injection (skipped where the agent has none)\n skill: true # heedvane-hooks skill bundle -> ~/.agents/skills/heedvane-hooks/\n mcp: true # heedvane MCP server registration (universal fallback, incl. Warp)\n\n# Session gating (Orca's env-gated dispatcher pattern).\ngating:\n mode: always # always = every session; pane = only sessions launched with HEED_SESSION env\n\n# The Heedvane backend (credentials: npx heedvane auth, stored in ~/.heed/credentials.json).\nheedvane:\n url: https://app.heedvane.com\n steering: true # ask the organization's steering rules before tool calls, prompts and stops\n memory: true # add your private memories (~/.heed/memory, local only) at session start\n skills:\n enforce: true # deny skills that are not on the organization's approved list\n strict: false # also require the local SKILL.md to match the approved revision\n whenUnknown: allow # no synced list (run npx heedvane skills sync) or listener down: allow or deny\n";
60
+ export declare const DEFAULT_HEED_YAML_TEMPLATE = "# ~/.heed/heed.yaml \u2014 Heedvane agent hooks control file (v1)\nschemaVersion: v1\n\n# Where managed scripts and spool live. Default below; one install per machine.\nroot: ~/.heed\n\n# The hook listener (Orca's pattern): loopback HTTP, token-authed, per-agent routes.\nlistener:\n port: 0 # 0 = pick a free port at first start, persist to endpoint.env\n spool: true # spool to disk when listener is unreachable (5 MB cap, 7-day TTL)\n\n# Which agents to manage. `auto` = every detected agent on the machine.\nagents: auto # or an explicit list: [claude, codex, warp]\n\n# Which integrations to install per agent where supported.\ninstall:\n hooks: true # native hook config injection (skipped where the agent has none)\n skill: true # heedvane-hooks skill bundle -> ~/.agents/skills/heedvane-hooks/\n mcp: true # heedvane MCP server registration (universal fallback, incl. Warp)\n\n# Session gating (Orca's env-gated dispatcher pattern).\ngating:\n mode: always # always = every session; pane = only sessions launched with HEED_SESSION env\n\n# The Heedvane backend (credentials: npx heedvane auth, stored in ~/.heed/credentials.json).\nheedvane:\n url: https://app.heedvane.com\n dataMode: look-but-dont-keep # what leaves this machine: look-but-dont-keep (checks read prompts and tool calls, keep only verdicts) or metrics-only (only counts and names)\n steering: true # ask the organization's steering rules before tool calls, prompts and stops\n memory: true # add your private memories (~/.heed/memory, local only) at session start\n skills:\n enforce: true # deny skills that are not on the organization's approved list\n strict: false # also require the local SKILL.md to match the approved revision\n whenUnknown: allow # no synced list (run npx heedvane skills sync) or listener down: allow or deny\n";
51
61
  /**
52
62
  * Parse and validate a heed.yaml document, returning the fully normalized file.
53
63
  * Throws {@link HeedFileError} (message prefixed "invalid heed.yaml:") on any problem:
@@ -55,6 +65,8 @@ export declare const DEFAULT_HEED_YAML_TEMPLATE = "# ~/.heed/heed.yaml \u2014 He
55
65
  * (top-level or nested), or wrong-typed values.
56
66
  */
57
67
  export declare function loadHeedFile(text: string): HeedFile;
68
+ /** `text` with each `[path, value]` written in place; the file's comments stay. Validate with {@link loadHeedFile}. */
69
+ export declare function withSettings(text: string, settings: ReadonlyArray<readonly [string[], unknown]>): string;
58
70
  /** `text` with `heedvane.url` set to `url` (`connect --server`); the file's comments stay. Validate with {@link loadHeedFile}. */
59
71
  export declare function withServerUrl(text: string, url: string): string;
60
72
  /** What the interactive installer asks: which agents, which integrations, and which Heedvane features. */
@@ -62,6 +74,7 @@ export interface InstallChoices {
62
74
  /** Chosen agent ids, or "auto" for every detected agent. */
63
75
  agents: "auto" | string[];
64
76
  install: HeedInstallConfig;
77
+ dataMode: DataMode;
65
78
  steering: boolean;
66
79
  memory: boolean;
67
80
  skillChecks: boolean;