@joekytc/dsh-swarm 0.1.0

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 (125) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +504 -0
  3. package/README.zh-CN.md +452 -0
  4. package/client/BoardCard.tsx +41 -0
  5. package/client/ConnectionBanner.tsx +16 -0
  6. package/client/KanbanBoard.tsx +168 -0
  7. package/client/KanbanTab.tsx +26 -0
  8. package/client/RenameModal.tsx +38 -0
  9. package/client/TaskDrawer.tsx +212 -0
  10. package/client/WorkflowRail.tsx +175 -0
  11. package/client/board-store.ts +196 -0
  12. package/client/css.d.ts +4 -0
  13. package/client/index.ts +31 -0
  14. package/client/kanban.css +653 -0
  15. package/client/useKanbanBoard.ts +7 -0
  16. package/client/workflow-model.ts +229 -0
  17. package/cordis.patch.yml +88 -0
  18. package/lib/client.js +1205 -0
  19. package/lib/config.d.ts +46 -0
  20. package/lib/config.js +43 -0
  21. package/lib/dispatcher/agent-runner.d.ts +23 -0
  22. package/lib/dispatcher/agent-runner.js +429 -0
  23. package/lib/dispatcher/chain-auditor.d.ts +47 -0
  24. package/lib/dispatcher/chain-auditor.js +194 -0
  25. package/lib/dispatcher/dispatcher.d.ts +50 -0
  26. package/lib/dispatcher/dispatcher.js +280 -0
  27. package/lib/dispatcher/event-waker.d.ts +13 -0
  28. package/lib/dispatcher/event-waker.js +24 -0
  29. package/lib/dispatcher/git-credentials.d.ts +33 -0
  30. package/lib/dispatcher/git-credentials.js +78 -0
  31. package/lib/dispatcher/merge-gate.d.ts +28 -0
  32. package/lib/dispatcher/merge-gate.js +74 -0
  33. package/lib/dispatcher/model-candidates.d.ts +13 -0
  34. package/lib/dispatcher/model-candidates.js +31 -0
  35. package/lib/dispatcher/session-events.d.ts +28 -0
  36. package/lib/dispatcher/session-events.js +33 -0
  37. package/lib/dispatcher/target-repo.d.ts +15 -0
  38. package/lib/dispatcher/target-repo.js +42 -0
  39. package/lib/dispatcher/v-orchestrator.d.ts +74 -0
  40. package/lib/dispatcher/v-orchestrator.js +452 -0
  41. package/lib/dispatcher/watchdog.d.ts +15 -0
  42. package/lib/dispatcher/watchdog.js +30 -0
  43. package/lib/dispatcher/workspace-attach.d.ts +40 -0
  44. package/lib/dispatcher/workspace-attach.js +112 -0
  45. package/lib/domain/delivery-contract.d.ts +18 -0
  46. package/lib/domain/delivery-contract.js +80 -0
  47. package/lib/domain/delivery-evidence.d.ts +12 -0
  48. package/lib/domain/delivery-evidence.js +41 -0
  49. package/lib/domain/event-store.d.ts +20 -0
  50. package/lib/domain/event-store.js +53 -0
  51. package/lib/domain/kanban-service.d.ts +94 -0
  52. package/lib/domain/kanban-service.js +430 -0
  53. package/lib/domain/permissions.d.ts +7 -0
  54. package/lib/domain/permissions.js +54 -0
  55. package/lib/domain/planning-checklist.d.ts +22 -0
  56. package/lib/domain/planning-checklist.js +81 -0
  57. package/lib/domain/prefetch-manifest.d.ts +21 -0
  58. package/lib/domain/prefetch-manifest.js +73 -0
  59. package/lib/domain/projection.d.ts +3 -0
  60. package/lib/domain/projection.js +165 -0
  61. package/lib/domain/review-evidence.d.ts +13 -0
  62. package/lib/domain/review-evidence.js +76 -0
  63. package/lib/domain/state-machine.d.ts +4 -0
  64. package/lib/domain/state-machine.js +32 -0
  65. package/lib/domain/task-parents.d.ts +17 -0
  66. package/lib/domain/task-parents.js +39 -0
  67. package/lib/domain/tdd-classify.d.ts +5 -0
  68. package/lib/domain/tdd-classify.js +20 -0
  69. package/lib/domain/types.d.ts +142 -0
  70. package/lib/domain/types.js +2 -0
  71. package/lib/index.d.ts +5 -0
  72. package/lib/index.js +56 -0
  73. package/lib/roles/preset-installer.d.ts +8 -0
  74. package/lib/roles/preset-installer.js +53 -0
  75. package/lib/roles/toolsets.d.ts +62 -0
  76. package/lib/roles/toolsets.js +332 -0
  77. package/lib/roles/wiki-worker.d.ts +20 -0
  78. package/lib/roles/wiki-worker.js +44 -0
  79. package/lib/routes/kanban-http.d.ts +6 -0
  80. package/lib/routes/kanban-http.js +159 -0
  81. package/lib/routes/kanban-sse.d.ts +8 -0
  82. package/lib/routes/kanban-sse.js +58 -0
  83. package/lib/routes/planning-driver.d.ts +16 -0
  84. package/lib/routes/planning-driver.js +52 -0
  85. package/lib/routes/prefix-router.d.ts +29 -0
  86. package/lib/routes/prefix-router.js +28 -0
  87. package/lib/services/kanban-provider.d.ts +16 -0
  88. package/lib/services/kanban-provider.js +14 -0
  89. package/lib/tools/kanban-tools.d.ts +11 -0
  90. package/lib/tools/kanban-tools.js +169 -0
  91. package/lib/tools/main-session-tools.d.ts +20 -0
  92. package/lib/tools/main-session-tools.js +170 -0
  93. package/lib/tools/planning-tools.d.ts +32 -0
  94. package/lib/tools/planning-tools.js +102 -0
  95. package/lib/tools/prefetch-tools.d.ts +5 -0
  96. package/lib/tools/prefetch-tools.js +59 -0
  97. package/lib/tools/spec-card-tools.d.ts +4 -0
  98. package/lib/tools/spec-card-tools.js +75 -0
  99. package/lib/tools/wiki-tools.d.ts +4 -0
  100. package/lib/tools/wiki-tools.js +57 -0
  101. package/lib/wiki/kb-linkage.d.ts +9 -0
  102. package/lib/wiki/kb-linkage.js +87 -0
  103. package/lib/wiki/page-path.d.ts +6 -0
  104. package/lib/wiki/page-path.js +28 -0
  105. package/lib/wiki/wiki-vault-client.d.ts +28 -0
  106. package/lib/wiki/wiki-vault-client.js +48 -0
  107. package/package.json +83 -0
  108. package/personas/kanban-d/agent.cordis.yml +157 -0
  109. package/personas/kanban-d/preset.yml +2 -0
  110. package/personas/kanban-dt/agent.cordis.yml +71 -0
  111. package/personas/kanban-dt/preset.yml +2 -0
  112. package/personas/kanban-p/agent.cordis.yml +66 -0
  113. package/personas/kanban-p/preset.yml +2 -0
  114. package/personas/kanban-pt/agent.cordis.yml +47 -0
  115. package/personas/kanban-pt/preset.yml +2 -0
  116. package/personas/kanban-v/agent.cordis.yml +47 -0
  117. package/personas/kanban-v/preset.yml +2 -0
  118. package/personas/kanban-w/agent.cordis.yml +47 -0
  119. package/personas/kanban-w/preset.yml +2 -0
  120. package/personas/persona-d.md +26 -0
  121. package/personas/persona-dt.md +18 -0
  122. package/personas/persona-p.md +13 -0
  123. package/personas/persona-pt.md +13 -0
  124. package/personas/persona-v.md +18 -0
  125. package/personas/persona-w.md +12 -0
