@dynamicagents/plugins 0.11.1 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -79
- package/dist/browser/index.d.ts +0 -2
- package/dist/browser/index.js +21 -27
- package/dist/claude-code/cards.d.ts +53 -0
- package/dist/claude-code/cards.js +272 -0
- package/dist/claude-code/config.d.ts +73 -31
- package/dist/claude-code/config.js +0 -2
- package/dist/claude-code/credentials.d.ts +21 -9
- package/dist/claude-code/credentials.js +43 -9
- package/dist/claude-code/egress.d.ts +15 -14
- package/dist/claude-code/egress.js +51 -14
- package/dist/claude-code/events.d.ts +153 -9
- package/dist/claude-code/events.js +289 -58
- package/dist/claude-code/github.d.ts +39 -0
- package/dist/claude-code/github.js +82 -0
- package/dist/claude-code/index.d.ts +24 -80
- package/dist/claude-code/index.js +19 -234
- package/dist/claude-code/model.d.ts +152 -0
- package/dist/claude-code/model.js +342 -0
- package/dist/claude-code/run.d.ts +283 -92
- package/dist/claude-code/run.js +620 -114
- package/dist/claude-code/session.d.ts +131 -0
- package/dist/claude-code/session.js +285 -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 +19 -0
- package/dist/computer/context.js +222 -0
- package/dist/computer/file-lock.d.ts +5 -0
- package/dist/computer/file-lock.js +40 -0
- package/dist/computer/gate.d.ts +12 -12
- package/dist/computer/gate.js +12 -12
- package/dist/computer/index.d.ts +57 -255
- package/dist/computer/index.js +98 -1039
- package/dist/computer/paths.d.ts +47 -22
- package/dist/computer/paths.js +64 -49
- package/dist/computer/proxy.d.ts +45 -0
- package/dist/computer/proxy.js +234 -0
- package/dist/computer/render.d.ts +27 -38
- package/dist/computer/render.js +32 -65
- package/dist/computer/tools-exec.d.ts +3 -0
- package/dist/computer/tools-exec.js +268 -0
- package/dist/computer/tools-file.d.ts +12 -0
- package/dist/computer/tools-file.js +64 -0
- package/dist/computer/tools-grep.d.ts +3 -0
- package/dist/computer/tools-grep.js +129 -0
- package/dist/repo/checkout.d.ts +35 -6
- package/dist/repo/checkout.js +64 -6
- package/dist/repo/context.d.ts +135 -0
- package/dist/repo/context.js +582 -0
- package/dist/repo/index.d.ts +79 -57
- package/dist/repo/index.js +118 -1112
- package/dist/repo/tools-checks.d.ts +3 -0
- package/dist/repo/tools-checks.js +112 -0
- package/dist/repo/tools-clone.d.ts +4 -0
- package/dist/repo/tools-clone.js +192 -0
- package/dist/repo/tools-pr.d.ts +14 -0
- package/dist/repo/tools-pr.js +196 -0
- package/dist/repo/tools-review.d.ts +12 -0
- package/dist/repo/tools-review.js +393 -0
- package/dist/repo/tools-worktree.d.ts +4 -0
- package/dist/repo/tools-worktree.js +305 -0
- 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 +33 -20
- package/dist/{computer → workspace}/advisory.js +32 -7
- package/dist/workspace/ca-trust.d.ts +95 -0
- package/dist/workspace/ca-trust.js +312 -0
- package/dist/workspace/container-deps.d.ts +54 -0
- package/dist/workspace/container-deps.js +224 -0
- package/dist/workspace/container-fault.d.ts +38 -0
- package/dist/workspace/container-fault.js +73 -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/workspace/git-host.d.ts +79 -0
- package/dist/workspace/git-host.js +226 -0
- package/dist/workspace/git-identity.d.ts +57 -0
- package/dist/workspace/git-identity.js +147 -0
- package/dist/workspace/index.d.ts +43 -47
- package/dist/workspace/index.js +52 -112
- package/dist/workspace/install-job.d.ts +121 -0
- package/dist/workspace/install-job.js +866 -0
- package/dist/{computer → workspace}/install.d.ts +15 -19
- package/dist/{computer → workspace}/install.js +8 -11
- package/dist/workspace/object.d.ts +502 -0
- package/dist/workspace/object.js +1427 -0
- 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/workspace/shell.d.ts +45 -0
- package/dist/workspace/shell.js +80 -0
- package/dist/workspace/sync.d.ts +111 -0
- package/dist/workspace/sync.js +124 -0
- package/dist/workspace/wake.d.ts +19 -0
- package/dist/workspace/wake.js +10 -0
- package/package.json +28 -42
- package/dist/arc-agi/analysis.d.ts +0 -336
- package/dist/arc-agi/analysis.js +0 -829
- 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 -25
- package/dist/arc-agi/game-tools.js +0 -26
- package/dist/arc-agi/index.d.ts +0 -42
- package/dist/arc-agi/index.js +0 -175
- package/dist/arc-agi/main-agent.d.ts +0 -27
- package/dist/arc-agi/main-agent.js +0 -65
- package/dist/arc-agi/recipe.d.ts +0 -51
- package/dist/arc-agi/recipe.js +0 -100
- 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 -40
- package/dist/arc-agi/soul.js +0 -90
- package/dist/arc-agi/store.d.ts +0 -154
- package/dist/arc-agi/store.js +0 -216
- package/dist/arc-agi/tools.d.ts +0 -36
- package/dist/arc-agi/tools.js +0 -576
- package/dist/arc-agi/types.d.ts +0 -212
- 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 -137
- package/dist/computer/read.js +0 -203
- package/dist/recall/index.d.ts +0 -99
- package/dist/recall/index.js +0 -171
- package/dist/triage/index.d.ts +0 -355
- package/dist/triage/index.js +0 -302
|
@@ -0,0 +1,305 @@
|
|
|
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, config, fetchOrigin, 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 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
|
+
inputSchema: z.object({
|
|
27
|
+
dir: z.string().describe("Checkout directory"),
|
|
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."),
|
|
37
|
+
stat: z
|
|
38
|
+
.boolean()
|
|
39
|
+
.optional()
|
|
40
|
+
.describe("Summarise as a per-file changed-line count instead of the full patch")
|
|
41
|
+
}),
|
|
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"`;
|
|
55
|
+
const flags = [staged ? "--staged" : "", stat ? "--stat" : ""]
|
|
56
|
+
.filter(Boolean)
|
|
57
|
+
.join(" ");
|
|
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);
|
|
78
|
+
// Same reasoning as `repo_status`: "(no diff)" and "the diff could not
|
|
79
|
+
// be read" are opposite answers, and this is the tool a reviewing agent
|
|
80
|
+
// trusts most.
|
|
81
|
+
if (!result.success) {
|
|
82
|
+
logFailure("repo_diff", result);
|
|
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}`);
|
|
91
|
+
}
|
|
92
|
+
// Truncated from the middle rather than the end: the head of a diff and
|
|
93
|
+
// its tail are both informative, and the middle of a large one rarely
|
|
94
|
+
// is. A model that needs the part that was dropped can ask for `stat`
|
|
95
|
+
// and then read the file.
|
|
96
|
+
return bounded(result.stdout.trim()) || "(no diff)";
|
|
97
|
+
}
|
|
98
|
+
}),
|
|
99
|
+
repo_commit: tool({
|
|
100
|
+
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.",
|
|
101
|
+
inputSchema: z.object({
|
|
102
|
+
dir: z.string().describe("Checkout directory"),
|
|
103
|
+
message: z.string().describe("Commit message")
|
|
104
|
+
}),
|
|
105
|
+
execute: async ({ dir, message }) => {
|
|
106
|
+
// Checked rather than fired and forgotten. A failed `add` leaves the
|
|
107
|
+
// index holding less than the model believes, and the commit that
|
|
108
|
+
// follows still succeeds — so the turn reports a commit that quietly
|
|
109
|
+
// does not contain the change.
|
|
110
|
+
const staged = await plain("add -A", dir);
|
|
111
|
+
if (!staged.success) {
|
|
112
|
+
logFailure("repo_commit", staged);
|
|
113
|
+
return bounded(`could not stage the changes in ${dir}: ${staged.stderr || staged.stdout}`);
|
|
114
|
+
}
|
|
115
|
+
// The message is model-authored free text — quotes, backticks, newlines,
|
|
116
|
+
// `$(…)`. Expanded from the environment inside the container, all of
|
|
117
|
+
// that is inert; interpolated into the command string, none of it is.
|
|
118
|
+
const result = await plain(`commit -m "$GIT_COMMIT_MESSAGE"`, dir, {
|
|
119
|
+
GIT_COMMIT_MESSAGE: message,
|
|
120
|
+
// On the commit as well as in the checkout's config, and this is
|
|
121
|
+
// the copy that is current: config is written when a checkout is set
|
|
122
|
+
// up, and the environment is read at the commit. A workspace that
|
|
123
|
+
// outlived a change of identity commits under the new one.
|
|
124
|
+
GIT_AUTHOR_NAME: author.name,
|
|
125
|
+
GIT_AUTHOR_EMAIL: author.email,
|
|
126
|
+
GIT_COMMITTER_NAME: author.name,
|
|
127
|
+
GIT_COMMITTER_EMAIL: author.email
|
|
128
|
+
});
|
|
129
|
+
if (!result.success && /nothing to commit/i.test(result.stdout))
|
|
130
|
+
return "nothing to commit — the working tree is clean";
|
|
131
|
+
if (!result.success) {
|
|
132
|
+
logFailure("repo_commit", result);
|
|
133
|
+
return bounded(`commit failed: ${result.stderr || result.stdout}`);
|
|
134
|
+
}
|
|
135
|
+
return bounded(result.stdout.trim());
|
|
136
|
+
}
|
|
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
|
+
}),
|
|
166
|
+
repo_push: tool({
|
|
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.",
|
|
168
|
+
inputSchema: z.object({
|
|
169
|
+
dir: z.string().describe("Checkout directory"),
|
|
170
|
+
branch: z
|
|
171
|
+
.string()
|
|
172
|
+
.describe("The branch your commits are on, or a new name to create at the current commit, e.g. 'coder/add-json-flag'")
|
|
173
|
+
}),
|
|
174
|
+
execute: async ({ dir, branch }) => {
|
|
175
|
+
// Enforced here rather than in the prompt: a guardrail a model can talk
|
|
176
|
+
// itself out of is not a guardrail.
|
|
177
|
+
//
|
|
178
|
+
// Shape first, then the name. `git push origin <name>` reads `<name>`
|
|
179
|
+
// as a refspec, so `+x:main` is a force push to main that the name
|
|
180
|
+
// check below would wave through — it only ever sees a literal string.
|
|
181
|
+
if (UNSAFE_BRANCH.test(branch))
|
|
182
|
+
return `"${branch}" is not a plain branch name — use something like "coder/add-json-flag"`;
|
|
183
|
+
if (PROTECTED_BRANCHES.has(branch))
|
|
184
|
+
return `refusing to push to "${branch}" — push a work branch and open a pull request`;
|
|
185
|
+
// The repository's *own* default, which is often none of the four names
|
|
186
|
+
// above: a repo whose trunk is `release` deserves the same protection.
|
|
187
|
+
// A command that never ran stops here rather than standing the guards
|
|
188
|
+
// down — the same rule as the probes further down, and the reason
|
|
189
|
+
// {@link resolveDefaultBranch} reports the two separately.
|
|
190
|
+
const head = await resolveDefaultBranch(plain, dir);
|
|
191
|
+
if (head.unreachable)
|
|
192
|
+
return bounded(`could not push "${branch}": ${head.unreachable}`);
|
|
193
|
+
const defaultBranch = head.branch;
|
|
194
|
+
if (defaultBranch && defaultBranch === branch)
|
|
195
|
+
return `refusing to push to "${branch}" — it is this repository's default branch; push a work branch and open a pull request`;
|
|
196
|
+
const { remote, unreachable } = await origin(dir);
|
|
197
|
+
if (unreachable)
|
|
198
|
+
return bounded(`could not push "${branch}": ${unreachable}`);
|
|
199
|
+
if (!remote)
|
|
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
|
+
}
|
|
257
|
+
// Switch to the branch, or create it — but never *reset* it.
|
|
258
|
+
//
|
|
259
|
+
// `-b`, never `-B`: `-B` is create-or-reset, so on a branch that already
|
|
260
|
+
// holds the commit it force-moves it to wherever HEAD is now. The commit
|
|
261
|
+
// survives only as an unreferenced object, and what gets pushed is an
|
|
262
|
+
// empty branch with a pull request opened on it — a complete loss
|
|
263
|
+
// dressed up as success.
|
|
264
|
+
const checkout = await plain(exists.success
|
|
265
|
+
? `checkout "$REPO_BRANCH"`
|
|
266
|
+
: `checkout -b "$REPO_BRANCH"`, dir, { REPO_BRANCH: branch });
|
|
267
|
+
if (!checkout.success) {
|
|
268
|
+
logFailure("repo_push", checkout);
|
|
269
|
+
return bounded(`could not switch to branch "${branch}": ${checkout.stderr || checkout.stdout}`);
|
|
270
|
+
}
|
|
271
|
+
const result = await pushBranch(dir, remote.url, branch);
|
|
272
|
+
if (!result.success) {
|
|
273
|
+
logFailure("repo_push", result);
|
|
274
|
+
return bounded(`push failed: ${result.stderr || result.stdout}`);
|
|
275
|
+
}
|
|
276
|
+
const pushed = `pushed ${branch} at ${short} "${subject}", ${count} ` +
|
|
277
|
+
`${count === "1" ? "commit" : "commits"} no other branch on the remote has`;
|
|
278
|
+
// What `--set-upstream` would do as a side effect of the push, written
|
|
279
|
+
// here because the push happens on the host's side: a sub-agent reaching
|
|
280
|
+
// for a bare `git push` in the shell should still find the branch
|
|
281
|
+
// tracking something.
|
|
282
|
+
//
|
|
283
|
+
// Checked, but *reported* rather than raised: this is the one place in
|
|
284
|
+
// the plugin where a failed command is not a failed operation. The push
|
|
285
|
+
// has landed, saying so is the important half, and all that is lost is
|
|
286
|
+
// the convenience these two keys buy.
|
|
287
|
+
const remoteSet = await plain(`config "branch.$REPO_BRANCH.remote" origin`, dir, { REPO_BRANCH: branch });
|
|
288
|
+
const mergeSet = await plain(`config "branch.$REPO_BRANCH.merge" "refs/heads/$REPO_BRANCH"`, dir, { REPO_BRANCH: branch });
|
|
289
|
+
const untracked = [remoteSet, mergeSet].find((r) => !r.success);
|
|
290
|
+
if (untracked) {
|
|
291
|
+
// Named apart from the push, which succeeded — a log line reading
|
|
292
|
+
// "repo_push failed" next to a branch that is on the remote is worse
|
|
293
|
+
// than no log line.
|
|
294
|
+
logFailure("repo_push (upstream tracking)", untracked);
|
|
295
|
+
return bounded(`${pushed}, but could not record what it tracks: ` +
|
|
296
|
+
`${untracked.stderr || untracked.stdout}\n` +
|
|
297
|
+
`The push itself landed. The only consequence is local: a bare ` +
|
|
298
|
+
`\`git push\` from the shell will not know where to send this ` +
|
|
299
|
+
`branch, and needs \`git push -u origin ${branch}\` once.`);
|
|
300
|
+
}
|
|
301
|
+
return bounded(pushed);
|
|
302
|
+
}
|
|
303
|
+
})
|
|
304
|
+
};
|
|
305
|
+
}
|
|
@@ -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
|
package/dist/repo/url.js
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 const DEFAULT_ALLOWED_HOSTS = ["github.com"];
|
|
11
11
|
/** Branch names a push must never target, whatever the model believes. */
|
|
12
12
|
export const PROTECTED_BRANCHES = new Set([
|
|
@@ -88,7 +88,7 @@ export function sameRepoUrl(a, b) {
|
|
|
88
88
|
return canonical(a) === canonical(b);
|
|
89
89
|
}
|
|
90
90
|
/**
|
|
91
|
-
* A name
|
|
91
|
+
* A name GitHub would actually issue — and one that is safe to use as a path
|
|
92
92
|
* segment and as a key.
|
|
93
93
|
*
|
|
94
94
|
* GitHub's rule for an owner and a repository alike is alphanumerics, `-`, `_`
|
|
@@ -101,17 +101,17 @@ export function sameRepoUrl(a, b) {
|
|
|
101
101
|
* is a separator in somebody else's key format, which is how two callers end up
|
|
102
102
|
* sharing one workspace.
|
|
103
103
|
*/
|
|
104
|
-
const
|
|
105
|
-
function
|
|
104
|
+
const GITHUB_NAME = /^[A-Za-z0-9._-]+$/;
|
|
105
|
+
function isGitHubName(name) {
|
|
106
106
|
// `.` and `..` pass the character class and are exactly the two that must not.
|
|
107
|
-
return name !== "." && name !== ".." &&
|
|
107
|
+
return name !== "." && name !== ".." && GITHUB_NAME.test(name);
|
|
108
108
|
}
|
|
109
109
|
/**
|
|
110
110
|
* `owner/repo` out of a repository URL, or `undefined` if it is not one.
|
|
111
111
|
*
|
|
112
112
|
* Anchored on the **parsed host** rather than matched in the string, so
|
|
113
113
|
* `https://evil.example.com/github.com/owner/repo` is not a github.com URL.
|
|
114
|
-
* Both names are then checked against {@link
|
|
114
|
+
* Both names are then checked against {@link isGitHubName}, so a name that could
|
|
115
115
|
* traverse a path or split somebody else's key never gets as far as being one.
|
|
116
116
|
*
|
|
117
117
|
* `undefined` is a refusal at every caller, never a reason to carry on with a
|
|
@@ -125,7 +125,7 @@ export function parseRepo(url, allowedHosts = DEFAULT_ALLOWED_HOSTS) {
|
|
|
125
125
|
if (!match)
|
|
126
126
|
return undefined;
|
|
127
127
|
const [, owner, repo] = match;
|
|
128
|
-
if (!
|
|
128
|
+
if (!isGitHubName(owner) || !isGitHubName(repo))
|
|
129
129
|
return undefined;
|
|
130
130
|
return { owner, repo };
|
|
131
131
|
}
|
package/dist/scratch/index.d.ts
CHANGED
|
@@ -42,7 +42,7 @@ import type { AgentPlugin } from "@dynamicagents/core";
|
|
|
42
42
|
/** Where a scratchpad lives, unless the host says otherwise. */
|
|
43
43
|
export declare const DEFAULT_SCRATCH_DIR = "/workspace/scratch";
|
|
44
44
|
/**
|
|
45
|
-
* Run one command in the host's container. Matches `
|
|
45
|
+
* Run one command in the host's container. Matches `workspaceExec`'s shape.
|
|
46
46
|
*
|
|
47
47
|
* Injected rather than imported, like `/repo`'s: `npm run verify:exports` fails
|
|
48
48
|
* any subpath whose module graph reaches a sibling's, and a scratchpad that
|
|
@@ -54,7 +54,7 @@ export type ScratchExec = (command: string, options?: {
|
|
|
54
54
|
env?: Record<string, string | undefined>;
|
|
55
55
|
timeout?: number;
|
|
56
56
|
/**
|
|
57
|
-
* The
|
|
57
|
+
* The running sub-agent's `runtime()`, forwarded **opaquely** — the same
|
|
58
58
|
* pass-through `/repo` does, for the same reason. This plugin never looks
|
|
59
59
|
* inside it.
|
|
60
60
|
*/
|
|
@@ -96,7 +96,7 @@ export interface ScratchConfig {
|
|
|
96
96
|
* Runs commands in the container holding the workspace.
|
|
97
97
|
*
|
|
98
98
|
* Uncredentialed, and nothing here ever needs a credential: a scratchpad has
|
|
99
|
-
* no remote, so no operation in this plugin talks to
|
|
99
|
+
* no remote, so no operation in this plugin talks to GitHub.
|
|
100
100
|
*/
|
|
101
101
|
exec: ScratchExec;
|
|
102
102
|
/** Where the scratchpad lives. Defaults to {@link DEFAULT_SCRATCH_DIR}. */
|
|
@@ -153,6 +153,6 @@ export interface ScratchConfig {
|
|
|
153
153
|
* carries the same copy for the same reason.
|
|
154
154
|
*/
|
|
155
155
|
export declare function truncateOutput(text: string, max: number): string;
|
|
156
|
-
/** The tool name, exported so a host restricting
|
|
156
|
+
/** The tool name, exported so a host restricting the plugin can name it. */
|
|
157
157
|
export declare const SCRATCH_OPEN_TOOL = "scratch_open";
|
|
158
158
|
export declare function scratch(config: ScratchConfig): AgentPlugin;
|
package/dist/scratch/index.js
CHANGED
|
@@ -68,10 +68,10 @@ export function truncateOutput(text, max) {
|
|
|
68
68
|
return text.slice(0, Math.max(0, max));
|
|
69
69
|
return (text.slice(0, half) + marker(text.length - half * 2) + text.slice(-half));
|
|
70
70
|
}
|
|
71
|
-
/** The tool name, exported so a host restricting
|
|
71
|
+
/** The tool name, exported so a host restricting the plugin can name it. */
|
|
72
72
|
export const SCRATCH_OPEN_TOOL = "scratch_open";
|
|
73
73
|
/**
|
|
74
|
-
* What the
|
|
74
|
+
* What the model is told.
|
|
75
75
|
*
|
|
76
76
|
* The mixing rule at the end is the line worth its tokens. A host that keys one
|
|
77
77
|
* workspace selection per caller — which is what makes a scratchpad routable at
|
|
@@ -294,19 +294,20 @@ export function scratch(config) {
|
|
|
294
294
|
.filter(Boolean)
|
|
295
295
|
.join(" "), maxChars);
|
|
296
296
|
};
|
|
297
|
+
const context = capabilityFor(dir);
|
|
297
298
|
return definePlugin({
|
|
298
|
-
|
|
299
|
+
name: "scratch",
|
|
299
300
|
/**
|
|
300
|
-
*
|
|
301
|
-
*
|
|
301
|
+
* For a parent, never a sub-agent — which the host decides by installing it
|
|
302
|
+
* on one and not the other.
|
|
302
303
|
*
|
|
303
304
|
* Opening a scratchpad *selects a workspace*, exactly as `repo_clone` does.
|
|
304
|
-
* A
|
|
305
|
+
* A sub-agent holding this could re-point the workspace its parent prepared
|
|
305
306
|
* half-way through its own run, which is the hazard a host's per-caller
|
|
306
|
-
* selection already has to document. A
|
|
307
|
-
*
|
|
307
|
+
* selection already has to document. A sub-agent works in whatever it was
|
|
308
|
+
* given.
|
|
308
309
|
*/
|
|
309
|
-
|
|
310
|
+
tools: (ctx) => ({
|
|
310
311
|
[SCRATCH_OPEN_TOOL]: tool({
|
|
311
312
|
description: "Open a scratchpad: a git repository with no remote, in your container, " +
|
|
312
313
|
"for work that does not need a cloned repository. Use it before " +
|
|
@@ -318,9 +319,9 @@ export function scratch(config) {
|
|
|
318
319
|
.optional()
|
|
319
320
|
.describe("Discard everything in the scratchpad first, including files an earlier task left")
|
|
320
321
|
}),
|
|
321
|
-
execute: ({ reset }) => open(reset,
|
|
322
|
+
execute: ({ reset }) => open(reset, ctx.runtime())
|
|
322
323
|
})
|
|
323
324
|
}),
|
|
324
|
-
|
|
325
|
+
context: [{ provider: { get: async () => context } }]
|
|
325
326
|
});
|
|
326
327
|
}
|
|
@@ -9,21 +9,20 @@ import type { InstallState } from "./install.js";
|
|
|
9
9
|
* and the generation marker. It is right for that and unchanged by this file.
|
|
10
10
|
*
|
|
11
11
|
* Every *reader*, though, is asking a different question: **can I rely on this
|
|
12
|
-
* workspace right now?** The two diverge, and
|
|
13
|
-
*
|
|
12
|
+
* workspace right now?** The two diverge, and expressing the second through the
|
|
13
|
+
* first goes wrong in ways that are each easy to reintroduce:
|
|
14
14
|
*
|
|
15
|
-
* - A workspace at its storage ceiling
|
|
16
|
-
*
|
|
17
|
-
* wall about the Durable Object, the
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* {@link file://./gate.ts needsDependencies}, so a full workspace said nothing
|
|
15
|
+
* - A workspace at its storage ceiling is not `skipped`. That variant means
|
|
16
|
+
* "this checkout has nothing to install" — routine, and about the repository.
|
|
17
|
+
* A ceiling is a hard wall about the Durable Object, and the two are then told
|
|
18
|
+
* apart only by reading prose.
|
|
19
|
+
* - Capacity must not travel through a channel gated by
|
|
20
|
+
* {@link file://../computer/gate.ts needsDependencies}, or a full workspace says nothing
|
|
22
21
|
* at all to `echo hi > file.txt` — the command whose write is being lost.
|
|
23
|
-
* - "The tree is fine now", after
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
22
|
+
* - "The tree is fine now", after an agent installed by hand, has no honest
|
|
23
|
+
* spelling as a job record: fabricating a `done` rests on the presence of a
|
|
24
|
+
* `node_modules` directory, which the wreckage of the very install being
|
|
25
|
+
* overridden already satisfies.
|
|
27
26
|
*
|
|
28
27
|
* So an advisory is **derived at read time** and describes the workspace, not a
|
|
29
28
|
* job. Its absence is the good case, which is what removes the need to invent
|
|
@@ -57,7 +56,7 @@ export type WorkspaceAdvisory = {
|
|
|
57
56
|
* Reported rather than acted on, because its presence proves nothing in
|
|
58
57
|
* either direction. A half-finished `npm ci` leaves the directory behind
|
|
59
58
|
* with an incomplete tree, so treating existence as success hides the
|
|
60
|
-
* common failure outright; and
|
|
59
|
+
* common failure outright; and an agent that re-ran the install by hand
|
|
61
60
|
* leaves a record the host cannot update, since the host cannot see an
|
|
62
61
|
* install it did not start. Both look identical from here, so the reader
|
|
63
62
|
* is told which way the ambiguity runs rather than being handed a guess.
|
|
@@ -139,11 +138,11 @@ export interface AdvisoryInput {
|
|
|
139
138
|
/**
|
|
140
139
|
* Whether a `node_modules` directory exists — **existence only, not health**.
|
|
141
140
|
*
|
|
142
|
-
* Named for what it can actually observe. A host
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
141
|
+
* Named for what it can actually observe. A host answers it by looking for the
|
|
142
|
+
* directory in the workspace, and that is a narrower question than "are the
|
|
143
|
+
* dependencies fine": an `npm ci` that died partway leaves the directory
|
|
144
|
+
* behind holding an incomplete tree, so a `true` here is entirely consistent
|
|
145
|
+
* with the install having failed for real.
|
|
147
146
|
*
|
|
148
147
|
* So it never suppresses a proven failure. It travels onto the advisory as
|
|
149
148
|
* {@link WorkspaceAdvisory} `treePresent` and the reader is told which way the
|
|
@@ -152,6 +151,20 @@ export interface AdvisoryInput {
|
|
|
152
151
|
* mode it was recording.
|
|
153
152
|
*/
|
|
154
153
|
dependencyTreePresent: boolean;
|
|
154
|
+
/**
|
|
155
|
+
* A reinstall the host has already queued, as the moment it was queued.
|
|
156
|
+
*
|
|
157
|
+
* The difference between "your dependencies are broken" and "your
|
|
158
|
+
* dependencies are being rebuilt", and the two read identically from the
|
|
159
|
+
* durable record alone — an install whose container was replaced writes
|
|
160
|
+
* `failed`, and the arming that will repair it is a separate fact the record
|
|
161
|
+
* does not carry.
|
|
162
|
+
*
|
|
163
|
+
* A production session was briefed `deps-broken` six seconds before the
|
|
164
|
+
* armed reinstall it did not know about finished, and spent its own turns
|
|
165
|
+
* re-running `npm ci` on top of a tree that was already landing.
|
|
166
|
+
*/
|
|
167
|
+
reinstallArmedAt?: number;
|
|
155
168
|
}
|
|
156
169
|
/**
|
|
157
170
|
* Everything currently true about a workspace, from what the host can see.
|
|
@@ -168,7 +181,7 @@ export declare function deriveAdvisories(input: AdvisoryInput): readonly Workspa
|
|
|
168
181
|
/**
|
|
169
182
|
* Everything a session should know before it starts, or `undefined` for silence.
|
|
170
183
|
*
|
|
171
|
-
* The counterpart to {@link file
|
|
184
|
+
* The counterpart to {@link file://../computer/gate.ts execGate}, for the reader that is a
|
|
172
185
|
* whole run rather than one command. It cannot be blocked and does not need to
|
|
173
186
|
* be: it can install dependencies itself, and the only thing it must not do is
|
|
174
187
|
* work silently against a workspace that is not what it assumes.
|