dsh-project-deck 0.0.0-stage → 0.1.2

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/backend/deck ADDED
@@ -0,0 +1,1765 @@
1
+ #!/usr/bin/env python3
2
+ """deck —— 项目甲板 的项目注册表与微信周期播报。
3
+
4
+ 用法:
5
+ deck list 列出项目
6
+ deck preview 生成播报文本(不发送)
7
+ deck report 立即生成并推送到微信
8
+ deck every <小时> 设置播报间隔(可小数,如 0.5 / 4)
9
+ deck on | off 打开 / 关闭周期播报
10
+ deck status 服务与配置状态
11
+ deck add <id> --root <path> [--name 名称] [--kind code] [--note 说明]
12
+ deck rm <id> 移除项目
13
+ deck scan [--add] 扫描发现新项目;--add 按阈值自动登记
14
+ deck image [--no-send|--on|--off] 生成看板图并推送;--on/--off 开关周期附图
15
+ deck daemon (由 launchd 调用)周期播报守护进程
16
+ """
17
+ import base64
18
+ import fcntl
19
+ import glob
20
+ import hashlib
21
+ import json
22
+ import os
23
+ import re
24
+ import secrets
25
+ import subprocess
26
+ import sys
27
+ import time
28
+ import urllib.parse
29
+ import urllib.request
30
+ from datetime import datetime
31
+
32
+ HOME = os.path.expanduser('~')
33
+ BASE = os.path.expanduser(os.environ.get('DECK_HOME', '~/.project-deck'))
34
+ CONFIG = os.path.join(BASE, 'config.json')
35
+ REGISTRY = os.path.join(BASE, 'registry.json')
36
+ STATE = os.path.join(BASE, 'report-state.json')
37
+ LAST = os.path.join(BASE, 'last-report.txt')
38
+ HISTORY = os.path.join(BASE, 'reports.jsonl')
39
+ CHANNEL_STATE = os.path.join(BASE, 'channel-state.json')
40
+ LOG = os.path.join(BASE, 'logs', 'daemon.log')
41
+ WXSEND = os.environ.get('DECK_WXSEND', os.path.join(HOME, 'tools', 'wxsend', 'wxsend'))
42
+ LOCK = os.path.join(BASE, 'daemon.lock')
43
+
44
+ KINDS = {'code': '代码', 'content': '内容', 'ops': '运维', 'life': '生活', 'research': '研究'}
45
+
46
+
47
+ # ── 基础读写 ────────────────────────────────────────────────
48
+ def load_json(path, default):
49
+ try:
50
+ with open(path) as f:
51
+ return json.load(f)
52
+ except (FileNotFoundError, json.JSONDecodeError):
53
+ return default
54
+
55
+
56
+ def save_json(path, data):
57
+ os.makedirs(os.path.dirname(path), exist_ok=True)
58
+ tmp = path + '.tmp'
59
+ with open(tmp, 'w') as f:
60
+ json.dump(data, f, ensure_ascii=False, indent=2)
61
+ f.write('\n')
62
+ os.replace(tmp, path)
63
+
64
+
65
+ def log(msg):
66
+ os.makedirs(os.path.dirname(LOG), exist_ok=True)
67
+ with open(LOG, 'a') as f:
68
+ f.write(f'[{datetime.now():%Y-%m-%d %H:%M:%S}] {msg}\n')
69
+
70
+
71
+ # ── 项目数据采集 ────────────────────────────────────────────
72
+ def state_file(p):
73
+ mem = p.get('memory')
74
+ if not mem:
75
+ return None
76
+ path = os.path.join(HOME, '.ai', 'projects', mem.get('project', p['id']),
77
+ 'memory', 'designs', mem.get('slug', p['id']), 'STATE.md')
78
+ return path if os.path.exists(path) else None
79
+
80
+
81
+ NEXT_RE = re.compile(r'next[_ ]?action\s*[::]\s*\*{0,2}\s*(.+)', re.I)
82
+ STATUS_RE = re.compile(r'^\s*[-*]?\s*(?:status|状态)\s*[::]\s*(.+)', re.I | re.M)
83
+
84
+
85
+ def parse_state(path):
86
+ try:
87
+ text = open(path, encoding='utf-8', errors='replace').read(200000)
88
+ except OSError:
89
+ return None, None
90
+ m = NEXT_RE.search(text)
91
+ nxt = m.group(1).strip().strip('*').strip()[:120] if m else None
92
+ s = STATUS_RE.search(text)
93
+ st = s.group(1).strip()[:60] if s else None
94
+ return nxt, st
95
+
96
+
97
+ def board_stats(root):
98
+ bdir = os.path.join(root, '.dsh-graph')
99
+ if not os.path.isdir(bdir):
100
+ return None
101
+ goals_dir = os.path.join(bdir, 'goals')
102
+ counts = {}
103
+ latest = 0
104
+ if os.path.isdir(goals_dir):
105
+ for name in os.listdir(goals_dir):
106
+ gpath = os.path.join(goals_dir, name, 'goal.md')
107
+ if not os.path.isfile(gpath):
108
+ continue
109
+ latest = max(latest, os.path.getmtime(gpath))
110
+ try:
111
+ head = open(gpath, encoding='utf-8', errors='replace').read(1200)
112
+ except OSError:
113
+ continue
114
+ m = re.search(r'^status:\s*(\S+)', head, re.M)
115
+ st = m.group(1).strip('"\'') if m else 'unknown'
116
+ counts[st] = counts.get(st, 0) + 1
117
+ return {'counts': counts, 'total': sum(counts.values()), 'latest': latest}
118
+
119
+
120
+ def git_info(root):
121
+ try:
122
+ r = subprocess.run(['git', '-C', root, 'log', '-1', '--format=%ct%x1f%h%x1f%s'],
123
+ capture_output=True, text=True, timeout=5)
124
+ if r.returncode != 0 or not r.stdout.strip():
125
+ return None
126
+ ts, sha, subj = r.stdout.strip().split('\x1f', 2)
127
+ dirty = subprocess.run(['git', '-C', root, 'status', '--porcelain'],
128
+ capture_output=True, text=True, timeout=5).stdout
129
+ return {'ts': int(ts), 'sha': sha, 'subject': subj[:80],
130
+ 'dirty': len([l for l in dirty.splitlines() if l.strip()])}
131
+ except (OSError, subprocess.SubprocessError, ValueError):
132
+ return None
133
+
134
+
135
+ SESSION_ROOT = os.path.join(HOME, '.dsh', 'sessions')
136
+
137
+
138
+ def project_key(cwd):
139
+ out, sep = [], False
140
+ for ch in cwd:
141
+ code = ord(ch)
142
+ if ch in '/\\:':
143
+ if not sep:
144
+ out.append('-')
145
+ sep = True
146
+ elif ch != '~' and (ch.isalnum() or ch in '._-') and code < 128:
147
+ out.append(ch)
148
+ sep = False
149
+ else:
150
+ out.append('~%04X' % code)
151
+ sep = False
152
+ s = ''.join(out).lstrip('-') or 'root'
153
+ return '--' + s[:251] + '--'
154
+
155
+
156
+ def session_activity(root):
157
+ """该项目工作区下最近一次会话活动时间(用于播报里的「最近活动」)。"""
158
+ if not root:
159
+ return 0
160
+ d = os.path.join(SESSION_ROOT, project_key(os.path.realpath(root)))
161
+ if not os.path.isdir(d):
162
+ return 0
163
+ latest = 0
164
+ try:
165
+ for name in os.listdir(d):
166
+ f = os.path.join(d, name, 'session.v3.jsonl.zstd')
167
+ if os.path.exists(f):
168
+ try:
169
+ latest = max(latest, os.path.getmtime(f))
170
+ except OSError:
171
+ pass
172
+ except OSError:
173
+ return 0
174
+ return latest
175
+
176
+
177
+ def autonomy_of(p):
178
+ a = p.get('autonomy') or {}
179
+ mode = a.get('mode', 'off')
180
+ if mode not in AUTONOMY_MODES:
181
+ mode = 'off'
182
+ return {'mode': mode, 'maxPerDay': int(a.get('maxPerDay', 3)),
183
+ 'day': a.get('day', ''), 'actionsToday': int(a.get('actionsToday', 0))}
184
+
185
+
186
+ def _today():
187
+ return datetime.now().strftime('%Y-%m-%d')
188
+
189
+
190
+ def can_act(p):
191
+ a = autonomy_of(p)
192
+ if a['mode'] == 'off':
193
+ return False, 'off'
194
+ if a['day'] != _today():
195
+ return a['mode'] != 'off', 'new-day'
196
+ if a['actionsToday'] >= a['maxPerDay']:
197
+ return False, f"预算用尽({a['actionsToday']}/{a['maxPerDay']})"
198
+ return True, 'ok'
199
+
200
+
201
+ def bump_actions(pid):
202
+ reg = load_json(REGISTRY, {'projects': []})
203
+ for p in reg['projects']:
204
+ if p['id'] == pid:
205
+ a = autonomy_of(p)
206
+ if a['day'] != _today():
207
+ a['day'] = _today(); a['actionsToday'] = 0
208
+ a['actionsToday'] += 1
209
+ p['autonomy'] = a
210
+ save_json(REGISTRY, reg)
211
+
212
+
213
+ def record_decision(pid, question, options, choice, why, reversible=True, mode=None):
214
+ entry = {'at': time.time(), 'project': pid, 'question': question,
215
+ 'options': options, 'choice': choice, 'why': why,
216
+ 'reversible': bool(reversible), 'mode': mode or ''}
217
+ with open(DECISIONS, 'a') as f:
218
+ f.write(json.dumps(entry, ensure_ascii=False) + '\n')
219
+ log(f'[autonomy] decision {pid}: {choice} ({question[:40]})')
220
+ return entry
221
+
222
+
223
+ def load_approvals():
224
+ return load_json(APPROVALS, {'items': []})
225
+
226
+
227
+ def add_approval(pid, question, options, recommend='', irreversible=True):
228
+ d = load_approvals()
229
+ item = {'id': 'ap-' + secrets.token_hex(4), 'project': pid, 'question': question,
230
+ 'options': options, 'recommend': recommend, 'irreversible': bool(irreversible),
231
+ 'createdAt': time.time(), 'status': 'pending'}
232
+ d.setdefault('items', []).append(item)
233
+ save_json(APPROVALS, d)
234
+ log(f'[autonomy] approval {item["id"]} {pid}: {question[:50]}')
235
+ return item
236
+
237
+
238
+ def resolve_approval(aid, decision, choice='', why=''):
239
+ d = load_approvals()
240
+ for it in d.get('items', []):
241
+ if it['id'] == aid and it['status'] == 'pending':
242
+ it['status'] = 'approved' if decision == 'approve' else 'rejected'
243
+ it['resolvedAt'] = time.time()
244
+ it['humanChoice'] = choice or it.get('recommend', '')
245
+ if why:
246
+ it['note'] = why
247
+ save_json(APPROVALS, d)
248
+ record_decision(it['project'], it['question'], it.get('options', []),
249
+ it['humanChoice'] if decision == 'approve' else 'rejected',
250
+ 'human: ' + (why or decision), True, 'human')
251
+ return it
252
+ return None
253
+
254
+
255
+ def cmd_autonomy(argv):
256
+ if not argv:
257
+ reg = load_json(REGISTRY, {'projects': []})
258
+ rows = [(p['id'], autonomy_of(p)) for p in reg['projects']]
259
+ for pid, a in rows:
260
+ print(f"{pid:22} {a['mode']:7} 今日 {a['actionsToday']}/{a['maxPerDay']}")
261
+ return 0
262
+ pid = argv[0]
263
+ reg = load_json(REGISTRY, {'projects': []})
264
+ proj = next((p for p in reg['projects'] if p['id'] == pid), None)
265
+ if not proj:
266
+ print('项目不存在'); return 1
267
+ if len(argv) < 2:
268
+ a = autonomy_of(proj); print(f"{pid}: {a['mode']} 今日 {a['actionsToday']}/{a['maxPerDay']}"); return 0
269
+ mode = argv[1]
270
+ if mode not in AUTONOMY_MODES:
271
+ print('模式只能是 off / assist / auto'); return 1
272
+ a = autonomy_of(proj)
273
+ a['mode'] = mode
274
+ if '--max' in argv:
275
+ try: a['maxPerDay'] = int(argv[argv.index('--max') + 1])
276
+ except (IndexError, ValueError): pass
277
+ proj['autonomy'] = a
278
+ save_json(REGISTRY, reg)
279
+ print(f"{pid}: 自主模式 → {mode}(每日上限 {a['maxPerDay']})")
280
+ return 0
281
+
282
+
283
+ def cmd_decide(argv):
284
+ if not argv:
285
+ print('用法: deck decide <id> --question Q --options A|B|C --choice B --why "..." [--irreversible]')
286
+ return 1
287
+ pid = argv[0]
288
+ opts = {}
289
+ i = 1
290
+ while i < len(argv):
291
+ if argv[i].startswith('--') and i + 1 < len(argv):
292
+ opts[argv[i][2:]] = argv[i + 1]; i += 2
293
+ else: i += 1
294
+ q = opts.get('question', ''); choice = opts.get('choice', '')
295
+ if not q or not choice:
296
+ print('缺少 --question / --choice'); return 1
297
+ record_decision(pid, q, opts.get('options', ''), choice, opts.get('why', ''),
298
+ not ('irreversible' in opts or '--irreversible' in argv))
299
+ bump_actions(pid)
300
+ print('已记录决策')
301
+ return 0
302
+
303
+
304
+ def cmd_ask(argv):
305
+ if not argv:
306
+ print('用法: deck ask <id> --question Q --options A,B [--recommend A]')
307
+ return 1
308
+ pid = argv[0]; opts = {}
309
+ i = 1
310
+ while i < len(argv):
311
+ if argv[i].startswith('--') and i + 1 < len(argv):
312
+ opts[argv[i][2:]] = argv[i + 1]; i += 2
313
+ else: i += 1
314
+ q = opts.get('question', '')
315
+ if not q: print('缺少 --question'); return 1
316
+ it = add_approval(pid, q, opts.get('options', ''), opts.get('recommend', ''))
317
+ print('已加入审批队列:', it['id'])
318
+ return 0
319
+
320
+
321
+ def cmd_approvals(argv):
322
+ d = load_approvals()
323
+ pend = [x for x in d.get('items', []) if x.get('status') == 'pending']
324
+ if not argv:
325
+ if not pend: print('没有待批事项'); return 0
326
+ for x in pend:
327
+ age = rel_time(x['createdAt'])
328
+ print(f"{x['id']} {x['project']:18} {age:8} {x['question'][:60]}")
329
+ if x.get('options'): print(' 选项:', x['options'], '| 推荐:', x.get('recommend') or '-')
330
+ return 0
331
+ if argv[0] in ('--approve', '--reject') and len(argv) > 1:
332
+ aid = argv[1]
333
+ choice = ''
334
+ if '--choice' in argv: choice = argv[argv.index('--choice') + 1]
335
+ why = ''
336
+ if '--why' in argv: why = argv[argv.index('--why') + 1]
337
+ it = resolve_approval(aid, 'approve' if argv[0] == '--approve' else 'reject', choice, why)
338
+ print('已处理' if it else '找不到该待批项')
339
+ return 0
340
+ print('用法: deck approvals [--approve ID [--choice X] | --reject ID [--why "..."]]')
341
+ return 1
342
+
343
+
344
+ def cmd_decisions(argv):
345
+ limit = 20
346
+ if '--limit' in argv:
347
+ try: limit = int(argv[argv.index('--limit') + 1])
348
+ except (IndexError, ValueError): pass
349
+ if not os.path.exists(DECISIONS):
350
+ print('还没有决策记录'); return 0
351
+ rows = []
352
+ for line in open(DECISIONS):
353
+ try: rows.append(json.loads(line))
354
+ except json.JSONDecodeError: pass
355
+ for r in rows[-limit:]:
356
+ flag = '' if r.get('reversible', True) else ' [不可逆]'
357
+ print(f"{datetime.fromtimestamp(r['at']):%m-%d %H:%M} {r['project']:18} {r['choice']}{flag} ← {r['question'][:50]}")
358
+ return 0
359
+
360
+
361
+ def in_quiet(cfg, ts=None):
362
+ q = (cfg.get('schedule') or {}).get('quiet') or {}
363
+ start, end = q.get('from'), q.get('to')
364
+ if not start or not end:
365
+ return False
366
+ hm = datetime.fromtimestamp(ts or time.time()).strftime('%H:%M')
367
+ if start <= end:
368
+ return start <= hm < end
369
+ return hm >= start or hm < end
370
+
371
+
372
+ def git_since(root, since, limit=3):
373
+ try:
374
+ r = subprocess.run(['git', '-C', root, 'log', '--since', str(int(since)), '-n', str(limit),
375
+ '--format=%ct%x1f%s'], capture_output=True, text=True, timeout=6)
376
+ if r.returncode != 0:
377
+ return []
378
+ out = []
379
+ for line in r.stdout.strip().splitlines():
380
+ parts = line.split('\x1f', 1)
381
+ if len(parts) == 2:
382
+ out.append({'ts': int(parts[0]), 'subject': parts[1][:80]})
383
+ return out
384
+ except (OSError, subprocess.SubprocessError, ValueError):
385
+ return []
386
+
387
+
388
+ def session_stats(root):
389
+ """返回 {ids:[...], count, latest_at, latest_id}(按会话文件 mtime)。"""
390
+ d = os.path.join(SESSION_ROOT, project_key(os.path.realpath(root or '')))
391
+ out = {'ids': [], 'count': 0, 'latest_at': 0, 'latest_id': None}
392
+ if not os.path.isdir(d):
393
+ return out
394
+ try:
395
+ names = os.listdir(d)
396
+ except OSError:
397
+ return out
398
+ for name in names:
399
+ f = os.path.join(d, name, 'session.v3.jsonl.zstd')
400
+ if not os.path.exists(f):
401
+ continue
402
+ try:
403
+ mt = os.path.getmtime(f)
404
+ except OSError:
405
+ continue
406
+ out['ids'].append(name)
407
+ if mt > out['latest_at']:
408
+ out['latest_at'] = mt
409
+ out['latest_id'] = name
410
+ out['count'] = len(out['ids'])
411
+ return out
412
+
413
+
414
+ def latest_session_topic(root):
415
+ """该项目最近会话的首条用户消息与时间(用于「进展」)。"""
416
+ d = os.path.join(SESSION_ROOT, project_key(os.path.realpath(root or '')))
417
+ if not os.path.isdir(d):
418
+ return None
419
+ best = None
420
+ try:
421
+ for name in os.listdir(d):
422
+ f = os.path.join(d, name, 'session.v3.jsonl.zstd')
423
+ if not os.path.exists(f):
424
+ continue
425
+ mt = os.path.getmtime(f)
426
+ if best is None or mt > best[0]:
427
+ best = (mt, f)
428
+ except OSError:
429
+ return None
430
+ if not best:
431
+ return None
432
+ mt, f = best
433
+ topic = ''
434
+ try:
435
+ proc = subprocess.Popen(['zstd', '-dc', f], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
436
+ raw = b''
437
+ while raw.count(b'\n') < 25 and len(raw) < 200000:
438
+ chunk = proc.stdout.read(65536)
439
+ if not chunk:
440
+ break
441
+ raw += chunk
442
+ proc.kill()
443
+ proc.wait(timeout=5)
444
+ for line in raw.splitlines()[:25]:
445
+ try:
446
+ j = json.loads(line)
447
+ except Exception:
448
+ continue
449
+ if j.get('type') == 'user/message':
450
+ content = (j.get('data') or {}).get('content') or []
451
+ topic = ' '.join(' '.join(c.get('text', '') for c in content if isinstance(c, dict)).split())[:60]
452
+ if topic:
453
+ break
454
+ except Exception:
455
+ pass
456
+ return {'at': mt, 'topic': topic}
457
+
458
+
459
+ def collect(p):
460
+ root = p.get('root', '')
461
+ info = {
462
+ 'id': p['id'], 'name': p.get('name', p['id']), 'kind': p.get('kind', 'code'),
463
+ 'status': p.get('status', 'active'), 'priority': p.get('priority', 50),
464
+ 'exists': os.path.isdir(root), 'next': None, 'state_status': None,
465
+ 'board': None, 'git': None, 'activity': 0, 'memory': bool(p.get('memory')),
466
+ }
467
+ sf = state_file(p) if info['exists'] else None
468
+ if sf:
469
+ nxt, st = parse_state(sf)
470
+ info['next'], info['state_status'] = nxt, st
471
+ info['activity'] = max(info['activity'], os.path.getmtime(sf))
472
+ info['memory'] = True
473
+ if info['exists']:
474
+ info['board'] = board_stats(root)
475
+ if info['board']:
476
+ info['activity'] = max(info['activity'], info['board']['latest'])
477
+ info['git'] = git_info(root)
478
+ if info['git']:
479
+ info['activity'] = max(info['activity'], info['git']['ts'])
480
+ try:
481
+ info['activity'] = max(info['activity'], int(os.path.getmtime(root)))
482
+ except OSError:
483
+ pass
484
+ info['activity'] = max(info['activity'], session_activity(root))
485
+ return info
486
+
487
+
488
+ def rel_time(ts):
489
+ if not ts:
490
+ return '无记录'
491
+ delta = time.time() - ts
492
+ if delta < 90:
493
+ return '刚刚'
494
+ if delta < 3600:
495
+ return f'{int(delta // 60)} 分钟前'
496
+ if delta < 86400:
497
+ return f'{int(delta // 3600)} 小时前'
498
+ return f'{int(delta // 86400)} 天前'
499
+
500
+
501
+ # ── 播报生成 ────────────────────────────────────────────────
502
+ def build_report(cfg):
503
+ rep = cfg.get('report', {})
504
+ md = rep.get('format', 'markdown') == 'markdown'
505
+ reg = load_json(REGISTRY, {'projects': []})
506
+ projects = [p for p in reg.get('projects', []) if p.get('status') != 'archived']
507
+ max_chars = int(rep.get('maxChars', 1200))
508
+
509
+ state_now = load_json(STATE, {})
510
+ prev_run = state_now.get('last_run') or (time.time() - 4 * 3600)
511
+ prev_sessions = state_now.get('sessions') or {}
512
+
513
+ entries = []
514
+ for p in projects:
515
+ info = collect(p)
516
+ prog, changed = [], False
517
+ commits = git_since(p.get('root', ''), prev_run) if info['exists'] else []
518
+ if commits:
519
+ prog.append(f"git {len(commits)} 次提交({commits[0]['subject'][:40]})")
520
+ changed = True
521
+ if info['git'] and info['git']['dirty']:
522
+ prog.append(f"{info['git']['dirty']} 个文件待提交")
523
+ changed = True
524
+ stats = session_stats(p.get('root', '')) if info['exists'] else {'ids': [], 'count': 0, 'latest_at': 0, 'latest_id': None}
525
+ prev = prev_sessions.get(p['id']) or {}
526
+ prev_ids = set(prev.get('ids') or [])
527
+ new_ids = [i for i in stats['ids'] if i not in prev_ids] if prev_ids else []
528
+ if new_ids:
529
+ prog.append(f"新增 {len(new_ids)} 个会话(共 {stats['count']})")
530
+ changed = True
531
+ elif stats['latest_at'] and stats['latest_at'] > float(prev.get('latest_at') or 0):
532
+ prog.append(f"会话更新({rel_time(stats['latest_at'])})")
533
+ changed = True
534
+ elif stats['count'] and not prev_ids:
535
+ prog.append(f"会话 {stats['count']} 个(最近 {rel_time(stats['latest_at'])})")
536
+ topic = latest_session_topic(p.get('root', '')) if (info['exists'] and (new_ids or stats['latest_at'] > prev_run)) else None
537
+ if topic and topic['topic']:
538
+ prog.append(f"「{topic['topic'][:36]}」")
539
+ if info['board'] and info['board']['total']:
540
+ prog.append(f"看板 {info['board']['total']} 个目标")
541
+ changed = True
542
+ nxt = info['next'] or info.get('state_status') or p.get('note') or '(未登记进展)'
543
+ if str(nxt).startswith('自动发现:'):
544
+ nxt = '待确认项目目标与下一步(自动发现)'
545
+ stale = bool(info['activity']) and time.time() - info['activity'] > 14 * 86400
546
+ entries.append({'p': p, 'info': info, 'prog': prog, 'changed': changed, 'next': nxt,
547
+ 'stale': stale, 'priority': p.get('priority', 50)})
548
+
549
+ entries.sort(key=lambda e: (not e['changed'], -e['priority'], e['p']['id']))
550
+ blocks, unchanged = [], []
551
+ for e in entries:
552
+ info, p = e['info'], e['p']
553
+ if not e['changed']:
554
+ unchanged.append(info['name'])
555
+ continue
556
+ rel = rel_time(info['activity'])
557
+ missing = '' if info['exists'] else ' · ⚠ 目录不存在'
558
+ stale_hint = ' · ⚠ 14 天未动' if e['stale'] else ''
559
+ name, kind = info['name'], KINDS.get(info['kind'], info['kind'])
560
+ blk = [f'- **{name}**({kind})· {rel}{missing}{stale_hint}' if md else f'{name}({kind})· {rel}{missing}{stale_hint}']
561
+ blk.append(' 进展:' + (' · '.join(e['prog']) if e['prog'] else '本期无新变化'))
562
+ blk.append(f" 下一步:{e['next']}")
563
+ bits = []
564
+ if info['git']:
565
+ g = info['git']
566
+ bits.append(f"git {g['sha']}" + (f" +{g['dirty']} 改动" if g['dirty'] else ''))
567
+ if not info['memory']:
568
+ bits.append('未落盘')
569
+ if bits:
570
+ if md:
571
+ bits = [f'`{b}`' for b in bits]
572
+ blk.append(' ' + (' · '.join(bits) if md else ' / '.join(bits)))
573
+ blocks.append('\n'.join(blk))
574
+
575
+ blocked = [p for p in projects if p.get('status') == 'blocked']
576
+ active = [p for p in projects if p.get('status') == 'active']
577
+ infos = [e['info'] for e in entries]
578
+ recent = [i for i in infos if i['activity'] and time.time() - i['activity'] < 6 * 3600]
579
+ now = datetime.now()
580
+
581
+ if md:
582
+ lines = [f'**项目播报** · {now:%m-%d %H:%M}', '',
583
+ f'{len(projects)} 个项目|活跃 {len(active)} · 阻塞 {len(blocked)} · 近 6 小时更新 {len(recent)}', '']
584
+ else:
585
+ lines = [f'【项目播报 {now:%m-%d %H:%M}】',
586
+ f'项目 {len(projects)} 个(活跃 {len(active)},阻塞 {len(blocked)}),近 6 小时有更新 {len(recent)} 个', '']
587
+
588
+ scan_st = load_json(SCAN_STATE, {})
589
+ if scan_st.get('lastRun') and time.time() - scan_st['lastRun'] < 7 * 86400:
590
+ added = scan_st.get('added') or []
591
+ cands = scan_st.get('candidates') or []
592
+ if added:
593
+ lines.append('自动发现:新增 ' + str(len(added)) + ' 个项目(' + '、'.join(a['name'] for a in added[:3]) + ')')
594
+ elif cands:
595
+ lines.append(f'自动发现:{len(cands)} 个候选待确认(deck scan)')
596
+ if added or cands:
597
+ lines.append('')
598
+
599
+ # 等你拍板 + 自主推进概览
600
+ pend = [x for x in load_approvals().get('items', []) if x.get('status') == 'pending']
601
+ if pend:
602
+ lines.append(f'**等你拍板**:{len(pend)} 项')
603
+ for x in pend[:3]:
604
+ opts = f"({x['options']},推荐 {x.get('recommend') or '-'})" if x.get('options') else ''
605
+ lines.append(f"- {x['project']}:{x['question'][:50]}{opts}")
606
+ lines.append('')
607
+ modes = {'auto': 0, 'assist': 0, 'off': 0}
608
+ for p in projects:
609
+ modes[autonomy_of(p)['mode']] = modes.get(autonomy_of(p)['mode'], 0) + 1
610
+ if modes['auto'] or modes['assist']:
611
+ lines.append(f"自主推进:auto {modes['auto']} · assist {modes['assist']} · off {modes['off']}")
612
+ lines.append('')
613
+
614
+ # 节点离线提醒(超过 3 天没上报)
615
+ try:
616
+ off = []
617
+ for n in load_nodes().get('nodes', []):
618
+ if n.get('local'):
619
+ continue
620
+ ls = float(n.get('lastSeen') or 0)
621
+ if not ls:
622
+ off.append(f"{n.get('name') or n['id']}(从未上报)")
623
+ elif time.time() - ls > 3 * 86400:
624
+ off.append(f"{n.get('name') or n['id']}({int((time.time()-ls)//86400)} 天)")
625
+ if off:
626
+ lines.append('节点离线:' + '、'.join(off))
627
+ lines.append('')
628
+ except Exception:
629
+ pass
630
+
631
+ budget = max_chars - len('\n'.join(lines)) - 200
632
+ keep, total = [], 0
633
+ for b in blocks:
634
+ if total + len(b) + 1 > budget:
635
+ break
636
+ keep.append(b)
637
+ total += len(b) + 1
638
+ omitted = len(blocks) - len(keep)
639
+ parts = keep[:]
640
+ if unchanged:
641
+ parts.append('无变化:' + '、'.join(unchanged[:8]) + ('…' if len(unchanged) > 8 else ''))
642
+ if omitted:
643
+ parts.append(f'……另有 {omitted} 个有变化的项目未展开')
644
+ sep = '\n' if md else '\n\n'
645
+ lines.append(sep.join(parts))
646
+ if blocked:
647
+ names = '、'.join(p.get('name', p['id']) for p in blocked)
648
+ lines.append('')
649
+ lines.append(f'**阻塞**:{names}' if md else f'阻塞:{names}')
650
+ return '\n'.join(lines)
651
+
652
+
653
+ # ── 自动发现 ────────────────────────────────────────────────
654
+ WS_STORE = os.path.join(HOME, '.dsh', 'storages', 'workspace.json')
655
+ SCAN_STATE = os.path.join(BASE, 'scan-state.json')
656
+ SCAN_IGNORE = os.path.join(BASE, 'scan-ignore.json')
657
+ DECISIONS = os.path.join(BASE, 'decisions.jsonl')
658
+ APPROVALS = os.path.join(BASE, 'approvals.json')
659
+ AUTONOMY_MODES = ('off', 'assist', 'auto')
660
+ NODES = os.path.join(BASE, 'nodes.json')
661
+ NODE_SNAPS = os.path.join(BASE, 'nodes')
662
+
663
+ # 不参与扫描的目录(宿主/缓存/系统/已有项目内部)
664
+ EXCLUDE_NAMES = {'.git', 'node_modules', '.venv', 'venv', '__pycache__', 'Library',
665
+ 'Applications', 'Movies', 'Music', 'Pictures', 'Public', 'Downloads',
666
+ '.npm', '.cache', '.Trash', '.DS_Store', 'dist', 'build', 'target',
667
+ 'go', 'vendor', 'attachments', 'logs'}
668
+ MARKERS = ['.git', 'package.json', 'pnpm-workspace.yaml', 'pyproject.toml', 'setup.py',
669
+ 'go.mod', 'Cargo.toml', 'AGENTS.md', 'CLAUDE.md', '.dsh-graph', 'README.md']
670
+
671
+
672
+ def dsh_workspaces():
673
+ """DSH 工作区 = 用户真实用过的「工作分区」,带会话数与最近活跃时间。"""
674
+ data = load_json(WS_STORE, {})
675
+ out = {}
676
+ for wid, w in (data.get('tables', {}).get('workspaces') or {}).items():
677
+ path = os.path.realpath(w.get('path', '') or '')
678
+ if not path:
679
+ continue
680
+ sids = w.get('sessionIds') or []
681
+ latest = 0
682
+ for sid in sids:
683
+ for f in glob.glob(os.path.join(HOME, '.dsh', 'sessions', '*', sid + '*')):
684
+ try:
685
+ latest = max(latest, os.path.getmtime(f))
686
+ except OSError:
687
+ pass
688
+ out[path] = {'path': path, 'title': w.get('title') or os.path.basename(path),
689
+ 'sessions': len(sids), 'last': latest}
690
+ return out
691
+
692
+
693
+ def is_excluded(path):
694
+ parts = set(path.split(os.sep))
695
+ if parts & EXCLUDE_NAMES:
696
+ return True
697
+ if path.startswith(os.path.join(HOME, '.dsh')) or path.startswith(os.path.join(HOME, '.ai')):
698
+ return True
699
+ if path.startswith(os.path.join(HOME, 'Library')):
700
+ return True
701
+ return False
702
+
703
+
704
+ def _marker_info(path):
705
+ found = []
706
+ latest = 0
707
+ for m in MARKERS:
708
+ fp = os.path.join(path, m)
709
+ if os.path.exists(fp):
710
+ found.append(m)
711
+ if m == '.git':
712
+ continue # .git 的 mtime 会被 git 自身刷新,不代表项目活跃
713
+ try:
714
+ latest = max(latest, os.path.getmtime(fp))
715
+ except OSError:
716
+ pass
717
+ return found, latest
718
+
719
+
720
+ def scan_filesystem(cfg):
721
+ scan = cfg.get('scan', {})
722
+ roots = scan.get('roots', [os.path.join(HOME, 'Desktop'), HOME])
723
+ depth = int(scan.get('depth', 1))
724
+ found = {}
725
+ for r in roots:
726
+ base = os.path.realpath(os.path.expanduser(r))
727
+ if not os.path.isdir(base):
728
+ continue
729
+ # 扫描根本身不是项目(例如家目录 / 桌面)
730
+ found.pop(base, None)
731
+ level = [(base, 0)]
732
+ while level:
733
+ cur, d = level.pop()
734
+ if d >= depth:
735
+ continue
736
+ try:
737
+ entries = sorted(os.scandir(cur), key=lambda e: e.name)
738
+ except OSError:
739
+ continue
740
+ for e in entries:
741
+ if not e.is_dir(follow_symlinks=False) or e.name.startswith('.'):
742
+ continue
743
+ if e.name in EXCLUDE_NAMES:
744
+ continue
745
+ sub = os.path.realpath(e.path)
746
+ if is_excluded(sub):
747
+ continue
748
+ markers, latest = _marker_info(sub)
749
+ if markers:
750
+ found[sub] = {'markers': markers, 'last': latest}
751
+ # 只对根目录的一级做 marker 判断;更深层仅当自身像项目才继续
752
+ level.append((sub, d + 1))
753
+ return found
754
+
755
+
756
+ def load_nodes():
757
+ return load_json(NODES, {'version': 1, 'nodes': []})
758
+
759
+
760
+ def find_node(node_id):
761
+ return next((n for n in load_nodes().get('nodes', []) if n.get('id') == node_id), None)
762
+
763
+
764
+ def remote_snapshots():
765
+ out = []
766
+ if not os.path.isdir(NODE_SNAPS):
767
+ return out
768
+ for f in sorted(glob.glob(os.path.join(NODE_SNAPS, '*.json'))):
769
+ snap = load_json(f, None)
770
+ if isinstance(snap, dict) and snap.get('node'):
771
+ out.append(snap)
772
+ return out
773
+
774
+
775
+ def save_node_scan(payload):
776
+ node_id = str(payload.get('node') or '').strip()
777
+ node = find_node(node_id)
778
+ if not node:
779
+ raise ValueError(f'未登记的节点 {node_id}')
780
+ token = str(payload.get('token') or '')
781
+ import hmac
782
+ if not token or not hmac.compare_digest(token, str(node.get('token') or '')):
783
+ raise ValueError('节点 token 不正确')
784
+ projects = payload.get('projects') or []
785
+ if not isinstance(projects, list) or len(projects) > 2000:
786
+ raise ValueError('projects 字段非法')
787
+ snap = {'node': node_id, 'name': node.get('name') or node_id,
788
+ 'platform': node.get('platform', ''), 'scanned_at': time.time(),
789
+ 'projects': projects}
790
+ os.makedirs(NODE_SNAPS, exist_ok=True)
791
+ save_json(os.path.join(NODE_SNAPS, node_id + '.json'), snap)
792
+ node['lastSeen'] = time.time()
793
+ nodes = load_nodes()
794
+ for n in nodes['nodes']:
795
+ if n['id'] == node_id:
796
+ n['lastSeen'] = node['lastSeen']
797
+ save_json(NODES, nodes)
798
+ log(f'[node] {node_id} 上报 {len(projects)} 个项目')
799
+ return snap
800
+
801
+
802
+ def cmd_nodes(argv):
803
+ nodes = load_nodes()
804
+ if not argv:
805
+ if not nodes['nodes']:
806
+ print('还没有登记节点。deck nodes add --id hos --name "HOS 手机" --platform android')
807
+ return 0
808
+ print(f"{'id':16} {'名称':14} {'平台':10} {'最近上报':12} {'项目':>4} 状态")
809
+ for n in nodes['nodes']:
810
+ snap = load_json(os.path.join(NODE_SNAPS, n['id'] + '.json'), None)
811
+ last = snap.get('scanned_at') if snap else None
812
+ print(f"{n['id']:16} {(n.get('name') or '')[:12]:14} {n.get('platform',''):10} "
813
+ f"{rel_time(last) if last else '从未':12} {len(snap.get('projects', [])) if snap else 0:>4} "
814
+ f"{'在线' if last and time.time() - last < 86400 else '离线'}")
815
+ return 0
816
+ action = argv[0]
817
+ if action == 'add':
818
+ opts = {}
819
+ i = 1
820
+ while i < len(argv):
821
+ if argv[i].startswith('--') and i + 1 < len(argv):
822
+ opts[argv[i][2:]] = argv[i + 1]
823
+ i += 2
824
+ else:
825
+ i += 1
826
+ nid = opts.get('id')
827
+ if not nid:
828
+ print('缺少 --id')
829
+ return 1
830
+ if find_node(nid):
831
+ print(f'节点 {nid} 已存在')
832
+ return 1
833
+ nodes['nodes'].append({
834
+ 'id': nid, 'name': opts.get('name', nid), 'platform': opts.get('platform', ''),
835
+ 'address': opts.get('address', ''), 'roots': opts.get('roots', ''),
836
+ 'token': secrets.token_urlsafe(24), 'createdAt': time.time(),
837
+ })
838
+ save_json(NODES, nodes)
839
+ n = find_node(nid)
840
+ print(f'已登记节点 {nid},token: {n["token"]}')
841
+ print('把 token 和服务器地址写进节点上的 ~/.deck-node.json:')
842
+ print(' {"server":"http://192.168.1.3:9199","token":"%s","id":"%s"}' % (n['token'], nid))
843
+ return 0
844
+ if action == 'rm':
845
+ if not argv[1:]:
846
+ return 1
847
+ nodes['nodes'] = [n for n in nodes['nodes'] if n['id'] != argv[1]]
848
+ save_json(NODES, nodes)
849
+ print('已移除节点', argv[1])
850
+ return 0
851
+ print('用法: deck nodes [add --id X --name Y --platform android | rm X]')
852
+ return 1
853
+
854
+
855
+ def slugify(text):
856
+ s = re.sub(r'[^a-z0-9]+', '-', (text or '').lower()).strip('-')
857
+ return s or 'project'
858
+
859
+
860
+ def load_ignore():
861
+ return load_json(SCAN_IGNORE, {'ignore': {}, 'blocked': []})
862
+
863
+
864
+ def is_ignored(path):
865
+ ig = load_ignore()
866
+ now = time.time()
867
+ for prefix in ig.get('blocked', []):
868
+ if path == prefix or path.startswith(prefix.rstrip('/') + '/'):
869
+ return True
870
+ until = (ig.get('ignore') or {}).get(path)
871
+ return bool(until and until > now)
872
+
873
+
874
+ def discover(cfg=None):
875
+ cfg = cfg or load_json(CONFIG, {})
876
+ scan = cfg.get('scan', {})
877
+ threshold = int(scan.get('threshold', 70))
878
+ reg = load_json(REGISTRY, {'projects': []})
879
+ known = {os.path.realpath(p.get('root', '')): p for p in reg['projects']}
880
+ ws = dsh_workspaces()
881
+ fs = scan_filesystem(cfg)
882
+ now = time.time()
883
+ paths = set(ws) | set(fs)
884
+ out = []
885
+ home = os.path.realpath(HOME)
886
+ for path in sorted(paths):
887
+ if path in (home, os.path.realpath(os.path.expanduser('~/Desktop'))):
888
+ continue
889
+ if path in known or is_excluded(path) or is_ignored(path):
890
+ continue
891
+ # 嵌套在已登记项目内部的目录不算新项目
892
+ if any(path != root and path.startswith(root + os.sep) for root in known):
893
+ continue
894
+ if not os.path.isdir(path):
895
+ continue
896
+ info = ws.get(path, {})
897
+ markers = fs.get(path, {}).get('markers', [])
898
+ git = git_info(path)
899
+ score, reasons = 0, []
900
+ if info:
901
+ score += 50
902
+ reasons.append(f'DSH 工作区({info["sessions"]} 个会话)')
903
+ if info['last'] and now - info['last'] < 30 * 86400:
904
+ score += 15
905
+ reasons.append('近 30 天用过')
906
+ elif info['last'] and now - info['last'] < 90 * 86400:
907
+ score += 5
908
+ if git:
909
+ score += 30
910
+ reasons.append(f'git 仓库({git["subject"][:24]})')
911
+ if git['ts'] and now - git['ts'] < 30 * 86400:
912
+ score += 10
913
+ reasons.append('近 30 天有提交')
914
+ if markers:
915
+ score += 5 * min(len(markers), 2)
916
+ reasons.append('含 ' + ' / '.join(markers[:3]))
917
+ activity = max([info.get('last', 0), fs.get(path, {}).get('last', 0),
918
+ git['ts'] if git else 0,
919
+ os.path.getmtime(path) if os.path.exists(path) else 0])
920
+ name = info.get('title') or os.path.basename(path)
921
+ out.append({
922
+ 'path': path, 'name': name, 'id': slugify(name), 'kind': 'code',
923
+ 'score': min(score, 100), 'reasons': reasons, 'git': git,
924
+ 'sessions': info.get('sessions', 0), 'last': activity,
925
+ 'workspace': bool(info), 'auto': score >= threshold,
926
+ })
927
+ # 远程节点上报的项目
928
+ seen_paths = {c['path'] for c in out}
929
+ for snap in remote_snapshots():
930
+ node_id = snap.get('node')
931
+ for rp in snap.get('projects') or []:
932
+ path = str(rp.get('path') or '').strip()
933
+ if not path or path in known or path in seen_paths:
934
+ continue
935
+ seen_paths.add(path)
936
+ markers = rp.get('markers') or []
937
+ git = rp.get('git')
938
+ score = 25
939
+ reasons = [f'来自节点 {snap.get("name") or node_id}']
940
+ if git:
941
+ score += 30
942
+ reasons.append('git 仓库')
943
+ if markers:
944
+ score += 5 * min(len(markers), 2)
945
+ reasons.append('含 ' + ' / '.join(markers[:2]))
946
+ last = float(rp.get('last') or snap.get('scanned_at') or 0)
947
+ last = min(last, now)
948
+ if last and now - last < 30 * 86400:
949
+ score += 10
950
+ reasons.append('近 30 天有改动')
951
+ out.append({
952
+ 'path': path, 'name': rp.get('name') or os.path.basename(path),
953
+ 'id': slugify(rp.get('name') or os.path.basename(path)), 'kind': rp.get('kind', 'code'),
954
+ 'score': min(score, 100), 'reasons': reasons, 'git': git, 'sessions': 0,
955
+ 'last': last, 'workspace': False, 'node': node_id,
956
+ 'auto': score >= threshold and node_id in {n['id'] for n in load_nodes().get('nodes', [])},
957
+ })
958
+ out.sort(key=lambda x: (-x['score'], -x['last']))
959
+ return out
960
+
961
+
962
+ def auto_scan(cfg=None):
963
+ """扫描 + 自动登记高置信度候选,返回本次结果摘要。"""
964
+ cfg = cfg or load_json(CONFIG, {})
965
+ cands = discover(cfg)
966
+ auto = [c for c in cands if c['auto']]
967
+ added = []
968
+ if cfg.get('scan', {}).get('autoAdd', True) and auto:
969
+ reg = load_json(REGISTRY, {'projects': []})
970
+ used = {p['id'] for p in reg['projects']}
971
+ for c in auto:
972
+ pid = c['id']
973
+ n = 2
974
+ while pid in used:
975
+ pid = f"{c['id']}-{n}"
976
+ n += 1
977
+ used.add(pid)
978
+ reg['projects'].append({
979
+ 'id': pid, 'name': c['name'], 'aliases': [pid, c['name']],
980
+ 'kind': c['kind'], 'status': 'active', 'priority': 40,
981
+ 'root': c['path'], 'memory': None,
982
+ 'note': '自动发现:' + ';'.join(c['reasons'][:3]),
983
+ 'created_at': datetime.now().astimezone().isoformat(timespec='seconds'),
984
+ })
985
+ added.append({'id': pid, 'name': c['name'], 'path': c['path'], 'score': c['score']})
986
+ if added:
987
+ save_json(REGISTRY, reg)
988
+ st = load_json(SCAN_STATE, {})
989
+ cands_sorted = sorted([c for c in cands if not c['auto']], key=lambda c: -c['score'])[:30]
990
+ st.update({'lastRun': time.time(), 'added': added, 'candidates': cands_sorted})
991
+ save_json(SCAN_STATE, st)
992
+ log(f"[scan] 候选 {len(cands)},自动登记 {len(added)}")
993
+ return {'added': added, 'candidates': st['candidates'], 'at': st['lastRun']}
994
+
995
+
996
+ def cmd_scan(argv):
997
+ cfg = load_json(CONFIG, {})
998
+ if '--add' in argv:
999
+ res = auto_scan(cfg)
1000
+ print(f"自动登记 {len(res['added'])} 个,剩余候选 {len(res['candidates'])} 个")
1001
+ for a in res['added']:
1002
+ print(f" + {a['id']:20} {a['name'][:16]:18} {a['path']}")
1003
+ return 0
1004
+ cands = discover(cfg)
1005
+ if not cands:
1006
+ print('没有发现新项目候选')
1007
+ return 0
1008
+ print(f"{'✓':2} {'score':5} {'id':20} {'名称':16} {'最近活动':10} 路径")
1009
+ for c in cands:
1010
+ mark = '·' if c['auto'] else '?'
1011
+ print(f"{mark:2} {c['score']:<5} {c['id']:20} {c['name'][:14]:16} "
1012
+ f"{rel_time(c['last']):10} {c['path']}")
1013
+ print(f" 理由:{';'.join(c['reasons']) or '—'}")
1014
+ print(f"\n共 {len(cands)} 个;· = 达到自动登记阈值,? = 仅候选。"
1015
+ f"执行 deck scan --add 自动登记高置信度项目。")
1016
+ return 0
1017
+
1018
+
1019
+ # ── 发送 ────────────────────────────────────────────────────
1020
+ def channel_state():
1021
+ return load_json(CHANNEL_STATE, {'stale': False, 'fails': 0})
1022
+
1023
+
1024
+ def _ctx_hash():
1025
+ st = load_json(WXPUSH_STATE, {})
1026
+ token = st.get('context_token') or ''
1027
+ return hashlib.sha256(token.encode()).hexdigest()[:16]
1028
+
1029
+
1030
+ def channel_stale():
1031
+ st = channel_state()
1032
+ if not st.get('stale'):
1033
+ return False
1034
+ # 用户给 bot 发过消息 → context_token 变了 → 自动解除 stale
1035
+ if st.get('ctx') and st['ctx'] != _ctx_hash():
1036
+ save_json(CHANNEL_STATE, {'stale': False, 'fails': 0, 'lastOk': time.time()})
1037
+ log('[channel] 检测到 context_token 已刷新,恢复播报')
1038
+ return False
1039
+ return True
1040
+
1041
+
1042
+ def mark_channel(ok, err=''):
1043
+ st = channel_state()
1044
+ if ok:
1045
+ st = {'stale': False, 'fails': 0, 'lastOk': time.time()}
1046
+ else:
1047
+ st['fails'] = int(st.get('fails', 0)) + 1
1048
+ st['ctx'] = _ctx_hash()
1049
+ st['lastError'] = (err or '')[:200]
1050
+ st['at'] = time.time()
1051
+ if st['fails'] >= 2:
1052
+ st['stale'] = True
1053
+ save_json(CHANNEL_STATE, st)
1054
+
1055
+
1056
+ def send_wechat(text):
1057
+ if not os.path.exists(WXSEND):
1058
+ return 3, 'wxsend 不存在'
1059
+ r = subprocess.run([WXSEND], input=text, capture_output=True, text=True, timeout=30)
1060
+ msg = (r.stdout or r.stderr).strip()
1061
+ if r.returncode == 0:
1062
+ mark_channel(True)
1063
+ elif r.returncode == 2:
1064
+ mark_channel(False, msg.splitlines()[0] if msg else 'queued')
1065
+ return r.returncode, msg
1066
+
1067
+
1068
+ def refresh_local_node(cfg=None):
1069
+ """把本机也当作一个节点:播报前刷新一次快照,面板里就不会显示「离线」。"""
1070
+ cfg = cfg or load_json(CONFIG, {})
1071
+ nodes = load_nodes()
1072
+ node = next((n for n in nodes.get('nodes', []) if n.get('local')), None)
1073
+ if not node:
1074
+ return
1075
+ fs = scan_filesystem(cfg)
1076
+ projects = []
1077
+ for path, meta in fs.items():
1078
+ projects.append({'path': path, 'name': os.path.basename(path), 'markers': meta.get('markers', []),
1079
+ 'git': git_info(path), 'last': meta.get('last', 0)})
1080
+ try:
1081
+ save_node_scan({'node': node['id'], 'token': node.get('token', ''),
1082
+ 'platform': 'darwin', 'projects': projects})
1083
+ log(f'[node] 本机快照已刷新({len(projects)} 个项目)')
1084
+ except ValueError as e:
1085
+ log(f'[node] 本机快照刷新失败: {e}')
1086
+
1087
+
1088
+ def save_session_snapshot():
1089
+ st = load_json(STATE, {})
1090
+ reg = load_json(REGISTRY, {'projects': []})
1091
+ snap = {}
1092
+ for p in reg.get('projects', []):
1093
+ snap[p['id']] = session_stats(p.get('root', ''))
1094
+ st['sessions'] = snap
1095
+ save_json(STATE, st)
1096
+
1097
+
1098
+ def do_report(cfg, quiet=False):
1099
+ text = build_report(cfg)
1100
+ if channel_stale():
1101
+ log('[report] 跳过:微信通道 context_token 过期(等你给 bot 发消息刷新)')
1102
+ if not quiet:
1103
+ print('⚠️ 微信通道过期,本次播报已跳过(不排队);在微信里给 bot 发任意一句话即可恢复')
1104
+ return 2, text, 'SKIP_CHANNEL_STALE'
1105
+ with open(LAST, 'w') as f:
1106
+ f.write(text + '\n')
1107
+ code, msg = send_wechat(text)
1108
+ try:
1109
+ save_session_snapshot()
1110
+ except Exception as e:
1111
+ log(f'[report] 会话快照失败: {e!r}')
1112
+ try:
1113
+ refresh_local_node(cfg)
1114
+ except Exception as e:
1115
+ log(f'[node] 本机快照刷新异常: {e!r}')
1116
+ log(f'report exit={code} {msg.splitlines()[-1] if msg else ""}')
1117
+ # 播报历史(面板读取)
1118
+ try:
1119
+ with open(HISTORY, 'a') as h:
1120
+ h.write(json.dumps({'at': time.time(), 'exit': code, 'text': text},
1121
+ ensure_ascii=False) + '\n')
1122
+ except OSError:
1123
+ pass
1124
+ if not quiet:
1125
+ print(text)
1126
+ print(f'\n[wechat exit {code}] {msg}')
1127
+ return code, text, msg
1128
+
1129
+
1130
+
1131
+ def do_report_with_image(cfg, quiet=False, force=None):
1132
+ """播报(文字)+ 可选附图;force=True/False 覆盖 config,None 跟随 config。"""
1133
+ code, text, msg = do_report(cfg, quiet)
1134
+ want = cfg.get('report', {}).get('image') if force is None else force
1135
+ if code == 0 and want:
1136
+ try:
1137
+ png = render_image(cfg)
1138
+ icode, imsg = send_wechat_image(png)
1139
+ if icode != 0:
1140
+ queue_image(png, None)
1141
+ log(f'[image] 附图 exit={icode} {imsg}')
1142
+ if not quiet:
1143
+ print(f'[image exit {icode}] {imsg}')
1144
+ except Exception as e:
1145
+ log(f'[image] 附图失败: {e!r}')
1146
+ if not quiet:
1147
+ print('附图失败:', e)
1148
+ return code, text, msg
1149
+
1150
+
1151
+ # ── 微信图片播报 ────────────────────────────────────────────
1152
+ WXPUSH_STATE = os.environ.get('WXPUSH_STATE', os.path.join(HOME, '.wxpush', 'state.json'))
1153
+ CDN_BASE = 'https://novac2c.cdn.weixin.qq.com/c2c'
1154
+ CHROME = os.environ.get('DECK_CHROME', '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome')
1155
+ GRAPHICS = os.path.join(BASE, 'graphics')
1156
+
1157
+
1158
+ def _wx_state():
1159
+ st = load_json(WXPUSH_STATE, {})
1160
+ return st.get('bot_token'), st.get('context_token'), st.get('ilink_user_id'), \
1161
+ (st.get('api_base') or 'https://ilinkai.weixin.qq.com/ilink')
1162
+
1163
+
1164
+ def _wx_headers(token):
1165
+ uin = base64.b64encode(str(secrets.randbelow(0xffffffff)).encode()).decode()
1166
+ return {'content-type': 'application/json', 'iLink-App-ClientVersion': '1',
1167
+ 'X-WECHAT-UIN': uin, 'Authorization': f'Bearer {token}',
1168
+ 'AuthorizationType': 'ilink_bot_token'}
1169
+
1170
+
1171
+ def _wx_post(url, body, token, timeout=30):
1172
+ req = urllib.request.Request(url, data=json.dumps(body).encode(), headers=_wx_headers(token))
1173
+ with urllib.request.urlopen(req, timeout=timeout) as r:
1174
+ return json.loads(r.read().decode())
1175
+
1176
+
1177
+ def _aes_ecb(data, key):
1178
+ size = ((len(data) + 1 + 15) // 16) * 16
1179
+ plen = 16 - (len(data) % 16)
1180
+ padded = data + bytes([plen]) * plen
1181
+ try:
1182
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
1183
+ enc = Cipher(algorithms.AES(key), modes.ECB()).encryptor()
1184
+ return enc.update(padded) + enc.finalize(), size
1185
+ except ImportError:
1186
+ # 系统 Python 3.9 没有 cryptography:退回 macOS 自带 openssl
1187
+ r = subprocess.run(['/usr/bin/openssl', 'enc', '-aes-128-ecb', '-K', key.hex(),
1188
+ '-nosalt', '-nopad'], input=padded, capture_output=True)
1189
+ if r.returncode != 0:
1190
+ raise RuntimeError('AES 加密失败: ' + (r.stderr.decode()[:120]))
1191
+ return r.stdout, size
1192
+
1193
+
1194
+ PENDING_IMG = os.path.join(BASE, 'pending-image.json')
1195
+
1196
+
1197
+ def queue_image(png_path, caption=None):
1198
+ save_json(PENDING_IMG, {'path': png_path, 'caption': caption, 'at': time.time()})
1199
+ log(f'[image] 已入队待补发 {os.path.basename(png_path)}')
1200
+
1201
+
1202
+ def flush_pending_image():
1203
+ p = load_json(PENDING_IMG, None)
1204
+ if not p:
1205
+ return False
1206
+ if channel_stale():
1207
+ return False
1208
+ # 失败后 2 分钟内不重复尝试,避免空转
1209
+ if time.time() - float(p.get('lastTry', 0)) < 120:
1210
+ return False
1211
+ p['lastTry'] = time.time()
1212
+ save_json(PENDING_IMG, p)
1213
+ path = p.get('path', '')
1214
+ if not os.path.exists(path):
1215
+ os.remove(PENDING_IMG)
1216
+ return False
1217
+ code, msg = send_wechat_image(path, p.get('caption'))
1218
+ if code == 0:
1219
+ try:
1220
+ os.remove(PENDING_IMG)
1221
+ except OSError:
1222
+ pass
1223
+ log('[image] 队列图片补发成功')
1224
+ return True
1225
+ if time.time() - float(p.get('at', 0)) > 3 * 86400:
1226
+ try:
1227
+ os.remove(PENDING_IMG)
1228
+ except OSError:
1229
+ pass
1230
+ log('[image] 队列图片超过 3 天,放弃')
1231
+ return False
1232
+
1233
+
1234
+ def send_wechat_image(png_path, caption=None):
1235
+ token, ctx, uid, api = _wx_state()
1236
+ if not token or not ctx or not uid:
1237
+ return 2, 'wxpush 未授权(token/context_token 缺失)'
1238
+ try:
1239
+ raw = open(png_path, 'rb').read()
1240
+ except OSError as e:
1241
+ return 1, f'读图失败: {e}'
1242
+ rawsize = len(raw)
1243
+ rawmd5 = hashlib.md5(raw).hexdigest()
1244
+ key = secrets.token_bytes(16)
1245
+ ciphertext, filesize = _aes_ecb(raw, key)
1246
+ filekey = secrets.token_hex(16)
1247
+ up = _wx_post(api + '/bot/getuploadurl', {
1248
+ 'filekey': filekey, 'media_type': 1, 'to_user_id': uid,
1249
+ 'rawsize': rawsize, 'rawfilemd5': rawmd5, 'filesize': filesize,
1250
+ 'no_need_thumb': True, 'aeskey': key.hex()}, token)
1251
+ url = up.get('upload_full_url') or up.get('upload_param')
1252
+ if not url:
1253
+ return 1, f'getuploadurl 失败: {str(up)[:120]}'
1254
+ if not str(url).startswith('http'):
1255
+ url = f'{CDN_BASE}/upload?encrypted_query_param={urllib.parse.quote(str(url))}&filekey={filekey}'
1256
+ req = urllib.request.Request(url, data=ciphertext,
1257
+ headers={'content-type': 'application/octet-stream'})
1258
+ with urllib.request.urlopen(req, timeout=120) as r:
1259
+ if r.status != 200:
1260
+ return 1, f'CDN 上传 HTTP {r.status}'
1261
+ download_param = r.headers.get('x-encrypted-param')
1262
+ if not download_param:
1263
+ return 1, 'CDN 上传响应缺少 x-encrypted-param'
1264
+ aes_b64 = base64.b64encode(key.hex().encode()).decode()
1265
+ body = {'msg': {'from_user_id': '', 'to_user_id': uid,
1266
+ 'client_id': f'deck:{int(time.time())}:{secrets.randbelow(999999)}',
1267
+ 'message_type': 2, 'message_state': 2, 'context_token': ctx,
1268
+ 'item_list': [{'type': 2, 'image_item': {
1269
+ 'media': {'encrypt_query_param': download_param, 'aes_key': aes_b64,
1270
+ 'encrypt_type': 1},
1271
+ 'mid_size': filesize}}]},
1272
+ 'base_info': {'channel_version': '1.0.0'}}
1273
+ res = _wx_post(api + '/bot/sendmessage', body, token)
1274
+ if res.get('ret', 0) != 0 and not res.get('message_id'):
1275
+ return 1, f'图片发送失败: {str(res)[:160]}'
1276
+ if caption:
1277
+ code, msg = send_wechat(caption)
1278
+ if code != 0:
1279
+ return code, '图片已发,说明文字未送达:' + msg
1280
+ log(f'[image] 已发送 {os.path.basename(png_path)} ({rawsize}B)')
1281
+ return 0, '图片已送达微信'
1282
+
1283
+
1284
+ def dashboard_html(cfg, limit=8):
1285
+ reg = load_json(REGISTRY, {'projects': []})
1286
+ projects = [p for p in reg.get('projects', []) if p.get('status') != 'archived']
1287
+ projects.sort(key=lambda p: (-p.get('priority', 50), p['id']))
1288
+ infos = [(p, collect(p)) for p in projects[:limit]]
1289
+ active = sum(1 for p in projects if p.get('status') == 'active')
1290
+ blocked = sum(1 for p in projects if p.get('status') == 'blocked')
1291
+ recent = sum(1 for _, i in infos if i['activity'] and time.time() - i['activity'] < 6 * 3600)
1292
+ st = load_json(STATE, {})
1293
+ rep = cfg.get('report', {})
1294
+ nxt = (st.get('last_run') or 0) + float(rep.get('intervalHours', 4)) * 3600
1295
+ scan_st = load_json(SCAN_STATE, {})
1296
+ scan_line = ''
1297
+ if scan_st.get('lastRun'):
1298
+ added = scan_st.get('added') or []
1299
+ cands = scan_st.get('candidates') or []
1300
+ if added:
1301
+ scan_line = '自动发现:新增 ' + str(len(added)) + ' 个项目(' + '、'.join(a['name'] for a in added[:3]) + ')'
1302
+ elif cands:
1303
+ scan_line = '自动发现:' + str(len(cands)) + ' 个候选待确认'
1304
+ rows = []
1305
+ for p, info in infos:
1306
+ bits = []
1307
+ if info['git']:
1308
+ bits.append('git ' + info['git']['sha'] + ((' +' + str(info['git']['dirty'])) if info['git']['dirty'] else ''))
1309
+ if info['board'] and info['board']['total']:
1310
+ bits.append('看板 ' + str(info['board']['total']))
1311
+ if not info['memory']:
1312
+ bits.append('未落盘')
1313
+ nxt_action = info['next'] or p.get('note') or '(未登记进展)'
1314
+ status_cn = {'active': '活跃', 'paused': '暂停', 'blocked': '阻塞', 'archived': '归档'}.get(p.get('status', 'active'), p.get('status'))
1315
+ rows.append(
1316
+ '<div class="row"><div class="rowhead"><span class="name">' + p.get('name', p['id']) + '</span>'
1317
+ '<span class="chip">' + KINDS.get(p.get('kind', 'code'), p.get('kind')) + '</span>'
1318
+ '<span class="chip st">' + status_cn + '</span>'
1319
+ '<span class="time">' + rel_time(info['activity']) + '</span></div>'
1320
+ '<div class="next">' + nxt_action + '</div>'
1321
+ '<div class="meta">' + ' · '.join(bits) + '</div></div>')
1322
+ more = ''
1323
+ if len(projects) > limit:
1324
+ more = '<div class="more">……另有 ' + str(len(projects) - limit) + ' 个项目未展示</div>'
1325
+ height = 300 + len(infos) * 128 + 60
1326
+ css = (
1327
+ "*{box-sizing:border-box;margin:0;padding:0}"
1328
+ "body{width:1080px;min-height:100vh;background:radial-gradient(900px 420px at 12% -8%,#12303a 0%,transparent 60%),#0b0f14;"
1329
+ "color:#e6edf3;font-family:-apple-system,'PingFang SC','Helvetica Neue',Arial,sans-serif;padding:46px 54px}"
1330
+ ".hdr{display:flex;align-items:baseline;justify-content:space-between;border-bottom:1px solid #1f2a37;padding-bottom:18px}"
1331
+ "h1{font-size:40px;letter-spacing:1px}h1 span{color:#35d0ba}"
1332
+ ".sub{margin-top:14px;color:#8b98a5;font-size:20px;display:flex;gap:18px;flex-wrap:wrap}"
1333
+ ".sub b{color:#e6edf3;font-weight:600}"
1334
+ ".cards{margin-top:26px;display:flex;flex-direction:column;gap:16px}"
1335
+ ".row{background:#121821;border:1px solid #1f2a37;border-radius:16px;padding:18px 22px;position:relative;overflow:hidden}"
1336
+ ".row::before{content:'';position:absolute;left:0;top:0;bottom:0;width:4px;background:#35d0ba}"
1337
+ ".rowhead{display:flex;align-items:center;gap:10px}"
1338
+ ".name{font-size:23px;font-weight:700}"
1339
+ ".chip{font-size:15px;color:#8b98a5;border:1px solid #24303d;border-radius:7px;padding:1px 9px;background:#0d131a}"
1340
+ ".chip.st{color:#7ce7da;border-color:#1f4d48;background:#0f1e1d}"
1341
+ ".time{margin-left:auto;font-size:16px;color:#6f7d8a}"
1342
+ ".next{margin-top:10px;font-size:18px;color:#cfd9e3;line-height:1.5}"
1343
+ ".meta{margin-top:8px;font-size:15px;color:#6f7d8a;font-family:ui-monospace,Menlo,monospace}"
1344
+ ".more{color:#6f7d8a;font-size:15px;margin-top:6px}"
1345
+ ".foot{margin-top:26px;border-top:1px solid #1f2a37;padding-top:16px;color:#6f7d8a;font-size:15px;display:flex;justify-content:space-between;gap:20px}"
1346
+ ".scan{color:#7ce7da}"
1347
+ )
1348
+ foot_time = ('下一条播报 ' + datetime.fromtimestamp(nxt).strftime('%H:%M')) if nxt else ''
1349
+ return ('<!DOCTYPE html><html><head><meta charset="utf-8"><style>' + css + '</style></head><body>'
1350
+ '<div class="hdr"><h1>项目播报 <span>·</span> 项目甲板</h1>'
1351
+ '<div class="time">' + datetime.now().strftime('%Y-%m-%d %H:%M') + '</div></div>'
1352
+ '<div class="sub"><span><b>' + str(len(projects)) + '</b> 个项目</span>'
1353
+ '<span>活跃 <b>' + str(active) + '</b></span><span>阻塞 <b>' + str(blocked) + '</b></span>'
1354
+ '<span>近 6 小时更新 <b>' + str(recent) + '</b></span></div>'
1355
+ '<div class="cards">' + ''.join(rows) + more + '</div>'
1356
+ '<div class="foot"><span class="scan">' + (scan_line or '自动发现:无新项目') + '</span>'
1357
+ '<span>' + foot_time + '</span></div>'
1358
+ '<script>addEventListener("load",()=>{document.title="H"+document.body.scrollHeight});</script>'
1359
+ '</body></html>')
1360
+
1361
+
1362
+ def render_image(cfg=None, out=None):
1363
+ cfg = cfg or load_json(CONFIG, {})
1364
+ os.makedirs(GRAPHICS, exist_ok=True)
1365
+ html = dashboard_html(cfg)
1366
+ ts = datetime.now().strftime('%Y%m%d-%H%M%S')
1367
+ html_path = os.path.join(GRAPHICS, 'board-' + ts + '.html')
1368
+ png_path = out or os.path.join(GRAPHICS, 'board-' + ts + '.png')
1369
+ with open(html_path, 'w', encoding='utf-8') as f:
1370
+ f.write(html)
1371
+ height = 900
1372
+ try:
1373
+ r = subprocess.run([CHROME, '--headless=new', '--disable-gpu',
1374
+ '--virtual-time-budget=1500', '--dump-dom', 'file://' + html_path],
1375
+ capture_output=True, timeout=60, text=True)
1376
+ m = re.search(r'<title>H(\d+)</title>', r.stdout or '')
1377
+ if m:
1378
+ height = int(m.group(1)) + 2
1379
+ except Exception:
1380
+ pass
1381
+ subprocess.run([CHROME, '--headless=new', '--disable-gpu', '--hide-scrollbars',
1382
+ '--force-device-scale-factor=1', '--window-size=1080,' + str(height),
1383
+ '--screenshot=' + png_path, 'file://' + html_path],
1384
+ capture_output=True, timeout=60)
1385
+ if not os.path.exists(png_path):
1386
+ raise RuntimeError('截图失败(chrome headless)')
1387
+ for f in sorted(glob.glob(os.path.join(GRAPHICS, 'board-*.png')))[:-20]:
1388
+ try:
1389
+ os.remove(f)
1390
+ except OSError:
1391
+ pass
1392
+ return png_path
1393
+
1394
+
1395
+ def cmd_image(argv):
1396
+ cfg = load_json(CONFIG, {})
1397
+ rep = cfg.setdefault('report', {})
1398
+ if '--on' in argv or '--off' in argv:
1399
+ rep['image'] = '--on' in argv
1400
+ save_json(CONFIG, cfg)
1401
+ print('周期播报附图:' + ('开启' if rep['image'] else '关闭'))
1402
+ return 0
1403
+ try:
1404
+ png = render_image(cfg)
1405
+ except Exception as e:
1406
+ print('生成图片失败:', e)
1407
+ return 1
1408
+ print('图片:', png)
1409
+ if '--no-send' in argv:
1410
+ return 0
1411
+ reg = load_json(REGISTRY, {'projects': []})
1412
+ n = len([p for p in reg.get('projects', []) if p.get('status') != 'archived'])
1413
+ caption = f'项目播报 · {n} 个项目 · {datetime.now():%m-%d %H:%M}'
1414
+ code, msg = send_wechat_image(png, caption)
1415
+ if code != 0:
1416
+ queue_image(png, caption)
1417
+ print(f'[wechat exit {code}] {msg}')
1418
+ return code
1419
+
1420
+
1421
+ # ── 命令 ────────────────────────────────────────────────────
1422
+ def cmd_list():
1423
+ reg = load_json(REGISTRY, {'projects': []})
1424
+ if not reg['projects']:
1425
+ print('(空)用 deck add 添加项目')
1426
+ return 0
1427
+ print(f'{"id":18} {"名称":14} {"类型":6} {"状态":8} {"优先级":6} 根目录')
1428
+ for p in sorted(reg['projects'], key=lambda x: -x.get('priority', 50)):
1429
+ print(f'{p["id"]:18} {p.get("name", "")[:12]:14} {p.get("kind","code"):6} '
1430
+ f'{p.get("status","active"):8} {p.get("priority",50):<6} {p.get("root","")}')
1431
+ return 0
1432
+
1433
+
1434
+ def cmd_add(argv):
1435
+ if not argv:
1436
+ print('用法: deck add <id> --root <path> [--name 名称] [--kind code] [--note 说明]')
1437
+ return 1
1438
+ pid = argv[0]
1439
+ opts = {}
1440
+ i = 1
1441
+ while i < len(argv):
1442
+ k = argv[i]
1443
+ if k.startswith('--') and i + 1 < len(argv):
1444
+ opts[k[2:]] = argv[i + 1]
1445
+ i += 2
1446
+ else:
1447
+ i += 1
1448
+ if 'root' not in opts:
1449
+ print('缺少 --root')
1450
+ return 1
1451
+ root = os.path.realpath(os.path.expanduser(opts['root']))
1452
+ if not os.path.isdir(root):
1453
+ print(f'目录不存在: {root}')
1454
+ return 1
1455
+ reg = load_json(REGISTRY, {'projects': []})
1456
+ for p in reg['projects']:
1457
+ if p['id'] == pid:
1458
+ print(f'已存在项目 {pid}')
1459
+ return 1
1460
+ if os.path.realpath(p.get('root', '')) == root:
1461
+ print(f'该目录已绑定项目 {p["id"]}')
1462
+ return 1
1463
+ reg['projects'].append({
1464
+ 'id': pid, 'name': opts.get('name', pid), 'aliases': [pid],
1465
+ 'kind': opts.get('kind', 'code'), 'status': 'active',
1466
+ 'priority': int(opts.get('priority', 50)), 'root': root,
1467
+ 'memory': None, 'note': opts.get('note', ''),
1468
+ 'created_at': datetime.now().astimezone().isoformat(timespec='seconds'),
1469
+ })
1470
+ save_json(REGISTRY, reg)
1471
+ print(f'已登记 {pid} → {root}')
1472
+ return 0
1473
+
1474
+
1475
+ def cmd_rm(argv):
1476
+ if not argv:
1477
+ return 1
1478
+ reg = load_json(REGISTRY, {'projects': []})
1479
+ before = len(reg['projects'])
1480
+ reg['projects'] = [p for p in reg['projects'] if p['id'] != argv[0]]
1481
+ if len(reg['projects']) == before:
1482
+ print(f'没有项目 {argv[0]}')
1483
+ return 1
1484
+ save_json(REGISTRY, reg)
1485
+ print(f'已移除 {argv[0]}(记忆与目录未动)')
1486
+ return 0
1487
+
1488
+
1489
+ def cmd_every(argv):
1490
+ if not argv:
1491
+ print(f'当前间隔 {load_json(CONFIG, {}).get("report", {}).get("intervalHours", 4)} 小时')
1492
+ return 0
1493
+ try:
1494
+ hours = float(argv[0])
1495
+ except ValueError:
1496
+ print('小时数无效')
1497
+ return 1
1498
+ if hours <= 0 or hours > 24 * 30:
1499
+ print('间隔需在 0 到 720 小时之间')
1500
+ return 1
1501
+ cfg = load_json(CONFIG, {})
1502
+ cfg.setdefault('report', {})['intervalHours'] = hours
1503
+ save_json(CONFIG, cfg)
1504
+ print(f'播报间隔已设为 {hours} 小时(守护进程 1 分钟内生效)')
1505
+ return 0
1506
+
1507
+
1508
+ def cmd_format(argv):
1509
+ cfg = load_json(CONFIG, {})
1510
+ rep = cfg.setdefault('report', {})
1511
+ cur = rep.get('format', 'markdown')
1512
+ if not argv:
1513
+ print(f'当前播报版式:{cur}(markdown | plain)')
1514
+ return 0
1515
+ v = argv[0].strip().lower()
1516
+ if v not in ('markdown', 'md', 'plain', 'text'):
1517
+ print('只支持 markdown 或 plain')
1518
+ return 1
1519
+ rep['format'] = 'markdown' if v in ('markdown', 'md') else 'plain'
1520
+ save_json(CONFIG, cfg)
1521
+ print(f'播报版式已设为 {rep["format"]}(下一次播报生效)')
1522
+ return 0
1523
+
1524
+
1525
+ def cmd_toggle(on):
1526
+ cfg = load_json(CONFIG, {})
1527
+ cfg.setdefault('report', {})['enabled'] = on
1528
+ save_json(CONFIG, cfg)
1529
+ print('周期播报:' + ('已开启' if on else '已关闭'))
1530
+ return 0
1531
+
1532
+
1533
+ def daemon_status():
1534
+ pid = None
1535
+ if os.path.exists(LOCK):
1536
+ try:
1537
+ with open(LOCK) as f:
1538
+ pid = int(f.read().strip() or 0)
1539
+ os.kill(pid, 0)
1540
+ except (ValueError, ProcessLookupError, PermissionError):
1541
+ pid = None
1542
+ return pid
1543
+
1544
+
1545
+ def cmd_doctor():
1546
+ reg = load_json(REGISTRY, {'projects': []})
1547
+ problems, warnings = [], []
1548
+ seen = {}
1549
+ for p in reg.get('projects', []):
1550
+ pid, root = p['id'], p.get('root', '')
1551
+ if root in seen:
1552
+ problems.append(f'{pid}: 根目录与 {seen[root]} 重复绑定({root})')
1553
+ seen[root] = pid
1554
+ if not os.path.isdir(root):
1555
+ problems.append(f'{pid}: 目录不存在 {root}')
1556
+ continue
1557
+ if p.get('memory') and not state_file(p):
1558
+ problems.append(f'{pid}: 记忆缺失(STATE 不存在)')
1559
+ if not os.path.isdir(os.path.join(root, '.dsh-graph')):
1560
+ warnings.append(f'{pid}: 无执行看板(.dsh-graph 缺失)')
1561
+ if not p.get('memory'):
1562
+ warnings.append(f'{pid}: 未落盘(无 ~/.ai STATE)')
1563
+ print(f'项目 {len(reg.get("projects", []))} 个,问题 {len(problems)},提示 {len(warnings)}')
1564
+ for x in problems:
1565
+ print(' ✗ ' + x)
1566
+ for x in warnings:
1567
+ print(' · ' + x)
1568
+ return 0 if not problems else 1
1569
+
1570
+
1571
+ def cmd_status():
1572
+ cfg = load_json(CONFIG, {})
1573
+ st = load_json(STATE, {})
1574
+ r = cfg.get('report', {})
1575
+ pid = daemon_status()
1576
+ print(f"配置: {CONFIG}")
1577
+ print(f"注册表: {REGISTRY}({len(load_json(REGISTRY, {'projects': []})['projects'])} 个项目)")
1578
+ print(f"周期播报: {'开启' if r.get('enabled', True) else '关闭'},每 {r.get('intervalHours', 4)} 小时,"
1579
+ f"版式 {r.get('format', 'markdown')},{'附图' if r.get('image') else '不附图'}")
1580
+ print(f"守护进程: {'运行中 pid ' + str(pid) if pid else '未运行'}")
1581
+ pend_count = len([x for x in load_approvals().get('items', []) if x.get('status') == 'pending'])
1582
+ if pend_count:
1583
+ print(f"待批事项: {pend_count} 个")
1584
+ sc = load_json(SCAN_STATE, {})
1585
+ if sc.get('lastRun'):
1586
+ print(f"自动发现: 上次扫描 {datetime.fromtimestamp(sc['lastRun']):%m-%d %H:%M}"
1587
+ f"({rel_time(sc['lastRun'])}),候选 {len(sc.get('candidates', []))} 个")
1588
+ else:
1589
+ print("自动发现: 尚未扫描")
1590
+ q = (cfg.get('schedule') or {}).get('quiet') or {}
1591
+ if q.get('from'):
1592
+ print(f"安静时段: {q['from']} – {q['to']}(期间不播报、不扫描)")
1593
+ last = st.get('last_run')
1594
+ print(f"上次播报: {datetime.fromtimestamp(last):%Y-%m-%d %H:%M}({rel_time(last)})" if last else '上次播报: 从未')
1595
+ nxt = (last or time.time()) + r.get('intervalHours', 4) * 3600
1596
+ while in_quiet(cfg, nxt):
1597
+ nxt += 1800
1598
+ print(f"下次播报: {datetime.fromtimestamp(nxt):%Y-%m-%d %H:%M}")
1599
+ wx = subprocess.run([WXSEND, '--status'], capture_output=True, text=True)
1600
+ print('微信通道: ' + ('✅ 可发送' if wx.returncode == 0 else '⚠️ 未接通(播报会排队,授权后补发)'))
1601
+ return 0 if pid else 1
1602
+
1603
+
1604
+ def cmd_daemon():
1605
+ os.makedirs(os.path.dirname(LOCK), exist_ok=True)
1606
+ fh = open(LOCK, 'w')
1607
+ try:
1608
+ fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
1609
+ except OSError:
1610
+ log('已有守护进程在跑,退出')
1611
+ return 0
1612
+ fh.write(str(os.getpid()))
1613
+ fh.flush()
1614
+ log(f'守护进程启动 pid={os.getpid()}')
1615
+ while True:
1616
+ try:
1617
+ cfg = load_json(CONFIG, {})
1618
+ r = cfg.get('report', {})
1619
+ if in_quiet(cfg):
1620
+ time.sleep(60)
1621
+ continue
1622
+ try:
1623
+ flush_pending_image()
1624
+ except Exception as e:
1625
+ log(f'[image] 补发异常: {e!r}')
1626
+ # 自动发现:按 scan.intervalHours 周期扫描
1627
+ sc = cfg.get('scan', {})
1628
+ if sc.get('enabled', True):
1629
+ sst = load_json(SCAN_STATE, {})
1630
+ sinterval = float(sc.get('intervalHours', 24)) * 3600
1631
+ if time.time() - float(sst.get('lastRun', 0)) >= sinterval:
1632
+ try:
1633
+ auto_scan(cfg)
1634
+ except Exception as e:
1635
+ log(f'[scan] 异常: {e!r}')
1636
+ if r.get('enabled', True):
1637
+ st = load_json(STATE, {})
1638
+ last = st.get('last_run', 0)
1639
+ interval = float(r.get('intervalHours', 4)) * 3600
1640
+ if time.time() - last >= interval:
1641
+ do_report_with_image(cfg, quiet=True)
1642
+ st['last_run'] = time.time()
1643
+ save_json(STATE, st)
1644
+ except Exception as e: # 守护进程不能因单次失败退出
1645
+ log(f'循环异常: {e!r}')
1646
+ time.sleep(30)
1647
+
1648
+
1649
+ def cmd_demo(argv):
1650
+ """deck demo:生成一个演示环境(假项目 + 独立面板端口),不碰真实注册表。"""
1651
+ import shutil
1652
+ home = os.path.expanduser('~/.project-deck-demo')
1653
+ port = '9299'
1654
+ for i, a in enumerate(argv):
1655
+ if a == '--port' and i + 1 < len(argv):
1656
+ port = argv[i + 1]
1657
+ if os.path.isdir(home):
1658
+ shutil.rmtree(home)
1659
+ os.makedirs(home, exist_ok=True)
1660
+ projects = [
1661
+ ('website', '官网改版', 'content', '落地页文案与设计,本周出第一版'),
1662
+ ('api-server', 'API 服务', 'code', '把 v1 接口迁到 v2,补 12 个测试'),
1663
+ ('notes-app', '笔记应用', 'code', '离线同步还有 2 个 bug'),
1664
+ ('infra', '基础设施', 'ops', '备份脚本换成 restic,验证一次恢复'),
1665
+ ]
1666
+ reg = {'version': 1, 'projects': []}
1667
+ for pid, name, kind, note in projects:
1668
+ root = os.path.join(home, 'projects', pid)
1669
+ os.makedirs(root, exist_ok=True)
1670
+ open(os.path.join(root, 'README.md'), 'w').write(f'# {name}\n\n{note}\n')
1671
+ if kind == 'code':
1672
+ open(os.path.join(root, 'package.json'), 'w').write('{"name":"%s","version":"0.1.0"}\n' % pid)
1673
+ subprocess.run(['git', 'init', '-q'], cwd=root)
1674
+ subprocess.run(['git', '-c', 'user.name=demo', '-c', 'user.email=demo@example.com',
1675
+ 'commit', '-qm', f'init: {name}', '--allow-empty'], cwd=root)
1676
+ reg['projects'].append({'id': pid, 'name': name, 'aliases': [pid], 'kind': kind,
1677
+ 'status': 'active', 'priority': 60, 'root': root,
1678
+ 'memory': None, 'note': note,
1679
+ 'created_at': time.strftime('%Y-%m-%dT%H:%M:%S%z')})
1680
+ cfg = {'version': 1,
1681
+ 'report': {'enabled': False, 'intervalHours': 4, 'format': 'markdown', 'image': False},
1682
+ 'scan': {'enabled': False, 'intervalHours': 12},
1683
+ 'panel': {'allowLanRead': False}}
1684
+ save_json(os.path.join(home, 'registry.json'), reg)
1685
+ save_json(os.path.join(home, 'config.json'), cfg)
1686
+ os.makedirs(os.path.join(home, 'graphics'), exist_ok=True)
1687
+ print(f'演示环境: {home}')
1688
+ print(f'面板地址: http://127.0.0.1:{port} (Ctrl-C 结束)')
1689
+ env = dict(os.environ, DECK_HOME=home, DECK_PANEL_PORT=str(port), DECK_PANEL_HOST='127.0.0.1')
1690
+ try:
1691
+ subprocess.run([sys.executable, os.path.join(os.path.dirname(os.path.abspath(__file__)), 'panel.py')], env=env)
1692
+ except KeyboardInterrupt:
1693
+ pass
1694
+ return 0
1695
+
1696
+
1697
+ def main():
1698
+ args = sys.argv[1:]
1699
+ if not args:
1700
+ return cmd_status()
1701
+ cmd, rest = args[0], args[1:]
1702
+ if cmd in ('list', 'ls'):
1703
+ return cmd_list()
1704
+ if cmd == 'add':
1705
+ return cmd_add(rest)
1706
+ if cmd == 'rm':
1707
+ return cmd_rm(rest)
1708
+ if cmd == 'every':
1709
+ return cmd_every(rest)
1710
+ if cmd == 'format':
1711
+ return cmd_format(rest)
1712
+ if cmd == 'scan':
1713
+ return cmd_scan(rest)
1714
+ if cmd == 'nodes':
1715
+ return cmd_nodes(rest)
1716
+ if cmd == 'image':
1717
+ return cmd_image(rest)
1718
+ if cmd == 'on':
1719
+ return cmd_toggle(True)
1720
+ if cmd == 'off':
1721
+ return cmd_toggle(False)
1722
+ if cmd == 'status':
1723
+ return cmd_status()
1724
+ if cmd == 'doctor':
1725
+ return cmd_doctor()
1726
+ if cmd == 'preview':
1727
+ print(build_report(load_json(CONFIG, {})))
1728
+ return 0
1729
+ if cmd == 'report':
1730
+ cfg0 = load_json(CONFIG, {})
1731
+ force = False if '--no-image' in rest else (True if '--image' in rest else None)
1732
+ code, text, msg = do_report_with_image(cfg0, force=force)
1733
+ st = load_json(STATE, {})
1734
+ st['last_run'] = time.time()
1735
+ save_json(STATE, st)
1736
+ return code
1737
+ if cmd == 'autonomy':
1738
+ return cmd_autonomy(rest)
1739
+ if cmd == 'decide':
1740
+ return cmd_decide(rest)
1741
+ if cmd == 'ask':
1742
+ return cmd_ask(rest)
1743
+ if cmd == 'approvals':
1744
+ return cmd_approvals(rest)
1745
+ if cmd == 'decisions':
1746
+ return cmd_decisions(rest)
1747
+ if cmd == 'demo':
1748
+ return cmd_demo(rest)
1749
+ if cmd == 'panel':
1750
+ port = os.environ.get('DECK_PANEL_PORT', '9199')
1751
+ url = f'http://127.0.0.1:{port}'
1752
+ print(f'管理面板: {url}')
1753
+ try:
1754
+ subprocess.run(['open', url], timeout=5)
1755
+ except (OSError, subprocess.SubprocessError):
1756
+ pass
1757
+ return 0
1758
+ if cmd == 'daemon':
1759
+ return cmd_daemon()
1760
+ print(__doc__)
1761
+ return 1
1762
+
1763
+
1764
+ if __name__ == '__main__':
1765
+ sys.exit(main())