ronds_ai 0.1.19 → 0.1.21

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
@@ -2,7 +2,7 @@
2
2
 
3
3
  `ronds_ai` 是一个命令行工具,主要用于两类事情:
4
4
 
5
- - 接收 Claude / Cursor 的 hook 事件,整理成统一的代码变更事件并上报
5
+ - 接收 Claude / Cursor / Hermes / Codex 的 hook 事件,整理成统一的代码变更事件并上报
6
6
  - 帮助项目写入对应的 hook 配置,以及安装 Skills 到 Claude / Codex / Cursor
7
7
 
8
8
  ## Requirements
@@ -97,7 +97,7 @@ npx ronds_ai@latest check record --no-prompt
97
97
 
98
98
  ### `record`
99
99
 
100
- 从标准输入读取 Claude、Codex 或 Cursor 的 hook payload,转换为统一事件后发送到服务端。
100
+ 从标准输入读取 Claude、Codex、CursorHermes 的 hook payload,转换为统一事件后发送到服务端。
101
101
 
102
102
  ```bash
103
103
  ronds_ai record <tool>
@@ -108,6 +108,7 @@ ronds_ai record <tool>
108
108
  - `claude`
109
109
  - `codex`
110
110
  - `cursor`
111
+ - `hermes`
111
112
 
112
113
  示例:
113
114
 
@@ -115,9 +116,10 @@ ronds_ai record <tool>
115
116
  npx ronds_ai@latest record claude
116
117
  npx ronds_ai@latest record codex
117
118
  npx ronds_ai@latest record cursor
119
+ npx ronds_ai@latest record hermes
118
120
  ```
119
121
 
120
- 这个命令通常不是手工执行,而是被 Claude / Codex / Cursor 的 hook 配置调用。
122
+ 这个命令通常不是手工执行,而是被 Claude / Codex / Cursor 或 Hermes 的 hook 配置调用。
121
123
 
122
124
  行为说明:
123
125
 
@@ -151,6 +153,7 @@ npx ronds_ai@latest record cursor
151
153
  - Claude: `PostToolUse`
152
154
  - Codex: `PostToolUse`(`apply_patch` 编辑后采集)
153
155
  - Cursor: `afterFileEdit`
156
+ - **Hermes**: `post_tool_call`
154
157
 
155
158
  其中 Claude 仅处理这些工具产生的事件:
156
159
 
@@ -166,6 +169,8 @@ Codex 输出示例:
166
169
  {"continue":true,"hookSpecificOutput":{"hookEventName":"PostToolUse"}}
167
170
  ```
168
171
 
172
+ **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 结果行。
173
+
169
174
  ### `doctor`
170
175
 
171
176
  检查当前目录下的 hook 配置和最近错误日志,方便排查接入问题。
@@ -179,6 +184,7 @@ ronds_ai doctor <tool>
179
184
  - `claude`
180
185
  - `codex`
181
186
  - `cursor`
187
+ - `hermes`
182
188
 
183
189
  示例:
184
190
 
@@ -186,6 +192,7 @@ ronds_ai doctor <tool>
186
192
  npx ronds_ai@latest doctor claude
187
193
  npx ronds_ai@latest doctor codex
188
194
  npx ronds_ai@latest doctor cursor
195
+ npx ronds_ai@latest doctor hermes
189
196
  ```
190
197
 
191
198
  输出内容包括:
@@ -210,6 +217,11 @@ Cursor 会检查:
210
217
 
211
218
  - `.cursor/hooks.json`
212
219
 
220
+ Hermes 会检查:
221
+
222
+ - `~/.hermes/config.yaml` 是否存在
223
+ - 该文件中是否有 `hooks.post_tool_call` 下 `matcher: "write_file|patch"` 的 hook 条目
224
+
213
225
  ### `hooks deploy`
214
226
 
215
227
  在当前项目目录或用户目录生成/更新 Claude / Codex / Cursor 的 hook 配置。
@@ -241,6 +253,29 @@ npx ronds_ai@latest hooks deploy --scope user
241
253
 
242
254
  Codex 推荐使用 `--scope user`,因为项目级 `.codex` 配置在未被用户显式信任的项目中会被 Codex 忽略。
243
255
 
256
+ #### Hermes Shell Hook 部署
257
+
258
+ ```bash
259
+ ronds_ai hooks deploy hermes
260
+ ```
261
+
262
+ 生成或更新 `~/.hermes/config.yaml`,添加 `hooks.post_tool_call` 条目(监听 `write_file` 和 `patch` 工具的调用)并设置 `hooks_auto_accept: true`。
263
+
264
+ 因为 Hermes 的 Shell hook 配置独立于 Claude / Codex / Cursor(YAML 格式、用户级目录),所以走单独的子命令路径,不与其他工具混用。
265
+
266
+ 执行后 `~/.hermes/config.yaml` 的效果:
267
+
268
+ ```yaml
269
+ hooks:
270
+ post_tool_call:
271
+ - matcher: write_file|patch
272
+ command: npx ronds_ai@latest record hermes
273
+ timeout: 30
274
+ hooks_auto_accept: true
275
+ ```
276
+
277
+ 也可手动复制以上内容到 `~/.hermes/config.yaml`。
278
+
244
279
  输出是一个 JSON,包含:
