claude-nb 0.3.0

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/LICENSE +38 -0
  2. package/Makefile +60 -0
  3. package/README.md +63 -0
  4. package/VERSION +1 -0
  5. package/bin/_pip_entry.py +25 -0
  6. package/bin/board +287 -0
  7. package/bin/cnb +150 -0
  8. package/bin/cnb.js +33 -0
  9. package/bin/dispatcher +151 -0
  10. package/bin/dispatcher-watchdog +57 -0
  11. package/bin/doctor +328 -0
  12. package/bin/init +316 -0
  13. package/bin/registry +347 -0
  14. package/bin/swarm +896 -0
  15. package/lib/__init__.py +1 -0
  16. package/lib/board_admin.py +128 -0
  17. package/lib/board_bbs.py +99 -0
  18. package/lib/board_bug.py +161 -0
  19. package/lib/board_db.py +262 -0
  20. package/lib/board_lock.py +113 -0
  21. package/lib/board_mailbox.py +145 -0
  22. package/lib/board_maintenance.py +237 -0
  23. package/lib/board_msg.py +230 -0
  24. package/lib/board_task.py +200 -0
  25. package/lib/board_view.py +366 -0
  26. package/lib/board_vote.py +164 -0
  27. package/lib/build_lock.py +221 -0
  28. package/lib/cli.py +34 -0
  29. package/lib/common.py +285 -0
  30. package/lib/concerns/__init__.py +42 -0
  31. package/lib/concerns/adaptive_throttle.py +26 -0
  32. package/lib/concerns/base.py +25 -0
  33. package/lib/concerns/bug_sla_checker.py +32 -0
  34. package/lib/concerns/config.py +22 -0
  35. package/lib/concerns/coral_manager.py +61 -0
  36. package/lib/concerns/coral_poker.py +57 -0
  37. package/lib/concerns/file_watcher.py +127 -0
  38. package/lib/concerns/health_checker.py +72 -0
  39. package/lib/concerns/helpers.py +152 -0
  40. package/lib/concerns/idle_detector.py +56 -0
  41. package/lib/concerns/idle_killer.py +41 -0
  42. package/lib/concerns/idle_nudger.py +38 -0
  43. package/lib/concerns/inbox_nudger.py +34 -0
  44. package/lib/concerns/resource_monitor.py +47 -0
  45. package/lib/concerns/session_keepalive.py +23 -0
  46. package/lib/concerns/time_announcer.py +34 -0
  47. package/lib/crypto.py +92 -0
  48. package/lib/health.py +187 -0
  49. package/lib/inject.py +164 -0
  50. package/lib/migrate.py +109 -0
  51. package/lib/monitor.py +373 -0
  52. package/lib/panel.py +137 -0
  53. package/lib/resources.py +341 -0
  54. package/migrations/001_foreign_keys.sql +77 -0
  55. package/migrations/002_session_persona.sql +1 -0
  56. package/migrations/003_mailbox.sql +9 -0
  57. package/package.json +28 -0
  58. package/pyproject.toml +71 -0
  59. package/registry/0001-meridian.json +12 -0
  60. package/registry/0002-forge.json +12 -0
  61. package/registry/0003-lead.json +12 -0
  62. package/registry/0004-ms-encrypted-mailbox-live.json +12 -0
  63. package/registry/GENESIS.json +9 -0
  64. package/registry/pubkeys.json +5 -0
  65. package/schema.sql +138 -0
