schub 0.1.2 → 0.1.3

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.
@@ -33,7 +33,7 @@ test("plan shows backlog tasks with satisfied dependencies as ready", () => {
33
33
  const base = mkdtempSync(join(tmpdir(), "schub-plan-view-"));
34
34
  const tasksDir = join(base, ".schub", "tasks", "backlog");
35
35
  mkdirSync(tasksDir, { recursive: true });
36
- writeFileSync(join(tasksDir, "T001_ready-task.md"), "# Task: T001 Ready Task\n", "utf8");
36
+ writeFileSync(join(tasksDir, "T0001_ready-task.md"), "# Task: T0001 Ready Task\n", "utf8");
37
37
 
38
38
  try {
39
39
  process.chdir(base);
@@ -41,7 +41,7 @@ test("plan shows backlog tasks with satisfied dependencies as ready", () => {
41
41
  const output = stripAnsi(lastFrame() ?? "");
42
42
  const readySection = extractSection(output, "Ready to Implement", "Dependency Plan");
43
43
 
44
- expect(readySection).toContain("T001");
44
+ expect(readySection).toContain("T0001");
45
45
  expect(readySection).not.toContain("No tasks ready for implementation.");
46
46
  } finally {
47
47
  process.chdir(originalCwd);
@@ -53,7 +53,7 @@ test("plan view omits per-item shortcuts for ready tasks", () => {
53
53
  const base = mkdtempSync(join(tmpdir(), "schub-plan-actions-"));
54
54
  const tasksDir = join(base, ".schub", "tasks", "backlog");
55
55
  mkdirSync(tasksDir, { recursive: true });
56
- writeFileSync(join(tasksDir, "T010_quick-action.md"), "# Task: T010 Quick Action\n", "utf8");
56
+ writeFileSync(join(tasksDir, "T0010_quick-action.md"), "# Task: T0010 Quick Action\n", "utf8");
57
57
 
58
58
  try {
59
59
  process.chdir(base);
@@ -61,7 +61,7 @@ test("plan view omits per-item shortcuts for ready tasks", () => {
61
61
  const output = stripAnsi(lastFrame() ?? "");
62
62
  const readySection = extractSection(output, "Ready to Implement", "Dependency Plan");
63
63
 
64
- expect(readySection).toContain("T010");
64
+ expect(readySection).toContain("T0010");
65
65
  expect(readySection).not.toContain("[o open file]");
66
66
  expect(readySection).not.toContain("[c copy]");
67
67
  } finally {
@@ -83,29 +83,29 @@ test("plan view refreshes ready list and dependency graph when tasks change", as
83
83
  try {
84
84
  process.chdir(base);
85
85
  writeFileSync(
86
- join(tasksDir, "T002_waiting-task.md"),
87
- "# Task: T002 Waiting Task\n\n**Depends on**: T003\n",
86
+ join(tasksDir, "T0002_waiting-task.md"),
87
+ "# Task: T0002 Waiting Task\n\n**Depends on**: T0003\n",
88
88
  "utf8",
89
89
  );
90
- writeFileSync(join(tasksDir, "T003_dependency.md"), "# Task: T003 Dependency\n", "utf8");
90
+ writeFileSync(join(tasksDir, "T0003_dependency.md"), "# Task: T0003 Dependency\n", "utf8");
91
91
  const rendered = render(<PlanView refreshIntervalMs={refreshIntervalMs} onCopyId={noopCopy} />);
92
92
  unmount = rendered.unmount;
93
93
  const initial = stripAnsi(rendered.lastFrame() ?? "");
94
94
  const initialReady = extractSection(initial, "Ready to Implement", "Dependency Plan");
95
- expect(initialReady).not.toContain("T002");
95
+ expect(initialReady).not.toContain("T0002");
96
96
  const initialGraph = extractSection(initial, "Dependency Plan");
97
- expect(initialGraph).toContain("T002");
98
- expect(initialGraph).toContain("T003");
97
+ expect(initialGraph).toContain("T0002");
98
+ expect(initialGraph).toContain("T0003");
99
99
 
100
- renameSync(join(tasksDir, "T003_dependency.md"), join(doneDir, "T003_dependency.md"));
100
+ renameSync(join(tasksDir, "T0003_dependency.md"), join(doneDir, "T0003_dependency.md"));
101
101
  await new Promise((resolve) => setTimeout(resolve, refreshIntervalMs * 2));
102
102
 
103
103
  const refreshed = stripAnsi(rendered.lastFrame() ?? "");
104
104
  const refreshedReady = extractSection(refreshed, "Ready to Implement", "Dependency Plan");
105
- expect(refreshedReady).toContain("T002");
105
+ expect(refreshedReady).toContain("T0002");
106
106
  const refreshedGraph = extractSection(refreshed, "Dependency Plan");
107
- expect(refreshedGraph).toContain("T002");
108
- expect(refreshedGraph).not.toContain("T003");
107
+ expect(refreshedGraph).toContain("T0002");
108
+ expect(refreshedGraph).not.toContain("T0003");
109
109
  } finally {
110
110
  unmount?.();
111
111
  process.chdir(originalCwd);
@@ -37,20 +37,20 @@ test("status view shows checklist indicator only for wip tasks", () => {
37
37
  mkdirSync(wipRoot, { recursive: true });
38
38
  mkdirSync(readyRoot, { recursive: true });
39
39
 
40
- writeTask(wipRoot, "T100", "wip-checklist", "# Task: T100 Wip Checklist\n\n## Steps\n- [ ] First\n- [x] Second\n");
41
- writeTask(wipRoot, "T101", "wip-complete", "# Task: T101 Wip Complete\n\n## Steps\n- [x] First\n- [x] Second\n");
42
- writeTask(wipRoot, "T102", "wip-empty", "# Task: T102 Wip Empty\n\n## Steps\n- Do the thing\n");
43
- writeTask(readyRoot, "T200", "ready-checklist", "# Task: T200 Ready Checklist\n\n## Steps\n- [ ] First\n");
40
+ writeTask(wipRoot, "T0100", "wip-checklist", "# Task: T0100 Wip Checklist\n\n## Steps\n- [ ] First\n- [x] Second\n");
41
+ writeTask(wipRoot, "T0101", "wip-complete", "# Task: T0101 Wip Complete\n\n## Steps\n- [x] First\n- [x] Second\n");
42
+ writeTask(wipRoot, "T0102", "wip-empty", "# Task: T0102 Wip Empty\n\n## Steps\n- Do the thing\n");
43
+ writeTask(readyRoot, "T0200", "ready-checklist", "# Task: T0200 Ready Checklist\n\n## Steps\n- [ ] First\n");
44
44
 
45
45
  try {
46
46
  process.chdir(base);
47
47
  const { lastFrame } = render(<StatusView onCopyId={noopCopy} />);
48
48
  const output = stripAnsi(lastFrame() ?? "");
49
49
 
50
- const wipChecklistLine = findLine(output, "T100");
51
- const wipCompleteLine = findLine(output, "T101");
52
- const wipEmptyLine = findLine(output, "T102");
53
- const readyLine = findLine(output, "T200");
50
+ const wipChecklistLine = findLine(output, "T0100");
51
+ const wipCompleteLine = findLine(output, "T0101");
52
+ const wipEmptyLine = findLine(output, "T0102");
53
+ const readyLine = findLine(output, "T0200");
54
54
 
55
55
  expect(wipChecklistLine).toContain("wip checklist");
56
56
  expect(wipChecklistLine).toContain("(1/2)");
@@ -74,11 +74,11 @@ test("status view omits per-item shortcuts for tasks and proposals", () => {
74
74
  mkdirSync(changesRoot, { recursive: true });
75
75
  mkdirSync(readyRoot, { recursive: true });
76
76
 
77
- const changeId = "C003_demo-change";
77
+ const changeId = "C0003_demo-change";
78
78
  const displayId = changeDisplayId(changeId);
79
79
 
80
80
  writeProposal(changesRoot, changeId, "Draft");
81
- writeTask(readyRoot, "T003", "ready-task", "# Task: T003 Ready Task\n");
81
+ writeTask(readyRoot, "T0003", "ready-task", "# Task: T0003 Ready Task\n");
82
82
 
83
83
  try {
84
84
  process.chdir(base);
@@ -87,8 +87,8 @@ test("status view omits per-item shortcuts for tasks and proposals", () => {
87
87
 
88
88
  expect(findLine(output, displayId)).not.toContain("[o open file]");
89
89
  expect(findLine(output, displayId)).not.toContain("[c copy]");
90
- expect(findLine(output, "T003")).not.toContain("[o open file]");
91
- expect(findLine(output, "T003")).not.toContain("[c copy]");
90
+ expect(findLine(output, "T0003")).not.toContain("[o open file]");
91
+ expect(findLine(output, "T0003")).not.toContain("[c copy]");
92
92
  } finally {
93
93
  process.chdir(originalCwd);
94
94
  }
@@ -102,7 +102,7 @@ test("status view shows proposal titles", () => {
102
102
 
103
103
  mkdirSync(changesRoot, { recursive: true });
104
104
 
105
- const changeId = "C004_pretty-proposals";
105
+ const changeId = "C0004_pretty-proposals";
106
106
  const changeTitle = "Pretty Print Change Proposals";
107
107
  const displayId = changeDisplayId(changeId);
108
108
 
@@ -122,6 +122,33 @@ test("status view shows proposal titles", () => {
122
122
  }
123
123
  });
124
124
 
125
+ test("status view formats default proposal titles from ids", () => {
126
+ const originalCwd = process.cwd();
127
+ const base = mkdtempSync(join(tmpdir(), "schub-status-proposal-id-title-"));
128
+ const schubDir = join(base, ".schub");
129
+ const changesRoot = join(schubDir, "changes");
130
+
131
+ mkdirSync(changesRoot, { recursive: true });
132
+
133
+ const changeId = "C0001_pending-review-quick-action";
134
+ const displayId = changeDisplayId(changeId);
135
+
136
+ writeProposal(changesRoot, changeId, "Pending Review");
137
+
138
+ try {
139
+ process.chdir(base);
140
+ const { lastFrame } = render(<StatusView onCopyId={noopCopy} />);
141
+ const output = stripAnsi(lastFrame() ?? "");
142
+ const line = findLine(output, displayId);
143
+
144
+ expect(line).toContain(displayId);
145
+ expect(line).toContain("Pending Review Quick Action");
146
+ expect(line).not.toContain("pending-review-quick-action");
147
+ } finally {
148
+ process.chdir(originalCwd);
149
+ }
150
+ });
151
+
125
152
  test("status view copies the selected id on c", async () => {
126
153
  const originalCwd = process.cwd();
127
154
  const base = mkdtempSync(join(tmpdir(), "schub-status-copy-"));
@@ -133,14 +160,39 @@ test("status view copies the selected id on c", async () => {
133
160
  };
134
161
 
135
162
  mkdirSync(readyRoot, { recursive: true });
136
- writeTask(readyRoot, "T010", "copy-task", "# Task: T010 Copy Task\n");
163
+ writeTask(readyRoot, "T0010", "copy-task", "# Task: T0010 Copy Task\n");
137
164
 
138
165
  try {
139
166
  process.chdir(base);
140
167
  const rendered = render(<StatusView onCopyId={recordCopy} />);
141
168
  rendered.stdin.write("c");
142
169
  await new Promise((resolve) => setTimeout(resolve, 0));
143
- expect(copied).toBe("T010");
170
+ expect(copied).toBe("T0010");
171
+ } finally {
172
+ process.chdir(originalCwd);
173
+ }
174
+ });
175
+
176
+ test("status view triggers review on r for pending review proposals", async () => {
177
+ const originalCwd = process.cwd();
178
+ const base = mkdtempSync(join(tmpdir(), "schub-status-review-"));
179
+ const schubDir = join(base, ".schub");
180
+ const changesRoot = join(schubDir, "changes");
181
+ const changeId = "C020_pending-review";
182
+ let reviewed = "";
183
+ const recordReview = (id: string) => {
184
+ reviewed = id;
185
+ };
186
+
187
+ mkdirSync(changesRoot, { recursive: true });
188
+ writeProposal(changesRoot, changeId, "Pending Review");
189
+
190
+ try {
191
+ process.chdir(base);
192
+ const rendered = render(<StatusView onCopyId={noopCopy} onReview={recordReview} />);
193
+ rendered.stdin.write("r");
194
+ await new Promise((resolve) => setTimeout(resolve, 0));
195
+ expect(reviewed).toBe(changeId);
144
196
  } finally {
145
197
  process.chdir(originalCwd);
146
198
  }
@@ -158,17 +210,17 @@ test("status view shows pending implementation counts and no tasks defined secti
158
210
  mkdirSync(doneRoot, { recursive: true });
159
211
  mkdirSync(changesRoot, { recursive: true });
160
212
 
161
- const changeWithTasks = "C001_with-tasks";
162
- const changeNoTasks = "C002_no-tasks";
213
+ const changeWithTasks = "C0001_with-tasks";
214
+ const changeNoTasks = "C0002_no-tasks";
163
215
  const displayWithTasks = changeDisplayId(changeWithTasks);
164
216
  const displayNoTasks = changeDisplayId(changeNoTasks);
165
217
 
166
218
  writeProposal(changesRoot, changeWithTasks, "Accepted");
167
219
  writeProposal(changesRoot, changeNoTasks, "Accepted");
168
220
 
169
- writeTask(backlogRoot, "T010", "first-task", `# Task: T010 First Task\n\n**Change ID**: ${changeWithTasks}\n`);
170
- writeTask(backlogRoot, "T011", "second-task", `# Task: T011 Second Task\n\n**Change ID**: ${changeWithTasks}\n`);
171
- writeTask(doneRoot, "T012", "done-task", `# Task: T012 Done Task\n\n**Change ID**: ${changeWithTasks}\n`);
221
+ writeTask(backlogRoot, "T0010", "first-task", `# Task: T0010 First Task\n\n**Change ID**: ${changeWithTasks}\n`);
222
+ writeTask(backlogRoot, "T0011", "second-task", `# Task: T0011 Second Task\n\n**Change ID**: ${changeWithTasks}\n`);
223
+ writeTask(doneRoot, "T0012", "done-task", `# Task: T0012 Done Task\n\n**Change ID**: ${changeWithTasks}\n`);
172
224
 
173
225
  try {
174
226
  process.chdir(base);
@@ -197,11 +249,11 @@ test("status view hides pending implementation when tasks complete", () => {
197
249
  mkdirSync(changesRoot, { recursive: true });
198
250
  mkdirSync(doneRoot, { recursive: true });
199
251
 
200
- const completedChange = "C003_all-done";
252
+ const completedChange = "C0003_all-done";
201
253
 
202
254
  writeProposal(changesRoot, completedChange, "Accepted");
203
- writeTask(doneRoot, "T020", "done-task", `# Task: T020 Done Task\n\n**Change ID**: ${completedChange}\n`);
204
- writeTask(doneRoot, "T021", "done-task-two", `# Task: T021 Done Task\n\n**Change ID**: ${completedChange}\n`);
255
+ writeTask(doneRoot, "T0020", "done-task", `# Task: T0020 Done Task\n\n**Change ID**: ${completedChange}\n`);
256
+ writeTask(doneRoot, "T0021", "done-task-two", `# Task: T0021 Done Task\n\n**Change ID**: ${completedChange}\n`);
205
257
 
206
258
  try {
207
259
  process.chdir(base);
@@ -226,12 +278,12 @@ test("status view auto-marks completed accepted changes as done", async () => {
226
278
  mkdirSync(changesRoot, { recursive: true });
227
279
  mkdirSync(doneRoot, { recursive: true });
228
280
 
229
- const changeId = "C011_auto-done";
281
+ const changeId = "C0011_auto-done";
230
282
  const proposalPath = join(changesRoot, changeId, "proposal.md");
231
283
 
232
284
  writeProposal(changesRoot, changeId, "Accepted");
233
- writeTask(doneRoot, "T100", "done-task", `# Task: T100 Done Task\n\n**Change ID**: ${changeId}\n`);
234
- writeTask(doneRoot, "T101", "done-task-two", `# Task: T101 Done Task\n\n**Change ID**: ${changeId}\n`);
285
+ writeTask(doneRoot, "T0100", "done-task", `# Task: T0100 Done Task\n\n**Change ID**: ${changeId}\n`);
286
+ writeTask(doneRoot, "T0101", "done-task-two", `# Task: T0101 Done Task\n\n**Change ID**: ${changeId}\n`);
235
287
 
236
288
  try {
237
289
  process.chdir(base);
@@ -258,7 +310,7 @@ test("status view does not auto-mark proposals without tasks", async () => {
258
310
 
259
311
  mkdirSync(changesRoot, { recursive: true });
260
312
 
261
- const changeId = "C012_no-tasks";
313
+ const changeId = "C0012_no-tasks";
262
314
  const proposalPath = join(changesRoot, changeId, "proposal.md");
263
315
 
264
316
  writeProposal(changesRoot, changeId, "Accepted");
@@ -295,11 +347,11 @@ test("status view refreshes when tasks change", async () => {
295
347
  const output = stripAnsi(rendered.lastFrame() ?? "");
296
348
  expect(output).toContain("No active changes or tasks found.");
297
349
 
298
- writeTask(readyRoot, "T001", "new-task", "# Task: T001 New Task\n");
350
+ writeTask(readyRoot, "T0001", "new-task", "# Task: T0001 New Task\n");
299
351
  await new Promise((resolve) => setTimeout(resolve, refreshIntervalMs * 2));
300
352
 
301
353
  const refreshed = stripAnsi(rendered.lastFrame() ?? "");
302
- expect(refreshed).toContain("T001");
354
+ expect(refreshed).toContain("T0001");
303
355
  } finally {
304
356
  unmount?.();
305
357
  process.chdir(originalCwd);
@@ -316,7 +368,7 @@ test("status view refreshes when proposals change", async () => {
316
368
 
317
369
  mkdirSync(changesRoot, { recursive: true });
318
370
 
319
- const changeId = "C010_refresh-proposal";
371
+ const changeId = "C0010_refresh-proposal";
320
372
  const displayId = changeDisplayId(changeId);
321
373
  writeProposal(changesRoot, changeId, "Draft");
322
374
 
@@ -352,8 +404,8 @@ test("status view clamps selection when tasks are removed", async () => {
352
404
  let unmount: (() => void) | undefined;
353
405
 
354
406
  mkdirSync(readyRoot, { recursive: true });
355
- writeTask(readyRoot, "T001", "alpha-task", "# Task: T001 Alpha Task\n");
356
- writeTask(readyRoot, "T002", "beta-task", "# Task: T002 Beta Task\n");
407
+ writeTask(readyRoot, "T0001", "alpha-task", "# Task: T0001 Alpha Task\n");
408
+ writeTask(readyRoot, "T0002", "beta-task", "# Task: T0002 Beta Task\n");
357
409
 
358
410
  try {
359
411
  process.chdir(base);
@@ -365,14 +417,14 @@ test("status view clamps selection when tasks are removed", async () => {
365
417
  await new Promise((resolve) => setTimeout(resolve, 0));
366
418
 
367
419
  const moved = stripAnsi(rendered.lastFrame() ?? "");
368
- expect(findLine(moved, "T002")).toContain(selectionMarker);
420
+ expect(findLine(moved, "T0002")).toContain(selectionMarker);
369
421
 
370
- unlinkSync(join(readyRoot, "T002_beta-task.md"));
422
+ unlinkSync(join(readyRoot, "T0002_beta-task.md"));
371
423
  await new Promise((resolve) => setTimeout(resolve, refreshIntervalMs * 2));
372
424
 
373
425
  const refreshed = stripAnsi(rendered.lastFrame() ?? "");
374
- expect(findLine(refreshed, "T001")).toContain(selectionMarker);
375
- expect(refreshed).not.toContain("T002");
426
+ expect(findLine(refreshed, "T0001")).toContain(selectionMarker);
427
+ expect(refreshed).not.toContain("T0002");
376
428
  } finally {
377
429
  unmount?.();
378
430
  process.chdir(originalCwd);
@@ -4,6 +4,7 @@ import React from "react";
4
4
  import { type ChangeInfo, listChanges, updateChangeStatus } from "../changes";
5
5
  import { findSchubRoot, loadTaskDependencies, type TaskInfo, trimTaskTitle } from "../features/tasks";
6
6
  import { openInVsCode } from "../ide";
7
+ import { launchOpencodeReview } from "../opencode";
7
8
 
8
9
  type StatusSortable = {
9
10
  id: string;
@@ -11,9 +12,16 @@ type StatusSortable = {
11
12
  status: string;
12
13
  };
13
14
 
15
+ type Shortcut = {
16
+ keyLabel: string;
17
+ label: string;
18
+ };
19
+
14
20
  type StatusViewProps = {
15
21
  refreshIntervalMs?: number;
16
22
  onCopyId: (id: string) => void;
23
+ onReview?: (changeId: string) => void;
24
+ onShortcutsChange?: (shortcuts: Shortcut[]) => void;
17
25
  };
18
26
 
19
27
  const DEFAULT_REFRESH_INTERVAL_MS = 1000;
@@ -26,6 +34,7 @@ type ChangeTaskCount = {
26
34
  };
27
35
 
28
36
  const AUTO_MARK_STATUSES = new Set(["accepted", "wip"]);
37
+ const REVIEW_SHORTCUT: Shortcut = { keyLabel: "r", label: "review" };
29
38
 
30
39
  const compareText = (left: string, right: string): number =>
31
40
  left.localeCompare(right, undefined, { sensitivity: "base" });
@@ -50,10 +59,27 @@ const isTaskItem = (item: ChangeInfo | TaskInfo): item is TaskInfo => {
50
59
  };
51
60
 
52
61
  const formatChangeId = (value: string) => {
53
- const match = value.match(/^([Cc]\d{3})_/);
62
+ const match = value.match(/^([Cc]\d{4})_/);
54
63
  return match ? match[1].toUpperCase() : value;
55
64
  };
56
65
 
66
+ const formatChangeTitle = (changeId: string, title: string) => {
67
+ const trimmedTitle = title.trim();
68
+ if (trimmedTitle !== changeId) {
69
+ return trimmedTitle;
70
+ }
71
+
72
+ const match = changeId.match(/^([Cc]\d{4})_(.+)$/);
73
+ if (!match) {
74
+ return trimmedTitle;
75
+ }
76
+
77
+ return match[2]
78
+ .split("-")
79
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
80
+ .join(" ");
81
+ };
82
+
57
83
  const canAutoMarkChange = (status: string) => AUTO_MARK_STATUSES.has(status.trim().toLowerCase());
58
84
 
59
85
  const autoMarkChangesDone = (schubDir: string) => {
@@ -185,7 +211,12 @@ const buildStatusData = (schubDir: string | null) => {
185
211
  };
186
212
  };
187
213
 
188
- export default function StatusView({ refreshIntervalMs = DEFAULT_REFRESH_INTERVAL_MS, onCopyId }: StatusViewProps) {
214
+ export default function StatusView({
215
+ refreshIntervalMs = DEFAULT_REFRESH_INTERVAL_MS,
216
+ onCopyId,
217
+ onReview = launchOpencodeReview,
218
+ onShortcutsChange,
219
+ }: StatusViewProps) {
189
220
  const schubDir = findSchubRoot();
190
221
  const [, setRefreshTick] = React.useState(0);
191
222
  const {
@@ -214,6 +245,24 @@ export default function StatusView({ refreshIntervalMs = DEFAULT_REFRESH_INTERVA
214
245
 
215
246
  const [selection, setSelection] = React.useState(0);
216
247
  const totalItems = allItems.length;
248
+ const pendingReviewIds = new Set(pendingReview.map((change) => change.id));
249
+ const selectedItem = totalItems > 0 ? allItems[selection] : null;
250
+ const selectedReviewId =
251
+ selectedItem && !isTaskItem(selectedItem) && pendingReviewIds.has(selectedItem.id) ? selectedItem.id : null;
252
+ const lastReviewIdRef = React.useRef<string | null>(null);
253
+
254
+ React.useEffect(() => {
255
+ if (!onShortcutsChange) {
256
+ return;
257
+ }
258
+
259
+ if (lastReviewIdRef.current === selectedReviewId) {
260
+ return;
261
+ }
262
+
263
+ lastReviewIdRef.current = selectedReviewId;
264
+ onShortcutsChange(selectedReviewId ? [REVIEW_SHORTCUT] : []);
265
+ }, [onShortcutsChange, selectedReviewId]);
217
266
 
218
267
  React.useEffect(() => {
219
268
  if (!schubDir) {
@@ -260,6 +309,10 @@ export default function StatusView({ refreshIntervalMs = DEFAULT_REFRESH_INTERVA
260
309
  const selectedItem = allItems[selection];
261
310
  onCopyId(selectedItem.id);
262
311
  }
312
+
313
+ if (input === "r" && selectedReviewId) {
314
+ onReview(selectedReviewId);
315
+ }
263
316
  });
264
317
 
265
318
  if (!schubDir) {
@@ -287,7 +340,7 @@ export default function StatusView({ refreshIntervalMs = DEFAULT_REFRESH_INTERVA
287
340
  task && task.status === "wip" && typeof task.checklistTotal === "number"
288
341
  ? ` (${task.checklistRemaining ?? 0}/${task.checklistTotal})`
289
342
  : "";
290
- const changeTitle = item.title;
343
+ const changeTitle = task ? "" : formatChangeTitle(item.id, item.title);
291
344
  const changeDetail = detail ? ` ${detail}` : "";
292
345
  const displayTitle = task ? `${title}${checklistIndicator}` : `${changeTitle}${changeDetail}`.trim();
293
346
  const displayId = task ? item.id : formatChangeId(item.id);
@@ -29,13 +29,13 @@ export const createTask = (
29
29
  const titles = options.titles.map((t) => t.trim()).filter(Boolean);
30
30
 
31
31
  // Validate change ID format (simple check)
32
- if (!/^(?:[Cc]\d{3}_)?[a-z0-9]+(?:-[a-z0-9]+)*$/.test(changeId)) {
33
- throw new Error(`Invalid change-id '${changeId}'. Use kebab-case or a C-prefixed id (e.g., C001_add-user-auth).`);
32
+ if (!/^(?:[Cc]\d{4}_)?[a-z0-9]+(?:-[a-z0-9]+)*$/.test(changeId)) {
33
+ throw new Error(`Invalid change-id '${changeId}'. Use kebab-case or a C-prefixed id (e.g., C0001_add-user-auth).`);
34
34
  }
35
35
 
36
36
  // Normalize change ID (ensure C prefix if it matches pattern)
37
37
  let normalizedChangeId = changeId;
38
- const match = changeId.match(/^([Cc])(\d{3})_(.+)$/);
38
+ const match = changeId.match(/^([Cc])(\d{4})_(.+)$/);
39
39
  if (match) {
40
40
  normalizedChangeId = `C${match[2]}_${match[3]}`;
41
41
  }
@@ -86,7 +86,7 @@ export const createTask = (
86
86
  if (entry.isDirectory()) {
87
87
  scan(join(dir, entry.name));
88
88
  } else if (entry.isFile() && entry.name.endsWith(".md")) {
89
- const m = entry.name.match(/(?:^|-)T(\d{3})(?:_[^.]+)?\.md$/);
89
+ const m = entry.name.match(/(?:^|-)T(\d{4})(?:_[^.]+)?\.md$/);
90
90
  if (m) existingNumbers.add(Number.parseInt(m[1], 10));
91
91
  }
92
92
  }
@@ -102,7 +102,7 @@ export const createTask = (
102
102
  const createdPaths: string[] = [];
103
103
 
104
104
  for (const title of titles) {
105
- const taskId = `T${nextNumber.toString().padStart(3, "0")}`;
105
+ const taskId = `T${nextNumber.toString().padStart(4, "0")}`;
106
106
  let slug = title
107
107
  .toLowerCase()
108
108
  .replace(/[^a-z0-9]+/g, "-")
@@ -1,8 +1,8 @@
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 { archiveTasksForChange, listTasks, loadTaskDependencies } from "./filesystem";
6
6
 
7
7
  const setupChecklistRepo = () => {
8
8
  const base = mkdtempSync(join(tmpdir(), "schub-checklist-"));
@@ -12,24 +12,24 @@ const setupChecklistRepo = () => {
12
12
 
13
13
  const tasks = [
14
14
  {
15
- id: "T001",
15
+ id: "T0001",
16
16
  slug: "unchecked-only",
17
- body: `# Task: T001 Unchecked Only\n\n## Steps\n- [ ] First item\n- [ ] Second item\n`,
17
+ body: `# Task: T0001 Unchecked Only\n\n## Steps\n- [ ] First item\n- [ ] Second item\n`,
18
18
  },
19
19
  {
20
- id: "T002",
20
+ id: "T0002",
21
21
  slug: "checked-only",
22
- body: `# Task: T002 Checked Only\n\n## Steps\n- [x] Finished item\n`,
22
+ body: `# Task: T0002 Checked Only\n\n## Steps\n- [x] Finished item\n`,
23
23
  },
24
24
  {
25
- id: "T003",
25
+ id: "T0003",
26
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`,
27
+ body: `# Task: T0003 Mixed Checklist\n\n## Steps\n- [ ] Remaining item\n- [x] Done item\n\n## Acceptance\n- [ ] Ignore this\n`,
28
28
  },
29
29
  {
30
- id: "T004",
30
+ id: "T0004",
31
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`,
32
+ body: `# Task: T0004 Missing Checklist\n\n## Steps\n- Do the thing\n- Another item\n\n## Acceptance\n- [ ] Ignore this too\n`,
33
33
  },
34
34
  ];
35
35
 
@@ -41,6 +41,36 @@ const setupChecklistRepo = () => {
41
41
  return schubDir;
42
42
  };
43
43
 
44
+ const setupArchiveRepo = () => {
45
+ const base = mkdtempSync(join(tmpdir(), "schub-archive-"));
46
+ const schubDir = join(base, ".schub");
47
+ const tasksRoot = join(schubDir, "tasks");
48
+ const statuses = ["backlog", "wip", "done"];
49
+
50
+ for (const status of statuses) {
51
+ mkdirSync(join(tasksRoot, status), { recursive: true });
52
+ }
53
+
54
+ const changeId = "C0001_archive-change";
55
+ writeFileSync(
56
+ join(tasksRoot, "backlog", "T0010_backlog-task.md"),
57
+ `# Task: T0010 Backlog Task\n\n**Change ID**: \`${changeId}\`\n`,
58
+ "utf8",
59
+ );
60
+ writeFileSync(
61
+ join(tasksRoot, "wip", "T0011_wip-task.md"),
62
+ `# Task: T0011 Wip Task\n\n**Change ID**: \`${changeId}\`\n`,
63
+ "utf8",
64
+ );
65
+ writeFileSync(
66
+ join(tasksRoot, "done", "T0012_other-task.md"),
67
+ "# Task: T0012 Other Task\n\n**Change ID**: `C0002_other-change`\n",
68
+ "utf8",
69
+ );
70
+
71
+ return { schubDir, tasksRoot, changeId };
72
+ };
73
+
44
74
  const getTaskById = <T extends { id: string }>(tasks: T[], id: string) => {
45
75
  return tasks.find((task) => task.id === id);
46
76
  };
@@ -49,10 +79,10 @@ test("listTasks includes Steps-only checklist counts", () => {
49
79
  const schubDir = setupChecklistRepo();
50
80
  const tasks = listTasks(schubDir);
51
81
 
52
- const unchecked = getTaskById(tasks, "T001");
53
- const checked = getTaskById(tasks, "T002");
54
- const mixed = getTaskById(tasks, "T003");
55
- const missing = getTaskById(tasks, "T004");
82
+ const unchecked = getTaskById(tasks, "T0001");
83
+ const checked = getTaskById(tasks, "T0002");
84
+ const mixed = getTaskById(tasks, "T0003");
85
+ const missing = getTaskById(tasks, "T0004");
56
86
 
57
87
  expect(unchecked).toMatchObject({ checklistTotal: 2, checklistRemaining: 2 });
58
88
  expect(checked).toMatchObject({ checklistTotal: 1, checklistRemaining: 0 });
@@ -65,10 +95,10 @@ test("loadTaskDependencies includes Steps-only checklist counts", () => {
65
95
  const schubDir = setupChecklistRepo();
66
96
  const tasks = loadTaskDependencies(schubDir);
67
97
 
68
- const unchecked = getTaskById(tasks, "T001");
69
- const checked = getTaskById(tasks, "T002");
70
- const mixed = getTaskById(tasks, "T003");
71
- const missing = getTaskById(tasks, "T004");
98
+ const unchecked = getTaskById(tasks, "T0001");
99
+ const checked = getTaskById(tasks, "T0002");
100
+ const mixed = getTaskById(tasks, "T0003");
101
+ const missing = getTaskById(tasks, "T0004");
72
102
 
73
103
  expect(unchecked).toMatchObject({ checklistTotal: 2, checklistRemaining: 2 });
74
104
  expect(checked).toMatchObject({ checklistTotal: 1, checklistRemaining: 0 });
@@ -76,3 +106,23 @@ test("loadTaskDependencies includes Steps-only checklist counts", () => {
76
106
  expect(missing?.checklistTotal).toBeUndefined();
77
107
  expect(missing?.checklistRemaining).toBeUndefined();
78
108
  });
109
+
110
+ test("archiveTasksForChange moves matching tasks to archived", () => {
111
+ const { schubDir, tasksRoot, changeId } = setupArchiveRepo();
112
+ const archived = archiveTasksForChange(schubDir, changeId);
113
+
114
+ const archiveRoot = join(tasksRoot, "archived");
115
+ const backlogTask = join(archiveRoot, "T0010_backlog-task.md");
116
+ const wipTask = join(archiveRoot, "T0011_wip-task.md");
117
+
118
+ expect(existsSync(archiveRoot)).toBe(true);
119
+ expect(existsSync(backlogTask)).toBe(true);
120
+ expect(existsSync(wipTask)).toBe(true);
121
+ expect(existsSync(join(tasksRoot, "backlog", "T0010_backlog-task.md"))).toBe(false);
122
+ expect(existsSync(join(tasksRoot, "wip", "T0011_wip-task.md"))).toBe(false);
123
+ expect(existsSync(join(tasksRoot, "done", "T0012_other-task.md"))).toBe(true);
124
+
125
+ expect(archived.map((task) => task.id)).toEqual(["T0010", "T0011"]);
126
+ expect(archived.every((task) => task.status === "archived")).toBe(true);
127
+ expect(archived[0]?.path).toContain(".schub/tasks/archived/");
128
+ });
@@ -1,5 +1,5 @@
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
3
  import { TASK_STATUSES, type TaskDependency, type TaskInfo, type TaskStatus } from "./constants";
4
4
  import { compareTaskIds, compareTasks } from "./sorting";
5
5
 
@@ -40,6 +40,9 @@ const parseTaskFilename = (fileName: string): { id: string; title: string } | nu
40
40
  }
41
41
 
42
42
  const id = baseName.slice(0, underscoreIndex);
43
+ if (!/^T\d{4}$/.test(id)) {
44
+ return null;
45
+ }
43
46
  const titleSlug = baseName.slice(underscoreIndex + 1);
44
47
  return { id, title: titleSlug.replace(/-/g, " ") };
45
48
  };
@@ -112,7 +115,7 @@ const parseTaskFile = (filePath: string, fallback: { id: string; title: string }
112
115
  if (dependsMatch) {
113
116
  const raw = dependsMatch[1].trim();
114
117
  if (!/^none$/i.test(raw)) {
115
- const matches = raw.match(/\bT\d+\b/g);
118
+ const matches = raw.match(/\bT\d{4}\b/g);
116
119
  if (matches) {
117
120
  dependsOn = Array.from(new Set(matches));
118
121
  }
@@ -230,3 +233,29 @@ export const loadTaskDependencies = (
230
233
 
231
234
  return tasks.sort(compareTasks);
232
235
  };
236
+
237
+ const ACTIVE_TASK_STATUSES = TASK_STATUSES.filter((status) => status !== "archived");
238
+
239
+ export const archiveTasksForChange = (schubDir: string, changeId: string) => {
240
+ const normalizedChangeId = changeId.trim();
241
+ const tasksRoot = join(schubDir, "tasks");
242
+ const archiveRoot = join(tasksRoot, "archived");
243
+ mkdirSync(archiveRoot, { recursive: true });
244
+
245
+ const repoRoot = dirname(schubDir);
246
+ const tasks = loadTaskDependencies(schubDir, ACTIVE_TASK_STATUSES).filter(
247
+ (task) => task.changeId === normalizedChangeId,
248
+ );
249
+
250
+ return tasks.map((task) => {
251
+ const currentPath = join(repoRoot, task.path);
252
+ const archivePath = join(archiveRoot, basename(task.path));
253
+ renameSync(currentPath, archivePath);
254
+
255
+ return {
256
+ ...task,
257
+ status: "archived",
258
+ path: relative(repoRoot, archivePath),
259
+ };
260
+ });
261
+ };