@@ -0,0 +1,452 @@
1
+ import { installRoleTools } from '../roles/toolsets.js';
2
+ import { resolveTaskParents } from '../domain/task-parents.js';
3
+ import { missingParentDelivery } from '../domain/delivery-contract.js';
4
+ import { toolArgs, toolName } from './session-events.js';
5
+ import { buildModelCandidates, isModelUnavailableError } from './model-candidates.js';
6
+ import { attachSessionToWorkspace, resolveOrCreateWorkspace } from './workspace-attach.js';
7
+ export const R20_PHASE_ORDER = ['p', 'pt', 'w2', 'd', 'dt', 'w3', 'summary'];
8
+ /** 每 phase 的期望建卡(pt 由 P 交付 pt_decision.needed=true 触发;dt 固定)。 */
9
+ export const R20_PHASE_EXPECT = {
10
+ p: { assignee: 'p', mode: 'openspec' },
11
+ pt: { assignee: 'pt', mode: 'review-plan' }, // 由 P 交付 pt_decision.needed=true 触发(需计划评审才进入)
12
+ w2: { assignee: 'w', mode: 'kb' },
13
+ d: { assignee: 'd', mode: 'execute' }, // R20:D=执行者(实际写代码/git 提交推送),非只读对齐/校验
14
+ dt: { assignee: 'dt', mode: 'review-impl' }, // 固定:D 之后必经实现校验+评审
15
+ w3: { assignee: 'w', mode: 'kb' },
16
+ summary: null,
17
+ };
18
+ /** M5:每阶段建卡的 body 生成指令(角色定位确定性模板,消除 V 自由发挥导致的角色漂移)。
19
+ * P=计划者(绝不执行)、D=唯一执行者(TARGET_REPO 必须取自规格卡 file-prefetch 附件 ref,禁止回退/猜测)、
20
+ * W=KB 同步(绝不执行代码)。V 把对应模板写入 kanban_create 的 body。 */
21
+ export const PHASE_INSTRUCTIONS = {
22
+ p: [
23
+ '## P 阶段任务体要求(计划者,非执行者)',
24
+ 'body 写入规划指令:读规格卡(含 file-prefetch/kb 附件=需求澄清清单)→ 产出 openspec 实施计划。仓库事实不足时先只读自查仓库代码实证(fs/search/grep 均可),再产出计划。',
25
+ '产物路径(OpenSpec 规范):写入目标仓库 `openspec/changes/<change_name>/` 下 proposal.md / design.md / tasks.md(change_name 依规格自拟 kebab-case,如 autoNote-tab);complete 时 metadata.artifacts_path = 该目录绝对路径。',
26
+ '铁律:P 是计划者,绝不执行任何 git/worktree/commit/push、不改源码/README、不跑构建部署——执行是 D 的职责;只读自查仅限读仓库,写边界仅限 openspec/changes/ 目录(会话工具级硬护栏强制,其余一律只读)。',
27
+ 'complete 时 metadata 必须带 schema 合法的 pt_decision = { needed: boolean, reason?: string }(needed=true 时 reason 必填)。按下列复杂度清单判定(逐条勾选,禁止"感觉"):',
28
+ ' needed=true(需 PT 计划评审),满足任一条:① 跨 ≥2 模块/目录,或改动公共接口/共享类型/配置文件;② 破坏性变更(对外 API、数据格式、迁移、兼容性);③ impl_decisions 含 ≥2 个互斥方案需仲裁;④ tasks ≥8 条,或含新增/重写核心模块;⑤ 涉安全/权限/并发/数据迁移等高风险面;⑥ OpenSpec change 中 specs/ 模块的 spec.md 文件数 ≥3。',
29
+ ' needed=false(免评审),须全部满足:① 单文件/单模块小改动、无公共接口变更;② 无破坏性变更、无外部依赖变更;③ 方案唯一无仲裁点、tasks<8;④ 无安全/权限/并发/迁移风险;⑤ specs/ 模块 spec.md <3。',
30
+ '仓库事实经只读自查后仍不足(关键目标文件缺失或仓库路径未实证)时,禁止编造计划——调用 kanban_block,reason 带 kb-insufficient,等主 agent 补清单后恢复。',
31
+ ].join('\n'),
32
+ pt: [
33
+ '## PT 阶段任务体要求(计划评审,只读)',
34
+ 'body 写入计划评审指令:P 已判定需要计划评审(理由见上)。只读评审 P 的计划产物(对齐需求/完整性/逻辑交互一致性),输出 verdict+issues 入交接 metadata.review_evidence。',
35
+ '铁律:PT 是只读评审角色,绝不修改任何产物/源码;不调用 kanban_create、不执行代码。',
36
+ ].join('\n'),
37
+ w2: [
38
+ '## W2 阶段任务体要求(KB 同步)',
39
+ 'body 写入 KB 同步指令:读父任务交接(P 产物路径)→ wiki_write 同步为项目页 → complete(kb_url, page_path)。禁止任何 git/代码操作。',
40
+ ].join('\n'),
41
+ d: [
42
+ '## D 阶段任务体要求(执行者,唯一,非只读对齐/校验)',
43
+ 'body 写入执行指令:先读父任务交接(W2)里的 page_path/kb_url,用 wiki_read 读取 openspec 实施计划原文,再按计划执行规格卡 solution/testing —— git worktree/branch → 改代码/README → git commit → git push(仅 feature 分支,可选)→ 自检(跑测试/构建)并附产物证据(changed_files/commit_hash)。',
44
+ 'body 第一行以 TARGET_REPO=<真实仓库绝对路径> 声明目标仓库:必须取自规格卡 file-prefetch 附件 ref,禁止写 kanban 存储目录、禁止猜测回退。',
45
+ 'body 同时声明 TARGET_BRANCH=<目标分支名>(来自规格卡/用户声明):D 在 worktree feature 分支完成实现并验证后即 complete,禁止合并回 TARGET_BRANCH、禁止推 TARGET_BRANCH——合入由 DT 通过后 system 统一执行。',
46
+ 'complete 时 metadata 必须带 branch=<feature 分支名>(DT 评审与 system 合入定位该分支用);git 证据 changed_files + commit_hash 必须(push 可选,可推 feature 分支)。',
47
+ '禁止把 D 任务体写成"只读对齐/校验/审核"类措辞——D 是唯一执行者,必须实际改代码并提交推送。',
48
+ 'TDD 硬要求:JS/TS/JSX/Vue 项目测试固定用 vitest(`npx vitest run`),先写测试(RED)再实现(GREEN),测试可与实现同提交但须不晚于实现进入 git 历史(DT 用 `git log --reverse` 核验);complete 时 metadata 必须带 tdd = { test_files: [...], test_first: bool };纯文档/配置变更则带 tdd = { skipped: { reason } }。',
49
+ ].join('\n'),
50
+ dt: [
51
+ '## DT 阶段任务体要求(实现校验+评审,只读护栏)',
52
+ 'body 写入实现校验指令:对 D 产物实证校验(test/build/typecheck/diff/git 证据 + open-code-review 评审),输出 verdict+issues 入交接 metadata.review_evidence。评审目标为 D 交接 metadata.branch 指向的 feature 分支(ocr review --from <TARGET_BRANCH> --to <branch>),而非 TARGET_BRANCH。',
53
+ '铁律:DT 是只读校验+评审角色,绝不修改源码/产物;校验经 ToolGuard 硬性只读护栏;不注入 git 凭据。',
54
+ ].join('\n'),
55
+ w3: [
56
+ '## W3 阶段任务体要求(KB 收尾同步)',
57
+ 'body 写入 KB 收尾同步指令:读 D 交接 → wiki_write 同步 → complete(kb_url)。禁止任何 git/代码操作。',
58
+ ].join('\n'),
59
+ };
60
+ /** 提取 P 交接里 pt_decision 的 reason(PT 阶段注入 V context,供 PT 卡 body 引用评审理由)。 */
61
+ function extractPtReason(state, chainId) {
62
+ const pTask = [...state.tasks.values()].find((t) => t.chainId === chainId && t.assignee === 'p' && t.mode === 'openspec');
63
+ const d = (pTask ? state.handoffs.get(pTask.id)?.metadata?.['pt_decision'] : undefined);
64
+ return typeof d?.reason === 'string' ? d.reason : '';
65
+ }
66
+ /** 仅评审卡:archived 且从未处理过 verdict = 作废(void)。human 归档评审卡即作废,编排器视其不存在。
67
+ * 非评审卡(p/d/w…)一律返回 false——避免 B6 误判"该阶段无卡"而重复建卡。 */
68
+ const REVIEW_MODES = ['review-plan', 'review-impl'];
69
+ function isVoidReview(task, events) {
70
+ if (task.status !== 'archived')
71
+ return false;
72
+ if (!REVIEW_MODES.includes(task.mode))
73
+ return false; // 非评审卡不判 void
74
+ return !events.some((e) => e.taskId === task.id && (e.kind === 'review/passed' || e.kind === 'review/failed' || e.kind === 'review/gave-up'));
75
+ }
76
+ export class VOrchestrator {
77
+ ctx;
78
+ kanban;
79
+ agents;
80
+ config;
81
+ orchestrations;
82
+ wiki;
83
+ defaultModel;
84
+ constructor(ctx, kanban, agents, config, orchestrations, wiki, defaultModel) { this.ctx = ctx; this.kanban = kanban; this.agents = agents; this.config = config; this.orchestrations = orchestrations; this.wiki = wiki; this.defaultModel = defaultModel; }
85
+ currentPhase(chainId) {
86
+ let o = this.orchestrations.get(chainId);
87
+ if (!o) {
88
+ o = { chainId, phase: 'p', sessionId: null, waitingOn: null };
89
+ this.orchestrations.set(chainId, o);
90
+ }
91
+ return o;
92
+ }
93
+ async wakeV(chainId) {
94
+ const orch = this.currentPhase(chainId);
95
+ if (orch.phase === 'summary')
96
+ return; // 链完成由 completeTask 机械规则产生
97
+ const state = await this.kanban.snapshot();
98
+ const chain = state.chains.get(chainId);
99
+ if (!chain)
100
+ throw new Error('unknown chain: ' + chainId);
101
+ const specCard = chain.specCardId ? state.specCards.get(chain.specCardId) : null;
102
+ if (chain.status === 'completed' || chain.status === 'aborted')
103
+ return;
104
+ // 阻塞复核 pass(全量阻塞恢复):链上有 status=blocked 且尚无 [blocked-review] 复核评论的任务
105
+ // → 向 V 发一轮阻塞复核(本轮 V 唯一动作),V 用 kanban_comment 以 [blocked-review] 开头逐一
106
+ // 评论给方向后待命;无此类任务则正常推进。扩展为所有 blocked(含 kb-insufficient 等),
107
+ // 幂等由 hasBlockReview 保证(每次阻塞至多一轮复核评论,blocked→unblocked→再 blocked
108
+ // 因新 block 事件 seq 更新才重新评论)。置于 B4 门控之前:即使规格卡未批准(planning/draft),
109
+ // 阻塞也可复核(恢复不依赖批准)。
110
+ const blockedReview = [...state.tasks.values()].filter((t) => {
111
+ if (t.chainId !== chainId || t.status !== 'blocked')
112
+ return false;
113
+ return !this.hasBlockReview(state, t);
114
+ });
115
+ if (blockedReview.length > 0) {
116
+ const agent = await this.getVAgent(orch);
117
+ const taskList = blockedReview.map((t) => {
118
+ const lastBlock = [...state.events].reverse().find((e) => e.taskId === t.id && e.kind === 'task/blocked');
119
+ return `${t.id} ${t.assignee}/${t.mode} blocked (reason: ${String(lastBlock?.payload['reason'] ?? '')})`;
120
+ }).join('\n');
121
+ const context = [
122
+ '# V 编排轮次(阻塞复核)',
123
+ `chain=${chainId}`,
124
+ '## 阻塞任务',
125
+ taskList,
126
+ '## 立即动作(本轮唯一任务)',
127
+ '对上述每个阻塞任务调用 kanban_comment 评论,正文以 [blocked-review] 开头给出协调方向:阻塞原因 + 阶段应交付 + 建议修复方向。',
128
+ 'gave_up 任务说明链路已停止,建议查看对应 [blocked-final] 证据链(block 时间线 + 复核/评论时间线 + 最终原因),给出终态解释。',
129
+ '规则:只评论、不建卡、不改任务状态;已有 [blocked-review] 评论的任务不要重复评论。',
130
+ ].join('\n\n');
131
+ agent.followup({ content: [{ type: 'text', text: context }], source: { kind: 'user' } });
132
+ await agent.whenIdle();
133
+ return; // 本轮 V 唯一动作是阻塞复核,不再推进阶段
134
+ }
135
+ // B4 阶段门控:V 仅规格卡 approved(链 executing)后才行动(从 p 起跑建执行链卡);
136
+ // draft/planning 时 V 待命,等 spec-card/approved 事件唤醒(event-waker 已订阅)。
137
+ const approved = chain.status === 'executing' && specCard?.status === 'approved';
138
+ if (!approved)
139
+ return;
140
+ // 阶段推进循环(修复轮 6,举一反三):跳过已完成(终态卡)阶段,
141
+ // 停在需要建卡的阶段建卡并推进;避免「推进 phase 后不建下一卡」造成的流水线停滞。
142
+ for (;;) {
143
+ // B4 阶段门控(每轮按当前 phase 重查):V 仅 approved 后行动(chain/executing 已确认)。
144
+ const approvedHere = chain.status === 'executing' && specCard?.status === 'approved';
145
+ if (!approvedHere)
146
+ return;
147
+ // pt 按需跳过:P 交付 pt_decision.needed=false → 不进 PT,直接推进 w2;needed=true/缺失 → 建 PT 卡。
148
+ // 判定输入 = P(openspec) 卡的完成交接 metadata.pt_decision(V 只执行建卡、不自行判断)。
149
+ // 仅当链上尚无 PT 卡(首次进入)时判定;已有 PT 卡(在途/终态,含复审卡)不再判定,
150
+ // 由下方评审卡 completed 分流处理(pass→推进 / fail→返工),避免复审被误跳过。
151
+ if (orch.phase === 'pt') {
152
+ const fresh = await this.kanban.snapshot();
153
+ const hasPtCard = [...fresh.tasks.values()].some((t) => t.chainId === chainId && t.assignee === 'pt' && t.mode === 'review-plan' && !isVoidReview(t, fresh.events));
154
+ if (!hasPtCard) {
155
+ // 取「最新」P(openspec) 卡判定(at(-1)):原 P(done) + 返工 P(blocked) 并存时,
156
+ // 首卡 find 会误取已 done 的原 P → 闸漏拦。最新卡为准,防 P blocked 时仍建 PT。
157
+ const pTask = [...fresh.tasks.values()]
158
+ .filter((t) => t.chainId === chainId && t.assignee === 'p' && t.mode === 'openspec')
159
+ .at(-1);
160
+ // 阻塞闸(修复 P blocked 时仍建 PT):P(openspec) 未 done(blocked/running/failed/todo)时,
161
+ // 无计划产物可评审,盲目建 PT 只会空转浪费 token。此时把阻塞/未完成状态提给主 agent
162
+ // (system 评论,含 kb-insufficient 等 reason),等 P 恢复 done 后再建 PT。
163
+ if (pTask && pTask.status !== 'done' && pTask.status !== 'archived') {
164
+ if (pTask.status === 'blocked') {
165
+ const lastBlock = [...fresh.events].reverse().find((e) => e.taskId === pTask.id && e.kind === 'task/blocked');
166
+ const reason = lastBlock ? String(lastBlock.payload['reason'] ?? '') : '';
167
+ await this.kanban.comment(pTask.id, `[blocked-p] P 任务 ${pTask.id} 处于 blocked,暂不创建 PT 卡(避免无产物空转)。阻塞原因:${reason || '(未知)'}。请主 agent 处理后 unblock,P 恢复 done 后再评审。`, 'system');
168
+ }
169
+ return; // 等 P 终态(不建 PT、不推进 phase)
170
+ }
171
+ const pHandoff = pTask ? fresh.handoffs.get(pTask.id) : null;
172
+ const decision = pHandoff?.metadata?.['pt_decision'];
173
+ if (decision && decision.needed === false) {
174
+ orch.phase = this.advance(orch.phase);
175
+ orch.waitingOn = 'task/completed';
176
+ continue;
177
+ }
178
+ }
179
+ }
180
+ const expect = R20_PHASE_EXPECT[orch.phase];
181
+ if (expect === null)
182
+ return; // summary:不再建卡
183
+ const chainTasks = [...state.tasks.values()].filter((t) => t.chainId === chainId);
184
+ const terminal = ['done', 'archived'];
185
+ // 评审阶段(pt/dt):已完成评审卡按 verdict 分流——
186
+ // pass → recordReview(passed)+推进下一阶段;fail → recordReview(failed)+返工(新评审卡)或超限 gave-up。
187
+ if (orch.phase === 'pt' || orch.phase === 'dt') {
188
+ const reviewCards = chainTasks.filter((t) => t.assignee === expect.assignee && t.mode === expect.mode && !isVoidReview(t, state.events));
189
+ const latest = reviewCards.at(-1);
190
+ if (latest && !terminal.includes(latest.status))
191
+ return; // 有在途评审卡 → 待命
192
+ if (latest && terminal.includes(latest.status)) {
193
+ const outcome = await this.handleReviewCompletion(chainId, orch, latest, state);
194
+ if (outcome === 'advanced') {
195
+ orch.phase = this.advance(orch.phase);
196
+ orch.waitingOn = 'task/completed';
197
+ continue; // pass:推进 phase 后继续循环建下一阶段卡
198
+ }
199
+ return; // rework/gave-up:phase 不变,等返工链(不推进)
200
+ }
201
+ // 无评审卡 → 建卡(走下方通用建卡路径,但建卡后不推进 phase)
202
+ }
203
+ // B6 幂等:当前 phase 期望卡(R20_PHASE_EXPECT 匹配 assignee+mode)已存在且未终态 → 不重复建卡直接待命
204
+ // (防插件重启后重复建卡、V 一轮连发多卡)。
205
+ const existing = chainTasks.find((t) => t.assignee === expect.assignee && t.mode === expect.mode && !isVoidReview(t, state.events));
206
+ if (existing && !terminal.includes(existing.status))
207
+ return;
208
+ // 仅当当前 phase 的 (assignee,mode) 在整个 R20 序列唯一时,"已终态 existing 卡 = 本阶段自己的卡已完成"
209
+ // 才成立;w2/w3 同为 (w,kb) 时 existing 可能是上一阶段(w2)的卡,不能据此推进(否则 w3 被误跳过)。
210
+ const sameExpectCount = R20_PHASE_ORDER.filter((p) => {
211
+ const e = R20_PHASE_EXPECT[p];
212
+ return e !== null && e.assignee === expect.assignee && e.mode === expect.mode;
213
+ }).length;
214
+ if (existing && terminal.includes(existing.status) && sameExpectCount === 1) {
215
+ // 该阶段工作已完成但 phase 未推进(firstMatch 历史缺陷等)→ 直接推进并继续循环建下一阶段卡
216
+ orch.phase = this.advance(orch.phase);
217
+ orch.waitingOn = 'task/completed';
218
+ continue;
219
+ }
220
+ // 语义父任务:从链上已终态任务推断当前阶段的输入来源(如 pt→p、w2→p、d→w2、dt→d),
221
+ // 使 V 建卡显式携带 parents;即便 V 漏填,createTask 兜底也会自动补上(双保险)。
222
+ const parents = resolveTaskParents(state.tasks.values(), chainId, expect.assignee, expect.mode);
223
+ // 前置校验(上游对下游负责,举一反三):建下游卡前确认各语义父卡已交付其阶段关键交付物
224
+ // (如 D 依赖 W2 的 kb_url+page_path)。父卡能到此步必然终态(done/archived)——completeTask
225
+ // 交付契约闸已对所有 actor(含 human 强制收尾)在完成时拦截(缺交付物会先被标 blocked,
226
+ // 不会被 resolve 成父卡),故此处仅剩 legacy(改闸前已落盘的 done-but-missing)。done 不可变、
227
+ // 不能重标 blocked,发 system 评论记录断裂并停住:不建下游卡、不推进 phase,避免拖到下游执行时才报错。
228
+ const missingParents = missingParentDelivery(state, parents);
229
+ if (missingParents.length > 0) {
230
+ for (const mp of missingParents) {
231
+ await this.kanban.comment(mp.taskId, `[delivery-required] 上游 ${mp.assignee}/${mp.mode} 缺关键交付物:${mp.missing.join(', ')},下游 ${expect.assignee}/${expect.mode} 卡未创建,请补交交付物或人工处理。`, 'system');
232
+ }
233
+ return;
234
+ }
235
+ // PT 阶段注入 P 判定需要计划评审的理由(供 V 写入 PT 卡 body 引用评审上下文)
236
+ const ptReason = orch.phase === 'pt' ? extractPtReason(state, chainId) : '';
237
+ const context = [
238
+ '# V 编排轮次(R20 逐阶段创建)',
239
+ `chain=${chainId} phase=${orch.phase}`,
240
+ `NEXT_TASK_ASSIGNEE=${expect.assignee} MODE=${expect.mode}`,
241
+ `PARENT_DEPS=${parents.length > 0 ? parents.join(',') : '(无)'}`,
242
+ // M5:D 阶段额外注入 testing 段(执行指令依赖 solution/testing);附件 ref 供 V 取真实仓库路径写 TARGET_REPO
243
+ specCard ? `## 规格卡\n${specCard.sections.problem}\n${specCard.sections.solution}${orch.phase === 'd' ? '\n' + specCard.sections.testing : ''}\n附件:${specCard.attachments.map((a) => `${a.kind}:${a.ref}`).join(' | ') || '(无)'}` : '',
244
+ '## 当前任务\n' + [...state.tasks.values()].filter((t) => t.chainId === chainId)
245
+ .map((t) => {
246
+ const lastBlock = [...state.events].reverse().find((e) => e.taskId === t.id && e.kind === 'task/blocked');
247
+ const reason = t.status === 'blocked' && lastBlock ? ` (${String(lastBlock.payload['reason'] ?? '')})` : '';
248
+ return `${t.id} ${t.assignee}/${t.mode} ${t.status}${reason}`;
249
+ }).join('\n'),
250
+ '## 立即动作(本轮唯一任务)',
251
+ `调用 kanban_create 创建本阶段唯一任务卡:chainId=${chainId},assignee=${expect.assignee},mode=${expect.mode},parents=${JSON.stringify(parents)},title 自拟(按本阶段语义命名),body 按下述阶段要求撰写。`,
252
+ PHASE_INSTRUCTIONS[orch.phase] ?? '',
253
+ (ptReason ? '## P 判定需要计划评审的理由\n' + ptReason : ''),
254
+ '规则:只创建一张卡(上一阶段完成事件后才进入下一阶段);禁止跨阶段并行;禁止自己实现任务;不要调用 kanban_heartbeat/kanban_list 探测(看板状态已在上文给出)。',
255
+ ].join('\n\n');
256
+ const agent = await this.getVAgent(orch);
257
+ agent.followup({ content: [{ type: 'text', text: context }], source: { kind: 'user' } });
258
+ await agent.whenIdle();
259
+ // R4 建卡数量硬闸:本轮只允许一张期望匹配卡推进 phase——取第一张匹配卡,其余建卡不推进
260
+ // (提取 kanban_create 调用;假实现从会话事件取,真实实现同名)。
261
+ // 修复轮 6:session.events 条目形态为 {type, data:{name, arguments}},name 在 data 下且
262
+ // arguments 是 JSON 字符串——统一经 toolName/toolArgs(src/dispatcher/session-events.ts)读取。
263
+ const creates = agent.session.events.filter((e) => toolName(e) === 'kanban_create');
264
+ const firstMatch = creates.find((e) => {
265
+ const a = toolArgs(e);
266
+ return a.assignee === expect.assignee && a.mode === expect.mode;
267
+ });
268
+ // 建卡失败防护(修复轮 7):V 本轮未产生期望卡(assignee+mode 不匹配)→ 记 stall 轮次;
269
+ // 连续 2 轮未产出期望卡 → 在链上锚点卡(最近终态卡,无则最新卡)发 [create-failed] system 评论后停住
270
+ // (幂等:锚点卡已有 [create-failed] 评论则不再发)。
271
+ if (!firstMatch) {
272
+ orch.stallCount = (orch.stallCount ?? 0) + 1;
273
+ if (orch.stallCount >= 2) {
274
+ const anchor = chainTasks.filter((t) => terminal.includes(t.status)).at(-1) ?? chainTasks.at(-1);
275
+ if (anchor && !state.events.some((e) => e.taskId === anchor.id && e.kind === 'task/commented' && String(e.payload['body'] ?? '').startsWith('[create-failed]'))) {
276
+ await this.kanban.comment(anchor.id, `[create-failed] 阶段 ${orch.phase} 建卡未产生期望卡(assignee=${expect.assignee}, mode=${expect.mode})。请检查工具 schema/可用性后人工处理。`, 'system');
277
+ }
278
+ }
279
+ return;
280
+ }
281
+ orch.stallCount = 0;
282
+ if (firstMatch) {
283
+ // 评审阶段(pt/dt):建卡后不推进 phase(等评审 verdict 分流,pass 才推进);
284
+ // 普通阶段:建卡后推进 phase(生产上由 task/completed 事件串行唤醒下一阶段)。
285
+ if (orch.phase !== 'pt' && orch.phase !== 'dt') {
286
+ orch.phase = this.advance(orch.phase);
287
+ }
288
+ orch.waitingOn = 'task/completed';
289
+ }
290
+ // 建卡后停止本轮,等本阶段完成事件推进下一阶段(不跨阶段并行建卡)
291
+ return;
292
+ }
293
+ }
294
+ /** 阻塞复核幂等判定:任务最近一次 task/blocked 之后已存在 [blocked-review] 开头的评论。
295
+ * 注:at 为 Date.now() 毫秒精度,block 与评论可能同毫秒(测试/快路径实测碰撞)→ 用 seq 比较(确定性)。 */
296
+ hasBlockReview(state, t) {
297
+ const lastBlockSeq = [...state.events].reverse().find((e) => e.taskId === t.id && e.kind === 'task/blocked')?.seq ?? -1;
298
+ return state.events.some((e) => e.taskId === t.id && e.kind === 'task/commented' && e.seq > lastBlockSeq &&
299
+ String(e.payload['body'] ?? '').startsWith('[blocked-review]'));
300
+ }
301
+ /** 评审卡 completed 处理(交付质量链):读 handoff 的 review_evidence verdict 分流。
302
+ * pass → recordReview(passed) + 推进;fail → recordReview(failed) + createReworkTask + 新建复审卡;
303
+ * reviewAttempt ≥ maxReworksPerRole → review/gave-up + [review-final] 证据链(链保持)。
304
+ * 严禁对已完成的上游 P/D 调 blockTask(done 不可变;返工走新 rework 卡)。 */
305
+ async handleReviewCompletion(chainId, orch, reviewTask, state) {
306
+ if (isVoidReview(reviewTask, state.events))
307
+ return 'gave-up'; // 作废评审卡不处理(编排层已过滤,此处双保险)
308
+ const role = orch.phase === 'pt' ? 'pt' : 'dt';
309
+ const handoff = state.handoffs.get(reviewTask.id);
310
+ const evidence = (handoff?.metadata?.['review_evidence']);
311
+ if (!evidence)
312
+ return 'gave-up'; // 无证据(异常路径;完成闸已拦)
313
+ // 幂等:该评审卡已有 review 事件(重启恢复)→ 按 passed 判定推进,否则保持
314
+ const hasReviewEvent = (kind) => state.events.some((e) => e.taskId === reviewTask.id && e.kind === kind);
315
+ if (hasReviewEvent('review/passed') || hasReviewEvent('review/failed') || hasReviewEvent('review/gave-up')) {
316
+ return hasReviewEvent('review/passed') ? 'advanced' : 'rework';
317
+ }
318
+ // 被评审任务:复审卡经 reworkOfTaskId/parents 指向 rework 任务;首次评审回退到阶段源任务(done)
319
+ const srcAssignee = role === 'pt' ? 'p' : 'd';
320
+ const srcMode = role === 'pt' ? 'openspec' : 'execute';
321
+ let currentTarget = reviewTask.reworkOfTaskId
322
+ ? state.tasks.get(reviewTask.reworkOfTaskId)
323
+ : reviewTask.parents[0]
324
+ ? state.tasks.get(reviewTask.parents[0])
325
+ : undefined;
326
+ if (!currentTarget) {
327
+ currentTarget = [...state.tasks.values()].find((t) => t.chainId === chainId && t.assignee === srcAssignee && t.mode === srcMode && t.status === 'done');
328
+ }
329
+ if (!currentTarget)
330
+ return 'gave-up';
331
+ // root = 沿 reworkOfTaskId 链到顶(原任务),reviewStatus 落在原任务上
332
+ let root = currentTarget;
333
+ while (root.reworkOfTaskId && state.tasks.get(root.reworkOfTaskId))
334
+ root = state.tasks.get(root.reworkOfTaskId);
335
+ if (evidence.verdict === 'pass') {
336
+ await this.kanban.recordReview(reviewTask.id, root.id, evidence, 'system');
337
+ return 'advanced';
338
+ }
339
+ // fail
340
+ await this.kanban.recordReview(reviewTask.id, root.id, evidence, 'system');
341
+ const maxR = this.config.dispatcher?.maxReworksPerRole?.[role] ?? (role === 'pt' ? 2 : 3);
342
+ if ((currentTarget.reviewAttempt ?? 0) >= maxR) {
343
+ await this.kanban.reviewGaveUp(reviewTask.id, root.id, 'exceeded max reworks (' + maxR + ')', 'system');
344
+ // [review-final] 证据链:评审时间线 + 最终原因(system 确定性写入)
345
+ const timeline = state.events
346
+ .filter((e) => e.taskId === root.id || e.taskId === currentTarget.id)
347
+ .map((e) => ` - seq=${e.seq} ${e.kind} at=${e.at}`)
348
+ .join('\n') || ' - (无)';
349
+ await this.kanban.comment(reviewTask.id, [
350
+ '[review-final] 评审超限(' + role + ' gave-up after ' + maxR + ' reworks),不再返工。',
351
+ '## 评审时间线',
352
+ timeline,
353
+ '最终原因: exceeded max reworks (' + maxR + ')',
354
+ ].join('\n'), 'system');
355
+ return 'gave-up';
356
+ }
357
+ // 未超限:createReworkTask(原任务保持 done)+ 新建复审卡(parents=rework,reviewAttempt=rework.reviewAttempt)
358
+ const rework = await this.kanban.createReworkTask({ sourceTaskId: currentTarget.id, reviewTaskId: reviewTask.id, reason: 'review failed' }, 'system');
359
+ await this.kanban.createTask({
360
+ chainId,
361
+ title: role === 'pt' ? '计划复审' : '实现复审',
362
+ assignee: role,
363
+ mode: role === 'pt' ? 'review-plan' : 'review-impl',
364
+ parents: [rework.id],
365
+ reviewAttempt: rework.reviewAttempt,
366
+ }, 'v');
367
+ return 'rework';
368
+ }
369
+ advance(phase) {
370
+ const i = R20_PHASE_ORDER.indexOf(phase);
371
+ return R20_PHASE_ORDER[Math.min(i + 1, R20_PHASE_ORDER.length - 1)];
372
+ }
373
+ async getVAgent(orch) {
374
+ // B3:resume 与 create 都传 setup——恢复的 V 会话同样装配角色工具面(agent scope 注册随会话重建),
375
+ // 与 agent-runner.ts 的 installRoleTools 用法一致。
376
+ // 修复轮 6:V 后台编排会话与 P/W/D 一致,显式设置 approval=never + sandbox=workspace-write,
377
+ // 避免在无 preset 装配时因默认审批策略在后台无应答者而挂起(卡死调度器首轮 tick)。
378
+ const setup = async (agentCtx) => {
379
+ // 思考等级强制(waterfall):与 agent-runner 同缺陷——宿主 selection 无 create-options 覆盖层,
380
+ // agentOptions.reasoningEffort 不被消费,V 编排会话思考等级会落回宿主默认。走 DSH agent/request
381
+ // waterfall 逐请求强制 'high'(宿主 installModelSelection 同机制),作用域仅本 V 会话。
382
+ const scoped = agentCtx;
383
+ scoped.on('agent/request', async (_payload, next) => {
384
+ // 异常不吞:await next() 失败原样向上抛
385
+ const resolved = await next();
386
+ return { ...resolved, reasoningEffort: 'high' };
387
+ });
388
+ const session = agentCtx.agent?.session;
389
+ session?.append?.('approval/policy', { policy: 'never', source: 'delegation' });
390
+ session?.append?.('sandbox/mode', { mode: 'workspace-write', source: 'delegation' });
391
+ // R21 对齐(2026-08-17):V=butler·orchestrator 零执行能力——先挂 kanban-v 裁剪 preset
392
+ // (组合仅 persona + agent-instructions,无 bash/fs/run_code/web/skill/delegation 等执行/探索工具),
393
+ // 再注入 kanban/spec 工具面;从基座层面落实「V 只路由、不执行」。
394
+ const presets = agentCtx.get?.('agentPresets');
395
+ if (presets) {
396
+ try {
397
+ await presets.mount(agentCtx, 'kanban-v');
398
+ }
399
+ catch (err) {
400
+ // preset 挂载失败不阻断:角色工具面仍注册,仅缺 persona/instructions 基座
401
+ console.error('[dsh-swarm][debug] V preset mount failed kanban-v: ' + String(err));
402
+ }
403
+ }
404
+ await installRoleTools(agentCtx, 'v', { kanban: this.kanban, wiki: this.wiki });
405
+ };
406
+ // 模型候选链(Task 12):V 会话 create/resume 按 primary→fallbacks 静默切换;
407
+ // V 无任务卡可 block——全候选不可用抛最后错误(wakeV 调用方按既有错误路径处理)。
408
+ const candidates = buildModelCandidates(this.config, 'v', this.defaultModel);
409
+ const spawnWith = async (opts) => {
410
+ if (orch.sessionId) {
411
+ // 修复轮 6:V 会话首轮创建后保持 live,resume 会抛 "cannot prepare session while it is live"。
412
+ // 优先复用 agents registry 中仍 live 的会话(followup 续用),仅当会话已下线时才 resume。
413
+ const live = this.agents.get?.(orch.sessionId);
414
+ if (live)
415
+ return live;
416
+ const h = await this.agents.resume({ resumeSessionId: orch.sessionId, ...opts, setup });
417
+ return h.agent;
418
+ }
419
+ // M2(Q5)+归组:V 编排会话创建在主 agent 工作空间;缺失时询问用户注册工作区,仍不可得 → 抛错(dispatcher 捕获日志,V 待命)。
420
+ let ws = await this.chainWorkspace(orch.chainId);
421
+ if (!ws) {
422
+ ws = await resolveOrCreateWorkspace(this.ctx, null, 'chain ' + orch.chainId + ' V');
423
+ }
424
+ if (!ws)
425
+ throw new Error('workspace-unknown: chain ' + orch.chainId + ' 无 workspaceDir,需重新 /plan:');
426
+ const h = await this.agents.create({ sessionId: `kbn-v-${orch.chainId}`, meta: { cwd: ws }, ...opts, setup });
427
+ orch.sessionId = `kbn-v-${orch.chainId}`;
428
+ await attachSessionToWorkspace(this.ctx, `kbn-v-${orch.chainId}`, ws, 'chain ' + orch.chainId + ' V');
429
+ return h.agent;
430
+ };
431
+ if (candidates.length === 0)
432
+ return spawnWith({});
433
+ let lastErr = null;
434
+ for (const candidate of candidates) {
435
+ try {
436
+ return await spawnWith({ agentOptions: candidate });
437
+ }
438
+ catch (err) {
439
+ lastErr = err;
440
+ if (!isModelUnavailableError(err))
441
+ throw err; // 非 model 错误立即失败
442
+ console.error('[dsh-swarm][debug] V model candidate unavailable ' + String(candidate.provider) + '/' + String(candidate.model) + ': ' + String(err));
443
+ }
444
+ }
445
+ throw lastErr;
446
+ }
447
+ /** M2(Q5)+归组:链的 workspaceDir(发起 /plan: 的主 agent 工作空间);缺失返回 null(调用方询问/报错,不落 kanban 存储)。 */
448
+ async chainWorkspace(chainId) {
449
+ const state = await this.kanban.snapshot();
450
+ return state.chains.get(chainId)?.workspaceDir ?? null;
451
+ }
452
+ }
@@ -0,0 +1,15 @@
1
+ import type { KanbanService } from '../domain/kanban-service.js';
2
+ /** 心跳超时回收(running 无心跳 → failed,可重试)。
3
+ * failed 任务的熔断(attempts≥maxRetries → blocked(gave_up))与重派由调度器(Dispatcher.tick)统一处理(B1)。 */
4
+ export declare class Watchdog {
5
+ private timer;
6
+ private readonly kanban;
7
+ private readonly cfg;
8
+ constructor(kanban: KanbanService, cfg: {
9
+ staleTimeoutSeconds: number;
10
+ maxRetries: number;
11
+ });
12
+ tick(now?: number): Promise<void>;
13
+ start(intervalMs: number): void;
14
+ stop(): void;
15
+ }
@@ -0,0 +1,30 @@
1
+ /** 心跳超时回收(running 无心跳 → failed,可重试)。
2
+ * failed 任务的熔断(attempts≥maxRetries → blocked(gave_up))与重派由调度器(Dispatcher.tick)统一处理(B1)。 */
3
+ export class Watchdog {
4
+ timer = null;
5
+ kanban;
6
+ cfg;
7
+ constructor(kanban, cfg) { this.kanban = kanban; this.cfg = cfg; }
8
+ async tick(now = Date.now()) {
9
+ const state = await this.kanban.snapshot();
10
+ for (const t of state.tasks.values()) {
11
+ if (t.status === 'running') {
12
+ const lastBeat = t.heartbeats.at(-1) ?? state.events.find((e) => e.taskId === t.id && e.kind === 'task/claimed')?.at ?? now;
13
+ if (now - lastBeat > this.cfg.staleTimeoutSeconds * 1000) {
14
+ await this.kanban.failTask(t.id, 'stale-reclaim', 'system'); // P0-5:超时回收发 failed(可重试),不直接 block
15
+ }
16
+ }
17
+ // failed 任务不在此处理:Dispatcher.tick 负责 attempts<maxRetries 重派 / attempts≥maxRetries 熔断
18
+ }
19
+ }
20
+ start(intervalMs) {
21
+ this.stop();
22
+ this.timer = setInterval(() => { void this.tick(); }, intervalMs);
23
+ }
24
+ stop() {
25
+ if (this.timer) {
26
+ clearInterval(this.timer);
27
+ this.timer = null;
28
+ }
29
+ }
30
+ }
@@ -0,0 +1,40 @@
1
+ import type { Context } from '@deepseek-ai/cordis';
2
+ /** duck-typed Workspace 实体面(dsh-workspace 的 create()/resolveByPath() 返回物;attachSession 在实体上,不在 registry 上)。 */
3
+ export interface WorkspaceEntityLike {
4
+ id: string;
5
+ attachSession(sessionId: unknown): Promise<void>;
6
+ }
7
+ /** duck-typed workspaceRegistry 服务面(dsh-workspace 注册为 ctx.workspaceRegistry;无 attachSession)。 */
8
+ export interface WorkspaceRegistryLike {
9
+ resolveByPath(path: string): Promise<WorkspaceEntityLike | undefined>;
10
+ create(path: string, title?: string): Promise<WorkspaceEntityLike>;
11
+ }
12
+ /** duck-typed userQuestions 服务面(dsh-user-questions 注册为 ctx.userQuestions;custom 为自由文本 Other 答案)。 */
13
+ export interface UserQuestionsLike {
14
+ ask(req: {
15
+ questions: Array<{
16
+ id: string;
17
+ question: string;
18
+ detail?: string;
19
+ header?: string;
20
+ options?: Array<{
21
+ label: string;
22
+ description?: string;
23
+ }>;
24
+ }>;
25
+ }): Promise<{
26
+ answers: Array<{
27
+ id: string;
28
+ selected: string[];
29
+ custom?: string;
30
+ }>;
31
+ }>;
32
+ }
33
+ /**
34
+ * 返回可用的会话工作目录路径。
35
+ * - cwd 已知:优先复用已注册工作区;未注册则询问用户(默认创建 @cwd)自动 create;询问失败/跳过仍返回 cwd(目录正确性优先,归组是 bonus)。
36
+ * - cwd 未知:必须经询问拿到路径并 create;无路径/无通道/无服务 → null(调用方 block 或待命)。
37
+ */
38
+ export declare function resolveOrCreateWorkspace(ctx: Context, cwd: string | null | undefined, label: string): Promise<string | null>;
39
+ /** 把已创建/恢复的会话归组到 cwd 对应工作区;未注册时询问用户(仅明确「创建」才 create);全程失败静默(归组是 UX 增强,不阻断任务)。 */
40
+ export declare function attachSessionToWorkspace(ctx: Context, sessionId: string, cwd: string, label: string): Promise<void>;