cursor-guard 4.3.5 → 4.4.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/README.md CHANGED
@@ -377,6 +377,15 @@ The skill activates on these signals:
377
377
 
378
378
  ## Changelog
379
379
 
380
+ ### v4.4.0 — V4 Final
381
+
382
+ - **Fix**: First snapshot now generates "Added N: file1, file2, ..." summary instead of blank — previously the very first backup had no summary because there was no parent tree to diff against
383
+ - **Feature**: `--dashboard` flag for watcher — `npx cursor-guard-backup --path <dir> --dashboard` starts the web dashboard alongside the watcher in a single process. Optional port: `--dashboard 4000`. Auto-increments if port is busy
384
+ - **Feature**: Doctor check "Git retention" — warns when git backup commits exceed 500 and `git_retention.enabled` is `false`, guiding users to enable auto-pruning before refs grow unbounded
385
+ - **Feature**: Doctor check "Backup integrity" — verifies that the latest auto-backup commit's tree object is reachable via `git cat-file -t`, catching silent corruption early
386
+ - **Improve**: `cursor-guard-init` now detects existing `.cursor-guard.json` and displays an upgrade notice instead of silently overwriting
387
+ - **Improve**: Dashboard server refactored to export `startDashboardServer()` for embedding into other processes
388
+
380
389
  ### v4.3.5
381
390
 
382
391
  - **Fix**: Backup summary now uses incremental `diff-tree` instead of `git status --porcelain` — previously summary always showed cumulative changes since HEAD, now correctly shows changes since the last auto-backup
package/README.zh-CN.md CHANGED
@@ -377,6 +377,15 @@ node references\dashboard\server.js --path "D:\MyProject"
377
377
 
378
378
  ## 更新日志
379
379
 
380
+ ### v4.4.0 — V4 收官版
381
+
382
+ - **修复**:首次快照现在会生成 "Added N: file1, file2, ..." 摘要,而不是空白——之前第一次备份因为没有 parent tree 对比所以 summary 始终为空
383
+ - **功能**:Watcher `--dashboard` 参数——`npx cursor-guard-backup --path <dir> --dashboard` 启动时同时启动 Web 仪表盘,单进程完成监控+查看。可选端口:`--dashboard 4000`,端口被占自动递增
384
+ - **功能**:Doctor 新增 "Git retention" 检查——当 Git 备份 commit 数超过 500 且 `git_retention.enabled` 为 `false` 时发出 WARN,引导用户开启自动清理防止 ref 无限增长
385
+ - **功能**:Doctor 新增 "Backup integrity" 检查——通过 `git cat-file -t` 验证最近一次 auto-backup commit 的 tree 对象是否可达,尽早发现静默损坏
386
+ - **改进**:`cursor-guard-init` 现在检测已有 `.cursor-guard.json`,显示升级提示而非静默覆盖
387
+ - **改进**:Dashboard server 重构,导出 `startDashboardServer()` 供嵌入其他进程使用
388
+
380
389
  ### v4.3.5
381
390
 
382
391
  - **修复**:备份摘要(Summary)现使用增量 `diff-tree` 替代 `git status --porcelain`——之前 summary 始终显示自 HEAD 以来的累计差异,现在正确显示自上次 auto-backup 以来的增量变化
package/ROADMAP.md CHANGED
@@ -3,8 +3,8 @@
3
3
  > 本文档描述 cursor-guard 从 V2 到 V7 的长期演进方向。
4
4
  > 每一代向下兼容,低版本功能永远不废弃。
5
5
  >