@@ -0,0 +1,23 @@
1
+ """SessionKeepAlive — detect dead dev sessions."""
2
+
3
+ from lib.common import is_suspended
4
+
5
+ from .base import Concern
6
+ from .config import DispatcherConfig
7
+ from .helpers import get_dev_sessions, is_claude_running, log, tmux_ok
8
+
9
+
10
+ class SessionKeepAlive(Concern):
11
+ interval = 5
12
+
13
+ def __init__(self, cfg: DispatcherConfig) -> None:
14
+ super().__init__()
15
+ self.cfg = cfg
16
+
17
+ def tick(self, now: int) -> None:
18
+ for name in get_dev_sessions(self.cfg):
19
+ if is_suspended(name, self.cfg.suspended_file):
20
+ continue
21
+ sess = f"{self.cfg.prefix}-{name}"
22
+ if tmux_ok("has-session", "-t", sess) and not is_claude_running(sess):
23
+ log(f"{name}: agent exited, NOT restarting (idle policy)")
@@ -0,0 +1,34 @@
1
+ """TimeAnnouncer — hourly/daily announcements."""
2
+
3
+ from .base import Concern
4
+ from .config import DispatcherConfig
5
+ from .helpers import board_send, log
6
+
7
+
8
+ class TimeAnnouncer(Concern):
9
+ interval = 30
10
+
11
+ def __init__(self, cfg: DispatcherConfig) -> None:
12
+ super().__init__()
13
+ self.cfg = cfg
14
+ self.last_hour = -1
15
+
16
+ def tick(self, now: int) -> None:
17
+ from datetime import datetime as dt
18
+
19
+ d = dt.now()
20
+ if d.minute != 0 or d.hour == self.last_hour:
21
+ return
22
+ self.last_hour = d.hour
23
+ ts = d.strftime("%Y-%m-%d %H:%M")
24
+
25
+ if d.hour == 9:
26
+ board_send(
27
+ self.cfg,
28
+ "All",
29
+ f"[Clock] {ts} ({d.strftime('%A')}) — 新一天。检查 KR 列表,确认优先级。",
30
+ )
31
+ log("Daily announcement sent")
32
+ else:
33
+ board_send(self.cfg, "All", f"[Clock] 现在是 {ts}。")
34
+ log(f"Hourly announcement: {d.hour}:00")
package/lib/crypto.py ADDED
@@ -0,0 +1,92 @@
1
+ """crypto — X25519 sealed-box encryption for inter-agent messaging."""
2
+
3
+ import base64
4
+ import os
5
+ from pathlib import Path
6
+
7
+ from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey
8
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
9
+ from cryptography.hazmat.primitives.hashes import SHA256
10
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
11
+ from cryptography.hazmat.primitives.serialization import (
12
+ Encoding,
13
+ NoEncryption,
14
+ PrivateFormat,
15
+ )
16
+
17
+ _HKDF_INFO = b"claudes-mailbox-v1"
18
+
19
+
20
+ def generate_keypair() -> tuple[X25519PrivateKey, X25519PublicKey]:
21
+ private = X25519PrivateKey.generate()
22
+ return private, private.public_key()
23
+
24
+
25
+ def private_key_to_pem(key: X25519PrivateKey) -> bytes:
26
+ return key.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption())
27
+
28
+
29
+ def private_key_from_pem(data: bytes) -> X25519PrivateKey:
30
+ from cryptography.hazmat.primitives.serialization import load_pem_private_key
31
+
32
+ k = load_pem_private_key(data, password=None)
33
+ if not isinstance(k, X25519PrivateKey):
34
+ raise ValueError("not an X25519 private key")
35
+ return k
36
+
37
+
38
+ def public_key_to_hex(key: X25519PublicKey) -> str:
39
+ return key.public_bytes_raw().hex()
40
+
41
+
42
+ def public_key_from_hex(h: str) -> X25519PublicKey:
43
+ return X25519PublicKey.from_public_bytes(bytes.fromhex(h))
44
+
45
+
46
+ def _derive_key(shared_secret: bytes) -> bytes:
47
+ return HKDF(algorithm=SHA256(), length=32, salt=None, info=_HKDF_INFO).derive(shared_secret)
48
+
49
+
50
+ def seal(plaintext: bytes, recipient_pub: X25519PublicKey) -> bytes:
51
+ """Encrypt with ephemeral X25519 + AESGCM. Returns ephemeral_pub(32) + nonce(12) + ciphertext."""
52
+ eph_priv, eph_pub = generate_keypair()
53
+ shared = eph_priv.exchange(recipient_pub)
54
+ aes_key = _derive_key(shared)
55
+ nonce = os.urandom(12)
56
+ ct = AESGCM(aes_key).encrypt(nonce, plaintext, None)
57
+ return eph_pub.public_bytes_raw() + nonce + ct
58
+
59
+
60
+ def unseal(blob: bytes, recipient_priv: X25519PrivateKey) -> bytes:
61
+ """Decrypt a sealed message. Raises on tamper or wrong key."""
62
+ if len(blob) < 44:
63
+ raise ValueError("ciphertext too short")
64
+ eph_pub_bytes = blob[:32]
65
+ nonce = blob[32:44]
66
+ ct = blob[44:]
67
+ eph_pub = X25519PublicKey.from_public_bytes(eph_pub_bytes)
68
+ shared = recipient_priv.exchange(eph_pub)
69
+ aes_key = _derive_key(shared)
70
+ return AESGCM(aes_key).decrypt(nonce, ct, None)
71
+
72
+
73
+ def seal_b64(plaintext: str, recipient_pub: X25519PublicKey) -> str:
74
+ return base64.b64encode(seal(plaintext.encode(), recipient_pub)).decode()
75
+
76
+
77
+ def unseal_b64(encoded: str, recipient_priv: X25519PrivateKey) -> str:
78
+ return unseal(base64.b64decode(encoded), recipient_priv).decode()
79
+
80
+
81
+ def save_keypair(keys_dir: Path, name: str, private: X25519PrivateKey, public: X25519PublicKey) -> None:
82
+ keys_dir.mkdir(parents=True, exist_ok=True)
83
+ (keys_dir / f"{name}.pem").write_bytes(private_key_to_pem(private))
84
+ (keys_dir / f"{name}.pem").chmod(0o600)
85
+ (keys_dir / f"{name}.pub").write_text(public_key_to_hex(public) + "\n")
86
+
87
+
88
+ def load_private_key(keys_dir: Path, name: str) -> X25519PrivateKey:
89
+ pem_path = keys_dir / f"{name}.pem"
90
+ if not pem_path.exists():
91
+ raise FileNotFoundError(f"私钥不存在: {pem_path} (先运行 keygen)")
92
+ return private_key_from_pem(pem_path.read_bytes())
package/lib/health.py ADDED
@@ -0,0 +1,187 @@
1
+ #!/usr/bin/env python3
2
+ """health.py -- Session health report with colored output.
3
+
4
+ Shows restart count, idle status, uptime for each session.
5
+
6
+ Usage:
7
+ ./lib/health.py
8
+ """
9
+
10
+ import re
11
+ import subprocess
12
+ import sys
13
+ import time
14
+ from pathlib import Path
15
+
16
+ # Try to import common; fall back gracefully for standalone use
17
+ try:
18
+ from lib.common import ClaudesEnv, date_to_epoch
19
+ except ImportError:
20
+ # Allow running from project root
21
+ _here = Path(__file__).resolve().parent.parent
22
+ sys.path.insert(0, str(_here))
23
+ from lib.common import ClaudesEnv, date_to_epoch
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # ANSI colors
27
+ # ---------------------------------------------------------------------------
28
+
29
+ G = "\033[0;32m"
30
+ R = "\033[0;31m"
31
+ Y = "\033[1;33m"
32
+ D = "\033[2m"
33
+ NC = "\033[0m"
34
+
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # Helpers
38
+ # ---------------------------------------------------------------------------
39
+
40
+
41
+ def _tmux(*args: str) -> str | None:
42
+ """Run a tmux command, return stdout or None on failure."""
43
+ try:
44
+ r = subprocess.run(
45
+ ["tmux", *args],
46
+ capture_output=True,
47
+ text=True,
48
+ timeout=5,
49
+ )
50
+ return r.stdout.strip() if r.returncode == 0 else None
51
+ except (subprocess.TimeoutExpired, OSError):
52
+ return None
53
+
54
+
55
+ def get_sessions(prefix: str, dispatcher: str = "dispatcher", lead: str = "lead") -> list:
56
+ """List dev session names from tmux (excludes dispatcher & lead)."""
57
+ raw = _tmux("list-sessions", "-F", "#{session_name}")
58
+ if raw is None:
59
+ return []
60
+ names = []
61
+ for line in sorted(raw.splitlines()):
62
+ if line.startswith(f"{prefix}-"):
63
+ name = line[len(prefix) + 1 :]
64
+ if name not in (dispatcher, lead):
65
+ names.append(name)
66
+ return names
67
+
68
+
69
+ def is_claude_running(sess: str) -> bool:
70
+ """Check if the pane's current command looks like an active Claude process."""
71
+ if _tmux("has-session", "-t", sess) is None:
72
+ return False
73
+ cmd = _tmux("list-panes", "-t", sess, "-F", "#{pane_current_command}")
74
+ if cmd is None:
75
+ return False
76
+ first = cmd.splitlines()[0] if cmd else ""
77
+ return first not in ("zsh", "bash", "sh", "-zsh", "-bash", "")
78
+
79
+
80
+ def session_status(sess: str, idle_cache: Path) -> str:
81
+ """Return status string: active, idle, exited, offline."""
82
+ if _tmux("has-session", "-t", sess) is None:
83
+ return "offline"
84
+
85
+ cmd = _tmux("list-panes", "-t", sess, "-F", "#{pane_current_command}")
86
+ first = (cmd or "").splitlines()[0] if cmd else ""
87
+ if first in ("zsh", "bash", "sh", "-zsh", "-bash", ""):
88
+ return "exited"
89
+
90
+ # Check idle cache
91
+ if idle_cache.exists():
92
+ for line in idle_cache.read_text().splitlines():
93
+ if line == f"{sess} idle":
94
+ return "idle"
95
+ return "active"
96
+
97
+
98
+ # ---------------------------------------------------------------------------
99
+ # Main report
100
+ # ---------------------------------------------------------------------------
101
+
102
+
103
+ def print_health_report() -> None:
104
+ env = ClaudesEnv.load()
105
+ prefix = env.prefix
106
+ log_dir = env.project_root / ".swarm-logs"
107
+ idle_cache = log_dir / "idle-cache"
108
+ now = int(time.time())
109
+
110
+ sessions = get_sessions(prefix)
111
+
112
+ print()
113
+ print(" Session\t\tStatus\t\tRestarts\tUptime\t\tAgent")
114
+ print(" -------\t\t------\t\t--------\t------\t\t-----")
115
+
116
+ total = 0
117
+ alive = 0
118
+ idle_count = 0
119
+
120
+ for name in sessions:
121
+ total += 1
122
+ sess = f"{prefix}-{name}"
123
+
124
+ status = session_status(sess, idle_cache)
125
+ if status == "idle":
126
+ idle_count += 1
127
+ alive += 1
128
+ elif status == "active":
129
+ alive += 1
130
+
131
+ # Restart count
132
+ restarts = 0
133
+ log_file = log_dir / f"{name}.log"
134
+ if log_file.exists():
135
+ restarts = sum(1 for _ in log_file.read_text().splitlines())
136
+
137
+ # Uptime + agent
138
+ uptime_str = "-"
139
+ agent = "?"
140
+ if log_file.exists():
141
+ lines = log_file.read_text().splitlines()
142
+ if lines:
143
+ last_line = lines[-1]
144
+ ts_match = re.search(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}", last_line)
145
+ agent_match = re.search(r"agent: ([a-z]+)", last_line)
146
+ if agent_match:
147
+ agent = agent_match.group(1)
148
+ if ts_match:
149
+ start_epoch = date_to_epoch(ts_match.group(0))
150
+ if start_epoch > 0:
151
+ elapsed = now - start_epoch
152
+ hours = elapsed // 3600
153
+ mins = (elapsed % 3600) // 60
154
+ uptime_str = f"{hours}h{mins}m"
155
+
156
+ # Color status
157
+ if status == "active":
158
+ status_col = f"{G}active{NC}"
159
+ elif status == "idle":
160
+ status_col = f"{Y}idle{NC}"
161
+ elif status == "exited":
162
+ status_col = f"{R}exited{NC}"
163
+ else:
164
+ status_col = f"{D}offline{NC}"
165
+
166
+ # Color restarts
167
+ if restarts > 5:
168
+ restart_col = f"{R}{restarts}{NC}"
169
+ elif restarts > 2:
170
+ restart_col = f"{Y}{restarts}{NC}"
171
+ else:
172
+ restart_col = f"{G}{restarts}{NC}"
173
+
174
+ print(f" {name}\t\t{status_col}\t\t{restart_col}\t\t{uptime_str}\t\t{agent}")
175
+
176
+ print()
177
+ offline = total - alive
178
+ print(f" Total: {total} | Active: {G}{alive}{NC} | Idle: {Y}{idle_count}{NC} | Offline: {D}{offline}{NC}")
179
+ print()
180
+
181
+
182
+ def main() -> None:
183
+ print_health_report()
184
+
185
+
186
+ if __name__ == "__main__":
187
+ main()
package/lib/inject.py ADDED
@@ -0,0 +1,164 @@
1
+ #!/usr/bin/env python3
2
+ """inject.py -- Force-inject a message into another Claude Code session.
3
+
4
+ Auto-detects tmux or screen. Override with SWARM_MODE=tmux|screen.
5
+
6
+ Usage:
7
+ ./lib/inject.py <target> <message>
8
+ ./lib/inject.py all "everyone check inbox"
9
+ """
10
+
11
+ import os
12
+ import shutil
13
+ import subprocess
14
+ import sys
15
+
16
+ # Try to import common; fall back gracefully for standalone use
17
+ try:
18
+ from lib.common import ClaudesEnv
19
+ except ImportError:
20
+ _here = os.path.dirname(os.path.abspath(__file__))
21
+ sys.path.insert(0, os.path.dirname(_here))
22
+ from lib.common import ClaudesEnv
23
+
24
+
25
+ def detect_mode(prefix: str) -> str:
26
+ """Auto-detect session multiplexer: tmux, screen, or none."""
27
+ swarm_mode = os.environ.get("SWARM_MODE", "")
28
+ if swarm_mode:
29
+ return swarm_mode
30
+
31
+ # Check tmux
32
+ try:
33
+ r = subprocess.run(
34
+ ["tmux", "list-sessions", "-F", "#{session_name}"],
35
+ capture_output=True,
36
+ text=True,
37
+ timeout=5,
38
+ )
39
+ if r.returncode == 0:
40
+ for line in r.stdout.splitlines():
41
+ if line.startswith(f"{prefix}-"):
42
+ return "tmux"
43
+ except (subprocess.TimeoutExpired, OSError):
44
+ pass
45
+
46
+ # Check screen
47
+ try:
48
+ r = subprocess.run(
49
+ ["screen", "-list"],
50
+ capture_output=True,
51
+ text=True,
52
+ timeout=5,
53
+ )
54
+ if f".{prefix}-" in (r.stdout + r.stderr):
55
+ return "screen"
56
+ except (subprocess.TimeoutExpired, OSError):
57
+ pass
58
+
59
+ if shutil.which("tmux"):
60
+ return "tmux"
61
+ elif shutil.which("screen"):
62
+ return "screen"
63
+ return "none"
64
+
65
+
66
+ def send_tmux(prefix: str, name: str, message: str) -> bool:
67
+ """Inject message into a tmux session. Returns True on success."""
68
+ sess = f"{prefix}-{name}"
69
+ try:
70
+ r = subprocess.run(
71
+ ["tmux", "has-session", "-t", sess],
72
+ capture_output=True,
73
+ timeout=5,
74
+ )
75
+ if r.returncode != 0:
76
+ print(f" {name}: not running")
77
+ return False
78
+ except (subprocess.TimeoutExpired, OSError):
79
+ print(f" {name}: not running")
80
+ return False
81
+
82
+ oneline = message.replace("\n", " ")
83
+ subprocess.run(["tmux", "send-keys", "-t", sess, "-l", oneline], timeout=5)
84
+ subprocess.run(["tmux", "send-keys", "-t", sess, "Enter"], timeout=5)
85
+ print(f" {name}: injected (tmux)")
86
+ return True
87
+
88
+
89
+ def send_screen(prefix: str, name: str, message: str) -> bool:
90
+ """Inject message into a screen session. Returns True on success."""
91
+ sess = f"{prefix}-{name}"
92
+ try:
93
+ r = subprocess.run(
94
+ ["screen", "-list"],
95
+ capture_output=True,
96
+ text=True,
97
+ timeout=5,
98
+ )
99
+ if f".{sess}" not in (r.stdout + r.stderr):
100
+ print(f" {name}: not running")
101
+ return False
102
+ except (subprocess.TimeoutExpired, OSError):
103
+ print(f" {name}: not running")
104
+ return False
105
+
106
+ oneline = message.replace("\n", " ")
107
+ subprocess.run(
108
+ ["screen", "-S", sess, "-p", "0", "-X", "stuff", oneline],
109
+ timeout=5,
110
+ )
111
+ import time
112
+
113
+ time.sleep(0.3)
114
+ subprocess.run(
115
+ ["screen", "-S", sess, "-p", "0", "-X", "stuff", "\r"],
116
+ timeout=5,
117
+ )
118
+ print(f" {name}: injected (screen)")
119
+ return True
120
+
121
+
122
+ def inject(target: str, message: str, prefix: str | None = None, sessions: list | None = None) -> None:
123
+ """Inject a message to *target* (a session name or 'all')."""
124
+ if prefix is None or sessions is None:
125
+ env = ClaudesEnv.load()
126
+ prefix = prefix or env.prefix
127
+ sessions = sessions if sessions is not None else env.sessions
128
+
129
+ mode = detect_mode(prefix)
130
+ if mode == "none":
131
+ print("ERROR: neither tmux nor screen found", file=sys.stderr)
132
+ sys.exit(1)
133
+
134
+ send_fn = send_tmux if mode == "tmux" else send_screen
135
+ target_lower = target.lower()
136
+
137
+ if target_lower == "all":
138
+ print(f"Injecting to all ({mode}):")
139
+ for name in sessions:
140
+ send_fn(prefix, name, message)
141
+ else:
142
+ send_fn(prefix, target_lower, message)
143
+
144
+
145
+ def main() -> None:
146
+ if len(sys.argv) < 3:
147
+ print(
148
+ "Usage: ./inject.py <target> <message>\n"
149
+ "\n"
150
+ " target: session name or 'all'\n"
151
+ "\n"
152
+ "Examples:\n"
153
+ ' ./inject.py alice "what\'s blocking P0?"\n'
154
+ ' ./inject.py all "everyone check inbox"',
155
+ )
156
+ sys.exit(1)
157
+
158
+ target = sys.argv[1]
159
+ message = " ".join(sys.argv[2:])
160
+ inject(target, message)
161
+
162
+
163
+ if __name__ == "__main__":
164
+ main()
package/lib/migrate.py ADDED
@@ -0,0 +1,109 @@
1
+ """migrate — schema migration runner for cnb.
2
+
3
+ Tracks applied migrations in the `meta` table (key='schema_version').
4
+ Migrations live in the `migrations/` directory alongside schema.sql.
5
+ """
6
+
7
+ import sqlite3
8
+ import sys
9
+ from pathlib import Path
10
+
11
+
12
+ def _migrations_dir(claudes_home: Path) -> Path:
13
+ d = claudes_home / "migrations"
14
+ if not d.is_dir():
15
+ print(f"FATAL: migrations directory not found: {d}", file=sys.stderr)
16
+ raise SystemExit(1)
17
+ return d
18
+
19
+
20
+ def _applied_versions(c: sqlite3.Connection) -> set[int]:
21
+ """Return the set of already-applied migration version numbers."""
22
+ try:
23
+ row = c.execute("SELECT value FROM meta WHERE key='schema_version'").fetchone()
24
+ if row:
25
+ return set(range(1, int(row[0]) + 1))
26
+ except sqlite3.OperationalError:
27
+ pass # meta table may not exist yet (first init)
28
+ return set()
29
+
30
+
31
+ def _record_version(c: sqlite3.Connection, version: int) -> None:
32
+ c.execute(
33
+ "INSERT OR REPLACE INTO meta(key, value) VALUES ('schema_version', ?)",
34
+ (str(version),),
35
+ )
36
+
37
+
38
+ def _discover_migrations(mdir: Path) -> list[tuple[int, Path]]:
39
+ """Find migration files named NNN_description.sql, sorted by version."""
40
+ migs: list[tuple[int, Path]] = []
41
+ for f in mdir.glob("*.sql"):
42
+ prefix = f.name[:3]
43
+ if prefix.isdigit():
44
+ migs.append((int(prefix), f))
45
+ migs.sort()
46
+ return migs
47
+
48
+
49
+ def run_migrations(db_path: Path, claudes_home: Path) -> int:
50
+ """Apply all pending migrations. Returns number of migrations applied."""
51
+ mdir = _migrations_dir(claudes_home)
52
+ conn = sqlite3.connect(str(db_path))
53
+ conn.execute("PRAGMA journal_mode=WAL")
54
+ conn.execute("PRAGMA foreign_keys=ON")
55
+
56
+ applied = 0
57
+ try:
58
+ done = _applied_versions(conn)
59
+ all_migs = _discover_migrations(mdir)
60
+
61
+ for ver, path in all_migs:
62
+ if ver in done:
63
+ continue
64
+ sql = path.read_text()
65
+ print(f" Applying migration {path.name} ...", end=" ", flush=True)
66
+ conn.executescript(sql)
67
+ _record_version(conn, ver)
68
+ conn.commit()
69
+ print("OK")
70
+ applied += 1
71
+
72
+ except Exception as e:
73
+ conn.rollback()
74
+ print(f"\nFATAL: migration failed: {e}", file=sys.stderr)
75
+ raise SystemExit(1) from e
76
+ finally:
77
+ conn.close()
78
+
79
+ return applied
80
+
81
+
82
+ # ---------------------------------------------------------------------------
83
+ # CLI
84
+ # ---------------------------------------------------------------------------
85
+
86
+ def main() -> None:
87
+ """Run pending migrations. Called from init and doctor."""
88
+ # Resolve CLAUDES_HOME relative to this file
89
+ claudes_home = Path(__file__).resolve().parent.parent
90
+ db_path = claudes_home / ".claudes" / "board.db"
91
+
92
+ # Prefer the DB path from the current project
93
+ from pathlib import Path as _P
94
+
95
+ cwd_db = _P.cwd() / ".claudes" / "board.db"
96
+ if cwd_db.exists():
97
+ db_path = cwd_db
98
+
99
+ if not db_path.exists():
100
+ print("ERROR: board.db not found. Run: cnb init <sessions>", flush=True)
101
+ raise SystemExit(1)
102
+
103
+ n = run_migrations(db_path, claudes_home)
104
+ if n > 0:
105
+ print(f"Applied {n} migration(s).")
106
+
107
+
108
+ if __name__ == "__main__":
109
+ main()