@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
package/lib/config.d.ts CHANGED
@@ -58,6 +58,15 @@ export interface KanbanConfig {
58
58
  /** 命令黑名单子串(命中即拒执行)。纵深防御:派生命令由系统从 tdd 生成,正常不触黑名单。 */
59
59
  forbidden: string[];
60
60
  };
61
+ /** 评审证据三级核验(PR2):L1 缺证标记 → L2 纸面核对(零执行)→ L3 重放(真执行 AI 命令,默认关——
62
+ * 开启即视为接受"非沙箱"风险;仅放行已知工具前缀,词边界匹配,npm run 仅固定脚本名)。 */
63
+ evidenceReplay: {
64
+ enabled: boolean;
65
+ /** 重放单条命令超时(ms),到点 SIGKILL。 */
66
+ timeoutMs: number;
67
+ /** 已知工具前缀白名单(词边界匹配命令开头;npm run 仅固定脚本名)。未命中 → 不执行转人工。 */
68
+ allowPrefixes: string[];
69
+ };
61
70
  /** IM 主动投递(企微,2026-09-07 评审决议):W3 收尾/链阻塞时经 dsh-im 投群。
62
71
  * enabled=false(默认)功能关闭;botId/targetId 留空=运行时自动发现(唯一 wecom bot + 唯一已保存群目标)。
63
72
  * 多机器人接入时:botId 留空 → 按会话 preset 匹配机器人(探针读 dsh-im 侧 agentPreset);
@@ -81,9 +90,11 @@ export interface KanbanConfig {
81
90
  };
82
91
  };
83
92
  /** wiki_write 会话级放行的 preset id 白名单(buildStandaloneDtGuard 非独立分支热读)。
84
- * 默认:插件自有 'swarm'(蜂群 preset)/'kanban-w'(W preset)+ 宿主内置 'ptc'
85
- * (2026-09-16 实测:dsh web「蜂群模式」标签会话的 header.agentPreset 实际为宿主
86
- * PTC 模式 id)。宿主 preset 体系演化/部署自定义模式 id 时改配置即可,无需改代码。
93
+ * 默认:插件自有 'swarm'(蜂群 preset)/'kanban-w'(W preset)+ 宿主内置 'ptc'。
94
+ * 历史:'ptc' 原是为「dsh web「蜂群模式」标签会话的 header.agentPreset 实为宿主 PTC id」
95
+ * 打的补丁;2026-09-17 起 preset 判定统一走 session-preset.ts(引擎真相优先),此处
96
+ * 'ptc' 语义变为「显式放行普通 PTC 模式会话」(写面仍由 wiki_write 路径白名单硬约束)。
97
+ * 宿主 preset 体系演化/部署自定义模式 id 时改配置即可,无需改代码。
87
98
  * 写面仍由 wiki_write 工具内 assertAllowedWikiPagePath 五类命名空间白名单硬约束。 */
88
99
  wikiWritePresets: string[];
89
100
  }
package/lib/config.js CHANGED
@@ -57,6 +57,16 @@ export const Config = Schema.object({
57
57
  timeoutMs: Schema.number().min(1000).default(600000),
58
58
  forbidden: Schema.array(Schema.string()).default(['rm -rf /', 'git push']),
59
59
  }).default({ enabled: true, timeoutMs: 600000, forbidden: ['rm -rf /', 'git push'] }),
