@joekytc/dsh-swarm 0.3.7 → 0.3.9

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 (48) hide show
  1. package/README.md +165 -116
  2. package/README.zh-CN.md +143 -96
  3. package/client/timeline-model.ts +14 -1
  4. package/lib/client.js +20 -2
  5. package/lib/config.d.ts +14 -3
  6. package/lib/config.js +10 -0
  7. package/lib/dispatcher/agent-runner.js +8 -8
  8. package/lib/dispatcher/chain-auditor.d.ts +7 -1
  9. package/lib/dispatcher/chain-auditor.js +7 -4
  10. package/lib/dispatcher/context-dedup.d.ts +1 -0
  11. package/lib/dispatcher/context-dedup.js +24 -0
  12. package/lib/dispatcher/dispatcher.d.ts +8 -0
  13. package/lib/dispatcher/dispatcher.js +33 -4
  14. package/lib/dispatcher/session-preset.d.ts +36 -0
  15. package/lib/dispatcher/session-preset.js +67 -0
  16. package/lib/dispatcher/v-orchestrator.js +4 -1
  17. package/lib/domain/delivery-contract.d.ts +2 -1
  18. package/lib/domain/delivery-contract.js +16 -3
  19. package/lib/domain/evidence-check.d.ts +17 -0
  20. package/lib/domain/evidence-check.js +44 -0
  21. package/lib/domain/gate-policy.d.ts +33 -9
  22. package/lib/domain/gate-policy.js +60 -19
  23. package/lib/domain/kanban-service.d.ts +27 -4
  24. package/lib/domain/kanban-service.js +45 -7
  25. package/lib/domain/planning-checklist.d.ts +3 -0
  26. package/lib/domain/planning-checklist.js +27 -0
  27. package/lib/domain/prefetch-manifest.js +15 -4
  28. package/lib/domain/review-evidence.js +3 -1
  29. package/lib/domain/types.d.ts +7 -1
  30. package/lib/roles/preset-installer.d.ts +14 -0
  31. package/lib/roles/preset-installer.js +17 -0
  32. package/lib/roles/toolsets.d.ts +19 -8
  33. package/lib/roles/toolsets.js +24 -15
  34. package/lib/services/evidence-replay.d.ts +28 -0
  35. package/lib/services/evidence-replay.js +79 -0
  36. package/lib/services/gate-evidence.d.ts +2 -0
  37. package/lib/services/gate-evidence.js +21 -0
  38. package/lib/services/im-delivery.d.ts +4 -0
  39. package/lib/services/im-delivery.js +31 -3
  40. package/lib/services/kanban-provider.d.ts +1 -0
  41. package/lib/services/kanban-provider.js +92 -19
  42. package/lib/tools/kanban-tools.js +10 -2
  43. package/lib/tools/main-session-tools.d.ts +1 -1
  44. package/lib/tools/main-session-tools.js +42 -27
  45. package/lib/tools/planning-tools.js +87 -1
  46. package/lib/tools/spec-card-tools.js +8 -2
  47. package/package.json +2 -2
  48. package/personas/persona-dt.md +3 -0
