heedvane 0.2.1 → 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 (65) hide show
  1. package/README.md +60 -13
  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 +4 -2
  7. package/dist/cli.js +389 -144
  8. package/dist/clients/definitions.d.ts +8 -4
  9. package/dist/clients/definitions.js +48 -13
  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/commands/memory.js +6 -1
  20. package/dist/dispatchers/posix.d.ts +16 -19
  21. package/dist/dispatchers/posix.js +71 -91
  22. package/dist/doctor.d.ts +2 -0
  23. package/dist/doctor.js +113 -29
  24. package/dist/guard.d.ts +73 -0
  25. package/dist/guard.js +302 -0
  26. package/dist/heed-file.d.ts +19 -3
  27. package/dist/heed-file.js +46 -23
  28. package/dist/hook-fallback.d.ts +73 -0
  29. package/dist/hook-fallback.js +61 -0
  30. package/dist/installer.d.ts +13 -6
  31. package/dist/installer.js +40 -13
  32. package/dist/listener/background.d.ts +48 -4
  33. package/dist/listener/background.js +153 -19
  34. package/dist/listener/endpoint.d.ts +26 -3
  35. package/dist/listener/endpoint.js +55 -10
  36. package/dist/listener/forward.d.ts +7 -1
  37. package/dist/listener/forward.js +40 -12
  38. package/dist/listener/serve.d.ts +37 -0
  39. package/dist/listener/serve.js +196 -32
  40. package/dist/listener/server.d.ts +9 -0
  41. package/dist/listener/server.js +59 -22
  42. package/dist/listener/spool.d.ts +2 -17
  43. package/dist/listener/spool.js +6 -3
  44. package/dist/mcp.d.ts +38 -48
  45. package/dist/mcp.js +196 -63
  46. package/dist/plan.d.ts +47 -8
  47. package/dist/plan.js +164 -52
  48. package/dist/render.js +8 -8
  49. package/dist/reply/harness-output.d.ts +9 -0
  50. package/dist/reply/harness-output.js +13 -0
  51. package/dist/reply/reply.js +2 -1
  52. package/dist/skill/content.d.ts +2 -2
  53. package/dist/skill/content.js +11 -8
  54. package/dist/status.d.ts +1 -0
  55. package/dist/status.js +29 -0
  56. package/dist/steering/rules.js +4 -2
  57. package/dist/uninstall.d.ts +7 -0
  58. package/dist/uninstall.js +72 -71
  59. package/package.json +10 -3
  60. package/skills/heedvane-hooks/SKILL.md +6 -6
  61. package/skills/heedvane-hooks/references/events.md +5 -2
  62. package/dist/dispatchers/windows.d.ts +0 -15
  63. package/dist/dispatchers/windows.js +0 -50
  64. package/dist/mcp-server.d.ts +0 -9
  65. package/dist/mcp-server.js +0 -187
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 { 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 { MCP_ENTRY_ARGS, desiredServer } 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
- /** 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];
@@ -31,18 +27,14 @@ function mcpTarget(client) {
31
27
  /**
32
28
  * Desired write targets per client × capability. This is the desired-state
33
29
  * compiler heed.yaml → concrete files; doctor reuses it via a callback so
34
- * it never needs apply.ts. `env` is accepted for API stability — the client's
35
- * 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.
36
32
  */
37
33
  export function desiredEntriesFor(client, capability, context) {
38
- const home = context.home;
39
- void context.env;
40
34
  const targets = [];
41
35
  if (capability === "hooks") {
42
- if (client.id === "hermes") {
43
- // Hermes gets a native plugin, not a hooks.json entry.
44
- return hermesDesiredFiles(home, context.gating);
45
- }
36
+ if (client.hookFiles !== undefined)
37
+ return hookFilesDesired(client, client.hookFiles, context.gating);
46
38
  // Real harness shape: the `hooks` container is keyed by event name, and
47
39
  // we patch per-event so a user's own hook entries are never clobbered.
48
40
  const target = hooksTarget(client);
@@ -59,14 +51,114 @@ export function desiredEntriesFor(client, capability, context) {
59
51
  }
60
52
  if (capability === "mcp") {
61
53
  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) });
54
+ const entry = context.connector === undefined ? undefined : connectionEntry(client.id, { ...context, connector: context.connector });
55
+ if (target !== undefined && entry !== undefined)
56
+ targets.push({ path: target.path, format: target.format, containerKey: client.mcpServersKey, entryName: HEEDVANE_SERVER_NAME, entry });
66
57
  return targets;
67
58
  }