60
+ evidenceReplay: Schema.object({
61
+ enabled: Schema.boolean().default(false),
62
+ timeoutMs: Schema.number().min(1000).default(600000),
63
+ allowPrefixes: Schema.array(Schema.string()).default([
64
+ 'npx --no-install vitest', 'npm test', 'npm run build', 'npm run typecheck', 'tsc', 'eslint',
65
+ ]),
66
+ }).default({
67
+ enabled: false, timeoutMs: 600000,
68
+ allowPrefixes: ['npx --no-install vitest', 'npm test', 'npm run build', 'npm run typecheck', 'tsc', 'eslint'],
69
+ }),
60
70
  imDelivery: Schema.object({
61
71
  enabled: Schema.boolean().default(false),
62
72
  botId: Schema.string().default(''),
@@ -5,11 +5,13 @@ 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';
11
12
  import { isPathInside, resolveTargetRepoDir } from './target-repo.js';
12
13
  import { injectGitCredentials, resolveGitPatFromCtx } from './git-credentials.js';
14
+ import { slimParentMetadata } from './context-dedup.js';
13
15
  /** 目标仓库在会话工作空间外、已 claim+block 等待用户授权且尚未建会话的任务集合(key=taskId)。
14
16
  * 人工放行后再次调度会重新询问;已授权后从集合移除。仅进程内记忆,重启后从事件日志恢复(block 事件仍在)。 */
15
17
  const permissionBlockedTasks = new Set();
@@ -113,7 +115,7 @@ ${task.body}`);
113
115
  parts.push('## Parent task results');
114
116
  for (const h of parents) {
115
117
  parts.push(`- summary: ${h.summary}`);
116
- parts.push(`- metadata: ${JSON.stringify(h.metadata)}`);
118
+ parts.push(`- metadata: ${JSON.stringify(slimParentMetadata((h.metadata ?? {})))}`);
117
119
  }
118
120
  }
119
121
  // 0.1.0 delegation:D(execute) 目标模式条件注入——spec 卡/父交接命中
@@ -181,12 +183,7 @@ ${task.body}`);
181
183
  const unresolved = issues.filter((i) => i.resolved !== true);
182
184
  parts.push(`- 上一轮评审 ${issues.length} 条 issue:未修复 ${unresolved.length} 条、已修复 ${issues.length - unresolved.length} 条。`);
183
185
  parts.push('- 完整修复清单(含定位、问题原文与建议改法)见任务体「本轮修复清单」节:逐条处理未修复项,只做定点修复,禁止无关联全文重写与任务编号重排。');
184
- if (unresolved.length > 0) {
185
- parts.push('- 未修复项速览:');
186
- for (const i of unresolved) {
187
- parts.push(` - [${i.severity}]${i.legacy ? '[遗留]' : '[新问题]'} ${i.title}${i.location ? `(${i.location})` : ''}`);
188
- }
189
- }
186
+ // PR1 注入去重:未修复项明细不在此重复(完整清单已在 body「本轮修复清单」节,P0-3 单一信息源)
190
187
  }
