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.
- package/README.md +18 -0
- package/dist/anchoring.js +2 -2
- package/dist/anchors-schema.d.ts +3 -1
- package/dist/anchors-schema.js +5 -3
- package/dist/checkout-form.d.ts +11 -0
- package/dist/checkout-form.js +14 -0
- package/dist/cli.js +4 -3
- package/dist/commands/guidance.js +23 -8
- package/dist/commands/judge.d.ts +50 -1
- package/dist/commands/judge.js +768 -110
- package/dist/commands/mcp.js +5 -9
- package/dist/commands/setup.js +5 -23
- package/dist/currency.d.ts +2 -0
- package/dist/currency.js +2 -1
- package/dist/guidance-hook.mjs +162 -56
- package/dist/guidance-runtime.d.ts +26 -0
- package/dist/guidance-runtime.js +71 -1
- package/dist/headless-agent.d.ts +2 -0
- package/dist/headless-agent.js +5 -1
- package/dist/judge-brief.d.ts +11 -5
- package/dist/judge-brief.js +26 -10
- package/dist/judge-evidence.d.ts +87 -0
- package/dist/judge-evidence.js +393 -0
- package/dist/judge-hook.d.ts +14 -1
- package/dist/judge-hook.js +34 -5
- package/dist/judge-review.d.ts +10 -0
- package/dist/judge-review.js +36 -0
- package/dist/judge-said.d.ts +1 -0
- package/dist/judge-said.js +3 -0
- package/dist/promise-meaning.d.ts +4 -0
- package/dist/promise-review-client.d.ts +84 -0
- package/dist/promise-review-client.js +357 -0
- package/dist/push-operation.d.ts +61 -0
- package/dist/push-operation.js +472 -0
- package/dist/read-only-review.d.ts +2 -0
- package/dist/read-only-review.js +2 -0
- package/dist/review-backstop.d.ts +20 -0
- package/dist/review-backstop.js +54 -0
- package/dist/review-reporting.d.ts +32 -0
- package/dist/review-reporting.js +319 -0
- package/dist/review-selection.d.ts +33 -0
- package/dist/review-selection.js +64 -0
- package/dist/review-settings.d.ts +6 -0
- package/dist/review-settings.js +9 -0
- package/dist/self-update.d.ts +3 -3
- package/dist/self-update.js +38 -8
- package/dist/user-scope.d.ts +1 -10
- package/dist/user-scope.js +12 -7
- package/dist/wire.d.ts +3 -3
- package/dist/wire.js +1 -1
- 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
|
+
}
|
package/dist/self-update.d.ts
CHANGED
|
@@ -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
|
|
33
|
-
*
|
|
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
|
|
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;
|
package/dist/self-update.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
113
|
-
*
|
|
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
|
|
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 (
|
|
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;
|
package/dist/user-scope.d.ts
CHANGED
|
@@ -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
|
package/dist/user-scope.js
CHANGED
|
@@ -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
|
|
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.
|
|
79
|
-
*
|
|
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
|
-
|
|
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
|
|
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,
|
|
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.
|
|
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.
|
|
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.
|
|
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`. */
|