heedvane 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -2
- package/dist/apply.d.ts +104 -0
- package/dist/apply.js +388 -0
- package/dist/backend/client.d.ts +40 -0
- package/dist/backend/client.js +32 -0
- package/dist/backend/credentials.d.ts +15 -0
- package/dist/backend/credentials.js +76 -0
- package/dist/backend/login.d.ts +33 -0
- package/dist/backend/login.js +265 -0
- package/dist/backend/repo-remote.d.ts +15 -0
- package/dist/backend/repo-remote.js +61 -0
- package/dist/backend/sign-in-change.d.ts +12 -0
- package/dist/backend/sign-in-change.js +72 -0
- package/dist/best-effort.d.ts +4 -0
- package/dist/best-effort.js +25 -0
- package/dist/cli.d.ts +29 -0
- package/dist/cli.js +666 -0
- package/dist/clients/definitions.d.ts +113 -0
- package/dist/clients/definitions.js +427 -0
- package/dist/clients/hermes-plugin.d.ts +33 -0
- package/dist/clients/hermes-plugin.js +247 -0
- package/dist/commands/heedvane.d.ts +15 -0
- package/dist/commands/heedvane.js +74 -0
- package/dist/commands/memory.d.ts +10 -0
- package/dist/commands/memory.js +106 -0
- package/dist/config-doc.d.ts +97 -0
- package/dist/config-doc.js +465 -0
- package/dist/dispatchers/posix.d.ts +41 -0
- package/dist/dispatchers/posix.js +208 -0
- package/dist/dispatchers/snippets.d.ts +6 -0
- package/dist/dispatchers/snippets.js +39 -0
- package/dist/dispatchers/windows.d.ts +15 -0
- package/dist/dispatchers/windows.js +50 -0
- package/dist/doctor.d.ts +37 -0
- package/dist/doctor.js +303 -0
- package/dist/events/contract.d.ts +54 -0
- package/dist/events/contract.js +87 -0
- package/dist/events/normalize.d.ts +36 -0
- package/dist/events/normalize.js +143 -0
- package/dist/guards.d.ts +18 -0
- package/dist/guards.js +28 -0
- package/dist/heed-file.d.ts +78 -0
- package/dist/heed-file.js +316 -0
- package/dist/installer.d.ts +47 -0
- package/dist/installer.js +174 -0
- package/dist/listener/background.d.ts +8 -0
- package/dist/listener/background.js +36 -0
- package/dist/listener/endpoint.d.ts +26 -0
- package/dist/listener/endpoint.js +125 -0
- package/dist/listener/forward.d.ts +36 -0
- package/dist/listener/forward.js +257 -0
- package/dist/listener/serve.d.ts +2 -0
- package/dist/listener/serve.js +155 -0
- package/dist/listener/server.d.ts +48 -0
- package/dist/listener/server.js +251 -0
- package/dist/listener/spool.d.ts +28 -0
- package/dist/listener/spool.js +60 -0
- package/dist/mcp-server.d.ts +9 -0
- package/dist/mcp-server.js +187 -0
- package/dist/mcp.d.ts +57 -0
- package/dist/mcp.js +83 -0
- package/dist/memory/private-memory.d.ts +12 -0
- package/dist/memory/private-memory.js +54 -0
- package/dist/memory/table-of-contents.d.ts +10 -0
- package/dist/memory/table-of-contents.js +80 -0
- package/dist/memory/team-memory.d.ts +60 -0
- package/dist/memory/team-memory.js +54 -0
- package/dist/plan.d.ts +77 -0
- package/dist/plan.js +288 -0
- package/dist/render.d.ts +32 -0
- package/dist/render.js +132 -0
- package/dist/reply/harness-output.d.ts +57 -0
- package/dist/reply/harness-output.js +88 -0
- package/dist/reply/reply.d.ts +65 -0
- package/dist/reply/reply.js +169 -0
- package/dist/reply/verdict.d.ts +28 -0
- package/dist/reply/verdict.js +17 -0
- package/dist/skill/bundle.d.ts +23 -0
- package/dist/skill/bundle.js +102 -0
- package/dist/skill/content.d.ts +16 -0
- package/dist/skill/content.js +182 -0
- package/dist/skills/approved.d.ts +31 -0
- package/dist/skills/approved.js +123 -0
- package/dist/skills/check.d.ts +21 -0
- package/dist/skills/check.js +83 -0
- package/dist/state.d.ts +51 -0
- package/dist/state.js +118 -0
- package/dist/steering/rules.d.ts +49 -0
- package/dist/steering/rules.js +165 -0
- package/dist/steering/steer.d.ts +29 -0
- package/dist/steering/steer.js +64 -0
- package/dist/steering/violations.d.ts +27 -0
- package/dist/steering/violations.js +24 -0
- package/dist/uninstall.d.ts +23 -0
- package/dist/uninstall.js +159 -0
- package/package.json +35 -4
- package/skills/heedvane-hooks/SKILL.md +87 -0
- package/skills/heedvane-hooks/references/events.md +69 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import writeFileAtomic from "write-file-atomic";
|
|
4
|
+
import { canonicalSkillFiles, skillDigest } from "./content.js";
|
|
5
|
+
import { unlinkOptional, writeFileOptional } from "../best-effort.js";
|
|
6
|
+
/** Where the skill lives for a given skills root (~/.agents/skills). */
|
|
7
|
+
export function skillInstallDir(skillsRoot, name = "heedvane-hooks") {
|
|
8
|
+
return join(skillsRoot, name);
|
|
9
|
+
}
|
|
10
|
+
/** Backup path for any differing existing skill file. */
|
|
11
|
+
export function skillBackupPath(target) {
|
|
12
|
+
return `${target}.heedvane.backup`;
|
|
13
|
+
}
|
|
14
|
+
/** The file's current bytes, or undefined when absent. Other read errors propagate. */
|
|
15
|
+
async function readExisting(target) {
|
|
16
|
+
try {
|
|
17
|
+
return await readFile(target);
|
|
18
|
+
}
|
|
19
|
+
catch (err) {
|
|
20
|
+
if (err.code === "ENOENT")
|
|
21
|
+
return undefined;
|
|
22
|
+
throw err;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** Best-effort restore of anything we already replaced. */
|
|
26
|
+
async function restoreOriginals(originals) {
|
|
27
|
+
for (const [target, original] of originals) {
|
|
28
|
+
if (original !== undefined)
|
|
29
|
+
await writeFileOptional(target, original, 0o644);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** An existing backup already holds the first original; any other write failure is real. */
|
|
33
|
+
function keepExistingBackup(err) {
|
|
34
|
+
if (err.code !== "EEXIST")
|
|
35
|
+
throw err;
|
|
36
|
+
}
|
|
37
|
+
/** Back up a differing original, atomic-write the canonical content, verify by read-back. */
|
|
38
|
+
async function installSkillFile(dir, file, ctx) {
|
|
39
|
+
const target = join(dir, file.relativePath);
|
|
40
|
+
const existing = await readExisting(target);
|
|
41
|
+
if (existing !== undefined && existing.toString("utf8") === file.content)
|
|
42
|
+
return { wrote: false };
|
|
43
|
+
// Back up the differing original before touching it, unless a backup already holds the first one.
|
|
44
|
+
if (existing !== undefined) {
|
|
45
|
+
ctx.originals.set(target, existing);
|
|
46
|
+
if (ctx.backUp(existing))
|
|
47
|
+
await writeFile(skillBackupPath(target), existing, { mode: 0o644, flag: "wx" }).catch(keepExistingBackup);
|
|
48
|
+
}
|
|
49
|
+
await mkdir(dirname(target), { recursive: true });
|
|
50
|
+
await writeFileAtomic(target, file.content, { mode: 0o644 });
|
|
51
|
+
// Read-back verify; restore the original on mismatch and abort the install.
|
|
52
|
+
const written = await readFile(target, "utf8");
|
|
53
|
+
if (written !== file.content) {
|
|
54
|
+
if (existing !== undefined)
|
|
55
|
+
await writeFile(target, existing, { mode: 0o644 });
|
|
56
|
+
else
|
|
57
|
+
await unlinkOptional(target);
|
|
58
|
+
return { failed: true };
|
|
59
|
+
}
|
|
60
|
+
return { wrote: true };
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* REA's co-install pattern, applied to the skill bundle: compare canonical
|
|
64
|
+
* files against disk, back up any differing file as <path>.heedvane.backup,
|
|
65
|
+
* atomic-write (write-file-atomic), then read back to verify — restore on
|
|
66
|
+
* mismatch and report 'failed'.
|
|
67
|
+
*/
|
|
68
|
+
export async function installSkill(skillsRoot, files = canonicalSkillFiles(), options = {}) {
|
|
69
|
+
const dir = skillInstallDir(skillsRoot, options.name);
|
|
70
|
+
const backUp = options.backUp ?? (() => true);
|
|
71
|
+
let wroteAny = false;
|
|
72
|
+
const originals = new Map();
|
|
73
|
+
try {
|
|
74
|
+
for (const file of files) {
|
|
75
|
+
const result = await installSkillFile(dir, file, { originals, backUp });
|
|
76
|
+
if ("failed" in result)
|
|
77
|
+
return "failed";
|
|
78
|
+
if (result.wrote)
|
|
79
|
+
wroteAny = true;
|
|
80
|
+
}
|
|
81
|
+
return wroteAny ? "installed" : "unchanged";
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// Best-effort restore of anything we already replaced.
|
|
85
|
+
await restoreOriginals(originals);
|
|
86
|
+
return "failed";
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** Digest of the currently installed skill files, or null when absent/incomplete. */
|
|
90
|
+
export async function readInstalledSkillDigest(skillsRoot, files = canonicalSkillFiles()) {
|
|
91
|
+
const dir = skillInstallDir(skillsRoot);
|
|
92
|
+
const onDisk = [];
|
|
93
|
+
for (const file of files) {
|
|
94
|
+
try {
|
|
95
|
+
onDisk.push({ relativePath: file.relativePath, content: await readFile(join(dir, file.relativePath), "utf8") });
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return null; // any missing file means "not installed"
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return skillDigest(onDisk);
|
|
102
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical heedvane-hooks skill content, embedded as strings so the
|
|
3
|
+
* package ships standalone (no fs reads relative to import.meta.url at
|
|
4
|
+
* runtime). The physical copies in /skills/heedvane-hooks/ must stay
|
|
5
|
+
* byte-identical — tests assert that.
|
|
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";
|
|
9
|
+
export interface SkillFile {
|
|
10
|
+
relativePath: string;
|
|
11
|
+
content: string;
|
|
12
|
+
}
|
|
13
|
+
/** The canonical file set, ready to install under ~/.agents/skills/heedvane-hooks/. */
|
|
14
|
+
export declare function canonicalSkillFiles(): SkillFile[];
|
|
15
|
+
/** sha256 over the sorted (relativePath + "\\0" + content) concatenation. */
|
|
16
|
+
export declare function skillDigest(files: SkillFile[]): string;
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
/**
|
|
3
|
+
* Canonical heedvane-hooks skill content, embedded as strings so the
|
|
4
|
+
* package ships standalone (no fs reads relative to import.meta.url at
|
|
5
|
+
* runtime). The physical copies in /skills/heedvane-hooks/ must stay
|
|
6
|
+
* byte-identical — tests assert that.
|
|
7
|
+
*/
|
|
8
|
+
export const SKILL_MD = `---
|
|
9
|
+
name: heedvane-hooks
|
|
10
|
+
description: Use when a user asks about heedvane hooks status, events, or how their coding agents are monitored — reports live hook installation state and the canonical event contract.
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# heedvane-hooks
|
|
14
|
+
|
|
15
|
+
## Overview
|
|
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:
|
|
18
|
+
|
|
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\`.
|
|
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.
|
|
24
|
+
|
|
25
|
+
## When to Use
|
|
26
|
+
|
|
27
|
+
- "Is my heedvane install healthy?" / "why aren't hooks firing?"
|
|
28
|
+
- "What events does heedvane capture?" / "what shape is the payload?"
|
|
29
|
+
- "Which of my agents are monitored?"
|
|
30
|
+
- After the user edits \`~/.heed/heed.yaml\` and wants the change applied/verified.
|
|
31
|
+
|
|
32
|
+
Do **not** use this skill to author policies — an organization's policies are defined and assigned on the Heedvane server, never in \`heed.yaml\`.
|
|
33
|
+
|
|
34
|
+
## Checking status
|
|
35
|
+
|
|
36
|
+
1. Run the doctor (scoped, exit-code-carrying):
|
|
37
|
+
|
|
38
|
+
\`\`\`sh
|
|
39
|
+
npx heedvane doctor --json
|
|
40
|
+
\`\`\`
|
|
41
|
+
|
|
42
|
+
Or unscoped plain text: \`npx heedvane doctor\`. Every failing check carries a \`remediation\` string — surface it verbatim.
|
|
43
|
+
|
|
44
|
+
2. Read the control file — it is the single source of truth for intent:
|
|
45
|
+
|
|
46
|
+
\`\`\`sh
|
|
47
|
+
cat ~/.heed/heed.yaml
|
|
48
|
+
\`\`\`
|
|
49
|
+
|
|
50
|
+
\`agents: auto\` manages every detected agent; an explicit list filters. \`install.hooks/skill/mcp\` toggles channels per agent.
|
|
51
|
+
|
|
52
|
+
3. **Never edit agent configs by hand.** \`~/.claude/settings.json\`, \`~/.codex/hooks.json\`, etc. are compiled outputs. Edit \`heed.yaml\`, then re-run:
|
|
53
|
+
|
|
54
|
+
\`\`\`sh
|
|
55
|
+
npx heedvane setup
|
|
56
|
+
\`\`\`
|
|
57
|
+
|
|
58
|
+
4. Machine state (installed digests, endpoint, protocol version) is \`~/.heed/state.json\` — CLI-owned, never hand-edit.
|
|
59
|
+
|
|
60
|
+
## Event contract summary
|
|
61
|
+
|
|
62
|
+
- 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).
|
|
63
|
+
- Envelope: \`{ "v": 1, "event", "agent", "session", "pane", "receivedAt", "payload": { … } }\` — strings capped at 8 KB, nodes at 500.
|
|
64
|
+
- Per-harness mapping (Claude's PascalCase \`:class\` names → canonical; Codex/Gemini are already snake_case) is tabled in \`references/events.md\`. Read that file before reasoning about any specific payload.
|
|
65
|
+
|
|
66
|
+
## Steering, approved skills and memory
|
|
67
|
+
|
|
68
|
+
With 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:
|
|
69
|
+
|
|
70
|
+
- **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.
|
|
71
|
+
- **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.
|
|
72
|
+
- **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).
|
|
73
|
+
- **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.
|
|
74
|
+
|
|
75
|
+
- **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>]\`.
|
|
76
|
+
|
|
77
|
+
Backend failures never block the agent: without Heedvane's answer, the locally checked rules decide, and every enforcement is reported to Heedvane.
|
|
78
|
+
|
|
79
|
+
## Common Pitfalls
|
|
80
|
+
|
|
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
|
+
- **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.
|
|
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
|
+
- Re-running \`setup\` after no changes reports "already current" — idempotence is a feature, not a stalled run.
|
|
86
|
+
|
|
87
|
+
## Verification Checklist
|
|
88
|
+
|
|
89
|
+
- [ ] \`npx heedvane doctor\` exits 0 and every check line shows ok
|
|
90
|
+
- [ ] \`~/.heed/heed.yaml\` parses and says \`schemaVersion: v1\`
|
|
91
|
+
- [ ] \`~/.heed/state.json\` skill digest matches the installed skill
|
|
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
|
+
- [ ] Backups exist: \`<config>.heedvane.backup\` next to every patched config
|
|
94
|
+
- [ ] \`/heedvane-status\` in a fresh agent session reports the same agents as \`doctor\`
|
|
95
|
+
`;
|
|
96
|
+
export const EVENTS_MD = `# Heedvane event contract (v1)
|
|
97
|
+
|
|
98
|
+
Harness-neutral taxonomy + JSON envelope. One normalizer maps every
|
|
99
|
+
per-source payload into the canonical shape; unknown events pass through
|
|
100
|
+
as \`unknown_event\` rather than being dropped.
|
|
101
|
+
|
|
102
|
+
## Canonical events (v1)
|
|
103
|
+
|
|
104
|
+
| Event | Fires when |
|
|
105
|
+
| --- | --- |
|
|
106
|
+
| \`session_start\` | A managed agent session begins |
|
|
107
|
+
| \`session_end\` | The session ends (clean or crash) |
|
|
108
|
+
| \`user_prompt_submit\` | The user submits a prompt |
|
|
109
|
+
| \`pre_tool_use\` | Before a tool call executes |
|
|
110
|
+
| \`post_tool_use\` | After a tool call returns |
|
|
111
|
+
| \`post_tool_use_failure\` | A tool call raises/errors |
|
|
112
|
+
| \`permission_request\` | The agent asks for approval |
|
|
113
|
+
| \`stop\` | The agent turn completes |
|
|
114
|
+
| \`stop_failure\` | The turn aborts with an error |
|
|
115
|
+
| \`subagent_start\` | A subagent/task tool spawns |
|
|
116
|
+
| \`subagent_stop\` | The subagent finishes |
|
|
117
|
+
| \`notification\` | Harness notification (idle, attention, updates) |
|
|
118
|
+
| \`pre_compact\` | Before the harness compacts the conversation |
|
|
119
|
+
|
|
120
|
+
## Envelope
|
|
121
|
+
|
|
122
|
+
\`\`\`json
|
|
123
|
+
{
|
|
124
|
+
"v": 1,
|
|
125
|
+
"event": "pre_tool_use",
|
|
126
|
+
"agent": "claude",
|
|
127
|
+
"session": "sess_01HQ…",
|
|
128
|
+
"pane": "iterm:tab-3",
|
|
129
|
+
"receivedAt": "2026-10-10T12:00:00.000Z",
|
|
130
|
+
"payload": { "tool": "Bash", "input": { "command": "ls" } }
|
|
131
|
+
}
|
|
132
|
+
\`\`\`
|
|
133
|
+
|
|
134
|
+
Caps (Orca's numbers): strings ≤ 8 KB, ≤ 500 nodes per payload; oversized
|
|
135
|
+
fields are truncated at ingest, never dropped.
|
|
136
|
+
|
|
137
|
+
## Per-harness mapping
|
|
138
|
+
|
|
139
|
+
| Canonical | Claude Code / Codex | Gemini CLI | Cursor | Hermes plugin |
|
|
140
|
+
| --- | --- | --- | --- | --- |
|
|
141
|
+
| \`session_start\` | \`SessionStart\` | \`SessionStart\` | \`sessionStart\` | \`on_session_start\` |
|
|
142
|
+
| \`session_end\` | \`SessionEnd\` | \`SessionEnd\` | \`sessionEnd\` | \`on_session_finalize\` |
|
|
143
|
+
| \`user_prompt_submit\` | \`UserPromptSubmit\` | \`BeforeAgent\` | \`beforeSubmitPrompt\` | \`pre_llm_call\` |
|
|
144
|
+
| \`pre_tool_use\` | \`PreToolUse\` | \`BeforeTool\` | \`preToolUse\` | \`pre_tool_call\` |
|
|
145
|
+
| \`post_tool_use\` | \`PostToolUse\` | \`AfterTool\` | \`postToolUse\` | \`post_tool_call\` |
|
|
146
|
+
| \`post_tool_use_failure\` | \`PostToolUseFailure\` | — | — | — |
|
|
147
|
+
| \`permission_request\` | \`PermissionRequest\` | — | — | \`pre_approval_request\` |
|
|
148
|
+
| \`stop\` | \`Stop\` | \`AfterAgent\` | \`stop\` | — |
|
|
149
|
+
| \`stop_failure\` | \`StopFailure\` | — | — | — |
|
|
150
|
+
| \`subagent_start\` | \`SubagentStart\` | — | — | — |
|
|
151
|
+
| \`subagent_stop\` | \`SubagentStop\` | — | \`subagentStop\` | — |
|
|
152
|
+
| \`notification\` | \`Notification\` | \`Notification\` | — | — |
|
|
153
|
+
| \`pre_compact\` | \`PreCompact\` | \`PreCompress\` | — | — |
|
|
154
|
+
|
|
155
|
+
Hermes' \`on_session_end\` fires once per turn despite its name, so it passes
|
|
156
|
+
through as \`unknown_event\`, as does \`post_approval_response\`. Cursor's session
|
|
157
|
+
is its \`conversation_id\`.
|
|
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.
|
|
161
|
+
|
|
162
|
+
Payload keys per source keep their harness names inside \`payload\` (e.g.
|
|
163
|
+
Claude's \`tool_input\`, Codex's \`tool_input\`) after normalization of the
|
|
164
|
+
envelope itself; the normalizer never invents fields.
|
|
165
|
+
`;
|
|
166
|
+
/** The canonical file set, ready to install under ~/.agents/skills/heedvane-hooks/. */
|
|
167
|
+
export function canonicalSkillFiles() {
|
|
168
|
+
return [
|
|
169
|
+
{ relativePath: "SKILL.md", content: SKILL_MD },
|
|
170
|
+
{ relativePath: "references/events.md", content: EVENTS_MD },
|
|
171
|
+
];
|
|
172
|
+
}
|
|
173
|
+
/** sha256 over the sorted (relativePath + "\\0" + content) concatenation. */
|
|
174
|
+
export function skillDigest(files) {
|
|
175
|
+
const hash = createHash("sha256");
|
|
176
|
+
for (const f of [...files].sort((a, b) => (a.relativePath < b.relativePath ? -1 : 1))) {
|
|
177
|
+
hash.update(f.relativePath);
|
|
178
|
+
hash.update("\0");
|
|
179
|
+
hash.update(f.content);
|
|
180
|
+
}
|
|
181
|
+
return hash.digest("hex");
|
|
182
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type Backend } from "../backend/client.js";
|
|
2
|
+
export interface ApprovedSkill {
|
|
3
|
+
name: string;
|
|
4
|
+
sha256: string;
|
|
5
|
+
}
|
|
6
|
+
export interface ApprovedSkillList {
|
|
7
|
+
syncedAt: string;
|
|
8
|
+
skills: ApprovedSkill[];
|
|
9
|
+
/** The sign-in (Backend.identity) that synced it; absent in a list an older version wrote. */
|
|
10
|
+
identity?: string;
|
|
11
|
+
}
|
|
12
|
+
export declare function approvedSkillsPath(root: string): string;
|
|
13
|
+
/** The cached approved list, or null when no sync has written one. */
|
|
14
|
+
export declare function readApprovedSkills(root: string): Promise<ApprovedSkillList | null>;
|
|
15
|
+
/** The cached list when this very sign-in synced it, else null: another organization's list, one from before identities were kept, and any with no sign-in never apply. */
|
|
16
|
+
export declare function approvedSkillsFor(root: string, backend: Backend | null): Promise<ApprovedSkillList | null>;
|
|
17
|
+
/** Where synced skills go: the shared skills dir and Claude Code's, in the home or in a project. */
|
|
18
|
+
export declare function skillRoots(base: string): string[];
|
|
19
|
+
export interface SyncResult {
|
|
20
|
+
installed: string[];
|
|
21
|
+
removed: string[];
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Download the approved list, verify every document against its sha256, install each SKILL.md under
|
|
25
|
+
* every root, remove the ones we installed that are no longer approved (only when still unmodified),
|
|
26
|
+
* and cache the list. Throws with the reason when Heedvane does not answer with a valid list.
|
|
27
|
+
*/
|
|
28
|
+
export declare function syncApprovedSkills(backend: Backend, target: {
|
|
29
|
+
root: string;
|
|
30
|
+
base: string;
|
|
31
|
+
}): Promise<SyncResult>;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The organization's approved skills on this machine: `npx heedvane skills sync` installs each
|
|
3
|
+
* approved SKILL.md and caches the list, and the listener denies a skill that is not on it.
|
|
4
|
+
*
|
|
5
|
+
* Source: `GET /api/agent/skills` (docs/product/agent-integration/session-skills-and-retention.md),
|
|
6
|
+
* `{ skills: [{ name, description, revision, sha256, document }] }`, approved revisions only, one per
|
|
7
|
+
* name. That list is the whitelist; `sha256` is the SHA-256 hex of exactly `document` (UTF-8).
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync } from "node:fs";
|
|
10
|
+
import { readdir, readFile, rename, rmdir, unlink } from "node:fs/promises";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import writeFileAtomic from "write-file-atomic";
|
|
13
|
+
import { callHeedvane } from "../backend/client.js";
|
|
14
|
+
import { isRecord, sha256Hex } from "../guards.js";
|
|
15
|
+
import { installSkill, skillBackupPath, skillInstallDir } from "../skill/bundle.js";
|
|
16
|
+
export function approvedSkillsPath(root) {
|
|
17
|
+
return join(root, "approved-skills.json");
|
|
18
|
+
}
|
|
19
|
+
/** The cached approved list, or null when no sync has written one. */
|
|
20
|
+
export async function readApprovedSkills(root) {
|
|
21
|
+
let text;
|
|
22
|
+
try {
|
|
23
|
+
text = await readFile(approvedSkillsPath(root), "utf8");
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
if (err.code === "ENOENT")
|
|
27
|
+
return null;
|
|
28
|
+
throw err;
|
|
29
|
+
}
|
|
30
|
+
return JSON.parse(text);
|
|
31
|
+
}
|
|
32
|
+
/** The cached list when this very sign-in synced it, else null: another organization's list, one from before identities were kept, and any with no sign-in never apply. */
|
|
33
|
+
export async function approvedSkillsFor(root, backend) {
|
|
34
|
+
const list = await readApprovedSkills(root);
|
|
35
|
+
if (list === null || backend === null)
|
|
36
|
+
return null;
|
|
37
|
+
const identity = await backend.identity();
|
|
38
|
+
return identity !== null && list.identity === identity ? list : null;
|
|
39
|
+
}
|
|
40
|
+
/** Where synced skills go: the shared skills dir and Claude Code's, in the home or in a project. */
|
|
41
|
+
export function skillRoots(base) {
|
|
42
|
+
return [join(base, ".agents", "skills"), join(base, ".claude", "skills")];
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Download the approved list, verify every document against its sha256, install each SKILL.md under
|
|
46
|
+
* every root, remove the ones we installed that are no longer approved (only when still unmodified),
|
|
47
|
+
* and cache the list. Throws with the reason when Heedvane does not answer with a valid list.
|
|
48
|
+
*/
|
|
49
|
+
export async function syncApprovedSkills(backend, target) {
|
|
50
|
+
const served = await fetchApprovedSkills(backend);
|
|
51
|
+
const roots = skillRoots(target.base);
|
|
52
|
+
const previous = (await readApprovedSkills(target.root))?.skills ?? [];
|
|
53
|
+
for (const skill of served) {
|
|
54
|
+
// A SKILL.md an earlier sync wrote is no one's own file: only a person's original gets a backup.
|
|
55
|
+
const synced = previous.find((old) => old.name === skill.name)?.sha256;
|
|
56
|
+
const backUp = (existing) => sha256Hex(existing) !== synced;
|
|
57
|
+
for (const skillsRoot of roots) {
|
|
58
|
+
if ((await installSkill(skillsRoot, [{ relativePath: "SKILL.md", content: skill.document }], { name: skill.name, backUp })) === "failed") {
|
|
59
|
+
throw new Error(`could not write ${join(skillInstallDir(skillsRoot, skill.name), "SKILL.md")}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const removed = previous.filter((old) => !served.some((skill) => skill.name === old.name));
|
|
64
|
+
for (const old of removed)
|
|
65
|
+
await removeIfUnmodified(roots, old);
|
|
66
|
+
const identity = await backend.identity();
|
|
67
|
+
const list = { syncedAt: new Date().toISOString(), skills: served.map(({ name, sha256 }) => ({ name, sha256 })), ...(identity === null ? {} : { identity }) };
|
|
68
|
+
await writeFileAtomic(approvedSkillsPath(target.root), `${JSON.stringify(list, null, 2)}\n`, { mode: 0o600 });
|
|
69
|
+
return { installed: served.map((skill) => skill.name), removed: removed.map((skill) => skill.name) };
|
|
70
|
+
}
|
|
71
|
+
/** Heedvane's ORG_SKILL_NAME (apps/web/src/lib/org-skills/org-skills.ts). */
|
|
72
|
+
const SKILL_NAME = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
73
|
+
function refusalMessage(json) {
|
|
74
|
+
return isRecord(json) && typeof json["error"] === "string" ? json["error"] : JSON.stringify(json);
|
|
75
|
+
}
|
|
76
|
+
async function fetchApprovedSkills(backend) {
|
|
77
|
+
const answer = await callHeedvane(backend, { method: "GET", path: "/api/agent/skills", timeoutMs: 15_000 });
|
|
78
|
+
if (answer === null)
|
|
79
|
+
throw new Error(`${backend.url} did not answer`);
|
|
80
|
+
if (answer.status === 401)
|
|
81
|
+
throw new Error(`${backend.url} refused the organization key (401): save a current one with npx heedvane auth org`);
|
|
82
|
+
// The plan gate: no list is written, so the previous one (or whenUnknown) stays in force.
|
|
83
|
+
if (answer.status === 403)
|
|
84
|
+
throw new Error(`${backend.url} does not serve approved skills to this organization (403): ${refusalMessage(answer.json)}`);
|
|
85
|
+
if (answer.status !== 200)
|
|
86
|
+
throw new Error(`${backend.url}/api/agent/skills answered ${answer.status}: ${JSON.stringify(answer.json)}`);
|
|
87
|
+
const skills = isRecord(answer.json) ? answer.json["skills"] : undefined;
|
|
88
|
+
if (!Array.isArray(skills))
|
|
89
|
+
throw new Error("the approved skills answer has no skills list");
|
|
90
|
+
return skills.map(servedSkill);
|
|
91
|
+
}
|
|
92
|
+
function servedSkill(value) {
|
|
93
|
+
if (!isRecord(value) || typeof value["name"] !== "string" || typeof value["sha256"] !== "string" || typeof value["document"] !== "string") {
|
|
94
|
+
throw new Error("an approved skill is missing its name, sha256 or document");
|
|
95
|
+
}
|
|
96
|
+
const { name, sha256, document } = value;
|
|
97
|
+
// The name becomes a directory: only Heedvane's own skill-name form (ORG_SKILL_NAME), which cannot leave the skills root.
|
|
98
|
+
if (!SKILL_NAME.test(name))
|
|
99
|
+
throw new Error(`refusing skill name ${JSON.stringify(name)}`);
|
|
100
|
+
if (sha256Hex(document) !== sha256)
|
|
101
|
+
throw new Error(`skill ${name}: document does not match its approved sha256`);
|
|
102
|
+
return { name, sha256, document };
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Undo what sync wrote for a revoked skill: its SKILL.md, only while unmodified. A SKILL.md that was
|
|
106
|
+
* there before sync is restored from the backup sync took (`skillBackupPath`), and the folder goes only
|
|
107
|
+
* when nothing else is left in it, so a person's own files are never removed.
|
|
108
|
+
*/
|
|
109
|
+
async function removeIfUnmodified(roots, skill) {
|
|
110
|
+
for (const skillsRoot of roots) {
|
|
111
|
+
const dir = skillInstallDir(skillsRoot, skill.name);
|
|
112
|
+
const file = join(dir, "SKILL.md");
|
|
113
|
+
if (!existsSync(file) || sha256Hex(await readFile(file, "utf8")) !== skill.sha256)
|
|
114
|
+
continue;
|
|
115
|
+
const backup = skillBackupPath(file);
|
|
116
|
+
if (existsSync(backup))
|
|
117
|
+
await rename(backup, file);
|
|
118
|
+
else
|
|
119
|
+
await unlink(file);
|
|
120
|
+
if ((await readdir(dir)).length === 0)
|
|
121
|
+
await rmdir(dir);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Envelope } from "../events/contract.js";
|
|
2
|
+
import type { HeedSkillsConfig } from "../heed-file.js";
|
|
3
|
+
import { type ApprovedSkillList } from "./approved.js";
|
|
4
|
+
import { type Verdict } from "../reply/verdict.js";
|
|
5
|
+
export interface SkillUse {
|
|
6
|
+
name: string;
|
|
7
|
+
/** The SKILL.md being read, when the agent names a file. */
|
|
8
|
+
path?: string;
|
|
9
|
+
}
|
|
10
|
+
export interface SkillCheckContext {
|
|
11
|
+
config: HeedSkillsConfig;
|
|
12
|
+
approved: ApprovedSkillList | null;
|
|
13
|
+
home: string;
|
|
14
|
+
}
|
|
15
|
+
export declare function checkSkill(envelope: Envelope, ctx: SkillCheckContext): Verdict;
|
|
16
|
+
/**
|
|
17
|
+
* The envelope with `skillSha256`: the hash of the exact bytes of the SKILL.md the event loads (the
|
|
18
|
+
* file it names, else the first of the project's and the home's copies). Unchanged when it loads no
|
|
19
|
+
* skill or the file cannot be read; the hook never waits on it.
|
|
20
|
+
*/
|
|
21
|
+
export declare function withSkillSha256(envelope: Envelope, home: string): Envelope;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The skill check the listener runs on every `pre_tool_use`: is the skill the agent is about to load on
|
|
3
|
+
* the organization's approved list? Local only (the cached list), so it costs no network round trip.
|
|
4
|
+
*
|
|
5
|
+
* A skill use is Claude Code's `Skill` tool (`tool_input.skill`), or a `Read` of a `SKILL.md` (the skill
|
|
6
|
+
* is its folder name), the same rule the backend's hook ingestion uses. Writing or editing a SKILL.md
|
|
7
|
+
* is authoring a skill, not using one, and is never checked.
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
10
|
+
import { basename, dirname, join } from "node:path";
|
|
11
|
+
import { isRecord, sha256Hex } from "../guards.js";
|
|
12
|
+
import { skillRoots } from "./approved.js";
|
|
13
|
+
import { ALLOW } from "../reply/verdict.js";
|
|
14
|
+
/** This package's own skill: installed by init, never on an organization's list. */
|
|
15
|
+
const OWN_SKILL = "heedvane-hooks";
|
|
16
|
+
/** The tool events that can load a skill (ingestion's TOOL_EVENTS). */
|
|
17
|
+
const TOOL_EVENTS = new Set(["pre_tool_use", "post_tool_use", "post_tool_use_failure"]);
|
|
18
|
+
/** The skill a tool event loads, or null when it loads none. */
|
|
19
|
+
function skillUse(envelope) {
|
|
20
|
+
if (!TOOL_EVENTS.has(envelope.event))
|
|
21
|
+
return null;
|
|
22
|
+
const input = envelope.payload["tool_input"];
|
|
23
|
+
if (!isRecord(input))
|
|
24
|
+
return null;
|
|
25
|
+
const tool = envelope.payload["tool"];
|
|
26
|
+
if (tool === "Skill" && typeof input["skill"] === "string")
|
|
27
|
+
return { name: input["skill"] };
|
|
28
|
+
if (tool !== "Read")
|
|
29
|
+
return null;
|
|
30
|
+
const path = [input["file_path"], input["path"]].find((value) => typeof value === "string");
|
|
31
|
+
return path !== undefined && basename(path) === "SKILL.md" ? { name: basename(dirname(path)), path } : null;
|
|
32
|
+
}
|
|
33
|
+
export function checkSkill(envelope, ctx) {
|
|
34
|
+
const use = envelope.event === "pre_tool_use" ? skillUse(envelope) : null;
|
|
35
|
+
if (use === null || use.name === OWN_SKILL || !ctx.config.enforce)
|
|
36
|
+
return ALLOW;
|
|
37
|
+
if (ctx.approved === null)
|
|
38
|
+
return unknownList(use, ctx.config);
|
|
39
|
+
const approved = ctx.approved.skills.find((skill) => skill.name === use.name);
|
|
40
|
+
if (approved === undefined) {
|
|
41
|
+
return steer(`Skill "${use.name}" is not approved by your organization. Ask an owner or admin to approve it on Heedvane's Skills page, then run \`npx heedvane skills sync\`.`);
|
|
42
|
+
}
|
|
43
|
+
if (ctx.config.strict && !matchesApproved(use, approved.sha256, localSkillFiles(use.name, envelope, ctx.home))) {
|
|
44
|
+
return steer(`Skill "${use.name}" differs from the revision your organization approved. Run \`npx heedvane skills sync\` to restore it.`);
|
|
45
|
+
}
|
|
46
|
+
return ALLOW;
|
|
47
|
+
}
|
|
48
|
+
function unknownList(use, config) {
|
|
49
|
+
const message = `Skill "${use.name}" could not be checked: no approved-skills list is synced on this machine (run \`npx heedvane skills sync\`).`;
|
|
50
|
+
return config.whenUnknown === "deny" ? steer(message) : { decision: "warn", messages: [message], context: [] };
|
|
51
|
+
}
|
|
52
|
+
function steer(message) {
|
|
53
|
+
return { decision: "steer", messages: [message], context: [] };
|
|
54
|
+
}
|
|
55
|
+
/** Where the skill could be loaded from: the file the agent named, else the project's and the home's skill dirs. */
|
|
56
|
+
function localSkillFiles(name, envelope, home) {
|
|
57
|
+
const cwd = typeof envelope.payload["cwd"] === "string" ? envelope.payload["cwd"] : undefined;
|
|
58
|
+
const bases = cwd === undefined ? [home] : [cwd, home];
|
|
59
|
+
return bases.flatMap(skillRoots).map((root) => join(root, name, "SKILL.md"));
|
|
60
|
+
}
|
|
61
|
+
function matchesApproved(use, sha256, candidates) {
|
|
62
|
+
const file = use.path ?? candidates.find((path) => existsSync(path));
|
|
63
|
+
return file !== undefined && existsSync(file) && sha256Hex(readFileSync(file, "utf8")) === sha256;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The envelope with `skillSha256`: the hash of the exact bytes of the SKILL.md the event loads (the
|
|
67
|
+
* file it names, else the first of the project's and the home's copies). Unchanged when it loads no
|
|
68
|
+
* skill or the file cannot be read; the hook never waits on it.
|
|
69
|
+
*/
|
|
70
|
+
export function withSkillSha256(envelope, home) {
|
|
71
|
+
const use = skillUse(envelope);
|
|
72
|
+
if (use === null)
|
|
73
|
+
return envelope;
|
|
74
|
+
const file = use.path ?? localSkillFiles(use.name, envelope, home).find((path) => existsSync(path));
|
|
75
|
+
if (file === undefined)
|
|
76
|
+
return envelope;
|
|
77
|
+
try {
|
|
78
|
+
return { ...envelope, skillSha256: sha256Hex(readFileSync(file)) };
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return envelope; // unreadable (gone, a directory, no permission): forwarded without the hash
|
|
82
|
+
}
|
|
83
|
+
}
|
package/dist/state.d.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI-owned machine state (<root>/state.json).
|
|
3
|
+
*
|
|
4
|
+
* heed.yaml is 100% human-owned; this file is 100% CLI-owned and never hand-edited.
|
|
5
|
+
* `doctor` compares state.json ↔ live disk ↔ heed.yaml. Written atomically (mode 0600)
|
|
6
|
+
* so a crash never leaves a half-written state behind.
|
|
7
|
+
*/
|
|
8
|
+
export interface HeedStateEndpoint {
|
|
9
|
+
port: number;
|
|
10
|
+
token: string;
|
|
11
|
+
}
|
|
12
|
+
export interface HeedInstalledClient {
|
|
13
|
+
/** Marker/entry names this install owns in the agent's config. */
|
|
14
|
+
entries: string[];
|
|
15
|
+
/** Content digests of the files we wrote, keyed by absolute path. */
|
|
16
|
+
digests: Record<string, string>;
|
|
17
|
+
}
|
|
18
|
+
export interface HeedState {
|
|
19
|
+
schemaVersion: 1;
|
|
20
|
+
protocolVersion: string;
|
|
21
|
+
endpoint: HeedStateEndpoint;
|
|
22
|
+
installed: Record<string, HeedInstalledClient>;
|
|
23
|
+
skill: {
|
|
24
|
+
version: string;
|
|
25
|
+
digest: string;
|
|
26
|
+
} | null;
|
|
27
|
+
}
|
|
28
|
+
/** Error for a state.json that exists but cannot be trusted. Carries the exact remediation. */
|
|
29
|
+
export declare class HeedStateCorruptError extends Error {
|
|
30
|
+
readonly path: string;
|
|
31
|
+
constructor(path: string, reason: string);
|
|
32
|
+
}
|
|
33
|
+
export declare function statePath(root: string): string;
|
|
34
|
+
/**
|
|
35
|
+
* A fresh, empty state for a new install. Endpoint starts unassigned
|
|
36
|
+
* (port 0 / empty token) until the listener resolves and persists one.
|
|
37
|
+
*/
|
|
38
|
+
export declare function emptyState(skillDigest?: string | null): HeedState;
|
|
39
|
+
/**
|
|
40
|
+
* Read <root>/state.json. Returns null when the file does not exist.
|
|
41
|
+
* Throws {@link HeedStateCorruptError} (remediation: "delete <path> or run
|
|
42
|
+
* npx heedvane doctor") when the file exists but is unparseable or does not
|
|
43
|
+
* match the HeedState schema.
|
|
44
|
+
*/
|
|
45
|
+
export declare function readState(root: string): Promise<HeedState | null>;
|
|
46
|
+
/**
|
|
47
|
+
* Atomically write state to <root>/state.json (create parent dirs, mode 0600).
|
|
48
|
+
* write-file-atomic writes to a sibling temp file and renames, so readers never
|
|
49
|
+
* observe a partial state.
|
|
50
|
+
*/
|
|
51
|
+
export declare function writeStateAtomic(root: string, state: HeedState): Promise<void>;
|