@joekytc/dsh-swarm 0.3.8 → 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.
@@ -5,6 +5,7 @@ import { probeOcr } from '../services/ocr-cli.js';
5
5
  import { installRoleTools, buildReadOnlyWriteGuard, buildDTWriteGuard, buildPlanWriteGuard, buildKbWriteGuard, registerDtTaskChain, unregisterDtTaskChain } from '../roles/toolsets.js';
6
6
  import { ensureLocalKbRoot } from '../wiki/local-kb.js';
7
7
  import { installCleanFsTools } from '../roles/clean-fs-tools.js';
8
+ import { mountOrRecompose } from '../roles/preset-installer.js';
8
9
  import { buildModelCandidates, isModelUnavailableError } from './model-candidates.js';
9
10
  import { toolName } from './session-events.js';
10
11
  import { attachSessionToWorkspace, resolveOrCreateWorkspace } from './workspace-attach.js';
@@ -271,7 +272,10 @@ ${task.body}`);
271
272
  if (presets) {
272
273
  const presetId = 'kanban-' + task.assignee;
273
274
  try {
274
- await presets.mount(agentCtx, presetId);
275
+ // mountOrRecompose(2026-09-21):blocked/unblock 多轮唤醒 resume 同一 session 时,
276
+ // 二次 mount 会撞宿主 dsh-scope 一次性绑定(already bound to a parent)——收敛为
277
+ // 已绑走 recompose 官方重链;其余挂载错误仍降级放行(角色工具面照常注册)。
278
+ await mountOrRecompose(presets, agentCtx, presetId);
275
279
  console.error('[dsh-swarm][debug] preset mounted ' + presetId + ' role=' + task.assignee + ' task=' + taskId);
276
280
  }
277
281
  catch (err) {
@@ -85,6 +85,14 @@ export declare class Dispatcher {
85
85
  /** 启动 reconcile:剔除事件流中已不存在的链的编排 entry(历史残留/外部 purge)。
86
86
  * 原地删除并返回被移除的 chainId 列表(调用方负责持久化与日志)。 */
87
87
  export declare function reconcileOrchestrations<T>(orch: Map<string, T>, chains: Set<string>): string[];
88
+ /** 启动补扫候选:快照中全部 executing 状态链(2026-09-21 重启吞编排轮事故)。
89
+ * EventWaker 纯事件驱动,历史 wakeable 事件(如 PT completed)被重启游标越过即永不重放;
90
+ * 启动时按链状态补唤醒,与 EventWaker 的实时驱动互补。 */
91
+ export declare function collectExecutingChains(snap: {
92
+ chains: Map<string, {
93
+ status: string;
94
+ }>;
95
+ }): string[];
88
96
  /** 启动 reconcile:进程重启会杀死 runner 的 whenIdle 协程,上次遗留的 running 卡无人收尾,
89
97
  * 看门狗默认 4h(staleTimeoutSeconds=14400)才回收——重启后立即把 running 孤儿卡收敛为 blocked
90
98
  * (system comment + blockTask),中断显形且可重派续跑(重派将 resume 同一会话,进度保留)。
@@ -311,6 +311,17 @@ export function reconcileOrchestrations(orch, chains) {
311
311
  orch.delete(k);
312
312
  return removed;
313
313
  }
314
+ /** 启动补扫候选:快照中全部 executing 状态链(2026-09-21 重启吞编排轮事故)。
315
+ * EventWaker 纯事件驱动,历史 wakeable 事件(如 PT completed)被重启游标越过即永不重放;
316
+ * 启动时按链状态补唤醒,与 EventWaker 的实时驱动互补。 */
317
+ export function collectExecutingChains(snap) {
318
+ const out = [];
319
+ for (const [chainId, chain] of snap.chains) {
320
+ if (chain.status === 'executing')
321
+ out.push(chainId);
322
+ }
323
+ return out;
324
+ }
314
325
  /** 启动 reconcile:进程重启会杀死 runner 的 whenIdle 协程,上次遗留的 running 卡无人收尾,
315
326
  * 看门狗默认 4h(staleTimeoutSeconds=14400)才回收——重启后立即把 running 孤儿卡收敛为 blocked
316
327
  * (system comment + blockTask),中断显形且可重派续跑(重派将 resume 同一会话,进度保留)。
@@ -523,6 +534,16 @@ function startDispatcherInner(ctx, configProvider, storageDir, logFile, provider
523
534
  saveOrchs();
524
535
  logToFile(logFile, '[orch-reconcile] removed dead entries: ' + removed.join(','));
525
536
  }
537
+ // 启动补扫(2026-09-21):EventWaker 纯事件驱动——实例重启后游标已越过「已完成但未推进」
538
+ // 的 wakeable 事件(如 PT completed)时编排轮被吞(历史事件不重放),executing 链静默停滞
539
+ // (销服一体 V3.0 链实测:PT done 后 W2 永不建卡)。启动时对全部 executing 链补一次 wakeV
540
+ // (幂等:wakeV 按 orchestration/链快照决策 + 在途合并;无可推进阶段时无害空转)。
541
+ for (const chainId of collectExecutingChains(snap)) {
542
+ logToFile(logFile, '[startup-rewake] executing chain=' + chainId);
543
+ void vOrch.wakeV(chainId).catch((err) => {
544
+ logToFile(logFile, '[startup-rewake] failed chain=' + chainId + ' ' + String(err));
545
+ });
546
+ }
526
547
  }
527
548
  catch (err) {
528
549
  logToFile(logFile, '[orch-reconcile] failed: ' + String(err));
@@ -1,4 +1,5 @@
1
1
  import { installRoleTools } from '../roles/toolsets.js';
2
+ import { mountOrRecompose } from '../roles/preset-installer.js';
2
3
  import { resolveTaskParents } from '../domain/task-parents.js';
3
4
  import { missingParentDelivery } from '../domain/delivery-contract.js';
4
5
  import { buildRepoSlug } from '../domain/memory.js';
@@ -636,9 +637,11 @@ export class VOrchestrator {
636
637
  // 无 persona 基座不得裸奔。抛错 → create/resume 失败 → getVAgent 抛 → 主推进路径并入
637
638
  // 异常收场(stall 计数,超限 [create-failed] + blockChain);候选循环对非 model
638
639
  // 错误立即失败(isModelUnavailableError=false → throw),不会被候选切换吞掉。
640
+ // mountOrRecompose(2026-09-21):V 会话跨进程重启 resume / 重入 setup 时二次 mount
641
+ // 会撞宿主 dsh-scope 一次性绑定——already-bound 收敛为 recompose 官方重链,其余照抛。
639
642
  const vSessionId = orch.sessionId ?? 'kbn-v-' + orch.chainId;
640
643
  try {
641
- await presets.mount(agentCtx, V_SESSION_PRESET_ID);
644
+ await mountOrRecompose(presets, agentCtx, V_SESSION_PRESET_ID);
642
645
  }
643
646
  catch (err) {
644
647
  throw new Error(V_SESSION_PRESET_ID + ' preset mount failed for ' + vSessionId + ': ' + String(err));
@@ -1,6 +1,7 @@
1
1
  import type { BoardState, Handoff, Role, TaskMode } from './types.js';
2
2
  export declare function requiredDeliveryKeys(assignee: Role, mode: TaskMode): string[];
3
- /** v2:pt_decision 结构校验(needed 布尔必填;needed=true 时 reason 必填)。返回缺失键列表。 */
3
+ /** v2:pt_decision 结构校验(needed 布尔必填;needed=true 时 reason 必填)。返回缺失键列表。
4
+ * 2026-09-21:metadata 非对象(双重编码字符串)时报类型真因,不产生误导性 'pt_decision' 缺键文案。 */
4
5
  export declare function missingPtDecisionKeys(handoff: Handoff | undefined): string[];
5
6
  /** 缺失的交付键(存在但为空的字符串/非字符串均视为缺失;pt_decision 走结构校验透传细粒度键)。
6
7
  * 可选 kbUrlBase:提供时对 w:kb 的 kb_url 做 host 前缀硬校验(防 LLM 手写错域名)、对 page_path 做
@@ -17,9 +17,14 @@ const REQUIRED_DELIVERY = {
17
17
  export function requiredDeliveryKeys(assignee, mode) {
18
18
  return REQUIRED_DELIVERY[`${assignee}:${mode}`] ?? [];
19
19
  }
20
- /** v2:pt_decision 结构校验(needed 布尔必填;needed=true 时 reason 必填)。返回缺失键列表。 */
20
+ /** v2:pt_decision 结构校验(needed 布尔必填;needed=true 时 reason 必填)。返回缺失键列表。
21
+ * 2026-09-21:metadata 非对象(双重编码字符串)时报类型真因,不产生误导性 'pt_decision' 缺键文案。 */
21
22
  export function missingPtDecisionKeys(handoff) {
22
- const d = (handoff?.metadata ?? {})['pt_decision'];
23
+ const rawMeta = handoff?.metadata ?? {};
24
+ if (typeof rawMeta !== 'object' || rawMeta === null) {
25
+ return [`pt_decision (metadata must be an object, got ${Array.isArray(rawMeta) ? 'array' : typeof rawMeta})`];
26
+ }
27
+ const d = rawMeta['pt_decision'];
23
28
  if (typeof d !== 'object' || d === null)
24
29
  return ['pt_decision'];
25
30
  const o = d;
@@ -39,7 +44,11 @@ export function missingDeliveryKeys(assignee, mode, handoff, kbUrlBase) {
39
44
  return [];
40
45
  if (!handoff)
41
46
  return keys.slice();
42
- const m = handoff.metadata ?? {};
47
+ // 2026-09-21:metadata 非对象(模型双重编码字符串穿透 {type:'json'} 宽参数)时,m[k] 对字符串
48
+ // 索引静默 undefined → 误导性 "delivery required" 缺键报错。此处报类型真因(纵深防线)。
49
+ const rawMeta = handoff.metadata ?? {};
50
+ const metaOk = typeof rawMeta === 'object' && rawMeta !== null && !Array.isArray(rawMeta);
51
+ const m = metaOk ? rawMeta : null;
43
52
  // 推导修正:kbUrlBase === undefined 才是宽松;'' 是 local strict
44
53
  const hasBase = kbUrlBase !== undefined;
45
54
  const base = hasBase ? kbUrlBase.replace(/\/$/, '') : null;
@@ -50,6 +59,10 @@ export function missingDeliveryKeys(assignee, mode, handoff, kbUrlBase) {
50
59
  missing.push(...missingPtDecisionKeys(handoff));
51
60
  continue;
52
61
  }
62
+ if (!metaOk) {
63
+ missing.push(`${k} (metadata must be an object, got ${Array.isArray(rawMeta) ? 'array' : typeof rawMeta})`);
64
+ continue;
65
+ }
53
66
  const v = m[k];
54
67
  if (strict && k === 'kb_url' && base === '') {
55
68
  // local 模式:kb_url 必须显式空串(非缺失键)
@@ -25,3 +25,6 @@ export declare function validatePlanningChecklist(raw: unknown): string[];
25
25
  export declare function buildChecklistTitle(c: PlanningChecklist): string;
26
26
  /** 需求澄清清单落库 body:标题【需求】+ 各段可读 markdown(非裸 JSON)。KB 与临时目录两分支共用。 */
27
27
  export declare function formatChecklistBody(c: PlanningChecklist): string;
28
+ /** 从清单页原文提取机读 PlanningChecklist(无损恢复路径):无段/损坏/校验不过一律返回 null,
29
+ * 由调用方回退 legacy LLM 重建流程。domain 纯函数,无副作用。 */
30
+ export declare function extractChecklistJson(pageMd: string): PlanningChecklist | null;
@@ -151,5 +151,32 @@ export function formatChecklistBody(c) {
151
151
  }
152
152
  if (doubts.length === 0)
153
153
  lines.push('(无)');
154
+ // 机读段(2026-09-21 恢复流程矫正):页正文是人读 markdown(有损),重启恢复若让 LLM 读页重建
155
+ // 再回存 = 有损再创作 + 双重编码风险(销服一体清单 7 轮失败即发生在恢复场景)。页尾内嵌无损
156
+ // JSON 单行段,/openspec: 路由2 直接提取灌内存建链,LLM 零参与;HTML 注释对人读零干扰。
157
+ const machine = JSON.stringify({ requirementName: c.requirementName ?? null, spec: c.spec, manifest: c.manifest, clarifications: c.clarifications, doubts: c.doubts, risks: c.risks ?? [] });
158
+ lines.push('', `<!-- dsh-swarm:checklist-json ${machine} -->`);
154
159
  return lines.join('\n').replace(/\n{3,}/g, '\n\n');
155
160
  }
161
+ const CHECKLIST_JSON_MARK = '<!-- dsh-swarm:checklist-json ';
162
+ /** 从清单页原文提取机读 PlanningChecklist(无损恢复路径):无段/损坏/校验不过一律返回 null,
163
+ * 由调用方回退 legacy LLM 重建流程。domain 纯函数,无副作用。 */
164
+ export function extractChecklistJson(pageMd) {
165
+ const start = pageMd.indexOf(CHECKLIST_JSON_MARK);
166
+ if (start < 0)
167
+ return null;
168
+ const jsonStart = start + CHECKLIST_JSON_MARK.length;
169
+ const end = pageMd.indexOf('-->', jsonStart);
170
+ if (end < 0)
171
+ return null;
172
+ let raw;
173
+ try {
174
+ raw = JSON.parse(pageMd.slice(jsonStart, end).trim());
175
+ }
176
+ catch {
177
+ return null;
178
+ }
179
+ if (validatePlanningChecklist(raw).length > 0)
180
+ return null;
181
+ return raw;
182
+ }
@@ -52,22 +52,33 @@ export function validatePrefetchManifest(raw) {
52
52
  errors.push('manifest.files must be an array');
53
53
  }
54
54
  else {
55
+ // files[] 逐条错误先收集再聚合(2026-09-21:26 条同文错误墙稀释有效信息,模型只见截断前几条)。
56
+ // 完全相同的消息合并为 "<msg> ×N",不同消息保持原样与原序。
57
+ const fileErrors = [];
55
58
  for (const f of m['files']) {
56
59
  if (typeof f !== 'object' || f === null) {
57
- errors.push('manifest.files entry must be an object');
60
+ fileErrors.push('manifest.files entry must be an object');
58
61
  continue;
59
62
  }
60
63
  const e = f;
61
64
  if (typeof e['path'] !== 'string' || e['path'].trim().length === 0) {
62
- errors.push(`manifest.files[].path (got: ${JSON.stringify(e['path'])})`);
65
+ fileErrors.push(`manifest.files[].path (got: ${JSON.stringify(e['path'])})`);
63
66
  }
64
67
  if (typeof e['expected'] !== 'string' || !EXPECTED_VALUES.has(e['expected'])) {
65
- errors.push(`manifest.files[].expected (got: ${JSON.stringify(e['expected'])})`);
68
+ fileErrors.push(`manifest.files[].expected (got: ${JSON.stringify(e['expected'])})`);
66
69
  }
67
70
  if (e['expected'] === 'content-hash' && (typeof e['note'] !== 'string' || e['note'].trim().length === 0)) {
68
- errors.push(`manifest.files[].note required for content-hash (got: ${JSON.stringify(e['note'])})`);
71
+ fileErrors.push(`manifest.files[].note required for content-hash (got: ${JSON.stringify(e['note'])})`);
69
72
  }
70
73
  }
74
+ errors.push(...tallyIdentical(fileErrors));
71
75
  }
72
76
  return errors;
73
77
  }
78
+ /** 相同消息聚合计数:["a","a","b"] → ["a ×2","b"]。domain 纯函数,无副作用。 */
79
+ function tallyIdentical(messages) {
80
+ const counts = new Map();
81
+ for (const msg of messages)
82
+ counts.set(msg, (counts.get(msg) ?? 0) + 1);
83
+ return [...counts.entries()].map(([msg, n]) => (n > 1 ? `${msg} ×${n}` : msg));
84
+ }
@@ -6,3 +6,17 @@ export declare function userPresetsRoot(): string;
6
6
  * 尽力而为:单 preset 写失败(用户预设根不可写等)仅告警不抛出,
7
7
  * 后续 agentPresets.mount('kanban-<role>') 失败由 runner 降级日志兜底。 */
8
8
  export declare function installRolePresets(): string[];
9
+ /** 角色 preset 挂载的容器面(dsh-agent-presets roster 的最小结构类型)。 */
10
+ export interface PresetMountLike {
11
+ mount(ctx: unknown, id: string): Promise<unknown>;
12
+ /** 官方重链入口(0.1.2-rc.1 起提供):已绑定 → binding.rebind;未绑定 → bind。 */
13
+ recompose?(ctx: unknown, id: string): Promise<unknown>;
14
+ }
15
+ /**
16
+ * preset 挂载收敛入口(2026-09-21 事故修复):宿主 dsh-scope 的 scope key 绑定在进程
17
+ * 生命周期内一次性(bindScopeParent 重复调用抛 "already bound to a parent"),官方重链
18
+ * 入口是 recompose(已绑 → binding.rebind)。同 agentKey 的二次 setup(P/W/D 卡 blocked/
19
+ * unblock 多轮唤醒 resume 同一 session、live 复用 re-setup 补挂)此前直接二次 mount 必炸。
20
+ * 收敛语义:mount 失败且系 already-bound → recompose 重链;其余错误(组合不可用等)原样上抛。
21
+ */
22
+ export declare function mountOrRecompose(presets: PresetMountLike, agentCtx: unknown, presetId: string): Promise<void>;
@@ -51,3 +51,20 @@ export function installRolePresets() {
51
51
  }
52
52
  return installed;
53
53
  }
54
+ /**
55
+ * preset 挂载收敛入口(2026-09-21 事故修复):宿主 dsh-scope 的 scope key 绑定在进程
56
+ * 生命周期内一次性(bindScopeParent 重复调用抛 "already bound to a parent"),官方重链
57
+ * 入口是 recompose(已绑 → binding.rebind)。同 agentKey 的二次 setup(P/W/D 卡 blocked/
58
+ * unblock 多轮唤醒 resume 同一 session、live 复用 re-setup 补挂)此前直接二次 mount 必炸。
59
+ * 收敛语义:mount 失败且系 already-bound → recompose 重链;其余错误(组合不可用等)原样上抛。
60
+ */
61
+ export async function mountOrRecompose(presets, agentCtx, presetId) {
62
+ try {
63
+ await presets.mount(agentCtx, presetId);
64
+ }
65
+ catch (err) {
66
+ if (!String(err).includes('already bound to a parent') || typeof presets.recompose !== 'function')
67
+ throw err;
68
+ await presets.recompose(agentCtx, presetId);
69
+ }
70
+ }
@@ -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;
@@ -12,6 +12,7 @@ import { buildPlanningGuidance } from '../routes/planning-driver.js';
12
12
  import { attachSessionToWorkspace, resolveOrCreateWorkspace } from '../dispatcher/workspace-attach.js';
13
13
  import { sessionPresetOf } from '../dispatcher/session-preset.js';
14
14
  import { PREFETCH_MANIFEST_SCHEMA } from '../domain/prefetch-manifest.js';
15
+ import { extractChecklistJson } from '../domain/planning-checklist.js';
15
16
  import { WikiVaultClient } from '../wiki/wiki-vault-client.js';
16
17
  import { LocalWikiClient } from '../wiki/local-kb-client.js';
17
18
  import { ensureLocalKbRoot } from '../wiki/local-kb.js';
@@ -61,9 +62,9 @@ const RECOVERY_KB_GUIDANCE = (routes, candidates) => `
61
62
  ${candidates.map((c) => '- ' + c).join('\n')}
