Skip to content

Commit 300187f

Browse files
chore(tooling): replace SQLite skill tracking with CSV log-based approach
Rewrites the AI tool usage collection system to use a local CSV append log instead of a direct SQLite write from hooks. Hooks are now pure-shell (no Node/yarn/tsx dependency), with the DB populated on-demand by the dev-tooling-explorer or nightly cron. Splits the shared dispatcher into agent-specific entry scripts (hook-cursor-dispatch.sh, hook-claude-dispatch.sh) and a common script (hook-common.sh). Removes obsolete Node modules (db.ts, events.ts, tool-usage-collection.ts, cursor-hook-skill-tracking.ts) and the better-sqlite3 dependency. Adds full test coverage for both the shell dispatcher and the Yarn plugin. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent b283dbd commit 300187f

21 files changed

Lines changed: 645 additions & 1323 deletions

.claude/settings.json

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"$schema": "https://json.schemastore.org/claude-code-settings.json",
3+
"hooks": {
4+
"PreToolUse": [
5+
{
6+
"matcher": "Skill",
7+
"hooks": [
8+
{
9+
"type": "command",
10+
"async": true,
11+
"command": "/bin/sh \"${CLAUDE_PROJECT_DIR:-.}/scripts/tooling/hook-claude-dispatch.sh\""
12+
}
13+
]
14+
}
15+
]
16+
}
17+
}
Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,6 @@
11
---
22
name: pr-changelog
33
summary: Generate a CHANGELOG entry line for a pull request from code changes.
4-
hooks:
5-
PreToolUse:
6-
- hooks:
7-
- type: command
8-
once: true
9-
async: true
10-
command: '[ -z "$CI" ] && [ "$TOOL_USAGE_COLLECTION_OPT_IN" != "false" ] && yarn tsx scripts/tooling/tool-usage-collection.ts --tool skill:pr-changelog --type skill --event start --agent claude || true'
114
---
125

136
Follow `.agents/skills/pr-changelog/SKILL.md`.

