bothouse-dsh 0.1.44 → 0.1.46

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.
@@ -0,0 +1,177 @@
1
+ // #876 团队工作台:任务板事件流 → hub 事件、回放接口、桌面页挂载。
2
+ //
3
+ // 事件契约见 Issue #876:`board-events.jsonl` 与 board.jsonl 同目录(BOARD_DIR),
4
+ // 一行一个 JSON,只追加;这里只读不改,写入归 gate 的 board.py。
5
+ import { closeSync, openSync, readFileSync, readSync, statSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { CollabError, publicMessage } from './store.mjs';
8
+ // #876 P: 回放消息的 @ 由这里解析,规则与 hub 路由同一套(mentions.mjs),页面不再自己猜文本。
9
+ import { mentions as mentionsOf } from './mentions.mjs';
10
+
11
+ export const BOARD_EVENTS_FILE = 'board-events.jsonl';
12
+ // 验收要求派活后「几秒内」光柱出现;每秒 stat 一次这个只追加的小文件足够便宜。
13
+ export const TAIL_INTERVAL_MS = 1000;
14
+ // 一次回放给一页历史,条数上限沿用消息分页的 200 条(store.mjs clampLimit)。
15
+ export const REPLAY_MAX = 200;
16
+
17
+ /** 读一行事件;空行跳过,坏行留一条 warn(写入方可能正写到一半)。 */
18
+ export function parseBoardEvent(line, logger) {
19
+ const text = line.trim();
20
+ if (text === '') return undefined;
21
+ let entry;
22
+ try { entry = JSON.parse(text); } catch { logger?.warn?.('conest-collab: bad board event line %s', text.slice(0, 200)); return undefined; }
23
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
24
+ logger?.warn?.('conest-collab: board event is not an object: %s', text.slice(0, 200));
25
+ return undefined;
26
+ }
27
+ return entry;
28
+ }
29
+
30
+ /** 板目录里的全部事件(回放用);文件不存在就是没有事件,不是错误。 */
31
+ export function readBoardEvents(boardDir) {
32
+ if (!boardDir) return [];
33
+ let raw;
34
+ try { raw = readFileSync(join(boardDir, BOARD_EVENTS_FILE), 'utf8'); } catch { return []; }
35
+ const out = [];
36
+ for (const line of raw.split('\n')) {
37
+ const entry = parseBoardEvent(line);
38
+ if (entry !== undefined) out.push(entry);
39
+ }
40
+ return out;
41
+ }
42
+
43
+ /**
44
+ * 尾随 board-events.jsonl,把新增行作为 `{type:"task", …}` 推进 hub 事件流。
45
+ * 启动时从文件末尾开始:启动前的事件属于回放素材,不该在启动瞬间补一屏光柱。
46
+ */
47
+ export class DeskEvents {
48
+ constructor(hub, { boardDir, logger, intervalMs = TAIL_INTERVAL_MS } = {}) {
49
+ this.hub = hub;
50
+ this.logger = logger;
51
+ this.intervalMs = intervalMs;
52
+ this.path = boardDir ? join(boardDir, BOARD_EVENTS_FILE) : undefined;
53
+ this.offset = 0;
54
+ this.timer = undefined;
55
+ }
56
+
57
+ start() {
58
+ if (this.path === undefined || this.timer !== undefined) return;
59
+ this.offset = this.size();
60
+ this.timer = setInterval(() => this.poll(), this.intervalMs);
61
+ this.timer.unref?.();
62
+ }
63
+
64
+ stop() {
65
+ if (this.timer !== undefined) clearInterval(this.timer);
66
+ this.timer = undefined;
67
+ }
68
+
69
+ size() {
70
+ try { return statSync(this.path).size; } catch { return 0; }
71
+ }
72
+
73
+ /** 读 offset 之后新增的完整行;半行留到下次;文件被截断则从头再读。 */
74
+ poll() {
75
+ let size;
76
+ try { size = statSync(this.path).size; } catch { return; }
77
+ if (size < this.offset) this.offset = 0;
78
+ if (size === this.offset) return;
79
+ const length = size - this.offset;
80
+ const buf = Buffer.allocUnsafe(length);
81
+ let read = 0;
82
+ const fd = openSync(this.path, 'r');
83
+ try { read = readSync(fd, buf, 0, length, this.offset); } finally { closeSync(fd); }
84
+ if (read <= 0) return;
85
+ const chunk = buf.subarray(0, read);
86
+ const end = chunk.lastIndexOf(0x0a);
87
+ if (end < 0) return;
88
+ this.offset += end + 1;
89
+ for (const line of chunk.subarray(0, end).toString('utf8').split('\n')) {
90
+ const entry = parseBoardEvent(line, this.logger);
91
+ if (entry === undefined) continue;
92
+ try { this.hub.emit({ ...entry, type: 'task' }); } catch (error) { this.logger?.warn?.('conest-collab: task event emit failed: %s', error?.message ?? error); }
93
+ }
94
+ }
95
+ }
96
+
97
+ /**
98
+ * 把该时段的板事件与房间消息按时间合并。
99
+ * 消息的 created_at 是秒(store.append 默认 Date.now()/1000),板事件是 ISO 时间。
100
+ * members 是 hub 的成员({id,name,mentionId?}):消息里的 @ 用 mentions.mjs 解析成
101
+ * 成员 id 放进 `message.mentions`,页面直接照着画,不再从文本猜(#876 P)。
102
+ */
103
+ export function deskReplay({ store, boardDir, from, to, members = [] }) {
104
+ const fromMs = Date.parse(String(from ?? ''));
105
+ const toMs = Date.parse(String(to ?? ''));
106
+ if (!Number.isFinite(fromMs) || !Number.isFinite(toMs)) throw new CollabError('invalid', 'from/to must be ISO timestamps');
107
+ if (toMs < fromMs) throw new CollabError('invalid', 'to must not be earlier than from');
108
+
109
+ const merged = [];
110
+ for (const entry of readBoardEvents(boardDir)) {
111
+ const ts = Date.parse(entry.ts ?? '');
112
+ if (!Number.isFinite(ts) || ts < fromMs || ts > toMs) continue;
113
+ merged.push({ ts, event: { ...entry, type: 'task' } });
114
+ }
115
+ const rows = store.db.prepare('SELECT * FROM messages WHERE deleted = 0 AND created_at >= ? AND created_at <= ? ORDER BY created_at DESC, id DESC LIMIT ?')
116
+ .all(fromMs / 1000, toMs / 1000, REPLAY_MAX);
117
+ for (const row of rows.reverse()) {
118
+ const message = publicMessage(row, store);
119
+ const mentioned = members.length ? [...mentionsOf(String(message.text ?? ''), members)] : [];
120
+ if (mentioned.length) message.mentions = mentioned;
121
+ merged.push({ ts: row.created_at * 1000, event: { type: 'message', message } });
122
+ }
123
+ merged.sort((a, b) => a.ts - b.ts);
124
+ const kept = merged.length > REPLAY_MAX ? merged.slice(-REPLAY_MAX) : merged;
125
+ return { from: new Date(fromMs).toISOString(), to: new Date(toMs).toISOString(), events: kept.map(m => m.event) };
126
+ }
127
+
128
+ /** 桌面页:C 路提供的 dsh/collab/desk/index.html;还没落地时给一个能看事件流的占位页。 */
129
+ export function deskPage() {
130
+ try { return readFileSync(new URL('./desk/index.html', import.meta.url), 'utf8'); } catch { return PLACEHOLDER_PAGE; }
131
+ }
132
+
133
+ /** /collab/desk 与 /collab/api/desk/replay;返回 true 表示已处理,未匹配返回 undefined。 */
134
+ export function deskRoute(req, res, url, { store, boardDir, members = [] }) {
135
+ if (url.pathname.replace(/\/+$/, '') !== '/collab/desk' && url.pathname.replace(/\/+$/, '') !== '/collab/api/desk/replay') return undefined;
136
+ if (req.method !== 'GET') return undefined;
137
+ if (url.pathname.replace(/\/+$/, '') === '/collab/desk') {
138
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
139
+ res.end(deskPage());
140
+ return true;
141
+ }
142
+ try {
143
+ const body = deskReplay({ store, boardDir, members, from: url.searchParams.get('from'), to: url.searchParams.get('to') });
144
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
145
+ res.end(JSON.stringify(body));
146
+ } catch (error) {
147
+ const status = error instanceof CollabError ? 400 : 500;
148
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
149
+ res.end(JSON.stringify(error instanceof CollabError ? { error: error.message, code: error.code } : { error: 'replay failed' }));
150
+ }
151
+ return true;
152
+ }
153
+
154
+ const PLACEHOLDER_PAGE = `<!doctype html>
155
+ <html lang="zh-CN">
156
+ <head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
157
+ <title>团队工作台</title>
158
+ <style>body{font:14px/1.6 system-ui,sans-serif;margin:0;padding:24px;background:#0f1115;color:#e6e8eb}
159
+ h1{font-size:18px;margin:0 0 8px}p{color:#8b949e;margin:0 0 16px}
160
+ ol{padding-left:20px;font-family:ui-monospace,monospace;font-size:12px}</style></head>
161
+ <body>
162
+ <h1>团队工作台</h1>
163
+ <p>办公室地图页由前端路补充;这里是占位页,只接真实事件流,不造假光柱。</p>
164
+ <ol id="events"></ol>
165
+ <script>
166
+ const list = document.getElementById('events');
167
+ const add = text => { const li = document.createElement('li'); li.textContent = text; list.prepend(li); };
168
+ const source = new EventSource('/collab/api/events');
169
+ source.onmessage = message => {
170
+ let event;
171
+ try { event = JSON.parse(message.data); } catch { return; }
172
+ if (event.type === 'task') add([event.ts, event.kind, event.from, '→', event.to, event.title].filter(Boolean).join(' '));
173
+ };
174
+ </script>
175
+ </body>
176
+ </html>
177
+ `;
@@ -16,19 +16,25 @@ export class GateContext {
16
16
  this.timeoutMs = timeoutMs;
17
17
  this.fetch = fetchFn;
18
18
  this.logger = logger;
19
- /** `${member}\0${room}` -> { session, paths: Set } : pages already in that conversation's context. */
19
+ /** `${member}\0${room}` -> { session, epoch }: the name of that conversation's context for the gate. */
20
20
  this.sent = new Map();
21
21
  }
