@webappwiz/arbor 0.0.28 → 0.0.30
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/AGENTS.md +7 -0
- package/README.md +34 -12
- package/add.test.ts +125 -0
- package/add.ts +192 -0
- package/age.ts +11 -0
- package/arbor.test.ts +145 -0
- package/arbor.ts +429 -0
- package/assets.d.ts +9 -0
- package/attachments.ts +87 -0
- package/build.ts +98 -0
- package/claim.test.ts +89 -0
- package/claim.ts +73 -0
- package/components.json +21 -0
- package/config.ts +58 -0
- package/dev/api.ts +120 -0
- package/dev/app.test.tsx +645 -0
- package/dev/app.tsx +80 -0
- package/dev/assets.ts +16 -0
- package/dev/build/main.txt +68 -0
- package/dev/build/shell.txt +21 -0
- package/dev/build/styles.txt +2263 -0
- package/dev/components/ui/badge.tsx +51 -0
- package/dev/components/ui/button.tsx +57 -0
- package/dev/components/ui/dialog.tsx +154 -0
- package/dev/components/ui/empty.tsx +100 -0
- package/dev/components/ui/input-group.tsx +155 -0
- package/dev/components/ui/input.tsx +19 -0
- package/dev/components/ui/progress.tsx +80 -0
- package/dev/components/ui/textarea.tsx +17 -0
- package/dev/components/ui/toast.tsx +227 -0
- package/dev/feed.ts +69 -0
- package/dev/files.tsx +191 -0
- package/dev/index.html +21 -0
- package/dev/lib/utils.ts +7 -0
- package/dev/main.tsx +22 -0
- package/dev/markdown.test.tsx +133 -0
- package/dev/markdown.tsx +124 -0
- package/dev/mentions.tsx +319 -0
- package/dev/shadcn.css +649 -0
- package/dev/styles.css +107 -0
- package/dev/tags.tsx +44 -0
- package/dev/tasks.tsx +151 -0
- package/dev/todos.tsx +759 -0
- package/dev.test.ts +439 -0
- package/dev.ts +386 -0
- package/e2e.test.ts +256 -0
- package/escalate.test.ts +76 -0
- package/escalate.ts +112 -0
- package/exit.test.ts +71 -0
- package/exit.ts +74 -0
- package/git.ts +294 -0
- package/index.ts +9 -0
- package/journal.ts +89 -0
- package/list.test.ts +82 -0
- package/list.ts +137 -0
- package/load-config.test.ts +121 -0
- package/load-config.ts +62 -0
- package/log.test.ts +65 -0
- package/log.ts +40 -0
- package/merge.test.ts +332 -0
- package/merge.ts +283 -0
- package/package.json +32 -15
- package/path.test.ts +35 -0
- package/path.ts +38 -0
- package/plan.test.ts +318 -0
- package/plan.ts +302 -0
- package/progress.test.ts +54 -0
- package/progress.ts +31 -0
- package/remove.test.ts +68 -0
- package/remove.ts +88 -0
- package/repository.ts +83 -0
- package/retry.test.ts +43 -0
- package/retry.ts +48 -0
- package/shell.ts +44 -0
- package/show.test.ts +91 -0
- package/show.ts +151 -0
- package/snapshot.test.ts +33 -0
- package/snapshot.ts +84 -0
- package/table.ts +20 -0
- package/testing.ts +229 -0
- package/todo.test.ts +490 -0
- package/todo.ts +834 -0
- package/wait.test.ts +61 -0
- package/wait.ts +77 -0
- package/worktree-service.test.ts +158 -0
- package/worktree-service.ts +193 -0
- package/worktree.ts +239 -0
- package/add.d.ts +0 -23
- package/age.d.ts +0 -2
- package/arbor.d.ts +0 -11
- package/attachments.d.ts +0 -36
- package/claim.d.ts +0 -11
- package/config.d.ts +0 -55
- package/config.js +0 -7
- package/dev/assets.d.ts +0 -11
- package/dev.d.ts +0 -47
- package/escalate.d.ts +0 -28
- package/exit.d.ts +0 -42
- package/git.d.ts +0 -77
- package/index.d.ts +0 -2
- package/index.js +0 -4669
- package/journal.d.ts +0 -29
- package/list.d.ts +0 -18
- package/load-config.d.ts +0 -15
- package/log.d.ts +0 -14
- package/merge.d.ts +0 -24
- package/path.d.ts +0 -13
- package/plan.d.ts +0 -83
- package/remove.d.ts +0 -19
- package/repository.d.ts +0 -25
- package/retry.d.ts +0 -17
- package/shell.d.ts +0 -17
- package/show.d.ts +0 -53
- package/snapshot.d.ts +0 -31
- package/table.d.ts +0 -5
- package/todo.d.ts +0 -230
- package/wait.d.ts +0 -22
- package/worktree-service.d.ts +0 -51
- package/worktree.d.ts +0 -95
package/AGENTS.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
If you change this package's API, check that the agent skill at
|
|
2
|
+
`packages/cli/templates/arbor.skill.md` is still in sync. That is the source;
|
|
3
|
+
the copies under `.agents/skills/` and `.claude/skills/` are installed from it.
|
|
4
|
+
|
|
5
|
+
`arbor` runs whichever checkout you are standing in, so working on this package
|
|
6
|
+
from a worktree merges with the code you are editing. Pin the main tree for the
|
|
7
|
+
merge itself: `WEBAPPWIZ_ROOT="$(arbor path)" arbor merge`.
|
package/README.md
CHANGED
|
@@ -105,7 +105,8 @@ configures none.
|
|
|
105
105
|
|
|
106
106
|
A merge onto trunk ends by recommending what to do next: the open todo highest
|
|
107
107
|
on the list that came up in the task that landed, since its context is
|
|
108
|
-
freshest,
|
|
108
|
+
freshest, then one sharing a tag with the todos the task settled, the same
|
|
109
|
+
area of work, or failing that the open todo highest on the list, plus any todo older than `todoStalenessMs` (30 days) offered for
|
|
109
110
|
removal instead. The todos the task
|
|
110
111
|
took up, with `--todo` or `todo take`, leave the list, since that work is now
|
|
111
112
|
done; release one first to keep what is left of it.
|
|
@@ -148,7 +149,7 @@ down the listing; a record whose worktree vanished shows as `orphaned`.
|
|
|
148
149
|
|
|
149
150
|
`--files` adds, under each task, every path it has changed (committed or
|
|
150
151
|
not) and every path its `ARBOR.md` plans under `## Files` that it has not
|
|
151
|
-
touched yet. This is the overlap check an agent runs
|
|
152
|
+
touched yet. This is the overlap check an agent runs once it has planned: its own
|
|
152
153
|
list of files against everyone else's.
|
|
153
154
|
|
|
154
155
|
```
|
|
@@ -229,23 +230,34 @@ the only thing that remembers a task landed at all. The last 1000 are kept
|
|
|
229
230
|
The todos and the tasks in a browser, on `http://localhost:4269`, reloading
|
|
230
231
|
themselves as anything changes. Built for a phone first.
|
|
231
232
|
|
|
233
|
+
One page, tasks above the todos.
|
|
234
|
+
|
|
232
235
|
Todos are cards in list order: drag one to reorder the list (a short press
|
|
233
236
|
on a phone, or Space on its grip and the arrow keys), or tap it to reword,
|
|
234
237
|
attach files to, or remove. A card shows its subject, two lines of its
|
|
235
|
-
detail, and badges for its files and staleness, and a green edge with
|
|
238
|
+
detail rendered as markdown, and badges for its files, tags and staleness, and a green edge with
|
|
236
239
|
"Taken by <task>" once a task has taken it; its
|
|
237
240
|
`#7` copies `[ARBOR TODO #7]`, which the agent skill reads as that todo, for
|
|
238
|
-
pasting into a chat.
|
|
241
|
+
pasting into a chat. An opened todo shows its detail rendered, with Write and
|
|
242
|
+
Preview to switch between the markdown and how it reads. Typing `@` in a todo offers the files and
|
|
239
243
|
directories in the main tree, tracked or new but not ignored, and writes the
|
|
240
244
|
one picked as `@path/from/root`; picking a directory keeps the list open on
|
|
241
|
-
what is inside.
|
|
245
|
+
what is inside. An opened todo's tags are edited there too, comma separated,
|
|
246
|
+
and its `#7` copies the same reference as the card's.
|
|
247
|
+
|
|
248
|
+
Tasks lists every task with a bar for its progress through its plan, counted
|
|
249
|
+
from the checkboxes under `## Done` and `## Next` in its `ARBOR.md`, flagging
|
|
250
|
+
only an escalated or broken status; tapping one opens its details and whole
|
|
251
|
+
plan in a dialog.
|
|
242
252
|
|
|
243
|
-
|
|
244
|
-
|
|
253
|
+
A row of tags over the todos filters them, the way a mail client filters by
|
|
254
|
+
label: All, then every tag a todo has. Tapping one there, or on a card, shows
|
|
255
|
+
only its todos. Dragging a card while filtered moves it to the place in the
|
|
256
|
+
whole list of the card it is dropped on, so the todos hidden between them
|
|
257
|
+
shift around it.
|
|
245
258
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
it is offline, since what the page shows may be stale.
|
|
259
|
+
If the server stops answering, the header says it is offline, since what the
|
|
260
|
+
page shows may be stale.
|
|
249
261
|
|
|
250
262
|
Todo writes are the same functions as `arbor todo add`, `update` and
|
|
251
263
|
`remove`. Questions are answered in the agent's chat, not here. Merging,
|
|
@@ -332,14 +344,15 @@ imports, a signature changed on one side and its callers on the other) and
|
|
|
332
344
|
unreliable when both sides restructured the same logic, because then there is no
|
|
333
345
|
correct merge, only a decision.
|
|
334
346
|
|
|
335
|
-
### `arbor todo add <subject> [text] [--position <n>] [--file <path>]`, `arbor todo list [--json]`, `arbor todo show <id> [--json]`, `arbor todo update <id> [text] [--subject <subject>] [--position <n>] [--file <path>] [--remove-file <name>]`, `arbor todo take <id...>`, `arbor todo release <id...>`, `arbor todo remove <id>`
|
|
347
|
+
### `arbor todo add <subject> [text] [--position <n>] [--file <path>] [--tag <tag>]`, `arbor todo list [--json] [--open] [--tag <tag>]`, `arbor todo show <id> [--json]`, `arbor todo update <id> [text] [--subject <subject>] [--position <n>] [--file <path>] [--remove-file <name>] [--tag <tag>] [--remove-tag <tag>]`, `arbor todo tags [--json]`, `arbor todo take <id...>`, `arbor todo release <id...>`, `arbor todo remove <id>`
|
|
336
348
|
|
|
337
349
|
Work deferred for later. When something outside the task comes up (a bug next
|
|
338
350
|
door, a follow-up the reviewer asked for, a question that turns out to be its
|
|
339
351
|
own project), the agent notes it with `arbor todo add` and carries on instead
|
|
340
352
|
of growing the task. Run from a worktree, `add` records the task it came up
|
|
341
353
|
in. A todo is a subject, one line that `list` and `merge` show, and an optional
|
|
342
|
-
detail below it
|
|
354
|
+
detail below it in markdown, which `show` prints in full and the `dev` page
|
|
355
|
+
renders.
|
|
343
356
|
|
|
344
357
|
The list is in order: position 1 is the top, and after a task's own todos,
|
|
345
358
|
the open todo highest on it is the one `merge` recommends next. A new one goes to the bottom, or with
|
|
@@ -358,6 +371,8 @@ run from a worktree, adds them to the task already under way, all or none.
|
|
|
358
371
|
A task can hold any number. `release` puts them back on the list, from a
|
|
359
372
|
worktree only its own task's: a task landing with one half done rewords it to
|
|
360
373
|
what is left with `update`, then releases it, so the merge leaves it open.
|
|
374
|
+
`list --open` shows only those no task holds, which is what an agent
|
|
375
|
+
looking for work to pick up wants.
|
|
361
376
|
`update` rewords one, its detail as the argument and its subject with
|
|
362
377
|
`--subject`, and keeps its number; `remove` drops one done some other way or
|
|
363
378
|
no longer wanted.
|
|
@@ -367,6 +382,13 @@ no longer wanted.
|
|
|
367
382
|
one, named by path or by its stored file name (`todo show` lists them). They
|
|
368
383
|
go when the todo does.
|
|
369
384
|
|
|
385
|
+
`--tag uploads,merge` tags a todo with the areas or goals it belongs to; a
|
|
386
|
+
todo can have several tags and a tag many todos. A tag is a lowercase word,
|
|
387
|
+
or a few joined by hyphens, and anything else is refused, so one area never
|
|
388
|
+
splits into two spellings. `update --tag` adds tags, `--remove-tag` drops them,
|
|
389
|
+
`list --tag` shows only one tag's todos, and `todo tags` lists every tag in
|
|
390
|
+
use with how many todos have it.
|
|
391
|
+
|
|
370
392
|
### `arbor retry <task>`
|
|
371
393
|
|
|
372
394
|
Grants an escalated task another `mergeRetryCount` merge attempts and puts it
|
package/add.test.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { add } from "./add";
|
|
4
|
+
import { Testing } from "./testing";
|
|
5
|
+
|
|
6
|
+
describe("add", () => {
|
|
7
|
+
let deps: Testing;
|
|
8
|
+
|
|
9
|
+
beforeEach(async () => {
|
|
10
|
+
deps = await Testing.open();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(() => deps.disposeAsync());
|
|
14
|
+
|
|
15
|
+
it("makes a worktree, a branch and a record", async () => {
|
|
16
|
+
await add(deps, "alpha");
|
|
17
|
+
|
|
18
|
+
const state = (await deps.service.find("alpha")).state;
|
|
19
|
+
expect(state).toMatchObject({
|
|
20
|
+
task: "alpha",
|
|
21
|
+
branch: "task/alpha",
|
|
22
|
+
status: "working",
|
|
23
|
+
mergeAttempts: 0,
|
|
24
|
+
});
|
|
25
|
+
expect(await deps.fs.exists(join(state?.worktree ?? "", "README.md"))).toBe(
|
|
26
|
+
true,
|
|
27
|
+
);
|
|
28
|
+
expect(
|
|
29
|
+
await deps.gitCli(
|
|
30
|
+
state?.worktree ?? "",
|
|
31
|
+
"rev-parse",
|
|
32
|
+
"--abbrev-ref",
|
|
33
|
+
"HEAD",
|
|
34
|
+
),
|
|
35
|
+
).toBe("task/alpha");
|
|
36
|
+
expect(deps.out()).toContain("added alpha");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("leaves a stub ARBOR.md for the agent to fill in", async () => {
|
|
40
|
+
await add(deps, "alpha");
|
|
41
|
+
|
|
42
|
+
const state = (await deps.service.find("alpha")).state;
|
|
43
|
+
const plan = await deps.fs.read(join(state?.worktree ?? "", "ARBOR.md"));
|
|
44
|
+
expect(plan).toStartWith("# alpha\n");
|
|
45
|
+
expect(plan).toContain("## Goal");
|
|
46
|
+
expect(plan).toContain("## Files");
|
|
47
|
+
expect(plan).toContain("- [ ]");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("excludes the plan from git, once, so no task can commit it", async () => {
|
|
51
|
+
await add(deps, "alpha");
|
|
52
|
+
await add(deps, "beta");
|
|
53
|
+
|
|
54
|
+
const exclude = await deps.fs.read(
|
|
55
|
+
join(deps.root, ".git", "info", "exclude"),
|
|
56
|
+
);
|
|
57
|
+
expect(exclude.split("\n").filter((line) => line === "ARBOR.md")).toEqual([
|
|
58
|
+
"ARBOR.md",
|
|
59
|
+
]);
|
|
60
|
+
const state = (await deps.service.find("alpha")).state;
|
|
61
|
+
expect(
|
|
62
|
+
await deps.gitCli(state?.worktree ?? "", "status", "--porcelain"),
|
|
63
|
+
).toBe("");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("refuses a name that is already taken and points at claim", async () => {
|
|
67
|
+
await add(deps, "alpha");
|
|
68
|
+
|
|
69
|
+
await expect(add(deps, "alpha")).toBail("exists", {
|
|
70
|
+
message: "arbor claim alpha",
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("rejects names that are not legal branch or directory names", async () => {
|
|
75
|
+
const names = ["Alpha", "a b", "feature/x", "-alpha", ""];
|
|
76
|
+
|
|
77
|
+
await Promise.all(
|
|
78
|
+
names.map((name) => expect(add(deps, name)).toBail("usage")),
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("refuses a repo with submodules, before making anything", async () => {
|
|
83
|
+
await deps.fs.write(join(deps.root, ".gitmodules"), "");
|
|
84
|
+
|
|
85
|
+
await expect(add(deps, "alpha")).toBail("usage", {
|
|
86
|
+
message: ".gitmodules",
|
|
87
|
+
});
|
|
88
|
+
expect((await deps.service.find("alpha")).gone).toBe(true);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("reports a failed postCheckout hook but keeps the worktree", async () => {
|
|
92
|
+
deps.config.postCheckout = "exit 3";
|
|
93
|
+
|
|
94
|
+
await expect(add(deps, "alpha")).toBail("hook_failed");
|
|
95
|
+
const state = (await deps.service.find("alpha")).state;
|
|
96
|
+
expect(await deps.fs.exists(state?.worktree ?? "")).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("tells the postCheckout hook which branch the trunk is", async () => {
|
|
100
|
+
deps.config.trunk = "master";
|
|
101
|
+
deps.config.postCheckout = "printenv ARBOR_TRUNK > trunk.txt";
|
|
102
|
+
|
|
103
|
+
await add(deps, "alpha", { base: "main" });
|
|
104
|
+
|
|
105
|
+
const state = (await deps.service.find("alpha")).state;
|
|
106
|
+
expect(await deps.fs.read(join(state?.worktree ?? "", "trunk.txt"))).toBe(
|
|
107
|
+
"master\n",
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("names the trunk and the config when the trunk is not a branch", async () => {
|
|
112
|
+
deps.config.trunk = "master";
|
|
113
|
+
|
|
114
|
+
await expect(add(deps, "alpha")).toBail("usage", {
|
|
115
|
+
message: ["trunk 'master'", "arbor.config.ts"],
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("refuses with a reason, a message and the data behind it", async () => {
|
|
120
|
+
await expect(add(deps, "Alpha")).toBail("usage", {
|
|
121
|
+
message: "invalid task name 'Alpha'",
|
|
122
|
+
data: { task: "Alpha" },
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
});
|
package/add.ts
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { color, type Logger } from "webappwiz/log";
|
|
2
|
+
import { MarkdownWriter } from "webappwiz/md";
|
|
3
|
+
import type { Fs } from "webappwiz/system";
|
|
4
|
+
import type { Config } from "./config";
|
|
5
|
+
import { fail } from "./exit";
|
|
6
|
+
import { PLAN_FILE } from "./plan";
|
|
7
|
+
import type { Shell } from "./shell";
|
|
8
|
+
import type { Todo, Todos } from "./todo";
|
|
9
|
+
import type { WorktreeService } from "./worktree-service";
|
|
10
|
+
|
|
11
|
+
const NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
12
|
+
|
|
13
|
+
export interface AddOptions {
|
|
14
|
+
/** Branch the task starts from and merges onto. Defaults to the trunk. */
|
|
15
|
+
base?: string;
|
|
16
|
+
/**
|
|
17
|
+
* The todos this task takes up: their words seed the plan's Goal, and
|
|
18
|
+
* nobody else can take them while the task lives.
|
|
19
|
+
*/
|
|
20
|
+
todos?: number[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function add(
|
|
24
|
+
{
|
|
25
|
+
service,
|
|
26
|
+
shell,
|
|
27
|
+
config,
|
|
28
|
+
log,
|
|
29
|
+
fs,
|
|
30
|
+
todos,
|
|
31
|
+
}: {
|
|
32
|
+
service: WorktreeService;
|
|
33
|
+
shell: Shell;
|
|
34
|
+
config: Config;
|
|
35
|
+
log: Logger;
|
|
36
|
+
fs: Fs;
|
|
37
|
+
todos: Todos;
|
|
38
|
+
},
|
|
39
|
+
task: string,
|
|
40
|
+
{ base = config.trunk, todos: ids = [] }: AddOptions = {},
|
|
41
|
+
): Promise<void> {
|
|
42
|
+
if (!NAME.test(task)) {
|
|
43
|
+
fail(
|
|
44
|
+
"usage",
|
|
45
|
+
`invalid task name '${task}': use lowercase letters, digits and dashes`,
|
|
46
|
+
{ task },
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// A worktree gets its own empty submodule directories, so every task would
|
|
51
|
+
// have to bootstrap them before anything builds. Refuse the repo instead.
|
|
52
|
+
// Git only reads the superproject's top-level file, so there is nowhere else
|
|
53
|
+
// to look; a stale one with no live gitlink refuses too, which is the safe
|
|
54
|
+
// direction to be wrong in.
|
|
55
|
+
if (await fs.exists(`${service.git.root}/.gitmodules`)) {
|
|
56
|
+
fail(
|
|
57
|
+
"usage",
|
|
58
|
+
"this repo has git submodules (.gitmodules), which arbor does not support: worktrees do not share them",
|
|
59
|
+
{ task },
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const found = await service.find(task);
|
|
64
|
+
if (found.status === "stray") {
|
|
65
|
+
fail(
|
|
66
|
+
"exists",
|
|
67
|
+
`branch ${found.branch} exists without a worktree: run \`arbor remove ${task}\` first`,
|
|
68
|
+
{ task, branch: found.branch },
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
if (!found.gone) {
|
|
72
|
+
fail(
|
|
73
|
+
"exists",
|
|
74
|
+
`task '${task}' already exists: run \`arbor claim ${task}\``,
|
|
75
|
+
{ task, worktree: found.path },
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Looked up before anything is created, so a todo that is gone or taken
|
|
80
|
+
// refuses the whole add instead of leaving a tree behind.
|
|
81
|
+
const taken: Todo[] = [];
|
|
82
|
+
for (const id of new Set(ids)) {
|
|
83
|
+
const todo = await todos.find(id);
|
|
84
|
+
if (todo.takenBy) {
|
|
85
|
+
await todo.take(task); // refuses, naming the task that has it
|
|
86
|
+
}
|
|
87
|
+
taken.push(todo);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const added = await service.add(task, { base });
|
|
91
|
+
if (added.code !== 0) {
|
|
92
|
+
// git's own "invalid reference" sends people to the branch, when what is
|
|
93
|
+
// wrong is which branch arbor thinks the trunk is.
|
|
94
|
+
const missingTrunk =
|
|
95
|
+
base === config.trunk && !(await service.git.branchExists(base));
|
|
96
|
+
fail(
|
|
97
|
+
"usage",
|
|
98
|
+
missingTrunk
|
|
99
|
+
? `trunk '${base}' is not a branch in this repo: set \`trunk\` in arbor.config.ts`
|
|
100
|
+
: `git worktree add failed: ${added.stderr}`,
|
|
101
|
+
{ task },
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const worktree = await (await service.find(task)).take({ base });
|
|
106
|
+
|
|
107
|
+
// The plan is scratch for one task and must never land on trunk. Excluding
|
|
108
|
+
// it through the shared `.git` covers every worktree at once and leaves the
|
|
109
|
+
// repo's own .gitignore, which arbor would have to commit to, alone.
|
|
110
|
+
const info = `${await service.git.commonDir()}/info`;
|
|
111
|
+
const excluded = await fs.read(`${info}/exclude`).catch(() => "");
|
|
112
|
+
if (!excluded.split("\n").includes(PLAN_FILE)) {
|
|
113
|
+
await fs.mkdir(info);
|
|
114
|
+
await fs.write(
|
|
115
|
+
`${info}/exclude`,
|
|
116
|
+
`${excluded.trimEnd()}\n${PLAN_FILE}\n`.trimStart(),
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
await fs.write(
|
|
121
|
+
`${worktree.path}/${PLAN_FILE}`,
|
|
122
|
+
PLAN(task, taken.length === 0 ? null : goal(taken)),
|
|
123
|
+
);
|
|
124
|
+
for (const todo of taken) {
|
|
125
|
+
await todo.take(task);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// A fresh worktree shares no untracked files with the repo: no node_modules,
|
|
129
|
+
// no .env. That is what the hook is for.
|
|
130
|
+
if (config.postCheckout) {
|
|
131
|
+
const { exitCode } = await shell.stream(
|
|
132
|
+
config.postCheckout,
|
|
133
|
+
worktree.path,
|
|
134
|
+
{
|
|
135
|
+
env: {
|
|
136
|
+
ARBOR_TASK: task,
|
|
137
|
+
ARBOR_WORKTREE: worktree.path,
|
|
138
|
+
ARBOR_TRUNK: config.trunk,
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
);
|
|
142
|
+
if (exitCode !== 0) {
|
|
143
|
+
// The worktree stays. Rolling back would throw away a tree the agent
|
|
144
|
+
// can fix by hand and re-run the hook in.
|
|
145
|
+
fail(
|
|
146
|
+
"hook_failed",
|
|
147
|
+
`postCheckout hook failed (exit ${exitCode}); worktree left in place at ${worktree.path}`,
|
|
148
|
+
{ task, worktree: worktree.path },
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
log.info(
|
|
154
|
+
`${color.green("added")} ${task}\n worktree: ${worktree.path}\n branch: ${worktree.branch}\n base: ${base}`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Todos as a Goal: each one's subject and detail, and each file attached to it
|
|
160
|
+
* by path, which stay readable until the task lands and takes the todo with
|
|
161
|
+
* it. One todo is the Goal as it stands; several are each named by id.
|
|
162
|
+
*/
|
|
163
|
+
function goal(todos: Todo[]): string {
|
|
164
|
+
return todos
|
|
165
|
+
.map((todo) => {
|
|
166
|
+
const subject =
|
|
167
|
+
todos.length === 1 ? todo.subject : `Todo ${todo.id}: ${todo.subject}`;
|
|
168
|
+
return [
|
|
169
|
+
subject,
|
|
170
|
+
todo.text,
|
|
171
|
+
todo.files.map((path) => `Attached: \`${path}\``).join("\n"),
|
|
172
|
+
]
|
|
173
|
+
.filter(Boolean)
|
|
174
|
+
.join("\n\n");
|
|
175
|
+
})
|
|
176
|
+
.join("\n\n");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The plan a fresh task starts with. `## Goal` is the todos it takes up, if
|
|
180
|
+
* any; it and `## Files` are otherwise left empty on purpose: `arbor show` nags
|
|
181
|
+
* until the agent fills them in. */
|
|
182
|
+
function PLAN(task: string, goal: string | null): string {
|
|
183
|
+
const writer = new MarkdownWriter().heading(1, task).heading(2, "Goal");
|
|
184
|
+
if (goal !== null) {
|
|
185
|
+
writer.text(goal);
|
|
186
|
+
}
|
|
187
|
+
return writer
|
|
188
|
+
.heading(2, "Files")
|
|
189
|
+
.heading(2, "Next")
|
|
190
|
+
.checklist("fill in Goal and list the steps here as `- [ ]` items")
|
|
191
|
+
.toString();
|
|
192
|
+
}
|
package/age.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** How long ago an ISO timestamp was, at the coarsest unit that still reads. */
|
|
2
|
+
export function age(since: string): string {
|
|
3
|
+
const minutes = Math.floor((Date.now() - Date.parse(since)) / 60_000);
|
|
4
|
+
if (minutes < 60) {
|
|
5
|
+
return `${minutes}m`;
|
|
6
|
+
}
|
|
7
|
+
if (minutes < 60 * 24) {
|
|
8
|
+
return `${Math.floor(minutes / 60)}h`;
|
|
9
|
+
}
|
|
10
|
+
return `${Math.floor(minutes / (60 * 24))}d`;
|
|
11
|
+
}
|
package/arbor.test.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
|
+
import { arbor } from "./arbor";
|
|
4
|
+
import { Testing } from "./testing";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The cli in this process, run with fakes: the same commands `e2e.test.ts`
|
|
8
|
+
* spawns, minus the subprocess. What it proves is the wiring, that every
|
|
9
|
+
* dependency an action uses arrives through `run`.
|
|
10
|
+
*/
|
|
11
|
+
describe("arbor cli", () => {
|
|
12
|
+
it("runs its commands against the dependencies it is given", async () => {
|
|
13
|
+
await using env = await Testing.open();
|
|
14
|
+
env.ps.cd(env.root);
|
|
15
|
+
const deps = {
|
|
16
|
+
log: env.log,
|
|
17
|
+
fs: env.fs,
|
|
18
|
+
ps: env.ps,
|
|
19
|
+
assets: env.assets,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
await arbor.run(deps, ["add", "alpha"]);
|
|
23
|
+
await arbor.run(deps, ["list", "--json"]);
|
|
24
|
+
|
|
25
|
+
const rows = JSON.parse(String(env.log.entries.at(-1)?.message));
|
|
26
|
+
expect(rows).toMatchObject([{ task: "alpha", status: "working" }]);
|
|
27
|
+
expect(await env.fs.exists(join(`${env.root}-arbor`, "alpha"))).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("escalates a task and edits its todos", async () => {
|
|
31
|
+
await using env = await Testing.open();
|
|
32
|
+
env.ps.cd(env.root);
|
|
33
|
+
const deps = {
|
|
34
|
+
log: env.log,
|
|
35
|
+
fs: env.fs,
|
|
36
|
+
ps: env.ps,
|
|
37
|
+
assets: env.assets,
|
|
38
|
+
};
|
|
39
|
+
await arbor.run(deps, ["add", "alpha"]);
|
|
40
|
+
const worktree = await env.service.find("alpha");
|
|
41
|
+
await env.fs.write(
|
|
42
|
+
join(worktree.path, "ARBOR.md"),
|
|
43
|
+
"# alpha\n\n## Blocked\n\n- [ ] 1. Open it.\n",
|
|
44
|
+
);
|
|
45
|
+
await env.fs.write(join(env.root, "a.png"), "a");
|
|
46
|
+
env.ps.cd(worktree.path);
|
|
47
|
+
await arbor.run(deps, ["escalate", "needs a person"]);
|
|
48
|
+
|
|
49
|
+
env.ps.cd(env.root);
|
|
50
|
+
await arbor.run(deps, [
|
|
51
|
+
"todo",
|
|
52
|
+
"add",
|
|
53
|
+
"write docs",
|
|
54
|
+
"the CLI first",
|
|
55
|
+
"--file",
|
|
56
|
+
"a.png",
|
|
57
|
+
]);
|
|
58
|
+
const [attached = ""] = (await env.todos.find(1)).files;
|
|
59
|
+
await arbor.run(deps, [
|
|
60
|
+
"todo",
|
|
61
|
+
"update",
|
|
62
|
+
"1",
|
|
63
|
+
"--subject",
|
|
64
|
+
"write the docs",
|
|
65
|
+
"--position",
|
|
66
|
+
"1",
|
|
67
|
+
"--remove-file",
|
|
68
|
+
basename(attached),
|
|
69
|
+
]);
|
|
70
|
+
expect((await env.todos.find(1)).state).toMatchObject({
|
|
71
|
+
subject: "write the docs",
|
|
72
|
+
text: "the CLI first",
|
|
73
|
+
position: 1,
|
|
74
|
+
files: [],
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
await arbor.run(deps, ["log", "--json"]);
|
|
78
|
+
const entries = JSON.parse(String(env.log.entries.at(-1)?.message));
|
|
79
|
+
expect(entries.map((entry: { action: string }) => entry.action)).toContain(
|
|
80
|
+
"escalate",
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("asks for a review from the command line", async () => {
|
|
85
|
+
await using env = await Testing.open();
|
|
86
|
+
env.ps.cd(env.root);
|
|
87
|
+
const deps = {
|
|
88
|
+
log: env.log,
|
|
89
|
+
fs: env.fs,
|
|
90
|
+
ps: env.ps,
|
|
91
|
+
assets: env.assets,
|
|
92
|
+
};
|
|
93
|
+
await arbor.run(deps, ["add", "alpha"]);
|
|
94
|
+
|
|
95
|
+
await arbor.run(deps, [
|
|
96
|
+
"escalate",
|
|
97
|
+
"check the header",
|
|
98
|
+
"--task",
|
|
99
|
+
"alpha",
|
|
100
|
+
"--review",
|
|
101
|
+
]);
|
|
102
|
+
|
|
103
|
+
const worktree = await env.service.find("alpha");
|
|
104
|
+
expect(worktree.state?.escalations?.at(-1)?.review).toBe("1");
|
|
105
|
+
expect(await env.fs.read(join(worktree.path, "ARBOR.md"))).toContain(
|
|
106
|
+
"- [ ] 1. Ready to merge?\n check the header\n",
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("reports a refusal as a reason, a message and an exit code", async () => {
|
|
111
|
+
await using env = await Testing.open();
|
|
112
|
+
env.ps.cd(env.root);
|
|
113
|
+
|
|
114
|
+
await arbor.run(
|
|
115
|
+
{
|
|
116
|
+
log: env.log,
|
|
117
|
+
fs: env.fs,
|
|
118
|
+
ps: env.ps,
|
|
119
|
+
assets: env.assets,
|
|
120
|
+
},
|
|
121
|
+
["claim", "nope"],
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
expect(env.out()).toContain('"reason":"not_found"');
|
|
125
|
+
expect(env.proc.lastExit()).toBe(8);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("refuses a port that cannot exist rather than trying to listen", async () => {
|
|
129
|
+
await using env = await Testing.open();
|
|
130
|
+
env.ps.cd(env.root);
|
|
131
|
+
|
|
132
|
+
await arbor.run(
|
|
133
|
+
{
|
|
134
|
+
log: env.log,
|
|
135
|
+
fs: env.fs,
|
|
136
|
+
ps: env.ps,
|
|
137
|
+
assets: env.assets,
|
|
138
|
+
},
|
|
139
|
+
["dev", "--port", "99999"],
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
expect(env.out()).not.toContain(" reply ");
|
|
143
|
+
expect(env.proc.lastExit()).toBe(1);
|
|
144
|
+
});
|
|
145
|
+
});
|