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.
- package/README.md +2 -0
- package/dist/behavior-map-schema.d.ts +107 -0
- package/dist/behavior-map-schema.js +232 -0
- package/dist/cli.d.ts +5 -0
- package/dist/cli.js +77 -2
- package/dist/commands/guidance.d.ts +1 -1
- package/dist/commands/guidance.js +6 -0
- package/dist/commands/judge.d.ts +271 -0
- package/dist/commands/judge.js +1170 -0
- package/dist/commands/map.d.ts +120 -0
- package/dist/commands/map.js +601 -0
- package/dist/commands/mcp.d.ts +6 -0
- package/dist/commands/mcp.js +53 -0
- package/dist/commands/offers.d.ts +265 -0
- package/dist/commands/offers.js +801 -0
- package/dist/commands/risk.d.ts +29 -0
- package/dist/commands/risk.js +133 -0
- package/dist/copy.d.ts +24 -1
- package/dist/copy.js +91 -0
- package/dist/guidance-hook.mjs +230 -74
- package/dist/guidance.d.ts +2 -0
- package/dist/guidance.js +7 -1
- package/dist/headless-agent.d.ts +166 -0
- package/dist/headless-agent.js +416 -0
- package/dist/hook-trust.d.ts +31 -0
- package/dist/hook-trust.js +63 -0
- package/dist/judge-brief.d.ts +36 -0
- package/dist/judge-brief.js +89 -0
- package/dist/judge-hook.d.ts +84 -0
- package/dist/judge-hook.js +420 -0
- package/dist/map-brief.d.ts +16 -0
- package/dist/map-brief.js +41 -0
- package/dist/offer-brief.d.ts +95 -0
- package/dist/offer-brief.js +217 -0
- package/dist/owned-process.d.ts +65 -0
- package/dist/owned-process.js +146 -0
- package/dist/promise-meaning.d.ts +126 -0
- package/dist/promise-meaning.js +292 -0
- package/dist/risk/contract.d.ts +145 -0
- package/dist/risk/contract.js +74 -0
- package/dist/risk/describe.d.ts +7 -0
- package/dist/risk/describe.js +45 -0
- package/dist/risk/diff.d.ts +26 -0
- package/dist/risk/diff.js +174 -0
- package/dist/risk/extract.d.ts +43 -0
- package/dist/risk/extract.js +336 -0
- package/dist/risk/git.d.ts +30 -0
- package/dist/risk/git.js +118 -0
- package/dist/risk/import-graph.d.ts +41 -0
- package/dist/risk/import-graph.js +487 -0
- package/dist/risk/index.d.ts +20 -0
- package/dist/risk/index.js +20 -0
- package/dist/risk/paths.d.ts +16 -0
- package/dist/risk/paths.js +73 -0
- package/dist/risk/pipeline.d.ts +47 -0
- package/dist/risk/pipeline.js +121 -0
- package/dist/risk/priors.d.ts +18 -0
- package/dist/risk/priors.js +86 -0
- package/dist/risk/resources.d.ts +56 -0
- package/dist/risk/resources.js +374 -0
- package/dist/risk/score.d.ts +85 -0
- package/dist/risk/score.js +543 -0
- package/dist/risk/symbols.d.ts +35 -0
- package/dist/risk/symbols.js +348 -0
- package/dist/risk/text.d.ts +43 -0
- package/dist/risk/text.js +277 -0
- package/dist/risk/validate.d.ts +19 -0
- package/dist/risk/validate.js +154 -0
- package/dist/scratch-worktree.d.ts +51 -0
- package/dist/scratch-worktree.js +153 -0
- package/dist/self-update.d.ts +29 -2
- package/dist/self-update.js +96 -11
- package/dist/user-scope.d.ts +2 -0
- package/dist/user-scope.js +26 -2
- package/dist/wire.d.ts +55 -2
- package/dist/wire.js +5 -1
- 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
|
+
}
|
package/dist/self-update.d.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/self-update.js
CHANGED
|
@@ -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.
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
|
35
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/user-scope.d.ts
CHANGED
|
@@ -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;
|
package/dist/user-scope.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|