cursor-guard 4.4.1 → 4.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ROADMAP.md CHANGED
@@ -3,8 +3,8 @@
3
3
  > 本文档描述 cursor-guard 从 V2 到 V7 的长期演进方向。
4
4
  > 每一代向下兼容,低版本功能永远不废弃。
5
5
  >
6
- > **当前版本**:`V4.4.0`(V4 收官版)
7
- > **文档状态**:`V2` ~ `V4.4.0` 已实施(含 V5 intent/audit 基础),`V5` 主体规划中
6
+ > **当前版本**:`V4.5.1`(V4 最终版)
7
+ > **文档状态**:`V2` ~ `V4.5.1` 已完成交付(含 V5 intent/audit 基础),`V5` 主体规划中
8
8
 
9
9
  ## 阅读导航
10
10
 
@@ -20,7 +20,7 @@
20
20
  |---|---|---|---|
21
21
  | `V2` | 能用 | Skill + Script | "AI 弄丢代码能恢复" |
22
22
  | `V3` | 更稳 | + Core 抽取 + 可选 MCP | "恢复操作更标准、更省 token" |
23
- | `V4` | 更聪明 | + 主动检测 + 可观测 + Web 仪表盘 + Intent + 增量摘要 | "cursor-guard 会主动提醒你,能看到为什么备份、改了什么" ✅ |
23
+ | `V4` | 更聪明 | + 主动检测 + 可观测 + Web 仪表盘 + Intent + 增量摘要 + 安全硬化 | "cursor-guard 会主动提醒你,能看到为什么备份、改了什么" ✅ |
24
24
  | `V5` | 成闭环 | + 变更控制层 | "AI 代码变更可预防、可追溯、可按事件恢复"(intent 基础已在 V4.3 落地) |
25
25
  | `V6` | 成标准 | + 开放协议 + 团队工作流 | "把 AI 代码变更安全做成跨工具标准" |
26
26
  | `V7` | 可证明 | + 可验证信任 + 治理层 | "能证明安全流程被执行了" |
@@ -457,6 +457,58 @@ V4 经过 4 轮系统性代码审查,修复了以下关键问题:
457
457
  | V4.3.4 | **运维加固**:`backup.log` 日志轮转(1MB / 3 文件);watcher 单实例保护加固(锁文件时间戳 + 24h 超时);`previewProjectRestore` 保护路径分组摘要(降低 token 消耗);SKILL.md 硬规则 #15(升级后提交 skill 文件) | ✅ |
458
458
  | V4.3.5 | **Summary 准确性修复 + UI 优化**:备份摘要改用 `diff-tree` 增量对比(修复 porcelain 假摘要 bug);仪表盘变更列三行堆叠布局;配色全面优化(背景层级 / 状态色 / 文字层级) | ✅ |
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
+ | V4.4.1 | **安全硬化版(5 项审计修复 + UX 优化)**:见下方详细说明 | ✅ |
461
+ | V4.5.0 | **V4 最终版(异常检测修复 + Dashboard 全面升级)**:见下方详细说明 | ✅ 收官 |
462
+
463
+ #### V4.4.1 详细内容
464
+
465
+ **安全修复(2×P1 + 3×P2)**:
466
+
467
+ | 级别 | 问题 | 修复 |
468
+ |------|------|------|
469
+ | P1 | `restoreFile` 接受目录 pathspec(`src`、`.cursor`)和项目根(`.`),单文件 API 能恢复整个目录或回滚受保护资产 | `validateRelativePath` 拦截 `.` 和空路径;`isToolPath` 匹配裸 `.cursor`;git 路径用 `cat-file -t` 验证必须是 blob(文件),tree(目录)直接拒绝;shadow 路径用 `statSync` 拦截目录 |
470
+ | P1 | `restore_project` 对 HEAD 已删除的受保护文件不处理,旧快照会把它们复活 | 恢复前用 `ls-tree HEAD -- <path>` 检查存在性,HEAD 中不存在的路径用 `rmSync/unlinkSync` 清除 |
471
+ | P2 | Git retention 重建链只保留 subject(`%s`),丢失 V4.3 审计 trailers | 改用 `%B`(完整 body + trailers),重建用 `fullBody` 传入 `commit-tree -m` |
472
+ | P2 | Dashboard 仅绑定 127.0.0.1 但无 Host/token 防护,可被 DNS rebinding 读取 | 加 Host header 校验(`127.0.0.1`/`localhost`)+ per-process 随机 token 注入 index.html,API 请求必须携带 |
473
+ | P2 | `doctor_fix` 初始化 Git 时 `git add -A` 会提交 `node_modules/` | 在 `git add -A` 前写入 `node_modules/` 和 `.cursor/skills/**/node_modules/` 到 `.gitignore` |
474
+
475
+ **回归测试**:新增 3 条负例锁定 restore 防线(目录 pathspec / 受保护 .cursor / 项目根 `.`),总测试 143/143 全绿。
476
+
477
+ **Dashboard UX**:
478
+ - 骨架屏加载(shimmer 占位,消除白屏→弹出的突兀感)
479
+ - 渐进渲染(`page-data?scope=` 按需返回,overview 先渲染,backups+doctor 并行加载)
480
+ - 备份 summary 行级统计(`git diff-tree --numstat`,每文件 `(+N -M)`),分行显示
481
+ - Summary 可见性提升(12px + secondary 颜色 + monospace 字体)
482
+ - 去除变更列冗余 trigger badge(类型列已有)
483
+ - Pre-restore 快照记录恢复方向(`From: <HEAD短hash>`、`Restore-To: <目标短hash>`、`File: <文件路径>`),表格琥珀色显示 `ab1b45d → f4029e9`
484
+
485
+ **架构级防护缺口修复**:
486
+ - MCP 工具注入 watcher 未运行警告(`_warning` 字段),AI 第一次调任何工具就能看到保护缺口
487
+ - SKILL.md Hard Rule #1 升级:"任何文件写入/删除前必须 snapshot"(之前仅要求"高风险操作前")
488
+ - SKILL.md 新增 Hard Rule #3a:"必须检查 watcher 状态"——看到 `_warning` 必须告知用户
489
+
490
+ #### V4.5.0 详细内容
491
+
492
+ **Bug 修复**:
493
+
494
+ | 问题 | 根因 | 修复 |
495
+ |------|------|------|
496
+ | 异常检测 `changedFileCount` 虚高 | `auto-backup.js` 用 `git status --porcelain`(对比 HEAD)计数,而非对比上一次备份的增量 | 改用 `createGitSnapshot` 返回的 `changedCount`(来自 `diff-tree`),异常检测和 Summary 共享同一数据源。移除了未使用的 `execFileSync` 和 `unquoteGitPath` 导入 |
497
+ | 诊断锁文件状态判断不够智能 | `doctor.js` Lock file 检测只要存在就报 WARN,不区分 watcher 是否在运行 | 加入 PID 存活判断(`process.kill(pid, 0)`):PID 在线 → PASS(`watcher running`);PID 已死 → WARN(`stale lock file`);无 PID → WARN(兜底)。前端 i18n 同步补全 `detail.lock_running` / `detail.lock_stale` / `detail.lock_exists` |
498
+
499
+ **Dashboard 升级(10 项改进)**:
500
+
501
+ | 优先级 | 改进 | 说明 |
502
+ |--------|------|------|
503
+ | 高 | 告警卡片补全 | 显示触发时间、过期倒计时(实时递减)、具体数字(N 文件 / N 秒 / 阈值 N) |
504
+ | 高 | 告警历史 | 保留最近 20 条告警记录,即使过期也显示在卡片下方(最近 5 条) |
505
+ | 中 | 文件搜索框 | 备份表格上方输入框,按文件名/意图/摘要实时过滤相关备份 |
506
+ | 中 | 恢复命令复制 | 抽屉底部显示 `restore_project` 和 `restore_file` MCP 命令,一键复制 |
507
+ | 低 | 筛选按钮计数 | "Git 自动备份 (12)" 而非仅 "Git 自动备份";无数据的类型自动隐藏 |
508
+ | 低 | Watcher 最后扫描 | 卡片增加 "最后扫描: 3s 前",确认 watcher 实际在工作 |
509
+ | 中 | 摘要展开/收起 | 变更摘要超过 2 行时自动折叠,显示 `+N more` 按钮点击展开;避免行过长截断,无需进抽屉即可看全 |
510
+ | 修复 | `showLoading` 引用 | 项目切换时调用了不存在的 `showLoading()`,改为 `showSkeleton()` |
511
+ | 优化 | i18n 补全 | 新增 14 个双语 key(告警详情、告警历史、文件搜索、恢复命令、扫描时间) |
460
512
 