62
63
  恢复步骤(严格顺序):
63
64
  1. 读取候选页内容,对照当前需求判定哪一页是本次需求的需求澄清清单(页首行标题为「# 【需求】<需求名>」);
64
- 2. 消化该页内容,重建结构化 PlanningChecklist(spec 六段 + manifest + clarifications + doubts + risks(风险点,若有),requirementName 取页标题中【需求】后的名称;恢复重建时 clarifications 允许登记单条无澄清说明,如 {"q": "本轮无澄清(恢复重建)", "a": "<来源页或原因>"},其余场景 clarifications 禁止为空);
65
- 3. 调 planning_checklist_save(checklist, restoreRef=<该候选页路径>) 回存(覆盖原页,勿产生重复页);
66
- 4. 回存成功后提示用户重新发送 ${routes.openspec} 确认。
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} 确认。
67
68
  禁止:跳过恢复直接建链建卡;猜测清单内容;把恢复失败归因于"重试/进程检查"之外的任何原因。
68
69
  `;
69
70
  const RECOVERY_NONE_GUIDANCE = (routes) => `
@@ -270,13 +271,9 @@ export function registerMainSessionTools(ctx, configProvider) {
270
271
  }
271
272
  if (plan.kind === 'none')
272
273
  return { kind: 'none' };
