balladeer 1.0.17 → 1.0.18
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/dist/anchor-budget.d.ts +40 -0
- package/dist/anchor-budget.js +110 -0
- package/dist/anchoring.d.ts +241 -0
- package/dist/anchoring.js +766 -0
- package/dist/anchors-client.d.ts +62 -0
- package/dist/anchors-client.js +197 -0
- package/dist/anchors-schema.d.ts +85 -0
- package/dist/anchors-schema.js +246 -0
- package/dist/cli.js +5 -1
- package/dist/commands/anchor-usage.d.ts +6 -0
- package/dist/commands/anchor-usage.js +19 -0
- package/dist/commands/anchor.d.ts +127 -0
- package/dist/commands/anchor.js +303 -0
- package/dist/commands/judge.d.ts +69 -21
- package/dist/commands/judge.js +354 -89
- package/dist/commands/map.js +2 -0
- package/dist/guidance-hook.mjs +5 -2
- package/dist/guidance.d.ts +7 -0
- package/dist/guidance.js +10 -1
- package/dist/headless-agent.d.ts +19 -1
- package/dist/headless-agent.js +22 -5
- package/dist/judge-brief.d.ts +20 -2
- package/dist/judge-brief.js +26 -2
- package/dist/promise-meaning.d.ts +17 -0
- package/dist/promise-meaning.js +15 -6
- package/dist/risk/git.d.ts +3 -1
- package/dist/risk/git.js +3 -3
- package/dist/risk/graph-cache.d.ts +83 -0
- package/dist/risk/graph-cache.js +291 -0
- package/dist/risk/import-graph.d.ts +43 -0
- package/dist/risk/import-graph.js +88 -34
- package/dist/risk/pipeline.d.ts +13 -0
- package/dist/risk/pipeline.js +15 -4
- package/dist/wire.d.ts +18 -2
- package/dist/wire.js +1 -1
- package/package.json +7 -2
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { type AnchorBudget } from "../anchor-budget.js";
|
|
2
|
+
import { anchoringLines, type AnchorRun } from "../anchoring.js";
|
|
3
|
+
import { connectionAnchorsClient, type AnchoredPromise, type AnchorsClient } from "../anchors-client.js";
|
|
4
|
+
import { type PromiseAnchors } from "../anchors-schema.js";
|
|
5
|
+
import { type BehaviorMap, type BehaviorMapMeaning } from "../behavior-map-schema.js";
|
|
6
|
+
import { type HeadlessAgentOptions, type HeadlessAgentResult, type HeadlessClient, type LoginCheck } from "../headless-agent.js";
|
|
7
|
+
import { type PromiseReader } from "../promise-meaning.js";
|
|
8
|
+
import { type RepositoryGraph } from "../risk/graph-cache.js";
|
|
9
|
+
import { ANCHOR_USAGE } from "./anchor-usage.js";
|
|
10
|
+
import { type StoredMap } from "./map.js";
|
|
11
|
+
export { anchoringLines, connectionAnchorsClient };
|
|
12
|
+
/**
|
|
13
|
+
* `balladeer anchor`, and the anchoring step the push check runs before it
|
|
14
|
+
* selects what to judge.
|
|
15
|
+
*
|
|
16
|
+
* Both read the repository's agreed promises and their current anchors from
|
|
17
|
+
* Balladeer, build the repository graph at the commit from this laptop's
|
|
18
|
+
* cache, and bring every promise's anchors current within today's budget (see
|
|
19
|
+
* `../anchoring.ts`). A behavior map for a promise's current meaning still
|
|
20
|
+
* wins over its anchors when promises are selected.
|
|
21
|
+
*/
|
|
22
|
+
export type AnchorStage = Readonly<{
|
|
23
|
+
available: true;
|
|
24
|
+
promises: readonly AnchoredPromise[];
|
|
25
|
+
/** Maps in this checkout for their promise's current meaning, by promise id. */
|
|
26
|
+
currentMaps: ReadonlyMap<string, BehaviorMap>;
|
|
27
|
+
repository?: RepositoryGraph;
|
|
28
|
+
run?: AnchorRun;
|
|
29
|
+
}> | Readonly<{
|
|
30
|
+
available: false;
|
|
31
|
+
/** Why the anchors could not be read, for a line when one is said. */
|
|
32
|
+
reason: string;
|
|
33
|
+
/**
|
|
34
|
+
* Anchors do not apply here at all: this laptop has no connection for
|
|
35
|
+
* the repository, or its server has no anchors route yet. Neither is
|
|
36
|
+
* worth a line of its own; everything works from maps, as before.
|
|
37
|
+
*/
|
|
38
|
+
unsupported: boolean;
|
|
39
|
+
}>;
|
|
40
|
+
export type AnchorStageInput = Readonly<{
|
|
41
|
+
root: string;
|
|
42
|
+
head: string;
|
|
43
|
+
environment: NodeJS.ProcessEnv;
|
|
44
|
+
maps: readonly StoredMap[];
|
|
45
|
+
client: AnchorsClient | undefined;
|
|
46
|
+
/** Why there is no client, when there is none. */
|
|
47
|
+
clientMissing?: string;
|
|
48
|
+
reader: PromiseReader | undefined;
|
|
49
|
+
login: () => Promise<LoginCheck | undefined>;
|
|
50
|
+
now: () => Date;
|
|
51
|
+
deadline?: number;
|
|
52
|
+
signal?: AbortSignal;
|
|
53
|
+
concurrency?: number;
|
|
54
|
+
scratch?: string;
|
|
55
|
+
force?: ReadonlySet<string>;
|
|
56
|
+
only?: ReadonlySet<string>;
|
|
57
|
+
budget?: AnchorBudget;
|
|
58
|
+
runAgent?: (options: HeadlessAgentOptions) => Promise<HeadlessAgentResult>;
|
|
59
|
+
/** Replaces the graph build, for tests. */
|
|
60
|
+
graph?: (root: string, head: string) => RepositoryGraph;
|
|
61
|
+
}>;
|
|
62
|
+
/** Every parsed map whose meaning is the promise's current one, by promise id. */
|
|
63
|
+
export declare function currentMapsFor(maps: readonly StoredMap[], promises: readonly AnchoredPromise[]): Map<string, BehaviorMap>;
|
|
64
|
+
/** Read the anchors, and bring them current. Never throws for anything the server or a call does. */
|
|
65
|
+
export declare function anchorStage(input: AnchorStageInput): Promise<AnchorStage>;
|
|
66
|
+
/**
|
|
67
|
+
* Anchors in the shape the risk scorer reads, so a promise selected by its
|
|
68
|
+
* anchors is ordered by the same rule as a mapped one: its entry points and
|
|
69
|
+
* symbols as core, its linked tests, its resources, and the meaning's excerpt
|
|
70
|
+
* when it was read, which is what the scorer compares a change's words with.
|
|
71
|
+
*/
|
|
72
|
+
export declare function anchorsAsMap(anchors: PromiseAnchors, meaning?: BehaviorMapMeaning): BehaviorMap;
|
|
73
|
+
/**
|
|
74
|
+
* What a judge is handed where a promise has anchors and no map: the anchors
|
|
75
|
+
* still in the code, the linked tests the graph gives at head, and the files
|
|
76
|
+
* one import step from the anchor files.
|
|
77
|
+
*/
|
|
78
|
+
export type JudgeAnchors = Readonly<{
|
|
79
|
+
anchors: PromiseAnchors;
|
|
80
|
+
linkedTests: readonly {
|
|
81
|
+
file: string;
|
|
82
|
+
}[];
|
|
83
|
+
neighborhood: readonly string[];
|
|
84
|
+
}>;
|
|
85
|
+
/** A promise's anchors as the judge receives them, read off the graph at the judged commit. */
|
|
86
|
+
export declare function judgeAnchors(anchors: PromiseAnchors, repository: RepositoryGraph): JudgeAnchors;
|
|
87
|
+
/**
|
|
88
|
+
* The promises a change edits an anchor file of (an entry point's or a
|
|
89
|
+
* symbol's file), from each promise's anchor files. Nothing further: no file
|
|
90
|
+
* that merely imports or is imported by an anchor file.
|
|
91
|
+
*/
|
|
92
|
+
export declare function anchoredByChange(anchorFilesOf: ReadonlyMap<string, readonly string[]>, changed: readonly string[]): string[];
|
|
93
|
+
export type AnchorArguments = Readonly<{
|
|
94
|
+
promiseId?: string;
|
|
95
|
+
all: boolean;
|
|
96
|
+
json: boolean;
|
|
97
|
+
repo?: string;
|
|
98
|
+
client: HeadlessClient | "auto";
|
|
99
|
+
repository?: string;
|
|
100
|
+
controlPlane: string;
|
|
101
|
+
}>;
|
|
102
|
+
export { ANCHOR_USAGE };
|
|
103
|
+
export declare function parseAnchorArguments(argv: readonly string[]): AnchorArguments;
|
|
104
|
+
export type AnchorDependencies = Readonly<{
|
|
105
|
+
client?: AnchorsClient;
|
|
106
|
+
reader?: PromiseReader;
|
|
107
|
+
resolveLogin?: (requested: HeadlessClient | "auto") => Promise<LoginCheck | undefined>;
|
|
108
|
+
runAgent?: (options: HeadlessAgentOptions) => Promise<HeadlessAgentResult>;
|
|
109
|
+
now?: () => Date;
|
|
110
|
+
graph?: (root: string, head: string) => RepositoryGraph;
|
|
111
|
+
scratch?: string;
|
|
112
|
+
}>;
|
|
113
|
+
/** `balladeer anchor`, from arguments already parsed. */
|
|
114
|
+
export declare function runAnchor(options: Readonly<{
|
|
115
|
+
args: AnchorArguments;
|
|
116
|
+
cwd: string;
|
|
117
|
+
environment: NodeJS.ProcessEnv;
|
|
118
|
+
write: (text: string) => void;
|
|
119
|
+
error: (text: string) => void;
|
|
120
|
+
deps?: AnchorDependencies;
|
|
121
|
+
}>): Promise<number>;
|
|
122
|
+
export declare function anchorCommand(argv: readonly string[], io: Readonly<{
|
|
123
|
+
cwd: string;
|
|
124
|
+
environment: NodeJS.ProcessEnv;
|
|
125
|
+
write: (text: string) => void;
|
|
126
|
+
error: (text: string) => void;
|
|
127
|
+
}>): Promise<number>;
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { join, resolve } from "node:path";
|
|
2
|
+
import { anchorBudget } from "../anchor-budget.js";
|
|
3
|
+
import { anchoringLines, bringAnchorsCurrent, linkedTestsFor, neighborhood, } from "../anchoring.js";
|
|
4
|
+
import { connectionAnchorsClient, } from "../anchors-client.js";
|
|
5
|
+
import { anchorFiles } from "../anchors-schema.js";
|
|
6
|
+
import { BEHAVIOR_MAP_DIRECTORY, BEHAVIOR_MAP_SCHEMA_VERSION, } from "../behavior-map-schema.js";
|
|
7
|
+
import { repositoryRoot } from "../git.js";
|
|
8
|
+
import { resolveClient, } from "../headless-agent.js";
|
|
9
|
+
import { connectionReader } from "../promise-meaning.js";
|
|
10
|
+
import { buildRepositoryGraph } from "../risk/graph-cache.js";
|
|
11
|
+
import { resolveCommit } from "../scratch-worktree.js";
|
|
12
|
+
import { normalizeControlPlane, StoreError } from "../store.js";
|
|
13
|
+
import { DEFAULT_CONTROL_PLANE } from "../wire.js";
|
|
14
|
+
import { ANCHOR_USAGE } from "./anchor-usage.js";
|
|
15
|
+
import { readBehaviorMaps } from "./map.js";
|
|
16
|
+
// What the push check loads from here once a push is its to judge.
|
|
17
|
+
export { anchoringLines, connectionAnchorsClient };
|
|
18
|
+
/** Every parsed map whose meaning is the promise's current one, by promise id. */
|
|
19
|
+
export function currentMapsFor(maps, promises) {
|
|
20
|
+
const digests = new Map(promises.map((promise) => [promise.promiseId, promise.semanticDigest]));
|
|
21
|
+
const current = new Map();
|
|
22
|
+
for (const entry of maps)
|
|
23
|
+
if (entry.map !== undefined && digests.get(entry.promiseId) === entry.map.semanticDigest)
|
|
24
|
+
current.set(entry.promiseId, entry.map);
|
|
25
|
+
return current;
|
|
26
|
+
}
|
|
27
|
+
/** Read the anchors, and bring them current. Never throws for anything the server or a call does. */
|
|
28
|
+
export async function anchorStage(input) {
|
|
29
|
+
if (input.client === undefined)
|
|
30
|
+
return {
|
|
31
|
+
available: false,
|
|
32
|
+
reason: input.clientMissing ?? "there is no Balladeer connection for this repository",
|
|
33
|
+
unsupported: true,
|
|
34
|
+
};
|
|
35
|
+
const listed = await input.client.list();
|
|
36
|
+
if (!listed.ok)
|
|
37
|
+
return { available: false, reason: listed.reason, unsupported: listed.unsupported === true };
|
|
38
|
+
const promises = listed.promises;
|
|
39
|
+
const currentMaps = currentMapsFor(input.maps, promises);
|
|
40
|
+
if (promises.length === 0)
|
|
41
|
+
return { available: true, promises, currentMaps };
|
|
42
|
+
let repository;
|
|
43
|
+
try {
|
|
44
|
+
repository = (input.graph ??
|
|
45
|
+
((root, head) => buildRepositoryGraph({ root, rev: head, environment: input.environment })))(input.root, input.head);
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
return {
|
|
49
|
+
available: false,
|
|
50
|
+
reason: `the repository could not be read at ${input.head.slice(0, 12)}: ${error.message}`,
|
|
51
|
+
unsupported: false,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
const run = await bringAnchorsCurrent({
|
|
55
|
+
promises,
|
|
56
|
+
currentMaps,
|
|
57
|
+
repository,
|
|
58
|
+
client: input.client,
|
|
59
|
+
reader: input.reader,
|
|
60
|
+
login: input.login,
|
|
61
|
+
budget: input.budget ?? anchorBudget(input.environment, input.now),
|
|
62
|
+
environment: input.environment,
|
|
63
|
+
now: input.now,
|
|
64
|
+
repositoryKey: repository.root,
|
|
65
|
+
...(input.force === undefined ? {} : { force: input.force }),
|
|
66
|
+
...(input.only === undefined ? {} : { only: input.only }),
|
|
67
|
+
...(input.deadline === undefined ? {} : { deadline: input.deadline }),
|
|
68
|
+
...(input.signal === undefined ? {} : { signal: input.signal }),
|
|
69
|
+
...(input.concurrency === undefined ? {} : { concurrency: input.concurrency }),
|
|
70
|
+
...(input.scratch === undefined ? {} : { scratch: input.scratch }),
|
|
71
|
+
...(input.runAgent === undefined ? {} : { runAgent: input.runAgent }),
|
|
72
|
+
});
|
|
73
|
+
repository.save();
|
|
74
|
+
return { available: true, promises, currentMaps, repository, run };
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Anchors in the shape the risk scorer reads, so a promise selected by its
|
|
78
|
+
* anchors is ordered by the same rule as a mapped one: its entry points and
|
|
79
|
+
* symbols as core, its linked tests, its resources, and the meaning's excerpt
|
|
80
|
+
* when it was read, which is what the scorer compares a change's words with.
|
|
81
|
+
*/
|
|
82
|
+
export function anchorsAsMap(anchors, meaning) {
|
|
83
|
+
return {
|
|
84
|
+
schemaVersion: BEHAVIOR_MAP_SCHEMA_VERSION,
|
|
85
|
+
promiseId: anchors.promiseId,
|
|
86
|
+
semanticDigest: anchors.semanticDigest,
|
|
87
|
+
mappedAt: {
|
|
88
|
+
sha: anchors.anchoredAt.sha,
|
|
89
|
+
at: anchors.anchoredAt.at,
|
|
90
|
+
client: anchors.anchoredAt.client ?? "claude",
|
|
91
|
+
model: anchors.anchoredAt.model ?? "",
|
|
92
|
+
},
|
|
93
|
+
entryPoints: anchors.entryPoints.map((entry) => ({ ...entry, note: "" })),
|
|
94
|
+
symbols: anchors.symbols.map((symbol) => ({ ...symbol, role: "core" })),
|
|
95
|
+
resources: [...anchors.resources],
|
|
96
|
+
linkedTests: anchors.linkedTests.map((test) => ({ file: test.file, name: "", cases: [] })),
|
|
97
|
+
sinks: [],
|
|
98
|
+
confidence: 0,
|
|
99
|
+
notes: "",
|
|
100
|
+
...(meaning === undefined ? {} : { meaning }),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/** A promise's anchors as the judge receives them, read off the graph at the judged commit. */
|
|
104
|
+
export function judgeAnchors(anchors, repository) {
|
|
105
|
+
const files = anchorFiles(anchors);
|
|
106
|
+
return {
|
|
107
|
+
anchors,
|
|
108
|
+
linkedTests: linkedTestsFor(repository.graph, files),
|
|
109
|
+
neighborhood: neighborhood(repository.graph, files),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* The promises a change edits an anchor file of (an entry point's or a
|
|
114
|
+
* symbol's file), from each promise's anchor files. Nothing further: no file
|
|
115
|
+
* that merely imports or is imported by an anchor file.
|
|
116
|
+
*/
|
|
117
|
+
export function anchoredByChange(anchorFilesOf, changed) {
|
|
118
|
+
const edited = new Set(changed);
|
|
119
|
+
const selected = [];
|
|
120
|
+
for (const [promiseId, files] of anchorFilesOf)
|
|
121
|
+
if (files.some((file) => edited.has(file)))
|
|
122
|
+
selected.push(promiseId);
|
|
123
|
+
return selected.sort();
|
|
124
|
+
}
|
|
125
|
+
export { ANCHOR_USAGE };
|
|
126
|
+
export function parseAnchorArguments(argv) {
|
|
127
|
+
const args = [...argv];
|
|
128
|
+
const positional = [];
|
|
129
|
+
let all = false;
|
|
130
|
+
let json = false;
|
|
131
|
+
let repo;
|
|
132
|
+
let repository;
|
|
133
|
+
let controlPlane;
|
|
134
|
+
let client = "auto";
|
|
135
|
+
const value = (flag, inline) => {
|
|
136
|
+
const next = inline ?? args.shift();
|
|
137
|
+
if (next === undefined || next === "")
|
|
138
|
+
throw new StoreError("usage", `${flag} needs a value.`);
|
|
139
|
+
return next;
|
|
140
|
+
};
|
|
141
|
+
while (args.length > 0) {
|
|
142
|
+
const arg = args.shift();
|
|
143
|
+
if (!arg.startsWith("--")) {
|
|
144
|
+
positional.push(arg);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const [name, ...rest] = arg.split("=");
|
|
148
|
+
const inline = rest.length > 0 ? rest.join("=") : undefined;
|
|
149
|
+
if (name === "--all")
|
|
150
|
+
all = true;
|
|
151
|
+
else if (name === "--json")
|
|
152
|
+
json = true;
|
|
153
|
+
else if (name === "--repo")
|
|
154
|
+
repo = value(name, inline);
|
|
155
|
+
else if (name === "--repository")
|
|
156
|
+
repository = value(name, inline);
|
|
157
|
+
else if (name === "--control-plane")
|
|
158
|
+
controlPlane = value(name, inline);
|
|
159
|
+
else if (name === "--client") {
|
|
160
|
+
const requested = value(name, inline);
|
|
161
|
+
if (requested !== "auto" && requested !== "claude" && requested !== "codex")
|
|
162
|
+
throw new StoreError("usage", "--client needs auto, claude or codex.");
|
|
163
|
+
client = requested;
|
|
164
|
+
}
|
|
165
|
+
else
|
|
166
|
+
throw new StoreError("usage", `Unknown option ${arg} for anchor.`);
|
|
167
|
+
}
|
|
168
|
+
if (positional.length > 1)
|
|
169
|
+
throw new StoreError("usage", "anchor takes at most one promise id.");
|
|
170
|
+
const promiseId = positional[0];
|
|
171
|
+
if (promiseId !== undefined && all)
|
|
172
|
+
throw new StoreError("usage", "Name one promise or pass --all, not both.");
|
|
173
|
+
if (promiseId !== undefined && !/^prom_[a-z0-9]{8,64}$/.test(promiseId))
|
|
174
|
+
throw new StoreError("usage", `"${promiseId}" is not a promise id.`);
|
|
175
|
+
const chosen = controlPlane ?? process.env.BALLADEER_CONTROL_PLANE?.trim() ?? DEFAULT_CONTROL_PLANE;
|
|
176
|
+
return {
|
|
177
|
+
...(promiseId === undefined ? {} : { promiseId }),
|
|
178
|
+
all: promiseId === undefined,
|
|
179
|
+
json,
|
|
180
|
+
...(repo === undefined ? {} : { repo }),
|
|
181
|
+
client,
|
|
182
|
+
...(repository === undefined ? {} : { repository }),
|
|
183
|
+
controlPlane: normalizeControlPlane(chosen),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
function count(n, one, many) {
|
|
187
|
+
return `${n} ${n === 1 ? one : many}`;
|
|
188
|
+
}
|
|
189
|
+
function describeOutcome(outcome, anchors) {
|
|
190
|
+
const name = `${outcome.title} (${outcome.promiseId})`;
|
|
191
|
+
const shape = anchors === undefined
|
|
192
|
+
? ""
|
|
193
|
+
: `: ${count(anchors.entryPoints.length, "entry point", "entry points")}, ${count(anchors.symbols.length, "symbol", "symbols")}, ${count(anchors.linkedTests.length, "linked test", "linked tests")}`;
|
|
194
|
+
switch (outcome.status) {
|
|
195
|
+
case "current":
|
|
196
|
+
return `already anchored: ${name}${shape}`;
|
|
197
|
+
case "anchored":
|
|
198
|
+
return `anchored${outcome.again === true ? " again" : ""}: ${name}${shape}`;
|
|
199
|
+
case "seeded":
|
|
200
|
+
return `anchored from its behavior map: ${name}${shape}`;
|
|
201
|
+
case "stale":
|
|
202
|
+
return `not anchored again: ${name}; some of its anchored code is gone, and what is left is used${outcome.reason ? ` (${outcome.reason})` : ""}`;
|
|
203
|
+
default:
|
|
204
|
+
return `not anchored: ${name}: ${outcome.reason ?? outcome.status}`;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
/** `balladeer anchor`, from arguments already parsed. */
|
|
208
|
+
export async function runAnchor(options) {
|
|
209
|
+
const { args } = options;
|
|
210
|
+
const deps = options.deps ?? {};
|
|
211
|
+
const now = deps.now ?? (() => new Date());
|
|
212
|
+
const fail = (reason, message) => {
|
|
213
|
+
if (args.json) {
|
|
214
|
+
const step = { step: "error", reason, message, changed: false, exitCode: 4 };
|
|
215
|
+
options.write(`${JSON.stringify(step)}\n`);
|
|
216
|
+
}
|
|
217
|
+
else
|
|
218
|
+
options.error(`${message}\n`);
|
|
219
|
+
return 4;
|
|
220
|
+
};
|
|
221
|
+
const root = args.repo !== undefined ? resolve(options.cwd, args.repo) : await repositoryRoot(options.cwd);
|
|
222
|
+
if (root === undefined)
|
|
223
|
+
return fail("not_a_repository", "This is not a git repository, so there is nothing to anchor.");
|
|
224
|
+
const head = await resolveCommit(root, "HEAD");
|
|
225
|
+
if (head === undefined)
|
|
226
|
+
return fail("no_head", "This repository has no commit yet, so there is nothing to anchor.");
|
|
227
|
+
const connection = {
|
|
228
|
+
environment: options.environment,
|
|
229
|
+
controlPlane: args.controlPlane,
|
|
230
|
+
cwd: root,
|
|
231
|
+
...(args.repository === undefined ? {} : { repository: args.repository }),
|
|
232
|
+
};
|
|
233
|
+
let client = deps.client;
|
|
234
|
+
if (client === undefined) {
|
|
235
|
+
const found = connectionAnchorsClient(connection);
|
|
236
|
+
if (!found.ok)
|
|
237
|
+
return fail("no_connection", `There is no Balladeer connection for this repository on this laptop, so nothing was anchored: ${found.reason}`);
|
|
238
|
+
client = found.client;
|
|
239
|
+
}
|
|
240
|
+
let reader = deps.reader;
|
|
241
|
+
if (reader === undefined) {
|
|
242
|
+
const found = connectionReader(connection);
|
|
243
|
+
if (found.ok)
|
|
244
|
+
reader = found.reader;
|
|
245
|
+
}
|
|
246
|
+
let login;
|
|
247
|
+
const stage = await anchorStage({
|
|
248
|
+
root,
|
|
249
|
+
head,
|
|
250
|
+
environment: options.environment,
|
|
251
|
+
maps: readBehaviorMaps(join(root, BEHAVIOR_MAP_DIRECTORY)),
|
|
252
|
+
client,
|
|
253
|
+
reader,
|
|
254
|
+
login: () => (login ??= (deps.resolveLogin ??
|
|
255
|
+
((requested) => resolveClient(requested, { env: options.environment, cwd: root })))(args.client)),
|
|
256
|
+
now,
|
|
257
|
+
...(args.promiseId === undefined
|
|
258
|
+
? {}
|
|
259
|
+
: { force: new Set([args.promiseId]), only: new Set([args.promiseId]) }),
|
|
260
|
+
...(deps.runAgent === undefined ? {} : { runAgent: deps.runAgent }),
|
|
261
|
+
...(deps.graph === undefined ? {} : { graph: deps.graph }),
|
|
262
|
+
...(deps.scratch === undefined ? {} : { scratch: deps.scratch }),
|
|
263
|
+
});
|
|
264
|
+
if (!stage.available)
|
|
265
|
+
return fail("anchors_unavailable", `Nothing was anchored: ${stage.reason}.`);
|
|
266
|
+
if (args.promiseId !== undefined && !stage.promises.some((p) => p.promiseId === args.promiseId))
|
|
267
|
+
return fail("not_agreed_here", `${args.promiseId} is not an agreed promise of this repository, so it was not anchored.`);
|
|
268
|
+
const outcomes = (stage.run?.outcomes ?? []).filter((outcome) => args.promiseId === undefined || outcome.promiseId === args.promiseId);
|
|
269
|
+
const anchors = stage.run?.anchors ?? new Map();
|
|
270
|
+
if (args.json) {
|
|
271
|
+
for (const outcome of outcomes)
|
|
272
|
+
options.write(`${JSON.stringify({
|
|
273
|
+
step: "anchor",
|
|
274
|
+
promiseId: outcome.promiseId,
|
|
275
|
+
status: outcome.status,
|
|
276
|
+
...(outcome.reason === undefined ? {} : { reason: outcome.reason }),
|
|
277
|
+
...(outcome.recorded === undefined ? {} : { recorded: outcome.recorded }),
|
|
278
|
+
...(anchors.has(outcome.promiseId) ? { anchors: anchors.get(outcome.promiseId) } : {}),
|
|
279
|
+
})}\n`);
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
if (stage.promises.length === 0)
|
|
283
|
+
options.write("This repository has no agreed promise yet, so there is nothing to anchor.\n");
|
|
284
|
+
for (const outcome of outcomes)
|
|
285
|
+
options.write(`${describeOutcome(outcome, anchors.get(outcome.promiseId))}\n`);
|
|
286
|
+
if (stage.run !== undefined)
|
|
287
|
+
for (const line of anchoringLines(stage.run, { push: false }))
|
|
288
|
+
options.write(`${line}\n`);
|
|
289
|
+
}
|
|
290
|
+
const unanchored = outcomes.filter((outcome) => !["current", "anchored", "seeded"].includes(outcome.status));
|
|
291
|
+
return unanchored.length > 0 ? 1 : 0;
|
|
292
|
+
}
|
|
293
|
+
export async function anchorCommand(argv, io) {
|
|
294
|
+
let args;
|
|
295
|
+
try {
|
|
296
|
+
args = parseAnchorArguments(argv);
|
|
297
|
+
}
|
|
298
|
+
catch (error) {
|
|
299
|
+
io.error(`${error instanceof Error ? error.message : String(error)}\n\n${ANCHOR_USAGE}`);
|
|
300
|
+
return 4;
|
|
301
|
+
}
|
|
302
|
+
return runAnchor({ args, ...io });
|
|
303
|
+
}
|
package/dist/commands/judge.d.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
|
+
import type { AnchorBudget } from "../anchor-budget.js";
|
|
2
|
+
import type { AnchorsClient } from "../anchors-client.js";
|
|
1
3
|
import { type BehaviorMap } from "../behavior-map-schema.js";
|
|
2
4
|
import { type HeadlessAgentOptions, type HeadlessAgentResult, type HeadlessClient, type LoginCheck } from "../headless-agent.js";
|
|
3
5
|
import { type HookHost, type MergeTarget } from "../judge-hook.js";
|
|
4
6
|
import { type PromiseDocument, type PromiseReader } from "../promise-meaning.js";
|
|
7
|
+
import type { RepositoryGraph } from "../risk/graph-cache.js";
|
|
5
8
|
import { type CarriedFile } from "../scratch-worktree.js";
|
|
6
9
|
import type { Features } from "../risk/contract.js";
|
|
10
|
+
import type { AnchorStage, JudgeAnchors } from "./anchor.js";
|
|
7
11
|
/**
|
|
8
12
|
* `balladeer judge`: for a change about to leave this machine, ask the person's
|
|
9
13
|
* own coding agent, one promise at a time, whether the change keeps it.
|
|
@@ -109,6 +113,8 @@ export type JudgeVerdict = {
|
|
|
109
113
|
};
|
|
110
114
|
briefRevision: string;
|
|
111
115
|
mapUsed: boolean;
|
|
116
|
+
/** The judge was handed the promise's anchors and their neighborhood, where it had no map. */
|
|
117
|
+
anchorsUsed?: boolean;
|
|
112
118
|
linkedTestRun: {
|
|
113
119
|
file: string;
|
|
114
120
|
outcome: "pass" | "fail" | "error" | "none";
|
|
@@ -213,10 +219,13 @@ type JudgeAnswer = Readonly<{
|
|
|
213
219
|
export declare const NEXT_STEP_LIMIT = 500;
|
|
214
220
|
/** The judge's final JSON, read strictly enough that a malformed answer is not a verdict. */
|
|
215
221
|
export declare function readJudgeAnswer(json: Record<string, unknown> | undefined): JudgeAnswer | undefined;
|
|
222
|
+
export type { JudgeAnchors } from "./anchor.js";
|
|
216
223
|
export type JudgePromiseInput = Readonly<{
|
|
217
224
|
root: string;
|
|
218
225
|
promise: PromiseDocument;
|
|
219
226
|
map?: BehaviorMap;
|
|
227
|
+
/** Handed only when there is no map. */
|
|
228
|
+
anchors?: JudgeAnchors;
|
|
220
229
|
base: string;
|
|
221
230
|
head: string;
|
|
222
231
|
changedFiles: readonly string[];
|
|
@@ -265,7 +274,7 @@ export type JudgeArguments = Readonly<{
|
|
|
265
274
|
repository?: string;
|
|
266
275
|
controlPlane: string;
|
|
267
276
|
}>;
|
|
268
|
-
export declare const JUDGE_USAGE = " npx -y balladeer@latest judge [--promises <id,id>] [--top <k>] [--base <ref>] [--head <ref>]\n [--report-only | --block] [--wait] [--json] [--client auto|claude|codex]\n [--from-file <promise.json> [--map <map.json>]]\n [--hook claude|codex|git] [--install-hook [claude|codex|git]]\n [--push-check on|off]\n Judge whether this change keeps the promises it could break, one promise\n at a time, on your own Claude Code or Codex login. A break counts only\n with a command that fails with the change and passes without it, and\n this command runs it both ways itself; anything unproven is \"could not\n tell\". Report mode prints one line per promise and never stops anything;\n --block exits 2 on a reproduced break, and when judges are still running\n at the time limit it holds the push rather than letting it through.\n --wait runs without the time limit. Promises come from --promises, else\n
|
|
277
|
+
export declare const JUDGE_USAGE = " npx -y balladeer@latest judge [--promises <id,id>] [--top <k>] [--base <ref>] [--head <ref>]\n [--report-only | --block] [--wait] [--json] [--client auto|claude|codex]\n [--from-file <promise.json> [--map <map.json>]]\n [--hook claude|codex|git] [--install-hook [claude|codex|git]]\n [--push-check on|off]\n Judge whether this change keeps the promises it could break, one promise\n at a time, on your own Claude Code or Codex login. A break counts only\n with a command that fails with the change and passes without it, and\n this command runs it both ways itself; anything unproven is \"could not\n tell\". Report mode prints one line per promise and never stops anything;\n --block exits 2 on a reproduced break, and when judges are still running\n at the time limit it holds the push rather than letting it through.\n --wait runs without the time limit. Promises come from --promises, else\n every agreed promise whose anchored code the change edits, and every\n promise with a behavior map whose own code or tests it edits (with\n \"select\": \"ranked\" in .continuity/judge.json, the top of the maps'\n ranking and a small random sample); plus that file's alwaysJudge. A\n promise with no anchors yet is anchored first, by one small call on\n your own login (see `balladeer anchor`), at most 20 a day on this\n laptop, and a line says what that cost. Verdicts are kept in\n .continuity/judge-log/, which git ignores. --install-hook adds the push\n hook to .claude/settings.json and .codex/hooks.json, before and after\n each shell command: report mode judges after a push has landed, so the\n push never waits, and block mode judges before it. With git it writes\n .git/hooks/pre-push, which judges before the push in both modes. With\n \"offers\": true in .continuity/judge.json, the push hook also reads the\n change for rules no promise records and offers at most three to\n whoever is pushing; it records nothing and never holds a push (see\n `balladeer offers`). Setup also writes the same pair of entries at\n each host's user scope, running `judge --hook <host> --user-scope`,\n so the check runs in every connected repository with agreed promises\n and says nothing anywhere else; a checkout with its own entry keeps\n the check to that entry. --push-check off turns the user-scope check\n off on this laptop, and --push-check on turns it back on.\n";
|
|
269
278
|
export declare function parseJudgeArguments(argv: readonly string[]): JudgeArguments;
|
|
270
279
|
/**
|
|
271
280
|
* The subset of `balladeer-risk/v1` this command reads: enough to order the
|
|
@@ -290,28 +299,39 @@ export declare function defaultBase(root: string, head: string): Promise<string
|
|
|
290
299
|
export declare function changedFiles(root: string, base: string, head: string): Promise<string[]>;
|
|
291
300
|
export type Selection = Readonly<{
|
|
292
301
|
promiseId: string;
|
|
293
|
-
why: "file" | "named" | "risk" | "sample" | "mapped" | "always";
|
|
302
|
+
why: "file" | "named" | "risk" | "anchored" | "sample" | "mapped" | "always";
|
|
294
303
|
}>;
|
|
295
304
|
/**
|
|
296
|
-
* Which promises to judge: named ones
|
|
297
|
-
*
|
|
305
|
+
* Which promises to judge: named ones; else the promises the change selects
|
|
306
|
+
* by their anchors and by their maps; then `alwaysJudge`.
|
|
298
307
|
*
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
*
|
|
308
|
+
* By anchors (`anchored`, chosen before this from the files the change edits):
|
|
309
|
+
* every promise one of whose anchor files (an entry point's or a symbol's
|
|
310
|
+
* file) the change edits, with no widening by import distance. The
|
|
311
|
+
* measurement of 1 October 2026 found that widening multiplied the promises
|
|
312
|
+
* selected and caught nothing more.
|
|
302
313
|
*
|
|
303
|
-
*
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
* - "
|
|
308
|
-
*
|
|
309
|
-
*
|
|
314
|
+
* By maps, from the risk report, in the order `balladeer risk` lists it (how
|
|
315
|
+
* directly the change reaches each promise, then score, then evidence, then
|
|
316
|
+
* id; a score alone ties at 1 and falls to the alphabet):
|
|
317
|
+
*
|
|
318
|
+
* - "direct" takes every mapped promise whose own code or tests the change
|
|
319
|
+
* edits (directness tier 3), and nothing else. Only a `--top` given by hand
|
|
320
|
+
* caps it, and it caps anchored and mapped promises together.
|
|
321
|
+
* - "ranked" takes the top k mapped promises with a score above zero, plus a
|
|
322
|
+
* random sample below that line. The sample is what keeps a ranking honest:
|
|
323
|
+
* a break found below the line is a miss the ranking made, counted rather
|
|
324
|
+
* than never seen. Anchored promises are taken too, before the sample.
|
|
325
|
+
*
|
|
326
|
+
* Without a risk report, every mapped promise. Anchored and mapped promises
|
|
327
|
+
* are listed together in the report's order, and one the report does not
|
|
328
|
+
* list comes after those it does.
|
|
310
329
|
*/
|
|
311
330
|
export declare function selectPromises(input: Readonly<{
|
|
312
331
|
named?: readonly string[];
|
|
313
332
|
risk?: readonly RiskEntry[];
|
|
314
333
|
mapped: readonly string[];
|
|
334
|
+
anchored?: readonly string[];
|
|
315
335
|
config: JudgeConfig;
|
|
316
336
|
topK?: number;
|
|
317
337
|
random: () => number;
|
|
@@ -364,9 +384,19 @@ export declare function verdictLine(verdict: JudgeVerdict, title: string, contex
|
|
|
364
384
|
export type JudgeDependencies = Readonly<{
|
|
365
385
|
runAgent?: (options: HeadlessAgentOptions) => Promise<HeadlessAgentResult>;
|
|
366
386
|
resolveLogin?: (requested: HeadlessClient | "auto", prefer?: HeadlessClient) => Promise<LoginCheck | undefined>;
|
|
367
|
-
riskReport?: (root: string, base: string, head: string
|
|
387
|
+
riskReport?: (root: string, base: string, head: string,
|
|
388
|
+
/** The maps in use and, in a map's shape, the anchors of the promises they select. */
|
|
389
|
+
maps: readonly BehaviorMap[]) => Promise<readonly RiskEntry[] | undefined>;
|
|
368
390
|
/** `null` means "no connection", without trying to find one. */
|
|
369
391
|
reader?: PromiseReader | null;
|
|
392
|
+
/** The anchors route; `null` means none, without trying to find one. */
|
|
393
|
+
anchors?: AnchorsClient | null;
|
|
394
|
+
/** The anchoring call, when it is not the person's own agent. */
|
|
395
|
+
anchorAgent?: (options: HeadlessAgentOptions) => Promise<HeadlessAgentResult>;
|
|
396
|
+
/** The repository graph at a commit, when it is not built from this laptop's cache. */
|
|
397
|
+
graph?: (root: string, head: string) => RepositoryGraph;
|
|
398
|
+
/** Today's anchoring budget, when it is not this laptop's own. */
|
|
399
|
+
anchorBudget?: AnchorBudget;
|
|
370
400
|
random?: () => number;
|
|
371
401
|
now?: () => Date;
|
|
372
402
|
stdin?: () => Promise<string>;
|
|
@@ -394,17 +424,22 @@ export type JudgeRunOptions = Readonly<{
|
|
|
394
424
|
}>;
|
|
395
425
|
/** What `--push-check off` and `--push-check on` say. */
|
|
396
426
|
export declare const PUSH_CHECK_OFF_LINE = "The push check is off on this laptop: after a push it now says nothing, in every repository. A repository's own entry from `balladeer judge --install-hook` still runs. Turn it back on with `balladeer judge --push-check on`.";
|
|
397
|
-
export declare const PUSH_CHECK_ON_LINE = "The push check is back on for this laptop: after a push in a connected repository
|
|
427
|
+
export declare const PUSH_CHECK_ON_LINE = "The push check is back on for this laptop: after a push in a connected repository with agreed promises, it reports what the push may have broken, and it never holds the push.";
|
|
398
428
|
/**
|
|
399
429
|
* Whether the push check's user-scope entry has this push to itself, decided
|
|
400
430
|
* before anything is started or fetched: the entry runs on every shell command
|
|
401
431
|
* in every folder on the laptop, and most folders have nothing to judge. It
|
|
402
432
|
* stands aside, saying nothing, when the laptop turned the check off, the
|
|
403
|
-
* folder is in no checkout, the checkout has the push check's own project
|
|
433
|
+
* folder is in no checkout, or the checkout has the push check's own project
|
|
404
434
|
* entry for this host (that entry owns the check there, so a push is judged
|
|
405
|
-
* once)
|
|
406
|
-
*
|
|
407
|
-
*
|
|
435
|
+
* once). Only then is the connection looked for, which reads the checkout's
|
|
436
|
+
* remotes, and a checkout this laptop has not connected is left alone too.
|
|
437
|
+
*
|
|
438
|
+
* A connected checkout is not left alone for having no behavior map: since
|
|
439
|
+
* promise anchors (approved 30 September 2026), its agreed promises are
|
|
440
|
+
* anchored and checked without one. Whether it has any agreed promise is the
|
|
441
|
+
* server's to say, after the push, and with none (and no map) the entry still
|
|
442
|
+
* says nothing.
|
|
408
443
|
*/
|
|
409
444
|
export declare function userScopeTakesThePush(input: {
|
|
410
445
|
cwd: string;
|
|
@@ -412,6 +447,20 @@ export declare function userScopeTakesThePush(input: {
|
|
|
412
447
|
environment: NodeJS.ProcessEnv;
|
|
413
448
|
connected: (root: string) => boolean;
|
|
414
449
|
}): boolean;
|
|
450
|
+
/**
|
|
451
|
+
* The one line said when nothing was selected, and its code: no agreed
|
|
452
|
+
* promise here; nothing anchored or mapped (with why, when the anchors could
|
|
453
|
+
* not be read); or nothing the change touches.
|
|
454
|
+
*/
|
|
455
|
+
export declare function nothingSelected(input: Readonly<{
|
|
456
|
+
stage: AnchorStage | undefined;
|
|
457
|
+
maps: number;
|
|
458
|
+
anchors: number;
|
|
459
|
+
push: boolean;
|
|
460
|
+
}>): {
|
|
461
|
+
reason: string;
|
|
462
|
+
message: string;
|
|
463
|
+
};
|
|
415
464
|
/** `balladeer judge`, from arguments already parsed. */
|
|
416
465
|
/**
|
|
417
466
|
* The line an answer opens with when the command makes a commit before it
|
|
@@ -427,4 +476,3 @@ export declare function judgeCommand(argv: readonly string[], io: Readonly<{
|
|
|
427
476
|
write: (text: string) => void;
|
|
428
477
|
error: (text: string) => void;
|
|
429
478
|
}>): Promise<number>;
|
|
430
|
-
export {};
|