@webappwiz/arbor 0.0.11 → 0.0.13

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
@@ -32,6 +32,11 @@ rebase, and that is what `rm` is for.
32
32
  Creates the task: branch `task/<task>`, a worktree at
33
33
  `../<repo>-arbor/<task>`, and a state record.
34
34
 
35
+ `--base <branch>` starts the task from that branch and lands it back there
36
+ instead of trunk. It takes another task's branch too: `--base task/<other>`
37
+ stacks this task on that one, and the work lands in that task's worktree
38
+ rather than on trunk.
39
+
35
40
  A fresh worktree shares no untracked files with the repo (no `node_modules`,
36
41
  no `.env`) which is what `postCheckout` is for.
37
42
 
@@ -53,10 +58,11 @@ holds the lease. A worktree with no record is rebuilt rather than rejected.
53
58
 
54
59
  ### `arbor merge`
55
60
 
56
- Lands the current worktree's branch on trunk. The core command.
61
+ Lands the current worktree's branch on its base, trunk unless the task was
62
+ created with `--base`. The core command.
57
63
 
58
- **Never a merge commit.** It rebases onto trunk, runs the `preMerge` gate
59
- there, and fast-forwards trunk with `git merge --ff-only`. History stays
64
+ **Never a merge commit.** It rebases onto the base, runs the `preMerge` gate
65
+ there, and fast-forwards the base with `git merge --ff-only`. History stays
60
66
  linear.
61
67
 
62
68
  1. Refuses if the worktree is dirty, out of retry budget, or leased elsewhere.
@@ -67,10 +73,14 @@ linear.
67
73
  passed before rebasing says nothing about whether it works against current
68
74
  trunk; this is the only defense against semantic conflicts, where both sides
69
75
  merge cleanly and the combination is broken.
70
- 4. Re-checks the lease, then `git checkout <trunk> && git merge --ff-only`.
76
+ 4. Re-checks the lease, then fast-forwards the base **in the worktree that has
77
+ it checked out**: the main tree for trunk, or the other task's worktree for
78
+ a `--base task/<other>` task. Git allows one worktree per branch, and that
79
+ tree is the only place the branch can move: advancing the ref behind its
80
+ back would leave its index and files on the old commit.
71
81
  5. Discards the task (worktree, branch and record) exactly as `rm`
72
82
  would. The work is on trunk, so the tree has nothing left to hold, and
73
- `arbor ls` stays a list of live work rather than a graveyard of landed
83
+ `arbor list` stays a list of live work rather than a graveyard of landed
74
84
  tasks. The agent's own directory goes with it, so the success message
75
85
  prints the main tree to `cd` back to.
76
86
 
@@ -95,7 +105,7 @@ Removal leaves a tombstone in `.git/arbor/removed/` so a second `rm` can say
95
105
  and drops the oldest as new ones arrive, so a long-forgotten task reports
96
106
  `not_found` again.
97
107
 
98
- ### `arbor ls [--json]`
108
+ ### `arbor list [--json]`
99
109
 
100
110
  Every task: name, status, lease (`held`/`stale`/`none`), commits ahead of
101
111
  trunk, age. A corrupt record shows as `unknown` instead of taking
@@ -103,7 +113,7 @@ down the listing; a record whose worktree vanished shows as `orphaned`.
103
113
 
104
114
  ### `arbor show <task> [--json]`
105
115
 
106
- One task in full: the row `ls` would print for it, plus the `ARBOR.md`
116
+ One task in full: the row `list` would print for it, plus the `ARBOR.md`
107
117
  its agent keeps at the worktree root and the reason behind an `escalated`
108
118
  status.
109
119
 
@@ -120,7 +130,7 @@ ARBOR.md
120
130
  ...
121
131
  ```
122
132
 
123
- `ls` says a task exists; this says what it is doing. Like `path`, it takes no
133
+ `list` says a task exists; this says what it is doing. Like `path`, it takes no
124
134
  lease, so reading another agent's tree cannot knock it off its own work the way
125
135
  `claim` would. A task with no `ARBOR.md` is called out rather than passed over
126
136
  in silence: it is the one thing that makes the work resumable.
@@ -161,14 +171,14 @@ WHEN ACTION TASK RESULT
161
171
  1h merge alpha ok
162
172
  ```
163
173
 
164
- `ls` is what still exists; this is what happened. Entries outlive their tasks:
174
+ `list` is what still exists; this is what happened. Entries outlive their tasks:
165
175
  a successful `merge` and a `rm` both take the record with them, so this is
166
176
  the only thing that remembers a task landed at all. The last 200 are kept
167
177
  (`logCapacity`) in `.git/arbor/log.jsonl`.
168
178
 
169
179
  ### `arbor dev [--port 4269]`
170
180
 
171
- `ls`, `show` and `log` in a browser, on `http://localhost:4269`, reloading
181
+ `list`, `show` and `log` in a browser, on `http://localhost:4269`, reloading
172
182
  themselves as tasks change. Read-only, and takes no lease.
