schub 0.1.0 → 0.1.2

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 (50) hide show
  1. package/README.md +68 -0
  2. package/dist/index.js +1573 -597
  3. package/package.json +3 -1
  4. package/skills/create-proposal/SKILL.md +33 -0
  5. package/skills/create-tasks/SKILL.md +40 -0
  6. package/skills/implement-task/SKILL.md +84 -0
  7. package/skills/review-proposal/SKILL.md +37 -0
  8. package/skills/setup-project/SKILL.md +29 -0
  9. package/src/App.test.tsx +93 -0
  10. package/src/App.tsx +62 -10
  11. package/src/changes.ts +86 -28
  12. package/src/clipboard.ts +5 -0
  13. package/src/commands/adr.test.ts +69 -0
  14. package/src/commands/adr.ts +107 -0
  15. package/src/commands/changes.test.ts +171 -0
  16. package/src/commands/changes.ts +163 -0
  17. package/src/commands/cookbook.test.ts +71 -0
  18. package/src/commands/cookbook.ts +95 -0
  19. package/src/commands/eject.test.ts +74 -0
  20. package/src/commands/eject.ts +100 -0
  21. package/src/commands/init.test.ts +78 -0
  22. package/src/commands/init.ts +144 -0
  23. package/src/commands/project.test.ts +113 -0
  24. package/src/commands/project.ts +75 -0
  25. package/src/commands/review.test.ts +100 -0
  26. package/src/commands/review.ts +231 -0
  27. package/src/commands/tasks-create.test.ts +172 -0
  28. package/src/commands/tasks-list.test.ts +177 -0
  29. package/src/commands/tasks.ts +172 -0
  30. package/src/components/PlanView.test.tsx +113 -0
  31. package/src/components/PlanView.tsx +95 -26
  32. package/src/components/StatusView.test.tsx +380 -0
  33. package/src/components/StatusView.tsx +233 -83
  34. package/src/features/tasks/constants.ts +2 -0
  35. package/src/features/tasks/create.ts +15 -7
  36. package/src/features/tasks/filesystem.test.ts +78 -0
  37. package/src/features/tasks/filesystem.ts +61 -7
  38. package/src/ide.ts +7 -0
  39. package/src/index.test.ts +23 -0
  40. package/src/index.ts +60 -383
  41. package/src/init.test.ts +43 -0
  42. package/src/init.ts +27 -0
  43. package/src/project.ts +5 -32
  44. package/src/schub-root.ts +33 -0
  45. package/src/templates.ts +18 -0
  46. package/src/terminal.test.ts +46 -0
  47. package/templates/create-proposal/cookbook-template.md +37 -0
  48. package/templates/review-proposal/q&a-template.md +5 -1
  49. package/templates/templates-parity.test.ts +45 -0
  50. package/templates/setup-project/review-me-template.md +0 -18
@@ -1,7 +1,9 @@
1
+ import { dirname, normalize, sep } from "node:path";
1
2
  import { Box, Text, useInput } from "ink";
2
3
  import React from "react";
3
- import { type ChangeInfo, listChanges } from "../changes";
4
+ import { type ChangeInfo, listChanges, updateChangeStatus } from "../changes";
4
5
  import { findSchubRoot, loadTaskDependencies, type TaskInfo, trimTaskTitle } from "../features/tasks";
6
+ import { openInVsCode } from "../ide";
5
7
 
