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
package/src/index.ts CHANGED
@@ -5,7 +5,7 @@ import { executeShell, parseInvocation, renderShellResult } from "./runtime/cli-
5
5
  export async function run(argv: readonly string[] = process.argv.slice(2)): Promise<void> {
6
6
  const parsed = parseInvocation(argv);
7
7
  const result = await executeShell(parsed);
8
- const rendered: string = renderShellResult(result, parsed.mode, parsed.compatibilityMode);
8
+ const rendered: string = renderShellResult(result, parsed.mode, parsed.compatibilityMode, { compact: parsed.compact });
9
9
 
10
10
  if (result.ok) {
11
11
  process.stdout.write(`${rendered}\n`);
package/src/io/output.ts CHANGED
@@ -13,8 +13,9 @@ const CONTRACT_VERSION = "1.0.0";
13
13
  const COMPATIBILITY_DEPRECATED_SINCE = "0.1.8";
14
14
  const COMPATIBILITY_REMOVAL_AFTER = "2026-09-30";
15
15
 
16
- interface RenderOptions {
16
+ export interface RenderOptions {
17
17
  readonly compatibilityMode?: CompatibilityMode | null;
18
+ readonly compact?: boolean;
18
19
  }
19
20
 
20
21
  function toLegacySyncCommandId(command: string): string {
@@ -136,13 +137,14 @@ export function failResult(input: ResultInput & { readonly error: ToonError }):
136
137
 
137
138
  export function toToonEnvelope(result: CliResult, options: RenderOptions = {}): ToonEnvelope {
138
139
  const compatibilityMode: CompatibilityMode | null = options.compatibilityMode ?? null;
140
+ const compact: boolean = options.compact ?? false;
139
141
  const command: string = resolveCompatibilityCommand(result.command, compatibilityMode);
140
142
 
141
143
  return {
142
144
  ok: result.ok,
143
145
  command,
144
146
  data: result.data,
145
- metadata: createContractMetadata(result, compatibilityMode),
147
+ ...(compact ? {} : { metadata: createContractMetadata(result, compatibilityMode) }),
146
148
  ...(result.error ? { error: result.error } : {}),
147
149
  ...(result.meta ? { meta: result.meta } : {}),
148
150
  };
@@ -8,11 +8,12 @@ import { runMigrate } from "../commands/migrate";
8
8
  import { runQuickstart } from "../commands/quickstart";
9
9
  import { runSession } from "../commands/session";
10
10
  import { runSkills } from "../commands/skills";
11
+ import { runSuggest } from "../commands/suggest";
11
12
  import { runSubtask } from "../commands/subtask";
12
13
  import { runSync } from "../commands/sync";
13
14
  import { runTask } from "../commands/task";
14
15
  import { runWipe } from "../commands/wipe";
15
- import { failResult, okResult, renderResult } from "../io/output";
16
+ import { failResult, okResult, renderResult, type RenderOptions } from "../io/output";
16
17
  import { resolveStorageResolutionDiagnostics } from "../storage/database";
17
18
  import { type CliContext, type CliResult, type CompatibilityMode, type OutputMode } from "./command-types";
18
19
  import { CLI_VERSION } from "./version";
@@ -32,11 +33,13 @@ const SUPPORTED_ROOT_COMMANDS: readonly string[] = [
32
33
  "migrate",
33
34
  "sync",
34
35
  "skills",
36
+ "suggest",
35
37
  "wipe",
36
38
  ];
37
39
 
38
40
  export interface ParsedInvocation {
39
41
  readonly mode: OutputMode;
42
+ readonly compact: boolean;
40
43
  readonly compatibilityMode: CompatibilityMode | null;
41
44
  readonly compatibilityModeRaw: string | null;
42
45
  readonly compatibilityModeMissingValue: boolean;
@@ -53,6 +56,7 @@ export interface ParseInvocationOptions {
53
56
  export function parseInvocation(argv: readonly string[], options: ParseInvocationOptions = {}): ParsedInvocation {
54
57
  const stdoutIsTTY: boolean = options.stdoutIsTTY ?? Boolean(process.stdout.isTTY);
55
58
  let explicitMode: OutputMode | null = null;
59
+ let compact = false;
56
60
  let compatibilityModeRaw: string | null = null;
57
61
  let compatibilityModeMissingValue = false;
58
62
  let wantsHelp = false;
@@ -75,6 +79,11 @@ export function parseInvocation(argv: readonly string[], options: ParseInvocatio
75
79
  continue;
76
80
  }
77
81
 
82
+ if (token === "--compact") {
83
+ compact = true;
84
+ continue;
85
+ }
86
+
78
87
  if (token === "--help" || token === "-h") {
79
88
  wantsHelp = true;
80
89
  continue;
@@ -105,6 +114,7 @@ export function parseInvocation(argv: readonly string[], options: ParseInvocatio
105
114
 
106
115
  return {
107
116
  mode: explicitMode ?? (stdoutIsTTY ? "human" : "json"),
117
+ compact,
108
118
  compatibilityMode,
109
119
  compatibilityModeRaw,
110
120
  compatibilityModeMissingValue,
@@ -115,13 +125,23 @@ export function parseInvocation(argv: readonly string[], options: ParseInvocatio
115
125
  };
116
126
  }
117
127
 
118
- export function renderShellResult(result: CliResult, mode: OutputMode, compatibilityMode: CompatibilityMode | null = null): string {
128
+ export function renderShellResult(
129
+ result: CliResult,
130
+ mode: OutputMode,
131
+ compatibilityMode: CompatibilityMode | null = null,
132
+ options: { compact?: boolean } = {},
133
+ ): string {
119
134
  const effectiveCompatibilityMode: CompatibilityMode | null =
120
135
  compatibilityMode === "legacy-sync-command-ids" && result.command.startsWith("sync.")
121
136
  ? compatibilityMode
122
137
  : null;
123
138
 
124
- return renderResult(result, mode, { compatibilityMode: effectiveCompatibilityMode });
139
+ const renderOptions: RenderOptions = {
140
+ compatibilityMode: effectiveCompatibilityMode,
141
+ compact: options.compact ?? false,
142
+ };
143
+
144
+ return renderResult(result, mode, renderOptions);
125
145
  }
126
146
 
127
147
  function isStringArray(value: unknown): value is string[] {
@@ -375,6 +395,9 @@ export async function executeShell(parsed: ParsedInvocation, cwd: string = proce
375
395
  case "skills":
376
396
  result = await runSkills(context);
377
397
  break;
398
+ case "suggest":
399
+ result = await runSuggest(context);
400
+ break;
378
401
  default:
379
402
  result = failResult({
380
403
  command: "shell",
@@ -32,7 +32,7 @@ export interface ToonEnvelope {
32
32
  readonly ok: boolean;
33
33
  readonly command: string;
34
34
  readonly data: unknown;
35
- readonly metadata: ContractMetadata;
35
+ readonly metadata?: ContractMetadata;
36
36
  readonly error?: ToonError;
37
37
  readonly meta?: Record<string, unknown>;
38
38
  }
@@ -98,6 +98,48 @@ export function resolveStorageResolutionDiagnostics(
98
98
  }
99
99
  }
100
100
 
101
+ /** Default connection-level busy_timeout applied at open time. */
102
+ const DEFAULT_BUSY_TIMEOUT_MS = 15000;
103
+
104
+ /**
105
+ * Maximum time (ms) to wait when acquiring the write lock via BEGIN IMMEDIATE.
106
+ * Kept below the default bun test timeout so that lock-contention surfaces as
107
+ * a SQLITE_BUSY error rather than a test-level timeout.
108
+ */
109
+ const WRITE_LOCK_BUSY_TIMEOUT_MS = 3000;
110
+
111
+ /**
112
+ * Execute a write transaction using BEGIN IMMEDIATE to acquire a reserved lock
113
+ * up-front, avoiding SQLITE_BUSY errors that occur when a deferred transaction
114
+ * is promoted to a write lock after readers have already started.
115
+ *
116
+ * A shorter busy_timeout is applied while acquiring the lock so callers receive
117
+ * a prompt SQLITE_BUSY error instead of blocking for the full connection-level
118
+ * timeout. The connection-level timeout is restored before returning.
119
+ */
120
+ export function writeTransaction<T>(db: Database, fn: (db: Database) => T): T {
121
+ db.exec(`PRAGMA busy_timeout = ${WRITE_LOCK_BUSY_TIMEOUT_MS};`);
122
+ try {
123
+ db.exec("BEGIN IMMEDIATE;");
124
+ } catch (error) {
125
+ db.exec(`PRAGMA busy_timeout = ${DEFAULT_BUSY_TIMEOUT_MS};`);
126
+ throw error;
127
+ }
128
+ db.exec(`PRAGMA busy_timeout = ${DEFAULT_BUSY_TIMEOUT_MS};`);
129
+ try {
130
+ const result: T = fn(db);
131
+ db.exec("COMMIT;");
132
+ return result;
133
+ } catch (error) {
134
+ try {
135
+ db.exec("ROLLBACK;");
136
+ } catch {
137
+ /* best-effort rollback — propagate the original error */
138
+ }
139
+ throw error;
140
+ }
141
+ }
142
+
101
143
  export function openTrekoonDatabase(
102
144
  workingDirectory: string = process.cwd(),
103
145
  options: OpenTrekoonDatabaseOptions = {},
@@ -110,7 +152,7 @@ export function openTrekoonDatabase(
110
152
 
111
153
  const db: Database = new Database(paths.databaseFile, { create: true });
112
154
 
113
- db.exec("PRAGMA busy_timeout = 5000;");
155
+ db.exec(`PRAGMA busy_timeout = ${DEFAULT_BUSY_TIMEOUT_MS};`);
114
156
  db.exec("PRAGMA journal_mode = WAL;");
115
157
  db.exec("PRAGMA foreign_keys = ON;");
116
158
 
@@ -1,5 +1,7 @@
1
1
  import { type Database } from "bun:sqlite";
2
2
 
3
+ import { writeTransaction } from "./database";
4
+
3
5
  export const DEFAULT_EVENT_RETENTION_DAYS = 90;
4
6
  const DAY_IN_MILLISECONDS = 24 * 60 * 60 * 1000;
5
7
 
@@ -18,6 +20,7 @@ export interface EventPruneSummary {
18
20
  readonly candidateCount: number;
19
21
  readonly archivedCount: number;
20
22
  readonly deletedCount: number;
23
+ readonly staleCursorCount: number;
21
24
  }
22
25
 
23
26
  function ensureArchiveTable(db: Database): void {
@@ -53,27 +56,72 @@ function countCandidates(db: Database, cutoffTimestamp: number): number {
53
56
  return row?.count ?? 0;
54
57
  }
55
58
 
59
+ function oldestCursorTimestamp(db: Database): number | null {
60
+ const row = db
61
+ .query("SELECT MIN(last_event_at) AS oldest FROM sync_cursors WHERE last_event_at IS NOT NULL;")
62
+ .get() as { oldest: number | null } | null;
63
+
64
+ return row?.oldest ?? null;
65
+ }
66
+
67
+ function countStaleCursors(db: Database): number {
68
+ // A cursor is stale when its last_event_at references a timestamp
69
+ // that has no corresponding event remaining in the events table.
70
+ // We detect this by checking if the oldest event in the table is
71
+ // newer than the cursor's last_event_at.
72
+ const oldestEventRow = db
73
+ .query("SELECT MIN(created_at) AS oldest FROM events;")
74
+ .get() as { oldest: number | null } | null;
75
+
76
+ const oldestEventAt: number | null = oldestEventRow?.oldest ?? null;
77
+
78
+ if (oldestEventAt === null) {
79
+ // No events at all — any cursor with a last_event_at is stale.
80
+ const row = db
81
+ .query("SELECT COUNT(*) AS count FROM sync_cursors WHERE last_event_at IS NOT NULL;")
82
+ .get() as { count: number } | null;
83
+ return row?.count ?? 0;
84
+ }
85
+
86
+ const row = db
87
+ .query(
88
+ "SELECT COUNT(*) AS count FROM sync_cursors WHERE last_event_at IS NOT NULL AND last_event_at < ?;",
89
+ )
90
+ .get(oldestEventAt) as { count: number } | null;
91
+
92
+ return row?.count ?? 0;
93
+ }
94
+
56
95
  export function pruneEvents(db: Database, options: EventPruneOptions = {}): EventPruneSummary {
57
96
  const retentionDays: number = assertRetentionDays(options.retentionDays ?? DEFAULT_EVENT_RETENTION_DAYS);
58
97
  const dryRun: boolean = options.dryRun ?? false;
59
98
  const archive: boolean = options.archive ?? false;
60
99
  const now: number = options.now ?? Date.now();
61
- const cutoffTimestamp: number = now - retentionDays * DAY_IN_MILLISECONDS;
62
- const candidateCount: number = countCandidates(db, cutoffTimestamp);
100
+ const retentionCutoff: number = now - retentionDays * DAY_IN_MILLISECONDS;
101
+
102
+ // Guard: never prune events that a sync cursor still references.
103
+ // The effective cutoff is the earlier of the retention cutoff and
104
+ // the oldest cursor timestamp — so cursors always have replayable history.
105
+ const oldest: number | null = oldestCursorTimestamp(db);
106
+ const effectiveCutoff: number = oldest !== null ? Math.min(retentionCutoff, oldest) : retentionCutoff;
107
+
108
+ const candidateCount: number = countCandidates(db, effectiveCutoff);
109
+ const staleCursors: number = countStaleCursors(db);
63
110
 
64
111
  if (dryRun || candidateCount === 0) {
65
112
  return {
66
113
  retentionDays,
67
- cutoffTimestamp,
114
+ cutoffTimestamp: effectiveCutoff,
68
115
  dryRun,
69
116
  archive,
70
117
  candidateCount,
71
118
  archivedCount: 0,
72
119
  deletedCount: 0,
120
+ staleCursorCount: staleCursors,
73
121
  };
74
122
  }
75
123
 
76
- return db.transaction((): EventPruneSummary => {
124
+ return writeTransaction(db, (): EventPruneSummary => {
77
125
  let archivedCount = 0;
78
126
 
79
127
  if (archive) {
@@ -118,21 +166,22 @@ export function pruneEvents(db: Database, options: EventPruneOptions = {}): Even
118
166
  version = excluded.version;
119
167
  `,
120
168
  )
121
- .run(cutoffTimestamp);
169
+ .run(effectiveCutoff);
122
170
 
123
171
  archivedCount = archived.changes;
124
172
  }
125
173
 
126
- const deleted = db.query("DELETE FROM events WHERE created_at < ?;").run(cutoffTimestamp);
174
+ const deleted = db.query("DELETE FROM events WHERE created_at < ?;").run(effectiveCutoff);
127
175
 
128
176
  return {
129
177
  retentionDays,
130
- cutoffTimestamp,
178
+ cutoffTimestamp: effectiveCutoff,
131
179
  dryRun,
132
180
  archive,
133
181
  candidateCount,
134
182
  archivedCount,
135
183
  deletedCount: deleted.changes,
184
+ staleCursorCount: staleCursors,
136
185
  };
137
- })();
186
+ });
138
187
  }
@@ -177,9 +177,64 @@ const MIGRATIONS: readonly Migration[] = [
177
177
  up(db: Database): void {
178
178
  migrateWorktreeScopedSyncMetadata(db);
179
179
  },
180
- down(db: Database): void {
181
- db.exec("DROP INDEX IF EXISTS idx_sync_cursors_owner;");
182
- db.exec("DROP INDEX IF EXISTS idx_git_context_scope_path;");
180
+ down(_db: Database): void {
181
+ throw new Error(
182
+ "Migration 0004 (worktree_scoped_sync_metadata) is irreversible. " +
183
+ "It adds columns via ALTER TABLE that cannot be removed without " +
184
+ "reconstructing tables and risking data loss. " +
185
+ "Rollback below version 4 is not supported.",
186
+ );
187
+ },
188
+ },
189
+ {
190
+ version: 5,
191
+ name: "0005_dependency_edge_integrity",
192
+ up(db: Database): void {
193
+ // Clean up orphaned dependency rows where source or target no longer exists.
194
+ db.exec(`
195
+ DELETE FROM dependencies
196
+ WHERE source_id NOT IN (SELECT id FROM tasks UNION ALL SELECT id FROM subtasks)
197
+ OR depends_on_id NOT IN (SELECT id FROM tasks UNION ALL SELECT id FROM subtasks);
198
+ `);
199
+
200
+ // Deduplicate any existing duplicate edges before creating the unique index.
201
+ // Keep one arbitrary row per logical edge (MIN(id) is lexicographic, not chronological, but any survivor is equivalent).
202
+ db.exec(`
203
+ DELETE FROM dependencies
204
+ WHERE id NOT IN (
205
+ SELECT MIN(id) FROM dependencies
206
+ GROUP BY source_id, depends_on_id
207
+ );
208
+ `);
209
+
210
+ db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_dependencies_edge ON dependencies (source_id, depends_on_id);");
211
+ },
212
+ down(_db: Database): void {
213
+ throw new Error(
214
+ "Migration 0005 (dependency_edge_integrity) is irreversible. " +
215
+ "It removes orphaned rows and deduplicates dependency edges. " +
216
+ "Rollback below version 5 is not supported.",
217
+ );
218
+ },
219
+ },
220
+ {
221
+ version: 6,
222
+ name: "0006_add_owner_column",
223
+ up(db: Database): void {
224
+ if (!tableHasColumn(db, "tasks", "owner")) {
225
+ db.exec("ALTER TABLE tasks ADD COLUMN owner TEXT;");
226
+ }
227
+ if (!tableHasColumn(db, "subtasks", "owner")) {
228
+ db.exec("ALTER TABLE subtasks ADD COLUMN owner TEXT;");
229
+ }
230
+ },
231
+ down(_db: Database): void {
232
+ throw new Error(
233
+ "Migration 0006 (add_owner_column) is irreversible. " +
234
+ "It adds columns via ALTER TABLE that cannot be removed without " +
235
+ "reconstructing tables and risking data loss. " +
236
+ "Rollback below version 6 is not supported.",
237
+ );
183
238
  },
184
239
  },
185
240
  ];
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
 
3
3
  import { type Database } from "bun:sqlite";
4
4
 
5
- import { openTrekoonDatabase } from "../storage/database";
5
+ import { openTrekoonDatabase, writeTransaction } from "../storage/database";
6
6
  import { countBranchEventsSince, queryBranchEventsSince } from "./branch-db";
7
7
  import { persistGitContext, resolveGitContext } from "./git-context";
8
8
  import {
@@ -15,6 +15,49 @@ import {
15
15
  type SyncStatusSummary,
16
16
  } from "./types";
17
17
 
18
+ function isCursorStale(db: Database, cursorToken: string, sourceBranch: string): boolean {
19
+ if (cursorToken === "0:") {
20
+ return false;
21
+ }
22
+
23
+ const [createdAtRaw, idRaw] = cursorToken.split(":");
24
+ const createdAt: number = Number.parseInt(createdAtRaw ?? "0", 10);
25
+ const id: string = idRaw ?? "";
26
+
27
+ if (!Number.isFinite(createdAt) || createdAt === 0) {
28
+ return false;
29
+ }
30
+
31
+ // Check if the event referenced by the cursor still exists.
32
+ // If the cursor references a specific event id, check for it.
33
+ // Otherwise, check if any event at or after the cursor timestamp exists
34
+ // on the source branch.
35
+ if (id.length > 0) {
36
+ const row = db
37
+ .query("SELECT id FROM events WHERE id = ? LIMIT 1;")
38
+ .get(id) as { id: string } | null;
39
+ if (row) {
40
+ return false;
41
+ }
42
+ }
43
+
44
+ // The referenced event is gone. Check if there are any events on the
45
+ // source branch at or after the cursor timestamp — if not, the cursor
46
+ // may simply be at the end of the stream.
47
+ const newerRow = db
48
+ .query(
49
+ `SELECT id FROM events
50
+ WHERE git_branch = ? AND created_at >= ?
51
+ ORDER BY created_at ASC, id ASC
52
+ LIMIT 1;`,
53
+ )
54
+ .get(sourceBranch, createdAt) as { id: string } | null;
55
+
56
+ // If there are newer events but our referenced event is gone,
57
+ // events between the cursor and the oldest remaining event were pruned.
58
+ return newerRow !== null;
59
+ }
60
+
18
61
  interface StoredEvent {
19
62
  readonly id: string;
20
63
  readonly entity_kind: string;
@@ -289,23 +332,22 @@ function entityFieldConflict(
289
332
  return null;
290
333
  }
291
334
 
335
+ // Note: loads all matching events into memory. For entities with very large
336
+ // event histories, consider a cursor-based scan. The idx_events_entity index
337
+ // keeps the scan narrow by (entity_kind, entity_id).
292
338
  const rows = localDb
293
339
  .query(
294
340
  `
295
341
  SELECT payload, git_branch
296
342
  FROM events
297
- WHERE entity_kind = ? AND entity_id = ?
343
+ WHERE entity_kind = ? AND entity_id = ? AND git_branch != ?
298
344
  ORDER BY created_at DESC, id DESC
299
- LIMIT 50;
300
- `,
345
+ LIMIT 500;
346
+ `,
301
347
  )
302
- .all(event.entity_kind, event.entity_id) as Array<{ payload: string; git_branch: string | null }>;
348
+ .all(event.entity_kind, event.entity_id, sourceBranch) as Array<{ payload: string; git_branch: string | null }>;
303
349
 
304
350
  for (const row of rows) {
305
- if (row.git_branch === sourceBranch) {
306
- continue;
307
- }
308
-
309
351
  const payloadValidation = parsePayload(row.payload);
310
352
  if (!payloadValidation.ok) {
311
353
  continue;
@@ -360,6 +402,15 @@ function createConflict(
360
402
  ).run(randomUUID(), event.id, event.entity_kind, event.entity_id, fieldName, oursValue, theirsValue, resolution, now, now);
361
403
  }
362
404
 
405
+ function hasLocalEntityEdits(db: Database, entityKind: string, entityId: string, sourceBranch: string): boolean {
406
+ const row = db
407
+ .query(
408
+ `SELECT 1 FROM events WHERE entity_kind = ? AND entity_id = ? AND git_branch != ? LIMIT 1;`,
409
+ )
410
+ .get(entityKind, entityId, sourceBranch);
411
+ return row !== null;
412
+ }
413
+
363
414
  function rowExists(db: Database, tableName: string, id: string): boolean {
364
415
  const row = db.query(`SELECT id FROM ${tableName} WHERE id = ? LIMIT 1;`).get(id) as { id: string } | null;
365
416
  return row !== null;
@@ -495,10 +546,9 @@ function applyCreate(db: Database, event: StoredEvent, fields: Record<string, un
495
546
  updated_at,
496
547
  version
497
548
  ) VALUES (?, ?, ?, ?, ?, ?, ?, 1)
498
- ON CONFLICT(id) DO UPDATE SET
499
- source_id = excluded.source_id,
549
+ ON CONFLICT(source_id, depends_on_id) DO UPDATE SET
550
+ id = excluded.id,
500
551
  source_kind = excluded.source_kind,
501
- depends_on_id = excluded.depends_on_id,
502
552
  depends_on_kind = excluded.depends_on_kind,
503
553
  updated_at = excluded.updated_at,
504
554
  version = dependencies.version + 1;
@@ -689,6 +739,7 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
689
739
  persistGitContext(storage.db, git);
690
740
  const cursor = loadCursor(storage.db, git.worktreePath, sourceBranch);
691
741
  const cursorToken = cursor?.cursor_token ?? "0:";
742
+ const staleCursor: boolean = cursor !== null && isCursorStale(storage.db, cursorToken, sourceBranch);
692
743
  const incomingEvents: StoredEvent[] = queryBranchEventsSince(storage.db, sourceBranch, cursorToken) as StoredEvent[];
693
744
 
694
745
  // Same-branch fast path: skip conflict detection when already on sourceBranch.
@@ -697,7 +748,7 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
697
748
  let lastToken: string | null = null;
698
749
  let lastEventAt: number | null = cursor?.last_event_at ?? null;
699
750
 
700
- storage.db.transaction((): void => {
751
+ writeTransaction(storage.db, (): void => {
701
752
  for (const incoming of incomingEvents) {
702
753
  storeEvent(storage.db, incoming);
703
754
  lastToken = cursorTokenFromEvent(incoming);
@@ -707,7 +758,7 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
707
758
  if (lastToken) {
708
759
  saveCursor(storage.db, git.worktreePath, sourceBranch, lastToken, lastEventAt);
709
760
  }
710
- })();
761
+ });
711
762
 
712
763
  return {
713
764
  sourceBranch,
@@ -721,7 +772,10 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
721
772
  applyRejectedEvents: 0,
722
773
  quarantinedEvents: 0,
723
774
  conflictEvents: 0,
724
- errorHints: [],
775
+ staleCursor,
776
+ errorHints: staleCursor
777
+ ? ["Stale cursor detected; some events may have been pruned. Consider a full rebuild."]
778
+ : [],
725
779
  },
726
780
  };
727
781
  }
@@ -735,7 +789,7 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
735
789
  let lastToken: string | null = null;
736
790
  let lastEventAt: number | null = cursor?.last_event_at ?? null;
737
791
 
738
- storage.db.transaction((): void => {
792
+ writeTransaction(storage.db, (): void => {
739
793
  for (const incoming of incomingEvents) {
740
794
  const payloadValidation = parsePayload(incoming.payload);
741
795
 
@@ -758,6 +812,23 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
758
812
  }
759
813
 
760
814
  const payload: EventPayload = { fields: payloadValidation.fields };
815
+
816
+ const isDeleteWithLocalEdits =
817
+ incoming.operation.endsWith(".deleted") &&
818
+ hasLocalEntityEdits(storage.db, incoming.entity_kind, incoming.entity_id, sourceBranch);
819
+ if (isDeleteWithLocalEdits) {
820
+ // Note: dependency.removed is intentionally excluded — dependencies are
821
+ // edges (not entities with local edit history), so conflict detection
822
+ // does not apply to them.
823
+ createConflict(storage.db, incoming, "__delete__", null, "Entity deleted on source branch");
824
+ createdConflicts += 1;
825
+ conflictEvents += 1;
826
+ storeEvent(storage.db, incoming);
827
+ lastToken = cursorTokenFromEvent(incoming);
828
+ lastEventAt = incoming.created_at;
829
+ continue;
830
+ }
831
+
761
832
  const fieldsToApply: Record<string, unknown> = {};
762
833
  let withheldConflictCount = 0;
763
834
 
@@ -801,7 +872,16 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
801
872
  if (lastToken) {
802
873
  saveCursor(storage.db, git.worktreePath, sourceBranch, lastToken, lastEventAt);
803
874
  }
804
- })();
875
+ });
876
+
877
+ const errorHints: string[] = buildSyncErrorHints({
878
+ malformedPayloadEvents,
879
+ applyRejectedEvents,
880
+ conflictEvents,
881
+ });
882
+ if (staleCursor) {
883
+ errorHints.push("Stale cursor detected; some events may have been pruned. Consider a full rebuild.");
884
+ }
805
885
 
806
886
  return {
807
887
  sourceBranch,
@@ -815,11 +895,8 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
815
895
  applyRejectedEvents,
816
896
  quarantinedEvents,
817
897
  conflictEvents,
818
- errorHints: buildSyncErrorHints({
819
- malformedPayloadEvents,
820
- applyRejectedEvents,
821
- conflictEvents,
822
- }),
898
+ staleCursor,
899
+ errorHints,
823
900
  },
824
901
  };
825
902
  } finally {
@@ -1010,7 +1087,7 @@ export function syncResolve(cwd: string, conflictId: string, resolution: SyncRes
1010
1087
  throw new Error(`Conflict '${conflictId}' already resolved.`);
1011
1088
  }
1012
1089
 
1013
- storage.db.transaction((): void => {
1090
+ writeTransaction(storage.db, (): void => {
1014
1091
  if (resolution === "theirs") {
1015
1092
  updateSingleField(
1016
1093
  storage.db,
@@ -1027,7 +1104,7 @@ export function syncResolve(cwd: string, conflictId: string, resolution: SyncRes
1027
1104
  .run(resolution, now, conflict.id);
1028
1105
 
1029
1106
  appendResolutionEvent(storage.db, git.branchName, git.headSha, conflict, resolution);
1030
- })();
1107
+ });
1031
1108
 
1032
1109
  return {
1033
1110
  conflictId,
package/src/sync/types.ts CHANGED
@@ -31,6 +31,7 @@ export interface SyncPullDiagnostics {
31
31
  readonly applyRejectedEvents: number;
32
32
  readonly quarantinedEvents: number;
33
33
  readonly conflictEvents: number;
34
+ readonly staleCursor: boolean;
34
35
  readonly errorHints: readonly string[];
35
36
  }
36
37