schub 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +27 -0
  2. package/dist/index.js +12830 -3057
  3. package/package.json +5 -2
  4. package/skills/create-proposal/SKILL.md +5 -1
  5. package/skills/create-tasks/SKILL.md +5 -4
  6. package/skills/implement-task/SKILL.md +6 -1
  7. package/skills/review-proposal/SKILL.md +3 -2
  8. package/skills/update-roadmap/SKILL.md +23 -0
  9. package/src/changes.test.ts +166 -0
  10. package/src/changes.ts +159 -54
  11. package/src/commands/adr.test.ts +6 -5
  12. package/src/commands/changes.test.ts +136 -14
  13. package/src/commands/changes.ts +102 -1
  14. package/src/commands/cookbook.test.ts +6 -5
  15. package/src/commands/init.test.ts +69 -2
  16. package/src/commands/init.ts +48 -5
  17. package/src/commands/review.test.ts +7 -6
  18. package/src/commands/review.ts +1 -1
  19. package/src/commands/roadmap.test.ts +84 -0
  20. package/src/commands/roadmap.ts +84 -0
  21. package/src/commands/tasks-create.test.ts +22 -22
  22. package/src/commands/tasks-implement.test.ts +253 -0
  23. package/src/commands/tasks-implement.ts +121 -0
  24. package/src/commands/tasks-list.test.ts +27 -27
  25. package/src/commands/tasks-update.test.ts +92 -0
  26. package/src/commands/tasks.ts +98 -1
  27. package/src/features/roadmap/index.ts +230 -0
  28. package/src/features/roadmap/roadmap.test.ts +77 -0
  29. package/src/features/tasks/constants.ts +1 -0
  30. package/src/features/tasks/create.ts +10 -8
  31. package/src/features/tasks/filesystem.test.ts +285 -18
  32. package/src/features/tasks/filesystem.ts +152 -39
  33. package/src/features/tasks/graph.ts +18 -3
  34. package/src/features/tasks/index.ts +10 -1
  35. package/src/features/tasks/worktree.ts +48 -0
  36. package/src/frontmatter.ts +115 -0
  37. package/src/index.test.ts +42 -6
  38. package/src/index.ts +226 -109
  39. package/src/opencode.test.ts +53 -0
  40. package/src/opencode.ts +74 -0
  41. package/src/tasks.ts +2 -0
  42. package/src/tui/App.test.tsx +418 -0
  43. package/src/tui/App.tsx +343 -0
  44. package/src/tui/components/PlanView.test.tsx +101 -0
  45. package/src/tui/components/PlanView.tsx +89 -0
  46. package/src/tui/components/PreviewPage.test.tsx +69 -0
  47. package/src/tui/components/PreviewPage.tsx +87 -0
  48. package/src/tui/components/ProposalDetailView.test.tsx +169 -0
  49. package/src/tui/components/ProposalDetailView.tsx +166 -0
  50. package/src/tui/components/RoadmapView.test.tsx +85 -0
  51. package/src/tui/components/RoadmapView.tsx +369 -0
  52. package/src/tui/components/StatusView.test.tsx +1351 -0
  53. package/src/tui/components/StatusView.tsx +519 -0
  54. package/src/tui/components/markdown-renderer.test.ts +46 -0
  55. package/src/tui/components/markdown-renderer.ts +89 -0
  56. package/src/tui/components/status-view-data.ts +322 -0
  57. package/src/tui/components/status-view-render.tsx +329 -0
  58. package/src/tui/index.ts +16 -0
  59. package/templates/create-proposal/adr-template.md +6 -4
  60. package/templates/create-proposal/cookbook-template.md +5 -3
  61. package/templates/create-proposal/proposal-template.md +8 -6
  62. package/templates/create-roadmap/roadmap.md +5 -0
  63. package/templates/create-tasks/task-template.md +9 -4
  64. package/templates/review-proposal/q&a-template.md +8 -3
  65. package/templates/review-proposal/review-me-template.md +6 -4
  66. package/templates/setup-project/project-overview-template.md +5 -0
  67. package/templates/setup-project/project-setup-template.md +5 -0
  68. package/templates/setup-project/project-wow-template.md +5 -0
  69. package/src/App.test.tsx +0 -93
  70. package/src/App.tsx +0 -155
  71. package/src/components/PlanView.test.tsx +0 -113
  72. package/src/components/PlanView.tsx +0 -160
  73. package/src/components/StatusView.test.tsx +0 -380
  74. package/src/components/StatusView.tsx +0 -367
  75. package/src/ide.ts +0 -7
  76. package/templates/templates-parity.test.ts +0 -45
  77. /package/src/{clipboard.ts → tui/clipboard.ts} +0 -0
  78. /package/src/{components → tui/components}/statusColor.ts +0 -0
  79. /package/src/{terminal.test.ts → tui/terminal.test.ts} +0 -0
  80. /package/src/{terminal.ts → tui/terminal.ts} +0 -0