461
513
  > **注**:V4.2 的 Web 仪表盘最初在 V4.0 规划中标记为"不做",但用户需求明确后实施。事实证明只读仪表盘投入产出比合理,且不违反安全原则。
462
514
 
@@ -466,6 +518,18 @@ V4 经过 4 轮系统性代码审查,修复了以下关键问题:
466
518
  - ~~不做 Web 仪表盘~~ → V4.2 已实施(只读、本地、零依赖)
467
519
  - 不做云端同步
468
520
 
521
+ ### V4 遗留的架构缺口(V5 接手)
522
+
523
+ 通过 V4.4.1 的安全审计和真实场景测试,发现以下架构层面的保护缺口。这些不是代码 bug,而是设计边界:
524
+
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 设计) |
532
+
469
533
  ### 进入 V5 的衡量标准
470
534
 
471
535
  - V4 的主动提醒功能误报率 < 10%
@@ -522,8 +586,101 @@ V5 不是"三个方向选一个",而是把下面这条链路做完整:
522
586
  | `impact set` | 为高风险编辑记录受影响文件 / 符号 / 测试集合 | `impact_set` 字段 | 查询事件时能看到"这次改动可能波及哪里" |
523
587
  | `MCP / CLI surface` | 暴露最小可用接口给 Agent 和终端 | `register_intent` / `list_active_intents` / `audit_query` / `get_event` / `restore_from_event` | AI 不需要拼复杂 shell,就能完成查询与恢复 |
524
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 按文件路径消歧,不按时间顺序 |
525
592
  | `tests / docs` | 为事件链路补齐单测、集成测试和文档 | tests + schema docs | V5.0 的所有核心事件和恢复路径都有测试覆盖 |
526
593
 
594
+ ### V5 核心设计:Embedded Watcher + 文件路径意图绑定
595
+
596
+ #### 问题根因
597
+
598
+ V4 的自动备份(auto-backup)和意图上下文(intent)分属两个独立进程:
599
+
600
+ ```
601
+ MCP Server 进程(知道 intent) ←——×——→ Watcher 进程(知道文件变了)
602
+ ↑ ↑
603
+ AI agent 调用 fs 检测循环
604
+ 有上下文 无上下文
605
+ ```
606
+
607
+ - `auto-backup.js` 调用 snapshot 时只传 `{ trigger: 'auto', changedFileCount }`,没有 intent/agent/session
608
+ - 只有 `snapshot_now` MCP 工具支持 intent 参数
609
+ - 曾考虑"意图队列"(AI 写文件 → watcher 读文件关联),但存在 4 类并发竞态:
610
+
611
+ | 竞态场景 | 描述 |
612
+ |---------|------|
613
+ | 多 Agent 竞争 | A 提交意图 → B 提交意图 → watcher 触发 → 绑谁的? |
614
+ | 意图-变更错位 | A 提交意图但未改文件 → B 改了另一个文件 → A 的意图被错绑到 B 的变更 |
615
+ | 意图堆积 | 一个 cycle 内多个 agent 提交意图 → watcher 只产生一次 commit → 意图丢失或错配 |
616
+ | 空意图残留 | Agent 提交意图后放弃 → 意图留在队列 → 被绑到下一次无关变更 |
617
+
618
+ 根本原因:**意图提交和文件变更是两个独立事件,跨进程 + 按时间绑定 = 无法原子化**。
619
+
620
+ #### 方案:同进程 + 按文件路径绑定
621
+
622
+ ```
623
+ ┌─────────────── MCP Server 进程(V5) ──────────────────┐
624
+ │ │
625
+ │ AI agent 调用 内存 activeEdits │
626
+ │ begin_edit({ Map<session, { │
627
+ │ intent, → intent, files[], │
628
+ │ files[], agent, timestamp, │
629
+ │ agent, ttl │
630
+ │ session }> │
631
+ │ }) ↓ 直接读取(同进程) │
632
+ │ │
633
+ │ Embedded Watcher 循环 ←─── 检测到 src/auth.ts 变更 │
634
+ │ 查 activeEdits: │
635
+ │ s1 声明了 [src/auth.ts] → 匹配 ✅ │
636
+ │ s2 声明了 [src/style.css] → 不匹配 ❌ │
637
+ │ → 创建 auto-backup commit 带 s1 的 intent │
638
+ │ │
639
+ └─────────────────────────────────────────────────────────┘
640
+ ```
641
+
642
+ **核心优势**:
643
+ - **同进程**:无 IPC、无文件 I/O、无并发竞争(Node.js 单线程 event loop)
644
+ - **按文件路径绑定**:不同 Agent 改不同文件时各自匹配,无歧义
645
+ - **TTL 自动过期**:空意图 5 分钟后自动清除,不会残留
646
+ - **多 Agent 同文件**:标记为 `multi-agent overlap`,附上所有匹配意图(同时作为冲突检测信号)
647
+
648
+ #### 并发场景处理
649
+
650
+ | 场景 | 处理方式 |
651
+ |------|---------|
652
+ | A 改 `auth.ts`,B 改 `style.css` | 各自匹配各自的 `begin_edit` scope,零歧义 |
653
+ | A、B 都声明要改 `auth.ts` | `begin_edit` 时即检测到重叠,返回 advisory warning;auto-backup 附上两个 intent |
654
+ | A 声明了意图但没改文件 | TTL 到期自动清除;`end_edit` 可提前关闭 |
655
+ | 文件变更但没有任何 `begin_edit` | 降级为普通 auto-backup(和 V4 行为一致,无退化) |
656
+ | AI 直接调 `snapshot_now(intent=...)` | 和现在一模一样,完全兼容 |
657
+
658
+ #### 实现分期
659
+
660
+ ```
661
+ Phase 1 (V5.0):
662
+ ├── always_watch: true → MCP server 内嵌 watcher 循环
663
+ ├── 新增 begin_edit / end_edit MCP 工具
664
+ ├── 内存 Map<session, EditScope> 管理活跃编辑意图
665
+ └── watcher 变更检测时查 Map,按文件路径匹配 intent → 写入 commit trailer
666
+
667
+ Phase 2 (V5.x):
668
+ ├── begin_edit → 产生 intent_registered 事件(写入审计存储)
669
+ ├── 文件变更 → 产生 edit_applied 事件(关联 intent + before_ref)
670
+ ├── end_edit → 产生 intent_released 事件
671
+ └── 完整审计链闭环,支持 restore_from_event
672
+ ```
673
+
674
+ #### 与"意图队列"方案的本质区别
675
+
676
+ | | 意图队列(已否决) | embedded watcher + begin_edit |
677
+ |---|---|---|
678
+ | 通信方式 | 文件 I/O(跨进程) | 内存 Map(同进程) |
679
+ | 绑定维度 | 时间顺序(先进先出) | 文件路径(精确匹配) |
680
+ | 并发安全 | 4 类竞态条件 | 无竞态(单进程 event loop) |
681
+ | 空意图处理 | 残留在文件中 | TTL 自动过期 + `end_edit` 显式关闭 |
682
+ | 多 Agent 消歧 | 无法消歧 | 文件路径级消歧 + 冲突检测信号 |
683
+
527
684
  ### V5 主线 A:并发编辑安全(预防层)
528
685
 
