balladeer 1.0.16 → 1.0.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/anchor-budget.d.ts +40 -0
- package/dist/anchor-budget.js +110 -0
- package/dist/anchoring.d.ts +241 -0
- package/dist/anchoring.js +766 -0
- package/dist/anchors-client.d.ts +62 -0
- package/dist/anchors-client.js +197 -0
- package/dist/anchors-schema.d.ts +85 -0
- package/dist/anchors-schema.js +246 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +24 -7
- package/dist/commands/anchor-usage.d.ts +6 -0
- package/dist/commands/anchor-usage.js +19 -0
- package/dist/commands/anchor.d.ts +127 -0
- package/dist/commands/anchor.js +303 -0
- package/dist/commands/guidance.d.ts +2 -1
- package/dist/commands/guidance.js +55 -0
- package/dist/commands/judge.d.ts +219 -12
- package/dist/commands/judge.js +911 -103
- package/dist/commands/map.d.ts +46 -2
- package/dist/commands/map.js +244 -5
- package/dist/commands/mcp.js +2 -2
- package/dist/guidance-hook.mjs +282 -95
- package/dist/guidance.d.ts +7 -0
- package/dist/guidance.js +10 -1
- package/dist/headless-agent.d.ts +19 -1
- package/dist/headless-agent.js +22 -5
- package/dist/hook-trust.d.ts +41 -9
- package/dist/hook-trust.js +98 -16
- package/dist/judge-brief.d.ts +33 -3
- package/dist/judge-brief.js +39 -2
- package/dist/judge-hook.d.ts +188 -14
- package/dist/judge-hook.js +919 -61
- package/dist/judge-said.d.ts +52 -0
- package/dist/judge-said.js +181 -0
- package/dist/promise-meaning.d.ts +25 -0
- package/dist/promise-meaning.js +24 -7
- package/dist/relay.d.ts +71 -0
- package/dist/relay.js +193 -0
- package/dist/remove-earlier.js +4 -2
- package/dist/risk/git.d.ts +3 -1
- package/dist/risk/git.js +3 -3
- package/dist/risk/graph-cache.d.ts +83 -0
- package/dist/risk/graph-cache.js +291 -0
- package/dist/risk/import-graph.d.ts +43 -0
- package/dist/risk/import-graph.js +88 -34
- package/dist/risk/index.d.ts +1 -1
- package/dist/risk/index.js +1 -1
- package/dist/risk/pipeline.d.ts +13 -0
- package/dist/risk/pipeline.js +15 -4
- package/dist/risk/score.d.ts +10 -0
- package/dist/risk/score.js +11 -2
- package/dist/scratch-worktree.d.ts +5 -1
- package/dist/scratch-worktree.js +9 -2
- package/dist/user-scope.d.ts +74 -3
- package/dist/user-scope.js +270 -9
- package/dist/wire.d.ts +19 -3
- package/dist/wire.js +2 -2
- package/package.json +7 -2
package/dist/commands/map.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type BehaviorMap } from "../behavior-map-schema.js";
|
|
1
|
+
import { type BehaviorMap, type BehaviorMapLinkedTest } from "../behavior-map-schema.js";
|
|
2
2
|
import { type HeadlessAgentOptions, type HeadlessAgentResult, type HeadlessClient, type LoginCheck } from "../headless-agent.js";
|
|
3
3
|
import { type PromiseDocument, type PromiseReader } from "../promise-meaning.js";
|
|
4
4
|
/** Tools a map builder or a judge may use, and the ones refused whatever else is allowed. */
|
|
@@ -17,8 +17,15 @@ export type MapArguments = Readonly<{
|
|
|
17
17
|
json: boolean;
|
|
18
18
|
repository?: string;
|
|
19
19
|
controlPlane: string;
|
|
20
|
+
/** `--link-test`: add one test to a promise's map, with no model and no server. */
|
|
21
|
+
linkTest?: Readonly<{
|
|
22
|
+
promiseId: string;
|
|
23
|
+
file: string;
|
|
24
|
+
name: string;
|
|
25
|
+
cases: string[];
|
|
26
|
+
}>;
|
|
20
27
|
}>;
|
|
21
|
-
export declare const MAP_USAGE = " npx -y balladeer@latest map <promise id> | --all | --check | --stale\n [--from-file <promise.json>] [--repo <root>] [--out <dir>]\n [--client auto|claude|codex] [--timeout <seconds>] [--json]\n Build a behavior map for a promise with your own coding agent: where its\n behavior starts, the code on its path, what it depends on, the tests that\n exercise it and where each failing case would show. The agent works in a\n throwaway read-only checkout, on the Claude Code or Codex login already on\n this machine, and the map is written to .continuity/behavior-maps/<id>.json\n for your team to commit. It never leaves this machine. --all maps every\n agreed promise; --check reports maps whose code or meaning has moved\n (exit 1 when any has) and --stale prints just their ids, both without a\n model. --from-file reads the promise from a file instead of Balladeer.\n";
|
|
28
|
+
export declare const MAP_USAGE = " npx -y balladeer@latest map <promise id> | --all | --check | --stale\n [--from-file <promise.json>] [--repo <root>] [--out <dir>]\n [--client auto|claude|codex] [--timeout <seconds>] [--json]\n npx -y balladeer@latest map --link-test <promise id> <test file>\n [--name <test name>] [--case <case id>]... [--repo <root>] [--json]\n Build a behavior map for a promise with your own coding agent: where its\n behavior starts, the code on its path, what it depends on, the tests that\n exercise it and where each failing case would show. The agent works in a\n throwaway read-only checkout, on the Claude Code or Codex login already on\n this machine, and the map is written to .continuity/behavior-maps/<id>.json\n for your team to commit. It never leaves this machine. --all maps every\n agreed promise; --check reports maps whose code or meaning has moved\n (exit 1 when any has) and --stale prints just their ids, both without a\n model. --from-file reads the promise from a file instead of Balladeer.\n --link-test adds a test in this repository to a promise's existing map,\n naming the cases it covers (passing-1, failing-2), so later judges run it\n and a change to it counts as risk to the promise. It changes nothing else\n in the map, calls no model and sends nothing anywhere.\n";
|
|
22
29
|
export declare function parseMapArguments(argv: readonly string[]): MapArguments;
|
|
23
30
|
export type MapDependencies = Readonly<{
|
|
24
31
|
runAgent?: (options: HeadlessAgentOptions) => Promise<HeadlessAgentResult>;
|
|
@@ -102,6 +109,43 @@ export type StoredMap = Readonly<{
|
|
|
102
109
|
/** Every map in a directory, read and checked; an unreadable one is listed with why. */
|
|
103
110
|
export declare function readBehaviorMaps(directory: string): StoredMap[];
|
|
104
111
|
export declare function readBehaviorMap(root: string, promiseId: string): BehaviorMap | undefined;
|
|
112
|
+
export type LinkTestResult = Readonly<{
|
|
113
|
+
ok: true;
|
|
114
|
+
changed: boolean;
|
|
115
|
+
path: string;
|
|
116
|
+
entry: BehaviorMapLinkedTest;
|
|
117
|
+
}> | Readonly<{
|
|
118
|
+
ok: false;
|
|
119
|
+
reason: string;
|
|
120
|
+
message: string;
|
|
121
|
+
}>;
|
|
122
|
+
/**
|
|
123
|
+
* The map's text with one entry added at the end of its top-level
|
|
124
|
+
* `linkedTests` list, and every other character left where it was, so the
|
|
125
|
+
* change a reviewer reads is the new entry and one comma. The entry is written
|
|
126
|
+
* the way this repository's formatter writes JSON: an object per line group,
|
|
127
|
+
* two-space indents, and the case list on one line while it fits in a
|
|
128
|
+
* hundred columns. Undefined when the list cannot be found.
|
|
129
|
+
*/
|
|
130
|
+
export declare function insertLinkedTest(text: string, entry: BehaviorMapLinkedTest): string | undefined;
|
|
131
|
+
/**
|
|
132
|
+
* Link one test in this repository to a promise's behavior map, the way a
|
|
133
|
+
* person would by hand, without a model: the test is appended to the map's
|
|
134
|
+
* `linkedTests` unless an equal entry is already there, and nothing else in
|
|
135
|
+
* the map changes. The map must already exist, the file must exist inside this
|
|
136
|
+
* repository and not be ignored by git (a test git ignores never reaches the
|
|
137
|
+
* repository's suite), the list must have room, and the result must pass the
|
|
138
|
+
* map checker. The map is written beside itself and renamed into place.
|
|
139
|
+
*/
|
|
140
|
+
export declare function linkTestToMap(input: Readonly<{
|
|
141
|
+
root: string;
|
|
142
|
+
directory: string;
|
|
143
|
+
cwd: string;
|
|
144
|
+
promiseId: string;
|
|
145
|
+
file: string;
|
|
146
|
+
name: string;
|
|
147
|
+
cases: readonly string[];
|
|
148
|
+
}>): Promise<LinkTestResult>;
|
|
105
149
|
export type MapOptions = Readonly<{
|
|
106
150
|
args: MapArguments;
|
|
107
151
|
cwd: string;
|
package/dist/commands/map.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
2
2
|
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
3
|
-
import { BEHAVIOR_MAP_DIRECTORY, BEHAVIOR_MAP_SCHEMA_VERSION, ENTRY_POINT_KINDS, PROMISE_ID, RESOURCE_ACCESS, RESOURCE_KINDS, SYMBOL_KINDS, SYMBOL_ROLES, parseBehaviorMap, serializeBehaviorMap, } from "../behavior-map-schema.js";
|
|
3
|
+
import { BEHAVIOR_MAP_DIRECTORY, BEHAVIOR_MAP_SCHEMA_VERSION, CASE_ID, ENTRY_POINT_KINDS, MAP_LIMITS, PROMISE_ID, RESOURCE_ACCESS, RESOURCE_KINDS, SYMBOL_KINDS, SYMBOL_ROLES, parseBehaviorMap, serializeBehaviorMap, } from "../behavior-map-schema.js";
|
|
4
|
+
import { runCommand } from "../gh.js";
|
|
4
5
|
import { repositoryRoot } from "../git.js";
|
|
5
6
|
import { runHeadlessAgent, } from "../headless-agent.js";
|
|
6
7
|
import { mapPrompt } from "../map-brief.js";
|
|
@@ -15,6 +16,8 @@ const DEFAULT_MAP_TIMEOUT_MS = 15 * 60_000;
|
|
|
15
16
|
export const MAP_USAGE = ` ${CLI_INVOCATION} map <promise id> | --all | --check | --stale
|
|
16
17
|
[--from-file <promise.json>] [--repo <root>] [--out <dir>]
|
|
17
18
|
[--client auto|claude|codex] [--timeout <seconds>] [--json]
|
|
19
|
+
${CLI_INVOCATION} map --link-test <promise id> <test file>
|
|
20
|
+
[--name <test name>] [--case <case id>]... [--repo <root>] [--json]
|
|
18
21
|
Build a behavior map for a promise with your own coding agent: where its
|
|
19
22
|
behavior starts, the code on its path, what it depends on, the tests that
|
|
20
23
|
exercise it and where each failing case would show. The agent works in a
|
|
@@ -24,6 +27,10 @@ export const MAP_USAGE = ` ${CLI_INVOCATION} map <promise id> | --all | --check
|
|
|
24
27
|
agreed promise; --check reports maps whose code or meaning has moved
|
|
25
28
|
(exit 1 when any has) and --stale prints just their ids, both without a
|
|
26
29
|
model. --from-file reads the promise from a file instead of Balladeer.
|
|
30
|
+
--link-test adds a test in this repository to a promise's existing map,
|
|
31
|
+
naming the cases it covers (passing-1, failing-2), so later judges run it
|
|
32
|
+
and a change to it counts as risk to the promise. It changes nothing else
|
|
33
|
+
in the map, calls no model and sends nothing anywhere.
|
|
27
34
|
`;
|
|
28
35
|
export function parseMapArguments(argv) {
|
|
29
36
|
const args = [...argv];
|
|
@@ -39,6 +46,9 @@ export function parseMapArguments(argv) {
|
|
|
39
46
|
let controlPlane;
|
|
40
47
|
let client = "auto";
|
|
41
48
|
let timeoutMs = DEFAULT_MAP_TIMEOUT_MS;
|
|
49
|
+
let linkTest;
|
|
50
|
+
let testName;
|
|
51
|
+
const cases = [];
|
|
42
52
|
const value = (flag, inline) => {
|
|
43
53
|
const next = inline ?? args.shift();
|
|
44
54
|
if (next === undefined || next === "")
|
|
@@ -83,16 +93,45 @@ export function parseMapArguments(argv) {
|
|
|
83
93
|
throw new StoreError("usage", "--timeout needs a number of seconds.");
|
|
84
94
|
timeoutMs = Math.round(seconds * 1000);
|
|
85
95
|
}
|
|
96
|
+
else if (name === "--link-test") {
|
|
97
|
+
const id = value(name, inline);
|
|
98
|
+
const file = args.shift();
|
|
99
|
+
if (file === undefined || file === "" || file.startsWith("--"))
|
|
100
|
+
throw new StoreError("usage", "--link-test needs a promise id and then a test file.");
|
|
101
|
+
linkTest = { promiseId: id, file };
|
|
102
|
+
}
|
|
103
|
+
else if (name === "--name")
|
|
104
|
+
testName = value(name, inline);
|
|
105
|
+
else if (name === "--case") {
|
|
106
|
+
for (const id of value(name, inline).split(",")) {
|
|
107
|
+
const caseId = id.trim();
|
|
108
|
+
if (!CASE_ID.test(caseId))
|
|
109
|
+
throw new StoreError("usage", `"${caseId}" is not a case id; a case id names a case of the promise, such as passing-1 or failing-2.`);
|
|
110
|
+
if (!cases.includes(caseId))
|
|
111
|
+
cases.push(caseId);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
86
114
|
else
|
|
87
115
|
throw new StoreError("usage", `Unknown option ${arg} for map.`);
|
|
88
116
|
}
|
|
89
117
|
if (positional.length > 1)
|
|
90
118
|
throw new StoreError("usage", "map takes at most one promise id.");
|
|
91
|
-
const modes = [
|
|
119
|
+
const modes = [
|
|
120
|
+
positional.length === 1,
|
|
121
|
+
all,
|
|
122
|
+
check,
|
|
123
|
+
stale,
|
|
124
|
+
fromFile !== undefined,
|
|
125
|
+
linkTest !== undefined,
|
|
126
|
+
].filter(Boolean);
|
|
92
127
|
if (modes.length === 0)
|
|
93
|
-
throw new StoreError("usage", "map needs a promise id, --all, --check, --stale or --
|
|
128
|
+
throw new StoreError("usage", "map needs a promise id, --all, --check, --stale, --from-file or --link-test.");
|
|
94
129
|
if (modes.length > 1)
|
|
95
|
-
throw new StoreError("usage", "map takes one of: a promise id, --all, --check, --stale, --from-file.");
|
|
130
|
+
throw new StoreError("usage", "map takes one of: a promise id, --all, --check, --stale, --from-file, --link-test.");
|
|
131
|
+
if (linkTest === undefined && (testName !== undefined || cases.length > 0))
|
|
132
|
+
throw new StoreError("usage", "--name and --case go with --link-test.");
|
|
133
|
+
if (linkTest !== undefined && !PROMISE_ID.test(linkTest.promiseId))
|
|
134
|
+
throw new StoreError("usage", `"${linkTest.promiseId}" is not a promise id.`);
|
|
96
135
|
const promiseId = positional[0];
|
|
97
136
|
if (promiseId !== undefined && !PROMISE_ID.test(promiseId))
|
|
98
137
|
throw new StoreError("usage", `"${promiseId}" is not a promise id.`);
|
|
@@ -110,6 +149,7 @@ export function parseMapArguments(argv) {
|
|
|
110
149
|
json,
|
|
111
150
|
...(repository === undefined ? {} : { repository }),
|
|
112
151
|
controlPlane: normalizeControlPlane(chosen),
|
|
152
|
+
...(linkTest === undefined ? {} : { linkTest: { ...linkTest, name: testName ?? "", cases } }),
|
|
113
153
|
};
|
|
114
154
|
}
|
|
115
155
|
/**
|
|
@@ -181,6 +221,8 @@ export function agentFailure(agent) {
|
|
|
181
221
|
return "the agent finished without an answer";
|
|
182
222
|
case "no_json":
|
|
183
223
|
return "the agent's answer held no JSON object";
|
|
224
|
+
case "output_capped":
|
|
225
|
+
return `the agent wrote ${agent.outputTokens ?? "more"} output tokens, past its cap, so its answer was not used`;
|
|
184
226
|
default:
|
|
185
227
|
return "the agent did not answer";
|
|
186
228
|
}
|
|
@@ -421,6 +463,171 @@ export function readBehaviorMap(root, promiseId) {
|
|
|
421
463
|
return undefined;
|
|
422
464
|
}
|
|
423
465
|
}
|
|
466
|
+
/** Two linked tests are the same entry when file, name and the set of cases match. */
|
|
467
|
+
function sameLinkedTest(a, b) {
|
|
468
|
+
return (a.file === b.file &&
|
|
469
|
+
a.name === b.name &&
|
|
470
|
+
a.cases.length === b.cases.length &&
|
|
471
|
+
a.cases.every((id) => b.cases.includes(id)));
|
|
472
|
+
}
|
|
473
|
+
/** Where a JSON string that starts at `at` ends: the index of its closing quote. */
|
|
474
|
+
function stringEnd(text, at) {
|
|
475
|
+
let index = at + 1;
|
|
476
|
+
while (index < text.length && text[index] !== '"')
|
|
477
|
+
index += text[index] === "\\" ? 2 : 1;
|
|
478
|
+
return index;
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* The map's text with one entry added at the end of its top-level
|
|
482
|
+
* `linkedTests` list, and every other character left where it was, so the
|
|
483
|
+
* change a reviewer reads is the new entry and one comma. The entry is written
|
|
484
|
+
* the way this repository's formatter writes JSON: an object per line group,
|
|
485
|
+
* two-space indents, and the case list on one line while it fits in a
|
|
486
|
+
* hundred columns. Undefined when the list cannot be found.
|
|
487
|
+
*/
|
|
488
|
+
export function insertLinkedTest(text, entry) {
|
|
489
|
+
let depth = 0;
|
|
490
|
+
let key = -1;
|
|
491
|
+
let open = -1;
|
|
492
|
+
for (let index = 0; index < text.length && open < 0; index += 1) {
|
|
493
|
+
const char = text[index];
|
|
494
|
+
if (char === '"') {
|
|
495
|
+
const end = stringEnd(text, index);
|
|
496
|
+
if (depth === 1 && text.slice(index, end + 1) === '"linkedTests"') {
|
|
497
|
+
const rest = /^\s*:\s*\[/.exec(text.slice(end + 1));
|
|
498
|
+
if (rest !== null) {
|
|
499
|
+
key = index;
|
|
500
|
+
open = end + rest[0].length;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
index = end;
|
|
504
|
+
}
|
|
505
|
+
else if (char === "{" || char === "[")
|
|
506
|
+
depth += 1;
|
|
507
|
+
else if (char === "}" || char === "]")
|
|
508
|
+
depth -= 1;
|
|
509
|
+
}
|
|
510
|
+
if (open < 0)
|
|
511
|
+
return undefined;
|
|
512
|
+
let close = -1;
|
|
513
|
+
depth = 0;
|
|
514
|
+
for (let index = open; index < text.length; index += 1) {
|
|
515
|
+
const char = text[index];
|
|
516
|
+
if (char === '"')
|
|
517
|
+
index = stringEnd(text, index);
|
|
518
|
+
else if (char === "[" || char === "{")
|
|
519
|
+
depth += 1;
|
|
520
|
+
else if (char === "]" || char === "}") {
|
|
521
|
+
depth -= 1;
|
|
522
|
+
if (depth === 0) {
|
|
523
|
+
close = index;
|
|
524
|
+
break;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
if (close < 0)
|
|
529
|
+
return undefined;
|
|
530
|
+
const indent = text.slice(text.lastIndexOf("\n", key) + 1, key);
|
|
531
|
+
if (!/^[ \t]*$/.test(indent))
|
|
532
|
+
return undefined;
|
|
533
|
+
const element = `${indent} `;
|
|
534
|
+
const field = `${indent} `;
|
|
535
|
+
const inline = `[${entry.cases.map((id) => JSON.stringify(id)).join(", ")}]`;
|
|
536
|
+
const cases = `${field}"cases": ${inline}`.length <= 100
|
|
537
|
+
? inline
|
|
538
|
+
: `[\n${entry.cases.map((id) => `${field} ${JSON.stringify(id)}`).join(",\n")}\n${field}]`;
|
|
539
|
+
const block = `${element}{\n` +
|
|
540
|
+
`${field}"file": ${JSON.stringify(entry.file)},\n` +
|
|
541
|
+
`${field}"name": ${JSON.stringify(entry.name)},\n` +
|
|
542
|
+
`${field}"cases": ${cases}\n` +
|
|
543
|
+
`${element}}`;
|
|
544
|
+
if (text.slice(open + 1, close).trim() === "")
|
|
545
|
+
return `${text.slice(0, open)}[\n${block}\n${indent}]${text.slice(close + 1)}`;
|
|
546
|
+
let last = close - 1;
|
|
547
|
+
while (last > open && /\s/.test(text[last] ?? ""))
|
|
548
|
+
last -= 1;
|
|
549
|
+
return `${text.slice(0, last + 1)},\n${block}${text.slice(last + 1)}`;
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Link one test in this repository to a promise's behavior map, the way a
|
|
553
|
+
* person would by hand, without a model: the test is appended to the map's
|
|
554
|
+
* `linkedTests` unless an equal entry is already there, and nothing else in
|
|
555
|
+
* the map changes. The map must already exist, the file must exist inside this
|
|
556
|
+
* repository and not be ignored by git (a test git ignores never reaches the
|
|
557
|
+
* repository's suite), the list must have room, and the result must pass the
|
|
558
|
+
* map checker. The map is written beside itself and renamed into place.
|
|
559
|
+
*/
|
|
560
|
+
export async function linkTestToMap(input) {
|
|
561
|
+
const refuse = (reason, message) => ({
|
|
562
|
+
ok: false,
|
|
563
|
+
reason,
|
|
564
|
+
message,
|
|
565
|
+
});
|
|
566
|
+
const path = join(input.directory, `${input.promiseId}.json`);
|
|
567
|
+
if (!existsSync(path))
|
|
568
|
+
return refuse("no_behavior_map", `${input.promiseId} has no behavior map here yet, so there is nothing to link a test to. Run \`balladeer map ${input.promiseId}\` first.`);
|
|
569
|
+
const target = resolve(input.cwd, input.file);
|
|
570
|
+
if (!existsSync(target))
|
|
571
|
+
return refuse("test_not_found", `${input.file} does not exist, so it was not linked.`);
|
|
572
|
+
const root = realpathSync(input.root);
|
|
573
|
+
const real = realpathSync(target);
|
|
574
|
+
const inside = relative(root, real);
|
|
575
|
+
if (inside === "" || inside.startsWith("..") || isAbsolute(inside))
|
|
576
|
+
return refuse("outside_repository", `${input.file} is outside this repository, so it was not linked. A linked test lives in this repository's own tests.`);
|
|
577
|
+
if (!statSync(real).isFile())
|
|
578
|
+
return refuse("not_a_file", `${input.file} is not a file, so it was not linked.`);
|
|
579
|
+
const file = inside.split(sep).join("/");
|
|
580
|
+
const ignored = await runCommand("git", ["-C", root, "check-ignore", "-q", "--", file]);
|
|
581
|
+
if (ignored.code === 0)
|
|
582
|
+
return refuse("ignored_by_git", `${file} is ignored by git, so it would never reach this repository's tests and was not linked. Move it into the repository's own tests first.`);
|
|
583
|
+
const text = readFileSync(path, "utf8");
|
|
584
|
+
let raw;
|
|
585
|
+
try {
|
|
586
|
+
raw = JSON.parse(text);
|
|
587
|
+
}
|
|
588
|
+
catch {
|
|
589
|
+
return refuse("map_unreadable", `${path} is not JSON, so nothing was linked.`);
|
|
590
|
+
}
|
|
591
|
+
const current = parseBehaviorMap(raw);
|
|
592
|
+
if (!current.ok)
|
|
593
|
+
return refuse("map_unreadable", `${path} is not a usable behavior map, so nothing was linked: ${current.errors.slice(0, 3).join("; ")}.`);
|
|
594
|
+
if (current.value.promiseId !== input.promiseId)
|
|
595
|
+
return refuse("map_mismatch", `${path} is the map of ${current.value.promiseId}, not ${input.promiseId}, so nothing was linked.`);
|
|
596
|
+
const entry = { file, name: input.name, cases: [...input.cases] };
|
|
597
|
+
if (current.value.linkedTests.some((test) => sameLinkedTest(test, entry)))
|
|
598
|
+
return { ok: true, changed: false, path, entry };
|
|
599
|
+
if (current.value.linkedTests.length >= MAP_LIMITS.linkedTests)
|
|
600
|
+
return refuse("linked_tests_full", `${input.promiseId}'s behavior map already links ${MAP_LIMITS.linkedTests} tests, the most a map holds, so ${file} was not linked. Remove one it no longer needs from linkedTests first.`);
|
|
601
|
+
const next = { ...raw, linkedTests: [...current.value.linkedTests, entry] };
|
|
602
|
+
const checked = parseBehaviorMap(next);
|
|
603
|
+
if (!checked.ok)
|
|
604
|
+
return refuse("invalid_link", `Linking ${file} would make the map invalid, so nothing was linked: ${checked.errors.slice(0, 3).join("; ")}.`);
|
|
605
|
+
// A map in the form this command writes stays in that form; any other is
|
|
606
|
+
// edited in place, and the edit is kept only if it reads back as exactly
|
|
607
|
+
// the intended map.
|
|
608
|
+
const plain = `${JSON.stringify(next, null, 2)}\n`;
|
|
609
|
+
const edited = text === `${JSON.stringify(raw, null, 2)}\n` ? undefined : insertLinkedTest(text, entry);
|
|
610
|
+
let written = plain;
|
|
611
|
+
if (edited !== undefined) {
|
|
612
|
+
try {
|
|
613
|
+
if (JSON.stringify(JSON.parse(edited)) === JSON.stringify(next))
|
|
614
|
+
written = edited;
|
|
615
|
+
}
|
|
616
|
+
catch {
|
|
617
|
+
/* The plain form is written instead. */
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
const staging = `${path}.${process.pid}.tmp`;
|
|
621
|
+
try {
|
|
622
|
+
writeFileSync(staging, written, "utf8");
|
|
623
|
+
renameSync(staging, path);
|
|
624
|
+
}
|
|
625
|
+
catch (error) {
|
|
626
|
+
rmSync(staging, { force: true });
|
|
627
|
+
return refuse("write_failed", `${path} could not be written, so nothing was linked: ${error instanceof Error ? error.message : String(error)}.`);
|
|
628
|
+
}
|
|
629
|
+
return { ok: true, changed: true, path, entry };
|
|
630
|
+
}
|
|
424
631
|
/** `balladeer map`, from arguments already parsed. */
|
|
425
632
|
export async function runMap(options) {
|
|
426
633
|
const { args } = options;
|
|
@@ -443,6 +650,38 @@ export async function runMap(options) {
|
|
|
443
650
|
if (root === undefined || !existsSync(root))
|
|
444
651
|
return fail("not_a_repository", "This is not a git repository, so there is nothing to map.", 4);
|
|
445
652
|
const directory = args.out !== undefined ? resolve(options.cwd, args.out) : join(root, BEHAVIOR_MAP_DIRECTORY);
|
|
653
|
+
if (args.linkTest !== undefined) {
|
|
654
|
+
// Deterministic and local: no connection, no model, nothing sent.
|
|
655
|
+
const linked = await linkTestToMap({
|
|
656
|
+
root,
|
|
657
|
+
directory,
|
|
658
|
+
cwd: options.cwd,
|
|
659
|
+
...args.linkTest,
|
|
660
|
+
});
|
|
661
|
+
if (!linked.ok)
|
|
662
|
+
return fail(linked.reason, linked.message, 4);
|
|
663
|
+
// Shown from where the command ran, through any link in either path (a
|
|
664
|
+
// temp directory on macOS is one), so a person reads a short path.
|
|
665
|
+
const here = existsSync(options.cwd) ? realpathSync(options.cwd) : options.cwd;
|
|
666
|
+
const shown = relative(here, realpathSync(linked.path)) || linked.path;
|
|
667
|
+
if (!linked.changed) {
|
|
668
|
+
const message = `${linked.entry.file} is already linked to ${args.linkTest.promiseId} in ${shown}, so nothing was changed.`;
|
|
669
|
+
if (args.json)
|
|
670
|
+
emit({ step: "error", reason: "already_linked", message, changed: false, exitCode: 0 });
|
|
671
|
+
else
|
|
672
|
+
say(message);
|
|
673
|
+
return 0;
|
|
674
|
+
}
|
|
675
|
+
const cases = linked.entry.cases.length > 0 ? ` for ${linked.entry.cases.join(", ")}` : "";
|
|
676
|
+
say(`Linked ${linked.entry.file} to ${args.linkTest.promiseId}${cases} in ${shown}. Nothing else in the map changed. Commit the map with the test.`);
|
|
677
|
+
emit({
|
|
678
|
+
step: "behavior_map",
|
|
679
|
+
promiseId: args.linkTest.promiseId,
|
|
680
|
+
status: "written",
|
|
681
|
+
path: linked.path,
|
|
682
|
+
});
|
|
683
|
+
return 0;
|
|
684
|
+
}
|
|
446
685
|
let reader = options.deps?.reader ?? undefined;
|
|
447
686
|
let readerProblem;
|
|
448
687
|
if (options.deps?.reader === undefined && args.fromFile === undefined) {
|
package/dist/commands/mcp.js
CHANGED
|
@@ -5,7 +5,7 @@ import { noteServerVersion, updateNotice } from "../currency.js";
|
|
|
5
5
|
import { repositoryHint, repositoryHints } from "../repository.js";
|
|
6
6
|
import { CLI_VERSION } from "../wire.js";
|
|
7
7
|
import { untrackedLine } from "./guidance.js";
|
|
8
|
-
import {
|
|
8
|
+
import { hooksAwaitingTrust, hooksAwaitingTrustNotice, trustingHost } from "../hook-trust.js";
|
|
9
9
|
import { StoreError, readCredentials } from "../store.js";
|
|
10
10
|
// The three commands that use an agent connection select and address it through
|
|
11
11
|
// one module. These are re-exported because this is where the forwarder's
|
|
@@ -260,7 +260,7 @@ export async function runMcp(options) {
|
|
|
260
260
|
host !== undefined &&
|
|
261
261
|
request?.method === "tools/call" &&
|
|
262
262
|
hooksAwaitingTrust(options.environment, host)) {
|
|
263
|
-
const noted = withNotice(text,
|
|
263
|
+
const noted = withNotice(text, hooksAwaitingTrustNotice(options.environment, host));
|
|
264
264
|
if (noted !== undefined) {
|
|
265
265
|
saidTrust = true;
|
|
266
266
|
options.write(`${noted}\n`);
|