@cleocode/core 2026.3.39 → 2026.3.41

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.
@@ -4,26 +4,28 @@
4
4
  * Takes normalized ExternalTask[] from any provider adapter and reconciles
5
5
  * them against CLEO's authoritative task state. CLEO is always the SSoT.
6
6
  *
7
- * Provider-specific parsing is NEVER done here that lives in the adapter's
8
- * AdapterTaskSyncProvider implementation.
7
+ * Uses the external_task_links table in tasks.db to track which external
8
+ * tasks map to which CLEO tasks, enabling re-sync, update detection, and
9
+ * bidirectional traceability.
9
10
  *
10
- * @task T5800
11
+ * Provider-specific parsing is NEVER done here — that lives in the adapter's
12
+ * ExternalTaskProvider implementation.
11
13
  */
12
14
 
13
15
  import type {
14
16
  DataAccessor,
15
17
  ExternalTask,
18
+ ExternalTaskLink,
16
19
  ReconcileAction,
17
20
  ReconcileOptions,
18
21
  ReconcileResult,
19
- SyncSessionState,
20
22
  Task,
21
23
  } from '@cleocode/contracts';
22
24
  import { getAccessor } from '../store/data-accessor.js';
23
25
  import { addTask } from '../tasks/add.js';
24
26
  import { completeTask } from '../tasks/complete.js';
25
27
  import { updateTask } from '../tasks/update.js';
26
- import { clearSyncState, readSyncState } from './sync-state.js';
28
+ import { createLink, getLinksByProvider, touchLink } from './link-store.js';
27
29
 
28
30
  // ---------------------------------------------------------------------------
29
31
  // Internal helpers
@@ -40,119 +42,120 @@ function buildTaskMap(tasks: Task[]): Map<string, Task> {
40
42
  return map;
41
43
  }
42
44
 
45
+ /**
46
+ * Build a lookup map from external ID to existing link for the provider.
47
+ */
48
+ function buildLinkMap(links: ExternalTaskLink[]): Map<string, ExternalTaskLink> {
49
+ const map = new Map<string, ExternalTaskLink>();
50
+ for (const link of links) {
51
+ map.set(link.externalId, link);
52
+ }
53
+ return map;
54
+ }
55
+
43
56
  /**
44
57
  * Compute reconciliation actions by diffing external tasks against CLEO state.
45
58
  */
