cursor-guard 3.4.0 → 4.1.0

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/SKILL.md CHANGED
@@ -23,6 +23,8 @@ Use this skill when any of the following appear:
23
23
  - **Health check**: e.g. "guard doctor", "检查备份配置", "自检", "诊断guard", "check guard setup", "MCP 能用吗". If MCP `doctor` tool is available, call `doctor { "path": "<project>" }` and format the result; otherwise run `guard-doctor.ps1` and report results. Doctor output includes an "MCP server" check (SDK installed + server.js present). If doctor reports FAIL items, suggest running `doctor_fix` (MCP) or guide the user through manual fixes.
24
24
  - **Auto-fix**: e.g. "guard fix", "修复配置", "自动修复". If MCP `doctor_fix` tool is available, call `doctor_fix { "path": "<project>", "dry_run": true }` first to preview, then `doctor_fix { "path": "<project>" }` to apply. Without MCP, guide the user through manual steps based on doctor output.
25
25
  - **Backup status**: e.g. "备份状态", "guard status", "watcher 在跑吗", "最近一次备份". If MCP `backup_status` tool is available, call `backup_status { "path": "<project>" }` and format the structured result for the user (watcher running/stale, last backup time, strategy, ref counts, disk). Without MCP, check lock file existence and `git log` manually.
26
+ - **Health dashboard**: e.g. "看板", "dashboard", "健康状态", "备份总览", "guard 概况". If MCP `dashboard` tool is available, call `dashboard { "path": "<project>" }` and present the structured dashboard (strategy, last backup, counts, disk usage, protection scope, health status, alerts). Format as a clear summary for the user.
27
+ - **Alert check**: e.g. "有告警吗", "alert status", "变更异常", "风险提示". If MCP `alert_status` tool is available, call `alert_status { "path": "<project>" }` to check for active change-velocity alerts. Report whether an alert is active and its details.
26
28
 
27
29
  If none of the above, do not expand scope; answer normally.
28
30
 
@@ -58,7 +60,17 @@ On first trigger in a session, check if the workspace root contains `.cursor-gua
58
60
 
59
61
  // Retention for Git auto-backup branch. Disabled by default.
60
62
  // "count": keep N newest commits. "days": keep commits from last N days.
61
- "git_retention": { "enabled": false, "mode": "count", "max_count": 200 }
63
+ "git_retention": { "enabled": false, "mode": "count", "max_count": 200 },
64
+
65
+ // V4: Proactive change-velocity detection (default: on).
66
+ // When enabled, the watcher monitors file change frequency and raises
67
+ // alerts when abnormal patterns are detected (e.g. 20+ files in 10s).
68
+ "proactive_alert": true,
69
+ "alert_thresholds": {
70
+ "files_per_window": 20, // trigger threshold
71
+ "window_seconds": 10, // sliding window
72
+ "cooldown_seconds": 60 // min gap between alerts
73
+ }
62
74
  }
63
75
  ```
64
76
 
