@stoneforge/quarry 1.12.0 → 1.14.0

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 (199) hide show
  1. package/README.md +2 -0
  2. package/dist/api/quarry-api.d.ts +9 -1
  3. package/dist/api/quarry-api.d.ts.map +1 -1
  4. package/dist/api/quarry-api.js +21 -2
  5. package/dist/api/quarry-api.js.map +1 -1
  6. package/dist/api/types.d.ts +8 -1
  7. package/dist/api/types.d.ts.map +1 -1
  8. package/dist/api/types.js.map +1 -1
  9. package/dist/cli/commands/auto-link-helper.d.ts +33 -0
  10. package/dist/cli/commands/auto-link-helper.d.ts.map +1 -0
  11. package/dist/cli/commands/auto-link-helper.js +74 -0
  12. package/dist/cli/commands/auto-link-helper.js.map +1 -0
  13. package/dist/cli/commands/crud.d.ts +3 -0
  14. package/dist/cli/commands/crud.d.ts.map +1 -1
  15. package/dist/cli/commands/crud.js +144 -15
  16. package/dist/cli/commands/crud.js.map +1 -1
  17. package/dist/cli/commands/docs.js +2 -2
  18. package/dist/cli/commands/docs.js.map +1 -1
  19. package/dist/cli/commands/document.js +1 -1
  20. package/dist/cli/commands/document.js.map +1 -1
  21. package/dist/cli/commands/entity.js +1 -1
  22. package/dist/cli/commands/entity.js.map +1 -1
  23. package/dist/cli/commands/external-sync.d.ts +18 -0
  24. package/dist/cli/commands/external-sync.d.ts.map +1 -0
  25. package/dist/cli/commands/external-sync.js +2499 -0
  26. package/dist/cli/commands/external-sync.js.map +1 -0
  27. package/dist/cli/commands/library.js +1 -1
  28. package/dist/cli/commands/library.js.map +1 -1
  29. package/dist/cli/commands/message.js +2 -2
  30. package/dist/cli/commands/message.js.map +1 -1
  31. package/dist/cli/commands/serve.d.ts.map +1 -1
  32. package/dist/cli/commands/serve.js +2 -0
  33. package/dist/cli/commands/serve.js.map +1 -1
  34. package/dist/cli/commands/task.d.ts.map +1 -1
  35. package/dist/cli/commands/task.js +7 -4
  36. package/dist/cli/commands/task.js.map +1 -1
  37. package/dist/cli/commands/team.js +1 -1
  38. package/dist/cli/commands/team.js.map +1 -1
  39. package/dist/cli/commands/workflow.js +1 -1
  40. package/dist/cli/commands/workflow.js.map +1 -1
  41. package/dist/cli/runner.d.ts.map +1 -1
  42. package/dist/cli/runner.js +3 -0
  43. package/dist/cli/runner.js.map +1 -1
  44. package/dist/cli/utils/progress.d.ts +30 -0
  45. package/dist/cli/utils/progress.d.ts.map +1 -0
  46. package/dist/cli/utils/progress.js +47 -0
  47. package/dist/cli/utils/progress.js.map +1 -0
  48. package/dist/config/config.d.ts.map +1 -1
  49. package/dist/config/config.js +34 -0
  50. package/dist/config/config.js.map +1 -1
  51. package/dist/config/defaults.d.ts +13 -1
  52. package/dist/config/defaults.d.ts.map +1 -1
  53. package/dist/config/defaults.js +22 -0
  54. package/dist/config/defaults.js.map +1 -1
  55. package/dist/config/file.d.ts.map +1 -1
  56. package/dist/config/file.js +71 -0
  57. package/dist/config/file.js.map +1 -1
  58. package/dist/config/index.d.ts +3 -3
  59. package/dist/config/index.d.ts.map +1 -1
  60. package/dist/config/index.js +2 -2
  61. package/dist/config/index.js.map +1 -1
  62. package/dist/config/merge.d.ts.map +1 -1
  63. package/dist/config/merge.js +52 -1
  64. package/dist/config/merge.js.map +1 -1
  65. package/dist/config/types.d.ts +68 -1
  66. package/dist/config/types.d.ts.map +1 -1
  67. package/dist/config/types.js +33 -0
  68. package/dist/config/types.js.map +1 -1
  69. package/dist/config/validation.d.ts.map +1 -1
  70. package/dist/config/validation.js +64 -1
  71. package/dist/config/validation.js.map +1 -1
  72. package/dist/external-sync/adapters/document-sync-adapter.d.ts +150 -0
  73. package/dist/external-sync/adapters/document-sync-adapter.d.ts.map +1 -0
  74. package/dist/external-sync/adapters/document-sync-adapter.js +325 -0
  75. package/dist/external-sync/adapters/document-sync-adapter.js.map +1 -0
  76. package/dist/external-sync/adapters/task-sync-adapter.d.ts +177 -0
  77. package/dist/external-sync/adapters/task-sync-adapter.d.ts.map +1 -0
  78. package/dist/external-sync/adapters/task-sync-adapter.js +353 -0
  79. package/dist/external-sync/adapters/task-sync-adapter.js.map +1 -0
  80. package/dist/external-sync/auto-link.d.ts +66 -0
  81. package/dist/external-sync/auto-link.d.ts.map +1 -0
  82. package/dist/external-sync/auto-link.js +98 -0
  83. package/dist/external-sync/auto-link.js.map +1 -0
  84. package/dist/external-sync/conflict-resolver.d.ts +170 -0
  85. package/dist/external-sync/conflict-resolver.d.ts.map +1 -0
  86. package/dist/external-sync/conflict-resolver.js +580 -0
  87. package/dist/external-sync/conflict-resolver.js.map +1 -0
  88. package/dist/external-sync/index.d.ts +23 -0
  89. package/dist/external-sync/index.d.ts.map +1 -0
  90. package/dist/external-sync/index.js +24 -0
  91. package/dist/external-sync/index.js.map +1 -0
  92. package/dist/external-sync/provider-registry.d.ts +113 -0
  93. package/dist/external-sync/provider-registry.d.ts.map +1 -0
  94. package/dist/external-sync/provider-registry.js +205 -0
  95. package/dist/external-sync/provider-registry.js.map +1 -0
  96. package/dist/external-sync/providers/folder/folder-document-adapter.d.ts +97 -0
  97. package/dist/external-sync/providers/folder/folder-document-adapter.d.ts.map +1 -0
  98. package/dist/external-sync/providers/folder/folder-document-adapter.js +261 -0
  99. package/dist/external-sync/providers/folder/folder-document-adapter.js.map +1 -0
  100. package/dist/external-sync/providers/folder/folder-fs.d.ts +146 -0
  101. package/dist/external-sync/providers/folder/folder-fs.d.ts.map +1 -0
  102. package/dist/external-sync/providers/folder/folder-fs.js +300 -0
  103. package/dist/external-sync/providers/folder/folder-fs.js.map +1 -0
  104. package/dist/external-sync/providers/folder/folder-provider.d.ts +28 -0
  105. package/dist/external-sync/providers/folder/folder-provider.d.ts.map +1 -0
  106. package/dist/external-sync/providers/folder/folder-provider.js +87 -0
  107. package/dist/external-sync/providers/folder/folder-provider.js.map +1 -0
  108. package/dist/external-sync/providers/folder/index.d.ts +11 -0
  109. package/dist/external-sync/providers/folder/index.d.ts.map +1 -0
  110. package/dist/external-sync/providers/folder/index.js +13 -0
  111. package/dist/external-sync/providers/folder/index.js.map +1 -0
  112. package/dist/external-sync/providers/github/github-api.d.ts +271 -0
  113. package/dist/external-sync/providers/github/github-api.d.ts.map +1 -0
  114. package/dist/external-sync/providers/github/github-api.js +366 -0
  115. package/dist/external-sync/providers/github/github-api.js.map +1 -0
  116. package/dist/external-sync/providers/github/github-field-map.d.ts +76 -0
  117. package/dist/external-sync/providers/github/github-field-map.d.ts.map +1 -0
  118. package/dist/external-sync/providers/github/github-field-map.js +157 -0
  119. package/dist/external-sync/providers/github/github-field-map.js.map +1 -0
  120. package/dist/external-sync/providers/github/github-provider.d.ts +36 -0
  121. package/dist/external-sync/providers/github/github-provider.d.ts.map +1 -0
  122. package/dist/external-sync/providers/github/github-provider.js +212 -0
  123. package/dist/external-sync/providers/github/github-provider.js.map +1 -0
  124. package/dist/external-sync/providers/github/github-task-adapter.d.ts +135 -0
  125. package/dist/external-sync/providers/github/github-task-adapter.d.ts.map +1 -0
  126. package/dist/external-sync/providers/github/github-task-adapter.js +374 -0
  127. package/dist/external-sync/providers/github/github-task-adapter.js.map +1 -0
  128. package/dist/external-sync/providers/github/index.d.ts +12 -0
  129. package/dist/external-sync/providers/github/index.d.ts.map +1 -0
  130. package/dist/external-sync/providers/github/index.js +15 -0
  131. package/dist/external-sync/providers/github/index.js.map +1 -0
  132. package/dist/external-sync/providers/index.d.ts +13 -0
  133. package/dist/external-sync/providers/index.d.ts.map +1 -0
  134. package/dist/external-sync/providers/index.js +15 -0
  135. package/dist/external-sync/providers/index.js.map +1 -0
  136. package/dist/external-sync/providers/linear/index.d.ts +19 -0
  137. package/dist/external-sync/providers/linear/index.d.ts.map +1 -0
  138. package/dist/external-sync/providers/linear/index.js +19 -0
  139. package/dist/external-sync/providers/linear/index.js.map +1 -0
  140. package/dist/external-sync/providers/linear/linear-api.d.ts +252 -0
  141. package/dist/external-sync/providers/linear/linear-api.d.ts.map +1 -0
  142. package/dist/external-sync/providers/linear/linear-api.js +522 -0
  143. package/dist/external-sync/providers/linear/linear-api.js.map +1 -0
  144. package/dist/external-sync/providers/linear/linear-field-map.d.ts +135 -0
  145. package/dist/external-sync/providers/linear/linear-field-map.d.ts.map +1 -0
  146. package/dist/external-sync/providers/linear/linear-field-map.js +338 -0
  147. package/dist/external-sync/providers/linear/linear-field-map.js.map +1 -0
  148. package/dist/external-sync/providers/linear/linear-provider.d.ts +52 -0
  149. package/dist/external-sync/providers/linear/linear-provider.d.ts.map +1 -0
  150. package/dist/external-sync/providers/linear/linear-provider.js +169 -0
  151. package/dist/external-sync/providers/linear/linear-provider.js.map +1 -0
  152. package/dist/external-sync/providers/linear/linear-task-adapter.d.ts +190 -0
  153. package/dist/external-sync/providers/linear/linear-task-adapter.d.ts.map +1 -0
  154. package/dist/external-sync/providers/linear/linear-task-adapter.js +521 -0
  155. package/dist/external-sync/providers/linear/linear-task-adapter.js.map +1 -0
  156. package/dist/external-sync/providers/linear/linear-types.d.ts +114 -0
  157. package/dist/external-sync/providers/linear/linear-types.d.ts.map +1 -0
  158. package/dist/external-sync/providers/linear/linear-types.js +10 -0
  159. package/dist/external-sync/providers/linear/linear-types.js.map +1 -0
  160. package/dist/external-sync/providers/notion/index.d.ts +19 -0
  161. package/dist/external-sync/providers/notion/index.d.ts.map +1 -0
  162. package/dist/external-sync/providers/notion/index.js +20 -0
  163. package/dist/external-sync/providers/notion/index.js.map +1 -0
  164. package/dist/external-sync/providers/notion/notion-api.d.ts +253 -0
  165. package/dist/external-sync/providers/notion/notion-api.d.ts.map +1 -0
  166. package/dist/external-sync/providers/notion/notion-api.js +492 -0
  167. package/dist/external-sync/providers/notion/notion-api.js.map +1 -0
  168. package/dist/external-sync/providers/notion/notion-blocks.d.ts +93 -0
  169. package/dist/external-sync/providers/notion/notion-blocks.d.ts.map +1 -0
  170. package/dist/external-sync/providers/notion/notion-blocks.js +773 -0
  171. package/dist/external-sync/providers/notion/notion-blocks.js.map +1 -0
  172. package/dist/external-sync/providers/notion/notion-document-adapter.d.ts +176 -0
  173. package/dist/external-sync/providers/notion/notion-document-adapter.d.ts.map +1 -0
  174. package/dist/external-sync/providers/notion/notion-document-adapter.js +413 -0
  175. package/dist/external-sync/providers/notion/notion-document-adapter.js.map +1 -0
  176. package/dist/external-sync/providers/notion/notion-provider.d.ts +57 -0
  177. package/dist/external-sync/providers/notion/notion-provider.d.ts.map +1 -0
  178. package/dist/external-sync/providers/notion/notion-provider.js +159 -0
  179. package/dist/external-sync/providers/notion/notion-provider.js.map +1 -0
  180. package/dist/external-sync/providers/notion/notion-types.d.ts +388 -0
  181. package/dist/external-sync/providers/notion/notion-types.d.ts.map +1 -0
  182. package/dist/external-sync/providers/notion/notion-types.js +47 -0
  183. package/dist/external-sync/providers/notion/notion-types.js.map +1 -0
  184. package/dist/external-sync/sync-engine.d.ts +364 -0
  185. package/dist/external-sync/sync-engine.d.ts.map +1 -0
  186. package/dist/external-sync/sync-engine.js +1154 -0
  187. package/dist/external-sync/sync-engine.js.map +1 -0
  188. package/dist/index.d.ts +1 -0
  189. package/dist/index.d.ts.map +1 -1
  190. package/dist/index.js +2 -0
  191. package/dist/index.js.map +1 -1
  192. package/dist/server/index.js +8 -8
  193. package/dist/server/index.js.map +1 -1
  194. package/dist/services/inbox.js +1 -1
  195. package/dist/sync/hash.d.ts +5 -0
  196. package/dist/sync/hash.d.ts.map +1 -1
  197. package/dist/sync/hash.js +21 -2
  198. package/dist/sync/hash.js.map +1 -1
  199. package/package.json +10 -12
