@webappwiz/arbor 0.0.23 → 0.0.25
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 +19 -12
- package/add.d.ts +4 -4
- package/index.js +84 -15
- package/package.json +2 -2
- package/todo.d.ts +18 -0
package/README.md
CHANGED
|
@@ -27,14 +27,14 @@ rebase, and that is what `remove` is for.
|
|
|
27
27
|
|
|
28
28
|
## Commands
|
|
29
29
|
|
|
30
|
-
### `arbor add <task> [--base <branch>] [--todo <id
|
|
30
|
+
### `arbor add <task> [--base <branch>] [--todo <id,...>]`
|
|
31
31
|
|
|
32
32
|
Creates the task: branch `task/<task>`, a worktree at
|
|
33
33
|
`../<repo>-arbor/<task>`, and a state record.
|
|
34
34
|
|
|
35
|
-
`--todo
|
|
36
|
-
`## Goal`, followed by the path of
|
|
37
|
-
can take
|
|
35
|
+
`--todo 3,5` takes up todos (see `arbor todo`): their text becomes the plan's
|
|
36
|
+
`## Goal`, each followed by the path of every file attached to it, and no
|
|
37
|
+
other task can take them while this one lives.
|
|
38
38
|
|
|
39
39
|
`--base <branch>` starts the task from that branch and lands it back there
|
|
40
40
|
instead of trunk. It takes another task's branch too: `--base task/<other>`
|
|
@@ -107,11 +107,13 @@ configures none.
|
|
|
107
107
|
A merge onto trunk ends by recommending what to do next: one todo, the task's
|
|
108
108
|
own follow-ups first and then the longest waiting, plus any todo older than
|
|
109
109
|
`todoStalenessMs` (30 days) offered for removal instead. The todos the task
|
|
110
|
-
took up with `--todo` leave the list, since that work is now
|
|
110
|
+
took up, with `--todo` or `todo take`, leave the list, since that work is now
|
|
111
|
+
done; release one first to keep what is left of it.
|
|
111
112
|
|
|
112
113
|
```
|
|
113
114
|
merged alpha onto main (1a2b3c4)
|
|
114
115
|
worktree removed, cd /src/repo
|
|
116
|
+
done todo 5: show upload progress
|
|
115
117
|
|
|
116
118
|
next todo 7: retry the upload when the token expires
|
|
117
119
|
from alpha, waiting 2h
|
|
@@ -186,7 +188,7 @@ and anything off is printed under it.
|
|
|
186
188
|
Warnings only, never a refusal: the agent that wrote the file is the one that
|
|
187
189
|
runs `show` on it, and a rough plan still beats none.
|
|
188
190
|
|
|
189
|
-
### `arbor wait <task> [--timeout-secs
|
|
191
|
+
### `arbor wait <task> [--timeout-secs 900] [--answered]`
|
|
190
192
|
|
|
191
193
|
Blocks until a task stops moving, then prints where it stopped.
|
|
192
194
|
|
|
@@ -196,7 +198,7 @@ discarded, and `arbor log` says which), or one of the broken statuses.
|
|
|
196
198
|
|
|
197
199
|
This is for the agent whose own work overlaps a task already in flight and
|
|
198
200
|
would rather rebase onto its result than against it. The timeout is short by
|
|
199
|
-
design:
|
|
201
|
+
design: fifteen minutes, and then a `timeout` refusal that hands the decision
|
|
200
202
|
back, rather than a session that blocks all afternoon on a tree nobody is
|
|
201
203
|
driving. Wait again, work alongside it, or ask the human.
|
|
202
204
|
|
|
@@ -303,7 +305,8 @@ session: it is waiting in its chat, not reading its plan, so answer it there.
|
|
|
303
305
|
### `arbor log [--count 20] [--json]`
|
|
304
306
|
|
|
305
307
|
The last N things done here (`add`, `claim`, `merge`, `remove`, `escalate`,
|
|
306
|
-
`retry`, `replies`, `todo add`, `todo update`, `todo
|
|
308
|
+
`retry`, `replies`, `todo add`, `todo update`, `todo take`, `todo release`,
|
|
309
|
+
`todo remove`, and from the
|
|
307
310
|
page `reply`, `withdraw`, `defer`, `skip` and `approve`), oldest first, each with the task and how it ended (`ok`, or
|
|
308
311
|
the refusal reason).
|
|
309
312
|
|
|
@@ -424,7 +427,7 @@ imports, a signature changed on one side and its callers on the other) and
|
|
|
424
427
|
unreliable when both sides restructured the same logic, because then there is no
|
|
425
428
|
correct merge, only a decision.
|
|
426
429
|
|
|
427
|
-
### `arbor todo add <text> [--file <path>]`, `arbor todo list [--json]`, `arbor todo update <id> [text] [--file <path>] [--remove-file <name>]`, `arbor todo remove <id>`
|
|
430
|
+
### `arbor todo add <text> [--file <path>]`, `arbor todo list [--json]`, `arbor todo update <id> [text] [--file <path>] [--remove-file <name>]`, `arbor todo take <id...>`, `arbor todo release <id...>`, `arbor todo remove <id>`
|
|
428
431
|
|
|
429
432
|
Work deferred for later. When something outside the task comes up (a bug next
|
|
430
433
|
door, a follow-up the reviewer asked for, a question that turns out to be its
|
|
@@ -437,9 +440,13 @@ one at once, with nothing to commit and no two agents rewriting the same file.
|
|
|
437
440
|
Numbers are never reused. They are local to the clone: not in git history,
|
|
438
441
|
not on a fresh checkout.
|
|
439
442
|
|
|
440
|
-
`arbor add <task> --todo <id
|
|
441
|
-
|
|
442
|
-
|
|
443
|
+
`arbor add <task> --todo <id,...>` picks todos up with a new task; `take`,
|
|
444
|
+
run from a worktree, adds them to the task already under way, all or none.
|
|
445
|
+
A task can hold any number. `release` puts them back on the list, from a
|
|
446
|
+
worktree only its own task's: a task landing with one half done rewords it to
|
|
447
|
+
what is left with `update`, then releases it, so the merge leaves it open.
|
|
448
|
+
`update` rewords one and keeps its number; `remove` drops one done some other
|
|
449
|
+
way or no longer wanted.
|
|
443
450
|
|
|
444
451
|
`--file a.png,notes.md` attaches files of any kind, stored beside the todo in
|
|
445
452
|
`.git/arbor/todos/<id>/` the way a reply's are. `update --remove-file` drops
|
package/add.d.ts
CHANGED
|
@@ -8,10 +8,10 @@ export interface AddOptions {
|
|
|
8
8
|
/** Branch the task starts from and merges onto. Defaults to the trunk. */
|
|
9
9
|
base?: string;
|
|
10
10
|
/**
|
|
11
|
-
* The
|
|
12
|
-
* else can take
|
|
11
|
+
* The todos this task takes up: their text seeds the plan's Goal, and
|
|
12
|
+
* nobody else can take them while the task lives.
|
|
13
13
|
*/
|
|
14
|
-
|
|
14
|
+
todos?: number[];
|
|
15
15
|
}
|
|
16
16
|
export declare function add({ service, shell, config, log, fs, todos, }: {
|
|
17
17
|
service: WorktreeService;
|
|
@@ -20,4 +20,4 @@ export declare function add({ service, shell, config, log, fs, todos, }: {
|
|
|
20
20
|
log: Logger;
|
|
21
21
|
fs: Fs;
|
|
22
22
|
todos: Todos;
|
|
23
|
-
}, task: string, { base,
|
|
23
|
+
}, task: string, { base, todos: ids }?: AddOptions): Promise<void>;
|
package/index.js
CHANGED
|
@@ -323,7 +323,7 @@ async function add({
|
|
|
323
323
|
log,
|
|
324
324
|
fs,
|
|
325
325
|
todos
|
|
326
|
-
}, task, { base = config.trunk,
|
|
326
|
+
}, task, { base = config.trunk, todos: ids = [] } = {}) {
|
|
327
327
|
if (!NAME.test(task)) {
|
|
328
328
|
fail("usage", `invalid task name '${task}': use lowercase letters, digits and dashes`, { task });
|
|
329
329
|
}
|
|
@@ -337,9 +337,13 @@ async function add({
|
|
|
337
337
|
if (!found.gone) {
|
|
338
338
|
fail("exists", `task '${task}' already exists: run \`arbor claim ${task}\``, { task, worktree: found.path });
|
|
339
339
|
}
|
|
340
|
-
const
|
|
341
|
-
|
|
342
|
-
await
|
|
340
|
+
const taken = [];
|
|
341
|
+
for (const id of new Set(ids)) {
|
|
342
|
+
const todo = await todos.find(id);
|
|
343
|
+
if (todo.takenBy) {
|
|
344
|
+
await todo.take(task);
|
|
345
|
+
}
|
|
346
|
+
taken.push(todo);
|
|
343
347
|
}
|
|
344
348
|
const added = await service.add(task, { base });
|
|
345
349
|
if (added.code !== 0) {
|
|
@@ -356,8 +360,10 @@ async function add({
|
|
|
356
360
|
${PLAN_FILE}
|
|
357
361
|
`.trimStart());
|
|
358
362
|
}
|
|
359
|
-
await fs.write(`${worktree.path}/${PLAN_FILE}`, PLAN(task,
|
|
360
|
-
|
|
363
|
+
await fs.write(`${worktree.path}/${PLAN_FILE}`, PLAN(task, taken.length === 0 ? null : goal(taken)));
|
|
364
|
+
for (const todo of taken) {
|
|
365
|
+
await todo.take(task);
|
|
366
|
+
}
|
|
361
367
|
if (config.postCheckout) {
|
|
362
368
|
const { exitCode } = await shell.stream(config.postCheckout, worktree.path, {
|
|
363
369
|
env: {
|
|
@@ -375,8 +381,13 @@ ${PLAN_FILE}
|
|
|
375
381
|
branch: ${worktree.branch}
|
|
376
382
|
base: ${base}`);
|
|
377
383
|
}
|
|
378
|
-
function goal(
|
|
379
|
-
return
|
|
384
|
+
function goal(todos) {
|
|
385
|
+
return todos.map((todo) => {
|
|
386
|
+
const text = todos.length === 1 ? todo.text : `Todo ${todo.id}: ${todo.text}`;
|
|
387
|
+
return todo.files.length === 0 ? text : [text, "", ...todo.files.map((path) => `Attached: \`${path}\``)].join(`
|
|
388
|
+
`);
|
|
389
|
+
}).join(`
|
|
390
|
+
|
|
380
391
|
`);
|
|
381
392
|
}
|
|
382
393
|
function PLAN(task, goal2) {
|
|
@@ -1661,6 +1672,41 @@ async function todoUpdate(deps, id, { text, files: files2 = [], removeFiles = []
|
|
|
1661
1672
|
`));
|
|
1662
1673
|
return updated;
|
|
1663
1674
|
}
|
|
1675
|
+
async function todoTake({ todos, log: log2 }, ids, task) {
|
|
1676
|
+
if (task === null) {
|
|
1677
|
+
fail("usage", "take todos from the worktree of the task they belong to, or start one with `arbor add <task> --todo <id>`", { todos: ids });
|
|
1678
|
+
}
|
|
1679
|
+
const found = await findAll(todos, ids);
|
|
1680
|
+
for (const todo of found) {
|
|
1681
|
+
if (todo.takenBy !== null && todo.takenBy !== task) {
|
|
1682
|
+
await todo.take(task);
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
const taken = await Promise.all(found.map((todo) => todo.take(task)));
|
|
1686
|
+
log2.info([
|
|
1687
|
+
...taken.map((todo) => `${color10.green("took")} todo ${todo.id}: ${todo.text}`),
|
|
1688
|
+
` add ${taken.length === 1 ? "it" : "them"} to the Goal in ARBOR.md; merging removes ${taken.length === 1 ? "it" : "them"}`
|
|
1689
|
+
].join(`
|
|
1690
|
+
`));
|
|
1691
|
+
return taken;
|
|
1692
|
+
}
|
|
1693
|
+
async function todoRelease({ todos, log: log2 }, ids, task) {
|
|
1694
|
+
const found = await findAll(todos, ids);
|
|
1695
|
+
const other = found.find((todo) => task !== null && todo.takenBy !== null && todo.takenBy !== task);
|
|
1696
|
+
if (other) {
|
|
1697
|
+
fail("exists", `todo ${other.id} is taken by '${other.takenBy}', not '${task}': nothing was released`, { todo: other.id, takenBy: other.takenBy });
|
|
1698
|
+
}
|
|
1699
|
+
const released = await Promise.all(found.map((todo) => todo.release()));
|
|
1700
|
+
log2.info(released.map((todo) => `${color10.green("released")} todo ${todo.id}: ${todo.text}`).join(`
|
|
1701
|
+
`));
|
|
1702
|
+
return released;
|
|
1703
|
+
}
|
|
1704
|
+
async function findAll(todos, ids) {
|
|
1705
|
+
if (ids.length === 0) {
|
|
1706
|
+
fail("usage", "name at least one todo id: run `arbor todo list`", {});
|
|
1707
|
+
}
|
|
1708
|
+
return Promise.all([...new Set(ids)].map((id) => todos.find(id)));
|
|
1709
|
+
}
|
|
1664
1710
|
async function todoRemove({ todos, log: log2 }, id) {
|
|
1665
1711
|
const todo = await todos.find(id);
|
|
1666
1712
|
await todo.remove();
|
|
@@ -1794,13 +1840,15 @@ ${ran.stderr}`)
|
|
|
1794
1840
|
`), { task, exitCode: ran.exitCode });
|
|
1795
1841
|
}
|
|
1796
1842
|
}
|
|
1797
|
-
|
|
1843
|
+
const done = await todos.takenBy(task);
|
|
1844
|
+
for (const todo of done) {
|
|
1798
1845
|
await todo.remove();
|
|
1799
1846
|
}
|
|
1800
1847
|
const lines = [
|
|
1801
1848
|
`${color11.green("merged")} ${task} onto ${base} (${head})${landing === git.root ? "" : `
|
|
1802
1849
|
landed in: ${landing}`}
|
|
1803
|
-
worktree removed, cd ${git.root}
|
|
1850
|
+
worktree removed, cd ${git.root}`,
|
|
1851
|
+
...done.map((todo) => ` done todo ${todo.id}: ${todo.text}`)
|
|
1804
1852
|
];
|
|
1805
1853
|
if (landing === git.root) {
|
|
1806
1854
|
lines.push(...recommendation(await recommend(todos, task, config.todoStalenessMs)));
|
|
@@ -2494,7 +2542,7 @@ async function retry({
|
|
|
2494
2542
|
// wait.ts
|
|
2495
2543
|
import { color as color14 } from "webappwiz/log";
|
|
2496
2544
|
import { Duration, sleep } from "webappwiz/time";
|
|
2497
|
-
var DEFAULT_TIMEOUT = Duration.mins(
|
|
2545
|
+
var DEFAULT_TIMEOUT = Duration.mins(15);
|
|
2498
2546
|
var POLL = Duration.secs(2);
|
|
2499
2547
|
var RUNNING = ["working", "merging"];
|
|
2500
2548
|
async function wait({
|
|
@@ -2556,12 +2604,12 @@ var arbor = cli("arbor").use(exits()).use(repository());
|
|
|
2556
2604
|
arbor.command("add").description("start a new task: create branch task/<task>, a worktree at ../<repo>-arbor/<task> and a state record").arg("task", z.string(), { description: "task name (lowercase-with-dashes)" }).option("base", z.string(), {
|
|
2557
2605
|
default: "",
|
|
2558
2606
|
description: "branch this task starts from and merges onto (default: trunk); `task/<other>` stacks this task on that one and lands the work in its worktree"
|
|
2559
|
-
}).option("todo", z.
|
|
2560
|
-
default:
|
|
2561
|
-
description: "take up
|
|
2607
|
+
}).option("todo", z.string(), {
|
|
2608
|
+
default: "",
|
|
2609
|
+
description: "take up these todos, comma separated: their text becomes the plan's Goal, merging removes them, and removing the task puts them back"
|
|
2562
2610
|
}).action((opts, ctx) => ctx.journal.record("add", opts.task, () => add(ctx, opts.task, {
|
|
2563
2611
|
base: opts.base || undefined,
|
|
2564
|
-
|
|
2612
|
+
todos: todoIds(opts.todo)
|
|
2565
2613
|
})));
|
|
2566
2614
|
arbor.command("claim").description("resume an existing task: take ownership of its worktree and print its path, status and any half-finished rebase; an escalated task goes back to working; refuses while another agent holds the lease, but takes a stale one silently, so `arbor show` first if the tree may not be abandoned").arg("task", z.string(), { description: "task name" }).action((opts, ctx) => ctx.journal.record("claim", opts.task, () => claim(ctx, opts.task)));
|
|
2567
2615
|
arbor.command("merge").description("land this worktree's branch on its base (trunk unless created with --base): rebase onto it, run tests on the rebased code, fast-forward it in whichever worktree has it checked out, then discard the worktree, branch and record (linear history, never a merge commit, no flag to skip tests); requires committed work, refusing a dirty worktree").action(async (_opts, ctx) => ctx.journal.record("merge", await here(ctx), () => merge(ctx, ctx.ps.cwd())));
|
|
@@ -2652,7 +2700,28 @@ todo.command("update").description("say what a todo is in other words, or attach
|
|
|
2652
2700
|
files: commaList(opts.file),
|
|
2653
2701
|
removeFiles: commaList(opts["remove-file"])
|
|
2654
2702
|
})));
|
|
2703
|
+
todo.command("take").description("take up todos for the task whose worktree you are in, as when they turn out to be part of its work: merging removes them, removing the task puts them back").rest("ids", z.coerce.number().int().positive(), {
|
|
2704
|
+
description: "todo ids"
|
|
2705
|
+
}).action(async (opts, ctx) => {
|
|
2706
|
+
const task = await here(ctx);
|
|
2707
|
+
await ctx.journal.record("todo take", task, () => todoTake(ctx, opts.ids, task));
|
|
2708
|
+
});
|
|
2709
|
+
todo.command("release").description("put taken todos back on the list, as when a task lands without finishing them: `arbor todo update` the leftover first; from a worktree, only its own task's").rest("ids", z.coerce.number().int().positive(), {
|
|
2710
|
+
description: "todo ids"
|
|
2711
|
+
}).action(async (opts, ctx) => {
|
|
2712
|
+
const task = await here(ctx);
|
|
2713
|
+
await ctx.journal.record("todo release", task, () => todoRelease(ctx, opts.ids, task));
|
|
2714
|
+
});
|
|
2655
2715
|
todo.command("remove").description("drop a todo that was done some other way or no longer applies").arg("id", z.coerce.number().int().positive(), { description: "todo id" }).action((opts, ctx) => ctx.journal.record("todo remove", null, () => todoRemove(ctx, opts.id)));
|
|
2716
|
+
function todoIds(raw) {
|
|
2717
|
+
return commaList(raw).map((value) => {
|
|
2718
|
+
const id = Number(value);
|
|
2719
|
+
if (!Number.isInteger(id) || id <= 0) {
|
|
2720
|
+
fail("usage", `'${value}' is not a todo id`, { todo: value });
|
|
2721
|
+
}
|
|
2722
|
+
return id;
|
|
2723
|
+
});
|
|
2724
|
+
}
|
|
2656
2725
|
function commaList(raw) {
|
|
2657
2726
|
return raw.split(",").map((value) => value.trim()).filter(Boolean);
|
|
2658
2727
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webappwiz/arbor",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.25",
|
|
4
4
|
"description": "Runs several AI coding agents on one repository at once, each in its own git worktree",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Jared Johnson",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"access": "public"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"webappwiz": "^0.0.
|
|
23
|
+
"webappwiz": "^0.0.25",
|
|
24
24
|
"zod": "^4.4.3"
|
|
25
25
|
},
|
|
26
26
|
"main": "./index.js",
|
package/todo.d.ts
CHANGED
|
@@ -132,6 +132,24 @@ export declare function todoUpdate(deps: {
|
|
|
132
132
|
fs: Fs;
|
|
133
133
|
ps: Ps;
|
|
134
134
|
}, id: number, { text, files, removeFiles }?: TodoUpdateOptions): Promise<Todo>;
|
|
135
|
+
/**
|
|
136
|
+
* Takes up todos for `task` after it started, as when one turns out to be
|
|
137
|
+
* part of the work. Every id is checked before any is taken, so one that is
|
|
138
|
+
* gone or another task's refuses them all.
|
|
139
|
+
*/
|
|
140
|
+
export declare function todoTake({ todos, log }: {
|
|
141
|
+
todos: Todos;
|
|
142
|
+
log: Logger;
|
|
143
|
+
}, ids: number[], task: string | null): Promise<Todo[]>;
|
|
144
|
+
/**
|
|
145
|
+
* Puts todos back on the list, as when a task is landing without finishing
|
|
146
|
+
* them: say what is left with `arbor todo update` first. From a worktree it
|
|
147
|
+
* gives back only its own task's todos.
|
|
148
|
+
*/
|
|
149
|
+
export declare function todoRelease({ todos, log }: {
|
|
150
|
+
todos: Todos;
|
|
151
|
+
log: Logger;
|
|
152
|
+
}, ids: number[], task: string | null): Promise<Todo[]>;
|
|
135
153
|
export declare function todoRemove({ todos, log }: {
|
|
136
154
|
todos: Todos;
|
|
137
155
|
log: Logger;
|