@joekytc/dsh-swarm 0.3.2 → 0.3.7

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 (49) 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 +11 -1
  12. package/lib/config.js +3 -1
  13. package/lib/dispatcher/agent-runner.js +25 -9
  14. package/lib/dispatcher/dispatcher.js +7 -3
  15. package/lib/dispatcher/event-waker.js +4 -1
  16. package/lib/dispatcher/v-orchestrator.d.ts +2 -0
  17. package/lib/dispatcher/v-orchestrator.js +58 -9
  18. package/lib/domain/config-override.d.ts +6 -0
  19. package/lib/domain/config-override.js +16 -1
  20. package/lib/domain/im-message.d.ts +21 -2
  21. package/lib/domain/im-message.js +84 -4
  22. package/lib/domain/kanban-service.d.ts +8 -1
  23. package/lib/domain/kanban-service.js +67 -1
  24. package/lib/domain/permissions.d.ts +1 -1
  25. package/lib/domain/permissions.js +4 -0
  26. package/lib/domain/projection.js +8 -3
  27. package/lib/domain/review-evidence.js +9 -0
  28. package/lib/domain/review-target.d.ts +10 -0
  29. package/lib/domain/review-target.js +24 -0
  30. package/lib/domain/state-machine.js +1 -1
  31. package/lib/domain/types.d.ts +16 -3
  32. package/lib/roles/toolsets.d.ts +16 -4
  33. package/lib/roles/toolsets.js +22 -5
  34. package/lib/routes/kanban-http.js +52 -2
  35. package/lib/services/config-provider.js +2 -0
  36. package/lib/services/im-bot-probe.d.ts +36 -0
  37. package/lib/services/im-bot-probe.js +128 -0
  38. package/lib/services/im-delivery.d.ts +69 -7
  39. package/lib/services/im-delivery.js +220 -34
  40. package/lib/tools/kanban-tools.js +38 -1
  41. package/lib/tools/main-session-tools.js +34 -9
  42. package/package.json +1 -1
  43. package/personas/kanban-p/agent.cordis.yml +16 -0
  44. package/personas/kanban-pt/agent.cordis.yml +19 -1
  45. package/personas/kanban-w/agent.cordis.yml +15 -0
  46. package/personas/persona-p.md +4 -0
  47. package/personas/persona-pt.md +4 -1
  48. package/personas/persona-w.md +1 -0
  49. package/personas/swarm/agent.cordis.yml +11 -0
@@ -1,5 +1,5 @@
1
1
  import type { Role, Task } from './types.js';
2
- export type KanbanAction = 'create-task' | 'create-chain' | 'claim' | 'complete' | 'block' | 'unblock' | 'comment' | 'heartbeat' | 'archive' | 'spec-approve' | 'spec-edit' | 'spec-attach' | 'wiki-write' | 'wiki-read' | 'prefetch' | 'audit-confirm' | 'update-title' | 'delete-chain' | 'create-rework-task';
2
+ export type KanbanAction = 'create-task' | 'create-chain' | 'claim' | 'complete' | 'block' | 'unblock' | 'comment' | 'heartbeat' | 'archive' | 'spec-approve' | 'spec-edit' | 'spec-attach' | 'wiki-write' | 'wiki-read' | 'prefetch' | 'audit-confirm' | 'update-title' | 'delete-chain' | 'create-rework-task' | 'reopen-chain' | 'waive-review';
3
3
  export type Actor = Role | 'human' | 'system';
