balladeer 1.0.14 → 1.0.16

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.
Files changed (77) hide show
  1. package/README.md +2 -0
  2. package/dist/behavior-map-schema.d.ts +107 -0
  3. package/dist/behavior-map-schema.js +232 -0
  4. package/dist/cli.d.ts +5 -0
  5. package/dist/cli.js +77 -2
  6. package/dist/commands/guidance.d.ts +1 -1
  7. package/dist/commands/guidance.js +6 -0
  8. package/dist/commands/judge.d.ts +271 -0
  9. package/dist/commands/judge.js +1170 -0
  10. package/dist/commands/map.d.ts +120 -0
  11. package/dist/commands/map.js +601 -0
  12. package/dist/commands/mcp.d.ts +6 -0
  13. package/dist/commands/mcp.js +53 -0
  14. package/dist/commands/offers.d.ts +265 -0
  15. package/dist/commands/offers.js +801 -0
  16. package/dist/commands/risk.d.ts +29 -0
  17. package/dist/commands/risk.js +133 -0
  18. package/dist/copy.d.ts +24 -1
  19. package/dist/copy.js +91 -0
  20. package/dist/guidance-hook.mjs +230 -74
  21. package/dist/guidance.d.ts +2 -0
  22. package/dist/guidance.js +7 -1
  23. package/dist/headless-agent.d.ts +166 -0
  24. package/dist/headless-agent.js +416 -0
  25. package/dist/hook-trust.d.ts +31 -0
  26. package/dist/hook-trust.js +63 -0
  27. package/dist/judge-brief.d.ts +36 -0
  28. package/dist/judge-brief.js +89 -0
  29. package/dist/judge-hook.d.ts +84 -0
  30. package/dist/judge-hook.js +420 -0
  31. package/dist/map-brief.d.ts +16 -0
  32. package/dist/map-brief.js +41 -0
  33. package/dist/offer-brief.d.ts +95 -0
  34. package/dist/offer-brief.js +217 -0
  35. package/dist/owned-process.d.ts +65 -0
  36. package/dist/owned-process.js +146 -0
  37. package/dist/promise-meaning.d.ts +126 -0
  38. package/dist/promise-meaning.js +292 -0
  39. package/dist/risk/contract.d.ts +145 -0
  40. package/dist/risk/contract.js +74 -0
  41. package/dist/risk/describe.d.ts +7 -0
  42. package/dist/risk/describe.js +45 -0
  43. package/dist/risk/diff.d.ts +26 -0
  44. package/dist/risk/diff.js +174 -0
  45. package/dist/risk/extract.d.ts +43 -0
  46. package/dist/risk/extract.js +336 -0
  47. package/dist/risk/git.d.ts +30 -0
  48. package/dist/risk/git.js +118 -0
  49. package/dist/risk/import-graph.d.ts +41 -0
  50. package/dist/risk/import-graph.js +487 -0
  51. package/dist/risk/index.d.ts +20 -0
  52. package/dist/risk/index.js +20 -0
  53. package/dist/risk/paths.d.ts +16 -0
  54. package/dist/risk/paths.js +73 -0
  55. package/dist/risk/pipeline.d.ts +47 -0
  56. package/dist/risk/pipeline.js +121 -0
  57. package/dist/risk/priors.d.ts +18 -0
  58. package/dist/risk/priors.js +86 -0
  59. package/dist/risk/resources.d.ts +56 -0
  60. package/dist/risk/resources.js +374 -0
  61. package/dist/risk/score.d.ts +85 -0
  62. package/dist/risk/score.js +543 -0
  63. package/dist/risk/symbols.d.ts +35 -0
  64. package/dist/risk/symbols.js +348 -0
  65. package/dist/risk/text.d.ts +43 -0
  66. package/dist/risk/text.js +277 -0
  67. package/dist/risk/validate.d.ts +19 -0
  68. package/dist/risk/validate.js +154 -0
  69. package/dist/scratch-worktree.d.ts +51 -0
  70. package/dist/scratch-worktree.js +153 -0
  71. package/dist/self-update.d.ts +29 -2
  72. package/dist/self-update.js +96 -11
  73. package/dist/user-scope.d.ts +2 -0
  74. package/dist/user-scope.js +26 -2
  75. package/dist/wire.d.ts +55 -2
  76. package/dist/wire.js +5 -1
  77. package/package.json +1 -1
