@apdesign/cursor-roi-tracker 0.5.2

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 ADDED
@@ -0,0 +1,146 @@
1
+ # cursor-roi-tracker
2
+
3
+ `cursor-roi-tracker` 是一个面向 Git 提交的 AI 代码归因工具。
4
+ 它会在 `pre-commit` 阶段统计本次暂存区代码变更与 AI 事件的交集,在 `post-commit` 阶段补齐提交信息、写入本地历史,并可选上报到服务端。
5
+
6
+ ## 功能概览
7
+
8
+ - 自动安装 Git hooks(`pre-commit` / `post-commit`)
9
+ - 基于暂存区 diff 做行级 AI 归因(新增/删除)
10
+ - 提交后生成结构化报告(JSON/JSONL)
11
+ - 支持消费并压缩已归因事件,减少事件文件膨胀
12
+ - 可选静默换 token + 自动上报提交报告
13
+ - 默认不阻塞开发流程(异常会记录到错误日志)
14
+
15
+ ## 安装
16
+
17
+ 在仓库根目录执行:
18
+
19
+ ```bash
20
+ npm i -D cursor-roi-tracker
21
+ ```
22
+
23
+ 如果你发布的是带 scope 的包,也可以使用:
24
+
25
+ ```bash
26
+ npm i -D @animus/cursor-roi-tracker
27
+ ```
28
+
29
+ 安装时会自动执行 `postinstall`,尝试完成以下动作:
30
+
31
+ 1. 写入/更新 `.git/hooks/pre-commit`
32
+ 2. 写入/更新 `.git/hooks/post-commit`
33
+ 3. 初始化默认配置文件 `.cursor-roi-tracker.json`(若不存在)
34
+ 4. 将事件文件加入 `.git/info/exclude`
35
+ 5. 若配置了服务端地址,尝试静默换取本地 token
36
+
37
+ ## 使用方法(推荐流程)
38
+
39
+ ### 第 1 步:安装 hooks(仅首次或需要重装时)
40
+
41
+ ```bash
42
+ npx cursor-roi-install-hooks
43
+ ```
44
+
45
+ > 若安装依赖后自动注入成功,这一步可跳过。
46
+
47
+ ### 第 2 步:正常开发并提交
48
+
49
+ ```bash
50
+ git add .
51
+ git commit -m "feat: your message"
52
+ ```
53
+
54
+ 提交时会自动触发:
55
+
56
+ - `pre-commit`:采集当前暂存区指标,生成待提交报告
57
+ - `post-commit`:绑定 commit sha/time,写入最终报告和历史
58
+
59
+ ### 第 3 步:查看本地产物
60
+
61
+ 默认写入在仓库的 `.cursor/` 下(若不可写则回退到 `.cursor-roi/`):
62
+
63
+ - `.cursor/ai-commit-report.pending.json`
64
+ - `.cursor/ai-commit-report.final.json`
65
+ - `.cursor/ai-commit-history.jsonl`
66
+ - `.cursor/ai-commit-errors.jsonl`
67
+
68
+ ## 手动命令
69
+
70
+ 你也可以单独执行:
71
+
72
+ ```bash
73
+ # 手动执行 pre-commit 采集
74
+ npx cursor-roi-pre-commit
75
+
76
+ # 手动执行 post-commit 绑定/压缩/上报
77
+ npx cursor-roi-post-commit
78
+
79
+ # 重新安装 hooks
80
+ npx cursor-roi-install-hooks
81
+ ```
82
+
83
+ ## 配置说明
84
+
85
+ 配置文件名:`.cursor-roi-tracker.json`(仓库根目录)
86
+
87
+ 示例:
88
+
89
+ ```json
90
+ {
91
+ "highThreshold": 0.85,
92
+ "sourceExtensions": [".ts", ".tsx", ".js", ".jsx", ".vue"],
93
+ "excludeRegexes": ["(^|/)dist/", "(^|/)build/", "\\.min\\."],
94
+ "eventsDirectory": [".cursor/animus-ai-events.jsonl", ".cursor/local-ai-events"],
95
+ "membersFile": ".cursor/enginuity_yunxiao_members.names.json",
96
+ "serverBaseUrl": "https://your-server.example.com",
97
+ "silentTokenPath": "/api/cursor-board/auth/silent-token",
98
+ "commitReportPath": "/api/cursor-board/commit-reports",
99
+ "requestTimeoutMs": 5000
100
+ }
101
+ ```
102
+
103
+ ### 关键字段说明
104
+
105
+ - `highThreshold`:判定 AI 归因命中阈值
106
+ - `sourceExtensions`:参与统计的代码文件后缀
107
+ - `excludeRegexes`:排除路径/文件规则(正则字符串)
108
+ - `eventsDirectory`:AI 事件文件路径(支持多个)
109
+ - `serverBaseUrl`:配置后启用提交后上报
110
+ - `requestTimeoutMs`:服务端请求超时(毫秒)
111
+
112
+ ## 与服务端联动(可选)
113
+
114
+ 当 `serverBaseUrl` 已配置时:
115
+
116
+ 1. 安装 hooks 时会尝试调用 `silentTokenPath` 换取 token
117
+ 2. token 会写入 Git 目录下的本地文件(并自动加入 exclude)
118
+ 3. `post-commit` 会把最终报告 POST 到 `commitReportPath`
119
+
120
+ 如果未配置 `serverBaseUrl`,工具仍可完整执行本地统计,只是不进行网络上报。
121
+
122
+ ## 常见问题
123
+
124
+ ### 1) 为什么没有看到 AI 指标(全是 0)?
125
+
126
+ 优先检查:
127
+
128
+ - `eventsDirectory` 指向的事件文件是否存在
129
+ - 事件文件中是否有有效 `eventId`
130
+ - 本次提交文件是否命中 `sourceExtensions` 且未被 `excludeRegexes` 排除
131
+
132
+ ### 2) 为什么没有生成报告文件?
133
+
134
+ 优先检查:
135
+
136
+ - 是否在 Git 仓库内执行
137
+ - hooks 是否成功写入 `.git/hooks/`
138
+ - 是否真的有暂存区源代码文件参与提交
139
+
140
+ ### 3) 会不会阻塞提交?
141
+
142
+ 默认不会。命令失败会尽量记录到 `.cursor/ai-commit-errors.jsonl`,并避免中断主提交流程。
143
+
144
+ ## 许可证
145
+
146
+ MIT
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { runInstallHooks } = require('../src/cli-install-hooks');
4
+
5
+ runInstallHooks().catch(() => {
6
+ process.exit(0);
7
+ });
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { runPostCommit } = require('../src/cli-post-commit');
4
+
5
+ runPostCommit().catch(() => {
6
+ process.exit(0);
7
+ });
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { runPreCommit } = require('../src/cli-pre-commit');
4
+
5
+ runPreCommit().catch(() => {
6
+ process.exit(0);
7
+ });
@@ -0,0 +1,244 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const vscode = require("vscode");
4
+ const crypto = require("crypto");
5
+ const { acquireMutexLock, releaseMutexLock, resolveEventLockPath } = require("../src/mutex-lock");
6
+
7
+ const EVENT_FILE_RELATIVE_PATH = path.join(".cursor", "animus-ai-events.jsonl");
8
+ const BULK_INSERT_THRESHOLD = 15;
9
+ const PURE_DELETE_THRESHOLD = 15;
10
+ const DEBOUNCE_WINDOW_MS = 200;
11
+ const NEARBY_LINE_DISTANCE = 8;
12
+ const writeChainsByFile = new Map();
13
+ const pendingDebounceByTarget = new Map();
14
+
15
+ function activate(context) {
16
+ const subscription = vscode.workspace.onDidChangeTextDocument((event) => {
17
+ try {
18
+ handleDocumentChanged(event);
19
+ } catch (_error) {
20
+ // Keep extension silent in background mode.
21
+ }
22
+ });
23
+
24
+ context.subscriptions.push(subscription);
25
+ }
26
+
27
+ function deactivate() {
28
+ for (const key of Array.from(pendingDebounceByTarget.keys())) {
29
+ const pending = pendingDebounceByTarget.get(key);
30
+ if (pending?.timer) {
31
+ clearTimeout(pending.timer);
32
+ }
33
+ flushPendingEvent(key);
34
+ }
35
+ }
36
+
37
+ function handleDocumentChanged(event) {
38
+ const document = event?.document;
39
+ if (!document || document.uri.scheme !== "file") {
40
+ return;
41
+ }
42
+ if (isUndoOrRedo(event.reason)) {
43
+ return;
44
+ }
45
+
46
+ const workspaceFolder = vscode.workspace.getWorkspaceFolder(document.uri);
47
+ if (!workspaceFolder) {
48
+ return;
49
+ }
50
+
51
+ for (const change of event.contentChanges || []) {
52
+ if (!change) {
53
+ continue;
54
+ }
55
+ const insertLines = countInsertedLines(change.text);
56
+ // Important: deleted lines must use line coordinates, never rangeLength (characters).
57
+ const deletedLines = Math.max(0, change.range.end.line - change.range.start.line);
58
+ const replacedLines = Math.max(1, deletedLines + 1);
59
+ if (isFullFileReplacement(change, document.lineCount, replacedLines)) {
60
+ continue;
61
+ }
62
+
63
+ const relativePath = normalizeRelativePath(
64
+ path.relative(workspaceFolder.uri.fsPath, document.uri.fsPath)
65
+ );
66
+ if (!relativePath || relativePath.startsWith("..")) {
67
+ continue;
68
+ }
69
+
70
+ const eventFilePath = path.join(workspaceFolder.uri.fsPath, EVENT_FILE_RELATIVE_PATH);
71
+ const payloads = buildEventPayloads({
72
+ change,
73
+ relativePath,
74
+ insertLines,
75
+ deletedLines,
76
+ });
77
+ for (const payload of payloads) {
78
+ enqueueDebouncedEvent(eventFilePath, payload);
79
+ }
80
+ }
81
+ }
82
+
83
+ function normalizeRelativePath(inputPath) {
84
+ return String(inputPath || "")
85
+ .replace(/\\/g, "/")
86
+ .replace(/^\.\//, "")
87
+ .trim();
88
+ }
89
+
90
+ function isUndoOrRedo(reason) {
91
+ return (
92
+ reason === vscode.TextDocumentChangeReason.Undo ||
93
+ reason === vscode.TextDocumentChangeReason.Redo
94
+ );
95
+ }
96
+
97
+ function countInsertedLines(text) {
98
+ if (text === "") {
99
+ return 0;
100
+ }
101
+ const normalizedText = String(text || "").replace(/\r\n/g, "\n");
102
+ return normalizedText.split("\n").length;
103
+ }
104
+
105
+ function isFullFileReplacement(change, fileLineCount, replacedLines) {
106
+ const startsAtTop = change.range.start.line === 0 && change.range.start.character === 0;
107
+ const nearWholeFile = replacedLines >= Math.max(1, fileLineCount - 1);
108
+ return startsAtTop && nearWholeFile;
109
+ }
110
+
111
+ function enqueueJsonlAppend(filePath, payload) {
112
+ const line = `${JSON.stringify(payload)}\n`;
113
+ const prev = writeChainsByFile.get(filePath) || Promise.resolve();
114
+ const next = prev
115
+ .catch(() => undefined)
116
+ .then(async () => {
117
+ await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
118
+ const repoRoot = resolveRepoRootFromEventFile(filePath);
119
+ const lockHandle = await acquireMutexLock(resolveEventLockPath(repoRoot));
120
+ try {
121
+ await fs.promises.appendFile(filePath, line, "utf8");
122
+ } finally {
123
+ await releaseMutexLock(lockHandle);
124
+ }
125
+ })
126
+ .catch(() => undefined);
127
+ writeChainsByFile.set(filePath, next);
128
+ }
129
+
130
+ function buildEventPayloads({ change, relativePath, insertLines, deletedLines }) {
131
+ const payloads = [];
132
+ const now = Date.now();
133
+ const insertStartLine = change.range.start.line + 1;
134
+ const insertEndLine = insertLines > 0 ? insertStartLine + insertLines - 1 : null;
135
+ const deleteStartLine = change.range.start.line + 1;
136
+ const deleteEndLine = deletedLines > 0 ? deleteStartLine + deletedLines - 1 : null;
137
+
138
+ if (insertLines > BULK_INSERT_THRESHOLD) {
139
+ payloads.push({
140
+ eventId: crypto.randomUUID(),
141
+ type: "agent_bulk_insert",
142
+ file_path: relativePath,
143
+ insert_lines: insertLines,
144
+ deleted_lines: deletedLines,
145
+ insert_start_line: insertStartLine,
146
+ insert_end_line: insertEndLine,
147
+ delete_start_line: deleteStartLine,
148
+ delete_end_line: deleteEndLine,
149
+ timestamp: now,
150
+ });
151
+ }
152
+
153
+ if (insertLines === 0 && deletedLines > PURE_DELETE_THRESHOLD) {
154
+ payloads.push({
155
+ eventId: crypto.randomUUID(),
156
+ type: "agent_pure_delete",
157
+ file_path: relativePath,
158
+ insert_lines: 0,
159
+ deleted_lines: deletedLines,
160
+ delete_start_line: deleteStartLine,
161
+ delete_end_line: deleteEndLine,
162
+ timestamp: now,
163
+ });
164
+ }
165
+
166
+ return payloads;
167
+ }
168
+
169
+ function resolveRepoRootFromEventFile(filePath) {
170
+ return path.dirname(path.dirname(filePath));
171
+ }
172
+
173
+ function enqueueDebouncedEvent(eventFilePath, payload) {
174
+ const filePath = normalizeRelativePath(payload.file_path);
175
+ const key = `${eventFilePath}::${filePath}`;
176
+ const current = pendingDebounceByTarget.get(key);
177
+
178
+ if (!current) {
179
+ pendingDebounceByTarget.set(key, createPendingEntry(key, eventFilePath, payload));
180
+ return;
181
+ }
182
+
183
+ if (isNearbyPayload(current.payload, payload)) {
184
+ clearTimeout(current.timer);
185
+ current.payload = payload;
186
+ current.timer = setTimeout(() => flushPendingEvent(key), DEBOUNCE_WINDOW_MS);
187
+ return;
188
+ }
189
+
190
+ clearTimeout(current.timer);
191
+ flushPendingEvent(key);
192
+ pendingDebounceByTarget.set(key, createPendingEntry(key, eventFilePath, payload));
193
+ }
194
+
195
+ function createPendingEntry(key, eventFilePath, payload) {
196
+ return {
197
+ eventFilePath,
198
+ payload,
199
+ timer: setTimeout(() => flushPendingEvent(key), DEBOUNCE_WINDOW_MS),
200
+ };
201
+ }
202
+
203
+ function flushPendingEvent(key) {
204
+ const pending = pendingDebounceByTarget.get(key);
205
+ if (!pending) {
206
+ return;
207
+ }
208
+ pendingDebounceByTarget.delete(key);
209
+ enqueueJsonlAppend(pending.eventFilePath, pending.payload);
210
+ }
211
+
212
+ function isNearbyPayload(prevPayload, nextPayload) {
213
+ if (!prevPayload || !nextPayload) {
214
+ return false;
215
+ }
216
+ const prevPath = normalizeRelativePath(prevPayload.file_path);
217
+ const nextPath = normalizeRelativePath(nextPayload.file_path);
218
+ if (!prevPath || !nextPath || prevPath !== nextPath) {
219
+ return false;
220
+ }
221
+ const prevLine = getPrimaryLineForPayload(prevPayload);
222
+ const nextLine = getPrimaryLineForPayload(nextPayload);
223
+ if (!Number.isFinite(prevLine) || !Number.isFinite(nextLine)) {
224
+ return false;
225
+ }
226
+ return Math.abs(prevLine - nextLine) <= NEARBY_LINE_DISTANCE;
227
+ }
228
+
229
+ function getPrimaryLineForPayload(payload) {
230
+ const deleteStart = Number(payload.delete_start_line);
231
+ if (Number.isFinite(deleteStart)) {
232
+ return deleteStart;
233
+ }
234
+ const insertStart = Number(payload.insert_start_line);
235
+ if (Number.isFinite(insertStart)) {
236
+ return insertStart;
237
+ }
238
+ return Number(payload.start_line);
239
+ }
240
+
241
+ module.exports = {
242
+ activate,
243
+ deactivate,
244
+ };
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "animus-cursor-roi-tracker",
3
+ "displayName": "Animus Cursor ROI Tracker",
4
+ "description": "Silently captures bulk agent insert events for ROI attribution.",
5
+ "version": "0.1.0",
6
+ "publisher": "animus",
7
+ "engines": {
8
+ "vscode": "^1.74.0"
9
+ },
10
+ "categories": [
11
+ "Other"
12
+ ],
13
+ "activationEvents": [
14
+ "onStartupFinished"
15
+ ],
16
+ "main": "./extension.js"
17
+ }
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@apdesign/cursor-roi-tracker",
3
+ "version": "0.5.2",
4
+ "description": "Collect commit-level AI coding metrics from staged diff and local AI events",
5
+ "license": "MIT",
6
+ "type": "commonjs",
7
+ "main": "src/index.js",
8
+ "bin": {
9
+ "cursor-roi-pre-commit": "./bin/pre-commit.js",
10
+ "cursor-roi-post-commit": "./bin/post-commit.js",
11
+ "cursor-roi-install-hooks": "./bin/install-hooks.js"
12
+ },
13
+ "scripts": {
14
+ "postinstall": "node ./bin/install-hooks.js --auto",
15
+ "precommit:collect": "node ./bin/pre-commit.js",
16
+ "postcommit:collect": "node ./bin/post-commit.js",
17
+ "hooks:install": "node ./bin/install-hooks.js",
18
+ "test": "node --test test/*.test.js"
19
+ },
20
+ "files": [
21
+ "bin",
22
+ "src",
23
+ "extension",
24
+ "README.md"
25
+ ],
26
+ "engines": {
27
+ "node": ">=18"
28
+ }
29
+ }