173
183
 
174
184
  ### `arbor path [task]`
@@ -236,7 +246,7 @@ The agent's control flow runs on these.
236
246
  | 9 | `hook_failed` | `postCheckout` failed (worktree still exists; fix and re-run the hook), or `postMerge` failed (the branch already landed; nothing rolled back). |
237
247
  | 10 | `exists` | Task already exists. `arbor claim` it, or `arbor rm` first. |
238
248
  | 11 | `orphaned` | Record with no worktree. `arbor rm` it. |
239
- | 12 | `merge_failed` | Trunk could not be fast-forwarded (usually a dirty main worktree). |
249
+ | 12 | `merge_failed` | The base could not be fast-forwarded (usually uncommitted changes in the worktree holding it). |
240
250
  | 13 | `already_removed` | This task was removed earlier; nothing left to remove. |
241
251
  | 14 | `timeout` | `arbor wait` gave up: the task is still working or merging. |
242
252
 
package/arbor.d.ts CHANGED
@@ -6,9 +6,9 @@ import { type Repository } from "./repository.js";
6
6
  /** Everything `arbor` is run with, before the repository middleware adds to it. */
7
7
  export interface ArborDeps extends Deps {
8
8
  fs: Fs;
9
- /** Only `dev` listens, but the runtime is picked once, where arbor starts. */
9
+ /** HTTP server `dev` listens on to serve its preview. */
10
10
  http: HttpServer;
11
- /** Likewise: only `dev` serves the page, and it is built before it ships. */
11
+ /** The built page `dev` serves as its preview. */
12
12
  assets: Assets;
13
13
  }
14
14
  export declare const arbor: import("webappwiz/cmd").Cli<ArborDeps, ArborDeps & Repository>;
package/dev/assets.d.ts CHANGED
@@ -7,11 +7,5 @@ export interface Assets {
7
7
  /** The stylesheet, with Tailwind already compiled. */
8
8
  styles: string;
9
9
  }
10
- /**
11
- * The page this package ships, built by `build.ts` and imported here as text so
12
- * it travels inside the bundle a release publishes. Serving it costs a string
13
- * lookup, and nothing is read off disk: a published package has no `dev/`
14
- * directory to read from, and the CLI's own dependencies stay clear of React
15
- * and Tailwind because neither is needed once the page is built.
16
- */
10
+ /** The page this package ships. Serve each field's contents as-is. */
17
11
  export declare const assets: Assets;
package/dev.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type AsyncResource } from "webappwiz/disposable";
1
2
  import type { HttpServer } from "webappwiz/http";
2
3
  import type { Logger } from "webappwiz/log";
3
4
  import { type Fs, type PortProvider } from "webappwiz/system";
@@ -8,30 +9,19 @@ import type { WorktreeService } from "./worktree-service.js";
8
9
  export declare const DEFAULT_PORT = 4269;
9
10
  /** How far above the port asked for `dev` will look before giving up. */
10
11
  export declare const PORT_SPAN = 20;
11
- /**
12
- * Where `dev` will listen, given the port asked for. A flag is outside input,
13
- * so a port that cannot exist is a refusal with an exit code rather than the
14
- * assertion `OpenPortProvider` would raise for a bug in here.
15
- */
16
- export declare function devPorts(from: number): PortProvider;
17
12
  /** What `dev` lets a caller choose. */
18
13
  export interface DevOptions {
19
14
  /** Where to listen; the port `--port` asked for, and the span above it. */
20
15
  ports?: PortProvider;
21
16
  }
22
17
  /** A running server, and the one thing a caller ever wants to do with it. */
