@joekytc/dsh-swarm 0.3.8 → 0.3.10

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 (39) hide show
  1. package/lib/config.d.ts +1 -1
  2. package/lib/dispatcher/agent-runner.js +37 -2
  3. package/lib/dispatcher/dispatcher.d.ts +8 -0
  4. package/lib/dispatcher/dispatcher.js +21 -0
  5. package/lib/dispatcher/merge-gate.d.ts +1 -0
  6. package/lib/dispatcher/merge-gate.js +28 -1
  7. package/lib/dispatcher/session-events.d.ts +9 -1
  8. package/lib/dispatcher/session-events.js +14 -2
  9. package/lib/dispatcher/v-orchestrator.js +13 -6
  10. package/lib/domain/delivery-contract.d.ts +2 -1
  11. package/lib/domain/delivery-contract.js +16 -3
  12. package/lib/domain/ocr-review.d.ts +5 -1
  13. package/lib/domain/ocr-review.js +10 -2
  14. package/lib/domain/planning-checklist.d.ts +33 -0
  15. package/lib/domain/planning-checklist.js +197 -0
  16. package/lib/domain/prefetch-manifest.js +15 -4
  17. package/lib/roles/preset-installer.d.ts +14 -0
  18. package/lib/roles/preset-installer.js +17 -0
  19. package/lib/roles/toolsets.d.ts +1 -1
  20. package/lib/roles/toolsets.js +7 -5
  21. package/lib/routes/planning-driver.d.ts +2 -1
  22. package/lib/routes/planning-driver.js +13 -10
  23. package/lib/services/ocr-cli.d.ts +5 -0
  24. package/lib/services/ocr-cli.js +10 -1
  25. package/lib/tools/kanban-tools.js +10 -2
  26. package/lib/tools/main-session-tools.d.ts +2 -1
  27. package/lib/tools/main-session-tools.js +101 -20
  28. package/lib/tools/ocr-review-tools.d.ts +3 -1
  29. package/lib/tools/ocr-review-tools.js +23 -5
  30. package/lib/tools/planning-tools.d.ts +3 -0
  31. package/lib/tools/planning-tools.js +248 -3
  32. package/lib/tools/spec-card-tools.js +8 -2
  33. package/lib/tools/wiki-tools.js +1 -1
  34. package/lib/wiki/page-path.d.ts +2 -1
  35. package/lib/wiki/page-path.js +6 -4
  36. package/package.json +2 -2
  37. package/personas/kanban-dt/agent.cordis.yml +4 -1
  38. package/personas/persona-dt.md +2 -1
  39. package/skills/grill-me/SKILL.md +7 -0
@@ -1,6 +1,8 @@
1
1
  import { defineTool } from '@deepseek-ai/dsh-tools';
2
2
  import {} from '@deepseek-ai/dsh-util-values';