@@ -81,7 +93,7 @@ On first trigger in a session, check if the workspace root contains `.cursor-gua
81
93
 
82
94
  cursor-guard provides an **MCP server** (`cursor-guard-mcp`) as an optional enhancement. When available, prefer MCP tool calls over shell commands — they are faster, return structured JSON, and consume fewer tokens.
83
95
 
84
- **Detection**: at the start of a session, check if the following MCP tools are available in your tool list: `doctor`, `list_backups`, `snapshot_now`, `restore_file`, `restore_project`. If **any** of them exists, use MCP for that operation; otherwise, fall back to shell commands as described in the sections below.
96
+ **Detection**: at the start of a session, check if the following MCP tools are available in your tool list: `doctor`, `list_backups`, `snapshot_now`, `restore_file`, `restore_project`, `dashboard`, `alert_status`. If **any** of them exists, use MCP for that operation; otherwise, fall back to shell commands as described in the sections below.
85
97
 
86
98
  **Routing table** (MCP tool → replaces which shell workflow):
87
99
 
@@ -95,6 +107,8 @@ cursor-guard provides an **MCP server** (`cursor-guard-mcp`) as an optional enha
95
107
  | Restore single file | `restore_file` | §5a Step 5 git restore |
96
108
  | Preview project restore | `restore_project` (preview=true) | §5a Step 5 git diff |
97
109
  | Execute project restore | `restore_project` (preview=false) | §5a Step 5 git restore -- . |
110
+ | Backup health dashboard | `dashboard` | manual: combine backup_status + git/shadow stats |
111
+ | Change-velocity alerts | `alert_status` | manual: check alert file in .git/ or .cursor-guard-backup/ |
98
112
 
99
113
  **Rules**:
100
114
  - MCP results are JSON — parse `status`, `error`, and data fields; do not re-run shell to verify.
@@ -188,7 +202,7 @@ git update-ref refs/guard/snapshot $commit
188
202
 
189
203
  1. **Quick git init** (preferred):
190
204
  ```
191
- git init && git add -A && git commit -m "guard: initial snapshot" --no-verify
205
+ git init; git add -A; git commit -m "guard: initial snapshot" --no-verify
192
206
  ```
193
207
  2. **Shadow copy** (fallback if user declines git):
194
208
  - Copy the target file(s) to `.cursor-guard-backup/<timestamp>/` via Shell.
@@ -570,8 +584,8 @@ Skip the block for unrelated turns.
570
584
  - Recovery commands: [references/recovery.md](references/recovery.md)
571
585
  - Auto-backup (Node.js core): [references/lib/auto-backup.js](references/lib/auto-backup.js)
572
586
  - Guard doctor (Node.js core): [references/lib/guard-doctor.js](references/lib/guard-doctor.js)
573
- - Core modules: [references/lib/core/](references/lib/core/) (doctor, doctor-fix, snapshot, backups, restore, status)
574
- - MCP server: [references/mcp/server.js](references/mcp/server.js) (7 tools: doctor, doctor_fix, backup_status, list_backups, snapshot_now, restore_file, restore_project)
587
+ - Core modules: [references/lib/core/](references/lib/core/) (doctor, doctor-fix, snapshot, backups, restore, status, anomaly, dashboard)
588
+ - MCP server: [references/mcp/server.js](references/mcp/server.js) (9 tools: doctor, doctor_fix, backup_status, list_backups, snapshot_now, restore_file, restore_project, dashboard, alert_status)
575
589
  - Shared utilities: [references/lib/utils.js](references/lib/utils.js)
576
590
  - Config JSON Schema: [references/cursor-guard.schema.json](references/cursor-guard.schema.json)
577
591
  - Example config: [references/cursor-guard.example.json](references/cursor-guard.example.json)
@@ -614,4 +628,4 @@ If your Cursor config supports MCP, add `cursor-guard` as an MCP server for lowe
614
628
  }
615
629
  ```
616
630
 
617
- Once configured, the 7 tools (`doctor`, `doctor_fix`, `backup_status`, `list_backups`, `snapshot_now`, `restore_file`, `restore_project`) are available as MCP tool calls. See §0a for routing logic.
631
+ Once configured, the 9 tools (`doctor`, `doctor_fix`, `backup_status`, `list_backups`, `snapshot_now`, `restore_file`, `restore_project`, `dashboard`, `alert_status`) are available as MCP tool calls. See §0a for routing logic.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursor-guard",
3
- "version": "3.4.0",
3
+ "version": "4.1.0",
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",
@@ -27,6 +27,7 @@
27
27
  "test": "node references/lib/utils.test.js && node references/lib/core/core.test.js && node references/mcp/mcp.test.js"
28
28
  },
