@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
package/README.md CHANGED
@@ -136,7 +136,7 @@ All keys are optional; schema lives in `src/config.ts`. **Most users only need t
136
136
  | `dispatcher.maxRetries` | `3` | Failure retries before circuit → `blocked(gave_up)` |
137
137
  | `dispatcher.heartbeatIntervalSeconds` | `300` | Watchdog heartbeat period |
138
138
  | `dispatcher.maxProtocolViolations` | `2` | Protocol-violation guardrail: after this many consecutive violations the next one is final (`gave_up`) |
139
- | `dispatcher.maxReworksPerRole` | `{ pt: 2, dt: 3 }` | Max review rework rounds before `review/gave-up` + `[review-final]` |
139
+ | `dispatcher.maxReworksPerRole` | `{ pt: 3, dt: 3 }` | Max review rework rounds before `review/gave-up` + `[review-final]` |
140
140
  | `prefixRoutes.plan` | `/plan:` | Command-mode planning prefix |
141
141
  | `prefixRoutes.openspec` | `/openspec:` | Command-mode approve-and-execute prefix |
142
142
  | `ui.enabled` | `true` | Enable the kanban web tab |
@@ -298,12 +298,19 @@ blocks the save, and chain creation mounts the checklist as the `file-prefetch`
298
298
  #### Rework (review failure)
299
299
 
300
300
  A failed review never mutates a `done` card. Instead the system records
301
- `review/failed`, creates a **rework task** (`[返工] ...`) that inherits the source's
302
- session (`resumeSessionId`), `reviewAttempt + 1`, and starts as `todo`
303
- (`reviewStatus: 'pending'`), then re-dispatches a fresh review card for the rework.
304
- When `reviewAttempt` reaches `maxReworksPerRole` (PT 2 / DT 3), the system records
305
- `review/gave-up` and posts a `[review-final]` evidence-chain comment; the pipeline
306
- stalls at the review stage for human intervention.
301
+ `review/failed`, creates a **rework task** (`[返工] ...`) with its own session
302
+ (`kbn-<reworkId>`, `resumeSessionId = null`), `reviewAttempt + 1`, and starts as
303
+ `todo` (`reviewStatus: 'pending'`). The rework card body additionally carries the
304
+ previous round's issues verbatim in a `## 本轮修复清单` (fix-this-round) section.
305
+ A fresh review card is then dispatched for the rework.
306
+ When `reviewAttempt` reaches `maxReworksPerRole` (PT 3 / DT 3), the system records
307
+ `review/gave-up` and posts a `[review-final]` evidence-chain comment, and (with IM
308
+ delivery enabled) sends a `[评审超限待裁决]` notification with two exit paths:
309
+ waive the review (`kanban_waive_review` / GUI “豁免评审”) or reopen the chain
310
+ (`kanban_reopen_chain` / GUI “人工恢复”). **Convergence gate (2026-09-15)**: on PT
311
+ rework rounds, once all legacy issues are resolved and no unresolved `critical`
312
+ remains, a `fail` verdict is downgraded to `pass` (new findings flow downstream as
313
+ non-blocking suggestions), so the loop always converges.
307
314
 
308
315
  #### Failure recovery
309
316
 
package/README.zh-CN.md CHANGED
@@ -138,7 +138,7 @@ p ──> (pt?) ──> w2 ──> d ──> dt ──> w3 ──> summary
138
138
  | `dispatcher.maxRetries` | `3` | 失败重试上限,超出进入熔断 → `blocked(gave_up)` |
139
139
  | `dispatcher.heartbeatIntervalSeconds` | `300` | 看门狗心跳周期 |
140
140
  | `dispatcher.maxProtocolViolations` | `2` | 协议违规护栏:连续违规超过该次数后,下一次即终局(`gave_up`) |