22
22
 
23
23
  /**
24
- * The block of pages this (member, room) conversation has not been given yet, and a commit()
25
- * that records them as given (call it once the prompt is handed to the agent). The bridge keeps
26
- * one list per session (core/gate.go UpdateTurnCtx); here a new `session` starts the list over.
24
+ * The block of pages this (member, room) conversation has not been given yet. The gate keeps what
25
+ * it pushed per (key, bot, epoch) and returns only new entries (ctxsvc.only_new, 11-03); here we only
26
+ * name the context: a new `session`, reset, forget or resetRoom gives a new epoch (as core/gate.go turnEpoch).
27
27
  * @returns {Promise<{ block: string, commit: () => void }>} block is '' when there is nothing new.
28
28
  */
29
29
  async turn({ member, room, session, text }) {
30
30
  const none = { block: '', commit: () => {} };
31
31
  if (!this.port || typeof this.fetch !== 'function') return none;
32
+ const key = `${member}\u0000${room}`;
33
+ let entry = this.sent.get(key);
34
+ if (entry === undefined || entry.session !== session) {
35
+ entry = { session, epoch: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}` };
36
+ this.sent.set(key, entry);
37
+ }
32
38
  let out;
33
39
  try {
34
40
  const res = await this.fetch(`http://127.0.0.1:${this.port}/context`, {
@@ -36,7 +42,7 @@ export class GateContext {
36
42
  headers: { 'Content-Type': 'application/json' },
37
43
  // bot = dsh:<member id>: the gate finds the kind "dsh" team entry with that id (lead.member),
38
44
  // apart from the bridge bots' keys, which may be the same word.
39
- body: JSON.stringify({ key: `dsh:${member}:${room}`, text: text ?? '', bot: `dsh:${member}` }),
45
+ body: JSON.stringify({ key: `dsh:${member}:${room}`, text: text ?? '', bot: `dsh:${member}`, epoch: entry.epoch }),
40
46
  signal: AbortSignal.timeout(this.timeoutMs),
41
47
  });
42
48
  if (!res.ok) throw new Error(`status ${res.status}`);
@@ -45,22 +51,10 @@ export class GateContext {
45
51
  this.logger?.debug?.('collab: gate context fetch failed: %s', error?.message ?? error);
46
52
  return none;
47
53
  }
48
- const key = `${member}\u0000${room}`;
49
- let entry = this.sent.get(key);
50
- if (entry !== undefined && entry.session !== session) entry = undefined;
51
- const known = entry?.paths ?? new Set();
52
- const fresh = (Array.isArray(out?.wiki) ? out.wiki : [])
53
- .filter(w => w && typeof w.path === 'string' && !known.has(w.path));
54
+ const fresh = (Array.isArray(out?.wiki) ? out.wiki : []).filter(w => w && typeof w.path === 'string');
54
55
  if (fresh.length === 0) return none;
55
56
  const block = '(可能相关的 wiki 页,需要时去读)' + fresh.map(w => `\n- ${w.path} — ${w.desc ?? ''}`).join('');
56
- return {
57
- block,
58
- commit: () => {
59
- let cur = this.sent.get(key);
60
- if (cur === undefined || cur.session !== session) { cur = { session, paths: new Set() }; this.sent.set(key, cur); }
61
- for (const w of fresh) cur.paths.add(w.path);
62
- },
63
- };
57
+ return { block, commit: () => {} };
64
58
  }
