@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.
Files changed (120) hide show
  1. package/README.md +2 -2
  2. package/add.d.ts +23 -0
  3. package/age.d.ts +2 -0
  4. package/arbor.d.ts +11 -0
  5. package/attachments.d.ts +36 -0
  6. package/claim.d.ts +11 -0
  7. package/config.d.ts +55 -0
  8. package/config.js +7 -0
  9. package/dev/assets.d.ts +11 -0
  10. package/dev.d.ts +48 -0
  11. package/escalate.d.ts +28 -0
  12. package/exit.d.ts +42 -0
  13. package/git.d.ts +82 -0
  14. package/index.d.ts +2 -0
  15. package/index.js +4763 -0
  16. package/journal.d.ts +29 -0
  17. package/list.d.ts +18 -0
  18. package/load-config.d.ts +15 -0
  19. package/log.d.ts +14 -0
  20. package/merge.d.ts +24 -0
  21. package/package.json +15 -32
  22. package/path.d.ts +13 -0
  23. package/plan.d.ts +83 -0
  24. package/remove.d.ts +19 -0
  25. package/repository.d.ts +25 -0
  26. package/retry.d.ts +17 -0
  27. package/shell.d.ts +17 -0
  28. package/show.d.ts +53 -0
  29. package/snapshot.d.ts +36 -0
  30. package/table.d.ts +5 -0
  31. package/todo.d.ts +202 -0
  32. package/todos.d.ts +75 -0
  33. package/wait.d.ts +22 -0
  34. package/worktree-service.d.ts +51 -0
  35. package/worktree.d.ts +95 -0
  36. package/AGENTS.md +0 -7
  37. package/add.test.ts +0 -125
  38. package/add.ts +0 -192
  39. package/age.ts +0 -11
  40. package/arbor.test.ts +0 -145
  41. package/arbor.ts +0 -429
  42. package/assets.d.ts +0 -9
  43. package/attachments.ts +0 -87
  44. package/build.ts +0 -98
  45. package/claim.test.ts +0 -89
  46. package/claim.ts +0 -73
  47. package/components.json +0 -21
  48. package/config.ts +0 -58
  49. package/dev/api.ts +0 -120
  50. package/dev/app.test.tsx +0 -645
  51. package/dev/app.tsx +0 -80
  52. package/dev/assets.ts +0 -16
  53. package/dev/build/main.txt +0 -68
  54. package/dev/build/shell.txt +0 -21
  55. package/dev/build/styles.txt +0 -2263
  56. package/dev/components/ui/badge.tsx +0 -51
  57. package/dev/components/ui/button.tsx +0 -57
  58. package/dev/components/ui/dialog.tsx +0 -154
  59. package/dev/components/ui/empty.tsx +0 -100
  60. package/dev/components/ui/input-group.tsx +0 -155
  61. package/dev/components/ui/input.tsx +0 -19
  62. package/dev/components/ui/progress.tsx +0 -80
  63. package/dev/components/ui/textarea.tsx +0 -17
  64. package/dev/components/ui/toast.tsx +0 -227
  65. package/dev/feed.ts +0 -69
  66. package/dev/files.tsx +0 -191
  67. package/dev/index.html +0 -21
  68. package/dev/lib/utils.ts +0 -7
  69. package/dev/main.tsx +0 -22
  70. package/dev/markdown.test.tsx +0 -133
  71. package/dev/markdown.tsx +0 -124
  72. package/dev/mentions.tsx +0 -319
  73. package/dev/shadcn.css +0 -649
  74. package/dev/styles.css +0 -107
  75. package/dev/tags.tsx +0 -44
  76. package/dev/tasks.tsx +0 -151
  77. package/dev/todos.tsx +0 -759
  78. package/dev.test.ts +0 -439
  79. package/dev.ts +0 -386
  80. package/e2e.test.ts +0 -256
  81. package/escalate.test.ts +0 -76
  82. package/escalate.ts +0 -112
  83. package/exit.test.ts +0 -71
  84. package/exit.ts +0 -74
  85. package/git.ts +0 -294
  86. package/index.ts +0 -9
  87. package/journal.ts +0 -89
  88. package/list.test.ts +0 -82
  89. package/list.ts +0 -137
  90. package/load-config.test.ts +0 -121
  91. package/load-config.ts +0 -62
  92. package/log.test.ts +0 -65
  93. package/log.ts +0 -40
  94. package/merge.test.ts +0 -332
  95. package/merge.ts +0 -283
  96. package/path.test.ts +0 -35
  97. package/path.ts +0 -38
  98. package/plan.test.ts +0 -318
  99. package/plan.ts +0 -302
  100. package/progress.test.ts +0 -54
  101. package/progress.ts +0 -31
  102. package/remove.test.ts +0 -68
  103. package/remove.ts +0 -88
  104. package/repository.ts +0 -83
  105. package/retry.test.ts +0 -43
  106. package/retry.ts +0 -48
  107. package/shell.ts +0 -44
  108. package/show.test.ts +0 -91
  109. package/show.ts +0 -151
  110. package/snapshot.test.ts +0 -33
  111. package/snapshot.ts +0 -84
  112. package/table.ts +0 -20
  113. package/testing.ts +0 -229
  114. package/todo.test.ts +0 -490
  115. package/todo.ts +0 -834
  116. package/wait.test.ts +0 -61
  117. package/wait.ts +0 -77
  118. package/worktree-service.test.ts +0 -158
  119. package/worktree-service.ts +0 -193
  120. package/worktree.ts +0 -239
