balladeer 1.0.16 → 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.d.ts +1 -1
- package/dist/cli.js +24 -7
- 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/guidance.d.ts +2 -1
- package/dist/commands/guidance.js +55 -0
- package/dist/commands/judge.d.ts +219 -12
- package/dist/commands/judge.js +911 -103
- package/dist/commands/map.d.ts +46 -2
- package/dist/commands/map.js +244 -5
- package/dist/commands/mcp.js +2 -2
- package/dist/guidance-hook.mjs +282 -95
- 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/hook-trust.d.ts +41 -9
- package/dist/hook-trust.js +98 -16
- package/dist/judge-brief.d.ts +33 -3
- package/dist/judge-brief.js +39 -2
- package/dist/judge-hook.d.ts +188 -14
- package/dist/judge-hook.js +919 -61
- package/dist/judge-said.d.ts +52 -0
- package/dist/judge-said.js +181 -0
- package/dist/promise-meaning.d.ts +25 -0
- package/dist/promise-meaning.js +24 -7
- package/dist/relay.d.ts +71 -0
- package/dist/relay.js +193 -0
- package/dist/remove-earlier.js +4 -2
- 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/index.d.ts +1 -1
- package/dist/risk/index.js +1 -1
- package/dist/risk/pipeline.d.ts +13 -0
- package/dist/risk/pipeline.js +15 -4
- package/dist/risk/score.d.ts +10 -0
- package/dist/risk/score.js +11 -2
- package/dist/scratch-worktree.d.ts +5 -1
- package/dist/scratch-worktree.js +9 -2
- package/dist/user-scope.d.ts +74 -3
- package/dist/user-scope.js +270 -9
- package/dist/wire.d.ts +19 -3
- package/dist/wire.js +2 -2
- package/package.json +7 -2
package/dist/cli.js
CHANGED
|
@@ -9,6 +9,7 @@ import { runDiscover } from "./commands/discover.js";
|
|
|
9
9
|
import { runExplain } from "./commands/explain.js";
|
|
10
10
|
import { parseRole, runInvite } from "./commands/invite.js";
|
|
11
11
|
import { runGuidance } from "./commands/guidance.js";
|
|
12
|
+
import { ANCHOR_USAGE } from "./commands/anchor-usage.js";
|
|
12
13
|
import { JUDGE_USAGE, judgeCommand } from "./commands/judge.js";
|
|
13
14
|
import { MAP_USAGE, mapCommand } from "./commands/map.js";
|
|
14
15
|
import { OFFERS_USAGE, offersCommand } from "./commands/offers.js";
|
|
@@ -25,7 +26,7 @@ import { runWhoami } from "./commands/whoami.js";
|
|
|
25
26
|
import { runInstall } from "./install.js";
|
|
26
27
|
import { openInBrowser } from "./open-browser.js";
|
|
27
28
|
import { PUBLISHED_SPECIFIER } from "./release.js";
|
|
28
|
-
import {
|
|
29
|
+
import { codexHookApproval, installUserScope, pushCheckSetupLine, pushCheckState, runningFromCheckout, userHome, } from "./user-scope.js";
|
|
29
30
|
import { updateNotice } from "./currency.js";
|
|
30
31
|
import { describeEarlier, describeRemoval, findEarlier, removeEarlier } from "./remove-earlier.js";
|
|
31
32
|
import { recordSelfUpdateState, SELF_UPDATE_ENVIRONMENT } from "./self-update.js";
|
|
@@ -160,6 +161,7 @@ const USAGE = `balladeer ${CLI_VERSION}
|
|
|
160
161
|
local file and contacts nothing.
|
|
161
162
|
|
|
162
163
|
${MAP_USAGE}
|
|
164
|
+
${ANCHOR_USAGE}
|
|
163
165
|
${JUDGE_USAGE}
|
|
164
166
|
${OFFERS_USAGE}
|
|
165
167
|
${CLI_INVOCATION} risk [--base <ref>] [--head <ref>] [--json] [--maps <dir>]
|
|
@@ -275,8 +277,9 @@ export function parseArguments(argv) {
|
|
|
275
277
|
const requested = value("--event", inline);
|
|
276
278
|
if (requested !== "SessionStart" &&
|
|
277
279
|
requested !== "UserPromptSubmit" &&
|
|
278
|
-
requested !== "SubagentStart"
|
|
279
|
-
|
|
280
|
+
requested !== "SubagentStart" &&
|
|
281
|
+
requested !== "PostToolUse")
|
|
282
|
+
throw new StoreError("usage", "--event needs SessionStart, UserPromptSubmit, SubagentStart or PostToolUse.");
|
|
280
283
|
event = requested;
|
|
281
284
|
}
|
|
282
285
|
else if (name === "--json")
|
|
@@ -449,7 +452,7 @@ export async function main(argv) {
|
|
|
449
452
|
// `map`, `judge` and `offers` read their own flags: `--hook`, `--repo` and
|
|
450
453
|
// `--install-hook` mean something different there, and `judge --hook` runs
|
|
451
454
|
// inside a coding host where a stderr nag would reach the agent.
|
|
452
|
-
if (argv[0] === "map" || argv[0] === "judge" || argv[0] === "offers") {
|
|
455
|
+
if (argv[0] === "map" || argv[0] === "judge" || argv[0] === "offers" || argv[0] === "anchor") {
|
|
453
456
|
const io = {
|
|
454
457
|
cwd: process.cwd(),
|
|
455
458
|
environment: process.env,
|
|
@@ -458,6 +461,8 @@ export async function main(argv) {
|
|
|
458
461
|
};
|
|
459
462
|
if (argv[0] === "offers")
|
|
460
463
|
return offersCommand(argv.slice(1), io);
|
|
464
|
+
if (argv[0] === "anchor")
|
|
465
|
+
return (await import("./commands/anchor.js")).anchorCommand(argv.slice(1), io);
|
|
461
466
|
return argv[0] === "map" ? mapCommand(argv.slice(1), io) : judgeCommand(argv.slice(1), io);
|
|
462
467
|
}
|
|
463
468
|
let parsed;
|
|
@@ -497,12 +502,14 @@ function commandVersion(name) {
|
|
|
497
502
|
function installEverything(options) {
|
|
498
503
|
const published = !runningFromCheckout();
|
|
499
504
|
let writes;
|
|
505
|
+
let pushCheck = "unavailable";
|
|
500
506
|
const register = (installed) => {
|
|
501
507
|
writes = installUserScope({
|
|
502
508
|
environment: process.env,
|
|
503
509
|
published,
|
|
504
510
|
...(installed === undefined ? {} : { installed }),
|
|
505
511
|
});
|
|
512
|
+
pushCheck = pushCheckState(process.env, published, installed);
|
|
506
513
|
return writes;
|
|
507
514
|
};
|
|
508
515
|
const code = runInstall({
|
|
@@ -510,12 +517,15 @@ function installEverything(options) {
|
|
|
510
517
|
json: options.json,
|
|
511
518
|
write: options.write,
|
|
512
519
|
allowPartial: options.allowPartial,
|
|
513
|
-
onInstalled: (result) =>
|
|
520
|
+
onInstalled: (result) => {
|
|
521
|
+
const userScope = register(result.command);
|
|
522
|
+
return { userScope, pushCheck };
|
|
523
|
+
},
|
|
514
524
|
});
|
|
515
525
|
if (writes === undefined) {
|
|
516
526
|
const fallback = register();
|
|
517
527
|
if (options.json)
|
|
518
|
-
options.write(`${JSON.stringify({ step: "user_scope", userScope: fallback })}\n`);
|
|
528
|
+
options.write(`${JSON.stringify({ step: "user_scope", userScope: fallback, pushCheck })}\n`);
|
|
519
529
|
}
|
|
520
530
|
for (const w of writes ?? [])
|
|
521
531
|
if (!options.json)
|
|
@@ -531,7 +541,7 @@ function installEverything(options) {
|
|
|
531
541
|
if (options.json)
|
|
532
542
|
options.write(`${JSON.stringify({ step: "codex_hooks", approval: codex.status === "written" ? "required" : "check", command: "/hooks" })}\n`);
|
|
533
543
|
else
|
|
534
|
-
options.write(`${
|
|
544
|
+
options.write(`${codexHookApproval(pushCheck !== "unavailable")}\n`);
|
|
535
545
|
}
|
|
536
546
|
// With the user-scope form in place, the older per-repository form in this
|
|
537
547
|
// checkout only makes the session hear the guidance twice. Out it goes, with
|
|
@@ -554,6 +564,13 @@ function installEverything(options) {
|
|
|
554
564
|
options.write("Balladeer now runs in every Claude Code" +
|
|
555
565
|
((writes ?? []).some((w) => w.host === "codex") ? " and Codex" : "") +
|
|
556
566
|
" session on this machine. In a folder of a connected repository it works as before; anywhere else it stays quiet, and `balladeer status` there says why.\n");
|
|
567
|
+
// The push check rides on the same hooks file, so it is on wherever that
|
|
568
|
+
// file was written: said once per run, with how to turn it off. Under
|
|
569
|
+
// --json it is the `pushCheck` field of the install step, so that step stays
|
|
570
|
+
// one object.
|
|
571
|
+
const hooksFile = (writes ?? []).find((w) => w.path.endsWith("settings.json"));
|
|
572
|
+
if (!options.json && hooksFile !== undefined && hooksFile.status !== "refused")
|
|
573
|
+
options.write(`${pushCheckSetupLine(pushCheck)}\n`);
|
|
557
574
|
const outcome = (writes ?? []).some((w) => w.status === "refused") ? 4 : code;
|
|
558
575
|
// Started by the session hook's background update: leave how it ended where
|
|
559
576
|
// the next hook fire will find it and tell the server.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `balladeer anchor`'s usage, apart from the command itself so the help text
|
|
3
|
+
* costs the command's start nothing: the anchoring machinery is loaded only
|
|
4
|
+
* when something anchors.
|
|
5
|
+
*/
|
|
6
|
+
export declare const ANCHOR_USAGE = " npx -y balladeer@latest anchor [<promise id> | --all] [--json] [--repo <root>]\n [--client auto|claude|codex]\n Anchor this repository's agreed promises to the code that realizes them,\n at HEAD: the files and names where each one's behavior lives, picked by\n one small call on your own Claude Code or Codex login from a list of\n this repository's files, and shared through Balladeer with your team.\n Only paths and names are sent, never code. A promise with a behavior map\n for its current meaning is anchored from the map, with no call. The push\n check does this by itself; this does it now. With no argument or --all,\n every promise without current anchors, or whose anchored code is gone;\n with a promise id, that one again. At most 20 calls a day on this\n laptop (set BALLADEER_ANCHOR_DAILY_BUDGET to change it).\n";
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { CLI_INVOCATION } from "../wire.js";
|
|
2
|
+
/**
|
|
3
|
+
* `balladeer anchor`'s usage, apart from the command itself so the help text
|
|
4
|
+
* costs the command's start nothing: the anchoring machinery is loaded only
|
|
5
|
+
* when something anchors.
|
|
6
|
+
*/
|
|
7
|
+
export const ANCHOR_USAGE = ` ${CLI_INVOCATION} anchor [<promise id> | --all] [--json] [--repo <root>]
|
|
8
|
+
[--client auto|claude|codex]
|
|
9
|
+
Anchor this repository's agreed promises to the code that realizes them,
|
|
10
|
+
at HEAD: the files and names where each one's behavior lives, picked by
|
|
11
|
+
one small call on your own Claude Code or Codex login from a list of
|
|
12
|
+
this repository's files, and shared through Balladeer with your team.
|
|
13
|
+
Only paths and names are sent, never code. A promise with a behavior map
|
|
14
|
+
for its current meaning is anchored from the map, with no call. The push
|
|
15
|
+
check does this by itself; this does it now. With no argument or --all,
|
|
16
|
+
every promise without current anchors, or whose anchored code is gone;
|
|
17
|
+
with a promise id, that one again. At most 20 calls a day on this
|
|
18
|
+
laptop (set BALLADEER_ANCHOR_DAILY_BUDGET to change it).
|
|
19
|
+
`;
|
|
@@ -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
|
+
}
|
|
@@ -5,7 +5,8 @@ export type GuidanceOptions = Readonly<{
|
|
|
5
5
|
controlPlane: string;
|
|
6
6
|
repositoryId?: string;
|
|
7
7
|
hook: "codex" | "claude";
|
|
8
|
-
|
|
8
|
+
/** PostToolUse is the relay note, not guidance: see `runRelayNote`. */
|
|
9
|
+
event?: GuidanceEvent | "PostToolUse";
|
|
9
10
|
runtimeGeneration?: string;
|
|
10
11
|
environment: NodeJS.ProcessEnv;
|
|
11
12
|
cwd: string;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { relayHook } from "../relay.js";
|
|
1
2
|
import { validateProjectGuidanceScope } from "../guidance-install.js";
|
|
2
3
|
import { GUIDANCE_UNAVAILABLE, loadGuidance, recordGuidanceContext, } from "../guidance.js";
|
|
3
4
|
import { readCredentials } from "../store.js";
|
|
@@ -60,8 +61,62 @@ function eventFrom(input) {
|
|
|
60
61
|
function identifier(input) {
|
|
61
62
|
return typeof input === "string" && input.length > 0 && input.length <= 256 ? input : undefined;
|
|
62
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* After a Balladeer tool acts, a note the agent reads before it replies: tell
|
|
66
|
+
* the person what Balladeer did, in your own words (promise
|
|
67
|
+
* prom_45ef4e3d9e5545a9a411; plans/promise-relations/plan.md, step 1). Codex
|
|
68
|
+
* relayed every detail in 10 of 10 lab sessions with it and 6 of 10 without.
|
|
69
|
+
* Local only: no connection, no network, nothing stored. A tool result can be
|
|
70
|
+
* large, so the input bound is wider than guidance's; anything unreadable,
|
|
71
|
+
* oversized or late prints nothing, never the guidance-unavailable notice.
|
|
72
|
+
*/
|
|
73
|
+
async function runRelayNote(options) {
|
|
74
|
+
const input = await new Promise((resolve) => {
|
|
75
|
+
const chunks = [];
|
|
76
|
+
let bytes = 0;
|
|
77
|
+
let settled = false;
|
|
78
|
+
const done = (value) => {
|
|
79
|
+
if (settled)
|
|
80
|
+
return;
|
|
81
|
+
settled = true;
|
|
82
|
+
clearTimeout(timer);
|
|
83
|
+
options.stdin.removeListener("data", data);
|
|
84
|
+
resolve(value);
|
|
85
|
+
};
|
|
86
|
+
const data = (chunk) => {
|
|
87
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
88
|
+
bytes += buffer.length;
|
|
89
|
+
if (bytes > 1_048_576)
|
|
90
|
+
done(undefined);
|
|
91
|
+
else
|
|
92
|
+
chunks.push(buffer);
|
|
93
|
+
};
|
|
94
|
+
const timer = setTimeout(() => done(undefined), 1_000);
|
|
95
|
+
options.stdin.on("data", data);
|
|
96
|
+
options.stdin.once("end", () => {
|
|
97
|
+
try {
|
|
98
|
+
done(JSON.parse(Buffer.concat(chunks).toString("utf8")));
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
done(undefined);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
options.stdin.once("error", () => done(undefined));
|
|
105
|
+
});
|
|
106
|
+
if (!input || typeof input !== "object" || Array.isArray(input))
|
|
107
|
+
return 0;
|
|
108
|
+
const record = input;
|
|
109
|
+
if (record.hook_event_name !== "PostToolUse")
|
|
110
|
+
return 0;
|
|
111
|
+
const out = relayHook(record);
|
|
112
|
+
if (out)
|
|
113
|
+
options.write(`${out}\n`);
|
|
114
|
+
return 0;
|
|
115
|
+
}
|
|
63
116
|
/** Hook stdout is bounded context only. Host prompts and transcript paths never leave this process. */
|
|
64
117
|
export async function runGuidance(options) {
|
|
118
|
+
if (options.event === "PostToolUse")
|
|
119
|
+
return runRelayNote(options);
|
|
65
120
|
// A project hook names its repository and must match the checkout it sits
|
|
66
121
|
// in. A user-scope hook names none: the folder's remotes decide.
|
|
67
122
|
const projectScoped = options.repositoryId !== undefined;
|