68
59
  return targets; // skill has no per-client config entry
69
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
+ }
71
+ /** The folder of the Node running this install: the hook dispatcher records it, and a local MCP bridge runs its npx. */
72
+ function installingNodeDir() {
73
+ return dirname(process.execPath);
74
+ }
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. */
89
+ export function heedvaneConnector(heedFile) {
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;
114
+ }
115
+ /** The MCP containers a client's configs can hold our entries in (Codex's TOML and OpenCode's `mcp` beside the usual key). */
116
+ export function mcpContainers(client) {
117
+ return [client.mcpServersKey, "mcp_servers", "mcp"];
118
+ }
119
+ /** The doc's parsed value, or null when the config is absent or unparseable — nothing marker-verified to remove. */
120
+ export function parseClientDoc(path, format) {
121
+ try {
122
+ return parseDoc(readFileSync(path, "utf8"), format).value;
123
+ }
124
+ catch {
125
+ return null; // absent or unparseable — nothing marker-verified to remove
126
+ }
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
+ }
144
+ /** The first config of this client still holding an MCP entry an earlier version wrote, and those entries' names. */
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) {
154
+ for (const target of client.configs) {
155
+ const doc = target.format === "yaml" ? null : parseClientDoc(target.path, target.format);
156
+ const names = doc === null ? [] : mcpEntryNames(doc, mcpContainers(client), match).map((qualified) => qualified.split("::")[1]);
157
+ if (names.length > 0)
158
+ return { path: target.path, names };
159
+ }
160
+ return undefined;
161
+ }
70
162
  /** Our one hook for an event, in the shape the client's hooks file holds it. */
