trekoon 0.2.0 → 0.2.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.
@@ -0,0 +1,223 @@
1
+ import type { Database } from "bun:sqlite";
2
+
3
+ import { unexpectedFailureResult } from "./error-utils";
4
+ import { buildTaskReadiness, type DependencyBlocker } from "./task-readiness";
5
+
6
+ import { TrackerDomain } from "../domain/tracker-domain";
7
+ import { okResult } from "../io/output";
8
+ import { type CliContext, type CliResult } from "../runtime/command-types";
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";
15
+
16
+ interface SessionReadiness {
17
+ readonly readyCount: number;
18
+ readonly blockedCount: number;
19
+ }
20
+
21
+ interface NextCandidate {
22
+ readonly id: string;
23
+ readonly epicId: string;
24
+ readonly title: string;
25
+ readonly description: string;
26
+ readonly status: string;
27
+ readonly subtasks: ReadonlyArray<{
28
+ readonly id: string;
29
+ readonly taskId: string;
30
+ readonly title: string;
31
+ readonly description: string;
32
+ readonly status: string;
33
+ }>;
34
+ }
35
+
36
+ interface SessionResult {
37
+ readonly diagnostics: {
38
+ readonly storageMode: string;
39
+ readonly recoveryRequired: boolean;
40
+ readonly recoveryStatus: string;
41
+ };
42
+ readonly sync: {
43
+ readonly ahead: number;
44
+ readonly behind: number;
45
+ readonly pendingConflicts: number;
46
+ readonly git: GitContextSnapshot;
47
+ };
48
+ readonly next: NextCandidate | null;
49
+ readonly nextDeps: ReadonlyArray<DependencyBlocker>;
50
+ readonly readiness: SessionReadiness;
51
+ }
52
+
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
+
121
+ function formatSessionHuman(result: SessionResult): string {
122
+ const lines: string[] = [];
123
+
124
+ lines.push("=== Session ===");
125
+ lines.push(`Storage mode: ${result.diagnostics.storageMode}`);
126
+ lines.push(`Recovery required: ${result.diagnostics.recoveryRequired}`);
127
+ lines.push(`Recovery status: ${result.diagnostics.recoveryStatus}`);
128
+
129
+ lines.push("");
130
+ lines.push("=== Sync ===");
131
+ lines.push(`Source branch: ${DEFAULT_SOURCE_BRANCH}`);
132
+ lines.push(`Ahead: ${result.sync.ahead}`);
133
+ lines.push(`Behind: ${result.sync.behind}`);
134
+ lines.push(`Pending conflicts: ${result.sync.pendingConflicts}`);
135
+ lines.push(`Branch: ${result.sync.git.branchName ?? "(detached)"}`);
136
+
137
+ lines.push("");
138
+ lines.push("=== Readiness ===");
139
+ lines.push(`Ready: ${result.readiness.readyCount}`);
140
+ lines.push(`Blocked: ${result.readiness.blockedCount}`);
141
+
142
+ lines.push("");
143
+ lines.push("=== Next Task ===");
144
+ if (result.next === null) {
145
+ lines.push("No ready tasks.");
146
+ } else {
147
+ lines.push(`${result.next.id} | epic=${result.next.epicId} | ${result.next.title} | ${result.next.status}`);
148
+ lines.push(`Description: ${result.next.description}`);
149
+ if (result.next.subtasks.length > 0) {
150
+ lines.push("Subtasks:");
151
+ for (const subtask of result.next.subtasks) {
152
+ lines.push(` ${subtask.id} | ${subtask.title} | ${subtask.status}`);
153
+ }
154
+ } else {
155
+ lines.push("Subtasks: none");
156
+ }
157
+ }
158
+
159
+ lines.push("");
160
+ lines.push("=== Next Task Deps ===");
161
+ if (result.nextDeps.length === 0) {
162
+ lines.push("No blockers.");
163
+ } else {
164
+ for (const dep of result.nextDeps) {
165
+ lines.push(`${dep.id} | kind=${dep.kind} | status=${dep.status}`);
166
+ }
167
+ }
168
+
169
+ return lines.join("\n");
170
+ }
171
+
172
+ export async function runSession(context: CliContext): Promise<CliResult> {
173
+ let database: TrekoonDatabase | undefined;
174
+
175
+ try {
176
+ database = openTrekoonDatabase(context.cwd);
177
+ const diagnostics = database.diagnostics;
178
+
179
+ const syncSummary = resolveSyncStatus(database, context.cwd, DEFAULT_SOURCE_BRANCH);
180
+ const domain = new TrackerDomain(database.db);
181
+ const readiness = buildTaskReadiness(domain, undefined);
182
+ const topCandidate = readiness.candidates[0] ?? null;
183
+
184
+ let nextTask: NextCandidate | null = null;
185
+ if (topCandidate !== null) {
186
+ const tree = domain.buildTaskTreeDetailed(topCandidate.task.id);
187
+ nextTask = tree;
188
+ }
189
+
190
+ const result: SessionResult = {
191
+ diagnostics: {
192
+ storageMode: diagnostics.storageMode,
193
+ recoveryRequired: diagnostics.recoveryRequired,
194
+ recoveryStatus: diagnostics.recoveryStatus,
195
+ },
196
+ sync: {
197
+ ahead: syncSummary.ahead,
198
+ behind: syncSummary.behind,
199
+ pendingConflicts: syncSummary.pendingConflicts,
200
+ git: syncSummary.git,
201
+ },
202
+ next: nextTask,
203
+ nextDeps: topCandidate?.blockerSummary.blockedBy ?? [],
204
+ readiness: {
205
+ readyCount: readiness.summary.readyCount,
206
+ blockedCount: readiness.summary.blockedCount,
207
+ },
208
+ };
209
+
210
+ return okResult({
211
+ command: "session",
212
+ human: formatSessionHuman(result),
213
+ data: result,
214
+ });
215
+ } catch (error: unknown) {
216
+ return unexpectedFailureResult(error, {
217
+ command: "session",
218
+ human: "Unexpected session command failure",
219
+ });
220
+ } finally {
221
+ database?.close();
222
+ }
223
+ }
@@ -16,8 +16,6 @@ const EDITOR_NAMES = ["opencode", "claude", "pi"] as const;
16
16
  const ALLOW_OUTSIDE_REPO_FLAG = "allow-outside-repo";