3
- import { tmpdir } from 'node:os';
3
+ import { readdirSync } from 'node:fs';
4
+ import { homedir, tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
4
6
  import { KanbanProvider } from '../services/kanban-provider.js';
5
7
  import { buildKanbanTools } from './kanban-tools.js';
6
8
  import { buildSpecCardTools } from './spec-card-tools.js';
@@ -12,6 +14,7 @@ import { buildPlanningGuidance } from '../routes/planning-driver.js';
12
14
  import { attachSessionToWorkspace, resolveOrCreateWorkspace } from '../dispatcher/workspace-attach.js';
13
15
  import { sessionPresetOf } from '../dispatcher/session-preset.js';
14
16
  import { PREFETCH_MANIFEST_SCHEMA } from '../domain/prefetch-manifest.js';
17
+ import { extractChecklistJson } from '../domain/planning-checklist.js';
15
18
  import { WikiVaultClient } from '../wiki/wiki-vault-client.js';
16
19
  import { LocalWikiClient } from '../wiki/local-kb-client.js';
17
20
  import { ensureLocalKbRoot } from '../wiki/local-kb.js';
@@ -19,9 +22,9 @@ import { LOCAL_CHECKLIST_PREFIX } from '../wiki/page-path.js';
19
22
  export const planningBySession = new Map();
20
23
  export const KANBAN_HANDOFF_RULE = (routes, opts) => {
21
24
  const confirmLine = opts?.swarm
22
- ? `- 澄清期:调 planning_prefetch(只读子代理)采集仓库事实 → 逐问用户收敛 → 调 planning_checklist_save 存需求澄清清单。
23
- - 确认闸:清单落库后向用户征求确认;仅当用户回复含明确肯定语义(确认/开干/开跑/开始/go 等)才调 kanban_route{intent:'openspec'} 建链;模糊、岔开话题、只提修改意见 = 未确认,继续澄清。禁止未确认建链。`
24
- : `- 澄清期:调 planning_prefetch(只读子代理)采集仓库事实 → 逐问用户收敛 → 调 planning_checklist_save 存需求澄清清单 → 提醒用户 ${routes.openspec} 确认。`;
25
+ ? `- 澄清期:用户给了 PRD/需求文档链接 → 先 planning_prd_collect 逐条采集(blocked 即停,按返回 guidance 转告用户解决后重采,禁猜禁跳)→ planning_prefetch(只读子代理)采集仓库事实 → (绿地:目标目录无 .git → 清单声明 greenfield:true)→ 逐问用户收敛 → planning_checklist_save 存需求澄清清单(每条问答完整决策正文,禁缩写),每条决策可追溯(问答/源码/文档位置至少其一,冲突值标【已调整】/【已确认】)。
26
+ - 确认闸:清单落库后向用户征求确认;仅当用户回复包含明确肯定语义(确认/开干/开跑/开始/go 等)才调 kanban_route{intent:'openspec'} 建链;模糊、岔开话题、只提修改意见 = 未确认,继续澄清。禁止未确认建链。`
27
+ : `- 澄清期:用户给了 PRD/需求文档链接 → 先 planning_prd_collect 逐条采集(blocked 即停,按返回 guidance 转告用户解决后重采,禁猜禁跳)→ planning_prefetch(只读子代理)采集仓库事实 → (绿地:目标目录无 .git → 清单声明 greenfield:true)→ 逐问用户收敛 → 调 planning_checklist_save 存需求澄清清单(每条问答完整决策正文,禁缩写),每条决策可追溯(问答/源码/文档位置至少其一,冲突值标【已调整】/【已确认】)→ 提醒用户 ${routes.openspec} 确认。`;
25
28
  const nextLine = opts?.swarm
26
29
  ? `- 用户确认后你调 kanban_route{intent:'openspec'};链路进入 executing,V 自动串行建卡 p→(pt)→w2→d→dt→w3;你不要自己执行。`
27
30
  : `- ${routes.openspec} 后链路进入 executing,V 自动串行建卡 p→(pt)→w2→d→dt→w3;你不要自己执行。`;
@@ -61,21 +64,25 @@ const RECOVERY_KB_GUIDANCE = (routes, candidates) => `
61
64
  ${candidates.map((c) => '- ' + c).join('\n')}
62
65
  恢复步骤(严格顺序):
63
66
  1. 读取候选页内容,对照当前需求判定哪一页是本次需求的需求澄清清单(页首行标题为「# 【需求】<需求名>」);
64
- 2. 消化该页内容,重建结构化 PlanningChecklist(spec 六段 + manifest + clarifications + doubts + risks(风险点,若有),requirementName 取页标题中【需求】后的名称;恢复重建时 clarifications 允许登记单条无澄清说明,如 {"q": "本轮无澄清(恢复重建)", "a": "<来源页或原因>"},其余场景 clarifications 禁止为空);
65
- 3. 调 planning_checklist_save(checklist, restoreRef=<该候选页路径>) 回存(覆盖原页,勿产生重复页);
66
- 4. 回存成功后提示用户重新发送 ${routes.openspec} 确认。
67
+ 2. 若页尾含 <!-- dsh-swarm:checklist-json ... --> 机读段:不要手动重建、不要调 planning_checklist_save——直接提示用户重新发送 ${routes.openspec}(路由会自动机读恢复并当场建链,LLM 零参与);
68
+ 3. 仅 legacy 页(无机读段)才执行本步:消化该页内容,重建结构化 PlanningChecklist(spec 六段 + manifest + clarifications + doubts + risks(风险点,若有),requirementName 取页标题中【需求】后的名称;恢复重建时 clarifications 允许登记单条无澄清说明,如 {"q": "本轮无澄清(恢复重建)", "a": "<来源页或原因>"},其余场景 clarifications 禁止为空);重建时旧页若无来源信息,sources 录 [{type:'其他', url:'', note:'恢复重建,原页无来源'}];无 PRD 采集记录则 prdCollection 录 [];
69
+ 4. legacy 页重建后调 planning_checklist_save(checklist, restoreRef=<该候选页路径>) 回存(覆盖原页,勿产生重复页),成功后提示用户重新发送 ${routes.openspec} 确认。
67
70
  禁止:跳过恢复直接建链建卡;猜测清单内容;把恢复失败归因于"重试/进程检查"之外的任何原因。
68
71
  `;
69
72
  const RECOVERY_NONE_GUIDANCE = (routes) => `
70
73
  两条获取路由均无本需求的需求澄清清单(内存为空,知识库亦无匹配页)。
71
74
  处理步骤(严格顺序):
72
75
  1. 消化当前对话上下文,判断需求澄清(grill-me 逐问收敛 + planning_prefetch 仓库事实)是否已完成但漏了保存动作;
73
- 2. 若已完成澄清——立即调 planning_checklist_save 保存清单;若尚未完成——先完成澄清(缺仓库事实则先 planning_prefetch),再保存;
76
+ 2. 若已完成澄清——立即调 planning_checklist_save 保存清单;若尚未完成——先完成澄清(缺仓库事实则先 planning_prefetch),再保存;重建时旧页若无来源信息,sources 录 [{type:'其他', url:'', note:'恢复重建,原页无来源'}];无 PRD 采集记录则 prdCollection 录 [];
74
77
  3. 保存成功后提示用户重新发送 ${routes.openspec} 确认。
75
78
  禁止:在清单落库前建链建卡;编造"先重试 / 查服务进程是否重启"之类与清单无关的诊断。
76
79
  `;
77
80
  /** 预取子代理禁用的写能力工具(官方全局工具名;deny = 从 prompt 消失 + 拒绝执行,"one visibility")。 */
78
81
  const PREFETCH_DENIED_TOOLS = ['bash', 'edit', 'write'];
82
+ /** 采集缝白名单:采集类工具放行 + skill 工具放行(子代理经 skill 工具加载 ~/.agents/skills/ 枚举出的采集技能正文——
83
+ * 技能名按目录动态枚举注入 prompt,配合放行 skill 工具,枚举出的技能才能加载其实现正文)。
84
+ * 写盘边界:子代理只许写临时目录,产物入 KB 由 planning_prd_collect 工具侧收口。 */
85
+ const PRD_COLLECT_ALLOWED_TOOLS = ['web_fetch', 'read', 'glob', 'grep', 'skill'];
79
86
  export function buildSpawnPrefetch(ctx) {
80
87
  const subagents = ctx.get('subagents');
81
88
  if (!subagents?.start)
@@ -119,6 +126,58 @@ export function buildSpawnPrefetch(ctx) {
119
126
  }
120
127
  };
121
128
  }
129
+ export function buildSpawnPrdCollect(ctx, listSkills = defaultSkillNames) {
130
+ const subagents = ctx.get('subagents');
131
+ if (!subagents?.start)
132
+ return undefined;
133
+ return async (prompt, workspaceDir, parentAgent, signal) => {
134
+ if (!parentAgent)
135
+ throw new Error('planning_prd_collect: missing parent agent — 工具运行时未注入 exec.agent');
136
+ // 无规划会话工作区 = fail-loud:拒绝回退 process.cwd()(会把子会话归组到插件进程目录,污染工作区边界)
137
+ if (!workspaceDir)
138
+ throw new Error('planning_prd_collect: missing workspace dir — 无活跃规划会话工作区,拒绝回退 process.cwd()');
139
+ const cwd = workspaceDir;
140
+ const skills = listSkills();
141
+ const fullPrompt = prompt + '\n\n可用采集技能(~/.agents/skills/ 实测枚举):' + (skills.length > 0 ? skills.join('、') : '(无——采集能力不足,如实报 blocked:缺技能)');
142
+ let run;
143
+ try {
144
+ run = await subagents.start('spawn', {
145
+ label: 'prd-collect',
146
+ prompt: [{ type: 'text', text: fullPrompt }],
147
+ parent: parentAgent,
148
+ signal: signal ?? new AbortController().signal,
149
+ maxDepth: 1,
150
+ toolFilter: { allow: [...PRD_COLLECT_ALLOWED_TOOLS] },
151
+ });
152
+ }
153
+ catch (err) {
154
+ throw new Error('planning_prd_collect: subagent start failed: ' + String(err));
155
+ }
156
+ try {
157
+ await attachSessionToWorkspace(ctx, run.id, cwd, 'prd-collect');
158
+ const result = await run.result;
159
+ if (result.stopReason !== 'completed') {
160
+ throw new Error(`planning_prd_collect: subagent ended with stopReason=${result.stopReason}${result.error ? ' — ' + result.error : ''}`);
161
+ }
162
+ if (result.structured !== undefined)
163
+ return JSON.stringify(result.structured);
164
+ return (result.output ?? []).map((b) => b.text ?? '').join('');
165
+ }
166
+ finally {
167
+ await run.dispose().catch(() => undefined);
168
+ }
169
+ };
170
+ }
171
+ /** ~/.agents/skills/ 目录名枚举(技能发现与 DSH 同源);读失败返回空数组(降级为「无技能」引导)。 */
172
+ function defaultSkillNames() {
173
+ try {
174
+ return readdirSync(join(homedir(), '.agents', 'skills'), { withFileTypes: true })
175
+ .filter((d) => d.isDirectory()).map((d) => d.name);
176
+ }
177
+ catch {
178
+ return [];
179
+ }
180
+ }
122
181
  /** 自由投递 guidance:三步教学(实查基准 → 生成正文 → sms_send 投递)+ 红线分界。 */
123
182
  function buildFreeSendGuidance(routes, query, dm) {
124
183
  return [
@@ -165,11 +224,12 @@ export function registerMainSessionTools(ctx, configProvider) {
165
224
  if (tool.name === 'spec_card_view')
166
225
  registry.register(tool);
167
226
  }
168
- // planning 工具(清单落库 + 只读预取)——spawnPrefetch 由模块级 buildSpawnPrefetch 提供(可单测)
227
+ // planning 工具(清单落库 + 只读预取 + PRD 采集)——spawnPrefetch/spawnPrdCollect 由模块级 builder 提供(可单测)
169
228
  for (const tool of buildPlanningTools({
170
229
  service, wiki: wiki, // local 模式为 LocalWikiClient(write/read/search 同面,双模式客户端)
171
230
  getCaller: caller,
172
231
  spawnPrefetch: buildSpawnPrefetch(ctx),
232
+ spawnPrdCollect: buildSpawnPrdCollect(ctx),
173
233
  tempDir: () => `${tmpdir()}/dsh-swarm-checklists`, // KB 不可达时的临时兜底,放系统临时目录(不落插件源码/核心存储目录)
174
234
  pagePrefix: configProvider.getEffective().wikiVault?.pagePrefix ?? 'projects/', // 生成的清单页路径保持在该客户端配置的命名空间内(避免 kb-rejected)
175
235
  kbMode: configProvider.mode, // 双模式:local 时 checklist/learning 落本地库命名空间(wiki/queries/checklists/、wiki/synthesis/learnings/)
@@ -270,13 +330,9 @@ export function registerMainSessionTools(ctx, configProvider) {
270
330
  }
271
331
  if (plan.kind === 'none')
272
332
  return { kind: 'none' };
273
- // 路由1(内存):planningBySession 命中 → 直接建链
274
- const pctx = planningBySession.get('session_main');
275
- if (pctx?.checklist && pctx.checklistRef) {
276
- // 恢复补捕①:内存有清单但 workspaceDir=null(主 agent 重启后清单经 KB 恢复、异常态)→
277
- // 从 exec.agent.session.header.cwd 捡回工作区,路由1 继续正常建链。已有值绝不重解析
278
- // (不重复弹 ask,对齐下方 /plan: 分支注释顾虑);解析 null(无 cwd/无通道/用户跳过)保持
279
- // 现状 → 走 handleOpenspecRoute 的 workspace-unknown fail-fast(不吞错、不猜测路径)。
333
+ // 建链共用段(2026-09-21 从路由1 提取):恢复补捕① + workspace-mismatch 闸2 + handleOpenspecRoute。
334
+ // 路由1(内存命中)与路由2(KB 机读恢复命中)同走此函数,建链语义单一事实源。
335
+ const openChainFromContext = async (pctx) => {
280
336
  if (!pctx.workspaceDir) {
281
337
  const headerCwd = exec?.agent?.session?.header?.cwd ?? null;
282
338
  const workspaceDir = await resolveOrCreateWorkspace(ctx, headerCwd, '主 agent 会话(/openspec: 恢复)');
@@ -303,11 +359,18 @@ export function registerMainSessionTools(ctx, configProvider) {
303
359
  kind: 'openspec', chainId: r.chainId, specCardId: r.specCardId, approved: true, firstCard: r.firstCard,
304
360
  guidance: KANBAN_HANDOFF_RULE(configProvider.getEffective().prefixRoutes, { swarm: planningBySession.get('session_main')?.mode === 'swarm' }) + '\n\n' + buildOpenspecNarrationRule({ chainId: r.chainId, specCardId: r.specCardId, firstCard: r.firstCard }),
305
361
  };
362
+ };
363
+ // 路由1(内存):planningBySession 命中 → 直接建链
364
+ const pctx = planningBySession.get('session_main');
365
+ if (pctx?.checklist && pctx.checklistRef) {
366
+ // 恢复补捕①:内存有清单但 workspaceDir=null(主 agent 重启后清单经 KB 恢复、异常态)→
367
+ // 从 exec.agent.session.header.cwd 捡回工作区(已有值绝不重解析,不重复弹 ask)。
368
+ return await openChainFromContext({ ...pctx, checklist: pctx.checklist, checklistRef: pctx.checklistRef });
306
369
  }
307
- // 路由2(知识库):内存丢失(插件重启)→ 搜 KB 候选清单页供 LLM 读页重建;搜不到/不可达 → 两条路皆空
308
- // 恢复补捕②:无内存条目或工作区未捕获 → 从 header.cwd 捡回,写入 planningBySession(checklist 等
309
- // 字段保持 cur 或空缺省),后续 planning_checklist_save 触发 onChecklistSaved 的 { ...cur } 展开自然
310
- // 带上 workspaceDir。解析失败不阻塞恢复 guidance 返回(行为同现状,只是少捕一次)。
370
+ // 路由2(知识库):内存丢失(插件重启)→ 优先机读恢复(2026-09-21 矫正):候选页尾含无损 JSON 段
371
+ // → extractChecklistJson 提取 → 灌内存 → 当场建链(LLM 零参与,杜绝读页重建的失真/双重编码/回存绕路);
372
+ // 无段/损坏/校验不过(legacy 页)→ 回退既有 LLM 读页重建+回存流程。搜不到/不可达 → 两条路皆空。
373
+ // 恢复补捕②:无内存条目或工作区未捕获 → 从 header.cwd 捡回,写入 planningBySession。
311
374
  if (!planningBySession.get('session_main')?.workspaceDir) {
312
375
  const headerCwd = exec?.agent?.session?.header?.cwd ?? null;
313
376
  const workspaceDir = await resolveOrCreateWorkspace(ctx, headerCwd, '主 agent 会话(/openspec: 恢复)');
@@ -321,6 +384,24 @@ export function registerMainSessionTools(ctx, configProvider) {
321
384
  candidates = await searchChecklists(wiki, kbMode === 'local' ? LOCAL_CHECKLIST_PREFIX : (configProvider.getEffective().wikiVault?.pagePrefix ?? 'projects/'));
322
385
  }
323
386
  catch { /* KB 不可达/搜索失败 → 候选为空,走两条路皆空分支 */ }
387
+ let machineRestored = null;
388
+ for (const cpath of candidates) {
389
+ try {
390
+ const d = await wiki.read(cpath);
391
+ const c = extractChecklistJson(d.rawMd);
392
+ if (c) {
393
+ machineRestored = { path: cpath, checklist: c };
394
+ break;
395
+ }
396
+ }
397
+ catch { /* 单页读取失败 → 试下一候选 */ }
398
+ }
399
+ if (machineRestored) {
400
+ const cur = planningBySession.get('session_main') ?? { workspaceDir: null, sessionId: 'session_main', checklist: null, checklistRef: null, checklistSource: null, requirementName: null };
401
+ const rpctx = { ...cur, checklist: machineRestored.checklist, checklistRef: machineRestored.path, checklistSource: 'kb', requirementName: machineRestored.checklist.requirementName ?? null };
402
+ planningBySession.set('session_main', rpctx);
403
+ return await openChainFromContext(rpctx);
404
+ }
324
405
  return {
325
406
  kind: 'openspec', approved: false, reason: 'no-checklist',
326
407
  recovery: candidates.length > 0 ? 'kb' : 'none',
@@ -2,7 +2,9 @@
2
2
  * 依赖全注入可换 fake(tests),缺省用 ocr-cli 真实现。 */
3
3
  import { type ToolDefinition as ToolDef } from '@deepseek-ai/dsh-tools';
4
4
  import { managedProviderReady, probeOcr, runOcr } from '../services/ocr-cli.js';
5
- /** 构造 ocr_review 工具定义(defineTool 返回形态,与 kanban-tools 一致)。 */
5
+ /** 构造 ocr_review 工具定义(defineTool 返回形态,与 kanban-tools 一致)。
6
+ * deps.cwd = 会话工作目录兜底注入(测试/特殊宿主);生产不注入——目标仓库只来自
7
+ * repo 参数或 exec.agent.session.header.cwd,绝不隐式用插件进程 cwd(非仓库)。 */
6
8
  export declare function buildOcrReviewTool(deps: {
7
9
  runOcrFn?: typeof runOcr;
8
10
  probeFn?: typeof probeOcr;
@@ -5,9 +5,21 @@ import { defineTool } from '@deepseek-ai/dsh-tools';
5
5
  import { INSTALL_GUIDANCE, managedProviderReady, probeOcr, runOcr } from '../services/ocr-cli.js';
6
6
  import { buildOcrArgs, parseManagedJson, parsePreviewJson, shouldSuggestManaged, SUGGEST_MANAGED_FILES } from '../domain/ocr-review.js';
7
7
  /** 托管未配置时的降级引导:不改道执行,交还调用方决策。 */
8
- const MANAGED_FALLBACK_TEXT = '托管模式未配置 LLM——本次按委托模式执行:改调 sub=\'preview\' 获取评审范围后自行评审';
8
+ const MANAGED_FALLBACK_TEXT = '托管模式未配置 LLM——本次按委托模式执行:改调 sub=\'preview\' 获取评审范围后自行评审'
9
+ + '(如需启用托管:GUI 配置面板「评审引擎(ocr)」卡选好提供方/模型后点「应用到 ocr」)';
9
10
  const SUBS = ['preview', 'rule', 'managed'];
10
- /** 构造 ocr_review 工具定义(defineTool 返回形态,与 kanban-tools 一致)。 */
11
+ /** 会话工作目录读取(官方链路 ToolRunContext.agent → Agent.session → Session.header.cwd,
12
+ * dsh-session SessionHeader.cwd = "Absolute working directory the session was created in")。
13
+ * 主会话/独立评审 = 用户打开的工作区(仓库目录);链上角色会话 = agent-runner 创建时的
14
+ * meta.cwd(chain.workspaceDir)。鸭子类型读取,取不到返回 undefined。 */
15
+ function sessionCwdOf(agent) {
16
+ const a = agent;
17
+ const cwd = a?.session?.header?.cwd;
18
+ return typeof cwd === 'string' && cwd.trim() ? cwd.trim() : undefined;
19
+ }
20
+ /** 构造 ocr_review 工具定义(defineTool 返回形态,与 kanban-tools 一致)。
21
+ * deps.cwd = 会话工作目录兜底注入(测试/特殊宿主);生产不注入——目标仓库只来自
22
+ * repo 参数或 exec.agent.session.header.cwd,绝不隐式用插件进程 cwd(非仓库)。 */
11
23
  export function buildOcrReviewTool(deps) {
12
24
  const runOcrFn = deps.runOcrFn ?? runOcr;
13
25
  const probeFn = deps.probeFn ?? probeOcr;
@@ -23,7 +35,7 @@ export function buildOcrReviewTool(deps) {
23
35
  + "Start with 'preview' to scope the review; prefer 'managed' for large change sets when the managed provider is configured.",
24
36
  parameters: {
25
37
  sub: { type: 'string', required: true, description: "'preview' | 'rule' | 'managed'" },
26
- repo: { type: 'string', description: '仓库根绝对路径;缺省用当前工作目录' },
38
+ repo: { type: 'string', description: '目标仓库根绝对路径;缺省用当前会话工作目录(会话创建时的 cwd)。评审非当前会话目录的仓库(如临时 clone)时必须显式传' },
27
39
  from: { type: 'string', description: 'base 分支/引用(range 模式)' },
28
40
  to: { type: 'string', description: '目标分支/引用(range 模式,默认 HEAD)' },
29
41
  commit: { type: 'string', description: '单次提交审查' },
@@ -31,7 +43,7 @@ export function buildOcrReviewTool(deps) {
31
43
  background: { type: 'string', description: '业务上下文,托管评审时提升评审质量' },
32
44
  },
33
45
  output: { schema: { type: 'json' }, render: (_a, v) => [{ type: 'text', text: String(v) }] },
34
- async execute(args) {
46
+ async execute(args, exec) {
35
47
  const probe = await probeFn();
36
48
  if (!probe.installed)
37
49
  return INSTALL_GUIDANCE;
@@ -43,9 +55,15 @@ export function buildOcrReviewTool(deps) {
43
55
  throw new Error("ocr_review: sub='rule' requires non-empty paths (file path list)");
44
56
  if (args.sub === 'managed' && !args.commit && !args.from)
45
57
  throw new Error("ocr_review: sub='managed' requires commit or from (base ref)");
58
+ // 目标仓库:显式 repo(跨仓库评审,如临时 clone)优先,其次当前会话工作目录
59
+ // (exec.agent.session.header.cwd);两处都没有则 fail-loud——绝不隐式兜底进程 cwd
60
+ // (插件进程 cwd 实测 ~/.codebuddy,非 git 仓库,ocr 必报 not a git repository)。
61
+ const repo = args.repo?.trim() || sessionCwdOf(exec?.agent) || deps.cwd?.()?.trim() || '';
62
+ if (!repo)
63
+ throw new Error('ocr_review: 无法确定目标仓库——请显式传 repo=<仓库绝对路径>(当前会话未记录工作目录)');
46
64
  // 托管评审走 ocr 自带 LLM(官方预算 15min×2 rounds),超时须远大于 preview/rule 的本地 git 操作
47
65
  const timeoutMs = args.sub === 'managed' ? 2_400_000 : 600_000;
48
- const res = await runOcrFn(buildOcrArgs(args.sub, { repo: args.repo, from: args.from, to: args.to, commit: args.commit, paths: args.paths, background: args.background }), { cwd: deps.cwd?.() ?? process.cwd(), timeoutMs });
66
+ const res = await runOcrFn(buildOcrArgs(args.sub, { repo, from: args.from, to: args.to, commit: args.commit, paths: args.paths, background: args.background }), { cwd: repo, timeoutMs });
49
67
  // 失败不抛错:结果附 error 摘要(error + stderr 前 500 字符),模型拿得到部分结果与原因后自行降级/重试
50
68
  const errSummary = res.error ? `${res.error} ${res.stderr.slice(0, 500)}`.trim() : '';
51
69
  if (args.sub === 'preview') {
@@ -18,6 +18,9 @@ export interface PlanningToolDeps {
18
18
  /** 真实实现:经官方子代理缝(ctx.subagents.start)启动只读预取子代理并返回其文本输出;测试注入 stub。
19
19
  * parentAgent = 发起调用的主 agent(血缘/模型继承源),由 planning_prefetch 的 exec.agent 透传。 */
20
20
  spawnPrefetch?(prompt: string, workspaceDir: string, parentAgent?: Agent, signal?: AbortSignal): Promise<string>;
21
+ /** 采集子代理缝(白名单工具面):启动 PRD 采集子代理并返回其文本输出;测试注入 stub。
22
+ * parentAgent = 发起调用的主 agent(血缘/模型继承源),由 planning_prd_collect 的 exec.agent 透传。 */
23
+ spawnPrdCollect?(prompt: string, workspaceDir: string, parentAgent?: Agent, signal?: AbortSignal): Promise<string>;
21
24
  tempDir(): string;
22
25
  pagePrefix?: string;
23
26
  /** KB 双模式:local 时 checklist/learning 落本地库命名空间(wiki/queries/checklists/、wiki/synthesis/learnings/),缺省 remote。 */
@@ -1,9 +1,9 @@
1
1
  // src/tools/planning-tools.ts
2
2
  import { defineTool } from '@deepseek-ai/dsh-tools';
3
3
  import {} from '@deepseek-ai/dsh-util-values';
4
- import { validatePlanningChecklist, formatChecklistBody } from '../domain/planning-checklist.js';
4
+ import { validatePlanningChecklist, formatChecklistBody, routePrdPlatform, slicePrdMarkdown } from '../domain/planning-checklist.js';
5
5
  import { validatePrefetchManifest } from '../domain/prefetch-manifest.js';
6
- import { buildChecklistSlug, KB_PAGE_NAMESPACES_HINT, LOCAL_CHECKLIST_PREFIX, LOCAL_LEARNING_BASE, assertAllowedWikiPagePath, assertLocalKbPagePath } from '../wiki/page-path.js';
6
+ import { buildChecklistSlug, KB_PAGE_NAMESPACES_HINT, LOCAL_CHECKLIST_PREFIX, LOCAL_SOURCE_DOCS_PREFIX, LOCAL_LEARNING_BASE, assertAllowedWikiPagePath, assertLocalKbPagePath } from '../wiki/page-path.js';
7
7
  import { validateLearning, formatLearningBody, buildRepoSlug, countLearningSignals } from '../domain/memory.js';
8
8
  const isWikiError = (e) => e instanceof Error && e.code === 'kb-unreachable';
9
9
  /** 主 agent 规划期工具:需求澄清清单落库(KB 优先/临时目录兜底)+ 只读仓库预取(子代理)。 */
@@ -20,7 +20,134 @@ export function buildPlanningTools(deps) {
20
20
  defineTool({
21
21
  name: 'planning_checklist_save',
22
22
  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).',
23
- parameters: { checklist: { type: 'json', required: true, description: 'Structured PlanningChecklist: {requirementName?, spec: {problem, solution, user_stories, impl_decisions, testing, out_of_scope}, manifest, clarifications, doubts: Array<{"q": string, "resolved": boolean, "answer"?: string}>, risks?}. spec.user_stories: array of plain strings — each element ONE sentence "As a <role>, I want <capability>, so that <benefit>"; NEVER objects/nested. spec.impl_decisions: array of plain strings — one decision per element; NEVER objects. clarifications: non-empty required — record every Q&A asked during this planning round; Array<{"q": string, "a": string}> with keys exactly "q"/"a" (NOT "question"/"answer"). risks (optional): Array<{"description": string, "source": string, "mitigation": string}> — register real risks/non-blocking concerns surfaced during clarification; omit or [] when none. checklist.requirementName (optional) = ' + deps.prefixRoutes.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' } },
23
+ // 参数用官方类型化 schema(dsh-tools DSL:显式 object 必须声明 additionalProperties)在运行时
24
+ // 强制形状(ToolArgsError/INVALID_ARGS 带"路径+期望+enum"violation);{type:'json'} 仅注解不
25
+ // 约束,曾致 spec 数组/双重编码/expected 词表错三连发(2026-09-21 销服一体清单案例,7 轮失败)。
26
+ // schema 表达不了的语义(非空、确切键名、expected 语义映射)仍由 description + validatePlanningChecklist 承担。
27
+ parameters: {
28
+ checklist: {
29
+ type: 'object', required: true, additionalProperties: true,
30
+ description: 'Structured PlanningChecklist. Shape is runtime-enforced by this schema; semantics not expressible in schema live in the description below.',
31
+ properties: {
32
+ requirementName: { type: 'string', description: 'Optional; page title 【需求】 source, same as task-card title' },
33
+ spec: {
34
+ type: 'object', required: true, additionalProperties: true,
35
+ description: 'Six sections, all required.',
36
+ properties: {
37
+ problem: { type: 'string', required: true, description: 'ONE coherent multi-line text (non-empty), never an array' },
38
+ solution: { type: 'string', required: true, description: 'ONE coherent multi-line text (non-empty), never an array' },
39
+ testing: { type: 'string', required: true, description: 'ONE coherent multi-line text (non-empty), never an array' },
40
+ out_of_scope: { type: 'string', required: true, description: 'ONE coherent multi-line text (non-empty), never an array' },
41
+ user_stories: { type: 'array', required: true, items: { type: 'string' }, description: 'Plain strings only; each element ONE sentence "As a <role>, I want <capability>, so that <benefit>"; never objects/nested' },
42
+ impl_decisions: { type: 'array', required: true, items: { type: 'string' }, description: 'Plain strings only; one decision per element; never objects' },
43
+ },
44
+ },
45
+ manifest: {
46
+ type: 'object', required: true, additionalProperties: true,
47
+ description: 'Repo-baseline facts (same shape as planning_prefetch output manifest).',
48
+ properties: {
49
+ repo: {
50
+ type: 'object', required: true, additionalProperties: true,
51
+ properties: {
52
+ localPath: { type: 'string', required: true, description: 'Absolute path of the target repo' },
53
+ remoteUrl: { type: 'string' },
54
+ branch: { type: 'string' },
55
+ dirtyFiles: { type: 'array', required: true, items: { type: 'string' }, description: 'Uncommitted changes; [] when clean' },
56
+ },
57
+ },
58
+ files: {
59
+ type: 'array', required: true,
60
+ description: 'File baseline; [] when not prefetched.',
61
+ items: {
62
+ type: 'object', additionalProperties: true,
63
+ properties: {
64
+ path: { type: 'string', required: true },
65
+ expected: { type: 'string', required: true, enum: ['exists', 'absent', 'content-hash'], description: 'What the file IS in the repo, NOT your change intent — never "modify"/"create": plan-to-edit file → "exists" + note「计划修改」; plan-to-create file → "absent" + note「计划新建」' },
66
+ note: { type: 'string' },
67
+ },
68
+ },
69
+ },
70
+ },
71
+ },
72
+ clarifications: {
73
+ type: 'array', required: true,
74
+ description: 'Record EVERY Q&A of this planning round; array must be non-empty (recovery rebuild may use {"q":"本轮无澄清(恢复重建)","a":"<来源页>"}).',
75
+ items: {
76
+ type: 'object', additionalProperties: true,
77
+ properties: {
78
+ q: { type: 'string', required: true, description: 'Key is exactly "q", not "question"' },
79
+ a: { type: 'string', required: true, description: 'Key is exactly "a", not "answer"' },
80
+ },
81
+ },
82
+ },
83
+ doubts: {
84
+ type: 'array', required: true,
85
+ items: {
86
+ type: 'object', additionalProperties: true,
87
+ properties: {
88
+ q: { type: 'string', required: true },
89
+ resolved: { type: 'boolean', required: true },
90
+ answer: { type: 'string' },
91
+ },
92
+ },
93
+ },
94
+ risks: {
95
+ type: 'array',
96
+ description: 'Optional; register real risks/non-blocking concerns surfaced during clarification; omit or [] when none.',
97
+ items: {
98
+ type: 'object', additionalProperties: true,
99
+ properties: {
100
+ description: { type: 'string', required: true },
101
+ source: { type: 'string', required: true },
102
+ mitigation: { type: 'string', required: true },
103
+ },
104
+ },
105
+ },
106
+ sources: {
107
+ type: 'array', required: true,
108
+ description: 'REQUIRED — requirement source links; if genuinely none, record exactly [{"type":"其他","url":"","note":"无来源+原因"}]',
109
+ items: {
110
+ type: 'object', additionalProperties: true,
111
+ properties: {
112
+ type: { type: 'string', required: true, enum: ['TAPD', 'Jira', '其他'], description: 'Source platform' },
113
+ url: { type: 'string', description: 'Source link; may be "" when genuinely none (note must then state why)' },
114
+ note: { type: 'string', description: 'Why this source / absence reason' },
115
+ },
116
+ },
117
+ },
118
+ prdCollection: {
119
+ type: 'array', required: true,
120
+ description: 'REQUIRED — register EVERY PRD link the user provided; [] only when no PRD link was given; blocked requires blockedReason (登录态|缺技能|404|其他)',
121
+ items: {
122
+ type: 'object', additionalProperties: true,
123
+ properties: {
124
+ url: { type: 'string', required: true, description: 'PRD link the user provided' },
125
+ platform: { type: 'string', enum: ['feishu', 'wecom', 'modao'], description: 'Optional; auto-routed by domain or LLM-judged' },
126
+ status: { type: 'string', required: true, enum: ['collected', 'blocked'], description: 'collection status' },
127
+ summary: { type: 'string', required: true, description: 'what was collected + key info + gaps' },
128
+ pages: { type: 'array', items: { type: 'string' }, description: 'optional; KB page paths of PRD slices' },
129
+ blockedReason: { type: 'string', description: 'required when status="blocked": 登录态 | 缺技能 | 404 | 其他' },
130
+ degraded: { type: 'boolean', description: 'optional; screenshot>5MB or write failed → degraded trace' },
131
+ },
132
+ },
133
+ },
134
+ placeholders: {
135
+ type: 'array',
136
+ description: 'Optional; centralized placeholder strategy for backend-not-ready items; omit or [] when none.',
137
+ items: {
138
+ type: 'object', additionalProperties: true,
139
+ properties: {
140
+ target: { type: 'string', required: true },
141
+ value: { type: 'string', required: true },
142
+ replace: { type: 'string', required: true },
143
+ },
144
+ },
145
+ },
146
+ greenfield: { type: 'boolean', description: 'Optional; true=greenfield (target dir has no .git), D-phase pre-req to git init; false/absent=existing repo' },
147
+ },
148
+ },
149
+ restoreRef: { type: 'string', description: 'Optional KB page path to overwrite in place (recovery path); omit for new save' },
150
+ },
24
151
  output: { schema: { type: 'json' }, render: (_a, v) => [{ type: 'text', text: JSON.stringify(v) }] },
25
152
  async execute(args) {
26
153
  const caller = deps.getCaller();
@@ -98,6 +225,94 @@ export function buildPlanningTools(deps) {
98
225
  return { ok: true, manifest };
99
226
  },
100
227
  }),
228
+ defineTool({
229
+ name: 'planning_prd_collect',
230
+ description: 'Dispatch a collection sub-agent to fetch ONE PRD link (login-state browser / doc skills). One call per link; for multiple links call once per link (parallel allowed). On success writes PRD source slices to KB source-docs namespace with screenshots base64-inlined (≤5MB each; screenshot oversized/embed-failure → degraded mark, never blocks collection; KB unreachable → blocked with re-collect guidance). Returns {ok, url, status, summary, pages, blockedReason?, degraded?, guidance?}. blocked = STOP and relay the returned guidance to the user by category (登录态/缺技能/404); never guess content, never skip, resolve then re-collect.',
231
+ parameters: {
232
+ url: { type: 'string', required: true, description: 'PRD link (modao.cc / feishu.cn / doc.weixin.qq.com / TAPD / Jira / other)' },
233
+ platform: { type: 'string', description: 'Optional platform hint when the domain is not recognized (feishu|wecom|modao|other); omit to auto-route by domain' },
234
+ },
235
+ output: { schema: { type: 'json' }, render: (_a, v) => [{ type: 'text', text: JSON.stringify(v) }] },
236
+ async execute(args, exec) {
237
+ const caller = deps.getCaller();
238
+ if (caller.actor !== 'human')
239
+ throw new Error('permission denied: planning_prd_collect');
240
+ if (typeof args.url !== 'string' || !args.url.trim())
241
+ throw new Error('url required');
242
+ const platform = args.platform?.trim() || routePrdPlatform(args.url) || null;
243
+ const prompt = [
244
+ '# PRD 采集(planning_prd_collect)',
245
+ `链接: ${args.url}`,
246
+ `平台: ${platform ?? '未知(自行判定)'}`,
247
+ '规则:带登录态抓全文(正文+截图);只写临时目录,禁止写任何仓库文件;抓不到就如实报 blocked 与原因(登录态|缺技能|404|其他),禁止猜测内容。',
248
+ '输出:仅一个 JSON 对象(无前后缀):{"status":"collected","summary":"<采了什么+关键信息+缺口>","markdown":"<全文>","screenshots":[{"name":"x.png","base64":"<...>"}]} 或 {"status":"blocked","blockedReason":"登录态|缺技能|404|其他","summary":"<失败现场>"}',
249
+ ].join('\n');
250
+ // 官方子代理缝要求 parent(血缘/模型继承)+ signal(取消通道),由 ToolRunContext 透传;
251
+ // 缝未注入 = 集成缺口,硬失败而非静默跳过
252
+ if (!deps.spawnPrdCollect)
253
+ throw new Error('planning_prd_collect: spawnPrdCollect not wired — main-session-tools 必须注入采集子代理缝');
254
+ // 无规划会话工作区(/plan: 未捕获或插件重启丢失)= fail-loud:
255
+ // 静默落 projects/unknown-repo/ 或回退 process.cwd() 会造成页面归组错位,必须先恢复工作区
256
+ const wsDir = deps.resolveWorkspaceDir?.() ?? null;
257
+ if (!wsDir) {
258
+ throw new Error(`[workspace-missing] planning_prd_collect 需要活跃规划会话的工作区(当前未捕获 workspaceDir)。请先执行 ${deps.prefixRoutes.plan} 捕获工作区后再采集(不可跳过)。`);
259
+ }
260
+ const output = await deps.spawnPrdCollect(prompt, wsDir, exec?.agent, exec?.signal);
261
+ const parsed = parsePrdCollectOutput(output);
262
+ if (parsed.status === 'blocked') {
263
+ const reason = parsed.blockedReason ?? '其他';
264
+ return {
265
+ ok: true, url: args.url, status: 'blocked', blockedReason: reason, summary: parsed.summary ?? '',
266
+ guidance: PRD_BLOCKED_GUIDANCE[reason] ?? `采集失败(${reason})。请用户排除原因后重新采集;不要猜测内容或跳过。`,
267
+ };
268
+ }
269
+ // collected:切片 + 截图内嵌 + 写 KB(source-docs 命名空间)
270
+ const markdown = parsed.markdown ?? '';
271
+ const parts = slicePrdMarkdown(markdown);
272
+ const slug = buildChecklistSlug(args.url.replace(/^https?:\/\//, '').slice(0, 40));
273
+ let degraded = false;
274
+ const images = [];
275
+ for (const s of parsed.screenshots ?? []) {
276
+ const img = embedScreenshot(s.name, s.base64);
277
+ if (img === null) {
278
+ degraded = true;
279
+ images.push(`> degraded: 截图 ${s.name} 超 5MB 或无法内嵌,已降级为文字描述(内容见摘要)`);
280
+ }
281
+ else {
282
+ images.push(img);
283
+ }
284
+ }
285
+ const pages = [];
286
+ for (let i = 0; i < parts.length; i++) {
287
+ const pagePath = local
288
+ ? `${LOCAL_SOURCE_DOCS_PREFIX}${slug}-part-${String(i + 1).padStart(2, '0')}.md`
289
+ : `${pagePrefix}${buildRepoSlug(wsDir)}/source-docs/${slug}-part-${String(i + 1).padStart(2, '0')}.md`;
290
+ const body = [
291
+ `# PRD 原文切片 ${i + 1}/${parts.length}`,
292
+ `- 来源链接: ${args.url}`,
293
+ `- 平台: ${platform ?? '未知'}`,
294
+ `- 采集摘要: ${parsed.summary ?? ''}`,
295
+ '',
296
+ parts[i],
297
+ ...(i === parts.length - 1 && images.length > 0 ? ['', '## 截图', '', ...images] : []),
298
+ ].join('\n');
299
+ try {
300
+ await deps.wiki.write(pagePath, body);
301
+ pages.push(pagePath);
302
+ }
303
+ catch (err) {
304
+ if (!isWikiError(err))
305
+ throw err;
306
+ // KB 不可达:截图/原文无处落 → 整条采集降级为 blocked 引导(不产生半截状态)
307
+ return {
308
+ ok: true, url: args.url, status: 'blocked', blockedReason: '其他', summary: 'KB 不可达,采集产物无法落库',
309
+ guidance: '知识库不可达,PRD 采集产物无法落库。请确认 wiki-vault 服务可用后重新采集。',
310
+ };
311
+ }
312
+ }
313
+ return { ok: true, url: args.url, status: 'collected', summary: parsed.summary ?? '', pages, degraded };
314
+ },
315
+ }),
101
316
  defineTool({
102
317
  name: 'planning_learning_save',
103
318
  description: 'Save a distilled learning (experience) to the knowledge base. Remote KB: scope=chain → projects/<repoSlug>/<chainId>/learnings/ (requirement-level); scope=project → projects/<repoSlug>/learnings/ (repo-level). repoSlug is derived from the chain workspaceDir; both scopes require chain.workspaceDir. Local KB: both scopes → wiki/synthesis/learnings/<chainId|repoSlug>/. Returns ref. Soft-fails {ok:false,reason:"kb-unreachable"} when KB is unreachable (no temp fallback).',
@@ -224,3 +439,33 @@ function parseManifestOutput(output) {
224
439
  throw new Error('planning_prefetch: invalid manifest from sub-agent: ' + errors.join('; '));
225
440
  return raw;
226
441
  }
442
+ const SCREENSHOT_MAX_BYTES = 5 * 1024 * 1024; // 单图 base64 ≤5MB 内嵌;超限记 degraded
443
+ function parsePrdCollectOutput(output) {
444
+ const text = output.trim();
445
+ const jsonText = text.startsWith('{') ? text : text.slice(text.indexOf('{'), text.lastIndexOf('}') + 1);
446
+ let raw;
447
+ try {
448
+ raw = JSON.parse(jsonText);
449
+ }
450
+ catch {
451
+ throw new Error('planning_prd_collect: sub-agent did not return valid JSON');
452
+ }
453
+ const o = raw;
454
+ if (o.status !== 'collected' && o.status !== 'blocked')
455
+ throw new Error('planning_prd_collect: sub-agent status must be collected|blocked');
456
+ return o;
457
+ }
458
+ /** blocked 分类引导(按 blockedReason 精确匹配;未匹配走调用方兜底文案)。 */
459
+ const PRD_BLOCKED_GUIDANCE = {
460
+ '登录态': '采集被登录态拦截。请用户自行在浏览器登录对应平台后,重新调用本工具采集;不要猜测页面内容。',
461
+ '缺技能': '当前环境缺少可采集该链接的技能。请用户安装对应技能(带登录态浏览器:huashu-chrome;CDP 兜底:web-access;企微文档:wecom-docs;飞书:lark)后重新采集。',
462
+ '404': '链接无效或已失效(404)。请用户提供有效的 PRD 链接后重新采集。',
463
+ };
464
+ /** 截图内嵌:≤5MB 转 markdown data URI;超限返回 null(调用方记 degraded)。 */
465
+ function embedScreenshot(name, base64) {
466
+ if (Buffer.byteLength(base64, 'utf8') > SCREENSHOT_MAX_BYTES)
467
+ return null;
468
+ const ext = (name.split('.').pop() ?? 'png').toLowerCase();
469
+ const mime = ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' : ext === 'gif' ? 'image/gif' : ext === 'webp' ? 'image/webp' : 'image/png';
470
+ return `![${name}](data:${mime};base64,${base64})`;
471
+ }
@@ -6,9 +6,13 @@ function guard(action, caller) {
6
6
  if (!can(action, caller.actor, null))
7
7
  throw new Error('permission denied: ' + action);
8
8
  }
9
- /** 逐字段校验 spec card sections:数组进 string 段(如 testing)会在下游 .trim() 崩溃。 */
9
+ /** 逐字段校验 spec card sections:数组进 string 段(如 testing)会在下游 .trim() 崩溃;
10
+ * 非对象(双重编码字符串)先报真因,不产生六字段误导性 undefined 墙。 */
10
11
  function validateSections(sections) {
11
12
  const errors = [];
13
+ if (sections !== undefined && (typeof sections !== 'object' || Array.isArray(sections))) {
14
+ return [`sections must be a JSON object (got ${Array.isArray(sections) ? 'array' : typeof sections}) — pass the object itself, never a JSON-encoded string`];
15
+ }
12
16
  const s = (sections ?? {});
13
17
  const strFields = [
14
18
  ['problem', 'problem'], ['solution', 'solution'], ['testing', 'testing'], ['out_of_scope', 'out_of_scope'],
@@ -47,7 +51,9 @@ export function buildSpecCardTools(service, getCaller) {
47
51
  description: 'Edit a draft spec card sections (human only).',
48
52
  parameters: {
49
53
  cardId: { type: 'string', required: true },
50
- sections: { type: 'json', required: true, description: 'Six-section spec card body' },
54
+ // 官方 object schema(2026-09-21):{type:'json'} 不约束,双重编码字符串穿透 validateSections
55
+ // 产出六字段误导性 undefined 墙;open object 运行时拦字符串。
56
+ sections: { type: 'object', required: true, additionalProperties: true, description: 'Six-section spec card body (object with problem/solution/testing/out_of_scope strings + user_stories/impl_decisions string arrays). Pass the object itself — never a JSON-encoded string.' },
51
57
  },
52
58
  output: { schema: { type: 'json' }, render: (_a, v) => [{ type: 'text', text: JSON.stringify(v) }] },
53
59
  async execute(args) {
@@ -44,7 +44,7 @@ export function buildWikiTools(wiki, getCaller) {
44
44
  async execute(args) {
45
45
  const caller = getCaller();
46
46
  guard('wiki-write', caller);
47
- // 工具边界强校验——只允许 projects/<repoSlug>/ 白名单命名空间(KB_PAGE_NAMESPACES_HINT 五类,
47
+ // 工具边界强校验——只允许 projects/<repoSlug>/ 白名单命名空间(KB_PAGE_NAMESPACES_HINT 六类,
48
48
  // 见 page-path.ts),杜绝 LLM 自造路径/拼错层级导致 kb_url 无法跳转。
49
49
  assertAllowedWikiPagePath(args.pagePath);
50
50
  const out = await wiki.write(args.pagePath, args.content);