6
- > **当前版本**:`V4.3.3`
7
- > **文档状态**:`V2` ~ `V4.3` 已实施(含 V5 intent 基础),`V5` 主体规划中
6
+ > **当前版本**:`V4.4.0`(V4 收官版)
7
+ > **文档状态**:`V2` ~ `V4.4.0` 已实施(含 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` | 可证明 | + 可验证信任 + 治理层 | "能证明安全流程被执行了" |
@@ -65,7 +65,7 @@ V2-V3 的目标是让用户"离不开",V6-V7 的目标是让行业"绕不开"
65
65
  | `MCP Server` | `V3.1-V4.0` ✅ 已完成 | 9 个工具,Agent 标准调用入口,结构化 JSON 返回 | 否 |
66
66
  | `智能提醒 / 可观测` | `V4.0` ✅ 已完成 | 主动发现风险、汇总健康状态(anomaly + dashboard) | 否 |
67
67
  | `Web 仪表盘` | `V4.2` ✅ 已完成 | 本地只读 Web UI,备份/恢复点/诊断/保护范围,中英双语 | 否 |
68
- | `备份上下文 + Intent` | `V4.3` ✅ 已完成 | 结构化 commit trailer(Files-Changed/Summary/Trigger/Intent/Agent/Session),仪表盘可追溯 | 否 |
68
+ | `备份上下文 + Intent` | `V4.3` ✅ 已完成 | 结构化 commit trailer(Files-Changed/Summary/Trigger/Intent/Agent/Session),增量 diff-tree 真实摘要,仪表盘可追溯 | 否 |
69
69
  | `变更控制层` | `V5` 规划(intent 基础已在 V4.3 落地) | 覆盖编辑意图、冲突告警、影响半径、审计事件、按事件恢复 | 否 |
70
70
  | `开放协议 / 团队工作流` | `V6` 规划 | 把变更控制能力提炼成跨工具协议、适配器和 CI 查询入口 | 否 |
71
71
  | `治理 / 可验证层` | `V7` 规划 | 让“是否走过安全流程”变成可以证明、可以审计、可以验证的事实 | 否 |
@@ -454,6 +454,9 @@ V4 经过 4 轮系统性代码审查,修复了以下关键问题:
454
454
  | V4.3.1 | `restore_project` 保护 `.gitignore`;`cursor-guard-index.lock` 清理;summary 按 protect/ignore 过滤 + 分类格式 | ✅ |
455
455
  | V4.3.2 | `cursor-guard-init` 自动添加根目录 `node_modules/` 到 `.gitignore`;doctor MCP 版本提示含重载快捷键 | ✅ |
456
456
  | V4.3.3 | **Intent 上下文**(V5 基础前置):`snapshot_now` 支持 `intent` / `agent` / `session` 参数,Git trailer 存储,仪表盘展示意图徽章和完整审计字段 | ✅ |
457
+ | V4.3.4 | **运维加固**:`backup.log` 日志轮转(1MB / 3 文件);watcher 单实例保护加固(锁文件时间戳 + 24h 超时);`previewProjectRestore` 保护路径分组摘要(降低 token 消耗);SKILL.md 硬规则 #15(升级后提交 skill 文件) | ✅ |
458
+ | V4.3.5 | **Summary 准确性修复 + UI 优化**:备份摘要改用 `diff-tree` 增量对比(修复 porcelain 假摘要 bug);仪表盘变更列三行堆叠布局;配色全面优化(背景层级 / 状态色 / 文字层级) | ✅ |
459
+ | V4.4.0 | **V4 收官版**:首次快照 summary(无 parent 时生成 Added N: ...);doctor 新增 Git retention 警告(>500 commits + disabled)和 Backup integrity 校验(`cat-file -t` tree 可达性);`cursor-guard-init` 升级检测(已有配置提示) | ✅ |
457
460
 
458
461
  > **注**:V4.2 的 Web 仪表盘最初在 V4.0 规划中标记为"不做",但用户需求明确后实施。事实证明只读仪表盘投入产出比合理,且不违反安全原则。
459
462
 
@@ -511,7 +514,7 @@ V5 不是"三个方向选一个",而是把下面这条链路做完整:
511
514
 
512
515
  | 模块 / 能力 | AI 要做什么 | 建议产物 | 完成标准 |
513
516
  |---|---|---|---|
514
- | `intent registry` | 在高风险写入前注册编辑意图,记录 agent、会话、工作区、分支、目标文件、风险级别 | `core/intent.*` 或同等模块 | 能列出活跃会话,能释放会话,能查到谁准备改哪个文件。**基础版已在 V4.3.3 落地**:`snapshot_now` 支持 `intent` / `agent` / `session` 参数,存储为 Git commit trailer,仪表盘可展示 |
517
+ | `intent registry` | 在高风险写入前注册编辑意图,记录 agent、会话、工作区、分支、目标文件、风险级别 | `core/intent.*` 或同等模块 | 能列出活跃会话,能释放会话,能查到谁准备改哪个文件。**基础版已在 V4.3.3 落地**:`snapshot_now` 支持 `intent` / `agent` / `session` 参数,存储为 Git commit trailer,仪表盘可展示。**V4.3.5 修复**:summary 改用 `diff-tree` 增量对比,确保元数据准确 |
515
518
  | `pre-edit snapshot` | 在每次高风险 AI 写入前创建 `refs/guard/pre-edit/*` 恢复点 | `refs/guard/pre-edit/<session>/<seq>` | 任意一条 AI 编辑事件都能关联到写前快照 |
516
519
  | `conflict detection` | 先做文件路径级冲突检测,再预留符号级增强位 | `detectConflicts()` / `listConflicts()` | 两个会话同时改重叠文件时能给出 advisory warning |
517
520
  | `audit store` | 以 append-only 方式保存 AI 编辑事件 | 默认本地 `JSONL`;后续可升级 `SQLite` | 能按文件 / 会话 / agent / 时间 / 风险级别查询。**雏形已在 V4.3.0-V4.3.3 落地**:审计元数据通过 Git commit trailer 持久化,`listBackups` 可按 trigger/intent/agent/session 解析 |
@@ -952,8 +955,8 @@ V7.2 完整 attestation(安全操作证明链)
952
955
 
953
956
  | | V2 | V3 | V4 | V5 | V6 | V7 |
954
957
  |---|---|---|---|---|---|---|
955
- | **一句话** | 能恢复 | 更稳更省 | 主动提醒 + 可观测 + Intent | 变更闭环 | 跨工具标准 | 可证明 |
956
- | **核心架构** | Skill + Script | + Core + MCP | + 智能检测 + Web 仪表盘 + Intent 基础 | + 变更控制层 | + 开放协议 + 适配器 | + 治理层 |
958
+ | **一句话** | 能恢复 | 更稳更省 | 主动提醒 + 可观测 + 可追溯 | 变更闭环 | 跨工具标准 | 可证明 |
959
+ | **核心架构** | Skill + Script | + Core + MCP | + 智能检测 + Web 仪表盘 + Intent + 增量摘要 | + 变更控制层 | + 开放协议 + 适配器 | + 治理层 |
957
960
  | **Agent 调用** | 拼 shell | 优先 MCP | MCP + 主动建议 | MCP + 意图 / 审计 / 恢复 | 标准接口 + 适配器 | 标准接口 + 审计 |
958
961
  | **安装门槛** | 最低 | 不变 | 不变 | 略增 | 看具体实现 | 看具体实现 |
959
962
  | **适合谁** | 所有人 | 所有人 | 所有人 | 重度 AI 用户 + 团队试点 | 工具开发者 + 团队 | 企业 + 合规场景 |
@@ -975,10 +978,17 @@ V3.4 ────── ✅ MCP 自检
975
978
  │ 前提:MCP 调用成功率 > 95%,token 消耗可观测下降
976
979
 
977
980
  V4.0 ────── ✅ 智能恢复建议 + 备份健康看板 + 4 轮代码审查加固(138 测试)
978
- V4.1 ────── ✅ 用户反馈修复
979
- V4.2 ────── ✅ Web 仪表盘(只读、双语、多项目)+ 代码审查修复
980
- V4.3 ────── 备份上下文元数据 + Intent 上下文 + restore 安全加固 ← 当前版本
981
- V4.x ────── 候选支线(完整性校验 / 更丰富的审计查询)
981
+ V4.1 ────── ✅ 用户反馈修复(fileCount 精度、安装流程、PowerShell 兼容)
982
+ V4.2.0 ───── ✅ Web 仪表盘(只读、双语、多项目、聚合 API)
983
+ V4.2.1 ─────代码审查修复(t() replaceAll、未用导入、过滤栏补全)
984
+ V4.2.2 ───── restore 保护 .cursor-guard.json + init 提示 git commit
985
+ V4.3.0 ───── ✅ 备份上下文元数据(Git trailer: Files-Changed / Summary / Trigger)
986
+ V4.3.1 ───── ✅ restore 保护 .gitignore + lock 清理 + summary 过滤/分类
987
+ V4.3.2 ───── ✅ init 自动添加 node_modules/ 到 .gitignore + doctor 重载提示
988
+ V4.3.3 ───── ✅ Intent 上下文(intent / agent / session trailer + 仪表盘展示)
989
+ V4.3.4 ───── ✅ 运维加固(日志轮转 / 锁文件时间戳 / preview 分组 / SKILL 规则)
990
+ V4.3.5 ───── ✅ Summary 增量 diff-tree 修复 + 变更列堆叠布局 + 配色优化
991
+ V4.4.0 ───── ✅ V4 收官:首次快照 summary + doctor 完整性/retention 检查 + init 升级检测 ← 当前版本
982
992
 
983
993
  │ 前提:AI 编辑需要更强的追溯 / 恢复 / 查询闭环
984
994
  │ 前提:多 Agent / 多工具协作成为真实场景
@@ -1022,14 +1032,18 @@ V7 的"可验证治理"是这条产品线的逻辑终点——该保护的都保
1022
1032
 
1023
1033
  ## 给用户说的话
1024
1034
 
1025
- ### 现在(V4.3)
1035
+ ### 现在(V4.3.5
1026
1036
 
1027
1037
  > cursor-guard 已经能保护你的代码,而且越来越聪明。
1028
1038
  > 自动备份、写前快照、确定性恢复——开箱即用。
1029
- > V3 新增:MCP 工具调用(可选)让 AI 操作更稳、更快、更省 token。
1030
- > V4.0 新增:系统会主动监测异常变更并提醒你,一个 `dashboard` 就能看全局健康状态。
1031
- > V4.2 新增:本地 Web 仪表盘——健康、备份、恢复点、诊断一页可见,中英双语自动刷新。
1032
- > V4.3 新增:每次备份带上下文(改了什么、为什么备份、哪个 AI 在操作),在仪表盘可追溯。
1039
+ >
1040
+ > **V3**:MCP 工具调用(可选)让 AI 操作更稳、更快、更省 token。
1041
+ > **V4.0**:系统会主动监测异常变更并提醒你,一个 `dashboard` 就能看全局健康状态。
1042
+ > **V4.2**:本地 Web 仪表盘——健康、备份、恢复点、诊断一页可见,中英双语自动刷新。
1043
+ > **V4.3.0-4.3.3**:每次备份带上下文(改了什么、为什么备份、哪个 AI 在操作),Intent 意图可追溯。
1044
+ > **V4.3.4**:运维加固——日志轮转、锁文件保护、restore 预览分组降低 token 消耗。
1045
+ > **V4.3.5**:修复了备份摘要准确性(增量 diff-tree);仪表盘变更列分层展示,配色全面优化。
1046
+ >
1033
1047
  > 经过 4 轮代码审查,138+ 个测试覆盖所有核心路径。
1034
1048
 
1035
1049
  ### 未来
@@ -1059,4 +1073,4 @@ V7 的"可验证治理"是这条产品线的逻辑终点——该保护的都保
1059
1073
  ---
1060
1074
 
1061
1075
  *最后更新:2026-03-22*
1062
- *版本:v1.3(V4.3.3 交付,含 Web 仪表盘、备份上下文元数据、Intent 基础)*
1076
+ *版本:v1.5(V4.4.0 收官版,含 Web 仪表盘、备份上下文元数据、Intent 基础、增量 summary、doctor 完整性校验、运维加固、UI 优化)*
package/SKILL.md CHANGED
@@ -147,7 +147,7 @@ When the target file of an edit **falls outside the protected scope**, the agent
147
147
 
148
148
  > **MCP shortcut**: if `snapshot_now` tool is available, call it with `{ "path": "<project>", "strategy": "git" }` instead of the shell commands below. The tool handles temp index, secrets exclusion, and ref creation internally, and returns `{ "git": { "status": "created", "commitHash": "...", "shortHash": "..." } }`. Report the `shortHash` to the user and proceed.
149
149
  >
150
- > **Best practice — intent context**: Always provide `intent` to describe *what operation you are about to perform and why*. This creates an audit trail so the user can later understand "what was the AI doing when this backup was made". Also pass `message` for the commit subject. Example:
150
+ > **Best practice — intent context**: Before making high-risk changes, call `snapshot_now` with `intent` to directly record what you are about to do. The intent is stored in the Git commit trailer no intermediary, no file bridge, no concurrency issues. Example:
151
151
  > ```json
152
152
  > {
153
153
  > "path": "/project",
@@ -158,7 +158,9 @@ When the target file of an edit **falls outside the protected scope**, the agent
158
158
  > "session": "6290c87f"
159
159
  > }
160
160
  > ```
161
- > The `intent`, `agent`, and `session` fields are stored as Git commit trailers and displayed in the dashboard restore-point list and detail drawer, forming a complete audit trail per operation.
161
+ > The `intent`, `agent`, and `session` fields are stored as Git commit trailers and displayed in the dashboard restore-point list and detail drawer.
162
+ >
163
+ > **Timeline the user sees**: manual snapshot with intent ("AI准备重构 calculator.js") → auto-backup with file changes ("Modified 2: src/app.js (+15 -3)"). The causal relationship is clear from ordering — the manual snapshot explains WHY, the auto-backup shows WHAT changed.
162
164
 
163
165
  Use a **temporary index and dedicated ref** so the user's staged/unstaged state is never touched:
164
166
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursor-guard",
3
- "version": "4.3.5",
3
+ "version": "4.4.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",
@@ -12,6 +12,7 @@ if (args.help || args.h) {
12
12
  Options:
13
13
  --path <dir> Project directory to watch (default: current dir)
14
14
  --interval <sec> Override backup interval in seconds
15
+ --dashboard [port] Start dashboard server alongside watcher (default port: 3120)
15
16
  --help, -h Show this help message
16
17
  --version, -v Show version number`);
17
18
  process.exit(0);
@@ -27,5 +28,12 @@ const targetPath = args.path || '.';
27
28
  const interval = parseInt(args.interval, 10) || 0;
28
29
  const resolved = path.resolve(targetPath);
29
30
 
31
+ const opts = {};
32
+ if (args.dashboard !== undefined) {
33
+ opts.dashboardPort = (typeof args.dashboard === 'string' && /^\d+$/.test(args.dashboard))
34
+ ? parseInt(args.dashboard, 10)
35
+ : 3120;
36
+ }
37
+
30
38
  const { runBackup } = require('../lib/auto-backup');
31
- runBackup(resolved, interval);
39
+ runBackup(resolved, interval, opts);
@@ -56,6 +56,13 @@ console.log(` Source: ${skillSource}`);
56
56
  console.log(` Target: ${skillTarget}`);
57
57
  console.log(` Mode: ${isGlobal ? 'global (~/.cursor/skills/)' : 'project-local (.cursor/skills/)'}\n`);
58
58
 
59
+ // Pre-check: warn if .cursor-guard.json already exists (upgrade scenario)
60
+ const configPath = path.join(projectDir, '.cursor-guard.json');
61
+ if (fs.existsSync(configPath)) {
62
+ console.log(' NOTE: .cursor-guard.json already exists — your config will be preserved.');
63
+ console.log(' Only skill files will be updated (upgrade mode).\n');
64
+ }
65
+
59
66
  // Step 1: Copy skill files (excluding node_modules and .git)
60
67
  console.log(' [1/4] Copying skill files...');
61
68
  if (fs.existsSync(skillTarget)) {
@@ -565,7 +565,9 @@ function relativeTime(ts) {
565
565
  /* ── Data fetching ────────────────────────────────────────── */
566
566
 
567
567
  async function fetchJson(url) {
568
- const r = await fetch(url);
568
+ const sep = url.includes('?') ? '&' : '?';
569
+ const tokenParam = window.__GUARD_TOKEN__ ? `${sep}token=${window.__GUARD_TOKEN__}` : '';
570
+ const r = await fetch(url + tokenParam);
569
571
  if (!r.ok) throw new Error(`HTTP ${r.status}`);
570
572
  return r.json();
571
573
  }
@@ -577,10 +579,45 @@ async function loadProjects() {
577
579
  }
578
580
  }