273
- // 路由1(内存):planningBySession 命中 → 直接建链
274
- const pctx = planningBySession.get('session_main');
275
- if (pctx?.checklist && pctx.checklistRef) {
276
- // 恢复补捕①:内存有清单但 workspaceDir=null(主 agent 重启后清单经 KB 恢复、异常态)→
277
- // 从 exec.agent.session.header.cwd 捡回工作区,路由1 继续正常建链。已有值绝不重解析
278
- // (不重复弹 ask,对齐下方 /plan: 分支注释顾虑);解析 null(无 cwd/无通道/用户跳过)保持
279
- // 现状 → 走 handleOpenspecRoute 的 workspace-unknown fail-fast(不吞错、不猜测路径)。
274
+ // 建链共用段(2026-09-21 从路由1 提取):恢复补捕① + workspace-mismatch 闸2 + handleOpenspecRoute。
275
+ // 路由1(内存命中)与路由2(KB 机读恢复命中)同走此函数,建链语义单一事实源。
276
+ const openChainFromContext = async (pctx) => {
280
277
  if (!pctx.workspaceDir) {
281
278
  const headerCwd = exec?.agent?.session?.header?.cwd ?? null;
282
279
  const workspaceDir = await resolveOrCreateWorkspace(ctx, headerCwd, '主 agent 会话(/openspec: 恢复)');
@@ -303,11 +300,18 @@ export function registerMainSessionTools(ctx, configProvider) {
303
300
  kind: 'openspec', chainId: r.chainId, specCardId: r.specCardId, approved: true, firstCard: r.firstCard,
304
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 }),
305
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 });
306
310
  }