529
686
  ```
@@ -650,6 +807,10 @@ V5 的关键不是"多打一行日志",而是建立完整证据链:
650
807
 
651
808
  ### V5 完成标志(Definition of Done)
652
809
 
810
+ - `always_watch: true` 配置生效后,MCP server 启动自动内嵌 watcher 循环,用户无需额外命令
811
+ - `begin_edit` / `end_edit` MCP 工具可用,AI 能声明编辑意图和目标文件
812
+ - embedded watcher 的自动备份能通过文件路径匹配关联 `begin_edit` 中的 intent/agent/session
813
+ - 无 `begin_edit` 时自动备份行为与 V4 一致(无退化)
653
814
  - AI 能在高风险写入前注册意图并创建 `pre-edit` 快照
654
815
  - 用户能查询最近一次 AI 编辑的完整上下文
655
816
  - 给定 `event_id` 能找到对应快照并执行恢复
package/SKILL.md CHANGED
@@ -571,9 +571,10 @@ Skip the block for unrelated turns.
571
571
 
572
572
  ## Hard Rules (Non-Negotiable)
573
573
 
574
- 1. **MUST snapshot before high-risk ops** — git commit or shadow copy. No exceptions unless user explicitly declines.
574
+ 1. **MUST snapshot before ANY file write or delete** — call `snapshot_now` (with `intent`) before creating, editing, or deleting files. This is NOT optional and NOT just for "high-risk" operations. Every write is potentially destructive. No exceptions unless user explicitly says "不用备份" / "skip backup". If the MCP response includes `_warning` about the watcher being stopped, tell the user immediately.
575
575
  2. **MUST Read before Write** — never overwrite a file the agent hasn't read in the current turn.
576
576
  3. **MUST preserve current version before restore** — every restore operation must first snapshot the current state (§5a Step 4). Skip ONLY when: (a) user explicitly opts out, (b) current state is identical to target, or (c) no changes exist. If preservation fails, abort restore by default.
577
+ 3a. **MUST check watcher status** — if any MCP tool returns `_warning` about the watcher not running, immediately warn the user: "自动备份守护进程未运行,当前处于无保护状态。建议先启动 watcher 或在每次修改前手动调用 snapshot_now。" Do NOT silently continue editing files without protection.
577
578
  4. **Do not** treat Timeline/Checkpoints as the only or primary recovery path.
578
579
  5. **Do not** recommend Checkpoints as long-term or sole backup.
579
580
  6. **No automatic push** to remotes; local commits only unless user requests push.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursor-guard",
3
- "version": "4.4.1",
3
+ "version": "4.5.1",
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",
@@ -40,6 +40,12 @@ const I18N = {
40
40
  'alert.title': 'Alerts',
41
41
  'alert.none': 'No active alerts',
42
42
  'alert.active': 'Active Alert',
43
+ 'alert.triggered': 'Triggered',
44
+ 'alert.expires': 'Expires in',
45
+ 'alert.detail': '{count} files in {window}s (threshold: {threshold})',
46
+ 'alert.expired': 'Expired',
47
+ 'alert.history': 'Recent Alert History',
48
+ 'alert.noHistory': 'No alert history',
43
49
 
44
50
  'backups.gitCommits': 'Git Commits',
45
51
  'backups.shadowSnapshots': 'Shadow Snapshots',
@@ -95,6 +101,7 @@ const I18N = {
95
101
  'trigger.manual': 'Manual (agent)',
96
102
  'trigger.pre-restore': 'Pre-Restore',
97
103
  'backups.col.summary': 'Changes',
104
+ 'backups.search': 'Search files…',
98
105
  'summary.modified': 'Modified',
99
106
  'summary.added': 'Added',
100
107
  'summary.deleted': 'Deleted',
@@ -103,6 +110,13 @@ const I18N = {
103
110
  'drawer.field.intent': 'Intent',
104
111
  'drawer.field.agent': 'Agent',
105
112
  'drawer.field.session': 'Session',
113
+ 'drawer.field.from': 'From (current)',
114
+ 'drawer.field.restoreTo':'Restore to',
115
+ 'drawer.field.restoreFile':'Restored file',
116
+ 'drawer.restoreCmd': 'Copy Restore Command',
117
+ 'drawer.restoreCmdFile':'Copy File Restore Command',
118
+
119
+ 'watcher.lastScan': 'Last scan',
106
120
 
107
121
  'error.fetchFailed': 'Failed to fetch data',
108
122
  'error.sectionFailed': 'This section failed to load',
@@ -176,6 +190,8 @@ const I18N = {
176
190
  'detail.disk_critical': '{gb} GB free — critically low',
177
191
  'detail.disk_free': '{gb} GB free',
178
192
  'detail.disk_unknown': 'could not determine free space',
193
+ 'detail.lock_running': 'watcher running (pid={pid}, since {since})',
194
+ 'detail.lock_stale': 'stale lock file (pid={pid} is dead) — safe to delete or run doctor_fix',
179
195
  'detail.lock_exists': 'lock file exists — another instance may be running. {info}',
180
196
  'detail.lock_none': 'no lock file (no running instance)',
181
197
  'detail.node_ok': '{v}',
@@ -221,6 +237,12 @@ const I18N = {
221
237
  'alert.title': '告警',
222
238
  'alert.none': '无活跃告警',
223
239
  'alert.active': '活跃告警',
240
+ 'alert.triggered': '触发时间',
241
+ 'alert.expires': '剩余有效',
242
+ 'alert.detail': '{count} 个文件在 {window} 秒内变更(阈值:{threshold})',
243
+ 'alert.expired': '已过期',
244
+ 'alert.history': '近期告警历史',
245
+ 'alert.noHistory': '暂无告警记录',
224
246
 
225
247
  'backups.gitCommits': 'Git 提交数',
226
248
  'backups.shadowSnapshots': '影子快照',
@@ -276,6 +298,7 @@ const I18N = {
276
298
  'trigger.manual': '手动(Agent)',
277
299
  'trigger.pre-restore': '恢复前快照',
278
300
  'backups.col.summary': '变更',
301
+ 'backups.search': '搜索文件…',
279
302
  'summary.modified': '修改',
280
303
  'summary.added': '新增',
281
304
  'summary.deleted': '删除',
@@ -284,6 +307,13 @@ const I18N = {
284
307
  'drawer.field.intent': '操作意图',
285
308
  'drawer.field.agent': 'AI 模型',
286
309
  'drawer.field.session': '会话 ID',
310
+ 'drawer.field.from': '恢复前版本',
311
+ 'drawer.field.restoreTo':'恢复目标',
312
+ 'drawer.field.restoreFile':'恢复文件',
313
+ 'drawer.restoreCmd': '复制恢复命令',
314
+ 'drawer.restoreCmdFile':'复制文件恢复命令',
315
+
316
+ 'watcher.lastScan': '最后扫描',
287
317
 
288
318
  'error.fetchFailed': '数据拉取失败',
289
319
  'error.sectionFailed': '此区块加载失败',
@@ -357,6 +387,8 @@ const I18N = {
357
387
  'detail.disk_critical': '{gb} GB 可用——严重不足',
358
388
  'detail.disk_free': '{gb} GB 可用',
359
389
  'detail.disk_unknown': '无法检测可用空间',
390
+ 'detail.lock_running': '守护进程运行中(pid={pid},启动于 {since})',
391
+ 'detail.lock_stale': '残留锁文件(pid={pid} 已终止)——可安全删除或运行 doctor_fix',
360
392
  'detail.lock_exists': '锁文件存在——可能有其他实例正在运行。{info}',
361
393
  'detail.lock_none': '无锁文件(无运行中的实例)',
362
394
  'detail.node_ok': '{v}',
@@ -379,10 +411,12 @@ const state = {
379
411
  pageData: null,
380
412
  filteredBackups: [],
381
413
  backupFilter: 'all',
414
+ fileSearch: '',
382
415
  refreshTimer: null,
383
416
  tickTimer: null,
384
417
  lastRefreshAt: null,
385
418
  drawerOpen: null,
419
+ alertHistory: [],
386
420
  };
387
421
 
388
422
  const REFRESH_MS = 15000;
@@ -498,6 +532,8 @@ const DETAIL_PATTERNS = [
498
532
  { re: /^(.+?) GB free — critically low$/, key: 'detail.disk_critical', extract: ['gb'] },
499
533
  { re: /^could not determine free space$/, key: 'detail.disk_unknown' },
500
534
  { re: /^(.+?) GB free$/, key: 'detail.disk_free', extract: ['gb'] },
535
+ { re: /^watcher running \(pid=(\d+), since (.+)\)$/, key: 'detail.lock_running', extract: ['pid', 'since'] },
536
+ { re: /^stale lock file \(pid=(\d+) is dead\)/, key: 'detail.lock_stale', extract: ['pid'] },
501
537
  { re: /^lock file exists — another instance may be running\. ?(.*)$/,key: 'detail.lock_exists', extract: ['info'] },
502
538
  { re: /^no lock file/, key: 'detail.lock_none' },
503
539
  { re: /^(v\d+\.\d+\.\d+\S*) — recommended >=18$/, key: 'detail.node_old', extract: ['v'] },
@@ -775,6 +811,7 @@ function renderWatcherCard(watcher) {
775
811
  let st = 'stopped';
776
812
  if (watcher?.running) st = 'running';
777
813
  else if (watcher?.stale) st = 'stale';
814
+ const lastScan = state.lastRefreshAt ? relativeTime(new Date(state.lastRefreshAt).toISOString()) : null;
778
815
  el.innerHTML = `
