@joekytc/dsh-swarm 0.3.1 → 0.3.6

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 (53) hide show
  1. package/README.md +14 -7
  2. package/README.zh-CN.md +10 -5
  3. package/client/KanbanBoard.tsx +3 -1
  4. package/client/TaskDrawer.tsx +74 -2
  5. package/client/WorkflowRail.tsx +84 -1
  6. package/client/config-store.ts +3 -1
  7. package/client/kanban.css +28 -0
  8. package/client/timeline-model.ts +4 -0
  9. package/client/workflow-model.ts +20 -11
  10. package/lib/client.js +1100 -22
  11. package/lib/config.d.ts +5 -1
  12. package/lib/config.js +2 -1
  13. package/lib/dispatcher/agent-runner.js +25 -9
  14. package/lib/dispatcher/event-waker.js +4 -1
  15. package/lib/dispatcher/v-orchestrator.d.ts +2 -0
  16. package/lib/dispatcher/v-orchestrator.js +58 -9
  17. package/lib/domain/config-override.d.ts +6 -0
  18. package/lib/domain/config-override.js +16 -1
  19. package/lib/domain/im-message.d.ts +21 -2
  20. package/lib/domain/im-message.js +84 -4
  21. package/lib/domain/kanban-service.d.ts +8 -1
  22. package/lib/domain/kanban-service.js +67 -1
  23. package/lib/domain/ocr-review.d.ts +6 -2
  24. package/lib/domain/ocr-review.js +20 -11
  25. package/lib/domain/permissions.d.ts +1 -1
  26. package/lib/domain/permissions.js +4 -0
  27. package/lib/domain/projection.js +8 -3
  28. package/lib/domain/review-evidence.js +9 -0
  29. package/lib/domain/review-target.d.ts +10 -0
  30. package/lib/domain/review-target.js +24 -0
  31. package/lib/domain/state-machine.js +1 -1
  32. package/lib/domain/types.d.ts +16 -3
  33. package/lib/routes/kanban-http.js +66 -6
  34. package/lib/services/config-provider.js +2 -0
  35. package/lib/services/im-bot-probe.d.ts +36 -0
  36. package/lib/services/im-bot-probe.js +128 -0
  37. package/lib/services/im-delivery.d.ts +69 -7
  38. package/lib/services/im-delivery.js +220 -34
  39. package/lib/tools/kanban-tools.js +38 -1
  40. package/lib/tools/main-session-tools.js +34 -9
  41. package/lib/tools/ocr-review-tools.js +13 -6
  42. package/package.json +6 -1
  43. package/personas/kanban-d/agent.cordis.yml +0 -1
  44. package/personas/kanban-dt/agent.cordis.yml +4 -3
  45. package/personas/kanban-p/agent.cordis.yml +16 -0
  46. package/personas/kanban-pt/agent.cordis.yml +19 -1
  47. package/personas/kanban-w/agent.cordis.yml +15 -0
  48. package/personas/persona-d.md +0 -1
  49. package/personas/persona-dt.md +2 -2
  50. package/personas/persona-p.md +4 -0
  51. package/personas/persona-pt.md +4 -1
  52. package/personas/persona-w.md +1 -0
  53. package/personas/swarm/agent.cordis.yml +11 -0
@@ -2,6 +2,7 @@ import type { Context } from '@deepseek-ai/cordis';
2
2
  import type { KanbanService } from '../domain/kanban-service.js';
3
3
  import type { BoardState } from '../domain/types.js';
4
4
  import type { ConfigProvider } from './config-provider.js';
5
+ import { type ProbeResult } from './im-bot-probe.js';
5
6
  /** dsh-im 宿主服务结构接口(形状以 @xmanrui/dsh-im PROACTIVE_DELIVERY.md 为准,运行时守卫校验)。
6
7
  * listTargets 证据(2026-09-07):PROACTIVE_DELIVERY.md:176 `const targets = await ctx.dshIm.listTargets(botId);
7
8
  * // [{ targetId, name?, kind, route }, ...]`;宿主 lib/index.js 实现为
@@ -33,17 +34,73 @@ export interface ImDeliveryOptions {
33
34
  manual?: boolean;
34
35
  /** 投递目标类型(0.3.x 自由投递):group=群聊;user=私聊(仅且只有一个已保存目标)。缺省 group。 */
35
36
  targetKind?: 'group' | 'user';
37
+ /** 手动投递会话的 preset(工具从 session header 注入):多机器人时用于匹配机器人默认模式。
38
+ * 缺省=无 preset(auto 路径无会话上下文),跳过模式匹配直接走默认/交互。 */
39
+ sessionPreset?: string;
40
+ /** 手动投递会话的 live agent(工具从 exec.agent 注入):userQuestions 的 GUI answerer 挂在
41
+ * Agent-scoped waterfall 上,不传 agent 的询问落在根 ctx 无人应答(NO_PROVIDER)。 */
42
+ agent?: unknown;
43
+ /** 测试注入:覆盖机器人决策链的探针/交互/写默认实现(生产由 ctx 与 configProvider 装配)。 */
44
+ botChoice?: Partial<BotChoiceDeps>;
36
45
  }
37
46
  /** dsh-im 未安装的可识别错误前缀(0.3.1:缺插件属环境问题不重试,直接友好提醒安装)。 */
38
47
  export declare const DSH_IM_MISSING_PREFIX = "dsh-im-not-installed";
39
48
  export declare const DSH_IM_MISSING_GUIDANCE = "\u672A\u68C0\u6D4B\u5230 dsh-im \u63D2\u4EF6\uFF0C\u65E0\u6CD5\u6295\u9012\u4F01\u5FAE\u6D88\u606F\u3002\u8BF7\u5148\u5B89\u88C5\u5E76\u542F\u7528 @xmanrui/dsh-im \u63D2\u4EF6\uFF08\u5B89\u88C5\u540E\u91CD\u542F dsh \u751F\u6548\uFF09\uFF0C\u518D\u91CD\u8BD5\u6295\u9012\u3002";
40
49
  export declare function isDshImLike(svc: unknown): svc is DshImLike;
