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.
Files changed (58) 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/cli.d.ts +1 -1
  10. package/dist/cli.js +24 -7
  11. package/dist/commands/anchor-usage.d.ts +6 -0
  12. package/dist/commands/anchor-usage.js +19 -0
  13. package/dist/commands/anchor.d.ts +127 -0
  14. package/dist/commands/anchor.js +303 -0
  15. package/dist/commands/guidance.d.ts +2 -1
  16. package/dist/commands/guidance.js +55 -0
  17. package/dist/commands/judge.d.ts +219 -12
  18. package/dist/commands/judge.js +911 -103
  19. package/dist/commands/map.d.ts +46 -2
  20. package/dist/commands/map.js +244 -5
  21. package/dist/commands/mcp.js +2 -2
  22. package/dist/guidance-hook.mjs +282 -95
  23. package/dist/guidance.d.ts +7 -0
  24. package/dist/guidance.js +10 -1
  25. package/dist/headless-agent.d.ts +19 -1
  26. package/dist/headless-agent.js +22 -5
  27. package/dist/hook-trust.d.ts +41 -9
  28. package/dist/hook-trust.js +98 -16
  29. package/dist/judge-brief.d.ts +33 -3
  30. package/dist/judge-brief.js +39 -2
  31. package/dist/judge-hook.d.ts +188 -14
  32. package/dist/judge-hook.js +919 -61
  33. package/dist/judge-said.d.ts +52 -0
  34. package/dist/judge-said.js +181 -0
  35. package/dist/promise-meaning.d.ts +25 -0
  36. package/dist/promise-meaning.js +24 -7
  37. package/dist/relay.d.ts +71 -0
  38. package/dist/relay.js +193 -0
  39. package/dist/remove-earlier.js +4 -2
  40. package/dist/risk/git.d.ts +3 -1
  41. package/dist/risk/git.js +3 -3
  42. package/dist/risk/graph-cache.d.ts +83 -0
  43. package/dist/risk/graph-cache.js +291 -0
  44. package/dist/risk/import-graph.d.ts +43 -0
  45. package/dist/risk/import-graph.js +88 -34
  46. package/dist/risk/index.d.ts +1 -1
  47. package/dist/risk/index.js +1 -1
  48. package/dist/risk/pipeline.d.ts +13 -0
  49. package/dist/risk/pipeline.js +15 -4
  50. package/dist/risk/score.d.ts +10 -0
  51. package/dist/risk/score.js +11 -2
  52. package/dist/scratch-worktree.d.ts +5 -1
  53. package/dist/scratch-worktree.js +9 -2
  54. package/dist/user-scope.d.ts +74 -3
  55. package/dist/user-scope.js +270 -9
  56. package/dist/wire.d.ts +19 -3
  57. package/dist/wire.js +2 -2
  58. 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 { CODEX_HOOK_APPROVAL, installUserScope, runningFromCheckout, userHome, } from "./user-scope.js";
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
- throw new StoreError("usage", "--event needs SessionStart, UserPromptSubmit or SubagentStart.");
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) => ({ userScope: register(result.command) }),
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(`${CODEX_HOOK_APPROVAL}\n`);
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
- event?: GuidanceEvent;
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;