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.
- 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/commands/judge.d.ts +50 -1
- package/dist/commands/judge.js +768 -110
- package/dist/commands/mcp.d.ts +4 -0
- package/dist/commands/mcp.js +24 -1
- package/dist/currency.js +9 -3
- package/dist/guidance-hook.mjs +13 -3
- 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 +9 -0
- package/dist/self-update.js +35 -1
- 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
|
@@ -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. */
|
package/dist/self-update.js
CHANGED
|
@@ -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(
|
|
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;
|
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.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.
|
|
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.
|
|
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`. */
|