4
4
  export declare function can(action: KanbanAction, actor: Actor, task: Task | null, opts?: {
5
5
  isOwnTask?: boolean;
@@ -50,5 +50,9 @@ export function can(action, actor, task, opts = {}) {
50
50
  case 'create-rework-task':
51
51
  // 评审失败返工卡创建:仅系统(V 建执行卡、system 建返工卡,防角色伪造返工链)
52
52
  return actor === 'system';
53
+ case 'reopen-chain':
54
+ case 'waive-review':
55
+ // 人工恢复/豁免:仅 human(人工为信任锚;角色与 system 一律拒,防角色越权恢复)
56
+ return actor === 'human';
53
57
  }
54
58
  }
@@ -13,7 +13,8 @@ export function applyTo(state, ev) {
13
13
  case 'chain/executing':
14
14
  case 'chain/completed':
15
15
  case 'chain/aborted':
16
- case 'chain/blocked': {
16
+ case 'chain/blocked':
17
+ case 'chain/reopened': {
17
18
  const c = state.chains.get(ev.chainId);
18
19
  if (!c)
19
20
  throw new Error('projection: unknown chain ' + ev.chainId);
@@ -128,12 +129,16 @@ export function applyTo(state, ev) {
128
129
  // 评审事件(交付质量链):非状态转换——按 payload.targetTaskId 更新被评审任务 reviewStatus
129
130
  case 'review/passed':
130
131
  case 'review/failed':
131
- case 'review/gave-up': {
132
+ case 'review/gave-up':
133
+ case 'review/waived': {
132
134
  const targetId = String(ev.payload['targetTaskId'] ?? '');
133
135
  const t = state.tasks.get(targetId);
134
136
  if (!t)
135
137
  throw new Error('projection: unknown review target ' + targetId);
136
- const status = ev.kind === 'review/passed' ? 'passed' : ev.kind === 'review/failed' ? 'failed' : 'gave-up';
138
+ const status = ev.kind === 'review/passed' ? 'passed'
139
+ : ev.kind === 'review/failed' ? 'failed'
140
+ : ev.kind === 'review/waived' ? 'waived'
141
+ : 'gave-up';
137
142
  next.tasks = new Map(state.tasks).set(targetId, { ...t, reviewStatus: status });
138
143
  break;
139
144
  }
@@ -20,6 +20,15 @@ export function validateReviewEvidence(role, handoff) {
20
20
  missing.push('review_evidence.verdict');
21
21
  if (!Array.isArray(ev.issues))
22
22
  missing.push('review_evidence.issues');
23
+ // 2026-09-15 口径锁死(P1-7,仅 PT):fail 必须至少一条未解决的 critical/high——
24
+ // persona 口径「fail = 存在 critical/high」机械落地,防 medium 冒充阻断(ch_1_mu235xfz R3 summary 实录)。
25
+ // 全为非阻断项时应输出 pass(issues 作为非阻塞建议留档)。DT 不适用:其实证失败(test/build/diff)
26
+ // 未必伴随 high issue,用 severity 门槛会误放行真问题。
27
+ if (role === 'pt' && ev.verdict === 'fail' && Array.isArray(ev.issues)) {
28
+ const blocking = ev.issues.some((i) => i.resolved !== true && (i.severity === 'critical' || i.severity === 'high'));
29
+ if (!blocking)
30
+ missing.push('review_evidence.issues (verdict=fail 需至少一条未解决的 critical/high;否则应判 pass)');
31
+ }
23
32
  if (role === 'pt') {
24
33
  // 计划结构字段:评审须给出被评审计划产物引用(如 artifacts_path/page 结构)——缺则视为证据不足
25
34
  const planRef = ev.reviewPage ?? (handoff.metadata ?? {})['artifacts_path'];
@@ -0,0 +1,10 @@
1
+ import type { BoardState, Task } from './types.js';
2
+ /** 评审目标推导(PT/DT 共用,2026-09-15 由 v-orchestrator 内联逻辑下沉):
3
+ * - currentTarget = 该评审卡实际评审的任务(复审卡经 reworkOfTaskId 指向返工卡;首评回退 parents[0] → 阶段源任务);
4
+ * - root = 沿 reworkOfTaskId 链到顶(原任务),reviewStatus 落点。
5
+ * 返回 null = 无法推导(调用方按原语义处理,如 gave-up)。
6
+ * 编排层与恢复 service 共用同一份推导,消除双份逻辑漂移。 */
7
+ export declare function resolveReviewTarget(state: BoardState, reviewTask: Task, chainId: string): {
8
+ currentTarget: Task;
9
+ root: Task;
10
+ } | null;
@@ -0,0 +1,24 @@
1
+ /** 评审目标推导(PT/DT 共用,2026-09-15 由 v-orchestrator 内联逻辑下沉):
2
+ * - currentTarget = 该评审卡实际评审的任务(复审卡经 reworkOfTaskId 指向返工卡;首评回退 parents[0] → 阶段源任务);
3
+ * - root = 沿 reworkOfTaskId 链到顶(原任务),reviewStatus 落点。
4
+ * 返回 null = 无法推导(调用方按原语义处理,如 gave-up)。
5
+ * 编排层与恢复 service 共用同一份推导,消除双份逻辑漂移。 */
6
+ export function resolveReviewTarget(state, reviewTask, chainId) {
7
+ const role = reviewTask.assignee === 'dt' ? 'dt' : 'pt';
8
+ const srcAssignee = role === 'pt' ? 'p' : 'd';
9
+ const srcMode = role === 'pt' ? 'openspec' : 'execute';
10
+ let currentTarget = reviewTask.reworkOfTaskId
11
+ ? state.tasks.get(reviewTask.reworkOfTaskId)
12
+ : reviewTask.parents[0]
13
+ ? state.tasks.get(reviewTask.parents[0])
14
+ : undefined;
15
+ if (!currentTarget) {
16
+ currentTarget = [...state.tasks.values()].find((t) => t.chainId === chainId && t.assignee === srcAssignee && t.mode === srcMode && t.status === 'done');
17
+ }
18
+ if (!currentTarget)
19
+ return null;
20
+ let root = currentTarget;
21
+ while (root.reworkOfTaskId && state.tasks.get(root.reworkOfTaskId))
22
+ root = state.tasks.get(root.reworkOfTaskId);
23
+ return { currentTarget, root };
24
+ }
@@ -11,7 +11,7 @@ const TASK_TRANSITIONS = {
11
11
  const CHAIN_TRANSITIONS = {
12
12
  planning: { 'chain/executing': 'executing' },
13
13
  executing: { 'chain/completed': 'completed', 'chain/aborted': 'aborted', 'chain/blocked': 'blocked' },
14
- blocked: {},
14
+ blocked: { 'chain/reopened': 'executing' }, // 人工恢复唯一出边(其余事件对 blocked 仍非法)
15
15
  completed: {},
16
16
  aborted: {},
17
17
  };
@@ -3,8 +3,8 @@ export type TaskMode = 'file' | 'external' | 'kb' | 'openspec' | 'mattpocock' |
3
3
  export type TaskStatus = 'triage' | 'todo' | 'ready' | 'running' | 'blocked' | 'done' | 'failed' | 'archived';
4
4
  export type ChainStatus = 'planning' | 'executing' | 'blocked' | 'completed' | 'aborted';
5
5
  export type SpecCardStatus = 'draft' | 'approved';
6
- /** 评审状态(交付质量链):not-required 普通卡 / pending 等待评审 / passed 通过 / failed 失败待返工 / gave-up 超限放弃。 */
7
- export type ReviewStatus = 'not-required' | 'pending' | 'passed' | 'failed' | 'gave-up';
6
+ /** 评审状态(交付质量链):not-required 普通卡 / pending 等待评审 / passed 通过 / failed 失败待返工 / gave-up 超限放弃 / waived 人工豁免。 */
7
+ export type ReviewStatus = 'not-required' | 'pending' | 'passed' | 'failed' | 'gave-up' | 'waived';
8
8
  /** 评审结论:pass=通过 / fail=不通过。 */
9
9
  export type ReviewVerdict = 'pass' | 'fail';
10
10
  /** 评审问题条目(PT/DT 交接 evidence.issues)。 */
@@ -14,6 +14,14 @@ export interface ReviewIssue {
14
14
  detail: string;
15
15
  location?: string;
16
16
  resolved: boolean;
17
+ /** 依据(上游声明引用或计划内部矛盾点)——评审方运行时扩展字段,注入返工时保留。 */
18
+ basis?: string;
19
+ /** 问题说明(违反什么)。 */
20
+ problem?: string;
21
+ /** 可执行修改建议(怎么改)——返工方最需要的字段。 */
22
+ fix?: string;
23
+ /** 对账标记:true=上一轮遗留 issue 的对账条目;false/缺省=本轮新问题。收敛闸据此判定旧账是否清零(漏标=false=视为新问题,方向安全)。 */
24
+ legacy?: boolean;
17
25
  }
18
26
  /** TDD 硬要求证据(评审时收集的测试相关元信息)。 */
19
27
  export interface TddEvidence {
@@ -27,6 +35,11 @@ export interface TddEvidence {
27
35
  export interface ReviewEvidence {
28
36
  verdict: ReviewVerdict;
29
37
  issues: ReviewIssue[];
38
+ /** 收敛闸降级留痕(2026-09-15):verdict 由 fail 降级为 pass 时记录原判定与原因,审计可查。 */
39
+ downgraded?: {
40
+ from: ReviewVerdict;
41
+ reason: string;
42
+ };
30
43
  tdd?: TddEvidence;
31
44
  test?: Record<string, unknown>;
32
45
  build?: Record<string, unknown>;
@@ -40,7 +53,7 @@ export interface ReviewEvidence {
40
53
  kbUrl: string;
41
54
  };
42
55
  }
43
- export type EventKind = 'chain/created' | 'chain/executing' | 'chain/completed' | 'chain/aborted' | 'chain/blocked' | 'chain/root-task-set' | 'chain/audit-warning' | 'chain/audit-confirmed' | 'chain/title-updated' | 'chain/im-delivery-failed' | 'spec-card/created' | 'spec-card/edited' | 'spec-card/approved' | 'task/created' | 'task/claimed' | 'task/heartbeat' | 'task/commented' | 'task/completed' | 'task/blocked' | 'task/unblocked' | 'task/archived' | 'task/gate-passed' | 'task/gate-failed' | 'task/failed' | 'task/renamed' | 'review/passed' | 'review/failed' | 'review/gave-up';
56
+ export type EventKind = 'chain/created' | 'chain/executing' | 'chain/completed' | 'chain/aborted' | 'chain/blocked' | 'chain/root-task-set' | 'chain/audit-warning' | 'chain/audit-confirmed' | 'chain/title-updated' | 'chain/im-delivery-failed' | 'spec-card/created' | 'spec-card/edited' | 'spec-card/approved' | 'task/created' | 'task/claimed' | 'task/heartbeat' | 'task/commented' | 'task/completed' | 'task/blocked' | 'task/unblocked' | 'task/archived' | 'task/gate-passed' | 'task/gate-failed' | 'task/failed' | 'task/renamed' | 'review/passed' | 'review/failed' | 'review/gave-up' | 'review/waived' | 'chain/reopened';
44
57
  export interface SpecCardSections {
45
58
  problem: string;
46
59
  solution: string;
@@ -96,10 +96,21 @@ export declare function buildSwarmSessionGuard(): (execution: {
96
96
  * 落盘是 git 自身行为,BASH_WRITE_RE 无此二动词天然放行,无需特判);
97
97
  * 3. wiki_write(独立模式)→ 仅放行 projects/<repo>/reviews/<主题>-<日期>/ 命名空间;
98
98
  * 4. wiki_write(非独立会话)→ 链上组合会话(isRoleComposed)放行(自有 agent-scope
99
- * guard 管,链命名空间写入绝不被本 guard 误拦);其余(main/swarm/未知 GUI)拒绝
100
- * ——评审工具全局注册后 wiki_write 收紧到评审会话;
99
+ * guard 管,链命名空间写入绝不被本 guard 误拦);swarm 主会话与 W preset 会话放行
100
+ * (2026-09-16 放宽,写面由工具内路径白名单约束);其余(main/未知 GUI)拒绝
101
+ * ——评审工具全局注册后 wiki_write 收紧到评审/知识会话;
101
102
  * 5. 其余工具(read/glob/grep/ocr_review/wiki_read/wiki_search 等)→ 放行。 */
102
- export declare function buildStandaloneDtGuard(): (execution: {
103
+ /** wiki_write 会话级放行 preset 默认白名单:与 config.ts wikiWritePresets 默认值同源,
104
+ * 仅供 guard deps 未接线(测试/裸调用)时兜底;生产经 dispatcher 注入热读配置。
105
+ * 'ptc'(2026-09-16 实测):dsh web「蜂群模式」标签会话的 header.agentPreset 实际为
106
+ * 宿主内置 PTC 模式 id 'ptc'(presets/ptc,非插件 'swarm')——UI 标签与 header id 不同源。 */
107
+ export declare const DEFAULT_WIKI_WRITE_PRESETS: ReadonlyArray<string>;
108
+ export interface StandaloneDtGuardDeps {
109
+ /** wiki_write 放行 preset 白名单热读(dispatcher 注入 () => getEffective().wikiWritePresets;
110
+ * 缺省回落 DEFAULT_WIKI_WRITE_PRESETS——测试与未接线场景保持默认行为)。 */
111
+ getWikiWritePresets?: () => ReadonlyArray<string>;
112
+ }
113
+ export declare function buildStandaloneDtGuard(deps?: StandaloneDtGuardDeps): (execution: {
103
114
  name?: string;
104
115
  arguments?: unknown;
105
116
  agent?: unknown;
@@ -107,7 +118,8 @@ export declare function buildStandaloneDtGuard(): (execution: {
107
118
  /** 独立评审工具全局注册:ocr_review + wiki 三原语(wiki_read/wiki_search/wiki_write)。
108
119
  * 独立评审不经 agent-runner 角色组合,角色工具面不存在,故在插件全局 ctx 注册;
109
120
  * caller 固定 actor='dt'(can('wiki-write','dt')=true),wiki_write 的会话级收紧由
110
- * buildStandaloneDtGuard 完成(链上组合会话放行,main/swarm/未知 GUI 拒绝)。
121
+ * buildStandaloneDtGuard 完成(链上组合会话/swarm 主会话/W preset 会话放行,
122
+ * main/未知 GUI 拒绝)。
111
123
  * wiki 客户端按 kbMode 构造(与 registerMainSessionTools 同源:remote 优先 ctx.get('wiki')
112
124
  * 注入(测试 mock),生产热读取 getEffective().wikiVault;local 走 LocalWikiClient)。
113
125
  * local 模式不注册 wiki_write:LocalWikiClient.abs 只接受 wiki/** 形态,而 wiki_write
@@ -581,10 +581,16 @@ function dualVerbGitDenyReason(verb, rest, deny) {
581
581
  * 落盘是 git 自身行为,BASH_WRITE_RE 无此二动词天然放行,无需特判);
582
582
  * 3. wiki_write(独立模式)→ 仅放行 projects/<repo>/reviews/<主题>-<日期>/ 命名空间;
583
583
  * 4. wiki_write(非独立会话)→ 链上组合会话(isRoleComposed)放行(自有 agent-scope
584
- * guard 管,链命名空间写入绝不被本 guard 误拦);其余(main/swarm/未知 GUI)拒绝
585
- * ——评审工具全局注册后 wiki_write 收紧到评审会话;
584
+ * guard 管,链命名空间写入绝不被本 guard 误拦);swarm 主会话与 W preset 会话放行
585
+ * (2026-09-16 放宽,写面由工具内路径白名单约束);其余(main/未知 GUI)拒绝
586
+ * ——评审工具全局注册后 wiki_write 收紧到评审/知识会话;
586
587
  * 5. 其余工具(read/glob/grep/ocr_review/wiki_read/wiki_search 等)→ 放行。 */
587
- export function buildStandaloneDtGuard() {
588
+ /** wiki_write 会话级放行 preset 默认白名单:与 config.ts wikiWritePresets 默认值同源,
589
+ * 仅供 guard deps 未接线(测试/裸调用)时兜底;生产经 dispatcher 注入热读配置。
590
+ * 'ptc'(2026-09-16 实测):dsh web「蜂群模式」标签会话的 header.agentPreset 实际为
591
+ * 宿主内置 PTC 模式 id 'ptc'(presets/ptc,非插件 'swarm')——UI 标签与 header id 不同源。 */
592
+ export const DEFAULT_WIKI_WRITE_PRESETS = ['swarm', 'kanban-w', 'ptc'];
593
+ export function buildStandaloneDtGuard(deps = {}) {
588
594
  return (execution) => {
589
595
  const header = extractSessionHeader(execution?.agent);
590
596
  const name = String(execution?.name ?? '');
@@ -648,7 +654,17 @@ export function buildStandaloneDtGuard() {
648
654
  if (name === 'wiki_write') {
649
655
  if (isRoleComposed(execution?.agent) || isDtChainSubagent)
650
656
  return undefined;
651
- return 'wiki-write-restricted-to-reviewer-sessions: wiki_write 仅限交付评审官(DT)评审会话使用';
657
+ // 放宽(2026-09-16):swarm 主会话与 W preset 会话可写 wiki——can('wiki-write') 本就含 w
658
+ // (permissions.ts),写面由 wiki_write 工具内 assertAllowedWikiPagePath 五类命名空间
659
+ // 白名单硬约束;local 模式 wiki_write 未全局注册,无工具面即无暴露面。
660
+ // 放行面配置化(硬规则 6):白名单经 deps 热读 config.wikiWritePresets,宿主 preset
661
+ // 体系演化/部署自定义模式 id 时改配置即可;config 缺字段回 [](fail-closed,仅内置默认兜底测试)。
662
+ const preset = header?.agentPreset;
663
+ const allowed = deps.getWikiWritePresets?.() ?? DEFAULT_WIKI_WRITE_PRESETS;
664
+ if (preset !== undefined && allowed.includes(preset))
665
+ return undefined;
666
+ // 诊断尾巴:报错带实际 preset(或"无"),用户重试一次即可定位会话模式判定缺口
667
+ return 'wiki-write-restricted-to-reviewer-sessions: wiki_write 仅限 DT 评审会话 / W 角色 / swarm 主会话使用(当前会话 preset=' + (preset ?? '无') + ')';
652
668
  }
653
669
  return undefined;
654
670
  };
@@ -656,7 +672,8 @@ export function buildStandaloneDtGuard() {
656
672
  /** 独立评审工具全局注册:ocr_review + wiki 三原语(wiki_read/wiki_search/wiki_write)。
657
673
  * 独立评审不经 agent-runner 角色组合,角色工具面不存在,故在插件全局 ctx 注册;
658
674
  * caller 固定 actor='dt'(can('wiki-write','dt')=true),wiki_write 的会话级收紧由
659
- * buildStandaloneDtGuard 完成(链上组合会话放行,main/swarm/未知 GUI 拒绝)。
675
+ * buildStandaloneDtGuard 完成(链上组合会话/swarm 主会话/W preset 会话放行,
676
+ * main/未知 GUI 拒绝)。
660
677
  * wiki 客户端按 kbMode 构造(与 registerMainSessionTools 同源:remote 优先 ctx.get('wiki')
661
678
  * 注入(测试 mock),生产热读取 getEffective().wikiVault;local 走 LocalWikiClient)。
662
679
  * local 模式不注册 wiki_write:LocalWikiClient.abs 只接受 wiki/** 形态,而 wiki_write
@@ -5,6 +5,7 @@ import { join } from 'node:path';
5
5
  import { buildLlmCatalog } from '../services/llm-catalog.js';
6
6
  import { INSTALL_GUIDANCE, OCR_PACKAGE, managedProviderReady, probeOcr, wireManagedProvider } from '../services/ocr-cli.js';
7
7
  import { serveKanbanEvents } from './kanban-sse.js';
8
+ import { resolveReviewTarget } from '../domain/review-target.js';
8
9
  function json(res, status, body) {
9
10
  res.statusCode = status;
10
11
  res.setHeader('content-type', 'application/json; charset=utf-8');
@@ -170,6 +171,25 @@ export function registerKanbanHttp(ctx, provider, configProvider, llm, config, o
170
171
  json(res, 200, { ok: true });
171
172
  return;
172
173
  }
174
+ // 人工恢复被 blocked 的链(2026-09-15 恢复能力,仅 human):链级 action,在 taskId 守卫前分流。
175
+ // 非 blocked 链 → 409(fail-closed);reason 必填(审计留痕)。
176
+ if (body.type === 'reopen-chain') {
177
+ const chainId = String(body.chainId ?? '').trim();
178
+ const reason = String(body.reason ?? '').trim();
179
+ if (!chainId || !reason) {
180
+ json(res, 400, { error: 'chainId and reason required' });
181
+ return;
182
+ }
183
+ try {
184
+ await provider.service.reopenChain(chainId, reason, 'human');
185
+ }
186
+ catch (err) {
187
+ json(res, 409, { error: String(err instanceof Error ? err.message : err) });
188
+ return;
189
+ }
190
+ json(res, 200, { ok: true });
191
+ return;
192
+ }
173
193
  // rename 是链级或任务级 action(chainId 或 taskId 二选一),在 taskId 守卫前分流
174
194
  if (body.type === 'rename') {
175
195
  const title = String(body.title ?? '').trim();
@@ -206,6 +226,34 @@ export function registerKanbanHttp(ctx, provider, configProvider, llm, config, o
206
226
  case 'unblock':
207
227
  await provider.service.unblockTask(t, 'human');
208
228
  break;
229
+ case 'waive-review': {
230
+ // 人工评审豁免(2026-09-15 恢复能力,仅 human):目标根卡由 resolveReviewTarget 推导
231
+ //(评审卡→返工链→root),前端无需重复实现;非 failed/gave-up 目标 → 409(防误豁免)。
232
+ const reason = String(body.reason ?? '').trim();
233
+ if (!reason) {
234
+ json(res, 400, { error: 'reason required' });
235
+ return;
236
+ }
237
+ const state = await provider.service.snapshot();
238
+ const reviewTask = state.tasks.get(t);
239
+ if (!reviewTask) {
240
+ json(res, 404, { error: 'unknown task: ' + t });
241
+ return;
242
+ }
243
+ const target = resolveReviewTarget(state, reviewTask, reviewTask.chainId);
244
+ if (!target) {
245
+ json(res, 409, { error: 'review target not resolvable for ' + t });
246
+ return;
247
+ }
248
+ try {
249
+ await provider.service.waiveReview(t, target.root.id, reason, 'human');
250
+ }
251
+ catch (err) {
252
+ json(res, 409, { error: String(err instanceof Error ? err.message : err) });
253
+ return;
254
+ }
255
+ break;
256
+ }
209
257
  case 'retry': {
210
258
  // retry 走 runner(failed→claim→spawn/resume),而非只 claim 造成 running 悬挂
211
259
  const state = await provider.service.snapshot();
@@ -260,8 +308,9 @@ export function registerKanbanHttp(ctx, provider, configProvider, llm, config, o
260
308
  if (req.method === 'PUT' && req.url?.startsWith('/kanban/config')) {
261
309
  const raw = JSON.parse((await readBody(req)) || '{}');
262
310
  // 归一化缺省字段,保证缺字段走 400 校验失败而非 500。
263
- // reviewEngine 缺省时回退当前 effective(再兜底默认):旧客户端 bundle 不带该字段时保存不清空既有评审引擎配置。
264
- const fb = configProvider.getEffective().reviewEngine;
311
+ // reviewEngine/imDelivery 缺省时回退当前 effective(再兜底默认):旧客户端 bundle 不带这些字段时保存不清空既有配置。
312
+ const cur = configProvider.getEffective();
313
+ const fb = cur.reviewEngine;
265
314
  const snapshot = {
266
315
  wikiVault: { baseUrl: raw.wikiVault?.baseUrl ?? '', pagePrefix: raw.wikiVault?.pagePrefix ?? '' },
267
316
  roles: { models: raw.roles?.models ?? {} },
@@ -272,6 +321,7 @@ export function registerKanbanHttp(ctx, provider, configProvider, llm, config, o
272
321
  model: raw.reviewEngine?.managed?.model ?? fb?.managed?.model ?? '',
273
322
  },
274
323
  },
324
+ imDelivery: { fallbackBotId: raw.imDelivery?.fallbackBotId ?? cur.imDelivery?.fallbackBotId ?? '' },
275
325
  };
276
326
  const r = configProvider.applyOverride(snapshot);
277
327
  if (!r.ok) {
@@ -87,6 +87,8 @@ export class ConfigProvider extends Service {
87
87
  if (prev.reviewEngine?.managed?.[f] !== next.reviewEngine?.managed?.[f])
88
88
  keys.add('reviewEngine.managed.' + f);
89
89
  }
90
+ if ((prev.imDelivery?.fallbackBotId ?? '') !== (next.imDelivery?.fallbackBotId ?? ''))
91
+ keys.add('imDelivery.fallbackBotId');
90
92
  return [...keys];
91
93
  }
92
94
  }
@@ -0,0 +1,36 @@
1
+ /** 一个企微机器人的探针视图(字段取自 connection.status 的 bots[],缺失值归一为空/离线)。 */
2
+ export interface BotCandidate {
3
+ botId: string;
4
+ /** 该机器人的默认 dsh 模式(dsh-im 设置页的 agentPreset);未设置时为空串。 */
5
+ agentPreset: string;
6
+ connected: boolean;
7
+ /** 展示名(appIdMasked,脱敏 id),仅用于交互提示。 */
8
+ label: string;
9
+ }
10
+ export type ProbeResult = {
11
+ ok: true;
12
+ bots: BotCandidate[];
13
+ } | {
14
+ ok: false;
15
+ error: string;
16
+ };
17
+ /** 探针依赖(全部注入,便于测试与惰性读取)。 */
18
+ export interface ProbeDeps {
19
+ /** 监听端口;未就绪(webServer 未挂载/未 listen)返回 null。 */
20
+ port: () => number | null;
21
+ /** 进程 token 换 cookie 的入口(ctx.connection.authenticatedUrl);服务缺失返回 null。 */
22
+ authenticatedUrl: (base: string) => string | null;
23
+ fetchImpl: typeof fetch;
24
+ /** 时钟(缓存 TTL 测试用)。 */
25
+ now?: () => number;
26
+ }
27
+ /** connection.status 响应信封 → 候选数组。形状不符返回 null(上游改形状即降级,不猜字段)。 */
28
+ export declare function parseBotCandidates(raw: unknown): BotCandidate[] | null;
29
+ /** Set-Cookie 头 → `name=value` 片段(剥属性)。无 dsh-auth 前缀项返回 null。 */
30
+ export declare function pickAuthCookie(lines: readonly string[]): string | null;
31
+ /** 探针工厂:同一实例内结果短期缓存(连发 /sms 不重复握手),失败同样缓存以免每次重试打满超时。 */
32
+ export declare function createBotProbe(deps: ProbeDeps): () => Promise<ProbeResult>;
33
+ /** 从 cordis ctx 装配探针依赖(惰性:每次调用才读端口与 connection,避免 apply 期缓存未就绪的值)。 */
34
+ export declare function probeDepsFromCtx(ctx: {
35
+ get(name: string): unknown;
36
+ }): ProbeDeps;
@@ -0,0 +1,128 @@
1
+ // src/services/im-bot-probe.ts
2
+ // 多机器人消歧探针:读 dsh-im 侧每个企微机器人的 agentPreset(默认 dsh 模式)与连接态。
3
+ //
4
+ // 数据源是 dsh-im 的管理 RPC `/wecom/connection.status`(WECOM_ENDPOINTS.status)。该通道只对浏览器开放
5
+ // (loopback authority + dsh-auth cookie),宿主插件侧没有本地 invoke 面(HostConnectionRpc 只有
6
+ // handle/intercept),故这里走「进程 token 换 cookie 再自调用」的公开路径:
7
+ // ctx.connection.authenticatedUrl(base) → GET `/` 拿 Set-Cookie → 带 cookie POST 该 RPC。
8
+ // launchToken 只存在于进程内存(WeakMap),不落盘;cookie 是自包含 HMAC,重铸成本极低。
9
+ //
10
+ // 红线(本模块唯一允许接触 cookie 的地方):
11
+ // 1. cookie / token / URL / 请求头绝不进任何日志、错误消息或链事件(dispatcher.log 与 events.jsonl 都长期留盘);
12
+ // 2. 探针失败一律降级(返回 {ok:false}),绝不让投递整体失败——调用方退化到默认机器人/交互选择;
13
+ // 3. 上游改 RPC 形状 → parseBotCandidates 返回 null → fail-closed 同样走降级,不静默投错对象。
14
+ const PROBE_TIMEOUT_MS = 1_500;
15
+ const CACHE_TTL_MS = 30_000;
16
+ const STATUS_PATH = '/wecom/connection.status';
17
+ const COOKIE_PREFIX = 'dsh-auth-';
18
+ /** connection.status 响应信封 → 候选数组。形状不符返回 null(上游改形状即降级,不猜字段)。 */
19
+ export function parseBotCandidates(raw) {
20
+ const bots = raw?.result?.value?.bots;
21
+ if (!Array.isArray(bots))
22
+ return null;
23
+ const out = [];
24
+ for (const entry of bots) {
25
+ const botId = entry?.botId;
26
+ if (typeof botId !== 'string' || !botId)
27
+ return null;
28
+ const preset = entry.agentPreset;
29
+ const label = entry?.bot?.appIdMasked;
30
+ out.push({
31
+ botId,
32
+ agentPreset: typeof preset === 'string' ? preset : '',
33
+ connected: entry.connected === true,
34
+ label: typeof label === 'string' ? label : '',
35
+ });
36
+ }
37
+ return out;
38
+ }
39
+ /** Set-Cookie 头 → `name=value` 片段(剥属性)。无 dsh-auth 前缀项返回 null。 */
40
+ export function pickAuthCookie(lines) {
41
+ for (const line of lines) {
42
+ const pair = line.split(';')[0]?.trim() ?? '';
43
+ if (pair.startsWith(COOKIE_PREFIX))
44
+ return pair;
45
+ }
46
+ return null;
47
+ }
48
+ function setCookieLines(headers) {
49
+ const multi = headers.getSetCookie?.();
50
+ if (Array.isArray(multi) && multi.length > 0)
51
+ return multi;
52
+ const single = headers.get('set-cookie');
53
+ return single ? [single] : [];
54
+ }
55
+ /** 错误归一:只取错误码/名,绝不带 URL、请求头或 cookie(日志红线)。 */
56
+ function errorCode(err) {
57
+ const code = err?.code;
58
+ if (typeof code === 'string' && code)
59
+ return code;
60
+ const name = err?.name;
61
+ return typeof name === 'string' && name ? name : 'probe-failed';
62
+ }
63
+ async function runProbe(deps) {
64
+ const port = deps.port();
65
+ if (typeof port !== 'number' || !Number.isInteger(port) || port <= 0) {
66
+ return { ok: false, error: 'probe-unavailable: web-server-port' };
67
+ }
68
+ const base = `http://127.0.0.1:${port}`;
69
+ const exchangeUrl = deps.authenticatedUrl(`${base}/`);
70
+ if (!exchangeUrl)
71
+ return { ok: false, error: 'probe-unavailable: connection' };
72
+ const controller = new AbortController();
73
+ const timer = setTimeout(() => { controller.abort(); }, PROBE_TIMEOUT_MS);
74
+ try {
75
+ // 进程 token → cookie:GET 应用根,白拿 Set-Cookie(不跟随重定向,避免多一次无用请求)。
76
+ const index = await deps.fetchImpl(exchangeUrl, { redirect: 'manual', signal: controller.signal });
77
+ const cookie = pickAuthCookie(setCookieLines(index.headers));
78
+ if (!cookie)
79
+ return { ok: false, error: `probe-unauthorized: ${index.status}` };
80
+ const res = await deps.fetchImpl(base + STATUS_PATH, {
81
+ method: 'POST',
82
+ redirect: 'manual',
83
+ signal: controller.signal,
84
+ headers: { 'content-type': 'application/json', origin: base, cookie },
85
+ body: JSON.stringify({ type: 'client-request', rpcId: 'dsh-swarm-bot-probe', method: 'connection.status', payload: {} }),
86
+ });
87
+ if (!res.ok)
88
+ return { ok: false, error: `probe-http: ${res.status}` };
89
+ const bots = parseBotCandidates(await res.json());
90
+ if (bots === null)
91
+ return { ok: false, error: 'probe-shape-mismatch' };
92
+ return { ok: true, bots };
93
+ }
94
+ catch (err) {
95
+ return { ok: false, error: `probe-failed: ${errorCode(err)}` };
96
+ }
97
+ finally {
98
+ clearTimeout(timer);
99
+ }
100
+ }
101
+ /** 探针工厂:同一实例内结果短期缓存(连发 /sms 不重复握手),失败同样缓存以免每次重试打满超时。 */
102
+ export function createBotProbe(deps) {
103
+ const now = deps.now ?? Date.now;
104
+ let cached = null;
105
+ return async () => {
106
+ if (cached && now() - cached.at < CACHE_TTL_MS)
107
+ return cached.result;
108
+ const result = await runProbe(deps);
109
+ cached = { at: now(), result };
110
+ return result;
111
+ };
112
+ }
113
+ /** 从 cordis ctx 装配探针依赖(惰性:每次调用才读端口与 connection,避免 apply 期缓存未就绪的值)。 */
114
+ export function probeDepsFromCtx(ctx) {
115
+ return {
116
+ port: () => {
117
+ const ws = ctx.get('webServer');
118
+ return typeof ws?.port === 'number' ? ws.port : null;
119
+ },
120
+ authenticatedUrl: (base) => {
121
+ const conn = ctx.get('connection');
122
+ if (typeof conn?.authenticatedUrl !== 'function')
123
+ return null;
124
+ return conn.authenticatedUrl(base);
125
+ },
126
+ fetchImpl: ((...args) => fetch(...args)),
127
+ };
128
+ }
@@ -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;