balladeer 1.0.17 → 1.0.19
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/checkout-form.d.ts +11 -0
- package/dist/checkout-form.js +14 -0
- package/dist/cli.js +9 -4
- 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.js +23 -8
- package/dist/commands/judge.d.ts +69 -21
- package/dist/commands/judge.js +354 -89
- package/dist/commands/map.js +2 -0
- 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 +166 -57
- package/dist/guidance-runtime.d.ts +26 -0
- package/dist/guidance-runtime.js +71 -1
- 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/self-update.d.ts +3 -3
- package/dist/self-update.js +38 -8
- package/dist/wire.d.ts +18 -2
- package/dist/wire.js +1 -1
- package/package.json +7 -2
|
@@ -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
|
+
}
|
|
@@ -7,6 +7,7 @@ import { repositoryHints } from "../repository.js";
|
|
|
7
7
|
import { newerVersionPublished } from "../currency.js";
|
|
8
8
|
import { startSelfUpdateIfDue } from "../self-update.js";
|
|
9
9
|
import { noteHookFired } from "../hook-trust.js";
|
|
10
|
+
import { retireCheckoutRuntime } from "../guidance-runtime.js";
|
|
10
11
|
/** A repository-specific setup step for explicit status and empty-catalog responses. */
|
|
11
12
|
export function untrackedLine(remote) {
|
|
12
13
|
const here = remote === "unknown/unknown" ? "This folder" : `This folder (${remote})`;
|
|
@@ -158,6 +159,18 @@ export async function runGuidance(options) {
|
|
|
158
159
|
// trust are the ones this matters to.
|
|
159
160
|
if (options.hook === "codex")
|
|
160
161
|
noteHookFired(options.environment, "codex");
|
|
162
|
+
// This hook covers this checkout now, so the older per-repository hook
|
|
163
|
+
// here (releases before 1.0.8) only makes the session hear the guidance
|
|
164
|
+
// twice. Switch it off from the laptop side, once, before the fetch:
|
|
165
|
+
// nothing printed, no repository file touched, nobody asked to run a thing.
|
|
166
|
+
if (!projectScoped && event === "SessionStart")
|
|
167
|
+
retireCheckoutRuntime({
|
|
168
|
+
cwd: options.cwd,
|
|
169
|
+
host: options.hook,
|
|
170
|
+
repositoryId: agents[0].repositoryId,
|
|
171
|
+
controlPlane: agents[0].controlPlane,
|
|
172
|
+
environment: options.environment,
|
|
173
|
+
});
|
|
161
174
|
const loaded = await loadGuidance({
|
|
162
175
|
agent: agents[0],
|
|
163
176
|
environment: options.environment,
|
|
@@ -165,14 +178,16 @@ export async function runGuidance(options) {
|
|
|
165
178
|
hook: options.hook,
|
|
166
179
|
...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}),
|
|
167
180
|
});
|
|
168
|
-
// Behind? Start the install in the background
|
|
169
|
-
//
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
})
|
|
181
|
+
// Behind? Start the install in the background and carry on serving this
|
|
182
|
+
// session from the copy that is here. Any hook that loaded guidance may
|
|
183
|
+
// start it, not only a session start: sessions run for days, and a laptop
|
|
184
|
+
// whose sessions never restart would otherwise never update (Robert's own,
|
|
185
|
+
// 1 October 2026). The stamp keeps it to once a day per release.
|
|
186
|
+
startSelfUpdateIfDue(newerVersionPublished(), {
|
|
187
|
+
environment: options.environment,
|
|
188
|
+
...(options.startUpdate ? { run: options.startUpdate } : {}),
|
|
189
|
+
...(options.now ? { now: options.now } : {}),
|
|
190
|
+
});
|
|
176
191
|
const sessionId = identifier(record.session_id);
|
|
177
192
|
const agentId = identifier(record.agent_id);
|
|
178
193
|
recordGuidanceContext({
|