@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
@@ -1,12 +1,50 @@
1
1
  // src/domain/gate-policy.ts
2
- /** kanban_complete 实测闸派生(纯函数,零 IO):
3
- * 声明来源单层——tdd.test_files 自动派生 npx --no-install vitest run(runner=vitest 为 review-evidence 既有口径)。
4
- * cwd 一律 metadata.worktree_dir,缺失即 skip(旧卡向后兼容零感知)。
5
- * test_files 必须相对路径且规范化后无 .. 段(防绝对路径跑主仓库/越界逃逸——跑错代码即闸被绕过),违规整单 skip。
6
- * branchMatches 供装配层在 git 查询后做分支一致性比对(current=null 视为不匹配,调用方 skip)。 */
2
+ /** kanban_complete 实测闸派生(纯函数,零 IO)。
3
+ * 2026-09-16 PR1 互证升级:声明与实际必须互证(GateOutcome 四态)——
4
+ * - run:真跑(派生 npx --no-install vitest run,runner=vitest 为 review-evidence 既有口径)
5
+ * - bounce:闸失败打回(同会话 throw 修复重交;3 次上限见 kanban-service MAX_GATE_BOUNCES)
6
+ * - alarm-skip:警报放行(task/gate-skipped 留痕后照常 completed)
7
+ * - silent-skip:不适用(旧 null 语义逐字节兼容:gates disabled / 非 d-execute / 旧卡无 worktree)
8
+ * diffFiles 由装配层以 `git diff --name-only <TARGET_BRANCH>...HEAD` 取得;null=不可得 → 保守放行(fail-visible 不 fail-closed)。 */
7
9
  import { isAbsolute } from 'node:path';
