@sakanano-moe/agenthelm 6.7.0 → 6.11.0-rc.1

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.
Files changed (86) hide show
  1. package/package.json +1 -1
  2. package/runtime/index.json +6 -6
  3. package/runtime/plugins/agent-pack/.codex-plugin/plugin.json +1 -1
  4. package/runtime/plugins/agent-pack/hooks/hooks.json +32 -0
  5. package/runtime/plugins/agent-pack/plugin.json +1 -1
  6. package/runtime/plugins/agent-pack/references/local-only.md +126 -0
  7. package/runtime/plugins/agent-pack/references/nudge.en.md +58 -0
  8. package/runtime/plugins/agent-pack/references/nudge.md +58 -0
  9. package/runtime/plugins/agent-pack/runtime/agent_pack_nudge.py +573 -0
  10. package/runtime/plugins/agent-pack/runtime/agenthelm.py +419 -107
  11. package/runtime/plugins/agent-pack/runtime/agenthelm_runtime.py +77 -53
  12. package/runtime/plugins/agent-pack/runtime/i18n_agent_pack.py +78 -0
  13. package/runtime/plugins/agent-pack/runtime/local_only.py +1130 -0
  14. package/runtime/plugins/agent-pack/runtime/locale/en.json +385 -0
  15. package/runtime/plugins/agent-pack/runtime/mcp_server.py +25 -0
  16. package/runtime/plugins/agent-pack/runtime/nudge_text.py +107 -0
  17. package/runtime/plugins/agent-pack/skills/agent-pack/SKILL.md +9 -1
  18. package/runtime/plugins/agents-init/.codex-plugin/plugin.json +1 -1
  19. package/runtime/plugins/agents-init/content.json +17 -2
  20. package/runtime/plugins/agents-init/plugin.json +1 -1
  21. package/runtime/plugins/agents-init/skills/agents-init/AGENT.template.md +47 -0
  22. package/runtime/plugins/agents-init/skills/agents-init/SKILL.md +39 -1
  23. package/runtime/plugins/agents-init/skills/agents-init/project.schema.json +404 -70
  24. package/runtime/plugins/agents-init/skills/agents-init/scripts/adopt.py +596 -0
  25. package/runtime/plugins/agents-init/skills/agents-init/scripts/i18n_init.py +78 -0
  26. package/runtime/plugins/agents-init/skills/agents-init/scripts/init_project.py +245 -72
  27. package/runtime/plugins/agents-init/skills/agents-init/scripts/locale/en.json +210 -0
  28. package/runtime/plugins/agents-init/skills/agents-init/scripts/regions.py +99 -0
  29. package/runtime/plugins/agents-init/skills/agents-init/scripts/scaffold.py +1081 -0
  30. package/runtime/plugins/agents-init/skills/agents-init/scripts/test_adopt.py +551 -0
  31. package/runtime/plugins/agents-init/skills/agents-init/scripts/test_init_project.py +46 -2
  32. package/runtime/plugins/agents-init/skills/agents-init/scripts/test_scaffold.py +555 -0
  33. package/runtime/plugins/agents-init/skills/agents-init/skeleton.json +66 -0
  34. package/runtime/plugins/agents-init/skills/agents-init/skill.json +1 -1
  35. package/runtime/plugins/opinion-manager/.codex-plugin/plugin.json +1 -1
  36. package/runtime/plugins/opinion-manager/plugin.json +1 -1
  37. package/runtime/plugins/opinion-manager/references/inbox.en.md +48 -0
  38. package/runtime/plugins/opinion-manager/references/inbox.md +48 -0
  39. package/runtime/plugins/opinion-manager/runtime/context.py +19 -18
  40. package/runtime/plugins/opinion-manager/runtime/i18n_opinion.py +78 -0
  41. package/runtime/plugins/opinion-manager/runtime/inbox.py +397 -0
  42. package/runtime/plugins/opinion-manager/runtime/inbox_text.py +101 -0
  43. package/runtime/plugins/opinion-manager/runtime/lifecycle.py +45 -44
  44. package/runtime/plugins/opinion-manager/runtime/locale/en.json +184 -0
  45. package/runtime/plugins/opinion-manager/runtime/opinion_manager.py +55 -50
  46. package/runtime/plugins/opinion-manager/runtime/versioned.py +84 -83
  47. package/runtime/plugins/opinion-manager/skills/opinion-manager/SKILL.md +15 -2
  48. package/runtime/plugins/project-orchestrator/.codex-plugin/plugin.json +1 -1
  49. package/runtime/plugins/project-orchestrator/content.json +30 -3
  50. package/runtime/plugins/project-orchestrator/plugin.json +1 -1
  51. package/runtime/plugins/project-orchestrator/references/case-workspaces.md +9 -1
  52. package/runtime/plugins/project-orchestrator/references/case.schema.json +1 -0
  53. package/runtime/plugins/project-orchestrator/references/sop-interview.md +83 -0
  54. package/runtime/plugins/project-orchestrator/references/sop.md +156 -0
  55. package/runtime/plugins/project-orchestrator/runtime/assets.py +52 -57
  56. package/runtime/plugins/project-orchestrator/runtime/case.py +139 -80
  57. package/runtime/plugins/project-orchestrator/runtime/case_structure.py +71 -0
  58. package/runtime/plugins/project-orchestrator/runtime/case_workspace.py +47 -46
  59. package/runtime/plugins/project-orchestrator/runtime/i18n_orchestrator.py +78 -0
  60. package/runtime/plugins/project-orchestrator/runtime/locale/en.json +1409 -0
  61. package/runtime/plugins/project-orchestrator/runtime/memory.py +44 -43
  62. package/runtime/plugins/project-orchestrator/runtime/private_paths.py +119 -0
  63. package/runtime/plugins/project-orchestrator/runtime/project_base.py +37 -19
  64. package/runtime/plugins/project-orchestrator/runtime/project_cli.py +267 -174
  65. package/runtime/plugins/project-orchestrator/runtime/project_demo.py +130 -48
  66. package/runtime/plugins/project-orchestrator/runtime/project_maintain.py +524 -134
  67. package/runtime/plugins/project-orchestrator/runtime/project_menu.py +159 -85
  68. package/runtime/plugins/project-orchestrator/runtime/project_real.py +191 -106
  69. package/runtime/plugins/project-orchestrator/runtime/project_write.py +58 -55
  70. package/runtime/plugins/project-orchestrator/runtime/sop.py +1670 -0
  71. package/runtime/plugins/project-orchestrator/runtime/sop_structure.py +226 -0
  72. package/runtime/plugins/project-orchestrator/runtime/wf.py +74 -70
  73. package/runtime/plugins/project-orchestrator/skills/project-orchestrator/SKILL.md +22 -0
  74. package/runtime/plugins/project-orchestrator/sop-templates/blank/SOP.en.md +57 -0
  75. package/runtime/plugins/project-orchestrator/sop-templates/blank/SOP.md +57 -0
  76. package/runtime/plugins/project-orchestrator/sop-templates/knowledge-digest/SOP.en.md +77 -0
  77. package/runtime/plugins/project-orchestrator/sop-templates/knowledge-digest/SOP.md +77 -0
  78. package/runtime/plugins/project-orchestrator/sop-templates/knowledge-digest/templates/digest.en.md +11 -0
  79. package/runtime/plugins/project-orchestrator/sop-templates/knowledge-digest/templates/digest.md +11 -0
  80. package/runtime/plugins/project-orchestrator/sop-templates/video-to-transcript/SOP.en.md +87 -0
  81. package/runtime/plugins/project-orchestrator/sop-templates/video-to-transcript/SOP.md +87 -0
  82. package/runtime/plugins/project-orchestrator/sop-templates/weekly-review/SOP.en.md +75 -0
  83. package/runtime/plugins/project-orchestrator/sop-templates/weekly-review/SOP.md +75 -0
  84. package/runtime/plugins/project-orchestrator/sop-templates/weekly-review/templates/review.en.md +11 -0
  85. package/runtime/plugins/project-orchestrator/sop-templates/weekly-review/templates/review.md +12 -0
  86. package/runtime/plugins/project-orchestrator/templates/CASE.en.md +39 -0
