@a2f0/agent-tool 0.1.23 → 0.1.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -85,6 +85,8 @@ validation suite, and release procedures.
85
85
  - [Development](docs/development.md): setup, source layout, validation, and release
86
86
  procedures for this package.
87
87
  - [Images](docs/images.md): declarative brand sources and generated outputs.
88
+ - [Permissions](docs/permissions.md): which commands run without approval in
89
+ Claude Code and Codex, and the dependency commands that enforce it.
88
90
  - [Dependency maintenance](docs/dependency-upgrades.md): active compatibility
89
91
  constraints and the dependency-update workflow.
90
92
  - [Design](docs/design.md) and [extraction history](docs/extraction.md): component
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a2f0/agent-tool",
3
- "version": "0.1.23",
3
+ "version": "0.1.24",
4
4
  "packageManager": "bun@1.4.3",
5
5
  "type": "module",
6
6
  "description": "Portable cross-agent review, PR workflows, and shared skills",
@@ -218,7 +218,12 @@ Capture relevant baseline checks and warnings first, after screening their side
218
218
  effects. Update one compatibility group at a time. Read the upstream instructions
219
219
  for every breaking step; implement required source/config/schema migrations and
220
220
  removed API replacements. Use the manager that owns each lockfile, on the selected
221
- runtime, and inspect the resulting diff. Refresh transitive dependencies through
221
+ runtime, and inspect the resulting diff. For Bun lockfiles, change declared
222
+ versions with `agent-tool bun update <name[@version]>` and drop packages with
223
+ `agent-tool bun remove <name>`; both use the pinned Bun and refuse to add a
224
+ package. A new dependency goes through `agent-tool bun add <name@version>`, which
225
+ asks for approval; never introduce one by editing `package.json` and
226
+ reinstalling. Refresh transitive dependencies through
222
227
  their owning direct dependency; do not hand-edit integrity hashes or lock entries.
223
228
  Review intentional patches, overrides, and pins before removal; retain them until
224
229
  upstream fixes and regression checks demonstrate they are unnecessary.
