@fitlab-ai/agent-infra 0.4.5 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +18 -2
  2. package/README.zh-CN.md +18 -2
  3. package/bin/cli.js +19 -0
  4. package/lib/defaults.json +17 -0
  5. package/lib/init.js +1 -0
  6. package/lib/log.js +5 -10
  7. package/lib/merge.js +885 -0
  8. package/lib/sandbox/commands/create.js +1170 -0
  9. package/lib/sandbox/commands/enter.js +64 -0
  10. package/lib/sandbox/commands/ls.js +71 -0
  11. package/lib/sandbox/commands/rebuild.js +102 -0
  12. package/lib/sandbox/commands/rm.js +211 -0
  13. package/lib/sandbox/commands/vm.js +101 -0
  14. package/lib/sandbox/config.js +79 -0
  15. package/lib/sandbox/constants.js +113 -0
  16. package/lib/sandbox/dockerfile.js +95 -0
  17. package/lib/sandbox/engine.js +93 -0
  18. package/lib/sandbox/index.js +64 -0
  19. package/lib/sandbox/runtimes/ai-tools.dockerfile +26 -0
  20. package/lib/sandbox/runtimes/base.dockerfile +30 -0
  21. package/lib/sandbox/runtimes/java17.dockerfile +3 -0
  22. package/lib/sandbox/runtimes/java21.dockerfile +3 -0
  23. package/lib/sandbox/runtimes/node20.dockerfile +3 -0
  24. package/lib/sandbox/runtimes/node22.dockerfile +3 -0
  25. package/lib/sandbox/runtimes/python3.dockerfile +3 -0
  26. package/lib/sandbox/shell.js +48 -0
  27. package/lib/sandbox/task-resolver.js +35 -0
  28. package/lib/sandbox/tools.js +135 -0
  29. package/lib/update.js +16 -2
  30. package/package.json +5 -1
  31. package/templates/.agents/rules/pr-sync.md +110 -0
  32. package/templates/.agents/rules/pr-sync.zh-CN.md +110 -0
  33. package/templates/.agents/scripts/validate-artifact.js +117 -1
  34. package/templates/.agents/skills/archive-tasks/SKILL.md +6 -3
  35. package/templates/.agents/skills/archive-tasks/SKILL.zh-CN.md +6 -3
  36. package/templates/.agents/skills/archive-tasks/scripts/archive-tasks.sh +91 -8
  37. package/templates/.agents/skills/commit/SKILL.md +9 -1
  38. package/templates/.agents/skills/commit/SKILL.zh-CN.md +9 -1
  39. package/templates/.agents/skills/commit/config/verify.json +5 -1
  40. package/templates/.agents/skills/commit/reference/pr-summary-sync.md +21 -0
  41. package/templates/.agents/skills/commit/reference/pr-summary-sync.zh-CN.md +21 -0
  42. package/templates/.agents/skills/commit/reference/task-status-update.md +2 -0
  43. package/templates/.agents/skills/commit/reference/task-status-update.zh-CN.md +2 -0
  44. package/templates/.agents/skills/create-pr/SKILL.md +2 -1
  45. package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +2 -1
  46. package/templates/.agents/skills/create-pr/reference/comment-publish.md +7 -74
  47. package/templates/.agents/skills/create-pr/reference/comment-publish.zh-CN.md +6 -73
  48. package/templates/.agents/skills/create-task/SKILL.md +6 -0
  49. package/templates/.agents/skills/create-task/SKILL.zh-CN.md +6 -0
  50. package/templates/.agents/skills/create-task/config/verify.json +1 -0
  51. package/templates/.agents/skills/import-issue/SKILL.md +2 -0
  52. package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +2 -0
  53. package/templates/.agents/skills/import-issue/config/verify.json +1 -0
  54. package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +18 -1
  55. package/templates/.agents/templates/task.md +5 -4
  56. package/templates/.agents/templates/task.zh-CN.md +5 -4
