@webappwiz/arbor 0.0.14 → 0.0.15

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
@@ -23,7 +23,7 @@ Two rules make that safe:
23
23
 
24
24
  Conflicts between agents are expected, not a process failure. Discarding a task
25
25
  and redoing it against current trunk is cheap and often better than a hard
26
- rebase, and that is what `rm` is for.
26
+ rebase, and that is what `remove` is for.
27
27
 
28
28
  ## Commands
29
29
 
@@ -78,7 +78,7 @@ linear.
78
78
  a `--base task/<other>` task. Git allows one worktree per branch, and that
79
79
  tree is the only place the branch can move: advancing the ref behind its
80
80
  back would leave its index and files on the old commit.
81
- 5. Discards the task (worktree, branch and record) exactly as `rm`
81
+ 5. Discards the task (worktree, branch and record) exactly as `remove`
82
82
  would. The work is on trunk, so the tree has nothing left to hold, and
83
83
  `arbor list` stays a list of live work rather than a graveyard of landed
84
84
  tasks. The agent's own directory goes with it, so the success message
@@ -91,7 +91,7 @@ fails the branch is reset to where it was and trunk is never touched.
91
91
  There is deliberately no flag to skip the gate: a repo that wants none
92
92
  configures none.
93
93
 
94
- ### `arbor rm <task>`
94
+ ### `arbor remove <task>`
95
95
 
96
96
  Discards a task: `git worktree remove` plus the branch and the record.
97
97
 
@@ -100,7 +100,7 @@ discards its own tree. Use it freely. Warns about commits that never landed,
100
100
  but never blocks: throwing work away is the cheap escape hatch, not a last
101
101
  resort.
102
102
 
103
- Removal leaves a tombstone in `.git/arbor/removed/` so a second `rm` can say
103
+ Removal leaves a tombstone in `.git/arbor/removed/` so a second `remove` can say
104
104
  `already_removed` rather than `not_found`. The ledger keeps the 50 most recent
105
105
  and drops the oldest as new ones arrive, so a long-forgotten task reports
106
106
  `not_found` again.
@@ -160,7 +160,7 @@ agent off it.
160
160
 
161
161
  ### `arbor log [--count 20] [--json]`
162
162
 