29
29
  "bin": {
30
+ "cursor-guard-init": "references/bin/cursor-guard-init.js",
30
31
  "cursor-guard-backup": "references/bin/cursor-guard-backup.js",
31
32
  "cursor-guard-doctor": "references/bin/cursor-guard-doctor.js",
32
33
  "cursor-guard-mcp": "references/mcp/server.js"
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const os = require('os');
7
+ const { execFileSync } = require('child_process');
8
+
9
+ const args = process.argv.slice(2);
10
+ if (args.includes('--help') || args.includes('-h')) {
11
+ console.log(`Usage: cursor-guard-init [options]
12
+
13
+ Installs cursor-guard skill into your Cursor skills directory, including
14
+ MCP dependencies and .gitignore entries.
15
+
16
+ Options:
17
+ --global Install to ~/.cursor/skills/ (default: project-local)
18
+ --path <dir> Project directory (default: current dir)
19
+ --help, -h Show this help message
20
+ --version, -v Show version number`);
21
+ process.exit(0);
22
+ }
23
+
24
+ if (args.includes('--version') || args.includes('-v')) {
25
+ const pkg = require('../../package.json');
26
+ console.log(pkg.version);
27
+ process.exit(0);
28
+ }
29
+
30
+ const isGlobal = args.includes('--global');
31
+ const pathIdx = args.indexOf('--path');
32
+ const projectDir = path.resolve(pathIdx >= 0 && args[pathIdx + 1] ? args[pathIdx + 1] : '.');
33
+
34
+ const skillSource = path.resolve(__dirname, '../..');
35
+ const skillTarget = isGlobal
36
+ ? path.join(process.env.USERPROFILE || process.env.HOME || os.homedir(), '.cursor', 'skills', 'cursor-guard')
37
+ : path.join(projectDir, '.cursor', 'skills', 'cursor-guard');
38
+
39
+ function copyRecursive(src, dest) {
40
+ const stat = fs.statSync(src);
41
+ if (stat.isDirectory()) {
42
+ if (path.basename(src) === 'node_modules') return;
43
+ if (path.basename(src) === '.git') return;
44
+ fs.mkdirSync(dest, { recursive: true });
45
+ for (const entry of fs.readdirSync(src)) {
46
+ copyRecursive(path.join(src, entry), path.join(dest, entry));
47
+ }
48
+ } else {
49
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
50
+ fs.copyFileSync(src, dest);
51
+ }
52
+ }
53
+
54
+ console.log(`\n cursor-guard init\n`);
55
+ console.log(` Source: ${skillSource}`);
56
+ console.log(` Target: ${skillTarget}`);
57
+ console.log(` Mode: ${isGlobal ? 'global (~/.cursor/skills/)' : 'project-local (.cursor/skills/)'}\n`);
58
+
59
+ // Step 1: Copy skill files (excluding node_modules and .git)
60
+ console.log(' [1/4] Copying skill files...');
61
+ if (fs.existsSync(skillTarget)) {
62
+ fs.rmSync(skillTarget, { recursive: true, force: true });
63
+ }
64
+ copyRecursive(skillSource, skillTarget);
65
+ console.log(' Done.');
66
+
67
+ // Step 2: Install MCP dependencies in skill directory
68
+ console.log(' [2/4] Installing MCP dependencies...');
69
+ try {
70
+ execFileSync('npm', ['install', '--omit=dev', '--ignore-scripts'], {
71
+ cwd: skillTarget,
72
+ stdio: 'pipe',
73
+ shell: process.platform === 'win32',
74
+ });
75
+ console.log(' Done.');
76
+ } catch (e) {
77
+ console.log(` Warning: npm install failed (${e.message}). MCP tools may not work.`);
78
+ console.log(' You can fix this later: cd "' + skillTarget + '" ; npm install');
79
+ }
80
+
81
+ // Step 3: Add .gitignore entries for skill node_modules
82
+ console.log(' [3/4] Updating .gitignore...');
83
+ const gitignorePath = path.join(projectDir, '.gitignore');
84
+ const entries = ['.cursor/skills/**/node_modules/'];
85
+ let gitignoreUpdated = false;
86
+ if (!isGlobal) {
87
+ let existing = '';
88
+ try { existing = fs.readFileSync(gitignorePath, 'utf-8'); } catch { /* doesn't exist */ }
89
+ const missing = entries.filter(e => !existing.includes(e));
90
+ if (missing.length > 0) {
91
+ const newline = existing.endsWith('\n') || !existing ? '' : '\n';
92
+ fs.appendFileSync(gitignorePath, `${newline}# cursor-guard skill dependencies\n${missing.join('\n')}\n`);
93
+ gitignoreUpdated = true;
94
+ console.log(' Added: ' + missing.join(', '));
95
+ } else {
96
+ console.log(' Already configured.');
97
+ }
98
+ } else {
99
+ console.log(' Skipped (global install, not inside a project).');
100
+ }
101
+
102
+ // Step 4: Summary
103
+ console.log(' [4/4] Verifying...');
104
+ const serverExists = fs.existsSync(path.join(skillTarget, 'references', 'mcp', 'server.js'));
105
+ const sdkExists = fs.existsSync(path.join(skillTarget, 'node_modules', '@modelcontextprotocol', 'sdk'));
106
+ const skillMdExists = fs.existsSync(path.join(skillTarget, 'SKILL.md'));
107
+
108
+ console.log(` SKILL.md: ${skillMdExists ? 'OK' : 'MISSING'}`);
109
+ console.log(` MCP server: ${serverExists ? 'OK' : 'MISSING'}`);
110
+ console.log(` MCP SDK: ${sdkExists ? 'OK' : 'MISSING — run npm install in skill dir'}`);
111
+
112
+ console.log(`\n Installation complete!\n`);
113
+ console.log(' Next steps:');
114
+ console.log(' 1. The skill activates automatically in Cursor Agent conversations.');
115
+ console.log(' 2. (Optional) Copy example config to project root:');
116
+ console.log(` cp "${path.join(skillTarget, 'references', 'cursor-guard.example.json')}" .cursor-guard.json`);
117
+ console.log(' 3. (Optional) Enable MCP — add to .cursor/mcp.json:');
118
+ console.log(` { "mcpServers": { "cursor-guard": { "command": "node", "args": ["${path.join(skillTarget, 'references', 'mcp', 'server.js').replace(/\\/g, '/')}"] } } }`);
119
+ console.log(' 4. (Optional) Start auto-backup:');
120
+ console.log(` npx cursor-guard-backup --path "${projectDir}"\n`);
@@ -173,3 +173,43 @@ Retention policy for the **`refs/guard/auto-backup` Git ref**. By default, auto-
173
173
  "max_count": 200
174
174
  }
