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
@@ -35,7 +35,7 @@ const createRepo = () => {
35
35
  const seedProposal = (schubRoot: string, changeId: string, status = "Accepted") => {
36
36
  const changeDir = join(schubRoot, "changes", changeId);
37
37
  mkdirSync(changeDir, { recursive: true });
38
- writeFileSync(join(changeDir, "proposal.md"), `# Proposal - Seed\n**Status**: ${status}\n`, "utf8");
38
+ writeFileSync(join(changeDir, "proposal.md"), `---\nstatus: ${status}\n---\n# Proposal - Seed\n`, "utf8");
39
39
  };
40
40
 
41
41
  const writeExistingTask = (schubRoot: string, status: string, id: string, slug: string) => {
@@ -49,12 +49,12 @@ const writeExistingTask = (schubRoot: string, status: string, id: string, slug:
49
49
  test("tasks create scaffolds multiple task files", () => {
50
50
  const { repoRoot, cwd } = createRepo();
51
51
  const schubRoot = join(repoRoot, ".schub");
52
- seedProposal(schubRoot, "C001_update-cli");
53
- writeExistingTask(schubRoot, "backlog", "T003", "existing-task");
52
+ seedProposal(schubRoot, "C0001_update-cli");
53
+ writeExistingTask(schubRoot, "backlog", "T0003", "existing-task");
54
54
 
55
55
  const { result, stdout } = runTasksCreate(cwd, [
56
56
  "--change-id",
57
- "C001_update-cli",
57
+ "C0001_update-cli",
58
58
  "--status",
59
59
  "ready",
60
60
  "--title",
@@ -65,8 +65,8 @@ test("tasks create scaffolds multiple task files", () => {
65
65
 
66
66
  expect(result.exitCode).toBe(0);
67
67
 
68
- const firstPath = join(schubRoot, "tasks", "ready", "T004_first-task.md");
69
- const secondPath = join(schubRoot, "tasks", "ready", "T005_second-task.md");
68
+ const firstPath = join(schubRoot, "tasks", "ready", "T0004_first-task.md");
69
+ const secondPath = join(schubRoot, "tasks", "ready", "T0005_second-task.md");
70
70
 
71
71
  expect(existsSync(firstPath)).toBe(true);
72
72
  expect(existsSync(secondPath)).toBe(true);
@@ -74,13 +74,13 @@ test("tasks create scaffolds multiple task files", () => {
74
74
  const firstContent = readFileSync(firstPath, "utf8");
75
75
  const template = readFileSync(taskTemplatePath, "utf8");
76
76
  const expectedFirst = template
77
- .replace("{{TASK_ID}}", "T004")
77
+ .replace("{{TASK_ID}}", "T0004")
78
78
  .replace("{{TASK_TITLE}}", "First Task")
79
- .replace("{{CHANGE_ID}}", "C001_update-cli");
79
+ .replace("{{CHANGE_ID}}", "C0001_update-cli");
80
80
  const expectedSecond = template
81
- .replace("{{TASK_ID}}", "T005")
81
+ .replace("{{TASK_ID}}", "T0005")
82
82
  .replace("{{TASK_TITLE}}", "Second: Task!")
83
- .replace("{{CHANGE_ID}}", "C001_update-cli");
83
+ .replace("{{CHANGE_ID}}", "C0001_update-cli");
84
84
 
85
85
  expect(firstContent).toBe(expectedFirst);
86
86
  expect(readFileSync(secondPath, "utf8")).toBe(expectedSecond);
@@ -92,7 +92,7 @@ test("tasks create scaffolds multiple task files", () => {
92
92
  test("tasks create prefers local template overrides", () => {
93
93
  const { repoRoot, cwd } = createRepo();
94
94
  const schubRoot = join(repoRoot, ".schub");
95
- seedProposal(schubRoot, "C001_update-cli");
95
+ seedProposal(schubRoot, "C0001_update-cli");
96
96
 
97
97
  const localTemplateDir = join(schubRoot, "templates", "create-tasks");
98
98
  mkdirSync(localTemplateDir, { recursive: true });
@@ -101,7 +101,7 @@ test("tasks create prefers local template overrides", () => {
101
101
 
102
102
  const { result } = runTasksCreate(cwd, [
103
103
  "--change-id",
104
- "C001_update-cli",
104
+ "C0001_update-cli",
105
105
  "--status",
106
106
  "ready",
107
107
  "--title",
@@ -110,12 +110,12 @@ test("tasks create prefers local template overrides", () => {
110
110
 
111
111
  expect(result.exitCode).toBe(0);
112
112
 
113
- const taskPath = join(schubRoot, "tasks", "ready", "T001_local-task.md");
113
+ const taskPath = join(schubRoot, "tasks", "ready", "T0001_local-task.md");
114
114
  const content = readFileSync(taskPath, "utf8");
115
115
  const expected = localTemplate
116
- .replace("{{TASK_ID}}", "T001")
116
+ .replace("{{TASK_ID}}", "T0001")
117
117
  .replace("{{TASK_TITLE}}", "Local Task")
118
- .replace("{{CHANGE_ID}}", "C001_update-cli");
118
+ .replace("{{CHANGE_ID}}", "C0001_update-cli");
119
119
 
120
120
  expect(content).toBe(expected);
121
121
  });
@@ -123,9 +123,9 @@ test("tasks create prefers local template overrides", () => {
123
123
  test("tasks create rejects invalid status", () => {
124
124
  const { repoRoot, cwd } = createRepo();
125
125
  const schubRoot = join(repoRoot, ".schub");
126
- seedProposal(schubRoot, "C001_update-cli");
126
+ seedProposal(schubRoot, "C0001_update-cli");
127
127
 
128
- const { result, stderr } = runTasksCreate(cwd, ["--change-id", "C001_update-cli", "--status", "bad status"]);
128
+ const { result, stderr } = runTasksCreate(cwd, ["--change-id", "C0001_update-cli", "--status", "bad status"]);
129
129
 
130
130
  expect(result.exitCode).not.toBe(0);
131
131
  expect(stderr).toContain("Invalid status");
@@ -136,7 +136,7 @@ test("tasks create rejects missing proposal", () => {
136
136
  const schubRoot = join(repoRoot, ".schub");
137
137
  mkdirSync(join(schubRoot, "changes"), { recursive: true });
138
138
 
139
- const { result, stderr } = runTasksCreate(cwd, ["--change-id", "C001_missing", "--title", "New Task"]);
139
+ const { result, stderr } = runTasksCreate(cwd, ["--change-id", "C0001_missing", "--title", "New Task"]);
140
140
 
141
141
  expect(result.exitCode).not.toBe(0);
142
142
  expect(stderr).toContain("Required change files missing");
@@ -145,9 +145,9 @@ test("tasks create rejects missing proposal", () => {
145
145
  test("tasks create rejects non-accepted proposals", () => {
146
146
  const { repoRoot, cwd } = createRepo();
147
147
  const schubRoot = join(repoRoot, ".schub");
148
- seedProposal(schubRoot, "C001_update-cli", "Draft");
148
+ seedProposal(schubRoot, "C0001_update-cli", "Draft");
149
149
 
150
- const { result, stderr } = runTasksCreate(cwd, ["--change-id", "C001_update-cli", "--title", "New Task"]);
150
+ const { result, stderr } = runTasksCreate(cwd, ["--change-id", "C0001_update-cli", "--title", "New Task"]);
151
151
 
152
152
  expect(result.exitCode).not.toBe(0);
153
153
  expect(stderr).toContain("Mark the proposal as Accepted");
@@ -156,11 +156,11 @@ test("tasks create rejects non-accepted proposals", () => {
156
156
  test("tasks create rejects schub root flags", () => {
157
157
  const { repoRoot, cwd } = createRepo();
158
158
  const schubRoot = join(repoRoot, ".schub");
159
- seedProposal(schubRoot, "C001_update-cli");
159
+ seedProposal(schubRoot, "C0001_update-cli");
160
160
 
161
161
  const { result, stderr } = runTasksCreate(cwd, [
162
162
  "--change-id",
163
- "C001_update-cli",
163
+ "C0001_update-cli",
164
164
  "--title",
165
165
  "New Task",
166
166
  "--schub-root",
@@ -0,0 +1,253 @@
1
+ import { expect, test } from "bun:test";
2
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { delimiter, 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 runTasksImplement = (schubCwd: string, args: string[], envOverrides: NodeJS.ProcessEnv = {}) => {
13
+ const result = spawnSync({
14
+ cmd: ["bun", "run", "schub", "tasks", "implement", ...args],
15
+ cwd: cliDir,
16
+ env: { ...process.env, FORCE_COLOR: "0", SCHUB_CWD: schubCwd, ...envOverrides },
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-implement-"));
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 { base, cwd, repoRoot, tasksRoot };
40
+ };
41
+
42
+ const writeTask = (tasksRoot: string, status: string, id: string, slug: string) => {
43
+ const filePath = join(tasksRoot, status, `${id}_${slug}.md`);
44
+ writeFileSync(filePath, `# Task: ${id} ${slug}\n`, "utf8");
45
+ return filePath;
46
+ };
47
+
48
+ const writeOpencodeStub = (baseDir: string) => {
49
+ const binDir = join(baseDir, "bin");
50
+ mkdirSync(binDir, { recursive: true });
51
+ const stubPath = join(binDir, "opencode");
52
+ writeFileSync(
53
+ stubPath,
54
+ [
55
+ "#!/usr/bin/env bash",
56
+ 'if [ -n "$OPENCODE_LOG_FILE" ]; then',
57
+ ' printf "%s" "$SCHUB_CWD" > "$OPENCODE_LOG_FILE"',
58
+ "fi",
59
+ "",
60
+ ].join("\n"),
61
+ "utf8",
62
+ );
63
+ chmodSync(stubPath, 0o755);
64
+ return binDir;
65
+ };
66
+
67
+ const writeGitStub = (baseDir: string) => {
68
+ const binDir = join(baseDir, "bin");
69
+ mkdirSync(binDir, { recursive: true });
70
+ const stubPath = join(binDir, "git");
71
+ writeFileSync(stubPath, ["#!/usr/bin/env bash", "exit 1", ""].join("\n"), "utf8");
72
+ chmodSync(stubPath, 0o755);
73
+ };
74
+
75
+ const runGit = (repoRoot: string, args: string[]) => {
76
+ return spawnSync({
77
+ cmd: ["git", ...args],
78
+ cwd: repoRoot,
79
+ });
80
+ };
81
+
82
+ const runGitChecked = (repoRoot: string, args: string[]) => {
83
+ const result = runGit(repoRoot, args);
84
+ if (result.exitCode !== 0) {
85
+ const stderr = decoder.decode(result.stderr ?? new Uint8Array());
86
+ throw new Error(`Git command failed: git ${args.join(" ")}\n${stderr}`);
87
+ }
88
+ return result;
89
+ };
90
+
91
+ const initGitRepo = (repoRoot: string) => {
92
+ runGitChecked(repoRoot, ["init"]);
93
+ writeFileSync(join(repoRoot, "README.md"), "test", "utf8");
94
+ runGitChecked(repoRoot, ["add", "."]);
95
+ runGitChecked(repoRoot, ["-c", "user.email=schub@example.com", "-c", "user.name=Schub", "commit", "-m", "init"]);
96
+ };
97
+
98
+ const waitForFile = async (filePath: string) => {
99
+ for (let attempt = 0; attempt < 100; attempt += 1) {
100
+ if (existsSync(filePath)) {
101
+ return;
102
+ }
103
+ await new Promise((resolve) => setTimeout(resolve, 10));
104
+ }
105
+
106
+ throw new Error(`Timed out waiting for ${filePath}`);
107
+ };
108
+
109
+ test("tasks implement --mode none moves task to wip and launches opencode from repo root", async () => {
110
+ const { base, cwd, repoRoot, tasksRoot } = createTaskRepo();
111
+ writeTask(tasksRoot, "ready", "T0008", "none-mode");
112
+
113
+ const logPath = join(base, "opencode-log.txt");
114
+ const binDir = writeOpencodeStub(base);
115
+ const path = [binDir, process.env.PATH ?? ""].filter(Boolean).join(delimiter);
116
+
117
+ const { result } = runTasksImplement(
118
+ cwd,
119
+ ["--id", "T0008", "--mode", "none", "--worktree-root", join(repoRoot, ".schub", "worktrees")],
120
+ {
121
+ PATH: path,
122
+ OPENCODE_LOG_FILE: logPath,
123
+ },
124
+ );
125
+
126
+ expect(result.exitCode).toBe(0);
127
+
128
+ const wipPath = join(tasksRoot, "wip", "T0008_none-mode.md");
129
+ expect(existsSync(wipPath)).toBe(true);
130
+ expect(existsSync(join(tasksRoot, "ready", "T0008_none-mode.md"))).toBe(false);
131
+
132
+ await waitForFile(logPath);
133
+ const loggedCwd = readFileSync(logPath, "utf8");
134
+ expect(realpathSync(loggedCwd)).toBe(realpathSync(repoRoot));
135
+ });
136
+
137
+ test("tasks implement --mode worktree creates a worktree and launches opencode from worktree root", async () => {
138
+ const { base, cwd, repoRoot, tasksRoot } = createTaskRepo();
139
+ initGitRepo(repoRoot);
140
+ writeTask(tasksRoot, "ready", "T0009", "worktree-mode");
141
+
142
+ const logPath = join(base, "opencode-log.txt");
143
+ const binDir = writeOpencodeStub(base);
144
+ const path = [binDir, process.env.PATH ?? ""].filter(Boolean).join(delimiter);
145
+
146
+ const { result } = runTasksImplement(cwd, ["--id", "T0009", "--mode", "worktree"], {
147
+ PATH: path,
148
+ OPENCODE_LOG_FILE: logPath,
149
+ });
150
+
151
+ expect(result.exitCode).toBe(0);
152
+
153
+ const wipPath = join(tasksRoot, "wip", "T0009_worktree-mode.md");
154
+ expect(existsSync(wipPath)).toBe(true);
155
+
156
+ const worktreePath = join(repoRoot, ".schub", "worktrees", "T0009");
157
+ expect(existsSync(worktreePath)).toBe(true);
158
+
159
+ const branchResult = runGit(repoRoot, ["rev-parse", "--verify", "task/T0009"]);
160
+ expect(branchResult.exitCode).toBe(0);
161
+
162
+ await waitForFile(logPath);
163
+ const loggedCwd = readFileSync(logPath, "utf8");
164
+ expect(realpathSync(loggedCwd)).toBe(realpathSync(worktreePath));
165
+ });
166
+
167
+ test("tasks implement --mode worktree respects --worktree-root override", async () => {
168
+ const { base, cwd, repoRoot, tasksRoot } = createTaskRepo();
169
+ initGitRepo(repoRoot);
170
+ writeTask(tasksRoot, "ready", "T0010", "worktree-root");
171
+
172
+ const logPath = join(base, "opencode-log.txt");
173
+ const binDir = writeOpencodeStub(base);
174
+ const path = [binDir, process.env.PATH ?? ""].filter(Boolean).join(delimiter);
175
+
176
+ const { result } = runTasksImplement(
177
+ cwd,
178
+ ["--id", "T0010", "--mode", "worktree", "--worktree-root", ".schub/custom-worktrees"],
179
+ {
180
+ PATH: path,
181
+ OPENCODE_LOG_FILE: logPath,
182
+ },
183
+ );
184
+
185
+ expect(result.exitCode).toBe(0);
186
+
187
+ const worktreePath = join(repoRoot, ".schub", "custom-worktrees", "T0010");
188
+ expect(existsSync(worktreePath)).toBe(true);
189
+
190
+ await waitForFile(logPath);
191
+ const loggedCwd = readFileSync(logPath, "utf8");
192
+ expect(realpathSync(loggedCwd)).toBe(realpathSync(worktreePath));
193
+ });
194
+
195
+ test("tasks implement --mode worktree errors when branch exists", () => {
196
+ const { cwd, repoRoot, tasksRoot } = createTaskRepo();
197
+ initGitRepo(repoRoot);
198
+ writeTask(tasksRoot, "ready", "T0011", "branch-exists");
199
+ runGitChecked(repoRoot, ["branch", "task/T0011"]);
200
+
201
+ const { result, stderr } = runTasksImplement(cwd, ["--id", "T0011", "--mode", "worktree"]);
202
+
203
+ expect(result.exitCode).not.toBe(0);
204
+ expect(stderr).toContain("Branch task/T0011 already exists");
205
+ });
206
+
207
+ test("tasks implement --mode worktree errors when worktree path exists", () => {
208
+ const { cwd, repoRoot, tasksRoot } = createTaskRepo();
209
+ initGitRepo(repoRoot);
210
+ writeTask(tasksRoot, "ready", "T0012", "worktree-exists");
211
+
212
+ const worktreePath = join(repoRoot, ".schub", "worktrees", "T0012");
213
+ mkdirSync(worktreePath, { recursive: true });
214
+
215
+ const { result, stderr } = runTasksImplement(cwd, ["--id", "T0012", "--mode", "worktree"]);
216
+
217
+ expect(result.exitCode).not.toBe(0);
218
+ expect(stderr).toContain("Worktree path already exists");
219
+ });
220
+
221
+ test("tasks implement --mode worktree falls back to none when git is unavailable", async () => {
222
+ const { base, cwd, repoRoot, tasksRoot } = createTaskRepo();
223
+ initGitRepo(repoRoot);
224
+ writeTask(tasksRoot, "ready", "T0013", "git-missing");
225
+
226
+ const logPath = join(base, "opencode-log.txt");
227
+ const binDir = writeOpencodeStub(base);
228
+ writeGitStub(base);
229
+ const path = [binDir, process.env.PATH ?? ""].filter(Boolean).join(delimiter);
230
+
231
+ const { result } = runTasksImplement(cwd, ["--id", "T0013", "--mode", "worktree"], {
232
+ PATH: path,
233
+ OPENCODE_LOG_FILE: logPath,
234
+ });
235
+
236
+ expect(result.exitCode).toBe(0);
237
+
238
+ const worktreePath = join(repoRoot, ".schub", "worktrees", "T0013");
239
+ expect(existsSync(worktreePath)).toBe(false);
240
+
241
+ await waitForFile(logPath);
242
+ const loggedCwd = readFileSync(logPath, "utf8");
243
+ expect(realpathSync(loggedCwd)).toBe(realpathSync(repoRoot));
244
+ });
245
+
246
+ test("tasks implement errors when task id is not found", () => {
247
+ const { cwd } = createTaskRepo();
248
+
249
+ const { result, stderr } = runTasksImplement(cwd, ["--id", "T9999", "--mode", "none"]);
250
+
251
+ expect(result.exitCode).not.toBe(0);
252
+ expect(stderr).toContain("Task T9999 not found");
253
+ });
@@ -0,0 +1,121 @@
1
+ import { dirname } from "node:path";
2
+ import { assignTaskToWip, createTaskWorktree } from "../features/tasks";
3
+ import { launchOpencodeImplement } from "../opencode";
4
+
5
+ type TaskImplementMode = "none" | "worktree";
6
+
7
+ type TaskImplementOptions = {
8
+ taskId: string;
9
+ mode: TaskImplementMode;
10
+ worktreeRoot?: string;
11
+ };
12
+
13
+ const parseTaskImplementOptions = (args: string[]) => {
14
+ let taskId: string | undefined;
15
+ let mode: string | undefined;
16
+ let worktreeRoot: string | undefined;
17
+ const unknown: string[] = [];
18
+
19
+ for (let index = 0; index < args.length; index += 1) {
20
+ const arg = args[index];
21
+ if (arg === "--id") {
22
+ const value = args[index + 1];
23
+ if (value === undefined) {
24
+ throw new Error("Missing value for --id.");
25
+ }
26
+ taskId = value;
27
+ index += 1;
28
+ continue;
29
+ }
30
+ if (arg.startsWith("--id=")) {
31
+ taskId = arg.slice("--id=".length);
32
+ continue;
33
+ }
34
+ if (arg === "--mode") {
35
+ const value = args[index + 1];
36
+ if (value === undefined) {
37
+ throw new Error("Missing value for --mode.");
38
+ }
39
+ mode = value;
40
+ index += 1;
41
+ continue;
42
+ }
43
+ if (arg.startsWith("--mode=")) {
44
+ mode = arg.slice("--mode=".length);
45
+ continue;
46
+ }
47
+ if (arg === "--worktree-root") {
48
+ const value = args[index + 1];
49
+ if (value === undefined) {
50
+ throw new Error("Missing value for --worktree-root.");
51
+ }
52
+ worktreeRoot = value;
53
+ index += 1;
54
+ continue;
55
+ }
56
+ if (arg.startsWith("--worktree-root=")) {
57
+ worktreeRoot = arg.slice("--worktree-root=".length);
58
+ continue;
59
+ }
60
+ unknown.push(arg);
61
+ }
62
+
63
+ if (unknown.length > 0) {
64
+ throw new Error(`Unknown option(s): ${unknown.join(", ")}`);
65
+ }
66
+
67
+ if (!taskId || taskId.trim() === "") {
68
+ throw new Error("Provide --id.");
69
+ }
70
+
71
+ if (mode !== undefined && mode.trim() === "") {
72
+ throw new Error("Missing value for --mode.");
73
+ }
74
+
75
+ if (worktreeRoot !== undefined && worktreeRoot.trim() === "") {
76
+ throw new Error("Missing value for --worktree-root.");
77
+ }
78
+
79
+ const normalizedMode = (mode ?? "none").trim().toLowerCase();
80
+ const allowedModes: TaskImplementMode[] = ["none", "worktree"];
81
+ if (!allowedModes.includes(normalizedMode as TaskImplementMode)) {
82
+ throw new Error(`Invalid mode '${mode}'. Use none or worktree.`);
83
+ }
84
+
85
+ const options: TaskImplementOptions = {
86
+ taskId: taskId.trim().toUpperCase(),
87
+ mode: normalizedMode as TaskImplementMode,
88
+ worktreeRoot: worktreeRoot?.trim() || undefined,
89
+ };
90
+ return options;
91
+ };
92
+
93
+ export const runTasksImplement = (schubDir: string | null, args: string[]) => {
94
+ if (!schubDir) {
95
+ throw new Error("No .schub directory found.");
96
+ }
97
+
98
+ const options = parseTaskImplementOptions(args);
99
+ const assigned = assignTaskToWip(schubDir, options.taskId);
100
+ const repoRoot = dirname(schubDir);
101
+
102
+ let launchRoot = repoRoot;
103
+
104
+ if (options.mode === "worktree") {
105
+ const worktree = createTaskWorktree({
106
+ repoRoot,
107
+ taskId: options.taskId,
108
+ worktreeRoot: options.worktreeRoot,
109
+ });
110
+
111
+ if (worktree) {
112
+ launchRoot = worktree.worktreePath;
113
+ }
114
+ }
115
+
116
+ launchOpencodeImplement(options.taskId, repoRoot, assigned.title, {
117
+ env: { ...process.env, SCHUB_CWD: launchRoot },
118
+ });
119
+
120
+ process.stdout.write(`[OK] Assigned task ${assigned.id}: ${assigned.status}\n`);
121
+ };
@@ -48,44 +48,44 @@ const writeTask = (tasksRoot: string, status: string, id: string, slug: string,
48
48
 
49
49
  test("tasks list shows tasks sorted by id", () => {
50
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");
51
+ writeTask(tasksRoot, "ready", "T0010", "later-task");
52
+ writeTask(tasksRoot, "backlog", "T0002", "middle-task");
53
+ writeTask(tasksRoot, "archived", "T0003", "archived-task");
54
+ writeTask(tasksRoot, "wip", "T0001", "first-task");
55
55
 
56
56
  const { result, stdout } = runCli(cwd);
57
57
 
58
58
  expect(result.exitCode).toBe(0);
59
59
  const lines = stdout.trim().split("\n").filter(Boolean);
60
60
  expect(lines).toHaveLength(4);
61
- expect(lines[0]).toContain("T001");
61
+ expect(lines[0]).toContain("T0001");
62
62
  expect(lines[0]).toContain("first task");
63
63
  expect(lines[0]).toContain("(wip)");
64
- expect(lines[1]).toContain("T002");
65
- expect(lines[2]).toContain("T003");
66
- expect(lines[3]).toContain("T010");
64
+ expect(lines[1]).toContain("T0002");
65
+ expect(lines[2]).toContain("T0003");
66
+ expect(lines[3]).toContain("T0010");
67
67
  });
68
68
 
69
69
  test("tasks list filters by status", () => {
70
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");
71
+ writeTask(tasksRoot, "ready", "T0010", "later-task");
72
+ writeTask(tasksRoot, "backlog", "T0002", "middle-task");
73
+ writeTask(tasksRoot, "wip", "T0001", "first-task");
74
74
 
75
75
  const { result, stdout } = runCli(cwd, ["--status", "ready,wip"]);
76
76
 
77
77
  expect(result.exitCode).toBe(0);
78
78
  const lines = stdout.trim().split("\n").filter(Boolean);
79
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");
80
+ expect(lines.join("\n")).toContain("T0010");
81
+ expect(lines.join("\n")).toContain("T0001");
82
+ expect(lines.join("\n")).not.toContain("T0002");
83
83
  });
84
84
 
85
85
  test("tasks list supports json output", () => {
86
86
  const { cwd, tasksRoot } = createTaskRepo();
87
- writeTask(tasksRoot, "ready", "T010", "later-task");
88
- writeTask(tasksRoot, "wip", "T001", "first-task");
87
+ writeTask(tasksRoot, "ready", "T0010", "later-task");
88
+ writeTask(tasksRoot, "wip", "T0001", "first-task");
89
89
 
90
90
  const { result, stdout } = runCli(cwd, ["--json"]);
91
91
 
@@ -97,11 +97,11 @@ test("tasks list supports json output", () => {
97
97
  path: string;
98
98
  }>;
99
99
  expect(tasks[0]).toMatchObject({
100
- id: "T001",
100
+ id: "T0001",
101
101
  title: "first task",
102
102
  status: "wip",
103
103
  });
104
- expect(tasks[0].path).toContain(".schub/tasks/wip/T001_first-task.md");
104
+ expect(tasks[0].path).toContain(".schub/tasks/wip/T0001_first-task.md");
105
105
  });
106
106
 
107
107
  test("tasks list shows checklist counts in text output", () => {
@@ -109,10 +109,10 @@ test("tasks list shows checklist counts in text output", () => {
109
109
  writeTask(
110
110
  tasksRoot,
111
111
  "wip",
112
- "T001",
112
+ "T0001",
113
113
  "first-task",
114
114
  [
115
- "# Task: T001 First task",
115
+ "# Task: T0001 First task",
116
116
  "",
117
117
  "## Steps",
118
118
  "- [ ] Draft outline",
@@ -123,17 +123,17 @@ test("tasks list shows checklist counts in text output", () => {
123
123
  "",
124
124
  ].join("\n"),
125
125
  );
126
- writeTask(tasksRoot, "backlog", "T002", "second-task");
126
+ writeTask(tasksRoot, "backlog", "T0002", "second-task");
127
127
 
128
128
  const { result, stdout } = runCli(cwd);
129
129
 
130
130
  expect(result.exitCode).toBe(0);
131
131
  const lines = stdout.trim().split("\n").filter(Boolean);
132
132
  expect(lines).toHaveLength(2);
133
- expect(lines[0]).toContain("T001");
133
+ expect(lines[0]).toContain("T0001");
134
134
  expect(lines[0]).toContain("(wip)");
135
135
  expect(lines[0]).toContain("(1/2)");
136
- expect(lines[1]).toContain("T002");
136
+ expect(lines[1]).toContain("T0002");
137
137
  expect(lines[1]).not.toMatch(/\(\d+\/\d+\)/);
138
138
  });
139
139
 
@@ -142,11 +142,11 @@ test("tasks list json includes checklist counts", () => {
142
142
  writeTask(
143
143
  tasksRoot,
144
144
  "ready",
145
- "T001",
145
+ "T0001",
146
146
  "first-task",
147
- ["# Task: T001 First task", "", "## Steps", "- [ ] Draft outline", "- [x] Review outline", ""].join("\n"),
147
+ ["# Task: T0001 First task", "", "## Steps", "- [ ] Draft outline", "- [x] Review outline", ""].join("\n"),
148
148
  );
149
- writeTask(tasksRoot, "ready", "T002", "second-task");
149
+ writeTask(tasksRoot, "ready", "T0002", "second-task");
150
150
 
151
151
  const { result, stdout } = runCli(cwd, ["--json"]);
152
152
 
@@ -160,7 +160,7 @@ test("tasks list json includes checklist counts", () => {
160
160
  checklistTotal?: number;
161
161
  }>;
162
162
  expect(tasks[0]).toMatchObject({
163
- id: "T001",
163
+ id: "T0001",
164
164
  checklistRemaining: 1,
165
165
  checklistTotal: 2,
166
166
  });