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.
@@ -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
- /** What a person has to do once in Codex, which no command can do for them. */
57
- export declare const CODEX_HOOK_APPROVAL = "Codex: type /hooks in a Codex session and trust Balladeer's three hooks. Codex skips a hook until you have trusted it, so until then Codex has Balladeer's tools and none of its session-start guidance or updates.";
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[];
@@ -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 (CLAUDE_EVENTS.includes(event) || !Array.isArray(rows))
353
+ if (!Array.isArray(rows))
216
354
  continue;
217
- const kept = rows.filter((row) => !row?.hooks?.some((h) => h.statusMessage === USER_SCOPE_OWNER));
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
- /** What a person has to do once in Codex, which no command can do for them. */
239
- export const CODEX_HOOK_APPROVAL = "Codex: type /hooks in a Codex session and trust Balladeer's three hooks. Codex skips a hook until you have trusted it, so until then Codex has Balladeer's tools and none of its session-start guidance or updates.";
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.16";
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.16`, `installed:1.0.16`, `failed:exit-1`, `unstartable:launcher`. */
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.16";
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.16";
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.16`, `installed:1.0.16`, `failed:exit-1`, `unstartable:launcher`. */
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";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "balladeer",
3
- "version": "1.0.16",
3
+ "version": "1.0.17",
4
4
  "description": "Set up Balladeer from your terminal, or from a coding agent's.",
5
5
  "license": "Apache-2.0",
6
6
  "private": false,