@@ -1,8 +1,15 @@
1
1
  import { expect, test } from "bun:test";
2
- import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
2
+ import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { listTasks, loadTaskDependencies } from "./filesystem";
5
+ import type { TaskStatus } from "./constants";
6
+ import {
7
+ archiveTasksForChange,
8
+ listTasks,
9
+ listTasksForChange,
10
+ loadTaskDependencies,
11
+ updateTaskStatuses,
12
+ } from "./filesystem";
6
13
 
7
14
  const setupChecklistRepo = () => {
8
15
  const base = mkdtempSync(join(tmpdir(), "schub-checklist-"));
@@ -12,24 +19,131 @@ const setupChecklistRepo = () => {
12
19
 
13
20
  const tasks = [
14
21
  {
15
- id: "T001",
22
+ id: "T0001",
16
23
  slug: "unchecked-only",
17
- body: `# Task: T001 Unchecked Only\n\n## Steps\n- [ ] First item\n- [ ] Second item\n`,
24
+ body: `# Task: T0001 Unchecked Only\n\n## Steps\n- [ ] First item\n- [ ] Second item\n`,
18
25
  },
19
26
  {
20
- id: "T002",
27
+ id: "T0002",
21
28
  slug: "checked-only",
22
- body: `# Task: T002 Checked Only\n\n## Steps\n- [x] Finished item\n`,
29
+ body: `# Task: T0002 Checked Only\n\n## Steps\n- [x] Finished item\n`,
23
30
  },
24
31
  {
25
- id: "T003",
32
+ id: "T0003",
26
33
  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`,
34
+ body: `# Task: T0003 Mixed Checklist\n\n## Steps\n- [ ] Remaining item\n- [x] Done item\n\n## Acceptance\n- [ ] Ignore this\n`,
28
35
  },
29
36
  {
30
- id: "T004",
37
+ id: "T0004",
31
38
  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`,
39
+ body: `# Task: T0004 Missing Checklist\n\n## Steps\n- Do the thing\n- Another item\n\n## Acceptance\n- [ ] Ignore this too\n`,
40
+ },
41
+ ];
42
+
43
+ for (const task of tasks) {
44
+ const filePath = join(tasksRoot, `${task.id}_${task.slug}.md`);
45
+ writeFileSync(filePath, task.body, "utf8");
46
+ }
47
+
48
+ return schubDir;
49
+ };
50
+
51
+ const setupArchiveRepo = () => {
52
+ const base = mkdtempSync(join(tmpdir(), "schub-archive-"));
53
+ const schubDir = join(base, ".schub");
54
+ const tasksRoot = join(schubDir, "tasks");
55
+ const statuses = ["backlog", "wip", "done"];
56
+
57
+ for (const status of statuses) {
58
+ mkdirSync(join(tasksRoot, status), { recursive: true });
59
+ }
60
+
61
+ const changeId = "C0001_archive-change";
62
+ writeFileSync(
63
+ join(tasksRoot, "backlog", "T0010_backlog-task.md"),
64
+ `---\nchange_id: ${changeId}\n---\n# Task: T0010 Backlog Task\n`,
65
+ "utf8",
66
+ );
67
+ writeFileSync(
68
+ join(tasksRoot, "wip", "T0011_wip-task.md"),
69
+ `---\nchange_id: ${changeId}\n---\n# Task: T0011 Wip Task\n`,
70
+ "utf8",
71
+ );
72
+ writeFileSync(
73
+ join(tasksRoot, "done", "T0012_other-task.md"),
74
+ "---\nchange_id: C0002_other-change\n---\n# Task: T0012 Other Task\n",
75
+ "utf8",
76
+ );
77
+
78
+ return { schubDir, tasksRoot, changeId };
79
+ };
80
+
81
+ const setupChangeFilterRepo = () => {
82
+ const base = mkdtempSync(join(tmpdir(), "schub-change-filter-"));
83
+ const schubDir = join(base, ".schub");
84
+ const tasksRoot = join(schubDir, "tasks");
85
+ const statuses = ["backlog", "ready", "wip", "blocked", "done", "archived"];
86
+
87
+ for (const status of statuses) {
88
+ mkdirSync(join(tasksRoot, status), { recursive: true });
89
+ }
90
+
91
+ const changeId = "C0200_filter-change";
92
+ const otherChangeId = "C0201_other-change";
93
+ const tasks = [
94
+ { id: "T0200", status: "backlog", slug: "backlog-task", changeId },
95
+ { id: "T0201", status: "wip", slug: "wip-task", changeId },
96
+ { id: "T0202", status: "done", slug: "done-task", changeId: otherChangeId },
97
+ { id: "T0203", status: "blocked", slug: "blocked-task", changeId },
98
+ { id: "T0204", status: "ready", slug: "ready-task" },
99
+ { id: "T0205", status: "archived", slug: "archived-task", changeId },
100
+ ];
101
+
102
+ for (const task of tasks) {
103
+ const frontmatter = task.changeId ? `---\nchange_id: ${task.changeId}\n---\n` : "";
104
+ const title = task.slug.replace(/-/g, " ");
105
+ const body = `${frontmatter}# Task: ${task.id} ${title}\n`;
106
+ const filePath = join(tasksRoot, task.status, `${task.id}_${task.slug}.md`);
107
+ writeFileSync(filePath, body, "utf8");
108
+ }
109
+
110
+ return { schubDir, changeId };
111
+ };
112
+
113
+ const setupBacklogUpdateRepo = () => {
114
+ const base = mkdtempSync(join(tmpdir(), "schub-backlog-update-"));
115
+ const schubDir = join(base, ".schub");
116
+ const tasksRoot = join(schubDir, "tasks");
117
+ const backlogRoot = join(tasksRoot, "backlog");
118
+ mkdirSync(backlogRoot, { recursive: true });
119
+
120
+ writeFileSync(join(backlogRoot, "T0100_ready-task.md"), "# Task: T0100 Ready Task\n", "utf8");
121
+ writeFileSync(join(backlogRoot, "T0101_archive-task.md"), "# Task: T0101 Archive Task\n", "utf8");
122
+
123
+ return { schubDir, tasksRoot };
124
+ };
125
+
126
+ const setupBlockedReasonRepo = () => {
127
+ const base = mkdtempSync(join(tmpdir(), "schub-blocked-reason-"));
128
+ const schubDir = join(base, ".schub");
129
+ const tasksRoot = join(schubDir, "tasks", "blocked");
130
+ mkdirSync(tasksRoot, { recursive: true });
131
+
132
+ const tasks = [
133
+ {
134
+ id: "T0020",
135
+ slug: "blocked-with-reason",
136
+ body: "---\nblocked_reason: Waiting on API\n---\n# Task: T0020 Blocked With Reason\n",
137
+ },
138
+ {
139
+ id: "T0021",
140
+ slug: "blocked-with-reason-two",
141
+ body: "---\nblocked_reason: Vendor response\n---\n# Task: T0021 Blocked Reason Two\n",
142
+ },
143
+ {
144
+ id: "T0022",
145
+ slug: "blocked-without-reason",
146
+ body: "# Task: T0022 Blocked Without Reason\n",
33
147
  },
34
148
  ];
35
149
 
@@ -49,10 +163,10 @@ test("listTasks includes Steps-only checklist counts", () => {
49
163
  const schubDir = setupChecklistRepo();
50
164
  const tasks = listTasks(schubDir);
51
165
 
52
- const unchecked = getTaskById(tasks, "T001");
53
- const checked = getTaskById(tasks, "T002");
54
- const mixed = getTaskById(tasks, "T003");
55
- const missing = getTaskById(tasks, "T004");
166
+ const unchecked = getTaskById(tasks, "T0001");
167
+ const checked = getTaskById(tasks, "T0002");
168
+ const mixed = getTaskById(tasks, "T0003");
169
+ const missing = getTaskById(tasks, "T0004");
56
170
 
57
171
  expect(unchecked).toMatchObject({ checklistTotal: 2, checklistRemaining: 2 });
58
172
  expect(checked).toMatchObject({ checklistTotal: 1, checklistRemaining: 0 });
@@ -65,10 +179,10 @@ test("loadTaskDependencies includes Steps-only checklist counts", () => {
65
179
  const schubDir = setupChecklistRepo();
66
180
  const tasks = loadTaskDependencies(schubDir);
67
181
 
68
- const unchecked = getTaskById(tasks, "T001");
69
- const checked = getTaskById(tasks, "T002");
70
- const mixed = getTaskById(tasks, "T003");
71
- const missing = getTaskById(tasks, "T004");
182
+ const unchecked = getTaskById(tasks, "T0001");
183
+ const checked = getTaskById(tasks, "T0002");
184
+ const mixed = getTaskById(tasks, "T0003");
185
+ const missing = getTaskById(tasks, "T0004");
72
186
 
73
187
  expect(unchecked).toMatchObject({ checklistTotal: 2, checklistRemaining: 2 });
74
188
  expect(checked).toMatchObject({ checklistTotal: 1, checklistRemaining: 0 });
@@ -76,3 +190,156 @@ test("loadTaskDependencies includes Steps-only checklist counts", () => {
76
190
  expect(missing?.checklistTotal).toBeUndefined();
77
191
  expect(missing?.checklistRemaining).toBeUndefined();
78
192
  });
193
+
194
+ test("listTasks includes blocked reasons when present", () => {
195
+ const schubDir = setupBlockedReasonRepo();
196
+ const tasks = listTasks(schubDir);
197
+
198
+ const blocked = getTaskById(tasks, "T0020");
199
+ const uppercase = getTaskById(tasks, "T0021");
200
+ const missing = getTaskById(tasks, "T0022");
201
+
202
+ expect(blocked?.blockedReason).toBe("Waiting on API");
203
+ expect(uppercase?.blockedReason).toBe("Vendor response");
204
+ expect(missing?.blockedReason).toBeUndefined();
205
+ });
206
+
207
+ test("listTasks accepts variable-length task ids", () => {
208
+ const base = mkdtempSync(join(tmpdir(), "schub-variable-tasks-"));
209
+ const schubDir = join(base, ".schub");
210
+ const tasksRoot = join(schubDir, "tasks", "backlog");
211
+ mkdirSync(tasksRoot, { recursive: true });
212
+
213
+ writeFileSync(join(tasksRoot, "T1_short-task.md"), "# Task: T1 Short Task\n", "utf8");
214
+ writeFileSync(join(tasksRoot, "T12345_long-task.md"), "# Task: T12345 Long Task\n", "utf8");
215
+
216
+ const tasks = listTasks(schubDir, ["backlog"]);
217
+
218
+ expect(tasks.map((task) => task.id)).toEqual(["T1", "T12345"]);
219
+ });
220
+
221
+ test("loadTaskDependencies includes blocked reasons when present", () => {
222
+ const schubDir = setupBlockedReasonRepo();
223
+ const tasks = loadTaskDependencies(schubDir);
224
+
225
+ const blocked = getTaskById(tasks, "T0020");
226
+ const uppercase = getTaskById(tasks, "T0021");
227
+ const missing = getTaskById(tasks, "T0022");
228
+
229
+ expect(blocked?.blockedReason).toBe("Waiting on API");
230
+ expect(uppercase?.blockedReason).toBe("Vendor response");
231
+ expect(missing?.blockedReason).toBeUndefined();
232
+ });
233
+
234
+ test("loadTaskDependencies captures variable-length dependencies", () => {
235
+ const base = mkdtempSync(join(tmpdir(), "schub-variable-deps-"));
236
+ const schubDir = join(base, ".schub");
237
+ const tasksRoot = join(schubDir, "tasks", "backlog");
238
+ mkdirSync(tasksRoot, { recursive: true });
239
+
240
+ writeFileSync(
241
+ join(tasksRoot, "T1_primary-task.md"),
242
+ "---\ndepends_on:\n - T7\n - T12345\n---\n# Task: T1 Primary Task\n",
243
+ "utf8",
244
+ );
245
+ writeFileSync(join(tasksRoot, "T7_support-task.md"), "# Task: T7 Support Task\n", "utf8");
246
+ writeFileSync(join(tasksRoot, "T12345_support-task.md"), "# Task: T12345 Support Task\n", "utf8");
247
+
248
+ const tasks = loadTaskDependencies(schubDir, ["backlog"]);
249
+ const primary = getTaskById(tasks, "T1");
250
+
251
+ expect(primary?.dependsOn).toEqual(["T7", "T12345"]);
252
+ });
253
+
254
+ test("listTasksForChange filters tasks across statuses", () => {
255
+ const { schubDir, changeId } = setupChangeFilterRepo();
256
+ const tasks = listTasksForChange(schubDir, changeId);
257
+
258
+ expect(tasks.map((task) => task.id)).toEqual(["T0200", "T0201", "T0203", "T0205"]);
259
+ expect(getTaskById(tasks, "T0202")).toBeUndefined();
260
+ expect(getTaskById(tasks, "T0204")).toBeUndefined();
261
+
262
+ const expectations = new Map<string, { status: TaskStatus; title: string }>([
263
+ ["T0200", { status: "backlog", title: "backlog task" }],
264
+ ["T0201", { status: "wip", title: "wip task" }],
265
+ ["T0203", { status: "blocked", title: "blocked task" }],
266
+ ["T0205", { status: "archived", title: "archived task" }],
267
+ ]);
268
+
269
+ for (const task of tasks) {
270
+ const expected = expectations.get(task.id);
271
+ expect(expected).toBeDefined();
272
+ expect(task.changeId).toBe(changeId);
273
+ expect(task.path).toContain(`.schub/tasks/${task.status}/`);
274
+ if (expected) {
275
+ expect(task.status).toBe(expected.status);
276
+ expect(task.title).toBe(expected.title);
277
+ }
278
+ }
279
+ });
280
+
281
+ test("archiveTasksForChange moves matching tasks to archived", () => {
282
+ const { schubDir, tasksRoot, changeId } = setupArchiveRepo();
283
+ const archived = archiveTasksForChange(schubDir, changeId);
284
+
285
+ const archiveRoot = join(tasksRoot, "archived");
286
+ const backlogTask = join(archiveRoot, "T0010_backlog-task.md");
287
+ const wipTask = join(archiveRoot, "T0011_wip-task.md");
288
+
289
+ expect(existsSync(archiveRoot)).toBe(true);
290
+ expect(existsSync(backlogTask)).toBe(true);
291
+ expect(existsSync(wipTask)).toBe(true);
292
+ expect(existsSync(join(tasksRoot, "backlog", "T0010_backlog-task.md"))).toBe(false);
293
+ expect(existsSync(join(tasksRoot, "wip", "T0011_wip-task.md"))).toBe(false);
294
+ expect(existsSync(join(tasksRoot, "done", "T0012_other-task.md"))).toBe(true);
295
+
296
+ expect(archived.map((task) => task.id)).toEqual(["T0010", "T0011"]);
297
+ expect(archived.every((task) => task.status === "archived")).toBe(true);
298
+ expect(archived[0]?.path).toContain(".schub/tasks/archived/");
299
+ });
300
+
301
+ test("updateTaskStatuses moves backlog task to ready", () => {
302
+ const { schubDir, tasksRoot } = setupBacklogUpdateRepo();
303
+ const updated = updateTaskStatuses(schubDir, ["T0100"], "ready");
304
+
305
+ const readyRoot = join(tasksRoot, "ready");
306
+ const readyTask = join(readyRoot, "T0100_ready-task.md");
307
+
308
+ expect(existsSync(readyRoot)).toBe(true);
309
+ expect(existsSync(readyTask)).toBe(true);
310
+ expect(existsSync(join(tasksRoot, "backlog", "T0100_ready-task.md"))).toBe(false);
311
+ expect(updated).toMatchObject([{ id: "T0100", status: "ready" }]);
312
+ expect(updated[0]?.path).toContain(".schub/tasks/ready/");
313
+ });
314
+
315
+ test("updateTaskStatuses moves backlog task to archived", () => {
316
+ const { schubDir, tasksRoot } = setupBacklogUpdateRepo();
317
+ const updated = updateTaskStatuses(schubDir, ["T0101"], "archived");
318
+
319
+ const archivedRoot = join(tasksRoot, "archived");
320
+ const archivedTask = join(archivedRoot, "T0101_archive-task.md");
321
+
322
+ expect(existsSync(archivedRoot)).toBe(true);
323
+ expect(existsSync(archivedTask)).toBe(true);
324
+ expect(existsSync(join(tasksRoot, "backlog", "T0101_archive-task.md"))).toBe(false);
325
+ expect(updated).toMatchObject([{ id: "T0101", status: "archived" }]);
326
+ expect(updated[0]?.path).toContain(".schub/tasks/archived/");
327
+ });
328
+
329
+ test("updateTaskStatuses moves ready task to backlog", () => {
330
+ const { schubDir, tasksRoot } = setupBacklogUpdateRepo();
331
+ const readyRoot = join(tasksRoot, "ready");
332
+ const backlogRoot = join(tasksRoot, "backlog");
333
+ const readyTask = join(readyRoot, "T0102_ready-task.md");
334
+ const backlogTask = join(backlogRoot, "T0102_ready-task.md");
335
+
336
+ mkdirSync(readyRoot, { recursive: true });
337
+ writeFileSync(readyTask, "# Task: T0102 Ready Task\n", "utf8");
338
+
339
+ const updated = updateTaskStatuses(schubDir, ["T0102"], "backlog");
340
+
341
+ expect(existsSync(backlogTask)).toBe(true);
342
+ expect(existsSync(readyTask)).toBe(false);
343
+ expect(updated).toMatchObject([{ id: "T0102", status: "backlog" }]);
344
+ expect(updated[0]?.path).toContain(".schub/tasks/backlog/");
345
+ });
@@ -1,5 +1,6 @@
1
- import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
- import { dirname, join, relative, resolve } from "node:path";
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync } from "node:fs";
2
+ import { basename, dirname, join, relative, resolve } from "node:path";
3
+ import { readFrontmatter } from "../../frontmatter";
3
4
  import { TASK_STATUSES, type TaskDependency, type TaskInfo, type TaskStatus } from "./constants";
4
5
  import { compareTaskIds, compareTasks } from "./sorting";
5
6
 
@@ -40,6 +41,9 @@ const parseTaskFilename = (fileName: string): { id: string; title: string } | nu
40
41
  }
41
42
 
42
43
  const id = baseName.slice(0, underscoreIndex);
44
+ if (!/^T\d+$/.test(id)) {
45
+ return null;
46
+ }
43
47
  const titleSlug = baseName.slice(underscoreIndex + 1);
44
48
  return { id, title: titleSlug.replace(/-/g, " ") };
45
49
  };
@@ -51,6 +55,7 @@ type ParsedTaskFile = {
51
55
  changeId?: string;
52
56
  checklistRemaining?: number;
53
57
  checklistTotal?: number;
58
+ blockedReason?: string;
54
59
  };
55
60
 
56
61
  const parseChecklistCounts = (content: string) => {
@@ -104,21 +109,21 @@ const parseTaskFile = (filePath: string, fallback: { id: string; title: string }
104
109
  return { ...fallback, dependsOn: [] };
105
110
  }
106
111
 
107
- const changeIdMatch = content.match(/^\*\*Change ID\*\*:\s*\[?`?([^\]`]+)`?]?/m);
108
- const changeId = changeIdMatch?.[1]?.trim();
112
+ const { data } = readFrontmatter(content);
113
+ const changeIdValue = data.change_id;
114
+ const changeId = typeof changeIdValue === "string" ? changeIdValue.trim() : undefined;
109
115
 
110
- const dependsMatch = content.match(/^\*\*Depends on\*\*:\s*(.+)$/m);
116
+ const dependsValue = data.depends_on;
111
117
  let dependsOn: string[] = [];
112
- if (dependsMatch) {
113
- const raw = dependsMatch[1].trim();
114
- if (!/^none$/i.test(raw)) {
115
- const matches = raw.match(/\bT\d+\b/g);
116
- if (matches) {
117
- dependsOn = Array.from(new Set(matches));
118
- }
119
- }
118
+ if (Array.isArray(dependsValue)) {
119
+ dependsOn = dependsValue.map((value) => value.trim()).filter(Boolean);
120
+ } else if (typeof dependsValue === "string" && dependsValue.trim()) {
121
+ dependsOn = [dependsValue.trim()];
120
122
  }
121
123
 
124
+ const blockedReasonValue = data.blocked_reason;
125
+ const blockedReason =
126
+ typeof blockedReasonValue === "string" && blockedReasonValue.trim() ? blockedReasonValue.trim() : undefined;
122
127
  const checklistCounts = parseChecklistCounts(content);
123
128
 
124
129
  return {
@@ -126,11 +131,19 @@ const parseTaskFile = (filePath: string, fallback: { id: string; title: string }
126
131
  title: fallback.title,
127
132
  dependsOn,
128
133
  changeId,
134
+ blockedReason: blockedReason || undefined,
129
135
  ...(checklistCounts ?? {}),
130
136
  };
131
137
  };
138
+ type TaskFileEntry = {
139
+ id: string;
140
+ title: string;
141
+ status: TaskStatus;
142
+ path: string;
143
+ parsedFile: ParsedTaskFile;
144
+ };
132
145
 
133
- export const listTasks = (schubDir: string, statuses: readonly TaskStatus[] = TASK_STATUSES): TaskInfo[] => {
146
+ const loadTaskFiles = (schubDir: string, statuses: readonly TaskStatus[] = TASK_STATUSES) => {
134
147
  const tasksRoot = join(schubDir, "tasks");
135
148
  if (!existsSync(tasksRoot) || !isDirectory(tasksRoot)) {
136
149
  return [];
@@ -138,7 +151,7 @@ export const listTasks = (schubDir: string, statuses: readonly TaskStatus[] = TA
138
151
 
139
152
  const allowed = new Set(statuses);
140
153
  const repoRoot = dirname(schubDir);
141
- const tasks: TaskInfo[] = [];
154
+ const tasks: TaskFileEntry[] = [];
142
155
 
143
156
  for (const status of TASK_STATUSES) {
144
157
  if (!allowed.has(status)) {
@@ -168,12 +181,24 @@ export const listTasks = (schubDir: string, statuses: readonly TaskStatus[] = TA
168
181
  title: parsed.title,
169
182
  status,
170
183
  path: relative(repoRoot, filePath),
171
- checklistRemaining: parsedFile.checklistRemaining,
172
- checklistTotal: parsedFile.checklistTotal,
184
+ parsedFile,
173
185
  });
174
186
  }
175
187
  }
176
188
 
189
+ return tasks;
190
+ };
191
+ export const listTasks = (schubDir: string, statuses: readonly TaskStatus[] = TASK_STATUSES): TaskInfo[] => {
192
+ const tasks = loadTaskFiles(schubDir, statuses).map((task) => ({
193
+ id: task.id,
194
+ title: task.title,
195
+ status: task.status,
196
+ path: task.path,
197
+ checklistRemaining: task.parsedFile.checklistRemaining,
198
+ checklistTotal: task.parsedFile.checklistTotal,
199
+ blockedReason: task.parsedFile.blockedReason,
200
+ }));
201
+
177
202
  return tasks.sort(compareTasks);
178
203
  };
179
204
 
@@ -181,20 +206,86 @@ export const loadTaskDependencies = (
181
206
  schubDir: string,
182
207
  statuses: readonly TaskStatus[] = TASK_STATUSES,
183
208
  ): TaskDependency[] => {
209
+ const tasks = loadTaskFiles(schubDir, statuses).map((task) => {
210
+ const dependsOn = task.parsedFile.dependsOn.sort(compareTaskIds);
211
+
212
+ return {
213
+ id: task.id,
214
+ title: task.title,
215
+ status: task.status,
216
+ path: task.path,
217
+ dependsOn,
218
+ changeId: task.parsedFile.changeId,
219
+ checklistRemaining: task.parsedFile.checklistRemaining,
220
+ checklistTotal: task.parsedFile.checklistTotal,
221
+ blockedReason: task.parsedFile.blockedReason,
222
+ };
223
+ });
224
+
225
+ return tasks.sort(compareTasks);
226
+ };
227
+
228
+ export const listTasksForChange = (
229
+ schubDir: string,
230
+ changeId: string,
231
+ statuses: readonly TaskStatus[] = TASK_STATUSES,
232
+ ) => {
233
+ const normalizedChangeId = changeId.trim();
234
+ const tasks = loadTaskFiles(schubDir, statuses)
235
+ .filter((task) => task.parsedFile.changeId === normalizedChangeId)
236
+ .map((task) => ({
237
+ id: task.id,
238
+ title: task.title,
239
+ status: task.status,
240
+ path: task.path,
241
+ changeId: task.parsedFile.changeId,
242
+ }));
243
+
244
+ return tasks.sort(compareTasks);
245
+ };
246
+
247
+ type TaskUpdateStatus = "backlog" | "ready" | "archived";
248
+
249
+ export const updateTaskStatuses = (schubDir: string, taskIds: string[], status: TaskUpdateStatus): TaskInfo[] => {
184
250
  const tasksRoot = join(schubDir, "tasks");
185
- if (!existsSync(tasksRoot) || !isDirectory(tasksRoot)) {
186
- return [];
187
- }
251
+ const targetRoot = join(tasksRoot, status);
252
+ mkdirSync(targetRoot, { recursive: true });
188
253
 
189
- const allowed = new Set(statuses);
190
254
  const repoRoot = dirname(schubDir);
191
- const tasks: TaskDependency[] = [];
255
+ const sourceTasks = listTasks(schubDir, ["backlog", "ready"]);
256
+ const tasksById = new Map(sourceTasks.map((task) => [task.id, task]));
257
+
258
+ return taskIds
259
+ .map((taskId) => tasksById.get(taskId))
260
+ .filter((task): task is TaskInfo => Boolean(task))
261
+ .map((task) => {
262
+ const currentPath = join(repoRoot, task.path);
263
+ const nextPath = join(targetRoot, basename(task.path));
264
+ renameSync(currentPath, nextPath);
265
+
266
+ return {
267
+ ...task,
268
+ status,
269
+ path: relative(repoRoot, nextPath),
270
+ };
271
+ });
272
+ };
192
273
 
193
- for (const status of TASK_STATUSES) {
194
- if (!allowed.has(status)) {
195
- continue;
196
- }
274
+ const ACTIVE_TASK_STATUSES = TASK_STATUSES.filter((status) => status !== "archived");
197
275
 
276
+ export const assignTaskToWip = (schubDir: string, taskId: string): TaskInfo => {
277
+ const normalizedId = taskId.trim().toUpperCase();
278
+ if (!normalizedId) {
279
+ throw new Error("Provide --id.");
280
+ }
281
+
282
+ const tasksRoot = join(schubDir, "tasks");
283
+ const targetRoot = join(tasksRoot, "wip");
284
+ mkdirSync(targetRoot, { recursive: true });
285
+
286
+ const repoRoot = dirname(schubDir);
287
+
288
+ for (const status of ACTIVE_TASK_STATUSES) {
198
289
  const statusDir = join(tasksRoot, status);
199
290
  if (!existsSync(statusDir) || !isDirectory(statusDir)) {
200
291
  continue;
@@ -207,26 +298,48 @@ export const loadTaskDependencies = (
207
298
  }
208
299
 
209
300
  const parsed = parseTaskFilename(entry.name);
210
- if (!parsed) {
301
+ if (!parsed || parsed.id !== normalizedId) {
211
302
  continue;
212
303
  }
213
304
 
214
- const filePath = join(statusDir, entry.name);
215
- const parsedFile = parseTaskFile(filePath, parsed);
216
- const dependsOn = parsedFile.dependsOn.sort(compareTaskIds);
305
+ const currentPath = join(statusDir, entry.name);
306
+ const nextPath = join(targetRoot, entry.name);
307
+ if (currentPath !== nextPath) {
308
+ renameSync(currentPath, nextPath);
309
+ }
217
310
 
218
- tasks.push({
311
+ return {
219
312
  id: parsed.id,
220
313
  title: parsed.title,
221
- status,
222
- path: relative(repoRoot, filePath),
223
- dependsOn,
224
- changeId: parsedFile.changeId,
225
- checklistRemaining: parsedFile.checklistRemaining,
226
- checklistTotal: parsedFile.checklistTotal,
227
- });
314
+ status: "wip",
315
+ path: relative(repoRoot, nextPath),
316
+ };
228
317
  }
229
318
  }
230
319
 
231
- return tasks.sort(compareTasks);
320
+ throw new Error(`Task ${normalizedId} not found.`);
321
+ };
322
+
323
+ export const archiveTasksForChange = (schubDir: string, changeId: string) => {
324
+ const normalizedChangeId = changeId.trim();
325
+ const tasksRoot = join(schubDir, "tasks");
326
+ const archiveRoot = join(tasksRoot, "archived");
327
+ mkdirSync(archiveRoot, { recursive: true });
328
+
329
+ const repoRoot = dirname(schubDir);
330
+ const tasks = loadTaskDependencies(schubDir, ACTIVE_TASK_STATUSES).filter(
331
+ (task) => task.changeId === normalizedChangeId,
332
+ );
333
+
334
+ return tasks.map((task) => {
335
+ const currentPath = join(repoRoot, task.path);
336
+ const archivePath = join(archiveRoot, basename(task.path));
337
+ renameSync(currentPath, archivePath);
338
+
339
+ return {
340
+ ...task,
341
+ status: "archived",
342
+ path: relative(repoRoot, archivePath),
343
+ };
344
+ });
232
345
  };
@@ -1,5 +1,20 @@
1
1
  import type { TaskDependency, TaskGraph, TaskGraphLine, TaskGraphNode } from "./constants";
2
- import { compareTaskIds, compareTasks } from "./sorting";
2
+ import { compareTaskIds } from "./sorting";
3
+
4
+ const compareText = (left: string, right: string) => left.localeCompare(right, undefined, { sensitivity: "base" });
5
+
6
+ const taskSortTitle = (task: TaskGraphNode) => {
7
+ const title = trimTaskTitle(task.title);
8
+ return title || task.id;
9
+ };
10
+
11
+ const compareTaskTitles = (left: TaskGraphNode, right: TaskGraphNode) => {
12
+ const titleCompare = compareText(taskSortTitle(left), taskSortTitle(right));
13
+ if (titleCompare !== 0) {
14
+ return titleCompare;
15
+ }
16
+ return compareText(left.id, right.id);
17
+ };
3
18
 
4
19
  export const buildTaskGraph = (tasks: TaskDependency[]): TaskGraph => {
5
20
  const tasksById = new Map(tasks.map((task) => [task.id, task]));
@@ -25,12 +40,12 @@ export const buildTaskGraph = (tasks: TaskDependency[]): TaskGraph => {
25
40
  }
26
41
 
27
42
  for (const [id, children] of childrenById) {
28
- childrenById.set(id, children.sort(compareTasks));
43
+ childrenById.set(id, children.sort(compareTaskTitles));
29
44
  }
30
45
 
31
46
  const roots = Array.from(nodes.values())
32
47
  .filter((node) => node.dependencyIds.length === 0)
33
- .sort(compareTasks);
48
+ .sort(compareTaskTitles);
34
49
 
35
50
  return { roots, childrenById };
36
51
  };
@@ -8,5 +8,14 @@ export {
8
8
  type TaskStatus,
9
9
  } from "./constants";
10
10
  export { createTask } from "./create";
11
- export { findSchubRoot, listTasks, loadTaskDependencies } from "./filesystem";
11
+ export {
12
+ archiveTasksForChange,
13
+ assignTaskToWip,
14
+ findSchubRoot,
15
+ listTasks,
16
+ listTasksForChange,
17
+ loadTaskDependencies,
18
+ updateTaskStatuses,
19
+ } from "./filesystem";
12
20
  export { buildTaskGraph, renderTaskGraphLines, trimTaskTitle } from "./graph";
21
+ export { createTaskWorktree } from "./worktree";