@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,430 @@
1
+ import { project, applyTo } from './projection.js';
2
+ import { can } from './permissions.js';
3
+ import { hasDeliveryEvidence, hasTddDeclaration } from './delivery-evidence.js';
4
+ import { missingDeliveryKeys } from './delivery-contract.js';
5
+ import { validateReviewEvidence } from './review-evidence.js';
6
+ import { resolveTaskParents, nonTerminalSemanticParents } from './task-parents.js';
7
+ let seqCounter = 0;
8
+ const nid = (p) => `${p}_${(++seqCounter).toString(36)}_${Date.now().toString(36)}`;
9
+ /** 首句:trim 后按首个换行或「。?! 」截断;超长兜底 40 字(T7 需求标题规范化)。 */
10
+ export function firstSentence(text) {
11
+ const t = text.trim();
12
+ const cut = t.search(/[\n。?!]/);
13
+ const s = cut === -1 ? t : t.slice(0, cut);
14
+ return s.trim().slice(0, 40);
15
+ }
16
+ /** 默认链标题:【需求】<一句话需求描述>。来源优先级 /plan: rest 首句 → checklist.problem 首句 → 未命名需求。 */
17
+ export function buildChainTitle(requirementName, _openspecRest, problem) {
18
+ const src = (requirementName && requirementName.trim()) || problem.trim();
19
+ return `【需求】${src ? firstSentence(src) : '未命名需求'}`;
20
+ }
21
+ /** 看板领域门面:三界面(工具/CLI/UI)统一路由的唯一入口。 */
22
+ export class KanbanService {
23
+ state;
24
+ store;
25
+ // Q4:kb_url host 前缀硬校验基准(config.wikiVault.baseUrl)。null = 不校验前缀(兼容测试/旧调用)。
26
+ kbUrlBase;
27
+ emitQueue = Promise.resolve();
28
+ listeners = new Set();
29
+ // D23:链完成验收核对钩子(dispatcher 注入:读主会话事件/产物归属核对 → auditWarning)
30
+ onChainCompletedHook = null;
31
+ // Q3&5:W2/W3 完成互链登记钩子(dispatcher 注入:拿 page_path → 机械写三方互链,失败不阻塞完成)
32
+ onTaskCompletedHook = null;
33
+ constructor(store, kbUrlBase) {
34
+ this.store = store;
35
+ this.kbUrlBase = kbUrlBase ?? null;
36
+ // P0-3:同步重投影,消除"构造后立即调用基于空状态"的竞态
37
+ this.state = project(store.readAllSync());
38
+ }
39
+ async emit(ev) {
40
+ let emitted;
41
+ const pending = this.emitQueue.then(async () => {
42
+ const candidate = applyTo(this.state, { ...ev, seq: -1 });
43
+ const full = await this.store.append(ev);
44
+ this.state = { ...candidate, events: [...candidate.events.slice(0, -1), full] };
45
+ emitted = full;
46
+ this.publish(full);
47
+ });
48
+ this.emitQueue = pending.catch(() => { });
49
+ await pending;
50
+ return emitted;
51
+ }
52
+ /** D23:注入链完成核对钩子(由调度层设置;仅一个消费者)。 */
53
+ setOnChainCompleted(hook) {
54
+ this.onChainCompletedHook = hook;
55
+ }
56
+ /** Q3&5:注入任务完成互链登记钩子(由调度层设置;仅一个消费者)。 */
57
+ setOnTaskCompleted(hook) {
58
+ this.onTaskCompletedHook = hook;
59
+ }
60
+ /** T22:订阅持久化后的看板事件;返回解除订阅函数。listener 异常不影响已落盘状态。 */
61
+ subscribe(listener) {
62
+ this.listeners.add(listener);
63
+ return () => { this.listeners.delete(listener); };
64
+ }
65
+ /** T22:返回 seq >= 入参 的事件(与 EventStore.readSince 同为 inclusive 语义)。 */
66
+ async eventsSince(seq) {
67
+ return this.store.readSince(seq);
68
+ }
69
+ publish(event) {
70
+ for (const listener of [...this.listeners]) {
71
+ try {
72
+ listener(event);
73
+ }
74
+ catch (error) {
75
+ console.error('[dsh-swarm] event listener failed', error);
76
+ }
77
+ }
78
+ }
79
+ async chainOf(chainId) {
80
+ const c = this.state.chains.get(chainId);
81
+ if (!c)
82
+ throw new Error('unknown chain: ' + chainId);
83
+ return c;
84
+ }
85
+ async taskOf(taskId) {
86
+ const t = this.state.tasks.get(taskId);
87
+ if (!t)
88
+ throw new Error('unknown task: ' + taskId);
89
+ return t;
90
+ }
91
+ async createChain(input, actor) {
92
+ if (!can('create-chain', actor, null))
93
+ throw new Error('permission denied');
94
+ // 工具边界硬拦:空标题链不得入库
95
+ if (!input.title || !input.title.trim())
96
+ throw new Error('title required');
97
+ const chain = { id: nid('ch'), title: input.title, status: 'planning', rootTaskId: null, specCardId: null, ownerSessionId: input.ownerSessionId, workspaceDir: input.workspaceDir ?? null, createdAt: Date.now() };
98
+ await this.emit({ chainId: chain.id, taskId: null, kind: 'chain/created', payload: { ...chain }, author: actor, at: Date.now() });
99
+ return chain;
100
+ }
101
+ async createSpecCard(chainId, sections, actor) {
102
+ if (!can('spec-edit', actor, null))
103
+ throw new Error('permission denied');
104
+ const card = { id: nid('sc'), chainId, status: 'draft', sections, attachments: [], rawDialogueRef: null, approvedAt: null, approvedBy: null };
105
+ await this.emit({ chainId, taskId: null, kind: 'spec-card/created', payload: { ...card }, author: actor, at: Date.now() });
106
+ return card;
107
+ }
108
+ async editSpecCard(cardId, sections, actor) {
109
+ if (!can('spec-edit', actor, null))
110
+ throw new Error('permission denied');
111
+ const card = this.state.specCards.get(cardId);
112
+ if (!card || card.status !== 'draft')
113
+ throw new Error('spec card not editable');
114
+ const updated = { ...card, sections };
115
+ await this.emit({ chainId: card.chainId, taskId: null, kind: 'spec-card/edited', payload: { ...updated }, author: actor, at: Date.now() });
116
+ return updated;
117
+ }
118
+ async approveSpecCard(cardId, actor) {
119
+ if (!can('spec-approve', actor, null))
120
+ throw new Error('permission denied');
121
+ const card = this.state.specCards.get(cardId);
122
+ if (!card)
123
+ throw new Error('unknown spec card: ' + cardId);
124
+ await this.emit({ chainId: card.chainId, taskId: null, kind: 'spec-card/approved', payload: { id: cardId }, author: actor, at: Date.now() });
125
+ const updated = this.state.specCards.get(cardId);
126
+ // 规格卡批准 → 链路进入 executing(语义正确的事件:chain/executing)
127
+ await this.emit({ chainId: card.chainId, taskId: null, kind: 'chain/executing', payload: {}, author: actor, at: Date.now() });
128
+ return updated;
129
+ }
130
+ async createTask(input, actor) {
131
+ if (!can('create-task', actor, null))
132
+ throw new Error('permission denied');
133
+ // 工具边界硬拦:provided must be legal——空标题卡不得入库(GUI 曾现空标题链/卡)
134
+ if (!input.title || !input.title.trim())
135
+ throw new Error('title required');
136
+ const chain = await this.chainOf(input.chainId);
137
+ const explicitParents = input.parents && input.parents.length > 0;
138
+ // P0 硬拦(防御纵深):调用方未显式指定 parents(V 建全新阶段卡)时,按语义依赖查上游非终态——
139
+ // 上游卡还卡着/未走完则拒绝建卡(防「P blocked 后仍建 PT」类事故)。显式 parents 的调用
140
+ // (复审卡 parents=[rework.id]、createReworkTask)由调用方决定接链,不在此拦。
141
+ if (!explicitParents) {
142
+ const blockers = nonTerminalSemanticParents(this.state.tasks.values(), input.chainId, input.assignee, input.mode);
143
+ if (blockers.length > 0) {
144
+ const b = blockers.map((t) => `${t.assignee}/${t.mode}(${t.id}, ${t.status})`).join(', ');
145
+ throw new Error(`create-task rejected: 上游 ${b} 未终态,下游 ${input.assignee}/${input.mode} 卡不能创建`);
146
+ }
147
+ }
148
+ // 语义 parents 兜底:调用方(V 建卡)未显式指定 parents 时,按 R20 依赖自动接链上终态父任务,
149
+ // 保证「父交接注入」通道闭合(如 w2 接 P、dt 接 d)。兜底对已显式传 parents 的
150
+ // 调用(复审卡 parents=[rework.id]、createReworkTask)不生效。
151
+ const parents = input.parents && input.parents.length > 0
152
+ ? input.parents
153
+ : resolveTaskParents(this.state.tasks.values(), input.chainId, input.assignee, input.mode);
154
+ const task = { id: nid('t'), chainId: input.chainId, title: input.title, body: input.body ?? '', assignee: input.assignee, status: 'todo', mode: input.mode, priority: 1, parents, children: [], createdBy: actor === 'human' ? 'human' : 'v', attempts: 0, heartbeats: [], sessionId: '', reworkOfTaskId: null, resumeSessionId: null, reviewAttempt: input.reviewAttempt ?? 0, reviewStatus: 'not-required' };
155
+ task.sessionId = 'kbn-' + task.id; // 确定性会话 id:与角色会话 id 一致,供追踪定位与 resume
156
+ await this.emit({ chainId: input.chainId, taskId: task.id, kind: 'task/created', payload: { ...task }, author: actor, at: Date.now() });
157
+ if (chain.rootTaskId === null) {
158
+ await this.emit({ chainId: input.chainId, taskId: task.id, kind: 'chain/root-task-set', payload: { rootTaskId: task.id }, author: actor, at: Date.now() });
159
+ }
160
+ return task;
161
+ }
162
+ async claimTask(taskId, actor) {
163
+ if (!can('claim', actor, null))
164
+ throw new Error('permission denied');
165
+ const t = this.state.tasks.get(taskId);
166
+ if (!t)
167
+ throw new Error('unknown task: ' + taskId);
168
+ await this.emit({ chainId: t.chainId, taskId, kind: 'task/claimed', payload: {}, author: actor, at: Date.now() });
169
+ return this.state.tasks.get(taskId);
170
+ }
171
+ async completeTask(taskId, handoff, actor, opts = {}) {
172
+ const t = this.state.tasks.get(taskId);
173
+ if (!t)
174
+ throw new Error('unknown task: ' + taskId);
175
+ if (!can('complete', actor, t, opts))
176
+ throw new Error('permission denied');
177
+ if (!handoff.summary.trim())
178
+ throw new Error('handoff summary required');
179
+ // C2:D(execute) 完成必须带 git 产物证据(changed_files + commit/push 至少其一)——
180
+ // 执行者语义硬校验;human 为信任锚(GUI 强制收尾)可豁免,但 C1 链完成门禁仍会拦截无证据链。
181
+ if (t.assignee === 'd' && t.mode === 'execute' && actor !== 'human' && !hasDeliveryEvidence(handoff)) {
182
+ throw new Error('delivery evidence required: D(execute) complete must carry changed_files + (commit_hash|push)');
183
+ }
184
+ // TDD 声明闸(2026-08-26):D(execute) 完成必须带 tdd 声明(test_files 非空 或 skipped.reason 非空),
185
+ // 否则抛错——防止 D 完全不声明测试;顺序真伪由 DT 评审闸兜底。human 信任锚可豁免(同 C2)。
186
+ if (t.assignee === 'd' && t.mode === 'execute' && actor !== 'human' && !hasTddDeclaration(handoff)) {
187
+ throw new Error('tdd declaration required: D(execute) complete must carry tdd = { test_files: [...] } or tdd = { skipped: { reason } }');
188
+ }
189
+ // 评审证据闸:PT/DT 完成必须带机械校验合法的 review_evidence(缺证据拒绝 pass)。
190
+ // human 为信任锚(GUI 强制收尾)可豁免。
191
+ if ((t.assignee === 'pt' || t.assignee === 'dt') && actor !== 'human') {
192
+ const missing = validateReviewEvidence(t.assignee === 'pt' ? 'pt' : 'dt', handoff);
193
+ if (missing.length > 0) {
194
+ throw new Error('review evidence required: ' + missing.join(', '));
195
+ }
196
+ }
197
+ // 交付契约闸(上游对下游负责):W/P 完成必须带其阶段交付物(W2-W3 kb_url+page_path /
198
+ // P artifacts_path + pt_decision)。缺失交付物 → 直接 kanban_block(running→blocked 标在缺交付的「当前角色」卡上),
199
+ // 而非仅报错留 running 等下游(如 D 读 W2 的 page_path)执行时才暴露断裂。blocked ≠ done,V 的
200
+ // resolveTaskParents 只取 done/archived 父卡,故 V 永远不会拿该父卡推进建下游卡;task/blocked 事件
201
+ // 同时唤醒 V 走其阻塞复核。与 D(execute)/PT/DT 证据闸不同:交付键是下游硬依赖的机械非空字符串,
202
+ // 无「human 信任锚强制收尾」豁免——human 强制收尾同样必须补齐交付键,否则 W/P 卡直接 blocked,
203
+ // 从源头杜绝 done-but-missing(上游未产出 page_path 就不会成为 done 父卡 → V 不会建 D 卡)。
204
+ // v2:pt_decision 为 P 卡硬键(needed 布尔必填;needed=true 时 reason 必填),缺则 blocked。
205
+ {
206
+ const missing = missingDeliveryKeys(t.assignee, t.mode, handoff, this.kbUrlBase ?? undefined);
207
+ if (missing.length > 0) {
208
+ await this.emit({ chainId: t.chainId, taskId, kind: 'task/blocked', payload: { reason: 'delivery required: ' + missing.join(', ') }, author: 'system', at: Date.now() });
209
+ throw new Error('delivery required: ' + missing.join(', '));
210
+ }
211
+ }
212
+ // v2 断代:w:file 交付键随旧 w1 预取阶段移除,manifest 校验块同步删除
213
+ // (validatePrefetchManifest 仍保留于 prefetch-manifest.ts,供清单 schema 校验复用)。
214
+ await this.emit({ chainId: t.chainId, taskId, kind: 'task/completed', payload: { ...handoff }, author: actor, at: Date.now() });
215
+ // Q3&5:W2/W3 完成 → 调度层互链登记(拿 page_path 机械写三方互链)。
216
+ // 仅 w:kb 触发(P/D/PT/DT 不涉 KB 页互链);钩子内异常不阻断 completeTask(登记失败仅记 warning)。
217
+ if (t.assignee === 'w' && t.mode === 'kb' && this.onTaskCompletedHook) {
218
+ try {
219
+ await this.onTaskCompletedHook(taskId);
220
+ }
221
+ catch (error) {
222
+ console.error('[dsh-swarm] task completion linkage hook failed: ' + String(error));
223
+ }
224
+ }
225
+ // P0-3 链完成机械规则:仅当「最后完成的执行任务是 W3(w/kb)且链上 D(execute) 已 done 且
226
+ // 交付物证据满足(changed_files + commit/push)」且无未终态任务时 → 链 completed(不靠 agent 自觉)。
227
+ // 收紧判据:中间阶段(如 P done 后 W2 尚未创建)无未终态任务也不得误收链。
228
+ // C1:D(execute) 无产物证据 → 不判链完成(human 强制 complete 的 D 卡同样被拦截,防漂移被掩盖)。
229
+ // 'align' 为旧链路兼容(只读对齐/校验语义已废弃,R20 起新卡一律 'execute')。
230
+ const terminal = ['done', 'archived'];
231
+ const chain = this.state.chains.get(t.chainId);
232
+ const chainTasks = [...this.state.tasks.values()].filter((x) => x.chainId === t.chainId);
233
+ const openTasks = chainTasks.filter((x) => !terminal.includes(x.status));
234
+ const dTask = chainTasks.find((x) => (x.mode === 'execute' || x.mode === 'align') && x.status === 'done');
235
+ const dDone = !!dTask;
236
+ const dEvidenceOk = dTask ? (dTask.mode === 'align' ? true : hasDeliveryEvidence(this.state.handoffs.get(dTask.id))) : false;
237
+ const completedEvents = this.state.events.filter((e) => e.chainId === t.chainId && e.kind === 'task/completed' && e.taskId);
238
+ const lastTask = completedEvents.length ? this.state.tasks.get(completedEvents[completedEvents.length - 1].taskId) : undefined;
239
+ const w3Done = !!lastTask && lastTask.assignee === 'w' && lastTask.mode === 'kb' && dDone && dEvidenceOk;
240
+ if (chain && chain.status === 'executing' && openTasks.length === 0 && w3Done) {
241
+ await this.emit({ chainId: t.chainId, taskId: null, kind: 'chain/completed', payload: {}, author: 'system', at: Date.now() });
242
+ // D23:链完成 → 调度层验收核对(主会话越权写产物 → chain/audit-warning)。
243
+ // 钩子内异常不阻断 completeTask 本身(核对失败仅记录,链路仍 completed)。
244
+ if (this.onChainCompletedHook) {
245
+ try {
246
+ await this.onChainCompletedHook(t.chainId);
247
+ }
248
+ catch (error) {
249
+ console.error('[dsh-swarm] chain completion audit hook failed: ' + String(error));
250
+ }
251
+ }
252
+ }
253
+ return this.state.tasks.get(taskId);
254
+ }
255
+ /** D23:链完成验收核对发警告(仅 system/dispatcher 可发)。Chain 状态保持 completed。 */
256
+ async auditWarning(chainId, evidence, actor) {
257
+ if (actor !== 'system')
258
+ throw new Error('permission denied: only dispatcher may raise audit warnings');
259
+ await this.chainOf(chainId);
260
+ return this.emit({ chainId, taskId: null, kind: 'chain/audit-warning', payload: { evidence }, author: actor, at: Date.now() });
261
+ }
262
+ /** D23:用户确认产物归属(仅 human,GUI confirm-audit action)。放行最终汇报。 */
263
+ async confirmAudit(chainId, actor) {
264
+ if (!can('audit-confirm', actor, null))
265
+ throw new Error('permission denied');
266
+ await this.chainOf(chainId);
267
+ const audit = this.state.auditWarnings.get(chainId);
268
+ if (!audit)
269
+ throw new Error('no audit warning for chain: ' + chainId);
270
+ return this.emit({ chainId, taskId: null, kind: 'chain/audit-confirmed', payload: {}, author: actor, at: Date.now() });
271
+ }
272
+ /** T7:链标题改名(仅 human,GUI)。发 chain/title-updated 事件(非状态转换)。 */
273
+ async updateChainTitle(chainId, title, actor) {
274
+ if (!can('update-title', actor, null))
275
+ throw new Error('permission denied');
276
+ const chain = await this.chainOf(chainId);
277
+ const next = title.trim();
278
+ if (!next)
279
+ throw new Error('title required');
280
+ if (next.length > 60)
281
+ throw new Error('title too long');
282
+ await this.emit({ chainId, taskId: chain.rootTaskId, kind: 'chain/title-updated', payload: { from: chain.title, to: next }, author: actor, at: Date.now() });
283
+ return { ...chain, title: next };
284
+ }
285
+ /** 整链硬删除(含其下全部角色卡/规格卡事件;仅 human,GUI 二次确认)。物理 purge 事件行,不可恢复。 */
286
+ async deleteChain(chainId, actor) {
287
+ if (!can('delete-chain', actor, null))
288
+ throw new Error('permission denied');
289
+ await this.chainOf(chainId); // 不存在则 throw
290
+ if (!this.store.purge)
291
+ throw new Error('event store does not support purge');
292
+ await this.store.purge((ev) => ev.chainId === chainId);
293
+ this.state = project(this.store.readAllSync());
294
+ }
295
+ async renameTask(taskId, title, actor) {
296
+ if (!can('update-title', actor, null))
297
+ throw new Error('permission denied');
298
+ const task = await this.taskOf(taskId);
299
+ const next = title.trim();
300
+ if (!next)
301
+ throw new Error('title required');
302
+ if (next.length > 60)
303
+ throw new Error('title too long');
304
+ await this.emit({ chainId: task.chainId, taskId, kind: 'task/renamed', payload: { from: task.title, to: next }, author: actor, at: Date.now() });
305
+ return { ...task, title: next };
306
+ }
307
+ async blockTask(taskId, reason, actor, opts = {}) {
308
+ const t = this.state.tasks.get(taskId);
309
+ if (!t)
310
+ throw new Error('unknown task: ' + taskId);
311
+ if (!can('block', actor, t, opts))
312
+ throw new Error('permission denied');
313
+ if (!reason.trim())
314
+ throw new Error('block reason required');
315
+ await this.emit({ chainId: t.chainId, taskId, kind: 'task/blocked', payload: { reason }, author: actor, at: Date.now() });
316
+ return this.state.tasks.get(taskId);
317
+ }
318
+ async unblockTask(taskId, actor) {
319
+ const t = this.state.tasks.get(taskId);
320
+ if (!t)
321
+ throw new Error('unknown task: ' + taskId);
322
+ if (!can('unblock', actor, t))
323
+ throw new Error('permission denied');
324
+ await this.emit({ chainId: t.chainId, taskId, kind: 'task/unblocked', payload: {}, author: actor, at: Date.now() });
325
+ return this.state.tasks.get(taskId);
326
+ }
327
+ async heartbeat(taskId, actor, opts = {}) {
328
+ const t = this.state.tasks.get(taskId);
329
+ if (!t)
330
+ throw new Error('unknown task: ' + taskId);
331
+ if (!can('heartbeat', actor, t, opts))
332
+ throw new Error('permission denied');
333
+ await this.emit({ chainId: t.chainId, taskId, kind: 'task/heartbeat', payload: {}, author: actor, at: Date.now() });
334
+ return this.state.tasks.get(taskId);
335
+ }
336
+ /** 标记任务失败(runner 异常/心跳超时回收);投影递增 attempts(infra 瞬时错误不计数)。重试由调度器重派。 */
337
+ async failTask(taskId, reason, actor, opts = {}) {
338
+ const t = this.state.tasks.get(taskId);
339
+ if (!t)
340
+ throw new Error('unknown task: ' + taskId);
341
+ if (actor !== 'system')
342
+ throw new Error('permission denied: only dispatcher may fail tasks');
343
+ if (!reason.trim())
344
+ throw new Error('fail reason required');
345
+ await this.emit({ chainId: t.chainId, taskId, kind: 'task/failed', payload: { reason, infra: opts.infra ?? false }, author: actor, at: Date.now() });
346
+ return this.state.tasks.get(taskId);
347
+ }
348
+ async comment(taskId, body, actor) {
349
+ const t = this.state.tasks.get(taskId);
350
+ if (!t)
351
+ throw new Error('unknown task: ' + taskId);
352
+ if (!can('comment', actor, t))
353
+ throw new Error('permission denied');
354
+ return this.emit({ chainId: t.chainId, taskId, kind: 'task/commented', payload: { body }, author: actor, at: Date.now() });
355
+ }
356
+ async archiveTask(taskId, actor) {
357
+ const t = this.state.tasks.get(taskId);
358
+ if (!t)
359
+ throw new Error('unknown task: ' + taskId);
360
+ if (!can('archive', actor, t))
361
+ throw new Error('permission denied');
362
+ await this.emit({ chainId: t.chainId, taskId, kind: 'task/archived', payload: {}, author: actor, at: Date.now() });
363
+ return this.state.tasks.get(taskId);
364
+ }
365
+ /** T10.5:仅 draft 规格卡可挂附件(V 挂清单附件(/openspec: 建链)/ human GUI 上传)。 */
366
+ async addSpecCardAttachment(cardId, attachment, actor) {
367
+ const card = this.state.specCards.get(cardId);
368
+ if (!card)
369
+ throw new Error('unknown spec card: ' + cardId);
370
+ if (!can('spec-attach', actor, null))
371
+ throw new Error('permission denied');
372
+ if (card.status !== 'draft')
373
+ throw new Error('spec card not editable');
374
+ const updated = { ...card, attachments: [...card.attachments, attachment] };
375
+ await this.emit({ chainId: card.chainId, taskId: null, kind: 'spec-card/edited', payload: { ...updated }, author: actor, at: Date.now() });
376
+ return updated;
377
+ }
378
+ /** 评审事件(交付质量链):recordReview 记录评审卡结论并更新被评审任务 reviewStatus。
379
+ * actor 必须 system(V/角色不可伪造评审结论);verdict=pass → review/passed,否则 review/failed。
380
+ * 投影(projection.ts)据事件更新 target.reviewStatus(passed/failed)。 */
381
+ async recordReview(reviewTaskId, targetTaskId, evidence, actor) {
382
+ if (actor !== 'system')
383
+ throw new Error('permission denied: only dispatcher may record review');
384
+ const t = this.state.tasks.get(targetTaskId);
385
+ if (!t)
386
+ throw new Error('unknown target task: ' + targetTaskId);
387
+ const kind = evidence.verdict === 'pass' ? 'review/passed' : 'review/failed';
388
+ return this.emit({ chainId: t.chainId, taskId: reviewTaskId, kind, payload: { reviewTaskId, targetTaskId, evidence }, author: actor, at: Date.now() });
389
+ }
390
+ /** 评审超限放弃:review/gave-up(含证据链信息)。仅 system。 */
391
+ async reviewGaveUp(reviewTaskId, targetTaskId, reason, actor) {
392
+ if (actor !== 'system')
393
+ throw new Error('permission denied: only dispatcher may give up review');
394
+ const t = this.state.tasks.get(targetTaskId);
395
+ if (!t)
396
+ throw new Error('unknown target task: ' + targetTaskId);
397
+ return this.emit({ chainId: t.chainId, taskId: reviewTaskId, kind: 'review/gave-up', payload: { reviewTaskId, targetTaskId, reason }, author: actor, at: Date.now() });
398
+ }
399
+ /** 评审失败返工卡创建(评审失败闭环):原任务保持 done(不可变),新建返工卡继承 rework 字段。
400
+ * 仅 system(can('create-rework-task')=system);V 建执行卡、system 建返工卡。 */
401
+ async createReworkTask(input, actor) {
402
+ if (!can('create-rework-task', actor, null))
403
+ throw new Error('permission denied');
404
+ const source = this.state.tasks.get(input.sourceTaskId);
405
+ if (!source)
406
+ throw new Error('unknown source task: ' + input.sourceTaskId);
407
+ if (source.status !== 'done')
408
+ throw new Error('rework source must be done: ' + source.status);
409
+ const task = {
410
+ id: nid('t'), chainId: source.chainId, title: `[返工] ${source.title}`, body: source.body,
411
+ assignee: source.assignee, status: 'todo', mode: source.mode, priority: 1,
412
+ parents: [...source.parents], children: [], createdBy: 'auto', // system 自动创建(返工卡)
413
+ attempts: 0, heartbeats: [],
414
+ sessionId: '', reworkOfTaskId: source.id, resumeSessionId: source.sessionId,
415
+ reviewAttempt: source.reviewAttempt + 1, reviewStatus: 'pending',
416
+ };
417
+ task.sessionId = 'kbn-' + task.id;
418
+ await this.emit({ chainId: source.chainId, taskId: task.id, kind: 'task/created', payload: { ...task }, author: actor, at: Date.now() });
419
+ return task;
420
+ }
421
+ async snapshot() {
422
+ // P0-3:重投影为权威(事件日志是唯一事实源;非法转换在此抛错)
423
+ this.state = project(await this.store.readAll());
424
+ return this.state;
425
+ }
426
+ async listTasks(opts = {}) {
427
+ return [...this.state.tasks.values()].filter((t) => (opts.assignee === undefined || t.assignee === opts.assignee) &&
428
+ (opts.status === undefined || t.status === opts.status));
429
+ }
430
+ }
@@ -0,0 +1,7 @@
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';
3
+ export type Actor = Role | 'human' | 'system';
4
+ export declare function can(action: KanbanAction, actor: Actor, task: Task | null, opts?: {
5
+ isOwnTask?: boolean;
6
+ boundTaskId?: string;
7
+ }): boolean;
@@ -0,0 +1,54 @@
1
+ export function can(action, actor, task, opts = {}) {
2
+ // P1-4:会话绑定——角色 agent 只能操作其被 spawn 绑定的任务(boundTaskId=AgentSessionRef.task_id);
3
+ // 旧 own(仅查 assignee)允许"链上任意同角色任务",属跨任务越权,已废弃。
4
+ const bound = opts.boundTaskId !== undefined && task !== null && opts.boundTaskId === task.id;
5
+ switch (action) {
6
+ case 'create-task':
7
+ case 'create-chain':
8
+ return actor === 'v' || actor === 'human';
9
+ case 'claim':
10
+ return actor === 'system';
11
+ case 'complete':
12
+ // 仅绑定该任务的 agent 会话(boundTaskId 匹配且角色=任务 assignee)、系统收尾,
13
+ // 或 human(GUI 强制收尾,T27:human 为信任锚,不算越权);跨角色 bound 拒。
14
+ return actor === 'system' || actor === 'human' || (bound && actor === task.assignee);
15
+ case 'block':
16
+ return actor === 'system' || actor === 'human' || bound;
17
+ case 'heartbeat':
18
+ return bound;
19
+ case 'comment':
20
+ return true;
21
+ case 'unblock':
22
+ return actor === 'human';
23
+ case 'archive':
24
+ return actor === 'human' || actor === 'v';
25
+ case 'spec-approve':
26
+ return actor === 'human';
27
+ case 'spec-edit':
28
+ // P1-4:规格卡编辑仅 human(主会话前台);P 对规格卡只读
29
+ return actor === 'human';
30
+ case 'spec-attach':
31
+ // V 挂清单附件到规格卡(/openspec: 建链);human 亦可(GUI 上传)
32
+ return actor === 'v' || actor === 'human';
33
+ case 'wiki-write':
34
+ // 交付质量链:w 写 KB 正文;dt 仅写 projects/<chain>/review/ 评审命名空间(ToolGuard 层再收窄路径)
35
+ return actor === 'w' || actor === 'dt';
36
+ case 'wiki-read':
37
+ // w/d 读 KB 正文;dt 需读 KB 校验(评审只读);pt 无 wiki 工具面
38
+ return actor === 'w' || actor === 'd' || actor === 'dt';
39
+ case 'prefetch':
40
+ return actor === 'w';
41
+ case 'audit-confirm':
42
+ // D23:仅人类在 GUI 确认产物归属;system/角色均不可
43
+ return actor === 'human';
44
+ case 'update-title':
45
+ // T7:链/任务标题改名仅 human(GUI);V/角色均不可(防角色伪造链/卡标题)
46
+ return actor === 'human';
47
+ case 'delete-chain':
48
+ // 整链硬删除仅 human(GUI 二次确认);V/角色/system 均不可
49
+ return actor === 'human';
50
+ case 'create-rework-task':
51
+ // 评审失败返工卡创建:仅系统(V 建执行卡、system 建返工卡,防角色伪造返工链)
52
+ return actor === 'system';
53
+ }
54
+ }
@@ -0,0 +1,22 @@
1
+ import type { SpecCardSections } from './types.js';
2
+ import { type PrefetchManifest } from './prefetch-manifest.js';
3
+ export interface PlanningChecklist {
4
+ requirementName?: string;
5
+ spec: SpecCardSections;
6
+ manifest: PrefetchManifest;
7
+ clarifications: Array<{
8
+ q: string;
9
+ a: string;
10
+ }>;
11
+ doubts: Array<{
12
+ q: string;
13
+ resolved: boolean;
14
+ answer?: string;
15
+ }>;
16
+ }
17
+ /** 需求澄清清单 schema 硬校验:返回错误列表(空数组=合法)。清单缺段即拒绝保存(硬闸,主 agent 会话内修正)。 */
18
+ export declare function validatePlanningChecklist(raw: unknown): string[];
19
+ /** 需求澄清清单页标题:与任务卡 title 同源同逻辑(buildChainTitle),保证 KB 可检索。 */
20
+ export declare function buildChecklistTitle(c: PlanningChecklist): string;
21
+ /** 需求澄清清单落库 body:标题【需求】+ 各段可读 markdown(非裸 JSON)。KB 与临时目录两分支共用。 */
22
+ export declare function formatChecklistBody(c: PlanningChecklist): string;
@@ -0,0 +1,81 @@
1
+ import { validatePrefetchManifest } from './prefetch-manifest.js';
2
+ import { buildChainTitle } from './kanban-service.js';
3
+ const STR_FIELDS = [
4
+ ['problem', 'problem'], ['solution', 'solution'], ['testing', 'testing'], ['out_of_scope', 'out_of_scope'],
5
+ ];
6
+ const ARR_FIELDS = [['user_stories', 'user_stories'], ['impl_decisions', 'impl_decisions']];
7
+ /** 需求澄清清单 schema 硬校验:返回错误列表(空数组=合法)。清单缺段即拒绝保存(硬闸,主 agent 会话内修正)。 */
8
+ export function validatePlanningChecklist(raw) {
9
+ const errors = [];
10
+ if (typeof raw !== 'object' || raw === null)
11
+ return ['checklist must be an object'];
12
+ const c = raw;
13
+ // requirementName(可选):存在则须为非空 string
14
+ if ('requirementName' in c && (typeof c['requirementName'] !== 'string' || !c['requirementName'].trim())) {
15
+ errors.push('checklist.requirementName must be a non-empty string when present');
16
+ }
17
+ // spec 六段
18
+ const spec = c['spec'];
19
+ if (typeof spec !== 'object' || spec === null) {
20
+ errors.push('checklist.spec required');
21
+ }
22
+ else {
23
+ for (const [label, key] of STR_FIELDS) {
24
+ if (typeof spec[key] !== 'string' || spec[key].trim().length === 0) {
25
+ errors.push(`checklist.spec.${label} must be a non-empty string (got: ${JSON.stringify(spec[key])})`);
26
+ }
27
+ }
28
+ for (const [label, key] of ARR_FIELDS) {
29
+ if (!Array.isArray(spec[key]) || spec[key].some((v) => typeof v !== 'string')) {
30
+ errors.push(`checklist.spec.${label} must be string[]`);
31
+ }
32
+ }
33
+ }
34
+ // manifest 复用 PrefetchManifest schema
35
+ errors.push(...validatePrefetchManifest(c['manifest']).map((e) => 'checklist.' + e));
36
+ // 澄清问答/疑问点
37
+ for (const key of ['clarifications', 'doubts']) {
38
+ if (!Array.isArray(c[key]))
39
+ errors.push(`checklist.${key} must be an array`);
40
+ }
41
+ return errors;
42
+ }
43
+ /** 需求澄清清单页标题:与任务卡 title 同源同逻辑(buildChainTitle),保证 KB 可检索。 */
44
+ export function buildChecklistTitle(c) {
45
+ return buildChainTitle(c.requirementName ?? null, '', c.spec.problem);
46
+ }
47
+ /** 需求澄清清单落库 body:标题【需求】+ 各段可读 markdown(非裸 JSON)。KB 与临时目录两分支共用。 */
48
+ export function formatChecklistBody(c) {
49
+ const { spec, manifest, clarifications, doubts } = c;
50
+ const lines = [`# ${buildChecklistTitle(c)}`, '## Spec', ''];
51
+ lines.push('### 问题描述 (problem)', spec.problem, '');
52
+ lines.push('### 解决方案 (solution)', spec.solution, '');
53
+ lines.push('### 用户故事 (user_stories)', ...spec.user_stories.map((u) => `- ${u}`), '');
54
+ lines.push('### 实现决策 (impl_decisions)', ...spec.impl_decisions.map((d) => `- ${d}`), '');
55
+ lines.push('### 测试计划 (testing)', spec.testing, '');
56
+ lines.push('### 范围外 (out_of_scope)', spec.out_of_scope, '');
57
+ lines.push('## Repo 事实 (manifest)', '');
58
+ const { repo, files } = manifest;
59
+ lines.push('- 本地路径: ' + repo.localPath);
60
+ if (repo.remoteUrl)
61
+ lines.push('- 远端仓库: ' + repo.remoteUrl);
62
+ if (repo.branch)
63
+ lines.push('- 当前分支: ' + repo.branch);
64
+ lines.push('- 未提交改动: ' + (repo.dirtyFiles.length ? repo.dirtyFiles.map((f) => `\`${f}\``).join(', ') : '无'));
65
+ lines.push('', '### 文件基线', '', '| 路径 | 期望 | 备注 |', '| --- | --- | --- |');
66
+ for (const f of files)
67
+ lines.push(`| ${f.path} | ${f.expected} | ${f.note ?? '-'} |`);
68
+ lines.push('', '## 澄清问答', '');
69
+ clarifications.forEach((qa, i) => {
70
+ lines.push(`### Q${i + 1}. ${qa.q}`, `- **A**: ${qa.a}`, '');
71
+ });
72
+ if (clarifications.length === 0)
73
+ lines.push('(无)', '');
74
+ lines.push('## 疑问点', '');
75
+ for (const d of doubts) {
76
+ lines.push(d.resolved ? `- [x] ${d.q}${d.answer ? ` — ${d.answer}` : ''}` : `- [ ] ${d.q}`);
77
+ }
78
+ if (doubts.length === 0)
79
+ lines.push('(无)');
80
+ return lines.join('\n').replace(/\n{3,}/g, '\n\n');
81
+ }
@@ -0,0 +1,21 @@
1
+ import type { ObjectJsonSchema } from '@deepseek-ai/dsh-tools';
2
+ export interface PrefetchFileEntry {
3
+ path: string;
4
+ expected: 'exists' | 'absent' | 'content-hash';
5
+ note?: string;
6
+ }
7
+ export interface PrefetchManifest {
8
+ repo: {
9
+ localPath: string;
10
+ remoteUrl?: string;
11
+ branch?: string;
12
+ dirtyFiles: string[];
13
+ };
14
+ files: PrefetchFileEntry[];
15
+ }
16
+ /** prefetch 子代理结构化输出的 JSON Schema(经 ctx.subagents.start 的 outputSchema 传入,
17
+ * 由 spawn provider 在子代理侧强制校验;assertObjectJsonSchema 强制子集内:
18
+ * type/properties/required/items/enum/additionalProperties)。与 validatePrefetchManifest 同源同语义。 */
19
+ export declare const PREFETCH_MANIFEST_SCHEMA: ObjectJsonSchema;
20
+ /** 需求澄清清单 manifest schema 校验(planning-checklist 复用本 schema):返回错误列表(空数组 = 合法)。 */
21
+ export declare function validatePrefetchManifest(raw: unknown): string[];