balladeer 1.0.6 → 1.0.8

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/README.md CHANGED
@@ -41,10 +41,11 @@ To upgrade the executable and refresh in one command, run `npx -y balladeer@late
41
41
 
42
42
  Run `balladeer setup --refresh` to refresh repository instructions without replacing other tools'
43
43
  settings. For Codex, keep `--client codex`: `balladeer setup --client codex --refresh`. Existing
44
- instructions are not refreshed just because a package was installed. Refresh can contact npm and
45
- write the local toolchain when ensuring the executable is installed; it does not perform a Balladeer
46
- authority action. The repository MCP entry continues to use `npx -y balladeer@latest mcp` so native
47
- tools can pick up published updates.
44
+ policy updates arrive through the project guidance loader after its initial activation. Installing a
45
+ package alone does not prove the host loaded that guidance. Refresh can contact npm and write the
46
+ local toolchain when ensuring the executable is installed; it does not perform a Balladeer authority
47
+ action. The repository MCP entry continues to use `npx -y balladeer@latest mcp` so native tools can
48
+ pick up published updates.
48
49
 
49
50
  Automatic installation supports macOS/Linux with zsh, bash or sh. It uses the active npm global
50
51
  prefix or a writable personal prefix; when needed, it adds a bounded PATH block to your shell
@@ -79,6 +80,22 @@ is refused, authorized MCP reads, coding and explicitly requested capture may co
79
80
  session ID. Do not invent an ID or trailer. This does not fix missing credentials, installation or
80
81
  authorization failures, and does not guarantee every CLI command works in every sandbox.
81
82
 
83
+ ## Current project guidance
84
+
85
+ Version 1.0.7 introduces a stable instruction block and project-local guidance hooks. Setup writes
86
+ Codex hooks in the connected project's `.codex/config.toml`, or Claude Code hooks in its
87
+ `.claude/settings.json`. It never installs global hooks. The loader checks that the current
88
+ project's MCP entry matches the requested repository and deployment before reading credentials or
89
+ fetching guidance. An unrelated repository remains untouched.
90
+
91
+ The next MCP launch can migrate recognized older Balladeer blocks; customized or conflicting blocks
92
+ are left for an explicit repair. Review your coding host's normal hook trust prompt once. Project
93
+ trust alone does not prove the hook is trusted or running. After activation, guidance is fetched at
94
+ supported work boundaries, so policy revisions and rollbacks do not require another package
95
+ installation or repository edit. A fetch failure keeps ordinary work moving and suppresses
96
+ unsolicited capture until the current workspace mode is available. A delivery receipt proves context
97
+ was emitted, not that an agent followed it.
98
+
82
99
  ## If npm cannot start the bootstrap
83
100
 
84
101
  An EPERM or EACCES error naming npm's cache happens before Balladeer starts. Use an allowed writable
package/dist/cli.d.ts CHANGED
@@ -19,6 +19,7 @@ type Parsed = Readonly<{
19
19
  */
20
20
  claudeDesktop: boolean | undefined;
21
21
  repo: string | undefined;
22
+ repoScope: boolean;
22
23
  file: string | undefined;
23
24
  repository: string | undefined;
24
25
  /**
@@ -30,6 +31,8 @@ type Parsed = Readonly<{
30
31
  * rather than quietly resolved to the last one.
31
32
  */
32
33
  repositories: readonly string[];
34
+ hook: "codex" | "claude" | undefined;
35
+ event: "SessionStart" | "UserPromptSubmit" | "SubagentStart" | undefined;
33
36
  owner: string | undefined;
34
37
  /** `check-seals --install-hook`: write the optional pre-push hook. */
35
38
  installHook: boolean;
package/dist/cli.js CHANGED
@@ -7,6 +7,7 @@ import { runCheckSeals } from "./commands/check-seals.js";
7
7
  import { runDiscover } from "./commands/discover.js";
8
8
  import { runExplain } from "./commands/explain.js";
9
9
  import { parseRole, runInvite } from "./commands/invite.js";
10
+ import { runGuidance } from "./commands/guidance.js";
10
11
  import { runMcp } from "./commands/mcp.js";
11
12
  import { runPrepare } from "./commands/prepare.js";
12
13
  import { runPropose } from "./commands/propose.js";
@@ -17,15 +18,24 @@ import { runStatus } from "./commands/status.js";
17
18
  import { runTouchMap } from "./commands/touch-map.js";
18
19
  import { runWhoami } from "./commands/whoami.js";
19
20
  import { runInstall } from "./install.js";
21
+ import { installUserScope, runningFromCheckout } from "./user-scope.js";
22
+ import { setQuiet } from "./quiet.js";
20
23
  import { updateNotice } from "./currency.js";
21
24
  import { StoreError, normalizeControlPlane } from "./store.js";
22
25
  import { CLI_INVOCATION, CLI_VERSION, DEFAULT_CONTROL_PLANE } from "./wire.js";
23
26
  const USAGE = `balladeer ${CLI_VERSION}
