@webappwiz/arbor 0.0.28 → 0.0.30

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.
Files changed (119) hide show
  1. package/AGENTS.md +7 -0
  2. package/README.md +34 -12
  3. package/add.test.ts +125 -0
  4. package/add.ts +192 -0
  5. package/age.ts +11 -0
  6. package/arbor.test.ts +145 -0
  7. package/arbor.ts +429 -0
  8. package/assets.d.ts +9 -0
  9. package/attachments.ts +87 -0
  10. package/build.ts +98 -0
  11. package/claim.test.ts +89 -0
  12. package/claim.ts +73 -0
  13. package/components.json +21 -0
  14. package/config.ts +58 -0
  15. package/dev/api.ts +120 -0
  16. package/dev/app.test.tsx +645 -0
  17. package/dev/app.tsx +80 -0
  18. package/dev/assets.ts +16 -0
  19. package/dev/build/main.txt +68 -0
  20. package/dev/build/shell.txt +21 -0
  21. package/dev/build/styles.txt +2263 -0
  22. package/dev/components/ui/badge.tsx +51 -0
  23. package/dev/components/ui/button.tsx +57 -0
  24. package/dev/components/ui/dialog.tsx +154 -0
  25. package/dev/components/ui/empty.tsx +100 -0
  26. package/dev/components/ui/input-group.tsx +155 -0
  27. package/dev/components/ui/input.tsx +19 -0
  28. package/dev/components/ui/progress.tsx +80 -0
  29. package/dev/components/ui/textarea.tsx +17 -0
  30. package/dev/components/ui/toast.tsx +227 -0
  31. package/dev/feed.ts +69 -0
  32. package/dev/files.tsx +191 -0
  33. package/dev/index.html +21 -0
  34. package/dev/lib/utils.ts +7 -0
  35. package/dev/main.tsx +22 -0
  36. package/dev/markdown.test.tsx +133 -0
  37. package/dev/markdown.tsx +124 -0
  38. package/dev/mentions.tsx +319 -0
  39. package/dev/shadcn.css +649 -0
  40. package/dev/styles.css +107 -0
  41. package/dev/tags.tsx +44 -0
  42. package/dev/tasks.tsx +151 -0
  43. package/dev/todos.tsx +759 -0
  44. package/dev.test.ts +439 -0
  45. package/dev.ts +386 -0
  46. package/e2e.test.ts +256 -0
  47. package/escalate.test.ts +76 -0
  48. package/escalate.ts +112 -0
  49. package/exit.test.ts +71 -0
  50. package/exit.ts +74 -0
  51. package/git.ts +294 -0
  52. package/index.ts +9 -0
  53. package/journal.ts +89 -0
  54. package/list.test.ts +82 -0
  55. package/list.ts +137 -0
  56. package/load-config.test.ts +121 -0
  57. package/load-config.ts +62 -0
  58. package/log.test.ts +65 -0
  59. package/log.ts +40 -0
  60. package/merge.test.ts +332 -0
  61. package/merge.ts +283 -0
  62. package/package.json +32 -15
  63. package/path.test.ts +35 -0
  64. package/path.ts +38 -0
  65. package/plan.test.ts +318 -0
  66. package/plan.ts +302 -0
  67. package/progress.test.ts +54 -0
  68. package/progress.ts +31 -0
  69. package/remove.test.ts +68 -0
  70. package/remove.ts +88 -0
  71. package/repository.ts +83 -0
  72. package/retry.test.ts +43 -0
  73. package/retry.ts +48 -0
  74. package/shell.ts +44 -0
  75. package/show.test.ts +91 -0
  76. package/show.ts +151 -0
  77. package/snapshot.test.ts +33 -0
  78. package/snapshot.ts +84 -0
  79. package/table.ts +20 -0
  80. package/testing.ts +229 -0
  81. package/todo.test.ts +490 -0
  82. package/todo.ts +834 -0
  83. package/wait.test.ts +61 -0
  84. package/wait.ts +77 -0
  85. package/worktree-service.test.ts +158 -0
  86. package/worktree-service.ts +193 -0
  87. package/worktree.ts +239 -0
  88. package/add.d.ts +0 -23
  89. package/age.d.ts +0 -2
  90. package/arbor.d.ts +0 -11
  91. package/attachments.d.ts +0 -36
  92. package/claim.d.ts +0 -11
  93. package/config.d.ts +0 -55
  94. package/config.js +0 -7
  95. package/dev/assets.d.ts +0 -11
  96. package/dev.d.ts +0 -47
  97. package/escalate.d.ts +0 -28
  98. package/exit.d.ts +0 -42
  99. package/git.d.ts +0 -77
  100. package/index.d.ts +0 -2
  101. package/index.js +0 -4669
  102. package/journal.d.ts +0 -29
  103. package/list.d.ts +0 -18
  104. package/load-config.d.ts +0 -15
  105. package/log.d.ts +0 -14
  106. package/merge.d.ts +0 -24
  107. package/path.d.ts +0 -13
  108. package/plan.d.ts +0 -83
  109. package/remove.d.ts +0 -19
  110. package/repository.d.ts +0 -25
  111. package/retry.d.ts +0 -17
  112. package/shell.d.ts +0 -17
  113. package/show.d.ts +0 -53
  114. package/snapshot.d.ts +0 -31
  115. package/table.d.ts +0 -5
  116. package/todo.d.ts +0 -230
  117. package/wait.d.ts +0 -22
  118. package/worktree-service.d.ts +0 -51
  119. package/worktree.d.ts +0 -95
