claude-nb 0.3.0 → 0.5.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 (65) hide show
  1. package/Makefile +8 -2
  2. package/README.md +57 -36
  3. package/VERSION +1 -1
  4. package/bin/board +112 -34
  5. package/bin/cnb +152 -65
  6. package/bin/dispatcher +25 -11
  7. package/bin/doctor +3 -5
  8. package/bin/init +13 -47
  9. package/bin/notify +224 -0
  10. package/bin/registry +8 -23
  11. package/bin/swarm +41 -860
  12. package/bin/sync-version +131 -0
  13. package/lib/board_admin.py +19 -9
  14. package/lib/board_bbs.py +23 -8
  15. package/lib/board_bug.py +2 -1
  16. package/lib/board_db.py +31 -141
  17. package/lib/board_lock.py +5 -1
  18. package/lib/board_mailbox.py +18 -8
  19. package/lib/board_maintenance.py +26 -27
  20. package/lib/board_msg.py +76 -39
  21. package/lib/board_pending.py +233 -0
  22. package/lib/board_pulse.py +14 -0
  23. package/lib/board_task.py +41 -32
  24. package/lib/board_tui.py +120 -0
  25. package/lib/board_view.py +70 -50
  26. package/lib/board_vote.py +9 -3
  27. package/lib/build_lock.py +7 -7
  28. package/lib/common.py +45 -3
  29. package/lib/concerns/__init__.py +7 -11
  30. package/lib/concerns/{coral_manager.py → coral.py} +54 -4
  31. package/lib/concerns/digest_scheduler.py +109 -0
  32. package/lib/concerns/file_watcher.py +73 -68
  33. package/lib/concerns/health.py +136 -0
  34. package/lib/concerns/helpers.py +1 -5
  35. package/lib/concerns/idle.py +130 -0
  36. package/lib/concerns/notification_push.py +171 -0
  37. package/lib/concerns/notifications.py +145 -0
  38. package/lib/concerns/nudge_coordinator.py +148 -0
  39. package/lib/digest.py +62 -0
  40. package/lib/health.py +2 -2
  41. package/lib/inject.py +2 -2
  42. package/lib/migrate.py +1 -0
  43. package/lib/monitor.py +9 -22
  44. package/lib/notification_config.py +101 -0
  45. package/lib/swarm.py +464 -0
  46. package/lib/swarm_backend.py +300 -0
  47. package/lib/theme_profiles.py +89 -0
  48. package/migrations/004_heartbeat.sql +1 -0
  49. package/migrations/005_notification_log.sql +12 -0
  50. package/migrations/006_pending_actions.sql +15 -0
  51. package/package.json +4 -3
  52. package/pyproject.toml +3 -2
  53. package/registry/README.md +9 -0
  54. package/registry/pubkeys.json +2 -1
  55. package/schema.sql +29 -1
  56. package/lib/concerns/bug_sla_checker.py +0 -32
  57. package/lib/concerns/coral_poker.py +0 -57
  58. package/lib/concerns/health_checker.py +0 -72
  59. package/lib/concerns/idle_detector.py +0 -56
  60. package/lib/concerns/idle_killer.py +0 -41
  61. package/lib/concerns/idle_nudger.py +0 -38
  62. package/lib/concerns/inbox_nudger.py +0 -34
  63. package/lib/concerns/resource_monitor.py +0 -47
  64. package/lib/concerns/session_keepalive.py +0 -23
  65. package/lib/concerns/time_announcer.py +0 -34
