bothouse-dsh 0.1.47 → 0.1.49

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/collab/tasks.mjs CHANGED
@@ -12,6 +12,7 @@ import { mentions, nameCalled, split, withoutLeadingMentions } from './mentions.
12
12
  import { NOTICE_PREFIXES } from './cloudweb.mjs';
13
13
  import { exitMark } from './team-exit.mjs';
14
14
  import { checkDispatch, HandoffRulesError } from './handoff-rules.mjs';
15
+ import { execFile } from 'node:child_process';
15
16
 
16
17
  /** Status codes and their labels (the eight names are a #139 B setting, not user-specified). */
17
18
  export const STATUS = {
@@ -58,6 +59,34 @@ const LIVE = `status IN (${PLACEHOLDERS}) AND paused_at IS NULL`;
58
59
  // from when several members edit the same files. The checks and their messages are in the shared
59
60
  // handoff rules (handoff-rules.json, also read by ws-handoff); a dispatch runs the ones listed under
60
61
  // its `dispatch` key. The rules file not being readable refuses the dispatch (HTTP 500), never skips it.
62
+ // Review handover (user 11-05「派活的角色忙超过 10 分钟,就转给空着的队友去验收。小活交回时,只要检查都过了
63
+ // 就自动收口,大活才需要人看」): a workspace task handed back waits CHECK_AFTER_S for its dispatcher's
64
+ // verdict, then goes to a free teammate other than its author (reviewOf). A small task closes itself:
65
+ // every GitHub PR its report links is merged with no failing check, or its handoff scope says 改:无.
66
+ // Not user-specified (橘 11-05, listed in the PR): the words below that keep a task for a person, and
67
+ // one extra wake of an idle dispatcher before handing over.
68
+ const PR_URL = /https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/pull\/\d+/g;
69
+ const NEEDS_PERSON = /发版|发布|release|线上|生产|部署|deploy|花钱|付费|付款|下单|充值|用户看|用户确认|用户拍板/i;
70
+ const CHANGE_LINE = /^[\s>*-]*改\s*[::]\s*(.*)$/gm;
71
+ const ASKS_USER = /要你做的\s*[::]\s*(.*)/;
72
+ const NOTHING = /^(没有|无)\s*[。.]?\s*$/;
73
+ const GREEN = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED']);
74
+
75
+ /** gh's view of one PR: { merged, green } (no checks is not green, same as board.py #1003); rejects when gh is missing or fails. */
76
+ export function ghPrStatus(url) {
77
+ return new Promise((resolve, reject) => {
78
+ execFile('gh', ['pr', 'view', url, '--json', 'state,statusCheckRollup'], { timeout: 30_000 }, (error, stdout) => {
79
+ if (error) return reject(error);
80
+ try {
81
+ const pr = JSON.parse(stdout);
82
+ const checks = Array.isArray(pr.statusCheckRollup) ? pr.statusCheckRollup : [];
83
+ const green = checks.length > 0 && checks.every(c => GREEN.has(String(c.conclusion ?? c.state ?? '').toUpperCase()));
84
+ resolve({ merged: pr.state === 'MERGED', green });
85
+ } catch (e) { reject(e); }
86
+ });
87
+ });
88
+ }
89
+
61
90
  const MISSING_REWORK_NOTE = '返工要写原因:note 填为什么打回,示例:note: "没先读现有实现"';
62
91
 
63
92
  /** Runs a handoff check; a missing or broken rules file becomes a CollabError instead of letting it through. */
@@ -90,8 +119,9 @@ export class Tasks {
90
119
  * start: false defers the timer and pending deliveries until start() (the plugin
91
120
  * starts after DSH recovery, so a reminder job is never mistaken for a leftover).
92
121
  */
93
- constructor(hub, { now = () => Date.now() / 1000, setTimer = setTimeout, clearTimer = clearTimeout, start = true, maxRenewals = MAX_RENEWALS } = {}) {
122
+ constructor(hub, { now = () => Date.now() / 1000, setTimer = setTimeout, clearTimer = clearTimeout, start = true, maxRenewals = MAX_RENEWALS, prStatus = ghPrStatus } = {}) {
94
123
  this.hub = hub;
124
+ this.prStatus = prStatus;
95
125
  this.maxRenewals = maxRenewals;
96
126
  this.store = hub.store;
97
127
  this.db = hub.store.db;
@@ -123,6 +153,10 @@ export class Tasks {
123
153
  renewals = CASE WHEN paused_at IS NOT NULL THEN 0 ELSE renewals END WHERE id = ? AND status = ? RETURNING *`),
124
154
  handBack: this.db.prepare(`UPDATE tasks SET status = 'returned', paused_at = NULL, next_check_at = NULL, remind_msg_id = NULL, result_msg_id = ?, returned_at = ?, updated_at = ?
125
155
  WHERE id = ? AND status = ? RETURNING *`),
156
+ // A returned workspace task waiting for its verdict: next_check_at is its review watch (reviewWatch).
157
+ reviewWait: this.db.prepare(`SELECT * FROM tasks WHERE status = 'returned' AND next_check_at IS NOT NULL ORDER BY next_check_at, id`),
158
+ setReviewCheck: this.db.prepare("UPDATE tasks SET next_check_at = ?, remind_msg_id = COALESCE(?, remind_msg_id) WHERE id = ? AND status = 'returned' RETURNING *"),
159
+ openReviews: this.db.prepare(`SELECT * FROM tasks WHERE parent_task = ? AND source = 'review' AND status IN (${PLACEHOLDERS})`),
126
160
  bindAssign: this.db.prepare('UPDATE tasks SET assign_msg_id = ? WHERE id = ? AND assign_msg_id IS NULL'),
127
161
  due: this.db.prepare(`SELECT * FROM tasks WHERE next_check_at IS NOT NULL AND next_check_at <= ?
128
162
  AND ${LIVE} ORDER BY next_check_at, id`),
@@ -365,7 +399,13 @@ export class Tasks {
365
399
  const row = this.q.handBack.get(messageId, now, now, id, task.status);
366
400
  if (row === undefined) throw new CollabError('conflict', `task ${id} changed meanwhile`);
367
401
  this.record(row, 'return', `#${id}「${row.title}」${this.nameOf(row.owner)} 交回,用时 ${duration(row.returned_at - row.created_at)}`);
368
- this.returnToChat(row, messageId);
402
+ const watched = this.reviewWatched(row);
403
+ const small = watched ? this.smallTask(row, messageId) : undefined;
404
+ // A small task's dispatcher is only woken if it does not close itself (checkPrs).
405
+ const copy = this.returnToChat(row, messageId, { wake: small === undefined });
406
+ if (watched) this.q.setReviewCheck.get(now + CHECK_AFTER_S, null, id);
407
+ if (small?.readOnly) this.autoAccept(id, small.readOnly);
408
+ else if (small?.prs) this.checkPrs(id, small.prs, copy);
369
409
  this.releaseIfDone(row.room_id);
370
410
  return this.changed(id);
371
411
  }
@@ -376,23 +416,162 @@ export class Tasks {
376
416
  * as a chat-channel hand-back @-s the lead (lead_return.go). A bridge-claimed workspace is the
377
417
  * bridge's (its lead session reads the workspace itself).
378
418
  */
379
- returnToChat(task, messageId) {
380
- if (!this.tree || this.store.room(task.room_id)?.kind !== 'workspace' || this.store.bridgeClaimed(task.room_id)) return;
419
+ returnToChat(task, messageId, { wake = true } = {}) {
420
+ if (!this.tree || this.store.room(task.room_id)?.kind !== 'workspace' || this.store.bridgeClaimed(task.room_id)) return undefined;
381
421
  const msg = this.store.message(messageId);
382
422
  let copy = msg === undefined ? undefined : this.hub.teamExit?.forwardResult(msg);
383
- if (copy === undefined || task.created_by === HUMAN) return;
423
+ if (copy === undefined || task.created_by === HUMAN) return copy;
384
424
  // The dispatcher is woken on this copy alone (a turn reads no other system row), so it says
385
425
  // how to review, as the pause record says how to answer (watch).
386
426
  const how = `\n\n验收 #${task.id}:回复里单独一行写【验收】#${task.id};要返工就单独一行写【返工】#${task.id} 原因。`;
387
427
  if (!copy.text.includes(how)) copy = this.hub.edit(copy.id, 'system', 'system', copy.text + how, copy.card) ?? copy;
388
- this.wake(task.created_by, copy);
428
+ if (wake) this.wake(task.created_by, copy);
429
+ return copy;
430
+ }
431
+
432
+ /** A workspace task (not a bridge claim's, not a review) whose verdict this gateway waits for. */
433
+ reviewWatched(task) {
434
+ return this.tree && task.created_by !== HUMAN && task.source !== 'review'
435
+ && this.store.room(task.room_id)?.kind === 'workspace' && !this.store.bridgeClaimed(task.room_id);
436
+ }
437
+
438
+ /**
439
+ * A small task, closed without a person: { prs } (GitHub PR links in its report, checked by checkPrs)
440
+ * or { readOnly: why } (every 改 line of its handoff says 无). undefined when it needs a person: its
441
+ * report asks the user for something, or its handoff touches release, production, deploy or money.
442
+ */
443
+ smallTask(task, messageId) {
444
+ const report = String(this.store.message(messageId)?.text ?? '');
445
+ const asks = ASKS_USER.exec(report)?.[1]?.trim();
446
+ if (asks !== undefined && !NOTHING.test(asks)) return undefined;
447
+ const handoff = `${task.title}\n${task.reason ?? ''}\n${task.assign_msg_id == null ? '' : this.store.message(task.assign_msg_id)?.text ?? ''}`;
448
+ const changes = [...handoff.matchAll(CHANGE_LINE)].map(m => m[1].trim());
449
+ if (NEEDS_PERSON.test(changes.length ? changes.join('\n') : handoff)) return undefined;
450
+ const prs = [...new Set(report.match(PR_URL) ?? [])];
451
+ if (prs.length) return { prs };
452
+ if (changes.length && changes.every(c => /^无/.test(c))) return { readOnly: '只查不改(交接范围「改:无」)' };
453
+ return undefined;
454
+ }
455
+
456
+ /** Every PR merged with no failing check closes the task; anything else (or gh failing) wakes its dispatcher on copy. */
457
+ checkPrs(id, prs, copy) {
458
+ return Promise.all(prs.map(url => this.prStatus(url))).then(states => {
459
+ if (states.every(s => s?.merged && s?.green)) {
460
+ const nums = prs.map(u => `#${u.split('/').pop()}`).join('、');
461
+ if (this.autoAccept(id, `PR ${nums} 已合、检查全绿`)) return;
462
+ }
463
+ this.wakeReviewer(id, copy);
464
+ }, error => {
465
+ this.hub.logger?.warn?.('collab: PR check for task #%s failed: %s', id, error?.message ?? error);
466
+ this.wakeReviewer(id, copy);
467
+ });
468
+ }
469
+
470
+ wakeReviewer(id, copy) {
471
+ const task = this.task(id);
472
+ if (!this.closed && copy !== undefined && task?.status === 'returned') this.wake(task.created_by, copy);
473
+ }
474
+
475
+ /** Close a returned task with no reviewer: one record says why. True when it closed it now. */
476
+ autoAccept(id, why) {
477
+ const task = this.task(id);
478
+ if (this.closed || task?.status !== 'returned') return false;
479
+ const row = this.q.set.get('done', null, clip(why), this.now(), 0, id, 'returned');
480
+ if (row === undefined) return false;
481
+ this.closeReviews(id, 'done');
482
+ this.record(row, 'done', `#${id}「${row.title}」已自动收口:${why}`);
483
+ this.releaseIfDone(row.room_id);
484
+ this.changed(id);
485
+ return true;
486
+ }
487
+
488
+ /** Delegated reviews of task id end with its verdict. */
489
+ closeReviews(id, verdict, messageId) {
490
+ for (const review of this.q.openReviews.all(id, ...WORKING)) {
491
+ this.db.prepare(`UPDATE tasks SET status = 'done', next_check_at = NULL, remind_msg_id = NULL,
492
+ paused_at = NULL, result_msg_id = ?, returned_at = ?, updated_at = ? WHERE id = ?`)
493
+ .run(messageId ?? null, this.now(), this.now(), review.id);
494
+ this.record(this.task(review.id), 'done', `#${review.id} 验收 #${id} 已结束:${verdict === 'done' ? '通过' : '返工'}`);
495
+ this.changed(review.id);
496
+ this.releaseIfDone(review.room_id);
497
+ }
498
+ }
499
+
500
+ /** Busy or unreachable now: offline, cooling, in a turn, busy on the team status, or owning work. */
501
+ memberBusy(id) {
502
+ const m = this.hub.members.get(id);
503
+ if (m === undefined || m.online === false || m.error) return true;
504
+ if (this.hub.quota?.cooling(id)) return true;
505
+ if ([...(this.hub.thinking?.values() ?? [])].some(s => s.has(id))) return true;
506
+ if (this.hub.presence?.get(id)?.busy) return true;
507
+ return this.busyInfo(id).busy;
508
+ }
509
+
510
+ /**
511
+ * A returned workspace task at its review check (CHECK_AFTER_S after hand-back, then every
512
+ * CHECK_AFTER_S): a delegated review in work → wait on it; an idle dispatcher → woken once more;
513
+ * a busy one (or one woken already) → a free teammate other than the author and the dispatcher
514
+ * reviews it (assign reviewOf), woken on a chat-channel note carrying the report; none free → wait.
515
+ */
516
+ reviewWatch(task, now) {
517
+ if (!this.reviewWatched(task)) {
518
+ this.db.prepare('UPDATE tasks SET next_check_at = NULL WHERE id = ?').run(task.id);
519
+ return;
520
+ }
521
+ const next = now + CHECK_AFTER_S;
522
+ const chat = this.chatOf(task.room_id);
523
+ const lead = task.created_by;
524
+ const how = `验收 #${task.id}:回复里单独一行写【验收】#${task.id};要返工就单独一行写【返工】#${task.id} 原因。`;
525
+ // A delegated review waits on its reviewer, unless that reviewer went offline or failed (a turn
526
+ // error, e.g. no model key): then the review moves on to another free teammate.
527
+ const reviews = this.q.openReviews.all(task.id, ...WORKING);
528
+ const unreachable = id => { const m = this.hub.members.get(id); return m === undefined || m.online === false || Boolean(m.error); };
529
+ if (reviews.some(r => !unreachable(r.owner))) {
530
+ this.q.setReviewCheck.get(next, null, task.id);
531
+ return;
532
+ }
533
+ const tried = new Set(reviews.map(r => r.owner));
534
+ if (!reviews.length && !this.memberBusy(lead) && task.remind_msg_id == null) {
535
+ const { message } = this.hub.post(chat, { authorKind: 'system', author: 'system', name: '任务',
536
+ card: JSON.stringify({ kind: 'task', task_id: task.id, event: 'remind' }),
537
+ text: `@${this.nameOf(lead)} #${task.id}「${task.title}」交回 ${CHECK_AFTER_S / 60} 分钟还没验收。${how}` });
538
+ this.q.setReviewCheck.get(next, message.id, task.id);
539
+ this.wake(lead, message);
540
+ this.changed(task.id);
541
+ return;
542
+ }
543
+ const reviewer = this.hub.routableIn(chat).find(m => m.id !== task.owner && m.id !== lead && !tried.has(m.id) && m.kind !== 'human' && !this.memberBusy(m.id));
544
+ if (reviewer === undefined) {
545
+ this.q.setReviewCheck.get(next, null, task.id);
546
+ return;
547
+ }
548
+ const result = task.result_msg_id == null ? undefined : this.store.message(task.result_msg_id);
549
+ const body = result === undefined ? '' : (exitMark(result.text, { lastLine: true, team: this.hub.routable() })?.body ?? String(result.text));
550
+ const cut = String(body).trim().length > BRIEF_HANDOFF ? `${String(body).trim().slice(0, BRIEF_HANDOFF)}…` : String(body).trim();
551
+ const why = reviews.length ? `${reviews.map(r => this.nameOf(r.owner)).join('、')} 掉线或出错`
552
+ : this.memberBusy(lead) ? `${this.nameOf(lead)} 在忙` : `${this.nameOf(lead)} 提醒后仍没验收`;
553
+ const { message } = this.hub.post(chat, { authorKind: 'system', author: 'system', name: '任务',
554
+ text: `@${reviewer.name} #${task.id}「${task.title}」交回超过 ${CHECK_AFTER_S / 60} 分钟,${why},转给你验收(写的人 ${this.nameOf(task.owner)} 不验收自己的活)。`
555
+ + `${cut ? `\n\n交回内容:\n${cut}` : ''}\n\n${how}` });
556
+ let review;
557
+ try {
558
+ review = this.assign({ roomId: chat, title: `验收 #${task.id}「${task.title}」`, owner: reviewer.id, actor: lead, reviewOf: task.id,
559
+ messageId: message.id, reason: `派活人忙超 ${CHECK_AFTER_S / 60} 分钟,自动转验收` });
560
+ } catch (error) {
561
+ this.hub.logger?.warn?.('collab: review handover for #%s failed: %s', task.id, error?.message ?? error);
562
+ this.q.setReviewCheck.get(next, null, task.id);
563
+ return;
564
+ }
565
+ this.record(task, 'handover', `#${task.id}「${task.title}」已转给 ${reviewer.name} 验收(#${review.id}):${why}`, chat);
566
+ this.q.setReviewCheck.get(next, null, task.id);
567
+ this.wake(reviewer.id, message);
568
+ this.changed(task.id);
389
569
  }
390
570
 
391
571
  /** The lead (or the human owner) closes the task or sends it back for rework. */
392
572
  review(id, { actor, verdict, note, messageId }) {
393
573
  const task = this.mustTask(id);
394
- const reviews = this.db.prepare(`SELECT * FROM tasks WHERE parent_task = ? AND source = 'review'
395
- AND status IN (${PLACEHOLDERS})`).all(id, ...WORKING);
574
+ const reviews = this.q.openReviews.all(id, ...WORKING);
396
575
  const delegated = actor !== task.owner && reviews.some(r => r.owner === actor && r.paused_at == null);
397
576
  if (actor !== task.created_by && actor !== HUMAN && !delegated) throw new CollabError('forbidden', `only ${this.nameOf(task.created_by)} or their delegated reviewer can review task ${id}`);
398
577
  if (!REVIEWABLE.includes(task.status)) throw new CollabError('conflict', `task ${id} is ${STATUS[task.status]}, nothing to review`);
@@ -404,14 +583,7 @@ export class Tasks {
404
583
  const reclaim = verdict === 'rework' && this.reworkClaim(task);
405
584
  const row = this.q.set.get(verdict, check, clip(note) ?? task.note, this.now(), 0, id, task.status);
406
585
  if (row === undefined) throw new CollabError('conflict', `task ${id} changed meanwhile`);
407
- for (const review of reviews) {
408
- this.db.prepare(`UPDATE tasks SET status = 'done', next_check_at = NULL, remind_msg_id = NULL,
409
- paused_at = NULL, result_msg_id = ?, returned_at = ?, updated_at = ? WHERE id = ?`)
410
- .run(messageId ?? null, this.now(), this.now(), review.id);
411
- this.record(this.task(review.id), 'done', `#${review.id} 验收 #${id} 已结束:${verdict === 'done' ? '通过' : '返工'}`);
412
- this.changed(review.id);
413
- this.releaseIfDone(review.room_id);
414
- }
586
+ this.closeReviews(id, verdict, messageId);
415
587
  if (verdict === 'rework') this.q.resetRenewals.run(id);
416
588
  const tail = clip(note) ? `:${clip(note)}` : '';
417
589
  // A workspace task sent back: its owner is woken there on the record (nobody routes it there
@@ -751,6 +923,9 @@ export class Tasks {
751
923
  for (const task of this.q.allBusy.all(...WORKING)) {
752
924
  if (this.watched(task) && watchDue(task) <= now) this.watch(task, now);
753
925
  }
926
+ for (const task of this.q.reviewWait.all()) {
927
+ if (task.next_check_at <= now) this.reviewWatch(task, now);
928
+ }
754
929
  for (const task of this.q.due.all(now, ...WORKING)) {
755
930
  if (this.store.room(task.room_id)?.kind === 'workspace') continue;
756
931
  if (this.hub.quota?.cooling(task.owner)) {
@@ -892,6 +1067,7 @@ export class Tasks {
892
1067
  if (t.paused_at != null) return null;
893
1068
  return this.store.room(t.room_id)?.kind !== 'workspace' ? t.next_check_at : this.watched(t) ? watchDue(t) : null;
894
1069
  })
1070
+ .concat(this.q.reviewWait.all().map(t => t.next_check_at))
895
1071
  .filter(t => t != null).sort((a, b) => a - b)[0];
896
1072
  if (at == null) return;
897
1073
  const ms = Math.min(MAX_TIMER_MS, Math.max(0, (at - this.now()) * 1000));
@@ -47,7 +47,8 @@ export function scanWiki(root) {
47
47
  const bodies = [];
48
48
  for (const path of files) {
49
49
  let text;
50
- try { text = readFileSync(join(root, path), 'utf8'); } catch { continue; }
50
+ // Pages saved on Windows are CRLF: without this the frontmatter (`---\n`) is never seen.
51
+ try { text = readFileSync(join(root, path), 'utf8').replace(/\r\n?/g, '\n'); } catch { continue; }
51
52
  const { meta, body } = splitFrontmatter(text);
52
53
  const heading = /^#\s+(.+)$/m.exec(body)?.[1]?.trim();
53
54
  const parts = path.split('/');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bothouse-dsh",
3
- "version": "0.1.47",
3
+ "version": "0.1.49",
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",