balladeer 1.0.14 → 1.0.16

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 (77) hide show
  1. package/README.md +2 -0
  2. package/dist/behavior-map-schema.d.ts +107 -0
  3. package/dist/behavior-map-schema.js +232 -0
  4. package/dist/cli.d.ts +5 -0
  5. package/dist/cli.js +77 -2
  6. package/dist/commands/guidance.d.ts +1 -1
  7. package/dist/commands/guidance.js +6 -0
  8. package/dist/commands/judge.d.ts +271 -0
  9. package/dist/commands/judge.js +1170 -0
  10. package/dist/commands/map.d.ts +120 -0
  11. package/dist/commands/map.js +601 -0
  12. package/dist/commands/mcp.d.ts +6 -0
  13. package/dist/commands/mcp.js +53 -0
  14. package/dist/commands/offers.d.ts +265 -0
  15. package/dist/commands/offers.js +801 -0
  16. package/dist/commands/risk.d.ts +29 -0
  17. package/dist/commands/risk.js +133 -0
  18. package/dist/copy.d.ts +24 -1
  19. package/dist/copy.js +91 -0
  20. package/dist/guidance-hook.mjs +230 -74
  21. package/dist/guidance.d.ts +2 -0
  22. package/dist/guidance.js +7 -1
  23. package/dist/headless-agent.d.ts +166 -0
  24. package/dist/headless-agent.js +416 -0
  25. package/dist/hook-trust.d.ts +31 -0
  26. package/dist/hook-trust.js +63 -0
  27. package/dist/judge-brief.d.ts +36 -0
  28. package/dist/judge-brief.js +89 -0
  29. package/dist/judge-hook.d.ts +84 -0
  30. package/dist/judge-hook.js +420 -0
  31. package/dist/map-brief.d.ts +16 -0
  32. package/dist/map-brief.js +41 -0
  33. package/dist/offer-brief.d.ts +95 -0
  34. package/dist/offer-brief.js +217 -0
  35. package/dist/owned-process.d.ts +65 -0
  36. package/dist/owned-process.js +146 -0
  37. package/dist/promise-meaning.d.ts +126 -0
  38. package/dist/promise-meaning.js +292 -0
  39. package/dist/risk/contract.d.ts +145 -0
  40. package/dist/risk/contract.js +74 -0
  41. package/dist/risk/describe.d.ts +7 -0
  42. package/dist/risk/describe.js +45 -0
  43. package/dist/risk/diff.d.ts +26 -0
  44. package/dist/risk/diff.js +174 -0
  45. package/dist/risk/extract.d.ts +43 -0
  46. package/dist/risk/extract.js +336 -0
  47. package/dist/risk/git.d.ts +30 -0
  48. package/dist/risk/git.js +118 -0
  49. package/dist/risk/import-graph.d.ts +41 -0
  50. package/dist/risk/import-graph.js +487 -0
  51. package/dist/risk/index.d.ts +20 -0
  52. package/dist/risk/index.js +20 -0
  53. package/dist/risk/paths.d.ts +16 -0
  54. package/dist/risk/paths.js +73 -0
  55. package/dist/risk/pipeline.d.ts +47 -0
  56. package/dist/risk/pipeline.js +121 -0
  57. package/dist/risk/priors.d.ts +18 -0
  58. package/dist/risk/priors.js +86 -0
  59. package/dist/risk/resources.d.ts +56 -0
  60. package/dist/risk/resources.js +374 -0
  61. package/dist/risk/score.d.ts +85 -0
  62. package/dist/risk/score.js +543 -0
  63. package/dist/risk/symbols.d.ts +35 -0
  64. package/dist/risk/symbols.js +348 -0
  65. package/dist/risk/text.d.ts +43 -0
  66. package/dist/risk/text.js +277 -0
  67. package/dist/risk/validate.d.ts +19 -0
  68. package/dist/risk/validate.js +154 -0
  69. package/dist/scratch-worktree.d.ts +51 -0
  70. package/dist/scratch-worktree.js +153 -0
  71. package/dist/self-update.d.ts +29 -2
  72. package/dist/self-update.js +96 -11
  73. package/dist/user-scope.d.ts +2 -0
  74. package/dist/user-scope.js +26 -2
  75. package/dist/wire.d.ts +55 -2
  76. package/dist/wire.js +5 -1
  77. package/package.json +1 -1
