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.
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Which could-not-tell verdicts a coding session has already been told.
3
+ *
4
+ * Robert, 30 September 2026: say a given could-not-tell once per session. The
5
+ * first time a session hears that a promise could not be judged, it hears it
6
+ * in full; later in the same session, for the same promise and the same
7
+ * reason, it hears one quiet line. A different reason, a new session, or a
8
+ * host that names no session is told in full. Kept and broken are never
9
+ * shortened.
10
+ *
11
+ * The record lives in the judge log directory, which carries its own
12
+ * `.gitignore`. It holds session ids, promise ids and a short gist of each
13
+ * reason, never the reason's text, and it forgets a session a day after it
14
+ * was last heard from.
15
+ */
16
+ export declare const SAID_FILE = "said.json";
17
+ export declare const SAID_SCHEMA_VERSION = "balladeer-judge-said/v1";
18
+ /** A session not heard from for this long is forgotten. */
19
+ export declare const SAID_KEEP_MS: number;
20
+ /** What of a verdict the record reads. */
21
+ export type SaidVerdict = Readonly<{
22
+ promiseId: string;
23
+ verdict: "kept" | "broken" | "could_not_tell";
24
+ notes: string;
25
+ reproductionRejected: string | null;
26
+ nextStep?: string;
27
+ }>;
28
+ /**
29
+ * The gist of why a promise could not be judged, stable across the different
30
+ * words two runs use for the same reason: a rejected reproduction by the
31
+ * runner's own sentence, the runner's other reasons by their fixed words,
32
+ * then what the tests needed and did not have, and otherwise a fingerprint of
33
+ * the judge's notes with numbers and hashes set aside.
34
+ */
35
+ export declare function reasonGist(verdict: SaidVerdict): string;
36
+ /** The one quiet line said instead of a could-not-tell this session already heard. */
37
+ export declare function quietLine(title: string, promiseId: string): string;
38
+ export type SessionMemory = Readonly<{
39
+ /**
40
+ * The quiet line for a could-not-tell this session was already told for the
41
+ * same promise and reason. Otherwise nothing, and the verdict is noted as
42
+ * told; it is then said in full by the caller.
43
+ */
44
+ repeat: (verdict: SaidVerdict, title: string) => string | undefined;
45
+ /** Keep what was told. A record that cannot be written is left as it was. */
46
+ save: () => void;
47
+ }>;
48
+ /**
49
+ * What this session has been told, from the record in `directory`, or nothing
50
+ * without a session id, in which case every verdict is said in full.
51
+ */
52
+ export declare function sessionMemory(directory: string, sessionId: string | undefined, now: Date): SessionMemory | undefined;
@@ -0,0 +1,181 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
3
+ import { join } from "node:path";
4
+ /**
5
+ * Which could-not-tell verdicts a coding session has already been told.
6
+ *
7
+ * Robert, 30 September 2026: say a given could-not-tell once per session. The
8
+ * first time a session hears that a promise could not be judged, it hears it
9
+ * in full; later in the same session, for the same promise and the same
10
+ * reason, it hears one quiet line. A different reason, a new session, or a
11
+ * host that names no session is told in full. Kept and broken are never
12
+ * shortened.
13
+ *
14
+ * The record lives in the judge log directory, which carries its own
15
+ * `.gitignore`. It holds session ids, promise ids and a short gist of each
16
+ * reason, never the reason's text, and it forgets a session a day after it
17
+ * was last heard from.
18
+ */
19
+ export const SAID_FILE = "said.json";
20
+ export const SAID_SCHEMA_VERSION = "balladeer-judge-said/v1";
21
+ /** A session not heard from for this long is forgotten. */
22
+ export const SAID_KEEP_MS = 24 * 60 * 60 * 1000;
23
+ const MOST_SESSIONS = 50;
24
+ const MOST_PER_SESSION = 200;
25
+ function fingerprint(text) {
26
+ const flat = text
27
+ .toLowerCase()
28
+ .replace(/\b[0-9a-f]{7,64}\b/g, "#")
29
+ .replace(/\d+/g, "#")
30
+ .replace(/\s+/g, " ")
31
+ .trim();
32
+ return createHash("sha256").update(flat).digest("hex").slice(0, 16);
33
+ }
34
+ /** Reasons the runner writes itself, in fixed words. */
35
+ const RUNNER_REASONS = [
36
+ ["unexercised", /^The judge answered kept without exercising ([^.]+)\./],
37
+ ["no-reproduction", /^The judge answered broken without a command that shows it\./],
38
+ ["no-answer", /^the judge's answer was not a verdict$/],
39
+ ];
40
+ /**
41
+ * What the tests needed and did not have, as a judge tends to say it in its
42
+ * own words, which differ from one run to the next. Checked in this order.
43
+ */
44
+ const NEEDS = [
45
+ ["database", /\b(databases?|postgres(ql)?|psql|mysql|mariadb|mongo(db)?|redis|sqlite)\b/],
46
+ ["browser", /\b(browsers?|playwright|puppeteer|chromium|selenium|webdriver|cypress)\b/],
47
+ [
48
+ "environment",
49
+ /\b(environment variables?|env vars?|api keys?|secrets?|credentials?|tokens?)\b|\.env\b/,
50
+ ],
51
+ [
52
+ "service",
53
+ /\b(services?|servers?|econnrefused|connection refused|unreachable|could not (connect|reach)|network|docker)\b/,
54
+ ],
55
+ [
56
+ "dependency",
57
+ /\b(not installed|missing dependenc(y|ies)|cannot find module|command not found|no such file)\b/,
58
+ ],
59
+ ["time", /\b(ran out|run out|running out|out of time|timed out|time limit|still working)\b/],
60
+ ];
61
+ /**
62
+ * The gist of why a promise could not be judged, stable across the different
63
+ * words two runs use for the same reason: a rejected reproduction by the
64
+ * runner's own sentence, the runner's other reasons by their fixed words,
65
+ * then what the tests needed and did not have, and otherwise a fingerprint of
66
+ * the judge's notes with numbers and hashes set aside.
67
+ */
68
+ export function reasonGist(verdict) {
69
+ if (verdict.reproductionRejected)
70
+ return `rejected:${fingerprint(verdict.reproductionRejected)}`;
71
+ for (const [gist, pattern] of RUNNER_REASONS) {
72
+ const found = pattern.exec(verdict.notes);
73
+ if (found !== null)
74
+ return found[1] === undefined ? gist : `${gist}:${found[1]}`;
75
+ }
76
+ const text = `${verdict.notes} ${verdict.nextStep ?? ""}`.toLowerCase();
77
+ for (const [gist, pattern] of NEEDS)
78
+ if (pattern.test(text))
79
+ return `needs:${gist}`;
80
+ return `notes:${fingerprint(verdict.notes)}`;
81
+ }
82
+ /** The one quiet line said instead of a could-not-tell this session already heard. */
83
+ export function quietLine(title, promiseId) {
84
+ return `could not tell again: ${title} (${promiseId}), for the reason given earlier.`;
85
+ }
86
+ function readRecord(path, now) {
87
+ const empty = { schemaVersion: SAID_SCHEMA_VERSION, sessions: {} };
88
+ let raw;
89
+ try {
90
+ if (!existsSync(path))
91
+ return empty;
92
+ raw = JSON.parse(readFileSync(path, "utf8"));
93
+ }
94
+ catch {
95
+ return empty;
96
+ }
97
+ if (raw === null || typeof raw !== "object")
98
+ return empty;
99
+ const sessions = raw.sessions;
100
+ if (raw.schemaVersion !== SAID_SCHEMA_VERSION ||
101
+ sessions === null ||
102
+ typeof sessions !== "object" ||
103
+ Array.isArray(sessions))
104
+ return empty;
105
+ const kept = {};
106
+ for (const [id, entry] of Object.entries(sessions)) {
107
+ const at = entry?.at;
108
+ const said = entry?.said;
109
+ if (typeof at !== "string" || !Array.isArray(said))
110
+ continue;
111
+ const time = Date.parse(at);
112
+ if (!Number.isFinite(time) || now.getTime() - time > SAID_KEEP_MS)
113
+ continue;
114
+ kept[id] = { at, said: said.filter((key) => typeof key === "string") };
115
+ }
116
+ return { schemaVersion: SAID_SCHEMA_VERSION, sessions: kept };
117
+ }
118
+ function writeRecord(directory, record) {
119
+ mkdirSync(directory, { recursive: true });
120
+ const ignore = join(directory, ".gitignore");
121
+ if (!existsSync(ignore))
122
+ writeFileSync(ignore, "*\n", "utf8");
123
+ const path = join(directory, SAID_FILE);
124
+ const temporary = join(directory, `.said-${randomBytes(6).toString("hex")}.tmp`);
125
+ writeFileSync(temporary, `${JSON.stringify(record)}\n`, { encoding: "utf8", flag: "wx" });
126
+ try {
127
+ renameSync(temporary, path);
128
+ }
129
+ catch (error) {
130
+ try {
131
+ unlinkSync(temporary);
132
+ }
133
+ catch {
134
+ /* Already gone. */
135
+ }
136
+ throw error;
137
+ }
138
+ }
139
+ /**
140
+ * What this session has been told, from the record in `directory`, or nothing
141
+ * without a session id, in which case every verdict is said in full.
142
+ */
143
+ export function sessionMemory(directory, sessionId, now) {
144
+ if (sessionId === undefined)
145
+ return undefined;
146
+ const record = readRecord(join(directory, SAID_FILE), now);
147
+ const said = new Set(record.sessions[sessionId]?.said ?? []);
148
+ let heard = false;
149
+ return {
150
+ repeat: (verdict, title) => {
151
+ if (verdict.verdict !== "could_not_tell")
152
+ return undefined;
153
+ heard = true;
154
+ const key = `${verdict.promiseId} ${reasonGist(verdict)}`;
155
+ if (said.has(key))
156
+ return quietLine(title, verdict.promiseId);
157
+ said.add(key);
158
+ return undefined;
159
+ },
160
+ save: () => {
161
+ if (!heard)
162
+ return;
163
+ record.sessions[sessionId] = {
164
+ at: now.toISOString(),
165
+ said: [...said].slice(-MOST_PER_SESSION),
166
+ };
167
+ const newest = Object.entries(record.sessions)
168
+ .sort(([, a], [, b]) => Date.parse(b.at) - Date.parse(a.at))
169
+ .slice(0, MOST_SESSIONS);
170
+ try {
171
+ writeRecord(directory, {
172
+ schemaVersion: SAID_SCHEMA_VERSION,
173
+ sessions: Object.fromEntries(newest),
174
+ });
175
+ }
176
+ catch {
177
+ /* Unwritable: the next time is said in full, which is the safe side. */
178
+ }
179
+ },
180
+ };
181
+ }
@@ -33,6 +33,14 @@ export type PromiseDocument = Readonly<{
33
33
  title: string;
34
34
  semanticDigest: string;
35
35
  meaning: PromiseMeaning;
36
+ /**
37
+ * Whether a Balladeer check is bound to this promise, as `get_promise`
38
+ * answered through this repository's connection. Absent means unknown: a
39
+ * promise read from a file or rebuilt from a map's excerpt never carries it,
40
+ * and neither does an answer whose field is missing or not a boolean. It is
41
+ * never handed to a judge, which works from the meaning alone.
42
+ */
43
+ verifierBound?: boolean;
36
44
  }>;
37
45
  /** Deterministic JSON with sorted keys, the form Balladeer digests a meaning in. */
38
46
  export declare function canonicalJson(value: unknown): string;
@@ -245,7 +245,15 @@ export function connectionReader(input) {
245
245
  return { ok: false, reason: olderServerRefusal(call.text) ?? call.text };
246
246
  if (call.kind !== "result")
247
247
  return { ok: false, reason: refusal(call.kind) };
248
- return normalizePromise(call.structured);
248
+ const read = normalizePromise(call.structured);
249
+ if (!read.ok)
250
+ return read;
251
+ // Only the connection's own answer says whether a check is bound;
252
+ // anything but a boolean there is left as unknown rather than guessed.
253
+ const bound = call.structured?.verifierBound;
254
+ return typeof bound === "boolean"
255
+ ? { ok: true, promise: { ...read.promise, verifierBound: bound } }
256
+ : read;
249
257
  },
250
258
  async list(options) {
251
259
  const max = options?.max ?? Number.POSITIVE_INFINITY;
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The relay: the person hears from their own agent what Balladeer did.
3
+ *
4
+ * Robert, 2026-09-30 (plans/promise-relations/rulings-2026-09-30.md, section
5
+ * 1): every action Balladeer takes is transparent to the person, and it is the
6
+ * person's own coding agent that tells them, in its own words and in one line,
7
+ * never by pasting what a tool returned. Agreed as promise
8
+ * prom_45ef4e3d9e5545a9a411.
9
+ *
10
+ * A Balladeer tool that changes something returns `forThePerson`: one sentence
11
+ * written from what actually happened, and the promise it concerns. Host hooks
12
+ * can make the relay closer to certain (setup installs only the first):
13
+ *
14
+ * - after the tool call, a note the agent reads before it replies: tell the
15
+ * person this, in your own words;
16
+ * - when the agent is about to finish, a check that its reply mentions the
17
+ * promise. If it does not, the agent is sent back once, with the reason.
18
+ * Never twice, and nothing here writes or rewrites the reply.
19
+ *
20
+ * Everything stays on this machine. Only the lab's stop check keeps pending
21
+ * notes, per session, in a private file it names; nothing from the reply is
22
+ * stored or sent.
23
+ */
24
+ export declare const RELAY_FIELD = "forThePerson";
25
+ export type Notice = Readonly<{
26
+ /** One sentence for the person, from what Balladeer actually did. */
27
+ sentence: string;
28
+ /** The promise titles the action concerns; the reply must name at least one. */
29
+ subjects: readonly string[];
30
+ }>;
31
+ /**
32
+ * The notice a Balladeer tool returned, read from whatever shape the host hands
33
+ * a hook: the MCP result object, its text content, or that text parsed.
34
+ */
35
+ export declare function noticeFromToolResponse(response: unknown): Notice | undefined;
36
+ /** What the agent reads after the tool call, before it replies. */
37
+ export declare function relayNote(notice: Notice): string;
38
+ /**
39
+ * Whether a reply mentions a promise: most of its title's words, in any order.
40
+ * Deliberately loose, because the agent is meant to use its own words; a miss
41
+ * costs one extra nudge, never a blocked reply.
42
+ */
43
+ export declare function mentions(reply: string, subject: string): boolean;
44
+ /** Whether the reply already tells the person about this notice. */
45
+ export declare function relayed(reply: string, notice: Notice): boolean;
46
+ /** What the stop check sends the agent back with. */
47
+ export declare function reminder(notices: readonly Notice[]): string;
48
+ export declare function readPending(directory: string, sessionId: string): Notice[];
49
+ export declare function writePending(directory: string, sessionId: string, notices: readonly Notice[]): void;
50
+ export type HookInput = Readonly<{
51
+ hook_event_name?: unknown;
52
+ session_id?: unknown;
53
+ tool_name?: unknown;
54
+ tool_response?: unknown;
55
+ tool_output?: unknown;
56
+ last_assistant_message?: unknown;
57
+ stop_hook_active?: unknown;
58
+ }>;
59
+ /** Only Balladeer's own tools are read, whatever the host names the server. */
60
+ export declare function isBalladeerTool(name: unknown): boolean;
61
+ /**
62
+ * One hook event, start to finish. The product installs only the post-tool
63
+ * note and passes no directory, so it keeps no state at all: across 135 lab
64
+ * sessions the stop check sent an agent back once, because agents always name
65
+ * the promise, so it was not installed (plans/promise-relations/plan.md, step
66
+ * 1 result). The relay lab still passes a directory to measure it.
67
+ *
68
+ * Returns what to print on stdout (the host's JSON), or nothing. Never throws:
69
+ * a relay that fails must not stop the work.
70
+ */
71
+ export declare function relayHook(input: HookInput, directory?: string): string | undefined;
package/dist/relay.js ADDED
@@ -0,0 +1,193 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ /**
5
+ * The relay: the person hears from their own agent what Balladeer did.
6
+ *
7
+ * Robert, 2026-09-30 (plans/promise-relations/rulings-2026-09-30.md, section
8
+ * 1): every action Balladeer takes is transparent to the person, and it is the
9
+ * person's own coding agent that tells them, in its own words and in one line,
10
+ * never by pasting what a tool returned. Agreed as promise
11
+ * prom_45ef4e3d9e5545a9a411.
12
+ *
13
+ * A Balladeer tool that changes something returns `forThePerson`: one sentence
14
+ * written from what actually happened, and the promise it concerns. Host hooks
15
+ * can make the relay closer to certain (setup installs only the first):
16
+ *
17
+ * - after the tool call, a note the agent reads before it replies: tell the
18
+ * person this, in your own words;
19
+ * - when the agent is about to finish, a check that its reply mentions the
20
+ * promise. If it does not, the agent is sent back once, with the reason.
21
+ * Never twice, and nothing here writes or rewrites the reply.
22
+ *
23
+ * Everything stays on this machine. Only the lab's stop check keeps pending
24
+ * notes, per session, in a private file it names; nothing from the reply is
25
+ * stored or sent.
26
+ */
27
+ export const RELAY_FIELD = "forThePerson";
28
+ const MAX_SENTENCE = 400;
29
+ const MAX_SUBJECT = 200;
30
+ const MAX_PENDING = 8;
31
+ function clean(value, limit) {
32
+ if (typeof value !== "string")
33
+ return undefined;
34
+ const text = value.replace(/[\u0000-\u001f\u007f]/g, " ").trim();
35
+ return text ? text.slice(0, limit) : undefined;
36
+ }
37
+ function noticeFrom(value) {
38
+ if (!value || typeof value !== "object")
39
+ return undefined;
40
+ const record = value;
41
+ const sentence = clean(record.sentence, MAX_SENTENCE);
42
+ if (!sentence)
43
+ return undefined;
44
+ const subjects = (Array.isArray(record.subjects) ? record.subjects : [])
45
+ .map((s) => clean(s, MAX_SUBJECT))
46
+ .filter((s) => s !== undefined)
47
+ .slice(0, 4);
48
+ return { sentence, subjects };
49
+ }
50
+ /**
51
+ * The notice a Balladeer tool returned, read from whatever shape the host hands
52
+ * a hook: the MCP result object, its text content, or that text parsed.
53
+ */
54
+ export function noticeFromToolResponse(response) {
55
+ const visit = (value, depth) => {
56
+ if (depth > 4 || value === null || value === undefined)
57
+ return undefined;
58
+ if (typeof value === "string") {
59
+ if (!value.includes(RELAY_FIELD))
60
+ return undefined;
61
+ try {
62
+ return visit(JSON.parse(value), depth + 1);
63
+ }
64
+ catch {
65
+ return undefined;
66
+ }
67
+ }
68
+ if (Array.isArray(value)) {
69
+ for (const item of value) {
70
+ const found = visit(item, depth + 1);
71
+ if (found)
72
+ return found;
73
+ }
74
+ return undefined;
75
+ }
76
+ if (typeof value !== "object")
77
+ return undefined;
78
+ const record = value;
79
+ if (RELAY_FIELD in record)
80
+ return noticeFrom(record[RELAY_FIELD]);
81
+ for (const key of ["structuredContent", "content", "text", "result", "tool_response"]) {
82
+ const found = visit(record[key], depth + 1);
83
+ if (found)
84
+ return found;
85
+ }
86
+ return undefined;
87
+ };
88
+ return visit(response, 0);
89
+ }
90
+ /** What the agent reads after the tool call, before it replies. */
91
+ export function relayNote(notice) {
92
+ return (`Balladeer just acted on the person's behalf: ${notice.sentence} ` +
93
+ "Make sure your reply tells them this, in your own words and in about one line.");
94
+ }
95
+ const WORD = /[a-z0-9]+/g;
96
+ const QUIET = new Set("a an the and or of to in on for with is are be by as at from that this it its not no".split(" "));
97
+ function words(text) {
98
+ return (text.toLowerCase().match(WORD) ?? []).filter((w) => w.length > 2 && !QUIET.has(w));
99
+ }
100
+ /**
101
+ * Whether a reply mentions a promise: most of its title's words, in any order.
102
+ * Deliberately loose, because the agent is meant to use its own words; a miss
103
+ * costs one extra nudge, never a blocked reply.
104
+ */
105
+ export function mentions(reply, subject) {
106
+ const wanted = [...new Set(words(subject))];
107
+ if (!wanted.length)
108
+ return true;
109
+ const said = new Set(words(reply));
110
+ const found = wanted.filter((w) => said.has(w) || said.has(w.replace(/s$/, ""))).length;
111
+ return found / wanted.length >= 0.5;
112
+ }
113
+ /** Whether the reply already tells the person about this notice. */
114
+ export function relayed(reply, notice) {
115
+ return notice.subjects.length === 0 || notice.subjects.some((s) => mentions(reply, s));
116
+ }
117
+ /** What the stop check sends the agent back with. */
118
+ export function reminder(notices) {
119
+ const told = notices.map((n) => n.sentence).join(" ");
120
+ return (`Your reply does not yet tell the person what Balladeer did: ${told} ` +
121
+ "Add it in your own words, in about one line.");
122
+ }
123
+ function pendingPath(directory, sessionId) {
124
+ return join(directory, `${createHash("sha256").update(sessionId).digest("hex")}.relay.json`);
125
+ }
126
+ export function readPending(directory, sessionId) {
127
+ try {
128
+ const parsed = JSON.parse(readFileSync(pendingPath(directory, sessionId), "utf8"));
129
+ return parsed.schemaVersion === 1 && Array.isArray(parsed.notices)
130
+ ? parsed.notices.map(noticeFrom).filter((n) => n !== undefined)
131
+ : [];
132
+ }
133
+ catch {
134
+ return [];
135
+ }
136
+ }
137
+ export function writePending(directory, sessionId, notices) {
138
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
139
+ const path = pendingPath(directory, sessionId);
140
+ const temporary = `${path}.${process.pid}.tmp`;
141
+ const body = { schemaVersion: 1, notices: notices.slice(-MAX_PENDING) };
142
+ writeFileSync(temporary, JSON.stringify(body), { mode: 0o600 });
143
+ renameSync(temporary, path);
144
+ }
145
+ /** Only Balladeer's own tools are read, whatever the host names the server. */
146
+ export function isBalladeerTool(name) {
147
+ return typeof name === "string" && /^mcp__balladeer[a-z0-9_-]*__/i.test(name);
148
+ }
149
+ /**
150
+ * One hook event, start to finish. The product installs only the post-tool
151
+ * note and passes no directory, so it keeps no state at all: across 135 lab
152
+ * sessions the stop check sent an agent back once, because agents always name
153
+ * the promise, so it was not installed (plans/promise-relations/plan.md, step
154
+ * 1 result). The relay lab still passes a directory to measure it.
155
+ *
156
+ * Returns what to print on stdout (the host's JSON), or nothing. Never throws:
157
+ * a relay that fails must not stop the work.
158
+ */
159
+ export function relayHook(input, directory) {
160
+ try {
161
+ const sessionId = typeof input.session_id === "string" ? input.session_id : undefined;
162
+ if (input.hook_event_name === "PostToolUse") {
163
+ if (!isBalladeerTool(input.tool_name))
164
+ return undefined;
165
+ const notice = noticeFromToolResponse(input.tool_response ?? input.tool_output);
166
+ if (!notice)
167
+ return undefined;
168
+ if (sessionId && directory)
169
+ writePending(directory, sessionId, [...readPending(directory, sessionId), notice]);
170
+ return JSON.stringify({
171
+ hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: relayNote(notice) },
172
+ });
173
+ }
174
+ if (input.hook_event_name === "Stop") {
175
+ if (!sessionId || !directory)
176
+ return undefined;
177
+ const pending = readPending(directory, sessionId);
178
+ if (!pending.length)
179
+ return undefined;
180
+ const reply = typeof input.last_assistant_message === "string" ? input.last_assistant_message : "";
181
+ const missing = pending.filter((n) => !relayed(reply, n));
182
+ // Relayed, or already sent back once: either way, this note is done.
183
+ writePending(directory, sessionId, []);
184
+ if (!missing.length || input.stop_hook_active === true)
185
+ return undefined;
186
+ return JSON.stringify({ decision: "block", reason: reminder(missing) });
187
+ }
188
+ return undefined;
189
+ }
190
+ catch {
191
+ return undefined;
192
+ }
193
+ }
@@ -99,10 +99,12 @@ function claudeConnector(home, controlPlane, now) {
99
99
  // path (1.0.11 and later). The July client's hook ran nothing of the kind, so
100
100
  // that shape is the line: a hook that names it is ours and is never "earlier".
101
101
  // Found on 22 September 2026: the installed-path form was being removed as
102
- // the older client on every setup run, leaving the laptop with no hook.
102
+ // the older client on every setup run, leaving the laptop with no hook. The
103
+ // push check setup writes beside it (`judge --hook claude|codex`, from 30
104
+ // September 2026) is ours by the same rule.
103
105
  const isEarlierHook = (command) => typeof command === "string" &&
104
106
  /balladeer/i.test(command) &&
105
- !/\bguidance --hook (?:claude|codex)\b/.test(command) &&
107
+ !/\b(?:guidance|judge) --hook (?:claude|codex)\b/.test(command) &&
106
108
  !/balladeer@(?:latest|\d+\.\d+\.\d+)\b/.test(command) &&
107
109
  !/\bpackages[/\\]cli[/\\]dist[/\\]cli\.js\b/.test(command);
108
110
  function claudeSettings(home, now) {
@@ -14,7 +14,7 @@ export { isTestPath, languageOf } from "./paths.js";
14
14
  export { assessChange, buildGraphAt, readBehaviorMaps, watchedResources, type AssessOptions, type Assessment, type MapProblem, } from "./pipeline.js";
15
15
  export { churnPriors, priorBreaksFromJudgeLog, readPriors } from "./priors.js";
16
16
  export { mentionsResource, normalizeRoute, parseResourceRef, resourceKey, resourcesInLine, resourcesMatch, routeFromPath, type ResourceRef, } from "./resources.js";
17
- export { BAND_THRESHOLDS, bandFor, byRisk, directness, forwardDecay, mapSeeds, reverseDecay, scoreChange, seededShuffle, WEIGHTS, type Prior, type ScoreOptions, type Weights, } from "./score.js";
17
+ export { BAND_THRESHOLDS, bandFor, byRisk, directness, forwardDecay, mapSeeds, reverseDecay, riskOrder, scoreChange, seededShuffle, WEIGHTS, type Prior, type RiskOrderKey, type ScoreOptions, type Weights, } from "./score.js";
18
18
  export { declaredSymbols, loadTypeScript, type DeclaredSymbol } from "./symbols.js";
19
19
  export { buildIdf, cosine, identifierTokens, STOP_WORDS, words } from "./text.js";
20
20
  export { parseChangeExtraction, parseRiskReport, type ParseResult } from "./validate.js";
@@ -14,7 +14,7 @@ export { isTestPath, languageOf } from "./paths.js";
14
14
  export { assessChange, buildGraphAt, readBehaviorMaps, watchedResources, } from "./pipeline.js";
15
15
  export { churnPriors, priorBreaksFromJudgeLog, readPriors } from "./priors.js";
16
16
  export { mentionsResource, normalizeRoute, parseResourceRef, resourceKey, resourcesInLine, resourcesMatch, routeFromPath, } from "./resources.js";
17
- export { BAND_THRESHOLDS, bandFor, byRisk, directness, forwardDecay, mapSeeds, reverseDecay, scoreChange, seededShuffle, WEIGHTS, } from "./score.js";
17
+ export { BAND_THRESHOLDS, bandFor, byRisk, directness, forwardDecay, mapSeeds, reverseDecay, riskOrder, scoreChange, seededShuffle, WEIGHTS, } from "./score.js";
18
18
  export { declaredSymbols, loadTypeScript } from "./symbols.js";
19
19
  export { buildIdf, cosine, identifierTokens, STOP_WORDS, words } from "./text.js";
20
20
  export { parseChangeExtraction, parseRiskReport } from "./validate.js";
@@ -65,6 +65,16 @@ export declare function directness(features: PromiseRisk["features"]): 0 | 1 | 2
65
65
  * is a tier and not a larger number. Scores and bands are unchanged.
66
66
  */
67
67
  export declare function byRisk(a: PromiseRisk, b: PromiseRisk): number;
68
+ /** What the order reads from one entry: enough to order a report read back from its JSON. */
69
+ export type RiskOrderKey = Readonly<{
70
+ promiseId: string;
71
+ score: number;
72
+ features: PromiseRisk["features"];
73
+ /** The total weight of the entry's reasons. */
74
+ evidence: number;
75
+ }>;
76
+ /** The order `byRisk` describes, on the fields it reads, so the judge ranks by the same rule. */
77
+ export declare function riskOrder(a: RiskOrderKey, b: RiskOrderKey): number;
68
78
  /** The files the promise's behavior runs through, where the import closures start. Tests excluded. */
69
79
  export declare function mapSeeds(map: BehaviorMap): string[];
70
80
  /** A seeded shuffle, so the random baseline is the same list every time for the same change. */
@@ -95,10 +95,19 @@ export function directness(features) {
95
95
  * is a tier and not a larger number. Scores and bands are unchanged.
96
96
  */
97
97
  export function byRisk(a, b) {
98
- const evidence = (p) => p.reasons.reduce((sum, reason) => sum + reason.weight, 0);
98
+ const key = (p) => ({
99
+ promiseId: p.promiseId,
100
+ score: p.score,
101
+ features: p.features,
102
+ evidence: p.reasons.reduce((sum, reason) => sum + reason.weight, 0),
103
+ });
104
+ return riskOrder(key(a), key(b));
105
+ }
106
+ /** The order `byRisk` describes, on the fields it reads, so the judge ranks by the same rule. */
107
+ export function riskOrder(a, b) {
99
108
  return (directness(b.features) - directness(a.features) ||
100
109
  b.score - a.score ||
101
- evidence(b) - evidence(a) ||
110
+ b.evidence - a.evidence ||
102
111
  a.promiseId.localeCompare(b.promiseId));
103
112
  }
104
113
  /** Independent signals combined so that none can push past 1 and more never lowers it. */
@@ -48,4 +48,8 @@ export type CarriedFile = Readonly<{
48
48
  * a reproduction in a new file for that reason.
49
49
  */
50
50
  export declare function newFiles(worktree: string): Promise<CarriedFile[]>;
51
- export declare function carryFiles(from: string, to: string, files: readonly CarriedFile[]): void;
51
+ /**
52
+ * Copy files from one directory into another at the same relative paths, and
53
+ * return the ones copied. A path that would land outside `to` is skipped.
54
+ */
55
+ export declare function carryFiles(from: string, to: string, files: readonly CarriedFile[]): CarriedFile[];
@@ -2,7 +2,7 @@ import { execFileSync } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
3
  import { copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
- import { dirname, join, relative, resolve } from "node:path";
5
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
6
6
  import { runCommand } from "./gh.js";
7
7
  /** Every worktree this process made and has not removed, for a signal handler to clear. */
8
8
  const live = new Map();
@@ -141,13 +141,20 @@ export async function newFiles(worktree) {
141
141
  }
142
142
  return files.sort((a, b) => a.path.localeCompare(b.path));
143
143
  }
144
+ /**
145
+ * Copy files from one directory into another at the same relative paths, and
146
+ * return the ones copied. A path that would land outside `to` is skipped.
147
+ */
144
148
  export function carryFiles(from, to, files) {
149
+ const carried = [];
145
150
  for (const file of files) {
146
151
  const target = resolve(to, file.path);
147
152
  const inside = relative(to, target);
148
- if (inside.startsWith("..") || inside === "")
153
+ if (inside.startsWith("..") || inside === "" || isAbsolute(inside))
149
154
  continue;
150
155
  mkdirSync(dirname(target), { recursive: true });
151
156
  copyFileSync(join(from, file.path), target);
157
+ carried.push(file);
152
158
  }
159
+ return carried;
153
160
  }