@@ -0,0 +1,252 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { closeSync, existsSync, fstatSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { createLog } from "../commands/logFile";
5
+ import { DEFAULT_LIMITS, printLogTail } from "../commands/output";
6
+ import { assert, flag, option, output } from "../workflows/core";
7
+ import { pinnedBun, type BunExecutable } from "./toolchain";
8
+ import { lockedWorkspaceVersions, lostWorkspaceReferences } from "../version/lockfileVersions";
9
+ import { packageDirectories, workspacePackage } from "./workspace";
10
+
11
+ const SECTIONS = ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"] as const;
12
+ type Section = typeof SECTIONS[number];
13
+ /** Sections Bun installs. Peer ranges are a published compatibility contract, so `update` never rewrites them. */
14
+ const INSTALLED: Section[] = ["dependencies", "devDependencies", "optionalDependencies"];
15
+ const SECTION_FLAGS: Record<Section, string[]> = { dependencies: [], devDependencies: ["--dev"], optionalDependencies: ["--optional"], peerDependencies: ["--peer"] };
16
+ const NAME = /^(?:@[a-z0-9~][a-z0-9._~-]*\/)?[a-z0-9~][a-z0-9._~-]*$/i;
17
+ const EXACT = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
18
+ const USAGE = `Usage:
19
+ agent-tool bun install [--cwd <dir>]
20
+ agent-tool bun add <name@version>... [--dev|--optional|--peer] [--exact] [--cwd <dir>]
21
+ agent-tool bun update <name[@version]>... [--cwd <dir>]
22
+ agent-tool bun remove <name>... [--cwd <dir>]`;
23
+
24
+ type Manifest = Record<string, unknown> & Partial<Record<Section, Record<string, string>>>;
25
+ interface Requested { name: string; spec?: string }
26
+ export interface DependencyChange { name: string; section: Section; from: string | null; to: string | null }
27
+
28
+ /** Registry versions, ranges, and tags; never a workspace, alias, Git, URL, or path source. */
29
+ export function registrySpec(spec: string): boolean { return /^[\w^~<>=*|][\w^~<>=*|.+ -]*$/.test(spec) && spec.trim() === spec; }
30
+
31
+ export function parseRequested(value: string): Requested {
32
+ const at = value.indexOf("@", value.startsWith("@") ? 1 : 0);
33
+ const name = at === -1 ? value : value.slice(0, at);
34
+ const spec = at === -1 ? undefined : value.slice(at + 1);
35
+ assert(NAME.test(name), `Invalid package name '${name}'.`);
36
+ assert(spec === undefined || (spec.trim().length > 0 && !/[\r\n\t]/.test(spec) && !spec.startsWith("-")), `Invalid version for ${name}.`);
37
+ return { name, spec };
38
+ }
39
+
40
+ function readManifest(file: string): Manifest {
41
+ const value = JSON.parse(readFileSync(file, "utf8")) as unknown;
42
+ assert(value && typeof value === "object" && !Array.isArray(value), `${file} is not a JSON object.`);
43
+ for (const section of SECTIONS) {
44
+ const entries = (value as Manifest)[section];
45
+ assert(entries === undefined || (typeof entries === "object" && !Array.isArray(entries) && Object.values(entries).every(spec => typeof spec === "string")), `${file} has an invalid ${section} section.`);
46
+ }
47
+ return value as Manifest;
48
+ }
49
+
50
+ function declarations(manifest: Manifest, name: string): { section: Section; spec: string }[] {
51
+ return SECTIONS.flatMap(section => { const spec = manifest[section]?.[name]; return spec === undefined ? [] : [{ section, spec }]; });
52
+ }
53
+
54
+ /** Compare everything except dependency sections, which callers check entry by entry. */
55
+ function otherFields(manifest: Manifest): string {
56
+ const rest: Record<string, unknown> = { ...manifest };
57
+ for (const section of SECTIONS) delete rest[section];
58
+ return JSON.stringify(rest);
59
+ }
60
+
61
+ /**
62
+ * Check that Bun changed only what the action allows. `update` may change only the
63
+ * requested versions; `remove` may only delete requested names; `add` may only create
64
+ * requested names in one section. Any other manifest change fails and is restored.
65
+ */
66
+ export function manifestViolations(action: "add" | "update" | "remove", before: Manifest, after: Manifest, names: Set<string>, addSection?: Section): string[] {
67
+ const violations: string[] = [];
68
+ if (otherFields(before) !== otherFields(after)) violations.push("package.json fields outside dependency sections changed.");
69
+ if (action === "add") for (const name of names) if (!(name in (after[addSection!] ?? {}))) violations.push(`${name} was not added to ${addSection}.`);
70
+ for (const section of SECTIONS) {
71
+ const old = before[section] ?? {}, now = after[section] ?? {};
72
+ for (const name of new Set([...Object.keys(old), ...Object.keys(now)])) {
73
+ const requested = names.has(name);
74
+ if (action === "remove" && requested) { if (name in now) violations.push(`${name} is still declared in ${section}.`); continue; }
75
+ if (action === "add" && requested && section === addSection) { if (!(name in now)) violations.push(`${name} was not added to ${section}.`); continue; }
76
+ if (action === "update" && requested && section !== "peerDependencies" && name in old) {
77
+ if (!(name in now)) violations.push(`${name} was removed from ${section}.`);
78
+ else if (!registrySpec(now[name]!)) violations.push(`${name} in ${section} no longer uses a registry version (${now[name]}).`);
79
+ continue;
80
+ }
81
+ if (!(name in old)) violations.push(`${name} was unexpectedly added to ${section}.`);
82
+ else if (!(name in now)) violations.push(`${name} was unexpectedly removed from ${section}.`);
83
+ else if (old[name] !== now[name]) violations.push(`${name} in ${section} unexpectedly changed from ${old[name]} to ${now[name]}.`);
84
+ }
85
+ }
86
+ return violations;
87
+ }
88
+
89
+ function changes(before: Manifest, after: Manifest, names: Set<string>): DependencyChange[] {
90
+ return SECTIONS.flatMap(section => [...names].flatMap(name => {
91
+ const from = before[section]?.[name] ?? null, to = after[section]?.[name] ?? null;
92
+ return from === to ? [] : [{ name, section, from, to }];
93
+ }));
94
+ }
95
+
96
+ /** Saved bytes for each file, with a way to put them back and to list which ones changed. */
97
+ function snapshot(files: string[]) {
98
+ const saved = files.map(file => ({ file, contents: existsSync(file) ? readFileSync(file) : null }));
99
+ return {
100
+ /** Restore every file even if one fails, recreating directories a script removed. */
101
+ restore() {
102
+ const failures: string[] = [];
103
+ for (const { file, contents } of saved) {
104
+ try {
105
+ if (contents) { mkdirSync(path.dirname(file), { recursive: true }); writeFileSync(file, contents); }
106
+ else if (existsSync(file)) unlinkSync(file);
107
+ } catch (error) { failures.push(`${file}: ${(error as Error).message}`); }
108
+ }
109
+ if (failures.length) throw new Error(`Could not restore ${failures.join("; ")}`);
110
+ },
111
+ changed(): string[] {
112
+ return saved.filter(({ file, contents }) => {
113
+ const now = existsSync(file) ? readFileSync(file) : null;
114
+ return !(now === null ? contents === null : contents !== null && now.equals(contents));
115
+ }).map(({ file }) => file);
116
+ },
117
+ };
118
+ }
119
+
120
+ function runBun(bun: BunExecutable, directory: string, args: string[]) {
121
+ const { log, fd } = createLog("agent-tool-bun-");
122
+ const started = performance.now();
123
+ try {
124
+ const result = spawnSync(bun.path, args, { cwd: directory, stdio: ["ignore", fd, fd], env: process.env });
125
+ const exitCode = result.error ? 126 : result.status ?? 1;
126
+ return { command: ["bun", ...args], exitCode, signal: result.signal ?? null, durationMs: Math.round(performance.now() - started), outputBytes: fstatSync(fd).size, log };
127
+ } finally { closeSync(fd); }
128
+ }
129
+
130
+ function parseArgs(action: string, args: string[]) {
131
+ const sections = (["--dev", "--optional", "--peer"] as const).filter(name => flag(args, name));
132
+ const exact = flag(args, "--exact");
133
+ for (const value of args) assert(!value.startsWith("-"), `Unknown bun ${action} argument '${value}'.\n${USAGE}`);
134
+ if (action === "add") assert(sections.length <= 1, "Choose at most one of --dev, --optional, and --peer.");
135
+ else assert(!sections.length && !exact, `bun ${action} keeps each dependency's section and pin style; section and --exact flags apply to add.`);
136
+ const requested = args.map(parseRequested);
137
+ assert(action === "install" ? !requested.length : requested.length > 0, action === "install" ? "bun install takes no packages; add one with `agent-tool bun add`." : USAGE);
138
+ assert(new Set(requested.map(item => item.name)).size === requested.length, "Each package may be named once.");
139
+ const addSection: Section = sections[0] === "--dev" ? "devDependencies" : sections[0] === "--optional" ? "optionalDependencies" : sections[0] === "--peer" ? "peerDependencies" : "dependencies";
140
+ return { requested, exact, addSection };
141
+ }
142
+
143
+ /** Plan the Bun invocations for one action, refusing anything outside its permission class. */
144
+ function plan(action: "add" | "update" | "remove", manifest: Manifest, requested: Requested[], exact: boolean, addSection: Section, workspaceNames: Set<string>): string[][] {
145
+ if (action === "add") {
146
+ for (const { name, spec } of requested) {
147
+ assert(!declarations(manifest, name).length, `${name} is already declared; use \`agent-tool bun update ${name}\` to change its version.`);
148
+ assert(spec && (registrySpec(spec) || /^workspace:[^\s]*$/.test(spec)), `Name an explicit registry or workspace version to add ${name}, for example ${name}@1.2.3.`);
149
+ }
150
+ return [["add", ...SECTION_FLAGS[addSection], ...(exact ? ["--exact"] : []), ...requested.map(({ name, spec }) => `${name}@${spec}`)]];
151
+ }
152
+ if (action === "remove") {
153
+ for (const { name, spec } of requested) {
154
+ assert(spec === undefined, `Name only the package to remove: ${name}.`);
155
+ assert(declarations(manifest, name).length, `${name} is not declared in this package.json; nothing to remove.`);
156
+ }
157
+ return [["remove", ...requested.map(({ name }) => name)]];
158
+ }
159
+ // Group by section and pin style so each Bun call keeps both unchanged.
160
+ const groups = new Map<string, string[]>();
161
+ for (const { name, spec } of requested) {
162
+ const installed = declarations(manifest, name).filter(item => INSTALLED.includes(item.section));
163
+ assert(installed.length, `${name} is not an installed dependency here; adding a package needs \`agent-tool bun add\`.`);
164
+ // A version change can move a workspace reference to the registry package of the same name.
165
+ assert(!workspaceNames.has(name), `${name} is a workspace package; change references to it by hand.`);
166
+ assert(installed.length === 1, `${name} is declared in ${installed.map(item => item.section).join(" and ")}; update it manually.`);
167
+ const current = installed[0]!;
168
+ assert(registrySpec(current.spec), `${name} uses a non-registry source (${current.spec}); update it manually.`);
169
+ assert(spec === undefined || registrySpec(spec), `${name} can only be updated to a registry version or tag.`);
170
+ // An explicit version is written as given; moving to latest keeps an exact pin exact.
171
+ const flags = [...SECTION_FLAGS[current.section], ...(EXACT.test(spec ?? current.spec) ? ["--exact"] : [])];
172
+ const key = flags.join(" ");
173
+ groups.set(key, [...(groups.get(key) ?? []), `${name}@${spec ?? "latest"}`]);
174
+ }
175
+ return [...groups].map(([flags, packages]) => ["add", ...(flags ? flags.split(" ") : []), ...packages]);
176
+ }
177
+
178
+ export function bunCommand(args: string[]): number {
179
+ const action = args.shift();
180
+ assert(action === "install" || action === "add" || action === "update" || action === "remove", USAGE);
181
+ const target = path.resolve(option(args, "--cwd") ?? ".");
182
+ assert(existsSync(path.join(target, "package.json")), `No package.json in ${target}.`);
183
+ const { root, directory } = workspacePackage(target);
184
+ const file = path.join(directory, "package.json");
185
+ const { requested, exact, addSection } = parseArgs(action, args);
186
+ const bun = pinnedBun(directory, root);
187
+ const bunInfo = { path: bun.path, version: bun.version, source: bun.source, pinned: bun.pinned };
188
+ const before = readManifest(file);
189
+ const lockfiles = [path.join(root, "bun.lock"), path.join(root, "bun.lockb")];
190
+ // Workspaces share the root lockfile; Bun creating one beside any package means it was treated as standalone.
191
+ const nested = packageDirectories(root).filter(item => item !== root).flatMap(item => [path.join(item, "bun.lock"), path.join(item, "bun.lockb")]);
192
+ // Without a lockfile, Bun resolves whatever package.json names, including hand-added packages.
193
+ assert(lockfiles.some(item => existsSync(item)), "No bun.lock was found; a first install resolves new packages, so run it directly with approval.");
194
+ // Before a change, the lockfile must already cover every declaration, or Bun would also resolve a hand-added package.
195
+ const preflight = ["install", "--frozen-lockfile", "--lockfile-only"];
196
+ const workspaceNames = new Set(packageDirectories(root).map(item => (JSON.parse(readFileSync(path.join(item, "package.json"), "utf8")) as { name?: string }).name).filter((name): name is string => typeof name === "string"));
197
+ const calls = action === "install" ? [["install", "--frozen-lockfile"]] : [preflight, ...plan(action, before, requested, exact, addSection, workspaceNames)];
198
+ // The checks below read the text lockfile; a binary bun.lockb cannot be verified.
199
+ if (action !== "install") assert(existsSync(path.join(root, "bun.lock")), "Changing dependencies needs a text bun.lock; convert bun.lockb with approval first.");
200
+ if (action !== "install") {
201
+ // A frozen check can accept stale workspace versions; an unfrozen call would then fetch such a reference.
202
+ const lost = lostWorkspaceReferences(root, lockedWorkspaceVersions(root));
203
+ assert(!lost.length, `${lost.join(", ")} no longer match their workspaces, so Bun would fetch them from the registry; fix those references before changing dependencies.`);
204
+ }
205
+ // Lifecycle scripts can touch any workspace manifest; every one but the target must stay as it was.
206
+ const others = packageDirectories(root).map(item => path.join(item, "package.json")).filter(item => item !== file && existsSync(item));
207
+ const othersBefore = new Map(others.map(item => [item, JSON.stringify(readManifest(item))]));
208
+ const restored = [...new Set([file, ...others, ...lockfiles, ...nested])];
209
+ const saved = snapshot(restored);
210
+ const names = new Set(requested.map(item => item.name));
211
+ const runs = [];
212
+ let violations: string[] = [];
213
+ let after = before;
214
+ // Validate after every Bun call: a lifecycle script in one call must not feed the next.
215
+ const known = new Set([file, ...others]);
216
+ // A lifecycle script can create a workspace, or widen the globs to one, for a later call to resolve.
217
+ const discover = (): string[] => packageDirectories(root).map(item => path.join(item, "package.json")).filter(item => !known.has(item) && existsSync(item));
218
+ // Call after restoring the root manifest: under the original globs, every manifest that existed
219
+ // before is known, so anything else was created during the change. Widened globs never delete.
220
+ const removeCreated = () => { for (const item of discover()) unlinkSync(item); };
221
+ const validate = (call: string[]): string[] => {
222
+ const appeared = discover();
223
+ if (appeared.length) return appeared.map(item => `${path.relative(root, item)} appeared during the change.`);
224
+ if (call === preflight || action === "install") return saved.changed().map(item => `${path.relative(directory, item) || "package.json"} changed during a frozen install.`);
225
+ after = readManifest(file);
226
+ const found = manifestViolations(action, before, after, names, addSection);
227
+ for (const item of others) if (JSON.stringify(readManifest(item)) !== othersBefore.get(item)) found.push(`${path.relative(root, item)} changed outside the target package.`);
228
+ for (const item of saved.changed().filter(changed => nested.includes(changed))) found.push(`${path.relative(root, item)} changed beside a workspace package.`);
229
+ return found;
230
+ };
231
+ try {
232
+ for (const call of calls) {
233
+ const run = runBun(bun, directory, call);
234
+ runs.push(run);
235
+ if (run.exitCode !== 0) { violations = [call === preflight ? "package.json and the lockfile disagree; resolve that difference with approval before changing dependencies." : `bun ${call[0]} failed with exit ${run.exitCode}.`]; break; }
236
+ violations = validate(call);
237
+ if (violations.length) break;
238
+ }
239
+ } catch (error) {
240
+ saved.restore();
241
+ removeCreated();
242
+ throw error;
243
+ }
244
+ // Leave no partial dependency change behind; node_modules is rebuilt by the next frozen install.
245
+ if (violations.length) {
246
+ saved.restore();
247
+ removeCreated();
248
+ }
249
+ output({ ok: !violations.length, action, directory, bun: bunInfo, changes: violations.length ? [] : changes(before, after, names), ...(violations.length ? { violations, restored: restored.map(item => path.relative(directory, item) || "package.json") } : {}), runs });
250
+ if (runs.at(-1)?.exitCode) printLogTail(runs.at(-1)!.log, DEFAULT_LIMITS, 2);
251
+ return violations.length ? 1 : 0;
252
+ }
@@ -0,0 +1,58 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { accessSync, constants, existsSync, readFileSync, realpathSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import path from "node:path";
5
+ import { outsideExecutable } from "../review/executable";
6
+ import { assert } from "../workflows/core";
7
+
8
+ export interface BunExecutable { path: string; version: string; source: "mise" | "path"; pinned: string | null }
9
+
10
+ /** The nearest mise config's bun pin, read the way `toolchain check` reads `.mise.toml`. */
11
+ export function misePin(directory: string): { file: string; version: string } | null {
12
+ for (let current = path.resolve(directory); ; current = path.dirname(current)) {
13
+ for (const name of [".mise.toml", "mise.toml"]) {
14
+ const file = path.join(current, name);
15
+ if (!existsSync(file)) continue;
16
+ const tools = (Bun.TOML.parse(readFileSync(file, "utf8")) as { tools?: Record<string, unknown> }).tools ?? {};
17
+ const pin = tools.bun;
18
+ if (pin === undefined) continue;
19
+ assert(typeof pin === "string" && /^[0-9][0-9A-Za-z.+-]*$/.test(pin), `Unsupported bun pin in ${file}; use a version string.`);
20
+ return { file, version: pin };
21
+ }
22
+ if (current === path.dirname(current)) return null;
23
+ }
24
+ }
25
+
26
+ function versionOf(executable: string): string {
27
+ const result = spawnSync(executable, ["--version"], { encoding: "utf8", timeout: 10000 });
28
+ assert(result.status === 0, `Could not run ${executable} --version.`);
29
+ return result.stdout.trim();
30
+ }
31
+
32
+ function runnable(file: string): boolean {
33
+ try { accessSync(file, constants.X_OK); return true; } catch { return false; }
34
+ }
35
+
36
+ /**
37
+ * Resolve the Bun a checkout pins, without relying on the agent shell's PATH.
38
+ * Agent shells do not activate mise, so the pinned install is used directly when present.
39
+ */
40
+ export function pinnedBun(directory: string, root: string): BunExecutable {
41
+ const pin = misePin(directory)?.version ?? null;
42
+ const inside = (file: string) => { const relative = path.relative(realpathSync(root), realpathSync(file)); return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative); };
43
+ if (pin) {
44
+ const data = process.env.MISE_DATA_DIR || path.join(process.env.XDG_DATA_HOME || path.join(homedir(), ".local/share"), "mise");
45
+ const candidate = path.join(data, "installs", "bun", pin, "bin", "bun");
46
+ if (runnable(candidate) && !inside(candidate)) {
47
+ const version = versionOf(candidate);
48
+ assert(version === pin || version.startsWith(`${pin}.`), `${candidate} reports Bun ${version}, not the pinned ${pin}; reinstall it with mise.`);
49
+ return { path: candidate, version, source: "mise", pinned: pin };
50
+ }
51
+ }
52
+ // A `bun` inside the checkout (node_modules/.bin is on PATH under `bun run`) is repository code; never run it.
53
+ const found = outsideExecutable("bun", process.env, root)?.candidate;
54
+ assert(found, "No Bun outside this checkout is on PATH.");
55
+ const version = versionOf(found);
56
+ assert(!pin || version === pin || version.startsWith(`${pin}.`), `Bun ${pin} from the mise config is not installed, and PATH has ${version}. Install the pinned Bun before changing dependencies.`);
57
+ return { path: found, version, source: "path", pinned: pin };
58
+ }
@@ -0,0 +1,38 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { assert } from "../workflows/core";
5
+
6
+ /** The checkout root, where the workspace manifest and its lockfile live. */
7
+ export function projectRoot(directory: string): string {
8
+ const result = spawnSync("git", ["rev-parse", "--show-toplevel"], { cwd: directory, encoding: "utf8" });
9
+ assert(result.status === 0, "Run Bun commands inside a Git checkout.");
10
+ return realpathSync(result.stdout.trim());
11
+ }
12
+
13
+ /** The root package and its declared Bun workspaces: the packages that share the root lockfile. */
14
+ export function packageDirectories(root: string): string[] {
15
+ const manifest = path.join(root, "package.json");
16
+ if (!existsSync(manifest)) return [];
17
+ const value = JSON.parse(readFileSync(manifest, "utf8")) as { workspaces?: unknown };
18
+ const patterns = Array.isArray(value.workspaces) ? value.workspaces : (value.workspaces as { packages?: unknown } | undefined)?.packages;
19
+ const list = (Array.isArray(patterns) ? patterns : []).filter((pattern): pattern is string => typeof pattern === "string");
20
+ // `!pattern` entries exclude directories that the other patterns match.
21
+ const excluded = list.filter(pattern => pattern.startsWith("!")).map(pattern => new Bun.Glob(pattern.slice(1).replace(/\/$/, "")));
22
+ const directories = new Set([root]);
23
+ for (const pattern of list.filter(pattern => !pattern.startsWith("!"))) {
24
+ for (const match of new Bun.Glob(`${pattern.replace(/\/$/, "")}/package.json`).scanSync({ cwd: root, onlyFiles: true })) {
25
+ const relative = path.dirname(match);
26
+ if (!match.split("/").includes("node_modules") && !excluded.some(glob => glob.match(relative))) directories.add(path.join(root, relative));
27
+ }
28
+ }
29
+ return [...directories];
30
+ }
31
+
32
+ /** Resolve a package directory that belongs to the checkout's workspace, refusing standalone packages. */
33
+ export function workspacePackage(directory: string): { root: string; directory: string } {
34
+ const resolved = realpathSync(directory);
35
+ const root = projectRoot(resolved);
36
+ assert(packageDirectories(root).some(item => realpathSync(item) === resolved), "Use the root package or a declared workspace; standalone packages keep their own lockfiles.");
37
+ return { root, directory: resolved };
38
+ }
package/src/cli.ts CHANGED
@@ -8,6 +8,8 @@ import { review } from "./harnesses";
8
8
  import { runAgentToolAction } from "./runAgentToolAction";
9
9
  import { BUNDLED_SKILLS } from "./skills/bundled";
10
10
  import { checkSkills, installSkills } from "./skills/install";
11
+ import { bunCommand } from "./bun/dependencies";
12
+ import { checkPermissions, installPermissions, showPermissions } from "./permissions/install";
11
13
  import { bumpVersions, checkVersions, planVersions } from "./version/bumpVersions";
12
14
  import { prepareVersions } from "./version/prepareVersions";
13
15
  import { resolveVersionConflicts } from "./version/resolveVersionConflicts";
@@ -56,6 +58,13 @@ Usage: agent-tool [--repo <directory>] [--config <file>] <command>
56
58
  skills list
57
59
  skills install [--harness <all|claude|codex|opencode>] [--apply]
58
60
  skills check [--harness <all|claude|codex|opencode>]
61
+ bun install [--cwd <dir>] Frozen-lockfile install with the pinned Bun
62
+ bun add <name@version>... [--dev|--optional|--peer] [--exact] [--cwd <dir>]
63
+ bun update <name[@version]>... [--cwd <dir>] Change versions of declared dependencies only
64
+ bun remove <name>... [--cwd <dir>] Remove declared dependencies only
65
+ permissions show [--harness <all|claude|codex>]
66
+ permissions install [--harness <all|claude|codex>] [--apply]
67
+ permissions check [--harness <all|claude|codex>]
59
68
  images list [target...] JSON files each image target writes
60
69
  images build [target...] [--out <dir>] Render assets/brand/*.svg per agent-tool.json
61
70
  init Create agent-tool.json without overwriting
@@ -147,6 +156,25 @@ export function main(argv = process.argv.slice(2)): number | Promise<number> {
147
156
  }
148
157
  output({ applied: apply, changes: installSkills(process.cwd(), target, apply) }); return 0;
149
158
  }
159
+ if (command === "bun") return bunCommand(args);
160
+ if (command === "permissions") {
161
+ const action = args.shift();
162
+ usage(action === "show" || action === "install" || action === "check", "Usage: agent-tool permissions <show|install|check> [--harness <all|claude|codex>] [--apply]");
163
+ let target = "all", apply = false;
164
+ while (args.length) {
165
+ const flag = args.shift();
166
+ if (flag === "--apply") { usage(action === "install" && !apply, "--apply applies once, to permissions install."); apply = true; }
167
+ else if (flag === "--harness") { const value = args.shift(); usage(!!value, "--harness requires a name."); target = value!; }
168
+ else throw new Error(`Unknown permissions ${action} argument '${flag}'.`);
169
+ }
170
+ if (action === "show") { writeSync(1, showPermissions(target)); return 0; }
171
+ if (action === "check") {
172
+ const changes = checkPermissions(process.cwd(), target);
173
+ const ok = changes.every(change => change.status === "unchanged");
174
+ output({ ok, changes }); return ok ? 0 : 1;
175
+ }
176
+ output({ applied: apply, changes: installPermissions(process.cwd(), target, apply) }); return 0;
177
+ }
150
178
  const root = gitRoot();
151
179
  process.chdir(root);
152
180
  loadConfig(root); // Validate policy before running a reviewer or mutating GitHub.
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Permission classes for agent-tool's own commands. Harness rules are generated from
3
+ * this table, so a command's risk is decided once, next to the code, instead of in
4
+ * every harness configuration.
5
+ */
6
+ export const CLASSES = {
7
+ read: { decision: "allow", description: "Reads local or GitHub state without changing it." },
8
+ local: { decision: "allow", description: "Changes only this checkout: files, local branches, lockfile installs, and versions of declared dependencies." },
9
+ share: { decision: "allow", description: "Publishes work for review without changing a shared branch: pushes a feature branch, opens a PR, or answers review threads." },
10
+ release: { decision: "ask", description: "Merges into a shared branch, publishes a package, or deletes remote state." },
11
+ intake: { decision: "ask", description: "Brings new third-party code into the repository." },
12
+ policy: { decision: "ask", description: "Changes the permissions agents run with." },
13
+ } as const;
14
+ export type PermissionClass = keyof typeof CLASSES;
15
+ export type Decision = "allow" | "ask";
16
+
17
+ /** `applyClass` covers the same command with `--apply`; harnesses without flag matching use the stricter class. */
18
+ export interface CatalogEntry { command: string; class: PermissionClass; applyClass?: PermissionClass }
19
+
20
+ export const CATALOG: CatalogEntry[] = [
21
+ ...["--help", "--version", "logs", "runs list", "doctor", "config show", "branch check", "branch status", "commits check", "checks binaries", "repo context", "policy check", "toolchain check", "ci gate", "release plan", "release verify", "pr context", "pr checks", "pr preflight", "pr threads list", "versions plan", "versions check", "dependencies check-terraform-plan", "skills list", "skills check", "hooks check", "images list", "permissions show", "permissions check"]
22
+ .map(command => ({ command, class: "read" as const })),
23
+ ...["bun install", "bun update", "bun remove", "branch create", "versions bump", "versions prepare", "versions resolve-conflicts", "images build", "review", "init"]
24
+ .map(command => ({ command, class: "local" as const })),
25
+ { command: "skills install", class: "read", applyClass: "local" },
26
+ { command: "hooks install", class: "read", applyClass: "local" },
27
+ { command: "permissions install", class: "read", applyClass: "policy" },
28
+ ...["branch push", "pr open", "pr threads reply", "pr threads resolve"].map(command => ({ command, class: "share" as const })),
29
+ { command: "pr merge", class: "release" },
30
+ { command: "repo cleanup", class: "read", applyClass: "release" },
31
+ { command: "bun add", class: "intake" },
32
+ // Legacy action names stay classified so a broad user rule cannot reach them unclassified.
33
+ ...["checkVersions"].map(command => ({ command, class: "read" as const })),
34
+ ...["bumpVersions", "resolveVersionConflicts", "solicitClaudeCodeReview", "solicitCodexReview", "solicitOpencodeReview"].map(command => ({ command, class: "local" as const })),
35
+ { command: "openPr", class: "share" },
36
+ { command: "squashMerge", class: "release" },
37
+ ];
38
+ // `run` and `run-group` execute arbitrary commands, so they stay unclassified and keep the harness default.
39
+
40
+ /** Direct spellings of classified actions. Asking here keeps a raw command from bypassing its agent-tool form. */
41
+ export const GUARDS: { command: string; class: PermissionClass }[] = [
42
+ ...["bun add", "bun a", "npm install", "npm i", "npm add", "pnpm add", "yarn add"].map(command => ({ command, class: "intake" as const })),
43
+ ...["gh pr merge", "npm publish", "bun publish"].map(command => ({ command, class: "release" as const })),
44
+ ];
45
+
46
+ /** How agents invoke an installed agent-tool: the package script, the bin link, or PATH. */
47
+ export const INVOCATIONS = ["bun run agent-tool", "node_modules/.bin/agent-tool", "agent-tool"];
48
+
49
+ const rank = (value: PermissionClass) => CLASSES[value].decision === "ask" ? 1 : 0;
50
+ export function strictest(entry: CatalogEntry): PermissionClass {
51
+ return entry.applyClass && rank(entry.applyClass) > rank(entry.class) ? entry.applyClass : entry.class;
52
+ }
53
+
54
+ export interface ClaudePermissions { allow: string[]; ask: string[] }
55
+
56
+ /** Claude Code matches `*` across spaces and splits compound commands before matching. */
57
+ export function claudePermissions(): ClaudePermissions {
58
+ const rules: ClaudePermissions = { allow: [], ask: [] };
59
+ for (const invocation of INVOCATIONS) {
60
+ for (const entry of CATALOG) {
61
+ const base = `${invocation} ${entry.command}`;
62
+ rules[CLASSES[entry.class].decision].push(entry.command.startsWith("--") ? `Bash(${base})` : `Bash(${base} *)`);
63
+ if (entry.applyClass && CLASSES[entry.applyClass].decision !== CLASSES[entry.class].decision) rules[CLASSES[entry.applyClass].decision].push(`Bash(${base} *--apply*)`);
64
+ }
65
+ }
66
+ for (const guard of GUARDS) rules[CLASSES[guard.class].decision].push(`Bash(${guard.command} *)`);
67
+ return rules;
68
+ }
69
+
70
+ const quote = (value: string) => JSON.stringify(value);
71
+ const element = (values: string[]) => values.length === 1 ? quote(values[0]!) : `[${values.map(quote).join(", ")}]`;
72
+
73
+ /** Codex prefix rules: `allow` runs outside the sandbox without a prompt; `prompt` always asks. */
74
+ export function codexRules(): string {
75
+ const lines = [
76
+ "# Managed by agent-tool; regenerate with `agent-tool permissions install --apply`.",
77
+ "# Codex applies the most restrictive matching rule. Commands with an --apply form",
78
+ "# use their stricter class here because prefix rules cannot match trailing flags.",
79
+ ];
80
+ const groups = new Map<string, { prefix: string[]; last: string[]; class: PermissionClass }>();
81
+ const add = (tokens: string[], value: PermissionClass) => {
82
+ const prefix = tokens.slice(0, -1), key = `${value}\0${prefix.join("\0")}`;
83
+ const group = groups.get(key) ?? { prefix, last: [], class: value };
84
+ group.last.push(tokens.at(-1)!); groups.set(key, group);
85
+ };
86
+ for (const invocation of INVOCATIONS) for (const entry of CATALOG) add([...invocation.split(" "), ...entry.command.split(" ")], strictest(entry));
87
+ for (const guard of GUARDS) add(guard.command.split(" "), guard.class);
88
+ for (const value of Object.keys(CLASSES) as PermissionClass[]) {
89
+ const rules = [...groups.values()].filter(group => group.class === value);
90
+ if (!rules.length) continue;
91
+ lines.push("", `# ${value}: ${CLASSES[value].description}`);
92
+ for (const rule of rules) {
93
+ const pattern = [...rule.prefix.map(quote), element(rule.last)].join(", ");
94
+ const reason = CLASSES[value].decision === "ask" ? `decision="prompt", justification=${quote(CLASSES[value].description)}` : `decision="allow"`;
95
+ lines.push(`prefix_rule(pattern=[${pattern}], ${reason}, match=[${quote([...rule.prefix, rule.last[0]!].join(" "))}])`);
96
+ }
97
+ }
98
+ return `${lines.join("\n")}\n`;
99
+ }
@@ -0,0 +1,126 @@
1
+ import { createHash } from "node:crypto";
2
+ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { claudePermissions, codexRules, type ClaudePermissions } from "./catalog";
5
+
6
+ const MANIFEST = ".agent-tool-permissions.json";
7
+ const CLAUDE_SETTINGS = ".claude/settings.json";
8
+ const CODEX_RULES = ".codex/rules/agent-tool.rules";
9
+ const HARNESSES = ["claude", "codex"] as const;
10
+ type Harness = typeof HARNESSES[number];
11
+ const hash = (source: string) => createHash("sha256").update(source).digest("hex");
12
+
13
+ interface Manifest { schemaVersion: 1; claude?: ClaudePermissions; codex?: { file: string; sha256: string } }
14
+ export interface PermissionChange { path: string; status: "create" | "update" | "unchanged" }
15
+ export interface PermissionCheck { path: string; status: "missing" | "outdated" | "unmanaged" | "unchanged" }
16
+
17
+ export function harnesses(target: string): Harness[] {
18
+ if (target === "all") return [...HARNESSES];
19
+ if (!(HARNESSES as readonly string[]).includes(target)) throw new Error(`Unknown permissions harness '${target}'; use all, claude, or codex.`);
20
+ return [target as Harness];
21
+ }
22
+
23
+ function destination(root: string, relative: string): string {
24
+ let current = root;
25
+ for (const part of relative.split("/")) {
26
+ current = path.join(current, part);
27
+ try { if (lstatSync(current).isSymbolicLink()) throw new Error(`Refusing permissions destination symlink: ${current}`); }
28
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
29
+ }
30
+ return current;
31
+ }
32
+
33
+ function atomicWrite(file: string, contents: string): void {
34
+ mkdirSync(path.dirname(file), { recursive: true });
35
+ const temporary = `${file}.agent-tool-${process.pid}.tmp`;
36
+ // Keep an existing file's mode: settings can hold private environment values.
37
+ const mode = existsSync(file) ? statSync(file).mode & 0o777 : 0o644;
38
+ try { writeFileSync(temporary, contents, { flag: "wx", mode }); chmodSync(temporary, mode); renameSync(temporary, file); }
39
+ finally { try { unlinkSync(temporary); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } }
40
+ }
41
+
42
+ function readManifest(root: string): Manifest {
43
+ const file = destination(root, MANIFEST);
44
+ if (!existsSync(file)) return { schemaVersion: 1 };
45
+ const value = JSON.parse(readFileSync(file, "utf8")) as Manifest;
46
+ const strings = (items: unknown) => Array.isArray(items) && items.every(item => typeof item === "string");
47
+ if (value.schemaVersion !== 1 || (value.claude && !(strings(value.claude.allow) && strings(value.claude.ask))) || (value.codex && !(typeof value.codex.file === "string" && /^[a-f0-9]{64}$/.test(value.codex.sha256)))) throw new Error("Invalid managed permissions manifest.");
48
+ return value;
49
+ }
50
+
51
+ type Settings = Record<string, unknown> & { permissions?: Record<string, unknown> };
52
+ function readSettings(file: string): Settings {
53
+ if (!existsSync(file)) return {};
54
+ const value = JSON.parse(readFileSync(file, "utf8")) as unknown;
55
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${CLAUDE_SETTINGS} is not a JSON object.`);
56
+ const permissions = (value as Settings).permissions;
57
+ if (permissions !== undefined && (typeof permissions !== "object" || Array.isArray(permissions))) throw new Error(`${CLAUDE_SETTINGS} has an invalid permissions object.`);
58
+ for (const key of ["allow", "ask"]) {
59
+ const list = permissions?.[key];
60
+ if (list !== undefined && !(Array.isArray(list) && list.every(item => typeof item === "string"))) throw new Error(`${CLAUDE_SETTINGS} permissions.${key} must be a string array.`);
61
+ }
62
+ return value as Settings;
63
+ }
64
+
65
+ /** Replace only entries this installer owns, keeping every other setting and rule in place. */
66
+ function mergedSettings(settings: Settings, previous: ClaudePermissions | undefined, current: ClaudePermissions): Settings {
67
+ const permissions: Record<string, unknown> = { ...settings.permissions };
68
+ for (const key of ["allow", "ask"] as const) {
69
+ const owned = new Set(previous?.[key] ?? []);
70
+ const kept = ((permissions[key] as string[] | undefined) ?? []).filter(item => !owned.has(item) && !current[key].includes(item));
71
+ permissions[key] = [...kept, ...current[key]];
72
+ }
73
+ return { ...settings, permissions };
74
+ }
75
+
76
+ interface Pending { harness: Harness; relative: string; file: string; contents: string; status: PermissionChange["status"] }
77
+
78
+ function pending(root: string, targets: Harness[], manifest: Manifest): Pending[] {
79
+ return targets.map(harness => {
80
+ const relative = harness === "claude" ? CLAUDE_SETTINGS : CODEX_RULES;
81
+ const file = destination(root, relative);
82
+ const exists = existsSync(file);
83
+ if (exists && !lstatSync(file).isFile()) throw new Error(`Permissions destination is not a regular file: ${relative}`);
84
+ const current = exists ? readFileSync(file, "utf8") : undefined;
85
+ let contents: string;
86
+ if (harness === "claude") contents = `${JSON.stringify(mergedSettings(readSettings(file), manifest.claude, claudePermissions()), null, 2)}\n`;
87
+ else {
88
+ contents = codexRules();
89
+ if (current !== undefined && current !== contents && hash(current) !== manifest.codex?.sha256) throw new Error(`Locally edited or unmanaged rules: ${relative}. Preserve them before installing.`);
90
+ }
91
+ return { harness, relative, file, contents, status: current === contents ? "unchanged" : current === undefined ? "create" : "update" };
92
+ });
93
+ }
94
+
95
+ export function installPermissions(rootDir: string, target: string, apply = false): PermissionChange[] {
96
+ const root = realpathSync(rootDir);
97
+ const manifest = readManifest(root);
98
+ const changes = pending(root, harnesses(target), manifest);
99
+ // Every destination is validated before any file is written.
100
+ if (apply) {
101
+ const next: Manifest = { ...manifest };
102
+ for (const change of changes) {
103
+ if (change.status !== "unchanged") atomicWrite(change.file, change.contents);
104
+ if (change.harness === "claude") next.claude = claudePermissions();
105
+ else next.codex = { file: change.relative, sha256: hash(change.contents) };
106
+ }
107
+ atomicWrite(destination(root, MANIFEST), `${JSON.stringify(next, null, 2)}\n`);
108
+ }
109
+ return changes.map(({ relative, status }) => ({ path: relative, status }));
110
+ }
111
+
112
+ /** Read-only CI gate: generated rules are current and recorded as managed. */
113
+ export function checkPermissions(rootDir: string, target: string): PermissionCheck[] {
114
+ const root = realpathSync(rootDir);
115
+ const manifest = readManifest(root);
116
+ return pending(root, harnesses(target), manifest).map(change => {
117
+ if (change.status === "create") return { path: change.relative, status: "missing" };
118
+ if (change.status === "update") return { path: change.relative, status: "outdated" };
119
+ const managed = change.harness === "claude" ? JSON.stringify(manifest.claude) === JSON.stringify(claudePermissions()) : manifest.codex?.sha256 === hash(change.contents);
120
+ return { path: change.relative, status: managed ? "unchanged" : "unmanaged" };
121
+ });
122
+ }
123
+
124
+ export function showPermissions(target: string): string {
125
+ return harnesses(target).map(harness => harness === "claude" ? `${JSON.stringify({ permissions: claudePermissions() }, null, 2)}\n` : codexRules()).join("\n");
126
+ }
@@ -5,5 +5,5 @@ export const BUNDLED_SKILLS: Readonly<Record<string, string>> = {
5
5
  "reset": "---\nname: reset\ndescription: Return a clean checkout to its updated default branch after confirmed shipping or an explicit checkout cleanup request.\n---\n\n# Reset\n\nThis is checkout cleanup, not a destructive Git reset. Read repository guidance\nfor setup and hooks. Determine the default branch through the repository's\nremote metadata rather than assuming main.\n\nFor post-merge cleanup, preview with\n`agent-tool repo cleanup <PR-number> <shipped-head>`, then run it with `--apply`\nwithin the authorized cleanup scope. It requires a confirmed MERGED PR, preserves\nlocal edits, checks exact local/remote feature commits, verifies the merge is on\nthe default branch, and fast-forwards that branch. By default it prunes the\nshipped local feature branch and deletes the matching remote branch if present.\nLocal pruning also applies after a squash merge or when GitHub already deleted\nthe remote branch; a missing remote is not evidence that local cleanup is done.\nPass `--keep-branch` when the user asked to preserve both feature refs.\nRead the receipt's completed steps if a later operation fails; do not claim full\ncleanup from a partial result. Verify the final branch and clean worktree, and\ncheck that `git branch --list <feature-branch>` is empty unless keeping the branch.\nIf the feature branch is checked out in another worktree, shared cleanup reports\na partial failure after updating the default branch and leaves both feature refs\nin place; preserve that worktree and report the remaining cleanup.\n\nRemote branch deletion uses GitHub REST after checking the live ref to avoid\npre-push hooks. The API has no atomic expected-OID delete guard; the check and\ndeletion retain a small concurrent-update window. Fork PRs require manual remote\nhandling and are refused by the shared cleanup command.\nUse a repository's stronger cleanup procedure when required, including atomic\nremote deletion or additional local verification; do not weaken it to use the\nshared command. The same local pruning and keep-branch outcomes still apply.\n\nFor an explicit checkout cleanup without a merged PR, preserve all feature refs:\ncheck worktree/index, resolve the default branch with `repo context`, fetch it,\nswitch, and update by fast-forward only. Never discard or automatically stash\nedits, force a diverged default branch, or infer shipping from Git ancestry after\na squash merge.\n\nRun the repository's documented dependency/hook setup if required. When copied\nhooks are configured, use `agent-tool hooks check` and `hooks install --apply`;\ninspect unmanaged hooks before an explicit `--adopt`, which backs them up.\nDo not assume a package manager. Report final branch, commit, whether the shipped\nlocal branch was pruned or preserved, and unfinished cleanup or setup.\n",
6
6
  "ship-pr": "---\nname: ship-pr\ndescription: Complete an authorized commit, independent review, repair, PR, CI, and squash-merge workflow for a GitHub repository.\n---\n\n# Ship PR\n\nUse the installed `agent-tool`. Read repository guidance and policy first;\nvalidation, versioning, package management, and hook bodies remain project\nconcerns. Use shared branch, hook, check, cleanup, and release commands where\nconfigured; keep application-specific commands in repository guidance. Shipping authorizes this workflow within the user's stated scope.\nPreserve unrelated edits and honor requested stopping points such as open-PR\nonly, report-only review, or keeping the feature branch.\n\nUse `agent-tool run --label <check> -- <command> [args...]` for noisy setup,\nvalidation, builds, and hook-running Git commands. It preserves exit status and\ncomplete private logs, prints one success receipt, and shows a bounded failure\ntail. Inspect more with `agent-tool logs <log> --max-bytes 8192 --tail-lines 80`\nor a targeted search; never dump an entire suite log or pipe checks into `tail`.\nThe receipt proves process completion, not absence of warnings: inspect required\nwarning, coverage, timing, or artifact evidence before accepting a check. Keep\nmachine-readable receipts and review findings visible by running `versions\nprepare`, `review`, and other commands whose output drives a decision directly.\nPrefer documented compact test reporters when they preserve required evidence;\ndo not reduce test scope, skip checks, silence warnings, or mask failures.\n\n1. Finish the requested change on a feature branch. Use\n `agent-tool branch create <branch> --base <base-branch> --remote <remote>`\n from a clean checkout; it fetches and pins the base, checks naming policy and\n collisions, and creates without a local upstream. Use `branch check` with\n explicit destination names in CI or hooks.\n Validate it with appropriate project checks and commit only the intended\n files.\n2. Use `agent-tool repo context --fetch` to resolve the PR's base repository\n and branch, or the default branch when no PR exists, and fetch its exact base.\n Record the returned base/head identities. Integrate the fetched base\n using the repository's normal workflow, resolving conflicts without losing\n user work. If project policy versions packages, prepare versions against the\n pinned base after every integration or repair and before each review, using\n the project's commands or `agent-tool versions prepare <base-oid>`, which\n commits bumped manifests and the refreshed lockfile.\n `agent-tool versions resolve-conflicts` resolves conflicts confined to\n version fields. Review the resulting committed HEAD with an independent\n reviewer using the bundled cross-agent-review skill. Repair blocking findings\n and review every changed HEAD again. Review failure blocks shipping.\n3. Run `agent-tool branch push <reviewed-head> --remote <remote>` through\n normal hooks; it verifies local and remote identity afterward. If a hook\n changed content or HEAD, review that result.\n Open the PR using the open-pr skill, or update the existing PR as needed.\n4. Allow configured review bots time to respond, then use\n `agent-tool pr threads list` to fetch review threads and all comments. Follow repository policy for feedback: fix valid findings,\n reply with `pr threads reply <comment-id>` (body file or stdin), and use\n `pr threads resolve <thread-id>` only for fully addressed findings.\n Validate, commit, push, and independently review every feedback or CI repair.\n Wait with `agent-tool pr checks --wait --head <reviewed-head>`, then run\n `agent-tool pr preflight <reviewed-head> <base-branch> <reviewed-base>` to\n recheck CI, live base, PR identity, local state, and explicit version policy.\n A changed base requires integration, validation, and another review. When\n versions are managed, `agent-tool versions check <base-oid>` must pass for the\n live base. Use the squash-merge skill to merge the exact reviewed HEAD.\n5. After confirmed MERGED, use the reset skill to return to the updated default\n branch and prune the shipped local feature branch as part of cleanup, honoring\n stronger repository cleanup rules. If the user requested keep-branch, preserve\n both feature refs. Check the cleanup receipt and verify the local feature ref\n is absent unless preserved; report incomplete cleanup explicitly. Apply\n documented dependency/hook setup.\n6. When the project publishes npm packages, use `agent-tool release verify\n <package> <version> <merge-oid> --workflow <publish-workflow>` to verify the\n exact workflow and tarball, allowing registry propagation time. When the\n project deploys the merged branch, verify that deployment and documented\n live checks. Report deployment failures\n separately from a successful merge; follow project policy for repairs.\n\nNever infer a clean review from process success alone or carry a review across\ncontent changes. Do not weaken configured CI policy to get a merge through.\nIf a step fails, preserve useful intermediate work and report the concrete\nremaining issue. Report PR link, merged commit, review verdict, validation, final\ncheckout state, and whether the shipped local branch was pruned or preserved.\n",
7
7
  "squash-merge": "---\nname: squash-merge\ndescription: Squash-merge a reviewed GitHub pull request while binding the mutation to the reviewed commit and checking CI.\n---\n\n# Squash Merge\n\nUse the installed `agent-tool` and repository merge policy. Merge only within\nthe user's authorized scope. Have the exact reviewed HEAD, base commit, and base\nbranch available, and read the verdict and unresolved findings. BLOCKER or MAJOR\nfindings require repair and a new review before merging.\nFollow the repository's review-bot policy and require all actionable blocking\nreview feedback to be addressed before merging.\n\nWait with `agent-tool pr checks --wait --head <reviewed-head>`, which waits for\nchecks to appear and for every workflow/job/event entry to finish. Run\n`agent-tool pr preflight <reviewed-head> <base-branch> <reviewed-base>` immediately\nbefore merging. It checks local and PR identity, live base, ancestry, explicit\nversion policy, merge readiness, configured strict rulesets, and passing checks.\nIf an identity changed, refresh, integrate, validate, and review again. Read\nunresolved feedback with `pr threads list` and follow project review policy.\n\nUse a title that satisfies `agent-tool.json` and pass both reviewed HEAD and\nbase branch:\n\n```sh\nagent-tool pr merge 'feat: describe the change' <reviewed-head-oid> <base-branch>\n```\n\nThe helper enforces HEAD atomically through GitHub's `expectedHeadOid`, checks\nCI, rejects queued or automatic merges, and creates a subject-only squash with\nthe PR reference. GitHub has no atomic expected-base argument in this mutation;\nrepository branch protection must enforce any required base freshness. With\n`merge.requireStrictBaseFreshness`, the helper refuses to merge unless an\nactive strict status ruleset that the actor cannot bypass protects the base. Do\nnot retarget the PR concurrently with merging.\n\nConfirm GitHub reports MERGED before any cleanup. A failed or uncertain mutation\nis not evidence of a merge. Preserve the branch on failure. After a confirmed\nmerge, perform checkout cleanup only when requested or as part of an authorized\nshipping workflow. Report the PR and merge result.\n",
8
- "update-dependencies": "---\nname: update-dependencies\ndescription: Update repository dependencies, runtimes, tools, and CI pins with compatibility checks and safe infrastructure previews, then ship through review, CI, merge, and release verification unless a narrower scope is requested.\n---\n\n# Update Dependencies\n\nUse the repository's package managers and installed `agent-tool`. Read `AGENTS.md`,\nREADME, existing dependency-maintenance notes, linked setup/release documents,\nand repository-owned upgrade skills before editing. Follow their hooks, version\npolicy, checks, and requested stopping point.\nUpdate applicable dependencies throughout the repository, including workspaces\nand examples. When asked to perform dependency updates, invoke the installed\n[`ship-pr`](../ship-pr/SKILL.md) to complete review, PR, CI, and merge, then verify\nthe repository's normal merge-triggered publication or deployment. An explicit\nnarrower scope, such as inventory-only, local changes, or open-PR-only, takes\nprecedence. This default does not authorize standalone infrastructure applies,\ndeployments, or global tool changes. Default dependency-update merge subject:\n`chore: update dependencies`, subject to repository title policy and any explicit\nuser subject.\n\nRun noisy installs, checks, tests, and builds with\n`agent-tool run --label <check> -- <command> [args...]` to retain full private\nlogs and return the original status with compact output. Inspect bounded excerpts\nusing `agent-tool logs <log>` and targeted searches for warnings and evidence;\na successful receipt does not establish a warning-free upgrade. Keep structured\naudit/plan results, deployment previews, and review findings directly visible\nwhen they drive a safety decision. Do not dump full suite logs into context,\nsilence warnings at their source, or change check scope to reduce output.\n\n## Safety before execution\n\n**Always dry-run before any infrastructure apply or deployment. Never destroy,\nreplace, or recreate a resource during an upgrade.** This includes Terraform,\nOpenTofu, Wrangler, Ansible, other infrastructure tools, and effects triggered by\ninstallation, tests, hooks, Git push, opening a PR, CI, or merging. Greenfield\nprojects and approval to ship do not waive this rule.\n\nBefore running project commands or pushing, trace their scripts, hooks, workflow\ntriggers, reusable workflows, and deployment commands. A dry-run flag alone is\ninsufficient: inspect tasks, provisioners, plugins, external programs, and actions\nthat can mutate despite preview mode. Avoid such a preview unless its effects\nare proven read-only. Do not run destroy/delete, taint/replace, state removal,\nbackend migration, forced database reset, or an automatic \"repair\" that bypasses\nthe rule. Do not hide destruction with targeting, disabled refresh, ignored\nchanges, new resource identities, or weakened policy.\n\nPreview with the proposed versions and complete configuration for each affected\nenvironment, using its real backend/workspace/account, variables, inventory, and\nresource identities. An empty local state, mocked credentials, validate-only run,\nor bundle build cannot establish safety for production. Check the current baseline\nas well when needed to distinguish existing drift from the upgrade. Keep state,\nbinary plans, JSON plans, and secret-bearing diffs out of Git and public reports.\n\nIf a preview proposes deletion or replacement, fails, is incomplete, omits\naffected resources, or cannot establish safety, skip that dependency and its\ncoupled migrations. Restore only this task's edits to the affected compatibility\ngroup, retain independent safe upgrades, and record the reason and affected\nresources. Never apply to find out. A preview is not deployment authorization.\n\nBefore an authorized apply/deploy, verify the exact validated commit, toolchain,\nconfiguration, environment, and current state still match the preview. Re-preview\nafter relevant changes or drift. CI must enforce the same order and stop on an\nunsafe preview before mutation; a successful local preview does not excuse an\nunguarded push- or merge-triggered deployment. Establish a narrowly scoped guard\nwhen authorized and reviewable, or hold the affected upgrade before triggering\nthat workflow. Do not disable protections to ship. A plan must be inspected before\nthe operation; inspecting deployment logs afterward is too late.\nAn unsafe workflow triggered on every branch push also blocks pushing otherwise\nindependent upgrades until that trigger has a proven safe guard.\n\n### Terraform and OpenTofu\n\nRead core, provider, and module upgrade guides, including intermediate major\nmigrations. Regenerate provider locks with the pinned CLI, preserving supported\nplatform hashes. Backend initialization/migration is also subject to the safety\ngate. Inspect external data programs and provisioners before planning.\n\nUse a full refreshed saved plan with locking and the real variables, for example\n`terraform plan -input=false -out=<private-plan>`; if using `-detailed-exitcode`,\n0 and 2 are success, 1 is failure. Inspect `terraform show -json <private-plan>`.\nRun `agent-tool dependencies check-terraform-plan <private-plan.json>` to check\nsupported JSON resource actions. It fails for every action containing `delete`,\nincluding both replacement orders, unknown actions, incomplete/deferred plans,\nfailed checks, or opaque provider action invocations. It prints actions and\ndiagnostics without resource attribute values; it does not prove that a plan is\nfresh, comprehensive, authentic, or that provider code has no hidden effects.\nOlder/unsupported plan formats that cannot establish completeness are skipped.\nOpenTofu currently omits `complete` in its JSON format and is rejected by this\nhelper; report that limitation rather than adding a synthetic completeness flag.\nAlso inspect resource identity, drift, warnings, provider semantics, and any\neffects outside resource actions. For an authorized apply, consume the exact\napproved saved plan, not an uninspected newly generated plan.\n\nSource: [Terraform JSON plan format](https://developer.hashicorp.com/terraform/internals/json-format).\n\n### Wrangler and other deployment tools\n\nUse the proposed pinned Wrangler for `wrangler deploy --dry-run` with the same\nbuild/configuration/environment used by deployment. This checks bundling, not a\ncomplete remote resource plan. Separately verify account, worker name, routes,\nbindings and resource IDs against the existing deployment and read migration\nsemantics. Review Durable Object deleted/renamed classes, D1 schema/data\nmigrations, storage resources, queues, containers, and deployment integrations.\nNever infer \"no destruction\" from a successful bundle. Resource removals,\nreplacement, or unprovable remote effects skip the affected group. Apply the\nequivalent preview and identity checks to other tools rather than assuming they\nshare Terraform's guarantees.\n\nDo not assume `wrangler d1 migrations list --remote` is read-only. The\nimplementation inspected for this skill initializes migration tracking with\n`CREATE TABLE IF NOT EXISTS`; verify the pinned Wrangler version before using\nthe command. Verify database identity and compare migration files with the\ndatabase's configured migration tracking table through audited read-only\nqueries. A missing table, pending migration, or unverifiable schema/data\neffects hold the affected upgrade/deployment group. Never initialize schema\nmerely to establish preview safety.\n\nWrangler runs a custom `build.command` again during deployment. Use supported\nconfiguration referencing the same validated artifacts for both preview and\ndeployment, preventing another unvalidated build before upload. Bind the\nartifacts, configuration, and pinned tool to the authorized commit, and recheck\ntheir identities before upload and after relevant changes. Hold the affected\ngroup if the pinned Wrangler cannot upload the validated output without\nrebuilding it, or if the upload cannot be proven to match the preview.\n\nSources: [Wrangler command documentation](https://developers.cloudflare.com/workers/wrangler/commands/),\n[D1 migration listing implementation](https://github.com/cloudflare/workers-sdk/blob/e3d186776fcc2e4b5cc4cfd7155591778a375957/packages/wrangler/src/d1/migrations/list.ts),\n[migration table initialization](https://github.com/cloudflare/workers-sdk/blob/e3d186776fcc2e4b5cc4cfd7155591778a375957/packages/wrangler/src/d1/migrations/helpers.ts),\nand [Wrangler custom builds](https://developers.cloudflare.com/workers/wrangler/custom-builds/).\n\n### Ansible\n\nCheck controller Python, ansible-core, collections/roles, and target Python support\ntogether; read porting guides. Inspect all tasks and invoked scripts before\n`ansible-playbook --check --diff` with the real inventory and variables.\n`check_mode: false` forces mutation even with `--check`; unsupported modules and\nconditions based on registered results can omit effects. Inspect deletion,\nrecreation, service/data reset, and package removal semantics beyond the diff.\nSkipped or opaque tasks are a safety gap, not a clean preview. Redact diffs.\n\nSource: [Ansible check mode limits](https://docs.ansible.com/projects/ansible/latest/playbook_guide/playbooks_checkmode.html).\n\n## Inventory and choose compatible targets\n\nRecord each dependency source, current resolved version, latest stable candidate,\nselected target, compatibility constraints, upstream migration links, and checks\nin the run ledger described under Report.\nQuery authoritative registries, official releases, support matrices, and migration\nguides at execution time; do not treat remembered versions or prereleases as\nlatest stable. Inspect runtime requirements, peer dependencies, ABI/API changes,\nplatform availability, maintained release lines, and changed defaults, including\nsecurity, privacy/telemetry, data retention, and postinstall/native behavior.\nCheck transitive dependencies' peer/runtime constraints as well as direct ones. Unsupported\nor unpublished targets stay pinned with an explanation.\n\nInventory manifests, locks, tool configuration, CI/reusable actions, containers,\nscripts, deployment files, and executable documentation. Search duplicate version\npins and aliases, not just filenames; exclude vendored/generated snapshots unless\nthe repository owns their update process. Examples of sources to inspect:\n\n| Ecosystem | Sources and compatibility group |\n| --- | --- |\n| JavaScript/TypeScript | All package.json dependency fields, overrides/resolutions, workspace catalogs, packageManager/engines, npm/Bun/pnpm/Yarn locks, Node/Bun pins, compiler and lint/build/test plugins |\n| Toolchains | mise.toml/.mise.toml, mise.lock, .tool-versions, runtime version files, tool URLs/checksums, CI pins and container images; include every tool declared by mise, not just mise itself |\n| Python/Ansible | pyproject/requirements, uv/Poetry/pip locks, Python pins, ansible-core, collection/role requirements, lint plugins and managed host constraints |\n| Ruby/iOS | .ruby-version, Gemfile/lock, Bundler, CocoaPods, Podfile/lock, podspecs, Swift packages and Xcode/iOS deployment requirements |\n| Infrastructure | Terraform/OpenTofu CLI constraints, provider locks/constraints, module versions/Git refs, Wrangler and Cloudflare integration dependencies |\n| CI/system/native | Actions/reusable workflow refs, Docker tags/digests, OS images/packages, Rust/Cargo toolchain and locks, Go modules, Gradle/AGP/Kotlin/JDK and other repository-specific manifests |\n\nBuild connected compatibility groups before editing. Select the newest supported\ncombination, which can be older than an individual package's latest. Upgrade the\nframework first or keep its compatible toolchain pinned; do not force peer\nresolution, drop compatibility checks, or add blanket overrides to make installs\npass. Preserve source pin policy (exact version, digest, full Git SHA) and resolve\nimmutable pins from verified upstream releases. Preserve intentional runtime\nminimums unless migration requires changing the support contract.\n\nFor React Native, use its version-specific upgrade guide/template diffs and\nrepository upgrade skills. Couple React and its renderer, native modules,\nMetro/Babel, CLI, codegen, Hermes/New Architecture, CocoaPods/Bundler/Ruby,\nXcode/iOS deployment targets, and Android Gradle/AGP/Kotlin/JDK/SDK/NDK. For Expo,\nuse the chosen SDK's supported React Native/dependency matrix and its dependency\nchecks. Do not independently update framework-managed pods or generated native\nfiles; use the framework's regeneration workflow and prove iOS/Android builds.\nPermissive peer ranges alone do not prove native renderer or toolchain compatibility;\ncheck the framework's actual supported versions and native implementation.\nAn unavailable native toolchain is a reported validation limit; do not claim the\ngroup verified. See [React Native upgrading](https://reactnative.dev/docs/upgrading).\n\nCheck developer tools' own dependencies too: a compiler release without its old\nprogrammatic API may break test/lint runners that import it even when `tsc` passes.\nAction runtime upgrades can require newer self-hosted runners; preserve workflow\npermissions, action inputs/outputs, caching, artifact semantics, and job names.\n\nCheck current advisories with read-only ecosystem audits against the baseline and\nproposed lockfiles, using the repository's configured registry/advisory sources.\nDo not send private dependency metadata to an unconfigured or unauthorized service;\nreport the resulting coverage limit. Cover direct and resolved transitive dependencies,\nincluding native/platform-specific binaries. Latest direct packages can still\npin vulnerable transitive versions. Trace each finding to its owning dependency,\nverify affected versions, runtime conditions, and remediation in the maintainer's\nadvisory and fixed release. When the owner's range permits a fixed version, use a\ntargeted refresh with the lockfile's owning manager and preserve unrelated\nresolutions; otherwise prefer a supported owner upgrade.\nIf the owner still pins a vulnerable version, a narrowly scoped security patch\noverride may be used only with API/ABI/runtime compatibility evidence and focused\nregression checks of the actual affected integration. Document its rationale and\nremoval condition; a passing install or bundle alone is insufficient. Do not run\nblind audit fixes or use blanket overrides to bypass compatibility constraints.\nReport unresolved advisories and unavailable audit coverage accurately; current\ndirect pins or a partial clean audit do not\nestablish that the whole dependency graph is safe.\n\n## Migrate and validate\n\nCapture relevant baseline checks and warnings first, after screening their side\neffects. Update one compatibility group at a time. Read the upstream instructions\nfor every breaking step; implement required source/config/schema migrations and\nremoved API replacements. Use the manager that owns each lockfile, on the selected\nruntime, and inspect the resulting diff. Refresh transitive dependencies through\ntheir owning direct dependency; do not hand-edit integrity hashes or lock entries.\nReview intentional patches, overrides, and pins before removal; retain them until\nupstream fixes and regression checks demonstrate they are unnecessary.\nAvoid unrelated global installations or environment changes. Use task-local or\nrepository-scoped tools for testing changed runtime pins.\nFor published packages, also validate tarballs and consumer installs with their\ndocumented supported package managers, including manifest/override syntax and\nruntime requirements. A successful owning-manager install does not prove that\npublished metadata works for those consumers.\n\nRun the repository's setup/hooks, frozen installs, lint, typechecks, tests, builds,\npackage/platform smoke checks, and relevant safe integration previews. Resolve\nupgrade-related deprecation warnings at their source. Do not suppress warnings,\nweaken tests, or disable checks; report remaining upstream/pre-existing warnings\nwith evidence and keep a target pinned when migration cannot be completed.\nAdd focused regression coverage when a migration changes behavior; avoid tests\nthat merely assert version strings. Re-run checks after repairs. Report checks\nblocked by missing credentials, platforms, or external services accurately.\nRe-audit the final resolved graph after migrations and repairs.\n\nConfirm every inventory row is upgraded, already current, intentionally constrained,\nor skipped with a concrete reason. Do not present an unresolved group as complete.\nWhen upgrading agent-tool itself, install its managed skills with the same harness\nscope and commit the ownership manifest; never edit generated managed skill copies.\n\nWhen changes depend on another repository's release, ship the producer first and\nconfirm publishing and registry availability before pinning the consumer; include\nits exact version/commit and API migration notes. Independent groups may proceed.\nComplete the installed `ship-pr` workflow within the requested scope, including\nindependent review of every changed HEAD, required CI and review feedback, merge,\nand verification of the merge's normal publication or deployment. Do not stop at\nan open PR unless the user requested that stopping point or a concrete blocker\nprevents continuing. Check deployment safety again before push/open/merge;\nshipping defaults do not waive the preview gates above.\n\n## Report\n\nKeep run-specific evidence in the upgrade PR body or a linked PR comment: the\ninventory and old/selected versions, completed migrations, validation and warnings,\nbaseline/final audits and coverage limits, preview environment/commit/tool versions\nand safe action summary, and skipped/constrained dependencies with reasons. Include\nsecurity overrides, their rationale and removal condition, and unresolved advisories.\nIf the user requested a scope that stops before a PR, provide this ledger in the\ntask report and honor that limit. For a sweep, keep one entry per repository,\nincluding unready and unchanged checkouts, and use an existing tracking issue only\nwhen explicitly requested. Never create an issue merely to store upgrade evidence.\n\nKeep durable repository guidance focused on what the next upgrade needs: current\ncompatibility constraints, intentional pins/overrides, unresolved advisories, and\nreusable repository-specific procedures. Prefer the existing maintenance document;\notherwise create `docs/dependency-upgrades.md` (or the repository's equivalent)\nonly when there is substantive guidance to preserve. For each constraint, record\nits rationale, upstream advisory/migration/issue link or originating PR, and the\ncondition and check for removing or revisiting it. Do not copy version inventories,\naudit output, validation/preview receipts, or dated run journals into that document.\n\nOn every upgrade, reconcile the existing maintenance document against the final\ndependency graph and checks: update constraints in place, remove resolved entries,\nand link the PR for historical evidence. Keep the root README focused on purpose,\nsetup, supported requirements, and normal usage, with a concise maintenance link\nwhen needed. Keep dependency-maintenance guidance in `AGENTS.md` to a short\nrouting rule and link; do not accumulate upgrade reports in either entrypoint\nor duplicate the maintenance document.\n\nFor shipping, report the branch, reviewed commit/verdict, PR, merge, publish/deploy\nresult, and final checkout state. Keep full logs private; never publish credentials,\nstate, or plan contents in any of these locations.\n"
8
+ "update-dependencies": "---\nname: update-dependencies\ndescription: Update repository dependencies, runtimes, tools, and CI pins with compatibility checks and safe infrastructure previews, then ship through review, CI, merge, and release verification unless a narrower scope is requested.\n---\n\n# Update Dependencies\n\nUse the repository's package managers and installed `agent-tool`. Read `AGENTS.md`,\nREADME, existing dependency-maintenance notes, linked setup/release documents,\nand repository-owned upgrade skills before editing. Follow their hooks, version\npolicy, checks, and requested stopping point.\nUpdate applicable dependencies throughout the repository, including workspaces\nand examples. When asked to perform dependency updates, invoke the installed\n[`ship-pr`](../ship-pr/SKILL.md) to complete review, PR, CI, and merge, then verify\nthe repository's normal merge-triggered publication or deployment. An explicit\nnarrower scope, such as inventory-only, local changes, or open-PR-only, takes\nprecedence. This default does not authorize standalone infrastructure applies,\ndeployments, or global tool changes. Default dependency-update merge subject:\n`chore: update dependencies`, subject to repository title policy and any explicit\nuser subject.\n\nRun noisy installs, checks, tests, and builds with\n`agent-tool run --label <check> -- <command> [args...]` to retain full private\nlogs and return the original status with compact output. Inspect bounded excerpts\nusing `agent-tool logs <log>` and targeted searches for warnings and evidence;\na successful receipt does not establish a warning-free upgrade. Keep structured\naudit/plan results, deployment previews, and review findings directly visible\nwhen they drive a safety decision. Do not dump full suite logs into context,\nsilence warnings at their source, or change check scope to reduce output.\n\n## Safety before execution\n\n**Always dry-run before any infrastructure apply or deployment. Never destroy,\nreplace, or recreate a resource during an upgrade.** This includes Terraform,\nOpenTofu, Wrangler, Ansible, other infrastructure tools, and effects triggered by\ninstallation, tests, hooks, Git push, opening a PR, CI, or merging. Greenfield\nprojects and approval to ship do not waive this rule.\n\nBefore running project commands or pushing, trace their scripts, hooks, workflow\ntriggers, reusable workflows, and deployment commands. A dry-run flag alone is\ninsufficient: inspect tasks, provisioners, plugins, external programs, and actions\nthat can mutate despite preview mode. Avoid such a preview unless its effects\nare proven read-only. Do not run destroy/delete, taint/replace, state removal,\nbackend migration, forced database reset, or an automatic \"repair\" that bypasses\nthe rule. Do not hide destruction with targeting, disabled refresh, ignored\nchanges, new resource identities, or weakened policy.\n\nPreview with the proposed versions and complete configuration for each affected\nenvironment, using its real backend/workspace/account, variables, inventory, and\nresource identities. An empty local state, mocked credentials, validate-only run,\nor bundle build cannot establish safety for production. Check the current baseline\nas well when needed to distinguish existing drift from the upgrade. Keep state,\nbinary plans, JSON plans, and secret-bearing diffs out of Git and public reports.\n\nIf a preview proposes deletion or replacement, fails, is incomplete, omits\naffected resources, or cannot establish safety, skip that dependency and its\ncoupled migrations. Restore only this task's edits to the affected compatibility\ngroup, retain independent safe upgrades, and record the reason and affected\nresources. Never apply to find out. A preview is not deployment authorization.\n\nBefore an authorized apply/deploy, verify the exact validated commit, toolchain,\nconfiguration, environment, and current state still match the preview. Re-preview\nafter relevant changes or drift. CI must enforce the same order and stop on an\nunsafe preview before mutation; a successful local preview does not excuse an\nunguarded push- or merge-triggered deployment. Establish a narrowly scoped guard\nwhen authorized and reviewable, or hold the affected upgrade before triggering\nthat workflow. Do not disable protections to ship. A plan must be inspected before\nthe operation; inspecting deployment logs afterward is too late.\nAn unsafe workflow triggered on every branch push also blocks pushing otherwise\nindependent upgrades until that trigger has a proven safe guard.\n\n### Terraform and OpenTofu\n\nRead core, provider, and module upgrade guides, including intermediate major\nmigrations. Regenerate provider locks with the pinned CLI, preserving supported\nplatform hashes. Backend initialization/migration is also subject to the safety\ngate. Inspect external data programs and provisioners before planning.\n\nUse a full refreshed saved plan with locking and the real variables, for example\n`terraform plan -input=false -out=<private-plan>`; if using `-detailed-exitcode`,\n0 and 2 are success, 1 is failure. Inspect `terraform show -json <private-plan>`.\nRun `agent-tool dependencies check-terraform-plan <private-plan.json>` to check\nsupported JSON resource actions. It fails for every action containing `delete`,\nincluding both replacement orders, unknown actions, incomplete/deferred plans,\nfailed checks, or opaque provider action invocations. It prints actions and\ndiagnostics without resource attribute values; it does not prove that a plan is\nfresh, comprehensive, authentic, or that provider code has no hidden effects.\nOlder/unsupported plan formats that cannot establish completeness are skipped.\nOpenTofu currently omits `complete` in its JSON format and is rejected by this\nhelper; report that limitation rather than adding a synthetic completeness flag.\nAlso inspect resource identity, drift, warnings, provider semantics, and any\neffects outside resource actions. For an authorized apply, consume the exact\napproved saved plan, not an uninspected newly generated plan.\n\nSource: [Terraform JSON plan format](https://developer.hashicorp.com/terraform/internals/json-format).\n\n### Wrangler and other deployment tools\n\nUse the proposed pinned Wrangler for `wrangler deploy --dry-run` with the same\nbuild/configuration/environment used by deployment. This checks bundling, not a\ncomplete remote resource plan. Separately verify account, worker name, routes,\nbindings and resource IDs against the existing deployment and read migration\nsemantics. Review Durable Object deleted/renamed classes, D1 schema/data\nmigrations, storage resources, queues, containers, and deployment integrations.\nNever infer \"no destruction\" from a successful bundle. Resource removals,\nreplacement, or unprovable remote effects skip the affected group. Apply the\nequivalent preview and identity checks to other tools rather than assuming they\nshare Terraform's guarantees.\n\nDo not assume `wrangler d1 migrations list --remote` is read-only. The\nimplementation inspected for this skill initializes migration tracking with\n`CREATE TABLE IF NOT EXISTS`; verify the pinned Wrangler version before using\nthe command. Verify database identity and compare migration files with the\ndatabase's configured migration tracking table through audited read-only\nqueries. A missing table, pending migration, or unverifiable schema/data\neffects hold the affected upgrade/deployment group. Never initialize schema\nmerely to establish preview safety.\n\nWrangler runs a custom `build.command` again during deployment. Use supported\nconfiguration referencing the same validated artifacts for both preview and\ndeployment, preventing another unvalidated build before upload. Bind the\nartifacts, configuration, and pinned tool to the authorized commit, and recheck\ntheir identities before upload and after relevant changes. Hold the affected\ngroup if the pinned Wrangler cannot upload the validated output without\nrebuilding it, or if the upload cannot be proven to match the preview.\n\nSources: [Wrangler command documentation](https://developers.cloudflare.com/workers/wrangler/commands/),\n[D1 migration listing implementation](https://github.com/cloudflare/workers-sdk/blob/e3d186776fcc2e4b5cc4cfd7155591778a375957/packages/wrangler/src/d1/migrations/list.ts),\n[migration table initialization](https://github.com/cloudflare/workers-sdk/blob/e3d186776fcc2e4b5cc4cfd7155591778a375957/packages/wrangler/src/d1/migrations/helpers.ts),\nand [Wrangler custom builds](https://developers.cloudflare.com/workers/wrangler/custom-builds/).\n\n### Ansible\n\nCheck controller Python, ansible-core, collections/roles, and target Python support\ntogether; read porting guides. Inspect all tasks and invoked scripts before\n`ansible-playbook --check --diff` with the real inventory and variables.\n`check_mode: false` forces mutation even with `--check`; unsupported modules and\nconditions based on registered results can omit effects. Inspect deletion,\nrecreation, service/data reset, and package removal semantics beyond the diff.\nSkipped or opaque tasks are a safety gap, not a clean preview. Redact diffs.\n\nSource: [Ansible check mode limits](https://docs.ansible.com/projects/ansible/latest/playbook_guide/playbooks_checkmode.html).\n\n## Inventory and choose compatible targets\n\nRecord each dependency source, current resolved version, latest stable candidate,\nselected target, compatibility constraints, upstream migration links, and checks\nin the run ledger described under Report.\nQuery authoritative registries, official releases, support matrices, and migration\nguides at execution time; do not treat remembered versions or prereleases as\nlatest stable. Inspect runtime requirements, peer dependencies, ABI/API changes,\nplatform availability, maintained release lines, and changed defaults, including\nsecurity, privacy/telemetry, data retention, and postinstall/native behavior.\nCheck transitive dependencies' peer/runtime constraints as well as direct ones. Unsupported\nor unpublished targets stay pinned with an explanation.\n\nInventory manifests, locks, tool configuration, CI/reusable actions, containers,\nscripts, deployment files, and executable documentation. Search duplicate version\npins and aliases, not just filenames; exclude vendored/generated snapshots unless\nthe repository owns their update process. Examples of sources to inspect:\n\n| Ecosystem | Sources and compatibility group |\n| --- | --- |\n| JavaScript/TypeScript | All package.json dependency fields, overrides/resolutions, workspace catalogs, packageManager/engines, npm/Bun/pnpm/Yarn locks, Node/Bun pins, compiler and lint/build/test plugins |\n| Toolchains | mise.toml/.mise.toml, mise.lock, .tool-versions, runtime version files, tool URLs/checksums, CI pins and container images; include every tool declared by mise, not just mise itself |\n| Python/Ansible | pyproject/requirements, uv/Poetry/pip locks, Python pins, ansible-core, collection/role requirements, lint plugins and managed host constraints |\n| Ruby/iOS | .ruby-version, Gemfile/lock, Bundler, CocoaPods, Podfile/lock, podspecs, Swift packages and Xcode/iOS deployment requirements |\n| Infrastructure | Terraform/OpenTofu CLI constraints, provider locks/constraints, module versions/Git refs, Wrangler and Cloudflare integration dependencies |\n| CI/system/native | Actions/reusable workflow refs, Docker tags/digests, OS images/packages, Rust/Cargo toolchain and locks, Go modules, Gradle/AGP/Kotlin/JDK and other repository-specific manifests |\n\nBuild connected compatibility groups before editing. Select the newest supported\ncombination, which can be older than an individual package's latest. Upgrade the\nframework first or keep its compatible toolchain pinned; do not force peer\nresolution, drop compatibility checks, or add blanket overrides to make installs\npass. Preserve source pin policy (exact version, digest, full Git SHA) and resolve\nimmutable pins from verified upstream releases. Preserve intentional runtime\nminimums unless migration requires changing the support contract.\n\nFor React Native, use its version-specific upgrade guide/template diffs and\nrepository upgrade skills. Couple React and its renderer, native modules,\nMetro/Babel, CLI, codegen, Hermes/New Architecture, CocoaPods/Bundler/Ruby,\nXcode/iOS deployment targets, and Android Gradle/AGP/Kotlin/JDK/SDK/NDK. For Expo,\nuse the chosen SDK's supported React Native/dependency matrix and its dependency\nchecks. Do not independently update framework-managed pods or generated native\nfiles; use the framework's regeneration workflow and prove iOS/Android builds.\nPermissive peer ranges alone do not prove native renderer or toolchain compatibility;\ncheck the framework's actual supported versions and native implementation.\nAn unavailable native toolchain is a reported validation limit; do not claim the\ngroup verified. See [React Native upgrading](https://reactnative.dev/docs/upgrading).\n\nCheck developer tools' own dependencies too: a compiler release without its old\nprogrammatic API may break test/lint runners that import it even when `tsc` passes.\nAction runtime upgrades can require newer self-hosted runners; preserve workflow\npermissions, action inputs/outputs, caching, artifact semantics, and job names.\n\nCheck current advisories with read-only ecosystem audits against the baseline and\nproposed lockfiles, using the repository's configured registry/advisory sources.\nDo not send private dependency metadata to an unconfigured or unauthorized service;\nreport the resulting coverage limit. Cover direct and resolved transitive dependencies,\nincluding native/platform-specific binaries. Latest direct packages can still\npin vulnerable transitive versions. Trace each finding to its owning dependency,\nverify affected versions, runtime conditions, and remediation in the maintainer's\nadvisory and fixed release. When the owner's range permits a fixed version, use a\ntargeted refresh with the lockfile's owning manager and preserve unrelated\nresolutions; otherwise prefer a supported owner upgrade.\nIf the owner still pins a vulnerable version, a narrowly scoped security patch\noverride may be used only with API/ABI/runtime compatibility evidence and focused\nregression checks of the actual affected integration. Document its rationale and\nremoval condition; a passing install or bundle alone is insufficient. Do not run\nblind audit fixes or use blanket overrides to bypass compatibility constraints.\nReport unresolved advisories and unavailable audit coverage accurately; current\ndirect pins or a partial clean audit do not\nestablish that the whole dependency graph is safe.\n\n## Migrate and validate\n\nCapture relevant baseline checks and warnings first, after screening their side\neffects. Update one compatibility group at a time. Read the upstream instructions\nfor every breaking step; implement required source/config/schema migrations and\nremoved API replacements. Use the manager that owns each lockfile, on the selected\nruntime, and inspect the resulting diff. For Bun lockfiles, change declared\nversions with `agent-tool bun update <name[@version]>` and drop packages with\n`agent-tool bun remove <name>`; both use the pinned Bun and refuse to add a\npackage. A new dependency goes through `agent-tool bun add <name@version>`, which\nasks for approval; never introduce one by editing `package.json` and\nreinstalling. Refresh transitive dependencies through\ntheir owning direct dependency; do not hand-edit integrity hashes or lock entries.\nReview intentional patches, overrides, and pins before removal; retain them until\nupstream fixes and regression checks demonstrate they are unnecessary.\nAvoid unrelated global installations or environment changes. Use task-local or\nrepository-scoped tools for testing changed runtime pins.\nFor published packages, also validate tarballs and consumer installs with their\ndocumented supported package managers, including manifest/override syntax and\nruntime requirements. A successful owning-manager install does not prove that\npublished metadata works for those consumers.\n\nRun the repository's setup/hooks, frozen installs, lint, typechecks, tests, builds,\npackage/platform smoke checks, and relevant safe integration previews. Resolve\nupgrade-related deprecation warnings at their source. Do not suppress warnings,\nweaken tests, or disable checks; report remaining upstream/pre-existing warnings\nwith evidence and keep a target pinned when migration cannot be completed.\nAdd focused regression coverage when a migration changes behavior; avoid tests\nthat merely assert version strings. Re-run checks after repairs. Report checks\nblocked by missing credentials, platforms, or external services accurately.\nRe-audit the final resolved graph after migrations and repairs.\n\nConfirm every inventory row is upgraded, already current, intentionally constrained,\nor skipped with a concrete reason. Do not present an unresolved group as complete.\nWhen upgrading agent-tool itself, install its managed skills with the same harness\nscope and commit the ownership manifest; never edit generated managed skill copies.\n\nWhen changes depend on another repository's release, ship the producer first and\nconfirm publishing and registry availability before pinning the consumer; include\nits exact version/commit and API migration notes. Independent groups may proceed.\nComplete the installed `ship-pr` workflow within the requested scope, including\nindependent review of every changed HEAD, required CI and review feedback, merge,\nand verification of the merge's normal publication or deployment. Do not stop at\nan open PR unless the user requested that stopping point or a concrete blocker\nprevents continuing. Check deployment safety again before push/open/merge;\nshipping defaults do not waive the preview gates above.\n\n## Report\n\nKeep run-specific evidence in the upgrade PR body or a linked PR comment: the\ninventory and old/selected versions, completed migrations, validation and warnings,\nbaseline/final audits and coverage limits, preview environment/commit/tool versions\nand safe action summary, and skipped/constrained dependencies with reasons. Include\nsecurity overrides, their rationale and removal condition, and unresolved advisories.\nIf the user requested a scope that stops before a PR, provide this ledger in the\ntask report and honor that limit. For a sweep, keep one entry per repository,\nincluding unready and unchanged checkouts, and use an existing tracking issue only\nwhen explicitly requested. Never create an issue merely to store upgrade evidence.\n\nKeep durable repository guidance focused on what the next upgrade needs: current\ncompatibility constraints, intentional pins/overrides, unresolved advisories, and\nreusable repository-specific procedures. Prefer the existing maintenance document;\notherwise create `docs/dependency-upgrades.md` (or the repository's equivalent)\nonly when there is substantive guidance to preserve. For each constraint, record\nits rationale, upstream advisory/migration/issue link or originating PR, and the\ncondition and check for removing or revisiting it. Do not copy version inventories,\naudit output, validation/preview receipts, or dated run journals into that document.\n\nOn every upgrade, reconcile the existing maintenance document against the final\ndependency graph and checks: update constraints in place, remove resolved entries,\nand link the PR for historical evidence. Keep the root README focused on purpose,\nsetup, supported requirements, and normal usage, with a concise maintenance link\nwhen needed. Keep dependency-maintenance guidance in `AGENTS.md` to a short\nrouting rule and link; do not accumulate upgrade reports in either entrypoint\nor duplicate the maintenance document.\n\nFor shipping, report the branch, reviewed commit/verdict, PR, merge, publish/deploy\nresult, and final checkout state. Keep full logs private; never publish credentials,\nstate, or plan contents in any of these locations.\n"
9
9
  };
@@ -1,5 +1,6 @@
1
- import { readFileSync } from "node:fs";
1
+ import { readFileSync, realpathSync } from "node:fs";
2
2
  import path from "node:path";
3
+ import { packageDirectories } from "../bun/workspace";
3
4
  import { manifestPath, readVersion, workspacePackages } from "./packageVersion";
4
5
 
5
6
  function field(value: unknown, key: string): unknown {
@@ -41,3 +42,126 @@ export function assertBunLockVersions(
41
42
  );
42
43
  }
43
44
  }
45
+
46
+ const SECTIONS = ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"];
47
+
48
+ function entries(value: unknown): [string, string][] {
49
+ return typeof value === "object" && value !== null && !Array.isArray(value)
50
+ ? Object.entries(value).map(([name, spec]) => [name, typeof spec === "string" ? spec : JSON.stringify(spec)])
51
+ : [];
52
+ }
53
+
54
+ /**
55
+ * Third-party declarations that bun.lock does not already record, as
56
+ * `<manifest>: <name>@<spec>`: a package added or re-sourced by hand rather
57
+ * than through an approved add. Every manifest Bun installs is compared with
58
+ * the lockfile's record of that workspace, section by section, and the root's
59
+ * catalogs, overrides, and patches with the lockfile's. A new workspace may
60
+ * only use a third-party package at a spec the lockfile already records.
61
+ */
62
+ export function unresolvedDependencies(
63
+ rootDir: string,
64
+ lockSource = readFileSync(path.join(rootDir, "bun.lock"), "utf8"),
65
+ ): string[] {
66
+ const lock = Bun.JSONC.parse(lockSource);
67
+ const workspaces = field(lock, "workspaces");
68
+ const root = realpathSync(rootDir);
69
+ const manifests = packageDirectories(root).map((directory) => ({
70
+ key: path.relative(root, directory).split(path.sep).join("/"),
71
+ manifest: JSON.parse(readFileSync(path.join(directory, "package.json"), "utf8")) as Record<string, unknown>,
72
+ }));
73
+ const known = new Set<string>();
74
+ for (const [, workspace] of entries(workspaces).length ? Object.entries(workspaces as object) : []) {
75
+ for (const section of SECTIONS) for (const [name, spec] of entries(field(workspace, section))) known.add(`${name}@${spec}`);
76
+ }
77
+ // A name the lockfile resolved to a workspace that no longer exists would now come from the registry.
78
+ const current = new Set(manifests.map(({ manifest }) => manifest.name));
79
+ const removed = new Set(Object.values((workspaces ?? {}) as Record<string, unknown>).map((workspace) => field(workspace, "name")).filter((name) => typeof name === "string" && !current.has(name)));
80
+ const missing = new Set<string>();
81
+ for (const { key, manifest } of manifests) {
82
+ const recorded = field(workspaces, key);
83
+ for (const section of SECTIONS) {
84
+ for (const [name, spec] of entries(manifest[section])) {
85
+ if (removed.has(name)) missing.add(`${key || "."}: ${name}@${spec} (its workspace was removed)`);
86
+ // A workspace's name with a version spec can still resolve from the registry; only `workspace:` is local.
87
+ if (spec.startsWith("workspace:")) continue;
88
+ // A relative source names different code from a new directory, so only position-independent specs carry over.
89
+ const relative = /^(?:file:|link:|\.{1,2}\/|\/|~\/)/.test(spec);
90
+ const covered = recorded === undefined ? !relative && known.has(`${name}@${spec}`) : field(field(recorded, section), name) === spec;
91
+ if (!covered) missing.add(`${key || "."}: ${name}@${spec}`);
92
+ }
93
+ }
94
+ }
95
+ const rootManifest = manifests.find(({ key }) => key === "")?.manifest ?? {};
96
+ const nested = field(rootManifest, "workspaces");
97
+ const catalogs = field(rootManifest, "catalogs") ?? field(nested, "catalogs");
98
+ const sources: [string, unknown, unknown][] = [
99
+ ["catalog", field(rootManifest, "catalog") ?? field(nested, "catalog"), field(lock, "catalog")],
100
+ // Names from both sides, so deleting a whole named catalog is compared too.
101
+ ...[...new Set([...entries(catalogs), ...entries(field(lock, "catalogs"))].map(([name]) => name))].map((name): [string, unknown, unknown] => [`catalogs.${name}`, field(catalogs, name), field(field(lock, "catalogs"), name)]),
102
+ ["overrides", field(rootManifest, "overrides") ?? field(rootManifest, "resolutions"), field(lock, "overrides")],
103
+ ["patchedDependencies", field(rootManifest, "patchedDependencies"), field(lock, "patchedDependencies")],
104
+ ];
105
+ for (const [label, declared, recorded] of sources) {
106
+ // Nested overrides are objects on both sides; compare them as JSON, in both directions.
107
+ for (const [name, spec] of entries(declared)) if (entries(recorded).find(([key]) => key === name)?.[1] !== spec) missing.add(`${label}: ${name}@${spec}`);
108
+ for (const [name] of entries(recorded)) if (!entries(declared).some(([key]) => key === name)) missing.add(`${label}: removed ${name}`);
109
+ }
110
+ return [...missing].sort();
111
+ }
112
+
113
+ function workspaceVersions(rootDir: string): Map<string, string> {
114
+ const root = realpathSync(rootDir);
115
+ const versions = new Map<string, string>();
116
+ for (const directory of packageDirectories(root)) {
117
+ const manifest = JSON.parse(readFileSync(path.join(directory, "package.json"), "utf8")) as Record<string, unknown>;
118
+ if (typeof manifest.name === "string" && typeof manifest.version === "string") versions.set(manifest.name, manifest.version);
119
+ }
120
+ return versions;
121
+ }
122
+
123
+ /** Workspace versions as bun.lock last resolved them, by package name. */
124
+ export function lockedWorkspaceVersions(
125
+ rootDir: string,
126
+ lockSource = readFileSync(path.join(rootDir, "bun.lock"), "utf8"),
127
+ ): Map<string, string> {
128
+ const versions = new Map<string, string>();
129
+ for (const workspace of Object.values((field(Bun.JSONC.parse(lockSource), "workspaces") ?? {}) as Record<string, unknown>)) {
130
+ const name = field(workspace, "name"), version = field(workspace, "version");
131
+ if (typeof name === "string" && typeof version === "string") versions.set(name, version);
132
+ }
133
+ return versions;
134
+ }
135
+
136
+ /**
137
+ * Declarations that a workspace satisfied at its locked version but no longer
138
+ * does, whether a committed or a prepared bump moved it, so Bun would resolve
139
+ * them from the registry instead of the workspace.
140
+ */
141
+ export function lostWorkspaceReferences(rootDir: string, before: ReadonlyMap<string, string>): string[] {
142
+ const root = realpathSync(rootDir);
143
+ const after = workspaceVersions(root);
144
+ const rootManifest = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) as Record<string, unknown>;
145
+ const nested = field(rootManifest, "workspaces");
146
+ // `catalog:` names a root catalog entry; compare the version it stands for.
147
+ const resolve = (name: string, spec: string): unknown => {
148
+ if (!spec.startsWith("catalog:")) return spec;
149
+ const catalog = spec.slice("catalog:".length);
150
+ return catalog && catalog !== "default"
151
+ ? field(field(field(rootManifest, "catalogs") ?? field(nested, "catalogs"), catalog), name)
152
+ : field(field(rootManifest, "catalog") ?? field(nested, "catalog"), name);
153
+ };
154
+ const lost = new Set<string>();
155
+ for (const directory of packageDirectories(root)) {
156
+ const manifest = JSON.parse(readFileSync(path.join(directory, "package.json"), "utf8")) as Record<string, unknown>;
157
+ const key = path.relative(root, directory).split(path.sep).join("/") || ".";
158
+ for (const section of SECTIONS) {
159
+ for (const [name, spec] of entries(manifest[section])) {
160
+ const old = before.get(name), now = after.get(name), range = resolve(name, spec);
161
+ if (spec.startsWith("workspace:") || old === undefined || now === undefined || typeof range !== "string") continue;
162
+ if (Bun.semver.satisfies(old, range) && !Bun.semver.satisfies(now, range)) lost.add(`${key}: ${name}@${spec}`);
163
+ }
164
+ }
165
+ }
166
+ return [...lost].sort();
167
+ }
@@ -1,7 +1,7 @@
1
1
  import { loadConfig } from "../config";
2
2
  import { validateCommitSubject } from "../pr/validateCommitSubject";
3
3
  import { checkVersions, planVersions, rewriteVersions } from "./bumpVersions";
4
- import { assertBunLockVersions } from "./lockfileVersions";
4
+ import { assertBunLockVersions, lockedWorkspaceVersions, lostWorkspaceReferences, unresolvedDependencies } from "./lockfileVersions";
5
5
  import {
6
6
  assertAllowedPaths,
7
7
  assertClean,
@@ -69,11 +69,24 @@ export function prepareVersions(rootDir: string, baseOid?: string): number {
69
69
  const allowed = [...pending.map((plan) => plan.manifest), ...(lockfile ? [lockfile] : [])];
70
70
  let committed = false;
71
71
  let lockfileChanged = false;
72
+ if (lockfile) {
73
+ // The refresh below is unfrozen; it may record workspace changes, never resolve a hand-added package.
74
+ const missing = unresolvedDependencies(rootDir);
75
+ if (missing.length) {
76
+ throw new Error(`bun.lock does not record ${missing.join(", ")}; add or change third-party packages through agent-tool bun commands, or refresh the lockfile with approval, before preparing versions.`);
77
+ }
78
+ }
79
+ const versionsBefore = lockfile ? lockedWorkspaceVersions(rootDir) : undefined;
72
80
  try {
73
81
  assertHead(rootDir, startHead);
74
82
  rewriteVersions(rootDir, baseCommit);
83
+ if (versionsBefore) {
84
+ // A bump can leave a version-pinned reference unsatisfied by its workspace, sending it to the registry.
85
+ const lost = lostWorkspaceReferences(rootDir, versionsBefore);
86
+ if (lost.length) throw new Error(`The version bump leaves ${lost.join(", ")} unsatisfied by the workspace, so Bun would fetch it from the registry; use a workspace: spec.`);
87
+ }
75
88
  if (lockfile) {
76
- // Also repairs a stale lockfile after a deliberate release or new package.
89
+ // Records the bumped workspace versions in the lockfile.
77
90
  runProjectCommand(rootDir, ["bun", "install", "--lockfile-only", "--ignore-scripts"]);
78
91
  assertBunLockVersions(rootDir);
79
92
  }