package/AGENTS.md ADDED
@@ -0,0 +1,7 @@
1
+ If you change this package's API, check that the agent skill at
2
+ `packages/cli/templates/arbor.skill.md` is still in sync. That is the source;
3
+ the copies under `.agents/skills/` and `.claude/skills/` are installed from it.
4
+
5
+ `arbor` runs whichever checkout you are standing in, so working on this package
6
+ from a worktree merges with the code you are editing. Pin the main tree for the
7
+ merge itself: `WEBAPPWIZ_ROOT="$(arbor path)" arbor merge`.
package/README.md CHANGED
@@ -105,7 +105,8 @@ configures none.
105
105
 
106
106
  A merge onto trunk ends by recommending what to do next: the open todo highest
107
107
  on the list that came up in the task that landed, since its context is
108
- freshest, or failing that the open todo highest on the list, plus any todo older than `todoStalenessMs` (30 days) offered for
108
+ freshest, then one sharing a tag with the todos the task settled, the same
109
+ area of work, or failing that the open todo highest on the list, plus any todo older than `todoStalenessMs` (30 days) offered for
109
110
  removal instead. The todos the task
110
111
  took up, with `--todo` or `todo take`, leave the list, since that work is now
111
112
  done; release one first to keep what is left of it.
@@ -148,7 +149,7 @@ down the listing; a record whose worktree vanished shows as `orphaned`.
148
149
 
149
150
  `--files` adds, under each task, every path it has changed (committed or
150
151
  not) and every path its `ARBOR.md` plans under `## Files` that it has not
151
- touched yet. This is the overlap check an agent runs before starting: its own
152
+ touched yet. This is the overlap check an agent runs once it has planned: its own
152
153
  list of files against everyone else's.
153
154
 
154
155
  ```
@@ -229,23 +230,34 @@ the only thing that remembers a task landed at all. The last 1000 are kept
229
230
  The todos and the tasks in a browser, on `http://localhost:4269`, reloading
230
231
  themselves as anything changes. Built for a phone first.
231
232
 
233
+ One page, tasks above the todos.
234
+
232
235
  Todos are cards in list order: drag one to reorder the list (a short press
233
236
  on a phone, or Space on its grip and the arrow keys), or tap it to reword,
234
237
  attach files to, or remove. A card shows its subject, two lines of its
