@dynamicagents/plugins 0.11.1 → 0.13.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.
Files changed (134) hide show
  1. package/README.md +59 -79
  2. package/dist/browser/index.d.ts +0 -2
  3. package/dist/browser/index.js +21 -27
  4. package/dist/claude-code/cards.d.ts +53 -0
  5. package/dist/claude-code/cards.js +272 -0
  6. package/dist/claude-code/config.d.ts +73 -31
  7. package/dist/claude-code/config.js +0 -2
  8. package/dist/claude-code/credentials.d.ts +21 -9
  9. package/dist/claude-code/credentials.js +43 -9
  10. package/dist/claude-code/egress.d.ts +15 -14
  11. package/dist/claude-code/egress.js +51 -14
  12. package/dist/claude-code/events.d.ts +153 -9
  13. package/dist/claude-code/events.js +289 -58
  14. package/dist/claude-code/github.d.ts +39 -0
  15. package/dist/claude-code/github.js +82 -0
  16. package/dist/claude-code/index.d.ts +24 -80
  17. package/dist/claude-code/index.js +19 -234
  18. package/dist/claude-code/model.d.ts +152 -0
  19. package/dist/claude-code/model.js +342 -0
  20. package/dist/claude-code/run.d.ts +283 -92
  21. package/dist/claude-code/run.js +620 -114
  22. package/dist/claude-code/session.d.ts +131 -0
  23. package/dist/claude-code/session.js +285 -0
  24. package/dist/claude-code/spec.d.ts +46 -0
  25. package/dist/claude-code/spec.js +83 -0
  26. package/dist/claude-code/verified.d.ts +15 -0
  27. package/dist/claude-code/verified.js +15 -0
  28. package/dist/computer/context.d.ts +19 -0
  29. package/dist/computer/context.js +222 -0
  30. package/dist/computer/file-lock.d.ts +5 -0
  31. package/dist/computer/file-lock.js +40 -0
  32. package/dist/computer/gate.d.ts +12 -12
  33. package/dist/computer/gate.js +12 -12
  34. package/dist/computer/index.d.ts +57 -255
  35. package/dist/computer/index.js +98 -1039
  36. package/dist/computer/paths.d.ts +47 -22
  37. package/dist/computer/paths.js +64 -49
  38. package/dist/computer/proxy.d.ts +45 -0
  39. package/dist/computer/proxy.js +234 -0
  40. package/dist/computer/render.d.ts +27 -38
  41. package/dist/computer/render.js +32 -65
  42. package/dist/computer/tools-exec.d.ts +3 -0
  43. package/dist/computer/tools-exec.js +268 -0
  44. package/dist/computer/tools-file.d.ts +12 -0
  45. package/dist/computer/tools-file.js +64 -0
  46. package/dist/computer/tools-grep.d.ts +3 -0
  47. package/dist/computer/tools-grep.js +129 -0
  48. package/dist/repo/checkout.d.ts +35 -6
  49. package/dist/repo/checkout.js +64 -6
  50. package/dist/repo/context.d.ts +135 -0
  51. package/dist/repo/context.js +582 -0
  52. package/dist/repo/index.d.ts +79 -57
  53. package/dist/repo/index.js +118 -1112
  54. package/dist/repo/tools-checks.d.ts +3 -0
  55. package/dist/repo/tools-checks.js +112 -0
  56. package/dist/repo/tools-clone.d.ts +4 -0
  57. package/dist/repo/tools-clone.js +192 -0
  58. package/dist/repo/tools-pr.d.ts +14 -0
  59. package/dist/repo/tools-pr.js +196 -0
  60. package/dist/repo/tools-review.d.ts +12 -0
  61. package/dist/repo/tools-review.js +393 -0
  62. package/dist/repo/tools-worktree.d.ts +4 -0
  63. package/dist/repo/tools-worktree.js +305 -0
  64. package/dist/repo/tools-worktrees.d.ts +12 -0
  65. package/dist/repo/tools-worktrees.js +48 -0
  66. package/dist/repo/url.d.ts +2 -2
  67. package/dist/repo/url.js +7 -7
  68. package/dist/scratch/index.d.ts +4 -4
  69. package/dist/scratch/index.js +12 -11
  70. package/dist/{computer → workspace}/advisory.d.ts +33 -20
  71. package/dist/{computer → workspace}/advisory.js +32 -7
  72. package/dist/workspace/ca-trust.d.ts +95 -0
  73. package/dist/workspace/ca-trust.js +312 -0
  74. package/dist/workspace/container-deps.d.ts +54 -0
  75. package/dist/workspace/container-deps.js +224 -0
  76. package/dist/workspace/container-fault.d.ts +38 -0
  77. package/dist/workspace/container-fault.js +73 -0
  78. package/dist/workspace/exec.d.ts +94 -0
  79. package/dist/workspace/exec.js +153 -0
  80. package/dist/workspace/format.d.ts +36 -0
  81. package/dist/workspace/format.js +57 -0
  82. package/dist/workspace/git-host.d.ts +79 -0
  83. package/dist/workspace/git-host.js +226 -0
  84. package/dist/workspace/git-identity.d.ts +57 -0
  85. package/dist/workspace/git-identity.js +147 -0
  86. package/dist/workspace/index.d.ts +43 -47
  87. package/dist/workspace/index.js +52 -112
  88. package/dist/workspace/install-job.d.ts +121 -0
  89. package/dist/workspace/install-job.js +866 -0
  90. package/dist/{computer → workspace}/install.d.ts +15 -19
  91. package/dist/{computer → workspace}/install.js +8 -11
  92. package/dist/workspace/object.d.ts +502 -0
  93. package/dist/workspace/object.js +1427 -0
  94. package/dist/workspace/open.d.ts +95 -0
  95. package/dist/workspace/open.js +63 -0
  96. package/dist/workspace/read.d.ts +16 -0
  97. package/dist/workspace/read.js +22 -0
  98. package/dist/workspace/shell.d.ts +45 -0
  99. package/dist/workspace/shell.js +80 -0
  100. package/dist/workspace/sync.d.ts +111 -0
  101. package/dist/workspace/sync.js +124 -0
  102. package/dist/workspace/wake.d.ts +19 -0
  103. package/dist/workspace/wake.js +10 -0
  104. package/package.json +28 -42
  105. package/dist/arc-agi/analysis.d.ts +0 -336
  106. package/dist/arc-agi/analysis.js +0 -829
  107. package/dist/arc-agi/client.d.ts +0 -82
  108. package/dist/arc-agi/client.js +0 -113
  109. package/dist/arc-agi/game-tools.d.ts +0 -25
  110. package/dist/arc-agi/game-tools.js +0 -26
  111. package/dist/arc-agi/index.d.ts +0 -42
  112. package/dist/arc-agi/index.js +0 -175
  113. package/dist/arc-agi/main-agent.d.ts +0 -27
  114. package/dist/arc-agi/main-agent.js +0 -65
  115. package/dist/arc-agi/recipe.d.ts +0 -51
  116. package/dist/arc-agi/recipe.js +0 -100
  117. package/dist/arc-agi/scorecard.d.ts +0 -118
  118. package/dist/arc-agi/scorecard.js +0 -163
  119. package/dist/arc-agi/soul.d.ts +0 -40
  120. package/dist/arc-agi/soul.js +0 -90
  121. package/dist/arc-agi/store.d.ts +0 -154
  122. package/dist/arc-agi/store.js +0 -216
  123. package/dist/arc-agi/tools.d.ts +0 -36
  124. package/dist/arc-agi/tools.js +0 -576
  125. package/dist/arc-agi/types.d.ts +0 -212
  126. package/dist/arc-agi/types.js +0 -19
  127. package/dist/claude-code/recipe.d.ts +0 -53
  128. package/dist/claude-code/recipe.js +0 -100
  129. package/dist/computer/read.d.ts +0 -137
  130. package/dist/computer/read.js +0 -203
  131. package/dist/recall/index.d.ts +0 -99
  132. package/dist/recall/index.js +0 -171
  133. package/dist/triage/index.d.ts +0 -355
  134. package/dist/triage/index.js +0 -302
