heedvane 0.0.0-stage → 0.1.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.
Files changed (94) hide show
  1. package/README.md +27 -2
  2. package/dist/apply.d.ts +104 -0
  3. package/dist/apply.js +388 -0
  4. package/dist/backend/client.d.ts +40 -0
  5. package/dist/backend/client.js +32 -0
  6. package/dist/backend/credentials.d.ts +15 -0
  7. package/dist/backend/credentials.js +76 -0
  8. package/dist/backend/login.d.ts +33 -0
  9. package/dist/backend/login.js +265 -0
  10. package/dist/backend/repo-remote.d.ts +15 -0
  11. package/dist/backend/repo-remote.js +61 -0
  12. package/dist/backend/sign-in-change.d.ts +12 -0
  13. package/dist/backend/sign-in-change.js +72 -0
  14. package/dist/best-effort.d.ts +4 -0
  15. package/dist/best-effort.js +25 -0
  16. package/dist/cli.d.ts +24 -0
  17. package/dist/cli.js +544 -0
  18. package/dist/clients/definitions.d.ts +113 -0
  19. package/dist/clients/definitions.js +427 -0
  20. package/dist/clients/hermes-plugin.d.ts +33 -0
  21. package/dist/clients/hermes-plugin.js +247 -0
  22. package/dist/commands/heedvane.d.ts +15 -0
  23. package/dist/commands/heedvane.js +74 -0
  24. package/dist/commands/memory.d.ts +10 -0
  25. package/dist/commands/memory.js +106 -0
  26. package/dist/config-doc.d.ts +97 -0
  27. package/dist/config-doc.js +465 -0
  28. package/dist/dispatchers/posix.d.ts +32 -0
  29. package/dist/dispatchers/posix.js +191 -0
  30. package/dist/dispatchers/snippets.d.ts +6 -0
  31. package/dist/dispatchers/snippets.js +39 -0
  32. package/dist/dispatchers/windows.d.ts +15 -0
  33. package/dist/dispatchers/windows.js +50 -0
  34. package/dist/doctor.d.ts +37 -0
  35. package/dist/doctor.js +316 -0
  36. package/dist/events/contract.d.ts +54 -0
  37. package/dist/events/contract.js +87 -0
  38. package/dist/events/normalize.d.ts +36 -0
  39. package/dist/events/normalize.js +143 -0
  40. package/dist/guards.d.ts +18 -0
  41. package/dist/guards.js +28 -0
  42. package/dist/heed-file.d.ts +67 -0
  43. package/dist/heed-file.js +295 -0
  44. package/dist/listener/endpoint.d.ts +21 -0
  45. package/dist/listener/endpoint.js +112 -0
  46. package/dist/listener/forward.d.ts +36 -0
  47. package/dist/listener/forward.js +257 -0
  48. package/dist/listener/serve.d.ts +2 -0
  49. package/dist/listener/serve.js +155 -0
  50. package/dist/listener/server.d.ts +48 -0
  51. package/dist/listener/server.js +251 -0
  52. package/dist/listener/spool.d.ts +28 -0
  53. package/dist/listener/spool.js +60 -0
  54. package/dist/mcp-server.d.ts +9 -0
  55. package/dist/mcp-server.js +187 -0
  56. package/dist/mcp.d.ts +57 -0
  57. package/dist/mcp.js +83 -0
  58. package/dist/memory/private-memory.d.ts +12 -0
  59. package/dist/memory/private-memory.js +54 -0
  60. package/dist/memory/table-of-contents.d.ts +10 -0
  61. package/dist/memory/table-of-contents.js +80 -0
  62. package/dist/memory/team-memory.d.ts +60 -0
  63. package/dist/memory/team-memory.js +54 -0
  64. package/dist/plan.d.ts +77 -0
  65. package/dist/plan.js +288 -0
  66. package/dist/render.d.ts +30 -0
  67. package/dist/render.js +131 -0
  68. package/dist/reply/harness-output.d.ts +57 -0
  69. package/dist/reply/harness-output.js +88 -0
  70. package/dist/reply/reply.d.ts +65 -0
  71. package/dist/reply/reply.js +169 -0
  72. package/dist/reply/verdict.d.ts +28 -0
  73. package/dist/reply/verdict.js +17 -0
  74. package/dist/skill/bundle.d.ts +23 -0
  75. package/dist/skill/bundle.js +102 -0
  76. package/dist/skill/content.d.ts +16 -0
  77. package/dist/skill/content.js +182 -0
  78. package/dist/skills/approved.d.ts +31 -0
  79. package/dist/skills/approved.js +123 -0
  80. package/dist/skills/check.d.ts +21 -0
  81. package/dist/skills/check.js +83 -0
  82. package/dist/state.d.ts +51 -0
  83. package/dist/state.js +118 -0
  84. package/dist/steering/rules.d.ts +49 -0
  85. package/dist/steering/rules.js +165 -0
  86. package/dist/steering/steer.d.ts +29 -0
  87. package/dist/steering/steer.js +64 -0
  88. package/dist/steering/violations.d.ts +27 -0
  89. package/dist/steering/violations.js +24 -0
  90. package/dist/uninstall.d.ts +23 -0
  91. package/dist/uninstall.js +159 -0
  92. package/package.json +34 -4
  93. package/skills/heedvane-hooks/SKILL.md +87 -0
  94. package/skills/heedvane-hooks/references/events.md +69 -0