package/bin/notify ADDED
@@ -0,0 +1,224 @@
1
+ #!/usr/bin/env python3
2
+ """notify — notification subscription management and manual trigger CLI.
3
+
4
+ Usage:
5
+ notify status Show current notification config
6
+ notify subscriptions [member] Show subscriptions for member (or all)
7
+ notify test <member> <type> Send a test notification
8
+ notify digest [--send] Generate daily digest (--send to deliver)
9
+ notify log [--limit N] Show recent notification log
10
+ """
11
+
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ CLAUDES_HOME = Path(__file__).resolve().parent.parent
16
+ sys.path.insert(0, str(CLAUDES_HOME))
17
+
18
+ from lib.board_db import BoardDB
19
+ from lib.common import ClaudesEnv
20
+ from lib.digest import generate_daily_digest
21
+ from lib.notification_config import BUILTIN_DEFAULTS, NOTIFICATION_TYPES, load
22
+
23
+
24
+ def _env() -> ClaudesEnv:
25
+ return ClaudesEnv.load()
26
+
27
+
28
+ def _config_path(env: ClaudesEnv) -> Path:
29
+ return Path(env.claudes_dir) / "notifications.toml"
30
+
31
+
32
+ def cmd_status() -> None:
33
+ env = _env()
34
+ path = _config_path(env)
35
+ config = load(path)
36
+
37
+ print(f"配置文件: {path}")
38
+ print(f" 存在: {'是' if path.exists() else '否(使用默认值)'}")
39
+ print(f" 人类通道: {config.human_channel}")
40
+ print(f" 队友通道: {config.teammate_channel}")
41
+ print()
42
+ print("默认订阅:")
43
+ for t in NOTIFICATION_TYPES:
44
+ default = config.defaults.get(t, BUILTIN_DEFAULTS.get(t, False))
45
+ print(f" {t}: {'✓' if default else '✗'}")
46
+ if config.overrides:
47
+ print()
48
+ print("个人覆盖:")
49
+ for member, prefs in config.overrides.items():
50
+ overrides_str = ", ".join(f"{k}={'✓' if v else '✗'}" for k, v in prefs.items())
51
+ print(f" {member}: {overrides_str}")
52
+ if config.human:
53
+ print()
54
+ print(f"人类: {config.human.name} <{config.human.email}>")
55
+ if config.human.subscriptions:
56
+ subs = ", ".join(f"{k}={'✓' if v else '✗'}" for k, v in config.human.subscriptions.items())
57
+ print(f" 订阅: {subs}")
58
+
59
+
60
+ def cmd_subscriptions(member: str | None = None) -> None:
61
+ env = _env()
62
+ config = load(_config_path(env))
63
+
64
+ if member:
65
+ print(f"{member} 的订阅:")
66
+ for t in NOTIFICATION_TYPES:
67
+ subscribed = config.is_subscribed(member, t)
68
+ channel = config.channel_for(member)
69
+ print(f" {t}: {'✓' if subscribed else '✗'} (via {channel})")
70
+ else:
71
+ db_path = Path(env.claudes_dir) / "board.db"
72
+ if not db_path.exists():
73
+ print("ERROR: board.db 不存在")
74
+ raise SystemExit(1)
75
+ board = BoardDB(db_path)
76
+ sessions = board.query("SELECT name FROM sessions ORDER BY name")
77
+ if not sessions:
78
+ print("无会话")
79
+ return
80
+ for row in sessions:
81
+ name = row[0]
82
+ subs = [t for t in NOTIFICATION_TYPES if config.is_subscribed(name, t)]
83
+ channel = config.channel_for(name)
84
+ subs_str = ", ".join(subs) if subs else "无"
85
+ print(f" {name}: {subs_str} (via {channel})")
86
+
87
+
88
+ def cmd_test(member: str, notif_type: str) -> None:
89
+ if notif_type not in NOTIFICATION_TYPES:
90
+ print(f"ERROR: 未知通知类型 '{notif_type}'")
91
+ print(f"可用类型: {', '.join(NOTIFICATION_TYPES)}")
92
+ raise SystemExit(1)
93
+
94
+ env = _env()
95
+ config = load(_config_path(env))
96
+ channel = config.channel_for(member)
97
+ subscribed = config.is_subscribed(member, notif_type)
98
+
99
+ print(f"测试通知: {notif_type} → {member}")
100
+ print(f" 通道: {channel}")
101
+ print(f" 已订阅: {'是' if subscribed else '否'}")
102
+
103
+ if channel == "board-inbox":
104
+ import subprocess
105
+
106
+ board_sh = str(CLAUDES_HOME / "bin" / "board")
107
+ msg = f"[测试通知] 类型: {notif_type} — 这是一条测试消息"
108
+ try:
109
+ subprocess.run([board_sh, "--as", "dispatcher", "send", member, msg], capture_output=True, timeout=10)
110
+ print("OK 测试通知已发送")
111
+ except Exception as e:
112
+ print(f"ERROR: 发送失败: {e}")
113
+ raise SystemExit(1)
114
+ else:
115
+ print(f"通道 {channel} 尚未实现投递")
116
+
117
+
118
+ def cmd_digest(send: bool = False) -> None:
119
+ env = _env()
120
+ db_path = Path(env.claudes_dir) / "board.db"
121
+ if not db_path.exists():
122
+ print("ERROR: board.db 不存在")
123
+ raise SystemExit(1)
124
+
125
+ board = BoardDB(db_path)
126
+ digest = generate_daily_digest(board)
127
+ print(digest)
128
+
129
+ if send:
130
+ config = load(_config_path(env))
131
+ sessions = board.query("SELECT name FROM sessions ORDER BY name")
132
+ members = [r[0] for r in (sessions or [])]
133
+ subscribers = config.subscribers_for("daily-digest", members)
134
+ if not subscribers:
135
+ print("\n无订阅者")
136
+ return
137
+
138
+ import subprocess
139
+
140
+ board_sh = str(CLAUDES_HOME / "bin" / "board")
141
+ sent = 0
142
+ for member in subscribers:
143
+ channel = config.channel_for(member)
144
+ if channel == "board-inbox":
145
+ try:
146
+ subprocess.run(
147
+ [board_sh, "--as", "dispatcher", "send", member, digest],
148
+ capture_output=True,
149
+ timeout=10,
150
+ )
151
+ sent += 1
152
+ except Exception:
153
+ print(f"ERROR: 发送到 {member} 失败")
154
+ else:
155
+ print(f" {member}: 通道 {channel} 尚未实现")
156
+ print(f"\nOK 已发送到 {sent}/{len(subscribers)} 订阅者")
157
+
158
+
159
+ def cmd_log(limit: int = 20) -> None:
160
+ env = _env()
161
+ db_path = Path(env.claudes_dir) / "board.db"
162
+ if not db_path.exists():
163
+ print("ERROR: board.db 不存在")
164
+ raise SystemExit(1)
165
+
166
+ board = BoardDB(db_path)
167
+ try:
168
+ rows = board.query(
169
+ "SELECT sent_at, notif_type, recipient, channel, ref_id FROM notification_log ORDER BY id DESC LIMIT ?",
170
+ (limit,),
171
+ )
172
+ except Exception:
173
+ print("notification_log 表不存在,请运行 migration")
174
+ raise SystemExit(1)
175
+
176
+ if not rows:
177
+ print("通知记录为空")
178
+ return
179
+
180
+ print(f"最近 {len(rows)} 条通知记录:")
181
+ for row in rows:
182
+ sent_at, ntype, recipient, channel, ref_id = row
183
+ print(f" {sent_at} | {ntype:16s} | {recipient:12s} | {channel:12s} | {ref_id}")
184
+
185
+
186
+ def main() -> None:
187
+ args = sys.argv[1:]
188
+ if not args:
189
+ print(__doc__.strip())
190
+ raise SystemExit(1)
191
+
192
+ cmd = args[0]
193
+
194
+ if cmd == "status":
195
+ cmd_status()
196
+ elif cmd == "subscriptions":
197
+ member = args[1] if len(args) > 1 else None
198
+ cmd_subscriptions(member)
199
+ elif cmd == "test":
200
+ if len(args) < 3:
201
+ print("Usage: notify test <member> <type>")
202
+ raise SystemExit(1)
203
+ cmd_test(args[1], args[2])
204
+ elif cmd == "digest":
205
+ send = "--send" in args
206
+ cmd_digest(send=send)
207
+ elif cmd == "log":
208
+ limit = 20
209
+ if "--limit" in args:
210
+ idx = args.index("--limit")
211
+ if idx + 1 < len(args):
212
+ try:
213
+ limit = int(args[idx + 1])
214
+ except ValueError:
215
+ pass
216
+ cmd_log(limit=limit)
217
+ else:
218
+ print(f"ERROR: 未知命令 '{cmd}'")
219
+ print(__doc__.strip())
220
+ raise SystemExit(1)
221
+
222
+
223
+ if __name__ == "__main__":
224
+ main()
package/bin/registry CHANGED
@@ -32,7 +32,9 @@ def _content_hash(entry: dict) -> str:
32
32
  def _load_chain() -> list[dict]:
