balladeer 1.0.18 → 1.0.20

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 (51) 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/checkout-form.d.ts +11 -0
  6. package/dist/checkout-form.js +14 -0
  7. package/dist/cli.js +4 -3
  8. package/dist/commands/guidance.js +23 -8
  9. package/dist/commands/judge.d.ts +50 -1
  10. package/dist/commands/judge.js +768 -110
  11. package/dist/commands/mcp.js +5 -9
  12. package/dist/commands/setup.js +5 -23
  13. package/dist/currency.d.ts +2 -0
  14. package/dist/currency.js +2 -1
  15. package/dist/guidance-hook.mjs +162 -56
  16. package/dist/guidance-runtime.d.ts +26 -0
  17. package/dist/guidance-runtime.js +71 -1
  18. package/dist/headless-agent.d.ts +2 -0
  19. package/dist/headless-agent.js +5 -1
  20. package/dist/judge-brief.d.ts +11 -5
  21. package/dist/judge-brief.js +26 -10
  22. package/dist/judge-evidence.d.ts +87 -0
  23. package/dist/judge-evidence.js +393 -0
  24. package/dist/judge-hook.d.ts +14 -1
  25. package/dist/judge-hook.js +34 -5
  26. package/dist/judge-review.d.ts +10 -0
  27. package/dist/judge-review.js +36 -0
  28. package/dist/judge-said.d.ts +1 -0
  29. package/dist/judge-said.js +3 -0
  30. package/dist/promise-meaning.d.ts +4 -0
  31. package/dist/promise-review-client.d.ts +84 -0
  32. package/dist/promise-review-client.js +357 -0
  33. package/dist/push-operation.d.ts +61 -0
  34. package/dist/push-operation.js +472 -0
  35. package/dist/read-only-review.d.ts +2 -0
  36. package/dist/read-only-review.js +2 -0
  37. package/dist/review-backstop.d.ts +20 -0
  38. package/dist/review-backstop.js +54 -0
  39. package/dist/review-reporting.d.ts +32 -0
  40. package/dist/review-reporting.js +319 -0
  41. package/dist/review-selection.d.ts +33 -0
  42. package/dist/review-selection.js +64 -0
  43. package/dist/review-settings.d.ts +6 -0
  44. package/dist/review-settings.js +9 -0
  45. package/dist/self-update.d.ts +3 -3
  46. package/dist/self-update.js +38 -8
  47. package/dist/user-scope.d.ts +1 -10
  48. package/dist/user-scope.js +12 -7
  49. package/dist/wire.d.ts +3 -3
  50. package/dist/wire.js +1 -1
  51. 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
+ }
@@ -29,10 +29,10 @@ export declare function selfUpdateState(environment: NodeJS.ProcessEnv): SelfUpd
29
29
  */
30
30
  export declare function resolveLaunch(specifier: string, environment: NodeJS.ProcessEnv, execPath?: string, exists?: (path: string) => boolean): Launch;
31
31
  /**
32
- * Start the update when the server named a newer release and none was
33
- * started today. Answers whether one was started, for the caller's own
32
+ * Start the update when the server named a newer release and an attempt
33
+ * toward it is due (`due`). Answers whether one was started, for the caller's own
34
34
  * record; the session hears nothing either way. A launch that could not start
35
- * leaves no stamp, so the next session start tries again rather than waiting
35
+ * leaves no stamp, so the next hook fire tries again rather than waiting
36
36
  * a day for the same failure.
37
37
  */
38
38
  export declare function startSelfUpdateIfDue(newer: string | undefined, deps: SelfUpdateDeps): boolean;
@@ -1,6 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { delimiter, dirname, join } from "node:path";
4
+ import { older } from "./currency.js";
4
5
  import { HOOK_SPECIFIER } from "./release.js";
5
6
  import { configHome } from "./store.js";
6
7
  /**
@@ -24,10 +25,22 @@ import { configHome } from "./store.js";
24
25
  * and what happened is kept in a small state file the next hook fire reports
25
26
  * to the server, so a laptop that cannot update is visible on the scorecard
26
27
  * instead of looking merely behind.
28
+ *
29
+ * What changed on 1 October 2026, from Robert's own laptop: it stayed on
30
+ * 1.0.16 through two releases while in use all day. Two rules held it. Only a
31
+ * session start began an install, and his sessions run for days, so the hooks
32
+ * that did fire (a prompt, a subagent) never asked. And the day's stamp was
33
+ * one stamp for every version: an attempt toward 1.0.15 at 03:20 held back
34
+ * 1.0.17 until 03:20 the next day, though a manual update had already passed
35
+ * 1.0.15. Now any guidance hook fire can begin the install, and a release
36
+ * newer than the one the last attempt aimed at may begin one an hour after
37
+ * it, rather than a day.
27
38
  */