175
175
  ```
176
+
177
+ ---
178
+
179
+ ## `proactive_alert`
180
+
181
+ - **Type**: `boolean`
182
+ - **Default**: `true`
183
+
184
+ Enable V4 proactive change-velocity detection. When enabled, the auto-backup watcher monitors file change frequency and raises alerts when abnormal patterns are detected (e.g. 20+ files modified in 10 seconds). Alerts are persisted to a file so the MCP server can include them in tool responses.
185
+
186
+ Set to `false` to disable proactive monitoring entirely.
187
+
188
+ ```json
189
+ "proactive_alert": true
190
+ ```
191
+
192
+ ---
193
+
194
+ ## `alert_thresholds`
195
+
196
+ - **Type**: `object`
197
+ - **Default**: `{ "files_per_window": 20, "window_seconds": 10, "cooldown_seconds": 60 }`
198
+
199
+ Thresholds for proactive change-velocity alerts. Only effective when `proactive_alert` is `true`.
200
+
201
+ ### Sub-fields
202
+
203
+ | Field | Type | Default | Description |
204
+ |-------|------|---------|-------------|
205
+ | `files_per_window` | `integer` | `20` | Number of file changes within the time window that triggers an alert |
206
+ | `window_seconds` | `integer` | `10` | Sliding time window in seconds for counting file changes |
207
+ | `cooldown_seconds` | `integer` | `60` | Minimum seconds between consecutive alerts to avoid noise |
208
+
209
+ ```json
210
+ "alert_thresholds": {
211
+ "files_per_window": 20,
212
+ "window_seconds": 10,
213
+ "cooldown_seconds": 60
214
+ }
215
+ ```
@@ -173,3 +173,43 @@
173
173
  "max_count": 200
174
174
  }
175
175
  ```