163
- The last N things done here (`add`, `claim`, `merge`, `rm`, `escalate`,
163
+ The last N things done here (`add`, `claim`, `merge`, `remove`, `escalate`,
164
164
  `retry`),
165
165
  oldest first, each with the task and how it ended (`ok`, or the refusal reason).
166
166
 
@@ -172,7 +172,7 @@ WHEN ACTION TASK RESULT
172
172
  ```
173
173
 
174
174
  `list` is what still exists; this is what happened. Entries outlive their tasks:
175
- a successful `merge` and a `rm` both take the record with them, so this is
175
+ a successful `merge` and a `remove` both take the record with them, so this is
176
176
  the only thing that remembers a task landed at all. The last 200 are kept
177
177
  (`logCapacity`) in `.git/arbor/log.jsonl`.
178
178
 
@@ -218,7 +218,7 @@ correct merge, only a decision.
218
218
  ### `arbor retry <task>`
219
219
 
220
220
  Grants an escalated task another `mergeRetryCount` merge attempts and puts it
221
- back to `working`. The way out of `budget_exhausted` that is not `rm` and redo,
221
+ back to `working`. The way out of `budget_exhausted` that is not `remove` and redo,
222
222
  for the case where the task was one fix away rather than genuinely lost.
223
223
 
224
224
  Only from `escalated`, and that is the whole design. The budget exists to make
@@ -239,13 +239,13 @@ The agent's control flow runs on these.
239
239
  | 2 | `conflict` | Rebase conflicted. **Rebase is still in progress.** Resolve, `git add`, `git rebase --continue`, merge again. |
240
240
  | 3 | `tests_failed` | The gate (`postRewrite`, `preMerge`) failed after the rebase. Branch rolled back, trunk untouched. Fix and merge again. |
241
241
  | 4 | `lease_lost` | Another agent took the tree mid-merge. **Stop. Do not retry.** |
242
- | 5 | `budget_exhausted` | Out of merge attempts. `arbor escalate`, and a human can grant another budget with `arbor retry`; or `arbor rm` and redo against current trunk. |
242
+ | 5 | `budget_exhausted` | Out of merge attempts. `arbor escalate`, and a human can grant another budget with `arbor retry`; or `arbor remove` and redo against current trunk. |
243
243
  | 6 | `lease_held` | Another agent is driving this tree. |
244
244
  | 7 | `dirty` | Uncommitted changes. Commit before merging. |
245
245
  | 8 | `not_found` | No such task, or not run from a task worktree. |
246
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). |
247
- | 10 | `exists` | Task already exists. `arbor claim` it, or `arbor rm` first. |
248
- | 11 | `orphaned` | Record with no worktree. `arbor rm` it. |
247
+ | 10 | `exists` | Task already exists. `arbor claim` it, or `arbor remove` first. |
248
+ | 11 | `orphaned` | Record with no worktree. `arbor remove` it. |
249
249
  | 12 | `merge_failed` | The base could not be fast-forwarded (usually uncommitted changes in the worktree holding it). |
250
250
  | 13 | `already_removed` | This task was removed earlier; nothing left to remove. |
251
251
  | 14 | `timeout` | `arbor wait` gave up: the task is still working or merging. |
@@ -270,7 +270,7 @@ export default defineConfig({
270
270
  postMerge: "bun install", // in the main tree, after the branch lands
271
271
  leaseStalenessMs: 90_000,
272
272
  mergeRetryCount: 2,
273
- removedCapacity: 50, // removed names kept, so rm can say "already removed"
273
+ removedCapacity: 50, // removed names kept, so remove can say "already removed"
274
274
  logCapacity: 200, // entries `arbor log` keeps before the oldest fall off
275
275
  });
276
276
  ```
@@ -330,5 +330,5 @@ other. Git still leaves the file staged as `UU`, so the agent must confirm with
330
330
  exists because of a real livelock: an agent rebases onto trunk, another agent
331
331
  lands during its test run, and it is stale again before it finishes. Under load
332
332
  an unlucky agent can chase a moving trunk indefinitely. When the budget is gone,
333
- escalate or `arbor rm`: redoing the task against current trunk usually beats
333
+ escalate or `arbor remove`: redoing the task against current trunk usually beats
334
334
  retrofitting a rebase.
package/config.d.ts CHANGED
@@ -33,7 +33,7 @@ export interface Config {
33
33
  /** Failed `merge` attempts a task gets before it must escalate or be removed. */
34
34
  mergeRetryCount: number;
35
35
  /**
36
- * How many removed task names to keep, so `rm` can say "already removed"
36
+ * How many removed task names to keep, so `remove` can say "already removed"
37
37
  * rather than "never existed". A flat cap with no age policy: losing the
38
38
  * oldest costs a nicer message and nothing else.
39
39
  */
package/index.js CHANGED
@@ -77,7 +77,7 @@ async function add({
77
77
  }
78
78
  const found = await service.find(task);
79
79
  if (found.status === "stray") {
80
- fail("exists", `branch ${found.branch} exists without a worktree: run \`arbor rm ${task}\` first`, { task, branch: found.branch });
80
+ fail("exists", `branch ${found.branch} exists without a worktree: run \`arbor remove ${task}\` first`, { task, branch: found.branch });
81
81
  }
82
82
  if (!found.gone) {
83
83
  fail("exists", `task '${task}' already exists: run \`arbor claim ${task}\``, { task, worktree: found.path });
@@ -129,10 +129,10 @@ async function claim({ service, log }, task) {
129
129
  });
130
130
  }
131
131
  if (found.status === "orphaned") {
132
- fail("orphaned", `state file for '${task}' has no worktree at ${found.path}: run \`arbor rm ${task}\``, { task, worktree: found.path });
132
+ fail("orphaned", `state file for '${task}' has no worktree at ${found.path}: run \`arbor remove ${task}\``, { task, worktree: found.path });
133
133
  }
134
134
  if (found.status === "stray") {
135
- fail("orphaned", `branch ${found.branch} exists but has no worktree: run \`arbor rm ${task}\` and start over`, { task, branch: found.branch });
135
+ fail("orphaned", `branch ${found.branch} exists but has no worktree: run \`arbor remove ${task}\` and start over`, { task, branch: found.branch });
136
136
  }
137
137
  if (found.leaseHeldByOther) {
138
138
  fail("lease_held", `'${task}' is held by pid ${found.lease?.pid} on ${found.lease?.hostname} (heartbeat ${found.lease?.heartbeatAt}): another agent is driving this tree`, { task, lease: found.lease });
@@ -555,7 +555,7 @@ function listing(rows) {
555
555
  ];
556
556
  const orphaned = rows.filter((row3) => row3.status === "orphaned");
557
557
  if (orphaned.length > 0) {
558
- out.push("", color7.yellow(`${orphaned.length} orphaned record(s): run \`arbor rm ${orphaned[0]?.task}\``));
558
+ out.push("", color7.yellow(`${orphaned.length} orphaned record(s): run \`arbor remove ${orphaned[0]?.task}\``));
559
559
  }
560
560
  return out.join(`
561
561
  `);
@@ -587,7 +587,7 @@ async function merge({
587
587
  fail("dirty", `'${task}' has uncommitted changes: commit them before merging`, { task, paths: dirty });
588
588
  }
589
589
  if (worktree.mergeAttempts >= config.mergeRetryCount) {
590
- fail("budget_exhausted", `'${task}' has used its ${config.mergeRetryCount} merge attempts: run \`arbor escalate <reason>\`, and a human can grant another ${config.mergeRetryCount} with \`arbor retry ${task}\`; or \`arbor rm ${task}\` and start over against current ${base}`, { task, mergeAttempts: worktree.mergeAttempts });
590
+ fail("budget_exhausted", `'${task}' has used its ${config.mergeRetryCount} merge attempts: run \`arbor escalate <reason>\`, and a human can grant another ${config.mergeRetryCount} with \`arbor retry ${task}\`; or \`arbor remove ${task}\` and start over against current ${base}`, { task, mergeAttempts: worktree.mergeAttempts });
591
591
  }
592
592
  if (worktree.leaseHeldByOther) {
593
593
  fail("lease_held", `'${task}' is held by pid ${worktree.lease?.pid} on ${worktree.lease?.hostname}: another agent is driving this tree`, { task, lease: worktree.lease });
@@ -608,7 +608,7 @@ async function merge({
608
608
  "The rebase is still in progress. Resolve the conflicts, `git add` them,",
609
609
  "`git rebase --continue`, then run `arbor merge` again.",
610
610
  "If both sides restructured the same logic, prefer `arbor escalate <reason>`",
611
- `or \`arbor rm ${task}\` and redo the task against current ${base}.`
611
+ `or \`arbor remove ${task}\` and redo the task against current ${base}.`
612
612
  ].join(`
613
613
  `), { task, paths });
614
614
  }
@@ -661,7 +661,7 @@ ${gated.stderr}`)
661
661
  await lock.release();
662
662
  if (discarded.code !== 0) {
663
663
  fail("usage", `landed '${task}' on ${base} (${head}) but could not discard its worktree: ${discarded.stderr || discarded.stdout}
664
- Run \`arbor rm ${task}\` to clean up.`, { task });
664
+ Run \`arbor remove ${task}\` to clean up.`, { task });
665
665
  }
666
666
  if (config.postMerge) {
667
667
  const ran = await shell.run(config.postMerge, landing, {
@@ -712,6 +712,40 @@ async function path({ service, log: log2 }, task) {
712
712
  log2.info(worktree.path);
713
713
  }
714
714
 
715
+ // remove.ts
716
+ import { color as color9 } from "webappwiz/log";
717
+ async function remove({ service, log: log2 }, task, { force = false } = {}) {
718
+ const worktree = await service.find(task);
719
+ if (worktree.gone) {
720
+ const removed = worktree.status === "removed";
721
+ fail(removed ? "already_removed" : "not_found", removed ? `'${task}' was already removed (${worktree.removedAt}), nothing left to remove` : `no worktree, branch or state file named '${task}', so it never existed here`, { task });
722
+ }
723
+ if (worktree.leaseHeldByOther) {
724
+ if (!force) {
725
+ fail("lease_held", `'${task}' is held by pid ${worktree.lease?.pid} on ${worktree.lease?.hostname}: pass --force to discard it anyway`, { task, lease: worktree.lease });
726
+ }
727
+ log2.error(color9.yellow(`arbor: --force discarding a tree held by pid ${worktree.lease?.pid}`));
728
+ }
729
+ const unlanded = worktree.hasBranch ? await worktree.commitsAhead() : 0;
730
+ const discarded = await worktree.discard();
731
+ if (discarded.code !== 0) {
732
+ fail("usage", `discarding '${task}' failed: ${discarded.stderr}`, {
733
+ task
734
+ });
735
+ }
736
+ const lines = [
737
+ `${color9.green("removed")} ${task}`,
738
+ ` worktree: ${worktree.exists ? worktree.path : "already gone"}`,
739
+ ` branch: ${worktree.hasBranch ? worktree.branch : "already gone"}`,
740
+ ` state: ${worktree.state ? "removed" : "already gone"}`
741
+ ];
742
+ if (unlanded) {
743
+ lines.push(color9.yellow(` discarded ${unlanded} commit(s) that were never on ${worktree.base}`));
744
+ }
745
+ log2.info(lines.join(`
746
+ `));
747
+ }
748
+
715
749
  // repository.ts
716
750
  import { dirname } from "node:path";
717
751
  import { FileLock } from "webappwiz/system";
@@ -1246,7 +1280,7 @@ function repository(at) {
1246
1280
  }
1247
1281
 
1248
1282
  // retry.ts
1249
- import { color as color9 } from "webappwiz/log";
1283
+ import { color as color10 } from "webappwiz/log";
1250
1284
  async function retry({
1251
1285
  service,
1252
1286
  config,
@@ -1261,7 +1295,7 @@ async function retry({
1261
1295
  }
1262
1296
  const worktree = await found.save({ status: "working", mergeAttempts: 0 });
1263
1297
  log2.info([
1264
- `${color9.green("retry")} ${worktree.task}`,
1298
+ `${color10.green("retry")} ${worktree.task}`,
1265
1299
  ` attempts: 0 of ${config.mergeRetryCount}`,
1266
1300
  ` status: working`,
1267
1301
  "",
@@ -1270,40 +1304,6 @@ async function retry({
1270
1304
  `));
1271
1305
  }
1272
1306
 
1273
- // rm.ts
1274
- import { color as color10 } from "webappwiz/log";
1275
- async function rm({ service, log: log2 }, task, { force = false } = {}) {
1276
- const worktree = await service.find(task);
1277
- if (worktree.gone) {
1278
- const removed = worktree.status === "removed";
1279
- fail(removed ? "already_removed" : "not_found", removed ? `'${task}' was already removed (${worktree.removedAt}), nothing left to remove` : `no worktree, branch or state file named '${task}', so it never existed here`, { task });
1280
- }
1281
- if (worktree.leaseHeldByOther) {
1282
- if (!force) {
1283
- fail("lease_held", `'${task}' is held by pid ${worktree.lease?.pid} on ${worktree.lease?.hostname}: pass --force to discard it anyway`, { task, lease: worktree.lease });
1284
- }
1285
- log2.error(color10.yellow(`arbor: --force discarding a tree held by pid ${worktree.lease?.pid}`));
1286
- }
1287
- const unlanded = worktree.hasBranch ? await worktree.commitsAhead() : 0;
1288
- const discarded = await worktree.discard();
1289
- if (discarded.code !== 0) {
1290
- fail("usage", `discarding '${task}' failed: ${discarded.stderr}`, {
1291
- task
1292
- });
1293
- }
1294
- const lines2 = [
1295
- `${color10.green("removed")} ${task}`,
1296
- ` worktree: ${worktree.exists ? worktree.path : "already gone"}`,
1297
- ` branch: ${worktree.hasBranch ? worktree.branch : "already gone"}`,
1298
- ` state: ${worktree.state ? "removed" : "already gone"}`
1299
- ];
1300
- if (unlanded) {
1301
- lines2.push(color10.yellow(` discarded ${unlanded} commit(s) that were never on ${worktree.base}`));
1302
- }
1303
- log2.info(lines2.join(`
1304
- `));
1305
- }
1306
-
1307
1307
  // wait.ts
1308
1308
  import { color as color11 } from "webappwiz/log";
1309
1309
  import { Duration as Duration2, sleep } from "webappwiz/time";
@@ -1349,10 +1349,10 @@ arbor.command("add").description("start a new task: create branch task/<task>, a
1349
1349
  }).action((opts, ctx) => ctx.journal.record("add", opts.task, () => add(ctx, opts.task, { base: opts.base || undefined })));
1350
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)));
1351
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())));
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(), {
1352
+ arbor.command("remove").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(), {
1353
1353
  default: false,
1354
1354
  description: "discard even when another agent holds the lease"
1355
- }).action((opts, ctx) => ctx.journal.record("rm", opts.task, () => rm(ctx, opts.task, { force: opts.force })));
1355
+ }).action((opts, ctx) => ctx.journal.record("remove", opts.task, () => remove(ctx, opts.task, { force: opts.force })));
1356
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
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 }));
1358
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(), {
@@ -1361,7 +1361,7 @@ arbor.command("wait").description("block until a task stops moving: escalated, o
1361
1361
  }).action((opts, ctx) => wait(ctx, opts.task, {
1362
1362
  timeout: Duration3.secs(opts["timeout-secs"])
1363
1363
  }));
1364
- arbor.command("log").description("show what has been done here recently: one line per add, claim, merge, rm, escalate and retry, with how it ended; outlives the tasks themselves").option("count", t.number(), {
1364
+ arbor.command("log").description("show what has been done here recently: one line per add, claim, merge, remove, escalate and retry, with how it ended; outlives the tasks themselves").option("count", t.number(), {
1365
1365
  default: DEFAULT_COUNT,
1366
1366
  description: "how many entries to show"
1367
1367
  }).option("json", t.boolean(), { default: false, description: "emit JSON" }).action((opts, ctx) => log(ctx, { count: opts.count, json: opts.json }));
@@ -1379,7 +1379,7 @@ arbor.command("escalate").description("hand this task to a human and stop: recor
1379
1379
  }).action(async (opts, ctx) => ctx.journal.record("escalate", opts.task || await here(ctx), () => escalate(ctx, opts.reason, ctx.ps.cwd(), {
1380
1380
  task: opts.task || undefined
1381
1381
  })));
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)));
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 remove 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
1383
  async function here({
1384
1384
  service,
1385
1385
  git,
package/journal.d.ts CHANGED
@@ -10,7 +10,7 @@ export interface Entry {
10
10
  }
11
11
  /**
12
12
  * What has been done in this repo, oldest first. Entries outlive their tasks:
13
- * `merge` and `rm` take the record with them, so this is the only thing that
13
+ * `merge` and `remove` take the record with them, so this is the only thing that
14
14
  * remembers a task existed at all.
15
15
  */
16
16
  /** What a `Journal` is stored through; the real filesystem by default. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webappwiz/arbor",
3
- "version": "0.0.14",
3
+ "version": "0.0.15",
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.14"
20
+ "webappwiz": "^0.0.15"
21
21
  },
22
22
  "main": "./index.js",
23
23
  "types": "./index.d.ts",
@@ -1,6 +1,6 @@
1
1
  import { type Logger } from "webappwiz/log";
2
2
  import type { WorktreeService } from "./worktree-service.js";
3
- export interface RmOptions {
3
+ export interface RemoveOptions {
4
4
  /** Discard the tree even when another agent holds its lease. */
5
5
  force?: boolean;
6
6
  }
@@ -11,7 +11,7 @@ export interface RmOptions {
11
11
  * Throwing a task away and redoing it against current trunk is usually cheaper
12
12
  * than a hard rebase, so this is meant to be used freely.
13
13
  */
14
- export declare function rm({ service, log }: {
14
+ export declare function remove({ service, log }: {
15
15
  service: WorktreeService;
16
16
  log: Logger;
17
- }, task: string, { force }?: RmOptions): Promise<void>;
17
+ }, task: string, { force }?: RemoveOptions): Promise<void>;
package/retry.d.ts CHANGED
@@ -3,7 +3,7 @@ import type { Config } from "./config.js";
3
3
  import type { WorktreeService } from "./worktree-service.js";
4
4
  /**
5
5
  * The way back from `budget_exhausted`. A task that has spent its merge
6
- * attempts gets another `mergeRetryCount` of them, instead of `rm` and redo.
6
+ * attempts gets another `mergeRetryCount` of them, instead of `remove` and redo.
7
7
  *
8
8
  * Only from `escalated`, and deliberately: the budget's whole job is to make an
9
9
  * agent stop and hand the task over. An agent that could grant itself more
@@ -43,7 +43,7 @@ export declare class WorktreeService {
43
43
  private removedPath;
44
44
  private removedAt;
45
45
  /**
46
- * Remembers a removed name so a second `rm` can say "already removed"
46
+ * Remembers a removed name so a second `remove` can say "already removed"
47
47
  * instead of "never existed", then drops the oldest so the list of
48
48
  * remembered names cannot grow without bound.
49
49
  */