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/dist/plan.d.ts CHANGED
@@ -21,11 +21,6 @@ export interface PlanInput {
21
21
  /** Desired skill digest; defaults to the canonical bundled content. */
22
22
  skillDigest?: string;
23
23
  }
24
- /** Our MCP stdio registration entry (unscoped package per the plan's decision). */
25
- export declare function mcpEntry(): {
26
- command: string;
27
- args: string[];
28
- };
29
24
  interface Desired {
30
25
  path: string;
31
26
  format: DocFormat;
@@ -42,6 +37,8 @@ export declare function hooksTarget(client: ResolvedClient): {
42
37
  export interface ClientContext {
43
38
  home: string;
44
39
  env: Env;
40
+ /** Heedvane's MCP connection URL (`connect --org`), registered where the agent takes it. */
41
+ connector?: string;
45
42
  }
46
43
  /**
47
44
  * Desired write targets per client × capability. This is the desired-state
@@ -52,6 +49,19 @@ export interface ClientContext {
52
49
  export declare function desiredEntriesFor(client: ResolvedClient, capability: Capability, context: ClientContext & {
53
50
  gating?: HeedGatingConfig["mode"];
54
51
  }): 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
+ export declare function heedvaneConnector(heedFile: HeedFile): string | undefined;
56
+ /** The MCP containers a client's configs can hold our entries in (Codex's TOML and OpenCode's `mcp` beside the usual key). */
57
+ export declare function mcpContainers(client: ResolvedClient): string[];
58
+ /** The doc's parsed value, or null when the config is absent or unparseable — nothing marker-verified to remove. */
59
+ export declare function parseClientDoc(path: string, format: DocFormat): Record<string, unknown> | null;
60
+ /** The first config of this client still holding an MCP entry an earlier version wrote, and those entries' names. */
61
+ export declare function legacyMcpEntries(client: ResolvedClient): {
62
+ path: string;
63
+ names: string[];
64
+ } | undefined;
55
65
  /**
56
66
  * The client whose hooks this one already runs, when that client's hooks are installed too, by this run or
57
67
  * an earlier one: installing both would run every event twice, recorded under two agents. Plan and doctor
package/dist/plan.js CHANGED
@@ -6,18 +6,14 @@
6
6
  * --dry-run printout and the apply step.
7
7
  */
8
8
  import { existsSync, readFileSync } from "node:fs";
9
- import { join } from "node:path";
9
+ import { dirname, join } from "node:path";
10
10
  import { supportedClients, } from "./clients/definitions.js";
11
11
  import { hermesPluginFiles } from "./clients/hermes-plugin.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 { MCP_ENTRY_ARGS, desiredServer } from "./mcp.js";
14
+ import { HEEDVANE_SERVER_NAME, connectorEntry, connectorUrl, isLegacyEntry, mcpEntryNames } from "./mcp.js";
15
15
  import { canonicalSkillFiles, skillDigest } from "./skill/content.js";
16
16
  import { errorMessage } from "./guards.js";
17
- /** Our MCP stdio registration entry (unscoped package per the plan's decision). */
18
- export function mcpEntry() {
19
- return { command: "npx", args: [...MCP_ENTRY_ARGS] };
20
- }
21
17
  /** A client's primary hooks config target, resolved. */
22
18
  export function hooksTarget(client) {
23
19
  const target = client.configs.find((t) => t.purpose === "hooks") ?? client.configs[0];
@@ -59,14 +55,45 @@ export function desiredEntriesFor(client, capability, context) {
59
55
  }
60
56
  if (capability === "mcp") {
61
57
  const target = mcpTarget(client);
62
- if (!target)
63
- return targets;
64
- // Codex (TOML) carries an explicit generous startup timeout for npx cold starts.
65
- targets.push({ path: target.path, format: target.format, containerKey: client.mcpServersKey, entryName: "heedvane", entry: desiredServer(client.id) });
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());
60
+ if (target !== undefined && entry !== undefined)
61
+ targets.push({ path: target.path, format: target.format, containerKey: client.mcpServersKey, entryName: HEEDVANE_SERVER_NAME, entry });
66
62
  return targets;
67
63
  }
68
64
  return targets; // skill has no per-client config entry
69
65
  }
66
+ /** 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() {
68
+ return dirname(process.execPath);
69
+ }
70
+ /** Heedvane's MCP connection URL for the organization heed.yaml names; none without one. */
71
+ export function heedvaneConnector(heedFile) {
72
+ return heedFile.heedvane.org === undefined ? undefined : connectorUrl(heedFile.heedvane.url, heedFile.heedvane.org);
73
+ }
74
+ /** The MCP containers a client's configs can hold our entries in (Codex's TOML and OpenCode's `mcp` beside the usual key). */
75
+ export function mcpContainers(client) {
76
+ return [client.mcpServersKey, "mcp_servers", "mcp"];
77
+ }
78
+ /** The doc's parsed value, or null when the config is absent or unparseable — nothing marker-verified to remove. */
79
+ export function parseClientDoc(path, format) {
80
+ try {
81
+ return parseDoc(readFileSync(path, "utf8"), format).value;
82
+ }
83
+ catch {
84
+ return null; // absent or unparseable — nothing marker-verified to remove
85
+ }
86
+ }
87
+ /** The first config of this client still holding an MCP entry an earlier version wrote, and those entries' names. */
88
+ export function legacyMcpEntries(client) {
89
+ for (const target of client.configs) {
90
+ 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]);
92
+ if (names.length > 0)
93
+ return { path: target.path, names };
94
+ }
95
+ return undefined;
96
+ }
70
97
  /** Our one hook for an event, in the shape the client's hooks file holds it. */
