balladeer 1.0.18 → 1.0.19

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.
@@ -1,3 +1,14 @@
1
+ /**
2
+ * Whether an install may take the older form out of the checkout it runs in:
3
+ * only when a person ran setup or install and the user-scope form is fully in
4
+ * place. The background update a session hook starts leaves the repository's
5
+ * files alone, because an edit to a file the team commits is a diff nobody
6
+ * asked for, and the user-scope hook already switches the older hook off from
7
+ * the laptop side (`retireCheckoutRuntime`), so nothing is lost.
8
+ */
9
+ export declare function checkoutFormRemovalAllowed(environment: NodeJS.ProcessEnv, writes: readonly {
10
+ status: string;
11
+ }[]): boolean;
1
12
  /**
2
13
  * Taking this product's own older, per-repository form out of a checkout once
3
14
  * the user-scope form is in place, so one hook speaks per session.
@@ -2,6 +2,20 @@ import { execFileSync } from "node:child_process";
2
2
  import { copyFileSync, existsSync, lstatSync, readFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { isOurEntry, writeJsonAtomically } from "./mcp-config.js";
5
+ import { SELF_UPDATE_ENVIRONMENT } from "./self-update.js";
6
+ /**
7
+ * Whether an install may take the older form out of the checkout it runs in:
8
+ * only when a person ran setup or install and the user-scope form is fully in
9
+ * place. The background update a session hook starts leaves the repository's
10
+ * files alone, because an edit to a file the team commits is a diff nobody
11
+ * asked for, and the user-scope hook already switches the older hook off from
12
+ * the laptop side (`retireCheckoutRuntime`), so nothing is lost.
13
+ */
14
+ export function checkoutFormRemovalAllowed(environment, writes) {
15
+ return (environment[SELF_UPDATE_ENVIRONMENT] !== "1" &&
16
+ writes.length > 0 &&
17
+ !writes.some((w) => w.status === "refused"));
18
+ }
5
19
  const PROJECT_OWNER = "Balladeer current guidance (loader 1)";