779
816
  <div class="card-label">${t('watcher.title')}</div>
780
817
  <div class="card-status">
@@ -783,23 +820,55 @@ function renderWatcherCard(watcher) {
783
820
  </div>
784
821
  ${watcher?.pid ? `<div class="card-detail text-muted text-sm">${t('watcher.pid')}: ${watcher.pid}</div>` : ''}
785
822
  ${watcher?.startedAt ? `<div class="card-detail text-muted text-sm">${t('watcher.since')}: ${esc(formatTime(watcher.startedAt))}</div>` : ''}
823
+ ${watcher?.running && lastScan ? `<div class="card-detail text-muted text-sm">${t('watcher.lastScan')}: ${esc(lastScan)}</div>` : ''}
786
824
  `;
787
825
  }
788
826
 
789
827
  function renderAlertCard(alerts) {
790
828
  const el = $('#card-alert');
791
829
  if (!alerts?.active) {
830
+ let historyHtml = '';
831
+ if (state.alertHistory.length > 0) {
832
+ const rows = state.alertHistory.slice(-5).reverse().map(h =>
833
+ `<div class="alert-history-row text-sm text-muted">
834
+ <span>${esc(formatTime(h.timestamp))}</span>
835
+ <span>${t('alert.detail', { count: h.fileCount, window: h.windowSeconds, threshold: h.threshold })}</span>
836
+ <span class="badge badge-expired">${t('alert.expired')}</span>
837
+ </div>`
838
+ ).join('');
839
+ historyHtml = `<div class="alert-history"><div class="alert-history-label text-sm">${t('alert.history')}</div>${rows}</div>`;
840
+ }
792
841
  el.innerHTML = `
793
842
  <div class="card-label">${t('alert.title')}</div>
794
843
  <div class="card-status"><span class="status-dot status-healthy"></span><span>${t('alert.none')}</span></div>
844
+ ${historyHtml}
795
845
  `;
796
846
  return;
797
847
  }
798
848
  const a = alerts.latest || {};
849
+
850
+ // Track in history
851
+ 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 });
853
+ if (state.alertHistory.length > 20) state.alertHistory = state.alertHistory.slice(-20);
854
+ }
855
+
856
+ const triggeredAt = a.timestamp ? formatTime(a.timestamp) : '-';
857
+ const expiresAt = a.expiresAt ? new Date(a.expiresAt) : null;
858
+ const remainMs = expiresAt ? expiresAt.getTime() - Date.now() : 0;
859
+ const remainSec = Math.max(0, Math.ceil(remainMs / 1000));
860
+ const remainMin = Math.floor(remainSec / 60);
861
+ const remainDisplay = remainMin > 0 ? `${remainMin}m ${remainSec % 60}s` : `${remainSec}s`;
862
+ const detailText = t('alert.detail', { count: a.fileCount || '?', window: a.windowSeconds || '?', threshold: a.threshold || '?' });
863
+
799
864
  el.innerHTML = `
800
865
  <div class="card-label">${t('alert.title')}</div>
801
866
  <div class="card-status"><span class="status-dot status-warning"></span><span class="status-text status-warning">${t('alert.active')}</span></div>
802
- <div class="card-detail text-muted text-sm">${esc(translateIssue(a.recommendation || a.message || ''))}</div>
867
+ <div class="alert-details">
868
+ <div class="alert-detail-row"><span class="alert-detail-label">${t('alert.triggered')}</span><span>${esc(triggeredAt)}</span></div>
869
+ <div class="alert-detail-row"><span class="alert-detail-label">${t('alert.expires')}</span><span class="alert-countdown">${esc(remainDisplay)}</span></div>
870
+ <div class="alert-detail-row alert-numbers">${esc(detailText)}</div>
871
+ </div>
803
872
  `;
804
873
  }
805
874
 
@@ -807,10 +876,17 @@ function renderAlertCard(alerts) {
807
876
 
808
877
  function renderBackupsSection(dashboard, backups) {
809
878
  renderBackupStats(dashboard, backups);
810
- renderFilterBar();
879
+ renderFilterBar(backups);
880
+ renderFileSearch();
811
881
  renderBackupTable(backups);
812
882
  }
813
883
 
884
+ function renderFileSearch() {
885
+ const el = $('#file-search-wrap');
886
+ if (!el) return;
887
+ el.innerHTML = `<input id="file-search" type="text" class="file-search-input" placeholder="${t('backups.search')}" value="${esc(state.fileSearch)}" />`;
888
+ }
889
+
814
890
  function renderBackupStats(d, backups) {
815
891
  const gitCount = d.counts?.git?.commits || 0;
816
892
  const shadowCount = d.counts?.shadow?.snapshots || 0;
@@ -829,7 +905,11 @@ function renderBackupStats(d, backups) {
829
905
  `;
830
906
  }
831
907
 
