trekoon 0.1.0

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 (45) hide show
  1. package/.agents/skills/trekoon/SKILL.md +91 -0
  2. package/AGENTS.md +54 -0
  3. package/CONTRIBUTING.md +18 -0
  4. package/README.md +151 -0
  5. package/bin/trekoon +5 -0
  6. package/bun.lock +28 -0
  7. package/package.json +24 -0
  8. package/src/commands/arg-parser.ts +93 -0
  9. package/src/commands/dep.ts +105 -0
  10. package/src/commands/epic.ts +539 -0
  11. package/src/commands/help.ts +61 -0
  12. package/src/commands/init.ts +24 -0
  13. package/src/commands/quickstart.ts +61 -0
  14. package/src/commands/subtask.ts +187 -0
  15. package/src/commands/sync.ts +128 -0
  16. package/src/commands/task.ts +554 -0
  17. package/src/commands/wipe.ts +39 -0
  18. package/src/domain/tracker-domain.ts +576 -0
  19. package/src/domain/types.ts +99 -0
  20. package/src/index.ts +21 -0
  21. package/src/io/human-table.ts +191 -0
  22. package/src/io/output.ts +70 -0
  23. package/src/runtime/cli-shell.ts +158 -0
  24. package/src/runtime/command-types.ts +33 -0
  25. package/src/storage/database.ts +35 -0
  26. package/src/storage/migrations.ts +46 -0
  27. package/src/storage/path.ts +22 -0
  28. package/src/storage/schema.ts +116 -0
  29. package/src/storage/types.ts +15 -0
  30. package/src/sync/branch-db.ts +49 -0
  31. package/src/sync/event-writes.ts +49 -0
  32. package/src/sync/git-context.ts +67 -0
  33. package/src/sync/service.ts +654 -0
  34. package/src/sync/types.ts +31 -0
  35. package/tests/commands/dep.test.ts +101 -0
  36. package/tests/commands/epic.test.ts +383 -0
  37. package/tests/commands/subtask.test.ts +132 -0
  38. package/tests/commands/sync/sync-command.test.ts +1 -0
  39. package/tests/commands/sync.test.ts +199 -0
  40. package/tests/commands/task.test.ts +474 -0
  41. package/tests/integration/sync-workflow.test.ts +279 -0
  42. package/tests/io/human-table.test.ts +81 -0
  43. package/tests/runtime/output-mode.test.ts +54 -0
  44. package/tests/storage/database.test.ts +91 -0
  45. package/tsconfig.json +19 -0
