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.
Files changed (49) hide show
  1. package/Makefile +8 -2
  2. package/README.md +40 -56
  3. package/VERSION +1 -1
  4. package/bin/board +102 -34
  5. package/bin/cnb +59 -33
  6. package/bin/dispatcher +25 -11
  7. package/bin/doctor +3 -5
  8. package/bin/init +8 -8
  9. package/bin/notify +224 -0
  10. package/bin/registry +8 -23
  11. package/bin/sync-version +131 -0
  12. package/lib/board_admin.py +19 -9
  13. package/lib/board_bbs.py +23 -8
  14. package/lib/board_bug.py +2 -1
  15. package/lib/board_db.py +18 -6
  16. package/lib/board_lock.py +5 -0
  17. package/lib/board_mailbox.py +6 -4
  18. package/lib/board_msg.py +112 -24
  19. package/lib/board_pending.py +233 -0
  20. package/lib/board_pulse.py +14 -0
  21. package/lib/board_task.py +22 -10
  22. package/lib/board_tui.py +28 -20
  23. package/lib/board_view.py +60 -28
  24. package/lib/board_vote.py +9 -3
  25. package/lib/build_lock.py +7 -7
  26. package/lib/common.py +45 -3
  27. package/lib/concerns/__init__.py +4 -1
  28. package/lib/concerns/coral.py +1 -1
  29. package/lib/concerns/digest_scheduler.py +109 -0
  30. package/lib/concerns/file_watcher.py +73 -68
  31. package/lib/concerns/health.py +1 -1
  32. package/lib/concerns/notification_push.py +171 -0
  33. package/lib/concerns/notifications.py +58 -3
  34. package/lib/concerns/nudge_coordinator.py +148 -0
  35. package/lib/digest.py +62 -0
  36. package/lib/health.py +2 -2
  37. package/lib/inject.py +2 -2
  38. package/lib/monitor.py +8 -4
  39. package/lib/notification_config.py +101 -0
  40. package/lib/swarm.py +43 -35
  41. package/lib/swarm_backend.py +63 -29
  42. package/lib/theme_profiles.py +89 -0
  43. package/migrations/004_heartbeat.sql +1 -0
  44. package/migrations/005_notification_log.sql +12 -0
  45. package/migrations/006_pending_actions.sql +15 -0
  46. package/package.json +4 -3
  47. package/pyproject.toml +3 -2
  48. package/registry/README.md +9 -0
  49. 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
- IdleNudger — nudge idle sessions to continue working
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
- IdleNudger,
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
- inbox = InboxNudger(cfg)
108
+ nudge = NudgeCoordinator(cfg, idle)
96
109
  throttle = AdaptiveThrottle()
97
- file_watcher = FileWatcher(cfg, inbox)
110
+ file_watcher = FileWatcher(cfg, nudge)
98
111
 
99
- # Order matters: idle must tick before idle_killer / idle_nudger
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
- inbox,
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
- if not tmux_ok("has-session", "-t", cfg.coral_sess):
135
- log("Coral session gone. Shutting down.")
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
- sys.exit(1)
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
- sys.exit(1)
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
- f'if [ -n "$CLAUDE_SESSION_NAME" ]; then '
121
- f"unread=$({board} --as $CLAUDE_SESSION_NAME inbox 2>/dev/null "
122
- f"| grep -oE '[0-9]+ 条未读' | head -1); "
123
- f'[ -n "$unread" ] && echo "[inbox] $unread" || true; fi'
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", "") and "inbox" 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', '2')")
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
- 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:
@@ -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()
@@ -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
- r = subprocess.run(["tmux", "has-session", "-t", sess], capture_output=True)
39
- if r.returncode == 0:
40
- subprocess.run(
41
- ["tmux", "send-keys", "-t", sess, "/exit", "Enter"],
42
- capture_output=True,
43
- )
44
- time.sleep(2)
45
- subprocess.run(["tmux", "kill-session", "-t", sess], capture_output=True)
46
- print(f"{target}: tmux session 已关闭")
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>]")