@joekytc/dsh-swarm 0.1.0 → 0.1.2

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.
@@ -1,13 +1,17 @@
1
1
  import { KanbanService } from '../domain/kanban-service.js';
2
2
  import { parsePrefix } from './prefix-router.js';
3
- export const MATTPOCOCK_PLANNING_GUIDANCE = `
3
+ import { DEFAULT_PREFIX_ROUTES } from '../config.js';
4
+ /** 阶段 0 规划引导:命令串从 config 派生(决策12),/openspec: 改名时文案自动跟随。 */
5
+ export function buildPlanningGuidance(routes) {
6
+ return `
4
7
  # 阶段 0 规划对话(v2:需求澄清前置化)
5
8
  1. 需求澄清(grill-me):一次只问一个问题,先澄清目的、约束、成功标准;逐项拷问假设直至用户"没有任何疑问"。
6
9
  2. 仓库事实(planning_prefetch):调只读子代理采集目标仓库/资料/知识库事实(本地路径/分支/目标文件基线/既有实现),不凭空假设。
7
10
  3. 收敛(planning_checklist_save):把结论写成结构化需求澄清清单(spec 六段 + manifest repo.files + 澄清问答 + 疑问点)存入 KB(KB 不可达自动兜底临时目录)。
8
- 4. 收尾:提醒用户以 /openspec: 确认执行结束规划阶段——/openspec: 会从清单建链并自动串行执行。
11
+ 4. 收尾:提醒用户以 ${routes.openspec} 确认执行结束规划阶段——${routes.openspec} 会从清单建链并自动串行执行。
9
12
  护栏:规划期只读仓库,禁止任何 git/源码写入;只写 KB 与临时目录。
10
13
  `;
14
+ }
11
15
  export function validateSpecCardForApproval(card) {
12
16
  const missing = [];
13
17
  const s = card.sections;
@@ -25,10 +29,10 @@ export function validateSpecCardForApproval(card) {
25
29
  missing.push('attachments:file-prefetch');
26
30
  return missing;
27
31
  }
28
- export function buildPlanningContext(chainId, card, attachments) {
32
+ export function buildPlanningContext(chainId, card, attachments, routes = DEFAULT_PREFIX_ROUTES) {
29
33
  return [
30
34
  `# 规划上下文 chain=${chainId} specCard=${card.id}`,
31
- MATTPOCOCK_PLANNING_GUIDANCE,
35
+ buildPlanningGuidance(routes),
32
36
  `## 当前规格卡\n${JSON.stringify(card.sections, null, 2)}`,
33
37
  `## 仓库事实附件\n${attachments.map((a) => `${a.name}: ${a.ref}`).join('\n') || '(无)'}`,
34
38
  ].join('\n\n');
