balladeer 1.0.19 → 1.0.21

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 (44) hide show
  1. package/README.md +18 -0
  2. package/dist/anchoring.js +2 -2
  3. package/dist/anchors-schema.d.ts +3 -1
  4. package/dist/anchors-schema.js +5 -3
  5. package/dist/commands/judge.d.ts +50 -1
  6. package/dist/commands/judge.js +768 -110
  7. package/dist/commands/mcp.d.ts +4 -0
  8. package/dist/commands/mcp.js +24 -1
  9. package/dist/currency.js +9 -3
  10. package/dist/guidance-hook.mjs +13 -3
  11. package/dist/headless-agent.d.ts +2 -0
  12. package/dist/headless-agent.js +5 -1
  13. package/dist/judge-brief.d.ts +11 -5
  14. package/dist/judge-brief.js +26 -10
  15. package/dist/judge-evidence.d.ts +87 -0
  16. package/dist/judge-evidence.js +393 -0
  17. package/dist/judge-hook.d.ts +14 -1
  18. package/dist/judge-hook.js +34 -5
  19. package/dist/judge-review.d.ts +10 -0
  20. package/dist/judge-review.js +36 -0
  21. package/dist/judge-said.d.ts +1 -0
  22. package/dist/judge-said.js +3 -0
  23. package/dist/promise-meaning.d.ts +4 -0
  24. package/dist/promise-review-client.d.ts +84 -0
  25. package/dist/promise-review-client.js +357 -0
  26. package/dist/push-operation.d.ts +61 -0
  27. package/dist/push-operation.js +472 -0
  28. package/dist/read-only-review.d.ts +2 -0
  29. package/dist/read-only-review.js +2 -0
  30. package/dist/review-backstop.d.ts +20 -0
  31. package/dist/review-backstop.js +54 -0
  32. package/dist/review-reporting.d.ts +32 -0
  33. package/dist/review-reporting.js +319 -0
  34. package/dist/review-selection.d.ts +33 -0
  35. package/dist/review-selection.js +64 -0
  36. package/dist/review-settings.d.ts +6 -0
  37. package/dist/review-settings.js +9 -0
  38. package/dist/self-update.d.ts +9 -0
  39. package/dist/self-update.js +35 -1
  40. package/dist/user-scope.d.ts +1 -10
  41. package/dist/user-scope.js +12 -7
  42. package/dist/wire.d.ts +3 -3
  43. package/dist/wire.js +1 -1
  44. package/package.json +1 -1
