Skip to content
Open
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
300187f
chore(tooling): replace SQLite skill tracking with CSV log-based appr…
NicolasMassart May 12, 2026
c09f2b7
Merge branch 'main' into MCWP-513-po-c-phase-2-automated-collection-f…
NicolasMassart May 12, 2026
7b89807
Merge branch 'main' into MCWP-513-po-c-phase-2-automated-collection-f…
NicolasMassart May 12, 2026
03df566
dedup
NicolasMassart May 12, 2026
48f6b2b
fix: update error type in hook-skill-tracking-dispatch test
NicolasMassart May 12, 2026
e361e49
Merge branch 'main' into MCWP-513-po-c-phase-2-automated-collection-f…
NicolasMassart May 12, 2026
894f41c
feat: add beforeSubmitPrompt hook for Cursor to handle slash command …
NicolasMassart May 12, 2026
8c177fa
Merge branch 'main' into MCWP-513-po-c-phase-2-automated-collection-f…
NicolasMassart May 12, 2026
1e56de5
Merge branch 'MCWP-513-po-c-phase-2-automated-collection-followup' of…
NicolasMassart May 12, 2026
338d10d
Merge branch 'main' into MCWP-513-po-c-phase-2-automated-collection-f…
NicolasMassart May 13, 2026
91e6579
fix: update script execution method in hook dispatchers
NicolasMassart May 13, 2026
fc9674f
Merge branch 'MCWP-513-po-c-phase-2-automated-collection-followup' of…
NicolasMassart May 13, 2026
dd088d1
feat: add skill name validation in hook-cursor-prompt-dispatch.sh
NicolasMassart May 13, 2026
e1cc49a
Merge branch 'main' into MCWP-513-po-c-phase-2-automated-collection-f…
NicolasMassart May 13, 2026
81505cd
Merge branch 'MCWP-513-po-c-phase-2-automated-collection-followup' of…
NicolasMassart May 13, 2026
59ada9b
feat: enhance hook-dispatchers tests with skill handling setup
NicolasMassart May 13, 2026
9fd5d5f
changes based on review feedback
NicolasMassart May 15, 2026
8958319
Merge branch 'main' into MCWP-513-po-c-phase-2-automated-collection-f…
NicolasMassart May 15, 2026
5126c91
refactor: update skill logging guard in hook-cursor-prompt-dispatch.sh
NicolasMassart May 15, 2026
7743a82
refactor: enhance skill logging in hook-cursor-prompt-dispatch.sh and…
NicolasMassart May 15, 2026
3b1e8d1
Merge branch 'main' into MCWP-513-po-c-phase-2-automated-collection-f…
NicolasMassart May 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"hooks": {
"PreToolUse": [
{
"matcher": "Skill",
"hooks": [
{
"type": "command",
"async": true,
"command": "/bin/sh \"${CLAUDE_PROJECT_DIR:-.}/scripts/tooling/hook-claude-dispatch.sh\""
}
]
}
]
}
}
12 changes: 10 additions & 2 deletions .cursor/hooks.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
{
"version": 1,
"hooks": {
"beforeReadFile": [
"preToolUse": [
{
"command": "[ -z \"$CI\" ] && [ \"$TOOL_USAGE_COLLECTION_OPT_IN\" != \"false\" ] && yarn tsx scripts/tooling/cursor-hook-skill-tracking.ts || echo '{\"permission\":\"allow\"}'"
"command": "/bin/sh scripts/tooling/hook-cursor-dispatch.sh",
"timeout": 5
}
],
"beforeSubmitPrompt": [
{
"matcher": "UserPromptSubmit",
"command": "/bin/sh scripts/tooling/hook-cursor-prompt-dispatch.sh",
"timeout": 2
}
]
}
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ release-signoffs.json
!.cursor/BUGBOT.md
!.cursor/hooks.json
!.cursor/worktrees.json
!.claude/settings.json

# Build environment validation
build-env.json
144 changes: 52 additions & 92 deletions .yarn/plugins/plugin-usage-tracking.cjs
Original file line number Diff line number Diff line change
@@ -1,114 +1,62 @@
// Yarn Berry plugin — records every yarn script execution in the local SQLite
// database (~/.tool-usage-collection/events.db) without modifying any script
// entries in package.json.
// Yarn Berry plugin — records every yarn script execution to the local CSV
// events log (defaults to ~/.tool-usage-collection/metamask-mobile-events.log).
//
// Delegates all tracking logic to scripts/tooling/tool-usage-collection.ts
// (via tsx) so there is a single source of truth for schema, DB access, and
// event writing.
// Appends one CSV line per event directly via fs.appendFileSync.
//
// NOTE: `yarn install` (and other built-in Yarn commands) are NOT tracked.
// The `wrapScriptExecution` hook only fires for scripts defined in package.json,
// not for Yarn's own built-in commands. This is a Yarn Berry limitation — no
// plugin hook exists that wraps built-in command execution.

'use strict';

// CJS modules are wrapped in a function by Node.js, so `return` is valid here.
// Skip entirely in CI or when the developer has opted out — no hooks registered,
// no filesystem access, no spawning.
if (process.env.CI || process.env.TOOL_USAGE_COLLECTION_OPT_IN === 'false') {
module.exports = { name: 'plugin-usage-tracking', factory: () => ({}) };
return;
}

const { spawn } = require('child_process');
const fs = require('fs');
const os = require('os');
const path = require('path');

// Resolve paths relative to this plugin file so the plugin works correctly
// regardless of which directory yarn is invoked from (e.g. .github/scripts in CI).
const PLUGIN_DIR = path.dirname(__filename);
const REPO_ROOT = path.resolve(PLUGIN_DIR, '..', '..');
const TSX_BIN = path.join(REPO_ROOT, 'node_modules', '.bin', 'tsx');
const COLLECTION_SCRIPT = path.join(
REPO_ROOT,
'scripts',
'tooling',
'tool-usage-collection.ts',
);
const DEBUG_LOG = path.join(os.homedir(), '.tool-usage-collection', 'plugin-debug.log');

function debugLog(message) {
try {
const dir = path.dirname(DEBUG_LOG);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.appendFileSync(DEBUG_LOG, `[${new Date().toISOString()}] ${message}\n`);
} catch {
// If we can't write the debug log either, there's nothing we can do
}
}
const PLUGIN_NAME = 'plugin-usage-tracking';

function track(scriptName, eventType, extra) {
const args = [
COLLECTION_SCRIPT,
'--tool', `yarn:${scriptName}`,
'--type', 'yarn_script',
'--event', eventType,
];

if (extra?.success != null) {
args.push('--success', String(extra.success));
}
if (extra?.duration_ms != null) {
args.push('--duration', String(extra.duration_ms));
}
function makeTrackingPlugin() {
const LOG_FILE =
process.env.TOOL_USAGE_COLLECTION_LOG_PATH ||
path.join(os.homedir(), '.tool-usage-collection', 'metamask-mobile-events.log');
const LOG_DIR = path.dirname(LOG_FILE);

// Guard: if tsx or the collection script are missing, skip silently.
// This happens when yarn is invoked from a subdirectory (e.g. .github/scripts in CI)
// where node_modules/.bin/tsx does not exist relative to that cwd.
if (!fs.existsSync(TSX_BIN) || !fs.existsSync(COLLECTION_SCRIPT)) {
debugLog(
`skipping tracking — tsx or script not found\n` +
` tsx_bin=${TSX_BIN}\n` +
` script=${COLLECTION_SCRIPT}`,
);
return;
}
const HEADER = 'tool_name,tool_type,event_type,agent_vendor,session_id,success,duration_ms,created_at\n';

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

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

if (child.pid === undefined) {
debugLog(
`spawn FAILED — tsx not found\n` +
` tsx_bin=${TSX_BIN}\n` +
` script=yarn:${scriptName} event=${eventType}`,
);
return;
try {
if (!fs.existsSync(LOG_DIR)) fs.mkdirSync(LOG_DIR, { recursive: true });
// Exclusive-create (O_EXCL): only the first concurrent writer creates the
// header; EEXIST from any other writer is silently swallowed, preventing
// duplicate header rows when two yarn scripts start in parallel.
try {
fs.writeFileSync(LOG_FILE, HEADER, { flag: 'wx' });
} catch (e) {
if (e.code !== 'EEXIST') throw e;
}
fs.appendFileSync(LOG_FILE, line + '\n');
} catch {
// Silently swallow — nothing we can do if the log write fails.
}
}

// Detach from the parent (Yarn) event loop — the child runs independently
child.unref();
}

module.exports = {
name: `plugin-usage-tracking`,
factory: () => ({
return {
hooks: {
// wrapScriptExecution signature:
// (executor, project, locator, scriptName, extra) => Promise<() => Promise<number>>
// The outer async resolves before the script runs; the inner async IS the script run.
wrapScriptExecution: (executor, _project, _locator, scriptName) =>
Promise.resolve(async () => {
track(scriptName, 'start');
appendEvent(scriptName, 'start');

const start = Date.now();
let exitCode;
Expand All @@ -119,7 +67,7 @@ module.exports = {
// the user presses Ctrl+C). Record as 'interrupted' so the report can
// distinguish abandoned sessions from genuine failures (success=0).
const eventType = exitCode === 129 ? 'interrupted' : 'end';
track(scriptName, eventType, {
appendEvent(scriptName, eventType, {
success: exitCode === 129 ? undefined : exitCode === 0,
duration_ms: Date.now() - start,
});
Expand All @@ -128,5 +76,17 @@ module.exports = {
return exitCode;
}),
},
}),
};
}

module.exports = {
name: PLUGIN_NAME,
factory: () => {
// Skip entirely in CI or when the developer has opted out — no hooks registered,
// no filesystem access.
if (process.env.CI || process.env.TOOL_USAGE_COLLECTION_OPT_IN === 'false') {
return {};
}
return makeTrackingPlugin();
},
};
155 changes: 155 additions & 0 deletions .yarn/plugins/plugin-usage-tracking.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs';

/** Return only the data rows from the log file (skip the CSV header). */
function dataLines(file: string): string[] {
return readFileSync(file, 'utf8')
.trim()
.split('\n')
.filter((l) => !l.startsWith('tool_name,'));
}
import { tmpdir } from 'os';
import { join } from 'path';

// Disable tracking globally at module load time so no accidental writes can
// reach the real log file before beforeEach sets up the temp path.
process.env.CI = 'jest';
delete process.env.TOOL_USAGE_COLLECTION_LOG_PATH;

const plugin = require('../plugins/plugin-usage-tracking.cjs') as {
name: string;
factory: () => {
hooks?: {
wrapScriptExecution: (
executor: () => Promise<number>,
project: unknown,
locator: unknown,
scriptName: string,
) => Promise<() => Promise<number>>;
};
};
};

describe('plugin-usage-tracking', () => {
let logDir: string;
let logFile: string;
let savedCI: string | undefined;
let savedOptIn: string | undefined;

beforeEach(() => {
logDir = mkdtempSync(join(tmpdir(), 'plugin-test-'));
logFile = join(logDir, 'events.log');

savedCI = process.env.CI;
savedOptIn = process.env.TOOL_USAGE_COLLECTION_OPT_IN;

delete process.env.CI;
delete process.env.TOOL_USAGE_COLLECTION_OPT_IN;
process.env.TOOL_USAGE_COLLECTION_LOG_PATH = logFile;
});

afterEach(() => {
rmSync(logDir, { recursive: true, force: true });

if (savedCI === undefined) delete process.env.CI;
else process.env.CI = savedCI;

if (savedOptIn === undefined)
delete process.env.TOOL_USAGE_COLLECTION_OPT_IN;
else process.env.TOOL_USAGE_COLLECTION_OPT_IN = savedOptIn;

delete process.env.TOOL_USAGE_COLLECTION_LOG_PATH;
});

describe('CI / opt-out guard', () => {
it('registers no hooks when CI is set', () => {
process.env.CI = '1';
expect(plugin.factory()).toEqual({});
});

it('registers no hooks when TOOL_USAGE_COLLECTION_OPT_IN is false', () => {
process.env.TOOL_USAGE_COLLECTION_OPT_IN = 'false';
expect(plugin.factory()).toEqual({});
});

it('registers hooks when neither CI nor opt-out is set', () => {
const result = plugin.factory();
expect(result.hooks).toBeDefined();
expect(typeof result.hooks?.wrapScriptExecution).toBe('function');
});
});

describe('wrapScriptExecution', () => {
async function runScript(
scriptName: string,
exitCode: number,
): Promise<void> {
const { hooks } = plugin.factory();
const executor = jest.fn().mockResolvedValue(exitCode);
const wrappedFactory = await hooks!.wrapScriptExecution(
executor,
null,
null,
scriptName,
);
await wrappedFactory();
}

// CSV columns: tool_name,tool_type,event_type,agent_vendor,session_id,success,duration_ms,created_at
// agent_vendor and session_id are always empty for Yarn plugin events.

it('appends a start row then an end row for a successful script', async () => {
await runScript('test:unit', 0);

const lines = dataLines(logFile);
expect(lines).toHaveLength(2);

const [startLine, endLine] = lines;
// start: success='', duration_ms=''
expect(startLine).toMatch(/^yarn:test:unit,yarn_script,start,,,,,.+Z$/);
// end: success=true, duration_ms=<number>
expect(endLine).toMatch(
/^yarn:test:unit,yarn_script,end,,,true,\d+,.+Z$/,
);
});

it('appends a start row then an end row with success=false for a failed script', async () => {
await runScript('lint', 1);

const lines = dataLines(logFile);
expect(lines).toHaveLength(2);

const [, endLine] = lines;
expect(endLine).toMatch(/^yarn:lint,yarn_script,end,,,false,\d+,.+Z$/);
});

it('appends an interrupted row for exit code 129 (SIGHUP)', async () => {
await runScript('build', 129);

const lines = dataLines(logFile);
expect(lines).toHaveLength(2);

const [, interruptedLine] = lines;
// interrupted: success='', duration_ms=<number>
expect(interruptedLine).toMatch(
/^yarn:build,yarn_script,interrupted,,,,\d+,.+Z$/,
);
});

it('accumulates rows across multiple script runs', async () => {
await runScript('test:unit', 0);
await runScript('lint', 0);

const lines = dataLines(logFile);
// 2 rows per run × 2 runs
expect(lines).toHaveLength(4);
expect(lines[0]).toMatch(/^yarn:test:unit,/);
expect(lines[2]).toMatch(/^yarn:lint,/);
});

it('does not write to the log when CI is set', async () => {
process.env.CI = '1';
plugin.factory(); // returns {} when CI is set — no filesystem access
expect(existsSync(logFile)).toBe(false);
});
});
});
Loading
Loading