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.
Files changed (47) hide show
  1. package/dist/anchor-budget.d.ts +40 -0
  2. package/dist/anchor-budget.js +110 -0
  3. package/dist/anchoring.d.ts +241 -0
  4. package/dist/anchoring.js +766 -0
  5. package/dist/anchors-client.d.ts +62 -0
  6. package/dist/anchors-client.js +197 -0
  7. package/dist/anchors-schema.d.ts +85 -0
  8. package/dist/anchors-schema.js +246 -0
  9. package/dist/checkout-form.d.ts +11 -0
  10. package/dist/checkout-form.js +14 -0
  11. package/dist/cli.js +9 -4
  12. package/dist/commands/anchor-usage.d.ts +6 -0
  13. package/dist/commands/anchor-usage.js +19 -0
  14. package/dist/commands/anchor.d.ts +127 -0
  15. package/dist/commands/anchor.js +303 -0
  16. package/dist/commands/guidance.js +23 -8
  17. package/dist/commands/judge.d.ts +69 -21
  18. package/dist/commands/judge.js +354 -89
  19. package/dist/commands/map.js +2 -0
  20. package/dist/commands/mcp.js +5 -9
  21. package/dist/commands/setup.js +5 -23
  22. package/dist/currency.d.ts +2 -0
  23. package/dist/currency.js +2 -1
  24. package/dist/guidance-hook.mjs +166 -57
  25. package/dist/guidance-runtime.d.ts +26 -0
  26. package/dist/guidance-runtime.js +71 -1
  27. package/dist/guidance.d.ts +7 -0
  28. package/dist/guidance.js +10 -1
  29. package/dist/headless-agent.d.ts +19 -1
  30. package/dist/headless-agent.js +22 -5
  31. package/dist/judge-brief.d.ts +20 -2
  32. package/dist/judge-brief.js +26 -2
  33. package/dist/promise-meaning.d.ts +17 -0
  34. package/dist/promise-meaning.js +15 -6
  35. package/dist/risk/git.d.ts +3 -1
  36. package/dist/risk/git.js +3 -3
  37. package/dist/risk/graph-cache.d.ts +83 -0
  38. package/dist/risk/graph-cache.js +291 -0
  39. package/dist/risk/import-graph.d.ts +43 -0
  40. package/dist/risk/import-graph.js +88 -34
  41. package/dist/risk/pipeline.d.ts +13 -0
  42. package/dist/risk/pipeline.js +15 -4
  43. package/dist/self-update.d.ts +3 -3
  44. package/dist/self-update.js +38 -8
  45. package/dist/wire.d.ts +18 -2
  46. package/dist/wire.js +1 -1
  47. 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, once a day at most, and
169
- // carry on serving this session from the copy that is here.
170
- if (event === "SessionStart")
171
- startSelfUpdateIfDue(newerVersionPublished(), {
172
- environment: options.environment,
173
- ...(options.startUpdate ? { run: options.startUpdate } : {}),
174
- ...(options.now ? { now: options.now } : {}),
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({