package/todos.d.ts ADDED
@@ -0,0 +1,75 @@
1
+ import { type IdProvider } from "webappwiz/id";
2
+ import { type Fs, type Lock } from "webappwiz/system";
3
+ import { Attachments } from "./attachments.js";
4
+ import { type TagState, Todo, type TodoNew, type TodoState } from "./todo.js";
5
+ /** Every todo in the repo, one JSON file each under `.git/arbor/todos`. */
6
+ export declare class Todos {
7
+ readonly dir: string;
8
+ /**
9
+ * Held while numbering a new todo, so two agents never share an id, and
10
+ * while writing one, so a move renumbering the rest never loses to a
11
+ * write of a position it just changed.
12
+ */
13
+ private readonly lock;
14
+ private readonly fs;
15
+ private readonly ids;
16
+ constructor(dir: string,
17
+ /**
18
+ * Held while numbering a new todo, so two agents never share an id, and
19
+ * while writing one, so a move renumbering the rest never loses to a
20
+ * write of a position it just changed.
21
+ */
22
+ lock: Lock, opts?: TodosOptions);
23
+ /** Where a todo's files live, beside its record. */
24
+ attachments(id: number): Attachments;
25
+ /** Whether `path` is a file some todo holds, and not a way out of here. */
26
+ owns(path: string): boolean;
27
+ add(subject: string, from: string | null, { text, files, position, tags }?: TodoNew): Promise<Todo>;
28
+ /**
29
+ * Top of the list first. A file that will not parse is skipped rather than
30
+ * fatal, and positions are counted again from 1 as they are read, so a
31
+ * gap or a tie left by a crash, or a todo saved before they had any (it
32
+ * goes below those that do, oldest first), never shows.
33
+ */
34
+ all(): Promise<Todo[]>;
35
+ find(id: number): Promise<Todo>;
36
+ /** Every tag a todo on the list has, by name, with how many have it. */
37
+ tags(): Promise<TagState[]>;
38
+ /** The todos `task` has taken; what a merge or remove settles. */
39
+ takenBy(task: string): Promise<Todo[]>;
40
+ /**
41
+ * Puts a todo at `position`, 1 at the top, past the bottom meaning the
42
+ * bottom, and numbers the rest around it.
43
+ */
44
+ move(id: number, position: number): Promise<Todo>;
45
+ /**
46
+ * @internal Rewrites one todo as it stands on disk now, under the lock, so
47
+ * a change to its words or who has it never undoes a move made meanwhile.
48
+ */
49
+ revise(id: number, change: (state: TodoState) => TodoState): Promise<Todo>;
50
+ /** @internal Writes one todo. Rename makes the swap atomic for readers. */
51
+ save(state: TodoState): Promise<Todo>;
52
+ /** @internal Removes one, moving those below it up to close the gap. */
53
+ delete(id: number): Promise<void>;
54
+ private locked;
55
+ /** One todo as it stands on disk; call it under the lock. */
56
+ private state;
57
+ /**
58
+ * Slots `state` in at `position` among the rest and saves whichever moved.
59
+ * Call it under the lock.
60
+ */
61
+ private place;
62
+ /** Every todo in list order, with the positions it has on disk. */
63
+ private stored;
64
+ /** Saves each todo in `order` whose position on disk is not its place there. */
65
+ private renumber;
66
+ private path;
67
+ private lastId;
68
+ private read;
69
+ }
70
+ /** What `Todos` is stored through; the real filesystem by default. */
71
+ export interface TodosOptions {
72
+ fs?: Fs;
73
+ /** Names stored files apart; a test counts. */
74
+ ids?: IdProvider;
75
+ }
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>;
@@ -0,0 +1,51 @@
1
+ import { type Fs, type Ps } from "webappwiz/system";
2
+ import type { Config } from "./config.js";
3
+ import type { Git, GitResult } from "./git.js";
4
+ import { type TaskState, Worktree } from "./worktree.js";
5
+ export interface AddOptions {
6
+ /** Branch the new worktree starts from. Defaults to the trunk. */
7
+ base?: string;
8
+ }
9
+ /**
10
+ * Where tasks live and everything persistent about them: the worktree
11
+ * directories, the records under `.git/arbor/tasks`, and the names of tasks
12
+ * already removed. One name, one lookup, whatever state it turns out to be in.
13
+ */
14
+ /** What a `WorktreeService` works through; the real ones by default. */
15
+ export interface WorktreeServiceOptions {
16
+ fs?: Fs;
17
+ ps?: Ps;
18
+ }
19
+ export declare class WorktreeService {
20
+ readonly git: Git;
21
+ readonly config: Config;
22
+ private readonly tasksDir;
23
+ private readonly removedDir;
24
+ private readonly fs;
25
+ readonly ps: Ps;
26
+ constructor(git: Git, config: Config, arborDir: string, opts?: WorktreeServiceOptions);
27
+ init(): Promise<void>;
28
+ get trunk(): string;
29
+ /** Where a task's worktree lives: a sibling of the repo, never inside it. */
30
+ pathFor(task: string): string;
31
+ branchFor(task: string): string;
32
+ taskFor(branch: string | null): string | null;
33
+ recordPath(task: string): string;
34
+ /** Always answers; the returned worktree's status says what was found. */
35
+ find(task: string): Promise<Worktree>;
36
+ list(): Promise<Worktree[]>;
37
+ /** Adds the branch and the working directory. The record comes after. */
38
+ add(task: string, { base }?: AddOptions): Promise<GitResult>;
39
+ discard(worktree: Worktree): Promise<GitResult>;
40
+ /** Writes a task's record. A concurrent reader sees the old one or the new. */
41
+ saveRecord(state: TaskState): Promise<void>;
42
+ private readRecord;
43
+ private removedPath;
44
+ private removedAt;
45
+ /**
46
+ * Remembers a removed name so a second `remove` can say "already removed"
47
+ * instead of "never existed", then drops the oldest so the list of
48
+ * remembered names cannot grow without bound.
49
+ */
50
+ private rememberRemoved;
51
+ }
package/worktree.d.ts ADDED
@@ -0,0 +1,95 @@
1
+ import type { GitResult } from "./git.js";
2
+ import type { WorktreeService } from "./worktree-service.js";
3
+ /**
4
+ * One task, whether or not it is still there. Commands ask the service for
5
+ * one of these and read its status rather than assembling the same handful of
6
+ * existence checks themselves.
7
+ */
8
+ export declare class Worktree {
9
+ private readonly service;
10
+ private readonly snapshot;
11
+ constructor(service: WorktreeService, snapshot: WorktreeSnapshot);
12
+ get task(): string;
13
+ get branch(): string;
14
+ /** Where this task's work lands: its recorded base branch, else trunk. */
15
+ get base(): string;
16
+ get path(): string;
17
+ get state(): TaskState | null;
18
+ get lease(): LeaseState | null;
19
+ get leaseHeld(): boolean;
20
+ get leaseStatus(): "held" | "stale" | "none";
21
+ get leaseOurs(): boolean;
22
+ get leaseHeldByOther(): boolean;
23
+ get mergeAttempts(): number;
24
+ get exists(): boolean;
25
+ get hasBranch(): boolean;
26
+ get removedAt(): string | null;
27
+ get status(): WorktreeStatus;
28
+ get gone(): boolean;
29
+ /**
30
+ * Merges changes into the record and writes it. Works on a name that has no
31
+ * record yet, which is how `add` writes the first one and how `claim`
32
+ * rebuilds one from a worktree found on disk.
33
+ */
34
+ save(changes?: Partial<TaskState>): Promise<Worktree>;
35
+ take(changes?: Partial<TaskState>): Promise<Worktree>;
36
+ /** Re-reads from disk. `merge` needs this: the record is the truth. */
37
+ reload(): Promise<Worktree>;
38
+ /** Removes the directory, the branch, and the record; remembers the name. */
39
+ discard(): Promise<GitResult>;
40
+ commitsAhead(): Promise<number | null>;
41
+ diffStat(): Promise<{
42
+ added: number;
43
+ removed: number;
44
+ } | null>;
45
+ /** Committed and uncommitted paths this task touches; null without a branch. */
46
+ changedFiles(): Promise<string[] | null>;
47
+ uncommitted(): Promise<string[]>;
48
+ interruptedOps(): Promise<string[]>;
49
+ }
50
+ /** The status a task's own record carries. */
51
+ export type RecordStatus = "working" | "merging" | "escalated";
52
+ /** What a task's record stores about who is driving it. */
53
+ export interface LeaseState {
54
+ pid: number;
55
+ hostname: string;
56
+ heartbeatAt: string;
57
+ }
58
+ export interface Escalation {
59
+ reason: string;
60
+ at: string;
61
+ /**
62
+ * For a task handed over to be approved rather than to answer questions,
63
+ * the question in its plan that asks for the approval, `4`.
64
+ */
65
+ review?: string;
66
+ }
67
+ export interface TaskState {
68
+ task: string;
69
+ branch: string;
70
+ worktree: string;
71
+ /** The branch this task lands on. Absent in old records: trunk. */
72
+ base?: string;
73
+ status: RecordStatus;
74
+ lease: LeaseState | null;
75
+ mergeAttempts: number;
76
+ createdAt: string;
77
+ updatedAt: string;
78
+ escalations?: Escalation[];
79
+ }
80
+ /**
81
+ * Everything a name can turn out to be. The record's own status when the
82
+ * task is intact, and otherwise the way in which it is not.
83
+ */
84
+ export type WorktreeStatus = RecordStatus | "absent" | "removed" | "orphaned" | "stray" | "unrecorded" | "unknown";
85
+ /** What the service found on disk. */
86
+ export interface WorktreeSnapshot {
87
+ task: string;
88
+ branch: string;
89
+ path: string;
90
+ state: TaskState | null;
91
+ exists: boolean;
92
+ hasBranch: boolean;
93
+ removedAt: string | null;
94
+ corrupt: boolean;
95
+ }
package/AGENTS.md DELETED
@@ -1,7 +0,0 @@
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/add.test.ts DELETED
@@ -1,125 +0,0 @@
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 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
- }