41
- /** botId/targetId 解析(评审决议):配置显式指定优先;留空自动发现唯一 wecom bot + 唯一已保存目标(按 kind)。
42
- * 群/私聊各自仅且只有一个——发现异常返回 error(调用方留痕不投,fail-closed——投错对象比不投更糟)。 */
50
+ /** 机器人来源(供调用方在 guidance 里如实转述,勿让模型猜)。 */
51
+ export type BotVia = 'explicit' | 'single' | 'preset' | 'fallback' | 'interactive';
52
+ export interface BotAskOption {
53
+ botId: string;
54
+ label: string;
55
+ }
56
+ export interface BotChoiceDeps {
57
+ /** 当前会话 preset(手动投递由工具从 session header 注入);null/空 = 无法模式匹配(auto 路径)。 */
58
+ sessionPreset: string | null;
59
+ /** 多机器人时读 bot↔preset/连接态;失败=降级(不阻断投递,退化到默认/交互)。 */
60
+ probe: () => Promise<ProbeResult>;
61
+ /** 交互选择;无询问通道返回 null,用户取消/超时也返回 null(fail-closed 不投)。 */
62
+ ask: ((options: BotAskOption[]) => Promise<{
63
+ botId: string;
64
+ setDefault: boolean;
65
+ } | null>) | null;
66
+ /** 写默认机器人;写失败返回 false(本次投递照常,仅默认未落库)。 */
67
+ setFallback: ((botId: string) => Promise<boolean>) | null;
68
+ log: (m: string) => void;
69
+ }
70
+ export interface BotChoice {
71
+ botId: string;
72
+ via: BotVia;
73
+ fallbackSaved?: boolean;
74
+ }
75
+ /** 机器人决策链:显式配置 → 唯一机器人 → 会话 preset 命中 → 默认机器人 → 交互选择。
76
+ * 每环都 fail-closed(宁可不投也不投错对象);探针不可用只失去自动匹配能力,不阻断投递。 */
77
+ export declare function chooseBot(im: DshImLike, cfg: {
78
+ botId: string;
79
+ fallbackBotId?: string;
80
+ }, deps: BotChoiceDeps): Promise<({
81
+ ok: true;
82
+ } & BotChoice) | {
83
+ ok: false;
84
+ error: string;
85
+ }>;
86
+ /** targetId 解析:配置显式指定优先;留空自动发现该机器人下唯一已保存目标(按 kind)。
87
+ * 群/私聊各自仅且只有一个——发现异常返回 error(调用方留痕不投,fail-closed)。
88
+ * 机器人非显式指定(preset/默认/交互选出)而配置又填死了 targetId 时先校验归属:
89
+ * targetId 是 per-bot 作用域,填错机器人只会落成 dsh-im 的 unknown-target,这里提前给可行动报错。 */
90
+ export declare function resolveTargetId(im: DshImLike, botId: string, botIdExplicit: boolean, cfg: {
91
+ targetId: string;
92
+ dmTargetId?: string;
93
+ }, kind: 'group' | 'user'): Promise<{
94
+ targetId: string;
95
+ } | {
96
+ error: string;
97
+ }>;
98
+ /** botId+targetId 一步解析(无交互/无探针的简化入口,保留给单机器人场景与单测)。 */
43
99
  export declare function resolveTarget(im: DshImLike, cfg: {
44
100
  botId: string;
45
101
  targetId: string;
46
102
  dmTargetId?: string;
103
+ fallbackBotId?: string;
47
104
  }, kind: 'group' | 'user'): Promise<{
48
105
  botId: string;
49
106
  targetId: string;
@@ -57,17 +114,20 @@ export declare function sendWithRetry(im: DshImLike, botId: string, targetId: st
57
114
  ok: false;
58
115
  error: string;
59
116
  }>;
60
- /** 发送器工厂:resolveDshIm → resolveTarget(fail-closed)→ sendWithRetry → 留痕。
61
- * auto 路径(wireImDelivery)失败额外写 chain/im-delivery-failed 链事件;
62
- * manual 路径(/sms,opts.manual)仅 dispatcher.log 留痕,错误同步返回给调用方。 */
63
- export declare function createSender(ctx: Context, kanban: KanbanService, configProvider: ConfigProvider, opts?: ImDeliveryOptions): (chainId: string, text: string) => Promise<{
117
+ export type SendResult = {
64
118
  ok: true;
65
119
  botId: string;
66
120
  targetId: string;
121
+ via: BotVia;
122
+ fallbackSaved?: boolean;
67
123
  } | {
68
124
  ok: false;
69
125
  error: string;
70
- }>;
126
+ };
127
+ /** 发送器工厂:resolveDshIm → chooseBot(fail-closed)→ resolveTargetId → sendWithRetry → 留痕。
128
+ * auto 路径(wireImDelivery)失败额外写 chain/im-delivery-failed 链事件;
129
+ * manual 路径(/sms,opts.manual)仅 dispatcher.log 留痕,错误同步返回给调用方。 */
130
+ export declare function createSender(ctx: Context, kanban: KanbanService, configProvider: ConfigProvider, opts?: ImDeliveryOptions): (chainId: string, text: string) => Promise<SendResult>;
71
131
  export type ReportVariant = 'completion' | 'blocked';
72
132
  /** /sms 链解析(纯函数)。空 query:completion=最近满足 W3 完成判据的链(最后完成事件 at 最大);
73
133
  * blocked=最近阻塞的链(chain/blocked 事件 at 最大)。显式 query:全量链精确/后缀解析后再验判据。 */
@@ -99,6 +159,8 @@ export declare function sendChainReport(ctx: Context, kanban: KanbanService, con
99
159
  chainId: string;
100
160
  botId: string;
101
161
  targetId: string;
162
+ via: BotVia;
163
+ fallbackSaved?: boolean;
102
164
  } | {
103
165
  ok: false;
104
166
  error: string;
@@ -2,8 +2,13 @@ import { homedir } from 'node:os';
2
2
  import { writeFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { buildCompletionMessage, buildBlockMessage } from '../domain/im-message.js';
5
+ import { createBotProbe, probeDepsFromCtx } from './im-bot-probe.js';
5
6
  const RETRYABLE_CODES = new Set(['bot-not-connected', 'delivery-failed']);
6
7
  const DEFAULT_RETRY_DELAYS_MS = [5_000, 10_000, 15_000];
8
+ /** 机器人交互选择等待上限(5 分钟):到点 abort 信号 + 竞速超时,按「未选择」fail-closed 不投。 */
9
+ const ASK_TIMEOUT_MS = 300_000;
10
+ /** 「设为默认」选项文案:提问与应答解析共用一处,改文案两处同步(字符串比对,无 id 兜底)。 */
11
+ const ASK_SET_DEFAULT_LABEL = '设为默认';
7
12
  /** dsh-im 未安装的可识别错误前缀(0.3.1:缺插件属环境问题不重试,直接友好提醒安装)。 */
8
13
  export const DSH_IM_MISSING_PREFIX = 'dsh-im-not-installed';
9
14
  const DSH_IM_MISSING_ERROR = `${DSH_IM_MISSING_PREFIX}: 未检测到 dsh-im 插件服务,请先安装并启用 @xmanrui/dsh-im(安装后重启 dsh 生效)`;
@@ -23,35 +28,115 @@ function resolveDshIm(ctx, log) {
23
28
  }
24
29
  return svc;
25
30
  }
26
- /** botId/targetId 解析(评审决议):配置显式指定优先;留空自动发现唯一 wecom bot + 唯一已保存目标(按 kind)。
27
- * 群/私聊各自仅且只有一个——发现异常返回 error(调用方留痕不投,fail-closed——投错对象比不投更糟)。 */
28
- export async function resolveTarget(im, cfg, kind) {
29
- let botId = cfg.botId.trim();
30
- if (!botId) {
31
- const bots = await im.listBots();
32
- const wecom = bots.filter((b) => b.channel === 'wecom');
33
- if (wecom.length !== 1)
34
- return { error: `企微机器人数量=${wecom.length}(期望 1),请在插件配置 imDelivery.botId 显式指定` };
35
- botId = wecom[0].botId;
31
+ /** 交互选项展示文案:脱敏名 + id(id 是唯一可靠标识,展示名可能重名)。 */
32
+ function askLabel(o) {
33
+ return o.label ? `${o.label}(${o.botId})` : o.botId;
34
+ }
35
+ /** 多机器人且无法自动决策时的 fail-closed 报错:列候选(有探针数据则带模式与连接态)。 */
36
+ function botCandidatesError(ids, probed) {
37
+ const byId = new Map(probed.map((b) => [b.botId, b]));
38
+ const list = ids.map((id) => {
39
+ const c = byId.get(id);
40
+ if (!c)
41
+ return id;
42
+ return `${id}(${c.label || '未命名'}·${c.agentPreset || '未设模式'}${c.connected ? '' : '·离线'})`;
43
+ }).join(' / ');
44
+ return `企微机器人数量=${ids.length}(期望 1),无法自动确定投递对象。候选:${list}。请在插件配置 imDelivery.botId 显式指定,或在可交互的手动投递会话重试以选择`;
45
+ }
46
+ /** 机器人决策链:显式配置 → 唯一机器人 → 会话 preset 命中 → 默认机器人 → 交互选择。
47
+ * 每环都 fail-closed(宁可不投也不投错对象);探针不可用只失去自动匹配能力,不阻断投递。 */
48
+ export async function chooseBot(im, cfg, deps) {
49
+ const explicit = cfg.botId.trim();
50
+ if (explicit)
51
+ return { ok: true, botId: explicit, via: 'explicit' };
52
+ const wecom = (await im.listBots()).filter((b) => b.channel === 'wecom');
53
+ if (wecom.length === 0) {
54
+ return { ok: false, error: '未检测到企微机器人接入:请先在 dsh-im 设置→IM机器人 接入机器人后重试' };
36
55
  }
56
+ const ids = wecom.map((b) => b.botId);
57
+ if (ids.length === 1)
58
+ return { ok: true, botId: ids[0], via: 'single' };
59
+ const fallback = (cfg.fallbackBotId ?? '').trim();
60
+ const preset = (deps.sessionPreset ?? '').trim();
61
+ // 只有「能模式匹配」或「能交互」时才值得探针:纯自动路径 + 无默认 → 直接给候选报错,省一次握手。
62
+ const probed = (preset || deps.ask) ? await deps.probe() : { ok: false, error: 'probe-skipped' };
63
+ if (!probed.ok && (preset || deps.ask))
64
+ deps.log(`[im-delivery] bot probe unavailable: ${probed.error}`);
65
+ const candidates = probed.ok ? probed.bots.filter((b) => ids.includes(b.botId)) : [];
66
+ if (preset && candidates.length) {
67
+ const hit = candidates.filter((b) => b.connected && b.agentPreset === preset);
68
+ if (hit.length === 1)
69
+ return { ok: true, botId: hit[0].botId, via: 'preset' };
70
+ deps.log(hit.length > 1
71
+ ? `[im-delivery] preset ${preset} 命中 ${hit.length} 个已连接机器人,不唯一,转默认/交互`
72
+ : `[im-delivery] preset ${preset} 未命中已连接机器人(候选模式:${candidates.map((b) => b.agentPreset || '未设').join(', ') || '无元数据'}),转默认/交互`);
73
+ }
74
+ if (fallback && ids.includes(fallback))
75
+ return { ok: true, botId: fallback, via: 'fallback' };
76
+ if (fallback)
77
+ deps.log(`[im-delivery] 默认机器人 ${fallback} 不在接入列表,转交互`);
78
+ if (!deps.ask)
79
+ return { ok: false, error: botCandidatesError(ids, candidates) };
80
+ const options = ids.map((id) => {
81
+ const c = candidates.find((b) => b.botId === id);
82
+ if (!c)
83
+ return { botId: id, label: '' };
84
+ return { botId: id, label: `${c.label || '未命名'}·${c.agentPreset || '未设模式'}${c.connected ? '' : '·离线'}` };
85
+ });
86
+ const answer = await deps.ask(options);
87
+ if (!answer)
88
+ return { ok: false, error: '未选择投递机器人(交互取消或超时),本次不投递' };
89
+ if (!answer.setDefault || !deps.setFallback)
90
+ return { ok: true, botId: answer.botId, via: 'interactive' };
91
+ const saved = await deps.setFallback(answer.botId);
92
+ if (!saved)
93
+ deps.log('[im-delivery] 默认机器人写入失败(配置未更新),本次仍按所选机器人投递');
94
+ return { ok: true, botId: answer.botId, via: 'interactive', fallbackSaved: saved };
95
+ }
96
+ /** dsh-im 目标列表(宿主同 Host 服务返回裸数组;防御兼容 Connection RPC `target.list` 信封形状)。 */
97
+ async function readTargets(im, botId) {
98
+ const raw = await im.listTargets(botId);
99
+ if (Array.isArray(raw))
100
+ return raw;
101
+ const enveloped = raw?.targets;
102
+ return Array.isArray(enveloped) ? enveloped : [];
103
+ }
104
+ /** targetId 解析:配置显式指定优先;留空自动发现该机器人下唯一已保存目标(按 kind)。
105
+ * 群/私聊各自仅且只有一个——发现异常返回 error(调用方留痕不投,fail-closed)。
106
+ * 机器人非显式指定(preset/默认/交互选出)而配置又填死了 targetId 时先校验归属:
107
+ * targetId 是 per-bot 作用域,填错机器人只会落成 dsh-im 的 unknown-target,这里提前给可行动报错。 */
108
+ export async function resolveTargetId(im, botId, botIdExplicit, cfg, kind) {
37
109
  const explicit = (kind === 'group' ? cfg.targetId : cfg.dmTargetId ?? '').trim();
38
- let targetId = explicit;
39
- if (!targetId) {
40
- const raw = await im.listTargets(botId);
41
- // 宿主同 Host 服务返回裸数组(PROACTIVE_DELIVERY.md:176);防御兼容 Connection RPC `target.list` 信封形状。
42
- const targets = Array.isArray(raw) ? raw : Array.isArray(raw?.targets)
43
- ? raw.targets
44
- : [];
45
- const matched = targets.filter((x) => x.kind === kind);
46
- const label = kind === 'group' ? '群目标' : '私聊目标';
47
- const cfgHint = kind === 'group' ? 'imDelivery.targetId' : 'imDelivery.dmTargetId';
48
- if (matched.length !== 1) {
49
- const candidates = matched.length > 1 ? ',候选:' + matched.map((x) => `${x.targetId}${x.name ? `(${x.name})` : ''}`).join(' / ') : '';
50
- return { error: `已保存${label}数量=${matched.length}(期望 1,仅且只有一个)${candidates}。请到 dsh-im 设置→IM机器人 ${matched.length > 1 ? '清理多余目标' : '新建目标'}或在插件配置 ${cfgHint} 显式指定` };
51
- }
52
- targetId = matched[0].targetId;
110
+ const label = kind === 'group' ? '群目标' : '私聊目标';
111
+ const cfgHint = kind === 'group' ? 'imDelivery.targetId' : 'imDelivery.dmTargetId';
112
+ if (explicit) {
113
+ if (botIdExplicit)
114
+ return { targetId: explicit };
115
+ const targets = await readTargets(im, botId);
116
+ const owned = targets.some((x) => x.targetId === explicit);
117
+ if (owned)
118
+ return { targetId: explicit };
119
+ return { error: `${cfgHint}=${explicit} 不属于本轮选定的机器人 ${botId}(targetId 按机器人隔离)。请清空该字段改由自动发现,或显式指定 imDelivery.botId 固定机器人` };
120
+ }
121
+ const targets = await readTargets(im, botId);
122
+ const matched = targets.filter((x) => x.kind === kind);
123
+ if (matched.length !== 1) {
124
+ const candidates = matched.length > 1 ? ',候选:' + matched.map((x) => `${x.targetId}${x.name ? `(${x.name})` : ''}`).join(' / ') : '';
125
+ return { error: `已保存${label}数量=${matched.length}(期望 1,仅且只有一个)${candidates}。请到 dsh-im 设置→IM机器人 ${matched.length > 1 ? '清理多余目标' : '新建目标'}或在插件配置 ${cfgHint} 显式指定` };
53
126
  }
54
- return { botId, targetId };
127
+ return { targetId: matched[0].targetId };
128
+ }
129
+ /** botId+targetId 一步解析(无交互/无探针的简化入口,保留给单机器人场景与单测)。 */
130
+ export async function resolveTarget(im, cfg, kind) {
131
+ const choice = await chooseBot(im, cfg, {
132
+ sessionPreset: null, probe: async () => ({ ok: false, error: 'probe-skipped' }), ask: null, setFallback: null, log: () => { },
133
+ });
134
+ if (!choice.ok)
135
+ return { error: choice.error };
136
+ const t = await resolveTargetId(im, choice.botId, cfg.botId.trim() !== '', cfg, kind);
137
+ if ('error' in t)
138
+ return t;
139
+ return { botId: choice.botId, targetId: t.targetId };
55
140
  }
56
141
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
57
142
  /** 发送 + 退避重试(仅可重试错误码;{sent:true} 即停——dsh-im 无幂等,成功后绝不重发)。 */
@@ -88,13 +173,90 @@ function makeDefaultLog(storageDir) {
88
173
  catch { /* 忽略写失败 */ }
89
174
  };
90
175
  }
91
- /** 发送器工厂:resolveDshIm → resolveTarget(fail-closed)→ sendWithRetry → 留痕。
176
+ /** 交互选择接线(仅手动投递路径):两问一次弹——选机器人 + 是否设为默认。
177
+ * agent 必传(exec.agent 的 live 实例):GUI 弹窗按 agent 路由;无询问通道返回 null(调用方 fail-closed 列候选),
178
+ * 取消/超时/服务端拒绝同样返回 null(不投),失败原因进日志(只带错误码,不含任何敏感载荷)。 */
179
+ function buildBotAsk(ctx, agent, log) {
180
+ const uq = ctx.get?.('userQuestions');
181
+ if (typeof uq?.ask !== 'function') {
182
+ log('[im-delivery] 机器人交互不可用:无 userQuestions 服务');
183
+ return null;
184
+ }
185
+ return async (options) => {
186
+ const controller = new AbortController();
187
+ const timer = setTimeout(() => { controller.abort(); }, ASK_TIMEOUT_MS);
188
+ try {
189
+ const ans = await Promise.race([
190
+ uq.ask({
191
+ questions: [
192
+ {
193
+ id: 'im-bot',
194
+ header: '投递机器人',
195
+ question: '本轮企微投递用哪个机器人?(当前会话模式未匹配到任何机器人)',
196
+ detail: '展示名·默认模式;离线机器人不保证能发出。选择只作用于本次投递。',
197
+ options: options.map((o) => ({ label: askLabel(o), description: o.botId })),
198
+ },
199
+ {
200
+ id: 'im-bot-default',
201
+ header: '设为默认',
202
+ question: '是否把所选机器人设为默认机器人?(之后模式未命中时直接使用,不再询问)',
203
+ detail: '写入插件配置 imDelivery.fallbackBotId(可在设置面板看到来源标记)。',
204
+ options: [{ label: ASK_SET_DEFAULT_LABEL }, { label: '仅本次' }],
205
+ },
206
+ ],
207
+ ...(agent === undefined ? {} : { agent }),
208
+ signal: controller.signal,
209
+ }),
210
+ new Promise((_, reject) => setTimeout(() => reject(new Error('im-bot-ask-timeout')), ASK_TIMEOUT_MS)),
211
+ ]);
212
+ const pick = ans.answers?.find((a) => a.id === 'im-bot');
213
+ const typed = pick?.custom?.trim() ?? '';
214
+ const selected = pick?.selected?.[0]?.trim() ?? '';
215
+ // 自由文本也接受,但必须是候选之一——防手写错 id 投到未知对象。
216
+ const botId = options.find((o) => askLabel(o) === selected || o.botId === selected || o.botId === typed)?.botId ?? '';
217
+ if (!botId) {
218
+ log('[im-delivery] 机器人交互已应答但未映射到候选(selected 与候选不符)');
219
+ return null;
220
+ }
221
+ const setDefault = (ans.answers?.find((a) => a.id === 'im-bot-default')?.selected ?? []).includes(ASK_SET_DEFAULT_LABEL);
222
+ return { botId, setDefault };
223
+ }
224
+ catch (err) {
225
+ const code = err.code;
226
+ const name = err.name;
227
+ log(`[im-delivery] 机器人交互未完成: ${typeof code === 'string' && code ? code : typeof name === 'string' && name ? name : 'unknown'}`);
228
+ return null;
229
+ }
230
+ finally {
231
+ clearTimeout(timer);
232
+ }
233
+ };
234
+ }
235
+ /** 写默认机器人接线:经 ConfigProvider.applyOverride 落 config-override.json + 审计留痕。 */
236
+ function buildSetFallback(configProvider) {
237
+ return async (botId) => {
238
+ try {
239
+ const snap = configProvider.snapshot().effective;
240
+ return configProvider.applyOverride({ ...snap, imDelivery: { fallbackBotId: botId } }).ok;
241
+ }
242
+ catch {
243
+ return false;
244
+ }
245
+ };
246
+ }
247
+ /** 发送器工厂:resolveDshIm → chooseBot(fail-closed)→ resolveTargetId → sendWithRetry → 留痕。
92
248
  * auto 路径(wireImDelivery)失败额外写 chain/im-delivery-failed 链事件;
93
249
  * manual 路径(/sms,opts.manual)仅 dispatcher.log 留痕,错误同步返回给调用方。 */
94
250
  export function createSender(ctx, kanban, configProvider, opts = {}) {
95
251
  const log = opts.log ?? makeDefaultLog(deriveStorageDir(configProvider));
96
252
  const delays = opts.retryDelaysMs ?? DEFAULT_RETRY_DELAYS_MS;
97
253
  const targetKind = opts.targetKind ?? 'group';
254
+ // 探针单飞缓存挂在发送器上(连发 /sms 不重复握手);交互与写默认只在手动路径接线(后台 auto 路径无人应答)。
255
+ const probe = opts.botChoice?.probe ?? createBotProbe(probeDepsFromCtx(ctx));
256
+ const ask = opts.botChoice?.ask !== undefined ? opts.botChoice.ask
257
+ : (opts.manual ? buildBotAsk(ctx, opts.agent, log) : null);
258
+ const setFallback = opts.botChoice?.setFallback !== undefined ? opts.botChoice.setFallback
259
+ : (opts.manual ? buildSetFallback(configProvider) : null);
98
260
  return async (chainId, text) => {
99
261
  const cfg = configProvider.getEffective().imDelivery;
100
262
  const im = resolveDshIm(ctx, log);
@@ -111,15 +273,22 @@ export function createSender(ctx, kanban, configProvider, opts = {}) {
111
273
  }
112
274
  return { ok: false, error: DSH_IM_MISSING_ERROR };
113
275
  }
114
- const t = await resolveTarget(im, cfg, targetKind);
276
+ const choice = await chooseBot(im, cfg, {
277
+ sessionPreset: opts.sessionPreset ?? null, probe, ask, setFallback, log,
278
+ });
279
+ if (!choice.ok) {
280
+ log(`[im-delivery] bot resolve failed chain=${chainId}: ${choice.error}`);
281
+ return { ok: false, error: choice.error };
282
+ }
283
+ const t = await resolveTargetId(im, choice.botId, cfg.botId.trim() !== '', cfg, targetKind);
115
284
  if ('error' in t) {
116
285
  log(`[im-delivery] target resolve failed chain=${chainId}: ${t.error}`);
117
286
  return { ok: false, error: t.error };
118
287
  }
119
- const r = await sendWithRetry(im, t.botId, t.targetId, text, delays, log);
288
+ const r = await sendWithRetry(im, choice.botId, t.targetId, text, delays, log);
120
289
  if (r.ok) {
121
- log(`[im-delivery] delivered chain=${chainId} bot=${t.botId} target=${t.targetId}`);
122
- return { ok: true, botId: t.botId, targetId: t.targetId };
290
+ log(`[im-delivery] delivered chain=${chainId} bot=${choice.botId} target=${t.targetId} via=${choice.via}`);
291
+ return { ok: true, botId: choice.botId, targetId: t.targetId, via: choice.via, ...(choice.fallbackSaved === undefined ? {} : { fallbackSaved: choice.fallbackSaved }) };
123
292
  }
124
293
  // 超限显形(评审决议):dispatcher.log 留痕;auto 路径追加 events.jsonl 双留痕;投递失败绝不 block 链。
125
294
  log(`[im-delivery] FAILED chain=${chainId}: ${r.error}`);
@@ -266,7 +435,7 @@ export async function sendChainReport(ctx, kanban, configProvider, opts, variant
266
435
  ? { ok: false, error: r.error, guidance: DSH_IM_MISSING_GUIDANCE }
267
436
  : { ok: false, error: r.error };
268
437
  }
269
- return { ok: true, chainId, botId: r.botId, targetId: r.targetId };
438
+ return { ok: true, chainId, botId: r.botId, targetId: r.targetId, via: r.via, ...(r.fallbackSaved === undefined ? {} : { fallbackSaved: r.fallbackSaved }) };
270
439
  }
271
440
  /** 接线:订阅看板事件多播通道(不动 setOnTaskCompleted 单消费者钩子)。
272
441
  * listener 同步返回,投递全程 fire-and-forget 自兜异常(publish 在 emit 队列内同步调用,不得拖慢落盘)。 */
@@ -279,8 +448,9 @@ export function wireImDelivery(ctx, kanban, configProvider, opts = {}) {
279
448
  const cfg = configProvider.getEffective().imDelivery;
280
449
  if (!cfg?.enabled)
281
450
  return; // 功能关闭:零开销早退(非异常,不留痕)
282
- // 仅 task/completed(带 taskId)与 chain/blocked 关心事件流;其余早退,避免每个无关事件触发 snapshot 全量重放
283
- if (!((ev.kind === 'task/completed' && ev.taskId) || ev.kind === 'chain/blocked'))
451
+ // 仅 task/completed(带 taskId)/ chain/blocked / review/gave-up 关心事件流;其余早退,避免每个无关事件触发 snapshot 全量重放。
452
+ // 2026-09-15:+ review/gave-up(评审超限待裁决)——不再静默等看门狗 block,人工在 gave-up 时刻即获通知与两条出口。
453
+ if (!((ev.kind === 'task/completed' && ev.taskId) || ev.kind === 'chain/blocked' || ev.kind === 'review/gave-up'))
284
454
  return;
285
455
  const state = await kanban.snapshot();
286
456
  if (ev.kind === 'task/completed' && ev.taskId) {
@@ -295,6 +465,22 @@ export function wireImDelivery(ctx, kanban, configProvider, opts = {}) {
295
465
  const msg = buildBlockMessage(state, ev.chainId, String(ev.payload['reason'] ?? ''), storageDir);
296
466
  await deliver(ev.chainId, msg);
297
467
  }
468
+ else if (ev.kind === 'review/gave-up') {
469
+ const chain = state.chains.get(ev.chainId);
470
+ if (!chain)
471
+ return;
472
+ const reviewTaskId = String(ev.payload['reviewTaskId'] ?? '');
473
+ const targetTaskId = String(ev.payload['targetTaskId'] ?? '');
474
+ const msg = [
475
+ `【评审超限待裁决】${chain.title || ev.chainId} 的评审卡 ${reviewTaskId} 超限放弃(${String(ev.payload['reason'] ?? '')})。`,
476
+ `被评审卡:${targetTaskId}`,
477
+ '两条出口:',
478
+ '① 人工豁免评审 → 计划评审按通过继续(GUI 任务详情「豁免评审」按钮 / 主会话 kanban_waive_review);',
479
+ '② 人工恢复链后按需返工(GUI 链头「人工恢复」按钮 / 主会话 kanban_reopen_chain)。',
480
+ '若无人处置,看门狗将在链持续无进展后把链置 blocked(届时可人工恢复)。',
481
+ ].join('\n');
482
+ await deliver(ev.chainId, msg);
483
+ }
298
484
  })().catch((err) => log('[im-delivery] handler failed ev=' + ev.kind + ': ' + String(err)));
299
485
  };
300
486
  return kanban.subscribe(handle);
@@ -2,6 +2,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools';
2
2
  import {} from '@deepseek-ai/dsh-util-values';
3
3
  import { KanbanService } from '../domain/kanban-service.js';
4
4
  import { can } from '../domain/permissions.js';
5
+ import { resolveReviewTarget } from '../domain/review-target.js';
5
6
  function guard(action, caller, task = null) {
6
7
  if (!can(action, caller.actor, task))
7
8
  throw new Error('permission denied: ' + action);
@@ -92,7 +93,7 @@ export function buildKanbanTools(service, getCaller) {
92
93
  parameters: {
93
94
  taskId: { type: 'string', required: true },
94
95
  summary: { type: 'string', required: true, description: 'Human-readable completion summary' },
95
- metadata: { type: 'json', description: 'Machine-readable handoff: changed_files/verification/kb_url...' },
96
+ metadata: { type: 'json', description: 'Machine-readable handoff: changed_files/verification/kb_url... W3/kb may add report = { requirement, status, branch, tasks: [{ text, done }], acceptance, verification, leftovers?, todos? } (delivery-report fields; tasks mirrors the OpenSpec plan checklist openspec/changes/<id>/tasks.md verbatim, done = checked)' },
96
97
  },
97
98
  output: { schema: { type: 'json' }, render: (_a, v) => [{ type: 'text', text: JSON.stringify(v) }] },
98
99
  async execute(args) {
@@ -166,5 +167,41 @@ export function buildKanbanTools(service, getCaller) {
166
167
  return task;
167
168
  },
168
169
  }),
170
+ // 人工恢复能力(2026-09-15):仅 human(caller.actor 由主会话/角色装配注入;角色工具面不暴露这两个工具)。
171
+ defineTool({
172
+ name: 'kanban_reopen_chain',
173
+ description: 'Reopen a blocked chain back to executing (human only; reason required).',
174
+ parameters: {
175
+ chainId: { type: 'string', required: true, description: 'Chain id (ch_xxx)' },
176
+ reason: { type: 'string', required: true, description: 'Recovery reason (audited)' },
177
+ },
178
+ output: { schema: { type: 'json' }, render: (_a, v) => [{ type: 'text', text: JSON.stringify(v) }] },
179
+ async execute(args) {
180
+ const caller = getCaller();
181
+ const ev = await service.reopenChain(args.chainId, args.reason, caller.actor);
182
+ return ev;
183
+ },
184
+ }),
185
+ defineTool({
186
+ name: 'kanban_waive_review',
187
+ description: 'Waive a failed/gave-up review card for its target task (human only; reason required).',
188
+ parameters: {
189
+ taskId: { type: 'string', required: true, description: 'Review card id (pt/dt card)' },
190
+ reason: { type: 'string', required: true, description: 'Waive reason (audited)' },
191
+ },
192
+ output: { schema: { type: 'json' }, render: (_a, v) => [{ type: 'text', text: JSON.stringify(v) }] },
193
+ async execute(args) {
194
+ const caller = getCaller();
195
+ const state = await service.snapshot();
196
+ const reviewTask = state.tasks.get(args.taskId);
197
+ if (!reviewTask)
198
+ throw new Error('unknown review task: ' + args.taskId);
199
+ const target = resolveReviewTarget(state, reviewTask, reviewTask.chainId);
200
+ if (!target)
201
+ throw new Error('review target not resolvable for ' + args.taskId);
202
+ const ev = await service.waiveReview(args.taskId, target.root.id, args.reason, caller.actor);
203
+ return ev;
204
+ },
205
+ }),
169
206
  ];
170
207
  }
@@ -131,6 +131,16 @@ function buildFreeSendGuidance(routes, query, dm) {
131
131
  '投递结果只向用户确认成功与目标,不复述正文;失败时把 error/guidance 原样转告,勿编造原因。',
132
132
  ].join('\n');
133
133
  }
134
+ /** 当前会话 preset(多机器人投递按它匹配机器人的默认 dsh 模式)。
135
+ * header.agentPreset 缺省(用默认模式创建的会话可能不落该字段)→ 回落到 agentPresets.defaultId;
136
+ * 两者都拿不到返回空串,调用方按「无模式匹配」处理(走默认机器人/交互,绝不猜)。 */
137
+ function sessionPresetOf(ctx, exec) {
138
+ const fromHeader = exec?.agent?.session?.header?.agentPreset;
139
+ if (typeof fromHeader === 'string' && fromHeader.trim())
140
+ return fromHeader.trim();
141
+ const presets = ctx.get?.('agentPresets');
142
+ return typeof presets?.defaultId === 'string' ? presets.defaultId : '';
143
+ }
134
144
  /** v2 主会话工具面:/plan: 捕获规划上下文(零副作用)→ planning_checklist_save 回写 → /openspec: 建链。
135
145
  * 工具面 = kanban_route + 只读 kanban 子集 + spec_card_view + planning 工具;
136
146
  * 无 spec_card_edit/approve、无 kanban_create/complete/block(主会话越权写由工具面裁剪 + prefetch 子代理只读护栏双保险)。 */
@@ -150,8 +160,10 @@ export function registerMainSessionTools(ctx, configProvider) {
150
160
  ? new LocalWikiClient(ensureLocalKbRoot())
151
161
  : (ctx.get('wiki') ?? new WikiVaultClient(() => configProvider.getEffective().wikiVault));
152
162
  const caller = () => ({ actor: 'human' });
153
- // 只读 kanban 子集(无 create/complete/block)
154
- const readOnly = new Set(['kanban_show', 'kanban_chain', 'kanban_list', 'kanban_comment']);
163
+ // 主会话 kanban 工具子集(无 create/complete/block)。2026-09-15 恢复能力:+ 人工恢复双工具
164
+ //(kanban_reopen_chain / kanban_waive_review)——主会话 caller 恒为 human(服务层再校验 can()),
165
+ // 角色 toolsets 名单不动(角色工具面零暴露)。
166
+ const readOnly = new Set(['kanban_show', 'kanban_chain', 'kanban_list', 'kanban_comment', 'kanban_reopen_chain', 'kanban_waive_review']);
155
167
  for (const tool of buildKanbanTools(service, caller)) {
156
168
  const name = tool.name;
157
169
  if (name && readOnly.has(name))
@@ -232,15 +244,23 @@ export function registerMainSessionTools(ctx, configProvider) {
232
244
  // 其余非空 rest → 自由投递(正文由主 agent 按意图生成,显式豁免;事实基准=看板实查)。
233
245
  // -s 独立 token 由 parseSendRequest 剥离置 dm;manual 路径零重试。
234
246
  const parsed = parseSendRequest(plan.rest);
235
- const targetOpts = { retryDelaysMs: [], manual: true, targetKind: (parsed.dm ? 'user' : 'group') };
247
+ const targetOpts = {
248
+ retryDelaysMs: [], manual: true,
249
+ targetKind: (parsed.dm ? 'user' : 'group'),
250
+ sessionPreset: sessionPresetOf(ctx, exec),
251
+ agent: exec?.agent,
252
+ };
236
253
  const deliver = async (variant, query) => {
237
254
  const r = await sendChainReport(ctx, service, configProvider, targetOpts, variant, query);
238
255
  if (r.ok) {
239
256
  const noun = variant === 'blocked' ? '阻塞通知' : '完成汇报';
240
257
  const dest = parsed.dm ? '私聊' : '群聊';
258
+ const via = r.via === 'interactive' ? `(本次交互选定${r.fallbackSaved ? ',已设为默认' : ''})`
259
+ : r.via === 'fallback' ? '(未命中会话模式,走默认机器人)'
260
+ : r.via === 'preset' ? '(按会话模式匹配机器人)' : '';
241
261
  return {
242
- kind: 'send', chainId: r.chainId, botId: r.botId, targetId: r.targetId,
243
- guidance: `${noun}已投递${dest}(/sms 手动触发)。请仅向用户确认投递成功与目标,勿复述消息正文。`,
262
+ kind: 'send', chainId: r.chainId, botId: r.botId, targetId: r.targetId, via: r.via,
263
+ guidance: `${noun}已投递${dest}${via}(/sms 手动触发)。请仅向用户确认投递成功与目标,勿复述消息正文。`,
244
264
  };
245
265
  }
246
266
  return { kind: 'send', error: r.error, guidance: r.guidance ?? '请将 error 字段原样转告用户,勿复述消息正文。' };
@@ -328,7 +348,7 @@ export function registerMainSessionTools(ctx, configProvider) {
328
348
  dm: { type: 'boolean', description: 'true = private chat; omit/false = group' },
329
349
  },
330
350
  output: { schema: { type: 'json' }, render: (_a, v) => [{ type: 'text', text: JSON.stringify(v) }] },
331
- async execute(args) {
351
+ async execute(args, exec) {
332
352
  // actor 边界与 planning 工具同构:主会话工具面仅限人工触发,非 human caller 一律拒绝
333
353
  if (caller().actor !== 'human')
334
354
  throw new Error('permission denied: sms_send');
@@ -338,7 +358,10 @@ export function registerMainSessionTools(ctx, configProvider) {
338
358
  if (text.length > 4000)
339
359
  throw new Error(`text too long: ${text.length} > 4000 chars`);
340
360
  const dm = args.dm === true;
341
- const deliver = createSender(ctx, service, configProvider, { retryDelaysMs: [], manual: true, targetKind: dm ? 'user' : 'group' });
361
+ const deliver = createSender(ctx, service, configProvider, {
362
+ retryDelaysMs: [], manual: true, targetKind: dm ? 'user' : 'group',
363
+ sessionPreset: sessionPresetOf(ctx, exec), agent: exec?.agent,
364
+ });
342
365
  const r = await deliver('free-send', text);
343
366
  if (!r.ok) {
344
367
  return {
@@ -347,8 +370,10 @@ export function registerMainSessionTools(ctx, configProvider) {
347
370
  };
348
371
  }
349
372
  return {
350
- kind: 'send', mode: 'free', dm, botId: r.botId, targetId: r.targetId,
351
- guidance: '已投递(自由投递)。请仅向用户确认投递成功与目标(群聊/私聊),勿复述正文。',
373
+ kind: 'send', mode: 'free', dm, botId: r.botId, targetId: r.targetId, via: r.via,
374
+ guidance: '已投递(自由投递)。请仅向用户确认投递成功与目标(群聊/私聊),勿复述正文。'
375
+ + (r.fallbackSaved === true ? '已按你的选择把该机器人设为默认机器人(后续模式未命中时直接使用)。' : '')
376
+ + (r.fallbackSaved === false ? '注意:设为默认未写入成功(配置未更新),下次仍需重新选择。' : ''),
352
377
  };
353
378
  },
354
379
  }));
@@ -28,6 +28,7 @@ export function buildOcrReviewTool(deps) {
28
28
  to: { type: 'string', description: '目标分支/引用(range 模式,默认 HEAD)' },
29
29
  commit: { type: 'string', description: '单次提交审查' },
30
30
  paths: { type: 'array', items: { type: 'string' }, description: 'rule 子命令的文件路径列表' },
31
+ background: { type: 'string', description: '业务上下文,托管评审时提升评审质量' },
31
32
  },
32
33
  output: { schema: { type: 'json' }, render: (_a, v) => [{ type: 'text', text: String(v) }] },
33
34
  async execute(args) {
@@ -42,18 +43,24 @@ export function buildOcrReviewTool(deps) {
42
43
  throw new Error("ocr_review: sub='rule' requires non-empty paths (file path list)");
43
44
  if (args.sub === 'managed' && !args.commit && !args.from)
44
45
  throw new Error("ocr_review: sub='managed' requires commit or from (base ref)");
45
- const res = await runOcrFn(buildOcrArgs(args.sub, { repo: args.repo, from: args.from, to: args.to, commit: args.commit, paths: args.paths }), { cwd: deps.cwd?.() ?? process.cwd(), timeoutMs: 600_000 });
46
- if (res.error && !res.stdout)
47
- throw new Error(`ocr_review: ${res.error} ${res.stderr.slice(0, 500)}`.trim());
46
+ // 托管评审走 ocr 自带 LLM(官方预算 15min×2 rounds),超时须远大于 preview/rule 的本地 git 操作
47
+ const timeoutMs = args.sub === 'managed' ? 2_400_000 : 600_000;
48
+ const res = await runOcrFn(buildOcrArgs(args.sub, { repo: args.repo, from: args.from, to: args.to, commit: args.commit, paths: args.paths, background: args.background }), { cwd: deps.cwd?.() ?? process.cwd(), timeoutMs });
49
+ // 失败不抛错:结果附 error 摘要(error + stderr 前 500 字符),模型拿得到部分结果与原因后自行降级/重试
50
+ const errSummary = res.error ? `${res.error} ${res.stderr.slice(0, 500)}`.trim() : '';
48
51
  if (args.sub === 'preview') {
49
52
  const preview = parsePreviewJson(res.stdout);
53
+ const errorPart = errSummary ? { error: errSummary } : {};
50
54
  if (shouldSuggestManaged(preview.files.length)) {
51
- return JSON.stringify({ ...preview, suggestion: `文件较多(N>${SUGGEST_MANAGED_FILES}),可在配置面板切换托管模式` });
55
+ return JSON.stringify({ ...preview, ...errorPart, suggestion: `文件较多(N>${SUGGEST_MANAGED_FILES}),可在配置面板切换托管模式` });
52
56
  }
53
- return JSON.stringify(preview);
57
+ return JSON.stringify({ ...preview, ...errorPart });
54
58
  }
55
- if (args.sub === 'rule')
59
+ if (args.sub === 'rule') {
60
+ if (errSummary)
61
+ return JSON.stringify({ error: errSummary });
56
62
  return res.stdout.slice(0, 8000);
63
+ }
57
64
  const report = parseManagedJson(res.stdout);
58
65
  if (report.status !== 'completed' && res.error)
59
66
  return JSON.stringify({ ...report, message: res.error });