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
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()