@@ -0,0 +1,573 @@
1
+ """agenthelm nudge:在 Claude Code、Codex、Cursor 的 hook 里自动采集观点候选、注入试行规则并提示未决事项。
2
+
3
+ ``nudge hook`` 由客户端的 hook 调用(stdin 为 hook 的 JSON),永远退出 0、只在有话要说时输出一段 JSON:
4
+
5
+ * ``session-start``:向模型注入候选采集协议与项目内试行的候选;未决候选超过阈值或久未处理时附一行用户可见提示。
6
+ * ``prompt-submit``(Claude Code、Codex):用户消息命中长期偏好关键词时,提醒模型把它暂存为候选。
7
+ * ``stop``:任务看起来已完成且本次会话暂存了还没展示过的候选时,让模型一次性请用户裁决。
8
+
9
+ 候选保存在项目的 ``.opinion/inbox.json``(由 opinion-manager 的 ``inbox`` 维护);本模块只读取汇总,
10
+ 按「待确认项提供者」契约聚合各来源,不写规则。阈值写在 Project Profile 的 ``opinion.nudge`` 或用户配置
11
+ ``~/.config/agenthelm/config.json`` 的 ``nudge``(用户配置优先)。``nudge status`` 显示生效配置与当前汇总,
12
+ ``nudge install-hooks --client cursor`` 预览或写入 Cursor 的 hooks.json。
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import json
18
+ import os
19
+ import re
20
+ import subprocess
21
+ import sys
22
+ import time
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+ from nudge_text import LANGUAGES, resolve_language, text as say
27
+
28
+ ENV_DISABLE = "AGENTHELM_NO_NUDGE"
29
+ ENV_DEBUG = "AGENTHELM_NUDGE_DEBUG"
30
+ CLIENTS = ("claude", "codex", "cursor")
31
+ EVENTS = ("session-start", "prompt-submit", "stop")
32
+ STOP_MODES = ("off", "on-completion", "always")
33
+ OURS = re.compile(r"agenthelm\.py\"?\s+nudge\s+hook")
34
+ SUBPROCESS_TIMEOUT = 5
35
+ MAX_PROMPT_CHARS = 1500
36
+ SESSION_RETENTION_SECONDS = 14 * 24 * 3600
37
+ SESSION_LIMIT = 200
38
+
39
+ DEFAULT_KEYWORDS = [
40
+ "以后都", "以后要", "以后不要", "以后别", "今后", "从现在起", "从现在开始", "每次都", "总是", "永远", "别再", "不要再",
41
+ "不要总是", "必须", "一定要", "务必", "记住", "记一下", "下次", "我希望你", "我不喜欢", "我喜欢你",
42
+ "always", "never", "from now on", "going forward", "remember to", "don't ever", "stop doing", "next time",
43
+ ]
44
+ DEFAULT_COMPLETION = [
45
+ "完成", "搞定", "已提交", "已推送", "已创建", "已实现", "已修复", "已合并", "总结",
46
+ "done", "completed", "finished", "all set", "merged", "pushed", "implemented", "fixed",
47
+ ]
48
+ DEFAULTS: dict[str, Any] = {
49
+ "enabled": True,
50
+ "language": "auto",
51
+ "pending_threshold": 5,
52
+ "stale_days": 7,
53
+ "max_prompts_per_session": 1,
54
+ "capture_hints_per_session": 3,
55
+ "trial_expire_days": 30,
56
+ "reminder_repeat_hours": 24,
57
+ "stop_prompt": {"claude": "on-completion", "codex": "on-completion", "cursor": "off"},
58
+ "keywords": DEFAULT_KEYWORDS,
59
+ "completion_keywords": DEFAULT_COMPLETION,
60
+ }
61
+
62
+
63
+ # ---------------------------------------------------------------- configuration
64
+
65
+ def _positive_int(value: Any, fallback: int) -> int:
66
+ return value if isinstance(value, int) and not isinstance(value, bool) and value >= 1 else fallback
67
+
68
+
69
+ def _words(value: Any, fallback: list[str]) -> list[str]:
70
+ if isinstance(value, list) and value and all(isinstance(item, str) and item.strip() for item in value):
71
+ return [item.strip().lower() for item in value]
72
+ return [item.lower() for item in fallback]
73
+
74
+
75
+ def merge_config(*layers: Any) -> tuple[dict[str, Any], list[str]]:
76
+ """内置默认 ← 项目 Profile ← 用户配置;非法值忽略并报告,不让会话失败。"""
77
+ config = {key: (dict(value) if isinstance(value, dict) else value) for key, value in DEFAULTS.items()}
78
+ problems: list[tuple[str, dict]] = []
79
+ for index, layer in enumerate(layers):
80
+ label = ("label_profile", "label_user")[index]
81
+ if layer is None:
82
+ continue
83
+ if not isinstance(layer, dict):
84
+ problems.append(("warn_object", {"label": label}))
85
+ continue
86
+ for key, value in layer.items():
87
+ if key not in DEFAULTS:
88
+ problems.append(("warn_unknown", {"label": label, "key": key}))
89
+ elif key == "enabled":
90
+ if isinstance(value, bool):
91
+ config[key] = value
92
+ else:
93
+ problems.append(("warn_bool", {"label": label, "key": key}))
94
+ elif key == "language":
95
+ if value in ("auto", *LANGUAGES):
96
+ config[key] = value
97
+ else:
98
+ problems.append(("warn_language", {"label": label}))
99
+ elif key == "stop_prompt":
100
+ modes = {client: value for client in CLIENTS} if isinstance(value, str) else value
101
+ if isinstance(modes, dict) and all(client in CLIENTS and mode in STOP_MODES for client, mode in modes.items()):
102
+ config[key] = {**config[key], **modes}
103
+ else:
104
+ problems.append(("warn_stop", {"label": label, "modes": "/".join(STOP_MODES)}))
105
+ elif key in ("keywords", "completion_keywords"):
106
+ parsed = _words(value, [])
107
+ if parsed:
108
+ config[key] = parsed
109
+ else:
110
+ problems.append(("warn_words", {"label": label, "key": key}))
111
+ elif isinstance(value, int) and not isinstance(value, bool) and value >= 1:
112
+ config[key] = value
113
+ else:
114
+ problems.append(("warn_int", {"label": label, "key": key}))
115
+ config["keywords"] = _words(config["keywords"], DEFAULT_KEYWORDS)
116
+ config["completion_keywords"] = _words(config["completion_keywords"], DEFAULT_COMPLETION)
117
+ config["lang"] = resolve_language(config["language"])
118
+ warnings = [say(key, config["lang"], **{**fields, **({"label": say(fields["label"], config["lang"])} if "label" in fields else {})})
119
+ for key, fields in problems]
120
+ return config, warnings
121
+
122
+
123
+ def project_profile(host: Any, root: Path) -> dict:
124
+ try:
125
+ data = host.read_json(root / host.PROFILE_FILE)
126
+ except (OSError, ValueError):
127
+ return {}
128
+ return data if isinstance(data, dict) else {}
129
+
130
+
131
+ def load_config(host: Any, env: Any, root: Path | None) -> tuple[dict[str, Any], list[str]]:
132
+ profile_layer = None
133
+ if root is not None:
134
+ opinion = project_profile(host, root).get("opinion")
135
+ profile_layer = opinion.get("nudge") if isinstance(opinion, dict) else None
136
+ try:
137
+ user = host.read_json(env.config_file)
138
+ user_layer = user.get("nudge") if isinstance(user, dict) else None
139
+ except (OSError, ValueError):
140
+ user_layer = None
141
+ return merge_config(profile_layer, user_layer)
142
+
143
+
144
+ # ---------------------------------------------------------------- project and manager discovery
145
+
146
+ def find_project(host: Any, start: Path | None) -> Path | None:
147
+ """从 start 向上找启用了 opinion-manager 的项目根;与 Opinion 无关的目录不产生任何输出。"""
148
+ if start is None:
149
+ return None
150
+ base = start.expanduser().resolve()
151
+ for path in [base, *base.parents]:
152
+ if (path / host.PROFILE_FILE).is_file():
153
+ opinion = project_profile(host, path).get("opinion")
154
+ provider = opinion.get("provider") if isinstance(opinion, dict) else None
155
+ enabled = provider == "opinion-manager" or (path / ".opinion").is_dir() or (path / "opinion.lock.json").is_file()
156
+ return path if enabled else None
157
+ if (path / ".opinion").is_dir() or (path / "opinion.lock.json").is_file():
158
+ return path
159
+ return None
160
+
161
+
162
+ def payload_directory(payload: dict, client: str, environment: dict) -> Path | None:
163
+ roots = payload.get("workspace_roots")
164
+ candidate = (roots[0] if isinstance(roots, list) and roots else None) or payload.get("cwd") \
165
+ or environment.get("CURSOR_PROJECT_DIR") or environment.get("CLAUDE_PROJECT_DIR")
166
+ return Path(candidate) if isinstance(candidate, str) and candidate else None
167
+
168
+
169
+ def detect_client(requested: str, payload: dict, environment: dict) -> str:
170
+ if requested != "auto":
171
+ return requested
172
+ if "workspace_roots" in payload or "conversation_id" in payload:
173
+ return "cursor"
174
+ if "turn_id" in payload or environment.get("PLUGIN_ROOT"):
175
+ return "codex"
176
+ return "claude"
177
+
178
+
179
+ def find_manager(host: Any, env: Any) -> Path | None:
180
+ installed = host.installed_plugins(env, {"opinion-manager"})
181
+ found = host.locate_companion("opinion-manager", installed)
182
+ return found / "runtime" / "opinion_manager.py" if found is not None else None
183
+
184
+
185
+ def run_manager(manager: Path, *arguments: str, lang: str | None = None) -> Any:
186
+ command = [*arguments, "--lang", lang] if lang else list(arguments)
187
+ try:
188
+ completed = subprocess.run([sys.executable, str(manager), *command], capture_output=True, text=True,
189
+ check=False, timeout=SUBPROCESS_TIMEOUT)
190
+ except (OSError, subprocess.SubprocessError):
191
+ return None
192
+ if completed.returncode != 0:
193
+ return None
194
+ if "markdown" in arguments:
195
+ return completed.stdout
196
+ try:
197
+ result = json.loads(completed.stdout)
198
+ except ValueError:
199
+ return None
200
+ return result if isinstance(result, dict) and result.get("ok") else None
201
+
202
+
203
+ # ---------------------------------------------------------------- pending-item providers
204
+
205
+ def opinion_provider(manager: Path | None, root: Path, session: str, lang: str) -> dict | None:
206
+ """待确认项提供者契约:返回 {source, items[{id, source, title, status, age_days, hint_command…}],
207
+ counts, oldest_pending_days, last_action_at, session_unpresented}。其他来源(如 SOP 待办)按同一结构接入。"""
208
+ if manager is None or not (root / ".opinion" / "inbox.json").is_file():
209
+ return None
210
+ arguments = ["inbox", "pending", "--project", str(root)] + (["--session", session] if session else [])
211
+ return run_manager(manager, *arguments, lang=lang)
212
+
213
+
214
+ PROVIDERS = (opinion_provider,)
215
+
216
+
217
+ def collect(manager: Path | None, root: Path, session: str, lang: str) -> dict:
218
+ summaries = [result for provider in PROVIDERS if (result := provider(manager, root, session, lang))]
219
+ waiting = [item for summary in summaries for item in summary["items"] if item["status"] == "pending"]
220
+ return {"summaries": summaries, "pending": len(waiting),
221
+ "trial": sum(1 for summary in summaries for item in summary["items"] if item["status"] == "trial"),
222
+ "oldest_days": max((item["age_days"] for item in waiting), default=0.0),
223
+ "hint": next((item["hint_command"] for item in waiting), None),
224
+ "session_unpresented": [identity for summary in summaries for identity in summary.get("session_unpresented", [])]}
225
+
226
+
227
+ def reminder_text(summary: dict, config: dict) -> str | None:
228
+ count, oldest, lang = summary["pending"], summary["oldest_days"], config["lang"]
229
+ if not count or not (count >= config["pending_threshold"] or oldest >= config["stale_days"]):
230
+ return None
231
+ return say("reminder", lang, count=count,
232
+ age=say("reminder_age", lang, days=int(oldest)) if oldest >= 1 else "",
233
+ trial=say("reminder_trial", lang, trial=summary["trial"]) if summary["trial"] else "")
234
+
235
+
236
+ # ---------------------------------------------------------------- machine-local state
237
+
238
+ def state_file(env: Any) -> Path:
239
+ return env.state_dir / "agenthelm" / "nudge.json"
240
+
241
+
242
+ def load_state(host: Any, env: Any) -> dict:
243
+ try:
244
+ data = host.read_json(state_file(env))
245
+ if isinstance(data, dict) and isinstance(data.get("sessions"), dict):
246
+ data.setdefault("reminders", {})
247
+ data.setdefault("exposure_noted", {})
248
+ return data
249
+ except (OSError, ValueError):
250
+ pass
251
+ return {"sessions": {}, "reminders": {}, "exposure_noted": {}}
252
+
253
+
254
+ def save_state(host: Any, env: Any, state: dict) -> None:
255
+ moment = time.time()
256
+ sessions = {key: value for key, value in state["sessions"].items() if moment - value.get("at", 0) < SESSION_RETENTION_SECONDS}
257
+ if len(sessions) > SESSION_LIMIT:
258
+ sessions = dict(sorted(sessions.items(), key=lambda item: item[1].get("at", 0))[-SESSION_LIMIT:])
259
+ state["sessions"] = sessions
260
+ try:
261
+ host.write_json_atomic(state_file(env), state)
262
+ except OSError:
263
+ pass
264
+
265
+
266
+ def session_record(state: dict, session: str) -> dict:
267
+ record = state["sessions"].setdefault(session or "unknown", {"capture_hints": 0, "stop_prompts": 0})
268
+ record["at"] = time.time()
269
+ return record
270
+
271
+
272
+ # ---------------------------------------------------------------- output per client
273
+
274
+ def render(client: str, event: str, *, context: str | None = None, system: str | None = None,
275
+ follow_up: bool = False) -> dict | None:
276
+ """各客户端的 hook 输出格式。context 为给模型的文字,system 为给用户看的一行。"""
277
+ if not context and not system:
278
+ return None
279
+ if client == "cursor":
280
+ if event == "session-start":
281
+ return {"additional_context": context} if context else None
282
+ return {"followup_message": context} if context and event == "stop" else None
283
+ if event == "stop" and client == "codex":
284
+ return {"decision": "block", "reason": context} if context else None
285
+ name = {"session-start": "SessionStart", "prompt-submit": "UserPromptSubmit", "stop": "Stop"}[event]
286
+ output: dict[str, Any] = {}
287
+ if system:
288
+ output["systemMessage"] = system
289
+ if context:
290
+ output["hookSpecificOutput"] = {"hookEventName": name, "additionalContext": context}
291
+ return output
292
+
293
+
294
+ # ---------------------------------------------------------------- events
295
+
296
+ def session_start(host: Any, env: Any, payload: dict, client: str, root: Path, config: dict) -> dict | None:
297
+ manager = find_manager(host, env)
298
+ if manager is None:
299
+ return None
300
+ session = str(payload.get("session_id") or payload.get("conversation_id") or "")
301
+ source = str(payload.get("source") or "startup")
302
+ if (root / ".opinion" / "inbox.json").is_file():
303
+ run_manager(manager, "inbox", "expire", "--project", str(root), "--days", str(config["trial_expire_days"]), lang=config["lang"])
304
+ brief = run_manager(manager, "inbox", "brief", "--project", str(root), *(["--session", session] if session else []), lang=config["lang"])
305
+ context = brief["text"] if brief else None
306
+ if source == "compact":
307
+ return render(client, "session-start", context=context)
308
+ summary = collect(manager, root, session, config["lang"])
309
+ text = reminder_text(summary, config)
310
+ state = load_state(host, env)
311
+ shown: list[str] = []
312
+ if text:
313
+ previous = state["reminders"].get(str(root), {})
314
+ if time.time() - previous.get("at", 0) >= config["reminder_repeat_hours"] * 3600:
315
+ state["reminders"][str(root)] = {"at": time.time()}
316
+ shown.append(text)
317
+ context = (context or "") + say("reminder_hint", config["lang"], text=text)
318
+ notice = exposure_notice(manager, root, state, config["lang"])
319
+ if notice:
320
+ shown.append(notice)
321
+ if client != "claude":
322
+ context = (context or "") + say("relay", config["lang"], notice=notice)
323
+ if shown:
324
+ save_state(host, env, state)
325
+ return render(client, "session-start", context=context, system="\n".join(shown) or None)
326
+
327
+
328
+ def exposure_notice(manager: Path, root: Path, state: dict, lang: str) -> str | None:
329
+ """收件箱含用户原话:每个项目只在第一次提醒一次是否会随仓库或同步盘被他人看到。只告知,不替用户决定,不改任何文件。"""
330
+ if not (root / ".opinion" / "inbox.json").is_file() or str(root) in state["exposure_noted"]:
331
+ return None
332
+ result = run_manager(manager, "inbox", "exposure", "--project", str(root), lang=lang)
333
+ if not result or not result.get("needs_notice"):
334
+ return None
335
+ state["exposure_noted"][str(root)] = {"at": time.time(), "git": result["git"]}
336
+ return "[Opinion] " + result["message"]
337
+
338
+
339
+ def prompt_submit(host: Any, env: Any, payload: dict, client: str, root: Path, config: dict) -> dict | None:
340
+ prompt = payload.get("prompt")
341
+ if client == "cursor" or not isinstance(prompt, str) or not prompt.strip() or len(prompt) > MAX_PROMPT_CHARS:
342
+ return None
343
+ lowered = prompt.lower()
344
+ hits = [word for word in config["keywords"] if word in lowered]
345
+ if not hits:
346
+ return None
347
+ session = str(payload.get("session_id") or "")
348
+ state = load_state(host, env)
349
+ record = session_record(state, session)
350
+ if record["capture_hints"] >= config["capture_hints_per_session"]:
351
+ return None
352
+ record["capture_hints"] += 1
353
+ save_state(host, env, state)
354
+ manager = find_manager(host, env)
355
+ lang = config["lang"]
356
+ command = say("add_command", lang, manager=manager, root=root, lang=lang,
357
+ session=f" --session {session}" if session else "") if manager else say("add_command_fallback", lang)
358
+ context = say("prompt_hint", lang, hits=say("sep", lang).join(hits[:3]), command=command)
359
+ return render(client, "prompt-submit", context=context)
360
+
361
+
362
+ def stop(host: Any, env: Any, payload: dict, client: str, root: Path, config: dict) -> dict | None:
363
+ mode = config["stop_prompt"].get(client, "off")
364
+ if mode == "off" or payload.get("stop_hook_active") or payload.get("loop_count"):
365
+ return None
366
+ if client == "cursor" and payload.get("status") not in (None, "completed"):
367
+ return None
368
+ session = str(payload.get("session_id") or payload.get("conversation_id") or "")
369
+ inbox = root / ".opinion" / "inbox.json"
370
+ if not session or not inbox.is_file() or session not in inbox.read_text(encoding="utf-8", errors="replace"):
371
+ return None
372
+ state = load_state(host, env)
373
+ record = session_record(state, session)
374
+ if record["stop_prompts"] >= config["max_prompts_per_session"]:
375
+ return None
376
+ message = str(payload.get("last_assistant_message") or "").lower()
377
+ if mode == "on-completion" and client != "cursor" and not any(word in message for word in config["completion_keywords"]):
378
+ return None
379
+ manager = find_manager(host, env)
380
+ if manager is None:
381
+ return None
382
+ summary = collect(manager, root, session, config["lang"])
383
+ identifiers = summary["session_unpresented"]
384
+ if not identifiers:
385
+ return None
386
+ listing = run_manager(manager, "inbox", "list", "--project", str(root), "--session", session, "--unpresented",
387
+ "--status", "pending", "--output", "markdown", lang=config["lang"])
388
+ if not listing:
389
+ return None
390
+ run_manager(manager, "inbox", "present", *identifiers, "--project", str(root), lang=config["lang"])
391
+ record["stop_prompts"] += 1
392
+ save_state(host, env, state)
393
+ context = say("stop_request", config["lang"], count=len(identifiers), manager=manager, root=root, lang=config["lang"], listing=listing)
394
+ return render(client, "stop", context=context)
395
+
396
+
397
+ HANDLERS = {"session-start": session_start, "prompt-submit": prompt_submit, "stop": stop}
398
+
399
+
400
+ def read_payload() -> dict:
401
+ if sys.stdin is None or sys.stdin.isatty():
402
+ return {}
403
+ try:
404
+ data = json.loads(sys.stdin.read() or "{}")
405
+ except ValueError:
406
+ return {}
407
+ return data if isinstance(data, dict) else {}
408
+
409
+
410
+ def run_hook(args: argparse.Namespace, host: Any, payload: dict | None = None) -> dict | None:
411
+ """hook 入口:任何异常都吞掉,保证不影响客户端会话。"""
412
+ try:
413
+ env = host.current_env(args.home)
414
+ if env.get(ENV_DISABLE) not in (None, "0", "false"):
415
+ return None
416
+ payload = read_payload() if payload is None else payload
417
+ client = detect_client(args.client, payload, dict(os.environ))
418
+ root = find_project(host, payload_directory(payload, client, dict(os.environ)))
419
+ if root is None:
420
+ return None
421
+ config, _ = load_config(host, env, root)
422
+ if not config["enabled"]:
423
+ return None
424
+ return HANDLERS[args.event](host, env, payload, client, root, config)
425
+ except Exception: # noqa: BLE001 - a nudge must never break a session
426
+ if os.environ.get(ENV_DEBUG):
427
+ import traceback
428
+ traceback.print_exc(file=sys.stderr)
429
+ return None
430
+
431
+
432
+ # ---------------------------------------------------------------- status and hook installation
433
+
434
+ def status(args: argparse.Namespace, host: Any) -> dict:
435
+ env = host.current_env(args.home)
436
+ root = find_project(host, args.project or Path.cwd())
437
+ config, warnings = load_config(host, env, root)
438
+ result: dict[str, Any] = {"ok": True, "project": str(root) if root else None, "config": config, "warnings": warnings,
439
+ "disabled_by_environment": env.get(ENV_DISABLE) not in (None, "0", "false")}
440
+ if root is None:
441
+ result["note"] = say("note_no_project", config["lang"])
442
+ return result
443
+ manager = find_manager(host, env)
444
+ result["opinion_manager"] = str(manager) if manager else None
445
+ summary = collect(manager, root, "", config["lang"])
446
+ result["summary"] = {key: summary[key] for key in ("pending", "trial", "oldest_days")}
447
+ if manager is not None and (root / ".opinion" / "inbox.json").is_file():
448
+ result["exposure"] = run_manager(manager, "inbox", "exposure", "--project", str(root), lang=config["lang"])
449
+ result["reminder"] = reminder_text(summary, config)
450
+ return result
451
+
452
+
453
+ def cursor_commands(runtime: Path) -> dict[str, dict]:
454
+ base = f'python3 "{runtime}" nudge hook --client cursor --event'
455
+ return {"sessionStart": {"command": f"{base} session-start", "timeout": 10},
456
+ "stop": {"command": f"{base} stop", "timeout": 10}}
457
+
458
+
459
+ def merged_cursor_hooks(existing: dict, runtime: Path, remove: bool) -> dict:
460
+ data = json.loads(json.dumps(existing)) if existing else {}
461
+ data.setdefault("version", 1)
462
+ hooks = data.setdefault("hooks", {})
463
+ wanted = cursor_commands(runtime)
464
+ for event in list(hooks):
465
+ kept = [entry for entry in hooks[event] if not (isinstance(entry, dict) and OURS.search(str(entry.get("command", ""))))]
466
+ if kept:
467
+ hooks[event] = kept
468
+ else:
469
+ del hooks[event]
470
+ if not remove:
471
+ for event, entry in wanted.items():
472
+ hooks.setdefault(event, []).append(entry)
473
+ return data
474
+
475
+
476
+ def install_hooks(args: argparse.Namespace, host: Any) -> dict:
477
+ env = host.current_env(args.home)
478
+ lang = load_config(host, env, None)[0]["lang"]
479
+ if args.scope == "project":
480
+ project = (args.project or Path.cwd()).expanduser().resolve()
481
+ target = project / ".cursor" / "hooks.json"
482
+ else:
483
+ target = env.home / ".cursor" / "hooks.json"
484
+ try:
485
+ existing = host.read_json(target) if target.is_file() else {}
486
+ except ValueError as exc:
487
+ raise host.CheckError(say("err_hooks_json", lang, target=target, error=exc)) from exc
488
+ if not isinstance(existing, dict):
489
+ raise host.CheckError(say("err_hooks_root", lang, target=target))
490
+ runtime = Path(__file__).resolve().with_name("agenthelm.py")
491
+ merged = merged_cursor_hooks(existing, runtime, args.remove)
492
+ changed = merged != existing
493
+ result = {"ok": True, "applied": False, "target": str(target), "changed": changed, "remove": args.remove,
494
+ "hooks": merged.get("hooks", {})}
495
+ if args.apply and changed:
496
+ if target.is_file():
497
+ backup = target.with_name(target.name + ".agenthelm-bak")
498
+ if not backup.exists():
499
+ backup.write_text(target.read_text(encoding="utf-8"), encoding="utf-8")
500
+ result["backup"] = str(backup)
501
+ host.write_json_atomic(target, merged)
502
+ result["applied"] = True
503
+ result["note"] = say("note_cursor", lang)
504
+ result["lang"] = lang
505
+ return result
506
+
507
+
508
+ def parser() -> argparse.ArgumentParser:
509
+ result = argparse.ArgumentParser(prog="agenthelm nudge", description="agenthelm nudge: collect opinion candidates from client hooks and remind about unconfirmed items")
510
+ sub = result.add_subparsers(dest="action", required=True)
511
+ hook = sub.add_parser("hook", help="called by a client hook: the hook JSON arrives on stdin, always exits 0")
512
+ hook.add_argument("--event", choices=EVENTS, required=True)
513
+ hook.add_argument("--client", choices=("auto", *CLIENTS), default="auto")
514
+ hook.add_argument("--home", type=Path, help=argparse.SUPPRESS)
515
+ state = sub.add_parser("status", help="show the effective config, the current pending summary and whether a new session would remind")
516
+ state.add_argument("--project", type=Path)
517
+ state.add_argument("--output", choices=("text", "json"), default="text")
518
+ state.add_argument("--home", type=Path, help=argparse.SUPPRESS)
519
+ install = sub.add_parser("install-hooks", help="write the nudge hooks into Cursor's hooks.json (preview by default)")
520
+ install.add_argument("--client", choices=("cursor",), default="cursor")
521
+ install.add_argument("--scope", choices=("user", "project"), default="user")
522
+ install.add_argument("--project", type=Path)
523
+ install.add_argument("--remove", action="store_true", help="remove the entries agenthelm wrote")
524
+ install.add_argument("--apply", action="store_true")
525
+ install.add_argument("--output", choices=("text", "json"), default="text")
526
+ install.add_argument("--home", type=Path, help=argparse.SUPPRESS)
527
+ return result
528
+
529
+
530
+ def main(argv: list[str], host: Any) -> int:
531
+ args = parser().parse_args(argv)
532
+ if args.action == "hook":
533
+ output = run_hook(args, host)
534
+ if output is not None:
535
+ print(json.dumps(output, ensure_ascii=False))
536
+ return 0
537
+ try:
538
+ result = status(args, host) if args.action == "status" else install_hooks(args, host)
539
+ except host.CheckError as exc:
540
+ print(f"error: {exc}", file=sys.stderr)
541
+ return 2
542
+ if args.output == "json":
543
+ print(json.dumps(result, ensure_ascii=False, indent=2))
544
+ return 0
545
+ if args.action == "status":
546
+ config = result["config"]
547
+ lang = config["lang"]
548
+ if result["disabled_by_environment"]:
549
+ state = say("status_env_off", lang)
550
+ else:
551
+ state = say("status_on", lang) if config["enabled"] else say("status_off", lang)
552
+ lines = [say("status_project", lang, project=result["project"] or say("status_no_project", lang)),
553
+ say("status_switch", lang, state=state),
554
+ say("status_thresholds", lang, pending=config["pending_threshold"], stale=config["stale_days"],
555
+ prompts=config["max_prompts_per_session"], expire=config["trial_expire_days"],
556
+ stop=config["stop_prompt"], language=f"{config['language']} ({lang})")]
557
+ if result.get("summary"):
558
+ summary = result["summary"]
559
+ lines.append(say("status_current", lang, pending=summary["pending"], trial=summary["trial"], days=int(summary["oldest_days"])))
560
+ lines.append(result["reminder"] or say("status_quiet", lang))
561
+ if result.get("exposure"):
562
+ lines.append(say("status_exposure", lang, message=result["exposure"]["message"]))
563
+ lines += [say("status_warning", lang, item=item) for item in result["warnings"]]
564
+ if result.get("note"):
565
+ lines.append(result["note"])
566
+ print("\n".join(lines))
567
+ else:
568
+ lang = result["lang"]
569
+ verb = say("install_written" if result["applied"] else ("install_unchanged" if not result["changed"] else "install_preview"), lang)
570
+ print(say("install_line", lang, verb=verb, target=result["target"]))
571
+ print(json.dumps(result["hooks"], ensure_ascii=False, indent=2))
572
+ print(result["note"])
573
+ return 0