17
17
 
18
18
  type EditorName = (typeof EDITOR_NAMES)[number];
19
- type LinkStateStatus = "missing" | "valid" | "conflict";
20
-
21
19
  interface InstallOutcome {
22
20
  readonly sourcePath: string;
23
21
  readonly installedPath: string;
@@ -32,20 +30,22 @@ interface LinkTargetValidation {
32
30
  readonly outsideRepoLink: boolean;
33
31
  }
34
32
 
35
- interface LinkState {
33
+ type UpdateLinkAction = "created" | "refreshed" | "skipped_conflict" | "skipped_no_editor_dir";
34
+
35
+ interface UpdateLinkEntry {
36
36
  readonly editor: EditorName;
37
37
  readonly linkPath: string;
38
38
  readonly expectedTarget: string;
39
- readonly status: LinkStateStatus;
40
- readonly existingTarget: string | null;
39
+ readonly action: UpdateLinkAction;
41
40
  readonly conflictCode: "non_link" | "wrong_target" | null;
41
+ readonly existingTarget: string | null;
42
42
  }
43
43
 
44
44
  interface UpdateOutcome {
45
45
  readonly sourcePath: string;
46
46
  readonly installedPath: string;
47
47
  readonly installedDir: string;
48
- readonly links: readonly LinkState[];
48
+ readonly links: readonly UpdateLinkEntry[];
49
49
  }
50
50
 
51
51
  function invalidArgs(message: string): CliResult {
@@ -215,6 +215,18 @@ function resolveDefaultLinkPath(cwd: string, editor: EditorName): string {
215
215
  return join(resolveLinkRoot(cwd, editor, undefined), "trekoon");
216
216
  }
217
217
 
218
+ function resolveEditorConfigDir(cwd: string, editor: EditorName): string {
219
+ if (editor === "opencode") {
220
+ return join(cwd, ".opencode");
221
+ }
222
+
223
+ if (editor === "claude") {
224
+ return join(cwd, ".claude");
225
+ }
226
+
227
+ return join(cwd, ".pi");
228
+ }
229
+
218
230
  function installCanonicalSkill(cwd: string): CliResult | { sourcePath: string; installedPath: string; installedDir: string } {
219
231
  const sourcePath: string = resolveBundledSkillFilePath();
220
232
  if (!existsSync(sourcePath)) {
@@ -323,56 +335,6 @@ function replaceOrCreateSymlink(
323
335
  return null;
324
336
  }
325
337
 
326
- function inspectDefaultLink(cwd: string, editor: EditorName, installedDir: string): LinkState {
327
- const linkPath: string = resolveDefaultLinkPath(cwd, editor);
328
- const expectedTarget: string = resolve(installedDir);
329
-
330
- if (!existsSync(linkPath)) {
331
- return {
332
- editor,
333
- linkPath,
334
- expectedTarget,
335
- status: "missing",
336
- existingTarget: null,
337
- conflictCode: null,
338
- };
339
- }
340
-
341
- const entry = lstatSync(linkPath);
342
- if (!entry.isSymbolicLink()) {
343
- return {
344
- editor,
345
- linkPath,
346
- expectedTarget,
347
- status: "conflict",
348
- existingTarget: null,
349
- conflictCode: "non_link",
350
- };
351
- }
352
-
353
- const existingRawTarget: string = readlinkSync(linkPath);
354
- const existingTarget: string = toAbsolutePath(dirname(linkPath), existingRawTarget);
355
- if (existingTarget !== expectedTarget) {
356
- return {
357
- editor,
358
- linkPath,
359
- expectedTarget,
360
- status: "conflict",
361
- existingTarget,
362
- conflictCode: "wrong_target",
363
- };
364
- }
365
-
366
- return {
367
- editor,
368
- linkPath,
369
- expectedTarget,
370
- status: "valid",
371
- existingTarget,
372
- conflictCode: null,
373
- };
374
- }
375
-
376
338
  function runSkillsInstall(context: CliContext): CliResult {
377
339
  const parsed = parseArgs(context.args);
378
340
  const missingValue = readMissingOptionValue(parsed.missingOptionValues, "editor", "to");
@@ -520,6 +482,77 @@ function runSkillsInstall(context: CliContext): CliResult {
520
482
  });
521
483
  }
522
484
 
485
+ function updateEditorLink(
486
+ cwd: string,
487
+ editor: EditorName,
488
+ installedDir: string,
489
+ ): UpdateLinkEntry {
490
+ const linkPath: string = resolveDefaultLinkPath(cwd, editor);
491
+ const expectedTarget: string = resolve(installedDir);
492
+ const editorConfigDir: string = resolveEditorConfigDir(cwd, editor);
493
+
494
+ if (!existsSync(editorConfigDir)) {
495
+ return {
496
+ editor,
497
+ linkPath,
498
+ expectedTarget,
499
+ action: "skipped_no_editor_dir",
500
+ conflictCode: null,
501
+ existingTarget: null,
502
+ };
503
+ }
504
+
505
+ if (!existsSync(linkPath)) {
506
+ mkdirSync(dirname(linkPath), { recursive: true });
507
+ symlinkSync(expectedTarget, linkPath, "dir");
508
+ return {
509
+ editor,
510
+ linkPath,
511
+ expectedTarget,
512
+ action: "created",
513
+ conflictCode: null,
514
+ existingTarget: null,
515
+ };
516
+ }
517
+
518
+ const entry = lstatSync(linkPath);
519
+ if (!entry.isSymbolicLink()) {
520
+ return {
521
+ editor,
522
+ linkPath,
523
+ expectedTarget,
524
+ action: "skipped_conflict",
525
+ conflictCode: "non_link",
526
+ existingTarget: null,
527
+ };
528
+ }
529
+
530
+ const existingRawTarget: string = readlinkSync(linkPath);
531
+ const existingTarget: string = toAbsolutePath(dirname(linkPath), existingRawTarget);
532
+
533
+ if (existingTarget !== expectedTarget) {
534
+ return {
535
+ editor,
536
+ linkPath,
537
+ expectedTarget,
538
+ action: "skipped_conflict",
539
+ conflictCode: "wrong_target",
540
+ existingTarget,
541
+ };
542
+ }
543
+
544
+ rmSync(linkPath, { force: true });
545
+ symlinkSync(expectedTarget, linkPath, "dir");
546
+ return {
547
+ editor,
548
+ linkPath,
549
+ expectedTarget,
550
+ action: "refreshed",
551
+ conflictCode: null,
552
+ existingTarget,
553
+ };
554
+ }
555
+
523
556
  function runSkillsUpdate(context: CliContext): CliResult {
524
557
  const parsed = parseArgs(context.args);
525
558
  if (parsed.positional.length > 1) {
@@ -544,8 +577,8 @@ function runSkillsUpdate(context: CliContext): CliResult {
544
577
  });
545
578
  }
546
579
 
547
- const links: readonly LinkState[] = EDITOR_NAMES.map((editor) =>
548
- inspectDefaultLink(context.cwd, editor, installResult.installedDir),
580
+ const links: readonly UpdateLinkEntry[] = EDITOR_NAMES.map((editor) =>
581
+ updateEditorLink(context.cwd, editor, installResult.installedDir),
549
582
  );
550
583
 
551
584
  const outcome: UpdateOutcome = {
@@ -557,12 +590,16 @@ function runSkillsUpdate(context: CliContext): CliResult {
557
590
 
558
591
  const linkSummary: string = outcome.links
559
592
  .map((entry) => {
560
- if (entry.status === "missing") {
561
- return `- ${entry.editor}: missing (${entry.linkPath})`;
593
+ if (entry.action === "created") {
594
+ return `- ${entry.editor}: created (${entry.linkPath} -> ${entry.expectedTarget})`;
595
+ }
596
+
597
+ if (entry.action === "refreshed") {
598
+ return `- ${entry.editor}: refreshed (${entry.linkPath} -> ${entry.expectedTarget})`;
562
599
  }
563
600
 
564
- if (entry.status === "valid") {
565
- return `- ${entry.editor}: valid (${entry.linkPath} -> ${entry.expectedTarget})`;
601
+ if (entry.action === "skipped_no_editor_dir") {
602
+ return `- ${entry.editor}: skipped (no editor config dir)`;
566
603
  }
567
604
 
568
605
  if (entry.conflictCode === "non_link") {
@@ -579,7 +616,7 @@ function runSkillsUpdate(context: CliContext): CliResult {
579
616
  "Updated Trekoon skill in canonical path.",
580
617
  `Source: ${outcome.sourcePath}`,
581
618
  `Installed file: ${outcome.installedPath}`,
582
- "Default link states:",
619
+ "Editor links:",
583
620
  linkSummary,
584
621
  ].join("\n"),
585
622
  data: {