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.
Files changed (98) hide show
  1. package/README.md +62 -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 +29 -0
  17. package/dist/cli.js +666 -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 +41 -0
  29. package/dist/dispatchers/posix.js +208 -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 +303 -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 +78 -0
  43. package/dist/heed-file.js +316 -0
  44. package/dist/installer.d.ts +47 -0
  45. package/dist/installer.js +174 -0
  46. package/dist/listener/background.d.ts +8 -0
  47. package/dist/listener/background.js +36 -0
  48. package/dist/listener/endpoint.d.ts +26 -0
  49. package/dist/listener/endpoint.js +125 -0
  50. package/dist/listener/forward.d.ts +36 -0
  51. package/dist/listener/forward.js +257 -0
  52. package/dist/listener/serve.d.ts +2 -0
  53. package/dist/listener/serve.js +155 -0
  54. package/dist/listener/server.d.ts +48 -0
  55. package/dist/listener/server.js +251 -0
  56. package/dist/listener/spool.d.ts +28 -0
  57. package/dist/listener/spool.js +60 -0
  58. package/dist/mcp-server.d.ts +9 -0
  59. package/dist/mcp-server.js +187 -0
  60. package/dist/mcp.d.ts +57 -0
  61. package/dist/mcp.js +83 -0
  62. package/dist/memory/private-memory.d.ts +12 -0
  63. package/dist/memory/private-memory.js +54 -0
  64. package/dist/memory/table-of-contents.d.ts +10 -0
  65. package/dist/memory/table-of-contents.js +80 -0
  66. package/dist/memory/team-memory.d.ts +60 -0
  67. package/dist/memory/team-memory.js +54 -0
  68. package/dist/plan.d.ts +77 -0
  69. package/dist/plan.js +288 -0
  70. package/dist/render.d.ts +32 -0
  71. package/dist/render.js +132 -0
  72. package/dist/reply/harness-output.d.ts +57 -0
  73. package/dist/reply/harness-output.js +88 -0
  74. package/dist/reply/reply.d.ts +65 -0
  75. package/dist/reply/reply.js +169 -0
  76. package/dist/reply/verdict.d.ts +28 -0
  77. package/dist/reply/verdict.js +17 -0
  78. package/dist/skill/bundle.d.ts +23 -0
  79. package/dist/skill/bundle.js +102 -0
  80. package/dist/skill/content.d.ts +16 -0
  81. package/dist/skill/content.js +182 -0
  82. package/dist/skills/approved.d.ts +31 -0
  83. package/dist/skills/approved.js +123 -0
  84. package/dist/skills/check.d.ts +21 -0
  85. package/dist/skills/check.js +83 -0
  86. package/dist/state.d.ts +51 -0
  87. package/dist/state.js +118 -0
  88. package/dist/steering/rules.d.ts +49 -0
  89. package/dist/steering/rules.js +165 -0
  90. package/dist/steering/steer.d.ts +29 -0
  91. package/dist/steering/steer.js +64 -0
  92. package/dist/steering/violations.d.ts +27 -0
  93. package/dist/steering/violations.js +24 -0
  94. package/dist/uninstall.d.ts +23 -0
  95. package/dist/uninstall.js +159 -0
  96. package/package.json +35 -4
  97. package/skills/heedvane-hooks/SKILL.md +87 -0
  98. package/skills/heedvane-hooks/references/events.md +69 -0