176
+
177
+ ---
178
+
179
+ ## `proactive_alert`
180
+
181
+ - **类型**:`boolean`
182
+ - **默认值**:`true`
183
+
184
+ 启用 V4 主动变更频率检测。开启后,自动备份 watcher 会监控文件变更频率,当检测到异常模式(如 10 秒内 20+ 文件被修改)时发出告警。告警会持久化到文件,以便 MCP 工具在响应中附加风险提示。
185
+
186
+ 设为 `false` 可完全禁用主动监控。
187
+
188
+ ```json
189
+ "proactive_alert": true
190
+ ```
191
+
192
+ ---
193
+
194
+ ## `alert_thresholds`
195
+
196
+ - **类型**:`object`
197
+ - **默认值**:`{ "files_per_window": 20, "window_seconds": 10, "cooldown_seconds": 60 }`
198
+
199
+ 主动变更频率告警的阈值配置。仅在 `proactive_alert` 为 `true` 时生效。
200
+
201
+ ### 子字段
202
+
203
+ | 字段 | 类型 | 默认值 | 说明 |
204
+ |------|------|--------|------|
205
+ | `files_per_window` | `integer` | `20` | 时间窗口内触发告警的文件变更数量 |
206
+ | `window_seconds` | `integer` | `10` | 统计文件变更的滑动时间窗口(秒) |
207
+ | `cooldown_seconds` | `integer` | `60` | 连续告警之间的最小间隔(秒),避免噪声 |
208
+
209
+ ```json
210
+ "alert_thresholds": {
211
+ "files_per_window": 20,
212
+ "window_seconds": 10,
213
+ "cooldown_seconds": 60
214
+ }
215
+ ```
@@ -33,5 +33,11 @@
33
33
  "mode": "count",
34
34
  "days": 30,
35
35
  "max_count": 200
36
+ },
37
+ "proactive_alert": true,
38
+ "alert_thresholds": {
39
+ "files_per_window": 20,
40
+ "window_seconds": 10,
41
+ "cooldown_seconds": 60
36
42
  }
37
43
  }
@@ -102,6 +102,36 @@
102
102
  }
103
103
  },
104
104
  "additionalProperties": false
105
+ },
106
+ "proactive_alert": {
107
+ "type": "boolean",
108
+ "default": true,
109
+ "description": "Enable V4 proactive change-velocity detection. When true, the auto-backup watcher monitors file change frequency and raises alerts when abnormal patterns are detected."
110
+ },
111
+ "alert_thresholds": {
112
+ "type": "object",
113
+ "description": "Thresholds for proactive change-velocity alerts (V4). Only effective when proactive_alert is true.",
114
+ "properties": {
115
+ "files_per_window": {
116
+ "type": "integer",
117
+ "minimum": 1,
118
+ "default": 20,
119
+ "description": "Number of file changes within the time window that triggers an alert."
120
+ },
121
+ "window_seconds": {
122
+ "type": "integer",
123
+ "minimum": 1,
124
+ "default": 10,
125
+ "description": "Sliding time window in seconds for counting file changes."
126
+ },
127
+ "cooldown_seconds": {
128
+ "type": "integer",
129
+ "minimum": 1,
130
+ "default": 60,
131
+ "description": "Minimum seconds between consecutive alerts to avoid noise."
132
+ }
133
+ },
134
+ "additionalProperties": false
105
135
  }
