trekoon 0.1.6 → 0.1.7

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.
package/README.md CHANGED
@@ -52,7 +52,7 @@ npm i -g trekoon
52
52
  - `trekoon subtask <create|list|update|delete>`
53
53
  - `trekoon dep <add|remove|list>`
54
54
  - `trekoon sync <status|pull|resolve>`
55
- - `trekoon skills install [--link --editor opencode|claude|pi] [--to <path>]`
55
+ - `trekoon skills install [--link --editor opencode|claude|pi] [--to <path>] [--allow-outside-repo]`
56
56
  - `trekoon skills update`
57
57
  - `trekoon wipe --yes`
58
58
 
@@ -161,6 +161,15 @@ trekoon sync pull --from main
161
161
  trekoon sync resolve <conflict-id> --use ours
162
162
  ```
163
163
 
164
+ `sync pull` machine output includes diagnostics counters and hints so agents can
165
+ react deterministically:
166
+
167
+ - `diagnostics.malformedPayloadEvents`
168
+ - `diagnostics.applyRejectedEvents`
169
+ - `diagnostics.quarantinedEvents`
170
+ - `diagnostics.conflictEvents`
171
+ - `diagnostics.errorHints`
172
+
164
173
  ### 6) Install project-local Trekoon skill for agents
165
174
 
166
175
  `trekoon skills install` always writes the bundled skill file into the current
@@ -186,6 +195,10 @@ Path behavior:
186
195
  - Default pi link path: `.pi/skills/trekoon`
187
196
  - `--to <path>` overrides the editor root for link creation only.
188
197
  - `--to` does **not** move or copy `SKILL.md` to that path.
198
+ - By default, link targets must resolve inside the repository root.
199
+ - Use `--allow-outside-repo` only for intentional external links.
200
+ - When override is used, install prints a warning and includes confirmation
201
+ fields in machine output.
189
202
  - Re-running install is idempotent: it refreshes `SKILL.md` and reuses/replaces
190
203
  the same symlink target.
191
204
  - `trekoon skills update` is idempotent: it refreshes canonical
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trekoon",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "AI-first local issue tracker CLI.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -42,7 +42,7 @@ const COMMAND_HELP: Record<string, string> = {
42
42
  migrate: "Usage: trekoon migrate <status|rollback> [--to-version <n>]",
43
43
  sync: "Usage: trekoon sync <subcommand> [options]",
44
44
  skills:
45
- "Usage: trekoon skills install [--link --editor opencode|claude|pi] [--to <path>] | trekoon skills update (--to sets symlink root for --link only; install path always <cwd>/.agents/skills/trekoon/SKILL.md; update refreshes canonical SKILL and reports default link states)",
45
+ "Usage: trekoon skills install [--link --editor opencode|claude|pi] [--to <path>] [--allow-outside-repo] | trekoon skills update (--to sets symlink root for --link only; install path always <cwd>/.agents/skills/trekoon/SKILL.md; links must resolve inside repo unless --allow-outside-repo is set; update refreshes canonical SKILL and reports default link states)",
46
46
  help: "Usage: trekoon help [command] [--json|--toon]",
47
47
  };
48
48
 
@@ -1,5 +1,5 @@
1
- import { copyFileSync, existsSync, lstatSync, mkdirSync, readlinkSync, rmSync, symlinkSync } from "node:fs";
2
- import { dirname, isAbsolute, join, resolve } from "node:path";
1
+ import { copyFileSync, existsSync, lstatSync, mkdirSync, readlinkSync, realpathSync, rmSync, symlinkSync } from "node:fs";
2
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
 
5
5
  import { hasFlag, parseArgs, readMissingOptionValue, readOption } from "./arg-parser";
@@ -9,10 +9,11 @@ import { type CliContext, type CliResult } from "../runtime/command-types";
9
9
 
10
10
  const SKILLS_USAGE = [
11
11
  "Usage:",
12
- " trekoon skills install [--link --editor opencode|claude|pi] [--to <path>]",
12
+ " trekoon skills install [--link --editor opencode|claude|pi] [--to <path>] [--allow-outside-repo]",
13
13
  " trekoon skills update",
14
14
  ].join("\n");
15
15
  const EDITOR_NAMES = ["opencode", "claude", "pi"] as const;
16
+ const ALLOW_OUTSIDE_REPO_FLAG = "allow-outside-repo";
16
17
 
17
18
  type EditorName = (typeof EDITOR_NAMES)[number];
18
19
  type LinkStateStatus = "missing" | "valid" | "conflict";
@@ -23,6 +24,12 @@ interface InstallOutcome {
23
24
  readonly installedDir: string;
24
25
  readonly linkPath: string | null;
25
26
  readonly linkTarget: string | null;
27
+ readonly outsideRepoLink: boolean;
28
+ }
29
+
30
+ interface LinkTargetValidation {
31
+ readonly linkRoot: string;
32
+ readonly outsideRepoLink: boolean;
26
33
  }
27
34
 
28
35
  interface LinkState {
@@ -96,6 +103,114 @@ function resolveLinkRoot(cwd: string, editor: EditorName, toOverride: string | u
96
103
  return join(cwd, ".pi", "skills");
97
104
  }
98
105
 
106
+ function isPathInsideRoot(pathValue: string, rootPath: string): boolean {
107
+ const normalizedPath: string = resolve(pathValue);
108
+ const normalizedRoot: string = resolve(rootPath);
109
+ const relativePath: string = relative(normalizedRoot, normalizedPath);
110
+ return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath));
111
+ }
112
+
113
+ function realpathNearestExistingAncestor(pathValue: string): string {
114
+ let cursor: string = resolve(pathValue);
115
+
116
+ while (!existsSync(cursor)) {
117
+ const parent: string = dirname(cursor);
118
+ if (parent === cursor) {
119
+ break;
120
+ }
121
+
122
+ cursor = parent;
123
+ }
124
+
125
+ return realpathSync(cursor);
126
+ }
127
+
128
+ function validateLinkRoot(
129
+ cwd: string,
130
+ editor: EditorName,
131
+ toOverride: string | undefined,
132
+ allowOutsideRepo: boolean,
133
+ ): CliResult | LinkTargetValidation {
134
+ const linkRoot: string = resolveLinkRoot(cwd, editor, toOverride);
135
+ const repoRoot: string = realpathSync(cwd);
136
+ const effectiveTargetRoot: string = realpathNearestExistingAncestor(linkRoot);
137
+ const insideRepo: boolean = isPathInsideRoot(effectiveTargetRoot, repoRoot);
138
+
139
+ if (insideRepo) {
140
+ return {
141
+ linkRoot,
142
+ outsideRepoLink: false,
143
+ };
144
+ }
145
+
146
+ if (!allowOutsideRepo) {
147
+ return failResult({
148
+ command: "skills.install",
149
+ human: [
150
+ "Refusing to link skills outside repository root by default.",
151
+ `Requested link root: ${linkRoot}`,
152
+ `Resolved existing target ancestor: ${effectiveTargetRoot}`,
153
+ `Repository root: ${repoRoot}`,
154
+ `If intentional, re-run with --${ALLOW_OUTSIDE_REPO_FLAG} to override.`,
155
+ ].join("\n"),
156
+ data: {
157
+ code: "outside_repo_target",
158
+ linkRoot,
159
+ effectiveTargetRoot,
160
+ repoRoot,
161
+ overrideFlag: `--${ALLOW_OUTSIDE_REPO_FLAG}`,
162
+ },
163
+ error: {
164
+ code: "outside_repo_target",
165
+ message: "Link target is outside repository root",
166
+ },
167
+ });
168
+ }
169
+
170
+ return {
171
+ linkRoot,
172
+ outsideRepoLink: true,
173
+ };
174
+ }
175
+
176
+ function revalidateLinkParentBoundary(
177
+ repoRoot: string,
178
+ linkPath: string,
179
+ allowOutsideRepo: boolean,
180
+ ): CliResult | null {
181
+ if (allowOutsideRepo) {
182
+ return null;
183
+ }
184
+
185
+ const linkParentRealpath: string = realpathSync(dirname(linkPath));
186
+ const insideRepo: boolean = isPathInsideRoot(linkParentRealpath, repoRoot);
187
+ if (insideRepo) {
188
+ return null;
189
+ }
190
+
191
+ return failResult({
192
+ command: "skills.install",
193
+ human: [
194
+ "Refusing to link skills outside repository root by default.",
195
+ `Requested link root: ${dirname(linkPath)}`,
196
+ `Resolved existing target ancestor: ${linkParentRealpath}`,
197
+ `Repository root: ${repoRoot}`,
198
+ `If intentional, re-run with --${ALLOW_OUTSIDE_REPO_FLAG} to override.`,
199
+ ].join("\n"),
200
+ data: {
201
+ code: "outside_repo_target",
202
+ linkRoot: dirname(linkPath),
203
+ effectiveTargetRoot: linkParentRealpath,
204
+ repoRoot,
205
+ overrideFlag: `--${ALLOW_OUTSIDE_REPO_FLAG}`,
206
+ },
207
+ error: {
208
+ code: "outside_repo_target",
209
+ message: "Link target is outside repository root",
210
+ },
211
+ });
212
+ }
213
+
99
214
  function resolveDefaultLinkPath(cwd: string, editor: EditorName): string {
100
215
  return join(resolveLinkRoot(cwd, editor, undefined), "trekoon");
101
216
  }
@@ -146,9 +261,18 @@ function installCanonicalSkill(cwd: string): CliResult | { sourcePath: string; i
146
261
  };
147
262
  }
148
263
 
149
- function replaceOrCreateSymlink(linkPath: string, targetPath: string): CliResult | null {
264
+ function replaceOrCreateSymlink(
265
+ linkPath: string,
266
+ targetPath: string,
267
+ repoRoot: string,
268
+ allowOutsideRepo: boolean,
269
+ ): CliResult | null {
150
270
  if (!existsSync(linkPath)) {
151
271
  mkdirSync(dirname(linkPath), { recursive: true });
272
+ const boundaryFailure = revalidateLinkParentBoundary(repoRoot, linkPath, allowOutsideRepo);
273
+ if (boundaryFailure) {
274
+ return boundaryFailure;
275
+ }
152
276
  symlinkSync(targetPath, linkPath, "dir");
153
277
  return null;
154
278
  }
@@ -191,6 +315,10 @@ function replaceOrCreateSymlink(linkPath: string, targetPath: string): CliResult
191
315
  }
192
316
 
193
317
  rmSync(linkPath, { force: true });
318
+ const boundaryFailure = revalidateLinkParentBoundary(repoRoot, linkPath, allowOutsideRepo);
319
+ if (boundaryFailure) {
320
+ return boundaryFailure;
321
+ }
194
322
  symlinkSync(targetPath, linkPath, "dir");
195
323
  return null;
196
324
  }
@@ -259,9 +387,16 @@ function runSkillsInstall(context: CliContext): CliResult {
259
387
  }
260
388
 
261
389
  const wantsLink: boolean = hasFlag(parsed.flags, "link");
390
+ const allowOutsideRepo: boolean = hasFlag(parsed.flags, ALLOW_OUTSIDE_REPO_FLAG);
262
391
  const rawEditor: string | undefined = readOption(parsed.options, "editor");
263
392
  const rawTo: string | undefined = readOption(parsed.options, "to");
264
393
 
394
+ if (allowOutsideRepo && !wantsLink) {
395
+ return invalidInput("skills.install", `--${ALLOW_OUTSIDE_REPO_FLAG} requires --link.`, {
396
+ allowOutsideRepo,
397
+ });
398
+ }
399
+
265
400
  if (!wantsLink && rawEditor !== undefined) {
266
401
  return invalidInput("skills.install", "--editor requires --link.", {
267
402
  editor: rawEditor,
@@ -299,22 +434,42 @@ function runSkillsInstall(context: CliContext): CliResult {
299
434
  let linkTarget: string | null = null;
300
435
 
301
436
  if (wantsLink && editor !== undefined) {
302
- const linkRoot: string = resolveLinkRoot(context.cwd, editor, rawTo);
437
+ const validation = validateLinkRoot(context.cwd, editor, rawTo, allowOutsideRepo);
438
+ if ("ok" in validation) {
439
+ return validation;
440
+ }
441
+
442
+ const linkRoot: string = validation.linkRoot;
303
443
  linkPath = join(linkRoot, "trekoon");
304
444
  linkTarget = installResult.installedDir;
305
- const linkFailure = replaceOrCreateSymlink(linkPath, linkTarget);
445
+ const linkFailure = replaceOrCreateSymlink(
446
+ linkPath,
447
+ linkTarget,
448
+ realpathSync(context.cwd),
449
+ allowOutsideRepo,
450
+ );
306
451
  if (linkFailure) {
307
452
  return linkFailure;
308
453
  }
309
- }
310
454
 
311
- outcome = {
312
- sourcePath: installResult.sourcePath,
313
- installedPath: installResult.installedPath,
314
- installedDir: installResult.installedDir,
315
- linkPath,
316
- linkTarget,
317
- };
455
+ outcome = {
456
+ sourcePath: installResult.sourcePath,
457
+ installedPath: installResult.installedPath,
458
+ installedDir: installResult.installedDir,
459
+ linkPath,
460
+ linkTarget,
461
+ outsideRepoLink: validation.outsideRepoLink,
462
+ };
463
+ } else {
464
+ outcome = {
465
+ sourcePath: installResult.sourcePath,
466
+ installedPath: installResult.installedPath,
467
+ installedDir: installResult.installedDir,
468
+ linkPath,
469
+ linkTarget,
470
+ outsideRepoLink: false,
471
+ };
472
+ }
318
473
  } catch (error: unknown) {
319
474
  const message = error instanceof Error ? error.message : "Unknown skills install failure";
320
475
  return failResult({
@@ -335,6 +490,11 @@ function runSkillsInstall(context: CliContext): CliResult {
335
490
  command: "skills.install",
336
491
  human: outcome.linkPath
337
492
  ? [
493
+ ...(outcome.outsideRepoLink
494
+ ? [
495
+ `WARNING: Linking outside repository root because --${ALLOW_OUTSIDE_REPO_FLAG} was provided.`,
496
+ ]
497
+ : []),
338
498
  "Installed Trekoon skill and linked editor path.",
339
499
  `Source: ${outcome.sourcePath}`,
340
500
  `Installed file: ${outcome.installedPath}`,
@@ -353,6 +513,9 @@ function runSkillsInstall(context: CliContext): CliResult {
353
513
  linked: outcome.linkPath !== null,
354
514
  linkPath: outcome.linkPath,
355
515
  linkTarget: outcome.linkTarget,
516
+ outsideRepoLink: outcome.outsideRepoLink,
517
+ outsideRepoOverrideUsed: outcome.outsideRepoLink,
518
+ outsideRepoOverrideFlag: outcome.outsideRepoLink ? `--${ALLOW_OUTSIDE_REPO_FLAG}` : null,
356
519
  },
357
520
  });
358
521
  }
@@ -113,6 +113,10 @@ export async function runSync(context: CliContext): Promise<CliResult> {
113
113
  `Scanned events: ${summary.scannedEvents}`,
114
114
  `Applied events: ${summary.appliedEvents}`,
115
115
  `Created conflicts: ${summary.createdConflicts}`,
116
+ `Malformed payloads: ${summary.diagnostics.malformedPayloadEvents}`,
117
+ `Quarantined events: ${summary.diagnostics.quarantinedEvents}`,
118
+ `Conflict events: ${summary.diagnostics.conflictEvents}`,
119
+ ...summary.diagnostics.errorHints,
116
120
  ].join("\n"),
117
121
  data: summary,
118
122
  });
@@ -278,11 +278,58 @@ function recordMigration(db: Database, migration: Migration): void {
278
278
  );
279
279
  }
280
280
 
281
+ function isSchemaCurrentFastPath(db: Database, latestVersion: number): boolean {
282
+ if (latestVersion === 0 || !migrationTableExists(db) || !hasMigrationVersionColumn(db)) {
283
+ return false;
284
+ }
285
+
286
+ const row = db
287
+ .query(
288
+ `
289
+ SELECT
290
+ COALESCE(MIN(version), 0) AS min_version,
291
+ COALESCE(MAX(version), 0) AS max_version,
292
+ COUNT(DISTINCT version) AS distinct_versions,
293
+ SUM(CASE WHEN version IS NULL THEN 1 ELSE 0 END) AS null_versions
294
+ FROM schema_migrations;
295
+ `,
296
+ )
297
+ .get() as
298
+ | {
299
+ min_version: number;
300
+ max_version: number;
301
+ distinct_versions: number;
302
+ null_versions: number;
303
+ }
304
+ | null;
305
+
306
+ if (!row) {
307
+ return false;
308
+ }
309
+
310
+ return (
311
+ row.null_versions === 0 &&
312
+ row.min_version === 1 &&
313
+ row.max_version === latestVersion &&
314
+ row.distinct_versions === latestVersion
315
+ );
316
+ }
317
+
281
318
  export function migrateDatabase(db: Database): void {
319
+ validateMigrationPlan();
320
+
321
+ const latestVersion: number = MIGRATIONS[MIGRATIONS.length - 1]?.version ?? 0;
322
+
323
+ // Fast path: avoid BEGIN EXCLUSIVE when schema is already current.
324
+ // This reduces startup lock contention while keeping the explicit
325
+ // transactional migration path for non-current/legacy schemas.
326
+ if (isSchemaCurrentFastPath(db, latestVersion)) {
327
+ return;
328
+ }
329
+
282
330
  runExclusive(db, (): void => {
283
331
  ensureMigrationTable(db);
284
332
  ensureMigrationVersionColumn(db);
285
- validateMigrationPlan();
286
333
 
287
334
  const version: number = currentVersion(db);
288
335
 
@@ -225,17 +225,55 @@ function countBehind(remoteDb: Database, cursorToken: string): number {
225
225
  return row?.count ?? 0;
226
226
  }
227
227
 
228
- function listRemoteEventIds(remoteDb: Database): Set<string> {
229
- const rows = remoteDb.query("SELECT id FROM events;").all() as Array<{ id: string }>;
230
- return new Set(rows.map((row) => row.id));
228
+ function countAhead(localDb: Database, currentBranch: string | null, remoteDbPath: string): number {
229
+ if (!currentBranch) {
230
+ return 0;
231
+ }
232
+
233
+ localDb.query("ATTACH DATABASE ? AS sync_remote;").run(remoteDbPath);
234
+
235
+ try {
236
+ const row = localDb
237
+ .query(
238
+ `
239
+ SELECT COUNT(*) AS count
240
+ FROM events AS local_events
241
+ WHERE local_events.git_branch = @branch
242
+ AND NOT EXISTS (
243
+ SELECT 1
244
+ FROM sync_remote.events AS remote_events
245
+ WHERE remote_events.id = local_events.id
246
+ );
247
+ `,
248
+ )
249
+ .get({ "@branch": currentBranch }) as { count: number } | null;
250
+
251
+ return row?.count ?? 0;
252
+ } finally {
253
+ localDb.query("DETACH DATABASE sync_remote;").run();
254
+ }
231
255
  }
232
256
 
233
- function countAhead(localDb: Database, currentBranch: string | null, remoteEventIds: Set<string>): number {
234
- const rows = localDb
235
- .query("SELECT id, git_branch FROM events WHERE git_branch = ?;")
236
- .all(currentBranch) as Array<{ id: string; git_branch: string | null }>;
257
+ function buildSyncErrorHints(diagnostics: {
258
+ malformedPayloadEvents: number;
259
+ applyRejectedEvents: number;
260
+ conflictEvents: number;
261
+ }): string[] {
262
+ const hints: string[] = [];
263
+
264
+ if (diagnostics.malformedPayloadEvents > 0) {
265
+ hints.push("Malformed event payloads were quarantined; inspect sync conflicts with field '__payload__'.");
266
+ }
267
+
268
+ if (diagnostics.applyRejectedEvents > 0) {
269
+ hints.push("Some events were quarantined as invalid; inspect sync conflicts with field '__apply__'.");
270
+ }
271
+
272
+ if (diagnostics.conflictEvents > 0) {
273
+ hints.push("Field-level conflicts detected; run 'trekoon sync conflicts list' and resolve pending entries.");
274
+ }
237
275
 
238
- return rows.filter((row) => !remoteEventIds.has(row.id)).length;
276
+ return hints;
239
277
  }
240
278
 
241
279
  function readFieldValue(payload: EventPayload, field: string): unknown {
@@ -631,7 +669,7 @@ export function syncStatus(cwd: string, sourceBranch: string): SyncStatusSummary
631
669
  try {
632
670
  return {
633
671
  sourceBranch,
634
- ahead: countAhead(storage.db, git.branchName, listRemoteEventIds(remote.db)),
672
+ ahead: countAhead(storage.db, git.branchName, remote.path),
635
673
  behind: countBehind(remote.db, cursorToken),
636
674
  pendingConflicts: countPendingConflicts(storage.db),
637
675
  git,
@@ -658,6 +696,10 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
658
696
 
659
697
  let appliedEvents = 0;
660
698
  let createdConflicts = 0;
699
+ let malformedPayloadEvents = 0;
700
+ let applyRejectedEvents = 0;
701
+ let quarantinedEvents = 0;
702
+ let conflictEvents = 0;
661
703
  let lastToken: string | null = null;
662
704
  let lastEventAt: number | null = cursor?.last_event_at ?? null;
663
705
 
@@ -666,6 +708,8 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
666
708
  const payloadValidation = parsePayload(incoming.payload);
667
709
 
668
710
  if (!payloadValidation.ok) {
711
+ malformedPayloadEvents += 1;
712
+ quarantinedEvents += 1;
669
713
  createConflict(
670
714
  storage.db,
671
715
  incoming,
@@ -688,6 +732,7 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
688
732
  const conflict = entityFieldConflict(storage.db, sourceBranch, incoming, fieldName, value);
689
733
 
690
734
  if (conflict) {
735
+ conflictEvents += 1;
691
736
  createConflict(storage.db, incoming, fieldName, conflict.oursValue, conflict.theirsValue);
692
737
  createdConflicts += 1;
693
738
  continue;
@@ -699,6 +744,8 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
699
744
  if (applyEntityFields(storage.db, incoming, fieldsToApply)) {
700
745
  appliedEvents += 1;
701
746
  } else if (incoming.operation !== "resolve_conflict") {
747
+ applyRejectedEvents += 1;
748
+ quarantinedEvents += 1;
702
749
  createConflict(
703
750
  storage.db,
704
751
  incoming,
@@ -726,6 +773,17 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
726
773
  appliedEvents,
727
774
  createdConflicts,
728
775
  cursorToken: lastToken,
776
+ diagnostics: {
777
+ malformedPayloadEvents,
778
+ applyRejectedEvents,
779
+ quarantinedEvents,
780
+ conflictEvents,
781
+ errorHints: buildSyncErrorHints({
782
+ malformedPayloadEvents,
783
+ applyRejectedEvents,
784
+ conflictEvents,
785
+ }),
786
+ },
729
787
  };
730
788
  } finally {
731
789
  remote.close();
package/src/sync/types.ts CHANGED
@@ -21,6 +21,15 @@ export interface PullSummary {
21
21
  readonly appliedEvents: number;
22
22
  readonly createdConflicts: number;
23
23
  readonly cursorToken: string | null;
24
+ readonly diagnostics: SyncPullDiagnostics;
25
+ }
26
+
27
+ export interface SyncPullDiagnostics {
28
+ readonly malformedPayloadEvents: number;
29
+ readonly applyRejectedEvents: number;
30
+ readonly quarantinedEvents: number;
31
+ readonly conflictEvents: number;
32
+ readonly errorHints: readonly string[];
24
33
  }
25
34
 
26
35
  export interface ResolveSummary {