71
98
  function hookEntry(client) {
72
99
  const command = genericSnippet(client.id);
@@ -152,8 +179,7 @@ function inspect(d) {
152
179
  return { operation: "update", path, backupPath };
153
180
  return { operation: "unchanged", path };
154
181
  }
155
- function inspectMcp(client, home, env) {
156
- const desireds = desiredEntriesFor(client, "mcp", { home, env });
182
+ function inspectMcp(desireds) {
157
183
  // A client with multiple MCP targets (codex hooks.json + config.toml) is
158
184
  // only "aligned" when every target is; the action reports the worst path.
159
185
  let worst = { operation: "unchanged" };
@@ -210,11 +236,22 @@ function worstHooksResult(desireds) {
210
236
  return worst;
211
237
  }
212
238
  function noHooksSkip(client) {
213
- return skipAction(client, `${client.id}: no native hooks — policies arrive via MCP`, client.configs[0]?.path ?? client.markerPath);
239
+ return skipAction(client, `${client.id}: no native hooks`, client.configs[0]?.path ?? client.markerPath);
214
240
  }
215
241
  function deferredHooksSkip(client, deferredTo) {
216
242
  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);
217
243
  }
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` };
248
+ if (desireds.length === 0)
249
+ return removal === undefined ? null : { clientId: client.id, kind: "mcp", ...removal };
250
+ const r = inspectMcp(desireds);
251
+ if (r.operation === "unchanged" && removal !== undefined)
252
+ return { clientId: client.id, kind: "mcp", ...removal };
253
+ return { clientId: client.id, kind: "mcp", operation: r.operation, path: r.path ?? desireds[0].path, backupPath: r.backupPath, reason: r.reason };
254
+ }
218
255
  /** One client × one capability → its planned action, or null when nothing to plan. */
219
256
  function planCapability(client, capability, ctx) {
220
257
  if (!ctx.heedFile.install[capability])
@@ -228,21 +265,11 @@ function planCapability(client, capability, ctx) {
228
265
  const r = inspectSkill(ctx.home, client, ctx.desiredSkillDigest);
229
266
  return { clientId: client.id, kind: "skill", operation: r.operation, path: r.path ?? join(ctx.home, ".agents", "skills", "heedvane-hooks", "SKILL.md"), backupPath: r.backupPath };
230
267
  }
231
- const desireds = desiredEntriesFor(client, capability, { home: ctx.home, env: ctx.env, gating: ctx.heedFile.gating.mode });
268
+ const desireds = desiredEntriesFor(client, capability, { home: ctx.home, env: ctx.env, gating: ctx.heedFile.gating.mode, connector: heedvaneConnector(ctx.heedFile) });
269
+ if (capability === "mcp")
270
+ return planMcp(client, desireds);
232
271
  if (desireds.length === 0)
233
272
  return null;
234
- if (capability === "mcp") {
235
- const r = inspectMcp(client, ctx.home, ctx.env);
236
- const primary = desireds[0];
237
- return {
238
- clientId: client.id,
239
- kind: "mcp",
240
- operation: r.operation,
241
- path: r.path ?? primary.path,
242
- backupPath: r.backupPath,
243
- reason: r.reason,
244
- };
245
- }
246
273
  // hooks (including the hermes whole-file plugin shape)
247
274
  const primary = desireds[0];
248
275
  const worst = worstHooksResult(desireds);
package/dist/render.js CHANGED
@@ -4,21 +4,20 @@
4
4
  */
5
5
  import { basename } from "node:path";
6
6
  import { wouldInstallCount } from "./plan.js";
7
+ import { connectorEntry } from "./mcp.js";
7
8
  /** What Heedvane installs into an agent, e.g. "hooks + MCP + skill". */
8
9
  export function capabilitySummary(client) {
9
10
  const parts = [];
10
11
  if (client.capabilities.includes("hooks"))
11
12
  parts.push("hooks");
12
- if (client.capabilities.includes("mcp"))
13
+ // Heedvane's MCP connection goes only into agents with a remote-server shape (not Claude Desktop).
14
+ if (client.capabilities.includes("mcp") && connectorEntry(client.id, "") !== undefined)
13
15
  parts.push("MCP");
14
16
  if (client.capabilities.includes("skill"))
15
17
  parts.push("skill");
16
- const hookless = client.events.length === 0 && client.capabilities.includes("mcp");
17
- if (hookless) {
18
- // e.g. Warp: "MCP + skill — no native hooks; policies via MCP"
19
- return `${parts.join(" + ")} — no native hooks; policies via MCP`;
20
- }
21
- return parts.join(" + ");
18
+ const summary = parts.join(" + ");
19
+ // e.g. Warp: "MCP + skill — no native hooks"
20
+ return client.events.length === 0 && client.capabilities.includes("mcp") ? `${summary} — no native hooks` : summary;
22
21
  }
23
22
  export function renderDetection(rows) {
24
23
  const lines = [" Detected on this machine:"];
@@ -24,7 +24,8 @@ export async function replyFor(envelope, ctx, extras = {}) {
24
24
  const startBlock = await sessionStartBlock(request, ctx, { policy, teamMemory: extras.teamMemory });
25
25
  const steered = steeringBackend(envelope, ctx, policy);
26
26
  const local = steered === null ? ALLOW : localVerdict(request, policy?.rules ?? []);
27
- if (steered !== null)
27
+ // Metrics only: the event's content never leaves this machine, so only the fixed rules above judge it.
28
+ if (steered !== null && ctx.config.dataMode !== "metrics-only")
28
29
  judgeInBackground(request, { backend: steered, enforcedLocally: new Set((local.enforcements ?? []).map((enforced) => enforced.ruleId)) }, ctx);
29
30
  reportDeliveries(envelope, local.enforcements ?? [], { status: "enforced", delivery: "local", report: ctx.report });
30
31
  const delivered = steered === null ? ALLOW : deliverViolations(envelope, ctx);
@@ -4,8 +4,8 @@
4
4
  * runtime). The physical copies in /skills/heedvane-hooks/ must stay
5
5
  * byte-identical — tests assert that.
6
6
  */
7
- export declare const SKILL_MD = "---\nname: heedvane-hooks\ndescription: Use when a user asks about heedvane hooks status, events, or how their coding agents are monitored \u2014 reports live hook installation state and the canonical event contract.\n---\n\n# heedvane-hooks\n\n## Overview\n\nHeedvane hooks give coding agents (Claude Code, Codex, Gemini CLI, Cursor, Warp, OpenCode, Hermes) a shared steering/guardrail/analysis channel without touching their internals. Three strictly separated layers:\n\n1. **Hook contract** \u2014 a harness-neutral event taxonomy (`pre_tool_use`, `post_tool_use`, \u2026), one JSON envelope, one normalizer. See `references/events.md`.\n2. **Per-agent adapters** \u2014 native hook config injection where the harness has hooks (Claude, Codex, Gemini, Cursor); MCP registration as the universal fallback (Warp has no native hooks \u2014 policy arrives via MCP); an honest skip where neither exists.\n3. **Setup mechanism** \u2014 `heedvane-hook` CLI: `init` / `setup` / `doctor` / `uninstall` with backup \u2192 atomic write \u2192 read-back verify. Human intent lives in `~/.heed/heed.yaml`; machine state in `~/.heed/state.json`.\n\nWhich sessions run the hooks follows `heed.yaml` `gating.mode`: with `always` (the default) every session is governed, with `pane` only sessions launched with `HEED_SESSION` set. The dispatcher script (for Hermes, its plugin) decides, from the mode `setup` bakes into it, and always exits 0. Observability never blocks the agent.\n\n## When to Use\n\n- \"Is my heedvane install healthy?\" / \"why aren't hooks firing?\"\n- \"What events does heedvane capture?\" / \"what shape is the payload?\"\n- \"Which of my agents are monitored?\"\n- After the user edits `~/.heed/heed.yaml` and wants the change applied/verified.\n\nDo **not** use this skill to author policies \u2014 an organization's policies are defined and assigned on the Heedvane server, never in `heed.yaml`.\n\n## Checking status\n\n1. Run the doctor (scoped, exit-code-carrying):\n\n ```sh\n npx heedvane doctor --json\n ```\n\n Or unscoped plain text: `npx heedvane doctor`. Every failing check carries a `remediation` string \u2014 surface it verbatim.\n\n2. Read the control file \u2014 it is the single source of truth for intent:\n\n ```sh\n cat ~/.heed/heed.yaml\n ```\n\n `agents: auto` manages every detected agent; an explicit list filters. `install.hooks/skill/mcp` toggles channels per agent.\n\n3. **Never edit agent configs by hand.** `~/.claude/settings.json`, `~/.codex/hooks.json`, etc. are compiled outputs. Edit `heed.yaml`, then re-run:\n\n ```sh\n npx heedvane setup\n ```\n\n4. Machine state (installed digests, endpoint, protocol version) is `~/.heed/state.json` \u2014 CLI-owned, never hand-edit.\n\n## Event contract summary\n\n- Canonical events (v1, snake_case): `session_start`, `session_end`, `user_prompt_submit`, `pre_tool_use`, `post_tool_use`, `post_tool_use_failure`, `permission_request`, `stop`, `stop_failure`, `subagent_start`, `subagent_stop`, `notification`, `pre_compact`. Unknown harness events pass through as `unknown_event` (never dropped).\n- Envelope: `{ \"v\": 1, \"event\", \"agent\", \"session\", \"pane\", \"receivedAt\", \"payload\": { \u2026 } }` \u2014 strings capped at 8 KB, nodes at 500.\n- Per-harness mapping (Claude's PascalCase `:class` names \u2192 canonical; Codex/Gemini are already snake_case) is tabled in `references/events.md`. Read that file before reasoning about any specific payload.\n\n## Steering, approved skills and memory\n\nWith the listener running (`npx heedvane listener start`) and a sign-in (`npx heedvane login`; CI and headless machines: `npx heedvane auth org` with the key on stdin), the listener answers each hook with a verdict instead of only recording it:\n\n- **Steering:** every rule of the organization that matches is enforced: a tool call is denied or a stop is blocked, with the rule's message as the reason. A session's stops are blocked at most four times in a row; the next one ends the turn so the person sees why, and no later stop of that session is blocked. No hook waits on Heedvane. Command and path rules are synced to the listener at each session start and checked locally, so they hold even when Heedvane is slow or down. Heedvane judges every event in the background, and a rule it finds violated blocks the agent's next tool call or stop.\n- **Organization instructions:** the organization's non-negotiable rules are added as context at session start or with each prompt. They cannot be turned off, and the rules named with them enforce them.\n- **Approved skills:** `npx heedvane skills sync` installs the organization's approved skills into `~/.agents/skills` and `~/.claude/skills` and caches the list in `~/.heed/approved-skills.json`. Using any other skill (the Skill tool, or reading its `SKILL.md`) is denied; writing a skill is not. `heed.yaml` `heedvane.skills` sets `enforce`, `strict` (also compare file hashes) and `whenUnknown` (what to do before the first sync or while the listener is down).\n- **Private memory** (local only, never sent to Heedvane): at session start the hook adds `~/.heed/memory/MEMORY.md` (every repository) and `~/.heed/memory/<owner>/<name>.md` (this repository) as context, so every agent sees them. To save a private memory, append a short line to the right file.\n\n- **Team memory:** at session start the hook adds a table of contents of this repository's team memory (folders and titles, never the text). Browse and read it with `npx heedvane memory list [folder]`, `npx heedvane memory show <id>` and `npx heedvane memory search \"<question>\" [--folder <folder>]`.\n\nBackend failures never block the agent: without Heedvane's answer, the locally checked rules decide, and every enforcement is reported to Heedvane.\n\n## Common Pitfalls\n\n- **Do not rewrite `~/.claude/settings.json` (or any agent config) manually while hooks are installed.** Hand edits fight the compiler; the next `setup` will either back up your change or report the file invalid. Edit `heed.yaml` instead.\n- **Spool files are diagnostics, not policy.** When the listener is unreachable, hook payloads spool to `~/.heed/spool/` (5 MB cap, 7-day TTL) so nothing is lost \u2014 but spool presence is not enforcement, and clearing it loses observability history only.\n- Warp gets policies **via MCP only** \u2014 that is the designed happy path, not an error to fix.\n- If `doctor` reports `invalid` on a config, the file does not parse; heedvane never overwrites malformed configs. Fix or move the file aside yourself, then re-run.\n- Re-running `setup` after no changes reports \"already current\" \u2014 idempotence is a feature, not a stalled run.\n\n## Verification Checklist\n\n- [ ] `npx heedvane doctor` exits 0 and every check line shows ok\n- [ ] `~/.heed/heed.yaml` parses and says `schemaVersion: v1`\n- [ ] `~/.heed/state.json` skill digest matches the installed skill\n- [ ] A new agent session (launched with `HEED_SESSION` set, or any session with `gating.mode: always`) produces events in the listener log or `~/.heed/spool/`\n- [ ] Backups exist: `<config>.heedvane.backup` next to every patched config\n- [ ] `/heedvane-status` in a fresh agent session reports the same agents as `doctor`\n";
8
- export declare const EVENTS_MD = "# Heedvane event contract (v1)\n\nHarness-neutral taxonomy + JSON envelope. One normalizer maps every\nper-source payload into the canonical shape; unknown events pass through\nas `unknown_event` rather than being dropped.\n\n## Canonical events (v1)\n\n| Event | Fires when |\n| --- | --- |\n| `session_start` | A managed agent session begins |\n| `session_end` | The session ends (clean or crash) |\n| `user_prompt_submit` | The user submits a prompt |\n| `pre_tool_use` | Before a tool call executes |\n| `post_tool_use` | After a tool call returns |\n| `post_tool_use_failure` | A tool call raises/errors |\n| `permission_request` | The agent asks for approval |\n| `stop` | The agent turn completes |\n| `stop_failure` | The turn aborts with an error |\n| `subagent_start` | A subagent/task tool spawns |\n| `subagent_stop` | The subagent finishes |\n| `notification` | Harness notification (idle, attention, updates) |\n| `pre_compact` | Before the harness compacts the conversation |\n\n## Envelope\n\n```json\n{\n \"v\": 1,\n \"event\": \"pre_tool_use\",\n \"agent\": \"claude\",\n \"session\": \"sess_01HQ\u2026\",\n \"pane\": \"iterm:tab-3\",\n \"receivedAt\": \"2026-10-10T12:00:00.000Z\",\n \"payload\": { \"tool\": \"Bash\", \"input\": { \"command\": \"ls\" } }\n}\n```\n\nCaps (Orca's numbers): strings \u2264 8 KB, \u2264 500 nodes per payload; oversized\nfields are truncated at ingest, never dropped.\n\n## Per-harness mapping\n\n| Canonical | Claude Code / Codex | Gemini CLI | Cursor | Hermes plugin |\n| --- | --- | --- | --- | --- |\n| `session_start` | `SessionStart` | `SessionStart` | `sessionStart` | `on_session_start` |\n| `session_end` | `SessionEnd` | `SessionEnd` | `sessionEnd` | `on_session_finalize` |\n| `user_prompt_submit` | `UserPromptSubmit` | `BeforeAgent` | `beforeSubmitPrompt` | `pre_llm_call` |\n| `pre_tool_use` | `PreToolUse` | `BeforeTool` | `preToolUse` | `pre_tool_call` |\n| `post_tool_use` | `PostToolUse` | `AfterTool` | `postToolUse` | `post_tool_call` |\n| `post_tool_use_failure` | `PostToolUseFailure` | \u2014 | \u2014 | \u2014 |\n| `permission_request` | `PermissionRequest` | \u2014 | \u2014 | `pre_approval_request` |\n| `stop` | `Stop` | `AfterAgent` | `stop` | \u2014 |\n| `stop_failure` | `StopFailure` | \u2014 | \u2014 | \u2014 |\n| `subagent_start` | `SubagentStart` | \u2014 | \u2014 | \u2014 |\n| `subagent_stop` | `SubagentStop` | \u2014 | `subagentStop` | \u2014 |\n| `notification` | `Notification` | `Notification` | \u2014 | \u2014 |\n| `pre_compact` | `PreCompact` | `PreCompress` | \u2014 | \u2014 |\n\nHermes' `on_session_end` fires once per turn despite its name, so it passes\nthrough as `unknown_event`, as does `post_approval_response`. Cursor's session\nis its `conversation_id`.\n\nWarp emits no native events at all \u2014 it has no hooks. Policy for Warp\narrives via MCP tool calls; nothing in this table applies to it.\n\nPayload keys per source keep their harness names inside `payload` (e.g.\nClaude's `tool_input`, Codex's `tool_input`) after normalization of the\nenvelope itself; the normalizer never invents fields.\n";
7
+ export declare const SKILL_MD = "---\nname: heedvane-hooks\ndescription: Use when a user asks about heedvane hooks status, events, or how their coding agents are monitored \u2014 reports live hook installation state and the canonical event contract.\n---\n\n# heedvane-hooks\n\n## Overview\n\nHeedvane hooks give coding agents (Claude Code, Codex, Gemini CLI, Cursor, Warp, OpenCode, Hermes) a shared steering/guardrail/analysis channel without touching their internals. Three strictly separated layers:\n\n1. **Hook contract** \u2014 a harness-neutral event taxonomy (`pre_tool_use`, `post_tool_use`, \u2026), one JSON envelope, one normalizer. See `references/events.md`.\n2. **Per-agent adapters** \u2014 native hook config injection where the harness has hooks (Claude, Codex, Gemini, Cursor); Heedvane's MCP connection in every agent (Claude Desktop through the mcp-remote bridge); an honest skip where an agent has no hooks (Warp).\n3. **Setup mechanism** \u2014 `heedvane-hook` CLI: `init` / `setup` / `doctor` / `uninstall` with backup \u2192 atomic write \u2192 read-back verify. Human intent lives in `~/.heed/heed.yaml`; machine state in `~/.heed/state.json`.\n\nWhich sessions run the hooks follows `heed.yaml` `gating.mode`: with `always` (the default) every session is governed, with `pane` only sessions launched with `HEED_SESSION` set. The dispatcher script (for Hermes, its plugin) decides, from the mode `setup` bakes into it, and always exits 0. Observability never blocks the agent.\n\n## When to Use\n\n- \"Is my heedvane install healthy?\" / \"why aren't hooks firing?\"\n- \"What events does heedvane capture?\" / \"what shape is the payload?\"\n- \"Which of my agents are monitored?\"\n- After the user edits `~/.heed/heed.yaml` and wants the change applied/verified.\n\nDo **not** use this skill to author policies \u2014 an organization's policies are defined and assigned on the Heedvane server, never in `heed.yaml`.\n\n## Checking status\n\n1. Run the doctor (scoped, exit-code-carrying):\n\n ```sh\n npx heedvane doctor --json\n ```\n\n Or unscoped plain text: `npx heedvane doctor`. Every failing check carries a `remediation` string \u2014 surface it verbatim.\n\n2. Read the control file \u2014 it is the single source of truth for intent:\n\n ```sh\n cat ~/.heed/heed.yaml\n ```\n\n `agents: auto` manages every detected agent; an explicit list filters. `install.hooks/skill/mcp` toggles channels per agent.\n\n3. **Never edit agent configs by hand.** `~/.claude/settings.json`, `~/.codex/hooks.json`, etc. are compiled outputs. Edit `heed.yaml`, then re-run:\n\n ```sh\n npx heedvane setup\n ```\n\n4. Machine state (installed digests, endpoint, protocol version) is `~/.heed/state.json` \u2014 CLI-owned, never hand-edit.\n\n## Event contract summary\n\n- Canonical events (v1, snake_case): `session_start`, `session_end`, `user_prompt_submit`, `pre_tool_use`, `post_tool_use`, `post_tool_use_failure`, `permission_request`, `stop`, `stop_failure`, `subagent_start`, `subagent_stop`, `notification`, `pre_compact`. Unknown harness events pass through as `unknown_event` (never dropped).\n- Envelope: `{ \"v\": 1, \"event\", \"agent\", \"session\", \"pane\", \"receivedAt\", \"payload\": { \u2026 } }` \u2014 strings capped at 8 KB, nodes at 500.\n- Per-harness mapping (Claude's PascalCase `:class` names \u2192 canonical; Codex/Gemini are already snake_case) is tabled in `references/events.md`. Read that file before reasoning about any specific payload.\n\n## Steering, approved skills and memory\n\nWith the listener running (`npx heedvane listener start`) and a sign-in (`npx heedvane login`; CI and headless machines: `npx heedvane auth org` with the key on stdin), the listener answers each hook with a verdict instead of only recording it:\n\n- **Steering:** every rule of the organization that matches is enforced: a tool call is denied or a stop is blocked, with the rule's message as the reason. A session's stops are blocked at most four times in a row; the next one ends the turn so the person sees why, and no later stop of that session is blocked. No hook waits on Heedvane. Command and path rules are synced to the listener at each session start and checked locally, so they hold even when Heedvane is slow or down. Heedvane judges every event in the background, and a rule it finds violated blocks the agent's next tool call or stop.\n- **Organization instructions:** the organization's non-negotiable rules are added as context at session start or with each prompt. They cannot be turned off, and the rules named with them enforce them.\n- **Approved skills:** `npx heedvane skills sync` installs the organization's approved skills into `~/.agents/skills` and `~/.claude/skills` and caches the list in `~/.heed/approved-skills.json`. Using any other skill (the Skill tool, or reading its `SKILL.md`) is denied; writing a skill is not. `heed.yaml` `heedvane.skills` sets `enforce`, `strict` (also compare file hashes) and `whenUnknown` (what to do before the first sync or while the listener is down).\n- **Private memory** (local only, never sent to Heedvane): at session start the hook adds `~/.heed/memory/MEMORY.md` (every repository) and `~/.heed/memory/<owner>/<name>.md` (this repository) as context, so every agent sees them. To save a private memory, append a short line to the right file.\n\n- **Team memory:** at session start the hook adds a table of contents of this repository's team memory (folders and titles, never the text). Browse and read it with `npx heedvane memory list [folder]`, `npx heedvane memory show <id>` and `npx heedvane memory search \"<question>\" [--folder <folder>]`.\n\nBackend failures never block the agent: without Heedvane's answer, the locally checked rules decide, and every enforcement is reported to Heedvane.\n\n## Common Pitfalls\n\n- **Do not rewrite `~/.claude/settings.json` (or any agent config) manually while hooks are installed.** Hand edits fight the compiler; the next `setup` will either back up your change or report the file invalid. Edit `heed.yaml` instead.\n- **Spool files are diagnostics, not policy.** When the listener is unreachable, hook payloads spool to `~/.heed/spool/` (5 MB cap, 7-day TTL) so nothing is lost \u2014 but spool presence is not enforcement, and clearing it loses observability history only.\n- Warp has no native hooks, so Heedvane adds only its MCP connection and the skill there \u2014 that is expected, not an error to fix.\n- If `doctor` reports `invalid` on a config, the file does not parse; heedvane never overwrites malformed configs. Fix or move the file aside yourself, then re-run.\n- Re-running `setup` after no changes reports \"already current\" \u2014 idempotence is a feature, not a stalled run.\n\n## Verification Checklist\n\n- [ ] `npx heedvane doctor` exits 0 and every check line shows ok\n- [ ] `~/.heed/heed.yaml` parses and says `schemaVersion: v1`\n- [ ] `~/.heed/state.json` skill digest matches the installed skill\n- [ ] A new agent session (launched with `HEED_SESSION` set, or any session with `gating.mode: always`) produces events in the listener log or `~/.heed/spool/`\n- [ ] Backups exist: `<config>.heedvane.backup` next to every patched config\n- [ ] `npx heedvane status` reports state.json ok and the listener's endpoint\n";
8
+ export declare const EVENTS_MD = "# Heedvane event contract (v1)\n\nHarness-neutral taxonomy + JSON envelope. One normalizer maps every\nper-source payload into the canonical shape; unknown events pass through\nas `unknown_event` rather than being dropped.\n\n## Canonical events (v1)\n\n| Event | Fires when |\n| --- | --- |\n| `session_start` | A managed agent session begins |\n| `session_end` | The session ends (clean or crash) |\n| `user_prompt_submit` | The user submits a prompt |\n| `pre_tool_use` | Before a tool call executes |\n| `post_tool_use` | After a tool call returns |\n| `post_tool_use_failure` | A tool call raises/errors |\n| `permission_request` | The agent asks for approval |\n| `stop` | The agent turn completes |\n| `stop_failure` | The turn aborts with an error |\n| `subagent_start` | A subagent/task tool spawns |\n| `subagent_stop` | The subagent finishes |\n| `notification` | Harness notification (idle, attention, updates) |\n| `pre_compact` | Before the harness compacts the conversation |\n\n## Envelope\n\n```json\n{\n \"v\": 1,\n \"event\": \"pre_tool_use\",\n \"agent\": \"claude\",\n \"session\": \"sess_01HQ\u2026\",\n \"pane\": \"iterm:tab-3\",\n \"receivedAt\": \"2026-10-10T12:00:00.000Z\",\n \"payload\": { \"tool\": \"Bash\", \"input\": { \"command\": \"ls\" } }\n}\n```\n\nCaps (Orca's numbers): strings \u2264 8 KB, \u2264 500 nodes per payload; oversized\nfields are truncated at ingest, never dropped.\n\n## Per-harness mapping\n\n| Canonical | Claude Code / Codex | Gemini CLI | Cursor | Hermes plugin |\n| --- | --- | --- | --- | --- |\n| `session_start` | `SessionStart` | `SessionStart` | `sessionStart` | `on_session_start` |\n| `session_end` | `SessionEnd` | `SessionEnd` | `sessionEnd` | `on_session_finalize` |\n| `user_prompt_submit` | `UserPromptSubmit` | `BeforeAgent` | `beforeSubmitPrompt` | `pre_llm_call` |\n| `pre_tool_use` | `PreToolUse` | `BeforeTool` | `preToolUse` | `pre_tool_call` |\n| `post_tool_use` | `PostToolUse` | `AfterTool` | `postToolUse` | `post_tool_call` |\n| `post_tool_use_failure` | `PostToolUseFailure` | \u2014 | \u2014 | \u2014 |\n| `permission_request` | `PermissionRequest` | \u2014 | \u2014 | `pre_approval_request` |\n| `stop` | `Stop` | `AfterAgent` | `stop` | \u2014 |\n| `stop_failure` | `StopFailure` | \u2014 | \u2014 | \u2014 |\n| `subagent_start` | `SubagentStart` | \u2014 | \u2014 | \u2014 |\n| `subagent_stop` | `SubagentStop` | \u2014 | `subagentStop` | \u2014 |\n| `notification` | `Notification` | `Notification` | \u2014 | \u2014 |\n| `pre_compact` | `PreCompact` | `PreCompress` | \u2014 | \u2014 |\n\nHermes' `on_session_end` fires once per turn despite its name, so it passes\nthrough as `unknown_event`, as does `post_approval_response`. Cursor's session\nis its `conversation_id`.\n\nWarp emits no native events at all \u2014 it has no hooks, so nothing in this\ntable applies to it.\n\nPayload keys per source keep their harness names inside `payload` (e.g.\nClaude's `tool_input`, Codex's `tool_input`) after normalization of the\nenvelope itself; the normalizer never invents fields.\n";
9
9
  export interface SkillFile {
10
10
  relativePath: string;
11
11
  content: string;
@@ -17,7 +17,7 @@ description: Use when a user asks about heedvane hooks status, events, or how th
17
17
  Heedvane hooks give coding agents (Claude Code, Codex, Gemini CLI, Cursor, Warp, OpenCode, Hermes) a shared steering/guardrail/analysis channel without touching their internals. Three strictly separated layers:
18
18
 
19
19
  1. **Hook contract** — a harness-neutral event taxonomy (\`pre_tool_use\`, \`post_tool_use\`, …), one JSON envelope, one normalizer. See \`references/events.md\`.
20
- 2. **Per-agent adapters** — native hook config injection where the harness has hooks (Claude, Codex, Gemini, Cursor); MCP registration as the universal fallback (Warp has no native hooks — policy arrives via MCP); an honest skip where neither exists.
20
+ 2. **Per-agent adapters** — native hook config injection where the harness has hooks (Claude, Codex, Gemini, Cursor); Heedvane's MCP connection in every agent (Claude Desktop through the mcp-remote bridge); an honest skip where an agent has no hooks (Warp).
21
21
  3. **Setup mechanism** — \`heedvane-hook\` CLI: \`init\` / \`setup\` / \`doctor\` / \`uninstall\` with backup → atomic write → read-back verify. Human intent lives in \`~/.heed/heed.yaml\`; machine state in \`~/.heed/state.json\`.
22
22
 
23
23
  Which sessions run the hooks follows \`heed.yaml\` \`gating.mode\`: with \`always\` (the default) every session is governed, with \`pane\` only sessions launched with \`HEED_SESSION\` set. The dispatcher script (for Hermes, its plugin) decides, from the mode \`setup\` bakes into it, and always exits 0. Observability never blocks the agent.
@@ -80,7 +80,7 @@ Backend failures never block the agent: without Heedvane's answer, the locally c
80
80
 
81
81
  - **Do not rewrite \`~/.claude/settings.json\` (or any agent config) manually while hooks are installed.** Hand edits fight the compiler; the next \`setup\` will either back up your change or report the file invalid. Edit \`heed.yaml\` instead.
82
82
  - **Spool files are diagnostics, not policy.** When the listener is unreachable, hook payloads spool to \`~/.heed/spool/\` (5 MB cap, 7-day TTL) so nothing is lost — but spool presence is not enforcement, and clearing it loses observability history only.
83
- - Warp gets policies **via MCP only** — that is the designed happy path, not an error to fix.
83
+ - Warp has no native hooks, so Heedvane adds only its MCP connection and the skill there — that is expected, not an error to fix.
84
84
  - If \`doctor\` reports \`invalid\` on a config, the file does not parse; heedvane never overwrites malformed configs. Fix or move the file aside yourself, then re-run.
85
85
  - Re-running \`setup\` after no changes reports "already current" — idempotence is a feature, not a stalled run.
86
86
 
@@ -91,7 +91,7 @@ Backend failures never block the agent: without Heedvane's answer, the locally c
91
91
  - [ ] \`~/.heed/state.json\` skill digest matches the installed skill
92
92
  - [ ] A new agent session (launched with \`HEED_SESSION\` set, or any session with \`gating.mode: always\`) produces events in the listener log or \`~/.heed/spool/\`
93
93
  - [ ] Backups exist: \`<config>.heedvane.backup\` next to every patched config
94
- - [ ] \`/heedvane-status\` in a fresh agent session reports the same agents as \`doctor\`
94
+ - [ ] \`npx heedvane status\` reports state.json ok and the listener's endpoint
95
95
  `;
96
96
  export const EVENTS_MD = `# Heedvane event contract (v1)
97
97
 
@@ -156,8 +156,8 @@ Hermes' \`on_session_end\` fires once per turn despite its name, so it passes
156
156
  through as \`unknown_event\`, as does \`post_approval_response\`. Cursor's session
157
157
  is its \`conversation_id\`.
158
158
 
159
- Warp emits no native events at all — it has no hooks. Policy for Warp
160
- arrives via MCP tool calls; nothing in this table applies to it.
159
+ Warp emits no native events at all — it has no hooks, so nothing in this
160
+ table applies to it.
161
161
 
162
162
  Payload keys per source keep their harness names inside \`payload\` (e.g.
163
163
  Claude's \`tool_input\`, Codex's \`tool_input\`) after normalization of the
@@ -0,0 +1 @@
1
+ export declare function statusText(): Promise<string>;
package/dist/status.js ADDED
@@ -0,0 +1,29 @@
1
+ /** `npx heedvane status`: a quick summary of this machine's install (state.json, the listener). */
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { HeedStateCorruptError, readState } from "./state.js";
5
+ import { readEndpoint } from "./listener/endpoint.js";
6
+ export async function statusText() {
7
+ const home = process.env.HEED_HOME ?? homedir();
8
+ const root = join(home, ".heed");
9
+ const lines = [`heedvane hooks status (home: ${home})`];
10
+ try {
11
+ const state = await readState(root);
12
+ if (state !== null) {
13
+ const skill = state.skill?.digest.slice(0, 12) ?? "not installed";
14
+ lines.push(` state.json: ok (protocol v${state.protocolVersion}, skill ${skill})`);
15
+ }
16
+ else {
17
+ lines.push(" state.json: absent (run npx heedvane init)");
18
+ }
19
+ }
20
+ catch (error) {
21
+ if (!(error instanceof HeedStateCorruptError))
22
+ throw error;
23
+ lines.push(` state.json: ${error.message}`);
24
+ }
25
+ const endpoint = await readEndpoint(root);
26
+ lines.push(` listener: ${endpoint ? `endpoint.env present (port ${endpoint.port})` : "not configured — events spool to ~/.heed/spool"}`);
27
+ lines.push(" next: npx heedvane doctor for scoped checks");
28
+ return lines.join("\n");
29
+ }
@@ -20,4 +20,6 @@ export declare function removeStaleHookGroups(client: ResolvedClient, target: {
20
20
  }): Promise<UninstallItem[]>;
21
21
  /** When hooksDeferredTo skips a client whose hooks an earlier run installed, remove our hook groups so events do not fire twice. */
22
22
  export declare function removeDeferredHookGroups(client: ResolvedClient): Promise<UninstallItem[]>;
23
+ /** Setup's half for MCP: the entries an earlier version registered (the local helper, the connection's earlier name), removed from every config. */
24
+ export declare function removeLegacyMcpEntries(client: ResolvedClient): Promise<UninstallItem[]>;
23
25
  export declare function uninstall(input: UninstallInput): Promise<UninstallItem[]>;
package/dist/uninstall.js CHANGED
@@ -4,18 +4,21 @@
4
4
  * byte-identical. heed.yaml is NEVER touched (the user's file) unless
5
5
  * --purge-config is passed, and even then it is deleted, not rewritten.
6
6
  */
7
- import { existsSync, readFileSync } from "node:fs";
7
+ import { existsSync } from "node:fs";
8
8
  import { rm } from "node:fs/promises";
9
9
  import { join } from "node:path";
10
10
  import { supportedClients } from "./clients/definitions.js";
11
11
  import { hermesPluginOwned } from "./clients/hermes-plugin.js";
12
12
  import { applyEntry } from "./apply.js";
13
- import { isOurHookGroup, parseDoc } from "./config-doc.js";
14
- import { isHeedvaneEntry } from "./mcp.js";
15
- import { hooksTarget, isClientDetected } from "./plan.js";
13
+ import { isOurHookGroup } from "./config-doc.js";
14
+ import { isHeedvaneEntry, isLegacyEntry, mcpEntryNames } from "./mcp.js";
15
+ import { hooksTarget, isClientDetected, mcpContainers, parseClientDoc } from "./plan.js";
16
16
  import { skillInstallDir } from "./skill/bundle.js";
17
17
  import { statePath } from "./state.js";
18
18
  import { unlinkOptional } from "./best-effort.js";
19
+ import { pingListener, readEndpoint } from "./listener/endpoint.js";
20
+ import { SHUTDOWN_ROUTE } from "./listener/server.js";
21
+ import { setTimeout as delay } from "node:timers/promises";
19
22
  /** Hook events carrying one of our groups, with the user's own groups that must survive. */
20
23
  function ourHookEvents(doc) {
21
24
  const hooks = doc["hooks"];
@@ -25,28 +28,6 @@ function ourHookEvents(doc) {
25
28
  .filter(([, groups]) => Array.isArray(groups) && groups.some(isOurHookGroup))
26
29
  .map(([event, groups]) => ({ event, theirs: groups.filter((group) => !isOurHookGroup(group)) }));
27
30
  }
28
- function ourMcpEntryNames(doc, containerKeys) {
29
- const names = [];
30
- for (const key of containerKeys) {
31
- const c = doc[key];
32
- if (c === null || typeof c !== "object" || Array.isArray(c))
33
- continue;
34
- for (const [k, v] of Object.entries(c)) {
35
- if (isHeedvaneEntry(k, v))
36
- names.push(`${key}::${k}`);
37
- }
38
- }
39
- return names;
40
- }
41
- /** The doc's parsed value, or null when the config is absent or unparseable — nothing marker-verified to remove. */
42
- function parseClientDoc(path, format) {
43
- try {
44
- return parseDoc(readFileSync(path, "utf8"), format).value;
45
- }
46
- catch {
47
- return null; // absent or unparseable — nothing marker-verified to remove
48
- }
49
- }
50
31
  /**
51
32
  * Remove our hook groups from every event of one parsed config except those in `keep`, leaving the
52
33
  * user's own groups. Setup passes the events it installs, so a key an earlier version wrote under a
@@ -78,16 +59,34 @@ export async function removeDeferredHookGroups(client) {
78
59
  const doc = parseClientDoc(target.path, target.format);
79
60
  return doc === null ? [] : removeOurHookGroups(client, target, { doc, keep: new Set() });
80
61
  }
62
+ /** Remove the MCP entries `match` accepts from one parsed config; one item per removal. */
63
+ async function removeMcpEntries(client, target, scope) {
64
+ const items = [];
65
+ for (const qualified of mcpEntryNames(scope.doc, mcpContainers(client), scope.match)) {
66
+ const [containerKey, entryName] = qualified.split("::");
67
+ const res = await applyEntry(target, { containerKey, remove: entryName });
68
+ items.push({ clientId: client.id, name: `MCP ${entryName}`, path: target.path, status: res.status === "update" ? "removed" : "failed", detail: res.reason });
69
+ }
70
+ return items;
71
+ }
72
+ /** Setup's half for MCP: the entries an earlier version registered (the local helper, the connection's earlier name), removed from every config. */
73
+ export async function removeLegacyMcpEntries(client) {
74
+ const items = [];
75
+ for (const target of client.configs) {
76
+ if (target.format === "yaml")
77
+ continue;
78
+ const doc = parseClientDoc(target.path, target.format);
79
+ if (doc !== null)
80
+ items.push(...(await removeMcpEntries(client, { path: target.path, format: target.format }, { doc, match: isLegacyEntry })));
81
+ }
82
+ return items;
83
+ }
81
84
  /** Remove every marker-verified entry of one config file; one item per removal. */
82
85
  async function removeMarkedEntries(client, target, doc) {
83
86
  // Both kinds can live in the same file (claude settings.json carries
84
87
  // hooks AND mcpServers) — remove ours from every container.
85
88
  const items = await removeOurHookGroups(client, target, { doc, keep: new Set() });
86
- for (const qualified of ourMcpEntryNames(doc, [client.mcpServersKey, "mcp_servers", "mcp"])) {
87
- const [containerKey, entryName] = qualified.split("::");
88
- const res = await applyEntry(target, { containerKey, remove: entryName });
89
- items.push({ clientId: client.id, name: `MCP ${entryName}`, path: target.path, status: res.status === "update" ? "removed" : "failed", detail: res.reason });
90
- }
89
+ items.push(...(await removeMcpEntries(client, target, { doc, match: isHeedvaneEntry })));
91
90
  return items;
92
91
  }
93
92
  /** One non-hermes client's config files: parse each and remove our marked entries. */
@@ -150,10 +149,34 @@ async function removeSharedFiles(home, scoped) {
150
149
  await rm(skillDir, { recursive: true, force: true });
151
150
  await unlinkOptional(statePath(join(home, ".heed")));
152
151
  return [
152
+ await stopListener(join(home, ".heed")),
153
153
  { clientId: "*", name: "skill bundle", path: skillDir, status: "removed" },
154
154
  { clientId: "*", name: "state.json", path: statePath(join(home, ".heed")), status: "removed" },
155
155
  ];
156
156
  }
157
+ /** How long uninstall waits for the listener to stop answering after asking it to stop. */
158
+ const STOP_WAIT_MS = 3_000;
159
+ const STOP_POLL_MS = 100;
160
+ /** The background listener answers hooks that are gone now: ask it to stop (POST /shutdown) and wait until it has. */
161
+ async function stopListener(root) {
162
+ const endpoint = await readEndpoint(root).catch(() => null);
163
+ const item = { clientId: "*", name: "listener", path: `127.0.0.1:${endpoint?.port ?? 0}` };
164
+ if (endpoint === null || !(await pingListener(endpoint)))
165
+ return { ...item, status: "absent" };
166
+ const asked = await fetch(`http://127.0.0.1:${endpoint.port}${SHUTDOWN_ROUTE}`, {
167
+ method: "POST",
168
+ headers: { "x-heed-agent-hook-token": endpoint.token },
169
+ signal: AbortSignal.timeout(STOP_WAIT_MS),
170
+ }).catch(() => null);
171
+ for (let waited = 0; waited < STOP_WAIT_MS; waited += STOP_POLL_MS) {
172
+ if (!(await pingListener(endpoint)))
173
+ return { ...item, status: "removed" };
174
+ await delay(STOP_POLL_MS);
175
+ }
176
+ // A listener from 0.2.0 or earlier has no stop route (404); it ends at logout or restart.
177
+ const detail = asked?.status === 404 ? "an older listener is still running; it stops at logout or restart" : "the listener did not stop; it stops at logout or restart";
178
+ return { ...item, status: "failed", detail };
179
+ }
157
180
  function existsMarker(client) {
158
181
  return isClientDetected(client);
159
182
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "heedvane",
3
- "version": "0.2.0",
3
+ "version": "0.3.3",
4
4
  "description": "Heedvane for coding agents: one command installs steering, guardrails, team memory and skill checks into Claude Code, Codex, Cursor, Gemini CLI and more.",
5
5
  "private": false,
6
6
  "type": "module",
@@ -10,7 +10,7 @@ description: Use when a user asks about heedvane hooks status, events, or how th
10
10
  Heedvane hooks give coding agents (Claude Code, Codex, Gemini CLI, Cursor, Warp, OpenCode, Hermes) a shared steering/guardrail/analysis channel without touching their internals. Three strictly separated layers:
11
11
 
12
12
  1. **Hook contract** — a harness-neutral event taxonomy (`pre_tool_use`, `post_tool_use`, …), one JSON envelope, one normalizer. See `references/events.md`.
13
- 2. **Per-agent adapters** — native hook config injection where the harness has hooks (Claude, Codex, Gemini, Cursor); MCP registration as the universal fallback (Warp has no native hooks — policy arrives via MCP); an honest skip where neither exists.
13
+ 2. **Per-agent adapters** — native hook config injection where the harness has hooks (Claude, Codex, Gemini, Cursor); Heedvane's MCP connection in every agent (Claude Desktop through the mcp-remote bridge); an honest skip where an agent has no hooks (Warp).
14
14
  3. **Setup mechanism** — `heedvane-hook` CLI: `init` / `setup` / `doctor` / `uninstall` with backup → atomic write → read-back verify. Human intent lives in `~/.heed/heed.yaml`; machine state in `~/.heed/state.json`.
15
15
 
16
16
  Which sessions run the hooks follows `heed.yaml` `gating.mode`: with `always` (the default) every session is governed, with `pane` only sessions launched with `HEED_SESSION` set. The dispatcher script (for Hermes, its plugin) decides, from the mode `setup` bakes into it, and always exits 0. Observability never blocks the agent.
@@ -73,7 +73,7 @@ Backend failures never block the agent: without Heedvane's answer, the locally c
73
73
 
74
74
  - **Do not rewrite `~/.claude/settings.json` (or any agent config) manually while hooks are installed.** Hand edits fight the compiler; the next `setup` will either back up your change or report the file invalid. Edit `heed.yaml` instead.
75
75
  - **Spool files are diagnostics, not policy.** When the listener is unreachable, hook payloads spool to `~/.heed/spool/` (5 MB cap, 7-day TTL) so nothing is lost — but spool presence is not enforcement, and clearing it loses observability history only.
76
- - Warp gets policies **via MCP only** — that is the designed happy path, not an error to fix.
76
+ - Warp has no native hooks, so Heedvane adds only its MCP connection and the skill there — that is expected, not an error to fix.
77
77
  - If `doctor` reports `invalid` on a config, the file does not parse; heedvane never overwrites malformed configs. Fix or move the file aside yourself, then re-run.
78
78
  - Re-running `setup` after no changes reports "already current" — idempotence is a feature, not a stalled run.
79
79
 
@@ -84,4 +84,4 @@ Backend failures never block the agent: without Heedvane's answer, the locally c
84
84
  - [ ] `~/.heed/state.json` skill digest matches the installed skill
85
85
  - [ ] A new agent session (launched with `HEED_SESSION` set, or any session with `gating.mode: always`) produces events in the listener log or `~/.heed/spool/`
86
86
  - [ ] Backups exist: `<config>.heedvane.backup` next to every patched config
87
- - [ ] `/heedvane-status` in a fresh agent session reports the same agents as `doctor`
87
+ - [ ] `npx heedvane status` reports state.json ok and the listener's endpoint
@@ -61,8 +61,8 @@ Hermes' `on_session_end` fires once per turn despite its name, so it passes
61
61
  through as `unknown_event`, as does `post_approval_response`. Cursor's session
62
62
  is its `conversation_id`.
63
63
 
64
- Warp emits no native events at all — it has no hooks. Policy for Warp
65
- arrives via MCP tool calls; nothing in this table applies to it.
64
+ Warp emits no native events at all — it has no hooks, so nothing in this
65
+ table applies to it.
66
66
 
67
67
  Payload keys per source keep their harness names inside `payload` (e.g.
68
68
  Claude's `tool_input`, Codex's `tool_input`) after normalization of the
@@ -1,9 +0,0 @@
1
- export interface McpIo {
2
- write: (line: string) => void;
3
- log: (line: string) => void;
4
- }
5
- export declare function statusText(): Promise<string>;
6
- /** Entry point for the `mcp` subcommand. */
7
- export declare function runMcpStdio(io: Pick<McpIo, "write"> & {
8
- log?: (line: string) => void;
9
- }): void;