heedvane 0.3.3 → 0.3.4

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.
Files changed (60) hide show
  1. package/README.md +41 -9
  2. package/dist/backend/credentials.d.ts +2 -0
  3. package/dist/backend/credentials.js +13 -0
  4. package/dist/backend/repo-remote.js +2 -1
  5. package/dist/backend/sign-in-change.js +3 -2
  6. package/dist/cli.d.ts +2 -1
  7. package/dist/cli.js +325 -118
  8. package/dist/clients/definitions.d.ts +7 -3
  9. package/dist/clients/definitions.js +27 -7
  10. package/dist/clients/hermes-plugin.d.ts +5 -20
  11. package/dist/clients/hermes-plugin.js +97 -71
  12. package/dist/clients/hook-files.d.ts +22 -0
  13. package/dist/clients/hook-files.js +39 -0
  14. package/dist/clients/pi-extension.d.ts +9 -0
  15. package/dist/clients/pi-extension.js +41 -0
  16. package/dist/commands/heedvane.js +18 -3
  17. package/dist/commands/mcp-stub.d.ts +1 -0
  18. package/dist/commands/mcp-stub.js +44 -0
  19. package/dist/dispatchers/posix.d.ts +16 -19
  20. package/dist/dispatchers/posix.js +71 -91
  21. package/dist/doctor.d.ts +2 -0
  22. package/dist/doctor.js +96 -28
  23. package/dist/guard.d.ts +73 -0
  24. package/dist/guard.js +302 -0
  25. package/dist/heed-file.d.ts +5 -2
  26. package/dist/heed-file.js +7 -7
  27. package/dist/hook-fallback.d.ts +73 -0
  28. package/dist/hook-fallback.js +61 -0
  29. package/dist/installer.d.ts +5 -5
  30. package/dist/installer.js +13 -12
  31. package/dist/listener/background.d.ts +48 -4
  32. package/dist/listener/background.js +153 -19
  33. package/dist/listener/endpoint.d.ts +26 -3
  34. package/dist/listener/endpoint.js +55 -10
  35. package/dist/listener/forward.d.ts +7 -1
  36. package/dist/listener/forward.js +40 -12
  37. package/dist/listener/serve.d.ts +37 -0
  38. package/dist/listener/serve.js +196 -32
  39. package/dist/listener/server.d.ts +9 -0
  40. package/dist/listener/server.js +59 -22
  41. package/dist/listener/spool.d.ts +2 -17
  42. package/dist/listener/spool.js +6 -3
  43. package/dist/mcp.d.ts +23 -4
  44. package/dist/mcp.js +124 -22
  45. package/dist/plan.d.ts +35 -6
  46. package/dist/plan.js +125 -40
  47. package/dist/render.js +2 -1
  48. package/dist/reply/harness-output.d.ts +9 -0
  49. package/dist/reply/harness-output.js +13 -0
  50. package/dist/skill/content.d.ts +2 -2
  51. package/dist/skill/content.js +6 -3
  52. package/dist/status.js +1 -1
  53. package/dist/steering/rules.js +4 -2
  54. package/dist/uninstall.d.ts +7 -2
  55. package/dist/uninstall.js +49 -44
  56. package/package.json +10 -3
  57. package/skills/heedvane-hooks/SKILL.md +3 -3
  58. package/skills/heedvane-hooks/references/events.md +3 -0
  59. package/dist/dispatchers/windows.d.ts +0 -15
  60. package/dist/dispatchers/windows.js +0 -50