71
163
  function hookEntry(client) {
72
164
  const command = genericSnippet(client.id);
@@ -113,14 +205,15 @@ function installsClient(client, scope) {
113
205
  const { agents } = scope.heedFile;
114
206
  return isClientDetected(client) && (agents === "auto" || agents.includes(client.id));
115
207
  }
116
- function hermesDesiredFiles(home, gating) {
117
- // Hermes gets a native plugin (kind: standalone) instead of hooks.json.
118
- return hermesPluginFiles(gating).map((f) => ({
119
- path: join(home, ".hermes", "plugins", "heedvane-hooks", f.relativePath),
120
- 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",
121
214
  containerKey: "",
122
- entryName: f.relativePath,
123
- entry: f.content,
215
+ entryName: file.relativePath === "" ? basename(path) : file.relativePath,
216
+ entry: file.content,
124
217
  }));
125
218
  }
126
219
  /** Read-only inspection: what operation would satisfy the desired entry at its path? */
@@ -129,7 +222,7 @@ function inspect(d) {
129
222
  if (!existsSync(path))
130
223
  return { operation: "create", path };
131
224
  const backupPath = `${path}.heedvane.backup`;
132
- // 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.
133
226
  if (containerKey === "") {
134
227
  return readFileSync(path, "utf8") === desired ? { operation: "unchanged", path } : { operation: "update", path, backupPath };
135
228
  }
@@ -148,12 +241,11 @@ function inspect(d) {
148
241
  const current = readEntry(doc, containerKey, entryName);
149
242
  if (current === undefined)
150
243
  return { operation: "create", path, backupPath };
151
- if (!valuesEqual(current, entryValue(containerKey, current, desired)))
244
+ if (!valuesEqual(comparableEntry(current), comparableEntry(entryValue(containerKey, current, desired))))
152
245
  return { operation: "update", path, backupPath };
153
246
  return { operation: "unchanged", path };
154
247
  }
155
- function inspectMcp(client, home, env) {
156
- const desireds = desiredEntriesFor(client, "mcp", { home, env });
248
+ function inspectMcp(desireds) {
157
249
  // A client with multiple MCP targets (codex hooks.json + config.toml) is
158
250
  // only "aligned" when every target is; the action reports the worst path.
159
251
  let worst = { operation: "unchanged" };
@@ -187,11 +279,9 @@ function inspectSkill(home, client, desiredDigest) {
187
279
  const backupPath = join(dir, "SKILL.md.heedvane.backup");
188
280
  return same ? { operation: "unchanged", path: skillMd } : { operation: "update", path: skillMd, backupPath };
189
281
  }
190
- /** Hooks ride the client's native capability, or hermes' plugin-install channel. */
191
- function supportsCapability(client, capability) {
192
- if (client.capabilities.includes(capability))
193
- return true;
194
- 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);
195
285
  }
196
286
  function skipAction(client, reason, path) {
197
287
  return { clientId: client.id, kind: "skip", operation: "skip", path: path ?? client.markerPath, reason };
@@ -210,11 +300,43 @@ function worstHooksResult(desireds) {
210
300
  return worst;
211
301
  }
212
302
  function noHooksSkip(client) {
213
- return skipAction(client, `${client.id}: no native hooks — policies arrive via MCP`, client.configs[0]?.path ?? client.markerPath);
303
+ return skipAction(client, `${client.id}: no native hooks`, client.configs[0]?.path ?? client.markerPath);
214
304
  }
215
305
  function deferredHooksSkip(client, deferredTo) {
216
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);
217
307
  }
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) };
326
+ if (desireds.length === 0)
327
+ return removal === undefined ? null : { clientId: client.id, kind: "mcp", ...removal };
328
+ const r = inspectMcp(desireds);
329
+ if (r.operation === "unchanged" && removal !== undefined)
330
+ return { clientId: client.id, kind: "mcp", ...removal };
331
+ return { clientId: client.id, kind: "mcp", operation: r.operation, path: r.path ?? desireds[0].path, backupPath: r.backupPath, reason: r.reason };
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
+ }
218
340
  /** One client × one capability → its planned action, or null when nothing to plan. */
219
341
  function planCapability(client, capability, ctx) {
220
342
  if (!ctx.heedFile.install[capability])
@@ -228,22 +350,12 @@ function planCapability(client, capability, ctx) {
228
350
  const r = inspectSkill(ctx.home, client, ctx.desiredSkillDigest);
229
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 };
230
352
  }
231
- const desireds = desiredEntriesFor(client, capability, { home: ctx.home, env: ctx.env, gating: ctx.heedFile.gating.mode });
353
+ const desireds = desiredEntriesFor(client, capability, { home: ctx.home, env: ctx.env, gating: ctx.heedFile.gating.mode, connector: heedvaneConnector(ctx.heedFile), nodeDir: ctx.nodeDir });
354
+ if (capability === "mcp")
355
+ return planMcp(client, desireds, ctx.heedFile);
232
356
  if (desireds.length === 0)
233
357
  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
- // hooks (including the hermes whole-file plugin shape)
358
+ // hooks (including whole hook files)
247
359
  const primary = desireds[0];
248
360
  const worst = worstHooksResult(desireds);
