@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,194 @@
1
+ // src/dispatcher/chain-auditor.ts
2
+ import { readdirSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { eventType, toolArgs, toolName } from './session-events.js';
5
+ import { isPathInside } from './target-repo.js';
6
+ /** 直接写工具(无条件视为写能力;只读工具如 read/glob/grep 不算)。 */
7
+ const DIRECT_WRITE_TOOLS = new Set(['write', 'edit', 'rm', 'mv', 'cp', 'mkdir', 'mkfile']);
8
+ /** run_code:写能力载体,需结合派发子调用判定(修复轮 7)。 */
9
+ const CODE_RUN_TOOLS = new Set(['run_code']);
10
+ /** bash 命令中的写操作标记(写证据启发式;ls/cat/grep/git status 等只读不算)。
11
+ * 重定向标记用 \s>>?(要求 > 前有空白),避免把 2>/dev/null、2>&1 等只读 stderr 重定向误判为写。 */
12
+ const BASH_WRITE_RE = /(?:\b(?:touch|mkdir|rm|rmdir|mv|cp|tee|truncate|install|ln|dd|chmod|chown|make|cmake)\b|\bgit\s+(?:add|commit|push|mv|rm|checkout\s+-b|switch\s+-c|worktree\s+add|merge|rebase|reset|clean|restore|tag|remote\s+add)\b|\bpnpm\s+(?:add|install|remove|update|link)\b|\bnpm\s+(?:i|install|add|remove|uninstall|update)\b|\byarn\s+(?:add|remove)\b|\bbun\s+(?:add|install|remove)\b|\bsed\s+-i\b|\bperl\s+-i\b|\s>>?)/i;
13
+ /** run_code/兜底 code 字符串中的 Python 写 API 标记(Fix round 2 F2 同源:audit 补 open( 兜底)。
14
+ * open() 写模式精确版(读模式 'r' 不命中)、os. 模块写、pathlib Path 写、shutil 复制/移动/删除。 */
15
+ const PY_CODE_WRITE_RE = /(?:\bopen\(\s*['"][^'"\n]*['"]\s*,\s*(?:(?:mode|encoding|errors|buffering|newline|closefd|opener|text)\s*=\s*)?['"][wa][^'"\n]*['"]|\bos\s*\.\s*(?:remove|unlink|write|rmdir|makedirs|rename)\b|\.(?:write_text|write_bytes|unlink|mkdir|rename)\(|shutil\s*\.\s*(?:copy|move|rmtree))/i;
16
+ /** 从工具调用参数中收集含 workspacesRoot 的字符串值(文件路径线索)。 */
17
+ function collectWorkspacePaths(value, root, out) {
18
+ if (typeof value === 'string') {
19
+ if (value.includes(root))
20
+ out.add(value);
21
+ return;
22
+ }
23
+ if (Array.isArray(value)) {
24
+ for (const item of value)
25
+ collectWorkspacePaths(item, root, out);
26
+ return;
27
+ }
28
+ if (value && typeof value === 'object') {
29
+ for (const v of Object.values(value)) {
30
+ collectWorkspacePaths(v, root, out);
31
+ }
32
+ }
33
+ }
34
+ /** 从会话事件读取任意字段(兼容顶层 / data 嵌套两种落盘形态)。 */
35
+ function dataField(e, key) {
36
+ const obj = (e ?? {});
37
+ if (obj[key] !== undefined)
38
+ return obj[key];
39
+ const d = obj['data'];
40
+ return d?.[key];
41
+ }
42
+ /** 文本是否含写操作标记(bash 命令 / 兜底 code 字符串共用;含 Python 写 API 标记)。 */
43
+ function hasWriteMarker(text) {
44
+ return BASH_WRITE_RE.test(text) || PY_CODE_WRITE_RE.test(text);
45
+ }
46
+ /** 判定单个工具调用是否构成写证据(修复轮 7:行为判定 + 只读排除)。
47
+ * @param subs 该调用的 run_code 派发子调用(无则 null,走兜底/直接判定)。 */
48
+ function handleCallEvidence(name, args, subs, root, paths) {
49
+ if (DIRECT_WRITE_TOOLS.has(name)) {
50
+ collectWorkspacePaths(args, root, paths);
51
+ return;
52
+ }
53
+ if (subs && subs.length > 0) {
54
+ // run_code 有派发记录:按实际子调用判定;只读子调用(read/glob/grep/bash 只读命令…)不产生证据
55
+ for (const sub of subs) {
56
+ if (DIRECT_WRITE_TOOLS.has(sub.name)) {
57
+ collectWorkspacePaths(sub.arguments, root, paths);
58
+ }
59
+ else if (sub.name === 'bash') {
60
+ const cmd = String(sub.arguments?.command ?? '');
61
+ if (hasWriteMarker(cmd) && cmd.includes(root))
62
+ paths.add(cmd);
63
+ }
64
+ else if (sub.name === 'run_code') {
65
+ const code = String(sub.arguments?.code ?? '');
66
+ if (hasWriteMarker(code) && code.includes(root))
67
+ paths.add(code);
68
+ }
69
+ }
70
+ return;
71
+ }
72
+ if (name === 'run_code') {
73
+ // 兜底:无派发记录(旧会话/未落盘)——仅当 code 含写标记且含工作区路径时才计为写证据
74
+ const code = String(args?.code ?? '');
75
+ if (hasWriteMarker(code) && code.includes(root))
76
+ paths.add(code);
77
+ return;
78
+ }
79
+ if (name === 'bash') {
80
+ const cmd = String(args?.command ?? '');
81
+ if (hasWriteMarker(cmd) && cmd.includes(root))
82
+ paths.add(cmd);
83
+ return;
84
+ }
85
+ }
86
+ function sessionWriteEvidence(id, workspacesRoot, events) {
87
+ // 外层工具调用索引:callId → {name, arguments}
88
+ const calls = new Map();
89
+ // run_code 派发子调用索引:rootCallId → [{name, arguments}]
90
+ const dispatchByRoot = new Map();
91
+ for (const ev of events) {
92
+ const t = eventType(ev);
93
+ if (t === 'tool/call' || t === 'tool-call') {
94
+ const callId = String(dataField(ev, 'callId') ?? '');
95
+ const name = String(toolName(ev) ?? '');
96
+ const args = toolArgs(ev);
97
+ if (callId) {
98
+ calls.set(callId, { name, arguments: args });
99
+ }
100
+ else {
101
+ // 兼容无 callId 的扁平事件(旧形态/测试夹具):按写标记直接判定
102
+ const flat = new Set();
103
+ handleCallEvidence(name, args, null, workspacesRoot, flat);
104
+ if (flat.size > 0) {
105
+ return {
106
+ source: 'main-session-scan',
107
+ detail: '非角色会话(id=' + id + ')对 kanban 工作区路径发起写能力工具调用,疑似主 agent 越权写产物',
108
+ paths: [...flat],
109
+ };
110
+ }
111
+ }
112
+ }
113
+ else if (t === 'tool/code-dispatch-start' || t === 'tool/code-dispatch') {
114
+ const rootCallId = String(dataField(ev, 'rootCallId') ?? '');
115
+ const name = String(dataField(ev, 'name') ?? '');
116
+ if (!rootCallId || !name)
117
+ continue;
118
+ const arr = dispatchByRoot.get(rootCallId) ?? [];
119
+ arr.push({ name, arguments: toolArgs(ev) });
120
+ dispatchByRoot.set(rootCallId, arr);
121
+ }
122
+ }
123
+ const paths = new Set();
124
+ for (const [callId, call] of calls) {
125
+ handleCallEvidence(call.name, call.arguments, dispatchByRoot.get(callId) ?? null, workspacesRoot, paths);
126
+ }
127
+ if (paths.size === 0)
128
+ return null;
129
+ return {
130
+ source: 'main-session-scan',
131
+ detail: '非角色会话(id=' + id + ')对 kanban 工作区路径发起写能力工具调用,疑似主 agent 越权写产物',
132
+ paths: [...paths],
133
+ };
134
+ }
135
+ export class ChainAuditor {
136
+ kanban;
137
+ workspacesRoot;
138
+ listLiveAgents;
139
+ constructor(deps) {
140
+ this.kanban = deps.kanban;
141
+ this.workspacesRoot = deps.workspacesRoot;
142
+ this.listLiveAgents = deps.listLiveAgents ?? (() => []);
143
+ }
144
+ /** 执行核对,返回越权证据(空=通过,不阻塞汇报)。
145
+ * @param workspaceDir 本链发起工作区(Chain.workspaceDir);提供时仅扫描工作区内的会话(修复轮 7)。 */
146
+ async check(chainId, workspaceDir = null) {
147
+ const evidence = [];
148
+ // 源 1:主会话(非 kbn- 角色会话)写能力工具事件扫描
149
+ for (const agent of this.listLiveAgents()) {
150
+ if (String(agent.id ?? '').startsWith('kbn-'))
151
+ continue; // 角色会话(P/W/D/V)跳过
152
+ // 0.1.0 delegation 豁免(spec FR5):header.agentPreset 以 kanban- 开头 → 角色会话
153
+ // 的子代理(childSessionMeta 记录所 join 的 preset id),产物归属 git 证据链,
154
+ // 不按"主会话越权"判定;源2(无主产物核对)仍兜底其误写链工作区根。
155
+ const joinedPreset = agent.session?.header?.agentPreset;
156
+ if (typeof joinedPreset === 'string' && joinedPreset.startsWith('kanban-'))
157
+ continue;
158
+ // 修复轮 7:作用域收窄——仅扫本链发起工作区内的会话;会话无 cwd(测试伪造)时保守保留扫描
159
+ if (workspaceDir && agent.session?.header?.cwd && !isPathInside(agent.session.header.cwd, workspaceDir)) {
160
+ continue;
161
+ }
162
+ const hit = sessionWriteEvidence(String(agent.id), this.workspacesRoot, agent.session?.events ?? []);
163
+ if (hit)
164
+ evidence.push(hit);
165
+ }
166
+ // 源 2:产物归属核对——链工作区根下非任务 id 的无主条目
167
+ const orphan = await this.reconcileArtifacts(chainId);
168
+ if (orphan.length > 0) {
169
+ evidence.push({
170
+ source: 'artifact-reconciliation',
171
+ detail: '链工作区存在不属于任何任务工作区的无主条目,疑似主 agent 越权写入',
172
+ paths: orphan,
173
+ });
174
+ }
175
+ return evidence;
176
+ }
177
+ async reconcileArtifacts(chainId) {
178
+ const chainDir = join(this.workspacesRoot, chainId);
179
+ let entries;
180
+ try {
181
+ entries = readdirSync(chainDir);
182
+ }
183
+ catch {
184
+ return []; // 无工作区目录 = 无产物,无越权线索
185
+ }
186
+ // 链任务 id 集合(角色 agent 只写各自任务工作区)
187
+ const state = await this.kanban.snapshot();
188
+ const taskIds = new Set();
189
+ for (const t of state.tasks.values())
190
+ if (t.chainId === chainId)
191
+ taskIds.add(t.id);
192
+ return entries.filter((name) => !taskIds.has(name)).map((name) => join(chainDir, name));
193
+ }
194
+ }
@@ -0,0 +1,50 @@
1
+ import type { Context } from '@deepseek-ai/cordis';
2
+ import type { KanbanConfig } from '../config.js';
3
+ import { EventWaker } from './event-waker.js';
4
+ import { Watchdog } from './watchdog.js';
5
+ import type { KanbanService } from '../domain/kanban-service.js';
6
+ export interface AgentModelOptions {
7
+ provider: string;
8
+ model: string;
9
+ reasoningEffort?: string;
10
+ }
11
+ /** 读取部署默认模型(settings 的 agent-default-model 优先,其次 dsh-agent-default-model),角色未单独配置时回退使用。 */
12
+ export declare function resolveDefaultModel(ctx: Context): AgentModelOptions | undefined;
13
+ export interface DispatcherDeps {
14
+ kanban: KanbanService;
15
+ runner: {
16
+ runTask(taskId: string): Promise<void>;
17
+ };
18
+ waker: EventWaker;
19
+ watchdog: Watchdog;
20
+ maxRetries: number;
21
+ /** lastSeq 持久化文件(与事件日志同目录,B6)。 */
22
+ stateFile: string;
23
+ /** 修复轮 6:调度器运行日志文件(storageDir/dispatcher.log)。 */
24
+ logFile: string;
25
+ }
26
+ /** 调度器:事件唤醒 V(R20 逐阶段建卡)+ 每任务一次性角色 agent + 心跳看门狗。
27
+ * - B1:failed 且 attempts<maxRetries 的任务重派(claim→running,AgentRunner resume 同一会话);
28
+ * attempts≥maxRetries 熔断 blocked(gave_up)。
29
+ * - B6:lastSeq 持久化,重启后仅唤醒 lastSeq 之后的事件。
30
+ * - R5:inFlight 互斥,防止慢 tick 与下一轮并发重复派发同一任务。 */
31
+ export declare class Dispatcher {
32
+ private readonly kanban;
33
+ private readonly runner;
34
+ private readonly waker;
35
+ private readonly watchdog;
36
+ private readonly maxRetries;
37
+ private readonly stateFile;
38
+ private readonly logFile;
39
+ private lastSeq;
40
+ private inFlight;
41
+ private timer;
42
+ constructor(deps: DispatcherDeps);
43
+ private ensureLastSeq;
44
+ tick(): Promise<void>;
45
+ start(intervalMs: number): void;
46
+ stop(): void;
47
+ }
48
+ /** 调度层装配:事件唤醒 V(R20 逐阶段建卡)+ 每任务一次性角色 agent + 心跳看门狗。
49
+ * 仅在 agents 与 kanban 服务同时可用时由插件入口调用(不依赖可能已错过的 ready 事件)。 */
50
+ export declare function startDispatcher(ctx: Context, config: KanbanConfig): void;
@@ -0,0 +1,280 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { KanbanProvider } from '../services/kanban-provider.js';
4
+ import { WikiVaultClient } from '../wiki/wiki-vault-client.js';
5
+ import { EventWaker } from './event-waker.js';
6
+ import { VOrchestrator } from './v-orchestrator.js';
7
+ import { AgentRunner } from './agent-runner.js';
8
+ import { Watchdog } from './watchdog.js';
9
+ import { ChainAuditor } from './chain-auditor.js';
10
+ import { mergeDAfterReview } from './merge-gate.js';
11
+ import { buildSubagentTreeGuard } from '../roles/toolsets.js';
12
+ import { syncKbLinks } from '../wiki/kb-linkage.js';
13
+ /** 读取部署默认模型(settings 的 agent-default-model 优先,其次 dsh-agent-default-model),角色未单独配置时回退使用。 */
14
+ export function resolveDefaultModel(ctx) {
15
+ try {
16
+ const settings = ctx.get('settings');
17
+ const m = settings?.get('agent-default-model');
18
+ if (m?.provider && m?.model) {
19
+ return m.reasoningEffort
20
+ ? { provider: m.provider, model: m.model, reasoningEffort: m.reasoningEffort }
21
+ : { provider: m.provider, model: m.model };
22
+ }
23
+ }
24
+ catch {
25
+ // settings 不可用则继续尝试 agentDefaultModel
26
+ }
27
+ try {
28
+ const svc = ctx.get('agentDefaultModel');
29
+ const sel = svc?.currentSelection();
30
+ return sel?.provider && sel?.model ? sel : undefined;
31
+ }
32
+ catch {
33
+ return undefined;
34
+ }
35
+ }
36
+ function parentsDone(task, state) {
37
+ return task.parents.every((pid) => {
38
+ const parent = state.tasks.get(pid);
39
+ return parent !== undefined && (parent.status === 'done' || parent.status === 'archived');
40
+ });
41
+ }
42
+ /** B6:从状态文件恢复 lastSeq;无文件时回退到事件日志尾行(不重放旧事件重复唤醒 V)。 */
43
+ function loadLastSeq(stateFile) {
44
+ try {
45
+ const raw = JSON.parse(readFileSync(stateFile, 'utf8'));
46
+ return typeof raw.lastSeq === 'number' ? raw.lastSeq : null;
47
+ }
48
+ catch {
49
+ return null;
50
+ }
51
+ }
52
+ function saveLastSeq(stateFile, lastSeq) {
53
+ try {
54
+ writeFileSync(stateFile, JSON.stringify({ lastSeq }));
55
+ }
56
+ catch { /* 忽略写失败:事件日志仍是事实源 */ }
57
+ }
58
+ /** 修复轮 6:把 [dsh-swarm] 关键事件追加到 storageDir/dispatcher.log,便于无控制台时观测调度器状态。 */
59
+ function logToFile(file, msg) {
60
+ try {
61
+ writeFileSync(file, new Date().toISOString() + ' ' + msg + '\n', { flag: 'a' });
62
+ }
63
+ catch { /* 忽略写失败 */ }
64
+ }
65
+ /** 修复轮 6:单次异步操作加超时护栏——一个挂起的 V 编排会话不得卡死整个调度器 tick。 */
66
+ function withTimeout(p, ms, label) {
67
+ return Promise.race([
68
+ p,
69
+ new Promise((_, reject) => setTimeout(() => reject(new Error('timeout: ' + label)), ms)),
70
+ ]);
71
+ }
72
+ /** 调度器:事件唤醒 V(R20 逐阶段建卡)+ 每任务一次性角色 agent + 心跳看门狗。
73
+ * - B1:failed 且 attempts<maxRetries 的任务重派(claim→running,AgentRunner resume 同一会话);
74
+ * attempts≥maxRetries 熔断 blocked(gave_up)。
75
+ * - B6:lastSeq 持久化,重启后仅唤醒 lastSeq 之后的事件。
76
+ * - R5:inFlight 互斥,防止慢 tick 与下一轮并发重复派发同一任务。 */
77
+ export class Dispatcher {
78
+ kanban;
79
+ runner;
80
+ waker;
81
+ watchdog;
82
+ maxRetries;
83
+ stateFile;
84
+ logFile;
85
+ lastSeq = null; // null=尚未加载(首轮 tick 从状态文件/事件日志尾行恢复)
86
+ inFlight = false;
87
+ timer = null;
88
+ constructor(deps) {
89
+ this.kanban = deps.kanban;
90
+ this.runner = deps.runner;
91
+ this.waker = deps.waker;
92
+ this.watchdog = deps.watchdog;
93
+ this.maxRetries = deps.maxRetries;
94
+ this.stateFile = deps.stateFile;
95
+ this.logFile = deps.logFile;
96
+ }
97
+ async ensureLastSeq(state) {
98
+ if (this.lastSeq !== null)
99
+ return;
100
+ // 修复轮 6:无状态文件(首次启动)时从 -1 起处理全部事件,避免跳过已存在的
101
+ // chain/created / spec-card/approved 等可唤醒事件导致 V 永不建卡(B6 回归)。
102
+ // 重放安全:VOrchestrator.wakeV 的 B6 幂等(已有匹配卡则跳过)保证不重复建卡。
103
+ this.lastSeq = loadLastSeq(this.stateFile) ?? -1;
104
+ logToFile(this.logFile, '[tick] initial lastSeq=' + this.lastSeq);
105
+ }
106
+ async tick() {
107
+ if (this.inFlight)
108
+ return; // R5:防重叠 tick 并发派发同一任务
109
+ this.inFlight = true;
110
+ try {
111
+ const state = await this.kanban.snapshot();
112
+ await this.ensureLastSeq(state);
113
+ let advanced = false;
114
+ for (const ev of state.events) {
115
+ if (ev.seq > this.lastSeq) {
116
+ this.lastSeq = ev.seq;
117
+ advanced = true;
118
+ try {
119
+ await withTimeout(this.waker.onEvent(ev), 60_000, 'wakeV ev=' + ev.seq + ' chain=' + ev.chainId);
120
+ }
121
+ catch (err) {
122
+ logToFile(this.logFile, '[tick] wakeV failed ev=' + ev.seq + ' chain=' + ev.chainId + ': ' + String(err));
123
+ }
124
+ }
125
+ }
126
+ if (advanced)
127
+ saveLastSeq(this.stateFile, this.lastSeq);
128
+ for (const t of state.tasks.values()) {
129
+ if (!parentsDone(t, state))
130
+ continue;
131
+ if (t.status === 'ready' || t.status === 'todo') {
132
+ await this.runner.runTask(t.id);
133
+ }
134
+ else if (t.status === 'failed' && t.attempts < this.maxRetries) {
135
+ // B1:failed 重派——AgentRunner 内 claim→running + resume 同一会话
136
+ await this.runner.runTask(t.id);
137
+ }
138
+ else if (t.status === 'failed') {
139
+ // B1:attempts≥maxRetries 熔断 blocked(gave_up),人工介入
140
+ await this.kanban.blockTask(t.id, 'gave_up: max retries', 'system');
141
+ }
142
+ }
143
+ await this.watchdog.tick();
144
+ }
145
+ catch (e) {
146
+ console.error('[dsh-swarm][debug] tick error: ' + String(e));
147
+ logToFile(this.logFile, '[tick] error: ' + String(e));
148
+ }
149
+ finally {
150
+ this.inFlight = false;
151
+ }
152
+ }
153
+ start(intervalMs) {
154
+ this.stop();
155
+ this.timer = setInterval(() => { void this.tick(); }, intervalMs);
156
+ }
157
+ stop() {
158
+ if (this.timer) {
159
+ clearInterval(this.timer);
160
+ this.timer = null;
161
+ }
162
+ }
163
+ }
164
+ /** 调度层装配:事件唤醒 V(R20 逐阶段建卡)+ 每任务一次性角色 agent + 心跳看门狗。
165
+ * 仅在 agents 与 kanban 服务同时可用时由插件入口调用(不依赖可能已错过的 ready 事件)。 */
166
+ export function startDispatcher(ctx, config) {
167
+ const storageDir = config.storageDir.replace('$DSH_HOME', process.env.DSH_HOME ?? process.cwd());
168
+ const logFile = join(storageDir, 'dispatcher.log');
169
+ const provider = ctx.get('kanban');
170
+ const agents = ctx.get('agents');
171
+ logToFile(logFile, '[startDispatcher] invoked provider=' + Boolean(provider) + ' agents=' + Boolean(agents));
172
+ if (!provider || !agents) {
173
+ logToFile(logFile, '[startDispatcher] SKIPPED: agents or kanban provider missing');
174
+ return;
175
+ }
176
+ try {
177
+ startDispatcherInner(ctx, config, storageDir, logFile, provider, agents);
178
+ }
179
+ catch (err) {
180
+ logToFile(logFile, '[startDispatcher] FAILED: ' + String(err));
181
+ console.error('[dsh-swarm][debug] startDispatcher failed: ' + String(err));
182
+ }
183
+ }
184
+ /** 调度器装配主体(startDispatcher 的容错包裹内执行,异常落盘不阻断插件加载)。 */
185
+ function startDispatcherInner(ctx, config, storageDir, logFile, provider, agents) {
186
+ const kanban = provider.service;
187
+ const wiki = new WikiVaultClient(config.wikiVault);
188
+ const defaultModel = resolveDefaultModel(ctx);
189
+ console.info('[dsh-swarm] role default model = ' + (defaultModel ? defaultModel.provider + '/' + defaultModel.model : 'none'));
190
+ const orchFile = join(storageDir, 'orchestration.json');
191
+ const orchestrations = new Map();
192
+ try {
193
+ const raw = JSON.parse(readFileSync(orchFile, 'utf8'));
194
+ for (const [k, v] of raw)
195
+ orchestrations.set(k, v);
196
+ }
197
+ catch { /* 首次启动无文件 */ }
198
+ const saveOrchs = () => {
199
+ try {
200
+ writeFileSync(orchFile, JSON.stringify([...orchestrations.entries()], null, 2));
201
+ }
202
+ catch { /* 忽略写失败 */ }
203
+ };
204
+ const vOrch = new VOrchestrator(ctx, kanban, agents, config, orchestrations, wiki, defaultModel);
205
+ // D23:链完成验收核对(重)——Chain(completed) 时核对主会话是否越权写工作区产物;
206
+ // 发现越权 → chain/audit-warning,阻塞最终汇报直至用户 GUI 确认(chain/audit-confirmed)。
207
+ const auditor = new ChainAuditor({
208
+ kanban,
209
+ workspacesRoot: join(storageDir, 'workspaces'),
210
+ listLiveAgents: () => (ctx.get('agents')?.list?.() ?? []),
211
+ });
212
+ kanban.setOnChainCompleted(async (chainId) => {
213
+ // 修复轮 7:传入本链发起工作区(Chain.workspaceDir),审计仅扫描该工作区内的会话,排除其他项目主会话
214
+ const chainState = await kanban.snapshot();
215
+ const workspaceDir = chainState.chains.get(chainId)?.workspaceDir ?? null;
216
+ const evidence = await auditor.check(chainId, workspaceDir);
217
+ if (evidence.length > 0) {
218
+ console.warn('[dsh-swarm] chain audit warning: ' + chainId + ' evidence=' + evidence.length);
219
+ await kanban.auditWarning(chainId, evidence, 'system');
220
+ }
221
+ // 合入门控(architecture-review 建议1):DT 通过后由 system 合入 TARGET_BRANCH;D 不再提前 merge/push。
222
+ // 解析失败软跳过([merge-skip]),合入失败记录 [merge-failed],均不阻断收尾(坏代码未被合入 = 方向安全)。
223
+ try {
224
+ const r = await mergeDAfterReview(kanban, chainId, storageDir);
225
+ if (r !== 'skipped')
226
+ logToFile(logFile, '[merge-gate] chain=' + chainId + ' result=' + r);
227
+ }
228
+ catch (err) {
229
+ console.error('[dsh-swarm][debug] merge gate failed ' + chainId + ': ' + String(err));
230
+ logToFile(logFile, '[merge-gate] error chain=' + chainId + ' ' + String(err));
231
+ }
232
+ });
233
+ // Q3&5:W2/W3(w:kb) 完成 → 机械互链登记(清单页 ↔ 计划页 ↔ 结果页)。
234
+ // syncKbLinks 内部对 wiki 读写全容错,失败不阻塞完成;钩子本身再包一层 try(防御未来改动抛错)。
235
+ kanban.setOnTaskCompleted(async (taskId) => {
236
+ try {
237
+ const snap = await kanban.snapshot();
238
+ await syncKbLinks(wiki, snap, taskId);
239
+ }
240
+ catch (err) {
241
+ console.error('[dsh-swarm][debug] kb linkage failed task=' + taskId + ': ' + String(err));
242
+ }
243
+ });
244
+ const waker = new EventWaker(ctx, config);
245
+ waker.setWakeImpl(async (chainId) => {
246
+ try {
247
+ await vOrch.wakeV(chainId);
248
+ }
249
+ catch (err) {
250
+ console.error('[dsh-swarm][debug] wakeV error chain=' + chainId + ': ' + String(err));
251
+ }
252
+ saveOrchs();
253
+ });
254
+ // 0.1.0 delegation(spec FR2):全局子代理写护栏——普通插件 ctx 上注册的 guard 全局
255
+ // 生效(dsh-tools:普通上下文守卫全局生效,agent.ctx 守卫仅对该 agent 生效)。
256
+ // guard 内部仅对 kanban-dt 系会话收紧;DT 父会话自身仍由 agent-runner 的 agent.ctx
257
+ // guard 双保险。dispose 时注销。
258
+ const toolsSvc = ctx.get('tools');
259
+ const unguardSubagents = toolsSvc?.guard?.(buildSubagentTreeGuard());
260
+ if (unguardSubagents) {
261
+ ctx.on('dispose', unguardSubagents);
262
+ }
263
+ const runner = new AgentRunner(ctx, kanban, config, wiki, defaultModel);
264
+ provider.runner = runner; // T32 fix:HTTP retry 复用同一执行器(failed→claim→spawn/resume)
265
+ const watchdog = new Watchdog(kanban, config.dispatcher);
266
+ const dispatcher = new Dispatcher({
267
+ kanban,
268
+ runner,
269
+ waker,
270
+ watchdog,
271
+ maxRetries: config.dispatcher.maxRetries,
272
+ stateFile: join(dirname(orchFile), 'dispatcher-state.json'), // 与事件日志同目录(B6)
273
+ logFile,
274
+ });
275
+ ctx.on('dispose', () => { dispatcher.stop(); watchdog.stop(); });
276
+ dispatcher.start(2000);
277
+ watchdog.start(config.dispatcher.heartbeatIntervalSeconds * 1000);
278
+ logToFile(logFile, '[startDispatcher] dispatcher started (tick=2000ms)');
279
+ void dispatcher.tick();
280
+ }
@@ -0,0 +1,13 @@
1
+ import type { KanbanEvent } from '../domain/types.js';
2
+ import type { KanbanConfig } from '../config.js';
3
+ type WakeImpl = (chainId: string) => Promise<void>;
4
+ /** 监听 kanban 事件并唤醒 V 编排会话(去重:同一链路在途只唤醒一次)。 */
5
+ export declare class EventWaker {
6
+ private inFlight;
7
+ private wakeImpl;
8
+ constructor(_ctx: unknown, _config: KanbanConfig);
9
+ /** 注入真实唤醒实现(T11.5:VOrchestrator.wakeV——创建/恢复 V 编排 agent 会话,按 R20 阶段序列建卡)。 */
10
+ setWakeImpl(impl: WakeImpl): void;
11
+ onEvent(ev: KanbanEvent): Promise<void>;
12
+ }
13
+ export {};
@@ -0,0 +1,24 @@
1
+ /** 监听 kanban 事件并唤醒 V 编排会话(去重:同一链路在途只唤醒一次)。 */
2
+ export class EventWaker {
3
+ inFlight = new Set();
4
+ wakeImpl = async () => { };
5
+ constructor(_ctx, _config) { }
6
+ /** 注入真实唤醒实现(T11.5:VOrchestrator.wakeV——创建/恢复 V 编排 agent 会话,按 R20 阶段序列建卡)。 */
7
+ setWakeImpl(impl) { this.wakeImpl = impl; }
8
+ async onEvent(ev) {
9
+ // v2:V 仅在规格卡批准后(spec-card/approved)或任务完成/阻塞(驱动阶段推进)时唤醒;
10
+ // chain/created 不再唤醒 V(V 不再处理旧 w1 预取阶段,等 /openspec: 建卡 + 批准后再行动)。
11
+ const wakeable = (ev.kind === 'task/completed' || ev.kind === 'task/blocked' || ev.kind === 'spec-card/approved');
12
+ if (!wakeable)
13
+ return;
14
+ if (this.inFlight.has(ev.chainId))
15
+ return;
16
+ this.inFlight.add(ev.chainId);
17
+ try {
18
+ await this.wakeImpl(ev.chainId);
19
+ }
20
+ finally {
21
+ this.inFlight.delete(ev.chainId);
22
+ }
23
+ }
24
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * R20 D(execute) git 凭据注入(M4):使 D 会话的 git ls-remote/push 可用。
3
+ *
4
+ * 机制:D 会话的 bash 每次调用都是新 shell(无环境持久),故 env 变量不可跨调用。
5
+ * 采用 repo-local http extraheader(AUTHORIZATION: basic),写入 <repo>/.git/config——
6
+ * 持久跨 bash 调用、由插件进程(web app 侧,不受 D 会话沙箱限制)注入。
7
+ *
8
+ * 鉴权按托管方区分:
9
+ * - GitLab PAT(glpat- 前缀)→ basic base64("oauth2:<PAT>")(GitLab 规范,实测 ls-remote 通过);
10
+ * - 其余(GitHub 等)→ basic base64("x-access-token:<PAT>")。
11
+ * D 会话为 danger-full-access(用户决策 Q4),HOME 可写,故 git 的 credential.helper
12
+ * (store/osxkeychain 等)不再触发 "credential storage lock",无需中和。
13
+ *
14
+ * PAT 来源(按优先级):DSH 凭据服务 ctx.credentials($DSH_HOME/.credentials.yaml 的
15
+ * KANBAN_GIT_PAT/GIT_PAT)→ 进程 env(KANBAN_GIT_PAT > GIT_PAT > GH_TOKEN > GITHUB_TOKEN)。
16
+ * 未配置 PAT 时不注入(回退用户已有 git 凭据/SSH)。
17
+ */
18
+ /** D 会话可用 git PAT 的 env 变量名(按优先级)。 */
19
+ export declare const GIT_PAT_ENV_NAMES: readonly ["KANBAN_GIT_PAT", "GIT_PAT", "GH_TOKEN", "GITHUB_TOKEN"];
20
+ /** 从进程 env 解析非空 PAT;无则返回 null。 */
21
+ export declare function resolveGitPat(env: NodeJS.ProcessEnv): string | null;
22
+ /** 经 DSH 凭据服务解析 PAT($DSH_HOME/.credentials.yaml / env / .env,见 dsh-credentials-local 分层);无则回退进程 env。 */
23
+ export declare function resolveGitPatFromCtx(ctx: unknown, env: NodeJS.ProcessEnv): Promise<string | null>;
24
+ /** PAT → http extraheader 值:GitLab(glpat-*) 用 oauth2 前缀,其余 x-access-token。 */
25
+ export declare function gitAuthHeader(pat: string): string;
26
+ /**
27
+ * 注入 repo-local http extraheader。返回 { ok, detail }(失败不抛——注入失败仅告警,
28
+ * D 仍可执行;用户自带凭据/SSH 的仓库不受影响)。
29
+ */
30
+ export declare function injectGitCredentials(repoDir: string, pat: string): {
31
+ ok: boolean;
32
+ detail: string;
33
+ };