claude-nb 0.4.0 → 0.5.1
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/Makefile +8 -2
- package/README.md +40 -56
- package/VERSION +1 -1
- package/bin/board +102 -34
- package/bin/cnb +59 -33
- package/bin/dispatcher +25 -11
- package/bin/doctor +3 -5
- package/bin/init +8 -8
- package/bin/notify +224 -0
- package/bin/registry +8 -23
- package/bin/sync-version +131 -0
- package/lib/board_admin.py +19 -9
- package/lib/board_bbs.py +23 -8
- package/lib/board_bug.py +2 -1
- package/lib/board_db.py +18 -6
- package/lib/board_lock.py +5 -0
- package/lib/board_mailbox.py +6 -4
- package/lib/board_msg.py +112 -24
- package/lib/board_pending.py +233 -0
- package/lib/board_pulse.py +14 -0
- package/lib/board_task.py +22 -10
- package/lib/board_tui.py +28 -20
- package/lib/board_view.py +60 -28
- package/lib/board_vote.py +9 -3
- package/lib/build_lock.py +7 -7
- package/lib/common.py +45 -3
- package/lib/concerns/__init__.py +4 -1
- package/lib/concerns/coral.py +1 -1
- package/lib/concerns/digest_scheduler.py +109 -0
- package/lib/concerns/file_watcher.py +73 -68
- package/lib/concerns/health.py +1 -1
- package/lib/concerns/notification_push.py +171 -0
- package/lib/concerns/notifications.py +58 -3
- package/lib/concerns/nudge_coordinator.py +148 -0
- package/lib/digest.py +62 -0
- package/lib/health.py +2 -2
- package/lib/inject.py +2 -2
- package/lib/monitor.py +8 -4
- package/lib/notification_config.py +101 -0
- package/lib/swarm.py +43 -35
- package/lib/swarm_backend.py +63 -29
- package/lib/theme_profiles.py +89 -0
- package/migrations/004_heartbeat.sql +1 -0
- package/migrations/005_notification_log.sql +12 -0
- package/migrations/006_pending_actions.sql +15 -0
- package/package.json +4 -3
- package/pyproject.toml +3 -2
- package/registry/README.md +9 -0
- package/schema.sql +29 -1
package/lib/board_task.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""board_task — task queue: add / done / list / next."""
|
|
2
2
|
|
|
3
3
|
from lib.board_db import BoardDB, ts
|
|
4
|
-
from lib.common import is_privileged, is_terminal_task_status, parse_flags
|
|
4
|
+
from lib.common import is_privileged, is_terminal_task_status, parse_flags, validate_identity
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
def _promote_next(db: BoardDB, target: str) -> None:
|
|
@@ -48,6 +48,7 @@ def _print_queue(db: BoardDB, target: str, include_done: bool = False) -> None:
|
|
|
48
48
|
|
|
49
49
|
|
|
50
50
|
def cmd_task(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
51
|
+
validate_identity(db, identity)
|
|
51
52
|
subcmd = args[0] if args else "list"
|
|
52
53
|
rest = args[1:] if len(args) > 1 else []
|
|
53
54
|
|
|
@@ -90,7 +91,7 @@ def _task_add(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
|
90
91
|
(target, desc, status, priority),
|
|
91
92
|
c=c,
|
|
92
93
|
)
|
|
93
|
-
print(f"OK #{task_id}")
|
|
94
|
+
print(f"OK task #{task_id} added to {target} ({status})")
|
|
94
95
|
|
|
95
96
|
if target != name:
|
|
96
97
|
now = ts()
|
|
@@ -100,28 +101,30 @@ def _task_add(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
|
100
101
|
c=c,
|
|
101
102
|
)
|
|
102
103
|
db.execute("INSERT INTO inbox(session, message_id) VALUES (?, ?)", (target, msg_id), c=c)
|
|
104
|
+
print(f"OK notified {target}")
|
|
105
|
+
_print_queue(db, target)
|
|
103
106
|
|
|
104
107
|
|
|
105
108
|
def _task_done(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
106
109
|
name = identity.lower()
|
|
107
110
|
|
|
108
|
-
|
|
111
|
+
raw_id: str | int | None = args[0] if args else None
|
|
109
112
|
|
|
110
|
-
if not
|
|
113
|
+
if not raw_id:
|
|
111
114
|
_promote_next(db, name)
|
|
112
|
-
|
|
115
|
+
raw_id = db.scalar(
|
|
113
116
|
"SELECT id FROM tasks WHERE session=? AND status='active' ORDER BY id ASC LIMIT 1",
|
|
114
117
|
(name,),
|
|
115
118
|
)
|
|
116
|
-
if not
|
|
119
|
+
if not raw_id:
|
|
117
120
|
print(f"No active task for {name}.")
|
|
118
121
|
_print_queue(db, name)
|
|
119
122
|
return
|
|
120
123
|
|
|
121
124
|
try:
|
|
122
|
-
task_id = int(
|
|
123
|
-
except ValueError:
|
|
124
|
-
print(f"ERROR: 无效的任务 ID: {
|
|
125
|
+
task_id = int(raw_id)
|
|
126
|
+
except (ValueError, TypeError):
|
|
127
|
+
print(f"ERROR: 无效的任务 ID: {raw_id}")
|
|
125
128
|
raise SystemExit(1)
|
|
126
129
|
row = db.query_one("SELECT session, status, description FROM tasks WHERE id=?", (task_id,))
|
|
127
130
|
if not row:
|
|
@@ -140,9 +143,18 @@ def _task_done(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
|
140
143
|
|
|
141
144
|
now = ts()
|
|
142
145
|
db.execute("UPDATE tasks SET status='done', done_at=? WHERE id=?", (now, task_id))
|
|
143
|
-
print(f"OK #{task_id} done")
|
|
146
|
+
print(f"OK task #{task_id} done: {desc}")
|
|
144
147
|
|
|
145
148
|
_promote_next(db, assignee)
|
|
149
|
+
nxt = db.query_one(
|
|
150
|
+
"SELECT id, description FROM tasks WHERE session=? AND status='active' ORDER BY id ASC LIMIT 1",
|
|
151
|
+
(assignee,),
|
|
152
|
+
)
|
|
153
|
+
if nxt:
|
|
154
|
+
print(f"Next: #{nxt[0]} {nxt[1]}")
|
|
155
|
+
else:
|
|
156
|
+
print(f"No remaining active/pending tasks for {assignee}.")
|
|
157
|
+
_print_queue(db, assignee)
|
|
146
158
|
|
|
147
159
|
|
|
148
160
|
def _task_list(db: BoardDB, identity: str, args: list[str]) -> None:
|
package/lib/board_tui.py
CHANGED
|
@@ -44,25 +44,26 @@ def cmd_tui(db: BoardDB) -> None:
|
|
|
44
44
|
print("ERROR: 没有在线的 worker,先运行 cnb swarm start")
|
|
45
45
|
raise SystemExit(1)
|
|
46
46
|
|
|
47
|
-
#
|
|
47
|
+
# Rebuild base UI session (shared window group)
|
|
48
48
|
if _session_exists(UI_SESSION):
|
|
49
49
|
_tmux("kill-session", "-t", UI_SESSION)
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
_tmux("new-session", "-d", "-s", UI_SESSION)
|
|
52
|
+
for name in online:
|
|
53
|
+
_tmux("link-window", "-s", f"{prefix}-{name}:0", "-t", UI_SESSION, "-a")
|
|
54
|
+
_tmux("kill-window", "-t", f"{UI_SESSION}:0")
|
|
53
55
|
|
|
54
|
-
# Link remaining workers as windows
|
|
55
|
-
for name in online[1:]:
|
|
56
|
-
_tmux("link-window", "-s", f"{prefix}-{name}", "-t", UI_SESSION, "-a")
|
|
57
|
-
|
|
58
|
-
# Rename windows
|
|
59
56
|
win_indices = _tmux_out("list-windows", "-t", UI_SESSION, "-F", "#{window_index}").split("\n")
|
|
60
57
|
for i, name in enumerate(online):
|
|
61
58
|
if i < len(win_indices):
|
|
62
59
|
_tmux("rename-window", "-t", f"{UI_SESSION}:{win_indices[i]}", name)
|
|
63
60
|
|
|
64
|
-
|
|
65
|
-
|
|
61
|
+
_apply_style(len(online), win_indices)
|
|
62
|
+
_open_terminal()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _apply_style(n_online: int, win_indices: list[str]) -> None:
|
|
66
|
+
session_opts = {
|
|
66
67
|
"mouse": "on",
|
|
67
68
|
"status": "on",
|
|
68
69
|
"status-position": "top",
|
|
@@ -70,24 +71,27 @@ def cmd_tui(db: BoardDB) -> None:
|
|
|
70
71
|
"status-left": " cnb ",
|
|
71
72
|
"status-left-style": "bold",
|
|
72
73
|
"status-left-length": "6",
|
|
73
|
-
"status-right": f" {
|
|
74
|
+
"status-right": f" {n_online} online ",
|
|
74
75
|
"status-right-style": "dim",
|
|
76
|
+
}
|
|
77
|
+
for k, v in session_opts.items():
|
|
78
|
+
_tmux("set-option", "-t", UI_SESSION, k, v)
|
|
79
|
+
|
|
80
|
+
window_opts = {
|
|
75
81
|
"window-status-format": " #W ",
|
|
76
82
|
"window-status-current-format": " #W ",
|
|
77
83
|
"window-status-style": "dim",
|
|
78
84
|
"window-status-current-style": "bold,underscore",
|
|
79
85
|
"window-status-separator": "",
|
|
80
86
|
}
|
|
81
|
-
for
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
# Open in new terminal window
|
|
85
|
-
_open_terminal()
|
|
87
|
+
for idx in win_indices:
|
|
88
|
+
for k, v in window_opts.items():
|
|
89
|
+
_tmux("set-option", "-w", "-t", f"{UI_SESSION}:{idx}", k, v)
|
|
86
90
|
|
|
87
91
|
|
|
88
92
|
def _open_terminal() -> None:
|
|
89
|
-
"""Open a new terminal
|
|
90
|
-
attach_cmd = f"tmux attach -t {UI_SESSION}"
|
|
93
|
+
"""Open a new terminal attached to cnb-ui."""
|
|
94
|
+
attach_cmd = f"tmux attach -t {UI_SESSION} ';' set-option destroy-unattached on"
|
|
91
95
|
|
|
92
96
|
if sys.platform == "darwin":
|
|
93
97
|
if os.path.isdir("/Applications/iTerm.app"):
|
|
@@ -99,7 +103,10 @@ def _open_terminal() -> None:
|
|
|
99
103
|
)
|
|
100
104
|
else:
|
|
101
105
|
script = f'tell application "Terminal"\n do script "{attach_cmd}"\n activate\nend tell'
|
|
102
|
-
subprocess.run(["osascript", "-e", script], capture_output=True)
|
|
106
|
+
r = subprocess.run(["osascript", "-e", script], capture_output=True, text=True)
|
|
107
|
+
if r.returncode != 0:
|
|
108
|
+
print(f"ERROR: 无法打开终端: {r.stderr.strip()}")
|
|
109
|
+
raise SystemExit(1)
|
|
103
110
|
print("OK 已打开 — 点击顶部 tab 切换同学")
|
|
104
111
|
else:
|
|
105
112
|
import shutil
|
|
@@ -109,4 +116,5 @@ def _open_terminal() -> None:
|
|
|
109
116
|
subprocess.Popen([term, "--", "bash", "-c", attach_cmd])
|
|
110
117
|
print("OK 已打开 — 点击顶部 tab 切换同学")
|
|
111
118
|
return
|
|
112
|
-
print(f"
|
|
119
|
+
print(f"ERROR: 找不到终端模拟器,手动运行: {attach_cmd}")
|
|
120
|
+
raise SystemExit(1)
|
package/lib/board_view.py
CHANGED
|
@@ -41,31 +41,57 @@ def _tmux_pane_command(name: str) -> str:
|
|
|
41
41
|
return ""
|
|
42
42
|
|
|
43
43
|
|
|
44
|
+
def _heartbeat_status(last_heartbeat: str | None, prefix: str, name: str) -> tuple[str, str]:
|
|
45
|
+
"""Derive agent liveness from heartbeat timestamp, with tmux fallback."""
|
|
46
|
+
if last_heartbeat:
|
|
47
|
+
try:
|
|
48
|
+
hb_time = datetime.strptime(last_heartbeat, "%Y-%m-%d %H:%M:%S")
|
|
49
|
+
delta = (datetime.now() - hb_time).total_seconds()
|
|
50
|
+
if delta < 120:
|
|
51
|
+
ago = f"[{int(delta)}s ago]"
|
|
52
|
+
return "● active", ago
|
|
53
|
+
elif delta < 180:
|
|
54
|
+
return "◐ thinking", f"[{int(delta / 60)}m ago]"
|
|
55
|
+
elif delta < 600:
|
|
56
|
+
return "○ stale", f"[{int(delta / 60)}m ago]"
|
|
57
|
+
else:
|
|
58
|
+
hours = delta / 3600
|
|
59
|
+
ago = f"[{int(hours)}h ago]" if hours >= 1 else f"[{int(delta / 60)}m ago]"
|
|
60
|
+
return "· offline", ago
|
|
61
|
+
except ValueError:
|
|
62
|
+
pass
|
|
63
|
+
sess = f"{prefix}-{name}"
|
|
64
|
+
if _tmux_has_session(sess):
|
|
65
|
+
cmd = _tmux_pane_command(sess)
|
|
66
|
+
if cmd not in ("zsh", "bash", "sh", "-zsh", "-bash"):
|
|
67
|
+
return "● running", ""
|
|
68
|
+
return "○ dead", ""
|
|
69
|
+
return "· offline", ""
|
|
70
|
+
|
|
71
|
+
|
|
44
72
|
def cmd_overview(db: BoardDB) -> None:
|
|
45
73
|
"""Default view when running cnb with no args."""
|
|
74
|
+
assert db.env is not None
|
|
46
75
|
prefix = db.env.prefix
|
|
47
76
|
now = datetime.now().strftime("%H:%M")
|
|
48
77
|
print(f"=== {db.env.project_root.name} {now} ===")
|
|
49
78
|
print()
|
|
50
79
|
|
|
51
80
|
# ── sessions ──
|
|
52
|
-
for
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
cmd = _tmux_pane_command(sess)
|
|
56
|
-
status = "● running" if cmd not in ("zsh", "bash", "sh", "-zsh", "-bash") else "○ dead"
|
|
57
|
-
else:
|
|
58
|
-
status = "· offline"
|
|
81
|
+
for row in db.query("SELECT name, status, last_heartbeat FROM sessions WHERE name != 'all' ORDER BY name"):
|
|
82
|
+
name, task, last_hb = row[0], row[1], row[2]
|
|
83
|
+
status, ago = _heartbeat_status(last_hb, prefix, name)
|
|
59
84
|
|
|
60
85
|
inbox = db.scalar("SELECT COUNT(*) FROM inbox WHERE session=? AND read=0", (name,)) or 0
|
|
61
86
|
inbox_str = f" [{inbox} msg]" if inbox else ""
|
|
62
|
-
task = db.scalar("SELECT status FROM sessions WHERE name=?", (name,)) or ""
|
|
63
87
|
if task:
|
|
64
88
|
task = task[:60]
|
|
65
89
|
else:
|
|
66
90
|
task = "(no status)"
|
|
67
91
|
|
|
68
92
|
line = f" {status:12s} {name:<10s} {task}"
|
|
93
|
+
if ago:
|
|
94
|
+
line += f" {ago}"
|
|
69
95
|
if inbox:
|
|
70
96
|
line += f"{inbox_str}"
|
|
71
97
|
print(line)
|
|
@@ -100,6 +126,7 @@ def cmd_overview(db: BoardDB) -> None:
|
|
|
100
126
|
|
|
101
127
|
|
|
102
128
|
def cmd_view(db: BoardDB, identity: str) -> None:
|
|
129
|
+
assert db.env is not None
|
|
103
130
|
print("=== Board ===\n")
|
|
104
131
|
|
|
105
132
|
roadmap = db.env.project_root / "ROADMAP.md"
|
|
@@ -118,16 +145,19 @@ def cmd_view(db: BoardDB, identity: str) -> None:
|
|
|
118
145
|
if count:
|
|
119
146
|
print(f">>> 你有 {count} 条未读消息,运行 ./board inbox 查看 <<<\n")
|
|
120
147
|
|
|
148
|
+
prefix = db.env.prefix
|
|
121
149
|
print("Status:")
|
|
122
|
-
for name, task in db.query("SELECT name, status FROM sessions ORDER BY name"):
|
|
150
|
+
for name, task, last_hb in db.query("SELECT name, status, last_heartbeat FROM sessions ORDER BY name"):
|
|
123
151
|
cap = name[0].upper() + name[1:] if name else name
|
|
152
|
+
status, ago = _heartbeat_status(last_hb, prefix, name)
|
|
124
153
|
task = task or "(none)"
|
|
125
154
|
tag = ""
|
|
126
155
|
if p0_locked and "[P0]" not in task:
|
|
127
156
|
tag = " [!! 未标 P0]"
|
|
128
|
-
if len(task) >
|
|
129
|
-
task = task[:
|
|
130
|
-
|
|
157
|
+
if len(task) > 60:
|
|
158
|
+
task = task[:57] + "..."
|
|
159
|
+
ago_str = f" {ago}" if ago else ""
|
|
160
|
+
print(f" {status:12s} {cap:<10s} {task}{tag}{ago_str}")
|
|
131
161
|
print()
|
|
132
162
|
|
|
133
163
|
print("Recent messages:")
|
|
@@ -154,6 +184,7 @@ def cmd_view(db: BoardDB, identity: str) -> None:
|
|
|
154
184
|
|
|
155
185
|
|
|
156
186
|
def cmd_p0(db: BoardDB) -> None:
|
|
187
|
+
assert db.env is not None
|
|
157
188
|
roadmap = db.env.project_root / "ROADMAP.md"
|
|
158
189
|
if not roadmap.is_file():
|
|
159
190
|
print("ERROR: ROADMAP.md not found")
|
|
@@ -181,6 +212,7 @@ def cmd_p0(db: BoardDB) -> None:
|
|
|
181
212
|
|
|
182
213
|
|
|
183
214
|
def cmd_prebuild(db: BoardDB) -> None:
|
|
215
|
+
assert db.env is not None
|
|
184
216
|
print("=== Pre-build Check ===\n")
|
|
185
217
|
has_fail = False
|
|
186
218
|
pr = db.env.project_root
|
|
@@ -207,6 +239,7 @@ def cmd_prebuild(db: BoardDB) -> None:
|
|
|
207
239
|
|
|
208
240
|
|
|
209
241
|
def cmd_dirty(db: BoardDB) -> None:
|
|
242
|
+
assert db.env is not None
|
|
210
243
|
print("=== Uncommitted Changes ===\n")
|
|
211
244
|
pr = db.env.project_root
|
|
212
245
|
changes = _git(pr, "status", "--porcelain").strip()
|
|
@@ -228,22 +261,18 @@ def cmd_dirty(db: BoardDB) -> None:
|
|
|
228
261
|
|
|
229
262
|
|
|
230
263
|
def cmd_dashboard(db: BoardDB) -> None:
|
|
264
|
+
assert db.env is not None
|
|
231
265
|
prefix = db.env.prefix
|
|
232
266
|
print(f"=== Team Dashboard {datetime.now().strftime('%H:%M')} ===\n")
|
|
233
|
-
for
|
|
234
|
-
|
|
235
|
-
status =
|
|
236
|
-
if _tmux_has_session(session_name):
|
|
237
|
-
cmd = _tmux_pane_command(session_name)
|
|
238
|
-
if cmd in ("zsh", "bash", "sh", "-zsh", "-bash"):
|
|
239
|
-
status = "DEAD"
|
|
240
|
-
else:
|
|
241
|
-
status = "running"
|
|
267
|
+
for row in db.query("SELECT name, status, last_heartbeat FROM sessions ORDER BY name"):
|
|
268
|
+
name, task, last_hb = row[0], row[1], row[2]
|
|
269
|
+
status, ago = _heartbeat_status(last_hb, prefix, name)
|
|
242
270
|
|
|
243
271
|
inbox_count = db.scalar("SELECT COUNT(*) FROM inbox WHERE session=? AND read=0", (name,))
|
|
244
272
|
inbox_str = f" [{inbox_count}msg]" if inbox_count else ""
|
|
245
|
-
task =
|
|
246
|
-
|
|
273
|
+
task = task[:50] if task else "-"
|
|
274
|
+
ago_str = f" {ago}" if ago else ""
|
|
275
|
+
print(f" {name:<7s} {status:<12s}{inbox_str}{ago_str}")
|
|
247
276
|
print(f" {task}")
|
|
248
277
|
print()
|
|
249
278
|
dispatcher_sess = f"{prefix}-dispatcher"
|
|
@@ -254,6 +283,7 @@ def cmd_dashboard(db: BoardDB) -> None:
|
|
|
254
283
|
|
|
255
284
|
|
|
256
285
|
def cmd_files(db: BoardDB) -> None:
|
|
286
|
+
assert db.env is not None
|
|
257
287
|
print("=== 共享文件 ===\n")
|
|
258
288
|
rows = db.query("SELECT hash, original_name, sender, ts FROM files ORDER BY ts DESC")
|
|
259
289
|
if not rows:
|
|
@@ -270,6 +300,7 @@ def cmd_files(db: BoardDB) -> None:
|
|
|
270
300
|
|
|
271
301
|
|
|
272
302
|
def cmd_get(db: BoardDB, args: list[str]) -> None:
|
|
303
|
+
assert db.env is not None
|
|
273
304
|
if not args:
|
|
274
305
|
print("Usage: ./board get <hash-prefix|filename>")
|
|
275
306
|
raise SystemExit(1)
|
|
@@ -298,15 +329,15 @@ def cmd_get(db: BoardDB, args: list[str]) -> None:
|
|
|
298
329
|
|
|
299
330
|
def cmd_freshness(db: BoardDB) -> None:
|
|
300
331
|
print("=== 数据新鲜度 ===\n")
|
|
301
|
-
print(f" {'Session':<8s} {'Last status
|
|
302
|
-
print(f" {'-------':<8s} {'
|
|
332
|
+
print(f" {'Session':<8s} {'Last status':<20s} {'Last heartbeat':<20s} {'Unread'}")
|
|
333
|
+
print(f" {'-------':<8s} {'-----------':<20s} {'--------------':<20s} {'------'}")
|
|
303
334
|
rows = db.query(
|
|
304
|
-
"SELECT s.name, s.updated_at, "
|
|
335
|
+
"SELECT s.name, s.updated_at, s.last_heartbeat, "
|
|
305
336
|
"(SELECT COUNT(*) FROM inbox i WHERE i.session=s.name AND i.read=0) "
|
|
306
337
|
"FROM sessions s ORDER BY s.name"
|
|
307
338
|
)
|
|
308
|
-
for name, updated, inbox_count in rows:
|
|
309
|
-
print(f" {name:<8s} {updated or '(never)':<20s} {inbox_count}")
|
|
339
|
+
for name, updated, heartbeat, inbox_count in rows:
|
|
340
|
+
print(f" {name:<8s} {updated or '(never)':<20s} {heartbeat or '(never)':<20s} {inbox_count}")
|
|
310
341
|
|
|
311
342
|
|
|
312
343
|
def cmd_relations(db: BoardDB) -> None:
|
|
@@ -343,6 +374,7 @@ def cmd_history(db: BoardDB, args: list[str]) -> None:
|
|
|
343
374
|
|
|
344
375
|
|
|
345
376
|
def cmd_roster(db: BoardDB) -> None:
|
|
377
|
+
assert db.env is not None
|
|
346
378
|
print("=== 员工状态 ===")
|
|
347
379
|
prefix = db.env.prefix
|
|
348
380
|
rows = db.query(
|
package/lib/board_vote.py
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
"""board_vote — governance: vote / tally / propose."""
|
|
2
2
|
|
|
3
3
|
from lib.board_db import BoardDB, ts
|
|
4
|
-
from lib.common import PRIVILEGED_ROLES, parse_flags
|
|
4
|
+
from lib.common import PRIVILEGED_ROLES, parse_flags, validate_identity
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
def cmd_vote(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
8
|
+
validate_identity(db, identity)
|
|
8
9
|
name = identity.lower()
|
|
9
10
|
if name in PRIVILEGED_ROLES:
|
|
10
11
|
print("ERROR: privileged roles have no voting rights (charter §二)")
|
|
@@ -58,7 +59,9 @@ def cmd_vote(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
|
58
59
|
|
|
59
60
|
prop_type = db.scalar("SELECT type FROM proposals WHERE id=?", (prop_id,))
|
|
60
61
|
eligible = (
|
|
61
|
-
db.scalar(
|
|
62
|
+
db.scalar(
|
|
63
|
+
"SELECT COUNT(*) FROM sessions WHERE name NOT IN (SELECT value FROM meta WHERE key='dispatcher_session')"
|
|
64
|
+
)
|
|
62
65
|
or 0
|
|
63
66
|
)
|
|
64
67
|
threshold = (eligible * 2 + 2) // 3 if prop_type == "S" else eligible // 2 + 1
|
|
@@ -90,6 +93,7 @@ def cmd_vote(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
|
90
93
|
|
|
91
94
|
|
|
92
95
|
def cmd_propose(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
96
|
+
validate_identity(db, identity)
|
|
93
97
|
name = identity.lower()
|
|
94
98
|
if len(args) < 1:
|
|
95
99
|
print("Usage: ./board --as <name> propose <内容> [--type S]")
|
|
@@ -157,7 +161,9 @@ def cmd_tally(db: BoardDB, args: list[str]) -> None:
|
|
|
157
161
|
)
|
|
158
162
|
prop_type = db.scalar("SELECT type FROM proposals WHERE id=?", (prop_id,))
|
|
159
163
|
eligible = (
|
|
160
|
-
db.scalar(
|
|
164
|
+
db.scalar(
|
|
165
|
+
"SELECT COUNT(*) FROM sessions WHERE name NOT IN (SELECT value FROM meta WHERE key='dispatcher_session')"
|
|
166
|
+
)
|
|
161
167
|
or 0
|
|
162
168
|
)
|
|
163
169
|
threshold = (eligible * 2 + 2) // 3 if prop_type == "S" else eligible // 2 + 1
|
package/lib/build_lock.py
CHANGED
|
@@ -184,22 +184,22 @@ def main() -> None:
|
|
|
184
184
|
if cmd == "acquire":
|
|
185
185
|
if len(args) < 2:
|
|
186
186
|
print("Usage: build_lock.py acquire <session> <target>", file=sys.stderr)
|
|
187
|
-
|
|
187
|
+
raise SystemExit(1)
|
|
188
188
|
session = args[1]
|
|
189
189
|
target = args[2] if len(args) > 2 else "unknown"
|
|
190
190
|
ok, msg = lock.acquire(session, target)
|
|
191
191
|
print(msg)
|
|
192
192
|
if not ok:
|
|
193
|
-
|
|
193
|
+
raise SystemExit(1)
|
|
194
194
|
|
|
195
195
|
elif cmd == "release":
|
|
196
196
|
if len(args) < 2:
|
|
197
197
|
print("Usage: build_lock.py release <session>", file=sys.stderr)
|
|
198
|
-
|
|
198
|
+
raise SystemExit(1)
|
|
199
199
|
ok, msg = lock.release(args[1])
|
|
200
200
|
print(msg)
|
|
201
201
|
if not ok:
|
|
202
|
-
|
|
202
|
+
raise SystemExit(1)
|
|
203
203
|
|
|
204
204
|
elif cmd == "status":
|
|
205
205
|
print(lock.status())
|
|
@@ -207,14 +207,14 @@ def main() -> None:
|
|
|
207
207
|
elif cmd == "wrap":
|
|
208
208
|
if len(args) < 3:
|
|
209
209
|
print("Usage: build_lock.py wrap <session> <command...>", file=sys.stderr)
|
|
210
|
-
|
|
210
|
+
raise SystemExit(1)
|
|
211
211
|
session = args[1]
|
|
212
212
|
command = args[2:]
|
|
213
|
-
|
|
213
|
+
raise SystemExit(lock.wrap(session, command))
|
|
214
214
|
|
|
215
215
|
else:
|
|
216
216
|
print("Usage: build_lock.py {acquire|release|status|wrap} [args...]", file=sys.stderr)
|
|
217
|
-
|
|
217
|
+
raise SystemExit(1)
|
|
218
218
|
|
|
219
219
|
|
|
220
220
|
if __name__ == "__main__":
|
package/lib/common.py
CHANGED
|
@@ -12,13 +12,21 @@ from typing import Any, Generic, TypeVar
|
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
def find_claudes_dir() -> Path:
|
|
15
|
-
"""
|
|
15
|
+
"""Find .claudes/ directory. Checks CNB_PROJECT env var first, then walks up from cwd."""
|
|
16
|
+
import os
|
|
17
|
+
|
|
18
|
+
env_root = os.environ.get("CNB_PROJECT")
|
|
19
|
+
if env_root:
|
|
20
|
+
p = Path(env_root) / ".claudes"
|
|
21
|
+
if p.is_dir():
|
|
22
|
+
return p
|
|
23
|
+
|
|
16
24
|
d = Path.cwd()
|
|
17
25
|
while d != d.parent:
|
|
18
26
|
if (d / ".claudes").is_dir():
|
|
19
27
|
return d / ".claudes"
|
|
20
28
|
d = d.parent
|
|
21
|
-
raise FileNotFoundError(".claudes/ not found")
|
|
29
|
+
raise FileNotFoundError(".claudes/ not found (set CNB_PROJECT or run from project dir)")
|
|
22
30
|
|
|
23
31
|
|
|
24
32
|
def _parse_toml(path: Path) -> dict:
|
|
@@ -28,6 +36,30 @@ def _parse_toml(path: Path) -> dict:
|
|
|
28
36
|
return tomllib.loads(path.read_text())
|
|
29
37
|
|
|
30
38
|
|
|
39
|
+
def _write_config_toml(path: Path, data: dict) -> None:
|
|
40
|
+
"""Serialize *data* back to our config.toml format."""
|
|
41
|
+
lines: list[str] = []
|
|
42
|
+
for key, val in data.items():
|
|
43
|
+
if key == "session":
|
|
44
|
+
continue
|
|
45
|
+
if isinstance(val, list):
|
|
46
|
+
items = ", ".join(f'"{v}"' for v in val)
|
|
47
|
+
lines.append(f"{key} = [{items}]")
|
|
48
|
+
else:
|
|
49
|
+
lines.append(f'{key} = "{val}"')
|
|
50
|
+
lines.append("")
|
|
51
|
+
for name, section in data.get("session", {}).items():
|
|
52
|
+
lines.append(f"[session.{name}]")
|
|
53
|
+
for k, v in section.items():
|
|
54
|
+
sv = str(v)
|
|
55
|
+
if "\n" in sv:
|
|
56
|
+
lines.append(f'{k} = """{sv}"""')
|
|
57
|
+
else:
|
|
58
|
+
lines.append(f'{k} = "{sv}"')
|
|
59
|
+
lines.append("")
|
|
60
|
+
path.write_text("\n".join(lines) + "\n")
|
|
61
|
+
|
|
62
|
+
|
|
31
63
|
@dataclass
|
|
32
64
|
class ClaudesEnv:
|
|
33
65
|
claudes_dir: Path
|
|
@@ -84,6 +116,16 @@ def is_privileged(name: str) -> bool:
|
|
|
84
116
|
return name in PRIVILEGED_ROLES
|
|
85
117
|
|
|
86
118
|
|
|
119
|
+
def validate_identity(db: "BaseDB", identity: str) -> None:
|
|
120
|
+
name = identity.lower()
|
|
121
|
+
if name in PRIVILEGED_ROLES:
|
|
122
|
+
return
|
|
123
|
+
exists = db.scalar("SELECT COUNT(*) FROM sessions WHERE name=?", (name,))
|
|
124
|
+
if not exists:
|
|
125
|
+
print(f"ERROR: '{name}' is not a registered session")
|
|
126
|
+
raise SystemExit(1)
|
|
127
|
+
|
|
128
|
+
|
|
87
129
|
def is_terminal_task_status(status: str) -> bool:
|
|
88
130
|
"""Return True if *status* is a terminal state (task will not transition further).
|
|
89
131
|
|
|
@@ -190,7 +232,7 @@ class DB(BaseDB):
|
|
|
190
232
|
|
|
191
233
|
def execute(self, sql: str, params: tuple[Any, ...] = ()) -> int:
|
|
192
234
|
with self.conn() as c:
|
|
193
|
-
return c.execute(sql, params).lastrowid
|
|
235
|
+
return c.execute(sql, params).lastrowid or 0
|
|
194
236
|
|
|
195
237
|
|
|
196
238
|
# ---------------------------------------------------------------------------
|
package/lib/concerns/__init__.py
CHANGED
|
@@ -11,7 +11,8 @@ from .file_watcher import FileWatcher
|
|
|
11
11
|
from .health import HealthChecker, ResourceMonitor, SessionKeepAlive
|
|
12
12
|
from .helpers import log, tmux_ok, warn
|
|
13
13
|
from .idle import IdleDetector, IdleKiller, IdleNudger
|
|
14
|
-
from .notifications import BugSLAChecker, InboxNudger, TimeAnnouncer
|
|
14
|
+
from .notifications import BugSLAChecker, InboxNudger, QueuedMessageFlusher, TimeAnnouncer
|
|
15
|
+
from .nudge_coordinator import NudgeCoordinator
|
|
15
16
|
|
|
16
17
|
__all__ = [
|
|
17
18
|
"AdaptiveThrottle",
|
|
@@ -26,6 +27,8 @@ __all__ = [
|
|
|
26
27
|
"IdleKiller",
|
|
27
28
|
"IdleNudger",
|
|
28
29
|
"InboxNudger",
|
|
30
|
+
"NudgeCoordinator",
|
|
31
|
+
"QueuedMessageFlusher",
|
|
29
32
|
"ResourceMonitor",
|
|
30
33
|
"SessionKeepAlive",
|
|
31
34
|
"TimeAnnouncer",
|
package/lib/concerns/coral.py
CHANGED
|
@@ -38,7 +38,7 @@ class CoralManager(Concern):
|
|
|
38
38
|
log("Starting Coral...")
|
|
39
39
|
tmux("kill-session", "-t", self.cfg.coral_sess)
|
|
40
40
|
tmux("new-session", "-d", "-s", self.cfg.coral_sess, "-x", "200", "-y", "50")
|
|
41
|
-
tmux_send(self.cfg.coral_sess, f"cd '{self.cfg.project_root}'")
|
|
41
|
+
tmux_send(self.cfg.coral_sess, f"cd '{self.cfg.project_root}' && export CNB_PROJECT='{self.cfg.project_root}'")
|
|
42
42
|
time.sleep(0.5)
|
|
43
43
|
tmux_send(
|
|
44
44
|
self.cfg.coral_sess,
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""DigestScheduler — sends daily/weekly digests at scheduled times."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from lib.digest import generate_daily_digest
|
|
9
|
+
from lib.notification_config import load as load_config
|
|
10
|
+
|
|
11
|
+
from .base import Concern
|
|
12
|
+
from .config import DispatcherConfig
|
|
13
|
+
from .helpers import board_send, db, get_dev_sessions, log, warn
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DigestScheduler(Concern):
|
|
17
|
+
interval = 30
|
|
18
|
+
|
|
19
|
+
def __init__(self, cfg: DispatcherConfig) -> None:
|
|
20
|
+
super().__init__()
|
|
21
|
+
self.cfg = cfg
|
|
22
|
+
self._last_daily_date: str = ""
|
|
23
|
+
self._last_weekly_date: str = ""
|
|
24
|
+
|
|
25
|
+
def _config_path(self) -> Path:
|
|
26
|
+
return self.cfg.claudes_dir / "notifications.toml"
|
|
27
|
+
|
|
28
|
+
def _already_sent_today(self, notif_type: str, date_str: str) -> bool:
|
|
29
|
+
try:
|
|
30
|
+
count = db(self.cfg).scalar(
|
|
31
|
+
"SELECT COUNT(*) FROM notification_log WHERE notif_type=? AND ref_id=?",
|
|
32
|
+
(notif_type, f"digest-{date_str}"),
|
|
33
|
+
)
|
|
34
|
+
return bool(count)
|
|
35
|
+
except Exception:
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
def _record_digest(self, notif_type: str, recipient: str, date_str: str, channel: str) -> None:
|
|
39
|
+
try:
|
|
40
|
+
with db(self.cfg).conn() as c:
|
|
41
|
+
c.execute(
|
|
42
|
+
"INSERT INTO notification_log(notif_type, recipient, ref_type, ref_id, channel) VALUES(?,?,?,?,?)",
|
|
43
|
+
(notif_type, recipient, "digest", f"digest-{date_str}", channel),
|
|
44
|
+
)
|
|
45
|
+
except Exception as e:
|
|
46
|
+
warn(f"digest record: {e}")
|
|
47
|
+
|
|
48
|
+
def tick(self, now: int) -> None:
|
|
49
|
+
d = datetime.now()
|
|
50
|
+
if d.hour != 9 or d.minute > 5:
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
date_str = d.strftime("%Y-%m-%d")
|
|
54
|
+
|
|
55
|
+
if date_str != self._last_daily_date:
|
|
56
|
+
self._send_daily(date_str)
|
|
57
|
+
self._last_daily_date = date_str
|
|
58
|
+
|
|
59
|
+
if d.weekday() == 0 and date_str != self._last_weekly_date:
|
|
60
|
+
self._send_weekly(date_str)
|
|
61
|
+
self._last_weekly_date = date_str
|
|
62
|
+
|
|
63
|
+
def _send_daily(self, date_str: str) -> None:
|
|
64
|
+
if self._already_sent_today("daily-digest", date_str):
|
|
65
|
+
log(f"Daily digest already sent for {date_str}, skipping")
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
config = load_config(self._config_path())
|
|
69
|
+
members = get_dev_sessions(self.cfg)
|
|
70
|
+
subscribers = config.subscribers_for("daily-digest", members)
|
|
71
|
+
|
|
72
|
+
if not subscribers:
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
board = db(self.cfg)
|
|
77
|
+
digest_text = generate_daily_digest(board)
|
|
78
|
+
except Exception as e:
|
|
79
|
+
warn(f"digest generation failed: {e}")
|
|
80
|
+
return
|
|
81
|
+
|
|
82
|
+
for member in subscribers:
|
|
83
|
+
channel = config.channel_for(member)
|
|
84
|
+
if channel == "board-inbox":
|
|
85
|
+
board_send(self.cfg, member, digest_text)
|
|
86
|
+
else:
|
|
87
|
+
log(f"[digest] {channel} delivery not implemented for {member}")
|
|
88
|
+
self._record_digest("daily-digest", member, date_str, channel)
|
|
89
|
+
|
|
90
|
+
log(f"Daily digest sent to {len(subscribers)} subscribers")
|
|
91
|
+
|
|
92
|
+
def _send_weekly(self, date_str: str) -> None:
|
|
93
|
+
if self._already_sent_today("weekly-report", date_str):
|
|
94
|
+
log(f"Weekly report already sent for {date_str}, skipping")
|
|
95
|
+
return
|
|
96
|
+
|
|
97
|
+
config = load_config(self._config_path())
|
|
98
|
+
members = get_dev_sessions(self.cfg)
|
|
99
|
+
subscribers = config.subscribers_for("weekly-report", members)
|
|
100
|
+
|
|
101
|
+
if not subscribers:
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
board_send(self.cfg, "all", f"[Weekly Report] {date_str} — 本周报告待实现")
|
|
105
|
+
for member in subscribers:
|
|
106
|
+
channel = config.channel_for(member)
|
|
107
|
+
self._record_digest("weekly-report", member, date_str, channel)
|
|
108
|
+
|
|
109
|
+
log(f"Weekly report sent to {len(subscribers)} subscribers")
|