cursor-guard 4.5.4 → 4.5.6

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.4`(V4 最终版)
7
- > **文档状态**:`V2` ~ `V4.5.4` 已完成交付(含 V5 intent/audit 基础),`V5` 主体规划中
6
+ > **当前版本**:`V4.5.6`(V4 最终版)
7
+ > **文档状态**:`V2` ~ `V4.5.6` 已完成交付(含 V5 intent/audit 基础),`V5` 主体规划中
8
8
 
9
9
  ## 阅读导航
10
10
 
@@ -462,6 +462,7 @@ V4 经过 4 轮系统性代码审查,修复了以下关键问题:
462
462
  | V4.5.2 | **告警结构化文件列表**:见下方详细说明 | ✅ |
463
463
  | V4.5.3 | **告警历史 UX 优化 + 备份结构化文件表格**:见下方详细说明 | ✅ |
464
464
  | V4.5.4 | **Shadow 硬链接增量优化 + always_watch 强保护模式**:见下方详细说明 | ✅ |
465
+ | V4.5.6 | **Bug 修复 + 告警 UX + init 优化**:见下方详细说明 | ✅ |
465
466
 
466
467
  #### V4.4.1 详细内容
467
468
 
@@ -619,6 +620,38 @@ V4 经过 4 轮系统性代码审查,修复了以下关键问题:
619
620
 
620
621
  > 这个特性直接填补了 V4 最大的架构缺口——"Watcher 停止 = 裸奔"。详见下方"V4 遗留的架构缺口"中该条目已标记为 **已解决**。
621
622
 
623
+ #### V4.5.6 详细内容
624
+
625
+ **Bug 修复**:
626
+
627
+ | 问题 | 严重度 | 根因 | 修复 |
628
+ |------|--------|------|------|
629
+ | Shadow hard-link 永远为 0 | 中 | `createShadowCopy` 中 `fs.mkdirSync(snapDir)` 在 `findPreviousSnapshot(backupDir)` 之前执行。新创建的空目录时间戳最新,被当作"上一个快照"返回,导致硬链接永远找不到文件 | 调换两行顺序:先 `findPreviousSnapshot`,再 `mkdirSync` |
630
+ | changedFiles 包含 ignore 路径 | 低 | `createGitSnapshot` 的 `diff-tree` 解析未按 `cfg.ignore` 过滤,`.cursor/` 等被忽略路径的文件混入告警的 files 数组和 changedCount | 在 diff-tree 解析循环中增加 `matchesAny(cfg.ignore, fileName)` 过滤;initial snapshot 的 `ls-tree` 列表同样过滤 |
631
+
632
+ **告警 UX 优化**:
633
+
634
+ | 改进 | 说明 |
635
+ |------|------|
636
+ | 告警详情增加文件类型摘要 | 活跃告警和历史告警均显示 "新增 N · 修改 N · 删除 N" 分类统计(`alertFileBreakdown` 辅助函数) |
637
+ | 活跃告警增加建议操作 | 告警卡片底部显示 "建议检查近期变更,并考虑手动创建快照" 提示文字 |
638
+ | i18n 补全 | 新增 `alert.breakdown`、`alert.suggestion` 双语 key |
639
+
640
+ **Dashboard UX 优化**:
641
+
642
+ | 改进 | 说明 |
643
+ |------|------|
644
+ | 文件详情弹出框(Modal) | 告警的"展开文件详情"改为全屏居中 Modal 弹窗,替代原本卡片内联展开(太小看不清)。Modal 支持排序、可滚动、720px 宽 |
645
+ | 每个文件可复制恢复命令 | Modal 和 Drawer 的文件表格中,每行增加"复制命令"按钮,一键生成 `restore_file({ path, file, source })` MCP 命令 |
646
+ | 备份表补全操作意图 | 备份表 summary 列:有 intent 时显示 "操作意图: xxx"(蓝色标签),无 intent 时显示 trigger 类型(自动/手动/恢复前)灰色文字 |
647
+
648
+ **工具链优化**:
649
+
650
+ | 改进 | 说明 |
651
+ |------|------|
652
+ | `cursor-guard-init` 自动创建配置 | init 流程新增 Step 4/5:若 `.cursor-guard.json` 不存在,自动从 `cursor-guard.example.json` 复制为项目根默认配置。升级场景下保留现有配置 |
653
+ | `backup_interval_seconds` 兼容别名 | `loadConfig` 支持 `backup_interval_seconds` 作为 `auto_backup_interval_seconds` 的别名(带 deprecation 警告) |
654
+
622
655
  #### V4.5.x 新增配置参考
623
656
 
624
657
  | 字段 | 类型 | 默认值 | 引入版本 | 说明 |
@@ -663,8 +696,9 @@ V4 经过 4 轮系统性代码审查,修复了以下关键问题:
663
696
 
664
697
  ### 进入 V5 的衡量标准
665
698
 
666
- - V4 的主动提醒功能误报率 < 10%
667
- - 健康看板的信息准确度经用户验证
699
+ - V4 的主动提醒功能误报率 < 10% — V4.5.0 修复了计数数据源(`diff-tree` 替代 `porcelain`)
700
+ - 健康看板的信息准确度经用户验证 — V4.5.2-V4.5.3 增加结构化文件列表,数据来源一致
701
+ - ✅ `always_watch` 基础能力可用 — V4.5.4 已实现(spawn 独立进程版)
668
702
  - 多 Agent 并发编辑已成为用户的真实场景(不是假设)
669
703
  - MCP 协议的 notification / resource subscription 机制已相对成熟
670
704
 
@@ -674,10 +708,12 @@ V4 经过 4 轮系统性代码审查,修复了以下关键问题:
674
708
 
675
709
  | 项目 | 内容 |
676
710
  |---|---|
677
- | 状态 | 中期主线 🚀 |
711
+ | 状态 | 中期主线 🚀(V4.5.x 已完成基础设施前置) |
678
712
  | 定位 | 从保护"一次 AI 写操作"升级为管理"整条 AI 代码变更链路":事前预防、事中判断、事后追溯、按事件恢复。 |
679
713
  | 产品定义 | `AI Code Change Control Layer` |
680
714
  | 关键词 | `intent` / `impact` / `audit` / `restore-by-event` |
715
+ | 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) |
716
+ | V5 核心差异 | **embedded watcher(同进程)** + **begin_edit 意图-变更原子绑定** — 这两项是 V4 无法做到的,需要架构升级 |
681
717
 
682
718
  ### 为什么需要这一步
683
719
 
@@ -698,29 +734,35 @@ V2-V4 已经证明,cursor-guard 的价值不只是"多留一个备份点",
698
734
 
699
735
  V5 不是"三个方向选一个",而是把下面这条链路做完整:
700
736
 
701
- 1. **Intent**:谁正准备改这里?
702
- 2. **Impact**:这次改动会波及哪里?
703
- 3. **Audit**:这段代码何时、被谁、因何改的?
704
- 4. **Restore**:出事后该回退哪次操作?
737
+ | 步骤 | 核心问题 | V4 已有基础 | V5 新增 |
738
+ |------|---------|------------|---------|
739
+ | **Intent** | 谁正准备改这里? | ✅ `snapshot_now` intent/agent/session 参数 | `begin_edit` 实时注册 + 文件路径绑定 |
740
+ | **Impact** | 这次改动波及哪里? | ✅ 结构化 `changedFiles` + `getBackupFiles` | 符号级分析(导出/引用/测试波及) |
741
+ | **Audit** | 何时、被谁、因何改的? | ✅ Git trailer 审计元数据 + 仪表盘展示 | 独立 JSONL 审计存储 + 结构化事件查询 |
742
+ | **Restore** | 出事回退哪次操作? | ✅ `restore_file` / `restore_project` + pre-restore 快照 | `restore_from_event`(按事件 ID 直接恢复) |
705
743
 
706
- 只有这四步连起来,cursor-guard 才不是"备份工具",而是"变更控制层"。
744
+ 只有这四步连起来,cursor-guard 才不是"备份工具",而是"变更控制层"。V4 已铺好每一步的基础设施,V5 的核心价值是把它们**原子化串联**——通过同进程 embedded watcher + 文件路径意图绑定,让 Intent → Impact → Audit → Restore 在一个事件链里自动完成。
707
745
 
708
746
  ### V5.0 可执行功能清单
709
747
 
710
- | 模块 / 能力 | AI 要做什么 | 建议产物 | 完成标准 |
748
+ > **注**:部分 V5 规划能力已在 V4.5.x 提前落地,下表用 标注。V5 的核心差异化在于 **embedded watcher(同进程)** 和 **begin_edit 意图-变更原子绑定**。
749
+
750
+ | 模块 / 能力 | 说明 | V4 已落地 | V5 新增 |
711
751
  |---|---|---|---|
712
- | `intent registry` | 在高风险写入前注册编辑意图,记录 agent、会话、工作区、分支、目标文件、风险级别 | `core/intent.*` 或同等模块 | 能列出活跃会话,能释放会话,能查到谁准备改哪个文件。**基础版已在 V4.3.3 落地**:`snapshot_now` 支持 `intent` / `agent` / `session` 参数,存储为 Git commit trailer,仪表盘可展示。**V4.3.5 修复**:summary 改用 `diff-tree` 增量对比,确保元数据准确 |
713
- | `pre-edit snapshot` | 在每次高风险 AI 写入前创建 `refs/guard/pre-edit/*` 恢复点 | `refs/guard/pre-edit/<session>/<seq>` | 任意一条 AI 编辑事件都能关联到写前快照 |
714
- | `conflict detection` | 先做文件路径级冲突检测,再预留符号级增强位 | `detectConflicts()` / `listConflicts()` | 两个会话同时改重叠文件时能给出 advisory warning |
715
- | `audit store` | append-only 方式保存 AI 编辑事件 | 默认本地 `JSONL`;后续可升级 `SQLite` | 能按文件 / 会话 / agent / 时间 / 风险级别查询。**雏形已在 V4.3.0-V4.3.3 落地**:审计元数据通过 Git commit trailer 持久化,`listBackups` 可按 trigger/intent/agent/session 解析 |
716
- | `restore by event` | 允许从一条审计事件直接跳转并执行恢复 | `restore_from_event` | 给定 `event_id` 可以定位 `before_ref` `restore_ref` |
717
- | `impact set` | 为高风险编辑记录受影响文件 / 符号 / 测试集合 | `impact_set` 字段 | 查询事件时能看到"这次改动可能波及哪里" |
718
- | `MCP / CLI surface` | 暴露最小可用接口给 Agent 和终端 | `register_intent` / `list_active_intents` / `audit_query` / `get_event` / `restore_from_event` | AI 不需要拼复杂 shell,就能完成查询与恢复 |
719
- | `dashboard / doctor` | 把活跃会话、冲突告警、最近 AI 事件纳入诊断和看板 | `dashboard` / `doctor` 扩展字段 | 用户能看见"现在谁在改、最近改了什么、哪里有冲突" |
720
- | `always_watch` | 配置项 `"always_watch": true`,MCP server 启动时自动内嵌 watcher 循环;用户可选两种保护模式:**轻量模式**(默认,AI 手动 `snapshot_now`)vs **强保护模式**(watcher 始终在后台,所有变更自动备份) | `.cursor-guard.json` 配置 + MCP server 启动逻辑 | MCP server 启动后,`always_watch: true` 的项目自动有 watcher 保护,无需额外命令;选择权在用户 |
721
- | `embedded watcher` | **消灭进程边界**:不再是独立后台进程,而是 MCP server 同进程内的 watcher 循环。同进程 = IPC、无文件桥接、无并发竞争。检测到文件变更时自动创建 snapshot,不依赖 AI 手动调用 | MCP server 内部模块 | watcher 停止 = 裸奔的保护缺口彻底消除;AI 忘记 snapshot 也有兜底 |
722
- | `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 按文件路径消歧,不按时间顺序 |
723
- | `tests / docs` | 为事件链路补齐单测、集成测试和文档 | tests + schema docs | V5.0 的所有核心事件和恢复路径都有测试覆盖 |
752
+ | `intent registry` | 注册编辑意图(agent、会话、目标文件、风险级别) | V4.3.3:`snapshot_now` 支持 `intent/agent/session`,Git trailer 存储,仪表盘展示 | 升级为 `begin_edit` MCP 工具,内存 Map 管理活跃会话,支持文件路径级冲突检测 |
753
+ | `pre-edit snapshot` | 每次高风险写入前创建恢复点 | V4:`restore_file` / `restore_project` 自动创建 `refs/guard/pre-restore/*` | 新增 `refs/guard/pre-edit/<session>/<seq>`,按会话隔离 |
754
+ | `conflict detection` | 并发编辑冲突告警 | | 新增:`begin_edit` 时检测文件路径重叠,advisory warning |
755
+ | `audit store` | 审计事件存储与查询 | V4.3.0-V4.3.3Git commit trailer 持久化(trigger/intent/agent/session),`listBackups` 可解析 | 升级为独立 JSONL 审计存储,支持按事件 ID / 文件 / 会话 / 风险级别查询 |
756
+ | `restore by event` | 从审计事件直接恢复 | | 新增:`restore_from_event(event_id)` 定位 `before_ref` 执行恢复 |
757
+ | `impact set` | 记录变更波及范围 | V4.5.2:告警携带结构化文件列表 `[{path, action, added, deleted}]` | 升级:增加符号级分析(导出/引用变更检测) |
758
+ | `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` |
759
+ | `dashboard / doctor` | 看板和诊断 | V4.2-V4.5.3:完整 Web 仪表盘,告警卡片+历史、备份表格+抽屉、诊断面板、结构化文件表格 | 扩展:活跃会话面板、冲突告警卡片、AI 事件时间线 |
760
+ | `always_watch` | 强保护模式 | ✅ **V4.5.4 已完整实现**:`always_watch: true` → MCP server 首次 tool 调用自动 spawn watcher 进程,`ensureWatcher()` + `watchedProjects` Map + 锁文件互斥兼容 | V5 进一步升级为 embedded watcher(同进程内嵌,非独立进程) |
761
+ | `embedded watcher` | 同进程 watcher 循环 | 部分:V4.5.4 `always_watch` 通过 spawn 独立子进程实现(解决了"裸奔"问题,但仍是跨进程) | **核心差异**:MCP server 内嵌 watcher 循环,同进程=无 IPC、无竞争。检测变更时按文件路径匹配 `begin_edit` 中的 intent |
762
+ | `begin_edit` / `end_edit` | 意图-变更原子绑定 | — | **核心新增**:`begin_edit({ intent, files[], agent, session })` → 内存 `Map<session, EditScope>` watcher 按文件路径匹配 → auto-backup 带完整上下文。`end_edit` 或 TTL 5min 自动清除 |
763
+ | `Shadow 增量优化` | 节省磁盘 I/O | **V4.5.4 已完整实现**:硬链接未变文件(mtime+size 比较),mtime 保留,跨卷容错,日志显示 linkedCount | |
764
+ | `备份结构化文件数据` | 结构化 files 数组 | ✅ **V4.5.2-V4.5.3 已完整实现**:snapshot changedFiles、anomaly alert files、`getBackupFiles` API、前端 mini 表格+抽屉可排序表格 | — |
765
+ | `tests / docs` | 测试和文档 | ✅ V4:138 测试全过(utils 39 + core 78 + MCP 21) | 为 V5 新增事件链路补齐测试 |
724
766
 
725
767
  ### V5 核心设计:Embedded Watcher + 文件路径意图绑定
726
768
 
@@ -789,8 +831,15 @@ MCP Server 进程(知道 intent) ←——×——→ Watcher 进程(知
789
831
  #### 实现分期
790
832
 
791
833
  ```
792
- Phase 1 (V5.0):
793
- ├── always_watch: true → MCP server 内嵌 watcher 循环
834
+ Phase 0 (V4.5.x — ✅ 已完成):
835
+ ├── always_watch: true → MCP server 自动 spawn watcher 子进程(V4.5.4)
836
+ ├── 结构化文件数据:snapshot changedFiles + alert files + getBackupFiles API(V4.5.2-V4.5.3)
837
+ ├── Shadow 硬链接增量优化(V4.5.4)
838
+ ├── 告警历史 UX + 备份文件表格化(V4.5.3)
839
+ └── intent/agent/session 基础支持(V4.3.3)
840
+
841
+ Phase 1 (V5.0 — 核心差异化):
842
+ ├── always_watch 升级:从 spawn 独立进程 → MCP server 内嵌 watcher 循环(同进程)
794
843
  ├── 新增 begin_edit / end_edit MCP 工具
795
844
  ├── 内存 Map<session, EditScope> 管理活跃编辑意图
796
845
  └── watcher 变更检测时查 Map,按文件路径匹配 intent → 写入 commit trailer
@@ -938,7 +987,8 @@ V5 的关键不是"多打一行日志",而是建立完整证据链:
938
987
 
939
988
  ### V5 完成标志(Definition of Done)
940
989
 
941
- - `always_watch: true` 配置生效后,MCP server 启动自动内嵌 watcher 循环,用户无需额外命令
990
+ - ~~`always_watch: true` 配置生效后,用户无需额外命令~~ **V4.5.4 已完成**(spawn 子进程方式)
991
+ - V5 升级:`always_watch` 从 spawn 子进程升级为 MCP server 内嵌 watcher 循环(同进程,无 IPC)
942
992
  - `begin_edit` / `end_edit` MCP 工具可用,AI 能声明编辑意图和目标文件
943
993
  - embedded watcher 的自动备份能通过文件路径匹配关联 `begin_edit` 中的 intent/agent/session
944
994
  - 无 `begin_edit` 时自动备份行为与 V4 一致(无退化)
@@ -946,7 +996,8 @@ V5 的关键不是"多打一行日志",而是建立完整证据链:
946
996
  - 用户能查询最近一次 AI 编辑的完整上下文
947
997
  - 给定 `event_id` 能找到对应快照并执行恢复
948
998
  - 两个活跃会话改同一文件时,系统能稳定给出冲突告警
949
- - dashboard / doctor 能展示最近 AI 事件、活跃会话和未解决冲突
999
+ - ~~dashboard / doctor 展示告警、备份详情~~ ✅ **V4.2-V4.5.3 已完成**(Web 仪表盘、告警卡片+历史、结构化文件表格、可排序抽屉)
1000
+ - V5 扩展:dashboard 增加活跃会话面板、冲突告警卡片、AI 事件时间线
950
1001
 
951
1002
  ### 进入 V6 的衡量标准
952
1003
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursor-guard",
3
- "version": "4.5.4",
3
+ "version": "4.5.6",
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",
@@ -64,7 +64,7 @@ if (fs.existsSync(configPath)) {
64
64
  }
65
65
 
66
66
  // Step 1: Copy skill files (excluding node_modules and .git)
67
- console.log(' [1/4] Copying skill files...');
67
+ console.log(' [1/5] Copying skill files...');
68
68
  if (fs.existsSync(skillTarget)) {
69
69
  fs.rmSync(skillTarget, { recursive: true, force: true });
70
70
  }
@@ -72,7 +72,7 @@ copyRecursive(skillSource, skillTarget);
72
72
  console.log(' Done.');
73
73
 
74
74
  // Step 2: Install MCP dependencies in skill directory
75
- console.log(' [2/4] Installing MCP dependencies...');
75
+ console.log(' [2/5] Installing MCP dependencies...');
76
76
  try {
77
77
  const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
78
78
  execFileSync(npmCmd, ['install', '--omit=dev', '--ignore-scripts'], {
@@ -86,7 +86,7 @@ try {
86
86
  }
87
87
 
88
88
  // Step 3: Add .gitignore entries for skill node_modules
89
- console.log(' [3/4] Updating .gitignore...');
89
+ console.log(' [3/5] Updating .gitignore...');
90
90
  const gitignorePath = path.join(projectDir, '.gitignore');
91
91
  const entries = ['node_modules/', '.cursor/skills/**/node_modules/'];
