@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
@@ -0,0 +1,54 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { progress } from "./progress";
3
+
4
+ const plan = (...lines: string[]): string => `${lines.join("\n")}\n`;
5
+
6
+ describe("progress", () => {
7
+ it("counts the ticked boxes against every box in Done and Next", () => {
8
+ expect(
9
+ progress(
10
+ plan(
11
+ "# alpha",
12
+ "",
13
+ "## Done",
14
+ "- [x] one",
15
+ "- [x] two",
16
+ "",
17
+ "## Next",
18
+ "- [ ] three",
19
+ "- [ ] four",
20
+ ),
21
+ ),
22
+ ).toEqual({ done: 2, total: 4 });
23
+ });
24
+
25
+ it("ignores a checklist outside the sections that hold the work", () => {
26
+ expect(
27
+ progress(
28
+ plan(
29
+ "# alpha",
30
+ "",
31
+ "## Next",
32
+ "- [ ] the rest",
33
+ "",
34
+ "## Notes",
35
+ "- [ ] a stray box here must not count",
36
+ ),
37
+ ),
38
+ ).toEqual({ done: 0, total: 1 });
39
+ });
40
+
41
+ it("reports nothing to count when the file has no checklist", () => {
42
+ expect(progress(plan("# alpha", "", "## Goal", "land it"))).toBeNull();
43
+ });
44
+
45
+ it("reports nothing to count when neither section is there at all", () => {
46
+ expect(progress(plan("- [x] a box under no heading"))).toBeNull();
47
+ });
48
+
49
+ it("counts an upper-case X as ticked, the way the CLI does", () => {
50
+ expect(
51
+ progress(plan("## Done", "- [X] one", "", "## Next", "- [ ] two")),
52
+ ).toEqual({ done: 1, total: 2 });
53
+ });
54
+ });
package/progress.ts ADDED
@@ -0,0 +1,31 @@
1
+ import { Markdown } from "webappwiz/md";
2
+
3
+ const BOX = /^[ \t]*- \[([ xX])\]/gm;
4
+
5
+ /** The sections that hold the work, so a checklist under Notes cannot skew it. */
6
+ const WORK = ["Done", "Next"];
7
+
8
+ export interface Progress {
9
+ done: number;
10
+ total: number;
11
+ }
12
+
13
+ /**
14
+ * How far along a task is, counted off its `ARBOR.md` checkboxes rather than
15
+ * anything arbor records: the agent moving an item to `## Done` is the only
16
+ * signal there is that a step finished. Null when there is nothing to count,
17
+ * which is a task whose file has no checklist yet.
18
+ */
19
+ export function progress(plan: string): Progress | null {
20
+ const md = Markdown.parse(plan);
21
+ const boxes = WORK.flatMap((name) =>
22
+ md.has(name) ? [...md.section(name).body.matchAll(BOX)] : [],
23
+ );
24
+ if (boxes.length === 0) {
25
+ return null;
26
+ }
27
+ return {
28
+ done: boxes.filter((box) => box[1] !== " ").length,
29
+ total: boxes.length,
30
+ };
31
+ }
package/remove.test.ts ADDED
@@ -0,0 +1,68 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
+ import { add } from "./add";
3
+ import { remove } from "./remove";
4
+ import { LIVE_PID, Testing } from "./testing";
5
+
6
+ describe("remove", () => {
7
+ let deps: Testing;
8
+
9
+ beforeEach(async () => {
10
+ deps = await Testing.open();
11
+ });
12
+
13
+ afterEach(() => deps.disposeAsync());
14
+
15
+ it("removes everything and says what was thrown away", async () => {
16
+ await add(deps, "alpha");
17
+ const worktree = (await deps.service.find("alpha")).path;
18
+ await deps.commit(worktree, "alpha.txt", "alpha\n", "unlanded work");
19
+
20
+ await remove(deps, "alpha");
21
+
22
+ expect(deps.out()).toContain("discarded 1 commit(s)");
23
+ expect(await deps.fs.exists(worktree)).toBe(false);
24
+ expect(await deps.fs.exists(deps.service.recordPath("alpha"))).toBe(false);
25
+ expect(await deps.gitCli(deps.root, "branch", "--list", "task/alpha")).toBe(
26
+ "",
27
+ );
28
+ });
29
+
30
+ it("tells 'already removed' apart from 'never existed'", async () => {
31
+ await add(deps, "alpha");
32
+ await remove(deps, "alpha");
33
+
34
+ await expect(remove(deps, "alpha")).toBail("already_removed");
35
+ await expect(remove(deps, "never")).toBail("not_found");
36
+ });
37
+
38
+ it("cleans up leftovers when the worktree directory is already gone", async () => {
39
+ await add(deps, "alpha");
40
+ const worktree = (await deps.service.find("alpha")).path;
41
+ await deps.fs.rm(worktree, { recursive: true, force: true });
42
+
43
+ await remove(deps, "alpha");
44
+
45
+ expect(deps.out()).toContain("already gone");
46
+ expect(await deps.gitCli(deps.root, "branch", "--list", "task/alpha")).toBe(
47
+ "",
48
+ );
49
+ });
50
+
51
+ it("refuses a tree another agent is driving, unless forced", async () => {
52
+ await add(deps, "alpha");
53
+ const worktree = await (await deps.service.find("alpha")).save({
54
+ lease: {
55
+ pid: LIVE_PID,
56
+ hostname: deps.ps.hostname,
57
+ heartbeatAt: new Date().toISOString(),
58
+ },
59
+ });
60
+
61
+ await expect(remove(deps, "alpha")).toBail("lease_held", {
62
+ message: "--force",
63
+ });
64
+
65
+ await remove(deps, "alpha", { force: true });
66
+ expect(await deps.fs.exists(worktree.path)).toBe(false);
67
+ });
68
+ });
package/remove.ts ADDED
@@ -0,0 +1,88 @@
1
+ import { color, type Logger } from "webappwiz/log";
2
+ import { fail } from "./exit";
3
+ import type { Todos } from "./todo";
4
+ import type { WorktreeService } from "./worktree-service";
5
+
6
+ export interface RemoveOptions {
7
+ /** Discard the tree even when another agent holds its lease. */
8
+ force?: boolean;
9
+ }
10
+
11
+ /**
12
+ * Discards a whole task: `git worktree remove` plus the branch and the
13
+ * record.
14
+ *
15
+ * Throwing a task away and redoing it against current trunk is usually cheaper
16
+ * than a hard rebase, so this is meant to be used freely.
17
+ */
18
+ export async function remove(
19
+ {
20
+ service,
21
+ log,
22
+ todos,
23
+ }: { service: WorktreeService; log: Logger; todos: Todos },
24
+ task: string,
25
+ { force = false }: RemoveOptions = {},
26
+ ): Promise<void> {
27
+ const worktree = await service.find(task);
28
+
29
+ if (worktree.gone) {
30
+ const removed = worktree.status === "removed";
31
+ fail(
32
+ removed ? "already_removed" : "not_found",
33
+ removed
34
+ ? `'${task}' was already removed (${worktree.removedAt}), nothing left to remove`
35
+ : `no worktree, branch or state file named '${task}', so it never existed here`,
36
+ { task },
37
+ );
38
+ }
39
+
40
+ if (worktree.leaseHeldByOther) {
41
+ if (!force) {
42
+ fail(
43
+ "lease_held",
44
+ `'${task}' is held by pid ${worktree.lease?.pid} on ${worktree.lease?.hostname}: pass --force to discard it anyway`,
45
+ { task, lease: worktree.lease },
46
+ );
47
+ }
48
+ log.error(
49
+ color.yellow(
50
+ `arbor: --force discarding a tree held by pid ${worktree.lease?.pid}`,
51
+ ),
52
+ );
53
+ }
54
+
55
+ // Counted before the branch goes, so the report can say what was lost.
56
+ const unlanded = worktree.hasBranch ? await worktree.commitsAhead() : 0;
57
+
58
+ const discarded = await worktree.discard();
59
+ if (discarded.code !== 0) {
60
+ fail("usage", `discarding '${task}' failed: ${discarded.stderr}`, {
61
+ task,
62
+ });
63
+ }
64
+
65
+ // The work was thrown away, not done, so what it took up is open again.
66
+ const released = await todos.takenBy(task);
67
+ for (const todo of released) {
68
+ await todo.release();
69
+ }
70
+
71
+ const lines = [
72
+ `${color.green("removed")} ${task}`,
73
+ ` worktree: ${worktree.exists ? worktree.path : "already gone"}`,
74
+ ` branch: ${worktree.hasBranch ? worktree.branch : "already gone"}`,
75
+ ` state: ${worktree.state ? "removed" : "already gone"}`,
76
+ ];
77
+ if (unlanded) {
78
+ lines.push(
79
+ color.yellow(
80
+ ` discarded ${unlanded} commit(s) that were never on ${worktree.base}`,
81
+ ),
82
+ );
83
+ }
84
+ for (const todo of released) {
85
+ lines.push(` todo ${todo.id} is open again: ${todo.subject}`);
86
+ }
87
+ log.info(lines.join("\n"));
88
+ }
package/repository.ts ADDED
@@ -0,0 +1,83 @@
1
+ import { dirname } from "node:path";
2
+ import type { Deps, Middleware } from "webappwiz/cmd";
3
+ import { FileLock, type Fs, type Lock } from "webappwiz/system";
4
+ import type { Config } from "./config";
5
+ import { Git } from "./git";
6
+ import { Journal } from "./journal";
7
+ import { loadConfig } from "./load-config";
8
+ import { Shell } from "./shell";
9
+ import { Todos } from "./todo";
10
+ import { WorktreeService } from "./worktree-service";
11
+
12
+ /** What a command gets to work with, once there is a repository to work in. */
13
+ export interface Repository {
14
+ config: Config;
15
+ git: Git;
16
+ service: WorktreeService;
17
+ lock: Lock;
18
+ shell: Shell;
19
+ journal: Journal;
20
+ todos: Todos;
21
+ }
22
+
23
+ /**
24
+ * Runs the action against the repository `cwd` sits in, and refuses to run it
25
+ * at all when that is not a repository.
26
+ */
27
+ export function repository<C extends Deps & { fs: Fs }>(
28
+ at?: string,
29
+ ): Middleware<C, C & Repository> {
30
+ // Nothing below here touches `Fs` or `Ps`: this is the only place they are
31
+ // spoken.
32
+ return async (ctx, next) => {
33
+ const { fs, ps, log } = ctx;
34
+ const cwd = at ?? ps.cwd();
35
+ const { exitCode, stdout } = await ps.spawnCapture([
36
+ "git",
37
+ "-C",
38
+ cwd,
39
+ "rev-parse",
40
+ "--path-format=absolute",
41
+ "--git-common-dir",
42
+ ]);
43
+ if (exitCode !== 0) {
44
+ throw new Error(`not a git repository: ${cwd}`);
45
+ }
46
+ // Every worktree shares one `.git`, so state kept under it is visible to
47
+ // every agent and is inherently untracked.
48
+ const gitDir = stdout.trim();
49
+ const arborDir = `${gitDir}/arbor`;
50
+
51
+ const git = new Git(dirname(gitDir), { ps, fs });
52
+ const config = await loadConfig(dirname(gitDir), { fs, git });
53
+ const service = new WorktreeService(git, config, arborDir, { fs, ps });
54
+ await service.init();
55
+
56
+ await next({
57
+ ...ctx,
58
+ config,
59
+ git,
60
+ service,
61
+ lock: new FileLock(`${arborDir}/merge.lock`, {
62
+ fs,
63
+ ps,
64
+ log,
65
+ stalenessMs: config.leaseStalenessMs,
66
+ }),
67
+ shell: new Shell({ ps }),
68
+ journal: new Journal(`${arborDir}/log.jsonl`, config.logCapacity, {
69
+ fs,
70
+ }),
71
+ todos: new Todos(
72
+ `${arborDir}/todos`,
73
+ new FileLock(`${arborDir}/todos.lock`, {
74
+ fs,
75
+ ps,
76
+ log,
77
+ stalenessMs: config.leaseStalenessMs,
78
+ }),
79
+ { fs },
80
+ ),
81
+ });
82
+ };
83
+ }
package/retry.test.ts ADDED
@@ -0,0 +1,43 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
+ import { add } from "./add";
3
+ import { retry } from "./retry";
4
+ import { Testing } from "./testing";
5
+
6
+ describe("retry", () => {
7
+ let deps: Testing;
8
+
9
+ beforeEach(async () => {
10
+ deps = await Testing.open();
11
+ });
12
+
13
+ afterEach(() => deps.disposeAsync());
14
+
15
+ it("puts an escalated task back to working with a fresh budget", async () => {
16
+ await add(deps, "alpha");
17
+ const spent = await deps.service.find("alpha");
18
+ await spent.save({
19
+ status: "escalated",
20
+ mergeAttempts: deps.config.mergeRetryCount,
21
+ });
22
+
23
+ await retry(deps, "alpha");
24
+
25
+ expect((await deps.service.find("alpha")).state).toMatchObject({
26
+ status: "working",
27
+ mergeAttempts: 0,
28
+ });
29
+ });
30
+
31
+ it("refuses a task nobody has escalated", async () => {
32
+ await add(deps, "alpha");
33
+
34
+ await expect(retry(deps, "alpha")).toBail("usage", {
35
+ message: "not escalated",
36
+ });
37
+ expect((await deps.service.find("alpha")).state?.status).toBe("working");
38
+ });
39
+
40
+ it("refuses a name with no task", async () => {
41
+ await expect(retry(deps, "nope")).toBail("not_found");
42
+ });
43
+ });
package/retry.ts ADDED
@@ -0,0 +1,48 @@
1
+ import { color, type Logger } from "webappwiz/log";
2
+ import type { Config } from "./config";
3
+ import { fail } from "./exit";
4
+ import type { WorktreeService } from "./worktree-service";
5
+
6
+ /**
7
+ * The way back from `budget_exhausted`. A task that has spent its merge
8
+ * attempts gets another `mergeRetryCount` of them, instead of `remove` and redo.
9
+ *
10
+ * Only from `escalated`, and deliberately: the budget's whole job is to make an
11
+ * agent stop and hand the task over. An agent that could grant itself more
12
+ * would be back to grinding against a moving trunk forever, so the price of a
13
+ * fresh budget is that a human has looked at the tree first.
14
+ */
15
+ export async function retry(
16
+ {
17
+ service,
18
+ config,
19
+ log,
20
+ }: { service: WorktreeService; config: Config; log: Logger },
21
+ task: string,
22
+ ): Promise<void> {
23
+ const found = await service.find(task);
24
+ if (found.gone) {
25
+ fail("not_found", `no task '${task}'`, { task });
26
+ }
27
+ if (found.status !== "escalated") {
28
+ fail(
29
+ "usage",
30
+ `'${task}' is ${found.status}, not escalated: only a task handed to a human can be granted more merge attempts`,
31
+ { task, status: found.status },
32
+ );
33
+ }
34
+
35
+ // The lease stays as escalate left it: whoever picks the task up takes it
36
+ // with `claim`, the same as any other resume.
37
+ const worktree = await found.save({ status: "working", mergeAttempts: 0 });
38
+
39
+ log.info(
40
+ [
41
+ `${color.green("retry")} ${worktree.task}`,
42
+ ` attempts: 0 of ${config.mergeRetryCount}`,
43
+ ` status: working`,
44
+ "",
45
+ `Run \`arbor claim ${worktree.task}\` to pick it up.`,
46
+ ].join("\n"),
47
+ );
48
+ }
package/shell.ts ADDED
@@ -0,0 +1,44 @@
1
+ import {
2
+ NodePs,
3
+ type Ps,
4
+ type SpawnCaptureResult,
5
+ type SpawnOptions,
6
+ type SpawnResult,
7
+ } from "webappwiz/system";
8
+
9
+ /** What a `Shell` spawns through; the real process by default. */
10
+ export interface ShellOptions {
11
+ ps?: Ps;
12
+ }
13
+
14
+ /**
15
+ * Runs the commands a repo configures (the lifecycle hooks) as shell strings
16
+ * rather than argv, since that is how a repo writes them.
17
+ */
18
+ export class Shell {
19
+ private readonly ps: Ps;
20
+
21
+ // This is the whole of what `merge` and `add` are allowed to do to the
22
+ // process. Handing them a `Ps` instead would hand them `exit` and `cd` too.
23
+ constructor(opts: ShellOptions = {}) {
24
+ this.ps = opts.ps ?? new NodePs();
25
+ }
26
+
27
+ /** Captures output, for commands whose failure has to be reported back. */
28
+ run(
29
+ command: string,
30
+ cwd: string,
31
+ opts: SpawnOptions = {},
32
+ ): Promise<SpawnCaptureResult> {
33
+ return this.ps.spawnCapture(["sh", "-c", command], { ...opts, cwd });
34
+ }
35
+
36
+ /** Inherits stdio, for commands the agent should watch as they run. */
37
+ stream(
38
+ command: string,
39
+ cwd: string,
40
+ opts: SpawnOptions = {},
41
+ ): Promise<SpawnResult> {
42
+ return this.ps.spawn(["sh", "-c", command], { ...opts, cwd });
43
+ }
44
+ }
package/show.test.ts ADDED
@@ -0,0 +1,91 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
+ import { add } from "./add";
3
+ import { show } from "./show";
4
+ import { Testing } from "./testing";
5
+
6
+ describe("show", () => {
7
+ let deps: Testing;
8
+
9
+ beforeEach(async () => {
10
+ deps = await Testing.open();
11
+ });
12
+
13
+ afterEach(() => deps.disposeAsync());
14
+
15
+ it("reports a task and prints the ARBOR.md left in its worktree", async () => {
16
+ await add(deps, "alpha");
17
+ const alpha = (await deps.service.find("alpha")).path;
18
+ await deps.fs.write(
19
+ `${alpha}/ARBOR.md`,
20
+ "# alpha\n\n## Next\n- [ ] the rest\n",
21
+ );
22
+ deps.log.clear();
23
+
24
+ await show(deps, "alpha");
25
+
26
+ expect(deps.out()).toContain("task/alpha");
27
+ expect(deps.out()).toContain("working");
28
+ expect(deps.out()).toContain("- [ ] the rest");
29
+ });
30
+
31
+ it("says how an ARBOR.md departs from the shape the skill prescribes", async () => {
32
+ await add(deps, "alpha");
33
+ const alpha = (await deps.service.find("alpha")).path;
34
+ await deps.fs.write(
35
+ `${alpha}/ARBOR.md`,
36
+ "# something else\n\n## Goal\nland it\n",
37
+ );
38
+ deps.log.clear();
39
+
40
+ await show(deps, "alpha");
41
+
42
+ expect(deps.out()).toContain('should be "# alpha"');
43
+ expect(deps.out()).toContain("no ## Next section");
44
+ });
45
+
46
+ it("says the ARBOR.md is missing rather than staying silent", async () => {
47
+ await add(deps, "alpha");
48
+ const alpha = (await deps.service.find("alpha")).path;
49
+ await deps.fs.rm(`${alpha}/ARBOR.md`);
50
+ deps.log.clear();
51
+
52
+ await show(deps, "alpha");
53
+
54
+ expect(deps.out()).toContain("no ARBOR.md");
55
+ });
56
+
57
+ it("carries the task's fields and its ARBOR.md as JSON", async () => {
58
+ await add(deps, "alpha");
59
+ const alpha = (await deps.service.find("alpha")).path;
60
+ await deps.fs.write(`${alpha}/ARBOR.md`, "# alpha\n");
61
+ deps.log.clear();
62
+
63
+ await show(deps, "alpha", { json: true });
64
+
65
+ expect(JSON.parse(deps.out())).toMatchObject({
66
+ task: "alpha",
67
+ status: "working",
68
+ branch: "task/alpha",
69
+ ahead: 0,
70
+ plan: "# alpha\n",
71
+ });
72
+ });
73
+
74
+ it("refuses a task that was never created", async () => {
75
+ await expect(show(deps, "ghost")).toBail("not_found");
76
+ });
77
+
78
+ it("still describes a task whose worktree is gone", async () => {
79
+ await add(deps, "alpha");
80
+ await deps.fs.rm((await deps.service.find("alpha")).path, {
81
+ recursive: true,
82
+ force: true,
83
+ });
84
+ deps.log.clear();
85
+
86
+ await show(deps, "alpha");
87
+
88
+ expect(deps.out()).toContain("orphaned");
89
+ expect(deps.out()).not.toContain("no ARBOR.md");
90
+ });
91
+ });