trekoon 0.2.9 → 0.3.1

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 (41) hide show
  1. package/.agents/skills/trekoon/SKILL.md +162 -26
  2. package/README.md +18 -15
  3. package/docs/ai-agents.md +49 -4
  4. package/docs/commands.md +90 -16
  5. package/docs/machine-contracts.md +120 -0
  6. package/docs/plans/r1-unified-skill-rewrite.md +290 -0
  7. package/docs/plans/r10-suggest-command-skill-integration.md +152 -0
  8. package/docs/plans/r9-task-done-diff-skill-integration.md +113 -0
  9. package/docs/quickstart.md +41 -12
  10. package/package.json +23 -1
  11. package/src/board/assets/app.js +1 -0
  12. package/src/board/assets/components/EpicRow.js +21 -6
  13. package/src/board/assets/components/EpicsOverview.js +5 -1
  14. package/src/board/assets/components/Notice.js +19 -12
  15. package/src/board/assets/components/Workspace.js +16 -5
  16. package/src/board/assets/components/helpers.js +17 -0
  17. package/src/board/assets/runtime/clipboard.js +34 -0
  18. package/src/board/assets/runtime/delegation.js +33 -0
  19. package/src/board/assets/state/actions.js +68 -0
  20. package/src/board/assets/state/store.js +1 -0
  21. package/src/board/assets/styles/board.css +156 -36
  22. package/src/board/routes.ts +2 -0
  23. package/src/commands/epic.ts +74 -3
  24. package/src/commands/session.ts +7 -75
  25. package/src/commands/subtask.ts +7 -5
  26. package/src/commands/suggest.ts +283 -0
  27. package/src/commands/sync-helpers.ts +75 -0
  28. package/src/commands/task-readiness.ts +8 -20
  29. package/src/commands/task.ts +59 -3
  30. package/src/domain/mutation-service.ts +69 -42
  31. package/src/domain/tracker-domain.ts +151 -22
  32. package/src/domain/types.ts +12 -0
  33. package/src/index.ts +1 -1
  34. package/src/io/output.ts +4 -2
  35. package/src/runtime/cli-shell.ts +26 -3
  36. package/src/runtime/command-types.ts +1 -1
  37. package/src/storage/database.ts +43 -1
  38. package/src/storage/events-retention.ts +57 -8
  39. package/src/storage/migrations.ts +58 -3
  40. package/src/sync/service.ts +101 -24
  41. package/src/sync/types.ts +1 -0
@@ -381,11 +381,165 @@ body.board-scroll-locked {
381
381
  min-width: 0;
382
382
  }
383
383
 
