balladeer 1.0.12 → 1.0.14
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/checkout-form.d.ts +45 -0
- package/dist/checkout-form.js +223 -0
- package/dist/cli.js +30 -2
- package/dist/commands/guidance.d.ts +1 -9
- package/dist/commands/guidance.js +5 -13
- package/dist/commands/setup.js +1 -1
- package/dist/guidance-hook.mjs +3 -7
- package/dist/remove-earlier.js +7 -0
- package/dist/user-scope.d.ts +2 -0
- package/dist/user-scope.js +20 -0
- package/dist/wire.d.ts +2 -2
- package/dist/wire.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Taking this product's own older, per-repository form out of a checkout once
|
|
3
|
+
* the user-scope form is in place, so one hook speaks per session.
|
|
4
|
+
*
|
|
5
|
+
* Releases before 1.0.8 registered Balladeer inside each repository: a hook in
|
|
6
|
+
* the checkout's `.claude/settings.json` (statusMessage "Balladeer current
|
|
7
|
+
* guidance (loader 1)"), a `balladeer` server in its `.mcp.json` that names the
|
|
8
|
+
* repository, and for Codex the fenced `# balladeer:guidance` and
|
|
9
|
+
* `# balladeer:mcp` blocks in `.codex/config.toml`. From 1.0.8 the same hook
|
|
10
|
+
* and server live at the host's user scope and run in every checkout. A laptop
|
|
11
|
+
* that moved from one to the other without cleaning up runs both: the session
|
|
12
|
+
* hears the guidance twice, and the server sees two hook forms fire for one
|
|
13
|
+
* person (found on Luke's laptop, 23 September 2026). Every removal here
|
|
14
|
+
* proves ownership from the entry itself; nothing else in those files moves.
|
|
15
|
+
* A file git tracks is edited without a copy (git holds the previous version)
|
|
16
|
+
* and the person is told to commit it; an untracked file is copied beside
|
|
17
|
+
* itself first.
|
|
18
|
+
*/
|
|
19
|
+
export type CheckoutFormItem = Readonly<{
|
|
20
|
+
path: string;
|
|
21
|
+
found: string;
|
|
22
|
+
tracked: boolean;
|
|
23
|
+
remove: () => string | undefined;
|
|
24
|
+
}>;
|
|
25
|
+
export type CheckoutFormReport = Readonly<{
|
|
26
|
+
removed: readonly {
|
|
27
|
+
path: string;
|
|
28
|
+
found: string;
|
|
29
|
+
copy?: string;
|
|
30
|
+
tracked: boolean;
|
|
31
|
+
}[];
|
|
32
|
+
failed: readonly {
|
|
33
|
+
path: string;
|
|
34
|
+
found: string;
|
|
35
|
+
reason: string;
|
|
36
|
+
}[];
|
|
37
|
+
}>;
|
|
38
|
+
/** The older per-repository form in the checkout that holds `cwd`, if any. */
|
|
39
|
+
export declare function findCheckoutForm(input: {
|
|
40
|
+
cwd: string;
|
|
41
|
+
controlPlane: string;
|
|
42
|
+
now?: () => Date;
|
|
43
|
+
}): CheckoutFormItem[];
|
|
44
|
+
export declare function removeCheckoutForm(items: readonly CheckoutFormItem[]): CheckoutFormReport;
|
|
45
|
+
export declare function describeCheckoutFormRemoval(report: CheckoutFormReport): string[];
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { copyFileSync, existsSync, lstatSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { isOurEntry, writeJsonAtomically } from "./mcp-config.js";
|
|
5
|
+
const PROJECT_OWNER = "Balladeer current guidance (loader 1)";
|
|
6
|
+
const CODEX_BLOCKS = [
|
|
7
|
+
["# balladeer:guidance:start", "# balladeer:guidance:end"],
|
|
8
|
+
["# balladeer:mcp:start", "# balladeer:mcp:end"],
|
|
9
|
+
];
|
|
10
|
+
function repositoryRoot(cwd) {
|
|
11
|
+
try {
|
|
12
|
+
const root = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
|
13
|
+
cwd,
|
|
14
|
+
encoding: "utf8",
|
|
15
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
16
|
+
timeout: 2_000,
|
|
17
|
+
}).trim();
|
|
18
|
+
return root.length > 0 ? root : undefined;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function tracked(root, path) {
|
|
25
|
+
try {
|
|
26
|
+
execFileSync("git", ["ls-files", "--error-unmatch", "--", path], {
|
|
27
|
+
cwd: root,
|
|
28
|
+
stdio: "ignore",
|
|
29
|
+
timeout: 2_000,
|
|
30
|
+
});
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function regularFile(path) {
|
|
38
|
+
try {
|
|
39
|
+
return lstatSync(path).isFile();
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function readJson(path) {
|
|
46
|
+
try {
|
|
47
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
48
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
49
|
+
? parsed
|
|
50
|
+
: undefined;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function backup(path, now) {
|
|
57
|
+
// A file name, not a sentence: the person reads the copy's path back, and the
|
|
58
|
+
// stamp only has to sort. Same form as the earlier-client backups.
|
|
59
|
+
const stamp = now().toISOString().replaceAll(/[:.]/g, "-");
|
|
60
|
+
const copy = `${path}.before-balladeer-${stamp}`;
|
|
61
|
+
copyFileSync(path, copy);
|
|
62
|
+
return copy;
|
|
63
|
+
}
|
|
64
|
+
const isProjectHook = (hook) => {
|
|
65
|
+
if (hook === null || typeof hook !== "object")
|
|
66
|
+
return false;
|
|
67
|
+
const { command, statusMessage } = hook;
|
|
68
|
+
if (statusMessage === PROJECT_OWNER)
|
|
69
|
+
return true;
|
|
70
|
+
return (typeof command === "string" &&
|
|
71
|
+
/balladeer/i.test(command) &&
|
|
72
|
+
/--hook (?:claude|codex)\b/.test(command) &&
|
|
73
|
+
/--repository\b/.test(command));
|
|
74
|
+
};
|
|
75
|
+
function claudeHooks(root, now) {
|
|
76
|
+
const path = join(root, ".claude", "settings.json");
|
|
77
|
+
if (!regularFile(path))
|
|
78
|
+
return [];
|
|
79
|
+
const settings = readJson(path);
|
|
80
|
+
const hooks = settings?.hooks;
|
|
81
|
+
if (!hooks || typeof hooks !== "object" || Array.isArray(hooks))
|
|
82
|
+
return [];
|
|
83
|
+
const events = Object.entries(hooks).filter(([, groups]) => Array.isArray(groups) &&
|
|
84
|
+
groups.some((group) => (group?.hooks ?? []).some((h) => isProjectHook(h))));
|
|
85
|
+
if (events.length === 0)
|
|
86
|
+
return [];
|
|
87
|
+
const isTracked = tracked(root, ".claude/settings.json");
|
|
88
|
+
return [
|
|
89
|
+
{
|
|
90
|
+
path,
|
|
91
|
+
tracked: isTracked,
|
|
92
|
+
found: `per-repository hook${events.length === 1 ? "" : "s"} (${events.map(([event]) => event).join(", ")})`,
|
|
93
|
+
remove: () => {
|
|
94
|
+
const copy = isTracked ? undefined : backup(path, now);
|
|
95
|
+
const current = readJson(path) ?? {};
|
|
96
|
+
const kept = {};
|
|
97
|
+
for (const [event, groups] of Object.entries((current.hooks ?? {}))) {
|
|
98
|
+
if (!Array.isArray(groups)) {
|
|
99
|
+
kept[event] = groups;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const left = groups.filter((group) => !(group?.hooks ?? []).some((h) => isProjectHook(h)));
|
|
103
|
+
if (left.length > 0)
|
|
104
|
+
kept[event] = left;
|
|
105
|
+
}
|
|
106
|
+
writeJsonAtomically(path, JSON.stringify({ ...current, hooks: kept }, null, 2) + "\n");
|
|
107
|
+
return copy;
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
];
|
|
111
|
+
}
|
|
112
|
+
function claudeServer(root, controlPlane, now) {
|
|
113
|
+
const path = join(root, ".mcp.json");
|
|
114
|
+
if (!regularFile(path))
|
|
115
|
+
return [];
|
|
116
|
+
const config = readJson(path);
|
|
117
|
+
const servers = config?.mcpServers;
|
|
118
|
+
if (!servers || typeof servers !== "object" || Array.isArray(servers))
|
|
119
|
+
return [];
|
|
120
|
+
const entry = servers.balladeer;
|
|
121
|
+
if (entry === undefined || !isOurEntry(entry, controlPlane))
|
|
122
|
+
return [];
|
|
123
|
+
const args = entry.args;
|
|
124
|
+
if (!Array.isArray(args) || !args.includes("--repository"))
|
|
125
|
+
return [];
|
|
126
|
+
const isTracked = tracked(root, ".mcp.json");
|
|
127
|
+
return [
|
|
128
|
+
{
|
|
129
|
+
path,
|
|
130
|
+
tracked: isTracked,
|
|
131
|
+
found: "per-repository server entry",
|
|
132
|
+
remove: () => {
|
|
133
|
+
const copy = isTracked ? undefined : backup(path, now);
|
|
134
|
+
const current = readJson(path) ?? {};
|
|
135
|
+
const remaining = { ...(current.mcpServers ?? {}) };
|
|
136
|
+
delete remaining.balladeer;
|
|
137
|
+
writeJsonAtomically(path, JSON.stringify({ ...current, mcpServers: remaining }, null, 2) + "\n");
|
|
138
|
+
return copy;
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
];
|
|
142
|
+
}
|
|
143
|
+
function codexBlocks(root, now) {
|
|
144
|
+
const path = join(root, ".codex", "config.toml");
|
|
145
|
+
if (!regularFile(path))
|
|
146
|
+
return [];
|
|
147
|
+
const text = readFileSync(path, "utf8");
|
|
148
|
+
const present = CODEX_BLOCKS.filter(([start, end]) => text.includes(`${start}\n`) && text.includes(end));
|
|
149
|
+
if (present.length === 0)
|
|
150
|
+
return [];
|
|
151
|
+
const isTracked = tracked(root, ".codex/config.toml");
|
|
152
|
+
return [
|
|
153
|
+
{
|
|
154
|
+
path,
|
|
155
|
+
tracked: isTracked,
|
|
156
|
+
found: `per-repository Codex block${present.length === 1 ? "" : "s"}`,
|
|
157
|
+
remove: () => {
|
|
158
|
+
const copy = isTracked ? undefined : backup(path, now);
|
|
159
|
+
let after = readFileSync(path, "utf8");
|
|
160
|
+
for (const [start, end] of CODEX_BLOCKS) {
|
|
161
|
+
const from = after.indexOf(`${start}\n`);
|
|
162
|
+
const to = after.indexOf(end, from);
|
|
163
|
+
if (from < 0 || to < 0)
|
|
164
|
+
continue;
|
|
165
|
+
const cut = to + end.length + (after[to + end.length] === "\n" ? 1 : 0);
|
|
166
|
+
after = after.slice(0, from) + after.slice(cut);
|
|
167
|
+
}
|
|
168
|
+
writeJsonAtomically(path, after.replace(/\n{3,}/g, "\n\n"));
|
|
169
|
+
return copy;
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
];
|
|
173
|
+
}
|
|
174
|
+
/** The older per-repository form in the checkout that holds `cwd`, if any. */
|
|
175
|
+
export function findCheckoutForm(input) {
|
|
176
|
+
const root = repositoryRoot(input.cwd);
|
|
177
|
+
if (root === undefined || !existsSync(root))
|
|
178
|
+
return [];
|
|
179
|
+
const now = input.now ?? (() => new Date());
|
|
180
|
+
return [
|
|
181
|
+
...claudeHooks(root, now),
|
|
182
|
+
...claudeServer(root, input.controlPlane, now),
|
|
183
|
+
...codexBlocks(root, now),
|
|
184
|
+
];
|
|
185
|
+
}
|
|
186
|
+
export function removeCheckoutForm(items) {
|
|
187
|
+
const removed = [];
|
|
188
|
+
const failed = [];
|
|
189
|
+
for (const item of items) {
|
|
190
|
+
try {
|
|
191
|
+
const copy = item.remove();
|
|
192
|
+
removed.push({
|
|
193
|
+
path: item.path,
|
|
194
|
+
found: item.found,
|
|
195
|
+
tracked: item.tracked,
|
|
196
|
+
...(copy === undefined ? {} : { copy }),
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
failed.push({
|
|
201
|
+
path: item.path,
|
|
202
|
+
found: item.found,
|
|
203
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return { removed, failed };
|
|
208
|
+
}
|
|
209
|
+
export function describeCheckoutFormRemoval(report) {
|
|
210
|
+
const lines = [];
|
|
211
|
+
for (const entry of report.removed)
|
|
212
|
+
lines.push(`Removed the older ${entry.found} from ${entry.path}` +
|
|
213
|
+
(entry.copy
|
|
214
|
+
? ` (copy at ${entry.copy}).`
|
|
215
|
+
: entry.tracked
|
|
216
|
+
? "; git tracks this file, so commit the change."
|
|
217
|
+
: "."));
|
|
218
|
+
for (const entry of report.failed)
|
|
219
|
+
lines.push(`Could not remove the older ${entry.found} from ${entry.path}: ${entry.reason}`);
|
|
220
|
+
if (report.removed.length > 0)
|
|
221
|
+
lines.push("The user-scope hook now speaks alone in this checkout; it was saying the same thing twice.");
|
|
222
|
+
return lines;
|
|
223
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -24,6 +24,7 @@ import { PUBLISHED_SPECIFIER } from "./release.js";
|
|
|
24
24
|
import { installUserScope, runningFromCheckout, userHome, } from "./user-scope.js";
|
|
25
25
|
import { updateNotice } from "./currency.js";
|
|
26
26
|
import { describeEarlier, describeRemoval, findEarlier, removeEarlier } from "./remove-earlier.js";
|
|
27
|
+
import { describeCheckoutFormRemoval, findCheckoutForm, removeCheckoutForm, } from "./checkout-form.js";
|
|
27
28
|
import { StoreError, normalizeControlPlane } from "./store.js";
|
|
28
29
|
import { CLI_INVOCATION, CLI_VERSION, DEFAULT_CONTROL_PLANE } from "./wire.js";
|
|
29
30
|
const USAGE = `balladeer ${CLI_VERSION}
|
|
@@ -471,6 +472,23 @@ function installEverything(options) {
|
|
|
471
472
|
for (const w of writes ?? [])
|
|
472
473
|
if (!options.json)
|
|
473
474
|
options.write(`${w.status === "refused" ? "Not changed" : w.status === "written" ? "Registered" : "Already current"}: ${w.path}${w.reason ? ` (${w.reason})` : ""}\n`);
|
|
475
|
+
// With the user-scope form in place, the older per-repository form in this
|
|
476
|
+
// checkout only makes the session hear the guidance twice. Out it goes, with
|
|
477
|
+
// a copy beside any file git does not already keep.
|
|
478
|
+
if ((writes ?? []).length > 0 && !(writes ?? []).some((w) => w.status === "refused")) {
|
|
479
|
+
const checkoutForm = findCheckoutForm({
|
|
480
|
+
cwd: process.cwd(),
|
|
481
|
+
controlPlane: options.controlPlane,
|
|
482
|
+
});
|
|
483
|
+
if (checkoutForm.length > 0) {
|
|
484
|
+
const report = removeCheckoutForm(checkoutForm);
|
|
485
|
+
if (options.json)
|
|
486
|
+
options.write(`${JSON.stringify({ step: "checkout_form", removed: report.removed, failed: report.failed })}\n`);
|
|
487
|
+
else
|
|
488
|
+
for (const line of describeCheckoutFormRemoval(report))
|
|
489
|
+
options.write(`${line}\n`);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
474
492
|
if (!options.json && (writes ?? []).some((w) => w.status === "written"))
|
|
475
493
|
options.write("Balladeer now runs in every Claude Code" +
|
|
476
494
|
((writes ?? []).some((w) => w.host === "codex") ? " and Codex" : "") +
|
|
@@ -522,11 +540,21 @@ async function dispatch(parsed, write) {
|
|
|
522
540
|
return report.failed.length === 0 ? 0 : 6;
|
|
523
541
|
}
|
|
524
542
|
case "install":
|
|
525
|
-
return installEverything({
|
|
543
|
+
return installEverything({
|
|
544
|
+
json: parsed.json,
|
|
545
|
+
write,
|
|
546
|
+
allowPartial: false,
|
|
547
|
+
controlPlane: parsed.controlPlane,
|
|
548
|
+
});
|
|
526
549
|
case "setup":
|
|
527
550
|
return runSetup({
|
|
528
551
|
installCommand: () => {
|
|
529
|
-
const result = installEverything({
|
|
552
|
+
const result = installEverything({
|
|
553
|
+
json: parsed.json,
|
|
554
|
+
write,
|
|
555
|
+
allowPartial: true,
|
|
556
|
+
controlPlane: parsed.controlPlane,
|
|
557
|
+
});
|
|
530
558
|
return process.platform === "win32" ? 0 : result;
|
|
531
559
|
},
|
|
532
560
|
controlPlane: parsed.controlPlane,
|
|
@@ -1,13 +1,5 @@
|
|
|
1
1
|
import { type GuidanceEvent } from "../guidance.js";
|
|
2
|
-
/**
|
|
3
|
-
* What an agent, or `status`, is told about a folder Balladeer is not tracking.
|
|
4
|
-
*
|
|
5
|
-
* Robert, 22 September 2026: the session hook says nothing in such a folder.
|
|
6
|
-
* A customer with one repository has every other folder on the laptop
|
|
7
|
-
* untracked, and a line in each of those is noise in the wrong place. This
|
|
8
|
-
* sentence stays for the two readers who ask: `balladeer status` in the folder,
|
|
9
|
-
* and an agent that opens the empty catalog and wonders why.
|
|
10
|
-
*/
|
|
2
|
+
/** A repository-specific setup step for explicit status and empty-catalog responses. */
|
|
11
3
|
export declare function untrackedLine(remote: string): string;
|
|
12
4
|
export type GuidanceOptions = Readonly<{
|
|
13
5
|
controlPlane: string;
|
|
@@ -5,15 +5,7 @@ import { selectAgent } from "../agent.js";
|
|
|
5
5
|
import { repositoryHints } from "../repository.js";
|
|
6
6
|
import { newerVersionPublished } from "../currency.js";
|
|
7
7
|
import { startSelfUpdateIfDue } from "../self-update.js";
|
|
8
|
-
/**
|
|
9
|
-
* What an agent, or `status`, is told about a folder Balladeer is not tracking.
|
|
10
|
-
*
|
|
11
|
-
* Robert, 22 September 2026: the session hook says nothing in such a folder.
|
|
12
|
-
* A customer with one repository has every other folder on the laptop
|
|
13
|
-
* untracked, and a line in each of those is noise in the wrong place. This
|
|
14
|
-
* sentence stays for the two readers who ask: `balladeer status` in the folder,
|
|
15
|
-
* and an agent that opens the empty catalog and wonders why.
|
|
16
|
-
*/
|
|
8
|
+
/** A repository-specific setup step for explicit status and empty-catalog responses. */
|
|
17
9
|
export function untrackedLine(remote) {
|
|
18
10
|
const here = remote === "unknown/unknown" ? "This folder" : `This folder (${remote})`;
|
|
19
11
|
return (`${here} isn't connected to Balladeer on this laptop, so nothing is tracked from here. ` +
|
|
@@ -70,8 +62,7 @@ function identifier(input) {
|
|
|
70
62
|
/** Hook stdout is bounded context only. Host prompts and transcript paths never leave this process. */
|
|
71
63
|
export async function runGuidance(options) {
|
|
72
64
|
// A project hook names its repository and must match the checkout it sits
|
|
73
|
-
// in. A user-scope hook names none: the folder's
|
|
74
|
-
// with no connection hears that it is untracked rather than nothing at all.
|
|
65
|
+
// in. A user-scope hook names none: the folder's remotes decide.
|
|
75
66
|
const projectScoped = options.repositoryId !== undefined;
|
|
76
67
|
if (projectScoped &&
|
|
77
68
|
!validateProjectGuidanceScope({
|
|
@@ -93,17 +84,18 @@ export async function runGuidance(options) {
|
|
|
93
84
|
event = parsedEvent;
|
|
94
85
|
const record = input;
|
|
95
86
|
const credentials = readCredentials(options.environment).agents;
|
|
87
|
+
const repositories = projectScoped ? [] : repositoryHints(options.cwd);
|
|
96
88
|
const agents = projectScoped
|
|
97
89
|
? credentials.filter((agent) => agent.controlPlane === options.controlPlane &&
|
|
98
90
|
agent.repositoryId === options.repositoryId)
|
|
99
91
|
: (() => {
|
|
100
|
-
const selection = selectAgent(credentials, options.controlPlane, undefined,
|
|
92
|
+
const selection = selectAgent(credentials, options.controlPlane, undefined, repositories);
|
|
101
93
|
return selection.kind === "refused" ? [] : [selection.agent];
|
|
102
94
|
})();
|
|
103
95
|
if (agents.length !== 1 || !agents[0]) {
|
|
104
96
|
if (projectScoped)
|
|
105
97
|
throw new Error("guidance_connection_unavailable");
|
|
106
|
-
//
|
|
98
|
+
// Unconnected folders stay silent. An explicit status request explains setup.
|
|
107
99
|
return 0;
|
|
108
100
|
}
|
|
109
101
|
const loaded = await loadGuidance({
|
package/dist/commands/setup.js
CHANGED
|
@@ -21,7 +21,7 @@ import { StoreError, assertStoreWritable, credentialsPath, dropPendingPairing, f
|
|
|
21
21
|
import { CLI_VERSION, DELEGATED_SCOPES, } from "../wire.js";
|
|
22
22
|
import { explainText } from "./explain.js";
|
|
23
23
|
import { probeAgentConnection } from "./mcp.js";
|
|
24
|
-
const REFUSAL_SENTENCE = "It may not agree to what a promise means, agree a change to what a promise means,
|
|
24
|
+
const REFUSAL_SENTENCE = "It may not agree to what a promise means, agree a change to what a promise means, transfer ownership, supersede, retire, or offboard anything.";
|
|
25
25
|
const MAX_WAIT_MS = 10 * 60 * 1000;
|
|
26
26
|
const AGENT_WAIT_MS = 90 * 1000;
|
|
27
27
|
/** The bound the control plane's own name check holds, refused here so a name
|
package/dist/guidance-hook.mjs
CHANGED
|
@@ -2027,7 +2027,7 @@ var require_toml = __commonJS({
|
|
|
2027
2027
|
});
|
|
2028
2028
|
|
|
2029
2029
|
// src/wire.ts
|
|
2030
|
-
var CLI_VERSION = "1.0.
|
|
2030
|
+
var CLI_VERSION = "1.0.14";
|
|
2031
2031
|
var CLIENT_HEADER = "x-balladeer-client";
|
|
2032
2032
|
var CLIENT_HEADER_VALUE = `balladeer/${CLI_VERSION}`;
|
|
2033
2033
|
var DEFAULT_CONTROL_PLANE = "https://envelopes.balladeer.ai";
|
|
@@ -3107,15 +3107,11 @@ async function runGuidance(options) {
|
|
|
3107
3107
|
event = parsedEvent;
|
|
3108
3108
|
const record = input;
|
|
3109
3109
|
const credentials = readCredentials(options.environment).agents;
|
|
3110
|
+
const repositories = projectScoped ? [] : repositoryHints(options.cwd);
|
|
3110
3111
|
const agents = projectScoped ? credentials.filter(
|
|
3111
3112
|
(agent) => agent.controlPlane === options.controlPlane && agent.repositoryId === options.repositoryId
|
|
3112
3113
|
) : (() => {
|
|
3113
|
-
const selection = selectAgent(
|
|
3114
|
-
credentials,
|
|
3115
|
-
options.controlPlane,
|
|
3116
|
-
void 0,
|
|
3117
|
-
repositoryHints(options.cwd)
|
|
3118
|
-
);
|
|
3114
|
+
const selection = selectAgent(credentials, options.controlPlane, void 0, repositories);
|
|
3119
3115
|
return selection.kind === "refused" ? [] : [selection.agent];
|
|
3120
3116
|
})();
|
|
3121
3117
|
if (agents.length !== 1 || !agents[0]) {
|
package/dist/remove-earlier.js
CHANGED
|
@@ -94,8 +94,15 @@ function claudeConnector(home, controlPlane, now) {
|
|
|
94
94
|
},
|
|
95
95
|
];
|
|
96
96
|
}
|
|
97
|
+
// This product's own hook runs `guidance --hook claude|codex --event ...`,
|
|
98
|
+
// whether by npx, by a checkout path, or by the installed command's absolute
|
|
99
|
+
// path (1.0.11 and later). The July client's hook ran nothing of the kind, so
|
|
100
|
+
// that shape is the line: a hook that names it is ours and is never "earlier".
|
|
101
|
+
// Found on 22 September 2026: the installed-path form was being removed as
|
|
102
|
+
// the older client on every setup run, leaving the laptop with no hook.
|
|
97
103
|
const isEarlierHook = (command) => typeof command === "string" &&
|
|
98
104
|
/balladeer/i.test(command) &&
|
|
105
|
+
!/\bguidance --hook (?:claude|codex)\b/.test(command) &&
|
|
99
106
|
!/balladeer@(?:latest|\d+\.\d+\.\d+)\b/.test(command) &&
|
|
100
107
|
!/\bpackages[/\\]cli[/\\]dist[/\\]cli\.js\b/.test(command);
|
|
101
108
|
function claudeSettings(home, now) {
|
package/dist/user-scope.d.ts
CHANGED
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
* shape, never by trust in the name.
|
|
16
16
|
*/
|
|
17
17
|
export declare const USER_SCOPE_OWNER = "Balladeer current guidance (user scope, loader 1)";
|
|
18
|
+
/** Every tool of the server named `balladeer`, in Claude Code's permission rule form. */
|
|
19
|
+
export declare const USER_SCOPE_ALLOW_RULE = "mcp__balladeer__*";
|
|
18
20
|
export type UserScopeHost = "claude" | "codex";
|
|
19
21
|
export type UserScopeWrite = Readonly<{
|
|
20
22
|
host: UserScopeHost;
|
package/dist/user-scope.js
CHANGED
|
@@ -22,6 +22,8 @@ import { writeJsonAtomically } from "./mcp-config.js";
|
|
|
22
22
|
*/
|
|
23
23
|
export const USER_SCOPE_OWNER = "Balladeer current guidance (user scope, loader 1)";
|
|
24
24
|
const CLAUDE_EVENTS = ["SessionStart", "UserPromptSubmit", "SubagentStart"];
|
|
25
|
+
/** Every tool of the server named `balladeer`, in Claude Code's permission rule form. */
|
|
26
|
+
export const USER_SCOPE_ALLOW_RULE = "mcp__balladeer__*";
|
|
25
27
|
/**
|
|
26
28
|
* The command a host runs to reach Balladeer, with no repository named: the
|
|
27
29
|
* directory decides.
|
|
@@ -192,6 +194,24 @@ export function mergeClaudeUserHooks(home, published, installed) {
|
|
|
192
194
|
hooks[event] = [...rows.filter((row) => !own.includes(row)), expected];
|
|
193
195
|
changed = true;
|
|
194
196
|
}
|
|
197
|
+
// Claude Code asks the person before every call to a server's tools unless
|
|
198
|
+
// a rule allows them. Without one, each read the guidance asks for costs a
|
|
199
|
+
// click, and a session that says no once hears no offer at all (measured
|
|
200
|
+
// 23 September 2026). The rule names this server's tools and nothing else;
|
|
201
|
+
// a deny rule the person wrote for them is theirs and is left to win.
|
|
202
|
+
const permissions = root.permissions && typeof root.permissions === "object" && !Array.isArray(root.permissions)
|
|
203
|
+
? { ...root.permissions }
|
|
204
|
+
: {};
|
|
205
|
+
const allow = permissions.allow === undefined ? [] : permissions.allow;
|
|
206
|
+
if (!Array.isArray(allow))
|
|
207
|
+
return { host, path, status: "refused", reason: "permissions.allow is not a list" };
|
|
208
|
+
const deny = Array.isArray(permissions.deny) ? permissions.deny : [];
|
|
209
|
+
const denied = deny.some((rule) => typeof rule === "string" && /^mcp__balladeer(__|$)/.test(rule));
|
|
210
|
+
if (!denied && !allow.some((rule) => rule === USER_SCOPE_ALLOW_RULE)) {
|
|
211
|
+
permissions.allow = [...allow, USER_SCOPE_ALLOW_RULE];
|
|
212
|
+
root.permissions = permissions;
|
|
213
|
+
changed = true;
|
|
214
|
+
}
|
|
195
215
|
if (!changed)
|
|
196
216
|
return { host, path, status: "current" };
|
|
197
217
|
root.hooks = hooks;
|
package/dist/wire.d.ts
CHANGED
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
* runtime dependency at all: Node 22 builtins and global fetch, nothing else.
|
|
6
6
|
* A contract test compares the scope list below against the server's.
|
|
7
7
|
*/
|
|
8
|
-
export declare const CLI_VERSION = "1.0.
|
|
8
|
+
export declare const CLI_VERSION = "1.0.14";
|
|
9
9
|
export declare const CLI_INVOCATION = "npx -y balladeer@latest";
|
|
10
10
|
export declare const CLIENT_HEADER = "x-balladeer-client";
|
|
11
|
-
export declare const CLIENT_HEADER_VALUE = "balladeer/1.0.
|
|
11
|
+
export declare const CLIENT_HEADER_VALUE = "balladeer/1.0.14";
|
|
12
12
|
export declare const DEFAULT_CONTROL_PLANE = "https://envelopes.balladeer.ai";
|
|
13
13
|
export type DelegatedScope = "repository:enroll" | "agent:issue" | "ci:connect" | "workspace:invite" | "candidate:propose";
|
|
14
14
|
export declare const DELEGATED_SCOPES: readonly DelegatedScope[];
|
package/dist/wire.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* runtime dependency at all: Node 22 builtins and global fetch, nothing else.
|
|
6
6
|
* A contract test compares the scope list below against the server's.
|
|
7
7
|
*/
|
|
8
|
-
export const CLI_VERSION = "1.0.
|
|
8
|
+
export const CLI_VERSION = "1.0.14";
|
|
9
9
|
export const CLI_INVOCATION = "npx -y balladeer@latest";
|
|
10
10
|
export const CLIENT_HEADER = "x-balladeer-client";
|
|
11
11
|
export const CLIENT_HEADER_VALUE = `balladeer/${CLI_VERSION}`;
|