@@ -0,0 +1,154 @@
1
+ import { PROMISE_ID } from "../behavior-map-schema.js";
2
+ import { BANDS, CHANGE_EXTRACTION_SCHEMA_VERSION, DELTA_KINDS, FILE_STATUSES, INTENT_SOURCES, LANGUAGES, RESOURCE_CHANGES, RESOURCE_KINDS, RISK_REPORT_SCHEMA_VERSION, SIGNALS, SYMBOL_CHANGES, SYMBOL_EXTRACTORS, SYMBOL_KINDS, } from "./contract.js";
3
+ const string = (max = 20_000) => (value, at, errors) => {
4
+ if (typeof value === "string" && value.length <= max)
5
+ return true;
6
+ errors.push(`${at} must be a string of at most ${max} characters`);
7
+ return false;
8
+ };
9
+ const pattern = (regex, description) => (value, at, errors) => {
10
+ if (typeof value === "string" && regex.test(value))
11
+ return true;
12
+ errors.push(`${at} must be ${description}`);
13
+ return false;
14
+ };
15
+ const oneOf = (values) => (value, at, errors) => {
16
+ if (typeof value === "string" && values.includes(value))
17
+ return true;
18
+ errors.push(`${at} must be one of ${values.join(", ")}`);
19
+ return false;
20
+ };
21
+ const number = (min, max, integer = false) => (value, at, errors) => {
22
+ if (typeof value === "number" &&
23
+ Number.isFinite(value) &&
24
+ value >= min &&
25
+ value <= max &&
26
+ (!integer || Number.isInteger(value)))
27
+ return true;
28
+ errors.push(`${at} must be ${integer ? "an integer" : "a number"} from ${min} to ${max}`);
29
+ return false;
30
+ };
31
+ const nullable = (check) => (value, at, errors) => value === null || check(value, at, errors);
32
+ const boolean = (value, at, errors) => {
33
+ if (typeof value === "boolean")
34
+ return true;
35
+ errors.push(`${at} must be true or false`);
36
+ return false;
37
+ };
38
+ const listOf = (item) => (value, at, errors) => {
39
+ if (!Array.isArray(value)) {
40
+ errors.push(`${at} must be a list`);
41
+ return false;
42
+ }
43
+ let ok = true;
44
+ value.forEach((entry, index) => {
45
+ if (!item(entry, `${at}[${index}]`, errors))
46
+ ok = false;
47
+ });
48
+ return ok;
49
+ };
50
+ const record = (fields, optional = []) => (value, at, errors) => {
51
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
52
+ errors.push(`${at} must be an object`);
53
+ return false;
54
+ }
55
+ const input = value;
56
+ const unknown = Object.keys(input).filter((key) => !(key in fields));
57
+ if (unknown.length > 0) {
58
+ errors.push(`${at} has fields the contract does not define: ${unknown.join(", ")}`);
59
+ return false;
60
+ }
61
+ let ok = true;
62
+ for (const [key, check] of Object.entries(fields)) {
63
+ if (!(key in input) || input[key] === undefined) {
64
+ if (optional.includes(key))
65
+ continue;
66
+ errors.push(`${at}.${key} is missing`);
67
+ ok = false;
68
+ continue;
69
+ }
70
+ if (!check(input[key], `${at}.${key}`, errors))
71
+ ok = false;
72
+ }
73
+ return ok;
74
+ };
75
+ const SHA = pattern(/^[0-9a-f]{40}$/, "a 40-character commit sha");
76
+ const promiseId = pattern(PROMISE_ID, "a promise id");
77
+ const count = number(0, Number.MAX_SAFE_INTEGER, true);
78
+ const changedFile = record({
79
+ path: string(1024),
80
+ status: oneOf(FILE_STATUSES),
81
+ oldPath: nullable(string(1024)),
82
+ language: oneOf(LANGUAGES),
83
+ isTest: boolean,
84
+ hunks: listOf(record({ oldStart: count, oldLines: count, newStart: count, newLines: count })),
85
+ symbolsChanged: listOf(record({ name: string(512), kind: oneOf(SYMBOL_KINDS), change: oneOf(SYMBOL_CHANGES) })),
86
+ identifiersAdded: listOf(string(512)),
87
+ identifiersRemoved: listOf(string(512)),
88
+ resources: listOf(record({ kind: oneOf(RESOURCE_KINDS), name: string(600), change: oneOf(RESOURCE_CHANGES) })),
89
+ guardDeletions: listOf(record({ line: count, text: string(2000) })),
90
+ constantChanges: listOf(record({ name: string(512), from: string(2000), to: string(2000) })),
91
+ symbolExtractor: oneOf(SYMBOL_EXTRACTORS),
92
+ }, ["symbolExtractor"]);
93
+ const extraction = record({
94
+ schemaVersion: oneOf([CHANGE_EXTRACTION_SCHEMA_VERSION]),
95
+ base: SHA,
96
+ head: SHA,
97
+ files: listOf(changedFile),
98
+ deltas: listOf(record({ text: string(4000), files: listOf(string(1024)), kind: oneOf(DELTA_KINDS) })),
99
+ intent: record({ summary: string(), source: oneOf(INTENT_SOURCES) }),
100
+ }, ["deltas", "intent"]);
101
+ const unit = number(0, 1);
102
+ const report = record({
103
+ schemaVersion: oneOf([RISK_REPORT_SCHEMA_VERSION]),
104
+ base: SHA,
105
+ head: SHA,
106
+ promises: listOf(record({
107
+ promiseId,
108
+ title: string(),
109
+ score: unit,
110
+ band: oneOf(BANDS),
111
+ reasons: listOf(record({
112
+ signal: oneOf(SIGNALS),
113
+ weight: unit,
114
+ evidence: record({
115
+ file: string(),
116
+ symbol: string(),
117
+ resource: string(),
118
+ delta: string(),
119
+ caseId: string(),
120
+ detail: string(),
121
+ }, ["caseId", "detail"]),
122
+ })),
123
+ features: record({
124
+ symbol_hit: unit,
125
+ graph_forward_distance: nullable(count),
126
+ graph_reverse_distance: nullable(count),
127
+ resource_overlap: unit,
128
+ linked_test_changed: unit,
129
+ sink_reach: unit,
130
+ delta_similarity: unit,
131
+ intent_clash: unit,
132
+ prior_break: count,
133
+ churn: unit,
134
+ }),
135
+ }, ["title"])),
136
+ baselines: record({
137
+ scope_only: listOf(promiseId),
138
+ import_closure: listOf(promiseId),
139
+ all: listOf(promiseId),
140
+ random: listOf(promiseId),
141
+ }),
142
+ });
143
+ function parse(check, value, name) {
144
+ const errors = [];
145
+ return check(value, name, errors) ? { ok: true, value: value } : { ok: false, errors };
146
+ }
147
+ /** A change extraction, checked against the contract. */
148
+ export function parseChangeExtraction(value) {
149
+ return parse(extraction, value, "extraction");
150
+ }
151
+ /** A risk report, checked against the contract. */
152
+ export function parseRiskReport(value) {
153
+ return parse(report, value, "report");
154
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Disposable checkouts of one commit, for an agent to read and run things in.
3
+ *
4
+ * A map builder and a judge each work in a detached `git worktree` of the
5
+ * commit they are about, made under the system temp directory and removed
6
+ * afterwards. Whatever the agent writes there, a scratch test, a build output,
7
+ * an edit it should not have made, goes with the directory, and the person's
8
+ * own checkout is never the one being changed. Nothing here fetches: the
9
+ * commit has to be in the repository already, which it is for a change being
10
+ * pushed.
11
+ */
12
+ export type ScratchWorktree = Readonly<{
13
+ path: string;
14
+ sha: string;
15
+ remove: () => Promise<void>;
16
+ }>;
17
+ export declare function resolveCommit(root: string, ref: string): Promise<string | undefined>;
18
+ export declare function addScratchWorktree(root: string, ref: string, label: string, scratch?: string): Promise<{
19
+ ok: true;
20
+ worktree: ScratchWorktree;
21
+ } | {
22
+ ok: false;
23
+ reason: string;
24
+ }>;
25
+ /** Synchronous, for a process that is being stopped: remove what this process made. */
26
+ export declare function removeLiveWorktreesNow(): void;
27
+ /**
28
+ * The installed dependencies a fresh worktree lacks, linked from the checkout.
29
+ *
30
+ * A new worktree holds only tracked files, so it has no `node_modules` and a
31
+ * test run in it fails before it reaches the behavior. Each `node_modules`
32
+ * beside a tracked `package.json` in the person's checkout, and a `.venv` at
33
+ * the top, is linked into the same place in the worktree. Linked, not copied:
34
+ * the link is removed with the worktree and the dependencies are never touched.
35
+ */
36
+ export declare function shareDependencies(root: string, worktree: string): Promise<number>;
37
+ export type CarriedFile = Readonly<{
38
+ path: string;
39
+ sha256: string;
40
+ }>;
41
+ /**
42
+ * Files an agent added in one worktree, so a command it wrote can run in another.
43
+ *
44
+ * A judge that proves a break usually writes a new test. Its reproduction is
45
+ * then run in a clean checkout of the base commit, where that test does not
46
+ * exist, so the new files travel with it: untracked, not ignored, regular, and
47
+ * small. Edits to tracked files do not travel, and the judge brief says to put
48
+ * a reproduction in a new file for that reason.
49
+ */
50
+ export declare function newFiles(worktree: string): Promise<CarriedFile[]>;
51
+ export declare function carryFiles(from: string, to: string, files: readonly CarriedFile[]): void;
@@ -0,0 +1,153 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { dirname, join, relative, resolve } from "node:path";
6
+ import { runCommand } from "./gh.js";
7
+ /** Every worktree this process made and has not removed, for a signal handler to clear. */
8
+ const live = new Map();
9
+ export async function resolveCommit(root, ref) {
10
+ const answer = await runCommand("git", ["-C", root, "rev-parse", "--verify", `${ref}^{commit}`]);
11
+ const sha = answer.stdout.trim();
12
+ return answer.ok && /^[0-9a-f]{40}$/.test(sha) ? sha : undefined;
13
+ }
14
+ export async function addScratchWorktree(root, ref, label, scratch) {
15
+ const sha = await resolveCommit(root, ref);
16
+ if (sha === undefined)
17
+ return { ok: false, reason: `${ref} is not a commit in this repository` };
18
+ const parent = realpathSync(mkdtempSync(join(scratch ?? tmpdir(), `balladeer-${label.replace(/[^A-Za-z0-9_-]/g, "-")}-`)));
19
+ const path = join(parent, "tree");
20
+ const added = await runCommand("git", ["-C", root, "worktree", "add", "--detach", "--quiet", path, sha], { timeoutMs: 120_000 });
21
+ if (!added.ok) {
22
+ rmSync(parent, { recursive: true, force: true });
23
+ return {
24
+ ok: false,
25
+ reason: `a scratch checkout of ${sha.slice(0, 12)} could not be made: ${added.stderr}`,
26
+ };
27
+ }
28
+ live.set(path, root);
29
+ return {
30
+ ok: true,
31
+ worktree: {
32
+ path,
33
+ sha,
34
+ remove: async () => {
35
+ await runCommand("git", ["-C", root, "worktree", "remove", "--force", path], {
36
+ timeoutMs: 60_000,
37
+ });
38
+ rmSync(parent, { recursive: true, force: true });
39
+ await runCommand("git", ["-C", root, "worktree", "prune"]);
40
+ live.delete(path);
41
+ },
42
+ },
43
+ };
44
+ }
45
+ /** Synchronous, for a process that is being stopped: remove what this process made. */
46
+ export function removeLiveWorktreesNow() {
47
+ for (const [path, root] of live) {
48
+ try {
49
+ execFileSync("git", ["-C", root, "worktree", "remove", "--force", path], {
50
+ stdio: "ignore",
51
+ timeout: 20_000,
52
+ });
53
+ }
54
+ catch {
55
+ /* The directory removal below still clears the files. */
56
+ }
57
+ rmSync(dirname(path), { recursive: true, force: true });
58
+ live.delete(path);
59
+ }
60
+ }
61
+ /**
62
+ * The installed dependencies a fresh worktree lacks, linked from the checkout.
63
+ *
64
+ * A new worktree holds only tracked files, so it has no `node_modules` and a
65
+ * test run in it fails before it reaches the behavior. Each `node_modules`
66
+ * beside a tracked `package.json` in the person's checkout, and a `.venv` at
67
+ * the top, is linked into the same place in the worktree. Linked, not copied:
68
+ * the link is removed with the worktree and the dependencies are never touched.
69
+ */
70
+ export async function shareDependencies(root, worktree) {
71
+ const listed = await runCommand("git", [
72
+ "-C",
73
+ root,
74
+ "ls-files",
75
+ "--",
76
+ "package.json",
77
+ "*/package.json",
78
+ "*/*/package.json",
79
+ ]);
80
+ const directories = new Set(["."]);
81
+ if (listed.ok)
82
+ for (const line of listed.stdout.split("\n"))
83
+ if (line.trim())
84
+ directories.add(dirname(line.trim()));
85
+ let linked = 0;
86
+ const link = (relativePath) => {
87
+ const source = join(root, relativePath);
88
+ const target = join(worktree, relativePath);
89
+ if (!existsSync(source) || existsSync(target) || !existsSync(dirname(target)))
90
+ return;
91
+ try {
92
+ symlinkSync(source, target, "dir");
93
+ linked += 1;
94
+ }
95
+ catch {
96
+ /* A test that needs it will say so. */
97
+ }
98
+ };
99
+ for (const directory of directories)
100
+ link(join(directory, "node_modules"));
101
+ link(".venv");
102
+ return linked;
103
+ }
104
+ /**
105
+ * Files an agent added in one worktree, so a command it wrote can run in another.
106
+ *
107
+ * A judge that proves a break usually writes a new test. Its reproduction is
108
+ * then run in a clean checkout of the base commit, where that test does not
109
+ * exist, so the new files travel with it: untracked, not ignored, regular, and
110
+ * small. Edits to tracked files do not travel, and the judge brief says to put
111
+ * a reproduction in a new file for that reason.
112
+ */
113
+ export async function newFiles(worktree) {
114
+ const listed = await runCommand("git", [
115
+ "-C",
116
+ worktree,
117
+ "ls-files",
118
+ "--others",
119
+ "--exclude-standard",
120
+ "-z",
121
+ ]);
122
+ if (!listed.ok)
123
+ return [];
124
+ const files = [];
125
+ for (const path of listed.stdout.split("\0")) {
126
+ if (!path || files.length >= 200)
127
+ continue;
128
+ const absolute = join(worktree, path);
129
+ try {
130
+ const stat = lstatSync(absolute);
131
+ if (!stat.isFile() || stat.size > 1024 * 1024)
132
+ continue;
133
+ files.push({
134
+ path,
135
+ sha256: createHash("sha256").update(readFileSync(absolute)).digest("hex"),
136
+ });
137
+ }
138
+ catch {
139
+ /* Gone between the listing and the read. */
140
+ }
141
+ }
142
+ return files.sort((a, b) => a.path.localeCompare(b.path));
143
+ }
144
+ export function carryFiles(from, to, files) {
145
+ for (const file of files) {
146
+ const target = resolve(to, file.path);
147
+ const inside = relative(to, target);
148
+ if (inside.startsWith("..") || inside === "")
149
+ continue;
150
+ mkdirSync(dirname(target), { recursive: true });
151
+ copyFileSync(join(from, file.path), target);
152
+ }
153
+ }
@@ -1,11 +1,38 @@
1
+ /** Set on the child so the install it runs can record how it ended. */
2
+ export declare const SELF_UPDATE_ENVIRONMENT = "BALLADEER_SELF_UPDATE";
3
+ export type Launch = Readonly<{
4
+ command: string;
5
+ args: readonly string[];
6
+ environment: NodeJS.ProcessEnv;
7
+ }>;
1
8
  export type SelfUpdateDeps = Readonly<{
2
9
  environment: NodeJS.ProcessEnv;
3
10
  now?: () => number;
4
- run?: (command: string, args: readonly string[]) => void;
11
+ /** Starts the child and answers whether one exists. Absent a return value, it started. */
12
+ run?: (command: string, args: readonly string[], environment: NodeJS.ProcessEnv) => boolean | void;
13
+ /** The node binary this process runs under; the launcher is found beside it. */
14
+ execPath?: string;
15
+ exists?: (path: string) => boolean;
5
16
  }>;
17
+ /** A short, closed vocabulary: it travels in a header and lands in a column. */
18
+ export type SelfUpdateState = `started:${string}` | `unstartable:${string}` | `installed:${string}` | `failed:${string}`;
19
+ /** What the last attempt came to, for the next hook fire to report. Never throws. */
20
+ export declare function recordSelfUpdateState(environment: NodeJS.ProcessEnv, state: SelfUpdateState, at: number): void;
21
+ /** The recorded state, or nothing when none was written or it is not one of ours. */
22
+ export declare function selfUpdateState(environment: NodeJS.ProcessEnv): SelfUpdateState | undefined;
23
+ /**
24
+ * How to start the installer without trusting PATH. npm ships npx as a script
25
+ * beside the node it belongs to, so the node this hook runs under can run it
26
+ * directly; that node certainly exists, which is what makes the launch
27
+ * dependable. The shim beside node is second, and PATH is last, for the
28
+ * layouts that keep npm somewhere else.
29
+ */
30
+ export declare function resolveLaunch(specifier: string, environment: NodeJS.ProcessEnv, execPath?: string, exists?: (path: string) => boolean): Launch;
6
31
  /**
7
32
  * Start the update when the server named a newer release and none was
8
33
  * started today. Answers whether one was started, for the caller's own
9
- * record; the session hears nothing either way.
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
36
+ * a day for the same failure.
10
37
  */
11
38
  export declare function startSelfUpdateIfDue(newer: string | undefined, deps: SelfUpdateDeps): boolean;
@@ -1,6 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
- import { join } from "node:path";
3
+ import { delimiter, dirname, join } from "node:path";
4
4
  import { HOOK_SPECIFIER } from "./release.js";
5
5
  import { configHome } from "./store.js";
6
6
  /**
@@ -10,15 +10,33 @@ import { configHome } from "./store.js";
10
10
  * update must never hold the agent. So the hook, having served this session
11
11
  * from the copy it has, only starts the install: a detached child on the
12
12
  * current major, output discarded, at most once a day per laptop whatever it
13
- * reports. The next session runs whatever that child installed. A child that
14
- * fails changes nothing and is tried again the next day; `balladeer update`
15
- * is the manual path. Nothing here reads the session or writes to it.
13
+ * reports. The next session runs whatever that child installed. `balladeer
14
+ * update` is the manual path. Nothing here reads the session or writes to it.
15
+ *
16
+ * What changed on 29 September 2026, from what Didero's laptops showed: four
17
+ * of nine stayed on 1.0.12 through two releases while active every day. The
18
+ * hook reached for `npx` on whatever PATH its host gave it, wrote "tried
19
+ * today" before it knew whether anything had started, and swallowed the
20
+ * failure, so a laptop whose editor launches hooks without npm on PATH failed
21
+ * silently once a day, for ever. Now the installer is launched through the
22
+ * node binary this hook is already running under, with npm's own npx script
23
+ * beside it; the day's stamp is written only once a child actually exists;
24
+ * and what happened is kept in a small state file the next hook fire reports
25
+ * to the server, so a laptop that cannot update is visible on the scorecard
26
+ * instead of looking merely behind.
16
27
  */
17
28
  const STAMP = "self-update.json";
29
+ const STATE = "self-update-state.json";
18
30
  const DAY_MS = 24 * 60 * 60 * 1000;
31
+ /** Set on the child so the install it runs can record how it ended. */
32
+ export const SELF_UPDATE_ENVIRONMENT = "BALLADEER_SELF_UPDATE";
33
+ const STATE_PATTERN = /^(?:started|unstartable|installed|failed):[A-Za-z0-9._-]{1,40}$/;
19
34
  function stampPath(environment) {
20
35
  return join(configHome(environment), STAMP);
21
36
  }
37
+ function statePath(environment) {
38
+ return join(configHome(environment), STATE);
39
+ }
22
40
  function lastStarted(environment) {
23
41
  const path = stampPath(environment);
24
42
  if (!existsSync(path))
@@ -31,15 +49,71 @@ function lastStarted(environment) {
31
49
  return 0;
32
50
  }
33
51
  }
34
- function detached(command, args) {
35
- const child = spawn(command, [...args], { detached: true, stdio: "ignore" });
52
+ function writePrivate(path, value, environment) {
53
+ mkdirSync(configHome(environment), { recursive: true, mode: 0o700 });
54
+ writeFileSync(path, JSON.stringify(value) + "\n", { mode: 0o600 });
55
+ }
56
+ /** What the last attempt came to, for the next hook fire to report. Never throws. */
57
+ export function recordSelfUpdateState(environment, state, at) {
58
+ if (!STATE_PATTERN.test(state))
59
+ return;
60
+ try {
61
+ writePrivate(statePath(environment), { state, at }, environment);
62
+ }
63
+ catch {
64
+ /* A state that cannot be written is only a state nobody hears about. */
65
+ }
66
+ }
67
+ /** The recorded state, or nothing when none was written or it is not one of ours. */
68
+ export function selfUpdateState(environment) {
69
+ try {
70
+ const parsed = JSON.parse(readFileSync(statePath(environment), "utf8"));
71
+ return typeof parsed.state === "string" && STATE_PATTERN.test(parsed.state)
72
+ ? parsed.state
73
+ : undefined;
74
+ }
75
+ catch {
76
+ return undefined;
77
+ }
78
+ }
79
+ /**
80
+ * How to start the installer without trusting PATH. npm ships npx as a script
81
+ * beside the node it belongs to, so the node this hook runs under can run it
82
+ * directly; that node certainly exists, which is what makes the launch
83
+ * dependable. The shim beside node is second, and PATH is last, for the
84
+ * layouts that keep npm somewhere else.
85
+ */
86
+ export function resolveLaunch(specifier, environment, execPath = process.execPath, exists = existsSync) {
87
+ const bin = dirname(execPath);
88
+ const tail = ["-y", specifier, "install", "--json"];
89
+ // The child's own children (npm's scripts) need to find node too.
90
+ const child = {
91
+ ...environment,
92
+ PATH: [bin, environment.PATH ?? ""].filter((part) => part.length > 0).join(delimiter),
93
+ [SELF_UPDATE_ENVIRONMENT]: "1",
94
+ };
95
+ const script = join(bin, "..", "lib", "node_modules", "npm", "bin", "npx-cli.js");
96
+ if (exists(script))
97
+ return { command: execPath, args: [script, ...tail], environment: child };
98
+ const shim = join(bin, process.platform === "win32" ? "npx.cmd" : "npx");
99
+ if (exists(shim))
100
+ return { command: shim, args: tail, environment: child };
101
+ return { command: "npx", args: tail, environment: child };
102
+ }
103
+ function detached(command, args, environment) {
104
+ const child = spawn(command, [...args], { detached: true, stdio: "ignore", env: environment });
36
105
  child.on("error", () => undefined);
37
106
  child.unref();
107
+ // A command that could not be found has no process id, and that is known
108
+ // before this function returns.
109
+ return child.pid !== undefined;
38
110
  }
39
111
  /**
40
112
  * Start the update when the server named a newer release and none was
41
113
  * started today. Answers whether one was started, for the caller's own
42
- * record; the session hears nothing either way.
114
+ * 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
116
+ * a day for the same failure.
43
117
  */
44
118
  export function startSelfUpdateIfDue(newer, deps) {
45
119
  if (newer === undefined)
@@ -47,13 +121,24 @@ export function startSelfUpdateIfDue(newer, deps) {
47
121
  const now = deps.now ?? Date.now;
48
122
  if (now() - lastStarted(deps.environment) < DAY_MS)
49
123
  return false;
124
+ const launch = resolveLaunch(HOOK_SPECIFIER, deps.environment, deps.execPath, deps.exists);
125
+ let started;
50
126
  try {
51
- mkdirSync(configHome(deps.environment), { recursive: true, mode: 0o700 });
52
- writeFileSync(stampPath(deps.environment), JSON.stringify({ startedAt: now(), toward: newer }) + "\n", { mode: 0o600 });
53
- (deps.run ?? detached)("npx", ["-y", HOOK_SPECIFIER, "install", "--json"]);
54
- return true;
127
+ started = (deps.run ?? detached)(launch.command, launch.args, launch.environment) !== false;
55
128
  }
56
129
  catch {
130
+ started = false;
131
+ }
132
+ if (!started) {
133
+ recordSelfUpdateState(deps.environment, "unstartable:launcher", now());
57
134
  return false;
58
135
  }
136
+ try {
137
+ writePrivate(stampPath(deps.environment), { startedAt: now(), toward: newer }, deps.environment);
138
+ }
139
+ catch {
140
+ /* The child is running; an unwritten stamp costs one more attempt, not a missed one. */
141
+ }
142
+ recordSelfUpdateState(deps.environment, `started:${newer}`, now());
143
+ return true;
59
144
  }
@@ -53,6 +53,8 @@ export declare function isOurUserEntry(entry: unknown): boolean;
53
53
  export declare function isInstalledCommandPath(command: string): boolean;
54
54
  /** `~/.claude/settings.json` carries user-scope hooks. */
55
55
  export declare function mergeClaudeUserHooks(home: string, published: boolean, installed?: string): UserScopeWrite;
56
+ /** What a person has to do once in Codex, which no command can do for them. */
57
+ export declare const CODEX_HOOK_APPROVAL = "Codex: type /hooks in a Codex session and trust Balladeer's three hooks. Codex skips a hook until you have trusted it, so until then Codex has Balladeer's tools and none of its session-start guidance or updates.";
56
58
  /** `~/.codex/config.toml` carries both the server and the hooks for Codex, in
57
59
  * one fenced block this command owns end to end. */
58
60
  export declare function mergeCodexUserConfig(home: string, published: boolean, installed?: string): UserScopeWrite;
@@ -1,3 +1,4 @@
1
+ import { noteHooksWritten } from "./hook-trust.js";
1
2
  import { existsSync, lstatSync, mkdirSync, readFileSync } from "node:fs";
2
3
  import { homedir } from "node:os";
3
4
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
@@ -207,6 +208,21 @@ export function mergeClaudeUserHooks(home, published, installed) {
207
208
  return { host, path, status: "refused", reason: "permissions.allow is not a list" };
208
209
  const deny = Array.isArray(permissions.deny) ? permissions.deny : [];
209
210
  const denied = deny.some((rule) => typeof rule === "string" && /^mcp__balladeer(__|$)/.test(rule));
211
+ // An event this release no longer hooks: its own entry there is deprecated
212
+ // and comes out. Robert, 29 September 2026: a hook that should no longer
213
+ // exist is removed, a new one is added, one that still belongs persists.
214
+ for (const [event, rows] of Object.entries(hooks)) {
215
+ if (CLAUDE_EVENTS.includes(event) || !Array.isArray(rows))
216
+ continue;
217
+ const kept = rows.filter((row) => !row?.hooks?.some((h) => h.statusMessage === USER_SCOPE_OWNER));
218
+ if (kept.length === rows.length)
219
+ continue;
220
+ if (kept.length === 0)
221
+ delete hooks[event];
222
+ else
223
+ hooks[event] = kept;
224
+ changed = true;
225
+ }
210
226
  if (!denied && !allow.some((rule) => rule === USER_SCOPE_ALLOW_RULE)) {
211
227
  permissions.allow = [...allow, USER_SCOPE_ALLOW_RULE];
212
228
  root.permissions = permissions;
@@ -219,6 +235,8 @@ export function mergeClaudeUserHooks(home, published, installed) {
219
235
  writeJsonAtomically(path, JSON.stringify(root, null, 2) + "\n");
220
236
  return { host, path, status: "written" };
221
237
  }
238
+ /** What a person has to do once in Codex, which no command can do for them. */
239
+ export const CODEX_HOOK_APPROVAL = "Codex: type /hooks in a Codex session and trust Balladeer's three hooks. Codex skips a hook until you have trusted it, so until then Codex has Balladeer's tools and none of its session-start guidance or updates.";
222
240
  const CODEX_START = "# balladeer:user:start";
223
241
  const CODEX_END = "# balladeer:user:end";
224
242
  /** `~/.codex/config.toml` carries both the server and the hooks for Codex, in
@@ -302,7 +320,13 @@ export function installUserScope(options) {
302
320
  mergeClaudeUserMcp(home, options.published, options.installed),
303
321
  mergeClaudeUserHooks(home, options.published, options.installed),
304
322
  ];
305
- if (existsSync(join(home, ".codex")))
306
- writes.push(mergeCodexUserConfig(home, options.published, options.installed));
323
+ if (existsSync(join(home, ".codex"))) {
324
+ const codex = mergeCodexUserConfig(home, options.published, options.installed);
325
+ writes.push(codex);
326
+ // Codex has to run these once before anybody assumes it does; the mark is
327
+ // what lets its own session say so after a silent background update.
328
+ if (codex.status !== "refused")
329
+ noteHooksWritten(options.environment, "codex", codex.status === "written");
330
+ }
307
331
  return writes;
308
332
  }