384
- .board-epic-row__title-row {
384
+ .board-epic-row__meta-row {
385
385
  display: flex;
386
386
  flex-wrap: wrap;
387
387
  align-items: center;
388
- gap: 8px;
388
+ gap: 6px;
389
+ }
390
+
391
+ .board-copy-btn,
392
+ .board-wh__notes-btn {
393
+ display: inline-flex;
394
+ align-items: center;
395
+ justify-content: center;
396
+ gap: 6px;
397
+ min-height: 36px;
398
+ padding: 0 10px;
399
+ border: 1px solid var(--board-border);
400
+ border-radius: 10px;
401
+ background: rgba(255, 255, 255, 0.03);
402
+ color: var(--board-text-muted);
403
+ font-size: 0.75rem;
404
+ font-weight: 600;
405
+ white-space: nowrap;
406
+ cursor: pointer;
407
+ touch-action: manipulation;
408
+ transition:
409
+ border-color 0.15s ease,
410
+ background-color 0.15s ease,
411
+ color 0.15s ease,
412
+ box-shadow 0.15s ease;
413
+ }
414
+
415
+ .board-copy-btn:hover,
416
+ .board-wh__notes-btn:hover {
417
+ border-color: var(--board-border-strong);
418
+ background: rgba(255, 255, 255, 0.045);
419
+ color: var(--board-text);
420
+ }
421
+
422
+ .board-copy-btn:focus-visible,
423
+ .board-wh__notes-btn:focus-visible {
424
+ outline: none;
425
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--board-bg) 78%, transparent), 0 0 0 4px var(--board-border-strong);
426
+ }
427
+
428
+ .board-copy-btn--active {
429
+ border-color: color-mix(in srgb, var(--board-success) 26%, var(--board-border-strong));
430
+ background: color-mix(in srgb, var(--board-success) 12%, rgba(255, 255, 255, 0.05));
431
+ color: var(--board-text);
432
+ }
433
+
434
+ .board-copy-btn--active:hover {
435
+ border-color: color-mix(in srgb, var(--board-success) 36%, var(--board-border-strong));
436
+ background: color-mix(in srgb, var(--board-success) 16%, rgba(255, 255, 255, 0.06));
437
+ }
438
+
439
+ .board-inline-icon {
440
+ display: block;
441
+ width: 16px;
442
+ height: 16px;
443
+ flex-shrink: 0;
444
+ }
445
+
446
+ .board-inline-icon--sm {
447
+ width: 14px;
448
+ height: 14px;
449
+ }
450
+
451
+ .board-copy-btn--icon {
452
+ min-height: 30px;
453
+ width: 30px;
454
+ padding: 0;
455
+ border-radius: 999px;
456
+ flex-shrink: 0;
457
+ }
458
+
459
+ .board-copy-btn--epic-row {
460
+ min-height: 24px;
461
+ width: 24px;
462
+ }
463
+
464
+ .board-copy-btn--epic-row .board-inline-icon--sm {
465
+ width: 12px;
466
+ height: 12px;
467
+ }
468
+
469
+ .board-copy-btn__label {
470
+ line-height: 1;
471
+ }
472
+
473
+ .board-toast-region {
474
+ position: fixed;
475
+ right: 16px;
476
+ bottom: 16px;
477
+ z-index: 30;
478
+ width: min(100vw - 24px, 380px);
479
+ pointer-events: none;
480
+ }
481
+
482
+ .board-toast {
483
+ display: flex;
484
+ align-items: flex-start;
485
+ gap: 12px;
486
+ width: 100%;
487
+ padding: 12px 14px;
488
+ border: 1px solid var(--board-border);
489
+ border-radius: 18px;
490
+ background: color-mix(in srgb, var(--board-shell) 94%, transparent);
491
+ box-shadow: var(--board-shadow);
492
+ backdrop-filter: blur(18px);
493
+ pointer-events: auto;
494
+ }
495
+
496
+ .board-toast--success {
497
+ border-color: color-mix(in srgb, var(--board-success) 24%, var(--board-border));
498
+ }
499
+
500
+ .board-toast--error {
501
+ border-color: color-mix(in srgb, var(--board-danger) 28%, var(--board-border));
502
+ }
503
+
504
+ .board-toast__icon {
505
+ display: inline-flex;
506
+ align-items: center;
507
+ justify-content: center;
508
+ width: 34px;
509
+ height: 34px;
510
+ border-radius: 999px;
511
+ flex-shrink: 0;
512
+ }
513
+
514
+ .board-toast__icon--success {
515
+ background: color-mix(in srgb, var(--board-success) 16%, transparent);
516
+ color: color-mix(in srgb, var(--board-success) 78%, white);
517
+ }
518
+
519
+ .board-toast__icon--error {
520
+ background: color-mix(in srgb, var(--board-danger) 14%, transparent);
521
+ color: color-mix(in srgb, var(--board-danger) 72%, white);
522
+ }
523
+
524
+ .board-toast__content {
525
+ min-width: 0;
526
+ }
527
+
528
+ .board-toast__title {
529
+ margin: 0;
530
+ font-size: 0.72rem;
531
+ font-weight: 700;
532
+ line-height: 1.2;
533
+ letter-spacing: 0.12em;
534
+ text-transform: uppercase;
535
+ color: var(--board-text-soft);
536
+ }
537
+
538
+ .board-toast__message {
539
+ margin: 4px 0 0;
540
+ font-size: 0.9rem;
541
+ line-height: 1.45;
542
+ color: var(--board-text-muted);
389
543
  }
390
544
 
