balladeer 1.0.16 → 1.0.17
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/dist/cli.d.ts +1 -1
- package/dist/cli.js +19 -6
- package/dist/commands/guidance.d.ts +2 -1
- package/dist/commands/guidance.js +55 -0
- package/dist/commands/judge.d.ts +168 -9
- package/dist/commands/judge.js +594 -51
- package/dist/commands/map.d.ts +46 -2
- package/dist/commands/map.js +242 -5
- package/dist/commands/mcp.js +2 -2
- package/dist/guidance-hook.mjs +278 -94
- package/dist/hook-trust.d.ts +41 -9
- package/dist/hook-trust.js +98 -16
- package/dist/judge-brief.d.ts +15 -3
- package/dist/judge-brief.js +15 -2
- package/dist/judge-hook.d.ts +188 -14
- package/dist/judge-hook.js +919 -61
- package/dist/judge-said.d.ts +52 -0
- package/dist/judge-said.js +181 -0
- package/dist/promise-meaning.d.ts +8 -0
- package/dist/promise-meaning.js +9 -1
- package/dist/relay.d.ts +71 -0
- package/dist/relay.js +193 -0
- package/dist/remove-earlier.js +4 -2
- package/dist/risk/index.d.ts +1 -1
- package/dist/risk/index.js +1 -1
- package/dist/risk/score.d.ts +10 -0
- package/dist/risk/score.js +11 -2
- package/dist/scratch-worktree.d.ts +5 -1
- package/dist/scratch-worktree.js +9 -2
- package/dist/user-scope.d.ts +74 -3
- package/dist/user-scope.js +270 -9
- package/dist/wire.d.ts +3 -3
- package/dist/wire.js +2 -2
- package/package.json +1 -1
package/dist/user-scope.d.ts
CHANGED
|
@@ -15,6 +15,59 @@
|
|
|
15
15
|
* shape, never by trust in the name.
|
|
16
16
|
*/
|
|
17
17
|
export declare const USER_SCOPE_OWNER = "Balladeer current guidance (user scope, loader 1)";
|
|
18
|
+
/**
|
|
19
|
+
* The push check at user scope. Robert, 30 September 2026: setup turns it on
|
|
20
|
+
* for everyone, where it writes Balladeer's other hooks, report-only unless a
|
|
21
|
+
* repository says block, so it runs in every connected repository and every
|
|
22
|
+
* worktree with no step per repository. Two entries on matcher `Bash`, the
|
|
23
|
+
* same pair `balladeer judge --install-hook` writes into a checkout: after the
|
|
24
|
+
* command, where report mode judges, and before it, where block mode does.
|
|
25
|
+
*
|
|
26
|
+
* Its own mark, apart from the guidance hooks' and from the project entry's
|
|
27
|
+
* `Balladeer judge`, so each is recognised, replaced and removed only by
|
|
28
|
+
* itself. The relay note and the push check both sit under PostToolUse.
|
|
29
|
+
*/
|
|
30
|
+
export declare const USER_SCOPE_PUSH_CHECK_OWNER = "Balladeer push check (user scope)";
|
|
31
|
+
/**
|
|
32
|
+
* The push check runs on every shell command an agent runs, twice, so it is
|
|
33
|
+
* written only where the command starts in milliseconds: the installed command
|
|
34
|
+
* by path, or this checkout's own entry file. The `npx` form costs npm's own
|
|
35
|
+
* start and a registry lookup each time, which would slow every command the
|
|
36
|
+
* agent runs, so a laptop on it gets no push check until the command installs.
|
|
37
|
+
*/
|
|
38
|
+
export declare function pushCheckInstallable(published: boolean, installed?: string): boolean;
|
|
39
|
+
/** Where the push check stands on this laptop after setup wrote the user scope. */
|
|
40
|
+
export type PushCheckState = "on" | "off" | "unavailable";
|
|
41
|
+
export declare function pushCheckState(environment: NodeJS.ProcessEnv, published: boolean, installed?: string): PushCheckState;
|
|
42
|
+
/** Setup's one line about the push check. */
|
|
43
|
+
export declare function pushCheckSetupLine(state: PushCheckState): string;
|
|
44
|
+
/**
|
|
45
|
+
* What the push check's entry asks grep before Node starts: whether the
|
|
46
|
+
* payload's `"command"`, read as JSON (a string, or a list of words as Codex
|
|
47
|
+
* can send it), so much as mentions `push`, `create` or `merge`. Every line
|
|
48
|
+
* the judge reads as a push (`git push`, `gh pr create`, `gh pr merge`) spells
|
|
49
|
+
* one of them out, so none is dropped, and a folder or an output that says
|
|
50
|
+
* "push" wakes nothing. A word split by quotes (`pu''sh`) is the one spelling
|
|
51
|
+
* the judge reads as a push and this does not.
|
|
52
|
+
*/
|
|
53
|
+
export declare const PUSH_CHECK_FILTER = "\"command\" *: *(\\[( *\"([^\"\\\\]|\\\\.)*\" *,)* *)?\"([^\"\\\\]|\\\\.)*(push|create|merge)";
|
|
54
|
+
/**
|
|
55
|
+
* The push check's entry as the host runs it. It runs on every shell command
|
|
56
|
+
* an agent runs, before and after, so a POSIX shell looks first and starts
|
|
57
|
+
* Node only for a command that could push: a few milliseconds on anything
|
|
58
|
+
* else instead of Node's start (measured 30 September 2026: 4 to 6 ms against
|
|
59
|
+
* 45 to 90). Both hosts run a hook's command through a shell (Codex checked
|
|
60
|
+
* natively the same day). Only grep's plain "no match" ends it there: a grep
|
|
61
|
+
* that cannot run lets the command through, so a laptop without one pays
|
|
62
|
+
* Node's start and loses nothing. `awaiting` is a file that exists while
|
|
63
|
+
* Codex has yet to run the entry; every command goes through while it does,
|
|
64
|
+
* so that first run is noted without waiting for a push. Windows gets the
|
|
65
|
+
* plain command: there is no /bin/sh to count on.
|
|
66
|
+
*/
|
|
67
|
+
export declare function pushCheckCommand(host: UserScopeHost, published: boolean, installed?: string, options?: Readonly<{
|
|
68
|
+
awaiting?: string;
|
|
69
|
+
platform?: NodeJS.Platform;
|
|
70
|
+
}>): string;
|
|
18
71
|
/** Every tool of the server named `balladeer`, in Claude Code's permission rule form. */
|
|
19
72
|
export declare const USER_SCOPE_ALLOW_RULE = "mcp__balladeer__*";
|
|
20
73
|
export type UserScopeHost = "claude" | "codex";
|
|
@@ -52,12 +105,28 @@ export declare function isOurUserEntry(entry: unknown): boolean;
|
|
|
52
105
|
*/
|
|
53
106
|
export declare function isInstalledCommandPath(command: string): boolean;
|
|
54
107
|
/** `~/.claude/settings.json` carries user-scope hooks. */
|
|
55
|
-
export declare function mergeClaudeUserHooks(home: string, published: boolean, installed?: string): UserScopeWrite;
|
|
56
|
-
/**
|
|
57
|
-
|
|
108
|
+
export declare function mergeClaudeUserHooks(home: string, published: boolean, installed?: string, platform?: NodeJS.Platform): UserScopeWrite;
|
|
109
|
+
/**
|
|
110
|
+
* What a person has to do once in Codex, which no command can do for them,
|
|
111
|
+
* counting the hooks this run gave Codex: the guidance hooks, and the push
|
|
112
|
+
* check's two where it was written.
|
|
113
|
+
*/
|
|
114
|
+
export declare function codexHookApproval(pushCheck: boolean): string;
|
|
58
115
|
/** `~/.codex/config.toml` carries both the server and the hooks for Codex, in
|
|
59
116
|
* one fenced block this command owns end to end. */
|
|
60
117
|
export declare function mergeCodexUserConfig(home: string, published: boolean, installed?: string): UserScopeWrite;
|
|
118
|
+
/**
|
|
119
|
+
* The push check's two entries in `~/.codex/config.toml`, in a fenced block of
|
|
120
|
+
* their own after Balladeer's other one, so the guidance block and this one are
|
|
121
|
+
* each written, replaced and removed only by themselves, and Codex's trust in
|
|
122
|
+
* the one says nothing about the other. TOML lets a later block add to
|
|
123
|
+
* `[[hooks.PostToolUse]]`, and Codex 0.144.5 reads the file that results
|
|
124
|
+
* (checked 30 September 2026 with `codex mcp list` on a file of this shape).
|
|
125
|
+
*/
|
|
126
|
+
export declare function mergeCodexPushCheck(home: string, published: boolean, installed?: string, options?: Readonly<{
|
|
127
|
+
awaiting?: string;
|
|
128
|
+
platform?: NodeJS.Platform;
|
|
129
|
+
}>): UserScopeWrite;
|
|
61
130
|
/** A copy of this command running out of a source checkout registers the
|
|
62
131
|
* checkout; any installed or npx copy registers the published package. */
|
|
63
132
|
export declare function runningFromCheckout(entry?: string): boolean;
|
|
@@ -72,4 +141,6 @@ export declare function installUserScope(options: {
|
|
|
72
141
|
home?: string;
|
|
73
142
|
/** The durable command's absolute path, when one is installed. */
|
|
74
143
|
installed?: string;
|
|
144
|
+
/** The clock the Codex trust marks are stamped with. */
|
|
145
|
+
now?: () => number;
|
|
75
146
|
}): UserScopeWrite[];
|
package/dist/user-scope.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { noteHooksWritten } from "./hook-trust.js";
|
|
1
|
+
import { countWord, noteHooksRemoved, noteHooksWritten, pushCheckAwaitingFile, } from "./hook-trust.js";
|
|
2
2
|
import { existsSync, lstatSync, mkdirSync, readFileSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
5
5
|
import { parse } from "@iarna/toml";
|
|
6
|
+
import { JUDGE_HOOK_EVENTS, JUDGE_HOOK_TIMEOUT_SECONDS, pushCheckOff } from "./judge-hook.js";
|
|
6
7
|
import { HOOK_SPECIFIER, checkoutEntryPath } from "./release.js";
|
|
7
8
|
import { writeJsonAtomically } from "./mcp-config.js";
|
|
8
9
|
/**
|
|
@@ -22,7 +23,99 @@ import { writeJsonAtomically } from "./mcp-config.js";
|
|
|
22
23
|
* shape, never by trust in the name.
|
|
23
24
|
*/
|
|
24
25
|
export const USER_SCOPE_OWNER = "Balladeer current guidance (user scope, loader 1)";
|
|
25
|
-
const CLAUDE_EVENTS = ["SessionStart", "UserPromptSubmit", "SubagentStart"];
|
|
26
|
+
const CLAUDE_EVENTS = ["SessionStart", "UserPromptSubmit", "SubagentStart", "PostToolUse"];
|
|
27
|
+
/**
|
|
28
|
+
* The relay note runs after Balladeer's own tools only, never after anyone
|
|
29
|
+
* else's: a regex on the tool name, which both hosts match against
|
|
30
|
+
* `mcp__<server>__<tool>`. Robert, 30 September 2026: installed on both hosts
|
|
31
|
+
* (plans/promise-relations/plan.md, step 1).
|
|
32
|
+
*/
|
|
33
|
+
const RELAY_MATCHER = "mcp__balladeer.*";
|
|
34
|
+
const matcherFor = (event) => event === "PostToolUse" ? { matcher: RELAY_MATCHER } : {};
|
|
35
|
+
/**
|
|
36
|
+
* The push check at user scope. Robert, 30 September 2026: setup turns it on
|
|
37
|
+
* for everyone, where it writes Balladeer's other hooks, report-only unless a
|
|
38
|
+
* repository says block, so it runs in every connected repository and every
|
|
39
|
+
* worktree with no step per repository. Two entries on matcher `Bash`, the
|
|
40
|
+
* same pair `balladeer judge --install-hook` writes into a checkout: after the
|
|
41
|
+
* command, where report mode judges, and before it, where block mode does.
|
|
42
|
+
*
|
|
43
|
+
* Its own mark, apart from the guidance hooks' and from the project entry's
|
|
44
|
+
* `Balladeer judge`, so each is recognised, replaced and removed only by
|
|
45
|
+
* itself. The relay note and the push check both sit under PostToolUse.
|
|
46
|
+
*/
|
|
47
|
+
export const USER_SCOPE_PUSH_CHECK_OWNER = "Balladeer push check (user scope)";
|
|
48
|
+
const PUSH_CHECK_MATCHER = "Bash";
|
|
49
|
+
/**
|
|
50
|
+
* The push check runs on every shell command an agent runs, twice, so it is
|
|
51
|
+
* written only where the command starts in milliseconds: the installed command
|
|
52
|
+
* by path, or this checkout's own entry file. The `npx` form costs npm's own
|
|
53
|
+
* start and a registry lookup each time, which would slow every command the
|
|
54
|
+
* agent runs, so a laptop on it gets no push check until the command installs.
|
|
55
|
+
*/
|
|
56
|
+
export function pushCheckInstallable(published, installed) {
|
|
57
|
+
return installed !== undefined || !published;
|
|
58
|
+
}
|
|
59
|
+
export function pushCheckState(environment, published, installed) {
|
|
60
|
+
if (!pushCheckInstallable(published, installed))
|
|
61
|
+
return "unavailable";
|
|
62
|
+
return pushCheckOff(environment) ? "off" : "on";
|
|
63
|
+
}
|
|
64
|
+
/** Setup's one line about the push check. */
|
|
65
|
+
export function pushCheckSetupLine(state) {
|
|
66
|
+
if (state === "off")
|
|
67
|
+
return "The push check is off on this laptop; `balladeer judge --push-check on` turns it back on.";
|
|
68
|
+
if (state === "unavailable")
|
|
69
|
+
return "The push check is not on: it needs the installed balladeer command, which is not installed here, and without it every shell command your agent runs would wait for npm. Run setup again once the command installs.";
|
|
70
|
+
return 'The push check is on for every connected repository: after your agent pushes, it reports any promise the push may have broken and never holds the push (a promise needs a behavior map, which `balladeer map --all` builds). Turn it off on this laptop with `balladeer judge --push-check off`; a repository can have it hold a breaking push instead with "mode": "block" in .continuity/judge.json.';
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* What the push check's entry asks grep before Node starts: whether the
|
|
74
|
+
* payload's `"command"`, read as JSON (a string, or a list of words as Codex
|
|
75
|
+
* can send it), so much as mentions `push`, `create` or `merge`. Every line
|
|
76
|
+
* the judge reads as a push (`git push`, `gh pr create`, `gh pr merge`) spells
|
|
77
|
+
* one of them out, so none is dropped, and a folder or an output that says
|
|
78
|
+
* "push" wakes nothing. A word split by quotes (`pu''sh`) is the one spelling
|
|
79
|
+
* the judge reads as a push and this does not.
|
|
80
|
+
*/
|
|
81
|
+
export const PUSH_CHECK_FILTER = '"command" *: *(\\[( *"([^"\\\\]|\\\\.)*" *,)* *)?"([^"\\\\]|\\\\.)*(push|create|merge)';
|
|
82
|
+
/** A word as a POSIX shell reads it back unchanged: bare when it is plain. */
|
|
83
|
+
function shellWord(word) {
|
|
84
|
+
return /^[\w@%+=:,./-]+$/.test(word) ? word : `'${word.replaceAll("'", "'\\''")}'`;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The push check's entry as the host runs it. It runs on every shell command
|
|
88
|
+
* an agent runs, before and after, so a POSIX shell looks first and starts
|
|
89
|
+
* Node only for a command that could push: a few milliseconds on anything
|
|
90
|
+
* else instead of Node's start (measured 30 September 2026: 4 to 6 ms against
|
|
91
|
+
* 45 to 90). Both hosts run a hook's command through a shell (Codex checked
|
|
92
|
+
* natively the same day). Only grep's plain "no match" ends it there: a grep
|
|
93
|
+
* that cannot run lets the command through, so a laptop without one pays
|
|
94
|
+
* Node's start and loses nothing. `awaiting` is a file that exists while
|
|
95
|
+
* Codex has yet to run the entry; every command goes through while it does,
|
|
96
|
+
* so that first run is noted without waiting for a push. Windows gets the
|
|
97
|
+
* plain command: there is no /bin/sh to count on.
|
|
98
|
+
*/
|
|
99
|
+
export function pushCheckCommand(host, published, installed, options = {}) {
|
|
100
|
+
const { command, args } = userScopeCommand(published, installed);
|
|
101
|
+
const run = [command, ...args, "judge", "--hook", host, "--user-scope"];
|
|
102
|
+
if ((options.platform ?? process.platform) === "win32")
|
|
103
|
+
return run.join(" ");
|
|
104
|
+
const awaiting = options.awaiting === undefined ? [] : [options.awaiting];
|
|
105
|
+
const script = [
|
|
106
|
+
'i=$(cat); printf %s "$i" | grep -qE "$1" || [ $? -ne 1 ] || ',
|
|
107
|
+
awaiting.length > 0 ? '[ -e "$2" ] || exit 0; shift 2; ' : "exit 0; shift; ",
|
|
108
|
+
'printf %s "$i" | exec "$@"',
|
|
109
|
+
].join("");
|
|
110
|
+
return ["/bin/sh", "-c", script, "balladeer-push-check", PUSH_CHECK_FILTER, ...awaiting, ...run]
|
|
111
|
+
.map(shellWord)
|
|
112
|
+
.join(" ");
|
|
113
|
+
}
|
|
114
|
+
const ownedBy = (owner) => (row) => {
|
|
115
|
+
const handlers = row?.hooks;
|
|
116
|
+
return (Array.isArray(handlers) &&
|
|
117
|
+
handlers.some((handler) => handler?.statusMessage === owner));
|
|
118
|
+
};
|
|
26
119
|
/** Every tool of the server named `balladeer`, in Claude Code's permission rule form. */
|
|
27
120
|
export const USER_SCOPE_ALLOW_RULE = "mcp__balladeer__*";
|
|
28
121
|
/**
|
|
@@ -146,7 +239,7 @@ export function isInstalledCommandPath(command) {
|
|
|
146
239
|
existsSync(join(dirname(bin), "lib", "node_modules", "balladeer", "package.json")));
|
|
147
240
|
}
|
|
148
241
|
/** `~/.claude/settings.json` carries user-scope hooks. */
|
|
149
|
-
export function mergeClaudeUserHooks(home, published, installed) {
|
|
242
|
+
export function mergeClaudeUserHooks(home, published, installed, platform = process.platform) {
|
|
150
243
|
const path = join(home, ".claude", "settings.json");
|
|
151
244
|
const host = "claude";
|
|
152
245
|
if (!homeExists(home))
|
|
@@ -177,6 +270,7 @@ export function mergeClaudeUserHooks(home, published, installed) {
|
|
|
177
270
|
return { host, path, status: "refused", reason: `hooks.${event} is not a list` };
|
|
178
271
|
const own = rows.filter((row) => row?.hooks?.some((h) => h.statusMessage === USER_SCOPE_OWNER));
|
|
179
272
|
const expected = {
|
|
273
|
+
...matcherFor(event),
|
|
180
274
|
hooks: [
|
|
181
275
|
{
|
|
182
276
|
type: "command",
|
|
@@ -195,6 +289,44 @@ export function mergeClaudeUserHooks(home, published, installed) {
|
|
|
195
289
|
hooks[event] = [...rows.filter((row) => !own.includes(row)), expected];
|
|
196
290
|
changed = true;
|
|
197
291
|
}
|
|
292
|
+
// The push check, beside whatever else is under the same event, the relay
|
|
293
|
+
// note included: only rows with its own mark are its to replace or remove.
|
|
294
|
+
const pushCheck = pushCheckInstallable(published, installed);
|
|
295
|
+
const judgeLine = pushCheckCommand(host, published, installed, { platform });
|
|
296
|
+
for (const event of JUDGE_HOOK_EVENTS) {
|
|
297
|
+
const rows = hooks[event] === undefined ? [] : hooks[event];
|
|
298
|
+
if (!Array.isArray(rows))
|
|
299
|
+
return { host, path, status: "refused", reason: `hooks.${event} is not a list` };
|
|
300
|
+
const own = rows.filter(ownedBy(USER_SCOPE_PUSH_CHECK_OWNER));
|
|
301
|
+
if (own.length > 1)
|
|
302
|
+
return { host, path, status: "refused", reason: `two Balladeer push checks under ${event}` };
|
|
303
|
+
const others = rows.filter((row) => !own.includes(row));
|
|
304
|
+
if (!pushCheck) {
|
|
305
|
+
if (own.length === 0)
|
|
306
|
+
continue;
|
|
307
|
+
if (others.length === 0)
|
|
308
|
+
delete hooks[event];
|
|
309
|
+
else
|
|
310
|
+
hooks[event] = others;
|
|
311
|
+
changed = true;
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
const expected = {
|
|
315
|
+
matcher: PUSH_CHECK_MATCHER,
|
|
316
|
+
hooks: [
|
|
317
|
+
{
|
|
318
|
+
type: "command",
|
|
319
|
+
command: judgeLine,
|
|
320
|
+
timeout: JUDGE_HOOK_TIMEOUT_SECONDS,
|
|
321
|
+
statusMessage: USER_SCOPE_PUSH_CHECK_OWNER,
|
|
322
|
+
},
|
|
323
|
+
],
|
|
324
|
+
};
|
|
325
|
+
if (own.length === 1 && JSON.stringify(own[0]) === JSON.stringify(expected))
|
|
326
|
+
continue;
|
|
327
|
+
hooks[event] = [...others, expected];
|
|
328
|
+
changed = true;
|
|
329
|
+
}
|
|
198
330
|
// Claude Code asks the person before every call to a server's tools unless
|
|
199
331
|
// a rule allows them. Without one, each read the guidance asks for costs a
|
|
200
332
|
// click, and a session that says no once hears no offer at all (measured
|
|
@@ -211,10 +343,16 @@ export function mergeClaudeUserHooks(home, published, installed) {
|
|
|
211
343
|
// An event this release no longer hooks: its own entry there is deprecated
|
|
212
344
|
// and comes out. Robert, 29 September 2026: a hook that should no longer
|
|
213
345
|
// exist is removed, a new one is added, one that still belongs persists.
|
|
346
|
+
// Each set of hooks answers for its own events only: the push check's rows
|
|
347
|
+
// under PreToolUse are not the guidance hooks' to take out.
|
|
348
|
+
const sets = [
|
|
349
|
+
[USER_SCOPE_OWNER, CLAUDE_EVENTS],
|
|
350
|
+
[USER_SCOPE_PUSH_CHECK_OWNER, JUDGE_HOOK_EVENTS],
|
|
351
|
+
];
|
|
214
352
|
for (const [event, rows] of Object.entries(hooks)) {
|
|
215
|
-
if (
|
|
353
|
+
if (!Array.isArray(rows))
|
|
216
354
|
continue;
|
|
217
|
-
const kept = rows.filter((row) => !
|
|
355
|
+
const kept = rows.filter((row) => !sets.some(([owner, events]) => !events.includes(event) && ownedBy(owner)(row)));
|
|
218
356
|
if (kept.length === rows.length)
|
|
219
357
|
continue;
|
|
220
358
|
if (kept.length === 0)
|
|
@@ -235,8 +373,19 @@ export function mergeClaudeUserHooks(home, published, installed) {
|
|
|
235
373
|
writeJsonAtomically(path, JSON.stringify(root, null, 2) + "\n");
|
|
236
374
|
return { host, path, status: "written" };
|
|
237
375
|
}
|
|
238
|
-
/**
|
|
239
|
-
|
|
376
|
+
/**
|
|
377
|
+
* What a person has to do once in Codex, which no command can do for them,
|
|
378
|
+
* counting the hooks this run gave Codex: the guidance hooks, and the push
|
|
379
|
+
* check's two where it was written.
|
|
380
|
+
*/
|
|
381
|
+
export function codexHookApproval(pushCheck) {
|
|
382
|
+
const count = CLAUDE_EVENTS.length + (pushCheck ? JUDGE_HOOK_EVENTS.length : 0);
|
|
383
|
+
return (`Codex: type /hooks in a Codex session and trust Balladeer's ${countWord(count)} hooks. ` +
|
|
384
|
+
"Codex skips a hook until you have trusted it, so until then Codex has Balladeer's tools and none of its session-start guidance, updates, " +
|
|
385
|
+
(pushCheck
|
|
386
|
+
? "the note that reminds your agent to tell you what Balladeer did, or its check after a push."
|
|
387
|
+
: "or the note that reminds your agent to tell you what Balladeer did."));
|
|
388
|
+
}
|
|
240
389
|
const CODEX_START = "# balladeer:user:start";
|
|
241
390
|
const CODEX_END = "# balladeer:user:end";
|
|
242
391
|
/** `~/.codex/config.toml` carries both the server and the hooks for Codex, in
|
|
@@ -264,6 +413,7 @@ export function mergeCodexUserConfig(home, published, installed) {
|
|
|
264
413
|
`args = ${JSON.stringify([...args, "mcp"])}`,
|
|
265
414
|
...CLAUDE_EVENTS.flatMap((event) => [
|
|
266
415
|
`[[hooks.${event}]]`,
|
|
416
|
+
...(event === "PostToolUse" ? [`matcher = ${JSON.stringify(RELAY_MATCHER)}`] : []),
|
|
267
417
|
`[[hooks.${event}.hooks]]`,
|
|
268
418
|
'type = "command"',
|
|
269
419
|
`command = ${JSON.stringify(`${line} --event ${event}`)}`,
|
|
@@ -301,6 +451,87 @@ export function mergeCodexUserConfig(home, published, installed) {
|
|
|
301
451
|
writeJsonAtomically(path, after);
|
|
302
452
|
return { host, path, status: "written" };
|
|
303
453
|
}
|
|
454
|
+
const PUSH_CHECK_START = "# balladeer:push-check:start";
|
|
455
|
+
const PUSH_CHECK_END = "# balladeer:push-check:end";
|
|
456
|
+
/**
|
|
457
|
+
* The push check's two entries in `~/.codex/config.toml`, in a fenced block of
|
|
458
|
+
* their own after Balladeer's other one, so the guidance block and this one are
|
|
459
|
+
* each written, replaced and removed only by themselves, and Codex's trust in
|
|
460
|
+
* the one says nothing about the other. TOML lets a later block add to
|
|
461
|
+
* `[[hooks.PostToolUse]]`, and Codex 0.144.5 reads the file that results
|
|
462
|
+
* (checked 30 September 2026 with `codex mcp list` on a file of this shape).
|
|
463
|
+
*/
|
|
464
|
+
export function mergeCodexPushCheck(home, published, installed, options = {}) {
|
|
465
|
+
const path = join(home, ".codex", "config.toml");
|
|
466
|
+
const host = "codex";
|
|
467
|
+
if (!ordinaryFile(path))
|
|
468
|
+
return { host, path, status: "refused", reason: "not an ordinary file" };
|
|
469
|
+
let previous = "";
|
|
470
|
+
try {
|
|
471
|
+
if (existsSync(path))
|
|
472
|
+
previous = readFileSync(path, "utf8");
|
|
473
|
+
parse(previous);
|
|
474
|
+
}
|
|
475
|
+
catch {
|
|
476
|
+
return { host, path, status: "refused", reason: "could not read valid TOML" };
|
|
477
|
+
}
|
|
478
|
+
const wanted = pushCheckInstallable(published, installed);
|
|
479
|
+
const line = pushCheckCommand(host, published, installed, options);
|
|
480
|
+
const block = [
|
|
481
|
+
PUSH_CHECK_START,
|
|
482
|
+
...JUDGE_HOOK_EVENTS.flatMap((event) => [
|
|
483
|
+
`[[hooks.${event}]]`,
|
|
484
|
+
`matcher = ${JSON.stringify(PUSH_CHECK_MATCHER)}`,
|
|
485
|
+
`[[hooks.${event}.hooks]]`,
|
|
486
|
+
'type = "command"',
|
|
487
|
+
`command = ${JSON.stringify(line)}`,
|
|
488
|
+
`timeout = ${JUDGE_HOOK_TIMEOUT_SECONDS}`,
|
|
489
|
+
`statusMessage = ${JSON.stringify(USER_SCOPE_PUSH_CHECK_OWNER)}`,
|
|
490
|
+
]),
|
|
491
|
+
PUSH_CHECK_END,
|
|
492
|
+
].join("\n");
|
|
493
|
+
const starts = [...previous.matchAll(/^# balladeer:push-check:start\r?$/gm)];
|
|
494
|
+
const ends = [...previous.matchAll(/^# balladeer:push-check:end\r?$/gm)];
|
|
495
|
+
if (starts.length !== ends.length || starts.length > 1)
|
|
496
|
+
return { host, path, status: "refused", reason: "the Balladeer push check block is malformed" };
|
|
497
|
+
let after;
|
|
498
|
+
if (starts[0] && ends[0]) {
|
|
499
|
+
if (starts[0].index >= ends[0].index)
|
|
500
|
+
return {
|
|
501
|
+
host,
|
|
502
|
+
path,
|
|
503
|
+
status: "refused",
|
|
504
|
+
reason: "the Balladeer push check block is malformed",
|
|
505
|
+
};
|
|
506
|
+
const from = starts[0].index;
|
|
507
|
+
const to = ends[0].index + PUSH_CHECK_END.length;
|
|
508
|
+
if (wanted && previous.slice(from, to) === block)
|
|
509
|
+
return { host, path, status: "current" };
|
|
510
|
+
after = wanted
|
|
511
|
+
? previous.slice(0, from) + block + previous.slice(to)
|
|
512
|
+
: previous.slice(0, from) + previous.slice(to + (previous[to] === "\n" ? 1 : 0));
|
|
513
|
+
}
|
|
514
|
+
else {
|
|
515
|
+
if (!wanted)
|
|
516
|
+
return { host, path, status: "current" };
|
|
517
|
+
after = previous + (previous.endsWith("\n") || previous === "" ? "" : "\n") + block + "\n";
|
|
518
|
+
}
|
|
519
|
+
try {
|
|
520
|
+
parse(after);
|
|
521
|
+
}
|
|
522
|
+
catch {
|
|
523
|
+
// A `hooks` table the person wrote in a form an added entry cannot join.
|
|
524
|
+
return {
|
|
525
|
+
host,
|
|
526
|
+
path,
|
|
527
|
+
status: "refused",
|
|
528
|
+
reason: "the push check's entries would not join the hooks already in the file",
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
532
|
+
writeJsonAtomically(path, after);
|
|
533
|
+
return { host, path, status: "written" };
|
|
534
|
+
}
|
|
304
535
|
/** A copy of this command running out of a source checkout registers the
|
|
305
536
|
* checkout; any installed or npx copy registers the published package. */
|
|
306
537
|
export function runningFromCheckout(entry = process.argv[1] ?? "") {
|
|
@@ -316,17 +547,47 @@ export function userHome(environment = process.env) {
|
|
|
316
547
|
/** Register both hosts. Codex is skipped, not refused, on a laptop without it. */
|
|
317
548
|
export function installUserScope(options) {
|
|
318
549
|
const home = options.home ?? userHome(options.environment);
|
|
550
|
+
const now = options.now?.() ?? Date.now();
|
|
319
551
|
const writes = [
|
|
320
552
|
mergeClaudeUserMcp(home, options.published, options.installed),
|
|
321
553
|
mergeClaudeUserHooks(home, options.published, options.installed),
|
|
322
554
|
];
|
|
323
555
|
if (existsSync(join(home, ".codex"))) {
|
|
324
556
|
const codex = mergeCodexUserConfig(home, options.published, options.installed);
|
|
325
|
-
writes.push(codex);
|
|
326
557
|
// Codex has to run these once before anybody assumes it does; the mark is
|
|
327
558
|
// what lets its own session say so after a silent background update.
|
|
328
559
|
if (codex.status !== "refused")
|
|
329
|
-
noteHooksWritten(options.environment, "codex", codex.status === "written"
|
|
560
|
+
noteHooksWritten(options.environment, "codex", codex.status === "written", now, {
|
|
561
|
+
set: "guidance",
|
|
562
|
+
hooks: CLAUDE_EVENTS.length,
|
|
563
|
+
});
|
|
564
|
+
// The push check after it, into the same file, with marks of its own: the
|
|
565
|
+
// guidance hooks already trusted keep running, and must not be taken as
|
|
566
|
+
// Codex having run the push check.
|
|
567
|
+
const pushCheck = codex.status === "refused"
|
|
568
|
+
? codex
|
|
569
|
+
: mergeCodexPushCheck(home, options.published, options.installed, {
|
|
570
|
+
awaiting: pushCheckAwaitingFile(options.environment, "codex"),
|
|
571
|
+
});
|
|
572
|
+
if (pushCheck.status !== "refused") {
|
|
573
|
+
if (pushCheckInstallable(options.published, options.installed))
|
|
574
|
+
noteHooksWritten(options.environment, "codex", pushCheck.status === "written", now, {
|
|
575
|
+
set: "push-check",
|
|
576
|
+
hooks: JUDGE_HOOK_EVENTS.length,
|
|
577
|
+
});
|
|
578
|
+
else
|
|
579
|
+
noteHooksRemoved(options.environment, "codex", "push-check");
|
|
580
|
+
}
|
|
581
|
+
// One line per file for the person: refused if either part was refused,
|
|
582
|
+
// written if either part changed it.
|
|
583
|
+
writes.push(pushCheck.status === "refused"
|
|
584
|
+
? {
|
|
585
|
+
...pushCheck,
|
|
586
|
+
...(codex.status === "refused" ? {} : { reason: `push check: ${pushCheck.reason}` }),
|
|
587
|
+
}
|
|
588
|
+
: codex.status === "written" || pushCheck.status === "written"
|
|
589
|
+
? { host: "codex", path: codex.path, status: "written" }
|
|
590
|
+
: codex);
|
|
330
591
|
}
|
|
331
592
|
return writes;
|
|
332
593
|
}
|
package/dist/wire.d.ts
CHANGED
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
* runtime dependency at all: Node 22 builtins and global fetch, nothing else.
|
|
6
6
|
* A contract test compares the scope list below against the server's.
|
|
7
7
|
*/
|
|
8
|
-
export declare const CLI_VERSION = "1.0.
|
|
8
|
+
export declare const CLI_VERSION = "1.0.17";
|
|
9
9
|
export declare const CLI_INVOCATION = "npx -y balladeer@latest";
|
|
10
10
|
export declare const CLIENT_HEADER = "x-balladeer-client";
|
|
11
|
-
/** What the last background update came to: `started:1.0.
|
|
11
|
+
/** What the last background update came to: `started:1.0.17`, `installed:1.0.17`, `failed:exit-1`, `unstartable:launcher`. */
|
|
12
12
|
export declare const UPDATE_STATE_HEADER = "x-balladeer-update-state";
|
|
13
13
|
/** Which host's hook made this guidance fetch: `claude` or `codex`. */
|
|
14
14
|
export declare const HOOK_HOST_HEADER = "x-balladeer-hook";
|
|
15
|
-
export declare const CLIENT_HEADER_VALUE = "balladeer/1.0.
|
|
15
|
+
export declare const CLIENT_HEADER_VALUE = "balladeer/1.0.17";
|
|
16
16
|
export declare const DEFAULT_CONTROL_PLANE = "https://envelopes.balladeer.ai";
|
|
17
17
|
export type DelegatedScope = "repository:enroll" | "agent:issue" | "ci:connect" | "workspace:invite" | "candidate:propose";
|
|
18
18
|
export declare const DELEGATED_SCOPES: readonly DelegatedScope[];
|
package/dist/wire.js
CHANGED
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
* runtime dependency at all: Node 22 builtins and global fetch, nothing else.
|
|
6
6
|
* A contract test compares the scope list below against the server's.
|
|
7
7
|
*/
|
|
8
|
-
export const CLI_VERSION = "1.0.
|
|
8
|
+
export const CLI_VERSION = "1.0.17";
|
|
9
9
|
export const CLI_INVOCATION = "npx -y balladeer@latest";
|
|
10
10
|
export const CLIENT_HEADER = "x-balladeer-client";
|
|
11
|
-
/** What the last background update came to: `started:1.0.
|
|
11
|
+
/** What the last background update came to: `started:1.0.17`, `installed:1.0.17`, `failed:exit-1`, `unstartable:launcher`. */
|
|
12
12
|
export const UPDATE_STATE_HEADER = "x-balladeer-update-state";
|
|
13
13
|
/** Which host's hook made this guidance fetch: `claude` or `codex`. */
|
|
14
14
|
export const HOOK_HOST_HEADER = "x-balladeer-hook";
|