@@ -0,0 +1,79 @@
1
+ // src/services/evidence-replay.ts
2
+ /** DT 评审证据三级核验编排(PR2):L1 缺证标记 → L2 纸面核对(零执行)→ L3 重放(默认关,非沙箱)。
3
+ * 非阻塞:任何结果只返回留痕数据,不改 verdict、不 throw。 */
4
+ import { readFile } from 'node:fs/promises';
5
+ import { runGateCommands } from './gate-runner.js';
6
+ import { isAllowedCommand, paperCheck, parseIssueEvidence } from '../domain/evidence-check.js';
7
+ /** 沿父链找最近 execute 卡的 worktree_dir(重放 cwd=被评审代码所在处)。 */
8
+ export function resolveTargetWorktree(parents) {
9
+ for (const p of parents) {
10
+ if (p.assignee === 'd' && p.mode === 'execute') {
11
+ const wt = p.metadata?.['worktree_dir'];
12
+ if (typeof wt === 'string' && wt.trim())
13
+ return wt;
14
+ }
15
+ }
16
+ return null;
17
+ }
18
+ export async function checkIssueEvidence(input) {
19
+ const list = Array.isArray(input.issues) ? input.issues : [];
20
+ const forbidden = input.forbidden ?? ['rm -rf /', 'git push'];
21
+ const out = [];
22
+ for (const rawIssue of list) {
23
+ // 评审 Important:per-issue 容错——被核验者塞 null/标量元素不得让整批核验无痕消失
24
+ if (typeof rawIssue !== 'object' || rawIssue === null) {
25
+ out.push({ title: '(invalid issue entry)', severity: 'unknown', state: 'could-not-replay', detail: 'issues 数组含非对象元素 — needs-human 核对' });
26
+ continue;
27
+ }
28
+ const issue = rawIssue;
29
+ const title = String(issue['title'] ?? '(untitled)');
30
+ const severity = String(issue['severity'] ?? 'low');
31
+ const ev = parseIssueEvidence(issue['evidence']);
32
+ if (!ev) {
33
+ const serious = severity === 'critical' || severity === 'high';
34
+ out.push(serious
35
+ ? { title, severity, state: 'could-not-replay', detail: 'no evidence provided (critical/high) — needs-human 核对' }
36
+ : { title, severity, state: 'not-provided', detail: 'no evidence attached' });
37
+ continue;
38
+ }
39
+ // L2 纸面核对(零执行)
40
+ let content = null;
41
+ try {
42
+ content = await input.readFile(ev.file);
43
+ }
44
+ catch {
45
+ content = null;
46
+ }
47
+ const paper = paperCheck(content, ev.exit);
48
+ if (paper === 'matches') {
49
+ out.push({ title, severity, state: 'matches', detail: `paper check ok: ${ev.file}` });
50
+ continue;
51
+ }
52
+ // L3 重放(对不上才动用;默认关)
53
+ if (!input.replayEnabled) {
54
+ out.push({ title, severity, state: 'could-not-replay', detail: `paper ${paper}; replay disabled by config — needs-human` });
55
+ continue;
56
+ }
57
+ if (!isAllowedCommand(ev.command, input.allowPrefixes)) {
58
+ out.push({ title, severity, state: 'could-not-replay', detail: `command not allowlisted: ${ev.command}` });
59
+ continue;
60
+ }
61
+ if (!input.worktreeDir) {
62
+ out.push({ title, severity, state: 'could-not-replay', detail: 'no target worktree resolved' });
63
+ continue;
64
+ }
65
+ const cmd = { command: ev.command, cwd: input.worktreeDir, source: 'tdd' };
66
+ const report = await runGateCommands([cmd], { timeoutMs: input.timeoutMs, forbidden }, input.run);
67
+ // 评审 Important:TIMEOUT/SPAWN_FAIL/FORBIDDEN ≠ 假证据——超时/派生失败/黑名单命中落 could-not-replay,
68
+ // 仅真实执行(含非零退出)与声明不符才判 differs(四态语义准确性)
69
+ if (report.failure && report.failure.code !== 'NONZERO') {
70
+ out.push({ title, severity, state: 'could-not-replay', detail: `replay ${report.failure.code}: ${report.failure.detail} — needs-human` });
71
+ continue;
72
+ }
73
+ const rerun = report.results.at(-1)?.exitCode;
74
+ out.push(rerun !== null && rerun === ev.exit
75
+ ? { title, severity, state: 'matches', detail: `replay confirmed exit ${rerun}` }
76
+ : { title, severity, state: 'differs', detail: `replay exit=${rerun} ≠ claimed ${ev.exit} — 假证据疑点,needs-human` });
77
+ }
78
+ return out;
79
+ }
@@ -0,0 +1,2 @@
1
+ import type { GateRunReport } from './gate-runner.js';
2
+ export declare function writeGateLog(dir: string, taskId: string, report: GateRunReport): Promise<string>;
@@ -0,0 +1,21 @@
1
+ // src/services/gate-evidence.ts
2
+ /** gate 实测日志落盘(PR1 P3 存档):pass 也留原始输出,事后可翻账对质(issue #2 verify.sh 模式的 D 侧落点)。
3
+ * 追加写(每轮一条时间戳头),单文件 per task,路径进 gate detail 供 agent/human 引用。 */
4
+ import { mkdir, appendFile } from 'node:fs/promises';
5
+ import { join } from 'node:path';
6
+ export async function writeGateLog(dir, taskId, report) {
7
+ const logDir = join(dir, 'gate-logs');
8
+ await mkdir(logDir, { recursive: true });
9
+ const path = join(logDir, `${taskId}.log`);
10
+ const lines = [`== ${new Date().toISOString()} ok=${report.ok}`];
11
+ for (const r of report.results) {
12
+ lines.push(`-- ${r.command}`);
13
+ lines.push(`exit=${r.exitCode} durationMs=${r.durationMs} truncated=${r.truncated}`);
14
+ if (r.output)
15
+ lines.push(r.output);
16
+ }
17
+ if (report.failure)
18
+ lines.push(`-- failure: ${report.failure.code}: ${report.failure.detail}`);
19
+ await appendFile(path, lines.join('\n') + '\n');
20
+ return path;
21
+ }
@@ -72,6 +72,10 @@ export interface BotChoice {
72
72
  via: BotVia;
73
73
  fallbackSaved?: boolean;
74
74
  }
75
+ /** 应答 → 候选 botId(宽松匹配,宁缺勿错):
76
+ * ① 令牌精确相等;② 与令牌互相包含(仅当令牌足够长,防 3~5 字符误命中)。
77
+ * 多义(>=2 个候选命中)或无法识别 → 空串(调用方 fail-closed 不投,绝不猜)。 */
78
+ export declare function matchBotAnswer(options: BotAskOption[], raw: string): string;
75
79
  /** 机器人决策链:显式配置 → 唯一机器人 → 会话 preset 命中 → 默认机器人 → 交互选择。
76
80
  * 每环都 fail-closed(宁可不投也不投错对象);探针不可用只失去自动匹配能力,不阻断投递。 */
