@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,46 @@
1
+ import Schema from '@deepseek-ai/schemastery';
2
+ import type { Role } from './domain/types.js';
3
+ export interface KanbanConfig {
4
+ storageDir: string;
5
+ wikiVault: {
6
+ baseUrl: string;
7
+ pagePrefix: string;
8
+ };
9
+ roles: {
10
+ models: Partial<Record<Role, {
11
+ provider: string;
12
+ model: string;
13
+ reasoningEffort?: string;
14
+ fallbacks?: Array<{
15
+ provider: string;
16
+ model: string;
17
+ reasoningEffort?: string;
18
+ }>;
19
+ }>>;
20
+ };
21
+ dispatcher: {
22
+ staleTimeoutSeconds: number;
23
+ maxRetries: number;
24
+ heartbeatIntervalSeconds: number;
25
+ /** 协议违规护栏:连续 protocol_violation 阻塞 ≥ 此值后,下次违规直接 gave_up 不再恢复。默认 2。 */
26
+ maxProtocolViolations: number;
27
+ /** 评审返工护栏:pt/dt 各自最大返工次数(超限 review/gave-up + [review-final])。默认 pt=2 dt=3。 */
28
+ maxReworksPerRole: {
29
+ pt: number;
30
+ dt: number;
31
+ };
32
+ };
33
+ prefixRoutes: {
34
+ plan: string;
35
+ openspec: string;
36
+ };
37
+ ui: {
38
+ enabled: boolean;
39
+ /** 看板宽度下界(px)。 */
40
+ contentMinWidth: number;
41
+ /** 看板宽度上界(px)。 */
42
+ contentMaxWidth: number;
43
+ sseHeartbeatSeconds: number;
44
+ };
45
+ }
46
+ export declare const Config: Schema<KanbanConfig>;
package/lib/config.js ADDED
@@ -0,0 +1,43 @@
1
+ import Schema from '@deepseek-ai/schemastery';
2
+ const modelItemSchema = () => Schema.object({
3
+ provider: Schema.string().required(),
4
+ model: Schema.string().required(),
5
+ reasoningEffort: Schema.string().default('high'),
6
+ fallbacks: Schema.array(Schema.object({
7
+ provider: Schema.string().required(),
8
+ model: Schema.string().required(),
9
+ reasoningEffort: Schema.string().default('high'),
10
+ })).default([]),
11
+ });
12
+ export const Config = Schema.object({
13
+ storageDir: Schema.string().default('$DSH_HOME/storages/kanban'),
14
+ wikiVault: Schema.object({
15
+ baseUrl: Schema.string().default('http://192.168.122.111:3000'),
16
+ pagePrefix: Schema.string().default('projects/'),
17
+ }),
18
+ roles: Schema.object({
19
+ // 角色系统提示词经 personas/kanban-{v,p,w,d}/agent.cordis.yml 组合装配(agentPresets.mount),
20
+ // 随包安装到 $DSH_HOME/.agent-presets/(preset-installer),不再经 config 引用 md 文本。
21
+ models: Schema.dict(modelItemSchema()).default({}),
22
+ }),
23
+ dispatcher: Schema.object({
24
+ staleTimeoutSeconds: Schema.number().default(14400),
25
+ maxRetries: Schema.number().default(3),
26
+ heartbeatIntervalSeconds: Schema.number().default(300),
27
+ maxProtocolViolations: Schema.number().min(1).default(2),
28
+ maxReworksPerRole: Schema.object({
29
+ pt: Schema.number().min(1).default(2),
30
+ dt: Schema.number().min(1).default(3),
31
+ }),
32
+ }),
33
+ prefixRoutes: Schema.object({
34
+ plan: Schema.string().default('/plan:'),
35
+ openspec: Schema.string().default('/openspec:'),
36
+ }),
37
+ ui: Schema.object({
38
+ enabled: Schema.boolean().default(true),
39
+ contentMinWidth: Schema.number().min(320).max(960).default(715), // 看板最小宽度 715px
40
+ contentMaxWidth: Schema.number().min(320).max(960).default(780), // 看板最大宽度 780px
41
+ sseHeartbeatSeconds: Schema.number().min(5).default(20),
42
+ }),
43
+ });
@@ -0,0 +1,23 @@
1
+ import type { Context } from '@deepseek-ai/cordis';
2
+ import type { KanbanService } from '../domain/kanban-service.js';
3
+ import type { KanbanConfig } from '../config.js';
4
+ import type { WikiVaultClient } from '../wiki/wiki-vault-client.js';
5
+ import type { AgentModelOptions } from './dispatcher.js';
6
+ /** 每任务一次性角色 agent:创建/resume、上下文组装、协议违规检测。 */
7
+ export declare class AgentRunner {
8
+ private readonly ctx;
9
+ private readonly kanban;
10
+ private readonly config;
11
+ private readonly wiki;
12
+ private readonly defaultModel;
13
+ constructor(ctx: Context, kanban: KanbanService, config: KanbanConfig, wiki: WikiVaultClient, defaultModel?: AgentModelOptions);
14
+ private buildContext;
15
+ runTask(taskId: string): Promise<void>;
16
+ /** RC2:resume 前先查 agents registry 同名会话是否仍 live——live 则直接复用(后续 followup 续用),
17
+ * 避免 block→unblock→重跑同一会话时 resume 抛 "cannot prepare session while it is live"
18
+ * (对齐 VOrchestrator.getVAgent 的 live 复用逻辑)。agents.get 未实现 → 防御回退 resume。 */
19
+ private resumeOrReuse;
20
+ /** M3(B):D(execute) 目标仓库在会话工作空间外时,跑 D 前询问用户是否允许。
21
+ * 经 ctx.userQuestions(GUI 弹窗)单次询问;无询问通道或拒绝 → 返回 false(由调用方 claim+block 等待人工放行)。 */
22
+ private requestRepoPermission;
23
+ }
@@ -0,0 +1,429 @@
1
+ import { SessionId } from '@deepseek-ai/dsh-session';
2
+ import { installRoleTools, buildReadOnlyWriteGuard, buildDTWriteGuard, buildPlanWriteGuard, registerDtTaskChain, unregisterDtTaskChain } from '../roles/toolsets.js';
3
+ import { buildModelCandidates, isModelUnavailableError } from './model-candidates.js';
4
+ import { attachSessionToWorkspace, resolveOrCreateWorkspace } from './workspace-attach.js';
5
+ import { toolName } from './session-events.js';
6
+ import { isPathInside, resolveTargetRepoDir } from './target-repo.js';
7
+ import { injectGitCredentials, resolveGitPatFromCtx } from './git-credentials.js';
8
+ /** M3(B):目标仓库在会话工作空间外、已 claim+block 等待用户授权且尚未建会话的任务集合(key=taskId)。
9
+ * 人工放行后再次调度会重新询问;已授权后从集合移除。仅进程内记忆,重启后从事件日志恢复(block 事件仍在)。 */
10
+ const permissionBlockedTasks = new Set();
11
+ /** D4 goal 条件启用(spec FR6):spec 卡六段或父交接命中关键词 → 注入目标模式指令。 */
12
+ const GOAL_MODE_KEYWORDS = ['/goal', '目标模式', 'goal mode'];
13
+ /** RC4:瞬时基础设施错误(会话 live 锁/网络超时)与任务质量失败区分——infra 不计入 attempts 重试预算。 */
14
+ function isInfraError(err) {
15
+ return /cannot prepare session|while it is live|timeout|ETIMEDOUT|ECONNREFUSED|ECONNRESET|socket/i.test(String(err));
16
+ }
17
+ /** 每任务一次性角色 agent:创建/resume、上下文组装、协议违规检测。 */
18
+ export class AgentRunner {
19
+ ctx;
20
+ kanban;
21
+ config;
22
+ wiki;
23
+ defaultModel;
24
+ constructor(ctx, kanban, config, wiki, defaultModel) { this.ctx = ctx; this.kanban = kanban; this.config = config; this.wiki = wiki; this.defaultModel = defaultModel; }
25
+ buildContext(task, state, resume) {
26
+ const parts = [`# Task ${task.id}: ${task.title}`, `assignee=${task.assignee} mode=${task.mode}`];
27
+ // Q3:权限提示仅对受限角色显示(v 无写、d/execute 全权、p/w 全权但受工具级写护栏约束——写边界不靠 prompt 软约束)
28
+ if (task.assignee !== 'v' && !(task.assignee === 'd' && task.mode === 'execute') && task.assignee !== 'p' && task.assignee !== 'w') {
29
+ parts.push('权限提示:你的权限范围固定在会话工作区(workspace-write),越权操作(如 sandbox_permissions 升级写工作区外)会被自动拒绝且不可重试。遇到拒绝不要重试被拒操作,改用工作区内可行方式记录结果,然后调用 kanban_complete。');
30
+ }
31
+ // A3/B5:D(execute) = 唯一执行者(danger-full-access,无权限提示)——目标仓库内实际写代码 + git 提交推送
32
+ if (task.assignee === 'd' && task.mode === 'execute') {
33
+ parts.push('## 执行者职责\n你是链路唯一执行者(不是只读对齐/校验):在目标仓库(见 Body 的 TARGET_REPO)内 git worktree/branch → 按规格卡 solution/testing 改代码/README → git commit → git push → 自检(跑测试/构建)。本会话为 full-access(可写目标仓库与 git 凭据已注入),不要做只读对齐/校验交差。调用 kanban_complete 时 metadata 必须带 git 产物证据:changed_files(数组)+ commit_hash 与 push 至少其一,否则完成会被拒绝、链路不会收尾。');
34
+ }
35
+ if (task.body)
36
+ parts.push(`## Body
37
+ ${task.body}`);
38
+ // P1-1:规格卡六段 + 附件注入(经 Chain.specCardId),角色 agent 的输入契约,原汁原味
39
+ const chain = state.chains.get(task.chainId);
40
+ const specCard = chain?.specCardId ? state.specCards.get(chain.specCardId) : null;
41
+ if (specCard) {
42
+ parts.push('## Spec card (approved)\n' +
43
+ `problem: ${specCard.sections.problem}\n` +
44
+ `solution: ${specCard.sections.solution}\n` +
45
+ `user_stories: ${specCard.sections.user_stories.join(' | ')}\n` +
46
+ `impl_decisions: ${specCard.sections.impl_decisions.join(' | ')}\n` +
47
+ `testing: ${specCard.sections.testing}\n` +
48
+ `out_of_scope: ${specCard.sections.out_of_scope}\n` +
49
+ `attachments: ${specCard.attachments.map((a) => `${a.kind}:${a.ref}`).join(' | ')}`);
50
+ }
51
+ const parents = task.parents.map((pid) => state.handoffs.get(pid)).filter(Boolean);
52
+ if (parents.length > 0) {
53
+ parts.push('## Parent task results');
54
+ for (const h of parents) {
55
+ parts.push(`- summary: ${h.summary}`);
56
+ parts.push(`- metadata: ${JSON.stringify(h.metadata)}`);
57
+ }
58
+ }
59
+ // 0.1.0 delegation(spec FR6):D(execute) 目标模式条件注入——spec 卡/父交接命中
60
+ // GOAL_MODE_KEYWORDS 才注入;否则默认执行(行为不变)。
61
+ if (task.assignee === 'd' && task.mode === 'execute') {
62
+ const specText = specCard
63
+ ? [specCard.sections.problem, specCard.sections.solution, specCard.sections.user_stories.join(' '),
64
+ specCard.sections.impl_decisions.join(' '), specCard.sections.testing, specCard.sections.out_of_scope].join(' ')
65
+ : '';
66
+ const parentText = parents.map((h) => (h?.summary ?? '') + ' ' + JSON.stringify(h?.metadata ?? {})).join(' ');
67
+ const hay = (specText + ' ' + parentText).toLowerCase();
68
+ if (GOAL_MODE_KEYWORDS.some((k) => hay.includes(k.toLowerCase()))) {
69
+ parts.push('## Goal mode\nThe plan requests /goal goal-mode execution: before starting, use the goal tool to register your execution goal; update it as you progress; mark it complete (or blocked) when the task finishes, then kanban_complete as usual.');
70
+ }
71
+ }
72
+ if (resume) {
73
+ // B2:resume 注入运行历史与上次失败原因(不只次数),返工/重试同会话可参考前因
74
+ const lastFail = state.events.filter((e) => e.taskId === task.id && e.kind === 'task/failed').at(-1);
75
+ const reason = lastFail ? String(lastFail.payload['reason'] ?? '') : '';
76
+ parts.push(`## Prior attempts: ${task.attempts} (resume session)` + (reason ? `\nlast failure: ${reason}` : ''));
77
+ }
78
+ // 阻塞 resume 场景:注入最近阻塞原因 + 阻塞后评论([blocked-review]/主 agent 方向)
79
+ const blocks = state.events.filter((e) => e.taskId === task.id && e.kind === 'task/blocked');
80
+ const lastBlock = blocks.at(-1);
81
+ if (lastBlock) {
82
+ const sinceBlock = state.events
83
+ .filter((e) => e.taskId === task.id && e.kind === 'task/commented' && e.at >= lastBlock.at)
84
+ .slice(-5);
85
+ parts.push('## Review guidance (blocked task resume)');
86
+ parts.push('- last block reason: ' + String(lastBlock.payload['reason'] ?? ''));
87
+ if (sinceBlock.length > 0) {
88
+ parts.push('- guidance comments:');
89
+ for (const c of sinceBlock) {
90
+ parts.push(` - ${c.author}: ${String(c.payload['body'] ?? '')}`);
91
+ }
92
+ }
93
+ else {
94
+ parts.push('- no guidance comments yet: coordinate the fix direction with the orchestrator/human, then call kanban_complete');
95
+ }
96
+ }
97
+ // 返工场景(task.reworkOfTaskId 非空且 reviewStatus='pending'):
98
+ // 注入 review/failed 的 issues 清单与建议方向(评审卡 verdict=fail 后的返工卡上下文)
99
+ if (task.reworkOfTaskId && task.reviewStatus === 'pending') {
100
+ const reviewFailed = [...state.events]
101
+ .reverse()
102
+ .find((e) => e.kind === 'review/failed' && e.payload['targetTaskId'] === task.reworkOfTaskId);
103
+ parts.push('## Review guidance (rework task)');
104
+ parts.push('- 上游任务: ' + task.reworkOfTaskId);
105
+ if (reviewFailed) {
106
+ const evidence = reviewFailed.payload['evidence'];
107
+ const issues = evidence?.issues ?? [];
108
+ parts.push('- review issues:');
109
+ for (const issue of issues) {
110
+ parts.push(` - [${issue.severity}] ${issue.title}${issue.resolved ? ' (resolved)' : ''}`);
111
+ }
112
+ if (issues.length === 0)
113
+ parts.push(' - (no issues recorded in review evidence)');
114
+ }
115
+ else {
116
+ parts.push('- no review/failed evidence found; re-verify the upstream deliverable before completing');
117
+ }
118
+ }
119
+ return parts.join('\n\n');
120
+ }
121
+ async runTask(taskId) {
122
+ const state = await this.kanban.snapshot();
123
+ const task = state.tasks.get(taskId);
124
+ if (!task)
125
+ throw new Error('unknown task: ' + taskId);
126
+ // todo(V 建卡默认态,无父任务即就绪)/ ready / failed(重试)均可调度;其余状态拒
127
+ if (task.status !== 'ready' && task.status !== 'todo' && task.status !== 'failed')
128
+ throw new Error('task not schedulable: ' + task.status);
129
+ // 0.1.0 delegation(spec FR2):DT 任务运行期注册 chainId(全局子代理 guard 的
130
+ // wiki review namespace 同步解析源);runTask 结束注销。
131
+ if (task.assignee === 'dt')
132
+ registerDtTaskChain(task.id, task.chainId);
133
+ try {
134
+ // M2(Q5)+归组:角色会话 cwd 恒为主 agent 工作空间(Chain.workspaceDir)。
135
+ // 缺失时询问用户注册工作区;仍不可得 → block('workspace-unknown'),绝不静默落 kanban 存储目录。
136
+ const chain = state.chains.get(task.chainId);
137
+ let sessionCwd = chain?.workspaceDir ?? null;
138
+ if (!sessionCwd) {
139
+ sessionCwd = await resolveOrCreateWorkspace(this.ctx, null, 'task ' + task.id + ' ' + task.assignee + '/' + task.mode);
140
+ }
141
+ if (!sessionCwd) {
142
+ await this.kanban.comment(taskId, '链未绑定工作区(Chain.workspaceDir 缺失且用户未提供工作区路径)。请重新 /plan: 绑定主 agent 工作空间后重试。', 'system');
143
+ await this.kanban.claimTask(taskId, 'system');
144
+ await this.kanban.blockTask(taskId, 'workspace-unknown: 链未绑定工作区(Chain.workspaceDir 缺失)', 'system');
145
+ return;
146
+ }
147
+ // R20 D(execute):目标仓库解析(供 M3 前置授权判定 + B4 git 凭据注入目标 + 上下文);
148
+ // 会话 cwd 不再指向仓库(会话必须在主 agent 工作空间,见 Q5)。
149
+ const isDExecute = task.assignee === 'd' && task.mode === 'execute';
150
+ const dRepo = isDExecute ? resolveTargetRepoDir(task, state, sessionCwd) : null;
151
+ // M3(B):D 目标仓库在会话工作空间外 → 跑 D 前先询问用户是否允许(一次授权,D 以 full-access 执行不再逐次提示)。
152
+ // 不允许/无询问通道 → claim+block 等待人工放行(状态机要求 running 才能 block);
153
+ // 放行后再次调度会重新询问。授权前不创建会话,permissionBlockedTasks 避免误走 resume。
154
+ if (isDExecute && dRepo && !isPathInside(dRepo, sessionCwd)) {
155
+ const allowed = await this.requestRepoPermission(task, dRepo, sessionCwd);
156
+ if (!allowed) {
157
+ await this.kanban.comment(taskId, 'D 执行需要访问会话工作空间外的目标仓库 ' + dRepo + '(会话工作空间:' + sessionCwd + ')。请在 GUI 解除阻塞以允许(再次调度会重新询问),或中止该链路。', 'system');
158
+ await this.kanban.claimTask(taskId, 'system');
159
+ await this.kanban.blockTask(taskId, 'repo-outside-workspace: 目标仓库 ' + dRepo + ' 在会话工作空间外,需用户授权', 'system');
160
+ permissionBlockedTasks.add(taskId);
161
+ return;
162
+ }
163
+ permissionBlockedTasks.delete(taskId);
164
+ }
165
+ // B2:resume 判定 = 有运行历史(attempts>0 或存在 claimed 事件)且不是「无会话授权阻塞」的任务;
166
+ // 后者虽有 claimed 事件但从未创建会话,必须走 create 而非 resume(否则 resume 不存在会话抛错)。
167
+ const hasRunHistory = !permissionBlockedTasks.has(taskId) &&
168
+ (task.attempts > 0 || state.events.some((e) => e.taskId === taskId && e.kind === 'task/claimed'));
169
+ let agent;
170
+ let context = '';
171
+ const setup = async (agentCtx) => {
172
+ // 思考等级强制(waterfall):宿主 selection 无 create-options 覆盖层(dsh-host-apiproxy 的
173
+ // selectionFor 不消费 agentOptions),AgentOptions 也仅有 provider/model/maxTokens——agentOptions
174
+ // 里的 reasoningEffort 不被宿主消费,新建角色会话思考等级会落回宿主默认。改走 DSH agent/request
175
+ // waterfall 逐请求强制(宿主 installModelSelection 同机制),作用域仅本角色会话;
176
+ // effort 可由 per-role config 覆盖,默认 'high'(与 model-candidates.ts 默认一致)。
177
+ const effort = this.config.roles?.models?.[task.assignee]?.reasoningEffort ?? 'high';
178
+ const scoped = agentCtx;
179
+ scoped.on('agent/request', async (_payload, next) => {
180
+ // 异常不吞:await next() 失败原样向上抛
181
+ const resolved = await next();
182
+ return { ...resolved, reasoningEffort: effort };
183
+ });
184
+ // 角色 agent 等同委派子 agent:固定 approval=never(避免后台会话悬挂等审批)。
185
+ // Q3:P/W/D = full access(跨目录读:P 读仓库/外部实证、W 读计划、D 执行);
186
+ // 但 P 挂 plan 写护栏、W 挂只读护栏(写边界由工具级强制,防"改动源码",不靠 prompt 软约束);
187
+ // PT/DT/V → workspace-write(评审/编排最小权限,PT/DT 只读护栏由 ToolGuard 独立保证)。
188
+ const session = agentCtx.agent?.session;
189
+ session?.append?.('approval/policy', { policy: 'never', source: 'delegation' });
190
+ const fullAccess = isDExecute || task.assignee === 'p' || task.assignee === 'w';
191
+ session?.append?.('sandbox/mode', fullAccess ? { mode: 'danger-full-access', source: 'delegation' } : { mode: 'workspace-write', source: 'delegation' });
192
+ // 执行角色(P/W/D)先挂载 D22 裁剪 preset(kanban-p/w/d),把 shell/approval 等服务注入 agent scope;
193
+ // 不再整包继承官方 code preset:bash/fs/fs-search 由裁剪组合装配,run_code/jobs/skill/goal/
194
+ // plan-mode/compaction/delegation/web/todo 按角色裁剪(组合文件随包分发 + 运行时安装到
195
+ // $DSH_HOME/.agent-presets/,见 preset-installer.ts)。否则官方 apply 会抛
196
+ // "cannot get property shell without inject"。
197
+ if (task.assignee === 'p' || task.assignee === 'w' || task.assignee === 'd' || task.assignee === 'pt' || task.assignee === 'dt') {
198
+ const presets = agentCtx.get('agentPresets');
199
+ if (presets) {
200
+ const presetId = 'kanban-' + task.assignee;
201
+ try {
202
+ await presets.mount(agentCtx, presetId);
203
+ console.error('[dsh-swarm][debug] preset mounted ' + presetId + ' role=' + task.assignee + ' task=' + taskId);
204
+ }
205
+ catch (err) {
206
+ // preset 挂载失败不阻断:角色工具面仍注册,仅缺基座工具
207
+ console.error('[dsh-swarm][debug] preset mount failed ' + presetId + ' role=' + task.assignee + ' task=' + taskId + ': ' + String(err));
208
+ }
209
+ }
210
+ }
211
+ await installRoleTools(agentCtx, task.assignee, { kanban: this.kanban, wiki: this.wiki, taskId: task.id });
212
+ // 只读评审角色(PT/DT)注册 ToolGuard:拦截 tracked source 写入 / git mutation / 含写标记 bash。
213
+ // 以 dsh-tools 类型为准:tools.guard(execution => reason|undefined),execution.name/arguments 为实际字段。
214
+ if (task.assignee === 'pt' || task.assignee === 'dt') {
215
+ const repoRoot = dRepo ?? sessionCwd; // DT 评审目标仓库;PT 以会话工作区为只读边界
216
+ const toolsSvc = agentCtx.tools;
217
+ // DT 额外叠加 wiki review namespace 收窄(projects/<chain>/review/)
218
+ const guardFn = task.assignee === 'dt'
219
+ ? buildDTWriteGuard(repoRoot, task.chainId)
220
+ : buildReadOnlyWriteGuard(repoRoot);
221
+ toolsSvc?.guard?.((e) => guardFn(e));
222
+ }
223
+ // Q3:P/W 挂写护栏(wsRoot = 链 workspaceDir = sessionCwd,同 PT/DT 解析方式)。
224
+ // P = plan 写护栏(openspec/changes/** 内可写,禁改源码);W = 只读护栏(交付=读计划→wiki API,
225
+ // fs 零写——I2 起全名拦截,repo 外/workspace 内任意路径 fs 写一律拒,不再依赖 repoRoot 边界)。
226
+ // 挂载方式同 PT/DT:tools.guard 是注册方法(dsh-tools 类型),execution 以 { name, arguments } 传入。
227
+ if (task.assignee === 'p' || task.assignee === 'w') {
228
+ const toolsSvc = agentCtx.tools;
229
+ const guardFn = task.assignee === 'p'
230
+ ? buildPlanWriteGuard(sessionCwd)
231
+ : buildReadOnlyWriteGuard(sessionCwd);
232
+ toolsSvc?.guard?.((e) => guardFn(e));
233
+ }
234
+ // M4:D(execute) 注入 git 凭据(repo-local http extraheader,GitLab glpat-* 用 oauth2 basic)。
235
+ // 由插件进程(不受 D 会话沙箱限制)写入 <repo>/.git/config;PAT 经 DSH 凭据服务/env 解析;
236
+ // 注入失败仅告警(用户自带凭据/SSH 的仓库不受影响),未配置 PAT 不注入。
237
+ if (isDExecute && dRepo) {
238
+ const pat = await resolveGitPatFromCtx(this.ctx, process.env);
239
+ if (pat) {
240
+ const cred = injectGitCredentials(dRepo, pat);
241
+ if (cred.ok)
242
+ console.error('[dsh-swarm][debug] git credential injected task=' + taskId + ' targets=' + cred.detail);
243
+ else
244
+ console.error('[dsh-swarm][debug] git credential inject skipped task=' + taskId + ' repo=' + dRepo + ': ' + cred.detail);
245
+ }
246
+ }
247
+ };
248
+ try {
249
+ // R1:claim 后任何异常(buildContext/spawn)→ failTask(attempts+1)→ 调度器重派/看门狗熔断;
250
+ // 不留 "running 无 agent" 悬挂。
251
+ await this.kanban.claimTask(taskId, 'system');
252
+ console.error('[dsh-swarm][debug] runner claimed ' + taskId + ' attempts=' + task.attempts);
253
+ context = this.buildContext(task, state, hasRunHistory);
254
+ // 模型候选链(Task 12):primary + fallbacks,model/provider 不可用时静默切换下一候选;
255
+ // 全部候选不可用 → block(model-unavailable) 抛给用户;非 model 错误 → failTask(原逻辑)。
256
+ const candidates = buildModelCandidates(this.config, task.assignee, this.defaultModel);
257
+ if (candidates.length === 0) {
258
+ // 无任何候选配置:不传 agentOptions(用部署默认),单次尝试
259
+ agent = hasRunHistory
260
+ ? await this.resumeOrReuse(this.ctx.get('agents'), task.resumeSessionId ?? `kbn-${taskId}`, { setup })
261
+ : (await this.ctx.get('agents').create({
262
+ sessionId: SessionId(`kbn-${taskId}`),
263
+ meta: { cwd: sessionCwd },
264
+ setup,
265
+ })).agent;
266
+ }
267
+ else {
268
+ const agents = this.ctx.get('agents');
269
+ let spawnError = null;
270
+ for (const candidate of candidates) {
271
+ try {
272
+ // hasRunHistory → resumeOrReuse 直接返回 AgentLike(内部已解包 .agent);create 返回 { agent } 需解包。
273
+ // 统一归一化为 AgentLike,避免二次解包(h.agent=undefined → if(!agent) 误标 failed)。
274
+ const h = hasRunHistory
275
+ ? await this.resumeOrReuse(agents, task.resumeSessionId ?? `kbn-${taskId}`, { agentOptions: candidate, setup })
276
+ : (await agents.create({ sessionId: SessionId(`kbn-${taskId}`), meta: { cwd: sessionCwd }, agentOptions: candidate, setup })).agent;
277
+ agent = h;
278
+ // 切换成功且非首选 → 发可审计 model/fallback 评论(记录证据,不弹用户)
279
+ if (candidate !== candidates[0]) {
280
+ try {
281
+ await this.kanban.comment(taskId, '[model-fallback] 主模型不可用,静默切换 ' + candidate.provider + '/' + candidate.model + '(reasoningEffort=' + (candidate.reasoningEffort ?? 'high') + ')', 'system');
282
+ }
283
+ catch { /* 证据记录失败不影响执行 */ }
284
+ }
285
+ break;
286
+ }
287
+ catch (err) {
288
+ spawnError = err;
289
+ if (!isModelUnavailableError(err))
290
+ throw err; // 非 model 错误立即失败
291
+ console.error('[dsh-swarm][debug] model candidate unavailable ' + String(candidate.provider) + '/' + String(candidate.model) + ': ' + String(err));
292
+ }
293
+ }
294
+ if (!agent) {
295
+ // 全部候选不可用 → block(model-unavailable)(不是 failed 重试;抛给用户处理)
296
+ if (isModelUnavailableError(spawnError)) {
297
+ await this.kanban.blockTask(taskId, 'model-unavailable: all configured candidates failed', 'system');
298
+ return;
299
+ }
300
+ throw spawnError;
301
+ }
302
+ }
303
+ }
304
+ catch (err) {
305
+ // P0-5/R1:claim/buildContext/spawn 失败也走 failed(attempts 递增)→ 调度器重派/看门狗熔断;不再让任务永久 claimed
306
+ console.error('[dsh-swarm][debug] runner spawn error ' + taskId + ': ' + String(err));
307
+ try {
308
+ await this.kanban.failTask(taskId, 'runner-error: ' + String(err), 'system', { infra: isInfraError(err) });
309
+ }
310
+ catch (failErr) {
311
+ // 防御:任务可能已被其他路径完成/归档(终态),failTask 会抛非法转换;记录并继续
312
+ console.error('[dsh-swarm][debug] runner spawn failTask skipped: ' + String(failErr));
313
+ }
314
+ return;
315
+ }
316
+ if (!agent)
317
+ return; // 防御:候选链耗尽已在上方 block(model-unavailable)/throw 处理
318
+ // 归组:角色会话 attach 到 cwd 对应工作区(无则询问创建;失败不阻断)
319
+ const attachId = task.resumeSessionId ?? `kbn-${task.id}`;
320
+ await attachSessionToWorkspace(this.ctx, attachId, sessionCwd, 'task ' + task.id + ' ' + task.assignee + '/' + task.mode);
321
+ try {
322
+ agent.followup({ content: [{ type: 'text', text: context }], source: { kind: 'user' } });
323
+ console.error('[dsh-swarm][debug] runner followup sent ' + taskId);
324
+ await agent.whenIdle();
325
+ console.error('[dsh-swarm][debug] runner whenIdle resolved ' + taskId);
326
+ // 修复轮 6:session.events 条目形态为 {type, data:{name}},name 在 data 下,需经 toolName 读取
327
+ const used = agent.session.events.some((e) => {
328
+ const n = toolName(e);
329
+ return n === 'kanban_complete' || n === 'kanban_block';
330
+ });
331
+ if (!used) {
332
+ // 防御:任务可能已被其他路径完成/归档(终态),此时 blockTask 会抛非法转换(done --task/blocked-->)
333
+ const fresh = await this.kanban.snapshot();
334
+ const cur = fresh.tasks.get(taskId);
335
+ const terminal = cur && (cur.status === 'done' || cur.status === 'archived');
336
+ if (terminal) {
337
+ console.error('[dsh-swarm][debug] runner skip block ' + taskId + ' status=' + (cur ? cur.status : 'gone'));
338
+ }
339
+ else {
340
+ // 协议违规护栏:连续 protocol_violation 阻塞 ≥ maxProtocolViolations(默认 2)后,
341
+ // 下一次违规直接 gave_up(不再恢复,走 [blocked-final] 证据链抛给主 agent)。任意角色(含 pt/dt)统一。
342
+ const maxPV = this.config.dispatcher?.maxProtocolViolations ?? 2;
343
+ const priorViolations = fresh.events.filter((e) => e.taskId === taskId && e.kind === 'task/blocked' &&
344
+ String(e.payload['reason'] ?? '').startsWith('protocol_violation')).length;
345
+ const finalBlock = priorViolations >= maxPV;
346
+ const reason = finalBlock
347
+ ? 'gave_up: protocol_violation after ' + maxPV + ' review cycles without complete/block'
348
+ : 'protocol_violation: idle without complete/block';
349
+ await this.kanban.blockTask(taskId, reason, 'system');
350
+ if (finalBlock) {
351
+ // [blocked-final] 证据链:block 时间线 + 复核/评论时间线 + 最终 reason(system 确定性写入)
352
+ const evs = fresh.events.filter((e) => e.taskId === taskId);
353
+ const blockTimeline = evs
354
+ .filter((e) => e.kind === 'task/blocked')
355
+ .map((e) => ` - seq=${e.seq} at=${e.at} author=${e.author} reason=${String(e.payload['reason'] ?? '')}`)
356
+ .join('\n');
357
+ const reviewTimeline = evs
358
+ .filter((e) => e.kind === 'task/commented')
359
+ .map((e) => ` - seq=${e.seq} at=${e.at} author=${e.author}: ${String(e.payload['body'] ?? '')}`)
360
+ .join('\n');
361
+ await this.kanban.comment(taskId, [
362
+ '[blocked-final] 协议违规超护栏,任务不再自动恢复(人工解除后仍按 gave_up 终态处理)。',
363
+ '## block 时间线',
364
+ blockTimeline,
365
+ '## 复核/评论时间线',
366
+ reviewTimeline || ' - (无复核评论)',
367
+ '最终原因: ' + reason,
368
+ ].join('\n'), 'system');
369
+ }
370
+ }
371
+ }
372
+ }
373
+ catch (err) {
374
+ console.error('[dsh-swarm][debug] runner error ' + taskId + ': ' + String(err));
375
+ // 失败语义(P0-5 统一):发 failed 事件(attempts 递增),由调度器重派或看门狗熔断;不直接 block。
376
+ // 防御:任务可能已被完成/归档(终态),failTask 会抛非法转换(done --task/failed-->)
377
+ try {
378
+ await this.kanban.failTask(taskId, 'runner-error: ' + String(err), 'system', { infra: isInfraError(err) });
379
+ }
380
+ catch (failErr) {
381
+ console.error('[dsh-swarm][debug] runner failTask skipped: ' + String(failErr));
382
+ }
383
+ }
384
+ }
385
+ finally {
386
+ if (task.assignee === 'dt')
387
+ unregisterDtTaskChain(task.id);
388
+ }
389
+ }
390
+ /** RC2:resume 前先查 agents registry 同名会话是否仍 live——live 则直接复用(后续 followup 续用),
391
+ * 避免 block→unblock→重跑同一会话时 resume 抛 "cannot prepare session while it is live"
392
+ * (对齐 VOrchestrator.getVAgent 的 live 复用逻辑)。agents.get 未实现 → 防御回退 resume。 */
393
+ async resumeOrReuse(agents, sessionId, opts) {
394
+ const live = agents.get?.(sessionId);
395
+ if (live)
396
+ return live;
397
+ const h = await agents.resume({ resumeSessionId: SessionId(sessionId), ...opts });
398
+ return h.agent;
399
+ }
400
+ /** M3(B):D(execute) 目标仓库在会话工作空间外时,跑 D 前询问用户是否允许。
401
+ * 经 ctx.userQuestions(GUI 弹窗)单次询问;无询问通道或拒绝 → 返回 false(由调用方 claim+block 等待人工放行)。 */
402
+ async requestRepoPermission(task, repo, sessionCwd) {
403
+ const uq = this.ctx.get?.('userQuestions');
404
+ if (!uq?.ask)
405
+ return false;
406
+ try {
407
+ // 超时护栏:用户长时间不答(无 UI 应答者)不得卡死调度器 tick → 超时按未授权处理(claim+block 等人工放行)
408
+ const ans = await Promise.race([
409
+ uq.ask({
410
+ questions: [{
411
+ id: 'd-repo-permission',
412
+ header: 'D 执行授权',
413
+ question: 'D(唯一执行者)需要在会话工作空间外的目标仓库 ' + repo + ' 执行 git 写操作(worktree/commit/push)。是否允许?',
414
+ detail: '会话工作空间:' + sessionCwd + '。允许后 D 以 full-access 执行且本次不再逐次询问;不允许则阻塞 D 任务等待你在 GUI 放行。',
415
+ options: [
416
+ { label: '允许', description: '授权 D 在该仓库执行(本次任务内不再询问)' },
417
+ { label: '不允许', description: '阻塞 D 任务,等待人工处理' },
418
+ ],
419
+ }],
420
+ }),
421
+ new Promise((_, reject) => setTimeout(() => reject(new Error('repo-permission-ask timeout')), 120_000)),
422
+ ]);
423
+ return ans.answers?.[0]?.selected?.[0] === '允许';
424
+ }
425
+ catch {
426
+ return false; // 询问失败(无 UI/超时)→ 视为未授权,走 block 等人工放行
427
+ }
428
+ }
429
+ }
@@ -0,0 +1,47 @@
1
+ import type { KanbanService } from '../domain/kanban-service.js';
2
+ import type { AuditEvidence, Task } from '../domain/types.js';
3
+ /**
4
+ * D23 链完成验收核对:Chain(completed) 时核对主会话是否越权写工作区产物。
5
+ *
6
+ * 数据源(修复轮 7,修复「评估项目只读排查 run_code 被误判为越权写产物」):
7
+ * 1. 主会话会话事件扫描(primary,尽力而为):枚举活 agent 注册表(ctx.agents.list),
8
+ * 对 id 非 kbn-*(角色会话确定性 id:kbn-<taskId> / kbn-v-<chainId>)的会话,
9
+ * 扫描其 session.events 中的工具调用。插件无法解析主会话真实 session id(路由只用
10
+ * 逻辑 id 'session_main'),故以"非角色会话写 kanban 工作区"为近似。修复轮 7 收紧三点:
11
+ * a. 作用域收窄:仅扫描会话工作区(session.header.cwd)位于本链发起工作区
12
+ * (Chain.workspaceDir = /plan: 主 agent 所在工作空间)内的会话,
13
+ * 排除其他项目的主会话(如 评估 项目里调试 dsh-swarm 的会话);
14
+ * b. 行为判定:run_code 按实际派发子调用(tool/code-dispatch-start / tool/code-dispatch,
15
+ * 经 rootCallId 关联外层调用)判定是否真的发生写,而非把 run_code 一律视为写;
16
+ * c. 只读排除:bash 命令 / 兜底 code 字符串仅当含写操作标记(BASH_WRITE_RE)且
17
+ * 含 workspacesRoot 路径时才计为写证据;纯只读排查(ls/cat/glob/read/grep)不产生证据。
18
+ * 2. 产物归属核对(fallback,机械可测):枚举 workspaces/<chainId>/ 下条目;
19
+ * 角色 agent 只写各自任务工作区(workspaces/<chainId>/<taskId>/),
20
+ * 链工作区根下非任务 id 的条目 = 无主产物(疑似主 agent 越权写)→ 证据。
21
+ */
22
+ export interface ChainAuditorDeps {
23
+ kanban: KanbanService;
24
+ workspacesRoot: string;
25
+ /** 活 agent 注册表快照(dispatcher 注入 ctx.agents.list 的适配);测试可伪造。 */
26
+ listLiveAgents?: () => Array<{
27
+ id: string;
28
+ session?: {
29
+ events: unknown[];
30
+ header?: {
31
+ cwd?: string;
32
+ agentPreset?: string;
33
+ };
34
+ };
35
+ }>;
36
+ }
37
+ export declare class ChainAuditor {
38
+ private readonly kanban;
39
+ private readonly workspacesRoot;
40
+ private readonly listLiveAgents;
41
+ constructor(deps: ChainAuditorDeps);
42
+ /** 执行核对,返回越权证据(空=通过,不阻塞汇报)。
43
+ * @param workspaceDir 本链发起工作区(Chain.workspaceDir);提供时仅扫描工作区内的会话(修复轮 7)。 */
44
+ check(chainId: string, workspaceDir?: string | null): Promise<AuditEvidence[]>;
45
+ private reconcileArtifacts;
46
+ }
47
+ export type { AuditEvidence, Task };