bothouse-dsh 0.1.41 → 0.1.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/collab/team-exit.mjs +140 -59
  2. package/package.json +1 -1
@@ -1,23 +1,29 @@
1
1
  // Durable workspace questions; system forwards never enter agent routing.
2
2
  import { randomBytes } from 'node:crypto';
3
+ import { readFileSync } from 'node:fs';
3
4
  import { withoutLeadingMentions } from './mentions.mjs';
4
5
 
5
6
  export function exitTarget(msg) {
6
7
  return /^team-answer:([^:]+):/.exec(msg.client_key ?? '')?.[1];
7
8
  }
8
9
 
9
- // #574 B4: an [ask] must carry 「自验:」 (evidence) and 「类别:」 before it waits on
10
- // the user; only 方向、花钱、需要用户看的成品、权限确认 may write 「自验:无需」.
11
- // Same check in the Go bridge: bridge/core/team_ask_evidence.go askEvidenceProblems.
12
- const SELF_CHECK = /自验\s*[::]/;
13
- const CATEGORY = /类别\s*[::]/;
14
- const EXEMPT = /方向|花钱|成品|权限/;
15
- const NO_CHECK = /^(无需|不需要|无|免)([\s,。,.;;((]|$)/;
16
- export const ASK_MISSING_SELF_CHECK = '缺「自验:」(附证据:截图或命令输出)';
17
- export const ASK_MISSING_CATEGORY = '缺「类别:」(方向、花钱、需要用户看的成品、权限确认之一,或写别的类别)';
18
- export const ASK_NO_CHECK_NOT_EXEMPT = '「自验:无需」只限方向、花钱、需要用户看的成品、权限确认四类,其它类别要附证据';
10
+ // The workspace [ask]/[report] rules (first-line mark and #id format, the [ask] self-check, bounce
11
+ // texts) are one file, team-exit-rules.json next to this one; the Go bridge reads the same file
12
+ // (bridge/core/team_exit_rules.go, from its dsh_bundle copy) and both sides run the shared cases in
13
+ // test/team-exit-cases.json. No formats or texts live here. Regexes are written for both RE2 and JS.
14
+ export const RULES = JSON.parse(readFileSync(new URL('./team-exit-rules.json', import.meta.url), 'utf8'));
15
+ const HEADLINE = new RegExp(RULES.headline_re);
16
+ const SELF_CHECK = new RegExp(RULES.ask.self_check_re);
17
+ const CATEGORY = new RegExp(RULES.ask.category_re);
18
+ const EXEMPT = new RegExp(RULES.ask.exempt_re);
19
+ const NO_CHECK = new RegExp(RULES.ask.no_check_re);
20
+ const JARGON_LINE = new RegExp(RULES.jargon.line_re, 'g');
21
+ const JARGON_TABLE = new RegExp(RULES.jargon.table_re);
22
+ const BACKGROUND = new RegExp(RULES.summary.background_re);
23
+ const TODO = new RegExp(RULES.summary.todo_re);
24
+ const DETAIL = new RegExp(RULES.summary.detail_re);
19
25
  // Consecutive bounces that still wake the asking member; later ones are only posted.
20
- const BOUNCE_WAKE_LIMIT = 2;
26
+ const BOUNCE_WAKE_LIMIT = RULES.bounce.wake_limit;
21
27
 
22
28
  /** What an [ask] body lacks; [] means it may go to the user. */
23
29
  export function askEvidenceProblems(body) {
@@ -32,42 +38,114 @@ export function askEvidenceProblems(body) {
32
38
  check = rest.trim();
33
39
  }
34
40
  const problems = [];
35
- if (!check) problems.push(ASK_MISSING_SELF_CHECK);
36
- if (!category) problems.push(ASK_MISSING_CATEGORY);
37
- if (check && category && NO_CHECK.test(check) && !EXEMPT.test(category)) problems.push(ASK_NO_CHECK_NOT_EXEMPT);
41
+ if (!check) problems.push(RULES.ask.missing_self_check);
42
+ if (!category) problems.push(RULES.ask.missing_category);
43
+ if (check && category && NO_CHECK.test(check) && !EXEMPT.test(category)) problems.push(RULES.ask.no_check_not_exempt);
38
44
  return problems;
39
45
  }
40
46
 
41
- export function askBounceText(problems) {
42
- return exitBounceText('ask', problems);
43
- }
44
-
45
- // Headline (user 2026-10-28 「做」): the first line is `[report] #<task id> <one or two sentences
46
- // for the user>`; that sentence is what the chat channel shows first. Same rule in the Go bridge:
47
- // bridge/core/team_exit_headline.go exitHeadline.
48
- const HEADLINE = /^#(\d+)[\s\u3000::,,、.。]*(.*)$/;
49
- export const EXIT_MISSING_ID = '第一行缺任务板编号(#编号)';
50
- export const EXIT_MISSING_LINE = '第一行缺给用户的一两句话';
51
- export const EXIT_UNKNOWN_ID = '任务板上没有这个编号';
52
-
53
- /** { id, line, problems } of a marked body's first line. */
47
+ /** { id, line, problems } of a marked body's first line; line is the part for the user (Go: exitHeadline). */
54
48
  export function exitHeadline(body) {
55
49
  const first = String(body ?? '').split('\n')[0].trim();
56
50
  const m = HEADLINE.exec(first);
57
- if (!m) return { id: 0, line: '', problems: first === '' || first.startsWith('#') ? [EXIT_MISSING_ID, EXIT_MISSING_LINE] : [EXIT_MISSING_ID] };
58
- const line = m[2].trim();
59
- return { id: Number(m[1]), line, problems: line ? [] : [EXIT_MISSING_LINE] };
51
+ const { missing_id: noId, missing_line: noLine } = RULES.headline;
52
+ if (!m) return { id: 0, line: '', problems: first === '' || first.startsWith('#') ? [noId, noLine] : [noId] };
53
+ const { summary: line } = exitSplit(body);
54
+ return { id: Number(m[1]), line, problems: line ? [] : [noLine] };
55
+ }
56
+
57
+ /**
58
+ * { summary, detail } of a marked body (Go: exitSplit): after the first line's #id, up to and
59
+ * including the 「要你做的:」 line is posted for the user; the lines after it go under that post.
60
+ */
61
+ export function exitSplit(body) {
62
+ let lines = String(body ?? '').replaceAll('\r\n', '\n').split('\n');
63
+ const m = HEADLINE.exec(lines[0].trim());
64
+ if (m) lines[0] = m[2];
65
+ while (lines.length && !lines[0].trim()) lines.shift();
66
+ let end = lines.findIndex(l => TODO.test(l));
67
+ if (end < 0) {
68
+ const blank = lines.findIndex(l => !l.trim());
69
+ end = blank < 0 ? lines.length - 1 : blank - 1;
70
+ }
71
+ const summary = lines.slice(0, end + 1).join('\n').trim();
72
+ let rest = lines.slice(end + 1);
73
+ while (rest.length && !rest[0].trim()) rest.shift();
74
+ if (rest.length && DETAIL.test(rest[0].trim())) rest = rest.slice(1);
75
+ return { summary, detail: rest.join('\n').trim() };
76
+ }
77
+
78
+ /** What the part for the user lacks: background, what was done, what the user is to do (Go: summaryProblems). */
79
+ export function summaryProblems(summary) {
80
+ const lines = String(summary).split('\n');
81
+ const problems = [];
82
+ if (!BACKGROUND.test(lines[0].trim())) problems.push(RULES.summary.missing_background);
83
+ const todo = lines.findIndex(l => TODO.test(l));
84
+ let done = false;
85
+ for (let i = 1; i < lines.length && !done; i++) {
86
+ let l = lines[i];
87
+ if (i === todo) l = l.slice(0, TODO.exec(l).index);
88
+ else if (todo >= 0 && i > todo) break;
89
+ done = l.trim() !== '';
90
+ }
91
+ if (!done) problems.push(RULES.summary.missing_done);
92
+ if (todo < 0) problems.push(RULES.summary.missing_todo);
93
+ return problems;
94
+ }
95
+
96
+ /** The part for the user names numbers, workspaces, branches, paths or identifiers, or has a table (Go: jargonProblems). */
97
+ export function jargonProblems(line) {
98
+ const problems = [];
99
+ const hits = [...String(line).matchAll(JARGON_LINE)].map(m => m[0].replace(/^[^~/`#!_\-0-9A-Za-z#!工]+/u, ''));
100
+ if (hits.length) problems.push(RULES.jargon.line.replaceAll('{hits}', hits.join('、')));
101
+ if (JARGON_TABLE.test(line)) problems.push(RULES.jargon.table);
102
+ return problems;
60
103
  }
61
104
 
62
105
  /** Why a workspace [ask]/[report] stayed in the workspace and how to resend it (Go: exitBounceText). */
63
106
  export function exitBounceText(kind, problems) {
64
- let t = `这条 [${kind}] 没有转给用户:${problems.join(';')}。补上后重发:第一行写 [${kind}] #任务板编号 给用户的一两句话(系统原样贴到聊天频道),第二行起写细节`;
65
- if (kind === 'ask') t += ',正文写「自验:」附证据和「类别:」;方向、花钱、需要用户看的成品、权限确认四类可写「自验:无需」';
66
- return `${t}。`;
107
+ const vars = { kind, problems: problems.join(RULES.bounce.sep), extra: RULES.bounce.extra[kind] ?? '' };
108
+ return RULES.bounce.text.replace(/\{(kind|problems|extra)\}/g, (_, k) => vars[k]);
67
109
  }
68
110
 
69
- const FIRST_MARK = /^\[(ask|report)\][ \t]*\n?([\s\S]*\S[\s\S]*)$/i;
70
- const LINE_MARK = /^[ \t]*\[(ask|report)\]/i;
111
+ /**
112
+ * How a marked body is judged (Go: judgeExit): { id, line, problems, legacy }. known(id): the id is a
113
+ * task; bounces: replies in a row already sent back. problems [] means it goes to the chat channel;
114
+ * legacy: the format is still wrong but bounces stopped waking the member (the bridge's chat lead
115
+ * rewrites it; DSH posts it without the headline).
116
+ */
117
+ export function judgeExit(kind, body, { known, bounces = 0 }) {
118
+ const problems = kind === 'ask' ? askEvidenceProblems(body) : [];
119
+ let { id, line, problems: hp } = exitHeadline(body);
120
+ if (!hp.length && !known(id)) hp = [RULES.headline.unknown_id.replaceAll('{id}', String(id))];
121
+ else if (!hp.length) hp = [...summaryProblems(line), ...jargonProblems(line)];
122
+ // Format goes back only while bounces still wake the member; after that the chat lead rewrites it.
123
+ if (hp.length && !problems.length && bounces >= BOUNCE_WAKE_LIMIT) return { id, line, problems, legacy: true };
124
+ return { id, line, problems: [...problems, ...hp], legacy: false };
125
+ }
126
+
127
+ /**
128
+ * The task a workspace reply is about (Go: exitTaskPick): the one its first line names when that one
129
+ * is here (bound to this workspace), else the one bound here last; 0 when none is here.
130
+ * items: [{ id, here, bound }], bound compared as given.
131
+ */
132
+ export function exitTaskPick(named, items) {
133
+ let pick = 0, last;
134
+ for (const it of items) {
135
+ if (!it.here) continue;
136
+ if (named > 0 && it.id === named) return named;
137
+ if (pick === 0 || it.bound > last) { pick = it.id; last = it.bound; }
138
+ }
139
+ return pick;
140
+ }
141
+
142
+ const escapeRe = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
143
+ const MARKS = RULES.kinds.map(k => escapeRe(RULES.mark.replace('{kind}', k))).join('|');
144
+ const FIRST_MARK = new RegExp(`^(${MARKS})([\\s\\S]*)$`, 'i');
145
+ // A mark with nothing after it is no mark (Go: parseExitKind).
146
+ const firstMark = t => { const m = FIRST_MARK.exec(t); return m?.[2].trim() ? m : null; };
147
+ const markKind = tag => RULES.kinds.find(k => RULES.mark.replace('{kind}', k).toLowerCase() === tag.toLowerCase());
148
+ const LINE_MARK = new RegExp(`^[ \\t]*(${MARKS})`, 'i');
71
149
 
72
150
  /**
73
151
  * The [ask] / [report] mark of a role reply: { kind, body } or undefined. Marked on the first
@@ -80,15 +158,15 @@ const LINE_MARK = /^[ \t]*\[(ask|report)\]/i;
80
158
  */
81
159
  export function exitMark(text, { lastLine = false, team = [] } = {}) {
82
160
  const t = withoutLeadingMentions(String(text ?? ''), team).trim();
83
- let m = FIRST_MARK.exec(t);
161
+ let m = firstMark(t);
84
162
  if (m === null && lastLine) {
85
163
  const lines = t.split('\n');
86
164
  for (let i = lines.length - 1; i > 0 && m === null; i--) {
87
165
  const line = withoutLeadingMentions(lines[i], team);
88
- if (LINE_MARK.test(line)) m = FIRST_MARK.exec([line.trimStart(), ...lines.slice(i + 1)].join('\n'));
166
+ if (LINE_MARK.test(line)) m = firstMark([line.trimStart(), ...lines.slice(i + 1)].join('\n'));
89
167
  }
90
168
  }
91
- return m === null ? undefined : { kind: m[1].toLowerCase(), body: m[2] };
169
+ return m === null ? undefined : { kind: markKind(m[1]), body: m[2].trim() };
92
170
  }
93
171
 
94
172
  export class TeamExit {
@@ -151,16 +229,11 @@ export class TeamExit {
151
229
  const ask = mark?.kind === 'ask';
152
230
  let headline;
153
231
  if (mark) {
154
- const problems = ask ? askEvidenceProblems(mark.body) : [];
155
- headline = exitHeadline(mark.body);
156
- if (!headline.problems.length) {
157
- headline.task = this.hub.tasks?.task?.(headline.id);
158
- if (!headline.task) headline.problems.push(`${EXIT_UNKNOWN_ID}(#${headline.id})`);
159
- }
232
+ const bounces = this.bounces.get(`${room.id}:${msg.author}`) ?? 0;
233
+ const v = judgeExit(mark.kind, mark.body, { known: id => Boolean(this.hub.tasks?.task?.(id)), bounces });
234
+ if (v.problems.length) return this.bounce(room, msg, v.problems, mark.kind);
160
235
  // Once bounces stop waking the member, a reply still without a headline goes out as before.
161
- if (headline.problems.length && !problems.length && (this.bounces.get(`${room.id}:${msg.author}`) ?? 0) >= BOUNCE_WAKE_LIMIT) headline = undefined;
162
- else problems.push(...headline.problems);
163
- if (problems.length) return this.bounce(room, msg, problems, mark.kind);
236
+ if (!v.legacy) headline = { ...v, task: this.hub.tasks?.task?.(v.id) };
164
237
  }
165
238
  // Any reply without a bounced [ask] ends the run of bounces.
166
239
  this.bounces.delete(`${room.id}:${msg.author}`);
@@ -178,12 +251,12 @@ export class TeamExit {
178
251
  // #597 G35: a DSH member's [ask] keeps its task in work and watched (and its workspace, so the
179
252
  // answer reaches the same session); a bridge bot's [ask] parks it as before (#280).
180
253
  if (tasks && !this.markOpts(msg).lastLine) {
181
- const taskId = headline?.id ?? /任务\s*#(\d+)\b/.exec(mark.body)?.[1];
182
- const inRoom = tasks.q.allBusy.all('assigned', 'accepted', 'asked', 'rework')
183
- .filter(t => t.room_id === room.id);
184
- const marked = taskId ? tasks.task(Number(taskId)) : undefined;
185
- const selected = marked?.room_id === room.id ? marked : inRoom[0];
186
- if (selected?.room_id === room.id) tasks.pauseTask(selected.id);
254
+ const busy = tasks.q.allBusy.all('assigned', 'accepted', 'asked', 'rework');
255
+ const named = headline?.task;
256
+ const items = [...(named ? [named] : []), ...busy]
257
+ .map(t => ({ id: t.id, here: t.room_id === room.id, bound: t.updated_at }));
258
+ const selected = exitTaskPick(named?.id ?? 0, items);
259
+ if (selected) tasks.pauseTask(selected);
187
260
  }
188
261
  }
189
262
  }
@@ -206,16 +279,24 @@ export class TeamExit {
206
279
  */
207
280
  forward(room, msg, body, ask, headline) {
208
281
  let text = `📬 来自 ${room.name} · ${msg.name}${ask ? ` · ${ask.id}` : ' · 阶段结果'}\n${body}`;
282
+ let detail = '';
209
283
  if (headline) {
210
- // The headline as written, named by its task; the details follow (the DSH lead reads them here).
284
+ // The three parts for the user, named by its task; DSH has no threads, so the details are a
285
+ // second post marked as such (Go: postWithDetail).
211
286
  const name = headline.task?.title ?? '';
212
- const rest = String(body).split('\n').slice(1).join('\n').trim();
213
- text = `📬 #${headline.id}「${name}」${ask ? `要你定:${headline.line}(直接回复这条就能答复,编号 ${ask.id})` : `:${headline.line}`}`
214
- + (rest ? `\n\n来自 ${room.name} · ${msg.name} 的细节:\n${rest}` : '');
287
+ detail = exitSplit(body).detail;
288
+ text = `📬「${name}」${ask ? `要你定\n${headline.line}\n(直接回复这条就能答复,编号 ${ask.id})` : `\n${headline.line}`}`;
289
+ if (detail) detail = `📬 细节「${name}」(来自 ${room.name} · ${msg.name},想看再展开,不用回)\n${detail}`;
215
290
  }
216
- return this.hub.post(room.parent_id, {
291
+ const { message } = this.hub.post(room.parent_id, {
217
292
  authorKind: 'system', author: 'system', name: 'system', clientKey: `team-exit:${msg.id}`, text,
218
- }).message;
293
+ });
294
+ if (detail) {
295
+ this.hub.post(room.parent_id, {
296
+ authorKind: 'system', author: 'system', name: 'system', clientKey: `team-exit-detail:${msg.id}`, text: detail,
297
+ });
298
+ }
299
+ return message;
219
300
  }
220
301
 
221
302
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bothouse-dsh",
3
- "version": "0.1.41",
3
+ "version": "0.1.42",
4
4
  "description": "BotHouse channels for DeepSeek Harness: Discord-style rooms where DSH models and local CLIs (Claude Code, Codex, Gemini) are members",
5
5
  "type": "module",
6
6
  "main": "./collab/plugin.mjs",