package/dist/mcp.js CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Heedvane's MCP connection: the one MCP entry the installer registers, named `heedvane`, pointing at
3
- * the organization's remote server (`<server>/api/mcp/<org>`), in the shapes the dashboard's setup writes
4
- * (apps/web/src/lib/agent/mcp-setup.ts `HEEDVANE_MCP_SERVER_NAME`). The agent signs in to it in the
5
- * browser on first use.
3
+ * the organization's remote server (`<server>/api/mcp/<org>`). Remote entries take the shapes the dashboard's
4
+ * setup writes (apps/web/src/lib/agent/mcp-setup.ts `HEEDVANE_MCP_SERVER_NAME`); Claude Desktop's mcp-remote bridge
5
+ * (`bridgeEntry`, with the installing Node's folder) is this CLI's own. The agent signs in in the browser on first use.
6
6
  *
7
7
  * Identity: uninstall and detection recognize an entry by its name and shape together, never by a name
8
8
  * alone: today's connection, the local helper earlier versions registered (`npx -y heedvane mcp`, under any
@@ -10,14 +10,16 @@
10
10
  */
11
11
  /** The entry's name in every agent's MCP config. */
12
12
  export const HEEDVANE_SERVER_NAME = "heedvane";
13
+ /** Why metrics only registers no MCP connection: its tools send questions and repository names to Heedvane. */
14
+ export const MCP_OFF_METRICS_ONLY = "Heedvane's MCP connection: off under Metrics only, since its tools send your questions and repository names to Heedvane";
13
15
  /** What to do when no organization is known, so no MCP connection can be registered. */
14
- export const MCP_NEEDS_ORG = "Heedvane's MCP connection needs your organization: run the connect command from your dashboard's Settings (it has --org), or npx heedvane connect --org <id>";
16
+ export const MCP_NEEDS_ORG = "Heedvane's MCP connection needs your organization: run the connect command from your dashboard's Settings (it has --org), or npx -y heedvane connect --org <id>";
17
+ /** How to add the organization: `setup` cannot, since it compiles heed.yaml as it is. */
18
+ export const ORG_REMEDIATION = "run the connect command from your dashboard's Settings, or npx -y heedvane connect --org <organization id>";
15
19
  /** The connection's name before 0.3.0; connect and uninstall remove an entry under it. */
16
20
  const EARLIER_CONNECTOR_NAME = "heedvane-findings";
17
- /** Names development builds of the local helper ran under, matched in its command and arguments. */
18
- const IDENTITY_MARKERS = ["@heedvane/hooks", "heedvane-hooks"];
19
- /** The local helper's arguments (its command was always `npx`). */
20
- const HELPER_ARGS = "-y heedvane mcp";
21
+ /** The packages the local helper ran from: `heedvane` (0.1.0 to 0.2.1 on npm) and the development builds' names. */
22
+ const HELPER_PACKAGES = ["heedvane", "heedvane-hooks", "@heedvane/hooks"];
21
23
  /** Uninstall/detection marker for inline dispatcher snippets (hooks configs). */
22
24
  export const SNIPPET_MARKER = "heedvane-agent-hook-form";
23
25
  /** The connection's URL for an organization, as the dashboard builds it (`buildHeedvaneMcpUrl`). */
@@ -48,33 +50,130 @@ const CONNECTOR_ENTRY = {
48
50
  windsurf: (url) => ({ serverUrl: url }),
49
51
  // Copilot CLI lists the tools it may call; `*` is every tool.
50
52
  copilot_cli: (url) => ({ type: "http", url, tools: ["*"] }),
53
+ // Pi signs in on the first 401 (`pi mcp login heedvane`) and offers the tools to the model only with `direct` exposure.
54
+ pi: (url) => ({ url, exposure: "direct" }),
51
55
  // Claude Desktop's config file runs only local (stdio) servers, so the mcp-remote bridge carries the connection and
52
56
  // its browser sign-in. `@latest` keeps npx off an older cached copy (0.1.15 and earlier had CVE-2025-6514).
53
57
  claude_desktop: (url, nodeDir) => bridgeEntry(["-y", "mcp-remote@latest", url], nodeDir),
54
58
  };
55
59
  /**
56
- * A local server run by npx. With the installing Node's folder, npx is named by its absolute path and that folder
57
- * leads the server's PATH: Claude Desktop opened from Finder starts servers with launchd's PATH, which holds no
58
- * Node from nvm, and npx finds `node` through PATH.
60
+ * Finds npx when the agent starts the server: the installing Node's folder first, then the newest Node 22 or later
61
+ * from nvm, then Homebrew's and /usr/local's, so an upgrade that removes the installing folder does not break the entry.
62
+ * The folder found leads PATH, since npx finds `node` through PATH and Claude Desktop opened from Finder starts servers
63
+ * with launchd's PATH, which holds no Node from nvm or Homebrew.
59
64
  */
65
+ const BRIDGE_LAUNCHER = [
66
+ 'installed="$1"; shift',
67
+ 'newest=""; newest_rank=0',
68
+ 'for bin in "$HOME"/.nvm/versions/node/v*/bin; do',
69
+ ' version="${bin%/bin}"; version="${version##*/v}"; major="${version%%.*}"; rest="${version#*.}"; minor="${rest%%.*}"; patch="${rest#*.}"',
70
+ ' case "$major$minor$patch" in ""|*[!0-9]*) continue ;; esac',
71
+ " rank=$((major * 1000000 + minor * 1000 + patch))",
72
+ ' if [ "$major" -ge 22 ] && [ "$rank" -gt "$newest_rank" ] && [ -x "$bin/npx" ]; then newest="$bin"; newest_rank="$rank"; fi',
73
+ "done",
74
+ 'for dir in "$installed" "$newest" /opt/homebrew/bin /usr/local/bin; do',
75
+ ' if [ -n "$dir" ] && [ -x "$dir/npx" ]; then PATH="$dir:/usr/bin:/bin:/usr/sbin:/sbin" exec "$dir/npx" "$@"; fi',
76
+ "done",
77
+ 'echo "heedvane: no npx found for Heedvane\'s MCP connection; install Node.js 22 or later" >&2',
78
+ "exit 127",
79
+ ].join("\n");
80
+ const BRIDGE_NAME = "heedvane-bridge";
81
+ /** A local server run by npx: through {@link BRIDGE_LAUNCHER} given the installing Node's folder, else the bare npx (Windows). */
60
82
  function bridgeEntry(args, nodeDir) {
61
83
  if (nodeDir === undefined)
62
84
  return { command: "npx", args };
63
- return { command: `${nodeDir}/npx`, args, env: { PATH: [nodeDir, "/usr/bin", "/bin", "/usr/sbin", "/sbin"].join(":") } };
85
+ return { command: "/bin/sh", args: ["-c", BRIDGE_LAUNCHER, BRIDGE_NAME, nodeDir, ...args] };
86
+ }
87
+ /**
88
+ * The entry as drift checks compare it: a bridge's installing-Node folder is left out, since the launcher finds a Node
89
+ * without it, and connect run from another Node is no change to the connection.
90
+ */
91
+ export function comparableEntry(entry) {
92
+ if (entry === null || typeof entry !== "object")
93
+ return entry;
94
+ const { command, args } = entry;
95
+ if (command !== "/bin/sh" || !Array.isArray(args) || args[0] !== "-c" || args[2] !== BRIDGE_NAME)
96
+ return entry;
97
+ return { ...entry, args: [...args.slice(0, 3), ...args.slice(4)] };
64
98
  }
65
99
  /** The fields agents name a remote server's URL with. */
66
100
  const URL_FIELDS = ["url", "httpUrl", "serverUrl"];
101
+ /** The variable a software factory holds its organization API key in; the dashboard's factory commands use it. */
102
+ export const ORG_KEY_ENV = "HEEDVANE_ORG_KEY";
103
+ /**
104
+ * A software factory's connection: the agent sends the organization key from HEEDVANE_ORG_KEY as `Authorization: Bearer`
105
+ * through its own documented way to read a variable, so the key is never written to a file and no browser sign-in is
106
+ * needed. VS Code, Warp, Antigravity and Claude Desktop document none, so they keep the browser sign-in.
107
+ */
108
+ const KEY_CONNECTOR_ENTRY = {
109
+ // Claude Code, Gemini CLI, Qwen Code, Droid, Copilot CLI and Pi expand ${VAR} in header values.
110
+ claude: (url) => ({ type: "http", url, headers: { Authorization: `Bearer \${${ORG_KEY_ENV}}` } }),
111
+ gemini: (url) => ({ httpUrl: url, headers: { Authorization: `Bearer \${${ORG_KEY_ENV}}` } }),
112
+ "qwen-code": (url) => ({ httpUrl: url, headers: { Authorization: `Bearer \${${ORG_KEY_ENV}}` } }),
113
+ droid: (url) => ({ type: "http", url, headers: { Authorization: `Bearer \${${ORG_KEY_ENV}}` } }),
114
+ copilot_cli: (url) => ({ type: "http", url, headers: { Authorization: `Bearer \${${ORG_KEY_ENV}}` }, tools: ["*"] }),
115
+ pi: (url) => ({ url, headers: { Authorization: `Bearer \${${ORG_KEY_ENV}}` }, exposure: "direct" }),
116
+ // Codex and Kimi Code name the variable that holds the bearer token.
117
+ codex: (url) => ({ url, bearer_token_env_var: ORG_KEY_ENV }),
118
+ kimi: (url) => ({ url, bearerTokenEnvVar: ORG_KEY_ENV }),
119
+ // Cursor and Windsurf (Devin Desktop) read ${env:VAR}; OpenCode reads {env:VAR} and needs its own OAuth off.
120
+ cursor: (url) => ({ url, headers: { Authorization: `Bearer \${env:${ORG_KEY_ENV}}` } }),
121
+ windsurf: (url) => ({ serverUrl: url, headers: { Authorization: `Bearer \${env:${ORG_KEY_ENV}}` } }),
122
+ opencode: (url) => ({ type: "remote", url, enabled: true, oauth: false, headers: { Authorization: `Bearer {env:${ORG_KEY_ENV}}` } }),
123
+ };
124
+ /** The software-factory connection entry for this agent; undefined where it keeps the browser sign-in. */
125
+ export function keyConnectorEntry(clientId, url) {
126
+ return KEY_CONNECTOR_ENTRY[clientId]?.(url);
127
+ }
67
128
  /** The connection entry for this agent, or undefined when the installer does not register it there. `nodeDir` (POSIX only) is the installing Node's folder, for an entry that runs a local bridge. */
68
129
  export function connectorEntry(clientId, url, nodeDir) {
69
130
  return CONNECTOR_ENTRY[clientId]?.(url, nodeDir);
70
131
  }
71
- /** Does this entry point at a Heedvane MCP URL, under any agent's name for the field or as a bridge's argument? */
72
- function hasConnectorUrl(entry) {
132
+ /**
133
+ * The server and organization of a Heedvane MCP URL exactly as {@link connectorUrl} builds it: http(s)
134
+ * `<server>/api/mcp/<organization>`, where a server under a base path keeps that path, with no credentials, query or
135
+ * fragment. A user's own URL whose path merely contains `/api/mcp/` is none.
136
+ */
137
+ function parsedConnectorUrl(value) {
138
+ try {
139
+ const url = new URL(value);
140
+ if (url.protocol !== "http:" && url.protocol !== "https:")
141
+ return undefined;
142
+ const path = url.pathname.endsWith("/") ? url.pathname.slice(0, -1) : url.pathname;
143
+ const segments = path.split("/");
144
+ const encodedOrg = segments.pop();
145
+ if (!encodedOrg || segments.pop() !== "mcp" || segments.pop() !== "api")
146
+ return undefined;
147
+ const written = new URL(url);
148
+ written.pathname = path;
149
+ const server = `${url.origin}${segments.join("/")}`;
150
+ const org = decodeURIComponent(encodedOrg);
151
+ return new URL(connectorUrl(server, org)).href === written.href ? { server, org } : undefined;
152
+ }
153
+ catch {
154
+ return undefined; // not a URL, or an organization that does not decode: not one connectorUrl builds
155
+ }
156
+ }
157
+ /** The organization an entry's Heedvane MCP URL names at `server`, if it points there. */
158
+ export function connectorOrganization(entry, server) {
159
+ for (const value of entryUrls(entry)) {
160
+ const parsed = parsedConnectorUrl(value);
161
+ if (parsed !== undefined && new URL(connectorUrl(parsed.server, parsed.org)).href === new URL(connectorUrl(server, parsed.org)).href)
162
+ return parsed.org;
163
+ }
164
+ return undefined;
165
+ }
166
+ /** The strings an entry names a server URL with: any agent's URL field, or a bridge's arguments. */
167
+ export function entryUrls(entry) {
73
168
  if (entry === null || typeof entry !== "object")
74
- return false;
169
+ return [];
75
170
  const e = entry;
76
171
  const args = Array.isArray(e.args) ? e.args : [];
77
- return [...URL_FIELDS.map((field) => e[field]), ...args].some((url) => typeof url === "string" && url.includes("/api/mcp/"));
172
+ return [...URL_FIELDS.map((field) => e[field]), ...args].filter((value) => typeof value === "string");
173
+ }
174
+ /** Does this entry point at a Heedvane MCP URL, under any agent's name for the field or as a bridge's argument? */
175
+ function hasConnectorUrl(entry) {
176
+ return entryUrls(entry).some((url) => parsedConnectorUrl(url) !== undefined);
78
177
  }
79
178
  /** Is this today's connection entry? Its name and a Heedvane MCP URL both have to match. */
80
179
  export function isConnectorEntry(name, entry) {
@@ -84,6 +183,10 @@ export function isConnectorEntry(name, entry) {
84
183
  export function isLegacyEntry(name, entry) {
85
184
  return hasHelperCommand(entry) || (name === EARLIER_CONNECTOR_NAME && hasConnectorUrl(entry));
86
185
  }
186
+ /** Does this entry run the local helper an earlier version registered, under any name? */
187
+ export function isHelperEntry(_name, entry) {
188
+ return hasHelperCommand(entry);
189
+ }
87
190
  /** Is this server entry Heedvane's, today's or an earlier version's? Used by uninstall, detection and doctor. */
88
191
  export function isHeedvaneEntry(name, entry) {
89
192
  return isConnectorEntry(name, entry) || isLegacyEntry(name, entry);
@@ -102,13 +205,12 @@ export function mcpEntryNames(doc, containerKeys, match) {
102
205
  }
103
206
  return names;
104
207
  }
105
- /** Does this entry's command/args run the local helper (today's package name or a development build's)? */
208
+ /** Does this entry run the local helper, exactly as earlier versions wrote it: `npx -y <helper package> mcp`? */
106
209
  function hasHelperCommand(entry) {
107
210
  if (entry === null || typeof entry !== "object")
108
211
  return false;
109
- const e = entry;
110
- const command = typeof e.command === "string" ? e.command : "";
111
- const args = Array.isArray(e.args) ? e.args.filter((a) => typeof a === "string") : [];
112
- const haystack = [command, ...args].join(" ");
113
- return haystack.includes(HELPER_ARGS) || IDENTITY_MARKERS.some((m) => haystack.includes(m));
212
+ const { command, args } = entry;
213
+ if (command !== "npx" || !Array.isArray(args) || args.length !== 3)
214
+ return false;
215
+ return args[0] === "-y" && HELPER_PACKAGES.includes(String(args[1])) && args[2] === "mcp";
114
216
  }
package/dist/plan.d.ts CHANGED
@@ -2,7 +2,8 @@ import { type Capability, type Env, type ResolvedClient } from "./clients/defini
2
2
  import { type DocFormat } from "./config-doc.js";
3
3
  import type { HeedFile, HeedGatingConfig } from "./heed-file.js";
4
4
  export type ActionKind = "hooks" | "mcp" | "skill" | "skip";
5
- export type Operation = "create" | "update" | "unchanged" | "skip";
5
+ /** `remove`: the action only removes entries ({@link mcpLeftovers}); the plan shows it as removed. */
6
+ export type Operation = "create" | "update" | "unchanged" | "skip" | "remove";
6
7
  export interface PlannedAction {
7
8
  clientId: string;
8
9
  kind: ActionKind;
@@ -39,29 +40,49 @@ export interface ClientContext {
39
40
  env: Env;
40
41
  /** Heedvane's MCP connection URL (`connect --org`), registered where the agent takes it. */
41
42
  connector?: string;
43
+ /** The folder whose npx runs a local MCP bridge (`bridgeNodeDir`); without it, the bare `npx`. */
44
+ nodeDir?: string;
42
45
  }
43
46
  /**
44
47
  * Desired write targets per client × capability. This is the desired-state
45
48
  * compiler heed.yaml → concrete files; doctor reuses it via a callback so
46
- * it never needs apply.ts. `env` is accepted for API stability — the client's
47
- * config paths arrive already resolved against it.
49
+ * it never needs apply.ts. The client's config paths arrive already resolved
50
+ * against `env`; its HEEDVANE_ORG_KEY picks the software-factory MCP connection.
48
51
  */
49
52
  export declare function desiredEntriesFor(client: ResolvedClient, capability: Capability, context: ClientContext & {
50
53
  gating?: HeedGatingConfig["mode"];
51
54
  }): Desired[];
52
- /** The folder of the Node running this install: the hook dispatcher records it, and a local MCP bridge runs its npx. */
53
- export declare function installingNodeDir(): string;
54
- /** Heedvane's MCP connection URL for the organization heed.yaml names; none without one. */
55
+ /** The folder whose npx runs a local MCP bridge on `platform`; none on Windows, which keeps the bare `npx` its docs show (a `.cmd` path cannot be started without a shell). */
56
+ export declare function bridgeNodeDir(platform: string): string | undefined;
57
+ /**
58
+ * Why heed.yaml registers no MCP connection, the one owner of that decision: metrics only, whose promise the
59
+ * connection's tools would break (they send questions and repository names), or no organization. None when it does.
60
+ */
61
+ export declare function connectionOff(heedFile: HeedFile): "metrics-only" | "no-organization" | undefined;
62
+ /** Heedvane's MCP connection URL for the organization heed.yaml names, unless {@link connectionOff} says there is none. */
55
63
  export declare function heedvaneConnector(heedFile: HeedFile): string | undefined;
64
+ /** What to tell a person who asked for the MCP connection but gets none, and why; nothing when it is registered. */
65
+ export declare function connectionNote(heedFile: HeedFile): string | undefined;
66
+ /** The organization an earlier version's connection (`heedvane-findings`) names at `server`, so connect can replace it without --org. */
67
+ export declare function legacyOrganization(clients: readonly ResolvedClient[], server: string): string | undefined;
56
68
  /** The MCP containers a client's configs can hold our entries in (Codex's TOML and OpenCode's `mcp` beside the usual key). */
57
69
  export declare function mcpContainers(client: ResolvedClient): string[];
58
70
  /** The doc's parsed value, or null when the config is absent or unparseable — nothing marker-verified to remove. */
59
71
  export declare function parseClientDoc(path: string, format: DocFormat): Record<string, unknown> | null;
72
+ /** True when the client's MCP config holds Heedvane's connection, not only a user's entry that shares its name. */
73
+ export declare function hasOwnConnection(client: ResolvedClient): boolean;
74
+ /** Whether Heedvane's connection in this client's MCP config is in the key form (mcp.ts `keyConnectorEntry`), for whatever server and organization it names. */
75
+ export declare function sendsOrgKey(client: ResolvedClient): boolean;
60
76
  /** The first config of this client still holding an MCP entry an earlier version wrote, and those entries' names. */
61
77
  export declare function legacyMcpEntries(client: ResolvedClient): {
62
78
  path: string;
63
79
  names: string[];
64
80
  } | undefined;
81
+ /** The first config of this client holding any Heedvane MCP entry, today's or an earlier version's, and their names. */
82
+ export declare function heedvaneMcpEntries(client: ResolvedClient): {
83
+ path: string;
84
+ names: string[];
85
+ } | undefined;
65
86
  /**
66
87
  * The client whose hooks this one already runs, when that client's hooks are installed too, by this run or
67
88
  * an earlier one: installing both would run every event twice, recorded under two agents. Plan and doctor
@@ -77,6 +98,14 @@ interface AgentScope {
77
98
  }
78
99
  /** Whether a client is detected on this machine via its primary marker or any altMarkerPaths (e.g. GUI ADEs). */
79
100
  export declare function isClientDetected(client: ResolvedClient): boolean;
101
+ /** The client's native capability, or hooks written as whole files (definitions.ts `hookFiles`). */
102
+ export declare function supportsCapability(client: ResolvedClient, capability: "hooks" | "mcp" | "skill"): boolean;
103
+ /**
104
+ * The MCP entries connect removes: under metrics only every Heedvane entry, today's too; without an organization the
105
+ * local helper, which has no tools any more (a connection under its earlier name still works, and nothing would replace
106
+ * it); otherwise everything an earlier version registered.
107
+ */
108
+ export declare function mcpLeftovers(heedFile: HeedFile): (name: string, entry: unknown) => boolean;
80
109
  /**
81
110
  * The planner. For each client a run installs (installsClient), emit one action per enabled
82
111
  * capability the client supports — or an honest skip with a reason it doesn't.
package/dist/plan.js CHANGED
@@ -6,12 +6,12 @@
6
6
  * --dry-run printout and the apply step.
7
7
  */
8
8
  import { existsSync, readFileSync } from "node:fs";
9
- import { dirname, join } from "node:path";
9
+ import { basename, dirname, join } from "node:path";
10
10
  import { supportedClients, } from "./clients/definitions.js";
11
- import { hermesPluginFiles } from "./clients/hermes-plugin.js";
11
+ import { signsInWithOrgKey } from "./backend/credentials.js";
12
12
  import { entryValue, isOurHookGroup, parseDoc, readEntry, TOP_LEVEL, valuesEqual } from "./config-doc.js";
13
13
  import { genericSnippet } from "./dispatchers/snippets.js";
14
- import { HEEDVANE_SERVER_NAME, connectorEntry, connectorUrl, isLegacyEntry, mcpEntryNames } from "./mcp.js";
14
+ import { HEEDVANE_SERVER_NAME, MCP_NEEDS_ORG, MCP_OFF_METRICS_ONLY, ORG_KEY_ENV, comparableEntry, connectorEntry, connectorOrganization, connectorUrl, entryUrls, isConnectorEntry, isHeedvaneEntry, isHelperEntry, isLegacyEntry, keyConnectorEntry, mcpEntryNames } from "./mcp.js";
15
15
  import { canonicalSkillFiles, skillDigest } from "./skill/content.js";
16
16
  import { errorMessage } from "./guards.js";
17
17
  /** A client's primary hooks config target, resolved. */
@@ -27,18 +27,14 @@ function mcpTarget(client) {
27
27
  /**
28
28
  * Desired write targets per client × capability. This is the desired-state
29
29
  * compiler heed.yaml → concrete files; doctor reuses it via a callback so
30
- * it never needs apply.ts. `env` is accepted for API stability — the client's
31
- * config paths arrive already resolved against it.
30
+ * it never needs apply.ts. The client's config paths arrive already resolved
31
+ * against `env`; its HEEDVANE_ORG_KEY picks the software-factory MCP connection.
32
32
  */
33
33
  export function desiredEntriesFor(client, capability, context) {
34
- const home = context.home;
35
- void context.env;
36
34
  const targets = [];
37
35
  if (capability === "hooks") {
38
- if (client.id === "hermes") {
39
- // Hermes gets a native plugin, not a hooks.json entry.
40
- return hermesDesiredFiles(home, context.gating);
41
- }
36
+ if (client.hookFiles !== undefined)
37
+ return hookFilesDesired(client, client.hookFiles, context.gating);
42
38
  // Real harness shape: the `hooks` container is keyed by event name, and
43
39
  // we patch per-event so a user's own hook entries are never clobbered.
44
40
  const target = hooksTarget(client);
@@ -55,21 +51,66 @@ export function desiredEntriesFor(client, capability, context) {
55
51
  }
56
52
  if (capability === "mcp") {
57
53
  const target = mcpTarget(client);
58
- // Windows keeps the bare `npx` its docs show: a `.cmd` path cannot be started without a shell.
59
- const entry = context.connector === undefined ? undefined : connectorEntry(client.id, context.connector, process.platform === "win32" ? undefined : installingNodeDir());
54
+ const entry = context.connector === undefined ? undefined : connectionEntry(client.id, { ...context, connector: context.connector });
60
55
  if (target !== undefined && entry !== undefined)
61
56
  targets.push({ path: target.path, format: target.format, containerKey: client.mcpServersKey, entryName: HEEDVANE_SERVER_NAME, entry });
62
57
  return targets;
63
58
  }
64
59
  return targets; // skill has no per-client config entry
65
60
  }
61
+ /**
62
+ * On a software factory (HEEDVANE_ORG_KEY set, or an organization key saved with `auth org`), the entry that sends the
63
+ * key from HEEDVANE_ORG_KEY; else the one that signs in in the browser. The saved key counts too, so connect, auth org
64
+ * and doctor agree in whatever order and environment they run.
65
+ */
66
+ function connectionEntry(clientId, context) {
67
+ const isFactory = Boolean(context.env[ORG_KEY_ENV]?.trim()) || signsInWithOrgKey(join(context.home, ".heed"));
68
+ const factory = isFactory ? keyConnectorEntry(clientId, context.connector) : undefined;
69
+ return factory ?? connectorEntry(clientId, context.connector, context.nodeDir);
70
+ }
66
71
  /** The folder of the Node running this install: the hook dispatcher records it, and a local MCP bridge runs its npx. */
67
- export function installingNodeDir() {
72
+ function installingNodeDir() {
68
73
  return dirname(process.execPath);
69
74
  }
70
- /** Heedvane's MCP connection URL for the organization heed.yaml names; none without one. */
75
+ /** The folder whose npx runs a local MCP bridge on `platform`; none on Windows, which keeps the bare `npx` its docs show (a `.cmd` path cannot be started without a shell). */
76
+ export function bridgeNodeDir(platform) {
77
+ return platform === "win32" ? undefined : installingNodeDir();
78
+ }
79
+ /**
80
+ * Why heed.yaml registers no MCP connection, the one owner of that decision: metrics only, whose promise the
81
+ * connection's tools would break (they send questions and repository names), or no organization. None when it does.
82
+ */
83
+ export function connectionOff(heedFile) {
84
+ if (heedFile.heedvane.dataMode === "metrics-only")
85
+ return "metrics-only";
86
+ return heedFile.heedvane.org === undefined ? "no-organization" : undefined;
87
+ }
88
+ /** Heedvane's MCP connection URL for the organization heed.yaml names, unless {@link connectionOff} says there is none. */
71
89
  export function heedvaneConnector(heedFile) {
72
- return heedFile.heedvane.org === undefined ? undefined : connectorUrl(heedFile.heedvane.url, heedFile.heedvane.org);
90
+ const { url, org } = heedFile.heedvane;
91
+ return org === undefined || connectionOff(heedFile) !== undefined ? undefined : connectorUrl(url, org);
92
+ }
93
+ /** What to tell a person who asked for the MCP connection but gets none, and why; nothing when it is registered. */
94
+ export function connectionNote(heedFile) {
95
+ const off = heedFile.install.mcp ? connectionOff(heedFile) : undefined;
96
+ if (off === undefined)
97
+ return undefined;
98
+ return off === "metrics-only" ? MCP_OFF_METRICS_ONLY : MCP_NEEDS_ORG;
99
+ }
100
+ /** The organization an earlier version's connection (`heedvane-findings`) names at `server`, so connect can replace it without --org. */
101
+ export function legacyOrganization(clients, server) {
102
+ for (const client of clients) {
103
+ for (const target of client.configs) {
104
+ const doc = target.format === "yaml" ? null : parseClientDoc(target.path, target.format);
105
+ for (const qualified of doc === null ? [] : mcpEntryNames(doc, mcpContainers(client), isLegacyEntry)) {
106
+ const [container, name] = qualified.split("::");
107
+ const org = connectorOrganization(doc[container][name], server);
108
+ if (org !== undefined)
109
+ return org;
110
+ }
111
+ }
112
+ }
113
+ return undefined;
73
114
  }
74
115
  /** The MCP containers a client's configs can hold our entries in (Codex's TOML and OpenCode's `mcp` beside the usual key). */
75
116
  export function mcpContainers(client) {
@@ -84,11 +125,35 @@ export function parseClientDoc(path, format) {
84
125
  return null; // absent or unparseable — nothing marker-verified to remove
85
126
  }
86
127
  }
128
+ /** True when the client's MCP config holds Heedvane's connection, not only a user's entry that shares its name. */
129
+ export function hasOwnConnection(client) {
130
+ const target = mcpTarget(client);
131
+ const doc = target === undefined ? null : parseClientDoc(target.path, target.format);
132
+ return doc !== null && mcpEntryNames(doc, mcpContainers(client), isConnectorEntry).length > 0;
133
+ }
134
+ /** Whether Heedvane's connection in this client's MCP config is in the key form (mcp.ts `keyConnectorEntry`), for whatever server and organization it names. */
135
+ export function sendsOrgKey(client) {
136
+ const target = mcpTarget(client);
137
+ const doc = target === undefined ? null : parseClientDoc(target.path, target.format);
138
+ return doc !== null && mcpEntryNames(doc, mcpContainers(client), isConnectorEntry).some((qualified) => {
139
+ const [container, name] = qualified.split("::");
140
+ const entry = doc[container][name];
141
+ return entryUrls(entry).some((url) => valuesEqual(entry, keyConnectorEntry(client.id, url)));
142
+ });
143
+ }
87
144
  /** The first config of this client still holding an MCP entry an earlier version wrote, and those entries' names. */
88
145
  export function legacyMcpEntries(client) {
146
+ return mcpEntriesMatching(client, isLegacyEntry);
147
+ }
148
+ /** The first config of this client holding any Heedvane MCP entry, today's or an earlier version's, and their names. */
149
+ export function heedvaneMcpEntries(client) {
150
+ return mcpEntriesMatching(client, isHeedvaneEntry);
151
+ }
152
+ /** The first config of this client holding MCP entries `match` accepts, and their names. */
153
+ function mcpEntriesMatching(client, match) {
89
154
  for (const target of client.configs) {
90
155
  const doc = target.format === "yaml" ? null : parseClientDoc(target.path, target.format);
91
- const names = doc === null ? [] : mcpEntryNames(doc, mcpContainers(client), isLegacyEntry).map((qualified) => qualified.split("::")[1]);
156
+ const names = doc === null ? [] : mcpEntryNames(doc, mcpContainers(client), match).map((qualified) => qualified.split("::")[1]);
92
157
  if (names.length > 0)
93
158
  return { path: target.path, names };
94
159
  }
@@ -140,14 +205,15 @@ function installsClient(client, scope) {
140
205
  const { agents } = scope.heedFile;
141
206
  return isClientDetected(client) && (agents === "auto" || agents.includes(client.id));
142
207
  }
143
- function hermesDesiredFiles(home, gating) {
144
- // Hermes gets a native plugin (kind: standalone) instead of hooks.json.
145
- return hermesPluginFiles(gating).map((f) => ({
146
- path: join(home, ".hermes", "plugins", "heedvane-hooks", f.relativePath),
147
- format: "json", // plugin.yaml is written verbatim, not patched
208
+ /** The whole files a client's hooks are (Hermes' plugin, Pi's extension): written verbatim, not patched into a config. */
209
+ function hookFilesDesired(client, hookFiles, gating) {
210
+ const path = hookFiles.path(client.markerPath);
211
+ return hookFiles.files(gating ?? "always").map((file) => ({
212
+ path: join(path, file.relativePath),
213
+ format: "json",
148
214
  containerKey: "",
149
- entryName: f.relativePath,
150
- entry: f.content,
215
+ entryName: file.relativePath === "" ? basename(path) : file.relativePath,
216
+ entry: file.content,
151
217
  }));
152
218
  }
153
219
  /** Read-only inspection: what operation would satisfy the desired entry at its path? */
@@ -156,7 +222,7 @@ function inspect(d) {
156
222
  if (!existsSync(path))
157
223
  return { operation: "create", path };
158
224
  const backupPath = `${path}.heedvane.backup`;
159
- // Whole-file desired entries (hermes plugin files) compare byte-wise; they are not configs to parse.
225
+ // Whole-file desired entries (definitions.ts `hookFiles`) compare byte-wise; they are not configs to parse.
160
226
  if (containerKey === "") {
161
227
  return readFileSync(path, "utf8") === desired ? { operation: "unchanged", path } : { operation: "update", path, backupPath };
162
228
  }
@@ -175,7 +241,7 @@ function inspect(d) {
175
241
  const current = readEntry(doc, containerKey, entryName);
176
242
  if (current === undefined)
177
243
  return { operation: "create", path, backupPath };
178
- if (!valuesEqual(current, entryValue(containerKey, current, desired)))
244
+ if (!valuesEqual(comparableEntry(current), comparableEntry(entryValue(containerKey, current, desired))))
179
245
  return { operation: "update", path, backupPath };
180
246
  return { operation: "unchanged", path };
181
247
  }
@@ -213,11 +279,9 @@ function inspectSkill(home, client, desiredDigest) {
213
279
  const backupPath = join(dir, "SKILL.md.heedvane.backup");
214
280
  return same ? { operation: "unchanged", path: skillMd } : { operation: "update", path: skillMd, backupPath };
215
281
  }
216
- /** Hooks ride the client's native capability, or hermes' plugin-install channel. */
217
- function supportsCapability(client, capability) {
218
- if (client.capabilities.includes(capability))
219
- return true;
220
- return capability === "hooks" && client.id === "hermes" && client.capabilities.includes("plugin-install");
282
+ /** The client's native capability, or hooks written as whole files (definitions.ts `hookFiles`). */
283
+ export function supportsCapability(client, capability) {
284
+ return client.capabilities.includes(capability) || (capability === "hooks" && client.hookFiles !== undefined);
221
285
  }
222
286
  function skipAction(client, reason, path) {
223
287
  return { clientId: client.id, kind: "skip", operation: "skip", path: path ?? client.markerPath, reason };
@@ -241,10 +305,24 @@ function noHooksSkip(client) {
241
305
  function deferredHooksSkip(client, deferredTo) {
242
306
  return skipAction(client, `${client.id}: runs ${deferredTo.displayName}'s hooks, which are installed — a second install would run each event twice`, client.configs[0]?.path);
243
307
  }
244
- /** The MCP action: write the connection where it is wanted, and remove what an earlier version registered either way. */
245
- function planMcp(client, desireds) {
246
- const legacy = legacyMcpEntries(client);
247
- const removal = legacy === undefined ? undefined : { operation: "update", path: legacy.path, backupPath: `${legacy.path}.heedvane.backup`, reason: `replaces ${legacy.names.join(", ")}, which an earlier version registered` };
308
+ /**
309
+ * The MCP entries connect removes: under metrics only every Heedvane entry, today's too; without an organization the
310
+ * local helper, which has no tools any more (a connection under its earlier name still works, and nothing would replace
311
+ * it); otherwise everything an earlier version registered.
312
+ */
313
+ export function mcpLeftovers(heedFile) {
314
+ const off = connectionOff(heedFile);
315
+ if (off === "metrics-only")
316
+ return isHeedvaneEntry;
317
+ if (off === "no-organization")
318
+ return isHelperEntry;
319
+ return isLegacyEntry;
320
+ }
321
+ /** The MCP action: write the connection where it is wanted, and remove the {@link mcpLeftovers} either way. */
322
+ function planMcp(client, desireds, heedFile) {
323
+ const off = connectionOff(heedFile);
324
+ const leftover = mcpEntriesMatching(client, mcpLeftovers(heedFile));
325
+ const removal = leftover === undefined ? undefined : { operation: "remove", path: leftover.path, backupPath: `${leftover.path}.heedvane.backup`, reason: leftoverReason(leftover.names, off) };
248
326
  if (desireds.length === 0)
249
327
  return removal === undefined ? null : { clientId: client.id, kind: "mcp", ...removal };
250
328
  const r = inspectMcp(desireds);
@@ -252,6 +330,13 @@ function planMcp(client, desireds) {
252
330
  return { clientId: client.id, kind: "mcp", ...removal };
253
331
  return { clientId: client.id, kind: "mcp", operation: r.operation, path: r.path ?? desireds[0].path, backupPath: r.backupPath, reason: r.reason };
254
332
  }
333
+ function leftoverReason(names, off) {
334
+ if (off === "metrics-only")
335
+ return `removes ${names.join(", ")}: ${MCP_OFF_METRICS_ONLY}`;
336
+ if (off === "no-organization")
337
+ return `removes ${names.join(", ")}, the local helper an earlier version registered; ${MCP_NEEDS_ORG}`;
338
+ return `replaces ${names.join(", ")}, which an earlier version registered`;
339
+ }
255
340
  /** One client × one capability → its planned action, or null when nothing to plan. */
256
341
  function planCapability(client, capability, ctx) {
257
342
  if (!ctx.heedFile.install[capability])
@@ -265,12 +350,12 @@ function planCapability(client, capability, ctx) {
265
350
  const r = inspectSkill(ctx.home, client, ctx.desiredSkillDigest);
266
351
  return { clientId: client.id, kind: "skill", operation: r.operation, path: r.path ?? join(ctx.home, ".agents", "skills", "heedvane-hooks", "SKILL.md"), backupPath: r.backupPath };
267
352
  }
268
- const desireds = desiredEntriesFor(client, capability, { home: ctx.home, env: ctx.env, gating: ctx.heedFile.gating.mode, connector: heedvaneConnector(ctx.heedFile) });
353
+ const desireds = desiredEntriesFor(client, capability, { home: ctx.home, env: ctx.env, gating: ctx.heedFile.gating.mode, connector: heedvaneConnector(ctx.heedFile), nodeDir: ctx.nodeDir });
269
354
  if (capability === "mcp")
270
- return planMcp(client, desireds);
355
+ return planMcp(client, desireds, ctx.heedFile);
271
356
  if (desireds.length === 0)
272
357
  return null;
273
- // hooks (including the hermes whole-file plugin shape)
358
+ // hooks (including whole hook files)
274
359
  const primary = desireds[0];
275
360
  const worst = worstHooksResult(desireds);
276
361
  return {
@@ -290,7 +375,7 @@ export function planActions(input) {
290
375
  const { home, platform, env, heedFile } = input;
291
376
  const actions = [];
292
377
  const desiredSkillDigest = input.skillDigest ?? skillDigest(canonicalSkillFiles());
293
- const ctx = { home, env, heedFile, forced: new Set(input.forced), desiredSkillDigest };
378
+ const ctx = { home, env, heedFile, forced: new Set(input.forced), desiredSkillDigest, nodeDir: bridgeNodeDir(platform) };
294
379
  for (const client of supportedClients({ home, platform: platform, env })) {
295
380
  if (!installsClient(client, ctx)) {
296
381
  // Listed in heed.yaml but not on this machine.
@@ -310,6 +395,6 @@ export function planActions(input) {
310
395
  /** Count of distinct agents that would receive a write for a kind. */
311
396
  export function wouldInstallCount(actions, kind) {
312
397
  return new Set(actions
313
- .filter((a) => a.kind === kind && (a.operation === "create" || a.operation === "update"))
398
+ .filter((a) => a.kind === kind && (a.operation === "create" || a.operation === "update" || a.operation === "remove"))
314
399
  .map((a) => a.clientId)).size;
315
400
  }
package/dist/render.js CHANGED
@@ -8,7 +8,7 @@ import { connectorEntry } from "./mcp.js";
8
8
  /** What Heedvane installs into an agent, e.g. "hooks + MCP + skill". */
9
9
  export function capabilitySummary(client) {
10
10
  const parts = [];
11
- if (client.capabilities.includes("hooks"))
11
+ if (client.capabilities.includes("hooks") || client.hookFiles !== undefined)
12
12
  parts.push("hooks");
13
13
  // Heedvane's MCP connection goes only into agents with a remote-server shape (not Claude Desktop).
14
14
  if (client.capabilities.includes("mcp") && connectorEntry(client.id, "") !== undefined)
@@ -37,6 +37,7 @@ const VERB = {
37
37
  create: "added",
38
38
  update: "updated",
39
39
  unchanged: "already current",
40
+ remove: "removed",
40
41
  skip: "skipped",
41
42
  };
42
43
  function kindLabel(kind) {
@@ -42,9 +42,18 @@ export type HarnessOutput = Record<string, never> | {
42
42
  } | {
43
43
  action: "block";
44
44
  message: string;
45
+ } | {
46
+ block: true;
47
+ reason: string;
45
48
  } | {
46
49
  context: string;
47
50
  };
51
+ /**
52
+ * The top-level output field each agent shows the person without steering (checked 2026-10-20): Claude Code's, Codex's
53
+ * (learn.chatgpt.com/docs/hooks) and Gemini CLI's (geminicli.com/docs/hooks/reference) `systemMessage`. Cursor shows
54
+ * `user_message` only with a denial, and Hermes' plugin hooks have no such field, so they have none here.
55
+ */
56
+ export declare const USER_NOTICE_FIELD: Readonly<Record<string, string>>;
48
57
  /** The events a stop block or halt applies to. */
49
58
  export declare const STOP_EVENTS: ReadonlySet<string>;
50
59
  /**