6
8
  type StatusSortable = {
7
9
  id: string;
@@ -9,6 +11,22 @@ type StatusSortable = {
9
11
  status: string;
10
12
  };
11
13
 
14
+ type StatusViewProps = {
15
+ refreshIntervalMs?: number;
16
+ onCopyId: (id: string) => void;
17
+ };
18
+
19
+ const DEFAULT_REFRESH_INTERVAL_MS = 1000;
20
+ const ACTIVE_TASK_STATUSES = ["blocked", "wip", "ready", "backlog"] as const;
21
+ const CHANGE_TASK_STATUSES = [...ACTIVE_TASK_STATUSES, "done"] as const;
22
+
23
+ type ChangeTaskCount = {
24
+ completed: number;
25
+ total: number;
26
+ };
27
+
28
+ const AUTO_MARK_STATUSES = new Set(["accepted", "wip"]);
29
+
12
30
  const compareText = (left: string, right: string): number =>
13
31
  left.localeCompare(right, undefined, { sensitivity: "base" });
14
32
 
@@ -26,82 +44,192 @@ const sortByStatusThenTitle = (left: StatusSortable, right: StatusSortable): num
26
44
  return compareText(left.id, right.id);
27
45
  };
28
46
 
29
- export default function StatusView() {
30
- const schubDir = React.useMemo(() => findSchubRoot(), []);
47
+ const isTaskItem = (item: ChangeInfo | TaskInfo): item is TaskInfo => {
48
+ const parts = normalize(item.path).split(sep);
49
+ return parts.includes("tasks");
50
+ };
31
51
 
32
- const { pendingReview, pendingImplementation, drafts, blocked, wip, ready, backlog } = React.useMemo(() => {
33
- if (!schubDir) {
34
- return {
35
- pendingReview: [],
36
- pendingImplementation: [],
37
- drafts: [],
38
- blocked: [],
39
- wip: [],
40
- ready: [],
41
- backlog: [],
42
- };
43
- }
44
-
45
- const allTasks = loadTaskDependencies(schubDir, ["blocked", "wip", "ready", "backlog"]);
46
-
47
- const blockedTasks: TaskInfo[] = [];
48
- const wipTasks: TaskInfo[] = [];
49
- const readyTasks: TaskInfo[] = [];
50
- const backlogTasks: TaskInfo[] = [];
51
-
52
- const activeChangeIds = new Set<string>();
53
-
54
- for (const task of allTasks) {
55
- const s = task.status.toLowerCase();
56
- if (s === "blocked") blockedTasks.push(task);
57
- else if (s === "wip") wipTasks.push(task);
58
- else if (s === "ready") readyTasks.push(task);
59
- else if (s === "backlog") backlogTasks.push(task);
60
-
61
- if (task.changeId) {
62
- activeChangeIds.add(task.changeId);
63
- }
64
- }
65
-
66
- const allChanges = listChanges(schubDir);
67
- const pendingReviewChanges: ChangeInfo[] = [];
68
- const pendingImplementationChanges: ChangeInfo[] = [];
69
- const draftChanges: ChangeInfo[] = [];
70
-
71
- for (const change of allChanges) {
72
- const normalized = change.status.toLowerCase();
73
- if (normalized.includes("review")) {
74
- pendingReviewChanges.push(change);
75
- } else if (
76
- activeChangeIds.has(change.id) ||
77
- normalized.includes("implementing") ||
78
- normalized.includes("accepted")
79
- ) {
80
- pendingImplementationChanges.push(change);
81
- } else if (normalized.includes("draft")) {
82
- draftChanges.push(change);
83
- }
52
+ const formatChangeId = (value: string) => {
53
+ const match = value.match(/^([Cc]\d{3})_/);
54
+ return match ? match[1].toUpperCase() : value;
55
+ };
56
+
57
+ const canAutoMarkChange = (status: string) => AUTO_MARK_STATUSES.has(status.trim().toLowerCase());
58
+
59
+ const autoMarkChangesDone = (schubDir: string) => {
60
+ const allTasks = loadTaskDependencies(schubDir, CHANGE_TASK_STATUSES);
61
+ const changeTaskCounts = new Map<string, ChangeTaskCount>();
62
+
63
+ for (const task of allTasks) {
64
+ if (!task.changeId) {
65
+ continue;
66
+ }
67
+
68
+ const current = changeTaskCounts.get(task.changeId) ?? { completed: 0, total: 0 };
69
+ const completed = current.completed + (task.status === "done" ? 1 : 0);
70
+ changeTaskCounts.set(task.changeId, { completed, total: current.total + 1 });
71
+ }
72
+
73
+ if (changeTaskCounts.size === 0) {
74
+ return;
75
+ }
76
+
77
+ for (const change of listChanges(schubDir)) {
78
+ const counts = changeTaskCounts.get(change.id);
79
+ if (!counts || counts.total === 0 || counts.completed !== counts.total) {
80
+ continue;
81
+ }
82
+
83
+ if (canAutoMarkChange(change.status)) {
84
+ updateChangeStatus(schubDir, change.id, "Done");
84
85
  }
86
+ }
87
+ };
85
88
 
89
+ const buildStatusData = (schubDir: string | null) => {
90
+ if (!schubDir) {
86
91
  return {
87
- pendingReview: pendingReviewChanges.sort(sortByStatusThenTitle),
88
- pendingImplementation: pendingImplementationChanges.sort(sortByStatusThenTitle),
89
- drafts: draftChanges.sort(sortByStatusThenTitle),
90
- blocked: blockedTasks.sort(sortByStatusThenTitle),
91
- wip: wipTasks.sort(sortByStatusThenTitle),
92
- ready: readyTasks.sort(sortByStatusThenTitle),
93
- backlog: backlogTasks.sort(sortByStatusThenTitle),
92
+ pendingReview: [],
93
+ pendingImplementation: [],
94
+ pendingImplementationNoTasks: [],
95
+ drafts: [],
96
+ blocked: [],
97
+ wip: [],
98
+ ready: [],
99
+ backlog: [],
100
+ pendingImplementationCounts: new Map<string, string>(),
94
101
  };
95
- }, [schubDir]);
102
+ }
96
103
 
97
- const allItems = React.useMemo(
98
- () => [...pendingReview, ...pendingImplementation, ...drafts, ...blocked, ...wip, ...ready, ...backlog],
99
- [pendingReview, pendingImplementation, drafts, blocked, wip, ready, backlog],
100
- );
104
+ const allTasks = loadTaskDependencies(schubDir, CHANGE_TASK_STATUSES);
105
+
106
+ const blockedTasks: TaskInfo[] = [];
107
+ const wipTasks: TaskInfo[] = [];
108
+ const readyTasks: TaskInfo[] = [];
109
+ const backlogTasks: TaskInfo[] = [];
110
+
111
+ const activeChangeIds = new Set<string>();
112
+ const changeTaskCounts = new Map<string, ChangeTaskCount>();
113
+
114
+ for (const task of allTasks) {
115
+ if (task.status === "blocked") blockedTasks.push(task);
116
+ else if (task.status === "wip") wipTasks.push(task);
117
+ else if (task.status === "ready") readyTasks.push(task);
118
+ else if (task.status === "backlog") backlogTasks.push(task);
119
+
120
+ if (task.changeId && task.status !== "done") {
121
+ activeChangeIds.add(task.changeId);
122
+ }
123
+
124
+ if (task.changeId) {
125
+ const current = changeTaskCounts.get(task.changeId) ?? { completed: 0, total: 0 };
126
+ const completed = current.completed + (task.status === "done" ? 1 : 0);
127
+ changeTaskCounts.set(task.changeId, { completed, total: current.total + 1 });
128
+ }
129
+ }
130
+
131
+ const allChanges = listChanges(schubDir);
132
+ const pendingReviewChanges: ChangeInfo[] = [];
133
+ const pendingImplementationChanges: ChangeInfo[] = [];
134
+ const draftChanges: ChangeInfo[] = [];
135
+
136
+ for (const change of allChanges) {
137
+ const normalized = change.status.toLowerCase();
138
+ if (normalized.includes("review")) {
139
+ pendingReviewChanges.push(change);
140
+ continue;
141
+ }
142
+
143
+ const counts = changeTaskCounts.get(change.id);
144
+ const allTasksCompleted = Boolean(counts && counts.total > 0 && counts.completed === counts.total);
145
+
146
+ if (activeChangeIds.has(change.id)) {
147
+ pendingImplementationChanges.push(change);
148
+ continue;
149
+ }
150
+
151
+ if ((normalized.includes("implementing") || normalized.includes("accepted")) && !allTasksCompleted) {
152
+ pendingImplementationChanges.push(change);
153
+ continue;
154
+ }
155
+
156
+ if (normalized.includes("draft")) {
157
+ draftChanges.push(change);
158
+ }
159
+ }
160
+
161
+ const pendingImplementationWithTasks: ChangeInfo[] = [];
162
+ const pendingImplementationNoTasks: ChangeInfo[] = [];
163
+ const pendingImplementationCounts = new Map<string, string>();
164
+
165
+ for (const change of pendingImplementationChanges.sort(sortByStatusThenTitle)) {
166
+ const counts = changeTaskCounts.get(change.id);
167
+ if (counts && counts.total > 0) {
168
+ pendingImplementationWithTasks.push(change);
169
+ pendingImplementationCounts.set(change.id, `${counts.completed}/${counts.total}`);
170
+ } else {
171
+ pendingImplementationNoTasks.push(change);
172
+ }
173
+ }
174
+
175
+ return {
176
+ pendingReview: pendingReviewChanges.sort(sortByStatusThenTitle),
177
+ pendingImplementation: pendingImplementationWithTasks,
178
+ pendingImplementationNoTasks,
179
+ drafts: draftChanges.sort(sortByStatusThenTitle),
180
+ blocked: blockedTasks.sort(sortByStatusThenTitle),
181
+ wip: wipTasks.sort(sortByStatusThenTitle),
182
+ ready: readyTasks.sort(sortByStatusThenTitle),
183
+ backlog: backlogTasks.sort(sortByStatusThenTitle),
184
+ pendingImplementationCounts,
185
+ };
186
+ };
187
+
188
+ export default function StatusView({ refreshIntervalMs = DEFAULT_REFRESH_INTERVAL_MS, onCopyId }: StatusViewProps) {
189
+ const schubDir = findSchubRoot();
190
+ const [, setRefreshTick] = React.useState(0);
191
+ const {
192
+ pendingReview,
193
+ pendingImplementation,
194
+ pendingImplementationNoTasks,
195
+ drafts,
196
+ blocked,
197
+ wip,
198
+ ready,
199
+ backlog,
200
+ pendingImplementationCounts,
201
+ } = buildStatusData(schubDir);
202
+ const repoRoot = schubDir ? dirname(schubDir) : "";
203
+
204
+ const allItems = [
205
+ ...pendingReview,
206
+ ...pendingImplementation,
207
+ ...pendingImplementationNoTasks,
208
+ ...drafts,
209
+ ...blocked,
210
+ ...wip,
211
+ ...ready,
212
+ ...backlog,
213
+ ];
101
214
 
102
215
  const [selection, setSelection] = React.useState(0);
103
216
  const totalItems = allItems.length;
104
217
 
218
+ React.useEffect(() => {
219
+ if (!schubDir) {
220
+ return;
221
+ }
222
+
223
+ const interval = setInterval(() => {
224
+ autoMarkChangesDone(schubDir);
225
+ setRefreshTick((current) => current + 1);
226
+ }, refreshIntervalMs);
227
+
228
+ return () => {
229
+ clearInterval(interval);
230
+ };
231
+ }, [refreshIntervalMs, schubDir]);
232
+
105
233
  React.useEffect(() => {
106
234
  if (totalItems === 0) {
107
235
  setSelection(0);
@@ -110,7 +238,7 @@ export default function StatusView() {
110
238
  setSelection((current) => Math.min(current, totalItems - 1));
111
239
  }, [totalItems]);
112
240
 
113
- useInput((_input, key) => {
241
+ useInput((input, key) => {
114
242
  if (totalItems === 0) {
115
243
  return;
116
244
  }
@@ -122,6 +250,16 @@ export default function StatusView() {
122
250
  if (key.upArrow) {
123
251
  setSelection((current) => Math.max(current - 1, 0));
124
252
  }
253
+
254
+ if (input === "o") {
255
+ const selectedItem = allItems[selection];
256
+ openInVsCode(repoRoot, selectedItem.path);
257
+ }
258
+
259
+ if (input === "c") {
260
+ const selectedItem = allItems[selection];
261
+ onCopyId(selectedItem.id);
262
+ }
125
263
  });
126
264
 
127
265
  if (!schubDir) {
@@ -141,19 +279,27 @@ export default function StatusView() {
141
279
  );
142
280
  }
143
281
 
144
- const renderRow = (item: ChangeInfo | TaskInfo, index: number) => {
282
+ const renderRow = (item: ChangeInfo | TaskInfo, index: number, detail?: string) => {
145
283
  const selected = index === selection;
146
- const isTask = "path" in item && item.path.includes("tasks/");
147
- const title = isTask ? trimTaskTitle(item.title) : "";
284
+ const task = isTaskItem(item) ? item : null;
285
+ const title = task ? trimTaskTitle(task.title) : "";
286
+ const checklistIndicator =
287
+ task && task.status === "wip" && typeof task.checklistTotal === "number"
288
+ ? ` (${task.checklistRemaining ?? 0}/${task.checklistTotal})`
289
+ : "";
290
+ const changeTitle = item.title;
291
+ const changeDetail = detail ? ` ${detail}` : "";
292
+ const displayTitle = task ? `${title}${checklistIndicator}` : `${changeTitle}${changeDetail}`.trim();
293
+ const displayId = task ? item.id : formatChangeId(item.id);
148
294
 
149
295
  return (
150
296
  <Box key={item.id} marginLeft={1}>
151
297
  <Text color={selected ? "blue" : "gray"}>{selected ? "›" : " "}</Text>
152
298
  <Box marginLeft={1}>
153
299
  <Text color="white" bold={selected}>
154
- {item.id}
300
+ {displayId}
155
301
  </Text>
156
- {title ? <Text color="gray"> {title}</Text> : null}
302
+ {displayTitle ? <Text color="gray"> {displayTitle}</Text> : null}
157
303
  </Box>
158
304
  </Box>
159
305
  );
@@ -161,11 +307,11 @@ export default function StatusView() {
161
307
 
162
308
  let currentIndex = 0;
163
309
 
164
- const renderSection = (title: string, items: (ChangeInfo | TaskInfo)[]) => {
310
+ const renderSection = (title: string, items: (ChangeInfo | TaskInfo)[], detailById?: Map<string, string>) => {
165
311
  if (items.length === 0) return null;
166
312
 
167
313
  const sectionStartIndex = currentIndex;
168
- const sectionItems = items.map((item, i) => renderRow(item, sectionStartIndex + i));
314
+ const sectionItems = items.map((item, i) => renderRow(item, sectionStartIndex + i, detailById?.get(item.id)));
169
315
  currentIndex += items.length;
170
316
 
171
317
  return (
@@ -182,23 +328,27 @@ export default function StatusView() {
182
328
  <Box flexDirection="column">
183
329
  <Box flexDirection="column" marginBottom={1}>
184
330
  <Box marginBottom={1}>
185
- <Text bold color="white" underline>
331
+ <Text bold color="white">
186
332
  Change Proposals
187
333
  </Text>
188
334
  </Box>
189
335
  {renderSection("Pending Review", pendingReview)}
190
- {renderSection("Pending Implementation", pendingImplementation)}
336
+ {renderSection("Pending Implementation", pendingImplementation, pendingImplementationCounts)}
337
+ {renderSection("No Tasks Defined", pendingImplementationNoTasks)}
191
338
  {renderSection("Drafts", drafts)}
192
- {pendingReview.length === 0 && pendingImplementation.length === 0 && drafts.length === 0 && (
193
- <Box marginLeft={1}>
194
- <Text color="gray">No active proposals.</Text>
195
- </Box>
196
- )}
339
+ {pendingReview.length === 0 &&
340
+ pendingImplementation.length === 0 &&
341
+ pendingImplementationNoTasks.length === 0 &&
342
+ drafts.length === 0 && (
343
+ <Box marginLeft={1}>
344
+ <Text color="gray">No active proposals.</Text>
345
+ </Box>
346
+ )}
197
347
  </Box>
198
348
 
199
349
  <Box flexDirection="column">
200
350
  <Box marginBottom={1}>
201
- <Text bold color="white" underline>
351
+ <Text bold color="white">
202
352
  Tasks
203
353
  </Text>
204
354
  </Box>
@@ -8,6 +8,8 @@ export type TaskInfo = {
8
8
  status: TaskStatus;
9
9
  path: string;
10
10
  changeId?: string;
11
+ checklistRemaining?: number;
12
+ checklistTotal?: number;
11
13
  };
12
14
 
13
15
  export type TaskDependency = TaskInfo & {
@@ -1,14 +1,22 @@
1
1
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
-
5
- const TASK_TEMPLATE_PATH = fileURLToPath(new URL("../../../templates/create-tasks/task-template.md", import.meta.url));
6
-
7
- const readTaskTemplate = () => {
4
+ import { resolveTemplatePath } from "../../templates";
5
+
6
+ const BUNDLED_TASK_TEMPLATE_PATH = fileURLToPath(
7
+ new URL("../../../templates/create-tasks/task-template.md", import.meta.url),
8
+ );
9
+
10
+ const readTaskTemplate = (schubDir: string) => {
11
+ const templatePath = resolveTemplatePath(
12
+ schubDir,
13
+ join("create-tasks", "task-template.md"),
14
+ BUNDLED_TASK_TEMPLATE_PATH,
15
+ );
8
16
  try {
9
- return readFileSync(TASK_TEMPLATE_PATH, "utf8");
17
+ return readFileSync(templatePath, "utf8");
10
18
  } catch {
11
- throw new Error(`[ERROR] Template not found: ${TASK_TEMPLATE_PATH}`);
19
+ throw new Error(`[ERROR] Template not found: ${templatePath}`);
12
20
  }
13
21
  };
14
22
 
@@ -89,7 +97,7 @@ export const createTask = (
89
97
  let nextNumber = existingNumbers.size > 0 ? Math.max(...existingNumbers) + 1 : 1;
90
98
  const statusDir = join(tasksRoot, status);
91
99
  mkdirSync(statusDir, { recursive: true });
92
- const template = readTaskTemplate();
100
+ const template = readTaskTemplate(schubDir);
93
101
 
94
102
  const createdPaths: string[] = [];
95
103
 
@@ -0,0 +1,78 @@
1
+ import { expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { listTasks, loadTaskDependencies } from "./filesystem";
6
+
7
+ const setupChecklistRepo = () => {
8
+ const base = mkdtempSync(join(tmpdir(), "schub-checklist-"));
9
+ const schubDir = join(base, ".schub");
10
+ const tasksRoot = join(schubDir, "tasks", "backlog");
11
+ mkdirSync(tasksRoot, { recursive: true });
12
+
13
+ const tasks = [
14
+ {
15
+ id: "T001",
16
+ slug: "unchecked-only",
17
+ body: `# Task: T001 Unchecked Only\n\n## Steps\n- [ ] First item\n- [ ] Second item\n`,
18
+ },
19
+ {
20
+ id: "T002",
21
+ slug: "checked-only",
22
+ body: `# Task: T002 Checked Only\n\n## Steps\n- [x] Finished item\n`,
23
+ },
24
+ {
25
+ id: "T003",
26
+ slug: "mixed-checklist",
27
+ body: `# Task: T003 Mixed Checklist\n\n## Steps\n- [ ] Remaining item\n- [x] Done item\n\n## Acceptance\n- [ ] Ignore this\n`,
28
+ },
29
+ {
30
+ id: "T004",
31
+ slug: "missing-checklist",
32
+ body: `# Task: T004 Missing Checklist\n\n## Steps\n- Do the thing\n- Another item\n\n## Acceptance\n- [ ] Ignore this too\n`,
33
+ },
34
+ ];
35
+
36
+ for (const task of tasks) {
37
+ const filePath = join(tasksRoot, `${task.id}_${task.slug}.md`);
38
+ writeFileSync(filePath, task.body, "utf8");
39
+ }
40
+
41
+ return schubDir;
42
+ };
43
+
44
+ const getTaskById = <T extends { id: string }>(tasks: T[], id: string) => {
45
+ return tasks.find((task) => task.id === id);
46
+ };
47
+
48
+ test("listTasks includes Steps-only checklist counts", () => {
49
+ const schubDir = setupChecklistRepo();
50
+ const tasks = listTasks(schubDir);
51
+
52
+ const unchecked = getTaskById(tasks, "T001");
53
+ const checked = getTaskById(tasks, "T002");
54
+ const mixed = getTaskById(tasks, "T003");
55
+ const missing = getTaskById(tasks, "T004");
56
+
57
+ expect(unchecked).toMatchObject({ checklistTotal: 2, checklistRemaining: 2 });
58
+ expect(checked).toMatchObject({ checklistTotal: 1, checklistRemaining: 0 });
59
+ expect(mixed).toMatchObject({ checklistTotal: 2, checklistRemaining: 1 });
60
+ expect(missing?.checklistTotal).toBeUndefined();
61
+ expect(missing?.checklistRemaining).toBeUndefined();
62
+ });
63
+
64
+ test("loadTaskDependencies includes Steps-only checklist counts", () => {
65
+ const schubDir = setupChecklistRepo();
66
+ const tasks = loadTaskDependencies(schubDir);
67
+
68
+ const unchecked = getTaskById(tasks, "T001");
69
+ const checked = getTaskById(tasks, "T002");
70
+ const mixed = getTaskById(tasks, "T003");
71
+ const missing = getTaskById(tasks, "T004");
72
+
73
+ expect(unchecked).toMatchObject({ checklistTotal: 2, checklistRemaining: 2 });
74
+ expect(checked).toMatchObject({ checklistTotal: 1, checklistRemaining: 0 });
75
+ expect(mixed).toMatchObject({ checklistTotal: 2, checklistRemaining: 1 });
76
+ expect(missing?.checklistTotal).toBeUndefined();
77
+ expect(missing?.checklistRemaining).toBeUndefined();
78
+ });
@@ -49,6 +49,51 @@ type ParsedTaskFile = {
49
49
  title: string;
50
50
  dependsOn: string[];
51
51
  changeId?: string;
52
+ checklistRemaining?: number;
53
+ checklistTotal?: number;
54
+ };
55
+
56
+ const parseChecklistCounts = (content: string) => {
57
+ const lines = content.split(/\r?\n/);
58
+ let inSteps = false;
59
+ let checklistTotal = 0;
60
+ let checklistRemaining = 0;
61
+
62
+ for (const line of lines) {
63
+ const headingMatch = line.match(/^##\s+(.*)$/);
64
+ if (headingMatch) {
65
+ const heading = headingMatch[1].trim().toLowerCase();
66
+ if (heading === "steps") {
67
+ inSteps = true;
68
+ } else if (inSteps) {
69
+ break;
70
+ }
71
+ continue;
72
+ }
73
+
74
+ if (!inSteps) {
75
+ continue;
76
+ }
77
+
78
+ const checklistMatch = line.match(/^\s*-\s+\[( |x|X)\]/);
79
+ if (!checklistMatch) {
80
+ continue;
81
+ }
82
+
83
+ checklistTotal += 1;
84
+ if (checklistMatch[1].toLowerCase() !== "x") {
85
+ checklistRemaining += 1;
86
+ }
87
+ }
88
+
89
+ if (checklistTotal === 0) {
90
+ return null;
91
+ }
92
+
93
+ return {
94
+ checklistRemaining,
95
+ checklistTotal,
96
+ };
52
97
  };
53
98
 
54
99
  const parseTaskFile = (filePath: string, fallback: { id: string; title: string }): ParsedTaskFile => {
@@ -59,10 +104,6 @@ const parseTaskFile = (filePath: string, fallback: { id: string; title: string }
59
104
  return { ...fallback, dependsOn: [] };
60
105
  }
61
106
 
62
- const headerMatch = content.match(/^#\s*Task:\s*(\S+)\s+(.+)$/m);
63
- const id = headerMatch?.[1] ?? fallback.id;
64
- const title = headerMatch?.[2]?.trim() ?? fallback.title;
65
-
66
107
  const changeIdMatch = content.match(/^\*\*Change ID\*\*:\s*\[?`?([^\]`]+)`?]?/m);
67
108
  const changeId = changeIdMatch?.[1]?.trim();
68
109
 
@@ -78,7 +119,15 @@ const parseTaskFile = (filePath: string, fallback: { id: string; title: string }
78
119
  }
79
120
  }
80
121
 
81
- return { id, title, dependsOn, changeId };
122
+ const checklistCounts = parseChecklistCounts(content);
123
+
124
+ return {
125
+ id: fallback.id,
126
+ title: fallback.title,
127
+ dependsOn,
128
+ changeId,
129
+ ...(checklistCounts ?? {}),
130
+ };
82
131
  };
83
132
 
84
133
  export const listTasks = (schubDir: string, statuses: readonly TaskStatus[] = TASK_STATUSES): TaskInfo[] => {
@@ -113,11 +162,14 @@ export const listTasks = (schubDir: string, statuses: readonly TaskStatus[] = TA
113
162
  }
114
163
 
115
164
  const filePath = join(statusDir, entry.name);
165
+ const parsedFile = parseTaskFile(filePath, parsed);
116
166
  tasks.push({
117
167
  id: parsed.id,
118
168
  title: parsed.title,
119
169
  status,
120
170
  path: relative(repoRoot, filePath),
171
+ checklistRemaining: parsedFile.checklistRemaining,
172
+ checklistTotal: parsedFile.checklistTotal,
121
173
  });
122
174
  }
123
175
  }
@@ -164,12 +216,14 @@ export const loadTaskDependencies = (
164
216
  const dependsOn = parsedFile.dependsOn.sort(compareTaskIds);
165
217
 
166
218
  tasks.push({
167
- id: parsedFile.id,
168
- title: parsedFile.title,
219
+ id: parsed.id,
220
+ title: parsed.title,
169
221
  status,
170
222
  path: relative(repoRoot, filePath),
171
223
  dependsOn,
172
224
  changeId: parsedFile.changeId,
225
+ checklistRemaining: parsedFile.checklistRemaining,
226
+ checklistTotal: parsedFile.checklistTotal,
173
227
  });
174
228
  }
175
229
  }
package/src/ide.ts ADDED
@@ -0,0 +1,7 @@
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
+ };
@@ -0,0 +1,23 @@
1
+ import { expect, test } from "bun:test";
2
+ import { dirname, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { spawnSync } from "bun";
5
+
6
+ test("schub help lists tasks list", () => {
7
+ const testDir = dirname(fileURLToPath(import.meta.url));
8
+ const cliDir = resolve(testDir, "..");
9
+ const result = spawnSync({
10
+ cmd: ["bun", "run", "schub", "--help"],
11
+ cwd: cliDir,
12
+ });
13
+
14
+ expect(result.exitCode).toBe(0);
15
+ const stdout = new TextDecoder().decode(result.stdout ?? new Uint8Array());
16
+ expect(stdout).toContain("changes create");
17
+ expect(stdout).toContain("eject");
18
+ expect(stdout).toContain("init");
19
+ expect(stdout).toContain("tasks list");
20
+ expect(stdout).toContain("review complete");
21
+ expect(stdout).not.toContain("lint");
22
+ expect(stdout).not.toContain("format");
23
+ });