@@ -0,0 +1,319 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { runCommand } from "./gh.js";
5
+ import { agentEndpoint } from "./guidance.js";
6
+ import { connectionAgent } from "./promise-meaning.js";
7
+ import { CLIENT_HEADER, CLIENT_HEADER_VALUE } from "./wire.js";
8
+ const SHA = /^[0-9a-f]{40}$/;
9
+ const DIGEST = /^sha256:[0-9a-f]{64}$/;
10
+ const REF = /^refs\/heads\/[A-Za-z0-9._/-]{1,244}$/;
11
+ const REASONS = new Set([
12
+ "test_setup_needed",
13
+ "cases_not_checked",
14
+ "reproduction_not_confirmed",
15
+ "review_inconclusive",
16
+ ]);
17
+ const FIELDS = new Set([
18
+ "promiseId",
19
+ "semanticDigest",
20
+ "sourceSha",
21
+ "baseSha",
22
+ "verdict",
23
+ "reproductionDigest",
24
+ "briefRevision",
25
+ "client",
26
+ "sessionId",
27
+ "changeRef",
28
+ "reasonCode",
29
+ "supersedes",
30
+ ]);
31
+ /** Validate the exact upload shape again, including retries of local files. */
32
+ export function isReviewPayload(value) {
33
+ if (value === null || typeof value !== "object" || Array.isArray(value))
34
+ return false;
35
+ const p = value;
36
+ return (Object.keys(p).every((k) => FIELDS.has(k)) &&
37
+ typeof p.promiseId === "string" &&
38
+ /^prom_[A-Za-z0-9_-]{1,120}$/.test(p.promiseId) &&
39
+ typeof p.semanticDigest === "string" &&
40
+ DIGEST.test(p.semanticDigest) &&
41
+ typeof p.sourceSha === "string" &&
42
+ SHA.test(p.sourceSha) &&
43
+ typeof p.baseSha === "string" &&
44
+ SHA.test(p.baseSha) &&
45
+ p.sourceSha !== p.baseSha &&
46
+ typeof p.verdict === "string" &&
47
+ ["kept", "broken", "could_not_tell"].includes(p.verdict) &&
48
+ typeof p.briefRevision === "string" &&
49
+ /^[a-z][a-z0-9-]{0,47}\/v[1-9][0-9]{0,5}$/.test(p.briefRevision) &&
50
+ typeof p.client === "string" &&
51
+ ["claude", "codex", "git", "other"].includes(p.client) &&
52
+ (p.reproductionDigest === undefined ||
53
+ (typeof p.reproductionDigest === "string" && DIGEST.test(p.reproductionDigest))) &&
54
+ (p.verdict !== "broken" || p.reproductionDigest !== undefined) &&
55
+ (p.sessionId === undefined ||
56
+ (typeof p.sessionId === "string" && /^bs_[0-9a-f]{32}$/.test(p.sessionId))) &&
57
+ (p.changeRef === undefined || (typeof p.changeRef === "string" && REF.test(p.changeRef))) &&
58
+ (p.reasonCode === undefined ||
59
+ (typeof p.reasonCode === "string" && REASONS.has(p.reasonCode))) &&
60
+ (p.supersedes === undefined ||
61
+ (Array.isArray(p.supersedes) &&
62
+ p.supersedes.length <= 64 &&
63
+ p.supersedes.every((v) => typeof v === "string" && SHA.test(v)))));
64
+ }
65
+ /** Validate what serialization produced, then reuse those exact upload bytes. */
66
+ function snapshot(value) {
67
+ try {
68
+ if (!isReviewPayload(value))
69
+ return;
70
+ const body = JSON.stringify(value);
71
+ if (Buffer.byteLength(body, "utf8") > 8192)
72
+ return;
73
+ const payload = JSON.parse(body);
74
+ return isReviewPayload(payload) ? { payload, body } : undefined;
75
+ }
76
+ catch {
77
+ return undefined;
78
+ }
79
+ }
80
+ export function reviewPayload(verdict, options = {}) {
81
+ return {
82
+ promiseId: verdict.promiseId,
83
+ semanticDigest: verdict.semanticDigest,
84
+ sourceSha: verdict.head,
85
+ baseSha: verdict.base,
86
+ verdict: verdict.verdict,
87
+ briefRevision: verdict.briefRevision,
88
+ client: options.client ?? verdict.judge.client,
89
+ ...(verdict.verdict === "broken" && verdict.reproduction
90
+ ? { reproductionDigest: verdict.reproduction.digest }
91
+ : {}),
92
+ ...(options.changeRef ? { changeRef: options.changeRef } : {}),
93
+ ...(options.sessionId ? { sessionId: options.sessionId } : {}),
94
+ ...(verdict.verdict !== "could_not_tell"
95
+ ? {}
96
+ : {
97
+ reasonCode: verdict.nextStep
98
+ ? "test_setup_needed"
99
+ : verdict.reproductionRejected
100
+ ? "reproduction_not_confirmed"
101
+ : verdict.notes.startsWith("The judge answered kept without exercising")
102
+ ? "cases_not_checked"
103
+ : "review_inconclusive",
104
+ }),
105
+ };
106
+ }
107
+ function atomic(path, value) {
108
+ const temporary = `${path}.${process.pid}.tmp`;
109
+ writeFileSync(temporary, JSON.stringify(value), { mode: 0o600 });
110
+ renameSync(temporary, path);
111
+ }
112
+ function read(path) {
113
+ try {
114
+ const text = readFileSync(path, "utf8");
115
+ if (text.length > 8192)
116
+ return;
117
+ const value = JSON.parse(text);
118
+ return isReviewPayload(value) ? value : undefined;
119
+ }
120
+ catch {
121
+ return undefined;
122
+ }
123
+ }
124
+ export function reporterFor(agent, fetchImpl = fetch) {
125
+ return async (payload) => {
126
+ const captured = snapshot(payload);
127
+ if (captured === undefined)
128
+ return 400;
129
+ try {
130
+ const response = await fetchImpl(agentEndpoint(agent, "/api/promise-checks"), {
131
+ method: "POST",
132
+ redirect: "manual",
133
+ signal: AbortSignal.timeout(3000),
134
+ headers: {
135
+ authorization: `Bearer ${agent.token}`,
136
+ [CLIENT_HEADER]: CLIENT_HEADER_VALUE,
137
+ "content-type": "application/json",
138
+ },
139
+ body: captured.body,
140
+ });
141
+ if (response.status !== 200 && response.status !== 201) {
142
+ await response.body?.cancel();
143
+ return response.status;
144
+ }
145
+ // An HTTP success alone is not a receipt for this particular review.
146
+ const reader = response.body?.getReader();
147
+ if (!reader)
148
+ return 503;
149
+ let text = "";
150
+ for (;;) {
151
+ const part = await reader.read();
152
+ if (part.done)
153
+ break;
154
+ text += new TextDecoder().decode(part.value);
155
+ if (text.length > 8192) {
156
+ await reader.cancel();
157
+ return 503;
158
+ }
159
+ }
160
+ const receipt = JSON.parse(text);
161
+ return receipt.recorded === true &&
162
+ receipt.promiseId === captured.payload.promiseId &&
163
+ receipt.sourceSha === captured.payload.sourceSha &&
164
+ receipt.verdict === captured.payload.verdict &&
165
+ receipt.protectionChanged === false
166
+ ? response.status
167
+ : 503;
168
+ }
169
+ catch {
170
+ return 503;
171
+ }
172
+ };
173
+ }
174
+ /** Durable, idempotent retries, partitioned by server/workspace/repository. */
175
+ export async function deliverReviews(input) {
176
+ if (input.send === null)
177
+ return;
178
+ const found = connectionAgent({
179
+ environment: input.environment,
180
+ controlPlane: input.controlPlane,
181
+ cwd: input.root,
182
+ });
183
+ if (!found.ok && input.send === undefined)
184
+ return;
185
+ const partition = found.ok
186
+ ? `${found.agent.controlPlane}:${found.agent.workspaceId}:${found.agent.repositoryId}`
187
+ : "test";
188
+ const directory = join(input.root, ".continuity/judge-log/reporting", createHash("sha256").update(partition).digest("hex"));
189
+ const pending = join(directory, "pending"), sent = join(directory, "sent");
190
+ mkdirSync(pending, { recursive: true });
191
+ mkdirSync(sent, { recursive: true });
192
+ const refResult = await runCommand("git", ["-C", input.root, "symbolic-ref", "-q", "HEAD"]);
193
+ const currentHead = await runCommand("git", ["-C", input.root, "rev-parse", "HEAD"]);
194
+ const changeRef = REF.test(refResult.stdout.trim()) ? refResult.stdout.trim() : undefined;
195
+ for (const original of input.payloads) {
196
+ const captured = snapshot(original);
197
+ if (captured === undefined)
198
+ continue;
199
+ const frozen = captured.payload;
200
+ const payload = {
201
+ ...frozen,
202
+ ...(frozen.changeRef || frozen.sourceSha !== currentHead.stdout.trim()
203
+ ? {}
204
+ : changeRef
205
+ ? { changeRef }
206
+ : {}),
207
+ };
208
+ if (!isReviewPayload(payload))
209
+ continue;
210
+ const key = createHash("sha256")
211
+ .update([
212
+ payload.promiseId,
213
+ payload.semanticDigest,
214
+ payload.sourceSha,
215
+ payload.verdict,
216
+ payload.changeRef ?? "",
217
+ ].join(":"))
218
+ .digest("hex") + ".json";
219
+ if (existsSync(join(sent, key)) || existsSync(join(pending, key)))
220
+ continue;
221
+ const historyKey = createHash("sha256")
222
+ .update([payload.promiseId, payload.semanticDigest, payload.changeRef ?? ""].join(":"))
223
+ .digest("hex") + ".heads";
224
+ let previous = [];
225
+ try {
226
+ const value = JSON.parse(readFileSync(join(sent, historyKey), "utf8"));
227
+ if (Array.isArray(value))
228
+ previous = value
229
+ .filter((sha) => typeof sha === "string" && SHA.test(sha))
230
+ .slice(-64);
231
+ }
232
+ catch {
233
+ /* First report of this meaning and branch. */
234
+ }
235
+ // A later fixed change may be queued while an earlier failure is still
236
+ // offline. Preserve its ancestry before either receipt reaches the server.
237
+ for (const name of readdirSync(pending)
238
+ .filter((name) => /^[0-9a-f]{64}\.json$/.test(name))
239
+ .slice(-64)) {
240
+ const queued = read(join(pending, name));
241
+ if (queued &&
242
+ queued.promiseId === payload.promiseId &&
243
+ queued.semanticDigest === payload.semanticDigest &&
244
+ queued.changeRef === payload.changeRef)
245
+ previous.push(queued.sourceSha);
246
+ }
247
+ previous = [...new Set(previous)].slice(-64);
248
+ const supersedes = [];
249
+ for (const sha of previous) {
250
+ if (sha === payload.sourceSha)
251
+ continue;
252
+ const ancestor = await runCommand("git", [
253
+ "-C",
254
+ input.root,
255
+ "merge-base",
256
+ "--is-ancestor",
257
+ sha,
258
+ payload.sourceSha,
259
+ ]);
260
+ if (ancestor.code === 0)
261
+ supersedes.push(sha);
262
+ }
263
+ if (supersedes.length)
264
+ payload.supersedes = supersedes;
265
+ atomic(join(pending, key), payload);
266
+ }
267
+ const send = input.send ?? (found.ok ? reporterFor(found.agent) : undefined);
268
+ if (!send)
269
+ return;
270
+ let failures = 0, refused = 0;
271
+ const deadline = Date.now() + 5000;
272
+ // Stop on a transient failure to preserve delivery order; retry next push.
273
+ for (const name of readdirSync(pending)
274
+ .filter((name) => /^[0-9a-f]{64}\.json$/.test(name))
275
+ .sort((a, b) => statSync(join(pending, a)).mtimeMs - statSync(join(pending, b)).mtimeMs ||
276
+ a.localeCompare(b))
277
+ .slice(0, 64)) {
278
+ if (Date.now() >= deadline)
279
+ break;
280
+ const path = join(pending, name), payload = read(path);
281
+ if (!payload) {
282
+ refused++;
283
+ renameSync(path, `${path}.refused`);
284
+ continue;
285
+ }
286
+ const status = await send(payload).catch(() => 503);
287
+ if (status === 200 || status === 201) {
288
+ atomic(join(sent, name), payload);
289
+ const historyKey = createHash("sha256")
290
+ .update([payload.promiseId, payload.semanticDigest, payload.changeRef ?? ""].join(":"))
291
+ .digest("hex") + ".heads";
292
+ let heads = [];
293
+ try {
294
+ const value = JSON.parse(readFileSync(join(sent, historyKey), "utf8"));
295
+ if (Array.isArray(value))
296
+ heads = value.filter((sha) => typeof sha === "string" && SHA.test(sha));
297
+ }
298
+ catch {
299
+ /* First delivery. */
300
+ }
301
+ atomic(join(sent, historyKey), [...new Set([...heads, payload.sourceSha])].slice(-64));
302
+ rmSync(path);
303
+ }
304
+ else if (status === 400 || status === 404 || status === 409 || status === 410) {
305
+ refused++;
306
+ renameSync(path, `${path}.refused`);
307
+ }
308
+ else {
309
+ failures++;
310
+ break;
311
+ }
312
+ }
313
+ const waiting = readdirSync(pending).filter((name) => name.endsWith(".json")).length;
314
+ if (failures || waiting)
315
+ return `Review results are kept on this machine for retry; ${waiting} have not reached Balladeer yet.`;
316
+ if (refused)
317
+ return "Balladeer could not accept some review results. They stay on this machine; read the current commitment and review again.";
318
+ return undefined;
319
+ }
@@ -0,0 +1,33 @@
1
+ import type { Features } from "./risk/contract.js";
2
+ /** A challenger selector. The direct baseline remains the serving default. */
3
+ export type ReviewRiskEntry = Readonly<{
4
+ promiseId: string;
5
+ score: number;
6
+ band: string;
7
+ features?: Features;
8
+ evidence?: number;
9
+ }>;
10
+ export type TargetedRiskCandidate = Readonly<{
11
+ promiseId: string;
12
+ signals: readonly ("resource" | "sink" | "intent")[];
13
+ }>;
14
+ /**
15
+ * Strong indirect evidence deserves measurement separately from graph distance.
16
+ * A high score alone is insufficient: weak topic/graph signals can saturate it.
17
+ * These additions have tier-2 resource, sink, or reversal evidence, and no cap
18
+ * can quietly drop one. Nothing here enables them in the push-check default.
19
+ */
20
+ export declare function targetedRiskCandidates(entries: readonly ReviewRiskEntry[], excluded?: ReadonlySet<string>): TargetedRiskCandidate[];
21
+ /** Only repository-visible, already authorized targets reach a local backstop. */
22
+ export declare function remainingVisibleTargets(visible: readonly string[], selected: ReadonlySet<string>): string[];
23
+ /**
24
+ * Parse a local model's bounded selection. It can choose only offered ids;
25
+ * malformed output is Unknown, never an authoritative "nothing applies".
26
+ */
27
+ export declare function readRelevanceBackstopSelection(value: unknown, candidates: ReadonlySet<string>): {
28
+ ok: true;
29
+ promiseIds: string[];
30
+ } | {
31
+ ok: false;
32
+ reason: string;
33
+ };
@@ -0,0 +1,64 @@
1
+ import { BAND_THRESHOLDS, directness, riskOrder } from "./risk/score.js";
2
+ /**
3
+ * Strong indirect evidence deserves measurement separately from graph distance.
4
+ * A high score alone is insufficient: weak topic/graph signals can saturate it.
5
+ * These additions have tier-2 resource, sink, or reversal evidence, and no cap
6
+ * can quietly drop one. Nothing here enables them in the push-check default.
7
+ */
8
+ export function targetedRiskCandidates(entries, excluded = new Set()) {
9
+ const eligible = entries
10
+ .filter((entry) => {
11
+ const f = entry.features;
12
+ return (!excluded.has(entry.promiseId) &&
13
+ entry.band !== "unmapped" &&
14
+ Number.isFinite(entry.score) &&
15
+ entry.score >= BAND_THRESHOLDS.high &&
16
+ f !== undefined &&
17
+ directness(f) === 2 &&
18
+ ((Number.isFinite(f.resource_overlap) && f.resource_overlap > 0) ||
19
+ (Number.isFinite(f.sink_reach) && f.sink_reach > 0) ||
20
+ (Number.isFinite(f.intent_clash) && f.intent_clash >= 0.5)));
21
+ })
22
+ .sort((a, b) => riskOrder({
23
+ promiseId: a.promiseId,
24
+ score: a.score,
25
+ features: a.features,
26
+ evidence: a.evidence ?? 0,
27
+ }, {
28
+ promiseId: b.promiseId,
29
+ score: b.score,
30
+ features: b.features,
31
+ evidence: b.evidence ?? 0,
32
+ }));
33
+ const seen = new Set();
34
+ return eligible.flatMap((entry) => {
35
+ if (seen.has(entry.promiseId))
36
+ return [];
37
+ seen.add(entry.promiseId);
38
+ const f = entry.features;
39
+ const signals = [];
40
+ if (Number.isFinite(f.resource_overlap) && f.resource_overlap > 0)
41
+ signals.push("resource");
42
+ if (Number.isFinite(f.sink_reach) && f.sink_reach > 0)
43
+ signals.push("sink");
44
+ if (Number.isFinite(f.intent_clash) && f.intent_clash >= 0.5)
45
+ signals.push("intent");
46
+ return [{ promiseId: entry.promiseId, signals }];
47
+ });
48
+ }
49
+ /** Only repository-visible, already authorized targets reach a local backstop. */
50
+ export function remainingVisibleTargets(visible, selected) {
51
+ return [...new Set(visible)].filter((id) => !selected.has(id));
52
+ }
53
+ /**
54
+ * Parse a local model's bounded selection. It can choose only offered ids;
55
+ * malformed output is Unknown, never an authoritative "nothing applies".
56
+ */
57
+ export function readRelevanceBackstopSelection(value, candidates) {
58
+ if (value === null || typeof value !== "object" || Array.isArray(value))
59
+ return { ok: false, reason: "the relevance backstop did not return a selection" };
60
+ const ids = value.promiseIds;
61
+ if (!Array.isArray(ids) || ids.some((id) => typeof id !== "string" || !candidates.has(id)))
62
+ return { ok: false, reason: "the relevance backstop selected a promise it was not offered" };
63
+ return { ok: true, promiseIds: [...new Set(ids)] };
64
+ }
@@ -0,0 +1,6 @@
1
+ /** Wire values from app Settings. Keep this dependency-free for the installed CLI. */
2
+ export type ReviewSettings = Readonly<{
3
+ concurrency: number;
4
+ budgetSeconds: number;
5
+ }>;
6
+ export declare function readReviewSettings(raw: unknown): ReviewSettings | undefined;
@@ -0,0 +1,9 @@
1
+ export function readReviewSettings(raw) {
2
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw))
3
+ return undefined;
4
+ const value = raw;
5
+ const whole = (n, max) => typeof n === "number" && Number.isInteger(n) && n >= 1 && n <= max;
6
+ return whole(value.concurrency, 16) && whole(value.budgetSeconds, 600)
7
+ ? { concurrency: value.concurrency, budgetSeconds: value.budgetSeconds }
8
+ : undefined;
9
+ }
@@ -16,6 +16,15 @@ export type SelfUpdateDeps = Readonly<{
16
16
  }>;
