cursor-guard 4.5.1 → 4.5.5

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/ROADMAP.md CHANGED
@@ -3,8 +3,8 @@
3
3
  > 本文档描述 cursor-guard 从 V2 到 V7 的长期演进方向。
4
4
  > 每一代向下兼容,低版本功能永远不废弃。
5
5
  >
6
- > **当前版本**:`V4.5.1`(V4 最终版)
7
- > **文档状态**:`V2` ~ `V4.5.1` 已完成交付(含 V5 intent/audit 基础),`V5` 主体规划中
6
+ > **当前版本**:`V4.5.5`(V4 最终版)
7
+ > **文档状态**:`V2` ~ `V4.5.5` 已完成交付(含 V5 intent/audit 基础),`V5` 主体规划中
8
8
 
9
9
  ## 阅读导航
10
10
 
@@ -459,6 +459,9 @@ V4 经过 4 轮系统性代码审查,修复了以下关键问题:
459
459
  | V4.4.0 | **V4 收官版**:首次快照 summary(无 parent 时生成 Added N: ...);doctor 新增 Git retention 警告(>500 commits + disabled)和 Backup integrity 校验(`cat-file -t` tree 可达性);`cursor-guard-init` 升级检测(已有配置提示) | ✅ |
460
460
  | V4.4.1 | **安全硬化版(5 项审计修复 + UX 优化)**:见下方详细说明 | ✅ |
461
461
  | V4.5.0 | **V4 最终版(异常检测修复 + Dashboard 全面升级)**:见下方详细说明 | ✅ 收官 |
462
+ | V4.5.2 | **告警结构化文件列表**:见下方详细说明 | ✅ |
463
+ | V4.5.3 | **告警历史 UX 优化 + 备份结构化文件表格**:见下方详细说明 | ✅ |
464
+ | V4.5.4 | **Shadow 硬链接增量优化 + always_watch 强保护模式**:见下方详细说明 | ✅ |
462
465
 
463
466
  #### V4.4.1 详细内容
464
467
 
@@ -512,6 +515,134 @@ V4 经过 4 轮系统性代码审查,修复了以下关键问题:
512
515
 
513
516
  > **注**:V4.2 的 Web 仪表盘最初在 V4.0 规划中标记为"不做",但用户需求明确后实施。事实证明只读仪表盘投入产出比合理,且不违反安全原则。
514
517
 
518
+ #### V4.5.2 详细内容
519
+
520
+ **告警结构化文件列表**:
521
+
522
+ | 层 | 改动 | 说明 |
523
+ |----|------|------|
524
+ | Core | `snapshot.js` 返回 `changedFiles` 数组 | 每项包含 `{ path, action, added, deleted }`,数据来源 `diff-tree --numstat`,按变化量降序排列 |
525
+ | Core | `auto-backup.js` 透传 `changedFiles` | `createGitSnapshot` → `recordChange(tracker, count, changedFiles)` |
526
+ | Core | `anomaly.js` alert 携带 `files` 字段 | 窗口内多次事件的文件按路径去重合并,最多保留 50 条,`saveAlert` 持久化到磁盘 |
527
+ | Dashboard | 告警卡片可展开文件详情表格 | 点击"展开文件详情"显示排序表格(文件路径 / 操作类型 / 变化量),操作类型用彩色 badge 区分(修改=蓝 / 新增=绿 / 删除=红 / 重命名=紫) |
528
+ | Dashboard | i18n 补全 | 新增 8 个双语 key(showFiles / hideFiles / col.file / col.action / col.changes / action.*) |
529
+
530
+ > 22 个文件被删除和 22 个文件被新增的风险完全不同——结构化文件列表让用户一眼判断严重程度。
531
+
532
+ #### V4.5.3 详细内容
533
+
534
+ **告警历史 UX 修复**:
535
+
536
+ | 问题 | 修复 | 实现细节 |
537
+ |------|------|----------|
538
+ | 告警过期后,"无活跃告警"绿色状态下方直接展示历史记录,上面说没事、下面列了两条记录,信息矛盾 | 历史默认完全隐藏,只显示灰色"历史(N 条)"可点击文字,展开后才显示历史列表 | `renderAlertCard` 无活跃告警分支:新增 `alert-history-toggle-btn`(灰色 11px 可点击按钮)+ `.alert-history-collapsed`(CSS `display:none`)。事件委托 `[data-alert-history-toggle]` 绑定在 `#card-alert` 上,toggle `alert-history-collapsed` class |
539
+
540
+ 告警卡片状态设计:
541
+
542
+ ```
543
+ 无告警时:
544
+ ✅ 无活跃告警
545
+ 历史(2 条) ← 灰色小字,点击展开
546
+
547
+ 有告警时:
548
+ ⚠ 活跃告警
549
+ 13:03:54 触发 · 剩余 1m 53s
550
+ 22 个文件在 10 秒内变更(阈值:20)
551
+ [展开文件详情]
552
+ ```
553
+
554
+ **备份结构化文件表格**:
555
+
556
+ | 层 | 改动 | 说明 |
557
+ |----|------|------|
558
+ | Core | `backups.js` 新增 `getBackupFiles(projectDir, commitHash)` | 对指定 commit 运行 `diff-tree --numstat + --name-status`,返回 `[{path, action, added, deleted}]`,按变化量降序。支持 rename 解析(`R` 前缀→取 tab 分割的最后一段)。无 parent 时退化为 `ls-tree` 列出全部文件 |
559
+ | Server | `GET /api/backup-files?id=<project>&hash=<commit>` | 懒加载端点,不在 `list_backups` 中批量计算(50 条备份×`diff-tree` 会很慢)。400 校验 `hash` 必填 |
560
+ | Dashboard | `parseSummaryToFiles(summary)` | 解析 summary 文本格式 `"Modified 3: a.js (+2 -1), b.js (+0 -5), ...; Added 2: c.js (+10 -0)"` → `[{path, action, added, deleted}]`。正则匹配 `(Modified|Added|Deleted|Renamed) N:` 段头,逐文件解析 `filename (+N -M)`,自动跳过 `...` 截断标记 |
561
+ | Dashboard | `fetchBackupFiles(hash)` | 调用 `/api/backup-files` 端点,返回完整文件数组。网络失败静默降级(返回空数组) |
562
+ | Dashboard | 备份表 `formatSummaryCell` | 行内 mini 文件表格:`parseSummaryToFiles` 取前 3 个文件,每文件显示路径(mono 字体 11px,`max-width:220px` 省略号截断)+ 操作 badge(彩色)+ `+N -M`。超出显示"等 N 个文件…"(斜体灰色),`N` 取 `filesChanged` 字段(当 summary 有 `...` 截断时)或实际剩余数 |
563
+ | Dashboard | 抽屉 `openRestoreDrawer` | summary 字段不再用 `<pre>` 文本,改为懒加载可排序文件表格。打开抽屉 → `fetchBackupFiles(hash)` → `renderDrawerFilesTable(files, sortKey)`。三列表头均可点击排序(path=字典序 / action=字母序 / changes=变化量降序),当前排序列高亮。API 失败时降级为 `parseSummaryToFiles` 本地解析 |
564
+ | Dashboard | `renderDrawerFilesTable(files, sortKey)` | 可排序表格渲染函数:sticky 表头、340px 最大高度滚动区域、表头带 ↕ 排序指示器、行内复用 `formatFileActionBadge` 统一操作 badge |
565
+ | Dashboard | i18n 补全 | 新增 `summary.andMore`("and {n} more…" / "等 {n} 个文件…")、`alert.historyCount`("History ({n})" / "历史({n} 条)") |
566
+
567
+ > 同一个 `getBackupFiles` 数据结构,告警详情和备份详情两个地方同时受益。备份表行内看 3 个关键文件,抽屉里看完整列表——信息层级清晰,不再一行文本挤 22 个文件名。
568
+
569
+ #### V4.5.4 详细内容
570
+
571
+ **Shadow 硬链接增量优化**:
572
+
573
+ | 层 | 改动 | 说明 |
574
+ |----|------|------|
575
+ | Core | `snapshot.js` `findPreviousSnapshot(backupDir)` | 新增辅助函数。扫描 backupDir 下所有 `YYYYMMDD_HHMMSS` 格式目录,按时间戳降序排列,返回最新的 snapshot 目录路径(`isDirectory` 校验) |
576
+ | Core | `snapshot.js` `createShadowCopy` 硬链接逻辑 | 备份循环中,对每个文件执行增量判断:① 读取源文件 `stat`(size + mtimeMs)② 读取上一个 snapshot 同路径文件 `stat` ③ 若 size 完全一致且 mtimeMs 差值 < 1ms → `fs.linkSync(prevFile, dest)` 硬链接 ④ 否则 → `fs.copyFileSync(src, dest)` + `fs.utimesSync(dest, atime, mtime)` 保留源文件时间戳 |
577
+ | Core | mtime 保留策略 | 每次 `copyFileSync` 后立即 `utimesSync` 将源文件的 mtime 同步到目标。这确保下一次 shadow 备份时,"上一个 snapshot 的文件 mtime" = "当时源文件的 mtime",硬链接比较才有基准。`utimesSync` 失败不阻塞备份(`try-catch` 静默) |
578
+ | Core | 跨卷容错 | `fs.linkSync` 在跨文件系统(如 backupDir 在不同磁盘卷)或 FAT32 分区上会抛 `EXDEV` 错误。外层 `try-catch` 捕获后自动 fall back 到 `copyFileSync`,不影响备份正确性 |
579
+ | Watcher | `auto-backup.js` 日志 | Shadow 日志增加硬链接统计:`Shadow copy 20260322_130000 (150 files [142 hard-linked])`。linkedCount = 0 时不显示 |
580
+ | 返回值 | `createShadowCopy` 新增 `linkedCount` 字段 | 表示本次备份中通过硬链接节省 I/O 的文件数。用于日志、Dashboard 未来可展示 |
581
+
582
+ 性能对比:
583
+
584
+ | 场景 | 文件总数 | 变更数 | 传统全量 copy | 硬链接增量 | 磁盘节省 |
585
+ |------|---------|--------|--------------|-----------|---------|
586
+ | 常规开发 | 150 | 8 | 150 次 copy | 8 copy + 142 link | ~95% I/O |
587
+ | 大规模重构 | 150 | 50 | 150 次 copy | 50 copy + 100 link | ~67% I/O |
588
+ | 首次备份 | 150 | 150 | 150 次 copy | 150 次 copy(无上一个 snapshot) | 0%(正常) |
589
+
590
+ > 硬链接在 NTFS(Windows)和 ext4/APFS(Linux/macOS)上均支持。同一 inode 共享磁盘块,150 个文件只改 8 个时磁盘写入降 95%。
591
+
592
+ **always_watch 强保护模式**:
593
+
594
+ | 层 | 改动 | 说明 |
595
+ |----|------|------|
596
+ | Config | `utils.js` DEFAULT_CONFIG | 新增 `always_watch: false` 默认配置项 |
597
+ | Config | `utils.js` loadConfig | 解析 `.cursor-guard.json` 中的 `always_watch` 布尔值。类型校验:非 `true`/`false` 值 → 警告 + 使用默认值 `false` |
598
+ | MCP | `mcp/server.js` `watchedProjects` Map | 进程级 Map,`key = projectPath`,`value = { pid, external }`。追踪已启动/检测到的 watcher,防止重复 spawn |
599
+ | MCP | `mcp/server.js` `ensureWatcher(projectPath)` | 自动 watcher 管理器,执行流程:① `watchedProjects.has(path)` → 已处理过则跳过 ② `loadConfig(path)` → 检查 `always_watch` 是否为 `true` ③ `isWatcherRunning(path)` → 已有外部 watcher 则标记 `external: true` 跳过 ④ `spawn(process.execPath, [cursor-guard-backup, --path, path])` → 创建 detached 子进程(`detached: true, stdio: 'ignore', windowsHide: true`) ⑤ `child.unref()` → 父进程退出不影响 watcher |
600
+ | MCP | 所有 9 个 tool handler | 入口处统一调用 `ensureWatcher(resolved)`。仅首次调用触发实际逻辑,后续调用通过 Map 缓存直接跳过(O(1)) |
601
+ | 安全 | 与现有锁文件机制兼容 | `ensureWatcher` 先检查 `isWatcherRunning`(读取 lock file + `process.kill(pid, 0)` PID 存活检测),不会在已有 watcher 时重复启动。手动启动的 watcher 和 auto-spawn 的 watcher 使用同一个 lock file,互斥保护 |
602
+
603
+ 用户配置方式:
604
+ ```json
605
+ {
606
+ "always_watch": true
607
+ }
608
+ ```
609
+
610
+ 两种保护模式对比:
611
+
612
+ | | 轻量模式(默认) | 强保护模式 |
613
+ |---|---|---|
614
+ | 配置 | `always_watch: false`(或不设) | `always_watch: true` |
615
+ | Watcher 启动 | 手动 `cursor-guard-backup` | MCP server 首次 tool 调用自动 spawn |
616
+ | 保护覆盖 | AI 需手动 `snapshot_now` | 全程自动备份,零保护缺口 |
617
+ | 适用场景 | 小项目、低频编辑 | 重要项目、高频 AI 协作 |
618
+ | 资源开销 | 无后台进程 | 后台 watcher 进程(低 CPU,周期性扫描) |
619
+
620
+ > 这个特性直接填补了 V4 最大的架构缺口——"Watcher 停止 = 裸奔"。详见下方"V4 遗留的架构缺口"中该条目已标记为 **已解决**。
621
+
622
+ #### V4.5.x 新增配置参考
623
+
624
+ | 字段 | 类型 | 默认值 | 引入版本 | 说明 |
625
+ |------|------|--------|---------|------|
626
+ | `always_watch` | `boolean` | `false` | V4.5.4 | 强保护模式。设为 `true` 后,MCP server 首次 tool 调用自动启动 watcher 进程 |
627
+
628
+ 完整 `.cursor-guard.json` 配置示例(含 V4.5.4 新增项):
629
+
630
+ ```json
631
+ {
632
+ "protect": ["src/**", "*.config.js"],
633
+ "ignore": ["node_modules/**", "dist/**"],
634
+ "backup_strategy": "git",
635
+ "auto_backup_interval_seconds": 60,
636
+ "always_watch": true,
637
+ "proactive_alert": true,
638
+ "alert_thresholds": {
639
+ "files_per_window": 20,
640
+ "window_seconds": 10,
641
+ "cooldown_seconds": 60
642
+ }
643
+ }
644
+ ```
645
+
515
646
  ### V4 不做的事
