@webappwiz/arbor 0.0.7 → 0.0.9

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
@@ -131,6 +131,23 @@ prescribes (`# <task>`, `## Goal`, `## Next` with something unchecked in it, a
131
131
  Warnings only, never a refusal: the agent that wrote the file is the one that
132
132
  runs `show` on it, and a rough plan still beats none.
133
133
 
134
+ ### `arbor wait <task> [--timeout-secs 300]`
135
+
136
+ Blocks until a task stops moving, then prints where it stopped.
137
+
138
+ Moving means `working` or `merging`. Everything else is somewhere it stays
139
+ without a person: `escalated` (printed with its reason), `removed` (merged or
140
+ discarded, and `arbor log` says which), or one of the broken statuses.
141
+
142
+ This is for the agent whose own work overlaps a task already in flight and
143
+ would rather rebase onto its result than against it. The timeout is short by
144
+ design: five minutes, and then a `timeout` refusal that hands the decision
145
+ back, rather than a session that blocks all afternoon on a tree nobody is
146
+ driving. Wait again, work alongside it, or ask the human.
147
+
148
+ Like `show` and `path`, it takes no lease, so watching a task cannot knock its
149
+ agent off it.
150
+
134
151
  ### `arbor log [--count 20] [--json]`
135
152
 
136
153
  The last N things done here (`add`, `claim`, `merge`, `rm`, `escalate`,
@@ -221,6 +238,7 @@ The agent's control flow runs on these.
221
238
  | 11 | `orphaned` | Record with no worktree. `arbor rm` it. |
222
239
  | 12 | `merge_failed` | Trunk could not be fast-forwarded (usually a dirty main worktree). |
223
240
  | 13 | `already_removed` | This task was removed earlier; nothing left to remove. |
241
+ | 14 | `timeout` | `arbor wait` gave up: the task is still working or merging. |
224
242
 
225
243
  Every failure prints a one-line JSON object on **stdout** (`{"reason": ...}`,
226
244
  plus fields like `paths` for conflicts) and the human explanation on **stderr**.
package/config.d.ts CHANGED
@@ -1,49 +1,26 @@
1
- export interface Config {
2
- /**
3
- * The integration branch name. `merge` rebases a task onto this branch and
4
- * then fast-forwards it to the result; new worktrees start from it.
5
- */
1
+ import { type InferConfig } from "webappwiz/config";
2
+ /**
3
+ * The settings arbor runs a repo with. `loadConfig` builds one; everything a
4
+ * command needs comes off it with `get`.
5
+ */
6
+ export declare const CONFIG_FACTORY: import("webappwiz/config").ConfigFactory<{
6
7
  trunk: string;
7
- /** Directory holding one worktree per task, a sibling of the repo. */
8
8
  worktreeRoot: string;
9
- /** Command run by `add` in the new worktree, via `sh -c`. */
10
9
  postCheckout: string | null;
11
- /**
12
- * Command run by `merge` after the rebase, before `preMerge`, via `sh -c`.
13
- * A rebase can bring in a dependency the worktree has never installed, and
14
- * whatever `preMerge` runs needs it.
15
- */
16
10
  postRewrite: string | null;
17
- /**
18
- * Command run by `merge` after the rebase, via `sh -c`. The last gate before
19
- * the branch lands: a nonzero exit rolls the branch back and leaves the base
20
- * untouched. Tests are the obvious thing to put here, but arbor has no
21
- * opinion; a repo with nothing to run leaves it null and merges on a green
22
- * rebase alone.
23
- */
24
11
  preMerge: string | null;
25
- /**
26
- * Command run by `merge` in the main tree after the base fast-forwards, via
27
- * `sh -c`. The branch has already landed and the worktree is gone: a nonzero
28
- * exit reports the failure but rolls nothing back.
29
- */
30
12
  postMerge: string | null;
31
- /** How long since its last heartbeat before a task's lease is up for grabs. */
32
13
  leaseStalenessMs: number;
33
- /** Failed `merge` attempts a task gets before it must escalate or be removed. */
34
14
  mergeRetryCount: number;
35
- /**
36
- * How many removed task names to keep, so `rm` can say "already removed"
37
- * rather than "never existed". A flat cap with no age policy: losing the
38
- * oldest costs a nicer message and nothing else.
39
- */
40
15
  removedCapacity: number;
41
- /** How many entries `arbor log` keeps before the oldest fall off. */
42
16
  logCapacity: number;
43
- }
17
+ }>;
18
+ export type Config = InferConfig<typeof CONFIG_FACTORY>;
19
+ /** The plain shape behind a `Config`: what a config file exports a part of. */
20
+ export type ConfigRecord = ReturnType<Config["toRecord"]>;
44
21
  /**
45
22
  * Identity, for the types. `export default defineConfig({ ... })` in
46
23
  * `arbor.config.ts` gets the key names and their types checked, and completion
47
24
  * while writing it; a bare object literal gets neither.
48
25
  */
49
- export declare function defineConfig(config: Partial<Config>): Partial<Config>;
26
+ export declare function defineConfig(config: Partial<ConfigRecord>): Partial<ConfigRecord>;
package/config.js CHANGED
@@ -1,7 +1,8 @@
1
- // config.ts
2
- function defineConfig(config) {
3
- return config;
4
- }
5
- export {
1
+ import {
2
+ CONFIG_FACTORY,
6
3
  defineConfig
4
+ } from "./index-84xjmns8.js";
5
+ export {
6
+ defineConfig,
7
+ CONFIG_FACTORY
7
8
  };
package/dev.d.ts CHANGED
@@ -1,10 +1,24 @@
1
1
  import type { HttpServer } from "webappwiz/http";
2
2
  import type { Logger } from "webappwiz/log";
3
- import type { Fs } from "webappwiz/system";
3
+ import { type Fs, type PortProvider } from "webappwiz/system";
4
4
  import type { Assets } from "./dev/assets.js";
5
5
  import type { Journal } from "./journal.js";
6
6
  import type { WorktreeService } from "./worktree-service.js";
7
+ /** Preferred, not required: `dev` moves up from here when it is taken. */
7
8
  export declare const DEFAULT_PORT = 4269;
9
+ /** How far above the port asked for `dev` will look before giving up. */
10
+ 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
+ /** What `dev` lets a caller choose. */
18
+ export interface DevOptions {
19
+ /** Where to listen; the port `--port` asked for, and the span above it. */
20
+ ports?: PortProvider;
21
+ }
8
22
  /** A running server, and the one thing a caller ever wants to do with it. */
9
23
  export interface DevServer {
10
24
  port: number;
@@ -26,6 +40,4 @@ export declare function dev({ service, fs, journal, log, http, assets, }: {
26
40
  log: Logger;
27
41
  http: HttpServer;
28
42
  assets: Assets;
29
- }, { port }?: {
30
- port?: number | undefined;
31
- }): Promise<DevServer>;
43
+ }, { ports }?: DevOptions): Promise<DevServer>;
package/exit.d.ts CHANGED
@@ -14,6 +14,7 @@ export declare const EXIT: {
14
14
  readonly orphaned: 11;
15
15
  readonly merge_failed: 12;
16
16
  readonly already_removed: 13;
17
+ readonly timeout: 14;
17
18
  };
18
19
  export type Reason = keyof typeof EXIT;
19
20
  /**
@@ -0,0 +1,20 @@
1
+ // config.ts
2
+ import { Config as WizConfig } from "webappwiz/config";
3
+ import { t } from "webappwiz/t";
4
+ var CONFIG_FACTORY = WizConfig.factory({
5
+ trunk: t.string(),
6
+ worktreeRoot: t.string(),
7
+ postCheckout: t.nullable(t.string()),
8
+ postRewrite: t.nullable(t.string()),
9
+ preMerge: t.nullable(t.string()),
10
+ postMerge: t.nullable(t.string()),
11
+ leaseStalenessMs: t.number(),
12
+ mergeRetryCount: t.number(),
13
+ removedCapacity: t.number(),
14
+ logCapacity: t.number()
15
+ });
16
+ function defineConfig(config) {
17
+ return config;
18
+ }
19
+
20
+ export { CONFIG_FACTORY, defineConfig };
package/index.js CHANGED
@@ -1,5 +1,8 @@
1
1
  #!/usr/bin/env bun
2
2
  // @bun
3
+ import {
4
+ CONFIG_FACTORY
5
+ } from "./index-84xjmns8.js";
3
6
 
4
7
  // index.ts
5
8
  import { BunHttpServer } from "webappwiz/http/bun";
@@ -8,6 +11,7 @@ import { NodeFs as NodeFs7 } from "webappwiz/system";
8
11
  // arbor.ts
9
12
  import { cli } from "webappwiz/cmd";
10
13
  import { t } from "webappwiz/t";
14
+ import { Duration as Duration3 } from "webappwiz/time";
11
15
 
12
16
  // add.ts
13
17
  import { color } from "webappwiz/log";
@@ -27,7 +31,8 @@ var EXIT = {
27
31
  exists: 10,
28
32
  orphaned: 11,
29
33
  merge_failed: 12,
30
- already_removed: 13
34
+ already_removed: 13,
35
+ timeout: 14
31
36
  };
32
37
 
33
38
  class Exit extends Error {
@@ -60,14 +65,14 @@ function exits() {
60
65
  // add.ts
61
66
  var NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
62
67
  var PLAN_FILE = "ARBOR.md";
63
- var PLAN = (task) => new MarkdownWriter().heading(1, task).heading(2, "Goal").heading(2, "Next").checklist("fill in Goal and list the steps here as `- [ ]` items").toString();
68
+ 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();
64
69
  async function add({
65
70
  service,
66
71
  shell,
67
72
  config,
68
73
  log,
69
74
  fs
70
- }, task, { base = config.trunk } = {}) {
75
+ }, task, { base = config.get("trunk") } = {}) {
71
76
  if (!NAME.test(task)) {
72
77
  fail("usage", `invalid task name '${task}': use lowercase letters, digits and dashes`, { task });
73
78
  }
@@ -83,7 +88,7 @@ async function add({
83
88
  }
84
89
  const added = await service.add(task, { base });
85
90
  if (added.code !== 0) {
86
- const missingTrunk = base === config.trunk && !await service.git.branchExists(base);
91
+ const missingTrunk = base === config.get("trunk") && !await service.git.branchExists(base);
87
92
  fail("usage", missingTrunk ? `trunk '${base}' is not a branch in this repo: set \`trunk\` in arbor.config.ts` : `git worktree add failed: ${added.stderr}`, { task });
88
93
  }
89
94
  const worktree = await (await service.find(task)).take({ base });
@@ -97,13 +102,14 @@ ${PLAN_FILE}
97
102
  `.trimStart());
98
103
  }
99
104
  await fs.write(`${worktree.path}/${PLAN_FILE}`, PLAN(task));
100
- if (config.postCheckout) {
101
- const { exitCode } = await shell.stream(config.postCheckout, {
105
+ const postCheckout = config.get("postCheckout");
106
+ if (postCheckout) {
107
+ const { exitCode } = await shell.stream(postCheckout, {
102
108
  cwd: worktree.path,
103
109
  env: {
104
110
  ARBOR_TASK: task,
105
111
  ARBOR_WORKTREE: worktree.path,
106
- ARBOR_TRUNK: config.trunk
112
+ ARBOR_TRUNK: config.get("trunk")
107
113
  }
108
114
  });
109
115
  if (exitCode !== 0) {
@@ -159,6 +165,10 @@ ${changes.map((change) => ` ${change}`).join(`
159
165
  }
160
166
 
161
167
  // dev.ts
168
+ import {
169
+ MAX_PORT,
170
+ OpenPortProvider
171
+ } from "webappwiz/system";
162
172
  import { Duration } from "webappwiz/time";
163
173
 
164
174
  // snapshot.ts
@@ -219,7 +229,7 @@ import { NodeFs } from "webappwiz/system";
219
229
  // plan.ts
220
230
  import { Markdown } from "webappwiz/md";
221
231
  var SECTIONS = ["Goal", "Files", "Done", "Next", "Notes", "Blocked"];
222
- var REQUIRED = ["Goal", "Next"];
232
+ var REQUIRED = ["Goal", "Files", "Next"];
223
233
  var UNCHECKED = /^[ \t]*- \[ \]/m;
224
234
  function checkPlan(text, { task, escalated = false }) {
225
235
  const md = Markdown.parse(text);
@@ -236,6 +246,9 @@ function checkPlan(text, { task, escalated = false }) {
236
246
  if (section("Goal") === "") {
237
247
  problems.push("## Goal is empty: say in a line or two what done means");
238
248
  }
249
+ if (section("Files") === "") {
250
+ problems.push("## Files is empty: list the file paths you plan to touch");
251
+ }
239
252
  const next = section("Next");
240
253
  if (next !== null && !UNCHECKED.test(next)) {
241
254
  problems.push("## Next has no `- [ ]` item: say what the next step is");
@@ -358,6 +371,15 @@ function fingerprint({ tasks, entries }) {
358
371
 
359
372
  // dev.ts
360
373
  var DEFAULT_PORT = 4269;
374
+ var PORT_SPAN = 20;
375
+ function devPorts(from) {
376
+ if (!Number.isInteger(from) || from < 0 || from > MAX_PORT) {
377
+ fail("usage", `invalid port '${from}': use a whole number 0 to ${MAX_PORT}`, {
378
+ port: from
379
+ });
380
+ }
381
+ return OpenPortProvider.span({ from, span: PORT_SPAN });
382
+ }
361
383
  var POLL_MS = 2000;
362
384
  async function dev({
363
385
  service,
@@ -366,7 +388,7 @@ async function dev({
366
388
  log: log2,
367
389
  http,
368
390
  assets
369
- }, { port = DEFAULT_PORT } = {}) {
391
+ }, { ports = devPorts(DEFAULT_PORT) } = {}) {
370
392
  const open = new Set;
371
393
  const encoder = new TextEncoder;
372
394
  let last = fingerprint(await snapshot(service, journal, { fs }));
@@ -428,7 +450,7 @@ async function dev({
428
450
  default:
429
451
  return new Response("not found", { status: 404 });
430
452
  }
431
- }, { port, idleTimeout: Duration.zero() });
453
+ }, { port: await ports.get(), idleTimeout: Duration.zero() });
432
454
  log2.info(`arbor dev on http://localhost:${listening.port}`);
433
455
  return {
434
456
  port: listening.port,
@@ -560,8 +582,8 @@ async function merge({
560
582
  if (dirty.length > 0) {
561
583
  fail("dirty", `'${task}' has uncommitted changes: commit them before merging`, { task, paths: dirty });
562
584
  }
563
- if (worktree.mergeAttempts >= config.mergeRetryCount) {
564
- 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 });
585
+ if (worktree.mergeAttempts >= config.get("mergeRetryCount")) {
586
+ fail("budget_exhausted", `'${task}' has used its ${config.get("mergeRetryCount")} merge attempts: run \`arbor escalate <reason>\`, and a human can grant another ${config.get("mergeRetryCount")} with \`arbor retry ${task}\`; or \`arbor rm ${task}\` and start over against current ${base}`, { task, mergeAttempts: worktree.mergeAttempts });
565
587
  }
566
588
  if (worktree.leaseHeldByOther) {
567
589
  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 });
@@ -586,7 +608,7 @@ async function merge({
586
608
  ].join(`
587
609
  `), { task, paths });
588
610
  }
589
- const gate = [config.postRewrite, config.preMerge].filter(Boolean).join(" && ");
611
+ const gate = [config.get("postRewrite"), config.get("preMerge")].filter(Boolean).join(" && ");
590
612
  const gated = gate ? await shell.run(gate, {
591
613
  cwd: worktree.path,
592
614
  env: {
@@ -627,8 +649,9 @@ ${gated.stderr}`)
627
649
  fail("usage", `landed '${task}' on ${base} (${head}) but could not discard its worktree: ${discarded.stderr || discarded.stdout}
628
650
  Run \`arbor rm ${task}\` to clean up.`, { task });
629
651
  }
630
- if (config.postMerge) {
631
- const ran = await shell.run(config.postMerge, {
652
+ const postMerge = config.get("postMerge");
653
+ if (postMerge) {
654
+ const ran = await shell.run(postMerge, {
632
655
  cwd: git.root,
633
656
  env: { ARBOR_TASK: task }
634
657
  });
@@ -852,7 +875,7 @@ import { NodeFs as NodeFs5 } from "webappwiz/system";
852
875
  async function loadConfig(root, opts = {}) {
853
876
  const found = await file(opts.fs ?? new NodeFs5, root);
854
877
  const trunk = found.trunk ?? await (opts.git ?? new Git(root)).defaultBranch() ?? "main";
855
- return { ...defaults(root, trunk), ...found };
878
+ return CONFIG_FACTORY.create({ ...defaults(root, trunk), ...found });
856
879
  }
857
880
  function defaults(root, trunk) {
858
881
  return {
@@ -933,7 +956,7 @@ class Worktree {
933
956
  if (!lease) {
934
957
  return false;
935
958
  }
936
- if (Date.now() - Date.parse(lease.heartbeatAt) >= config.leaseStalenessMs) {
959
+ if (Date.now() - Date.parse(lease.heartbeatAt) >= config.get("leaseStalenessMs")) {
937
960
  return false;
938
961
  }
939
962
  return lease.hostname === ps.hostname ? ps.alive(lease.pid) : true;
@@ -1054,10 +1077,10 @@ class WorktreeService {
1054
1077
  await this.fs.mkdir(this.removedDir);
1055
1078
  }
1056
1079
  get trunk() {
1057
- return this.config.trunk;
1080
+ return this.config.get("trunk");
1058
1081
  }
1059
1082
  pathFor(task) {
1060
- return resolve2(this.config.worktreeRoot, task);
1083
+ return resolve2(this.config.get("worktreeRoot"), task);
1061
1084
  }
1062
1085
  branchFor(task) {
1063
1086
  return `${BRANCH_PREFIX}${task}`;
@@ -1099,8 +1122,8 @@ class WorktreeService {
1099
1122
  }
1100
1123
  return found;
1101
1124
  }
1102
- async add(task, { base = this.config.trunk } = {}) {
1103
- await this.fs.mkdir(this.config.worktreeRoot);
1125
+ async add(task, { base = this.config.get("trunk") } = {}) {
1126
+ await this.fs.mkdir(this.config.get("worktreeRoot"));
1104
1127
  return this.git.addWorktree(this.branchFor(task), this.pathFor(task), base);
1105
1128
  }
1106
1129
  async discard(worktree) {
@@ -1137,7 +1160,7 @@ class WorktreeService {
1137
1160
  return raw?.trim() ?? null;
1138
1161
  }
1139
1162
  async rememberRemoved(task, at = new Date().toISOString()) {
1140
- const { removedCapacity } = this.config;
1163
+ const removedCapacity = this.config.get("removedCapacity");
1141
1164
  await this.fs.write(this.removedPath(task), `${at}
1142
1165
  `);
1143
1166
  const names = await this.fs.readdir(this.removedDir).catch(() => []);
@@ -1186,10 +1209,10 @@ function repository(at) {
1186
1209
  fs,
1187
1210
  ps,
1188
1211
  log: log2,
1189
- stalenessMs: config.leaseStalenessMs
1212
+ stalenessMs: config.get("leaseStalenessMs")
1190
1213
  }),
1191
1214
  shell: new Shell({ ps }),
1192
- journal: new Journal(`${arborDir}/log.jsonl`, config.logCapacity, {
1215
+ journal: new Journal(`${arborDir}/log.jsonl`, config.get("logCapacity"), {
1193
1216
  fs
1194
1217
  })
1195
1218
  });
@@ -1213,7 +1236,7 @@ async function retry({
1213
1236
  const worktree = await found.save({ status: "working", mergeAttempts: 0 });
1214
1237
  log2.info([
1215
1238
  `${color9.green("retry")} ${worktree.task}`,
1216
- ` attempts: 0 of ${config.mergeRetryCount}`,
1239
+ ` attempts: 0 of ${config.get("mergeRetryCount")}`,
1217
1240
  ` status: working`,
1218
1241
  "",
1219
1242
  `Run \`arbor claim ${worktree.task}\` to pick it up.`
@@ -1255,6 +1278,43 @@ async function rm({ service, log: log2 }, task, { force = false } = {}) {
1255
1278
  `));
1256
1279
  }
1257
1280
 
1281
+ // wait.ts
1282
+ import { color as color11 } from "webappwiz/log";
1283
+ import { Duration as Duration2, sleep } from "webappwiz/time";
1284
+ var DEFAULT_TIMEOUT = Duration2.mins(5);
1285
+ var POLL = Duration2.secs(2);
1286
+ var RUNNING = ["working", "merging"];
1287
+ async function wait({ service, log: log2 }, task, { timeout = DEFAULT_TIMEOUT, poll = POLL } = {}) {
1288
+ const deadline = Date.now() + timeout.ms;
1289
+ for (;; ) {
1290
+ const worktree = await service.find(task);
1291
+ if (worktree.status === "absent") {
1292
+ fail("not_found", `no task '${task}': run \`arbor ls\` to see what there is`, { task });
1293
+ }
1294
+ if (!RUNNING.includes(worktree.status)) {
1295
+ log2.info(report2(worktree));
1296
+ return;
1297
+ }
1298
+ const left = deadline - Date.now();
1299
+ if (left <= 0) {
1300
+ fail("timeout", `'${task}' is still ${worktree.status} after ${timeout.secs}s: wait again, work alongside it and accept the rebase, or ask the human`, { task, status: worktree.status });
1301
+ }
1302
+ await sleep(Duration2.min(poll, Duration2.ms(left)));
1303
+ }
1304
+ }
1305
+ function report2(worktree) {
1306
+ const lines2 = [`${color11.bold(worktree.task)} ${worktree.status}`];
1307
+ const escalation = worktree.state?.escalations?.at(-1)?.reason;
1308
+ if (escalation) {
1309
+ lines2.push(` ${color11.yellow(`escalated: ${escalation}`)}`);
1310
+ }
1311
+ if (worktree.status === "removed") {
1312
+ lines2.push("", `Nothing left of it here: \`arbor log\` says whether it landed.`);
1313
+ }
1314
+ return lines2.join(`
1315
+ `);
1316
+ }
1317
+
1258
1318
  // arbor.ts
1259
1319
  var here = async ({
1260
1320
  service,
@@ -1274,14 +1334,20 @@ arbor.command("rm").description("discard a task: worktree, branch and state file
1274
1334
  }).action((opts, ctx) => ctx.journal.record("rm", opts.task, () => rm(ctx, opts.task, { force: opts.force })));
1275
1335
  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 }));
1276
1336
  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 }));
1337
+ 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(), {
1338
+ default: DEFAULT_TIMEOUT.secs,
1339
+ description: "how long to wait before giving up"
1340
+ }).action((opts, ctx) => wait(ctx, opts.task, {
1341
+ timeout: Duration3.secs(opts["timeout-secs"])
1342
+ }));
1277
1343
  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(), {
1278
1344
  default: DEFAULT_COUNT,
1279
1345
  description: "how many entries to show"
1280
1346
  }).option("json", t.boolean(), { default: false, description: "emit JSON" }).action((opts, ctx) => log(ctx, { count: opts.count, json: opts.json }));
1281
1347
  arbor.command("dev").description("serve `ls`, `show` and `log` as a web page; read-only").option("port", t.number(), {
1282
1348
  default: DEFAULT_PORT,
1283
- description: "port to listen on"
1284
- }).action((opts, ctx) => dev(ctx, { port: opts.port }));
1349
+ description: "port to listen on, or the next open one above it"
1350
+ }).action((opts, ctx) => dev(ctx, { ports: devPorts(opts.port) }));
1285
1351
  arbor.command("path").description("print a task's worktree path, or the main tree with no task; names another agent's tree without taking its lease").arg("task", t.string(), {
1286
1352
  default: "",
1287
1353
  description: "task name; omit for the main tree"
package/load-config.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type Fs } from "webappwiz/system";
2
- import type { Config } from "./config.js";
2
+ import { type Config } from "./config.js";
3
3
  import { Git } from "./git.js";
4
4
  export interface LoadConfigOptions {
5
5
  /** What `arbor.config.ts` is looked for through; the real one by default. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webappwiz/arbor",
3
- "version": "0.0.7",
3
+ "version": "0.0.9",
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.7"
20
+ "webappwiz": "^0.0.9"
21
21
  },
22
22
  "main": "./index.js",
23
23
  "types": "./index.d.ts",
package/wait.d.ts ADDED
@@ -0,0 +1,22 @@
1
+ import { type Logger } from "webappwiz/log";
2
+ import { Duration } from "webappwiz/time";
3
+ import type { WorktreeService } from "./worktree-service.js";
4
+ /** How long `wait` gives a task before handing the wait back to its caller. */
5
+ export declare const DEFAULT_TIMEOUT: Duration;
6
+ export interface WaitOptions {
7
+ /** How long to wait before giving up. */
8
+ timeout?: Duration;
9
+ /** How long between reads of the record; the default suits a human's patience. */
10
+ poll?: Duration;
11
+ }
12
+ /**
13
+ * Blocks until a task stops moving: merged or removed (both leave the name
14
+ * `removed`), escalated to a human, or broken. Waiting is for the agent whose
15
+ * own work overlaps this one's and would rather rebase onto the result than
16
+ * against it, which is why the timeout is short enough to come back and think
17
+ * again rather than block a session for an afternoon.
18
+ */
19
+ export declare function wait({ service, log }: {
20
+ service: WorktreeService;
21
+ log: Logger;
22
+ }, task: string, { timeout, poll }?: WaitOptions): Promise<void>;