249
361
  return {
@@ -263,7 +375,7 @@ export function planActions(input) {
263
375
  const { home, platform, env, heedFile } = input;
264
376
  const actions = [];
265
377
  const desiredSkillDigest = input.skillDigest ?? skillDigest(canonicalSkillFiles());
266
- 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) };
267
379
  for (const client of supportedClients({ home, platform: platform, env })) {
268
380
  if (!installsClient(client, ctx)) {
269
381
  // Listed in heed.yaml but not on this machine.
@@ -283,6 +395,6 @@ export function planActions(input) {
283
395
  /** Count of distinct agents that would receive a write for a kind. */
284
396
  export function wouldInstallCount(actions, kind) {
285
397
  return new Set(actions
286
- .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"))
287
399
  .map((a) => a.clientId)).size;
288
400
  }
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
- if (client.capabilities.includes("hooks"))
11
+ if (client.capabilities.includes("hooks") || client.hookFiles !== undefined)
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:"];
@@ -38,6 +37,7 @@ const VERB = {
38
37
  create: "added",
39
38
  update: "updated",
40
39
  unchanged: "already current",
40
+ remove: "removed",
41
41
  skip: "skipped",
42
42
  };
43
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
  /**
@@ -13,6 +13,12 @@ const CLAUDE_EVENT_NAMES = {
13
13
  export const ADDITIONAL_CONTEXT_BUDGET = 9_500;
14
14
  /** Between the pieces of one answer's context. */
15
15
  export const CONTEXT_SEPARATOR = "\n\n";
16
+ /**
17
+ * The top-level output field each agent shows the person without steering (checked 2026-10-20): Claude Code's, Codex's
18
+ * (learn.chatgpt.com/docs/hooks) and Gemini CLI's (geminicli.com/docs/hooks/reference) `systemMessage`. Cursor shows
19
+ * `user_message` only with a denial, and Hermes' plugin hooks have no such field, so they have none here.
20
+ */
21
+ export const USER_NOTICE_FIELD = { claude: "systemMessage", codex: "systemMessage", gemini: "systemMessage" };
16
22
  /** The events a stop block or halt applies to. */
17
23
  export const STOP_EVENTS = new Set(["stop", "subagent_stop"]);
18
24
  const CLAUDE_STEERING = {
@@ -35,6 +41,8 @@ const CLAUDE_STEERING = {
35
41
  * - Hermes (hermes-agent.nousresearch.com/docs/user-guide/features/hooks): its plugin's `pre_tool_call`
36
42
  * blocks with `{action: "block", message}`, which Hermes ignores without a message; `pre_llm_call`
37
43
  * adds `{context}` to the turn. It has no stop hook.
44
+ * - Pi (pi.dev, docs/extensions.md): Heedvane's guard (src/guard.ts) sends a tool call as Claude Code's PreToolUse and
45
+ * hands `{block: true, reason}` to Pi's `tool_call` event, Pi's core `beforeToolCall` or Pi Durable `beforeTool`.
38
46
  */
39
47
  const HARNESS_STEERING = {
40
48
  claude: CLAUDE_STEERING,
@@ -58,6 +66,11 @@ const HARNESS_STEERING = {
58
66
  deny: (_name, reason) => (reason === "" ? {} : { action: "block", message: reason }),
59
67
  context: (name, text) => (name === "pre_llm_call" ? { context: text } : null),
60
68
  },
69
+ pi: {
70
+ names: { pre_tool_use: "PreToolUse" },
71
+ deny: (_name, reason) => ({ block: true, reason }),
72
+ context: () => null,
73
+ },
61
74
  };
62
75
  /**
63
76
  * The stdout JSON for the envelope's agent and event. A `steer` always enforces where the harness can:
@@ -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, Pi) 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 `npx heedvane`: `connect` / `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; for Pi, Heedvane's guard extension) 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\nPi's guard (the extension `connect` installs, or `heedvane/guard` in code built on Pi) sends only\n`pre_tool_use`, as Claude Code's `PreToolUse` with Claude Code's tool names.\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;
@@ -14,13 +14,13 @@ description: Use when a user asks about heedvane hooks status, events, or how th
14
14
 
15
15
  ## Overview
16
16
 
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:
17
+ Heedvane hooks give coding agents (Claude Code, Codex, Gemini CLI, Cursor, Warp, OpenCode, Hermes, Pi) 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.
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\`.
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
+ 3. **Setup mechanism** — \`npx heedvane\`: \`connect\` / \`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
- 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.
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; for Pi, Heedvane's guard extension) decides, from the mode \`setup\` bakes into it, and always exits 0. Observability never blocks the agent.
24
24
 
25
25
  ## When to Use
26
26
 
@@ -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
 
@@ -152,12 +152,15 @@ fields are truncated at ingest, never dropped.
152
152
  | \`notification\` | \`Notification\` | \`Notification\` | — | — |
153
153
  | \`pre_compact\` | \`PreCompact\` | \`PreCompress\` | — | — |
154
154
 
155
+ Pi's guard (the extension \`connect\` installs, or \`heedvane/guard\` in code built on Pi) sends only
156
+ \`pre_tool_use\`, as Claude Code's \`PreToolUse\` with Claude Code's tool names.
157
+
155
158
  Hermes' \`on_session_end\` fires once per turn despite its name, so it passes
156
159
  through as \`unknown_event\`, as does \`post_approval_response\`. Cursor's session
157
160
  is its \`conversation_id\`.
158
161
 
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.
162
+ Warp emits no native events at all — it has no hooks, so nothing in this
163
+ table applies to it.
161
164
 
162
165
  Payload keys per source keep their harness names inside \`payload\` (e.g.
163
166
  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 ${join(root, "spool")}`}`);
27
+ lines.push(" next: npx heedvane doctor for scoped checks");
28
+ return lines.join("\n");
29
+ }
@@ -13,7 +13,9 @@
13
13
  * injects instructions before its first sync, and with Heedvane down.
14
14
  */
15
15
  import { readFile } from "node:fs/promises";
16
- import { isAbsolute, join, matchesGlob, normalize, relative } from "node:path";
16
+ import { isAbsolute, join, normalize, relative } from "node:path";
17
+ // A namespace import: matchesGlob is Node 22.5's, and naming it in an import fails the whole CLI at load on 22.0 to 22.4.
18
+ import * as nodePath from "node:path";
17
19
  import writeFileAtomic from "write-file-atomic";
18
20
  import { callHeedvane } from "../backend/client.js";
19
21
  import { isRecord } from "../guards.js";
@@ -109,7 +111,7 @@ function matches(rule, input, cwd) {
109
111
  const touched = touchedFile(input, cwd);
110
112
  // Normalized first, so `./././` padding cannot push a real file past the length bound.
111
113
  const path = touched === null ? null : normalize(touched);
112
- return path !== null && path.length <= MAX_MATCHED_PATH && rule.patterns.some((glob) => matchesGlob(path, glob));
114
+ return path !== null && path.length <= MAX_MATCHED_PATH && rule.patterns.some((glob) => nodePath.matchesGlob(path, glob));
113
115
  }
114
116
  /** The file a tool event touches, relative to `cwd` when inside it (Heedvane's `touchedFile`). */
115
117
  function touchedFile(input, cwd) {
@@ -20,4 +20,11 @@ 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
+ /**
24
+ * Setup's half for MCP: the entries an earlier version registered (the local helper, the connection's earlier name),
25
+ * removed from every config; under metrics only (`match` = isHeedvaneEntry), today's connection too.
26
+ */
27
+ export declare function removeLegacyMcpEntries(client: ResolvedClient, match?: (name: string, entry: unknown) => boolean): Promise<UninstallItem[]>;
23
28
  export declare function uninstall(input: UninstallInput): Promise<UninstallItem[]>;
29
+ /** Uninstall asks the background listener to stop (POST /shutdown) and waits until it has. */
30
+ export declare function stopListener(root: string): Promise<UninstallItem>;