@joekytc/dsh-swarm 0.2.0 → 0.3.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.
- package/README.md +176 -139
- package/README.zh-CN.md +172 -125
- package/client/ConfigSection.tsx +96 -5
- package/client/ConfigSelect.tsx +3 -1
- package/client/TaskDrawer.tsx +2 -0
- package/client/config-store.ts +48 -7
- package/client/kanban.css +69 -0
- package/client/timeline-model.ts +2 -0
- package/lib/client.js +172 -14
- package/lib/config.d.ts +18 -1
- package/lib/config.js +14 -0
- package/lib/dispatcher/agent-runner.d.ts +13 -5
- package/lib/dispatcher/agent-runner.js +78 -38
- package/lib/dispatcher/chain-auditor.d.ts +4 -4
- package/lib/dispatcher/chain-auditor.js +10 -8
- package/lib/dispatcher/dispatcher.d.ts +18 -18
- package/lib/dispatcher/dispatcher.js +59 -43
- package/lib/dispatcher/event-waker.d.ts +1 -1
- package/lib/dispatcher/event-waker.js +2 -2
- package/lib/dispatcher/git-credentials.d.ts +2 -2
- package/lib/dispatcher/git-credentials.js +2 -2
- package/lib/dispatcher/model-candidates.d.ts +1 -1
- package/lib/dispatcher/model-candidates.js +1 -1
- package/lib/dispatcher/session-events.d.ts +1 -1
- package/lib/dispatcher/session-events.js +1 -1
- package/lib/dispatcher/target-repo.d.ts +2 -2
- package/lib/dispatcher/target-repo.js +3 -3
- package/lib/dispatcher/v-orchestrator.d.ts +8 -8
- package/lib/dispatcher/v-orchestrator.js +41 -41
- package/lib/dispatcher/watchdog.d.ts +1 -1
- package/lib/dispatcher/watchdog.js +2 -2
- package/lib/domain/config-override.d.ts +14 -0
- package/lib/domain/config-override.js +38 -1
- package/lib/domain/delivery-contract.d.ts +2 -2
- package/lib/domain/delivery-contract.js +5 -5
- package/lib/domain/delivery-evidence.d.ts +1 -1
- package/lib/domain/delivery-evidence.js +1 -1
- package/lib/domain/event-store.js +1 -1
- package/lib/domain/im-message.d.ts +15 -0
- package/lib/domain/im-message.js +149 -0
- package/lib/domain/kanban-service.d.ts +13 -10
- package/lib/domain/kanban-service.js +36 -28
- package/lib/domain/memory.js +1 -1
- package/lib/domain/ocr-review.d.ts +47 -0
- package/lib/domain/ocr-review.js +105 -0
- package/lib/domain/permissions.js +5 -5
- package/lib/domain/projection.js +6 -6
- package/lib/domain/state-machine.js +1 -1
- package/lib/domain/task-parents.js +1 -1
- package/lib/domain/types.d.ts +4 -4
- package/lib/index.js +14 -9
- package/lib/roles/clean-fs-tools.js +3 -4
- package/lib/roles/preset-installer.js +3 -3
- package/lib/roles/skill-installer.js +1 -1
- package/lib/roles/toolsets.d.ts +60 -16
- package/lib/roles/toolsets.js +265 -47
- package/lib/roles/wiki-worker.js +3 -3
- package/lib/routes/kanban-http.d.ts +25 -2
- package/lib/routes/kanban-http.js +171 -11
- package/lib/routes/kanban-sse.d.ts +1 -1
- package/lib/routes/kanban-sse.js +1 -1
- package/lib/routes/planning-driver.d.ts +4 -2
- package/lib/routes/planning-driver.js +7 -3
- package/lib/routes/prefix-router.d.ts +3 -3
- package/lib/routes/prefix-router.js +3 -1
- package/lib/services/config-provider.d.ts +0 -4
- package/lib/services/config-provider.js +7 -9
- package/lib/services/im-delivery.d.ts +94 -0
- package/lib/services/im-delivery.js +263 -0
- package/lib/services/kanban-provider.d.ts +1 -1
- package/lib/services/kanban-provider.js +2 -2
- package/lib/services/ocr-cli.d.ts +35 -0
- package/lib/services/ocr-cli.js +128 -0
- package/lib/tools/kanban-tools.d.ts +2 -2
- package/lib/tools/kanban-tools.js +1 -1
- package/lib/tools/main-session-tools.d.ts +6 -2
- package/lib/tools/main-session-tools.js +58 -20
- package/lib/tools/ocr-review-tools.d.ts +11 -0
- package/lib/tools/ocr-review-tools.js +63 -0
- package/lib/tools/planning-tools.d.ts +5 -2
- package/lib/tools/planning-tools.js +11 -9
- package/lib/tools/wiki-tools.js +2 -2
- package/lib/wiki/kb-linkage.d.ts +1 -1
- package/lib/wiki/local-kb-client.js +3 -3
- package/lib/wiki/local-kb.d.ts +1 -1
- package/lib/wiki/local-kb.js +2 -2
- package/lib/wiki/page-path.d.ts +1 -1
- package/lib/wiki/page-path.js +6 -3
- package/lib/wiki/wiki-vault-client.d.ts +1 -1
- package/lib/wiki/wiki-vault-client.js +1 -1
- package/package.json +1 -1
- package/personas/kanban-d/agent.cordis.yml +5 -5
- package/personas/kanban-dt/agent.cordis.yml +23 -5
- package/personas/kanban-p/agent.cordis.yml +3 -3
- package/personas/kanban-pt/agent.cordis.yml +1 -1
- package/personas/kanban-v/agent.cordis.yml +3 -3
- package/personas/kanban-w/agent.cordis.yml +5 -5
- package/personas/persona-d.md +1 -1
- package/personas/persona-dt.md +12 -4
- package/personas/persona-p.md +1 -1
- package/personas/persona-v.md +2 -2
- package/personas/swarm/agent.cordis.yml +43 -0
- package/personas/swarm/preset.yml +5 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { buildCompletionMessage, buildBlockMessage } from '../domain/im-message.js';
|
|
5
|
+
const RETRYABLE_CODES = new Set(['bot-not-connected', 'delivery-failed']);
|
|
6
|
+
const DEFAULT_RETRY_DELAYS_MS = [5_000, 10_000, 15_000];
|
|
7
|
+
export function isDshImLike(svc) {
|
|
8
|
+
const s = svc;
|
|
9
|
+
return typeof s === 'object' && s !== null
|
|
10
|
+
&& typeof s.send === 'function'
|
|
11
|
+
&& typeof s.listBots === 'function'
|
|
12
|
+
&& typeof s.listTargets === 'function';
|
|
13
|
+
}
|
|
14
|
+
function resolveDshIm(ctx, log) {
|
|
15
|
+
const svc = ctx.get('dshIm');
|
|
16
|
+
if (!isDshImLike(svc)) {
|
|
17
|
+
log('[im-delivery] dshIm 服务缺失或形状不符,显式降级跳过(本次不投递)');
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
return svc;
|
|
21
|
+
}
|
|
22
|
+
/** botId/targetId 解析(评审决议):配置显式指定优先;留空自动发现唯一 wecom bot + 唯一已保存群目标;
|
|
23
|
+
* 发现异常返回 error(调用方留痕不投,fail-closed——投错群比不投更糟)。 */
|
|
24
|
+
export async function resolveTarget(im, cfg) {
|
|
25
|
+
let botId = cfg.botId.trim();
|
|
26
|
+
if (!botId) {
|
|
27
|
+
const bots = await im.listBots();
|
|
28
|
+
const wecom = bots.filter((b) => b.channel === 'wecom');
|
|
29
|
+
if (wecom.length !== 1)
|
|
30
|
+
return { error: `企微机器人数量=${wecom.length}(期望 1),请在插件配置 imDelivery.botId 显式指定` };
|
|
31
|
+
botId = wecom[0].botId;
|
|
32
|
+
}
|
|
33
|
+
let targetId = cfg.targetId.trim();
|
|
34
|
+
if (!targetId) {
|
|
35
|
+
const raw = await im.listTargets(botId);
|
|
36
|
+
// 宿主同 Host 服务返回裸数组(PROACTIVE_DELIVERY.md:176);防御兼容 Connection RPC `target.list` 信封形状。
|
|
37
|
+
const targets = Array.isArray(raw) ? raw : Array.isArray(raw?.targets)
|
|
38
|
+
? raw.targets
|
|
39
|
+
: [];
|
|
40
|
+
const groups = targets.filter((x) => x.kind === 'group');
|
|
41
|
+
if (groups.length !== 1)
|
|
42
|
+
return { error: `已保存群目标数量=${groups.length}(期望 1),请到 dsh-im 设置→IM机器人 新建目标或在插件配置 imDelivery.targetId 显式指定` };
|
|
43
|
+
targetId = groups[0].targetId;
|
|
44
|
+
}
|
|
45
|
+
return { botId, targetId };
|
|
46
|
+
}
|
|
47
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
48
|
+
/** 发送 + 退避重试(仅可重试错误码;{sent:true} 即停——dsh-im 无幂等,成功后绝不重发)。 */
|
|
49
|
+
export async function sendWithRetry(im, botId, targetId, text, delays, log) {
|
|
50
|
+
for (let attempt = 0;; attempt++) {
|
|
51
|
+
try {
|
|
52
|
+
const r = await im.send(botId, targetId, text);
|
|
53
|
+
if (r?.sent !== true) {
|
|
54
|
+
const e = new Error('dsh-im send 返回 sent!==true');
|
|
55
|
+
e.code = 'delivery-failed';
|
|
56
|
+
throw e;
|
|
57
|
+
}
|
|
58
|
+
return { ok: true };
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
const code = err.code ?? 'delivery-failed';
|
|
62
|
+
if (attempt >= delays.length || !RETRYABLE_CODES.has(code)) {
|
|
63
|
+
return { ok: false, error: `${code}: ${String(err)}` };
|
|
64
|
+
}
|
|
65
|
+
log(`[im-delivery] send failed (attempt ${attempt + 1}, code=${code}),retry after ${delays[attempt]}ms 后重试`);
|
|
66
|
+
await sleep(delays[attempt]);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** storageDir 派生(config 占位符 $DSH_HOME 替换;auto 与 /sms 手动路径同源)。 */
|
|
71
|
+
function deriveStorageDir(configProvider) {
|
|
72
|
+
return configProvider.getEffective().storageDir.replace('$DSH_HOME', process.env.DSH_HOME ?? homedir());
|
|
73
|
+
}
|
|
74
|
+
function makeDefaultLog(storageDir) {
|
|
75
|
+
return (msg) => {
|
|
76
|
+
try {
|
|
77
|
+
writeFileSync(join(storageDir, 'dispatcher.log'), new Date().toISOString() + ' ' + msg + '\n', { flag: 'a' });
|
|
78
|
+
}
|
|
79
|
+
catch { /* 忽略写失败 */ }
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/** 发送器工厂:resolveDshIm → resolveTarget(fail-closed)→ sendWithRetry → 留痕。
|
|
83
|
+
* auto 路径(wireImDelivery)失败额外写 chain/im-delivery-failed 链事件;
|
|
84
|
+
* manual 路径(/sms,opts.manual)仅 dispatcher.log 留痕,错误同步返回给调用方。 */
|
|
85
|
+
export function createSender(ctx, kanban, configProvider, opts = {}) {
|
|
86
|
+
const log = opts.log ?? makeDefaultLog(deriveStorageDir(configProvider));
|
|
87
|
+
const delays = opts.retryDelaysMs ?? DEFAULT_RETRY_DELAYS_MS;
|
|
88
|
+
return async (chainId, text) => {
|
|
89
|
+
const cfg = configProvider.getEffective().imDelivery;
|
|
90
|
+
const im = resolveDshIm(ctx, log);
|
|
91
|
+
if (!im)
|
|
92
|
+
return { ok: false, error: 'dshIm 服务缺失或形状不符(需 @xmanrui/dsh-im 宿主服务)' };
|
|
93
|
+
const t = await resolveTarget(im, cfg);
|
|
94
|
+
if ('error' in t) {
|
|
95
|
+
log(`[im-delivery] target resolve failed chain=${chainId}: ${t.error}`);
|
|
96
|
+
return { ok: false, error: t.error };
|
|
97
|
+
}
|
|
98
|
+
const r = await sendWithRetry(im, t.botId, t.targetId, text, delays, log);
|
|
99
|
+
if (r.ok) {
|
|
100
|
+
log(`[im-delivery] delivered chain=${chainId} bot=${t.botId} target=${t.targetId}`);
|
|
101
|
+
return { ok: true, botId: t.botId, targetId: t.targetId };
|
|
102
|
+
}
|
|
103
|
+
// 超限显形(评审决议):dispatcher.log 留痕;auto 路径追加 events.jsonl 双留痕;投递失败绝不 block 链。
|
|
104
|
+
log(`[im-delivery] FAILED chain=${chainId}: ${r.error}`);
|
|
105
|
+
if (!opts.manual) {
|
|
106
|
+
try {
|
|
107
|
+
await kanban.noteImDeliveryFailed(chainId, `企微投递失败(重试 ${delays.length} 次后放弃):${r.error}`, 'system');
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
log(`[im-delivery] noteImDeliveryFailed failed chain=${chainId}: ` + String(err));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return { ok: false, error: r.error };
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
/** 链上最后一个带 taskId 的 task/completed 事件(state.events 按 seq 有序)。 */
|
|
117
|
+
function lastCompleted(state, chainId) {
|
|
118
|
+
const last = state.events.filter((e) => e.chainId === chainId && e.kind === 'task/completed' && e.taskId).at(-1);
|
|
119
|
+
return last?.taskId ? { taskId: last.taskId, at: last.at } : null;
|
|
120
|
+
}
|
|
121
|
+
function lastChainBlockedAt(state, chainId) {
|
|
122
|
+
const last = state.events.filter((e) => e.chainId === chainId && e.kind === 'chain/blocked').at(-1);
|
|
123
|
+
return last?.at ?? -1;
|
|
124
|
+
}
|
|
125
|
+
/** 全量链内按 精确 id → 唯一 id 后缀 解析(/learning UX:歧义返回候选列表)。 */
|
|
126
|
+
function findByIdOrSuffix(chains, query) {
|
|
127
|
+
const exact = chains.find((c) => c.id === query);
|
|
128
|
+
if (exact)
|
|
129
|
+
return { ok: true, chainId: exact.id };
|
|
130
|
+
const matches = chains.filter((c) => c.id.endsWith(query));
|
|
131
|
+
if (matches.length === 1)
|
|
132
|
+
return { ok: true, chainId: matches[0].id };
|
|
133
|
+
if (matches.length === 0)
|
|
134
|
+
return { ok: false, error: 'chain-not-found' };
|
|
135
|
+
return { ok: false, error: 'chain-ambiguous', candidates: matches.map((c) => ({ chainId: c.id, title: c.title })) };
|
|
136
|
+
}
|
|
137
|
+
/** /sms 链解析(纯函数)。空 query:completion=最近满足 W3 完成判据的链(最后完成事件 at 最大);
|
|
138
|
+
* blocked=最近阻塞的链(chain/blocked 事件 at 最大)。显式 query:全量链精确/后缀解析后再验判据。 */
|
|
139
|
+
export function resolveReportChainId(state, variant, query) {
|
|
140
|
+
const q = query.trim();
|
|
141
|
+
const chains = [...state.chains.values()];
|
|
142
|
+
if (variant === 'blocked') {
|
|
143
|
+
const blockedChains = chains.filter((c) => c.status === 'blocked');
|
|
144
|
+
if (!q) {
|
|
145
|
+
if (blockedChains.length === 0)
|
|
146
|
+
return { ok: false, error: 'chain-not-found' };
|
|
147
|
+
let best = blockedChains[0];
|
|
148
|
+
let bestAt = lastChainBlockedAt(state, best.id);
|
|
149
|
+
for (const c of blockedChains.slice(1)) {
|
|
150
|
+
const at = lastChainBlockedAt(state, c.id);
|
|
151
|
+
if (at > bestAt) {
|
|
152
|
+
best = c;
|
|
153
|
+
bestAt = at;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return { ok: true, chainId: best.id };
|
|
157
|
+
}
|
|
158
|
+
const resolved = findByIdOrSuffix(chains, q);
|
|
159
|
+
if (!resolved.ok)
|
|
160
|
+
return resolved;
|
|
161
|
+
const chain = state.chains.get(resolved.chainId);
|
|
162
|
+
if (chain.status !== 'blocked')
|
|
163
|
+
return { ok: false, error: 'not-blocked' };
|
|
164
|
+
return { ok: true, chainId: chain.id };
|
|
165
|
+
}
|
|
166
|
+
// completion:判据即 buildCompletionMessage !== null(W3 收尾机械判据,防 W2 中间态误报)
|
|
167
|
+
const candidates = chains.filter((c) => {
|
|
168
|
+
const lc = lastCompleted(state, c.id);
|
|
169
|
+
return lc !== null && buildCompletionMessage(state, c.id, lc.taskId, Date.now()) !== null;
|
|
170
|
+
});
|
|
171
|
+
if (!q) {
|
|
172
|
+
if (candidates.length === 0)
|
|
173
|
+
return { ok: false, error: 'chain-not-found' };
|
|
174
|
+
let best = candidates[0];
|
|
175
|
+
let bestAt = lastCompleted(state, best.id)?.at ?? -1;
|
|
176
|
+
for (const c of candidates.slice(1)) {
|
|
177
|
+
const at = lastCompleted(state, c.id)?.at ?? -1;
|
|
178
|
+
if (at > bestAt) {
|
|
179
|
+
best = c;
|
|
180
|
+
bestAt = at;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return { ok: true, chainId: best.id };
|
|
184
|
+
}
|
|
185
|
+
const resolved = findByIdOrSuffix(chains, q);
|
|
186
|
+
if (!resolved.ok)
|
|
187
|
+
return resolved;
|
|
188
|
+
if (!candidates.some((c) => c.id === resolved.chainId))
|
|
189
|
+
return { ok: false, error: 'completion-not-met' };
|
|
190
|
+
return { ok: true, chainId: resolved.chainId };
|
|
191
|
+
}
|
|
192
|
+
/** /sms 失败 guidance(主会话模型原样转述给用户;绝不生成消息正文)。 */
|
|
193
|
+
function reportGuidance(error, candidates, sendCmd) {
|
|
194
|
+
if (error === 'chain-ambiguous' && candidates?.length) {
|
|
195
|
+
const list = candidates.map((c) => `- ${c.chainId} ${c.title}`).join('\n');
|
|
196
|
+
return `匹配到多条链,请用 ${sendCmd} <chainId> 精确指定:\n${list}`;
|
|
197
|
+
}
|
|
198
|
+
if (error === 'chain-not-found')
|
|
199
|
+
return `未找到可汇报的链。可用 ${sendCmd} <chainId> 指定(完成后自动汇报的链),或 ${sendCmd} blocked [chainId] 重发阻塞通知。`;
|
|
200
|
+
if (error === 'completion-not-met')
|
|
201
|
+
return '该链不满足完成汇报判据(W3 未收尾或中间态)。';
|
|
202
|
+
if (error === 'not-blocked')
|
|
203
|
+
return '链未处于阻塞态,无阻塞通知可发。';
|
|
204
|
+
return '投递失败,请将 error 字段原样转告用户,勿自行编造原因、勿复述消息正文。';
|
|
205
|
+
}
|
|
206
|
+
/** /sms 手动投递:解析链 → 领域函数渲染正文(红线:正文只出自 buildCompletionMessage/buildBlockMessage,
|
|
207
|
+
* 绝不返回给模型)→ createSender 发送。不受 imDelivery.enabled 门控(显式人工调用即意图),
|
|
208
|
+
* 但仍要求 dshIm 服务在位且形状合法、目标可解析(同 auto 路径 fail-closed 规则)。 */
|
|
209
|
+
export async function sendChainReport(ctx, kanban, configProvider, opts, variant, query) {
|
|
210
|
+
const state = await kanban.snapshot();
|
|
211
|
+
const resolved = resolveReportChainId(state, variant, query);
|
|
212
|
+
if (!resolved.ok) {
|
|
213
|
+
return { ok: false, error: resolved.error, guidance: reportGuidance(resolved.error, resolved.candidates, configProvider.getEffective().prefixRoutes.send) };
|
|
214
|
+
}
|
|
215
|
+
const chainId = resolved.chainId;
|
|
216
|
+
let text;
|
|
217
|
+
if (variant === 'completion') {
|
|
218
|
+
const lc = lastCompleted(state, chainId);
|
|
219
|
+
const rendered = lc !== null ? buildCompletionMessage(state, chainId, lc.taskId, Date.now()) : null;
|
|
220
|
+
if (rendered === null)
|
|
221
|
+
return { ok: false, error: 'completion-not-met' };
|
|
222
|
+
text = rendered;
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
const reason = state.events.filter((e) => e.chainId === chainId && e.kind === 'chain/blocked').at(-1)?.payload['reason'];
|
|
226
|
+
text = buildBlockMessage(state, chainId, String(reason ?? ''), deriveStorageDir(configProvider));
|
|
227
|
+
}
|
|
228
|
+
const r = await createSender(ctx, kanban, configProvider, { ...opts, manual: true })(chainId, text);
|
|
229
|
+
if (!r.ok)
|
|
230
|
+
return { ok: false, error: r.error };
|
|
231
|
+
return { ok: true, chainId, botId: r.botId, targetId: r.targetId };
|
|
232
|
+
}
|
|
233
|
+
/** 接线:订阅看板事件多播通道(不动 setOnTaskCompleted 单消费者钩子)。
|
|
234
|
+
* listener 同步返回,投递全程 fire-and-forget 自兜异常(publish 在 emit 队列内同步调用,不得拖慢落盘)。 */
|
|
235
|
+
export function wireImDelivery(ctx, kanban, configProvider, opts = {}) {
|
|
236
|
+
const storageDir = deriveStorageDir(configProvider);
|
|
237
|
+
const log = opts.log ?? makeDefaultLog(storageDir);
|
|
238
|
+
const deliver = createSender(ctx, kanban, configProvider, opts);
|
|
239
|
+
const handle = (ev) => {
|
|
240
|
+
void (async () => {
|
|
241
|
+
const cfg = configProvider.getEffective().imDelivery;
|
|
242
|
+
if (!cfg?.enabled)
|
|
243
|
+
return; // 功能关闭:零开销早退(非异常,不留痕)
|
|
244
|
+
// 仅 task/completed(带 taskId)与 chain/blocked 关心事件流;其余早退,避免每个无关事件触发 snapshot 全量重放
|
|
245
|
+
if (!((ev.kind === 'task/completed' && ev.taskId) || ev.kind === 'chain/blocked'))
|
|
246
|
+
return;
|
|
247
|
+
const state = await kanban.snapshot();
|
|
248
|
+
if (ev.kind === 'task/completed' && ev.taskId) {
|
|
249
|
+
const msg = buildCompletionMessage(state, ev.chainId, ev.taskId, Date.now());
|
|
250
|
+
if (msg)
|
|
251
|
+
await deliver(ev.chainId, msg);
|
|
252
|
+
}
|
|
253
|
+
else if (ev.kind === 'chain/blocked') {
|
|
254
|
+
const chain = state.chains.get(ev.chainId);
|
|
255
|
+
if (!chain)
|
|
256
|
+
return;
|
|
257
|
+
const msg = buildBlockMessage(state, ev.chainId, String(ev.payload['reason'] ?? ''), storageDir);
|
|
258
|
+
await deliver(ev.chainId, msg);
|
|
259
|
+
}
|
|
260
|
+
})().catch((err) => log('[im-delivery] handler failed ev=' + ev.kind + ': ' + String(err)));
|
|
261
|
+
};
|
|
262
|
+
return kanban.subscribe(handle);
|
|
263
|
+
}
|
|
@@ -9,7 +9,7 @@ declare module '@deepseek-ai/cordis' {
|
|
|
9
9
|
}
|
|
10
10
|
export declare class KanbanProvider extends Service {
|
|
11
11
|
readonly service: KanbanService;
|
|
12
|
-
/**
|
|
12
|
+
/** GUI retry 的任务执行器(由 startDispatcher 装配后注入;webServer 先于 agents 就绪时可为 null)。 */
|
|
13
13
|
runner: {
|
|
14
14
|
runTask(taskId: string): Promise<void>;
|
|
15
15
|
} | null;
|
|
@@ -7,12 +7,12 @@ import { deriveGatePlan, isGatePlan, branchMatches } from '../domain/gate-policy
|
|
|
7
7
|
import { runGateCommands } from './gate-runner.js';
|
|
8
8
|
export class KanbanProvider extends Service {
|
|
9
9
|
service;
|
|
10
|
-
/**
|
|
10
|
+
/** GUI retry 的任务执行器(由 startDispatcher 装配后注入;webServer 先于 agents 就绪时可为 null)。 */
|
|
11
11
|
runner = null;
|
|
12
12
|
/** 整链硬删除后的联动钩子(由 startDispatcher 装配注入):dispatcher 游标同步 + V 编排 entry 清理。
|
|
13
13
|
* purge 物理重排 events seq,若不同步则删链后新建链的可唤醒事件被运行中实例永久跳过。 */
|
|
14
14
|
onChainDeleted = null;
|
|
15
|
-
//
|
|
15
|
+
// 经 configProvider getter 读 effective 配置——配置面板改 wikiVault.baseUrl 后 kb_url 前缀校验热生效。
|
|
16
16
|
constructor(ctx, config, configProvider) {
|
|
17
17
|
super(ctx, 'kanban');
|
|
18
18
|
const dir = config.storageDir.replace('$DSH_HOME', process.env.DSH_HOME ?? homedir());
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** ocr CLI runner(@alibaba-group/open-code-review 命令行):二进制定位、探活、执行与托管 provider 配置。
|
|
2
|
+
* 踩坑警告:插件进程 PATH 常缺 npm 全局 bin 目录,不能裸依赖 PATH 找 ocr(社区方案踩过)——
|
|
3
|
+
* 故按常见 npm 全局 bin 目录逐个探测并进程内缓存定位结果。 */
|
|
4
|
+
import { execFile } from 'node:child_process';
|
|
5
|
+
export declare const OCR_PACKAGE = "@alibaba-group/open-code-review";
|
|
6
|
+
export declare const OCR_MANAGED_PROVIDER_NAME = "dsh-managed";
|
|
7
|
+
export declare const INSTALL_GUIDANCE: string;
|
|
8
|
+
export type OcrCliDeps = {
|
|
9
|
+
execFileFn?: typeof execFile;
|
|
10
|
+
env?: NodeJS.ProcessEnv;
|
|
11
|
+
homedirFn?: () => string;
|
|
12
|
+
};
|
|
13
|
+
export declare function probeOcr(deps?: OcrCliDeps): Promise<{
|
|
14
|
+
installed: boolean;
|
|
15
|
+
version: string;
|
|
16
|
+
binPath: string | null;
|
|
17
|
+
}>;
|
|
18
|
+
export declare function runOcr(args: string[], opts: {
|
|
19
|
+
cwd: string;
|
|
20
|
+
timeoutMs?: number;
|
|
21
|
+
}, deps?: OcrCliDeps): Promise<{
|
|
22
|
+
stdout: string;
|
|
23
|
+
stderr: string;
|
|
24
|
+
error?: string;
|
|
25
|
+
}>;
|
|
26
|
+
export declare function managedProviderReady(deps?: OcrCliDeps): boolean;
|
|
27
|
+
export declare function wireManagedProvider(a: {
|
|
28
|
+
baseUrl: string;
|
|
29
|
+
protocol: 'openai' | 'anthropic';
|
|
30
|
+
apiKey: string;
|
|
31
|
+
model: string;
|
|
32
|
+
}, deps?: OcrCliDeps): Promise<{
|
|
33
|
+
ok: boolean;
|
|
34
|
+
log: string;
|
|
35
|
+
}>;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// src/services/ocr-cli.ts
|
|
2
|
+
/** ocr CLI runner(@alibaba-group/open-code-review 命令行):二进制定位、探活、执行与托管 provider 配置。
|
|
3
|
+
* 踩坑警告:插件进程 PATH 常缺 npm 全局 bin 目录,不能裸依赖 PATH 找 ocr(社区方案踩过)——
|
|
4
|
+
* 故按常见 npm 全局 bin 目录逐个探测并进程内缓存定位结果。 */
|
|
5
|
+
import { execFile } from 'node:child_process';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
8
|
+
import { homedir } from 'node:os';
|
|
9
|
+
import { delimiter, join } from 'node:path';
|
|
10
|
+
export const OCR_PACKAGE = '@alibaba-group/open-code-review';
|
|
11
|
+
export const OCR_MANAGED_PROVIDER_NAME = 'dsh-managed';
|
|
12
|
+
export const INSTALL_GUIDANCE = [
|
|
13
|
+
`未检测到 ocr CLI(${OCR_PACKAGE})。`,
|
|
14
|
+
`请二选一安装:在 GUI 配置面板点击「安装 ocr」按钮;或在终端执行 \`npm install -g ${OCR_PACKAGE}\`。`,
|
|
15
|
+
'安装后执行 `ocr --version` 验证。',
|
|
16
|
+
'托管模式下 ocr 的模型 provider 由本插件统一接管配置,无需手工登录。',
|
|
17
|
+
].join('');
|
|
18
|
+
const BIN_NAME = 'ocr';
|
|
19
|
+
/** 版本号分段数值比较(升序):'v22.22.2' 与 'v9.1.0' 字符串比较会错序,必须逐段转数字。 */
|
|
20
|
+
function versionAsc(a, b) {
|
|
21
|
+
const pa = a.replace(/^v/, '').split('.');
|
|
22
|
+
const pb = b.replace(/^v/, '').split('.');
|
|
23
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
24
|
+
const d = Number(pa[i] ?? 0) - Number(pb[i] ?? 0);
|
|
25
|
+
if (d !== 0)
|
|
26
|
+
return d;
|
|
27
|
+
}
|
|
28
|
+
return 0;
|
|
29
|
+
}
|
|
30
|
+
/** 扫描目录序:进程 PATH(nvm 激活版本在其中)→ nvm versions 布局取最新 → 常见固定 bin 目录。
|
|
31
|
+
* 踩坑:标准 nvm 布局是 ~/.nvm/versions/node/<ver>/bin,~/.nvm/current 软链仅部分环境存在,
|
|
32
|
+
* 只扫后者会漏掉 npm install -g 的真实落点(GUI 安装成功但探活判未安装的根因)。 */
|
|
33
|
+
const scanBinDirs = (home, env) => {
|
|
34
|
+
const dirs = (env.PATH ?? '').split(delimiter).filter(Boolean);
|
|
35
|
+
const nvmRoot = join(home, '.nvm', 'versions', 'node');
|
|
36
|
+
try {
|
|
37
|
+
for (const v of readdirSync(nvmRoot).sort(versionAsc).reverse())
|
|
38
|
+
dirs.push(join(nvmRoot, v, 'bin'));
|
|
39
|
+
}
|
|
40
|
+
catch { /* 无 nvm 目录:跳过 */ }
|
|
41
|
+
dirs.push(join(home, '.nvm', 'current', 'bin'), '/opt/homebrew/bin', '/usr/local/bin', join(home, '.local', 'bin'));
|
|
42
|
+
return dirs;
|
|
43
|
+
};
|
|
44
|
+
/** 进程内缓存按 homedir 隔离,避免注入不同 home 时串路径;命中后仍需 existsSync 复核(文件可能已被删)。 */
|
|
45
|
+
let cached = null;
|
|
46
|
+
function resolveBin(deps = {}) {
|
|
47
|
+
const env = deps.env ?? process.env;
|
|
48
|
+
const fromEnv = env.OCR_OCR_BIN;
|
|
49
|
+
if (fromEnv)
|
|
50
|
+
return fromEnv; // 显式指定优先,不进缓存
|
|
51
|
+
const home = deps.homedirFn ? deps.homedirFn() : homedir();
|
|
52
|
+
if (cached && cached.homedir === home && existsSync(cached.binPath))
|
|
53
|
+
return cached.binPath;
|
|
54
|
+
for (const dir of scanBinDirs(home, env)) {
|
|
55
|
+
const candidate = join(dir, BIN_NAME);
|
|
56
|
+
if (existsSync(candidate)) {
|
|
57
|
+
cached = { homedir: home, binPath: candidate };
|
|
58
|
+
return candidate;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return null; // 未命中不缓存,留待下次重扫
|
|
62
|
+
}
|
|
63
|
+
export async function probeOcr(deps = {}) {
|
|
64
|
+
const binPath = resolveBin(deps);
|
|
65
|
+
if (!binPath)
|
|
66
|
+
return { installed: false, version: '', binPath: null };
|
|
67
|
+
try {
|
|
68
|
+
const run = promisify(deps.execFileFn ?? execFile);
|
|
69
|
+
// 始终传 options 对象:保持 (bin, args, opts, cb) 四参调用形态,promisify 包装的回调式 fake/真实现均兼容
|
|
70
|
+
const { stdout } = await run(binPath, ['--version'], {});
|
|
71
|
+
return { installed: true, version: stdout.split('\n')[0].trim(), binPath };
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// 探活失败(含 ENOENT):定位结果仍保留,供上层展示/诊断
|
|
75
|
+
return { installed: false, version: '', binPath };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
export async function runOcr(args, opts, deps = {}) {
|
|
79
|
+
const binPath = resolveBin(deps);
|
|
80
|
+
if (!binPath)
|
|
81
|
+
return { stdout: '', stderr: INSTALL_GUIDANCE, error: 'ocr-not-installed' };
|
|
82
|
+
const run = promisify(deps.execFileFn ?? execFile);
|
|
83
|
+
try {
|
|
84
|
+
const { stdout, stderr } = await run(binPath, args, {
|
|
85
|
+
cwd: opts.cwd,
|
|
86
|
+
timeout: opts.timeoutMs ?? 600_000,
|
|
87
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
88
|
+
windowsHide: true,
|
|
89
|
+
});
|
|
90
|
+
return { stdout, stderr };
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
// 绝不抛出:非零退出/超时/ENOENT 统一转结果对象(promisify 拒因自带 stdout/stderr)
|
|
94
|
+
const e = err;
|
|
95
|
+
return {
|
|
96
|
+
stdout: typeof e.stdout === 'string' ? e.stdout : '',
|
|
97
|
+
stderr: typeof e.stderr === 'string' ? e.stderr : '',
|
|
98
|
+
error: e.message ?? String(err),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
export function managedProviderReady(deps = {}) {
|
|
103
|
+
try {
|
|
104
|
+
const home = deps.homedirFn ? deps.homedirFn() : homedir();
|
|
105
|
+
const cfg = JSON.parse(readFileSync(join(home, '.opencodereview', 'config.json'), 'utf8'));
|
|
106
|
+
return cfg.provider === OCR_MANAGED_PROVIDER_NAME && typeof cfg.model === 'string' && cfg.model.length > 0;
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return false; // 文件不存在/解析失败/字段缺失一律视为未就绪
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
export async function wireManagedProvider(a, deps = {}) {
|
|
113
|
+
// ocr 官方非交互配置:五组 config set 按序写入自定义 provider
|
|
114
|
+
const steps = [
|
|
115
|
+
['provider', ['config', 'set', 'provider', OCR_MANAGED_PROVIDER_NAME]],
|
|
116
|
+
['model', ['config', 'set', 'model', a.model]],
|
|
117
|
+
['providers.dsh-managed.url', ['config', 'set', 'providers.dsh-managed.url', a.baseUrl]],
|
|
118
|
+
['providers.dsh-managed.protocol', ['config', 'set', 'providers.dsh-managed.protocol', a.protocol]],
|
|
119
|
+
['providers.dsh-managed.api_key', ['config', 'set', 'providers.dsh-managed.api_key', a.apiKey]],
|
|
120
|
+
];
|
|
121
|
+
const home = deps.homedirFn ? deps.homedirFn() : homedir();
|
|
122
|
+
for (const [name, args] of steps) {
|
|
123
|
+
const r = await runOcr(args, { cwd: home, timeoutMs: 120_000 }, deps);
|
|
124
|
+
if (r.error)
|
|
125
|
+
return { ok: false, log: `config set ${name} 失败: ${r.error} ${r.stderr.slice(0, 500)}`.trim() };
|
|
126
|
+
}
|
|
127
|
+
return { ok: true, log: 'ocr managed provider wired: dsh-managed' };
|
|
128
|
+
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { KanbanService } from '../domain/kanban-service.js';
|
|
2
2
|
import { type Actor } from '../domain/permissions.js';
|
|
3
3
|
/** 工具执行上下文:由角色 agent scope 注入;主会话=human;调度器=system。
|
|
4
|
-
* boundTaskId:角色 agent 会话绑定的任务(AgentSessionRef.task_id
|
|
4
|
+
* boundTaskId:角色 agent 会话绑定的任务(AgentSessionRef.task_id)。 */
|
|
5
5
|
export interface ToolCaller {
|
|
6
6
|
actor: Actor;
|
|
7
7
|
boundTaskId?: string;
|
|
8
8
|
}
|
|
9
|
-
/**
|
|
9
|
+
/** 工具定义工厂:不直接注册;由 toolsets 按角色 agent-scope 装配,或由主会话注册其专属子集。
|
|
10
10
|
* getCaller:装配方提供的闭包(捕获 actor/boundTaskId),execute 内取用——不依赖 execute 第二参数(DSH ToolRunContext 真实形状以官方为准)。 */
|
|
11
11
|
export declare function buildKanbanTools(service: KanbanService, getCaller: () => ToolCaller): import("@deepseek-ai/dsh-tools").ToolDefinition[];
|
|
@@ -6,7 +6,7 @@ function guard(action, caller, task = null) {
|
|
|
6
6
|
if (!can(action, caller.actor, task))
|
|
7
7
|
throw new Error('permission denied: ' + action);
|
|
8
8
|
}
|
|
9
|
-
/**
|
|
9
|
+
/** 工具定义工厂:不直接注册;由 toolsets 按角色 agent-scope 装配,或由主会话注册其专属子集。
|
|
10
10
|
* getCaller:装配方提供的闭包(捕获 actor/boundTaskId),execute 内取用——不依赖 execute 第二参数(DSH ToolRunContext 真实形状以官方为准)。 */
|
|
11
11
|
export function buildKanbanTools(service, getCaller) {
|
|
12
12
|
return [
|
|
@@ -10,11 +10,15 @@ export interface PlanningContext {
|
|
|
10
10
|
checklist: PlanningChecklist | null;
|
|
11
11
|
checklistRef: string | null;
|
|
12
12
|
checklistSource: 'kb' | 'temp' | null;
|
|
13
|
-
/**
|
|
13
|
+
/** /plan: rest 原始需求描述(建链默认标题来源,优先级最高)。 */
|
|
14
14
|
requirementName: string | null;
|
|
15
|
+
/** 蜂群模式标记:kanban_route 触发方式(intent=swarm / 前缀=prefix);planning_checklist_save 指导文案分叉数据源。 */
|
|
16
|
+
mode?: 'swarm' | 'prefix' | null;
|
|
15
17
|
}
|
|
16
18
|
export declare const planningBySession: Map<string, PlanningContext>;
|
|
17
|
-
export declare const KANBAN_HANDOFF_RULE: (routes: PrefixRoutes
|
|
19
|
+
export declare const KANBAN_HANDOFF_RULE: (routes: PrefixRoutes, opts?: {
|
|
20
|
+
swarm?: boolean;
|
|
21
|
+
}) => string;
|
|
18
22
|
/** 防线③:/openspec: 成功后的逐链确定性叙述规则(2026-09-04 mtmgp81q:模型口播 ch_1_mtjrhkrf
|
|
19
23
|
* 与工具结果 ch_1_mtmgp81q 脱节)。逐字引用锚点 + firstCard 成败结论;整包缓存回放仍可能
|
|
20
24
|
* 绕过 prompt 层——最终防线是链级看门狗(防线①)。 */
|