@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/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 \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
|
-
 
|
|
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
|
-
" ",
|
|
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, `` 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
|
-
}
|