@@ -0,0 +1,12 @@
1
+ import type { Envelope } from "../events/contract.js";
2
+ import { type Verdict } from "../reply/verdict.js";
3
+ export declare function memoryDir(root: string): string;
4
+ /**
5
+ * The private memories to inject at session start, in at most `room` characters (what the organization's
6
+ * rules left of the session-start budget, src/reply/harness-output.ts); `allow` with no context when there
7
+ * are none. What does not fit is cut with a note naming where the rest is; with no room for any, a pointer; with no room for that, nothing.
8
+ */
9
+ export declare function recall(root: string, request: {
10
+ envelope: Envelope;
11
+ repository: string;
12
+ }, room: number): Promise<Verdict>;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * A developer's private memories stay on their machine: plain Markdown under `<root>/memory/`
3
+ * (`~/.heed/memory/`), written by the developer or their agent, never sent to Heedvane. At session
4
+ * start the hook adds them as context, so every agent (not only the one that wrote them) starts with
5
+ * them:
6
+ *
7
+ * - `MEMORY.md`: memories for every repository.
8
+ * - `<owner>/<name>.md`: memories for one repository (its origin's owner/name).
9
+ *
10
+ * Team memory is separate and lives in Heedvane.
11
+ */
12
+ import { readFile } from "node:fs/promises";
13
+ import { join } from "node:path";
14
+ import { ALLOW } from "../reply/verdict.js";
15
+ export function memoryDir(root) {
16
+ return join(root, "memory");
17
+ }
18
+ /** The memory files for a repository (`owner/name`, or "" outside a git checkout), general first. */
19
+ function memoryFiles(root, repository) {
20
+ const general = join(memoryDir(root), "MEMORY.md");
21
+ return repository === "" ? [general] : [general, join(memoryDir(root), ...repository.split("/")) + ".md"];
22
+ }
23
+ /**
24
+ * The private memories to inject at session start, in at most `room` characters (what the organization's
25
+ * rules left of the session-start budget, src/reply/harness-output.ts); `allow` with no context when there
26
+ * are none. What does not fit is cut with a note naming where the rest is; with no room for any, a pointer; with no room for that, nothing.
27
+ */
28
+ export async function recall(root, request, room) {
29
+ if (request.envelope.event !== "session_start")
30
+ return ALLOW;
31
+ const texts = await Promise.all(memoryFiles(root, request.repository).map(readMemory));
32
+ const text = texts.filter((t) => t !== "").join("\n\n");
33
+ if (text === "")
34
+ return ALLOW;
35
+ const dir = memoryDir(root);
36
+ const header = `Your private memories (${dir}):\n\n`;
37
+ const note = `\n[truncated: the rest is in ${dir}]`;
38
+ const pointer = `Your private memories are in ${dir}; read them there.`;
39
+ const fits = header.length + text.length <= room;
40
+ const keep = room - header.length - note.length;
41
+ const cutText = keep > 0 ? header + text.slice(0, keep) + note : null;
42
+ const context = fits ? header + text : (cutText ?? pointer);
43
+ return context.length <= room ? { decision: "allow", messages: [], context: [context] } : ALLOW;
44
+ }
45
+ async function readMemory(path) {
46
+ try {
47
+ return (await readFile(path, "utf8")).trim();
48
+ }
49
+ catch (err) {
50
+ if (err.code === "ENOENT")
51
+ return "";
52
+ throw err;
53
+ }
54
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The team memory's table of contents for the session-start block (docs/product/agent-integration/policies.md
3
+ * "Session start" owns where it sits: after the policies, before the private memories). Folders and titles,
4
+ * never memory text. It fills at most the room it is given and shrinks in a fixed order: titles first,
5
+ * starting with the largest folders; then every folder keeps only its count; last, one line with the
6
+ * total. Every shorter form still says where the rest is.
7
+ */
8
+ import type { TeamMemoryMap } from "./team-memory.js";
9
+ /** The table of contents in at most `room` characters; "" when not even the total line fits. */
10
+ export declare function renderTableOfContents(map: TeamMemoryMap, repository: string, room: number): string;
@@ -0,0 +1,80 @@
1
+ /** A title is cut here. */
2
+ const TITLE_CHARACTERS = 100;
3
+ const quoted = (folder) => `"${folder}"`;
4
+ /** Cuts on whole characters (never inside a surrogate pair) and says so with an ellipsis. */
5
+ function shortTitle(title) {
6
+ const characters = Array.from(title);
7
+ return characters.length <= TITLE_CHARACTERS ? title : `${characters.slice(0, TITLE_CHARACTERS - 1).join("")}…`;
8
+ }
9
+ function entryLine(entry) {
10
+ const commit = entry.validatedCommitSha === null ? "" : ` at ${entry.validatedCommitSha.slice(0, 7)}`;
11
+ return ` [${entry.id}] ${shortTitle(entry.title)} · confirmed ${entry.validatedAt.slice(0, 10)}${commit}`;
12
+ }
13
+ function summary(map, repository) {
14
+ const { live, expired, retired } = map.counts;
15
+ const gone = [expired > 0 ? `${expired} expired` : "", retired > 0 ? `${retired} retired` : ""].filter((part) => part !== "").join(", ");
16
+ return `Team memory for ${repository}: ${live} memories in ${map.folders.length} folders${gone === "" ? "" : ` (${gone}: see the team memory page)`}.`;
17
+ }
18
+ const HOW_TO = [
19
+ 'Open a folder: list_team_memory "<folder>" (or: npx heedvane memory list "<folder>"). Read one: get_team_memory <id> (npx heedvane memory show <id>).',
20
+ 'Search: search_team_memory "<question>", optionally inside one folder (npx heedvane memory search "<question>").',
21
+ ];
22
+ /** Entries by folder, in the order Heedvane gave them, as the lines they print. */
23
+ function entryLinesByFolder(map) {
24
+ const byFolder = new Map();
25
+ for (const entry of map.entries) {
26
+ const lines = byFolder.get(entry.folder) ?? [];
27
+ lines.push(entryLine(entry));
28
+ byFolder.set(entry.folder, lines);
29
+ }
30
+ return byFolder;
31
+ }
32
+ /** Every folder with at most `cap` of its entry lines, and a line naming the rest. */
33
+ function withTitles(table, cap) {
34
+ const { header, map, byFolder } = table;
35
+ const lines = [...header];
36
+ for (const { folder, count } of map.folders) {
37
+ const shown = (byFolder.get(folder) ?? []).slice(0, cap);
38
+ lines.push(`${folder} (${count})`, ...shown);
39
+ if (shown.length < count)
40
+ lines.push(` ... ${count - shown.length} more: list_team_memory ${quoted(folder)}`);
41
+ }
42
+ return lines.join("\n");
43
+ }
44
+ /**
45
+ * The most entries per folder that fit in `room`, or 0 when not even one does. A table never gets shorter
46
+ * when a folder shows one more title, so the answer is found by halving; no cap above `room` divided by the
47
+ * shortest entry line can fit, so the search starts there.
48
+ */
49
+ function largestCapThatFits(room, byFolder, render) {
50
+ const lines = [...byFolder.values()].flat();
51
+ const shortest = lines.reduce((least, line) => Math.min(least, line.length), Infinity);
52
+ let high = Math.min(Math.max(0, ...[...byFolder.values()].map((folder) => folder.length)), Math.floor(room / shortest));
53
+ let low = 0;
54
+ while (low < high) {
55
+ const middle = Math.ceil((low + high) / 2);
56
+ if (render(middle).length <= room)
57
+ low = middle;
58
+ else
59
+ high = middle - 1;
60
+ }
61
+ return low;
62
+ }
63
+ /** The table of contents in at most `room` characters; "" when not even the total line fits. */
64
+ export function renderTableOfContents(map, repository, room) {
65
+ if (map.counts.live === 0) {
66
+ const empty = `Team memory for ${repository} has no memories yet.`;
67
+ return empty.length <= room ? empty : "";
68
+ }
69
+ const byFolder = entryLinesByFolder(map);
70
+ const header = [summary(map, repository), ...HOW_TO];
71
+ const table = { header, map, byFolder };
72
+ const cap = largestCapThatFits(room, byFolder, (candidate) => withTitles(table, candidate));
73
+ if (cap >= 1)
74
+ return withTitles(table, cap);
75
+ const counts = [...header, ...map.folders.map(({ folder, count }) => `${folder} (${count})`)].join("\n");
76
+ if (counts.length <= room)
77
+ return counts;
78
+ const total = `${summary(map, repository).replace(/ \(.*\)\.$/, ".")} List them: list_team_memory (npx heedvane memory list).`;
79
+ return total.length <= room ? total : "";
80
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Heedvane's team memory from this machine (docs/engineering/openviking-foundation.md "Team memory",
3
+ * the hooks client's routes): `POST /api/agent/memory/<action>` with the checkout's `gitOrigin`. The
4
+ * session-start table of contents (src/memory/table-of-contents.ts) and `npx heedvane memory ...` read
5
+ * the same routes. A folder is Heedvane's canonical name (one or two segments, raw UTF-8); this client
6
+ * hands names back exactly as the routes gave them.
7
+ */
8
+ import { type Backend } from "../backend/client.js";
9
+ export type TeamMemoryAction = "map" | "list" | "search" | "get";
10
+ /** One live, unexpired memory as the map and a list show it: never its text. */
11
+ export interface TeamMemoryEntry {
12
+ id: string;
13
+ title: string;
14
+ folder: string;
15
+ createdAt: string;
16
+ validatedAt: string;
17
+ commitSha: string | null;
18
+ validatedCommitSha: string | null;
19
+ }
20
+ /** Every live, unexpired memory of a repository, most recently confirmed first, and its folders in name order (`_general` last). */
21
+ export interface TeamMemoryMap {
22
+ repositoryId: string;
23
+ generatedAt: string;
24
+ entries: TeamMemoryEntry[];
25
+ folders: Array<{
26
+ folder: string;
27
+ count: number;
28
+ }>;
29
+ counts: {
30
+ live: number;
31
+ expired: number;
32
+ retired: number;
33
+ };
34
+ }
35
+ /** The outcome of asking for a route: Heedvane's JSON, or why there is none. */
36
+ export type TeamMemoryAnswer = {
37
+ state: "answered";
38
+ json: Record<string, unknown>;
39
+ } | {
40
+ state: "unavailable";
41
+ status: number | null;
42
+ error: string;
43
+ };
44
+ export declare function askTeamMemory(backend: Backend, ask: {
45
+ action: TeamMemoryAction;
46
+ body: Record<string, unknown>;
47
+ timeoutMs?: number;
48
+ }): Promise<TeamMemoryAnswer>;
49
+ /** What the session start learned of the repository's team memory: its map, none to show (no plan, repository not connected), or an unanswered ask. */
50
+ export type TeamMemoryStart = {
51
+ state: "map";
52
+ map: TeamMemoryMap;
53
+ } | {
54
+ state: "none";
55
+ } | {
56
+ state: "unavailable";
57
+ error: string;
58
+ };
59
+ /** The map of the repository at `gitOrigin`. A refusal that is about this checkout (a plan without team memory, a repository Heedvane does not know) is "none", not a failure. */
60
+ export declare function fetchTeamMemoryMap(backend: Backend, gitOrigin: string): Promise<TeamMemoryStart>;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Heedvane's team memory from this machine (docs/engineering/openviking-foundation.md "Team memory",
3
+ * the hooks client's routes): `POST /api/agent/memory/<action>` with the checkout's `gitOrigin`. The
4
+ * session-start table of contents (src/memory/table-of-contents.ts) and `npx heedvane memory ...` read
5
+ * the same routes. A folder is Heedvane's canonical name (one or two segments, raw UTF-8); this client
6
+ * hands names back exactly as the routes gave them.
7
+ */
8
+ import { callHeedvane } from "../backend/client.js";
9
+ import { isRecord } from "../guards.js";
10
+ /** How long the session start waits for the map; the one wait on Heedvane besides the policy fetch, run beside it. */
11
+ const TEAM_MEMORY_MAP_TIMEOUT_MS = 1_000;
12
+ const TEAM_MEMORY_TIMEOUT_MS = 15_000;
13
+ export async function askTeamMemory(backend, ask) {
14
+ const { action, body, timeoutMs = TEAM_MEMORY_TIMEOUT_MS } = ask;
15
+ const answer = await callHeedvane(backend, { method: "POST", path: `/api/agent/memory/${action}`, body, timeoutMs });
16
+ if (answer === null)
17
+ return { state: "unavailable", status: null, error: "Heedvane did not answer" };
18
+ if (answer.status === 200 && isRecord(answer.json))
19
+ return { state: "answered", json: answer.json };
20
+ const error = isRecord(answer.json) && typeof answer.json["error"] === "string" ? answer.json["error"] : `status ${answer.status}`;
21
+ return { state: "unavailable", status: answer.status, error };
22
+ }
23
+ /** The `map` route's answer; null when it is not one. */
24
+ function teamMemoryMap(json) {
25
+ const { repositoryId, generatedAt, entries, folders, counts } = json;
26
+ if (typeof repositoryId !== "string" || typeof generatedAt !== "string" || !Array.isArray(entries) || !Array.isArray(folders) || !isRecord(counts))
27
+ return null;
28
+ const { live, expired, retired } = counts;
29
+ if (typeof live !== "number" || typeof expired !== "number" || typeof retired !== "number")
30
+ return null;
31
+ return {
32
+ repositoryId,
33
+ generatedAt,
34
+ entries: entries.flatMap((entry) => teamMemoryEntry(entry) ?? []),
35
+ folders: folders.flatMap((folder) => (isRecord(folder) && typeof folder["folder"] === "string" && typeof folder["count"] === "number" ? [{ folder: folder["folder"], count: folder["count"] }] : [])),
36
+ counts: { live, expired, retired },
37
+ };
38
+ }
39
+ function teamMemoryEntry(value) {
40
+ if (!isRecord(value))
41
+ return null;
42
+ const { id, title, folder, createdAt, validatedAt, commitSha, validatedCommitSha } = value;
43
+ if (typeof id !== "string" || typeof title !== "string" || typeof folder !== "string" || typeof createdAt !== "string" || typeof validatedAt !== "string")
44
+ return null;
45
+ return { id, title, folder, createdAt, validatedAt, commitSha: typeof commitSha === "string" ? commitSha : null, validatedCommitSha: typeof validatedCommitSha === "string" ? validatedCommitSha : null };
46
+ }
47
+ /** The map of the repository at `gitOrigin`. A refusal that is about this checkout (a plan without team memory, a repository Heedvane does not know) is "none", not a failure. */
48
+ export async function fetchTeamMemoryMap(backend, gitOrigin) {
49
+ const answer = await askTeamMemory(backend, { action: "map", body: { gitOrigin }, timeoutMs: TEAM_MEMORY_MAP_TIMEOUT_MS });
50
+ if (answer.state === "unavailable")
51
+ return answer.status !== null && answer.status < 500 ? { state: "none" } : { state: "unavailable", error: answer.error };
52
+ const map = teamMemoryMap(answer.json);
53
+ return map === null ? { state: "unavailable", error: "the map answer is not one" } : { state: "map", map };
54
+ }
package/dist/plan.d.ts ADDED
@@ -0,0 +1,77 @@
1
+ import { type Capability, type Env, type ResolvedClient } from "./clients/definitions.js";
2
+ import { type DocFormat } from "./config-doc.js";
3
+ import type { HeedFile, HeedGatingConfig } from "./heed-file.js";
4
+ export type ActionKind = "hooks" | "mcp" | "skill" | "skip";
5
+ export type Operation = "create" | "update" | "unchanged" | "skip";
6
+ export interface PlannedAction {
7
+ clientId: string;
8
+ kind: ActionKind;
9
+ operation: Operation;
10
+ path: string;
11
+ backupPath?: string;
12
+ reason?: string;
13
+ }
14
+ export interface PlanInput {
15
+ home: string;
16
+ platform: string;
17
+ env: Env;
18
+ heedFile: HeedFile;
19
+ /** Agents named with `--agent`: installed even when not detected on this machine. */
20
+ forced?: readonly string[];
21
+ /** Desired skill digest; defaults to the canonical bundled content. */
22
+ skillDigest?: string;
23
+ }
24
+ /** Our MCP stdio registration entry (unscoped package per the plan's decision). */
25
+ export declare function mcpEntry(): {
26
+ command: string;
27
+ args: string[];
28
+ };
29
+ interface Desired {
30
+ path: string;
31
+ format: DocFormat;
32
+ containerKey: string;
33
+ entryName: string;
34
+ entry: unknown;
35
+ }
36
+ /** A client's primary hooks config target, resolved. */
37
+ export declare function hooksTarget(client: ResolvedClient): {
38
+ path: string;
39
+ format: DocFormat;
40
+ } | undefined;
41
+ /** Where a client's files live: the object form of the per-client path parameters. */
42
+ export interface ClientContext {
43
+ home: string;
44
+ env: Env;
45
+ }
46
+ /**
47
+ * Desired write targets per client × capability. This is the desired-state
48
+ * compiler heed.yaml → concrete files; doctor reuses it via a callback so
49
+ * it never needs apply.ts. `env` is accepted for API stability — the client's
50
+ * config paths arrive already resolved against it.
51
+ */
52
+ export declare function desiredEntriesFor(client: ResolvedClient, capability: Capability, context: ClientContext & {
53
+ gating?: HeedGatingConfig["mode"];
54
+ }): Desired[];
55
+ /**
56
+ * The client whose hooks this one already runs, when that client's hooks are installed too, by this run or
57
+ * an earlier one: installing both would run every event twice, recorded under two agents. Plan and doctor
58
+ * both skip on it.
59
+ */
60
+ export declare function hooksDeferredTo(client: ResolvedClient, context: ClientContext & AgentScope): ResolvedClient | undefined;
61
+ /** Whether the client's hooks file already holds our hook on any of its events. */
62
+ export declare function ourHooksOnDisk(client: ResolvedClient): boolean;
63
+ /** Which agents a run manages: heed.yaml's `agents`, plus the ones named with `--agent`. */
64
+ interface AgentScope {
65
+ heedFile: HeedFile;
66
+ forced?: ReadonlySet<string>;
67
+ }
68
+ /** Whether a client is detected on this machine via its primary marker or any altMarkerPaths (e.g. GUI ADEs). */
69
+ export declare function isClientDetected(client: ResolvedClient): boolean;
70
+ /**
71
+ * The planner. For each client a run installs (installsClient), emit one action per enabled
72
+ * capability the client supports — or an honest skip with a reason it doesn't.
73
+ */
74
+ export declare function planActions(input: PlanInput): PlannedAction[];
75
+ /** Count of distinct agents that would receive a write for a kind. */
76
+ export declare function wouldInstallCount(actions: PlannedAction[], kind: "hooks" | "mcp" | "skill"): number;
77
+ export {};
package/dist/plan.js ADDED
@@ -0,0 +1,288 @@
1
+ /**
2
+ * The PURE planner: heed.yaml + machine detection → PlannedAction[].
3
+ *
4
+ * Zero side effects — read-only fs for detection/inspection only. This is
5
+ * what makes --dry-run trivially trustworthy: the same function feeds the
6
+ * --dry-run printout and the apply step.
7
+ */
8
+ import { existsSync, readFileSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { supportedClients, } from "./clients/definitions.js";
11
+ import { hermesPluginFiles } from "./clients/hermes-plugin.js";
12
+ import { entryValue, isOurHookGroup, parseDoc, readEntry, TOP_LEVEL, valuesEqual } from "./config-doc.js";
13
+ import { genericSnippet } from "./dispatchers/snippets.js";
14
+ import { MCP_ENTRY_ARGS, desiredServer } from "./mcp.js";
15
+ import { canonicalSkillFiles, skillDigest } from "./skill/content.js";
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
+ /** A client's primary hooks config target, resolved. */
22
+ export function hooksTarget(client) {
23
+ const target = client.configs.find((t) => t.purpose === "hooks") ?? client.configs[0];
24
+ return target && target.format !== "yaml" ? { path: target.path, format: target.format } : undefined;
25
+ }
26
+ /** A client's MCP config target, resolved. */
27
+ function mcpTarget(client) {
28
+ const target = client.configs.find((t) => t.purpose === "mcp");
29
+ return target && target.format !== "yaml" ? { path: target.path, format: target.format } : undefined;
30
+ }
31
+ /**
32
+ * Desired write targets per client × capability. This is the desired-state
33
+ * 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.
36
+ */
37
+ export function desiredEntriesFor(client, capability, context) {
38
+ const home = context.home;
39
+ void context.env;
40
+ const targets = [];
41
+ 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
+ }
46
+ // Real harness shape: the `hooks` container is keyed by event name, and
47
+ // we patch per-event so a user's own hook entries are never clobbered.
48
+ const target = hooksTarget(client);
49
+ if (!target)
50
+ return targets;
51
+ const entry = [hookEntry(client)];
52
+ if (client.hooksVersion !== undefined) {
53
+ targets.push({ path: target.path, format: target.format, containerKey: TOP_LEVEL, entryName: "version", entry: client.hooksVersion });
54
+ }
55
+ for (const event of client.events) {
56
+ targets.push({ path: target.path, format: target.format, containerKey: "hooks", entryName: event, entry });
57
+ }
58
+ return targets;
59
+ }
60
+ if (capability === "mcp") {
61
+ 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) });
66
+ return targets;
67
+ }
68
+ return targets; // skill has no per-client config entry
69
+ }
70
+ /** Our one hook for an event, in the shape the client's hooks file holds it. */
71
+ function hookEntry(client) {
72
+ const command = genericSnippet(client.id);
73
+ return client.hookEntries === "flat"
74
+ ? { command }
75
+ : { matcher: "", hooks: [{ type: "command", command }] };
76
+ }
77
+ /**
78
+ * The client whose hooks this one already runs, when that client's hooks are installed too, by this run or
79
+ * an earlier one: installing both would run every event twice, recorded under two agents. Plan and doctor
80
+ * both skip on it.
81
+ */
82
+ export function hooksDeferredTo(client, context) {
83
+ const runs = client.runsHooksOf;
84
+ if (runs === undefined || !context.heedFile.install.hooks)
85
+ return undefined;
86
+ const other = supportedClients({ home: context.home, platform: process.platform, env: context.env }).find((c) => c.id === runs);
87
+ return other !== undefined && (installsClient(other, context) || ourHooksOnDisk(other)) ? other : undefined;
88
+ }
89
+ /** Whether the client's hooks file already holds our hook on any of its events. */
90
+ export function ourHooksOnDisk(client) {
91
+ const target = hooksTarget(client);
92
+ if (target === undefined || !existsSync(target.path))
93
+ return false;
94
+ try {
95
+ const doc = parseDoc(readFileSync(target.path, "utf8"), target.format);
96
+ return client.events.some((event) => {
97
+ const groups = readEntry(doc, "hooks", event);
98
+ return Array.isArray(groups) && groups.some(isOurHookGroup);
99
+ });
100
+ }
101
+ catch {
102
+ return false; // a file that does not parse shows no hook of ours; that client's own row reports it
103
+ }
104
+ }
105
+ /** Whether a client is detected on this machine via its primary marker or any altMarkerPaths (e.g. GUI ADEs). */
106
+ export function isClientDetected(client) {
107
+ return existsSync(client.markerPath) || (client.altMarkerPaths?.some((p) => existsSync(p)) ?? false);
108
+ }
109
+ /** Whether a run installs this client: named with `--agent`, or detected and in heed.yaml's `agents` (every one under `auto`). */
110
+ function installsClient(client, scope) {
111
+ if (scope.forced?.has(client.id) === true)
112
+ return true;
113
+ const { agents } = scope.heedFile;
114
+ return isClientDetected(client) && (agents === "auto" || agents.includes(client.id));
115
+ }
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
121
+ containerKey: "",
122
+ entryName: f.relativePath,
123
+ entry: f.content,
124
+ }));
125
+ }
126
+ /** Read-only inspection: what operation would satisfy the desired entry at its path? */
127
+ function inspect(d) {
128
+ const { path, format, containerKey, entryName, entry: desired } = d;
129
+ if (!existsSync(path))
130
+ return { operation: "create", path };
131
+ const backupPath = `${path}.heedvane.backup`;
132
+ // Whole-file desired entries (hermes plugin files) compare byte-wise; they are not configs to parse.
133
+ if (containerKey === "") {
134
+ return readFileSync(path, "utf8") === desired ? { operation: "unchanged", path } : { operation: "update", path, backupPath };
135
+ }
136
+ let doc;
137
+ try {
138
+ doc = parseDoc(readFileSync(path, "utf8"), format);
139
+ }
140
+ catch (err) {
141
+ return {
142
+ operation: "update",
143
+ path,
144
+ backupPath: `${path}.heedvane.backup`,
145
+ reason: `existing config does not parse (${errorMessage(err)}) — apply will refuse and report remediation`,
146
+ };
147
+ }
148
+ const current = readEntry(doc, containerKey, entryName);
149
+ if (current === undefined)
150
+ return { operation: "create", path, backupPath };
151
+ if (!valuesEqual(current, entryValue(containerKey, current, desired)))
152
+ return { operation: "update", path, backupPath };
153
+ return { operation: "unchanged", path };
154
+ }
155
+ function inspectMcp(client, home, env) {
156
+ const desireds = desiredEntriesFor(client, "mcp", { home, env });
157
+ // A client with multiple MCP targets (codex hooks.json + config.toml) is
158
+ // only "aligned" when every target is; the action reports the worst path.
159
+ let worst = { operation: "unchanged" };
160
+ for (const d of desireds) {
161
+ const r = inspect(d);
162
+ if (r.operation === "create")
163
+ return r;
164
+ if (r.operation === "update")
165
+ worst = r;
166
+ }
167
+ return worst.path ? worst : { ...worst, path: desireds[0].path };
168
+ }
169
+ function inspectSkill(home, client, desiredDigest) {
170
+ // One shared install dir; the action is reported per managed client so
171
+ // the plan is honest about per-agent scope.
172
+ const dir = join(home, ".agents", "skills", "heedvane-hooks");
173
+ const skillMd = join(dir, "SKILL.md");
174
+ if (!existsSync(skillMd))
175
+ return { operation: "create", path: skillMd };
176
+ const files = canonicalSkillFiles();
177
+ let same = true;
178
+ for (const f of files) {
179
+ const p = join(dir, f.relativePath);
180
+ if (!existsSync(p) || readFileSync(p, "utf8") !== f.content) {
181
+ same = false;
182
+ break;
183
+ }
184
+ }
185
+ void desiredDigest;
186
+ void client;
187
+ const backupPath = join(dir, "SKILL.md.heedvane.backup");
188
+ return same ? { operation: "unchanged", path: skillMd } : { operation: "update", path: skillMd, backupPath };
189
+ }
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");
195
+ }
196
+ function skipAction(client, reason, path) {
197
+ return { clientId: client.id, kind: "skip", operation: "skip", path: path ?? client.markerPath, reason };
198
+ }
199
+ /** Worst per-target inspection for the hooks capability: first create wins, else the latest update. */
200
+ function worstHooksResult(desireds) {
201
+ let worst = { operation: "unchanged" };
202
+ for (const d of desireds) {
203
+ const r = inspect(d);
204
+ if (r.operation === "create") {
205
+ return { operation: "create", path: d.path, backupPath: r.backupPath };
206
+ }
207
+ if (r.operation === "update")
208
+ worst = { ...r, path: r.path ?? d.path };
209
+ }
210
+ return worst;
211
+ }
212
+ function noHooksSkip(client) {
213
+ return skipAction(client, `${client.id}: no native hooks — policies arrive via MCP`, client.configs[0]?.path ?? client.markerPath);
214
+ }
215
+ function deferredHooksSkip(client, deferredTo) {
216
+ 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
+ }
218
+ /** One client × one capability → its planned action, or null when nothing to plan. */
219
+ function planCapability(client, capability, ctx) {
220
+ if (!ctx.heedFile.install[capability])
221
+ return null;
222
+ if (!supportsCapability(client, capability))
223
+ return capability === "hooks" ? noHooksSkip(client) : null;
224
+ const deferredTo = capability === "hooks" ? hooksDeferredTo(client, ctx) : undefined;
225
+ if (deferredTo !== undefined)
226
+ return deferredHooksSkip(client, deferredTo);
227
+ if (capability === "skill") {
228
+ const r = inspectSkill(ctx.home, client, ctx.desiredSkillDigest);
229
+ 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
+ }
231
+ const desireds = desiredEntriesFor(client, capability, { home: ctx.home, env: ctx.env, gating: ctx.heedFile.gating.mode });
232
+ if (desireds.length === 0)
233
+ 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)
247
+ const primary = desireds[0];
248
+ const worst = worstHooksResult(desireds);
249
+ return {
250
+ clientId: client.id,
251
+ kind: "hooks",
252
+ operation: worst.operation,
253
+ path: worst.path ?? primary.path,
254
+ backupPath: worst.backupPath,
255
+ reason: worst.reason,
256
+ };
257
+ }
258
+ /**
259
+ * The planner. For each client a run installs (installsClient), emit one action per enabled
260
+ * capability the client supports — or an honest skip with a reason it doesn't.
261
+ */
262
+ export function planActions(input) {
263
+ const { home, platform, env, heedFile } = input;
264
+ const actions = [];
265
+ const desiredSkillDigest = input.skillDigest ?? skillDigest(canonicalSkillFiles());
266
+ const ctx = { home, env, heedFile, forced: new Set(input.forced), desiredSkillDigest };
267
+ for (const client of supportedClients({ home, platform: platform, env })) {
268
+ if (!installsClient(client, ctx)) {
269
+ // Listed in heed.yaml but not on this machine.
270
+ if (heedFile.agents !== "auto" && heedFile.agents.includes(client.id)) {
271
+ actions.push(skipAction(client, `${client.id}: not detected — skip or force with --agent ${client.id}`));
272
+ }
273
+ continue;
274
+ }
275
+ for (const capability of ["hooks", "mcp", "skill"]) {
276
+ const action = planCapability(client, capability, ctx);
277
+ if (action !== null)
278
+ actions.push(action);
279
+ }
280
+ }
281
+ return actions;
282
+ }
283
+ /** Count of distinct agents that would receive a write for a kind. */
284
+ export function wouldInstallCount(actions, kind) {
285
+ return new Set(actions
286
+ .filter((a) => a.kind === kind && (a.operation === "create" || a.operation === "update"))
287
+ .map((a) => a.clientId)).size;
288
+ }