@joekytc/dsh-swarm 0.3.9 → 0.3.11

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.
@@ -405,8 +405,10 @@ export async function installRoleTools(agentCtx, role, deps) {
405
405
  registry.register(tool);
406
406
  }
407
407
  }
408
- // ocr_review:ocr CLI 三子命令(preview/rule/managed);未安装/托管未配置在 execute 内降级引导,注册无条件
409
- registry.register(buildOcrReviewTool({ cwd: () => process.cwd() }));
408
+ // ocr_review:ocr CLI 三子命令(preview/rule/managed);未安装/托管未配置在 execute 内降级引导,注册无条件。
409
+ // 目标仓库由 execute 内解析(repo 参数 → exec.agent.session.header.cwd)——不注入 cwd,
410
+ // 避免回退到插件进程目录(非仓库,ocr 必报 not a git repository)。
411
+ registry.register(buildOcrReviewTool({}));
410
412
  }
411
413
  else if (role === 'v') {
412
414
  for (const tool of buildSpecCardTools(deps.kanban, caller)) {
@@ -595,7 +597,7 @@ function dualVerbGitDenyReason(verb, rest, deny) {
595
597
  /** wiki_write 会话级放行 preset 默认白名单:与 config.ts wikiWritePresets 默认值同源,
596
598
  * 仅供 guard deps 未接线(测试/裸调用)时兜底;生产经 dispatcher 注入热读配置。
597
599
  * 'ptc':普通(PTC 模式)会话亦可写 wiki 命名空间——写面本身由 wiki_write 工具内的
598
- * 五类命名空间白名单硬约束;2026-09-17 起 preset 判定走统一读面(session-preset.ts),
600
+ * 六类命名空间白名单硬约束;2026-09-17 起 preset 判定走统一读面(session-preset.ts),
599
601
  * 'ptc' 不再是「蜂群会话被误读成 ptc」的兜底,而是显式放行普通会话。 */
600
602
  export const DEFAULT_WIKI_WRITE_PRESETS = ['swarm', 'kanban-w', 'ptc'];
601
603
  export function buildStandaloneDtGuard(deps = {}) {
@@ -665,7 +667,7 @@ export function buildStandaloneDtGuard(deps = {}) {
665
667
  if (isRoleComposed(execution?.agent) || isDtChainSubagent)
666
668
  return undefined;
667
669
  // 放宽(2026-09-16):swarm 主会话与 W preset 会话可写 wiki——can('wiki-write') 本就含 w
668
- // (permissions.ts),写面由 wiki_write 工具内 assertAllowedWikiPagePath 五类命名空间
670
+ // (permissions.ts),写面由 wiki_write 工具内 assertAllowedWikiPagePath 六类命名空间
669
671
  // 白名单硬约束;local 模式 wiki_write 未全局注册,无工具面即无暴露面。
670
672
  // 放行面配置化(硬规则 6):白名单经 deps 热读 config.wikiWritePresets,宿主 preset
671
673
  // 体系演化/部署自定义模式 id 时改配置即可;config 缺字段回 [](fail-closed,仅内置默认兜底测试)。
@@ -694,7 +696,7 @@ export function registerStandaloneReviewerTools(ctx, configProvider) {
694
696
  const registry = ctx.get('tools');
695
697
  if (!registry)
696
698
  return; // 测试裸 Context 无 tools 服务,跳过注册
697
- registry.register(buildOcrReviewTool({ cwd: () => process.cwd() }));
699
+ registry.register(buildOcrReviewTool({})); // 目标仓库同链上路径:repo 参数 → 会话工作目录(见 ocr-review-tools)
698
700
  const kbMode = configProvider.mode;
699
701
  const wiki = (kbMode === 'local'
700
702
  ? new LocalWikiClient(ensureLocalKbRoot())
@@ -3,7 +3,8 @@ import type { SpecCard, SpecCardAttachment } from '../domain/types.js';
3
3
  import { type PrefixRoutes } from '../config.js';
4
4
  /** 阶段 0 规划引导:命令串从 config 派生,/openspec: 改名时文案自动跟随。
5
5
  * 提问节奏与 skills/grill-me/SKILL.md 对齐:官方 frontier 分轮制(整批问 + 推荐答案 + 答复后解锁下一轮);
6
- * 增强项:人话硬规则 + 过程风险登记;收敛产物仍为 planning_checklist_save(KB 清单是建链数据源)。 */
6
+ * 需求澄清十规则:来源必挂 + PRD 采集 + 范围边界 + 人话 + 仓库事实/缺口暂停 + 收敛录入铁律(含决策可追溯);
7
+ * 收敛产物仍为 planning_checklist_save(KB 清单是建链数据源)。 */
7
8
  export declare function buildPlanningGuidance(routes: PrefixRoutes, opts?: {
8
9
  swarm?: boolean;
9
10
  }): string;
@@ -3,19 +3,22 @@ import { parsePrefix } from './prefix-router.js';
3
3
  import { DEFAULT_PREFIX_ROUTES } from '../config.js';
4
4
  /** 阶段 0 规划引导:命令串从 config 派生,/openspec: 改名时文案自动跟随。
5
5
  * 提问节奏与 skills/grill-me/SKILL.md 对齐:官方 frontier 分轮制(整批问 + 推荐答案 + 答复后解锁下一轮);
6
- * 增强项:人话硬规则 + 过程风险登记;收敛产物仍为 planning_checklist_save(KB 清单是建链数据源)。 */
6
+ * 需求澄清十规则:来源必挂 + PRD 采集 + 范围边界 + 人话 + 仓库事实/缺口暂停 + 收敛录入铁律(含决策可追溯);
7
+ * 收敛产物仍为 planning_checklist_save(KB 清单是建链数据源)。 */
7
8
  export function buildPlanningGuidance(routes, opts) {
8
- // 第 6 条按形态分叉:前缀 = 教学旧命令收尾(逐字节不变);swarm = 确认闸语义(不提旧命令,与 KANBAN_HANDOFF_RULE swarm 确认闸对齐)。
9
+ // 第 8 条按形态分叉:前缀 = 教学旧命令收尾(收尾句不变);swarm = 确认闸语义(不提旧命令,与 KANBAN_HANDOFF_RULE swarm 确认闸对齐)。
9
10
  const finish = opts?.swarm
10
- ? `6. 收尾(确认闸):清单落库后向用户征求确认;仅当用户回复含明确肯定语义(确认/开干/开跑/开始/go 等)才调 kanban_route{intent:'openspec'} 建链开跑;模糊回复视为未确认,继续澄清。`
11
- : `6. 收尾:提醒用户以 ${routes.openspec} 确认执行结束规划阶段——${routes.openspec} 会从清单建链并自动串行执行。`;
11
+ ? `8. 收尾(确认闸):清单落库后向用户征求确认;仅当用户回复含明确肯定语义(确认/开干/开跑/开始/go 等)才调 kanban_route{intent:'openspec'} 建链开跑;模糊回复视为未确认,继续澄清。`
12
+ : `8. 收尾:提醒用户以 ${routes.openspec} 确认执行结束规划阶段——${routes.openspec} 会从清单建链并自动串行执行。`;
12
13
  return `
13
- # 阶段 0 规划对话(v4:grill-me frontier 分轮,与 skills/grill-me 对齐)
14
- 1. 需求澄清(grill-me,frontier 分轮):把计划映射为设计决策树——每轮只问当前前提已就绪的问题(frontier),整批问出:编号 + 每问附推荐答案;等用户全部答复后重算 frontier(已定决定向外推进、解锁依赖它的问题)再发下一轮,本轮答不了的依赖问题留到后续轮。先澄清目的、约束、成功标准,逐支拷问直至用户"没有任何疑问"。
15
- 2. 人话硬规则:问题正文与选项必须通俗;术语避不开时首次出现括号一句话解释;发问前自检「不懂本仓库的人能直接回答吗」,不能就重写。
16
- 3. 仓库事实(planning_prefetch):事实自查是规划者的职责——调只读子代理采集目标仓库/资料/知识库事实(本地路径/分支/目标文件基线/既有实现),不凭空假设、不拿可自查的问题问用户;只把真正的决定留给用户。
17
- 4. 风险登记:每轮答复中若发现真实风险或非阻塞关注点,随手记一行(描述 + 来源决定 + 缓解思路),收敛时并入澄清清单;没有就不写,禁止硬凑。
18
- 5. 收敛(planning_checklist_save):把结论写成结构化需求澄清清单(spec 六段 + manifest repo.files + 澄清问答 + 疑问点 + 风险点(若有))存入 KB(KB 不可达自动兜底临时目录)。
14
+ # 阶段 0 规划对话(v5:grill-me frontier 分轮 + 需求澄清十规则)
15
+ 1. 需求来源必挂:起手记录需求来源(TAPD/Jira/其他链接);确无来源也要显式记录「无来源+原因」,不许空。同时判定是否绿地(目标目录不存在或尚无 .git):是 → 清单声明 greenfield:true(D 阶段将 git init 建仓),否则不声明。
16
+ 2. PRD 链接必采集:用户给了 PRD/需求文档链接,必须先调 planning_prd_collect 逐条采集(一条链接一次调用,多条可并行),采集完成前不得进入 grill-me 提问。采集失败(blocked)立即停下,把工具返回的 guidance 按类转告用户:登录态→请用户自行登录后重采;缺技能→引导安装(带登录态浏览器 huashu-chrome / CDP 兜底 web-access / 企微 wecom-docs / 飞书 lark);404→请用户提供有效链接。用户解决后继续未完成的采集。禁止盲猜页面内容、禁止跳过、禁止自行探索浪费时间。
17
+ 3. 范围边界:起手明确「范围内/范围外」清单,尤其需求文档中标注多但用户未点名的部分,逐条确认纳入与否。
18
+ 4. 需求澄清(grill-me,frontier 分轮):把计划映射为设计决策树——每轮只问当前前提已就绪的问题(frontier),整批问出:编号 + 每问附推荐答案;等用户全部答复后重算 frontier 再发下一轮。先澄清目的、约束、成功标准,逐支拷问直至用户"没有任何疑问"。
19
+ 5. 人话硬规则:问题正文与选项必须通俗;术语避不开时首次出现括号一句话解释;发问前自检「不懂本仓库的人能直接回答吗」,不能就重写。
20
+ 6. 仓库事实(planning_prefetch)与缺口暂停:事实自查是规划者的职责——涉筛选字段/列表字段/取数 hook/组件选型,先调只读子代理查证仓库(存在/可复用/复用+转化),推荐答案言出有据,不拿可自查的问题问用户;澄清中发现信息缺口 → 停止提问 → 先调 planning_prefetch 补齐 → 再继续,不边猜边问。每功能点先查仓库同类实现,可复用优先复用。
21
+ 7. 收敛(planning_checklist_save):把结论写成结构化需求澄清清单存入 KB(KB 不可达自动兜底临时目录)。录入铁律:每条问答含完整决策正文(字段名/接口路径/枚举值/组件名/位置规则),禁止「按推荐」「同上」等指代不明缩写(代码硬闸会拒收);sources(需求来源)/ prdCollection(每条 PRD 链接的采集状态)必填;后端未就绪项统一登记 placeholders(占位对象/占位值/替换方式),集中声明避免逐点重复讨论。每条决策可追溯(问答编号/仓库源码路径/接口文档位置至少其一);原始文档值与确认决策冲突处必须标【已调整】/【已确认】,未确认值不录或显式标「待确认」——无来源记录的决策会被视为 agent 自行默认。
19
22
  ${finish}
20
23
  护栏:规划期只读仓库,禁止任何 git/源码写入;只写 KB 与临时目录。
21
24
  `;
@@ -23,6 +23,11 @@ export declare function runOcr(args: string[], opts: {
23
23
  stderr: string;
24
24
  error?: string;
25
25
  }>;
26
+ /** 托管就绪探测:provider 为 dsh-managed 且模型非空。
27
+ * 踩坑:`ocr config set model <m>` 的落盘位置随当前 provider 类型分流——内置 provider 写顶层
28
+ * `model`,自定义 provider(本插件的 dsh-managed)写 `custom_providers.dsh-managed.model`
29
+ * (沙箱 HOME 实测)。只读顶层 model 会把已接好的托管配置判成未就绪,工具层随即返回
30
+ * 「托管模式未配置 LLM」而永不真正调用 `ocr review`(两处都算就绪)。 */
26
31
  export declare function managedProviderReady(deps?: OcrCliDeps): boolean;
27
32
  export declare function wireManagedProvider(a: {
28
33
  baseUrl: string;
@@ -99,11 +99,20 @@ export async function runOcr(args, opts, deps = {}) {
99
99
  };
100
100
  }
101
101
  }
102
+ /** 托管就绪探测:provider 为 dsh-managed 且模型非空。
103
+ * 踩坑:`ocr config set model <m>` 的落盘位置随当前 provider 类型分流——内置 provider 写顶层
104
+ * `model`,自定义 provider(本插件的 dsh-managed)写 `custom_providers.dsh-managed.model`
105
+ * (沙箱 HOME 实测)。只读顶层 model 会把已接好的托管配置判成未就绪,工具层随即返回
106
+ * 「托管模式未配置 LLM」而永不真正调用 `ocr review`(两处都算就绪)。 */
102
107
  export function managedProviderReady(deps = {}) {
103
108
  try {
104
109
  const home = deps.homedirFn ? deps.homedirFn() : homedir();
105
110
  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;
111
+ if (cfg.provider !== OCR_MANAGED_PROVIDER_NAME)
112
+ return false;
113
+ const top = typeof cfg.model === 'string' ? cfg.model : '';
114
+ const nested = cfg.custom_providers?.[OCR_MANAGED_PROVIDER_NAME]?.model;
115
+ return (top || (typeof nested === 'string' ? nested : '')).length > 0;
107
116
  }
108
117
  catch {
109
118
  return false; // 文件不存在/解析失败/字段缺失一律视为未就绪
@@ -33,6 +33,7 @@ export declare function buildOpenspecNarrationRule(r: {
33
33
  };
34
34
  }): string;
35
35
  export declare function buildSpawnPrefetch(ctx: Context): PlanningToolDeps['spawnPrefetch'] | undefined;
36
+ export declare function buildSpawnPrdCollect(ctx: Context, listSkills?: () => string[]): PlanningToolDeps['spawnPrdCollect'] | undefined;
36
37
  /** v2 主会话工具面:/plan: 捕获规划上下文(零副作用)→ planning_checklist_save 回写 → /openspec: 建链。
37
38
  * 工具面 = kanban_route + 只读 kanban 子集 + spec_card_view + planning 工具;
38
39
  * 无 spec_card_edit/approve、无 kanban_create/complete/block(主会话越权写由工具面裁剪 + prefetch 子代理只读护栏双保险)。 */
@@ -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';
@@ -20,9 +22,9 @@ import { LOCAL_CHECKLIST_PREFIX } from '../wiki/page-path.js';
20
22
  export const planningBySession = new Map();
21
23
  export const KANBAN_HANDOFF_RULE = (routes, opts) => {
22
24
  const confirmLine = opts?.swarm
23
- ? `- 澄清期:调 planning_prefetch(只读子代理)采集仓库事实 → 逐问用户收敛 → 调 planning_checklist_save 存需求澄清清单。
24
- - 确认闸:清单落库后向用户征求确认;仅当用户回复含明确肯定语义(确认/开干/开跑/开始/go 等)才调 kanban_route{intent:'openspec'} 建链;模糊、岔开话题、只提修改意见 = 未确认,继续澄清。禁止未确认建链。`
25
- : `- 澄清期:调 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} 确认。`;
26
28
  const nextLine = opts?.swarm
27
29
  ? `- 用户确认后你调 kanban_route{intent:'openspec'};链路进入 executing,V 自动串行建卡 p→(pt)→w2→d→dt→w3;你不要自己执行。`
28
30
  : `- ${routes.openspec} 后链路进入 executing,V 自动串行建卡 p→(pt)→w2→d→dt→w3;你不要自己执行。`;
@@ -63,7 +65,7 @@ ${candidates.map((c) => '- ' + c).join('\n')}
63
65
  恢复步骤(严格顺序):
64
66
  1. 读取候选页内容,对照当前需求判定哪一页是本次需求的需求澄清清单(页首行标题为「# 【需求】<需求名>」);
65
67
  2. 若页尾含 <!-- dsh-swarm:checklist-json ... --> 机读段:不要手动重建、不要调 planning_checklist_save——直接提示用户重新发送 ${routes.openspec}(路由会自动机读恢复并当场建链,LLM 零参与);
66
- 3. 仅 legacy 页(无机读段)才执行本步:消化该页内容,重建结构化 PlanningChecklist(spec 六段 + manifest + clarifications + doubts + risks(风险点,若有),requirementName 取页标题中【需求】后的名称;恢复重建时 clarifications 允许登记单条无澄清说明,如 {"q": "本轮无澄清(恢复重建)", "a": "<来源页或原因>"},其余场景 clarifications 禁止为空);
68
+ 3. 仅 legacy 页(无机读段)才执行本步:消化该页内容,重建结构化 PlanningChecklist(spec 六段 + manifest + clarifications + doubts + risks(风险点,若有),requirementName 取页标题中【需求】后的名称;恢复重建时 clarifications 允许登记单条无澄清说明,如 {"q": "本轮无澄清(恢复重建)", "a": "<来源页或原因>"},其余场景 clarifications 禁止为空);重建时旧页若无来源信息,sources 录 [{type:'其他', url:'', note:'恢复重建,原页无来源'}];无 PRD 采集记录则 prdCollection 录 [];
67
69
  4. legacy 页重建后调 planning_checklist_save(checklist, restoreRef=<该候选页路径>) 回存(覆盖原页,勿产生重复页),成功后提示用户重新发送 ${routes.openspec} 确认。
68
70
  禁止:跳过恢复直接建链建卡;猜测清单内容;把恢复失败归因于"重试/进程检查"之外的任何原因。
69
71
  `;
@@ -71,12 +73,16 @@ const RECOVERY_NONE_GUIDANCE = (routes) => `
71
73
  两条获取路由均无本需求的需求澄清清单(内存为空,知识库亦无匹配页)。
72
74
  处理步骤(严格顺序):
73
75
  1. 消化当前对话上下文,判断需求澄清(grill-me 逐问收敛 + planning_prefetch 仓库事实)是否已完成但漏了保存动作;
74
- 2. 若已完成澄清——立即调 planning_checklist_save 保存清单;若尚未完成——先完成澄清(缺仓库事实则先 planning_prefetch),再保存;
76
+ 2. 若已完成澄清——立即调 planning_checklist_save 保存清单;若尚未完成——先完成澄清(缺仓库事实则先 planning_prefetch),再保存;重建时旧页若无来源信息,sources 录 [{type:'其他', url:'', note:'恢复重建,原页无来源'}];无 PRD 采集记录则 prdCollection 录 [];
75
77
  3. 保存成功后提示用户重新发送 ${routes.openspec} 确认。
76
78
  禁止:在清单落库前建链建卡;编造"先重试 / 查服务进程是否重启"之类与清单无关的诊断。
77
79
  `;
78
80
  /** 预取子代理禁用的写能力工具(官方全局工具名;deny = 从 prompt 消失 + 拒绝执行,"one visibility")。 */
79
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'];
80
86
  export function buildSpawnPrefetch(ctx) {
81
87
  const subagents = ctx.get('subagents');
82
88
  if (!subagents?.start)
@@ -120,6 +126,58 @@ export function buildSpawnPrefetch(ctx) {
120
126
  }
121
127
  };
122
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
+ }
123
181
  /** 自由投递 guidance:三步教学(实查基准 → 生成正文 → sms_send 投递)+ 红线分界。 */
124
182
  function buildFreeSendGuidance(routes, query, dm) {
125
183
  return [
@@ -166,11 +224,12 @@ export function registerMainSessionTools(ctx, configProvider) {
166
224
  if (tool.name === 'spec_card_view')
167
225
  registry.register(tool);
168
226
  }
169
- // planning 工具(清单落库 + 只读预取)——spawnPrefetch 由模块级 buildSpawnPrefetch 提供(可单测)
227
+ // planning 工具(清单落库 + 只读预取 + PRD 采集)——spawnPrefetch/spawnPrdCollect 由模块级 builder 提供(可单测)
170
228
  for (const tool of buildPlanningTools({
171
229
  service, wiki: wiki, // local 模式为 LocalWikiClient(write/read/search 同面,双模式客户端)
172
230
  getCaller: caller,
173
231
  spawnPrefetch: buildSpawnPrefetch(ctx),
232
+ spawnPrdCollect: buildSpawnPrdCollect(ctx),
174
233
  tempDir: () => `${tmpdir()}/dsh-swarm-checklists`, // KB 不可达时的临时兜底,放系统临时目录(不落插件源码/核心存储目录)
175
234
  pagePrefix: configProvider.getEffective().wikiVault?.pagePrefix ?? 'projects/', // 生成的清单页路径保持在该客户端配置的命名空间内(避免 kb-rejected)
176
235
  kbMode: configProvider.mode, // 双模式:local 时 checklist/learning 落本地库命名空间(wiki/queries/checklists/、wiki/synthesis/learnings/)
@@ -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 优先/临时目录兜底)+ 只读仓库预取(子代理)。 */
@@ -103,6 +103,47 @@ export function buildPlanningTools(deps) {
103
103
  },
104
104
  },
105
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' },
106
147
  },
107
148
  },
108
149
  restoreRef: { type: 'string', description: 'Optional KB page path to overwrite in place (recovery path); omit for new save' },
@@ -184,6 +225,94 @@ export function buildPlanningTools(deps) {
184
225
  return { ok: true, manifest };
185
226
  },
186
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
+ }),
187
316
  defineTool({
188
317
  name: 'planning_learning_save',
189
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).',
@@ -310,3 +439,33 @@ function parseManifestOutput(output) {
310
439
  throw new Error('planning_prefetch: invalid manifest from sub-agent: ' + errors.join('; '));
311
440
  return raw;
312
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
+ }
@@ -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);
@@ -1,5 +1,5 @@
1
1
  /** 命名空间提示(报错/工具 description 文案单一来源,禁止散落硬编码)。 */
2
- export declare const KB_PAGE_NAMESPACES_HINT = "projects/<repoSlug>/checklists/\u3001projects/<repoSlug>/learnings/\u3001projects/<repoSlug>/ch_*/learnings/\u3001projects/<repoSlug>/ch_*/t_*.md\u3001projects/<repoSlug>/ch_*/review/\u3001projects/<repoSlug>/reviews/<topic>-<ymd>/";
2
+ export declare const KB_PAGE_NAMESPACES_HINT = "projects/<repoSlug>/checklists/\u3001projects/<repoSlug>/source-docs/\u3001projects/<repoSlug>/learnings/\u3001projects/<repoSlug>/ch_*/learnings/\u3001projects/<repoSlug>/ch_*/t_*.md\u3001projects/<repoSlug>/ch_*/review/\u3001projects/<repoSlug>/reviews/<topic>-<ymd>/";
3
3
  /** 从需求名(回退 problem)派生 URL 安全 slug:ASCII 化、空格/特殊字符→-、限长 40;全非 ASCII(如中文)兜底 'req'。 */
4
4
  export declare function buildChecklistSlug(name: string): string;
5
5
  export declare function isAllowedWikiPagePath(pagePath: string): boolean;
@@ -8,6 +8,7 @@ export declare function isLearningsPath(pagePath: string): boolean;
8
8
  /** 工具边界硬校验:不符白名单直接抛 kb-rejected(wiki_write 用)。 */
9
9
  export declare function assertAllowedWikiPagePath(pagePath: string): void;
10
10
  export declare const LOCAL_CHECKLIST_PREFIX = "wiki/queries/checklists/";
11
+ export declare const LOCAL_SOURCE_DOCS_PREFIX = "wiki/queries/source-docs/";
11
12
  export declare const LOCAL_LEARNING_BASE = "wiki/synthesis/learnings/";
12
13
  /** 本地模式交付白名单:KB 库根下 wiki/** 相对路径(拒绝对象路径、.. 穿越、空串)。 */
13
14
  export declare function isLocalKbPagePath(pagePath: string): boolean;
@@ -2,6 +2,7 @@
2
2
  // KB 页面路径统一规则(单一来源):一级段 = repoSlug(buildRepoSlug(workspaceDir) 派生),
3
3
  // 知识库内按目标仓库分组定位。旧全局命名空间(projects/checklists|learnings|ch_* 直挂根)已断代,不兼容。
4
4
  // 需求清单 projects/<repoSlug>/checklists/<slug>-<ts>.md (planning_checklist_save,代码强制)
5
+ // PRD 原文切片 projects/<repoSlug>/source-docs/<slug>-part-NN.md (planning_prd_collect,代码强制)
5
6
  // 实施计划/结果 projects/<repoSlug>/ch_<id>/t_<id>.md (W2/W3 wiki_write,工具边界强制)
6
7
  // DT 评审页 projects/<repoSlug>/ch_<id>/review/<name>.md (DT wiki_write + 写护栏强制)
7
8
  // 独立评审报告 projects/<repoSlug>/reviews/<topic>-<ymd>/ (独立 DT 模式 wiki_write,形态由 domain/ocr-review 判定)
@@ -9,9 +10,9 @@
9
10
  import { WikiError } from './wiki-vault-client.js';
10
11
  import { isStandaloneReviewNamespacePath } from '../domain/ocr-review.js';
11
12
  /** 命名空间提示(报错/工具 description 文案单一来源,禁止散落硬编码)。 */
12
- export const KB_PAGE_NAMESPACES_HINT = 'projects/<repoSlug>/checklists/、projects/<repoSlug>/learnings/、projects/<repoSlug>/ch_*/learnings/、projects/<repoSlug>/ch_*/t_*.md、projects/<repoSlug>/ch_*/review/、projects/<repoSlug>/reviews/<topic>-<ymd>/';
13
- /** 白名单:projects/<repoSlug>/ 下五类命名空间(repoSlug=[a-z0-9-]+,杜绝 LLM 自造路径)。 */
14
- const KB_PAGE_PATH_RE = /^projects\/[a-z0-9-]+\/(?:checklists\/|learnings\/|ch_[0-9a-z_]+\/(?:t_[0-9a-z_]+\.md|review\/|learnings\/))/;
13
+ export const KB_PAGE_NAMESPACES_HINT = 'projects/<repoSlug>/checklists/、projects/<repoSlug>/source-docs/、projects/<repoSlug>/learnings/、projects/<repoSlug>/ch_*/learnings/、projects/<repoSlug>/ch_*/t_*.md、projects/<repoSlug>/ch_*/review/、projects/<repoSlug>/reviews/<topic>-<ymd>/';
14
+ /** 白名单:projects/<repoSlug>/ 下六类命名空间(repoSlug=[a-z0-9-]+,杜绝 LLM 自造路径)。 */
15
+ const KB_PAGE_PATH_RE = /^projects\/[a-z0-9-]+\/(?:checklists\/|learnings\/|source-docs\/|ch_[0-9a-z_]+\/(?:t_[0-9a-z_]+\.md|review\/|learnings\/))/;
15
16
  /** 从需求名(回退 problem)派生 URL 安全 slug:ASCII 化、空格/特殊字符→-、限长 40;全非 ASCII(如中文)兜底 'req'。 */
16
17
  export function buildChecklistSlug(name) {
17
18
  const slug = name
@@ -33,11 +34,12 @@ export function isLearningsPath(pagePath) {
33
34
  /** 工具边界硬校验:不符白名单直接抛 kb-rejected(wiki_write 用)。 */
34
35
  export function assertAllowedWikiPagePath(pagePath) {
35
36
  if (!isAllowedWikiPagePath(pagePath)) {
36
- throw new WikiError('kb-rejected', undefined, `page path outside allowed namespaces (${KB_PAGE_NAMESPACES_HINT}): ${pagePath}`);
37
+ throw new WikiError('kb-rejected', undefined, `kb-rejected: page path outside allowed namespaces (${KB_PAGE_NAMESPACES_HINT}): ${pagePath}`);
37
38
  }
38
39
  }
39
40
  // ── 本地 KB(llm-wiki)路径规则(双模式)──────────────────────────────
40
41
  export const LOCAL_CHECKLIST_PREFIX = 'wiki/queries/checklists/';
42
+ export const LOCAL_SOURCE_DOCS_PREFIX = 'wiki/queries/source-docs/';
41
43
  export const LOCAL_LEARNING_BASE = 'wiki/synthesis/learnings/';
42
44
  /** 本地模式交付白名单:KB 库根下 wiki/** 相对路径(拒绝对象路径、.. 穿越、空串)。 */
43
45
  export function isLocalKbPagePath(pagePath) {