@dynamicagents/plugins 0.11.0 → 0.12.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 (95) hide show
  1. package/README.md +12 -13
  2. package/dist/arc-agi/analysis.d.ts +16 -30
  3. package/dist/arc-agi/analysis.js +20 -47
  4. package/dist/arc-agi/client.d.ts +3 -2
  5. package/dist/arc-agi/game-tools.d.ts +5 -6
  6. package/dist/arc-agi/index.d.ts +6 -8
  7. package/dist/arc-agi/index.js +3 -3
  8. package/dist/arc-agi/main-agent.d.ts +5 -8
  9. package/dist/arc-agi/main-agent.js +5 -8
  10. package/dist/arc-agi/recipe.d.ts +17 -20
  11. package/dist/arc-agi/recipe.js +17 -20
  12. package/dist/arc-agi/soul.d.ts +16 -27
  13. package/dist/arc-agi/soul.js +16 -27
  14. package/dist/arc-agi/store.d.ts +4 -5
  15. package/dist/arc-agi/store.js +4 -5
  16. package/dist/arc-agi/tools.d.ts +1 -1
  17. package/dist/arc-agi/tools.js +26 -19
  18. package/dist/arc-agi/types.d.ts +6 -7
  19. package/dist/claude-code/config.d.ts +88 -11
  20. package/dist/claude-code/config.js +11 -2
  21. package/dist/claude-code/credentials.d.ts +21 -9
  22. package/dist/claude-code/credentials.js +43 -9
  23. package/dist/claude-code/egress.d.ts +7 -13
  24. package/dist/claude-code/egress.js +9 -10
  25. package/dist/claude-code/events.d.ts +47 -0
  26. package/dist/claude-code/events.js +96 -10
  27. package/dist/claude-code/index.d.ts +31 -13
  28. package/dist/claude-code/index.js +62 -15
  29. package/dist/claude-code/run.d.ts +135 -41
  30. package/dist/claude-code/run.js +366 -61
  31. package/dist/computer/advisory.d.ts +31 -18
  32. package/dist/computer/advisory.js +30 -5
  33. package/dist/computer/context.d.ts +23 -0
  34. package/dist/computer/context.js +225 -0
  35. package/dist/computer/file-lock.d.ts +5 -0
  36. package/dist/computer/file-lock.js +39 -0
  37. package/dist/computer/gate.d.ts +8 -8
  38. package/dist/computer/gate.js +7 -7
  39. package/dist/computer/host/ca-trust.d.ts +88 -0
  40. package/dist/computer/host/ca-trust.js +303 -0
  41. package/dist/computer/host/container-deps.d.ts +54 -0
  42. package/dist/computer/host/container-deps.js +224 -0
  43. package/dist/computer/host/container-fault.d.ts +38 -0
  44. package/dist/computer/host/container-fault.js +58 -0
  45. package/dist/computer/host/git-host.d.ts +62 -0
  46. package/dist/computer/host/git-host.js +156 -0
  47. package/dist/computer/host/git-identity.d.ts +57 -0
  48. package/dist/computer/host/git-identity.js +147 -0
  49. package/dist/computer/host/install-job.d.ts +121 -0
  50. package/dist/computer/host/install-job.js +766 -0
  51. package/dist/computer/host/sync.d.ts +111 -0
  52. package/dist/computer/host/sync.js +124 -0
  53. package/dist/computer/host/wake.d.ts +19 -0
  54. package/dist/computer/host/wake.js +10 -0
  55. package/dist/computer/host/workspace.d.ts +424 -0
  56. package/dist/computer/host/workspace.js +1258 -0
  57. package/dist/computer/index.d.ts +70 -50
  58. package/dist/computer/index.js +79 -767
  59. package/dist/computer/install.d.ts +8 -12
  60. package/dist/computer/install.js +6 -9
  61. package/dist/computer/paths.d.ts +21 -16
  62. package/dist/computer/paths.js +40 -38
  63. package/dist/computer/read.d.ts +18 -22
  64. package/dist/computer/read.js +24 -27
  65. package/dist/computer/render.d.ts +28 -0
  66. package/dist/computer/render.js +34 -1
  67. package/dist/computer/shell.d.ts +45 -0
  68. package/dist/computer/shell.js +80 -0
  69. package/dist/computer/tools-exec.d.ts +3 -0
  70. package/dist/computer/tools-exec.js +269 -0
  71. package/dist/computer/tools-file.d.ts +4 -0
  72. package/dist/computer/tools-file.js +124 -0
  73. package/dist/computer/tools-find.d.ts +3 -0
  74. package/dist/computer/tools-find.js +216 -0
  75. package/dist/recall/index.d.ts +9 -3
  76. package/dist/recall/index.js +22 -12
  77. package/dist/repo/checkout.d.ts +34 -4
  78. package/dist/repo/checkout.js +36 -1
  79. package/dist/repo/context.d.ts +109 -0
  80. package/dist/repo/context.js +530 -0
  81. package/dist/repo/index.d.ts +6 -29
  82. package/dist/repo/index.js +17 -1027
  83. package/dist/repo/tools-clone.d.ts +4 -0
  84. package/dist/repo/tools-clone.js +192 -0
  85. package/dist/repo/tools-forge.d.ts +4 -0
  86. package/dist/repo/tools-forge.js +179 -0
  87. package/dist/repo/tools-review.d.ts +3 -0
  88. package/dist/repo/tools-review.js +373 -0
  89. package/dist/repo/tools-worktree.d.ts +4 -0
  90. package/dist/repo/tools-worktree.js +221 -0
  91. package/dist/triage/index.d.ts +6 -1
  92. package/dist/triage/index.js +32 -23
  93. package/dist/workspace/index.d.ts +3 -3
  94. package/dist/workspace/index.js +3 -3
  95. package/package.json +21 -15
