@fitlab-ai/agent-infra 0.5.0 → 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 (27) hide show
  1. package/README.md +4 -2
  2. package/README.zh-CN.md +4 -2
  3. package/bin/cli.js +1 -1
  4. package/lib/merge.js +442 -22
  5. package/lib/sandbox/commands/create.js +190 -67
  6. package/lib/sandbox/commands/enter.js +36 -3
  7. package/lib/sandbox/commands/ls.js +3 -2
  8. package/lib/sandbox/commands/rm.js +2 -2
  9. package/lib/sandbox/config.js +1 -1
  10. package/lib/sandbox/runtimes/base.dockerfile +1 -1
  11. package/lib/sandbox/tools.js +9 -5
  12. package/package.json +1 -1
  13. package/templates/.agents/rules/pr-sync.md +110 -0
  14. package/templates/.agents/rules/pr-sync.zh-CN.md +110 -0
  15. package/templates/.agents/scripts/validate-artifact.js +77 -1
  16. package/templates/.agents/skills/commit/SKILL.md +9 -1
  17. package/templates/.agents/skills/commit/SKILL.zh-CN.md +9 -1
  18. package/templates/.agents/skills/commit/config/verify.json +5 -1
  19. package/templates/.agents/skills/commit/reference/pr-summary-sync.md +21 -0
  20. package/templates/.agents/skills/commit/reference/pr-summary-sync.zh-CN.md +21 -0
  21. package/templates/.agents/skills/commit/reference/task-status-update.md +2 -0
  22. package/templates/.agents/skills/commit/reference/task-status-update.zh-CN.md +2 -0
  23. package/templates/.agents/skills/create-pr/SKILL.md +2 -1
  24. package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +2 -1
  25. package/templates/.agents/skills/create-pr/reference/comment-publish.md +7 -74
  26. package/templates/.agents/skills/create-pr/reference/comment-publish.zh-CN.md +6 -73
  27. package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +1 -1
package/README.md CHANGED
@@ -181,10 +181,12 @@ This detects the packaged template version and renders all managed files. The sa
181
181
 
182
182
  ### Sandbox aliases and GitHub CLI
183
183
 
184
- `ai sandbox create` now bootstraps the host-side aliases file at `~/.ai-sandbox-aliases` on first run. The generated file includes ready-to-edit yolo shortcuts for Claude, Codex, Gemini CLI, and OpenCode, and every sandbox syncs that file into `/home/devuser/.bash_aliases`.
184
+ `ai sandbox create` now bootstraps the host-side aliases file at `~/.agent-infra/aliases/sandbox.sh` on first run. The generated file includes ready-to-edit yolo shortcuts for Claude, Codex, Gemini CLI, and OpenCode, and every sandbox syncs that file into `/home/devuser/.bash_aliases`.
185
185
 
186
186
  The sandbox image also preinstalls `gh`. When `gh auth token` succeeds on the host, `ai sandbox create` injects the token into the container as `GH_TOKEN`, so `gh` commands work inside the sandbox without extra setup.
187
187
 
188
+ `ai sandbox exec` also forwards a small terminal-detection whitelist (`TERM_PROGRAM`, `TERM_PROGRAM_VERSION`, `LC_TERMINAL`, `LC_TERMINAL_VERSION`) into the container. This keeps interactive TUIs aligned with the host terminal for behaviors such as Claude Code's Shift+Enter newline support, without passing through the full host environment.
189
+
188
190
  <a id="architecture-overview"></a>
189
191
 
190
192
  ## Architecture Overview
@@ -394,7 +396,7 @@ The generated `.agents/.airc.json` file is the central contract between the boot
394
396
  "project": "my-project",
395
397
  "org": "my-org",
396
398
  "language": "en",