@@ -0,0 +1,1154 @@
1
+ /**
2
+ * Sync Engine — orchestrates push/pull operations between Stoneforge and external services
3
+ *
4
+ * The sync engine coordinates bidirectional synchronization between Stoneforge elements
5
+ * and external services (GitHub, Linear, etc.) using the provider registry and adapters.
6
+ *
7
+ * Core operations:
8
+ * - push(): Push locally-changed linked elements to external services
9
+ * - pull(): Pull externally-changed items into Stoneforge
10
+ * - sync(): Bidirectional sync (push then pull)
11
+ *
12
+ * Change detection:
13
+ * - Push: Query events since lastPushedAt, filter to elements with _externalSync metadata,
14
+ * compare content hash against lastPushedHash
15
+ * - Pull: Call adapter.listIssuesSince() using global sync cursor from settings,
16
+ * compare against lastPulledHash
17
+ *
18
+ * Usage:
19
+ * ```typescript
20
+ * import { createSyncEngine } from '@stoneforge/quarry';
21
+ *
22
+ * const engine = createSyncEngine({ api, registry, settings });
23
+ * const result = await engine.push({ all: true });
24
+ * const result = await engine.pull();
25
+ * const result = await engine.sync({ dryRun: true });
26
+ * ```
27
+ */
28
+ import { getExternalSyncState, setExternalSyncState, } from '@stoneforge/core';
29
+ import { createTimestamp } from '@stoneforge/core';
30
+ import { createHash } from 'crypto';
31
+ import { computeContentHashSync } from '../sync/hash.js';
32
+ import { taskToExternalTask, externalTaskToTaskUpdates, getFieldMapConfigForProvider } from './adapters/task-sync-adapter.js';
33
+ import { documentToExternalDocumentInput, externalDocumentToDocumentUpdates, computeExternalDocumentHash, resolveDocumentLibraryPaths } from './adapters/document-sync-adapter.js';
34
+ // ============================================================================
35
+ // Settings Keys
36
+ // ============================================================================
37
+ /** Key prefix for sync cursor settings */
38
+ const SYNC_CURSOR_KEY_PREFIX = 'external_sync.cursor';
39
+ /** Number of documents to push concurrently (balances throughput vs API rate limits) */
40
+ const PUSH_CONCURRENCY = 3;
41
+ /**
42
+ * Build a settings key for a sync cursor.
43
+ * Format: external_sync.cursor.<provider>.<project>.<adapterType>
44
+ */
45
+ function buildCursorKey(provider, project, adapterType) {
46
+ return `${SYNC_CURSOR_KEY_PREFIX}.${provider}.${project}.${adapterType}`;
47
+ }
48
+ // ============================================================================
49
+ // Default Conflict Resolver
50
+ // ============================================================================
51
+ /**
52
+ * Default conflict resolver — implements last-write-wins strategy.
53
+ * Compares updatedAt timestamps from local element and remote item.
54
+ */
55
+ const defaultConflictResolver = {
56
+ resolve(localElement, remoteItem, strategy) {
57
+ switch (strategy) {
58
+ case 'local_wins':
59
+ return { winner: 'local', resolved: true };
60
+ case 'remote_wins':
61
+ return { winner: 'remote', resolved: true };
62
+ case 'manual':
63
+ return { winner: 'manual', resolved: false };
64
+ case 'last_write_wins':
65
+ default: {
66
+ const localTime = new Date(localElement.updatedAt).getTime();
67
+ const remoteTime = new Date(remoteItem.updatedAt).getTime();
68
+ if (remoteTime >= localTime) {
69
+ return { winner: 'remote', resolved: true };
70
+ }
71
+ return { winner: 'local', resolved: true };
72
+ }
73
+ }
74
+ },
75
+ };
76
+ // ============================================================================
77
+ // SyncEngine
78
+ // ============================================================================
79
+ /**
80
+ * Sync Engine — coordinates push/pull operations between Stoneforge and external services.
81
+ *
82
+ * The engine is stateless — all state is stored in element metadata (_externalSync)
83
+ * and settings (sync cursors). Each operation reads current state, computes changes,
84
+ * and writes updated state.
85
+ */
86
+ export class SyncEngine {
87
+ api;
88
+ registry;
89
+ settings;
90
+ conflictResolver;
91
+ defaultStrategy;
92
+ providerConfigs;
93
+ constructor(config) {
94
+ this.api = config.api;
95
+ this.registry = config.registry;
96
+ this.settings = config.settings;
97
+ this.conflictResolver = config.conflictResolver ?? defaultConflictResolver;
98
+ this.defaultStrategy = config.defaultConflictStrategy ?? 'last_write_wins';
99
+ this.providerConfigs = config.providerConfigs ?? [];
100
+ }
101
+ // --------------------------------------------------------------------------
102
+ // Push — push locally-changed linked elements to external services
103
+ // --------------------------------------------------------------------------
104
+ /**
105
+ * Push locally-changed linked elements to external services.
106
+ *
107
+ * Algorithm:
108
+ * 1. Find elements with _externalSync metadata
109
+ * 2. For each element, query events since lastPushedAt
110
+ * 3. Compare current content hash against lastPushedHash
111
+ * 4. If hash differs, push changes to external service via adapter
112
+ * 5. Update _externalSync metadata with new timestamp and hash
113
+ *
114
+ * @param options - Push options (dryRun, taskIds, all)
115
+ * @returns Aggregated sync result across all providers
116
+ */
117
+ async push(options = {}) {
118
+ const pushed = [];
119
+ const skipped = [];
120
+ const conflicts = [];
121
+ const errors = [];
122
+ const now = createTimestamp();
123
+ // Step 1: Find elements to push
124
+ let elements = await this.findLinkedElements(options);
125
+ // Step 1.5: Batch-resolve library paths for all document elements
126
+ // This avoids N+1 queries by resolving all paths upfront
127
+ const documentElements = elements.filter(el => {
128
+ const syncState = getExternalSyncState(el.metadata);
129
+ return syncState?.adapterType === 'document';
130
+ });
131
+ const libraryPaths = documentElements.length > 0
132
+ ? await resolveDocumentLibraryPaths(this.api, documentElements.map(el => el.id))
133
+ : new Map();
134
+ // Step 1.6: Filter out documents without a library unless includeNoLibrary is set
135
+ let noLibrarySkipped = 0;
136
+ if (!options.includeNoLibrary && documentElements.length > 0) {
137
+ const beforeCount = elements.length;
138
+ elements = elements.filter(el => {
139
+ const syncState = getExternalSyncState(el.metadata);
140
+ if (syncState?.adapterType !== 'document')
141
+ return true; // keep non-documents
142
+ return libraryPaths.has(el.id);
143
+ });
144
+ noLibrarySkipped = beforeCount - elements.length;
145
+ }
146
+ // Notify caller of total count before processing begins
147
+ options.onBeforeProcess?.(elements.length);
148
+ // Step 2: Process elements concurrently in batches
149
+ let processedCount = 0;
150
+ // Notify caller of total element count on first progress callback
151
+ options.onProgress?.(0, elements.length);
152
+ for (let i = 0; i < elements.length; i += PUSH_CONCURRENCY) {
153
+ const batch = elements.slice(i, i + PUSH_CONCURRENCY);
154
+ const results = await Promise.allSettled(batch.map(element => this.pushSingleElement(element, options, now, libraryPaths)));
155
+ for (let j = 0; j < results.length; j++) {
156
+ const result = results[j];
157
+ if (result.status === 'fulfilled') {
158
+ const outcome = result.value;
159
+ if (outcome === 'pushed') {
160
+ pushed.push(1);
161
+ }
162
+ else if (outcome === 'skipped') {
163
+ skipped.push(1);
164
+ }
165
+ }
166
+ else {
167
+ // rejected — extract error info
168
+ const element = batch[j];
169
+ const syncState = getExternalSyncState(element.metadata);
170
+ const err = result.reason;
171
+ errors.push({
172
+ elementId: element.id,
173
+ externalId: syncState?.externalId ?? '',
174
+ provider: syncState?.provider ?? 'unknown',
175
+ project: syncState?.project ?? 'unknown',
176
+ message: err instanceof Error ? err.message : String(err),
177
+ retryable: isRetryableError(err),
178
+ });
179
+ }
180
+ }
181
+ processedCount += batch.length;
182
+ options.onProgress?.(processedCount, elements.length);
183
+ }
184
+ return buildResult({
185
+ pushed: pushed.length,
186
+ pulled: 0,
187
+ skipped: skipped.length,
188
+ conflicts,
189
+ errors,
190
+ provider: this.getPrimaryProvider(),
191
+ project: this.getPrimaryProject(),
192
+ noLibrarySkipped,
193
+ });
194
+ }
195
+ /**
196
+ * Process a single element for push — validates sync state, checks skip conditions,
197
+ * and routes to the appropriate push method (pushElement or pushDocument).
198
+ *
199
+ * Extracted from the push() loop body to enable concurrent execution via Promise.allSettled.
200
+ *
201
+ * @returns 'pushed' if changes were sent, 'skipped' if element was skipped
202
+ */
203
+ async pushSingleElement(element, options, now, libraryPaths = new Map()) {
204
+ const syncState = getExternalSyncState(element.metadata);
205
+ if (!syncState) {
206
+ // No sync state — shouldn't happen since we filtered for it, but guard
207
+ return 'skipped';
208
+ }
209
+ // Skip if direction is pull-only
210
+ if (syncState.direction === 'pull') {
211
+ return 'skipped';
212
+ }
213
+ // Skip closed/tombstone tasks and archived documents — they're done
214
+ // and shouldn't sync. If an element is later reopened/reactivated,
215
+ // it will be picked up again naturally since the filter no longer applies.
216
+ const elementStatus = element.status;
217
+ if (elementStatus === 'closed' || elementStatus === 'tombstone' || elementStatus === 'archived') {
218
+ return 'skipped';
219
+ }
220
+ // Route by adapter type: tasks → pushElement(), documents → pushDocument()
221
+ if (syncState.adapterType === 'document') {
222
+ const libraryPath = libraryPaths.get(element.id);
223
+ return this.pushDocument(element, syncState, options, now, libraryPath);
224
+ }
225
+ else {
226
+ return this.pushElement(element, syncState, options, now);
227
+ }
228
+ }
229
+ /**
230
+ * Push a single element to its external service.
231
+ *
232
+ * @returns 'pushed' if changes were sent, 'skipped' if no changes detected
233
+ */
234
+ async pushElement(element, syncState, options, now) {
235
+ // Check for actual content change via hash (skip when force is true)
236
+ const currentHash = computeContentHashSync(element).hash;
237
+ if (!options.force && syncState.lastPushedHash && currentHash === syncState.lastPushedHash) {
238
+ return 'skipped';
239
+ }
240
+ // Verify events have occurred since last push (skip when force is true)
241
+ if (!options.force && syncState.lastPushedAt) {
242
+ const events = await this.api.listEvents({
243
+ elementId: element.id,
244
+ eventType: ['updated', 'closed', 'reopened'],
245
+ after: syncState.lastPushedAt,
246
+ });
247
+ if (events.length === 0) {
248
+ return 'skipped';
249
+ }
250
+ }
251
+ // Dry run — report but don't actually push
252
+ if (options.dryRun) {
253
+ return 'pushed';
254
+ }
255
+ // Get the adapter for this element's provider
256
+ const adapter = this.getTaskAdapter(syncState.provider);
257
+ if (!adapter) {
258
+ throw new Error(`No task adapter found for provider '${syncState.provider}'`);
259
+ }
260
+ // Build external task input using the shared field mapping utilities.
261
+ // This properly converts priority → sf:priority:* labels, taskType → sf:type:* labels,
262
+ // status → open/closed state, hydrates description, and resolves assignees.
263
+ const fieldMapConfig = getFieldMapConfigForProvider(syncState.provider);
264
+ const taskInput = await taskToExternalTask(element, fieldMapConfig, this.api);
265
+ // Push to external service
266
+ await adapter.updateIssue(syncState.project, syncState.externalId, taskInput);
267
+ // Update sync state on element
268
+ const updatedSyncState = {
269
+ ...syncState,
270
+ lastPushedAt: now,
271
+ lastPushedHash: currentHash,
272
+ };
273
+ await this.api.update(element.id, {
274
+ metadata: setExternalSyncState(element.metadata, updatedSyncState),
275
+ });
276
+ return 'pushed';
277
+ }
278
+ /**
279
+ * Push a single document to its external service.
280
+ *
281
+ * Parallel to pushElement(), but uses document-specific conversion
282
+ * (documentToExternalDocumentInput) and the DocumentSyncAdapter.
283
+ *
284
+ * @returns 'pushed' if changes were sent, 'skipped' if no changes detected
285
+ */
286
+ async pushDocument(element, syncState, options, now, libraryPath) {
287
+ // Check for actual content change via hash (skip when force is true)
288
+ const currentHash = computeContentHashSync(element).hash;
289
+ if (!options.force && syncState.lastPushedHash && currentHash === syncState.lastPushedHash) {
290
+ return 'skipped';
291
+ }
292
+ // Verify events have occurred since last push (skip when force is true)
293
+ if (!options.force && syncState.lastPushedAt) {
294
+ const events = await this.api.listEvents({
295
+ elementId: element.id,
296
+ eventType: ['updated'],
297
+ after: syncState.lastPushedAt,
298
+ });
299
+ if (events.length === 0) {
300
+ return 'skipped';
301
+ }
302
+ }
303
+ // Dry run — report but don't actually push
304
+ if (options.dryRun) {
305
+ return 'pushed';
306
+ }
307
+ // Get the document adapter for this element's provider
308
+ const adapter = this.getDocumentAdapter(syncState.provider);
309
+ if (!adapter) {
310
+ throw new Error(`No document adapter found for provider '${syncState.provider}'`);
311
+ }
312
+ // Build external document input using the shared field mapping utilities
313
+ const docInput = documentToExternalDocumentInput(element, libraryPath);
314
+ // Push to external service
315
+ await adapter.updatePage(syncState.project, syncState.externalId, docInput);
316
+ // Update sync state on element
317
+ const updatedSyncState = {
318
+ ...syncState,
319
+ lastPushedAt: now,
320
+ lastPushedHash: currentHash,
321
+ };
322
+ await this.api.update(element.id, {
323
+ metadata: setExternalSyncState(element.metadata, updatedSyncState),
324
+ });
325
+ return 'pushed';
326
+ }
327
+ // --------------------------------------------------------------------------
328
+ // Pull — pull externally-changed items into Stoneforge
329
+ // --------------------------------------------------------------------------
330
+ /**
331
+ * Pull externally-changed items into Stoneforge.
332
+ *
333
+ * Algorithm:
334
+ * 1. For each configured provider, get the sync cursor (last poll timestamp)
335
+ * 2. Call adapter.listIssuesSince(project, cursor) to find changed items
336
+ * 3. For each changed item:
337
+ * a. If linked to a local element, compare against lastPulledHash
338
+ * b. If unlinked and options.all is set, create a new Stoneforge task
339
+ * c. If both local and remote changed, use conflict resolver
340
+ * 4. Update sync cursors and element metadata
341
+ *
342
+ * @param options - Pull options (dryRun, taskIds, all)
343
+ * @returns Aggregated sync result
344
+ */
345
+ async pull(options = {}) {
346
+ const pulled = [];
347
+ const skipped = [];
348
+ const conflicts = [];
349
+ const errors = [];
350
+ const now = createTimestamp();
351
+ const adapterFilter = options.adapterTypes && options.adapterTypes.length > 0
352
+ ? new Set(options.adapterTypes)
353
+ : null;
354
+ // Get all task adapters from the registry (skip if filtered out)
355
+ if (!adapterFilter || adapterFilter.has('task')) {
356
+ const taskAdapterEntries = this.registry.getAdaptersOfType('task');
357
+ for (const { provider, adapter } of taskAdapterEntries) {
358
+ const taskAdapter = adapter;
359
+ const providerConfig = this.getProviderConfig(provider.name);
360
+ const project = providerConfig?.defaultProject;
361
+ if (!project) {
362
+ // No project configured for this provider — skip
363
+ continue;
364
+ }
365
+ try {
366
+ const result = await this.pullFromProvider(provider, taskAdapter, project, options, now, conflicts, errors);
367
+ pulled.push(result.pulled);
368
+ skipped.push(result.skipped);
369
+ }
370
+ catch (err) {
371
+ errors.push({
372
+ provider: provider.name,
373
+ project,
374
+ message: err instanceof Error ? err.message : String(err),
375
+ retryable: isRetryableError(err),
376
+ });
377
+ }
378
+ }
379
+ }
380
+ // Get all document adapters from the registry (skip if filtered out)
381
+ if (!adapterFilter || adapterFilter.has('document')) {
382
+ const docAdapterEntries = this.registry.getAdaptersOfType('document');
383
+ for (const { provider, adapter } of docAdapterEntries) {
384
+ const docAdapter = adapter;
385
+ const providerConfig = this.getProviderConfig(provider.name);
386
+ const project = providerConfig?.defaultProject;
387
+ if (!project) {
388
+ continue;
389
+ }
390
+ try {
391
+ const result = await this.pullDocumentsFromProvider(provider, docAdapter, project, options, now, conflicts, errors);
392
+ pulled.push(result.pulled);
393
+ skipped.push(result.skipped);
394
+ }
395
+ catch (err) {
396
+ errors.push({
397
+ provider: provider.name,
398
+ project,
399
+ message: err instanceof Error ? err.message : String(err),
400
+ retryable: isRetryableError(err),
401
+ });
402
+ }
403
+ }
404
+ }
405
+ return buildResult({
406
+ pushed: 0,
407
+ pulled: pulled.reduce((a, b) => a + b, 0),
408
+ skipped: skipped.reduce((a, b) => a + b, 0),
409
+ conflicts,
410
+ errors,
411
+ provider: this.getPrimaryProvider(),
412
+ project: this.getPrimaryProject(),
413
+ });
414
+ }
415
+ /**
416
+ * Pull changes from a specific provider.
417
+ */
418
+ async pullFromProvider(provider, adapter, project, options, now, conflicts, errors) {
419
+ let pulledCount = 0;
420
+ let skippedCount = 0;
421
+ // Get the sync cursor for this provider+project
422
+ const syncCursor = this.getSyncCursor(provider.name, project, 'task');
423
+ // Fetch changed items since cursor
424
+ const externalItems = await adapter.listIssuesSince(project, syncCursor);
425
+ // Get all locally-linked elements for matching
426
+ const linkedElements = await this.findLinkedElementsForProvider(provider.name, project);
427
+ // Build a map of externalId → local element for fast lookup
428
+ const linkedByExternalId = new Map();
429
+ for (const el of linkedElements) {
430
+ const state = getExternalSyncState(el.metadata);
431
+ if (state) {
432
+ linkedByExternalId.set(state.externalId, el);
433
+ }
434
+ }
435
+ for (const externalItem of externalItems) {
436
+ // If taskIds filter is set, check if this external item matches
437
+ if (options.taskIds) {
438
+ const localEl = linkedByExternalId.get(externalItem.externalId);
439
+ if (!localEl || !options.taskIds.includes(localEl.id)) {
440
+ skippedCount++;
441
+ continue;
442
+ }
443
+ }
444
+ try {
445
+ const result = await this.pullItem(provider, project, externalItem, linkedByExternalId, options, now, conflicts);
446
+ if (result === 'pulled') {
447
+ pulledCount++;
448
+ }
449
+ else if (result === 'skipped') {
450
+ skippedCount++;
451
+ }
452
+ else if (result === 'created') {
453
+ pulledCount++;
454
+ }
455
+ }
456
+ catch (err) {
457
+ errors.push({
458
+ externalId: externalItem.externalId,
459
+ provider: provider.name,
460
+ project,
461
+ message: err instanceof Error ? err.message : String(err),
462
+ retryable: isRetryableError(err),
463
+ });
464
+ }
465
+ }
466
+ // Update sync cursor (unless dry run)
467
+ if (!options.dryRun && externalItems.length > 0) {
468
+ this.setSyncCursor(provider.name, project, 'task', now);
469
+ }
470
+ return { pulled: pulledCount, skipped: skippedCount };
471
+ }
472
+ /**
473
+ * Pull a single external item into Stoneforge.
474
+ *
475
+ * @returns 'pulled' if local element was updated, 'skipped' if no changes,
476
+ * 'created' if a new task was created
477
+ */
478
+ async pullItem(provider, project, externalItem, linkedByExternalId, options, now, conflicts) {
479
+ const localElement = linkedByExternalId.get(externalItem.externalId);
480
+ if (!localElement) {
481
+ // Unlinked external item — create new task if --all flag is set
482
+ if (options.all) {
483
+ if (options.dryRun) {
484
+ return 'created';
485
+ }
486
+ await this.createTaskFromExternal(provider, project, externalItem, now);
487
+ return 'created';
488
+ }
489
+ return 'skipped';
490
+ }
491
+ // Linked element — check for actual change via hash
492
+ const syncState = getExternalSyncState(localElement.metadata);
493
+ // Skip if direction is push-only
494
+ if (syncState.direction === 'push') {
495
+ return 'skipped';
496
+ }
497
+ // Skip updates to closed/tombstone tasks UNLESS the external item is open
498
+ // (which means someone reopened the issue externally — we should sync that).
499
+ const localStatus = localElement.status;
500
+ if ((localStatus === 'closed' || localStatus === 'tombstone') && externalItem.state !== 'open') {
501
+ return 'skipped';
502
+ }
503
+ // Compute a hash of the external item content to detect real changes
504
+ const remoteContentKey = computeExternalItemHash(externalItem);
505
+ if (syncState.lastPulledHash && remoteContentKey === syncState.lastPulledHash) {
506
+ return 'skipped';
507
+ }
508
+ // Check for conflict: has local also changed since last pull?
509
+ const localHash = computeContentHashSync(localElement).hash;
510
+ const localChanged = syncState.lastPushedHash !== undefined && localHash !== syncState.lastPushedHash;
511
+ if (localChanged) {
512
+ // Both sides changed — use conflict resolver
513
+ const resolution = this.conflictResolver.resolve(localElement, externalItem, this.defaultStrategy);
514
+ conflicts.push({
515
+ elementId: localElement.id,
516
+ externalId: externalItem.externalId,
517
+ provider: provider.name,
518
+ project,
519
+ localUpdatedAt: localElement.updatedAt,
520
+ remoteUpdatedAt: externalItem.updatedAt,
521
+ strategy: this.defaultStrategy,
522
+ resolved: resolution.resolved,
523
+ winner: resolution.resolved ? resolution.winner : undefined,
524
+ });
525
+ if (!resolution.resolved) {
526
+ // Manual resolution needed — tag the element
527
+ if (!options.dryRun) {
528
+ const tags = localElement.tags.includes('sync-conflict')
529
+ ? localElement.tags
530
+ : [...localElement.tags, 'sync-conflict'];
531
+ await this.api.update(localElement.id, { tags });
532
+ }
533
+ return 'skipped';
534
+ }
535
+ if (resolution.winner === 'local') {
536
+ // Local wins — skip the pull, but update the pulled hash
537
+ if (!options.dryRun) {
538
+ const updatedSyncState = {
539
+ ...syncState,
540
+ lastPulledAt: now,
541
+ lastPulledHash: remoteContentKey,
542
+ };
543
+ await this.api.update(localElement.id, {
544
+ metadata: setExternalSyncState(localElement.metadata, updatedSyncState),
545
+ });
546
+ }
547
+ return 'skipped';
548
+ }
549
+ // Remote wins — fall through to apply remote changes
550
+ }
551
+ // Dry run — report but don't actually apply
552
+ if (options.dryRun) {
553
+ return 'pulled';
554
+ }
555
+ // Apply remote changes to local element using provider field map config.
556
+ // Pass the existing task for diff mode (only changed fields are returned).
557
+ const updates = this.externalItemToUpdates(externalItem, localElement);
558
+ const updatedSyncState = {
559
+ ...syncState,
560
+ lastPulledAt: now,
561
+ lastPulledHash: remoteContentKey,
562
+ };
563
+ // Merge metadata: preserve existing metadata, add any from updates
564
+ // (e.g., _pendingAssignee), and set the updated sync state.
565
+ const updatesMetadata = (updates.metadata ?? {});
566
+ const mergedMetadata = setExternalSyncState({ ...localElement.metadata, ...updatesMetadata }, updatedSyncState);
567
+ // Handle description (body) sync from external item.
568
+ // The body is not included in externalItemToUpdates() because it requires
569
+ // separate document creation/update operations.
570
+ const descriptionRefUpdate = await this.syncDescriptionFromExternal(localElement, externalItem);
571
+ await this.api.update(localElement.id, {
572
+ ...updates,
573
+ ...descriptionRefUpdate,
574
+ metadata: mergedMetadata,
575
+ });
576
+ return 'pulled';
577
+ }
578
+ // --------------------------------------------------------------------------
579
+ // Pull — document-specific helpers
580
+ // --------------------------------------------------------------------------
581
+ /**
582
+ * Pull document changes from a specific provider.
583
+ * Parallel to pullFromProvider(), but uses the DocumentSyncAdapter.
584
+ */
585
+ async pullDocumentsFromProvider(provider, adapter, project, options, now, conflicts, errors) {
586
+ let pulledCount = 0;
587
+ let skippedCount = 0;
588
+ // Get the sync cursor for this provider+project for documents
589
+ const syncCursor = this.getSyncCursor(provider.name, project, 'document');
590
+ // Fetch changed documents since cursor
591
+ const allExternalDocs = await adapter.listPagesSince(project, syncCursor);
592
+ // Skip external documents with empty or whitespace-only titles
593
+ const externalDocs = allExternalDocs.filter((doc) => doc.title && doc.title.trim().length > 0);
594
+ // Get all locally-linked document elements for matching
595
+ const linkedElements = await this.findLinkedElementsForProvider(provider.name, project);
596
+ // Filter to only document-type linked elements
597
+ const linkedDocuments = linkedElements.filter((el) => {
598
+ const state = getExternalSyncState(el.metadata);
599
+ return state && state.adapterType === 'document';
600
+ });
601
+ // Build a map of externalId → local element for fast lookup
602
+ const linkedByExternalId = new Map();
603
+ for (const el of linkedDocuments) {
604
+ const state = getExternalSyncState(el.metadata);
605
+ if (state) {
606
+ linkedByExternalId.set(state.externalId, el);
607
+ }
608
+ }
609
+ for (const externalDoc of externalDocs) {
610
+ // If taskIds filter is set (used generically for element IDs), check if this external doc matches
611
+ if (options.taskIds) {
612
+ const localEl = linkedByExternalId.get(externalDoc.externalId);
613
+ if (!localEl || !options.taskIds.includes(localEl.id)) {
614
+ skippedCount++;
615
+ continue;
616
+ }
617
+ }
618
+ try {
619
+ const result = await this.pullDocumentItem(provider, project, externalDoc, linkedByExternalId, options, now, conflicts);
620
+ if (result === 'pulled') {
621
+ pulledCount++;
622
+ }
623
+ else if (result === 'skipped') {
624
+ skippedCount++;
625
+ }
626
+ else if (result === 'created') {
627
+ pulledCount++;
628
+ }
629
+ }
630
+ catch (err) {
631
+ errors.push({
632
+ externalId: externalDoc.externalId,
633
+ provider: provider.name,
634
+ project,
635
+ message: err instanceof Error ? err.message : String(err),
636
+ retryable: isRetryableError(err),
637
+ });
638
+ }
639
+ }
640
+ // Update sync cursor (unless dry run)
641
+ if (!options.dryRun && externalDocs.length > 0) {
642
+ this.setSyncCursor(provider.name, project, 'document', now);
643
+ }
644
+ return { pulled: pulledCount, skipped: skippedCount };
645
+ }
646
+ /**
647
+ * Pull a single external document into Stoneforge.
648
+ * Parallel to pullItem(), but uses document-specific conversion.
649
+ *
650
+ * @returns 'pulled' if local element was updated, 'skipped' if no changes,
651
+ * 'created' if a new document was created
652
+ */
653
+ async pullDocumentItem(provider, project, externalDoc, linkedByExternalId, options, now, conflicts) {
654
+ const localElement = linkedByExternalId.get(externalDoc.externalId);
655
+ if (!localElement) {
656
+ // Unlinked external document — create new document if --all flag is set
657
+ if (options.all) {
658
+ if (options.dryRun) {
659
+ return 'created';
660
+ }
661
+ await this.createDocumentFromExternal(provider, project, externalDoc, now);
662
+ return 'created';
663
+ }
664
+ return 'skipped';
665
+ }
666
+ // Linked element — check for actual change via hash
667
+ const syncState = getExternalSyncState(localElement.metadata);
668
+ // Skip if direction is push-only
669
+ if (syncState.direction === 'push') {
670
+ return 'skipped';
671
+ }
672
+ // Skip updates to archived documents
673
+ const localStatus = localElement.status;
674
+ if (localStatus === 'archived') {
675
+ return 'skipped';
676
+ }
677
+ // Compute a hash of the external document content to detect real changes
678
+ const remoteContentKey = computeExternalDocumentHash(externalDoc);
679
+ if (syncState.lastPulledHash && remoteContentKey === syncState.lastPulledHash) {
680
+ return 'skipped';
681
+ }
682
+ // Check for conflict: has local also changed since last pull?
683
+ const localHash = computeContentHashSync(localElement).hash;
684
+ const localChanged = syncState.lastPushedHash !== undefined && localHash !== syncState.lastPushedHash;
685
+ if (localChanged) {
686
+ // Both sides changed — use conflict resolver
687
+ const resolution = this.conflictResolver.resolve(localElement, externalDoc, this.defaultStrategy);
688
+ conflicts.push({
689
+ elementId: localElement.id,
690
+ externalId: externalDoc.externalId,
691
+ provider: provider.name,
692
+ project,
693
+ localUpdatedAt: localElement.updatedAt,
694
+ remoteUpdatedAt: externalDoc.updatedAt,
695
+ strategy: this.defaultStrategy,
696
+ resolved: resolution.resolved,
697
+ winner: resolution.resolved ? resolution.winner : undefined,
698
+ });
699
+ if (!resolution.resolved) {
700
+ // Manual resolution needed — tag the element
701
+ if (!options.dryRun) {
702
+ const tags = localElement.tags.includes('sync-conflict')
703
+ ? localElement.tags
704
+ : [...localElement.tags, 'sync-conflict'];
705
+ await this.api.update(localElement.id, { tags });
706
+ }
707
+ return 'skipped';
708
+ }
709
+ if (resolution.winner === 'local') {
710
+ // Local wins — skip the pull, but update the pulled hash
711
+ if (!options.dryRun) {
712
+ const updatedSyncState = {
713
+ ...syncState,
714
+ lastPulledAt: now,
715
+ lastPulledHash: remoteContentKey,
716
+ };
717
+ await this.api.update(localElement.id, {
718
+ metadata: setExternalSyncState(localElement.metadata, updatedSyncState),
719
+ });
720
+ }
721
+ return 'skipped';
722
+ }
723
+ // Remote wins — fall through to apply remote changes
724
+ }
725
+ // Dry run — report but don't actually apply
726
+ if (options.dryRun) {
727
+ return 'pulled';
728
+ }
729
+ // Apply remote changes to local document using document field mapping.
730
+ // Pass the existing document for diff mode (only changed fields are returned).
731
+ const updates = externalDocumentToDocumentUpdates(externalDoc, localElement);
732
+ const updatedSyncState = {
733
+ ...syncState,
734
+ lastPulledAt: now,
735
+ lastPulledHash: remoteContentKey,
736
+ };
737
+ const mergedMetadata = setExternalSyncState({ ...localElement.metadata }, updatedSyncState);
738
+ await this.api.update(localElement.id, {
739
+ ...updates,
740
+ metadata: mergedMetadata,
741
+ });
742
+ return 'pulled';
743
+ }
744
+ /**
745
+ * Create a new Stoneforge document from an unlinked external document.
746
+ */
747
+ async createDocumentFromExternal(provider, project, externalDoc, now) {
748
+ const syncState = {
749
+ provider: provider.name,
750
+ project,
751
+ externalId: externalDoc.externalId,
752
+ url: externalDoc.url,
753
+ lastPulledAt: now,
754
+ lastPulledHash: computeExternalDocumentHash(externalDoc),
755
+ direction: 'bidirectional',
756
+ adapterType: 'document',
757
+ };
758
+ // Use the document field mapping for conversion
759
+ const docUpdates = externalDocumentToDocumentUpdates(externalDoc);
760
+ const createInput = {
761
+ type: 'document',
762
+ title: docUpdates.title ?? externalDoc.title,
763
+ content: docUpdates.content ?? externalDoc.content,
764
+ contentType: docUpdates.contentType ?? 'markdown',
765
+ status: 'active',
766
+ category: 'other',
767
+ version: 1,
768
+ previousVersionId: null,
769
+ immutable: false,
770
+ tags: [],
771
+ externalRef: externalDoc.url,
772
+ createdBy: 'system',
773
+ metadata: { _externalSync: syncState },
774
+ };
775
+ const element = await this.api.create(createInput);
776
+ return element;
777
+ }
778
+ // --------------------------------------------------------------------------
779
+ // Sync — bidirectional (push then pull)
780
+ // --------------------------------------------------------------------------
781
+ /**
782
+ * Bidirectional sync — push then pull.
783
+ *
784
+ * Runs push first to send local changes, then pull to receive remote changes.
785
+ * Results are merged from both operations.
786
+ *
787
+ * @param options - Sync options (dryRun, taskIds, all)
788
+ * @returns Merged sync result
789
+ */
790
+ async sync(options = {}) {
791
+ const pushResult = await this.push(options);
792
+ const pullResult = await this.pull(options);
793
+ return {
794
+ success: pushResult.success && pullResult.success,
795
+ provider: pushResult.provider || pullResult.provider,
796
+ project: pushResult.project || pullResult.project,
797
+ adapterType: pushResult.adapterType || pullResult.adapterType,
798
+ pushed: pushResult.pushed,
799
+ pulled: pullResult.pulled,
800
+ skipped: pushResult.skipped + pullResult.skipped,
801
+ conflicts: [...pushResult.conflicts, ...pullResult.conflicts],
802
+ errors: [...pushResult.errors, ...pullResult.errors],
803
+ };
804
+ }
805
+ // --------------------------------------------------------------------------
806
+ // Element Discovery
807
+ // --------------------------------------------------------------------------
808
+ /**
809
+ * Find elements that are linked to external services and match the given options.
810
+ *
811
+ * - If taskIds is specified, returns only those tasks (that have _externalSync)
812
+ * - If all is true, returns all tasks with _externalSync metadata
813
+ * - Otherwise, returns tasks with _externalSync that have been updated
814
+ */
815
+ async findLinkedElements(options) {
816
+ const adapterFilter = options.adapterTypes && options.adapterTypes.length > 0
817
+ ? new Set(options.adapterTypes)
818
+ : null;
819
+ if (options.taskIds && options.taskIds.length > 0) {
820
+ // Fetch specific elements (tasks or documents)
821
+ const elements = [];
822
+ for (const id of options.taskIds) {
823
+ const element = await this.api.get(id);
824
+ if (element) {
825
+ const state = getExternalSyncState(element.metadata);
826
+ if (state && (!adapterFilter || adapterFilter.has(state.adapterType))) {
827
+ elements.push(element);
828
+ }
829
+ }
830
+ }
831
+ return elements;
832
+ }
833
+ // Fetch elements based on adapter type filter
834
+ const collections = [];
835
+ if (!adapterFilter || adapterFilter.has('task')) {
836
+ collections.push(await this.api.list({ type: 'task' }));
837
+ }
838
+ if (!adapterFilter || adapterFilter.has('document')) {
839
+ collections.push(await this.api.list({ type: 'document' }));
840
+ }
841
+ return collections.flat().filter((el) => {
842
+ const state = getExternalSyncState(el.metadata);
843
+ return state !== undefined && (!adapterFilter || adapterFilter.has(state.adapterType));
844
+ });
845
+ }
846
+ /**
847
+ * Find elements linked to a specific provider and project.
848
+ */
849
+ async findLinkedElementsForProvider(providerName, project) {
850
+ const allTasks = await this.api.list({ type: 'task' });
851
+ const allDocs = await this.api.list({ type: 'document' });
852
+ return [...allTasks, ...allDocs].filter((el) => {
853
+ const state = getExternalSyncState(el.metadata);
854
+ return state && state.provider === providerName && state.project === project;
855
+ });
856
+ }
857
+ // --------------------------------------------------------------------------
858
+ // Field Mapping Utilities
859
+ // --------------------------------------------------------------------------
860
+ /**
861
+ * Convert an ExternalTask to partial updates for applying to a local element.
862
+ *
863
+ * Delegates to externalTaskToTaskUpdates() from task-sync-adapter.ts, which
864
+ * uses the provider's TaskSyncFieldMapConfig for correct status, priority,
865
+ * taskType, and tag mapping. The provider is looked up from item.provider.
866
+ *
867
+ * @param item - The external task to convert
868
+ * @param existingTask - The existing local task (for diff mode), or undefined
869
+ * @returns Partial<Task> with the mapped fields
870
+ */
871
+ externalItemToUpdates(item, existingTask) {
872
+ const config = getFieldMapConfigForProvider(item.provider);
873
+ return externalTaskToTaskUpdates(item, existingTask, config);
874
+ }
875
+ // --------------------------------------------------------------------------
876
+ // Task Creation (for pull with --all)
877
+ // --------------------------------------------------------------------------
878
+ /**
879
+ * Create a new Stoneforge task from an unlinked external item.
880
+ */
881
+ async createTaskFromExternal(provider, project, item, now) {
882
+ const syncState = {
883
+ provider: provider.name,
884
+ project,
885
+ externalId: item.externalId,
886
+ url: item.url,
887
+ lastPulledAt: now,
888
+ lastPulledHash: computeExternalItemHash(item),
889
+ direction: 'bidirectional',
890
+ adapterType: 'task',
891
+ };
892
+ // Use the provider's field map config for correct status, priority,
893
+ // taskType, and tag mapping — instead of hardcoded open/closed.
894
+ const taskUpdates = this.externalItemToUpdates(item);
895
+ // Merge metadata from field mapping (e.g., _pendingAssignee) with sync state
896
+ const taskMetadata = (taskUpdates.metadata ?? {});
897
+ // Create a description document if the external item has a body
898
+ let descriptionRef;
899
+ if (item.body && item.body.trim().length > 0) {
900
+ descriptionRef = await this.createDescriptionDocument(item.body, taskUpdates.title ?? item.title);
901
+ }
902
+ const createInput = {
903
+ type: 'task',
904
+ title: taskUpdates.title ?? item.title,
905
+ status: taskUpdates.status ?? 'open',
906
+ priority: taskUpdates.priority,
907
+ taskType: taskUpdates.taskType,
908
+ tags: taskUpdates.tags ?? [...item.labels],
909
+ externalRef: taskUpdates.externalRef ?? item.url,
910
+ createdBy: 'system',
911
+ metadata: { ...taskMetadata, _externalSync: syncState },
912
+ ...(descriptionRef !== undefined && { descriptionRef }),
913
+ };
914
+ const element = await this.api.create(createInput);
915
+ return element;
916
+ }
917
+ // --------------------------------------------------------------------------
918
+ // Description Sync Helpers
919
+ // --------------------------------------------------------------------------
920
+ /**
921
+ * Sync the description (body) from an external item to the local task's
922
+ * description document.
923
+ *
924
+ * - If the task already has a descriptionRef, compare the body against the
925
+ * existing document content and update only if changed.
926
+ * - If the task has no descriptionRef and the body is non-empty, create a
927
+ * new document and return the descriptionRef to link.
928
+ * - If the body is empty/undefined, skip (don't delete existing descriptions).
929
+ *
930
+ * @returns Partial update with descriptionRef if a new document was created,
931
+ * or empty object if no descriptionRef change is needed.
932
+ */
933
+ async syncDescriptionFromExternal(localTask, externalItem) {
934
+ const body = externalItem.body;
935
+ // If body is empty/undefined, skip — don't delete existing descriptions
936
+ if (!body || body.trim().length === 0) {
937
+ return {};
938
+ }
939
+ if (localTask.descriptionRef) {
940
+ // Task already has a description document — update if content changed
941
+ const existingDoc = await this.api.get(localTask.descriptionRef);
942
+ if (existingDoc && existingDoc.type === 'document') {
943
+ if (existingDoc.content !== body) {
944
+ await this.api.update(localTask.descriptionRef, {
945
+ content: body,
946
+ });
947
+ }
948
+ // No descriptionRef change needed
949
+ return {};
950
+ }
951
+ // Document not found — fall through to create a new one
952
+ }
953
+ // No existing description document — create a new one and link it
954
+ const newDescRef = await this.createDescriptionDocument(body, localTask.title);
955
+ return { descriptionRef: newDescRef };
956
+ }
957
+ /**
958
+ * Create a new description document for a task.
959
+ *
960
+ * Uses the same pattern as the server-side task creation:
961
+ * - contentType: 'markdown'
962
+ * - category: 'task-description' tag
963
+ * - createdBy: 'system'
964
+ *
965
+ * @param body - The description content
966
+ * @param taskTitle - The task title (used in the document title)
967
+ * @returns The DocumentId of the created document
968
+ */
969
+ async createDescriptionDocument(body, taskTitle) {
970
+ const docInput = {
971
+ type: 'document',
972
+ contentType: 'markdown',
973
+ content: body,
974
+ createdBy: 'system',
975
+ tags: ['task-description'],
976
+ title: `Description for task ${taskTitle}`,
977
+ metadata: {},
978
+ version: 1,
979
+ previousVersionId: null,
980
+ category: 'task-description',
981
+ status: 'active',
982
+ immutable: false,
983
+ };
984
+ const createdDoc = await this.api.create(docInput);
985
+ return createdDoc.id;
986
+ }
987
+ // --------------------------------------------------------------------------
988
+ // Sync Cursor Management
989
+ // --------------------------------------------------------------------------
990
+ /**
991
+ * Get the sync cursor for a provider+project+adapterType.
992
+ * Returns a timestamp indicating the last time we polled this combination.
993
+ */
994
+ getSyncCursor(provider, project, adapterType) {
995
+ if (!this.settings) {
996
+ // No settings service — return epoch
997
+ return '1970-01-01T00:00:00.000Z';
998
+ }
999
+ const key = buildCursorKey(provider, project, adapterType);
1000
+ const setting = this.settings.getSetting(key);
1001
+ if (setting && typeof setting.value === 'string') {
1002
+ return setting.value;
1003
+ }
1004
+ return '1970-01-01T00:00:00.000Z';
1005
+ }
1006
+ /**
1007
+ * Update the sync cursor for a provider+project+adapterType.
1008
+ */
1009
+ setSyncCursor(provider, project, adapterType, cursor) {
1010
+ if (!this.settings) {
1011
+ return;
1012
+ }
1013
+ const key = buildCursorKey(provider, project, adapterType);
1014
+ this.settings.setSetting(key, cursor);
1015
+ }
1016
+ // --------------------------------------------------------------------------
1017
+ // Provider/Adapter Lookup
1018
+ // --------------------------------------------------------------------------
1019
+ /**
1020
+ * Get a TaskSyncAdapter for a given provider name.
1021
+ */
1022
+ getTaskAdapter(providerName) {
1023
+ const provider = this.registry.get(providerName);
1024
+ if (!provider)
1025
+ return undefined;
1026
+ return provider.getTaskAdapter?.();
1027
+ }
1028
+ /**
1029
+ * Get a DocumentSyncAdapter for a given provider name.
1030
+ */
1031
+ getDocumentAdapter(providerName) {
1032
+ const provider = this.registry.get(providerName);
1033
+ if (!provider)
1034
+ return undefined;
1035
+ return provider.getDocumentAdapter?.();
1036
+ }
1037
+ /**
1038
+ * Get the ProviderConfig for a given provider name.
1039
+ */
1040
+ getProviderConfig(providerName) {
1041
+ return this.providerConfigs.find((c) => c.provider === providerName);
1042
+ }
1043
+ /**
1044
+ * Get the primary provider name (for result reporting).
1045
+ */
1046
+ getPrimaryProvider() {
1047
+ if (this.providerConfigs.length > 0) {
1048
+ return this.providerConfigs[0].provider;
1049
+ }
1050
+ const providers = this.registry.list();
1051
+ return providers.length > 0 ? providers[0].name : 'unknown';
1052
+ }
1053
+ /**
1054
+ * Get the primary project (for result reporting).
1055
+ */
1056
+ getPrimaryProject() {
1057
+ if (this.providerConfigs.length > 0 && this.providerConfigs[0].defaultProject) {
1058
+ return this.providerConfigs[0].defaultProject;
1059
+ }
1060
+ return '';
1061
+ }
1062
+ }
1063
+ // ============================================================================
1064
+ // Helper Functions
1065
+ // ============================================================================
1066
+ /**
1067
+ * Compute a content hash for an external item.
1068
+ * Used to detect real changes from the remote side.
1069
+ * Hashes key semantic fields in a deterministic order.
1070
+ */
1071
+ function computeExternalItemHash(item) {
1072
+ // Build deterministic content representation with sorted keys
1073
+ const contentFields = {
1074
+ assignees: [...item.assignees].sort(),
1075
+ body: item.body ?? '',
1076
+ labels: [...item.labels].sort(),
1077
+ priority: item.priority,
1078
+ state: item.state,
1079
+ title: item.title,
1080
+ };
1081
+ const hashInput = `external:${JSON.stringify(contentFields)}`;
1082
+ return createHash('sha256').update(hashInput).digest('hex');
1083
+ }
1084
+ /**
1085
+ * Check if an error is likely retryable (network issues, rate limits, etc.)
1086
+ */
1087
+ function isRetryableError(err) {
1088
+ if (!(err instanceof Error))
1089
+ return false;
1090
+ const message = err.message.toLowerCase();
1091
+ return (message.includes('rate limit') ||
1092
+ message.includes('timeout') ||
1093
+ message.includes('gateway timeout') ||
1094
+ message.includes('bad gateway') ||
1095
+ message.includes('econnrefused') ||
1096
+ message.includes('enotfound') ||
1097
+ message.includes('network') ||
1098
+ message.includes('502') ||
1099
+ message.includes('503') ||
1100
+ message.includes('504') ||
1101
+ message.includes('429'));
1102
+ }
1103
+ /**
1104
+ * Build a standardized ExternalSyncResult.
1105
+ */
1106
+ function buildResult(params) {
1107
+ return {
1108
+ success: params.errors.length === 0,
1109
+ provider: params.provider,
1110
+ project: params.project,
1111
+ adapterType: 'task',
1112
+ pushed: params.pushed,
1113
+ pulled: params.pulled,
1114
+ skipped: params.skipped,
1115
+ conflicts: params.conflicts,
1116
+ errors: params.errors,
1117
+ ...(params.noLibrarySkipped !== undefined && params.noLibrarySkipped > 0
1118
+ ? { noLibrarySkipped: params.noLibrarySkipped }
1119
+ : {}),
1120
+ };
1121
+ }
1122
+ // ============================================================================
1123
+ // Factory
1124
+ // ============================================================================
1125
+ /**
1126
+ * Create a new SyncEngine instance.
1127
+ *
1128
+ * @param config - Sync engine configuration
1129
+ * @returns A new SyncEngine instance
1130
+ *
1131
+ * @example
1132
+ * ```typescript
1133
+ * const engine = createSyncEngine({
1134
+ * api: quarryApi,
1135
+ * registry: providerRegistry,
1136
+ * settings: settingsService,
1137
+ * });
1138
+ *
1139
+ * // Push locally-changed tasks
1140
+ * const pushResult = await engine.push({ all: true });
1141
+ *
1142
+ * // Pull externally-changed items
1143
+ * const pullResult = await engine.pull();
1144
+ *
1145
+ * // Bidirectional sync
1146
+ * const syncResult = await engine.sync({ dryRun: true });
1147
+ * ```
1148
+ */
1149
+ export function createSyncEngine(config) {
1150
+ return new SyncEngine(config);
1151
+ }
1152
+ // Export constants for testing
1153
+ export { PUSH_CONCURRENCY };
1154
+ //# sourceMappingURL=sync-engine.js.map