516
647
 
517
648
  - 不做自动恢复(恢复永远需要人确认,这是产品底线)
@@ -522,18 +653,19 @@ V4 经过 4 轮系统性代码审查,修复了以下关键问题:
522
653
 
523
654
  通过 V4.4.1 的安全审计和真实场景测试,发现以下架构层面的保护缺口。这些不是代码 bug,而是设计边界:
524
655
 
525
- | 缺口 | 现状 | 影响 | V5 改进方向 |
526
- |------|------|------|------------|
527
- | **Watcher 停止 = 裸奔** | Watcher 不运行期间的文件变更无任何自动备份 | 如果 AI agent 也没手动 snapshot,变更永久丢失 | **`always_watch` 配置项**:在 `.cursor-guard.json` 中设置 `"always_watch": true`,MCP server 启动时自动 fork watcher 进程。用户可选两种模式:轻量模式(默认,AI 手动 snapshot)和强保护模式(watcher 始终在后台) |
528
- | **保护依赖 AI 自觉** | SKILL.md 要求 AI 在写入前 snapshot,但没有强制机制 | AI 不遵守协议就直接写,保护形同虚设 | **embedded watcher + `begin_edit` 意图绑定**:MCP server 内嵌 watcher 循环 + 按文件路径匹配 intent,消除进程边界和并发竞争(详见 V5 设计) |
529
- | **自动备份无意图上下文** | auto-backup 只有 `trigger: auto`,不知道是谁改的、为什么改 | 事后回溯只能看到时间点快照,不知道操作意图 | **`begin_edit` → 文件路径绑定**:AI 编辑前声明意图和目标文件,embedded watcher 检测变更时按路径匹配 intent,自动备份也能带上下文 |
530
- | **无跨进程写拦截** | 当前 MCP 架构下无法拦截 Cursor 编辑器的文件写入 | 只能在写后检测,不能写前阻止 | 等待 MCP 协议支持 `notification` / `resource subscription`,或探索 fs watch + pre-commit 组合 |
531
- | **意图队列并发问题** | 曾考虑"意图队列"(AI 写文件 → watcher 读文件关联 intent),但存在 4 类并发竞态:多 Agent 竞争、意图-变更错位、意图堆积、空意图残留 | 文件 I/O 跨进程 + 时间顺序绑定 = 不可靠 | **同进程内存 Map + 文件路径绑定**:消灭进程边界后无 IPC,按路径而非时间匹配消除歧义(详见 V5 设计) |
656
+ | 缺口 | 现状 | 影响 | V5 改进方向 | 状态 |
657
+ |------|------|------|------------|------|
658
+ | ~~**Watcher 停止 = 裸奔**~~ | ~~Watcher 不运行期间无自动备份~~ | ~~变更永久丢失~~ | ~~`always_watch` 配置项~~ | **V4.5.4 已解决**:`always_watch: true` 时 MCP server 首次 tool 调用自动 spawn watcher 进程,与现有锁文件互斥机制兼容 |
659
+ | **保护依赖 AI 自觉** | SKILL.md 要求 AI 在写入前 snapshot,但没有强制机制 | AI 不遵守协议就直接写,保护形同虚设 | **embedded watcher + `begin_edit` 意图绑定**:MCP server 内嵌 watcher 循环 + 按文件路径匹配 intent,消除进程边界和并发竞争(详见 V5 设计) | 🔮 V5 |
660
+ | **自动备份无意图上下文** | auto-backup 只有 `trigger: auto`,不知道是谁改的、为什么改 | 事后回溯只能看到时间点快照,不知道操作意图 | **`begin_edit` → 文件路径绑定**:AI 编辑前声明意图和目标文件,embedded watcher 检测变更时按路径匹配 intent,自动备份也能带上下文 | 🔮 V5 |
661
+ | **无跨进程写拦截** | 当前 MCP 架构下无法拦截 Cursor 编辑器的文件写入 | 只能在写后检测,不能写前阻止 | 等待 MCP 协议支持 `notification` / `resource subscription`,或探索 fs watch + pre-commit 组合 | 🔮 V5+ |
662
+ | **意图队列并发问题** | 曾考虑"意图队列"(AI 写文件 → watcher 读文件关联 intent),但存在 4 类并发竞态:多 Agent 竞争、意图-变更错位、意图堆积、空意图残留 | 文件 I/O 跨进程 + 时间顺序绑定 = 不可靠 | **同进程内存 Map + 文件路径绑定**:消灭进程边界后无 IPC,按路径而非时间匹配消除歧义(详见 V5 设计) | 🔮 V5 |
532
663
 
533
664
  ### 进入 V5 的衡量标准
534
665
 
535
- - V4 的主动提醒功能误报率 < 10%
536
- - 健康看板的信息准确度经用户验证
666
+ - V4 的主动提醒功能误报率 < 10% — V4.5.0 修复了计数数据源(`diff-tree` 替代 `porcelain`)
667
+ - 健康看板的信息准确度经用户验证 — V4.5.2-V4.5.3 增加结构化文件列表,数据来源一致
668
+ - ✅ `always_watch` 基础能力可用 — V4.5.4 已实现(spawn 独立进程版)
537
669
  - 多 Agent 并发编辑已成为用户的真实场景(不是假设)
538
670
  - MCP 协议的 notification / resource subscription 机制已相对成熟
539
671
 
@@ -543,10 +675,12 @@ V4 经过 4 轮系统性代码审查,修复了以下关键问题:
543
675
 
544
676
  | 项目 | 内容 |
545
677
  |---|---|
546
- | 状态 | 中期主线 🚀 |
678
+ | 状态 | 中期主线 🚀(V4.5.x 已完成基础设施前置) |
547
679
  | 定位 | 从保护"一次 AI 写操作"升级为管理"整条 AI 代码变更链路":事前预防、事中判断、事后追溯、按事件恢复。 |
548
680
  | 产品定义 | `AI Code Change Control Layer` |
549
681
  | 关键词 | `intent` / `impact` / `audit` / `restore-by-event` |