235
- detail, and badges for its files and staleness, and a green edge with
238
+ detail rendered as markdown, and badges for its files, tags and staleness, and a green edge with
236
239
  "Taken by <task>" once a task has taken it; its
237
240
  `#7` copies `[ARBOR TODO #7]`, which the agent skill reads as that todo, for
238
- pasting into a chat. Typing `@` in a todo offers the files and
241
+ pasting into a chat. An opened todo shows its detail rendered, with Write and
242
+ Preview to switch between the markdown and how it reads. Typing `@` in a todo offers the files and
239
243
  directories in the main tree, tracked or new but not ignored, and writes the
240
244
  one picked as `@path/from/root`; picking a directory keeps the list open on
241
- what is inside.
245
+ what is inside. An opened todo's tags are edited there too, comma separated,
246
+ and its `#7` copies the same reference as the card's.
247
+
248
+ Tasks lists every task with a bar for its progress through its plan, counted
249
+ from the checkboxes under `## Done` and `## Next` in its `ARBOR.md`, flagging
250
+ only an escalated or broken status; tapping one opens its details and whole
251
+ plan in a dialog.
242
252
 
243
- Tasks lists every task with its progress through its plan, flagging only an
244
- escalated or broken status; tapping one opens its details and whole plan in a dialog.
253
+ A row of tags over the todos filters them, the way a mail client filters by
254
+ label: All, then every tag a todo has. Tapping one there, or on a card, shows
255
+ only its todos. Dragging a card while filtered moves it to the place in the
256
+ whole list of the card it is dropped on, so the todos hidden between them
257
+ shift around it.
245
258
 
246
- On a phone the tabs sit along the bottom, in reach of a thumb; on anything
247
- wider they run down a sidebar. If the server stops answering, the header says
248
- it is offline, since what the page shows may be stale.
259
+ If the server stops answering, the header says it is offline, since what the
260
+ page shows may be stale.
249
261
 
250
262
  Todo writes are the same functions as `arbor todo add`, `update` and
251
263
  `remove`. Questions are answered in the agent's chat, not here. Merging,
@@ -332,14 +344,15 @@ imports, a signature changed on one side and its callers on the other) and
332
344
  unreliable when both sides restructured the same logic, because then there is no
333
345
  correct merge, only a decision.
334
346
 
335
- ### `arbor todo add <subject> [text] [--position <n>] [--file <path>]`, `arbor todo list [--json]`, `arbor todo show <id> [--json]`, `arbor todo update <id> [text] [--subject <subject>] [--position <n>] [--file <path>] [--remove-file <name>]`, `arbor todo take <id...>`, `arbor todo release <id...>`, `arbor todo remove <id>`
347
+ ### `arbor todo add <subject> [text] [--position <n>] [--file <path>] [--tag <tag>]`, `arbor todo list [--json] [--open] [--tag <tag>]`, `arbor todo show <id> [--json]`, `arbor todo update <id> [text] [--subject <subject>] [--position <n>] [--file <path>] [--remove-file <name>] [--tag <tag>] [--remove-tag <tag>]`, `arbor todo tags [--json]`, `arbor todo take <id...>`, `arbor todo release <id...>`, `arbor todo remove <id>`
336
348
 
337
349
  Work deferred for later. When something outside the task comes up (a bug next
338
350
  door, a follow-up the reviewer asked for, a question that turns out to be its
339
351
  own project), the agent notes it with `arbor todo add` and carries on instead
340
352
  of growing the task. Run from a worktree, `add` records the task it came up
341
353
  in. A todo is a subject, one line that `list` and `merge` show, and an optional
342
- detail below it that `show` prints in full.
354
+ detail below it in markdown, which `show` prints in full and the `dev` page
355
+ renders.
343
356
 
344
357
  The list is in order: position 1 is the top, and after a task's own todos,
345
358
  the open todo highest on it is the one `merge` recommends next. A new one goes to the bottom, or with
