@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/plan.test.ts DELETED
@@ -1,318 +0,0 @@
1
- import { describe, expect, it } from "bun:test";
2
- import {
3
- checkPlan,
4
- plannedFiles,
5
- type Question,
6
- questionNumber,
7
- questions,
8
- withQuestion,
9
- } from "./plan";
10
-
11
- const GOOD = `# alpha
12
-
13
- ## Goal
14
- Teach show to check the plan.
15
-
16
- ## Files
17
- - plan.ts
18
-
19
- ## Done
20
- - [x] wrote the checker
21
-
22
- ## Next
23
- - [ ] wire it up
24
-
25
- ## Notes
26
- - lives in plan.ts
27
- `;
28
-
29
- describe("checkPlan", () => {
30
- it("passes a plan in the prescribed shape", () => {
31
- expect(checkPlan(GOOD, { task: "alpha" })).toEqual([]);
32
- });
33
-
34
- it("wants the title to name the task", () => {
35
- expect(
36
- checkPlan(GOOD.replace("# alpha", "# Alpha work"), { task: "alpha" }),
37
- ).toEqual(['title is "# Alpha work", should be "# alpha"']);
38
- });
39
-
40
- it("names the required sections that are missing", () => {
41
- const problems = checkPlan("# alpha\n", { task: "alpha" });
42
- expect(problems).toEqual([
43
- "no ## Goal section",
44
- "no ## Files section",
45
- "no ## Next section",
46
- ]);
47
- });
48
-
49
- it("wants the files the task plans to touch", () => {
50
- const problems = checkPlan(GOOD.replace("- plan.ts", ""), {
51
- task: "alpha",
52
- });
53
- expect(problems).toEqual([
54
- "## Files is empty: list the file paths you plan to touch",
55
- ]);
56
- });
57
-
58
- it("wants a next step, not just an empty heading", () => {
59
- const problems = checkPlan(GOOD.replace("- [ ] wire it up", ""), {
60
- task: "alpha",
61
- });
62
- expect(problems).toEqual([
63
- "## Next has no `- [ ]` item: say what the next step is",
64
- ]);
65
- });
66
-
67
- it("catches work parked under Done that is not done", () => {
68
- const problems = checkPlan(GOOD.replace("- [x] wrote", "- [ ] wrote"), {
69
- task: "alpha",
70
- });
71
- expect(problems).toEqual([
72
- "## Done has a `- [ ]` item: it belongs under ## Next",
73
- ]);
74
- });
75
-
76
- it("wants an escalation to leave numbered items behind", () => {
77
- expect(checkPlan(GOOD, { task: "alpha", escalated: true })).toEqual([
78
- "escalated with no ## Blocked section: add `- [ ] 1.` items for what the reviewer must do",
79
- ]);
80
- const vague = `${GOOD}\n## Blocked\nIs the new banner the right green?\n`;
81
- expect(checkPlan(vague, { task: "alpha", escalated: true })).toEqual([
82
- "## Blocked lists nothing: add `- [ ] 1.` items for what the reviewer must do",
83
- ]);
84
- const asked = `${GOOD}\n## Blocked\n- [ ] 1. Open /tmp/shot.png. Confirm the banner is green.\n`;
85
- expect(checkPlan(asked, { task: "alpha", escalated: true })).toEqual([]);
86
- const detailed = `${GOOD}\n## Blocked\n- [ ] 1. Does the banner fit?\n ![banner](/tmp/shot.png)\n - (a) Yes\n`;
87
- expect(checkPlan(detailed, { task: "alpha", escalated: true })).toEqual([]);
88
- });
89
-
90
- it("flags open items once the task is no longer escalated", () => {
91
- const items =
92
- "- [x] 1. Run the tests. → pass\n- [ ] 2. Decide: keep or drop?\n- [ ] 3. Open /tmp/a.png.\n";
93
- const blocked = `${GOOD}\n## Blocked\n${items}`;
94
- expect(checkPlan(blocked, { task: "alpha" })).toEqual([
95
- "## Blocked has open questions 2, 3: ask the reviewer before merging",
96
- ]);
97
- const answered = `${GOOD}\n## Blocked\n${items.replaceAll("- [ ]", "- [x]")}`;
98
- expect(checkPlan(answered, { task: "alpha" })).toEqual([]);
99
- // Answered but unchecked is the agent's to act on, not the reviewer's.
100
- const acting = `${GOOD}\n## Blocked\n- [ ] 1. Keep it? → yes\n → and rename it\n`;
101
- expect(checkPlan(acting, { task: "alpha" })).toEqual([]);
102
- });
103
-
104
- it("flags a section nobody will think to read", () => {
105
- const problems = checkPlan(`${GOOD}\n## Ideas\n- someday\n`, {
106
- task: "alpha",
107
- });
108
- expect(problems[0]).toContain('unexpected section "## Ideas"');
109
- });
110
-
111
- it("does not mistake a comment in a fenced block for a heading", () => {
112
- const fenced = `${GOOD}\n\`\`\`bash\n# alpha notes\n## Goal\n\`\`\`\n`;
113
- expect(checkPlan(fenced, { task: "alpha" })).toEqual([]);
114
- });
115
- });
116
-
117
- describe("plannedFiles", () => {
118
- it("takes each top-level bullet under ## Files, without backticks", () => {
119
- const plan = [
120
- "# alpha",
121
- "",
122
- "## Files",
123
- "",
124
- "Phase 1:",
125
- "",
126
- "- `a.ts`",
127
- "- b/{c,d}.ts",
128
- " - nested note",
129
- "",
130
- "## Next",
131
- "",
132
- "- [ ] not a file",
133
- ].join("\n");
134
-
135
- expect(plannedFiles(plan)).toEqual(["a.ts", "b/{c,d}.ts"]);
136
- });
137
-
138
- it("plans nothing without a ## Files section", () => {
139
- expect(plannedFiles("# alpha\n")).toEqual([]);
140
- });
141
- });
142
-
143
- const BLOCKED = `${GOOD}
144
- ## Blocked
145
-
146
- - [x] 1. Run the tests. Does it fit? → pass
147
- - [ ] 2. Does the header fit? → no, too wide
148
- - [ ] 3. Decide: keep or drop?
149
- - not a question of its own
150
- - [ ] 4. Confirm the copy.
151
- `;
152
-
153
- /** A question as `questions` reads it, with nothing under it. */
154
- function bare(overrides: Partial<Question>): Question {
155
- return {
156
- number: "1",
157
- done: false,
158
- text: "",
159
- body: "",
160
- reply: null,
161
- followUps: [],
162
- choices: [],
163
- pick: null,
164
- ...overrides,
165
- };
166
- }
167
-
168
- describe("questions", () => {
169
- it("still reads a plan numbered the old way, Q1", () => {
170
- const [asked] = questions("## Blocked\n\n- [ ] Q3. Keep it? → yes\n");
171
-
172
- expect(asked).toMatchObject({
173
- number: "3",
174
- text: "Keep it?",
175
- reply: "yes",
176
- });
177
- });
178
-
179
- it("reads each numbered item with its reply and what is indented under it", () => {
180
- expect(questions(BLOCKED)).toEqual([
181
- bare({
182
- number: "1",
183
- done: true,
184
- text: "Run the tests. Does it fit?",
185
- reply: "pass",
186
- }),
187
- bare({
188
- number: "2",
189
- text: "Does the header fit?",
190
- reply: "no, too wide",
191
- }),
192
- bare({
193
- number: "3",
194
- text: "Decide: keep or drop?",
195
- body: "- not a question of its own",
196
- }),
197
- bare({ number: "4", text: "Confirm the copy." }),
198
- ]);
199
- });
200
-
201
- it("reads only ## Blocked", () => {
202
- const elsewhere = GOOD.replace("- [ ] wire it up", "- [ ] 1. Not blocked.");
203
- expect(questions(elsewhere)).toEqual([]);
204
- const after = `${BLOCKED}\n## Notes\n- [ ] 9. Also not blocked.\n`;
205
- expect(questions(after).map((found) => found.number)).toEqual([
206
- "1",
207
- "2",
208
- "3",
209
- "4",
210
- ]);
211
- });
212
-
213
- it("keeps the body's markdown, code and images included", () => {
214
- const plan = `${GOOD}
215
- ## Blocked
216
-
217
- - [ ] 1. Does this migration look right?
218
- It runs before the deploy.
219
-
220
- \`\`\`sql
221
- alter table users
222
- add column email text;
223
- \`\`\`
224
-
225
- ![before](/tmp/before.png) ![after](/tmp/after.png)
226
- - (a) Ship it
227
- Prose back at the margin ends it.
228
- - (b) not a choice of 1
229
- `;
230
- expect(questions(plan)).toEqual([
231
- bare({
232
- text: "Does this migration look right?",
233
- body: [
234
- "It runs before the deploy.",
235
- "",
236
- "```sql",
237
- "alter table users",
238
- " add column email text;",
239
- "```",
240
- "",
241
- "![before](/tmp/before.png) ![after](/tmp/after.png)",
242
- ].join("\n"),
243
- choices: [{ key: "a", text: "Ship it" }],
244
- pick: "one",
245
- }),
246
- ]);
247
- });
248
- });
249
-
250
- const CHOICES = `${GOOD}
251
- ## Blocked
252
-
253
- - [ ] 1. How do old sessions move over?
254
- - (a) Force everyone to sign in again
255
- - (b) Migrate on next login
256
- - [ ] 2. Which table? → b (users): and backfill
257
- - (a) sessions
258
- - (b) users
259
- - [ ] 3. Anything else? → a bit more logging
260
- - [ ] 4. Pick one → c
261
- - (a) left
262
- - [ ] 5. Where should it notify? → c (Push), a: and log it
263
- - [a] Email
264
- - [b] Slack
265
- - [c] Push
266
- `;
267
-
268
- describe("choices", () => {
269
- it("reads the choices indented under each question", () => {
270
- const [first, second, third, fourth, fifth] = questions(CHOICES);
271
- expect(first).toMatchObject({
272
- choices: [
273
- { key: "a", text: "Force everyone to sign in again" },
274
- { key: "b", text: "Migrate on next login" },
275
- ],
276
- pick: "one",
277
- });
278
- expect(second?.choices.map((choice) => choice.key)).toEqual(["a", "b"]);
279
- expect(third).toMatchObject({ choices: [], pick: null });
280
- expect(fourth?.pick).toBe("one");
281
- expect(fifth).toMatchObject({ pick: "any" });
282
- });
283
- });
284
-
285
- describe("withQuestion", () => {
286
- it("numbers a new question after the rest, its detail indented under it", () => {
287
- const added = withQuestion(
288
- BLOCKED,
289
- "Ready to merge?",
290
- "Look at\nthe header.",
291
- );
292
- expect(added.number).toBe("5");
293
- expect(questions(added.plan).at(-1)).toMatchObject({
294
- number: "5",
295
- text: "Ready to merge?",
296
- body: "Look at\nthe header.",
297
- });
298
- expect(questions(added.plan)).toHaveLength(questions(BLOCKED).length + 1);
299
- });
300
-
301
- it("makes ## Blocked when there is none", () => {
302
- const added = withQuestion(GOOD, "Ready to merge?");
303
- expect(added).toEqual({
304
- plan: `${GOOD.trimEnd()}\n\n## Blocked\n\n- [ ] 1. Ready to merge?\n`,
305
- number: "1",
306
- });
307
- });
308
- });
309
-
310
- describe("questionNumber", () => {
311
- it("reads a number however it was typed", () => {
312
- for (const raw of ["9", "q9", "Q9", "9.", " q9: "]) {
313
- expect(questionNumber(raw)).toBe("9");
314
- }
315
- expect(questionNumber("nine")).toBeNull();
316
- expect(questionNumber("D9")).toBeNull();
317
- });
318
- });
package/plan.ts DELETED
@@ -1,302 +0,0 @@
1
- import { Markdown } from "webappwiz/md";
2
-
3
- /** The plan each task keeps at its worktree root, excluded from git. */
4
- export const PLAN_FILE = "ARBOR.md";
5
-
6
- /** The h2 sections an `ARBOR.md` may have, in the order they belong in. */
7
- const SECTIONS = ["Goal", "Files", "Done", "Next", "Notes", "Blocked"];
8
- const REQUIRED = ["Goal", "Files", "Next"];
9
- const UNCHECKED = /^[ \t]*- \[ \]/m;
10
- // A `Q` before the number is how plans were numbered once, and still reads.
11
- const QUESTION = /^[ \t]*- \[[ xX]\] [Qq]?\d+\./m;
12
-
13
- const BULLET = /^- +(.+)$/;
14
-
15
- /**
16
- * The paths an `ARBOR.md` says its task will touch: each top-level bullet
17
- * under `## Files`, taken as written. Prose around the list is left out, and a
18
- * plan with no such section plans nothing.
19
- */
20
- export function plannedFiles(text: string): string[] {
21
- const md = Markdown.parse(text);
22
- if (!md.has("Files")) {
23
- return [];
24
- }
25
- return md
26
- .section("Files")
27
- .body.split("\n")
28
- .flatMap((line) => {
29
- const item = BULLET.exec(line)?.[1];
30
- return item === undefined ? [] : [item.replaceAll("`", "").trim()];
31
- });
32
- }
33
-
34
- /**
35
- * One `- [ ] 9.` item under `## Blocked`: something only a person can do.
36
- * The item's line is its subject; lines indented under it are its body, and
37
- * `- (a) ...` or `- [a] ...` lines among them are the answers it offers.
38
- */
39
- export interface Question {
40
- /** As the plan numbers it, `9`: the name a reply goes by. */
41
- number: string;
42
- /** Checked off, which the agent does once it has acted on the reply. */
43
- done: boolean;
44
- /** The item's own line, without its checkbox, number or reply. */
45
- text: string;
46
- /**
47
- * The markdown indented under it, dedented, choices left out: detail,
48
- * code blocks, `![shot](/abs/path.png)` images. Empty when there is none.
49
- */
50
- body: string;
51
- /** Whatever follows ` → `, or null when nobody has answered yet. */
52
- reply: string | null;
53
- /**
54
- * What a person added once the reply was read, oldest first: each an
55
- * indented `→ ` line under the question.
56
- */
57
- followUps: string[];
58
- /**
59
- * The answers it offers, in order. Empty for a question answered in words
60
- * alone. A reply can always add words to its picks, or answer in words
61
- * instead.
62
- */
63
- choices: Choice[];
64
- /**
65
- * `one` for `- (a)` choices, at most one of which a reply picks; `any` for
66
- * `- [a]` choices, where it picks all that apply. Null without choices.
67
- */
68
- pick: "one" | "any" | null;
69
- }
70
-
71
- /** One answer a question offers: `- (b) Migrate on next login`. */
72
- export interface Choice {
73
- /** The letter it goes by: `b`. */
74
- key: string;
75
- text: string;
76
- }
77
-
78
- const ITEM = /^[ \t]*- \[([ xX])\] [Qq]?(\d+)\.[ \t]*(.*)$/;
79
- /** What separates an item from its reply, spaces included. */
80
- const ARROW = " → ";
81
- /** A follow-up: a line of its own under the question, led by the arrow. */
82
- const FOLLOW_UP = /^[ \t]+→[ \t]+(.*)$/;
83
- /** `- (a) text` picks one, `- [a] text` picks any. */
84
- const CHOICE = /^[ \t]+- (?:\(([a-z])\)|\[([a-z])\])[ \t]+(.+)$/;
85
-
86
- /**
87
- * Every numbered item under `## Blocked`, open or checked off, each with the
88
- * indented lines under it. The reply is read off the item's own line: an
89
- * answer is one line, written by `arbor reply` or by hand after the arrow.
90
- */
91
- export function questions(text: string): Question[] {
92
- const found: { asked: Question; body: string[] }[] = [];
93
- // The question the lines being read sit under, if they still do.
94
- let last: { asked: Question; body: string[] } | undefined;
95
- for (const { line } of blockedLines(text)) {
96
- const asked = question(line);
97
- if (asked !== null) {
98
- last = { asked, body: [] };
99
- found.push(last);
100
- continue;
101
- }
102
- // A line back at the margin ends the question; blank ones do not.
103
- if (line.trim() !== "" && !/^[ \t]/.test(line)) {
104
- last = undefined;
105
- }
106
- if (last === undefined) {
107
- continue;
108
- }
109
- const followUp = FOLLOW_UP.exec(line);
110
- const choice = CHOICE.exec(line);
111
- if (followUp !== null) {
112
- last.asked.followUps.push((followUp[1] ?? "").trim());
113
- } else if (choice !== null) {
114
- last.asked.choices.push({
115
- key: choice[1] ?? choice[2] ?? "",
116
- text: (choice[3] ?? "").trim(),
117
- });
118
- last.asked.pick ??= choice[1] === undefined ? "any" : "one";
119
- } else {
120
- last.body.push(line);
121
- }
122
- }
123
- return found.map(({ asked, body }) => {
124
- asked.body = dedent(body);
125
- return asked;
126
- });
127
- }
128
-
129
- /** Lines with the indent they all share taken off, blank ends trimmed. */
130
- function dedent(lines: string[]): string {
131
- const indents = lines
132
- .filter((line) => line.trim() !== "")
133
- .map((line) => /^[ \t]*/.exec(line)?.[0].length ?? 0);
134
- const cut = indents.length === 0 ? 0 : Math.min(...indents);
135
- return lines
136
- .map((line) => line.slice(cut).trimEnd())
137
- .join("\n")
138
- .replace(/^\n+|\s+$/g, "");
139
- }
140
-
141
- /**
142
- * The plan with a new open question at the end of `## Blocked`, the section
143
- * made if missing, numbered after the highest there. Returns the plan and
144
- * the number it went by.
145
- */
146
- export function withQuestion(
147
- text: string,
148
- subject: string,
149
- body = "",
150
- ): { plan: string; number: string } {
151
- const numbers = questions(text).map((asked) => Number(asked.number));
152
- const number = String(Math.max(0, ...numbers) + 1);
153
- const detail = body.trim() === "" ? [] : body.trim().split("\n");
154
- const item = [
155
- `- [ ] ${number}. ${subject.trim()}`,
156
- ...detail.map((line) => ` ${line}`.trimEnd()),
157
- ];
158
- const blocked = blockedLines(text);
159
- if (blocked.length === 0 && !/^## Blocked[ \t]*$/im.test(text)) {
160
- return {
161
- plan: `${text.trimEnd()}\n\n## Blocked\n\n${item.join("\n")}\n`,
162
- number,
163
- };
164
- }
165
- const lines = text.split("\n");
166
- const last = blocked.findLast(({ line }) => line.trim() !== "");
167
- const heading = lines.findIndex((line) => /^## Blocked[ \t]*$/i.test(line));
168
- const at = last === undefined ? heading + 1 : last.index + 1;
169
- lines.splice(at, 0, ...(last === undefined ? ["", ...item] : item));
170
- return { plan: lines.join("\n"), number };
171
- }
172
-
173
- /**
174
- * A question's number however a person typed it: `9`, `9.`, `Q9` and `q9:`
175
- * all mean `9`. Null for anything that is not a number at all.
176
- */
177
- export function questionNumber(raw: string): string | null {
178
- const digits = /^q?(\d+)[.:]?$/i.exec(raw.trim())?.[1];
179
- return digits === undefined ? null : String(Number(digits));
180
- }
181
-
182
- function question(line: string): Question | null {
183
- const item = ITEM.exec(line);
184
- if (item === null) {
185
- return null;
186
- }
187
- const [, check = " ", number = "", rest = ""] = item;
188
- const arrow = rest.indexOf(ARROW);
189
- return {
190
- number,
191
- done: check !== " ",
192
- text: (arrow === -1 ? rest : rest.slice(0, arrow)).trim(),
193
- body: "",
194
- reply: arrow === -1 ? null : rest.slice(arrow + ARROW.length).trim(),
195
- followUps: [],
196
- choices: [],
197
- pick: null,
198
- };
199
- }
200
-
201
- /**
202
- * The lines under `## Blocked`, with where each sits in the whole file so a
203
- * reply can be written back in place.
204
- */
205
- function blockedLines(text: string): { index: number; line: string }[] {
206
- const { sections } = Markdown.parse(text);
207
- const at = sections.findIndex(
208
- (section) => section.heading.toLowerCase() === "blocked",
209
- );
210
- const blocked = sections[at];
211
- if (blocked === undefined) {
212
- return [];
213
- }
214
- const lines = text.split("\n");
215
- // `line` is 1-based, so it is also the index of the line after the heading.
216
- const end =
217
- sections.slice(at + 1).find((later) => later.level <= blocked.level)
218
- ?.line ?? lines.length + 1;
219
- return lines
220
- .slice(blocked.line, end - 1)
221
- .map((line, offset) => ({ index: blocked.line + offset, line }));
222
- }
223
-
224
- export interface PlanOptions {
225
- /** The task name the title is expected to match. */
226
- task: string;
227
- /** Whether the task has escalated, which makes `## Blocked` required. */
228
- escalated?: boolean;
229
- }
230
-
231
- /**
232
- * Every way a task's `ARBOR.md` departs from the shape the agent skill
233
- * prescribes, phrased for the agent that wrote it. Advisory only: a resumable
234
- * plan is the point, and a malformed one still beats none, so nothing here
235
- * blocks a merge.
236
- */
237
- export function checkPlan(
238
- text: string,
239
- { task, escalated = false }: PlanOptions,
240
- ): string[] {
241
- const md = Markdown.parse(text);
242
- const problems: string[] = [];
243
- const section = (name: string) =>
244
- md.has(name) ? md.section(name).body : null;
245
-
246
- if (md.title !== task) {
247
- problems.push(
248
- md.title === null
249
- ? `no title: the first line should be "# ${task}"`
250
- : `title is "# ${md.title}", should be "# ${task}"`,
251
- );
252
- }
253
- for (const name of REQUIRED) {
254
- if (!md.has(name)) {
255
- problems.push(`no ## ${name} section`);
256
- }
257
- }
258
- if (section("Goal") === "") {
259
- problems.push("## Goal is empty: say in a line or two what done means");
260
- }
261
- if (section("Files") === "") {
262
- problems.push("## Files is empty: list the file paths you plan to touch");
263
- }
264
- const next = section("Next");
265
- if (next !== null && !UNCHECKED.test(next)) {
266
- problems.push("## Next has no `- [ ]` item: say what the next step is");
267
- }
268
- const done = section("Done");
269
- if (done !== null && UNCHECKED.test(done)) {
270
- problems.push("## Done has a `- [ ]` item: it belongs under ## Next");
271
- }
272
- const blocked = section("Blocked");
273
- if (escalated && blocked === null) {
274
- problems.push(
275
- "escalated with no ## Blocked section: add `- [ ] 1.` items for what the reviewer must do",
276
- );
277
- }
278
- if (blocked !== null && !QUESTION.test(blocked)) {
279
- problems.push(
280
- "## Blocked lists nothing: add `- [ ] 1.` items for what the reviewer must do",
281
- );
282
- }
283
- // Answered and unchecked is a follow-up to act on; unanswered needs a person.
284
- const open = questions(text).filter(
285
- (asked) => !asked.done && asked.reply === null,
286
- );
287
- if (!escalated && open.length > 0) {
288
- const numbers = open.map((asked) => asked.number).join(", ");
289
- problems.push(
290
- `## Blocked has open questions ${numbers}: ask the reviewer before merging`,
291
- );
292
- }
293
- const known = SECTIONS.map((section) => section.toLowerCase());
294
- for (const section of md.sections) {
295
- if (section.level === 2 && !known.includes(section.heading.toLowerCase())) {
296
- problems.push(
297
- `unexpected section "## ${section.heading}" (line ${section.line}): keep to ${SECTIONS.join(", ")}`,
298
- );
299
- }
300
- }
301
- return problems;
302
- }
package/progress.test.ts DELETED
@@ -1,54 +0,0 @@
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 DELETED
@@ -1,31 +0,0 @@
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
- }