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,366 @@
1
+ """board_view — read-only views: view, dashboard, p0, dirty, prebuild, freshness, relations, history, roster, files, get."""
2
+
3
+ import glob
4
+ import os
5
+ import re
6
+ import subprocess
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+
10
+ from lib.board_db import BoardDB
11
+
12
+
13
+ def _git(project_root: Path, *args: str) -> str:
14
+ r = subprocess.run(
15
+ ["git", "-C", str(project_root), *args],
16
+ capture_output=True,
17
+ text=True,
18
+ )
19
+ return r.stdout
20
+
21
+
22
+ def _tmux_has_session(name: str) -> bool:
23
+ try:
24
+ r = subprocess.run(["tmux", "has-session", "-t", name], capture_output=True, timeout=3)
25
+ return r.returncode == 0
26
+ except (FileNotFoundError, subprocess.TimeoutExpired):
27
+ return False
28
+
29
+
30
+ def _tmux_pane_command(name: str) -> str:
31
+ try:
32
+ r = subprocess.run(
33
+ ["tmux", "list-panes", "-t", name, "-F", "#{pane_current_command}"],
34
+ capture_output=True,
35
+ text=True,
36
+ timeout=3,
37
+ )
38
+ return r.stdout.strip().split("\n")[0]
39
+ except (FileNotFoundError, subprocess.TimeoutExpired):
40
+ return ""
41
+
42
+
43
+ def _pgrep(pattern: str) -> str | None:
44
+ try:
45
+ r = subprocess.run(["pgrep", "-f", pattern], capture_output=True, text=True, timeout=3)
46
+ if r.returncode == 0:
47
+ return r.stdout.strip().split("\n")[0]
48
+ except (FileNotFoundError, subprocess.TimeoutExpired):
49
+ pass
50
+ return None
51
+
52
+
53
+ def cmd_overview(db: BoardDB) -> None:
54
+ """Default view when running cnb with no args."""
55
+ prefix = db.env.prefix
56
+ now = datetime.now().strftime("%H:%M")
57
+ print(f"=== {db.env.project_root.name} {now} ===")
58
+ print()
59
+
60
+ # ── sessions ──
61
+ for (name,) in db.query("SELECT name FROM sessions WHERE name != 'all' ORDER BY name"):
62
+ sess = f"{prefix}-{name}"
63
+ if _tmux_has_session(sess):
64
+ cmd = _tmux_pane_command(sess)
65
+ status = "● running" if cmd not in ("zsh", "bash", "sh", "-zsh", "-bash") else "○ dead"
66
+ else:
67
+ status = "· offline"
68
+
69
+ inbox = db.scalar("SELECT COUNT(*) FROM inbox WHERE session=? AND read=0", (name,)) or 0
70
+ inbox_str = f" [{inbox} msg]" if inbox else ""
71
+ task = db.scalar("SELECT status FROM sessions WHERE name=?", (name,)) or ""
72
+ if task:
73
+ task = task[:60]
74
+ else:
75
+ task = "(no status)"
76
+
77
+ line = f" {status:12s} {name:<10s} {task}"
78
+ if inbox:
79
+ line += f"{inbox_str}"
80
+ print(line)
81
+
82
+ # ── recent messages ──
83
+ rows = db.query(
84
+ "SELECT ts, sender, recipient, substr(body, 1, 80) "
85
+ "FROM messages ORDER BY id DESC LIMIT 5"
86
+ )
87
+ if rows:
88
+ print()
89
+ print("Recent:")
90
+ for ts_val, sender, recipient, body in reversed(rows):
91
+ print(f" [{ts_val}] {sender} → {recipient}: {body}")
92
+
93
+ # ── open proposals ──
94
+ proposals = db.query(
95
+ "SELECT number || '-' || slug FROM proposals WHERE status='OPEN'"
96
+ )
97
+ if proposals:
98
+ print()
99
+ print(f"Open proposals: {len(proposals)}")
100
+
101
+ # ── dispatcher ──
102
+ pid = _pgrep("dispatcher")
103
+ print()
104
+ if pid:
105
+ print(f" dispatcher: running (pid {pid})")
106
+ else:
107
+ running = any(_tmux_has_session(f"{prefix}-{n}") for (n,) in db.query("SELECT name FROM sessions WHERE name != 'all'"))
108
+ if running:
109
+ print(" dispatcher: NOT RUNNING — run: cnb dispatcher")
110
+ else:
111
+ print(" No sessions running. Start with: cnb swarm start")
112
+
113
+
114
+ def cmd_view(db: BoardDB, identity: str) -> None:
115
+ print("=== Board ===\n")
116
+
117
+ roadmap = db.env.project_root / "ROADMAP.md"
118
+ p0_locked = False
119
+ if roadmap.is_file():
120
+ text = roadmap.read_text()
121
+ m = re.search(r"端到端状态.*?(?=\n## [A-Z]|\Z)", text, re.DOTALL)
122
+ if m and re.search(r"从未|未验证|阻塞", m.group()):
123
+ p0_locked = True
124
+ print("!!! P0 LOCKED — 端到端未验证,全员聚焦 P0 !!!")
125
+ print(" 运行 ./board p0 查看详情\n")
126
+
127
+ if identity:
128
+ me = identity.lower()
129
+ count = db.scalar("SELECT COUNT(*) FROM inbox WHERE session=? AND read=0", (me,))
130
+ if count:
131
+ print(f">>> 你有 {count} 条未读消息,运行 ./board inbox 查看 <<<\n")
132
+
133
+ print("Status:")
134
+ for name, task in db.query("SELECT name, status FROM sessions ORDER BY name"):
135
+ cap = name[0].upper() + name[1:] if name else name
136
+ task = task or "(none)"
137
+ tag = ""
138
+ if p0_locked and "[P0]" not in task:
139
+ tag = " [!! 未标 P0]"
140
+ if len(task) > 72:
141
+ task = task[:69] + "..."
142
+ print(f" {cap:<8s} {task}{tag}")
143
+ print()
144
+
145
+ print("Recent messages:")
146
+ rows = db.query(
147
+ "SELECT '[' || ts || '] ' || sender || ' → ' || recipient || ': ' || substr(body, 1, 80) "
148
+ "FROM messages ORDER BY id DESC LIMIT 8"
149
+ )
150
+ for (line,) in reversed(rows):
151
+ print(f" {line}")
152
+ print()
153
+
154
+ print("Proposals:")
155
+ rows = db.query(
156
+ "SELECT number || '-' || slug, status, "
157
+ "(SELECT COUNT(*) FROM votes v WHERE v.proposal_id=p.id AND v.decision='SUPPORT'), "
158
+ "(SELECT COUNT(*) FROM votes v WHERE v.proposal_id=p.id AND v.decision='OBJECT') "
159
+ "FROM proposals p WHERE status='OPEN'"
160
+ )
161
+ if not rows:
162
+ print(" (none)")
163
+ else:
164
+ for pname, _, s, o in rows:
165
+ print(f" {pname} [OPEN] S={s} O={o}")
166
+
167
+
168
+ def cmd_p0(db: BoardDB) -> None:
169
+ roadmap = db.env.project_root / "ROADMAP.md"
170
+ if not roadmap.is_file():
171
+ print("ERROR: ROADMAP.md not found")
172
+ raise SystemExit(1)
173
+
174
+ text = roadmap.read_text()
175
+ m = re.search(r"端到端状态(.*?)(?=\n## [A-Z]|\Z)", text, re.DOTALL)
176
+ status_block = m.group() if m else ""
177
+ locked = bool(re.search(r"从未|未验证|阻塞", status_block))
178
+
179
+ if locked:
180
+ print("=== P0 LOCKED ===\n")
181
+ print("Status from ROADMAP.md:")
182
+ for line in status_block.split("\n"):
183
+ print(f" {line}")
184
+ print("\nSession alignment:")
185
+ for name, task in db.query("SELECT name, status FROM sessions ORDER BY name"):
186
+ cap = name[0].upper() + name[1:] if name else name
187
+ task = task or "(no status)"
188
+ tag = "[OK]" if "[P0]" in task else "[!!]"
189
+ print(f" {cap:<8s} {tag} {task}")
190
+ else:
191
+ print("=== P0 CLEAR ===")
192
+ print("No active P0 blocker. Normal work allowed.")
193
+
194
+
195
+ def cmd_prebuild(db: BoardDB) -> None:
196
+ print("=== Pre-build Check ===\n")
197
+ has_fail = False
198
+ pr = db.env.project_root
199
+
200
+ dirty = _git(pr, "status", "--porcelain")
201
+ code = "\n".join(l for l in dirty.splitlines() if not l.startswith("??") and "board/" not in l)
202
+ if code:
203
+ print("FAIL: uncommitted code changes:")
204
+ for l in code.splitlines():
205
+ print(f" {l}")
206
+ has_fail = True
207
+ else:
208
+ print("OK: working tree clean (code files)")
209
+
210
+ print("\nLast 3 commits:")
211
+ log = _git(pr, "log", "--oneline", "-3")
212
+ for l in log.splitlines():
213
+ print(f" {l}")
214
+ print()
215
+ if has_fail:
216
+ print("NOT ready to build. Fix issues above first.")
217
+ raise SystemExit(1)
218
+ print("Ready to build.")
219
+
220
+
221
+ def cmd_dirty(db: BoardDB) -> None:
222
+ print("=== Uncommitted Changes ===\n")
223
+ pr = db.env.project_root
224
+ changes = _git(pr, "status", "--porcelain").strip()
225
+ if not changes:
226
+ print("Working tree clean.")
227
+ return
228
+ code = "\n".join(l for l in changes.splitlines() if "board/" not in l)
229
+ if code:
230
+ print("Code:")
231
+ for l in code.splitlines():
232
+ print(f" {l}")
233
+ print()
234
+ board = "\n".join(l for l in changes.splitlines() if "board/" in l)
235
+ if board:
236
+ print(f"Board: {len(board.splitlines())} files (normal churn)")
237
+ print()
238
+ log = _git(pr, "log", "--oneline", "-1").strip()
239
+ print(f"Last commit: {log}")
240
+
241
+
242
+ def cmd_dashboard(db: BoardDB) -> None:
243
+ prefix = db.env.prefix
244
+ print(f"=== Team Dashboard {datetime.now().strftime('%H:%M')} ===\n")
245
+ for (name,) in db.query("SELECT name FROM sessions ORDER BY name"):
246
+ session_name = f"{prefix}-{name}"
247
+ status = "offline"
248
+ if _tmux_has_session(session_name):
249
+ cmd = _tmux_pane_command(session_name)
250
+ if cmd in ("zsh", "bash", "sh", "-zsh", "-bash"):
251
+ status = "DEAD"
252
+ else:
253
+ status = "running"
254
+
255
+ inbox_count = db.scalar("SELECT COUNT(*) FROM inbox WHERE session=? AND read=0", (name,))
256
+ inbox_str = f" [{inbox_count}msg]" if inbox_count else ""
257
+ task = db.scalar("SELECT substr(status, 1, 50) FROM sessions WHERE name=?", (name,)) or "-"
258
+ print(f" {name:<7s} {status:<8s}{inbox_str}")
259
+ print(f" {task}")
260
+ print()
261
+ pid = _pgrep("dispatcher")
262
+ if pid:
263
+ print(f" dispatcher: running (PID {pid})")
264
+ else:
265
+ print(" dispatcher: NOT RUNNING")
266
+
267
+
268
+ def cmd_files(db: BoardDB) -> None:
269
+ print("=== 共享文件 ===\n")
270
+ rows = db.query("SELECT hash, original_name, sender, ts FROM files ORDER BY ts DESC")
271
+ if not rows:
272
+ print(" (none)")
273
+ else:
274
+ for h, orig, sender, date in rows:
275
+ size = 0
276
+ for f in glob.glob(str(db.env.claudes_dir / "files" / f"{h}.*")):
277
+ if os.path.isfile(f):
278
+ size = os.path.getsize(f)
279
+ break
280
+ print(f" {h:<14s} {orig:<30s} {size:>6d} bytes by {sender:<6s} {date}")
281
+ print("\n查看文件: ./board get <hash前缀或文件名>")
282
+
283
+
284
+ def cmd_get(db: BoardDB, args: list[str]) -> None:
285
+ if not args:
286
+ print("Usage: ./board get <hash-prefix|filename>")
287
+ raise SystemExit(1)
288
+ query = args[0]
289
+ row = db.query_one(
290
+ "SELECT hash, original_name, sender, ts, stored_path FROM files "
291
+ "WHERE hash LIKE ? ESCAPE '\\' OR original_name=? LIMIT 1",
292
+ (query + "%", query),
293
+ )
294
+ if not row:
295
+ print(f"ERROR: no file matching '{query}'")
296
+ raise SystemExit(1)
297
+ h, orig, sender, date, path = row
298
+ print("--- 文件信息 ---")
299
+ print(f" Name: {orig}")
300
+ print(f" Hash: {h}")
301
+ print(f" Sender: {sender}")
302
+ print(f" Date: {date}")
303
+ print("\n--- 内容 ---")
304
+ full_path = db.env.claudes_dir / path
305
+ if full_path.is_file():
306
+ print(full_path.read_text(), end="")
307
+ else:
308
+ print("(file content not on disk)")
309
+
310
+
311
+ def cmd_freshness(db: BoardDB) -> None:
312
+ print("=== 数据新鲜度 ===\n")
313
+ print(f" {'Session':<8s} {'Last status update':<20s} {'Unread inbox'}")
314
+ print(f" {'-------':<8s} {'------------------':<20s} {'------------'}")
315
+ rows = db.query(
316
+ "SELECT s.name, s.updated_at, "
317
+ "(SELECT COUNT(*) FROM inbox i WHERE i.session=s.name AND i.read=0) "
318
+ "FROM sessions s ORDER BY s.name"
319
+ )
320
+ for name, updated, inbox_count in rows:
321
+ print(f" {name:<8s} {updated or '(never)':<20s} {inbox_count}")
322
+
323
+
324
+ def cmd_relations(db: BoardDB) -> None:
325
+ print("=== 通信关系图 ===\n")
326
+ rows = db.query(
327
+ "SELECT sender, recipient, COUNT(*) as c FROM messages "
328
+ "WHERE sender != 'SYSTEM' GROUP BY sender, recipient ORDER BY c DESC LIMIT 20"
329
+ )
330
+ for sender, recipient, count in rows:
331
+ print(f" {sender} → {recipient}: {count} messages")
332
+
333
+
334
+ def cmd_history(db: BoardDB, args: list[str]) -> None:
335
+ if not args:
336
+ print("Usage: ./board history <session|topic> [limit]")
337
+ raise SystemExit(1)
338
+ subject = args[0].lower()
339
+ limit = int(args[1]) if len(args) > 1 else 20
340
+
341
+ print(f"=== History: {args[0]} ===\n")
342
+ print(f"Messages involving {args[0]} (last {limit}):")
343
+ rows = db.query(
344
+ "SELECT '[' || ts || '] ' || sender || ' → ' || recipient || ': ' || substr(body, 1, 100) "
345
+ "FROM messages WHERE sender=? OR recipient=? OR (recipient='all' AND sender=?) "
346
+ "OR body LIKE '%' || ? || '%' ESCAPE '\\' ORDER BY id DESC LIMIT ?",
347
+ (subject, subject, subject, subject, limit),
348
+ )
349
+ for (line,) in reversed(rows):
350
+ print(f" {line}")
351
+ print()
352
+ print("Status changes:")
353
+ for updated_at, status in db.query("SELECT updated_at, status FROM sessions WHERE name=?", (subject,)):
354
+ print(f" [{updated_at}] {status}")
355
+
356
+
357
+ def cmd_roster(db: BoardDB) -> None:
358
+ print("=== 员工状态 ===")
359
+ prefix = db.env.prefix
360
+ rows = db.query(
361
+ "SELECT s.name, CASE WHEN su.name IS NOT NULL THEN 'SUSPENDED' ELSE 'active' END "
362
+ "FROM sessions s LEFT JOIN suspended su ON s.name=su.name ORDER BY s.name"
363
+ )
364
+ for name, state in rows:
365
+ online = "online" if _tmux_has_session(f"{prefix}-{name}") else "offline"
366
+ print(f" {name:<8s} {state:<10s} {online}")
@@ -0,0 +1,164 @@
1
+ """board_vote — governance: vote / tally / propose."""
2
+
3
+ from lib.board_db import BoardDB, ts
4
+ from lib.common import PRIVILEGED_ROLES, parse_flags
5
+
6
+
7
+ def cmd_vote(db: BoardDB, identity: str, args: list[str]) -> None:
8
+ name = identity.lower()
9
+ if name in PRIVILEGED_ROLES:
10
+ print("ERROR: privileged roles have no voting rights (charter §二)")
11
+ raise SystemExit(1)
12
+ if len(args) < 3:
13
+ print("Usage: ./board --as <name> vote <number> <SUPPORT|OBJECT> <reason>")
14
+ raise SystemExit(1)
15
+
16
+ num = args[0]
17
+ decision = args[1].upper()
18
+ reason = " ".join(args[2:])
19
+ if decision not in ("SUPPORT", "OBJECT"):
20
+ print("ERROR: must be SUPPORT or OBJECT")
21
+ raise SystemExit(1)
22
+
23
+ padded = f"{int(num):03d}" if num.isdigit() else num
24
+ prop_id = db.scalar(
25
+ "SELECT id FROM proposals WHERE number=? OR number=? LIMIT 1",
26
+ (padded, num),
27
+ )
28
+ if not prop_id:
29
+ print(f"ERROR: proposal {num} not found")
30
+ raise SystemExit(1)
31
+
32
+ prop_status = db.scalar("SELECT status FROM proposals WHERE id=?", (prop_id,))
33
+ if prop_status != "OPEN":
34
+ print(f"ERROR: proposal {num} already decided ({prop_status})")
35
+ raise SystemExit(1)
36
+
37
+ now = ts()
38
+ db.execute(
39
+ "INSERT OR REPLACE INTO votes(proposal_id, voter, decision, reason, ts) VALUES (?, ?, ?, ?, ?)",
40
+ (prop_id, name, decision, reason, now),
41
+ )
42
+ print(f"OK voted {decision}\n\nTally:")
43
+
44
+ for (line,) in db.query(
45
+ "SELECT voter || ': ' || decision || ' — ' || reason FROM votes WHERE proposal_id=?",
46
+ (prop_id,),
47
+ ):
48
+ print(f" {line}")
49
+
50
+ s = db.scalar(
51
+ "SELECT COUNT(*) FROM votes WHERE proposal_id=? AND decision='SUPPORT'",
52
+ (prop_id,),
53
+ )
54
+ o = db.scalar(
55
+ "SELECT COUNT(*) FROM votes WHERE proposal_id=? AND decision='OBJECT'",
56
+ (prop_id,),
57
+ )
58
+
59
+ prop_type = db.scalar("SELECT type FROM proposals WHERE id=?", (prop_id,))
60
+ eligible = (
61
+ db.scalar("SELECT COUNT(*) FROM sessions WHERE name != (SELECT value FROM meta WHERE key='dispatcher_session')")
62
+ or 0
63
+ )
64
+ threshold = (eligible * 2 + 2) // 3 if prop_type == "S" else eligible // 2 + 1
65
+
66
+ print(f"\n SUPPORT={s} OBJECT={o} | type={prop_type} threshold={threshold}/{eligible}")
67
+
68
+ if s >= threshold:
69
+ db.execute(
70
+ "UPDATE proposals SET status='PASSED', decided_at=? WHERE id=?",
71
+ (now, prop_id),
72
+ )
73
+ print(f"\n>>> PASSED ({prop_type}级, {s}/{eligible} >= {threshold}) <<<")
74
+ db.execute(
75
+ "INSERT INTO messages(ts, sender, recipient, body) VALUES (?, 'SYSTEM', 'All', ?)",
76
+ (now, f"[VOTE] Proposal {padded} PASSED ({s}S/{o}O, threshold {threshold})"),
77
+ )
78
+ elif o > (eligible - threshold):
79
+ db.execute(
80
+ "UPDATE proposals SET status='FAILED', decided_at=? WHERE id=?",
81
+ (now, prop_id),
82
+ )
83
+ print(f"\n>>> FAILED (无法达到 {threshold} 票) <<<")
84
+ db.execute(
85
+ "INSERT INTO messages(ts, sender, recipient, body) VALUES (?, 'SYSTEM', 'All', ?)",
86
+ (now, f"[VOTE] Proposal {padded} FAILED ({s}S/{o}O, threshold {threshold})"),
87
+ )
88
+ else:
89
+ print(f" (待定,还需 {threshold - s} 票 SUPPORT 通过)")
90
+
91
+
92
+ def cmd_propose(db: BoardDB, identity: str, args: list[str]) -> None:
93
+ name = identity.lower()
94
+ if len(args) < 1:
95
+ print("Usage: ./board --as <name> propose <内容> [--type S]")
96
+ raise SystemExit(1)
97
+
98
+ flags, clean_args = parse_flags(args, value_flags={"type": ["--type", "-t"]})
99
+ prop_type = str(flags.get("type", "A")).upper()
100
+ if prop_type not in ("A", "S"):
101
+ print("ERROR: type 只能是 A (普通) 或 S (重大)")
102
+ raise SystemExit(1)
103
+
104
+ content = " ".join(clean_args)
105
+ if not content:
106
+ print("ERROR: 提案内容不能为空")
107
+ raise SystemExit(1)
108
+
109
+ max_num = db.scalar("SELECT MAX(CAST(number AS INTEGER)) FROM proposals") or 0
110
+ number = f"{max_num + 1:03d}"
111
+ slug = content[:40].replace(" ", "-").lower()
112
+ now = ts()
113
+ db.execute(
114
+ "INSERT INTO proposals(number, slug, type, content, created_at) VALUES (?, ?, ?, ?, ?)",
115
+ (number, slug, prop_type, content, now),
116
+ )
117
+ db.execute(
118
+ "INSERT INTO messages(ts, sender, recipient, body) VALUES (?, ?, 'all', ?)",
119
+ (now, name, f"[PROPOSAL #{number}] {content}"),
120
+ )
121
+ print(f"OK 提案 #{number} 已创建 (type={prop_type})")
122
+
123
+
124
+ def cmd_tally(db: BoardDB, args: list[str]) -> None:
125
+ if not args:
126
+ print("Usage: ./board tally <number>")
127
+ raise SystemExit(1)
128
+ num = args[0]
129
+ padded = f"{int(num):03d}" if num.isdigit() else num
130
+ prop_id = db.scalar(
131
+ "SELECT id FROM proposals WHERE number=? OR number=? LIMIT 1",
132
+ (padded, num),
133
+ )
134
+ if not prop_id:
135
+ print(f"ERROR: proposal {num} not found")
136
+ raise SystemExit(1)
137
+
138
+ prop_status = db.scalar("SELECT status FROM proposals WHERE id=?", (prop_id,))
139
+ if prop_status != "OPEN":
140
+ decided_at = db.scalar("SELECT decided_at FROM proposals WHERE id=?", (prop_id,))
141
+ print(f"Already {prop_status}: {decided_at}")
142
+ return
143
+
144
+ for (line,) in db.query(
145
+ "SELECT voter || ': ' || decision || ' — ' || reason FROM votes WHERE proposal_id=?",
146
+ (prop_id,),
147
+ ):
148
+ print(f" {line}")
149
+
150
+ s = db.scalar(
151
+ "SELECT COUNT(*) FROM votes WHERE proposal_id=? AND decision='SUPPORT'",
152
+ (prop_id,),
153
+ )
154
+ o = db.scalar(
155
+ "SELECT COUNT(*) FROM votes WHERE proposal_id=? AND decision='OBJECT'",
156
+ (prop_id,),
157
+ )
158
+ prop_type = db.scalar("SELECT type FROM proposals WHERE id=?", (prop_id,))
159
+ eligible = (
160
+ db.scalar("SELECT COUNT(*) FROM sessions WHERE name != (SELECT value FROM meta WHERE key='dispatcher_session')")
161
+ or 0
162
+ )
163
+ threshold = (eligible * 2 + 2) // 3 if prop_type == "S" else eligible // 2 + 1
164
+ print(f" SUPPORT={s} OBJECT={o} | type={prop_type} threshold={threshold}/{eligible}")