391
545
  .board-epic-row__title {
@@ -1045,40 +1199,6 @@ body.board-scroll-locked {
1045
1199
  line-height: 1;
1046
1200
  }
1047
1201
 
1048
- .board-wh__notes-btn {
1049
- display: inline-flex;
1050
- align-items: center;
1051
- justify-content: center;
1052
- gap: 6px;
1053
- min-height: 36px;
1054
- padding: 0 10px;
1055
- border: 1px solid var(--board-border);
1056
- border-radius: 10px;
1057
- background: rgba(255, 255, 255, 0.03);
1058
- color: var(--board-text-muted);
1059
- font-size: 0.75rem;
1060
- font-weight: 600;
1061
- white-space: nowrap;
1062
- cursor: pointer;
1063
- touch-action: manipulation;
1064
- transition:
1065
- border-color 0.15s ease,
1066
- background-color 0.15s ease,
1067
- color 0.15s ease,
1068
- box-shadow 0.15s ease;
1069
- }
1070
-
1071
- .board-wh__notes-btn:hover {
1072
- border-color: var(--board-border-strong);
1073
- background: rgba(255, 255, 255, 0.045);
1074
- color: var(--board-text);
1075
- }
1076
-
1077
- .board-wh__notes-btn:focus-visible {
1078
- outline: none;
1079
- box-shadow: 0 0 0 2px color-mix(in srgb, var(--board-bg) 78%, transparent), 0 0 0 4px var(--board-border-strong);
1080
- }
1081
-
1082
1202
  .board-wh__notes-btn--active {
1083
1203
  border-color: color-mix(in srgb, var(--board-border-strong) 60%, var(--board-border));
1084
1204
  background: color-mix(in srgb, var(--board-accent-soft) 55%, rgba(255, 255, 255, 0.03));
@@ -229,6 +229,7 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
229
229
  title: readOptionalString(body, "title"),
230
230
  description: readOptionalString(body, "description"),
231
231
  status: readOptionalString(body, "status"),
232
+ owner: readOptionalString(body, "owner"),
232
233
  });
233
234
  return buildMutationResponse(domain, { task });
234
235
  }
@@ -240,6 +241,7 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
240
241
  title: readOptionalString(body, "title"),
241
242
  description: readOptionalString(body, "description"),
242
243
  status: readOptionalString(body, "status"),
244
+ owner: readOptionalString(body, "owner"),
243
245
  });
244
246
  return buildMutationResponse(domain, { subtask });
245
247
  }
@@ -18,6 +18,7 @@ import {
18
18
  suggestOptions,
19
19
  } from "./arg-parser";
20
20
  import { unexpectedFailureResult } from "./error-utils";
21
+ import { buildTaskReadiness } from "./task-readiness";
21
22
 
22
23
  import { MutationService } from "../domain/mutation-service";
23
24
  import { TrackerDomain } from "../domain/tracker-domain";