682
+ | V4 前置 | `always_watch`(V4.5.4)、结构化文件数据(V4.5.2-V4.5.3)、intent 基础(V4.3.3)、审计 trailer(V4.3.0)、Shadow 硬链接(V4.5.4)、Web 仪表盘(V4.2-V4.5.3) |
683
+ | V5 核心差异 | **embedded watcher(同进程)** + **begin_edit 意图-变更原子绑定** — 这两项是 V4 无法做到的,需要架构升级 |
550
684
 
551
685
  ### 为什么需要这一步
552
686
 
@@ -567,29 +701,35 @@ V2-V4 已经证明,cursor-guard 的价值不只是"多留一个备份点",
567
701
 
568
702
  V5 不是"三个方向选一个",而是把下面这条链路做完整:
569
703
 
570
- 1. **Intent**:谁正准备改这里?
571
- 2. **Impact**:这次改动会波及哪里?
572
- 3. **Audit**:这段代码何时、被谁、因何改的?
573
- 4. **Restore**:出事后该回退哪次操作?
704
+ | 步骤 | 核心问题 | V4 已有基础 | V5 新增 |
705
+ |------|---------|------------|---------|
706
+ | **Intent** | 谁正准备改这里? | ✅ `snapshot_now` intent/agent/session 参数 | `begin_edit` 实时注册 + 文件路径绑定 |
707
+ | **Impact** | 这次改动波及哪里? | ✅ 结构化 `changedFiles` + `getBackupFiles` | 符号级分析(导出/引用/测试波及) |
708
+ | **Audit** | 何时、被谁、因何改的? | ✅ Git trailer 审计元数据 + 仪表盘展示 | 独立 JSONL 审计存储 + 结构化事件查询 |
709
+ | **Restore** | 出事回退哪次操作? | ✅ `restore_file` / `restore_project` + pre-restore 快照 | `restore_from_event`(按事件 ID 直接恢复) |
574
710
 
575
- 只有这四步连起来,cursor-guard 才不是"备份工具",而是"变更控制层"。
711
+ 只有这四步连起来,cursor-guard 才不是"备份工具",而是"变更控制层"。V4 已铺好每一步的基础设施,V5 的核心价值是把它们**原子化串联**——通过同进程 embedded watcher + 文件路径意图绑定,让 Intent → Impact → Audit → Restore 在一个事件链里自动完成。
576
712
 
577
713
  ### V5.0 可执行功能清单
578
714
 
579
- | 模块 / 能力 | AI 要做什么 | 建议产物 | 完成标准 |
715
+ > **注**:部分 V5 规划能力已在 V4.5.x 提前落地,下表用 标注。V5 的核心差异化在于 **embedded watcher(同进程)** 和 **begin_edit 意图-变更原子绑定**。
716
+
717
+ | 模块 / 能力 | 说明 | V4 已落地 | V5 新增 |
580
718
  |---|---|---|---|
581
- | `intent registry` | 在高风险写入前注册编辑意图,记录 agent、会话、工作区、分支、目标文件、风险级别 | `core/intent.*` 或同等模块 | 能列出活跃会话,能释放会话,能查到谁准备改哪个文件。**基础版已在 V4.3.3 落地**:`snapshot_now` 支持 `intent` / `agent` / `session` 参数,存储为 Git commit trailer,仪表盘可展示。**V4.3.5 修复**:summary 改用 `diff-tree` 增量对比,确保元数据准确 |
582
- | `pre-edit snapshot` | 在每次高风险 AI 写入前创建 `refs/guard/pre-edit/*` 恢复点 | `refs/guard/pre-edit/<session>/<seq>` | 任意一条 AI 编辑事件都能关联到写前快照 |
583
- | `conflict detection` | 先做文件路径级冲突检测,再预留符号级增强位 | `detectConflicts()` / `listConflicts()` | 两个会话同时改重叠文件时能给出 advisory warning |
584
- | `audit store` | append-only 方式保存 AI 编辑事件 | 默认本地 `JSONL`;后续可升级 `SQLite` | 能按文件 / 会话 / agent / 时间 / 风险级别查询。**雏形已在 V4.3.0-V4.3.3 落地**:审计元数据通过 Git commit trailer 持久化,`listBackups` 可按 trigger/intent/agent/session 解析 |
585
- | `restore by event` | 允许从一条审计事件直接跳转并执行恢复 | `restore_from_event` | 给定 `event_id` 可以定位 `before_ref` `restore_ref` |
586
- | `impact set` | 为高风险编辑记录受影响文件 / 符号 / 测试集合 | `impact_set` 字段 | 查询事件时能看到"这次改动可能波及哪里" |
587
- | `MCP / CLI surface` | 暴露最小可用接口给 Agent 和终端 | `register_intent` / `list_active_intents` / `audit_query` / `get_event` / `restore_from_event` | AI 不需要拼复杂 shell,就能完成查询与恢复 |
588
- | `dashboard / doctor` | 把活跃会话、冲突告警、最近 AI 事件纳入诊断和看板 | `dashboard` / `doctor` 扩展字段 | 用户能看见"现在谁在改、最近改了什么、哪里有冲突" |
589
- | `always_watch` | 配置项 `"always_watch": true`,MCP server 启动时自动内嵌 watcher 循环;用户可选两种保护模式:**轻量模式**(默认,AI 手动 `snapshot_now`)vs **强保护模式**(watcher 始终在后台,所有变更自动备份) | `.cursor-guard.json` 配置 + MCP server 启动逻辑 | MCP server 启动后,`always_watch: true` 的项目自动有 watcher 保护,无需额外命令;选择权在用户 |
590
- | `embedded watcher` | **消灭进程边界**:不再是独立后台进程,而是 MCP server 同进程内的 watcher 循环。同进程 = IPC、无文件桥接、无并发竞争。检测到文件变更时自动创建 snapshot,不依赖 AI 手动调用 | MCP server 内部模块 | watcher 停止 = 裸奔的保护缺口彻底消除;AI 忘记 snapshot 也有兜底 |
591
- | `begin_edit` / `end_edit` | **意图-变更原子绑定**:AI 编辑前调 `begin_edit({ intent, files[], agent, session })`,在内存 `Map<session, EditScope>` 注册编辑意图和目标文件。embedded watcher 检测到变更时按**文件路径**匹配 intent,自动备份带完整上下文。`end_edit(session)` 或 TTL(默认 5 分钟)自动清除 | `begin_edit` / `end_edit` MCP 工具 + 内存 `activeEdits` Map | auto-backup 也能带 intent/agent/session;并发多 Agent 按文件路径消歧,不按时间顺序 |
592
- | `tests / docs` | 为事件链路补齐单测、集成测试和文档 | tests + schema docs | V5.0 的所有核心事件和恢复路径都有测试覆盖 |
719
+ | `intent registry` | 注册编辑意图(agent、会话、目标文件、风险级别) | V4.3.3:`snapshot_now` 支持 `intent/agent/session`,Git trailer 存储,仪表盘展示 | 升级为 `begin_edit` MCP 工具,内存 Map 管理活跃会话,支持文件路径级冲突检测 |
720
+ | `pre-edit snapshot` | 每次高风险写入前创建恢复点 | V4:`restore_file` / `restore_project` 自动创建 `refs/guard/pre-restore/*` | 新增 `refs/guard/pre-edit/<session>/<seq>`,按会话隔离 |
721
+ | `conflict detection` | 并发编辑冲突告警 | | 新增:`begin_edit` 时检测文件路径重叠,advisory warning |
722
+ | `audit store` | 审计事件存储与查询 | V4.3.0-V4.3.3Git commit trailer 持久化(trigger/intent/agent/session),`listBackups` 可解析 | 升级为独立 JSONL 审计存储,支持按事件 ID / 文件 / 会话 / 风险级别查询 |
723
+ | `restore by event` | 从审计事件直接恢复 | | 新增:`restore_from_event(event_id)` 定位 `before_ref` 执行恢复 |
724
+ | `impact set` | 记录变更波及范围 | V4.5.2:告警携带结构化文件列表 `[{path, action, added, deleted}]` | 升级:增加符号级分析(导出/引用变更检测) |
725
+ | `MCP / CLI surface` | 变更控制接口 | V4:9 个 MCP 工具(doctor / list_backups / snapshot_now / restore_file / restore_project / doctor_fix / backup_status / dashboard / alert_status) | 新增:`begin_edit` / `end_edit` / `audit_query` / `get_event` / `restore_from_event` |
726
+ | `dashboard / doctor` | 看板和诊断 | V4.2-V4.5.3:完整 Web 仪表盘,告警卡片+历史、备份表格+抽屉、诊断面板、结构化文件表格 | 扩展:活跃会话面板、冲突告警卡片、AI 事件时间线 |
727
+ | `always_watch` | 强保护模式 | ✅ **V4.5.4 已完整实现**:`always_watch: true` → MCP server 首次 tool 调用自动 spawn watcher 进程,`ensureWatcher()` + `watchedProjects` Map + 锁文件互斥兼容 | V5 进一步升级为 embedded watcher(同进程内嵌,非独立进程) |
728
+ | `embedded watcher` | 同进程 watcher 循环 | 部分:V4.5.4 `always_watch` 通过 spawn 独立子进程实现(解决了"裸奔"问题,但仍是跨进程) | **核心差异**:MCP server 内嵌 watcher 循环,同进程=无 IPC、无竞争。检测变更时按文件路径匹配 `begin_edit` 中的 intent |
729
+ | `begin_edit` / `end_edit` | 意图-变更原子绑定 | — | **核心新增**:`begin_edit({ intent, files[], agent, session })` → 内存 `Map<session, EditScope>` watcher 按文件路径匹配 → auto-backup 带完整上下文。`end_edit` 或 TTL 5min 自动清除 |
730
+ | `Shadow 增量优化` | 节省磁盘 I/O | **V4.5.4 已完整实现**:硬链接未变文件(mtime+size 比较),mtime 保留,跨卷容错,日志显示 linkedCount | |
731
+ | `备份结构化文件数据` | 结构化 files 数组 | ✅ **V4.5.2-V4.5.3 已完整实现**:snapshot changedFiles、anomaly alert files、`getBackupFiles` API、前端 mini 表格+抽屉可排序表格 | — |
732
+ | `tests / docs` | 测试和文档 | ✅ V4:138 测试全过(utils 39 + core 78 + MCP 21) | 为 V5 新增事件链路补齐测试 |
593
733
 
