@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 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 <id>` takes up a todo (see `arbor todo`): its text becomes the plan's
36
- `## Goal`, followed by the path of each file attached to it, and no other task
37
- can take it while this one lives.
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 done.
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 300] [--answered]`
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: five minutes, and then a `timeout` refusal that hands the decision
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 remove`, and from the
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>` is how one gets picked up. `update` rewords
441
- one and keeps its number; `remove` drops one done some other way or no longer
442
- wanted.
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 todo this task takes up: its text seeds the plan's Goal, and nobody
12
- * else can take it while the task lives.
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
- todo?: number;
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, todo: id }?: AddOptions): Promise<void>;
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, todo: id } = {}) {
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 todo = id === undefined ? null : await todos.find(id);
341
- if (todo?.takenBy) {
342
- await todo.take(task);
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, todo === null ? null : goal(todo.text, todo.files)));
360
- await todo?.take(task);
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(text, files) {
379
- return files.length === 0 ? text : [text, "", ...files.map((path) => `Attached: \`${path}\``)].join(`
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
- for (const todo of await todos.takenBy(task)) {
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(5);
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.coerce.number().int().nonnegative(), {
2560
- default: 0,
2561
- description: "take up this todo: its text becomes the plan's Goal, merging removes it, and removing the task puts it back"
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
- todo: opts.todo || undefined
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.23",
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",
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;