@joekytc/dsh-swarm 0.3.6 → 0.3.8

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.
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);
@@ -80,5 +89,13 @@ export interface KanbanConfig {
80
89
  model: string;
81
90
  };
82
91
  };
92
+ /** wiki_write 会话级放行的 preset id 白名单(buildStandaloneDtGuard 非独立分支热读)。
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 时改配置即可,无需改代码。
98
+ * 写面仍由 wiki_write 工具内 assertAllowedWikiPagePath 五类命名空间白名单硬约束。 */
99
+ wikiWritePresets: string[];
83
100
  }
84
101
  export declare const Config: Schema<KanbanConfig>;
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(''),
@@ -71,4 +81,5 @@ export const Config = Schema.object({
71
81
  model: Schema.string().default(''),
72
82
  }),
73
83
  }).default({ mode: 'delegate', managed: { provider: '', model: '' } }),
84
+ wikiWritePresets: Schema.array(Schema.string()).default(['swarm', 'kanban-w', 'ptc']),
74
85
  });
@@ -10,6 +10,7 @@ import { toolName } from './session-events.js';
10
10
  import { attachSessionToWorkspace, resolveOrCreateWorkspace } from './workspace-attach.js';
11
11
  import { isPathInside, resolveTargetRepoDir } from './target-repo.js';
12
12
  import { injectGitCredentials, resolveGitPatFromCtx } from './git-credentials.js';
13
+ import { slimParentMetadata } from './context-dedup.js';
13
14
  /** 目标仓库在会话工作空间外、已 claim+block 等待用户授权且尚未建会话的任务集合(key=taskId)。
14
15
  * 人工放行后再次调度会重新询问;已授权后从集合移除。仅进程内记忆,重启后从事件日志恢复(block 事件仍在)。 */
15
16
  const permissionBlockedTasks = new Set();
@@ -113,7 +114,7 @@ ${task.body}`);
113
114
  parts.push('## Parent task results');
114
115
  for (const h of parents) {
115
116
  parts.push(`- summary: ${h.summary}`);
116
- parts.push(`- metadata: ${JSON.stringify(h.metadata)}`);
117
+ parts.push(`- metadata: ${JSON.stringify(slimParentMetadata((h.metadata ?? {})))}`);
117
118
  }
118
119
  }
119
120
  // 0.1.0 delegation:D(execute) 目标模式条件注入——spec 卡/父交接命中
@@ -181,12 +182,7 @@ ${task.body}`);
181
182
  const unresolved = issues.filter((i) => i.resolved !== true);
182
183
  parts.push(`- 上一轮评审 ${issues.length} 条 issue:未修复 ${unresolved.length} 条、已修复 ${issues.length - unresolved.length} 条。`);
183
184
  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
- }
185
+ // PR1 注入去重:未修复项明细不在此重复(完整清单已在 body「本轮修复清单」节,P0-3 单一信息源)
190
186
  }
191
187
  else {
192
188
  parts.push('- 事件流未找到 review/failed 记录;请先核对任务体「本轮修复清单」节,再完成返工。');
@@ -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
+ }
@@ -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';
@@ -414,6 +415,9 @@ function startDispatcherInner(ctx, configProvider, storageDir, logFile, provider
414
415
  kanban,
415
416
  workspacesRoot: join(storageDir, 'workspaces'),
416
417
  listLiveAgents: () => (ctx.get('agents')?.list?.() ?? []),
418
+ // preset 判定统一走 session-preset.ts(引擎真相优先):只读 header 会把
419
+ // 「蜂群模式」标签会话(header=宿主默认 ptc)误判成主会话越权。
420
+ readPreset: (agent) => sessionPresetOf(ctx, agent),
417
421
  });
418
422
  kanban.setOnChainCompleted(async (chainId) => {
419
423
  // 传入本链发起工作区(Chain.workspaceDir),审计仅扫描该工作区内的会话,排除其他项目主会话
@@ -455,21 +459,29 @@ function startDispatcherInner(ctx, configProvider, storageDir, logFile, provider
455
459
  // guard 内部仅对 kanban-dt 系会话收紧;DT 父会话自身仍由 agent-runner 的 agent.ctx
456
460
  // guard 双保险。dispose 时注销。
457
461
  const toolsSvc = ctx.get('tools');
458
- const unguardSubagents = toolsSvc?.guard?.(buildSubagentTreeGuard());
462
+ // 三个 preset 判定 guard 共用统一读面(session-preset.ts):引擎真相优先,避免
463
+ // 「蜂群模式」标签会话(header=宿主默认 ptc)绕过只读/评审硬闸。
464
+ const readPreset = (agent) => sessionPresetOf(ctx, agent);
465
+ const unguardSubagents = toolsSvc?.guard?.(buildSubagentTreeGuard({ readPreset }));
459
466
  if (unguardSubagents) {
460
467
  ctx.on('dispose', unguardSubagents);
461
468
  }
462
- // 蜂群模式硬闸:header.agentPreset==='swarm' 时只读+git 反选拦截;
469
+ // 蜂群模式硬闸:会话 preset==='swarm'(统一读面,非 header)时只读+git 反选拦截;
463
470
  // 内部对其余会话恒放行。dispose 时注销(与 subagent guard 同款)。
464
- const unguardSwarm = toolsSvc?.guard?.(buildSwarmSessionGuard());
471
+ const unguardSwarm = toolsSvc?.guard?.(buildSwarmSessionGuard({ readPreset }));
465
472
  if (unguardSwarm) {
466
473
  ctx.on('dispose', unguardSwarm);
467
474
  }
468
- // 独立评审硬闸:header.agentPreset==='kanban-dt' 且未经角色组合(session id 非 kbn-
475
+ // 独立评审硬闸:会话 preset==='kanban-dt'(统一读面)且未经角色组合(session id 非 kbn-
469
476
  // 前缀)时收紧(看板写工具拒、bash/run_code 仅只读 git+clone/fetch、wiki_write 仅
470
- // reviews 命名空间);wiki_write 对非评审会话全局收紧(链上组合会话放行)。dispose
471
- // 时注销(与 subagent/swarm guard 同款)。
472
- const unguardStandalone = toolsSvc?.guard?.(buildStandaloneDtGuard());
477
+ // reviews 命名空间);wiki_write 对非评审会话全局收紧(链上组合会话/swarm 主会话/
478
+ // W preset 会话放行)。dispose 时注销(与 subagent/swarm guard 同款)。
479
+ const unguardStandalone = toolsSvc?.guard?.(buildStandaloneDtGuard({
480
+ // wiki_write 放行白名单热读配置(applyOverride 后下次工具调用即生效);
481
+ // config 缺字段回 [] fail-closed(默认值由 config.ts schema 保证存在)。
482
+ getWikiWritePresets: () => configProvider.getEffective().wikiWritePresets ?? [],
483
+ readPreset,
484
+ }));
473
485
  if (unguardStandalone) {
474
486
  ctx.on('dispose', unguardStandalone);
475
487
  }
@@ -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
+ }
@@ -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;
@@ -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 语义)。 */