17
17
  /** A short, closed vocabulary: it travels in a header and lands in a column. */
18
18
  export type SelfUpdateState = `started:${string}` | `unstartable:${string}` | `installed:${string}` | `failed:${string}`;
19
+ /**
20
+ * The exact release to ask npx for, or nothing when there is none to ask for.
21
+ *
22
+ * Exact, because a range is answered from whatever copy is already on the
23
+ * laptop (see above). Only a release on this copy's own major, so a 2.x stays a
24
+ * choice a person makes, and only one newer than this copy, so a server that
25
+ * names an older number cannot walk a laptop backwards.
26
+ */
27
+ export declare function updateSpecifier(newer: string): string | undefined;
19
28
  /** What the last attempt came to, for the next hook fire to report. Never throws. */
20
29
  export declare function recordSelfUpdateState(environment: NodeJS.ProcessEnv, state: SelfUpdateState, at: number): void;
21
30
  /** The recorded state, or nothing when none was written or it is not one of ours. */
@@ -4,6 +4,7 @@ import { delimiter, dirname, join } from "node:path";
4
4
  import { older } from "./currency.js";
5
5
  import { HOOK_SPECIFIER } from "./release.js";
6
6
  import { configHome } from "./store.js";
7
+ import { CLI_VERSION } from "./wire.js";
7
8
  /**
8
9
  * Bringing the durable command current without the session ever waiting.
9
10
  *
@@ -35,6 +36,18 @@ import { configHome } from "./store.js";
35
36
  * 1.0.15. Now any guidance hook fire can begin the install, and a release
36
37
  * newer than the one the last attempt aimed at may begin one an hour after
37
38
  * it, rather than a day.
39
+ *
40
+ * What changed on 14 October 2026, from Didero's laptops and Robert's again:
41
+ * seven laptops sat on 1.0.12 to 1.0.14 for two weeks, and Robert's on 1.0.19
42
+ * with an attempt toward 1.0.20 "started" that morning. Every attempt had
43
+ * asked npx for `balladeer@1`, a range. npx runs any copy already on the
44
+ * laptop that satisfies a range without asking the registry: the durable
45
+ * command itself when it sits in npm's global folder, or whatever the first
46
+ * attempt left in npx's own cache. That copy's installer installs its own
47
+ * version and refuses to go down, so every attempt after the first changed
48
+ * nothing, silently, once a day. Now the attempt names the exact release the
49
+ * server announced, which npx cannot satisfy from an older copy, and the
50
+ * installer that runs is the one being installed.
38
51
  */
