ronds_ai 0.1.22 → 0.1.24

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
@@ -7,12 +7,12 @@
7
7
 
8
8
  ## Requirements
9
9
 
10
- - Node.js `>=16`
11
- - `git`
12
- - `unzip`
13
-
14
- `git` 主要用于读取仓库信息和用户邮箱;`unzip` 用于 `skills install` 解压技能包。
15
- 可选的 `analyze` 命令单独要求 Node.js `>=20`,不会改变 `record` 等现有命令的 Node 版本要求。
10
+ - Node.js `>=16`
11
+ - `git`
12
+ - `unzip`
13
+
14
+ `git` 主要用于读取仓库信息和用户邮箱;`unzip` 用于 `skills install` 解压技能包。
15
+ 可选的 `analyze` 命令单独要求 Node.js `>=20`,不会改变 `record` 等现有命令的 Node 版本要求。
16
16
 
17
17
  ## Install
18
18
 
@@ -170,65 +170,65 @@ Codex 输出示例:
170
170
  {"continue":true,"hookSpecificOutput":{"hookEventName":"PostToolUse"}}
171
171
  ```
172
172
 
173
- **Hermes** 使用 [Shell hooks](https://hermes-agent.nousresearch.com/docs/user-guide/features/hooks#shell-hooks),通过 `~/.hermes/config.yaml` 中的 `hooks.post_tool_call` 配置监听 `write_file` 和 `patch` 工具的调用。Hermes Shell hook 不要求特定 stdout 格式,成功/失败信息直接输出 JSON 结果行。
174
-
175
- ### `analyze`
176
-
177
- 从 Claude Code 的会话 transcript 增量生成 Langfuse 轨迹:
178
-
179
- ```bash
180
- ronds_ai analyze claude
181
- ```
182
-
183
- 该命令用于 `Stop` 和 `SessionEnd` Hook,不是必选功能,也不会由 `hooks deploy` 自动安装。它会在 Langfuse 中记录:
184
-
185
- - 每个对话 Turn 的根 Span
186
- - 每次 Assistant 消息对应的 LLM Generation 和 Token usage
187
- - Tool 调用的输入、输出和时间
188
- - Skill 标签及可选的 Skill 注入内容
189
- - 一层 Subagent 的 Generation 和 Tool 轨迹
190
-
191
- 行为与 Langfuse Claude Observability Plugin 一致,只采集 transcript 中的 `text`、`tool_use` 和 `tool_result`,不上传 `thinking` block。状态和日志保存在 `~/.ronds_ai/analyze/`。
192
-
193
- Langfuse 连接信息已内置,Trace 的 `userId` 使用当前操作系统用户名。可选配置:
194
-
195
- - `CC_LANGFUSE_DEBUG=true`:启用详细日志
196
- - `CC_LANGFUSE_MAX_CHARS=<正整数>`:单个文本字段的最大字符数,默认 `20000`
197
- - `CC_LANGFUSE_SKILL_TAGS=false`:关闭 `skill:<name>` 标签
198
- - `CC_LANGFUSE_CAPTURE_SKILL_CONTENT=true`:把 Skill 注入内容写入 Tool 输出
199
-
200
- 用户需要自行把以下条目合并到 Claude Code 的 hooks 配置,并停用其他 Langfuse Claude 插件以避免重复上传:
201
-
202
- ```json
203
- {
204
- "hooks": {
205
- "Stop": [
206
- {
207
- "hooks": [
208
- {
209
- "type": "command",
210
- "command": "npx ronds_ai@latest analyze claude"
211
- }
212
- ]
213
- }
214
- ],
215
- "SessionEnd": [
216
- {
217
- "hooks": [
218
- {
219
- "type": "command",
220
- "command": "npx ronds_ai@latest analyze claude"
221
- }
222
- ]
223
- }
224
- ]
225
- }
226
- }
227
- ```
228
-
229
- Node.js 低于 20 或安装时使用了 `--omit=optional` 时,只有 `analyze` 会返回明确错误;`record` 等其他命令仍可运行。Hook payload 或上传处理失败时,`analyze` 保持 fail-open 并把诊断信息写入本地日志。
230
-
231
- ### `doctor`
173
+ **Hermes** 使用 [Shell hooks](https://hermes-agent.nousresearch.com/docs/user-guide/features/hooks#shell-hooks),通过 `~/.hermes/config.yaml` 中的 `hooks.post_tool_call` 配置监听 `write_file` 和 `patch` 工具的调用。Hermes Shell hook 不要求特定 stdout 格式,成功/失败信息直接输出 JSON 结果行。
174
+
175
+ ### `analyze`
176
+
177
+ 从 Claude Code 的会话 transcript 增量生成 Langfuse 轨迹:
178
+
179
+ ```bash
180
+ ronds_ai analyze claude
181
+ ```
182
+
183
+ 该命令用于 `Stop` 和 `SessionEnd` Hook,会由 `hooks deploy` 自动安装(project 或 user scope,未用 `--tool` 排除 claude 时)。它会在 Langfuse 中记录:
184
+
185
+ - 每个对话 Turn 的根 Span
186
+ - 每次 Assistant 消息对应的 LLM Generation 和 Token usage
187
+ - Tool 调用的输入、输出和时间
188
+ - Skill 标签及可选的 Skill 注入内容
189
+ - 一层 Subagent 的 Generation 和 Tool 轨迹
190
+
191
+ 行为与 Langfuse Claude Observability Plugin 一致,只采集 transcript 中的 `text`、`tool_use` 和 `tool_result`,不上传 `thinking` block。状态和日志保存在 `~/.ronds_ai/analyze/`。
192
+
193
+ Langfuse 连接信息已内置,Trace 的 `userId` 优先使用当前项目的 `git user.email`,读取不到时回退到当前操作系统用户名。可选配置:
194
+
195
+ - `CC_LANGFUSE_DEBUG=true`:启用详细日志
196
+ - `CC_LANGFUSE_MAX_CHARS=<正整数>`:单个文本字段的最大字符数,默认 `20000`
197
+ - `CC_LANGFUSE_SKILL_TAGS=false`:关闭 `skill:<name>` 标签
198
+ - `CC_LANGFUSE_CAPTURE_SKILL_CONTENT=true`:把 Skill 注入内容写入 Tool 输出
199
+
200
+ `hooks deploy` 会自动把以下条目写入 Claude Code 的 hooks 配置;如需手动配置可参考。注意停用其他 Langfuse Claude 插件以避免重复上传:
201
+
202
+ ```json
203
+ {
204
+ "hooks": {
205
+ "Stop": [
206
+ {
207
+ "hooks": [
208
+ {
209
+ "type": "command",
210
+ "command": "npx ronds_ai@latest analyze claude"
211
+ }
212
+ ]
213
+ }
214
+ ],
215
+ "SessionEnd": [
216
+ {
217
+ "hooks": [
218
+ {
219
+ "type": "command",
220
+ "command": "npx ronds_ai@latest analyze claude"
221
+ }
222
+ ]
223
+ }
224
+ ]
225
+ }
226
+ }
227
+ ```
228
+
229
+ Node.js 低于 20 或安装时使用了 `--omit=optional` 时,只有 `analyze` 会返回明确错误;`record` 等其他命令仍可运行。Hook payload 或上传处理失败时,`analyze` 保持 fail-open 并把诊断信息写入本地日志。
230
+
231
+ ### `doctor`
232
232
 
233
233
  检查当前目录下的 hook 配置和最近错误日志,方便排查接入问题。
234
234
 
@@ -297,14 +297,14 @@ npx ronds_ai@latest hooks deploy --scope user
297
297
  默认 scope 为 `project`(项目级部署),行为:
298
298
 
299
299
  - 写入或更新 `.cursor/hooks.json`
300
- - 写入或更新 `.claude/settings.json`
300
+ - 写入或更新 `.claude/settings.json`(`PostToolUse` 的 `record claude` 采集 hook,以及 `Stop`、`SessionEnd` 的 `analyze claude` Langfuse 轨迹 hook;Node 低于 20 时跳过 analyze 并在输出 `analyze` 字段说明原因)
301
301
  - 清理项目内旧版 Cursor、Claude、Codex hook 脚本
302
302
  - 跳过 Codex 项目级配置部署,并在输出中说明原因
303
303
 
304
304
  `--scope user`(用户级部署),行为:
305
305
 
306
306
  - 写入或更新用户级 Cursor hook 配置
307
- - 写入或更新用户级 Claude hook 配置
307
+ - 写入或更新用户级 Claude hook 配置(同上:`record claude` 采集 hook + `analyze claude` 轨迹 hook)
308
308
  - 写入或更新系统级 `requirements.toml`(Windows: `C:\ProgramData\OpenAI\Codex\requirements.toml`,Linux / macOS: `/etc/codex/requirements.toml`),包含 `[features] hooks = true`、`[hooks]` 托管目录配置以及 `PostToolUse` 的 `apply_patch` hook
309
309
  - 清理用户目录中的旧版 hook 脚本文件
310
310
 
@@ -342,6 +342,7 @@ hooks_auto_accept: true
342
342
  - `updatedFiles`
343
343
  - `removedFiles`
344
344
  - `skipped`: 被跳过的工具及原因(仅在 project scope 下出现)
345
+ - `analyze`: Claude Langfuse 轨迹 hook 的安装状态(`installed`、`events`、`command`;未安装时包含 `reason`)
345
346
 
346
347
  ### Hooks Auto Sync(自动后台同步)
347
348
 
@@ -364,6 +365,7 @@ RONDS_AI_DISABLE_HOOKS_AUTO_SYNC=1
364
365
  注意:
365
366
 
366
367
  - auto-sync 只维护**用户级** hooks,不影响项目级配置
368
+ - auto-sync 会同步维护用户级 Claude 的 `analyze claude` Langfuse 轨迹 hook
367
369
  - 后台同步失败时,错误记录到 `~/.ronds_ai/hooks_auto_sync_error.log`,不污染当前命令输出
368
370
  - 首次安装 hooks 仍建议执行 `ronds_ai hooks deploy --scope user`
369
371
 
@@ -102,11 +102,20 @@ function extractHookContext(payload) {
102
102
  return null;
103
103
  }
104
104
  const hookEventName = payload.hook_event_name || payload.hookEventName || '';
105
- return {
105
+ const context = {
106
106
  sessionId,
107
107
  transcriptPath: resolvedPath,
108
108
  flushDeferredAgentTurns: hookEventName === 'SessionEnd',
109
109
  };
110
+ const projectDir = [
111
+ payload.cwd,
112
+ payload.workspace && payload.workspace.current_dir,
113
+ payload.workspace && payload.workspace.project_dir,
114
+ ].find((value) => typeof value === 'string' && value.trim());
115
+ if (projectDir) {
116
+ context.projectDir = path.resolve(projectDir);
117
+ }
118
+ return context;
110
119
  }
111
120
 
112
121
  /**
@@ -426,10 +435,7 @@ async function processHookContext(context, config, sdkModules) {
426
435
  async function runClaudeAnalyze() {
427
436
  assertAnalyzeNodeVersion();
428
437
  const sdkModules = loadLangfuseSdk();
429
- const config = getAnalyzeConfig();
430
- writeAnalyzeDebug('analyze hook started', config);
431
438
  const raw = await readStdin();
432
- writeAnalyzeDebug(`stdin received ${raw.length} chars`, config);
433
439
  if (!raw.trim()) {
434
440
  return;
435
441
  }
@@ -438,18 +444,23 @@ async function runClaudeAnalyze() {
438
444
  try {
439
445
  payload = JSON.parse(raw);
440
446
  } catch (error) {
447
+ const config = getAnalyzeConfig();
441
448
  writeAnalyzeLog('INFO', `invalid hook payload: ${error.message}`, config);
442
449
  return;
443
450
  }
444
- writeAnalyzeDebug(
445
- `payload top-level keys: ${Object.keys(payload).sort().join(', ')}`,
446
- config,
447
- );
448
451
  const context = extractHookContext(payload);
449
452
  if (!context) {
453
+ const config = getAnalyzeConfig();
450
454
  writeAnalyzeLog('INFO', 'hook payload has no usable session or transcript', config);
451
455
  return;
452
456
  }
457
+ const config = getAnalyzeConfig(context.projectDir);
458
+ writeAnalyzeDebug('analyze hook started', config);
459
+ writeAnalyzeDebug(`stdin received ${raw.length} chars`, config);
460
+ writeAnalyzeDebug(
461
+ `payload top-level keys: ${Object.keys(payload).sort().join(', ')}`,
462
+ config,
463
+ );
453
464
 
454
465
  try {
455
466
  await processHookContext(context, config, sdkModules);
@@ -1,4 +1,5 @@
1
1
  const os = require('os');
2
+ const { runGit } = require('./git');
2
3
 
3
4
  // Langfuse 连接信息按产品要求从当前 Claude 配置写入源码。
4
5
  const LANGFUSE_PUBLIC_KEY = 'pk-lf-default-001';
@@ -46,15 +47,32 @@ function resolveSystemUsername() {
46
47
  return process.env.USERNAME || process.env.USER || 'unknown';
47
48
  }
48
49
 
50
+ /**
51
+ * 读取当前项目的 Git 邮箱,与 record 上报的 git.user.email 保持一致。
52
+ */
53
+ function resolveGitUserEmail(projectDir) {
54
+ const workingDirectory = typeof projectDir === 'string' && projectDir.trim()
55
+ ? projectDir.trim()
56
+ : process.env.CLAUDE_PROJECT_DIR || process.cwd();
57
+ return runGit(workingDirectory, ['config', 'user.email'], false).toLowerCase();
58
+ }
59
+
60
+ /**
61
+ * 解析 Langfuse 用户标识,优先使用 Git 邮箱并保留系统用户名兜底。
62
+ */
63
+ function resolveAnalyzeUserId(projectDir) {
64
+ return resolveGitUserEmail(projectDir) || resolveSystemUsername();
65
+ }
66
+
49
67
  /**
50
68
  * 返回 analyze 命令使用的固定 Langfuse 配置。
51
69
  */
52
- function getAnalyzeConfig() {
70
+ function getAnalyzeConfig(projectDir) {
53
71
  return {
54
72
  publicKey: LANGFUSE_PUBLIC_KEY,
55
73
  secretKey: LANGFUSE_SECRET_KEY,
56
74
  baseUrl: LANGFUSE_BASE_URL,
57
- userId: resolveSystemUsername(),
75
+ userId: resolveAnalyzeUserId(projectDir),
58
76
  debug: readBooleanOption('CC_LANGFUSE_DEBUG', false),
59
77
  maxChars: readPositiveIntegerOption('CC_LANGFUSE_MAX_CHARS', 20000),
60
78
  skillTags: readBooleanOption('CC_LANGFUSE_SKILL_TAGS', true),
@@ -64,5 +82,7 @@ function getAnalyzeConfig() {
64
82
 
65
83
  module.exports = {
66
84
  getAnalyzeConfig,
85
+ resolveAnalyzeUserId,
86
+ resolveGitUserEmail,
67
87
  resolveSystemUsername,
68
88
  };
@@ -19,7 +19,8 @@ const { spawn } = require('child_process');
19
19
  // ---------------------------------------------------------------------------
20
20
 
21
21
  /** 当前 hooks schema 版本。当用户级 hooks deploy 输出发生变化时 +1。 */
22
- const HOOKS_SCHEMA_VERSION = 1;
22
+ // v2: 用户级 Claude settings 新增 Stop/SessionEnd analyze hook。
23
+ const HOOKS_SCHEMA_VERSION = 2;
23
24
 
24
25
  /** sentinel 有效时长:24 小时 */
25
26
  const HOOKS_AUTO_SYNC_CHECK_TTL_MS = 24 * 60 * 60 * 1000;
@@ -6,6 +6,8 @@ const yaml = require('js-yaml');
6
6
 
7
7
  const CURSOR_COMMAND = 'npx ronds_ai@latest record cursor';
8
8
  const CLAUDE_COMMAND = 'npx ronds_ai@latest record claude';
9
+ const CLAUDE_ANALYZE_COMMAND = 'npx ronds_ai@latest analyze claude';
10
+ const CLAUDE_ANALYZE_EVENTS = ['Stop', 'SessionEnd'];
9
11
  const CODEX_COMMAND = 'npx ronds_ai@latest record codex';
10
12
  const CURSOR_OLD_COMMAND = 'node .cursor/hooks/cursor_hook_request.cjs';
11
13
  const CURSOR_OLD_JS_COMMAND = 'node .cursor/hooks/cursor_hook_request.js';
@@ -216,6 +218,77 @@ function removeClaudeHookFromConfig(config) {
216
218
  return next;
217
219
  }
218
220
 
221
+ /**
222
+ * 解析 Node.js 主版本号,解析失败返回 0。
223
+ * @param {string} version - Node.js 版本字符串
224
+ * @returns {number}
225
+ */
226
+ function getNodeMajorVersion(version = process.versions.node) {
227
+ const major = Number(String(version || '').replace(/^v/i, '').split('.')[0]);
228
+ return Number.isInteger(major) ? major : 0;
229
+ }
230
+
231
+ /**
232
+ * 从单个 Claude hook 事件数组中抽离本工具管理的 analyze 命令。
233
+ * 保留用户在该事件下的其他 entry;analyze 所在 entry 的其余命令会合并到结果中。
234
+ *
235
+ * @param {Array} entries - hooks[event] 数组
236
+ * @returns {{ keepEntries: Array, desiredHooks: Array }}
237
+ */
238
+ function splitClaudeAnalyzeEntries(entries) {
239
+ const keepEntries = [];
240
+ const desiredHooks = [];
241
+
242
+ for (const entry of entries) {
243
+ if (!isPlainObject(entry)) {
244
+ keepEntries.push(entry);
245
+ continue;
246
+ }
247
+
248
+ const existingHooks = Array.isArray(entry.hooks) ? entry.hooks : [];
249
+ const containsAnalyze = existingHooks.some(
250
+ (hook) => isPlainObject(hook) && hook.type === 'command' && hook.command === CLAUDE_ANALYZE_COMMAND,
251
+ );
252
+
253
+ if (!containsAnalyze) {
254
+ keepEntries.push(entry);
255
+ continue;
256
+ }
257
+
258
+ for (const hook of existingHooks) {
259
+ if (!(isPlainObject(hook) && hook.type === 'command' && hook.command === CLAUDE_ANALYZE_COMMAND)) {
260
+ desiredHooks.push(hook);
261
+ }
262
+ }
263
+ }
264
+
265
+ return { keepEntries, desiredHooks };
266
+ }
267
+
268
+ /**
269
+ * 在 Claude settings 中确保 Stop/SessionEnd 的 analyze hook 条目。
270
+ * 只管理命令完全等于 CLAUDE_ANALYZE_COMMAND 的 hook,保留用户自定义条目。
271
+ */
272
+ function ensureClaudeAnalyzeHooks(config) {
273
+ const next = isPlainObject(config) ? { ...config } : {};
274
+ const hooks = isPlainObject(next.hooks) ? { ...next.hooks } : {};
275
+
276
+ for (const eventName of CLAUDE_ANALYZE_EVENTS) {
277
+ const entries = Array.isArray(hooks[eventName]) ? hooks[eventName] : [];
278
+ const { keepEntries, desiredHooks } = splitClaudeAnalyzeEntries(entries);
279
+
280
+ desiredHooks.push({
281
+ type: 'command',
282
+ command: CLAUDE_ANALYZE_COMMAND,
283
+ });
284
+
285
+ hooks[eventName] = keepEntries.concat([{ hooks: desiredHooks }]);
286
+ }
287
+
288
+ next.hooks = hooks;
289
+ return next;
290
+ }
291
+
219
292
  function ensureCodexHooksFeatureFlag(toml) {
220
293
  const newline = toml.includes('\r\n') ? '\r\n' : '\n';
221
294
  const desiredLine = `hooks = true${newline}`;
@@ -511,9 +584,28 @@ function deployCursorHook(cursorPath, result) {
511
584
  recordWriteResult(cursorPath, cursorResult.exists, changed, result);
512
585
  }
513
586
 
514
- function deployClaudeSettings(claudeSettingsPath, result) {
587
+ /**
588
+ * 判断本次部署是否写入 Claude analyze hook。
589
+ * 不满足条件时把原因写入 result.analyze.reason 并返回 false。
590
+ */
591
+ function shouldDeployClaudeAnalyze(result) {
592
+ const nodeMajor = getNodeMajorVersion();
593
+ if (nodeMajor < 20) {
594
+ result.analyze.reason = `analyze requires Node.js 20 or newer; current is ${process.versions.node || 'unknown'}`;
595
+ return false;
596
+ }
597
+
598
+ return true;
599
+ }
600
+
601
+ function deployClaudeSettings(claudeSettingsPath, result, analyzeDeployed) {
515
602
  const claudeSettingsResult = readJsonFile(claudeSettingsPath, {});
516
- const nextClaudeSettings = ensureClaudeSettings(claudeSettingsResult.data);
603
+ let nextClaudeSettings = ensureClaudeSettings(claudeSettingsResult.data);
604
+
605
+ if (analyzeDeployed) {
606
+ nextClaudeSettings = ensureClaudeAnalyzeHooks(nextClaudeSettings);
607
+ }
608
+
517
609
  const changed = writeJsonFile(claudeSettingsPath, nextClaudeSettings);
518
610
  recordWriteResult(claudeSettingsPath, claudeSettingsResult.exists, changed, result);
519
611
  }
@@ -574,6 +666,12 @@ function deployHooks(targetDir = process.cwd(), options = {}) {
574
666
  updatedFiles: [],
575
667
  createdFiles: [],
576
668
  skipped: [],
669
+ analyze: {
670
+ tool: 'claude',
671
+ installed: false,
672
+ events: [],
673
+ command: CLAUDE_ANALYZE_COMMAND,
674
+ },
577
675
  };
578
676
 
579
677
  cleanupLegacyHookScripts(paths.configRoot, result, options.tool);
@@ -585,9 +683,15 @@ function deployHooks(targetDir = process.cwd(), options = {}) {
585
683
  }
586
684
 
587
685
  if (!options.tool || options.tool === 'claude') {
588
- deployClaudeSettings(paths.claudeSettingsPath, result);
686
+ const analyzeDeployed = shouldDeployClaudeAnalyze(result);
687
+ deployClaudeSettings(paths.claudeSettingsPath, result, analyzeDeployed);
688
+ if (analyzeDeployed) {
689
+ result.analyze.installed = true;
690
+ result.analyze.events = [...CLAUDE_ANALYZE_EVENTS];
691
+ }
589
692
  } else {
590
693
  result.skipped.push({ tool: 'claude', reason: 'Skipped by --tool filter' });
694
+ result.analyze.reason = 'Skipped by --tool filter';
591
695
  }
592
696
 
593
697
  if (options.tool && options.tool !== 'codex') {
@@ -683,4 +787,6 @@ function deployHermesHook() {
683
787
  module.exports = {
684
788
  deployHooks,
685
789
  deployHermesHook,
790
+ ensureClaudeAnalyzeHooks,
791
+ getNodeMajorVersion,
686
792
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ronds_ai",
3
- "version": "0.1.22",
3
+ "version": "0.1.24",
4
4
  "description": "CLI for reporting AI code edit events.",
5
5
  "bin": {
6
6
  "ronds_ai": "bin/ronds_ai.js"