@@ -0,0 +1,373 @@
1
+ import { tool } from "ai";
2
+ import { z } from "zod";
3
+ import { FORGE_PAGE_SIZE } from "./context.js";
4
+ /** A review as a conversation: whether it landed, what it said, and answering it. */
5
+ /**
6
+ * How many pages of review threads one `repo_pr_threads` call will walk.
7
+ *
8
+ * A bound rather than a full walk, for the reason {@link FORGE_PAGE_SIZE} is one
9
+ * — but unlike the single page everything else here takes, threads genuinely
10
+ * have to page: a review long enough to spill one puts its newest threads on the
11
+ * last page, and those are exactly the ones an agent was sent to answer. So this
12
+ * is high enough that a real review never reaches it and low enough that a
13
+ * pathological pull request cannot turn one tool call into an unbounded number of
14
+ * round trips.
15
+ */
16
+ const MAX_THREAD_PAGES = 10;
17
+ /**
18
+ * How much of one thread's conversation is read back.
19
+ *
20
+ * Both ends, because the two uses pull opposite ways: what the reviewer asked for
21
+ * is at the top, and whether a reply landed is at the bottom. A thread shorter
22
+ * than this is returned whole and the two selections are the same comments, which
23
+ * is why the render de-duplicates on the count rather than on the text.
24
+ */
25
+ const MAX_THREAD_COMMENTS = 6;
26
+ /**
27
+ * The review threads on one pull request.
28
+ *
29
+ * `isResolved` is the field the whole workflow turns on and REST does not expose
30
+ * at all — which is why these three operations are GraphQL while everything else
31
+ * in this file is REST. Both ends of each thread are selected; see
32
+ * {@link MAX_THREAD_COMMENTS} for why one end is not enough.
33
+ */
34
+ const REVIEW_THREADS_QUERY = `
35
+ query($owner:String!,$repo:String!,$number:Int!,$after:String){
36
+ repository(owner:$owner,name:$repo){
37
+ pullRequest(number:$number){
38
+ reviewThreads(first:${FORGE_PAGE_SIZE},after:$after){
39
+ pageInfo{hasNextPage endCursor}
40
+ nodes{
41
+ id isResolved isOutdated path line
42
+ opening: comments(first:1){nodes{author{login} body}}
43
+ latest: comments(last:${MAX_THREAD_COMMENTS}){totalCount nodes{author{login} body}}
44
+ }
45
+ }
46
+ }
47
+ }
48
+ }`;
49
+ /**
50
+ * Who is currently *asked* to review, apps included.
51
+ *
52
+ * REST's `pulls/{n}/requested_reviewers` carries `users` and `teams` and no
53
+ * third key, so a Bot reviewer — Copilot is one — is absent from a request that
54
+ * is live, and its answer for "Copilot is working on it right now" is byte for
55
+ * byte its answer for "nobody was ever asked". GraphQL types the requested
56
+ * reviewer as a union with `Bot` in it, which is what makes the two
57
+ * distinguishable at all.
58
+ */
59
+ const REVIEW_REQUESTS_QUERY = `
60
+ query($owner:String!,$repo:String!,$number:Int!){
61
+ repository(owner:$owner,name:$repo){
62
+ pullRequest(number:$number){
63
+ reviewRequests(first:${FORGE_PAGE_SIZE}){
64
+ nodes{
65
+ requestedReviewer{
66
+ ... on User{login}
67
+ ... on Bot{login}
68
+ ... on Team{slug}
69
+ }
70
+ }
71
+ }
72
+ }
73
+ }
74
+ }`;
75
+ /** Which pull request, in which repository, a thread id names. */
76
+ const THREAD_OWNER_QUERY = `
77
+ query($id:ID!){
78
+ node(id:$id){
79
+ ... on PullRequestReviewThread {
80
+ pullRequest{number repository{nameWithOwner}}
81
+ }
82
+ }
83
+ }`;
84
+ const THREAD_REPLY_MUTATION = `
85
+ mutation($id:ID!,$body:String!){
86
+ addPullRequestReviewThreadReply(input:{pullRequestReviewThreadId:$id,body:$body}){
87
+ comment{url}
88
+ }
89
+ }`;
90
+ const THREAD_RESOLVE_MUTATION = `
91
+ mutation($id:ID!){
92
+ resolveReviewThread(input:{threadId:$id}){thread{isResolved}}
93
+ }`;
94
+ /** One thread, as the model reads it: where it is, its id, and the conversation. */
95
+ function renderThread(t) {
96
+ const where = t.path
97
+ ? `${t.path}${typeof t.line === "number" ? `:${t.line}` : ""}`
98
+ : "(no file)";
99
+ const total = t.latest?.totalCount ?? 0;
100
+ const tail = t.latest?.nodes ?? [];
101
+ // Whole when it fits, and both ends with the middle named when it does not.
102
+ // The opening comment is the reviewer's actual point and the tail is where a
103
+ // reply this plugin sent would be, so neither end can be the one dropped.
104
+ const conversation = total > tail.length
105
+ ? [
106
+ ...(t.opening?.nodes ?? []),
107
+ {
108
+ author: { login: "…" },
109
+ body: `(${total - tail.length - 1} earlier replies not shown)`
110
+ },
111
+ ...tail
112
+ ]
113
+ : tail;
114
+ return [
115
+ `--- ${where}${t.isResolved ? " [resolved]" : ""}${t.isOutdated ? " [outdated]" : ""}`,
116
+ `id: ${t.id}`,
117
+ ...conversation.map((c) => `${c.author?.login ?? "unknown"}: ${c.body ?? ""}`)
118
+ ].join("\n");
119
+ }
120
+ /**
121
+ * The owner and repository as their own names, rather than as REST path segments.
122
+ *
123
+ * {@link file://./context.ts forgeRepo} percent-encodes both, because that is
124
+ * what makes a path safe. A GraphQL *variable* is not a path — it is compared
125
+ * against the repository's actual name — so an encoded one would silently stop
126
+ * matching the moment a name contains a character worth encoding.
127
+ */
128
+ function decoded(target) {
129
+ return {
130
+ owner: decodeURIComponent(target.owner),
131
+ repo: decodeURIComponent(target.repo)
132
+ };
133
+ }
134
+ /**
135
+ * The review bot's logins, which GitHub does not spell the same way twice.
136
+ *
137
+ * One account arrives as `Copilot` from the issue timeline, as
138
+ * `copilot-pull-request-reviewer` from GraphQL, and as
139
+ * `copilot-pull-request-reviewer[bot]` from `pulls/{n}/reviews`. Compared
140
+ * exactly against the login a caller names, the request matches and the review
141
+ * never does, so `repo_pr_review_status` reports "none pending — waiting will
142
+ * not change it" at the moment the review lands. That is the one answer a
143
+ * polling caller stops on, so the mismatch does not degrade the tool, it
144
+ * inverts it.
145
+ *
146
+ * An alternation of the spellings that account answers to, and deliberately not
147
+ * a `copilot-` prefix: `copilot-swe-agent` is a **different** account, one that
148
+ * opens pull requests rather than reviewing them, so a prefix would let its
149
+ * request or its authorship answer a question asked about the reviewer.
150
+ */
151
+ const COPILOT_LOGIN = /^copilot(-pull-request-reviewer)?$/;
152
+ /**
153
+ * Whether a login from the API is the reviewer the caller asked about.
154
+ *
155
+ * `[bot]` is a suffix REST appends to an app's login and GraphQL does not, so it
156
+ * is never part of the identity — stripping it is what lets a caller name any
157
+ * app reviewer the way GitHub's UI shows it.
158
+ */
159
+ function sameReviewer(a, b) {
160
+ const bare = (login) => login.toLowerCase().replace(/\[bot\]$/, "");
161
+ const [x, y] = [bare(a), bare(b)];
162
+ return COPILOT_LOGIN.test(x) && COPILOT_LOGIN.test(y) ? true : x === y;
163
+ }
164
+ export function reviewTools(ctx) {
165
+ const { bounded, forge, forgeGraphql, forgeRepo } = ctx;
166
+ return {
167
+ repo_pr_review_status: tool({
168
+ description: "Say whether a reviewer has finished reviewing a pull request in the repository you have checked out. Use it before reading review comments: a review that has not landed has left no comments, and an empty thread list means nothing yet.",
169
+ inputSchema: z.object({
170
+ dir: z.string().describe("The checkout directory"),
171
+ number: z.number().int().positive().describe("Pull request number"),
172
+ reviewer: z
173
+ .string()
174
+ .optional()
175
+ .describe('The reviewer to ask about, as their login. Defaults to "Copilot".')
176
+ }),
177
+ execute: async ({ dir, number, reviewer }) => {
178
+ const target = await forgeRepo(dir);
179
+ if ("refusal" in target)
180
+ return target.refusal;
181
+ const { owner, repo } = target;
182
+ const who = reviewer ?? "Copilot";
183
+ // A *pending* request outranks any review already on the pull request,
184
+ // and that ordering is the whole logic here. GitHub clears the request
185
+ // when a review is submitted and writes a new one when a re-review is
186
+ // asked for, so "still requested" is true now and "has reviewed" is
187
+ // about the past. Reading them the other way round reports a re-review
188
+ // as finished the moment it is asked for.
189
+ const requested = await forgeGraphql("repo_pr_review_status", REVIEW_REQUESTS_QUERY, { owner, repo, number });
190
+ if (!requested.ok)
191
+ return bounded(requested.message);
192
+ const waiting = requested.data.repository?.pullRequest?.reviewRequests;
193
+ if (!waiting)
194
+ return `#${number} is not a pull request in ${owner}/${repo}`;
195
+ const pending = (waiting.nodes ?? []).some((n) => {
196
+ const name = n?.requestedReviewer?.login ?? n?.requestedReviewer?.slug;
197
+ return !!name && sameReviewer(name, who);
198
+ });
199
+ if (pending)
200
+ return `${who} has been asked to review #${number} and has not finished.`;
201
+ const reviews = await forge("repo_pr_review_status", `/repos/${owner}/${repo}/pulls/${number}/reviews?per_page=${FORGE_PAGE_SIZE}`);
202
+ if (!reviews.ok)
203
+ return bounded(reviews.message);
204
+ const all = reviews.data;
205
+ const submitted = all.filter((r) => !!r.user?.login &&
206
+ sameReviewer(r.user.login, who) &&
207
+ // A `PENDING` review is a draft its author has not sent, and it is
208
+ // visible to whoever holds the token that wrote it. Counting one as
209
+ // finished reports a review nobody has read, which is the answer a
210
+ // poll loop acts on by stopping.
211
+ (r.state ?? "").toUpperCase() !== "PENDING");
212
+ const theirs = submitted.at(-1);
213
+ if (!theirs) {
214
+ // The endpoint pages oldest-first, so a match beyond the first page is
215
+ // **newer** than everything read — which is to say it is exactly the
216
+ // review being waited for. So a truncated read cannot answer the
217
+ // question at all, and must not answer it with "waiting will not help":
218
+ // that is the one reply a caller ends its polling on.
219
+ if (all.length >= FORGE_PAGE_SIZE)
220
+ return bounded(`#${number} has at least ${FORGE_PAGE_SIZE} reviews and only the oldest were read, ` +
221
+ `so whether ${who} has reviewed it is unknown. Read the pull request itself with repo_pr_view, ` +
222
+ `or look for threads with repo_pr_threads.`);
223
+ return bounded(`#${number} has no review from ${who}, and none is pending. ` +
224
+ `Nobody asked them, or the request was withdrawn — waiting will not change it.`);
225
+ }
226
+ return bounded(`${who} reviewed #${number}${theirs.submitted_at ? ` at ${theirs.submitted_at}` : ""}` +
227
+ `${theirs.state ? ` (${theirs.state.toLowerCase()})` : ""}. ` +
228
+ `Read what it said with repo_pr_threads — a review can finish having left no comments.`);
229
+ }
230
+ }),
231
+ repo_pr_threads: tool({
232
+ description: "Read the review threads on a pull request in the repository you have checked out — the inline comments a reviewer left on specific lines, which are not on the conversation timeline repo_issue_view reads. Unresolved ones only, unless you ask for all.",
233
+ inputSchema: z.object({
234
+ dir: z.string().describe("The checkout directory"),
235
+ number: z.number().int().positive().describe("Pull request number"),
236
+ includeResolved: z
237
+ .boolean()
238
+ .optional()
239
+ .describe("Include threads already resolved. Defaults to false.")
240
+ }),
241
+ execute: async ({ dir, number, includeResolved }) => {
242
+ const target = await forgeRepo(dir);
243
+ if ("refusal" in target)
244
+ return target.refusal;
245
+ const { owner, repo } = decoded(target);
246
+ const threads = [];
247
+ let after = null;
248
+ let truncated = false;
249
+ // Paged, and that is not an optimisation. A review long enough to spill a
250
+ // page puts its newest threads on the last one — exactly the threads the
251
+ // model was sent to answer — so a single page can report a busy review as
252
+ // clean. The page cap is what stops a pathological pull request becoming
253
+ // an unbounded number of round trips.
254
+ for (let page = 0;; page++) {
255
+ if (page >= MAX_THREAD_PAGES) {
256
+ truncated = true;
257
+ break;
258
+ }
259
+ const answered = await forgeGraphql("repo_pr_threads", REVIEW_THREADS_QUERY, {
260
+ owner,
261
+ repo,
262
+ number,
263
+ after
264
+ });
265
+ if (!answered.ok)
266
+ return bounded(answered.message);
267
+ const page_ = answered.data.repository?.pullRequest?.reviewThreads;
268
+ if (!page_)
269
+ return `#${number} is not a pull request in ${owner}/${repo}`;
270
+ threads.push(...(page_.nodes ?? []));
271
+ if (!page_.pageInfo?.hasNextPage)
272
+ break;
273
+ after = page_.pageInfo.endCursor ?? null;
274
+ if (!after)
275
+ break;
276
+ }
277
+ // Said once, and said on **every** exit including the empty one. A run
278
+ // that stopped early and found nothing open in what it did read must not
279
+ // answer "nothing to do": the unread pages are exactly where the newest
280
+ // threads are, which is the false-clean result the paging exists to
281
+ // prevent — reintroduced at the one exit that skipped the warning.
282
+ const unread = truncated
283
+ ? `\n\n(stopped after ${MAX_THREAD_PAGES} pages, so this is not the whole review — there are more threads than were read)`
284
+ : "";
285
+ const shown = includeResolved
286
+ ? threads
287
+ : threads.filter((t) => !t.isResolved);
288
+ if (shown.length === 0)
289
+ return bounded((includeResolved
290
+ ? `#${number} has no review threads.`
291
+ : `#${number} has no unresolved review threads.` +
292
+ (threads.length > 0
293
+ ? ` (${threads.length} resolved — pass includeResolved to read them.)`
294
+ : "")) + unread);
295
+ const rendered = shown.map((t) => renderThread(t)).join("\n\n");
296
+ return bounded(rendered + unread);
297
+ }
298
+ }),
299
+ repo_pr_thread_reply: tool({
300
+ description: "Answer one review thread on a pull request in the repository you have checked out, and resolve it. Reply with what you changed and where, or with why you did not — either way the thread ends resolved unless you say otherwise.",
301
+ inputSchema: z.object({
302
+ dir: z.string().describe("The checkout directory"),
303
+ number: z.number().int().positive().describe("Pull request number"),
304
+ threadId: z
305
+ .string()
306
+ .describe("The thread's id, exactly as repo_pr_threads printed it"),
307
+ body: z
308
+ .string()
309
+ .optional()
310
+ .describe("The reply, as markdown. Leave it out to resolve a thread you have already replied to."),
311
+ resolve: z
312
+ .boolean()
313
+ .optional()
314
+ .describe("Resolve the thread after replying. Defaults to true.")
315
+ }),
316
+ execute: async ({ dir, number, threadId, body, resolve }) => {
317
+ const target = await forgeRepo(dir);
318
+ if ("refusal" in target)
319
+ return target.refusal;
320
+ const { owner, repo } = decoded(target);
321
+ // A thread id is a global node id, so unlike every other tool here this
322
+ // one takes something that can name a pull request in a repository
323
+ // nobody checked out — the reach `forgeRepo` exists to prevent. Checked
324
+ // rather than trusted: the id came from a tool result the model read,
325
+ // and a model that misremembers one must not write into a stranger's
326
+ // review.
327
+ const belongs = await forgeGraphql("repo_pr_thread_reply", THREAD_OWNER_QUERY, { id: threadId });
328
+ if (!belongs.ok)
329
+ return bounded(belongs.message);
330
+ const node = belongs.data.node;
331
+ if (!node?.pullRequest)
332
+ return `${threadId} is not a review thread — read the ids with repo_pr_threads`;
333
+ const at = node.pullRequest.repository?.nameWithOwner ?? "";
334
+ if (at.toLowerCase() !== `${owner}/${repo}`.toLowerCase() ||
335
+ node.pullRequest.number !== number)
336
+ return (`${threadId} belongs to ${at}#${node.pullRequest.number}, not ${owner}/${repo}#${number}. ` +
337
+ `Read the ids for this pull request with repo_pr_threads.`);
338
+ // Resolve-only. The reply and the resolve are two calls, so they fail
339
+ // independently — and the tool that tells a caller "the reply landed,
340
+ // resolve it alone" has to give it a way to do that. Without this the
341
+ // only route back is a second reply the reviewer has already read.
342
+ if (body === undefined) {
343
+ if (resolve === false)
344
+ return "nothing to do: give a body to reply, or leave resolve unset to resolve the thread";
345
+ const only = await forgeGraphql("repo_pr_thread_reply", THREAD_RESOLVE_MUTATION, { id: threadId });
346
+ return only.ok
347
+ ? `resolved ${threadId}`
348
+ : bounded(`resolving the thread failed: ${only.message}`);
349
+ }
350
+ const replied = await forgeGraphql("repo_pr_thread_reply", THREAD_REPLY_MUTATION, { id: threadId, body });
351
+ if (!replied.ok)
352
+ // The hazard `repo_pr_comment` names, and worse here: a reply that
353
+ // landed and an answer that did not arrive look identical, and the
354
+ // retry leaves the reviewer two of them.
355
+ return bounded(`${replied.message}\nIf this was a timeout rather than a rejection the ` +
356
+ `reply may exist anyway — read the thread back with repo_pr_threads before retrying.`);
357
+ const url = replied.data.addPullRequestReviewThreadReply?.comment?.url ?? "(no url)";
358
+ if (resolve === false)
359
+ return `replied: ${url} (left unresolved)`;
360
+ // Reported separately, never folded into the reply's own failure: a
361
+ // thread that was answered and not resolved needs resolving, and one
362
+ // that was never answered needs answering. Saying "it failed" covers
363
+ // both and tells the model to do the wrong one.
364
+ const resolved = await forgeGraphql("repo_pr_thread_reply", THREAD_RESOLVE_MUTATION, { id: threadId });
365
+ if (!resolved.ok)
366
+ return bounded(`replied: ${url}\nbut resolving the thread failed: ${resolved.message}\n` +
367
+ `The reply has landed — do not send it again. Call repo_pr_thread_reply on the same ` +
368
+ `threadId with no body to resolve it on its own.`);
369
+ return `replied and resolved: ${url}`;
370
+ }
371
+ })
372
+ };
373
+ }
@@ -0,0 +1,4 @@
1
+ import type { ToolSet } from "ai";
2
+ import type { RepoContext } from "./context.js";
3
+ /** Reading the working tree, committing it, and publishing the branch. */
4
+ export declare function worktreeTools(ctx: RepoContext): ToolSet;
@@ -0,0 +1,221 @@
1
+ import { tool } from "ai";
2
+ import { z } from "zod";
3
+ import { PROTECTED_BRANCHES, UNSAFE_BRANCH } from "./url.js";
4
+ import { resolveDefaultBranch } from "./checkout.js";
5
+ /** Reading the working tree, committing it, and publishing the branch. */
6
+ export function worktreeTools(ctx) {
7
+ const { author, bounded, logFailure, plain, origin, pushBranch } = ctx;
8
+ return {
9
+ repo_status: tool({
10
+ description: "Show which files changed in the checkout.",
11
+ inputSchema: z.object({ dir: z.string().describe("Checkout directory") }),
12
+ execute: async ({ dir }) => {
13
+ const result = await plain("status --short", dir);
14
+ // "(no changes)" is a claim about the tree. A command that failed
15
+ // supports no claim about anything, and reporting one as the other sends
16
+ // the model on to commit against a repository it cannot read.
17
+ if (!result.success) {
18
+ logFailure("repo_status", result);
19
+ return bounded(`could not read the status of ${dir}: ${result.stderr || result.stdout}`);
20
+ }
21
+ return bounded(result.stdout.trim()) || "(no changes)";
22
+ }
23
+ }),
24
+ repo_diff: tool({
25
+ description: "Show the current diff. Read this before committing — it is the cheapest way to catch an edit that did more than you intended. Pass stat:true first on a large change to see which files moved and by how much, then read the full diff of what matters.",
26
+ inputSchema: z.object({
27
+ dir: z.string().describe("Checkout directory"),
28
+ staged: z.boolean().optional().describe("Show staged changes instead"),
29
+ stat: z
30
+ .boolean()
31
+ .optional()
32
+ .describe("Summarise as a per-file changed-line count instead of the full patch")
33
+ }),
34
+ execute: async ({ dir, staged, stat }) => {
35
+ const flags = [staged ? "--staged" : "", stat ? "--stat" : ""]
36
+ .filter(Boolean)
37
+ .join(" ");
38
+ const result = await plain(`diff ${flags}`, dir);
39
+ // Same reasoning as `repo_status`: "(no diff)" and "the diff could not
40
+ // be read" are opposite answers, and this is the tool a reviewing agent
41
+ // trusts most.
42
+ if (!result.success) {
43
+ logFailure("repo_diff", result);
44
+ return bounded(`could not read the diff in ${dir}: ${result.stderr || result.stdout}`);
45
+ }
46
+ // Truncated from the middle rather than the end: the head of a diff and
47
+ // its tail are both informative, and the middle of a large one rarely
48
+ // is. A model that needs the part that was dropped can ask for `stat`
49
+ // and then read the file.
50
+ return bounded(result.stdout.trim()) || "(no diff)";
51
+ }
52
+ }),
53
+ repo_commit: tool({
54
+ description: "Stage everything and commit. Write the message for a reviewer reading it in the log a year from now: what changed and why, not how.",
55
+ inputSchema: z.object({
56
+ dir: z.string().describe("Checkout directory"),
57
+ message: z.string().describe("Commit message")
58
+ }),
59
+ execute: async ({ dir, message }) => {
60
+ // Checked rather than fired and forgotten. A failed `add` leaves the
61
+ // index holding less than the model believes, and the commit that
62
+ // follows still succeeds — so the round reports a commit that quietly
63
+ // does not contain the change.
64
+ const staged = await plain("add -A", dir);
65
+ if (!staged.success) {
66
+ logFailure("repo_commit", staged);
67
+ return bounded(`could not stage the changes in ${dir}: ${staged.stderr || staged.stdout}`);
68
+ }
69
+ // The message is model-authored free text — quotes, backticks, newlines,
70
+ // `$(…)`. Expanded from the environment inside the container, all of
71
+ // that is inert; interpolated into the command string, none of it is.
72
+ const result = await plain(`commit -m "$GIT_COMMIT_MESSAGE"`, dir, {
73
+ GIT_COMMIT_MESSAGE: message,
74
+ // On the commit as well as in the checkout's config, and this is
75
+ // the copy that is current: config is written when a checkout is set
76
+ // up, and the environment is read at the commit. A workspace that
77
+ // outlived a change of identity commits under the new one.
78
+ GIT_AUTHOR_NAME: author.name,
79
+ GIT_AUTHOR_EMAIL: author.email,
80
+ GIT_COMMITTER_NAME: author.name,
81
+ GIT_COMMITTER_EMAIL: author.email
82
+ });
83
+ if (!result.success && /nothing to commit/i.test(result.stdout))
84
+ return "nothing to commit — the working tree is clean";
85
+ if (!result.success) {
86
+ logFailure("repo_commit", result);
87
+ return bounded(`commit failed: ${result.stderr || result.stdout}`);
88
+ }
89
+ return bounded(result.stdout.trim());
90
+ }
91
+ }),
92
+ repo_push: tool({
93
+ description: "Push a branch to the remote. Refuses to push to a protected branch and refuses to force-push — open a pull request instead.",
94
+ inputSchema: z.object({
95
+ dir: z.string().describe("Checkout directory"),
96
+ branch: z
97
+ .string()
98
+ .describe("Branch name to create and push, e.g. 'coder/add-json-flag'")
99
+ }),
100
+ execute: async ({ dir, branch }) => {
101
+ // Enforced here rather than in the prompt: a guardrail a model can talk
102
+ // itself out of is not a guardrail.
103
+ //
104
+ // Shape first, then the name. `git push origin <name>` reads `<name>`
105
+ // as a refspec, so `+x:main` is a force push to main that the name
106
+ // check below would wave through — it only ever sees a literal string.
107
+ if (UNSAFE_BRANCH.test(branch))
108
+ return `"${branch}" is not a plain branch name — use something like "coder/add-json-flag"`;
109
+ if (PROTECTED_BRANCHES.has(branch))
110
+ return `refusing to push to "${branch}" — push a work branch and open a pull request`;
111
+ // The repository's *own* default, which is often none of the four names
112
+ // above: a repo whose trunk is `release` deserves the same protection.
113
+ // A command that never ran stops here rather than standing the guards
114
+ // down — the same rule as the two probes further down, and the reason
115
+ // {@link resolveDefaultBranch} reports the two separately.
116
+ const head = await resolveDefaultBranch(plain, dir);
117
+ if (head.unreachable)
118
+ return bounded(`could not push "${branch}": ${head.unreachable}`);
119
+ const defaultBranch = head.branch;
120
+ if (defaultBranch && defaultBranch === branch)
121
+ return `refusing to push to "${branch}" — it is this repository's default branch; push a work branch and open a pull request`;
122
+ const { remote, unreachable } = await origin(dir);
123
+ if (unreachable)
124
+ return bounded(`could not push "${branch}": ${unreachable}`);
125
+ if (!remote)
126
+ return `${dir} has no origin on an allowed host — clone it with repo_clone first`;
127
+ // Switch to the branch, or create it — but never *reset* it.
128
+ //
129
+ // `-b`, never `-B`: `-B` is create-or-reset, so on a branch that already
130
+ // holds the commit it force-moves it to wherever HEAD is now. The commit
131
+ // survives only as an unreferenced object, and what gets pushed is an
132
+ // empty branch with a pull request opened on it — a complete loss
133
+ // dressed up as success.
134
+ const exists = await plain(`rev-parse --verify --quiet "refs/heads/$REPO_BRANCH"`, dir, { REPO_BRANCH: branch });
135
+ // Same reasoning as the probe in `repo_clone`: `!success` means "no such
136
+ // branch, create it" only when git was there to say so.
137
+ if (exists.unreachable)
138
+ return bounded(`could not push "${branch}": ${exists.stderr}`);
139
+ const checkout = await plain(exists.success
140
+ ? `checkout "$REPO_BRANCH"`
141
+ : `checkout -b "$REPO_BRANCH"`, dir, { REPO_BRANCH: branch });
142
+ if (!checkout.success) {
143
+ logFailure("repo_push", checkout);
144
+ return bounded(`could not switch to branch "${branch}": ${checkout.stderr || checkout.stdout}`);
145
+ }
146
+ // Nothing to push is a bug upstream of here, not a no-op.
147
+ //
148
+ // A branch level with the default branch means the commit went
149
+ // somewhere else, or was never made. Pushing it succeeds, `repo_open_pr`
150
+ // opens an empty pull request, and the round reports a URL as if the
151
+ // work had landed — the one outcome worse than an error.
152
+ //
153
+ // The guard is skipped only when there is no baseline to compare
154
+ // against: a repository with no `origin/HEAD` is unusual but legitimate,
155
+ // and guessing is worse than not guarding. Once there *is* one, a
156
+ // comparison that fails is an unanswered question rather than a pass —
157
+ // the same rule the three probes around this one follow.
158
+ if (defaultBranch) {
159
+ const ahead = await plain(`rev-list --count "origin/$REPO_BASE..HEAD"`, dir, {
160
+ REPO_BASE: defaultBranch
161
+ });
162
+ if (ahead.unreachable)
163
+ return bounded(`could not push "${branch}": ${ahead.stderr}`);
164
+ if (!ahead.success) {
165
+ logFailure("repo_push", ahead);
166
+ return bounded(`could not tell whether "${branch}" has anything to push: comparing it ` +
167
+ `against origin/${defaultBranch} failed with ` +
168
+ `${ahead.stderr || ahead.stdout || "no output"}\n` +
169
+ `Nothing was pushed. Pushing without that answer risks an empty pull ` +
170
+ `request reported as success, so check repo_status and repo_diff first.`);
171
+ }
172
+ if (ahead.stdout.trim() === "0") {
173
+ return (`refusing to push "${branch}" — it has no commits that origin/${defaultBranch} ` +
174
+ `does not already have, so the pull request would be empty. Check repo_status ` +
175
+ `and repo_diff: either the change was never committed, or it was committed on ` +
176
+ `a different branch.`);
177
+ }
178
+ }
179
+ // A pre-flight check, not a value anyone downstream consumes: `push`
180
+ // takes the branch *name* and resolves it itself, against the same
181
+ // files. What this buys is the failure arriving here, where the sentence
182
+ // can name the branch and the checkout, instead of surfacing as a push
183
+ // that rejects a ref nobody can see.
184
+ const tip = await plain(`rev-parse --verify "refs/heads/$REPO_BRANCH"`, dir, { REPO_BRANCH: branch });
185
+ if (!tip.success || !tip.stdout.trim()) {
186
+ logFailure("repo_push", tip);
187
+ return bounded(`could not resolve "${branch}" to a commit: ${tip.stderr || tip.stdout}`);
188
+ }
189
+ const result = await pushBranch(dir, remote.url, branch);
190
+ if (!result.success) {
191
+ logFailure("repo_push", result);
192
+ return bounded(`push failed: ${result.stderr || result.stdout}`);
193
+ }
194
+ // What `--set-upstream` would do as a side effect of the push, written
195
+ // here because the push happens on the host's side: a subagent reaching
196
+ // for a bare `git push` in the shell should still find the branch
197
+ // tracking something.
198
+ //
199
+ // Checked, but *reported* rather than raised: this is the one place in
200
+ // the plugin where a failed command is not a failed operation. The push
201
+ // has landed, saying so is the important half, and all that is lost is
202
+ // the convenience these two keys buy.
203
+ const remoteSet = await plain(`config "branch.$REPO_BRANCH.remote" origin`, dir, { REPO_BRANCH: branch });
204
+ const mergeSet = await plain(`config "branch.$REPO_BRANCH.merge" "refs/heads/$REPO_BRANCH"`, dir, { REPO_BRANCH: branch });
205
+ const untracked = [remoteSet, mergeSet].find((r) => !r.success);
206
+ if (untracked) {
207
+ // Named apart from the push, which succeeded — a log line reading
208
+ // "repo_push failed" next to a branch that is on the remote is worse
209
+ // than no log line.
210
+ logFailure("repo_push (upstream tracking)", untracked);
211
+ return bounded(`pushed ${branch}, but could not record what it tracks: ` +
212
+ `${untracked.stderr || untracked.stdout}\n` +
213
+ `The push itself landed. The only consequence is local: a bare ` +
214
+ `\`git push\` from the shell will not know where to send this ` +
215
+ `branch, and needs \`git push -u origin ${branch}\` once.`);
216
+ }
217
+ return `pushed ${branch}`;
218
+ }
219
+ })
220
+ };
221
+ }
@@ -1,7 +1,7 @@
1
1
  import type { LanguageModel } from "ai";
2
2
  import { z } from "zod";
3
- import type { SessionMessage } from "agents/experimental/memory/session";
4
3
  import type { AgentPlugin } from "@dynamicagents/core";
4
+ import type { SessionMessage } from "@dynamicagents/core/agent";
5
5
  /**
6
6
  * The classifier's output. The booleans are **not** metadata for the caller —
7
7
  * only `should_reply` is ever read. They exist to shape the generation itself:
@@ -38,6 +38,11 @@ export interface TriageTuning {
38
38
  * in one gateway.
39
39
  */
40
40
  aiGatewayId?: string;
41
+ /**
42
+ * The `agent` key on the classifier's AI Gateway log rows. Pass the host's
43
+ * `PluginHost.agentName`, for the reason given on {@link aiGatewayId}.
44
+ */
45
+ agentName?: string;
41
46
  /** Workers-AI id for the classifier. Defaults to a small, fast model. */
42
47
  modelId?: string;
43
48
  /** Trailing messages the classifier sees. Defaults to 12. */