23
- export interface DevServer {
18
+ export interface DevServer extends AsyncResource {
24
19
  port: number;
25
- stop(): Promise<void>;
26
20
  }
27
21
  /**
28
- * Serves what `ls`, `show` and `log` print, as one page that refetches when the
22
+ * Serves what `list`, `show` and `log` print, as one page that refetches when the
29
23
  * repo changes. Read-only on purpose: driving arbor is what the CLI is for, and
30
24
  * a button that took a lease would fight the agent holding it.
31
- *
32
- * The page itself is a React app under `dev/`, built before publishing and
33
- * carried in the bundle, so nothing here builds markup and nothing reads it
34
- * off disk.
35
25
  */
36
26
  export declare function dev({ service, fs, journal, log, http, assets, }: {
37
27
  service: WorktreeService;
@@ -41,3 +31,9 @@ export declare function dev({ service, fs, journal, log, http, assets, }: {
41
31
  http: HttpServer;
42
32
  assets: Assets;
43
33
  }, { ports }?: DevOptions): Promise<DevServer>;
34
+ /**
35
+ * Where `dev` will listen, given the port asked for. A flag is outside input,
36
+ * so a port that cannot exist is a refusal with an exit code rather than the
37
+ * assertion `OpenPortProvider` would raise for a bug in here.
38
+ */
39
+ export declare function devPorts(from: number): PortProvider;
package/escalate.d.ts CHANGED
@@ -2,6 +2,10 @@ import { type Logger } from "webappwiz/log";
2
2
  import type { Lock } from "webappwiz/system";
3
3
  import type { Git } from "./git.js";
4
4
  import type { WorktreeService } from "./worktree-service.js";
5
+ export interface EscalateOptions {
6
+ /** Task to escalate. Defaults to the one `cwd` is a worktree for. */
7
+ task?: string;
8
+ }
5
9
  /**
6
10
  * The way out that is not "resolve the conflict badly to finish the task".
7
11
  * When both sides restructured the same logic there is no correct merge, only
@@ -12,4 +16,4 @@ export declare function escalate({ service, git, lock, log, }: {
12
16
  git: Git;
13
17
  lock: Lock;
14
18
  log: Logger;
15
- }, reason: string, cwd: string, task?: string): Promise<void>;
19
+ }, reason: string, cwd: string, { task }?: EscalateOptions): Promise<void>;
package/git.d.ts CHANGED
@@ -44,7 +44,13 @@ export declare class Git {
44
44
  rebase(cwd: string, onto: string): Promise<GitResult>;
45
45
  resetHard(cwd: string, commit: string): Promise<GitResult>;
46
46
  checkout(branch: string): Promise<GitResult>;
47
- mergeFfOnly(branch: string): Promise<GitResult>;
47
+ mergeFfOnly(cwd: string, branch: string): Promise<GitResult>;
48
+ /**
49
+ * The worktree with `branch` checked out, or null when no worktree has it.
50
+ * Git allows one worktree per branch and that is the only place the branch
51
+ * can be advanced, so this is where a fast-forward has to happen.
52
+ */
53
+ worktreeFor(branch: string): Promise<string | null>;
48
54
  addWorktree(branch: string, path: string, from: string): Promise<GitResult>;
49
55
  removeWorktree(path: string): Promise<GitResult>;
50
56
  /** Drops metadata for worktrees whose directories are gone. */
package/index.js CHANGED
@@ -62,7 +62,6 @@ function exits() {
62
62
  // add.ts
63
63
  var NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
64
64
  var PLAN_FILE = "ARBOR.md";
65
- var PLAN = (task) => new MarkdownWriter().heading(1, task).heading(2, "Goal").heading(2, "Files").heading(2, "Next").checklist("fill in Goal and list the steps here as `- [ ]` items").toString();
66
65
  async function add({
67
66
  service,
68
67
  shell,
@@ -100,8 +99,7 @@ ${PLAN_FILE}
100
99
  }
101
100
  await fs.write(`${worktree.path}/${PLAN_FILE}`, PLAN(task));
102
101
  if (config.postCheckout) {
103
- const { exitCode } = await shell.stream(config.postCheckout, {
104
- cwd: worktree.path,
102
+ const { exitCode } = await shell.stream(config.postCheckout, worktree.path, {
105
103
  env: {
106
104
  ARBOR_TASK: task,
107
105
  ARBOR_WORKTREE: worktree.path,
@@ -117,6 +115,9 @@ ${PLAN_FILE}
117
115
  branch: ${worktree.branch}
118
116
  base: ${base}`);
119
117
  }
118
+ function PLAN(task) {
119
+ return new MarkdownWriter().heading(1, task).heading(2, "Goal").heading(2, "Files").heading(2, "Next").checklist("fill in Goal and list the steps here as `- [ ]` items").toString();
120
+ }
120
121
 
121
122
  // claim.ts
122
123
  import { color as color2 } from "webappwiz/log";
@@ -161,6 +162,7 @@ ${changes.map((change) => ` ${change}`).join(`
161
162
  }
162
163
 
163
164
  // dev.ts
165
+ import { AsyncDisposer } from "webappwiz/disposable";
164
166
  import {
165
167
  MAX_PORT,
166
168
  OpenPortProvider
@@ -271,41 +273,47 @@ function checkPlan(text, { task, escalated = false }) {
271
273
 
272
274
  // show.ts
273
275
  var FILE = "ARBOR.md";
274
- async function show({ service, fs, log: log2 }, task, { json = false } = {}) {
276
+ async function show({ service, fs, log: log2 }, task, opts = {}) {
275
277
  const worktree = await service.find(task);
276
278
  if (worktree.gone) {
277
- fail("not_found", `no task '${task}': run \`arbor ls\` to see what there is`, { task });
279
+ fail("not_found", `no task '${task}': run \`arbor list\` to see what there is`, { task });
278
280
  }
279
- const details = await taskDetails(worktree, { fs });
280
- if (json) {
281
+ const details = await new TaskDetails({ fs }).get(worktree);
282
+ if (opts.json ?? false) {
281
283
  log2.info(JSON.stringify(details, null, "\t"));
282
284
  return;
283
285
  }
284
286
  log2.info(report(details));
285
287
  }
286
- async function taskDetails(worktree, opts = {}) {
287
- const files = opts.fs ?? new NodeFs;
288
- const { state } = worktree;
289
- const stat = worktree.hasBranch ? await worktree.diffStat() : null;
290
- const plan = worktree.exists ? await files.read(`${worktree.path}/${FILE}`).catch(() => null) : null;
291
- return {
292
- task: worktree.task,
293
- status: worktree.status,
294
- branch: worktree.branch,
295
- base: worktree.base,
296
- worktree: worktree.path,
297
- lease: worktree.leaseStatus,
298
- ahead: worktree.hasBranch ? await worktree.commitsAhead() : null,
299
- added: stat?.added ?? null,
300
- removed: stat?.removed ?? null,
301
- age: state ? age(state.createdAt) : null,
302
- escalation: state?.escalations?.at(-1)?.reason ?? null,
303
- plan,
304
- planProblems: plan === null ? [] : checkPlan(plan, {
288
+
289
+ class TaskDetails {
290
+ fs;
291
+ constructor(opts = {}) {
292
+ this.fs = opts.fs ?? new NodeFs;
293
+ }
294
+ async get(worktree) {
295
+ const { state } = worktree;
296
+ const stat = worktree.hasBranch ? await worktree.diffStat() : null;
297
+ const plan = worktree.exists ? await this.fs.read(`${worktree.path}/${FILE}`).catch(() => null) : null;
298
+ return {
305
299
  task: worktree.task,
306
- escalated: worktree.status === "escalated"
307
- })
308
- };
300
+ status: worktree.status,
301
+ branch: worktree.branch,
302
+ base: worktree.base,
303
+ worktree: worktree.path,
304
+ lease: worktree.leaseStatus,
305
+ ahead: worktree.hasBranch ? await worktree.commitsAhead() : null,
306
+ added: stat?.added ?? null,
307
+ removed: stat?.removed ?? null,
308
+ age: state ? age(state.createdAt) : null,
309
+ escalation: state?.escalations?.at(-1)?.reason ?? null,
310
+ plan,
311
+ planProblems: plan === null ? [] : checkPlan(plan, {
312
+ task: worktree.task,
313
+ escalated: worktree.status === "escalated"
314
+ })
315
+ };
316
+ }
309
317
  }
310
318
  function report(details) {
311
319
  const lines = [
@@ -340,10 +348,10 @@ function diff(details) {
340
348
 
341
349
  // snapshot.ts
342
350
  async function snapshot(service, journal, opts = {}) {
343
- const fs = opts.fs ?? new NodeFs2;
351
+ const details = new TaskDetails({ fs: opts.fs ?? new NodeFs2 });
344
352
  const tasks = [];
345
353
  for (const worktree of await service.list()) {
346
- tasks.push(await taskDetails(worktree, { fs }));
354
+ tasks.push(await details.get(worktree));
347
355
  }
348
356
  return { tasks, entries: await journal.tail(DEFAULT_COUNT) };
349
357
  }
@@ -368,14 +376,6 @@ function fingerprint({ tasks, entries }) {
368
376
  // dev.ts
369
377
  var DEFAULT_PORT = 4269;
370
378
  var PORT_SPAN = 20;
371
- function devPorts(from) {
372
- if (!Number.isInteger(from) || from < 0 || from > MAX_PORT) {
373
- fail("usage", `invalid port '${from}': use a whole number 0 to ${MAX_PORT}`, {
374
- port: from
375
- });
376
- }
377
- return OpenPortProvider.span({ from, span: PORT_SPAN });
378
- }
379
379
  var POLL_MS = 2000;
380
380
  async function dev({
381
381
  service,
@@ -448,14 +448,22 @@ async function dev({
448
448
  }
449
449
  }, { port: await ports.get(), idleTimeout: Duration.zero() });
450
450
  log2.info(`arbor dev on http://localhost:${listening.port}`);
451
+ const disposer = new AsyncDisposer;
452
+ disposer.defer(() => listening.stop());
453
+ disposer.defer(async () => clearInterval(poll));
451
454
  return {
452
455
  port: listening.port,
453
- stop: async () => {
454
- clearInterval(poll);
455
- await listening.stop();
456
- }
456
+ disposeAsync: disposer.disposeAsync
457
457
  };
458
458
  }
459
+ function devPorts(from) {
460
+ if (!Number.isInteger(from) || from < 0 || from > MAX_PORT) {
461
+ fail("usage", `invalid port '${from}': use a whole number 0 to ${MAX_PORT}`, {
462
+ port: from
463
+ });
464
+ }
465
+ return OpenPortProvider.span({ from, span: PORT_SPAN });
466
+ }
459
467
 
460
468
  // escalate.ts
461
469
  import { color as color6 } from "webappwiz/log";
@@ -464,7 +472,7 @@ async function escalate({
464
472
  git,
465
473
  lock,
466
474
  log: log2
467
- }, reason, cwd, task) {
475
+ }, reason, cwd, { task } = {}) {
468
476
  const branch = await git.currentBranch(cwd).catch(() => "");
469
477
  const name = task || service.taskFor(branch);
470
478
  if (!name) {
@@ -496,9 +504,9 @@ async function escalate({
496
504
  `));
497
505
  }
498
506
 
499
- // ls.ts
507
+ // list.ts
500
508
  import { color as color7 } from "webappwiz/log";
501
- async function ls({ service, log: log2 }, { json = false } = {}) {
509
+ async function list({ service, log: log2 }, { json = false } = {}) {
502
510
  const rows = [];
503
511
  for (const worktree of await service.list()) {
504
512
  rows.push(await row2(worktree));
@@ -605,8 +613,7 @@ async function merge({
605
613
  `), { task, paths });
606
614
  }
607
615
  const gate = [config.postRewrite, config.preMerge].filter(Boolean).join(" && ");
608
- const gated = gate ? await shell.run(gate, {
609
- cwd: worktree.path,
616
+ const gated = gate ? await shell.run(gate, worktree.path, {
610
617
  env: {
611
618
  ARBOR_TASK: task,
612
619
  ARBOR_WORKTREE: worktree.path
@@ -630,14 +637,25 @@ ${gated.stderr}`)
630
637
  await lock.release();
631
638
  fail("lease_lost", `the lease on '${task}' was taken by pid ${current.lease?.pid} during the merge, stopping without landing. Do not retry; another agent owns this tree.`, { task, lease: current.lease });
632
639
  }
633
- const checkout = await git.checkout(base);
634
- const merged = checkout.code === 0 ? await git.mergeFfOnly(branch) : checkout;
640
+ const holder = await git.worktreeFor(base);
641
+ const landing = holder ?? git.root;
642
+ const checkout = holder ? null : await git.checkout(base);
643
+ const merged = checkout && checkout.code !== 0 ? checkout : await git.mergeFfOnly(landing, branch);
635
644
  if (merged.code !== 0) {
636
645
  await lock.release();
637
646
  await bump(worktree);
638
- fail("merge_failed", `could not fast-forward ${base} in ${git.root}: ${merged.stderr || merged.stdout}`, { task });
647
+ fail("merge_failed", [
648
+ `could not fast-forward ${base} in ${landing}: ${merged.stderr || merged.stdout}`,
649
+ ...landing === git.root ? [] : [
650
+ "",
651
+ `${base} is checked out there, which is the only place it can move.`,
652
+ "Whoever is working in that tree has to commit or stash what collides,",
653
+ "then merge again."
654
+ ]
655
+ ].join(`
656
+ `), { task, base, landing });
639
657
  }
640
- const head = await git.shortHead(git.root);
658
+ const head = await git.shortHead(landing);
641
659
  service.ps.cd(git.root);
642
660
  const discarded = await current.discard();
643
661
  await lock.release();
@@ -646,8 +664,7 @@ ${gated.stderr}`)
646
664
  Run \`arbor rm ${task}\` to clean up.`, { task });
647
665
  }
648
666
  if (config.postMerge) {
649
- const ran = await shell.run(config.postMerge, {
650
- cwd: git.root,
667
+ const ran = await shell.run(config.postMerge, landing, {
651
668
  env: { ARBOR_TASK: task }
652
669
  });
653
670
  if (ran.exitCode !== 0) {
@@ -660,7 +677,8 @@ ${ran.stderr}`)
660
677
  `), { task, exitCode: ran.exitCode });
661
678
  }
662
679
  }
663
- log2.info(`${color8.green("merged")} ${task} onto ${base} (${head})
680
+ log2.info(`${color8.green("merged")} ${task} onto ${base} (${head})${landing === git.root ? "" : `
681
+ landed in: ${landing}`}
664
682
  worktree removed, cd ${git.root}`);
665
683
  }
666
684
  async function bump(worktree) {
@@ -683,7 +701,7 @@ async function path({ service, log: log2 }, task) {
683
701
  }
684
702
  const worktree = await service.find(task);
685
703
  if (worktree.gone) {
686
- fail("not_found", `no task '${task}': run \`arbor ls\` to see what there is`, { task });
704
+ fail("not_found", `no task '${task}': run \`arbor list\` to see what there is`, { task });
687
705
  }
688
706
  if (!worktree.exists) {
689
707
  fail("orphaned", `'${task}' has no worktree at ${worktree.path}`, {
@@ -774,8 +792,21 @@ class Git {
774
792
  checkout(branch) {
775
793
  return this.run(this.root, "checkout", branch);
776
794
  }
777
- mergeFfOnly(branch) {
778
- return this.run(this.root, "merge", "--ff-only", branch);
795
+ mergeFfOnly(cwd, branch) {
796
+ return this.run(cwd, "merge", "--ff-only", branch);
797
+ }
798
+ async worktreeFor(branch) {
799
+ const listing2 = await this.out(this.root, "worktree", "list", "--porcelain");
800
+ let path2 = null;
801
+ for (const line of listing2.split(`
802
+ `)) {
803
+ if (line.startsWith("worktree ")) {
804
+ path2 = line.slice("worktree ".length);
805
+ } else if (line === `branch refs/heads/${branch}`) {
806
+ return path2;
807
+ }
808
+ }
809
+ return null;
779
810
  }
780
811
  addWorktree(branch, path2, from) {
781
812
  return this.run(this.root, "worktree", "add", "-b", branch, path2, from);
@@ -907,11 +938,11 @@ class Shell {
907
938
  constructor(opts = {}) {
908
939
  this.ps = opts.ps ?? new NodePs2;
909
940
  }
910
- run(command, opts) {
911
- return this.ps.spawnCapture(["sh", "-c", command], opts);
941
+ run(command, cwd, opts = {}) {
942
+ return this.ps.spawnCapture(["sh", "-c", command], { ...opts, cwd });
912
943
  }
913
- stream(command, opts) {
914
- return this.ps.spawn(["sh", "-c", command], opts);
944
+ stream(command, cwd, opts = {}) {
945
+ return this.ps.spawn(["sh", "-c", command], { ...opts, cwd });
915
946
  }
916
947
  }
917
948
 
@@ -1284,7 +1315,7 @@ async function wait({ service, log: log2 }, task, { timeout = DEFAULT_TIMEOUT, p
1284
1315
  for (;; ) {
1285
1316
  const worktree = await service.find(task);
1286
1317
  if (worktree.status === "absent") {
1287
- fail("not_found", `no task '${task}': run \`arbor ls\` to see what there is`, { task });
1318
+ fail("not_found", `no task '${task}': run \`arbor list\` to see what there is`, { task });
1288
1319
  }
1289
1320
  if (!RUNNING.includes(worktree.status)) {
1290
1321
  log2.info(report2(worktree));
@@ -1311,24 +1342,19 @@ function report2(worktree) {
1311
1342
  }
1312
1343
 
1313
1344
  // arbor.ts
1314
- var here = async ({
1315
- service,
1316
- git,
1317
- ps
1318
- }) => service.taskFor(await git.currentBranch(ps.cwd()).catch(() => ""));
1319
1345
  var arbor = cli("arbor").use(exits()).use(repository());
1320
1346
  arbor.command("add").description("start a new task: create branch task/<task>, a worktree at ../<repo>-arbor/<task> and a state record").arg("task", t.string(), { description: "task name (lowercase-with-dashes)" }).option("base", t.string(), {
1321
1347
  default: "",
1322
- description: "branch this task starts from and merges onto (default: trunk)"
1348
+ 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"
1323
1349
  }).action((opts, ctx) => ctx.journal.record("add", opts.task, () => add(ctx, opts.task, { base: opts.base || undefined })));
1324
1350
  arbor.command("claim").description("resume an existing task: take ownership of its worktree and print its path, status and any half-finished rebase; 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", t.string(), { description: "task name" }).action((opts, ctx) => ctx.journal.record("claim", opts.task, () => claim(ctx, opts.task)));
1325
- 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, 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())));
1351
+ 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())));
1326
1352
  arbor.command("rm").description("discard a task: worktree, branch and state file; cheap and encouraged, since redoing a task against current trunk often beats a hard rebase").arg("task", t.string(), { description: "task name" }).option("force", t.boolean(), {
1327
1353
  default: false,
1328
1354
  description: "discard even when another agent holds the lease"
1329
1355
  }).action((opts, ctx) => ctx.journal.record("rm", opts.task, () => rm(ctx, opts.task, { force: opts.force })));
1330
- arbor.command("ls").description("list every task: name, status, lease (held: an agent is on it now; stale: gone quiet, normal for a task mid-edit; none), commits ahead of trunk, age").option("json", t.boolean(), { default: false, description: "emit JSON" }).action((opts, ctx) => ls(ctx, { json: opts.json }));
1331
- arbor.command("show").description("read one task without touching it: everything `ls` lists for it, plus the ARBOR.md its agent left at the worktree root; takes no lease, so it cannot knock that agent off its own tree").arg("task", t.string(), { description: "task name" }).option("json", t.boolean(), { default: false, description: "emit JSON" }).action((opts, ctx) => show(ctx, opts.task, { json: opts.json }));
1356
+ arbor.command("list").description("list every task: name, status, lease (held: an agent is on it now; stale: gone quiet, normal for a task mid-edit; none), commits ahead of trunk, age").option("json", t.boolean(), { default: false, description: "emit JSON" }).action((opts, ctx) => list(ctx, { json: opts.json }));
1357
+ arbor.command("show").description("read one task without touching it: everything `list` shows for it, plus the ARBOR.md its agent left at the worktree root; takes no lease, so it cannot knock that agent off its own tree").arg("task", t.string(), { description: "task name" }).option("json", t.boolean(), { default: false, description: "emit JSON" }).action((opts, ctx) => show(ctx, opts.task, { json: opts.json }));
1332
1358
  arbor.command("wait").description("block until a task stops moving: escalated, or gone (merged or removed), or broken; takes no lease, and gives up with `timeout` rather than waiting forever").arg("task", t.string(), { description: "task name" }).option("timeout-secs", t.number(), {
1333
1359
  default: DEFAULT_TIMEOUT.secs,
1334
1360
  description: "how long to wait before giving up"
@@ -1339,7 +1365,7 @@ arbor.command("log").description("show what has been done here recently: one lin
1339
1365
  default: DEFAULT_COUNT,
1340
1366
  description: "how many entries to show"
1341
1367
  }).option("json", t.boolean(), { default: false, description: "emit JSON" }).action((opts, ctx) => log(ctx, { count: opts.count, json: opts.json }));
1342
- arbor.command("dev").description("serve `ls`, `show` and `log` as a web page; read-only").option("port", t.number(), {
1368
+ arbor.command("dev").description("serve `list`, `show` and `log` as a web page; read-only").option("port", t.number(), {
1343
1369
  default: DEFAULT_PORT,
1344
1370
  description: "port to listen on, or the next open one above it"
1345
1371
  }).action((opts, ctx) => dev(ctx, { ports: devPorts(opts.port) }));
@@ -1350,8 +1376,17 @@ arbor.command("path").description("print a task's worktree path, or the main tre
1350
1376
  arbor.command("escalate").description("hand this task to a human and stop: records the reason, drops the lease and leaves the worktree exactly as it is; use instead of resolving a genuine conflict badly just to finish").arg("reason", t.string(), { description: "why this needs a human" }).option("task", t.string(), {
1351
1377
  default: "",
1352
1378
  description: "task name, when run outside its worktree"
1353
- }).action(async (opts, ctx) => ctx.journal.record("escalate", opts.task || await here(ctx), () => escalate(ctx, opts.reason, ctx.ps.cwd(), opts.task || undefined)));
1379
+ }).action(async (opts, ctx) => ctx.journal.record("escalate", opts.task || await here(ctx), () => escalate(ctx, opts.reason, ctx.ps.cwd(), {
1380
+ task: opts.task || undefined
1381
+ })));
1354
1382
  arbor.command("retry").description("give an escalated task another mergeRetryCount merge attempts and put it back to working; the way out of `budget_exhausted` that is not rm and redo, and only from escalated, so a human has seen the tree first").arg("task", t.string(), { description: "task name" }).action((opts, ctx) => ctx.journal.record("retry", opts.task, () => retry(ctx, opts.task)));
1383
+ async function here({
1384
+ service,
1385
+ git,
1386
+ ps
1387
+ }) {
1388
+ return service.taskFor(await git.currentBranch(ps.cwd()).catch(() => ""));
1389
+ }
1355
1390
 
1356
1391
  // dev/build/main.txt
1357
1392
  var main_default = `var __create = Object.create;
@@ -18889,7 +18924,7 @@ class Feed {
18889
18924
  stream.onerror = () => this.set({ offline: true });
18890
18925
  this.load();
18891
18926
  }
18892
- stop() {
18927
+ dispose() {
18893
18928
  this.stream?.close();
18894
18929
  this.stream = null;
18895
18930
  }
@@ -19106,7 +19141,7 @@ function App() {
19106
19141
  const [feed] = import_react6.useState(() => new Feed);
19107
19142
  useDisposerEffect((disposer) => {
19108
19143
  feed.start();
19109
- disposer.defer(() => feed.stop());
19144
+ disposer.use(feed);
19110
19145
  }, [feed]);
19111
19146
  const { snapshot, offline } = useReactive(feed, (feed2) => ({ snapshot: feed2.snapshot, offline: feed2.offline }), ["changed"]);
19112
19147
  const [tab, setTab] = import_react6.useState("tasks");
@@ -1,10 +1,10 @@
1
1
  import { type Logger } from "webappwiz/log";
2
2
  import type { WorktreeService } from "./worktree-service.js";
3
- export interface LsOptions {
3
+ export interface ListOptions {
4
4
  /** Print the rows as JSON instead of a table. */
5
5
  json?: boolean;
6
6
  }
7
- export declare function ls({ service, log }: {
7
+ export declare function list({ service, log }: {
8
8
  service: WorktreeService;
9
9
  log: Logger;
10
- }, { json }?: LsOptions): Promise<void>;
10
+ }, { json }?: ListOptions): Promise<void>;
package/merge.d.ts CHANGED
@@ -8,7 +8,8 @@ import type { WorktreeService } from "./worktree-service.js";
8
8
  * Lands the current worktree's branch on its base branch, trunk unless the
9
9
  * task was created with `--base`. Never a merge commit: it rebases onto the
10
10
  * base, runs the gate there, and fast-forwards the base with
11
- * `git merge --ff-only`. History stays linear.
11
+ * `git merge --ff-only`, in whichever worktree has the base checked out.
12
+ * History stays linear.
12
13
  */
13
14
  export declare function merge({ service, git, lock, shell, config, log, }: {
14
15
  service: WorktreeService;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webappwiz/arbor",
3
- "version": "0.0.11",
3
+ "version": "0.0.13",
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",
@@ -17,7 +17,7 @@
17
17
  "access": "public"
18
18
  },
19
19
  "dependencies": {
20
- "webappwiz": "^0.0.11"
20
+ "webappwiz": "^0.0.13"
21
21
  },
22
22
  "main": "./index.js",
23
23
  "types": "./index.d.ts",
package/shell.d.ts CHANGED
@@ -1,8 +1,4 @@
1
1
  import { type Ps, type SpawnCaptureResult, type SpawnOptions, type SpawnResult } from "webappwiz/system";
2
- /** Spawn options for a shell command. `cwd` is the worktree it runs against. */
3
- export type ShellRunOptions = SpawnOptions & {
4
- cwd: string;
5
- };
6
2
  /** What a `Shell` spawns through; the real process by default. */
7
3
  export interface ShellOptions {
8
4
  ps?: Ps;
@@ -15,7 +11,7 @@ export declare class Shell {
15
11
  private readonly ps;
16
12
  constructor(opts?: ShellOptions);
17
13
  /** Captures output, for commands whose failure has to be reported back. */
18
- run(command: string, opts: ShellRunOptions): Promise<SpawnCaptureResult>;
14
+ run(command: string, cwd: string, opts?: SpawnOptions): Promise<SpawnCaptureResult>;
19
15
  /** Inherits stdio, for commands the agent should watch as they run. */
20
- stream(command: string, opts: ShellRunOptions): Promise<SpawnResult>;
16
+ stream(command: string, cwd: string, opts?: SpawnOptions): Promise<SpawnResult>;
21
17
  }
package/show.d.ts CHANGED
@@ -23,7 +23,7 @@ export interface ShowOptions {
23
23
  json?: boolean;
24
24
  }
25
25
  /**
26
- * One task in full: what `ls` shows for it, plus the `ARBOR.md` its agent
26
+ * One task in full: what `list` shows for it, plus the `ARBOR.md` its agent
27
27
  * left at the worktree root. Reading a tree this way takes no lease, so it
28
28
  * cannot knock the agent driving it off its own work.
29
29
  */
@@ -31,13 +31,18 @@ export declare function show({ service, fs, log }: {
31
31
  service: WorktreeService;
32
32
  fs: Fs;
33
33
  log: Logger;
34
- }, task: string, { json }?: ShowOptions): Promise<void>;
34
+ }, task: string, opts?: ShowOptions): Promise<void>;
35
+ /** Configures how {@link TaskDetails} gathers a task's info. */
36
+ export interface TaskDetailsOptions {
37
+ /** What the worktree is read through; the real filesystem by default. */
38
+ fs?: Fs;
39
+ }
35
40
  /**
36
41
  * Everything there is to say about one task. Split out so `dev` can render the
37
42
  * same fields this prints, rather than assembling its own and drifting.
38
43
  */
39
- export interface TaskDetailsOptions {
40
- /** What the worktree is read through; the real filesystem by default. */
41
- fs?: Fs;
44
+ export declare class TaskDetails {
45
+ private readonly fs;
46
+ constructor(opts?: TaskDetailsOptions);
47
+ get(worktree: Worktree): Promise<Details>;
42
48
  }
43
- export declare function taskDetails(worktree: Worktree, opts?: TaskDetailsOptions): Promise<Details>;
package/snapshot.d.ts CHANGED
@@ -2,13 +2,13 @@ import { type Fs } from "webappwiz/system";
2
2
  import type { Entry, Journal } from "./journal.js";
3
3
  import { type Details } from "./show.js";
4
4
  import type { WorktreeService } from "./worktree-service.js";
5
- /** Everything one page shows: `ls` and `show` for each task, plus `log`. */
5
+ /** Everything one page shows: `list` and `show` for each task, plus `log`. */
6
6
  export interface Snapshot {
7
7
  tasks: Details[];
8
8
  entries: Entry[];
9
9
  }
10
10
  /**
11
- * Reads the whole repo the way `ls`, `show` and `log` do. Takes no lease, so
11
+ * Reads the whole repo the way `list`, `show` and `log` do. Takes no lease, so
12
12
  * serving a page cannot knock an agent off the tree it is driving.
13
13
  */
14
14
  export interface SnapshotOptions {