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
|
@@ -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}")
|