@a2f0/agent-tool 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +288 -0
  2. package/package.json +36 -0
  3. package/skills/cross-agent-review/SKILL.md +46 -0
  4. package/skills/open-pr/SKILL.md +31 -0
  5. package/skills/reset/SKILL.md +25 -0
  6. package/skills/ship-pr/SKILL.md +48 -0
  7. package/skills/squash-merge/SKILL.md +39 -0
  8. package/src/cli.ts +121 -0
  9. package/src/config.ts +101 -0
  10. package/src/doctor.ts +23 -0
  11. package/src/git/currentBranchPr.ts +114 -0
  12. package/src/git/environment.ts +10 -0
  13. package/src/git/prContext.ts +497 -0
  14. package/src/harnesses.ts +53 -0
  15. package/src/index.ts +9 -0
  16. package/src/pr/assertBranchPushed.ts +21 -0
  17. package/src/pr/assertNoClaudeBranding.ts +52 -0
  18. package/src/pr/baseFreshness.ts +67 -0
  19. package/src/pr/mergeChecks.ts +90 -0
  20. package/src/pr/openPr.ts +80 -0
  21. package/src/pr/prNumberSuffix.ts +43 -0
  22. package/src/pr/squashMerge.ts +229 -0
  23. package/src/pr/subjectLine.ts +21 -0
  24. package/src/pr/validateCommitSubject.ts +12 -0
  25. package/src/review/destinationPaths.ts +154 -0
  26. package/src/review/executable.ts +34 -0
  27. package/src/review/pinnedReviewInput.ts +91 -0
  28. package/src/review/rawGitTree.ts +301 -0
  29. package/src/review/reviewEffort.ts +54 -0
  30. package/src/review/reviewOutput.ts +62 -0
  31. package/src/review/reviewPrompt.ts +104 -0
  32. package/src/review/reviewSnapshot.ts +67 -0
  33. package/src/review/runReview.ts +62 -0
  34. package/src/review/solicitClaudeCodeReview.ts +140 -0
  35. package/src/review/solicitCodexReview.ts +205 -0
  36. package/src/review/solicitOpencodeReview.ts +233 -0
  37. package/src/runAgentToolAction.ts +100 -0
  38. package/src/skills/bundled.ts +8 -0
  39. package/src/skills/install.ts +92 -0
  40. package/src/version/bumpVersions.ts +196 -0
  41. package/src/version/lockfileVersions.ts +43 -0
  42. package/src/version/packageVersion.ts +112 -0
  43. package/src/version/prepareVersions.ts +135 -0
  44. package/src/version/resolveVersionConflicts.ts +126 -0
  45. package/src/version/versionPreparationGit.ts +127 -0