24
27
 
25
28
  ${CLI_INVOCATION} install [--json]
26
- Install this exact release as the durable balladeer command, without pairing
27
- or changing any workspace, repository, or CI configuration. Use the latest
28
- npx bootstrap to upgrade an older global command.
29
+ Install this exact release as the durable balladeer command, and register
30
+ Balladeer once for every Claude Code and Codex session on this machine:
31
+ it decides per folder, from the git remote, which connection applies.
32
+ Pairs nothing and changes no workspace, repository, or CI configuration.
33
+
34
+ ${CLI_INVOCATION} quiet [--repo]
35
+ ${CLI_INVOCATION} track [--repo]
36
+ In a folder Balladeer does not track, each session hears that once. quiet
37
+ stops it for this folder (or --repo, for every checkout of this
38
+ repository); track undoes it.
29
39
 
30
40
  ${CLI_INVOCATION} setup [--repository owner/name]... [--json] [--wait] [--control-plane <url>]
31
41
  [--create-workspace <name>] [--choose-workspace] [--refresh] [--force]
@@ -170,6 +180,8 @@ export function parseArguments(argv) {
170
180
  throw new StoreError("usage", `${command} needs one of: ${SUBCOMMANDS[command].join(", ")}.`);
171
181
  }
172
182
  }
183
+ let hook;
184
+ let event;
173
185
  let json = false;
174
186
  let wait = false;
175
187
  let refresh = false;
