claude-nb 0.4.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 +40 -56
- package/VERSION +1 -1
- package/bin/board +102 -34
- package/bin/cnb +59 -33
- package/bin/dispatcher +25 -11
- package/bin/doctor +3 -5
- package/bin/init +8 -8
- package/bin/notify +224 -0
- package/bin/registry +8 -23
- 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 +18 -6
- package/lib/board_lock.py +5 -0
- package/lib/board_mailbox.py +6 -4
- package/lib/board_msg.py +112 -24
- package/lib/board_pending.py +233 -0
- package/lib/board_pulse.py +14 -0
- package/lib/board_task.py +22 -10
- package/lib/board_tui.py +28 -20
- package/lib/board_view.py +60 -28
- 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 +4 -1
- package/lib/concerns/coral.py +1 -1
- package/lib/concerns/digest_scheduler.py +109 -0
- package/lib/concerns/file_watcher.py +73 -68
- package/lib/concerns/health.py +1 -1
- package/lib/concerns/notification_push.py +171 -0
- package/lib/concerns/notifications.py +58 -3
- 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/monitor.py +8 -4
- package/lib/notification_config.py +101 -0
- package/lib/swarm.py +43 -35
- package/lib/swarm_backend.py +63 -29
- 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/schema.sql +29 -1
package/bin/dispatcher
CHANGED
|
@@ -6,8 +6,7 @@ Concerns (each with independent check intervals):
|
|
|
6
6
|
SessionKeepAlive — detect dead dev sessions
|
|
7
7
|
IdleDetector — batch screen snapshot comparison
|
|
8
8
|
IdleKiller — kill sessions idle >30min
|
|
9
|
-
|
|
10
|
-
InboxNudger — detect unread inboxes, nudge sessions
|
|
9
|
+
NudgeCoordinator — unified nudge orchestrator (inbox/queued/idle)
|
|
11
10
|
CoralPoker — periodic heartbeat to dispatcher session
|
|
12
11
|
HealthChecker — periodic full status report + team idle detection
|
|
13
12
|
BugSLAChecker — check overdue bugs
|
|
@@ -39,8 +38,7 @@ from lib.concerns import (
|
|
|
39
38
|
HealthChecker,
|
|
40
39
|
IdleDetector,
|
|
41
40
|
IdleKiller,
|
|
42
|
-
|
|
43
|
-
InboxNudger,
|
|
41
|
+
NudgeCoordinator,
|
|
44
42
|
ResourceMonitor,
|
|
45
43
|
SessionKeepAlive,
|
|
46
44
|
TimeAnnouncer,
|
|
@@ -86,25 +84,39 @@ if not Path(cfg.board_sh).exists():
|
|
|
86
84
|
# ---------------------------------------------------------------------------
|
|
87
85
|
|
|
88
86
|
|
|
87
|
+
def _acquire_pidlock() -> Path:
|
|
88
|
+
pidfile = cfg.claudes_dir / "dispatcher.pid"
|
|
89
|
+
if pidfile.exists():
|
|
90
|
+
try:
|
|
91
|
+
old_pid = int(pidfile.read_text().strip())
|
|
92
|
+
os.kill(old_pid, 0)
|
|
93
|
+
print(f"FATAL: dispatcher already running (pid {old_pid})", file=sys.stderr)
|
|
94
|
+
sys.exit(1)
|
|
95
|
+
except (ValueError, ProcessLookupError, PermissionError):
|
|
96
|
+
pass
|
|
97
|
+
pidfile.write_text(str(os.getpid()))
|
|
98
|
+
return pidfile
|
|
99
|
+
|
|
100
|
+
|
|
89
101
|
def main() -> None:
|
|
102
|
+
pidfile = _acquire_pidlock()
|
|
90
103
|
base_interval = 2
|
|
91
104
|
|
|
92
105
|
coral = CoralManager(cfg)
|
|
93
106
|
idle = IdleDetector(cfg)
|
|
94
107
|
poker = CoralPoker(cfg)
|
|
95
|
-
|
|
108
|
+
nudge = NudgeCoordinator(cfg, idle)
|
|
96
109
|
throttle = AdaptiveThrottle()
|
|
97
|
-
file_watcher = FileWatcher(cfg,
|
|
110
|
+
file_watcher = FileWatcher(cfg, nudge)
|
|
98
111
|
|
|
99
|
-
# Order matters: idle must tick before idle_killer /
|
|
112
|
+
# Order matters: idle must tick before idle_killer / nudge_coordinator
|
|
100
113
|
concerns: list[Concern] = [
|
|
101
114
|
coral,
|
|
102
115
|
TimeAnnouncer(cfg),
|
|
103
116
|
idle,
|
|
104
117
|
SessionKeepAlive(cfg),
|
|
105
118
|
IdleKiller(cfg, idle, coral),
|
|
106
|
-
|
|
107
|
-
IdleNudger(cfg, idle),
|
|
119
|
+
nudge,
|
|
108
120
|
poker,
|
|
109
121
|
BugSLAChecker(cfg, poker),
|
|
110
122
|
HealthChecker(cfg, poker, coral),
|
|
@@ -131,8 +143,9 @@ def main() -> None:
|
|
|
131
143
|
while running:
|
|
132
144
|
now = int(time.time())
|
|
133
145
|
|
|
134
|
-
|
|
135
|
-
|
|
146
|
+
any_alive = any(tmux_ok("has-session", "-t", f"{cfg.prefix}-{s}") for s in cfg.dev_sessions)
|
|
147
|
+
if not any_alive:
|
|
148
|
+
log("No dev sessions alive. Shutting down.")
|
|
136
149
|
break
|
|
137
150
|
|
|
138
151
|
for c in concerns:
|
|
@@ -144,6 +157,7 @@ def main() -> None:
|
|
|
144
157
|
finally:
|
|
145
158
|
log("Shutting down...")
|
|
146
159
|
file_watcher.stop()
|
|
160
|
+
pidfile.unlink(missing_ok=True)
|
|
147
161
|
log("Stopped.")
|
|
148
162
|
|
|
149
163
|
|
package/bin/doctor
CHANGED
|
@@ -103,9 +103,7 @@ def check_foreign_keys(path: Path) -> bool:
|
|
|
103
103
|
try:
|
|
104
104
|
conn = sqlite3.connect(str(path))
|
|
105
105
|
# PRAGMA foreign_keys is connection-level; check table definitions instead
|
|
106
|
-
rows = conn.execute(
|
|
107
|
-
"SELECT sql FROM sqlite_master WHERE type='table' AND sql LIKE '%REFERENCES%'"
|
|
108
|
-
).fetchall()
|
|
106
|
+
rows = conn.execute("SELECT sql FROM sqlite_master WHERE type='table' AND sql LIKE '%REFERENCES%'").fetchall()
|
|
109
107
|
conn.close()
|
|
110
108
|
if rows:
|
|
111
109
|
_ok(f"Foreign keys defined ({len(rows)} tables)")
|
|
@@ -288,7 +286,7 @@ def main() -> None:
|
|
|
288
286
|
check_git()
|
|
289
287
|
check_claude_cli()
|
|
290
288
|
print("\nResult: project not initialized — run 'cnb init' first")
|
|
291
|
-
|
|
289
|
+
raise SystemExit(1)
|
|
292
290
|
|
|
293
291
|
print("=== cnb Doctor ===\n")
|
|
294
292
|
|
|
@@ -321,7 +319,7 @@ def main() -> None:
|
|
|
321
319
|
print("✓ All checks passed.")
|
|
322
320
|
else:
|
|
323
321
|
print("⚠ Some checks failed — review warnings above.")
|
|
324
|
-
|
|
322
|
+
raise SystemExit(1)
|
|
325
323
|
|
|
326
324
|
|
|
327
325
|
if __name__ == "__main__":
|
package/bin/init
CHANGED
|
@@ -115,12 +115,11 @@ def _update_claude_md(project_dir: Path, snippet: str) -> None:
|
|
|
115
115
|
|
|
116
116
|
|
|
117
117
|
def _hook_command(claudes_home: Path) -> str:
|
|
118
|
-
board = f"{claudes_home}/bin/board"
|
|
119
118
|
return (
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
119
|
+
'if [ -n "$CLAUDE_SESSION_NAME" ] && [ -n "$CNB_PROJECT" ]; then '
|
|
120
|
+
"BOARD=$(grep '^claudes_home' \"$CNB_PROJECT/.claudes/config.toml\" 2>/dev/null "
|
|
121
|
+
"| cut -d'\"' -f2)/bin/board; "
|
|
122
|
+
'[ -x "$BOARD" ] && $BOARD --as $CLAUDE_SESSION_NAME pulse 2>/dev/null; fi'
|
|
124
123
|
)
|
|
125
124
|
|
|
126
125
|
|
|
@@ -148,7 +147,8 @@ def _update_settings(project_dir: Path, claudes_home: Path) -> None:
|
|
|
148
147
|
already = any(
|
|
149
148
|
isinstance(h, dict)
|
|
150
149
|
and any(
|
|
151
|
-
"board" in sub.get("command", "")
|
|
150
|
+
"board" in sub.get("command", "")
|
|
151
|
+
and ("pulse" in sub.get("command", "") or "inbox" in sub.get("command", ""))
|
|
152
152
|
for sub in h.get("hooks", [])
|
|
153
153
|
if isinstance(sub, dict)
|
|
154
154
|
)
|
|
@@ -252,7 +252,7 @@ def main() -> None:
|
|
|
252
252
|
(claudes_dir / "sessions" / f"{n}.md").write_text(md_content)
|
|
253
253
|
conn.execute("INSERT OR IGNORE INTO meta(key, value) VALUES ('dispatcher_session', 'dispatcher')")
|
|
254
254
|
# Record schema version so migration runner knows where we are
|
|
255
|
-
conn.execute("INSERT OR IGNORE INTO meta(key, value) VALUES ('schema_version', '
|
|
255
|
+
conn.execute("INSERT OR IGNORE INTO meta(key, value) VALUES ('schema_version', '4')")
|
|
256
256
|
conn.commit()
|
|
257
257
|
conn.close()
|
|
258
258
|
|
|
@@ -264,7 +264,7 @@ def main() -> None:
|
|
|
264
264
|
print(f"Applied {applied} schema migration(s).")
|
|
265
265
|
|
|
266
266
|
# .gitignore for generated stuff
|
|
267
|
-
gitignore_content = "board.db\nboard.db-shm\nboard.db-wal\nlogs/\n"
|
|
267
|
+
gitignore_content = "board.db\nboard.db-shm\nboard.db-wal\nlogs/\ndispatcher.pid\n"
|
|
268
268
|
(claudes_dir / ".gitignore").write_text(gitignore_content)
|
|
269
269
|
|
|
270
270
|
# Update .claude/settings.json (merge hooks if exists, create if not)
|
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:
|
package/bin/sync-version
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""sync-version — sync VERSION to package.json and pyproject.toml.
|
|
3
|
+
|
|
4
|
+
Reads the canonical version from VERSION file and updates:
|
|
5
|
+
- package.json: "version" field
|
|
6
|
+
- pyproject.toml: version field (PEP 440 format: X.Y.Z.dev0)
|
|
7
|
+
Also syncs the license from pyproject.toml to package.json.
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
./bin/sync-version # sync + report
|
|
11
|
+
./bin/sync-version --check # check consistency only (exit 1 if drift)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def read_version() -> str:
|
|
23
|
+
return (ROOT / "VERSION").read_text().strip()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def read_pyproject() -> tuple[str, str]:
|
|
27
|
+
"""Return (version, license) from pyproject.toml."""
|
|
28
|
+
text = (ROOT / "pyproject.toml").read_text()
|
|
29
|
+
ver_m = re.search(r'^version\s*=\s*"([^"]+)"', text, re.MULTILINE)
|
|
30
|
+
lic_m = re.search(r'^license\s*=\s*"([^"]+)"', text, re.MULTILINE)
|
|
31
|
+
return (ver_m.group(1) if ver_m else ""), (lic_m.group(1) if lic_m else "")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def read_package_json() -> tuple[str, str]:
|
|
35
|
+
"""Return (version, license) from package.json."""
|
|
36
|
+
data = json.loads((ROOT / "package.json").read_text())
|
|
37
|
+
return data.get("version", ""), data.get("license", "")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def canonical_to_pep440(ver: str) -> str:
|
|
41
|
+
"""Convert '0.5.1-dev' to '0.5.1.dev0' (PEP 440)."""
|
|
42
|
+
return re.sub(r"-dev$", ".dev0", ver)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def pep440_to_npm(ver: str) -> str:
|
|
46
|
+
"""Convert '0.5.1.dev0' to '0.5.1-dev'."""
|
|
47
|
+
return re.sub(r"\.dev\d+$", "-dev", ver)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def check() -> list[str]:
|
|
51
|
+
"""Return list of inconsistency descriptions. Empty = all good."""
|
|
52
|
+
ver = read_version()
|
|
53
|
+
pep_ver, pep_lic = read_pyproject()
|
|
54
|
+
npm_ver, npm_lic = read_package_json()
|
|
55
|
+
|
|
56
|
+
expected_pep = canonical_to_pep440(ver)
|
|
57
|
+
errors = []
|
|
58
|
+
|
|
59
|
+
if pep_ver != expected_pep:
|
|
60
|
+
errors.append(f"pyproject.toml version '{pep_ver}' != expected '{expected_pep}' (from VERSION '{ver}')")
|
|
61
|
+
if npm_ver != ver:
|
|
62
|
+
errors.append(f"package.json version '{npm_ver}' != VERSION '{ver}'")
|
|
63
|
+
if pep_lic and npm_lic != pep_lic:
|
|
64
|
+
errors.append(f"package.json license '{npm_lic}' != pyproject.toml license '{pep_lic}'")
|
|
65
|
+
|
|
66
|
+
return errors
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def sync() -> None:
|
|
70
|
+
ver = read_version()
|
|
71
|
+
_, pep_lic = read_pyproject()
|
|
72
|
+
|
|
73
|
+
# Sync package.json
|
|
74
|
+
pkg_path = ROOT / "package.json"
|
|
75
|
+
pkg = json.loads(pkg_path.read_text())
|
|
76
|
+
changed = False
|
|
77
|
+
if pkg.get("version") != ver:
|
|
78
|
+
print(f"package.json version: '{pkg['version']}' -> '{ver}'")
|
|
79
|
+
pkg["version"] = ver
|
|
80
|
+
changed = True
|
|
81
|
+
if pep_lic and pkg.get("license") != pep_lic:
|
|
82
|
+
print(f"package.json license: '{pkg.get('license')}' -> '{pep_lic}'")
|
|
83
|
+
pkg["license"] = pep_lic
|
|
84
|
+
changed = True
|
|
85
|
+
if changed:
|
|
86
|
+
pkg_path.write_text(json.dumps(pkg, indent=2) + "\n")
|
|
87
|
+
else:
|
|
88
|
+
print("package.json: already in sync")
|
|
89
|
+
|
|
90
|
+
# Sync pyproject.toml version
|
|
91
|
+
pyp_path = ROOT / "pyproject.toml"
|
|
92
|
+
text = pyp_path.read_text()
|
|
93
|
+
expected_pep = canonical_to_pep440(ver)
|
|
94
|
+
new_text = re.sub(
|
|
95
|
+
r'^(version\s*=\s*")[^"]+"',
|
|
96
|
+
rf'\g<1>{expected_pep}"',
|
|
97
|
+
text,
|
|
98
|
+
count=1,
|
|
99
|
+
flags=re.MULTILINE,
|
|
100
|
+
)
|
|
101
|
+
if new_text != text:
|
|
102
|
+
pep_ver, _ = read_pyproject()
|
|
103
|
+
print(f"pyproject.toml version: '{pep_ver}' -> '{expected_pep}'")
|
|
104
|
+
pyp_path.write_text(new_text)
|
|
105
|
+
else:
|
|
106
|
+
print("pyproject.toml: already in sync")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def main() -> None:
|
|
110
|
+
if "--check" in sys.argv:
|
|
111
|
+
errors = check()
|
|
112
|
+
if errors:
|
|
113
|
+
print("VERSION DRIFT DETECTED:")
|
|
114
|
+
for e in errors:
|
|
115
|
+
print(f" - {e}")
|
|
116
|
+
raise SystemExit(1)
|
|
117
|
+
print("OK all versions and licenses consistent")
|
|
118
|
+
return
|
|
119
|
+
|
|
120
|
+
sync()
|
|
121
|
+
errors = check()
|
|
122
|
+
if errors:
|
|
123
|
+
print("\nWARNING: still inconsistent after sync:")
|
|
124
|
+
for e in errors:
|
|
125
|
+
print(f" - {e}")
|
|
126
|
+
raise SystemExit(1)
|
|
127
|
+
print("\nOK all synced")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
if __name__ == "__main__":
|
|
131
|
+
main()
|
package/lib/board_admin.py
CHANGED
|
@@ -4,9 +4,12 @@ import subprocess
|
|
|
4
4
|
import time
|
|
5
5
|
|
|
6
6
|
from lib.board_db import BoardDB, ts
|
|
7
|
+
from lib.common import validate_identity
|
|
7
8
|
|
|
8
9
|
|
|
9
10
|
def cmd_suspend(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
11
|
+
assert db.env is not None
|
|
12
|
+
validate_identity(db, identity)
|
|
10
13
|
name = identity.lower()
|
|
11
14
|
if not args:
|
|
12
15
|
print("Usage: ./board --as <name> suspend <session>")
|
|
@@ -35,15 +38,19 @@ def cmd_suspend(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
|
35
38
|
|
|
36
39
|
prefix = db.env.prefix
|
|
37
40
|
sess = f"{prefix}-{target}"
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
41
|
+
try:
|
|
42
|
+
r = subprocess.run(["tmux", "has-session", "-t", sess], capture_output=True, timeout=5)
|
|
43
|
+
if r.returncode == 0:
|
|
44
|
+
subprocess.run(
|
|
45
|
+
["tmux", "send-keys", "-t", sess, "/exit", "Enter"],
|
|
46
|
+
capture_output=True,
|
|
47
|
+
timeout=5,
|
|
48
|
+
)
|
|
49
|
+
time.sleep(2)
|
|
50
|
+
subprocess.run(["tmux", "kill-session", "-t", sess], capture_output=True, timeout=5)
|
|
51
|
+
print(f"{target}: tmux session 已关闭")
|
|
52
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
53
|
+
pass
|
|
47
54
|
|
|
48
55
|
now = ts()
|
|
49
56
|
db.execute(
|
|
@@ -53,6 +60,8 @@ def cmd_suspend(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
|
53
60
|
|
|
54
61
|
|
|
55
62
|
def cmd_resume(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
63
|
+
assert db.env is not None
|
|
64
|
+
validate_identity(db, identity)
|
|
56
65
|
name = identity.lower()
|
|
57
66
|
if not args:
|
|
58
67
|
print("Usage: ./board --as <name> resume <session>")
|
|
@@ -80,6 +89,7 @@ def cmd_resume(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
|
80
89
|
|
|
81
90
|
|
|
82
91
|
def cmd_kudos(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
92
|
+
validate_identity(db, identity)
|
|
83
93
|
name = identity.lower()
|
|
84
94
|
if len(args) < 2:
|
|
85
95
|
print("Usage: ./board --as <name> kudos <target> <reason> [--evidence <commit/link>]")
|