@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.
- package/dist/index.js +670 -655
- package/dist/index.js.map +4 -4
- package/package.json +2 -2
- package/src/admin/export.ts +2 -17
- package/src/cleo.ts +16 -8
- package/src/inject/index.ts +7 -7
- package/src/internal.ts +14 -23
- package/src/reconciliation/index.ts +11 -2
- package/src/reconciliation/link-store.ts +174 -0
- package/src/reconciliation/reconciliation-engine.ts +136 -83
- package/src/release/release-manifest.ts +55 -1
- package/src/routing/capability-matrix.ts +4 -5
- package/src/store/tasks-schema.ts +52 -0
- package/src/task-work/index.ts +0 -14
- package/src/admin/sync.ts +0 -164
- package/src/reconciliation/sync-state.ts +0 -73
- package/src/task-work/todowrite-merge.ts +0 -289
|
@@ -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
|
-
*
|
|
8
|
-
*
|
|
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
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
//
|
|
74
|
+
// Linked CLEO task was deleted — skip
|
|
62
75
|
actions.push({
|
|
63
76
|
type: 'skip',
|
|
64
|
-
cleoTaskId:
|
|
77
|
+
cleoTaskId: existingLink.taskId,
|
|
65
78
|
externalId: ext.externalId,
|
|
66
|
-
summary: `CLEO task ${
|
|
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
|
|
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:
|
|
89
|
+
cleoTaskId: cleoTask.id,
|
|
77
90
|
externalId: ext.externalId,
|
|
78
|
-
summary: `CLEO task ${
|
|
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:
|
|
101
|
+
cleoTaskId: cleoTask.id,
|
|
89
102
|
externalId: ext.externalId,
|
|
90
|
-
summary: `Complete ${
|
|
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:
|
|
117
|
+
cleoTaskId: cleoTask.id,
|
|
104
118
|
externalId: ext.externalId,
|
|
105
|
-
summary: `Activate ${
|
|
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
|
-
//
|
|
112
|
-
if (ext.
|
|
126
|
+
// Check if title or other properties changed → update
|
|
127
|
+
if (ext.title !== cleoTask.title) {
|
|
113
128
|
actions.push({
|
|
114
|
-
type: '
|
|
115
|
-
cleoTaskId:
|
|
129
|
+
type: 'update',
|
|
130
|
+
cleoTaskId: cleoTask.id,
|
|
116
131
|
externalId: ext.externalId,
|
|
117
|
-
summary: `
|
|
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:
|
|
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: `
|
|
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
|
|
187
|
-
const
|
|
188
|
-
const
|
|
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,
|
|
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}
|
|
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
|
|
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 '
|
|
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
|
|
289
|
+
await updateTask(
|
|
272
290
|
{
|
|
291
|
+
taskId: action.cleoTaskId!,
|
|
273
292
|
title: ext.title,
|
|
274
|
-
|
|
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 '
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
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;
|
package/src/task-work/index.ts
CHANGED
|
@@ -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';
|