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
@@ -0,0 +1,231 @@
1
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { isValidChangeId, normalizeChangeId, readChangeSummary, resolveChangeRoot } from "../changes";
5
+ import { resolveTemplatePath } from "../templates";
6
+
7
+ const BUNDLED_REVIEW_TEMPLATE_PATH = fileURLToPath(
8
+ new URL("../../templates/review-proposal/review-me-template.md", import.meta.url),
9
+ );
10
+ const BUNDLED_QNA_TEMPLATE_PATH = fileURLToPath(
11
+ new URL("../../templates/review-proposal/q&a-template.md", import.meta.url),
12
+ );
13
+
14
+ type ReviewCreateOptions = {
15
+ changeId: string;
16
+ title?: string;
17
+ output?: string;
18
+ overwrite: boolean;
19
+ };
20
+
21
+ type ReviewCompleteOptions = {
22
+ changeId: string;
23
+ overwrite: boolean;
24
+ };
25
+
26
+ const renderChangeTemplate = (template: string, changeTitle: string, changeId: string) => {
27
+ const today = new Date().toISOString().split("T")[0];
28
+ return template
29
+ .replace("{{CHANGE_TITLE}}", changeTitle)
30
+ .replace("{{CHANGE_ID}}", changeId)
31
+ .replace("{{DATE}}", today);
32
+ };
33
+
34
+ const parseReviewCreateOptions = (args: string[]) => {
35
+ let changeId: string | undefined;
36
+ let title: string | undefined;
37
+ let output: string | undefined;
38
+ let overwrite = false;
39
+ const unknown: string[] = [];
40
+
41
+ const rejectUnsupported = (flag: string) => {
42
+ throw new Error(`Unsupported option: ${flag}.`);
43
+ };
44
+
45
+ for (let index = 0; index < args.length; index += 1) {
46
+ const arg = args[index];
47
+ if (arg === "--overwrite") {
48
+ overwrite = true;
49
+ continue;
50
+ }
51
+ if (arg === "--change-id") {
52
+ changeId = args[index + 1];
53
+ if (changeId === undefined) {
54
+ throw new Error("Missing value for --change-id.");
55
+ }
56
+ index += 1;
57
+ continue;
58
+ }
59
+ if (arg.startsWith("--change-id=")) {
60
+ changeId = arg.slice("--change-id=".length);
61
+ continue;
62
+ }
63
+ if (arg === "--title") {
64
+ title = args[index + 1];
65
+ if (title === undefined) {
66
+ throw new Error("Missing value for --title.");
67
+ }
68
+ index += 1;
69
+ continue;
70
+ }
71
+ if (arg.startsWith("--title=")) {
72
+ title = arg.slice("--title=".length);
73
+ continue;
74
+ }
75
+ if (arg === "--output") {
76
+ output = args[index + 1];
77
+ if (output === undefined) {
78
+ throw new Error("Missing value for --output.");
79
+ }
80
+ index += 1;
81
+ continue;
82
+ }
83
+ if (arg.startsWith("--output=")) {
84
+ output = arg.slice("--output=".length);
85
+ continue;
86
+ }
87
+ if (arg === "--schub-root" || arg === "--agent-root") {
88
+ rejectUnsupported(arg);
89
+ }
90
+ if (arg.startsWith("--schub-root=")) {
91
+ rejectUnsupported("--schub-root");
92
+ }
93
+ if (arg.startsWith("--agent-root=")) {
94
+ rejectUnsupported("--agent-root");
95
+ }
96
+ unknown.push(arg);
97
+ }
98
+
99
+ if (unknown.length > 0) {
100
+ throw new Error(`Unknown option(s): ${unknown.join(", ")}`);
101
+ }
102
+
103
+ if (!changeId) {
104
+ throw new Error("Provide --change-id.");
105
+ }
106
+
107
+ return { changeId, title, output, overwrite };
108
+ };
109
+
110
+ const parseReviewCompleteOptions = (args: string[]) => {
111
+ let changeId: string | undefined;
112
+ let overwrite = false;
113
+ const unknown: string[] = [];
114
+
115
+ const rejectUnsupported = (flag: string) => {
116
+ throw new Error(`Unsupported option: ${flag}.`);
117
+ };
118
+
119
+ for (let index = 0; index < args.length; index += 1) {
120
+ const arg = args[index];
121
+ if (arg === "--overwrite") {
122
+ overwrite = true;
123
+ continue;
124
+ }
125
+ if (arg === "--change-id") {
126
+ changeId = args[index + 1];
127
+ if (changeId === undefined) {
128
+ throw new Error("Missing value for --change-id.");
129
+ }
130
+ index += 1;
131
+ continue;
132
+ }
133
+ if (arg.startsWith("--change-id=")) {
134
+ changeId = arg.slice("--change-id=".length);
135
+ continue;
136
+ }
137
+ if (arg === "--schub-root" || arg === "--agent-root") {
138
+ rejectUnsupported(arg);
139
+ }
140
+ if (arg.startsWith("--schub-root=")) {
141
+ rejectUnsupported("--schub-root");
142
+ }
143
+ if (arg.startsWith("--agent-root=")) {
144
+ rejectUnsupported("--agent-root");
145
+ }
146
+ unknown.push(arg);
147
+ }
148
+
149
+ if (unknown.length > 0) {
150
+ throw new Error(`Unknown option(s): ${unknown.join(", ")}`);
151
+ }
152
+
153
+ if (!changeId) {
154
+ throw new Error("Provide --change-id.");
155
+ }
156
+
157
+ return { changeId, overwrite };
158
+ };
159
+
160
+ const resolveReviewOutput = (schubDir: string, changeId: string, output?: string) => {
161
+ if (output) {
162
+ return resolve(schubDir, output);
163
+ }
164
+ return join(schubDir, "changes", changeId, "REVIEW_ME.md");
165
+ };
166
+
167
+ export const runReviewCreate = (args: string[], startDir: string) => {
168
+ const options: ReviewCreateOptions = parseReviewCreateOptions(args);
169
+ const schubDir = resolveChangeRoot(startDir);
170
+ const trimmedId = options.changeId.trim();
171
+
172
+ if (!isValidChangeId(trimmedId)) {
173
+ throw new Error(
174
+ `Invalid change-id '${options.changeId}'. Use kebab-case or a C-prefixed id (e.g., C001_add-user-auth).`,
175
+ );
176
+ }
177
+
178
+ const changeId = normalizeChangeId(trimmedId);
179
+ const changeTitle = options.title?.trim() || readChangeSummary(schubDir, changeId).changeTitle;
180
+ const outputPath = resolveReviewOutput(schubDir, changeId, options.output);
181
+
182
+ if (existsSync(outputPath) && !options.overwrite) {
183
+ throw new Error(`Refusing to overwrite existing file: ${outputPath}`);
184
+ }
185
+
186
+ mkdirSync(dirname(outputPath), { recursive: true });
187
+ const templatePath = resolveTemplatePath(
188
+ schubDir,
189
+ join("review-proposal", "review-me-template.md"),
190
+ BUNDLED_REVIEW_TEMPLATE_PATH,
191
+ );
192
+ const template = readFileSync(templatePath, "utf8");
193
+ const rendered = renderChangeTemplate(template, changeTitle || changeId, changeId);
194
+ writeFileSync(outputPath, rendered, "utf8");
195
+
196
+ process.stdout.write(`[OK] Wrote REVIEW_ME: ${outputPath}\n`);
197
+ };
198
+
199
+ export const runReviewComplete = (args: string[], startDir: string) => {
200
+ const options: ReviewCompleteOptions = parseReviewCompleteOptions(args);
201
+ const schubDir = resolveChangeRoot(startDir);
202
+ const summary = readChangeSummary(schubDir, options.changeId);
203
+ const reviewPath = join(summary.changeDir, "REVIEW_ME.md");
204
+
205
+ if (!existsSync(reviewPath)) {
206
+ throw new Error(`Review file not found: ${reviewPath}`);
207
+ }
208
+
209
+ const reviewContent = readFileSync(reviewPath, "utf8");
210
+ const templatePath = resolveTemplatePath(
211
+ schubDir,
212
+ join("review-proposal", "q&a-template.md"),
213
+ BUNDLED_QNA_TEMPLATE_PATH,
214
+ );
215
+ const template = readFileSync(templatePath, "utf8");
216
+ const rendered = renderChangeTemplate(template, summary.changeTitle, summary.changeId).replace(
217
+ "{{REVIEW_CONTENT}}",
218
+ reviewContent,
219
+ );
220
+ const outputPath = join(summary.changeDir, "Q&A.md");
221
+
222
+ if (existsSync(outputPath) && !options.overwrite) {
223
+ throw new Error(`Refusing to overwrite existing file: ${outputPath}`);
224
+ }
225
+
226
+ writeFileSync(outputPath, rendered, "utf8");
227
+ unlinkSync(reviewPath);
228
+
229
+ process.stdout.write(`[OK] Wrote Q&A: ${outputPath}\n`);
230
+ process.stdout.write(`[NEXT] Ask the LLM to migrate the TODO block in ${outputPath} into the Q&A sections.\n`);
231
+ };
@@ -0,0 +1,172 @@
1
+ import { expect, test } from "bun:test";
2
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { spawnSync } from "bun";
7
+
8
+ const testDir = dirname(fileURLToPath(import.meta.url));
9
+ const cliDir = resolve(testDir, "..", "..");
10
+ const taskTemplatePath = join(cliDir, "templates", "create-tasks", "task-template.md");
11
+ const decoder = new TextDecoder();
12
+
13
+ const runTasksCreate = (schubCwd: string, args: string[] = []) => {
14
+ const result = spawnSync({
15
+ cmd: ["bun", "run", "schub", "tasks", "create", ...args],
16
+ cwd: cliDir,
17
+ env: { ...process.env, FORCE_COLOR: "0", SCHUB_CWD: schubCwd },
18
+ });
19
+
20
+ return {
21
+ result,
22
+ stdout: decoder.decode(result.stdout ?? new Uint8Array()),
23
+ stderr: decoder.decode(result.stderr ?? new Uint8Array()),
24
+ };
25
+ };
26
+
27
+ const createRepo = () => {
28
+ const base = mkdtempSync(join(tmpdir(), "schub-tasks-create-"));
29
+ const repoRoot = join(base, "repo");
30
+ const cwd = join(repoRoot, "nested", "dir");
31
+ mkdirSync(cwd, { recursive: true });
32
+ return { repoRoot, cwd };
33
+ };
34
+
35
+ const seedProposal = (schubRoot: string, changeId: string, status = "Accepted") => {
36
+ const changeDir = join(schubRoot, "changes", changeId);
37
+ mkdirSync(changeDir, { recursive: true });
38
+ writeFileSync(join(changeDir, "proposal.md"), `# Proposal - Seed\n**Status**: ${status}\n`, "utf8");
39
+ };
40
+
41
+ const writeExistingTask = (schubRoot: string, status: string, id: string, slug: string) => {
42
+ const tasksDir = join(schubRoot, "tasks", status);
43
+ mkdirSync(tasksDir, { recursive: true });
44
+ const taskPath = join(tasksDir, `${id}_${slug}.md`);
45
+ writeFileSync(taskPath, `# Task: ${id} ${slug}\n`, "utf8");
46
+ return taskPath;
47
+ };
48
+
49
+ test("tasks create scaffolds multiple task files", () => {
50
+ const { repoRoot, cwd } = createRepo();
51
+ const schubRoot = join(repoRoot, ".schub");
52
+ seedProposal(schubRoot, "C001_update-cli");
53
+ writeExistingTask(schubRoot, "backlog", "T003", "existing-task");
54
+
55
+ const { result, stdout } = runTasksCreate(cwd, [
56
+ "--change-id",
57
+ "C001_update-cli",
58
+ "--status",
59
+ "ready",
60
+ "--title",
61
+ "First Task",
62
+ "--title",
63
+ "Second: Task!",
64
+ ]);
65
+
66
+ expect(result.exitCode).toBe(0);
67
+
68
+ const firstPath = join(schubRoot, "tasks", "ready", "T004_first-task.md");
69
+ const secondPath = join(schubRoot, "tasks", "ready", "T005_second-task.md");
70
+
71
+ expect(existsSync(firstPath)).toBe(true);
72
+ expect(existsSync(secondPath)).toBe(true);
73
+
74
+ const firstContent = readFileSync(firstPath, "utf8");
75
+ const template = readFileSync(taskTemplatePath, "utf8");
76
+ const expectedFirst = template
77
+ .replace("{{TASK_ID}}", "T004")
78
+ .replace("{{TASK_TITLE}}", "First Task")
79
+ .replace("{{CHANGE_ID}}", "C001_update-cli");
80
+ const expectedSecond = template
81
+ .replace("{{TASK_ID}}", "T005")
82
+ .replace("{{TASK_TITLE}}", "Second: Task!")
83
+ .replace("{{CHANGE_ID}}", "C001_update-cli");
84
+
85
+ expect(firstContent).toBe(expectedFirst);
86
+ expect(readFileSync(secondPath, "utf8")).toBe(expectedSecond);
87
+
88
+ expect(stdout).toContain(`[OK] Wrote task: ${firstPath}`);
89
+ expect(stdout).toContain(`[OK] Wrote task: ${secondPath}`);
90
+ });
91
+
92
+ test("tasks create prefers local template overrides", () => {
93
+ const { repoRoot, cwd } = createRepo();
94
+ const schubRoot = join(repoRoot, ".schub");
95
+ seedProposal(schubRoot, "C001_update-cli");
96
+
97
+ const localTemplateDir = join(schubRoot, "templates", "create-tasks");
98
+ mkdirSync(localTemplateDir, { recursive: true });
99
+ const localTemplate = "# Local {{TASK_ID}} {{TASK_TITLE}}\nChange: {{CHANGE_ID}}\n";
100
+ writeFileSync(join(localTemplateDir, "task-template.md"), localTemplate, "utf8");
101
+
102
+ const { result } = runTasksCreate(cwd, [
103
+ "--change-id",
104
+ "C001_update-cli",
105
+ "--status",
106
+ "ready",
107
+ "--title",
108
+ "Local Task",
109
+ ]);
110
+
111
+ expect(result.exitCode).toBe(0);
112
+
113
+ const taskPath = join(schubRoot, "tasks", "ready", "T001_local-task.md");
114
+ const content = readFileSync(taskPath, "utf8");
115
+ const expected = localTemplate
116
+ .replace("{{TASK_ID}}", "T001")
117
+ .replace("{{TASK_TITLE}}", "Local Task")
118
+ .replace("{{CHANGE_ID}}", "C001_update-cli");
119
+
120
+ expect(content).toBe(expected);
121
+ });
122
+
123
+ test("tasks create rejects invalid status", () => {
124
+ const { repoRoot, cwd } = createRepo();
125
+ const schubRoot = join(repoRoot, ".schub");
126
+ seedProposal(schubRoot, "C001_update-cli");
127
+
128
+ const { result, stderr } = runTasksCreate(cwd, ["--change-id", "C001_update-cli", "--status", "bad status"]);
129
+
130
+ expect(result.exitCode).not.toBe(0);
131
+ expect(stderr).toContain("Invalid status");
132
+ });
133
+
134
+ test("tasks create rejects missing proposal", () => {
135
+ const { repoRoot, cwd } = createRepo();
136
+ const schubRoot = join(repoRoot, ".schub");
137
+ mkdirSync(join(schubRoot, "changes"), { recursive: true });
138
+
139
+ const { result, stderr } = runTasksCreate(cwd, ["--change-id", "C001_missing", "--title", "New Task"]);
140
+
141
+ expect(result.exitCode).not.toBe(0);
142
+ expect(stderr).toContain("Required change files missing");
143
+ });
144
+
145
+ test("tasks create rejects non-accepted proposals", () => {
146
+ const { repoRoot, cwd } = createRepo();
147
+ const schubRoot = join(repoRoot, ".schub");
148
+ seedProposal(schubRoot, "C001_update-cli", "Draft");
149
+
150
+ const { result, stderr } = runTasksCreate(cwd, ["--change-id", "C001_update-cli", "--title", "New Task"]);
151
+
152
+ expect(result.exitCode).not.toBe(0);
153
+ expect(stderr).toContain("Mark the proposal as Accepted");
154
+ });
155
+
156
+ test("tasks create rejects schub root flags", () => {
157
+ const { repoRoot, cwd } = createRepo();
158
+ const schubRoot = join(repoRoot, ".schub");
159
+ seedProposal(schubRoot, "C001_update-cli");
160
+
161
+ const { result, stderr } = runTasksCreate(cwd, [
162
+ "--change-id",
163
+ "C001_update-cli",
164
+ "--title",
165
+ "New Task",
166
+ "--schub-root",
167
+ "/tmp",
168
+ ]);
169
+
170
+ expect(result.exitCode).not.toBe(0);
171
+ expect(stderr).toContain("Unsupported option: --schub-root.");
172
+ });
@@ -0,0 +1,177 @@
1
+ import { expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { spawnSync } from "bun";
7
+
8
+ const testDir = dirname(fileURLToPath(import.meta.url));
9
+ const cliDir = resolve(testDir, "..", "..");
10
+ const decoder = new TextDecoder();
11
+
12
+ const runCli = (schubCwd: string, args: string[] = []) => {
13
+ const result = spawnSync({
14
+ cmd: ["bun", "run", "schub", "tasks", "list", ...args],
15
+ cwd: cliDir,
16
+ env: { ...process.env, FORCE_COLOR: "0", SCHUB_CWD: schubCwd },
17
+ });
18
+
19
+ return {
20
+ result,
21
+ stdout: decoder.decode(result.stdout ?? new Uint8Array()),
22
+ stderr: decoder.decode(result.stderr ?? new Uint8Array()),
23
+ };
24
+ };
25
+
26
+ const createTaskRepo = () => {
27
+ const base = mkdtempSync(join(tmpdir(), "schub-tasks-"));
28
+ const repoRoot = join(base, "repo");
29
+ const cwd = join(repoRoot, "nested", "dir");
30
+ mkdirSync(cwd, { recursive: true });
31
+
32
+ const tasksRoot = join(repoRoot, ".schub", "tasks");
33
+ const statuses = ["backlog", "ready", "wip", "blocked", "done", "archived"];
34
+
35
+ for (const status of statuses) {
36
+ mkdirSync(join(tasksRoot, status), { recursive: true });
37
+ }
38
+
39
+ return { cwd, tasksRoot };
40
+ };
41
+
42
+ const writeTask = (tasksRoot: string, status: string, id: string, slug: string, content?: string) => {
43
+ const filePath = join(tasksRoot, status, `${id}_${slug}.md`);
44
+ const taskContent = content ?? `# ${id} ${slug}\n`;
45
+ writeFileSync(filePath, taskContent);
46
+ return filePath;
47
+ };
48
+
49
+ test("tasks list shows tasks sorted by id", () => {
50
+ const { cwd, tasksRoot } = createTaskRepo();
51
+ writeTask(tasksRoot, "ready", "T010", "later-task");
52
+ writeTask(tasksRoot, "backlog", "T002", "middle-task");
53
+ writeTask(tasksRoot, "archived", "T003", "archived-task");
54
+ writeTask(tasksRoot, "wip", "T001", "first-task");
55
+
56
+ const { result, stdout } = runCli(cwd);
57
+
58
+ expect(result.exitCode).toBe(0);
59
+ const lines = stdout.trim().split("\n").filter(Boolean);
60
+ expect(lines).toHaveLength(4);
61
+ expect(lines[0]).toContain("T001");
62
+ expect(lines[0]).toContain("first task");
63
+ expect(lines[0]).toContain("(wip)");
64
+ expect(lines[1]).toContain("T002");
65
+ expect(lines[2]).toContain("T003");
66
+ expect(lines[3]).toContain("T010");
67
+ });
68
+
69
+ test("tasks list filters by status", () => {
70
+ const { cwd, tasksRoot } = createTaskRepo();
71
+ writeTask(tasksRoot, "ready", "T010", "later-task");
72
+ writeTask(tasksRoot, "backlog", "T002", "middle-task");
73
+ writeTask(tasksRoot, "wip", "T001", "first-task");
74
+
75
+ const { result, stdout } = runCli(cwd, ["--status", "ready,wip"]);
76
+
77
+ expect(result.exitCode).toBe(0);
78
+ const lines = stdout.trim().split("\n").filter(Boolean);
79
+ expect(lines).toHaveLength(2);
80
+ expect(lines.join("\n")).toContain("T010");
81
+ expect(lines.join("\n")).toContain("T001");
82
+ expect(lines.join("\n")).not.toContain("T002");
83
+ });
84
+
85
+ test("tasks list supports json output", () => {
86
+ const { cwd, tasksRoot } = createTaskRepo();
87
+ writeTask(tasksRoot, "ready", "T010", "later-task");
88
+ writeTask(tasksRoot, "wip", "T001", "first-task");
89
+
90
+ const { result, stdout } = runCli(cwd, ["--json"]);
91
+
92
+ expect(result.exitCode).toBe(0);
93
+ const tasks = JSON.parse(stdout) as Array<{
94
+ id: string;
95
+ title: string;
96
+ status: string;
97
+ path: string;
98
+ }>;
99
+ expect(tasks[0]).toMatchObject({
100
+ id: "T001",
101
+ title: "first task",
102
+ status: "wip",
103
+ });
104
+ expect(tasks[0].path).toContain(".schub/tasks/wip/T001_first-task.md");
105
+ });
106
+
107
+ test("tasks list shows checklist counts in text output", () => {
108
+ const { cwd, tasksRoot } = createTaskRepo();
109
+ writeTask(
110
+ tasksRoot,
111
+ "wip",
112
+ "T001",
113
+ "first-task",
114
+ [
115
+ "# Task: T001 First task",
116
+ "",
117
+ "## Steps",
118
+ "- [ ] Draft outline",
119
+ "- [x] Review outline",
120
+ "",
121
+ "## Acceptance",
122
+ "- [ ] Not counted",
123
+ "",
124
+ ].join("\n"),
125
+ );
126
+ writeTask(tasksRoot, "backlog", "T002", "second-task");
127
+
128
+ const { result, stdout } = runCli(cwd);
129
+
130
+ expect(result.exitCode).toBe(0);
131
+ const lines = stdout.trim().split("\n").filter(Boolean);
132
+ expect(lines).toHaveLength(2);
133
+ expect(lines[0]).toContain("T001");
134
+ expect(lines[0]).toContain("(wip)");
135
+ expect(lines[0]).toContain("(1/2)");
136
+ expect(lines[1]).toContain("T002");
137
+ expect(lines[1]).not.toMatch(/\(\d+\/\d+\)/);
138
+ });
139
+
140
+ test("tasks list json includes checklist counts", () => {
141
+ const { cwd, tasksRoot } = createTaskRepo();
142
+ writeTask(
143
+ tasksRoot,
144
+ "ready",
145
+ "T001",
146
+ "first-task",
147
+ ["# Task: T001 First task", "", "## Steps", "- [ ] Draft outline", "- [x] Review outline", ""].join("\n"),
148
+ );
149
+ writeTask(tasksRoot, "ready", "T002", "second-task");
150
+
151
+ const { result, stdout } = runCli(cwd, ["--json"]);
152
+
153
+ expect(result.exitCode).toBe(0);
154
+ const tasks = JSON.parse(stdout) as Array<{
155
+ id: string;
156
+ title: string;
157
+ status: string;
158
+ path: string;
159
+ checklistRemaining?: number;
160
+ checklistTotal?: number;
161
+ }>;
162
+ expect(tasks[0]).toMatchObject({
163
+ id: "T001",
164
+ checklistRemaining: 1,
165
+ checklistTotal: 2,
166
+ });
167
+ expect("checklistRemaining" in tasks[1]).toBe(false);
168
+ expect("checklistTotal" in tasks[1]).toBe(false);
169
+ });
170
+
171
+ test("tasks list errors without schub root", () => {
172
+ const base = mkdtempSync(join(tmpdir(), "schub-no-root-"));
173
+ const { result, stderr } = runCli(base);
174
+
175
+ expect(result.exitCode).not.toBe(0);
176
+ expect(stderr).toContain(".schub");
177
+ });