106
136
  },
107
137
  "additionalProperties": false
@@ -2,13 +2,15 @@
2
2
 
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
+ const { execFileSync } = require('child_process');
5
6
  const {
6
7
  color, loadConfig, gitAvailable, git, isGitRepo, gitDir: getGitDir,
7
8
  walkDir, filterFiles, buildManifest, loadManifest, saveManifest,
8
- manifestChanged, createLogger,
9
+ manifestChanged, createLogger, unquoteGitPath,
9
10
  } = require('./utils');
10
11
  const { createGitSnapshot, createShadowCopy } = require('./core/snapshot');
11
12
  const { cleanShadowRetention, cleanGitRetention } = require('./core/backups');
13
+ const { createChangeTracker, recordChange, checkAnomaly, saveAlert, clearExpiredAlert } = require('./core/anomaly');
12
14
 
13
15
  // ── Helpers ─────────────────────────────────────────────────────
14
16
 
@@ -123,11 +125,7 @@ async function runBackup(projectDir, intervalOverride) {
123
125
  git(['update-ref', '-d', legacyRef], { cwd: projectDir, allowFail: true });
124
126
  console.log(color.green(`[guard] Migrated ${legacyRef} → ${branchRef}`));
125
127
  } else {
126
- const head = git(['rev-parse', 'HEAD'], { cwd: projectDir, allowFail: true });
127
- if (head) {
128
- git(['update-ref', branchRef, head], { cwd: projectDir, allowFail: true });
129
- console.log(color.green(`[guard] Created ref: ${branchRef}`));
130
- }
128
+ console.log(color.cyan(`[guard] Ref ${branchRef} does not exist yet — will be created on first snapshot.`));
131
129
  }
132
130
  }
133
131
 
@@ -153,6 +151,12 @@ async function runBackup(projectDir, intervalOverride) {
153
151
  logger.error(`Unhandled rejection: ${reason}`);
154
152
  });
155
153
 
154
+ // V4: Initialize change tracker for anomaly detection
155
+ let tracker = createChangeTracker(cfg);
156
+ if (cfg.proactive_alert) {
157
+ console.log(color.cyan(`[guard] Proactive alert: ON (threshold: ${cfg.alert_thresholds.files_per_window} files / ${cfg.alert_thresholds.window_seconds}s)`));
158
+ }
159
+
156
160
  // Banner
157
161
  console.log('');
158
162
  console.log(color.cyan(`[guard] Watching '${projectDir}' every ${interval}s (Ctrl+C to stop)`));
@@ -175,6 +179,7 @@ async function runBackup(projectDir, intervalOverride) {
175
179
  if (reload.loaded && !reload.error) {
176
180
  cfg = reload.cfg;
177
181
  cfgMtime = newMtime;
182
+ tracker = createChangeTracker(cfg);
178
183
  logger.info('Config reloaded (file changed)');
179
184
  }
180
185
  }