@@ -0,0 +1,135 @@
1
+ import { loadConfig } from "../config";
2
+ import { validateCommitSubject } from "../pr/validateCommitSubject";
3
+ import { checkVersions, planVersions, rewriteVersions } from "./bumpVersions";
4
+ import { assertBunLockVersions } from "./lockfileVersions";
5
+ import {
6
+ assertAllowedPaths,
7
+ assertClean,
8
+ assertHead,
9
+ assertTracked,
10
+ changedPaths,
11
+ commitWithHooks,
12
+ git,
13
+ headOid,
14
+ runProjectCommand,
15
+ } from "./versionPreparationGit";
16
+
17
+ function commitPreparedVersions(
18
+ rootDir: string,
19
+ startHead: string,
20
+ allowed: readonly string[],
21
+ lockfile: string | null,
22
+ validate: readonly (readonly string[])[],
23
+ message: string,
24
+ ): void {
25
+ git(rootDir, ["add", "--", ...allowed]);
26
+ // A clean filter can make the staged blob differ from the checked file.
27
+ if (lockfile) assertBunLockVersions(rootDir, git(rootDir, ["show", `:${lockfile}`]));
28
+ const preparedTree = git(rootDir, ["write-tree"]);
29
+ for (const command of validate) runProjectCommand(rootDir, command);
30
+ assertHead(rootDir, startHead);
31
+ assertAllowedPaths(rootDir, allowed);
32
+ if (
33
+ git(rootDir, ["diff", "--name-only"]) ||
34
+ git(rootDir, ["write-tree"]) !== preparedTree
35
+ ) {
36
+ throw new Error("Validation changed the prepared files or index.");
37
+ }
38
+ commitWithHooks(rootDir, message);
39
+ if (
40
+ git(rootDir, ["rev-parse", "HEAD^", "HEAD^{tree}"]) !==
41
+ `${startHead}\n${preparedTree}`
42
+ ) {
43
+ throw new Error("The version commit differs from the prepared snapshot.");
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Bump, refresh the lockfile, validate, and commit versions before a review
49
+ * snapshot. Writes one JSON receipt to stdout; diagnostics go to stderr.
50
+ */
51
+ export function prepareVersions(rootDir: string, baseOid?: string): number {
52
+ const { lockfile, validate, commitMessage } = loadConfig(rootDir).versions;
53
+ validateCommitSubject(rootDir, commitMessage);
54
+ assertClean(rootDir);
55
+ if (lockfile) assertTracked(rootDir, lockfile);
56
+ const startHead = headOid(rootDir);
57
+ const plans = planVersions(rootDir, baseOid);
58
+ const baseCommit = git(rootDir, ["rev-parse", `${baseOid}^{commit}`]);
59
+ try {
60
+ git(rootDir, ["merge-base", "--is-ancestor", baseCommit, startHead]);
61
+ } catch {
62
+ throw new Error(
63
+ "Merge the pinned base into HEAD before preparing versions.",
64
+ );
65
+ }
66
+ const pending = plans.filter(
67
+ (plan) => plan.headVersion !== plan.targetVersion,
68
+ );
69
+ const allowed = [...pending.map((plan) => plan.manifest), ...(lockfile ? [lockfile] : [])];
70
+ let committed = false;
71
+ let lockfileChanged = false;
72
+ try {
73
+ assertHead(rootDir, startHead);
74
+ rewriteVersions(rootDir, baseCommit);
75
+ if (lockfile) {
76
+ // Also repairs a stale lockfile after a deliberate release or new package.
77
+ runProjectCommand(rootDir, ["bun", "install", "--lockfile-only", "--ignore-scripts"]);
78
+ assertBunLockVersions(rootDir);
79
+ }
80
+ assertHead(rootDir, startHead);
81
+ assertAllowedPaths(rootDir, allowed);
82
+ const changed = changedPaths(rootDir);
83
+ lockfileChanged = lockfile !== null && changed.includes(lockfile);
84
+ if (changed.length) {
85
+ commitPreparedVersions(rootDir, startHead, allowed, lockfile, validate, commitMessage);
86
+ committed = true;
87
+ }
88
+ if (checkVersions(rootDir, baseCommit) !== 0) {
89
+ throw new Error("Prepared versions do not match the pinned base.");
90
+ }
91
+ assertClean(rootDir);
92
+ } catch (error) {
93
+ // A clean starting tree permits a narrow rollback before a commit lands.
94
+ // Preserve intermediate state if another operation changed HEAD or other paths.
95
+ if (
96
+ headOid(rootDir) === startHead &&
97
+ changedPaths(rootDir).every((file) => allowed.includes(file))
98
+ ) {
99
+ if (allowed.length) {
100
+ git(rootDir, [
101
+ "restore",
102
+ `--source=${startHead}`,
103
+ "--staged",
104
+ "--worktree",
105
+ "--",
106
+ ...allowed,
107
+ ]);
108
+ }
109
+ process.stderr.write(
110
+ "Restored version manifests, lockfile, and index to the starting commit.\n",
111
+ );
112
+ } else {
113
+ process.stderr.write(
114
+ "Preserved intermediate state because HEAD or other files changed; inspect git status before retrying.\n",
115
+ );
116
+ }
117
+ throw error;
118
+ }
119
+ process.stdout.write(
120
+ `${JSON.stringify({
121
+ schemaVersion: 1,
122
+ baseOid: baseCommit,
123
+ startHead,
124
+ headOid: headOid(rootDir),
125
+ committed,
126
+ lockfileChanged,
127
+ versions: pending.map((plan) => ({
128
+ manifest: plan.manifest,
129
+ from: plan.headVersion,
130
+ to: plan.targetVersion,
131
+ })),
132
+ })}\n`,
133
+ );
134
+ return 0;
135
+ }
@@ -0,0 +1,126 @@
1
+ import { gitEnvironment } from "../git/environment";
2
+ import { execFileSync, spawnSync } from "node:child_process";
3
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import path from "node:path";
6
+
7
+ import {
8
+ isReleaseBump,
9
+ isVersionedManifest,
10
+ readVersion,
11
+ withVersion,
12
+ } from "./packageVersion";
13
+
14
+ const gitEnv = gitEnvironment();
15
+
16
+ function git(rootDir: string, args: string[]): string {
17
+ return execFileSync("git", args, {
18
+ cwd: rootDir,
19
+ env: gitEnv,
20
+ encoding: "utf8",
21
+ stdio: ["ignore", "pipe", "pipe"],
22
+ });
23
+ }
24
+
25
+ function stage(rootDir: string, index: 1 | 2 | 3, file: string): string {
26
+ return git(rootDir, ["show", `:${index}:${file}`]);
27
+ }
28
+
29
+ /**
30
+ * Three-way merge a manifest with every side's version set to one version, so
31
+ * a version-only conflict merges cleanly: the branch's when it is a deliberate
32
+ * major or minor release over the incoming base, which `bumpVersions` keeps,
33
+ * and the base's otherwise. Null when anything besides the version still
34
+ * conflicts.
35
+ */
36
+ function mergeIgnoringVersion(rootDir: string, file: string): string | null {
37
+ const ours = stage(rootDir, 2, file);
38
+ const theirs = stage(rootDir, 3, file);
39
+ const oursVersion = readVersion(ours);
40
+ const theirsVersion = readVersion(theirs);
41
+ const version = isReleaseBump(oursVersion, theirsVersion)
42
+ ? oursVersion
43
+ : theirsVersion;
44
+ const scratch = mkdtempSync(path.join(tmpdir(), "agent-tool-version-merge-"));
45
+ try {
46
+ const inputs = {
47
+ ours: withVersion(ours, version),
48
+ base: withVersion(stage(rootDir, 1, file), version),
49
+ theirs: withVersion(theirs, version),
50
+ };
51
+ const paths = Object.entries(inputs).map(([name, source]) => {
52
+ const scratchFile = path.join(scratch, name);
53
+ writeFileSync(scratchFile, source);
54
+ return scratchFile;
55
+ });
56
+ const result = spawnSync("git", ["merge-file", "-p", ...paths], {
57
+ cwd: rootDir,
58
+ env: gitEnv,
59
+ encoding: "utf8",
60
+ stdio: ["ignore", "pipe", "pipe"],
61
+ });
62
+ if (result.error) {
63
+ throw result.error;
64
+ }
65
+ return result.status === 0 ? result.stdout : null;
66
+ } finally {
67
+ rmSync(scratch, { recursive: true, force: true });
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Finish an in-progress base merge whose only conflicts are the version field
73
+ * of versioned package.json files: take the base's version (or the branch's
74
+ * deliberate release) and stage the
75
+ * result, leaving the bump itself to `bumpVersions`. Touches nothing and exits
76
+ * non-zero when any conflict is something else.
77
+ */
78
+ export function resolveVersionConflicts(rootDir: string): number {
79
+ const inMerge = spawnSync(
80
+ "git",
81
+ ["rev-parse", "-q", "--verify", "MERGE_HEAD"],
82
+ { cwd: rootDir, env: gitEnv, stdio: "ignore" },
83
+ );
84
+ if (inMerge.status !== 0) {
85
+ process.stderr.write("Error: no merge is in progress.\n");
86
+ return 1;
87
+ }
88
+ const conflicted = git(rootDir, [
89
+ "diff",
90
+ "--name-only",
91
+ "--diff-filter=U",
92
+ "-z",
93
+ ])
94
+ .split("\0")
95
+ .filter(Boolean);
96
+ if (conflicted.length === 0) {
97
+ process.stderr.write("Error: the merge has no conflicts to resolve.\n");
98
+ return 1;
99
+ }
100
+ const others = conflicted.filter(
101
+ (file) => !isVersionedManifest(rootDir, file),
102
+ );
103
+ if (others.length > 0) {
104
+ process.stderr.write(
105
+ `Error: conflicts outside the versioned package.json files: ${others.join(", ")}\n`,
106
+ );
107
+ return 1;
108
+ }
109
+ const resolved = new Map<string, string>();
110
+ for (const file of conflicted) {
111
+ const merged = mergeIgnoringVersion(rootDir, file);
112
+ if (merged === null) {
113
+ process.stderr.write(
114
+ `Error: ${file} conflicts beyond its version field.\n`,
115
+ );
116
+ return 1;
117
+ }
118
+ resolved.set(file, merged);
119
+ }
120
+ for (const [file, merged] of resolved) {
121
+ writeFileSync(path.join(rootDir, file), merged);
122
+ git(rootDir, ["add", "--", file]);
123
+ process.stderr.write(`Resolved the version conflict in ${file}.\n`);
124
+ }
125
+ return 0;
126
+ }
@@ -0,0 +1,127 @@
1
+ import { execFileSync, spawnSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { gitEnvironment } from "../git/environment";
5
+ import { outsideExecutable } from "../review/executable";
6
+
7
+ /** Helper Git operations run without hooks or replace objects. */
8
+ export function git(rootDir: string, args: readonly string[]): string {
9
+ return execFileSync("git", ["--literal-pathspecs", ...args], {
10
+ cwd: rootDir,
11
+ env: gitEnvironment(),
12
+ encoding: "utf8",
13
+ stdio: ["ignore", "pipe", "pipe"],
14
+ }).replace(/\n$/u, "");
15
+ }
16
+
17
+ /** The version commit runs the project's hooks like any other commit. */
18
+ export function commitWithHooks(rootDir: string, message: string): void {
19
+ const result = spawnSync("git", ["--literal-pathspecs", "commit", "-m", message], {
20
+ cwd: rootDir,
21
+ env: { ...process.env, GIT_NO_REPLACE_OBJECTS: "1" },
22
+ encoding: "utf8",
23
+ stdio: ["ignore", "pipe", "pipe"],
24
+ maxBuffer: 8 * 1024 * 1024,
25
+ });
26
+ // Hook output is diagnostic; stdout is reserved for the preparation receipt.
27
+ process.stderr.write(result.stdout ?? "");
28
+ process.stderr.write(result.stderr ?? "");
29
+ if (result.error) throw result.error;
30
+ if (result.status !== 0) {
31
+ throw new Error(`git commit failed (${result.signal ?? result.status}).`);
32
+ }
33
+ }
34
+
35
+ export function headOid(rootDir: string): string {
36
+ return git(rootDir, ["rev-parse", "HEAD"]);
37
+ }
38
+
39
+ export function assertHead(rootDir: string, expected: string): void {
40
+ if (headOid(rootDir) !== expected) {
41
+ throw new Error("HEAD changed during version preparation.");
42
+ }
43
+ }
44
+
45
+ export function changedPaths(rootDir: string): string[] {
46
+ return [
47
+ ...new Set(
48
+ [
49
+ git(rootDir, ["diff", "--name-only", "-z"]),
50
+ git(rootDir, ["diff", "--cached", "--name-only", "-z", "HEAD"]),
51
+ git(rootDir, ["ls-files", "--others", "--exclude-standard", "-z"]),
52
+ ].flatMap((output) => output.split("\0").filter(Boolean)),
53
+ ),
54
+ ];
55
+ }
56
+
57
+ export function assertClean(rootDir: string): void {
58
+ if (git(rootDir, ["status", "--porcelain", "--untracked-files=all"])) {
59
+ throw new Error("Version preparation requires a clean committed worktree.");
60
+ }
61
+ for (const state of [
62
+ "MERGE_HEAD",
63
+ "CHERRY_PICK_HEAD",
64
+ "REVERT_HEAD",
65
+ "rebase-merge",
66
+ "rebase-apply",
67
+ ]) {
68
+ const file = git(rootDir, ["rev-parse", "--git-path", state]);
69
+ if (existsSync(path.resolve(rootDir, file))) {
70
+ throw new Error(`Finish the in-progress Git operation (${state}) first.`);
71
+ }
72
+ }
73
+ try {
74
+ git(rootDir, ["symbolic-ref", "--quiet", "HEAD"]);
75
+ } catch {
76
+ throw new Error("Version preparation requires an attached branch.");
77
+ }
78
+ }
79
+
80
+ export function assertTracked(rootDir: string, file: string): void {
81
+ try {
82
+ git(rootDir, ["ls-files", "--error-unmatch", "--", file]);
83
+ } catch {
84
+ throw new Error(`${file} must be tracked; set versions.lockfile to null for projects without it.`);
85
+ }
86
+ }
87
+
88
+ export function assertAllowedPaths(
89
+ rootDir: string,
90
+ allowed: readonly string[],
91
+ ): void {
92
+ const unexpected = changedPaths(rootDir).filter(
93
+ (file) => !allowed.includes(file),
94
+ );
95
+ if (unexpected.length) {
96
+ throw new Error(
97
+ `Unexpected changes during version preparation: ${unexpected.join(", ")}`,
98
+ );
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Run a project command found on PATH outside the repository, without a shell.
104
+ * Its logs are diagnostics; stdout is reserved for the preparation receipt.
105
+ */
106
+ export function runProjectCommand(rootDir: string, argv: readonly string[]): void {
107
+ const [name, ...args] = argv;
108
+ const executable = outsideExecutable(name!, process.env, rootDir);
109
+ if (!executable) {
110
+ throw new Error(`${name} was not found on PATH outside the repository.`);
111
+ }
112
+ const result = spawnSync(executable.candidate, args, {
113
+ cwd: rootDir,
114
+ env: { ...process.env, GIT_NO_REPLACE_OBJECTS: "1" },
115
+ encoding: "utf8",
116
+ stdio: ["ignore", "pipe", "pipe"],
117
+ maxBuffer: 8 * 1024 * 1024,
118
+ });
119
+ process.stderr.write(result.stdout ?? "");
120
+ process.stderr.write(result.stderr ?? "");
121
+ if (result.error) throw result.error;
122
+ if (result.status !== 0) {
123
+ throw new Error(
124
+ `${argv.join(" ")} failed (${result.signal ?? result.status}).`,
125
+ );
126
+ }
127
+ }