65
59
 
66
60
  /** /new for a (member, room) whose session key does not change (a local CLI member): start it over. */
@@ -0,0 +1,175 @@
1
+ {
2
+ "_doc": [
3
+ "交接校验规则,只此一份:ws-handoff(_handoff_rules.py)和 DSH collab(handoff-rules.mjs:建工作区交接、/tasks 派活)都读它。",
4
+ "标题行 = 去掉前导空白后以 # 开头的行;一节 = 从标题(或 inline 时以节名开头的行)到下一个标题。lead 是行首可去掉的字符。",
5
+ "正则只写 ASCII 字符类(JS 的 \\w、\\d 只认 ASCII,Python 认 Unicode),按 search 用,要锚定就写 ^。",
6
+ "checks 按顺序跑,报第一条不合格的。dispatch 列出只带字段的派活(/tasks:title、ref、base)要跑的检查:字段拼成一份交接再查。",
7
+ "改规则后跑 contrib/lead/test_handoff_rules.py(Python 与 Node 跑同一组 fixture 比对)。",
8
+ "art_ref:出图类活(总目标/范围提到画、出图、小人、角色图、封面、立绘、Q版等,且范围的「改」行没写代码文件)要有「参考图」节(或「参考图:」行),列至少 min 张参考图路径或链接;写「原创:无现成图」的创意活照样要 min 张风格/构图参考,另写先出小样给用户挑。交回 [report] 要带对照图(team-exit-rules.json 的 art)。起因:用户 11-01「每次都给我画一坨屎出来」,规矩写在文字里没人执行,改成检查。",
9
+ "reuse:参照实现写「无(新建)」时必须有「复用盘点:」行。起因:用户 10-30 两次、11-02 一次骂「又自己写一套、不复用记忆论坛」,同类错第 3 次改成检查。"
10
+ ],
11
+ "lead": "#-*> \t",
12
+ "checks": [
13
+ {
14
+ "id": "sections",
15
+ "type": "headings",
16
+ "names": [
17
+ "用户原话",
18
+ "总目标",
19
+ "范围",
20
+ "已查过什么",
21
+ "进度",
22
+ "相关改动",
23
+ "规矩"
24
+ ],
25
+ "msg": "交接文件缺小节:{missing}",
26
+ "sep": "、"
27
+ },
28
+ {
29
+ "id": "ref",
30
+ "type": "ref",
31
+ "section": "参照实现",
32
+ "inline": true,
33
+ "none_re": "无\\s*[((]\\s*新建\\s*[))]",
34
+ "ref_re": "[~A-Za-z0-9_./-]*[A-Za-z_][~A-Za-z0-9_./-]*:[0-9]+",
35
+ "path_re": "[/.]",
36
+ "msg": "缺参照实现:写 文件:行,非代码活写「无(新建)」。示例:\n## 参照实现\n- bridge/contrib/lead/ws-up:120(和它的差异:…)",
37
+ "dispatch_msg": "缺参照实现:写 文件:行,非代码活写「无(新建)」。示例:ref: \"dsh/collab/tasks.mjs:226\",或 ref: \"无(新建)\""
38
+ },
39
+ {
40
+ "id": "reuse",
41
+ "type": "needs_if",
42
+ "trigger_re": "无\\s*[((]\\s*新建\\s*[))]",
43
+ "need_re": "复用盘点\\s*[::]\\s*\\S.{5,}",
44
+ "msg": "参照实现写了「无(新建)」,就要加一行「复用盘点:」,写清查过哪些现成能力(记忆论坛推送/打分、夜间反思、dream 手册、ws-handoff 续做、已有脚本或 skill 等,带 文件:行 或名字)、为什么都不能用。起因:用户 10-30、11-02 三次骂「又自己写一套」。示例:\n复用盘点:查过 ~/.bothouse/gate-exp/ctxsvc.py:355 推送、~/wiki/bin/manuals.py 夜里手册,都管不到 X,所以新建"
45
+ },
46
+ {
47
+ "id": "checked",
48
+ "type": "ref",
49
+ "section": "已查过什么",
50
+ "none_re": "无\\s*[((]\\s*没查过",
51
+ "ref_re": "[~A-Za-z0-9_./-]*[A-Za-z_][~A-Za-z0-9_./-]*(:[0-9]+|\\.[A-Za-z]+)",
52
+ "path_re": "[/.]",
53
+ "msg": "「已查过什么」节要写已查过的 文件:行 或文档路径,下一棒不用重查;什么都没查过写「无(没查过)」。示例:\n## 已查过什么\n- dsh/collab/tasks.mjs:467 assign 只校验 ref/base\n- ~/work/codemaps/bothouse-bridge.md「编译与测试」节"
54
+ },
55
+ {
56
+ "id": "scope",
57
+ "type": "kinds",
58
+ "section": "范围",
59
+ "line_re": "^(不?改)\\s*[::]\\s*\\S",
60
+ "need": [
61
+ "改",
62
+ "不改"
63
+ ],
64
+ "max": {
65
+ "kind": "改",
66
+ "n": 3,
67
+ "msg": "「范围」里「改」有 {n} 项,超过 3 项:拆成几件分别交,每件只管 1-3 项。"
68
+ },
69
+ "item": "{}:…",
70
+ "sep": "」「",
71
+ "msg": "「范围」节缺「{missing}」行:用户原话里的名词逐个写改或不改,没有不改的写「不改:无」。示例:\n## 范围\n- 改:任务卡(只在工作区显示)\n- 不改:进度表、聊天频道的每轮回复"
72
+ },
73
+ {
74
+ "id": "conflict",
75
+ "type": "values",
76
+ "section": "相关改动",
77
+ "line_re": "^冲突\\s*[::]\\s*(.*)",
78
+ "ok_re": "^无\\s*([((].*[))])?\\s*[。.;;]?$",
79
+ "or_re": "已问用户\\s*[::]\\s*\\S",
80
+ "msg": "「相关改动」节缺一行「冲突:无」或「冲突:…(已问用户:<答复>)」。示例:\n## 相关改动\n- #612 已合,改了 core/handoff.go,和这件不重叠\n冲突:无",
81
+ "bad_msg": "相关改动里写了冲突、没写已问用户:先在聊天频道问用户,把答复写进相关改动(「冲突:…(已问用户:<答复>)」)"
82
+ },
83
+ {
84
+ "id": "base",
85
+ "type": "heading_needs_field",
86
+ "headings": [
87
+ "共改文件",
88
+ "多人改"
89
+ ],
90
+ "field": "底分支",
91
+ "msg": "交接文件写了多人改同组文件(「共改文件」/「多人改」节),缺「底分支:<分支名>」一行。示例:\n底分支:feat/approvals-card",
92
+ "dispatch_msg": "多人改同组文件要写底分支:base 填分支名,示例:base: \"feat/approvals-card\""
93
+ },
94
+ {
95
+ "id": "art_ref",
96
+ "type": "art_ref",
97
+ "trigger_sections": [
98
+ "总目标",
99
+ "范围"
100
+ ],
101
+ "trigger_re": "出图|生图|小人|角色图|立绘|[Qq] ?版|插画|海报|宣传图|配图|封面图|做封面|画(一|个|张|幅|出)?[张个幅]?(角色|人物|头像|封面|画像|插图)",
102
+ "code_section": "范围",
103
+ "code_line_re": "^改\\s*[::].*([A-Za-z0-9_-][.](py|go|mjs|js|ts|tsx|json|sh|yml|yaml|toml|html|css)([^A-Za-z0-9]|$)|[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)",
104
+ "section": "参考图",
105
+ "inline": true,
106
+ "image_re": "https?://[^ \t<>()()「」\"'`,,、;;]+|[^ \t<>()()「」\"'`,,、;;::]+[.](png|jpg|jpeg|webp|gif|avif|PNG|JPG|JPEG|WEBP)",
107
+ "min": 2,
108
+ "original_re": "原创\\s*[::]\\s*无现成图",
109
+ "sample_re": "小样",
110
+ "msg": "这是出图类活,缺参考图:先找官方图或现成高分图当参考,写一节「参考图」列至少 {min} 张图的路径或链接(现在 {n} 张)。真是原创没有现成图,写「原创:无现成图」,照样列 {min} 张风格/构图参考(同类作品、画风、配色都行),并写明先出 3 张小样给用户挑,挑定再出正稿。示例:\n## 参考图\n- ~/art/ref/官方立绘.png\n- https://example.com/高分同人.jpg",
111
+ "original_msg": "写了「原创:无现成图」,还要写明先出 3 张小样给用户挑、挑定再出正稿(正文里要有「小样」)。"
112
+ },
113
+ {
114
+ "id": "exemplar",
115
+ "type": "section_needs_table",
116
+ "trigger_section": "用户原话",
117
+ "trigger_re": "「(?!\\[replying)[^」]*(照着|照抄|抄|学习|学一下|参考|对标|模仿|仿照)",
118
+ "object_re": "https?://|《[^》]+》",
119
+ "section": "照抄对象",
120
+ "row_re": "^\\S.*(→|->)\\s*\\S",
121
+ "rows": 3,
122
+ "need_res": [
123
+ "https?://|《[^》]+》",
124
+ "并排截图"
125
+ ],
126
+ "msg": "用户原话给了范本(链接或《…》+ 照着/抄/学习/参考/对标),交接要有「照抄对象」节:范本链接、「范本第 N 部分 → 我们第 N 部分」对照至少 3 行、验收写「并排截图」。示例:\n## 照抄对象\n- 范本:https://github.com/BotHarness/DeepSeekBot 的 README\n- 范本第 1 部分 顶部插画横幅 → 我们第 1 部分 顶部横幅\n- 范本第 2 部分 明暗两版截图 → 我们第 2 部分 功能截图\n- 范本第 3 部分 三行安装 → 我们第 3 部分 安装\n- 验收:并排截图(范本和我们同一部分左右放)"
127
+ },
128
+ {
129
+ "id": "hq_kinds",
130
+ "type": "kinds",
131
+ "section": "总部判断",
132
+ "line_re": "^(要的效果|好的样子|教训和定案)\\s*[::]\\s*\\S.{9,}",
133
+ "need": [
134
+ "要的效果",
135
+ "好的样子",
136
+ "教训和定案"
137
+ ],
138
+ "item": "{}:…",
139
+ "sep": "」「",
140
+ "msg": "交接缺「总部判断」或写得太空:要有「{missing}」行,每行写满一句(10 字以上),写清要的效果、好的样子、教训和定案。"
141
+ },
142
+ {
143
+ "id": "hq_ref",
144
+ "type": "ref",
145
+ "section": "总部判断",
146
+ "none_re": "(?!)",
147
+ "ref_re": "[~A-Za-z0-9_./-]*[A-Za-z_][~A-Za-z0-9_./-]*:[0-9]+|[0-9]{1,2}-[0-9]{2}",
148
+ "path_re": "[/.]|^[0-9]{1,2}-[0-9]{2}$",
149
+ "msg": "「总部判断」没写出处:至少带一处文档 文件:行,或原话的日期(如 11-01)。"
150
+ },
151
+ {
152
+ "id": "no_tmp",
153
+ "type": "forbid",
154
+ "re": "(^|[\\s`'\"((「:=])/(private/)?tmp/",
155
+ "msg": "交接里引用了 /tmp 下的文件:下一棒(换了会话、重启过)常找不到。脚本和状态文件挪到角色目录(如 notes/、~/work/),交接里写完整路径。示例:\n- 上一棒的脚本:~/work/tools/render_cover.py(不写 /tmp/render.py)"
156
+ },
157
+ {
158
+ "id": "browser_tab",
159
+ "type": "needs_if",
160
+ "trigger_re": "(开|用|在|进|上)个?浏览器|浏览器(里|上|中|配置)|[Cc]hrome ?(里|上)|登录态下(操作|下单|点)",
161
+ "need_re": "(标签页|tab)\\s*(id|ID|Id)?\\s*[::=]\\s*([A-Za-z0-9_-]+|无)",
162
+ "msg": "浏览器活要写用哪个标签页:一行「标签页 id:<id>」,下一棒直接接着用,不用重新找页面、重新登录;还没开过页面写「标签页 id:无」。"
163
+ }
164
+ ],
165
+ "dispatch": {
166
+ "checks": [
167
+ "ref",
168
+ "base"
169
+ ],
170
+ "fields": {
171
+ "ref": "ref",
172
+ "base": "base"
173
+ }
174
+ }
175
+ }
package/collab/hub.mjs CHANGED
@@ -17,6 +17,8 @@ export class Hub {
17
17
  this.members = new Map();
18
18
  /** roomId -> Set<memberId> currently working */
19
19
  this.thinking = new Map();
20
+ /** id -> optional busy fields published by the team-status reader (#876). */
21
+ this.presence = new Map();
20
22
  this.listeners = [];
21
23
  this.planners = [];
22
24
  this.subscribers = new Set();
@@ -62,6 +64,7 @@ export class Hub {
62
64
 
63
65
  removeMember(id) {
64
66
  if (!this.members.delete(id)) return;
67
+ this.presence.delete(id);
65
68
  for (const room of this.thinking.keys()) this.setThinking(room, id, false);
66
69
  this.emit({ type: 'members', members: this.memberView() });
67
70
  }
@@ -70,7 +73,27 @@ export class Hub {
70
73
  const m = this.members.get(id);
71
74
  if (m === undefined || m.online === online) return;
72
75
  m.online = online;
73
- if (!online) for (const room of this.thinking.keys()) this.setThinking(room, id, false);
76
+ if (!online) {
77
+ this.presence.delete(id);
78
+ for (const room of this.thinking.keys()) this.setThinking(room, id, false);
79
+ }
80
+ this.emit({ type: 'members', members: this.memberView() });
81
+ }
82
+
83
+ /**
84
+ * Publish optional busy fields on one member (#876). undefined clears them;
85
+ * unchanged values do not emit, so a 2s poll never floods the SSE stream.
86
+ */
87
+ setPresence(id, presence) {
88
+ if (!this.members.has(id)) return;
89
+ const prev = this.presence.get(id);
90
+ if (presence === undefined) {
91
+ if (prev === undefined) return;
92
+ this.presence.delete(id);
93
+ } else {
94
+ if (JSON.stringify(prev) === JSON.stringify(presence)) return;
95
+ this.presence.set(id, presence);
96
+ }
74
97
  this.emit({ type: 'members', members: this.memberView() });
75
98
  }
76
99
 
@@ -78,6 +101,7 @@ export class Hub {
78
101
  const member = this.members.get(id);
79
102
  if (member === undefined) return;
80
103
  member.error = error;
104
+ if (error !== undefined) this.presence.delete(id);
81
105
  this.emit({ type: 'members', members: this.memberView() });
82
106
  }
83
107
 
@@ -94,6 +118,7 @@ export class Hub {
94
118
  return this.memberList().map(({ id, name, kind, online, model, cli, permissionPreset, persona, nameCustomized, avatar, avatarVersion, source, error, rooms }) => ({
95
119
  id, name, kind, ...(rooms ? { rooms } : {}), online: online !== false && !error, ...(error ? { error } : {}), ...(avatar ? { avatar, ...(avatarVersion ? { avatarVersion } : {}) } : {}),
96
120
  ...(kind === 'dsh' ? { model: model ?? null, ...(cli ? { cli, ...(permissionPreset ? { permissionPreset } : {}) } : {}), ...(nameCustomized === undefined ? {} : { nameCustomized }), persona: persona ?? '', editable: source === 'ui' } : {}),
121
+ ...(this.presence.get(id) ?? {}),
97
122
  }));
98
123
  }
99
124