balladeer 1.0.6 → 1.0.7

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.
@@ -0,0 +1,29 @@
1
+ type Host = "codex" | "claude";
2
+ export type GuidanceInstallResult = Readonly<{
3
+ status: "installed_needs_host_trust" | "installed_trust_unverified" | "unavailable" | "not_applicable";
4
+ reason?: string;
5
+ changed: boolean;
6
+ hosts: readonly Host[];
7
+ }>;
8
+ export declare function guidanceHookCommand(host: Host, repositoryId: string, controlPlane: string): string;
9
+ /** Migrate only the current checkout's already-bound integration. No disk search or pairing. */
10
+ export declare function installGuidanceLoader(options: {
11
+ environment: NodeJS.ProcessEnv;
12
+ cwd: string;
13
+ repositoryId: string;
14
+ controlPlane: string;
15
+ /** Explicit setup targets one host; automatic MCP startup may inspect both. */
16
+ host?: Host;
17
+ /** Deterministic race injection for acceptance, never used by CLI. */
18
+ beforeCommit?: () => void;
19
+ /** Deterministic interrupted-rename acceptance injection, never used by CLI. */
20
+ beforeRename?: (index: number) => void;
21
+ }): GuidanceInstallResult;
22
+ /** A project hook is inert outside the exact repository integration which installed it. */
23
+ export declare function validateProjectGuidanceScope(options: {
24
+ cwd: string;
25
+ hook: Host;
26
+ repositoryId: string;
27
+ controlPlane: string;
28
+ }): boolean;
29
+ export {};
@@ -0,0 +1,420 @@
1
+ import { DEFAULT_CONTROL_PLANE } from "./wire.js";
2
+ import { guidanceRuntimeCommand, installGuidanceRuntime } from "./guidance-runtime.js";
3
+ import { createHash, randomBytes } from "node:crypto";
4
+ import { execFileSync } from "node:child_process";
5
+ import { constants, closeSync, fstatSync, openSync, existsSync, lstatSync, linkSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
6
+ import { hostname } from "node:os";
7
+ import { dirname, join, relative } from "node:path";
8
+ import { parse } from "@iarna/toml";
9
+ import { readCodexEntry, hasManagedCodexEntry } from "./codex-config.js";
10
+ import { CONVENTIONS_END, CONVENTIONS_START, CONVENTIONS_VERSION, managedByLine, } from "./conventions.js";
11
+ import { CONVENTIONS_BLOCK } from "./copy.js";
12
+ import { currentEntry, entryRepositoryId, isOurEntry, readMcpConfig } from "./mcp-config.js";
13
+ const KNOWN_POLICIES = new Set([
14
+ "9c3a271aaeadb2c6543faa23c2a870212fecaaa56dc1dfd2fccf112b5041900d",
15
+ "a8527de805c81003ff5f9f6abaf7a2852911384393cc444fcd50e36bf911289d",
16
+ "3c40ead350a3dc57f0d9f8317fbca273f0c27cd38754cdae47d96f86455a14e2",
17
+ ]);
18
+ const HOOK_START = "# balladeer:guidance:start";
19
+ const HOOK_END = "# balladeer:guidance:end";
20
+ const EVENTS = ["SessionStart", "UserPromptSubmit", "SubagentStart"];
21
+ const OWNER = "Balladeer current guidance (loader 1)";
22
+ const MAX_FILE = 262144;
23
+ function hash(text) {
24
+ return createHash("sha256").update(text).digest("hex");
25
+ }
26
+ function lockOwner(path) {
27
+ const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
28
+ let owner;
29
+ let stat;
30
+ try {
31
+ stat = fstatSync(fd);
32
+ if (!stat.isFile() ||
33
+ stat.size > 1024 ||
34
+ (typeof process.getuid === "function" && stat.uid !== process.getuid()))
35
+ throw new Error("lock_owner_unverifiable");
36
+ owner = JSON.parse(readFileSync(fd, "utf8"));
37
+ }
38
+ catch {
39
+ throw new Error("lock_owner_unverifiable");
40
+ }
41
+ finally {
42
+ closeSync(fd);
43
+ }
44
+ if (owner.schemaVersion !== 1 ||
45
+ !Number.isSafeInteger(owner.pid) ||
46
+ owner.pid <= 0 ||
47
+ owner.host !== hostname() ||
48
+ !/^[a-f0-9]{32}$/.test(owner.nonce))
49
+ throw new Error("lock_owner_unverifiable");
50
+ return { owner, identity: `${stat.dev}:${stat.ino}:${owner.nonce}` };
51
+ }
52
+ function ownerIsAlive(pid) {
53
+ try {
54
+ process.kill(pid, 0);
55
+ return true;
56
+ }
57
+ catch (error) {
58
+ if (error.code === "ESRCH")
59
+ return false;
60
+ // EPERM and unknown process errors never authorize stealing a lock.
61
+ return true;
62
+ }
63
+ }
64
+ /** Initialized metadata is linked atomically: process death cannot leave an empty owned lock. */
65
+ function acquireMigrationLock(path) {
66
+ const nonce = randomBytes(16).toString("hex");
67
+ const temporary = `${path}.${nonce}.tmp`;
68
+ const owner = { schemaVersion: 1, pid: process.pid, host: hostname(), nonce };
69
+ let recovery;
70
+ let ownsRecovery = false;
71
+ let identity;
72
+ writeFileSync(temporary, JSON.stringify(owner), { flag: "wx", mode: 0o600 });
73
+ try {
74
+ try {
75
+ linkSync(temporary, path);
76
+ }
77
+ catch (error) {
78
+ if (error.code !== "EEXIST")
79
+ throw error;
80
+ const stale = lockOwner(path);
81
+ if (ownerIsAlive(stale.owner.pid))
82
+ throw new Error("migration_in_progress");
83
+ // One reaper per immutable old identity. A second reaper cannot unlink a
84
+ // replacement live lock. An interrupted reaper remains explicitly blocked.
85
+ recovery = `${path}.recovery-${stale.owner.nonce}`;
86
+ try {
87
+ linkSync(temporary, recovery);
88
+ ownsRecovery = true;
89
+ }
90
+ catch (claimError) {
91
+ if (claimError.code === "EEXIST")
92
+ throw new Error("lock_recovery_unavailable");
93
+ throw claimError;
94
+ }
95
+ if (lockOwner(path).identity !== stale.identity || ownerIsAlive(stale.owner.pid))
96
+ throw new Error("migration_in_progress");
97
+ unlinkSync(path);
98
+ // A competing normal acquirer may win here; never remove its lock.
99
+ try {
100
+ linkSync(temporary, path);
101
+ }
102
+ catch (claimError) {
103
+ if (claimError.code === "EEXIST")
104
+ throw new Error("migration_in_progress");
105
+ throw claimError;
106
+ }
107
+ }
108
+ identity = lockOwner(path).identity;
109
+ }
110
+ finally {
111
+ try {
112
+ unlinkSync(temporary);
113
+ }
114
+ catch {
115
+ /* Already absent. */
116
+ }
117
+ if (ownsRecovery && recovery) {
118
+ try {
119
+ unlinkSync(recovery);
120
+ }
121
+ catch {
122
+ /* Interrupted recovery stays conservative. */
123
+ }
124
+ }
125
+ }
126
+ return () => {
127
+ try {
128
+ if (lockOwner(path).identity === identity)
129
+ unlinkSync(path);
130
+ }
131
+ catch {
132
+ /* Never remove an unverified replacement lock. */
133
+ }
134
+ };
135
+ }
136
+ function readRegular(path, allowMissing = false, writable = true) {
137
+ if (!existsSync(path)) {
138
+ try {
139
+ lstatSync(path);
140
+ }
141
+ catch (e) {
142
+ if (e.code === "ENOENT" && allowMissing)
143
+ return undefined;
144
+ }
145
+ throw new Error("file_unavailable");
146
+ }
147
+ const stat = lstatSync(path);
148
+ if (!stat.isFile() ||
149
+ stat.isSymbolicLink() ||
150
+ stat.size > MAX_FILE ||
151
+ (writable && (stat.mode & 0o222) === 0))
152
+ throw new Error("file_not_safely_writable");
153
+ return readFileSync(path, "utf8");
154
+ }
155
+ function safeParent(root, path) {
156
+ let at = dirname(path);
157
+ while (at !== root) {
158
+ if (relative(root, at).startsWith(".."))
159
+ throw new Error("outside_repository");
160
+ try {
161
+ const stat = lstatSync(at);
162
+ if (!stat.isDirectory() || stat.isSymbolicLink())
163
+ throw new Error("unsafe_directory");
164
+ }
165
+ catch (e) {
166
+ if (e.code !== "ENOENT")
167
+ throw e;
168
+ }
169
+ at = dirname(at);
170
+ }
171
+ }
172
+ function policyEdit(path) {
173
+ const before = readRegular(path);
174
+ const starts = [...before.matchAll(/<!-- balladeer:conventions:start(?: v\d{1,4})? -->/g)];
175
+ const ends = [...before.matchAll(/<!-- balladeer:conventions:end -->/g)];
176
+ if (starts.length !== 1 || ends.length !== 1 || starts[0].index >= ends[0].index)
177
+ throw new Error("managed_block_ambiguous");
178
+ const start = starts[0], end = ends[0];
179
+ const interior = before.slice(start.index + start[0].length, end.index).trim();
180
+ const body = interior
181
+ .replace(/^Managed by Balladeer \(conventions v\d+\)\.[^\n]*\n\s*/, "")
182
+ .trim();
183
+ if (body !== CONVENTIONS_BLOCK.trim() && !KNOWN_POLICIES.has(hash(body)))
184
+ throw new Error("managed_block_customized_or_unknown");
185
+ const after = before.slice(0, start.index) +
186
+ `${CONVENTIONS_START}\n${managedByLine(CONVENTIONS_VERSION)}\n\n${CONVENTIONS_BLOCK.trim()}\n${CONVENTIONS_END}` +
187
+ before.slice(end.index + end[0].length);
188
+ return { path, before, after, mode: lstatSync(path).mode & 0o777 };
189
+ }
190
+ export function guidanceHookCommand(host, repositoryId, controlPlane) {
191
+ return guidanceRuntimeCommand(host, repositoryId, controlPlane);
192
+ }
193
+ function hookEdit(root, host, repositoryId, controlPlane) {
194
+ const command = guidanceHookCommand(host, repositoryId, controlPlane);
195
+ const path = join(root, host === "codex" ? ".codex/config.toml" : ".claude/settings.json");
196
+ safeParent(root, path);
197
+ const before = readRegular(path, true);
198
+ let after;
199
+ if (host === "codex") {
200
+ const base = before ?? "";
201
+ const starts = [...base.matchAll(/^# balladeer:guidance:start$/gm)], ends = [...base.matchAll(/^# balladeer:guidance:end$/gm)];
202
+ if (starts.length !== ends.length ||
203
+ starts.length > 1 ||
204
+ (starts[0] && starts[0].index >= ends[0].index))
205
+ throw new Error("hook_block_ambiguous");
206
+ const block = [
207
+ HOOK_START,
208
+ ...EVENTS.flatMap((event) => [
209
+ `[[hooks.${event}]]`,
210
+ `[[hooks.${event}.hooks]]`,
211
+ 'type = "command"',
212
+ `command = ${JSON.stringify(`${command} --event ${event}`)}`,
213
+ "timeout = 3",
214
+ "additionalContextLimit = 2200",
215
+ ]),
216
+ HOOK_END,
217
+ ].join("\n");
218
+ if (starts[0]) {
219
+ const old = base.slice(starts[0].index, ends[0].index + HOOK_END.length);
220
+ if (old !== block)
221
+ throw new Error("hook_block_customized_or_unknown");
222
+ after = base;
223
+ }
224
+ else
225
+ after = base + (base.endsWith("\n") || base === "" ? "" : "\n") + block + "\n";
226
+ parse(after);
227
+ }
228
+ else {
229
+ const obj = before === undefined ? {} : JSON.parse(before);
230
+ if (!obj || typeof obj !== "object" || Array.isArray(obj))
231
+ throw new Error("invalid_hook_config");
232
+ const hooks = obj.hooks === undefined ? {} : obj.hooks;
233
+ if (!hooks || typeof hooks !== "object" || Array.isArray(hooks))
234
+ throw new Error("invalid_hooks");
235
+ for (const event of EVENTS) {
236
+ const rows = hooks[event] === undefined ? [] : hooks[event];
237
+ if (!Array.isArray(rows))
238
+ throw new Error("invalid_hook_event");
239
+ const own = rows.filter((row) => row?.hooks?.some((h) => h.statusMessage === OWNER));
240
+ const expected = {
241
+ hooks: [
242
+ {
243
+ type: "command",
244
+ command: `${command} --event ${event}`,
245
+ timeout: 3,
246
+ statusMessage: OWNER,
247
+ },
248
+ ],
249
+ };
250
+ if (own.length > 1 ||
251
+ (own.length === 1 && JSON.stringify(own[0]) !== JSON.stringify(expected)))
252
+ throw new Error("hook_customized_or_unknown");
253
+ hooks[event] = own.length === 1 ? rows : [...rows, expected];
254
+ }
255
+ obj.hooks = hooks;
256
+ // If semantically unchanged, preserve formatting as well as settings.
257
+ after =
258
+ before !== undefined && JSON.stringify(JSON.parse(before)) === JSON.stringify(obj)
259
+ ? before
260
+ : JSON.stringify(obj, null, 2) + "\n";
261
+ }
262
+ return { path, before, after, mode: before === undefined ? 0o600 : lstatSync(path).mode & 0o777 };
263
+ }
264
+ /** Migrate only the current checkout's already-bound integration. No disk search or pairing. */
265
+ export function installGuidanceLoader(options) {
266
+ let root;
267
+ try {
268
+ root = execFileSync("git", ["-C", options.cwd, "rev-parse", "--show-toplevel"], {
269
+ encoding: "utf8",
270
+ stdio: ["ignore", "pipe", "ignore"],
271
+ timeout: 1000,
272
+ }).trim();
273
+ }
274
+ catch {
275
+ return { status: "not_applicable", reason: "no_repository_root", changed: false, hosts: [] };
276
+ }
277
+ const lock = join(root, ".balladeer-guidance-migration.lock");
278
+ let releaseLock;
279
+ const hosts = [];
280
+ let changed = false;
281
+ const staged = [];
282
+ try {
283
+ releaseLock = acquireMigrationLock(lock);
284
+ for (const host of ["codex", "claude"]) {
285
+ if (options.host !== undefined && host !== options.host)
286
+ continue;
287
+ const config = join(root, host === "codex" ? ".codex/config.toml" : ".mcp.json");
288
+ safeParent(root, config);
289
+ if (!existsSync(config))
290
+ continue;
291
+ readRegular(config);
292
+ const entry = host === "codex" ? readCodexEntry(root) : currentEntry(readMcpConfig(root));
293
+ if (!isOurEntry(entry, options.controlPlane))
294
+ continue;
295
+ if (host === "codex" && !hasManagedCodexEntry(root, options.controlPlane))
296
+ throw new Error("codex_entry_unmanaged");
297
+ if (entryRepositoryId(entry) !== options.repositoryId)
298
+ throw new Error("repository_mapping_conflict");
299
+ const args = entry.args, at = args.indexOf("--control-plane");
300
+ if (at !== -1 && args[at + 1] !== options.controlPlane)
301
+ throw new Error("control_plane_mapping_conflict");
302
+ if (at === -1 && options.controlPlane !== DEFAULT_CONTROL_PLANE)
303
+ throw new Error("control_plane_mapping_conflict");
304
+ hosts.push(host);
305
+ }
306
+ if (hosts.length === 0)
307
+ return { status: "not_applicable", reason: "no_bound_managed_entry", changed: false, hosts };
308
+ const paths = new Set();
309
+ for (const host of hosts)
310
+ paths.add(join(root, host === "codex"
311
+ ? "AGENTS.md"
312
+ : existsSync(join(root, "CLAUDE.md"))
313
+ ? "CLAUDE.md"
314
+ : "AGENTS.md"));
315
+ // A second owned block is also loaded by some hosts; never leave conflicting policy behind.
316
+ for (const name of options.host === undefined ? ["AGENTS.md", "CLAUDE.md"] : []) {
317
+ const path = join(root, name);
318
+ if (existsSync(path) && readRegular(path).includes("balladeer:conventions:start"))
319
+ paths.add(path);
320
+ }
321
+ const edits = [...paths]
322
+ .map(policyEdit)
323
+ .concat(hosts.map((host) => hookEdit(root, host, options.repositoryId, options.controlPlane)));
324
+ const conflicts = execFileSync("git", ["-C", root, "ls-files", "--unmerged", "--", ...edits.map((e) => relative(root, e.path))], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 1000 });
325
+ if (conflicts.trim())
326
+ throw new Error("unresolved_merge");
327
+ for (const edit of edits.filter((e) => e.before !== e.after)) {
328
+ safeParent(root, edit.path);
329
+ mkdirSync(dirname(edit.path), { recursive: true });
330
+ const temporary = join(dirname(edit.path), `.balladeer-guidance-${randomBytes(8).toString("hex")}.tmp`);
331
+ writeFileSync(temporary, edit.after, { flag: "wx", mode: edit.mode });
332
+ staged.push({ edit, temporary });
333
+ }
334
+ options.beforeCommit?.();
335
+ for (const { edit } of staged) {
336
+ safeParent(root, edit.path);
337
+ if (readRegular(edit.path, true) !== edit.before)
338
+ throw new Error("concurrent_file_change");
339
+ }
340
+ for (const host of hosts) {
341
+ const config = join(root, host === "codex" ? ".codex/config.toml" : ".mcp.json");
342
+ const finalConfiguration = edits.find((edit) => edit.path === config)?.after ?? readRegular(config);
343
+ installGuidanceRuntime({
344
+ root,
345
+ host,
346
+ repositoryId: options.repositoryId,
347
+ controlPlane: options.controlPlane,
348
+ environment: options.environment,
349
+ configurationDigest: createHash("sha256").update(finalConfiguration).digest("hex"),
350
+ });
351
+ }
352
+ for (const [index, { edit, temporary }] of staged.entries()) {
353
+ options.beforeRename?.(index);
354
+ // Recheck immediately before each rename as well as before the batch.
355
+ safeParent(root, edit.path);
356
+ if (readRegular(edit.path, true) !== edit.before)
357
+ throw new Error("concurrent_file_change");
358
+ renameSync(temporary, edit.path);
359
+ changed = true;
360
+ }
361
+ return {
362
+ status: changed ? "installed_needs_host_trust" : "installed_trust_unverified",
363
+ changed,
364
+ hosts,
365
+ };
366
+ }
367
+ catch (e) {
368
+ const reason = e.code === "EEXIST"
369
+ ? "migration_in_progress"
370
+ : e instanceof Error && /^[a-z_]+$/.test(e.message)
371
+ ? e.message
372
+ : "migration_unavailable";
373
+ return {
374
+ status: "unavailable",
375
+ reason: changed ? "partial_migration_retry" : reason,
376
+ changed,
377
+ hosts,
378
+ };
379
+ }
380
+ finally {
381
+ for (const { temporary } of staged) {
382
+ try {
383
+ unlinkSync(temporary);
384
+ }
385
+ catch {
386
+ /* Renamed or already absent. */
387
+ }
388
+ }
389
+ releaseLock?.();
390
+ }
391
+ }
392
+ /** A project hook is inert outside the exact repository integration which installed it. */
393
+ export function validateProjectGuidanceScope(options) {
394
+ try {
395
+ const root = execFileSync("git", ["-C", options.cwd, "rev-parse", "--show-toplevel"], {
396
+ encoding: "utf8",
397
+ stdio: ["ignore", "pipe", "ignore"],
398
+ timeout: 1000,
399
+ }).trim();
400
+ const path = join(root, options.hook === "codex" ? ".codex/config.toml" : ".mcp.json");
401
+ safeParent(root, path);
402
+ readRegular(path, false, false);
403
+ const entry = options.hook === "codex" ? readCodexEntry(root) : currentEntry(readMcpConfig(root));
404
+ if (!isOurEntry(entry, options.controlPlane) ||
405
+ entryRepositoryId(entry) !== options.repositoryId)
406
+ return false;
407
+ if (options.hook === "codex" && !hasManagedCodexEntry(root, options.controlPlane))
408
+ return false;
409
+ const args = entry.args;
410
+ if (!Array.isArray(args))
411
+ return false;
412
+ const at = args.indexOf("--control-plane");
413
+ return at === -1
414
+ ? options.controlPlane === DEFAULT_CONTROL_PLANE
415
+ : args[at + 1] === options.controlPlane;
416
+ }
417
+ catch {
418
+ return false;
419
+ }
420
+ }
@@ -0,0 +1 @@
1
+ export { runGuidance } from "./commands/guidance.js";
@@ -0,0 +1,2 @@
1
+ // The published standalone hook has no npm/runtime-package dependency.
2
+ export { runGuidance } from "./commands/guidance.js";
@@ -0,0 +1,15 @@
1
+ type Scope = {
2
+ root: string;
3
+ host: "codex" | "claude";
4
+ repositoryId: string;
5
+ controlPlane: string;
6
+ };
7
+ export declare function guidanceRuntimeKey(scope: Scope): string;
8
+ export declare function installGuidanceRuntime(options: Scope & {
9
+ environment: NodeJS.ProcessEnv;
10
+ /** Digest of the final, already-validated project MCP configuration. */
11
+ configurationDigest: string;
12
+ }): void;
13
+ /** Stable, dependency-free wrapper. Current policy exists only in the authenticated server response. */
14
+ export declare function guidanceRuntimeCommand(host: "codex" | "claude", repositoryId: string, controlPlane: string): string;
15
+ export {};
@@ -0,0 +1,149 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { constants, closeSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { ensureStoreDirectory } from "./store.js";
6
+ import { GUIDANCE_UNAVAILABLE } from "./guidance.js";
7
+ const OWNER = "balladeer-guidance-runtime/1";
8
+ const HEX = /^[a-f0-9]{64}$/;
9
+ const MAX_RUNTIME = 1_048_576;
10
+ const sha = (value) => createHash("sha256").update(value).digest("hex");
11
+ const quote = (s) => `'${s.replaceAll("'", `'\\''`)}'`;
12
+ export function guidanceRuntimeKey(scope) {
13
+ return sha(JSON.stringify([
14
+ realpathSync(scope.root),
15
+ new URL(scope.controlPlane).origin,
16
+ scope.repositoryId,
17
+ scope.host,
18
+ ]));
19
+ }
20
+ function privateDirectory(path) {
21
+ mkdirSync(path, { mode: 0o700, recursive: false });
22
+ }
23
+ function checkDirectory(path) {
24
+ const stat = lstatSync(path);
25
+ if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0)
26
+ throw new Error("guidance_runtime_directory_unsafe");
27
+ }
28
+ function readPrivate(path, max) {
29
+ const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
30
+ try {
31
+ const stat = fstatSync(fd);
32
+ if (!stat.isFile() || stat.size > max || (stat.mode & 0o077) !== 0)
33
+ throw new Error("guidance_runtime_file_unsafe");
34
+ return readFileSync(fd);
35
+ }
36
+ finally {
37
+ closeSync(fd);
38
+ }
39
+ }
40
+ function atomic(path, body) {
41
+ const temporary = `${path}.${randomBytes(8).toString("hex")}.tmp`;
42
+ try {
43
+ writeFileSync(temporary, body, { mode: 0o600, flag: "wx" });
44
+ renameSync(temporary, path);
45
+ }
46
+ finally {
47
+ try {
48
+ unlinkSync(temporary);
49
+ }
50
+ catch {
51
+ /* Already renamed. */
52
+ }
53
+ }
54
+ }
55
+ export function installGuidanceRuntime(options) {
56
+ if (!HEX.test(options.configurationDigest))
57
+ throw new Error("guidance_runtime_scope_invalid");
58
+ const home = ensureStoreDirectory(options.environment);
59
+ const key = guidanceRuntimeKey(options);
60
+ const base = join(home, "guidance-runtime-v1");
61
+ const directory = join(base, key);
62
+ for (const path of [base, directory]) {
63
+ try {
64
+ privateDirectory(path);
65
+ }
66
+ catch (error) {
67
+ if (error.code !== "EEXIST")
68
+ throw error;
69
+ }
70
+ checkDirectory(path);
71
+ }
72
+ const manifestPath = join(directory, "current.json");
73
+ let previous;
74
+ try {
75
+ previous = JSON.parse(readPrivate(manifestPath, 4096).toString("utf8"));
76
+ if (previous?.owner !== OWNER || previous.scopeKey !== key || !HEX.test(previous.digest))
77
+ throw new Error("guidance_runtime_manifest_unknown");
78
+ }
79
+ catch (error) {
80
+ if (error.code !== "ENOENT")
81
+ throw error;
82
+ }
83
+ // Source execution tests use the freshly built published asset too; no alternate hook implementation.
84
+ const bodyPath = fileURLToPath(new URL(import.meta.url.endsWith(".ts") ? "../dist/guidance-hook.mjs" : "./guidance-hook.mjs", import.meta.url));
85
+ const body = readFileSync(bodyPath);
86
+ if (body.length === 0 || body.length > MAX_RUNTIME)
87
+ throw new Error("guidance_runtime_bundle_invalid");
88
+ const digest = sha(body);
89
+ const target = join(directory, `${digest}.mjs`);
90
+ try {
91
+ if (!readPrivate(target, MAX_RUNTIME).equals(body))
92
+ throw new Error("guidance_runtime_body_unknown");
93
+ }
94
+ catch (error) {
95
+ if (error.code !== "ENOENT")
96
+ throw error;
97
+ writeFileSync(target, body, { mode: 0o600, flag: "wx" });
98
+ }
99
+ atomic(manifestPath, JSON.stringify({
100
+ owner: OWNER,
101
+ scopeKey: key,
102
+ digest,
103
+ configurationDigest: options.configurationDigest,
104
+ generation: randomBytes(16).toString("hex"),
105
+ }) + "\n");
106
+ if (previous && previous.digest !== digest) {
107
+ try {
108
+ const old = join(directory, `${previous.digest}.mjs`);
109
+ if (sha(readPrivate(old, MAX_RUNTIME)) === previous.digest)
110
+ unlinkSync(old);
111
+ }
112
+ catch {
113
+ /* Retain unrecognized or concurrently removed files. */
114
+ }
115
+ }
116
+ }
117
+ /** Stable, dependency-free wrapper. Current policy exists only in the authenticated server response. */
118
+ export function guidanceRuntimeCommand(host, repositoryId, controlPlane) {
119
+ // Keep this readable: it is the exact code the host asks a person to trust once.
120
+ const wrapper = `
121
+ const fs = require("node:fs"), path = require("node:path"), crypto = require("node:crypto");
122
+ const argv = process.argv.slice(1), get = name => argv[argv.indexOf(name) + 1];
123
+ const hook = get("--hook"), repositoryId = get("--repository"), controlPlane = get("--control-plane"), event = get("--event");
124
+ const sha = value => crypto.createHash("sha256").update(value).digest("hex");
125
+ const read = (file, max, privateFile) => { const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); try { const s = fs.fstatSync(fd); if (!s.isFile() || s.size > max || (privateFile && (s.mode & 63))) throw Error("unsafe"); return fs.readFileSync(fd); } finally { fs.closeSync(fd); } };
126
+ const checkDir = dir => { const s = fs.lstatSync(dir); if (!s.isDirectory() || s.isSymbolicLink() || (s.mode & 63)) throw Error("unsafe"); };
127
+ (async () => {
128
+ if (!["codex", "claude"].includes(hook) || !["SessionStart", "SubagentStart", "UserPromptSubmit"].includes(event)) return;
129
+ const root = fs.realpathSync(require("node:child_process").execFileSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 250, maxBuffer: 8192 }).trim());
130
+ const key = sha(JSON.stringify([root, new URL(controlPlane).origin, repositoryId, hook]));
131
+ const home = path.resolve(process.env.BALLADEER_CONFIG_HOME?.trim() || (process.env.XDG_CONFIG_HOME?.trim() ? path.join(process.env.XDG_CONFIG_HOME.trim(), "balladeer") : path.join(require("node:os").homedir(), ".config", "balladeer")));
132
+ const base = path.join(home, "guidance-runtime-v1"), directory = path.join(base, key); [home, base, directory].forEach(checkDir);
133
+ const manifest = JSON.parse(read(path.join(directory, "current.json"), 4096, true));
134
+ if (manifest.owner !== "${OWNER}" || manifest.scopeKey !== key || !/^[a-f0-9]{64}$/.test(manifest.digest) || !/^[a-f0-9]{64}$/.test(manifest.configurationDigest) || !/^[a-f0-9]{32}$/.test(manifest.generation)) return;
135
+ try {
136
+ const file = path.join(directory, manifest.digest + ".mjs");
137
+ if (sha(read(file, ${MAX_RUNTIME}, true)) !== manifest.digest) throw Error("integrity");
138
+ const runtime = await import(require("node:url").pathToFileURL(file).href);
139
+ await runtime.runGuidance({ hook, event, runtimeGeneration: manifest.generation, repositoryId, controlPlane, environment: process.env, cwd: process.cwd(), stdin: process.stdin, write: text => process.stdout.write(text) });
140
+ } catch {
141
+ const config = path.join(root, hook === "codex" ? ".codex/config.toml" : ".mcp.json");
142
+ if (hook === "codex") { const s = fs.lstatSync(path.dirname(config)); if (!s.isDirectory() || s.isSymbolicLink()) return; }
143
+ if (sha(read(config, 262144, false)) !== manifest.configurationDigest) return;
144
+ process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: event, additionalContext: ${JSON.stringify(GUIDANCE_UNAVAILABLE)} } }) + "\\n");
145
+ }
146
+ })().catch(() => {});
147
+ `.trim();
148
+ return `node -e ${quote(wrapper)} -- --hook ${host} --repository ${quote(repositoryId)} --control-plane ${quote(controlPlane)}`;
149
+ }
@@ -0,0 +1,39 @@
1
+ import { type StoredAgent } from "./store.js";
2
+ export type GuidanceDocument = Readonly<{
3
+ schemaVersion: 1;
4
+ revision: string;
5
+ digest: string;
6
+ workspaceId: string;
7
+ repositoryId: string;
8
+ captureStyle: "quiet" | "thorough" | "off";
9
+ instructions: string;
10
+ }>;
11
+ export type GuidanceEvent = "SessionStart" | "SubagentStart" | "UserPromptSubmit";
12
+ export declare const GUIDANCE_UNAVAILABLE = "Balladeer could not verify the current workspace guidance and capture mode. Continue the user's authorized work, but do not make unsolicited capture offers or file inferred promises. Do not reuse earlier Quiet/Thorough permission or cached instructions as current. An explicit request to record still requires current Balladeer tool checks and named-human agreement to meaning; never invent approval. Retry current guidance at the next hook boundary.";
13
+ export declare function parseGuidance(value: unknown, agent: StoredAgent): GuidanceDocument;
14
+ export declare function guidanceEndpoint(agent: StoredAgent): URL;
15
+ export declare function guidanceScopeKey(agent: StoredAgent): string;
16
+ export type GuidanceLoad = Readonly<{
17
+ status: "fresh" | "revalidated" | "unavailable";
18
+ document?: GuidanceDocument;
19
+ scopeKey: string;
20
+ }>;
21
+ /** Never returns stale instructions: even a cache hit requires a fresh scoped server response. */
22
+ export declare function loadGuidance(input: {
23
+ agent: StoredAgent;
24
+ environment: NodeJS.ProcessEnv;
25
+ fetchImpl?: typeof fetch;
26
+ timeoutMs?: number;
27
+ }): Promise<GuidanceLoad>;
28
+ /** Writes context first, then records emission metadata; this never claims model receipt. */
29
+ export declare function recordGuidanceContext(input: {
30
+ environment: NodeJS.ProcessEnv;
31
+ scopeKey: string;
32
+ hook: "codex" | "claude";
33
+ event: GuidanceEvent;
34
+ sessionId?: string;
35
+ agentId?: string;
36
+ runtimeGeneration?: string;
37
+ load: GuidanceLoad;
38
+ now?: number;
39
+ }, write: () => void): boolean;