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,113 @@
1
+ """board_lock — git lock coordination: git-lock / git-unlock / git-lock-status."""
2
+
3
+ import subprocess
4
+ import time
5
+
6
+ from lib.board_db import BoardDB, ts
7
+
8
+ GIT_LOCK_TTL = 60
9
+
10
+
11
+ def _cleanup_stale(db: BoardDB) -> None:
12
+ now_epoch = int(time.time())
13
+ db.execute("DELETE FROM git_locks WHERE expires_at < ?", (now_epoch,))
14
+
15
+
16
+ def cmd_git_lock(db: BoardDB, identity: str, args: list[str]) -> None:
17
+ name = identity.lower()
18
+ reason = " ".join(args) if args else "git operation"
19
+
20
+ _cleanup_stale(db)
21
+
22
+ expires = int(time.time()) + GIT_LOCK_TTL
23
+ did_insert = db.execute_changes(
24
+ "INSERT OR IGNORE INTO git_locks(id, session, reason, expires_at) VALUES (1, ?, ?, ?)",
25
+ (name, reason, expires),
26
+ )
27
+ if did_insert:
28
+ print(f"OK git-lock acquired by {name} (expires in {GIT_LOCK_TTL}s)")
29
+ return
30
+
31
+ holder = db.scalar("SELECT session FROM git_locks WHERE id=1")
32
+ if holder == name:
33
+ new_expires = int(time.time()) + GIT_LOCK_TTL
34
+ db.execute(
35
+ "UPDATE git_locks SET expires_at=?, reason=?, "
36
+ "acquired_at=strftime('%Y-%m-%d %H:%M:%S', 'now', 'localtime') WHERE id=1",
37
+ (new_expires, reason),
38
+ )
39
+ print(f"OK git-lock extended (held by you, expires in {GIT_LOCK_TTL}s)")
40
+ return
41
+
42
+ expires_at = db.scalar("SELECT expires_at FROM git_locks WHERE id=1") or 0
43
+ remaining = expires_at - int(time.time())
44
+ lock_reason = db.scalar("SELECT reason FROM git_locks WHERE id=1") or ""
45
+ print(
46
+ f"BLOCKED: git lock held by '{holder}' ({lock_reason}, expires in {remaining}s)",
47
+ )
48
+ print(f" Wait and retry, or force with: ./board --as {identity} git-unlock --force")
49
+ raise SystemExit(1)
50
+
51
+
52
+ def cmd_git_unlock(db: BoardDB, identity: str, args: list[str]) -> None:
53
+ name = identity.lower()
54
+ force = "--force" in args
55
+
56
+ _cleanup_stale(db)
57
+
58
+ holder = db.scalar("SELECT session FROM git_locks WHERE id=1")
59
+ if not holder:
60
+ print("OK git lock is already free")
61
+ return
62
+
63
+ if holder != name and not force:
64
+ print(f"ERROR: git lock held by '{holder}', not you. Use --force to override.")
65
+ raise SystemExit(1)
66
+
67
+ if holder != name and force:
68
+ print(f"WARN: force-releasing git lock held by '{holder}'")
69
+ now = ts()
70
+ msg_id = db.execute(
71
+ "INSERT INTO messages(ts, sender, recipient, body) VALUES (?, 'SYSTEM', ?, ?)",
72
+ (now, holder, f"[GIT-LOCK] {name} force-released your git lock"),
73
+ )
74
+ db.execute("INSERT INTO inbox(session, message_id) VALUES (?, ?)", (holder, msg_id))
75
+ db.sync_inbox_to_file(holder)
76
+
77
+ db.execute("DELETE FROM git_locks WHERE id=1")
78
+ print("OK git-lock released")
79
+
80
+ index_lock = db.env.project_root / ".git" / "index.lock"
81
+ if index_lock.exists():
82
+ r = subprocess.run(
83
+ ["pgrep", "-f", f"git.*{db.env.project_root}"],
84
+ capture_output=True,
85
+ )
86
+ if r.returncode != 0:
87
+ index_lock.unlink()
88
+ print(" Also removed stale .git/index.lock")
89
+ else:
90
+ print(" WARN: .git/index.lock exists and a git process is running")
91
+
92
+
93
+ def cmd_git_lock_status(db: BoardDB) -> None:
94
+ _cleanup_stale(db)
95
+
96
+ row = db.query_one("SELECT session, reason, acquired_at, expires_at FROM git_locks WHERE id=1")
97
+ if not row:
98
+ print("FREE: no session holds the git lock")
99
+ else:
100
+ holder, reason, acquired, expires_at = row
101
+ remaining = expires_at - int(time.time())
102
+ print(f"LOCKED by {holder}")
103
+ print(f" Reason: {reason}")
104
+ print(f" Acquired: {acquired}")
105
+ print(f" Expires in: {remaining}s")
106
+
107
+ index_lock = db.env.project_root / ".git" / "index.lock"
108
+ if index_lock.exists():
109
+ try:
110
+ lock_age = int(time.time()) - int(index_lock.stat().st_mtime)
111
+ except OSError:
112
+ lock_age = 0
113
+ print(f" .git/index.lock exists (age: {lock_age}s)")
@@ -0,0 +1,145 @@
1
+ """board_mailbox — encrypted async messaging between registered agents."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from lib.board_db import BoardDB, ts
7
+ from lib.crypto import (
8
+ generate_keypair,
9
+ load_private_key,
10
+ public_key_from_hex,
11
+ public_key_to_hex,
12
+ save_keypair,
13
+ seal_b64,
14
+ unseal_b64,
15
+ )
16
+
17
+ REGISTRY_DIR = Path(__file__).resolve().parent.parent / "registry"
18
+ PUBKEYS_FILE = REGISTRY_DIR / "pubkeys.json"
19
+
20
+
21
+ def _keys_dir(db: BoardDB) -> Path:
22
+ return db.env.claudes_dir / "keys"
23
+
24
+
25
+ def _load_pubkeys() -> dict[str, str]:
26
+ if PUBKEYS_FILE.exists():
27
+ return json.loads(PUBKEYS_FILE.read_text())
28
+ return {}
29
+
30
+
31
+ def _save_pubkeys(data: dict[str, str]) -> None:
32
+ PUBKEYS_FILE.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n")
33
+
34
+
35
+ def _find_pubkey(name: str) -> str | None:
36
+ """Look up public_key from pubkeys.json (separate from immutable chain blocks)."""
37
+ return _load_pubkeys().get(name)
38
+
39
+
40
+ def cmd_keygen(db: BoardDB, identity: str) -> None:
41
+ name = identity.lower()
42
+ kd = _keys_dir(db)
43
+
44
+ if (kd / f"{name}.pem").exists():
45
+ print(f"ERROR: 密钥已存在 ({kd / f'{name}.pem'}),如需重新生成请先删除旧密钥")
46
+ raise SystemExit(1)
47
+
48
+ private, public = generate_keypair()
49
+ save_keypair(kd, name, private, public)
50
+ pubkey_hex = public_key_to_hex(public)
51
+
52
+ pubkeys = _load_pubkeys()
53
+ pubkeys[name] = pubkey_hex
54
+ _save_pubkeys(pubkeys)
55
+
56
+ print("OK 密钥已生成")
57
+ print(f" 私钥: {kd / f'{name}.pem'} (勿泄露)")
58
+ print(f" 公钥: {pubkey_hex[:16]}...")
59
+ print(f" 已写入 {PUBKEYS_FILE.relative_to(REGISTRY_DIR.parent)}")
60
+
61
+
62
+ def cmd_seal(db: BoardDB, identity: str, args: list[str]) -> None:
63
+ name = identity.lower()
64
+ if len(args) < 2:
65
+ print("Usage: ./board --as <name> seal <recipient> <message>")
66
+ raise SystemExit(1)
67
+
68
+ recipient = args[0].lower()
69
+ plaintext = " ".join(args[1:])
70
+
71
+ recipient_pubkey_hex = _find_pubkey(recipient)
72
+ if not recipient_pubkey_hex:
73
+ print(f"ERROR: {recipient} 未注册公钥 (需先运行 keygen)")
74
+ raise SystemExit(1)
75
+
76
+ recipient_pub = public_key_from_hex(recipient_pubkey_hex)
77
+ encrypted = seal_b64(plaintext, recipient_pub)
78
+
79
+ now = ts()
80
+ db.execute(
81
+ "INSERT INTO mailbox(ts, sender, recipient, encrypted_body) VALUES (?, ?, ?, ?)",
82
+ (now, name, recipient, encrypted),
83
+ )
84
+ print(f"OK 加密消息已发送给 {recipient} ({len(encrypted)} bytes)")
85
+
86
+
87
+ def cmd_unseal(db: BoardDB, identity: str) -> None:
88
+ name = identity.lower()
89
+ kd = _keys_dir(db)
90
+
91
+ try:
92
+ private = load_private_key(kd, name)
93
+ except FileNotFoundError as e:
94
+ print(f"ERROR: {e}")
95
+ raise SystemExit(1)
96
+
97
+ rows = db.query(
98
+ "SELECT id, ts, sender, encrypted_body FROM mailbox WHERE recipient=? AND read=0 ORDER BY ts",
99
+ (name,),
100
+ )
101
+
102
+ if not rows:
103
+ print("加密信箱为空")
104
+ return
105
+
106
+ print(f"你有 {len(rows)} 条加密消息:\n")
107
+ decrypted_ids = []
108
+ for msg_id, msg_ts, sender, encrypted_body in rows:
109
+ try:
110
+ plaintext = unseal_b64(encrypted_body, private)
111
+ print(f" [{msg_ts}] **{sender}**: {plaintext}")
112
+ decrypted_ids.append(msg_id)
113
+ except Exception:
114
+ print(f" [{msg_ts}] **{sender}**: [解密失败 — 密钥不匹配或消息损坏]")
115
+
116
+ if decrypted_ids:
117
+ placeholders = ",".join("?" * len(decrypted_ids))
118
+ db.execute(f"UPDATE mailbox SET read=1 WHERE id IN ({placeholders})", tuple(decrypted_ids))
119
+ print(f"\n已标记 {len(decrypted_ids)} 条为已读")
120
+
121
+
122
+ def cmd_mailbox_log(db: BoardDB, identity: str) -> None:
123
+ name = identity.lower()
124
+ kd = _keys_dir(db)
125
+
126
+ try:
127
+ private = load_private_key(kd, name)
128
+ except FileNotFoundError as e:
129
+ print(f"ERROR: {e}")
130
+ raise SystemExit(1)
131
+
132
+ rows = db.query(
133
+ "SELECT ts, sender, encrypted_body FROM mailbox WHERE recipient=? ORDER BY ts DESC LIMIT 20",
134
+ (name,),
135
+ )
136
+ if not rows:
137
+ print("无加密消息记录")
138
+ return
139
+
140
+ for msg_ts, sender, encrypted_body in reversed(rows):
141
+ try:
142
+ plaintext = unseal_b64(encrypted_body, private)
143
+ print(f" [{msg_ts}] {sender}: {plaintext}")
144
+ except Exception:
145
+ print(f" [{msg_ts}] {sender}: [无法解密]")
@@ -0,0 +1,237 @@
1
+ """board_maintenance — data maintenance: prune, backup, restore."""
2
+
3
+ import shutil
4
+ import time
5
+ from pathlib import Path
6
+
7
+ from lib.board_db import BoardDB
8
+
9
+ # ---------------------------------------------------------------------------
10
+ # prune
11
+ # ---------------------------------------------------------------------------
12
+
13
+
14
+ def cmd_prune(db: BoardDB, args: list[str]) -> None:
15
+ """Prune old messages and inbox entries.
16
+
17
+ Usage: board --as <name> prune [--before YYYY-MM-DD] [--keep N] [--dry-run]
18
+ """
19
+ usage = "Usage: board --as <name> prune [--before YYYY-MM-DD] [--keep N] [--dry-run]"
20
+
21
+ before_days = 90 # default: keep 90 days
22
+ dry_run = False
23
+ positional: list[str] = []
24
+
25
+ i = 0
26
+ while i < len(args):
27
+ if args[i] == "--before" and i + 1 < len(args):
28
+ before_days = _parse_days(args[i + 1])
29
+ i += 2
30
+ elif args[i] == "--keep" and i + 1 < len(args):
31
+ try:
32
+ int(args[i + 1])
33
+ except ValueError:
34
+ print(f"ERROR: --keep requires a number, got '{args[i + 1]}'")
35
+ raise SystemExit(1)
36
+ i += 2
37
+ elif args[i] == "--dry-run":
38
+ dry_run = True
39
+ i += 1
40
+ else:
41
+ positional.append(args[i])
42
+ i += 1
43
+
44
+ if positional:
45
+ print(usage)
46
+ raise SystemExit(1)
47
+
48
+ cutoff = _days_ago_ts(before_days)
49
+
50
+ # Count what would be deleted
51
+ old_inbox = db.scalar(
52
+ "SELECT COUNT(*) FROM inbox WHERE message_id IN "
53
+ "(SELECT id FROM messages WHERE ts < ?)",
54
+ (cutoff,),
55
+ ) or 0
56
+ old_messages = db.scalar(
57
+ "SELECT COUNT(*) FROM messages WHERE ts < ?",
58
+ (cutoff,),
59
+ ) or 0
60
+
61
+ # Also count old read inbox entries (not just those linked to old messages)
62
+ old_read_inbox = db.scalar(
63
+ "SELECT COUNT(*) FROM inbox WHERE read=1 AND "
64
+ "delivered_at < ?",
65
+ (cutoff,),
66
+ ) or 0
67
+
68
+ if dry_run:
69
+ print("=== DRY RUN: would delete ===")
70
+ print(f" {old_messages} messages older than {before_days} days")
71
+ print(f" {old_inbox} inbox entries referencing old messages")
72
+ print(f" {old_read_inbox} already-read inbox entries")
73
+ total = old_messages + old_inbox + old_read_inbox
74
+ if total == 0:
75
+ print(" Nothing to prune.")
76
+ else:
77
+ print(f" Total: {total} rows")
78
+ return
79
+
80
+ total_deleted = 0
81
+
82
+ # Delete old messages (cascades to inbox via FK)
83
+ if old_messages > 0:
84
+ db.execute("DELETE FROM messages WHERE ts < ?", (cutoff,))
85
+ total_deleted += old_messages
86
+
87
+ # Delete old read inbox entries (not covered by message FK cascade)
88
+ if old_read_inbox > 0:
89
+ db.execute("DELETE FROM inbox WHERE read=1 AND delivered_at < ?", (cutoff,))
90
+ total_deleted += old_read_inbox
91
+
92
+ if total_deleted == 0:
93
+ print("OK nothing to prune")
94
+ else:
95
+ print(f"OK pruned {total_deleted} rows (messages older than {before_days} days)")
96
+
97
+
98
+ def _parse_days(arg: str) -> int:
99
+ """Parse a date or day-count argument.
100
+
101
+ Accepts: 'YYYY-MM-DD' (exact date), or integer (days ago).
102
+ """
103
+ from datetime import datetime
104
+
105
+ try:
106
+ target = datetime.strptime(arg, "%Y-%m-%d")
107
+ now = datetime.now()
108
+ return (now - target).days
109
+ except ValueError:
110
+ pass
111
+ try:
112
+ return int(arg)
113
+ except ValueError:
114
+ print(f"ERROR: invalid date/days: '{arg}'. Use YYYY-MM-DD or a number.")
115
+ raise SystemExit(1)
116
+
117
+
118
+ def _days_ago_ts(days: int) -> str:
119
+ """Return a timestamp string for *days* ago."""
120
+ from datetime import datetime, timedelta
121
+
122
+ return (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d %H:%M:%S")
123
+
124
+
125
+ # ---------------------------------------------------------------------------
126
+ # backup
127
+ # ---------------------------------------------------------------------------
128
+
129
+
130
+ def cmd_backup(db: BoardDB, args: list[str]) -> None:
131
+ """Backup board.db to a timestamped file.
132
+
133
+ Usage: board backup [--output <path>]
134
+ """
135
+ import shutil as _shutil
136
+
137
+ output: Path | None = None
138
+ for arg in args:
139
+ if arg.startswith("--output="):
140
+ output = Path(arg.split("=", 1)[1])
141
+ elif arg == "--output" and len(args) > args.index(arg) + 1:
142
+ output = Path(args[args.index(arg) + 1])
143
+ elif arg == "--help":
144
+ print("Usage: board backup [--output <path>]")
145
+ return
146
+
147
+ if output is None:
148
+ stamp = time.strftime("%Y%m%d-%H%M%S")
149
+ output = db.db_path.parent / f"backup-{stamp}.db"
150
+
151
+ _shutil.copy2(db.db_path, output)
152
+
153
+ # Verify the backup
154
+ import sqlite3
155
+
156
+ try:
157
+ conn = sqlite3.connect(str(output))
158
+ conn.execute("SELECT COUNT(*) FROM meta")
159
+ conn.close()
160
+ except sqlite3.Error as e:
161
+ print(f"ERROR: backup verification failed: {e}")
162
+ if output.exists():
163
+ output.unlink()
164
+ raise SystemExit(1)
165
+
166
+ size = output.stat().st_size
167
+ print(f"OK backup saved: {output}")
168
+ print(f" Size: {size} bytes")
169
+
170
+
171
+ # ---------------------------------------------------------------------------
172
+ # restore
173
+ # ---------------------------------------------------------------------------
174
+
175
+
176
+ def cmd_restore(db: BoardDB, args: list[str]) -> None:
177
+ """Restore board.db from a backup file.
178
+
179
+ Usage: board restore <backup-file> [--force]
180
+ """
181
+ force = False
182
+ source: Path | None = None
183
+ for arg in args:
184
+ if arg in ("--force", "-f"):
185
+ force = True
186
+ elif arg in ("--help", "-h"):
187
+ print("Usage: board restore <backup-file> [--force]")
188
+ print()
189
+ print(" --force Skip confirmation prompt")
190
+ return
191
+ else:
192
+ source = Path(arg)
193
+
194
+ if source is None:
195
+ print("Usage: board restore <backup-file> [--force]")
196
+ raise SystemExit(1)
197
+ if not source.exists():
198
+ print(f"ERROR: backup file not found: {source}")
199
+ raise SystemExit(1)
200
+
201
+ # Verify the backup
202
+ import sqlite3
203
+
204
+ try:
205
+ conn = sqlite3.connect(str(source))
206
+ conn.execute("PRAGMA integrity_check")
207
+ tables = conn.execute(
208
+ "SELECT name FROM sqlite_master WHERE type='table'"
209
+ ).fetchall()
210
+ conn.close()
211
+ if not tables:
212
+ print("ERROR: backup file contains no tables")
213
+ raise SystemExit(1)
214
+ except sqlite3.Error as e:
215
+ print(f"ERROR: invalid backup file: {e}")
216
+ raise SystemExit(1)
217
+
218
+ if not force:
219
+ print("About to restore board.db from:")
220
+ print(f" {source}")
221
+ print("This will OVERWRITE the current database.")
222
+ try:
223
+ answer = input("Continue? [y/N] ").strip().lower()
224
+ except (EOFError, KeyboardInterrupt):
225
+ print("\nCancelled.")
226
+ return
227
+ if answer not in ("y", "yes"):
228
+ print("Cancelled.")
229
+ return
230
+
231
+ # Atomic restore: copy to temp, rename
232
+ tmp = db.db_path.with_suffix(".db.restore-tmp")
233
+ shutil.copy2(source, tmp)
234
+ tmp.replace(db.db_path)
235
+
236
+ print(f"OK restored from {source}")
237
+ print(" Sessions may need to restart to pick up changes.")