8
- export function isGatePlan(x) {
9
- return x.skipped === false;
10
+ import { isTestFile } from './tdd-classify.js';
11
+ /** 文档/配置扩展白名单——对齐 D 卡 body 既有口径「纯文档/配置变更则带 tdd={skipped:{reason}}」(v-orchestrator d 模板)。 */
12
+ const DOC_CONFIG_EXT = new Set(['md', 'txt', 'json', 'yml', 'yaml', 'toml']);
13
+ function extOf(path) {
14
+ const m = /\.([A-Za-z0-9]+)$/.exec(path);
15
+ return m ? m[1].toLowerCase() : '';
16
+ }
17
+ function isDocOrConfig(path) {
18
+ return DOC_CONFIG_EXT.has(extOf(path));
19
+ }
20
+ /** ④ tdd.skipped 声明互证:diff 实况三分支(改声明 / tdd 先行 / 放行留痕)。
21
+ * 返回类型排除 proceed(proceed 仅为 test_files 对称核验的"放行"语义)。 */
22
+ export function classifySkippedDeclaration(diffFiles) {
23
+ if (diffFiles === null)
24
+ return { action: 'allow-alarm', reason: 'tdd skipped by declaration (diff unavailable, 保守放行)' };
25
+ if (diffFiles.some(isTestFile)) {
26
+ return { action: 'bounce', reason: '声明 tdd.skipped 但本次变更包含测试文件:请改声明 tdd.test_files(闸门将实测)' };
27
+ }
28
+ const hasCodeChange = diffFiles.some((f) => !isTestFile(f) && !isDocOrConfig(f));
29
+ if (hasCodeChange) {
30
+ return { action: 'bounce', reason: 'tdd 先行:本次变更含代码文件,必须声明 tdd.test_files(纯文档/配置变更才允许 skipped)' };
31
+ }
32
+ return { action: 'allow-alarm', reason: 'tdd skipped by declaration: pure docs/config change' };
33
+ }
34
+ /** 对称核验:声明 tdd.test_files 但 diff 零测试变更(拿旧测试交差)→ bounce。 */
35
+ export function classifyTestFilesDeclaration(diffFiles) {
36
+ if (diffFiles === null)
37
+ return { action: 'proceed' };
38
+ if (diffFiles.some(isTestFile))
39
+ return { action: 'proceed' };
40
+ return { action: 'bounce', reason: 'tdd 先行:本次变更未包含测试文件(新增或修改测试文件后重交)' };
41
+ }
42
+ /** 从 D 卡 body 解析 diff base(body 模板声明 `TARGET_BRANCH=<目标分支名>`,v-orchestrator d:76)。
43
+ * 与 test_files 同款防线:拒 git 旗标注入(`--output=/x` 可写任意路径文件)与路径逃逸。 */
44
+ export function resolveDiffBase(body) {
45
+ const m = /TARGET_BRANCH[=:]\s*([^\s,,。;]+)/.exec(body ?? '');
46
+ const base = m ? m[1] : null;
47
+ return base && /^[A-Za-z0-9._/-]+$/.test(base) && !base.startsWith('-') ? base : null;
10
48
  }
11
49
  export function branchMatches(current, declared) {
12
50
  return typeof declared === 'string' && declared.trim().length > 0 && current === declared;
@@ -20,25 +58,28 @@ const isSafeRelativeTestFile = (f) => /^[A-Za-z0-9._/-]+$/.test(f) && !isAbsolut
20
58
  && !f.startsWith('-'); // 拒绝以 - 开头的条目:防 vitest CLI 旗标注入(如 --passWithNoTests 使 0 tests matched 也 exit 0,空跑绕闸)
21
59
  export function deriveGatePlan(input) {
22
60
  const m = input.handoff.metadata ?? {};
61
+ const diff = input.diffFiles ?? null;
23
62
  if (!input.config.enabled)
24
- return { commands: [], skipped: true, reason: 'gates disabled' };
63
+ return { kind: 'silent-skip', reason: 'gates disabled' };
25
64
  if (!(input.assignee === 'd' && input.mode === 'execute'))
26
- return { commands: [], skipped: true, reason: 'not d/execute' };
65
+ return { kind: 'silent-skip', reason: 'not d/execute' };
27
66
  const wt = m['worktree_dir'];
28
67
  if (typeof wt !== 'string' || !wt.trim())
29
- return { commands: [], skipped: true, reason: 'worktree_dir missing (legacy card)' };
68
+ return { kind: 'silent-skip', reason: 'worktree_dir missing (legacy card)' };
30
69
  const tdd = m['tdd'];
31
- if (tdd?.skipped)
32
- return { commands: [], skipped: true, reason: 'tdd skipped by declaration' };
70
+ if (tdd?.skipped) {
71
+ const v = classifySkippedDeclaration(diff);
72
+ return v.action === 'allow-alarm' ? { kind: 'alarm-skip', reason: v.reason } : { kind: 'bounce', reason: v.reason };
73
+ }
33
74
  if (!Array.isArray(tdd?.test_files) || tdd.test_files.length === 0)
34
- return { commands: [], skipped: true, reason: 'no gate commands' };
75
+ return { kind: 'bounce', reason: 'tdd 先行:交付代码变更必须声明 tdd.test_files(纯文档/配置变更才允许声明 skipped)' };
35
76
  const files = tdd.test_files.filter((f) => typeof f === 'string' && f.trim().length > 0);
36
77
  if (files.length === 0)
37
- return { commands: [], skipped: true, reason: 'no gate commands' };
78
+ return { kind: 'bounce', reason: 'tdd 先行:tdd.test_files 为空(纯文档/配置变更才允许声明 skipped)' };
38
79
  if (!files.every(isSafeRelativeTestFile))
39
- return { commands: [], skipped: true, reason: 'invalid test_files path (relative in-worktree required)' };
40
- return {
41
- commands: [{ command: `npx --no-install vitest run ${files.join(' ')}`, cwd: wt, source: 'tdd' }],
42
- skipped: false,
43
- };
80
+ return { kind: 'bounce', reason: 'invalid test_files path (relative in-worktree required)' };
81
+ const declared = classifyTestFilesDeclaration(diff);
82
+ if (declared.action === 'bounce')
83
+ return { kind: 'bounce', reason: declared.reason };
84
+ return { kind: 'run', commands: [{ command: `npx --no-install vitest run ${files.join(' ')}`, cwd: wt, source: 'tdd' }] };
44
85
  }
@@ -6,6 +6,29 @@ export type KanbanListener = (event: KanbanEvent) => void;
6
6
  export declare function firstSentence(text: string): string;
7
7
  /** 默认链标题:【需求】<一句话需求描述>。来源优先级 /plan: rest 首句 → checklist.problem 首句 → 未命名需求。 */
8
8
  export declare function buildChainTitle(requirementName: string | null, _openspecRest: string, problem: string): string;
9
+ /** gate hook 三态裁决(PR1 互证):null=不适用(总开关关/非 D/旧卡,静默零事件);
10
+ * {skipped,reason}=警报放行(task/gate-skipped 留痕后照常 completed);
11
+ * {ok,detail}=真跑或打回(ok=false → gate-failed + throw 同会话修复重交)。 */
12
+ export type GateHookVerdict = {
13
+ ok: boolean;
14
+ detail: string;
15
+ } | {
16
+ skipped: true;
17
+ reason: string;
18
+ } | null;
19
+ /** 同一任务实测闸打回上限(累计计数):超过后 blockTask 转人工。
20
+ * gate fail 不走 failTask、attempts 不递增(仅会话死亡路径 +1),故按 gate-failed 事件数自建计数。 */
21
+ export declare const MAX_GATE_BOUNCES = 3;
22
+ /** 评审证据核验汇总(PR2,非阻塞):仅 DT 卡 complete 时调用,结果只发事件留痕
23
+ * (differs/could-not-replay 不阻塞 complete、不改 verdict——转人工信号走事件流)。 */
24
+ export type EvidenceCheckSummary = {
25
+ results: Array<{
26
+ title: string;
27
+ severity: string;
28
+ state: string;
29
+ detail: string;
30
+ }>;
31
+ } | null;
9
32
  /** 看板领域门面:三界面(工具/CLI/UI)统一路由的唯一入口。 */
10
33
  export declare class KanbanService {
11
34
  private state;
@@ -16,6 +39,7 @@ export declare class KanbanService {
16
39
  private onChainCompletedHook;
17
40
  private onTaskCompletedHook;
18
41
  private gateHook;
42
+ private evidenceCheckHook;
19
43
  constructor(store: EventStore, getKbUrlBase?: () => string | undefined);
20
44
  private emit;
21
45
  /** 注入链完成核对钩子(由调度层设置;仅一个消费者)。 */
@@ -23,10 +47,9 @@ export declare class KanbanService {
23
47
  /** 注入任务完成互链登记钩子(由调度层设置;仅一个消费者)。 */
24
48
  setOnTaskCompleted(hook: (taskId: string) => void | Promise<void>): void;
25
49
  /** 注入实测闸钩子(由装配层设置;null=关闭实测闸,行为与旧版逐字节一致)。 */
26
- setGateHook(hook: ((task: Task, handoff: Handoff) => Promise<{
27
- ok: boolean;
28
- detail: string;
29
- } | null>) | null): void;
50
+ setGateHook(hook: ((task: Task, handoff: Handoff) => Promise<GateHookVerdict>) | null): void;
51
+ /** 注入评审证据核验钩子(PR2;null=关闭,行为与旧版一致)。 */
52
+ setEvidenceCheckHook(hook: ((task: Task, handoff: Handoff) => Promise<EvidenceCheckSummary>) | null): void;
30
53
  /** 订阅持久化后的看板事件;返回解除订阅函数。listener 异常不影响已落盘状态。 */
31
54
  subscribe(listener: KanbanListener): () => void;
32
55
  /** 返回 seq >= 入参 的事件(与 EventStore.readSince 同为 inclusive 语义)。 */
@@ -51,6 +51,9 @@ function reworkChecklistSection(issues) {
51
51
  }
52
52
  return lines.join('\n');
53
53
  }
54
+ /** 同一任务实测闸打回上限(累计计数):超过后 blockTask 转人工。
55
+ * gate fail 不走 failTask、attempts 不递增(仅会话死亡路径 +1),故按 gate-failed 事件数自建计数。 */
56
+ export const MAX_GATE_BOUNCES = 3;
54
57
  /** 看板领域门面:三界面(工具/CLI/UI)统一路由的唯一入口。 */
55
58
  export class KanbanService {
56
59
  state;
@@ -65,9 +68,13 @@ export class KanbanService {
65
68
  // W2/W3 完成互链登记钩子(dispatcher 注入:拿 page_path → 机械写三方互链,失败不阻塞完成)
66
69
  onTaskCompletedHook = null;
67
70
  // 实测闸钩子(装配层注入:gate-policy 派生 + gate-runner 实测执行 + 分支核对)。
68
- // null=未启用(行为不变);hook 返回 null=跳过(零感知,无 gate 事件)。
71
+ // 三态(PR1 互证):null=不适用(静默零事件,行为不变);{skipped,reason}=警报放行;
72
+ // {ok,detail}=真跑(ok=false → gate-failed + throw 打回,同会话修复重交)。
69
73
  // **无 actor 参数——human 无豁免**(2026-09-02 决议收紧)。
70
74
  gateHook = null;
75
+ // 评审证据核验钩子(PR2,非阻塞):仅 DT 卡 complete 时调用,结果只发 review/evidence-check 事件留痕
76
+ // (differs/could-not-replay 不阻塞 complete、不改 verdict——转人工信号走事件流)。
77
+ evidenceCheckHook = null;
71
78
  constructor(store, getKbUrlBase) {
72
79
  this.store = store;
73
80
  this.getKbUrlBase = getKbUrlBase ?? null;
@@ -99,6 +106,10 @@ export class KanbanService {
99
106
  setGateHook(hook) {
100
107
  this.gateHook = hook;
101
108
  }
109
+ /** 注入评审证据核验钩子(PR2;null=关闭,行为与旧版一致)。 */
110
+ setEvidenceCheckHook(hook) {
111
+ this.evidenceCheckHook = hook;
112
+ }
102
113
  /** 订阅持久化后的看板事件;返回解除订阅函数。listener 异常不影响已落盘状态。 */
103
114
  subscribe(listener) {
104
115
  this.listeners.add(listener);
@@ -272,15 +283,42 @@ export class KanbanService {
272
283
  // v2 断代:w:file 交付键随旧 w1 预取阶段移除,manifest 校验块同步删除
273
284
  // (validatePrefetchManifest 仍保留于 prefetch-manifest.ts,供清单 schema 校验复用)。
274
285
  // 实测闸:hook 由装配层注入(gate-policy 派生 + gate-runner 实测执行 + 分支核对),
275
- // hook 返回 null=跳过(未启用/非 D/旧卡/分支不一致)。**无 human 豁免**(2026-09-02 决议收紧);
276
- // ok=false → gate-failed 事件 + 拒绝 complete(卡留 running,throw 经工具边界回 D 会话)。
286
+ // 三态语义见 GateHookVerdict。**无 human 豁免**(2026-09-02 决议收紧);
287
+ // ok=false → gate-failed 事件 + 拒绝 complete(卡留 running,throw 经工具边界回 D 会话);
288
+ // 累计打回 ≥MAX_GATE_BOUNCES → blockTask 转人工(防同会话无限循环;人工 unblock 不重置计数——语义取舍见台账)。
277
289
  if (this.gateHook) {
278
290
  const verdict = await this.gateHook(t, handoff);
279
291
  if (verdict !== null) {
280
- await this.emit({ chainId: t.chainId, taskId, kind: verdict.ok ? 'task/gate-passed' : 'task/gate-failed',
281
- payload: { detail: verdict.detail }, author: 'system', at: Date.now() });
282
- if (!verdict.ok)
283
- throw new Error('gate failed: ' + verdict.detail);
292
+ if ('skipped' in verdict) {
293
+ // 警报放行:留痕后照常 completed(④ 诚实纯文档场景不被打扰,谎报由 DT/人工下游兜底)
294
+ await this.emit({ chainId: t.chainId, taskId, kind: 'task/gate-skipped', payload: { reason: verdict.reason }, author: 'system', at: Date.now() });
295
+ }
296
+ else {
297
+ await this.emit({ chainId: t.chainId, taskId, kind: verdict.ok ? 'task/gate-passed' : 'task/gate-failed',
298
+ payload: { detail: verdict.detail }, author: 'system', at: Date.now() });
299
+ if (!verdict.ok) {
300
+ const bounces = this.state.events.filter((e) => e.taskId === taskId && e.kind === 'task/gate-failed').length;
301
+ const escalated = bounces >= MAX_GATE_BOUNCES;
302
+ if (escalated) {
303
+ await this.blockTask(taskId, `gave_up: gate bounced ${bounces} times — 转人工核对`, 'system');
304
+ }
305
+ // throw 文案区分升级态(评审 Minor):blocked 后 D 会话不应误以为还能重试
306
+ throw new Error('gate failed' + (escalated ? '(已转人工核对,请勿盲目重试)' : '') + ': ' + verdict.detail);
307
+ }
308
+ }
309
+ }
310
+ }
311
+ // PR2 评审证据核验(非阻塞,仅 DT):结果发 review/evidence-check 汇总事件(先于 completed)。
312
+ // hook 异常不阻断 complete;differs/could-not-replay 不改 verdict——转人工信号走事件流。
313
+ if (this.evidenceCheckHook && t.assignee === 'dt') {
314
+ try {
315
+ const summary = await this.evidenceCheckHook(t, handoff);
316
+ if (summary && summary.results.length > 0) {
317
+ await this.emit({ chainId: t.chainId, taskId, kind: 'review/evidence-check', payload: { results: summary.results }, author: 'system', at: Date.now() });
318
+ }
319
+ }
320
+ catch (error) {
321
+ console.error('[dsh-swarm] evidence check failed (non-blocking): ' + String(error));
284
322
  }
285
323
  }
286
324
  await this.emit({ chainId: t.chainId, taskId, kind: 'task/completed', payload: { ...handoff }, author: actor, at: Date.now() });
@@ -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
+ }
@@ -45,7 +45,9 @@ export function validateReviewEvidence(role, handoff) {
45
45
  const tcOk = ev.typecheck && typeof ev.typecheck === 'object';
46
46
  if (!buildOk && !tcOk)
47
47
  missing.push('review_evidence.build/typecheck');
48
- if (ev.lint === undefined)
48
+ // PR1 对齐 build 口径(上方 buildOk/typecheckOk):lint 必须为结构化对象;null/字符串/数字拒收(lint:null 曾溜闸)。
49
+ const lintOk = ev.lint && typeof ev.lint === 'object';
50
+ if (!lintOk)
49
51
  missing.push('review_evidence.lint');
50
52
  const diffOk = ev.diff && typeof ev.diff === 'object' && Object.keys(ev.diff).length > 0;
51
53
  if (!diffOk)
@@ -22,6 +22,12 @@ export interface ReviewIssue {
22
22
  fix?: string;
23
23
  /** 对账标记:true=上一轮遗留 issue 的对账条目;false/缺省=本轮新问题。收敛闸据此判定旧账是否清零(漏标=false=视为新问题,方向安全)。 */
24
24
  legacy?: boolean;
25
+ /** 重放取证(PR2,可选):评审者声称跑过的命令 + 原始输出存档(绝对路径,首行 [exit code: N])+ 声明退出码。 */
26
+ evidence?: {
27
+ file: string;
28
+ command: string;
29
+ exit: number;
30
+ };
25
31
  }
26
32
  /** TDD 硬要求证据(评审时收集的测试相关元信息)。 */
27
33
  export interface TddEvidence {
@@ -53,7 +59,7 @@ export interface ReviewEvidence {
53
59
  kbUrl: string;
54
60
  };
55
61
  }
56
- export type EventKind = 'chain/created' | 'chain/executing' | 'chain/completed' | 'chain/aborted' | 'chain/blocked' | 'chain/root-task-set' | 'chain/audit-warning' | 'chain/audit-confirmed' | 'chain/title-updated' | 'chain/im-delivery-failed' | 'spec-card/created' | 'spec-card/edited' | 'spec-card/approved' | 'task/created' | 'task/claimed' | 'task/heartbeat' | 'task/commented' | 'task/completed' | 'task/blocked' | 'task/unblocked' | 'task/archived' | 'task/gate-passed' | 'task/gate-failed' | 'task/failed' | 'task/renamed' | 'review/passed' | 'review/failed' | 'review/gave-up' | 'review/waived' | 'chain/reopened';
62
+ export type EventKind = 'chain/created' | 'chain/executing' | 'chain/completed' | 'chain/aborted' | 'chain/blocked' | 'chain/root-task-set' | 'chain/audit-warning' | 'chain/audit-confirmed' | 'chain/title-updated' | 'chain/im-delivery-failed' | 'spec-card/created' | 'spec-card/edited' | 'spec-card/approved' | 'task/created' | 'task/claimed' | 'task/heartbeat' | 'task/commented' | 'task/completed' | 'task/blocked' | 'task/unblocked' | 'task/archived' | 'task/gate-passed' | 'task/gate-failed' | 'task/gate-skipped' | 'task/failed' | 'task/renamed' | 'review/passed' | 'review/failed' | 'review/gave-up' | 'review/waived' | 'review/evidence-check' | 'chain/reopened';
57
63
  export interface SpecCardSections {
58
64
  problem: string;
59
65
  solution: string;
@@ -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
+ }
@@ -54,33 +54,40 @@ export declare function unregisterDtTaskChain(taskId: string): void;
54
54
  export interface SubagentGuardDeps {
55
55
  /** kbn-<taskId> → chainId 同步解析(缺省用 module 缓存;测试注入用)。 */
56
56
  getTaskChainId?(taskId: string): string | undefined;
57
+ /** 会话 preset 读取(生产由 dispatcher 注入 session-preset.ts 的统一实现;缺省回落 header
58
+ * ——创建事实,仅供测试/未接线场景保留既有行为)。 */
59
+ readPreset?(agent: unknown): string;
57
60
  }
58
- /** 全局子代理写护栏:仅 DT 角色会话的"子代理"(agentPreset === 'kanban-dt' 且
61
+ /** 全局子代理写护栏:仅 DT 角色会话的"子代理"(preset === 'kanban-dt' 且
59
62
  * header.parentSession 为 kbn-<taskId> 前缀)应用 buildDTWriteGuard。判据:parentSession
60
63
  * 缺失或非 kbn- 前缀 → 放行(DT 父会话自身或无关会话;DT 父会话只读由 agent.ctx guard
61
64
  * 兜底,双保险)。repoRoot 取子代理 header.cwd(继承 DT 会话 cwd=评审目标仓库);缺省
62
65
  * '/'(写标记全拦的保守形态)。chainId 从 parentSession(kbn-<taskId>)解析;解析不到
63
- * → 空(wiki_write fail-closed 全拒,源码写拦截不受影响)。 */
66
+ * → 空(wiki_write fail-closed 全拒,源码写拦截不受影响)。
67
+ * preset 判定经 deps.readPreset(统一实现):子代理继承父组合,引擎真相同样有答案;
68
+ * header.parentSession 仍是创建事实(子代理会话由 agent-runner 建,不存在空白期改 preset 的窗口)。 */
64
69
  export declare function buildSubagentTreeGuard(deps?: SubagentGuardDeps): (execution: {
65
70
  name?: string;
66
71
  arguments?: unknown;
67
72
  agent?: unknown;
68
73
  }) => string | undefined;
69
- /** 蜂群模式主会话硬闸:全局 guard,按 header.agentPreset==='swarm'
70
- * 精准判定(先例 buildSubagentTreeGuard)。swarm 会话 = 扩权参数教学拦截(宿主 bash/write/edit
74
+ /** 蜂群模式主会话硬闸:全局 guard,按会话 preset==='swarm'
75
+ * 精准判定(先例 buildSubagentTreeGuard)。**必须用统一 preset 读面**:web「蜂群模式」标签会话的
76
+ * header 落宿主默认 'ptc'(2026-09-17 实测),只读 header 会让本闸对目标会话整体失效。
77
+ * swarm 会话 = 扩权参数教学拦截(宿主 bash/write/edit
71
78
  * schema 广播 sandbox_permissions/justification,模型带参重试会触发天花板会话 approveEscalation
72
79
  * 死循环——独立 DT 同款拦截)+ git 反选(与独立 DT 共用 GIT_MUTATION_VERBS/dualVerbGitDenyReason,
73
80
  * buildPlanWriteGuard 同款分段提取判定,跳过 git 全局选项、提取不到动词 fail-closed,先行判定——
74
81
  * git 变更动词多数同时命中只读基座的写标记,须以 swarm-guard 文案优先返回)+ 只读基座
75
82
  * (buildReadOnlyWriteGuard:直接写工具全名拦截 + bash/run_code 写标记)。其余会话恒放行
76
83
  * (角色会话自有 agent scope 护栏兜底,双保险不叠加)。 */
77
- export declare function buildSwarmSessionGuard(): (execution: {
84
+ export declare function buildSwarmSessionGuard(deps?: SubagentGuardDeps): (execution: {
78
85
  name?: string;
79
86
  arguments?: unknown;
80
87
  agent?: unknown;
81
88
  }) => string | undefined;
82
89
  /** 独立评审(standalone DT)全局硬闸。独立会话判定(同时成立):
83
- * header.agentPreset === 'kanban-dt'、未经角色组合标记(!isRoleComposed)、parentSession
90
+ * 会话 preset === 'kanban-dt'(经 deps.readPreset 统一读面)、未经角色组合标记(!isRoleComposed)、parentSession
84
91
  * 非 kbn- 前缀(与 buildSubagentTreeGuard 判据对齐:有 kbn- parentSession 的是链上系
85
92
  * DT 子代理,交由该 guard 管——其 incarnation 可能无组合标记、宿主 id 也可能非 kbn-
86
93
  * 前缀,漏判会把链评审写入误当独立模式拒掉)、且(能从 execution.agent 取到 session
@@ -102,13 +109,17 @@ export declare function buildSwarmSessionGuard(): (execution: {
102
109
  * 5. 其余工具(read/glob/grep/ocr_review/wiki_read/wiki_search 等)→ 放行。 */
103
110
  /** wiki_write 会话级放行 preset 默认白名单:与 config.ts wikiWritePresets 默认值同源,
104
111
  * 仅供 guard deps 未接线(测试/裸调用)时兜底;生产经 dispatcher 注入热读配置。
105
- * 'ptc'(2026-09-16 实测):dsh web「蜂群模式」标签会话的 header.agentPreset 实际为
106
- * 宿主内置 PTC 模式 id 'ptc'(presets/ptc,非插件 'swarm')——UI 标签与 header id 不同源。 */
112
+ * 'ptc':普通(PTC 模式)会话亦可写 wiki 命名空间——写面本身由 wiki_write 工具内的
113
+ * 五类命名空间白名单硬约束;2026-09-17 起 preset 判定走统一读面(session-preset.ts),
114
+ * 'ptc' 不再是「蜂群会话被误读成 ptc」的兜底,而是显式放行普通会话。 */
107
115
  export declare const DEFAULT_WIKI_WRITE_PRESETS: ReadonlyArray<string>;
108
116
  export interface StandaloneDtGuardDeps {
109
117
  /** wiki_write 放行 preset 白名单热读(dispatcher 注入 () => getEffective().wikiWritePresets;
110
118
  * 缺省回落 DEFAULT_WIKI_WRITE_PRESETS——测试与未接线场景保持默认行为)。 */
111
119
  getWikiWritePresets?: () => ReadonlyArray<string>;
120
+ /** 会话 preset 读取(生产由 dispatcher 注入 session-preset.ts 的统一实现;缺省回落 header
121
+ * ——创建事实,仅供测试/未接线场景保留既有行为)。 */
122
+ readPreset?(agent: unknown): string;
112
123
  }
113
124
  export declare function buildStandaloneDtGuard(deps?: StandaloneDtGuardDeps): (execution: {
114
125
  name?: string;
@@ -5,6 +5,7 @@ import { isInsideKbRoot, ensureLocalKbRoot } from '../wiki/local-kb.js';
5
5
  import { LocalWikiClient } from '../wiki/local-kb-client.js';
6
6
  import { isStandaloneReviewNamespacePath } from '../domain/ocr-review.js';
7
7
  import { isRoleComposed } from '../dispatcher/agent-runner.js';
8
+ import { headerPresetOf } from '../dispatcher/session-preset.js';
8
9
  import { ESCALATION_PARAM_KEYS } from './clean-fs-tools.js';
9
10
  export { isRoleComposed };
10
11
  import { buildKanbanTools } from '../tools/kanban-tools.js';
@@ -437,16 +438,19 @@ function extractSessionHeader(agent) {
437
438
  const h = a?.session?.header;
438
439
  return h && typeof h === 'object' ? h : undefined;
439
440
  }
440
- /** 全局子代理写护栏:仅 DT 角色会话的"子代理"(agentPreset === 'kanban-dt' 且
441
+ /** 全局子代理写护栏:仅 DT 角色会话的"子代理"(preset === 'kanban-dt' 且
441
442
  * header.parentSession 为 kbn-<taskId> 前缀)应用 buildDTWriteGuard。判据:parentSession
442
443
  * 缺失或非 kbn- 前缀 → 放行(DT 父会话自身或无关会话;DT 父会话只读由 agent.ctx guard
443
444
  * 兜底,双保险)。repoRoot 取子代理 header.cwd(继承 DT 会话 cwd=评审目标仓库);缺省
444
445
  * '/'(写标记全拦的保守形态)。chainId 从 parentSession(kbn-<taskId>)解析;解析不到
445
- * → 空(wiki_write fail-closed 全拒,源码写拦截不受影响)。 */
446
+ * → 空(wiki_write fail-closed 全拒,源码写拦截不受影响)。
447
+ * preset 判定经 deps.readPreset(统一实现):子代理继承父组合,引擎真相同样有答案;
448
+ * header.parentSession 仍是创建事实(子代理会话由 agent-runner 建,不存在空白期改 preset 的窗口)。 */
446
449
  export function buildSubagentTreeGuard(deps = {}) {
450
+ const readPreset = deps.readPreset ?? headerPresetOf;
447
451
  return (execution) => {
448
452
  const header = extractSessionHeader(execution?.agent);
449
- if (!header || header.agentPreset !== 'kanban-dt')
453
+ if (!header || readPreset(execution?.agent) !== 'kanban-dt')
450
454
  return undefined;
451
455
  // 仅真实子代理(parentSession 为 kbn- 前缀)受全局护栏约束;DT 父会话自身
452
456
  // parentSession 是主会话或缺失(非 kbn- 前缀),chainId 解析不到 → 空,若误拦
@@ -461,18 +465,21 @@ export function buildSubagentTreeGuard(deps = {}) {
461
465
  return buildDTWriteGuard(repoRoot, chainId)(execution);
462
466
  };
463
467
  }
464
- /** 蜂群模式主会话硬闸:全局 guard,按 header.agentPreset==='swarm'
465
- * 精准判定(先例 buildSubagentTreeGuard)。swarm 会话 = 扩权参数教学拦截(宿主 bash/write/edit
468
+ /** 蜂群模式主会话硬闸:全局 guard,按会话 preset==='swarm'
469
+ * 精准判定(先例 buildSubagentTreeGuard)。**必须用统一 preset 读面**:web「蜂群模式」标签会话的
470
+ * header 落宿主默认 'ptc'(2026-09-17 实测),只读 header 会让本闸对目标会话整体失效。
471
+ * swarm 会话 = 扩权参数教学拦截(宿主 bash/write/edit
466
472
  * schema 广播 sandbox_permissions/justification,模型带参重试会触发天花板会话 approveEscalation
467
473
  * 死循环——独立 DT 同款拦截)+ git 反选(与独立 DT 共用 GIT_MUTATION_VERBS/dualVerbGitDenyReason,
468
474
  * buildPlanWriteGuard 同款分段提取判定,跳过 git 全局选项、提取不到动词 fail-closed,先行判定——
469
475
  * git 变更动词多数同时命中只读基座的写标记,须以 swarm-guard 文案优先返回)+ 只读基座
470
476
  * (buildReadOnlyWriteGuard:直接写工具全名拦截 + bash/run_code 写标记)。其余会话恒放行
471
477
  * (角色会话自有 agent scope 护栏兜底,双保险不叠加)。 */
472
- export function buildSwarmSessionGuard() {
478
+ export function buildSwarmSessionGuard(deps = {}) {
479
+ const readPreset = deps.readPreset ?? headerPresetOf;
473
480
  return (execution) => {
474
481
  const header = extractSessionHeader(execution?.agent);
475
- if (!header || header.agentPreset !== 'swarm')
482
+ if (!header || readPreset(execution?.agent) !== 'swarm')
476
483
  return undefined;
477
484
  const name = String(execution?.name ?? '');
478
485
  const args = execution?.arguments ?? {};
@@ -565,7 +572,7 @@ function dualVerbGitDenyReason(verb, rest, deny) {
565
572
  }
566
573
  }
567
574
  /** 独立评审(standalone DT)全局硬闸。独立会话判定(同时成立):
568
- * header.agentPreset === 'kanban-dt'、未经角色组合标记(!isRoleComposed)、parentSession
575
+ * 会话 preset === 'kanban-dt'(经 deps.readPreset 统一读面)、未经角色组合标记(!isRoleComposed)、parentSession
569
576
  * 非 kbn- 前缀(与 buildSubagentTreeGuard 判据对齐:有 kbn- parentSession 的是链上系
570
577
  * DT 子代理,交由该 guard 管——其 incarnation 可能无组合标记、宿主 id 也可能非 kbn-
571
578
  * 前缀,漏判会把链评审写入误当独立模式拒掉)、且(能从 execution.agent 取到 session
@@ -587,23 +594,26 @@ function dualVerbGitDenyReason(verb, rest, deny) {
587
594
  * 5. 其余工具(read/glob/grep/ocr_review/wiki_read/wiki_search 等)→ 放行。 */
588
595
  /** wiki_write 会话级放行 preset 默认白名单:与 config.ts wikiWritePresets 默认值同源,
589
596
  * 仅供 guard deps 未接线(测试/裸调用)时兜底;生产经 dispatcher 注入热读配置。
590
- * 'ptc'(2026-09-16 实测):dsh web「蜂群模式」标签会话的 header.agentPreset 实际为
591
- * 宿主内置 PTC 模式 id 'ptc'(presets/ptc,非插件 'swarm')——UI 标签与 header id 不同源。 */
597
+ * 'ptc':普通(PTC 模式)会话亦可写 wiki 命名空间——写面本身由 wiki_write 工具内的
598
+ * 五类命名空间白名单硬约束;2026-09-17 起 preset 判定走统一读面(session-preset.ts),
599
+ * 'ptc' 不再是「蜂群会话被误读成 ptc」的兜底,而是显式放行普通会话。 */
592
600
  export const DEFAULT_WIKI_WRITE_PRESETS = ['swarm', 'kanban-w', 'ptc'];
593
601
  export function buildStandaloneDtGuard(deps = {}) {
602
+ const readPreset = deps.readPreset ?? headerPresetOf;
594
603
  return (execution) => {
595
604
  const header = extractSessionHeader(execution?.agent);
596
605
  const name = String(execution?.name ?? '');
597
606
  const agent = execution?.agent;
598
607
  const sessionId = typeof agent?.id === 'string' ? agent.id : undefined;
608
+ const preset = readPreset(execution?.agent);
599
609
  // 链上系 DT 子代理(parentSession 为 kbn-<taskId> 前缀):交由 buildSubagentTreeGuard 管,
600
610
  // 本 guard 不按独立模式收紧、也不在非独立分支拒其 wiki_write(链评审目录写入归该 guard 校验)。
601
611
  const isDtChainSubagent = !!header
602
- && header.agentPreset === 'kanban-dt'
612
+ && preset === 'kanban-dt'
603
613
  && typeof header.parentSession === 'string'
604
614
  && header.parentSession.startsWith('kbn-');
605
615
  const standalone = !!header
606
- && header.agentPreset === 'kanban-dt'
616
+ && preset === 'kanban-dt'
607
617
  && !isRoleComposed(execution?.agent)
608
618
  && !isDtChainSubagent
609
619
  && !(sessionId !== undefined && sessionId.startsWith('kbn-'));
@@ -659,12 +669,11 @@ export function buildStandaloneDtGuard(deps = {}) {
659
669
  // 白名单硬约束;local 模式 wiki_write 未全局注册,无工具面即无暴露面。
660
670
  // 放行面配置化(硬规则 6):白名单经 deps 热读 config.wikiWritePresets,宿主 preset
661
671
  // 体系演化/部署自定义模式 id 时改配置即可;config 缺字段回 [](fail-closed,仅内置默认兜底测试)。
662
- const preset = header?.agentPreset;
663
672
  const allowed = deps.getWikiWritePresets?.() ?? DEFAULT_WIKI_WRITE_PRESETS;
664
- if (preset !== undefined && allowed.includes(preset))
673
+ if (preset !== '' && allowed.includes(preset))
665
674
  return undefined;
666
675
  // 诊断尾巴:报错带实际 preset(或"无"),用户重试一次即可定位会话模式判定缺口
667
- return 'wiki-write-restricted-to-reviewer-sessions: wiki_write 仅限 DT 评审会话 / W 角色 / swarm 主会话使用(当前会话 preset=' + (preset ?? '无') + ')';
676
+ return 'wiki-write-restricted-to-reviewer-sessions: wiki_write 仅限 DT 评审会话 / W 角色 / swarm 主会话使用(当前会话 preset=' + (preset || '无') + ')';
668
677
  }
669
678
  return undefined;
670
679
  };
@@ -0,0 +1,28 @@
1
+ import { type runOne } from './gate-runner.js';
2
+ import { type EvidenceState } from '../domain/evidence-check.js';
3
+ export interface EvidenceCheckResult {
4
+ title: string;
5
+ severity: string;
6
+ state: EvidenceState;
7
+ detail: string;
8
+ }
9
+ /** 沿父链找最近 execute 卡的 worktree_dir(重放 cwd=被评审代码所在处)。 */
10
+ export declare function resolveTargetWorktree(parents: Array<{
11
+ assignee: string;
12
+ mode: string;
13
+ metadata?: Record<string, unknown>;
14
+ }>): string | null;
15
+ interface ReplayCfg {
16
+ replayEnabled: boolean;
17
+ timeoutMs: number;
18
+ allowPrefixes: string[];
19
+ /** 黑名单子串预检(沿用 gates.forbidden 纵深,评审 Important:不得清空)。 */
20
+ forbidden?: string[];
21
+ worktreeDir: string | null;
22
+ readFile: (p: string) => Promise<string | null>;
23
+ run?: typeof runOne;
24
+ }
25
+ export declare function checkIssueEvidence(input: {
26
+ issues: unknown;
27
+ } & ReplayCfg): Promise<EvidenceCheckResult[]>;
28
+ export {};