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
package/dist/judge-hook.d.ts
CHANGED
|
@@ -15,8 +15,10 @@
|
|
|
15
15
|
*/
|
|
16
16
|
export type HookHost = "claude" | "codex" | "git";
|
|
17
17
|
/** The two host events the judge is installed on, in the order a command meets them. */
|
|
18
|
-
export type HookEvent = "PreToolUse" | "PostToolUse";
|
|
18
|
+
export type HookEvent = "PreToolUse" | "PostToolUse" | "PostToolUseFailure";
|
|
19
19
|
export declare const JUDGE_HOOK_EVENTS: readonly HookEvent[];
|
|
20
|
+
/** Claude has a distinct event for failed tools; Codex sends their PostToolUse. */
|
|
21
|
+
export declare function judgeHookEvents(host: HookHost): readonly HookEvent[];
|
|
20
22
|
export type HookInput = Readonly<{
|
|
21
23
|
eventName?: string;
|
|
22
24
|
toolName?: string;
|
|
@@ -25,6 +27,15 @@ export type HookInput = Readonly<{
|
|
|
25
27
|
model?: string;
|
|
26
28
|
/** The host's session, from `session_id`, which both Claude Code and Codex send. */
|
|
27
29
|
sessionId?: string;
|
|
30
|
+
toolUseId?: string;
|
|
31
|
+
/** Only the bounded local shell result; never sent to Balladeer. */
|
|
32
|
+
result?: HookCommandResult;
|
|
33
|
+
}>;
|
|
34
|
+
export type HookCommandResult = Readonly<{
|
|
35
|
+
exitCode?: number;
|
|
36
|
+
stdout?: string;
|
|
37
|
+
stderr?: string;
|
|
38
|
+
interrupted?: boolean;
|
|
28
39
|
}>;
|
|
29
40
|
/** A coding host's hook payload, or nothing when the text is not one. */
|
|
30
41
|
export declare function parseHookInput(text: string): HookInput | undefined;
|
|
@@ -117,6 +128,8 @@ export declare function pushPlace(line: string, cwd: string): PushPlace;
|
|
|
117
128
|
export declare function repositoryName(reference: string): string | undefined;
|
|
118
129
|
/** The repositories a checkout's remotes point at, from what `git remote -v` prints. */
|
|
119
130
|
export declare function remoteRepositoryNames(listing: string): Set<string>;
|
|
131
|
+
/** The command's words with leading assignments and wrappers like `env` and `sudo` removed. */
|
|
132
|
+
export declare function shellProgram(words: readonly string[]): string[];
|
|
120
133
|
/** Whether a shell line pushes: `git push`, `gh pr create`, or `gh pr merge`, anywhere in it. */
|
|
121
134
|
export declare function isPushCommand(line: string): boolean;
|
|
122
135
|
/**
|
package/dist/judge-hook.js
CHANGED
|
@@ -6,6 +6,26 @@ import { runCommand } from "./gh.js";
|
|
|
6
6
|
import { resolveCommit } from "./scratch-worktree.js";
|
|
7
7
|
import { configHome } from "./store.js";
|
|
8
8
|
export const JUDGE_HOOK_EVENTS = ["PreToolUse", "PostToolUse"];
|
|
9
|
+
/** Claude has a distinct event for failed tools; Codex sends their PostToolUse. */
|
|
10
|
+
export function judgeHookEvents(host) {
|
|
11
|
+
return host === "claude" ? [...JUDGE_HOOK_EVENTS, "PostToolUseFailure"] : JUDGE_HOOK_EVENTS;
|
|
12
|
+
}
|
|
13
|
+
function commandResult(value) {
|
|
14
|
+
// Codex 0.144.5 sends the shell's combined output as a string, including
|
|
15
|
+
// successful remote ref updates followed by local tracking-lock failures.
|
|
16
|
+
if (typeof value === "string")
|
|
17
|
+
return { stdout: value.slice(0, 131072) };
|
|
18
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
19
|
+
return undefined;
|
|
20
|
+
const row = value;
|
|
21
|
+
const code = row.exit_code ?? row.exitCode;
|
|
22
|
+
return {
|
|
23
|
+
...(typeof code === "number" && Number.isInteger(code) ? { exitCode: code } : {}),
|
|
24
|
+
...(typeof row.stdout === "string" ? { stdout: row.stdout.slice(0, 131072) } : {}),
|
|
25
|
+
...(typeof row.stderr === "string" ? { stderr: row.stderr.slice(0, 131072) } : {}),
|
|
26
|
+
...(typeof row.interrupted === "boolean" ? { interrupted: row.interrupted } : {}),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
9
29
|
/** A session id as the hook keeps it: a bounded name, never a path or a sentence. */
|
|
10
30
|
const SESSION_ID = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
11
31
|
/** A coding host's hook payload, or nothing when the text is not one. */
|
|
@@ -22,6 +42,11 @@ export function parseHookInput(text) {
|
|
|
22
42
|
const input = value;
|
|
23
43
|
const toolInput = input.tool_input;
|
|
24
44
|
const command = commandText(toolInput?.command);
|
|
45
|
+
const result = commandResult(input.tool_response ??
|
|
46
|
+
input.tool_output ??
|
|
47
|
+
(typeof input.error === "string"
|
|
48
|
+
? { stderr: input.error, interrupted: input.is_interrupt === true }
|
|
49
|
+
: undefined));
|
|
25
50
|
return {
|
|
26
51
|
...(typeof input.hook_event_name === "string" ? { eventName: input.hook_event_name } : {}),
|
|
27
52
|
...(typeof input.tool_name === "string" ? { toolName: input.tool_name } : {}),
|
|
@@ -31,6 +56,10 @@ export function parseHookInput(text) {
|
|
|
31
56
|
...(typeof input.session_id === "string" && SESSION_ID.test(input.session_id)
|
|
32
57
|
? { sessionId: input.session_id }
|
|
33
58
|
: {}),
|
|
59
|
+
...(typeof input.tool_use_id === "string" && SESSION_ID.test(input.tool_use_id)
|
|
60
|
+
? { toolUseId: input.tool_use_id }
|
|
61
|
+
: {}),
|
|
62
|
+
...(result === undefined ? {} : { result }),
|
|
34
63
|
};
|
|
35
64
|
}
|
|
36
65
|
const SHELLS = new Set(["bash", "sh", "zsh", "dash", "ksh"]);
|
|
@@ -45,14 +74,13 @@ function commandText(value) {
|
|
|
45
74
|
return words[2];
|
|
46
75
|
return words.join(" ");
|
|
47
76
|
}
|
|
48
|
-
const ZERO = /^0{40}$/;
|
|
49
77
|
export function parsePrePushInput(text) {
|
|
50
78
|
const refs = [];
|
|
51
79
|
for (const line of text.split("\n")) {
|
|
52
80
|
const [localRef, localSha, remoteRef, remoteSha] = line.trim().split(/\s+/);
|
|
53
81
|
if (!localRef || !localSha || !remoteRef || !remoteSha)
|
|
54
82
|
continue;
|
|
55
|
-
if (!/^[0-9a-f]{40}$/.test(localSha) ||
|
|
83
|
+
if (!/^[0-9a-f]{40}$/.test(localSha) || !/^[0-9a-f]{40}$/.test(remoteSha))
|
|
56
84
|
continue;
|
|
57
85
|
refs.push({ localRef, localSha, remoteRef, remoteSha });
|
|
58
86
|
}
|
|
@@ -670,7 +698,7 @@ export function remoteRepositoryNames(listing) {
|
|
|
670
698
|
const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
671
699
|
const WRAPPERS = new Set(["env", "command", "exec", "time", "nohup", "nice", "sudo", "builtin"]);
|
|
672
700
|
/** The command's words with leading assignments and wrappers like `env` and `sudo` removed. */
|
|
673
|
-
function
|
|
701
|
+
export function shellProgram(words) {
|
|
674
702
|
let at = 0;
|
|
675
703
|
while (at < words.length) {
|
|
676
704
|
const word = words[at];
|
|
@@ -686,6 +714,7 @@ function program(words) {
|
|
|
686
714
|
}
|
|
687
715
|
return words.slice(at);
|
|
688
716
|
}
|
|
717
|
+
const program = shellProgram;
|
|
689
718
|
/** git's global options that take the next word as their value. */
|
|
690
719
|
const GIT_VALUE_OPTIONS = new Set([
|
|
691
720
|
"-C",
|
|
@@ -1005,7 +1034,7 @@ export function hasJudgeEntry(root, host, event) {
|
|
|
1005
1034
|
* installed has only the entry before the push, and it keeps judging there.
|
|
1006
1035
|
*/
|
|
1007
1036
|
export function hasProjectJudgeHook(root, host) {
|
|
1008
|
-
return
|
|
1037
|
+
return judgeHookEvents(host).some((event) => hasJudgeEntry(root, host, event));
|
|
1009
1038
|
}
|
|
1010
1039
|
/**
|
|
1011
1040
|
* The checkout that holds a folder, found by walking up to its `.git` entry (a
|
|
@@ -1091,7 +1120,7 @@ function installJsonHook(root, host) {
|
|
|
1091
1120
|
const events = hooks;
|
|
1092
1121
|
const expected = expectedEntry(host);
|
|
1093
1122
|
const missing = [];
|
|
1094
|
-
for (const event of
|
|
1123
|
+
for (const event of judgeHookEvents(host)) {
|
|
1095
1124
|
const rows = events[event] === undefined ? [] : events[event];
|
|
1096
1125
|
if (!Array.isArray(rows))
|
|
1097
1126
|
return {
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Source-grounded findings stay on the customer's machine, alongside the verdict. */
|
|
2
|
+
export type CodeReview = Readonly<{
|
|
3
|
+
summary: string;
|
|
4
|
+
inspectedFiles: readonly string[];
|
|
5
|
+
risks: readonly string[];
|
|
6
|
+
limitations: readonly string[];
|
|
7
|
+
}>;
|
|
8
|
+
export declare function readCodeReview(raw: unknown): CodeReview | undefined;
|
|
9
|
+
/** A review is reasoning, not verified evidence. Keep that distinction visible at the host. */
|
|
10
|
+
export declare function codeReviewLines(review: CodeReview | undefined): string;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export function readCodeReview(raw) {
|
|
2
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw))
|
|
3
|
+
return undefined;
|
|
4
|
+
const value = raw;
|
|
5
|
+
const strings = (input, max, length) => Array.isArray(input) && input.length <= max && input.every((v) => typeof v === "string")
|
|
6
|
+
? [...new Set(input.map((v) => v.trim().slice(0, length)).filter(Boolean))]
|
|
7
|
+
: undefined;
|
|
8
|
+
const files = strings(value.inspectedFiles, 300, 500);
|
|
9
|
+
const risks = strings(value.risks, 8, 1000);
|
|
10
|
+
const limitations = strings(value.limitations, 8, 1000);
|
|
11
|
+
if (typeof value.summary !== "string" ||
|
|
12
|
+
!value.summary.trim() ||
|
|
13
|
+
files === undefined ||
|
|
14
|
+
files.length === 0 ||
|
|
15
|
+
risks === undefined ||
|
|
16
|
+
limitations === undefined ||
|
|
17
|
+
files.some((file) => file.startsWith("/") || file.includes("\\") || file.split("/").includes("..")))
|
|
18
|
+
return undefined;
|
|
19
|
+
return {
|
|
20
|
+
summary: value.summary.trim().slice(0, 2000),
|
|
21
|
+
inspectedFiles: files,
|
|
22
|
+
risks,
|
|
23
|
+
limitations,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/** A review is reasoning, not verified evidence. Keep that distinction visible at the host. */
|
|
27
|
+
export function codeReviewLines(review) {
|
|
28
|
+
if (review === undefined)
|
|
29
|
+
return "";
|
|
30
|
+
return [
|
|
31
|
+
` Review: ${review.summary}`,
|
|
32
|
+
` Inspected: ${review.inspectedFiles.join(", ")}`,
|
|
33
|
+
...review.risks.map((risk) => ` Review concern (not independently established): ${risk}`),
|
|
34
|
+
...review.limitations.map((limit) => ` Review limit: ${limit}`),
|
|
35
|
+
].join("\n");
|
|
36
|
+
}
|
package/dist/judge-said.d.ts
CHANGED
|
@@ -24,6 +24,7 @@ export type SaidVerdict = Readonly<{
|
|
|
24
24
|
notes: string;
|
|
25
25
|
reproductionRejected: string | null;
|
|
26
26
|
nextStep?: string;
|
|
27
|
+
review?: import("./judge-review.js").CodeReview;
|
|
27
28
|
}>;
|
|
28
29
|
/**
|
|
29
30
|
* The gist of why a promise could not be judged, stable across the different
|
package/dist/judge-said.js
CHANGED
|
@@ -66,6 +66,9 @@ const NEEDS = [
|
|
|
66
66
|
* the judge's notes with numbers and hashes set aside.
|
|
67
67
|
*/
|
|
68
68
|
export function reasonGist(verdict) {
|
|
69
|
+
// A new concern must reach the session even if the same dependency is missing.
|
|
70
|
+
if (verdict.review !== undefined)
|
|
71
|
+
return `review:${fingerprint(JSON.stringify({ review: verdict.review, notes: verdict.notes, rejected: verdict.reproductionRejected, nextStep: verdict.nextStep }))}`;
|
|
69
72
|
if (verdict.reproductionRejected)
|
|
70
73
|
return `rejected:${fingerprint(verdict.reproductionRejected)}`;
|
|
71
74
|
for (const [gist, pattern] of RUNNER_REASONS) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type BehaviorMapMeaning } from "./behavior-map-schema.js";
|
|
2
2
|
import { type StoredAgent } from "./store.js";
|
|
3
|
+
import type { ReviewSubject } from "./promise-review-client.js";
|
|
3
4
|
/**
|
|
4
5
|
* One promise's agreed meaning, the way the map builder and the judge read it.
|
|
5
6
|
*
|
|
@@ -34,6 +35,9 @@ export type PromiseDocument = Readonly<{
|
|
|
34
35
|
title: string;
|
|
35
36
|
semanticDigest: string;
|
|
36
37
|
meaning: PromiseMeaning;
|
|
38
|
+
/** Present only when a current catalog has established the meaning's standing. */
|
|
39
|
+
standing?: "recorded" | "agreed";
|
|
40
|
+
subject?: ReviewSubject;
|
|
37
41
|
/**
|
|
38
42
|
* Whether a Balladeer check is bound to this promise, as `get_promise`
|
|
39
43
|
* answered through this repository's connection. Absent means unknown: a
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { type PromiseAnchors } from "./anchors-schema.js";
|
|
2
|
+
import type { AnchorsClient } from "./anchors-client.js";
|
|
3
|
+
import { connectionAgent, type PromiseDocument, type PromiseReader } from "./promise-meaning.js";
|
|
4
|
+
import type { StoredAgent } from "./store.js";
|
|
5
|
+
import { type ReviewSettings } from "./review-settings.js";
|
|
6
|
+
export type ReviewSubject = Readonly<{
|
|
7
|
+
kind: "agreed";
|
|
8
|
+
promiseId: string;
|
|
9
|
+
revisionId: string;
|
|
10
|
+
}> | Readonly<{
|
|
11
|
+
kind: "recorded";
|
|
12
|
+
proposalId: string;
|
|
13
|
+
candidateRevision: number;
|
|
14
|
+
candidateEventId: string;
|
|
15
|
+
}>;
|
|
16
|
+
export declare const reviewSubjectId: (s: ReviewSubject) => string;
|
|
17
|
+
export type ReviewTarget = PromiseDocument & Readonly<{
|
|
18
|
+
subject: ReviewSubject;
|
|
19
|
+
standing: "recorded" | "agreed";
|
|
20
|
+
anchors: PromiseAnchors | null;
|
|
21
|
+
openFindings?: readonly Readonly<{
|
|
22
|
+
reportId: string;
|
|
23
|
+
sourceSha: string;
|
|
24
|
+
baseSha: string;
|
|
25
|
+
destinationRef: string;
|
|
26
|
+
destinationDigest?: string;
|
|
27
|
+
}>[];
|
|
28
|
+
}>;
|
|
29
|
+
export type ReviewCatalog = Readonly<{
|
|
30
|
+
ok: true;
|
|
31
|
+
targets: readonly ReviewTarget[];
|
|
32
|
+
reviewSettings?: ReviewSettings;
|
|
33
|
+
}> | Readonly<{
|
|
34
|
+
ok: false;
|
|
35
|
+
reason: string;
|
|
36
|
+
unsupported?: boolean;
|
|
37
|
+
reviewSettings?: ReviewSettings;
|
|
38
|
+
}>;
|
|
39
|
+
export type ReviewReceipt = Readonly<{
|
|
40
|
+
schemaVersion: "balladeer-review-receipt/v1";
|
|
41
|
+
subject: ReviewSubject;
|
|
42
|
+
semanticDigest: string;
|
|
43
|
+
baseSha: string;
|
|
44
|
+
sourceSha: string;
|
|
45
|
+
destinationRef: string;
|
|
46
|
+
destinationDigest?: string;
|
|
47
|
+
verdict: "kept" | "broken" | "could_not_tell";
|
|
48
|
+
reproductionDigest?: string;
|
|
49
|
+
briefRevision: string;
|
|
50
|
+
client: "codex" | "claude";
|
|
51
|
+
resolvesReportIds?: readonly string[];
|
|
52
|
+
ancestryVerified?: true;
|
|
53
|
+
}>;
|
|
54
|
+
export type ReviewsClient = Readonly<{
|
|
55
|
+
list(): Promise<ReviewCatalog>;
|
|
56
|
+
record(receipt: ReviewReceipt, options?: {
|
|
57
|
+
signal?: AbortSignal;
|
|
58
|
+
timeoutMs?: number;
|
|
59
|
+
}): Promise<{
|
|
60
|
+
ok: true;
|
|
61
|
+
reportId: string;
|
|
62
|
+
} | {
|
|
63
|
+
ok: false;
|
|
64
|
+
reason: string;
|
|
65
|
+
}>;
|
|
66
|
+
recordAnchors(target: ReviewTarget, anchors: PromiseAnchors): ReturnType<AnchorsClient["record"]>;
|
|
67
|
+
}>;
|
|
68
|
+
/** Read current meaning once per push. Customer code and test output never enter this client. */
|
|
69
|
+
export declare function reviewsClientFor(agent: StoredAgent, options?: {
|
|
70
|
+
fetchImpl?: typeof fetch;
|
|
71
|
+
timeoutMs?: number;
|
|
72
|
+
}): ReviewsClient;
|
|
73
|
+
export declare function connectionReviewsClient(input: Parameters<typeof connectionAgent>[0]): {
|
|
74
|
+
ok: true;
|
|
75
|
+
client: ReviewsClient;
|
|
76
|
+
} | {
|
|
77
|
+
ok: false;
|
|
78
|
+
reason: string;
|
|
79
|
+
};
|
|
80
|
+
/** Adapt the existing local anchor builder without rereading each meaning through MCP. */
|
|
81
|
+
export declare function catalogReaders(client: ReviewsClient, targets: readonly ReviewTarget[]): {
|
|
82
|
+
reader: PromiseReader;
|
|
83
|
+
anchors: AnchorsClient;
|
|
84
|
+
};
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import { checkPromiseAnchors } from "./anchors-schema.js";
|
|
2
|
+
import { noteServerVersion } from "./currency.js";
|
|
3
|
+
import { agentEndpoint } from "./guidance.js";
|
|
4
|
+
import { connectionAgent, normalizePromise, } from "./promise-meaning.js";
|
|
5
|
+
import { CLIENT_HEADER, CLIENT_HEADER_VALUE } from "./wire.js";
|
|
6
|
+
import { readReviewSettings } from "./review-settings.js";
|
|
7
|
+
export const reviewSubjectId = (s) => s.kind === "agreed" ? s.promiseId : s.proposalId;
|
|
8
|
+
const ID = /^[A-Za-z0-9_.-]{1,128}$/;
|
|
9
|
+
const DIGEST = /^sha256:[0-9a-f]{64}$/;
|
|
10
|
+
const MAX_BYTES = 8 * 1024 * 1024;
|
|
11
|
+
const PATH = "/api/promise-reviews";
|
|
12
|
+
const SHA = /^[0-9a-f]{40}$/;
|
|
13
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
14
|
+
const REF = /^refs\/heads\/(?!.*(?:\.\.|@\{|\/\/|[\x00-\x20\x7f~^:?*\[\\]))(?!.*(?:\.lock|\.)$)[A-Za-z0-9_./-]+$/;
|
|
15
|
+
function exactKeys(value, allowed) {
|
|
16
|
+
return Object.keys(value).every((key) => allowed.includes(key));
|
|
17
|
+
}
|
|
18
|
+
/** Runtime checks precede upload: a server refusal cannot undo leaked customer material. */
|
|
19
|
+
function receiptSafe(receipt) {
|
|
20
|
+
const subjectKeys = receipt.subject?.kind === "agreed"
|
|
21
|
+
? ["kind", "promiseId", "revisionId"]
|
|
22
|
+
: ["kind", "proposalId", "candidateRevision", "candidateEventId"];
|
|
23
|
+
return (exactKeys(receipt, [
|
|
24
|
+
"schemaVersion",
|
|
25
|
+
"subject",
|
|
26
|
+
"semanticDigest",
|
|
27
|
+
"baseSha",
|
|
28
|
+
"sourceSha",
|
|
29
|
+
"destinationRef",
|
|
30
|
+
"destinationDigest",
|
|
31
|
+
"verdict",
|
|
32
|
+
"reproductionDigest",
|
|
33
|
+
"briefRevision",
|
|
34
|
+
"client",
|
|
35
|
+
"resolvesReportIds",
|
|
36
|
+
"ancestryVerified",
|
|
37
|
+
]) &&
|
|
38
|
+
receipt.subject !== null &&
|
|
39
|
+
typeof receipt.subject === "object" &&
|
|
40
|
+
exactKeys(receipt.subject, subjectKeys) &&
|
|
41
|
+
subject(receipt.subject) !== undefined &&
|
|
42
|
+
receipt.schemaVersion === "balladeer-review-receipt/v1" &&
|
|
43
|
+
DIGEST.test(receipt.semanticDigest) &&
|
|
44
|
+
SHA.test(receipt.baseSha) &&
|
|
45
|
+
SHA.test(receipt.sourceSha) &&
|
|
46
|
+
receipt.baseSha !== receipt.sourceSha &&
|
|
47
|
+
receipt.destinationRef.length <= 255 &&
|
|
48
|
+
REF.test(receipt.destinationRef) &&
|
|
49
|
+
(receipt.destinationDigest === undefined || DIGEST.test(receipt.destinationDigest)) &&
|
|
50
|
+
["kept", "broken", "could_not_tell"].includes(receipt.verdict) &&
|
|
51
|
+
(receipt.reproductionDigest === undefined || DIGEST.test(receipt.reproductionDigest)) &&
|
|
52
|
+
(receipt.verdict !== "broken" || receipt.reproductionDigest !== undefined) &&
|
|
53
|
+
/^[a-z][a-z0-9-]{0,47}\/v[1-9][0-9]{0,5}$/.test(receipt.briefRevision) &&
|
|
54
|
+
["claude", "codex"].includes(receipt.client) &&
|
|
55
|
+
(receipt.ancestryVerified === undefined || receipt.ancestryVerified === true) &&
|
|
56
|
+
(receipt.resolvesReportIds === undefined ||
|
|
57
|
+
(Array.isArray(receipt.resolvesReportIds) &&
|
|
58
|
+
receipt.resolvesReportIds.length <= 100 &&
|
|
59
|
+
new Set(receipt.resolvesReportIds).size === receipt.resolvesReportIds.length &&
|
|
60
|
+
receipt.resolvesReportIds.every((id) => UUID.test(id)) &&
|
|
61
|
+
receipt.verdict === "kept" &&
|
|
62
|
+
receipt.ancestryVerified === true &&
|
|
63
|
+
receipt.destinationDigest !== undefined)));
|
|
64
|
+
}
|
|
65
|
+
function subject(value) {
|
|
66
|
+
if (value === null || typeof value !== "object")
|
|
67
|
+
return undefined;
|
|
68
|
+
const s = value;
|
|
69
|
+
const id = (v) => typeof v === "string" && ID.test(v);
|
|
70
|
+
if (s.kind === "agreed" && id(s.promiseId) && id(s.revisionId))
|
|
71
|
+
return { kind: "agreed", promiseId: s.promiseId, revisionId: s.revisionId };
|
|
72
|
+
if (s.kind === "recorded" &&
|
|
73
|
+
id(s.proposalId) &&
|
|
74
|
+
id(s.candidateEventId) &&
|
|
75
|
+
typeof s.candidateRevision === "number" &&
|
|
76
|
+
Number.isSafeInteger(s.candidateRevision) &&
|
|
77
|
+
s.candidateRevision > 0)
|
|
78
|
+
return {
|
|
79
|
+
kind: "recorded",
|
|
80
|
+
proposalId: s.proposalId,
|
|
81
|
+
candidateRevision: s.candidateRevision,
|
|
82
|
+
candidateEventId: s.candidateEventId,
|
|
83
|
+
};
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
/** Read current meaning once per push. Customer code and test output never enter this client. */
|
|
87
|
+
export function reviewsClientFor(agent, options = {}) {
|
|
88
|
+
const send = async (body, cursor, timeoutMs = options.timeoutMs ?? 10_000, parentSignal) => {
|
|
89
|
+
const abort = new AbortController();
|
|
90
|
+
const timer = setTimeout(() => abort.abort(), timeoutMs);
|
|
91
|
+
try {
|
|
92
|
+
const url = agentEndpoint(agent, PATH);
|
|
93
|
+
if (cursor !== undefined)
|
|
94
|
+
url.searchParams.set("cursor", cursor);
|
|
95
|
+
const response = await (options.fetchImpl ?? fetch)(url, {
|
|
96
|
+
method: body === undefined ? "GET" : "POST",
|
|
97
|
+
redirect: "manual",
|
|
98
|
+
signal: parentSignal === undefined ? abort.signal : AbortSignal.any([abort.signal, parentSignal]),
|
|
99
|
+
headers: {
|
|
100
|
+
authorization: `Bearer ${agent.token}`,
|
|
101
|
+
[CLIENT_HEADER]: CLIENT_HEADER_VALUE,
|
|
102
|
+
accept: "application/json",
|
|
103
|
+
...(body === undefined ? {} : { "content-type": "application/json" }),
|
|
104
|
+
},
|
|
105
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
106
|
+
});
|
|
107
|
+
noteServerVersion(response.headers);
|
|
108
|
+
const parts = [];
|
|
109
|
+
let size = 0;
|
|
110
|
+
const reader = response.body?.getReader();
|
|
111
|
+
try {
|
|
112
|
+
if (reader)
|
|
113
|
+
for (;;) {
|
|
114
|
+
const part = await reader.read();
|
|
115
|
+
if (part.done)
|
|
116
|
+
break;
|
|
117
|
+
size += part.value.byteLength;
|
|
118
|
+
if (size > MAX_BYTES)
|
|
119
|
+
throw new Error("oversized");
|
|
120
|
+
parts.push(part.value);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
finally {
|
|
124
|
+
await reader?.cancel().catch(() => undefined);
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
status: response.status,
|
|
128
|
+
body: JSON.parse(Buffer.concat(parts).toString("utf8")),
|
|
129
|
+
bytes: size,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return {
|
|
134
|
+
error: abort.signal.aborted || parentSignal?.aborted
|
|
135
|
+
? "Balladeer's review catalog timed out"
|
|
136
|
+
: "Balladeer's review response could not be read",
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
finally {
|
|
140
|
+
clearTimeout(timer);
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
return {
|
|
144
|
+
async list() {
|
|
145
|
+
const targets = [];
|
|
146
|
+
const seen = new Set();
|
|
147
|
+
const cursors = new Set();
|
|
148
|
+
let cursor;
|
|
149
|
+
let bytes = 0;
|
|
150
|
+
let reviewSettings;
|
|
151
|
+
const started = Date.now();
|
|
152
|
+
let deadline = started + (options.timeoutMs ?? 10_000);
|
|
153
|
+
const failed = (reason, unsupported = false) => ({
|
|
154
|
+
ok: false,
|
|
155
|
+
reason,
|
|
156
|
+
...(unsupported ? { unsupported: true } : {}),
|
|
157
|
+
...(reviewSettings === undefined ? {} : { reviewSettings }),
|
|
158
|
+
});
|
|
159
|
+
for (let page = 0; page < 100; page++) {
|
|
160
|
+
if (Date.now() >= deadline)
|
|
161
|
+
return failed("Balladeer’s complete review catalog timed out");
|
|
162
|
+
const reply = await send(undefined, cursor, Math.max(1, deadline - Date.now()));
|
|
163
|
+
if ("error" in reply)
|
|
164
|
+
return failed(reply.error);
|
|
165
|
+
bytes += reply.bytes;
|
|
166
|
+
if (bytes > MAX_BYTES)
|
|
167
|
+
return failed("Balladeer’s complete review catalog exceeded the local size limit");
|
|
168
|
+
if (reply.status !== 200)
|
|
169
|
+
return failed(`Balladeer could not provide the complete review catalog (${reply.status})`, reply.status === 404 || reply.status === 405);
|
|
170
|
+
const body = reply.body;
|
|
171
|
+
if (body?.schemaVersion !== "balladeer-promise-reviews/v1" || !Array.isArray(body.targets))
|
|
172
|
+
return failed("Balladeer's review catalog is invalid");
|
|
173
|
+
// One snapshot for the whole operation, even if settings change during pagination.
|
|
174
|
+
if (page === 0) {
|
|
175
|
+
reviewSettings = readReviewSettings(body.reviewSettings);
|
|
176
|
+
if (reviewSettings !== undefined)
|
|
177
|
+
deadline = Math.min(deadline, started + reviewSettings.budgetSeconds * 1000);
|
|
178
|
+
}
|
|
179
|
+
for (const raw of body.targets) {
|
|
180
|
+
const row = raw;
|
|
181
|
+
const s = subject(row?.subject);
|
|
182
|
+
if (!s ||
|
|
183
|
+
row?.standing !== s.kind ||
|
|
184
|
+
typeof row.semanticDigest !== "string" ||
|
|
185
|
+
!DIGEST.test(row.semanticDigest))
|
|
186
|
+
return failed("Balladeer's catalog contains an invalid current meaning");
|
|
187
|
+
const id = reviewSubjectId(s);
|
|
188
|
+
const normalized = normalizePromise({ ...row, promiseId: id });
|
|
189
|
+
if (!normalized.ok || seen.has(id))
|
|
190
|
+
return failed("Balladeer's catalog contains an unreadable or duplicate meaning");
|
|
191
|
+
seen.add(id);
|
|
192
|
+
let anchors = null;
|
|
193
|
+
if (row.anchors !== null && row.anchors !== undefined) {
|
|
194
|
+
const checked = checkPromiseAnchors(row.anchors, { allowRecordedId: true });
|
|
195
|
+
if (checked.ok &&
|
|
196
|
+
checked.value.promiseId === id &&
|
|
197
|
+
checked.value.semanticDigest === row.semanticDigest)
|
|
198
|
+
anchors = checked.value;
|
|
199
|
+
}
|
|
200
|
+
const openFindings = Array.isArray(row.openFindings)
|
|
201
|
+
? row.openFindings.flatMap((item) => {
|
|
202
|
+
const finding = item;
|
|
203
|
+
return finding !== null &&
|
|
204
|
+
typeof finding?.reportId === "string" &&
|
|
205
|
+
UUID.test(finding.reportId) &&
|
|
206
|
+
typeof finding.sourceSha === "string" &&
|
|
207
|
+
SHA.test(finding.sourceSha) &&
|
|
208
|
+
typeof finding.baseSha === "string" &&
|
|
209
|
+
SHA.test(finding.baseSha) &&
|
|
210
|
+
typeof finding.destinationRef === "string" &&
|
|
211
|
+
REF.test(finding.destinationRef) &&
|
|
212
|
+
(finding.destinationDigest === undefined ||
|
|
213
|
+
(typeof finding.destinationDigest === "string" &&
|
|
214
|
+
DIGEST.test(finding.destinationDigest)))
|
|
215
|
+
? [
|
|
216
|
+
{
|
|
217
|
+
reportId: finding.reportId,
|
|
218
|
+
sourceSha: finding.sourceSha,
|
|
219
|
+
baseSha: finding.baseSha,
|
|
220
|
+
destinationRef: finding.destinationRef,
|
|
221
|
+
...(typeof finding.destinationDigest === "string"
|
|
222
|
+
? { destinationDigest: finding.destinationDigest }
|
|
223
|
+
: {}),
|
|
224
|
+
},
|
|
225
|
+
]
|
|
226
|
+
: [];
|
|
227
|
+
})
|
|
228
|
+
: [];
|
|
229
|
+
targets.push({
|
|
230
|
+
...normalized.promise,
|
|
231
|
+
subject: s,
|
|
232
|
+
standing: s.kind,
|
|
233
|
+
anchors,
|
|
234
|
+
openFindings,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
if (body.nextCursor === undefined || body.nextCursor === null)
|
|
238
|
+
return { ok: true, targets, ...(reviewSettings === undefined ? {} : { reviewSettings }) };
|
|
239
|
+
if (typeof body.nextCursor !== "string" ||
|
|
240
|
+
body.nextCursor.length > 2048 ||
|
|
241
|
+
cursors.has(body.nextCursor))
|
|
242
|
+
return failed("Balladeer's catalog pagination did not complete");
|
|
243
|
+
cursors.add(body.nextCursor);
|
|
244
|
+
cursor = body.nextCursor;
|
|
245
|
+
}
|
|
246
|
+
return failed("Balladeer's catalog exceeded the review limit");
|
|
247
|
+
},
|
|
248
|
+
async record(receipt, sendOptions) {
|
|
249
|
+
// Validate the serialized representation too: inherited toJSON/getters
|
|
250
|
+
// must not add unbounded material between validation and upload.
|
|
251
|
+
try {
|
|
252
|
+
const serialized = JSON.stringify(receipt);
|
|
253
|
+
if (serialized.length > 16_384)
|
|
254
|
+
throw new Error("large receipt");
|
|
255
|
+
receipt = JSON.parse(serialized);
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
return { ok: false, reason: "The receipt failed the local data boundary and was not sent" };
|
|
259
|
+
}
|
|
260
|
+
try {
|
|
261
|
+
if (!receiptSafe(receipt))
|
|
262
|
+
return {
|
|
263
|
+
ok: false,
|
|
264
|
+
reason: "The receipt failed the local data boundary and was not sent",
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
return { ok: false, reason: "The receipt failed the local data boundary and was not sent" };
|
|
269
|
+
}
|
|
270
|
+
const reply = await send(receipt, undefined, sendOptions?.timeoutMs, sendOptions?.signal);
|
|
271
|
+
if ("error" in reply)
|
|
272
|
+
return { ok: false, reason: reply.error };
|
|
273
|
+
const body = reply.body;
|
|
274
|
+
if ((reply.status !== 200 && reply.status !== 201) ||
|
|
275
|
+
body?.recorded !== true ||
|
|
276
|
+
typeof body.reportId !== "string")
|
|
277
|
+
return {
|
|
278
|
+
ok: false,
|
|
279
|
+
reason: `Balladeer did not record the review receipt (${reply.status})`,
|
|
280
|
+
};
|
|
281
|
+
return { ok: true, reportId: body.reportId };
|
|
282
|
+
},
|
|
283
|
+
async recordAnchors(target, anchors) {
|
|
284
|
+
let boundedSubject;
|
|
285
|
+
try {
|
|
286
|
+
const serialized = JSON.stringify(anchors);
|
|
287
|
+
if (serialized.length > 64 * 1024)
|
|
288
|
+
throw new Error("large anchors");
|
|
289
|
+
anchors = JSON.parse(serialized);
|
|
290
|
+
boundedSubject = JSON.parse(JSON.stringify(target.subject));
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
return { ok: false, reason: "The anchors failed the local data boundary" };
|
|
294
|
+
}
|
|
295
|
+
const currentSubject = subject(boundedSubject);
|
|
296
|
+
if (currentSubject === undefined ||
|
|
297
|
+
!exactKeys(boundedSubject, currentSubject.kind === "agreed"
|
|
298
|
+
? ["kind", "promiseId", "revisionId"]
|
|
299
|
+
: ["kind", "proposalId", "candidateRevision", "candidateEventId"]))
|
|
300
|
+
return { ok: false, reason: "The subject failed the local data boundary" };
|
|
301
|
+
const checked = checkPromiseAnchors(anchors, { allowRecordedId: true });
|
|
302
|
+
if (!checked.ok ||
|
|
303
|
+
anchors.promiseId !== target.promiseId ||
|
|
304
|
+
anchors.semanticDigest !== target.semanticDigest)
|
|
305
|
+
return { ok: false, reason: "The anchors do not match this current meaning" };
|
|
306
|
+
const reply = await send({
|
|
307
|
+
schemaVersion: "balladeer-review-anchors/v1",
|
|
308
|
+
subject: currentSubject,
|
|
309
|
+
semanticDigest: target.semanticDigest,
|
|
310
|
+
anchors,
|
|
311
|
+
});
|
|
312
|
+
if ("error" in reply)
|
|
313
|
+
return { ok: false, reason: reply.error };
|
|
314
|
+
const body = reply.body;
|
|
315
|
+
return (reply.status === 200 || reply.status === 201) && body?.recorded === true
|
|
316
|
+
? { ok: true, alreadyRecorded: body.alreadyRecorded === true }
|
|
317
|
+
: { ok: false, reason: `Balladeer did not record the anchors (${reply.status})` };
|
|
318
|
+
},
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
export function connectionReviewsClient(input) {
|
|
322
|
+
const connection = connectionAgent(input);
|
|
323
|
+
return connection.ok ? { ok: true, client: reviewsClientFor(connection.agent) } : connection;
|
|
324
|
+
}
|
|
325
|
+
/** Adapt the existing local anchor builder without rereading each meaning through MCP. */
|
|
326
|
+
export function catalogReaders(client, targets) {
|
|
327
|
+
const byId = new Map(targets.map((target) => [target.promiseId, target]));
|
|
328
|
+
return {
|
|
329
|
+
reader: {
|
|
330
|
+
async get(id) {
|
|
331
|
+
const promise = byId.get(id);
|
|
332
|
+
return promise
|
|
333
|
+
? { ok: true, promise }
|
|
334
|
+
: { ok: false, reason: "No current review target by that id" };
|
|
335
|
+
},
|
|
336
|
+
async list(options) {
|
|
337
|
+
const ids = [...byId.keys()];
|
|
338
|
+
return {
|
|
339
|
+
ok: true,
|
|
340
|
+
ids: options?.max === undefined ? ids : ids.slice(0, options.max),
|
|
341
|
+
...(options?.max !== undefined && ids.length > options.max ? { more: true } : {}),
|
|
342
|
+
};
|
|
343
|
+
},
|
|
344
|
+
},
|
|
345
|
+
anchors: {
|
|
346
|
+
async list() {
|
|
347
|
+
return { ok: true, promises: targets, refused: 0 };
|
|
348
|
+
},
|
|
349
|
+
async record(anchors) {
|
|
350
|
+
const target = byId.get(anchors.promiseId);
|
|
351
|
+
return target
|
|
352
|
+
? client.recordAnchors(target, anchors)
|
|
353
|
+
: { ok: false, reason: "The target is no longer current" };
|
|
354
|
+
},
|
|
355
|
+
},
|
|
356
|
+
};
|
|
357
|
+
}
|