.cursor/hooks.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
{
22
"version": 1,
33
"hooks": {
4-
"beforeReadFile": [
4+
"preToolUse": [
55
{
6-
"command": "[ -z \"$CI\" ] && [ \"$TOOL_USAGE_COLLECTION_OPT_IN\" != \"false\" ] && yarn tsx scripts/tooling/cursor-hook-skill-tracking.ts || echo '{\"permission\":\"allow\"}'"
6+
"command": "/bin/sh scripts/tooling/hook-cursor-dispatch.sh",
7+
"timeout": 5
78
}
89
]
910
}
Lines changed: 52 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -1,114 +1,64 @@
1-
// Yarn Berry plugin — records every yarn script execution in the local SQLite
2-
// database (~/.tool-usage-collection/events.db) without modifying any script
3-
// entries in package.json.
1+
// Yarn Berry plugin — records every yarn script execution to the local CSV
2+
// events log (~/.tool-usage-collection/metamask-mobile-events.log).
43
//
5-
// Delegates all tracking logic to scripts/tooling/tool-usage-collection.ts
6-
// (via tsx) so there is a single source of truth for schema, DB access, and
7-
// event writing.
4+
// Appends one CSV line per event directly via fs.appendFileSync — no spawning,
5+
// no tsx, no SQLite. The log is drained into the DB by dev-tooling-explorer
6+
// and the nightly cronjob when they start up.
87

98
'use strict';
109

11-
// CJS modules are wrapped in a function by Node.js, so `return` is valid here.
12-
// Skip entirely in CI or when the developer has opted out — no hooks registered,
13-
// no filesystem access, no spawning.
14-
if (process.env.CI || process.env.TOOL_USAGE_COLLECTION_OPT_IN === 'false') {
15-
module.exports = { name: 'plugin-usage-tracking', factory: () => ({}) };
16-
return;
17-
}
18-
19-
const { spawn } = require('child_process');
2010
const fs = require('fs');
2111
const os = require('os');
2212
const path = require('path');
2313

24-
// Resolve paths relative to this plugin file so the plugin works correctly
25-
// regardless of which directory yarn is invoked from (e.g. .github/scripts in CI).
26-
const PLUGIN_DIR = path.dirname(__filename);
27-
const REPO_ROOT = path.resolve(PLUGIN_DIR, '..', '..');
28-
const TSX_BIN = path.join(REPO_ROOT, 'node_modules', '.bin', 'tsx');
29-
const COLLECTION_SCRIPT = path.join(
30-
REPO_ROOT,
31-
'scripts',
32-
'tooling',
33-
'tool-usage-collection.ts',
34-
);
35-
const DEBUG_LOG = path.join(os.homedir(), '.tool-usage-collection', 'plugin-debug.log');
14+
const PLUGIN_NAME = 'plugin-usage-tracking';
3615

37-
function debugLog(message) {
38-
try {
39-
const dir = path.dirname(DEBUG_LOG);
40-
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
41-
fs.appendFileSync(DEBUG_LOG, `[${new Date().toISOString()}] ${message}\n`);
42-
} catch {
43-
// If we can't write the debug log either, there's nothing we can do
44-
}
45-
}
16+
function makeTrackingPlugin() {
17+
const LOG_FILE =
18+
process.env.TOOL_USAGE_COLLECTION_LOG_PATH ||
19+
path.join(os.homedir(), '.tool-usage-collection', 'metamask-mobile-events.log');
20+
const LOG_DIR = path.dirname(LOG_FILE);
4621

47-
function track(scriptName, eventType, extra) {
48-
const args = [
49-
COLLECTION_SCRIPT,
50-
'--tool', `yarn:${scriptName}`,
51-
'--type', 'yarn_script',
52-
'--event', eventType,
53-
];
54-
55-
if (extra?.success != null) {
56-
args.push('--success', String(extra.success));
57-
}
58-
if (extra?.duration_ms != null) {
59-
args.push('--duration', String(extra.duration_ms));
60-
}
22+
const DEBUG_LOG = path.join(LOG_DIR, 'plugin-debug.log');
6123

62-
// Guard: if tsx or the collection script are missing, skip silently.
63-
// This happens when yarn is invoked from a subdirectory (e.g. .github/scripts in CI)
64-
// where node_modules/.bin/tsx does not exist relative to that cwd.
65-
if (!fs.existsSync(TSX_BIN) || !fs.existsSync(COLLECTION_SCRIPT)) {
66-
debugLog(
67-
`skipping tracking — tsx or script not found\n` +
68-
` tsx_bin=${TSX_BIN}\n` +
69-
` script=${COLLECTION_SCRIPT}`,
70-
);
71-
return;
24+
function debugLog(message) {
25+
try {
26+
if (!fs.existsSync(LOG_DIR)) fs.mkdirSync(LOG_DIR, { recursive: true });
27+
fs.appendFileSync(DEBUG_LOG, `[${new Date().toISOString()}] ${message}\n`);
28+
} catch {
29+
// Nothing we can do if even the debug log fails.
30+
}
7231
}
7332

74-
// Fire-and-forget: detach immediately so the subprocess never blocks the
75-
// user's terminal. The child writes its own DB errors to stderr (ignored
76-
// here); spawn-level failures are logged to the debug log file.
77-
const child = spawn(TSX_BIN, args, {
78-
detached: true,
79-
stdio: 'ignore',
80-
cwd: REPO_ROOT,
81-
});
33+
// Format: tool_name,tool_type,event_type,agent_vendor,session_id,success,duration_ms,created_at
34+
function appendEvent(toolName, eventType, extra) {
35+
const success = extra?.success != null ? String(extra.success) : '';
36+
const durationMs = extra?.duration_ms != null ? String(extra.duration_ms) : '';
37+
const timestamp = new Date().toISOString();
8238

83-
// Attach a no-op error handler to prevent unhandled 'error' events from
84-
// crashing Yarn when spawn fails (e.g. ENOENT on the binary).
85-
child.on('error', (err) => {
86-
debugLog(`spawn error: ${err.message}`);
87-
});
39+
// agent_vendor and session_id are always empty for Yarn plugin events.
40+
const line = `yarn:${toolName},yarn_script,${eventType},,,${success},${durationMs},${timestamp}`;
8841

89-
if (child.pid === undefined) {
90-
debugLog(
91-
`spawn FAILED — tsx not found\n` +
92-
` tsx_bin=${TSX_BIN}\n` +
93-
` script=yarn:${scriptName} event=${eventType}`,
94-
);
95-
return;
42+
try {
43+
if (!fs.existsSync(LOG_DIR)) fs.mkdirSync(LOG_DIR, { recursive: true });
44+
// Write the header on first creation so the file is self-describing.
45+
if (!fs.existsSync(LOG_FILE)) {
46+
fs.appendFileSync(LOG_FILE, 'tool_name,tool_type,event_type,agent_vendor,session_id,success,duration_ms,created_at\n');
47+
}
48+
fs.appendFileSync(LOG_FILE, line + '\n');
49+
} catch (err) {
50+
debugLog(`append failed: ${err.message}`);
51+
}
9652
}
9753

98-
// Detach from the parent (Yarn) event loop — the child runs independently
99-
child.unref();
100-
}
101-
102-
module.exports = {
103-
name: `plugin-usage-tracking`,
104-
factory: () => ({
54+
return {
10555
hooks: {
10656
// wrapScriptExecution signature:
10757
// (executor, project, locator, scriptName, extra) => Promise<() => Promise<number>>
10858
// The outer async resolves before the script runs; the inner async IS the script run.
10959
wrapScriptExecution: (executor, _project, _locator, scriptName) =>
11060
Promise.resolve(async () => {
111-
track(scriptName, 'start');
61+
appendEvent(scriptName, 'start');
11262

11363
const start = Date.now();
11464
let exitCode;
@@ -119,7 +69,7 @@ module.exports = {
11969
// the user presses Ctrl+C). Record as 'interrupted' so the report can
12070
// distinguish abandoned sessions from genuine failures (success=0).
12171
const eventType = exitCode === 129 ? 'interrupted' : 'end';
122-
track(scriptName, eventType, {
72+
appendEvent(scriptName, eventType, {
12373
success: exitCode === 129 ? undefined : exitCode === 0,
12474
duration_ms: Date.now() - start,
12575
});
@@ -128,5 +78,17 @@ module.exports = {
12878
return exitCode;
12979
}),
13080
},
131-
}),
81+
};
82+
}
83+
84+
module.exports = {
85+
name: PLUGIN_NAME,
86+
factory: () => {
87+
// Skip entirely in CI or when the developer has opted out — no hooks registered,
88+
// no filesystem access.
89+
if (process.env.CI || process.env.TOOL_USAGE_COLLECTION_OPT_IN === 'false') {
90+
return {};
91+
}
92+
return makeTrackingPlugin();
93+
},
13294
};
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs';
2+
3+
/** Return only the data rows from the log file (skip the CSV header). */
4+
function dataLines(file: string): string[] {
5+
return readFileSync(file, 'utf8')
6+
.trim()
7+
.split('\n')
8+
.filter((l) => !l.startsWith('tool_name,'));
9+
}
10+
import { tmpdir } from 'os';
11+
import { join } from 'path';
12+
13+
// Disable tracking globally at module load time so no accidental writes can
14+
// reach the real log file before beforeEach sets up the temp path.
15+
process.env.CI = 'jest';
16+
delete process.env.TOOL_USAGE_COLLECTION_LOG_PATH;
17+
18+
const plugin = require('../plugins/plugin-usage-tracking.cjs') as {
19+
name: string;
20+
factory: () => {
21+
hooks?: {
22+
wrapScriptExecution: (
23+
executor: () => Promise<number>,
24+
project: unknown,
25+
locator: unknown,
26+
scriptName: string,
27+
) => Promise<() => Promise<number>>;
28+
};
29+
};
30+
};
31+
32+
describe('plugin-usage-tracking', () => {
33+
let logDir: string;
34+
let logFile: string;
35+
let savedCI: string | undefined;
36+
let savedOptIn: string | undefined;
37+
38+
beforeEach(() => {
39+
logDir = mkdtempSync(join(tmpdir(), 'plugin-test-'));
40+
logFile = join(logDir, 'events.log');
41+
42+
savedCI = process.env.CI;
43+
savedOptIn = process.env.TOOL_USAGE_COLLECTION_OPT_IN;
44+
45+
delete process.env.CI;
46+
delete process.env.TOOL_USAGE_COLLECTION_OPT_IN;
47+
process.env.TOOL_USAGE_COLLECTION_LOG_PATH = logFile;
48+
});
49+
50+
afterEach(() => {
51+
rmSync(logDir, { recursive: true, force: true });
52+
53+
if (savedCI === undefined) delete process.env.CI;
54+
else process.env.CI = savedCI;
55+
56+
if (savedOptIn === undefined)
57+
delete process.env.TOOL_USAGE_COLLECTION_OPT_IN;
58+
else process.env.TOOL_USAGE_COLLECTION_OPT_IN = savedOptIn;
59+
60+
delete process.env.TOOL_USAGE_COLLECTION_LOG_PATH;
61+
});
62+
63+
describe('CI / opt-out guard', () => {
64+
it('registers no hooks when CI is set', () => {
65+
process.env.CI = '1';
66+
expect(plugin.factory()).toEqual({});
67+
});
68+
69+
it('registers no hooks when TOOL_USAGE_COLLECTION_OPT_IN is false', () => {
70+
process.env.TOOL_USAGE_COLLECTION_OPT_IN = 'false';
71+
expect(plugin.factory()).toEqual({});
72+
});
73+
74+
it('registers hooks when neither CI nor opt-out is set', () => {
75+
const result = plugin.factory();
76+
expect(result.hooks).toBeDefined();
77+
expect(typeof result.hooks?.wrapScriptExecution).toBe('function');
78+
});
79+
});
80+
81+
describe('wrapScriptExecution', () => {
82+
async function runScript(
83+
scriptName: string,
84+
exitCode: number,
85+
): Promise<void> {
86+
const { hooks } = plugin.factory();
87+
const executor = jest.fn().mockResolvedValue(exitCode);
88+
const wrappedFactory = await hooks!.wrapScriptExecution(
89+
executor,
90+
null,
91+
null,
92+
scriptName,
93+
);
94+
await wrappedFactory();
95+
}
96+
97+
// CSV columns: tool_name,tool_type,event_type,agent_vendor,session_id,success,duration_ms,created_at
98+
// agent_vendor and session_id are always empty for Yarn plugin events.
99+
100+
it('appends a start row then an end row for a successful script', async () => {
101+
await runScript('test:unit', 0);
102+
103+
const lines = dataLines(logFile);
104+
expect(lines).toHaveLength(2);
105+
106+
const [startLine, endLine] = lines;
107+
// start: success='', duration_ms=''
108+
expect(startLine).toMatch(/^yarn:test:unit,yarn_script,start,,,,,.+Z$/);
109+
// end: success=true, duration_ms=<number>
110+
expect(endLine).toMatch(
111+
/^yarn:test:unit,yarn_script,end,,,true,\d+,.+Z$/,
112+
);
113+
});
114+
115+
it('appends a start row then an end row with success=false for a failed script', async () => {
116+
await runScript('lint', 1);
117+
118+
const lines = dataLines(logFile);
119+
expect(lines).toHaveLength(2);
120+
121+
const [, endLine] = lines;
122+
expect(endLine).toMatch(/^yarn:lint,yarn_script,end,,,false,\d+,.+Z$/);
123+
});
124+
125+
it('appends an interrupted row for exit code 129 (SIGHUP)', async () => {
126+
await runScript('build', 129);
127+
128+
const lines = dataLines(logFile);
129+
expect(lines).toHaveLength(2);
130+
131+
const [, interruptedLine] = lines;
132+
// interrupted: success='', duration_ms=<number>
133+
expect(interruptedLine).toMatch(
134+
/^yarn:build,yarn_script,interrupted,,,,\d+,.+Z$/,
135+
);
136+
});
137+
138+
it('accumulates rows across multiple script runs', async () => {
139+
await runScript('test:unit', 0);
140+
await runScript('lint', 0);
141+
142+
const lines = dataLines(logFile);
143+
// 2 rows per run × 2 runs
144+
expect(lines).toHaveLength(4);
145+
expect(lines[0]).toMatch(/^yarn:test:unit,/);
146+
expect(lines[2]).toMatch(/^yarn:lint,/);
147+
});
148+
149+
it('does not write to the log when CI is set', async () => {
150+
process.env.CI = '1';
151+
plugin.factory(); // returns {} when CI is set — no filesystem access
152+
expect(existsSync(logFile)).toBe(false);
153+
});
154+
});
155+
});

0 commit comments

Comments
 (0)