141
- | `dispatcher.maxReworksPerRole` | `{ pt: 2, dt: 3 }` | 评审返工轮数上限,超出进入 `review/gave-up` + `[review-final]` |
141
+ | `dispatcher.maxReworksPerRole` | `{ pt: 3, dt: 3 }` | 评审返工轮数上限,超出进入 `review/gave-up` + `[review-final]` |
142
142
  | `prefixRoutes.plan` | `/plan:` | 命令模式阶段 0 规划前缀 |
143
143
  | `prefixRoutes.openspec` | `/openspec:` | 命令模式批准并执行前缀 |
144
144
  | `ui.enabled` | `true` | 启用看板 Web 标签页 |
@@ -290,10 +290,15 @@ D 只有带 `tdd` 才能完成——`test_files`(含 `test_first`)或 `skipp
290
290
  #### 返工(评审失败)
291
291
 
292
292
  评审失败**从不改写** `done` 卡。系统改为记录 `review/failed`,创建**返工任务**
293
- (`[返工] ...`),继承源会话(`resumeSessionId`)、`reviewAttempt + 1`,初始为
294
- `todo`(`reviewStatus: 'pending'`),然后为返工重新派发全新评审卡。当 `reviewAttempt`
295
- 达到 `maxReworksPerRole`(PT 2 / DT 3)时,系统记录 `review/gave-up` 并发布
296
- `[review-final]` 证据链评论;管线停在评审阶段等待人类介入。
293
+ (`[返工] ...`)——独立会话(`kbn-<reworkId>`,`resumeSessionId = null`)、
294
+ `reviewAttempt + 1`,初始为 `todo`(`reviewStatus: 'pending'`);返工卡 body 追加
295
+ 「本轮修复清单」节(携带上一轮 issues 全文),然后为返工重新派发全新评审卡。
296
+ 当 `reviewAttempt` 达到 `maxReworksPerRole`(PT 3 / DT 3)时,系统记录
297
+ `review/gave-up` 并发布 `[review-final]` 证据链评论,同时(启用 IM 投递时)发出
298
+ `[评审超限待裁决]` 通知,附两条出口:豁免评审(`kanban_waive_review` / GUI「豁免评审」)
299
+ 或人工恢复链(`kanban_reopen_chain` / GUI「人工恢复」)。**收敛闸(2026-09-15)**:
300
+ PT 复审轮中旧账清零且无未解决 `critical` 时,fail 自动降级为 pass(新发现转下游
301
+ 非阻塞建议),循环必然收敛。
297
302
 
298
303
  #### 故障恢复
299
304
 
@@ -126,7 +126,7 @@ export function KanbanBoard(props: {
126
126
  readOnly={task.status === 'archived'}
127
127
  onRetry={failedAction && failedAction.taskId === task.id ? () => void runAction(failedAction.action) : undefined}
128
128
  onComment={(body) => void runAction({ type: 'comment', taskId: task.id, body })}
129
- onAction={(action) => void runAction(action)}
129
+ onAction={(action) => runAction(action)}
130
130
  onClose={() => history.back()}
131
131
  />
132
132
  );
@@ -160,6 +160,8 @@ export function KanbanBoard(props: {
160
160
  onOpenView={props.onOpenView}
161
161
  onConfirmAudit={(chainId) => void runAction({ type: 'confirm-audit', chainId })}
162
162
  onRenameChain={(chainId, title) => void runAction({ type: 'rename', chainId, title })}
163
+ // 2026-09-15 恢复能力:直连 postAction(失败 throw 由弹窗展示;无乐观更新,走服务端事件收敛)
164
+ onReopenChain={(chainId, reason) => props.postAction({ type: 'reopen-chain', chainId, reason }).then(() => undefined)}
163
165
  onDeleteChain={async (chainId) => {
164
166
  // 直连 postAction:失败 throw 上抛删除弹窗展示(runAction 吞错仅服务 TaskDrawer 重试条)
165
167
  await props.postAction({ type: 'delete', chainId });
@@ -37,14 +37,23 @@ export function TaskDrawer(props: {
37
37
  readOnly?: boolean;
38
38
  onRetry?: () => void;
39
39
  onComment(body: string): void;
40
- onAction(action: { type: string; taskId: string; reason?: string; summary?: string; metadata?: Record<string, unknown>; body?: string }): void;
40
+ onAction(action: { type: string; taskId: string; reason?: string; summary?: string; metadata?: Record<string, unknown>; body?: string }): void | Promise<unknown>;
41
41
  onClose(): void;
42
42
  }) {
43
43
  const { task, events, handoff, specCard, chain } = props;
44
44
  const [tab, setTab] = useState<string>('overview');
45
- const [pending, setPending] = useState<{ kind: 'archive'; value: string } | null>(null);
45
+ const [pending, setPending] = useState<{ kind: 'archive' | 'waive'; value: string } | null>(null);
46
+ // 2026-09-15:豁免弹窗反馈三态(校验提示 / 在途 / 成功)——此前点击无任何反馈
47
+ const [waiveError, setWaiveError] = useState<string | null>(null);
48
+ const [waiveBusy, setWaiveBusy] = useState(false);
49
+ const [waiveOk, setWaiveOk] = useState(false);
46
50
  const timeline = events.filter((e) => e.taskId === task.id).toSorted((a, b) => a.seq - b.seq);
47
51
  const comments = timeline.filter((e) => e.kind === 'task/commented');
52
+ // 2026-09-15 恢复能力:评审卡存在 failed/gave-up 结论 → 可人工豁免(理由必填,审计留痕)。
53
+ const reviewOutcome = task.mode === 'review-plan' || task.mode === 'review-impl'
54
+ ? events.filter((e) => e.taskId === task.id && e.kind.startsWith('review/')).at(-1)?.kind ?? null
55
+ : null;
56
+ const canWaive = (reviewOutcome === 'review/failed' || reviewOutcome === 'review/gave-up');
48
57
  const submitComment = (el: HTMLInputElement) => {
49
58
  const value = el.value.trim();
50
59
  if (!value) return;
@@ -62,6 +71,26 @@ export function TaskDrawer(props: {
62
71
  setPending(null);
63
72
  props.onAction({ type: 'archive', taskId: task.id });
64
73
  };
74
+ /** 豁免提交(2026-09-15):校验必给反馈 + 在途禁用 + 成功显式提示;失败保留输入可重试。 */
75
+ const submitWaive = async () => {
76
+ if (pending?.kind !== 'waive') return;
77
+ const reason = pending.value.trim();
78
+ if (!reason) {
79
+ setWaiveError('请填写豁免理由(必填,用于审计留痕)');
80
+ return;
81
+ }
82
+ setWaiveError(null);
83
+ setWaiveBusy(true);
84
+ try {
85
+ await props.onAction({ type: 'waive-review', taskId: task.id, reason });
86
+ setWaiveOk(true);
87
+ window.setTimeout(() => { setPending(null); setWaiveOk(false); }, 800);
88
+ } catch (err) {
89
+ setWaiveError('豁免失败:' + String(err));
90
+ } finally {
91
+ setWaiveBusy(false);
92
+ }
93
+ };
65
94
  return (
66
95
  <div className="dsh-kb-detail">
67
96
  <header className="dsh-kb-detail__header">
@@ -83,6 +112,14 @@ export function TaskDrawer(props: {
83
112
  {pending?.kind === 'archive' ? '确认归档' : '归档'}
84
113
  </button>
85
114
  )}
115
+ {canWaive && (
116
+ <button
117
+ type="button"
118
+ onClick={() => { setPending({ kind: 'waive', value: '' }); setWaiveError(null); setWaiveOk(false); }}
119
+ >
120
+ 豁免评审
121
+ </button>
122
+ )}
86
123
  </div>
87
124
  )}
88
125
  </header>
@@ -209,6 +246,41 @@ export function TaskDrawer(props: {
209
246
  )}
210
247
  </section>
211
248
  )}
249
+ {/* 豁免评审弹窗(2026-09-15):由行内编辑改为弹窗确认——行内输入框会挤压 header 按钮(文字被迫竖排) */}
250
+ {!props.readOnly && pending?.kind === 'waive' && (
251
+ <div className="dsh-kb-rename-overlay" onClick={(e) => { e.stopPropagation(); if (!waiveBusy) { setPending(null); setWaiveError(null); } }}>
252
+ <div
253
+ className="dsh-kb-rename-modal"
254
+ role="dialog"
255
+ aria-modal="true"
256
+ aria-label="豁免评审"
257
+ onClick={(e) => e.stopPropagation()}
258
+ >
259
+ <div className="dsh-kb-rename-modal__label">豁免评审</div>
260
+ <div className="dsh-kb-delete-modal__text">
261
+ 豁免「{task.title}」({task.id})的评审结论:被评审卡置为 waived,链路按评审通过继续推进(该评审的遗留问题仍作为非阻塞建议传下游)。请填写豁免理由。
262
+ </div>
263
+ <input
264
+ className="dsh-kb-comment-input"
265
+ aria-label="豁免理由"
266
+ placeholder="豁免理由(必填)"
267
+ autoFocus
268
+ value={pending.value}
269
+ onChange={(e) => { setPending({ kind: 'waive', value: e.target.value }); if (waiveError) setWaiveError(null); }}
270
+ onKeyDown={(e) => { if (e.key === 'Enter') void submitWaive(); }}
271
+ />
272
+ <div className="dsh-kb-rename-modal__hint">豁免理由必填(审计留痕);填写后点「确认豁免」。</div>
273
+ {waiveError && <div className="dsh-kb-delete-modal__error" role="alert">{waiveError}</div>}
274
+ {waiveOk && <div className="dsh-kb-rename-modal__success" role="status">✓ 已豁免,链路将继续推进</div>}
275
+ <div className="dsh-kb-rename-modal__actions">
276
+ <button type="button" className="dsh-kb-rename-cancel" disabled={waiveBusy} onClick={() => { setPending(null); setWaiveError(null); }}>取消</button>
277
+ <button type="button" className="dsh-kb-rename-save" disabled={waiveBusy || waiveOk} onClick={() => void submitWaive()}>
278
+ {waiveOk ? '已豁免' : waiveBusy ? '豁免中…' : '确认豁免'}
279
+ </button>
280
+ </div>
281
+ </div>
282
+ </div>
283
+ )}
212
284
  </div>
213
285
  );
214
286
  }
@@ -30,6 +30,8 @@ export function WorkflowRail(props: {
30
30
  onRenameChain?(chainId: string, title: string): void;
31
31
  /** 整链硬删除(POST /kanban/action {type:'delete', chainId});失败 throw 由弹窗展示。 */
32
32
  onDeleteChain?(chainId: string): Promise<void>;
33
+ /** 人工恢复被 blocked 的链(POST /kanban/action {type:'reopen-chain', chainId, reason});失败 throw 由弹窗展示。 */
34
+ onReopenChain?(chainId: string, reason: string): Promise<void>;
33
35
  }) {
34
36
  const searching = props.query.trim().length > 0;
35
37
  const visible = props.chains.filter((view) => matches(view, props.query));
@@ -37,6 +39,38 @@ export function WorkflowRail(props: {
37
39
  const renamingChain = renamingChainId ? props.chains.find((v) => v.chain.id === renamingChainId)?.chain : undefined;
38
40
  const [deletingChainId, setDeletingChainId] = useState<string | null>(null);
39
41
  const [deleteError, setDeleteError] = useState<string | null>(null);
42
+ // 2026-09-15 恢复能力:blocked 链 → 人工恢复(理由必填,审计留痕)。
43
+ const [reopeningChainId, setReopeningChainId] = useState<string | null>(null);
44
+ const [reopenReason, setReopenReason] = useState('');
45
+ const [reopenError, setReopenError] = useState<string | null>(null);
46
+ const [reopenBusy, setReopenBusy] = useState(false);
47
+ const [reopenOk, setReopenOk] = useState(false);
48
+ const reopeningView = reopeningChainId ? props.chains.find((v) => v.chain.id === reopeningChainId) : undefined;
49
+ const closeReopen = () => {
50
+ setReopeningChainId(null);
51
+ setReopenReason('');
52
+ setReopenError(null);
53
+ setReopenOk(false);
54
+ };
55
+ /** 校验必给反馈(此前按钮 disabled 且无禁用样式 → 用户点击毫无反应);请求在途禁用防重复;成功后显式提示。 */
56
+ const confirmReopen = async () => {
57
+ if (!reopeningChainId) return;
58
+ if (!reopenReason.trim()) {
59
+ setReopenError('请填写恢复理由(必填,用于审计留痕)');
60
+ return;
61
+ }
62
+ setReopenError(null);
63
+ setReopenBusy(true);
64
+ try {
65
+ await props.onReopenChain?.(reopeningChainId, reopenReason.trim());
66
+ setReopenOk(true); // 明确成功反馈,800ms 后自动关闭
67
+ window.setTimeout(() => closeReopen(), 800);
68
+ } catch (err) {
69
+ setReopenError('恢复失败:' + String(err)); // 失败保留弹窗,理由可改后重试
70
+ } finally {
71
+ setReopenBusy(false);
72
+ }
73
+ };
40
74
  const deletingView = deletingChainId ? props.chains.find((v) => v.chain.id === deletingChainId) : undefined;
41
75
  const confirmDelete = async () => {
42
76
  if (!deletingChainId) return;
@@ -97,6 +131,7 @@ export function WorkflowRail(props: {
97
131
  type="button"
98
132
  className="dsh-kb-chain__delete"
99
133
  aria-label="删除需求"
134
+ title="删除需求(整链硬删除,不可恢复)"
100
135
  onClick={(e) => { e.stopPropagation(); setDeleteError(null); setDeletingChainId(view.chain.id); }}
101
136
  onKeyDown={(e) => { e.stopPropagation(); }}
102
137
  >
@@ -111,15 +146,29 @@ export function WorkflowRail(props: {
111
146
  type="button"
112
147
  className="dsh-kb-chain__rename"
113
148
  aria-label="改链标题"
149
+ title="修改链路标题"
114
150
  onClick={(e) => { e.stopPropagation(); setRenamingChainId(view.chain.id); }}
115
151
  onKeyDown={(e) => { e.stopPropagation(); }}
116
152
  >
117
153
  ✎
118
154
  </button>
119
155
  )}
156
+ {/* 人工恢复(2026-09-15):仅 blocked 链显示;文字按钮(低频高价值,明确性优先,⟳ 易被误读为刷新) */}
157
+ {props.onReopenChain && view.chain.status === 'blocked' && (
158
+ <button
159
+ type="button"
160
+ className="dsh-kb-chain__reopen"
161
+ aria-label="人工恢复链路"
162
+ title="将 blocked 链恢复为 executing,编排器继续推进后续阶段"
163
+ onClick={(e) => { e.stopPropagation(); setReopenError(null); setReopenReason(''); setReopeningChainId(view.chain.id); }}
164
+ onKeyDown={(e) => { e.stopPropagation(); }}
165
+ >
166
+ 恢复
167
+ </button>
168
+ )}
120
169
  </div>
121
170
  {(blocked || view.blockedSummary) && (
122
- <div className="dsh-kb-chain__warning">{summary}</div>
171
+ <div className="dsh-kb-chain__warning" title={summary}>{summary}</div>
123
172
  )}
124
173
  {/* D23:completed 链存在未确认 audit-warning → 顶部警告行 + 确认按钮(阻塞最终汇报直至确认) */}
125
174
  {view.audit && !view.audit.confirmed && (
@@ -151,6 +200,40 @@ export function WorkflowRail(props: {
151
200
  onCancel={() => setRenamingChainId(null)}
152
201
  />
153
202
  )}
203
+ {reopeningView && props.onReopenChain && (
204
+ <div className="dsh-kb-rename-overlay" onClick={(e) => { e.stopPropagation(); closeReopen(); }}>
205
+ <div
206
+ className="dsh-kb-rename-modal"
207
+ role="dialog"
208
+ aria-modal="true"
209
+ aria-label="人工恢复链路"
210
+ onClick={(e) => e.stopPropagation()}
211
+ >
212
+ <div className="dsh-kb-rename-modal__label">人工恢复链路</div>
213
+ <div className="dsh-kb-delete-modal__text">
214
+ 将「{reopeningView.chain.title || '未命名需求'}」从 blocked 恢复为 executing,编排器继续推进后续阶段。
215
+ </div>
216
+ <input
217
+ className="dsh-kb-comment-input"
218
+ aria-label="恢复理由"
219
+ placeholder="恢复理由(必填)"
220
+ autoFocus
221
+ value={reopenReason}
222
+ onChange={(e) => { setReopenReason(e.target.value); if (reopenError) setReopenError(null); }}
223
+ onKeyDown={(e) => { if (e.key === 'Enter') void confirmReopen(); }}
224
+ />
225
+ <div className="dsh-kb-rename-modal__hint">恢复理由必填(审计留痕);填写后点「确认恢复」。</div>
226
+ {reopenError && <div className="dsh-kb-delete-modal__error" role="alert">{reopenError}</div>}
227
+ {reopenOk && <div className="dsh-kb-rename-modal__success" role="status">✓ 已恢复,编排器将继续推进后续阶段</div>}
228
+ <div className="dsh-kb-rename-modal__actions">
229
+ <button type="button" className="dsh-kb-rename-cancel" disabled={reopenBusy} onClick={closeReopen}>取消</button>
230
+ <button type="button" className="dsh-kb-rename-save" disabled={reopenBusy || reopenOk} onClick={() => void confirmReopen()}>
231
+ {reopenOk ? '已恢复' : reopenBusy ? '恢复中…' : '确认恢复'}
232
+ </button>
233
+ </div>
234
+ </div>
235
+ </div>
236
+ )}
154
237
  {deletingView && props.onDeleteChain && (
155
238
  <div className="dsh-kb-rename-overlay" onClick={(e) => { e.stopPropagation(); setDeletingChainId(null); }}>
156
239
  <div
@@ -7,6 +7,8 @@ export interface EditableSnapshot {
7
7
  wikiVault: { baseUrl: string; pagePrefix: string };
8
8
  roles: { models: Partial<Record<string, EditableModelSnapshot>> };
9
9
  reviewEngine: { mode: 'delegate' | 'managed'; managed: { provider: string; model: string } };
10
+ /** 默认投递机器人(预设未命中的落点)。本面板不编辑:用户经 /sms 交互写入,原样回传避免清空。 */
11
+ imDelivery: { fallbackBotId: string };
10
12
  }
11
13
  /** GET /kanban/ocr/status 响应镜像;null = 尚未拉到/拉取失败(状态未知)。 */
12
14
  export interface OcrStatus { installed: boolean; version: string; mode: string; kbMode: 'remote' | 'local'; managedReady: boolean; }
@@ -23,7 +25,7 @@ export interface ConfigState {
23
25
 
24
26
  /** 配置外部 store(GET /kanban/config + /kanban/llm-catalog + ocr 运维面;PUT 保存;POST reset),fetch 注入便于测试。 */
25
27
  export function createConfigStore(fetchImpl: typeof fetch) {
26
- let state: ConfigState = { effective: { wikiVault: { baseUrl: '', pagePrefix: '' }, roles: { models: {} }, reviewEngine: { mode: 'delegate', managed: { provider: '', model: '' } } }, sources: {}, catalog: { providers: [], models: {} }, ocrStatus: null, install: { phase: 'idle', log: '' }, saving: false, error: null };
28
+ let state: ConfigState = { effective: { wikiVault: { baseUrl: '', pagePrefix: '' }, roles: { models: {} }, reviewEngine: { mode: 'delegate', managed: { provider: '', model: '' } }, imDelivery: { fallbackBotId: '' } }, sources: {}, catalog: { providers: [], models: {} }, ocrStatus: null, install: { phase: 'idle', log: '' }, saving: false, error: null };
27
29
  const listeners = new Set<() => void>();
28
30
  const setState = (patch: Partial<ConfigState>) => { state = { ...state, ...patch }; for (const l of [...listeners]) l(); };
29
31
  const get = () => state;
package/client/kanban.css CHANGED
@@ -399,6 +399,7 @@
399
399
  padding: 4px 10px;
400
400
  cursor: pointer;
401
401
  font: inherit;
402
+ white-space: nowrap; /* 2026-09-15:横向空间不足时按钮文字曾被逐字竖排 */
402
403
  transition: border-color 150ms ease, color 150ms ease, background-color 150ms ease;
403
404
  }
404
405
  .dsh-kb-detail__actions button:hover {
@@ -722,6 +723,21 @@
722
723
  cursor: pointer;
723
724
  }
724
725
  .dsh-kb-chain__rename:hover { opacity: 1; }
726
+ /* 人工恢复按钮(2026-09-15):仅 blocked 链显示,常显(低频高价值场景,明确性优先于紧凑性) */
727
+ .dsh-kb-chain__reopen {
728
+ flex: 0 0 auto;
729
+ border: 0;
730
+ background: transparent;
731
+ color: currentColor;
732
+ font: inherit;
733
+ font-size: 12px;
734
+ line-height: 1;
735
+ padding: 2px 6px;
736
+ border-radius: 4px;
737
+ cursor: pointer;
738
+ transition: background 150ms ease;
739
+ }
740
+ .dsh-kb-chain__reopen:hover { background: rgba(255, 255, 255, 0.12); }
725
741
  .dsh-kb-task__status-row {
726
742
  display: flex;
727
743
  align-items: center;
@@ -803,6 +819,18 @@
803
819
  background: var(--dsw-alias-button-primary-fill);
804
820
  color: var(--dsw-alias-label-primary-foreground);
805
821
  }
822
+ /* 2026-09-15:弹窗操作按钮的禁用/在途视觉(此前完全缺失——disabled 无视觉差异,点击像"没反应") */
823
+ .dsh-kb-rename-modal__actions button:disabled { opacity: 0.5; cursor: not-allowed; }
824
+ .dsh-kb-rename-modal__hint {
825
+ color: var(--dsh-kb-text-secondary);
826
+ font-size: 12px;
827
+ line-height: 1.4;
828
+ }
829
+ .dsh-kb-rename-modal__success {
830
+ color: #2ea043;
831
+ font-size: 12px;
832
+ line-height: 1.4;
833
+ }
806
834
 
807
835
  /* Swarm 配置面板「评审引擎(ocr)」卡:安装中 spinner 行与状态行(成功绿 / 警示黄) */
808
836
  .dsh-kb-config__installing {
@@ -41,6 +41,8 @@ const STATUS_OF: Record<EventKind, TimelineStatus> = {
41
41
  'chain/audit-confirmed': 'neutral',
42
42
  'chain/title-updated': 'neutral',
43
43
  'chain/im-delivery-failed': 'exception', // IM 投递失败留痕(非状态转换注记)
44
+ 'chain/reopened': 'running', // 人工恢复(blocked → executing)
45
+ 'review/waived': 'success', // 人工评审豁免(放行)
44
46
  'spec-card/created': 'neutral',
45
47
  'spec-card/edited': 'neutral',
46
48
  'spec-card/approved': 'success',
@@ -77,6 +79,7 @@ const KIND_LABEL: Record<EventKind, string> = {
77
79
  'chain/audit-confirmed': '越权已确认',
78
80
  'chain/title-updated': '链路改名',
79
81
  'chain/im-delivery-failed': 'IM 投递失败',
82
+ 'chain/reopened': '人工恢复链路',
80
83
  'spec-card/created': '规格卡创建',
81
84
  'spec-card/edited': '规格卡编辑',
82
85
  'spec-card/approved': '规格卡批准',
@@ -95,6 +98,7 @@ const KIND_LABEL: Record<EventKind, string> = {
95
98
  'review/passed': '评审通过',
96
99
  'review/failed': '评审驳回',
97
100
  'review/gave-up': '评审超限放弃',
101
+ 'review/waived': '评审豁免',
98
102
  };
99
103
 
100
104
  /** author id → 友好名(复用 ROLE_NAME 语义,system/human 另映射)。 */
@@ -117,19 +117,28 @@ function activityLabel(task: Task, state: BoardState, now: number): string {
117
117
  return `${Math.floor(diff / 86_400_000)}d`;
118
118
  }
119
119
 
120
+ /** 链头警告摘要(2026-09-15 修正):只反映"当前"状态,不粘历史。
121
+ * 旧实现是纯历史扫描(取链上 seq 最大的 task/blocked|task/failed 的 reason),不看该卡当前状态——
122
+ * 历史阻塞解除/链恢复后警告行永久常驻,且真正停因(chain/blocked,如 stall-watchdog)从不显示。
123
+ * 新优先级:① 链级 blocked → chain/blocked 的 reason("链为什么停"的第一手原因);
124
+ * ② 否则 → 当前处于 blocked/failed 的卡自身最后一次 block/fail 原因;
125
+ * ③ 均无 → null(警告行不显示;历史原因仍可在卡轨迹查看)。 */
120
126
  function blockedSummary(chainId: string, state: BoardState): string | null {
121
- let latest: string | null = null;
122
- let latestSeq = -1;
123
- for (const ev of state.events) {
124
- if (ev.chainId !== chainId) continue;
125
- if (ev.kind === 'task/blocked' || ev.kind === 'task/failed') {
126
- if (ev.seq > latestSeq) {
127
- latestSeq = ev.seq;
128
- latest = String(ev.payload['reason'] ?? '');
129
- }
130
- }
127
+ if (state.chains.get(chainId)?.status === 'blocked') {
128
+ const ev = [...state.events].reverse().find((e) => e.chainId === chainId && e.kind === 'chain/blocked');
129
+ return String(ev?.payload['reason'] ?? '') || '链级阻塞(原因未记录)';
131
130
  }
132
- return latest;
131
+ const blockedIds = new Set(
132
+ [...state.tasks.values()]
133
+ .filter((t) => t.chainId === chainId && (t.status === 'blocked' || t.status === 'failed'))
134
+ .map((t) => t.id),
135
+ );
136
+ if (blockedIds.size === 0) return null;
137
+ const ev = [...state.events]
138
+ .reverse()
139
+ .find((e) => e.chainId === chainId && e.taskId !== null && blockedIds.has(e.taskId)
140
+ && (e.kind === 'task/blocked' || e.kind === 'task/failed'));
141
+ return ev ? String(ev.payload['reason'] ?? '') : null;
133
142
  }
134
143
 
135
144
  function relatedIds(state: BoardState, chainId: string, selectedTaskId: string): Set<string> {