832
- function renderFilterBar() {
908
+ function renderFilterBar(backups) {
909
+ const allBackups = Array.isArray(backups) ? backups : (Array.isArray(state.pageData?.backups) ? state.pageData.backups : []);
910
+ const typeCounts = {};
911
+ for (const b of allBackups) { typeCounts[b.type] = (typeCounts[b.type] || 0) + 1; }
912
+
833
913
  const types = [
834
914
  { key: 'all', label: 'backups.filterAll' },
835
915
  { key: 'git-auto-backup', label: 'type.git-auto-backup' },
@@ -838,9 +918,12 @@ function renderFilterBar() {
838
918
  { key: 'shadow', label: 'type.shadow' },
839
919
  { key: 'shadow-pre-restore',label: 'type.shadow-pre-restore' },
840
920
  ];
841
- $('#backup-filters').innerHTML = types.map(t2 =>
842
- `<button class="filter-btn ${state.backupFilter === t2.key ? 'active' : ''}" data-filter="${t2.key}">${t(t2.label)}</button>`
843
- ).join('');
921
+ const total = allBackups.length;
922
+ $('#backup-filters').innerHTML = types.map(t2 => {
923
+ const count = t2.key === 'all' ? total : (typeCounts[t2.key] || 0);
924
+ if (t2.key !== 'all' && count === 0) return '';
925
+ return `<button class="filter-btn ${state.backupFilter === t2.key ? 'active' : ''}" data-filter="${t2.key}">${t(t2.label)} <span class="filter-count">(${count})</span></button>`;
926
+ }).join('');
844
927
  }
845
928
 
846
929
  function translateSummary(raw) {
@@ -859,7 +942,10 @@ function formatSummaryCell(b) {
859
942
  }
860
943
 
861
944
  let line2 = '';
862
- if (b.intent) {
945
+ if (b.from && b.restoreTo) {
946
+ const label = b.restoreFile ? `${esc(b.restoreFile)}: ` : '';
947
+ line2 = `<div class="summary-restore-ctx">${label}<span class="text-mono">${esc(b.from)}</span> → <span class="text-mono">${esc(b.restoreTo)}</span></div>`;
948
+ } else if (b.intent) {
863
949
  const intentShort = b.intent.length > 70 ? b.intent.substring(0, 67) + '...' : b.intent;
864
950
  line2 = `<div class="summary-intent">${esc(intentShort)}</div>`;
865
951
  } else if (b.message && !b.message.startsWith('guard:')) {
@@ -870,7 +956,15 @@ function formatSummaryCell(b) {
870
956
  let line3 = '';
871
957
  if (b.summary) {
872
958
  const categories = b.summary.split('; ').map(s => translateSummary(s));
873
- line3 = categories.map(c => `<div class="summary-detail-line">${esc(c)}</div>`).join('');
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('');
962
+ } 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>`;
967
+ }
874
968
  }
875
969
 
876
970
  if (!line1 && !line2 && !line3) return '<span class="text-muted text-sm">-</span>';
@@ -882,10 +976,21 @@ function renderBackupTable(backups) {
882
976
  $('#backup-table-wrap').innerHTML = `<div class="error-panel">${t('error.sectionFailed')}</div>`;
883
977
  return;
884
978
  }
885
- state.filteredBackups = state.backupFilter === 'all'
979
+ let filtered = state.backupFilter === 'all'
886
980
  ? backups
887
981
  : backups.filter(b => b.type === state.backupFilter);
888
982
 
983
+ if (state.fileSearch) {
984
+ const q = state.fileSearch.toLowerCase();
985
+ filtered = filtered.filter(b =>
986
+ (b.summary && b.summary.toLowerCase().includes(q)) ||
987
+ (b.message && b.message.toLowerCase().includes(q)) ||
988
+ (b.intent && b.intent.toLowerCase().includes(q)) ||
989
+ (b.restoreFile && b.restoreFile.toLowerCase().includes(q))
990
+ );
991
+ }
992
+ state.filteredBackups = filtered;
993
+
889
994
  if (state.filteredBackups.length === 0) {
890
995
  $('#backup-table-wrap').innerHTML = `<div class="empty-state">${t('backups.noBackups')}</div>`;
891
996
  return;
@@ -1004,6 +1109,9 @@ function openRestoreDrawer(backup) {
1004
1109
  if (backup.intent) fields.push({ key: 'drawer.field.intent', val: backup.intent });
1005
1110
  if (backup.agent) fields.push({ key: 'drawer.field.agent', val: backup.agent });
1006
1111
  if (backup.session) fields.push({ key: 'drawer.field.session', val: backup.session });
1112
+ if (backup.from) fields.push({ key: 'drawer.field.from', val: backup.from });
1113
+ if (backup.restoreTo) fields.push({ key: 'drawer.field.restoreTo', val: backup.restoreTo });
1114
+ if (backup.restoreFile) fields.push({ key: 'drawer.field.restoreFile', val: backup.restoreFile });
1007
1115
  if (backup.message) fields.push({ key: 'drawer.field.message', val: backup.message });
1008
1116
  if (backup.summary) {
1009
1117
  const translated = backup.summary.split('; ').map(s => translateSummary(s)).join('\n');
@@ -1013,6 +1121,11 @@ function openRestoreDrawer(backup) {
1013
1121
  const refText = backup.ref || backup.shortHash || backup.timestamp || '';
1014
1122
  const jsonText = JSON.stringify(backup, null, 2);
1015
1123
 
1124
+ const isGit = backup.type?.startsWith('git');
1125
+ const hash = backup.commitHash || backup.shortHash || '';
1126
+ const restoreProjectCmd = isGit && hash ? `restore_project({ version: "${hash}", mode: "execute" })` : '';
1127
+ const restoreFileCmd = isGit && hash ? `restore_file({ file: "<filename>", version: "${hash}" })` : '';
1128
+
1016
1129
  body.innerHTML = `
1017
1130
  ${fields.map(f => `
1018
1131
  <div class="restore-field">
@@ -1028,12 +1141,29 @@ function openRestoreDrawer(backup) {
1028
1141
  <button class="btn btn-sm" data-copy-json>${t('drawer.copyJson')}</button>
1029
1142
  <button class="btn btn-sm" id="preview-toggle">${t('drawer.preview')}</button>
1030
1143
  </div>
1144
+ ${restoreProjectCmd ? `
1145
+ <div class="restore-cmd-section">
1146
+ <div class="restore-cmd-label text-sm text-muted">MCP Restore Commands</div>
1147
+ <div class="restore-cmd-row">
1148
+ <code class="restore-cmd-code">${esc(restoreProjectCmd)}</code>
1149
+ <button class="btn btn-sm btn-restore-cmd" data-copy-restore-project>${t('drawer.restoreCmd')}</button>
1150
+ </div>
1151
+ <div class="restore-cmd-row">
1152
+ <code class="restore-cmd-code">${esc(restoreFileCmd)}</code>
1153
+ <button class="btn btn-sm btn-restore-cmd" data-copy-restore-file>${t('drawer.restoreCmdFile')}</button>
1154
+ </div>
1155
+ </div>
1156
+ ` : ''}
1031
1157
  <div id="json-preview-wrap" class="hidden">
1032
1158
  <pre class="json-preview">${esc(jsonText)}</pre>
1033
1159
  </div>
1034
1160
  `;
1035
1161
 
1036
1162
  body.querySelector('[data-copy-json]')?.addEventListener('click', () => copyText(jsonText));
1163
+ if (restoreProjectCmd) {
1164
+ body.querySelector('[data-copy-restore-project]')?.addEventListener('click', () => copyText(restoreProjectCmd));
1165
+ body.querySelector('[data-copy-restore-file]')?.addEventListener('click', () => copyText(restoreFileCmd));
1166
+ }
1037
1167
  body.querySelector('#preview-toggle')?.addEventListener('click', () => {
1038
1168
  const wrap = body.querySelector('#json-preview-wrap');
1039
1169
  wrap.classList.toggle('hidden');
@@ -1101,7 +1231,7 @@ function setupEvents() {
1101
1231
  state.currentProjectId = e.target.value;
1102
1232
  state.backupFilter = 'all';
1103
1233
  stopRefresh();
1104
- showLoading();
1234
+ showSkeleton();
1105
1235
  try { await loadPageData(); renderAll(); }
1106
1236
  catch (err) { showGlobalError(err.message); }
1107
1237
  startRefresh();
@@ -1121,13 +1251,29 @@ function setupEvents() {
1121
1251
  state.backupFilter = btn.dataset.filter;
1122
1252
  const backups = state.pageData?.backups;
1123
1253
  if (Array.isArray(backups)) {
1124
- renderFilterBar();
1254
+ renderFilterBar(backups);
1125
1255
  renderBackupTable(backups);
1126
1256
  }
1127
1257
  });
1128
1258
 
1129
- // Backup table row click (event delegation)
1259
+ // File search (event delegation on parent)
1260
+ document.addEventListener('input', (e) => {
1261
+ if (e.target.id === 'file-search') {
1262
+ state.fileSearch = e.target.value;
1263
+ const backups = state.pageData?.backups;
1264
+ if (Array.isArray(backups)) renderBackupTable(backups);
1265
+ }
1266
+ });
1267
+
1268
+ // Summary expand toggle (must come before row click to prevent drawer open)
1130
1269
  $('#backup-table-wrap').addEventListener('click', (e) => {
1270
+ const toggleBtn = e.target.closest('[data-summary-toggle]');
1271
+ if (toggleBtn) {
1272
+ e.stopPropagation();
1273
+ const cell = toggleBtn.closest('.summary-stack');
1274
+ if (cell) cell.classList.toggle('summary-expanded');
1275
+ return;
1276
+ }
1131
1277
  const row = e.target.closest('tr[data-bi]');
1132
1278
  if (!row) return;
1133
1279
  const idx = parseInt(row.dataset.bi, 10);
@@ -57,6 +57,7 @@
57
57
  <h2 class="section-title" data-i18n="backups.title">Backups &amp; Recovery</h2>
58
58
  <div id="backup-stats" class="stats-row"><div class="skeleton-row"></div></div>
59
59
  <div id="backup-filters" class="filter-bar"></div>
60
+ <div id="file-search-wrap" class="file-search-wrap"></div>
60
61
  <div id="backup-table-wrap" class="table-wrap"><div class="skeleton-table"></div></div>
61
62
  </section>
62
63
 
@@ -349,6 +349,19 @@ main {
349
349
  max-width: 400px;
350
350
  line-height: 1.4;
351
351
  }
352
+ .summary-restore-ctx {
353
+ font-size: 12px;
354
+ color: var(--amber);
355
+ background: var(--amber-bg);
356
+ padding: 4px 10px;
357
+ border-radius: 4px;
358
+ border-left: 3px solid var(--amber);
359
+ overflow: hidden;
360
+ text-overflow: ellipsis;
361
+ white-space: nowrap;
362
+ max-width: 400px;
363
+ line-height: 1.4;
364
+ }
352
365
  .summary-message {
353
366
  font-size: 12px;
354
367
  color: var(--text-primary);
@@ -375,6 +388,25 @@ main {
375
388
  }
376
389
  .summary-detail-line:first-child { padding-top: 2px; }
377
390
 
391
+ .summary-collapsed {
392
+ display: none;
393
+ }
394
+ .summary-expanded .summary-collapsed {
395
+ display: block;
396
+ }
397
+ .summary-toggle-btn {
398
+ background: none;
399
+ border: none;
400
+ color: var(--blue);
401
+ font-size: 11px;
402
+ font-weight: 600;
403
+ cursor: pointer;
404
+ padding: 2px 0;
405
+ transition: color var(--transition);
406
+ }
407
+ .summary-toggle-btn:hover { color: var(--text-heading); }
408
+ .summary-expanded .summary-toggle-btn { display: none; }
409
+
378
410
  .summary-pre {
379
411
  font-size: 12px;
380
412
  line-height: 1.6;
@@ -850,6 +882,143 @@ main {
850
882
  }
851
883
  .skeleton-table::after { width: 75%; opacity: .6; }
852
884
 
885
+ /* ── Alert Card Details ───────────────────────────────────── */
886
+
887
+ .alert-details {
888
+ margin-top: 10px;
889
+ display: flex;
890
+ flex-direction: column;
891
+ gap: 6px;
892
+ }
893
+ .alert-detail-row {
894
+ display: flex;
895
+ align-items: center;
896
+ gap: 8px;
897
+ font-size: 12px;
898
+ color: var(--text-secondary);
899
+ }
900
+ .alert-detail-label {
901
+ font-weight: 600;
902
+ color: var(--text-tertiary);
903
+ min-width: 72px;
904
+ font-size: 10px;
905
+ text-transform: uppercase;
906
+ letter-spacing: .06em;
907
+ }
908
+ .alert-countdown {
909
+ color: var(--yellow);
910
+ font-weight: 700;
911
+ font-variant-numeric: tabular-nums;
912
+ }
913
+ .alert-numbers {
914
+ margin-top: 4px;
915
+ font-size: 13px;
916
+ font-weight: 600;
917
+ color: var(--yellow);
918
+ background: var(--yellow-bg);
919
+ padding: 6px 12px;
920
+ border-radius: var(--radius-sm);
921
+ border-left: 3px solid var(--yellow);
922
+ }
923
+
924
+ .alert-history {
925
+ margin-top: 12px;
926
+ border-top: 1px solid var(--border-subtle);
927
+ padding-top: 8px;
928
+ }
929
+ .alert-history-label {
930
+ font-size: 10px;
931
+ font-weight: 700;
932
+ text-transform: uppercase;
933
+ letter-spacing: .06em;
934
+ color: var(--text-tertiary);
935
+ margin-bottom: 6px;
936
+ }
937
+ .alert-history-row {
938
+ display: flex;
939
+ align-items: center;
940
+ gap: 8px;
941
+ padding: 3px 0;
942
+ font-size: 11px;
943
+ }
944
+ .badge-expired {
945
+ background: var(--gray-bg);
946
+ color: var(--gray);
947
+ border-color: rgba(100,116,139,.18);
948
+ font-size: 9px;
949
+ padding: 1px 6px;
950
+ }
951
+
952
+ /* ── File Search ─────────────────────────────────────────── */
953
+
954
+ .file-search-wrap {
955
+ margin-bottom: 12px;
956
+ }
957
+ .file-search-input {
958
+ width: 100%;
959
+ max-width: 360px;
960
+ background: var(--bg-secondary);
961
+ color: var(--text-primary);
962
+ border: 1px solid var(--border);
963
+ border-radius: var(--radius-sm);
964
+ padding: 8px 14px;
965
+ font-size: 13px;
966
+ font-family: inherit;
967
+ transition: border-color var(--transition);
968
+ }
969
+ .file-search-input::placeholder { color: var(--text-tertiary); }
970
+ .file-search-input:focus {
971
+ outline: none;
972
+ border-color: var(--blue);
973
+ box-shadow: 0 0 0 3px var(--blue-bg);
974
+ }
975
+
976
+ /* ── Filter Count ────────────────────────────────────────── */
977
+
978
+ .filter-count {
979
+ font-size: 10px;
980
+ opacity: .65;
981
+ font-weight: 400;
982
+ }
983
+
984
+ /* ── Restore Command Section ─────────────────────────────── */
985
+
986
+ .restore-cmd-section {
987
+ margin-top: 18px;
988
+ padding-top: 16px;
989
+ border-top: 1px solid var(--border);
990
+ }
991
+ .restore-cmd-label {
992
+ font-size: 10px;
993
+ font-weight: 700;
994
+ text-transform: uppercase;
995
+ letter-spacing: .08em;
996
+ color: var(--text-tertiary);
997
+ margin-bottom: 10px;
998
+ }
999
+ .restore-cmd-row {
1000
+ display: flex;
1001
+ align-items: center;
1002
+ gap: 8px;
1003
+ margin-bottom: 8px;
1004
+ }
1005
+ .restore-cmd-code {
1006
+ flex: 1;
1007
+ font-family: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
1008
+ font-size: 11px;
1009
+ background: var(--bg-primary);
1010
+ border: 1px solid var(--border-subtle);
1011
+ border-radius: var(--radius-sm);
1012
+ padding: 8px 12px;
1013
+ color: var(--green);
1014
+ word-break: break-all;
1015
+ line-height: 1.5;
1016
+ }
1017
+ .btn-restore-cmd {
1018
+ flex-shrink: 0;
1019
+ white-space: nowrap;
1020
+ }
1021
+
853
1022
  /* ── Utility ──────────────────────────────────────────────── */
854
1023
 
855
1024
  .hidden { display: none !important; }
@@ -2,11 +2,10 @@
2
2
 
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
- const { execFileSync } = require('child_process');
6
5
  const {
7
6
  color, loadConfig, gitAvailable, git, isGitRepo, gitDir: getGitDir,
8
7
  walkDir, filterFiles, buildManifest, loadManifest, saveManifest,
9
- manifestChanged, createLogger, unquoteGitPath,
8
+ manifestChanged, createLogger,
10
9
  } = require('./utils');
11
10
  const { createGitSnapshot, createShadowCopy } = require('./core/snapshot');
12
11
  const { cleanShadowRetention, cleanGitRetention } = require('./core/backups');
@@ -235,33 +234,9 @@ async function runBackup(projectDir, intervalOverride, opts = {}) {
235
234
  }
236
235
  if (!hasChanges) continue;
237
236
 
238
- // V4: Record change event and check for anomalies
237
+ // Shadow: pre-compute changed file count from manifest diff (already accurate)
239
238
  let changedFileCount = 0;
240
- let porcelain = '';
241
- if (repo) {
242
- // Use execFileSync directly — git() helper's trim() strips leading spaces
243
- // from porcelain output, corrupting the first line when it starts with ' '.
244
- try {
245
- porcelain = execFileSync('git', ['status', '--porcelain'], {
246
- cwd: projectDir, stdio: 'pipe', encoding: 'utf-8',
247
- });
248
- } catch { /* ignore */ }
249
- if (porcelain) {
250
- const lines = porcelain.split('\n').filter(Boolean);
251
- if (cfg.protect.length === 0 && cfg.ignore.length === 0) {
252
- changedFileCount = lines.length;
253
- } else {
254
- const changedPaths = lines.map(line => {
255
- const filePart = line.substring(3);
256
- const arrowIdx = filePart.indexOf(' -> ');
257
- const raw = arrowIdx >= 0 ? filePart.substring(arrowIdx + 4) : filePart;
258
- return unquoteGitPath(raw);
259
- });
260
- const fakeFiles = changedPaths.map(rel => ({ rel, full: path.join(projectDir, rel) }));
261
- changedFileCount = filterFiles(fakeFiles, cfg).length;
262
- }
263
- }
264
- } else if (pendingManifest) {
239
+ if (!repo && pendingManifest) {
265
240
  if (!lastManifest) {
266
241
  changedFileCount = Object.keys(pendingManifest).length;
267
242
  } else {
@@ -278,18 +253,12 @@ async function runBackup(projectDir, intervalOverride, opts = {}) {
278
253
  }
279
254
  }
280
255
 
281
- recordChange(tracker, changedFileCount);
282
- const anomalyResult = checkAnomaly(tracker);
283
- if (anomalyResult.anomaly && anomalyResult.alert && !anomalyResult.suppressed) {
284
- saveAlert(projectDir, anomalyResult.alert);
285
- logger.warn(`ALERT: ${anomalyResult.alert.fileCount} files changed in ${anomalyResult.alert.windowSeconds}s (threshold: ${anomalyResult.alert.threshold})`);
286
- }
287
-
288
- // Git snapshot via Core
256
+ // Git snapshot via Core — changedFileCount comes from diff-tree (accurate incremental)
289
257
  if ((cfg.backup_strategy === 'git' || cfg.backup_strategy === 'both') && repo) {
290
- const context = { trigger: 'auto', changedFileCount };
258
+ const context = { trigger: 'auto' };
291
259
  const snapResult = createGitSnapshot(projectDir, cfg, { branchRef, context });
292
260
  if (snapResult.status === 'created') {
261
+ changedFileCount = snapResult.changedCount != null ? snapResult.changedCount : 0;
293
262
  let msg = `Git snapshot ${snapResult.shortHash} (${snapResult.fileCount} files)`;
294
263
  if (snapResult.secretsExcluded) {
295
264
  msg += ` [secrets excluded: ${snapResult.secretsExcluded.join(', ')}]`;
@@ -302,6 +271,14 @@ async function runBackup(projectDir, intervalOverride, opts = {}) {
302
271
  }
303
272
  }
304
273
 
274
+ // V4: Record change event and check for anomalies (after snapshot, using accurate count)
275
+ recordChange(tracker, changedFileCount);
276
+ const anomalyResult = checkAnomaly(tracker);
277
+ if (anomalyResult.anomaly && anomalyResult.alert && !anomalyResult.suppressed) {
278
+ saveAlert(projectDir, anomalyResult.alert);
279
+ logger.warn(`ALERT: ${anomalyResult.alert.fileCount} files changed in ${anomalyResult.alert.windowSeconds}s (threshold: ${anomalyResult.alert.threshold})`);
280
+ }
281
+
305
282
  // Shadow copy via Core
306
283
  if (cfg.backup_strategy === 'shadow' || cfg.backup_strategy === 'both') {
307
284
  const shadowResult = createShadowCopy(projectDir, cfg, { backupDir });
@@ -48,6 +48,9 @@ const TRAILER_MAP = {
48
48
  'Intent': { key: 'intent' },
49
49
  'Agent': { key: 'agent' },
50
50
  'Session': { key: 'session' },
51
+ 'From': { key: 'from' },
52
+ 'Restore-To': { key: 'restoreTo' },
53
+ 'File': { key: 'restoreFile' },
51
54
  };
52
55
 
53
56
  function parseCommitTrailers(body) {
@@ -621,6 +621,46 @@ test('restoreFile respects pre_restore_backup=never from config', () => {
621
621
  }
622
622
  });
623
623
 
624
+ test('restoreFile rejects directory pathspec (git tree)', () => {
625
+ const tmpDir = createTempGitRepo();
626
+ try {
627
+ const headHash = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: tmpDir, stdio: 'pipe', encoding: 'utf-8' }).trim();
628
+ const result = restoreFile(tmpDir, 'src', headHash, { preserveCurrent: false });
629
+ assert.strictEqual(result.status, 'error');
630
+ assert.ok(result.error.includes('tree') || result.error.includes('directory'), `expected tree/directory error, got: ${result.error}`);
631
+ } finally {
632
+ cleanupDir(tmpDir);
633
+ }
634
+ });
635
+
636
+ test('restoreFile rejects protected .cursor directory', () => {
637
+ const tmpDir = createTempGitRepo();
638
+ try {
639
+ fs.mkdirSync(path.join(tmpDir, '.cursor'), { recursive: true });
640
+ fs.writeFileSync(path.join(tmpDir, '.cursor', 'mcp.json'), '{}');
641
+ execFileSync('git', ['add', '-A'], { cwd: tmpDir, stdio: 'pipe' });
642
+ execFileSync('git', ['commit', '-m', 'add .cursor'], { cwd: tmpDir, stdio: 'pipe' });
643
+ const headHash = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: tmpDir, stdio: 'pipe', encoding: 'utf-8' }).trim();
644
+ const result = restoreFile(tmpDir, '.cursor', headHash, { preserveCurrent: false });
645
+ assert.strictEqual(result.status, 'error');
646
+ assert.ok(result.error.includes('protected'), `expected protected path error, got: ${result.error}`);
647
+ } finally {
648
+ cleanupDir(tmpDir);
649
+ }
650
+ });
651
+
652
+ test('restoreFile rejects project root "."', () => {
653
+ const tmpDir = createTempGitRepo();
654
+ try {
655
+ const headHash = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: tmpDir, stdio: 'pipe', encoding: 'utf-8' }).trim();
656
+ const result = restoreFile(tmpDir, '.', headHash, { preserveCurrent: false });
657
+ assert.strictEqual(result.status, 'error');
658
+ assert.ok(result.error.includes('project root') || result.error.includes('specific file'), `expected root rejection, got: ${result.error}`);
659
+ } finally {
660
+ cleanupDir(tmpDir);
661
+ }
662
+ });
663
+
624
664
  test('createPreRestoreSnapshot creates ref under refs/guard/pre-restore/', () => {
625
665
  const tmpDir = createTempGitRepo();
626
666
  try {
@@ -217,14 +217,28 @@ function runDiagnostics(projectDir) {
217
217
  check('Disk space', 'WARN', 'could not determine free space');
218
218
  }
219
219
 
220
- // 12. Lock file
220
+ // 12. Lock file — distinguish running watcher from stale lock
221
221
  const lockFile = gDir
222
222
  ? path.join(gDir, 'cursor-guard.lock')
223
223
  : path.join(backupDir, 'cursor-guard.lock');
224
224
  if (fs.existsSync(lockFile)) {
225
225
  let content = '';
226
226
  try { content = fs.readFileSync(lockFile, 'utf-8').trim(); } catch { /* ignore */ }
227
- check('Lock file', 'WARN', `lock file exists — another instance may be running. ${content}`);
227
+ const pidMatch = content.match(/pid=(\d+)/);
228
+ const startedMatch = content.match(/started=(.+)/);
229
+ const lockPid = pidMatch ? parseInt(pidMatch[1], 10) : null;
230
+ let pidAlive = false;
231
+ if (lockPid) {
232
+ try { process.kill(lockPid, 0); pidAlive = true; } catch { /* not running */ }
233
+ }
234
+ if (lockPid && pidAlive) {
235
+ const since = startedMatch ? startedMatch[1] : 'unknown';
236
+ check('Lock file', 'PASS', `watcher running (pid=${lockPid}, since ${since})`);
237
+ } else if (lockPid && !pidAlive) {
238
+ check('Lock file', 'WARN', `stale lock file (pid=${lockPid} is dead) — safe to delete or run doctor_fix`);
239
+ } else {
240
+ check('Lock file', 'WARN', `lock file exists — another instance may be running. ${content}`);
241
+ }
228
242
  } else {
229
243
  check('Lock file', 'PASS', 'no lock file (no running instance)');
230
244
  }
@@ -31,7 +31,7 @@ const GUARD_CONFIGS = ['.cursor-guard.json', '.gitignore'];
31
31
 
32
32
  function isToolPath(filePath) {
33
33
  const normalized = filePath.replace(/\\/g, '/');
34
- if (TOOL_DIRS.some(d => normalized.startsWith(d))) return true;
34
+ if (TOOL_DIRS.some(d => normalized.startsWith(d) || normalized === d.replace(/\/$/, ''))) return true;
35
35
  if (GUARD_CONFIGS.includes(normalized)) return true;
36
36
  return false;
37
37
  }
@@ -93,7 +93,7 @@ function restoreFile(projectDir, file, source, opts = {}) {
93
93
 
94
94
  // Pre-restore snapshot (git path)
95
95
  if (preserveCurrent && repo) {
96
- const preRestoreResult = createPreRestoreSnapshot(projectDir, file);
96
+ const preRestoreResult = createPreRestoreSnapshot(projectDir, file, { source, file: pathCheck.normalized });
97
97
  if (preRestoreResult.status === 'created') {
98
98
  result.preRestoreRef = preRestoreResult.ref;
99
99
  result.preRestoreShortHash = preRestoreResult.shortHash;
@@ -131,6 +131,10 @@ function restoreFile(projectDir, file, source, opts = {}) {
131
131
  // Restore from shadow copy
132
132
  if (isShadowSource) {
133
133
  try {
134
+ const stat = fs.statSync(shadowDir);
135
+ if (stat.isDirectory()) {
136
+ return { status: 'error', restoredFrom: source, error: `'${file}' is a directory, not a file — use restore_project for directory-level restores` };
137
+ }
134
138
  const dest = path.join(projectDir, file);
135
139
  fs.mkdirSync(path.dirname(dest), { recursive: true });
136
140
  fs.copyFileSync(shadowDir, dest);
@@ -144,24 +148,21 @@ function restoreFile(projectDir, file, source, opts = {}) {
144
148
 
145
149
  // Restore from git
146
150
  try {
147
- // Verify the source ref/hash is valid
148
151
  const resolved = git(['rev-parse', '--verify', source], { cwd: projectDir, allowFail: true });
149
152
  if (!resolved) {
150
153
  return { status: 'error', restoredFrom: source, error: `cannot resolve git source: ${source}` };
151
154
  }
152
155
 
153
- // Check that the file exists in the source
154
- const fileExists = git(['cat-file', '-e', `${resolved}:${file}`], { cwd: projectDir, allowFail: true });
155
- if (fileExists === null) {
156
- // cat-file -e returns empty on success with allowFail, null on error
157
- // Try ls-tree instead
158
- const lsOut = git(['ls-tree', resolved, '--', file], { cwd: projectDir, allowFail: true });
159
- if (!lsOut) {
160
- return { status: 'error', restoredFrom: source, error: `file '${file}' not found in source ${source}` };
161
- }
156
+ // Verify the target is a blob (file), not a tree (directory)
157
+ const objType = git(['cat-file', '-t', `${resolved}:${pathCheck.normalized}`], { cwd: projectDir, allowFail: true });
158
+ if (!objType) {
159
+ return { status: 'error', restoredFrom: source, error: `'${file}' not found in source ${source}` };
160
+ }
161
+ if (objType !== 'blob') {
162
+ return { status: 'error', restoredFrom: source, error: `'${file}' is a ${objType} (directory), not a file — use restore_project for directory-level restores` };
162
163
  }
163
164
 
164
- execFileSync('git', ['restore', `--source=${resolved}`, '--', file], {
165
+ execFileSync('git', ['restore', `--source=${resolved}`, '--', pathCheck.normalized], {
165
166
  cwd: projectDir, stdio: 'pipe',
166
167
  });
167
168
 
@@ -295,7 +296,7 @@ function executeProjectRestore(projectDir, source, opts = {}) {
295
296
  const result = { filesRestored: 0, files: effectiveFiles };
296
297
 
297
298
  if (preserveCurrent) {
298
- const snap = createPreRestoreSnapshot(projectDir, null);
299
+ const snap = createPreRestoreSnapshot(projectDir, null, { source });
299
300
  if (snap.status === 'created') {
300
301
  result.preRestoreRef = snap.ref;
301
302
  result.preRestoreShortHash = snap.shortHash;
@@ -370,9 +371,12 @@ function executeProjectRestore(projectDir, source, opts = {}) {
370
371
  *
371
372
  * @param {string} projectDir
372
373
  * @param {string} [scope] - Specific file to check for changes, or null for all
374
+ * @param {object} [opts]
375
+ * @param {string} [opts.source] - Target restore source (commit hash or ref)
376
+ * @param {string} [opts.file] - File being restored (single-file restore only)
373
377
  * @returns {{ status: 'created'|'skipped'|'error', ref?: string, shortHash?: string, error?: string }}
374
378
  */
375
- function createPreRestoreSnapshot(projectDir, scope) {
379
+ function createPreRestoreSnapshot(projectDir, scope, opts = {}) {
376
380
  const gDir = getGitDir(projectDir);
377
381
  if (!gDir) return { status: 'error', error: 'not a git repository' };
378
382
 
@@ -410,6 +414,12 @@ function createPreRestoreSnapshot(projectDir, scope) {
410
414
 
411
415
  let msg = `guard: pre-restore snapshot ${ts}`;
412
416
  msg += '\n\nTrigger: pre-restore';
417
+ msg += `\nFrom: ${head.substring(0, 7)}`;
418
+ if (opts.source) {
419
+ const targetShort = git(['rev-parse', '--short', opts.source], { cwd, allowFail: true }) || opts.source;
420
+ msg += `\nRestore-To: ${targetShort}`;
421
+ }
422
+ if (opts.file) msg += `\nFile: ${opts.file}`;
413
423
  const commitHash = execFileSync('git', [
414
424
  'commit-tree', tree, '-p', head, '-m', msg,
415
425
  ], { cwd, stdio: 'pipe', encoding: 'utf-8' }).trim();
@@ -15,6 +15,8 @@ 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');
19
+
18
20
  const pkg = require('../../package.json');
19
21
 
20
22
  // ── Alert injection helper ──────────────────────────────────────
@@ -32,6 +34,33 @@ function injectAlert(projectPath, result) {
32
34
  return result;
33
35
  }
34
36
 
37
+ // ── Watcher status check ────────────────────────────────────────
38
+
39
+ function isWatcherRunning(projectDir) {
40
+ const fs = require('fs');
41
+ const gDir = getGitDir(projectDir);
42
+ const lockFile = gDir
43
+ ? path.join(gDir, 'cursor-guard.lock')
44
+ : path.join(projectDir, '.cursor-guard-backup', 'cursor-guard.lock');
45
+ if (!fs.existsSync(lockFile)) return false;
46
+ try {
47
+ const content = fs.readFileSync(lockFile, 'utf-8');
48
+ const pidMatch = content.match(/pid=(\d+)/);
49
+ if (pidMatch) {
50
+ process.kill(parseInt(pidMatch[1], 10), 0);
51
+ return true;
52
+ }
53
+ } catch { /* pid gone or lock unreadable */ }
54
+ return false;
55
+ }
56
+
57
+ function injectWatcherWarning(projectPath, result) {
58
+ if (!isWatcherRunning(projectPath)) {
59
+ result._warning = 'Watcher is NOT running — auto-backup protection is inactive. Any file changes made without a manual snapshot_now call will NOT be captured. Consider starting the watcher or calling snapshot_now before making changes.';
60
+ }
61
+ return result;
62
+ }
63
+
35
64
  // ── Server ──────────────────────────────────────────────────────
36
65
 
37
66
  const server = new McpServer({
@@ -50,6 +79,7 @@ server.tool(
50
79
  async ({ path: projectPath }) => {
51
80
  const resolved = path.resolve(projectPath);
52
81
  const result = injectAlert(resolved, runDiagnostics(resolved));
82
+ injectWatcherWarning(resolved, result);
53
83
  return {
54
84
  content: [{
55
85
  type: 'text',
@@ -128,6 +158,7 @@ server.tool(
128
158
  }
129
159
 
130
160
  injectAlert(resolved, results);
161
+ injectWatcherWarning(resolved, results);
131
162
 
132
163
  return {
133
164
  content: [{
@@ -154,6 +185,7 @@ server.tool(
154
185
  const result = injectAlert(resolved, restoreFile(resolved, file, source, {
155
186
  preserveCurrent: preserve_current,
156
187
  }));
188
+ injectWatcherWarning(resolved, result);
157
189
  return {
158
190
  content: [{
159
191
  type: 'text',
@@ -187,6 +219,7 @@ server.tool(
187
219
  preserveCurrent: preserve_current,
188
220
  cleanUntracked: clean_untracked,
189
221
  }));
222
+ injectWatcherWarning(resolved, result);
190
223
  return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
191
224
  }
192
225
  );