@webappwiz/arbor 0.0.30 → 0.0.31
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/add.d.ts +23 -0
- package/age.d.ts +2 -0
- package/arbor.d.ts +11 -0
- package/attachments.d.ts +36 -0
- package/claim.d.ts +11 -0
- package/config.d.ts +55 -0
- package/config.js +7 -0
- package/dev/assets.d.ts +11 -0
- package/dev.d.ts +48 -0
- package/escalate.d.ts +28 -0
- package/exit.d.ts +42 -0
- package/git.d.ts +77 -0
- package/index.d.ts +2 -0
- package/index.js +4712 -0
- package/journal.d.ts +29 -0
- package/list.d.ts +18 -0
- package/load-config.d.ts +15 -0
- package/log.d.ts +14 -0
- package/merge.d.ts +24 -0
- package/package.json +15 -32
- package/path.d.ts +13 -0
- package/plan.d.ts +83 -0
- package/remove.d.ts +19 -0
- package/repository.d.ts +25 -0
- package/retry.d.ts +17 -0
- package/shell.d.ts +17 -0
- package/show.d.ts +53 -0
- package/snapshot.d.ts +35 -0
- package/table.d.ts +5 -0
- package/todo.d.ts +264 -0
- package/wait.d.ts +22 -0
- package/worktree-service.d.ts +51 -0
- package/worktree.d.ts +95 -0
- package/AGENTS.md +0 -7
- package/add.test.ts +0 -125
- package/add.ts +0 -192
- package/age.ts +0 -11
- package/arbor.test.ts +0 -145
- package/arbor.ts +0 -429
- package/assets.d.ts +0 -9
- package/attachments.ts +0 -87
- package/build.ts +0 -98
- package/claim.test.ts +0 -89
- package/claim.ts +0 -73
- package/components.json +0 -21
- package/config.ts +0 -58
- package/dev/api.ts +0 -120
- package/dev/app.test.tsx +0 -645
- package/dev/app.tsx +0 -80
- package/dev/assets.ts +0 -16
- package/dev/build/main.txt +0 -68
- package/dev/build/shell.txt +0 -21
- package/dev/build/styles.txt +0 -2263
- package/dev/components/ui/badge.tsx +0 -51
- package/dev/components/ui/button.tsx +0 -57
- package/dev/components/ui/dialog.tsx +0 -154
- package/dev/components/ui/empty.tsx +0 -100
- package/dev/components/ui/input-group.tsx +0 -155
- package/dev/components/ui/input.tsx +0 -19
- package/dev/components/ui/progress.tsx +0 -80
- package/dev/components/ui/textarea.tsx +0 -17
- package/dev/components/ui/toast.tsx +0 -227
- package/dev/feed.ts +0 -69
- package/dev/files.tsx +0 -191
- package/dev/index.html +0 -21
- package/dev/lib/utils.ts +0 -7
- package/dev/main.tsx +0 -22
- package/dev/markdown.test.tsx +0 -133
- package/dev/markdown.tsx +0 -124
- package/dev/mentions.tsx +0 -319
- package/dev/shadcn.css +0 -649
- package/dev/styles.css +0 -107
- package/dev/tags.tsx +0 -44
- package/dev/tasks.tsx +0 -151
- package/dev/todos.tsx +0 -759
- package/dev.test.ts +0 -439
- package/dev.ts +0 -386
- package/e2e.test.ts +0 -256
- package/escalate.test.ts +0 -76
- package/escalate.ts +0 -112
- package/exit.test.ts +0 -71
- package/exit.ts +0 -74
- package/git.ts +0 -294
- package/index.ts +0 -9
- package/journal.ts +0 -89
- package/list.test.ts +0 -82
- package/list.ts +0 -137
- package/load-config.test.ts +0 -121
- package/load-config.ts +0 -62
- package/log.test.ts +0 -65
- package/log.ts +0 -40
- package/merge.test.ts +0 -332
- package/merge.ts +0 -283
- package/path.test.ts +0 -35
- package/path.ts +0 -38
- package/plan.test.ts +0 -318
- package/plan.ts +0 -302
- package/progress.test.ts +0 -54
- package/progress.ts +0 -31
- package/remove.test.ts +0 -68
- package/remove.ts +0 -88
- package/repository.ts +0 -83
- package/retry.test.ts +0 -43
- package/retry.ts +0 -48
- package/shell.ts +0 -44
- package/show.test.ts +0 -91
- package/show.ts +0 -151
- package/snapshot.test.ts +0 -33
- package/snapshot.ts +0 -84
- package/table.ts +0 -20
- package/testing.ts +0 -229
- package/todo.test.ts +0 -490
- package/todo.ts +0 -834
- package/wait.test.ts +0 -61
- package/wait.ts +0 -77
- package/worktree-service.test.ts +0 -158
- package/worktree-service.ts +0 -193
- package/worktree.ts +0 -239
package/add.ts
DELETED
|
@@ -1,192 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,145 +0,0 @@
|
|
|
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
|
-
});
|