579
581
 
580
- async function loadPageData() {
582
+ async function loadPageData(opts = {}) {
581
583
  if (!state.currentProjectId) return;
582
- state.pageData = await fetchJson(`/api/page-data?id=${state.currentProjectId}`);
583
- state.lastRefreshAt = Date.now();
584
+ const id = state.currentProjectId;
585
+
586
+ if (opts.progressive) {
587
+ state.pageData = { dashboard: null, doctor: null, backups: null };
588
+ const dashPromise = fetchJson(`/api/page-data?id=${id}&scope=dashboard`);
589
+ const restPromise = Promise.allSettled([
590
+ fetchJson(`/api/page-data?id=${id}&scope=backups`),
591
+ fetchJson(`/api/page-data?id=${id}&scope=doctor`),
592
+ ]);
593
+
594
+ const dash = await dashPromise;
595
+ state.pageData.dashboard = dash.dashboard;
596
+ state.lastRefreshAt = Date.now();
597
+ showContent();
598
+ if (dash.dashboard && !dash.dashboard.error) {
599
+ renderStrategyBadge(dash.dashboard.strategy);
600
+ renderOverview(dash.dashboard);
601
+ renderProtection(dash.dashboard.protectionScope);
602
+ }
603
+
604
+ const [backupsResult, doctorResult] = await restPromise;
605
+ if (backupsResult.status === 'fulfilled') {
606
+ state.pageData.backups = backupsResult.value.backups;
607
+ if (state.pageData.dashboard) {
608
+ renderBackupsSection(state.pageData.dashboard, Array.isArray(state.pageData.backups) ? state.pageData.backups : []);
609
+ }
610
+ }
611
+ if (doctorResult.status === 'fulfilled') {
612
+ state.pageData.doctor = doctorResult.value.doctor;
613
+ if (state.pageData.doctor && !state.pageData.doctor.error) {
614
+ renderDiagnostics(state.pageData.doctor);
615
+ }
616
+ }
617
+ } else {
618
+ state.pageData = await fetchJson(`/api/page-data?id=${state.currentProjectId}`);
619
+ state.lastRefreshAt = Date.now();
620
+ }
584
621
  }
585
622
 
586
623
  /* ── Refresh ──────────────────────────────────────────────── */
@@ -633,21 +670,18 @@ function renderStrategyBadge(strategy) {
633
670
 
634
671
  /* ── Rendering: Global states ─────────────────────────────── */
635
672
 
636
- function showLoading() {
637
- show($('#loading-state'));
673
+ function showSkeleton() {
638
674
  hide($('#error-state'));
639
- $$('.screen').forEach(s => hide(s));
675
+ $$('.screen').forEach(s => show(s));
640
676
  }
641
677
 
642
678
  function showGlobalError(msg) {
643
- hide($('#loading-state'));
644
679
  show($('#error-state'));
645
680
  $$('.screen').forEach(s => hide(s));
646
681
  $('#error-message').textContent = msg || t('error.fetchFailed');
647
682
  }
648
683
 
649
684
  function showContent() {
650
- hide($('#loading-state'));
651
685
  hide($('#error-state'));
652
686
  $$('.screen').forEach(s => show(s));
653
687
  }
@@ -819,9 +853,10 @@ function translateSummary(raw) {
819
853
  }
820
854
 
821
855
  function formatSummaryCell(b) {
822
- const line1 = [];
823
- if (b.filesChanged != null) line1.push(`<span class="summary-files">${b.filesChanged} ${t('summary.files')}</span>`);
824
- if (b.trigger) line1.push(`<span class="badge badge-trigger">${t('trigger.' + b.trigger)}</span>`);
856
+ let line1 = '';
857
+ if (b.filesChanged != null) {
858
+ line1 = `<div class="summary-meta"><span class="summary-files">${b.filesChanged} ${t('summary.files')}</span></div>`;
859
+ }
825
860
 
826
861
  let line2 = '';
827
862
  if (b.intent) {
@@ -834,13 +869,12 @@ function formatSummaryCell(b) {
834
869
 
835
870
  let line3 = '';
836
871
  if (b.summary) {
837
- const translated = translateSummary(b.summary);
838
- const short = translated.length > 90 ? translated.substring(0, 87) + '...' : translated;
839
- line3 = `<div class="summary-detail">${esc(short)}</div>`;
872
+ const categories = b.summary.split('; ').map(s => translateSummary(s));
873
+ line3 = categories.map(c => `<div class="summary-detail-line">${esc(c)}</div>`).join('');
840
874
  }
841
875
 
842
- if (!line1.length && !line2 && !line3) return '<span class="text-muted text-sm">-</span>';
843
- return `<div class="summary-stack">${line1.length ? '<div class="summary-meta">' + line1.join(' ') + '</div>' : ''}${line2}${line3}</div>`;
876
+ if (!line1 && !line2 && !line3) return '<span class="text-muted text-sm">-</span>';
877
+ return `<div class="summary-stack">${line1}${line2}${line3}</div>`;
844
878
  }
845
879
 
846
880
  function renderBackupTable(backups) {
@@ -971,7 +1005,10 @@ function openRestoreDrawer(backup) {
971
1005
  if (backup.agent) fields.push({ key: 'drawer.field.agent', val: backup.agent });
972
1006
  if (backup.session) fields.push({ key: 'drawer.field.session', val: backup.session });
973
1007
  if (backup.message) fields.push({ key: 'drawer.field.message', val: backup.message });
974
- if (backup.summary) fields.push({ key: 'drawer.field.summary', val: translateSummary(backup.summary) });
1008
+ if (backup.summary) {
1009
+ const translated = backup.summary.split('; ').map(s => translateSummary(s)).join('\n');
1010
+ fields.push({ key: 'drawer.field.summary', val: translated, pre: true });
1011
+ }
975
1012
 
976
1013
  const refText = backup.ref || backup.shortHash || backup.timestamp || '';
977
1014
  const jsonText = JSON.stringify(backup, null, 2);
@@ -980,7 +1017,10 @@ function openRestoreDrawer(backup) {
980
1017
  ${fields.map(f => `
981
1018
  <div class="restore-field">
982
1019
  <div class="restore-field-label">${t(f.key)}</div>
983
- <div class="restore-field-value text-mono">${esc(f.val)}</div>
1020
+ ${f.pre
1021
+ ? `<pre class="restore-field-value text-mono summary-pre">${esc(f.val)}</pre>`
1022
+ : `<div class="restore-field-value text-mono">${esc(f.val)}</div>`
1023
+ }
984
1024
  </div>
985
1025
  `).join('')}
986
1026
  <div class="restore-actions">
@@ -1123,12 +1163,12 @@ async function init() {
1123
1163
  document.documentElement.lang = state.locale === 'zh-CN' ? 'zh-CN' : 'en';
1124
1164
  document.title = t('app.title');
1125
1165
  updateStaticI18n();
1126
- showLoading();
1166
+ showSkeleton();
1127
1167
 
1128
1168
  try {
1129
1169
  await loadProjects();
1130
1170
  renderProjectSelect();
1131
- await loadPageData();
1171
+ await loadPageData({ progressive: true });
1132
1172
  renderAll();
1133
1173
  startRefresh();
1134
1174
  } catch (e) {
@@ -33,12 +33,6 @@
33
33
 
34
34
  <main id="content">
35
35
 
36
- <!-- Loading -->
37
- <div id="loading-state" class="state-panel">
38
- <div class="spinner"></div>
39
- <p data-i18n="state.loading">Loading…</p>
40
- </div>
41
-
42
36
  <!-- Global Error -->
43
37
  <div id="error-state" class="state-panel hidden">
44
38
  <div class="error-icon">⚠</div>
@@ -47,35 +41,35 @@
47
41
  </div>
48
42
 
49
43
  <!-- Screen 1: Overview ───────────────────────────────── -->
50
- <section id="screen-overview" class="screen hidden">
44
+ <section id="screen-overview" class="screen">
51
45
  <h2 class="section-title" data-i18n="overview.title">Overview</h2>
52
46
  <div id="overview-grid" class="card-grid">
53
- <div id="card-health" class="card card-health"></div>
54
- <div id="card-git-backup" class="card"></div>
55
- <div id="card-shadow-backup" class="card"></div>
56
- <div id="card-watcher" class="card"></div>
57
- <div id="card-alert" class="card"></div>
47
+ <div id="card-health" class="card card-health"><div class="skeleton-block"></div></div>
48
+ <div id="card-git-backup" class="card"><div class="skeleton-block"></div></div>
49
+ <div id="card-shadow-backup" class="card"><div class="skeleton-block"></div></div>
50
+ <div id="card-watcher" class="card"><div class="skeleton-block"></div></div>
51
+ <div id="card-alert" class="card"><div class="skeleton-block"></div></div>
58
52
  </div>
59
53
  </section>
60
54
 
61
55
  <!-- Screen 2: Backups & Recovery ─────────────────────── -->
62
- <section id="screen-backups" class="screen hidden">
56
+ <section id="screen-backups" class="screen">
63
57
  <h2 class="section-title" data-i18n="backups.title">Backups &amp; Recovery</h2>
64
- <div id="backup-stats" class="stats-row"></div>
58
+ <div id="backup-stats" class="stats-row"><div class="skeleton-row"></div></div>
65
59
  <div id="backup-filters" class="filter-bar"></div>
66
- <div id="backup-table-wrap" class="table-wrap"></div>
60
+ <div id="backup-table-wrap" class="table-wrap"><div class="skeleton-table"></div></div>
67
61
  </section>
68
62
 
69
63
  <!-- Screen 3: Protection Scope ───────────────────────── -->
70
- <section id="screen-protection" class="screen hidden">
64
+ <section id="screen-protection" class="screen">
71
65
  <h2 class="section-title" data-i18n="protection.title">Protection Scope</h2>
72
- <div id="protection-content"></div>
66
+ <div id="protection-content"><div class="skeleton-block"></div></div>
73
67
  </section>
74
68
 
75
69
  <!-- Screen 4: Diagnostics ────────────────────────────── -->
76
- <section id="screen-diagnostics" class="screen hidden">
70
+ <section id="screen-diagnostics" class="screen">
77
71
  <h2 class="section-title" data-i18n="diagnostics.title">Diagnostics</h2>
78
- <div id="diagnostics-summary"></div>
72
+ <div id="diagnostics-summary"><div class="skeleton-block"></div></div>
79
73
  </section>
80
74
 
81
75
  </main>
@@ -326,38 +326,64 @@ main {
326
326
  .badge-trigger { background: var(--bg-tertiary); color: var(--text-secondary); font-size: 0.7rem; border-color: var(--border-subtle); }
327
327
  .badge-intent { background: var(--blue-bg); color: var(--blue); font-size: 0.7rem; border-color: rgba(59,130,246,.18); max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: inline-block; vertical-align: middle; }
328
328
 
329
- .backup-summary-cell { max-width: 400px; }
329
+ .backup-summary-cell { max-width: 420px; min-width: 180px; }
330
330
 
331
- .summary-stack { display: flex; flex-direction: column; gap: 4px; }
332
- .summary-meta { display: flex; align-items: center; gap: 6px; }
333
- .summary-files { font-size: 13px; font-weight: 600; color: var(--text-heading); }
331
+ .summary-stack { display: flex; flex-direction: column; gap: 6px; padding: 4px 0; }
332
+ .summary-meta { display: flex; align-items: center; gap: 8px; }
333
+ .summary-files {
334
+ font-size: 13px;
335
+ font-weight: 700;
336
+ color: var(--text-heading);
337
+ font-variant-numeric: tabular-nums;
338
+ }
334
339
  .summary-intent {
335
340
  font-size: 12px;
336
341
  color: var(--blue);
337
342
  background: var(--blue-bg);
338
- padding: 2px 8px;
339
- border-radius: 3px;
340
- border-left: 2px solid var(--blue);
343
+ padding: 4px 10px;
344
+ border-radius: 4px;
345
+ border-left: 3px solid var(--blue);
341
346
  overflow: hidden;
342
347
  text-overflow: ellipsis;
343
348
  white-space: nowrap;
344
- max-width: 380px;
349
+ max-width: 400px;
350
+ line-height: 1.4;
345
351
  }
346
352
  .summary-message {
347
353
  font-size: 12px;
348
- color: var(--text-secondary);
354
+ color: var(--text-primary);
355
+ background: var(--bg-tertiary);
356
+ padding: 3px 8px;
357
+ border-radius: 3px;
358
+ border-left: 3px solid var(--text-tertiary);
349
359
  overflow: hidden;
350
360
  text-overflow: ellipsis;
351
361
  white-space: nowrap;
352
- max-width: 380px;
362
+ max-width: 400px;
363
+ line-height: 1.4;
353
364
  }
354
- .summary-detail {
355
- font-size: 11px;
356
- color: var(--text-tertiary);
365
+ .summary-detail-line {
366
+ font-size: 12px;
367
+ color: var(--text-secondary);
368
+ font-family: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
369
+ line-height: 1.5;
370
+ padding: 1px 0;
357
371
  overflow: hidden;
358
372
  text-overflow: ellipsis;
359
373
  white-space: nowrap;
360
- max-width: 380px;
374
+ max-width: 400px;
375
+ }
376
+ .summary-detail-line:first-child { padding-top: 2px; }
377
+
378
+ .summary-pre {
379
+ font-size: 12px;
380
+ line-height: 1.6;
381
+ white-space: pre-wrap;
382
+ margin: 0;
383
+ padding: 8px 10px;
384
+ background: var(--bg-tertiary);
385
+ border-radius: 4px;
386
+ border-left: 3px solid var(--blue);
361
387
  }
362
388
 
363
389
  /* ── Stats Row ────────────────────────────────────────────── */
@@ -776,6 +802,54 @@ main {
776
802
 
777
803
  .icon-spin-active { animation: spin .6s linear infinite; }
778
804
 
805
+ /* ── Skeleton loading ────────────────────────────────────── */
806
+
807
+ @keyframes shimmer {
808
+ 0% { background-position: -200% 0; }
809
+ 100% { background-position: 200% 0; }
810
+ }
811
+
812
+ .skeleton-block {
813
+ height: 48px;
814
+ border-radius: var(--radius-sm);
815
+ background: linear-gradient(90deg, var(--bg-tertiary) 25%, var(--bg-hover) 50%, var(--bg-tertiary) 75%);
816
+ background-size: 200% 100%;
817
+ animation: shimmer 1.5s ease-in-out infinite;
818
+ }
819
+
820
+ .skeleton-row {
821
+ display: flex;
822
+ gap: 12px;
823
+ }
824
+ .skeleton-row::before,
825
+ .skeleton-row::after {
826
+ content: '';
827
+ flex: 1;
828
+ height: 64px;
829
+ border-radius: var(--radius-sm);
830
+ background: linear-gradient(90deg, var(--bg-tertiary) 25%, var(--bg-hover) 50%, var(--bg-tertiary) 75%);
831
+ background-size: 200% 100%;
832
+ animation: shimmer 1.5s ease-in-out infinite;
833
+ }
834
+
835
+ .skeleton-table {
836
+ display: flex;
837
+ flex-direction: column;
838
+ gap: 8px;
839
+ padding: 12px 0;
840
+ }
841
+ .skeleton-table::before,
842
+ .skeleton-table::after {
843
+ content: '';
844
+ display: block;
845
+ height: 36px;
846
+ border-radius: var(--radius-sm);
847
+ background: linear-gradient(90deg, var(--bg-tertiary) 25%, var(--bg-hover) 50%, var(--bg-tertiary) 75%);
848
+ background-size: 200% 100%;
849
+ animation: shimmer 1.5s ease-in-out infinite;
850
+ }
851
+ .skeleton-table::after { width: 75%; opacity: .6; }
852
+
779
853
  /* ── Utility ──────────────────────────────────────────────── */
780
854
 
781
855
  .hidden { display: none !important; }
@@ -2,6 +2,7 @@
2
2
  'use strict';
3
3
 
4
4
  const http = require('http');
5
+ const crypto = require('crypto');
5
6
  const fs = require('fs');
6
7
  const path = require('path');
7
8
 
@@ -12,6 +13,7 @@ const { listBackups } = require('../lib/core/backups');
12
13
  const PUBLIC_DIR = path.join(__dirname, 'public');
13
14
  const DEFAULT_PORT = 3120;
14
15
  const MAX_PORT_RETRIES = 10;
16
+ const ALLOWED_HOSTS = /^(127\.0\.0\.1|localhost)(:\d+)?$/;
15
17
 
16
18
  const MIME = {
17
19
  '.html': 'text/html; charset=utf-8',
@@ -78,7 +80,7 @@ function forbidden(res) { res.writeHead(403); res.end('Forbidden'); }
78
80
 
79
81
  /* ── Static file server (strict) ────────────────────────────── */
80
82
 
81
- function serveStatic(reqUrl, res) {
83
+ function serveStatic(reqUrl, res, serverToken) {
82
84
  let pathname;
83
85
  try { pathname = decodeURIComponent(new URL(reqUrl, 'http://x').pathname); }
84
86
  catch { return notFound(res); }
@@ -94,6 +96,23 @@ function serveStatic(reqUrl, res) {
94
96
  fs.readFile(resolved, (err, data) => {
95
97
  if (err) return notFound(res);
96
98
  const ext = path.extname(resolved).toLowerCase();
99
+
100
+ // Inject per-process token into index.html so the frontend can authenticate API calls
101
+ if (pathname === '/index.html' && serverToken) {
102
+ const html = data.toString('utf-8').replace(
103
+ '</head>',
104
+ `<script>window.__GUARD_TOKEN__="${serverToken}";</script></head>`
105
+ );
106
+ const buf = Buffer.from(html, 'utf-8');
107
+ res.writeHead(200, {
108
+ 'Content-Type': MIME[ext] || 'text/html; charset=utf-8',
109
+ 'Content-Length': buf.length,
110
+ 'X-Content-Type-Options': 'nosniff',
111
+ 'Cache-Control': 'no-store',
112
+ });
113
+ return res.end(buf);
114
+ }
115
+
97
116
  res.writeHead(200, {
98
117
  'Content-Type': MIME[ext] || 'application/octet-stream',
99
118
  'Content-Length': data.length,
@@ -121,13 +140,21 @@ function handleApi(pathname, query, registry, res) {
121
140
  }
122
141
 
123
142
  if (pathname === '/api/page-data') {
143
+ const scope = query.get('scope');
124
144
  const result = { timestamp: new Date().toISOString() };
125
- try { result.dashboard = getDashboard(pp); }
126
- catch (e) { result.dashboard = { error: e.message }; }
127
- try { result.doctor = runDiagnostics(pp); }
128
- catch (e) { result.doctor = { error: e.message }; }
129
- try { result.backups = listBackups(pp, { limit: 50 }).sources || []; }
130
- catch (e) { result.backups = { error: e.message }; }
145
+
146
+ if (!scope || scope === 'dashboard') {
147
+ try { result.dashboard = getDashboard(pp); }
148
+ catch (e) { result.dashboard = { error: e.message }; }
149
+ }
150
+ if (!scope || scope === 'doctor') {
151
+ try { result.doctor = runDiagnostics(pp); }
152
+ catch (e) { result.doctor = { error: e.message }; }
153
+ }
154
+ if (!scope || scope === 'backups') {
155
+ try { result.backups = listBackups(pp, { limit: 50 }).sources || []; }
156
+ catch (e) { result.backups = { error: e.message }; }
157
+ }
131
158
  return json(res, result);
132
159
  }
133
160
 
@@ -154,53 +181,93 @@ function handleApi(pathname, query, registry, res) {
154
181
 
155
182
  /* ── Server ─────────────────────────────────────────────────── */
156
183
 
157
- function main() {
158
- const args = parseCliArgs();
159
- const registry = buildRegistry(args.paths);
160
- let port = args.port;
161
- let retries = 0;
162
-
163
- const server = http.createServer((req, res) => {
164
- if (req.method !== 'GET') {
165
- res.writeHead(405);
166
- return res.end('Method Not Allowed');
167
- }
168
- let parsed;
169
- try { parsed = new URL(req.url, `http://${req.headers.host || 'localhost'}`); }
170
- catch { return notFound(res); }
171
-
172
- if (parsed.pathname.startsWith('/api/')) {
173
- handleApi(parsed.pathname, parsed.searchParams, registry, res);
174
- } else {
175
- serveStatic(req.url, res);
176
- }
177
- });
184
+ /**
185
+ * Start the dashboard HTTP server.
186
+ * Can be called standalone (CLI) or embedded (from watcher).
187
+ *
188
+ * @param {string[]} paths - Project directories to serve
189
+ * @param {object} [opts]
190
+ * @param {number} [opts.port=3120] - Starting port
191
+ * @param {boolean} [opts.silent=false] - Suppress banner output
192
+ * @returns {Promise<{server: http.Server, port: number, registry: Map}>}
193
+ */
194
+ function startDashboardServer(paths, opts = {}) {
195
+ const port = opts.port || DEFAULT_PORT;
196
+ const silent = opts.silent || false;
197
+ const registry = buildRegistry(paths);
198
+ const token = crypto.randomBytes(16).toString('hex');
199
+
200
+ return new Promise((resolve, reject) => {
201
+ let currentPort = port;
202
+ let retries = 0;
203
+
204
+ const server = http.createServer((req, res) => {
205
+ // DNS rebinding protection: reject unexpected Host headers
206
+ const host = req.headers.host || '';
207
+ if (!ALLOWED_HOSTS.test(host)) {
208
+ res.writeHead(403);
209
+ return res.end('Forbidden: invalid host');
210
+ }
211
+
212
+ if (req.method !== 'GET') {
213
+ res.writeHead(405);
214
+ return res.end('Method Not Allowed');
215
+ }
216
+ let parsed;
217
+ try { parsed = new URL(req.url, `http://${host}`); }
218
+ catch { return notFound(res); }
219
+
220
+ // API endpoints require per-process token
221
+ if (parsed.pathname.startsWith('/api/')) {
222
+ const reqToken = parsed.searchParams.get('token');
223
+ if (reqToken !== token) {
224
+ res.writeHead(403);
225
+ return res.end('Forbidden: invalid token');
226
+ }
227
+ handleApi(parsed.pathname, parsed.searchParams, registry, res);
228
+ } else {
229
+ serveStatic(req.url, res, token);
230
+ }
231
+ });
178
232
 
179
- server.on('error', (err) => {
180
- if (err.code === 'EADDRINUSE' && retries < MAX_PORT_RETRIES) {
181
- retries++;
182
- port++;
183
- server.listen(port, '127.0.0.1');
184
- } else {
185
- console.error('Failed to start:', err.message);
186
- process.exit(1);
187
- }
188
- });
233
+ server.on('error', (err) => {
234
+ if (err.code === 'EADDRINUSE' && retries < MAX_PORT_RETRIES) {
235
+ retries++;
236
+ currentPort++;
237
+ server.listen(currentPort, '127.0.0.1');
238
+ } else {
239
+ reject(err);
240
+ }
241
+ });
189
242
 
190
- server.on('listening', () => {
191
- const addr = server.address();
192
- console.log('');
193
- console.log(' Cursor Guard Dashboard');
194
- console.log(' ─────────────────────────');
195
- console.log(` URL: http://127.0.0.1:${addr.port}`);
196
- console.log(` Projects: ${registry.size}`);
197
- for (const p of registry.values()) {
198
- console.log(` [${p.id}] ${p.name} ${p._path}`);
199
- }
200
- console.log('');
243
+ server.on('listening', () => {
244
+ const addr = server.address();
245
+ if (!silent) {
246
+ console.log('');
247
+ console.log(' Cursor Guard Dashboard');
248
+ console.log(' ─────────────────────────');
249
+ console.log(` URL: http://127.0.0.1:${addr.port}`);
250
+ console.log(` Projects: ${registry.size}`);
251
+ for (const p of registry.values()) {
252
+ console.log(` [${p.id}] ${p.name} → ${p._path}`);
253
+ }
254
+ console.log('');
255
+ }
256
+ resolve({ server, port: addr.port, registry });
257
+ });
258
+
259
+ server.listen(currentPort, '127.0.0.1');
201
260
  });
261
+ }
262
+
263
+ /* ── CLI entry ─────────────────────────────────────────────── */
202
264
 
203
- server.listen(port, '127.0.0.1');
265
+ if (require.main === module) {
266
+ const args = parseCliArgs();
267
+ startDashboardServer(args.paths, { port: args.port }).catch(err => {
268
+ console.error('Failed to start:', err.message);
269
+ process.exit(1);
270
+ });
204
271
  }
205
272
 
206
- main();
273
+ module.exports = { startDashboardServer };
@@ -25,7 +25,7 @@ function isProcessAlive(pid) {
25
25
 
26
26
  // ── Main ────────────────────────────────────────────────────────
27
27
 
28
- async function runBackup(projectDir, intervalOverride) {
28
+ async function runBackup(projectDir, intervalOverride, opts = {}) {
29
29
  const hasGit = gitAvailable();
30
30
  const repo = hasGit && isGitRepo(projectDir);
31
31
  const gDir = repo ? getGitDir(projectDir) : null;
@@ -177,6 +177,18 @@ async function runBackup(projectDir, intervalOverride) {
177
177
  console.log(color.cyan(`[guard] Watching '${projectDir}' every ${interval}s (Ctrl+C to stop)`));
178
178
  console.log(color.cyan(`[guard] Strategy: ${cfg.backup_strategy} | Ref: ${branchRef} | Retention: ${cfg.retention.mode}`));
179
179
  console.log(color.cyan(`[guard] Log: ${logFilePath}`));
180
+
181
+ // Optional embedded dashboard
182
+ if (opts.dashboardPort) {
183
+ try {
184
+ const { startDashboardServer } = require('../dashboard/server');
185
+ const { port } = await startDashboardServer([projectDir], { port: opts.dashboardPort, silent: true });
186
+ console.log(color.cyan(`[guard] Dashboard: http://127.0.0.1:${port}`));
187
+ } catch (e) {
188
+ console.log(color.yellow(`[guard] Dashboard failed to start: ${e.message}`));
189
+ }
190
+ }
191
+
180
192
  console.log('');
181
193
 
182
194
  // Main loop
@@ -313,25 +313,25 @@ function cleanGitRetention(branchRef, gitDirPath, cfg, cwd) {
313
313
  return { kept: 0, pruned: 0, mode, rebuilt: false, skipped: true, reason: 'retention disabled' };
314
314
  }
315
315
 
316
- const out = git(['log', branchRef, '--format=%H %aI %cI %s'], { cwd, allowFail: true });
316
+ const RS = '\x1e', US = '\x1f';
317
+ const out = git(['log', branchRef, `--format=%H${US}%aI${US}%cI${US}%s${US}%B${RS}`], { cwd, allowFail: true });
317
318
  if (!out) {
318
319
  return { kept: 0, pruned: 0, mode, rebuilt: false, skipped: true, reason: 'no commits on ref' };
319
320
  }
320
321
 
321
- const lines = out.split('\n').filter(Boolean);
322
+ const records = out.split(RS).filter(r => r.trim());
322
323
  const guardCommits = [];
323
- for (const line of lines) {
324
- const firstSpace = line.indexOf(' ');
325
- const secondSpace = line.indexOf(' ', firstSpace + 1);
326
- const thirdSpace = line.indexOf(' ', secondSpace + 1);
327
- const hash = line.substring(0, firstSpace);
328
- const authorDate = line.substring(firstSpace + 1, secondSpace);
329
- const committerDate = line.substring(secondSpace + 1, thirdSpace);
330
- const subject = line.substring(thirdSpace + 1);
324
+ for (const record of records) {
325
+ const fields = record.split(US);
326
+ if (fields.length < 5) continue;
327
+ const hash = fields[0].trim();
328
+ const authorDate = fields[1].trim();
329
+ const committerDate = fields[2].trim();
330
+ const subject = fields[3].trim();
331
+ const fullBody = fields[4].trim();
331
332
  if (subject.startsWith('guard: auto-backup') || subject.startsWith('guard: snapshot')) {
332
- guardCommits.push({ hash, authorDate, committerDate, subject });
333
+ guardCommits.push({ hash, authorDate, committerDate, subject, fullBody });
333
334
  }
334
- // Non-guard commits are silently skipped; continue scanning older history
335
335
  }
336
336
 
337
337
  const total = guardCommits.length;
@@ -373,7 +373,8 @@ function cleanGitRetention(branchRef, gitDirPath, cfg, cwd) {
373
373
  if (!rootTree) {
374
374
  return { kept: total, pruned: 0, mode, rebuilt: false, reason: 'could not resolve root tree' };
375
375
  }
376
- let prevHash = commitTreeWithDate(['commit-tree', rootTree, '-m', toKeep[0].subject], toKeep[0]);
376
+ const msgOf = (c) => c.fullBody || c.subject;
377
+ let prevHash = commitTreeWithDate(['commit-tree', rootTree, '-m', msgOf(toKeep[0])], toKeep[0]);
377
378
  if (!prevHash) {
378
379
  return { kept: total, pruned: 0, mode, rebuilt: false, reason: 'commit-tree failed for root' };
379
380
  }
@@ -383,7 +384,7 @@ function cleanGitRetention(branchRef, gitDirPath, cfg, cwd) {
383
384
  if (!tree) {
384
385
  return { kept: total, pruned: 0, mode, rebuilt: false, reason: `could not resolve tree for commit ${i}` };
385
386
  }
386
- prevHash = commitTreeWithDate(['commit-tree', tree, '-p', prevHash, '-m', toKeep[i].subject], toKeep[i]);
387
+ prevHash = commitTreeWithDate(['commit-tree', tree, '-p', prevHash, '-m', msgOf(toKeep[i])], toKeep[i]);
387
388
  if (!prevHash) {
388
389
  return { kept: total, pruned: 0, mode, rebuilt: false, reason: `commit-tree failed at index ${i}` };
389
390
  }
@@ -92,6 +92,11 @@ function runFixes(projectDir, opts = {}) {
92
92
  if (!existingIgnore.includes('.cursor-guard-backup')) {
93
93
  missingPatterns.push('# cursor-guard shadow copies', '.cursor-guard-backup/', '');
94
94
  }
95
+ const nmEntries = ['node_modules/', '.cursor/skills/**/node_modules/'];
96
+ const missingNm = nmEntries.filter(e => !existingIgnore.includes(e));
97
+ if (missingNm.length > 0) {
98
+ missingPatterns.push('# Dependencies', ...missingNm, '');
99
+ }
95
100
  const missingSecrets = initCfg.secrets_patterns.filter(p => !existingIgnore.includes(p));
96
101
  if (missingSecrets.length > 0) {
97
102
  missingPatterns.push('# Secrets (cursor-guard defaults)', ...missingSecrets, '');
@@ -92,6 +92,33 @@ function runDiagnostics(projectDir) {
92
92
  }
93
93
  }
94
94
 
95
+ // 5b. Git retention warning
96
+ if (repo) {
97
+ const guardRef = 'refs/guard/auto-backup';
98
+ const countStr = git(['rev-list', '--count', guardRef], { cwd: projectDir, allowFail: true });
99
+ const commitCount = countStr ? parseInt(countStr, 10) : 0;
100
+ if (commitCount > 500 && !cfg.git_retention.enabled) {
101
+ check('Git retention', 'WARN',
102
+ `${commitCount} backup commits and git_retention is disabled — set git_retention.enabled=true in .cursor-guard.json to auto-prune old snapshots`);
103
+ } else if (commitCount > 0 && cfg.git_retention.enabled) {
104
+ check('Git retention', 'PASS', `${commitCount} commits, auto-prune enabled (${cfg.git_retention.mode}: ${cfg.git_retention.mode === 'days' ? cfg.git_retention.days + 'd' : cfg.git_retention.max_count})`);
105
+ }
106
+ }
107
+
108
+ // 5c. Backup integrity — verify latest auto-backup tree is reachable
109
+ if (repo) {
110
+ const guardRef = 'refs/guard/auto-backup';
111
+ const latestHash = git(['rev-parse', '--verify', guardRef], { cwd: projectDir, allowFail: true });
112
+ if (latestHash) {
113
+ const treeType = git(['cat-file', '-t', `${latestHash}^{tree}`], { cwd: projectDir, allowFail: true });
114
+ if (treeType === 'tree') {
115
+ check('Backup integrity', 'PASS', `latest auto-backup commit ${latestHash.substring(0, 7)} tree is valid`);
116
+ } else {
117
+ check('Backup integrity', 'FAIL', `latest auto-backup commit ${latestHash.substring(0, 7)} tree is corrupted or unreachable`);
118
+ }
119
+ }
120
+ }
121
+
95
122
  // 6. Guard refs
96
123
  if (repo) {
97
124
  const refs = git(['for-each-ref', 'refs/guard/', '--format=%(refname)'], { cwd: projectDir, allowFail: true });
@@ -11,10 +11,16 @@ const { createGitSnapshot, formatTimestamp, removeSecretsFromIndex } = require('
11
11
  // ── Path safety ─────────────────────────────────────────────────
12
12
 
13
13
  function validateRelativePath(file) {
14
+ if (!file || typeof file !== 'string') {
15
+ return { valid: false, error: 'file path is required' };
16
+ }
14
17
  const normalized = path.normalize(file).replace(/\\/g, '/');
15
18
  if (path.isAbsolute(normalized) || normalized.startsWith('..')) {
16
19
  return { valid: false, error: 'file path must be relative and within project directory' };
17
20
  }
21
+ if (normalized === '.' || normalized === '') {
22
+ return { valid: false, error: 'file path must target a specific file, not the project root' };
23
+ }
18
24
  return { valid: true, normalized };
19
25
  }
20
26
 
@@ -66,6 +72,10 @@ function restoreFile(projectDir, file, source, opts = {}) {
66
72
  return { status: 'error', restoredFrom: source, error: pathCheck.error };
67
73
  }
68
74
 
75
+ if (isToolPath(pathCheck.normalized)) {
76
+ return { status: 'error', restoredFrom: source, error: `refusing to restore protected path '${pathCheck.normalized}' — use restore_project instead` };
77
+ }
78
+
69
79
  const preserveCurrent = resolvePreserve(projectDir, opts);
70
80
  const repo = isGitRepo(projectDir);
71
81
  const result = { restoredFrom: source };
@@ -299,15 +309,29 @@ function executeProjectRestore(projectDir, source, opts = {}) {
299
309
  cwd: projectDir, stdio: 'pipe',
300
310
  });
301
311
 
302
- // Restore protected paths from HEAD to prevent tool/skill/config downgrade
312
+ // Restore protected paths: keep HEAD state, don't let old snapshots resurrect deleted files
303
313
  const head = git(['rev-parse', 'HEAD'], { cwd: projectDir, allowFail: true });
304
314
  if (head) {
305
- for (const p of ['.cursor/', ...GUARD_CONFIGS]) {
306
- try {
307
- execFileSync('git', ['restore', `--source=HEAD`, '--', p], {
308
- cwd: projectDir, stdio: 'pipe',
309
- });
310
- } catch { /* may not exist in HEAD, that's fine */ }
315
+ const protectedPatterns = ['.cursor/', ...GUARD_CONFIGS];
316
+ for (const p of protectedPatterns) {
317
+ const existsInHead = git(['ls-tree', '--name-only', head, '--', p], { cwd: projectDir, allowFail: true });
318
+ if (existsInHead) {
319
+ try {
320
+ execFileSync('git', ['restore', `--source=HEAD`, '--', p], {
321
+ cwd: projectDir, stdio: 'pipe',
322
+ });
323
+ } catch { /* restore failed, keep whatever is there */ }
324
+ } else {
325
+ // HEAD intentionally doesn't have this path — remove if old snapshot resurrected it
326
+ const fullPath = path.join(projectDir, p);
327
+ try {
328
+ if (p.endsWith('/')) {
329
+ fs.rmSync(fullPath, { recursive: true, force: true });
330
+ } else {
331
+ fs.unlinkSync(fullPath);
332
+ }
333
+ } catch { /* already gone */ }
334
+ }
311
335
  }
312
336
  }
313
337
 
@@ -159,13 +159,39 @@ function createGitSnapshot(projectDir, cfg, opts = {}) {
159
159
  const fileName = filePart.split('\t').pop();
160
160
  groups[key].push(fileName);
161
161
  }
162
+
163
+ const numstatOut = git(['diff-tree', '--no-commit-id', '--numstat', '-r', parentTree, newTree], { cwd, allowFail: true });
164
+ const stats = {};
165
+ if (numstatOut) {
166
+ for (const line of numstatOut.split('\n').filter(Boolean)) {
167
+ const [add, del, ...nameParts] = line.split('\t');
168
+ const fname = nameParts.join('\t');
169
+ if (add !== '-') stats[fname] = `+${add} -${del}`;
170
+ }
171
+ }
172
+
173
+ function fmtFiles(arr) {
174
+ return arr.slice(0, 5).map(f => {
175
+ const s = stats[f];
176
+ return s ? `${f} (${s})` : f;
177
+ }).join(', ');
178
+ }
179
+
162
180
  const parts = [];
163
- if (groups.M.length) parts.push(`Modified ${groups.M.length}: ${groups.M.slice(0, 5).join(', ')}`);
164
- if (groups.A.length) parts.push(`Added ${groups.A.length}: ${groups.A.slice(0, 5).join(', ')}`);
165
- if (groups.D.length) parts.push(`Deleted ${groups.D.length}: ${groups.D.slice(0, 5).join(', ')}`);
166
- if (groups.R.length) parts.push(`Renamed ${groups.R.length}: ${groups.R.slice(0, 5).join(', ')}`);
181
+ if (groups.M.length) parts.push(`Modified ${groups.M.length}: ${fmtFiles(groups.M)}${groups.M.length > 5 ? ', ...' : ''}`);
182
+ if (groups.A.length) parts.push(`Added ${groups.A.length}: ${fmtFiles(groups.A)}${groups.A.length > 5 ? ', ...' : ''}`);
183
+ if (groups.D.length) parts.push(`Deleted ${groups.D.length}: ${fmtFiles(groups.D)}${groups.D.length > 5 ? ', ...' : ''}`);
184
+ if (groups.R.length) parts.push(`Renamed ${groups.R.length}: ${fmtFiles(groups.R)}${groups.R.length > 5 ? ', ...' : ''}`);
167
185
  if (parts.length) incrementalSummary = parts.join('; ');
168
186
  }
187
+ } else {
188
+ const lsInitial = git(['ls-tree', '--name-only', '-r', newTree], { cwd, allowFail: true });
189
+ if (lsInitial) {
190
+ const files = lsInitial.split('\n').filter(Boolean);
191
+ changedCount = files.length;
192
+ const sample = files.slice(0, 5).join(', ');
193
+ incrementalSummary = `Added ${files.length}: ${sample}${files.length > 5 ? ', ...' : ''}`;
194
+ }
169
195
  }
170
196
 
171
197
  // Override context summary with the accurate incremental one