77
81
  export declare function chooseBot(im: DshImLike, cfg: {
@@ -32,6 +32,33 @@ function resolveDshIm(ctx, log) {
32
32
  function askLabel(o) {
33
33
  return o.label ? `${o.label}(${o.botId})` : o.botId;
34
34
  }
35
+ /** 归一:去空白(含全角空格)+ 小写(GUI 可能回传带装饰/大小写变化的文本)。 */
36
+ function normAnswer(s) {
37
+ return s.replace(/[\s\u3000]/g, '').toLowerCase();
38
+ }
39
+ /** 一个候选的全部可识别令牌:botId / 展示 label 全文 / label 的展示名段(去 ·模式·离线 与 (botId)后缀)。 */
40
+ function idTokens(o) {
41
+ const out = [normAnswer(o.botId)];
42
+ if (o.label) {
43
+ const n = normAnswer(o.label);
44
+ out.push(n.split('(')[0], n.split('·')[0]);
45
+ }
46
+ return [...new Set(out.filter((t) => t.length > 0))];
47
+ }
48
+ /** 应答 → 候选 botId(宽松匹配,宁缺勿错):
49
+ * ① 令牌精确相等;② 与令牌互相包含(仅当令牌足够长,防 3~5 字符误命中)。
50
+ * 多义(>=2 个候选命中)或无法识别 → 空串(调用方 fail-closed 不投,绝不猜)。 */
51
+ export function matchBotAnswer(options, raw) {
52
+ const target = normAnswer(raw);
53
+ if (!target)
54
+ return '';
55
+ const exact = options.find((o) => idTokens(o).includes(target));
56
+ if (exact)
57
+ return exact.botId;
58
+ const partial = options.filter((o) => idTokens(o).some((t) => t.length >= 6 && (t.includes(target) || target.includes(t))));
59
+ const uniq = [...new Set(partial.map((o) => o.botId))];
60
+ return uniq.length === 1 ? uniq[0] : '';
61
+ }
35
62
  /** 多机器人且无法自动决策时的 fail-closed 报错:列候选(有探针数据则带模式与连接态)。 */
36
63
  function botCandidatesError(ids, probed) {
37
64
  const byId = new Map(probed.map((b) => [b.botId, b]));
@@ -212,10 +239,11 @@ function buildBotAsk(ctx, agent, log) {
212
239
  const pick = ans.answers?.find((a) => a.id === 'im-bot');
213
240
  const typed = pick?.custom?.trim() ?? '';
214
241
  const selected = pick?.selected?.[0]?.trim() ?? '';
215
- // 自由文本也接受,但必须是候选之一——防手写错 id 投到未知对象。
216
- const botId = options.find((o) => askLabel(o) === selected || o.botId === selected || o.botId === typed)?.botId ?? '';
242
+ // 选中标签与自由文本都接受,但必须唯一落到候选之一——防手写错 id 投到未知对象。
243
+ const botId = matchBotAnswer(options, selected) || matchBotAnswer(options, typed);
217
244
  if (!botId) {
218
- log('[im-delivery] 机器人交互已应答但未映射到候选(selected 与候选不符)');
245
+ // 只记候选 id 与用户可见文本(公开标识,无敏感载荷),便于下次定位映射失败。
246
+ log(`[im-delivery] 机器人交互已应答但未映射到候选: selected=${JSON.stringify(selected).slice(0, 120)} custom=${JSON.stringify(typed).slice(0, 120)} candidates=${options.map((o) => o.botId).join(',')}`);
219
247
  return null;
220
248
  }
221
249
  const setDefault = (ans.answers?.find((a) => a.id === 'im-bot-default')?.selected ?? []).includes(ASK_SET_DEFAULT_LABEL);
@@ -16,5 +16,6 @@ export declare class KanbanProvider extends Service {
16
16
  /** 整链硬删除后的联动钩子(由 startDispatcher 装配注入):dispatcher 游标同步 + V 编排 entry 清理。
17
17
  * purge 物理重排 events seq,若不同步则删链后新建链的可唤醒事件被运行中实例永久跳过。 */
18
18
  onChainDeleted: ((chainId: string) => Promise<void> | void) | null;
19
+ private readonly gateLogDir;
19
20
  constructor(ctx: Context, config: KanbanConfig, configProvider: ConfigProvider);
20
21
  }
@@ -1,10 +1,13 @@
1
1
  import { Service } from '@deepseek-ai/cordis';
2
2
  import { homedir } from 'node:os';
3
3
  import { spawn } from 'node:child_process';
4
+ import { readFile } from 'node:fs/promises';
4
5
  import { KanbanService } from '../domain/kanban-service.js';
5
6
  import { FileEventStore } from '../domain/event-store.js';
6
- import { deriveGatePlan, isGatePlan, branchMatches } from '../domain/gate-policy.js';
7
+ import { deriveGatePlan, branchMatches, resolveDiffBase } from '../domain/gate-policy.js';
7
8
  import { runGateCommands } from './gate-runner.js';
9
+ import { writeGateLog } from './gate-evidence.js';
10
+ import { checkIssueEvidence, resolveTargetWorktree } from './evidence-replay.js';
8
11
  export class KanbanProvider extends Service {
9
12
  service;
10
13
  /** GUI retry 的任务执行器(由 startDispatcher 装配后注入;webServer 先于 agents 就绪时可为 null)。 */
@@ -13,34 +16,49 @@ export class KanbanProvider extends Service {
13
16
  * purge 物理重排 events seq,若不同步则删链后新建链的可唤醒事件被运行中实例永久跳过。 */
14
17
  onChainDeleted = null;
15
18
  // 经 configProvider getter 读 effective 配置——配置面板改 wikiVault.baseUrl 后 kb_url 前缀校验热生效。
19
+ gateLogDir;
16
20
  constructor(ctx, config, configProvider) {
17
21
  super(ctx, 'kanban');
18
22
  const dir = config.storageDir.replace('$DSH_HOME', process.env.DSH_HOME ?? homedir());
23
+ this.gateLogDir = dir;
19
24
  this.service = new KanbanService(new FileEventStore(dir), () => configProvider.getEffective().wikiVault?.baseUrl);
20
- // P1 实测闸装配(组合根):complete 前派生 GatePlan → 分支一致性核对 → 实测执行。
21
- // hook 绝不 throw:意外异常记日志并返回 null(零感知 skip,fail-open 与分支不一致语义一致)。
25
+ // P1 实测闸装配(组合根):complete 前派生 GateOutcome → 分支核对 → 实测执行/打回/警报。
26
+ // hook 绝不 throw:意外异常记日志并返回 null(基建故障静默,既有决议)。
27
+ // diff 实况:base=D 卡 body 的 TARGET_BRANCH(gate-policy resolveDiffBase);git 失败或无 base → null(派生层保守放行)。
22
28
  this.service.setGateHook(async (task, handoff) => {
23
29
  try {
24
30
  // 热读取(同 wikiVault.baseUrl 先例,getEffective):配置面板改 gates 后无需重建即生效。
25
31
  const cfg = configProvider.getEffective().gates ?? { enabled: true, timeoutMs: 600000, forbidden: ['rm -rf /', 'git push'] };
26
- const plan = deriveGatePlan({ assignee: task.assignee, mode: task.mode, handoff, config: { enabled: cfg.enabled } });
27
- if (!isGatePlan(plan))
28
- return null; // skip:disabled/非 D/旧卡/路径违规
29
- const wt = plan.commands[0].cwd;
30
- // 分支一致性核对(防报假 worktree 绕闸):查询失败或与 metadata.branch 不一致 → skip 零感知。
31
- const current = await new Promise((resolve) => {
32
- const child = spawn('git', ['-C', wt, 'rev-parse', '--abbrev-ref', 'HEAD'], { stdio: ['ignore', 'pipe', 'pipe'] });
33
- let out = '';
34
- child.stdout.on('data', (c) => { out += c.toString('utf8'); });
35
- child.on('error', () => resolve(null));
36
- child.on('close', (code) => resolve(code === 0 ? out.trim() || null : null));
32
+ const metadata = (handoff.metadata ?? {});
33
+ const wt = typeof metadata['worktree_dir'] === 'string' ? metadata['worktree_dir'] : '';
34
+ const base = resolveDiffBase(task.body ?? '');
35
+ const diffFiles = wt.trim() && base ? await gitDiffNames(wt, base) : null;
36
+ const outcome = deriveGatePlan({
37
+ assignee: task.assignee, mode: task.mode, handoff,
38
+ config: { enabled: cfg.enabled }, diffFiles,
37
39
  });
38
- if (!branchMatches(current, handoff.metadata?.['branch']))
39
- return null;
40
- const report = await runGateCommands(plan.commands, { timeoutMs: cfg.timeoutMs, forbidden: cfg.forbidden });
40
+ if (outcome.kind === 'silent-skip')
41
+ return null; // 不适用:零事件(旧语义)
42
+ if (outcome.kind === 'alarm-skip')
43
+ return { skipped: true, reason: outcome.reason };
44
+ if (outcome.kind === 'bounce')
45
+ return { ok: false, detail: outcome.reason };
46
+ // run:分支一致性核对(⑦ 不一致由静默 skip 升级为 bounce 打回)
47
+ const current = await gitCurrentBranch(outcome.commands[0].cwd);
48
+ if (!branchMatches(current, metadata['branch']))
49
+ return { ok: false, detail: `分支不一致:声明 ${String(metadata['branch'] ?? '')},实际 ${current ?? 'unknown'}——先同步分支再重交` };
50
+ const report = await runGateCommands(outcome.commands, { timeoutMs: cfg.timeoutMs, forbidden: cfg.forbidden });
51
+ // 落盘失败不吞实测结果(评审 Minor):测量已做,结论必须保住——仅降级 evidence 路径
52
+ let evidencePath = '(log write failed)';
53
+ try {
54
+ evidencePath = await writeGateLog(this.gateLogDir, task.id, report);
55
+ }
56
+ catch (logError) {
57
+ console.error('[dsh-swarm] gate log write failed (result kept): ' + String(logError));
58
+ }
41
59
  const detail = report.ok
42
- ? plan.commands.map((c) => c.command).join(' && ') + ' exit 0'
43
- : `${report.failure.code}: ${report.failure.command} — ${report.failure.detail}\n${(report.results.at(-1)?.output ?? '').slice(-2000)}`;
60
+ ? outcome.commands.map((c) => c.command).join(' && ') + ' exit 0\nevidence: ' + evidencePath
61
+ : `${report.failure.code}: ${report.failure.command} — ${report.failure.detail}\n${(report.results.at(-1)?.output ?? '').slice(-2000)}\nevidence: ${evidencePath}`;
44
62
  return { ok: report.ok, detail };
45
63
  }
46
64
  catch (error) {
@@ -48,5 +66,60 @@ export class KanbanProvider extends Service {
48
66
  return null;
49
67
  }
50
68
  });
69
+ // PR2 评审证据核验装配:DT 卡 complete 时三级核验(缺证标记/纸面核对/白名单重放)。
70
+ // 非阻塞:结果只发 review/evidence-check 事件;重放开关默认关(真执行 AI 命令=非沙箱)。
71
+ this.service.setEvidenceCheckHook(async (task, handoff) => {
72
+ try {
73
+ const cfg = configProvider.getEffective().evidenceReplay;
74
+ const state = await this.service.snapshot();
75
+ const parentTasks = task.parents
76
+ .map((pid) => state.tasks.get(pid))
77
+ .filter((t) => Boolean(t));
78
+ const parentsWithMeta = parentTasks.map((t) => ({
79
+ assignee: t.assignee, mode: t.mode,
80
+ metadata: (state.handoffs.get(t.id)?.metadata ?? {}),
81
+ }));
82
+ const issues = handoff.metadata?.['review_evidence']?.issues;
83
+ if (!Array.isArray(issues) || issues.length === 0)
84
+ return null;
85
+ // worktree 解析边界(评审 Minor 固化):一层 parents 解析覆盖现网全部建卡路径
86
+ //(首评卡 parents=d:execute;复审卡 parents=[rework 卡] 直接命中);
87
+ // rework source 为 DT 卡等未命中场景 fail-safe → could-not-replay 不执行。
88
+ return { results: await checkIssueEvidence({
89
+ issues,
90
+ replayEnabled: cfg.enabled, timeoutMs: cfg.timeoutMs, allowPrefixes: cfg.allowPrefixes,
91
+ forbidden: configProvider.getEffective().gates?.forbidden,
92
+ worktreeDir: resolveTargetWorktree(parentsWithMeta),
93
+ readFile: (p) => readFile(p, 'utf8').catch(() => null),
94
+ }) };
95
+ }
96
+ catch (error) {
97
+ console.error('[dsh-swarm] evidence check hook failed (non-blocking): ' + String(error));
98
+ return null;
99
+ }
100
+ });
51
101
  }
52
102
  }
103
+ function gitCurrentBranch(wt) {
104
+ return new Promise((resolve) => {
105
+ const child = spawn('git', ['-C', wt, 'rev-parse', '--abbrev-ref', 'HEAD'], { stdio: ['ignore', 'pipe', 'pipe'] });
106
+ let out = '';
107
+ child.stdout.on('data', (c) => { out += c.toString('utf8'); });
108
+ child.on('error', () => resolve(null));
109
+ child.on('close', (code) => resolve(code === 0 ? out.trim() || null : null));
110
+ });
111
+ }
112
+ /** git diff --name-only base...HEAD;任何失败 → null(gate-policy 保守放行)。 */
113
+ function gitDiffNames(wt, base) {
114
+ return new Promise((resolve) => {
115
+ const child = spawn('git', ['-C', wt, 'diff', '--name-only', `${base}...HEAD`], { stdio: ['ignore', 'pipe', 'pipe'] });
116
+ let out = '';
117
+ child.stdout.on('data', (c) => { out += c.toString('utf8'); });
118
+ child.on('error', () => resolve(null));
119
+ child.on('close', (code) => {
120
+ if (code !== 0)
121
+ return resolve(null);
122
+ resolve(out.split('\n').map((s) => s.trim()).filter(Boolean));
123
+ });
124
+ });
125
+ }
@@ -93,12 +93,20 @@ export function buildKanbanTools(service, getCaller) {
93
93
  parameters: {
94
94
  taskId: { type: 'string', required: true },
95
95
  summary: { type: 'string', required: true, description: 'Human-readable completion summary' },
96
- metadata: { type: 'json', description: 'Machine-readable handoff: changed_files/verification/kb_url... W3/kb may add report = { requirement, status, branch, tasks: [{ text, done }], acceptance, verification, leftovers?, todos? } (delivery-report fields; tasks mirrors the OpenSpec plan checklist openspec/changes/<id>/tasks.md verbatim, done = checked)' },
96
+ // metadata 用官方 object schema(2026-09-21:{type:'json'} 不施加约束,模型双重编码字符串静默
97
+ // 穿透到 delivery 闸才爆出误导性 "delivery required"——open object = 必须对象、键值任意,字符串
98
+ // 由运行时 ToolArgsError "must be an object" 单轮拦截。additionalProperties 必填(DSL 硬规则)。
99
+ metadata: { type: 'object', additionalProperties: true, description: 'Machine-readable handoff: changed_files/verification/kb_url... W3/kb may add report = { requirement, status, branch, tasks: [{ text, done }], acceptance, verification, leftovers?, todos? } (delivery-report fields; tasks mirrors the OpenSpec plan checklist openspec/changes/<id>/tasks.md verbatim, done = checked). Pass the object itself — never a JSON-encoded string.' },
97
100
  },
98
101
  output: { schema: { type: 'json' }, render: (_a, v) => [{ type: 'text', text: JSON.stringify(v) }] },
99
102
  async execute(args) {
100
103
  const caller = getCaller();
101
- const done = await service.completeTask(args.taskId, { summary: args.summary, metadata: args.metadata ?? {}, completedAt: Date.now() }, caller.actor, { boundTaskId: caller.boundTaskId });
104
+ // 运行时诚实检查(纵深):schema 已强制 object,此处拦截直调/回归路径——绝不静默强转字符串
105
+ const meta = args.metadata;
106
+ if (meta !== undefined && (typeof meta !== 'object' || Array.isArray(meta))) {
107
+ throw new Error(`metadata must be a JSON object (got ${Array.isArray(meta) ? 'array' : typeof meta}) — pass the object itself, never a JSON-encoded string`);
108
+ }
109
+ const done = await service.completeTask(args.taskId, { summary: args.summary, metadata: (meta ?? {}), completedAt: Date.now() }, caller.actor, { boundTaskId: caller.boundTaskId });
102
110
  return done;
103
111
  },
104
112
  }),
@@ -2,7 +2,7 @@ import type { Context } from '@deepseek-ai/cordis';
2
2
  import type { PrefixRoutes } from '../config.js';
3
3
  import type { ConfigProvider } from '../services/config-provider.js';
4
4
  import { type PlanningToolDeps } from './planning-tools.js';
5
- import type { PlanningChecklist } from '../domain/planning-checklist.js';
5
+ import { type PlanningChecklist } from '../domain/planning-checklist.js';
6
6
  /** v2 规划上下文(/plan: 捕获 → planning_checklist_save 回写 → /openspec: 建链)。模块级内存,随插件进程存活。 */
7
7
  export interface PlanningContext {
8
8
  workspaceDir: string | null;
@@ -10,7 +10,9 @@ import { sendChainReport, createSender, parseSendRequest, resolveReportChainId,
10
10
  import { recallMemoryIndex, searchChecklists } from '../wiki/memory-recall.js';
11
11
  import { buildPlanningGuidance } from '../routes/planning-driver.js';
12
12
  import { attachSessionToWorkspace, resolveOrCreateWorkspace } from '../dispatcher/workspace-attach.js';
13
+ import { sessionPresetOf } from '../dispatcher/session-preset.js';
13
14
  import { PREFETCH_MANIFEST_SCHEMA } from '../domain/prefetch-manifest.js';
15
+ import { extractChecklistJson } from '../domain/planning-checklist.js';
14
16
  import { WikiVaultClient } from '../wiki/wiki-vault-client.js';
15
17
  import { LocalWikiClient } from '../wiki/local-kb-client.js';
16
18
  import { ensureLocalKbRoot } from '../wiki/local-kb.js';
@@ -60,9 +62,9 @@ const RECOVERY_KB_GUIDANCE = (routes, candidates) => `
60
62
  ${candidates.map((c) => '- ' + c).join('\n')}
61
63
  恢复步骤(严格顺序):
62
64
  1. 读取候选页内容,对照当前需求判定哪一页是本次需求的需求澄清清单(页首行标题为「# 【需求】<需求名>」);
63
- 2. 消化该页内容,重建结构化 PlanningChecklist(spec 六段 + manifest + clarifications + doubts + risks(风险点,若有),requirementName 取页标题中【需求】后的名称;恢复重建时 clarifications 允许登记单条无澄清说明,如 {"q": "本轮无澄清(恢复重建)", "a": "<来源页或原因>"},其余场景 clarifications 禁止为空);
64
- 3. 调 planning_checklist_save(checklist, restoreRef=<该候选页路径>) 回存(覆盖原页,勿产生重复页);
65
- 4. 回存成功后提示用户重新发送 ${routes.openspec} 确认。
65
+ 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 禁止为空);
67
+ 4. legacy 页重建后调 planning_checklist_save(checklist, restoreRef=<该候选页路径>) 回存(覆盖原页,勿产生重复页),成功后提示用户重新发送 ${routes.openspec} 确认。
66
68
  禁止:跳过恢复直接建链建卡;猜测清单内容;把恢复失败归因于"重试/进程检查"之外的任何原因。
67
69
  `;
68
70
  const RECOVERY_NONE_GUIDANCE = (routes) => `
@@ -131,16 +133,6 @@ function buildFreeSendGuidance(routes, query, dm) {
131
133
  '投递结果只向用户确认成功与目标,不复述正文;失败时把 error/guidance 原样转告,勿编造原因。',
132
134
  ].join('\n');
133
135
  }
134
- /** 当前会话 preset(多机器人投递按它匹配机器人的默认 dsh 模式)。
135
- * header.agentPreset 缺省(用默认模式创建的会话可能不落该字段)→ 回落到 agentPresets.defaultId;
136
- * 两者都拿不到返回空串,调用方按「无模式匹配」处理(走默认机器人/交互,绝不猜)。 */
137
- function sessionPresetOf(ctx, exec) {
138
- const fromHeader = exec?.agent?.session?.header?.agentPreset;
139
- if (typeof fromHeader === 'string' && fromHeader.trim())
140
- return fromHeader.trim();
141
- const presets = ctx.get?.('agentPresets');
142
- return typeof presets?.defaultId === 'string' ? presets.defaultId : '';
143
- }
144
136
  /** v2 主会话工具面:/plan: 捕获规划上下文(零副作用)→ planning_checklist_save 回写 → /openspec: 建链。
145
137
  * 工具面 = kanban_route + 只读 kanban 子集 + spec_card_view + planning 工具;
146
138
  * 无 spec_card_edit/approve、无 kanban_create/complete/block(主会话越权写由工具面裁剪 + prefetch 子代理只读护栏双保险)。 */
@@ -247,7 +239,7 @@ export function registerMainSessionTools(ctx, configProvider) {
247
239
  const targetOpts = {
248
240
  retryDelaysMs: [], manual: true,
249
241
  targetKind: (parsed.dm ? 'user' : 'group'),
250
- sessionPreset: sessionPresetOf(ctx, exec),
242
+ sessionPreset: sessionPresetOf(ctx, exec?.agent),
251
243
  agent: exec?.agent,
252
244
  };
253
245
  const deliver = async (variant, query) => {
@@ -279,13 +271,9 @@ export function registerMainSessionTools(ctx, configProvider) {
279
271
  }
280
272
  if (plan.kind === 'none')
281
273
  return { kind: 'none' };
282
- // 路由1(内存):planningBySession 命中 → 直接建链
283
- const pctx = planningBySession.get('session_main');
284
- if (pctx?.checklist && pctx.checklistRef) {
285
- // 恢复补捕①:内存有清单但 workspaceDir=null(主 agent 重启后清单经 KB 恢复、异常态)→
286
- // 从 exec.agent.session.header.cwd 捡回工作区,路由1 继续正常建链。已有值绝不重解析
287
- // (不重复弹 ask,对齐下方 /plan: 分支注释顾虑);解析 null(无 cwd/无通道/用户跳过)保持
288
- // 现状 → 走 handleOpenspecRoute 的 workspace-unknown fail-fast(不吞错、不猜测路径)。
274
+ // 建链共用段(2026-09-21 从路由1 提取):恢复补捕① + workspace-mismatch 闸2 + handleOpenspecRoute。
275
+ // 路由1(内存命中)与路由2(KB 机读恢复命中)同走此函数,建链语义单一事实源。
276
+ const openChainFromContext = async (pctx) => {
289
277
  if (!pctx.workspaceDir) {
290
278
  const headerCwd = exec?.agent?.session?.header?.cwd ?? null;
291
279
  const workspaceDir = await resolveOrCreateWorkspace(ctx, headerCwd, '主 agent 会话(/openspec: 恢复)');
@@ -312,11 +300,18 @@ export function registerMainSessionTools(ctx, configProvider) {
312
300
  kind: 'openspec', chainId: r.chainId, specCardId: r.specCardId, approved: true, firstCard: r.firstCard,
313
301
  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 }),
314
302
  };
303
+ };
304
+ // 路由1(内存):planningBySession 命中 → 直接建链
305
+ const pctx = planningBySession.get('session_main');
306
+ if (pctx?.checklist && pctx.checklistRef) {
307
+ // 恢复补捕①:内存有清单但 workspaceDir=null(主 agent 重启后清单经 KB 恢复、异常态)→
308
+ // 从 exec.agent.session.header.cwd 捡回工作区(已有值绝不重解析,不重复弹 ask)。
309
+ return await openChainFromContext({ ...pctx, checklist: pctx.checklist, checklistRef: pctx.checklistRef });
315
310
  }
316
- // 路由2(知识库):内存丢失(插件重启)→ 搜 KB 候选清单页供 LLM 读页重建;搜不到/不可达 → 两条路皆空
317
- // 恢复补捕②:无内存条目或工作区未捕获 → 从 header.cwd 捡回,写入 planningBySession(checklist 等
318
- // 字段保持 cur 或空缺省),后续 planning_checklist_save 触发 onChecklistSaved 的 { ...cur } 展开自然
319
- // 带上 workspaceDir。解析失败不阻塞恢复 guidance 返回(行为同现状,只是少捕一次)。
311
+ // 路由2(知识库):内存丢失(插件重启)→ 优先机读恢复(2026-09-21 矫正):候选页尾含无损 JSON 段
312
+ // → extractChecklistJson 提取 → 灌内存 → 当场建链(LLM 零参与,杜绝读页重建的失真/双重编码/回存绕路);
313
+ // 无段/损坏/校验不过(legacy 页)→ 回退既有 LLM 读页重建+回存流程。搜不到/不可达 → 两条路皆空。
314
+ // 恢复补捕②:无内存条目或工作区未捕获 → 从 header.cwd 捡回,写入 planningBySession。
320
315
  if (!planningBySession.get('session_main')?.workspaceDir) {
321
316
  const headerCwd = exec?.agent?.session?.header?.cwd ?? null;
322
317
  const workspaceDir = await resolveOrCreateWorkspace(ctx, headerCwd, '主 agent 会话(/openspec: 恢复)');
@@ -330,6 +325,24 @@ export function registerMainSessionTools(ctx, configProvider) {
330
325
  candidates = await searchChecklists(wiki, kbMode === 'local' ? LOCAL_CHECKLIST_PREFIX : (configProvider.getEffective().wikiVault?.pagePrefix ?? 'projects/'));
331
326
  }
332
327
  catch { /* KB 不可达/搜索失败 → 候选为空,走两条路皆空分支 */ }
328
+ let machineRestored = null;
329
+ for (const cpath of candidates) {
330
+ try {
331
+ const d = await wiki.read(cpath);
332
+ const c = extractChecklistJson(d.rawMd);
333
+ if (c) {
334
+ machineRestored = { path: cpath, checklist: c };
335
+ break;
336
+ }
337
+ }
338
+ catch { /* 单页读取失败 → 试下一候选 */ }
339
+ }
340
+ if (machineRestored) {
341
+ const cur = planningBySession.get('session_main') ?? { workspaceDir: null, sessionId: 'session_main', checklist: null, checklistRef: null, checklistSource: null, requirementName: null };
342
+ const rpctx = { ...cur, checklist: machineRestored.checklist, checklistRef: machineRestored.path, checklistSource: 'kb', requirementName: machineRestored.checklist.requirementName ?? null };
343
+ planningBySession.set('session_main', rpctx);
344
+ return await openChainFromContext(rpctx);
345
+ }
333
346
  return {
334
347
  kind: 'openspec', approved: false, reason: 'no-checklist',
335
348
  recovery: candidates.length > 0 ? 'kb' : 'none',
@@ -360,13 +373,15 @@ export function registerMainSessionTools(ctx, configProvider) {
360
373
  const dm = args.dm === true;
361
374
  const deliver = createSender(ctx, service, configProvider, {
362
375
  retryDelaysMs: [], manual: true, targetKind: dm ? 'user' : 'group',
363
- sessionPreset: sessionPresetOf(ctx, exec), agent: exec?.agent,
376
+ sessionPreset: sessionPresetOf(ctx, exec?.agent), agent: exec?.agent,
364
377
  });
365
378
  const r = await deliver('free-send', text);
366
379
  if (!r.ok) {
380
+ // 注意:返回对象不得带 undefined 值——工具输出要过 lossless-JSON 校验,undefined 会让模型只看到
381
+ // 「value is not lossless JSON」而看不到真实错误(实测踩过)。
367
382
  return {
368
383
  kind: 'send', mode: 'free', dm, error: r.error,
369
- guidance: r.error.startsWith(DSH_IM_MISSING_PREFIX) ? DSH_IM_MISSING_GUIDANCE : undefined,
384
+ ...(r.error.startsWith(DSH_IM_MISSING_PREFIX) ? { guidance: DSH_IM_MISSING_GUIDANCE } : {}),
370
385
  };
371
386
  }
372
387
  return {
@@ -20,7 +20,93 @@ 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
+ },
107
+ },
108
+ restoreRef: { type: 'string', description: 'Optional KB page path to overwrite in place (recovery path); omit for new save' },
109
+ },
24
110
  output: { schema: { type: 'json' }, render: (_a, v) => [{ type: 'text', text: JSON.stringify(v) }] },
25
111
  async execute(args) {
26
112
  const caller = deps.getCaller();
@@ -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) {