594
734
  ### V5 核心设计:Embedded Watcher + 文件路径意图绑定
595
735
 
@@ -658,8 +798,15 @@ MCP Server 进程(知道 intent) ←——×——→ Watcher 进程(知
658
798
  #### 实现分期
659
799
 
660
800
  ```
661
- Phase 1 (V5.0):
662
- ├── always_watch: true → MCP server 内嵌 watcher 循环
801
+ Phase 0 (V4.5.x — ✅ 已完成):
802
+ ├── always_watch: true → MCP server 自动 spawn watcher 子进程(V4.5.4)
803
+ ├── 结构化文件数据:snapshot changedFiles + alert files + getBackupFiles API(V4.5.2-V4.5.3)
804
+ ├── Shadow 硬链接增量优化(V4.5.4)
805
+ ├── 告警历史 UX + 备份文件表格化(V4.5.3)
806
+ └── intent/agent/session 基础支持(V4.3.3)
807
+
808
+ Phase 1 (V5.0 — 核心差异化):
809
+ ├── always_watch 升级:从 spawn 独立进程 → MCP server 内嵌 watcher 循环(同进程)
663
810
  ├── 新增 begin_edit / end_edit MCP 工具
664
811
  ├── 内存 Map<session, EditScope> 管理活跃编辑意图
665
812
  └── watcher 变更检测时查 Map,按文件路径匹配 intent → 写入 commit trailer
@@ -807,7 +954,8 @@ V5 的关键不是"多打一行日志",而是建立完整证据链:
807
954
 
808
955
  ### V5 完成标志(Definition of Done)
809
956
 
810
- - `always_watch: true` 配置生效后,MCP server 启动自动内嵌 watcher 循环,用户无需额外命令
957
+ - ~~`always_watch: true` 配置生效后,用户无需额外命令~~ **V4.5.4 已完成**(spawn 子进程方式)
958
+ - V5 升级:`always_watch` 从 spawn 子进程升级为 MCP server 内嵌 watcher 循环(同进程,无 IPC)
811
959
  - `begin_edit` / `end_edit` MCP 工具可用,AI 能声明编辑意图和目标文件
812
960
  - embedded watcher 的自动备份能通过文件路径匹配关联 `begin_edit` 中的 intent/agent/session
813
961
  - 无 `begin_edit` 时自动备份行为与 V4 一致(无退化)
@@ -815,7 +963,8 @@ V5 的关键不是"多打一行日志",而是建立完整证据链:
815
963
  - 用户能查询最近一次 AI 编辑的完整上下文
816
964
  - 给定 `event_id` 能找到对应快照并执行恢复
817
965
  - 两个活跃会话改同一文件时,系统能稳定给出冲突告警
818
- - dashboard / doctor 能展示最近 AI 事件、活跃会话和未解决冲突
966
+ - ~~dashboard / doctor 展示告警、备份详情~~ ✅ **V4.2-V4.5.3 已完成**(Web 仪表盘、告警卡片+历史、结构化文件表格、可排序抽屉)
967
+ - V5 扩展:dashboard 增加活跃会话面板、冲突告警卡片、AI 事件时间线
819
968
 
820
969
  ### 进入 V6 的衡量标准
821
970
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursor-guard",
3
- "version": "4.5.1",
3
+ "version": "4.5.5",
4
4
  "description": "Protects code from accidental AI overwrite or deletion in Cursor IDE — mandatory pre-write snapshots, review-before-apply, local Git safety net, and deterministic recovery. | 保护代码免受 Cursor AI 代理意外覆写或删除——强制写前快照、预览再执行、本地 Git 安全网、确定性恢复。",
5
5
  "keywords": [
6
6
  "cursor",
@@ -46,6 +46,16 @@ const I18N = {
46
46
  'alert.expired': 'Expired',
47
47
  'alert.history': 'Recent Alert History',
48
48
  'alert.noHistory': 'No alert history',
49
+ 'alert.historyCount':'History ({n})',
50
+ 'alert.showFiles': 'Show file details',
51
+ 'alert.hideFiles': 'Hide file details',
52
+ 'alert.col.file': 'File',
53
+ 'alert.col.action': 'Action',
54
+ 'alert.col.changes':'Changes',
55
+ 'alert.action.modified': 'Modified',
56
+ 'alert.action.added': 'Added',
57
+ 'alert.action.deleted': 'Deleted',
58
+ 'alert.action.renamed': 'Renamed',
49
59
 
50
60
  'backups.gitCommits': 'Git Commits',
51
61
  'backups.shadowSnapshots': 'Shadow Snapshots',
@@ -107,6 +117,7 @@ const I18N = {
107
117
  'summary.deleted': 'Deleted',
108
118
  'summary.renamed': 'Renamed',
109
119
  'summary.files': 'files',
120
+ 'summary.andMore': 'and {n} more…',
110
121
  'drawer.field.intent': 'Intent',
111
122
  'drawer.field.agent': 'Agent',
112
123
  'drawer.field.session': 'Session',
@@ -243,6 +254,16 @@ const I18N = {
243
254
  'alert.expired': '已过期',
244
255
  'alert.history': '近期告警历史',
245
256
  'alert.noHistory': '暂无告警记录',
257
+ 'alert.historyCount':'历史({n} 条)',
258
+ 'alert.showFiles': '展开文件详情',
259
+ 'alert.hideFiles': '收起文件详情',
260
+ 'alert.col.file': '文件',
261
+ 'alert.col.action': '操作',
262
+ 'alert.col.changes':'变化量',
263
+ 'alert.action.modified': '修改',
264
+ 'alert.action.added': '新增',
265
+ 'alert.action.deleted': '删除',
266
+ 'alert.action.renamed': '重命名',
246
267
 
247
268
  'backups.gitCommits': 'Git 提交数',
248
269
  'backups.shadowSnapshots': '影子快照',
@@ -304,6 +325,7 @@ const I18N = {
304
325
  'summary.deleted': '删除',
305
326
  'summary.renamed': '重命名',
306
327
  'summary.files': '个文件',
328
+ 'summary.andMore': '等 {n} 个文件…',
307
329
  'drawer.field.intent': '操作意图',
308
330
  'drawer.field.agent': 'AI 模型',
309
331
  'drawer.field.session': '会话 ID',
@@ -829,6 +851,7 @@ function renderAlertCard(alerts) {
829
851
  if (!alerts?.active) {
830
852
  let historyHtml = '';
831
853
  if (state.alertHistory.length > 0) {
854
+ const count = state.alertHistory.length;
832
855
  const rows = state.alertHistory.slice(-5).reverse().map(h =>
833
856
  `<div class="alert-history-row text-sm text-muted">
834
857
  <span>${esc(formatTime(h.timestamp))}</span>
@@ -836,7 +859,13 @@ function renderAlertCard(alerts) {
836
859
  <span class="badge badge-expired">${t('alert.expired')}</span>
837
860
  </div>`
838
861
  ).join('');
839
- historyHtml = `<div class="alert-history"><div class="alert-history-label text-sm">${t('alert.history')}</div>${rows}</div>`;
862
+ historyHtml = `
863
+ <div class="alert-history-toggle-wrap">
864
+ <button class="alert-history-toggle-btn text-sm text-muted" data-alert-history-toggle>${t('alert.historyCount', { n: count })}</button>
865
+ </div>
866
+ <div class="alert-history alert-history-collapsed">
867
+ <div class="alert-history-label text-sm">${t('alert.history')}</div>${rows}
868
+ </div>`;
840
869
  }
841
870
  el.innerHTML = `
842
871
  <div class="card-label">${t('alert.title')}</div>
@@ -849,7 +878,7 @@ function renderAlertCard(alerts) {
849
878
 
850
879
  // Track in history
851
880
  if (a.timestamp && !state.alertHistory.some(h => h.timestamp === a.timestamp)) {
852
- state.alertHistory.push({ timestamp: a.timestamp, fileCount: a.fileCount, windowSeconds: a.windowSeconds, threshold: a.threshold, expiresAt: a.expiresAt });
881
+ state.alertHistory.push({ timestamp: a.timestamp, fileCount: a.fileCount, windowSeconds: a.windowSeconds, threshold: a.threshold, expiresAt: a.expiresAt, files: a.files });
853
882
  if (state.alertHistory.length > 20) state.alertHistory = state.alertHistory.slice(-20);
854
883
  }
855
884
 
@@ -861,6 +890,32 @@ function renderAlertCard(alerts) {
861
890
  const remainDisplay = remainMin > 0 ? `${remainMin}m ${remainSec % 60}s` : `${remainSec}s`;
862
891
  const detailText = t('alert.detail', { count: a.fileCount || '?', window: a.windowSeconds || '?', threshold: a.threshold || '?' });
863
892
 
893
+ const files = Array.isArray(a.files) ? a.files : [];
894
+ let filesHtml = '';
895
+ if (files.length > 0) {
896
+ const actionBadge = (action) => {
897
+ const cls = action === 'deleted' ? 'alert-action-deleted'
898
+ : action === 'added' ? 'alert-action-added'
899
+ : action === 'renamed' ? 'alert-action-renamed'
900
+ : 'alert-action-modified';
901
+ return `<span class="alert-action-badge ${cls}">${t('alert.action.' + action)}</span>`;
902
+ };
903
+ const rows = files.map(f =>
904
+ `<tr><td class="text-mono alert-file-path">${esc(f.path)}</td><td>${actionBadge(f.action)}</td><td class="text-mono alert-file-changes">+${f.added || 0} -${f.deleted || 0}</td></tr>`
905
+ ).join('');
906
+ filesHtml = `
907
+ <div class="alert-files-section">
908
+ <button class="alert-files-toggle" data-alert-files-toggle>${t('alert.showFiles')}</button>
909
+ <div class="alert-files-table-wrap alert-files-hidden">
910
+ <table class="alert-files-table">
911
+ <thead><tr><th>${t('alert.col.file')}</th><th>${t('alert.col.action')}</th><th>${t('alert.col.changes')}</th></tr></thead>
912
+ <tbody>${rows}</tbody>
913
+ </table>
914
+ </div>
915
+ </div>
916
+ `;
917
+ }
918
+
864
919
  el.innerHTML = `
865
920
  <div class="card-label">${t('alert.title')}</div>
866
921
  <div class="card-status"><span class="status-dot status-warning"></span><span class="status-text status-warning">${t('alert.active')}</span></div>
@@ -869,6 +924,7 @@ function renderAlertCard(alerts) {
869
924
  <div class="alert-detail-row"><span class="alert-detail-label">${t('alert.expires')}</span><span class="alert-countdown">${esc(remainDisplay)}</span></div>
870
925
  <div class="alert-detail-row alert-numbers">${esc(detailText)}</div>
871
926
  </div>
927
+ ${filesHtml}
872
928
  `;
873
929
  }
874
930
 
@@ -935,6 +991,49 @@ function translateSummary(raw) {
935
991
  .replace(/\bRenamed (\d+)/g, (_, n) => `${t('summary.renamed')} ${n}`);
936
992
  }
937
993
 
994
+ /**
995
+ * Parse summary text into structured file array for inline preview.
996
+ * Format: "Modified 3: a.js (+2 -1), b.js (+0 -5), ...; Added 2: c.js (+10 -0), d.js (+3 -0)"
997
+ */
998
+ function parseSummaryToFiles(summary) {
999
+ if (!summary) return [];
1000
+ const ACTION_MAP = { Modified: 'modified', Added: 'added', Deleted: 'deleted', Renamed: 'renamed' };
1001
+ const files = [];
1002
+ for (const segment of summary.split('; ')) {
1003
+ const headerMatch = segment.match(/^(Modified|Added|Deleted|Renamed)\s+\d+:\s*/);
1004
+ if (!headerMatch) continue;
1005
+ const action = ACTION_MAP[headerMatch[1]] || 'modified';
1006
+ const rest = segment.slice(headerMatch[0].length);
1007
+ for (const part of rest.split(/,\s*/)) {
1008
+ if (part === '...') continue;
1009
+ const fileMatch = part.match(/^(.+?)\s*\(\+(\d+)\s+-(\d+)\)$/);
1010
+ if (fileMatch) {
1011
+ files.push({ path: fileMatch[1], action, added: parseInt(fileMatch[2], 10), deleted: parseInt(fileMatch[3], 10) });
1012
+ } else if (part.trim()) {
1013
+ files.push({ path: part.trim(), action, added: 0, deleted: 0 });
1014
+ }
1015
+ }
1016
+ }
1017
+ files.sort((a, b) => (b.added + b.deleted) - (a.added + a.deleted));
1018
+ return files;
1019
+ }
1020
+
1021
+ async function fetchBackupFiles(commitHash) {
1022
+ if (!state.currentProjectId || !commitHash) return [];
1023
+ try {
1024
+ const data = await fetchJson(`/api/backup-files?id=${state.currentProjectId}&hash=${commitHash}`);
1025
+ return Array.isArray(data.files) ? data.files : [];
1026
+ } catch { return []; }
1027
+ }
1028
+
1029
+ function formatFileActionBadge(action) {
1030
+ const cls = action === 'deleted' ? 'alert-action-deleted'
1031
+ : action === 'added' ? 'alert-action-added'
1032
+ : action === 'renamed' ? 'alert-action-renamed'
1033
+ : 'alert-action-modified';
1034
+ return `<span class="alert-action-badge ${cls}">${t('alert.action.' + action)}</span>`;
1035
+ }
1036
+
938
1037
  function formatSummaryCell(b) {
939
1038
  let line1 = '';
940
1039
  if (b.filesChanged != null) {
@@ -955,15 +1054,20 @@ function formatSummaryCell(b) {
955
1054
 
956
1055
  let line3 = '';
957
1056
  if (b.summary) {
958
- const categories = b.summary.split('; ').map(s => translateSummary(s));
959
- const MAX_VISIBLE = 2;
960
- if (categories.length <= MAX_VISIBLE) {
961
- line3 = categories.map(c => `<div class="summary-detail-line">${esc(c)}</div>`).join('');
1057
+ const parsed = parseSummaryToFiles(b.summary);
1058
+ if (parsed.length > 0) {
1059
+ const MAX_INLINE = 3;
1060
+ const visible = parsed.slice(0, MAX_INLINE).map(f =>
1061
+ `<div class="summary-file-row"><span class="text-mono summary-file-path">${esc(f.path)}</span>${formatFileActionBadge(f.action)}<span class="text-mono text-muted">+${f.added} -${f.deleted}</span></div>`
1062
+ ).join('');
1063
+ const remaining = parsed.length > MAX_INLINE ? parsed.length - MAX_INLINE : 0;
1064
+ const truncated = b.summary.includes('...');
1065
+ const moreCount = truncated ? (b.filesChanged || '?') - MAX_INLINE : remaining;
1066
+ const moreHtml = (remaining > 0 || truncated) ? `<div class="summary-file-more text-sm text-muted">${t('summary.andMore', { n: moreCount > 0 ? moreCount : '…' })}</div>` : '';
1067
+ line3 = visible + moreHtml;
962
1068
  } else {
963
- const visible = categories.slice(0, MAX_VISIBLE).map(c => `<div class="summary-detail-line">${esc(c)}</div>`).join('');
964
- const hidden = categories.slice(MAX_VISIBLE).map(c => `<div class="summary-detail-line">${esc(c)}</div>`).join('');
965
- const more = categories.length - MAX_VISIBLE;
966
- line3 = `${visible}<div class="summary-collapsed" data-summary-toggle>${hidden}</div><button class="summary-toggle-btn" data-summary-toggle>+${more} more</button>`;
1069
+ const categories = b.summary.split('; ').map(s => translateSummary(s));
1070
+ line3 = categories.slice(0, 2).map(c => `<div class="summary-detail-line">${esc(c)}</div>`).join('');
967
1071
  }
968
1072
  }
969
1073
 
@@ -1095,6 +1199,24 @@ function closeDrawer() {
1095
1199
  state.drawerOpen = null;
1096
1200
  }
1097
1201
 
1202
+ function renderDrawerFilesTable(files, sortKey) {
1203
+ const sorted = [...files];
1204
+ if (sortKey === 'path') sorted.sort((a, b) => a.path.localeCompare(b.path));
1205
+ else if (sortKey === 'action') sorted.sort((a, b) => a.action.localeCompare(b.action));
1206
+ else sorted.sort((a, b) => (b.added + b.deleted) - (a.added + a.deleted));
1207
+ const rows = sorted.map(f =>
1208
+ `<tr><td class="text-mono drawer-file-path">${esc(f.path)}</td><td>${formatFileActionBadge(f.action)}</td><td class="text-mono drawer-file-changes">+${f.added} -${f.deleted}</td></tr>`
1209
+ ).join('');
1210
+ return `<table class="drawer-files-table">
1211
+ <thead><tr>
1212
+ <th data-sort="path" class="drawer-sort-header">${t('alert.col.file')} ↕</th>
1213
+ <th data-sort="action" class="drawer-sort-header">${t('alert.col.action')} ↕</th>
1214
+ <th data-sort="changes" class="drawer-sort-header">${t('alert.col.changes')} ↕</th>
1215
+ </tr></thead>
1216
+ <tbody>${rows}</tbody>
1217
+ </table>`;
1218
+ }
1219
+
1098
1220
  function openRestoreDrawer(backup) {
1099
1221
  const body = $('#restore-drawer-body');
1100
1222
  const fields = [
@@ -1113,10 +1235,6 @@ function openRestoreDrawer(backup) {
1113
1235
  if (backup.restoreTo) fields.push({ key: 'drawer.field.restoreTo', val: backup.restoreTo });
1114
1236
  if (backup.restoreFile) fields.push({ key: 'drawer.field.restoreFile', val: backup.restoreFile });
1115
1237
  if (backup.message) fields.push({ key: 'drawer.field.message', val: backup.message });
1116
- if (backup.summary) {
1117
- const translated = backup.summary.split('; ').map(s => translateSummary(s)).join('\n');
1118
- fields.push({ key: 'drawer.field.summary', val: translated, pre: true });
1119
- }
1120
1238
 
1121
1239
  const refText = backup.ref || backup.shortHash || backup.timestamp || '';
1122
1240
  const jsonText = JSON.stringify(backup, null, 2);
@@ -1130,12 +1248,17 @@ function openRestoreDrawer(backup) {
1130
1248
  ${fields.map(f => `
1131
1249
  <div class="restore-field">
1132
1250
  <div class="restore-field-label">${t(f.key)}</div>
1133
- ${f.pre
1134
- ? `<pre class="restore-field-value text-mono summary-pre">${esc(f.val)}</pre>`
1135
- : `<div class="restore-field-value text-mono">${esc(f.val)}</div>`
1136
- }
1251
+ <div class="restore-field-value text-mono">${esc(f.val)}</div>
1137
1252
  </div>
1138
1253
  `).join('')}
1254
+ ${backup.summary ? `
1255
+ <div class="restore-field">
1256
+ <div class="restore-field-label">${t('drawer.field.summary')}</div>
1257
+ <div id="drawer-files-container" class="drawer-files-container">
1258
+ <div class="drawer-files-loading text-muted text-sm">${t('state.loading')}</div>
1259
+ </div>
1260
+ </div>
1261
+ ` : ''}
1139
1262
  <div class="restore-actions">
1140
1263
  <button class="btn btn-sm" data-copy="${esc(refText)}">${t('drawer.copyRef')}</button>
1141
1264
  <button class="btn btn-sm" data-copy-json>${t('drawer.copyJson')}</button>
@@ -1169,6 +1292,34 @@ function openRestoreDrawer(backup) {
1169
1292
  wrap.classList.toggle('hidden');
1170
1293
  });
1171
1294
 
1295
+ // Lazy-load full file list for summary section
1296
+ if (backup.summary && isGit && hash) {
1297
+ let currentFiles = [];
1298
+ let currentSort = 'changes';
1299
+ fetchBackupFiles(hash).then(files => {
1300
+ currentFiles = files.length > 0 ? files : parseSummaryToFiles(backup.summary);
1301
+ const container = body.querySelector('#drawer-files-container');
1302
+ if (container) {
1303
+ container.innerHTML = renderDrawerFilesTable(currentFiles, currentSort);
1304
+ container.addEventListener('click', (e) => {
1305
+ const th = e.target.closest('[data-sort]');
1306
+ if (!th) return;
1307
+ currentSort = th.dataset.sort;
1308
+ container.innerHTML = renderDrawerFilesTable(currentFiles, currentSort);
1309
+ });
1310
+ }
1311
+ });
1312
+ } else if (backup.summary) {
1313
+ const fallback = parseSummaryToFiles(backup.summary);
1314
+ const container = body.querySelector('#drawer-files-container');
1315
+ if (container && fallback.length > 0) {
1316
+ container.innerHTML = renderDrawerFilesTable(fallback, 'changes');
1317
+ } else if (container) {
1318
+ const translated = backup.summary.split('; ').map(s => translateSummary(s)).join('\n');
1319
+ container.innerHTML = `<pre class="restore-field-value text-mono summary-pre">${esc(translated)}</pre>`;
1320
+ }
1321
+ }
1322
+
1172
1323
  openDrawer('restore');
1173
1324
  }
1174
1325
 
@@ -1281,6 +1432,25 @@ function setupEvents() {
1281
1432
  if (backup) openRestoreDrawer(backup);
1282
1433
  });
1283
1434
 
1435
+ // Alert history + files toggle (event delegation)
1436
+ $('#card-alert').addEventListener('click', (e) => {
1437
+ const historyToggle = e.target.closest('[data-alert-history-toggle]');
1438
+ if (historyToggle) {
1439
+ const card = historyToggle.closest('#card-alert');
1440
+ const history = card?.querySelector('.alert-history');
1441
+ if (history) history.classList.toggle('alert-history-collapsed');
1442
+ return;
1443
+ }
1444
+ const toggleBtn = e.target.closest('[data-alert-files-toggle]');
1445
+ if (!toggleBtn) return;
1446
+ const section = toggleBtn.closest('.alert-files-section');
1447
+ if (!section) return;
1448
+ const wrap = section.querySelector('.alert-files-table-wrap');
1449
+ if (!wrap) return;
1450
+ const hidden = wrap.classList.toggle('alert-files-hidden');
1451
+ toggleBtn.textContent = hidden ? t('alert.showFiles') : t('alert.hideFiles');
1452
+ });
1453
+
1284
1454
  // Diagnostics summary click
1285
1455
  $('#diagnostics-summary').addEventListener('click', (e) => {
1286
1456
  if (e.target.closest('#diag-summary-click')) openDoctorDrawer();
@@ -418,6 +418,30 @@ main {
418
418
  border-left: 3px solid var(--blue);
419
419
  }
420
420
 
421
+ /* ── Summary inline file rows ────────────────────────────── */
422
+
423
+ .summary-file-row {
424
+ display: flex;
425
+ align-items: center;
426
+ gap: 6px;
427
+ padding: 1px 0;
428
+ font-size: 11px;
429
+ line-height: 1.4;
430
+ }
431
+ .summary-file-path {
432
+ flex: 1;
433
+ overflow: hidden;
434
+ text-overflow: ellipsis;
435
+ white-space: nowrap;
436
+ max-width: 220px;
437
+ font-size: 11px;
438
+ color: var(--text-primary);
439
+ }
440
+ .summary-file-more {
441
+ padding: 2px 0;
442
+ font-style: italic;
443
+ }
444
+
421
445
  /* ── Stats Row ────────────────────────────────────────────── */
422
446
 
423
447
  .stats-row {
@@ -749,6 +773,58 @@ main {
749
773
  line-height: 1.6;
750
774
  }
751
775
 
776
+ /* ── Drawer: Files Table ──────────────────────────────────── */
777
+
778
+ .drawer-files-container {
779
+ margin-top: 6px;
780
+ max-height: 340px;
781
+ overflow: auto;
782
+ border: 1px solid var(--border-subtle);
783
+ border-radius: var(--radius-sm);
784
+ }
785
+ .drawer-files-loading {
786
+ padding: 16px;
787
+ text-align: center;
788
+ }
789
+ .drawer-files-table {
790
+ width: 100%;
791
+ border-collapse: collapse;
792
+ font-size: 12px;
793
+ }
794
+ .drawer-files-table th {
795
+ text-align: left;
796
+ padding: 8px 12px;
797
+ font-size: 10px;
798
+ font-weight: 700;
799
+ text-transform: uppercase;
800
+ letter-spacing: .06em;
801
+ color: var(--text-tertiary);
802
+ border-bottom: 1px solid var(--border-subtle);
803
+ background: var(--bg-elevated);
804
+ position: sticky;
805
+ top: 0;
806
+ z-index: 1;
807
+ }
808
+ .drawer-sort-header { cursor: pointer; transition: color var(--transition); }
809
+ .drawer-sort-header:hover { color: var(--blue); }
810
+ .drawer-files-table td {
811
+ padding: 5px 12px;
812
+ border-bottom: 1px solid rgba(71,85,105,.15);
813
+ vertical-align: middle;
814
+ }
815
+ .drawer-file-path {
816
+ max-width: 280px;
817
+ overflow: hidden;
818
+ text-overflow: ellipsis;
819
+ white-space: nowrap;
820
+ color: var(--text-primary);
821
+ }
822
+ .drawer-file-changes {
823
+ white-space: nowrap;
824
+ font-variant-numeric: tabular-nums;
825
+ color: var(--text-secondary);
826
+ }
827
+
752
828
  /* ── Drawer: Doctor Checks ────────────────────────────────── */
753
829
 
754
830
  .check-item {
@@ -921,11 +997,26 @@ main {
921
997
  border-left: 3px solid var(--yellow);
922
998
  }
923
999
 
1000
+ .alert-history-toggle-wrap {
1001
+ margin-top: 8px;
1002
+ }
1003
+ .alert-history-toggle-btn {
1004
+ background: none;
1005
+ border: none;
1006
+ color: var(--text-tertiary);
1007
+ font-size: 11px;
1008
+ cursor: pointer;
1009
+ padding: 2px 0;
1010
+ transition: color var(--transition);
1011
+ }
1012
+ .alert-history-toggle-btn:hover { color: var(--blue); }
1013
+
924
1014
  .alert-history {
925
- margin-top: 12px;
1015
+ margin-top: 8px;
926
1016
  border-top: 1px solid var(--border-subtle);
927
1017
  padding-top: 8px;
928
1018
  }
1019
+ .alert-history.alert-history-collapsed { display: none; }
929
1020
  .alert-history-label {
930
1021
  font-size: 10px;
931
1022
  font-weight: 700;
@@ -949,6 +1040,84 @@ main {
949
1040
  padding: 1px 6px;
950
1041
  }
951
1042
 
1043
+ /* ── Alert Files Table ───────────────────────────────────── */
1044
+
1045
+ .alert-files-section {
1046
+ margin-top: 10px;
1047
+ border-top: 1px solid var(--border-subtle);
1048
+ padding-top: 8px;
1049
+ }
1050
+ .alert-files-toggle {
1051
+ background: none;
1052
+ border: none;
1053
+ color: var(--blue);
1054
+ font-size: 11px;
1055
+ font-weight: 600;
1056
+ cursor: pointer;
1057
+ padding: 2px 0;
1058
+ transition: color var(--transition);
1059
+ }
1060
+ .alert-files-toggle:hover { color: var(--text-heading); }
1061
+
1062
+ .alert-files-table-wrap {
1063
+ margin-top: 8px;
1064
+ max-height: 220px;
1065
+ overflow: auto;
1066
+ border: 1px solid var(--border-subtle);
1067
+ border-radius: var(--radius-sm);
1068
+ }
1069
+ .alert-files-hidden { display: none; }
1070
+
1071
+ .alert-files-table {
1072
+ width: 100%;
1073
+ border-collapse: collapse;
1074
+ font-size: 11px;
1075
+ }
1076
+ .alert-files-table th {
1077
+ text-align: left;
1078
+ padding: 6px 10px;
1079
+ font-size: 9px;
1080
+ font-weight: 700;
1081
+ text-transform: uppercase;
1082
+ letter-spacing: .06em;
1083
+ color: var(--text-tertiary);
1084
+ border-bottom: 1px solid var(--border-subtle);
1085
+ background: var(--bg-elevated);
1086
+ position: sticky;
1087
+ top: 0;
1088
+ z-index: 1;
1089
+ }
1090
+ .alert-files-table td {
1091
+ padding: 4px 10px;
1092
+ border-bottom: 1px solid rgba(71,85,105,.15);
1093
+ vertical-align: middle;
1094
+ }
1095
+ .alert-file-path {
1096
+ max-width: 240px;
1097
+ overflow: hidden;
1098
+ text-overflow: ellipsis;
1099
+ white-space: nowrap;
1100
+ color: var(--text-primary);
1101
+ }
1102
+ .alert-file-changes {
1103
+ white-space: nowrap;
1104
+ font-variant-numeric: tabular-nums;
1105
+ color: var(--text-secondary);
1106
+ }
1107
+
1108
+ .alert-action-badge {
1109
+ display: inline-block;
1110
+ padding: 1px 6px;
1111
+ border-radius: 8px;
1112
+ font-size: 9px;
1113
+ font-weight: 600;
1114
+ letter-spacing: .02em;
1115
+ }
1116
+ .alert-action-modified { background: var(--blue-bg); color: var(--blue); }
1117
+ .alert-action-added { background: var(--green-bg); color: var(--green); }
1118
+ .alert-action-deleted { background: var(--red-bg); color: var(--red); }
1119
+ .alert-action-renamed { background: var(--purple-bg); color: var(--purple); }
1120
+
952
1121
  /* ── File Search ─────────────────────────────────────────── */
953
1122
 
954
1123
  .file-search-wrap {
@@ -8,7 +8,7 @@ const path = require('path');
8
8
 
9
9
  const { getDashboard } = require('../lib/core/dashboard');
10
10
  const { runDiagnostics } = require('../lib/core/doctor');
11
- const { listBackups } = require('../lib/core/backups');
11
+ const { listBackups, getBackupFiles } = require('../lib/core/backups');
12
12
 
13
13
  const PUBLIC_DIR = path.join(__dirname, 'public');
14
14
  const DEFAULT_PORT = 3120;
@@ -171,6 +171,13 @@ function handleApi(pathname, query, registry, res) {
171
171
  }
172
172
  }
173
173
 
174
+ if (pathname === '/api/backup-files') {
175
+ const hash = query.get('hash');
176
+ if (!hash) return json(res, { error: 'Missing hash parameter' }, 400);
177
+ try { return json(res, getBackupFiles(pp, hash)); }
178
+ catch (e) { return json(res, { error: e.message }, 500); }
179
+ }
180
+
174
181
  if (pathname === '/api/doctor') {
175
182
  try { return json(res, runDiagnostics(pp)); }
176
183
  catch (e) { return json(res, { error: e.message }, 500); }
@@ -254,11 +254,13 @@ async function runBackup(projectDir, intervalOverride, opts = {}) {
254
254
  }
255
255
 
256
256
  // Git snapshot via Core — changedFileCount comes from diff-tree (accurate incremental)
257
+ let changedFiles;
257
258
  if ((cfg.backup_strategy === 'git' || cfg.backup_strategy === 'both') && repo) {
258
259
  const context = { trigger: 'auto' };
259
260
  const snapResult = createGitSnapshot(projectDir, cfg, { branchRef, context });
260
261
  if (snapResult.status === 'created') {
261
262
  changedFileCount = snapResult.changedCount != null ? snapResult.changedCount : 0;
263
+ changedFiles = snapResult.changedFiles;
262
264
  let msg = `Git snapshot ${snapResult.shortHash} (${snapResult.fileCount} files)`;
263
265
  if (snapResult.secretsExcluded) {
264
266
  msg += ` [secrets excluded: ${snapResult.secretsExcluded.join(', ')}]`;
@@ -272,7 +274,7 @@ async function runBackup(projectDir, intervalOverride, opts = {}) {
272
274
  }
273
275
 
274
276
  // V4: Record change event and check for anomalies (after snapshot, using accurate count)
275
- recordChange(tracker, changedFileCount);
277
+ recordChange(tracker, changedFileCount, changedFiles);
276
278
  const anomalyResult = checkAnomaly(tracker);
277
279
  if (anomalyResult.anomaly && anomalyResult.alert && !anomalyResult.suppressed) {
278
280
  saveAlert(projectDir, anomalyResult.alert);
@@ -283,7 +285,8 @@ async function runBackup(projectDir, intervalOverride, opts = {}) {
283
285
  if (cfg.backup_strategy === 'shadow' || cfg.backup_strategy === 'both') {
284
286
  const shadowResult = createShadowCopy(projectDir, cfg, { backupDir });
285
287
  if (shadowResult.status === 'created') {
286
- logger.log(`Shadow copy ${shadowResult.timestamp} (${shadowResult.fileCount} files)`);
288
+ const linkInfo = shadowResult.linkedCount ? ` [${shadowResult.linkedCount} hard-linked]` : '';
289
+ logger.log(`Shadow copy ${shadowResult.timestamp} (${shadowResult.fileCount} files${linkInfo})`);
287
290
  if (pendingManifest) {
288
291
  saveManifest(backupDir, pendingManifest);
289
292
  pendingManifest = null;
@@ -84,6 +84,27 @@ function checkAnomaly(tracker) {
84
84
  return { anomaly: true, alert: lastAlert, suppressed: true };
85
85
  }
86
86
 
87
+ // Aggregate per-file details from recent events, deduplicated by path (latest wins)
88
+ const fileMap = new Map();
89
+ for (const e of recentEvents) {
90
+ if (Array.isArray(e.files)) {
91
+ for (const f of e.files) {
92
+ if (f && f.path) {
93
+ const existing = fileMap.get(f.path);
94
+ if (existing) {
95
+ existing.added = (existing.added || 0) + (f.added || 0);
96
+ existing.deleted = (existing.deleted || 0) + (f.deleted || 0);
97
+ } else {
98
+ fileMap.set(f.path, { path: f.path, action: f.action || 'modified', added: f.added || 0, deleted: f.deleted || 0 });
99
+ }
100
+ }
101
+ }
102
+ }
103
+ }
104
+ const alertFiles = [...fileMap.values()]
105
+ .sort((a, b) => (b.added + b.deleted) - (a.added + a.deleted))
106
+ .slice(0, 50);
107
+
87
108
  const alert = {
88
109
  type: 'high_change_velocity',
89
110
  detectedAt: now,
@@ -93,6 +114,7 @@ function checkAnomaly(tracker) {
93
114
  threshold: tracker.config.filesPerWindow,
94
115
  expiresAt: new Date(now + 5 * 60 * 1000).toISOString(),
95
116
  recommendation: 'High volume of file changes detected. Consider reviewing recent modifications and creating a manual snapshot.',
117
+ files: alertFiles.length > 0 ? alertFiles : undefined,
96
118
  };
97
119
 
98
120
  tracker.alerts.push(alert);
@@ -398,4 +398,65 @@ function cleanGitRetention(branchRef, gitDirPath, cfg, cwd) {
398
398
  return { kept: keepCount, pruned: total - keepCount, mode, rebuilt: true };
399
399
  }
400
400
 
401
- module.exports = { listBackups, cleanShadowRetention, cleanGitRetention, parseShadowTimestamp };
401
+ // ── Get backup file details ─────────────────────────────────────
402
+
403
+ /**
404
+ * Get structured file-level changes for a specific git backup commit.
405
+ * Runs diff-tree --numstat + --name-status against parent (or ls-tree for root).
406
+ *
407
+ * @param {string} projectDir
408
+ * @param {string} commitHash - Full or short commit hash
409
+ * @returns {{ files: Array<{path: string, action: string, added: number, deleted: number}>, error?: string }}
410
+ */
411
+ function getBackupFiles(projectDir, commitHash) {
412
+ if (!isGitRepo(projectDir)) {
413
+ return { files: [], error: 'not a git repository' };
414
+ }
415
+
416
+ const resolved = git(['rev-parse', '--verify', commitHash], { cwd: projectDir, allowFail: true });
417
+ if (!resolved) {
418
+ return { files: [], error: `cannot resolve commit: ${commitHash}` };
419
+ }
420
+
421
+ const parentCheck = git(['rev-parse', '--verify', `${resolved}^`], { cwd: projectDir, allowFail: true });
422
+
423
+ if (!parentCheck) {
424
+ const lsOut = git(['ls-tree', '--name-only', '-r', resolved], { cwd: projectDir, allowFail: true });
425
+ if (!lsOut) return { files: [] };
426
+ return {
427
+ files: lsOut.split('\n').filter(Boolean).map(f => ({ path: f, action: 'added', added: 0, deleted: 0 })),
428
+ };
429
+ }
430
+
431
+ const nameStatusOut = git(['diff-tree', '--no-commit-id', '--name-status', '-r', `${resolved}^`, resolved], { cwd: projectDir, allowFail: true });
432
+ const numstatOut = git(['diff-tree', '--no-commit-id', '--numstat', '-r', `${resolved}^`, resolved], { cwd: projectDir, allowFail: true });
433
+
434
+ const stats = {};
435
+ if (numstatOut) {
436
+ for (const line of numstatOut.split('\n').filter(Boolean)) {
437
+ const [add, del, ...nameParts] = line.split('\t');
438
+ const fname = nameParts.join('\t');
439
+ stats[fname] = { added: add === '-' ? 0 : parseInt(add, 10), deleted: del === '-' ? 0 : parseInt(del, 10) };
440
+ }
441
+ }
442
+
443
+ const ACTION_MAP = { M: 'modified', A: 'added', D: 'deleted' };
444
+ const files = [];
445
+ if (nameStatusOut) {
446
+ for (const line of nameStatusOut.split('\n').filter(Boolean)) {
447
+ const tab = line.indexOf('\t');
448
+ if (tab < 0) continue;
449
+ const code = line.substring(0, tab).trim();
450
+ const filePart = line.substring(tab + 1);
451
+ const action = code.startsWith('R') ? 'renamed' : (ACTION_MAP[code] || 'modified');
452
+ const fileName = filePart.split('\t').pop();
453
+ const s = stats[fileName] || { added: 0, deleted: 0 };
454
+ files.push({ path: fileName, action, added: s.added, deleted: s.deleted });
455
+ }
456
+ }
457
+
458
+ files.sort((a, b) => (b.added + b.deleted) - (a.added + a.deleted));
459
+ return { files };
460
+ }
461
+
462
+ module.exports = { listBackups, getBackupFiles, cleanShadowRetention, cleanGitRetention, parseShadowTimestamp };
@@ -141,6 +141,7 @@ function createGitSnapshot(projectDir, cfg, opts = {}) {
141
141
  // Build incremental summary from actual tree diff (not working-dir status)
142
142
  let changedCount;
143
143
  let incrementalSummary;
144
+ let changedFiles;
144
145
  if (parentTree) {
145
146
  const diffOut = git(['diff-tree', '--no-commit-id', '--name-status', '-r', parentTree, newTree], { cwd, allowFail: true });
146
147
  if (diffOut) {
@@ -166,14 +167,25 @@ function createGitSnapshot(projectDir, cfg, opts = {}) {
166
167
  for (const line of numstatOut.split('\n').filter(Boolean)) {
167
168
  const [add, del, ...nameParts] = line.split('\t');
168
169
  const fname = nameParts.join('\t');
169
- if (add !== '-') stats[fname] = `+${add} -${del}`;
170
+ stats[fname] = { added: add === '-' ? 0 : parseInt(add, 10), deleted: del === '-' ? 0 : parseInt(del, 10) };
170
171
  }
171
172
  }
172
173
 
174
+ // Build structured changedFiles array
175
+ changedFiles = [];
176
+ const ACTION_MAP = { M: 'modified', A: 'added', D: 'deleted', R: 'renamed' };
177
+ for (const [key, arr] of Object.entries(groups)) {
178
+ for (const f of arr) {
179
+ const s = stats[f] || { added: 0, deleted: 0 };
180
+ changedFiles.push({ path: f, action: ACTION_MAP[key], added: s.added, deleted: s.deleted });
181
+ }
182
+ }
183
+ changedFiles.sort((a, b) => (b.added + b.deleted) - (a.added + a.deleted));
184
+
173
185
  function fmtFiles(arr) {
174
186
  return arr.slice(0, 5).map(f => {
175
187
  const s = stats[f];
176
- return s ? `${f} (${s})` : f;
188
+ return s ? `${f} (+${s.added} -${s.deleted})` : f;
177
189
  }).join(', ');
178
190
  }
179
191
 
@@ -191,6 +203,7 @@ function createGitSnapshot(projectDir, cfg, opts = {}) {
191
203
  changedCount = files.length;
192
204
  const sample = files.slice(0, 5).join(', ');
193
205
  incrementalSummary = `Added ${files.length}: ${sample}${files.length > 5 ? ', ...' : ''}`;
206
+ changedFiles = files.map(f => ({ path: f, action: 'added', added: 0, deleted: 0 }));
194
207
  }
195
208
  }
196
209
 
@@ -226,6 +239,7 @@ function createGitSnapshot(projectDir, cfg, opts = {}) {
226
239
  shortHash: commitHash.substring(0, 7),
227
240
  fileCount,
228
241
  changedCount,
242
+ changedFiles,
229
243
  incrementalSummary,
230
244
  secretsExcluded: secretsExcluded.length > 0 ? secretsExcluded : undefined,
231
245
  };
@@ -248,6 +262,20 @@ function createGitSnapshot(projectDir, cfg, opts = {}) {
248
262
  * @param {string} [opts.backupDir] - Override backup directory (default: projectDir/.cursor-guard-backup)
249
263
  * @returns {{ status: 'created'|'empty'|'error', timestamp?: string, fileCount?: number, snapshotDir?: string, error?: string }}
250
264
  */
265
+ function findPreviousSnapshot(backupDir) {
266
+ try {
267
+ const entries = fs.readdirSync(backupDir)
268
+ .filter(e => /^\d{8}_\d{6}/.test(e))
269
+ .sort()
270
+ .reverse();
271
+ for (const e of entries) {
272
+ const full = path.join(backupDir, e);
273
+ if (fs.statSync(full).isDirectory()) return full;
274
+ }
275
+ } catch { /* no previous snapshots */ }
276
+ return null;
277
+ }
278
+
251
279
  function createShadowCopy(projectDir, cfg, opts = {}) {
252
280
  const backupDir = opts.backupDir || path.join(projectDir, '.cursor-guard-backup');
253
281
  let ts = formatTimestamp(new Date());
@@ -264,15 +292,37 @@ function createShadowCopy(projectDir, cfg, opts = {}) {
264
292
  }
265
293
  fs.mkdirSync(snapDir, { recursive: true });
266
294
 
295
+ const prevSnapDir = findPreviousSnapshot(backupDir);
296
+
267
297
  const allFiles = walkDir(projectDir, projectDir);
268
298
  const files = filterFiles(allFiles, cfg);
269
299
 
270
300
  let copied = 0;
301
+ let linked = 0;
271
302
  for (const f of files) {
272
303
  const dest = path.join(snapDir, f.rel);
273
304
  fs.mkdirSync(path.dirname(dest), { recursive: true });
274
305
  try {
275
- fs.copyFileSync(f.full, dest);
306
+ let didLink = false;
307
+ if (prevSnapDir) {
308
+ const prevFile = path.join(prevSnapDir, f.rel);
309
+ try {
310
+ const srcStat = fs.statSync(f.full);
311
+ const prevStat = fs.statSync(prevFile);
312
+ if (srcStat.size === prevStat.size && Math.abs(srcStat.mtimeMs - prevStat.mtimeMs) < 1) {
313
+ fs.linkSync(prevFile, dest);
314
+ didLink = true;
315
+ linked++;
316
+ }
317
+ } catch { /* prev file missing or stat error — fall through to copy */ }
318
+ }
319
+ if (!didLink) {
320
+ fs.copyFileSync(f.full, dest);
321
+ try {
322
+ const srcStat = fs.statSync(f.full);
323
+ fs.utimesSync(dest, srcStat.atime, srcStat.mtime);
324
+ } catch { /* non-critical: mtime preservation failed */ }
325
+ }
276
326
  copied++;
277
327
  } catch { /* skip unreadable */ }
278
328
  }
@@ -282,7 +332,7 @@ function createShadowCopy(projectDir, cfg, opts = {}) {
282
332
  return { status: 'empty', timestamp: ts };
283
333
  }
284
334
 
285
- return { status: 'created', timestamp: ts, fileCount: copied, snapshotDir: snapDir };
335
+ return { status: 'created', timestamp: ts, fileCount: copied, linkedCount: linked, snapshotDir: snapDir };
286
336
  } catch (e) {
287
337
  return { status: 'error', error: e.message };
288
338
  }
@@ -137,6 +137,7 @@ const DEFAULT_CONFIG = {
137
137
  git_retention: { enabled: false, mode: 'count', days: 30, max_count: 200 },
138
138
  proactive_alert: true,
139
139
  alert_thresholds: { files_per_window: 20, window_seconds: 10, cooldown_seconds: 60 },
140
+ always_watch: false,
140
141
  };
141
142
 
142
143
  function loadConfig(projectDir) {
@@ -221,6 +222,11 @@ function loadConfig(projectDir) {
221
222
  if (typeof raw.alert_thresholds.cooldown_seconds === 'number' && raw.alert_thresholds.cooldown_seconds > 0)
222
223
  cfg.alert_thresholds.cooldown_seconds = raw.alert_thresholds.cooldown_seconds;
223
224
  }
225
+ if (raw.always_watch === true) {
226
+ cfg.always_watch = true;
227
+ } else if (raw.always_watch !== undefined && raw.always_watch !== false) {
228
+ warnings.push(`always_watch should be a boolean, got ${JSON.stringify(raw.always_watch)} — using default (false)`);
229
+ }
224
230
  return { cfg, loaded: true, error: null, warnings };
225
231
  } catch (e) {
226
232
  return { cfg, loaded: false, error: e.message };
@@ -15,10 +15,35 @@ const { getBackupStatus } = require('../lib/core/status');
15
15
  const { getDashboard } = require('../lib/core/dashboard');
16
16
  const { loadActiveAlert } = require('../lib/core/anomaly');
17
17
 
18
- const { gitDir: getGitDir } = require('../lib/utils');
18
+ const { loadConfig, gitDir: getGitDir } = require('../lib/utils');
19
19
 
20
20
  const pkg = require('../../package.json');
21
21
 
22
+ // ── Auto-watch manager for always_watch mode ─────────────────
23
+
24
+ const watchedProjects = new Map();
25
+
26
+ function ensureWatcher(projectPath) {
27
+ if (watchedProjects.has(projectPath)) return;
28
+ const { cfg, loaded } = loadConfig(projectPath);
29
+ if (!loaded || !cfg.always_watch) return;
30
+ if (isWatcherRunning(projectPath)) {
31
+ watchedProjects.set(projectPath, { pid: null, external: true });
32
+ return;
33
+ }
34
+ try {
35
+ const { spawn } = require('child_process');
36
+ const watcherScript = path.join(__dirname, '..', 'bin', 'cursor-guard-backup.js');
37
+ const child = spawn(process.execPath, [watcherScript, '--path', projectPath], {
38
+ detached: true,
39
+ stdio: 'ignore',
40
+ windowsHide: true,
41
+ });
42
+ child.unref();
43
+ watchedProjects.set(projectPath, { pid: child.pid, external: false });
44
+ } catch { /* spawn failed — non-fatal */ }
45
+ }
46
+
22
47
  // ── Alert injection helper ──────────────────────────────────────
23
48
 
24
49
  function injectAlert(projectPath, result) {
@@ -78,6 +103,7 @@ server.tool(
78
103
  },
79
104
  async ({ path: projectPath }) => {
80
105
  const resolved = path.resolve(projectPath);
106
+ ensureWatcher(resolved);
81
107
  const result = injectAlert(resolved, runDiagnostics(resolved));
82
108
  injectWatcherWarning(resolved, result);
83
109
  return {
@@ -102,6 +128,7 @@ server.tool(
102
128
  },
103
129
  async ({ path: projectPath, file, before, limit }) => {
104
130
  const resolved = path.resolve(projectPath);
131
+ ensureWatcher(resolved);
105
132
  const result = injectAlert(resolved, listBackups(resolved, { file, before, limit }));
106
133
  return {
107
134
  content: [{
@@ -128,7 +155,7 @@ server.tool(
128
155
  },
129
156
  async ({ path: projectPath, strategy, message, scope, intent, agent, session }) => {
130
157
  const resolved = path.resolve(projectPath);
131
- const { loadConfig } = require('../lib/utils');
158
+ ensureWatcher(resolved);
132
159
  const { cfg } = loadConfig(resolved);
133
160
 
134
161
  if (scope === 'all') {
@@ -182,6 +209,7 @@ server.tool(
182
209
  },
183
210
  async ({ path: projectPath, file, source, preserve_current }) => {
184
211
  const resolved = path.resolve(projectPath);
212
+ ensureWatcher(resolved);
185
213
  const result = injectAlert(resolved, restoreFile(resolved, file, source, {
186
214
  preserveCurrent: preserve_current,
187
215
  }));
@@ -209,6 +237,7 @@ server.tool(
209
237
  },
210
238
  async ({ path: projectPath, source, preview, preserve_current, clean_untracked }) => {
211
239
  const resolved = path.resolve(projectPath);
240
+ ensureWatcher(resolved);
212
241
 
213
242
  if (preview !== false) {
214
243
  const result = injectAlert(resolved, previewProjectRestore(resolved, source));
@@ -235,6 +264,7 @@ server.tool(
235
264
  },
236
265
  async ({ path: projectPath, dry_run }) => {
237
266
  const resolved = path.resolve(projectPath);
267
+ ensureWatcher(resolved);
238
268
  const result = injectAlert(resolved, runFixes(resolved, { dryRun: !!dry_run }));
239
269
  return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
240
270
  }
@@ -250,6 +280,7 @@ server.tool(
250
280
  },
251
281
  async ({ path: projectPath }) => {
252
282
  const resolved = path.resolve(projectPath);
283
+ ensureWatcher(resolved);
253
284
  const result = injectAlert(resolved, getBackupStatus(resolved));
254
285
  return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
255
286
  }
@@ -265,6 +296,7 @@ server.tool(
265
296
  },
266
297
  async ({ path: projectPath }) => {
267
298
  const resolved = path.resolve(projectPath);
299
+ ensureWatcher(resolved);
268
300
  const result = injectAlert(resolved, getDashboard(resolved));
269
301
  return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
270
302
  }
@@ -280,6 +312,7 @@ server.tool(
280
312
  },
281
313
  async ({ path: projectPath }) => {
282
314
  const resolved = path.resolve(projectPath);
315
+ ensureWatcher(resolved);
283
316
  const alert = loadActiveAlert(resolved);
284
317
  const result = alert
285
318
  ? { active: true, alert }