@gonxped31/agent-kit 0.0.0-stage → 0.2.0

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.
@@ -0,0 +1,5 @@
1
+ {
2
+ "steps": [
3
+ { "name": "doctor", "run": "node scripts/doctor.mjs" }
4
+ ]
5
+ }
@@ -0,0 +1,50 @@
1
+ // Runs the project's checks, listed in scripts/check.json. Prints only what failed, then OK or FAIL.
2
+ // Usage: node scripts/check.mjs [--fast] [--verbose]
3
+ // --fast skips steps marked "slow" (the pre-commit hook runs this)
4
+ // --verbose prints every step's full output
5
+ // A step: { "name": "test", "run": "npm test", "slow": true, "tail": 30 }. When it fails, the
6
+ // last `tail` lines of its output are printed (default 20).
7
+ import { spawnSync } from "node:child_process";
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+
12
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
13
+ const fast = process.argv.includes("--fast");
14
+ const verbose = process.argv.includes("--verbose");
15
+ const strip = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
16
+
17
+ let steps;
18
+ try {
19
+ ({ steps } = JSON.parse(fs.readFileSync(path.join(ROOT, "scripts", "check.json"), "utf8")));
20
+ if (!Array.isArray(steps) || !steps.every((s) => typeof s.name === "string" && typeof s.run === "string")) {
21
+ throw new Error('needs a "steps" array of { "name", "run" }');
22
+ }
23
+ } catch (e) {
24
+ console.log(`scripts/check.json: ${e.message}`);
25
+ console.log("FAIL");
26
+ process.exit(1);
27
+ }
28
+
29
+ const start = Date.now();
30
+ let ran = 0;
31
+ let failed = 0;
32
+ for (const step of steps) {
33
+ if (fast && step.slow) continue;
34
+ ran++;
35
+ const r = spawnSync(step.run, { cwd: ROOT, shell: true, encoding: "utf8", maxBuffer: 1 << 28 });
36
+ const out = strip(`${r.stdout ?? ""}${r.stderr ?? ""}`).trimEnd();
37
+ if (verbose) console.log(`$ ${step.run}\n${out}`);
38
+ if (r.status === 0) continue;
39
+ failed++;
40
+ console.log(`${step.name}: FAIL`);
41
+ if (verbose) continue;
42
+ const lines = out.split(/\r?\n/);
43
+ const tail = lines.slice(-(step.tail ?? 20));
44
+ if (lines.length > tail.length) console.log(` (${lines.length - tail.length} lines above; --verbose shows them)`);
45
+ for (const line of tail) console.log(` ${line}`);
46
+ }
47
+
48
+ const secs = ((Date.now() - start) / 1000).toFixed(1);
49
+ console.log(failed ? "FAIL" : `OK (${ran} step${ran === 1 ? "" : "s"}, ${secs}s)`);
50
+ process.exit(failed ? 1 : 0);
@@ -0,0 +1,177 @@
1
+ // Checks the agent setup: size limits, dead paths, unfilled placeholders, hooks turned on, and
2
+ // the kit's versions of files (<file>.kit) still to merge.
3
+ // Prints one line per problem, then OK or FAIL. The limits are the agent-setup skill's:
4
+ // change them here, with the reason in the commit.
5
+ // Usage: node scripts/doctor.mjs
6
+ import { execFileSync } from "node:child_process";
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+
11
+ const LIMITS = {
12
+ rootWords: 800, // the root CLAUDE.md: in context on every turn
13
+ nestedWords: 400, // a folder's CLAUDE.md: loaded when the agent works in that folder
14
+ descriptionWords: 30, // a skill's description: in context in every session
15
+ skillLines: 100, // a SKILL.md: loaded when the skill is used
16
+ };
17
+
18
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
19
+ const problems = [];
20
+ const exists = (rel) => fs.existsSync(path.join(ROOT, rel));
21
+ const read = (rel) => fs.readFileSync(path.join(ROOT, rel), "utf8").replace(/\r\n/g, "\n");
22
+ const words = (text) => text.split(/\s+/).filter(Boolean).length;
23
+
24
+ function git(...args) {
25
+ try {
26
+ return execFileSync("git", args, { cwd: ROOT, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ /** A frontmatter field's value, including folded (`>`) and indented continuation lines. */
33
+ function field(front, key) {
34
+ const lines = front.split("\n");
35
+ const i = lines.findIndex((l) => l.startsWith(`${key}:`));
36
+ if (i < 0) return undefined;
37
+ let value = lines[i].slice(key.length + 1).trim().replace(/^[>|][-+]?$/, "");
38
+ for (const l of lines.slice(i + 1)) {
39
+ if (!/^\s/.test(l)) break;
40
+ value += ` ${l.trim()}`;
41
+ }
42
+ return value.trim().replace(/^(["'])(.*)\1$/, "$2");
43
+ }
44
+
45
+ /** The lines outside fenced code blocks, with their line numbers. */
46
+ function proseLines(text) {
47
+ let fenced = false;
48
+ const out = [];
49
+ text.split("\n").forEach((line, i) => {
50
+ if (/^\s*(```|~~~)/.test(line)) fenced = !fenced;
51
+ else if (!fenced) out.push({ line, n: i + 1 });
52
+ });
53
+ return out;
54
+ }
55
+
56
+ // Tracked and untracked-but-not-ignored files, so dependencies and build output are never walked.
57
+ const listed = git("ls-files", "-z", "--cached", "--others", "--exclude-standard");
58
+ if (listed === null) problems.push("not a git repository");
59
+ const files = (listed ?? "").split("\0").filter((f) => f && exists(f));
60
+ const markdown = files.filter((f) => f.endsWith(".md"));
61
+ const isInstructions = (f) => path.posix.basename(f) === "CLAUDE.md";
62
+
63
+ // ---- CLAUDE.md files: size, placeholders, @-imports
64
+ if (!exists("CLAUDE.md")) problems.push("CLAUDE.md: missing at the root");
65
+ for (const f of markdown.filter(isInstructions)) {
66
+ const text = read(f);
67
+ const max = f === "CLAUDE.md" ? LIMITS.rootWords : LIMITS.nestedWords;
68
+ const n = words(text);
69
+ if (n > max) problems.push(`${f}: ${n} words (max ${max}); move detail into a skill, a folder's CLAUDE.md or docs/`);
70
+ const placeholder = /\{\{[^}]*\}\}/.exec(text);
71
+ if (placeholder) problems.push(`${f}: unfilled placeholder ${placeholder[0]}`);
72
+ for (const { line, n: ln } of proseLines(text)) {
73
+ if (/(^|\s)@[^\s@]*[/.][^\s@]*/.test(line.replace(/`[^`]*`/g, ""))) {
74
+ problems.push(`${f}:${ln}: an @-import loads that file on every turn; name the path in backticks instead`);
75
+ }
76
+ }
77
+ }
78
+
79
+ // ---- Skills: frontmatter, description, size
80
+ const skillsDir = path.join(ROOT, ".claude", "skills");
81
+ const skillNames = fs.existsSync(skillsDir)
82
+ ? fs.readdirSync(skillsDir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name)
83
+ : [];
84
+ let descriptionWords = 0;
85
+ for (const name of skillNames) {
86
+ const rel = `.claude/skills/${name}/SKILL.md`;
87
+ if (!exists(rel)) {
88
+ problems.push(`${rel}: missing`);
89
+ continue;
90
+ }
91
+ const text = read(rel).trimEnd();
92
+ const front = /^---\n([\s\S]*?)\n---/.exec(text)?.[1];
93
+ if (front === undefined) {
94
+ problems.push(`${rel}: no frontmatter (name, description)`);
95
+ continue;
96
+ }
97
+ const declared = field(front, "name");
98
+ if (declared !== name) problems.push(`${rel}: name is "${declared ?? ""}", must be "${name}" (its folder)`);
99
+ const description = field(front, "description") ?? "";
100
+ const n = words(description);
101
+ descriptionWords += n;
102
+ if (!n) problems.push(`${rel}: no description`);
103
+ else if (n > LIMITS.descriptionWords) problems.push(`${rel}: description is ${n} words (max ${LIMITS.descriptionWords})`);
104
+ else if (!/\buse (when|whenever|before|after|for)\b/i.test(description)) problems.push(`${rel}: description must say when to use it ("Use when ...")`);
105
+ const lines = text.split("\n").length;
106
+ if (lines > LIMITS.skillLines) problems.push(`${rel}: ${lines} lines (max ${LIMITS.skillLines}); move reference into files next to it`);
107
+ }
108
+
109
+ // ---- Paths named in agent docs must exist
110
+ /**
111
+ * Reports a path that doesn't exist. Links are always checked. A code span can be anything
112
+ * (`origin/main`, `user.name`), so it counts as a path only when it has a slash and its first
113
+ * folder exists next to the file or at the root.
114
+ */
115
+ function checkPath(from, n, raw, isLink) {
116
+ const p = raw.replace(/[#?].*$/, "").replace(/:\d+(:\d+)?$/, "");
117
+ if (!p || /^[a-z][a-z0-9+.-]*:/i.test(p) || /[\s<>*{}$|"'`()=\\]/.test(p) || /^[~-]/.test(p)) return;
118
+ const rel = p.replace(/^\/+/, "").replace(/\/+$/, "");
119
+ if (!rel) return;
120
+ const bases = p.startsWith("/") ? [""] : [path.posix.dirname(from), ""];
121
+ if (bases.some((b) => exists(path.posix.join(b, rel)))) return;
122
+ const first = rel.split("/")[0];
123
+ const anchored = isLink || (rel.includes("/") && bases.some((b) => exists(path.posix.join(b, first))));
124
+ if (anchored) problems.push(`${from}:${n}: ${raw} doesn't exist`);
125
+ }
126
+
127
+ const scanned = markdown.filter((f) => isInstructions(f) || f.startsWith(".claude/") || f.startsWith("docs/"));
128
+ for (const f of scanned) {
129
+ for (const { line, n } of proseLines(read(f))) {
130
+ for (const [, span] of line.matchAll(/`([^`]+)`/g)) for (const token of span.split(/\s+/)) checkPath(f, n, token, false);
131
+ for (const [, target] of line.matchAll(/\]\(([^)\s]+)\)/g)) checkPath(f, n, target, true);
132
+ }
133
+ }
134
+
135
+ // ---- Hooks: the git hooks are on, and every Claude hook script exists
136
+ // A CI checkout never turns the hooks on, and needn't: CI runs the checks itself. CI services set CI.
137
+ const inCI = Boolean(process.env.CI) && !/^(false|0)$/i.test(process.env.CI);
138
+ if (!inCI && git("config", "core.hooksPath")?.trim() !== ".githooks") {
139
+ problems.push("git hooks are off in this clone: git config core.hooksPath .githooks");
140
+ }
141
+ let settings = {};
142
+ try {
143
+ settings = JSON.parse(read(".claude/settings.json"));
144
+ } catch (e) {
145
+ problems.push(`.claude/settings.json: ${e.code === "ENOENT" ? "missing" : e.message}`);
146
+ }
147
+ for (const entries of Object.values(settings.hooks ?? {})) {
148
+ for (const { hooks = [] } of entries) {
149
+ for (const { command = "" } of hooks) {
150
+ for (const [, rel] of command.matchAll(/\$CLAUDE_PROJECT_DIR\/([^"'\s]+)/g)) {
151
+ if (!exists(rel)) problems.push(`.claude/settings.json: hook script ${rel} doesn't exist`);
152
+ }
153
+ }
154
+ }
155
+ }
156
+
157
+ // ---- The kit's versions of files the repo has its own of (agk setup, agk update), still to merge
158
+ if (exists(".claude/agk.json")) {
159
+ let kitFiles = [];
160
+ try {
161
+ kitFiles = Object.keys(JSON.parse(read(".claude/agk.json")).files ?? {});
162
+ } catch (e) {
163
+ problems.push(`.claude/agk.json: ${e.message}`);
164
+ }
165
+ for (const f of kitFiles.filter((f) => exists(`${f}.kit`))) {
166
+ problems.push(`${f}.kit: the kit's version of ${f}. Merge what's new in it into ${f}, then delete it`);
167
+ }
168
+ }
169
+
170
+ for (const p of problems) console.log(p);
171
+ const always = (exists("CLAUDE.md") ? words(read("CLAUDE.md")) : 0) + descriptionWords;
172
+ console.log(
173
+ problems.length
174
+ ? "FAIL"
175
+ : `OK (always in context: CLAUDE.md + ${skillNames.length} skill descriptions, ~${always} words)`,
176
+ );
177
+ process.exit(problems.length ? 1 : 0);
@@ -0,0 +1,25 @@
1
+ // PreToolUse hook for Read: refuse a whole-file read of a text file over MAX_LINES.
2
+ // Fails open: any error, or a read with a `limit`, lets the call through. So do images and PDFs,
3
+ // which Read shows as pictures or by page, and other binary files: their "lines" mean nothing.
4
+ import { readFileSync, statSync } from "node:fs";
5
+
6
+ const MAX_LINES = 1500;
7
+ const SHOWN_AS_IS = /\.(png|jpe?g|gif|webp|bmp|ico|pdf)$/i;
8
+
9
+ try {
10
+ const input = JSON.parse(readFileSync(0, "utf8"));
11
+ const { file_path: file, limit } = input.tool_input ?? {};
12
+ if (file && !limit && !SHOWN_AS_IS.test(file) && statSync(file).size > 20_000) {
13
+ const data = readFileSync(file);
14
+ const binary = data.subarray(0, 8000).includes(0); // git's test: a NUL byte near the start
15
+ const lines = binary ? 0 : data.toString("utf8").split("\n").length;
16
+ if (lines > MAX_LINES) {
17
+ console.error(
18
+ `${file} has ${lines} lines (over ${MAX_LINES}). Use Grep to find what you need, or Read with offset and limit.`,
19
+ );
20
+ process.exit(2);
21
+ }
22
+ }
23
+ } catch {
24
+ // fail open
25
+ }
@@ -0,0 +1,110 @@
1
+ // PreToolUse hook for Bash and PowerShell: keeps the repo's safety nets in place.
2
+ // Refuses (exit 2; the reason goes to Claude):
3
+ // --no-verify (and git commit -n), which skips the git hooks, and turning the hooks off
4
+ // (core.hooksPath);
5
+ // bulk staging (git add -A/./-u, git commit -a), which picks up changes that aren't yours.
6
+ // Long options count in any abbreviation git accepts (--no-veri, --al).
7
+ // Asks the user first: merging into main, pushing, and commands that discard work or move
8
+ // branches by hand.
9
+ // Quoted text, heredocs and here-strings are ignored, so a commit message can mention any of this.
10
+ // Fails open: unreadable input lets the command through.
11
+ import { readFileSync } from "node:fs";
12
+
13
+ let command;
14
+ try {
15
+ command = String(JSON.parse(readFileSync(0, "utf8")).tool_input?.command ?? "");
16
+ } catch {
17
+ process.exit(0);
18
+ }
19
+
20
+ const code = command
21
+ .replace(/<<-?\s*(['"]?)(\w+)\1[^\n]*\n[\s\S]*?\n\s*\2\s*(?=\n|$)/g, " ") // heredoc bodies
22
+ .replace(/@(['"])\r?\n[\s\S]*?\r?\n\1@/g, '""') // PowerShell here-strings
23
+ .replace(/"(?:[^"\\`]|[\\`].)*"|'[^']*'/g, '""'); // quoted strings
24
+
25
+ function refuse(reason) {
26
+ console.error(reason);
27
+ process.exit(2);
28
+ }
29
+
30
+ /** A simple command's git subcommand and its arguments, skipping git's own options (-C dir, -c k=v). */
31
+ function parseGit(segment) {
32
+ const words = segment.trim().split(/\s+/);
33
+ let i = words.findIndex((w) => /(^|[\\/])git(\.exe)?$/.test(w));
34
+ if (i < 0) return null;
35
+ for (i++; i < words.length && words[i].startsWith("-"); i++) if (words[i] === "-C" || words[i] === "-c") i++;
36
+ return { sub: words[i], args: words.slice(i + 1) };
37
+ }
38
+ const gits = code.split(/&&|\|\||[;|\n]/).map(parseGit).filter(Boolean);
39
+ const has = (args, ...flags) => args.some((a) => flags.includes(a));
40
+
41
+ /** The letters of a short-option group like -qnm, up to one that takes a value (m: -mfoo is a message). */
42
+ function shortFlags(arg, takesValue = "") {
43
+ if (!/^-[a-zA-Z]+$/.test(arg)) return "";
44
+ let flags = "";
45
+ for (const ch of arg.slice(1)) {
46
+ flags += ch;
47
+ if (takesValue.includes(ch)) break;
48
+ }
49
+ return flags;
50
+ }
51
+ /** True for `name` or an abbreviation of it git would accept (git takes any unambiguous prefix). */
52
+ const long = (arg, name, shortest) => arg.length >= shortest && name.startsWith(arg.split("=")[0]);
53
+
54
+ // --no-veri is enough for git (--no-ve could also be --no-verbose).
55
+ if (/(^|\s)--no-veri/.test(code)) {
56
+ refuse("--no-verify skips the git hooks (the checks, no commits on main). Fix what the hook reports instead.");
57
+ }
58
+ if (/core\.hooksPath[\s=]+(?!\.githooks\b)[^\s;&|]/i.test(code) || /\bunset(-all)?\s+core\.hooksPath\b/i.test(code)) {
59
+ refuse("The git hooks stay on: core.hooksPath is .githooks.");
60
+ }
61
+ const BULK_PATHS = [".", "./", ".\\", "*", ":/", ":/."];
62
+ for (const { sub, args } of gits) {
63
+ if (
64
+ sub === "add" &&
65
+ args.some(
66
+ (a) =>
67
+ BULK_PATHS.includes(a) ||
68
+ /[Au]/.test(shortFlags(a)) ||
69
+ long(a, "--all", 3) ||
70
+ long(a, "--update", 3) ||
71
+ long(a, "--no-ignore-removal", 6),
72
+ )
73
+ ) {
74
+ refuse("Stage the files you changed by name (git skill): bulk staging picks up changes that aren't yours.");
75
+ }
76
+ if (sub === "commit") {
77
+ // Options of git commit that take a value: -m msg, -F file, -C/-c commit, -t file, -S[key], -u[mode].
78
+ const flags = args.map((a) => shortFlags(a, "mFCctSu")).join("");
79
+ if (flags.includes("n")) {
80
+ refuse("git commit -n is --no-verify: it skips the git hooks. Fix what the hook reports instead.");
81
+ }
82
+ if (flags.includes("a") || args.includes("--all")) {
83
+ refuse("git commit -a stages every changed file. Stage yours by name, then commit (git skill).");
84
+ }
85
+ }
86
+ }
87
+
88
+ const reasons = [];
89
+ if (/\bnode(\.exe)?\s+\S*merge\.mjs\b/.test(code)) reasons.push("This merges into main: the user approves first.");
90
+ for (const { sub, args } of gits) {
91
+ if (sub === "push") reasons.push("This pushes to a remote.");
92
+ if (sub === "merge") reasons.push("main moves only through scripts/merge.mjs; merging by hand needs the user's OK.");
93
+ if (sub === "reset" && has(args, "--hard")) reasons.push("reset --hard discards uncommitted work.");
94
+ if (sub === "clean") reasons.push("git clean deletes untracked files.");
95
+ if (sub === "checkout" && has(args, "--", ".")) reasons.push("This discards uncommitted changes.");
96
+ if (sub === "restore" && !(has(args, "--staged", "-S") && !has(args, "--worktree", "-W"))) {
97
+ reasons.push("git restore discards uncommitted changes.");
98
+ }
99
+ if (sub === "stash" && has(args, "drop", "clear")) reasons.push("This deletes stashed work.");
100
+ if (sub === "branch" && has(args, "-D", "-f", "--force")) reasons.push("Force-deleting or moving a branch can lose commits.");
101
+ if (sub === "update-ref") reasons.push("This moves a branch by hand.");
102
+ if (sub === "worktree" && args[0] === "remove" && has(args, "-f", "--force")) {
103
+ reasons.push("Removing a worktree by force deletes its uncommitted work.");
104
+ }
105
+ }
106
+ if (reasons.length) {
107
+ const output = { hookEventName: "PreToolUse", permissionDecision: "ask", permissionDecisionReason: [...new Set(reasons)].join(" ") };
108
+ console.log(JSON.stringify({ hookSpecificOutput: output }));
109
+ }
110
+ process.exit(0);
@@ -0,0 +1,82 @@
1
+ // Fast-forwards main to the branch checked out here, once its checks pass. The only way main
2
+ // moves (git skill): run it from the branch's checkout, after the user approves the merge.
3
+ // Usage: node scripts/merge.mjs [branch] (if given, it must be the branch checked out here)
4
+ import { execFileSync, spawnSync } from "node:child_process";
5
+ import path from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ const MAIN = "main";
9
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
10
+
11
+ function git(...args) {
12
+ return execFileSync("git", args, { cwd: ROOT, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
13
+ }
14
+ function tryGit(...args) {
15
+ try {
16
+ return git(...args);
17
+ } catch {
18
+ return null;
19
+ }
20
+ }
21
+ function fail(message) {
22
+ console.error(`merge: ${message}`);
23
+ process.exit(1);
24
+ }
25
+
26
+ /** The worktree that has `branch` checked out, or null. */
27
+ function worktreeOf(branch) {
28
+ let dir = null;
29
+ for (const line of git("worktree", "list", "--porcelain").split("\n")) {
30
+ if (line.startsWith("worktree ")) dir = line.slice("worktree ".length);
31
+ else if (line === `branch refs/heads/${branch}`) return dir;
32
+ }
33
+ return null;
34
+ }
35
+
36
+ const branch = tryGit("symbolic-ref", "--short", "-q", "HEAD");
37
+ if (!branch) fail("HEAD is detached; check out the branch to merge");
38
+ if (branch === MAIN) fail(`this checkout is on ${MAIN}; run this from the branch to merge`);
39
+ const named = process.argv[2];
40
+ if (named && named !== branch) fail(`${named} isn't checked out here (${branch} is); run this from ${named}'s checkout`);
41
+ if (tryGit("rev-parse", "--verify", "-q", `refs/heads/${MAIN}`) === null) fail(`there's no ${MAIN} branch`);
42
+ if (git("status", "--porcelain", "--untracked-files=no")) fail("uncommitted changes; commit them first");
43
+
44
+ const base = git("rev-parse", MAIN);
45
+ const tip = git("rev-parse", "HEAD");
46
+ if (base === tip) fail(`${branch} has nothing that ${MAIN} doesn't`);
47
+ if (tryGit("merge-base", "--is-ancestor", MAIN, "HEAD") === null) {
48
+ fail(`${branch} isn't on top of ${MAIN}: git rebase ${MAIN}, then run this again`);
49
+ }
50
+
51
+ // The checks run on exactly what main is about to become.
52
+ const check = spawnSync(process.execPath, [path.join(ROOT, "scripts", "check.mjs")], { cwd: ROOT, stdio: "inherit" });
53
+ if (check.status !== 0) fail(`checks failed; ${MAIN} is unchanged`);
54
+
55
+ // Where main is checked out (usually the main checkout, when this is a worktree), fast-forward
56
+ // it there so its files follow. Otherwise move the ref alone, guarded by its old value.
57
+ const mainTree = worktreeOf(MAIN);
58
+ if (mainTree) {
59
+ const r = spawnSync("git", ["merge", "--ff-only", "-q", tip], { cwd: mainTree, encoding: "utf8" });
60
+ if (r.status !== 0) fail(`couldn't fast-forward ${MAIN} in ${mainTree}:\n${(r.stderr || r.stdout).trim()}`);
61
+ } else {
62
+ git("update-ref", "-m", `merge.mjs: fast-forward to ${branch}`, `refs/heads/${MAIN}`, tip, base);
63
+ }
64
+ const count = Number(git("rev-list", "--count", `${base}..${tip}`));
65
+ console.log(`merged ${branch} into ${MAIN}: ${count} commit${count === 1 ? "" : "s"} (${base.slice(0, 7)}..${tip.slice(0, 7)})`);
66
+
67
+ // The branch is done: leave it and delete it. If main is checked out elsewhere, this checkout
68
+ // can't switch to it, so say how to finish instead.
69
+ const isWorktree = path.resolve(ROOT, git("rev-parse", "--git-dir")) !== path.resolve(ROOT, git("rev-parse", "--git-common-dir"));
70
+ if (!mainTree) {
71
+ if (tryGit("switch", "-q", MAIN) !== null && tryGit("branch", "-q", "-d", branch) !== null) {
72
+ console.log(`deleted ${branch}; this checkout is on ${MAIN}`);
73
+ } else {
74
+ console.log(`couldn't switch this checkout to ${MAIN}; ${branch} is kept`);
75
+ }
76
+ } else if (isWorktree) {
77
+ console.log(`this worktree is done: cd "${mainTree}" && git worktree remove "${ROOT}" && git branch -d ${branch}`);
78
+ } else {
79
+ console.log(`${MAIN} is checked out in ${mainTree}; switch this checkout off ${branch}, then: git branch -d ${branch}`);
80
+ }
81
+ const remote = tryGit("remote")?.split("\n")[0];
82
+ if (remote) console.log(`${remote}: push ${MAIN} once the user agrees (git push ${remote} ${MAIN})`);