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,30 @@
1
+ import { type PlannedAction } from "./plan.js";
2
+ import type { ResolvedClient } from "./clients/definitions.js";
3
+ export interface DetectionRow {
4
+ client: ResolvedClient;
5
+ detected: boolean;
6
+ /** Marker path relative to home, e.g. ~/.claude */
7
+ markerRel: string;
8
+ }
9
+ export declare function renderDetection(rows: DetectionRow[]): string[];
10
+ /**
11
+ * The summary line from the spec:
12
+ * Would install: hooks -> 2 agents · skill -> 3 agents · MCP -> 3 agents
13
+ * Kinds with zero pending writes are omitted; singularized when 1.
14
+ */
15
+ export declare function wouldInstallSummary(actions: PlannedAction[]): string;
16
+ /** File-by-file plan lines, with backup paths where a write would occur. */
17
+ export declare function renderPlan(actions: PlannedAction[]): string[];
18
+ export interface AppliedResult {
19
+ clientId: string;
20
+ kind: string;
21
+ status: string;
22
+ path: string;
23
+ backupPath?: string;
24
+ reason?: string;
25
+ remediation?: string;
26
+ }
27
+ /** A refused or failed write: printed with ✗ and makes the command exit non-zero. */
28
+ export declare function appliedFailed(result: AppliedResult): boolean;
29
+ /** Applied-result lines matching the mock's ✓ block. */
30
+ export declare function renderApplied(results: AppliedResult[]): string[];
package/dist/render.js ADDED
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Plain-text renderers (testable: pure string arrays) for detection,
3
+ * plans, and applied results. --json envelopes are built in cli.ts.
4
+ */
5
+ import { basename } from "node:path";
6
+ import { wouldInstallCount } from "./plan.js";
7
+ function capabilitySummary(client) {
8
+ const parts = [];
9
+ if (client.capabilities.includes("hooks"))
10
+ parts.push("hooks");
11
+ if (client.capabilities.includes("mcp"))
12
+ parts.push("MCP");
13
+ if (client.capabilities.includes("skill"))
14
+ parts.push("skill");
15
+ const hookless = client.events.length === 0 && client.capabilities.includes("mcp");
16
+ if (hookless) {
17
+ // e.g. Warp: "MCP + skill — no native hooks; policies via MCP"
18
+ return `${parts.join(" + ")} — no native hooks; policies via MCP`;
19
+ }
20
+ return parts.join(" + ");
21
+ }
22
+ export function renderDetection(rows) {
23
+ const lines = [" Detected on this machine:"];
24
+ const width = Math.max(...rows.map((r) => r.client.displayName.length));
25
+ for (const row of rows) {
26
+ const name = row.client.displayName.padEnd(width);
27
+ if (row.detected) {
28
+ lines.push(` ✓ ${name} (${capabilitySummary(row.client)}) ${row.markerRel}`);
29
+ }
30
+ else {
31
+ lines.push(` ✗ ${name} (not detected — skip or force with --agent ${row.client.id})`);
32
+ }
33
+ }
34
+ return lines;
35
+ }
36
+ const VERB = {
37
+ create: "added",
38
+ update: "updated",
39
+ unchanged: "already current",
40
+ skip: "skipped",
41
+ };
42
+ function kindLabel(kind) {
43
+ switch (kind) {
44
+ case "hooks": return "hooks";
45
+ case "mcp": return "MCP";
46
+ case "skill": return "skill";
47
+ case "skip": return "";
48
+ }
49
+ }
50
+ function plural(n, noun) {
51
+ return n === 1 ? noun : `${noun}s`;
52
+ }
53
+ /**
54
+ * The summary line from the spec:
55
+ * Would install: hooks -> 2 agents · skill -> 3 agents · MCP -> 3 agents
56
+ * Kinds with zero pending writes are omitted; singularized when 1.
57
+ */
58
+ export function wouldInstallSummary(actions) {
59
+ const parts = [];
60
+ for (const kind of ["hooks", "skill", "mcp"]) {
61
+ const n = wouldInstallCount(actions, kind);
62
+ if (n > 0)
63
+ parts.push(`${kindLabel(kind)} -> ${n} ${plural(n, "agent")}`);
64
+ }
65
+ return parts.length > 0 ? ` Would install: ${parts.join(" · ")}` : " Would install: nothing — already current";
66
+ }
67
+ /** File-by-file plan lines, with backup paths where a write would occur. */
68
+ export function renderPlan(actions) {
69
+ const lines = [];
70
+ for (const action of actions) {
71
+ if (action.kind === "skip") {
72
+ lines.push(` · ${action.reason}`);
73
+ continue;
74
+ }
75
+ if (action.kind === "skill")
76
+ continue; // collapsed below, one line per unique dir
77
+ const label = kindLabel(action.kind);
78
+ const verb = VERB[action.operation] ?? action.operation;
79
+ const backup = action.backupPath ? ` (backup: ${basename(action.backupPath)})` : "";
80
+ lines.push(` ${action.path} ${label} ${verb}${backup}`);
81
+ }
82
+ // One line per unique skill dir (the plan reports it per agent).
83
+ const skillDirs = new Set(actions.filter((a) => a.kind === "skill" && a.operation !== "unchanged").map((a) => a.path));
84
+ for (const dir of skillDirs) {
85
+ lines.push(` ${dir.slice(0, dir.lastIndexOf("SKILL.md") > 0 ? dir.lastIndexOf("SKILL.md") : undefined)} skill installed`);
86
+ }
87
+ for (const action of actions.filter((a) => a.kind === "skill" && a.operation === "unchanged")) {
88
+ lines.push(` ${action.path} skill already current`);
89
+ }
90
+ return lines;
91
+ }
92
+ /** A refused or failed write: printed with ✗ and makes the command exit non-zero. */
93
+ export function appliedFailed(result) {
94
+ return result.status === "failed" || result.status === "invalid";
95
+ }
96
+ /** ✗ refused or failed, · nothing written, ✓ written. */
97
+ function appliedMarker(result) {
98
+ if (appliedFailed(result))
99
+ return "✗";
100
+ return result.status === "unchanged" || result.status === "skipped" ? "·" : "✓";
101
+ }
102
+ /** One config result: its line, then its reason and remediation when present. */
103
+ function appliedLines(r) {
104
+ const label = kindLabel(r.kind) ?? r.kind;
105
+ const verb = VERB[r.status] ?? r.status;
106
+ const backup = r.backupPath ? ` (backup: ${basename(r.backupPath)})` : "";
107
+ const lines = [` ${appliedMarker(r)} ${r.path} ${label} ${verb}${backup}`];
108
+ if (r.reason)
109
+ lines.push(` ↳ ${r.reason}`);
110
+ if (r.remediation)
111
+ lines.push(` remediation: ${r.remediation}`);
112
+ return lines;
113
+ }
114
+ /** Applied-result lines matching the mock's ✓ block. */
115
+ export function renderApplied(results) {
116
+ const lines = [];
117
+ const seenSkill = new Set();
118
+ for (const r of results) {
119
+ if (r.kind === "skill") {
120
+ const dir = r.path.slice(0, Math.max(r.path.lastIndexOf("/SKILL.md"), 0));
121
+ if (seenSkill.has(dir))
122
+ continue;
123
+ seenSkill.add(dir);
124
+ const label = r.status === "unchanged" ? "skill already current" : `skill v1 installed`;
125
+ lines.push(` ✓ ${dir}/ ${label}`);
126
+ continue;
127
+ }
128
+ lines.push(...appliedLines(r));
129
+ }
130
+ return lines;
131
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * A verdict as the JSON a harness reads from its hook's stdout. The mapping is the steering contract's
3
+ * (docs/product/agent-integration/steering.md, "Mapping to harness hook output"), each harness checked
4
+ * against its own hook reference. Claude Code: decisions and context sit under `hookSpecificOutput` with
5
+ * `hookEventName`; a top-level `permissionDecision` would be ignored, turning a deny into an allow.
6
+ */
7
+ import type { Envelope } from "../events/contract.js";
8
+ import type { Verdict } from "./verdict.js";
9
+ /**
10
+ * Claude Code shows the agent only a 2,000-character preview of a hook's `additionalContext` over 10,000
11
+ * characters. The session-start answer (organization rules first, then private memories) stays under this.
12
+ */
13
+ export declare const ADDITIONAL_CONTEXT_BUDGET = 9500;
14
+ /** Between the pieces of one answer's context. */
15
+ export declare const CONTEXT_SEPARATOR = "\n\n";
16
+ /** What a harness reads from its hook's stdout; `{}` lets the agent continue untouched. */
17
+ export type HarnessOutput = Record<string, never> | {
18
+ hookSpecificOutput: {
19
+ hookEventName: string;
20
+ permissionDecision: "deny";
21
+ permissionDecisionReason: string;
22
+ };
23
+ } | {
24
+ hookSpecificOutput: {
25
+ hookEventName: string;
26
+ additionalContext: string;
27
+ };
28
+ } | {
29
+ decision: "block" | "deny";
30
+ reason: string;
31
+ } | {
32
+ continue: false;
33
+ stopReason: string;
34
+ } | {
35
+ permission: "deny";
36
+ user_message: string;
37
+ agent_message: string;
38
+ } | {
39
+ followup_message: string;
40
+ } | {
41
+ additional_context: string;
42
+ } | {
43
+ action: "block";
44
+ message: string;
45
+ } | {
46
+ context: string;
47
+ };
48
+ /** The events a stop block or halt applies to. */
49
+ export declare const STOP_EVENTS: ReadonlySet<string>;
50
+ /**
51
+ * The stdout JSON for the envelope's agent and event. A `steer` always enforces where the harness can:
52
+ * PreToolUse denies the tool; Stop and SubagentStop block with the reason; a halted Stop (Heedvane's
53
+ * `halt`, or the Stop cap in src/reply/reply.ts) ends the turn, so the person sees why. Nothing else is
54
+ * enforceable. A `warn` (the skill check only), recalled memory and organization instructions are
55
+ * context.
56
+ */
57
+ export declare function harnessOutput(envelope: Envelope, verdict: Verdict): HarnessOutput;
@@ -0,0 +1,88 @@
1
+ import { normalizeAgent } from "../events/normalize.js";
2
+ const CLAUDE_EVENT_NAMES = {
3
+ pre_tool_use: "PreToolUse",
4
+ user_prompt_submit: "UserPromptSubmit",
5
+ session_start: "SessionStart",
6
+ stop: "Stop",
7
+ subagent_stop: "SubagentStop",
8
+ };
9
+ /**
10
+ * Claude Code shows the agent only a 2,000-character preview of a hook's `additionalContext` over 10,000
11
+ * characters. The session-start answer (organization rules first, then private memories) stays under this.
12
+ */
13
+ export const ADDITIONAL_CONTEXT_BUDGET = 9_500;
14
+ /** Between the pieces of one answer's context. */
15
+ export const CONTEXT_SEPARATOR = "\n\n";
16
+ /** The events a stop block or halt applies to. */
17
+ export const STOP_EVENTS = new Set(["stop", "subagent_stop"]);
18
+ const CLAUDE_STEERING = {
19
+ names: CLAUDE_EVENT_NAMES,
20
+ deny: (hookEventName, reason) => ({ hookSpecificOutput: { hookEventName, permissionDecision: "deny", permissionDecisionReason: reason } }),
21
+ block: (reason) => ({ decision: "block", reason }),
22
+ halt: (reason) => ({ continue: false, stopReason: reason }),
23
+ context: (hookEventName, text) => ({ hookSpecificOutput: { hookEventName, additionalContext: text } }),
24
+ };
25
+ /**
26
+ * Each harness checked against its own hook reference on 2026-10-11; any other agent gets `{}`.
27
+ * - Codex (developers.openai.com/codex/hooks, rust-v0.162): Claude Code's output, with the same event
28
+ * names.
29
+ * - Gemini CLI (geminicli.com/docs/hooks/reference): a top-level `decision: "deny"` refuses BeforeTool and
30
+ * makes AfterAgent retry with the reason; `continue: false` ends the session. BeforeTool carries no
31
+ * context, and there is no subagent stop.
32
+ * - Cursor (cursor.com/docs/hooks): `permission: "deny"` refuses preToolUse; a stop's `followup_message`
33
+ * is submitted as the next user message. Nothing documented ends a turn, and only sessionStart carries
34
+ * context.
35
+ * - Hermes (hermes-agent.nousresearch.com/docs/user-guide/features/hooks): its plugin's `pre_tool_call`
36
+ * blocks with `{action: "block", message}`, which Hermes ignores without a message; `pre_llm_call`
37
+ * adds `{context}` to the turn. It has no stop hook.
38
+ */
39
+ const HARNESS_STEERING = {
40
+ claude: CLAUDE_STEERING,
41
+ codex: CLAUDE_STEERING,
42
+ antigravity: CLAUDE_STEERING,
43
+ gemini: {
44
+ names: { pre_tool_use: "BeforeTool", user_prompt_submit: "BeforeAgent", session_start: "SessionStart", stop: "AfterAgent" },
45
+ deny: (_name, reason) => ({ decision: "deny", reason }),
46
+ block: (reason) => ({ decision: "deny", reason }),
47
+ halt: (reason) => ({ continue: false, stopReason: reason }),
48
+ context: (hookEventName, text) => (hookEventName === "BeforeTool" ? null : { hookSpecificOutput: { hookEventName, additionalContext: text } }),
49
+ },
50
+ cursor: {
51
+ names: { pre_tool_use: "preToolUse", user_prompt_submit: "beforeSubmitPrompt", session_start: "sessionStart", stop: "stop", subagent_stop: "subagentStop" },
52
+ deny: (_name, reason) => ({ permission: "deny", user_message: reason, agent_message: reason }),
53
+ block: (reason) => ({ followup_message: reason }),
54
+ context: (name, text) => (name === "sessionStart" ? { additional_context: text } : null),
55
+ },
56
+ hermes: {
57
+ names: { pre_tool_use: "pre_tool_call", user_prompt_submit: "pre_llm_call" },
58
+ deny: (_name, reason) => (reason === "" ? {} : { action: "block", message: reason }),
59
+ context: (name, text) => (name === "pre_llm_call" ? { context: text } : null),
60
+ },
61
+ };
62
+ /**
63
+ * The stdout JSON for the envelope's agent and event. A `steer` always enforces where the harness can:
64
+ * PreToolUse denies the tool; Stop and SubagentStop block with the reason; a halted Stop (Heedvane's
65
+ * `halt`, or the Stop cap in src/reply/reply.ts) ends the turn, so the person sees why. Nothing else is
66
+ * enforceable. A `warn` (the skill check only), recalled memory and organization instructions are
67
+ * context.
68
+ */
69
+ export function harnessOutput(envelope, verdict) {
70
+ const { agent, event } = envelope;
71
+ const steering = HARNESS_STEERING[normalizeAgent(agent)];
72
+ const name = steering?.names[event];
73
+ if (steering === undefined || name === undefined)
74
+ return {};
75
+ if (verdict.decision === "steer")
76
+ return enforced(steering, { event, name }, verdict);
77
+ const text = [...verdict.messages, ...verdict.context].join(CONTEXT_SEPARATOR);
78
+ return text === "" || STOP_EVENTS.has(event) ? {} : (steering.context(name, text) ?? {});
79
+ }
80
+ /** `event` is canonical, `name` the harness's own name for it. */
81
+ function enforced(steering, { event, name }, verdict) {
82
+ const reason = verdict.messages.join("\n\n");
83
+ if (event === "pre_tool_use")
84
+ return steering.deny?.(name, reason) ?? {};
85
+ if (!STOP_EVENTS.has(event))
86
+ return {};
87
+ return (verdict.halt === true ? steering.halt?.(reason) : steering.block?.(reason)) ?? {};
88
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The listener's answer to one hook event, from this machine alone: the local skill check first (a
3
+ * deny needs no network), then the synced deterministic steering rules and local private-memory recall,
4
+ * plus the organization's instructions as context, shaped for the harness. Heedvane's own evaluation
5
+ * never holds an answer back: it runs in the background (src/steering/steer.ts), and an enforcing verdict
6
+ * is delivered by the session's next tool call or stop (src/steering/violations.ts). A session's stops
7
+ * are blocked at most {@link MAX_STOP_BLOCKS_IN_A_ROW} times in a row; the next one ends the turn (`halt`)
8
+ * and no later stop of that session is blocked. Every steering
9
+ * enforcement is reported to Heedvane as a delivery (steering.md "Enforcement records"): what the hook
10
+ * emitted, and who decided.
11
+ */
12
+ import type { Backend } from "../backend/client.js";
13
+ import type { Envelope } from "../events/contract.js";
14
+ import type { HeedvaneConfig } from "../heed-file.js";
15
+ import type { TeamMemoryStart } from "../memory/team-memory.js";
16
+ import { type SteeringPolicy } from "../steering/rules.js";
17
+ import { type Delivery } from "../steering/steer.js";
18
+ import { type Violations } from "../steering/violations.js";
19
+ import { type HarnessOutput } from "./harness-output.js";
20
+ /** How long the session start's one fetch of the steering policy may take; the only wait on Heedvane in a hook. */
21
+ export declare const BACKEND_BUDGET_MS = 1000;
22
+ /** One enforced rule as reported to `POST /api/agent/steering/deliveries`. */
23
+ export interface SteeringDelivery {
24
+ agent: string;
25
+ session: string;
26
+ event: string;
27
+ tool?: string;
28
+ eventKey: string;
29
+ ruleId: string;
30
+ ruleSha256: string;
31
+ status: "enforced" | "halted";
32
+ delivery: Delivery;
33
+ /** On a `next_event` delivery, the key of the event that carried the block (tool_use_id, else receivedAt). */
34
+ carriedBy?: string;
35
+ }
36
+ export interface ReplyContext {
37
+ config: HeedvaneConfig;
38
+ /** Heedvane with this machine's credential (src/backend/credentials.ts), or null without one. */
39
+ backend: Backend | null;
40
+ /** The heed root (approved-skills cache). */
41
+ root: string;
42
+ home: string;
43
+ log: (message: string) => void;
44
+ /** The steering policy the session started with (fetched once, at its session start), or the last one synced; null before the first sync. */
45
+ policy: (session: string) => SteeringPolicy | null;
46
+ /** Queue a delivery report for Heedvane. */
47
+ report: (delivery: SteeringDelivery) => void;
48
+ /** Per session, how many Stops in a row were blocked, and whether it ran out of stop blocks (then none is delivered for the rest of the session). */
49
+ stopBlocks: Map<string, {
50
+ inARow: number;
51
+ ranOut: boolean;
52
+ }>;
53
+ /** Heedvane's enforcing verdicts, until the session's next tool call or stop carries them. */
54
+ violations: Violations;
55
+ /** How many times each session has ended: a verdict judged before the end belongs to a session that is over. */
56
+ sessionEnds: Map<string, number>;
57
+ }
58
+ /** What only some events bring along: the tool call's whole input, and the team memory the session start learned. */
59
+ export interface ReplyExtras {
60
+ toolInput?: unknown;
61
+ teamMemory?: TeamMemoryStart;
62
+ }
63
+ export declare function replyFor(envelope: Envelope, ctx: ReplyContext, extras?: ReplyExtras): Promise<HarnessOutput>;
64
+ /** The event's checkout origin URL (credentials stripped), or "" without a cwd or a git origin. */
65
+ export declare function eventRemote(envelope: Envelope): Promise<string>;
@@ -0,0 +1,169 @@
1
+ import { repoRemote, repositoryName } from "../backend/repo-remote.js";
2
+ import { errorMessage } from "../guards.js";
3
+ import { recall } from "../memory/private-memory.js";
4
+ import { renderTableOfContents } from "../memory/table-of-contents.js";
5
+ import { approvedSkillsFor } from "../skills/approved.js";
6
+ import { checkSkill } from "../skills/check.js";
7
+ import { instructionContext } from "../steering/rules.js";
8
+ import { STEERED_EVENTS, localVerdict, steer } from "../steering/steer.js";
9
+ import { DELIVERY_EVENTS, openViolation, takeViolations, withoutRules } from "../steering/violations.js";
10
+ import { ADDITIONAL_CONTEXT_BUDGET, CONTEXT_SEPARATOR, STOP_EVENTS, harnessOutput } from "./harness-output.js";
11
+ import { ALLOW, combine } from "./verdict.js";
12
+ /** How long the session start's one fetch of the steering policy may take; the only wait on Heedvane in a hook. */
13
+ export const BACKEND_BUDGET_MS = 1_000;
14
+ /** André's ruling: block finishing only this many times in a row; after that other mechanisms have to do it. */
15
+ const MAX_STOP_BLOCKS_IN_A_ROW = 4;
16
+ export async function replyFor(envelope, ctx, extras = {}) {
17
+ if (envelope.event === "session_end")
18
+ endSession(envelope.session, ctx);
19
+ const skill = checkSkill(envelope, { config: ctx.config.skills, approved: await approvedSkillsFor(ctx.root, ctx.backend), home: ctx.home });
20
+ if (skill.decision === "steer")
21
+ return harnessOutput(envelope, skill);
22
+ const request = { envelope, repository: repositoryName(await eventRemote(envelope)), toolInput: extras.toolInput };
23
+ const policy = ctx.policy(envelope.session);
24
+ const startBlock = await sessionStartBlock(request, ctx, { policy, teamMemory: extras.teamMemory });
25
+ const steered = steeringBackend(envelope, ctx, policy);
26
+ const local = steered === null ? ALLOW : localVerdict(request, policy?.rules ?? []);
27
+ if (steered !== null)
28
+ judgeInBackground(request, { backend: steered, enforcedLocally: new Set((local.enforcements ?? []).map((enforced) => enforced.ruleId)) }, ctx);
29
+ reportDeliveries(envelope, local.enforcements ?? [], { status: "enforced", delivery: "local", report: ctx.report });
30
+ const delivered = steered === null ? ALLOW : deliverViolations(envelope, ctx);
31
+ return harnessOutput(envelope, combine([skill, local, delivered, ...startBlock]));
32
+ }
33
+ /** A session that ended keeps no stop count or violation, and a verdict still being judged for it is dropped on arrival. */
34
+ function endSession(session, ctx) {
35
+ ctx.stopBlocks.delete(session);
36
+ ctx.violations.delete(session);
37
+ ctx.sessionEnds.set(session, (ctx.sessionEnds.get(session) ?? 0) + 1);
38
+ }
39
+ /**
40
+ * The session-start block (policies.md "Session start"): the organization's instructions first, whole; then the team
41
+ * memory's table of contents; then the private memories, each in the room the ones before left.
42
+ */
43
+ async function sessionStartBlock(request, ctx, known) {
44
+ const { envelope, repository } = request;
45
+ const { policy, teamMemory } = known;
46
+ const instructions = { ...ALLOW, context: instructionContext(policy, envelope.event) };
47
+ const afterPolicies = ADDITIONAL_CONTEXT_BUDGET - instructions.context.reduce((used, text) => used + text.length + CONTEXT_SEPARATOR.length, 0);
48
+ const contents = tableOfContents({ envelope, repository, teamMemory }, afterPolicies - CONTEXT_SEPARATOR.length);
49
+ const tableVerdict = { ...ALLOW, context: contents === "" ? [] : [contents] };
50
+ const room = afterPolicies - (contents === "" ? 0 : contents.length + CONTEXT_SEPARATOR.length);
51
+ const recalled = ctx.config.memory ? await recall(ctx.root, { envelope, repository }, room) : ALLOW;
52
+ return [instructions, tableVerdict, recalled];
53
+ }
54
+ /** The team memory's table of contents for a session start, in at most `room` characters; a line saying where to look when Heedvane did not answer. */
55
+ function tableOfContents({ envelope, repository, teamMemory }, room) {
56
+ if (envelope.event !== "session_start" || teamMemory === undefined || teamMemory.state === "none")
57
+ return "";
58
+ if (teamMemory.state === "map")
59
+ return renderTableOfContents(teamMemory.map, repository, room);
60
+ const unavailable = `Team memory for ${repository} could not be reached: npx heedvane memory list`;
61
+ return unavailable.length <= room ? unavailable : "";
62
+ }
63
+ /** Heedvane, when heed.yaml, a credential and the policy allow steering this event; else null. */
64
+ function steeringBackend(envelope, ctx, policy) {
65
+ const steering = ctx.config.steering && policy?.enabled !== false && STEERED_EVENTS.has(envelope.event);
66
+ return steering ? ctx.backend : null;
67
+ }
68
+ /**
69
+ * Heedvane evaluates the event while the agent goes on: an enforcing answer opens a violation for the
70
+ * session, less the rules the synced rules enforced on this event on the spot. Nothing waits for it; a verdict that
71
+ * arrives after the sign-in changed, or the session ended, belongs to another organization or a session that is over and is dropped.
72
+ */
73
+ function judgeInBackground(request, judge, ctx) {
74
+ const { envelope } = request;
75
+ const { backend, enforcedLocally } = judge;
76
+ const endsBefore = ctx.sessionEnds.get(envelope.session) ?? 0;
77
+ void (async () => {
78
+ const identity = await backend.identity();
79
+ const verdict = await steer(backend, request);
80
+ if (verdict === null || verdict.decision === "allow" || (await backend.identity()) !== identity)
81
+ return;
82
+ if ((ctx.sessionEnds.get(envelope.session) ?? 0) !== endsBefore)
83
+ return;
84
+ const violation = withoutRules(verdict, enforcedLocally);
85
+ if (violation !== null)
86
+ openViolation(ctx.violations, { judged: envelope, verdict: violation });
87
+ })().catch((err) => ctx.log(`[heedvane] could not evaluate a ${envelope.event} event: ${errorMessage(err)}`));
88
+ }
89
+ /**
90
+ * The session's open violations as one verdict on a tool call or stop, each delivered once and reported
91
+ * against the event Heedvane judged. A stop that finds none goes through and starts the count of blocked
92
+ * stops again; the stop after {@link MAX_STOP_BLOCKS_IN_A_ROW} blocked ones ends the turn instead, and
93
+ * from then on the session's stops are not blocked (its violations wait for a tool call).
94
+ */
95
+ function deliverViolations(envelope, ctx) {
96
+ if (!DELIVERY_EVENTS.has(envelope.event))
97
+ return ALLOW;
98
+ if (STOP_EVENTS.has(envelope.event) && stopRun(envelope.session, ctx).ranOut)
99
+ return ALLOW;
100
+ const violations = takeViolations(ctx.violations, envelope.session);
101
+ if (violations.length > 0)
102
+ return blockWith(violations, envelope, ctx);
103
+ // Only the main agent's clean stop starts the count again; a subagent finishing does not.
104
+ if (envelope.event === "stop")
105
+ ctx.stopBlocks.set(envelope.session, { inARow: 0, ranOut: false });
106
+ return ALLOW;
107
+ }
108
+ /** The session's count of stops blocked in a row, and whether it ran out of stop blocks. */
109
+ function stopRun(session, ctx) {
110
+ return ctx.stopBlocks.get(session) ?? { inARow: 0, ranOut: false };
111
+ }
112
+ /** The open violations as one verdict on this tool call or stop, with the stop count moved on and each delivery reported. */
113
+ function blockWith(violations, envelope, ctx) {
114
+ const stops = STOP_EVENTS.has(envelope.event);
115
+ const run = stopRun(envelope.session, ctx);
116
+ const combined = combine(violations.map((violation) => violation.verdict));
117
+ // After four blocked stops the session's stop blocks are over, whatever the fifth stop carries.
118
+ const ranOut = stops && run.inARow >= MAX_STOP_BLOCKS_IN_A_ROW;
119
+ // Heedvane's own halt ends the turn but is not one of the blocks in a row.
120
+ if (stops)
121
+ ctx.stopBlocks.set(envelope.session, { inARow: combined.halt === true ? run.inARow : run.inARow + 1, ranOut });
122
+ const verdict = ranOut ? { ...combined, halt: true } : combined;
123
+ const outcome = {
124
+ status: verdict.halt === true && stops ? "halted" : "enforced",
125
+ delivery: ranOut && combined.halt !== true ? "local" : "next_event",
126
+ report: ctx.report,
127
+ carrier: envelope,
128
+ };
129
+ for (const { judged, verdict: judgedVerdict } of violations)
130
+ reportDeliveries(judged, judgedVerdict.enforcements ?? [], outcome);
131
+ return verdict;
132
+ }
133
+ /**
134
+ * One delivery per enforced rule, keyed like Heedvane's records: `tool_use_id` on tool events, else
135
+ * `receivedAt`, of the event Heedvane judged (the one that is being enforced). Heedvane requires the
136
+ * rule's version and a non-empty tool, so a rule without a known version (a policy cached before rules
137
+ * carried one) is not reported, and an empty tool is left out.
138
+ */
139
+ function reportDeliveries(judged, enforcements, outcome) {
140
+ const { payload } = judged;
141
+ const { status, delivery, report, carrier } = outcome;
142
+ const tool = payload["tool"];
143
+ for (const { ruleId, ruleSha256 } of enforcements) {
144
+ if (ruleSha256 === undefined || ruleSha256 === "")
145
+ continue;
146
+ report({
147
+ agent: judged.agent,
148
+ session: judged.session,
149
+ event: judged.event,
150
+ ...(typeof tool === "string" && tool !== "" ? { tool } : {}),
151
+ eventKey: eventKeyOf(judged),
152
+ ruleId,
153
+ ruleSha256,
154
+ status,
155
+ delivery,
156
+ ...(delivery === "next_event" && carrier !== undefined ? { carriedBy: eventKeyOf(carrier) } : {}),
157
+ });
158
+ }
159
+ }
160
+ /** Heedvane's key for an event in enforcement records: `tool_use_id` on tool events, else `receivedAt`. */
161
+ function eventKeyOf(envelope) {
162
+ const toolUseId = envelope.payload["tool_use_id"];
163
+ return typeof toolUseId === "string" && toolUseId !== "" ? toolUseId : envelope.receivedAt;
164
+ }
165
+ /** The event's checkout origin URL (credentials stripped), or "" without a cwd or a git origin. */
166
+ export function eventRemote(envelope) {
167
+ const cwd = envelope.payload["cwd"];
168
+ return typeof cwd === "string" && cwd !== "" ? repoRemote(cwd) : Promise.resolve("");
169
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * What the listener answers a hook with, before it is shaped for one harness. Steering, the skill
3
+ * check and memory recall each produce one; `combine` merges them with one precedence rule.
4
+ */
5
+ export type Decision = "allow" | "warn" | "steer";
6
+ /** One enforced steering rule and its version (steering.md "Hook response contract", `enforcements`). */
7
+ export interface Enforced {
8
+ ruleId: string;
9
+ ruleSha256?: string;
10
+ }
11
+ export interface Verdict {
12
+ /** The strongest action: `steer` enforces (denies a tool, blocks a prompt or stop); `warn` only informs (the skill check). */
13
+ decision: Decision;
14
+ /** Guidance from rules that matched, strongest first. */
15
+ messages: string[];
16
+ /** Context to inject (recalled memories, organization instructions); never a reason to deny. */
17
+ context: string[];
18
+ /** The steering rules this verdict enforces, for the Stop cap and the delivery report. */
19
+ enforcements?: Enforced[];
20
+ /** End the turn instead of blocking the stop again (Heedvane's `halt`, or the Stop cap). */
21
+ halt?: boolean;
22
+ }
23
+ export declare const ALLOW: Verdict;
24
+ /**
25
+ * Strongest decision wins; messages keep the stronger verdict's first, each once (a rule matched
26
+ * locally and by Heedvane is one message); enforcements once per rule; context is concatenated.
27
+ */
28
+ export declare function combine(verdicts: Verdict[]): Verdict;
@@ -0,0 +1,17 @@
1
+ export const ALLOW = { decision: "allow", messages: [], context: [] };
2
+ const RANK = { allow: 0, warn: 1, steer: 2 };
3
+ /**
4
+ * Strongest decision wins; messages keep the stronger verdict's first, each once (a rule matched
5
+ * locally and by Heedvane is one message); enforcements once per rule; context is concatenated.
6
+ */
7
+ export function combine(verdicts) {
8
+ const ordered = [...verdicts].sort((a, b) => RANK[b.decision] - RANK[a.decision]);
9
+ const enforcements = new Map(verdicts.flatMap((verdict) => verdict.enforcements ?? []).map((enforced) => [enforced.ruleId, enforced]));
10
+ return {
11
+ decision: ordered[0]?.decision ?? "allow",
12
+ messages: [...new Set(ordered.flatMap((verdict) => verdict.messages))],
13
+ enforcements: [...enforcements.values()],
14
+ context: verdicts.flatMap((verdict) => verdict.context),
15
+ ...(verdicts.some((verdict) => verdict.halt === true) ? { halt: true } : {}),
16
+ };
17
+ }
@@ -0,0 +1,23 @@
1
+ import { type SkillFile } from "./content.js";
2
+ export type SkillInstallStatus = "installed" | "unchanged" | "failed";
3
+ /** Where the skill lives for a given skills root (~/.agents/skills). */
4
+ export declare function skillInstallDir(skillsRoot: string, name?: string): string;
5
+ /** Backup path for any differing existing skill file. */
6
+ export declare function skillBackupPath(target: string): string;
7
+ export interface InstallSkillOptions {
8
+ name?: string;
9
+ /**
10
+ * Whether a differing file on disk is the person's own and needs a backup; default: always. A
11
+ * backup is never overwritten, so it keeps the first original even across later updates.
12
+ */
13
+ backUp?: (existing: Buffer) => boolean;
14
+ }
15
+ /**
16
+ * REA's co-install pattern, applied to the skill bundle: compare canonical
17
+ * files against disk, back up any differing file as <path>.heedvane.backup,
18
+ * atomic-write (write-file-atomic), then read back to verify — restore on
19
+ * mismatch and report 'failed'.
20
+ */
21
+ export declare function installSkill(skillsRoot: string, files?: SkillFile[], options?: InstallSkillOptions): Promise<SkillInstallStatus>;
22
+ /** Digest of the currently installed skill files, or null when absent/incomplete. */
23
+ export declare function readInstalledSkillDigest(skillsRoot: string, files?: SkillFile[]): Promise<string | null>;