claude-nb 0.3.0 → 0.4.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/lib/board_db.py CHANGED
@@ -1,4 +1,4 @@
1
- """board_db — DB connection, helpers, and .md file sync."""
1
+ """board_db — DB connection and helpers."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -25,7 +25,7 @@ class BoardDB(BaseDB):
25
25
  """Unified SQLite wrapper — new connection per call, no pooling.
26
26
 
27
27
  Accepts either a ClaudesEnv (production) or a bare Path/str (tests, lightweight callers).
28
- Adds .md file sync helpers on top of the BaseDB interface.
28
+ SQLite is the single source of truth; no .md file sync.
29
29
  """
30
30
 
31
31
  def __init__(self, env_or_path: ClaudesEnv | Path | str):
@@ -111,145 +111,24 @@ class BoardDB(BaseDB):
111
111
  if existing == 0:
112
112
  self.execute("INSERT INTO sessions(name) VALUES (?)", (n,), c=c)
113
113
 
114
- # --- .md file sync ---
115
- #
116
- # All writes use temp-file + atomic rename to avoid corruption on
117
- # crash mid-write. Section detection is strict: ## heading must be
118
- # followed by space or end-of-line (not e.g. "## @收件箱-extra").
119
-
120
- INBOX_HEADINGS: tuple[str, ...] = ("## @inbox", "## @收件箱")
121
- TASK_HEADINGS: tuple[str, ...] = ("## Current task", "## 当前任务")
122
-
123
- @staticmethod
124
- def _replace_section(
125
- text: str,
126
- headings: tuple[str, ...],
127
- replacement: str | None,
128
- ) -> str | None:
129
- """Replace or remove the content of a named Markdown section.
130
-
131
- If *replacement* is None, the section is removed. Returns the new
132
- text, or None if the section was not found and no append is needed.
133
-
134
- Heading detection: a line is a section boundary if it starts with "## "
135
- (after stripping leading whitespace). Target headings are matched
136
- case-insensitively against the normalized prefix.
137
- """
138
- lines = text.split("\n")
139
- out: list[str] = []
140
- in_section = False
141
- found = False
142
-
143
- normalized_headings = tuple(h.strip().lower() for h in headings)
144
-
145
- for line in lines:
146
- stripped = line.strip().lower()
147
- if any(stripped == h or stripped.startswith(h + " ") for h in normalized_headings):
148
- found = True
149
- in_section = True
150
- if replacement is not None:
151
- out.append(line)
152
- out.append(replacement)
153
- continue
154
- if in_section and stripped.startswith("## "):
155
- in_section = False
156
- out.append("")
157
- out.append(line)
158
- continue
159
- if in_section:
160
- continue
161
- out.append(line)
162
-
163
- if not found:
164
- return None
165
- return "\n".join(out)
166
-
167
- def _atomic_write(self, path: Path, content: str) -> None:
168
- """Write *content* to *path* atomically (temp + rename)."""
169
- tmp = path.with_suffix(path.suffix + ".tmp")
170
- try:
171
- tmp.write_text(content)
172
- tmp.replace(path)
173
- except OSError:
174
- pass
175
-
176
- # ── inbox sync ──
177
-
178
- def sync_inbox_to_file(self, target: str, *, c: sqlite3.Connection | None = None) -> None:
179
- """Sync unread inbox messages to {target}.md file.
180
-
181
- Accepts an optional *c* to use the caller's connection (so that
182
- uncommitted inbox rows are visible within a transaction).
183
- """
184
- if not self.env:
185
- return
186
- sf = self.env.sessions_dir / f"{target}.md"
187
- if not sf.exists():
188
- return
189
- rows = self.query(
190
- "SELECT '- [' || m.ts || '] **' || m.sender || '**: ' || substr(m.body, 1, 60) "
191
- "FROM inbox i JOIN messages m ON i.message_id=m.id "
192
- "WHERE i.session=? AND i.read=0 ORDER BY m.ts",
193
- (target,),
194
- c=c,
195
- )
196
- inbox_lines = "\n".join(r[0] for r in rows) if rows else ""
197
-
198
- try:
199
- text = sf.read_text()
200
- except OSError:
201
- return
202
-
203
- result = self._replace_section(text, self.INBOX_HEADINGS, inbox_lines or None)
204
- if result is not None:
205
- self._atomic_write(sf, result)
206
- elif inbox_lines:
207
- heading = self.INBOX_HEADINGS[1] # canonical: Chinese heading
208
- self._atomic_write(sf, text.rstrip("\n") + f"\n\n{heading}\n{inbox_lines}\n")
209
-
210
- def clear_inbox_file(self, target: str) -> None:
211
- if not self.env:
212
- return
213
- sf = self.env.sessions_dir / f"{target}.md"
214
- if not sf.exists():
215
- return
216
- try:
217
- text = sf.read_text()
218
- except OSError:
219
- return
220
- result = self._replace_section(text, self.INBOX_HEADINGS, None)
221
- if result is not None:
222
- self._atomic_write(sf, result)
223
-
224
- # ── status sync ──
225
-
226
- def sync_status_to_file(self, target: str, status: str) -> None:
227
- if not self.env:
228
- return
229
- sf = self.env.sessions_dir / f"{target}.md"
230
- if not sf.exists():
231
- return
232
- try:
233
- text = sf.read_text()
234
- except OSError:
235
- return
236
- result = self._replace_section(text, self.TASK_HEADINGS, status)
237
- if result is not None:
238
- self._atomic_write(sf, result)
239
-
240
114
  def deliver_to_inbox(
241
115
  self, sender: str, recipient: str, msg_id: int, *, c: sqlite3.Connection | None = None
242
116
  ) -> None:
243
117
  if recipient == "all":
244
- sessions = self.query("SELECT name FROM sessions WHERE name != ?", (sender,), c=c)
245
- for (target,) in sessions:
246
- self.execute(
247
- "INSERT INTO inbox(session, message_id) VALUES (?, ?)",
248
- (target, msg_id),
249
- c=c,
118
+
119
+ def _do(conn: sqlite3.Connection) -> list[str]:
120
+ conn.execute(
121
+ "INSERT INTO inbox(session, message_id) SELECT name, ? FROM sessions WHERE name != ?",
122
+ (msg_id, sender),
250
123
  )
251
- for (target,) in sessions:
252
- self.sync_inbox_to_file(target, c=c)
124
+ return [r[0] for r in conn.execute("SELECT name FROM sessions WHERE name != ?", (sender,)).fetchall()]
125
+
126
+ if c is not None:
127
+ targets = _do(c)
128
+ else:
129
+ with self.conn() as conn:
130
+ targets = _do(conn)
131
+ for target in targets:
253
132
  inbox_delivered.emit(target)
254
133
  else:
255
134
  self.ensure_session(recipient, c=c)
@@ -258,5 +137,4 @@ class BoardDB(BaseDB):
258
137
  (recipient, msg_id),
259
138
  c=c,
260
139
  )
261
- self.sync_inbox_to_file(recipient, c=c)
262
140
  inbox_delivered.emit(recipient)
package/lib/board_lock.py CHANGED
@@ -72,7 +72,6 @@ def cmd_git_unlock(db: BoardDB, identity: str, args: list[str]) -> None:
72
72
  (now, holder, f"[GIT-LOCK] {name} force-released your git lock"),
73
73
  )
74
74
  db.execute("INSERT INTO inbox(session, message_id) VALUES (?, ?)", (holder, msg_id))
75
- db.sync_inbox_to_file(holder)
76
75
 
77
76
  db.execute("DELETE FROM git_locks WHERE id=1")
78
77
  print("OK git-lock released")
@@ -1,8 +1,11 @@
1
1
  """board_mailbox — encrypted async messaging between registered agents."""
2
2
 
3
+ import base64
3
4
  import json
4
5
  from pathlib import Path
5
6
 
7
+ from cryptography.exceptions import InvalidTag
8
+
6
9
  from lib.board_db import BoardDB, ts
7
10
  from lib.crypto import (
8
11
  generate_keypair,
@@ -66,7 +69,11 @@ def cmd_seal(db: BoardDB, identity: str, args: list[str]) -> None:
66
69
  raise SystemExit(1)
67
70
 
68
71
  recipient = args[0].lower()
69
- plaintext = " ".join(args[1:])
72
+ plaintext = " ".join(args[1:]).strip()
73
+
74
+ if not plaintext:
75
+ print("ERROR: 消息不能为空")
76
+ raise SystemExit(1)
70
77
 
71
78
  recipient_pubkey_hex = _find_pubkey(recipient)
72
79
  if not recipient_pubkey_hex:
@@ -110,12 +117,13 @@ def cmd_unseal(db: BoardDB, identity: str) -> None:
110
117
  plaintext = unseal_b64(encrypted_body, private)
111
118
  print(f" [{msg_ts}] **{sender}**: {plaintext}")
112
119
  decrypted_ids.append(msg_id)
113
- except Exception:
114
- print(f" [{msg_ts}] **{sender}**: [解密失败 — 密钥不匹配或消息损坏]")
120
+ except (InvalidTag, ValueError, base64.binascii.Error, UnicodeDecodeError) as e:
121
+ print(f" [{msg_ts}] **{sender}**: [解密失败 — {type(e).__name__}: {e}]")
115
122
 
116
123
  if decrypted_ids:
117
- placeholders = ",".join("?" * len(decrypted_ids))
118
- db.execute(f"UPDATE mailbox SET read=1 WHERE id IN ({placeholders})", tuple(decrypted_ids))
124
+ with db.conn() as c:
125
+ for mid in decrypted_ids:
126
+ db.execute("UPDATE mailbox SET read=1 WHERE id=?", (mid,), c=c)
119
127
  print(f"\n已标记 {len(decrypted_ids)} 条为已读")
120
128
 
121
129
 
@@ -141,5 +149,5 @@ def cmd_mailbox_log(db: BoardDB, identity: str) -> None:
141
149
  try:
142
150
  plaintext = unseal_b64(encrypted_body, private)
143
151
  print(f" [{msg_ts}] {sender}: {plaintext}")
144
- except Exception:
145
- print(f" [{msg_ts}] {sender}: [无法解密]")
152
+ except (InvalidTag, ValueError, base64.binascii.Error, UnicodeDecodeError) as e:
153
+ print(f" [{msg_ts}] {sender}: [无法解密 — {type(e).__name__}: {e}]")
@@ -1,6 +1,5 @@
1
1
  """board_maintenance — data maintenance: prune, backup, restore."""
2
2
 
3
- import shutil
4
3
  import time
5
4
  from pathlib import Path
6
5
 
@@ -14,9 +13,9 @@ from lib.board_db import BoardDB
14
13
  def cmd_prune(db: BoardDB, args: list[str]) -> None:
15
14
  """Prune old messages and inbox entries.
