schub 0.1.3 → 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 (76) hide show
  1. package/README.md +27 -0
  2. package/dist/index.js +17231 -7669
  3. package/package.json +5 -2
  4. package/skills/create-proposal/SKILL.md +4 -0
  5. package/skills/create-tasks/SKILL.md +2 -1
  6. package/skills/implement-task/SKILL.md +6 -1
  7. package/skills/review-proposal/SKILL.md +1 -0
  8. package/skills/update-roadmap/SKILL.md +23 -0
  9. package/src/changes.test.ts +119 -5
  10. package/src/changes.ts +136 -54
  11. package/src/commands/adr.test.ts +5 -4
  12. package/src/commands/changes.test.ts +6 -6
  13. package/src/commands/cookbook.test.ts +5 -4
  14. package/src/commands/init.ts +5 -0
  15. package/src/commands/review.test.ts +5 -4
  16. package/src/commands/review.ts +1 -1
  17. package/src/commands/roadmap.test.ts +84 -0
  18. package/src/commands/roadmap.ts +84 -0
  19. package/src/commands/tasks-create.test.ts +1 -1
  20. package/src/commands/tasks-implement.test.ts +253 -0
  21. package/src/commands/tasks-implement.ts +121 -0
  22. package/src/commands/tasks-update.test.ts +92 -0
  23. package/src/commands/tasks.ts +98 -1
  24. package/src/features/roadmap/index.ts +230 -0
  25. package/src/features/roadmap/roadmap.test.ts +77 -0
  26. package/src/features/tasks/constants.ts +1 -0
  27. package/src/features/tasks/create.ts +9 -7
  28. package/src/features/tasks/filesystem.test.ts +221 -4
  29. package/src/features/tasks/filesystem.ts +124 -40
  30. package/src/features/tasks/graph.ts +18 -3
  31. package/src/features/tasks/index.ts +10 -1
  32. package/src/features/tasks/worktree.ts +48 -0
  33. package/src/frontmatter.ts +115 -0
  34. package/src/index.test.ts +42 -6
  35. package/src/index.ts +225 -118
  36. package/src/opencode.test.ts +53 -0
  37. package/src/opencode.ts +71 -3
  38. package/src/tasks.ts +1 -0
  39. package/src/tui/App.test.tsx +418 -0
  40. package/src/tui/App.tsx +343 -0
  41. package/src/{components → tui/components}/PlanView.test.tsx +26 -38
  42. package/src/tui/components/PlanView.tsx +89 -0
  43. package/src/tui/components/PreviewPage.test.tsx +69 -0
  44. package/src/tui/components/PreviewPage.tsx +87 -0
  45. package/src/tui/components/ProposalDetailView.test.tsx +169 -0
  46. package/src/tui/components/ProposalDetailView.tsx +166 -0
  47. package/src/tui/components/RoadmapView.test.tsx +85 -0
  48. package/src/tui/components/RoadmapView.tsx +369 -0
  49. package/src/tui/components/StatusView.test.tsx +1351 -0
  50. package/src/tui/components/StatusView.tsx +519 -0
  51. package/src/tui/components/markdown-renderer.test.ts +46 -0
  52. package/src/tui/components/markdown-renderer.ts +89 -0
  53. package/src/tui/components/status-view-data.ts +322 -0
  54. package/src/tui/components/status-view-render.tsx +329 -0
  55. package/src/tui/index.ts +16 -0
  56. package/templates/create-proposal/adr-template.md +6 -4
  57. package/templates/create-proposal/cookbook-template.md +5 -3
  58. package/templates/create-proposal/proposal-template.md +8 -6
  59. package/templates/create-roadmap/roadmap.md +5 -0
  60. package/templates/create-tasks/task-template.md +9 -4
  61. package/templates/review-proposal/q&a-template.md +8 -3
  62. package/templates/review-proposal/review-me-template.md +6 -4
  63. package/templates/setup-project/project-overview-template.md +5 -0
  64. package/templates/setup-project/project-setup-template.md +5 -0
  65. package/templates/setup-project/project-wow-template.md +5 -0
  66. package/src/App.test.tsx +0 -145
  67. package/src/App.tsx +0 -172
  68. package/src/components/PlanView.tsx +0 -160
  69. package/src/components/StatusView.test.tsx +0 -432
  70. package/src/components/StatusView.tsx +0 -420
  71. package/src/ide.ts +0 -7
  72. package/templates/templates-parity.test.ts +0 -45
  73. /package/src/{clipboard.ts → tui/clipboard.ts} +0 -0
  74. /package/src/{components → tui/components}/statusColor.ts +0 -0
  75. /package/src/{terminal.test.ts → tui/terminal.test.ts} +0 -0
  76. /package/src/{terminal.ts → tui/terminal.ts} +0 -0
