@cleocode/core 2026.3.43 → 2026.3.45
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/admin/export-tasks.d.ts.map +1 -1
- package/dist/admin/import-tasks.d.ts +10 -2
- package/dist/admin/import-tasks.d.ts.map +1 -1
- package/dist/agents/agent-schema.d.ts +358 -0
- package/dist/agents/agent-schema.d.ts.map +1 -0
- package/dist/agents/capacity.d.ts +57 -0
- package/dist/agents/capacity.d.ts.map +1 -0
- package/dist/agents/index.d.ts +17 -0
- package/dist/agents/index.d.ts.map +1 -0
- package/dist/agents/registry.d.ts +115 -0
- package/dist/agents/registry.d.ts.map +1 -0
- package/dist/agents/retry.d.ts +83 -0
- package/dist/agents/retry.d.ts.map +1 -0
- package/dist/hooks/index.d.ts +4 -1
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/payload-schemas.d.ts +214 -0
- package/dist/hooks/payload-schemas.d.ts.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16937 -2371
- package/dist/index.js.map +4 -4
- package/dist/inject/index.d.ts.map +1 -1
- package/dist/intelligence/impact.d.ts +51 -0
- package/dist/intelligence/impact.d.ts.map +1 -0
- package/dist/intelligence/index.d.ts +15 -0
- package/dist/intelligence/index.d.ts.map +1 -0
- package/dist/intelligence/patterns.d.ts +66 -0
- package/dist/intelligence/patterns.d.ts.map +1 -0
- package/dist/intelligence/prediction.d.ts +51 -0
- package/dist/intelligence/prediction.d.ts.map +1 -0
- package/dist/intelligence/types.d.ts +221 -0
- package/dist/intelligence/types.d.ts.map +1 -0
- package/dist/internal.d.ts +12 -1
- package/dist/internal.d.ts.map +1 -1
- package/dist/issue/template-parser.d.ts +8 -2
- package/dist/issue/template-parser.d.ts.map +1 -1
- package/dist/lifecycle/pipeline.d.ts +2 -2
- package/dist/lifecycle/pipeline.d.ts.map +1 -1
- package/dist/lifecycle/state-machine.d.ts +1 -1
- package/dist/lifecycle/state-machine.d.ts.map +1 -1
- package/dist/memory/brain-lifecycle.d.ts.map +1 -1
- package/dist/memory/brain-retrieval.d.ts.map +1 -1
- package/dist/memory/brain-row-types.d.ts +40 -6
- package/dist/memory/brain-row-types.d.ts.map +1 -1
- package/dist/memory/brain-search.d.ts.map +1 -1
- package/dist/memory/brain-similarity.d.ts.map +1 -1
- package/dist/memory/claude-mem-migration.d.ts.map +1 -1
- package/dist/nexus/discover.d.ts.map +1 -1
- package/dist/nexus/index.d.ts +2 -0
- package/dist/nexus/index.d.ts.map +1 -1
- package/dist/nexus/transfer-types.d.ts +123 -0
- package/dist/nexus/transfer-types.d.ts.map +1 -0
- package/dist/nexus/transfer.d.ts +31 -0
- package/dist/nexus/transfer.d.ts.map +1 -0
- package/dist/orchestration/bootstrap.d.ts.map +1 -1
- package/dist/orchestration/skill-ops.d.ts +4 -4
- package/dist/orchestration/skill-ops.d.ts.map +1 -1
- package/dist/otel/index.d.ts +1 -1
- package/dist/otel/index.d.ts.map +1 -1
- package/dist/sessions/briefing.d.ts.map +1 -1
- package/dist/sessions/handoff.d.ts.map +1 -1
- package/dist/sessions/index.d.ts +1 -1
- package/dist/sessions/index.d.ts.map +1 -1
- package/dist/sessions/types.d.ts +8 -42
- package/dist/sessions/types.d.ts.map +1 -1
- package/dist/signaldock/signaldock-transport.d.ts +1 -1
- package/dist/signaldock/signaldock-transport.d.ts.map +1 -1
- package/dist/skills/injection/subagent.d.ts +3 -3
- package/dist/skills/injection/subagent.d.ts.map +1 -1
- package/dist/skills/manifests/contribution.d.ts +2 -2
- package/dist/skills/manifests/contribution.d.ts.map +1 -1
- package/dist/skills/orchestrator/spawn.d.ts +6 -6
- package/dist/skills/orchestrator/spawn.d.ts.map +1 -1
- package/dist/skills/orchestrator/startup.d.ts +1 -1
- package/dist/skills/orchestrator/startup.d.ts.map +1 -1
- package/dist/skills/orchestrator/validator.d.ts +2 -2
- package/dist/skills/orchestrator/validator.d.ts.map +1 -1
- package/dist/skills/precedence-types.d.ts +24 -1
- package/dist/skills/precedence-types.d.ts.map +1 -1
- package/dist/skills/types.d.ts +70 -4
- package/dist/skills/types.d.ts.map +1 -1
- package/dist/store/brain-sqlite.d.ts +4 -1
- package/dist/store/brain-sqlite.d.ts.map +1 -1
- package/dist/store/export.d.ts +5 -4
- package/dist/store/export.d.ts.map +1 -1
- package/dist/store/nexus-sqlite.d.ts +4 -1
- package/dist/store/nexus-sqlite.d.ts.map +1 -1
- package/dist/store/sqlite.d.ts +4 -1
- package/dist/store/sqlite.d.ts.map +1 -1
- package/dist/store/tasks-schema.d.ts +14 -4
- package/dist/store/tasks-schema.d.ts.map +1 -1
- package/dist/store/typed-query.d.ts +12 -0
- package/dist/store/typed-query.d.ts.map +1 -0
- package/dist/store/validation-schemas.d.ts +2423 -50
- package/dist/store/validation-schemas.d.ts.map +1 -1
- package/dist/system/inject-generate.d.ts.map +1 -1
- package/dist/validation/doctor/checks.d.ts +5 -0
- package/dist/validation/doctor/checks.d.ts.map +1 -1
- package/dist/validation/engine.d.ts +10 -10
- package/dist/validation/engine.d.ts.map +1 -1
- package/dist/validation/index.d.ts +6 -2
- package/dist/validation/index.d.ts.map +1 -1
- package/dist/validation/protocol-common.d.ts +10 -2
- package/dist/validation/protocol-common.d.ts.map +1 -1
- package/migrations/drizzle-tasks/20260320013731_wave0-schema-hardening/migration.sql +84 -0
- package/migrations/drizzle-tasks/20260320013731_wave0-schema-hardening/snapshot.json +4060 -0
- package/migrations/drizzle-tasks/20260320020000_agent-dimension/migration.sql +35 -0
- package/migrations/drizzle-tasks/20260320020000_agent-dimension/snapshot.json +4312 -0
- package/package.json +2 -2
- package/src/admin/export-tasks.ts +2 -5
- package/src/admin/import-tasks.ts +53 -29
- package/src/agents/__tests__/capacity.test.ts +219 -0
- package/src/agents/__tests__/registry.test.ts +457 -0
- package/src/agents/__tests__/retry.test.ts +289 -0
- package/src/agents/agent-schema.ts +107 -0
- package/src/agents/capacity.ts +151 -0
- package/src/agents/index.ts +68 -0
- package/src/agents/registry.ts +449 -0
- package/src/agents/retry.ts +255 -0
- package/src/hooks/index.ts +20 -1
- package/src/hooks/payload-schemas.ts +199 -0
- package/src/index.ts +69 -0
- package/src/inject/index.ts +14 -14
- package/src/intelligence/__tests__/impact.test.ts +453 -0
- package/src/intelligence/__tests__/patterns.test.ts +450 -0
- package/src/intelligence/__tests__/prediction.test.ts +418 -0
- package/src/intelligence/impact.ts +638 -0
- package/src/intelligence/index.ts +47 -0
- package/src/intelligence/patterns.ts +621 -0
- package/src/intelligence/prediction.ts +621 -0
- package/src/intelligence/types.ts +273 -0
- package/src/internal.ts +89 -2
- package/src/issue/template-parser.ts +65 -4
- package/src/lifecycle/pipeline.ts +14 -7
- package/src/lifecycle/state-machine.ts +6 -2
- package/src/memory/brain-lifecycle.ts +5 -11
- package/src/memory/brain-retrieval.ts +44 -38
- package/src/memory/brain-row-types.ts +43 -6
- package/src/memory/brain-search.ts +53 -32
- package/src/memory/brain-similarity.ts +9 -8
- package/src/memory/claude-mem-migration.ts +4 -3
- package/src/nexus/__tests__/nexus-e2e.test.ts +1481 -0
- package/src/nexus/__tests__/transfer.test.ts +446 -0
- package/src/nexus/discover.ts +1 -0
- package/src/nexus/index.ts +14 -0
- package/src/nexus/transfer-types.ts +129 -0
- package/src/nexus/transfer.ts +314 -0
- package/src/orchestration/bootstrap.ts +11 -17
- package/src/orchestration/skill-ops.ts +52 -32
- package/src/otel/index.ts +48 -4
- package/src/sessions/__tests__/briefing.test.ts +31 -2
- package/src/sessions/briefing.ts +27 -42
- package/src/sessions/handoff.ts +52 -86
- package/src/sessions/index.ts +5 -1
- package/src/sessions/types.ts +9 -43
- package/src/signaldock/signaldock-transport.ts +5 -2
- package/src/skills/injection/subagent.ts +10 -16
- package/src/skills/manifests/contribution.ts +5 -13
- package/src/skills/orchestrator/__tests__/spawn-tier.test.ts +44 -30
- package/src/skills/orchestrator/spawn.ts +18 -31
- package/src/skills/orchestrator/startup.ts +78 -65
- package/src/skills/orchestrator/validator.ts +26 -31
- package/src/skills/precedence-types.ts +24 -1
- package/src/skills/types.ts +72 -5
- package/src/store/__tests__/test-db-helper.d.ts +4 -4
- package/src/store/__tests__/test-db-helper.js +5 -16
- package/src/store/__tests__/test-db-helper.ts +5 -18
- package/src/store/brain-sqlite.ts +7 -3
- package/src/store/chain-schema.ts +1 -1
- package/src/store/export.ts +22 -12
- package/src/store/nexus-sqlite.ts +7 -3
- package/src/store/sqlite.ts +9 -3
- package/src/store/tasks-schema.ts +65 -8
- package/src/store/typed-query.ts +17 -0
- package/src/store/validation-schemas.ts +347 -23
- package/src/system/inject-generate.ts +9 -23
- package/src/validation/doctor/checks.ts +24 -2
- package/src/validation/engine.ts +11 -11
- package/src/validation/index.ts +131 -3
- package/src/validation/protocol-common.ts +54 -3
- package/dist/tasks/reparent.d.ts +0 -38
- package/dist/tasks/reparent.d.ts.map +0 -1
- package/src/tasks/reparent.ts +0 -134
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for NEXUS cross-project task transfer.
|
|
3
|
+
*
|
|
4
|
+
* @task T046, T055
|
|
5
|
+
* @epic T4540
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { mkdir, mkdtemp, rm } from 'node:fs/promises';
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import type { Task } from '@cleocode/contracts';
|
|
12
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
13
|
+
import { getLinksByTaskId } from '../../reconciliation/link-store.js';
|
|
14
|
+
import { seedTasks } from '../../store/__tests__/test-db-helper.js';
|
|
15
|
+
import { resetDbState } from '../../store/sqlite.js';
|
|
16
|
+
import { createSqliteDataAccessor } from '../../store/sqlite-data-accessor.js';
|
|
17
|
+
import { nexusInit, nexusRegister, resetNexusDbState } from '../registry.js';
|
|
18
|
+
import { executeTransfer, previewTransfer } from '../transfer.js';
|
|
19
|
+
|
|
20
|
+
/** Create a test project with tasks in SQLite (tasks.db). */
|
|
21
|
+
async function createTestProjectDb(
|
|
22
|
+
dir: string,
|
|
23
|
+
tasks: Array<Partial<Task> & { id: string }>,
|
|
24
|
+
): Promise<void> {
|
|
25
|
+
await mkdir(join(dir, '.cleo'), { recursive: true });
|
|
26
|
+
resetDbState();
|
|
27
|
+
const accessor = await createSqliteDataAccessor(dir);
|
|
28
|
+
await seedTasks(accessor, tasks);
|
|
29
|
+
await accessor.close();
|
|
30
|
+
resetDbState();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let testDir: string;
|
|
34
|
+
let registryDir: string;
|
|
35
|
+
let sourceDir: string;
|
|
36
|
+
let targetDir: string;
|
|
37
|
+
|
|
38
|
+
beforeEach(async () => {
|
|
39
|
+
testDir = await mkdtemp(join(tmpdir(), 'nexus-transfer-test-'));
|
|
40
|
+
registryDir = join(testDir, 'cleo-home');
|
|
41
|
+
sourceDir = join(testDir, 'source-project');
|
|
42
|
+
targetDir = join(testDir, 'target-project');
|
|
43
|
+
|
|
44
|
+
await mkdir(registryDir, { recursive: true });
|
|
45
|
+
|
|
46
|
+
// Create source project with a task hierarchy
|
|
47
|
+
await createTestProjectDb(sourceDir, [
|
|
48
|
+
{ id: 'T001', title: 'Epic: Auth', type: 'epic', status: 'in-progress' },
|
|
49
|
+
{
|
|
50
|
+
id: 'T002',
|
|
51
|
+
title: 'Login form',
|
|
52
|
+
parentId: 'T001',
|
|
53
|
+
status: 'pending',
|
|
54
|
+
description: 'Build login',
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: 'T003',
|
|
58
|
+
title: 'JWT tokens',
|
|
59
|
+
parentId: 'T001',
|
|
60
|
+
depends: ['T002'],
|
|
61
|
+
status: 'pending',
|
|
62
|
+
},
|
|
63
|
+
{ id: 'T004', title: 'Unrelated task', status: 'done' },
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
// Create empty target project
|
|
67
|
+
await createTestProjectDb(targetDir, []);
|
|
68
|
+
|
|
69
|
+
// Point env vars to test dirs
|
|
70
|
+
process.env['CLEO_HOME'] = registryDir;
|
|
71
|
+
process.env['NEXUS_HOME'] = join(registryDir, 'nexus');
|
|
72
|
+
process.env['NEXUS_CACHE_DIR'] = join(registryDir, 'nexus', 'cache');
|
|
73
|
+
process.env['NEXUS_SKIP_PERMISSION_CHECK'] = 'true';
|
|
74
|
+
|
|
75
|
+
resetNexusDbState();
|
|
76
|
+
|
|
77
|
+
// Register both projects
|
|
78
|
+
await nexusInit();
|
|
79
|
+
await nexusRegister(sourceDir, 'source-project', 'read');
|
|
80
|
+
resetDbState();
|
|
81
|
+
await nexusRegister(targetDir, 'target-project', 'write');
|
|
82
|
+
resetDbState();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
afterEach(async () => {
|
|
86
|
+
delete process.env['CLEO_HOME'];
|
|
87
|
+
delete process.env['NEXUS_HOME'];
|
|
88
|
+
delete process.env['NEXUS_CACHE_DIR'];
|
|
89
|
+
delete process.env['NEXUS_SKIP_PERMISSION_CHECK'];
|
|
90
|
+
resetNexusDbState();
|
|
91
|
+
resetDbState();
|
|
92
|
+
await rm(testDir, { recursive: true, force: true });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('previewTransfer', () => {
|
|
96
|
+
it('returns a dry-run result without writing', async () => {
|
|
97
|
+
const result = await previewTransfer({
|
|
98
|
+
taskIds: ['T001'],
|
|
99
|
+
sourceProject: 'source-project',
|
|
100
|
+
targetProject: 'target-project',
|
|
101
|
+
scope: 'subtree',
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
expect(result.dryRun).toBe(true);
|
|
105
|
+
expect(result.transferred).toBe(3); // T001 + T002 + T003
|
|
106
|
+
expect(result.manifest.sourceProject).toBe('source-project');
|
|
107
|
+
expect(result.manifest.targetProject).toBe('target-project');
|
|
108
|
+
expect(result.manifest.entries).toHaveLength(3);
|
|
109
|
+
|
|
110
|
+
// Verify nothing was written to target
|
|
111
|
+
resetDbState();
|
|
112
|
+
const accessor = await createSqliteDataAccessor(targetDir);
|
|
113
|
+
const { tasks } = await accessor.queryTasks({});
|
|
114
|
+
expect(tasks).toHaveLength(0);
|
|
115
|
+
await accessor.close();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('returns single-task preview when scope is single', async () => {
|
|
119
|
+
const result = await previewTransfer({
|
|
120
|
+
taskIds: ['T001'],
|
|
121
|
+
sourceProject: 'source-project',
|
|
122
|
+
targetProject: 'target-project',
|
|
123
|
+
scope: 'single',
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
expect(result.dryRun).toBe(true);
|
|
127
|
+
expect(result.transferred).toBe(1);
|
|
128
|
+
expect(result.manifest.entries).toHaveLength(1);
|
|
129
|
+
expect(result.manifest.entries[0]!.sourceId).toBe('T001');
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe('executeTransfer - copy mode', () => {
|
|
134
|
+
it('copies a subtree to the target project', async () => {
|
|
135
|
+
const result = await executeTransfer({
|
|
136
|
+
taskIds: ['T001'],
|
|
137
|
+
sourceProject: 'source-project',
|
|
138
|
+
targetProject: 'target-project',
|
|
139
|
+
mode: 'copy',
|
|
140
|
+
scope: 'subtree',
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
expect(result.dryRun).toBe(false);
|
|
144
|
+
expect(result.transferred).toBe(3);
|
|
145
|
+
expect(result.skipped).toBe(0);
|
|
146
|
+
expect(result.archived).toBe(0);
|
|
147
|
+
expect(result.manifest.mode).toBe('copy');
|
|
148
|
+
|
|
149
|
+
// Verify tasks exist in target
|
|
150
|
+
resetDbState();
|
|
151
|
+
const targetAccessor = await createSqliteDataAccessor(targetDir);
|
|
152
|
+
const { tasks: targetTasks } = await targetAccessor.queryTasks({});
|
|
153
|
+
expect(targetTasks).toHaveLength(3);
|
|
154
|
+
await targetAccessor.close();
|
|
155
|
+
|
|
156
|
+
// Verify source tasks still exist (not archived)
|
|
157
|
+
resetDbState();
|
|
158
|
+
const sourceAccessor = await createSqliteDataAccessor(sourceDir);
|
|
159
|
+
const { tasks: sourceTasks } = await sourceAccessor.queryTasks({});
|
|
160
|
+
expect(sourceTasks).toHaveLength(4); // all 4 original tasks
|
|
161
|
+
await sourceAccessor.close();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('remaps task IDs in the target', async () => {
|
|
165
|
+
// Seed target with a task so IDs don't accidentally collide
|
|
166
|
+
resetDbState();
|
|
167
|
+
const accessor = await createSqliteDataAccessor(targetDir);
|
|
168
|
+
await seedTasks(accessor, [{ id: 'T010', title: 'Existing target task', status: 'pending' }]);
|
|
169
|
+
await accessor.close();
|
|
170
|
+
resetDbState();
|
|
171
|
+
|
|
172
|
+
const result = await executeTransfer({
|
|
173
|
+
taskIds: ['T001'],
|
|
174
|
+
sourceProject: 'source-project',
|
|
175
|
+
targetProject: 'target-project',
|
|
176
|
+
scope: 'subtree',
|
|
177
|
+
onConflict: 'rename',
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
expect(Object.keys(result.manifest.idRemap).length).toBeGreaterThan(0);
|
|
181
|
+
|
|
182
|
+
// All target IDs should be different from source IDs since target has T010
|
|
183
|
+
for (const entry of result.manifest.entries) {
|
|
184
|
+
expect(entry.targetId).not.toBe(entry.sourceId);
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('preserves parent-child hierarchy in target', async () => {
|
|
189
|
+
const result = await executeTransfer({
|
|
190
|
+
taskIds: ['T001'],
|
|
191
|
+
sourceProject: 'source-project',
|
|
192
|
+
targetProject: 'target-project',
|
|
193
|
+
scope: 'subtree',
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
resetDbState();
|
|
197
|
+
const accessor = await createSqliteDataAccessor(targetDir);
|
|
198
|
+
const { tasks } = await accessor.queryTasks({});
|
|
199
|
+
|
|
200
|
+
const epicId = result.manifest.idRemap['T001']!;
|
|
201
|
+
const loginId = result.manifest.idRemap['T002']!;
|
|
202
|
+
const jwtId = result.manifest.idRemap['T003']!;
|
|
203
|
+
|
|
204
|
+
const loginTask = tasks.find((t) => t.id === loginId);
|
|
205
|
+
const jwtTask = tasks.find((t) => t.id === jwtId);
|
|
206
|
+
|
|
207
|
+
expect(loginTask?.parentId).toBe(epicId);
|
|
208
|
+
expect(jwtTask?.parentId).toBe(epicId);
|
|
209
|
+
await accessor.close();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('preserves dependencies in target', async () => {
|
|
213
|
+
const result = await executeTransfer({
|
|
214
|
+
taskIds: ['T001'],
|
|
215
|
+
sourceProject: 'source-project',
|
|
216
|
+
targetProject: 'target-project',
|
|
217
|
+
scope: 'subtree',
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
resetDbState();
|
|
221
|
+
const accessor = await createSqliteDataAccessor(targetDir);
|
|
222
|
+
const { tasks } = await accessor.queryTasks({});
|
|
223
|
+
|
|
224
|
+
const loginId = result.manifest.idRemap['T002']!;
|
|
225
|
+
const jwtId = result.manifest.idRemap['T003']!;
|
|
226
|
+
const jwtTask = tasks.find((t) => t.id === jwtId);
|
|
227
|
+
|
|
228
|
+
expect(jwtTask?.depends).toContain(loginId);
|
|
229
|
+
await accessor.close();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('adds provenance notes by default', async () => {
|
|
233
|
+
const result = await executeTransfer({
|
|
234
|
+
taskIds: ['T001'],
|
|
235
|
+
sourceProject: 'source-project',
|
|
236
|
+
targetProject: 'target-project',
|
|
237
|
+
scope: 'single',
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
resetDbState();
|
|
241
|
+
const accessor = await createSqliteDataAccessor(targetDir);
|
|
242
|
+
const { tasks } = await accessor.queryTasks({});
|
|
243
|
+
|
|
244
|
+
const epicId = result.manifest.idRemap['T001']!;
|
|
245
|
+
const task = tasks.find((t) => t.id === epicId);
|
|
246
|
+
expect(task?.notes?.some((n) => n.includes('Imported from source-project'))).toBe(true);
|
|
247
|
+
await accessor.close();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('creates bidirectional external links', async () => {
|
|
251
|
+
const result = await executeTransfer({
|
|
252
|
+
taskIds: ['T004'],
|
|
253
|
+
sourceProject: 'source-project',
|
|
254
|
+
targetProject: 'target-project',
|
|
255
|
+
scope: 'single',
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
expect(result.linksCreated).toBe(2); // one in each project
|
|
259
|
+
|
|
260
|
+
// Check target link
|
|
261
|
+
resetDbState();
|
|
262
|
+
const targetId = result.manifest.idRemap['T004']!;
|
|
263
|
+
const targetLinks = await getLinksByTaskId(targetId, targetDir);
|
|
264
|
+
expect(targetLinks).toHaveLength(1);
|
|
265
|
+
expect(targetLinks[0]!.providerId).toBe('nexus:source-project');
|
|
266
|
+
expect(targetLinks[0]!.externalId).toBe('T004');
|
|
267
|
+
expect(targetLinks[0]!.linkType).toBe('transferred');
|
|
268
|
+
expect(targetLinks[0]!.syncDirection).toBe('inbound');
|
|
269
|
+
|
|
270
|
+
// Check source link
|
|
271
|
+
resetDbState();
|
|
272
|
+
const sourceLinks = await getLinksByTaskId('T004', sourceDir);
|
|
273
|
+
expect(sourceLinks).toHaveLength(1);
|
|
274
|
+
expect(sourceLinks[0]!.providerId).toBe('nexus:target-project');
|
|
275
|
+
expect(sourceLinks[0]!.externalId).toBe(targetId);
|
|
276
|
+
expect(sourceLinks[0]!.linkType).toBe('transferred');
|
|
277
|
+
expect(sourceLinks[0]!.syncDirection).toBe('outbound');
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('transfers a single task without descendants', async () => {
|
|
281
|
+
const result = await executeTransfer({
|
|
282
|
+
taskIds: ['T004'],
|
|
283
|
+
sourceProject: 'source-project',
|
|
284
|
+
targetProject: 'target-project',
|
|
285
|
+
scope: 'single',
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
expect(result.transferred).toBe(1);
|
|
289
|
+
expect(result.manifest.entries).toHaveLength(1);
|
|
290
|
+
expect(result.manifest.entries[0]!.sourceId).toBe('T004');
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
describe('executeTransfer - move mode', () => {
|
|
295
|
+
it('archives source tasks after transfer', async () => {
|
|
296
|
+
const result = await executeTransfer({
|
|
297
|
+
taskIds: ['T004'],
|
|
298
|
+
sourceProject: 'source-project',
|
|
299
|
+
targetProject: 'target-project',
|
|
300
|
+
mode: 'move',
|
|
301
|
+
scope: 'single',
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
expect(result.transferred).toBe(1);
|
|
305
|
+
expect(result.archived).toBe(1);
|
|
306
|
+
expect(result.manifest.mode).toBe('move');
|
|
307
|
+
|
|
308
|
+
// Source task should be archived (queryTasks excludes archived by default)
|
|
309
|
+
resetDbState();
|
|
310
|
+
const sourceAccessor = await createSqliteDataAccessor(sourceDir);
|
|
311
|
+
const { tasks: sourceTasks } = await sourceAccessor.queryTasks({
|
|
312
|
+
status: 'archived',
|
|
313
|
+
});
|
|
314
|
+
const archivedTask = sourceTasks.find((t) => t.id === 'T004');
|
|
315
|
+
expect(archivedTask?.status).toBe('archived');
|
|
316
|
+
await sourceAccessor.close();
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
describe('executeTransfer - error handling', () => {
|
|
321
|
+
it('throws when source project not found', async () => {
|
|
322
|
+
await expect(
|
|
323
|
+
executeTransfer({
|
|
324
|
+
taskIds: ['T001'],
|
|
325
|
+
sourceProject: 'nonexistent',
|
|
326
|
+
targetProject: 'target-project',
|
|
327
|
+
}),
|
|
328
|
+
).rejects.toThrow('Source project not found');
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('throws when target project not found', async () => {
|
|
332
|
+
await expect(
|
|
333
|
+
executeTransfer({
|
|
334
|
+
taskIds: ['T001'],
|
|
335
|
+
sourceProject: 'source-project',
|
|
336
|
+
targetProject: 'nonexistent',
|
|
337
|
+
}),
|
|
338
|
+
).rejects.toThrow('Target project not found');
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('throws when source and target are the same', async () => {
|
|
342
|
+
await expect(
|
|
343
|
+
executeTransfer({
|
|
344
|
+
taskIds: ['T001'],
|
|
345
|
+
sourceProject: 'source-project',
|
|
346
|
+
targetProject: 'source-project',
|
|
347
|
+
}),
|
|
348
|
+
).rejects.toThrow('Source and target projects must be different');
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('throws when task not found in source', async () => {
|
|
352
|
+
await expect(
|
|
353
|
+
executeTransfer({
|
|
354
|
+
taskIds: ['T999'],
|
|
355
|
+
sourceProject: 'source-project',
|
|
356
|
+
targetProject: 'target-project',
|
|
357
|
+
}),
|
|
358
|
+
).rejects.toThrow('Task not found in source project: T999');
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it('throws when no task IDs specified', async () => {
|
|
362
|
+
await expect(
|
|
363
|
+
executeTransfer({
|
|
364
|
+
taskIds: [],
|
|
365
|
+
sourceProject: 'source-project',
|
|
366
|
+
targetProject: 'target-project',
|
|
367
|
+
}),
|
|
368
|
+
).rejects.toThrow('No task IDs specified');
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
describe('executeTransfer - conflict resolution', () => {
|
|
373
|
+
it('renames tasks with duplicate titles by default', async () => {
|
|
374
|
+
// Create a task in target with the same title as source
|
|
375
|
+
resetDbState();
|
|
376
|
+
const accessor = await createSqliteDataAccessor(targetDir);
|
|
377
|
+
await seedTasks(accessor, [{ id: 'T001', title: 'Unrelated task', status: 'pending' }]);
|
|
378
|
+
await accessor.close();
|
|
379
|
+
resetDbState();
|
|
380
|
+
|
|
381
|
+
const result = await executeTransfer({
|
|
382
|
+
taskIds: ['T004'],
|
|
383
|
+
sourceProject: 'source-project',
|
|
384
|
+
targetProject: 'target-project',
|
|
385
|
+
scope: 'single',
|
|
386
|
+
onConflict: 'rename',
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
expect(result.transferred).toBe(1);
|
|
390
|
+
|
|
391
|
+
resetDbState();
|
|
392
|
+
const targetAccessor = await createSqliteDataAccessor(targetDir);
|
|
393
|
+
const { tasks: targetTasks } = await targetAccessor.queryTasks({});
|
|
394
|
+
const transferredTask = targetTasks.find((t) => t.id === result.manifest.idRemap['T004']);
|
|
395
|
+
expect(transferredTask?.title).toContain('imported');
|
|
396
|
+
await targetAccessor.close();
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it('skips tasks with duplicate titles when onConflict=skip', async () => {
|
|
400
|
+
resetDbState();
|
|
401
|
+
const accessor = await createSqliteDataAccessor(targetDir);
|
|
402
|
+
await seedTasks(accessor, [{ id: 'T001', title: 'Unrelated task', status: 'pending' }]);
|
|
403
|
+
await accessor.close();
|
|
404
|
+
resetDbState();
|
|
405
|
+
|
|
406
|
+
const result = await executeTransfer({
|
|
407
|
+
taskIds: ['T004'],
|
|
408
|
+
sourceProject: 'source-project',
|
|
409
|
+
targetProject: 'target-project',
|
|
410
|
+
scope: 'single',
|
|
411
|
+
onConflict: 'skip',
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
expect(result.skipped).toBe(1);
|
|
415
|
+
expect(result.transferred).toBe(0);
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
describe('executeTransfer - multiple tasks', () => {
|
|
420
|
+
it('transfers multiple independent tasks', async () => {
|
|
421
|
+
const result = await executeTransfer({
|
|
422
|
+
taskIds: ['T001', 'T004'],
|
|
423
|
+
sourceProject: 'source-project',
|
|
424
|
+
targetProject: 'target-project',
|
|
425
|
+
scope: 'single',
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
expect(result.transferred).toBe(2);
|
|
429
|
+
expect(result.manifest.entries).toHaveLength(2);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it('deduplicates tasks when subtree overlaps', async () => {
|
|
433
|
+
// T001 subtree includes T002 and T003
|
|
434
|
+
// Requesting T001 and T002 should not duplicate T002
|
|
435
|
+
const result = await executeTransfer({
|
|
436
|
+
taskIds: ['T001', 'T002'],
|
|
437
|
+
sourceProject: 'source-project',
|
|
438
|
+
targetProject: 'target-project',
|
|
439
|
+
scope: 'subtree',
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
// T001 subtree is T001, T002, T003. T002 subtree is just T002.
|
|
443
|
+
// Merged = T001, T002, T003 (deduped)
|
|
444
|
+
expect(result.transferred).toBe(3);
|
|
445
|
+
});
|
|
446
|
+
});
|
package/src/nexus/discover.ts
CHANGED
package/src/nexus/index.ts
CHANGED
|
@@ -98,3 +98,17 @@ export {
|
|
|
98
98
|
type SharingStatus,
|
|
99
99
|
syncGitignore,
|
|
100
100
|
} from './sharing/index.js';
|
|
101
|
+
// Transfer - cross-project task transfer
|
|
102
|
+
export { executeTransfer, previewTransfer } from './transfer.js';
|
|
103
|
+
export type {
|
|
104
|
+
ImportFromPackageOptions,
|
|
105
|
+
ImportFromPackageResult,
|
|
106
|
+
TransferManifest,
|
|
107
|
+
TransferManifestEntry,
|
|
108
|
+
TransferMode,
|
|
109
|
+
TransferOnConflict,
|
|
110
|
+
TransferOnMissingDep,
|
|
111
|
+
TransferParams,
|
|
112
|
+
TransferResult,
|
|
113
|
+
TransferScope,
|
|
114
|
+
} from './transfer-types.js';
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types for cross-project task transfer via NEXUS.
|
|
3
|
+
*
|
|
4
|
+
* @task T046, T047
|
|
5
|
+
* @epic T4540
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Task } from '@cleocode/contracts';
|
|
9
|
+
|
|
10
|
+
/** Transfer mode: copy keeps source tasks, move archives them. */
|
|
11
|
+
export type TransferMode = 'copy' | 'move';
|
|
12
|
+
|
|
13
|
+
/** Transfer scope: single task or full subtree. */
|
|
14
|
+
export type TransferScope = 'single' | 'subtree';
|
|
15
|
+
|
|
16
|
+
/** Conflict resolution when target has tasks with duplicate titles. */
|
|
17
|
+
export type TransferOnConflict = 'duplicate' | 'rename' | 'skip' | 'fail';
|
|
18
|
+
|
|
19
|
+
/** How to handle missing dependencies in the target project. */
|
|
20
|
+
export type TransferOnMissingDep = 'strip' | 'fail';
|
|
21
|
+
|
|
22
|
+
/** Parameters for a cross-project transfer operation. */
|
|
23
|
+
export interface TransferParams {
|
|
24
|
+
/** Task IDs to transfer from the source project. */
|
|
25
|
+
taskIds: string[];
|
|
26
|
+
/** Source project name or hash. */
|
|
27
|
+
sourceProject: string;
|
|
28
|
+
/** Target project name or hash. */
|
|
29
|
+
targetProject: string;
|
|
30
|
+
/** Copy (default) keeps source tasks; move archives them. */
|
|
31
|
+
mode?: TransferMode;
|
|
32
|
+
/** Single transfers individual tasks; subtree transfers tasks + all descendants. */
|
|
33
|
+
scope?: TransferScope;
|
|
34
|
+
/** How to handle title conflicts in the target. */
|
|
35
|
+
onConflict?: TransferOnConflict;
|
|
36
|
+
/** How to handle missing deps in the target. */
|
|
37
|
+
onMissingDep?: TransferOnMissingDep;
|
|
38
|
+
/** Whether to add provenance notes to transferred tasks. */
|
|
39
|
+
provenance?: boolean;
|
|
40
|
+
/** Override parent ID in target project. */
|
|
41
|
+
targetParent?: string;
|
|
42
|
+
/** Whether to transfer brain observations linked to source tasks. */
|
|
43
|
+
transferBrain?: boolean;
|
|
44
|
+
/** Dry run: preview without writing. */
|
|
45
|
+
dryRun?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A single task entry in the transfer manifest. */
|
|
49
|
+
export interface TransferManifestEntry {
|
|
50
|
+
/** Original task ID in source project. */
|
|
51
|
+
sourceId: string;
|
|
52
|
+
/** New task ID in target project. */
|
|
53
|
+
targetId: string;
|
|
54
|
+
/** Task title. */
|
|
55
|
+
title: string;
|
|
56
|
+
/** Task type (task, epic, milestone, etc.). */
|
|
57
|
+
type: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Manifest describing what was (or would be) transferred. */
|
|
61
|
+
export interface TransferManifest {
|
|
62
|
+
/** Source project name. */
|
|
63
|
+
sourceProject: string;
|
|
64
|
+
/** Target project name. */
|
|
65
|
+
targetProject: string;
|
|
66
|
+
/** Transfer mode used. */
|
|
67
|
+
mode: TransferMode;
|
|
68
|
+
/** Transfer scope used. */
|
|
69
|
+
scope: TransferScope;
|
|
70
|
+
/** Tasks included in the transfer. */
|
|
71
|
+
entries: TransferManifestEntry[];
|
|
72
|
+
/** ID remap table: sourceId -> targetId. */
|
|
73
|
+
idRemap: Record<string, string>;
|
|
74
|
+
/** Number of brain observations transferred. */
|
|
75
|
+
brainObservationsTransferred: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Result of a transfer operation. */
|
|
79
|
+
export interface TransferResult {
|
|
80
|
+
/** Whether this was a dry run. */
|
|
81
|
+
dryRun: boolean;
|
|
82
|
+
/** Number of tasks transferred. */
|
|
83
|
+
transferred: number;
|
|
84
|
+
/** Number of tasks skipped (conflict resolution). */
|
|
85
|
+
skipped: number;
|
|
86
|
+
/** Number of source tasks archived (move mode only). */
|
|
87
|
+
archived: number;
|
|
88
|
+
/** Number of external links created. */
|
|
89
|
+
linksCreated: number;
|
|
90
|
+
/** Number of brain observations transferred. */
|
|
91
|
+
brainObservationsTransferred: number;
|
|
92
|
+
/** Transfer manifest with full details. */
|
|
93
|
+
manifest: TransferManifest;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Options passed to importFromPackage (extracted from importTasksPackage). */
|
|
97
|
+
export interface ImportFromPackageOptions {
|
|
98
|
+
/** Working directory for the target project. */
|
|
99
|
+
cwd?: string;
|
|
100
|
+
/** Dry run: preview without writing. */
|
|
101
|
+
dryRun?: boolean;
|
|
102
|
+
/** Parent task ID in target. */
|
|
103
|
+
parent?: string;
|
|
104
|
+
/** Phase override. */
|
|
105
|
+
phase?: string;
|
|
106
|
+
/** Label to add to imported tasks. */
|
|
107
|
+
addLabel?: string;
|
|
108
|
+
/** Whether to add provenance notes. */
|
|
109
|
+
provenance?: boolean;
|
|
110
|
+
/** Status to reset imported tasks to. */
|
|
111
|
+
resetStatus?: Task['status'];
|
|
112
|
+
/** Conflict resolution strategy. */
|
|
113
|
+
onConflict?: TransferOnConflict;
|
|
114
|
+
/** Missing dependency strategy. */
|
|
115
|
+
onMissingDep?: TransferOnMissingDep;
|
|
116
|
+
/** Force import (skip duplicate checks). */
|
|
117
|
+
force?: boolean;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Result from importFromPackage. */
|
|
121
|
+
export interface ImportFromPackageResult {
|
|
122
|
+
imported: number;
|
|
123
|
+
skipped: number;
|
|
124
|
+
idRemap: Record<string, string>;
|
|
125
|
+
dryRun?: boolean;
|
|
126
|
+
preview?: {
|
|
127
|
+
tasks: Array<{ id: string; title: string; type: string }>;
|
|
128
|
+
};
|
|
129
|
+
}
|