245
280
 
246
281
  - `scope`: 本次部署的 scope
@@ -251,6 +286,30 @@ Codex 推荐使用 `--scope user`,因为项目级 `.codex` 配置在未被用
251
286
  - `removedFiles`
252
287
  - `skipped`: 被跳过的工具及原因(仅在 project scope 下出现)
253
288
 
289
+ ### Hooks Auto Sync(自动后台同步)
290
+
291
+ CLI 会**自动在后台检查并更新用户级 hooks 配置**,无需用户每次手动执行 `hooks deploy --scope user`。
292
+
293
+ 工作方式:
294
+
295
+ 1. 运行任意 CLI 命令时,进行轻量检查(多数情况下只读取一次 sentinel 文件的修改时间)
296
+ 2. 如果距离上次检查超过 24 小时,判断 hooks schema 版本是否落后
297
+ 3. 版本落后时,后台启动子进程自动执行 `hooks deploy --scope user` 和 `deploy hermes`
298
+ 4. 当前命令不等待后台同步完成,也不受后台同步失败的影响
299
+ 5. `record` 高频路径使用 sentinel fast path,额外成本约等于一次文件 stat
300
+
301
+ 可通过环境变量禁用:
302
+
303
+ ```bash
304
+ RONDS_AI_DISABLE_HOOKS_AUTO_SYNC=1
305
+ ```
306
+
307
+ 注意:
308
+
309
+ - auto-sync 只维护**用户级** hooks,不影响项目级配置
310
+ - 后台同步失败时,错误记录到 `~/.ronds_ai/hooks_auto_sync_error.log`,不污染当前命令输出
311
+ - 首次安装 hooks 仍建议执行 `ronds_ai hooks deploy --scope user`
312
+
254
313
  ### `skills install`
255
314
 
256
315
  下载一个技能包并安装到 Claude / Codex / Cursor 对应的技能目录。
@@ -334,11 +393,13 @@ https://aihub.ronds.com/api/api/v1/ai-code-events
334
393
  - `YYYYMMDD-claude-error.jsonl`
335
394
  - `YYYYMMDD-codex-error.jsonl`
336
395
  - `YYYYMMDD-cursor-error.jsonl`
396
+ - `YYYYMMDD-hermes-error.jsonl`
337
397
  - `YYYYMMDD-cli-error.jsonl`
338
398
 
339
399
  ## Notes
340
400
 
341
- - `record` 和 `doctor` 目前支持 `claude`、`codex` 与 `cursor`
401
+ - `record` 和 `doctor` 目前支持 `claude`、`codex`、`cursor`、`hermes`
402
+ - `hooks deploy` 支持 `claude`、`codex`、`cursor`、`hermes`(hermes 为独立子命令)
342
403
  - `skills install` 支持 `claude`、`codex`、`cursor`
343
404
  - 不支持的命令或参数会直接报错,并把错误写入 CLI 错误日志
344
405
 
package/bin/ronds_ai.js CHANGED
@@ -10,7 +10,8 @@ const {
10
10
  savePersistentWorkerId,
11
11
  } = require('../lib/check_record');
12
12
  const { runDoctor } = require('../lib/doctor');
13
- const { deployHooks } = require('../lib/hooks_deploy');
13
+ const { deployHooks, deployHermesHook } = require('../lib/hooks_deploy');
14
+ const { maybeStartHooksAutoSync, runHooksAutoSync } = require('../lib/hooks_auto_sync');
14
15
  const { promptForText, promptYesNo } = require('../lib/skills_prompt');
15
16
 