307
- // 路由2(知识库):内存丢失(插件重启)→ 搜 KB 候选清单页供 LLM 读页重建;搜不到/不可达 → 两条路皆空
308
- // 恢复补捕②:无内存条目或工作区未捕获 → 从 header.cwd 捡回,写入 planningBySession(checklist 等
309
- // 字段保持 cur 或空缺省),后续 planning_checklist_save 触发 onChecklistSaved 的 { ...cur } 展开自然
310
- // 带上 workspaceDir。解析失败不阻塞恢复 guidance 返回(行为同现状,只是少捕一次)。
311
+ // 路由2(知识库):内存丢失(插件重启)→ 优先机读恢复(2026-09-21 矫正):候选页尾含无损 JSON 段
312
+ // → extractChecklistJson 提取 → 灌内存 → 当场建链(LLM 零参与,杜绝读页重建的失真/双重编码/回存绕路);
313
+ // 无段/损坏/校验不过(legacy 页)→ 回退既有 LLM 读页重建+回存流程。搜不到/不可达 → 两条路皆空。
314
+ // 恢复补捕②:无内存条目或工作区未捕获 → 从 header.cwd 捡回,写入 planningBySession。
311
315
  if (!planningBySession.get('session_main')?.workspaceDir) {
312
316
  const headerCwd = exec?.agent?.session?.header?.cwd ?? null;
313
317
  const workspaceDir = await resolveOrCreateWorkspace(ctx, headerCwd, '主 agent 会话(/openspec: 恢复)');
@@ -321,6 +325,24 @@ export function registerMainSessionTools(ctx, configProvider) {
321
325
  candidates = await searchChecklists(wiki, kbMode === 'local' ? LOCAL_CHECKLIST_PREFIX : (configProvider.getEffective().wikiVault?.pagePrefix ?? 'projects/'));
322
326
  }
323
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
+ }
324
346
  return {
325
347
  kind: 'openspec', approved: false, reason: 'no-checklist',
326
348
  recovery: candidates.length > 0 ? 'kb' : 'none',
@@ -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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joekytc/dsh-swarm",
3
- "version": "0.3.8",
3
+ "version": "0.3.9",
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",
@@ -90,4 +90,4 @@
90
90
  "typescript": "^5.8.0",
91
91
  "vitest": "^3.0.0"
92
92
  }
93
- }
93
+ }