@@ -184,6 +189,7 @@ async function runBackup(projectDir, intervalOverride) {
184
189
  // Detect changes
185
190
  let hasChanges = false;
186
191
  let pendingManifest = null;
192
+ let lastManifest = null;
187
193
  try {
188
194
  if (repo) {
189
195
  const dirty = git(['status', '--porcelain'], { cwd: projectDir, allowFail: true });
@@ -192,8 +198,8 @@ async function runBackup(projectDir, intervalOverride) {
192
198
  const allFiles = walkDir(projectDir, projectDir);
193
199
  const filtered = filterFiles(allFiles, cfg);
194
200
  const newManifest = buildManifest(filtered);
195
- const oldManifest = loadManifest(backupDir);
196
- hasChanges = manifestChanged(oldManifest, newManifest);
201
+ lastManifest = loadManifest(backupDir);
202
+ hasChanges = manifestChanged(lastManifest, newManifest);
197
203
  if (hasChanges) pendingManifest = newManifest;
198
204
  }
199
205
  } catch (e) {
@@ -202,6 +208,56 @@ async function runBackup(projectDir, intervalOverride) {
202
208
  }
203
209
  if (!hasChanges) continue;
204
210
 
211
+ // V4: Record change event and check for anomalies
212
+ let changedFileCount = 0;
213
+ if (repo) {
214
+ // Use execFileSync directly — git() helper's trim() strips leading spaces
215
+ // from porcelain output, corrupting the first line when it starts with ' '.
216
+ let porcelain = '';
217
+ try {
218
+ porcelain = execFileSync('git', ['status', '--porcelain'], {
219
+ cwd: projectDir, stdio: 'pipe', encoding: 'utf-8',
220
+ });
221
+ } catch { /* ignore */ }
222
+ if (porcelain) {
223
+ const lines = porcelain.split('\n').filter(Boolean);
224
+ if (cfg.protect.length === 0 && cfg.ignore.length === 0) {
225
+ changedFileCount = lines.length;
226
+ } else {
227
+ const changedPaths = lines.map(line => {
228
+ const filePart = line.substring(3);
229
+ const arrowIdx = filePart.indexOf(' -> ');
230
+ const raw = arrowIdx >= 0 ? filePart.substring(arrowIdx + 4) : filePart;
231
+ return unquoteGitPath(raw);
232
+ });
233
+ const fakeFiles = changedPaths.map(rel => ({ rel, full: path.join(projectDir, rel) }));
234
+ changedFileCount = filterFiles(fakeFiles, cfg).length;
235
+ }
236
+ }
237
+ } else if (pendingManifest) {
238
+ if (!lastManifest) {
239
+ changedFileCount = Object.keys(pendingManifest).length;
240
+ } else {
241
+ const newKeys = new Set(Object.keys(pendingManifest));
242
+ const oldKeys = new Set(Object.keys(lastManifest));
243
+ let diffCount = 0;
244
+ for (const k of newKeys) {
245
+ if (!oldKeys.has(k) || lastManifest[k].mtimeMs !== pendingManifest[k].mtimeMs || lastManifest[k].size !== pendingManifest[k].size) diffCount++;
246
+ }
247
+ for (const k of oldKeys) {
248
+ if (!newKeys.has(k)) diffCount++;
249
+ }
250
+ changedFileCount = diffCount;
251
+ }
252
+ }
253
+
254
+ recordChange(tracker, changedFileCount);
255
+ const anomalyResult = checkAnomaly(tracker);
256
+ if (anomalyResult.anomaly && anomalyResult.alert && !anomalyResult.suppressed) {
257
+ saveAlert(projectDir, anomalyResult.alert);
258
+ logger.warn(`ALERT: ${anomalyResult.alert.fileCount} files changed in ${anomalyResult.alert.windowSeconds}s (threshold: ${anomalyResult.alert.threshold})`);
259
+ }
260
+
205
261
  // Git snapshot via Core
206
262
  if ((cfg.backup_strategy === 'git' || cfg.backup_strategy === 'both') && repo) {
207
263
  const snapResult = createGitSnapshot(projectDir, cfg, { branchRef });
@@ -250,6 +306,8 @@ async function runBackup(projectDir, intervalOverride) {
250
306
  logger.log(`Git retention (${gitRetResult.mode}): rebuilt branch with ${gitRetResult.kept} newest snapshots, pruned ${gitRetResult.pruned}. Run 'git gc' to reclaim space.`, 'gray');
251
307
  }
252
308
  }
309
+
310
+ clearExpiredAlert(projectDir);
253
311
  }
254
312
  }
255
313
  }