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,279 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { existsSync, 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 { runInit } from "../../src/commands/init";
9
+ import { runSync } from "../../src/commands/sync";
10
+ import { runWipe } from "../../src/commands/wipe";
11
+ import { okResult, toToonEnvelope } from "../../src/io/output";
12
+ import { appendEventWithGitContext } from "../../src/sync/event-writes";
13
+ import { openTrekoonDatabase } from "../../src/storage/database";
14
+ import { resolveStoragePaths } from "../../src/storage/path";
15
+
16
+ const tempDirs: string[] = [];
17
+
18
+ function createWorkspace(): string {
19
+ const workspace: string = mkdtempSync(join(tmpdir(), "trekoon-integration-"));
20
+ tempDirs.push(workspace);
21
+ return workspace;
22
+ }
23
+
24
+ function runGit(cwd: string, args: readonly string[]): string {
25
+ const command = Bun.spawnSync({
26
+ cmd: ["git", ...args],
27
+ cwd,
28
+ stdout: "pipe",
29
+ stderr: "pipe",
30
+ });
31
+
32
+ const stdout: string = new TextDecoder().decode(command.stdout).trim();
33
+ const stderr: string = new TextDecoder().decode(command.stderr).trim();
34
+
35
+ if (command.exitCode !== 0) {
36
+ throw new Error(`git ${args.join(" ")} failed: ${stderr}`);
37
+ }
38
+
39
+ return stdout;
40
+ }
41
+
42
+ function seedRepository(workspace: string): void {
43
+ runGit(workspace, ["init", "-b", "main"]);
44
+ runGit(workspace, ["config", "user.email", "tests@trekoon.local"]);
45
+ runGit(workspace, ["config", "user.name", "Trekoon Tests"]);
46
+ writeFileSync(join(workspace, "README.md"), "# integration repo\n");
47
+ runGit(workspace, ["add", "README.md"]);
48
+ runGit(workspace, ["commit", "-m", "seed repo"]);
49
+ }
50
+
51
+ function commitDb(workspace: string, message: string): void {
52
+ runGit(workspace, ["add", ".trekoon/trekoon.db"]);
53
+ runGit(workspace, ["commit", "-m", message]);
54
+ }
55
+
56
+ interface WorkflowIds {
57
+ readonly epicId: string;
58
+ readonly taskId: string;
59
+ readonly subtaskId: string;
60
+ readonly depId: string;
61
+ }
62
+
63
+ function seedTrackerRows(workspace: string): WorkflowIds {
64
+ const epicId: string = randomUUID();
65
+ const taskId: string = randomUUID();
66
+ const subtaskId: string = randomUUID();
67
+ const depId: string = randomUUID();
68
+ const now: number = Date.now();
69
+
70
+ const storage = openTrekoonDatabase(workspace);
71
+ try {
72
+ storage.db
73
+ .query("INSERT INTO epics (id, title, description, status, created_at, updated_at, version) VALUES (?, ?, ?, ?, ?, ?, 1);")
74
+ .run(epicId, "Sync epic", "integration", "open", now, now);
75
+
76
+ storage.db
77
+ .query(
78
+ "INSERT INTO tasks (id, epic_id, title, description, status, created_at, updated_at, version) VALUES (?, ?, ?, ?, ?, ?, ?, 1);",
79
+ )
80
+ .run(taskId, epicId, "Sync task", "integration", "open", now, now);
81
+
82
+ storage.db
83
+ .query(
84
+ "INSERT INTO subtasks (id, task_id, title, description, status, created_at, updated_at, version) VALUES (?, ?, ?, ?, ?, ?, ?, 1);",
85
+ )
86
+ .run(subtaskId, taskId, "Sync subtask", "integration", "open", now, now);
87
+
88
+ storage.db
89
+ .query(
90
+ "INSERT INTO dependencies (id, source_id, source_kind, depends_on_id, depends_on_kind, created_at, updated_at, version) VALUES (?, ?, ?, ?, ?, ?, ?, 1);",
91
+ )
92
+ .run(depId, taskId, "task", subtaskId, "subtask", now, now);
93
+
94
+ appendEventWithGitContext(storage.db, workspace, {
95
+ entityKind: "epic",
96
+ entityId: epicId,
97
+ operation: "upsert",
98
+ fields: {
99
+ title: "Sync epic",
100
+ description: "integration",
101
+ status: "open",
102
+ },
103
+ });
104
+
105
+ appendEventWithGitContext(storage.db, workspace, {
106
+ entityKind: "task",
107
+ entityId: taskId,
108
+ operation: "upsert",
109
+ fields: {
110
+ epic_id: epicId,
111
+ title: "Sync task",
112
+ description: "integration",
113
+ status: "open",
114
+ },
115
+ });
116
+
117
+ appendEventWithGitContext(storage.db, workspace, {
118
+ entityKind: "subtask",
119
+ entityId: subtaskId,
120
+ operation: "upsert",
121
+ fields: {
122
+ task_id: taskId,
123
+ title: "Sync subtask",
124
+ description: "integration",
125
+ status: "open",
126
+ },
127
+ });
128
+ } finally {
129
+ storage.close();
130
+ }
131
+
132
+ return {
133
+ epicId,
134
+ taskId,
135
+ subtaskId,
136
+ depId,
137
+ };
138
+ }
139
+
140
+ afterEach((): void => {
141
+ while (tempDirs.length > 0) {
142
+ const workspace: string | undefined = tempDirs.pop();
143
+ if (workspace) {
144
+ rmSync(workspace, { recursive: true, force: true });
145
+ }
146
+ }
147
+ });
148
+
149
+ describe("integration sync workflow", (): void => {
150
+ test("init -> entity flow -> sync -> wipe", async (): Promise<void> => {
151
+ const workspace: string = createWorkspace();
152
+ seedRepository(workspace);
153
+
154
+ const initResult = await runInit({
155
+ args: [],
156
+ cwd: workspace,
157
+ mode: "human",
158
+ });
159
+ expect(initResult.ok).toBe(true);
160
+
161
+ const ids = seedTrackerRows(workspace);
162
+ commitDb(workspace, "add main tracker records");
163
+
164
+ runGit(workspace, ["checkout", "-b", "feature/integration-sync"]);
165
+
166
+ {
167
+ const storage = openTrekoonDatabase(workspace);
168
+ try {
169
+ storage.db
170
+ .query("UPDATE epics SET title = ?, updated_at = ?, version = version + 1 WHERE id = ?;")
171
+ .run("Local sync epic", Date.now(), ids.epicId);
172
+
173
+ appendEventWithGitContext(storage.db, workspace, {
174
+ entityKind: "epic",
175
+ entityId: ids.epicId,
176
+ operation: "upsert",
177
+ fields: { title: "Local sync epic" },
178
+ });
179
+ } finally {
180
+ storage.close();
181
+ }
182
+ }
183
+
184
+ const epicStorage = openTrekoonDatabase(workspace);
185
+ try {
186
+ const epic = epicStorage.db.query("SELECT id, title, status FROM epics WHERE id = ?;").get(ids.epicId) as {
187
+ id: string;
188
+ title: string;
189
+ status: string;
190
+ };
191
+ const task = epicStorage.db.query("SELECT id, title, status FROM tasks WHERE id = ?;").get(ids.taskId) as {
192
+ id: string;
193
+ title: string;
194
+ status: string;
195
+ };
196
+ const subtask = epicStorage.db
197
+ .query("SELECT id, title, status FROM subtasks WHERE id = ?;")
198
+ .get(ids.subtaskId) as { id: string; title: string; status: string };
199
+
200
+ const toon = toToonEnvelope(
201
+ okResult({
202
+ command: "epic show",
203
+ human: "integration",
204
+ data: {
205
+ tree: {
206
+ id: epic.id,
207
+ title: epic.title,
208
+ status: epic.status,
209
+ tasks: [
210
+ {
211
+ id: task.id,
212
+ title: task.title,
213
+ status: task.status,
214
+ subtasks: [
215
+ {
216
+ id: subtask.id,
217
+ title: subtask.title,
218
+ status: subtask.status,
219
+ },
220
+ ],
221
+ },
222
+ ],
223
+ },
224
+ },
225
+ }),
226
+ );
227
+
228
+ expect(toon.ok).toBe(true);
229
+ expect((toon.data as { tree: { tasks: Array<{ subtasks: Array<{ id: string }> }> } }).tree.tasks[0]?.subtasks[0]?.id).toBe(
230
+ ids.subtaskId,
231
+ );
232
+ } finally {
233
+ epicStorage.close();
234
+ }
235
+
236
+ const statusResult = await runSync({
237
+ args: ["status", "--from", "main"],
238
+ cwd: workspace,
239
+ mode: "toon",
240
+ });
241
+ expect(statusResult.ok).toBe(true);
242
+
243
+ const pullResult = await runSync({
244
+ args: ["pull", "--from", "main"],
245
+ cwd: workspace,
246
+ mode: "toon",
247
+ });
248
+ expect(pullResult.ok).toBe(true);
249
+ expect((pullResult.data as { createdConflicts: number }).createdConflicts).toBe(1);
250
+
251
+ const conflictStorage = openTrekoonDatabase(workspace);
252
+ try {
253
+ const conflict = conflictStorage.db
254
+ .query("SELECT id FROM sync_conflicts WHERE resolution = 'pending' LIMIT 1;")
255
+ .get() as { id: string } | null;
256
+
257
+ expect(conflict?.id).toBeDefined();
258
+
259
+ const resolveResult = await runSync({
260
+ args: ["resolve", conflict!.id, "--use", "theirs"],
261
+ cwd: workspace,
262
+ mode: "toon",
263
+ });
264
+ expect(resolveResult.ok).toBe(true);
265
+ } finally {
266
+ conflictStorage.close();
267
+ }
268
+
269
+ const wipeResult = await runWipe({
270
+ args: ["--yes"],
271
+ cwd: workspace,
272
+ mode: "human",
273
+ });
274
+ expect(wipeResult.ok).toBe(true);
275
+
276
+ const storagePaths = resolveStoragePaths(workspace);
277
+ expect(existsSync(storagePaths.storageDir)).toBe(false);
278
+ });
279
+ });
@@ -0,0 +1,81 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { formatHumanTable } from "../../src/io/human-table";
4
+
5
+ describe("formatHumanTable", (): void => {
6
+ test("renders a standard table", (): void => {
7
+ const output = formatHumanTable(
8
+ ["ID", "TITLE", "STATUS"],
9
+ [["a1b2c3d4", "Short title", "todo"]],
10
+ { maxWidth: 120, wrapColumns: [1] },
11
+ );
12
+
13
+ expect(output).toContain("ID");
14
+ expect(output).toContain("TITLE");
15
+ expect(output).toContain("Short title");
16
+ });
17
+
18
+ test("wraps long text into multiline rows", (): void => {
19
+ const output = formatHumanTable(
20
+ ["ID", "TITLE", "DESCRIPTION"],
21
+ [
22
+ [
23
+ "0bcc31b2",
24
+ "Review branch changes",
25
+ "Review all commits and diffs in feature/image-upload-gitlab, provide feedback, and confirm merge readiness.",
26
+ ],
27
+ ],
28
+ { maxWidth: 72, wrapColumns: [1, 2] },
29
+ );
30
+
31
+ const lines = output.split("\n");
32
+ expect(lines.length).toBeGreaterThan(4);
33
+ expect(output).toContain("merge readiness.");
34
+ expect(output).toContain("Review");
35
+ expect(output).toContain("branch");
36
+ });
37
+
38
+ test("keeps non-wrap columns single-line on narrow widths", (): void => {
39
+ const output = formatHumanTable(
40
+ ["ID", "TITLE", "STATUS"],
41
+ [["TASK-2026-000001", "This title should wrap on a narrow terminal width", "in_progress"]],
42
+ { maxWidth: 30, wrapColumns: [1] },
43
+ );
44
+
45
+ const lines = output.split("\n");
46
+ expect(lines.length).toBeGreaterThan(4);
47
+ expect(output).toContain("TASK-2026-000001");
48
+ expect(output).toContain("in_progress");
49
+
50
+ const dataLines = lines.slice(2);
51
+ const wrappedTitleLines = dataLines
52
+ .map((line) => line.split(" | "))
53
+ .filter((parts) => parts.length === 3 && parts[0]?.trim() === "" && parts[2]?.trim() === "")
54
+ .map((parts) => parts[1]?.trim() ?? "")
55
+ .filter((value) => value.length > 0);
56
+
57
+ expect(wrappedTitleLines.length).toBeGreaterThan(0);
58
+ });
59
+
60
+ test("does not wrap non-configured columns", (): void => {
61
+ const output = formatHumanTable(
62
+ ["ID", "TYPE", "SUMMARY"],
63
+ [["a1b2c3d4", "feature_request", "Add export support for markdown notes from the timeline view"]],
64
+ { maxWidth: 32, wrapColumns: [2] },
65
+ );
66
+
67
+ const rowLines = output.split("\n").slice(2);
68
+ expect(rowLines[0]).toContain("a1b2c3d4");
69
+ expect(rowLines[0]).toContain("feature_request");
70
+ expect(rowLines.some((line) => line.includes("feature_request"))).toBe(true);
71
+ expect(rowLines.filter((line) => line.includes("feature_request")).length).toBe(1);
72
+
73
+ const wrappedSummaryLines = rowLines
74
+ .map((line) => line.split(" | "))
75
+ .filter((parts) => parts.length === 3 && parts[0]?.trim() === "" && parts[1]?.trim() === "")
76
+ .map((parts) => parts[2]?.trim() ?? "")
77
+ .filter((value) => value.length > 0);
78
+
79
+ expect(wrappedSummaryLines.length).toBeGreaterThan(0);
80
+ });
81
+ });
@@ -0,0 +1,54 @@
1
+ import { decode } from "@toon-format/toon";
2
+ import { describe, expect, test } from "bun:test";
3
+
4
+ import { okResult, renderResult, toToonEnvelope } from "../../src/io/output";
5
+ import { parseInvocation } from "../../src/runtime/cli-shell";
6
+
7
+ describe("output mode parsing", (): void => {
8
+ test("TTY default => human", (): void => {
9
+ const parsed = parseInvocation(["quickstart"], { stdoutIsTTY: true });
10
+ expect(parsed.mode).toBe("human");
11
+ });
12
+
13
+ test("non-TTY default => json", (): void => {
14
+ const parsed = parseInvocation(["quickstart"], { stdoutIsTTY: false });
15
+ expect(parsed.mode).toBe("json");
16
+ });
17
+
18
+ test("explicit --toon wins", (): void => {
19
+ const parsed = parseInvocation(["quickstart", "--toon"], { stdoutIsTTY: false });
20
+ expect(parsed.mode).toBe("toon");
21
+ });
22
+
23
+ test("explicit --json wins", (): void => {
24
+ const parsed = parseInvocation(["quickstart", "--json"], { stdoutIsTTY: true });
25
+ expect(parsed.mode).toBe("json");
26
+ });
27
+ });
28
+
29
+ describe("output rendering", (): void => {
30
+ test("renders JSON envelope with --json mode", (): void => {
31
+ const result = okResult({
32
+ command: "quickstart",
33
+ human: "Trekoon quickstart",
34
+ data: { examples: ["trekoon quickstart --json"] },
35
+ });
36
+
37
+ const jsonOutput = renderResult(result, "json");
38
+ expect(JSON.parse(jsonOutput)).toEqual(toToonEnvelope(result));
39
+ });
40
+
41
+ test("renders TOON output with --toon mode", (): void => {
42
+ const result = okResult({
43
+ command: "sync.status",
44
+ human: "sync human",
45
+ data: { branch: "main", conflicts: [] },
46
+ });
47
+
48
+ const toonOutput = renderResult(result, "toon");
49
+ const jsonOutput = renderResult(result, "json");
50
+
51
+ expect(toonOutput).not.toBe(jsonOutput);
52
+ expect(decode(toonOutput) as unknown).toEqual(toToonEnvelope(result));
53
+ });
54
+ });
@@ -0,0 +1,91 @@
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 { openTrekoonDatabase } from "../../src/storage/database";
8
+
9
+ const tempDirs: string[] = [];
10
+
11
+ function createWorkspace(): string {
12
+ const workspace: string = mkdtempSync(join(tmpdir(), "trekoon-storage-"));
13
+ tempDirs.push(workspace);
14
+ return workspace;
15
+ }
16
+
17
+ afterEach((): void => {
18
+ while (tempDirs.length > 0) {
19
+ const next: string | undefined = tempDirs.pop();
20
+ if (next) {
21
+ rmSync(next, { recursive: true, force: true });
22
+ }
23
+ }
24
+ });
25
+
26
+ function tableColumns(db: ReturnType<typeof openTrekoonDatabase>["db"], tableName: string): string[] {
27
+ const rows = db.query(`PRAGMA table_info(${tableName});`).all() as Array<{ name: string }>;
28
+ return rows.map((row) => row.name);
29
+ }
30
+
31
+ describe("storage lifecycle", (): void => {
32
+ test("creates .trekoon database in current workspace", (): void => {
33
+ const workspace: string = createWorkspace();
34
+ const storage = openTrekoonDatabase(workspace);
35
+
36
+ try {
37
+ expect(storage.paths.databaseFile).toBe(join(workspace, ".trekoon", "trekoon.db"));
38
+
39
+ const epicsTable = storage.db
40
+ .query("SELECT name FROM sqlite_master WHERE type='table' AND name='epics';")
41
+ .get() as { name: string } | null;
42
+
43
+ expect(epicsTable?.name).toBe("epics");
44
+ } finally {
45
+ storage.close();
46
+ }
47
+ });
48
+
49
+ test("bootstraps required sync tables", (): void => {
50
+ const workspace: string = createWorkspace();
51
+ const storage = openTrekoonDatabase(workspace);
52
+
53
+ try {
54
+ const tables = ["git_context", "sync_cursors", "sync_conflicts"];
55
+
56
+ for (const table of tables) {
57
+ const row = storage.db
58
+ .query("SELECT name FROM sqlite_master WHERE type='table' AND name=?;")
59
+ .get(table) as { name: string } | null;
60
+ expect(row?.name).toBe(table);
61
+ }
62
+ } finally {
63
+ storage.close();
64
+ }
65
+ });
66
+
67
+ test("tracks updated_at and version for mutable rows", (): void => {
68
+ const workspace: string = createWorkspace();
69
+ const storage = openTrekoonDatabase(workspace);
70
+
71
+ try {
72
+ const mutableTables = [
73
+ "epics",
74
+ "tasks",
75
+ "subtasks",
76
+ "dependencies",
77
+ "git_context",
78
+ "sync_cursors",
79
+ "sync_conflicts",
80
+ ];
81
+
82
+ for (const tableName of mutableTables) {
83
+ const columns: string[] = tableColumns(storage.db, tableName);
84
+ expect(columns).toContain("updated_at");
85
+ expect(columns).toContain("version");
86
+ }
87
+ } finally {
88
+ storage.close();
89
+ }
90
+ });
91
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "strict": true,
7
+ "noUncheckedIndexedAccess": true,
8
+ "noImplicitOverride": true,
9
+ "noFallthroughCasesInSwitch": true,
10
+ "exactOptionalPropertyTypes": true,
11
+ "forceConsistentCasingInFileNames": true,
12
+ "allowSyntheticDefaultImports": true,
13
+ "types": ["bun"],
14
+ "outDir": "dist",
15
+ "rootDir": "."
16
+ },
17
+ "include": ["src", "tests"],
18
+ "exclude": ["dist", "trekker", "node_modules"]
19
+ }