@@ -185,6 +197,7 @@ export function parseArguments(argv) {
185
197
  let runner;
186
198
  let controlPlane;
187
199
  let repo;
200
+ let repoScope = false;
188
201
  let file;
189
202
  let owner;
190
203
  let createWorkspace;
@@ -216,7 +229,21 @@ export function parseArguments(argv) {
216
229
  }
217
230
  const [name, ...rest] = flag.split("=");
218
231
  const inline = rest.length > 0 ? rest.join("=") : undefined;
219
- if (name === "--json")
232
+ if (name === "--hook") {
233
+ const requested = value("--hook", inline);
234
+ if (requested !== "codex" && requested !== "claude")
235
+ throw new StoreError("usage", "--hook needs codex or claude.");
236
+ hook = requested;
237
+ }
238
+ else if (name === "--event") {
239
+ const requested = value("--event", inline);
240
+ if (requested !== "SessionStart" &&
241
+ requested !== "UserPromptSubmit" &&
242
+ requested !== "SubagentStart")
243
+ throw new StoreError("usage", "--event needs SessionStart, UserPromptSubmit or SubagentStart.");
244
+ event = requested;
245
+ }
246
+ else if (name === "--json")
220
247
  json = true;
221
248
  else if (name === "--again")
222
249
  again = true;
@@ -250,6 +277,8 @@ export function parseArguments(argv) {
250
277
  claudeDesktop = false;
251
278
  else if (name === "--control-plane")
252
279
  controlPlane = value("--control-plane", inline);
280
+ else if (name === "--repo" && (command === "quiet" || command === "track"))
281
+ repoScope = true;
253
282
  else if (name === "--repo")
254
283
  repo = value("--repo", inline);
255
284
  else if (name === "--file")
@@ -334,6 +363,10 @@ export function parseArguments(argv) {
334
363
  if (command !== "setup" && repositories.length > 1) {
335
364
  throw new StoreError("usage", `${command} acts on one repository. Name --repository once, or run it again for the other.`);
336
365
  }
366
+ if (hook !== undefined && command !== "guidance")
367
+ throw new StoreError("usage", "--hook belongs to guidance.");
368
+ if (event !== undefined && command !== "guidance")
369
+ throw new StoreError("usage", "--event belongs to guidance.");
337
370
  const chosen = controlPlane ?? process.env.BALLADEER_CONTROL_PLANE?.trim() ?? DEFAULT_CONTROL_PLANE;
338
371
  return {
339
372
  command,
@@ -347,9 +380,12 @@ export function parseArguments(argv) {
347
380
  existingOnly,
348
381
  claudeDesktop,
349
382
  repo,
383
+ repoScope,
350
384
  file,
351
385
  repository: repositories[0],
352
386
  repositories,
387
+ hook,
388
+ event,
353
389
  owner,
354
390
  installHook,
355
391
  fresh,
@@ -386,8 +422,46 @@ async function dispatch(parsed, write) {
386
422
  switch (parsed.command) {
387
423
  case "explain":
388
424
  return runExplain(write, parsed.controlPlane);
389
- case "install":
390
- return runInstall({ environment: process.env, json: parsed.json, write });
425
+ case "install": {
426
+ // Once per laptop: the same server and hook, registered where every
427
+ // session on this machine reads them, deciding per folder at run time.
428
+ // Registered first and independently: the entries run through npx and
429
+ // need no durable command, so a blocked PATH must not keep a laptop
430
+ // from getting them.
431
+ const writes = installUserScope({
432
+ environment: process.env,
433
+ published: !runningFromCheckout(),
434
+ });
435
+ for (const w of writes)
436
+ if (!parsed.json)
437
+ write(`${w.status === "refused" ? "Not changed" : w.status === "written" ? "Registered" : "Already current"}: ${w.path}${w.reason ? ` (${w.reason})` : ""}\n`);
438
+ if (!parsed.json && writes.some((w) => w.status === "written"))
439
+ write("Balladeer now runs in every Claude Code" +
440
+ (writes.some((w) => w.host === "codex") ? " and Codex" : "") +
441
+ " session on this machine. In a folder of a connected repository it works as before; anywhere else it says once that the folder isn't tracked. Approve the new hook when your coding host asks.\n");
442
+ const code = runInstall({
443
+ environment: process.env,
444
+ json: parsed.json,
445
+ write,
446
+ extra: { userScope: writes },
447
+ });
448
+ return writes.some((w) => w.status === "refused") ? 4 : code;
449
+ }
450
+ case "quiet":
451
+ case "track": {
452
+ const scope = parsed.repoScope ? "repo" : "folder";
453
+ try {
454
+ const { key, changed } = setQuiet(process.cwd(), scope, parsed.command === "quiet", process.env);
455
+ write(parsed.command === "quiet"
456
+ ? `${changed ? "Balladeer will stay quiet" : "Balladeer was already quiet"} in ${scope === "repo" ? `every checkout of ${key}` : key}. Run \`balladeer track${scope === "repo" ? " --repo" : ""}\` here to undo.\n`
457
+ : `${changed ? "Balladeer will speak again" : "Balladeer was not quiet"} in ${scope === "repo" ? `checkouts of ${key}` : key}.\n`);
458
+ return 0;
459
+ }
460
+ catch (error) {
461
+ write(`${error instanceof Error ? error.message : String(error)}\n`);
462
+ return 4;
463
+ }
464
+ }
391
465
  case "setup":
392
466
  return runSetup({
393
467
  installCommand: () => {
@@ -552,6 +626,21 @@ async function dispatch(parsed, write) {
552
626
  environment: process.env,
553
627
  write,
554
628
  });
629
+ case "guidance":
630
+ if (parsed.hook === undefined) {
631
+ write("guidance needs --hook codex or claude.\n");
632
+ return 4;
633
+ }
634
+ return runGuidance({
635
+ controlPlane: parsed.controlPlane,
636
+ hook: parsed.hook,
637
+ ...(parsed.event === undefined ? {} : { event: parsed.event }),
638
+ ...(parsed.repository === undefined ? {} : { repositoryId: parsed.repository }),
639
+ environment: process.env,
640
+ cwd: process.cwd(),
641
+ stdin: process.stdin,
642
+ write,
643
+ });
555
644
  case "mcp":
556
645
  return runMcp({
557
646
  controlPlane: parsed.controlPlane,
@@ -0,0 +1,19 @@
1
+ import { type GuidanceEvent } from "../guidance.js";
2
+ /** What a session hears, once, in a folder Balladeer is not tracking. */
3
+ export declare function untrackedLine(remote: string): string;
4
+ export type GuidanceOptions = Readonly<{
5
+ controlPlane: string;
6
+ repositoryId?: string;
7
+ hook: "codex" | "claude";
8
+ event?: GuidanceEvent;
9
+ runtimeGeneration?: string;
10
+ environment: NodeJS.ProcessEnv;
11
+ cwd: string;
12
+ stdin: NodeJS.ReadableStream;
13
+ write: (text: string) => void;
14
+ error?: (text: string) => void;
15
+ fetchImpl?: typeof fetch;
16
+ now?: () => number;
17
+ }>;
18
+ /** Hook stdout is bounded context only. Host prompts and transcript paths never leave this process. */
19
+ export declare function runGuidance(options: GuidanceOptions): Promise<number>;
@@ -0,0 +1,136 @@
1
+ import { validateProjectGuidanceScope } from "../guidance-install.js";
2
+ import { GUIDANCE_UNAVAILABLE, loadGuidance, recordGuidanceContext, } from "../guidance.js";
3
+ import { readCredentials } from "../store.js";
4
+ import { selectAgent } from "../agent.js";
5
+ import { repositoryHint } from "../repository.js";
6
+ import { isQuiet } from "../quiet.js";
7
+ import { PUBLISHED_SPECIFIER } from "../release.js";
8
+ /** What a session hears, once, in a folder Balladeer is not tracking. */
9
+ export function untrackedLine(remote) {
10
+ const here = remote === "unknown/unknown" ? "This folder" : `This folder (${remote})`;
11
+ return (`${here} isn't tracked by Balladeer, so promises can't be tracked from here. ` +
12
+ `To track it, run \`npx -y ${PUBLISHED_SPECIFIER} setup\` in this folder. ` +
13
+ `To stop this message here, run \`npx -y ${PUBLISHED_SPECIFIER} quiet\` (or \`quiet --repo\` for the whole repository).`);
14
+ }
15
+ function readInput(stream) {
16
+ return new Promise((resolve, reject) => {
17
+ let bytes = 0;
18
+ const chunks = [];
19
+ const finish = (error) => {
20
+ clearTimeout(timer);
21
+ stream.removeListener("data", data);
22
+ stream.removeListener("end", end);
23
+ stream.removeListener("error", failed);
24
+ stream.pause();
25
+ if (error)
26
+ reject(error);
27
+ else {
28
+ try {
29
+ resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
30
+ }
31
+ catch {
32
+ reject(new Error("invalid_hook_input"));
33
+ }
34
+ }
35
+ };
36
+ const data = (chunk) => {
37
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
38
+ bytes += buffer.length;
39
+ if (bytes > 65_536)
40
+ finish(new Error("hook_input_oversized"));
41
+ else
42
+ chunks.push(buffer);
43
+ };
44
+ const end = () => finish();
45
+ const failed = () => finish(new Error("hook_input_unavailable"));
46
+ const timer = setTimeout(() => finish(new Error("hook_input_deadline")), 250);
47
+ stream.on("data", data);
48
+ stream.once("end", end);
49
+ stream.once("error", failed);
50
+ });
51
+ }
52
+ function eventFrom(input) {
53
+ if (!input || typeof input !== "object" || Array.isArray(input))
54
+ return undefined;
55
+ const event = input.hook_event_name;
56
+ return event === "SessionStart" || event === "SubagentStart" || event === "UserPromptSubmit"
57
+ ? event
58
+ : undefined;
59
+ }
60
+ function identifier(input) {
61
+ return typeof input === "string" && input.length > 0 && input.length <= 256 ? input : undefined;
62
+ }
63
+ /** Hook stdout is bounded context only. Host prompts and transcript paths never leave this process. */
64
+ export async function runGuidance(options) {
65
+ // A project hook names its repository and must match the checkout it sits
66
+ // in. A user-scope hook names none: the folder's remote decides, and a folder
67
+ // with no connection hears that it is untracked rather than nothing at all.
68
+ const projectScoped = options.repositoryId !== undefined;
69
+ if (projectScoped &&
70
+ !validateProjectGuidanceScope({
71
+ cwd: options.cwd,
72
+ hook: options.hook,
73
+ repositoryId: options.repositoryId,
74
+ controlPlane: options.controlPlane,
75
+ }))
76
+ return 0;
77
+ let event = options.event ?? "SessionStart";
78
+ const emit = (context) => options.write(`${JSON.stringify({
79
+ hookSpecificOutput: { hookEventName: event, additionalContext: context },
80
+ })}\n`);
81
+ try {
82
+ const input = await readInput(options.stdin);
83
+ const parsedEvent = eventFrom(input);
84
+ if (!parsedEvent || (options.event && parsedEvent !== options.event))
85
+ throw new Error("invalid_hook_event");
86
+ event = parsedEvent;
87
+ const record = input;
88
+ const credentials = readCredentials(options.environment).agents;
89
+ const agents = projectScoped
90
+ ? credentials.filter((agent) => agent.controlPlane === options.controlPlane &&
91
+ agent.repositoryId === options.repositoryId)
92
+ : (() => {
93
+ const selection = selectAgent(credentials, options.controlPlane, undefined, repositoryHint(options.cwd));
94
+ return selection.kind === "refused" ? [] : [selection.agent];
95
+ })();
96
+ if (agents.length !== 1 || !agents[0]) {
97
+ if (projectScoped)
98
+ throw new Error("guidance_connection_unavailable");
99
+ // Untracked. Said once, at session start, and never when silenced.
100
+ if (event === "SessionStart" && !isQuiet(options.cwd, options.environment))
101
+ emit(untrackedLine(repositoryHint(options.cwd)));
102
+ return 0;
103
+ }
104
+ const loaded = await loadGuidance({
105
+ agent: agents[0],
106
+ environment: options.environment,
107
+ timeoutMs: 750,
108
+ ...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}),
109
+ });
110
+ const sessionId = identifier(record.session_id);
111
+ const agentId = identifier(record.agent_id);
112
+ recordGuidanceContext({
113
+ environment: options.environment,
114
+ scopeKey: loaded.scopeKey,
115
+ hook: options.hook,
116
+ event,
117
+ ...(options.runtimeGeneration ? { runtimeGeneration: options.runtimeGeneration } : {}),
118
+ load: loaded,
119
+ ...(sessionId ? { sessionId } : {}),
120
+ ...(agentId ? { agentId } : {}),
121
+ ...(options.now ? { now: options.now() } : {}),
122
+ }, () => emit(loaded.document
123
+ ? `Current Balladeer guidance (${loaded.document.revision}). This supersedes earlier Balladeer workflow and capture guidance in this context.\n\n${loaded.document.instructions}`
124
+ : GUIDANCE_UNAVAILABLE));
125
+ }
126
+ catch {
127
+ // A fixed fallback contains neither raw input nor credential/error details. Work continues.
128
+ try {
129
+ emit(GUIDANCE_UNAVAILABLE);
130
+ }
131
+ catch {
132
+ /* Closed stdout cannot block the host. */
133
+ }
134
+ }
135
+ return 0;
136
+ }
@@ -1,7 +1,10 @@
1
1
  import { createInterface } from "node:readline";
2
+ import { installGuidanceLoader } from "../guidance-install.js";
2
3
  import { callAgentTool, forwarderHeaders, noAgentCredentialSentence, safeJson, selectAgent, structuredString, updateLine, } from "../agent.js";
3
4
  import { noteServerVersion, updateNotice } from "../currency.js";
4
5
  import { repositoryHint } from "../repository.js";
6
+ import { CLI_VERSION } from "../wire.js";
7
+ import { untrackedLine } from "./guidance.js";
5
8
  import { StoreError, readCredentials } from "../store.js";
6
9
  // The three commands that use an agent connection select and address it through
7
10
  // one module. These are re-exported because this is where the forwarder's
@@ -126,6 +129,39 @@ function frameId(line) {
126
129
  }
127
130
  return null;
128
131
  }
132
+ /** The smallest server a host accepts: initialize, an empty tools list, ping. */
133
+ async function serveUntracked(options, instructions) {
134
+ const lines = createInterface({ input: options.stdin, crlfDelay: Infinity });
135
+ for await (const line of lines) {
136
+ if (line.trim().length === 0)
137
+ continue;
138
+ let request;
139
+ try {
140
+ request = JSON.parse(line);
141
+ }
142
+ catch {
143
+ continue;
144
+ }
145
+ if (request.id === undefined)
146
+ continue; // a notification
147
+ const result = request.method === "initialize"
148
+ ? {
149
+ protocolVersion: request.params?.protocolVersion ?? "2025-06-18",
150
+ capabilities: { tools: {} },
151
+ serverInfo: { name: "balladeer", version: CLI_VERSION },
152
+ instructions,
153
+ }
154
+ : request.method === "tools/list"
155
+ ? { tools: [] }
156
+ : request.method === "ping"
157
+ ? {}
158
+ : undefined;
159
+ options.write(JSON.stringify(result === undefined
160
+ ? { jsonrpc: "2.0", id: request.id, error: { code: -32601, message: "Method not found" } }
161
+ : { jsonrpc: "2.0", id: request.id, result }) + "\n");
162
+ }
163
+ return 0;
164
+ }
129
165
  export async function runMcp(options) {
130
166
  let credentials;
131
167
  try {
@@ -143,9 +179,24 @@ export async function runMcp(options) {
143
179
  options.error(selection.missingFor === undefined
144
180
  ? `${selection.reason} Run setup in this repository, or issue the connection at ${options.controlPlane}/setup.\n`
145
181
  : `${noAgentCredentialSentence(selection.missingFor)}\n`);
146
- return 4;
182
+ // A project entry named its repository and got it wrong: that is an error
183
+ // the host should show. A user-scope entry named none and simply landed in
184
+ // a folder nobody tracks: that is ordinary, and a server that dies here
185
+ // shows up red in every untracked folder on the laptop. Answer the host
186
+ // with an empty catalog and the one sentence the hook also says.
187
+ if (options.repositoryId !== undefined)
188
+ return 4;
189
+ return serveUntracked(options, untrackedLine(repositoryHint(options.cwd)));
147
190
  }
148
191
  const { agent } = selection;
192
+ const guidanceInstall = installGuidanceLoader({
193
+ environment: options.environment,
194
+ cwd: options.cwd,
195
+ repositoryId: agent.repositoryId,
196
+ controlPlane: agent.controlPlane,
197
+ });
198
+ if (guidanceInstall.status !== "not_applicable")
199
+ options.error(`Balladeer guidance loader: ${guidanceInstall.status}${guidanceInstall.reason ? ` (${guidanceInstall.reason})` : ""}. New project hooks may need host trust; no trust is assumed.\n`);
149
200
  let saidUpdate = false;
150
201
  const lines = createInterface({ input: options.stdin, crlfDelay: Infinity });
151
202
  for await (const line of lines) {
@@ -123,8 +123,13 @@ export function readTeachBackFile(raw) {
123
123
  reason: 'teachBack.wrongOutcome is missing. Say what going wrong looks like, in the words the person used and as a must-not: "a second payment must not go out". If no sentence of that shape exists, nothing could ever fail this promise, so do not propose it',
124
124
  };
125
125
  }
126
- if (teachBack.leastSure !== undefined && typeof teachBack.leastSure !== "string") {
127
- return { ok: false, reason: "teachBack.leastSure must be one sentence of text" };
126
+ if (teachBack.leastSure !== undefined ||
127
+ (teachBack.unresolvedQuestions !== undefined &&
128
+ (!Array.isArray(teachBack.unresolvedQuestions) || teachBack.unresolvedQuestions.length > 0))) {
129
+ return {
130
+ ok: false,
131
+ reason: "Outstanding questions are retired. Clarify material ambiguity with the person before writing, and put their actual answer in the meaning and cases. Nothing was sent.",
132
+ };
128
133
  }
129
134
  return {
130
135
  ok: true,
@@ -11,6 +11,7 @@ import { CI_BRANCH, commitWorkflowOnBranch, repositoryRoot, workflowOnDefaultBra
11
11
  import { MCP_CONFIG_FILE, currentEntry, entryRepositoryId, isOurEntry, mergeMcpConfig, readMcpConfig, stdioEntry, } from "../mcp-config.js";
12
12
  import { DESKTOP_CONFIG_FILE, desktopConfigLocation, desktopServerKey, desktopStdioEntry, mergeDesktopConfig, } from "../desktop-config.js";
13
13
  import { selectAgent } from "../agent.js";
14
+ import { installGuidanceLoader } from "../guidance-install.js";
14
15
  import { findLegacyInstall, legacyMessage } from "../legacy.js";
15
16
  import { formatInstant } from "../local-time.js";
16
17
  import { checkoutEntryPath, commandLine, runningFromRegistryInstall } from "../release.js";
@@ -932,13 +933,35 @@ function hostConfigFile(options) {
932
933
  return options.client === "codex" ? CODEX_CONFIG_FILE : MCP_CONFIG_FILE;
933
934
  }
934
935
  function mergeHostConfig(options, root, repositoryId, published) {
935
- const entry = stdioEntry(repositoryId, published);
936
+ const base = stdioEntry(repositoryId, published);
937
+ const entry = { ...base, args: [...base.args, "--control-plane", options.controlPlane] };
936
938
  return options.client === "codex"
937
- ? mergeCodexConfig(root, { ...entry, args: [...entry.args, "--control-plane", options.controlPlane] }, options.controlPlane)
939
+ ? mergeCodexConfig(root, entry, options.controlPlane)
938
940
  : mergeMcpConfig(root, entry, options.controlPlane);
939
941
  }
940
942
  function writeHostConventions(options, root) {
941
- return writeConventions(root, options.client === "codex" ? { file: "AGENTS.md" } : {});
943
+ const result = writeConventions(root, options.client === "codex" ? { file: "AGENTS.md" } : {});
944
+ if (result.kind === "written") {
945
+ const entry = options.client === "codex" ? readCodexEntry(root) : currentEntry(readMcpConfig(root));
946
+ const repositoryId = entryRepositoryId(entry);
947
+ if (repositoryId !== undefined) {
948
+ const loader = installGuidanceLoader({
949
+ environment: options.environment,
950
+ cwd: root,
951
+ repositoryId,
952
+ controlPlane: options.controlPlane,
953
+ host: options.client === "codex" ? "codex" : "claude",
954
+ });
955
+ emit(options, { step: "guidance_loader", ...loader });
956
+ say(options, loader.status === "installed_needs_host_trust" ||
957
+ loader.status === "installed_trust_unverified"
958
+ ? "Project guidance loader installed. Review new hooks in your coding host once; future policy updates do not change that hook definition. A fresh session is needed to observe it."
959
+ : loader.status === "unavailable"
960
+ ? `Project guidance loader needs repair (${loader.reason}). No active hook is claimed.`
961
+ : "No matching project guidance integration was found. No guidance hook was installed or activated.");
962
+ }
963
+ }
964
+ return result;
942
965
  }
943
966
  function hostNextSteps(options) {
944
967
  if (options.client === "codex")
@@ -27,7 +27,7 @@ export declare const CONVENTIONS_END = "<!-- balladeer:conventions:end -->";
27
27
  * instructions marker instead. A bump here with no change to the block would
28
28
  * rewrite twelve repositories to say exactly what they already said.
29
29
  */
30
- export declare const CONVENTIONS_VERSION = 18;
30
+ export declare const CONVENTIONS_VERSION = 20;
31
31
  export declare function managedByLine(version: number): string;
32
32
  /**
33
33
  * Which version of the block a file already carries, or nothing when it carries
@@ -31,7 +31,7 @@ export const CONVENTIONS_END = "<!-- balladeer:conventions:end -->";
31
31
  * instructions marker instead. A bump here with no change to the block would
32
32
  * rewrite twelve repositories to say exactly what they already said.
33
33
  */
34
- export const CONVENTIONS_VERSION = 18;
34
+ export const CONVENTIONS_VERSION = 20;
35
35
  /** Both spellings: the stable marker, and the versioned one version 1 wrote. */
36
36
  const START_MARKER = /<!-- balladeer:conventions:start(?: v(\d{1,4}))? -->/g;
37
37
  const END_MARKER = /<!-- balladeer:conventions:end -->/g;