@@ -44,7 +45,7 @@ function formatEpic(epic: EpicRecord): string {
44
45
  const VIEW_MODES = ["table", "compact", "tree", "detail"] as const;
45
46
  const LIST_VIEW_MODES = ["table", "compact"] as const;
46
47
  const DEFAULT_LIST_LIMIT = 10;
47
- const DEFAULT_OPEN_STATUSES = ["in_progress", "in-progress", "todo"] as const;
48
+ const DEFAULT_OPEN_STATUSES = ["in_progress", "todo"] as const;
48
49
  const CREATE_OPTIONS = ["title", "t", "description", "d", "status", "s", "task", "subtask", "dep"] as const;
49
50
  const LIST_OPTIONS = ["status", "s", "limit", "l", "cursor", "all", "view"] as const;
50
51
  const SHOW_OPTIONS = ["view", "all"] as const;
@@ -115,7 +116,7 @@ function formatSearchHuman(matches: readonly SearchEntityMatch[], emptyMessage:
115
116
  }
116
117
 
117
118
  function getStatusPriority(status: string): number {
118
- if (status === "in_progress" || status === "in-progress") {
119
+ if (status === "in_progress") {
119
120
  return 0;
120
121
  }
121
122
 
@@ -1391,6 +1392,76 @@ export async function runEpic(context: CliContext): Promise<CliResult> {
1391
1392
  data: { epic },
1392
1393
  });
1393
1394
  }
1395
+ case "progress": {
1396
+ const epicId: string = parsed.positional[1] ?? "";
1397
+ if (epicId.length === 0) {
1398
+ return failResult({
1399
+ command: "epic.progress",
1400
+ human: "Provide an epic id. Usage: trekoon epic progress <epic-id>",
1401
+ data: { code: "invalid_input" },
1402
+ error: {
1403
+ code: "invalid_input",
1404
+ message: "Missing epic id",
1405
+ },
1406
+ });
1407
+ }
1408
+
1409
+ const epic = domain.getEpic(epicId);
1410
+ if (!epic) {
1411
+ return failResult({
1412
+ command: "epic.progress",
1413
+ human: `Epic not found: ${epicId}`,
1414
+ data: { code: "not_found", id: epicId },
1415
+ error: {
1416
+ code: "not_found",
1417
+ message: `Epic not found: ${epicId}`,
1418
+ },
1419
+ });
1420
+ }
1421
+
1422
+ const allTasks = domain.listTasks(epicId);
1423
+ let doneCount = 0;
1424
+ let inProgressCount = 0;
1425
+ let blockedCount = 0;
1426
+ let todoCount = 0;
1427
+ for (const t of allTasks) {
1428
+ if (t.status === "done") doneCount += 1;
1429
+ else if (t.status === "in_progress") inProgressCount += 1;
1430
+ else if (t.status === "blocked") blockedCount += 1;
1431
+ else if (t.status === "todo") todoCount += 1;
1432
+ }
1433
+
1434
+ const readiness = buildTaskReadiness(domain, epicId);
1435
+ const readyCount = readiness.summary.readyCount;
1436
+ const nextCandidate = readiness.candidates[0] ?? null;
1437
+
1438
+ const nextTask = nextCandidate !== null
1439
+ ? { id: nextCandidate.task.id, title: nextCandidate.task.title }
1440
+ : null;
1441
+
1442
+ const total = allTasks.length;
1443
+ let human = `Epic: ${epic.title}\n`;
1444
+ human += `Total: ${total}, Done: ${doneCount}, In Progress: ${inProgressCount}, Blocked: ${blockedCount}, Todo: ${todoCount}, Ready: ${readyCount}`;
1445
+ if (nextTask !== null) {
1446
+ human += `\nNext candidate: ${nextTask.id} | ${nextTask.title}`;
1447
+ }
1448
+
1449
+ return okResult({
1450
+ command: "epic.progress",
1451
+ human,
1452
+ data: {
1453
+ epicId: epic.id,
1454
+ title: epic.title,
1455
+ total,
1456
+ doneCount,
1457
+ inProgressCount,
1458
+ blockedCount,
1459
+ todoCount,
1460
+ readyCount,
1461
+ nextCandidate: nextTask,
1462
+ },
1463
+ });
1464
+ }
1394
1465
  case "delete": {
1395
1466
  const epicId: string = parsed.positional[1] ?? "";
1396
1467
  mutations.deleteEpic(epicId);
@@ -1404,7 +1475,7 @@ export async function runEpic(context: CliContext): Promise<CliResult> {
1404
1475
  default:
1405
1476
  return failResult({
1406
1477
  command: "epic",
1407
- human: "Usage: trekoon epic <create|expand|list|show|search|replace|update|delete>",
1478
+ human: "Usage: trekoon epic <create|expand|list|show|search|replace|update|delete|progress>",
1408
1479
  data: {
1409
1480
  args: context.args,
1410
1481
  },
@@ -1,17 +1,13 @@
1
- import type { Database } from "bun:sqlite";
2
-
1
+ import { parseArgs, readOption } from "./arg-parser";
3
2
  import { unexpectedFailureResult } from "./error-utils";
3
+ import { DEFAULT_SOURCE_BRANCH, resolveSyncStatus } from "./sync-helpers";
4
4
  import { buildTaskReadiness, type DependencyBlocker } from "./task-readiness";
5
5
 
6
6
  import { TrackerDomain } from "../domain/tracker-domain";
7
7
  import { okResult } from "../io/output";
8
8
  import { type CliContext, type CliResult } from "../runtime/command-types";
9
9
  import { openTrekoonDatabase, type TrekoonDatabase } from "../storage/database";
10
- import { countBranchEventsSince } from "../sync/branch-db";
11
- import { persistGitContext, resolveGitContext } from "../sync/git-context";
12
- import { type GitContextSnapshot, type SyncStatusSummary } from "../sync/types";
13
-
14
- const DEFAULT_SOURCE_BRANCH = "main";
10
+ import { type GitContextSnapshot } from "../sync/types";
15
11
 
16
12
  interface SessionReadiness {
17
13
  readonly readyCount: number;
@@ -50,73 +46,6 @@ interface SessionResult {
50
46
  readonly readiness: SessionReadiness;
51
47
  }
52
48
 
53
- function countAheadLocal(db: Database, currentBranch: string | null, sourceBranch: string): number {
54
- if (!currentBranch || currentBranch === sourceBranch) {
55
- return 0;
56
- }
57
-
58
- const row = db
59
- .query(
60
- `
61
- SELECT COUNT(*) AS count
62
- FROM events
63
- WHERE git_branch = @branch;
64
- `,
65
- )
66
- .get({ "@branch": currentBranch }) as { count: number } | null;
67
-
68
- return row?.count ?? 0;
69
- }
70
-
71
- function countPendingConflictsLocal(db: Database): number {
72
- const row = db
73
- .query("SELECT COUNT(*) AS count FROM sync_conflicts WHERE resolution = 'pending';")
74
- .get() as { count: number } | null;
75
-
76
- return row?.count ?? 0;
77
- }
78
-
79
- function loadCursorLocal(
80
- db: Database,
81
- worktreePath: string,
82
- sourceBranch: string,
83
- ): { cursor_token: string } | null {
84
- return db
85
- .query(
86
- `
87
- SELECT cursor_token
88
- FROM sync_cursors
89
- WHERE owner_scope = 'worktree'
90
- AND owner_worktree_path = ?
91
- AND source_branch = ?
92
- LIMIT 1;
93
- `,
94
- )
95
- .get(worktreePath, sourceBranch) as { cursor_token: string } | null;
96
- }
97
-
98
- function resolveSyncStatus(
99
- database: TrekoonDatabase,
100
- cwd: string,
101
- sourceBranch: string,
102
- ): SyncStatusSummary {
103
- const git: GitContextSnapshot = resolveGitContext(cwd);
104
- persistGitContext(database.db, git);
105
-
106
- const cursor = loadCursorLocal(database.db, git.worktreePath, sourceBranch);
107
- const cursorToken: string = cursor?.cursor_token ?? "0:";
108
- const onSourceBranch: boolean = git.branchName !== null && git.branchName === sourceBranch;
109
-
110
- return {
111
- sourceBranch,
112
- ahead: countAheadLocal(database.db, git.branchName, sourceBranch),
113
- behind: onSourceBranch ? 0 : countBranchEventsSince(database.db, sourceBranch, cursorToken),
114
- pendingConflicts: countPendingConflictsLocal(database.db),
115
- sameBranch: onSourceBranch,
116
- git,
117
- };
118
- }
119
-
120
49
 
121
50
  function formatSessionHuman(result: SessionResult): string {
122
51
  const lines: string[] = [];
@@ -173,12 +102,15 @@ export async function runSession(context: CliContext): Promise<CliResult> {
173
102
  let database: TrekoonDatabase | undefined;
174
103
 
175
104
  try {
105
+ const parsed = parseArgs(context.args);
106
+ const epicId: string | undefined = readOption(parsed.options, "epic");
107
+
176
108
  database = openTrekoonDatabase(context.cwd);
177
109
  const diagnostics = database.diagnostics;
178
110
 
179
111
  const syncSummary = resolveSyncStatus(database, context.cwd, DEFAULT_SOURCE_BRANCH);
180
112
  const domain = new TrackerDomain(database.db);
181
- const readiness = buildTaskReadiness(domain, undefined);
113
+ const readiness = buildTaskReadiness(domain, epicId);
182
114
  const topCandidate = readiness.candidates[0] ?? null;
183
115
 
184
116
  let nextTask: NextCandidate | null = null;
@@ -32,13 +32,13 @@ function formatSubtask(subtask: SubtaskRecord): string {
32
32
 
33
33
  const VIEW_MODES = ["table", "compact"] as const;
34
34
  const DEFAULT_SUBTASK_LIST_LIMIT = 10;
35
- const DEFAULT_OPEN_SUBTASK_STATUSES = ["in_progress", "in-progress", "todo"] as const;
35
+ const DEFAULT_OPEN_SUBTASK_STATUSES = ["in_progress", "todo"] as const;
36
36
  const CREATE_OPTIONS = ["task", "t", "title", "description", "d", "status", "s"] as const;
37
37
  const LIST_OPTIONS = ["task", "t", "status", "s", "limit", "l", "cursor", "all", "view"] as const;
38
38
  const SEARCH_OPTIONS = ["fields", "preview"] as const;
39
39
  const REPLACE_OPTIONS = ["search", "replace", "fields", "preview", "apply"] as const;
40
40
  const CREATE_MANY_OPTIONS = ["task", "t", "subtask"] as const;
41
- const UPDATE_OPTIONS = ["all", "ids", "append", "description", "d", "status", "s", "title"] as const;
41
+ const UPDATE_OPTIONS = ["all", "ids", "append", "description", "d", "status", "s", "title", "owner"] as const;
42
42
  const STATUS_CASCADE_UPDATE_STATUSES = ["done", "todo"] as const;
43
43
 
44
44
  function parseIdsOption(rawIds: string | undefined): string[] {
@@ -113,7 +113,7 @@ function parseStatusCsv(rawStatuses: string | undefined): string[] | undefined {
113
113
  }
114
114
 
115
115
  function subtaskStatusPriority(status: string): number {
116
- if (status === "in_progress" || status === "in-progress") {
116
+ if (status === "in_progress") {
117
117
  return 0;
118
118
  }
119
119
 
@@ -788,7 +788,8 @@ export async function runSubtask(context: CliContext): Promise<CliResult> {
788
788
  readMissingOptionValue(parsed.missingOptionValues, "ids") ??
789
789
  readMissingOptionValue(parsed.missingOptionValues, "append") ??
790
790
  readMissingOptionValue(parsed.missingOptionValues, "description", "d") ??
791
- readMissingOptionValue(parsed.missingOptionValues, "status", "s");
791
+ readMissingOptionValue(parsed.missingOptionValues, "status", "s") ??
792
+ readMissingOptionValue(parsed.missingOptionValues, "owner");
792
793
  if (missingUpdateOption !== undefined) {
793
794
  return failMissingOptionValue("subtask.update", missingUpdateOption);
794
795
  }
@@ -801,6 +802,7 @@ export async function runSubtask(context: CliContext): Promise<CliResult> {
801
802
  const description: string | undefined = readOption(parsed.options, "description", "d");
802
803
  const append: string | undefined = readOption(parsed.options, "append");
803
804
  const status: string | undefined = readOption(parsed.options, "status", "s");
805
+ const owner: string | undefined = readOption(parsed.options, "owner");
804
806
 
805
807
  if (updateAll && ids.length > 0) {
806
808
  return failResult({
@@ -923,7 +925,7 @@ export async function runSubtask(context: CliContext): Promise<CliResult> {
923
925
  append === undefined
924
926
  ? description
925
927
  : appendLine(domain.getSubtaskOrThrow(subtaskId).description, append);
926
- const subtask = mutations.updateSubtask(subtaskId, { title, description: nextDescription, status });
928
+ const subtask = mutations.updateSubtask(subtaskId, { title, description: nextDescription, status, owner });
927
929
 
928
930
  return okResult({
929
931
  command: "subtask.update",