@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.
- package/LICENSE +21 -0
- package/README.md +504 -0
- package/README.zh-CN.md +452 -0
- package/client/BoardCard.tsx +41 -0
- package/client/ConnectionBanner.tsx +16 -0
- package/client/KanbanBoard.tsx +168 -0
- package/client/KanbanTab.tsx +26 -0
- package/client/RenameModal.tsx +38 -0
- package/client/TaskDrawer.tsx +212 -0
- package/client/WorkflowRail.tsx +175 -0
- package/client/board-store.ts +196 -0
- package/client/css.d.ts +4 -0
- package/client/index.ts +31 -0
- package/client/kanban.css +653 -0
- package/client/useKanbanBoard.ts +7 -0
- package/client/workflow-model.ts +229 -0
- package/cordis.patch.yml +88 -0
- package/lib/client.js +1205 -0
- package/lib/config.d.ts +46 -0
- package/lib/config.js +43 -0
- package/lib/dispatcher/agent-runner.d.ts +23 -0
- package/lib/dispatcher/agent-runner.js +429 -0
- package/lib/dispatcher/chain-auditor.d.ts +47 -0
- package/lib/dispatcher/chain-auditor.js +194 -0
- package/lib/dispatcher/dispatcher.d.ts +50 -0
- package/lib/dispatcher/dispatcher.js +280 -0
- package/lib/dispatcher/event-waker.d.ts +13 -0
- package/lib/dispatcher/event-waker.js +24 -0
- package/lib/dispatcher/git-credentials.d.ts +33 -0
- package/lib/dispatcher/git-credentials.js +78 -0
- package/lib/dispatcher/merge-gate.d.ts +28 -0
- package/lib/dispatcher/merge-gate.js +74 -0
- package/lib/dispatcher/model-candidates.d.ts +13 -0
- package/lib/dispatcher/model-candidates.js +31 -0
- package/lib/dispatcher/session-events.d.ts +28 -0
- package/lib/dispatcher/session-events.js +33 -0
- package/lib/dispatcher/target-repo.d.ts +15 -0
- package/lib/dispatcher/target-repo.js +42 -0
- package/lib/dispatcher/v-orchestrator.d.ts +74 -0
- package/lib/dispatcher/v-orchestrator.js +452 -0
- package/lib/dispatcher/watchdog.d.ts +15 -0
- package/lib/dispatcher/watchdog.js +30 -0
- package/lib/dispatcher/workspace-attach.d.ts +40 -0
- package/lib/dispatcher/workspace-attach.js +112 -0
- package/lib/domain/delivery-contract.d.ts +18 -0
- package/lib/domain/delivery-contract.js +80 -0
- package/lib/domain/delivery-evidence.d.ts +12 -0
- package/lib/domain/delivery-evidence.js +41 -0
- package/lib/domain/event-store.d.ts +20 -0
- package/lib/domain/event-store.js +53 -0
- package/lib/domain/kanban-service.d.ts +94 -0
- package/lib/domain/kanban-service.js +430 -0
- package/lib/domain/permissions.d.ts +7 -0
- package/lib/domain/permissions.js +54 -0
- package/lib/domain/planning-checklist.d.ts +22 -0
- package/lib/domain/planning-checklist.js +81 -0
- package/lib/domain/prefetch-manifest.d.ts +21 -0
- package/lib/domain/prefetch-manifest.js +73 -0
- package/lib/domain/projection.d.ts +3 -0
- package/lib/domain/projection.js +165 -0
- package/lib/domain/review-evidence.d.ts +13 -0
- package/lib/domain/review-evidence.js +76 -0
- package/lib/domain/state-machine.d.ts +4 -0
- package/lib/domain/state-machine.js +32 -0
- package/lib/domain/task-parents.d.ts +17 -0
- package/lib/domain/task-parents.js +39 -0
- package/lib/domain/tdd-classify.d.ts +5 -0
- package/lib/domain/tdd-classify.js +20 -0
- package/lib/domain/types.d.ts +142 -0
- package/lib/domain/types.js +2 -0
- package/lib/index.d.ts +5 -0
- package/lib/index.js +56 -0
- package/lib/roles/preset-installer.d.ts +8 -0
- package/lib/roles/preset-installer.js +53 -0
- package/lib/roles/toolsets.d.ts +62 -0
- package/lib/roles/toolsets.js +332 -0
- package/lib/roles/wiki-worker.d.ts +20 -0
- package/lib/roles/wiki-worker.js +44 -0
- package/lib/routes/kanban-http.d.ts +6 -0
- package/lib/routes/kanban-http.js +159 -0
- package/lib/routes/kanban-sse.d.ts +8 -0
- package/lib/routes/kanban-sse.js +58 -0
- package/lib/routes/planning-driver.d.ts +16 -0
- package/lib/routes/planning-driver.js +52 -0
- package/lib/routes/prefix-router.d.ts +29 -0
- package/lib/routes/prefix-router.js +28 -0
- package/lib/services/kanban-provider.d.ts +16 -0
- package/lib/services/kanban-provider.js +14 -0
- package/lib/tools/kanban-tools.d.ts +11 -0
- package/lib/tools/kanban-tools.js +169 -0
- package/lib/tools/main-session-tools.d.ts +20 -0
- package/lib/tools/main-session-tools.js +170 -0
- package/lib/tools/planning-tools.d.ts +32 -0
- package/lib/tools/planning-tools.js +102 -0
- package/lib/tools/prefetch-tools.d.ts +5 -0
- package/lib/tools/prefetch-tools.js +59 -0
- package/lib/tools/spec-card-tools.d.ts +4 -0
- package/lib/tools/spec-card-tools.js +75 -0
- package/lib/tools/wiki-tools.d.ts +4 -0
- package/lib/tools/wiki-tools.js +57 -0
- package/lib/wiki/kb-linkage.d.ts +9 -0
- package/lib/wiki/kb-linkage.js +87 -0
- package/lib/wiki/page-path.d.ts +6 -0
- package/lib/wiki/page-path.js +28 -0
- package/lib/wiki/wiki-vault-client.d.ts +28 -0
- package/lib/wiki/wiki-vault-client.js +48 -0
- package/package.json +83 -0
- package/personas/kanban-d/agent.cordis.yml +157 -0
- package/personas/kanban-d/preset.yml +2 -0
- package/personas/kanban-dt/agent.cordis.yml +71 -0
- package/personas/kanban-dt/preset.yml +2 -0
- package/personas/kanban-p/agent.cordis.yml +66 -0
- package/personas/kanban-p/preset.yml +2 -0
- package/personas/kanban-pt/agent.cordis.yml +47 -0
- package/personas/kanban-pt/preset.yml +2 -0
- package/personas/kanban-v/agent.cordis.yml +47 -0
- package/personas/kanban-v/preset.yml +2 -0
- package/personas/kanban-w/agent.cordis.yml +47 -0
- package/personas/kanban-w/preset.yml +2 -0
- package/personas/persona-d.md +26 -0
- package/personas/persona-dt.md +18 -0
- package/personas/persona-p.md +13 -0
- package/personas/persona-pt.md +13 -0
- package/personas/persona-v.md +18 -0
- package/personas/persona-w.md +12 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { KanbanProvider } from '../services/kanban-provider.js';
|
|
4
|
+
import { buildKanbanTools } from './kanban-tools.js';
|
|
5
|
+
import { buildSpecCardTools } from './spec-card-tools.js';
|
|
6
|
+
import { buildPlanningTools } from './planning-tools.js';
|
|
7
|
+
import { handlePlanRoute, handleOpenspecRoute } from '../routes/prefix-router.js';
|
|
8
|
+
import { MATTPOCOCK_PLANNING_GUIDANCE } from '../routes/planning-driver.js';
|
|
9
|
+
import { attachSessionToWorkspace, resolveOrCreateWorkspace } from '../dispatcher/workspace-attach.js';
|
|
10
|
+
import { PREFETCH_MANIFEST_SCHEMA } from '../domain/prefetch-manifest.js';
|
|
11
|
+
import { WikiVaultClient } from '../wiki/wiki-vault-client.js';
|
|
12
|
+
export const planningBySession = new Map();
|
|
13
|
+
const KANBAN_HANDOFF_RULE = `
|
|
14
|
+
## 主 agent 铁律(看板工作流 v2)
|
|
15
|
+
- 你是计划者:只做需求澄清(grill-me)与最终收尾汇报;绝不执行任务本身。
|
|
16
|
+
- 最高护栏:只读仓库——禁止 git 操作、禁止 write/edit 任何仓库源码;只允许写 KB(planning_checklist_save)与临时目录兜底。
|
|
17
|
+
- 澄清期:调 planning_prefetch(只读子代理)采集仓库事实 → 逐问用户收敛 → 调 planning_checklist_save 存需求澄清清单 → 提醒用户 /openspec: 确认。
|
|
18
|
+
- /openspec: 后链路进入 executing,V 自动串行建卡 p→(pt)→w2→d→dt→w3;你不要自己执行。
|
|
19
|
+
- 用 kanban_show / kanban_list / spec_card_view 观察进度,链完成后向用户汇报产物链接与轨迹入口。
|
|
20
|
+
`;
|
|
21
|
+
// 清单获取只有两条路:内存(路由1)> KB 候选页(路由2,LLM 读页重建)。禁止编造其他原因
|
|
22
|
+
// ("先重试 / 查服务进程是否重启"类诊断是噪声:内存丢失唯一成因是插件重启,重启后按两条路恢复即可)。
|
|
23
|
+
const RECOVERY_KB_GUIDANCE = (candidates) => `
|
|
24
|
+
插件内存中的需求澄清清单已丢失(插件进程重启所致,属预期情况,按两条获取路由恢复即可,勿猜测其他原因)。
|
|
25
|
+
知识库中检索到候选清单页:
|
|
26
|
+
${candidates.map((c) => '- ' + c).join('\n')}
|
|
27
|
+
恢复步骤(严格顺序):
|
|
28
|
+
1. 读取候选页内容,对照当前需求判定哪一页是本次需求的需求澄清清单(页首行标题为「# 【需求】<需求名>」);
|
|
29
|
+
2. 消化该页内容,重建结构化 PlanningChecklist(spec 六段 + manifest + clarifications + doubts,requirementName 取页标题中【需求】后的名称);
|
|
30
|
+
3. 调 planning_checklist_save(checklist, restoreRef=<该候选页路径>) 回存(覆盖原页,勿产生重复页);
|
|
31
|
+
4. 回存成功后提示用户重新发送 /openspec: 确认。
|
|
32
|
+
禁止:跳过恢复直接建链建卡;猜测清单内容;把恢复失败归因于"重试/进程检查"之外的任何原因。
|
|
33
|
+
`;
|
|
34
|
+
const RECOVERY_NONE_GUIDANCE = `
|
|
35
|
+
两条获取路由均无本需求的需求澄清清单(内存为空,知识库亦无匹配页)。
|
|
36
|
+
处理步骤(严格顺序):
|
|
37
|
+
1. 消化当前对话上下文,判断需求澄清(grill-me 逐问收敛 + planning_prefetch 仓库事实)是否已完成但漏了保存动作;
|
|
38
|
+
2. 若已完成澄清——立即调 planning_checklist_save 保存清单;若尚未完成——先完成澄清(缺仓库事实则先 planning_prefetch),再保存;
|
|
39
|
+
3. 保存成功后提示用户重新发送 /openspec: 确认。
|
|
40
|
+
禁止:在清单落库前建链建卡;编造"先重试 / 查服务进程是否重启"之类与清单无关的诊断。
|
|
41
|
+
`;
|
|
42
|
+
/** 预取子代理禁用的写能力工具(官方全局工具名;deny = 从 prompt 消失 + 拒绝执行,"one visibility")。 */
|
|
43
|
+
const PREFETCH_DENIED_TOOLS = ['bash', 'edit', 'write'];
|
|
44
|
+
export function buildSpawnPrefetch(ctx) {
|
|
45
|
+
const subagents = ctx.get('subagents');
|
|
46
|
+
if (!subagents?.start)
|
|
47
|
+
return undefined;
|
|
48
|
+
return async (prompt, workspaceDir, parentAgent, signal) => {
|
|
49
|
+
// 血缘源必须由 agent loop 经 ToolRunContext 透传(planning_prefetch exec.agent);
|
|
50
|
+
// 无 parent 说明工具面接线缺失,快速失败而非静默退化为无血缘会话。
|
|
51
|
+
if (!parentAgent)
|
|
52
|
+
throw new Error('planning_prefetch: missing parent agent — 工具运行时未注入 exec.agent');
|
|
53
|
+
const cwd = workspaceDir || process.cwd();
|
|
54
|
+
let run;
|
|
55
|
+
try {
|
|
56
|
+
run = await subagents.start('spawn', {
|
|
57
|
+
label: 'prefetch',
|
|
58
|
+
prompt: [{ type: 'text', text: prompt }],
|
|
59
|
+
parent: parentAgent,
|
|
60
|
+
signal: signal ?? new AbortController().signal,
|
|
61
|
+
maxDepth: 1,
|
|
62
|
+
toolFilter: { deny: [...PREFETCH_DENIED_TOOLS] },
|
|
63
|
+
outputSchema: PREFETCH_MANIFEST_SCHEMA,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
throw new Error('planning_prefetch: subagent start failed: ' + String(err));
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
// 归组:缝生成的子会话(run.id = 子 session id)attach 到 cwd 对应工作区(失败不阻断只读采集)
|
|
71
|
+
await attachSessionToWorkspace(ctx, run.id, cwd, 'prefetch');
|
|
72
|
+
const result = await run.result;
|
|
73
|
+
// fail-fast:非 completed 终止(error/cancelled/...)抛错带诊断,不做静默兜底
|
|
74
|
+
if (result.stopReason !== 'completed') {
|
|
75
|
+
throw new Error(`planning_prefetch: subagent ended with stopReason=${result.stopReason}${result.error ? ' — ' + result.error : ''}`);
|
|
76
|
+
}
|
|
77
|
+
// outputSchema 由 spawn provider 在子代理侧强制校验;structured 合法即用,文本兜底仅作能力缺失防御
|
|
78
|
+
if (result.structured !== undefined)
|
|
79
|
+
return JSON.stringify(result.structured);
|
|
80
|
+
return (result.output ?? []).map((b) => b.text ?? '').join('');
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
await run.dispose().catch(() => undefined);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/** v2 主会话工具面:/plan: 捕获规划上下文(零副作用)→ planning_checklist_save 回写 → /openspec: 用清单建链。
|
|
88
|
+
* 工具面 = kanban_route + 只读 kanban 子集 + spec_card_view + planning 工具;
|
|
89
|
+
* 无 spec_card_edit/approve、无 kanban_create/complete/block(主会话越权写由工具面裁剪 + prefetch 子代理只读护栏双保险)。 */
|
|
90
|
+
export function registerMainSessionTools(ctx, config) {
|
|
91
|
+
const registry = ctx.get('tools');
|
|
92
|
+
if (!registry)
|
|
93
|
+
return; // 测试裸 Context 无 tools 服务(P1-9 无 inject 依赖),跳过注册
|
|
94
|
+
const provider = ctx.get('kanban');
|
|
95
|
+
if (!provider)
|
|
96
|
+
return;
|
|
97
|
+
const service = provider.service;
|
|
98
|
+
// 生产 wiring(src/index.ts:44)仅保证 tools+kanban 可用,无 wiki 服务 → 用 config.wikiVault 自建
|
|
99
|
+
//(与 dispatcher 构造同源);测试经 ctx.get('wiki') 注入 mock 客户端。
|
|
100
|
+
const wiki = ctx.get('wiki') ?? new WikiVaultClient(config.wikiVault);
|
|
101
|
+
const caller = () => ({ actor: 'human' });
|
|
102
|
+
// 只读 kanban 子集(无 create/complete/block)
|
|
103
|
+
const readOnly = new Set(['kanban_show', 'kanban_chain', 'kanban_list', 'kanban_comment']);
|
|
104
|
+
for (const tool of buildKanbanTools(service, caller)) {
|
|
105
|
+
const name = tool.name;
|
|
106
|
+
if (name && readOnly.has(name))
|
|
107
|
+
registry.register(tool);
|
|
108
|
+
}
|
|
109
|
+
// spec_card_view 仅保留(主 agent 只读规格卡;编辑/批准经清单→/openspec: 建链,GUI 走 HTTP 桥)
|
|
110
|
+
for (const tool of buildSpecCardTools(service, caller)) {
|
|
111
|
+
if (tool.name === 'spec_card_view')
|
|
112
|
+
registry.register(tool);
|
|
113
|
+
}
|
|
114
|
+
// planning 工具(清单落库 + 只读预取)——spawnPrefetch 由模块级 buildSpawnPrefetch 提供(可单测)
|
|
115
|
+
for (const tool of buildPlanningTools({
|
|
116
|
+
service, wiki,
|
|
117
|
+
getCaller: caller,
|
|
118
|
+
spawnPrefetch: buildSpawnPrefetch(ctx),
|
|
119
|
+
tempDir: () => `${tmpdir()}/dsh-swarm-checklists`, // KB 不可达时的临时兜底,放系统临时目录(不落插件源码/核心存储目录)
|
|
120
|
+
pagePrefix: config.wikiVault?.pagePrefix ?? 'projects/', // 生成的清单页路径保持在该客户端配置的命名空间内(避免 kb-rejected)
|
|
121
|
+
ownerSessionId: 'session_main',
|
|
122
|
+
onChecklistSaved({ ref, source, checklist }) {
|
|
123
|
+
const cur = planningBySession.get('session_main') ?? { workspaceDir: null, sessionId: 'session_main', checklist: null, checklistRef: null, checklistSource: null, requirementName: null };
|
|
124
|
+
planningBySession.set('session_main', { ...cur, checklist, checklistRef: ref, checklistSource: source });
|
|
125
|
+
},
|
|
126
|
+
}))
|
|
127
|
+
registry.register(tool);
|
|
128
|
+
// kanban_route:/plan: 捕获规划上下文;/openspec: 用清单建链
|
|
129
|
+
registry.register(defineTool({
|
|
130
|
+
name: 'kanban_route',
|
|
131
|
+
description: 'MUST be called when the human message starts with /plan: or /openspec:. This is dsh-swarm planning, NOT the built-in /plan plan mode. /plan: = zero side-effect + start grill-me; /openspec: = create chain from saved checklist and start execution.',
|
|
132
|
+
parameters: { message: { type: 'string', required: true } },
|
|
133
|
+
output: { schema: { type: 'json' }, render: (_a, v) => [{ type: 'text', text: JSON.stringify(v) }] },
|
|
134
|
+
async execute(args, exec) {
|
|
135
|
+
// M2(Q5)+归组:仅 /plan: 分支捕获主 agent 工作空间并可能询问注册——/openspec:/none 不触发,
|
|
136
|
+
// 避免死代码副作用多弹一次 ask(/openspec: 实际用的是 planningBySession 已存的 workspaceDir)。
|
|
137
|
+
// header.cwd 缺失或未注册时询问用户注册工作区;仍不可得保持 null(链任务随后 block 'workspace-unknown')。
|
|
138
|
+
const plan = await handlePlanRoute(args.message, service, config.prefixRoutes, 'session_main');
|
|
139
|
+
if (plan.kind === 'plan') {
|
|
140
|
+
const headerCwd = exec?.agent?.session?.header?.cwd ?? null;
|
|
141
|
+
const workspaceDir = await resolveOrCreateWorkspace(ctx, headerCwd, '主 agent 会话');
|
|
142
|
+
planningBySession.set('session_main', { workspaceDir, sessionId: 'session_main', checklist: null, checklistRef: null, checklistSource: null, requirementName: plan.rest });
|
|
143
|
+
return { kind: 'plan', guidance: MATTPOCOCK_PLANNING_GUIDANCE + KANBAN_HANDOFF_RULE };
|
|
144
|
+
}
|
|
145
|
+
if (plan.kind === 'none')
|
|
146
|
+
return { kind: 'none' };
|
|
147
|
+
// 路由1(内存):planningBySession 命中 → 直接建链
|
|
148
|
+
const pctx = planningBySession.get('session_main');
|
|
149
|
+
if (pctx?.checklist && pctx.checklistRef) {
|
|
150
|
+
const input = { workspaceDir: pctx.workspaceDir, checklist: pctx.checklist, checklistRef: pctx.checklistRef, requirementName: pctx.requirementName };
|
|
151
|
+
const r = await handleOpenspecRoute(args.message, service, config.prefixRoutes, input, 'session_main');
|
|
152
|
+
return { kind: 'openspec', chainId: r.chainId, specCardId: r.specCardId, approved: true, guidance: KANBAN_HANDOFF_RULE };
|
|
153
|
+
}
|
|
154
|
+
// 路由2(知识库):内存丢失(插件重启)→ 搜 KB 候选清单页供 LLM 读页重建;搜不到/不可达 → 两条路皆空
|
|
155
|
+
let candidates = [];
|
|
156
|
+
try {
|
|
157
|
+
const pagePrefix = config.wikiVault?.pagePrefix ?? 'projects/';
|
|
158
|
+
candidates = (await wiki.search('【需求】')).map((r) => r.path).filter((p) => p.startsWith(pagePrefix)).slice(0, 5);
|
|
159
|
+
}
|
|
160
|
+
catch { /* KB 不可达/搜索失败 → 候选为空,走两条路皆空分支 */ }
|
|
161
|
+
return {
|
|
162
|
+
kind: 'openspec', approved: false, reason: 'no-checklist',
|
|
163
|
+
recovery: candidates.length > 0 ? 'kb' : 'none',
|
|
164
|
+
checklistCandidates: candidates,
|
|
165
|
+
guidance: candidates.length > 0 ? RECOVERY_KB_GUIDANCE(candidates) : RECOVERY_NONE_GUIDANCE,
|
|
166
|
+
};
|
|
167
|
+
},
|
|
168
|
+
}));
|
|
169
|
+
console.info('[dsh-swarm] main-session tools registered (v2: kanban_route + planning + spec view + read-only kanban)');
|
|
170
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Agent } from '@deepseek-ai/dsh-agent';
|
|
2
|
+
import type { KanbanService } from '../domain/kanban-service.js';
|
|
3
|
+
import type { WikiVaultClient } from '../wiki/wiki-vault-client.js';
|
|
4
|
+
import { type PlanningChecklist } from '../domain/planning-checklist.js';
|
|
5
|
+
import type { ToolCaller } from './kanban-tools.js';
|
|
6
|
+
import type { AgentModelOptions } from '../dispatcher/dispatcher.js';
|
|
7
|
+
/** 工具运行时上下文(dsh-tools ToolRunContext 窄型):agent loop 注入调用者 Agent 与取消信号,
|
|
8
|
+
* planning_prefetch 经官方子代理缝启动时需透传(parent + signal)。 */
|
|
9
|
+
export interface PrefetchExecContext {
|
|
10
|
+
agent?: Agent;
|
|
11
|
+
signal?: AbortSignal;
|
|
12
|
+
}
|
|
13
|
+
export interface PlanningToolDeps {
|
|
14
|
+
service: KanbanService;
|
|
15
|
+
wiki: WikiVaultClient;
|
|
16
|
+
getCaller(): ToolCaller;
|
|
17
|
+
/** 真实实现:经官方子代理缝(ctx.subagents.start)启动只读预取子代理并返回其文本输出;测试注入 stub。
|
|
18
|
+
* parentAgent = 发起调用的主 agent(血缘/模型继承源),由 planning_prefetch 的 exec.agent 透传。 */
|
|
19
|
+
spawnPrefetch?(prompt: string, workspaceDir: string, parentAgent?: Agent, signal?: AbortSignal): Promise<string>;
|
|
20
|
+
tempDir(): string;
|
|
21
|
+
pagePrefix?: string;
|
|
22
|
+
ownerSessionId?: string;
|
|
23
|
+
defaultModel?: AgentModelOptions;
|
|
24
|
+
/** 清单落库成功回调(kb 与 temp 两分支各调一次),供 main-session-tools 回写 planningBySession。 */
|
|
25
|
+
onChecklistSaved?(saved: {
|
|
26
|
+
ref: string;
|
|
27
|
+
source: 'kb' | 'temp';
|
|
28
|
+
checklist: PlanningChecklist;
|
|
29
|
+
}): void;
|
|
30
|
+
}
|
|
31
|
+
/** 主 agent 规划期工具:需求澄清清单落库(KB 优先/临时目录兜底)+ 只读仓库预取(子代理)。 */
|
|
32
|
+
export declare function buildPlanningTools(deps: PlanningToolDeps): import("@deepseek-ai/dsh-tools").ToolDefinition[];
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// src/tools/planning-tools.ts
|
|
2
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
3
|
+
import { validatePlanningChecklist, formatChecklistBody } from '../domain/planning-checklist.js';
|
|
4
|
+
import { validatePrefetchManifest } from '../domain/prefetch-manifest.js';
|
|
5
|
+
import { buildChecklistSlug, CHECKLIST_PAGE_PREFIX } from '../wiki/page-path.js';
|
|
6
|
+
const isWikiError = (e) => e instanceof Error && e.code === 'kb-unreachable';
|
|
7
|
+
/** 主 agent 规划期工具:需求澄清清单落库(KB 优先/临时目录兜底)+ 只读仓库预取(子代理)。 */
|
|
8
|
+
export function buildPlanningTools(deps) {
|
|
9
|
+
const pagePrefix = deps.pagePrefix ?? 'projects/';
|
|
10
|
+
const session = deps.ownerSessionId ?? 'session_main';
|
|
11
|
+
return [
|
|
12
|
+
defineTool({
|
|
13
|
+
name: 'planning_checklist_save',
|
|
14
|
+
description: 'Save the converged requirement-clarification checklist (structured schema) to KB, falling back to a temp dir if KB is unreachable. Returns ref/path + authoritative repo path. restoreRef (optional) = existing KB page path to overwrite in place (recovery path when in-memory context was lost); omit for first-time save (creates a new timestamped page).',
|
|
15
|
+
parameters: { checklist: { type: 'json', required: true, description: 'Structured PlanningChecklist: spec six sections + manifest(repo.files) + clarifications + doubts. checklist.requirementName (optional) = /plan: rest first sentence, used for the checklist page title 【需求】, same source as the task-card title' }, restoreRef: { type: 'string', description: 'Optional KB page path to overwrite in place (recovery path); omit for new save' } },
|
|
16
|
+
output: { schema: { type: 'json' }, render: (_a, v) => [{ type: 'text', text: JSON.stringify(v) }] },
|
|
17
|
+
async execute(args) {
|
|
18
|
+
const caller = deps.getCaller();
|
|
19
|
+
if (caller.actor !== 'human')
|
|
20
|
+
throw new Error('permission denied: planning_checklist_save');
|
|
21
|
+
const errors = validatePlanningChecklist(args.checklist);
|
|
22
|
+
if (errors.length > 0)
|
|
23
|
+
throw new Error('invalid planning checklist: ' + errors.join('; '));
|
|
24
|
+
const checklist = args.checklist;
|
|
25
|
+
const body = formatChecklistBody(checklist);
|
|
26
|
+
// 恢复路径(内存丢失后重建):传 restoreRef 则覆盖原页,不产生重复页
|
|
27
|
+
if (args.restoreRef && args.restoreRef.startsWith(pagePrefix)) {
|
|
28
|
+
try {
|
|
29
|
+
await deps.wiki.write(args.restoreRef, body);
|
|
30
|
+
deps.onChecklistSaved?.({ ref: args.restoreRef, source: 'kb', checklist });
|
|
31
|
+
return { ok: true, ref: args.restoreRef, source: 'kb', repoPath: checklist.manifest.repo.localPath };
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
if (!isWikiError(err))
|
|
35
|
+
throw err;
|
|
36
|
+
// KB 不可达 → 落临时目录兜底(不覆盖原页),回调仍回填内存
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const pagePath = `${CHECKLIST_PAGE_PREFIX}${buildChecklistSlug(checklist.requirementName ?? checklist.spec.problem)}-${Date.now().toString(36)}.md`;
|
|
40
|
+
try {
|
|
41
|
+
await deps.wiki.write(pagePath, body);
|
|
42
|
+
deps.onChecklistSaved?.({ ref: pagePath, source: 'kb', checklist });
|
|
43
|
+
return { ok: true, ref: pagePath, source: 'kb', repoPath: checklist.manifest.repo.localPath };
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
if (!isWikiError(err))
|
|
47
|
+
throw err;
|
|
48
|
+
// KB 不可达 → 临时目录兜底
|
|
49
|
+
const local = `${deps.tempDir()}/${session}-${Date.now().toString(36)}.md`;
|
|
50
|
+
const { writeFileSync, mkdirSync } = await import('node:fs');
|
|
51
|
+
mkdirSync(deps.tempDir(), { recursive: true });
|
|
52
|
+
writeFileSync(local, body, 'utf8');
|
|
53
|
+
deps.onChecklistSaved?.({ ref: local, source: 'temp', checklist });
|
|
54
|
+
return { ok: true, ref: local, source: 'temp', repoPath: checklist.manifest.repo.localPath };
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
}),
|
|
58
|
+
defineTool({
|
|
59
|
+
name: 'planning_prefetch',
|
|
60
|
+
description: 'Dispatch a READ-ONLY sub-agent to gather repo/material/KB facts for requirement clarification. Returns a structured PrefetchManifest (repo.localPath + files baseline). Never modifies the repo.',
|
|
61
|
+
parameters: {
|
|
62
|
+
scope: { type: 'string', required: true, description: 'What to prefetch (e.g. the target feature area, existing tab implementation, enums)' },
|
|
63
|
+
repoPath: { type: 'string', description: 'Target repo absolute path (if known); sub-agent confirms it' },
|
|
64
|
+
},
|
|
65
|
+
output: { schema: { type: 'json' }, render: (_a, v) => [{ type: 'text', text: JSON.stringify(v) }] },
|
|
66
|
+
async execute(args, exec) {
|
|
67
|
+
const caller = deps.getCaller();
|
|
68
|
+
if (caller.actor !== 'human')
|
|
69
|
+
throw new Error('permission denied: planning_prefetch');
|
|
70
|
+
const prompt = [
|
|
71
|
+
'# 只读仓库预取(planning_prefetch)',
|
|
72
|
+
`scope: ${args.scope}`,
|
|
73
|
+
`目标仓库路径: ${args.repoPath ?? '(未指定,需你确认绝对路径)'}`,
|
|
74
|
+
'规则:只读采集仓库事实(本地路径/远端 URL/当前分支/未提交改动/目标文件基线),禁止 git 写操作、禁止修改任何文件。',
|
|
75
|
+
'输出:仅输出一个 JSON 对象(无前后缀文字),形如 {"repo":{"localPath":"<绝对路径>","remoteUrl":"<可选>","branch":"<可选>","dirtyFiles":[]},"files":[{"path":"<相对路径>","expected":"exists|absent|content-hash","note":"<可选>"}]}',
|
|
76
|
+
].join('\n');
|
|
77
|
+
// 官方子代理缝要求 parent(血缘/模型继承/工作目录源)+ signal(取消通道),
|
|
78
|
+
// 均由 agent loop 注入的 ToolRunContext 透传;测试直调无 exec → undefined(stub 不依赖)
|
|
79
|
+
const output = deps.spawnPrefetch
|
|
80
|
+
? await deps.spawnPrefetch(prompt, args.repoPath ?? '', exec?.agent, exec?.signal)
|
|
81
|
+
: (() => { throw new Error('planning_prefetch: spawnPrefetch not wired — main-session-tools 必须注入只读预取子代理'); })();
|
|
82
|
+
const manifest = parseManifestOutput(output);
|
|
83
|
+
return { ok: true, manifest };
|
|
84
|
+
},
|
|
85
|
+
}),
|
|
86
|
+
];
|
|
87
|
+
}
|
|
88
|
+
function parseManifestOutput(output) {
|
|
89
|
+
const text = output.trim();
|
|
90
|
+
const jsonText = text.startsWith('{') ? text : text.slice(text.indexOf('{'), text.lastIndexOf('}') + 1);
|
|
91
|
+
let raw;
|
|
92
|
+
try {
|
|
93
|
+
raw = JSON.parse(jsonText);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
throw new Error('planning_prefetch: sub-agent did not return valid JSON manifest');
|
|
97
|
+
}
|
|
98
|
+
const errors = validatePrefetchManifest(raw);
|
|
99
|
+
if (errors.length > 0)
|
|
100
|
+
throw new Error('planning_prefetch: invalid manifest from sub-agent: ' + errors.join('; '));
|
|
101
|
+
return raw;
|
|
102
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { WikiWorker } from '../roles/wiki-worker.js';
|
|
2
|
+
import type { Task } from '../domain/types.js';
|
|
3
|
+
import type { ToolCaller } from './kanban-tools.js';
|
|
4
|
+
/** 预取工具工厂(W 角色 agent scope):file/external/kb 三模式,产物引用登记(原汁原味,禁压缩)。 */
|
|
5
|
+
export declare function buildPrefetchTools(worker: WikiWorker, getTask: (taskId: string) => Promise<Task>, getCaller: () => ToolCaller): import("@deepseek-ai/dsh-tools").ToolDefinition[];
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
2
|
+
import { can } from '../domain/permissions.js';
|
|
3
|
+
function guard(action, caller) {
|
|
4
|
+
if (!can(action, caller.actor, null))
|
|
5
|
+
throw new Error('permission denied: ' + action);
|
|
6
|
+
}
|
|
7
|
+
/** 预取工具工厂(W 角色 agent scope):file/external/kb 三模式,产物引用登记(原汁原味,禁压缩)。 */
|
|
8
|
+
export function buildPrefetchTools(worker, getTask, getCaller) {
|
|
9
|
+
return [
|
|
10
|
+
defineTool({
|
|
11
|
+
name: 'prefetch_file',
|
|
12
|
+
description: 'Register a read-only file-prefetch artifact reference under the task workspace.',
|
|
13
|
+
parameters: {
|
|
14
|
+
taskId: { type: 'string', required: true },
|
|
15
|
+
source: { type: 'string', required: true, description: 'Absolute path under the task workspace' },
|
|
16
|
+
},
|
|
17
|
+
output: { schema: { type: 'json' }, render: (_a, v) => [{ type: 'text', text: JSON.stringify(v) }] },
|
|
18
|
+
async execute(args) {
|
|
19
|
+
const caller = getCaller();
|
|
20
|
+
guard('prefetch', caller);
|
|
21
|
+
const task = await getTask(args.taskId);
|
|
22
|
+
const out = await worker.executePrefetch(task, 'file', args.source);
|
|
23
|
+
return out;
|
|
24
|
+
},
|
|
25
|
+
}),
|
|
26
|
+
defineTool({
|
|
27
|
+
name: 'prefetch_external',
|
|
28
|
+
description: 'Register an external-research prefetch artifact (writes ws/prefetch-external.md).',
|
|
29
|
+
parameters: {
|
|
30
|
+
taskId: { type: 'string', required: true },
|
|
31
|
+
source: { type: 'string', description: 'Optional path; defaults to the workspace prefetch-external.md' },
|
|
32
|
+
},
|
|
33
|
+
output: { schema: { type: 'json' }, render: (_a, v) => [{ type: 'text', text: JSON.stringify(v) }] },
|
|
34
|
+
async execute(args) {
|
|
35
|
+
const caller = getCaller();
|
|
36
|
+
guard('prefetch', caller);
|
|
37
|
+
const task = await getTask(args.taskId);
|
|
38
|
+
const out = await worker.executePrefetch(task, 'external', args.source ?? '');
|
|
39
|
+
return out;
|
|
40
|
+
},
|
|
41
|
+
}),
|
|
42
|
+
defineTool({
|
|
43
|
+
name: 'prefetch_kb',
|
|
44
|
+
description: 'Register a knowledge-base prefetch artifact (writes ws/prefetch-kb.md).',
|
|
45
|
+
parameters: {
|
|
46
|
+
taskId: { type: 'string', required: true },
|
|
47
|
+
source: { type: 'string', description: 'Optional path; defaults to the workspace prefetch-kb.md' },
|
|
48
|
+
},
|
|
49
|
+
output: { schema: { type: 'json' }, render: (_a, v) => [{ type: 'text', text: JSON.stringify(v) }] },
|
|
50
|
+
async execute(args) {
|
|
51
|
+
const caller = getCaller();
|
|
52
|
+
guard('prefetch', caller);
|
|
53
|
+
const task = await getTask(args.taskId);
|
|
54
|
+
const out = await worker.executePrefetch(task, 'kb', args.source ?? '');
|
|
55
|
+
return out;
|
|
56
|
+
},
|
|
57
|
+
}),
|
|
58
|
+
];
|
|
59
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { KanbanService } from '../domain/kanban-service.js';
|
|
2
|
+
import type { ToolCaller } from './kanban-tools.js';
|
|
3
|
+
/** 规格卡工具工厂:主会话(human)专属——编辑/批准仅 human;查看任意角色可读。 */
|
|
4
|
+
export declare function buildSpecCardTools(service: KanbanService, getCaller: () => ToolCaller): import("@deepseek-ai/dsh-tools").ToolDefinition[];
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
2
|
+
import { KanbanService } from '../domain/kanban-service.js';
|
|
3
|
+
import { can } from '../domain/permissions.js';
|
|
4
|
+
function guard(action, caller) {
|
|
5
|
+
if (!can(action, caller.actor, null))
|
|
6
|
+
throw new Error('permission denied: ' + action);
|
|
7
|
+
}
|
|
8
|
+
/** 逐字段校验 spec card sections:数组进 string 段(如 testing)会在下游 .trim() 崩溃。 */
|
|
9
|
+
function validateSections(sections) {
|
|
10
|
+
const errors = [];
|
|
11
|
+
const s = (sections ?? {});
|
|
12
|
+
const strFields = [
|
|
13
|
+
['problem', 'problem'], ['solution', 'solution'], ['testing', 'testing'], ['out_of_scope', 'out_of_scope'],
|
|
14
|
+
];
|
|
15
|
+
for (const [, key] of strFields) {
|
|
16
|
+
if (typeof s[key] !== 'string')
|
|
17
|
+
errors.push(`sections.${key} must be a string (got: ${JSON.stringify(s[key])})`);
|
|
18
|
+
}
|
|
19
|
+
const arrFields = [['user_stories', 'user_stories'], ['impl_decisions', 'impl_decisions']];
|
|
20
|
+
for (const [, key] of arrFields) {
|
|
21
|
+
if (!Array.isArray(s[key]))
|
|
22
|
+
errors.push(`sections.${key} must be an array`);
|
|
23
|
+
else if (s[key].some((v) => typeof v !== 'string'))
|
|
24
|
+
errors.push(`sections.${key} must be string[]`);
|
|
25
|
+
}
|
|
26
|
+
return errors;
|
|
27
|
+
}
|
|
28
|
+
/** 规格卡工具工厂:主会话(human)专属——编辑/批准仅 human;查看任意角色可读。 */
|
|
29
|
+
export function buildSpecCardTools(service, getCaller) {
|
|
30
|
+
return [
|
|
31
|
+
defineTool({
|
|
32
|
+
name: 'spec_card_view',
|
|
33
|
+
description: 'View a spec card (draft or approved).',
|
|
34
|
+
parameters: { cardId: { type: 'string', required: true, description: 'Spec card id (sc_xxx)' } },
|
|
35
|
+
output: { schema: { type: 'json' }, render: (_a, v) => [{ type: 'text', text: JSON.stringify(v) }] },
|
|
36
|
+
async execute(args) {
|
|
37
|
+
const state = await service.snapshot();
|
|
38
|
+
const card = state.specCards.get(args.cardId);
|
|
39
|
+
if (!card)
|
|
40
|
+
throw new Error('unknown spec card: ' + args.cardId);
|
|
41
|
+
return card;
|
|
42
|
+
},
|
|
43
|
+
}),
|
|
44
|
+
defineTool({
|
|
45
|
+
name: 'spec_card_edit',
|
|
46
|
+
description: 'Edit a draft spec card sections (human only).',
|
|
47
|
+
parameters: {
|
|
48
|
+
cardId: { type: 'string', required: true },
|
|
49
|
+
sections: { type: 'json', required: true, description: 'Six-section spec card body' },
|
|
50
|
+
},
|
|
51
|
+
output: { schema: { type: 'json' }, render: (_a, v) => [{ type: 'text', text: JSON.stringify(v) }] },
|
|
52
|
+
async execute(args) {
|
|
53
|
+
const errs = validateSections(args.sections);
|
|
54
|
+
if (errs.length > 0)
|
|
55
|
+
throw new Error('invalid spec card sections: ' + errs.join('; '));
|
|
56
|
+
const caller = getCaller();
|
|
57
|
+
guard('spec-edit', caller);
|
|
58
|
+
const card = await service.editSpecCard(args.cardId, args.sections, caller.actor);
|
|
59
|
+
return card;
|
|
60
|
+
},
|
|
61
|
+
}),
|
|
62
|
+
defineTool({
|
|
63
|
+
name: 'spec_card_approve',
|
|
64
|
+
description: 'Approve a spec card and move its chain to executing (human only).',
|
|
65
|
+
parameters: { cardId: { type: 'string', required: true } },
|
|
66
|
+
output: { schema: { type: 'json' }, render: (_a, v) => [{ type: 'text', text: JSON.stringify(v) }] },
|
|
67
|
+
async execute(args) {
|
|
68
|
+
const caller = getCaller();
|
|
69
|
+
guard('spec-approve', caller);
|
|
70
|
+
const card = await service.approveSpecCard(args.cardId, caller.actor);
|
|
71
|
+
return card;
|
|
72
|
+
},
|
|
73
|
+
}),
|
|
74
|
+
];
|
|
75
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { WikiVaultClient } from '../wiki/wiki-vault-client.js';
|
|
2
|
+
import type { ToolCaller } from './kanban-tools.js';
|
|
3
|
+
/** wiki-vault 工具工厂(W 角色 agent scope):search/read/write,权限兜底 wiki-read=w/d、wiki-write=w。 */
|
|
4
|
+
export declare function buildWikiTools(wiki: WikiVaultClient, getCaller: () => ToolCaller): import("@deepseek-ai/dsh-tools").ToolDefinition[];
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
2
|
+
import { can } from '../domain/permissions.js';
|
|
3
|
+
import { assertAllowedWikiPagePath } from '../wiki/page-path.js';
|
|
4
|
+
function guard(action, caller) {
|
|
5
|
+
if (!can(action, caller.actor, null))
|
|
6
|
+
throw new Error('permission denied: ' + action);
|
|
7
|
+
}
|
|
8
|
+
/** wiki-vault 工具工厂(W 角色 agent scope):search/read/write,权限兜底 wiki-read=w/d、wiki-write=w。 */
|
|
9
|
+
export function buildWikiTools(wiki, getCaller) {
|
|
10
|
+
return [
|
|
11
|
+
defineTool({
|
|
12
|
+
name: 'wiki_search',
|
|
13
|
+
description: 'Search the wiki-vault knowledge base.',
|
|
14
|
+
parameters: { q: { type: 'string', required: true } },
|
|
15
|
+
output: { schema: { type: 'json' }, render: (_a, v) => [{ type: 'text', text: JSON.stringify(v) }] },
|
|
16
|
+
async execute(args) {
|
|
17
|
+
const caller = getCaller();
|
|
18
|
+
guard('wiki-read', caller);
|
|
19
|
+
const results = await wiki.search(args.q);
|
|
20
|
+
return results;
|
|
21
|
+
},
|
|
22
|
+
}),
|
|
23
|
+
defineTool({
|
|
24
|
+
name: 'wiki_read',
|
|
25
|
+
description: 'Read a wiki-vault page (read-only KB access).',
|
|
26
|
+
parameters: { pagePath: { type: 'string', required: true } },
|
|
27
|
+
output: { schema: { type: 'json' }, render: (_a, v) => [{ type: 'text', text: JSON.stringify(v) }] },
|
|
28
|
+
async execute(args) {
|
|
29
|
+
const caller = getCaller();
|
|
30
|
+
guard('wiki-read', caller);
|
|
31
|
+
const page = await wiki.read(args.pagePath);
|
|
32
|
+
return page;
|
|
33
|
+
},
|
|
34
|
+
}),
|
|
35
|
+
defineTool({
|
|
36
|
+
name: 'wiki_write',
|
|
37
|
+
description: 'Write a wiki-vault page under the configured pagePrefix (W only).',
|
|
38
|
+
parameters: {
|
|
39
|
+
pagePath: { type: 'string', required: true },
|
|
40
|
+
content: { type: 'string', required: true },
|
|
41
|
+
},
|
|
42
|
+
output: { schema: { type: 'json' }, render: (_a, v) => [{ type: 'text', text: JSON.stringify(v) }] },
|
|
43
|
+
async execute(args) {
|
|
44
|
+
const caller = getCaller();
|
|
45
|
+
guard('wiki-write', caller);
|
|
46
|
+
// Q3&5:工具边界强校验——只允许 projects/checklists/、projects/ch_*/t_*.md、projects/ch_*/review/
|
|
47
|
+
// 三类命名空间(page-path.ts 白名单),杜绝 LLM 自造路径/拼错层级导致 kb_url 无法跳转。
|
|
48
|
+
assertAllowedWikiPagePath(args.pagePath);
|
|
49
|
+
const out = await wiki.write(args.pagePath, args.content);
|
|
50
|
+
// Q4:工具直接拼完整 kb_url(host 用 config.wikiVault.baseUrl,杜绝 LLM 手写错域名)。
|
|
51
|
+
// W 角色交付闸(kanban_complete w:kb)同时校验 host 前缀,双保险。
|
|
52
|
+
const base = wiki.baseUrl.replace(/\/$/, '');
|
|
53
|
+
return { path: out.path, kb_url: `${base}/#/page/${args.pagePath}` };
|
|
54
|
+
},
|
|
55
|
+
}),
|
|
56
|
+
];
|
|
57
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { WikiVaultClient } from './wiki-vault-client.js';
|
|
2
|
+
import type { BoardState } from '../domain/types.js';
|
|
3
|
+
/**
|
|
4
|
+
* 机械互链登记:链上 w:kb 任务完成时调用。
|
|
5
|
+
* - 清单页:追加/更新「关联文档」区块,列出链上全部已 done 的 W2/W3 页。
|
|
6
|
+
* - 当前页:写回清单页链接(清单 ref 来自规格卡 kind:'kb' 附件)。
|
|
7
|
+
* 幂等(containsLine / 区块就地替换);任何 wiki 读/写失败均静默跳过,不抛错。
|
|
8
|
+
*/
|
|
9
|
+
export declare function syncKbLinks(wiki: WikiVaultClient, state: BoardState, taskId: string): Promise<void>;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/** 目标页若已含指定行(幂等判据),则不再重复注入。 */
|
|
2
|
+
function containsLine(content, line) {
|
|
3
|
+
return content.split('\n').some((l) => l.trim() === line.trim());
|
|
4
|
+
}
|
|
5
|
+
/** 构造页内互链行:用 wiki 内部相对路径(#/page/…),host 变化不失效。 */
|
|
6
|
+
function linkLine(label, pagePath) {
|
|
7
|
+
return `- ${label}:[#/page/${pagePath}](#/page/${pagePath})`;
|
|
8
|
+
}
|
|
9
|
+
/** 读取现有内容;页不存在或 KB 不可达 → 返回 null(调用方跳过,不阻塞)。 */
|
|
10
|
+
async function tryRead(wiki, pagePath) {
|
|
11
|
+
try {
|
|
12
|
+
const d = await wiki.read(pagePath);
|
|
13
|
+
return d.rawMd;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
async function tryWrite(wiki, pagePath, content) {
|
|
20
|
+
try {
|
|
21
|
+
await wiki.write(pagePath, content);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// 登记失败不阻塞完成(completeTask 钩子外再捕获)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** 往文档追加「关联文档」区块(已存在则就地更新,避免重复追加)。 */
|
|
28
|
+
function upsertLinkedBlock(content, header, lines) {
|
|
29
|
+
const block = ['## 关联文档', `> ${header}`, ...lines, ''].join('\n');
|
|
30
|
+
const idx = content.indexOf('## 关联文档');
|
|
31
|
+
if (idx === -1)
|
|
32
|
+
return content.replace(/\n*$/, '\n\n') + block;
|
|
33
|
+
// 替换旧区块:找到下一个二级标题或结尾
|
|
34
|
+
const next = content.indexOf('\n## ', idx + '## 关联文档'.length);
|
|
35
|
+
const end = next === -1 ? content.length : next + 1;
|
|
36
|
+
return content.slice(0, idx) + block + '\n' + content.slice(end);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* 机械互链登记:链上 w:kb 任务完成时调用。
|
|
40
|
+
* - 清单页:追加/更新「关联文档」区块,列出链上全部已 done 的 W2/W3 页。
|
|
41
|
+
* - 当前页:写回清单页链接(清单 ref 来自规格卡 kind:'kb' 附件)。
|
|
42
|
+
* 幂等(containsLine / 区块就地替换);任何 wiki 读/写失败均静默跳过,不抛错。
|
|
43
|
+
*/
|
|
44
|
+
export async function syncKbLinks(wiki, state, taskId) {
|
|
45
|
+
const task = state.tasks.get(taskId);
|
|
46
|
+
if (!task || task.assignee !== 'w' || task.mode !== 'kb')
|
|
47
|
+
return;
|
|
48
|
+
const chain = state.chains.get(task.chainId);
|
|
49
|
+
if (!chain?.specCardId)
|
|
50
|
+
return;
|
|
51
|
+
const spec = state.specCards.get(chain.specCardId);
|
|
52
|
+
// 清单页 ref:kind:'kb' 附件;仅接受 projects/ 命名空间(临时目录兜底路径不是 KB 页,跳过)
|
|
53
|
+
const checklistRef = spec?.attachments.find((a) => a.kind === 'kb')?.ref ?? null;
|
|
54
|
+
const checklistPath = checklistRef && checklistRef.startsWith('projects/') ? checklistRef : null;
|
|
55
|
+
// 链上已 done 的 W2/W3 页(含当前任务所属页)
|
|
56
|
+
const pages = [...state.tasks.values()]
|
|
57
|
+
.filter((t) => t.chainId === task.chainId && t.assignee === 'w' && t.mode === 'kb' && t.status === 'done')
|
|
58
|
+
.map((t) => ({
|
|
59
|
+
title: t.title,
|
|
60
|
+
pagePath: typeof state.handoffs.get(t.id)?.metadata?.page_path === 'string'
|
|
61
|
+
? state.handoffs.get(t.id).metadata.page_path
|
|
62
|
+
: null,
|
|
63
|
+
}))
|
|
64
|
+
.filter((p) => p.pagePath !== null && p.pagePath.startsWith('projects/'));
|
|
65
|
+
if (pages.length === 0)
|
|
66
|
+
return;
|
|
67
|
+
// 1) 清单页:登记全部 W2/W3 页链接
|
|
68
|
+
if (checklistPath) {
|
|
69
|
+
const cur = await tryRead(wiki, checklistPath);
|
|
70
|
+
if (cur !== null) {
|
|
71
|
+
const lines = pages.map((p) => linkLine(p.title, p.pagePath));
|
|
72
|
+
await tryWrite(wiki, checklistPath, upsertLinkedBlock(cur, '需求完整链路', lines));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// 2) 每个 W2/W3 页:写回清单页链接
|
|
76
|
+
if (checklistPath) {
|
|
77
|
+
for (const p of pages) {
|
|
78
|
+
const cur = await tryRead(wiki, p.pagePath);
|
|
79
|
+
if (cur === null)
|
|
80
|
+
continue;
|
|
81
|
+
const line = linkLine('需求清单', checklistPath);
|
|
82
|
+
if (containsLine(cur, line))
|
|
83
|
+
continue;
|
|
84
|
+
await tryWrite(wiki, p.pagePath, upsertLinkedBlock(cur, '需求清单', [line]));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare const CHECKLIST_PAGE_PREFIX = "projects/checklists/";
|
|
2
|
+
/** 从需求名(回退 problem)派生 URL 安全 slug:ASCII 化、空格/特殊字符→-、限长 40;全非 ASCII(如中文)兜底 'req'。 */
|
|
3
|
+
export declare function buildChecklistSlug(name: string): string;
|
|
4
|
+
export declare function isAllowedWikiPagePath(pagePath: string): boolean;
|
|
5
|
+
/** 工具边界硬校验:不符白名单直接抛 kb-rejected(wiki_write 用)。 */
|
|
6
|
+
export declare function assertAllowedWikiPagePath(pagePath: string): void;
|