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.
- package/LICENSE +38 -0
- package/Makefile +60 -0
- package/README.md +63 -0
- package/VERSION +1 -0
- package/bin/_pip_entry.py +25 -0
- package/bin/board +287 -0
- package/bin/cnb +150 -0
- package/bin/cnb.js +33 -0
- package/bin/dispatcher +151 -0
- package/bin/dispatcher-watchdog +57 -0
- package/bin/doctor +328 -0
- package/bin/init +316 -0
- package/bin/registry +347 -0
- package/bin/swarm +896 -0
- package/lib/__init__.py +1 -0
- package/lib/board_admin.py +128 -0
- package/lib/board_bbs.py +99 -0
- package/lib/board_bug.py +161 -0
- package/lib/board_db.py +262 -0
- package/lib/board_lock.py +113 -0
- package/lib/board_mailbox.py +145 -0
- package/lib/board_maintenance.py +237 -0
- package/lib/board_msg.py +230 -0
- package/lib/board_task.py +200 -0
- package/lib/board_view.py +366 -0
- package/lib/board_vote.py +164 -0
- package/lib/build_lock.py +221 -0
- package/lib/cli.py +34 -0
- package/lib/common.py +285 -0
- package/lib/concerns/__init__.py +42 -0
- package/lib/concerns/adaptive_throttle.py +26 -0
- package/lib/concerns/base.py +25 -0
- package/lib/concerns/bug_sla_checker.py +32 -0
- package/lib/concerns/config.py +22 -0
- package/lib/concerns/coral_manager.py +61 -0
- package/lib/concerns/coral_poker.py +57 -0
- package/lib/concerns/file_watcher.py +127 -0
- package/lib/concerns/health_checker.py +72 -0
- package/lib/concerns/helpers.py +152 -0
- package/lib/concerns/idle_detector.py +56 -0
- package/lib/concerns/idle_killer.py +41 -0
- package/lib/concerns/idle_nudger.py +38 -0
- package/lib/concerns/inbox_nudger.py +34 -0
- package/lib/concerns/resource_monitor.py +47 -0
- package/lib/concerns/session_keepalive.py +23 -0
- package/lib/concerns/time_announcer.py +34 -0
- package/lib/crypto.py +92 -0
- package/lib/health.py +187 -0
- package/lib/inject.py +164 -0
- package/lib/migrate.py +109 -0
- package/lib/monitor.py +373 -0
- package/lib/panel.py +137 -0
- package/lib/resources.py +341 -0
- package/migrations/001_foreign_keys.sql +77 -0
- package/migrations/002_session_persona.sql +1 -0
- package/migrations/003_mailbox.sql +9 -0
- package/package.json +28 -0
- package/pyproject.toml +71 -0
- package/registry/0001-meridian.json +12 -0
- package/registry/0002-forge.json +12 -0
- package/registry/0003-lead.json +12 -0
- package/registry/0004-ms-encrypted-mailbox-live.json +12 -0
- package/registry/GENESIS.json +9 -0
- package/registry/pubkeys.json +5 -0
- package/schema.sql +138 -0
package/bin/dispatcher
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""dispatcher — Session keepalive + health monitoring daemon.
|
|
3
|
+
|
|
4
|
+
Concerns (each with independent check intervals):
|
|
5
|
+
CoralManager — dispatcher Claude session lifecycle
|
|
6
|
+
SessionKeepAlive — detect dead dev sessions
|
|
7
|
+
IdleDetector — batch screen snapshot comparison
|
|
8
|
+
IdleKiller — kill sessions idle >30min
|
|
9
|
+
IdleNudger — nudge idle sessions to continue working
|
|
10
|
+
InboxNudger — detect unread inboxes, nudge sessions
|
|
11
|
+
CoralPoker — periodic heartbeat to dispatcher session
|
|
12
|
+
HealthChecker — periodic full status report + team idle detection
|
|
13
|
+
BugSLAChecker — check overdue bugs
|
|
14
|
+
ResourceMonitor — battery/memory/CPU
|
|
15
|
+
TimeAnnouncer — hourly/daily announcements
|
|
16
|
+
FileWatcher — kqueue-based instant inbox detection (background thread)
|
|
17
|
+
AdaptiveThrottle — slow down main loop when CPU is high
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
import signal
|
|
22
|
+
import sys
|
|
23
|
+
import time
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
_self = Path(__file__).resolve()
|
|
27
|
+
CLAUDES_HOME = _self.parent.parent
|
|
28
|
+
sys.path.insert(0, str(CLAUDES_HOME))
|
|
29
|
+
|
|
30
|
+
from lib.common import ClaudesEnv
|
|
31
|
+
from lib.concerns import (
|
|
32
|
+
AdaptiveThrottle,
|
|
33
|
+
BugSLAChecker,
|
|
34
|
+
Concern,
|
|
35
|
+
CoralManager,
|
|
36
|
+
CoralPoker,
|
|
37
|
+
DispatcherConfig,
|
|
38
|
+
FileWatcher,
|
|
39
|
+
HealthChecker,
|
|
40
|
+
IdleDetector,
|
|
41
|
+
IdleKiller,
|
|
42
|
+
IdleNudger,
|
|
43
|
+
InboxNudger,
|
|
44
|
+
ResourceMonitor,
|
|
45
|
+
SessionKeepAlive,
|
|
46
|
+
TimeAnnouncer,
|
|
47
|
+
log,
|
|
48
|
+
tmux_ok,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
# Configuration
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
env = ClaudesEnv.load()
|
|
56
|
+
DISPATCHER_SESSION = os.environ.get("DISPATCHER_SESSION", "dispatcher")
|
|
57
|
+
|
|
58
|
+
cfg = DispatcherConfig(
|
|
59
|
+
prefix=env.prefix,
|
|
60
|
+
project_root=env.project_root,
|
|
61
|
+
claudes_dir=env.claudes_dir,
|
|
62
|
+
sessions_dir=env.sessions_dir,
|
|
63
|
+
board_db=env.board_db,
|
|
64
|
+
suspended_file=env.suspended_file,
|
|
65
|
+
board_sh=str(CLAUDES_HOME / "bin" / "board"),
|
|
66
|
+
coral_sess=f"{env.prefix}-{DISPATCHER_SESSION}",
|
|
67
|
+
dispatcher_session=DISPATCHER_SESSION,
|
|
68
|
+
log_dir=env.claudes_dir / "logs",
|
|
69
|
+
okr_dir=env.claudes_dir / "okr",
|
|
70
|
+
dev_sessions=sorted(sf.stem for sf in env.sessions_dir.glob("*.md") if sf.stem != "dispatcher"),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
cfg.log_dir.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
|
|
75
|
+
for _p in [cfg.sessions_dir, cfg.claudes_dir]:
|
|
76
|
+
if not _p.exists():
|
|
77
|
+
print(f"FATAL: missing {_p}", file=sys.stderr)
|
|
78
|
+
sys.exit(1)
|
|
79
|
+
if not Path(cfg.board_sh).exists():
|
|
80
|
+
print(f"FATAL: missing {cfg.board_sh}", file=sys.stderr)
|
|
81
|
+
sys.exit(1)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
# Main loop
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def main() -> None:
|
|
90
|
+
base_interval = 2
|
|
91
|
+
|
|
92
|
+
coral = CoralManager(cfg)
|
|
93
|
+
idle = IdleDetector(cfg)
|
|
94
|
+
poker = CoralPoker(cfg)
|
|
95
|
+
inbox = InboxNudger(cfg)
|
|
96
|
+
throttle = AdaptiveThrottle()
|
|
97
|
+
file_watcher = FileWatcher(cfg, inbox)
|
|
98
|
+
|
|
99
|
+
# Order matters: idle must tick before idle_killer / idle_nudger
|
|
100
|
+
concerns: list[Concern] = [
|
|
101
|
+
coral,
|
|
102
|
+
TimeAnnouncer(cfg),
|
|
103
|
+
idle,
|
|
104
|
+
SessionKeepAlive(cfg),
|
|
105
|
+
IdleKiller(cfg, idle, coral),
|
|
106
|
+
inbox,
|
|
107
|
+
IdleNudger(cfg, idle),
|
|
108
|
+
poker,
|
|
109
|
+
BugSLAChecker(cfg, poker),
|
|
110
|
+
HealthChecker(cfg, poker, coral),
|
|
111
|
+
ResourceMonitor(cfg),
|
|
112
|
+
file_watcher,
|
|
113
|
+
throttle,
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
log(f"Starting (base_interval:{base_interval}s concerns:{len(concerns)})")
|
|
117
|
+
log("Ctrl-C to stop")
|
|
118
|
+
|
|
119
|
+
file_watcher.start()
|
|
120
|
+
|
|
121
|
+
running = True
|
|
122
|
+
|
|
123
|
+
def _shutdown(signum, frame):
|
|
124
|
+
nonlocal running
|
|
125
|
+
running = False
|
|
126
|
+
|
|
127
|
+
signal.signal(signal.SIGINT, _shutdown)
|
|
128
|
+
signal.signal(signal.SIGTERM, _shutdown)
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
while running:
|
|
132
|
+
now = int(time.time())
|
|
133
|
+
|
|
134
|
+
if not tmux_ok("has-session", "-t", cfg.coral_sess):
|
|
135
|
+
log("Coral session gone. Shutting down.")
|
|
136
|
+
break
|
|
137
|
+
|
|
138
|
+
for c in concerns:
|
|
139
|
+
c.maybe_tick(now)
|
|
140
|
+
|
|
141
|
+
deadline = time.time() + base_interval * throttle.multiplier
|
|
142
|
+
while running and time.time() < deadline:
|
|
143
|
+
time.sleep(min(0.5, max(0, deadline - time.time())))
|
|
144
|
+
finally:
|
|
145
|
+
log("Shutting down...")
|
|
146
|
+
file_watcher.stop()
|
|
147
|
+
log("Stopped.")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
if __name__ == "__main__":
|
|
151
|
+
main()
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""dispatcher-watchdog — Keeps dispatcher running, restarts on crash.
|
|
3
|
+
|
|
4
|
+
Usage: nohup ./dispatcher-watchdog > /tmp/dispatcher.log 2>&1 &
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
# Resolve CLAUDES_HOME
|
|
14
|
+
_self = Path(__file__).resolve()
|
|
15
|
+
CLAUDES_HOME = _self.parent.parent
|
|
16
|
+
|
|
17
|
+
DISPATCHER = CLAUDES_HOME / "bin" / "dispatcher"
|
|
18
|
+
MAX_RESTARTS = 50
|
|
19
|
+
COOLDOWN = 5
|
|
20
|
+
CRASH_WINDOW = 60
|
|
21
|
+
LOG_TAG = "[watchdog]"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def log(msg: str) -> None:
|
|
25
|
+
ts = datetime.now().strftime("%H:%M:%S")
|
|
26
|
+
print(f"{LOG_TAG} {ts} {msg}", flush=True)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def main() -> None:
|
|
30
|
+
restart_count = 0
|
|
31
|
+
last_crash = 0.0
|
|
32
|
+
|
|
33
|
+
log(f"Starting watchdog for dispatcher (max restarts: {MAX_RESTARTS})")
|
|
34
|
+
|
|
35
|
+
while True:
|
|
36
|
+
log(f"Launching dispatcher (restart #{restart_count})")
|
|
37
|
+
result = subprocess.run([sys.executable, str(DISPATCHER)])
|
|
38
|
+
exit_code = result.returncode
|
|
39
|
+
now = time.time()
|
|
40
|
+
|
|
41
|
+
log(f"dispatcher exited with code {exit_code}")
|
|
42
|
+
|
|
43
|
+
if (now - last_crash) < CRASH_WINDOW:
|
|
44
|
+
restart_count += 1
|
|
45
|
+
if restart_count >= MAX_RESTARTS:
|
|
46
|
+
log(f"FATAL: {MAX_RESTARTS} rapid restarts within {CRASH_WINDOW}s window. Giving up.")
|
|
47
|
+
sys.exit(1)
|
|
48
|
+
log(f"Rapid restart detected (#{restart_count}/{MAX_RESTARTS}), cooling down {COOLDOWN}s...")
|
|
49
|
+
time.sleep(COOLDOWN)
|
|
50
|
+
else:
|
|
51
|
+
restart_count = 0
|
|
52
|
+
|
|
53
|
+
last_crash = now
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
if __name__ == "__main__":
|
|
57
|
+
main()
|
package/bin/doctor
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""cnb doctor — system health check.
|
|
3
|
+
|
|
4
|
+
Checks environment, database integrity, config, and session status.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import sqlite3
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
# Resolve CLAUDES_HOME
|
|
15
|
+
_self = Path(__file__).resolve()
|
|
16
|
+
CLAUDES_HOME = _self.parent.parent
|
|
17
|
+
sys.path.insert(0, str(CLAUDES_HOME))
|
|
18
|
+
|
|
19
|
+
from lib.common import ClaudesEnv
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _ok(msg: str) -> None:
|
|
23
|
+
print(f" ✓ {msg}")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _warn(msg: str) -> None:
|
|
27
|
+
print(f" ⚠ {msg}")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _err(msg: str) -> None:
|
|
31
|
+
print(f" ✗ {msg}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
# Environment checks
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def check_python() -> bool:
|
|
40
|
+
v = sys.version_info
|
|
41
|
+
if v >= (3, 11):
|
|
42
|
+
_ok(f"Python {v.major}.{v.minor}.{v.micro}")
|
|
43
|
+
return True
|
|
44
|
+
_err(f"Python {v.major}.{v.minor} < 3.11 required")
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def check_tmux() -> bool:
|
|
49
|
+
if shutil.which("tmux"):
|
|
50
|
+
r = subprocess.run(["tmux", "-V"], capture_output=True, text=True)
|
|
51
|
+
_ok(f"tmux {r.stdout.strip()}")
|
|
52
|
+
return True
|
|
53
|
+
_err("tmux not found (brew install tmux)")
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def check_git() -> bool:
|
|
58
|
+
if shutil.which("git"):
|
|
59
|
+
r = subprocess.run(["git", "--version"], capture_output=True, text=True)
|
|
60
|
+
_ok(r.stdout.strip())
|
|
61
|
+
return True
|
|
62
|
+
_err("git not found")
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def check_claude_cli() -> bool:
|
|
67
|
+
if shutil.which("claude"):
|
|
68
|
+
_ok("claude CLI found")
|
|
69
|
+
return True
|
|
70
|
+
_warn("claude CLI not found (npm install -g @anthropic-ai/claude-code)")
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def check_disk(path: Path) -> bool:
|
|
75
|
+
try:
|
|
76
|
+
stat = os.statvfs(path)
|
|
77
|
+
free_gb = (stat.f_frsize * stat.f_bavail) / (1024**3)
|
|
78
|
+
if free_gb > 1:
|
|
79
|
+
_ok(f"Disk free: {free_gb:.1f} GB")
|
|
80
|
+
return True
|
|
81
|
+
_warn(f"Low disk space: {free_gb:.1f} GB")
|
|
82
|
+
return False
|
|
83
|
+
except OSError:
|
|
84
|
+
_warn("Cannot check disk space")
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
# Database checks
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def check_db(path: Path) -> bool:
|
|
94
|
+
if not path.exists():
|
|
95
|
+
_err(f"board.db not found at {path}")
|
|
96
|
+
return False
|
|
97
|
+
_ok(f"board.db found ({path.stat().st_size} bytes)")
|
|
98
|
+
return True
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def check_foreign_keys(path: Path) -> bool:
|
|
102
|
+
"""Check that foreign key constraints exist in table definitions."""
|
|
103
|
+
try:
|
|
104
|
+
conn = sqlite3.connect(str(path))
|
|
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()
|
|
109
|
+
conn.close()
|
|
110
|
+
if rows:
|
|
111
|
+
_ok(f"Foreign keys defined ({len(rows)} tables)")
|
|
112
|
+
return True
|
|
113
|
+
_warn("No foreign key constraints defined")
|
|
114
|
+
return False
|
|
115
|
+
except sqlite3.Error as e:
|
|
116
|
+
_err(f"FK check failed: {e}")
|
|
117
|
+
return False
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def check_orphans(path: Path) -> bool:
|
|
121
|
+
"""Detect orphaned rows that violate referential integrity."""
|
|
122
|
+
ok = True
|
|
123
|
+
try:
|
|
124
|
+
conn = sqlite3.connect(str(path))
|
|
125
|
+
conn.row_factory = sqlite3.Row
|
|
126
|
+
|
|
127
|
+
# inbox referencing missing sessions
|
|
128
|
+
orphans = conn.execute(
|
|
129
|
+
"SELECT COUNT(*) FROM inbox WHERE session NOT IN (SELECT name FROM sessions)"
|
|
130
|
+
).fetchone()[0]
|
|
131
|
+
if orphans:
|
|
132
|
+
_warn(f"{orphans} inbox rows reference missing sessions")
|
|
133
|
+
ok = False
|
|
134
|
+
|
|
135
|
+
# inbox referencing missing messages
|
|
136
|
+
orphans = conn.execute(
|
|
137
|
+
"SELECT COUNT(*) FROM inbox WHERE message_id NOT IN (SELECT id FROM messages)"
|
|
138
|
+
).fetchone()[0]
|
|
139
|
+
if orphans:
|
|
140
|
+
_warn(f"{orphans} inbox rows reference missing messages")
|
|
141
|
+
ok = False
|
|
142
|
+
|
|
143
|
+
# votes referencing missing proposals
|
|
144
|
+
orphans = conn.execute(
|
|
145
|
+
"SELECT COUNT(*) FROM votes WHERE proposal_id NOT IN (SELECT id FROM proposals)"
|
|
146
|
+
).fetchone()[0]
|
|
147
|
+
if orphans:
|
|
148
|
+
_warn(f"{orphans} votes reference missing proposals")
|
|
149
|
+
ok = False
|
|
150
|
+
|
|
151
|
+
# tasks referencing missing sessions
|
|
152
|
+
orphans = conn.execute(
|
|
153
|
+
"SELECT COUNT(*) FROM tasks WHERE session NOT IN (SELECT name FROM sessions)"
|
|
154
|
+
).fetchone()[0]
|
|
155
|
+
if orphans:
|
|
156
|
+
_warn(f"{orphans} tasks reference missing sessions")
|
|
157
|
+
ok = False
|
|
158
|
+
|
|
159
|
+
# suspended referencing missing sessions
|
|
160
|
+
orphans = conn.execute(
|
|
161
|
+
"SELECT COUNT(*) FROM suspended WHERE name NOT IN (SELECT name FROM sessions)"
|
|
162
|
+
).fetchone()[0]
|
|
163
|
+
if orphans:
|
|
164
|
+
_warn(f"{orphans} suspended entries reference missing sessions")
|
|
165
|
+
ok = False
|
|
166
|
+
|
|
167
|
+
# thread_replies referencing missing threads
|
|
168
|
+
orphans = conn.execute(
|
|
169
|
+
"SELECT COUNT(*) FROM thread_replies WHERE thread_id NOT IN (SELECT id FROM threads)"
|
|
170
|
+
).fetchone()[0]
|
|
171
|
+
if orphans:
|
|
172
|
+
_warn(f"{orphans} replies reference missing threads")
|
|
173
|
+
ok = False
|
|
174
|
+
|
|
175
|
+
conn.close()
|
|
176
|
+
if ok:
|
|
177
|
+
_ok("No orphaned data")
|
|
178
|
+
return ok
|
|
179
|
+
except sqlite3.Error as e:
|
|
180
|
+
_err(f"Orphan check failed: {e}")
|
|
181
|
+
return False
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def check_schema_version(path: Path) -> bool:
|
|
185
|
+
try:
|
|
186
|
+
conn = sqlite3.connect(str(path))
|
|
187
|
+
row = conn.execute("SELECT value FROM meta WHERE key='schema_version'").fetchone()
|
|
188
|
+
conn.close()
|
|
189
|
+
v = row[0] if row else "unknown"
|
|
190
|
+
_ok(f"Schema version: {v}")
|
|
191
|
+
return True
|
|
192
|
+
except sqlite3.Error as e:
|
|
193
|
+
_warn(f"Schema version check failed: {e}")
|
|
194
|
+
return False
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def check_migrations_pending(env: ClaudesEnv) -> bool:
|
|
198
|
+
try:
|
|
199
|
+
from lib.migrate import run_migrations
|
|
200
|
+
|
|
201
|
+
applied = run_migrations(env.board_db, env.install_home)
|
|
202
|
+
if applied:
|
|
203
|
+
print(f"\n → Applied {applied} pending migration(s)")
|
|
204
|
+
return True
|
|
205
|
+
return True
|
|
206
|
+
except SystemExit:
|
|
207
|
+
raise
|
|
208
|
+
except Exception as e:
|
|
209
|
+
_err(f"Migration check failed: {e}")
|
|
210
|
+
return False
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# ---------------------------------------------------------------------------
|
|
214
|
+
# Config checks
|
|
215
|
+
# ---------------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def check_config(env: ClaudesEnv) -> bool:
|
|
219
|
+
ok = True
|
|
220
|
+
if env.sessions:
|
|
221
|
+
_ok(f"Sessions: {', '.join(env.sessions)}")
|
|
222
|
+
else:
|
|
223
|
+
_warn("No sessions configured")
|
|
224
|
+
ok = False
|
|
225
|
+
|
|
226
|
+
if env.prefix:
|
|
227
|
+
_ok(f"Prefix: {env.prefix}")
|
|
228
|
+
else:
|
|
229
|
+
_warn("No prefix configured")
|
|
230
|
+
ok = False
|
|
231
|
+
|
|
232
|
+
if env.install_home.exists():
|
|
233
|
+
_ok(f"Install home: {env.install_home}")
|
|
234
|
+
else:
|
|
235
|
+
_err(f"Install home not found: {env.install_home}")
|
|
236
|
+
ok = False
|
|
237
|
+
|
|
238
|
+
return ok
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# ---------------------------------------------------------------------------
|
|
242
|
+
# Session status
|
|
243
|
+
# ---------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def check_sessions(env: ClaudesEnv) -> bool:
|
|
247
|
+
running = 0
|
|
248
|
+
stopped = 0
|
|
249
|
+
suspended = set()
|
|
250
|
+
if env.suspended_file.exists():
|
|
251
|
+
suspended = set(env.suspended_file.read_text().splitlines())
|
|
252
|
+
|
|
253
|
+
for name in env.sessions:
|
|
254
|
+
sess = f"{env.prefix}-{name}"
|
|
255
|
+
r = subprocess.run(["tmux", "has-session", "-t", sess], capture_output=True)
|
|
256
|
+
if r.returncode == 0:
|
|
257
|
+
running += 1
|
|
258
|
+
elif name in suspended:
|
|
259
|
+
pass # expected — suspended sessions aren't running
|
|
260
|
+
else:
|
|
261
|
+
stopped += 1
|
|
262
|
+
|
|
263
|
+
susp_count = len(suspended)
|
|
264
|
+
status_parts = [f"{running} running"]
|
|
265
|
+
if stopped:
|
|
266
|
+
status_parts.append(f"{stopped} stopped")
|
|
267
|
+
if susp_count:
|
|
268
|
+
status_parts.append(f"{susp_count} suspended")
|
|
269
|
+
|
|
270
|
+
_ok(f"Session status: {', '.join(status_parts)}")
|
|
271
|
+
return True
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# ---------------------------------------------------------------------------
|
|
275
|
+
# Main
|
|
276
|
+
# ---------------------------------------------------------------------------
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def main() -> None:
|
|
280
|
+
try:
|
|
281
|
+
env = ClaudesEnv.load()
|
|
282
|
+
except (FileNotFoundError, SystemExit):
|
|
283
|
+
print("Doctor: project not initialized")
|
|
284
|
+
print(" Run: cnb init <session-names>")
|
|
285
|
+
print("\n--- Environment Check ---")
|
|
286
|
+
check_python()
|
|
287
|
+
check_tmux()
|
|
288
|
+
check_git()
|
|
289
|
+
check_claude_cli()
|
|
290
|
+
print("\nResult: project not initialized — run 'cnb init' first")
|
|
291
|
+
sys.exit(1)
|
|
292
|
+
|
|
293
|
+
print("=== cnb Doctor ===\n")
|
|
294
|
+
|
|
295
|
+
# ── Environment ──
|
|
296
|
+
print("── Environment ──")
|
|
297
|
+
env_ok = all([check_python(), check_tmux(), check_git(), check_claude_cli(), check_disk(env.claudes_dir)])
|
|
298
|
+
|
|
299
|
+
# ── Configuration ──
|
|
300
|
+
print("\n── Configuration ──")
|
|
301
|
+
cfg_ok = check_config(env)
|
|
302
|
+
|
|
303
|
+
# ── Database ──
|
|
304
|
+
print("\n── Database ──")
|
|
305
|
+
db_ok = check_db(env.board_db)
|
|
306
|
+
if db_ok:
|
|
307
|
+
fk_ok = check_foreign_keys(env.board_db)
|
|
308
|
+
orph_ok = check_orphans(env.board_db)
|
|
309
|
+
ver_ok = check_schema_version(env.board_db)
|
|
310
|
+
mig_ok = check_migrations_pending(env)
|
|
311
|
+
db_ok = all([fk_ok, orph_ok, ver_ok, mig_ok])
|
|
312
|
+
|
|
313
|
+
# ── Sessions ──
|
|
314
|
+
print("\n── Sessions ──")
|
|
315
|
+
sess_ok = check_sessions(env)
|
|
316
|
+
|
|
317
|
+
# ── Result ──
|
|
318
|
+
print()
|
|
319
|
+
all_ok = all([env_ok, cfg_ok, db_ok, sess_ok])
|
|
320
|
+
if all_ok:
|
|
321
|
+
print("✓ All checks passed.")
|
|
322
|
+
else:
|
|
323
|
+
print("⚠ Some checks failed — review warnings above.")
|
|
324
|
+
sys.exit(1)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
if __name__ == "__main__":
|
|
328
|
+
main()
|