@@ -358,6 +371,8 @@ run from a worktree, adds them to the task already under way, all or none.
358
371
  A task can hold any number. `release` puts them back on the list, from a
359
372
  worktree only its own task's: a task landing with one half done rewords it to
360
373
  what is left with `update`, then releases it, so the merge leaves it open.
374
+ `list --open` shows only those no task holds, which is what an agent
375
+ looking for work to pick up wants.
361
376
  `update` rewords one, its detail as the argument and its subject with
362
377
  `--subject`, and keeps its number; `remove` drops one done some other way or
363
378
  no longer wanted.
@@ -367,6 +382,13 @@ no longer wanted.
367
382
  one, named by path or by its stored file name (`todo show` lists them). They
368
383
  go when the todo does.
369
384
 
385
+ `--tag uploads,merge` tags a todo with the areas or goals it belongs to; a
386
+ todo can have several tags and a tag many todos. A tag is a lowercase word,
387
+ or a few joined by hyphens, and anything else is refused, so one area never
388
+ splits into two spellings. `update --tag` adds tags, `--remove-tag` drops them,
389
+ `list --tag` shows only one tag's todos, and `todo tags` lists every tag in
390
+ use with how many todos have it.
391
+
370
392
  ### `arbor retry <task>`
371
393
 
372
394
  Grants an escalated task another `mergeRetryCount` merge attempts and puts it