@@ -0,0 +1,36 @@
1
+ import type { BehaviorMap } from "./behavior-map-schema.js";
2
+ import type { PromiseDocument } from "./promise-meaning.js";
3
+ /**
4
+ * The fixed instructions a judge works from, `judge-brief/v1`.
5
+ *
6
+ * One promise, one change, one disposable checkout at the change's head. The
7
+ * rules are the contract's: cases first, linked test first, broken only with a
8
+ * command that fails with the change and passes without it, kept only after
9
+ * each passing case was exercised, and could not tell for everything else,
10
+ * with what stopped the judge. The runner checks the reproduction itself by
11
+ * running it both ways, so a judge that claims a break it did not prove is
12
+ * downgraded rather than believed.
13
+ *
14
+ * The text is a constant so a verdict can name the exact brief it was given.
15
+ * Changing a word here is a new revision.
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.";
19
+ export type JudgeBriefInput = Readonly<{
20
+ promise: PromiseDocument;
21
+ map?: BehaviorMap;
22
+ changedFiles: readonly string[];
23
+ base: string;
24
+ head: string;
25
+ /** What the runner saw when it ran the linked tests itself, before the judge started. */
26
+ linkedTestRuns?: readonly Readonly<{
27
+ file: string;
28
+ command: string;
29
+ outcome: string;
30
+ output: string;
31
+ }>[];
32
+ /** Roughly how long the judge has, so it answers before it is stopped. */
33
+ minutes?: number;
34
+ }>;
35
+ /** The brief plus this one judgment's facts, as the prompt the agent receives. */
36
+ export declare function judgePrompt(input: JudgeBriefInput): string;
@@ -0,0 +1,89 @@
1
+ /**
2
+ * The fixed instructions a judge works from, `judge-brief/v1`.
3
+ *
4
+ * One promise, one change, one disposable checkout at the change's head. The
5
+ * rules are the contract's: cases first, linked test first, broken only with a
6
+ * command that fails with the change and passes without it, kept only after
7
+ * each passing case was exercised, and could not tell for everything else,
8
+ * with what stopped the judge. The runner checks the reproduction itself by
9
+ * running it both ways, so a judge that claims a break it did not prove is
10
+ * downgraded rather than believed.
11
+ *
12
+ * The text is a constant so a verdict can name the exact brief it was given.
13
+ * Changing a word here is a new revision.
14
+ */
15
+ export const JUDGE_BRIEF_REVISION = "judge-brief/v1";
16
+ 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
+
18
+ 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.
19
+
20
+ Rules:
21
+
22
+ 1. 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.
23
+ 2. 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.
24
+ 3. Read the code, not descriptions of it. Commit messages, comments, documentation and pull request text say what someone intended, not what the code does.
25
+ 4. 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.
26
+ 5. 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.
27
+ 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
+ 7. Name the promise, never the person. Do not put anyone's name, email or account in your answer.
29
+ 8. Do not push, open or merge pull requests, publish anything, or change anything outside this copy. Do not read or print credentials.
30
+
31
+ Your final message must be exactly one JSON object and nothing after it:
32
+
33
+ {"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>"}
34
+
35
+ A reproduction is required for "broken" and optional otherwise.`;
36
+ const CHANGED_FILES_SHOWN = 300;
37
+ /** The brief plus this one judgment's facts, as the prompt the agent receives. */
38
+ export function judgePrompt(input) {
39
+ const sections = [JUDGE_BRIEF, ""];
40
+ sections.push(`Base commit (without the change): ${input.base}`);
41
+ sections.push(`Head commit (with the change, checked out now): ${input.head}`);
42
+ if (input.minutes !== undefined)
43
+ sections.push(`You have about ${input.minutes} minutes. If time is short, answer could_not_tell with what is left.`);
44
+ sections.push("");
45
+ sections.push("The promise, as Balladeer returns it:");
46
+ sections.push(JSON.stringify({
47
+ promiseId: input.promise.promiseId,
48
+ title: input.promise.title,
49
+ semanticDigest: input.promise.semanticDigest,
50
+ meaning: input.promise.meaning,
51
+ }, null, 2));
52
+ if (input.promise.meaning.passingExamples.length === 0)
53
+ sections.push("Its passing cases were not available here, so no case can be exercised; answer kept only if you can say from the code that none of the failing cases can happen.");
54
+ sections.push("");
55
+ if (input.map !== undefined) {
56
+ // The excerpt repeats the promise printed above; the judge reads it once.
57
+ const map = { ...input.map };
58
+ delete map.meaning;
59
+ sections.push("Where this promise's behavior lives, from its behavior map (built earlier; check it against the code):");
60
+ sections.push(JSON.stringify(map, null, 2));
61
+ }
62
+ else {
63
+ sections.push("There is no behavior map for this promise; find where its behavior lives yourself.");
64
+ }
65
+ sections.push("");
66
+ const shown = input.changedFiles.slice(0, CHANGED_FILES_SHOWN);
67
+ sections.push(`Files this change touches (${input.changedFiles.length}):`);
68
+ for (const file of shown)
69
+ sections.push(`- ${file}`);
70
+ if (input.changedFiles.length > shown.length)
71
+ sections.push(`- and ${input.changedFiles.length - shown.length} more; run git diff to see them`);
72
+ sections.push(`See the change itself with: git diff ${input.base} ${input.head}`);
73
+ if (input.linkedTestRuns !== undefined && input.linkedTestRuns.length > 0) {
74
+ sections.push("");
75
+ sections.push("The runner already ran the linked tests at head:");
76
+ for (const run of input.linkedTestRuns) {
77
+ sections.push(`- ${run.file}: ${run.outcome} (${run.command})`);
78
+ if (run.output.trim())
79
+ sections.push(indent(run.output.trim()));
80
+ }
81
+ }
82
+ return `${sections.join("\n")}\n`;
83
+ }
84
+ function indent(text) {
85
+ return text
86
+ .split("\n")
87
+ .map((line) => ` ${line}`)
88
+ .join("\n");
89
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Where a judge run starts: a coding host's PreToolUse hook, or git's own
3
+ * pre-push hook.
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.
10
+ */
11
+ export type HookHost = "claude" | "codex" | "git";
12
+ export type HookInput = Readonly<{
13
+ eventName?: string;
14
+ toolName?: string;
15
+ command?: string;
16
+ cwd?: string;
17
+ model?: string;
18
+ }>;
19
+ /** A coding host's hook payload, or nothing when the text is not one. */
20
+ export declare function parseHookInput(text: string): HookInput | undefined;
21
+ /** The refs git hands a pre-push hook: one line per ref being pushed. */
22
+ export type PushedRef = Readonly<{
23
+ localRef: string;
24
+ localSha: string;
25
+ remoteRef: string;
26
+ remoteSha: string;
27
+ }>;
28
+ export declare function parsePrePushInput(text: string): PushedRef[];
29
+ /**
30
+ * The simple commands a shell line runs, as word lists.
31
+ *
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.
36
+ */
37
+ export declare function shellCommands(line: string): string[][];
38
+ /** 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;
40
+ export type HookResponse = Readonly<{
41
+ stdout: string;
42
+ stderr: string;
43
+ exitCode: number;
44
+ }>;
45
+ /**
46
+ * What the host is told. In report mode the lines reach the agent as context
47
+ * and the push goes ahead; a block is exit 2 with the reason, which both hosts
48
+ * treat as a refusal of that one command. Claude Code also gets the reason as
49
+ * `permissionDecision: "deny"`, which it prefers to stderr; Codex is given the
50
+ * stderr form its documentation names.
51
+ */
52
+ export declare function hookResponse(host: HookHost, outcome: Readonly<{
53
+ block: boolean;
54
+ lines: readonly string[];
55
+ }>): HookResponse;
56
+ /** The status line both hosts show while the hook runs, and the mark of the entry this command owns. */
57
+ export declare const JUDGE_HOOK_MARKER = "Balladeer judge";
58
+ export declare const JUDGE_HOOK_TIMEOUT_SECONDS = 900;
59
+ /**
60
+ * The most a judge budget may be when the command runs from a host's hook.
61
+ *
62
+ * Claude Code documents that a hook reaching its own timeout lets the command
63
+ * run, and Codex documents no rule; so a budget longer than the hook's timeout
64
+ * would let a push through with no verdict and no line saying so, which is the
65
+ * one thing block mode exists to prevent. Login checks and the risk ranking can
66
+ * spend about ninety seconds before the budget starts, so the cap sits well
67
+ * under the hook timeout. The hook canary of 29 September 2026 found the gap.
68
+ */
69
+ export declare const HOOK_BUDGET_CAP_SECONDS = 600;
70
+ export declare const GIT_HOOK_MARKER = "# Installed by: balladeer judge --install-hook git";
71
+ /** The budget a run may spend: as configured, except from a hook, where the host's timeout rules. */
72
+ export declare function effectiveBudgetSeconds(configured: number, fromHook: boolean): number;
73
+ export declare function judgeHookCommand(host: "claude" | "codex"): string;
74
+ export declare const GIT_PRE_PUSH_SCRIPT: string;
75
+ export type HookInstallResult = Readonly<{
76
+ target: HookHost;
77
+ path: string;
78
+ status: "written" | "unchanged" | "refused";
79
+ reason?: string;
80
+ }>;
81
+ /** Where git looks for hooks in this checkout, which is not `.git/hooks` in a worktree. */
82
+ export declare function gitHooksDirectory(root: string): string;
83
+ /** Install the judge hook for one host. Running it twice changes nothing the second time. */
84
+ export declare function installJudgeHook(root: string, target: HookHost): HookInstallResult;
@@ -0,0 +1,420 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { randomBytes } from "node:crypto";
3
+ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
4
+ import { basename, dirname, isAbsolute, join } from "node:path";
5
+ /** A coding host's hook payload, or nothing when the text is not one. */
6
+ export function parseHookInput(text) {
7
+ let value;
8
+ try {
9
+ value = JSON.parse(text);
10
+ }
11
+ catch {
12
+ return undefined;
13
+ }
14
+ if (value === null || typeof value !== "object" || Array.isArray(value))
15
+ return undefined;
16
+ const input = value;
17
+ const toolInput = input.tool_input;
18
+ const command = commandText(toolInput?.command);
19
+ return {
20
+ ...(typeof input.hook_event_name === "string" ? { eventName: input.hook_event_name } : {}),
21
+ ...(typeof input.tool_name === "string" ? { toolName: input.tool_name } : {}),
22
+ ...(command === undefined ? {} : { command }),
23
+ ...(typeof input.cwd === "string" ? { cwd: input.cwd } : {}),
24
+ ...(typeof input.model === "string" ? { model: input.model } : {}),
25
+ };
26
+ }
27
+ const SHELLS = new Set(["bash", "sh", "zsh", "dash", "ksh"]);
28
+ /** Codex can send a command as an argument vector; a shell's `-c` script is what runs. */
29
+ function commandText(value) {
30
+ if (typeof value === "string")
31
+ return value;
32
+ if (!Array.isArray(value) || !value.every((word) => typeof word === "string"))
33
+ return undefined;
34
+ const words = value;
35
+ if (words.length >= 3 && SHELLS.has(basename(words[0])) && /^-[a-z]*c[a-z]*$/.test(words[1]))
36
+ return words[2];
37
+ return words.join(" ");
38
+ }
39
+ const ZERO = /^0{40}$/;
40
+ export function parsePrePushInput(text) {
41
+ const refs = [];
42
+ for (const line of text.split("\n")) {
43
+ const [localRef, localSha, remoteRef, remoteSha] = line.trim().split(/\s+/);
44
+ if (!localRef || !localSha || !remoteRef || !remoteSha)
45
+ continue;
46
+ if (!/^[0-9a-f]{40}$/.test(localSha) || ZERO.test(localSha))
47
+ continue;
48
+ refs.push({ localRef, localSha, remoteRef, remoteSha });
49
+ }
50
+ return refs;
51
+ }
52
+ /**
53
+ * The simple commands a shell line runs, as word lists.
54
+ *
55
+ * Quotes and backslashes are honored, so quoted text stays one word; `;`,
56
+ * `&&`, `||`, `|`, `&`, newlines and parentheses end a command, so `npm test
57
+ * && git push` is two commands and `$(git push)` is found. A comment runs to
58
+ * the end of its line.
59
+ */
60
+ export function shellCommands(line) {
61
+ const commands = [];
62
+ let words = [];
63
+ let word = "";
64
+ let inWord = false;
65
+ const endWord = () => {
66
+ if (inWord)
67
+ words.push(word);
68
+ word = "";
69
+ inWord = false;
70
+ };
71
+ const endCommand = () => {
72
+ endWord();
73
+ if (words.length > 0)
74
+ commands.push(words);
75
+ words = [];
76
+ };
77
+ for (let at = 0; at < line.length; at += 1) {
78
+ const char = line[at];
79
+ if (char === "'") {
80
+ const close = line.indexOf("'", at + 1);
81
+ const end = close < 0 ? line.length : close;
82
+ word += line.slice(at + 1, end);
83
+ inWord = true;
84
+ at = end;
85
+ }
86
+ else if (char === '"') {
87
+ inWord = true;
88
+ at += 1;
89
+ while (at < line.length && line[at] !== '"') {
90
+ if (line[at] === "\\" && at + 1 < line.length)
91
+ at += 1;
92
+ word += line[at];
93
+ at += 1;
94
+ }
95
+ }
96
+ else if (char === "\\") {
97
+ if (at + 1 < line.length && line[at + 1] !== "\n")
98
+ word += line[at + 1];
99
+ inWord = true;
100
+ at += 1;
101
+ }
102
+ else if (char === "#" && !inWord) {
103
+ const end = line.indexOf("\n", at);
104
+ at = end < 0 ? line.length : end - 1;
105
+ }
106
+ else if (/\s/.test(char)) {
107
+ if (char === "\n")
108
+ endCommand();
109
+ else
110
+ endWord();
111
+ }
112
+ else if (";&|()`".includes(char)) {
113
+ endCommand();
114
+ }
115
+ else {
116
+ word += char;
117
+ inWord = true;
118
+ }
119
+ }
120
+ endCommand();
121
+ return commands;
122
+ }
123
+ const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
124
+ const WRAPPERS = new Set(["env", "command", "exec", "time", "nohup", "nice", "sudo", "builtin"]);
125
+ /** The command's words with leading assignments and wrappers like `env` and `sudo` removed. */
126
+ function program(words) {
127
+ let at = 0;
128
+ while (at < words.length) {
129
+ const word = words[at];
130
+ if (ASSIGNMENT.test(word))
131
+ at += 1;
132
+ else if (WRAPPERS.has(basename(word))) {
133
+ at += 1;
134
+ while (at < words.length && (words[at].startsWith("-") || ASSIGNMENT.test(words[at])))
135
+ at += 1;
136
+ }
137
+ else
138
+ break;
139
+ }
140
+ return words.slice(at);
141
+ }
142
+ /** git's global options that take the next word as their value. */
143
+ const GIT_VALUE_OPTIONS = new Set([
144
+ "-C",
145
+ "-c",
146
+ "--git-dir",
147
+ "--work-tree",
148
+ "--namespace",
149
+ "--super-prefix",
150
+ "--config-env",
151
+ ]);
152
+ function gitSubcommand(args) {
153
+ for (let at = 0; at < args.length; at += 1) {
154
+ const word = args[at];
155
+ if (GIT_VALUE_OPTIONS.has(word)) {
156
+ at += 1;
157
+ continue;
158
+ }
159
+ if (word.startsWith("-"))
160
+ continue;
161
+ return word;
162
+ }
163
+ return undefined;
164
+ }
165
+ /** Whether a shell line pushes: `git push`, `gh pr create`, or `gh pr merge`, anywhere in it. */
166
+ export function isPushCommand(line, depth = 0) {
167
+ for (const words of shellCommands(line)) {
168
+ const run = program(words);
169
+ if (run.length === 0)
170
+ continue;
171
+ const name = basename(run[0]);
172
+ if (name === "git" && gitSubcommand(run.slice(1)) === "push")
173
+ return true;
174
+ if (name === "gh" && run[1] === "pr" && (run[2] === "create" || run[2] === "merge"))
175
+ return true;
176
+ if (SHELLS.has(name) && depth < 3) {
177
+ const flag = run.findIndex((word, index) => index > 0 && /^-[a-z]*c[a-z]*$/.test(word));
178
+ const script = flag > 0 ? run[flag + 1] : undefined;
179
+ if (script !== undefined && isPushCommand(script, depth + 1))
180
+ return true;
181
+ }
182
+ }
183
+ return false;
184
+ }
185
+ /**
186
+ * What the host is told. In report mode the lines reach the agent as context
187
+ * and the push goes ahead; a block is exit 2 with the reason, which both hosts
188
+ * treat as a refusal of that one command. Claude Code also gets the reason as
189
+ * `permissionDecision: "deny"`, which it prefers to stderr; Codex is given the
190
+ * stderr form its documentation names.
191
+ */
192
+ export function hookResponse(host, outcome) {
193
+ const text = outcome.lines.join("\n");
194
+ if (outcome.block) {
195
+ if (host === "claude")
196
+ return {
197
+ stdout: `${JSON.stringify({
198
+ hookSpecificOutput: {
199
+ hookEventName: "PreToolUse",
200
+ permissionDecision: "deny",
201
+ permissionDecisionReason: text,
202
+ },
203
+ })}\n`,
204
+ stderr: `${text}\n`,
205
+ exitCode: 2,
206
+ };
207
+ return { stdout: "", stderr: `${text}\n`, exitCode: 2 };
208
+ }
209
+ if (outcome.lines.length === 0)
210
+ return { stdout: "", stderr: "", exitCode: 0 };
211
+ if (host === "git")
212
+ return { stdout: `${text}\n`, stderr: "", exitCode: 0 };
213
+ return {
214
+ stdout: `${JSON.stringify({
215
+ hookSpecificOutput: { hookEventName: "PreToolUse", additionalContext: text },
216
+ })}\n`,
217
+ stderr: "",
218
+ exitCode: 0,
219
+ };
220
+ }
221
+ /** The status line both hosts show while the hook runs, and the mark of the entry this command owns. */
222
+ export const JUDGE_HOOK_MARKER = "Balladeer judge";
223
+ export const JUDGE_HOOK_TIMEOUT_SECONDS = 900;
224
+ /**
225
+ * The most a judge budget may be when the command runs from a host's hook.
226
+ *
227
+ * Claude Code documents that a hook reaching its own timeout lets the command
228
+ * run, and Codex documents no rule; so a budget longer than the hook's timeout
229
+ * would let a push through with no verdict and no line saying so, which is the
230
+ * one thing block mode exists to prevent. Login checks and the risk ranking can
231
+ * spend about ninety seconds before the budget starts, so the cap sits well
232
+ * under the hook timeout. The hook canary of 29 September 2026 found the gap.
233
+ */
234
+ export const HOOK_BUDGET_CAP_SECONDS = 600;
235
+ export const GIT_HOOK_MARKER = "# Installed by: balladeer judge --install-hook git";
236
+ /** The budget a run may spend: as configured, except from a hook, where the host's timeout rules. */
237
+ export function effectiveBudgetSeconds(configured, fromHook) {
238
+ return fromHook ? Math.min(configured, HOOK_BUDGET_CAP_SECONDS) : configured;
239
+ }
240
+ export function judgeHookCommand(host) {
241
+ return `balladeer judge --hook ${host}`;
242
+ }
243
+ function expectedEntry(host) {
244
+ return {
245
+ matcher: "Bash",
246
+ hooks: [
247
+ {
248
+ type: "command",
249
+ command: judgeHookCommand(host),
250
+ timeout: JUDGE_HOOK_TIMEOUT_SECONDS,
251
+ statusMessage: JUDGE_HOOK_MARKER,
252
+ },
253
+ ],
254
+ };
255
+ }
256
+ export const GIT_PRE_PUSH_SCRIPT = [
257
+ "#!/bin/sh",
258
+ GIT_HOOK_MARKER,
259
+ "# It judges the promises this push could break, on this machine's own coding agent login.",
260
+ "# In block mode it stops a push whose break was reproduced both ways. Delete this file to remove it.",
261
+ "command -v balladeer >/dev/null 2>&1 || exit 0",
262
+ "balladeer judge --hook git",
263
+ "",
264
+ ].join("\n");
265
+ function readOwnFile(path) {
266
+ if (!existsSync(path)) {
267
+ try {
268
+ lstatSync(path);
269
+ }
270
+ catch {
271
+ return undefined;
272
+ }
273
+ throw new Error(`${path} is a link to nothing`);
274
+ }
275
+ const stat = lstatSync(path);
276
+ if (!stat.isFile() || stat.isSymbolicLink())
277
+ throw new Error(`${path} is not a regular file`);
278
+ if (stat.size > 1024 * 1024)
279
+ throw new Error(`${path} is larger than this command edits`);
280
+ return readFileSync(path, "utf8");
281
+ }
282
+ function writeAtomically(path, content, mode) {
283
+ mkdirSync(dirname(path), { recursive: true });
284
+ const temporary = join(dirname(path), `.balladeer-judge-${randomBytes(6).toString("hex")}.tmp`);
285
+ writeFileSync(temporary, content, { flag: "wx", mode });
286
+ try {
287
+ renameSync(temporary, path);
288
+ }
289
+ catch (error) {
290
+ try {
291
+ unlinkSync(temporary);
292
+ }
293
+ catch {
294
+ /* Already gone. */
295
+ }
296
+ throw error;
297
+ }
298
+ }
299
+ /**
300
+ * The PreToolUse entry in a host's JSON hook file, added once.
301
+ *
302
+ * The entry this command owns is the one whose handler carries the marker. A
303
+ * second run finds it and changes nothing, byte for byte. An owned entry that
304
+ * somebody edited is left alone and reported, the same rule the guidance
305
+ * installer follows: it is theirs now, and silently putting it back would undo
306
+ * a decision somebody made on purpose.
307
+ */
308
+ function installJsonHook(root, host) {
309
+ const relativePath = host === "claude" ? ".claude/settings.json" : ".codex/hooks.json";
310
+ const path = join(root, relativePath);
311
+ try {
312
+ const before = readOwnFile(path);
313
+ const document = before === undefined ? {} : JSON.parse(before);
314
+ if (document === null || typeof document !== "object" || Array.isArray(document))
315
+ return {
316
+ target: host,
317
+ path: relativePath,
318
+ status: "refused",
319
+ reason: `${relativePath} is not a JSON object`,
320
+ };
321
+ const settings = document;
322
+ const hooks = settings.hooks === undefined ? {} : settings.hooks;
323
+ if (hooks === null || typeof hooks !== "object" || Array.isArray(hooks))
324
+ return {
325
+ target: host,
326
+ path: relativePath,
327
+ status: "refused",
328
+ reason: `hooks in ${relativePath} is not an object`,
329
+ };
330
+ const events = hooks;
331
+ const rows = events.PreToolUse === undefined ? [] : events.PreToolUse;
332
+ if (!Array.isArray(rows))
333
+ return {
334
+ target: host,
335
+ path: relativePath,
336
+ status: "refused",
337
+ reason: `hooks.PreToolUse in ${relativePath} is not a list`,
338
+ };
339
+ const own = rows.filter((row) => Array.isArray(row?.hooks)
340
+ ? row.hooks.some((handler) => handler?.statusMessage === JUDGE_HOOK_MARKER)
341
+ : false);
342
+ const expected = expectedEntry(host);
343
+ if (own.length === 1 && JSON.stringify(own[0]) === JSON.stringify(expected))
344
+ return { target: host, path: relativePath, status: "unchanged" };
345
+ if (own.length > 0)
346
+ return {
347
+ target: host,
348
+ path: relativePath,
349
+ status: "refused",
350
+ reason: `${relativePath} already has a Balladeer judge entry that was edited by hand, so it was left as it is. Remove it to have this command write the current one.`,
351
+ };
352
+ events.PreToolUse = [...rows, expected];
353
+ settings.hooks = events;
354
+ const mode = before === undefined ? 0o644 : lstatSync(path).mode & 0o777;
355
+ writeAtomically(path, `${JSON.stringify(settings, null, 2)}\n`, mode);
356
+ return { target: host, path: relativePath, status: "written" };
357
+ }
358
+ catch (error) {
359
+ return {
360
+ target: host,
361
+ path: relativePath,
362
+ status: "refused",
363
+ reason: error instanceof SyntaxError
364
+ ? `${relativePath} is not valid JSON`
365
+ : error instanceof Error
366
+ ? error.message
367
+ : String(error),
368
+ };
369
+ }
370
+ }
371
+ /** Where git looks for hooks in this checkout, which is not `.git/hooks` in a worktree. */
372
+ export function gitHooksDirectory(root) {
373
+ try {
374
+ const answer = execFileSync("git", ["-C", root, "rev-parse", "--git-path", "hooks"], {
375
+ encoding: "utf8",
376
+ stdio: ["ignore", "pipe", "ignore"],
377
+ timeout: 5000,
378
+ }).trim();
379
+ if (answer)
380
+ return isAbsolute(answer) ? answer : join(root, answer);
381
+ }
382
+ catch {
383
+ /* Fall through to the ordinary place. */
384
+ }
385
+ return join(root, ".git", "hooks");
386
+ }
387
+ function installGitHook(root) {
388
+ const directory = gitHooksDirectory(root);
389
+ const path = join(directory, "pre-push");
390
+ const shown = path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path;
391
+ try {
392
+ const before = readOwnFile(path);
393
+ if (before === GIT_PRE_PUSH_SCRIPT)
394
+ return { target: "git", path: shown, status: "unchanged" };
395
+ if (before !== undefined)
396
+ return {
397
+ target: "git",
398
+ path: shown,
399
+ status: "refused",
400
+ reason: before.includes(GIT_HOOK_MARKER)
401
+ ? `${shown} was installed by this command and edited since, so it was left as it is.`
402
+ : `${shown} already exists and is not Balladeer's, so nothing was written. Add this line to it yourself: balladeer judge --hook git`,
403
+ };
404
+ writeAtomically(path, GIT_PRE_PUSH_SCRIPT, 0o755);
405
+ chmodSync(path, 0o755);
406
+ return { target: "git", path: shown, status: "written" };
407
+ }
408
+ catch (error) {
409
+ return {
410
+ target: "git",
411
+ path: shown,
412
+ status: "refused",
413
+ reason: error instanceof Error ? error.message : String(error),
414
+ };
415
+ }
416
+ }
417
+ /** Install the judge hook for one host. Running it twice changes nothing the second time. */
418
+ export function installJudgeHook(root, target) {
419
+ return target === "git" ? installGitHook(root) : installJsonHook(root, target);
420
+ }
@@ -0,0 +1,16 @@
1
+ import type { PromiseDocument } from "./promise-meaning.js";
2
+ /**
3
+ * The fixed instructions a map builder works from, `map-brief/v1`.
4
+ *
5
+ * The map says where one promise's behavior lives, so a later change can be
6
+ * matched against it cheaply and a judge can start in the right place. It is
7
+ * built once per promise, from the code, by the person's own agent, in a
8
+ * disposable read-only checkout, and it never leaves the machine. The runner
9
+ * fills in the fields it owns (the promise id, the digest, when and on what
10
+ * commit it was mapped, and the meaning excerpt) and checks the rest against
11
+ * the contract before anything is written.
12
+ */
13
+ export declare const MAP_BRIEF_REVISION = "map-brief/v1";
14
+ export declare const MAP_BRIEF = "You are mapping where one promise's behavior lives in this repository. A named person agreed the promise's meaning; it is below. Your working directory is a disposable copy of the repository at its current commit. Read only: anything you write here is thrown away.\n\nFind, by reading and searching the code:\n\n1. entryPoints: where the behavior starts, such as a route handler, a command, a UI action, a scheduled job or an event handler. kind is one of route, command, ui, job, event, function. note is one short phrase or \"\".\n2. symbols: the specific functions, classes, methods, constants, types or modules on the path from those entry points to the outcome. role is core for the ones that decide the outcome and supporting for the ones it passes through. kind is one of function, class, method, const, type, module. Name each symbol exactly as it is spelled in its file, because a later check searches the file for that name. Prefer specific symbols over whole modules.\n3. resources: what the behavior depends on outside the code: tables, columns, routes, feature flags, environment keys, queues, events, outbound calls, files, configuration. kind is one of table, column, route, flag, env, queue, event, outbound, file, config; access is one of read, write, both, call.\n4. linkedTests: search the test suite for tests that exercise this behavior. For each, give the test file, the test's own name as written in the file, and which passing cases it covers (passing-1, passing-2, in the order the meaning lists them). Only tests that exist; an empty list is a fine answer.\n5. sinks: for each failing case (failing-1, failing-2, in order), the effect that must not happen, the resources where it would happen, and the symbols where it would happen, written as path#symbol.\n6. confidence: a number from 0 to 1 for how sure you are that this map points at the code that decides the behavior.\n7. notes: anything a later reader needs, in at most three sentences, or \"\".\n8. meaning: copy this excerpt of the promise verbatim: title, observableOutcome, nonGoals, and failingExamples as {\"id\": \"failing-1\", \"text\": \"...\"}.\n\nPaths are relative to the repository root, with forward slashes. Every path must exist. Keep the map small: at most 20 entry points, 60 symbols, 40 resources, 20 linked tests.\n\nDo not push, publish 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{\"entryPoints\": [{\"file\": \"\", \"symbol\": \"\", \"kind\": \"function\", \"note\": \"\"}], \"symbols\": [{\"file\": \"\", \"name\": \"\", \"kind\": \"function\", \"role\": \"core\"}], \"resources\": [{\"kind\": \"table\", \"name\": \"\", \"access\": \"read\"}], \"linkedTests\": [{\"file\": \"\", \"name\": \"\", \"cases\": [\"passing-1\"]}], \"sinks\": [{\"caseId\": \"failing-1\", \"effect\": \"\", \"resources\": [], \"symbols\": [\"path#symbol\"]}], \"confidence\": 0.5, \"notes\": \"\", \"meaning\": {\"title\": \"\", \"observableOutcome\": \"\", \"failingExamples\": [{\"id\": \"failing-1\", \"text\": \"\"}], \"nonGoals\": []}}";
15
+ /** The brief plus the promise, as the prompt the agent receives. */
16
+ export declare function mapPrompt(promise: PromiseDocument): string;