schub 0.1.1 → 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.
- package/README.md +2 -0
- package/dist/index.js +836 -269
- package/package.json +3 -1
- package/skills/create-proposal/SKILL.md +33 -0
- package/skills/create-tasks/SKILL.md +40 -0
- package/skills/implement-task/SKILL.md +84 -0
- package/skills/review-proposal/SKILL.md +37 -0
- package/skills/setup-project/SKILL.md +29 -0
- package/src/App.test.tsx +93 -0
- package/src/App.tsx +50 -7
- package/src/changes.ts +42 -28
- package/src/clipboard.ts +5 -0
- package/src/commands/adr.test.ts +69 -0
- package/src/commands/adr.ts +10 -2
- package/src/commands/changes.test.ts +171 -0
- package/src/commands/changes.ts +76 -1
- package/src/commands/cookbook.test.ts +71 -0
- package/src/commands/cookbook.ts +8 -2
- package/src/commands/eject.test.ts +74 -0
- package/src/commands/eject.ts +100 -0
- package/src/commands/init.test.ts +78 -0
- package/src/commands/init.ts +144 -0
- package/src/commands/project.test.ts +113 -0
- package/src/commands/review.test.ts +100 -0
- package/src/commands/review.ts +17 -4
- package/src/commands/tasks-create.test.ts +172 -0
- package/src/commands/tasks-list.test.ts +177 -0
- package/src/components/PlanView.test.tsx +113 -0
- package/src/components/PlanView.tsx +95 -26
- package/src/components/StatusView.test.tsx +380 -0
- package/src/components/StatusView.tsx +175 -34
- package/src/features/tasks/create.ts +15 -7
- package/src/features/tasks/filesystem.test.ts +78 -0
- package/src/features/tasks/filesystem.ts +4 -8
- package/src/ide.ts +7 -0
- package/src/index.test.ts +23 -0
- package/src/index.ts +19 -6
- package/src/init.test.ts +43 -0
- package/src/init.ts +27 -0
- package/src/project.ts +5 -32
- package/src/schub-root.ts +33 -0
- package/src/templates.ts +18 -0
- package/src/terminal.test.ts +46 -0
- package/templates/templates-parity.test.ts +45 -0
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import { dirname, normalize, sep } from "node:path";
|
|
1
2
|
import { Box, Text, useInput } from "ink";
|
|
2
|
-
import { normalize, sep } from "node:path";
|
|
3
3
|
import React from "react";
|
|
4
|
-
import { type ChangeInfo, listChanges } from "../changes";
|
|
4
|
+
import { type ChangeInfo, listChanges, updateChangeStatus } from "../changes";
|
|
5
5
|
import { findSchubRoot, loadTaskDependencies, type TaskInfo, trimTaskTitle } from "../features/tasks";
|
|
6
|
+
import { openInVsCode } from "../ide";
|
|
6
7
|
|
|
7
8
|
type StatusSortable = {
|
|
8
9
|
id: string;
|
|
@@ -10,6 +11,22 @@ type StatusSortable = {
|
|
|
10
11
|
status: string;
|
|
11
12
|
};
|
|
12
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
|
+
|
|
13
30
|
const compareText = (left: string, right: string): number =>
|
|
14
31
|
left.localeCompare(right, undefined, { sensitivity: "base" });
|
|
15
32
|
|
|
@@ -32,20 +49,59 @@ const isTaskItem = (item: ChangeInfo | TaskInfo): item is TaskInfo => {
|
|
|
32
49
|
return parts.includes("tasks");
|
|
33
50
|
};
|
|
34
51
|
|
|
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");
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
35
89
|
const buildStatusData = (schubDir: string | null) => {
|
|
36
90
|
if (!schubDir) {
|
|
37
91
|
return {
|
|
38
92
|
pendingReview: [],
|
|
39
93
|
pendingImplementation: [],
|
|
94
|
+
pendingImplementationNoTasks: [],
|
|
40
95
|
drafts: [],
|
|
41
96
|
blocked: [],
|
|
42
97
|
wip: [],
|
|
43
98
|
ready: [],
|
|
44
99
|
backlog: [],
|
|
100
|
+
pendingImplementationCounts: new Map<string, string>(),
|
|
45
101
|
};
|
|
46
102
|
}
|
|
47
103
|
|
|
48
|
-
const allTasks = loadTaskDependencies(schubDir,
|
|
104
|
+
const allTasks = loadTaskDependencies(schubDir, CHANGE_TASK_STATUSES);
|
|
49
105
|
|
|
50
106
|
const blockedTasks: TaskInfo[] = [];
|
|
51
107
|
const wipTasks: TaskInfo[] = [];
|
|
@@ -53,17 +109,23 @@ const buildStatusData = (schubDir: string | null) => {
|
|
|
53
109
|
const backlogTasks: TaskInfo[] = [];
|
|
54
110
|
|
|
55
111
|
const activeChangeIds = new Set<string>();
|
|
112
|
+
const changeTaskCounts = new Map<string, ChangeTaskCount>();
|
|
56
113
|
|
|
57
114
|
for (const task of allTasks) {
|
|
58
|
-
|
|
59
|
-
if (
|
|
60
|
-
else if (
|
|
61
|
-
else if (
|
|
62
|
-
else if (s === "backlog") backlogTasks.push(task);
|
|
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);
|
|
63
119
|
|
|
64
|
-
if (task.changeId) {
|
|
120
|
+
if (task.changeId && task.status !== "done") {
|
|
65
121
|
activeChangeIds.add(task.changeId);
|
|
66
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
|
+
}
|
|
67
129
|
}
|
|
68
130
|
|
|
69
131
|
const allChanges = listChanges(schubDir);
|
|
@@ -75,37 +137,99 @@ const buildStatusData = (schubDir: string | null) => {
|
|
|
75
137
|
const normalized = change.status.toLowerCase();
|
|
76
138
|
if (normalized.includes("review")) {
|
|
77
139
|
pendingReviewChanges.push(change);
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
)
|
|
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)) {
|
|
83
147
|
pendingImplementationChanges.push(change);
|
|
84
|
-
|
|
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")) {
|
|
85
157
|
draftChanges.push(change);
|
|
86
158
|
}
|
|
87
159
|
}
|
|
88
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
|
+
|
|
89
175
|
return {
|
|
90
176
|
pendingReview: pendingReviewChanges.sort(sortByStatusThenTitle),
|
|
91
|
-
pendingImplementation:
|
|
177
|
+
pendingImplementation: pendingImplementationWithTasks,
|
|
178
|
+
pendingImplementationNoTasks,
|
|
92
179
|
drafts: draftChanges.sort(sortByStatusThenTitle),
|
|
93
180
|
blocked: blockedTasks.sort(sortByStatusThenTitle),
|
|
94
181
|
wip: wipTasks.sort(sortByStatusThenTitle),
|
|
95
182
|
ready: readyTasks.sort(sortByStatusThenTitle),
|
|
96
183
|
backlog: backlogTasks.sort(sortByStatusThenTitle),
|
|
184
|
+
pendingImplementationCounts,
|
|
97
185
|
};
|
|
98
186
|
};
|
|
99
187
|
|
|
100
|
-
export default function StatusView() {
|
|
188
|
+
export default function StatusView({ refreshIntervalMs = DEFAULT_REFRESH_INTERVAL_MS, onCopyId }: StatusViewProps) {
|
|
101
189
|
const schubDir = findSchubRoot();
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
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
|
+
];
|
|
105
214
|
|
|
106
215
|
const [selection, setSelection] = React.useState(0);
|
|
107
216
|
const totalItems = allItems.length;
|
|
108
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
|
+
|
|
109
233
|
React.useEffect(() => {
|
|
110
234
|
if (totalItems === 0) {
|
|
111
235
|
setSelection(0);
|
|
@@ -114,7 +238,7 @@ export default function StatusView() {
|
|
|
114
238
|
setSelection((current) => Math.min(current, totalItems - 1));
|
|
115
239
|
}, [totalItems]);
|
|
116
240
|
|
|
117
|
-
useInput((
|
|
241
|
+
useInput((input, key) => {
|
|
118
242
|
if (totalItems === 0) {
|
|
119
243
|
return;
|
|
120
244
|
}
|
|
@@ -126,6 +250,16 @@ export default function StatusView() {
|
|
|
126
250
|
if (key.upArrow) {
|
|
127
251
|
setSelection((current) => Math.max(current - 1, 0));
|
|
128
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
|
+
}
|
|
129
263
|
});
|
|
130
264
|
|
|
131
265
|
if (!schubDir) {
|
|
@@ -145,7 +279,7 @@ export default function StatusView() {
|
|
|
145
279
|
);
|
|
146
280
|
}
|
|
147
281
|
|
|
148
|
-
const renderRow = (item: ChangeInfo | TaskInfo, index: number) => {
|
|
282
|
+
const renderRow = (item: ChangeInfo | TaskInfo, index: number, detail?: string) => {
|
|
149
283
|
const selected = index === selection;
|
|
150
284
|
const task = isTaskItem(item) ? item : null;
|
|
151
285
|
const title = task ? trimTaskTitle(task.title) : "";
|
|
@@ -153,14 +287,17 @@ export default function StatusView() {
|
|
|
153
287
|
task && task.status === "wip" && typeof task.checklistTotal === "number"
|
|
154
288
|
? ` (${task.checklistRemaining ?? 0}/${task.checklistTotal})`
|
|
155
289
|
: "";
|
|
156
|
-
const
|
|
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);
|
|
157
294
|
|
|
158
295
|
return (
|
|
159
296
|
<Box key={item.id} marginLeft={1}>
|
|
160
297
|
<Text color={selected ? "blue" : "gray"}>{selected ? "›" : " "}</Text>
|
|
161
298
|
<Box marginLeft={1}>
|
|
162
299
|
<Text color="white" bold={selected}>
|
|
163
|
-
{
|
|
300
|
+
{displayId}
|
|
164
301
|
</Text>
|
|
165
302
|
{displayTitle ? <Text color="gray"> {displayTitle}</Text> : null}
|
|
166
303
|
</Box>
|
|
@@ -170,11 +307,11 @@ export default function StatusView() {
|
|
|
170
307
|
|
|
171
308
|
let currentIndex = 0;
|
|
172
309
|
|
|
173
|
-
const renderSection = (title: string, items: (ChangeInfo | TaskInfo)[]) => {
|
|
310
|
+
const renderSection = (title: string, items: (ChangeInfo | TaskInfo)[], detailById?: Map<string, string>) => {
|
|
174
311
|
if (items.length === 0) return null;
|
|
175
312
|
|
|
176
313
|
const sectionStartIndex = currentIndex;
|
|
177
|
-
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)));
|
|
178
315
|
currentIndex += items.length;
|
|
179
316
|
|
|
180
317
|
return (
|
|
@@ -191,23 +328,27 @@ export default function StatusView() {
|
|
|
191
328
|
<Box flexDirection="column">
|
|
192
329
|
<Box flexDirection="column" marginBottom={1}>
|
|
193
330
|
<Box marginBottom={1}>
|
|
194
|
-
<Text bold color="white"
|
|
331
|
+
<Text bold color="white">
|
|
195
332
|
Change Proposals
|
|
196
333
|
</Text>
|
|
197
334
|
</Box>
|
|
198
335
|
{renderSection("Pending Review", pendingReview)}
|
|
199
|
-
{renderSection("Pending Implementation", pendingImplementation)}
|
|
336
|
+
{renderSection("Pending Implementation", pendingImplementation, pendingImplementationCounts)}
|
|
337
|
+
{renderSection("No Tasks Defined", pendingImplementationNoTasks)}
|
|
200
338
|
{renderSection("Drafts", drafts)}
|
|
201
|
-
{pendingReview.length === 0 &&
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
+
)}
|
|
206
347
|
</Box>
|
|
207
348
|
|
|
208
349
|
<Box flexDirection="column">
|
|
209
350
|
<Box marginBottom={1}>
|
|
210
|
-
<Text bold color="white"
|
|
351
|
+
<Text bold color="white">
|
|
211
352
|
Tasks
|
|
212
353
|
</Text>
|
|
213
354
|
</Box>
|
|
@@ -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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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(
|
|
17
|
+
return readFileSync(templatePath, "utf8");
|
|
10
18
|
} catch {
|
|
11
|
-
throw new Error(`[ERROR] Template not found: ${
|
|
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
|
+
});
|
|
@@ -104,10 +104,6 @@ const parseTaskFile = (filePath: string, fallback: { id: string; title: string }
|
|
|
104
104
|
return { ...fallback, dependsOn: [] };
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
const headerMatch = content.match(/^#\s*Task:\s*(\S+)\s+(.+)$/m);
|
|
108
|
-
const id = headerMatch?.[1] ?? fallback.id;
|
|
109
|
-
const title = headerMatch?.[2]?.trim() ?? fallback.title;
|
|
110
|
-
|
|
111
107
|
const changeIdMatch = content.match(/^\*\*Change ID\*\*:\s*\[?`?([^\]`]+)`?]?/m);
|
|
112
108
|
const changeId = changeIdMatch?.[1]?.trim();
|
|
113
109
|
|
|
@@ -126,8 +122,8 @@ const parseTaskFile = (filePath: string, fallback: { id: string; title: string }
|
|
|
126
122
|
const checklistCounts = parseChecklistCounts(content);
|
|
127
123
|
|
|
128
124
|
return {
|
|
129
|
-
id,
|
|
130
|
-
title,
|
|
125
|
+
id: fallback.id,
|
|
126
|
+
title: fallback.title,
|
|
131
127
|
dependsOn,
|
|
132
128
|
changeId,
|
|
133
129
|
...(checklistCounts ?? {}),
|
|
@@ -220,8 +216,8 @@ export const loadTaskDependencies = (
|
|
|
220
216
|
const dependsOn = parsedFile.dependsOn.sort(compareTaskIds);
|
|
221
217
|
|
|
222
218
|
tasks.push({
|
|
223
|
-
id:
|
|
224
|
-
title:
|
|
219
|
+
id: parsed.id,
|
|
220
|
+
title: parsed.title,
|
|
225
221
|
status,
|
|
226
222
|
path: relative(repoRoot, filePath),
|
|
227
223
|
dependsOn,
|
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
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -3,8 +3,10 @@ import { render } from "ink";
|
|
|
3
3
|
import React from "react";
|
|
4
4
|
import App from "./App";
|
|
5
5
|
import { runAdrCreate } from "./commands/adr";
|
|
6
|
-
import { runChangesCreate } from "./commands/changes";
|
|
6
|
+
import { runChangesCreate, runChangesStatus } from "./commands/changes";
|
|
7
7
|
import { runCookbookCreate } from "./commands/cookbook";
|
|
8
|
+
import { runEject } from "./commands/eject";
|
|
9
|
+
import { runInit } from "./commands/init";
|
|
8
10
|
import { runProjectCreate } from "./commands/project";
|
|
9
11
|
import { runReviewComplete, runReviewCreate } from "./commands/review";
|
|
10
12
|
import { runTasksCreate, runTasksList } from "./commands/tasks";
|
|
@@ -15,6 +17,7 @@ const HELP_TEXT = `schub [command]
|
|
|
15
17
|
|
|
16
18
|
Commands:
|
|
17
19
|
changes create Create a change proposal
|
|
20
|
+
changes status Update change proposal status
|
|
18
21
|
project create Create project docs
|
|
19
22
|
tasks create Create task files for a change
|
|
20
23
|
tasks list List tasks
|
|
@@ -22,6 +25,8 @@ Commands:
|
|
|
22
25
|
review complete Create Q&A from REVIEW_ME
|
|
23
26
|
adr create Create an ADR for a change
|
|
24
27
|
cookbook create Create a cookbook for a change
|
|
28
|
+
init Initialize .schub and install Codex skills
|
|
29
|
+
eject Copy bundled skills and templates into .schub
|
|
25
30
|
ui Launch the interactive dashboard
|
|
26
31
|
`;
|
|
27
32
|
|
|
@@ -46,7 +51,7 @@ const runUi = () => {
|
|
|
46
51
|
render(React.createElement(App));
|
|
47
52
|
};
|
|
48
53
|
|
|
49
|
-
const runCommand = () => {
|
|
54
|
+
const runCommand = async () => {
|
|
50
55
|
const args = process.argv.slice(2);
|
|
51
56
|
if (args.length === 0) {
|
|
52
57
|
runUi();
|
|
@@ -66,6 +71,10 @@ const runCommand = () => {
|
|
|
66
71
|
runChangesCreate(rest, getStartDir());
|
|
67
72
|
return;
|
|
68
73
|
}
|
|
74
|
+
if (secondary === "status") {
|
|
75
|
+
runChangesStatus(rest, getStartDir());
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
69
78
|
break;
|
|
70
79
|
case "project":
|
|
71
80
|
if (secondary === "create") {
|
|
@@ -105,6 +114,12 @@ const runCommand = () => {
|
|
|
105
114
|
return;
|
|
106
115
|
}
|
|
107
116
|
break;
|
|
117
|
+
case "eject":
|
|
118
|
+
runEject(args.slice(1), getStartDir());
|
|
119
|
+
return;
|
|
120
|
+
case "init":
|
|
121
|
+
await runInit(args.slice(1), getStartDir());
|
|
122
|
+
return;
|
|
108
123
|
case "ui":
|
|
109
124
|
runUi();
|
|
110
125
|
return;
|
|
@@ -113,10 +128,8 @@ const runCommand = () => {
|
|
|
113
128
|
printHelp(1);
|
|
114
129
|
};
|
|
115
130
|
|
|
116
|
-
|
|
117
|
-
runCommand();
|
|
118
|
-
} catch (error) {
|
|
131
|
+
runCommand().catch((error) => {
|
|
119
132
|
const message = error instanceof Error ? error.message : String(error);
|
|
120
133
|
process.stderr.write(`${message}\n`);
|
|
121
134
|
process.exitCode = 1;
|
|
122
|
-
}
|
|
135
|
+
});
|
package/src/init.test.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { existsSync, mkdirSync, mkdtempSync, realpathSync, statSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { initSchubRoot } from "./init";
|
|
7
|
+
|
|
8
|
+
const createRepoFixture = () => {
|
|
9
|
+
const base = mkdtempSync(join(tmpdir(), "schub-init-root-"));
|
|
10
|
+
const repoRoot = join(base, "repo");
|
|
11
|
+
const startDir = join(repoRoot, "nested", "dir");
|
|
12
|
+
mkdirSync(startDir, { recursive: true });
|
|
13
|
+
return { base, repoRoot, startDir };
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
test("initSchubRoot resolves git worktree root and creates .schub", () => {
|
|
17
|
+
const { repoRoot, startDir } = createRepoFixture();
|
|
18
|
+
const result = spawnSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" });
|
|
19
|
+
|
|
20
|
+
expect(result.status).toBe(0);
|
|
21
|
+
|
|
22
|
+
const schubRoot = initSchubRoot(startDir);
|
|
23
|
+
|
|
24
|
+
expect(realpathSync(schubRoot)).toBe(realpathSync(join(repoRoot, ".schub")));
|
|
25
|
+
expect(existsSync(schubRoot)).toBe(true);
|
|
26
|
+
expect(statSync(schubRoot).isDirectory()).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("initSchubRoot falls back to startDir when git is unavailable", () => {
|
|
30
|
+
const { startDir } = createRepoFixture();
|
|
31
|
+
const originalPath = process.env.PATH;
|
|
32
|
+
process.env.PATH = "";
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const schubRoot = initSchubRoot(startDir);
|
|
36
|
+
|
|
37
|
+
expect(schubRoot).toBe(join(startDir, ".schub"));
|
|
38
|
+
expect(existsSync(schubRoot)).toBe(true);
|
|
39
|
+
expect(statSync(schubRoot).isDirectory()).toBe(true);
|
|
40
|
+
} finally {
|
|
41
|
+
process.env.PATH = originalPath;
|
|
42
|
+
}
|
|
43
|
+
});
|
package/src/init.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { mkdirSync } from "node:fs";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
export const resolveGitRoot = (startDir: string) => {
|
|
6
|
+
const result = spawnSync("git", ["rev-parse", "--show-toplevel"], {
|
|
7
|
+
cwd: startDir,
|
|
8
|
+
encoding: "utf8",
|
|
9
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
if (result.status !== 0) {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const output = result.stdout?.trim();
|
|
17
|
+
return output ? resolve(output) : null;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const initSchubRoot = (startDir: string = process.cwd()) => {
|
|
21
|
+
const resolvedStart = resolve(startDir);
|
|
22
|
+
const gitRoot = resolveGitRoot(resolvedStart);
|
|
23
|
+
const root = gitRoot ?? resolvedStart;
|
|
24
|
+
const schubRoot = join(root, ".schub");
|
|
25
|
+
mkdirSync(schubRoot, { recursive: true });
|
|
26
|
+
return schubRoot;
|
|
27
|
+
};
|