@@ -36,16 +40,16 @@ export function buildPlanningContext(chainId, card, attachments) {
36
40
  export async function approveIfReady(message, service, cfg, chainId, specCardId) {
37
41
  const parsed = parsePrefix(message, cfg);
38
42
  if (parsed.kind !== 'openspec')
39
- return { ok: false, missing: ['prefix'], guidance: MATTPOCOCK_PLANNING_GUIDANCE };
43
+ return { ok: false, missing: ['prefix'], guidance: buildPlanningGuidance(cfg) };
40
44
  const state = await service.snapshot();
41
45
  const card = state.specCards.get(specCardId);
42
46
  if (!card)
43
- return { ok: false, missing: ['spec-card'], guidance: MATTPOCOCK_PLANNING_GUIDANCE };
47
+ return { ok: false, missing: ['spec-card'], guidance: buildPlanningGuidance(cfg) };
44
48
  if (card.status === 'approved')
45
49
  return { ok: true, card };
46
50
  const missing = validateSpecCardForApproval(card);
47
51
  if (missing.length > 0) {
48
- return { ok: false, missing, guidance: MATTPOCOCK_PLANNING_GUIDANCE };
52
+ return { ok: false, missing, guidance: buildPlanningGuidance(cfg) };
49
53
  }
50
54
  const approved = await service.approveSpecCard(specCardId, 'human');
51
55
  return { ok: true, card: approved };
@@ -1,20 +1,18 @@
1
1
  import { KanbanService } from '../domain/kanban-service.js';
2
2
  import type { PlanningChecklist } from '../domain/planning-checklist.js';
3
+ import type { PrefixRoutes } from '../config.js';
3
4
  export interface PrefixRouteResult {
4
- kind: 'plan' | 'openspec' | 'none';
5
+ kind: 'plan' | 'openspec' | 'learning' | 'none';
5
6
  chainId?: string;
6
7
  specCardId?: string;
7
8
  rest: string;
9
+ brief?: string;
10
+ guidance?: string;
11
+ error?: string;
8
12
  }
9
- export declare function parsePrefix(message: string, cfg: {
10
- plan: string;
11
- openspec: string;
12
- }): PrefixRouteResult;
13
+ export declare function parsePrefix(message: string, cfg: PrefixRoutes): PrefixRouteResult;
13
14
  /** v2:/plan: 零副作用——不建链/规格卡/任务卡,仅返回路由结果(workspaceDir/sessionId 由 main-session-tools 捕获)。 */
14
- export declare function handlePlanRoute(message: string, _service: KanbanService, cfg: {
15
- plan: string;
16
- openspec: string;
17
- }, _ownerSessionId: string): Promise<PrefixRouteResult>;
15
+ export declare function handlePlanRoute(message: string, _service: KanbanService, cfg: PrefixRoutes, _ownerSessionId: string): Promise<PrefixRouteResult>;
18
16
  export interface OpenspecPlanningInput {
19
17
  workspaceDir: string | null;
20
18
  checklist: PlanningChecklist;
@@ -23,7 +21,8 @@ export interface OpenspecPlanningInput {
23
21
  requirementName?: string | null;
24
22
  }
25
23
  /** v2:/openspec: 建链——从清单机械映射规格卡六段 → 挂 file-prefetch(仓库 localPath)+kb(清单页) → 批准 → executing。 */
26
- export declare function handleOpenspecRoute(message: string, service: KanbanService, cfg: {
27
- plan: string;
28
- openspec: string;
29
- }, planning: OpenspecPlanningInput, ownerSessionId: string): Promise<PrefixRouteResult>;
24
+ export declare function handleOpenspecRoute(message: string, service: KanbanService, cfg: PrefixRoutes, planning: OpenspecPlanningInput, ownerSessionId: string): Promise<PrefixRouteResult>;
25
+ /** /learning 零副作用引导文案:命令串从 config 派生(决策12),歧义/未找到时注入主 agent。 */
26
+ export declare function buildLearningGuidance(routes: PrefixRoutes): string;
27
+ /** v2:/learning 零副作用——不建链建卡,仅机械提取证据包供主 agent 蒸馏。歧义返回候选列表,链不存在返回错误文本(不 throw)。 */
28
+ export declare function handleLearningRoute(message: string, service: KanbanService, cfg: PrefixRoutes, _ownerSessionId: string): Promise<PrefixRouteResult>;
@@ -1,10 +1,13 @@
1
1
  import { KanbanService, buildChainTitle } from '../domain/kanban-service.js';
2
+ import { buildLearningBrief, resolveLearningChainId } from '../domain/memory.js';
2
3
  export function parsePrefix(message, cfg) {
3
4
  const trimmed = message.trim();
4
5
  if (trimmed.startsWith(cfg.plan))
5
6
  return { kind: 'plan', rest: trimmed.slice(cfg.plan.length).trim() };
6
7
  if (trimmed.startsWith(cfg.openspec))
7
8
  return { kind: 'openspec', rest: trimmed.slice(cfg.openspec.length).trim() };
9
+ if (trimmed.startsWith(cfg.learning))
10
+ return { kind: 'learning', rest: trimmed.slice(cfg.learning.length).trim() };
8
11
  return { kind: 'none', rest: trimmed };
9
12
  }
10
13
  /** v2:/plan: 零副作用——不建链/规格卡/任务卡,仅返回路由结果(workspaceDir/sessionId 由 main-session-tools 捕获)。 */
@@ -26,3 +29,30 @@ export async function handleOpenspecRoute(message, service, cfg, planning, owner
26
29
  await service.approveSpecCard(card.id, 'human');
27
30
  return { kind: 'openspec', chainId: chain.id, specCardId: card.id, rest: parsed.rest };
28
31
  }
32
+ /** /learning 零副作用引导文案:命令串从 config 派生(决策12),歧义/未找到时注入主 agent。 */
33
+ export function buildLearningGuidance(routes) {
34
+ return [
35
+ '## 经验蒸馏指令(' + routes.learning + ')',
36
+ '消化上方「链上下文 + 机械信号证据包」,蒸馏 1-3 条可复用经验(返工根因 / 阻塞原因 / 审计教训)。',
37
+ '每条约成 LearningEntry(title 一句话≤80 字符;lesson 教训;evidence 必须填本链 chain id 作机械证据;tags 自由标签)。',
38
+ '调 planning_learning_save:scope=chain 存需求级 projects/<chainId>/learnings/;仓库通用经验用 scope=project(自动归入目标仓库项目级)。',
39
+ '无值得沉淀的经验时,明确回复「无新经验」,不要硬凑。',
40
+ ].join('\n');
41
+ }
42
+ /** v2:/learning 零副作用——不建链建卡,仅机械提取证据包供主 agent 蒸馏。歧义返回候选列表,链不存在返回错误文本(不 throw)。 */
43
+ export async function handleLearningRoute(message, service, cfg, _ownerSessionId) {
44
+ const parsed = parsePrefix(message, cfg);
45
+ if (parsed.kind !== 'learning')
46
+ return parsed;
47
+ const state = await service.snapshot();
48
+ const resolved = resolveLearningChainId(state, parsed.rest);
49
+ if (resolved === null) {
50
+ return { kind: 'learning', rest: parsed.rest, error: 'chain-not-found', guidance: `未找到可蒸馏经验的链。可用 ${cfg.learning} <chainId> 指定,或先经 ${cfg.plan}${cfg.openspec} 建立链路。` };
51
+ }
52
+ if ('candidates' in resolved) {
53
+ const list = resolved.candidates.map((c) => `- ${c.chainId} ${c.title}`).join('\n');
54
+ return { kind: 'learning', rest: parsed.rest, error: 'chain-ambiguous', guidance: `匹配到多条链,请用 ${cfg.learning} <chainId> 精确指定:\n${list}` };
55
+ }
56
+ const brief = buildLearningBrief(state, resolved.chainId);
57
+ return { kind: 'learning', chainId: resolved.chainId, rest: parsed.rest, brief, guidance: buildLearningGuidance(cfg) };
58
+ }
@@ -4,23 +4,25 @@ import { KanbanProvider } from '../services/kanban-provider.js';
4
4
  import { buildKanbanTools } from './kanban-tools.js';
5
5
  import { buildSpecCardTools } from './spec-card-tools.js';
6
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';
7
+ import { handlePlanRoute, handleOpenspecRoute, handleLearningRoute } from '../routes/prefix-router.js';
8
+ import { recallMemoryIndex, searchChecklists } from '../wiki/memory-recall.js';
9
+ import { buildPlanningGuidance } from '../routes/planning-driver.js';
9
10
  import { attachSessionToWorkspace, resolveOrCreateWorkspace } from '../dispatcher/workspace-attach.js';
10
11
  import { PREFETCH_MANIFEST_SCHEMA } from '../domain/prefetch-manifest.js';
11
12
  import { WikiVaultClient } from '../wiki/wiki-vault-client.js';
12
13
  export const planningBySession = new Map();
13
- const KANBAN_HANDOFF_RULE = `
14
+ const KANBAN_HANDOFF_RULE = (routes) => `
14
15
  ## 主 agent 铁律(看板工作流 v2)
15
16
  - 你是计划者:只做需求澄清(grill-me)与最终收尾汇报;绝不执行任务本身。
16
17
  - 最高护栏:只读仓库——禁止 git 操作、禁止 write/edit 任何仓库源码;只允许写 KB(planning_checklist_save)与临时目录兜底。
17
- - 澄清期:调 planning_prefetch(只读子代理)采集仓库事实 → 逐问用户收敛 → 调 planning_checklist_save 存需求澄清清单 → 提醒用户 /openspec: 确认。
18
- - /openspec: 后链路进入 executing,V 自动串行建卡 p→(pt)→w2→d→dt→w3;你不要自己执行。
18
+ - 澄清期:调 planning_prefetch(只读子代理)采集仓库事实 → 逐问用户收敛 → 调 planning_checklist_save 存需求澄清清单 → 提醒用户 ${routes.openspec} 确认。
19
+ - ${routes.openspec} 后链路进入 executing,V 自动串行建卡 p→(pt)→w2→d→dt→w3;你不要自己执行。
19
20
  - 用 kanban_show / kanban_list / spec_card_view 观察进度,链完成后向用户汇报产物链接与轨迹入口。
21
+ - 经验沉淀:链路完成后,用户可发 ${routes.learning}(或 ${routes.learning} <chainId>)沉淀本链经验;主 agent 消化机械证据包后调 planning_learning_save 入库。
20
22
  `;
21
23
  // 清单获取只有两条路:内存(路由1)> KB 候选页(路由2,LLM 读页重建)。禁止编造其他原因
22
24
  // ("先重试 / 查服务进程是否重启"类诊断是噪声:内存丢失唯一成因是插件重启,重启后按两条路恢复即可)。
23
- const RECOVERY_KB_GUIDANCE = (candidates) => `
25
+ const RECOVERY_KB_GUIDANCE = (routes, candidates) => `
24
26
  插件内存中的需求澄清清单已丢失(插件进程重启所致,属预期情况,按两条获取路由恢复即可,勿猜测其他原因)。
25
27
  知识库中检索到候选清单页:
26
28
  ${candidates.map((c) => '- ' + c).join('\n')}
@@ -28,15 +30,15 @@ ${candidates.map((c) => '- ' + c).join('\n')}
28
30
  1. 读取候选页内容,对照当前需求判定哪一页是本次需求的需求澄清清单(页首行标题为「# 【需求】<需求名>」);
29
31
  2. 消化该页内容,重建结构化 PlanningChecklist(spec 六段 + manifest + clarifications + doubts,requirementName 取页标题中【需求】后的名称);
30
32
  3. 调 planning_checklist_save(checklist, restoreRef=<该候选页路径>) 回存(覆盖原页,勿产生重复页);
31
- 4. 回存成功后提示用户重新发送 /openspec: 确认。
33
+ 4. 回存成功后提示用户重新发送 ${routes.openspec} 确认。
32
34
  禁止:跳过恢复直接建链建卡;猜测清单内容;把恢复失败归因于"重试/进程检查"之外的任何原因。
33
35
  `;
34
- const RECOVERY_NONE_GUIDANCE = `
36
+ const RECOVERY_NONE_GUIDANCE = (routes) => `
35
37
  两条获取路由均无本需求的需求澄清清单(内存为空,知识库亦无匹配页)。
36
38
  处理步骤(严格顺序):
37
39
  1. 消化当前对话上下文,判断需求澄清(grill-me 逐问收敛 + planning_prefetch 仓库事实)是否已完成但漏了保存动作;
38
40
  2. 若已完成澄清——立即调 planning_checklist_save 保存清单;若尚未完成——先完成澄清(缺仓库事实则先 planning_prefetch),再保存;
39
- 3. 保存成功后提示用户重新发送 /openspec: 确认。
41
+ 3. 保存成功后提示用户重新发送 ${routes.openspec} 确认。
40
42
  禁止:在清单落库前建链建卡;编造"先重试 / 查服务进程是否重启"之类与清单无关的诊断。
41
43
  `;
42
44
  /** 预取子代理禁用的写能力工具(官方全局工具名;deny = 从 prompt 消失 + 拒绝执行,"one visibility")。 */
@@ -118,6 +120,8 @@ export function registerMainSessionTools(ctx, config) {
118
120
  spawnPrefetch: buildSpawnPrefetch(ctx),
119
121
  tempDir: () => `${tmpdir()}/dsh-swarm-checklists`, // KB 不可达时的临时兜底,放系统临时目录(不落插件源码/核心存储目录)
120
122
  pagePrefix: config.wikiVault?.pagePrefix ?? 'projects/', // 生成的清单页路径保持在该客户端配置的命名空间内(避免 kb-rejected)
123
+ prefixRoutes: config.prefixRoutes,
124
+ memoryEnabled: config.memory?.enabled ?? true,
121
125
  ownerSessionId: 'session_main',
122
126
  onChecklistSaved({ ref, source, checklist }) {
123
127
  const cur = planningBySession.get('session_main') ?? { workspaceDir: null, sessionId: 'session_main', checklist: null, checklistRef: null, checklistSource: null, requirementName: null };
@@ -126,9 +130,10 @@ export function registerMainSessionTools(ctx, config) {
126
130
  }))
127
131
  registry.register(tool);
128
132
  // kanban_route:/plan: 捕获规划上下文;/openspec: 用清单建链
133
+ const { plan, openspec, learning } = config.prefixRoutes;
129
134
  registry.register(defineTool({
130
135
  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.',
136
+ description: `MUST be called when the human message starts with ${plan}, ${openspec}, or ${learning}. This is dsh-swarm planning, NOT the built-in /plan plan mode. ${plan} = zero side-effect + start grill-me (+ auto KB memory index); ${openspec} = create chain from saved checklist; ${learning} = distill experience from a chain (evidence pack + planning_learning_save).`,
132
137
  parameters: { message: { type: 'string', required: true } },
133
138
  output: { schema: { type: 'json' }, render: (_a, v) => [{ type: 'text', text: JSON.stringify(v) }] },
134
139
  async execute(args, exec) {
@@ -140,7 +145,23 @@ export function registerMainSessionTools(ctx, config) {
140
145
  const headerCwd = exec?.agent?.session?.header?.cwd ?? null;
141
146
  const workspaceDir = await resolveOrCreateWorkspace(ctx, headerCwd, '主 agent 会话');
142
147
  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 };
148
+ let guidance = buildPlanningGuidance(config.prefixRoutes) + KANBAN_HANDOFF_RULE(config.prefixRoutes);
149
+ if ((config.memory?.enabled ?? true) && workspaceDir) {
150
+ const idx = await recallMemoryIndex(wiki, {
151
+ requirementName: plan.rest || null,
152
+ workspaceDir,
153
+ maxEntries: config.memory?.maxIndexEntries ?? 8,
154
+ });
155
+ if (idx)
156
+ guidance += '\n' + idx;
157
+ }
158
+ return { kind: 'plan', guidance };
159
+ }
160
+ if (plan.kind === 'learning') {
161
+ const r = await handleLearningRoute(args.message, service, config.prefixRoutes, 'session_main');
162
+ if (r.error)
163
+ return { kind: 'learning', error: r.error, guidance: r.guidance };
164
+ return { kind: 'learning', chainId: r.chainId, brief: r.brief, guidance: r.guidance };
144
165
  }
145
166
  if (plan.kind === 'none')
146
167
  return { kind: 'none' };
@@ -149,20 +170,19 @@ export function registerMainSessionTools(ctx, config) {
149
170
  if (pctx?.checklist && pctx.checklistRef) {
150
171
  const input = { workspaceDir: pctx.workspaceDir, checklist: pctx.checklist, checklistRef: pctx.checklistRef, requirementName: pctx.requirementName };
151
172
  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 };
173
+ return { kind: 'openspec', chainId: r.chainId, specCardId: r.specCardId, approved: true, guidance: KANBAN_HANDOFF_RULE(config.prefixRoutes) };
153
174
  }
154
175
  // 路由2(知识库):内存丢失(插件重启)→ 搜 KB 候选清单页供 LLM 读页重建;搜不到/不可达 → 两条路皆空
155
176
  let candidates = [];
156
177
  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);
178
+ candidates = await searchChecklists(wiki, config.wikiVault?.pagePrefix ?? 'projects/');
159
179
  }
160
180
  catch { /* KB 不可达/搜索失败 → 候选为空,走两条路皆空分支 */ }
161
181
  return {
162
182
  kind: 'openspec', approved: false, reason: 'no-checklist',
163
183
  recovery: candidates.length > 0 ? 'kb' : 'none',
164
184
  checklistCandidates: candidates,
165
- guidance: candidates.length > 0 ? RECOVERY_KB_GUIDANCE(candidates) : RECOVERY_NONE_GUIDANCE,
185
+ guidance: candidates.length > 0 ? RECOVERY_KB_GUIDANCE(config.prefixRoutes, candidates) : RECOVERY_NONE_GUIDANCE(config.prefixRoutes),
166
186
  };
167
187
  },
168
188
  }));
@@ -4,6 +4,7 @@ import type { WikiVaultClient } from '../wiki/wiki-vault-client.js';
4
4
  import { type PlanningChecklist } from '../domain/planning-checklist.js';
5
5
  import type { ToolCaller } from './kanban-tools.js';
6
6
  import type { AgentModelOptions } from '../dispatcher/dispatcher.js';
7
+ import type { PrefixRoutes } from '../config.js';
7
8
  /** 工具运行时上下文(dsh-tools ToolRunContext 窄型):agent loop 注入调用者 Agent 与取消信号,
8
9
  * planning_prefetch 经官方子代理缝启动时需透传(parent + signal)。 */
9
10
  export interface PrefetchExecContext {
@@ -20,6 +21,8 @@ export interface PlanningToolDeps {
20
21
  tempDir(): string;
21
22
  pagePrefix?: string;
22
23
  ownerSessionId?: string;
24
+ /** 斜杠命令前缀路由(决策12 单一事实源),用于 description 文案派生。 */
25
+ prefixRoutes: PrefixRoutes;
23
26
  defaultModel?: AgentModelOptions;
24
27
  /** 清单落库成功回调(kb 与 temp 两分支各调一次),供 main-session-tools 回写 planningBySession。 */
25
28
  onChecklistSaved?(saved: {
@@ -27,6 +30,8 @@ export interface PlanningToolDeps {
27
30
  source: 'kb' | 'temp';
28
31
  checklist: PlanningChecklist;
29
32
  }): void;
33
+ /** memory.enabled;false 时 planning_memory_recall 返回 disabled 提示(planning_learning_save 不受影响)。 */
34
+ memoryEnabled?: boolean;
30
35
  }
31
36
  /** 主 agent 规划期工具:需求澄清清单落库(KB 优先/临时目录兜底)+ 只读仓库预取(子代理)。 */
32
37
  export declare function buildPlanningTools(deps: PlanningToolDeps): import("@deepseek-ai/dsh-tools").ToolDefinition[];
@@ -2,7 +2,8 @@
2
2
  import { defineTool } from '@deepseek-ai/dsh-tools';
3
3
  import { validatePlanningChecklist, formatChecklistBody } from '../domain/planning-checklist.js';
4
4
  import { validatePrefetchManifest } from '../domain/prefetch-manifest.js';
5
- import { buildChecklistSlug, CHECKLIST_PAGE_PREFIX } from '../wiki/page-path.js';
5
+ import { buildChecklistSlug, CHECKLIST_PAGE_PREFIX, assertAllowedWikiPagePath } from '../wiki/page-path.js';
6
+ import { validateLearning, formatLearningBody, buildRepoSlug } from '../domain/memory.js';
6
7
  const isWikiError = (e) => e instanceof Error && e.code === 'kb-unreachable';
7
8
  /** 主 agent 规划期工具:需求澄清清单落库(KB 优先/临时目录兜底)+ 只读仓库预取(子代理)。 */
8
9
  export function buildPlanningTools(deps) {
@@ -12,7 +13,7 @@ export function buildPlanningTools(deps) {
12
13
  defineTool({
13
14
  name: 'planning_checklist_save',
14
15
  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
+ parameters: { checklist: { type: 'json', required: true, description: 'Structured PlanningChecklist: spec six sections + manifest(repo.files) + clarifications + doubts. 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' } },
16
17
  output: { schema: { type: 'json' }, render: (_a, v) => [{ type: 'text', text: JSON.stringify(v) }] },
17
18
  async execute(args) {
18
19
  const caller = deps.getCaller();
@@ -83,6 +84,94 @@ export function buildPlanningTools(deps) {
83
84
  return { ok: true, manifest };
84
85
  },
85
86
  }),
87
+ defineTool({
88
+ name: 'planning_learning_save',
89
+ description: 'Save a distilled learning (experience) to the knowledge base. scope=chain → projects/<chainId>/learnings/ (requirement-level); scope=project → projects/<repoSlug>/learnings/ (repo-level, repoSlug derived from the chain workspaceDir). Returns ref. Soft-fails {ok:false,reason:"kb-unreachable"} when KB is unreachable (no temp fallback).',
90
+ parameters: {
91
+ learning: { type: 'json', required: true, description: 'LearningEntry: { title (≤80 chars), lesson, evidence (mechanical chain/task id — required), tags: string[] }' },
92
+ scope: { type: 'string', enum: ['chain', 'project'], required: true, description: '"chain" (requirement-level) | "project" (repo-level)' },
93
+ chainId: { type: 'string', required: true, description: 'The chain this learning is distilled from; must exist' },
94
+ },
95
+ output: { schema: { type: 'json' }, render: (_a, v) => [{ type: 'text', text: JSON.stringify(v) }] },
96
+ async execute(args) {
97
+ const caller = deps.getCaller();
98
+ if (caller.actor !== 'human')
99
+ throw new Error('permission denied: planning_learning_save');
100
+ const errors = validateLearning(args.learning);
101
+ if (errors.length > 0)
102
+ throw new Error('invalid learning: ' + errors.join('; '));
103
+ if (args.scope !== 'chain' && args.scope !== 'project')
104
+ throw new Error('invalid scope: ' + String(args.scope));
105
+ if (typeof args.chainId !== 'string' || !args.chainId.trim())
106
+ throw new Error('chainId required');
107
+ const state = await deps.service.snapshot();
108
+ const chain = state.chains.get(args.chainId);
109
+ if (!chain)
110
+ throw new Error('unknown chain: ' + args.chainId);
111
+ const entry = args.learning;
112
+ let prefix;
113
+ if (args.scope === 'chain') {
114
+ prefix = `projects/${args.chainId}/learnings/`;
115
+ }
116
+ else {
117
+ if (!chain.workspaceDir)
118
+ throw new Error('scope=project requires chain.workspaceDir (target repo) — chain has none');
119
+ prefix = `projects/${buildRepoSlug(chain.workspaceDir)}/learnings/`;
120
+ }
121
+ const pagePath = `${prefix}${buildChecklistSlug(entry.title)}-${Date.now().toString(36)}.md`;
122
+ try {
123
+ await deps.wiki.write(pagePath, formatLearningBody(entry));
124
+ return { ok: true, ref: pagePath, scope: args.scope };
125
+ }
126
+ catch (err) {
127
+ if (isWikiError(err))
128
+ return { ok: false, reason: 'kb-unreachable' };
129
+ throw err;
130
+ }
131
+ },
132
+ }),
133
+ defineTool({
134
+ name: 'planning_memory_recall',
135
+ description: 'Recall KB memory for planning. path mode: read a full page (whitelist: projects/checklists/, projects/learnings/, projects/<slug>/learnings/, projects/ch_*/learnings/, projects/ch_*/t_*.md, projects/ch_*/review/) truncated to 8000 chars. query mode: full-text search returning top 5 {path,title,score}. Returns {ok:false,reason:"kb-unreachable"} on KB failure; {ok:false,reason:"disabled"} when memory is disabled.',
136
+ parameters: {
137
+ path: { type: 'string', description: 'KB page path to read in full (mutually exclusive with query)' },
138
+ query: { type: 'string', description: 'Full-text query; returns top 5 result paths (mutually exclusive with path)' },
139
+ },
140
+ output: { schema: { type: 'json' }, render: (_a, v) => [{ type: 'text', text: JSON.stringify(v) }] },
141
+ async execute(args) {
142
+ const caller = deps.getCaller();
143
+ if (caller.actor !== 'human')
144
+ throw new Error('permission denied: planning_memory_recall');
145
+ if (deps.memoryEnabled === false)
146
+ return { ok: false, reason: 'disabled' };
147
+ const hasPath = typeof args.path === 'string' && args.path.trim().length > 0;
148
+ const hasQuery = typeof args.query === 'string' && args.query.trim().length > 0;
149
+ if (hasPath === hasQuery)
150
+ throw new Error('provide exactly one of path|query');
151
+ if (hasPath) {
152
+ assertAllowedWikiPagePath(args.path);
153
+ try {
154
+ const d = await deps.wiki.read(args.path);
155
+ const content = d.rawMd.length > 8000 ? d.rawMd.slice(0, 8000) + '…' : d.rawMd;
156
+ return { ok: true, path: args.path, content };
157
+ }
158
+ catch (err) {
159
+ if (isWikiError(err))
160
+ return { ok: false, reason: 'kb-unreachable' };
161
+ throw err;
162
+ }
163
+ }
164
+ try {
165
+ const results = (await deps.wiki.search(args.query)).slice(0, 5).map((r) => ({ path: r.path, title: r.title, score: r.score }));
166
+ return { ok: true, results };
167
+ }
168
+ catch (err) {
169
+ if (isWikiError(err))
170
+ return { ok: false, reason: 'kb-unreachable' };
171
+ throw err;
172
+ }
173
+ },
174
+ }),
86
175
  ];
87
176
  }
88
177
  function parseManifestOutput(output) {
@@ -0,0 +1,16 @@
1
+ import type { WikiVaultClient, WikiSearchResult } from './wiki-vault-client.js';
2
+ export declare function recallLearningIndex(wiki: WikiVaultClient, opts: {
3
+ requirementName: string | null;
4
+ workspaceDir: string | null;
5
+ }): Promise<WikiSearchResult[]>;
6
+ export declare function recallDocIndex(wiki: WikiVaultClient, opts: {
7
+ requirementName: string | null;
8
+ workspaceDir: string | null;
9
+ }): Promise<WikiSearchResult[]>;
10
+ export declare function recallMemoryIndex(wiki: WikiVaultClient, opts: {
11
+ requirementName: string | null;
12
+ workspaceDir: string | null;
13
+ maxEntries: number;
14
+ }): Promise<string | null>;
15
+ /** /openspec: 恢复路径复用:搜【需求】候选清单页(projects/ 前缀 top5)。 */
16
+ export declare function searchChecklists(wiki: WikiVaultClient, pagePrefix?: string): Promise<string[]>;
@@ -0,0 +1,58 @@
1
+ import { buildMemoryIndexBlock, weightedRank, buildRepoSlug } from '../domain/memory.js';
2
+ const TIMEOUT_MS = 6_000;
3
+ async function withTimeout(p) {
4
+ try {
5
+ return await Promise.race([p, new Promise((resolve) => setTimeout(() => resolve(null), TIMEOUT_MS))]);
6
+ }
7
+ catch {
8
+ return null;
9
+ }
10
+ }
11
+ /** 路1 范围:全局 + 当前仓库项目级 learnings(workspaceDir null → 仅全局)。 */
12
+ function isScopedLearning(path, repoSlug) {
13
+ if (path.startsWith('projects/learnings/'))
14
+ return true;
15
+ if (repoSlug && path.startsWith(`projects/${repoSlug}/learnings/`))
16
+ return true;
17
+ return false;
18
+ }
19
+ export async function recallLearningIndex(wiki, opts) {
20
+ const repoSlug = opts.workspaceDir ? buildRepoSlug(opts.workspaceDir) : null;
21
+ if (opts.requirementName) {
22
+ const r = await withTimeout(wiki.search(opts.requirementName));
23
+ if (!r)
24
+ return [];
25
+ return weightedRank(r.filter((x) => isScopedLearning(x.path, repoSlug)), (x) => x.score, (x) => x.mtime);
26
+ }
27
+ const r = await withTimeout(wiki.search('【Learning】'));
28
+ if (!r)
29
+ return [];
30
+ return r.filter((x) => isScopedLearning(x.path, repoSlug)).sort((a, b) => b.mtime - a.mtime);
31
+ }
32
+ export async function recallDocIndex(wiki, opts) {
33
+ if (!opts.requirementName)
34
+ return [];
35
+ const r = await withTimeout(wiki.search(opts.requirementName));
36
+ if (!r)
37
+ return [];
38
+ const repoSlug = opts.workspaceDir ? buildRepoSlug(opts.workspaceDir) : null;
39
+ return r
40
+ .filter((x) => x.path.startsWith('projects/') && !isScopedLearning(x.path, repoSlug))
41
+ .sort((a, b) => b.score - a.score);
42
+ }
43
+ export async function recallMemoryIndex(wiki, opts) {
44
+ const [learnings, docs] = await Promise.all([
45
+ recallLearningIndex(wiki, { requirementName: opts.requirementName, workspaceDir: opts.workspaceDir }),
46
+ recallDocIndex(wiki, { requirementName: opts.requirementName, workspaceDir: opts.workspaceDir }),
47
+ ]);
48
+ const learningEntries = learnings.slice(0, Math.ceil(opts.maxEntries / 2)).map((r) => ({ kind: 'learning', title: r.title, path: r.path }));
49
+ const docEntries = docs.slice(0, opts.maxEntries - learningEntries.length).map((r) => ({ kind: 'doc', title: r.title, path: r.path }));
50
+ return buildMemoryIndexBlock([...learningEntries, ...docEntries]);
51
+ }
52
+ /** /openspec: 恢复路径复用:搜【需求】候选清单页(projects/ 前缀 top5)。 */
53
+ export async function searchChecklists(wiki, pagePrefix = 'projects/') {
54
+ const r = await withTimeout(wiki.search('【需求】'));
55
+ if (!r)
56
+ return [];
57
+ return r.map((x) => x.path).filter((p) => p.startsWith(pagePrefix)).slice(0, 5);
58
+ }
@@ -1,6 +1,9 @@
1
1
  export declare const CHECKLIST_PAGE_PREFIX = "projects/checklists/";
2
+ export declare const LEARNINGS_PAGE_PREFIX = "projects/learnings/";
2
3
  /** 从需求名(回退 problem)派生 URL 安全 slug:ASCII 化、空格/特殊字符→-、限长 40;全非 ASCII(如中文)兜底 'req'。 */
3
4
  export declare function buildChecklistSlug(name: string): string;
4
5
  export declare function isAllowedWikiPagePath(pagePath: string): boolean;
6
+ /** 三级 learnings 路径谓词:全局 / 项目级 / 需求级。 */
7
+ export declare function isLearningsPath(pagePath: string): boolean;
5
8
  /** 工具边界硬校验:不符白名单直接抛 kb-rejected(wiki_write 用)。 */
6
9
  export declare function assertAllowedWikiPagePath(pagePath: string): void;
@@ -5,8 +5,9 @@
5
5
  // DT 评审页 projects/ch_<id>/review/<name>.md (DT wiki_write,工具边界强制)
6
6
  import { WikiError } from './wiki-vault-client.js';
7
7
  export const CHECKLIST_PAGE_PREFIX = 'projects/checklists/';
8
- /** 白名单:wiki_write 只允许写这三类命名空间(杜绝 LLM 自造路径/拼错层级)。id 段宽松匹配(兼容真实 nid 两段式 ch_1_xxx 与测试单段 ch_1)。 */
9
- const KB_PAGE_PATH_RE = /^projects\/(?:checklists\/|ch_[0-9a-z_]+\/(?:t_[0-9a-z_]+\.md|review\/))/;
8
+ export const LEARNINGS_PAGE_PREFIX = 'projects/learnings/';
9
+ /** 白名单:checklists + 三级 learnings + 链命名空间(杜绝 LLM 自造路径)。 */
10
+ const KB_PAGE_PATH_RE = /^projects\/(?:checklists\/|learnings\/|[a-z0-9-]+\/learnings\/|ch_[0-9a-z_]+\/(?:t_[0-9a-z_]+\.md|review\/|learnings\/))/;
10
11
  /** 从需求名(回退 problem)派生 URL 安全 slug:ASCII 化、空格/特殊字符→-、限长 40;全非 ASCII(如中文)兜底 'req'。 */
11
12
  export function buildChecklistSlug(name) {
12
13
  const slug = name
@@ -20,9 +21,13 @@ export function buildChecklistSlug(name) {
20
21
  export function isAllowedWikiPagePath(pagePath) {
21
22
  return KB_PAGE_PATH_RE.test(pagePath);
22
23
  }
24
+ /** 三级 learnings 路径谓词:全局 / 项目级 / 需求级。 */
25
+ export function isLearningsPath(pagePath) {
26
+ return /^projects\/(?:learnings\/|[a-z0-9-]+\/learnings\/|ch_[0-9a-z_]+\/learnings\/)/.test(pagePath);
27
+ }
23
28
  /** 工具边界硬校验:不符白名单直接抛 kb-rejected(wiki_write 用)。 */
24
29
  export function assertAllowedWikiPagePath(pagePath) {
25
30
  if (!isAllowedWikiPagePath(pagePath)) {
26
- throw new WikiError('kb-rejected', undefined, `page path outside allowed namespaces (projects/checklists/, projects/ch_*/t_*.md, projects/ch_*/review/): ${pagePath}`);
31
+ throw new WikiError('kb-rejected', undefined, `page path outside allowed namespaces (projects/checklists/, projects/learnings/, projects/<slug>/learnings/, projects/ch_*/learnings/, projects/ch_*/t_*.md, projects/ch_*/review/): ${pagePath}`);
27
32
  }
28
33
  }
@@ -7,6 +7,7 @@ export interface WikiSearchResult {
7
7
  path: string;
8
8
  title: string;
9
9
  score: number;
10
+ mtime: number;
10
11
  }
11
12
  export declare class WikiVaultClient {
12
13
  private readonly cfg;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joekytc/dsh-swarm",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "A governed swarm of six specialist DSH agents (orchestrator, planner, knowledge-base bridge, developer and two reviewers) that turns a requirement into a strict phase pipeline with machine-verified delivery evidence, review-gated merges, a full audit-log event stream and a live kanban tab; design inspired by the Hermes Agent kanban",
5
5
  "license": "MIT",
6
6
  "author": "joekytc",
@@ -59,7 +59,7 @@
59
59
 
60
60
  # run_code 供验证程序(跑测试/构建/typecheck 编排);ToolGuard 拦其对源码的写
61
61
  - id: tool-presentation
62
- name: '@deepseek-ai/dsh-tool-presentation'
62
+ name: '@deepseek-ai/dsh-agent-tool-presentation'
63
63
  config:
64
64
  mode: both
65
65