ronds_ai 0.1.11 → 0.1.13

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
@@ -221,6 +221,7 @@ npx ronds_ai@latest hooks deploy
221
221
  - 在 `.cursor/hooks.json` 中确保存在 `npx ronds_ai@latest record cursor`
222
222
  - 在 `.claude/settings.json` 中确保存在 `npx ronds_ai@latest record claude`
223
223
  - 在 `.codex/hooks.json` 中确保存在 `npx ronds_ai@latest record codex`(`UserPromptSubmit` + `Stop` 两个 hook)
224
+ - 在 `.codex/config.toml` 中确保存在 `[features] codex_hooks = true`,启用 Codex hooks
224
225
  - 清理旧版 hook 脚本文件
225
226
  - 如果存在 `.claude/settings.local.json`,会移除其中由本工具管理的旧 hook,避免重复触发
226
227
  - 所有工具的失败事件统一写入 `~/.ronds_ai/failed-events`
@@ -2,24 +2,16 @@ const fs = require('fs');
2
2
  const os = require('os');
3
3
  const path = require('path');
4
4
  const { execFileSync } = require('child_process');
5
+ const { runGit } = require('./git');
5
6
 
6
7
  const ENVIRONMENT_VARIABLE_NAME = process.env.ENVIRONMENT_VARIABLE_NAME || 'MCP_TRACKER_WORKER_ID';
7
8
  const MINIMUM_NODE_MAJOR = 16;
8
9
  const SUPPORTED_PERSISTENT_PLATFORMS = new Set(['win32', 'linux']);
9
10
 