6
20
  const CODEX_BLOCKS = [
7
21
  ["# balladeer:guidance:start", "# balladeer:guidance:end"],
package/dist/cli.js CHANGED
@@ -31,7 +31,7 @@ import { updateNotice } from "./currency.js";
31
31
  import { describeEarlier, describeRemoval, findEarlier, removeEarlier } from "./remove-earlier.js";
32
32
  import { recordSelfUpdateState, SELF_UPDATE_ENVIRONMENT } from "./self-update.js";
33
33
  import { hooksAwaitingTrust } from "./hook-trust.js";
34
- import { describeCheckoutFormRemoval, findCheckoutForm, removeCheckoutForm, } from "./checkout-form.js";
34
+ import { checkoutFormRemovalAllowed, describeCheckoutFormRemoval, findCheckoutForm, removeCheckoutForm, } from "./checkout-form.js";
35
35
  import { StoreError, normalizeControlPlane } from "./store.js";
36
36
  import { CLI_INVOCATION, CLI_VERSION, DEFAULT_CONTROL_PLANE } from "./wire.js";
37
37
  const USAGE = `balladeer ${CLI_VERSION}
@@ -545,8 +545,9 @@ function installEverything(options) {
545
545
  }
546
546
  // With the user-scope form in place, the older per-repository form in this
547
547
  // checkout only makes the session hear the guidance twice. Out it goes, with
548
- // a copy beside any file git does not already keep.
549
- if ((writes ?? []).length > 0 && !(writes ?? []).some((w) => w.status === "refused")) {
548
+ // a copy beside any file git does not already keep, and never from the
549
+ // background update (see `checkoutFormRemovalAllowed`).
550
+ if (checkoutFormRemovalAllowed(process.env, writes ?? [])) {
550
551
  const checkoutForm = findCheckoutForm({
551
552
  cwd: process.cwd(),
552
553
  controlPlane: options.controlPlane,
@@ -7,6 +7,7 @@ import { repositoryHints } from "../repository.js";
7
7
  import { newerVersionPublished } from "../currency.js";
8
8
  import { startSelfUpdateIfDue } from "../self-update.js";
9
9
  import { noteHookFired } from "../hook-trust.js";
10
+ import { retireCheckoutRuntime } from "../guidance-runtime.js";
10
11
  /** A repository-specific setup step for explicit status and empty-catalog responses. */
11
12
  export function untrackedLine(remote) {
12
13
  const here = remote === "unknown/unknown" ? "This folder" : `This folder (${remote})`;
@@ -158,6 +159,18 @@ export async function runGuidance(options) {
158
159
  // trust are the ones this matters to.
159
160
  if (options.hook === "codex")
160
161
  noteHookFired(options.environment, "codex");
162
+ // This hook covers this checkout now, so the older per-repository hook
163
+ // here (releases before 1.0.8) only makes the session hear the guidance
164
+ // twice. Switch it off from the laptop side, once, before the fetch:
165
+ // nothing printed, no repository file touched, nobody asked to run a thing.
166
+ if (!projectScoped && event === "SessionStart")
167
+ retireCheckoutRuntime({
168
+ cwd: options.cwd,
169
+ host: options.hook,
170
+ repositoryId: agents[0].repositoryId,
171
+ controlPlane: agents[0].controlPlane,
172
+ environment: options.environment,
173
+ });
161
174
  const loaded = await loadGuidance({
162
175
  agent: agents[0],
163
176
  environment: options.environment,
@@ -165,14 +178,16 @@ export async function runGuidance(options) {
165
178
  hook: options.hook,
166
179
  ...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}),
167
180
  });
168
- // Behind? Start the install in the background, once a day at most, and
169
- // carry on serving this session from the copy that is here.
170
- if (event === "SessionStart")
171
- startSelfUpdateIfDue(newerVersionPublished(), {
172
- environment: options.environment,
173
- ...(options.startUpdate ? { run: options.startUpdate } : {}),
174
- ...(options.now ? { now: options.now } : {}),
175
- });
181
+ // Behind? Start the install in the background and carry on serving this
182
+ // session from the copy that is here. Any hook that loaded guidance may
183
+ // start it, not only a session start: sessions run for days, and a laptop
184
+ // whose sessions never restart would otherwise never update (Robert's own,
185
+ // 1 October 2026). The stamp keeps it to once a day per release.
186
+ startSelfUpdateIfDue(newerVersionPublished(), {
187
+ environment: options.environment,
188
+ ...(options.startUpdate ? { run: options.startUpdate } : {}),
189
+ ...(options.now ? { now: options.now } : {}),
190
+ });
176
191
  const sessionId = identifier(record.session_id);
177
192
  const agentId = identifier(record.agent_id);
178
193
  recordGuidanceContext({
@@ -1,5 +1,4 @@
1
1
  import { createInterface } from "node:readline";
2
- import { installGuidanceLoader } from "../guidance-install.js";
3
2
  import { callAgentTool, forwarderHeaders, noAgentCredentialSentence, safeJson, selectAgent, structuredString, updateLine, } from "../agent.js";
4
3
  import { noteServerVersion, updateNotice } from "../currency.js";
5
4
  import { repositoryHint, repositoryHints } from "../repository.js";
@@ -189,15 +188,12 @@ export async function runMcp(options) {
189
188
  return 4;
190
189
  return serveUntracked(options, untrackedLine(repositoryHint(options.cwd)));
191
190
  }
191
+ // This forwarder used to install the per-repository guidance hook into any
192
+ // checkout whose project config named this repository. From 1.0.8 the hook
193
+ // lives at user scope, so doing that put a second hook beside it and every
194
+ // session heard the guidance twice; it also re-armed the older hook the
195
+ // user-scope hook had just switched off. It installs nothing now.
192
196
  const { agent } = selection;
193
- const guidanceInstall = installGuidanceLoader({
194
- environment: options.environment,
195
- cwd: options.cwd,
196
- repositoryId: agent.repositoryId,
197
- controlPlane: agent.controlPlane,
198
- });
199
- if (guidanceInstall.status !== "not_applicable")
200
- options.error(`Balladeer guidance loader: ${guidanceInstall.status}${guidanceInstall.reason ? ` (${guidanceInstall.reason})` : ""}. New project hooks may need host trust; no trust is assumed.\n`);
201
197
  let saidUpdate = false;
202
198
  // Which host this session belongs to is known from its hello; whether its
203
199
  // hooks are waiting for the person's trust is asked at the first tool call,
@@ -11,7 +11,6 @@ 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";
15
14
  import { describeEarlier, describeRemoval, findEarlier, removeEarlier } from "../remove-earlier.js";
16
15
  import { userHome } from "../user-scope.js";
17
16
  import { formatInstant } from "../local-time.js";
@@ -1008,28 +1007,11 @@ function mergeHostConfig(options, root, repositoryId, published) {
1008
1007
  : mergeMcpConfig(root, entry, options.controlPlane);
1009
1008
  }
1010
1009
  function writeHostConventions(options, root) {
1011
- const result = writeConventions(root, options.client === "codex" ? { file: "AGENTS.md" } : {});
1012
- if (result.kind === "written") {
1013
- const entry = options.client === "codex" ? readCodexEntry(root) : currentEntry(readMcpConfig(root));
1014
- const repositoryId = entryRepositoryId(entry);
1015
- if (repositoryId !== undefined) {
1016
- const loader = installGuidanceLoader({
1017
- environment: options.environment,
1018
- cwd: root,
1019
- repositoryId,
1020
- controlPlane: options.controlPlane,
1021
- host: options.client === "codex" ? "codex" : "claude",
1022
- });
1023
- emit(options, { step: "guidance_loader", ...loader });
1024
- say(options, loader.status === "installed_needs_host_trust" ||
1025
- loader.status === "installed_trust_unverified"
1026
- ? "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."
1027
- : loader.status === "unavailable"
1028
- ? `Project guidance loader needs repair (${loader.reason}). No active hook is claimed.`
1029
- : "No matching project guidance integration was found. No guidance hook was installed or activated.");
1030
- }
1031
- }
1032
- return result;
1010
+ // No per-repository guidance hook any more. The install step registers the
1011
+ // hook at user scope, where it runs in every checkout; adding the older
1012
+ // per-repository one here as well put a second hook in this checkout, and
1013
+ // every session heard the guidance twice (Didero, September 2026).
1014
+ return writeConventions(root, options.client === "codex" ? { file: "AGENTS.md" } : {});
1033
1015
  }
1034
1016
  function hostNextSteps(options) {
1035
1017
  if (options.client === "codex")
@@ -15,6 +15,8 @@
15
15
  export declare const CLI_NEWEST_HEADER = "x-balladeer-cli-newest";
16
16
  export declare const CLI_FLOOR_HEADER = "x-balladeer-cli-minimum";
17
17
  export declare const CLI_ADVICE_HEADER = "x-balladeer-cli-advice";
18
+ /** Whether `left` is an older release than `right` (both `x.y.z`). */
19
+ export declare function older(left: string, right: string): boolean;
18
20
  /**
19
21
  * Reads the version signals off one response.
20
22
  *
package/dist/currency.js CHANGED
@@ -25,7 +25,8 @@ const ADVICE_LIMIT = 300;
25
25
  const VERSION = /^\d{1,4}\.\d{1,4}\.\d{1,4}$/;
26
26
  let notice;
27
27
  let newestSeen;
28
- function older(left, right) {
28
+ /** Whether `left` is an older release than `right` (both `x.y.z`). */
29
+ export function older(left, right) {
29
30
  const [a, b] = [left.split(".").map(Number), right.split(".").map(Number)];
30
31
  for (let index = 0; index < 3; index += 1) {
31
32
  const difference = a[index] - b[index];
@@ -2152,13 +2152,31 @@ function relayHook(input, directory) {
2152
2152
  }
2153
2153
 
2154
2154
  // src/wire.ts
2155
- var CLI_VERSION = "1.0.18";
2155
+ var CLI_VERSION = "1.0.19";
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";
2159
2159
  var CLIENT_HEADER_VALUE = `balladeer/${CLI_VERSION}`;
2160
2160
  var DEFAULT_CONTROL_PLANE = "https://envelopes.balladeer.ai";
2161
2161
 
2162
+ // src/guidance-runtime.ts
2163
+ import { createHash as createHash3, randomBytes as randomBytes2 } from "node:crypto";
2164
+ import {
2165
+ constants as constants2,
2166
+ closeSync as closeSync3,
2167
+ fstatSync as fstatSync2,
2168
+ lstatSync as lstatSync3,
2169
+ mkdirSync as mkdirSync5,
2170
+ openSync as openSync3,
2171
+ readFileSync as readFileSync5,
2172
+ realpathSync,
2173
+ renameSync as renameSync4,
2174
+ unlinkSync as unlinkSync3,
2175
+ writeFileSync as writeFileSync4
2176
+ } from "node:fs";
2177
+ import { join as join5 } from "node:path";
2178
+ import { execFileSync } from "node:child_process";
2179
+
2162
2180
  // src/store.ts
2163
2181
  import {
2164
2182
  closeSync,
@@ -2289,6 +2307,8 @@ var HOOK_SPECIFIER = "balladeer@1";
2289
2307
  var STAMP = "self-update.json";
2290
2308
  var STATE = "self-update-state.json";
2291
2309
  var DAY_MS = 24 * 60 * 60 * 1e3;
2310
+ var HOUR_MS = 60 * 60 * 1e3;
2311
+ var VERSION2 = /^\d{1,4}\.\d{1,4}\.\d{1,4}$/;
2292
2312
  var SELF_UPDATE_ENVIRONMENT = "BALLADEER_SELF_UPDATE";
2293
2313
  var STATE_PATTERN = /^(?:started|unstartable|installed|failed):[A-Za-z0-9._-]{1,40}$/;
2294
2314
  function stampPath(environment) {
@@ -2297,16 +2317,23 @@ function stampPath(environment) {
2297
2317
  function statePath(environment) {
2298
2318
  return join3(configHome(environment), STATE);
2299
2319
  }
2300
- function lastStarted(environment) {
2320
+ function lastAttempt(environment) {
2301
2321
  const path = stampPath(environment);
2302
- if (!existsSync(path)) return 0;
2322
+ if (!existsSync(path)) return { startedAt: 0 };
2303
2323
  try {
2304
2324
  const parsed = JSON.parse(readFileSync3(path, "utf8"));
2305
- return typeof parsed.startedAt === "number" ? parsed.startedAt : 0;
2325
+ const startedAt = typeof parsed.startedAt === "number" ? parsed.startedAt : 0;
2326
+ return typeof parsed.toward === "string" && VERSION2.test(parsed.toward) ? { startedAt, toward: parsed.toward } : { startedAt };
2306
2327
  } catch {
2307
- return 0;
2328
+ return { startedAt: 0 };
2308
2329
  }
2309
2330
  }
2331
+ function due(newer, last, now) {
2332
+ const since = now - last.startedAt;
2333
+ if (since >= DAY_MS) return true;
2334
+ const newerTarget = last.toward === void 0 || older(last.toward, newer);
2335
+ return newerTarget && since >= HOUR_MS;
2336
+ }
2310
2337
  function writePrivate(path, value, environment) {
2311
2338
  mkdirSync3(configHome(environment), { recursive: true, mode: 448 });
2312
2339
  writeFileSync2(path, JSON.stringify(value) + "\n", { mode: 384 });
@@ -2349,7 +2376,7 @@ function detached(command, args, environment) {
2349
2376
  function startSelfUpdateIfDue(newer, deps) {
2350
2377
  if (newer === void 0) return false;
2351
2378
  const now = deps.now ?? Date.now;
2352
- if (now() - lastStarted(deps.environment) < DAY_MS) return false;
2379
+ if (!due(newer, lastAttempt(deps.environment), now())) return false;
2353
2380
  const launch = resolveLaunch(HOOK_SPECIFIER, deps.environment, deps.execPath, deps.exists);
2354
2381
  let started;
2355
2382
  try {
@@ -2628,52 +2655,124 @@ function recordGuidanceContext(input, write2) {
2628
2655
  return true;
2629
2656
  }
2630
2657
 
2658
+ // src/guidance-runtime.ts
2659
+ var OWNER = "balladeer-guidance-runtime/1";
2660
+ var sha2 = (value) => createHash3("sha256").update(value).digest("hex");
2661
+ function guidanceRuntimeKey(scope) {
2662
+ return sha2(
2663
+ JSON.stringify([
2664
+ realpathSync(scope.root),
2665
+ new URL(scope.controlPlane).origin,
2666
+ scope.repositoryId,
2667
+ scope.host
2668
+ ])
2669
+ );
2670
+ }
2671
+ function checkDirectory(path) {
2672
+ const stat = lstatSync3(path);
2673
+ if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 63) !== 0)
2674
+ throw new Error("guidance_runtime_directory_unsafe");
2675
+ }
2676
+ function readPrivate(path, max) {
2677
+ const fd = openSync3(path, constants2.O_RDONLY | constants2.O_NOFOLLOW);
2678
+ try {
2679
+ const stat = fstatSync2(fd);
2680
+ if (!stat.isFile() || stat.size > max || (stat.mode & 63) !== 0)
2681
+ throw new Error("guidance_runtime_file_unsafe");
2682
+ return readFileSync5(fd);
2683
+ } finally {
2684
+ closeSync3(fd);
2685
+ }
2686
+ }
2687
+ function retireCheckoutRuntime(input) {
2688
+ try {
2689
+ const base = join5(configHome(input.environment), "guidance-runtime-v1");
2690
+ try {
2691
+ lstatSync3(base);
2692
+ } catch {
2693
+ return "absent";
2694
+ }
2695
+ const root = execFileSync("git", ["rev-parse", "--show-toplevel"], {
2696
+ cwd: input.cwd,
2697
+ encoding: "utf8",
2698
+ stdio: ["ignore", "pipe", "ignore"],
2699
+ timeout: 500,
2700
+ maxBuffer: 8192
2701
+ }).trim();
2702
+ if (root.length === 0) return "absent";
2703
+ const key2 = guidanceRuntimeKey({
2704
+ root,
2705
+ host: input.host,
2706
+ repositoryId: input.repositoryId,
2707
+ controlPlane: input.controlPlane
2708
+ });
2709
+ const directory = join5(base, key2);
2710
+ const manifestPath = join5(directory, "current.json");
2711
+ try {
2712
+ checkDirectory(directory);
2713
+ } catch {
2714
+ return "absent";
2715
+ }
2716
+ let manifest;
2717
+ try {
2718
+ manifest = JSON.parse(readPrivate(manifestPath, 4096).toString("utf8"));
2719
+ } catch (error) {
2720
+ return error.code === "ENOENT" ? "absent" : "kept";
2721
+ }
2722
+ if (manifest?.owner !== OWNER || manifest.scopeKey !== key2) return "kept";
2723
+ renameSync4(manifestPath, join5(directory, "retired.json"));
2724
+ return "retired";
2725
+ } catch {
2726
+ return "kept";
2727
+ }
2728
+ }
2729
+
2631
2730
  // src/guidance-install.ts
2632
- import { execFileSync } from "node:child_process";
2731
+ import { execFileSync as execFileSync2 } from "node:child_process";
2633
2732
  import {
2634
- constants as constants2,
2635
- closeSync as closeSync5,
2636
- fstatSync as fstatSync2,
2637
- openSync as openSync5,
2733
+ constants as constants3,
2734
+ closeSync as closeSync6,
2735
+ fstatSync as fstatSync3,
2736
+ openSync as openSync6,
2638
2737
  existsSync as existsSync3,
2639
- lstatSync as lstatSync4,
2738
+ lstatSync as lstatSync5,
2640
2739
  linkSync,
2641
- mkdirSync as mkdirSync6,
2642
- readFileSync as readFileSync7,
2643
- renameSync as renameSync6,
2644
- unlinkSync as unlinkSync5,
2645
- writeFileSync as writeFileSync4
2740
+ mkdirSync as mkdirSync7,
2741
+ readFileSync as readFileSync8,
2742
+ renameSync as renameSync7,
2743
+ unlinkSync as unlinkSync6,
2744
+ writeFileSync as writeFileSync5
2646
2745
  } from "node:fs";
2647
2746
  var import_toml2 = __toESM(require_toml(), 1);
2648
- import { dirname as dirname4, join as join7, relative } from "node:path";
2747
+ import { dirname as dirname4, join as join8, relative } from "node:path";
2649
2748
 
2650
2749
  // src/codex-config.ts
2651
2750
  var import_toml = __toESM(require_toml(), 1);
2652
2751
  import {
2653
- closeSync as closeSync4,
2752
+ closeSync as closeSync5,
2654
2753
  existsSync as existsSync2,
2655
2754
  fsyncSync as fsyncSync3,
2656
- lstatSync as lstatSync3,
2657
- mkdirSync as mkdirSync5,
2658
- openSync as openSync4,
2659
- readFileSync as readFileSync6,
2660
- renameSync as renameSync5,
2661
- unlinkSync as unlinkSync4,
2755
+ lstatSync as lstatSync4,
2756
+ mkdirSync as mkdirSync6,
2757
+ openSync as openSync5,
2758
+ readFileSync as readFileSync7,
2759
+ renameSync as renameSync6,
2760
+ unlinkSync as unlinkSync5,
2662
2761
  writeSync as writeSync3
2663
2762
  } from "node:fs";
2664
- import { join as join6 } from "node:path";
2763
+ import { join as join7 } from "node:path";
2665
2764
 
2666
2765
  // src/mcp-config.ts
2667
2766
  import {
2668
- closeSync as closeSync3,
2767
+ closeSync as closeSync4,
2669
2768
  fsyncSync as fsyncSync2,
2670
- openSync as openSync3,
2671
- readFileSync as readFileSync5,
2672
- renameSync as renameSync4,
2673
- unlinkSync as unlinkSync3,
2769
+ openSync as openSync4,
2770
+ readFileSync as readFileSync6,
2771
+ renameSync as renameSync5,
2772
+ unlinkSync as unlinkSync4,
2674
2773
  writeSync as writeSync2
2675
2774
  } from "node:fs";
2676
- import { basename, dirname as dirname3, isAbsolute, join as join5 } from "node:path";
2775
+ import { basename, dirname as dirname3, isAbsolute, join as join6 } from "node:path";
2677
2776
  var MCP_CONFIG_FILE = ".mcp.json";
2678
2777
  function sameOrigin(left, right) {
2679
2778
  try {
@@ -2716,7 +2815,7 @@ function currentEntry(parsed) {
2716
2815
  }
2717
2816
  function readMcpConfig(repositoryRoot) {
2718
2817
  try {
2719
- return JSON.parse(readFileSync5(join5(repositoryRoot, MCP_CONFIG_FILE), "utf8"));
2818
+ return JSON.parse(readFileSync6(join6(repositoryRoot, MCP_CONFIG_FILE), "utf8"));
2720
2819
  } catch {
2721
2820
  return void 0;
2722
2821
  }
@@ -2726,7 +2825,7 @@ function readMcpConfig(repositoryRoot) {
2726
2825
  var CODEX_CONFIG_FILE = ".codex/config.toml";
2727
2826
  function readCodexEntry(root) {
2728
2827
  try {
2729
- const config = (0, import_toml.parse)(readFileSync6(join6(root, CODEX_CONFIG_FILE), "utf8"));
2828
+ const config = (0, import_toml.parse)(readFileSync7(join7(root, CODEX_CONFIG_FILE), "utf8"));
2730
2829
  const servers = config.mcp_servers;
2731
2830
  return servers && typeof servers === "object" && !Array.isArray(servers) && !(servers instanceof Date) ? servers.balladeer : void 0;
2732
2831
  } catch {
@@ -2735,7 +2834,7 @@ function readCodexEntry(root) {
2735
2834
  }
2736
2835
  function hasManagedCodexEntry(root, controlPlane) {
2737
2836
  try {
2738
- const text = readFileSync6(join6(root, CODEX_CONFIG_FILE), "utf8");
2837
+ const text = readFileSync7(join7(root, CODEX_CONFIG_FILE), "utf8");
2739
2838
  const starts = [...text.matchAll(/^# balladeer:mcp:start\r?$/gm)];
2740
2839
  const ends = [...text.matchAll(/^# balladeer:mcp:end\r?$/gm)];
2741
2840
  if (starts.length !== 1 || ends.length !== 1 || !starts[0] || !ends[0] || starts[0].index >= ends[0].index)
@@ -3151,23 +3250,23 @@ var MAX_FILE = 262144;
3151
3250
  function readRegular(path, allowMissing = false, writable = true) {
3152
3251
  if (!existsSync3(path)) {
3153
3252
  try {
3154
- lstatSync4(path);
3253
+ lstatSync5(path);
3155
3254
  } catch (e) {
3156
3255
  if (e.code === "ENOENT" && allowMissing) return void 0;
3157
3256
  }
3158
3257
  throw new Error("file_unavailable");
3159
3258
  }
3160
- const stat = lstatSync4(path);
3259
+ const stat = lstatSync5(path);
3161
3260
  if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_FILE || writable && (stat.mode & 146) === 0)
3162
3261
  throw new Error("file_not_safely_writable");
3163
- return readFileSync7(path, "utf8");
3262
+ return readFileSync8(path, "utf8");
3164
3263
  }
3165
3264
  function safeParent(root, path) {
3166
3265
  let at = dirname4(path);
3167
3266
  while (at !== root) {
3168
3267
  if (relative(root, at).startsWith("..")) throw new Error("outside_repository");
3169
3268
  try {
3170
- const stat = lstatSync4(at);
3269
+ const stat = lstatSync5(at);
3171
3270
  if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("unsafe_directory");
3172
3271
  } catch (e) {
3173
3272
  if (e.code !== "ENOENT") throw e;
@@ -3177,12 +3276,12 @@ function safeParent(root, path) {
3177
3276
  }
3178
3277
  function validateProjectGuidanceScope(options) {
3179
3278
  try {
3180
- const root = execFileSync("git", ["-C", options.cwd, "rev-parse", "--show-toplevel"], {
3279
+ const root = execFileSync2("git", ["-C", options.cwd, "rev-parse", "--show-toplevel"], {
3181
3280
  encoding: "utf8",
3182
3281
  stdio: ["ignore", "pipe", "ignore"],
3183
3282
  timeout: 1e3
3184
3283
  }).trim();
3185
- const path = join7(root, options.hook === "codex" ? ".codex/config.toml" : ".mcp.json");
3284
+ const path = join8(root, options.hook === "codex" ? ".codex/config.toml" : ".mcp.json");
3186
3285
  safeParent(root, path);
3187
3286
  readRegular(path, false, false);
3188
3287
  const entry = options.hook === "codex" ? readCodexEntry(root) : currentEntry(readMcpConfig(root));
@@ -3262,11 +3361,11 @@ function withSafeEndpoint(agent) {
3262
3361
  }
3263
3362
 
3264
3363
  // src/repository.ts
3265
- import { execFileSync as execFileSync2 } from "node:child_process";
3364
+ import { execFileSync as execFileSync3 } from "node:child_process";
3266
3365
  var REPOSITORY_HINT_PATTERN = /^[A-Za-z0-9._-]{1,39}\/[A-Za-z0-9._-]{1,100}$/;
3267
3366
  function git(cwd, args) {
3268
3367
  try {
3269
- return execFileSync2("git", ["-C", cwd, ...args], {
3368
+ return execFileSync3("git", ["-C", cwd, ...args], {
3270
3369
  encoding: "utf8",
3271
3370
  stdio: ["ignore", "pipe", "ignore"]
3272
3371
  }).trim();
@@ -3296,21 +3395,21 @@ function repositoryHints(cwd = process.cwd()) {
3296
3395
  }
3297
3396
 
3298
3397
  // src/hook-trust.ts
3299
- import { mkdirSync as mkdirSync7, readFileSync as readFileSync8, rmSync, writeFileSync as writeFileSync5 } from "node:fs";
3300
- import { join as join8 } from "node:path";
3398
+ import { mkdirSync as mkdirSync8, readFileSync as readFileSync9, rmSync, writeFileSync as writeFileSync6 } from "node:fs";
3399
+ import { join as join9 } from "node:path";
3301
3400
  var HOSTS_REQUIRING_TRUST = ["codex"];
3302
3401
  var FILE = "hook-trust.json";
3303
3402
  var key = (host, set) => set === "guidance" ? host : `${host}:${set}`;
3304
3403
  function read(environment) {
3305
3404
  try {
3306
- const parsed = JSON.parse(readFileSync8(join8(configHome(environment), FILE), "utf8"));
3405
+ const parsed = JSON.parse(readFileSync9(join9(configHome(environment), FILE), "utf8"));
3307
3406
  return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
3308
3407
  } catch {
3309
3408
  return {};
3310
3409
  }
3311
3410
  }
3312
3411
  function pushCheckAwaitingFile(environment, host) {
3313
- return join8(configHome(environment), `${host}-push-check-awaiting-first-run`);
3412
+ return join9(configHome(environment), `${host}-push-check-awaiting-first-run`);
3314
3413
  }
3315
3414
  function forget(path) {
3316
3415
  try {
@@ -3320,13 +3419,13 @@ function forget(path) {
3320
3419
  }
3321
3420
  function write(environment, marks) {
3322
3421
  try {
3323
- mkdirSync7(configHome(environment), { recursive: true, mode: 448 });
3324
- writeFileSync5(join8(configHome(environment), FILE), JSON.stringify(marks) + "\n", {
3422
+ mkdirSync8(configHome(environment), { recursive: true, mode: 448 });
3423
+ writeFileSync6(join9(configHome(environment), FILE), JSON.stringify(marks) + "\n", {
3325
3424
  mode: 384
3326
3425
  });
3327
3426
  for (const host of HOSTS_REQUIRING_TRUST) {
3328
3427
  const file = pushCheckAwaitingFile(environment, host);
3329
- if (awaiting(marks[key(host, "push-check")])) writeFileSync5(file, "", { mode: 384 });
3428
+ if (awaiting(marks[key(host, "push-check")])) writeFileSync6(file, "", { mode: 384 });
3330
3429
  else forget(file);
3331
3430
  }
3332
3431
  } catch {
@@ -3460,6 +3559,14 @@ async function runGuidance(options) {
3460
3559
  return 0;
3461
3560
  }
3462
3561
  if (options.hook === "codex") noteHookFired(options.environment, "codex");
3562
+ if (!projectScoped && event === "SessionStart")
3563
+ retireCheckoutRuntime({
3564
+ cwd: options.cwd,
3565
+ host: options.hook,
3566
+ repositoryId: agents[0].repositoryId,
3567
+ controlPlane: agents[0].controlPlane,
3568
+ environment: options.environment
3569
+ });
3463
3570
  const loaded = await loadGuidance({
3464
3571
  agent: agents[0],
3465
3572
  environment: options.environment,
@@ -3467,12 +3574,11 @@ async function runGuidance(options) {
3467
3574
  hook: options.hook,
3468
3575
  ...options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}
3469
3576
  });
3470
- if (event === "SessionStart")
3471
- startSelfUpdateIfDue(newerVersionPublished(), {
3472
- environment: options.environment,
3473
- ...options.startUpdate ? { run: options.startUpdate } : {},
3474
- ...options.now ? { now: options.now } : {}
3475
- });
3577
+ startSelfUpdateIfDue(newerVersionPublished(), {
3578
+ environment: options.environment,
3579
+ ...options.startUpdate ? { run: options.startUpdate } : {},
3580
+ ...options.now ? { now: options.now } : {}
3581
+ });
3476
3582
  const sessionId = identifier(record.session_id);
3477
3583
  const agentId = identifier(record.agent_id);
3478
3584
  recordGuidanceContext(
@@ -10,6 +10,32 @@ export declare function installGuidanceRuntime(options: Scope & {
10
10
  /** Digest of the final, already-validated project MCP configuration. */
11
11
  configurationDigest: string;
12
12
  }): void;
13
+ export type RetireOutcome = "retired" | "absent" | "kept";
14
+ /**
15
+ * Switches off the older per-repository hook in one checkout, without touching
16
+ * any file in the repository.
17
+ *
18
+ * Releases before 1.0.8 put a hook in the checkout's `.claude/settings.json`
19
+ * or `.codex/config.toml` whose command is the wrapper below. That wrapper
20
+ * reads `current.json` from this checkout's runtime directory before anything
21
+ * else, outside its own try, and its outer catch swallows the failure: with
22
+ * the manifest gone it prints nothing and fetches nothing. So renaming the
23
+ * manifest aside silences it for every later session, while the tracked hook
24
+ * entry stays exactly as the team committed it.
25
+ *
26
+ * Called by the user-scope hook at session start, only once it has found this
27
+ * checkout's connection, so a checkout is switched off only where the
28
+ * user-scope hook is already serving the guidance. Only the directory for this
29
+ * checkout, connection and host is touched, and only when the manifest proves
30
+ * it is ours (owner and scope key). Never throws and never prints.
31
+ */
32
+ export declare function retireCheckoutRuntime(input: {
33
+ cwd: string;
34
+ host: "codex" | "claude";
35
+ repositoryId: string;
36
+ controlPlane: string;
37
+ environment: NodeJS.ProcessEnv;
38
+ }): RetireOutcome;
13
39
  /** Stable, dependency-free wrapper. Current policy exists only in the authenticated server response. */
14
40
  export declare function guidanceRuntimeCommand(host: "codex" | "claude", repositoryId: string, controlPlane: string): string;
15
41
  export {};
@@ -2,7 +2,8 @@ import { createHash, randomBytes } from "node:crypto";
2
2
  import { constants, closeSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import { ensureStoreDirectory } from "./store.js";
5
+ import { execFileSync } from "node:child_process";
6
+ import { configHome, ensureStoreDirectory } from "./store.js";
6
7
  import { GUIDANCE_UNAVAILABLE } from "./guidance.js";
7
8
  const OWNER = "balladeer-guidance-runtime/1";
8
9
  const HEX = /^[a-f0-9]{64}$/;
@@ -114,6 +115,75 @@ export function installGuidanceRuntime(options) {
114
115
  }
115
116
  }
116
117
  }
118
+ /**
119
+ * Switches off the older per-repository hook in one checkout, without touching
120
+ * any file in the repository.
121
+ *
122
+ * Releases before 1.0.8 put a hook in the checkout's `.claude/settings.json`
123
+ * or `.codex/config.toml` whose command is the wrapper below. That wrapper
124
+ * reads `current.json` from this checkout's runtime directory before anything
125
+ * else, outside its own try, and its outer catch swallows the failure: with
126
+ * the manifest gone it prints nothing and fetches nothing. So renaming the
127
+ * manifest aside silences it for every later session, while the tracked hook
128
+ * entry stays exactly as the team committed it.
129
+ *
130
+ * Called by the user-scope hook at session start, only once it has found this
131
+ * checkout's connection, so a checkout is switched off only where the
132
+ * user-scope hook is already serving the guidance. Only the directory for this
133
+ * checkout, connection and host is touched, and only when the manifest proves
134
+ * it is ours (owner and scope key). Never throws and never prints.
135
+ */
136
+ export function retireCheckoutRuntime(input) {
137
+ try {
138
+ const base = join(configHome(input.environment), "guidance-runtime-v1");
139
+ // Most laptops never had the older form: one stat and nothing else.
140
+ try {
141
+ lstatSync(base);
142
+ }
143
+ catch {
144
+ return "absent";
145
+ }
146
+ const root = execFileSync("git", ["rev-parse", "--show-toplevel"], {
147
+ cwd: input.cwd,
148
+ encoding: "utf8",
149
+ stdio: ["ignore", "pipe", "ignore"],
150
+ timeout: 500,
151
+ maxBuffer: 8192,
152
+ }).trim();
153
+ if (root.length === 0)
154
+ return "absent";
155
+ const key = guidanceRuntimeKey({
156
+ root,
157
+ host: input.host,
158
+ repositoryId: input.repositoryId,
159
+ controlPlane: input.controlPlane,
160
+ });
161
+ const directory = join(base, key);
162
+ const manifestPath = join(directory, "current.json");
163
+ try {
164
+ checkDirectory(directory);
165
+ }
166
+ catch {
167
+ return "absent";
168
+ }
169
+ let manifest;
170
+ try {
171
+ manifest = JSON.parse(readPrivate(manifestPath, 4096).toString("utf8"));
172
+ }
173
+ catch (error) {
174
+ return error.code === "ENOENT" ? "absent" : "kept";
175
+ }
176
+ if (manifest?.owner !== OWNER || manifest.scopeKey !== key)
177
+ return "kept";
178
+ // Renamed, not deleted: the pinned runtime stays beside it, and an
179
+ // explicit install of the older form would simply write a new manifest.
180
+ renameSync(manifestPath, join(directory, "retired.json"));
181
+ return "retired";
182
+ }
183
+ catch {
184
+ return "kept";
185
+ }
186
+ }
117
187
  /** Stable, dependency-free wrapper. Current policy exists only in the authenticated server response. */
118
188
  export function guidanceRuntimeCommand(host, repositoryId, controlPlane) {
119
189
  // Keep this readable: it is the exact code the host asks a person to trust once.
@@ -29,10 +29,10 @@ export declare function selfUpdateState(environment: NodeJS.ProcessEnv): SelfUpd
29
29
  */
30
30
  export declare function resolveLaunch(specifier: string, environment: NodeJS.ProcessEnv, execPath?: string, exists?: (path: string) => boolean): Launch;
31
31
  /**
32
- * Start the update when the server named a newer release and none was
33
- * started today. Answers whether one was started, for the caller's own
32
+ * Start the update when the server named a newer release and an attempt
33
+ * toward it is due (`due`). Answers whether one was started, for the caller's own
34
34
  * record; the session hears nothing either way. A launch that could not start
35
- * leaves no stamp, so the next session start tries again rather than waiting
35
+ * leaves no stamp, so the next hook fire tries again rather than waiting
36
36
  * a day for the same failure.
37
37
  */
38
38
  export declare function startSelfUpdateIfDue(newer: string | undefined, deps: SelfUpdateDeps): boolean;
@@ -1,6 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { delimiter, dirname, join } from "node:path";
4
+ import { older } from "./currency.js";
4
5
  import { HOOK_SPECIFIER } from "./release.js";
5
6
  import { configHome } from "./store.js";
6
7
  /**
@@ -24,10 +25,22 @@ import { configHome } from "./store.js";
24
25
  * and what happened is kept in a small state file the next hook fire reports
25
26
  * to the server, so a laptop that cannot update is visible on the scorecard
26
27
  * instead of looking merely behind.
28
+ *
29
+ * What changed on 1 October 2026, from Robert's own laptop: it stayed on
30
+ * 1.0.16 through two releases while in use all day. Two rules held it. Only a
31
+ * session start began an install, and his sessions run for days, so the hooks
32
+ * that did fire (a prompt, a subagent) never asked. And the day's stamp was
33
+ * one stamp for every version: an attempt toward 1.0.15 at 03:20 held back
34
+ * 1.0.17 until 03:20 the next day, though a manual update had already passed
35
+ * 1.0.15. Now any guidance hook fire can begin the install, and a release
36
+ * newer than the one the last attempt aimed at may begin one an hour after
37
+ * it, rather than a day.
27
38
  */
28
39
  const STAMP = "self-update.json";
29
40
  const STATE = "self-update-state.json";
30
41
  const DAY_MS = 24 * 60 * 60 * 1000;
42
+ const HOUR_MS = 60 * 60 * 1000;
43
+ const VERSION = /^\d{1,4}\.\d{1,4}\.\d{1,4}$/;
31
44
  /** Set on the child so the install it runs can record how it ended. */
32
45
  export const SELF_UPDATE_ENVIRONMENT = "BALLADEER_SELF_UPDATE";
33
46
  const STATE_PATTERN = /^(?:started|unstartable|installed|failed):[A-Za-z0-9._-]{1,40}$/;
@@ -37,18 +50,35 @@ function stampPath(environment) {
37
50
  function statePath(environment) {
38
51
  return join(configHome(environment), STATE);
39
52
  }
40
- function lastStarted(environment) {
53
+ /** The last attempt: when it started and which release it aimed at, when it said. */
54
+ function lastAttempt(environment) {
41
55
  const path = stampPath(environment);
42
56
  if (!existsSync(path))
43
- return 0;
57
+ return { startedAt: 0 };
44
58
  try {
45
59
  const parsed = JSON.parse(readFileSync(path, "utf8"));
46
- return typeof parsed.startedAt === "number" ? parsed.startedAt : 0;
60
+ const startedAt = typeof parsed.startedAt === "number" ? parsed.startedAt : 0;
61
+ return typeof parsed.toward === "string" && VERSION.test(parsed.toward)
62
+ ? { startedAt, toward: parsed.toward }
63
+ : { startedAt };
47
64
  }
48
65
  catch {
49
- return 0;
66
+ return { startedAt: 0 };
50
67
  }
51
68
  }
69
+ /**
70
+ * Whether an attempt toward `newer` may start now. Once a day toward the same
71
+ * release, so a laptop whose install keeps failing is not hammered; but a
72
+ * release newer than the last attempt's target is a new reason, and waits only
73
+ * an hour, so one stale attempt cannot hold back the next release for a day.
74
+ */
75
+ function due(newer, last, now) {
76
+ const since = now - last.startedAt;
77
+ if (since >= DAY_MS)
78
+ return true;
79
+ const newerTarget = last.toward === undefined || older(last.toward, newer);
80
+ return newerTarget && since >= HOUR_MS;
81
+ }
52
82
  function writePrivate(path, value, environment) {
53
83
  mkdirSync(configHome(environment), { recursive: true, mode: 0o700 });
54
84
  writeFileSync(path, JSON.stringify(value) + "\n", { mode: 0o600 });
@@ -109,17 +139,17 @@ function detached(command, args, environment) {
109
139
  return child.pid !== undefined;
110
140
  }
111
141
  /**
112
- * Start the update when the server named a newer release and none was
113
- * started today. Answers whether one was started, for the caller's own
142
+ * Start the update when the server named a newer release and an attempt
143
+ * toward it is due (`due`). Answers whether one was started, for the caller's own
114
144
  * record; the session hears nothing either way. A launch that could not start
115
- * leaves no stamp, so the next session start tries again rather than waiting
145
+ * leaves no stamp, so the next hook fire tries again rather than waiting
116
146
  * a day for the same failure.
117
147
  */
118
148
  export function startSelfUpdateIfDue(newer, deps) {
119
149
  if (newer === undefined)
120
150
  return false;
121
151
  const now = deps.now ?? Date.now;
122
- if (now() - lastStarted(deps.environment) < DAY_MS)
152
+ if (!due(newer, lastAttempt(deps.environment), now()))
123
153
  return false;
124
154
  const launch = resolveLaunch(HOOK_SPECIFIER, deps.environment, deps.execPath, deps.exists);
125
155
  let started;
package/dist/wire.d.ts CHANGED
@@ -5,14 +5,14 @@
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.18";
8
+ export declare const CLI_VERSION = "1.0.19";
9
9
  export declare const CLI_INVOCATION = "npx -y balladeer@latest";
10
10
  export declare const CLIENT_HEADER = "x-balladeer-client";
11
11
  /** What the last background update came to: `started:1.0.17`, `installed:1.0.17`, `failed:exit-1`, `unstartable:launcher`. */
12
12
  export declare const UPDATE_STATE_HEADER = "x-balladeer-update-state";
13
13
  /** Which host's hook made this guidance fetch: `claude` or `codex`. */
14
14
  export declare const HOOK_HOST_HEADER = "x-balladeer-hook";
15
- export declare const CLIENT_HEADER_VALUE = "balladeer/1.0.18";
15
+ export declare const CLIENT_HEADER_VALUE = "balladeer/1.0.19";
16
16
  export declare const DEFAULT_CONTROL_PLANE = "https://envelopes.balladeer.ai";
17
17
  export type DelegatedScope = "repository:enroll" | "agent:issue" | "ci:connect" | "workspace:invite" | "candidate:propose";
18
18
  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.18";
8
+ export const CLI_VERSION = "1.0.19";
9
9
  export const CLI_INVOCATION = "npx -y balladeer@latest";
10
10
  export const CLIENT_HEADER = "x-balladeer-client";
11
11
  /** What the last background update came to: `started:1.0.17`, `installed:1.0.17`, `failed:exit-1`, `unstartable:launcher`. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "balladeer",
3
- "version": "1.0.18",
3
+ "version": "1.0.19",
4
4
  "description": "Set up Balladeer from your terminal, or from a coding agent's.",
5
5
  "license": "Apache-2.0",
6
6
  "private": false,