package/lib/merge.js ADDED
@@ -0,0 +1,885 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { info, ok } from './log.js';
4
+
5
+ const TASK_ID_RE = /^TASK-\d{8}-\d{6}$/;
6
+ const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
7
+ const TITLE_RE = /^# (.+)$/m;
8
+ const DATE_FROM_PATH_RE = /(?:^|[/\\])(\d{4})[/\\](\d{2})[/\\](\d{2})(?:[/\\]|$)/;
9
+ const MUTABLE_SECTIONS = ['active', 'blocked', 'completed'];
10
+ const ALL_SECTIONS = [...MUTABLE_SECTIONS, 'archive'];
11
+ const SECTION_LABELS = {
12
+ active: 'Active',
13
+ blocked: 'Blocked',
14
+ completed: 'Completed',
15
+ archive: 'Archive'
16
+ };
17
+ const DIVIDER = '═'.repeat(55);
18
+
19
+ function extractField(content, fieldName) {
20
+ const match = content.match(FRONTMATTER_RE);
21
+ if (!match) {
22
+ return null;
23
+ }
24
+
25
+ const lines = match[1].split(/\r?\n/);
26
+ const prefix = `${fieldName}:`;
27
+
28
+ for (const line of lines) {
29
+ if (!line.startsWith(prefix)) {
30
+ continue;
31
+ }
32
+
33
+ const value = line.slice(prefix.length).trim().replace(/^['"]|['"]$/g, '');
34
+ return value || null;
35
+ }
36
+
37
+ return null;
38
+ }
39
+
40
+ function extractTitle(content) {
41
+ const withoutFrontmatter = content.replace(FRONTMATTER_RE, '');
42
+ const match = withoutFrontmatter.match(TITLE_RE);
43
+ if (!match) {
44
+ return null;
45
+ }
46
+
47
+ return match[1]
48
+ .trim()
49
+ .replace(/^任务:/, '')
50
+ .replace(/^Task:\s*/, '')
51
+ .replace(/\\/g, '\\\\')
52
+ .replace(/\|/g, '\\|') || null;
53
+ }
54
+
55
+ function normalizeTaskRecord(taskDir, taskFile, dateParts) {
56
+ const taskId = path.basename(taskDir);
57
+ const content = fs.readFileSync(taskFile, 'utf8');
58
+ const completedAt = extractField(content, 'completed_at');
59
+ const updatedAt = extractField(content, 'updated_at');
60
+ const taskDate = completedAt || updatedAt || `${dateParts.year}-${dateParts.month}-${dateParts.day}`;
61
+ const title = extractTitle(content) || taskId;
62
+ const type = extractField(content, 'type') || 'unknown';
63
+
64
+ return {
65
+ taskId,
66
+ taskDir,
67
+ relativePath: `${dateParts.year}/${dateParts.month}/${dateParts.day}/${taskId}/`,
68
+ year: dateParts.year,
69
+ month: dateParts.month,
70
+ day: dateParts.day,
71
+ title,
72
+ type,
73
+ completedAt: taskDate
74
+ };
75
+ }
76
+
77
+ function fallbackDateParts(taskDir, content) {
78
+ const pathMatch = taskDir.match(DATE_FROM_PATH_RE);
79
+ if (pathMatch) {
80
+ return {
81
+ year: pathMatch[1],
82
+ month: pathMatch[2],
83
+ day: pathMatch[3]
84
+ };
85
+ }
86
+
87
+ const completedAt = extractField(content, 'completed_at');
88
+ const updatedAt = extractField(content, 'updated_at');
89
+ const source = completedAt || updatedAt;
90
+ const dateMatch = source?.match(/^(\d{4})-(\d{2})-(\d{2})/);
91
+
92
+ if (dateMatch) {
93
+ return {
94
+ year: dateMatch[1],
95
+ month: dateMatch[2],
96
+ day: dateMatch[3]
97
+ };
98
+ }
99
+
100
+ return null;
101
+ }
102
+
103
+ function scanSourceTasks(sourceDir) {
104
+ const tasks = [];
105
+ const years = fs.existsSync(sourceDir) ? fs.readdirSync(sourceDir, { withFileTypes: true }) : [];
106
+
107
+ for (const yearEntry of years) {
108
+ if (!yearEntry.isDirectory() || !/^\d{4}$/.test(yearEntry.name)) {
109
+ continue;
110
+ }
111
+
112
+ const yearDir = path.join(sourceDir, yearEntry.name);
113
+ for (const monthEntry of fs.readdirSync(yearDir, { withFileTypes: true })) {
114
+ if (!monthEntry.isDirectory() || !/^\d{2}$/.test(monthEntry.name)) {
115
+ continue;
116
+ }
117
+
118
+ const monthDir = path.join(yearDir, monthEntry.name);
119
+ for (const dayEntry of fs.readdirSync(monthDir, { withFileTypes: true })) {
120
+ if (!dayEntry.isDirectory() || !/^\d{2}$/.test(dayEntry.name)) {
121
+ continue;
122
+ }
123
+
124
+ const dayDir = path.join(monthDir, dayEntry.name);
125
+ for (const taskEntry of fs.readdirSync(dayDir, { withFileTypes: true })) {
126
+ if (!taskEntry.isDirectory() || !TASK_ID_RE.test(taskEntry.name)) {
127
+ continue;
128
+ }
129
+
130
+ const taskDir = path.join(dayDir, taskEntry.name);
131
+ const taskFile = path.join(taskDir, 'task.md');
132
+ if (!fs.existsSync(taskFile)) {
133
+ continue;
134
+ }
135
+
136
+ tasks.push(normalizeTaskRecord(taskDir, taskFile, {
137
+ year: yearEntry.name,
138
+ month: monthEntry.name,
139
+ day: dayEntry.name
140
+ }));
141
+ }
142
+ }
143
+ }
144
+ }
145
+
146
+ if (tasks.length > 0) {
147
+ return tasks;
148
+ }
149
+
150
+ // Fall back to a deeper scan if the source layout is unusual but still contains archived tasks.
151
+ const stack = [sourceDir];
152
+ while (stack.length > 0) {
153
+ const currentDir = stack.pop();
154
+ if (!currentDir || !fs.existsSync(currentDir)) {
155
+ continue;
156
+ }
157
+
158
+ for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) {
159
+ const entryPath = path.join(currentDir, entry.name);
160
+ if (!entry.isDirectory()) {
161
+ continue;
162
+ }
163
+
164
+ if (TASK_ID_RE.test(entry.name)) {
165
+ const taskFile = path.join(entryPath, 'task.md');
166
+ if (!fs.existsSync(taskFile)) {
167
+ continue;
168
+ }
169
+
170
+ const content = fs.readFileSync(taskFile, 'utf8');
171
+ const dateParts = fallbackDateParts(entryPath, content);
172
+ if (!dateParts) {
173
+ continue;
174
+ }
175
+
176
+ tasks.push(normalizeTaskRecord(entryPath, taskFile, dateParts));
177
+ continue;
178
+ }
179
+
180
+ stack.push(entryPath);
181
+ }
182
+ }
183
+
184
+ return tasks.sort((left, right) => left.relativePath.localeCompare(right.relativePath));
185
+ }
186
+
187
+ function findTaskDirById(rootDir, taskId) {
188
+ if (!fs.existsSync(rootDir)) {
189
+ return null;
190
+ }
191
+
192
+ for (const yearEntry of fs.readdirSync(rootDir, { withFileTypes: true })) {
193
+ if (!yearEntry.isDirectory() || !/^\d{4}$/.test(yearEntry.name)) {
194
+ continue;
195
+ }
196
+
197
+ const yearDir = path.join(rootDir, yearEntry.name);
198
+ for (const monthEntry of fs.readdirSync(yearDir, { withFileTypes: true })) {
199
+ if (!monthEntry.isDirectory() || !/^\d{2}$/.test(monthEntry.name)) {
200
+ continue;
201
+ }
202
+
203
+ const monthDir = path.join(yearDir, monthEntry.name);
204
+ for (const dayEntry of fs.readdirSync(monthDir, { withFileTypes: true })) {
205
+ if (!dayEntry.isDirectory() || !/^\d{2}$/.test(dayEntry.name)) {
206
+ continue;
207
+ }
208
+
209
+ const candidate = path.join(monthDir, dayEntry.name, taskId);
210
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {
211
+ return candidate;
212
+ }
213
+ }
214
+ }
215
+ }
216
+
217
+ return null;
218
+ }
219
+
220
+ function taskExistsInArchive(archiveDir, taskId) {
221
+ return findTaskDirById(archiveDir, taskId);
222
+ }
223
+
224
+ function formatManifestHeader(generatedAt) {
225
+ return [
226
+ '# Archive Manifest',
227
+ '',
228
+ '> Auto-generated by archive-tasks. Do not edit manually.',
229
+ `> Last updated: ${generatedAt}`,
230
+ ''
231
+ ];
232
+ }
233
+
234
+ function collectArchiveEntries(archiveDir) {
235
+ const entries = [];
236
+ if (!fs.existsSync(archiveDir)) {
237
+ return entries;
238
+ }
239
+
240
+ for (const yearEntry of fs.readdirSync(archiveDir, { withFileTypes: true })) {
241
+ if (!yearEntry.isDirectory() || !/^\d{4}$/.test(yearEntry.name)) {
242
+ continue;
243
+ }
244
+
245
+ const yearDir = path.join(archiveDir, yearEntry.name);
246
+ for (const monthEntry of fs.readdirSync(yearDir, { withFileTypes: true })) {
247
+ if (!monthEntry.isDirectory() || !/^\d{2}$/.test(monthEntry.name)) {
248
+ continue;
249
+ }
250
+
251
+ const monthDir = path.join(yearDir, monthEntry.name);
252
+ for (const dayEntry of fs.readdirSync(monthDir, { withFileTypes: true })) {
253
+ if (!dayEntry.isDirectory() || !/^\d{2}$/.test(dayEntry.name)) {
254
+ continue;
255
+ }
256
+
257
+ const dayDir = path.join(monthDir, dayEntry.name);
258
+ for (const taskEntry of fs.readdirSync(dayDir, { withFileTypes: true })) {
259
+ if (!taskEntry.isDirectory() || !TASK_ID_RE.test(taskEntry.name)) {
260
+ continue;
261
+ }
262
+
263
+ const taskDir = path.join(dayDir, taskEntry.name);
264
+ const taskFile = path.join(taskDir, 'task.md');
265
+ const relativePath = `${yearEntry.name}/${monthEntry.name}/${dayEntry.name}/${taskEntry.name}/`;
266
+ let title = taskEntry.name;
267
+ let type = 'unknown';
268
+ let completedAt = `${yearEntry.name}-${monthEntry.name}-${dayEntry.name}`;
269
+
270
+ if (fs.existsSync(taskFile)) {
271
+ const content = fs.readFileSync(taskFile, 'utf8');
272
+ title = extractTitle(content) || title;
273
+ type = extractField(content, 'type') || type;
274
+ completedAt = extractField(content, 'completed_at') || completedAt;
275
+ }
276
+
277
+ entries.push({
278
+ year: yearEntry.name,
279
+ month: monthEntry.name,
280
+ completedAt,
281
+ taskId: taskEntry.name,
282
+ title,
283
+ type,
284
+ relativePath
285
+ });
286
+ }
287
+ }
288
+ }
289
+ }
290
+
291
+ return entries;
292
+ }
293
+
294
+ function rebuildManifests(archiveDir) {
295
+ fs.mkdirSync(archiveDir, { recursive: true });
296
+ const entries = collectArchiveEntries(archiveDir);
297
+ const generatedAt = new Date().toISOString().slice(0, 19).replace('T', ' ');
298
+
299
+ removeManifestFiles(archiveDir);
300
+
301
+ const monthGroups = new Map();
302
+ const yearCounts = new Map();
303
+ const monthCounts = new Map();
304
+
305
+ for (const entry of entries) {
306
+ const monthKey = `${entry.year}\t${entry.month}`;
307
+ if (!monthGroups.has(monthKey)) {
308
+ monthGroups.set(monthKey, []);
309
+ }
310
+ monthGroups.get(monthKey).push(entry);
311
+ yearCounts.set(entry.year, (yearCounts.get(entry.year) || 0) + 1);
312
+ monthCounts.set(monthKey, (monthCounts.get(monthKey) || 0) + 1);
313
+ }
314
+
315
+ for (const [monthKey, monthEntries] of [...monthGroups.entries()].sort()) {
316
+ const [year, month] = monthKey.split('\t');
317
+ const monthManifestPath = path.join(archiveDir, year, month, 'manifest.md');
318
+ fs.mkdirSync(path.dirname(monthManifestPath), { recursive: true });
319
+
320
+ const sortedEntries = [...monthEntries].sort((left, right) => {
321
+ const completedCompare = right.completedAt.localeCompare(left.completedAt);
322
+ if (completedCompare !== 0) {
323
+ return completedCompare;
324
+ }
325
+ return right.taskId.localeCompare(left.taskId);
326
+ });
327
+
328
+ const lines = [
329
+ ...formatManifestHeader(generatedAt),
330
+ '| Task ID | Title | Type | Completed | Path |',
331
+ '| --- | --- | --- | --- | --- |'
332
+ ];
333
+
334
+ for (const entry of sortedEntries.slice(0, 1000)) {
335
+ lines.push(
336
+ `| ${entry.taskId} | ${entry.title} | ${entry.type} | ${entry.completedAt} | ${entry.relativePath} |`
337
+ );
338
+ }
339
+
340
+ if (sortedEntries.length > 1000) {
341
+ lines.push('', `> Showing 1000 of ${sortedEntries.length} entries.`);
342
+ }
343
+
344
+ fs.writeFileSync(monthManifestPath, `${lines.join('\n')}\n`, 'utf8');
345
+ }
346
+
347
+ for (const year of [...yearCounts.keys()].sort().reverse()) {
348
+ const yearManifestPath = path.join(archiveDir, year, 'manifest.md');
349
+ fs.mkdirSync(path.dirname(yearManifestPath), { recursive: true });
350
+
351
+ const lines = [
352
+ ...formatManifestHeader(generatedAt),
353
+ '| Month | Tasks | Manifest |',
354
+ '| --- | --- | --- |'
355
+ ];
356
+
357
+ for (const month of [...monthGroups.keys()]
358
+ .filter((key) => key.startsWith(`${year}\t`))
359
+ .map((key) => key.split('\t')[1])
360
+ .sort()
361
+ .reverse()) {
362
+ lines.push(
363
+ `| ${month} | ${monthCounts.get(`${year}\t${month}`)} | [${month}/manifest.md](${month}/manifest.md) |`
364
+ );
365
+ }
366
+
367
+ fs.writeFileSync(yearManifestPath, `${lines.join('\n')}\n`, 'utf8');
368
+ }
369
+
370
+ const rootLines = [
371
+ ...formatManifestHeader(generatedAt),
372
+ '| Year | Tasks | Manifest |',
373
+ '| --- | --- | --- |'
374
+ ];
375
+
376
+ for (const year of [...yearCounts.keys()].sort().reverse()) {
377
+ rootLines.push(
378
+ `| ${year} | ${yearCounts.get(year)} | [${year}/manifest.md](${year}/manifest.md) |`
379
+ );
380
+ }
381
+
382
+ fs.writeFileSync(path.join(archiveDir, 'manifest.md'), `${rootLines.join('\n')}\n`, 'utf8');
383
+ }
384
+
385
+ function removeManifestFiles(rootDir) {
386
+ if (!fs.existsSync(rootDir)) {
387
+ return;
388
+ }
389
+
390
+ for (const entry of fs.readdirSync(rootDir, { withFileTypes: true })) {
391
+ const entryPath = path.join(rootDir, entry.name);
392
+ if (entry.isDirectory()) {
393
+ if (entry.name.startsWith('TASK-')) {
394
+ continue;
395
+ }
396
+ removeManifestFiles(entryPath);
397
+ continue;
398
+ }
399
+
400
+ if (entry.isFile() && entry.name === 'manifest.md') {
401
+ fs.rmSync(entryPath, { force: true });
402
+ }
403
+ }
404
+ }
405
+
406
+ function formatTimestamp(date) {
407
+ return [
408
+ date.getFullYear(),
409
+ String(date.getMonth() + 1).padStart(2, '0'),
410
+ String(date.getDate()).padStart(2, '0')
411
+ ].join('-') + ' ' + [
412
+ String(date.getHours()).padStart(2, '0'),
413
+ String(date.getMinutes()).padStart(2, '0'),
414
+ String(date.getSeconds()).padStart(2, '0')
415
+ ].join(':');
416
+ }
417
+
418
+ function formatBackupTimestamp(date) {
419
+ return [
420
+ date.getFullYear(),
421
+ String(date.getMonth() + 1).padStart(2, '0'),
422
+ String(date.getDate()).padStart(2, '0')
423
+ ].join('') + `-${String(date.getHours()).padStart(2, '0')}${String(date.getMinutes()).padStart(2, '0')}${String(date.getSeconds()).padStart(2, '0')}`;
424
+ }
425
+
426
+ function toPosixPath(relativePath) {
427
+ return relativePath.split(path.sep).join('/');
428
+ }
429
+
430
+ function getLatestFileMtime(taskDir) {
431
+ let latestMs = null;
432
+ const stack = [taskDir];
433
+
434
+ while (stack.length > 0) {
435
+ const currentDir = stack.pop();
436
+ if (!currentDir || !fs.existsSync(currentDir)) {
437
+ continue;
438
+ }
439
+
440
+ for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) {
441
+ const entryPath = path.join(currentDir, entry.name);
442
+ if (entry.isDirectory()) {
443
+ stack.push(entryPath);
444
+ continue;
445
+ }
446
+
447
+ const { mtimeMs } = fs.statSync(entryPath);
448
+ latestMs = latestMs === null ? mtimeMs : Math.max(latestMs, mtimeMs);
449
+ }
450
+ }
451
+
452
+ return latestMs;
453
+ }
454
+
455
+ function getTaskTimestamp(taskDir) {
456
+ const taskFile = path.join(taskDir, 'task.md');
457
+
458
+ if (fs.existsSync(taskFile)) {
459
+ const content = fs.readFileSync(taskFile, 'utf8');
460
+ const updatedAt = extractField(content, 'updated_at');
461
+ if (updatedAt) {
462
+ return { value: updatedAt, source: 'frontmatter' };
463
+ }
464
+
465
+ const taskFileStat = fs.statSync(taskFile);
466
+ return {
467
+ value: formatTimestamp(taskFileStat.mtime),
468
+ source: 'task-mtime'
469
+ };
470
+ }
471
+
472
+ const latestMs = getLatestFileMtime(taskDir);
473
+ if (latestMs !== null) {
474
+ return {
475
+ value: formatTimestamp(new Date(latestMs)),
476
+ source: 'dir-mtime'
477
+ };
478
+ }
479
+
480
+ const dirStat = fs.statSync(taskDir);
481
+ return {
482
+ value: formatTimestamp(dirStat.mtime),
483
+ source: 'dir-mtime'
484
+ };
485
+ }
486
+
487
+ function compareTimestamps(left, right) {
488
+ return left.value.localeCompare(right.value);
489
+ }
490
+
491
+ function scanWorkspaceSection(rootDir, sectionName) {
492
+ const sectionDir = path.join(rootDir, sectionName);
493
+ if (!fs.existsSync(sectionDir) || !fs.statSync(sectionDir).isDirectory()) {
494
+ return [];
495
+ }
496
+
497
+ const records = [];
498
+ for (const entry of fs.readdirSync(sectionDir, { withFileTypes: true })) {
499
+ if (!entry.isDirectory() || !TASK_ID_RE.test(entry.name)) {
500
+ continue;
501
+ }
502
+
503
+ const taskDir = path.join(sectionDir, entry.name);
504
+ const taskFile = path.join(taskDir, 'task.md');
505
+ if (!fs.existsSync(taskFile)) {
506
+ continue;
507
+ }
508
+
509
+ records.push({
510
+ taskId: entry.name,
511
+ section: sectionName,
512
+ taskDir,
513
+ timestamp: getTaskTimestamp(taskDir)
514
+ });
515
+ }
516
+
517
+ return records.sort((left, right) => left.taskId.localeCompare(right.taskId));
518
+ }
519
+
520
+ function buildWorkspaceIndex(workspaceDir) {
521
+ const index = new Map();
522
+
523
+ for (const section of MUTABLE_SECTIONS) {
524
+ for (const record of scanWorkspaceSection(workspaceDir, section)) {
525
+ index.set(record.taskId, record);
526
+ }
527
+ }
528
+
529
+ return index;
530
+ }
531
+
532
+ function backupTaskDir(backupRoot, section, taskDir, taskId) {
533
+ const backupDir = path.join(backupRoot, section, taskId);
534
+ fs.mkdirSync(path.dirname(backupDir), { recursive: true });
535
+ fs.cpSync(taskDir, backupDir, { recursive: true });
536
+ return backupDir;
537
+ }
538
+
539
+ function copyTaskToSection(sourceTask, workspaceDir) {
540
+ const destinationDir = path.join(workspaceDir, sourceTask.section, sourceTask.taskId);
541
+ fs.mkdirSync(path.dirname(destinationDir), { recursive: true });
542
+ fs.cpSync(sourceTask.taskDir, destinationDir, { recursive: true });
543
+ return destinationDir;
544
+ }
545
+
546
+ function detectSourceMode(sourcePath) {
547
+ for (const section of ALL_SECTIONS) {
548
+ const sectionDir = path.join(sourcePath, section);
549
+ if (fs.existsSync(sectionDir) && fs.statSync(sectionDir).isDirectory()) {
550
+ return 'workspace';
551
+ }
552
+ }
553
+
554
+ return 'legacy-archive';
555
+ }
556
+
557
+ function createReport(sourcePath, backupRoot) {
558
+ return {
559
+ sourcePath,
560
+ backupRoot,
561
+ sections: {
562
+ active: { copied: [], updated: [], moved: [], skipped: [] },
563
+ blocked: { copied: [], updated: [], moved: [], skipped: [] },
564
+ completed: { copied: [], updated: [], moved: [], skipped: [] },
565
+ archive: { copied: [], skipped: [] }
566
+ },
567
+ details: [],
568
+ backupCount: 0
569
+ };
570
+ }
571
+
572
+ function recordMutable(report, reportSection, action, entry) {
573
+ report.sections[reportSection][action].push(entry);
574
+ report.details.push(entry);
575
+ }
576
+
577
+ function recordArchive(report, action, entry) {
578
+ report.sections.archive[action].push(entry);
579
+ report.details.push(entry);
580
+ }
581
+
582
+ function mergeMutableSections({ sourceWorkspace, localWorkspace, backupRoot, report }) {
583
+ const localIndex = buildWorkspaceIndex(localWorkspace);
584
+
585
+ for (const sourceSection of MUTABLE_SECTIONS) {
586
+ const sourceTasks = scanWorkspaceSection(sourceWorkspace, sourceSection);
587
+
588
+ for (const sourceTask of sourceTasks) {
589
+ const localMatch = localIndex.get(sourceTask.taskId) || null;
590
+
591
+ if (!localMatch) {
592
+ const destinationDir = copyTaskToSection(sourceTask, localWorkspace);
593
+ localIndex.set(sourceTask.taskId, {
594
+ taskId: sourceTask.taskId,
595
+ section: sourceTask.section,
596
+ taskDir: destinationDir,
597
+ timestamp: getTaskTimestamp(destinationDir)
598
+ });
599
+ recordMutable(report, sourceTask.section, 'copied', {
600
+ action: 'copied',
601
+ symbol: '✓',
602
+ taskId: sourceTask.taskId,
603
+ section: sourceTask.section,
604
+ detail: 'copied'
605
+ });
606
+ continue;
607
+ }
608
+
609
+ const comparison = compareTimestamps(sourceTask.timestamp, localMatch.timestamp);
610
+ if (comparison > 0) {
611
+ backupTaskDir(backupRoot, localMatch.section, localMatch.taskDir, localMatch.taskId);
612
+ report.backupCount += 1;
613
+ fs.rmSync(localMatch.taskDir, { recursive: true, force: true });
614
+
615
+ const destinationDir = copyTaskToSection(sourceTask, localWorkspace);
616
+ localIndex.set(sourceTask.taskId, {
617
+ taskId: sourceTask.taskId,
618
+ section: sourceTask.section,
619
+ taskDir: destinationDir,
620
+ timestamp: getTaskTimestamp(destinationDir)
621
+ });
622
+
623
+ if (localMatch.section === sourceTask.section) {
624
+ recordMutable(report, sourceTask.section, 'updated', {
625
+ action: 'updated',
626
+ symbol: '↑',
627
+ taskId: sourceTask.taskId,
628
+ section: sourceTask.section,
629
+ detail: `updated (source newer: ${sourceTask.timestamp.value} > ${localMatch.timestamp.value})`
630
+ });
631
+ } else {
632
+ recordMutable(report, localMatch.section, 'moved', {
633
+ action: 'moved',
634
+ symbol: '⇄',
635
+ taskId: sourceTask.taskId,
636
+ fromSection: localMatch.section,
637
+ toSection: sourceTask.section,
638
+ detail: `moved (source newer: ${sourceTask.timestamp.value} > ${localMatch.timestamp.value})`
639
+ });
640
+ }
641
+
642
+ continue;
643
+ }
644
+
645
+ if (comparison < 0) {
646
+ recordMutable(report, localMatch.section, 'skipped', {
647
+ action: 'skipped',
648
+ symbol: '⊘',
649
+ taskId: sourceTask.taskId,
650
+ section: localMatch.section,
651
+ detail: `skipped (local newer: ${localMatch.timestamp.value} > ${sourceTask.timestamp.value})`
652
+ });
653
+ continue;
654
+ }
655
+
656
+ recordMutable(report, localMatch.section, 'skipped', {
657
+ action: 'skipped',
658
+ symbol: '⊘',
659
+ taskId: sourceTask.taskId,
660
+ section: localMatch.section,
661
+ detail: `skipped (same timestamp: ${sourceTask.timestamp.value})`
662
+ });
663
+ }
664
+ }
665
+ }
666
+
667
+ function mergeArchiveSection(sourceArchive, localArchive, report) {
668
+ const sourceTasks = scanSourceTasks(sourceArchive);
669
+
670
+ for (const task of sourceTasks) {
671
+ const existingTaskDir = taskExistsInArchive(localArchive, task.taskId);
672
+ if (existingTaskDir) {
673
+ recordArchive(report, 'skipped', {
674
+ action: 'skipped',
675
+ symbol: '⊘',
676
+ taskId: task.taskId,
677
+ section: 'archive',
678
+ relativePath: `${toPosixPath(path.relative(localArchive, existingTaskDir))}/`,
679
+ detail: `skipped (already exists at ${toPosixPath(path.relative(localArchive, existingTaskDir))}/)`
680
+ });
681
+ continue;
682
+ }
683
+
684
+ const destinationDir = path.join(localArchive, task.relativePath);
685
+ fs.mkdirSync(path.dirname(destinationDir), { recursive: true });
686
+ fs.cpSync(task.taskDir, destinationDir, { recursive: true });
687
+ recordArchive(report, 'copied', {
688
+ action: 'copied',
689
+ symbol: '✓',
690
+ taskId: task.taskId,
691
+ section: 'archive',
692
+ relativePath: task.relativePath,
693
+ detail: 'copied'
694
+ });
695
+ }
696
+
697
+ return sourceTasks.length;
698
+ }
699
+
700
+ function printLegacyArchiveMessages(report, sourcePath) {
701
+ const merged = report.sections.archive.copied;
702
+ const skipped = report.sections.archive.skipped;
703
+
704
+ if (merged.length === 0 && skipped.length === 0) {
705
+ info(`No archived tasks found in ${sourcePath}`);
706
+ }
707
+
708
+ for (const task of merged) {
709
+ ok(`Merged ${task.taskId} -> ${task.relativePath}`);
710
+ }
711
+
712
+ for (const task of skipped) {
713
+ info(`Skipped ${task.taskId} (already exists at ${task.relativePath})`);
714
+ }
715
+
716
+ process.stdout.write('\n');
717
+ info('Merge summary');
718
+ info(`- Merged: ${merged.length}`);
719
+ info(`- Skipped: ${skipped.length}`);
720
+ process.stdout.write('\n');
721
+ }
722
+
723
+ function printSection(lines, name, counts) {
724
+ const title = `${SECTION_LABELS[name].padEnd(9, ' ')} (.agents/workspace/${name}/):`;
725
+ lines.push(title);
726
+
727
+ const entries = [
728
+ ['copied', '✓ Copied '],
729
+ ['updated', '↑ Updated '],
730
+ ['moved', '⇄ Moved '],
731
+ ['skipped', '⊘ Skipped ']
732
+ ].filter(([key]) => Array.isArray(counts[key]));
733
+
734
+ const nonZeroEntries = entries.filter(([key]) => counts[key].length > 0);
735
+ if (nonZeroEntries.length === 0) {
736
+ lines.push(' (no changes)', '');
737
+ return;
738
+ }
739
+
740
+ for (const [key, label] of nonZeroEntries) {
741
+ lines.push(` ${label}: ${counts[key].length}`);
742
+ }
743
+
744
+ lines.push('');
745
+ }
746
+
747
+ function printArchiveSection(lines, counts) {
748
+ const title = `${SECTION_LABELS.archive.padEnd(9, ' ')} (.agents/workspace/archive/):`;
749
+ lines.push(title);
750
+
751
+ if (counts.copied.length === 0 && counts.skipped.length === 0) {
752
+ lines.push(' (no changes)', '');
753
+ return;
754
+ }
755
+
756
+ if (counts.copied.length > 0) {
757
+ lines.push(` ✓ Copied : ${counts.copied.length}`);
758
+ }
759
+ if (counts.skipped.length > 0) {
760
+ lines.push(` ⊘ Skipped : ${counts.skipped.length}`);
761
+ }
762
+ lines.push('');
763
+ }
764
+
765
+ function renderDetail(entry) {
766
+ if (entry.action === 'moved') {
767
+ return ` ${entry.symbol} ${entry.taskId} ${entry.fromSection}→${entry.toSection} ${entry.detail}`;
768
+ }
769
+
770
+ const label = entry.section.padEnd(9, ' ');
771
+ return ` ${entry.symbol} ${entry.taskId} ${label} ${entry.detail}`;
772
+ }
773
+
774
+ function printReport(report) {
775
+ const mutableTotals = MUTABLE_SECTIONS.reduce((acc, section) => {
776
+ acc.copied += report.sections[section].copied.length;
777
+ acc.updated += report.sections[section].updated.length;
778
+ acc.moved += report.sections[section].moved.length;
779
+ acc.skipped += report.sections[section].skipped.length;
780
+ return acc;
781
+ }, { copied: 0, updated: 0, moved: 0, skipped: 0 });
782
+
783
+ const archiveTotals = {
784
+ copied: report.sections.archive.copied.length,
785
+ skipped: report.sections.archive.skipped.length
786
+ };
787
+
788
+ const lines = [
789
+ 'Merge summary',
790
+ DIVIDER,
791
+ `Source: ${report.sourcePath}`,
792
+ `Backup: ${report.backupRoot}`,
793
+ ''
794
+ ];
795
+
796
+ for (const section of MUTABLE_SECTIONS) {
797
+ printSection(lines, section, report.sections[section]);
798
+ }
799
+ printArchiveSection(lines, report.sections.archive);
800
+
801
+ lines.push(
802
+ DIVIDER,
803
+ `Totals: ${mutableTotals.copied + archiveTotals.copied} copied, ${mutableTotals.updated} updated, ${mutableTotals.moved} moved, ${mutableTotals.skipped + archiveTotals.skipped} skipped`,
804
+ `Backup contains ${report.backupCount} task(s); review and remove when verified.`,
805
+ '',
806
+ 'Detailed log:'
807
+ );
808
+
809
+ if (report.details.length === 0) {
810
+ lines.push(' (none)');
811
+ } else {
812
+ for (const detail of report.details) {
813
+ lines.push(renderDetail(detail));
814
+ }
815
+ }
816
+
817
+ process.stdout.write(`${lines.join('\n')}\n`);
818
+ }
819
+
820
+ async function cmdMerge(args) {
821
+ const sourcePath = args[0];
822
+ if (!sourcePath) {
823
+ throw new Error('Usage: agent-infra merge <source-path>');
824
+ }
825
+
826
+ const resolvedSource = path.resolve(sourcePath);
827
+ if (!fs.existsSync(resolvedSource)) {
828
+ throw new Error(`Source path does not exist: ${sourcePath}`);
829
+ }
830
+
831
+ if (!fs.statSync(resolvedSource).isDirectory()) {
832
+ throw new Error(`Source path is not a directory: ${sourcePath}`);
833
+ }
834
+
835
+ const workspaceDir = path.join(process.cwd(), '.agents', 'workspace');
836
+ const archiveDir = path.join(workspaceDir, 'archive');
837
+ const backupStamp = formatBackupTimestamp(new Date());
838
+ const backupRootRelative = `.agents/workspace/.merge-backup/${backupStamp}/`;
839
+ const backupRoot = path.join(workspaceDir, '.merge-backup', backupStamp);
840
+ const report = createReport(resolvedSource, backupRootRelative);
841
+ const mode = detectSourceMode(resolvedSource);
842
+
843
+ for (const section of ALL_SECTIONS) {
844
+ fs.mkdirSync(path.join(workspaceDir, section), { recursive: true });
845
+ }
846
+
847
+ if (mode === 'legacy-archive') {
848
+ info('Detected legacy archive source; treating the input as archive-only for backward compatibility.');
849
+ mergeArchiveSection(resolvedSource, archiveDir, report);
850
+ } else {
851
+ mergeMutableSections({
852
+ sourceWorkspace: resolvedSource,
853
+ localWorkspace: workspaceDir,
854
+ backupRoot,
855
+ report
856
+ });
857
+
858
+ const sourceArchive = path.join(resolvedSource, 'archive');
859
+ if (fs.existsSync(sourceArchive) && fs.statSync(sourceArchive).isDirectory()) {
860
+ mergeArchiveSection(sourceArchive, archiveDir, report);
861
+ }
862
+ }
863
+
864
+ rebuildManifests(archiveDir);
865
+
866
+ if (mode === 'legacy-archive') {
867
+ printLegacyArchiveMessages(report, sourcePath);
868
+ }
869
+
870
+ printReport(report);
871
+ }
872
+
873
+ export {
874
+ cmdMerge,
875
+ compareTimestamps,
876
+ detectSourceMode,
877
+ extractField,
878
+ extractTitle,
879
+ formatBackupTimestamp,
880
+ getTaskTimestamp,
881
+ rebuildManifests,
882
+ scanSourceTasks,
883
+ scanWorkspaceSection,
884
+ taskExistsInArchive
885
+ };