46
59
  function computeActions(
47
60
  externalTasks: ExternalTask[],
48
61
  taskMap: Map<string, Task>,
49
- injectedIds: Set<string>,
62
+ linkMap: Map<string, ExternalTaskLink>,
50
63
  ): ReconcileAction[] {
51
64
  const actions: ReconcileAction[] = [];
52
- const seenCleoIds = new Set<string>();
53
65
 
54
66
  for (const ext of externalTasks) {
55
- // Case 1: External task maps to an existing CLEO task
56
- if (ext.cleoTaskId) {
57
- seenCleoIds.add(ext.cleoTaskId);
58
- const cleoTask = taskMap.get(ext.cleoTaskId);
67
+ const existingLink = linkMap.get(ext.externalId);
68
+
69
+ if (existingLink) {
70
+ // External task has an existing link to a CLEO task
71
+ const cleoTask = taskMap.get(existingLink.taskId);
59
72
 
60
73
  if (!cleoTask) {
61
- // Mapped to a CLEO ID that doesn't exist — skip
74
+ // Linked CLEO task was deleted — skip
62
75
  actions.push({
63
76
  type: 'skip',
64
- cleoTaskId: ext.cleoTaskId,
77
+ cleoTaskId: existingLink.taskId,
65
78
  externalId: ext.externalId,
66
- summary: `CLEO task ${ext.cleoTaskId} not found — skipping`,
79
+ summary: `Linked CLEO task ${existingLink.taskId} no longer exists — skipping`,
67
80
  applied: false,
68
81
  });
69
82
  continue;
70
83
  }
71
84
 
72
- // Already done in CLEO — skip
85
+ // Already terminal in CLEO — skip
73
86
  if (cleoTask.status === 'done' || cleoTask.status === 'cancelled') {
74
87
  actions.push({
75
88
  type: 'skip',
76
- cleoTaskId: ext.cleoTaskId,
89
+ cleoTaskId: cleoTask.id,
77
90
  externalId: ext.externalId,
78
- summary: `CLEO task ${ext.cleoTaskId} already ${cleoTask.status}`,
91
+ summary: `CLEO task ${cleoTask.id} already ${cleoTask.status}`,
79
92
  applied: false,
80
93
  });
81
94
  continue;
82
95
  }
83
96
 
84
- // External says completed
97
+ // External says completed → complete CLEO task
85
98
  if (ext.status === 'completed') {
86
99
  actions.push({
87
100
  type: 'complete',
88
- cleoTaskId: ext.cleoTaskId,
101
+ cleoTaskId: cleoTask.id,
89
102
  externalId: ext.externalId,
90
- summary: `Complete ${ext.cleoTaskId} (${cleoTask.title})`,
103
+ summary: `Complete ${cleoTask.id} (${cleoTask.title})`,
91
104
  applied: false,
105
+ linkId: existingLink.id,
92
106
  });
93
107
  continue;
94
108
  }
95
109
 
96
- // External says active, CLEO is pending/blocked
110
+ // External says active, CLEO is pending/blocked → activate
97
111
  if (
98
112
  ext.status === 'active' &&
99
113
  (cleoTask.status === 'pending' || cleoTask.status === 'blocked')
100
114
  ) {
101
115
  actions.push({
102
116
  type: 'activate',
103
- cleoTaskId: ext.cleoTaskId,
117
+ cleoTaskId: cleoTask.id,
104
118
  externalId: ext.externalId,
105
- summary: `Activate ${ext.cleoTaskId} (${cleoTask.title})`,
119
+ summary: `Activate ${cleoTask.id} (${cleoTask.title})`,
106
120
  applied: false,
121
+ linkId: existingLink.id,
107
122
  });
108
123
  continue;
109
124
  }
110
125
 
111
- // External says removed
112
- if (ext.status === 'removed') {
126
+ // Check if title or other properties changed → update
127
+ if (ext.title !== cleoTask.title) {
113
128
  actions.push({
114
- type: 'remove',
115
- cleoTaskId: ext.cleoTaskId,
129
+ type: 'update',
130
+ cleoTaskId: cleoTask.id,
116
131
  externalId: ext.externalId,
117
- summary: `Task ${ext.cleoTaskId} removed from provider`,
132
+ summary: `Update ${cleoTask.id} title: "${cleoTask.title}" → "${ext.title}"`,
118
133
  applied: false,
134
+ linkId: existingLink.id,
119
135
  });
120
136
  continue;
121
137
  }
122
138
 
123
- // No change needed
139
+ // No change needed — just touch the link timestamp
124
140
  actions.push({
125
141
  type: 'skip',
126
- cleoTaskId: ext.cleoTaskId,
127
- externalId: ext.externalId,
128
- summary: `No change needed for ${ext.cleoTaskId}`,
129
- applied: false,
130
- });
131
- continue;
132
- }
133
-
134
- // Case 2: New task (no cleoTaskId)
135
- if (ext.status !== 'removed') {
136
- actions.push({
137
- type: 'create',
138
- cleoTaskId: null,
142
+ cleoTaskId: cleoTask.id,
139
143
  externalId: ext.externalId,
140
- summary: `Create new task: ${ext.title}`,
141
- applied: false,
142
- });
143
- }
144
- }
145
-
146
- // Case 3: Injected tasks that are no longer present in external state
147
- for (const injectedId of injectedIds) {
148
- if (!seenCleoIds.has(injectedId)) {
149
- actions.push({
150
- type: 'remove',
151
- cleoTaskId: injectedId,
152
- externalId: `injected:${injectedId}`,
153
- summary: `Injected task ${injectedId} no longer in provider`,
144
+ summary: `No change needed for ${cleoTask.id}`,
154
145
  applied: false,
146
+ linkId: existingLink.id,
155
147
  });
148
+ } else {
149
+ // No existing link — this is a new external task
150
+ if (ext.status !== 'removed') {
151
+ actions.push({
152
+ type: 'create',
153
+ cleoTaskId: null,
154
+ externalId: ext.externalId,
155
+ summary: `Create new task: ${ext.title}`,
156
+ applied: false,
157
+ });
158
+ }
156
159
  }
157
160
  }
158
161
 
@@ -183,39 +186,40 @@ export async function reconcile(
183
186
  const { tasks: allTasks } = await acc.queryTasks({});
184
187
  const taskMap = buildTaskMap(allTasks);
185
188
 
186
- // Load sync session state for this provider
187
- const syncState: SyncSessionState | null = await readSyncState(providerId, cwd);
188
- const injectedIds = new Set(syncState?.injectedTaskIds ?? []);
189
+ // Load existing links for this provider
190
+ const existingLinks = await getLinksByProvider(providerId, cwd);
191
+ const linkMap = buildLinkMap(existingLinks);
189
192
 
190
193
  // Compute actions
191
- const actions = computeActions(externalTasks, taskMap, injectedIds);
194
+ const actions = computeActions(externalTasks, taskMap, linkMap);
192
195
 
193
196
  // Summary counters
194
197
  const summary = {
198
+ created: 0,
199
+ updated: 0,
195
200
  completed: 0,
196
201
  activated: 0,
197
- created: 0,
198
- removed: 0,
199
202
  skipped: 0,
200
203
  conflicts: 0,
204
+ total: actions.length,
201
205
  applied: 0,
202
206
  };
203
207
 
204
208
  // Count by type
205
209
  for (const action of actions) {
206
210
  switch (action.type) {
211
+ case 'create':
212
+ summary.created++;
213
+ break;
214
+ case 'update':
215
+ summary.updated++;
216
+ break;
207
217
  case 'complete':
208
218
  summary.completed++;
209
219
  break;
210
220
  case 'activate':
211
221
  summary.activated++;
212
222
  break;
213
- case 'create':
214
- summary.created++;
215
- break;
216
- case 'remove':
217
- summary.removed++;
218
- break;
219
223
  case 'skip':
220
224
  summary.skipped++;
221
225
  break;
@@ -225,10 +229,17 @@ export async function reconcile(
225
229
  }
226
230
  }
227
231
 
232
+ let linksAffected = 0;
233
+
228
234
  // Apply actions if not dry-run
229
235
  if (!dryRun) {
230
236
  for (const action of actions) {
231
237
  if (action.type === 'skip' || action.type === 'conflict') {
238
+ // Touch link timestamps for skipped items that have links
239
+ if (action.linkId) {
240
+ const ext = externalTasks.find((e) => e.externalId === action.externalId);
241
+ await touchLink(action.linkId, { externalTitle: ext?.title }, cwd);
242
+ }
232
243
  continue;
233
244
  }
234
245
 
@@ -238,11 +249,15 @@ export async function reconcile(
238
249
  await completeTask(
239
250
  {
240
251
  taskId: action.cleoTaskId!,
241
- notes: `Completed via ${providerId} task sync`,
252
+ notes: `Completed via ${providerId} sync`,
242
253
  },
243
254
  cwd,
244
255
  acc,
245
256
  );
257
+ if (action.linkId) {
258
+ await touchLink(action.linkId, undefined, cwd);
259
+ linksAffected++;
260
+ }
246
261
  action.applied = true;
247
262
  summary.applied++;
248
263
  break;
@@ -253,39 +268,80 @@ export async function reconcile(
253
268
  {
254
269
  taskId: action.cleoTaskId!,
255
270
  status: 'active',
256
- notes: `Activated during ${providerId} task sync`,
271
+ notes: `Activated via ${providerId} sync`,
257
272
  },
258
273
  cwd,
259
274
  acc,
260
275
  );
276
+ if (action.linkId) {
277
+ await touchLink(action.linkId, undefined, cwd);
278
+ linksAffected++;
279
+ }
261
280
  action.applied = true;
262
281
  summary.applied++;
263
282
  break;
264
283
  }
265
284
 
266
- case 'create': {
267
- // Find the external task for metadata
285
+ case 'update': {
268
286
  const ext = externalTasks.find((e) => e.externalId === action.externalId);
269
287
  if (!ext) break;
270
288
 
271
- await addTask(
289
+ await updateTask(
272
290
  {
291
+ taskId: action.cleoTaskId!,
273
292
  title: ext.title,
274
- description: ext.description ?? `Created during ${providerId} task sync`,
275
- labels: [...(defaultLabels ?? []), ...(ext.labels ?? []), 'sync-created'],
276
- ...(defaultPhase ? { phase: defaultPhase, addPhase: true } : {}),
293
+ notes: `Updated via ${providerId} sync`,
277
294
  },
278
295
  cwd,
279
296
  acc,
280
297
  );
298
+ if (action.linkId) {
299
+ await touchLink(action.linkId, { externalTitle: ext.title }, cwd);
300
+ linksAffected++;
301
+ }
281
302
  action.applied = true;
282
303
  summary.applied++;
283
304
  break;
284
305
  }
285
306
 
286
- case 'remove': {
287
- // Removals are informational we don't delete CLEO tasks
288
- // just because a provider removed them. Log but don't act.
307
+ case 'create': {
308
+ const ext = externalTasks.find((e) => e.externalId === action.externalId);
309
+ if (!ext) break;
310
+
311
+ const result = await addTask(
312
+ {
313
+ title: ext.title,
314
+ description: ext.description ?? `Synced from ${providerId}`,
315
+ priority: ext.priority,
316
+ type: ext.type,
317
+ labels: [...(defaultLabels ?? []), ...(ext.labels ?? []), `sync:${providerId}`],
318
+ ...(defaultPhase ? { phase: defaultPhase, addPhase: true } : {}),
319
+ },
320
+ cwd,
321
+ acc,
322
+ );
323
+
324
+ // Create a link to track this external → CLEO task mapping
325
+ const newTaskId = result.task.id;
326
+ if (newTaskId) {
327
+ const link = await createLink(
328
+ {
329
+ taskId: newTaskId,
330
+ providerId,
331
+ externalId: ext.externalId,
332
+ externalUrl: ext.url,
333
+ externalTitle: ext.title,
334
+ linkType: 'created',
335
+ syncDirection: 'inbound',
336
+ metadata: ext.providerMeta,
337
+ },
338
+ cwd,
339
+ );
340
+ action.cleoTaskId = newTaskId;
341
+ action.linkId = link.id;
342
+ linksAffected++;
343
+ }
344
+
289
345
  action.applied = true;
290
346
  summary.applied++;
291
347
  break;
@@ -295,9 +351,6 @@ export async function reconcile(
295
351
  action.error = err instanceof Error ? err.message : String(err);
296
352
  }
297
353
  }
298
-
299
- // Clear sync session state after successful apply
300
- await clearSyncState(providerId, cwd);
301
354
  }
302
355
 
303
356
  return {
@@ -305,6 +358,6 @@ export async function reconcile(
305
358
  providerId,
306
359
  actions,
307
360
  summary,
308
- sessionCleared: !dryRun,
361
+ linksAffected,
309
362
  };
310
363
  }
@@ -95,6 +95,51 @@ function isValidVersion(version: string): boolean {
95
95
  return /^v?\d+\.\d+\.\d+(-[\w.]+)?(\+[\w.]+)?$/.test(version);
96
96
  }
97
97
 
98
+ function validateCalVerWindow(
99
+ version: string,
100
+ now = new Date(),
101
+ ): { valid: boolean; message: string } {
102
+ const normalized = version.startsWith('v') ? version.slice(1) : version;
103
+ const base = normalized.split('-')[0] ?? normalized;
104
+ const parts = base.split('.');
105
+
106
+ if (parts.length !== 3) {
107
+ return { valid: false, message: `Invalid CalVer format: ${version}` };
108
+ }
109
+
110
+ const tagYear = Number.parseInt(parts[0] ?? '', 10);
111
+ const tagMonth = Number.parseInt(parts[1] ?? '', 10);
112
+ if (!Number.isInteger(tagYear) || !Number.isInteger(tagMonth)) {
113
+ return { valid: false, message: `Invalid CalVer date components: ${version}` };
114
+ }
115
+
116
+ const currentYear = now.getUTCFullYear();
117
+ const currentMonth = now.getUTCMonth() + 1;
118
+ const nextMonth = currentMonth === 12 ? 1 : currentMonth + 1;
119
+ const nextYear = currentMonth === 12 ? currentYear + 1 : currentYear;
120
+
121
+ const isPreRelease = normalized.includes('-');
122
+ if (isPreRelease) {
123
+ const valid =
124
+ (tagYear === currentYear || tagYear === nextYear) &&
125
+ (tagMonth === currentMonth || tagMonth === nextMonth);
126
+ return {
127
+ valid,
128
+ message: valid
129
+ ? `CalVer OK (pre-release): ${version}`
130
+ : `Pre-release ${version} outside allowed CalVer range ${currentYear}.${currentMonth} or ${nextYear}.${nextMonth}`,
131
+ };
132
+ }
133
+
134
+ const valid = tagYear === currentYear && tagMonth === currentMonth;
135
+ return {
136
+ valid,
137
+ message: valid
138
+ ? `CalVer OK (stable): ${version}`
139
+ : `${version} does not match current CalVer ${currentYear}.${currentMonth}`,
140
+ };
141
+ }
142
+
98
143
  function normalizeVersion(version: string): string {
99
144
  return version.startsWith('v') ? version : `v${version}`;
100
145
  }
@@ -723,6 +768,16 @@ export async function runReleaseGates(
723
768
  : 'Invalid version format',
724
769
  });
725
770
 
771
+ const releaseConfig = loadReleaseConfig(cwd);
772
+ if (releaseConfig.versioningScheme === 'calver') {
773
+ const calver = validateCalVerWindow(normalizedVersion);
774
+ gates.push({
775
+ name: 'calver_window',
776
+ status: calver.valid ? 'passed' : 'failed',
777
+ message: calver.message,
778
+ });
779
+ }
780
+
726
781
  gates.push({
727
782
  name: 'has_tasks',
728
783
  status: releaseTasks.length > 0 ? 'passed' : 'failed',
@@ -824,7 +879,6 @@ export async function runReleaseGates(
824
879
  /* git not available — skip */
825
880
  }
826
881
 
827
- const releaseConfig = loadReleaseConfig(cwd);
828
882
  const gitFlowCfg = getGitFlowConfig(releaseConfig);
829
883
  const channelCfg = getChannelConfig(releaseConfig);
830
884
 
@@ -101,6 +101,10 @@ const CAPABILITY_MATRIX: OperationCapability[] = [
101
101
  { domain: 'tasks', operation: 'relates.add', gateway: 'mutate', mode: 'native' },
102
102
  { domain: 'tasks', operation: 'start', gateway: 'mutate', mode: 'native' },
103
103
  { domain: 'tasks', operation: 'stop', gateway: 'mutate', mode: 'native' },
104
+ // Sync sub-domain (provider-agnostic task reconciliation)
105
+ { domain: 'tasks', operation: 'sync.reconcile', gateway: 'mutate', mode: 'native' },
106
+ { domain: 'tasks', operation: 'sync.links', gateway: 'query', mode: 'native' },
107
+ { domain: 'tasks', operation: 'sync.links.remove', gateway: 'mutate', mode: 'native' },
104
108
 
105
109
  // === Session Domain ===
106
110
  // Query operations
@@ -278,11 +282,6 @@ const CAPABILITY_MATRIX: OperationCapability[] = [
278
282
  { domain: 'tools', operation: 'provider.supports', gateway: 'query', mode: 'native' },
279
283
  { domain: 'tools', operation: 'provider.hooks', gateway: 'query', mode: 'native' },
280
284
  { domain: 'tools', operation: 'provider.inject', gateway: 'mutate', mode: 'native' },
281
- // TodoWrite operations
282
- { domain: 'tools', operation: 'todowrite.status', gateway: 'query', mode: 'native' },
283
- { domain: 'tools', operation: 'todowrite.sync', gateway: 'mutate', mode: 'native' },
284
- { domain: 'tools', operation: 'todowrite.clear', gateway: 'mutate', mode: 'native' },
285
-
286
285
  // === Nexus Domain ===
287
286
  // Query operations
288
287
  { domain: 'nexus', operation: 'status', gateway: 'query', mode: 'native' },
@@ -645,6 +645,56 @@ export const adrRelations = sqliteTable(
645
645
  (table) => [primaryKey({ columns: [table.fromAdrId, table.toAdrId, table.relationType] })],
646
646
  );
647
647
 
648
+ // === EXTERNAL TASK LINKS (provider-agnostic task reconciliation) ===
649
+
650
+ /**
651
+ * Tracks links between CLEO tasks and external system tasks (Linear, Jira, GitHub, etc.).
652
+ * Used by the reconciliation engine to match external tasks to existing CLEO tasks,
653
+ * detect updates, and maintain bidirectional traceability.
654
+ *
655
+ * Each row represents one link: one CLEO task ↔ one external task from one provider.
656
+ * A CLEO task MAY have links from multiple providers (e.g., both Linear and GitHub).
657
+ * An external task SHOULD have at most one link per provider.
658
+ */
659
+ export const externalTaskLinks = sqliteTable(
660
+ 'external_task_links',
661
+ {
662
+ id: text('id').primaryKey(),
663
+ taskId: text('task_id')
664
+ .notNull()
665
+ .references(() => tasks.id, { onDelete: 'cascade' }),
666
+ /** Provider identifier (e.g. 'linear', 'jira', 'github', 'gitlab'). */
667
+ providerId: text('provider_id').notNull(),
668
+ /** Provider-assigned identifier for the external task (opaque to CLEO). */
669
+ externalId: text('external_id').notNull(),
670
+ /** Optional URL to the external task (for human navigation). */
671
+ externalUrl: text('external_url'),
672
+ /** Title of the external task at the time of last sync. */
673
+ externalTitle: text('external_title'),
674
+ /** How this link was established. */
675
+ linkType: text('link_type', {
676
+ enum: ['created', 'matched', 'manual'],
677
+ }).notNull(),
678
+ /** Direction of the sync that created this link. */
679
+ syncDirection: text('sync_direction', {
680
+ enum: ['inbound', 'outbound', 'bidirectional'],
681
+ })
682
+ .notNull()
683
+ .default('inbound'),
684
+ /** Arbitrary provider-specific metadata (JSON). */
685
+ metadataJson: text('metadata_json').default('{}'),
686
+ /** When the link was first established. */
687
+ linkedAt: text('linked_at').notNull().default(sql`(datetime('now'))`),
688
+ /** When the external task was last synchronized. */
689
+ lastSyncAt: text('last_sync_at'),
690
+ },
691
+ (table) => [
692
+ index('idx_ext_links_task_id').on(table.taskId),
693
+ index('idx_ext_links_provider_external').on(table.providerId, table.externalId),
694
+ index('idx_ext_links_provider_id').on(table.providerId),
695
+ ],
696
+ );
697
+
648
698
  // === STATUS REGISTRY (ADR-018) ===
649
699
 
650
700
  export const statusRegistryTable = sqliteTable(
@@ -702,3 +752,5 @@ export type PipelineManifestRow = typeof pipelineManifest.$inferSelect;
702
752
  export type NewPipelineManifestRow = typeof pipelineManifest.$inferInsert;
703
753
  export type ReleaseManifestRow = typeof releaseManifests.$inferSelect;
704
754
  export type NewReleaseManifestRow = typeof releaseManifests.$inferInsert;
755
+ export type ExternalTaskLinkRow = typeof externalTaskLinks.$inferSelect;
756
+ export type NewExternalTaskLinkRow = typeof externalTaskLinks.$inferInsert;
@@ -232,17 +232,3 @@ export async function getWorkHistory(
232
232
  * @task T5323
233
233
  */
234
234
  export const getTaskHistory = getWorkHistory;
235
-
236
- export type {
237
- ChangeSet as TodoWriteChangeSet,
238
- SyncSessionState,
239
- TodoWriteItem,
240
- TodoWriteMergeOptions,
241
- TodoWriteMergeResult,
242
- TodoWriteState,
243
- } from './todowrite-merge.js';
244
- // TodoWrite merge
245
- export {
246
- analyzeChanges as analyzeTodoWriteChanges,
247
- mergeTodoWriteState,
248
- } from './todowrite-merge.js';