16
17
  function writeWorkerSummary(result) {
@@ -57,7 +58,7 @@ function stripCheckFlags(args) {
57
58
  return args.filter((value) => value !== '--no-prompt');
58
59
  }
59
60
 
60
- const SUPPORTED_SOURCES = new Set(['claude', 'cursor', 'codex']);
61
+ const SUPPORTED_SOURCES = new Set(['claude', 'cursor', 'codex', 'hermes']);
61
62
 
62
63
  function printUsage() {
63
64
  process.stderr.write([
@@ -66,12 +67,14 @@ function printUsage() {
66
67
  ' ronds_ai check record',
67
68
  ' ronds_ai doctor <tool>',
68
69
  ' ronds_ai hooks deploy [--scope project|user] [--tool cursor|claude|codex]',
70
+ ' ronds_ai hooks deploy hermes',
69
71
  ' ronds_ai skills install <name> [--tool claude,codex,cursor] [--scope project|global] [--project-dir <path>] [--force]',
70
72
  '',
71
73
  'Supported tools:',
72
74
  ' claude',
73
75
  ' cursor',
74
76
  ' codex',
77
+ ' hermes',
75
78
  '',
76
79
  'Examples:',
77
80
  ' npx ronds_ai@latest record claude',
@@ -91,6 +94,13 @@ function printUsage() {
91
94
  }
92
95
 
93
96
  async function runHooksCommand(args) {
97
+ // Hermes deploy 走独立路径(YAML 格式、用户级配置,与现有 JSON 部署不同)
98
+ if (args[0] === 'deploy' && args[1] === 'hermes') {
99
+ const result = deployHermesHook();
100
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
101
+ return;
102
+ }
103
+
94
104
  const options = parseHooksDeployArgs(args);
95
105
  const result = deployHooks(process.cwd(), options);
96
106
  process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
@@ -251,9 +261,25 @@ async function runSkillsCommand(args) {
251
261
  process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
252
262
  }
253
263
 
264
+ /**
265
+ * 内部命令分发。当前只支持 hooks auto-sync。
266
+ */
267
+ async function runInternalCommand(args) {
268
+ const [subcommand, ...subargs] = args;
269
+
270
+ if (subcommand === 'hooks' && subargs[0] === 'auto-sync') {
271
+ runHooksAutoSync();
272
+ return;
273
+ }
274
+
275
+ throw new Error('Unsupported internal command');
276
+ }
277
+
254
278
  async function run() {
255
279
  const [, , command, ...args] = process.argv;
256
280
 
281
+ maybeStartHooksAutoSync({ command, args });
282
+
257
283
  if (command === 'record') {
258
284
  const [source] = args;
259
285
  const normalizedSource = String(source || '').trim().toLowerCase();
@@ -294,6 +320,11 @@ async function run() {
294
320
  return;
295
321
  }
296
322
 
323
+ if (command === 'internal') {
324
+ await runInternalCommand(args);
325
+ return;
326
+ }
327
+
297
328
  throw new Error(`Unsupported command: ${command || ''}`);
298
329
  }
299
330
 
@@ -14,6 +14,8 @@ const CLAUDE_SUPPORTED_TOOLS = new Set(['Write', 'Edit', 'MultiEdit']);
14
14
  const CURSOR_SUPPORTED_HOOK_EVENT = 'afterFileEdit';
15
15
  const CODEX_SUPPORTED_HOOK_EVENT = 'PostToolUse';
16
16
  const CODEX_SUPPORTED_TOOL = 'apply_patch';
17
+ const HERMES_SUPPORTED_HOOK_EVENT = 'post_tool_call';
18
+ const HERMES_SUPPORTED_TOOLS = new Set(['write_file', 'patch']);
17
19
 
18
20
  // 构造 Codex PostToolUse hook 要求的标准输出,避免 stdout 出现 schema 之外的字段。
19
21
  function buildCodexPostToolUseOutput() {
@@ -207,6 +209,31 @@ function resolveClaudeAbsoluteFilePath(filePath, payload) {
207
209
  return path.resolve(process.cwd(), rawPath);
208
210
  }
209
211
 
212
+ function resolveHermesAbsoluteFilePath(filePath, payload) {
213
+ const rawPath = String(filePath || '').trim();
214
+ if (!rawPath) {
215
+ return '';
216
+ }
217
+
218
+ if (path.isAbsolute(rawPath)) {
219
+ return path.normalize(rawPath);
220
+ }
221
+
222
+ const candidateBases = [
223
+ payload.cwd,
224
+ process.cwd(),
225
+ ].filter(Boolean);
226
+
227
+ for (const base of candidateBases) {
228
+ const candidate = path.resolve(base, rawPath);
229
+ if (fs.existsSync(candidate) || fs.existsSync(path.dirname(candidate))) {
230
+ return candidate;
231
+ }
232
+ }
233
+
234
+ return path.resolve(process.cwd(), rawPath);
235
+ }
236
+
210
237
  function resolveCursorAbsoluteFilePath(filePath, payload) {
211
238
  const rawPath = String(filePath || '').trim();
212
239
  if (!rawPath) {
@@ -562,6 +589,73 @@ function buildCodexEvents(payload, source) {
562
589
  return buildCodexApplyPatchEvents(payload, source, parseApplyPatchFiles(patchText));
563
590
  }
564
591
 
592
+ function buildHermesEvents(payload, source) {
593
+ const hookEvent = payload.hook_event_name || payload.hookEvent;
594
+ const toolName = payload.tool_name || payload.toolName;
595
+
596
+ if (hookEvent !== HERMES_SUPPORTED_HOOK_EVENT || !HERMES_SUPPORTED_TOOLS.has(toolName)) {
597
+ return [];
598
+ }
599
+
600
+ const toolInput = payload.tool_input || payload.toolInput || {};
601
+
602
+ // write_file — 全量写入,等价于 Claude 的 Write
603
+ if (toolName === 'write_file') {
604
+ const absoluteFilePath = resolveHermesAbsoluteFilePath(toolInput.path, payload);
605
+ const { repoRoot, git, repoRelativePath } = getGitInfo(absoluteFilePath);
606
+ const workerId = resolveWorkerId(repoRoot);
607
+ const changeInfo = buildWriteChange(toolInput);
608
+
609
+ return [{
610
+ event_id: randomUUID(),
611
+ timestamp: new Date().toISOString(),
612
+ source,
613
+ worker_id: workerId,
614
+ git,
615
+ file: {
616
+ path: repoRelativePath || String(toolInput.path || ''),
617
+ operation: changeInfo.operation,
618
+ },
619
+ changes: changeInfo.changes,
620
+ }];
621
+ }
622
+
623
+ // patch — 分两种子模式
624
+ if (toolName === 'patch') {
625
+ // replace 模式 — 锚定替换,等价于 Claude 的 Edit
626
+ if (toolInput.mode !== 'patch') {
627
+ const absoluteFilePath = resolveHermesAbsoluteFilePath(toolInput.path, payload);
628
+ const { repoRoot, git, repoRelativePath } = getGitInfo(absoluteFilePath);
629
+ const workerId = resolveWorkerId(repoRoot);
630
+ const changeInfo = buildEditChange(toolInput);
631
+
632
+ return [{
633
+ event_id: randomUUID(),
634
+ timestamp: new Date().toISOString(),
635
+ source,
636
+ worker_id: workerId,
637
+ git,
638
+ file: {
639
+ path: repoRelativePath || String(toolInput.path || ''),
640
+ operation: changeInfo.operation,
641
+ },
642
+ changes: changeInfo.changes,
643
+ }];
644
+ }
645
+
646
+ // patch 模式 — V4A 多文件补丁,等价于 Codex 的 apply_patch
647
+ const patchText = toolInput.patch || '';
648
+ if (!patchText) {
649
+ return [];
650
+ }
651
+
652
+ const patchFiles = parseApplyPatchFiles(patchText);
653
+ return buildCodexApplyPatchEvents(payload, source, patchFiles);
654
+ }
655
+
656
+ return [];
657
+ }
658
+
565
659
  function loadRequestConfig() {
566
660
  const url = process.env.HOOK_REPORT_URL || process.env.CHANGE_REPORT_URL || DEFAULT_HOOK_REPORT_URL;
567
661
  const timeoutMs = Number(process.env.HOOK_REPORT_TIMEOUT_MS || DEFAULT_TIMEOUT_MS);
@@ -714,6 +808,10 @@ function buildEvents(payload, source) {
714
808
  return buildCodexEvents(payload, source);
715
809
  }
716
810
 
811
+ if (source === 'hermes') {
812
+ return buildHermesEvents(payload, source);
813
+ }
814
+
717
815
  const singleEvent = buildEvent(payload, source);
718
816
  return singleEvent ? [singleEvent] : [];
719
817
  }
package/lib/doctor.js CHANGED
@@ -2,8 +2,9 @@ const fs = require('fs');
2
2
  const os = require('os');
3
3
  const path = require('path');
4
4
  const { runGit } = require('./git');
5
+ const yaml = require('js-yaml');
5
6
 
6
- const SUPPORTED_TOOLS = new Set(['claude', 'codex', 'cursor']);
7
+ const SUPPORTED_TOOLS = new Set(['claude', 'codex', 'cursor', 'hermes']);
7
8
 
8
9
  function getFailedEventDir() {
9
10
  return path.join(os.homedir(), '.ronds_ai', 'failed-events');
@@ -24,6 +25,35 @@ function readRecentLogs(tool) {
24
25
  }
25
26
 
26
27
  function getConfigChecks(tool, baseDir) {
28
+ if (tool === 'hermes') {
29
+ const hermesConfig = path.join(os.homedir(), '.hermes', 'config.yaml');
30
+ let hookEntryFound = false;
31
+
32
+ if (fs.existsSync(hermesConfig)) {
33
+ try {
34
+ const raw = fs.readFileSync(hermesConfig, 'utf8');
35
+ const parsed = yaml.load(raw) || {};
36
+ const postToolCall = parsed.hooks && parsed.hooks.post_tool_call;
37
+ if (Array.isArray(postToolCall)) {
38
+ hookEntryFound = postToolCall.some((entry) =>
39
+ entry && entry.matcher === 'write_file|patch'
40
+ );
41
+ }
42
+ } catch {
43
+ // 解析失败,不做深度校验
44
+ }
45
+ }
46
+
47
+ return [
48
+ {
49
+ label: '~/.hermes/config.yaml',
50
+ path: hermesConfig,
51
+ exists: fs.existsSync(hermesConfig),
52
+ hookEntryFound,
53
+ },
54
+ ];
55
+ }
56
+
27
57
  if (tool === 'cursor') {
28
58
  return [
29
59
  {
@@ -0,0 +1,397 @@
1
+ /**
2
+ * hooks_auto_sync.js
3
+ *
4
+ * 用户级 Hooks 自动同步模块。
5
+ *
6
+ * 提供轻量的前台检查(maybeStartHooksAutoSync)和后端子进程同步(runHooksAutoSync),
7
+ * 让 CLI 命令能无感自动维护用户级 hooks 配置(Claude/Cursor/Hermes/Codex)。
8
+ *
9
+ * 快速路径:sentinel 文件 mtime + TTL,避免高频命令(如 record)反复读取状态 JSON。
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const os = require('os');
15
+ const { spawn } = require('child_process');
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // 常量
19
+ // ---------------------------------------------------------------------------
20
+
21
+ /** 当前 hooks schema 版本。当用户级 hooks deploy 输出发生变化时 +1。 */
22
+ const HOOKS_SCHEMA_VERSION = 1;
23
+
24
+ /** sentinel 有效时长:24 小时 */
25
+ const HOOKS_AUTO_SYNC_CHECK_TTL_MS = 24 * 60 * 60 * 1000;
26
+
27
+ /** lock 文件视为 stale 的超时时长:10 分钟 */
28
+ const STALE_LOCK_TIMEOUT_MS = 10 * 60 * 1000;
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // 路径解析
32
+ // ---------------------------------------------------------------------------
33
+
34
+ function getAutoSyncDir() {
35
+ return path.join(os.homedir(), '.ronds_ai');
36
+ }
37
+
38
+ function getStateFilePath() {
39
+ return path.join(getAutoSyncDir(), 'hooks_state.json');
40
+ }
41
+
42
+ function getSentinelFilePath() {
43
+ return path.join(getAutoSyncDir(), 'hooks_auto_sync_sentinel');
44
+ }
45
+
46
+ function getLockFilePath() {
47
+ return path.join(getAutoSyncDir(), 'hooks_auto_sync.lock');
48
+ }
49
+
50
+ function getErrorLogPath() {
51
+ return path.join(getAutoSyncDir(), 'hooks_auto_sync_error.log');
52
+ }
53
+
54
+ function ensureAutoSyncDir() {
55
+ const dir = getAutoSyncDir();
56
+ fs.mkdirSync(dir, { recursive: true });
57
+ return dir;
58
+ }
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Sentinel fast path(步骤 3)
62
+ // ---------------------------------------------------------------------------
63
+
64
+ /**
65
+ * 快速跳过检查——sentinel 文件存在且 mtime 在 TTL 内。
66
+ * @param {number} now - Date.now()
67
+ * @returns {boolean}
68
+ */
69
+ function shouldFastSkipAutoSync(now) {
70
+ try {
71
+ const stat = fs.statSync(getSentinelFilePath());
72
+ return now - stat.mtimeMs < HOOKS_AUTO_SYNC_CHECK_TTL_MS;
73
+ } catch {
74
+ return false;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * 更新 sentinel 文件的 mtime。
80
+ * 文件不存在则创建空文件。
81
+ * @param {number} now - Date.now()
82
+ */
83
+ function touchAutoSyncSentinel(now) {
84
+ ensureAutoSyncDir();
85
+ const sentinelPath = getSentinelFilePath();
86
+ const atime = Math.floor(now / 1000);
87
+ const mtime = Math.floor(now / 1000);
88
+ try {
89
+ fs.utimesSync(sentinelPath, atime, mtime);
90
+ } catch {
91
+ // 文件不存在,创建空文件
92
+ fs.writeFileSync(sentinelPath, '');
93
+ }
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // 状态文件读写(步骤 4)
98
+ // ---------------------------------------------------------------------------
99
+
100
+ /**
101
+ * 读取 hooks_state.json。
102
+ * 文件不存在、内容为空、JSON 解析失败或顶层非对象时返回空对象。
103
+ * @returns {object}
104
+ */
105
+ function readHooksAutoSyncState() {
106
+ try {
107
+ const content = fs.readFileSync(getStateFilePath(), 'utf-8');
108
+ if (!content.trim()) return {};
109
+ const parsed = JSON.parse(content);
110
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
111
+ return {};
112
+ }
113
+ return parsed;
114
+ } catch {
115
+ return {};
116
+ }
117
+ }
118
+
119
+ /**
120
+ * 写入 hooks_state.json。
121
+ * @param {{ schemaVersion?: number, lastCheckedAt?: number, lastSyncedAt?: number }} state
122
+ */
123
+ function writeHooksAutoSyncState(state) {
124
+ ensureAutoSyncDir();
125
+ const data = {
126
+ schemaVersion: Number(state.schemaVersion) || 0,
127
+ lastCheckedAt: Number(state.lastCheckedAt) || 0,
128
+ lastSyncedAt: Number(state.lastSyncedAt) || 0,
129
+ };
130
+ fs.writeFileSync(getStateFilePath(), JSON.stringify(data, null, 2) + '\n');
131
+ }
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // 命令过滤逻辑(步骤 5)
135
+ // ---------------------------------------------------------------------------
136
+
137
+ /**
138
+ * 判断当前命令是否应完全跳过 auto-sync。
139
+ * @param {string} command - 命令名称
140
+ * @returns {boolean}
141
+ */
142
+ function shouldSkipHooksAutoSync(command) {
143
+ if (process.env.RONDS_AI_DISABLE_HOOKS_AUTO_SYNC === '1') return true;
144
+ if (command === 'hooks' || command === 'internal') return true;
145
+ if (!command) return true;
146
+ return false;
147
+ }
148
+
149
+ // ---------------------------------------------------------------------------
150
+ // TTL 与版本判断(步骤 6)
151
+ // ---------------------------------------------------------------------------
152
+
153
+ /**
154
+ * 根据状态和当前时间判断是否需要调度后台同步。
155
+ *
156
+ * @param {object} state - 从状态文件读取的状态
157
+ * @param {number} now - Date.now()
158
+ * @returns {'skip' | 'update-timestamp' | 'schedule'}
159
+ */
160
+ function shouldScheduleHooksAutoSync(state, now) {
161
+ const lastCheckedAt = Number(state.lastCheckedAt || 0);
162
+
163
+ // 仍在 TTL 窗口内,跳过
164
+ if (now - lastCheckedAt < HOOKS_AUTO_SYNC_CHECK_TTL_MS) {
165
+ return 'skip';
166
+ }
167
+
168
+ const schemaVersion = Number(state.schemaVersion || 0);
169
+
170
+ // 版本已是最新,仅更新时间戳
171
+ if (schemaVersion >= HOOKS_SCHEMA_VERSION) {
172
+ return 'update-timestamp';
173
+ }
174
+
175
+ // 版本落后,需要调度同步
176
+ return 'schedule';
177
+ }
178
+
179
+ // ---------------------------------------------------------------------------
180
+ // 后台进程启动(步骤 7)
181
+ // ---------------------------------------------------------------------------
182
+
183
+ /**
184
+ * 启动后台子进程执行用户级 hooks deploy。
185
+ * 使用 process.execPath + require.resolve 确保:
186
+ * - Windows 上不需 shell,detach 可靠
187
+ * - 与当前进程使用相同的 Node 版本
188
+ * - 指向本地已安装的代码(版本一致)
189
+ */
190
+ function spawnHooksAutoSyncProcess() {
191
+ try {
192
+ const cliEntry = require.resolve('../bin/ronds_ai.js');
193
+ const child = spawn(process.execPath, [cliEntry, 'internal', 'hooks', 'auto-sync'], {
194
+ detached: true,
195
+ stdio: 'ignore',
196
+ windowsHide: true,
197
+ });
198
+ child.on('error', saveHooksAutoSyncError);
199
+ child.unref();
200
+ } catch (error) {
201
+ saveHooksAutoSyncError(error);
202
+ }
203
+ }
204
+
205
+ // ---------------------------------------------------------------------------
206
+ // maybeStartHooksAutoSync(步骤 8)
207
+ // ---------------------------------------------------------------------------
208
+
209
+ /**
210
+ * 轻量前台检查函数。
211
+ *
212
+ * 所有 CLI 命令在分发前均可调用此函数,它会在绝大多数调用中快速返回。
213
+ *
214
+ * @param {{ command: string, args?: string[] }} params
215
+ */
216
+ function maybeStartHooksAutoSync({ command, args } = {}) {
217
+ try {
218
+ if (shouldSkipHooksAutoSync(command)) return;
219
+
220
+ const now = Date.now();
221
+ if (shouldFastSkipAutoSync(now)) return;
222
+
223
+ const state = readHooksAutoSyncState();
224
+ const decision = shouldScheduleHooksAutoSync(state, now);
225
+
226
+ if (decision === 'skip') {
227
+ touchAutoSyncSentinel(now);
228
+ return;
229
+ }
230
+
231
+ if (decision === 'update-timestamp') {
232
+ writeHooksAutoSyncState({
233
+ schemaVersion: Number(state.schemaVersion || 0),
234
+ lastCheckedAt: now,
235
+ lastSyncedAt: Number(state.lastSyncedAt || 0),
236
+ });
237
+ touchAutoSyncSentinel(now);
238
+ return;
239
+ }
240
+
241
+ // decision === 'schedule'
242
+ touchAutoSyncSentinel(now);
243
+ spawnHooksAutoSyncProcess();
244
+ } catch (error) {
245
+ saveHooksAutoSyncError(error);
246
+ }
247
+ }
248
+
249
+ // ---------------------------------------------------------------------------
250
+ // Lock 文件管理(步骤 9)
251
+ // ---------------------------------------------------------------------------
252
+
253
+ /**
254
+ * 获取 auto-sync 全局锁。
255
+ * 使用原子创建('wx')防止并发。
256
+ *
257
+ * @returns {number | null} lock 文件描述符,获取失败返回 null
258
+ */
259
+ function acquireHooksAutoSyncLock() {
260
+ const lockPath = getLockFilePath();
261
+ ensureAutoSyncDir();
262
+
263
+ // 首次尝试创建
264
+ try {
265
+ const fd = fs.openSync(lockPath, 'wx');
266
+ fs.writeSync(fd, JSON.stringify({ pid: process.pid, time: Date.now() }) + '\n');
267
+ return fd;
268
+ } catch (err) {
269
+ if (err.code !== 'EEXIST') return null;
270
+ }
271
+
272
+ // 文件已存在,检查是否 stale
273
+ try {
274
+ const stat = fs.statSync(lockPath);
275
+ if (Date.now() - stat.mtimeMs > STALE_LOCK_TIMEOUT_MS) {
276
+ fs.unlinkSync(lockPath);
277
+ // 重试一次
278
+ try {
279
+ const fd = fs.openSync(lockPath, 'wx');
280
+ fs.writeSync(fd, JSON.stringify({ pid: process.pid, time: Date.now() }) + '\n');
281
+ return fd;
282
+ } catch {
283
+ return null;
284
+ }
285
+ }
286
+ } catch {
287
+ // stat 失败,尝试直接创建
288
+ try {
289
+ const fd = fs.openSync(lockPath, 'wx');
290
+ return fd;
291
+ } catch {
292
+ return null;
293
+ }
294
+ }
295
+
296
+ return null;
297
+ }
298
+
299
+ /**
300
+ * 释放 auto-sync 全局锁。
301
+ * @param {number} fd - 文件描述符
302
+ */
303
+ function releaseHooksAutoSyncLock(fd) {
304
+ try {
305
+ fs.closeSync(fd);
306
+ } catch {
307
+ // 忽略关闭失败
308
+ }
309
+ try {
310
+ fs.unlinkSync(getLockFilePath());
311
+ } catch {
312
+ // 忽略删除失败
313
+ }
314
+ }
315
+
316
+ // ---------------------------------------------------------------------------
317
+ // 后台同步函数 runHooksAutoSync(步骤 10)
318
+ // ---------------------------------------------------------------------------
319
+
320
+ /**
321
+ * 执行用户级 hooks auto-sync。
322
+ *
323
+ * 此函数由后台子进程(ronds_ai internal hooks auto-sync)调用。
324
+ * 不向 stdout 输出,错误写入本地日志文件。
325
+ */
326
+ function runHooksAutoSync() {
327
+ const lock = acquireHooksAutoSyncLock();
328
+ if (!lock) return; // 已有进程在同步
329
+
330
+ const now = Date.now();
331
+
332
+ try {
333
+ const { deployHooks, deployHermesHook } = require('./hooks_deploy');
334
+
335
+ deployHooks(os.homedir(), { scope: 'user' });
336
+ deployHermesHook();
337
+
338
+ writeHooksAutoSyncState({
339
+ schemaVersion: HOOKS_SCHEMA_VERSION,
340
+ lastCheckedAt: now,
341
+ lastSyncedAt: now,
342
+ });
343
+ touchAutoSyncSentinel(now);
344
+ } catch (error) {
345
+ saveHooksAutoSyncError(error);
346
+ } finally {
347
+ releaseHooksAutoSyncLock(lock);
348
+ }
349
+ }
350
+
351
+ // ---------------------------------------------------------------------------
352
+ // 错误记录
353
+ // ---------------------------------------------------------------------------
354
+
355
+ /**
356
+ * 将 auto-sync 错误写入本地日志文件。
357
+ * @param {Error | string} error
358
+ */
359
+ function saveHooksAutoSyncError(error) {
360
+ try {
361
+ ensureAutoSyncDir();
362
+ const timestamp = new Date().toISOString();
363
+ const message = error instanceof Error ? error.stack || error.message : String(error);
364
+ const entry = [
365
+ `--- ${timestamp} ---`,
366
+ `HOOKS_SCHEMA_VERSION=${HOOKS_SCHEMA_VERSION}`,
367
+ `node=${process.version}`,
368
+ `platform=${process.platform}`,
369
+ `error=${message}`,
370
+ '',
371
+ ].join('\n');
372
+
373
+ fs.appendFileSync(getErrorLogPath(), entry, 'utf-8');
374
+ } catch {
375
+ // 错误记录失败时不做任何事,避免连锁异常
376
+ }
377
+ }
378
+
379
+ // ---------------------------------------------------------------------------
380
+ // 导出
381
+ // ---------------------------------------------------------------------------
382
+
383
+ module.exports = {
384
+ HOOKS_SCHEMA_VERSION,
385
+ maybeStartHooksAutoSync,
386
+ runHooksAutoSync,
387
+
388
+ // 以下为测试和内部使用导出的函数
389
+ shouldSkipHooksAutoSync,
390
+ shouldFastSkipAutoSync,
391
+ shouldScheduleHooksAutoSync,
392
+ touchAutoSyncSentinel,
393
+ readHooksAutoSyncState,
394
+ writeHooksAutoSyncState,
395
+ acquireHooksAutoSyncLock,
396
+ releaseHooksAutoSyncLock,
397
+ };
@@ -2,6 +2,7 @@
2
2
  const os = require('os');
3
3
  const path = require('path');
4
4
  const commentJson = require('comment-json');
5
+ const yaml = require('js-yaml');
5
6
 
6
7
  const CURSOR_COMMAND = 'npx ronds_ai@latest record cursor';
7
8
  const CLAUDE_COMMAND = 'npx ronds_ai@latest record claude';
@@ -604,6 +605,82 @@ function deployHooks(targetDir = process.cwd(), options = {}) {
604
605
  return result;
605
606
  }
606
607
 
608
+ const HERMES_CONFIG_PATH = path.join(os.homedir(), '.hermes', 'config.yaml');
609
+ const HERMES_MATCHER = 'write_file|patch';
610
+ const HERMES_COMMAND = 'npx ronds_ai@latest record hermes';
611
+
612
+ function isHermesHookEntry(entry) {
613
+ return isPlainObject(entry)
614
+ && entry.matcher === HERMES_MATCHER
615
+ && entry.command === HERMES_COMMAND;
616
+ }
617
+
618
+ function deployHermesHook() {
619
+ const exists = fs.existsSync(HERMES_CONFIG_PATH);
620
+ let config = {};
621
+
622
+ if (exists) {
623
+ const raw = fs.readFileSync(HERMES_CONFIG_PATH, 'utf8');
624
+ if (raw.trim()) {
625
+ config = yaml.load(raw) || {};
626
+ }
627
+ }
628
+
629
+ if (!isPlainObject(config)) {
630
+ config = {};
631
+ }
632
+
633
+ const result = {
634
+ tool: 'hermes',
635
+ configPath: HERMES_CONFIG_PATH,
636
+ created: !exists,
637
+ updated: false,
638
+ details: [],
639
+ };
640
+
641
+ // 确保 hooks.post_tool_call 存在
642
+ const hooks = isPlainObject(config.hooks) ? { ...config.hooks } : {};
643
+ const postToolCall = Array.isArray(hooks.post_tool_call) ? hooks.post_tool_call.slice() : [];
644
+
645
+ // 检查是否已有我们的条目
646
+ const existingEntry = postToolCall.find(isHermesHookEntry);
647
+
648
+ if (!existingEntry) {
649
+ // 移除旧的 ronds 条目(如有),再插入新条目
650
+ const filtered = postToolCall.filter((entry) => !isHermesHookEntry(entry));
651
+ filtered.push({
652
+ matcher: HERMES_MATCHER,
653
+ command: HERMES_COMMAND,
654
+ timeout: 30,
655
+ });
656
+ hooks.post_tool_call = filtered;
657
+ result.details.push('Added Hermes shell hook entry for write_file|patch');
658
+ result.updated = true;
659
+ }
660
+
661
+ config.hooks = hooks;
662
+
663
+ // 如果 hooks_auto_accept 未设置,添加它(首次运行跳过交互确认)
664
+ if (config.hooks_auto_accept === undefined) {
665
+ config.hooks_auto_accept = true;
666
+ result.details.push('Set hooks_auto_accept: true');
667
+ result.updated = true;
668
+ }
669
+
670
+ if (result.updated || result.created) {
671
+ const yamlContent = yaml.dump(config, {
672
+ lineWidth: -1,
673
+ noRefs: true,
674
+ sortKeys: false,
675
+ });
676
+ ensureDir(path.dirname(HERMES_CONFIG_PATH));
677
+ fs.writeFileSync(HERMES_CONFIG_PATH, yamlContent, 'utf8');
678
+ }
679
+
680
+ return result;
681
+ }
682
+
607
683
  module.exports = {
608
684
  deployHooks,
685
+ deployHermesHook,
609
686
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ronds_ai",
3
- "version": "0.1.19",
3
+ "version": "0.1.21",
4
4
  "description": "CLI for reporting AI code edit events.",
5
5
  "bin": {
6
6
  "ronds_ai": "bin/ronds_ai.js"
@@ -13,7 +13,8 @@
13
13
  "node": ">=16"
14
14
  },
15
15
  "dependencies": {
16
- "comment-json": "^4.2.5"
16
+ "comment-json": "^4.2.5",
17
+ "js-yaml": "^5.1.0"
17
18
  },
18
19
  "license": "MIT"
19
20
  }