@@ -1,420 +0,0 @@
1
- import { dirname, normalize, sep } from "node:path";
2
- import { Box, Text, useInput } from "ink";
3
- import React from "react";
4
- import { type ChangeInfo, listChanges, updateChangeStatus } from "../changes";
5
- import { findSchubRoot, loadTaskDependencies, type TaskInfo, trimTaskTitle } from "../features/tasks";
6
- import { openInVsCode } from "../ide";
7
- import { launchOpencodeReview } from "../opencode";
8
-
9
- type StatusSortable = {
10
- id: string;
11
- title: string;
12
- status: string;
13
- };
14
-
15
- type Shortcut = {
16
- keyLabel: string;
17
- label: string;
18
- };
19
-
20
- type StatusViewProps = {
21
- refreshIntervalMs?: number;
22
- onCopyId: (id: string) => void;
23
- onReview?: (changeId: string) => void;
24
- onShortcutsChange?: (shortcuts: Shortcut[]) => void;
25
- };
26
-
27
- const DEFAULT_REFRESH_INTERVAL_MS = 1000;
28
- const ACTIVE_TASK_STATUSES = ["blocked", "wip", "ready", "backlog"] as const;
29
- const CHANGE_TASK_STATUSES = [...ACTIVE_TASK_STATUSES, "done"] as const;
30
-
31
- type ChangeTaskCount = {
32
- completed: number;
33
- total: number;
34
- };
35
-
36
- const AUTO_MARK_STATUSES = new Set(["accepted", "wip"]);
37
- const REVIEW_SHORTCUT: Shortcut = { keyLabel: "r", label: "review" };
38
-
39
- const compareText = (left: string, right: string): number =>
40
- left.localeCompare(right, undefined, { sensitivity: "base" });
41
-
42
- const sortByStatusThenTitle = (left: StatusSortable, right: StatusSortable): number => {
43
- const statusCompare = compareText(left.status, right.status);
44
- if (statusCompare !== 0) {
45
- return statusCompare;
46
- }
47
-
48
- const titleCompare = compareText(left.title, right.title);
49
- if (titleCompare !== 0) {
50
- return titleCompare;
51
- }
52
-
53
- return compareText(left.id, right.id);
54
- };
55
-
56
- const isTaskItem = (item: ChangeInfo | TaskInfo): item is TaskInfo => {
57
- const parts = normalize(item.path).split(sep);
58
- return parts.includes("tasks");
59
- };
60
-
61
- const formatChangeId = (value: string) => {
62
- const match = value.match(/^([Cc]\d{4})_/);
63
- return match ? match[1].toUpperCase() : value;
64
- };
65
-
66
- const formatChangeTitle = (changeId: string, title: string) => {
67
- const trimmedTitle = title.trim();
68
- if (trimmedTitle !== changeId) {
69
- return trimmedTitle;
70
- }
71
-
72
- const match = changeId.match(/^([Cc]\d{4})_(.+)$/);
73
- if (!match) {
74
- return trimmedTitle;
75
- }
76
-
77
- return match[2]
78
- .split("-")
79
- .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
80
- .join(" ");
81
- };
82
-
83
- const canAutoMarkChange = (status: string) => AUTO_MARK_STATUSES.has(status.trim().toLowerCase());
84
-
85
- const autoMarkChangesDone = (schubDir: string) => {
86
- const allTasks = loadTaskDependencies(schubDir, CHANGE_TASK_STATUSES);
87
- const changeTaskCounts = new Map<string, ChangeTaskCount>();
88
-
89
- for (const task of allTasks) {
90
- if (!task.changeId) {
91
- continue;
92
- }
93
-
94
- const current = changeTaskCounts.get(task.changeId) ?? { completed: 0, total: 0 };
95
- const completed = current.completed + (task.status === "done" ? 1 : 0);
96
- changeTaskCounts.set(task.changeId, { completed, total: current.total + 1 });
97
- }
98
-
99
- if (changeTaskCounts.size === 0) {
100
- return;
101
- }
102
-
103
- for (const change of listChanges(schubDir)) {
104
- const counts = changeTaskCounts.get(change.id);
105
- if (!counts || counts.total === 0 || counts.completed !== counts.total) {
106
- continue;
107
- }
108
-
109
- if (canAutoMarkChange(change.status)) {
110
- updateChangeStatus(schubDir, change.id, "Done");
111
- }
112
- }
113
- };
114
-
115
- const buildStatusData = (schubDir: string | null) => {
116
- if (!schubDir) {
117
- return {
118
- pendingReview: [],
119
- pendingImplementation: [],
120
- pendingImplementationNoTasks: [],
121
- drafts: [],
122
- blocked: [],
123
- wip: [],
124
- ready: [],
125
- backlog: [],
126
- pendingImplementationCounts: new Map<string, string>(),
127
- };
128
- }
129
-
130
- const allTasks = loadTaskDependencies(schubDir, CHANGE_TASK_STATUSES);
131
-
132
- const blockedTasks: TaskInfo[] = [];
133
- const wipTasks: TaskInfo[] = [];
134
- const readyTasks: TaskInfo[] = [];
135
- const backlogTasks: TaskInfo[] = [];
136
-
137
- const activeChangeIds = new Set<string>();
138
- const changeTaskCounts = new Map<string, ChangeTaskCount>();
139
-
140
- for (const task of allTasks) {
141
- if (task.status === "blocked") blockedTasks.push(task);
142
- else if (task.status === "wip") wipTasks.push(task);
143
- else if (task.status === "ready") readyTasks.push(task);
144
- else if (task.status === "backlog") backlogTasks.push(task);
145
-
146
- if (task.changeId && task.status !== "done") {
147
- activeChangeIds.add(task.changeId);
148
- }
149
-
150
- if (task.changeId) {
151
- const current = changeTaskCounts.get(task.changeId) ?? { completed: 0, total: 0 };
152
- const completed = current.completed + (task.status === "done" ? 1 : 0);
153
- changeTaskCounts.set(task.changeId, { completed, total: current.total + 1 });
154
- }
155
- }
156
-
157
- const allChanges = listChanges(schubDir);
158
- const pendingReviewChanges: ChangeInfo[] = [];
159
- const pendingImplementationChanges: ChangeInfo[] = [];
160
- const draftChanges: ChangeInfo[] = [];
161
-
162
- for (const change of allChanges) {
163
- const normalized = change.status.toLowerCase();
164
- if (normalized.includes("review")) {
165
- pendingReviewChanges.push(change);
166
- continue;
167
- }
168
-
169
- const counts = changeTaskCounts.get(change.id);
170
- const allTasksCompleted = Boolean(counts && counts.total > 0 && counts.completed === counts.total);
171
-
172
- if (activeChangeIds.has(change.id)) {
173
- pendingImplementationChanges.push(change);
174
- continue;
175
- }
176
-
177
- if ((normalized.includes("implementing") || normalized.includes("accepted")) && !allTasksCompleted) {
178
- pendingImplementationChanges.push(change);
179
- continue;
180
- }
181
-
182
- if (normalized.includes("draft")) {
183
- draftChanges.push(change);
184
- }
185
- }
186
-
187
- const pendingImplementationWithTasks: ChangeInfo[] = [];
188
- const pendingImplementationNoTasks: ChangeInfo[] = [];
189
- const pendingImplementationCounts = new Map<string, string>();
190
-
191
- for (const change of pendingImplementationChanges.sort(sortByStatusThenTitle)) {
192
- const counts = changeTaskCounts.get(change.id);
193
- if (counts && counts.total > 0) {
194
- pendingImplementationWithTasks.push(change);
195
- pendingImplementationCounts.set(change.id, `${counts.completed}/${counts.total}`);
196
- } else {
197
- pendingImplementationNoTasks.push(change);
198
- }
199
- }
200
-
201
- return {
202
- pendingReview: pendingReviewChanges.sort(sortByStatusThenTitle),
203
- pendingImplementation: pendingImplementationWithTasks,
204
- pendingImplementationNoTasks,
205
- drafts: draftChanges.sort(sortByStatusThenTitle),
206
- blocked: blockedTasks.sort(sortByStatusThenTitle),
207
- wip: wipTasks.sort(sortByStatusThenTitle),
208
- ready: readyTasks.sort(sortByStatusThenTitle),
209
- backlog: backlogTasks.sort(sortByStatusThenTitle),
210
- pendingImplementationCounts,
211
- };
212
- };
213
-
214
- export default function StatusView({
215
- refreshIntervalMs = DEFAULT_REFRESH_INTERVAL_MS,
216
- onCopyId,
217
- onReview = launchOpencodeReview,
218
- onShortcutsChange,
219
- }: StatusViewProps) {
220
- const schubDir = findSchubRoot();
221
- const [, setRefreshTick] = React.useState(0);
222
- const {
223
- pendingReview,
224
- pendingImplementation,
225
- pendingImplementationNoTasks,
226
- drafts,
227
- blocked,
228
- wip,
229
- ready,
230
- backlog,
231
- pendingImplementationCounts,
232
- } = buildStatusData(schubDir);
233
- const repoRoot = schubDir ? dirname(schubDir) : "";
234
-
235
- const allItems = [
236
- ...pendingReview,
237
- ...pendingImplementation,
238
- ...pendingImplementationNoTasks,
239
- ...drafts,
240
- ...blocked,
241
- ...wip,
242
- ...ready,
243
- ...backlog,
244
- ];
245
-
246
- const [selection, setSelection] = React.useState(0);
247
- const totalItems = allItems.length;
248
- const pendingReviewIds = new Set(pendingReview.map((change) => change.id));
249
- const selectedItem = totalItems > 0 ? allItems[selection] : null;
250
- const selectedReviewId =
251
- selectedItem && !isTaskItem(selectedItem) && pendingReviewIds.has(selectedItem.id) ? selectedItem.id : null;
252
- const lastReviewIdRef = React.useRef<string | null>(null);
253
-
254
- React.useEffect(() => {
255
- if (!onShortcutsChange) {
256
- return;
257
- }
258
-
259
- if (lastReviewIdRef.current === selectedReviewId) {
260
- return;
261
- }
262
-
263
- lastReviewIdRef.current = selectedReviewId;
264
- onShortcutsChange(selectedReviewId ? [REVIEW_SHORTCUT] : []);
265
- }, [onShortcutsChange, selectedReviewId]);
266
-
267
- React.useEffect(() => {
268
- if (!schubDir) {
269
- return;
270
- }
271
-
272
- const interval = setInterval(() => {
273
- autoMarkChangesDone(schubDir);
274
- setRefreshTick((current) => current + 1);
275
- }, refreshIntervalMs);
276
-
277
- return () => {
278
- clearInterval(interval);
279
- };
280
- }, [refreshIntervalMs, schubDir]);
281
-
282
- React.useEffect(() => {
283
- if (totalItems === 0) {
284
- setSelection(0);
285
- return;
286
- }
287
- setSelection((current) => Math.min(current, totalItems - 1));
288
- }, [totalItems]);
289
-
290
- useInput((input, key) => {
291
- if (totalItems === 0) {
292
- return;
293
- }
294
-
295
- if (key.downArrow) {
296
- setSelection((current) => Math.min(current + 1, totalItems - 1));
297
- }
298
-
299
- if (key.upArrow) {
300
- setSelection((current) => Math.max(current - 1, 0));
301
- }
302
-
303
- if (input === "o") {
304
- const selectedItem = allItems[selection];
305
- openInVsCode(repoRoot, selectedItem.path);
306
- }
307
-
308
- if (input === "c") {
309
- const selectedItem = allItems[selection];
310
- onCopyId(selectedItem.id);
311
- }
312
-
313
- if (input === "r" && selectedReviewId) {
314
- onReview(selectedReviewId);
315
- }
316
- });
317
-
318
- if (!schubDir) {
319
- return (
320
- <Box flexDirection="column">
321
- <Text bold>Status</Text>
322
- <Text color="red">No .schub directory found.</Text>
323
- </Box>
324
- );
325
- }
326
-
327
- if (totalItems === 0) {
328
- return (
329
- <Box flexDirection="column">
330
- <Text color="gray">No active changes or tasks found.</Text>
331
- </Box>
332
- );
333
- }
334
-
335
- const renderRow = (item: ChangeInfo | TaskInfo, index: number, detail?: string) => {
336
- const selected = index === selection;
337
- const task = isTaskItem(item) ? item : null;
338
- const title = task ? trimTaskTitle(task.title) : "";
339
- const checklistIndicator =
340
- task && task.status === "wip" && typeof task.checklistTotal === "number"
341
- ? ` (${task.checklistRemaining ?? 0}/${task.checklistTotal})`
342
- : "";
343
- const changeTitle = task ? "" : formatChangeTitle(item.id, item.title);
344
- const changeDetail = detail ? ` ${detail}` : "";
345
- const displayTitle = task ? `${title}${checklistIndicator}` : `${changeTitle}${changeDetail}`.trim();
346
- const displayId = task ? item.id : formatChangeId(item.id);
347
-
348
- return (
349
- <Box key={item.id} marginLeft={1}>
350
- <Text color={selected ? "blue" : "gray"}>{selected ? "›" : " "}</Text>
351
- <Box marginLeft={1}>
352
- <Text color="white" bold={selected}>
353
- {displayId}
354
- </Text>
355
- {displayTitle ? <Text color="gray"> {displayTitle}</Text> : null}
356
- </Box>
357
- </Box>
358
- );
359
- };
360
-
361
- let currentIndex = 0;
362
-
363
- const renderSection = (title: string, items: (ChangeInfo | TaskInfo)[], detailById?: Map<string, string>) => {
364
- if (items.length === 0) return null;
365
-
366
- const sectionStartIndex = currentIndex;
367
- const sectionItems = items.map((item, i) => renderRow(item, sectionStartIndex + i, detailById?.get(item.id)));
368
- currentIndex += items.length;
369
-
370
- return (
371
- <Box flexDirection="column" marginBottom={1}>
372
- <Box marginBottom={0}>
373
- <Text color="white">{title}</Text>
374
- </Box>
375
- {sectionItems}
376
- </Box>
377
- );
378
- };
379
-
380
- return (
381
- <Box flexDirection="column">
382
- <Box flexDirection="column" marginBottom={1}>
383
- <Box marginBottom={1}>
384
- <Text bold color="white">
385
- Change Proposals
386
- </Text>
387
- </Box>
388
- {renderSection("Pending Review", pendingReview)}
389
- {renderSection("Pending Implementation", pendingImplementation, pendingImplementationCounts)}
390
- {renderSection("No Tasks Defined", pendingImplementationNoTasks)}
391
- {renderSection("Drafts", drafts)}
392
- {pendingReview.length === 0 &&
393
- pendingImplementation.length === 0 &&
394
- pendingImplementationNoTasks.length === 0 &&
395
- drafts.length === 0 && (
396
- <Box marginLeft={1}>
397
- <Text color="gray">No active proposals.</Text>
398
- </Box>
399
- )}
400
- </Box>
401
-
402
- <Box flexDirection="column">
403
- <Box marginBottom={1}>
404
- <Text bold color="white">
405
- Tasks
406
- </Text>
407
- </Box>
408
- {renderSection("Blocked", blocked)}
409
- {renderSection("WIP", wip)}
410
- {renderSection("Ready", ready)}
411
- {renderSection("Backlog", backlog)}
412
- {blocked.length === 0 && wip.length === 0 && ready.length === 0 && backlog.length === 0 && (
413
- <Box marginLeft={1}>
414
- <Text color="gray">No active tasks.</Text>
415
- </Box>
416
- )}
417
- </Box>
418
- </Box>
419
- );
420
- }
package/src/ide.ts DELETED
@@ -1,7 +0,0 @@
1
- import { spawn } from "node:child_process";
2
- import { resolve } from "node:path";
3
-
4
- export const openInVsCode = (repoRoot: string, relativePath: string) => {
5
- const targetPath = resolve(repoRoot, relativePath);
6
- spawn("code", ["-g", targetPath], { stdio: "ignore" }).unref();
7
- };
@@ -1,45 +0,0 @@
1
- import { expect, test } from "bun:test";
2
- import { existsSync, readFileSync } from "node:fs";
3
- import { dirname, join, resolve } from "node:path";
4
- import { fileURLToPath } from "node:url";
5
-
6
- const testDir = dirname(fileURLToPath(import.meta.url));
7
- const cliDir = resolve(testDir, "..");
8
- const repoRoot = resolve(cliDir, "..", "..");
9
-
10
- const cliTemplates = [
11
- join(cliDir, "templates", "create-proposal", "proposal-template.md"),
12
- join(cliDir, "templates", "create-proposal", "adr-template.md"),
13
- join(cliDir, "templates", "create-proposal", "cookbook-template.md"),
14
- join(cliDir, "templates", "create-tasks", "task-template.md"),
15
- join(cliDir, "templates", "setup-project", "project-overview-template.md"),
16
- join(cliDir, "templates", "setup-project", "project-setup-template.md"),
17
- join(cliDir, "templates", "setup-project", "project-wow-template.md"),
18
- join(cliDir, "templates", "review-proposal", "review-me-template.md"),
19
- join(cliDir, "templates", "review-proposal", "q&a-template.md"),
20
- ];
21
-
22
- const legacyTemplates = [
23
- join(repoRoot, "skills", "create-proposal", "assets", "proposal-template.md"),
24
- join(repoRoot, "skills", "create-proposal", "assets", "adr-template.md"),
25
- join(repoRoot, "skills", "create-tasks", "assets", "task-template.md"),
26
- join(repoRoot, "skills", "setup-project", "assets", "project-overview-template.md"),
27
- join(repoRoot, "skills", "setup-project", "assets", "project-setup-template.md"),
28
- join(repoRoot, "skills", "setup-project", "assets", "project-wow-template.md"),
29
- join(repoRoot, "skills", "setup-project", "assets", "review-me-template.md"),
30
- join(repoRoot, "skills", "review-proposal", "assets", "review-me-template.md"),
31
- join(repoRoot, "skills", "review-proposal", "assets", "q&a-template.md"),
32
- ];
33
-
34
- test("cli templates exist and are non-empty", () => {
35
- for (const templatePath of cliTemplates) {
36
- const contents = readFileSync(templatePath, "utf8");
37
- expect(contents.trim().length).toBeGreaterThan(0);
38
- }
39
- });
40
-
41
- test("legacy skill template assets are removed", () => {
42
- for (const legacyPath of legacyTemplates) {
43
- expect(existsSync(legacyPath)).toBe(false);
44
- }
45
- });
File without changes
File without changes
File without changes