39
52
  const STAMP = "self-update.json";
40
53
  const STATE = "self-update-state.json";
@@ -79,6 +92,24 @@ function due(newer, last, now) {
79
92
  const newerTarget = last.toward === undefined || older(last.toward, newer);
80
93
  return newerTarget && since >= HOUR_MS;
81
94
  }
95
+ /**
96
+ * The exact release to ask npx for, or nothing when there is none to ask for.
97
+ *
98
+ * Exact, because a range is answered from whatever copy is already on the
99
+ * laptop (see above). Only a release on this copy's own major, so a 2.x stays a
100
+ * choice a person makes, and only one newer than this copy, so a server that
101
+ * names an older number cannot walk a laptop backwards.
102
+ */
103
+ export function updateSpecifier(newer) {
104
+ if (!VERSION.test(newer))
105
+ return undefined;
106
+ const [name, major] = HOOK_SPECIFIER.split("@");
107
+ if (newer.split(".")[0] !== major)
108
+ return undefined;
109
+ if (!older(CLI_VERSION, newer))
110
+ return undefined;
111
+ return `${name}@${newer}`;
112
+ }
82
113
  function writePrivate(path, value, environment) {
83
114
  mkdirSync(configHome(environment), { recursive: true, mode: 0o700 });
84
115
  writeFileSync(path, JSON.stringify(value) + "\n", { mode: 0o600 });
@@ -148,10 +179,13 @@ function detached(command, args, environment) {
148
179
  export function startSelfUpdateIfDue(newer, deps) {
149
180
  if (newer === undefined)
150
181
  return false;
182
+ const specifier = updateSpecifier(newer);
183
+ if (specifier === undefined)
184
+ return false;
151
185
  const now = deps.now ?? Date.now;
152
186
  if (!due(newer, lastAttempt(deps.environment), now()))
153
187
  return false;
154
- const launch = resolveLaunch(HOOK_SPECIFIER, deps.environment, deps.execPath, deps.exists);
188
+ const launch = resolveLaunch(specifier, deps.environment, deps.execPath, deps.exists);
155
189
  let started;
156
190
  try {
157
191
  started = (deps.run ?? detached)(launch.command, launch.args, launch.environment) !== false;
@@ -41,16 +41,7 @@ export type PushCheckState = "on" | "off" | "unavailable";
41
41
  export declare function pushCheckState(environment: NodeJS.ProcessEnv, published: boolean, installed?: string): PushCheckState;
42
42
  /** Setup's one line about the push check. */
43
43
  export declare function pushCheckSetupLine(state: PushCheckState): string;
44
- /**
45
- * What the push check's entry asks grep before Node starts: whether the
46
- * payload's `"command"`, read as JSON (a string, or a list of words as Codex
47
- * can send it), so much as mentions `push`, `create` or `merge`. Every line
48
- * the judge reads as a push (`git push`, `gh pr create`, `gh pr merge`) spells
49
- * one of them out, so none is dropped, and a folder or an output that says
50
- * "push" wakes nothing. A word split by quotes (`pu''sh`) is the one spelling
51
- * the judge reads as a push and this does not.
52
- */
53
- export declare const PUSH_CHECK_FILTER = "\"command\" *: *(\\[( *\"([^\"\\\\]|\\\\.)*\" *,)* *)?\"([^\"\\\\]|\\\\.)*(push|create|merge)";
44
+ export declare const PUSH_CHECK_FILTER: string;
54
45
  /**
55
46
  * The push check's entry as the host runs it. It runs on every shell command
56
47
  * an agent runs, before and after, so a POSIX shell looks first and starts
@@ -3,7 +3,7 @@ import { existsSync, lstatSync, mkdirSync, readFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
5
5
  import { parse } from "@iarna/toml";
6
- import { JUDGE_HOOK_EVENTS, JUDGE_HOOK_TIMEOUT_SECONDS, pushCheckOff } from "./judge-hook.js";
6
+ import { JUDGE_HOOK_EVENTS, JUDGE_HOOK_TIMEOUT_SECONDS, judgeHookEvents, pushCheckOff, } from "./judge-hook.js";
7
7
  import { HOOK_SPECIFIER, checkoutEntryPath } from "./release.js";
8
8
  import { writeJsonAtomically } from "./mcp-config.js";
9
9
  /**
@@ -67,7 +67,7 @@ export function pushCheckSetupLine(state) {
67
67
  return "The push check is off on this laptop; `balladeer judge --push-check on` turns it back on.";
68
68
  if (state === "unavailable")
69
69
  return "The push check is not on: it needs the installed balladeer command, which is not installed here, and without it every shell command your agent runs would wait for npm. Run setup again once the command installs.";
70
- return 'The push check is on for every connected repository: after your agent pushes, it reports any promise the push may have broken and never holds the push (a promise needs a behavior map, which `balladeer map --all` builds). Turn it off on this laptop with `balladeer judge --push-check off`; a repository can have it hold a breaking push instead with "mode": "block" in .continuity/judge.json.';
70
+ return 'The push check is on for every connected repository: after your agent pushes, it reports any promise the push may have broken and never holds the push. Each agreed promise is tied to the code it lives in on the first push after it is agreed, on your own Claude Code or Codex login, with nothing for you to run. Turn it off on this laptop with `balladeer judge --push-check off`; a repository can have it hold a breaking push instead with "mode": "block" in .continuity/judge.json.';
71
71
  }
72
72
  /**
73
73
  * What the push check's entry asks grep before Node starts: whether the
@@ -75,10 +75,15 @@ export function pushCheckSetupLine(state) {
75
75
  * can send it), so much as mentions `push`, `create` or `merge`. Every line
76
76
  * the judge reads as a push (`git push`, `gh pr create`, `gh pr merge`) spells
77
77
  * one of them out, so none is dropped, and a folder or an output that says
78
- * "push" wakes nothing. A word split by quotes (`pu''sh`) is the one spelling
79
- * the judge reads as a push and this does not.
78
+ * "push" wakes nothing. Between letters it permits punctuation, including
79
+ * shell quotes and escapes: `pu''sh` must reach the full parser too. This
80
+ * deliberately admits a few extra commands instead of losing an observed push.
80
81
  */
81
- export const PUSH_CHECK_FILTER = '"command" *: *(\\[( *"([^"\\\\]|\\\\.)*" *,)* *)?"([^"\\\\]|\\\\.)*(push|create|merge)';
82
+ const FILTER_GAP = '([^"[:alnum:]]|\\\\.)*';
83
+ const FILTER_WORDS = ["push", "create", "merge"]
84
+ .map((word) => [...word].join(FILTER_GAP))
85
+ .join("|");
86
+ export const PUSH_CHECK_FILTER = `"command" *: *(\\[( *"([^"\\\\]|\\\\.)*" *,)* *)?"([^"\\\\]|\\\\.)*(${FILTER_WORDS})`;
82
87
  /** A word as a POSIX shell reads it back unchanged: bare when it is plain. */
83
88
  function shellWord(word) {
84
89
  return /^[\w@%+=:,./-]+$/.test(word) ? word : `'${word.replaceAll("'", "'\\''")}'`;
@@ -293,7 +298,7 @@ export function mergeClaudeUserHooks(home, published, installed, platform = proc
293
298
  // note included: only rows with its own mark are its to replace or remove.
294
299
  const pushCheck = pushCheckInstallable(published, installed);
295
300
  const judgeLine = pushCheckCommand(host, published, installed, { platform });
296
- for (const event of JUDGE_HOOK_EVENTS) {
301
+ for (const event of judgeHookEvents(host)) {
297
302
  const rows = hooks[event] === undefined ? [] : hooks[event];
298
303
  if (!Array.isArray(rows))
299
304
  return { host, path, status: "refused", reason: `hooks.${event} is not a list` };
@@ -347,7 +352,7 @@ export function mergeClaudeUserHooks(home, published, installed, platform = proc
347
352
  // under PreToolUse are not the guidance hooks' to take out.
348
353
  const sets = [
349
354
  [USER_SCOPE_OWNER, CLAUDE_EVENTS],
350
- [USER_SCOPE_PUSH_CHECK_OWNER, JUDGE_HOOK_EVENTS],
355
+ [USER_SCOPE_PUSH_CHECK_OWNER, judgeHookEvents(host)],
351
356
  ];
352
357
  for (const [event, rows] of Object.entries(hooks)) {
353
358
  if (!Array.isArray(rows))
package/dist/wire.d.ts CHANGED
@@ -5,14 +5,14 @@
5
5
  * runtime dependency at all: Node 22 builtins and global fetch, nothing else.
6
6
  * A contract test compares the scope list below against the server's.
7
7
  */
8
- export declare const CLI_VERSION = "1.0.19";
8
+ export declare const CLI_VERSION = "1.0.21";
9
9
  export declare const CLI_INVOCATION = "npx -y balladeer@latest";
10
10
  export declare const CLIENT_HEADER = "x-balladeer-client";
11
11
  /** What the last background update came to: `started:1.0.17`, `installed:1.0.17`, `failed:exit-1`, `unstartable:launcher`. */
12
12
  export declare const UPDATE_STATE_HEADER = "x-balladeer-update-state";
13
13
  /** Which host's hook made this guidance fetch: `claude` or `codex`. */
14
14
  export declare const HOOK_HOST_HEADER = "x-balladeer-hook";
15
- export declare const CLIENT_HEADER_VALUE = "balladeer/1.0.19";
15
+ export declare const CLIENT_HEADER_VALUE = "balladeer/1.0.21";
16
16
  export declare const DEFAULT_CONTROL_PLANE = "https://envelopes.balladeer.ai";
17
17
  export type DelegatedScope = "repository:enroll" | "agent:issue" | "ci:connect" | "workspace:invite" | "candidate:propose";
18
18
  export declare const DELEGATED_SCOPES: readonly DelegatedScope[];
@@ -747,7 +747,7 @@ export type JsonStep = Readonly<{
747
747
  remainingToday: number;
748
748
  }> | Readonly<{
749
749
  step: "judge";
750
- status: "not_judged" | "still_judging" | "nothing_to_judge";
750
+ status: "not_judged" | "still_judging" | "nothing_to_judge" | "review_receipt_remains_local" | "review_settings_unavailable";
751
751
  reason: string;
752
752
  message: string;
753
753
  promiseIds?: readonly string[];
package/dist/wire.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * runtime dependency at all: Node 22 builtins and global fetch, nothing else.
6
6
  * A contract test compares the scope list below against the server's.
7
7
  */
8
- export const CLI_VERSION = "1.0.19";
8
+ export const CLI_VERSION = "1.0.21";
9
9
  export const CLI_INVOCATION = "npx -y balladeer@latest";
10
10
  export const CLIENT_HEADER = "x-balladeer-client";
11
11
  /** What the last background update came to: `started:1.0.17`, `installed:1.0.17`, `failed:exit-1`, `unstartable:launcher`. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "balladeer",
3
- "version": "1.0.19",
3
+ "version": "1.0.21",
4
4
  "description": "Set up Balladeer from your terminal, or from a coding agent's.",
5
5
  "license": "Apache-2.0",
6
6
  "private": false,