397
- "templateVersion": "v0.5.0",
399
+ "templateVersion": "v0.5.1",
398
400
  "files": {
399
401
  "managed": [
400
402
  ".agents/workspace/README.md",
package/README.zh-CN.md CHANGED
@@ -181,10 +181,12 @@ CLI 会收集项目元数据,向所有支持的 AI TUI 安装 `update-agent-in
181
181
 
182
182
  ### 沙箱 aliases 与 GitHub CLI
183
183
 
184
- `ai sandbox create` 在首次运行时会自动生成宿主机侧的 `~/.ai-sandbox-aliases`。该文件内置了 Claude、Codex、Gemini CLI 和 OpenCode 的 yolo 快捷命令模板,你可以直接修改;每次创建沙箱时,这个文件都会同步到容器内的 `/home/devuser/.bash_aliases`。
184
+ `ai sandbox create` 在首次运行时会自动生成宿主机侧的 `~/.agent-infra/aliases/sandbox.sh`。该文件内置了 Claude、Codex、Gemini CLI 和 OpenCode 的 yolo 快捷命令模板,你可以直接修改;每次创建沙箱时,这个文件都会同步到容器内的 `/home/devuser/.bash_aliases`。
185
185
 
186
186
  沙箱镜像也会预装 `gh`。如果宿主机上的 `gh auth token` 能成功返回 token,`ai sandbox create` 会把它以 `GH_TOKEN` 环境变量注入容器,让你在沙箱里直接使用 `gh`,无需额外登录配置。
187
187
 
188
+ `ai sandbox exec` 也会向容器透传一小组终端检测白名单变量(`TERM_PROGRAM`、`TERM_PROGRAM_VERSION`、`LC_TERMINAL`、`LC_TERMINAL_VERSION`)。这样可以让交互式 TUI 保持与宿主终端一致的行为,例如 Claude Code 的 `Shift+Enter` 换行支持,同时避免把整个宿主环境灌入容器。
189
+
188
190
  <a id="architecture-overview"></a>
189
191
 
190
192
  ## 架构概览
@@ -394,7 +396,7 @@ import-issue #42 从 GitHub Issue 导入任务
394
396
  "project": "my-project",
395
397
  "org": "my-org",
396
398
  "language": "en",
397
- "templateVersion": "v0.5.0",
399
+ "templateVersion": "v0.5.1",
398
400
  "files": {
399
401
  "managed": [
400
402
  ".agents/workspace/README.md",
package/bin/cli.js CHANGED
@@ -14,7 +14,7 @@ const USAGE = `agent-infra - bootstrap AI collaboration infrastructure
14
14
 
15
15
  Usage:
16
16
  agent-infra init Initialize a new project with update-agent-infra seed command
17
- agent-infra merge Merge archived tasks from another archive directory
17
+ agent-infra merge Merge tasks from another workspace directory (active/blocked/completed/archive)
18
18
  agent-infra update Update seed files and sync file registry for an existing project
19
19
  agent-infra sandbox Manage Docker-based AI sandboxes
20
20
  agent-infra version Show version
package/lib/merge.js CHANGED
@@ -6,6 +6,15 @@ const TASK_ID_RE = /^TASK-\d{8}-\d{6}$/;
6
6
  const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
7
7
  const TITLE_RE = /^# (.+)$/m;
8
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);
9
18
 
10
19
  function extractField(content, fieldName) {
11
20
  const match = content.match(FRONTMATTER_RE);
@@ -394,50 +403,305 @@ function removeManifestFiles(rootDir) {
394
403
  }
395
404
  }
396
405
 
397
- async function cmdMerge(args) {
398
- const sourcePath = args[0];
399
- if (!sourcePath) {
400
- throw new Error('Usage: agent-infra merge <source-path>');
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
+ }
401
450
  }
402
451
 
403
- const resolvedSource = path.resolve(sourcePath);
404
- if (!fs.existsSync(resolvedSource)) {
405
- throw new Error(`Source path does not exist: ${sourcePath}`);
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
+ };
406
470
  }
407
471
 
408
- if (!fs.statSync(resolvedSource).isDirectory()) {
409
- throw new Error(`Source path is not a directory: ${sourcePath}`);
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 [];
410
495
  }
411
496
 
412
- const archiveDir = path.join(process.cwd(), '.agents', 'workspace', 'archive');
413
- const sourceTasks = scanSourceTasks(resolvedSource);
414
- const merged = [];
415
- const skipped = [];
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
+ }
416
502
 
417
- fs.mkdirSync(archiveDir, { recursive: true });
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);
418
669
 
419
670
  for (const task of sourceTasks) {
420
- const existingTaskDir = taskExistsInArchive(archiveDir, task.taskId);
671
+ const existingTaskDir = taskExistsInArchive(localArchive, task.taskId);
421
672
  if (existingTaskDir) {
422
- skipped.push({
673
+ recordArchive(report, 'skipped', {
674
+ action: 'skipped',
675
+ symbol: '⊘',
423
676
  taskId: task.taskId,
424
- relativePath: path.relative(archiveDir, existingTaskDir).split(path.sep).join('/') + '/'
677
+ section: 'archive',
678
+ relativePath: `${toPosixPath(path.relative(localArchive, existingTaskDir))}/`,
679
+ detail: `skipped (already exists at ${toPosixPath(path.relative(localArchive, existingTaskDir))}/)`
425
680
  });
426
681
  continue;
427
682
  }
428
683
 
429
- const destinationDir = path.join(archiveDir, task.relativePath);
684
+ const destinationDir = path.join(localArchive, task.relativePath);
430
685
  fs.mkdirSync(path.dirname(destinationDir), { recursive: true });
431
686
  fs.cpSync(task.taskDir, destinationDir, { recursive: true });
432
- merged.push({
687
+ recordArchive(report, 'copied', {
688
+ action: 'copied',
689
+ symbol: '✓',
433
690
  taskId: task.taskId,
434
- relativePath: task.relativePath
691
+ section: 'archive',
692
+ relativePath: task.relativePath,
693
+ detail: 'copied'
435
694
  });
436
695
  }
437
696
 
438
- rebuildManifests(archiveDir);
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;
439
703
 
440
- if (sourceTasks.length === 0) {
704
+ if (merged.length === 0 && skipped.length === 0) {
441
705
  info(`No archived tasks found in ${sourcePath}`);
442
706
  }
443
707
 
@@ -453,13 +717,169 @@ async function cmdMerge(args) {
453
717
  info('Merge summary');
454
718
  info(`- Merged: ${merged.length}`);
455
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);
456
871
  }
457
872
 
458
873
  export {
459
874
  cmdMerge,
875
+ compareTimestamps,
876
+ detectSourceMode,
460
877
  extractField,
461
878
  extractTitle,
879
+ formatBackupTimestamp,
880
+ getTaskTimestamp,
462
881
  rebuildManifests,
463
882
  scanSourceTasks,
883
+ scanWorkspaceSection,
464
884
  taskExistsInArchive
465
885
  };