trekoon 0.2.1 → 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.
@@ -7,28 +7,42 @@ const QUICKSTART_TEXT = [
7
7
  "This quickstart is aligned with .agents/skills/trekoon/SKILL.md.",
8
8
  "For agents: always use --toon for every command.",
9
9
  "",
10
- "1) Local DB and worktree model",
11
- "- Every worktree stores tracker state at .trekoon/trekoon.db.",
12
- "- This DB stays local; it is not merged by Git automatically.",
10
+ "1) Shared storage model",
11
+ "- In git repos and worktrees, Trekoon resolves one repo-scoped .trekoon directory.",
12
+ "- Every worktree for the same repo points at the same .trekoon/trekoon.db file.",
13
+ "- Keep .trekoon gitignored: the SQLite DB is live operational state, not source.",
14
+ "- Committing the DB is the wrong fix for setup drift because it snapshots machine-local state.",
13
15
  "",
14
- "2) Agent session-start checklist (verify setup + current state)",
15
- "- 1. Verify tracker exists (or initialize once): trekoon --toon init",
16
- "- 2. Check sync baseline: trekoon --toon sync status",
17
- "- 3. Load active context: trekoon --toon epic list",
18
- "- 4. Load active tasks: trekoon --toon task list",
19
- "- 5. Load deterministic candidates: trekoon --toon task ready --limit 5",
20
- "- 6. If blocked, inspect downstream impact: trekoon --toon dep reverse <task-or-subtask-id>",
16
+ "2) Bootstrap and agent startup (primary)",
17
+ "- Single call: trekoon --toon session",
18
+ "- Replaces: init + sync status + task next + dep list + task show in one call.",
19
+ "- Returns diagnostics, next ready task, its dependencies, and full task payload.",
20
+ "- If diagnostics show recoveryRequired or a tracked/ignored mismatch, stop and repair setup.",
21
+ "- Do not continue after storage mismatch, ambiguous recovery, or broken bootstrap warnings.",
22
+ "- In worktrees, confirm meta.storageRootDiagnostics.sharedStorageRoot matches the repo root.",
23
+ "",
24
+ "2a) Bootstrap (manual/legacy — use session instead)",
25
+ "- 1. Initialize or verify shared storage: trekoon --toon init",
26
+ "- 2. Read machine diagnostics: trekoon --toon sync status",
27
+ "- 3. Select next task: trekoon --toon task next",
28
+ "- 4. Check dependencies: trekoon --toon dep list <task-id>",
29
+ "- 5. Show full task: trekoon --toon task show <task-id> --all",
21
30
  "",
22
31
  "3) AI execution loop (deterministic, dependency-aware)",
23
- "- 1. Sync branch/worktree state: trekoon --toon sync status",
24
- "- 2. Select ready work: trekoon --toon task ready --limit 5",
25
- "- 3. Pick top candidate when needed: trekoon --toon task next",
26
- "- 4. Check downstream blockers: trekoon --toon dep reverse <task-or-subtask-id>",
27
- "- 5. Claim work and update status: trekoon --toon task update <task-id> --status in_progress",
28
- "- 6. Complete with context: trekoon --toon task update <task-id> --append \"Completed implementation\" --status done",
29
- "- 7. Or report block: trekoon --toon task update <task-id> --append \"Blocked by <reason>\" --status blocked",
32
+ "- 1. Start or resume session: trekoon --toon session",
33
+ "- 2. Claim work: trekoon --toon task update <task-id> --status in_progress",
34
+ "- 3. Complete with context: trekoon --toon task done <task-id> --append \"Completed implementation\"",
35
+ " - Replaces: update status done + task next + dep list + task show in one call.",
36
+ " - Returns next ready task, its dependencies, and full task payload.",
37
+ "- 4. Or report block: trekoon --toon task update <task-id> --append \"Blocked by <reason>\" --status blocked",
38
+ "",
39
+ "4) Worktree diagnostics and recovery",
40
+ "- Read machine fields when available: storageMode, repoCommonDir, worktreeRoot, sharedStorageRoot, databaseFile.",
41
+ "- sharedStorageRoot differs from worktreeRoot in linked worktrees; that is expected and should be visible.",
42
+ "- If recoveryRequired is true, run trekoon --toon init and follow the reported recovery action before more commands.",
43
+ "- If tracked and ignored storage disagree, delete the bad fix in Git and re-bootstrap local storage instead.",
30
44
  "",
31
- "4) Power-user command patterns (aligned with skill)",
45
+ "5) Power-user command patterns (aligned with skill)",
32
46
  "- Inspect full epic tree: trekoon --toon epic show <epic-id> --all",
33
47
  "- Inspect full task payload: trekoon --toon task show <task-id> --all",
34
48
  "- Check direct dependencies before starting: trekoon --toon dep list <task-id>",
@@ -37,7 +51,7 @@ const QUICKSTART_TEXT = [
37
51
  "- Paginate deterministically: trekoon --toon task list --cursor <n>",
38
52
  "- Bulk append/status update: trekoon --toon task update --ids id1,id2 --append \"...\" --status in_progress",
39
53
  "",
40
- "5) Task details and description",
54
+ "6) Task details and description",
41
55
  "- Human list and show views default to table format.",
42
56
  "- Alternate list view: add --view compact.",
43
57
  "- task/epic/subtask list defaults: open work only (in_progress/in-progress, todo), max 10.",
@@ -53,14 +67,21 @@ const QUICKSTART_TEXT = [
53
67
  "- Full task payload (including description): trekoon --toon task show <task-id> --all",
54
68
  "- Optional integration format: trekoon --json task show <task-id> --all",
55
69
  "",
56
- "6) Pre-merge sync flow",
70
+ "7) Pre-merge sync flow",
57
71
  "- Run: trekoon --toon sync status",
58
72
  "- Pull upstream tracker events: trekoon --toon sync pull --from main",
59
73
  "- Resolve conflicts if needed: trekoon --toon sync resolve <id> --use ours",
60
74
  "- Run sync status again before opening or merging a PR.",
61
75
  "",
62
- "7) Machine output examples",
76
+ "8) Shared-storage wipe warning",
77
+ "- trekoon wipe --yes removes the shared repo-scoped .trekoon directory for every worktree in the repo.",
78
+ "- Treat wipe as destructive recovery, not routine cleanup.",
79
+ "- Never use wipe as a substitute for sync, bootstrap, or gitignore fixes.",
80
+ "",
81
+ "9) Machine output examples",
63
82
  "- trekoon --toon quickstart",
83
+ "- trekoon --toon session",
84
+ "- trekoon --toon task done <task-id> --append \"Completed implementation\"",
64
85
  "- trekoon --toon task show <task-id> --all",
65
86
  "- trekoon --toon epic show <epic-id> --all",
66
87
  "- trekoon --toon sync status",
@@ -77,17 +98,24 @@ export async function runQuickstart(_: CliContext): Promise<CliResult> {
77
98
  localModel: {
78
99
  storageDir: ".trekoon",
79
100
  databaseFile: ".trekoon/trekoon.db",
101
+ storageScope: "repo-shared-across-worktrees",
102
+ gitPolicy: "gitignored-operational-state",
80
103
  mergeBehavior: "manual-sync",
81
104
  },
82
105
  alignedSkill: ".agents/skills/trekoon/SKILL.md",
83
106
  requiresToonForAgents: true,
84
- agentStartupChecklist: [
107
+ bootstrapChecklist: [
108
+ "trekoon --toon session",
109
+ "Replaces: init + sync status + task next + dep list + task show in one call",
110
+ "Fail fast on recoveryRequired or tracked/ignored mismatch diagnostics",
111
+ "Confirm meta.storageRootDiagnostics.sharedStorageRoot matches the repo root",
112
+ ],
113
+ bootstrapLegacy: [
85
114
  "trekoon --toon init",
86
115
  "trekoon --toon sync status",
87
- "trekoon --toon epic list",
88
- "trekoon --toon task list",
89
- "trekoon --toon task ready --limit 5",
90
- "trekoon --toon dep reverse <task-or-subtask-id>",
116
+ "trekoon --toon task next",
117
+ "trekoon --toon dep list <task-id>",
118
+ "trekoon --toon task show <task-id> --all",
91
119
  ],
92
120
  preMergeFlow: [
93
121
  "trekoon --toon sync status",
@@ -96,11 +124,22 @@ export async function runQuickstart(_: CliContext): Promise<CliResult> {
96
124
  "trekoon --toon sync status",
97
125
  ],
98
126
  executionLoop: [
99
- "trekoon --toon sync status",
100
- "trekoon --toon task ready --limit 5",
101
- "trekoon --toon task next",
102
- "trekoon --toon dep reverse <task-or-subtask-id>",
127
+ "trekoon --toon session",
103
128
  "trekoon --toon task update <task-id> --status in_progress",
129
+ "trekoon --toon task done <task-id> --append \"Completed implementation\"",
130
+ ],
131
+ diagnostics: [
132
+ "storageMode",
133
+ "repoCommonDir",
134
+ "worktreeRoot",
135
+ "sharedStorageRoot",
136
+ "databaseFile",
137
+ "recoveryRequired",
138
+ ],
139
+ recoveryGuidance: [
140
+ "Run trekoon --toon init to bootstrap or re-bootstrap shared storage",
141
+ "Stop when diagnostics report tracked/ignored mismatch or ambiguous recovery",
142
+ "Do not commit .trekoon/trekoon.db; remove the tracked DB and keep .trekoon ignored",
104
143
  ],
105
144
  powerUserCommands: [
106
145
  "trekoon --toon epic show <epic-id> --all",
@@ -113,6 +152,8 @@ export async function runQuickstart(_: CliContext): Promise<CliResult> {
113
152
  ],
114
153
  machineExamples: [
115
154
  "trekoon --toon quickstart",
155
+ "trekoon --toon session",
156
+ "trekoon --toon task done <task-id> --append \"Completed implementation\"",
116
157
  "trekoon --toon task show <task-id> --all",
117
158
  "trekoon --toon epic show <epic-id> --all",
118
159
  "trekoon --toon sync status",
@@ -120,6 +161,11 @@ export async function runQuickstart(_: CliContext): Promise<CliResult> {
120
161
  "trekoon --toon task next",
121
162
  "trekoon --toon dep reverse <task-or-subtask-id>",
122
163
  ],
164
+ wipeWarning: {
165
+ command: "trekoon wipe --yes",
166
+ scope: "repo-wide shared storage",
167
+ destructive: true,
168
+ },
123
169
  },
124
170
  });
125
171
  }
@@ -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: {