@@ -0,0 +1,3 @@
1
+ import type { ToolSet } from "ai";
2
+ import type { RepoContext } from "./context.js";
3
+ export declare function checkTools(ctx: RepoContext): ToolSet;
@@ -0,0 +1,112 @@
1
+ import { tool } from "ai";
2
+ import { z } from "zod";
3
+ import { GITHUB_PAGE_SIZE } from "./context.js";
4
+ /** A check run's conclusion that is not a failure. */
5
+ const PASSING_CONCLUSIONS = new Set(["success", "neutral", "skipped"]);
6
+ /**
7
+ * Both of GitHub's CI APIs, because a repository may report through either:
8
+ * Actions and apps through check runs, older integrations through statuses.
9
+ */
10
+ function readChecks(runs, statuses) {
11
+ const fromRuns = runs.map((run) => {
12
+ const name = run.name ?? "(unnamed check)";
13
+ const url = run.html_url ?? run.details_url;
14
+ if (run.status !== "completed")
15
+ return { name, outcome: "pending", ...(url ? { url } : {}) };
16
+ const conclusion = run.conclusion ?? "unknown";
17
+ return PASSING_CONCLUSIONS.has(conclusion)
18
+ ? { name, outcome: "passed" }
19
+ : {
20
+ name,
21
+ outcome: "failed",
22
+ detail: conclusion,
23
+ ...(url ? { url } : {})
24
+ };
25
+ });
26
+ const fromStatuses = statuses.map((status) => {
27
+ const name = status.context ?? "(unnamed status)";
28
+ const url = status.target_url ?? undefined;
29
+ switch (status.state) {
30
+ case "success":
31
+ return { name, outcome: "passed" };
32
+ case "pending":
33
+ return { name, outcome: "pending", ...(url ? { url } : {}) };
34
+ default:
35
+ return {
36
+ name,
37
+ outcome: "failed",
38
+ detail: status.state ?? "unknown",
39
+ ...(url ? { url } : {})
40
+ };
41
+ }
42
+ });
43
+ return [...fromRuns, ...fromStatuses];
44
+ }
45
+ function listed(checks) {
46
+ return checks
47
+ .map((c) => `- ${c.name}${c.detail ? ` (${c.detail})` : ""}${c.url ? ` ${c.url}` : ""}`)
48
+ .join("\n");
49
+ }
50
+ export function checkTools(ctx) {
51
+ const { bounded, github, githubRepo } = ctx;
52
+ return {
53
+ repo_pr_checks: tool({
54
+ description: "Say how a pull request's checks stand on its latest commit — CI that is still running, failed or passed — in the repository you have checked out. Use it to know whether CI has finished and what failed, without waiting inside a session for it.",
55
+ inputSchema: z.object({
56
+ dir: z.string().describe("The checkout directory"),
57
+ number: z.number().int().positive().describe("Pull request number")
58
+ }),
59
+ execute: async ({ dir, number }) => {
60
+ const target = await githubRepo(dir);
61
+ if ("refusal" in target)
62
+ return target.refusal;
63
+ const { owner, repo } = target;
64
+ const pr = await github("repo_pr_checks", `/repos/${owner}/${repo}/pulls/${number}`);
65
+ if (!pr.ok)
66
+ return bounded(pr.message);
67
+ const sha = pr.data.head?.sha;
68
+ if (!sha)
69
+ return `#${number} is not a pull request in ${owner}/${repo}`;
70
+ const short = sha.slice(0, 7);
71
+ const runs = await github("repo_pr_checks", `/repos/${owner}/${repo}/commits/${sha}/check-runs?per_page=${GITHUB_PAGE_SIZE}`);
72
+ if (!runs.ok)
73
+ return bounded(runs.message);
74
+ // The combined status holds the latest per context, so one page is
75
+ // every context there is.
76
+ const statuses = await github("repo_pr_checks", `/repos/${owner}/${repo}/commits/${sha}/status?per_page=${GITHUB_PAGE_SIZE}`);
77
+ if (!statuses.ok)
78
+ return bounded(statuses.message);
79
+ const runData = runs.data;
80
+ const statusData = statuses.data;
81
+ const checks = readChecks(runData.check_runs ?? [], statusData.statuses ?? []);
82
+ if (checks.length === 0) {
83
+ return (`No checks have reported on #${number}'s latest commit (${short}). ` +
84
+ "CI that runs on pushes registers within a minute or so of one; " +
85
+ "after that, this repository runs none for this pull request.");
86
+ }
87
+ const failed = checks.filter((c) => c.outcome === "failed");
88
+ const pending = checks.filter((c) => c.outcome === "pending");
89
+ const passed = checks.length - failed.length - pending.length;
90
+ // Either API can run past a page, and what is past it may be the one
91
+ // failure, so an unread page is never "all passed".
92
+ const unread = [
93
+ [runData.total_count ?? 0, "check runs"],
94
+ [statusData.total_count ?? 0, "commit statuses"]
95
+ ]
96
+ .filter(([total]) => total > GITHUB_PAGE_SIZE)
97
+ .map(([total, what]) => `Only the first ${GITHUB_PAGE_SIZE} of ${total} ${what} were read.`);
98
+ const truncated = unread.length > 0;
99
+ return bounded([
100
+ `Checks on #${number} at ${short}: ${failed.length} failed, ` +
101
+ `${pending.length} still running, ${passed} passed.`,
102
+ ...(failed.length ? ["", "Failed:", listed(failed)] : []),
103
+ ...(pending.length ? ["", "Still running:", listed(pending)] : []),
104
+ ...(truncated ? ["", ...unread] : []),
105
+ ...(!failed.length && !pending.length && !truncated
106
+ ? ["", "Every check has finished and passed."]
107
+ : [])
108
+ ].join("\n"));
109
+ }
110
+ })
111
+ };
112
+ }
@@ -0,0 +1,4 @@
1
+ import type { ToolSet } from "ai";
2
+ import type { RepoContext } from "./context.js";
3
+ /** Putting a checkout on disk, fresh or refreshed. */
4
+ export declare function cloneTools(ctx: RepoContext): ToolSet;
@@ -0,0 +1,192 @@
1
+ import { tool } from "ai";
2
+ import { z } from "zod";
3
+ import { parseRepo, repoLocation, UNSAFE_BRANCH } from "./url.js";
4
+ import { refreshCheckout, writeGitIdentity } from "./checkout.js";
5
+ /** Putting a checkout on disk, fresh or refreshed. */
6
+ export function cloneTools(ctx) {
7
+ const { config, workdir, author, allowedHosts, bounded, notifyCheckout, logFailure, plain, runGit, fetchOrigin } = ctx;
8
+ return {
9
+ repo_clone: tool({
10
+ description: "Clone a git repository into the workspace. Returns the checkout path and the branch you landed on. If the repository is already checked out from an earlier task, it is fetched and reset to the remote instead — unless it has uncommitted changes, which are left alone for you to inspect.",
11
+ inputSchema: z.object({
12
+ url: z.string().describe("HTTPS repository URL"),
13
+ branch: z
14
+ .string()
15
+ .optional()
16
+ .describe("Branch to check out (default: the repo's default)"),
17
+ depth: z
18
+ .number()
19
+ .optional()
20
+ .describe("Shallow-clone depth. Omit for full history; needed if you must rebase.")
21
+ }),
22
+ execute: async ({ url, branch, depth }) => {
23
+ // The gate, before anything runs. `url` is model input, and a clone is
24
+ // the one command that hands a credential to a host it names.
25
+ const location = repoLocation(url);
26
+ if (!location || !allowedHosts.includes(location.host)) {
27
+ return (`refusing to clone from "${location?.host ?? url}" — this agent may ` +
28
+ `only clone over https from: ${allowedHosts.join(", ")}`);
29
+ }
30
+ // Everything past this point travels the canonical form, so what the
31
+ // host clones, what lands in `.git/config`, and what `afterCheckout`
32
+ // records are one string. Refusals above still quote what the model
33
+ // actually sent.
34
+ const { host, url: target } = location;
35
+ // Refused rather than worked around. A URL that passes the host check
36
+ // but names no repository is what a model copies out of a browser —
37
+ // `.../tree/main`, `.../pull/4` — and carrying on means cloning without
38
+ // telling `beforeCheckout` which repository this is, so a host keying its
39
+ // filesystem per repository never switches and the checkout lands in
40
+ // whichever workspace was already open.
41
+ //
42
+ // Not silently trimmed to the first two segments either, tempting as it
43
+ // is: that reads `.../orgs/x/repositories` as the repository `x`, and
44
+ // quietly reinterpreting the target is the wrong instinct for the one
45
+ // tool that offers a credential to a host on the model's say-so. A
46
+ // refusal costs one turn and says exactly what to send instead.
47
+ const parsed = parseRepo(target, allowedHosts);
48
+ if (!parsed) {
49
+ return (`refusing to clone "${url}" — it does not name a repository. ` +
50
+ `A clone URL is https://<host>/<owner>/<repo>, so if this came out of ` +
51
+ `a browser, drop everything after the repository name (the /tree/…, ` +
52
+ `/pull/… or /blob/… part) and try again.`);
53
+ }
54
+ // The same guard `repo_push` applies, at the second door a branch name
55
+ // enters by. `refreshCheckout` runs `git checkout "$REPO_BRANCH"`, and
56
+ // quoting stops word-splitting but *not* option parsing: a `branch` of
57
+ // `--detach` arrives as an option, git detaches HEAD, the `reset` that
58
+ // follows fails against `origin/--detach`, and the checkout a later task
59
+ // inherits has moved for reasons nothing reported.
60
+ //
61
+ // Checked here rather than in `refreshCheckout` because this is where
62
+ // model input arrives and where the message can say what to send instead.
63
+ // It also covers the fresh-clone path, where the name reaches
64
+ // isomorphic-git as a ref: no argv there, so no option to parse, but an
65
+ // unresolvable ref is a worse error than this sentence.
66
+ if (branch !== undefined && UNSAFE_BRANCH.test(branch))
67
+ return `"${branch}" is not a plain branch name — use something like "main"`;
68
+ const dir = `${workdir}/${parsed.repo}`;
69
+ // Before anything runs, and before `dir` is touched: a host keying its
70
+ // filesystem per repository needs to have switched by now. See
71
+ // `beforeCheckout` for why this cannot wait for the clone to finish.
72
+ //
73
+ // A throw here stops the clone, which is the exact opposite of how
74
+ // `afterCheckout` is treated — and the asymmetry is the point rather than
75
+ // an inconsistency. This hook *chooses the workspace*; carrying on past a
76
+ // failed one would put the checkout wherever the last task left the
77
+ // selection, which is the failure the hook exists to prevent. By the time
78
+ // `afterCheckout` runs there is a checkout on disk and the model needs to
79
+ // be told about it, so that one is caught and logged.
80
+ try {
81
+ config.beforeCheckout?.({
82
+ url: target,
83
+ host,
84
+ owner: parsed.owner,
85
+ repo: parsed.repo
86
+ });
87
+ }
88
+ catch (err) {
89
+ console.error("[repo] beforeCheckout failed", {
90
+ url,
91
+ repo: `${parsed.owner}/${parsed.repo}`,
92
+ err: String(err)
93
+ });
94
+ return (`could not select a workspace for ${parsed.owner}/${parsed.repo}: ` +
95
+ `${String(err)}\nNothing was cloned — going ahead would have put the ` +
96
+ `checkout in whichever workspace was already open.`);
97
+ }
98
+ // The workspace outlives the task, so this path may already hold the
99
+ // checkout a previous task left — see `refreshCheckout`.
100
+ const existing = await plain("rev-parse --git-dir", dir);
101
+ // A failed probe means "nothing here, clone it" — but only when git
102
+ // answered. A command that never ran means nothing, and the distinction
103
+ // is not academic: a replaced container is precisely the case where this
104
+ // call fails and the *next* one lands on a working replacement. Read as
105
+ // "empty", it points `git clone` at a directory that already holds the
106
+ // checkout — which fails with `destination path already exists` and skips
107
+ // the refresh that was the right answer all along.
108
+ if (existing.unreachable)
109
+ return bounded(`could not clone ${url}: ${existing.stderr}`);
110
+ if (existing.success) {
111
+ const refreshed = await refreshCheckout({
112
+ dir,
113
+ url: target,
114
+ branch,
115
+ author,
116
+ plain,
117
+ fetchOrigin
118
+ });
119
+ if (refreshed.branch) {
120
+ await notifyCheckout({
121
+ dir,
122
+ url: target,
123
+ host,
124
+ repo: `${parsed.owner}/${parsed.repo}`,
125
+ branch: refreshed.branch,
126
+ fresh: false
127
+ });
128
+ }
129
+ return refreshed.message;
130
+ }
131
+ // `depth` is bounded here rather than trusted from the schema. It no
132
+ // longer reaches a shell, so this is arithmetic rather than quoting: a
133
+ // fractional or negative depth is not something to hand to git.
134
+ const result = await runGit(() => config.git.clone({
135
+ url: target,
136
+ dir,
137
+ allowedHosts,
138
+ ...(branch ? { branch } : {}),
139
+ ...(depth ? { depth: Math.max(1, Math.floor(depth)) } : {})
140
+ }));
141
+ if (!result.success) {
142
+ logFailure("repo_clone", result);
143
+ return bounded(`clone failed: ${result.stderr || result.stdout}`);
144
+ }
145
+ // Identity has to exist before the first commit. Written through the
146
+ // container, and deliberately: the commits it names are made there, by
147
+ // `repo_commit`, with no credential in sight — only the three operations
148
+ // that talk to GitHub moved.
149
+ //
150
+ // Why a refresh rewrites it, and what answers for a repository this
151
+ // plugin never cloned, is on
152
+ // {@link file://./checkout.ts writeGitIdentity}.
153
+ await writeGitIdentity(plain, dir, author);
154
+ // Two gits now share one `.git`, and this is where they could disagree.
155
+ //
156
+ // The container's git and the host's isomorphic-git read and write the
157
+ // same index. isomorphic-git understands version 2 and the extensions
158
+ // git writes for its own caches are not part of that contract, so a
159
+ // repository that picked up an untracked cache or a split index — from a
160
+ // `feature.manyFiles` default, or a user config in some future image —
161
+ // would be written by one and misread by the other. Pinned at clone
162
+ // time, repo-locally, where it costs three commands and cannot surprise
163
+ // anyone later.
164
+ //
165
+ // Unchecked deliberately, like the upstream-tracking pair in
166
+ // `repo_push`: git's own defaults are already these values, so a failure
167
+ // here means the config could not be written at all, which the very next
168
+ // command will say far more clearly than this one could.
169
+ await plain(`config index.version 2`, dir);
170
+ await plain(`config core.untrackedCache false`, dir);
171
+ await plain(`config core.splitIndex false`, dir);
172
+ // The clone reports the branch it landed on, so nothing has to ask git
173
+ // afterwards — and a failed question would fire `afterCheckout` with no
174
+ // branch at all, starting an install against a checkout nobody can name.
175
+ const landed = result.stdout.trim() || branch;
176
+ if (!landed) {
177
+ logFailure("repo_clone", result);
178
+ return bounded(`cloned to ${dir}, but could not read which branch it landed on`);
179
+ }
180
+ await notifyCheckout({
181
+ dir,
182
+ url: target,
183
+ host,
184
+ repo: `${parsed.owner}/${parsed.repo}`,
185
+ branch: landed,
186
+ fresh: true
187
+ });
188
+ return `cloned to ${dir} on branch ${landed}`;
189
+ }
190
+ })
191
+ };
192
+ }
@@ -0,0 +1,14 @@
1
+ import { type Action } from "@cloudflare/think";
2
+ import type { ToolSet } from "ai";
3
+ import type { RepoContext } from "./context.js";
4
+ /** GitHub's REST surface: pull requests, issues and their comments. */
5
+ export declare function prTools(ctx: RepoContext): ToolSet;
6
+ /**
7
+ * The GitHub writes a recovered turn must not repeat, as Think actions.
8
+ *
9
+ * Every failure is thrown rather than returned. Think settles what an action
10
+ * returns and replays it for the life of the key, so a refusal returned as a
11
+ * string would answer every retry in the task; a throw releases the key, and
12
+ * the retry runs.
13
+ */
14
+ export declare function prActions(ctx: RepoContext): Record<string, Action>;
@@ -0,0 +1,196 @@
1
+ import { action } from "@cloudflare/think";
2
+ import { tool } from "ai";
3
+ import { z } from "zod";
4
+ import { actionTask, GITHUB_ACTION_TIMEOUT_MS, GITHUB_PAGE_SIZE, sha256 } from "./context.js";
5
+ /** GitHub's REST surface: pull requests, issues and their comments. */
6
+ export function prTools(ctx) {
7
+ const { bounded, github, githubRepo } = ctx;
8
+ return {
9
+ repo_open_pr: tool({
10
+ description: "Open a pull request for a pushed branch and return its URL. Do this once the branch is pushed and the tests pass.",
11
+ inputSchema: z.object({
12
+ dir: z.string().describe("The checkout directory"),
13
+ head: z.string().describe("The branch you pushed"),
14
+ base: z.string().describe("The branch to merge into, e.g. 'main'"),
15
+ title: z.string().describe("Pull request title"),
16
+ body: z
17
+ .string()
18
+ .describe("Pull request description — what changed and why, and how you verified it")
19
+ }),
20
+ execute: async ({ dir, head, base, title, body }) => {
21
+ // The checkout's own origin, never a URL the model names — the rule the
22
+ // three read tools follow, and it binds harder here because this one
23
+ // writes. A repository named at the call site is bounded only by the
24
+ // host allowlist, so an agent talked into naming one could open a pull
25
+ // request on anything the token can write to.
26
+ const target = await githubRepo(dir);
27
+ if ("refusal" in target)
28
+ return target.refusal;
29
+ const { owner, repo } = target;
30
+ // One may be open already: a call abandoned before its answer arrived
31
+ // still opened it, and the retry that follows is the ordinary case.
32
+ // Best-effort — a lookup that fails leaves the POST to answer for itself.
33
+ // The filter wants `owner:branch`; a fork's head already says whose.
34
+ const filter = new URLSearchParams({
35
+ state: "open",
36
+ head: head.includes(":")
37
+ ? head
38
+ : `${decodeURIComponent(owner)}:${head}`,
39
+ base
40
+ });
41
+ const existing = await github("repo_open_pr", `/repos/${owner}/${repo}/pulls?${filter}`);
42
+ const open = existing.ok && Array.isArray(existing.data)
43
+ ? existing.data[0]
44
+ : undefined;
45
+ if (open?.html_url)
46
+ return `already open, nothing new was created: ${open.html_url}`;
47
+ const opened = await github("repo_open_pr", `/repos/${owner}/${repo}/pulls`, { method: "POST", body: { title, head, base, body } });
48
+ if (!opened.ok)
49
+ // A POST whose answer never arrived may still have been received, and
50
+ // a blind retry is how one pull request becomes two.
51
+ return bounded(`${opened.message}\nIf this was a timeout rather than a rejection the ` +
52
+ `pull request may have been opened anyway — check with repo_pr_view ` +
53
+ `before retrying, or the retry will open a second one alongside it.`);
54
+ const data = opened.data;
55
+ return (data.html_url ??
56
+ "pull request opened, but the response carried no URL");
57
+ }
58
+ }),
59
+ repo_issue_view: tool({
60
+ description: "Read an issue or pull request from the repository you have checked out: its title, state, description and comments. Use this when a task refers to an issue number, before guessing what it asks for.",
61
+ inputSchema: z.object({
62
+ dir: z.string().describe("The checkout directory"),
63
+ number: z.number().int().positive().describe("Issue or PR number")
64
+ }),
65
+ execute: async ({ dir, number }) => {
66
+ const target = await githubRepo(dir);
67
+ if ("refusal" in target)
68
+ return target.refusal;
69
+ const { owner, repo } = target;
70
+ // The issues endpoint, deliberately, even for a pull request: GitHub
71
+ // models every PR as an issue, and this is the one that carries the
72
+ // discussion. `repo_pr_view` is for the parts that are only a PR's.
73
+ const issue = await github("repo_issue_view", `/repos/${owner}/${repo}/issues/${number}`);
74
+ if (!issue.ok)
75
+ return bounded(issue.message);
76
+ const data = issue.data;
77
+ const comments = await github("repo_issue_view", `/repos/${owner}/${repo}/issues/${number}/comments?per_page=${GITHUB_PAGE_SIZE}`);
78
+ // A failure here is not a failure of the tool: the issue itself was
79
+ // read, and half an answer beats none.
80
+ const thread = (comments.ok && Array.isArray(comments.data) ? comments.data : []);
81
+ // A full page means there is very likely another. Said out loud, because
82
+ // this thread is what the model reasons from: comments arrive oldest
83
+ // first, so the ones it cannot see are the most recent — the review
84
+ // feedback, on the pull request busy enough to have overflowed.
85
+ const moreComments = thread.length >= GITHUB_PAGE_SIZE;
86
+ return bounded([
87
+ `#${number} ${data.title ?? "(no title)"} [${data.state ?? "?"}]` +
88
+ (data.pull_request ? " (pull request)" : ""),
89
+ `opened by ${data.user?.login ?? "unknown"}`,
90
+ "",
91
+ data.body?.trim() || "(no description)",
92
+ ...thread.map((c) => `\n--- ${c.user?.login ?? "unknown"} ---\n${c.body?.trim() ?? ""}`),
93
+ ...(comments.ok ? [] : [`\n(comments could not be read)`]),
94
+ ...(moreComments
95
+ ? [
96
+ `\n(showing the first ${GITHUB_PAGE_SIZE} comments; the thread is ` +
97
+ `longer, and the newest are not among them)`
98
+ ]
99
+ : [])
100
+ ].join("\n"));
101
+ }
102
+ }),
103
+ repo_pr_view: tool({
104
+ description: "Read a pull request's state and the files it touches — whether it is mergeable, its review state, and which paths changed. Use this to check on a pull request you opened, or to see what an existing one does before duplicating it.",
105
+ inputSchema: z.object({
106
+ dir: z.string().describe("The checkout directory"),
107
+ number: z.number().int().positive().describe("Pull request number")
108
+ }),
109
+ execute: async ({ dir, number }) => {
110
+ const target = await githubRepo(dir);
111
+ if ("refusal" in target)
112
+ return target.refusal;
113
+ const { owner, repo } = target;
114
+ const pr = await github("repo_pr_view", `/repos/${owner}/${repo}/pulls/${number}`);
115
+ if (!pr.ok)
116
+ return bounded(pr.message);
117
+ const data = pr.data;
118
+ const files = await github("repo_pr_view", `/repos/${owner}/${repo}/pulls/${number}/files?per_page=${GITHUB_PAGE_SIZE}`);
119
+ const changed = (files.ok && Array.isArray(files.data) ? files.data : []);
120
+ const moreFiles = changed.length >= GITHUB_PAGE_SIZE;
121
+ return bounded([
122
+ `#${number} ${data.title ?? "(no title)"}`,
123
+ `${data.merged ? "merged" : (data.state ?? "?")}` +
124
+ (data.draft ? " (draft)" : "") +
125
+ // `mergeable` is computed asynchronously by GitHub and is `null`
126
+ // until it has been, which is common on a pull request opened
127
+ // seconds ago — exactly when this tool is most likely to be called.
128
+ (data.mergeable === null
129
+ ? ", mergeability not yet computed"
130
+ : data.mergeable === false
131
+ ? ", NOT mergeable"
132
+ : ""),
133
+ `${data.head?.ref ?? "?"} → ${data.base?.ref ?? "?"}`,
134
+ data.html_url ?? "",
135
+ "",
136
+ changed.length
137
+ ? changed
138
+ .map((f) => ` ${f.filename ?? "?"} (+${f.additions ?? 0} -${f.deletions ?? 0})`)
139
+ .join("\n")
140
+ : files.ok
141
+ ? " (no files reported)"
142
+ : " (changed files could not be read)",
143
+ ...(moreFiles
144
+ ? [
145
+ `\n(showing the first ${GITHUB_PAGE_SIZE} files; this pull request ` +
146
+ `touches more)`
147
+ ]
148
+ : [])
149
+ ].join("\n"));
150
+ }
151
+ })
152
+ };
153
+ }
154
+ /**
155
+ * The GitHub writes a recovered turn must not repeat, as Think actions.
156
+ *
157
+ * Every failure is thrown rather than returned. Think settles what an action
158
+ * returns and replays it for the life of the key, so a refusal returned as a
159
+ * string would answer every retry in the task; a throw releases the key, and
160
+ * the retry runs.
161
+ */
162
+ export function prActions(ctx) {
163
+ const { bounded, github, githubRepo } = ctx;
164
+ const commentInput = z.object({
165
+ dir: z.string().describe("The checkout directory"),
166
+ number: z
167
+ .number()
168
+ .int()
169
+ .positive()
170
+ .describe("Pull request or issue number"),
171
+ body: z.string().describe("The comment, as markdown")
172
+ });
173
+ return {
174
+ repo_pr_comment: action({
175
+ description: "Leave a comment on a pull request or issue in the repository you have checked out. Use this to report what you did, or to answer a review — not to announce work you have not finished. The same comment twice in one task is posted once.",
176
+ inputSchema: commentInput,
177
+ timeoutMs: GITHUB_ACTION_TIMEOUT_MS,
178
+ idempotencyKey: async ({ input, ctx: turn }) => `${actionTask(turn)}:${input.dir}#${input.number}:comment:${await sha256(input.body)}`,
179
+ execute: async ({ dir, number, body }) => {
180
+ const target = await githubRepo(dir);
181
+ if ("refusal" in target)
182
+ throw new Error(target.refusal);
183
+ const { owner, repo } = target;
184
+ const posted = await github("repo_pr_comment", `/repos/${owner}/${repo}/issues/${number}/comments`, { method: "POST", body: { body } });
185
+ if (!posted.ok)
186
+ // The same hazard `repo_open_pr` names, for the same reason: a POST
187
+ // whose answer never arrived may still have been received. The key
188
+ // is released, so a retry posts again.
189
+ throw new Error(bounded(`${posted.message}\nIf this was a timeout rather than a rejection the ` +
190
+ `comment may exist anyway — read it back with repo_issue_view before retrying.`));
191
+ const data = posted.data;
192
+ return data.html_url ?? `commented on #${number}`;
193
+ }
194
+ })
195
+ };
196
+ }
@@ -0,0 +1,12 @@
1
+ import { type Action } from "@cloudflare/think";
2
+ import type { ToolSet } from "ai";
3
+ import type { RepoContext } from "./context.js";
4
+ export declare function reviewTools(ctx: RepoContext): ToolSet;
5
+ /**
6
+ * Answering a thread, as a Think action — see
7
+ * {@link file://./tools-pr.ts prActions} for why every failure is
8
+ * thrown. The one outcome returned short of success is a reply that landed on
9
+ * a thread that did not resolve: that reply must never be sent again, so it is
10
+ * settled, and the resolve-only call it asks for is a different key.
11
+ */
12
+ export declare function reviewActions(ctx: RepoContext): Record<string, Action>;