@@ -0,0 +1,191 @@
1
+ import { chmod, mkdir, readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import writeFileAtomic from "write-file-atomic";
4
+ import { validateAgentId } from "./snippets.js";
5
+ /** The stdout a Claude Code PreToolUse hook prints to deny (src/reply/harness-output.ts owns the shape). */
6
+ const UNCHECKED_SKILL_DENY = JSON.stringify({
7
+ hookSpecificOutput: {
8
+ hookEventName: "PreToolUse",
9
+ permissionDecision: "deny",
10
+ permissionDecisionReason: "Skills need the Heedvane listener to check them against your organization's approved list, and it is not answering. Start it with: npx heedvane listener start",
11
+ },
12
+ });
13
+ export function dispatcherScript(agentId, options = {}) {
14
+ validateAgentId(agentId);
15
+ // Only Claude Code's deny shape is verified; other agents keep the fail-open fallback.
16
+ const denySkills = options.denySkillsWhenUnchecked === true && agentId === "claude";
17
+ const gating = options.gating ?? "always";
18
+ return `#!/bin/sh
19
+ # heedvane-agent-hook dispatcher for ${agentId} — generated by heedvane-hook. Do not hand-edit.
20
+ # Rewritten wholesale by \`npx heedvane setup\`; policy updates never touch agent configs.
21
+ # Marker: heedvane-agent-hook-form
22
+ # This hook never fails the agent: every path exits 0 and prints exactly one JSON answer, the
23
+ # listener's when it answers in time, else the fallback below.
24
+
25
+ set -u
26
+ umask 077
27
+
28
+ HEED_HOOK_AGENT="${agentId}"
29
+ HEED_HOOK_VERSION=1
30
+ HEED_HOOK_SPOOL_CAP=5242880
31
+ HEED_HOOK_SPOOL_TTL_DAYS=7
32
+
33
+ [ -n "\${HOME:-}" ] || exit 0
34
+
35
+ # heed.yaml gating.mode: "pane" runs the hooks only in sessions launched with HEED_SESSION set;
36
+ # "always" runs them in every session. Any other session gets no answer, as if no hook ran.
37
+ HEED_HOOK_GATING="${gating}"
38
+ [ "$HEED_HOOK_GATING" = "always" ] || [ -n "\${HEED_SESSION:-}" ] || exit 0
39
+
40
+ # endpoint.env (written by init/setup/listener start) sits in the heed root; the spool beside it.
41
+ HEED_HOOK_ENDPOINT_FILE="\${HEED_AGENT_HOOK_ENDPOINT:-\${HEED_AGENT_HOOK_ROOT:-$HOME/.heed}/endpoint.env}"
42
+
43
+ heed_spool_dir() {
44
+ printf '%s\\n' "\${HEED_HOOK_ENDPOINT_FILE%/*}/spool"
45
+ }
46
+
47
+ # Escape a string for embedding inside a JSON document (POSIX awk).
48
+ # Encodes backslash, double quote, tab and CR; newlines are re-encoded per record.
49
+ # Other C0 control chars survive as-is — payloads are harness JSON, and v1
50
+ # treats a best-effort escape as acceptable for spool records.
51
+ json_escape() {
52
+ printf '%s' "$1" | LC_ALL=C awk '
53
+ NR > 1 { printf "\\\\n" }
54
+ {
55
+ gsub(/\\\\/, "\\\\\\\\")
56
+ gsub(/"/, "\\\\\\"")
57
+ gsub(/\\t/, "\\\\t")
58
+ gsub(/\\r/, "\\\\r")
59
+ printf "%s", $0
60
+ }'
61
+ }
62
+
63
+ spool_hook_event() {
64
+ _body="\${1:-}"
65
+ [ -n "$_body" ] || return 0
66
+ _dir="$(heed_spool_dir)"
67
+ mkdir -p "$_dir" 2>/dev/null || return 0
68
+ _key="\${HEED_PANE_KEY:-\${HEED_SESSION:-}}"
69
+ [ -n "$_key" ] || _key="default"
70
+ _file="$_dir/pane-$_key.jsonl"
71
+
72
+ # 7-day TTL: prune stale pane spool files, and the events set aside after a change of sign-in (other-sign-in/), on every write (cheap).
73
+ find "$_dir" -type f \\( -name 'pane-*.jsonl*' -o -path '*/other-sign-in/*' \\) -mtime "+$HEED_HOOK_SPOOL_TTL_DAYS" -delete 2>/dev/null || :
74
+
75
+ # 5 MB cap: once a pane file is over budget, drop further events (never grow unbounded).
76
+ if [ -f "$_file" ]; then
77
+ _size="$(wc -c < "$_file" 2>/dev/null || echo 0)"
78
+ [ "$_size" -lt "$HEED_HOOK_SPOOL_CAP" ] || return 0
79
+ fi
80
+
81
+ _received="$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u)"
82
+ _envelope="{\\"paneKey\\":\\"$(json_escape "\${HEED_PANE_KEY:-\${HEED_SESSION:-}}")\\""
83
+ _envelope="$_envelope,\\"tabId\\":\\"$(json_escape "\${HEED_TAB_ID:-}")\\""
84
+ _envelope="$_envelope,\\"worktreeId\\":\\"$(json_escape "\${HEED_WORKTREE_ID:-}")\\""
85
+ _envelope="$_envelope,\\"launchToken\\":\\"$(json_escape "\${HEED_LAUNCH_TOKEN:-}")\\""
86
+ _envelope="$_envelope,\\"source\\":\\"$(json_escape "$HEED_HOOK_AGENT")\\""
87
+ _envelope="$_envelope,\\"receivedAt\\":\\"$(json_escape "$_received")\\""
88
+ _envelope="$_envelope,\\"id\\":\\"$(json_escape "\${heed_event_id:-}")\\""
89
+ _envelope="$_envelope,\\"payload\\":\\"$(json_escape "$_body")\\"}"
90
+
91
+ printf '%s\\n' "$_envelope" >> "$_file" 2>/dev/null || return 0
92
+ chmod 600 "$_file" 2>/dev/null || :
93
+ return 0
94
+ }
95
+
96
+ # The hook payload arrives on stdin. command -p keeps cat working under a stripped PATH.
97
+ payload="$(command -p cat 2>/dev/null || cat)"
98
+
99
+ # One id per event, sent live and kept in the spool, so Heedvane can drop the copy when a slow
100
+ # listener already forwarded an event this script then spooled.
101
+ heed_event_id="$(cat /proc/sys/kernel/random/uuid 2>/dev/null || uuidgen 2>/dev/null || od -An -N16 -tx1 /dev/urandom 2>/dev/null | tr -d ' \\n')"
102
+ heed_event_id="$(printf '%s' "$heed_event_id" | tr -cd 'A-Za-z0-9-' | cut -c1-64)"
103
+
104
+ # What the agent gets when the listener cannot answer: {} lets it continue.
105
+ heed_fallback='{}'
106
+ ${denySkills ? `case "$payload" in
107
+ *'"PreToolUse"'*) case "$payload" in
108
+ *'"tool_name":"Skill"'*|*'"tool_name": "Skill"'*|*'SKILL.md"'*) heed_fallback='${UNCHECKED_SKILL_DENY.replaceAll("'", "'\\''")}' ;;
109
+ esac ;;
110
+ esac` : "# Skills are not denied without the listener (heedvane.skills.whenUnknown: allow)."}
111
+ ${agentId === "claude" ? `# The organization's instructions, pre-rendered by the listener at its last policy sync, are injected
112
+ # even when it cannot answer.
113
+ case "$payload" in
114
+ *'"hook_event_name":"SessionStart"'*|*'"hook_event_name": "SessionStart"'*) heed_instructions="SessionStart" ;;
115
+ *'"hook_event_name":"UserPromptSubmit"'*|*'"hook_event_name": "UserPromptSubmit"'*) heed_instructions="UserPromptSubmit" ;;
116
+ *) heed_instructions="" ;;
117
+ esac
118
+ heed_instructions_file="\${HEED_HOOK_ENDPOINT_FILE%/*}/steering/instructions-\${heed_instructions}.json"
119
+ if [ -n "$heed_instructions" ] && [ -r "$heed_instructions_file" ]; then
120
+ heed_fallback="$(cat "$heed_instructions_file" 2>/dev/null || printf '{}')"
121
+ fi` : ""}
122
+
123
+ heed_answer() {
124
+ printf '%s\\n' "$1"
125
+ exit 0
126
+ }
127
+
128
+ [ -n "$payload" ] || heed_answer '{}'
129
+
130
+ # Port/token come from endpoint.env (HEED_AGENT_HOOK_PORT / HEED_AGENT_HOOK_TOKEN lines).
131
+ # shellcheck disable=SC1090
132
+ if [ -r "$HEED_HOOK_ENDPOINT_FILE" ]; then
133
+ . "$HEED_HOOK_ENDPOINT_FILE" 2>/dev/null || :
134
+ fi
135
+
136
+ # No listener contract (endpoint env, port, token): spool instead, still exit 0.
137
+ if [ -z "\${HEED_AGENT_HOOK_PORT:-}" ] || [ -z "\${HEED_AGENT_HOOK_TOKEN:-}" ]; then
138
+ spool_hook_event "$payload"
139
+ heed_answer "$heed_fallback"
140
+ fi
141
+
142
+ # A local answer takes milliseconds. Only a session start waits for Heedvane (its one policy fetch).
143
+ heed_max_time=0.8
144
+ case "$payload" in
145
+ *'"hook_event_name":"SessionStart"'*|*'"hook_event_name": "SessionStart"'*) heed_max_time=2 ;;
146
+ esac
147
+
148
+ reply="$(printf '%s' "$payload" | curl -sS --fail -X POST "http://127.0.0.1:\${HEED_AGENT_HOOK_PORT}/hook/${agentId}" \\
149
+ --connect-timeout 0.5 --max-time "$heed_max_time" --noproxy 127.0.0.1 \\
150
+ -H "Content-Type: application/json" \\
151
+ -H "X-Heed-Agent-Hook-Token: \${HEED_AGENT_HOOK_TOKEN}" \\
152
+ -H "X-Heed-Hook-Version: \${HEED_HOOK_VERSION}" \\
153
+ -H "X-Heed-Event-Id: \${heed_event_id:-}" \\
154
+ --data-binary @- 2>/dev/null)" || {
155
+ spool_hook_event "$payload"
156
+ heed_answer "$heed_fallback"
157
+ }
158
+
159
+ # The listener's answer is the agent's hook output (skill check, steering, recalled memory).
160
+ case "$reply" in
161
+ "{"*) heed_answer "$reply" ;;
162
+ *) heed_answer "$heed_fallback" ;;
163
+ esac
164
+ `;
165
+ }
166
+ /** The on-disk dispatcher directory under the heed root. */
167
+ export function agentHooksDir(home) {
168
+ return join(home, ".heed", "agent-hooks");
169
+ }
170
+ /** Write (or update) the dispatcher script for one agent. Idempotent, mode 0700. */
171
+ export async function writeDispatcherScript(home, agent, options = {}) {
172
+ validateAgentId(agent);
173
+ const dir = agentHooksDir(home);
174
+ await mkdir(dir, { recursive: true, mode: 0o700 });
175
+ const target = join(dir, `${agent}-hook.sh`);
176
+ const content = dispatcherScript(agent, options);
177
+ const existing = await currentDispatcherScript(target);
178
+ if (existing === content)
179
+ return; // already current
180
+ await writeFileAtomic(target, content, { mode: 0o700 });
181
+ await chmod(target, 0o700);
182
+ }
183
+ /** The dispatcher script's current text, or null when absent (it will be written below). */
184
+ async function currentDispatcherScript(target) {
185
+ try {
186
+ return await readFile(target, "utf8");
187
+ }
188
+ catch {
189
+ return null; // absent — write below
190
+ }
191
+ }
@@ -0,0 +1,6 @@
1
+ /** Compat alias — A's modules import HOOK_SCRIPT_MARKER from this module. */
2
+ export declare const HOOK_SCRIPT_MARKER = "heedvane-agent-hook-form";
3
+ export declare function validateAgentId(agentId: string): string;
4
+ export declare function genericSnippet(agentId: string): string;
5
+ export declare function claudeSnippet(): string;
6
+ export declare function codexSnippet(): string;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Per-agent inline snippet generators.
3
+ *
4
+ * The snippet is the tiny sh one-liner embedded in the agent's native hook config. It must:
5
+ * (a) delegate through a two-tier lookup: HEED_AGENT_HOOK_ROOT when set and
6
+ * readable, else $HOME/.heed/agent-hooks — so policy updates never rewrite
7
+ * agent configs. Which sessions run the hooks (heed.yaml `gating.mode`), the
8
+ * port and the token are the dispatcher script's to decide;
9
+ * (b) always exit 0. Observability hooks must never block the agent.
10
+ *
11
+ * The trailing `# heedvane-agent-hook-form` comment is the uninstall marker:
12
+ * `npx heedvane uninstall` removes only entries carrying it. The marker is
13
+ * owned by src/mcp.ts (SNIPPET_MARKER) so uninstall/doctor share one constant.
14
+ */
15
+ import { SNIPPET_MARKER as marker } from "../mcp.js";
16
+ /** Compat alias — A's modules import HOOK_SCRIPT_MARKER from this module. */
17
+ export const HOOK_SCRIPT_MARKER = marker;
18
+ export function validateAgentId(agentId) {
19
+ if (!/^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/.test(agentId)) {
20
+ throw new Error(`invalid agent id: ${JSON.stringify(agentId)}`);
21
+ }
22
+ return agentId;
23
+ }
24
+ export function genericSnippet(agentId) {
25
+ validateAgentId(agentId);
26
+ return [
27
+ `d="\${HEED_AGENT_HOOK_ROOT:-$HOME/.heed}/agent-hooks";`,
28
+ `s="$d/${agentId}-hook.sh";`,
29
+ `[ -r "$s" ] || s="$HOME/.heed/agent-hooks/${agentId}-hook.sh";`,
30
+ `[ -r "$s" ] && sh "$s" "$@";`,
31
+ `exit 0 # ${marker}`,
32
+ ].join(" ");
33
+ }
34
+ export function claudeSnippet() {
35
+ return genericSnippet("claude");
36
+ }
37
+ export function codexSnippet() {
38
+ return genericSnippet("codex");
39
+ }
@@ -0,0 +1,15 @@
1
+ import type { DispatcherOptions } from "./posix.js";
2
+ /**
3
+ * Windows .cmd launcher (best-effort tier — the plan marks Windows untested until
4
+ * CI covers it). It:
5
+ * 1. in heed.yaml's default `gating.mode: pane`, no-ops unless HEED_SESSION is set; with
6
+ * `always`, runs in every session (the POSIX dispatcher decides the same way);
7
+ * 2. resolves the shared POSIX dispatcher via the two-tier lookup
8
+ * (HEED_AGENT_HOOK_ROOT → %HOME% → %USERPROFILE%);
9
+ * 3. prefers running that dispatcher through any POSIX shell on PATH (Git for
10
+ * Windows / MSYS2 / BusyBox) — hook stdin passes through, so spooling still
11
+ * works with the listener down;
12
+ * 4. otherwise posts via curl.exe with the same headers as the POSIX dispatcher and prints
13
+ * the listener's answer (nothing, which lets the agent continue, when it fails).
14
+ */
15
+ export declare function windowsLauncher(agentId: string, options?: Pick<DispatcherOptions, "gating">): string;
@@ -0,0 +1,50 @@
1
+ import { validateAgentId } from "./snippets.js";
2
+ /**
3
+ * Windows .cmd launcher (best-effort tier — the plan marks Windows untested until
4
+ * CI covers it). It:
5
+ * 1. in heed.yaml's default `gating.mode: pane`, no-ops unless HEED_SESSION is set; with
6
+ * `always`, runs in every session (the POSIX dispatcher decides the same way);
7
+ * 2. resolves the shared POSIX dispatcher via the two-tier lookup
8
+ * (HEED_AGENT_HOOK_ROOT → %HOME% → %USERPROFILE%);
9
+ * 3. prefers running that dispatcher through any POSIX shell on PATH (Git for
10
+ * Windows / MSYS2 / BusyBox) — hook stdin passes through, so spooling still
11
+ * works with the listener down;
12
+ * 4. otherwise posts via curl.exe with the same headers as the POSIX dispatcher and prints
13
+ * the listener's answer (nothing, which lets the agent continue, when it fails).
14
+ */
15
+ export function windowsLauncher(agentId, options = {}) {
16
+ validateAgentId(agentId);
17
+ const gate = (options.gating ?? "always") === "always" ? "rem heed.yaml gating.mode: always, so every session runs the hooks." : "if not defined HEED_SESSION exit /b 0";
18
+ return `@echo off
19
+ setlocal EnableExtensions
20
+ rem heedvane-agent-hook launcher for ${agentId} — generated by heedvane-hook. Do not hand-edit.
21
+ rem Marker: heedvane-agent-hook-form
22
+ rem Every path exits 0. The listener's answer (steering, skill check) is printed as the hook output.
23
+
24
+ ${gate}
25
+
26
+ set "HEED_HOOK_SCRIPT="
27
+ if defined HEED_AGENT_HOOK_ROOT if exist "%HEED_AGENT_HOOK_ROOT%\\agent-hooks\\${agentId}-hook.sh" set "HEED_HOOK_SCRIPT=%HEED_AGENT_HOOK_ROOT%\\agent-hooks\\${agentId}-hook.sh"
28
+ if not defined HEED_HOOK_SCRIPT if defined HOME if exist "%HOME%\\.heed\\agent-hooks\\${agentId}-hook.sh" set "HEED_HOOK_SCRIPT=%HOME%\\.heed\\agent-hooks\\${agentId}-hook.sh"
29
+ if not defined HEED_HOOK_SCRIPT if defined USERPROFILE if exist "%USERPROFILE%\\.heed\\agent-hooks\\${agentId}-hook.sh" set "HEED_HOOK_SCRIPT=%USERPROFILE%\\.heed\\agent-hooks\\${agentId}-hook.sh"
30
+ if not defined HEED_HOOK_SCRIPT exit /b 0
31
+
32
+ rem Preferred: the shared POSIX dispatcher via any available POSIX shell.
33
+ rem Hook stdin passes through to the script, which spools when the listener is down.
34
+ where sh >nul 2>&1
35
+ if %errorlevel%==0 (
36
+ sh "%HEED_HOOK_SCRIPT%"
37
+ exit /b 0
38
+ )
39
+
40
+ rem No POSIX shell: post via curl.exe with the same headers as the POSIX dispatcher.
41
+ if not defined HEED_AGENT_HOOK_PORT exit /b 0
42
+ if not defined HEED_AGENT_HOOK_TOKEN exit /b 0
43
+
44
+ set "HEED_HOOK_INPUT=%TEMP%\\heed-hook-%RANDOM%-%RANDOM%.json"
45
+ more > "%HEED_HOOK_INPUT%" 2>nul
46
+ curl.exe -s --fail -X POST "http://127.0.0.1:%HEED_AGENT_HOOK_PORT%/hook/${agentId}" --connect-timeout 0.5 --max-time 1.5 --noproxy 127.0.0.1 -H "Content-Type: application/json" -H "X-Heed-Agent-Hook-Token: %HEED_AGENT_HOOK_TOKEN%" -H "X-Heed-Hook-Version: 1" --data-binary "@%HEED_HOOK_INPUT%"
47
+ del "%HEED_HOOK_INPUT%" >nul 2>&1
48
+ exit /b 0
49
+ `;
50
+ }
@@ -0,0 +1,37 @@
1
+ import { type Env } from "./clients/definitions.js";
2
+ import { type DocFormat } from "./config-doc.js";
3
+ export type RegistrationState = "aligned" | "stale" | "missing";
4
+ export interface DoctorCheck {
5
+ ok: boolean;
6
+ name: string;
7
+ detail: string;
8
+ remediation?: string;
9
+ /** Warn-only checks never fail the report (listener reachability). */
10
+ warnOnly?: boolean;
11
+ }
12
+ export interface DoctorReport {
13
+ healthy: boolean;
14
+ checks: DoctorCheck[];
15
+ /** Client ids the report was scoped to, when a scope was given. */
16
+ scopedTo?: string[];
17
+ }
18
+ /** Where the desired entry for a client capability would be written. */
19
+ export interface DesiredEntry {
20
+ path: string;
21
+ format: DocFormat;
22
+ containerKey: string;
23
+ entryName: string;
24
+ entry: unknown;
25
+ }
26
+ export type DesiredEntryLookup = (clientId: string, capability: "hooks" | "mcp") => DesiredEntry[] | undefined;
27
+ export interface DoctorInput {
28
+ home: string;
29
+ env: Env;
30
+ /** Restrict checks to these client ids (the --client flag). */
31
+ clients?: string[];
32
+ /** Emits the {status, checks, healthy} envelope in cli; kept here for API parity. */
33
+ json?: boolean;
34
+ /** Desired-entry lookup injection; defaults to the pure planner. */
35
+ desired?: DesiredEntryLookup;
36
+ }
37
+ export declare function runDoctor(input: DoctorInput): Promise<DoctorReport>;
package/dist/doctor.js ADDED
@@ -0,0 +1,316 @@
1
+ /**
2
+ * Scoped, versioned doctor. Every check carries {ok, name, detail,
3
+ * remediation}. The desired-entry lookup is injected (defaulting to the
4
+ * pure planner's desiredEntriesFor) so this module never imports apply.ts.
5
+ */
6
+ import { existsSync, readFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { supportedClients } from "./clients/definitions.js";
9
+ import { entryValue, parseDoc, readEntry, valuesEqual } from "./config-doc.js";
10
+ import { loadHeedFileAt } from "./heed-file.js";
11
+ import { desiredEntriesFor, hooksDeferredTo, isClientDetected } from "./plan.js";
12
+ import { readInstalledSkillDigest } from "./skill/bundle.js";
13
+ import { HeedStateCorruptError, readState } from "./state.js";
14
+ import { readApprovedSkills } from "./skills/approved.js";
15
+ import { readEndpoint } from "./listener/endpoint.js";
16
+ import { errorMessage } from "./guards.js";
17
+ const INIT_REMEDIATION = "run npx heedvane init";
18
+ const SETUP_REMEDIATION = "run npx heedvane setup";
19
+ /** Inspect one desired entry on disk; every failure path produces a detail line, never a throw. */
20
+ function entryVerdict(d, home) {
21
+ const path = d.path.startsWith("~") ? join(home, d.path.slice(1)) : d.path;
22
+ if (!existsSync(path)) {
23
+ return { aligned: false, present: false, detail: `${d.entryName}: missing (${d.path})` };
24
+ }
25
+ let doc;
26
+ try {
27
+ doc = parseDoc(readFileSync(path, "utf8"), d.format);
28
+ }
29
+ catch {
30
+ return { aligned: false, present: true, detail: `${d.entryName}: config does not parse` };
31
+ }
32
+ if (d.containerKey === "") {
33
+ // Whole-file desired (hermes plugin.yaml).
34
+ const ok = readFileSync(path, "utf8") === d.entry;
35
+ return { aligned: ok, present: true, detail: `${d.entryName}: ${ok ? "aligned" : "stale"}` };
36
+ }
37
+ const current = readEntry(doc, d.containerKey, d.entryName);
38
+ if (current === undefined) {
39
+ return { aligned: false, present: true, detail: `${d.entryName}: missing in ${d.path}` };
40
+ }
41
+ if (!valuesEqual(current, entryValue(d.containerKey, current, d.entry))) {
42
+ return { aligned: false, present: true, detail: `${d.entryName}: stale in ${d.path}` };
43
+ }
44
+ return { aligned: true, present: true, detail: `${d.entryName}: aligned` };
45
+ }
46
+ function registrationState(desireds, home) {
47
+ let sawAny = false;
48
+ let allAligned = true;
49
+ const parts = [];
50
+ for (const d of desireds) {
51
+ const verdict = entryVerdict(d, home);
52
+ if (verdict.present)
53
+ sawAny = true;
54
+ if (!verdict.aligned)
55
+ allAligned = false;
56
+ parts.push(verdict.detail);
57
+ }
58
+ if (!sawAny && !allAligned)
59
+ return { state: "missing", detail: parts.join("; ") || "no heedvane entry found" };
60
+ if (!allAligned)
61
+ return { state: "stale", detail: parts.join("; ") };
62
+ return { state: "aligned", detail: parts.join("; ") };
63
+ }
64
+ function checkNode() {
65
+ const major = Number.parseInt(process.versions.node.split(".")[0] ?? "0", 10);
66
+ const ok = major >= 22;
67
+ return {
68
+ ok,
69
+ name: "node",
70
+ detail: `node ${process.versions.node} (${ok ? ">= 22" : "< 22 — heedvane requires Node 22+"})`,
71
+ remediation: ok ? undefined : "upgrade Node to >= 22 (nvm install 22)",
72
+ };
73
+ }
74
+ function checkHeedFile(home) {
75
+ const path = join(home, ".heed", "heed.yaml");
76
+ if (!existsSync(path)) {
77
+ return {
78
+ check: { ok: false, name: "heed.yaml", detail: `missing at ${path}`, remediation: INIT_REMEDIATION },
79
+ };
80
+ }
81
+ try {
82
+ const parsed = loadHeedFileAt(path);
83
+ if (parsed.schemaVersion !== "v1") {
84
+ return {
85
+ check: { ok: false, name: "heed.yaml", detail: `unsupported schemaVersion ${parsed.schemaVersion}`, remediation: INIT_REMEDIATION },
86
+ };
87
+ }
88
+ return {
89
+ check: { ok: true, name: "heed.yaml", detail: `parses, schemaVersion ${parsed.schemaVersion}, agents: ${parsed.agents === "auto" ? "auto" : parsed.agents.join(", ")}` },
90
+ heedFile: parsed,
91
+ };
92
+ }
93
+ catch (err) {
94
+ return {
95
+ check: {
96
+ ok: false,
97
+ name: "heed.yaml",
98
+ detail: `does not parse: ${errorMessage(err)}`,
99
+ remediation: INIT_REMEDIATION,
100
+ },
101
+ };
102
+ }
103
+ }
104
+ function checkState() {
105
+ // state.json surfaces via readState in runDoctor; kept for doc parity.
106
+ }
107
+ void checkState;
108
+ /** The client ids the report is scoped to, or null for all clients. */
109
+ function scopeSet(clients) {
110
+ return clients && clients.length > 0 ? new Set(clients) : null;
111
+ }
112
+ /** The pure-planner desired-entry lookup, resolving each client id against home/env. */
113
+ function defaultDesiredLookup(home, env) {
114
+ return (clientId, capability) => {
115
+ const client = supportedClients({ home, platform: process.platform, env }).find((c) => c.id === clientId);
116
+ if (!client)
117
+ return undefined;
118
+ return desiredEntriesFor(client, capability, { home, env }).map((d) => ({
119
+ path: d.path,
120
+ format: d.format,
121
+ containerKey: d.containerKey,
122
+ entryName: d.entryName,
123
+ entry: d.entry,
124
+ }));
125
+ };
126
+ }
127
+ /** Read state.json, pushing a remediation-carrying check when it exists but is corrupt. */
128
+ async function readStateOrReport(root, checks) {
129
+ try {
130
+ return await readState(root);
131
+ }
132
+ catch (err) {
133
+ if (!(err instanceof HeedStateCorruptError))
134
+ throw err;
135
+ checks.push({
136
+ ok: false,
137
+ name: "state.json",
138
+ detail: `corrupt: ${err.message}`,
139
+ remediation: `delete ${join(root, "state.json")} and ${SETUP_REMEDIATION}`,
140
+ });
141
+ return null;
142
+ }
143
+ }
144
+ /** Does heed.yaml (when present) have setup configure this capability for this client? */
145
+ function heedFileWants(client, capability, ctx) {
146
+ const heedFile = ctx.heedFile;
147
+ if (heedFile === undefined)
148
+ return true;
149
+ if (heedFile.agents !== "auto" && !heedFile.agents.includes(client.id))
150
+ return false;
151
+ if (!heedFile.install[capability])
152
+ return false;
153
+ // Setup skips hooks another installed client already runs here (Cursor runs Claude Code's).
154
+ return capability !== "hooks" || hooksDeferredTo(client, { home: ctx.home, env: ctx.env, heedFile }) === undefined;
155
+ }
156
+ /** Registration verdicts for the capabilities this client should have configured. */
157
+ function collectCapabilityStates(client, ctx) {
158
+ const states = [];
159
+ for (const capability of ["hooks", "mcp"]) {
160
+ const supported = client.capabilities.includes(capability) || (capability === "hooks" && client.id === "hermes" && client.capabilities.includes("plugin-install"));
161
+ if (!supported || !heedFileWants(client, capability, ctx))
162
+ continue;
163
+ const desireds = ctx.desired(client.id, capability);
164
+ if (!desireds || desireds.length === 0)
165
+ continue;
166
+ states.push({ capability, result: registrationState(desireds, ctx.home) });
167
+ }
168
+ return states;
169
+ }
170
+ /** One detected client's registration check (aligned / stale / missing with per-entry detail). */
171
+ function pushRegistrationChecks(checks, client, ctx) {
172
+ const detected = isClientDetected(client);
173
+ if (!detected) {
174
+ // Under `agents: auto`, an undetected client is normal — no check.
175
+ // Only fail when the user explicitly listed this client in heed.yaml.
176
+ const explicitlyListed = ctx.heedFile && ctx.heedFile.agents !== "auto" && ctx.heedFile.agents.includes(client.id);
177
+ if (!explicitlyListed)
178
+ return;
179
+ checks.push({
180
+ ok: false,
181
+ name: `${client.id} registration`,
182
+ detail: `${client.displayName} not detected (no ${client.markerPath})`,
183
+ remediation: `install ${client.displayName} or remove ${client.id} from heed.yaml agents`,
184
+ });
185
+ return;
186
+ }
187
+ const states = collectCapabilityStates(client, ctx);
188
+ if (states.length === 0)
189
+ return;
190
+ const allAligned = states.every((s) => s.result.state === "aligned");
191
+ const detail = states.map((s) => `${s.capability}: ${s.result.state} (${s.result.detail})`).join("; ");
192
+ checks.push({
193
+ ok: allAligned,
194
+ name: `${client.id} registration`,
195
+ detail,
196
+ remediation: allAligned ? undefined : SETUP_REMEDIATION,
197
+ });
198
+ }
199
+ /** The skill-digest verdict against state.json (aligned / stale / missing / unrecorded). */
200
+ function skillDigestCheck(skillsRoot, state, installedDigest) {
201
+ const name = "skill digest";
202
+ if (state === null) {
203
+ return installedDigest !== null
204
+ ? { ok: false, name, detail: "installed but state.json has no record", remediation: SETUP_REMEDIATION }
205
+ : { ok: false, name, detail: "skill missing and no state.json", remediation: INIT_REMEDIATION };
206
+ }
207
+ const expected = state.skill?.digest;
208
+ if (expected === undefined) {
209
+ return { ok: false, name, detail: "state.json records no skill install", remediation: SETUP_REMEDIATION };
210
+ }
211
+ if (installedDigest === null) {
212
+ return { ok: false, name, detail: `skill not installed at ${join(skillsRoot, "heedvane-hooks")}`, remediation: SETUP_REMEDIATION };
213
+ }
214
+ if (installedDigest === expected) {
215
+ return { ok: true, name, detail: `matches state.json (${expected.slice(0, 12)}…)` };
216
+ }
217
+ return {
218
+ ok: false,
219
+ name,
220
+ detail: `stale: installed ${installedDigest.slice(0, 12)}…, state.json ${expected.slice(0, 12)}…`,
221
+ remediation: SETUP_REMEDIATION,
222
+ };
223
+ }
224
+ export async function runDoctor(input) {
225
+ const { home, env } = input;
226
+ const checks = [];
227
+ // 1. Node version.
228
+ checks.push(checkNode());
229
+ // 2. heed.yaml exists + parses (schemaVersion v1).
230
+ const { check: heedCheck, heedFile } = checkHeedFile(home);
231
+ checks.push(heedCheck);
232
+ // 3. state.json (corrupt → remediation).
233
+ const root = join(home, ".heed");
234
+ const state = await readStateOrReport(root, checks);
235
+ // 4. Desired-entry lookup: injected, falling back to the pure planner.
236
+ const desired = input.desired ?? defaultDesiredLookup(home, env);
237
+ // 5. Per detected client: registration state aligned | stale | missing.
238
+ const scope = scopeSet(input.clients);
239
+ const allClients = supportedClients({ home, platform: process.platform, env });
240
+ const clients = allClients.filter((c) => (scope ? scope.has(c.id) : true));
241
+ const regCtx = { home, env, heedFile, desired };
242
+ for (const client of clients) {
243
+ pushRegistrationChecks(checks, client, regCtx);
244
+ }
245
+ // 6. Skill digest vs state.json (aligned / stale / missing), unless heed.yaml opts out of the skill.
246
+ if (heedFile?.install.skill !== false) {
247
+ const skillsRoot = join(home, ".agents", "skills");
248
+ const installedDigest = await readInstalledSkillDigest(skillsRoot);
249
+ checks.push(skillDigestCheck(skillsRoot, state, installedDigest));
250
+ }
251
+ // 7. Listener reachable — best-effort POST, warn-only, never fails the report.
252
+ checks.push(await checkListener(root));
253
+ checks.push(await checkApprovedSkills(root, heedFile));
254
+ const healthy = checks.every((c) => c.ok || c.warnOnly);
255
+ return { healthy, checks, scopedTo: scope ? [...scope] : undefined };
256
+ }
257
+ const ENFORCEMENT_OFF = "skill checks, steering and memory are off until it runs (skills follow heedvane.skills.whenUnknown)";
258
+ /** A synced approved list older than this is reported, not ignored: revocations reach this machine only on sync. */
259
+ const APPROVED_LIST_STALE_MS = 7 * 24 * 60 * 60 * 1000;
260
+ /** Warn-only: is there an approved-skills list for the skill check to enforce, and is it recent? */
261
+ async function checkApprovedSkills(root, heedFile) {
262
+ const base = { ok: true, name: "approved skills", detail: "", warnOnly: true };
263
+ const skills = heedFile?.heedvane.skills;
264
+ if (skills?.enforce === false)
265
+ return { ...base, detail: "not enforced (heedvane.skills.enforce: false)" };
266
+ const list = await readApprovedSkills(root);
267
+ const sync = "run npx heedvane skills sync";
268
+ if (list === null) {
269
+ const detail = skills?.whenUnknown === "deny"
270
+ ? "no list synced — every skill is denied (heedvane.skills.whenUnknown: deny)"
271
+ : "SKILLS ARE NOT ENFORCED: no list synced, so every skill is allowed unchecked (heedvane.skills.whenUnknown: allow)";
272
+ return { ...base, ok: false, detail, remediation: `${sync} (a failed sync printed why)` };
273
+ }
274
+ const age = Date.now() - Date.parse(list.syncedAt);
275
+ if (age > APPROVED_LIST_STALE_MS)
276
+ return { ...base, ok: false, detail: `${list.skills.length} approved, synced ${list.syncedAt}: revocations since then are not enforced`, remediation: sync };
277
+ return { ...base, detail: `${list.skills.length} approved, synced ${list.syncedAt}` };
278
+ }
279
+ async function checkListener(root) {
280
+ const base = { ok: true, name: "listener", detail: "", warnOnly: true };
281
+ let endpoint;
282
+ try {
283
+ endpoint = await readEndpoint(root);
284
+ }
285
+ catch {
286
+ return { ...base, detail: "endpoint.env unreadable — listener not configured (opt-in in v1)" };
287
+ }
288
+ if (!endpoint) {
289
+ return {
290
+ ...base,
291
+ detail: `not running — ${ENFORCEMENT_OFF}; hook events spool to ~/.heed/spool until a listener starts (npx heedvane listener start)`,
292
+ };
293
+ }
294
+ if (!endpoint.port) {
295
+ return { ...base, detail: "endpoint.env incomplete (no port) — re-run npx heedvane setup" };
296
+ }
297
+ try {
298
+ const res = await fetch(`http://127.0.0.1:${endpoint.port}/hook/claude`, {
299
+ method: "POST",
300
+ headers: { "content-type": "application/json", ...(endpoint.token ? { "x-heed-agent-hook-token": endpoint.token } : {}) },
301
+ body: JSON.stringify({ v: 1, event: "doctor_probe", agent: "claude", receivedAt: new Date().toISOString() }),
302
+ signal: AbortSignal.timeout(750),
303
+ });
304
+ if (res.ok || res.status === 400 || res.status === 401) {
305
+ // Reachable is the point; auth/payload validation is the server's business.
306
+ return { ...base, detail: `reachable on 127.0.0.1:${endpoint.port}` };
307
+ }
308
+ return { ...base, detail: `responded ${res.status} on 127.0.0.1:${endpoint.port} (reachable)` };
309
+ }
310
+ catch {
311
+ return {
312
+ ...base,
313
+ detail: `not reachable on 127.0.0.1:${endpoint.port} — ${ENFORCEMENT_OFF}; events will spool (warn-only; never fails the report)`,
314
+ };
315
+ }
316
+ }