28
39
  const STAMP = "self-update.json";
29
40
  const STATE = "self-update-state.json";
30
41
  const DAY_MS = 24 * 60 * 60 * 1000;
42
+ const HOUR_MS = 60 * 60 * 1000;
43
+ const VERSION = /^\d{1,4}\.\d{1,4}\.\d{1,4}$/;
31
44
  /** Set on the child so the install it runs can record how it ended. */
32
45
  export const SELF_UPDATE_ENVIRONMENT = "BALLADEER_SELF_UPDATE";
33
46
  const STATE_PATTERN = /^(?:started|unstartable|installed|failed):[A-Za-z0-9._-]{1,40}$/;
@@ -37,18 +50,35 @@ function stampPath(environment) {
37
50
  function statePath(environment) {
38
51
  return join(configHome(environment), STATE);
39
52
  }
40
- function lastStarted(environment) {
53
+ /** The last attempt: when it started and which release it aimed at, when it said. */
54
+ function lastAttempt(environment) {
41
55
  const path = stampPath(environment);
42
56
  if (!existsSync(path))
43
- return 0;
57
+ return { startedAt: 0 };
44
58
  try {
45
59
  const parsed = JSON.parse(readFileSync(path, "utf8"));
46
- return typeof parsed.startedAt === "number" ? parsed.startedAt : 0;
60
+ const startedAt = typeof parsed.startedAt === "number" ? parsed.startedAt : 0;
61
+ return typeof parsed.toward === "string" && VERSION.test(parsed.toward)
62
+ ? { startedAt, toward: parsed.toward }
63
+ : { startedAt };
47
64
  }
48
65
  catch {
49
- return 0;
66
+ return { startedAt: 0 };
50
67
  }
51
68
  }
69
+ /**
70
+ * Whether an attempt toward `newer` may start now. Once a day toward the same
71
+ * release, so a laptop whose install keeps failing is not hammered; but a
72
+ * release newer than the last attempt's target is a new reason, and waits only
73
+ * an hour, so one stale attempt cannot hold back the next release for a day.
74
+ */
75
+ function due(newer, last, now) {
76
+ const since = now - last.startedAt;
77
+ if (since >= DAY_MS)
78
+ return true;
79
+ const newerTarget = last.toward === undefined || older(last.toward, newer);
80
+ return newerTarget && since >= HOUR_MS;
81
+ }
52
82
  function writePrivate(path, value, environment) {
53
83
  mkdirSync(configHome(environment), { recursive: true, mode: 0o700 });
54
84
  writeFileSync(path, JSON.stringify(value) + "\n", { mode: 0o600 });
@@ -109,17 +139,17 @@ function detached(command, args, environment) {
109
139
  return child.pid !== undefined;
110
140
  }
111
141
  /**
112
- * Start the update when the server named a newer release and none was
113
- * started today. Answers whether one was started, for the caller's own
142
+ * Start the update when the server named a newer release and an attempt
143
+ * toward it is due (`due`). Answers whether one was started, for the caller's own
114
144
  * record; the session hears nothing either way. A launch that could not start
115
- * leaves no stamp, so the next session start tries again rather than waiting
145
+ * leaves no stamp, so the next hook fire tries again rather than waiting
116
146
  * a day for the same failure.
117
147
  */
118
148
  export function startSelfUpdateIfDue(newer, deps) {
119
149
  if (newer === undefined)
120
150
  return false;
121
151
  const now = deps.now ?? Date.now;
122
- if (now() - lastStarted(deps.environment) < DAY_MS)
152
+ if (!due(newer, lastAttempt(deps.environment), now()))
123
153
  return false;
124
154
  const launch = resolveLaunch(HOOK_SPECIFIER, deps.environment, deps.execPath, deps.exists);
125
155
  let started;
@@ -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.18";
8
+ export declare const CLI_VERSION = "1.0.20";
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.18";
15
+ export declare const CLIENT_HEADER_VALUE = "balladeer/1.0.20";
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.18";
8
+ export const CLI_VERSION = "1.0.20";
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.18",
3
+ "version": "1.0.20",
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,