bothouse-dsh 0.1.45 → 0.1.47

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,225 @@
1
+ // #876 团队工作台:任务板事件流 → hub 事件、回放接口、桌面页挂载。
2
+ //
3
+ // 事件契约见 Issue #876:`board-events.jsonl` 与 board.jsonl 同目录(BOARD_DIR),
4
+ // 一行一个 JSON,只追加;这里只读不改,写入归 gate 的 board.py。
5
+ import { closeSync, mkdirSync, openSync, readFileSync, readSync, renameSync, statSync, writeFileSync } from 'node:fs';
6
+ import { dirname, 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
+ // 名牌自定义名:成员 id → 名字,存插件数据目录,刷新、换设备都还在;空名 = 恢复默认(roster.name)。
134
+ export const DESK_NAMES_FILE = 'desk-names.json';
135
+ export const DESK_NAME_MAX = 24;
136
+
137
+ export function readDeskNames(file) {
138
+ if (!file) return {};
139
+ try {
140
+ const value = JSON.parse(readFileSync(file, 'utf8'));
141
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
142
+ } catch { return {}; }
143
+ }
144
+
145
+ /** 改一个成员的名牌名;name 去空白后为空就删掉,回到默认名。返回改后的全表。 */
146
+ export function setDeskName(file, id, name) {
147
+ const key = String(id ?? '').trim();
148
+ if (!key) throw new CollabError('invalid', 'id is required');
149
+ const value = String(name ?? '').replace(/\s+/g, ' ').trim();
150
+ if (Array.from(value).length > DESK_NAME_MAX) throw new CollabError('invalid', `name must be at most ${DESK_NAME_MAX} characters`);
151
+ const names = readDeskNames(file);
152
+ if (value) names[key] = value; else delete names[key];
153
+ mkdirSync(dirname(file), { recursive: true });
154
+ const tmp = `${file}.${process.pid}.tmp`;
155
+ writeFileSync(tmp, JSON.stringify(names, null, 2) + '\n');
156
+ renameSync(tmp, file);
157
+ return names;
158
+ }
159
+
160
+ function sendJson(res, status, value) {
161
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
162
+ res.end(JSON.stringify(value));
163
+ }
164
+
165
+ async function namesRoute(req, res, namesFile) {
166
+ if (!namesFile) return sendJson(res, 404, { error: 'desk names are not configured' });
167
+ if (req.method === 'GET') return sendJson(res, 200, { names: readDeskNames(namesFile) });
168
+ let raw = '';
169
+ try {
170
+ for await (const chunk of req) { raw += chunk; if (raw.length > 4096) return sendJson(res, 413, { error: 'body too large' }); }
171
+ const body = JSON.parse(raw || '{}');
172
+ return sendJson(res, 200, { names: setDeskName(namesFile, body?.id, body?.name) });
173
+ } catch (error) {
174
+ if (error instanceof CollabError) return sendJson(res, 400, { error: error.message, code: error.code });
175
+ if (error instanceof SyntaxError) return sendJson(res, 400, { error: 'invalid JSON body' });
176
+ return sendJson(res, 500, { error: 'save failed' });
177
+ }
178
+ }
179
+
180
+ /** /collab/desk、/collab/api/desk/replay、/collab/api/desk/names;返回 true(或 Promise)表示已处理,未匹配返回 undefined。 */
181
+ export function deskRoute(req, res, url, { store, boardDir, members = [], namesFile } = {}) {
182
+ if (url.pathname.replace(/\/+$/, '') === '/collab/api/desk/names' && (req.method === 'GET' || req.method === 'PUT')) return namesRoute(req, res, namesFile);
183
+ if (url.pathname.replace(/\/+$/, '') !== '/collab/desk' && url.pathname.replace(/\/+$/, '') !== '/collab/api/desk/replay') return undefined;
184
+ if (req.method !== 'GET') return undefined;
185
+ if (url.pathname.replace(/\/+$/, '') === '/collab/desk') {
186
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
187
+ res.end(deskPage());
188
+ return true;
189
+ }
190
+ try {
191
+ const body = deskReplay({ store, boardDir, members, from: url.searchParams.get('from'), to: url.searchParams.get('to') });
192
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
193
+ res.end(JSON.stringify(body));
194
+ } catch (error) {
195
+ const status = error instanceof CollabError ? 400 : 500;
196
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
197
+ res.end(JSON.stringify(error instanceof CollabError ? { error: error.message, code: error.code } : { error: 'replay failed' }));
198
+ }
199
+ return true;
200
+ }
201
+
202
+ const PLACEHOLDER_PAGE = `<!doctype html>
203
+ <html lang="zh-CN">
204
+ <head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
205
+ <title>团队工作台</title>
206
+ <style>body{font:14px/1.6 system-ui,sans-serif;margin:0;padding:24px;background:#0f1115;color:#e6e8eb}
207
+ h1{font-size:18px;margin:0 0 8px}p{color:#8b949e;margin:0 0 16px}
208
+ ol{padding-left:20px;font-family:ui-monospace,monospace;font-size:12px}</style></head>
209
+ <body>
210
+ <h1>团队工作台</h1>
211
+ <p>办公室地图页由前端路补充;这里是占位页,只接真实事件流,不造假光柱。</p>
212
+ <ol id="events"></ol>
213
+ <script>
214
+ const list = document.getElementById('events');
215
+ const add = text => { const li = document.createElement('li'); li.textContent = text; list.prepend(li); };
216
+ const source = new EventSource('/collab/api/events');
217
+ source.onmessage = message => {
218
+ let event;
219
+ try { event = JSON.parse(message.data); } catch { return; }
220
+ if (event.type === 'task') add([event.ts, event.kind, event.from, '→', event.to, event.title].filter(Boolean).join(' '));
221
+ };
222
+ </script>
223
+ </body>
224
+ </html>
225
+ `;
@@ -203,9 +203,34 @@ export function friendlyFailure(raw) {
203
203
  if (/model.{0,30}(not found|unavailable|not available|unsupported)|unknown model|模型.*不可用|404/i.test(text)) return '这个模型暂时不可用,请检查模型名称或换一个模型。';
204
204
  if (/login|log.?in|auth|unauthorized|401|未登录/i.test(text)) return '模型或 CLI 还没有登录,或登录已过期。请先登录后再试。';
205
205
  if (/exit|ENOENT|spawn|没有输出文字/i.test(text)) return '本机 CLI 未能正常运行或已退出,请检查安装和登录状态。';
206
+ // A Host older than this plugin has no scope lease on its preset registry, so no member can be
207
+ // composed. Name the cause and the fix instead of showing the bare TypeError.
208
+ if (/agentPresets\.\w+ is not a function/i.test(text)) return '当前 DSH 宿主缺少频道插件需要的预设接口(agentPresets),成员无法启动。请把 DSH 升级到与本插件匹配的版本后重试。';
206
209
  return `这次没能完成,请检查配置后重试。原始错误:${text}`;
207
210
  }
208
211
 
212
+ /**
213
+ * Lease one preset revision while an Agent joins it, or undefined when the Host's
214
+ * registry has no scope lease. `acquireScope` shipped in a later registry than
215
+ * `resolve`/`mount`, so a Host can serve presets without it; the lease only keeps
216
+ * that revision alive during assembly. Skipping it there keeps members usable
217
+ * instead of failing every turn with a bare "is not a function" TypeError.
218
+ * @param ctx - Host context carrying the preset registry.
219
+ * @param presetId - Preset identity the Agent joins.
220
+ * @returns Something with an async disposer, or undefined when unsupported.
221
+ */
222
+ export async function presetScope(ctx, presetId) {
223
+ const registry = ctx?.agentPresets;
224
+ if (typeof registry?.acquireScope !== 'function') return undefined;
225
+ return await registry.acquireScope(presetId);
226
+ }
227
+
228
+ /** Release a presetScope result; a Host without the lease releases nothing. */
229
+ export async function releasePresetScope(scope) {
230
+ const dispose = scope?.[Symbol.asyncDispose];
231
+ if (typeof dispose === 'function') await dispose();
232
+ }
233
+
209
234
  /**
210
235
  * Card text of one approval request, in cc-connect's permission prompt layout:
211
236
  * the tool, what it wants to run (the call's first argument), and DSH's reason.
@@ -911,7 +936,7 @@ export class DshMembers {
911
936
  async createAgent(member, room) {
912
937
  const { ctx } = this.rt;
913
938
  const preset = await ctx.agentPresets.resolve(member.agentPreset);
914
- const scope = await ctx.agentPresets.acquireScope(preset.id);
939
+ const scope = await presetScope(ctx, preset.id);
915
940
  const sessionId = `collab-${randomUUID()}`;
916
941
  let handle;
917
942
  let workspace;
@@ -932,7 +957,7 @@ export class DshMembers {
932
957
  },
933
958
  });
934
959
  } finally {
935
- await scope[Symbol.asyncDispose]();
960
+ await releasePresetScope(scope);
936
961
  }
937
962
  await workspace.attachSession(sessionId);
938
963
  // setup ran before the Agent existed, so record it here: liveAgent must not hook it again.
@@ -1006,7 +1031,7 @@ export class DshMembers {
1006
1031
  // Installing only the request hook left a resumed Session with just the
1007
1032
  // built-in tools after a DSH restart.
1008
1033
  const preset = await ctx.agentPresets.resolve(member.agentPreset);
1009
- const scope = await ctx.agentPresets.acquireScope(preset.id);
1034
+ const scope = await presetScope(ctx, preset.id);
1010
1035
  let handle;
1011
1036
  try {
1012
1037
  handle = await ctx.agents.resume({
@@ -1018,7 +1043,7 @@ export class DshMembers {
1018
1043
  },
1019
1044
  });
1020
1045
  } finally {
1021
- await scope[Symbol.asyncDispose]();
1046
+ await releasePresetScope(scope);
1022
1047
  }
1023
1048
  this.hooked.add(handle.agent);
1024
1049
  if (member.permissionPreset !== undefined) ctx.permissionPresets.set(handle.agent.session, member.permissionPreset);
@@ -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