package/add.test.ts ADDED
@@ -0,0 +1,125 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
+ import { join } from "node:path";
3
+ import { add } from "./add";
4
+ import { Testing } from "./testing";
5
+
6
+ describe("add", () => {
7
+ let deps: Testing;
8
+
9
+ beforeEach(async () => {
10
+ deps = await Testing.open();
11
+ });
12
+
13
+ afterEach(() => deps.disposeAsync());
14
+
15
+ it("makes a worktree, a branch and a record", async () => {
16
+ await add(deps, "alpha");
17
+
18
+ const state = (await deps.service.find("alpha")).state;
19
+ expect(state).toMatchObject({
20
+ task: "alpha",
21
+ branch: "task/alpha",
22
+ status: "working",
23
+ mergeAttempts: 0,
24
+ });
25
+ expect(await deps.fs.exists(join(state?.worktree ?? "", "README.md"))).toBe(
26
+ true,
27
+ );
28
+ expect(
29
+ await deps.gitCli(
30
+ state?.worktree ?? "",
31
+ "rev-parse",
32
+ "--abbrev-ref",
33
+ "HEAD",
34
+ ),
35
+ ).toBe("task/alpha");
36
+ expect(deps.out()).toContain("added alpha");
37
+ });
38
+
39
+ it("leaves a stub ARBOR.md for the agent to fill in", async () => {
40
+ await add(deps, "alpha");
41
+
42
+ const state = (await deps.service.find("alpha")).state;
43
+ const plan = await deps.fs.read(join(state?.worktree ?? "", "ARBOR.md"));
44
+ expect(plan).toStartWith("# alpha\n");
45
+ expect(plan).toContain("## Goal");
46
+ expect(plan).toContain("## Files");
47
+ expect(plan).toContain("- [ ]");
48
+ });
49
+
50
+ it("excludes the plan from git, once, so no task can commit it", async () => {
51
+ await add(deps, "alpha");
52
+ await add(deps, "beta");
53
+
54
+ const exclude = await deps.fs.read(
55
+ join(deps.root, ".git", "info", "exclude"),
56
+ );
57
+ expect(exclude.split("\n").filter((line) => line === "ARBOR.md")).toEqual([
58
+ "ARBOR.md",
59
+ ]);
60
+ const state = (await deps.service.find("alpha")).state;
61
+ expect(
62
+ await deps.gitCli(state?.worktree ?? "", "status", "--porcelain"),
63
+ ).toBe("");
64
+ });
65
+
66
+ it("refuses a name that is already taken and points at claim", async () => {
67
+ await add(deps, "alpha");
68
+
69
+ await expect(add(deps, "alpha")).toBail("exists", {
70
+ message: "arbor claim alpha",
71
+ });
72
+ });
73
+
74
+ it("rejects names that are not legal branch or directory names", async () => {
75
+ const names = ["Alpha", "a b", "feature/x", "-alpha", ""];
76
+
77
+ await Promise.all(
78
+ names.map((name) => expect(add(deps, name)).toBail("usage")),
79
+ );
80
+ });
81
+
82
+ it("refuses a repo with submodules, before making anything", async () => {
83
+ await deps.fs.write(join(deps.root, ".gitmodules"), "");
84
+
85
+ await expect(add(deps, "alpha")).toBail("usage", {
86
+ message: ".gitmodules",
87
+ });
88
+ expect((await deps.service.find("alpha")).gone).toBe(true);
89
+ });
90
+
91
+ it("reports a failed postCheckout hook but keeps the worktree", async () => {
92
+ deps.config.postCheckout = "exit 3";
93
+
94
+ await expect(add(deps, "alpha")).toBail("hook_failed");
95
+ const state = (await deps.service.find("alpha")).state;
96
+ expect(await deps.fs.exists(state?.worktree ?? "")).toBe(true);
97
+ });
98
+
99
+ it("tells the postCheckout hook which branch the trunk is", async () => {
100
+ deps.config.trunk = "master";
101
+ deps.config.postCheckout = "printenv ARBOR_TRUNK > trunk.txt";
102
+
103
+ await add(deps, "alpha", { base: "main" });
104
+
105
+ const state = (await deps.service.find("alpha")).state;
106
+ expect(await deps.fs.read(join(state?.worktree ?? "", "trunk.txt"))).toBe(
107
+ "master\n",
108
+ );
109
+ });
110
+
111
+ it("names the trunk and the config when the trunk is not a branch", async () => {
112
+ deps.config.trunk = "master";
113
+
114
+ await expect(add(deps, "alpha")).toBail("usage", {
115
+ message: ["trunk 'master'", "arbor.config.ts"],
116
+ });
117
+ });
118
+
119
+ it("refuses with a reason, a message and the data behind it", async () => {
120
+ await expect(add(deps, "Alpha")).toBail("usage", {
121
+ message: "invalid task name 'Alpha'",
122
+ data: { task: "Alpha" },
123
+ });
124
+ });
125
+ });
package/add.ts ADDED
@@ -0,0 +1,192 @@
1
+ import { color, type Logger } from "webappwiz/log";
2
+ import { MarkdownWriter } from "webappwiz/md";
3
+ import type { Fs } from "webappwiz/system";
4
+ import type { Config } from "./config";
5
+ import { fail } from "./exit";
6
+ import { PLAN_FILE } from "./plan";
7
+ import type { Shell } from "./shell";
8
+ import type { Todo, Todos } from "./todo";
9
+ import type { WorktreeService } from "./worktree-service";
10
+
11
+ const NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
12
+
13
+ export interface AddOptions {
14
+ /** Branch the task starts from and merges onto. Defaults to the trunk. */
15
+ base?: string;
16
+ /**
17
+ * The todos this task takes up: their words seed the plan's Goal, and
18
+ * nobody else can take them while the task lives.
19
+ */
20
+ todos?: number[];
21
+ }
22
+
23
+ export async function add(
24
+ {
25
+ service,
26
+ shell,
27
+ config,
28
+ log,
29
+ fs,
30
+ todos,
31
+ }: {
32
+ service: WorktreeService;
33
+ shell: Shell;
34
+ config: Config;
35
+ log: Logger;
36
+ fs: Fs;
37
+ todos: Todos;
38
+ },
39
+ task: string,
40
+ { base = config.trunk, todos: ids = [] }: AddOptions = {},
41
+ ): Promise<void> {
42
+ if (!NAME.test(task)) {
43
+ fail(
44
+ "usage",
45
+ `invalid task name '${task}': use lowercase letters, digits and dashes`,
46
+ { task },
47
+ );
48
+ }
49
+
50
+ // A worktree gets its own empty submodule directories, so every task would
51
+ // have to bootstrap them before anything builds. Refuse the repo instead.
52
+ // Git only reads the superproject's top-level file, so there is nowhere else
53
+ // to look; a stale one with no live gitlink refuses too, which is the safe
54
+ // direction to be wrong in.
55
+ if (await fs.exists(`${service.git.root}/.gitmodules`)) {
56
+ fail(
57
+ "usage",
58
+ "this repo has git submodules (.gitmodules), which arbor does not support: worktrees do not share them",
59
+ { task },
60
+ );
61
+ }
62
+
63
+ const found = await service.find(task);
64
+ if (found.status === "stray") {
65
+ fail(
66
+ "exists",
67
+ `branch ${found.branch} exists without a worktree: run \`arbor remove ${task}\` first`,
68
+ { task, branch: found.branch },
69
+ );
70
+ }
71
+ if (!found.gone) {
72
+ fail(
73
+ "exists",
74
+ `task '${task}' already exists: run \`arbor claim ${task}\``,
75
+ { task, worktree: found.path },
76
+ );
77
+ }
78
+
79
+ // Looked up before anything is created, so a todo that is gone or taken
80
+ // refuses the whole add instead of leaving a tree behind.
81
+ const taken: Todo[] = [];
82
+ for (const id of new Set(ids)) {
83
+ const todo = await todos.find(id);
84
+ if (todo.takenBy) {
85
+ await todo.take(task); // refuses, naming the task that has it
86
+ }
87
+ taken.push(todo);
88
+ }
89
+
90
+ const added = await service.add(task, { base });
91
+ if (added.code !== 0) {
92
+ // git's own "invalid reference" sends people to the branch, when what is
93
+ // wrong is which branch arbor thinks the trunk is.
94
+ const missingTrunk =
95
+ base === config.trunk && !(await service.git.branchExists(base));
96
+ fail(
97
+ "usage",
98
+ missingTrunk
99
+ ? `trunk '${base}' is not a branch in this repo: set \`trunk\` in arbor.config.ts`
100
+ : `git worktree add failed: ${added.stderr}`,
101
+ { task },
102
+ );
103
+ }
104
+
105
+ const worktree = await (await service.find(task)).take({ base });
106
+
107
+ // The plan is scratch for one task and must never land on trunk. Excluding
108
+ // it through the shared `.git` covers every worktree at once and leaves the
109
+ // repo's own .gitignore, which arbor would have to commit to, alone.
110
+ const info = `${await service.git.commonDir()}/info`;
111
+ const excluded = await fs.read(`${info}/exclude`).catch(() => "");
112
+ if (!excluded.split("\n").includes(PLAN_FILE)) {
113
+ await fs.mkdir(info);
114
+ await fs.write(
115
+ `${info}/exclude`,
116
+ `${excluded.trimEnd()}\n${PLAN_FILE}\n`.trimStart(),
117
+ );
118
+ }
119
+
120
+ await fs.write(
121
+ `${worktree.path}/${PLAN_FILE}`,
122
+ PLAN(task, taken.length === 0 ? null : goal(taken)),
123
+ );
124
+ for (const todo of taken) {
125
+ await todo.take(task);
126
+ }
127
+
128
+ // A fresh worktree shares no untracked files with the repo: no node_modules,
129
+ // no .env. That is what the hook is for.
130
+ if (config.postCheckout) {
131
+ const { exitCode } = await shell.stream(
132
+ config.postCheckout,
133
+ worktree.path,
134
+ {
135
+ env: {
136
+ ARBOR_TASK: task,
137
+ ARBOR_WORKTREE: worktree.path,
138
+ ARBOR_TRUNK: config.trunk,
139
+ },
140
+ },
141
+ );
142
+ if (exitCode !== 0) {
143
+ // The worktree stays. Rolling back would throw away a tree the agent
144
+ // can fix by hand and re-run the hook in.
145
+ fail(
146
+ "hook_failed",
147
+ `postCheckout hook failed (exit ${exitCode}); worktree left in place at ${worktree.path}`,
148
+ { task, worktree: worktree.path },
149
+ );
150
+ }
151
+ }
152
+
153
+ log.info(
154
+ `${color.green("added")} ${task}\n worktree: ${worktree.path}\n branch: ${worktree.branch}\n base: ${base}`,
155
+ );
156
+ }
157
+
158
+ /**
159
+ * Todos as a Goal: each one's subject and detail, and each file attached to it
160
+ * by path, which stay readable until the task lands and takes the todo with
161
+ * it. One todo is the Goal as it stands; several are each named by id.
162
+ */
163
+ function goal(todos: Todo[]): string {
164
+ return todos
165
+ .map((todo) => {
166
+ const subject =
167
+ todos.length === 1 ? todo.subject : `Todo ${todo.id}: ${todo.subject}`;
168
+ return [
169
+ subject,
170
+ todo.text,
171
+ todo.files.map((path) => `Attached: \`${path}\``).join("\n"),
172
+ ]
173
+ .filter(Boolean)
174
+ .join("\n\n");
175
+ })
176
+ .join("\n\n");
177
+ }
178
+
179
+ /** The plan a fresh task starts with. `## Goal` is the todos it takes up, if
180
+ * any; it and `## Files` are otherwise left empty on purpose: `arbor show` nags
181
+ * until the agent fills them in. */
182
+ function PLAN(task: string, goal: string | null): string {
183
+ const writer = new MarkdownWriter().heading(1, task).heading(2, "Goal");
184
+ if (goal !== null) {
185
+ writer.text(goal);
186
+ }
187
+ return writer
188
+ .heading(2, "Files")
189
+ .heading(2, "Next")
190
+ .checklist("fill in Goal and list the steps here as `- [ ]` items")
191
+ .toString();
192
+ }
package/age.ts ADDED
@@ -0,0 +1,11 @@
1
+ /** How long ago an ISO timestamp was, at the coarsest unit that still reads. */
2
+ export function age(since: string): string {
3
+ const minutes = Math.floor((Date.now() - Date.parse(since)) / 60_000);
4
+ if (minutes < 60) {
5
+ return `${minutes}m`;
6
+ }
7
+ if (minutes < 60 * 24) {
8
+ return `${Math.floor(minutes / 60)}h`;
9
+ }
10
+ return `${Math.floor(minutes / (60 * 24))}d`;
11
+ }
package/arbor.test.ts ADDED
@@ -0,0 +1,145 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { basename, join } from "node:path";
3
+ import { arbor } from "./arbor";
4
+ import { Testing } from "./testing";
5
+
6
+ /**
7
+ * The cli in this process, run with fakes: the same commands `e2e.test.ts`
8
+ * spawns, minus the subprocess. What it proves is the wiring, that every
9
+ * dependency an action uses arrives through `run`.
10
+ */
11
+ describe("arbor cli", () => {
12
+ it("runs its commands against the dependencies it is given", async () => {
13
+ await using env = await Testing.open();
14
+ env.ps.cd(env.root);
15
+ const deps = {
16
+ log: env.log,
17
+ fs: env.fs,
18
+ ps: env.ps,
19
+ assets: env.assets,
20
+ };
21
+
22
+ await arbor.run(deps, ["add", "alpha"]);
23
+ await arbor.run(deps, ["list", "--json"]);
24
+
25
+ const rows = JSON.parse(String(env.log.entries.at(-1)?.message));
26
+ expect(rows).toMatchObject([{ task: "alpha", status: "working" }]);
27
+ expect(await env.fs.exists(join(`${env.root}-arbor`, "alpha"))).toBe(true);
28
+ });
29
+
30
+ it("escalates a task and edits its todos", async () => {
31
+ await using env = await Testing.open();
32
+ env.ps.cd(env.root);
33
+ const deps = {
34
+ log: env.log,
35
+ fs: env.fs,
36
+ ps: env.ps,
37
+ assets: env.assets,
38
+ };
39
+ await arbor.run(deps, ["add", "alpha"]);
40
+ const worktree = await env.service.find("alpha");
41
+ await env.fs.write(
42
+ join(worktree.path, "ARBOR.md"),
43
+ "# alpha\n\n## Blocked\n\n- [ ] 1. Open it.\n",
44
+ );
45
+ await env.fs.write(join(env.root, "a.png"), "a");
46
+ env.ps.cd(worktree.path);
47
+ await arbor.run(deps, ["escalate", "needs a person"]);
48
+
49
+ env.ps.cd(env.root);
50
+ await arbor.run(deps, [
51
+ "todo",
52
+ "add",
53
+ "write docs",
54
+ "the CLI first",
55
+ "--file",
56
+ "a.png",
57
+ ]);
58
+ const [attached = ""] = (await env.todos.find(1)).files;
59
+ await arbor.run(deps, [
60
+ "todo",
61
+ "update",
62
+ "1",
63
+ "--subject",
64
+ "write the docs",
65
+ "--position",
66
+ "1",
67
+ "--remove-file",
68
+ basename(attached),
69
+ ]);
70
+ expect((await env.todos.find(1)).state).toMatchObject({
71
+ subject: "write the docs",
72
+ text: "the CLI first",
73
+ position: 1,
74
+ files: [],
75
+ });
76
+
77
+ await arbor.run(deps, ["log", "--json"]);
78
+ const entries = JSON.parse(String(env.log.entries.at(-1)?.message));
79
+ expect(entries.map((entry: { action: string }) => entry.action)).toContain(
80
+ "escalate",
81
+ );
82
+ });
83
+
84
+ it("asks for a review from the command line", async () => {
85
+ await using env = await Testing.open();
86
+ env.ps.cd(env.root);
87
+ const deps = {
88
+ log: env.log,
89
+ fs: env.fs,
90
+ ps: env.ps,
91
+ assets: env.assets,
92
+ };
93
+ await arbor.run(deps, ["add", "alpha"]);
94
+
95
+ await arbor.run(deps, [
96
+ "escalate",
97
+ "check the header",
98
+ "--task",
99
+ "alpha",
100
+ "--review",
101
+ ]);
102
+
103
+ const worktree = await env.service.find("alpha");
104
+ expect(worktree.state?.escalations?.at(-1)?.review).toBe("1");
105
+ expect(await env.fs.read(join(worktree.path, "ARBOR.md"))).toContain(
106
+ "- [ ] 1. Ready to merge?\n check the header\n",
107
+ );
108
+ });
109
+
110
+ it("reports a refusal as a reason, a message and an exit code", async () => {
111
+ await using env = await Testing.open();
112
+ env.ps.cd(env.root);
113
+
114
+ await arbor.run(
115
+ {
116
+ log: env.log,
117
+ fs: env.fs,
118
+ ps: env.ps,
119
+ assets: env.assets,
120
+ },
121
+ ["claim", "nope"],
122
+ );
123
+
124
+ expect(env.out()).toContain('"reason":"not_found"');
125
+ expect(env.proc.lastExit()).toBe(8);
126
+ });
127
+
128
+ it("refuses a port that cannot exist rather than trying to listen", async () => {
129
+ await using env = await Testing.open();
130
+ env.ps.cd(env.root);
131
+
132
+ await arbor.run(
133
+ {
134
+ log: env.log,
135
+ fs: env.fs,
136
+ ps: env.ps,
137
+ assets: env.assets,
138
+ },
139
+ ["dev", "--port", "99999"],
140
+ );
141
+
142
+ expect(env.out()).not.toContain(" reply ");
143
+ expect(env.proc.lastExit()).toBe(1);
144
+ });
145
+ });