92
92
  let gitignoreUpdated = false;
@@ -106,8 +106,22 @@ if (!isGlobal) {
106
106
  console.log(' Skipped (global install, not inside a project).');
107
107
  }
108
108
 
109
- // Step 4: Summary
110
- console.log(' [4/4] Verifying...');
109
+ // Step 4: Create default config if missing
110
+ console.log(' [4/5] Checking .cursor-guard.json...');
111
+ if (!fs.existsSync(configPath)) {
112
+ const examplePath = path.join(skillTarget, 'references', 'cursor-guard.example.json');
113
+ if (fs.existsSync(examplePath)) {
114
+ fs.copyFileSync(examplePath, configPath);
115
+ console.log(' Created .cursor-guard.json with default settings.');
116
+ } else {
117
+ console.log(' Warning: example config not found, skipping.');
118
+ }
119
+ } else {
120
+ console.log(' Already exists — preserved.');
121
+ }
122
+
123
+ // Step 5: Summary
124
+ console.log(' [5/5] Verifying...');
111
125
  const serverExists = fs.existsSync(path.join(skillTarget, 'references', 'mcp', 'server.js'));
112
126
  const sdkExists = fs.existsSync(path.join(skillTarget, 'node_modules', '@modelcontextprotocol', 'sdk'));
