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.
Files changed (49) hide show
  1. package/Makefile +8 -2
  2. package/README.md +40 -56
  3. package/VERSION +1 -1
  4. package/bin/board +102 -34
  5. package/bin/cnb +59 -33
  6. package/bin/dispatcher +25 -11
  7. package/bin/doctor +3 -5
  8. package/bin/init +8 -8
  9. package/bin/notify +224 -0
  10. package/bin/registry +8 -23
  11. package/bin/sync-version +131 -0
  12. package/lib/board_admin.py +19 -9
  13. package/lib/board_bbs.py +23 -8
  14. package/lib/board_bug.py +2 -1
  15. package/lib/board_db.py +18 -6
  16. package/lib/board_lock.py +5 -0
  17. package/lib/board_mailbox.py +6 -4
  18. package/lib/board_msg.py +112 -24
  19. package/lib/board_pending.py +233 -0
  20. package/lib/board_pulse.py +14 -0
  21. package/lib/board_task.py +22 -10
  22. package/lib/board_tui.py +28 -20
  23. package/lib/board_view.py +60 -28
  24. package/lib/board_vote.py +9 -3
  25. package/lib/build_lock.py +7 -7
  26. package/lib/common.py +45 -3
  27. package/lib/concerns/__init__.py +4 -1
  28. package/lib/concerns/coral.py +1 -1
  29. package/lib/concerns/digest_scheduler.py +109 -0
  30. package/lib/concerns/file_watcher.py +73 -68
  31. package/lib/concerns/health.py +1 -1
  32. package/lib/concerns/notification_push.py +171 -0
  33. package/lib/concerns/notifications.py +58 -3
  34. package/lib/concerns/nudge_coordinator.py +148 -0
  35. package/lib/digest.py +62 -0
  36. package/lib/health.py +2 -2
  37. package/lib/inject.py +2 -2
  38. package/lib/monitor.py +8 -4
  39. package/lib/notification_config.py +101 -0
  40. package/lib/swarm.py +43 -35
  41. package/lib/swarm_backend.py +63 -29
  42. package/lib/theme_profiles.py +89 -0
  43. package/migrations/004_heartbeat.sql +1 -0
  44. package/migrations/005_notification_log.sql +12 -0
  45. package/migrations/006_pending_actions.sql +15 -0
  46. package/package.json +4 -3
  47. package/pyproject.toml +3 -2
  48. package/registry/README.md +9 -0
  49. 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
- task_id = args[0] if args else None
111
+ raw_id: str | int | None = args[0] if args else None
109
112
 
110
- if not task_id:
113
+ if not raw_id:
111
114
  _promote_next(db, name)
112
- task_id = db.scalar(
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 task_id:
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(task_id)
123
- except ValueError:
124
- print(f"ERROR: 无效的任务 ID: {task_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
- # Kill old UI session if exists
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
- # Create session grouped with first worker
52
- _tmux("new-session", "-d", "-s", UI_SESSION, "-t", f"{prefix}-{online[0]}")
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
- # Mouse + clean visual config
65
- opts = {
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" {len(online)} online ",
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 k, v in opts.items():
82
- _tmux("set-option", "-t", UI_SESSION, k, v)
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 window attached to cnb-ui."""
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"运行: {attach_cmd}")
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 (name,) in db.query("SELECT name FROM sessions WHERE name != 'all' ORDER BY name"):
53
- sess = f"{prefix}-{name}"
54
- if _tmux_has_session(sess):
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) > 72:
129
- task = task[:69] + "..."
130
- print(f" {cap:<8s} {task}{tag}")
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 (name,) in db.query("SELECT name FROM sessions ORDER BY name"):
234
- session_name = f"{prefix}-{name}"
235
- status = "offline"
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 = db.scalar("SELECT substr(status, 1, 50) FROM sessions WHERE name=?", (name,)) or "-"
246
- print(f" {name:<7s} {status:<8s}{inbox_str}")
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 update':<20s} {'Unread inbox'}")
302
- print(f" {'-------':<8s} {'------------------':<20s} {'------------'}")
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("SELECT COUNT(*) FROM sessions WHERE name != (SELECT value FROM meta WHERE key='dispatcher_session')")
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("SELECT COUNT(*) FROM sessions WHERE name != (SELECT value FROM meta WHERE key='dispatcher_session')")
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
- sys.exit(1)
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
- sys.exit(1)
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
- sys.exit(1)
198
+ raise SystemExit(1)
199
199
  ok, msg = lock.release(args[1])
200
200
  print(msg)
201
201
  if not ok:
202
- sys.exit(1)
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
- sys.exit(1)
210
+ raise SystemExit(1)
211
211
  session = args[1]
212
212
  command = args[2:]
213
- sys.exit(lock.wrap(session, command))
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
- sys.exit(1)
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
- """Walk up from cwd to find the .claudes/ directory."""
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
  # ---------------------------------------------------------------------------
@@ -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",
@@ -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")