@dynamicagents/plugins 0.12.0 → 0.14.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.
- package/README.md +58 -77
- package/dist/browser/index.d.ts +0 -2
- package/dist/browser/index.js +21 -27
- package/dist/claude-code/cards.d.ts +55 -0
- package/dist/claude-code/cards.js +283 -0
- package/dist/claude-code/config.d.ts +28 -63
- package/dist/claude-code/config.js +0 -11
- package/dist/claude-code/egress.d.ts +8 -1
- package/dist/claude-code/egress.js +42 -4
- package/dist/claude-code/events.d.ts +106 -9
- package/dist/claude-code/events.js +201 -56
- package/dist/claude-code/github.d.ts +39 -0
- package/dist/claude-code/github.js +82 -0
- package/dist/claude-code/index.d.ts +16 -90
- package/dist/claude-code/index.js +14 -276
- package/dist/claude-code/model.d.ts +152 -0
- package/dist/claude-code/model.js +366 -0
- package/dist/claude-code/run.d.ts +230 -116
- package/dist/claude-code/run.js +380 -167
- package/dist/claude-code/session.d.ts +131 -0
- package/dist/claude-code/session.js +288 -0
- package/dist/claude-code/spec.d.ts +46 -0
- package/dist/claude-code/spec.js +83 -0
- package/dist/claude-code/verified.d.ts +15 -0
- package/dist/claude-code/verified.js +15 -0
- package/dist/computer/context.d.ts +1 -5
- package/dist/computer/context.js +7 -10
- package/dist/computer/file-lock.js +5 -4
- package/dist/computer/gate.d.ts +4 -4
- package/dist/computer/gate.js +5 -5
- package/dist/computer/index.d.ts +40 -258
- package/dist/computer/index.js +71 -324
- package/dist/computer/paths.d.ts +36 -16
- package/dist/computer/paths.js +42 -29
- package/dist/computer/proxy.d.ts +45 -0
- package/dist/computer/proxy.js +234 -0
- package/dist/computer/render.d.ts +1 -40
- package/dist/computer/render.js +2 -68
- package/dist/computer/tools-exec.js +12 -13
- package/dist/computer/tools-file.d.ts +9 -1
- package/dist/computer/tools-file.js +22 -82
- package/dist/computer/{tools-find.d.ts → tools-grep.d.ts} +1 -1
- package/dist/computer/tools-grep.js +129 -0
- package/dist/repo/checkout.d.ts +4 -5
- package/dist/repo/checkout.js +31 -8
- package/dist/repo/context.d.ts +35 -9
- package/dist/repo/context.js +101 -49
- package/dist/repo/index.d.ts +73 -28
- package/dist/repo/index.js +112 -96
- package/dist/repo/tools-checks.d.ts +3 -0
- package/dist/repo/tools-checks.js +112 -0
- package/dist/repo/tools-clone.js +1 -1
- package/dist/repo/tools-pr.d.ts +14 -0
- package/dist/repo/{tools-forge.js → tools-pr.js} +53 -36
- package/dist/repo/tools-review.d.ts +9 -0
- package/dist/repo/tools-review.js +67 -47
- package/dist/repo/tools-worktree.js +144 -60
- package/dist/repo/tools-worktrees.d.ts +12 -0
- package/dist/repo/tools-worktrees.js +48 -0
- package/dist/repo/url.d.ts +2 -2
- package/dist/repo/url.js +7 -7
- package/dist/scratch/index.d.ts +4 -4
- package/dist/scratch/index.js +12 -11
- package/dist/{computer → workspace}/advisory.d.ts +4 -4
- package/dist/{computer → workspace}/advisory.js +2 -2
- package/dist/{computer/host → workspace}/ca-trust.d.ts +8 -1
- package/dist/{computer/host → workspace}/ca-trust.js +12 -3
- package/dist/{computer/host → workspace}/container-deps.d.ts +4 -0
- package/dist/{computer/host → workspace}/container-deps.js +26 -1
- package/dist/{computer/host → workspace}/container-fault.d.ts +3 -3
- package/dist/{computer/host → workspace}/container-fault.js +23 -8
- package/dist/workspace/deps-snapshot.d.ts +110 -0
- package/dist/workspace/deps-snapshot.js +424 -0
- package/dist/workspace/exec.d.ts +94 -0
- package/dist/workspace/exec.js +153 -0
- package/dist/workspace/format.d.ts +36 -0
- package/dist/workspace/format.js +57 -0
- package/dist/{computer/host → workspace}/git-host.d.ts +21 -4
- package/dist/{computer/host → workspace}/git-host.js +72 -2
- package/dist/{computer/host → workspace}/git-identity.d.ts +1 -1
- package/dist/{computer/host → workspace}/git-identity.js +1 -1
- package/dist/workspace/index.d.ts +44 -47
- package/dist/workspace/index.js +52 -112
- package/dist/{computer/host → workspace}/install-job.d.ts +53 -4
- package/dist/{computer/host → workspace}/install-job.js +346 -178
- package/dist/{computer → workspace}/install.d.ts +17 -8
- package/dist/{computer → workspace}/install.js +24 -6
- package/dist/{computer/host/workspace.d.ts → workspace/object.d.ts} +125 -27
- package/dist/{computer/host/workspace.js → workspace/object.js} +341 -58
- package/dist/workspace/open.d.ts +95 -0
- package/dist/workspace/open.js +63 -0
- package/dist/workspace/read.d.ts +16 -0
- package/dist/workspace/read.js +22 -0
- package/dist/{computer → workspace}/shell.d.ts +5 -5
- package/dist/{computer → workspace}/shell.js +6 -6
- package/dist/{computer/host → workspace}/wake.d.ts +3 -1
- package/package.json +17 -37
- package/dist/arc-agi/analysis.d.ts +0 -322
- package/dist/arc-agi/analysis.js +0 -802
- package/dist/arc-agi/client.d.ts +0 -82
- package/dist/arc-agi/client.js +0 -113
- package/dist/arc-agi/game-tools.d.ts +0 -24
- package/dist/arc-agi/game-tools.js +0 -26
- package/dist/arc-agi/index.d.ts +0 -40
- package/dist/arc-agi/index.js +0 -175
- package/dist/arc-agi/main-agent.d.ts +0 -24
- package/dist/arc-agi/main-agent.js +0 -62
- package/dist/arc-agi/recipe.d.ts +0 -48
- package/dist/arc-agi/recipe.js +0 -97
- package/dist/arc-agi/scorecard.d.ts +0 -118
- package/dist/arc-agi/scorecard.js +0 -163
- package/dist/arc-agi/soul.d.ts +0 -29
- package/dist/arc-agi/soul.js +0 -79
- package/dist/arc-agi/store.d.ts +0 -153
- package/dist/arc-agi/store.js +0 -215
- package/dist/arc-agi/tools.d.ts +0 -36
- package/dist/arc-agi/tools.js +0 -574
- package/dist/arc-agi/types.d.ts +0 -211
- package/dist/arc-agi/types.js +0 -19
- package/dist/claude-code/recipe.d.ts +0 -53
- package/dist/claude-code/recipe.js +0 -100
- package/dist/computer/read.d.ts +0 -133
- package/dist/computer/read.js +0 -200
- package/dist/computer/tools-find.js +0 -216
- package/dist/recall/index.d.ts +0 -105
- package/dist/recall/index.js +0 -181
- package/dist/repo/tools-forge.d.ts +0 -4
- package/dist/triage/index.d.ts +0 -360
- package/dist/triage/index.js +0 -311
- /package/dist/{computer/host → workspace}/sync.d.ts +0 -0
- /package/dist/{computer/host → workspace}/sync.js +0 -0
- /package/dist/{computer/host → workspace}/wake.js +0 -0
|
@@ -1,11 +1,12 @@
|
|
|
1
|
+
import { action } from "@cloudflare/think";
|
|
1
2
|
import { tool } from "ai";
|
|
2
3
|
import { z } from "zod";
|
|
3
|
-
import {
|
|
4
|
+
import { actionTask, GITHUB_ACTION_TIMEOUT_MS, GITHUB_PAGE_SIZE, sha256 } from "./context.js";
|
|
4
5
|
/** A review as a conversation: whether it landed, what it said, and answering it. */
|
|
5
6
|
/**
|
|
6
7
|
* How many pages of review threads one `repo_pr_threads` call will walk.
|
|
7
8
|
*
|
|
8
|
-
* A bound rather than a full walk, for the reason {@link
|
|
9
|
+
* A bound rather than a full walk, for the reason {@link GITHUB_PAGE_SIZE} is one
|
|
9
10
|
* — but unlike the single page everything else here takes, threads genuinely
|
|
10
11
|
* have to page: a review long enough to spill one puts its newest threads on the
|
|
11
12
|
* last page, and those are exactly the ones an agent was sent to answer. So this
|
|
@@ -35,7 +36,7 @@ const REVIEW_THREADS_QUERY = `
|
|
|
35
36
|
query($owner:String!,$repo:String!,$number:Int!,$after:String){
|
|
36
37
|
repository(owner:$owner,name:$repo){
|
|
37
38
|
pullRequest(number:$number){
|
|
38
|
-
reviewThreads(first:${
|
|
39
|
+
reviewThreads(first:${GITHUB_PAGE_SIZE},after:$after){
|
|
39
40
|
pageInfo{hasNextPage endCursor}
|
|
40
41
|
nodes{
|
|
41
42
|
id isResolved isOutdated path line
|
|
@@ -60,7 +61,7 @@ const REVIEW_REQUESTS_QUERY = `
|
|
|
60
61
|
query($owner:String!,$repo:String!,$number:Int!){
|
|
61
62
|
repository(owner:$owner,name:$repo){
|
|
62
63
|
pullRequest(number:$number){
|
|
63
|
-
reviewRequests(first:${
|
|
64
|
+
reviewRequests(first:${GITHUB_PAGE_SIZE}){
|
|
64
65
|
nodes{
|
|
65
66
|
requestedReviewer{
|
|
66
67
|
... on User{login}
|
|
@@ -120,7 +121,7 @@ function renderThread(t) {
|
|
|
120
121
|
/**
|
|
121
122
|
* The owner and repository as their own names, rather than as REST path segments.
|
|
122
123
|
*
|
|
123
|
-
* {@link file://./context.ts
|
|
124
|
+
* {@link file://./context.ts githubRepo} percent-encodes both, because that is
|
|
124
125
|
* what makes a path safe. A GraphQL *variable* is not a path — it is compared
|
|
125
126
|
* against the repository's actual name — so an encoded one would silently stop
|
|
126
127
|
* matching the moment a name contains a character worth encoding.
|
|
@@ -162,7 +163,7 @@ function sameReviewer(a, b) {
|
|
|
162
163
|
return COPILOT_LOGIN.test(x) && COPILOT_LOGIN.test(y) ? true : x === y;
|
|
163
164
|
}
|
|
164
165
|
export function reviewTools(ctx) {
|
|
165
|
-
const { bounded,
|
|
166
|
+
const { bounded, github, githubGraphql, githubRepo } = ctx;
|
|
166
167
|
return {
|
|
167
168
|
repo_pr_review_status: tool({
|
|
168
169
|
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.",
|
|
@@ -175,7 +176,7 @@ export function reviewTools(ctx) {
|
|
|
175
176
|
.describe('The reviewer to ask about, as their login. Defaults to "Copilot".')
|
|
176
177
|
}),
|
|
177
178
|
execute: async ({ dir, number, reviewer }) => {
|
|
178
|
-
const target = await
|
|
179
|
+
const target = await githubRepo(dir);
|
|
179
180
|
if ("refusal" in target)
|
|
180
181
|
return target.refusal;
|
|
181
182
|
const { owner, repo } = target;
|
|
@@ -186,7 +187,7 @@ export function reviewTools(ctx) {
|
|
|
186
187
|
// asked for, so "still requested" is true now and "has reviewed" is
|
|
187
188
|
// about the past. Reading them the other way round reports a re-review
|
|
188
189
|
// as finished the moment it is asked for.
|
|
189
|
-
const requested = await
|
|
190
|
+
const requested = await githubGraphql("repo_pr_review_status", REVIEW_REQUESTS_QUERY, { owner, repo, number });
|
|
190
191
|
if (!requested.ok)
|
|
191
192
|
return bounded(requested.message);
|
|
192
193
|
const waiting = requested.data.repository?.pullRequest?.reviewRequests;
|
|
@@ -198,7 +199,7 @@ export function reviewTools(ctx) {
|
|
|
198
199
|
});
|
|
199
200
|
if (pending)
|
|
200
201
|
return `${who} has been asked to review #${number} and has not finished.`;
|
|
201
|
-
const reviews = await
|
|
202
|
+
const reviews = await github("repo_pr_review_status", `/repos/${owner}/${repo}/pulls/${number}/reviews?per_page=${GITHUB_PAGE_SIZE}`);
|
|
202
203
|
if (!reviews.ok)
|
|
203
204
|
return bounded(reviews.message);
|
|
204
205
|
const all = reviews.data;
|
|
@@ -216,8 +217,8 @@ export function reviewTools(ctx) {
|
|
|
216
217
|
// review being waited for. So a truncated read cannot answer the
|
|
217
218
|
// question at all, and must not answer it with "waiting will not help":
|
|
218
219
|
// that is the one reply a caller ends its polling on.
|
|
219
|
-
if (all.length >=
|
|
220
|
-
return bounded(`#${number} has at least ${
|
|
220
|
+
if (all.length >= GITHUB_PAGE_SIZE)
|
|
221
|
+
return bounded(`#${number} has at least ${GITHUB_PAGE_SIZE} reviews and only the oldest were read, ` +
|
|
221
222
|
`so whether ${who} has reviewed it is unknown. Read the pull request itself with repo_pr_view, ` +
|
|
222
223
|
`or look for threads with repo_pr_threads.`);
|
|
223
224
|
return bounded(`#${number} has no review from ${who}, and none is pending. ` +
|
|
@@ -239,7 +240,7 @@ export function reviewTools(ctx) {
|
|
|
239
240
|
.describe("Include threads already resolved. Defaults to false.")
|
|
240
241
|
}),
|
|
241
242
|
execute: async ({ dir, number, includeResolved }) => {
|
|
242
|
-
const target = await
|
|
243
|
+
const target = await githubRepo(dir);
|
|
243
244
|
if ("refusal" in target)
|
|
244
245
|
return target.refusal;
|
|
245
246
|
const { owner, repo } = decoded(target);
|
|
@@ -256,7 +257,7 @@ export function reviewTools(ctx) {
|
|
|
256
257
|
truncated = true;
|
|
257
258
|
break;
|
|
258
259
|
}
|
|
259
|
-
const answered = await
|
|
260
|
+
const answered = await githubGraphql("repo_pr_threads", REVIEW_THREADS_QUERY, {
|
|
260
261
|
owner,
|
|
261
262
|
repo,
|
|
262
263
|
number,
|
|
@@ -295,45 +296,64 @@ export function reviewTools(ctx) {
|
|
|
295
296
|
const rendered = shown.map((t) => renderThread(t)).join("\n\n");
|
|
296
297
|
return bounded(rendered + unread);
|
|
297
298
|
}
|
|
298
|
-
})
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
299
|
+
})
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Answering a thread, as a Think action — see
|
|
304
|
+
* {@link file://./tools-pr.ts prActions} for why every failure is
|
|
305
|
+
* thrown. The one outcome returned short of success is a reply that landed on
|
|
306
|
+
* a thread that did not resolve: that reply must never be sent again, so it is
|
|
307
|
+
* settled, and the resolve-only call it asks for is a different key.
|
|
308
|
+
*/
|
|
309
|
+
export function reviewActions(ctx) {
|
|
310
|
+
const { bounded, githubGraphql, githubRepo } = ctx;
|
|
311
|
+
const replyInput = z.object({
|
|
312
|
+
dir: z.string().describe("The checkout directory"),
|
|
313
|
+
number: z.number().int().positive().describe("Pull request number"),
|
|
314
|
+
threadId: z
|
|
315
|
+
.string()
|
|
316
|
+
.describe("The thread's id, exactly as repo_pr_threads printed it"),
|
|
317
|
+
body: z
|
|
318
|
+
.string()
|
|
319
|
+
.min(1)
|
|
320
|
+
.optional()
|
|
321
|
+
.describe("The reply, as markdown. Leave it out to resolve a thread you have already replied to."),
|
|
322
|
+
resolve: z
|
|
323
|
+
.boolean()
|
|
324
|
+
.optional()
|
|
325
|
+
.describe("Resolve the thread after replying. Defaults to true.")
|
|
326
|
+
});
|
|
327
|
+
return {
|
|
328
|
+
repo_pr_thread_reply: action({
|
|
329
|
+
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. The same reply to one thread twice in one task is sent once.",
|
|
330
|
+
inputSchema: replyInput,
|
|
331
|
+
timeoutMs: GITHUB_ACTION_TIMEOUT_MS,
|
|
332
|
+
// The thread and the resolve flag as well as the body, or the same reply
|
|
333
|
+
// to two threads — "Fixed." — would be one key.
|
|
334
|
+
idempotencyKey: async ({ input, ctx: turn }) => `${actionTask(turn)}:${input.dir}#${input.number}:${input.threadId}:` +
|
|
335
|
+
`${await sha256(input.body ?? "")}:${input.resolve ?? true}`,
|
|
316
336
|
execute: async ({ dir, number, threadId, body, resolve }) => {
|
|
317
|
-
const target = await
|
|
337
|
+
const target = await githubRepo(dir);
|
|
318
338
|
if ("refusal" in target)
|
|
319
|
-
|
|
339
|
+
throw new Error(target.refusal);
|
|
320
340
|
const { owner, repo } = decoded(target);
|
|
321
341
|
// A thread id is a global node id, so unlike every other tool here this
|
|
322
342
|
// one takes something that can name a pull request in a repository
|
|
323
|
-
// nobody checked out — the reach `
|
|
343
|
+
// nobody checked out — the reach `githubRepo` exists to prevent. Checked
|
|
324
344
|
// rather than trusted: the id came from a tool result the model read,
|
|
325
345
|
// and a model that misremembers one must not write into a stranger's
|
|
326
346
|
// review.
|
|
327
|
-
const belongs = await
|
|
347
|
+
const belongs = await githubGraphql("repo_pr_thread_reply", THREAD_OWNER_QUERY, { id: threadId });
|
|
328
348
|
if (!belongs.ok)
|
|
329
|
-
|
|
349
|
+
throw new Error(bounded(belongs.message));
|
|
330
350
|
const node = belongs.data.node;
|
|
331
351
|
if (!node?.pullRequest)
|
|
332
|
-
|
|
352
|
+
throw new Error(`${threadId} is not a review thread — read the ids with repo_pr_threads`);
|
|
333
353
|
const at = node.pullRequest.repository?.nameWithOwner ?? "";
|
|
334
354
|
if (at.toLowerCase() !== `${owner}/${repo}`.toLowerCase() ||
|
|
335
355
|
node.pullRequest.number !== number)
|
|
336
|
-
|
|
356
|
+
throw new Error(`${threadId} belongs to ${at}#${node.pullRequest.number}, not ${owner}/${repo}#${number}. ` +
|
|
337
357
|
`Read the ids for this pull request with repo_pr_threads.`);
|
|
338
358
|
// Resolve-only. The reply and the resolve are two calls, so they fail
|
|
339
359
|
// independently — and the tool that tells a caller "the reply landed,
|
|
@@ -341,19 +361,19 @@ export function reviewTools(ctx) {
|
|
|
341
361
|
// only route back is a second reply the reviewer has already read.
|
|
342
362
|
if (body === undefined) {
|
|
343
363
|
if (resolve === false)
|
|
344
|
-
|
|
345
|
-
const only = await
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
364
|
+
throw new Error("nothing to do: give a body to reply, or leave resolve unset to resolve the thread");
|
|
365
|
+
const only = await githubGraphql("repo_pr_thread_reply", THREAD_RESOLVE_MUTATION, { id: threadId });
|
|
366
|
+
if (!only.ok)
|
|
367
|
+
throw new Error(bounded(`resolving the thread failed: ${only.message}`));
|
|
368
|
+
return `resolved ${threadId}`;
|
|
349
369
|
}
|
|
350
|
-
const replied = await
|
|
370
|
+
const replied = await githubGraphql("repo_pr_thread_reply", THREAD_REPLY_MUTATION, { id: threadId, body });
|
|
351
371
|
if (!replied.ok)
|
|
352
372
|
// The hazard `repo_pr_comment` names, and worse here: a reply that
|
|
353
373
|
// landed and an answer that did not arrive look identical, and the
|
|
354
374
|
// retry leaves the reviewer two of them.
|
|
355
|
-
|
|
356
|
-
`reply may exist anyway — read the thread back with repo_pr_threads before retrying.`);
|
|
375
|
+
throw new Error(bounded(`${replied.message}\nIf this was a timeout rather than a rejection the ` +
|
|
376
|
+
`reply may exist anyway — read the thread back with repo_pr_threads before retrying.`));
|
|
357
377
|
const url = replied.data.addPullRequestReviewThreadReply?.comment?.url ?? "(no url)";
|
|
358
378
|
if (resolve === false)
|
|
359
379
|
return `replied: ${url} (left unresolved)`;
|
|
@@ -361,7 +381,7 @@ export function reviewTools(ctx) {
|
|
|
361
381
|
// thread that was answered and not resolved needs resolving, and one
|
|
362
382
|
// that was never answered needs answering. Saying "it failed" covers
|
|
363
383
|
// both and tells the model to do the wrong one.
|
|
364
|
-
const resolved = await
|
|
384
|
+
const resolved = await githubGraphql("repo_pr_thread_reply", THREAD_RESOLVE_MUTATION, { id: threadId });
|
|
365
385
|
if (!resolved.ok)
|
|
366
386
|
return bounded(`replied: ${url}\nbut resolving the thread failed: ${resolved.message}\n` +
|
|
367
387
|
`The reply has landed — do not send it again. Call repo_pr_thread_reply on the same ` +
|
|
@@ -4,7 +4,7 @@ import { PROTECTED_BRANCHES, UNSAFE_BRANCH } from "./url.js";
|
|
|
4
4
|
import { resolveDefaultBranch } from "./checkout.js";
|
|
5
5
|
/** Reading the working tree, committing it, and publishing the branch. */
|
|
6
6
|
export function worktreeTools(ctx) {
|
|
7
|
-
const { author, bounded, logFailure, plain, origin, pushBranch } = ctx;
|
|
7
|
+
const { author, bounded, config, fetchOrigin, logFailure, plain, origin, pushBranch } = ctx;
|
|
8
8
|
return {
|
|
9
9
|
repo_status: tool({
|
|
10
10
|
description: "Show which files changed in the checkout.",
|
|
@@ -22,26 +22,72 @@ export function worktreeTools(ctx) {
|
|
|
22
22
|
}
|
|
23
23
|
}),
|
|
24
24
|
repo_diff: tool({
|
|
25
|
-
description: "Show
|
|
25
|
+
description: "Show a diff. With no ref, the uncommitted changes in the checkout — read this before committing, it is the cheapest way to catch an edit that did more than you intended. With a ref, everything that ref adds, which is how you review work that arrived on a branch rather than in your own tree. 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
26
|
inputSchema: z.object({
|
|
27
27
|
dir: z.string().describe("Checkout directory"),
|
|
28
28
|
staged: z.boolean().optional().describe("Show staged changes instead"),
|
|
29
|
+
ref: z
|
|
30
|
+
.string()
|
|
31
|
+
.optional()
|
|
32
|
+
.describe("Review a ref instead of the working tree — e.g. 'origin/coder/add-json-flag'. Fetch it first; a ref this checkout has never seen cannot be diffed."),
|
|
33
|
+
base: z
|
|
34
|
+
.string()
|
|
35
|
+
.optional()
|
|
36
|
+
.describe("Review what this checkout's own commits add since a ref — e.g. 'origin/main' — the other direction from `ref`. For a branch a sub-agent committed in a worktree you have switched into."),
|
|
29
37
|
stat: z
|
|
30
38
|
.boolean()
|
|
31
39
|
.optional()
|
|
32
40
|
.describe("Summarise as a per-file changed-line count instead of the full patch")
|
|
33
41
|
}),
|
|
34
|
-
execute: async ({ dir, staged, stat }) => {
|
|
42
|
+
execute: async ({ dir, staged, stat, ref, base }) => {
|
|
43
|
+
if (ref !== undefined && base !== undefined)
|
|
44
|
+
return "pass `ref` or `base`, not both — `ref` is what another ref adds, `base` is what this checkout's commits add";
|
|
45
|
+
if (base !== undefined && (base === "" || UNSAFE_BRANCH.test(base)))
|
|
46
|
+
return `"${base}" is not a plain ref — pass something like "origin/main"`;
|
|
47
|
+
// Shape-checked for the same reason `repo_push` checks a branch: a ref is
|
|
48
|
+
// model-authored, and `git diff -x` reads a leading `-` as an option
|
|
49
|
+
// rather than a name. `UNSAFE_BRANCH` already refuses that and the
|
|
50
|
+
// traversal spellings.
|
|
51
|
+
// Empty is refused with the rest: it reads as "no ref" below, and the
|
|
52
|
+
// working tree's diff would be reported as the ref's.
|
|
53
|
+
if (ref !== undefined && (ref === "" || UNSAFE_BRANCH.test(ref)))
|
|
54
|
+
return `"${ref}" is not a plain ref — pass something like "origin/coder/add-json-flag"`;
|
|
35
55
|
const flags = [staged ? "--staged" : "", stat ? "--stat" : ""]
|
|
36
56
|
.filter(Boolean)
|
|
37
57
|
.join(" ");
|
|
38
|
-
|
|
58
|
+
// `HEAD...<ref>`, and the order is the whole point: `git diff A...B`
|
|
59
|
+
// compares the merge base of the two to **B**, so the reviewed ref has to
|
|
60
|
+
// be on the right. Reversed, this reports what the reviewer's own HEAD
|
|
61
|
+
// gained since the branch diverged — which is the "the branch reverted
|
|
62
|
+
// things" reading it exists to avoid, printed with confidence.
|
|
63
|
+
//
|
|
64
|
+
// The ref goes in an env var and is never interpolated — see `shell`'s
|
|
65
|
+
// `vars`, which carries why. A `--` would not be enough on its own here,
|
|
66
|
+
// because the injection this prevents is a second shell command.
|
|
67
|
+
const result = ref
|
|
68
|
+
? await plain(`diff ${flags} HEAD..."$REPO_REF" --`, dir, {
|
|
69
|
+
REPO_REF: ref
|
|
70
|
+
})
|
|
71
|
+
: base
|
|
72
|
+
? // The same rule from the other side: the checkout's HEAD on the
|
|
73
|
+
// right, so what is reported is what its commits add.
|
|
74
|
+
await plain(`diff ${flags} "$REPO_BASE"...HEAD --`, dir, {
|
|
75
|
+
REPO_BASE: base
|
|
76
|
+
})
|
|
77
|
+
: await plain(`diff ${flags}`, dir);
|
|
39
78
|
// Same reasoning as `repo_status`: "(no diff)" and "the diff could not
|
|
40
79
|
// be read" are opposite answers, and this is the tool a reviewing agent
|
|
41
80
|
// trusts most.
|
|
42
81
|
if (!result.success) {
|
|
43
82
|
logFailure("repo_diff", result);
|
|
44
|
-
|
|
83
|
+
const named = ref ?? base;
|
|
84
|
+
return bounded(named
|
|
85
|
+
? // Names the ref and the likely cause: the common failure is a ref
|
|
86
|
+
// this checkout has never fetched, and "unknown revision" on its
|
|
87
|
+
// own does not say that.
|
|
88
|
+
`could not diff "${named}" in ${dir} — fetch it first if it has not ` +
|
|
89
|
+
`been fetched: ${result.stderr || result.stdout}`
|
|
90
|
+
: `could not read the diff in ${dir}: ${result.stderr || result.stdout}`);
|
|
45
91
|
}
|
|
46
92
|
// Truncated from the middle rather than the end: the head of a diff and
|
|
47
93
|
// its tail are both informative, and the middle of a large one rarely
|
|
@@ -59,7 +105,7 @@ export function worktreeTools(ctx) {
|
|
|
59
105
|
execute: async ({ dir, message }) => {
|
|
60
106
|
// Checked rather than fired and forgotten. A failed `add` leaves the
|
|
61
107
|
// index holding less than the model believes, and the commit that
|
|
62
|
-
// follows still succeeds — so the
|
|
108
|
+
// follows still succeeds — so the turn reports a commit that quietly
|
|
63
109
|
// does not contain the change.
|
|
64
110
|
const staged = await plain("add -A", dir);
|
|
65
111
|
if (!staged.success) {
|
|
@@ -89,13 +135,41 @@ export function worktreeTools(ctx) {
|
|
|
89
135
|
return bounded(result.stdout.trim());
|
|
90
136
|
}
|
|
91
137
|
}),
|
|
138
|
+
/**
|
|
139
|
+
* Bring the remote's branches into a checkout without touching its tree.
|
|
140
|
+
*
|
|
141
|
+
* `repo_clone` fetches too, but it resets the tree afterwards and only ever
|
|
142
|
+
* reaches the checkout it clones into — so work pushed to a branch by
|
|
143
|
+
* somebody else, a submodule's included, had no way into a checkout that
|
|
144
|
+
* already existed. Nothing here writes to the working tree, so there is no
|
|
145
|
+
* refusal path for uncommitted work.
|
|
146
|
+
*/
|
|
147
|
+
repo_fetch: tool({
|
|
148
|
+
description: "Fetch every branch from a checkout's own origin, without touching its working tree. This is how a branch someone else pushed becomes a ref you can review: after it, repo_diff with ref 'origin/<branch>'. Pass the directory of the repository the branch was pushed to — in a superproject, that is the submodule's directory.",
|
|
149
|
+
inputSchema: z.object({ dir: z.string().describe("Checkout directory") }),
|
|
150
|
+
execute: async ({ dir }) => {
|
|
151
|
+
// The checkout's own origin, for the reason `repo_push` reads it: it has
|
|
152
|
+
// already passed the allowlist, and a URL named here would not have.
|
|
153
|
+
const { remote, unreachable } = await origin(dir);
|
|
154
|
+
if (unreachable)
|
|
155
|
+
return bounded(`could not fetch in ${dir}: ${unreachable}`);
|
|
156
|
+
if (!remote)
|
|
157
|
+
return `${dir} has no origin on an allowed host — clone it with repo_clone first`;
|
|
158
|
+
const fetched = await fetchOrigin(dir, remote.url);
|
|
159
|
+
if (!fetched.success) {
|
|
160
|
+
logFailure("repo_fetch", fetched);
|
|
161
|
+
return bounded(`fetch failed: ${fetched.stderr || fetched.stdout}`);
|
|
162
|
+
}
|
|
163
|
+
return `fetched ${remote.url} into ${dir}; its branches are under origin/ — review one with repo_diff and ref "origin/<branch>"`;
|
|
164
|
+
}
|
|
165
|
+
}),
|
|
92
166
|
repo_push: tool({
|
|
93
|
-
description: "Push a branch to the remote
|
|
167
|
+
description: "Push a branch to the remote: one this checkout holds, or a new one created at its current commit. Refuses a protected branch, a force-push, and a branch that adds no commit the remote does not already have — open a pull request instead.",
|
|
94
168
|
inputSchema: z.object({
|
|
95
169
|
dir: z.string().describe("Checkout directory"),
|
|
96
170
|
branch: z
|
|
97
171
|
.string()
|
|
98
|
-
.describe("
|
|
172
|
+
.describe("The branch your commits are on, or a new name to create at the current commit, e.g. 'coder/add-json-flag'")
|
|
99
173
|
}),
|
|
100
174
|
execute: async ({ dir, branch }) => {
|
|
101
175
|
// Enforced here rather than in the prompt: a guardrail a model can talk
|
|
@@ -111,7 +185,7 @@ export function worktreeTools(ctx) {
|
|
|
111
185
|
// The repository's *own* default, which is often none of the four names
|
|
112
186
|
// above: a repo whose trunk is `release` deserves the same protection.
|
|
113
187
|
// A command that never ran stops here rather than standing the guards
|
|
114
|
-
// down — the same rule as the
|
|
188
|
+
// down — the same rule as the probes further down, and the reason
|
|
115
189
|
// {@link resolveDefaultBranch} reports the two separately.
|
|
116
190
|
const head = await resolveDefaultBranch(plain, dir);
|
|
117
191
|
if (head.unreachable)
|
|
@@ -124,6 +198,62 @@ export function worktreeTools(ctx) {
|
|
|
124
198
|
return bounded(`could not push "${branch}": ${unreachable}`);
|
|
125
199
|
if (!remote)
|
|
126
200
|
return `${dir} has no origin on an allowed host — clone it with repo_clone first`;
|
|
201
|
+
const exists = await plain(`rev-parse --verify --quiet "refs/heads/$REPO_BRANCH"`, dir, { REPO_BRANCH: branch });
|
|
202
|
+
// Same reasoning as the probe in `repo_clone`: `!success` means "no such
|
|
203
|
+
// branch, create it" only when git was there to say so.
|
|
204
|
+
if (exists.unreachable)
|
|
205
|
+
return bounded(`could not push "${branch}": ${exists.stderr}`);
|
|
206
|
+
// The commit the push would publish: the branch's own, or HEAD for a
|
|
207
|
+
// branch this creates, which is where `checkout -b` puts it.
|
|
208
|
+
const tip = await plain(`log -1 --format=%H%n%s "$REPO_REF" --`, dir, {
|
|
209
|
+
REPO_REF: exists.success ? `refs/heads/${branch}` : "HEAD"
|
|
210
|
+
});
|
|
211
|
+
if (tip.unreachable)
|
|
212
|
+
return bounded(`could not push "${branch}": ${tip.stderr}`);
|
|
213
|
+
const [commit = "", subject = ""] = tip.stdout.trim().split("\n");
|
|
214
|
+
if (!tip.success || !commit) {
|
|
215
|
+
logFailure("repo_push", tip);
|
|
216
|
+
return bounded(`could not resolve "${branch}" to a commit: ${tip.stderr || tip.stdout}`);
|
|
217
|
+
}
|
|
218
|
+
const short = commit.slice(0, 7);
|
|
219
|
+
// Nothing to push is refused — the README's "Guardrails" says why — and
|
|
220
|
+
// measured against every remote branch, since a pull request's base need
|
|
221
|
+
// not be the default. The branch's own remote ref is left out so a landed
|
|
222
|
+
// push can be retried: `--exclude` wants it without `refs/remotes/`, and
|
|
223
|
+
// with that prefix excludes nothing. Checked before the branch is
|
|
224
|
+
// created, so a refusal leaves none behind for a retry to find.
|
|
225
|
+
const fresh = await plain(`rev-list --count "$REPO_TIP" --not --exclude="origin/$REPO_BRANCH" --remotes=origin`, dir, { REPO_TIP: commit, REPO_BRANCH: branch });
|
|
226
|
+
if (fresh.unreachable)
|
|
227
|
+
return bounded(`could not push "${branch}": ${fresh.stderr}`);
|
|
228
|
+
const count = fresh.stdout.trim();
|
|
229
|
+
// A comparison with no answer is not a pass, and an empty one is not 0.
|
|
230
|
+
if (!fresh.success || !/^\d+$/.test(count)) {
|
|
231
|
+
logFailure("repo_push", fresh);
|
|
232
|
+
return bounded(`could not tell whether "${branch}" has anything to push: comparing it ` +
|
|
233
|
+
`against the remote's branches failed with ` +
|
|
234
|
+
`${fresh.stderr || fresh.stdout || "no output"}\n` +
|
|
235
|
+
`Nothing was pushed. Pushing without that answer risks an empty pull ` +
|
|
236
|
+
`request reported as success, so check repo_status and repo_diff first.`);
|
|
237
|
+
}
|
|
238
|
+
if (count === "0") {
|
|
239
|
+
// Where the work went is the host's to say, when it keeps worktrees:
|
|
240
|
+
// a writing sub-agent's commits are on its worktree's branch.
|
|
241
|
+
let held;
|
|
242
|
+
try {
|
|
243
|
+
held = await config.worktrees?.list();
|
|
244
|
+
}
|
|
245
|
+
catch (err) {
|
|
246
|
+
console.warn("[repo] worktrees.list failed", { err: String(err) });
|
|
247
|
+
}
|
|
248
|
+
return bounded(`refusing to push "${branch}" — ` +
|
|
249
|
+
(exists.success
|
|
250
|
+
? ""
|
|
251
|
+
: `it is not a branch in ${dir}, so it would be created at the current commit, and `) +
|
|
252
|
+
`${short} adds no commit the remote does not already have, so the pull ` +
|
|
253
|
+
`request would be empty. Either the change was never committed, or it was ` +
|
|
254
|
+
`committed on a different branch` +
|
|
255
|
+
(held ? `.\n\n${held}` : ` — check repo_status and repo_diff.`));
|
|
256
|
+
}
|
|
127
257
|
// Switch to the branch, or create it — but never *reset* it.
|
|
128
258
|
//
|
|
129
259
|
// `-b`, never `-B`: `-B` is create-or-reset, so on a branch that already
|
|
@@ -131,11 +261,6 @@ export function worktreeTools(ctx) {
|
|
|
131
261
|
// survives only as an unreferenced object, and what gets pushed is an
|
|
132
262
|
// empty branch with a pull request opened on it — a complete loss
|
|
133
263
|
// 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
264
|
const checkout = await plain(exists.success
|
|
140
265
|
? `checkout "$REPO_BRANCH"`
|
|
141
266
|
: `checkout -b "$REPO_BRANCH"`, dir, { REPO_BRANCH: branch });
|
|
@@ -143,56 +268,15 @@ export function worktreeTools(ctx) {
|
|
|
143
268
|
logFailure("repo_push", checkout);
|
|
144
269
|
return bounded(`could not switch to branch "${branch}": ${checkout.stderr || checkout.stdout}`);
|
|
145
270
|
}
|
|
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
271
|
const result = await pushBranch(dir, remote.url, branch);
|
|
190
272
|
if (!result.success) {
|
|
191
273
|
logFailure("repo_push", result);
|
|
192
274
|
return bounded(`push failed: ${result.stderr || result.stdout}`);
|
|
193
275
|
}
|
|
276
|
+
const pushed = `pushed ${branch} at ${short} "${subject}", ${count} ` +
|
|
277
|
+
`${count === "1" ? "commit" : "commits"} no other branch on the remote has`;
|
|
194
278
|
// 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
|
|
279
|
+
// here because the push happens on the host's side: a sub-agent reaching
|
|
196
280
|
// for a bare `git push` in the shell should still find the branch
|
|
197
281
|
// tracking something.
|
|
198
282
|
//
|
|
@@ -208,13 +292,13 @@ export function worktreeTools(ctx) {
|
|
|
208
292
|
// "repo_push failed" next to a branch that is on the remote is worse
|
|
209
293
|
// than no log line.
|
|
210
294
|
logFailure("repo_push (upstream tracking)", untracked);
|
|
211
|
-
return bounded(
|
|
295
|
+
return bounded(`${pushed}, but could not record what it tracks: ` +
|
|
212
296
|
`${untracked.stderr || untracked.stdout}\n` +
|
|
213
297
|
`The push itself landed. The only consequence is local: a bare ` +
|
|
214
298
|
`\`git push\` from the shell will not know where to send this ` +
|
|
215
299
|
`branch, and needs \`git push -u origin ${branch}\` once.`);
|
|
216
300
|
}
|
|
217
|
-
return
|
|
301
|
+
return bounded(pushed);
|
|
218
302
|
}
|
|
219
303
|
})
|
|
220
304
|
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ToolSet } from "ai";
|
|
2
|
+
import type { RepoWorktrees } from "./index.js";
|
|
3
|
+
/**
|
|
4
|
+
* Moving between the host's worktrees — not to be confused with
|
|
5
|
+
* `./tools-worktree.ts`, which reads and writes the one checkout the tools point
|
|
6
|
+
* at.
|
|
7
|
+
*
|
|
8
|
+
* Thin by design: which worktrees exist and what a switch reaches are the host's
|
|
9
|
+
* facts — see {@link RepoWorktrees} — so these tools check a branch's shape and
|
|
10
|
+
* pass the host's sentence through.
|
|
11
|
+
*/
|
|
12
|
+
export declare function worktreesTools(worktrees: RepoWorktrees): ToolSet;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { tool } from "ai";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { UNSAFE_BRANCH } from "./url.js";
|
|
4
|
+
/**
|
|
5
|
+
* Moving between the host's worktrees — not to be confused with
|
|
6
|
+
* `./tools-worktree.ts`, which reads and writes the one checkout the tools point
|
|
7
|
+
* at.
|
|
8
|
+
*
|
|
9
|
+
* Thin by design: which worktrees exist and what a switch reaches are the host's
|
|
10
|
+
* facts — see {@link RepoWorktrees} — so these tools check a branch's shape and
|
|
11
|
+
* pass the host's sentence through.
|
|
12
|
+
*/
|
|
13
|
+
export function worktreesTools(worktrees) {
|
|
14
|
+
const refuse = (branch) => `"${branch}" is not a plain branch name — pass it as the sub-agent's report named it`;
|
|
15
|
+
const unsafe = (branch) => branch === "" || UNSAFE_BRANCH.test(branch);
|
|
16
|
+
return {
|
|
17
|
+
repo_worktrees: tool({
|
|
18
|
+
description: "List the worktrees your writing sub-agents committed in: each one's branch, whether a session is still working in it, and whether its commits are pushed. Pass `release` with a branch to give up its worktree once you have decided not to keep that work — unpushed commits in it are lost.",
|
|
19
|
+
inputSchema: z.object({
|
|
20
|
+
release: z
|
|
21
|
+
.string()
|
|
22
|
+
.optional()
|
|
23
|
+
.describe("A branch whose worktree to give up")
|
|
24
|
+
}),
|
|
25
|
+
execute: async ({ release }) => {
|
|
26
|
+
if (release === undefined)
|
|
27
|
+
return await worktrees.list();
|
|
28
|
+
if (unsafe(release))
|
|
29
|
+
return refuse(release);
|
|
30
|
+
return await worktrees.release(release);
|
|
31
|
+
}
|
|
32
|
+
}),
|
|
33
|
+
repo_worktree: tool({
|
|
34
|
+
description: "Point every repo tool and your file reads at the worktree holding a branch a writing sub-agent reported, to read what it did. They stay there until you call this again; call it with no branch to come back to your own checkout.",
|
|
35
|
+
inputSchema: z.object({
|
|
36
|
+
branch: z
|
|
37
|
+
.string()
|
|
38
|
+
.optional()
|
|
39
|
+
.describe("The branch a sub-agent's report named, e.g. 'claude-coordinator/<task>/<n>'; omit to go back to your own checkout")
|
|
40
|
+
}),
|
|
41
|
+
execute: async ({ branch }) => {
|
|
42
|
+
if (branch !== undefined && unsafe(branch))
|
|
43
|
+
return refuse(branch);
|
|
44
|
+
return await worktrees.use(branch);
|
|
45
|
+
}
|
|
46
|
+
})
|
|
47
|
+
};
|
|
48
|
+
}
|
package/dist/repo/url.d.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* naming it": every pattern here turns away a string that resolves to something
|
|
7
7
|
* other than what it appears to name.
|
|
8
8
|
*/
|
|
9
|
-
/** The
|
|
9
|
+
/** The hosts a clone URL may name. `RepoConfig.allowedHosts` overrides it. */
|
|
10
10
|
export declare const DEFAULT_ALLOWED_HOSTS: string[];
|
|
11
11
|
/** Branch names a push must never target, whatever the model believes. */
|
|
12
12
|
export declare const PROTECTED_BRANCHES: Set<string>;
|
|
@@ -50,7 +50,7 @@ export declare function sameRepoUrl(a: string, b: string): boolean;
|
|
|
50
50
|
*
|
|
51
51
|
* Anchored on the **parsed host** rather than matched in the string, so
|
|
52
52
|
* `https://evil.example.com/github.com/owner/repo` is not a github.com URL.
|
|
53
|
-
* Both names are then checked against {@link
|
|
53
|
+
* Both names are then checked against {@link isGitHubName}, so a name that could
|
|
54
54
|
* traverse a path or split somebody else's key never gets as far as being one.
|
|
55
55
|
*
|
|
56
56
|
* `undefined` is a refusal at every caller, never a reason to carry on with a
|