balladeer 1.0.18 → 1.0.20

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.
Files changed (51) hide show
  1. package/README.md +18 -0
  2. package/dist/anchoring.js +2 -2
  3. package/dist/anchors-schema.d.ts +3 -1
  4. package/dist/anchors-schema.js +5 -3
  5. package/dist/checkout-form.d.ts +11 -0
  6. package/dist/checkout-form.js +14 -0
  7. package/dist/cli.js +4 -3
  8. package/dist/commands/guidance.js +23 -8
  9. package/dist/commands/judge.d.ts +50 -1
  10. package/dist/commands/judge.js +768 -110
  11. package/dist/commands/mcp.js +5 -9
  12. package/dist/commands/setup.js +5 -23
  13. package/dist/currency.d.ts +2 -0
  14. package/dist/currency.js +2 -1
  15. package/dist/guidance-hook.mjs +162 -56
  16. package/dist/guidance-runtime.d.ts +26 -0
  17. package/dist/guidance-runtime.js +71 -1
  18. package/dist/headless-agent.d.ts +2 -0
  19. package/dist/headless-agent.js +5 -1
  20. package/dist/judge-brief.d.ts +11 -5
  21. package/dist/judge-brief.js +26 -10
  22. package/dist/judge-evidence.d.ts +87 -0
  23. package/dist/judge-evidence.js +393 -0
  24. package/dist/judge-hook.d.ts +14 -1
  25. package/dist/judge-hook.js +34 -5
  26. package/dist/judge-review.d.ts +10 -0
  27. package/dist/judge-review.js +36 -0
  28. package/dist/judge-said.d.ts +1 -0
  29. package/dist/judge-said.js +3 -0
  30. package/dist/promise-meaning.d.ts +4 -0
  31. package/dist/promise-review-client.d.ts +84 -0
  32. package/dist/promise-review-client.js +357 -0
  33. package/dist/push-operation.d.ts +61 -0
  34. package/dist/push-operation.js +472 -0
  35. package/dist/read-only-review.d.ts +2 -0
  36. package/dist/read-only-review.js +2 -0
  37. package/dist/review-backstop.d.ts +20 -0
  38. package/dist/review-backstop.js +54 -0
  39. package/dist/review-reporting.d.ts +32 -0
  40. package/dist/review-reporting.js +319 -0
  41. package/dist/review-selection.d.ts +33 -0
  42. package/dist/review-selection.js +64 -0
  43. package/dist/review-settings.d.ts +6 -0
  44. package/dist/review-settings.js +9 -0
  45. package/dist/self-update.d.ts +3 -3
  46. package/dist/self-update.js +38 -8
  47. package/dist/user-scope.d.ts +1 -10
  48. package/dist/user-scope.js +12 -7
  49. package/dist/wire.d.ts +3 -3
  50. package/dist/wire.js +1 -1
  51. package/package.json +1 -1
@@ -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.20";
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.
@@ -133,6 +133,7 @@ export declare function verifyLogin(client: HeadlessClient, options: Readonly<{
133
133
  cwd: string;
134
134
  spawn?: SpawnLike;
135
135
  killGroup?: KillGroup;
136
+ deadline?: number;
136
137
  }>): Promise<LoginCheck>;
137
138
  /**
138
139
  * The client to run, or none. `auto` tries the hook's own client first, then
@@ -144,6 +145,7 @@ export declare function resolveClient(requested: HeadlessClient | "auto", option
144
145
  prefer?: HeadlessClient;
145
146
  spawn?: SpawnLike;
146
147
  killGroup?: KillGroup;
148
+ deadline?: number;
147
149
  }>): Promise<LoginCheck | undefined>;
148
150
  /**
149
151
  * The last JSON object in a piece of text.
@@ -99,13 +99,15 @@ export function codexArguments(input) {
99
99
  * dropped, the same way the compatibility canary treats them.
100
100
  */
101
101
  export async function verifyLogin(client, options) {
102
+ if (options.deadline !== undefined && Date.now() >= options.deadline)
103
+ return { client, verified: false };
102
104
  const env = agentEnvironment(options.env);
103
105
  const run = await runOwnedProcess({
104
106
  command: clientBinary(client, options.env),
105
107
  args: client === "claude" ? ["auth", "status", "--json"] : ["login", "status"],
106
108
  cwd: options.cwd,
107
109
  env,
108
- timeoutMs: 15_000,
110
+ timeoutMs: Math.max(1, Math.min(15_000, (options.deadline ?? Number.POSITIVE_INFINITY) - Date.now())),
109
111
  maxOutputBytes: 64 * 1024,
110
112
  ...(options.spawn === undefined ? {} : { spawn: options.spawn }),
111
113
  ...(options.killGroup === undefined ? {} : { killGroup: options.killGroup }),
@@ -136,6 +138,8 @@ export async function resolveClient(requested, options) {
136
138
  ? ["codex", "claude"]
137
139
  : ["claude", "codex"];
138
140
  for (const client of order) {
141
+ if (options.deadline !== undefined && Date.now() >= options.deadline)
142
+ return undefined;
139
143
  const check = await verifyLogin(client, options);
140
144
  if (check.verified)
141
145
  return check;