113
127
  const skillMdExists = fs.existsSync(path.join(skillTarget, 'SKILL.md'));
@@ -130,8 +144,7 @@ console.log(' If MCP was already configured, restart Cursor (or Ctrl+Shift+P ->
130
144
  console.log(' "Developer: Reload Window") to load the updated MCP server.\n');
131
145
  console.log(' Next steps:');
132
146
  console.log(' 1. The skill activates automatically in Cursor Agent conversations.');
133
- console.log(' 2. (Optional) Copy example config to project root:');
134
- console.log(` cp "${path.join(skillTarget, 'references', 'cursor-guard.example.json')}" .cursor-guard.json`);
147
+ console.log(' 2. (Optional) Edit .cursor-guard.json to customize protect/ignore patterns.');
135
148
  console.log(' 3. (Optional) Enable MCP — add to .cursor/mcp.json:');
136
149
  console.log(` { "mcpServers": { "cursor-guard": { "command": "node", "args": ["${path.join(skillTarget, 'references', 'mcp', 'server.js').replace(/\\/g, '/')}"] } } }`);
137
150
  console.log(' 4. (Optional) Start auto-backup:');
@@ -56,6 +56,13 @@ const I18N = {
56
56
  'alert.action.added': 'Added',
57
57
  'alert.action.deleted': 'Deleted',
58
58
  'alert.action.renamed': 'Renamed',
59
+ 'alert.breakdown': '{added} added, {modified} modified, {deleted} deleted',
60
+ 'alert.suggestion': 'Check recent changes and consider creating a manual snapshot',
61
+ 'alert.viewFiles': 'View file details ({n} files)',
62
+ 'modal.alertFiles': 'Alert File Details',
63
+ 'modal.col.restore': 'Restore',
64
+ 'modal.copyRestore': 'Copy cmd',
65
+ 'modal.copied': 'Copied!',
59
66
 
60
67
  'backups.gitCommits': 'Git Commits',
61
68
  'backups.shadowSnapshots': 'Shadow Snapshots',
@@ -264,6 +271,13 @@ const I18N = {
264
271
  'alert.action.added': '新增',
265
272
  'alert.action.deleted': '删除',
266
273
  'alert.action.renamed': '重命名',
274
+ 'alert.breakdown': '新增 {added} · 修改 {modified} · 删除 {deleted}',
275
+ 'alert.suggestion': '建议检查近期变更,并考虑手动创建快照',
276
+ 'alert.viewFiles': '查看文件详情({n} 个文件)',
277
+ 'modal.alertFiles': '告警文件详情',
278
+ 'modal.col.restore': '恢复',
279
+ 'modal.copyRestore': '复制命令',
280
+ 'modal.copied': '已复制!',
267
281
 
268
282
  'backups.gitCommits': 'Git 提交数',
269
283
  'backups.shadowSnapshots': '影子快照',
@@ -846,19 +860,32 @@ function renderWatcherCard(watcher) {
846
860
  `;
847
861
  }
848
862
 
863
+ function alertFileBreakdown(files) {
864
+ if (!Array.isArray(files) || files.length === 0) return '';
865
+ let added = 0, modified = 0, deleted = 0;
866
+ for (const f of files) {
867
+ if (f.action === 'added') added++;
868
+ else if (f.action === 'deleted') deleted++;
869
+ else modified++;
870
+ }
871
+ return t('alert.breakdown', { added, modified, deleted });
872
+ }
873
+
849
874
  function renderAlertCard(alerts) {
850
875
  const el = $('#card-alert');
851
876
  if (!alerts?.active) {
852
877
  let historyHtml = '';
853
878
  if (state.alertHistory.length > 0) {
854
879
  const count = state.alertHistory.length;
855
- const rows = state.alertHistory.slice(-5).reverse().map(h =>
856
- `<div class="alert-history-row text-sm text-muted">
857
- <span>${esc(formatTime(h.timestamp))}</span>
880
+ const rows = state.alertHistory.slice(-5).reverse().map(h => {
881
+ const breakdown = alertFileBreakdown(h.files);
882
+ return `<div class="alert-history-row text-sm text-muted">
883
+ <span class="alert-history-time">${esc(formatTime(h.timestamp))}</span>
858
884
  <span>${t('alert.detail', { count: h.fileCount, window: h.windowSeconds, threshold: h.threshold })}</span>
885
+ ${breakdown ? `<span class="alert-history-breakdown">${esc(breakdown)}</span>` : ''}
859
886
  <span class="badge badge-expired">${t('alert.expired')}</span>
860
- </div>`
861
- ).join('');
887
+ </div>`;
888
+ }).join('');
862
889
  historyHtml = `
863
890
  <div class="alert-history-toggle-wrap">
864
891
  <button class="alert-history-toggle-btn text-sm text-muted" data-alert-history-toggle>${t('alert.historyCount', { n: count })}</button>
@@ -893,25 +920,9 @@ function renderAlertCard(alerts) {
893
920
  const files = Array.isArray(a.files) ? a.files : [];
894
921
  let filesHtml = '';
895
922
  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
923
  filesHtml = `
907
924
  <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>
925
+ <button class="alert-files-toggle" data-alert-files-modal>${t('alert.viewFiles', { n: files.length })}</button>
915
926
  </div>
916
927
  `;
917
928
  }
@@ -923,6 +934,8 @@ function renderAlertCard(alerts) {
923
934
  <div class="alert-detail-row"><span class="alert-detail-label">${t('alert.triggered')}</span><span>${esc(triggeredAt)}</span></div>
924
935
  <div class="alert-detail-row"><span class="alert-detail-label">${t('alert.expires')}</span><span class="alert-countdown">${esc(remainDisplay)}</span></div>
925
936
  <div class="alert-detail-row alert-numbers">${esc(detailText)}</div>
937
+ ${alertFileBreakdown(files) ? `<div class="alert-detail-row alert-breakdown text-sm">${esc(alertFileBreakdown(files))}</div>` : ''}
938
+ <div class="alert-detail-row alert-suggestion text-sm text-muted">${t('alert.suggestion')}</div>
926
939
  </div>
927
940
  ${filesHtml}
928
941
  `;
@@ -1046,11 +1059,14 @@ function formatSummaryCell(b) {
1046
1059
  line2 = `<div class="summary-restore-ctx">${label}<span class="text-mono">${esc(b.from)}</span> → <span class="text-mono">${esc(b.restoreTo)}</span></div>`;
1047
1060
  } else if (b.intent) {
1048
1061
  const intentShort = b.intent.length > 70 ? b.intent.substring(0, 67) + '...' : b.intent;
1049
- line2 = `<div class="summary-intent">${esc(intentShort)}</div>`;
1062
+ line2 = `<div class="summary-intent"><span class="summary-intent-label">${t('drawer.field.intent')}:</span> ${esc(intentShort)}</div>`;
1050
1063
  } else if (b.message && !b.message.startsWith('guard:')) {
1051
1064
  const msgShort = b.message.length > 70 ? b.message.substring(0, 67) + '...' : b.message;
1052
1065
  line2 = `<div class="summary-message">${esc(msgShort)}</div>`;
1053
1066
  }
1067
+ if (b.trigger && !line2) {
1068
+ line2 = `<div class="summary-trigger text-sm text-muted">${t('trigger.' + b.trigger)}</div>`;
1069
+ }
1054
1070
 
1055
1071
  let line3 = '';
1056
1072
  if (b.summary) {
@@ -1182,6 +1198,67 @@ function renderSectionError(elementId, msg) {
1182
1198
  el.innerHTML = `<div class="error-panel"><div class="error-icon">⚠</div><p>${esc(msg || t('error.sectionFailed'))}</p></div>`;
1183
1199
  }
1184
1200
 
1201
+ /* ── File Detail Modal ────────────────────────────────────── */
1202
+
1203
+ function openFileModal(title, files, projectPath, commitHash) {
1204
+ $('#file-modal-title').textContent = title;
1205
+ const body = $('#file-modal-body');
1206
+ let sortKey = 'changes';
1207
+ const render = () => {
1208
+ const sorted = [...files];
1209
+ if (sortKey === 'path') sorted.sort((a, b) => a.path.localeCompare(b.path));
1210
+ else if (sortKey === 'action') sorted.sort((a, b) => a.action.localeCompare(b.action));
1211
+ else sorted.sort((a, b) => (b.added + b.deleted) - (a.added + a.deleted));
1212
+
1213
+ const rows = sorted.map(f => {
1214
+ const restoreCmd = commitHash
1215
+ ? `restore_file({ path: "${projectPath || ''}", file: "${f.path}", source: "${commitHash}" })`
1216
+ : '';
1217
+ return `<tr>
1218
+ <td class="text-mono modal-file-path" title="${esc(f.path)}">${esc(f.path)}</td>
1219
+ <td>${formatFileActionBadge(f.action)}</td>
1220
+ <td class="text-mono modal-file-changes">+${f.added || 0} -${f.deleted || 0}</td>
1221
+ ${commitHash ? `<td><button class="modal-restore-btn" data-restore-cmd="${esc(restoreCmd)}">${t('modal.copyRestore')}</button></td>` : ''}
1222
+ </tr>`;
1223
+ }).join('');
1224
+
1225
+ body.innerHTML = `<table>
1226
+ <thead><tr>
1227
+ <th data-msort="path">${t('alert.col.file')} ↕</th>
1228
+ <th data-msort="action">${t('alert.col.action')} ↕</th>
1229
+ <th data-msort="changes">${t('alert.col.changes')} ↕</th>
1230
+ ${commitHash ? `<th>${t('modal.col.restore')}</th>` : ''}
1231
+ </tr></thead>
1232
+ <tbody>${rows}</tbody>
1233
+ </table>`;
1234
+ };
1235
+ render();
1236
+
1237
+ body.addEventListener('click', (e) => {
1238
+ const th = e.target.closest('[data-msort]');
1239
+ if (th) {
1240
+ sortKey = th.dataset.msort;
1241
+ render();
1242
+ return;
1243
+ }
1244
+ const btn = e.target.closest('[data-restore-cmd]');
1245
+ if (btn) {
1246
+ copyText(btn.dataset.restoreCmd);
1247
+ btn.textContent = t('modal.copied');
1248
+ btn.classList.add('copied');
1249
+ setTimeout(() => { btn.textContent = t('modal.copyRestore'); btn.classList.remove('copied'); }, 1500);
1250
+ }
1251
+ });
1252
+
1253
+ $('#file-modal-overlay').classList.add('active');
1254
+ document.body.style.overflow = 'hidden';
1255
+ }
1256
+
1257
+ function closeFileModal() {
1258
+ $('#file-modal-overlay').classList.remove('active');
1259
+ document.body.style.overflow = state.drawerOpen ? 'hidden' : '';
1260
+ }
1261
+
1185
1262
  /* ── Drawers ──────────────────────────────────────────────── */
1186
1263
 
1187
1264
  function openDrawer(name) {
@@ -1199,19 +1276,27 @@ function closeDrawer() {
1199
1276
  state.drawerOpen = null;
1200
1277
  }
1201
1278
 
1202
- function renderDrawerFilesTable(files, sortKey) {
1279
+ function renderDrawerFilesTable(files, sortKey, commitHash, projectPath) {
1203
1280
  const sorted = [...files];
1204
1281
  if (sortKey === 'path') sorted.sort((a, b) => a.path.localeCompare(b.path));
1205
1282
  else if (sortKey === 'action') sorted.sort((a, b) => a.action.localeCompare(b.action));
1206
1283
  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('');
1284
+ const hasRestore = !!commitHash;
1285
+ const rows = sorted.map(f => {
1286
+ const cmd = hasRestore ? `restore_file({ path: "${projectPath || ''}", file: "${f.path}", source: "${commitHash}" })` : '';
1287
+ return `<tr>
1288
+ <td class="text-mono drawer-file-path">${esc(f.path)}</td>
1289
+ <td>${formatFileActionBadge(f.action)}</td>
1290
+ <td class="text-mono drawer-file-changes">+${f.added} -${f.deleted}</td>
1291
+ ${hasRestore ? `<td><button class="modal-restore-btn" data-restore-cmd="${esc(cmd)}">${t('modal.copyRestore')}</button></td>` : ''}
1292
+ </tr>`;
1293
+ }).join('');
1210
1294
  return `<table class="drawer-files-table">
1211
1295
  <thead><tr>
1212
1296
  <th data-sort="path" class="drawer-sort-header">${t('alert.col.file')} ↕</th>
1213
1297
  <th data-sort="action" class="drawer-sort-header">${t('alert.col.action')} ↕</th>
1214
1298
  <th data-sort="changes" class="drawer-sort-header">${t('alert.col.changes')} ↕</th>
1299
+ ${hasRestore ? `<th>${t('modal.col.restore')}</th>` : ''}
1215
1300
  </tr></thead>
1216
1301
  <tbody>${rows}</tbody>
1217
1302
  </table>`;
@@ -1292,28 +1377,40 @@ function openRestoreDrawer(backup) {
1292
1377
  wrap.classList.toggle('hidden');
1293
1378
  });
1294
1379
 
1380
+ const projPath = backup.path || state.pageData?.status?.config?.path || '';
1381
+
1295
1382
  // Lazy-load full file list for summary section
1296
1383
  if (backup.summary && isGit && hash) {
1297
1384
  let currentFiles = [];
1298
1385
  let currentSort = 'changes';
1386
+ const setupContainer = (container) => {
1387
+ container.innerHTML = renderDrawerFilesTable(currentFiles, currentSort, hash, projPath);
1388
+ container.addEventListener('click', (e) => {
1389
+ const th = e.target.closest('[data-sort]');
1390
+ if (th) {
1391
+ currentSort = th.dataset.sort;
1392
+ container.innerHTML = renderDrawerFilesTable(currentFiles, currentSort, hash, projPath);
1393
+ return;
1394
+ }
1395
+ const restoreBtn = e.target.closest('[data-restore-cmd]');
1396
+ if (restoreBtn) {
1397
+ copyText(restoreBtn.dataset.restoreCmd);
1398
+ restoreBtn.textContent = t('modal.copied');
1399
+ restoreBtn.classList.add('copied');
1400
+ setTimeout(() => { restoreBtn.textContent = t('modal.copyRestore'); restoreBtn.classList.remove('copied'); }, 1500);
1401
+ }
1402
+ });
1403
+ };
1299
1404
  fetchBackupFiles(hash).then(files => {
1300
1405
  currentFiles = files.length > 0 ? files : parseSummaryToFiles(backup.summary);
1301
1406
  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
- }
1407
+ if (container) setupContainer(container);
1311
1408
  });
1312
1409
  } else if (backup.summary) {
1313
1410
  const fallback = parseSummaryToFiles(backup.summary);
1314
1411
  const container = body.querySelector('#drawer-files-container');
1315
1412
  if (container && fallback.length > 0) {
1316
- container.innerHTML = renderDrawerFilesTable(fallback, 'changes');
1413
+ container.innerHTML = renderDrawerFilesTable(fallback, 'changes', hash, projPath);
1317
1414
  } else if (container) {
1318
1415
  const translated = backup.summary.split('; ').map(s => translateSummary(s)).join('\n');
1319
1416
  container.innerHTML = `<pre class="restore-field-value text-mono summary-pre">${esc(translated)}</pre>`;
@@ -1432,7 +1529,7 @@ function setupEvents() {
1432
1529
  if (backup) openRestoreDrawer(backup);
1433
1530
  });
1434
1531
 
1435
- // Alert history + files toggle (event delegation)
1532
+ // Alert history toggle + file modal (event delegation)
1436
1533
  $('#card-alert').addEventListener('click', (e) => {
1437
1534
  const historyToggle = e.target.closest('[data-alert-history-toggle]');
1438
1535
  if (historyToggle) {
@@ -1441,14 +1538,16 @@ function setupEvents() {
1441
1538
  if (history) history.classList.toggle('alert-history-collapsed');
1442
1539
  return;
1443
1540
  }
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');
1541
+ const modalBtn = e.target.closest('[data-alert-files-modal]');
1542
+ if (modalBtn) {
1543
+ const alerts = state.pageData?.alerts;
1544
+ const files = alerts?.latest?.files || [];
1545
+ if (files.length > 0) {
1546
+ const proj = state.pageData?.status?.config?.path || '';
1547
+ openFileModal(t('modal.alertFiles'), files, proj, '');
1548
+ }
1549
+ return;
1550
+ }
1452
1551
  });
1453
1552
 
1454
1553
  // Diagnostics summary click
@@ -1467,8 +1566,17 @@ function setupEvents() {
1467
1566
  document.querySelectorAll('[data-action="close-drawer"]').forEach(btn => {
1468
1567
  btn.addEventListener('click', closeDrawer);
1469
1568
  });
1569
+
1570
+ // Close modal
1571
+ $('#file-modal-overlay').addEventListener('click', (e) => {
1572
+ if (e.target === $('#file-modal-overlay')) closeFileModal();
1573
+ });
1574
+ document.querySelectorAll('[data-action="close-modal"]').forEach(btn => {
1575
+ btn.addEventListener('click', closeFileModal);
1576
+ });
1577
+
1470
1578
  document.addEventListener('keydown', (e) => {
1471
- if (e.key === 'Escape') closeDrawer();
1579
+ if (e.key === 'Escape') { closeFileModal(); closeDrawer(); }
1472
1580
  });
1473
1581
  }
1474
1582
 
@@ -75,6 +75,17 @@
75
75
 
76
76
  </main>
77
77
 
78
+ <!-- ── File Detail Modal ──────────────────────────────────── -->
79
+ <div id="file-modal-overlay" class="modal-overlay">
80
+ <div id="file-modal" class="file-modal">
81
+ <div class="file-modal-header">
82
+ <h3 id="file-modal-title"></h3>
83
+ <button class="drawer-close" data-action="close-modal">&times;</button>
84
+ </div>
85
+ <div id="file-modal-body" class="file-modal-body"></div>
86
+ </div>
87
+ </div>
88
+
78
89
  <!-- ── Drawer Overlay ───────────────────────────────────── -->
79
90
  <div id="drawer-overlay" class="drawer-overlay"></div>
80
91
 
@@ -349,6 +349,14 @@ main {
349
349
  max-width: 400px;
350
350
  line-height: 1.4;
351
351
  }
352
+ .summary-intent-label {
353
+ font-weight: 600;
354
+ opacity: 0.7;
355
+ }
356
+ .summary-trigger {
357
+ font-size: 11px;
358
+ padding: 2px 0;
359
+ }
352
360
  .summary-restore-ctx {
353
361
  font-size: 12px;
354
362
  color: var(--amber);
@@ -997,6 +1005,22 @@ main {
997
1005
  border-left: 3px solid var(--yellow);
998
1006
  }
999
1007
 
1008
+ .alert-breakdown {
1009
+ color: var(--text-secondary);
1010
+ font-weight: 500;
1011
+ padding: 2px 12px;
1012
+ }
1013
+ .alert-suggestion {
1014
+ padding: 4px 12px;
1015
+ font-style: italic;
1016
+ }
1017
+ .alert-history-breakdown {
1018
+ display: block;
1019
+ font-size: 10px;
1020
+ color: var(--text-tertiary);
1021
+ margin-top: 1px;
1022
+ }
1023
+
1000
1024
  .alert-history-toggle-wrap {
1001
1025
  margin-top: 8px;
1002
1026
  }
@@ -1236,6 +1260,103 @@ main {
1236
1260
  color: var(--text-heading);
1237
1261
  }
1238
1262
 
1263
+ /* ── File Detail Modal ────────────────────────────────────── */
1264
+
1265
+ .modal-overlay {
1266
+ position: fixed;
1267
+ inset: 0;
1268
+ background: rgba(0, 0, 0, 0.55);
1269
+ z-index: 2000;
1270
+ display: none;
1271
+ align-items: center;
1272
+ justify-content: center;
1273
+ backdrop-filter: blur(2px);
1274
+ }
1275
+ .modal-overlay.active {
1276
+ display: flex;
1277
+ }
1278
+ .file-modal {
1279
+ background: var(--card-bg);
1280
+ border: 1px solid var(--border);
1281
+ border-radius: var(--radius);
1282
+ width: 90vw;
1283
+ max-width: 720px;
1284
+ max-height: 80vh;
1285
+ display: flex;
1286
+ flex-direction: column;
1287
+ box-shadow: 0 16px 48px rgba(0, 0, 0, 0.3);
1288
+ }
1289
+ .file-modal-header {
1290
+ display: flex;
1291
+ justify-content: space-between;
1292
+ align-items: center;
1293
+ padding: 14px 20px;
1294
+ border-bottom: 1px solid var(--border);
1295
+ }
1296
+ .file-modal-header h3 {
1297
+ margin: 0;
1298
+ font-size: 15px;
1299
+ font-weight: 600;
1300
+ }
1301
+ .file-modal-body {
1302
+ padding: 16px 20px;
1303
+ overflow-y: auto;
1304
+ flex: 1;
1305
+ }
1306
+ .file-modal-body table {
1307
+ width: 100%;
1308
+ border-collapse: collapse;
1309
+ }
1310
+ .file-modal-body th,
1311
+ .file-modal-body td {
1312
+ padding: 6px 10px;
1313
+ text-align: left;
1314
+ border-bottom: 1px solid var(--border);
1315
+ font-size: 12px;
1316
+ }
1317
+ .file-modal-body th {
1318
+ font-weight: 600;
1319
+ font-size: 11px;
1320
+ text-transform: uppercase;
1321
+ letter-spacing: 0.3px;
1322
+ color: var(--text-secondary);
1323
+ cursor: pointer;
1324
+ user-select: none;
1325
+ }
1326
+ .file-modal-body th:hover {
1327
+ color: var(--accent);
1328
+ }
1329
+ .modal-file-path {
1330
+ max-width: 340px;
1331
+ overflow: hidden;
1332
+ text-overflow: ellipsis;
1333
+ white-space: nowrap;
1334
+ }
1335
+ .modal-file-changes {
1336
+ white-space: nowrap;
1337
+ }
1338
+ .modal-restore-btn {
1339
+ padding: 2px 8px;
1340
+ font-size: 10px;
1341
+ border: 1px solid var(--border);
1342
+ border-radius: var(--radius-sm);
1343
+ background: var(--bg);
1344
+ color: var(--text-secondary);
1345
+ cursor: pointer;
1346
+ white-space: nowrap;
1347
+ transition: all 0.15s;
1348
+ }
1349
+ .modal-restore-btn:hover {
1350
+ background: var(--accent);
1351
+ color: #fff;
1352
+ border-color: var(--accent);
1353
+ }
1354
+ .modal-restore-btn.copied {
1355
+ background: var(--green);
1356
+ color: #fff;
1357
+ border-color: var(--green);
1358
+ }
1359
+
1239
1360
  /* ── Responsive ───────────────────────────────────────────── */
1240
1361
 
1241
1362
  @media (max-width: 768px) {
@@ -146,7 +146,6 @@ function createGitSnapshot(projectDir, cfg, opts = {}) {
146
146
  const diffOut = git(['diff-tree', '--no-commit-id', '--name-status', '-r', parentTree, newTree], { cwd, allowFail: true });
147
147
  if (diffOut) {
148
148
  const diffLines = diffOut.split('\n').filter(Boolean);
149
- changedCount = diffLines.length;
150
149
  const groups = { M: [], A: [], D: [], R: [] };
151
150
  for (const line of diffLines) {
152
151
  const tab = line.indexOf('\t');
@@ -158,8 +157,10 @@ function createGitSnapshot(projectDir, cfg, opts = {}) {
158
157
  : code === 'A' ? 'A'
159
158
  : 'M';
160
159
  const fileName = filePart.split('\t').pop();
160
+ if (matchesAny(cfg.ignore, fileName) || matchesAny(cfg.ignore, path.basename(fileName))) continue;
161
161
  groups[key].push(fileName);
162
162
  }
163
+ changedCount = Object.values(groups).reduce((sum, arr) => sum + arr.length, 0);
163
164
 
164
165
  const numstatOut = git(['diff-tree', '--no-commit-id', '--numstat', '-r', parentTree, newTree], { cwd, allowFail: true });
165
166
  const stats = {};
@@ -199,7 +200,8 @@ function createGitSnapshot(projectDir, cfg, opts = {}) {
199
200
  } else {
200
201
  const lsInitial = git(['ls-tree', '--name-only', '-r', newTree], { cwd, allowFail: true });
201
202
  if (lsInitial) {
202
- const files = lsInitial.split('\n').filter(Boolean);
203
+ const files = lsInitial.split('\n').filter(Boolean)
204
+ .filter(f => !matchesAny(cfg.ignore, f) && !matchesAny(cfg.ignore, path.basename(f)));
203
205
  changedCount = files.length;
204
206
  const sample = files.slice(0, 5).join(', ');
205
207
  incrementalSummary = `Added ${files.length}: ${sample}${files.length > 5 ? ', ...' : ''}`;
@@ -290,10 +292,10 @@ function createShadowCopy(projectDir, cfg, opts = {}) {
290
292
  snapDir = path.join(backupDir, ts);
291
293
  }
292
294
  }
293
- fs.mkdirSync(snapDir, { recursive: true });
294
-
295
295
  const prevSnapDir = findPreviousSnapshot(backupDir);
296
296
 
297
+ fs.mkdirSync(snapDir, { recursive: true });
298
+
297
299
  const allFiles = walkDir(projectDir, projectDir);
298
300
  const files = filterFiles(allFiles, cfg);
299
301
 
@@ -177,7 +177,12 @@ function loadConfig(projectDir) {
177
177
  warnings.push(`Unknown backup_strategy "${raw.backup_strategy}", using default "${cfg.backup_strategy}"`);
178
178
  }
179
179
  }
180
- if (typeof raw.auto_backup_interval_seconds === 'number') cfg.auto_backup_interval_seconds = raw.auto_backup_interval_seconds;
180
+ if (typeof raw.auto_backup_interval_seconds === 'number') {
181
+ cfg.auto_backup_interval_seconds = raw.auto_backup_interval_seconds;
182
+ } else if (typeof raw.backup_interval_seconds === 'number') {
183
+ cfg.auto_backup_interval_seconds = raw.backup_interval_seconds;
184
+ warnings.push('backup_interval_seconds is a deprecated alias — please use auto_backup_interval_seconds');
185
+ }
181
186
  if (typeof raw.pre_restore_backup === 'string') {
182
187
  if (VALID_PRE_RESTORE.includes(raw.pre_restore_backup)) {
183
188
  cfg.pre_restore_backup = raw.pre_restore_backup;