10
11
  function readGitUserEmail(baseDir) {
11
- try {
12
- return execFileSync('git', ['config', 'user.email'], {
13
- cwd: baseDir,
14
- encoding: 'utf8',
15
- stdio: ['ignore', 'pipe', 'pipe'],
16
- })
17
- .trim()
18
- .replace(/^["'“”]+/, '')
19
- .replace(/["'“”]+$/, '');
20
- } catch {
21
- return '';
22
- }
12
+ return runGit(baseDir, ['config', 'user.email'], false)
13
+ .replace(/^["'“”]+/, '')
14
+ .replace(/["'“”]+$/, '');
23
15
  }
24
16
 
25
17
  function getProfileFilePath() {
@@ -4,6 +4,7 @@ const path = require('path');
4
4
  const http = require('http');
5
5
  const https = require('https');
6
6
  const { execFileSync } = require('child_process');
7
+ const { runGit } = require('./git');
7
8
  const { createHash, randomUUID } = require('crypto');
8
9
 
9
10
  const DEFAULT_TIMEOUT_MS = 10000;
@@ -14,6 +15,7 @@ const CLAUDE_SUPPORTED_TOOLS = new Set(['Write', 'Edit', 'MultiEdit']);
14
15
  const CURSOR_SUPPORTED_HOOK_EVENT = 'afterFileEdit';
15
16
  const CODEX_SUPPORTED_HOOK_EVENTS = new Set(['UserPromptSubmit', 'Stop']);
16
17
  const CODEX_SNAPSHOT_DIR = path.join(os.homedir(), '.ronds_ai', 'codex-snapshots');
18
+ const CODEX_SNAPSHOT_TTL_SEC = 86400;
17
19
  const GIT_STATUS_ARGS = ['status', '--porcelain=v1', '--untracked-files=all'];
18
20
  const CODEX_STOP_OUTPUT = {
19
21
  continue: false,
@@ -135,23 +137,6 @@ function findGitRepoRoot(startDir) {
135
137
  }
136
138
  }
137
139
 
138
- function runGit(repoRoot, args, required = true) {
139
- try {
140
- return execFileSync('git', args, {
141
- cwd: repoRoot,
142
- encoding: 'utf8',
143
- stdio: ['ignore', 'pipe', 'pipe'],
144
- }).trim();
145
- } catch (error) {
146
- if (!required) {
147
- return '';
148
- }
149
-
150
- const stderr = error && error.stderr ? String(error.stderr).trim() : String(error);
151
- throw new Error(`git ${args.join(' ')} failed: ${stderr}`);
152
- }
153
- }
154
-
155
140
  function extractRepoNameFromRemoteUrl(remoteUrl) {
156
141
  const normalizedUrl = String(remoteUrl || '').trim().replace(/[\\/]+$/, '');
157
142
  if (!normalizedUrl) {
@@ -509,9 +494,9 @@ function readFileState(filePath) {
509
494
  }
510
495
  }
511
496
 
512
- function readHeadFileState(repoRoot, relativePath) {
497
+ function readGitRefFileState(repoRoot, ref, relativePath) {
513
498
  try {
514
- const buffer = execFileSync('git', ['show', `HEAD:${relativePath}`], {
499
+ const buffer = execFileSync('git', ['show', `${ref}:${relativePath}`], {
515
500
  cwd: repoRoot,
516
501
  encoding: null,
517
502
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -539,6 +524,27 @@ function readHeadFileState(repoRoot, relativePath) {
539
524
  }
540
525
  }
541
526
 
527
+ function readHeadFileState(repoRoot, relativePath) {
528
+ return readGitRefFileState(repoRoot, 'HEAD', relativePath);
529
+ }
530
+
531
+ function readCurrentHead(repoRoot) {
532
+ return runGit(repoRoot, ['rev-parse', 'HEAD'], false);
533
+ }
534
+
535
+ function parseGitDiffNameOnly(repoRoot, fromRef, toRef) {
536
+ const normalizedFromRef = String(fromRef || '').trim();
537
+ const normalizedToRef = String(toRef || '').trim();
538
+ if (!normalizedFromRef || !normalizedToRef || normalizedFromRef === normalizedToRef) {
539
+ return [];
540
+ }
541
+
542
+ const output = runGit(repoRoot, ['diff', '--name-only', `${normalizedFromRef}..${normalizedToRef}`], false);
543
+ return output.split('\n')
544
+ .map((line) => line.trim().replace(/\\/g, '/'))
545
+ .filter(Boolean);
546
+ }
547
+
542
548
  function statesEqual(beforeState, afterState) {
543
549
  return beforeState.exists === afterState.exists
544
550
  && beforeState.isBinary === afterState.isBinary
@@ -569,6 +575,41 @@ function buildCodexChange(beforeState, afterState) {
569
575
  };
570
576
  }
571
577
 
578
+ function cleanupStaleSnapshots() {
579
+ let entries;
580
+ try {
581
+ if (!fs.existsSync(CODEX_SNAPSHOT_DIR)) {
582
+ return;
583
+ }
584
+ entries = fs.readdirSync(CODEX_SNAPSHOT_DIR);
585
+ } catch {
586
+ return;
587
+ }
588
+
589
+ const nowSec = Date.now() / 1000;
590
+ for (const entry of entries) {
591
+ if (!entry.endsWith('.json')) {
592
+ continue;
593
+ }
594
+
595
+ const filePath = path.join(CODEX_SNAPSHOT_DIR, entry);
596
+ try {
597
+ const raw = fs.readFileSync(filePath, 'utf8');
598
+ const snapshot = JSON.parse(raw);
599
+ const createdAt = String(snapshot.created_at || '');
600
+ const createdSec = new Date(createdAt).getTime() / 1000;
601
+ if (isNaN(createdSec)) {
602
+ continue;
603
+ }
604
+ if (nowSec - createdSec > CODEX_SNAPSHOT_TTL_SEC) {
605
+ fs.rmSync(filePath, { force: true });
606
+ }
607
+ } catch {
608
+ continue;
609
+ }
610
+ }
611
+ }
612
+
572
613
  function handleCodexUserPromptSubmit(payload) {
573
614
  const cwd = payload.cwd || process.cwd();
574
615
  const repoRoot = findGitRepoRoot(cwd);
@@ -577,6 +618,7 @@ function handleCodexUserPromptSubmit(payload) {
577
618
  return [];
578
619
  }
579
620
 
621
+ const head = readCurrentHead(repoRoot);
580
622
  const baseline = {};
581
623
  for (const entry of parseGitStatus(repoRoot)) {
582
624
  const absolutePath = path.join(repoRoot, entry.path);
@@ -585,12 +627,14 @@ function handleCodexUserPromptSubmit(payload) {
585
627
  : readFileState(absolutePath);
586
628
  }
587
629
 
630
+ cleanupStaleSnapshots();
588
631
  fs.mkdirSync(CODEX_SNAPSHOT_DIR, { recursive: true });
589
632
  fs.writeFileSync(snapshotPath, JSON.stringify({
590
633
  version: 1,
591
634
  session_id: payload.session_id || payload.sessionId || '',
592
635
  turn_id: payload.turn_id || payload.turnId || '',
593
636
  repo_root: repoRoot,
637
+ head,
594
638
  created_at: new Date().toISOString(),
595
639
  baseline,
596
640
  }), 'utf8');
@@ -620,9 +664,15 @@ function buildCodexStopEvents(payload, source) {
620
664
  : {};
621
665
  const currentEntries = parseGitStatus(repoRoot);
622
666
  const currentPaths = new Set(currentEntries.map((entry) => entry.path));
667
+ const baseRef = snapshot && typeof snapshot.head === 'string' && snapshot.head.trim()
668
+ ? snapshot.head.trim()
669
+ : 'HEAD';
670
+ const currentHead = readCurrentHead(repoRoot) || 'HEAD';
671
+ const committedPaths = new Set(parseGitDiffNameOnly(repoRoot, baseRef, currentHead));
623
672
  const allPaths = new Set([
624
673
  ...Object.keys(baseline),
625
674
  ...currentPaths,
675
+ ...committedPaths,
626
676
  ]);
627
677
  const workerId = resolveWorkerId(repoRoot);
628
678
  const git = resolveGitMetadata(repoRoot);
@@ -630,8 +680,11 @@ function buildCodexStopEvents(payload, source) {
630
680
 
631
681
  for (const repoRelativePath of allPaths) {
632
682
  const absolutePath = path.join(repoRoot, repoRelativePath);
633
- const beforeState = baseline[repoRelativePath] || readHeadFileState(repoRoot, repoRelativePath);
634
- const afterState = readFileState(absolutePath);
683
+ const beforeState = baseline[repoRelativePath] || readGitRefFileState(repoRoot, baseRef, repoRelativePath);
684
+ let afterState = readFileState(absolutePath);
685
+ if (!afterState.exists && committedPaths.has(repoRelativePath)) {
686
+ afterState = readGitRefFileState(repoRoot, currentHead, repoRelativePath);
687
+ }
635
688
 
636
689
  if (statesEqual(beforeState, afterState)) {
637
690
  continue;
package/lib/doctor.js CHANGED
@@ -1,45 +1,26 @@
1
1
  const fs = require('fs');
2
2
  const os = require('os');
3
3
  const path = require('path');
4
- const { execFileSync } = require('child_process');
4
+ const { runGit } = require('./git');
5
5
 
6
6
  const SUPPORTED_TOOLS = new Set(['claude', 'codex', 'cursor']);
7
7
 
8
- function formatDateStamp(date = new Date()) {
9
- const year = String(date.getFullYear());
10
- const month = String(date.getMonth() + 1).padStart(2, '0');
11
- const day = String(date.getDate()).padStart(2, '0');
12
- return `${year}${month}${day}`;
13
- }
14
-
15
8
  function getFailedEventDir() {
16
9
  return path.join(os.homedir(), '.ronds_ai', 'failed-events');
17
10
  }
18
11
 
19
- function getRecentLogPaths(tool) {
20
- const dateStamp = formatDateStamp();
21
- return [
22
- path.join(getFailedEventDir(), `${dateStamp}-${tool}-error.jsonl`),
23
- path.join(getFailedEventDir(), `${dateStamp}-cli-error.jsonl`),
24
- ];
25
- }
26
-
27
- function tailFileLines(filePath, maxLines = 20) {
28
- if (!fs.existsSync(filePath)) {
12
+ function readRecentLogs(tool) {
13
+ const dir = getFailedEventDir();
14
+ if (!fs.existsSync(dir)) {
29
15
  return [];
30
16
  }
31
17
 
32
- const raw = fs.readFileSync(filePath, 'utf8');
33
- const lines = raw.split('\n').filter((line) => line.trim() !== '');
34
- return lines.slice(-maxLines);
35
- }
36
-
37
- function readRecentLogs(tool) {
38
- return getRecentLogPaths(tool).map((filePath) => ({
39
- path: filePath,
40
- exists: fs.existsSync(filePath),
41
- lines: tailFileLines(filePath),
42
- }));
18
+ return fs.readdirSync(dir)
19
+ .filter((name) => name.endsWith('.jsonl') && (name.includes(`-${tool}-error`) || name.includes('-cli-error')))
20
+ .sort()
21
+ .reverse()
22
+ .slice(0, 3)
23
+ .map((name) => path.join(dir, name));
43
24
  }
44
25
 
45
26
  function getConfigChecks(tool, baseDir) {
@@ -74,15 +55,11 @@ function getConfigChecks(tool, baseDir) {
74
55
  }
75
56
 
76
57
  function readGitUserEmail(baseDir) {
77
- try {
78
- return execFileSync('git', ['config', 'user.email'], {
79
- cwd: baseDir,
80
- encoding: 'utf8',
81
- stdio: ['ignore', 'pipe', 'pipe'],
82
- }).trim();
83
- } catch {
84
- return '';
85
- }
58
+ return runGit(baseDir, ['config', 'user.email'], false);
59
+ }
60
+
61
+ function readGitRemoteUrl(baseDir) {
62
+ return runGit(baseDir, ['remote', 'get-url', 'origin'], false);
86
63
  }
87
64
 
88
65
  function runDoctor(tool, targetDir = process.cwd()) {
@@ -101,6 +78,7 @@ function runDoctor(tool, targetDir = process.cwd()) {
101
78
  tool: normalizedTool,
102
79
  targetDir: baseDir,
103
80
  gitUserEmail: readGitUserEmail(baseDir),
81
+ gitRemoteUrl: readGitRemoteUrl(baseDir),
104
82
  configChecks,
105
83
  recentLogs: readRecentLogs(normalizedTool),
106
84
  };
package/lib/git.js ADDED
@@ -0,0 +1,22 @@
1
+ const { execFileSync } = require('child_process');
2
+
3
+ function runGit(repoRoot, args, required = true) {
4
+ try {
5
+ return execFileSync('git', args, {
6
+ cwd: repoRoot,
7
+ encoding: 'utf8',
8
+ stdio: ['ignore', 'pipe', 'pipe'],
9
+ }).trim();
10
+ } catch (error) {
11
+ if (!required) {
12
+ return '';
13
+ }
14
+
15
+ const stderr = error && error.stderr ? String(error.stderr).trim() : String(error);
16
+ throw new Error(`git ${args.join(' ')} failed: ${stderr}`);
17
+ }
18
+ }
19
+
20
+ module.exports = {
21
+ runGit,
22
+ };
@@ -72,6 +72,19 @@ function writeJsonFile(filePath, data) {
72
72
  return true;
73
73
  }
74
74
 
75
+ function writeTextFileIfChanged(filePath, nextContent) {
76
+ if (fs.existsSync(filePath)) {
77
+ const currentContent = fs.readFileSync(filePath, 'utf8');
78
+ if (currentContent === nextContent) {
79
+ return false;
80
+ }
81
+ }
82
+
83
+ ensureDir(path.dirname(filePath));
84
+ fs.writeFileSync(filePath, nextContent, 'utf8');
85
+ return true;
86
+ }
87
+
75
88
  function removeFileIfExists(filePath, removedFiles) {
76
89
  if (!fs.existsSync(filePath)) {
77
90
  return;
@@ -217,6 +230,57 @@ function cleanupCodexManagedHooks(hooks) {
217
230
  return removeCommandEntriesByMatcher(withoutManagedCommands, CODEX_OLD_COMMAND_MATCHERS);
218
231
  }
219
232
 
233
+ function ensureCodexHooksFeatureFlag(toml) {
234
+ const newline = toml.includes('\r\n') ? '\r\n' : '\n';
235
+ const desiredLine = `codex_hooks = true${newline}`;
236
+ const desiredSection = `[features]${newline}${desiredLine}`;
237
+
238
+ if (!toml.trim()) {
239
+ return desiredSection;
240
+ }
241
+
242
+ const featuresHeaderPattern = /^[ \t]*\[features\][ \t]*(?:#.*)?$/m;
243
+ const featuresHeaderMatch = featuresHeaderPattern.exec(toml);
244
+ if (!featuresHeaderMatch) {
245
+ const trimmed = toml.replace(/[ \t\r\n]*$/, '');
246
+ return `${trimmed}${newline}${newline}${desiredSection}`;
247
+ }
248
+
249
+ const headerStart = featuresHeaderMatch.index;
250
+ const headerEnd = headerStart + featuresHeaderMatch[0].length;
251
+ const sectionAfterHeaderStart = toml.indexOf('\n', headerEnd) === -1
252
+ ? toml.length
253
+ : toml.indexOf('\n', headerEnd) + 1;
254
+ const afterHeader = toml.slice(sectionAfterHeaderStart);
255
+ const nextHeaderMatch = /^[ \t]*\[[^\]]+\][ \t]*(?:#.*)?$/m.exec(afterHeader);
256
+ const sectionEnd = nextHeaderMatch
257
+ ? sectionAfterHeaderStart + nextHeaderMatch.index
258
+ : toml.length;
259
+ const section = toml.slice(headerStart, sectionEnd);
260
+ const flagPattern = /^([ \t]*codex_hooks[ \t]*=[ \t]*)(true|false)([ \t]*(?:#.*)?$)/m;
261
+ const flagMatch = flagPattern.exec(section);
262
+
263
+ if (flagMatch) {
264
+ if (flagMatch[2] === 'true') {
265
+ return toml;
266
+ }
267
+
268
+ const nextSection = section.replace(flagPattern, '$1true$3');
269
+ return `${toml.slice(0, headerStart)}${nextSection}${toml.slice(sectionEnd)}`;
270
+ }
271
+
272
+ return `${toml.slice(0, sectionAfterHeaderStart)}${desiredLine}${toml.slice(sectionAfterHeaderStart)}`;
273
+ }
274
+
275
+ function ensureCodexConfigToml(configPath) {
276
+ const exists = fs.existsSync(configPath);
277
+ const currentContent = exists ? fs.readFileSync(configPath, 'utf8') : '';
278
+ const nextContent = ensureCodexHooksFeatureFlag(currentContent);
279
+ const changed = writeTextFileIfChanged(configPath, nextContent);
280
+
281
+ return { exists, changed };
282
+ }
283
+
220
284
  function ensureCodexHook(config) {
221
285
  const next = isPlainObject(config) ? { ...config } : {};
222
286
  const hooks = isPlainObject(next.hooks) ? { ...next.hooks } : {};
@@ -289,13 +353,20 @@ function deployHooks(targetDir = process.cwd()) {
289
353
  (claudeSettingsResult.exists ? updatedFiles : createdFiles).push(claudeSettingsPath);
290
354
  }
291
355
 
292
- const codexHooksPath = path.join(baseDir, '.codex', 'hooks.json');
356
+ const codexDir = path.join(baseDir, '.codex');
357
+ const codexHooksPath = path.join(codexDir, 'hooks.json');
293
358
  const codexHooksResult = readJsonFile(codexHooksPath, {});
294
359
  const nextCodexHooks = ensureCodexHook(codexHooksResult.data);
295
360
  if (writeJsonFile(codexHooksPath, nextCodexHooks)) {
296
361
  (codexHooksResult.exists ? updatedFiles : createdFiles).push(codexHooksPath);
297
362
  }
298
363
 
364
+ const codexConfigPath = path.join(codexDir, 'config.toml');
365
+ const codexConfigResult = ensureCodexConfigToml(codexConfigPath);
366
+ if (codexConfigResult.changed) {
367
+ (codexConfigResult.exists ? updatedFiles : createdFiles).push(codexConfigPath);
368
+ }
369
+
299
370
  const localCandidates = [
300
371
  path.join(baseDir, '.claude', 'settings.local.json'),
301
372
  path.join(baseDir, '.claude', 'setting.local.json'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ronds_ai",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "CLI for reporting AI code edit events.",
5
5
  "bin": {
6
6
  "ronds_ai": "bin/ronds_ai.js"