@webappwiz/arbor 0.0.30 → 0.0.32
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 +2 -2
- 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 +82 -0
- package/index.d.ts +2 -0
- package/index.js +4763 -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 +36 -0
- package/table.d.ts +5 -0
- package/todo.d.ts +202 -0
- package/todos.d.ts +75 -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/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
|
-
});
|
package/arbor.ts
DELETED
|
@@ -1,429 +0,0 @@
|
|
|
1
|
-
import { cli, type Deps } from "webappwiz/cmd";
|
|
2
|
-
import type { Fs } from "webappwiz/system";
|
|
3
|
-
import { Duration } from "webappwiz/time";
|
|
4
|
-
import { z } from "zod";
|
|
5
|
-
import { add } from "./add";
|
|
6
|
-
import { claim } from "./claim";
|
|
7
|
-
import { DEFAULT_PORT, dev, devPorts } from "./dev";
|
|
8
|
-
import type { Assets } from "./dev/assets";
|
|
9
|
-
import { escalate } from "./escalate";
|
|
10
|
-
import { exits, fail } from "./exit";
|
|
11
|
-
import { list } from "./list";
|
|
12
|
-
import { DEFAULT_COUNT, log as showLog } from "./log";
|
|
13
|
-
import { merge } from "./merge";
|
|
14
|
-
import { path } from "./path";
|
|
15
|
-
import { remove } from "./remove";
|
|
16
|
-
import { type Repository, repository } from "./repository";
|
|
17
|
-
import { retry } from "./retry";
|
|
18
|
-
import { show } from "./show";
|
|
19
|
-
import {
|
|
20
|
-
todoAdd,
|
|
21
|
-
todoList,
|
|
22
|
-
todoRelease,
|
|
23
|
-
todoRemove,
|
|
24
|
-
todoShow,
|
|
25
|
-
todoTags,
|
|
26
|
-
todoTake,
|
|
27
|
-
todoUpdate,
|
|
28
|
-
} from "./todo";
|
|
29
|
-
import { DEFAULT_TIMEOUT, wait } from "./wait";
|
|
30
|
-
|
|
31
|
-
/** Everything `arbor` is run with, before the repository middleware adds to it. */
|
|
32
|
-
export interface ArborDeps extends Deps {
|
|
33
|
-
fs: Fs;
|
|
34
|
-
/** The built page `dev` serves as its preview. */
|
|
35
|
-
assets: Assets;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// Outermost first: a refusal raised in an action unwinds past `repository` and
|
|
39
|
-
// stops at `exits`, which is the only thing that ends the process.
|
|
40
|
-
export const arbor = cli<ArborDeps>("arbor")
|
|
41
|
-
.use(exits<ArborDeps>())
|
|
42
|
-
.use(repository<ArborDeps>());
|
|
43
|
-
|
|
44
|
-
// Every action is handed the dependencies it runs with, so it passes the
|
|
45
|
-
// context straight on: each command takes the few it names off it.
|
|
46
|
-
arbor
|
|
47
|
-
.command("add")
|
|
48
|
-
.description(
|
|
49
|
-
"start a new task: create branch task/<task>, a worktree at ../<repo>-arbor/<task> and a state record",
|
|
50
|
-
)
|
|
51
|
-
.arg("task", z.string(), { description: "task name (lowercase-with-dashes)" })
|
|
52
|
-
.option("base", z.string(), {
|
|
53
|
-
default: "",
|
|
54
|
-
description:
|
|
55
|
-
"branch this task starts from and merges onto (default: trunk); `task/<other>` stacks this task on that one and lands the work in its worktree",
|
|
56
|
-
})
|
|
57
|
-
.option("todo", z.string(), {
|
|
58
|
-
default: "",
|
|
59
|
-
description:
|
|
60
|
-
"take up these todos, comma separated: their words become the plan's Goal, merging removes them, and removing the task puts them back",
|
|
61
|
-
})
|
|
62
|
-
.action((opts, ctx) =>
|
|
63
|
-
ctx.journal.record("add", opts.task, () =>
|
|
64
|
-
add(ctx, opts.task, {
|
|
65
|
-
base: opts.base || undefined,
|
|
66
|
-
todos: todoIds(opts.todo),
|
|
67
|
-
}),
|
|
68
|
-
),
|
|
69
|
-
);
|
|
70
|
-
|
|
71
|
-
arbor
|
|
72
|
-
.command("claim")
|
|
73
|
-
.description(
|
|
74
|
-
"resume an existing task: take ownership of its worktree and print its path, status and any half-finished rebase; an escalated task goes back to working; refuses while another agent holds the lease, but takes a stale one silently, so `arbor show` first if the tree may not be abandoned",
|
|
75
|
-
)
|
|
76
|
-
.arg("task", z.string(), { description: "task name" })
|
|
77
|
-
.action((opts, ctx) =>
|
|
78
|
-
ctx.journal.record("claim", opts.task, () => claim(ctx, opts.task)),
|
|
79
|
-
);
|
|
80
|
-
|
|
81
|
-
arbor
|
|
82
|
-
.command("merge")
|
|
83
|
-
.description(
|
|
84
|
-
"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",
|
|
85
|
-
)
|
|
86
|
-
.action(async (_opts, ctx) =>
|
|
87
|
-
ctx.journal.record("merge", await here(ctx), () =>
|
|
88
|
-
merge(ctx, ctx.ps.cwd()),
|
|
89
|
-
),
|
|
90
|
-
);
|
|
91
|
-
|
|
92
|
-
arbor
|
|
93
|
-
.command("remove")
|
|
94
|
-
.description(
|
|
95
|
-
"discard a task: worktree, branch and state file; cheap and encouraged, since redoing a task against current trunk often beats a hard rebase",
|
|
96
|
-
)
|
|
97
|
-
.arg("task", z.string(), { description: "task name" })
|
|
98
|
-
.option("force", z.boolean(), {
|
|
99
|
-
default: false,
|
|
100
|
-
description: "discard even when another agent holds the lease",
|
|
101
|
-
})
|
|
102
|
-
.action((opts, ctx) =>
|
|
103
|
-
ctx.journal.record("remove", opts.task, () =>
|
|
104
|
-
remove(ctx, opts.task, { force: opts.force }),
|
|
105
|
-
),
|
|
106
|
-
);
|
|
107
|
-
|
|
108
|
-
arbor
|
|
109
|
-
.command("list")
|
|
110
|
-
.description(
|
|
111
|
-
"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",
|
|
112
|
-
)
|
|
113
|
-
.option("json", z.boolean(), { default: false, description: "emit JSON" })
|
|
114
|
-
.option("files", z.boolean(), {
|
|
115
|
-
default: false,
|
|
116
|
-
description:
|
|
117
|
-
"add each task's changed files (committed or not) and the files its ARBOR.md plans to touch, to check for overlap before starting",
|
|
118
|
-
})
|
|
119
|
-
.action((opts, ctx) => list(ctx, { json: opts.json, files: opts.files }));
|
|
120
|
-
|
|
121
|
-
arbor
|
|
122
|
-
.command("show")
|
|
123
|
-
.description(
|
|
124
|
-
"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",
|
|
125
|
-
)
|
|
126
|
-
.arg("task", z.string(), { description: "task name" })
|
|
127
|
-
.option("json", z.boolean(), { default: false, description: "emit JSON" })
|
|
128
|
-
.action((opts, ctx) => show(ctx, opts.task, { json: opts.json }));
|
|
129
|
-
|
|
130
|
-
arbor
|
|
131
|
-
.command("wait")
|
|
132
|
-
.description(
|
|
133
|
-
"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",
|
|
134
|
-
)
|
|
135
|
-
.arg("task", z.string(), { description: "task name" })
|
|
136
|
-
.option("timeout-secs", z.coerce.number(), {
|
|
137
|
-
default: DEFAULT_TIMEOUT.secs,
|
|
138
|
-
description: "how long to wait before giving up",
|
|
139
|
-
})
|
|
140
|
-
.action((opts, ctx) =>
|
|
141
|
-
wait(ctx, opts.task, {
|
|
142
|
-
timeout: Duration.secs(opts["timeout-secs"]),
|
|
143
|
-
}),
|
|
144
|
-
);
|
|
145
|
-
|
|
146
|
-
arbor
|
|
147
|
-
.command("log")
|
|
148
|
-
.description(
|
|
149
|
-
"show what has been done here recently: one line per add, claim, merge, remove, escalate, retry, todo and more, with how it ended; outlives the tasks themselves",
|
|
150
|
-
)
|
|
151
|
-
.option("count", z.coerce.number(), {
|
|
152
|
-
default: DEFAULT_COUNT,
|
|
153
|
-
description: "how many entries to show",
|
|
154
|
-
})
|
|
155
|
-
.option("json", z.boolean(), { default: false, description: "emit JSON" })
|
|
156
|
-
.action((opts, ctx) => showLog(ctx, { count: opts.count, json: opts.json }));
|
|
157
|
-
|
|
158
|
-
arbor
|
|
159
|
-
.command("dev")
|
|
160
|
-
.description(
|
|
161
|
-
"serve the todos and the tasks as a web page on this machine, where todos can be added, updated, reordered or removed",
|
|
162
|
-
)
|
|
163
|
-
.option("port", z.coerce.number(), {
|
|
164
|
-
default: DEFAULT_PORT,
|
|
165
|
-
description: "port to listen on, or the next open one above it",
|
|
166
|
-
})
|
|
167
|
-
.option("allow-hosts", z.string(), {
|
|
168
|
-
default: "",
|
|
169
|
-
description:
|
|
170
|
-
"comma-separated host names the page may also be reached by, like a tunnel's; putting a login in front of them is the tunnel's job",
|
|
171
|
-
})
|
|
172
|
-
.action((opts, ctx) =>
|
|
173
|
-
dev(ctx, {
|
|
174
|
-
ports: devPorts(opts.port),
|
|
175
|
-
hosts: opts["allow-hosts"]
|
|
176
|
-
.split(",")
|
|
177
|
-
.map((host) => host.trim())
|
|
178
|
-
.filter(Boolean),
|
|
179
|
-
}),
|
|
180
|
-
);
|
|
181
|
-
|
|
182
|
-
arbor
|
|
183
|
-
.command("path")
|
|
184
|
-
.description(
|
|
185
|
-
"print a task's worktree path, or the main tree with no task; names another agent's tree without taking its lease",
|
|
186
|
-
)
|
|
187
|
-
.arg("task", z.string(), {
|
|
188
|
-
default: "",
|
|
189
|
-
description: "task name; omit for the main tree",
|
|
190
|
-
})
|
|
191
|
-
.action((opts, ctx) => path(ctx, opts.task || undefined));
|
|
192
|
-
|
|
193
|
-
arbor
|
|
194
|
-
.command("escalate")
|
|
195
|
-
.description(
|
|
196
|
-
"hand this task to a human and stop: records the reason, drops the lease and leaves the worktree exactly as it is; use instead of resolving a genuine conflict badly just to finish",
|
|
197
|
-
)
|
|
198
|
-
.arg("reason", z.string(), {
|
|
199
|
-
description:
|
|
200
|
-
"why this needs a human; with --review, what to look at before approving",
|
|
201
|
-
})
|
|
202
|
-
.option("task", z.string(), {
|
|
203
|
-
default: "",
|
|
204
|
-
description: "task name, when run outside its worktree",
|
|
205
|
-
})
|
|
206
|
-
.option("review", z.boolean(), {
|
|
207
|
-
default: false,
|
|
208
|
-
description:
|
|
209
|
-
"ask a person to approve merging: adds a `Ready to merge?` question under ## Blocked with the reason as its detail, answered in chat; refused while another question is unchecked",
|
|
210
|
-
})
|
|
211
|
-
.action(async (opts, ctx) =>
|
|
212
|
-
ctx.journal.record("escalate", opts.task || (await here(ctx)), () =>
|
|
213
|
-
escalate(ctx, opts.reason, ctx.ps.cwd(), {
|
|
214
|
-
task: opts.task || undefined,
|
|
215
|
-
review: opts.review,
|
|
216
|
-
}),
|
|
217
|
-
),
|
|
218
|
-
);
|
|
219
|
-
|
|
220
|
-
arbor
|
|
221
|
-
.command("retry")
|
|
222
|
-
.description(
|
|
223
|
-
"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",
|
|
224
|
-
)
|
|
225
|
-
.arg("task", z.string(), { description: "task name" })
|
|
226
|
-
.action((opts, ctx) =>
|
|
227
|
-
ctx.journal.record("retry", opts.task, () => retry(ctx, opts.task)),
|
|
228
|
-
);
|
|
229
|
-
|
|
230
|
-
/** A place in the todo list, 1 at the top; absent leaves it to the command. */
|
|
231
|
-
const position = z.coerce.number().int().positive().optional();
|
|
232
|
-
|
|
233
|
-
const todo = arbor
|
|
234
|
-
.group("todo")
|
|
235
|
-
.description(
|
|
236
|
-
"defer work for later instead of growing the task you are in: `merge` recommends the next one when a task lands",
|
|
237
|
-
);
|
|
238
|
-
|
|
239
|
-
todo
|
|
240
|
-
.command("add")
|
|
241
|
-
.description(
|
|
242
|
-
"note something to do later and move on; run from a worktree, it records the task it came up in",
|
|
243
|
-
)
|
|
244
|
-
.arg("subject", z.string(), { description: "what is left to do, in a line" })
|
|
245
|
-
.arg("text", z.string(), {
|
|
246
|
-
default: "",
|
|
247
|
-
description: "whatever more there is to say about it, in markdown",
|
|
248
|
-
})
|
|
249
|
-
.option("position", position, {
|
|
250
|
-
description:
|
|
251
|
-
"where it goes in the list, 1 at the top, pushing those from there down (default: the bottom)",
|
|
252
|
-
})
|
|
253
|
-
.option("file", z.string(), {
|
|
254
|
-
default: "",
|
|
255
|
-
description:
|
|
256
|
-
"files to attach, comma separated: copied under .git/arbor/todos/<id>/",
|
|
257
|
-
})
|
|
258
|
-
.option("tag", z.string(), {
|
|
259
|
-
default: "",
|
|
260
|
-
description:
|
|
261
|
-
"tags, comma separated, each one lowercase word (or a few joined by hyphens): the areas or goals it belongs to; `arbor todo tags` lists those in use",
|
|
262
|
-
})
|
|
263
|
-
.action(async (opts, ctx) => {
|
|
264
|
-
const from = await here(ctx);
|
|
265
|
-
await ctx.journal.record("todo add", from, () =>
|
|
266
|
-
todoAdd(ctx, opts.subject, from, {
|
|
267
|
-
text: opts.text,
|
|
268
|
-
position: opts.position,
|
|
269
|
-
files: commaList(opts.file),
|
|
270
|
-
tags: commaList(opts.tag),
|
|
271
|
-
}),
|
|
272
|
-
);
|
|
273
|
-
});
|
|
274
|
-
|
|
275
|
-
todo
|
|
276
|
-
.command("list")
|
|
277
|
-
.description(
|
|
278
|
-
"every todo, top of the list first, with the task it came from and the task that took it up",
|
|
279
|
-
)
|
|
280
|
-
.option("json", z.boolean(), { default: false, description: "emit JSON" })
|
|
281
|
-
.option("open", z.boolean(), {
|
|
282
|
-
default: false,
|
|
283
|
-
description: "only todos no task has taken up: the ones free to pick up",
|
|
284
|
-
})
|
|
285
|
-
.option("tag", z.string(), {
|
|
286
|
-
default: "",
|
|
287
|
-
description: "only todos with this tag",
|
|
288
|
-
})
|
|
289
|
-
.action((opts, ctx) =>
|
|
290
|
-
todoList(ctx, {
|
|
291
|
-
json: opts.json,
|
|
292
|
-
open: opts.open,
|
|
293
|
-
tag: opts.tag || undefined,
|
|
294
|
-
}),
|
|
295
|
-
);
|
|
296
|
-
|
|
297
|
-
todo
|
|
298
|
-
.command("tags")
|
|
299
|
-
.description(
|
|
300
|
-
"every tag in use, with how many todos have it: reuse one before making up another",
|
|
301
|
-
)
|
|
302
|
-
.option("json", z.boolean(), { default: false, description: "emit JSON" })
|
|
303
|
-
.action((opts, ctx) => todoTags(ctx, { json: opts.json }));
|
|
304
|
-
|
|
305
|
-
todo
|
|
306
|
-
.command("show")
|
|
307
|
-
.description("one todo in full: where it stands, its files, and its detail")
|
|
308
|
-
.arg("id", z.coerce.number().int().positive(), { description: "todo id" })
|
|
309
|
-
.option("json", z.boolean(), { default: false, description: "emit JSON" })
|
|
310
|
-
.action((opts, ctx) => todoShow(ctx, opts.id, { json: opts.json }));
|
|
311
|
-
|
|
312
|
-
todo
|
|
313
|
-
.command("update")
|
|
314
|
-
.description(
|
|
315
|
-
"say what a todo is in other words, move it up or down the list, tag it, or attach and drop files; it keeps its id",
|
|
316
|
-
)
|
|
317
|
-
.arg("id", z.coerce.number().int().positive(), { description: "todo id" })
|
|
318
|
-
.arg("text", z.string(), {
|
|
319
|
-
default: "",
|
|
320
|
-
description:
|
|
321
|
-
"whatever more there is to say about it, in markdown; leave out to keep the detail",
|
|
322
|
-
})
|
|
323
|
-
.option("subject", z.string(), {
|
|
324
|
-
default: "",
|
|
325
|
-
description: "what is left to do, in a line; leave out to keep it",
|
|
326
|
-
})
|
|
327
|
-
.option("position", position, {
|
|
328
|
-
description:
|
|
329
|
-
"where to move it in the list, 1 at the top; the others close up around it",
|
|
330
|
-
})
|
|
331
|
-
.option("file", z.string(), {
|
|
332
|
-
default: "",
|
|
333
|
-
description: "files to attach, comma separated",
|
|
334
|
-
})
|
|
335
|
-
.option("remove-file", z.string(), {
|
|
336
|
-
default: "",
|
|
337
|
-
description:
|
|
338
|
-
"attached files to drop, comma separated, by path or stored name",
|
|
339
|
-
})
|
|
340
|
-
.option("tag", z.string(), {
|
|
341
|
-
default: "",
|
|
342
|
-
description: "tags to add, comma separated",
|
|
343
|
-
})
|
|
344
|
-
.option("remove-tag", z.string(), {
|
|
345
|
-
default: "",
|
|
346
|
-
description: "tags to drop, comma separated",
|
|
347
|
-
})
|
|
348
|
-
.action((opts, ctx) =>
|
|
349
|
-
ctx.journal.record("todo update", null, () =>
|
|
350
|
-
todoUpdate(ctx, opts.id, {
|
|
351
|
-
subject: opts.subject,
|
|
352
|
-
text: opts.text,
|
|
353
|
-
position: opts.position,
|
|
354
|
-
files: commaList(opts.file),
|
|
355
|
-
removeFiles: commaList(opts["remove-file"]),
|
|
356
|
-
tags: commaList(opts.tag),
|
|
357
|
-
removeTags: commaList(opts["remove-tag"]),
|
|
358
|
-
}),
|
|
359
|
-
),
|
|
360
|
-
);
|
|
361
|
-
|
|
362
|
-
todo
|
|
363
|
-
.command("take")
|
|
364
|
-
.description(
|
|
365
|
-
"take up todos for the task whose worktree you are in, as when they turn out to be part of its work: merging removes them, removing the task puts them back",
|
|
366
|
-
)
|
|
367
|
-
.rest("ids", z.coerce.number().int().positive(), {
|
|
368
|
-
description: "todo ids",
|
|
369
|
-
})
|
|
370
|
-
.action(async (opts, ctx) => {
|
|
371
|
-
const task = await here(ctx);
|
|
372
|
-
await ctx.journal.record("todo take", task, () =>
|
|
373
|
-
todoTake(ctx, opts.ids, task),
|
|
374
|
-
);
|
|
375
|
-
});
|
|
376
|
-
|
|
377
|
-
todo
|
|
378
|
-
.command("release")
|
|
379
|
-
.description(
|
|
380
|
-
"put taken todos back on the list, as when a task lands without finishing them: `arbor todo update` the leftover first; from a worktree, only its own task's",
|
|
381
|
-
)
|
|
382
|
-
.rest("ids", z.coerce.number().int().positive(), {
|
|
383
|
-
description: "todo ids",
|
|
384
|
-
})
|
|
385
|
-
.action(async (opts, ctx) => {
|
|
386
|
-
const task = await here(ctx);
|
|
387
|
-
await ctx.journal.record("todo release", task, () =>
|
|
388
|
-
todoRelease(ctx, opts.ids, task),
|
|
389
|
-
);
|
|
390
|
-
});
|
|
391
|
-
|
|
392
|
-
todo
|
|
393
|
-
.command("remove")
|
|
394
|
-
.description("drop a todo that was done some other way or no longer applies")
|
|
395
|
-
.arg("id", z.coerce.number().int().positive(), { description: "todo id" })
|
|
396
|
-
.action((opts, ctx) =>
|
|
397
|
-
ctx.journal.record("todo remove", null, () => todoRemove(ctx, opts.id)),
|
|
398
|
-
);
|
|
399
|
-
|
|
400
|
-
/** `--todo 3,5` as ids, refusing anything that is not one. */
|
|
401
|
-
function todoIds(raw: string): number[] {
|
|
402
|
-
return commaList(raw).map((value) => {
|
|
403
|
-
const id = Number(value);
|
|
404
|
-
if (!Number.isInteger(id) || id <= 0) {
|
|
405
|
-
fail("usage", `'${value}' is not a todo id`, { todo: value });
|
|
406
|
-
}
|
|
407
|
-
return id;
|
|
408
|
-
});
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
/** A comma-separated flag's values, with no empty ones. */
|
|
412
|
-
function commaList(raw: string): string[] {
|
|
413
|
-
return raw
|
|
414
|
-
.split(",")
|
|
415
|
-
.map((value) => value.trim())
|
|
416
|
-
.filter(Boolean);
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
/**
|
|
420
|
-
* Which task a command that takes no task name is about, so the journal can
|
|
421
|
-
* name it. `merge` and `escalate` read it off the current branch.
|
|
422
|
-
*/
|
|
423
|
-
async function here({
|
|
424
|
-
service,
|
|
425
|
-
git,
|
|
426
|
-
ps,
|
|
427
|
-
}: ArborDeps & Repository): Promise<string | null> {
|
|
428
|
-
return service.taskFor(await git.currentBranch(ps.cwd()).catch(() => ""));
|
|
429
|
-
}
|
package/assets.d.ts
DELETED
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
// Bun imports any file as text with `with { type: "text" }`, but its types only
|
|
2
|
-
// declare the extensions it treats as text by default, and for `.html` and
|
|
3
|
-
// `.css` it declares something else entirely. The three files the dev page is
|
|
4
|
-
// served from are written as `.txt` for that reason: they are blobs a server
|
|
5
|
-
// hands to a browser, not modules anything here imports for their exports.
|
|
6
|
-
declare module "*.txt" {
|
|
7
|
-
const text: string;
|
|
8
|
-
export default text;
|
|
9
|
-
}
|
package/attachments.ts
DELETED
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
import { basename, resolve } from "node:path";
|
|
2
|
-
import { type IdProvider, UuidProvider } from "webappwiz/id";
|
|
3
|
-
import type { Fs, Ps } from "webappwiz/system";
|
|
4
|
-
import { fail } from "./exit";
|
|
5
|
-
|
|
6
|
-
/** A file handed over, as bytes so a pasted image needs no temp file. */
|
|
7
|
-
export interface Attachment {
|
|
8
|
-
/** What it was called, kept in the stored name so the files stay apart. */
|
|
9
|
-
name: string;
|
|
10
|
-
bytes: Uint8Array;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* The folder of files that belong to a todo: todo 7's beside its record, in
|
|
15
|
-
* `todos/7/`.
|
|
16
|
-
* Under `.git/arbor`, so nothing here is ever committed and every tree can
|
|
17
|
-
* open what it holds.
|
|
18
|
-
*/
|
|
19
|
-
export class Attachments {
|
|
20
|
-
constructor(
|
|
21
|
-
readonly dir: string,
|
|
22
|
-
private readonly fs: Fs,
|
|
23
|
-
private readonly ids: IdProvider = new UuidProvider(),
|
|
24
|
-
) {}
|
|
25
|
-
|
|
26
|
-
/** Copies each file in, returning the absolute path each was stored at. */
|
|
27
|
-
async store(files: Attachment[]): Promise<string[]> {
|
|
28
|
-
if (files.length === 0) {
|
|
29
|
-
return [];
|
|
30
|
-
}
|
|
31
|
-
await this.fs.mkdir(this.dir);
|
|
32
|
-
const stored: string[] = [];
|
|
33
|
-
for (const file of files) {
|
|
34
|
-
const path = `${this.dir}/${this.ids.next()}-${safe(file.name)}`;
|
|
35
|
-
await this.fs.writeBytes(path, file.bytes);
|
|
36
|
-
stored.push(path);
|
|
37
|
-
}
|
|
38
|
-
return stored;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** Whether `path` is one of this folder's files, and not a way out of it. */
|
|
42
|
-
owns(path: string): boolean {
|
|
43
|
-
return resolve(path).startsWith(`${resolve(this.dir)}/`);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** Deletes the files given that are this folder's; others are left alone. */
|
|
47
|
-
async remove(paths: string[]): Promise<void> {
|
|
48
|
-
for (const path of paths.filter((path) => this.owns(path))) {
|
|
49
|
-
await this.fs.rm(path, { force: true });
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** Deletes the folder and everything in it. */
|
|
54
|
-
async clear(): Promise<void> {
|
|
55
|
-
await this.fs.rm(this.dir, { recursive: true, force: true });
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Reads files named on the command line, relative to the current directory or
|
|
61
|
-
* absolute, refusing the whole command over one that cannot be read.
|
|
62
|
-
*/
|
|
63
|
-
export async function readFiles(
|
|
64
|
-
{ fs, ps }: { fs: Fs; ps: Ps },
|
|
65
|
-
paths: string[],
|
|
66
|
-
): Promise<Attachment[]> {
|
|
67
|
-
const read: Attachment[] = [];
|
|
68
|
-
for (const given of paths) {
|
|
69
|
-
const path = resolve(ps.cwd(), given);
|
|
70
|
-
const bytes = await fs.readBytes(path).catch(() => null);
|
|
71
|
-
if (bytes === null) {
|
|
72
|
-
fail("usage", `cannot read ${path}: nothing was changed`, {
|
|
73
|
-
file: path,
|
|
74
|
-
});
|
|
75
|
-
}
|
|
76
|
-
read.push({ name: basename(path), bytes });
|
|
77
|
-
}
|
|
78
|
-
return read;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* A stored name that survives being written into a one-line reply: paths are
|
|
83
|
-
* separated by spaces there, so nothing in them may be one.
|
|
84
|
-
*/
|
|
85
|
-
function safe(name: string): string {
|
|
86
|
-
return basename(name).replace(/[^\w.-]+/g, "-") || "attachment";
|
|
87
|
-
}
|