33
33
  entries = []
34
34
  for f in sorted(REGISTRY_DIR.glob("*.json")):
35
- entries.append(json.loads(f.read_text()))
35
+ data = json.loads(f.read_text())
36
+ if "block" in data:
37
+ entries.append(data)
36
38
  entries.sort(key=lambda e: e["block"])
37
39
  return entries
38
40
 
@@ -279,21 +281,13 @@ def cmd_whois(args: list[str]) -> None:
279
281
  print(f" Commits: {contrib['commits']}")
280
282
 
281
283
 
282
- CHAIN_START = "<!-- chain:start -->"
283
- CHAIN_END = "<!-- chain:end -->"
284
-
285
-
286
284
  def _sync_readme() -> None:
287
- readme = REGISTRY_DIR.parent / "README.md"
288
- if not readme.exists():
289
- return
290
- text = readme.read_text()
291
- if CHAIN_START not in text:
292
- return
285
+ readme = REGISTRY_DIR / "README.md"
293
286
 
294
287
  chain = _load_chain()
295
288
  lines = [
296
- CHAIN_START,
289
+ "# Registry Chain",
290
+ "",
297
291
  "| Block | Name | Role | Hash |",
298
292
  "|-------|------|------|------|",
299
293
  ]
@@ -303,17 +297,8 @@ def _sync_readme() -> None:
303
297
  role = entry.get("role", entry.get("type", ""))
304
298
  chain_hash = f"`{entry['chain']}`" if entry.get("chain") else "—"
305
299
  lines.append(f"| {block} | {name} | {role} | {chain_hash} |")
306
- lines.append(CHAIN_END)
307
-
308
- import re
309
-
310
- text = re.sub(
311
- rf"{re.escape(CHAIN_START)}.*?{re.escape(CHAIN_END)}",
312
- "\n".join(lines),
313
- text,
314
- flags=re.DOTALL,
315
- )
316
- readme.write_text(text)
300
+ lines.append("")
301
+ readme.write_text("\n".join(lines))
317
302
 
318
303
 
319
304
  def main() -> None: