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.
- package/Makefile +8 -2
- package/README.md +57 -36
- package/VERSION +1 -1
- package/bin/board +112 -34
- package/bin/cnb +152 -65
- package/bin/dispatcher +25 -11
- package/bin/doctor +3 -5
- package/bin/init +13 -47
- package/bin/notify +224 -0
- package/bin/registry +8 -23
- package/bin/swarm +41 -860
- package/bin/sync-version +131 -0
- package/lib/board_admin.py +19 -9
- package/lib/board_bbs.py +23 -8
- package/lib/board_bug.py +2 -1
- package/lib/board_db.py +31 -141
- package/lib/board_lock.py +5 -1
- package/lib/board_mailbox.py +18 -8
- package/lib/board_maintenance.py +26 -27
- package/lib/board_msg.py +76 -39
- package/lib/board_pending.py +233 -0
- package/lib/board_pulse.py +14 -0
- package/lib/board_task.py +41 -32
- package/lib/board_tui.py +120 -0
- package/lib/board_view.py +70 -50
- package/lib/board_vote.py +9 -3
- package/lib/build_lock.py +7 -7
- package/lib/common.py +45 -3
- package/lib/concerns/__init__.py +7 -11
- package/lib/concerns/{coral_manager.py → coral.py} +54 -4
- package/lib/concerns/digest_scheduler.py +109 -0
- package/lib/concerns/file_watcher.py +73 -68
- package/lib/concerns/health.py +136 -0
- package/lib/concerns/helpers.py +1 -5
- package/lib/concerns/idle.py +130 -0
- package/lib/concerns/notification_push.py +171 -0
- package/lib/concerns/notifications.py +145 -0
- package/lib/concerns/nudge_coordinator.py +148 -0
- package/lib/digest.py +62 -0
- package/lib/health.py +2 -2
- package/lib/inject.py +2 -2
- package/lib/migrate.py +1 -0
- package/lib/monitor.py +9 -22
- package/lib/notification_config.py +101 -0
- package/lib/swarm.py +464 -0
- package/lib/swarm_backend.py +300 -0
- package/lib/theme_profiles.py +89 -0
- package/migrations/004_heartbeat.sql +1 -0
- package/migrations/005_notification_log.sql +12 -0
- package/migrations/006_pending_actions.sql +15 -0
- package/package.json +4 -3
- package/pyproject.toml +3 -2
- package/registry/README.md +9 -0
- package/registry/pubkeys.json +2 -1
- package/schema.sql +29 -1
- package/lib/concerns/bug_sla_checker.py +0 -32
- package/lib/concerns/coral_poker.py +0 -57
- package/lib/concerns/health_checker.py +0 -72
- package/lib/concerns/idle_detector.py +0 -56
- package/lib/concerns/idle_killer.py +0 -41
- package/lib/concerns/idle_nudger.py +0 -38
- package/lib/concerns/inbox_nudger.py +0 -34
- package/lib/concerns/resource_monitor.py +0 -47
- package/lib/concerns/session_keepalive.py +0 -23
- 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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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:
|