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.
@@ -16,16 +16,48 @@ export type TrustingHost = "codex";
16
16
  /** Hosts that run a hook only after the person has approved it. */
17
17
  export declare const HOSTS_REQUIRING_TRUST: readonly TrustingHost[];
18
18
  /**
19
- * This command wrote or changed the host's hooks, or found them in place with
20
- * no record that it ever had: either way, from now the host has to have run
21
- * them once before anybody assumes it does.
19
+ * Hooks this command writes as one set, each set with its own marks. The
20
+ * guidance hooks and the push check are written separately, and a host keeps
21
+ * running an unchanged hook it already trusted, so one set running says
22
+ * nothing about the other: once the push check was added, the trusted
23
+ * guidance hooks would run at the next session start and settle a single mark
24
+ * while the new push check still waited for review.
22
25
  */
23
- export declare function noteHooksWritten(environment: NodeJS.ProcessEnv, host: TrustingHost, changed: boolean, now?: number): void;
24
- /** One of the host's hooks ran, which it does only once they are trusted. */
25
- export declare function noteHookFired(environment: NodeJS.ProcessEnv, host: TrustingHost, now?: number): void;
26
- /** True while the hooks last written for this host have never run in it. */
26
+ export type HookSet = "guidance" | "push-check";
27
+ /**
28
+ * The push check's entry starts Node only for a command that could push
29
+ * (`pushCheckCommand` in user-scope.ts), so the run that settles its mark
30
+ * would wait for the first push, and meanwhile the person would be told to
31
+ * trust hooks they already trusted. This file exists exactly while that set
32
+ * waits for its first run in the host, and the entry sends every command
33
+ * through to Node while it does.
34
+ */
35
+ export declare function pushCheckAwaitingFile(environment: NodeJS.ProcessEnv, host: TrustingHost): string;
36
+ /**
37
+ * This command wrote or changed one set of the host's hooks, or found it in
38
+ * place with no record that it ever had: either way, from now the host has to
39
+ * have run that set once before anybody assumes it does. `hooks` is how many
40
+ * hooks the set is, which the notice counts.
41
+ */
42
+ export declare function noteHooksWritten(environment: NodeJS.ProcessEnv, host: TrustingHost, changed: boolean, now?: number, options?: Readonly<{
43
+ set?: HookSet;
44
+ hooks?: number;
45
+ }>): void;
46
+ /** This command took one set of the host's hooks out: nothing of it is left to trust. */
47
+ export declare function noteHooksRemoved(environment: NodeJS.ProcessEnv, host: TrustingHost, set: HookSet): void;
48
+ /** One hook of that set ran, which it does only once it is trusted. */
49
+ export declare function noteHookFired(environment: NodeJS.ProcessEnv, host: TrustingHost, now?: number, set?: HookSet): void;
50
+ /** True while some set of the hooks last written for this host has never run in it. */
27
51
  export declare function hooksAwaitingTrust(environment: NodeJS.ProcessEnv, host: TrustingHost): boolean;
52
+ /** A small count the way a sentence says it. */
53
+ export declare function countWord(count: number): string;
54
+ /** How many of Balladeer's hooks this host was last given, over every set written. */
55
+ export declare function hooksWritten(environment: NodeJS.ProcessEnv, host: TrustingHost): number;
28
56
  /** The host a session belongs to, from the name it gave when it said hello. */
29
57
  export declare function trustingHost(clientName: unknown): TrustingHost | undefined;
30
- /** What the agent is asked to pass on, once, in the host where the approval happens. */
31
- export declare const HOOKS_AWAITING_TRUST_NOTICE = "Note from Balladeer on this laptop, to pass on to the person once: Balladeer's hooks here were added or changed and Codex has not run them. Codex runs a hook only after the person trusts it. Ask them to type /hooks in Codex and trust Balladeer's three hooks; until then Codex has Balladeer's tools without its session-start guidance or its updates.";
58
+ /**
59
+ * What the agent is asked to pass on, once, in the host where the approval
60
+ * happens. It counts the hooks this command last wrote there, so a laptop given
61
+ * the push check is told to trust six and one without it four.
62
+ */
63
+ export declare function hooksAwaitingTrustNotice(environment: NodeJS.ProcessEnv, host?: TrustingHost): string;
@@ -1,9 +1,14 @@
1
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { configHome } from "./store.js";
4
4
  /** Hosts that run a hook only after the person has approved it. */
5
5
  export const HOSTS_REQUIRING_TRUST = ["codex"];
6
+ const SETS = ["guidance", "push-check"];
7
+ /** How many hooks a set is, for a mark an earlier release wrote without saying. */
8
+ const DEFAULT_COUNT = { guidance: 4, "push-check": 2 };
6
9
  const FILE = "hook-trust.json";
10
+ /** The guidance set keeps the key earlier releases wrote, so their marks still count. */
11
+ const key = (host, set) => set === "guidance" ? host : `${host}:${set}`;
7
12
  function read(environment) {
8
13
  try {
9
14
  const parsed = JSON.parse(readFileSync(join(configHome(environment), FILE), "utf8"));
@@ -15,49 +20,126 @@ function read(environment) {
15
20
  return {};
16
21
  }
17
22
  }
23
+ /**
24
+ * The push check's entry starts Node only for a command that could push
25
+ * (`pushCheckCommand` in user-scope.ts), so the run that settles its mark
26
+ * would wait for the first push, and meanwhile the person would be told to
27
+ * trust hooks they already trusted. This file exists exactly while that set
28
+ * waits for its first run in the host, and the entry sends every command
29
+ * through to Node while it does.
30
+ */
31
+ export function pushCheckAwaitingFile(environment, host) {
32
+ return join(configHome(environment), `${host}-push-check-awaiting-first-run`);
33
+ }
34
+ function forget(path) {
35
+ try {
36
+ rmSync(path, { force: true });
37
+ }
38
+ catch {
39
+ /* Left behind, it costs Node's start on each command, never a session. */
40
+ }
41
+ }
18
42
  function write(environment, marks) {
19
43
  try {
20
44
  mkdirSync(configHome(environment), { recursive: true, mode: 0o700 });
21
45
  writeFileSync(join(configHome(environment), FILE), JSON.stringify(marks) + "\n", {
22
46
  mode: 0o600,
23
47
  });
48
+ for (const host of HOSTS_REQUIRING_TRUST) {
49
+ const file = pushCheckAwaitingFile(environment, host);
50
+ if (awaiting(marks[key(host, "push-check")]))
51
+ writeFileSync(file, "", { mode: 0o600 });
52
+ else
53
+ forget(file);
54
+ }
24
55
  }
25
56
  catch {
26
57
  /* A mark that cannot be written costs a notice, never a session. */
27
58
  }
28
59
  }
29
60
  /**
30
- * This command wrote or changed the host's hooks, or found them in place with
31
- * no record that it ever had: either way, from now the host has to have run
32
- * them once before anybody assumes it does.
61
+ * This command wrote or changed one set of the host's hooks, or found it in
62
+ * place with no record that it ever had: either way, from now the host has to
63
+ * have run that set once before anybody assumes it does. `hooks` is how many
64
+ * hooks the set is, which the notice counts.
33
65
  */
34
- export function noteHooksWritten(environment, host, changed, now = Date.now()) {
66
+ export function noteHooksWritten(environment, host, changed, now = Date.now(), options = {}) {
67
+ const set = options.set ?? "guidance";
68
+ const marks = read(environment);
69
+ const mark = marks[key(host, set)];
70
+ const counted = options.hooks === undefined || mark?.hooks === options.hooks;
71
+ if (!changed && mark?.writtenAt !== undefined && counted)
72
+ return;
73
+ write(environment, {
74
+ ...marks,
75
+ [key(host, set)]: {
76
+ ...mark,
77
+ ...(changed || mark?.writtenAt === undefined ? { writtenAt: now } : {}),
78
+ ...(options.hooks === undefined ? {} : { hooks: options.hooks }),
79
+ },
80
+ });
81
+ }
82
+ /** This command took one set of the host's hooks out: nothing of it is left to trust. */
83
+ export function noteHooksRemoved(environment, host, set) {
35
84
  const marks = read(environment);
36
- if (!changed && marks[host]?.writtenAt !== undefined)
85
+ if (marks[key(host, set)] === undefined)
37
86
  return;
38
- write(environment, { ...marks, [host]: { ...marks[host], writtenAt: now } });
87
+ const rest = { ...marks };
88
+ delete rest[key(host, set)];
89
+ write(environment, rest);
39
90
  }
40
- /** One of the host's hooks ran, which it does only once they are trusted. */
41
- export function noteHookFired(environment, host, now = Date.now()) {
91
+ /** One hook of that set ran, which it does only once it is trusted. */
92
+ export function noteHookFired(environment, host, now = Date.now(), set = "guidance") {
93
+ // Settled or not, a file left behind would send every command to Node for good.
94
+ if (set === "push-check")
95
+ forget(pushCheckAwaitingFile(environment, host));
42
96
  const marks = read(environment);
43
- const mark = marks[host];
97
+ const mark = marks[key(host, set)];
44
98
  // Nothing to settle, or already settled: no write on the session's path.
45
99
  if (mark?.firedAt !== undefined && mark.firedAt >= (mark.writtenAt ?? 0))
46
100
  return;
47
- write(environment, { ...marks, [host]: { ...mark, firedAt: now } });
101
+ write(environment, { ...marks, [key(host, set)]: { ...mark, firedAt: now } });
48
102
  }
49
- /** True while the hooks last written for this host have never run in it. */
50
- export function hooksAwaitingTrust(environment, host) {
51
- const mark = read(environment)[host];
103
+ function awaiting(mark) {
52
104
  if (mark?.writtenAt === undefined)
53
105
  return false;
54
106
  return mark.firedAt === undefined || mark.firedAt < mark.writtenAt;
55
107
  }
108
+ /** True while some set of the hooks last written for this host has never run in it. */
109
+ export function hooksAwaitingTrust(environment, host) {
110
+ const marks = read(environment);
111
+ return SETS.some((set) => awaiting(marks[key(host, set)]));
112
+ }
113
+ const WORDS = ["no", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine"];
114
+ /** A small count the way a sentence says it. */
115
+ export function countWord(count) {
116
+ return WORDS[count] ?? String(count);
117
+ }
118
+ /** How many of Balladeer's hooks this host was last given, over every set written. */
119
+ export function hooksWritten(environment, host) {
120
+ const marks = read(environment);
121
+ return SETS.reduce((total, set) => {
122
+ const mark = marks[key(host, set)];
123
+ return mark?.writtenAt === undefined ? total : total + (mark.hooks ?? DEFAULT_COUNT[set]);
124
+ }, 0);
125
+ }
56
126
  /** The host a session belongs to, from the name it gave when it said hello. */
57
127
  export function trustingHost(clientName) {
58
128
  return typeof clientName === "string" && clientName.toLowerCase().startsWith("codex")
59
129
  ? "codex"
60
130
  : undefined;
61
131
  }
62
- /** What the agent is asked to pass on, once, in the host where the approval happens. */
63
- export const HOOKS_AWAITING_TRUST_NOTICE = "Note from Balladeer on this laptop, to pass on to the person once: Balladeer's hooks here were added or changed and Codex has not run them. Codex runs a hook only after the person trusts it. Ask them to type /hooks in Codex and trust Balladeer's three hooks; until then Codex has Balladeer's tools without its session-start guidance or its updates.";
132
+ /**
133
+ * What the agent is asked to pass on, once, in the host where the approval
134
+ * happens. It counts the hooks this command last wrote there, so a laptop given
135
+ * the push check is told to trust six and one without it four.
136
+ */
137
+ export function hooksAwaitingTrustNotice(environment, host = "codex") {
138
+ const pushCheck = read(environment)[key(host, "push-check")]?.writtenAt !== undefined;
139
+ const count = hooksWritten(environment, host) || DEFAULT_COUNT.guidance;
140
+ return ("Note from Balladeer on this laptop, to pass on to the person once: Balladeer's hooks here were added or changed and Codex has not run them. Codex runs a hook only after the person trusts it. " +
141
+ `Ask them to type /hooks in Codex and trust Balladeer's ${countWord(count)} hooks; until then Codex has Balladeer's tools without its session-start guidance, its updates, ` +
142
+ (pushCheck
143
+ ? "the note that reminds you to tell them what Balladeer did, or its check after a push."
144
+ : "or the note that reminds you to tell them what Balladeer did."));
145
+ }
@@ -1,7 +1,7 @@
1
1
  import type { BehaviorMap } from "./behavior-map-schema.js";
2
2
  import type { PromiseDocument } from "./promise-meaning.js";
3
3
  /**
4
- * The fixed instructions a judge works from, `judge-brief/v1`.
4
+ * The fixed instructions a judge works from, `judge-brief/v3`.
5
5
  *
6
6
  * One promise, one change, one disposable checkout at the change's head. The
7
7
  * rules are the contract's: cases first, linked test first, broken only with a
@@ -14,8 +14,20 @@ import type { PromiseDocument } from "./promise-meaning.js";
14
14
  * The text is a constant so a verdict can name the exact brief it was given.
15
15
  * Changing a word here is a new revision.
16
16
  */
17
- export declare const JUDGE_BRIEF_REVISION = "judge-brief/v1";
18
- export declare const JUDGE_BRIEF = "You are judging one change to this repository against one promise about the software's behavior. A named person agreed the promise's meaning. Your working directory is a disposable copy of the repository checked out at the change's head commit; anything you write here is thrown away afterwards.\n\nDecide whether this change keeps the promise, breaks it, or whether you could not tell, and back what you say with evidence you produced yourself.\n\nRules:\n\n1. Read the promise's cases first. The passing cases (passing-1, passing-2, and so on, in the order listed) say what must keep happening. The failing cases (failing-1, and so on) say what must never happen. The non-goals say what the promise does not cover; a change there is not a break.\n2. If linked tests are listed, run them first, before anything else. If the runner already reports a linked test's outcome below, use it and rerun only when you need its output.\n3. Read the code, not descriptions of it. Commit messages, comments, documentation and pull request text say what someone intended, not what the code does.\n4. Report \"broken\" only with a command that fails (exits non-zero) with this change and passes (exits zero) without it, and only after you have run it both ways yourself. To run it without the change, check out the base commit in this copy with `git checkout --detach <base>`, run the command, then return with `git checkout --detach <head>`. If the command needs a test you wrote, put that test in a new file rather than editing an existing one, so it exists on both commits. The command must run from the repository root or from the directory you name as its cwd, and must not depend on anything outside this copy.\n5. Report \"kept\" only after you have exercised each passing case against the changed code, by running a test or a command that shows it, and list every case you exercised.\n6. Anything else is \"could_not_tell\". Say in notes what stopped you: a missing dependency, a service you could not reach, a case you could not exercise, time running out.\n7. Name the promise, never the person. Do not put anyone's name, email or account in your answer.\n8. Do not push, open or merge pull requests, publish anything, or change anything outside this copy. Do not read or print credentials.\n\nYour final message must be exactly one JSON object and nothing after it:\n\n{\"promiseId\": \"<the promise id below>\", \"verdict\": \"kept\" | \"broken\" | \"could_not_tell\", \"reproduction\": {\"command\": \"<shell command>\", \"cwd\": \".\"} or null, \"exercisedCases\": [\"passing-1\", ...], \"notes\": \"<at most a few sentences>\"}\n\nA reproduction is required for \"broken\" and optional otherwise.";
17
+ export declare const JUDGE_BRIEF_REVISION = "judge-brief/v3";
18
+ /**
19
+ * What `judge-brief/v3` added to v1, and all it added (Robert, 30 September
20
+ * 2026): a way forward when the tests need something the machine lacks.
21
+ *
22
+ * It comes last among the rules and only once the judge would otherwise
23
+ * answer could not tell, never before it has tried to prove or refute, so it
24
+ * cannot spend the time a proof needs. Measured against a same-day control of
25
+ * v1 on 30 September (docs/agentic-testing/evidence/20260930/setup-rule.md),
26
+ * it proved no fewer breaks. Could not tell stays could not tell; the only new
27
+ * thing in the answer is `nextStep`, the one step that would let the tests run.
28
+ */
29
+ export declare const JUDGE_BRIEF_SETUP_RULE = "9. Only when you would otherwise answer \"could_not_tell\" because the tests need something this machine does not have, such as a test database, a running service, a browser or an environment variable, and only after you have tried to show a break or a keep as the rules above say: look for how the repository documents running its tests, in its README, its agent instruction files (AGENTS.md, CLAUDE.md and the like) and its package scripts, and try the steps it documents. Take no step it does not document, and even among those never install system software, and never ask for or read credentials. If the tests then run, carry on under the rules above. If they still cannot run, or trying would leave no time to answer, the answer stays \"could_not_tell\", never \"kept\", and you add \"nextStep\" to your JSON answer: the one step that would let the tests run, written so the person's agent can act on it, such as the exact documented command that starts the test database. Otherwise leave nextStep out.";
30
+ export declare const JUDGE_BRIEF = "You are judging one change to this repository against one promise about the software's behavior. A named person agreed the promise's meaning. Your working directory is a disposable copy of the repository checked out at the change's head commit; anything you write here is thrown away afterwards.\n\nDecide whether this change keeps the promise, breaks it, or whether you could not tell, and back what you say with evidence you produced yourself.\n\nRules:\n\n1. Read the promise's cases first. The passing cases (passing-1, passing-2, and so on, in the order listed) say what must keep happening. The failing cases (failing-1, and so on) say what must never happen. The non-goals say what the promise does not cover; a change there is not a break.\n2. If linked tests are listed, run them first, before anything else. If the runner already reports a linked test's outcome below, use it and rerun only when you need its output.\n3. Read the code, not descriptions of it. Commit messages, comments, documentation and pull request text say what someone intended, not what the code does.\n4. Report \"broken\" only with a command that fails (exits non-zero) with this change and passes (exits zero) without it, and only after you have run it both ways yourself. To run it without the change, check out the base commit in this copy with `git checkout --detach <base>`, run the command, then return with `git checkout --detach <head>`. If the command needs a test you wrote, put that test in a new file rather than editing an existing one, so it exists on both commits. The command must run from the repository root or from the directory you name as its cwd, and must not depend on anything outside this copy.\n5. Report \"kept\" only after you have exercised each passing case against the changed code, by running a test or a command that shows it, and list every case you exercised.\n6. Anything else is \"could_not_tell\". Say in notes what stopped you: a missing dependency, a service you could not reach, a case you could not exercise, time running out.\n7. Name the promise, never the person. Do not put anyone's name, email or account in your answer.\n8. Do not push, open or merge pull requests, publish anything, or change anything outside this copy. Do not read or print credentials.\n9. Only when you would otherwise answer \"could_not_tell\" because the tests need something this machine does not have, such as a test database, a running service, a browser or an environment variable, and only after you have tried to show a break or a keep as the rules above say: look for how the repository documents running its tests, in its README, its agent instruction files (AGENTS.md, CLAUDE.md and the like) and its package scripts, and try the steps it documents. Take no step it does not document, and even among those never install system software, and never ask for or read credentials. If the tests then run, carry on under the rules above. If they still cannot run, or trying would leave no time to answer, the answer stays \"could_not_tell\", never \"kept\", and you add \"nextStep\" to your JSON answer: the one step that would let the tests run, written so the person's agent can act on it, such as the exact documented command that starts the test database. Otherwise leave nextStep out.\n\nYour final message must be exactly one JSON object and nothing after it:\n\n{\"promiseId\": \"<the promise id below>\", \"verdict\": \"kept\" | \"broken\" | \"could_not_tell\", \"reproduction\": {\"command\": \"<shell command>\", \"cwd\": \".\"} or null, \"exercisedCases\": [\"passing-1\", ...], \"notes\": \"<at most a few sentences>\"}\n\nA reproduction is required for \"broken\" and optional otherwise.";
19
31
  export type JudgeBriefInput = Readonly<{
20
32
  promise: PromiseDocument;
21
33
  map?: BehaviorMap;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The fixed instructions a judge works from, `judge-brief/v1`.
2
+ * The fixed instructions a judge works from, `judge-brief/v3`.
3
3
  *
4
4
  * One promise, one change, one disposable checkout at the change's head. The
5
5
  * rules are the contract's: cases first, linked test first, broken only with a
@@ -12,7 +12,19 @@
12
12
  * The text is a constant so a verdict can name the exact brief it was given.
13
13
  * Changing a word here is a new revision.
14
14
  */
15
- export const JUDGE_BRIEF_REVISION = "judge-brief/v1";
15
+ export const JUDGE_BRIEF_REVISION = "judge-brief/v3";
16
+ /**
17
+ * What `judge-brief/v3` added to v1, and all it added (Robert, 30 September
18
+ * 2026): a way forward when the tests need something the machine lacks.
19
+ *
20
+ * It comes last among the rules and only once the judge would otherwise
21
+ * answer could not tell, never before it has tried to prove or refute, so it
22
+ * cannot spend the time a proof needs. Measured against a same-day control of
23
+ * v1 on 30 September (docs/agentic-testing/evidence/20260930/setup-rule.md),
24
+ * it proved no fewer breaks. Could not tell stays could not tell; the only new
25
+ * thing in the answer is `nextStep`, the one step that would let the tests run.
26
+ */
27
+ export const JUDGE_BRIEF_SETUP_RULE = `9. Only when you would otherwise answer "could_not_tell" because the tests need something this machine does not have, such as a test database, a running service, a browser or an environment variable, and only after you have tried to show a break or a keep as the rules above say: look for how the repository documents running its tests, in its README, its agent instruction files (AGENTS.md, CLAUDE.md and the like) and its package scripts, and try the steps it documents. Take no step it does not document, and even among those never install system software, and never ask for or read credentials. If the tests then run, carry on under the rules above. If they still cannot run, or trying would leave no time to answer, the answer stays "could_not_tell", never "kept", and you add "nextStep" to your JSON answer: the one step that would let the tests run, written so the person's agent can act on it, such as the exact documented command that starts the test database. Otherwise leave nextStep out.`;
16
28
  export const JUDGE_BRIEF = `You are judging one change to this repository against one promise about the software's behavior. A named person agreed the promise's meaning. Your working directory is a disposable copy of the repository checked out at the change's head commit; anything you write here is thrown away afterwards.
17
29
 
18
30
  Decide whether this change keeps the promise, breaks it, or whether you could not tell, and back what you say with evidence you produced yourself.
@@ -27,6 +39,7 @@ Rules:
27
39
  6. Anything else is "could_not_tell". Say in notes what stopped you: a missing dependency, a service you could not reach, a case you could not exercise, time running out.
28
40
  7. Name the promise, never the person. Do not put anyone's name, email or account in your answer.
29
41
  8. Do not push, open or merge pull requests, publish anything, or change anything outside this copy. Do not read or print credentials.
42
+ ${JUDGE_BRIEF_SETUP_RULE}
30
43
 
31
44
  Your final message must be exactly one JSON object and nothing after it:
32
45
 
@@ -1,20 +1,30 @@
1
1
  /**
2
- * Where a judge run starts: a coding host's PreToolUse hook, or git's own
3
- * pre-push hook.
2
+ * Where a judge run starts: a coding host's hook around a shell command, or
3
+ * git's own pre-push hook.
4
4
  *
5
- * The host hook fires on every shell command the agent runs, so the first job
6
- * is to say "not a push" and get out of the way in milliseconds. Only `git
7
- * push`, `gh pr create` and `gh pr merge` go further. The command is read the
8
- * way a shell reads it, so a push inside a quoted commit message is not a push
9
- * and `git stash push` is not one either.
5
+ * The host hook fires on every shell command the agent runs, twice: before it
6
+ * runs (PreToolUse) and after (PostToolUse). The first job each time is to say
7
+ * "not a push" and get out of the way in milliseconds. Only `git push`, `gh pr
8
+ * create` and `gh pr merge` go further. The command is read the way a shell
9
+ * reads it, so a push inside a quoted commit message is not a push and `git
10
+ * stash push` is not one either.
11
+ *
12
+ * Report mode judges after the push, so the push is never held and the check
13
+ * sees exactly what was pushed. Block mode judges before it, because a push
14
+ * can only be held before it runs.
10
15
  */
11
16
  export type HookHost = "claude" | "codex" | "git";
17
+ /** The two host events the judge is installed on, in the order a command meets them. */
18
+ export type HookEvent = "PreToolUse" | "PostToolUse";
19
+ export declare const JUDGE_HOOK_EVENTS: readonly HookEvent[];
12
20
  export type HookInput = Readonly<{
13
21
  eventName?: string;
14
22
  toolName?: string;
15
23
  command?: string;
16
24
  cwd?: string;
17
25
  model?: string;
26
+ /** The host's session, from `session_id`, which both Claude Code and Codex send. */
27
+ sessionId?: string;
18
28
  }>;
19
29
  /** A coding host's hook payload, or nothing when the text is not one. */
20
30
  export declare function parseHookInput(text: string): HookInput | undefined;
@@ -26,17 +36,118 @@ export type PushedRef = Readonly<{
26
36
  remoteSha: string;
27
37
  }>;
28
38
  export declare function parsePrePushInput(text: string): PushedRef[];
39
+ /** What separates or groups commands in a shell line. */
40
+ export type ShellOperator = ";" | "&&" | "||" | "|" | "&" | "\n" | "(" | ")" | "`";
41
+ /** One piece of a shell line: a simple command's words, or what comes between commands. */
42
+ export type ShellPiece = Readonly<{
43
+ kind: "command";
44
+ words: readonly string[];
45
+ }> | Readonly<{
46
+ kind: "operator";
47
+ operator: ShellOperator;
48
+ }>;
29
49
  /**
30
- * The simple commands a shell line runs, as word lists.
50
+ * A shell line in the order a shell reads it: simple commands and the
51
+ * operators between them.
52
+ *
53
+ * Quotes and backslashes are honored, so quoted text stays one word. `;`,
54
+ * `&&`, `||`, `|`, `&`, newlines, parentheses and backquotes come between
55
+ * commands. A comment runs to the end of its line. A redirection (`>`, `2>&1`,
56
+ * `<file`, `&>log`) and its target are left out of the command's words.
31
57
  *
32
- * Quotes and backslashes are honored, so quoted text stays one word; `;`,
33
- * `&&`, `||`, `|`, `&`, newlines and parentheses end a command, so `npm test
34
- * && git push` is two commands and `$(git push)` is found. A comment runs to
35
- * the end of its line.
58
+ * A here-document (`<<WORD`, `<<'WORD'`, `<<"WORD"`, `<<-WORD`) is data: the
59
+ * lines after its command, up to a line that is exactly WORD (leading tabs
60
+ * removed for `<<-`), are not commands. A here-string's word (`<<<`) is data
61
+ * too. Two exceptions keep a push inside one from going unseen: text fed to a
62
+ * shell that reads its script from input (`bash <<EOF`, `cat <<EOF | sh`) is
63
+ * read as that script, and the `$(...)` and backquotes in a here-document
64
+ * whose delimiter is not quoted are read as the commands the shell runs while
65
+ * expanding it. Either is read as a subshell, after the pipeline it feeds. A
66
+ * here-document that never reaches its delimiter line is not taken as one, so
67
+ * the lines after it are still read as commands.
68
+ */
69
+ export declare function scanShell(line: string, depth?: number): ShellPiece[];
70
+ /**
71
+ * The simple commands a shell line runs, as word lists, in the order
72
+ * `scanShell` reads them: `npm test && git push` is two commands, `$(git
73
+ * push)` is found, and a here-document's text is not a command.
36
74
  */
37
75
  export declare function shellCommands(line: string): string[][];
76
+ /** Where one push, pull request creation or merge in a shell line runs. */
77
+ export type PushSite = Readonly<{
78
+ kind: "push" | "create" | "merge";
79
+ /** The absolute directory it runs in; absent when that cannot be told for certain. */
80
+ directory?: string;
81
+ /** The repository a pull request command names with `--repo`, `-R` or `GH_REPO`. */
82
+ repo?: string;
83
+ }>;
84
+ /**
85
+ * Every push, pull request creation and merge in a shell line, with the
86
+ * directory it runs in when that can be told for certain.
87
+ *
88
+ * Starting from `cwd`, each `cd` moves the line along in order, and a git
89
+ * command's own `-C` options apply to that command alone. A `cd` inside
90
+ * parentheses, backquotes or `$(...)` ends with them. Anything that makes the
91
+ * directory uncertain makes it unknown from there on rather than guessed: `cd`
92
+ * with no directory, `cd -`, a variable or pattern, `pushd` or `popd`, a `cd`
93
+ * in a pipeline or in the background, a `cd` inside `if`, a loop, `case` or a
94
+ * function, and a `cd` that ran only when an earlier command succeeded or
95
+ * failed, once the line goes past its `&&` chain. A shell's `-c` script starts
96
+ * where the shell does and moves nothing outside it.
97
+ */
98
+ export declare function pushSites(line: string, cwd: string | undefined, depth?: number): PushSite[];
99
+ /** Where a shell line's pushes run, when that is one directory that can be told for certain. */
100
+ export type PushPlace = Readonly<{
101
+ known: true;
102
+ directory: string;
103
+ sites: readonly PushSite[];
104
+ }> | Readonly<{
105
+ known: false;
106
+ }>;
107
+ /**
108
+ * The one directory a shell line's pushes run in, starting from `cwd`, or
109
+ * unknown when any of them cannot be told or they run in more than one.
110
+ */
111
+ export declare function pushPlace(line: string, cwd: string): PushPlace;
112
+ /**
113
+ * A repository as `owner/name` in lower case, from a remote URL or a `--repo`
114
+ * value: `https://github.com/o/r.git`, `git@github.com:o/r.git`,
115
+ * `ssh://git@host/o/r`, `HOST/o/r` or `o/r`. Nothing when there is no owner.
116
+ */
117
+ export declare function repositoryName(reference: string): string | undefined;
118
+ /** The repositories a checkout's remotes point at, from what `git remote -v` prints. */
119
+ export declare function remoteRepositoryNames(listing: string): Set<string>;
38
120
  /** Whether a shell line pushes: `git push`, `gh pr create`, or `gh pr merge`, anywhere in it. */
39
- export declare function isPushCommand(line: string, depth?: number): boolean;
121
+ export declare function isPushCommand(line: string): boolean;
122
+ /**
123
+ * Whether a shell line sends this checkout's commits: a `git push`, or a `gh pr
124
+ * create`, which pushes the branch it opens a pull request for. A line whose
125
+ * only push is `gh pr merge` sends none; that pull request's commits were on
126
+ * the remote before the line ran.
127
+ */
128
+ export declare function sendsCommits(line: string): boolean;
129
+ /**
130
+ * The first command in the line that makes a commit before it pushes, as
131
+ * `git <subcommand>`, or nothing.
132
+ *
133
+ * A host's PreToolUse hook sees the checkout before the line runs, so in
134
+ * `git commit -am x && git push` the check judges the code as it was before
135
+ * the commit, and the commit the push carries is one it never saw. Only a
136
+ * commit made before the push matters: one made after it is not in what was
137
+ * pushed, and `gh pr merge` merges a pull request's head, not this checkout's.
138
+ */
139
+ export declare function commitBeforePush(line: string): string | undefined;
140
+ /**
141
+ * What a `gh pr merge` names. Neither field means the current branch's pull
142
+ * request, which is the change in this checkout; either one can name a pull
143
+ * request whose change is somewhere else entirely.
144
+ */
145
+ export type MergeTarget = Readonly<{
146
+ selector?: string;
147
+ repo?: string;
148
+ }>;
149
+ /** Every `gh pr merge` in a shell line, with the pull request and repository it names. */
150
+ export declare function mergeTargets(line: string): MergeTarget[];
40
151
  export type HookResponse = Readonly<{
41
152
  stdout: string;
42
153
  stderr: string;
@@ -47,11 +158,14 @@ export type HookResponse = Readonly<{
47
158
  * and the push goes ahead; a block is exit 2 with the reason, which both hosts
48
159
  * treat as a refusal of that one command. Claude Code also gets the reason as
49
160
  * `permissionDecision: "deny"`, which it prefers to stderr; Codex is given the
50
- * stderr form its documentation names.
161
+ * stderr form its documentation names. After the command ran (PostToolUse)
162
+ * there is nothing left to hold, so the lines are only ever context, named for
163
+ * the event that asked.
51
164
  */
52
165
  export declare function hookResponse(host: HookHost, outcome: Readonly<{
53
166
  block: boolean;
54
167
  lines: readonly string[];
168
+ event?: HookEvent;
55
169
  }>): HookResponse;
56
170
  /** The status line both hosts show while the hook runs, and the mark of the entry this command owns. */
57
171
  export declare const JUDGE_HOOK_MARKER = "Balladeer judge";
@@ -78,7 +192,67 @@ export type HookInstallResult = Readonly<{
78
192
  status: "written" | "unchanged" | "refused";
79
193
  reason?: string;
80
194
  }>;
195
+ /** The hook file the installer writes for a host, relative to the repository root. */
196
+ export declare function judgeHookFile(host: "claude" | "codex"): string;
197
+ /**
198
+ * Whether the hook file the installer writes has this command's entry for one
199
+ * event. It reads the file rather than guessing, so a checkout installed by an
200
+ * earlier release, which wrote only the PreToolUse entry, is told apart from a
201
+ * current one. Anything unreadable counts as no entry.
202
+ */
203
+ export declare function hasJudgeEntry(root: string, host: "claude" | "codex", event: HookEvent): boolean;
204
+ /**
205
+ * Whether this checkout has the push check's own project entry for a host,
206
+ * under either event. Where it does, that entry owns the check in this
207
+ * checkout and the one setup writes at the host's user scope stands aside, so
208
+ * a push is judged once. Either event counts: a checkout an earlier release
209
+ * installed has only the entry before the push, and it keeps judging there.
210
+ */
211
+ export declare function hasProjectJudgeHook(root: string, host: "claude" | "codex"): boolean;
212
+ /**
213
+ * The checkout that holds a folder, found by walking up to its `.git` entry (a
214
+ * directory, or a file in a worktree). No process is started, so a folder that
215
+ * is not in a checkout at all is known in well under a millisecond.
216
+ */
217
+ export declare function checkoutRoot(start: string): string | undefined;
218
+ /**
219
+ * The push check's switch for this laptop, in Balladeer's own config home.
220
+ * Absent means on. Written only by `balladeer judge --push-check off`, and
221
+ * read by the user-scope entry on each push, so turning it off takes effect in
222
+ * sessions that are already open.
223
+ */
224
+ export declare const PUSH_CHECK_SETTING_FILE = "push-check.json";
225
+ export declare function pushCheckSettingPath(environment: NodeJS.ProcessEnv): string;
226
+ /** True only when this laptop's switch says off; anything unreadable leaves it on. */
227
+ export declare function pushCheckOff(environment: NodeJS.ProcessEnv): boolean;
228
+ /** Turn the user-scope push check off or back on for this laptop. */
229
+ export declare function setPushCheck(environment: NodeJS.ProcessEnv, on: boolean): void;
81
230
  /** Where git looks for hooks in this checkout, which is not `.git/hooks` in a worktree. */
82
231
  export declare function gitHooksDirectory(root: string): string;
83
232
  /** Install the judge hook for one host. Running it twice changes nothing the second time. */
84
233
  export declare function installJudgeHook(root: string, target: HookHost): HookInstallResult;
234
+ /**
235
+ * Whether a commit is on a remote now: reachable from a remote-tracking ref,
236
+ * or else the tip of a branch some remote reports.
237
+ *
238
+ * `git push`, `git push origin HEAD:branch` and the push `gh pr create` makes
239
+ * all move the remote-tracking ref of what they pushed, so the first check is
240
+ * local and immediate. The second is there because a push can land without
241
+ * that ref moving: Codex's workspace sandbox keeps `.git` read-only, so its
242
+ * pushes reach the remote and then fail to update `refs/remotes/...` (hook
243
+ * canary, 29 September 2026). Asking each remote for its branch tips settles
244
+ * it without fetching anything, and a push that failed leaves its commit on
245
+ * neither.
246
+ */
247
+ export declare function pushLanded(root: string, sha: string): Promise<boolean>;
248
+ /**
249
+ * Where the remote default branch stood before this push, when this push is
250
+ * what moved it to `head`; otherwise nothing, and the ordinary fork-point rule
251
+ * applies.
252
+ *
253
+ * Judged after a push straight to the default branch, the fork point with that
254
+ * branch's remote-tracking ref is `head` itself, so the change would shrink to
255
+ * its last commit. git records the push in the ref's reflog ("update by
256
+ * push"), and the entry before it is where the branch stood.
257
+ */
258
+ export declare function baseBeforePush(root: string, head: string): Promise<string | undefined>;