@@ -0,0 +1,199 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ import { afterEach, describe, expect, test } from "bun:test";
7
+
8
+ import { runSync } from "../../src/commands/sync";
9
+ import { appendEventWithGitContext } from "../../src/sync/event-writes";
10
+ import { openTrekoonDatabase } from "../../src/storage/database";
11
+
12
+ const tempDirs: string[] = [];
13
+
14
+ function createWorkspace(): string {
15
+ const workspace: string = mkdtempSync(join(tmpdir(), "trekoon-sync-command-"));
16
+ tempDirs.push(workspace);
17
+ return workspace;
18
+ }
19
+
20
+ function runGit(cwd: string, args: readonly string[]): string {
21
+ const command = Bun.spawnSync({
22
+ cmd: ["git", ...args],
23
+ cwd,
24
+ stdout: "pipe",
25
+ stderr: "pipe",
26
+ });
27
+
28
+ const stdout: string = new TextDecoder().decode(command.stdout).trim();
29
+ const stderr: string = new TextDecoder().decode(command.stderr).trim();
30
+
31
+ if (command.exitCode !== 0) {
32
+ throw new Error(`git ${args.join(" ")} failed: ${stderr}`);
33
+ }
34
+
35
+ return stdout;
36
+ }
37
+
38
+ function initializeRepository(workspace: string): void {
39
+ runGit(workspace, ["init", "-b", "main"]);
40
+ runGit(workspace, ["config", "user.email", "tests@trekoon.local"]);
41
+ runGit(workspace, ["config", "user.name", "Trekoon Tests"]);
42
+ writeFileSync(join(workspace, "README.md"), "# test repo\n");
43
+ runGit(workspace, ["add", "README.md"]);
44
+ runGit(workspace, ["commit", "-m", "init repository"]);
45
+ }
46
+
47
+ function commitDatabase(workspace: string, subject: string): void {
48
+ runGit(workspace, ["add", ".trekoon/trekoon.db"]);
49
+ runGit(workspace, ["commit", "-m", subject]);
50
+ }
51
+
52
+ afterEach((): void => {
53
+ while (tempDirs.length > 0) {
54
+ const workspace: string | undefined = tempDirs.pop();
55
+ if (workspace) {
56
+ rmSync(workspace, { recursive: true, force: true });
57
+ }
58
+ }
59
+ });
60
+
61
+ describe("sync command", (): void => {
62
+ test("reports ahead/behind and resolves pull conflicts", async (): Promise<void> => {
63
+ const workspace: string = createWorkspace();
64
+ initializeRepository(workspace);
65
+
66
+ const epicId: string = randomUUID();
67
+
68
+ {
69
+ const storage = openTrekoonDatabase(workspace);
70
+ try {
71
+ storage.db
72
+ .query(
73
+ "INSERT INTO epics (id, title, description, status, created_at, updated_at, version) VALUES (?, ?, ?, ?, ?, ?, 1);",
74
+ )
75
+ .run(epicId, "Remote epic", "", "open", Date.now(), Date.now());
76
+
77
+ appendEventWithGitContext(storage.db, workspace, {
78
+ entityKind: "epic",
79
+ entityId: epicId,
80
+ operation: "upsert",
81
+ fields: {
82
+ title: "Remote epic",
83
+ description: "",
84
+ status: "open",
85
+ },
86
+ });
87
+ } finally {
88
+ storage.close();
89
+ }
90
+ }
91
+
92
+ commitDatabase(workspace, "store main tracker event");
93
+
94
+ runGit(workspace, ["checkout", "-b", "feature/sync"]);
95
+
96
+ {
97
+ const storage = openTrekoonDatabase(workspace);
98
+ try {
99
+ storage.db
100
+ .query("UPDATE epics SET title = ?, updated_at = ?, version = version + 1 WHERE id = ?;")
101
+ .run("Local epic", Date.now(), epicId);
102
+
103
+ appendEventWithGitContext(storage.db, workspace, {
104
+ entityKind: "epic",
105
+ entityId: epicId,
106
+ operation: "upsert",
107
+ fields: {
108
+ title: "Local epic",
109
+ },
110
+ });
111
+ } finally {
112
+ storage.close();
113
+ }
114
+ }
115
+
116
+ const statusBefore = await runSync({
117
+ args: ["status", "--from", "main"],
118
+ cwd: workspace,
119
+ mode: "toon",
120
+ });
121
+
122
+ expect(statusBefore.ok).toBe(true);
123
+ expect((statusBefore.data as { behind: number }).behind).toBeGreaterThan(0);
124
+ expect((statusBefore.data as { ahead: number }).ahead).toBeGreaterThan(0);
125
+
126
+ const pullResult = await runSync({
127
+ args: ["pull", "--from", "main"],
128
+ cwd: workspace,
129
+ mode: "toon",
130
+ });
131
+
132
+ expect(pullResult.ok).toBe(true);
133
+ expect((pullResult.data as { createdConflicts: number }).createdConflicts).toBe(1);
134
+
135
+ const storage = openTrekoonDatabase(workspace);
136
+ try {
137
+ const gitContext = storage.db
138
+ .query("SELECT branch_name, head_sha, worktree_path FROM git_context WHERE id = 'current';")
139
+ .get() as { branch_name: string | null; head_sha: string | null; worktree_path: string } | null;
140
+
141
+ expect(gitContext?.branch_name).toBe("feature/sync");
142
+ expect(typeof gitContext?.head_sha).toBe("string");
143
+ expect(gitContext?.worktree_path).toBe(workspace);
144
+
145
+ const pendingConflict = storage.db
146
+ .query("SELECT id FROM sync_conflicts WHERE resolution = 'pending' LIMIT 1;")
147
+ .get() as { id: string } | null;
148
+
149
+ expect(typeof pendingConflict?.id).toBe("string");
150
+
151
+ const resolveResult = await runSync({
152
+ args: ["resolve", pendingConflict!.id, "--use", "theirs"],
153
+ cwd: workspace,
154
+ mode: "toon",
155
+ });
156
+
157
+ expect(resolveResult.ok).toBe(true);
158
+
159
+ const resolved = storage.db
160
+ .query("SELECT resolution FROM sync_conflicts WHERE id = ?;")
161
+ .get(pendingConflict!.id) as { resolution: string } | null;
162
+ expect(resolved?.resolution).toBe("theirs");
163
+
164
+ const epic = storage.db.query("SELECT title FROM epics WHERE id = ?;").get(epicId) as { title: string } | null;
165
+ expect(epic?.title).toBe("Remote epic");
166
+
167
+ const resolutionEvent = storage.db
168
+ .query("SELECT operation, git_branch FROM events WHERE operation = 'resolve_conflict' LIMIT 1;")
169
+ .get() as { operation: string; git_branch: string | null } | null;
170
+ expect(resolutionEvent?.operation).toBe("resolve_conflict");
171
+ expect(resolutionEvent?.git_branch).toBe("feature/sync");
172
+ } finally {
173
+ storage.close();
174
+ }
175
+ });
176
+
177
+ test("returns usage errors for invalid input", async (): Promise<void> => {
178
+ const workspace: string = createWorkspace();
179
+ initializeRepository(workspace);
180
+
181
+ const missingFrom = await runSync({
182
+ args: ["pull"],
183
+ cwd: workspace,
184
+ mode: "human",
185
+ });
186
+
187
+ expect(missingFrom.ok).toBe(false);
188
+ expect(missingFrom.error?.code).toBe("invalid_args");
189
+
190
+ const badResolution = await runSync({
191
+ args: ["resolve", "123", "--use", "bad"],
192
+ cwd: workspace,
193
+ mode: "human",
194
+ });
195
+
196
+ expect(badResolution.ok).toBe(false);
197
+ expect(badResolution.error?.code).toBe("invalid_args");
198
+ });
199
+ });
@@ -0,0 +1,474 @@
1
+ import { mkdtempSync, rmSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ import { afterEach, describe, expect, test } from "bun:test";
6
+
7
+ import { runEpic } from "../../src/commands/epic";
8
+ import { runSubtask } from "../../src/commands/subtask";
9
+ import { runTask } from "../../src/commands/task";
10
+
11
+ const tempDirs: string[] = [];
12
+
13
+ function createWorkspace(): string {
14
+ const workspace = mkdtempSync(join(tmpdir(), "trekoon-task-"));
15
+ tempDirs.push(workspace);
16
+ return workspace;
17
+ }
18
+
19
+ afterEach((): void => {
20
+ while (tempDirs.length > 0) {
21
+ const next = tempDirs.pop();
22
+ if (next) {
23
+ rmSync(next, { recursive: true, force: true });
24
+ }
25
+ }
26
+ });
27
+
28
+ describe("task command", (): void => {
29
+ test("requires description on create", async (): Promise<void> => {
30
+ const cwd = createWorkspace();
31
+ const epicCreated = await runEpic({
32
+ cwd,
33
+ mode: "human",
34
+ args: ["create", "--title", "Roadmap", "--description", "desc"],
35
+ });
36
+ const epicId = (epicCreated.data as { epic: { id: string } }).epic.id;
37
+
38
+ const result = await runTask({
39
+ cwd,
40
+ mode: "human",
41
+ args: ["create", "--epic", epicId, "--title", "Implement"],
42
+ });
43
+
44
+ expect(result.ok).toBeFalse();
45
+ expect(result.error?.code).toBe("invalid_input");
46
+ });
47
+
48
+ test("supports create/show/update/delete lifecycle", async (): Promise<void> => {
49
+ const cwd = createWorkspace();
50
+ const epicCreated = await runEpic({
51
+ cwd,
52
+ mode: "human",
53
+ args: ["create", "--title", "Roadmap", "--description", "desc"],
54
+ });
55
+ const epicId = (epicCreated.data as { epic: { id: string } }).epic.id;
56
+
57
+ const created = await runTask({
58
+ cwd,
59
+ mode: "human",
60
+ args: ["create", "--epic", epicId, "--title", "Implement", "--description", "build it", "--status", "custom"],
61
+ });
62
+ expect(created.ok).toBeTrue();
63
+ const taskId = (created.data as { task: { id: string; status: string } }).task.id;
64
+ expect((created.data as { task: { status: string } }).task.status).toBe("custom");
65
+
66
+ const shown = await runTask({ cwd, mode: "human", args: ["show", taskId] });
67
+ expect(shown.ok).toBeTrue();
68
+
69
+ const updated = await runTask({ cwd, mode: "human", args: ["update", taskId, "--status", "in-progress"] });
70
+ expect(updated.ok).toBeTrue();
71
+ expect((updated.data as { task: { status: string } }).task.status).toBe("in-progress");
72
+
73
+ const removed = await runTask({ cwd, mode: "human", args: ["delete", taskId] });
74
+ expect(removed.ok).toBeTrue();
75
+
76
+ const afterDelete = await runTask({ cwd, mode: "human", args: ["show", taskId] });
77
+ expect(afterDelete.ok).toBeFalse();
78
+ expect(afterDelete.error?.code).toBe("not_found");
79
+ });
80
+
81
+ test("task show --all returns subtasks with descriptions", async (): Promise<void> => {
82
+ const cwd = createWorkspace();
83
+ const epicCreated = await runEpic({
84
+ cwd,
85
+ mode: "human",
86
+ args: ["create", "--title", "Roadmap", "--description", "desc"],
87
+ });
88
+ const epicId = (epicCreated.data as { epic: { id: string } }).epic.id;
89
+
90
+ const createdTask = await runTask({
91
+ cwd,
92
+ mode: "human",
93
+ args: ["create", "--epic", epicId, "--title", "Implement", "--description", "build it"],
94
+ });
95
+ const taskId = (createdTask.data as { task: { id: string } }).task.id;
96
+
97
+ await runSubtask({
98
+ cwd,
99
+ mode: "human",
100
+ args: ["create", "--task", taskId, "--title", "Do part A", "--description", "subtask details"],
101
+ });
102
+
103
+ const shown = await runTask({ cwd, mode: "toon", args: ["show", taskId, "--all"] });
104
+ expect(shown.ok).toBeTrue();
105
+
106
+ const task = (shown.data as { task: { description: string; subtasks: Array<{ description: string }> } }).task;
107
+ expect(task.description).toBe("build it");
108
+ expect(task.subtasks.length).toBe(1);
109
+ expect(task.subtasks[0]?.description).toBe("subtask details");
110
+ expect((shown.data as { subtasksCount: number }).subtasksCount).toBe(1);
111
+ });
112
+
113
+ test("show defaults to tree in machine mode", async (): Promise<void> => {
114
+ const cwd = createWorkspace();
115
+ const epicCreated = await runEpic({
116
+ cwd,
117
+ mode: "human",
118
+ args: ["create", "--title", "Roadmap", "--description", "desc"],
119
+ });
120
+ const epicId = (epicCreated.data as { epic: { id: string } }).epic.id;
121
+
122
+ const createdTask = await runTask({
123
+ cwd,
124
+ mode: "human",
125
+ args: ["create", "--epic", epicId, "--title", "Implement", "--description", "build it"],
126
+ });
127
+ const taskId = (createdTask.data as { task: { id: string } }).task.id;
128
+
129
+ await runSubtask({
130
+ cwd,
131
+ mode: "human",
132
+ args: ["create", "--task", taskId, "--title", "Do part A", "--description", "subtask details"],
133
+ });
134
+
135
+ const shown = await runTask({ cwd, mode: "toon", args: ["show", taskId] });
136
+ expect(shown.ok).toBeTrue();
137
+ expect(shown.human).toContain("subtask");
138
+ expect(shown.human).not.toContain("desc=");
139
+ });
140
+
141
+ test("list defaults to table and show supports table view", async (): Promise<void> => {
142
+ const cwd = createWorkspace();
143
+ const epicCreated = await runEpic({
144
+ cwd,
145
+ mode: "human",
146
+ args: ["create", "--title", "Roadmap", "--description", "desc"],
147
+ });
148
+ const epicId = (epicCreated.data as { epic: { id: string } }).epic.id;
149
+
150
+ const created = await runTask({
151
+ cwd,
152
+ mode: "human",
153
+ args: ["create", "--epic", epicId, "--title", "Implement", "--description", "build it"],
154
+ });
155
+ const taskId = (created.data as { task: { id: string } }).task.id;
156
+
157
+ const listed = await runTask({ cwd, mode: "human", args: ["list"] });
158
+ expect(listed.ok).toBeTrue();
159
+ expect(listed.human).toContain("ID");
160
+ expect(listed.human).toContain("EPIC");
161
+ expect(listed.human).toContain("TITLE");
162
+
163
+ const shown = await runTask({ cwd, mode: "human", args: ["show", taskId, "--view", "table"] });
164
+ expect(shown.ok).toBeTrue();
165
+ expect(shown.human).toContain("TASK");
166
+ expect(shown.human).toContain("SUBTASKS");
167
+ expect(shown.human).toContain("DESCRIPTION");
168
+ });
169
+
170
+ test("default list returns only open statuses and max 10", async (): Promise<void> => {
171
+ const cwd = createWorkspace();
172
+ const epicCreated = await runEpic({
173
+ cwd,
174
+ mode: "human",
175
+ args: ["create", "--title", "Roadmap", "--description", "desc"],
176
+ });
177
+ const epicId = (epicCreated.data as { epic: { id: string } }).epic.id;
178
+
179
+ for (let index = 0; index < 9; index += 1) {
180
+ await runTask({
181
+ cwd,
182
+ mode: "human",
183
+ args: ["create", "--epic", epicId, "--title", `Todo ${index}`, "--description", "todo", "--status", "todo"],
184
+ });
185
+ }
186
+
187
+ await runTask({
188
+ cwd,
189
+ mode: "human",
190
+ args: ["create", "--epic", epicId, "--title", "In progress", "--description", "active", "--status", "in_progress"],
191
+ });
192
+
193
+ await runTask({
194
+ cwd,
195
+ mode: "human",
196
+ args: ["create", "--epic", epicId, "--title", "In-progress", "--description", "active", "--status", "in-progress"],
197
+ });
198
+
199
+ for (let index = 0; index < 5; index += 1) {
200
+ await runTask({
201
+ cwd,
202
+ mode: "human",
203
+ args: ["create", "--epic", epicId, "--title", `Done ${index}`, "--description", "done", "--status", "done"],
204
+ });
205
+ }
206
+
207
+ const listed = await runTask({ cwd, mode: "human", args: ["list"] });
208
+ expect(listed.ok).toBeTrue();
209
+
210
+ const tasks = (listed.data as { tasks: Array<{ status: string }> }).tasks;
211
+ expect(tasks.length).toBe(10);
212
+ expect(tasks.every((task) => task.status === "in_progress" || task.status === "in-progress" || task.status === "todo")).toBeTrue();
213
+ });
214
+
215
+ test("default ordering puts in_progress and in-progress before todo", async (): Promise<void> => {
216
+ const cwd = createWorkspace();
217
+ const epicCreated = await runEpic({
218
+ cwd,
219
+ mode: "human",
220
+ args: ["create", "--title", "Roadmap", "--description", "desc"],
221
+ });
222
+ const epicId = (epicCreated.data as { epic: { id: string } }).epic.id;
223
+
224
+ await runTask({
225
+ cwd,
226
+ mode: "human",
227
+ args: ["create", "--epic", epicId, "--title", "Todo", "--description", "todo", "--status", "todo"],
228
+ });
229
+ await runTask({
230
+ cwd,
231
+ mode: "human",
232
+ args: ["create", "--epic", epicId, "--title", "In-progress", "--description", "active", "--status", "in-progress"],
233
+ });
234
+ await runTask({
235
+ cwd,
236
+ mode: "human",
237
+ args: ["create", "--epic", epicId, "--title", "In progress", "--description", "active", "--status", "in_progress"],
238
+ });
239
+
240
+ const listed = await runTask({ cwd, mode: "human", args: ["list"] });
241
+ expect(listed.ok).toBeTrue();
242
+
243
+ const statuses = (listed.data as { tasks: Array<{ status: string }> }).tasks.map((task) => task.status);
244
+ expect(statuses).toEqual(["in-progress", "in_progress", "todo"]);
245
+ });
246
+
247
+ test("list --status done returns done items", async (): Promise<void> => {
248
+ const cwd = createWorkspace();
249
+ const epicCreated = await runEpic({
250
+ cwd,
251
+ mode: "human",
252
+ args: ["create", "--title", "Roadmap", "--description", "desc"],
253
+ });
254
+ const epicId = (epicCreated.data as { epic: { id: string } }).epic.id;
255
+
256
+ await runTask({
257
+ cwd,
258
+ mode: "human",
259
+ args: ["create", "--epic", epicId, "--title", "Done", "--description", "done", "--status", "done"],
260
+ });
261
+ await runTask({
262
+ cwd,
263
+ mode: "human",
264
+ args: ["create", "--epic", epicId, "--title", "Todo", "--description", "todo", "--status", "todo"],
265
+ });
266
+
267
+ const listed = await runTask({ cwd, mode: "human", args: ["list", "--status", "done"] });
268
+ expect(listed.ok).toBeTrue();
269
+
270
+ const statuses = (listed.data as { tasks: Array<{ status: string }> }).tasks.map((task) => task.status);
271
+ expect(statuses).toEqual(["done"]);
272
+ });
273
+
274
+ test("list --all includes done items and bypasses default limit", async (): Promise<void> => {
275
+ const cwd = createWorkspace();
276
+ const epicCreated = await runEpic({
277
+ cwd,
278
+ mode: "human",
279
+ args: ["create", "--title", "Roadmap", "--description", "desc"],
280
+ });
281
+ const epicId = (epicCreated.data as { epic: { id: string } }).epic.id;
282
+
283
+ for (let index = 0; index < 12; index += 1) {
284
+ await runTask({
285
+ cwd,
286
+ mode: "human",
287
+ args: [
288
+ "create",
289
+ "--epic",
290
+ epicId,
291
+ "--title",
292
+ `Task ${index}`,
293
+ "--description",
294
+ "desc",
295
+ "--status",
296
+ index % 2 === 0 ? "done" : "todo",
297
+ ],
298
+ });
299
+ }
300
+
301
+ const listed = await runTask({ cwd, mode: "human", args: ["list", "--all"] });
302
+ expect(listed.ok).toBeTrue();
303
+
304
+ const tasks = (listed.data as { tasks: Array<{ status: string }> }).tasks;
305
+ expect(tasks.length).toBe(12);
306
+ expect(tasks.some((task) => task.status === "done")).toBeTrue();
307
+ });
308
+
309
+ test("list rejects --all with --status", async (): Promise<void> => {
310
+ const cwd = createWorkspace();
311
+ const result = await runTask({ cwd, mode: "human", args: ["list", "--all", "--status", "done"] });
312
+
313
+ expect(result.ok).toBeFalse();
314
+ expect(result.error?.code).toBe("invalid_input");
315
+ });
316
+
317
+ test("list rejects --all with --limit", async (): Promise<void> => {
318
+ const cwd = createWorkspace();
319
+ const result = await runTask({ cwd, mode: "human", args: ["list", "--all", "--limit", "5"] });
320
+
321
+ expect(result.ok).toBeFalse();
322
+ expect(result.error?.code).toBe("invalid_input");
323
+ });
324
+
325
+ test("list rejects invalid --limit values", async (): Promise<void> => {
326
+ const cwd = createWorkspace();
327
+
328
+ const zeroLimit = await runTask({ cwd, mode: "human", args: ["list", "--limit", "0"] });
329
+ expect(zeroLimit.ok).toBeFalse();
330
+ expect(zeroLimit.error?.code).toBe("invalid_input");
331
+
332
+ const nonNumericLimit = await runTask({ cwd, mode: "human", args: ["list", "--limit", "abc"] });
333
+ expect(nonNumericLimit.ok).toBeFalse();
334
+ expect(nonNumericLimit.error?.code).toBe("invalid_input");
335
+
336
+ const leadingZeroLimit = await runTask({ cwd, mode: "human", args: ["list", "--limit", "01"] });
337
+ expect(leadingZeroLimit.ok).toBeFalse();
338
+ expect(leadingZeroLimit.error?.code).toBe("invalid_input");
339
+ });
340
+
341
+ test("errors when value-required task options are missing values", async (): Promise<void> => {
342
+ const cwd = createWorkspace();
343
+ const epicCreated = await runEpic({
344
+ cwd,
345
+ mode: "human",
346
+ args: ["create", "--title", "Roadmap", "--description", "desc"],
347
+ });
348
+ const epicId = (epicCreated.data as { epic: { id: string } }).epic.id;
349
+ const taskCreated = await runTask({
350
+ cwd,
351
+ mode: "human",
352
+ args: ["create", "--epic", epicId, "--title", "Implement", "--description", "build it"],
353
+ });
354
+ const taskId = (taskCreated.data as { task: { id: string } }).task.id;
355
+
356
+ const missingEpic = await runTask({ cwd, mode: "human", args: ["create", "--epic", "--title", "Implement", "--description", "x"] });
357
+ expect(missingEpic.ok).toBeFalse();
358
+ expect(missingEpic.error?.code).toBe("invalid_input");
359
+ expect((missingEpic.data as { option: string }).option).toBe("epic");
360
+
361
+ const missingLimit = await runTask({ cwd, mode: "human", args: ["list", "--limit"] });
362
+ expect(missingLimit.ok).toBeFalse();
363
+ expect(missingLimit.error?.code).toBe("invalid_input");
364
+ expect((missingLimit.data as { option: string }).option).toBe("limit");
365
+
366
+ const missingView = await runTask({ cwd, mode: "human", args: ["show", taskId, "--view"] });
367
+ expect(missingView.ok).toBeFalse();
368
+ expect(missingView.error?.code).toBe("invalid_input");
369
+ expect((missingView.data as { option: string }).option).toBe("view");
370
+
371
+ const missingIds = await runTask({ cwd, mode: "human", args: ["update", "--ids", "--append", "note"] });
372
+ expect(missingIds.ok).toBeFalse();
373
+ expect(missingIds.error?.code).toBe("invalid_input");
374
+ expect((missingIds.data as { option: string }).option).toBe("ids");
375
+ });
376
+
377
+ test("show returns helpful error when id is an epic", async (): Promise<void> => {
378
+ const cwd = createWorkspace();
379
+ const epicCreated = await runEpic({
380
+ cwd,
381
+ mode: "human",
382
+ args: ["create", "--title", "Roadmap", "--description", "desc"],
383
+ });
384
+ const epicId = (epicCreated.data as { epic: { id: string } }).epic.id;
385
+
386
+ const shown = await runTask({ cwd, mode: "human", args: ["show", epicId] });
387
+ expect(shown.ok).toBeFalse();
388
+ expect(shown.error?.code).toBe("wrong_entity_type");
389
+ expect(shown.human).toContain("trekoon epic show");
390
+ });
391
+
392
+ test("task show --all exposes zero subtask count", async (): Promise<void> => {
393
+ const cwd = createWorkspace();
394
+ const epicCreated = await runEpic({
395
+ cwd,
396
+ mode: "human",
397
+ args: ["create", "--title", "Roadmap", "--description", "desc"],
398
+ });
399
+ const epicId = (epicCreated.data as { epic: { id: string } }).epic.id;
400
+
401
+ const createdTask = await runTask({
402
+ cwd,
403
+ mode: "human",
404
+ args: ["create", "--epic", epicId, "--title", "Implement", "--description", "build it"],
405
+ });
406
+ const taskId = (createdTask.data as { task: { id: string } }).task.id;
407
+
408
+ const shown = await runTask({ cwd, mode: "toon", args: ["show", taskId, "--all"] });
409
+ expect(shown.ok).toBeTrue();
410
+ expect((shown.data as { subtasksCount: number }).subtasksCount).toBe(0);
411
+ });
412
+
413
+ test("bulk update supports --ids with --append and --status", async (): Promise<void> => {
414
+ const cwd = createWorkspace();
415
+ const epicCreated = await runEpic({
416
+ cwd,
417
+ mode: "human",
418
+ args: ["create", "--title", "Roadmap", "--description", "desc"],
419
+ });
420
+ const epicId = (epicCreated.data as { epic: { id: string } }).epic.id;
421
+
422
+ const first = await runTask({
423
+ cwd,
424
+ mode: "human",
425
+ args: ["create", "--epic", epicId, "--title", "Implement A", "--description", "build it"],
426
+ });
427
+ const second = await runTask({
428
+ cwd,
429
+ mode: "human",
430
+ args: ["create", "--epic", epicId, "--title", "Implement B", "--description", "ship it"],
431
+ });
432
+ const firstId = (first.data as { task: { id: string } }).task.id;
433
+ const secondId = (second.data as { task: { id: string } }).task.id;
434
+
435
+ const updated = await runTask({
436
+ cwd,
437
+ mode: "human",
438
+ args: ["update", "--ids", `${firstId},${secondId}`, "--append", "follow policy", "--status", "blocked"],
439
+ });
440
+
441
+ expect(updated.ok).toBeTrue();
442
+ expect((updated.data as { ids: string[] }).ids).toEqual([firstId, secondId]);
443
+ const tasks = (updated.data as { tasks: Array<{ description: string; status: string }> }).tasks;
444
+ expect(tasks[0]?.description).toContain("follow policy");
445
+ expect(tasks[1]?.description).toContain("follow policy");
446
+ expect(tasks[0]?.status).toBe("blocked");
447
+ expect(tasks[1]?.status).toBe("blocked");
448
+ });
449
+
450
+ test("bulk update rejects --all with --ids", async (): Promise<void> => {
451
+ const cwd = createWorkspace();
452
+ const epicCreated = await runEpic({
453
+ cwd,
454
+ mode: "human",
455
+ args: ["create", "--title", "Roadmap", "--description", "desc"],
456
+ });
457
+ const epicId = (epicCreated.data as { epic: { id: string } }).epic.id;
458
+ const created = await runTask({
459
+ cwd,
460
+ mode: "human",
461
+ args: ["create", "--epic", epicId, "--title", "Implement", "--description", "build it"],
462
+ });
463
+ const taskId = (created.data as { task: { id: string } }).task.id;
464
+
465
+ const result = await runTask({
466
+ cwd,
467
+ mode: "human",
468
+ args: ["update", "--all", "--ids", taskId, "--append", "follow policy"],
469
+ });
470
+
471
+ expect(result.ok).toBeFalse();
472
+ expect(result.error?.code).toBe("invalid_input");
473
+ });
474
+ });