schub 0.1.2 → 0.1.4

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 (80) hide show
  1. package/README.md +27 -0
  2. package/dist/index.js +12830 -3057
  3. package/package.json +5 -2
  4. package/skills/create-proposal/SKILL.md +5 -1
  5. package/skills/create-tasks/SKILL.md +5 -4
  6. package/skills/implement-task/SKILL.md +6 -1
  7. package/skills/review-proposal/SKILL.md +3 -2
  8. package/skills/update-roadmap/SKILL.md +23 -0
  9. package/src/changes.test.ts +166 -0
  10. package/src/changes.ts +159 -54
  11. package/src/commands/adr.test.ts +6 -5
  12. package/src/commands/changes.test.ts +136 -14
  13. package/src/commands/changes.ts +102 -1
  14. package/src/commands/cookbook.test.ts +6 -5
  15. package/src/commands/init.test.ts +69 -2
  16. package/src/commands/init.ts +48 -5
  17. package/src/commands/review.test.ts +7 -6
  18. package/src/commands/review.ts +1 -1
  19. package/src/commands/roadmap.test.ts +84 -0
  20. package/src/commands/roadmap.ts +84 -0
  21. package/src/commands/tasks-create.test.ts +22 -22
  22. package/src/commands/tasks-implement.test.ts +253 -0
  23. package/src/commands/tasks-implement.ts +121 -0
  24. package/src/commands/tasks-list.test.ts +27 -27
  25. package/src/commands/tasks-update.test.ts +92 -0
  26. package/src/commands/tasks.ts +98 -1
  27. package/src/features/roadmap/index.ts +230 -0
  28. package/src/features/roadmap/roadmap.test.ts +77 -0
  29. package/src/features/tasks/constants.ts +1 -0
  30. package/src/features/tasks/create.ts +10 -8
  31. package/src/features/tasks/filesystem.test.ts +285 -18
  32. package/src/features/tasks/filesystem.ts +152 -39
  33. package/src/features/tasks/graph.ts +18 -3
  34. package/src/features/tasks/index.ts +10 -1
  35. package/src/features/tasks/worktree.ts +48 -0
  36. package/src/frontmatter.ts +115 -0
  37. package/src/index.test.ts +42 -6
  38. package/src/index.ts +226 -109
  39. package/src/opencode.test.ts +53 -0
  40. package/src/opencode.ts +74 -0
  41. package/src/tasks.ts +2 -0
  42. package/src/tui/App.test.tsx +418 -0
  43. package/src/tui/App.tsx +343 -0
  44. package/src/tui/components/PlanView.test.tsx +101 -0
  45. package/src/tui/components/PlanView.tsx +89 -0
  46. package/src/tui/components/PreviewPage.test.tsx +69 -0
  47. package/src/tui/components/PreviewPage.tsx +87 -0
  48. package/src/tui/components/ProposalDetailView.test.tsx +169 -0
  49. package/src/tui/components/ProposalDetailView.tsx +166 -0
  50. package/src/tui/components/RoadmapView.test.tsx +85 -0
  51. package/src/tui/components/RoadmapView.tsx +369 -0
  52. package/src/tui/components/StatusView.test.tsx +1351 -0
  53. package/src/tui/components/StatusView.tsx +519 -0
  54. package/src/tui/components/markdown-renderer.test.ts +46 -0
  55. package/src/tui/components/markdown-renderer.ts +89 -0
  56. package/src/tui/components/status-view-data.ts +322 -0
  57. package/src/tui/components/status-view-render.tsx +329 -0
  58. package/src/tui/index.ts +16 -0
  59. package/templates/create-proposal/adr-template.md +6 -4
  60. package/templates/create-proposal/cookbook-template.md +5 -3
  61. package/templates/create-proposal/proposal-template.md +8 -6
  62. package/templates/create-roadmap/roadmap.md +5 -0
  63. package/templates/create-tasks/task-template.md +9 -4
  64. package/templates/review-proposal/q&a-template.md +8 -3
  65. package/templates/review-proposal/review-me-template.md +6 -4
  66. package/templates/setup-project/project-overview-template.md +5 -0
  67. package/templates/setup-project/project-setup-template.md +5 -0
  68. package/templates/setup-project/project-wow-template.md +5 -0
  69. package/src/App.test.tsx +0 -93
  70. package/src/App.tsx +0 -155
  71. package/src/components/PlanView.test.tsx +0 -113
  72. package/src/components/PlanView.tsx +0 -160
  73. package/src/components/StatusView.test.tsx +0 -380
  74. package/src/components/StatusView.tsx +0 -367
  75. package/src/ide.ts +0 -7
  76. package/templates/templates-parity.test.ts +0 -45
  77. /package/src/{clipboard.ts → tui/clipboard.ts} +0 -0
  78. /package/src/{components → tui/components}/statusColor.ts +0 -0
  79. /package/src/{terminal.test.ts → tui/terminal.test.ts} +0 -0
  80. /package/src/{terminal.ts → tui/terminal.ts} +0 -0
@@ -1,380 +0,0 @@
1
- import { expect, test } from "bun:test";
2
- import { mkdirSync, mkdtempSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
3
- import { tmpdir } from "node:os";
4
- import { join } from "node:path";
5
- import { render } from "ink-testing-library";
6
- import StatusView from "./StatusView";
7
-
8
- const ansiPattern = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
9
- const stripAnsi = (value: string) => value.replace(ansiPattern, "");
10
-
11
- const writeTask = (tasksRoot: string, id: string, slug: string, body: string) => {
12
- const filePath = join(tasksRoot, `${id}_${slug}.md`);
13
- writeFileSync(filePath, body, "utf8");
14
- };
15
-
16
- const writeProposal = (changesRoot: string, changeId: string, status: string, title = changeId) => {
17
- const changeDir = join(changesRoot, changeId);
18
- mkdirSync(changeDir, { recursive: true });
19
- writeFileSync(join(changeDir, "proposal.md"), `# Proposal - ${title}\n**Status**: ${status}\n`, "utf8");
20
- };
21
-
22
- const findLine = (output: string, id: string) => {
23
- return output.split("\n").find((line) => line.includes(id)) ?? "";
24
- };
25
-
26
- const changeDisplayId = (value: string) => value.split("_")[0];
27
-
28
- const noopCopy = () => {};
29
-
30
- test("status view shows checklist indicator only for wip tasks", () => {
31
- const originalCwd = process.cwd();
32
- const base = mkdtempSync(join(tmpdir(), "schub-status-view-"));
33
- const schubDir = join(base, ".schub");
34
- const wipRoot = join(schubDir, "tasks", "wip");
35
- const readyRoot = join(schubDir, "tasks", "ready");
36
-
37
- mkdirSync(wipRoot, { recursive: true });
38
- mkdirSync(readyRoot, { recursive: true });
39
-
40
- writeTask(wipRoot, "T100", "wip-checklist", "# Task: T100 Wip Checklist\n\n## Steps\n- [ ] First\n- [x] Second\n");
41
- writeTask(wipRoot, "T101", "wip-complete", "# Task: T101 Wip Complete\n\n## Steps\n- [x] First\n- [x] Second\n");
42
- writeTask(wipRoot, "T102", "wip-empty", "# Task: T102 Wip Empty\n\n## Steps\n- Do the thing\n");
43
- writeTask(readyRoot, "T200", "ready-checklist", "# Task: T200 Ready Checklist\n\n## Steps\n- [ ] First\n");
44
-
45
- try {
46
- process.chdir(base);
47
- const { lastFrame } = render(<StatusView onCopyId={noopCopy} />);
48
- const output = stripAnsi(lastFrame() ?? "");
49
-
50
- const wipChecklistLine = findLine(output, "T100");
51
- const wipCompleteLine = findLine(output, "T101");
52
- const wipEmptyLine = findLine(output, "T102");
53
- const readyLine = findLine(output, "T200");
54
-
55
- expect(wipChecklistLine).toContain("wip checklist");
56
- expect(wipChecklistLine).toContain("(1/2)");
57
- expect(wipCompleteLine).toContain("(0/2)");
58
- expect(wipEmptyLine).toContain("wip empty");
59
- expect(wipEmptyLine).not.toContain("wip empty (");
60
- expect(readyLine).toContain("ready checklist");
61
- expect(readyLine).not.toContain("ready checklist (");
62
- } finally {
63
- process.chdir(originalCwd);
64
- }
65
- });
66
-
67
- test("status view omits per-item shortcuts for tasks and proposals", () => {
68
- const originalCwd = process.cwd();
69
- const base = mkdtempSync(join(tmpdir(), "schub-status-actions-"));
70
- const schubDir = join(base, ".schub");
71
- const changesRoot = join(schubDir, "changes");
72
- const readyRoot = join(schubDir, "tasks", "ready");
73
-
74
- mkdirSync(changesRoot, { recursive: true });
75
- mkdirSync(readyRoot, { recursive: true });
76
-
77
- const changeId = "C003_demo-change";
78
- const displayId = changeDisplayId(changeId);
79
-
80
- writeProposal(changesRoot, changeId, "Draft");
81
- writeTask(readyRoot, "T003", "ready-task", "# Task: T003 Ready Task\n");
82
-
83
- try {
84
- process.chdir(base);
85
- const { lastFrame } = render(<StatusView onCopyId={noopCopy} />);
86
- const output = stripAnsi(lastFrame() ?? "");
87
-
88
- expect(findLine(output, displayId)).not.toContain("[o open file]");
89
- expect(findLine(output, displayId)).not.toContain("[c copy]");
90
- expect(findLine(output, "T003")).not.toContain("[o open file]");
91
- expect(findLine(output, "T003")).not.toContain("[c copy]");
92
- } finally {
93
- process.chdir(originalCwd);
94
- }
95
- });
96
-
97
- test("status view shows proposal titles", () => {
98
- const originalCwd = process.cwd();
99
- const base = mkdtempSync(join(tmpdir(), "schub-status-proposal-title-"));
100
- const schubDir = join(base, ".schub");
101
- const changesRoot = join(schubDir, "changes");
102
-
103
- mkdirSync(changesRoot, { recursive: true });
104
-
105
- const changeId = "C004_pretty-proposals";
106
- const changeTitle = "Pretty Print Change Proposals";
107
- const displayId = changeDisplayId(changeId);
108
-
109
- writeProposal(changesRoot, changeId, "Draft", changeTitle);
110
-
111
- try {
112
- process.chdir(base);
113
- const { lastFrame } = render(<StatusView onCopyId={noopCopy} />);
114
- const output = stripAnsi(lastFrame() ?? "");
115
- const line = findLine(output, displayId);
116
-
117
- expect(line).toContain(displayId);
118
- expect(line).toContain(changeTitle);
119
- expect(line).not.toContain(changeId);
120
- } finally {
121
- process.chdir(originalCwd);
122
- }
123
- });
124
-
125
- test("status view copies the selected id on c", async () => {
126
- const originalCwd = process.cwd();
127
- const base = mkdtempSync(join(tmpdir(), "schub-status-copy-"));
128
- const schubDir = join(base, ".schub");
129
- const readyRoot = join(schubDir, "tasks", "ready");
130
- let copied = "";
131
- const recordCopy = (id: string) => {
132
- copied = id;
133
- };
134
-
135
- mkdirSync(readyRoot, { recursive: true });
136
- writeTask(readyRoot, "T010", "copy-task", "# Task: T010 Copy Task\n");
137
-
138
- try {
139
- process.chdir(base);
140
- const rendered = render(<StatusView onCopyId={recordCopy} />);
141
- rendered.stdin.write("c");
142
- await new Promise((resolve) => setTimeout(resolve, 0));
143
- expect(copied).toBe("T010");
144
- } finally {
145
- process.chdir(originalCwd);
146
- }
147
- });
148
-
149
- test("status view shows pending implementation counts and no tasks defined section", () => {
150
- const originalCwd = process.cwd();
151
- const base = mkdtempSync(join(tmpdir(), "schub-status-proposals-"));
152
- const schubDir = join(base, ".schub");
153
- const changesRoot = join(schubDir, "changes");
154
- const backlogRoot = join(schubDir, "tasks", "backlog");
155
- const doneRoot = join(schubDir, "tasks", "done");
156
-
157
- mkdirSync(backlogRoot, { recursive: true });
158
- mkdirSync(doneRoot, { recursive: true });
159
- mkdirSync(changesRoot, { recursive: true });
160
-
161
- const changeWithTasks = "C001_with-tasks";
162
- const changeNoTasks = "C002_no-tasks";
163
- const displayWithTasks = changeDisplayId(changeWithTasks);
164
- const displayNoTasks = changeDisplayId(changeNoTasks);
165
-
166
- writeProposal(changesRoot, changeWithTasks, "Accepted");
167
- writeProposal(changesRoot, changeNoTasks, "Accepted");
168
-
169
- writeTask(backlogRoot, "T010", "first-task", `# Task: T010 First Task\n\n**Change ID**: ${changeWithTasks}\n`);
170
- writeTask(backlogRoot, "T011", "second-task", `# Task: T011 Second Task\n\n**Change ID**: ${changeWithTasks}\n`);
171
- writeTask(doneRoot, "T012", "done-task", `# Task: T012 Done Task\n\n**Change ID**: ${changeWithTasks}\n`);
172
-
173
- try {
174
- process.chdir(base);
175
- const { lastFrame } = render(<StatusView onCopyId={noopCopy} />);
176
- const output = stripAnsi(lastFrame() ?? "");
177
-
178
- expect(output).toContain("No Tasks Defined");
179
-
180
- const withTasksLine = findLine(output, displayWithTasks);
181
- expect(withTasksLine).toContain("1/3");
182
-
183
- const noTasksLine = findLine(output, displayNoTasks);
184
- expect(noTasksLine).not.toContain("/");
185
- } finally {
186
- process.chdir(originalCwd);
187
- }
188
- });
189
-
190
- test("status view hides pending implementation when tasks complete", () => {
191
- const originalCwd = process.cwd();
192
- const base = mkdtempSync(join(tmpdir(), "schub-status-complete-"));
193
- const schubDir = join(base, ".schub");
194
- const changesRoot = join(schubDir, "changes");
195
- const doneRoot = join(schubDir, "tasks", "done");
196
-
197
- mkdirSync(changesRoot, { recursive: true });
198
- mkdirSync(doneRoot, { recursive: true });
199
-
200
- const completedChange = "C003_all-done";
201
-
202
- writeProposal(changesRoot, completedChange, "Accepted");
203
- writeTask(doneRoot, "T020", "done-task", `# Task: T020 Done Task\n\n**Change ID**: ${completedChange}\n`);
204
- writeTask(doneRoot, "T021", "done-task-two", `# Task: T021 Done Task\n\n**Change ID**: ${completedChange}\n`);
205
-
206
- try {
207
- process.chdir(base);
208
- const { lastFrame } = render(<StatusView onCopyId={noopCopy} />);
209
- const output = stripAnsi(lastFrame() ?? "");
210
-
211
- expect(output).not.toContain(completedChange);
212
- } finally {
213
- process.chdir(originalCwd);
214
- }
215
- });
216
-
217
- test("status view auto-marks completed accepted changes as done", async () => {
218
- const originalCwd = process.cwd();
219
- const base = mkdtempSync(join(tmpdir(), "schub-status-auto-done-"));
220
- const schubDir = join(base, ".schub");
221
- const changesRoot = join(schubDir, "changes");
222
- const doneRoot = join(schubDir, "tasks", "done");
223
- const refreshIntervalMs = 50;
224
- let unmount: (() => void) | undefined;
225
-
226
- mkdirSync(changesRoot, { recursive: true });
227
- mkdirSync(doneRoot, { recursive: true });
228
-
229
- const changeId = "C011_auto-done";
230
- const proposalPath = join(changesRoot, changeId, "proposal.md");
231
-
232
- writeProposal(changesRoot, changeId, "Accepted");
233
- writeTask(doneRoot, "T100", "done-task", `# Task: T100 Done Task\n\n**Change ID**: ${changeId}\n`);
234
- writeTask(doneRoot, "T101", "done-task-two", `# Task: T101 Done Task\n\n**Change ID**: ${changeId}\n`);
235
-
236
- try {
237
- process.chdir(base);
238
- const rendered = render(<StatusView refreshIntervalMs={refreshIntervalMs} onCopyId={noopCopy} />);
239
- unmount = rendered.unmount;
240
-
241
- await new Promise((resolve) => setTimeout(resolve, refreshIntervalMs * 2));
242
-
243
- const updated = readFileSync(proposalPath, "utf8");
244
- expect(updated).toContain("**Status**: Done");
245
- } finally {
246
- unmount?.();
247
- process.chdir(originalCwd);
248
- }
249
- });
250
-
251
- test("status view does not auto-mark proposals without tasks", async () => {
252
- const originalCwd = process.cwd();
253
- const base = mkdtempSync(join(tmpdir(), "schub-status-no-tasks-"));
254
- const schubDir = join(base, ".schub");
255
- const changesRoot = join(schubDir, "changes");
256
- const refreshIntervalMs = 50;
257
- let unmount: (() => void) | undefined;
258
-
259
- mkdirSync(changesRoot, { recursive: true });
260
-
261
- const changeId = "C012_no-tasks";
262
- const proposalPath = join(changesRoot, changeId, "proposal.md");
263
-
264
- writeProposal(changesRoot, changeId, "Accepted");
265
-
266
- try {
267
- process.chdir(base);
268
- const rendered = render(<StatusView refreshIntervalMs={refreshIntervalMs} onCopyId={noopCopy} />);
269
- unmount = rendered.unmount;
270
-
271
- await new Promise((resolve) => setTimeout(resolve, refreshIntervalMs * 2));
272
-
273
- const updated = readFileSync(proposalPath, "utf8");
274
- expect(updated).toContain("**Status**: Accepted");
275
- } finally {
276
- unmount?.();
277
- process.chdir(originalCwd);
278
- }
279
- });
280
-
281
- test("status view refreshes when tasks change", async () => {
282
- const originalCwd = process.cwd();
283
- const base = mkdtempSync(join(tmpdir(), "schub-status-refresh-"));
284
- const schubDir = join(base, ".schub");
285
- const readyRoot = join(schubDir, "tasks", "ready");
286
- const refreshIntervalMs = 50;
287
- let unmount: (() => void) | undefined;
288
-
289
- mkdirSync(readyRoot, { recursive: true });
290
-
291
- try {
292
- process.chdir(base);
293
- const rendered = render(<StatusView refreshIntervalMs={refreshIntervalMs} onCopyId={noopCopy} />);
294
- unmount = rendered.unmount;
295
- const output = stripAnsi(rendered.lastFrame() ?? "");
296
- expect(output).toContain("No active changes or tasks found.");
297
-
298
- writeTask(readyRoot, "T001", "new-task", "# Task: T001 New Task\n");
299
- await new Promise((resolve) => setTimeout(resolve, refreshIntervalMs * 2));
300
-
301
- const refreshed = stripAnsi(rendered.lastFrame() ?? "");
302
- expect(refreshed).toContain("T001");
303
- } finally {
304
- unmount?.();
305
- process.chdir(originalCwd);
306
- }
307
- });
308
-
309
- test("status view refreshes when proposals change", async () => {
310
- const originalCwd = process.cwd();
311
- const base = mkdtempSync(join(tmpdir(), "schub-status-proposal-refresh-"));
312
- const schubDir = join(base, ".schub");
313
- const changesRoot = join(schubDir, "changes");
314
- const refreshIntervalMs = 50;
315
- let unmount: (() => void) | undefined;
316
-
317
- mkdirSync(changesRoot, { recursive: true });
318
-
319
- const changeId = "C010_refresh-proposal";
320
- const displayId = changeDisplayId(changeId);
321
- writeProposal(changesRoot, changeId, "Draft");
322
-
323
- try {
324
- process.chdir(base);
325
- const rendered = render(<StatusView refreshIntervalMs={refreshIntervalMs} onCopyId={noopCopy} />);
326
- unmount = rendered.unmount;
327
- const output = stripAnsi(rendered.lastFrame() ?? "");
328
-
329
- expect(output).toContain("Drafts");
330
- expect(findLine(output, displayId)).toContain(displayId);
331
-
332
- writeProposal(changesRoot, changeId, "Accepted");
333
- await new Promise((resolve) => setTimeout(resolve, refreshIntervalMs * 2));
334
-
335
- const refreshed = stripAnsi(rendered.lastFrame() ?? "");
336
- expect(refreshed).toContain("No Tasks Defined");
337
- expect(findLine(refreshed, displayId)).toContain(displayId);
338
- expect(refreshed).not.toContain("Drafts");
339
- } finally {
340
- unmount?.();
341
- process.chdir(originalCwd);
342
- }
343
- });
344
-
345
- test("status view clamps selection when tasks are removed", async () => {
346
- const originalCwd = process.cwd();
347
- const base = mkdtempSync(join(tmpdir(), "schub-status-selection-"));
348
- const schubDir = join(base, ".schub");
349
- const readyRoot = join(schubDir, "tasks", "ready");
350
- const refreshIntervalMs = 50;
351
- const selectionMarker = "\u203A";
352
- let unmount: (() => void) | undefined;
353
-
354
- mkdirSync(readyRoot, { recursive: true });
355
- writeTask(readyRoot, "T001", "alpha-task", "# Task: T001 Alpha Task\n");
356
- writeTask(readyRoot, "T002", "beta-task", "# Task: T002 Beta Task\n");
357
-
358
- try {
359
- process.chdir(base);
360
- const rendered = render(<StatusView refreshIntervalMs={refreshIntervalMs} onCopyId={noopCopy} />);
361
- unmount = rendered.unmount;
362
- const { stdin } = rendered;
363
-
364
- stdin.write("\u001B[B");
365
- await new Promise((resolve) => setTimeout(resolve, 0));
366
-
367
- const moved = stripAnsi(rendered.lastFrame() ?? "");
368
- expect(findLine(moved, "T002")).toContain(selectionMarker);
369
-
370
- unlinkSync(join(readyRoot, "T002_beta-task.md"));
371
- await new Promise((resolve) => setTimeout(resolve, refreshIntervalMs * 2));
372
-
373
- const refreshed = stripAnsi(rendered.lastFrame() ?? "");
374
- expect(findLine(refreshed, "T001")).toContain(selectionMarker);
375
- expect(refreshed).not.toContain("T002");
376
- } finally {
377
- unmount?.();
378
- process.chdir(originalCwd);
379
- }
380
- });