191
188
  else {
192
189
  parts.push('- 事件流未找到 review/failed 记录;请先核对任务体「本轮修复清单」节,再完成返工。');
@@ -275,7 +272,10 @@ ${task.body}`);
275
272
  if (presets) {
276
273
  const presetId = 'kanban-' + task.assignee;
277
274
  try {
278
- 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);
279
279
  console.error('[dsh-swarm][debug] preset mounted ' + presetId + ' role=' + task.assignee + ' task=' + taskId);
280
280
  }
281
281
  catch (err) {
@@ -19,7 +19,12 @@ import type { AuditEvidence, Task } from '../domain/types.js';
19
19
  * 角色 agent 只写各自任务工作区(workspaces/<chainId>/<taskId>/),
20
20
  * 链工作区根下非任务 id 的条目 = 无主产物(疑似主 agent 越权写)→ 证据。
21
21
  */
22
- export interface ChainAuditorDeps {
22
+ export interface ChainAuditorPresetDeps {
23
+ /** 会话 preset 读取(生产由 dispatcher 注入 session-preset.ts 的统一实现;缺省回落 header
24
+ * ——创建事实,仅供测试/未接线场景保留既有行为)。 */
25
+ readPreset?(agent: unknown): string;
26
+ }
27
+ export interface ChainAuditorDeps extends ChainAuditorPresetDeps {
23
28
  kanban: KanbanService;
24
29
  workspacesRoot: string;
25
30
  /** 活 agent 注册表快照(dispatcher 注入 ctx.agents.list 的适配);测试可伪造。 */
@@ -38,6 +43,7 @@ export declare class ChainAuditor {
38
43
  private readonly kanban;
39
44
  private readonly workspacesRoot;
40
45
  private readonly listLiveAgents;
46
+ private readonly readPreset;
41
47
  constructor(deps: ChainAuditorDeps);
42
48
  /** 执行核对,返回越权证据(空=通过,不阻塞汇报)。
43
49
  * @param workspaceDir 本链发起工作区(Chain.workspaceDir);提供时仅扫描工作区内的会话。 */
@@ -3,6 +3,7 @@ import { readdirSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { eventType, toolArgs, toolName } from './session-events.js';
5
5
  import { isPathInside } from './target-repo.js';
6
+ import { headerPresetOf } from './session-preset.js';
6
7
  /** 直接写工具(无条件视为写能力;只读工具如 read/glob/grep 不算)。 */
7
8
  const DIRECT_WRITE_TOOLS = new Set(['write', 'edit', 'rm', 'mv', 'cp', 'mkdir', 'mkfile']);
8
9
  /** run_code:写能力载体,需结合派发子调用判定。 */
@@ -138,10 +139,12 @@ export class ChainAuditor {
138
139
  kanban;
139
140
  workspacesRoot;
140
141
  listLiveAgents;
142
+ readPreset;
141
143
  constructor(deps) {
142
144
  this.kanban = deps.kanban;
143
145
  this.workspacesRoot = deps.workspacesRoot;
144
146
  this.listLiveAgents = deps.listLiveAgents ?? (() => []);
147
+ this.readPreset = deps.readPreset ?? headerPresetOf;
145
148
  }
146
149
  /** 执行核对,返回越权证据(空=通过,不阻塞汇报)。
147
150
  * @param workspaceDir 本链发起工作区(Chain.workspaceDir);提供时仅扫描工作区内的会话。 */
@@ -151,11 +154,11 @@ export class ChainAuditor {
151
154
  for (const agent of this.listLiveAgents()) {
152
155
  if (String(agent.id ?? '').startsWith('kbn-'))
153
156
  continue; // 角色会话(P/W/D/V)跳过
154
- // 0.1.0 delegation 豁免:header.agentPreset 以 kanban- 开头 → 角色会话
155
- // 的子代理(childSessionMeta 记录所 join 的 preset id),产物归属 git 证据链,
157
+ // 0.1.0 delegation 豁免:preset 以 kanban- 开头 → 角色会话的子代理,产物归属 git 证据链,
156
158
  // 不按"主会话越权"判定;源2(无主产物核对)仍兜底其误写链工作区根。
157
- const joinedPreset = agent.session?.header?.agentPreset;
158
- if (typeof joinedPreset === 'string' && joinedPreset.startsWith('kanban-'))
159
+ // preset 经 readPreset 统一读面(子代理继承父组合,引擎真相同样成立)。
160
+ const joinedPreset = this.readPreset(agent);
161
+ if (joinedPreset.startsWith('kanban-'))
159
162
  continue;
160
163
  // 作用域收窄——仅扫本链发起工作区内的会话;会话无 cwd(测试伪造)时保守保留扫描
161
164
  if (workspaceDir && agent.session?.header?.cwd && !isPathInside(agent.session.header.cwd, workspaceDir)) {
@@ -0,0 +1 @@
1
+ export declare function slimParentMetadata(metadata: Record<string, unknown>): Record<string, unknown>;
@@ -0,0 +1,24 @@
1
+ // src/dispatcher/context-dedup.ts
2
+ /** buildContext 父链 metadata 注入瘦身(PR1 注入去重,纯函数):
3
+ * 1) review_evidence 整键剥离——issues 已由返工卡 body「本轮修复清单」单一信息源承载(P0-3);
4
+ * 2) 超长数组截断 20 条 + 计数尾注(changed_files 等);超长字符串截断 2000 字符。
5
+ * 防御性通用规则,不感知业务键语义;原 metadata 不修改。 */
6
+ const MAX_ARRAY = 20;
7
+ const MAX_STRING = 2000;
8
+ export function slimParentMetadata(metadata) {
9
+ const out = {};
10
+ for (const [k, v] of Object.entries(metadata)) {
11
+ if (k === 'review_evidence')
12
+ continue;
13
+ if (Array.isArray(v)) {
14
+ out[k] = v.length > MAX_ARRAY ? [...v.slice(0, MAX_ARRAY), `…(+${v.length - MAX_ARRAY} more)`] : v;
15
+ }
16
+ else if (typeof v === 'string' && v.length > MAX_STRING) {
17
+ out[k] = v.slice(0, MAX_STRING) + '…(truncated)';
18
+ }
19
+ else {
20
+ out[k] = v;
21
+ }
22
+ }
23
+ return out;
24
+ }
@@ -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 同一会话,进度保留)。
@@ -9,6 +9,7 @@ import { VOrchestrator } from './v-orchestrator.js';
9
9
  import { AgentRunner } from './agent-runner.js';
10
10
  import { Watchdog } from './watchdog.js';
11
11
  import { ChainAuditor } from './chain-auditor.js';
12
+ import { sessionPresetOf } from './session-preset.js';
12
13
  import { mergeDAfterReview } from './merge-gate.js';
13
14
  import { buildSubagentTreeGuard, buildSwarmSessionGuard, buildStandaloneDtGuard, registerStandaloneReviewerTools } from '../roles/toolsets.js';
14
15
  import { syncKbLinks } from '../wiki/kb-linkage.js';
@@ -310,6 +311,17 @@ export function reconcileOrchestrations(orch, chains) {
310
311
  orch.delete(k);
311
312
  return removed;
312
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
+ }
313
325
  /** 启动 reconcile:进程重启会杀死 runner 的 whenIdle 协程,上次遗留的 running 卡无人收尾,
314
326
  * 看门狗默认 4h(staleTimeoutSeconds=14400)才回收——重启后立即把 running 孤儿卡收敛为 blocked
315
327
  * (system comment + blockTask),中断显形且可重派续跑(重派将 resume 同一会话,进度保留)。
@@ -414,6 +426,9 @@ function startDispatcherInner(ctx, configProvider, storageDir, logFile, provider
414
426
  kanban,
415
427
  workspacesRoot: join(storageDir, 'workspaces'),
416
428
  listLiveAgents: () => (ctx.get('agents')?.list?.() ?? []),
429
+ // preset 判定统一走 session-preset.ts(引擎真相优先):只读 header 会把
430
+ // 「蜂群模式」标签会话(header=宿主默认 ptc)误判成主会话越权。
431
+ readPreset: (agent) => sessionPresetOf(ctx, agent),
417
432
  });
418
433
  kanban.setOnChainCompleted(async (chainId) => {
419
434
  // 传入本链发起工作区(Chain.workspaceDir),审计仅扫描该工作区内的会话,排除其他项目主会话
@@ -455,17 +470,20 @@ function startDispatcherInner(ctx, configProvider, storageDir, logFile, provider
455
470
  // guard 内部仅对 kanban-dt 系会话收紧;DT 父会话自身仍由 agent-runner 的 agent.ctx
456
471
  // guard 双保险。dispose 时注销。
457
472
  const toolsSvc = ctx.get('tools');
458
- const unguardSubagents = toolsSvc?.guard?.(buildSubagentTreeGuard());
473
+ // 三个 preset 判定 guard 共用统一读面(session-preset.ts):引擎真相优先,避免
474
+ // 「蜂群模式」标签会话(header=宿主默认 ptc)绕过只读/评审硬闸。
475
+ const readPreset = (agent) => sessionPresetOf(ctx, agent);
476
+ const unguardSubagents = toolsSvc?.guard?.(buildSubagentTreeGuard({ readPreset }));
459
477
  if (unguardSubagents) {
460
478
  ctx.on('dispose', unguardSubagents);
461
479
  }
462
- // 蜂群模式硬闸:header.agentPreset==='swarm' 时只读+git 反选拦截;
480
+ // 蜂群模式硬闸:会话 preset==='swarm'(统一读面,非 header)时只读+git 反选拦截;
463
481
  // 内部对其余会话恒放行。dispose 时注销(与 subagent guard 同款)。
464
- const unguardSwarm = toolsSvc?.guard?.(buildSwarmSessionGuard());
482
+ const unguardSwarm = toolsSvc?.guard?.(buildSwarmSessionGuard({ readPreset }));
465
483
  if (unguardSwarm) {
466
484
  ctx.on('dispose', unguardSwarm);
467
485
  }
468
- // 独立评审硬闸:header.agentPreset==='kanban-dt' 且未经角色组合(session id 非 kbn-
486
+ // 独立评审硬闸:会话 preset==='kanban-dt'(统一读面)且未经角色组合(session id 非 kbn-
469
487
  // 前缀)时收紧(看板写工具拒、bash/run_code 仅只读 git+clone/fetch、wiki_write 仅
470
488
  // reviews 命名空间);wiki_write 对非评审会话全局收紧(链上组合会话/swarm 主会话/
471
489
  // W preset 会话放行)。dispose 时注销(与 subagent/swarm guard 同款)。
@@ -473,6 +491,7 @@ function startDispatcherInner(ctx, configProvider, storageDir, logFile, provider
473
491
  // wiki_write 放行白名单热读配置(applyOverride 后下次工具调用即生效);
474
492
  // config 缺字段回 [] fail-closed(默认值由 config.ts schema 保证存在)。
475
493
  getWikiWritePresets: () => configProvider.getEffective().wikiWritePresets ?? [],
494
+ readPreset,
476
495
  }));
477
496
  if (unguardStandalone) {
478
497
  ctx.on('dispose', unguardStandalone);
@@ -515,6 +534,16 @@ function startDispatcherInner(ctx, configProvider, storageDir, logFile, provider
515
534
  saveOrchs();
516
535
  logToFile(logFile, '[orch-reconcile] removed dead entries: ' + removed.join(','));
517
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
+ }
518
547
  }
519
548
  catch (err) {
520
549
  logToFile(logFile, '[orch-reconcile] failed: ' + String(err));
@@ -0,0 +1,36 @@
1
+ /**
2
+ * 会话 preset 读取 —— 全插件单一实现(守卫、交付投递、审计共用,禁止各写一份)。
3
+ *
4
+ * 四级官方链,逐级回退(鸭子类型,不引入 dsh-session / dsh-agent 依赖):
5
+ * ① `ctx.agentPresets.composedPreset(agent.ctx)` —— **引擎真相**:该 live agent 实际挂载的组合。
6
+ * 官方语义:"Read from the live scope chain rather than from the session, so it answers for an
7
+ * agent whose session has not recorded a preset yet";子代理继承父组合,同样有答案。
8
+ * ② `ctx.sessionProjections.stateOf(session,'agentPreset')` —— 会话日志口径(持久、可冷读,
9
+ * 镜像客户端所见);`agent-presets` 在插件 apply 期一次注册该 key(源码 register 调用),
10
+ * 故 key 恒在,仅当会话对象缺失或投影服务未装时为空。
11
+ * ③ `session.header.agentPreset` —— **创建事实**(官方 deep-frozen)。
12
+ * ④ `agentPresets.defaultId` → 空串(调用方按「未知模式」处理,绝不能当默认模式猜)。
13
+ *
14
+ * 为什么不能只读 header(2026-09-17 实测 + 官方语义):preset 只在会话**空白期(首轮之前)**可改,
15
+ * 改后 header 保持创建时的值不再回头,而 ①② 前进。dsh web 的「蜂群模式」标签会话即此形态——
16
+ * header 落宿主默认 'ptc'、实际运行 'swarm'。只读 header 会让所有按 preset 分流的判定整体错位
17
+ * (实测造成:多机器人投递误判未命中、蜂群只读硬闸被绕过)。
18
+ */
19
+ /** preset 读取用的 agent 最小面(结构鸭子类型)。 */
20
+ export interface PresetAgentLike {
21
+ ctx?: unknown;
22
+ session?: {
23
+ header?: {
24
+ agentPreset?: unknown;
25
+ };
26
+ };
27
+ }
28
+ /** preset 读取用的 ctx 最小面(只需 get)。 */
29
+ export interface PresetCtxLike {
30
+ get?(name: string): unknown;
31
+ }
32
+ /** 创建事实(header.agentPreset):仅用于「会话创建时是什么」这类语义,或作为末级回退。
33
+ * 需要「现在跑什么模式」一律用 {@link sessionPresetOf}。 */
34
+ export declare function headerPresetOf(agent: unknown): string;
35
+ /** 当前会话/agent 的 preset id(引擎真相 → 会话投影 → 创建 header → 默认 id → 空串)。 */
36
+ export declare function sessionPresetOf(ctx: PresetCtxLike | null | undefined, agent: unknown): string;
@@ -0,0 +1,67 @@
1
+ // src/dispatcher/session-preset.ts
2
+ /**
3
+ * 会话 preset 读取 —— 全插件单一实现(守卫、交付投递、审计共用,禁止各写一份)。
4
+ *
5
+ * 四级官方链,逐级回退(鸭子类型,不引入 dsh-session / dsh-agent 依赖):
6
+ * ① `ctx.agentPresets.composedPreset(agent.ctx)` —— **引擎真相**:该 live agent 实际挂载的组合。
7
+ * 官方语义:"Read from the live scope chain rather than from the session, so it answers for an
8
+ * agent whose session has not recorded a preset yet";子代理继承父组合,同样有答案。
9
+ * ② `ctx.sessionProjections.stateOf(session,'agentPreset')` —— 会话日志口径(持久、可冷读,
10
+ * 镜像客户端所见);`agent-presets` 在插件 apply 期一次注册该 key(源码 register 调用),
11
+ * 故 key 恒在,仅当会话对象缺失或投影服务未装时为空。
12
+ * ③ `session.header.agentPreset` —— **创建事实**(官方 deep-frozen)。
13
+ * ④ `agentPresets.defaultId` → 空串(调用方按「未知模式」处理,绝不能当默认模式猜)。
14
+ *
15
+ * 为什么不能只读 header(2026-09-17 实测 + 官方语义):preset 只在会话**空白期(首轮之前)**可改,
16
+ * 改后 header 保持创建时的值不再回头,而 ①② 前进。dsh web 的「蜂群模式」标签会话即此形态——
17
+ * header 落宿主默认 'ptc'、实际运行 'swarm'。只读 header 会让所有按 preset 分流的判定整体错位
18
+ * (实测造成:多机器人投递误判未命中、蜂群只读硬闸被绕过)。
19
+ */
20
+ function asPresetId(value) {
21
+ return typeof value === 'string' ? value.trim() : '';
22
+ }
23
+ /** 创建事实(header.agentPreset):仅用于「会话创建时是什么」这类语义,或作为末级回退。
24
+ * 需要「现在跑什么模式」一律用 {@link sessionPresetOf}。 */
25
+ export function headerPresetOf(agent) {
26
+ const a = agent;
27
+ return asPresetId(a?.session?.header?.agentPreset);
28
+ }
29
+ /** 当前会话/agent 的 preset id(引擎真相 → 会话投影 → 创建 header → 默认 id → 空串)。 */
30
+ export function sessionPresetOf(ctx, agent) {
31
+ const get = typeof ctx?.get === 'function' ? ctx.get.bind(ctx) : undefined;
32
+ const presets = get?.('agentPresets');
33
+ const a = agent;
34
+ // ① 引擎真相:读取失败(作用域链异常)不阻断调用方,降级到会话口径。
35
+ if (a?.ctx !== undefined && typeof presets?.composedPreset === 'function') {
36
+ let composed;
37
+ try {
38
+ composed = presets.composedPreset(a.ctx);
39
+ }
40
+ catch {
41
+ composed = undefined;
42
+ }
43
+ const id = asPresetId(composed);
44
+ if (id)
45
+ return id;
46
+ }
47
+ // ② 会话日志口径。
48
+ const projections = get?.('sessionProjections');
49
+ if (a?.session !== undefined && typeof projections?.stateOf === 'function') {
50
+ let projected;
51
+ try {
52
+ projected = projections.stateOf(a.session, 'agentPreset');
53
+ }
54
+ catch {
55
+ projected = undefined;
56
+ }
57
+ const id = asPresetId(projected);
58
+ if (id)
59
+ return id;
60
+ }
61
+ // ③ 创建事实。
62
+ const fromHeader = headerPresetOf(a);
63
+ if (fromHeader)
64
+ return fromHeader;
65
+ // ④ 默认 id(读不到 → 空串 = 未知模式)。
66
+ return asPresetId(presets?.defaultId);
67
+ }
@@ -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 必须显式空串(非缺失键)
@@ -0,0 +1,17 @@
1
+ /** 评审证据核验纯函数(PR2 三级递进的判定层,零 IO)。
2
+ * 四态:matches/differs/could-not-replay/not-provided(澄清表 #6,2026-09-17 精化:
3
+ * "严重缺证才重放"不可执行——缺证即无命令可重放,落 could-not-replay + 转人工)。
4
+ * schema 单点封装:owner 调整 evidence 形态只改 parseIssueEvidence。 */
5
+ export type EvidenceState = 'matches' | 'differs' | 'could-not-replay' | 'not-provided';
6
+ export interface IssueEvidence {
7
+ file: string;
8
+ command: string;
9
+ exit: number;
10
+ }
11
+ /** issue.evidence 结构校验(issue #2 原样 schema:{file, command, exit})。非法 → null(按 not-provided 处理)。 */
12
+ export declare function parseIssueEvidence(raw: unknown): IssueEvidence | null;
13
+ /** 纸面核对(零执行):存档文件首行 `[exit code: N]` vs 声明 exit(行尾锚定,防 `[exit code: 0]任意文本` 绕过)。
14
+ * unreadable(文件缺失/首行非规范格式)宁纵勿枉 → 转人工,不判 matches。 */
15
+ export declare function paperCheck(content: string | null, claimedExit: number): 'matches' | 'mismatch' | 'unreadable';
16
+ /** 命令准入:词边界前缀匹配(非子串);`npm run` 仅放行白名单内固定脚本名;rest 禁 shell 元字符。 */
17
+ export declare function isAllowedCommand(command: string, prefixes: string[]): boolean;
@@ -0,0 +1,44 @@
1
+ // src/domain/evidence-check.ts
2
+ /** 评审证据核验纯函数(PR2 三级递进的判定层,零 IO)。
3
+ * 四态:matches/differs/could-not-replay/not-provided(澄清表 #6,2026-09-17 精化:
4
+ * "严重缺证才重放"不可执行——缺证即无命令可重放,落 could-not-replay + 转人工)。
5
+ * schema 单点封装:owner 调整 evidence 形态只改 parseIssueEvidence。 */
6
+ /** issue.evidence 结构校验(issue #2 原样 schema:{file, command, exit})。非法 → null(按 not-provided 处理)。 */
7
+ export function parseIssueEvidence(raw) {
8
+ if (typeof raw !== 'object' || raw === null)
9
+ return null;
10
+ const r = raw;
11
+ if (typeof r['file'] !== 'string' || !r['file'].trim())
12
+ return null;
13
+ if (typeof r['command'] !== 'string' || !r['command'].trim())
14
+ return null;
15
+ if (typeof r['exit'] !== 'number' || !Number.isFinite(r['exit']))
16
+ return null;
17
+ return { file: r['file'], command: r['command'], exit: r['exit'] };
18
+ }
19
+ /** 纸面核对(零执行):存档文件首行 `[exit code: N]` vs 声明 exit(行尾锚定,防 `[exit code: 0]任意文本` 绕过)。
20
+ * unreadable(文件缺失/首行非规范格式)宁纵勿枉 → 转人工,不判 matches。 */
21
+ export function paperCheck(content, claimedExit) {
22
+ if (content === null)
23
+ return 'unreadable';
24
+ const first = content.trimStart().split('\n')[0]?.trim() ?? '';
25
+ const m = /^\[exit code:\s*(-?\d+)\]$/.exec(first);
26
+ if (!m)
27
+ return 'unreadable';
28
+ return Number(m[1]) === claimedExit ? 'matches' : 'mismatch';
29
+ }
30
+ /** shell 元字符:rest 中出现任一即拒绝——整串命令会交给 `bash -c` 执行(gate-runner),
31
+ * 前缀白名单只保证"以安全工具开头",元字符可拼接任意后续命令(`npm test && rm -rf`)。 */
32
+ const SHELL_META = /[;|&`$()<>\n'"\\]/;
33
+ /** 命令准入:词边界前缀匹配(非子串);`npm run` 仅放行白名单内固定脚本名;rest 禁 shell 元字符。 */
34
+ export function isAllowedCommand(command, prefixes) {
35
+ const trimmed = command.trim();
36
+ return prefixes.some((p) => {
37
+ if (!trimmed.startsWith(p))
38
+ return false;
39
+ const rest = trimmed.slice(p.length);
40
+ if (SHELL_META.test(rest))
41
+ return false; // 元字符 = 命令链/替换/重定向,一律拒绝
42
+ return rest === '' || rest.startsWith(' ') || rest.startsWith('\t'); // 词边界:拒绝 'npm testify'
43
+ });
44
+ }
@@ -3,16 +3,38 @@ export interface GateCommand {
3
3
  cwd: string;
4
4
  source: 'tdd';
5
5
  }
6
- export interface GatePlan {
6
+ export type GateOutcome = {
7
+ kind: 'run';
7
8
  commands: GateCommand[];
8
- skipped: false;
9
- }
10
- export interface GateSkip {
11
- commands: [];
12
- skipped: true;
9
+ } | {
10
+ kind: 'bounce';
13
11
  reason: string;
14
- }
15
- export declare function isGatePlan(x: GatePlan | GateSkip): x is GatePlan;
12
+ } | {
13
+ kind: 'alarm-skip';
14
+ reason: string;
15
+ } | {
16
+ kind: 'silent-skip';
17
+ reason: string;
18
+ };
19
+ export type SkippedVerdict = {
20
+ action: 'allow-alarm';
21
+ reason: string;
22
+ } | {
23
+ action: 'bounce';
24
+ reason: string;
25
+ } | {
26
+ action: 'proceed';
27
+ };
28
+ /** ④ tdd.skipped 声明互证:diff 实况三分支(改声明 / tdd 先行 / 放行留痕)。
29
+ * 返回类型排除 proceed(proceed 仅为 test_files 对称核验的"放行"语义)。 */
30
+ export declare function classifySkippedDeclaration(diffFiles: string[] | null): Exclude<SkippedVerdict, {
31
+ action: 'proceed';
32
+ }>;
33
+ /** 对称核验:声明 tdd.test_files 但 diff 零测试变更(拿旧测试交差)→ bounce。 */
34
+ export declare function classifyTestFilesDeclaration(diffFiles: string[] | null): SkippedVerdict;
35
+ /** 从 D 卡 body 解析 diff base(body 模板声明 `TARGET_BRANCH=<目标分支名>`,v-orchestrator d:76)。
36
+ * 与 test_files 同款防线:拒 git 旗标注入(`--output=/x` 可写任意路径文件)与路径逃逸。 */
37
+ export declare function resolveDiffBase(body: string): string | null;
16
38
  export declare function branchMatches(current: string | null, declared: unknown): boolean;
17
39
  export declare function deriveGatePlan(input: {
18
40
  assignee: string;
@@ -23,4 +45,6 @@ export declare function deriveGatePlan(input: {
23
45
  config: {
24
46
  enabled: boolean;
25
47
  };
26
- }): GatePlan | GateSkip;
48
+ /** 装配层 git diff --name-only base...HEAD 结果;undefined/null=不可得(保守)。 */
49
+ diffFiles?: string[] | null;
50
+ }): GateOutcome;