16
15
 
17
- Usage: board --as <name> prune [--before YYYY-MM-DD] [--keep N] [--dry-run]
16
+ Usage: board --as <name> prune [--before YYYY-MM-DD] [--dry-run]
18
17
  """
19
- usage = "Usage: board --as <name> prune [--before YYYY-MM-DD] [--keep N] [--dry-run]"
18
+ usage = "Usage: board --as <name> prune [--before YYYY-MM-DD] [--dry-run]"
20
19
 
21
20
  before_days = 90 # default: keep 90 days
22
21
  dry_run = False
@@ -27,13 +26,6 @@ def cmd_prune(db: BoardDB, args: list[str]) -> None:
27
26
  if args[i] == "--before" and i + 1 < len(args):
28
27
  before_days = _parse_days(args[i + 1])
29
28
  i += 2
30
- elif args[i] == "--keep" and i + 1 < len(args):
31
- try:
32
- int(args[i + 1])
33
- except ValueError:
34
- print(f"ERROR: --keep requires a number, got '{args[i + 1]}'")
35
- raise SystemExit(1)
36
- i += 2
37
29
  elif args[i] == "--dry-run":
38
30
  dry_run = True
39
31
  i += 1
@@ -48,22 +40,29 @@ def cmd_prune(db: BoardDB, args: list[str]) -> None:
48
40
  cutoff = _days_ago_ts(before_days)
49
41
 
50
42
  # Count what would be deleted
51
- old_inbox = db.scalar(
52
- "SELECT COUNT(*) FROM inbox WHERE message_id IN "
53
- "(SELECT id FROM messages WHERE ts < ?)",
54
- (cutoff,),
55
- ) or 0
56
- old_messages = db.scalar(
57
- "SELECT COUNT(*) FROM messages WHERE ts < ?",
58
- (cutoff,),
59
- ) or 0
43
+ old_inbox = (
44
+ db.scalar(
45
+ "SELECT COUNT(*) FROM inbox WHERE message_id IN (SELECT id FROM messages WHERE ts < ?)",
46
+ (cutoff,),
47
+ )
48
+ or 0
49
+ )
50
+ old_messages = (
51
+ db.scalar(
52
+ "SELECT COUNT(*) FROM messages WHERE ts < ?",
53
+ (cutoff,),
54
+ )
55
+ or 0
56
+ )
60
57
 
61
58
  # Also count old read inbox entries (not just those linked to old messages)
62
- old_read_inbox = db.scalar(
63
- "SELECT COUNT(*) FROM inbox WHERE read=1 AND "
64
- "delivered_at < ?",
65
- (cutoff,),
66
- ) or 0
59
+ old_read_inbox = (
60
+ db.scalar(
61
+ "SELECT COUNT(*) FROM inbox WHERE read=1 AND delivered_at < ?",
62
+ (cutoff,),
63
+ )
64
+ or 0
65
+ )
67
66
 
68
67
  if dry_run:
69
68
  print("=== DRY RUN: would delete ===")
@@ -204,9 +203,7 @@ def cmd_restore(db: BoardDB, args: list[str]) -> None:
204
203
  try:
205
204
  conn = sqlite3.connect(str(source))
206
205
  conn.execute("PRAGMA integrity_check")
207
- tables = conn.execute(
208
- "SELECT name FROM sqlite_master WHERE type='table'"
209
- ).fetchall()
206
+ tables = conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()
210
207
  conn.close()
211
208
  if not tables:
212
209
  print("ERROR: backup file contains no tables")
@@ -229,6 +226,8 @@ def cmd_restore(db: BoardDB, args: list[str]) -> None:
229
226
  return
230
227
 
231
228
  # Atomic restore: copy to temp, rename
229
+ import shutil
230
+
232
231
  tmp = db.db_path.with_suffix(".db.restore-tmp")
233
232
  shutil.copy2(source, tmp)
234
233
  tmp.replace(db.db_path)
package/lib/board_msg.py CHANGED
@@ -2,7 +2,6 @@
2
2
 
3
3
  import hashlib
4
4
  import shutil
5
- import subprocess
6
5
  from pathlib import Path
7
6
 
8
7
  from lib.board_db import BoardDB, ts
@@ -14,29 +13,6 @@ def _ack_marker_path(db: BoardDB, name: str) -> Path:
14
13
  return db.env.sessions_dir / f".{name}.ack_max_id"
15
14
 
16
15
 
17
- def _nudge_session(db: BoardDB, recipient: str) -> None:
18
- """Inject a fixed prompt into the recipient's tmux session to check inbox.
19
-
20
- Only sends a hardcoded command — never user-controlled content — to avoid
21
- shell metacharacter injection via tmux send-keys.
22
- """
23
- if recipient == "all":
24
- sessions = [r[0] for r in db.query("SELECT name FROM sessions WHERE name != 'all'")]
25
- else:
26
- sessions = [recipient]
27
- prefix = db.env.prefix
28
- board = db.env.install_home / "bin" / "board"
29
- for name in sessions:
30
- if not name.isalnum():
31
- continue
32
- sess = f"{prefix}-{name}"
33
- r = subprocess.run(["tmux", "has-session", "-t", sess], capture_output=True)
34
- if r.returncode == 0:
35
- cmd = f"{board} --as {name} inbox"
36
- subprocess.run(["tmux", "send-keys", "-t", sess, "-l", cmd], capture_output=True)
37
- subprocess.run(["tmux", "send-keys", "-t", sess, "Enter"], capture_output=True)
38
-
39
-
40
16
  def cmd_send(db: BoardDB, identity: str, args: list[str]) -> None:
41
17
  name = identity.lower()
42
18
  flags, send_args = parse_flags(args, value_flags={"attach": ["--attach", "-a"]})
@@ -100,10 +76,6 @@ def cmd_send(db: BoardDB, identity: str, args: list[str]) -> None:
100
76
  db.deliver_to_inbox(name, to, msg_id, c=c)
101
77
 
102
78
  print("OK sent")
103
- if attach_ref:
104
- print(f" 附件已存储: {stored_path}")
105
-
106
- _nudge_session(db, to)
107
79
 
108
80
 
109
81
  def cmd_status(db: BoardDB, identity: str, args: list[str]) -> None:
@@ -118,8 +90,7 @@ def cmd_status(db: BoardDB, identity: str, args: list[str]) -> None:
118
90
  "UPDATE sessions SET status=?, updated_at=? WHERE name=?",
119
91
  (full_status, now, name),
120
92
  )
121
- db.sync_status_to_file(name, full_status)
122
- print("OK status updated")
93
+ print("OK")
123
94
 
124
95
 
125
96
  def cmd_inbox(db: BoardDB, identity: str) -> None:
@@ -129,25 +100,21 @@ def cmd_inbox(db: BoardDB, identity: str) -> None:
129
100
  raise SystemExit(1)
130
101
  count = db.scalar("SELECT COUNT(*) FROM inbox WHERE session=? AND read=0", (name,))
131
102
  if not count:
132
- print("收件箱为空")
133
- else:
134
- print(f"你有 {count} 条未读:")
135
- rows = db.query(
136
- "SELECT i.message_id, '- [' || m.ts || '] **' || m.sender || '**: ' || m.body "
137
- "FROM inbox i JOIN messages m ON i.message_id=m.id "
138
- "WHERE i.session=? AND i.read=0 ORDER BY m.ts",
139
- (name,),
140
- )
141
- max_id = 0
142
- for msg_id, line in rows:
143
- print(line)
144
- if msg_id > max_id:
145
- max_id = msg_id
146
- if max_id > 0:
147
- _ack_marker_path(db, name).write_text(str(max_id))
148
- print(f"\n(运行 ack 清除: board --as {identity} ack)")
149
-
150
- _task_print_queue_short(db, name)
103
+ print("(empty)")
104
+ return
105
+ rows = db.query(
106
+ "SELECT i.message_id, m.ts, m.sender, m.body "
107
+ "FROM inbox i JOIN messages m ON i.message_id=m.id "
108
+ "WHERE i.session=? AND i.read=0 ORDER BY m.ts",
109
+ (name,),
110
+ )
111
+ max_id = 0
112
+ for msg_id, msg_ts, sender, body in rows:
113
+ print(f'<message from="{sender}" ts="{msg_ts}">\n{body}\n</message>')
114
+ if msg_id > max_id:
115
+ max_id = msg_id
116
+ if max_id > 0:
117
+ _ack_marker_path(db, name).write_text(str(max_id))
151
118
 
152
119
 
153
120
  def cmd_ack(db: BoardDB, identity: str) -> None:
@@ -169,8 +136,7 @@ def cmd_ack(db: BoardDB, identity: str) -> None:
169
136
  count = db.scalar("SELECT COUNT(*) FROM inbox WHERE session=? AND read=0", (name,))
170
137
 
171
138
  if count == 0:
172
- print("收件箱已经是空的")
173
- db.clear_inbox_file(name)
139
+ print("OK")
174
140
  marker.unlink(missing_ok=True)
175
141
  return
176
142
 
@@ -182,8 +148,7 @@ def cmd_ack(db: BoardDB, identity: str) -> None:
182
148
  else:
183
149
  db.execute("UPDATE inbox SET read=1 WHERE session=? AND read=0", (name,))
184
150
 
185
- print(f"OK {count} 条已清空(完整记录在 messages.log)")
186
- db.clear_inbox_file(name)
151
+ print(f"OK {count}")
187
152
  marker.unlink(missing_ok=True)
188
153
 
189
154
 
@@ -212,19 +177,3 @@ def cmd_log(db: BoardDB, identity: str, args: list[str]) -> None:
212
177
  )
213
178
  for (line,) in reversed(rows):
214
179
  print(line)
215
-
216
-
217
- def _task_print_queue_short(db: BoardDB, target: str) -> None:
218
- rows = db.query(
219
- "SELECT id, status, priority, description FROM tasks "
220
- "WHERE session=? AND status != 'done' "
221
- "ORDER BY CASE status WHEN 'active' THEN 0 ELSE 1 END, priority DESC, id ASC",
222
- (target,),
223
- )
224
- print("\n任务队列:")
225
- if not rows:
226
- print(" (无待办任务)")
227
- return
228
- for tid, status, priority, desc in rows:
229
- marker = "*" if status == "active" else " "
230
- print(f" {marker} #{tid} [{status} p{priority}] {desc}")
package/lib/board_task.py CHANGED
@@ -5,15 +5,17 @@ from lib.common import is_privileged, is_terminal_task_status, parse_flags
5
5
 
6
6
 
7
7
  def _promote_next(db: BoardDB, target: str) -> None:
8
- active = db.scalar("SELECT COUNT(*) FROM tasks WHERE session=? AND status='active'", (target,))
9
- if active:
10
- return
11
- next_id = db.scalar(
12
- "SELECT id FROM tasks WHERE session=? AND status='pending' ORDER BY priority DESC, id ASC LIMIT 1",
13
- (target,),
14
- )
15
- if next_id:
16
- db.execute("UPDATE tasks SET status='active' WHERE id=?", (next_id,))
8
+ with db.conn() as c:
9
+ active = db.scalar("SELECT COUNT(*) FROM tasks WHERE session=? AND status='active'", (target,), c=c)
10
+ if active:
11
+ return
12
+ next_id = db.scalar(
13
+ "SELECT id FROM tasks WHERE session=? AND status='pending' ORDER BY priority DESC, id ASC LIMIT 1",
14
+ (target,),
15
+ c=c,
16
+ )
17
+ if next_id:
18
+ db.execute("UPDATE tasks SET status='active' WHERE id=?", (next_id,), c=c)
17
19
 
18
20
 
19
21
  def _print_queue(db: BoardDB, target: str, include_done: bool = False) -> None:
@@ -77,27 +79,27 @@ def _task_add(db: BoardDB, identity: str, args: list[str]) -> None:
77
79
 
78
80
  desc = " ".join(desc_parts)
79
81
 
80
- db.ensure_session(target)
82
+ with db.conn() as c:
83
+ db.ensure_session(target, c=c)
81
84
 
82
- active = db.scalar("SELECT COUNT(*) FROM tasks WHERE session=? AND status='active'", (target,))
83
- status = "active" if not active else "pending"
85
+ active = db.scalar("SELECT COUNT(*) FROM tasks WHERE session=? AND status='active'", (target,), c=c)
86
+ status = "active" if not active else "pending"
84
87
 
85
- task_id = db.execute(
86
- "INSERT INTO tasks(session, description, status, priority) VALUES (?, ?, ?, ?)",
87
- (target, desc, status, priority),
88
- )
89
- print(f"OK task #{task_id} added to {target} ({status})")
90
-
91
- if target != name:
92
- now = ts()
93
- msg_id = db.execute(
94
- "INSERT INTO messages(ts, sender, recipient, body) VALUES (?, ?, ?, ?)",
95
- (now, name, target, f"[TASK #{task_id}] {desc}"),
88
+ task_id = db.execute(
89
+ "INSERT INTO tasks(session, description, status, priority) VALUES (?, ?, ?, ?)",
90
+ (target, desc, status, priority),
91
+ c=c,
96
92
  )
97
- db.execute("INSERT INTO inbox(session, message_id) VALUES (?, ?)", (target, msg_id))
98
- db.sync_inbox_to_file(target)
99
- print(f"OK notified {target}")
100
- _print_queue(db, target)
93
+ print(f"OK #{task_id}")
94
+
95
+ if target != name:
96
+ now = ts()
97
+ msg_id = db.execute(
98
+ "INSERT INTO messages(ts, sender, recipient, body) VALUES (?, ?, ?, ?)",
99
+ (now, name, target, f"[TASK #{task_id}] {desc}"),
100
+ c=c,
101
+ )
102
+ db.execute("INSERT INTO inbox(session, message_id) VALUES (?, ?)", (target, msg_id), c=c)
101
103
 
102
104
 
103
105
  def _task_done(db: BoardDB, identity: str, args: list[str]) -> None:
@@ -116,7 +118,11 @@ def _task_done(db: BoardDB, identity: str, args: list[str]) -> None:
116
118
  _print_queue(db, name)
117
119
  return
118
120
 
119
- task_id = int(task_id)
121
+ try:
122
+ task_id = int(task_id)
123
+ except ValueError:
124
+ print(f"ERROR: 无效的任务 ID: {task_id}")
125
+ raise SystemExit(1)
120
126
  row = db.query_one("SELECT session, status, description FROM tasks WHERE id=?", (task_id,))
121
127
  if not row:
122
128
  print(f"ERROR: task #{task_id} not found")
@@ -134,18 +140,9 @@ def _task_done(db: BoardDB, identity: str, args: list[str]) -> None:
134
140
 
135
141
  now = ts()
136
142
  db.execute("UPDATE tasks SET status='done', done_at=? WHERE id=?", (now, task_id))
137
- print(f"OK task #{task_id} done: {desc}")
143
+ print(f"OK #{task_id} done")
138
144
 
139
145
  _promote_next(db, assignee)
140
- nxt = db.query_one(
141
- "SELECT id, description FROM tasks WHERE session=? AND status='active' ORDER BY id ASC LIMIT 1",
142
- (assignee,),
143
- )
144
- if nxt:
145
- print(f"Next: #{nxt[0]} {nxt[1]}")
146
- else:
147
- print(f"No remaining active/pending tasks for {assignee}.")
148
- _print_queue(db, assignee)
149
146
 
150
147
 
151
148
  def _task_list(db: BoardDB, identity: str, args: list[str]) -> None: