balladeer 1.0.17 → 1.0.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2152,7 +2152,7 @@ function relayHook(input, directory) {
2152
2152
  }
2153
2153
 
2154
2154
  // src/wire.ts
2155
- var CLI_VERSION = "1.0.17";
2155
+ var CLI_VERSION = "1.0.18";
2156
2156
  var CLIENT_HEADER = "x-balladeer-client";
2157
2157
  var UPDATE_STATE_HEADER = "x-balladeer-update-state";
2158
2158
  var HOOK_HOST_HEADER = "x-balladeer-hook";
@@ -2403,6 +2403,9 @@ function parseGuidance(value, agent) {
2403
2403
  return value;
2404
2404
  }
2405
2405
  function guidanceEndpoint(agent) {
2406
+ return agentEndpoint(agent, "/api/agent-guidance");
2407
+ }
2408
+ function agentEndpoint(agent, pathname) {
2406
2409
  const url = new URL(agent.mcpUrl);
2407
2410
  const expected = new URL(agent.controlPlane);
2408
2411
  if (url.origin !== expected.origin || url.protocol !== "https:" || url.pathname !== "/api/mcp" || url.username || url.password || url.hash || !UUID.test(agent.workspaceId) || !UUID.test(agent.repositoryId))
@@ -2415,7 +2418,7 @@ function guidanceEndpoint(agent) {
2415
2418
  throw new Error("invalid_guidance_scope");
2416
2419
  url.searchParams.set(key2, value);
2417
2420
  }
2418
- url.pathname = "/api/agent-guidance";
2421
+ url.pathname = pathname;
2419
2422
  return url;
2420
2423
  }
2421
2424
  function readJson(path) {
@@ -12,6 +12,13 @@ export type GuidanceEvent = "SessionStart" | "SubagentStart" | "UserPromptSubmit
12
12
  export declare const GUIDANCE_UNAVAILABLE = "Balladeer could not verify the current workspace guidance and capture mode. Continue the user's authorized work, but do not make unsolicited capture offers or file inferred promises. Do not reuse earlier Quiet/Thorough permission or cached instructions as current. An explicit request to record still requires current Balladeer tool checks and named-human agreement to meaning; never invent approval. Retry current guidance at the next hook boundary.";
13
13
  export declare function parseGuidance(value: unknown, agent: StoredAgent): GuidanceDocument;
14
14
  export declare function guidanceEndpoint(agent: StoredAgent): URL;
15
+ /**
16
+ * A route of the server this repository's agent connection belongs to, scoped
17
+ * to its workspace and repository the way `/api/agent-guidance` is: same
18
+ * origin as the connection's MCP address, https only, and the scope in the
19
+ * query exactly once.
20
+ */
21
+ export declare function agentEndpoint(agent: StoredAgent, pathname: string): URL;
15
22
  export declare function guidanceScopeKey(agent: StoredAgent): string;
16
23
  export type GuidanceLoad = Readonly<{
17
24
  status: "fresh" | "revalidated" | "unavailable";
package/dist/guidance.js CHANGED
@@ -34,6 +34,15 @@ export function parseGuidance(value, agent) {
34
34
  return value;
35
35
  }
36
36
  export function guidanceEndpoint(agent) {
37
+ return agentEndpoint(agent, "/api/agent-guidance");
38
+ }
39
+ /**
40
+ * A route of the server this repository's agent connection belongs to, scoped
41
+ * to its workspace and repository the way `/api/agent-guidance` is: same
42
+ * origin as the connection's MCP address, https only, and the scope in the
43
+ * query exactly once.
44
+ */
45
+ export function agentEndpoint(agent, pathname) {
37
46
  const url = new URL(agent.mcpUrl);
38
47
  const expected = new URL(agent.controlPlane);
39
48
  if (url.origin !== expected.origin ||
@@ -54,7 +63,7 @@ export function guidanceEndpoint(agent) {
54
63
  throw new Error("invalid_guidance_scope");
55
64
  url.searchParams.set(key, value);
56
65
  }
57
- url.pathname = "/api/agent-guidance";
66
+ url.pathname = pathname;
58
67
  return url;
59
68
  }
60
69
  function readJson(path) {
@@ -49,6 +49,11 @@ export type HeadlessAgentOptions = Readonly<{
49
49
  prefer?: HeadlessClient;
50
50
  /** Codex's sandbox. Read-only unless the run has to execute tests. */
51
51
  sandbox?: "read-only" | "workspace-write";
52
+ /**
53
+ * The run's folder is no repository on purpose (an anchoring call works in
54
+ * an empty folder, so it can read nothing), which Codex must be told.
55
+ */
56
+ outsideRepository?: boolean;
52
57
  /** Claude Code permission rules refused outright, such as `Bash(git push:*)`. */
53
58
  disallowedTools?: readonly string[];
54
59
  /** Fires when the caller's budget runs out; the run is stopped, not failed. */
@@ -58,6 +63,15 @@ export type HeadlessAgentOptions = Readonly<{
58
63
  /** A login already verified by the caller, so a batch checks it once. */
59
64
  login?: LoginCheck;
60
65
  maxOutputBytes?: number;
66
+ /**
67
+ * The most output tokens the model may write in this run. Claude Code is
68
+ * held to it by `CLAUDE_CODE_MAX_OUTPUT_TOKENS`, so a run that would go
69
+ * further is stopped there. Codex has no such setting (none in its
70
+ * configuration reference, and none in its 0.144 binary), so for Codex the
71
+ * run is held by its time limit and `overOutputCap` says afterwards when it
72
+ * wrote more than this.
73
+ */
74
+ maxOutputTokens?: number;
61
75
  /** Where the private settings directory is made. Defaults to the system temp directory. */
62
76
  scratch?: string;
63
77
  }>;
@@ -76,8 +90,10 @@ export type HeadlessAgentResult = Readonly<{
76
90
  stderrTail: string;
77
91
  timedOut: boolean;
78
92
  aborted: boolean;
93
+ /** The run reported more output tokens than `maxOutputTokens` allowed, or stopped at that cap. */
94
+ overOutputCap?: boolean;
79
95
  /** Why `ok` is false, as a code a caller can branch on. */
80
- reason?: "no_verified_login" | "spawn_failed" | "timed_out" | "aborted" | "output_bounded" | "exit_nonzero" | "no_answer" | "no_json";
96
+ reason?: "no_verified_login" | "spawn_failed" | "timed_out" | "aborted" | "output_bounded" | "exit_nonzero" | "no_answer" | "no_json" | "output_capped";
81
97
  }>;
82
98
  /** Set on every run this module starts, so a hook inside it knows not to judge again. */
83
99
  export declare const JUDGE_ACTIVE_VARIABLE = "BALLADEER_JUDGE_ACTIVE";
@@ -102,6 +118,8 @@ export declare function claudeArguments(input: Readonly<{
102
118
  export declare function codexArguments(input: Readonly<{
103
119
  cwd: string;
104
120
  sandbox: "read-only" | "workspace-write";
121
+ /** The run is in a folder that is no repository on purpose, such as an empty one. */
122
+ outsideRepository?: boolean;
105
123
  }>): string[];
106
124
  /**
107
125
  * Whether this laptop has the subscription login the client needs.
@@ -40,8 +40,13 @@ export function clientBinary(client, env) {
40
40
  export function claudeArguments(input) {
41
41
  const tools = (input.tools ?? input.allowedTools).join(",");
42
42
  // Rules narrower than a tool carry spaces (`Bash(git diff:*)`), so they go
43
- // in as separate values rather than as one comma list.
44
- const allowed = input.tools === undefined ? [tools] : [...input.allowedTools];
43
+ // in as separate values rather than as one comma list. A run with no tools
44
+ // at all (`--tools ""`) has nothing to allow, and the flag is left out.
45
+ const allowed = input.allowedTools.length === 0
46
+ ? []
47
+ : input.tools === undefined
48
+ ? [tools]
49
+ : [...input.allowedTools];
45
50
  return [
46
51
  "--print",
47
52
  "--output-format",
@@ -59,8 +64,7 @@ export function claudeArguments(input) {
59
64
  "dontAsk",
60
65
  "--tools",
61
66
  tools,
62
- "--allowedTools",
63
- ...allowed,
67
+ ...(allowed.length > 0 ? ["--allowedTools", ...allowed] : []),
64
68
  ...((input.disallowedTools ?? []).length > 0
65
69
  ? ["--disallowedTools", ...(input.disallowedTools ?? [])]
66
70
  : []),
@@ -77,6 +81,8 @@ export function codexArguments(input) {
77
81
  input.sandbox,
78
82
  "-C",
79
83
  input.cwd,
84
+ // Codex refuses to start outside a git repository unless told this.
85
+ ...(input.outsideRepository === true ? ["--skip-git-repo-check"] : []),
80
86
  "--json",
81
87
  "-c",
82
88
  'approval_policy="never"',
@@ -338,6 +344,8 @@ export async function runHeadlessAgent(options) {
338
344
  const env = agentEnvironment(options.env);
339
345
  let args;
340
346
  if (client === "claude") {
347
+ if (options.maxOutputTokens !== undefined)
348
+ env.CLAUDE_CODE_MAX_OUTPUT_TOKENS = String(options.maxOutputTokens);
341
349
  const settingsPath = join(privateRoot, "settings.json");
342
350
  writeFileSync(settingsPath, JSON.stringify({ disableAllHooks: true }), { mode: 0o600 });
343
351
  args = claudeArguments({
@@ -359,7 +367,11 @@ export async function runHeadlessAgent(options) {
359
367
  if (existsSync(auth))
360
368
  symlinkSync(auth, join(codexHome, "auth.json"));
361
369
  env.CODEX_HOME = codexHome;
362
- args = codexArguments({ cwd: options.cwd, sandbox: options.sandbox ?? "read-only" });
370
+ args = codexArguments({
371
+ cwd: options.cwd,
372
+ sandbox: options.sandbox ?? "read-only",
373
+ ...(options.outsideRepository === true ? { outsideRepository: true } : {}),
374
+ });
363
375
  }
364
376
  const run = await runOwnedProcess({
365
377
  command: clientBinary(client, options.env),
@@ -399,6 +411,11 @@ export async function runHeadlessAgent(options) {
399
411
  return failed("timed_out");
400
412
  if (run.bounded)
401
413
  return failed("output_bounded");
414
+ // Reaching the cap is a stop, whatever the agent managed to say before it.
415
+ if (options.maxOutputTokens !== undefined &&
416
+ parsed?.outputTokens !== undefined &&
417
+ parsed.outputTokens >= options.maxOutputTokens)
418
+ return { ...failed("output_capped"), overOutputCap: true };
402
419
  if (run.exitCode !== 0)
403
420
  return failed("exit_nonzero");
404
421
  if (parsed === undefined || parsed.isError || parsed.text.trim() === "")
@@ -1,7 +1,8 @@
1
+ import type { PromiseAnchors } from "./anchors-schema.js";
1
2
  import type { BehaviorMap } from "./behavior-map-schema.js";
2
3
  import type { PromiseDocument } from "./promise-meaning.js";
3
4
  /**
4
- * The fixed instructions a judge works from, `judge-brief/v3`.
5
+ * The fixed instructions a judge works from, `judge-brief/v5`.
5
6
  *
6
7
  * One promise, one change, one disposable checkout at the change's head. The
7
8
  * rules are the contract's: cases first, linked test first, broken only with a
@@ -13,8 +14,17 @@ import type { PromiseDocument } from "./promise-meaning.js";
13
14
  *
14
15
  * The text is a constant so a verdict can name the exact brief it was given.
15
16
  * Changing a word here is a new revision.
17
+ *
18
+ * v5 is v3's rules word for word; what it added is a section of the prompt
19
+ * (1 October 2026, with promise anchors): a promise with anchors and no
20
+ * behavior map is handed its anchors, the linked tests the graph gives at
21
+ * head, and the files one import step from its anchor files, where v3 said
22
+ * "find where its behavior lives yourself". A promise with a map is handed
23
+ * exactly what v3 handed it. v4 belongs to a candidate measured and not
24
+ * adopted on 30 September, so it is never reused. The judge with anchors in
25
+ * place of a map has not been measured yet.
16
26
  */
17
- export declare const JUDGE_BRIEF_REVISION = "judge-brief/v3";
27
+ export declare const JUDGE_BRIEF_REVISION = "judge-brief/v5";
18
28
  /**
19
29
  * What `judge-brief/v3` added to v1, and all it added (Robert, 30 September
20
30
  * 2026): a way forward when the tests need something the machine lacks.
@@ -31,6 +41,14 @@ export declare const JUDGE_BRIEF = "You are judging one change to this repositor
31
41
  export type JudgeBriefInput = Readonly<{
32
42
  promise: PromiseDocument;
33
43
  map?: BehaviorMap;
44
+ /** Read only when there is no map. */
45
+ anchors?: Readonly<{
46
+ anchors: PromiseAnchors;
47
+ linkedTests: readonly Readonly<{
48
+ file: string;
49
+ }>[];
50
+ neighborhood: readonly string[];
51
+ }>;
34
52
  changedFiles: readonly string[];
35
53
  base: string;
36
54
  head: string;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The fixed instructions a judge works from, `judge-brief/v3`.
2
+ * The fixed instructions a judge works from, `judge-brief/v5`.
3
3
  *
4
4
  * One promise, one change, one disposable checkout at the change's head. The
5
5
  * rules are the contract's: cases first, linked test first, broken only with a
@@ -11,8 +11,17 @@
11
11
  *
12
12
  * The text is a constant so a verdict can name the exact brief it was given.
13
13
  * Changing a word here is a new revision.
14
+ *
15
+ * v5 is v3's rules word for word; what it added is a section of the prompt
16
+ * (1 October 2026, with promise anchors): a promise with anchors and no
17
+ * behavior map is handed its anchors, the linked tests the graph gives at
18
+ * head, and the files one import step from its anchor files, where v3 said
19
+ * "find where its behavior lives yourself". A promise with a map is handed
20
+ * exactly what v3 handed it. v4 belongs to a candidate measured and not
21
+ * adopted on 30 September, so it is never reused. The judge with anchors in
22
+ * place of a map has not been measured yet.
14
23
  */
15
- export const JUDGE_BRIEF_REVISION = "judge-brief/v3";
24
+ export const JUDGE_BRIEF_REVISION = "judge-brief/v5";
16
25
  /**
17
26
  * What `judge-brief/v3` added to v1, and all it added (Robert, 30 September
18
27
  * 2026): a way forward when the tests need something the machine lacks.
@@ -72,6 +81,21 @@ export function judgePrompt(input) {
72
81
  sections.push("Where this promise's behavior lives, from its behavior map (built earlier; check it against the code):");
73
82
  sections.push(JSON.stringify(map, null, 2));
74
83
  }
84
+ else if (input.anchors !== undefined) {
85
+ const { anchors, linkedTests, neighborhood } = input.anchors;
86
+ sections.push("Where this promise's behavior lives, from its anchors (picked earlier from the promise's words and a list of this repository's files; check them against the code):");
87
+ sections.push(JSON.stringify({
88
+ entryPoints: anchors.entryPoints,
89
+ symbols: anchors.symbols,
90
+ resources: anchors.resources,
91
+ linkedTests,
92
+ }, null, 2));
93
+ if (neighborhood.length > 0) {
94
+ sections.push("Files one import step from those (what they import and what imports them), where to look next if the behavior reaches further:");
95
+ for (const file of neighborhood)
96
+ sections.push(`- ${file}`);
97
+ }
98
+ }
75
99
  else {
76
100
  sections.push("There is no behavior map for this promise; find where its behavior lives yourself.");
77
101
  }
@@ -1,4 +1,5 @@
1
1
  import { type BehaviorMapMeaning } from "./behavior-map-schema.js";
2
+ import { type StoredAgent } from "./store.js";
2
3
  /**
3
4
  * One promise's agreed meaning, the way the map builder and the judge read it.
4
5
  *
@@ -115,6 +116,22 @@ export type PromiseReader = Readonly<{
115
116
  * is told what happened and what works meanwhile.
116
117
  */
117
118
  export declare function olderServerRefusal(text: string): string | undefined;
119
+ /**
120
+ * The agent connection stored on this laptop for the repository a folder is
121
+ * in, or the reason there is none. Read locally; nothing is asked of the server.
122
+ */
123
+ export declare function connectionAgent(input: Readonly<{
124
+ environment: NodeJS.ProcessEnv;
125
+ controlPlane: string;
126
+ cwd: string;
127
+ repository?: string;
128
+ }>): {
129
+ ok: true;
130
+ agent: StoredAgent;
131
+ } | {
132
+ ok: false;
133
+ reason: string;
134
+ };
118
135
  /**
119
136
  * This repository's agent connection as a reader of promises, or the reason
120
137
  * there is none. Reading is all it does: `get_promise` and `list_promises`,
@@ -211,22 +211,31 @@ export function olderServerRefusal(text) {
211
211
  "the server is updated; until then, hand the promise over as a file with --from-file.");
212
212
  }
213
213
  /**
214
- * This repository's agent connection as a reader of promises, or the reason
215
- * there is none. Reading is all it does: `get_promise` and `list_promises`,
216
- * the same two reads an agent in this repository makes.
214
+ * The agent connection stored on this laptop for the repository a folder is
215
+ * in, or the reason there is none. Read locally; nothing is asked of the server.
217
216
  */
218
- export function connectionReader(input) {
219
- let agent;
217
+ export function connectionAgent(input) {
220
218
  try {
221
219
  const credentials = readCredentials(input.environment);
222
220
  const selection = selectAgent(credentials.agents, input.controlPlane, input.repository, repositoryHints(input.cwd));
223
221
  if (selection.kind === "refused")
224
222
  return { ok: false, reason: selection.reason };
225
- agent = selection.agent;
223
+ return { ok: true, agent: selection.agent };
226
224
  }
227
225
  catch (error) {
228
226
  return { ok: false, reason: error instanceof Error ? error.message : String(error) };
229
227
  }
228
+ }
229
+ /**
230
+ * This repository's agent connection as a reader of promises, or the reason
231
+ * there is none. Reading is all it does: `get_promise` and `list_promises`,
232
+ * the same two reads an agent in this repository makes.
233
+ */
234
+ export function connectionReader(input) {
235
+ const found = connectionAgent(input);
236
+ if (!found.ok)
237
+ return found;
238
+ const agent = found.agent;
230
239
  const refusal = (kind) => kind === "unreachable"
231
240
  ? `Balladeer could not be reached at ${input.controlPlane}`
232
241
  : kind === "unauthorized"
@@ -15,11 +15,13 @@ export declare function tryGit(root: string, args: readonly string[]): string |
15
15
  export declare function resolveCommit(root: string, ref: string): string;
16
16
  /** The merge base `git diff base...head` compares against. */
17
17
  export declare function mergeBase(root: string, base: string, head: string): string;
18
+ /** One blob in a tree: its path, its size, and the object id its content hashes to. */
18
19
  export type TreeEntry = Readonly<{
19
20
  path: string;
20
21
  size: number;
22
+ sha: string;
21
23
  }>;
22
- /** Every blob in a commit's tree, with its size. */
24
+ /** Every blob in a commit's tree, with its size and object id. */
23
25
  export declare function listTree(root: string, rev: string): TreeEntry[];
24
26
  /**
25
27
  * The contents of many files at one commit, in one `git cat-file --batch`.
package/dist/risk/git.js CHANGED
@@ -59,7 +59,7 @@ export function mergeBase(root, base, head) {
59
59
  throw new GitReadError(["merge-base", base, head], "no merge base between the two commits");
60
60
  return sha;
61
61
  }
62
- /** Every blob in a commit's tree, with its size. */
62
+ /** Every blob in a commit's tree, with its size and object id. */
63
63
  export function listTree(root, rev) {
64
64
  const out = runGit(root, ["ls-tree", "-r", "-z", "--long", rev]).toString("utf8");
65
65
  const entries = [];
@@ -69,10 +69,10 @@ export function listTree(root, rev) {
69
69
  const tab = record.indexOf("\t");
70
70
  if (tab < 0)
71
71
  continue;
72
- const [, type, , size] = record.slice(0, tab).split(/\s+/);
72
+ const [, type, sha, size] = record.slice(0, tab).split(/\s+/);
73
73
  if (type !== "blob")
74
74
  continue;
75
- entries.push({ path: record.slice(tab + 1), size: Number(size) || 0 });
75
+ entries.push({ path: record.slice(tab + 1), size: Number(size) || 0, sha: sha ?? "" });
76
76
  }
77
77
  return entries;
78
78
  }
@@ -0,0 +1,83 @@
1
+ import type { SymbolKind } from "./contract.js";
2
+ import { type ImportGraph } from "./import-graph.js";
3
+ /**
4
+ * The repository graph of one commit, built from a cache on this laptop.
5
+ *
6
+ * What a file imports and which names it declares depend only on its own
7
+ * content, so they are kept per blob, and a commit parses only the blobs this
8
+ * laptop has not read before. How those imports resolve depends on the whole
9
+ * tree, so that is worked out again on every build, from the cached
10
+ * specifiers; it is cheap. The cache sits in Balladeer's config home, one file
11
+ * per repository (all worktrees of a repository share it), and holds file
12
+ * paths' blob ids, import specifiers and declared names. It is never committed
13
+ * and never sent anywhere.
14
+ */
15
+ /** Bumped whenever what is read out of a blob changes, so old entries are read again. */
16
+ export declare const GRAPH_CACHE_VERSION = 1;
17
+ export declare const GRAPH_CACHE_DIRECTORY = "graph-cache";
18
+ export type ExportedName = Readonly<{
19
+ name: string;
20
+ kind: SymbolKind;
21
+ }>;
22
+ type BlobSymbols = {
23
+ /** Which reader found them: `typescript@<version>` or `regex`. */
24
+ reader: string;
25
+ /** Top-level names the file exports, in the order it declares them. */
26
+ exported: ExportedName[];
27
+ /** Every name the file declares or exports, which is what an anchor's symbol is checked against. */
28
+ names: string[];
29
+ };
30
+ export type GraphBuildStats = Readonly<{
31
+ /** Source files in the graph. */
32
+ files: number;
33
+ /** Blobs read and parsed in this build. */
34
+ parsed: number;
35
+ /** Blobs whose reading came from the cache. */
36
+ reused: number;
37
+ ms: number;
38
+ }>;
39
+ export type RepositoryGraph = Readonly<{
40
+ root: string;
41
+ /** The commit, as a full sha. */
42
+ sha: string;
43
+ graph: ImportGraph;
44
+ /** Every path in the commit's tree, of any kind. */
45
+ tree: ReadonlySet<string>;
46
+ /** The names a source file exports, read once per blob. Undefined for a file not in the graph. */
47
+ exportedNames(file: string): readonly ExportedName[] | undefined;
48
+ /** Every name a source file declares or exports. Undefined for a file not in the graph. */
49
+ declaredNames(file: string): ReadonlySet<string> | undefined;
50
+ /** Read the names of many files at once, so a whole index costs one git read. */
51
+ prepareNames(files: readonly string[]): void;
52
+ /** Keep what this build read for the next one. Never throws. */
53
+ save(): void;
54
+ stats(): GraphBuildStats;
55
+ }>;
56
+ /**
57
+ * Names a script file makes available beyond its declarations: `default` for
58
+ * `export default`, each name or alias in `export { a, b as c }`, a namespace
59
+ * re-export's name, and the names bound by `export const { a, b } = ...` or
60
+ * `export const [a, b] = ...`.
61
+ */
62
+ export declare function exportClauseNames(text: string): string[];
63
+ /**
64
+ * Whether a graph file belongs in the anchoring index: product source, so not
65
+ * a test, and (from `anchor-brief/v3`) not a story or a mock.
66
+ */
67
+ export declare function isIndexSource(file: string): boolean;
68
+ /** The names a file declares and exports, read the way the anchoring index reads them. */
69
+ export declare function readSymbols(file: string, text: string, root: string): Omit<BlobSymbols, "reader">;
70
+ /** The cache file of the repository a checkout belongs to, shared by all its worktrees. */
71
+ export declare function graphCachePath(root: string, environment: NodeJS.ProcessEnv): string;
72
+ /**
73
+ * The graph of `rev`, from this laptop's cache where it can be. With
74
+ * `cache: false` nothing is read from or written to disk, which is the plain
75
+ * build every time.
76
+ */
77
+ export declare function buildRepositoryGraph(input: Readonly<{
78
+ root: string;
79
+ rev: string;
80
+ environment?: NodeJS.ProcessEnv;
81
+ cache?: boolean;
82
+ }>): RepositoryGraph;
83
+ export {};