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.
- package/README.md +27 -2
- package/dist/apply.d.ts +104 -0
- package/dist/apply.js +388 -0
- package/dist/backend/client.d.ts +40 -0
- package/dist/backend/client.js +32 -0
- package/dist/backend/credentials.d.ts +15 -0
- package/dist/backend/credentials.js +76 -0
- package/dist/backend/login.d.ts +33 -0
- package/dist/backend/login.js +265 -0
- package/dist/backend/repo-remote.d.ts +15 -0
- package/dist/backend/repo-remote.js +61 -0
- package/dist/backend/sign-in-change.d.ts +12 -0
- package/dist/backend/sign-in-change.js +72 -0
- package/dist/best-effort.d.ts +4 -0
- package/dist/best-effort.js +25 -0
- package/dist/cli.d.ts +24 -0
- package/dist/cli.js +544 -0
- package/dist/clients/definitions.d.ts +113 -0
- package/dist/clients/definitions.js +427 -0
- package/dist/clients/hermes-plugin.d.ts +33 -0
- package/dist/clients/hermes-plugin.js +247 -0
- package/dist/commands/heedvane.d.ts +15 -0
- package/dist/commands/heedvane.js +74 -0
- package/dist/commands/memory.d.ts +10 -0
- package/dist/commands/memory.js +106 -0
- package/dist/config-doc.d.ts +97 -0
- package/dist/config-doc.js +465 -0
- package/dist/dispatchers/posix.d.ts +32 -0
- package/dist/dispatchers/posix.js +191 -0
- package/dist/dispatchers/snippets.d.ts +6 -0
- package/dist/dispatchers/snippets.js +39 -0
- package/dist/dispatchers/windows.d.ts +15 -0
- package/dist/dispatchers/windows.js +50 -0
- package/dist/doctor.d.ts +37 -0
- package/dist/doctor.js +316 -0
- package/dist/events/contract.d.ts +54 -0
- package/dist/events/contract.js +87 -0
- package/dist/events/normalize.d.ts +36 -0
- package/dist/events/normalize.js +143 -0
- package/dist/guards.d.ts +18 -0
- package/dist/guards.js +28 -0
- package/dist/heed-file.d.ts +67 -0
- package/dist/heed-file.js +295 -0
- package/dist/listener/endpoint.d.ts +21 -0
- package/dist/listener/endpoint.js +112 -0
- package/dist/listener/forward.d.ts +36 -0
- package/dist/listener/forward.js +257 -0
- package/dist/listener/serve.d.ts +2 -0
- package/dist/listener/serve.js +155 -0
- package/dist/listener/server.d.ts +48 -0
- package/dist/listener/server.js +251 -0
- package/dist/listener/spool.d.ts +28 -0
- package/dist/listener/spool.js +60 -0
- package/dist/mcp-server.d.ts +9 -0
- package/dist/mcp-server.js +187 -0
- package/dist/mcp.d.ts +57 -0
- package/dist/mcp.js +83 -0
- package/dist/memory/private-memory.d.ts +12 -0
- package/dist/memory/private-memory.js +54 -0
- package/dist/memory/table-of-contents.d.ts +10 -0
- package/dist/memory/table-of-contents.js +80 -0
- package/dist/memory/team-memory.d.ts +60 -0
- package/dist/memory/team-memory.js +54 -0
- package/dist/plan.d.ts +77 -0
- package/dist/plan.js +288 -0
- package/dist/render.d.ts +30 -0
- package/dist/render.js +131 -0
- package/dist/reply/harness-output.d.ts +57 -0
- package/dist/reply/harness-output.js +88 -0
- package/dist/reply/reply.d.ts +65 -0
- package/dist/reply/reply.js +169 -0
- package/dist/reply/verdict.d.ts +28 -0
- package/dist/reply/verdict.js +17 -0
- package/dist/skill/bundle.d.ts +23 -0
- package/dist/skill/bundle.js +102 -0
- package/dist/skill/content.d.ts +16 -0
- package/dist/skill/content.js +182 -0
- package/dist/skills/approved.d.ts +31 -0
- package/dist/skills/approved.js +123 -0
- package/dist/skills/check.d.ts +21 -0
- package/dist/skills/check.js +83 -0
- package/dist/state.d.ts +51 -0
- package/dist/state.js +118 -0
- package/dist/steering/rules.d.ts +49 -0
- package/dist/steering/rules.js +165 -0
- package/dist/steering/steer.d.ts +29 -0
- package/dist/steering/steer.js +64 -0
- package/dist/steering/violations.d.ts +27 -0
- package/dist/steering/violations.js +24 -0
- package/dist/uninstall.d.ts +23 -0
- package/dist/uninstall.js +159 -0
- package/package.json +34 -4
- package/skills/heedvane-hooks/SKILL.md +87 -0
- package/skills/heedvane-hooks/references/events.md +69 -0
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-format parse / patch / serialize for agent config files.
|
|
3
|
+
*
|
|
4
|
+
* Formats:
|
|
5
|
+
* - "json" — strict JSON (JSON.parse), servers key `mcpServers`
|
|
6
|
+
* - "jsonc" — JSON with comments (jsonc-parser), style preserved via
|
|
7
|
+
* format edits; servers key `mcpServers`
|
|
8
|
+
* - "toml" — smol-toml parse/stringify, servers key `mcp_servers`
|
|
9
|
+
* - "vscode" — strict JSON, servers key `servers`
|
|
10
|
+
* - "opencode" — strict JSON, servers key `mcp`
|
|
11
|
+
*
|
|
12
|
+
* A ParsedDoc carries the parsed value; for JSONC it also carries the original
|
|
13
|
+
* text so mutations can be applied as edits that preserve comments and the
|
|
14
|
+
* user's existing style. JSONC mutation MUST go through withServer /
|
|
15
|
+
* removeServer — mutating doc.value directly leaves the edit buffer stale.
|
|
16
|
+
*/
|
|
17
|
+
import { applyEdits as applyJsoncEdits, modify as modifyJsonc, parse as parseJsonc, parseTree as parseJsoncTree, } from "jsonc-parser";
|
|
18
|
+
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
|
|
19
|
+
import { SNIPPET_MARKER, isHeedvaneEntry } from "./mcp.js";
|
|
20
|
+
import { errorMessage, isRecord } from "./guards.js";
|
|
21
|
+
class ConfigParseError extends Error {
|
|
22
|
+
constructor(message) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = "ConfigParseError";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** Thrown when the servers table exists but is not an object (never overwrite such a file). */
|
|
28
|
+
class ServersTableError extends Error {
|
|
29
|
+
serversKey;
|
|
30
|
+
constructor(serversKey) {
|
|
31
|
+
super(`"${serversKey}" exists but is not an object`);
|
|
32
|
+
this.name = "ServersTableError";
|
|
33
|
+
this.serversKey = serversKey;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function defaultServersKey(format) {
|
|
37
|
+
switch (format) {
|
|
38
|
+
case "toml":
|
|
39
|
+
return "mcp_servers";
|
|
40
|
+
case "vscode":
|
|
41
|
+
return "servers";
|
|
42
|
+
case "opencode":
|
|
43
|
+
return "mcp";
|
|
44
|
+
case "jsonc":
|
|
45
|
+
case "json":
|
|
46
|
+
return "mcpServers";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const JSONC_EDIT_OPTS = { formattingOptions: { insertSpaces: true, tabSize: 2 } };
|
|
50
|
+
/** Parse config text into a ParsedDoc. Throws ConfigParseError on malformed input. */
|
|
51
|
+
export function parseDoc(text, format) {
|
|
52
|
+
if (format === "toml") {
|
|
53
|
+
let value;
|
|
54
|
+
try {
|
|
55
|
+
value = parseToml(text);
|
|
56
|
+
}
|
|
57
|
+
catch (e) {
|
|
58
|
+
throw new ConfigParseError(`invalid TOML: ${errorMessage(e)}`);
|
|
59
|
+
}
|
|
60
|
+
if (!isRecord(value))
|
|
61
|
+
throw new ConfigParseError("TOML document is not a table at the top level");
|
|
62
|
+
return { format, value, text, edited: false };
|
|
63
|
+
}
|
|
64
|
+
if (format === "jsonc") {
|
|
65
|
+
const errors = [];
|
|
66
|
+
const value = parseJsonc(text, errors, { allowTrailingComma: true });
|
|
67
|
+
if (errors.length > 0 || value === undefined) {
|
|
68
|
+
throw new ConfigParseError("invalid JSONC");
|
|
69
|
+
}
|
|
70
|
+
if (!isRecord(value))
|
|
71
|
+
throw new ConfigParseError("JSONC document is not an object at the top level");
|
|
72
|
+
return { format, value, text, edited: false };
|
|
73
|
+
}
|
|
74
|
+
// json | vscode | opencode
|
|
75
|
+
let value;
|
|
76
|
+
try {
|
|
77
|
+
value = JSON.parse(text);
|
|
78
|
+
}
|
|
79
|
+
catch (e) {
|
|
80
|
+
throw new ConfigParseError(`invalid JSON: ${errorMessage(e)}`);
|
|
81
|
+
}
|
|
82
|
+
if (!isRecord(value))
|
|
83
|
+
throw new ConfigParseError("JSON document is not an object at the top level");
|
|
84
|
+
return { format, value };
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Serialize a doc back to text.
|
|
88
|
+
* - JSON family: 2-space pretty JSON with trailing newline.
|
|
89
|
+
* - JSONC: if the doc carries an edit buffer (edited), return it verbatim —
|
|
90
|
+
* comments and surrounding style preserved. If the doc was never edited and
|
|
91
|
+
* `originalText` is given, return `originalText` unchanged (no semantic
|
|
92
|
+
* change => no byte change). Otherwise fall back to pretty JSON.
|
|
93
|
+
* - TOML: the edit buffer when it decodes to the doc's value (comments preserved),
|
|
94
|
+
* else smol-toml stringify.
|
|
95
|
+
*/
|
|
96
|
+
export function serializeDoc(doc, format, originalText) {
|
|
97
|
+
if (doc.format !== format)
|
|
98
|
+
throw new Error(`doc format ${doc.format} does not match requested ${format}`);
|
|
99
|
+
if (format === "jsonc") {
|
|
100
|
+
if (doc.edited && typeof doc.text === "string")
|
|
101
|
+
return doc.text;
|
|
102
|
+
if (originalText !== undefined)
|
|
103
|
+
return originalText;
|
|
104
|
+
return JSON.stringify(doc.value, null, 2) + "\n";
|
|
105
|
+
}
|
|
106
|
+
if (format === "toml") {
|
|
107
|
+
const edited = tomlEditedText(doc);
|
|
108
|
+
if (edited !== undefined)
|
|
109
|
+
return edited;
|
|
110
|
+
let out;
|
|
111
|
+
try {
|
|
112
|
+
out = stringifyToml(doc.value);
|
|
113
|
+
}
|
|
114
|
+
catch (e) {
|
|
115
|
+
throw new Error(`failed to serialize TOML: ${errorMessage(e)}`, { cause: e });
|
|
116
|
+
}
|
|
117
|
+
return out.endsWith("\n") ? out : out + "\n";
|
|
118
|
+
}
|
|
119
|
+
return JSON.stringify(doc.value, null, 2) + "\n";
|
|
120
|
+
}
|
|
121
|
+
/** The container key naming the document's own top level (Cursor's `version`), not a table inside it. */
|
|
122
|
+
export const TOP_LEVEL = ".";
|
|
123
|
+
/**
|
|
124
|
+
* Compat helper (A's API): read `doc[containerKey][entryName]` without the
|
|
125
|
+
* per-format servers-key machinery, or `doc[entryName]` for {@link TOP_LEVEL}.
|
|
126
|
+
* `undefined` when either level is missing or not a plain object.
|
|
127
|
+
*/
|
|
128
|
+
export function readEntry(doc, containerKey, entryName) {
|
|
129
|
+
if (containerKey === TOP_LEVEL)
|
|
130
|
+
return doc.value[entryName];
|
|
131
|
+
const container = doc.value[containerKey];
|
|
132
|
+
if (!isRecord(container))
|
|
133
|
+
return undefined;
|
|
134
|
+
return container[entryName];
|
|
135
|
+
}
|
|
136
|
+
/** The servers map of a doc, or undefined when absent. Throws ServersTableError when present but not an object. */
|
|
137
|
+
export function getServers(doc, format, serversKey = defaultServersKey(format)) {
|
|
138
|
+
if (doc.format !== format)
|
|
139
|
+
throw new Error(`doc format ${doc.format} does not match requested ${format}`);
|
|
140
|
+
const v = doc.value[serversKey];
|
|
141
|
+
if (v === undefined)
|
|
142
|
+
return undefined;
|
|
143
|
+
if (!isRecord(v))
|
|
144
|
+
throw new ServersTableError(serversKey);
|
|
145
|
+
return v;
|
|
146
|
+
}
|
|
147
|
+
function ensureServersContainer(doc, serversKey) {
|
|
148
|
+
const v = doc.value[serversKey];
|
|
149
|
+
if (v === undefined) {
|
|
150
|
+
doc.value[serversKey] = {};
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (!isRecord(v))
|
|
154
|
+
throw new ServersTableError(serversKey);
|
|
155
|
+
}
|
|
156
|
+
/** JSONC: ensure `<key> = {}` exists in the text buffer, preserving style. */
|
|
157
|
+
function jsoncEnsureTable(text, key) {
|
|
158
|
+
const parsed = parseJsonc(text, [], { allowTrailingComma: true });
|
|
159
|
+
const container = isRecord(parsed) ? parsed[key] : undefined;
|
|
160
|
+
if (container === undefined) {
|
|
161
|
+
return applyJsoncEdits(text, modifyJsonc(text, [key], {}, JSONC_EDIT_OPTS));
|
|
162
|
+
}
|
|
163
|
+
if (!isRecord(container))
|
|
164
|
+
throw new ServersTableError(key);
|
|
165
|
+
return text;
|
|
166
|
+
}
|
|
167
|
+
function jsoncSyncValue(doc) {
|
|
168
|
+
const parsed = parseJsonc(doc.text, [], { allowTrailingComma: true });
|
|
169
|
+
if (!isRecord(parsed))
|
|
170
|
+
throw new ConfigParseError("JSONC edit produced a non-object document");
|
|
171
|
+
doc.value = parsed;
|
|
172
|
+
}
|
|
173
|
+
function jsoncPrepareBuffer(doc) {
|
|
174
|
+
if (typeof doc.text !== "string")
|
|
175
|
+
doc.text = JSON.stringify(doc.value, null, 2);
|
|
176
|
+
return doc.text;
|
|
177
|
+
}
|
|
178
|
+
/** Set (or, with `undefined`, remove) one top-level key of a JSON or JSONC document, keeping JSONC comments. */
|
|
179
|
+
export function withTopLevelKey(doc, edit) {
|
|
180
|
+
const { key, value } = edit;
|
|
181
|
+
const { format } = doc;
|
|
182
|
+
if (format === "jsonc") {
|
|
183
|
+
const text = jsoncPrepareBuffer(doc);
|
|
184
|
+
doc.text = applyJsoncEdits(text, modifyJsonc(text, [key], value, JSONC_EDIT_OPTS));
|
|
185
|
+
doc.edited = true;
|
|
186
|
+
jsoncSyncValue(doc);
|
|
187
|
+
return doc;
|
|
188
|
+
}
|
|
189
|
+
if (format !== "json")
|
|
190
|
+
throw new Error(`top-level keys are only edited in JSON documents, not ${format}`);
|
|
191
|
+
if (value === undefined)
|
|
192
|
+
delete doc.value[key];
|
|
193
|
+
else
|
|
194
|
+
doc.value[key] = value;
|
|
195
|
+
return doc;
|
|
196
|
+
}
|
|
197
|
+
/** Find the `property` node for `key` inside an object node. */
|
|
198
|
+
function findPropertyNode(obj, key) {
|
|
199
|
+
if (obj.type !== "object" || !obj.children)
|
|
200
|
+
return undefined;
|
|
201
|
+
for (const prop of obj.children) {
|
|
202
|
+
if (prop.type === "property" && prop.children && prop.children[0].type === "string" && prop.children[0].value === key) {
|
|
203
|
+
return prop;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return undefined;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Style-preserving removal of `servers[name]` from JSONC text.
|
|
210
|
+
*
|
|
211
|
+
* jsonc-parser's own delete (modify with undefined) reformats the whole
|
|
212
|
+
* containing object and strips comments around the removed entry — losing
|
|
213
|
+
* the user's comments on SIBLING entries. Instead we remove exactly the
|
|
214
|
+
* property's own text: its indentation, the entry, its trailing comma, the
|
|
215
|
+
* rest of its line, and (if the entry started its own line) a comment-only
|
|
216
|
+
* line directly above it. Neighbouring comments and entries are untouched.
|
|
217
|
+
*/
|
|
218
|
+
/** Offset moved back over the line's leading indentation whitespace. */
|
|
219
|
+
function indentStart(text, offset) {
|
|
220
|
+
let start = offset;
|
|
221
|
+
while (start > 0 && (text[start - 1] === " " || text[start - 1] === "\t"))
|
|
222
|
+
start -= 1;
|
|
223
|
+
return start;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* If `start` sits at the beginning of a line whose line directly above is a
|
|
227
|
+
* comment-only line, that comment belongs to the entry — return the comment's
|
|
228
|
+
* own start. Otherwise return `start` unchanged.
|
|
229
|
+
*/
|
|
230
|
+
function commentLineAboveStart(text, start) {
|
|
231
|
+
const prevNl = text.lastIndexOf("\n", start - 1);
|
|
232
|
+
if (prevNl === -1)
|
|
233
|
+
return start;
|
|
234
|
+
const prevLine = text.slice(prevNl + 1, start).trim();
|
|
235
|
+
if (!/^(\/\/.*|\/\*.*\*\/)$/.test(prevLine))
|
|
236
|
+
return start;
|
|
237
|
+
const commentStart = indentStart(text, prevNl + 1);
|
|
238
|
+
if (commentStart === prevNl + 1 || text.slice(commentStart, prevNl).trim().length === prevLine.length) {
|
|
239
|
+
return commentStart; // only whitespace before the comment on that line
|
|
240
|
+
}
|
|
241
|
+
return start;
|
|
242
|
+
}
|
|
243
|
+
/** One past the entry's own text: trailing comma, any trailing padding, then the line's newline. */
|
|
244
|
+
function removalEnd(text, propEnd) {
|
|
245
|
+
let end = propEnd;
|
|
246
|
+
if (text[end] === ",")
|
|
247
|
+
end += 1; // trailing comma goes with the entry
|
|
248
|
+
// Consume the rest of the line (up to and including its newline) so no
|
|
249
|
+
// blank husk is left behind.
|
|
250
|
+
while (end < text.length && text[end] !== "\n" && text[end] !== "\r") {
|
|
251
|
+
if (text[end] !== " " && text[end] !== "\t")
|
|
252
|
+
break; // more content on this line — keep it
|
|
253
|
+
end += 1;
|
|
254
|
+
}
|
|
255
|
+
if (text[end] === "\r")
|
|
256
|
+
end += 1;
|
|
257
|
+
if (text[end] === "\n")
|
|
258
|
+
end += 1;
|
|
259
|
+
return end;
|
|
260
|
+
}
|
|
261
|
+
function jsoncRemoveProperty(text, serversKey, name) {
|
|
262
|
+
const root = parseJsoncTree(text, [], { allowTrailingComma: true });
|
|
263
|
+
if (!root)
|
|
264
|
+
return text;
|
|
265
|
+
const serversObj = findPropertyNode(root, serversKey)?.children?.[1];
|
|
266
|
+
const propNode = serversObj !== undefined ? findPropertyNode(serversObj, name) : undefined;
|
|
267
|
+
if (!propNode)
|
|
268
|
+
return text;
|
|
269
|
+
// Eat back the entry's indentation; a comment-only line directly above it
|
|
270
|
+
// belongs to the entry and is removed too.
|
|
271
|
+
const start = commentLineAboveStart(text, indentStart(text, propNode.offset));
|
|
272
|
+
const end = removalEnd(text, propNode.offset + propNode.length);
|
|
273
|
+
return applyJsoncEdits(text, [{ offset: start, length: end - start, content: "" }]);
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Set `servers[name] = desired`, creating the servers table when missing.
|
|
277
|
+
* For JSONC the change is applied as a format edit on the preserved text.
|
|
278
|
+
* Returns the same doc (mutated), for chaining.
|
|
279
|
+
*/
|
|
280
|
+
export function withServer(doc, format, edit) {
|
|
281
|
+
const { serversKey, name, desired } = edit;
|
|
282
|
+
if (doc.format !== format)
|
|
283
|
+
throw new Error(`doc format ${doc.format} does not match requested ${format}`);
|
|
284
|
+
if (format === "jsonc") {
|
|
285
|
+
let text = jsoncPrepareBuffer(doc);
|
|
286
|
+
text = jsoncEnsureTable(text, serversKey);
|
|
287
|
+
const value = entryValue(serversKey, readEntry(doc, serversKey, name), desired);
|
|
288
|
+
doc.text = applyJsoncEdits(text, modifyJsonc(text, [serversKey, name], value, JSONC_EDIT_OPTS));
|
|
289
|
+
doc.edited = true;
|
|
290
|
+
jsoncSyncValue(doc);
|
|
291
|
+
return doc;
|
|
292
|
+
}
|
|
293
|
+
ensureServersContainer(doc, serversKey);
|
|
294
|
+
const servers = doc.value[serversKey];
|
|
295
|
+
servers[name] = entryValue(serversKey, servers[name], desired);
|
|
296
|
+
if (format === "toml")
|
|
297
|
+
tomlEditTable(doc, { key: serversKey, name, value: servers[name] });
|
|
298
|
+
return doc;
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* TOML: rewrite only our entry in the edit buffer, leaving every other line, comment included, as it
|
|
302
|
+
* was. A table entry is the `[<key>.<name>]` table (and its sub-tables); a scalar entry is its
|
|
303
|
+
* `name = …` line inside `[<key>]`. `value` undefined removes it, and then a `[<key>]` left empty.
|
|
304
|
+
*/
|
|
305
|
+
function tomlEditTable(doc, table) {
|
|
306
|
+
const { key, name, value } = table;
|
|
307
|
+
if (typeof doc.text !== "string")
|
|
308
|
+
return; // fresh doc: serializeDoc stringifies the value
|
|
309
|
+
const ours = new RegExp(`^\\s*\\[\\s*${escapeRegExp(key)}\\s*\\.\\s*${escapeRegExp(name)}\\s*[\\].]`);
|
|
310
|
+
let skipping = false;
|
|
311
|
+
const kept = doc.text.split("\n").filter((line) => {
|
|
312
|
+
if (/^\s*\[/.test(line))
|
|
313
|
+
skipping = ours.test(line);
|
|
314
|
+
return !skipping;
|
|
315
|
+
});
|
|
316
|
+
let lines = withoutSectionKey(kept, key, name);
|
|
317
|
+
if (value !== undefined && !isRecord(value))
|
|
318
|
+
lines = withSectionLine(lines, key, stringifyToml({ [name]: value }).trim());
|
|
319
|
+
if (value === undefined && isRecord(doc.value[key]) && Object.keys(doc.value[key]).length === 0)
|
|
320
|
+
lines = withoutEmptySection(doc, lines, key);
|
|
321
|
+
let text = lines.join("\n").replace(/\n*$/, "\n");
|
|
322
|
+
if (isRecord(value))
|
|
323
|
+
text += `\n${stringifyToml({ [key]: { [name]: value } })}`;
|
|
324
|
+
doc.text = text;
|
|
325
|
+
doc.edited = true;
|
|
326
|
+
}
|
|
327
|
+
/** The index of the `[<key>]` header line, or -1. */
|
|
328
|
+
function sectionHeader(lines, key) {
|
|
329
|
+
const header = new RegExp(`^\\s*\\[\\s*${escapeRegExp(key)}\\s*\\]\\s*(#.*)?$`);
|
|
330
|
+
return lines.findIndex((line) => header.test(line));
|
|
331
|
+
}
|
|
332
|
+
/** The lines without a one-line `name = …` inside `[<key>]` (a multi-line value fails the readback check). */
|
|
333
|
+
function withoutSectionKey(lines, key, name) {
|
|
334
|
+
const start = sectionHeader(lines, key);
|
|
335
|
+
if (start === -1)
|
|
336
|
+
return [...lines];
|
|
337
|
+
const assignment = new RegExp(`^\\s*(["']?)${escapeRegExp(name)}\\1\\s*=`);
|
|
338
|
+
let inSection = true;
|
|
339
|
+
return lines.filter((line, index) => {
|
|
340
|
+
if (index > start && /^\s*\[/.test(line))
|
|
341
|
+
inSection = false;
|
|
342
|
+
return index <= start || !inSection || !assignment.test(line);
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
/** The lines with `line` first under the `[<key>]` header, adding the header at the end when missing. */
|
|
346
|
+
function withSectionLine(lines, key, line) {
|
|
347
|
+
const start = sectionHeader(lines, key);
|
|
348
|
+
if (start === -1)
|
|
349
|
+
return [...lines.join("\n").replace(/\n*$/, "").split("\n"), "", `[${key}]`, line];
|
|
350
|
+
return [...lines.slice(0, start + 1), line, ...lines.slice(start + 1)];
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Drop a `[<key>]` header with nothing but blank lines under it, and the empty table from the value.
|
|
354
|
+
* A table that was only implied by its sub-tables (`[<key>.<name>]`) has no header: once the last
|
|
355
|
+
* sub-table is gone, it is gone from the value too.
|
|
356
|
+
*/
|
|
357
|
+
function withoutEmptySection(doc, lines, key) {
|
|
358
|
+
const start = sectionHeader(lines, key);
|
|
359
|
+
if (start === -1) {
|
|
360
|
+
const subTable = new RegExp(`^\\s*\\[\\s*${escapeRegExp(key)}\\s*\\.`);
|
|
361
|
+
if (!lines.some((line) => subTable.test(line)))
|
|
362
|
+
delete doc.value[key];
|
|
363
|
+
return [...lines];
|
|
364
|
+
}
|
|
365
|
+
const next = lines.findIndex((line, index) => index > start && /^\s*\[/.test(line));
|
|
366
|
+
const body = lines.slice(start + 1, next === -1 ? lines.length : next);
|
|
367
|
+
if (body.some((line) => line.trim() !== ""))
|
|
368
|
+
return [...lines];
|
|
369
|
+
delete doc.value[key];
|
|
370
|
+
return [...lines.slice(0, start), ...(next === -1 ? [] : lines.slice(next))];
|
|
371
|
+
}
|
|
372
|
+
function escapeRegExp(text) {
|
|
373
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
374
|
+
}
|
|
375
|
+
/** The TOML edit buffer, when edited and it decodes to exactly the doc's value; undefined otherwise. */
|
|
376
|
+
function tomlEditedText(doc) {
|
|
377
|
+
if (!doc.edited || typeof doc.text !== "string")
|
|
378
|
+
return undefined;
|
|
379
|
+
try {
|
|
380
|
+
return valuesEqual(parseToml(doc.text), doc.value) ? doc.text : undefined;
|
|
381
|
+
}
|
|
382
|
+
catch {
|
|
383
|
+
return undefined; // the line edit did not fit this file's layout: stringify the value instead
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
/** A hooks entry we own: its command (Cursor's flat entry) or one of its group's commands carries the snippet marker. */
|
|
387
|
+
export function isOurHookGroup(group) {
|
|
388
|
+
if (!isRecord(group))
|
|
389
|
+
return false;
|
|
390
|
+
return isOurCommand(group) || (Array.isArray(group["hooks"]) && group["hooks"].some(isOurCommand));
|
|
391
|
+
}
|
|
392
|
+
function isOurCommand(hook) {
|
|
393
|
+
return isRecord(hook) && typeof hook["command"] === "string" && hook["command"].includes(SNIPPET_MARKER);
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* The value an entry should hold once ours is applied. A `hooks` event holds a list of
|
|
397
|
+
* groups shared with the user's own hooks: theirs are kept in order and ours replaced.
|
|
398
|
+
* A top-level version keeps a higher one already there.
|
|
399
|
+
*/
|
|
400
|
+
export function entryValue(containerKey, current, desired) {
|
|
401
|
+
// A schema version the file already declares at or above ours (Cursor's `version`) is the person's or a newer tool's: kept.
|
|
402
|
+
if (containerKey === TOP_LEVEL)
|
|
403
|
+
return typeof desired === "number" && typeof current === "number" && current >= desired ? current : desired;
|
|
404
|
+
if (containerKey !== "hooks" || !Array.isArray(desired))
|
|
405
|
+
return desired;
|
|
406
|
+
const theirs = Array.isArray(current) ? current.filter((group) => !isOurHookGroup(group)) : [];
|
|
407
|
+
return [...theirs, ...desired];
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Delete `servers[name]` when present. No-op when the table or the entry is
|
|
411
|
+
* absent. For JSONC the deletion is a style-preserving edit.
|
|
412
|
+
*/
|
|
413
|
+
export function removeServer(doc, format, ref) {
|
|
414
|
+
const { serversKey, name } = ref;
|
|
415
|
+
if (doc.format !== format)
|
|
416
|
+
throw new Error(`doc format ${doc.format} does not match requested ${format}`);
|
|
417
|
+
if (format === "jsonc") {
|
|
418
|
+
const servers = getServers(doc, format, serversKey);
|
|
419
|
+
if (servers === undefined || !(name in servers))
|
|
420
|
+
return doc;
|
|
421
|
+
const text = jsoncPrepareBuffer(doc);
|
|
422
|
+
doc.text = jsoncRemoveProperty(text, serversKey, name);
|
|
423
|
+
doc.edited = true;
|
|
424
|
+
jsoncSyncValue(doc);
|
|
425
|
+
return doc;
|
|
426
|
+
}
|
|
427
|
+
const servers = getServers(doc, format, serversKey);
|
|
428
|
+
if (servers === undefined)
|
|
429
|
+
return doc;
|
|
430
|
+
delete servers[name];
|
|
431
|
+
if (format === "toml")
|
|
432
|
+
tomlEditTable(doc, { key: serversKey, name, value: undefined });
|
|
433
|
+
return doc;
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Deep-equal over sorted keys (key order ignored, array order preserved).
|
|
437
|
+
* Returns true when `a` and `b` are semantically the same config value.
|
|
438
|
+
*/
|
|
439
|
+
export function valuesEqual(a, b) {
|
|
440
|
+
return canonicalize(a) === canonicalize(b);
|
|
441
|
+
}
|
|
442
|
+
function canonicalize(value) {
|
|
443
|
+
if (Array.isArray(value))
|
|
444
|
+
return `[${value.map(canonicalize).join(",")}]`;
|
|
445
|
+
if (isRecord(value)) {
|
|
446
|
+
const keys = Object.keys(value).sort();
|
|
447
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalize(value[k])}`).join(",")}}`;
|
|
448
|
+
}
|
|
449
|
+
return JSON.stringify(value) ?? "undefined";
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Marker detection: does the doc carry a heedvane-managed entry? True when
|
|
453
|
+
* `name` is the reserved name or carries an identity marker, or when ANY
|
|
454
|
+
* entry in the servers table is marker-carrying (our entry may live under a
|
|
455
|
+
* user-chosen name). False when the doc has no heedvane entry.
|
|
456
|
+
*/
|
|
457
|
+
export function detectMarker(doc, format, ref) {
|
|
458
|
+
const { serversKey, name } = ref;
|
|
459
|
+
const servers = getServers(doc, format, serversKey);
|
|
460
|
+
if (servers === undefined)
|
|
461
|
+
return false;
|
|
462
|
+
if (Object.prototype.hasOwnProperty.call(servers, name) && isHeedvaneEntry(name, servers[name]))
|
|
463
|
+
return true;
|
|
464
|
+
return Object.keys(servers).some((k) => isHeedvaneEntry(k, servers[k]));
|
|
465
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { HeedGatingConfig } from "../heed-file.js";
|
|
2
|
+
/**
|
|
3
|
+
* On-disk dispatcher (Task 6 recipe), and the one place that decides which sessions run the hooks
|
|
4
|
+
* (heed.yaml `gating.mode`, baked in at setup).
|
|
5
|
+
*
|
|
6
|
+
* Written by setup to ~/.heed/agent-hooks/<agent>-hook.sh — never inlined in agent
|
|
7
|
+
* configs, so policy updates rewrite only this file. Observe-only v1: every path
|
|
8
|
+
* exits 0; the agent is never blocked and never sees stderr noise.
|
|
9
|
+
*
|
|
10
|
+
* Spool fallback (Orca's spool_hook_event): appends one JSON line to
|
|
11
|
+
* <heed root>/spool/pane-<key>.jsonl, beside endpoint.env (HEED_AGENT_HOOK_ENDPOINT, else
|
|
12
|
+
* $HEED_AGENT_HOOK_ROOT or ~/.heed), 5 MB cap per pane file, 7-day TTL via
|
|
13
|
+
* `find -mtime`, 0600 perms, JSON-escaped envelope carrying
|
|
14
|
+
* paneKey/tabId/worktreeId/launchToken/source/receivedAt/payload.
|
|
15
|
+
*
|
|
16
|
+
* Live path: curl POST to http://127.0.0.1:$HEED_AGENT_HOOK_PORT/hook/<agent> with
|
|
17
|
+
* X-Heed-Agent-Hook-Token and X-Heed-Hook-Version: 1, 0.5s connect / 0.8s max, falling back to spool
|
|
18
|
+
* on any failure. The listener answers from this machine alone; only a session start waits (2s max)
|
|
19
|
+
* for the session's one fetch of the steering policy.
|
|
20
|
+
*/
|
|
21
|
+
export interface DispatcherOptions {
|
|
22
|
+
/** heed.yaml `heedvane.skills.whenUnknown: deny`: with no listener to check it, deny a skill use. */
|
|
23
|
+
denySkillsWhenUnchecked?: boolean;
|
|
24
|
+
/** heed.yaml `gating.mode`: `pane` (the default) runs the hooks only in sessions launched with
|
|
25
|
+
* HEED_SESSION set; `always` runs them in every session. */
|
|
26
|
+
gating?: HeedGatingConfig["mode"];
|
|
27
|
+
}
|
|
28
|
+
export declare function dispatcherScript(agentId: string, options?: DispatcherOptions): string;
|
|
29
|
+
/** The on-disk dispatcher directory under the heed root. */
|
|
30
|
+
export declare function agentHooksDir(home: string): string;
|
|
31
|
+
/** Write (or update) the dispatcher script for one agent. Idempotent, mode 0700. */
|
|
32
|
+
export declare function writeDispatcherScript(home: string, agent: string, options?: DispatcherOptions): Promise<void>;
|