@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/README.md +165 -116
- package/README.zh-CN.md +143 -96
- package/client/timeline-model.ts +14 -1
- package/lib/client.js +20 -2
- package/lib/config.d.ts +17 -0
- package/lib/config.js +11 -0
- package/lib/dispatcher/agent-runner.js +3 -7
- package/lib/dispatcher/chain-auditor.d.ts +7 -1
- package/lib/dispatcher/chain-auditor.js +7 -4
- package/lib/dispatcher/context-dedup.d.ts +1 -0
- package/lib/dispatcher/context-dedup.js +24 -0
- package/lib/dispatcher/dispatcher.js +19 -7
- package/lib/dispatcher/session-preset.d.ts +36 -0
- package/lib/dispatcher/session-preset.js +67 -0
- package/lib/domain/evidence-check.d.ts +17 -0
- package/lib/domain/evidence-check.js +44 -0
- package/lib/domain/gate-policy.d.ts +33 -9
- package/lib/domain/gate-policy.js +60 -19
- package/lib/domain/kanban-service.d.ts +27 -4
- package/lib/domain/kanban-service.js +45 -7
- package/lib/domain/review-evidence.js +3 -1
- package/lib/domain/types.d.ts +7 -1
- package/lib/roles/toolsets.d.ts +33 -10
- package/lib/roles/toolsets.js +41 -15
- package/lib/services/evidence-replay.d.ts +28 -0
- package/lib/services/evidence-replay.js +79 -0
- package/lib/services/gate-evidence.d.ts +2 -0
- package/lib/services/gate-evidence.js +21 -0
- package/lib/services/im-delivery.d.ts +4 -0
- package/lib/services/im-delivery.js +31 -3
- package/lib/services/kanban-provider.d.ts +1 -0
- package/lib/services/kanban-provider.js +92 -19
- package/lib/tools/main-session-tools.js +6 -13
- package/package.json +1 -1
- package/personas/persona-dt.md +3 -0
|
@@ -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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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() });
|
|
@@ -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
|
-
|
|
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)
|
package/lib/domain/types.d.ts
CHANGED
|
@@ -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;
|
package/lib/roles/toolsets.d.ts
CHANGED
|
@@ -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 角色会话的"子代理"(
|
|
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
|
|
70
|
-
* 精准判定(先例 buildSubagentTreeGuard
|
|
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
|
-
*
|
|
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
|
|
@@ -96,10 +103,25 @@ export declare function buildSwarmSessionGuard(): (execution: {
|
|
|
96
103
|
* 落盘是 git 自身行为,BASH_WRITE_RE 无此二动词天然放行,无需特判);
|
|
97
104
|
* 3. wiki_write(独立模式)→ 仅放行 projects/<repo>/reviews/<主题>-<日期>/ 命名空间;
|
|
98
105
|
* 4. wiki_write(非独立会话)→ 链上组合会话(isRoleComposed)放行(自有 agent-scope
|
|
99
|
-
* guard 管,链命名空间写入绝不被本 guard
|
|
100
|
-
*
|
|
106
|
+
* guard 管,链命名空间写入绝不被本 guard 误拦);swarm 主会话与 W preset 会话放行
|
|
107
|
+
* (2026-09-16 放宽,写面由工具内路径白名单约束);其余(main/未知 GUI)拒绝
|
|
108
|
+
* ——评审工具全局注册后 wiki_write 收紧到评审/知识会话;
|
|
101
109
|
* 5. 其余工具(read/glob/grep/ocr_review/wiki_read/wiki_search 等)→ 放行。 */
|
|
102
|
-
|
|
110
|
+
/** wiki_write 会话级放行 preset 默认白名单:与 config.ts wikiWritePresets 默认值同源,
|
|
111
|
+
* 仅供 guard deps 未接线(测试/裸调用)时兜底;生产经 dispatcher 注入热读配置。
|
|
112
|
+
* 'ptc':普通(PTC 模式)会话亦可写 wiki 命名空间——写面本身由 wiki_write 工具内的
|
|
113
|
+
* 五类命名空间白名单硬约束;2026-09-17 起 preset 判定走统一读面(session-preset.ts),
|
|
114
|
+
* 'ptc' 不再是「蜂群会话被误读成 ptc」的兜底,而是显式放行普通会话。 */
|
|
115
|
+
export declare const DEFAULT_WIKI_WRITE_PRESETS: ReadonlyArray<string>;
|
|
116
|
+
export interface StandaloneDtGuardDeps {
|
|
117
|
+
/** wiki_write 放行 preset 白名单热读(dispatcher 注入 () => getEffective().wikiWritePresets;
|
|
118
|
+
* 缺省回落 DEFAULT_WIKI_WRITE_PRESETS——测试与未接线场景保持默认行为)。 */
|
|
119
|
+
getWikiWritePresets?: () => ReadonlyArray<string>;
|
|
120
|
+
/** 会话 preset 读取(生产由 dispatcher 注入 session-preset.ts 的统一实现;缺省回落 header
|
|
121
|
+
* ——创建事实,仅供测试/未接线场景保留既有行为)。 */
|
|
122
|
+
readPreset?(agent: unknown): string;
|
|
123
|
+
}
|
|
124
|
+
export declare function buildStandaloneDtGuard(deps?: StandaloneDtGuardDeps): (execution: {
|
|
103
125
|
name?: string;
|
|
104
126
|
arguments?: unknown;
|
|
105
127
|
agent?: unknown;
|
|
@@ -107,7 +129,8 @@ export declare function buildStandaloneDtGuard(): (execution: {
|
|
|
107
129
|
/** 独立评审工具全局注册:ocr_review + wiki 三原语(wiki_read/wiki_search/wiki_write)。
|
|
108
130
|
* 独立评审不经 agent-runner 角色组合,角色工具面不存在,故在插件全局 ctx 注册;
|
|
109
131
|
* caller 固定 actor='dt'(can('wiki-write','dt')=true),wiki_write 的会话级收紧由
|
|
110
|
-
* buildStandaloneDtGuard
|
|
132
|
+
* buildStandaloneDtGuard 完成(链上组合会话/swarm 主会话/W preset 会话放行,
|
|
133
|
+
* main/未知 GUI 拒绝)。
|
|
111
134
|
* wiki 客户端按 kbMode 构造(与 registerMainSessionTools 同源:remote 优先 ctx.get('wiki')
|
|
112
135
|
* 注入(测试 mock),生产热读取 getEffective().wikiVault;local 走 LocalWikiClient)。
|
|
113
136
|
* local 模式不注册 wiki_write:LocalWikiClient.abs 只接受 wiki/** 形态,而 wiki_write
|
package/lib/roles/toolsets.js
CHANGED
|
@@ -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 角色会话的"子代理"(
|
|
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 ||
|
|
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
|
|
465
|
-
* 精准判定(先例 buildSubagentTreeGuard
|
|
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 ||
|
|
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
|
-
*
|
|
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
|
|
@@ -581,23 +588,32 @@ function dualVerbGitDenyReason(verb, rest, deny) {
|
|
|
581
588
|
* 落盘是 git 自身行为,BASH_WRITE_RE 无此二动词天然放行,无需特判);
|
|
582
589
|
* 3. wiki_write(独立模式)→ 仅放行 projects/<repo>/reviews/<主题>-<日期>/ 命名空间;
|
|
583
590
|
* 4. wiki_write(非独立会话)→ 链上组合会话(isRoleComposed)放行(自有 agent-scope
|
|
584
|
-
* guard 管,链命名空间写入绝不被本 guard
|
|
585
|
-
*
|
|
591
|
+
* guard 管,链命名空间写入绝不被本 guard 误拦);swarm 主会话与 W preset 会话放行
|
|
592
|
+
* (2026-09-16 放宽,写面由工具内路径白名单约束);其余(main/未知 GUI)拒绝
|
|
593
|
+
* ——评审工具全局注册后 wiki_write 收紧到评审/知识会话;
|
|
586
594
|
* 5. 其余工具(read/glob/grep/ocr_review/wiki_read/wiki_search 等)→ 放行。 */
|
|
587
|
-
|
|
595
|
+
/** wiki_write 会话级放行 preset 默认白名单:与 config.ts wikiWritePresets 默认值同源,
|
|
596
|
+
* 仅供 guard deps 未接线(测试/裸调用)时兜底;生产经 dispatcher 注入热读配置。
|
|
597
|
+
* 'ptc':普通(PTC 模式)会话亦可写 wiki 命名空间——写面本身由 wiki_write 工具内的
|
|
598
|
+
* 五类命名空间白名单硬约束;2026-09-17 起 preset 判定走统一读面(session-preset.ts),
|
|
599
|
+
* 'ptc' 不再是「蜂群会话被误读成 ptc」的兜底,而是显式放行普通会话。 */
|
|
600
|
+
export const DEFAULT_WIKI_WRITE_PRESETS = ['swarm', 'kanban-w', 'ptc'];
|
|
601
|
+
export function buildStandaloneDtGuard(deps = {}) {
|
|
602
|
+
const readPreset = deps.readPreset ?? headerPresetOf;
|
|
588
603
|
return (execution) => {
|
|
589
604
|
const header = extractSessionHeader(execution?.agent);
|
|
590
605
|
const name = String(execution?.name ?? '');
|
|
591
606
|
const agent = execution?.agent;
|
|
592
607
|
const sessionId = typeof agent?.id === 'string' ? agent.id : undefined;
|
|
608
|
+
const preset = readPreset(execution?.agent);
|
|
593
609
|
// 链上系 DT 子代理(parentSession 为 kbn-<taskId> 前缀):交由 buildSubagentTreeGuard 管,
|
|
594
610
|
// 本 guard 不按独立模式收紧、也不在非独立分支拒其 wiki_write(链评审目录写入归该 guard 校验)。
|
|
595
611
|
const isDtChainSubagent = !!header
|
|
596
|
-
&&
|
|
612
|
+
&& preset === 'kanban-dt'
|
|
597
613
|
&& typeof header.parentSession === 'string'
|
|
598
614
|
&& header.parentSession.startsWith('kbn-');
|
|
599
615
|
const standalone = !!header
|
|
600
|
-
&&
|
|
616
|
+
&& preset === 'kanban-dt'
|
|
601
617
|
&& !isRoleComposed(execution?.agent)
|
|
602
618
|
&& !isDtChainSubagent
|
|
603
619
|
&& !(sessionId !== undefined && sessionId.startsWith('kbn-'));
|
|
@@ -648,7 +664,16 @@ export function buildStandaloneDtGuard() {
|
|
|
648
664
|
if (name === 'wiki_write') {
|
|
649
665
|
if (isRoleComposed(execution?.agent) || isDtChainSubagent)
|
|
650
666
|
return undefined;
|
|
651
|
-
|
|
667
|
+
// 放宽(2026-09-16):swarm 主会话与 W preset 会话可写 wiki——can('wiki-write') 本就含 w
|
|
668
|
+
// (permissions.ts),写面由 wiki_write 工具内 assertAllowedWikiPagePath 五类命名空间
|
|
669
|
+
// 白名单硬约束;local 模式 wiki_write 未全局注册,无工具面即无暴露面。
|
|
670
|
+
// 放行面配置化(硬规则 6):白名单经 deps 热读 config.wikiWritePresets,宿主 preset
|
|
671
|
+
// 体系演化/部署自定义模式 id 时改配置即可;config 缺字段回 [](fail-closed,仅内置默认兜底测试)。
|
|
672
|
+
const allowed = deps.getWikiWritePresets?.() ?? DEFAULT_WIKI_WRITE_PRESETS;
|
|
673
|
+
if (preset !== '' && allowed.includes(preset))
|
|
674
|
+
return undefined;
|
|
675
|
+
// 诊断尾巴:报错带实际 preset(或"无"),用户重试一次即可定位会话模式判定缺口
|
|
676
|
+
return 'wiki-write-restricted-to-reviewer-sessions: wiki_write 仅限 DT 评审会话 / W 角色 / swarm 主会话使用(当前会话 preset=' + (preset || '无') + ')';
|
|
652
677
|
}
|
|
653
678
|
return undefined;
|
|
654
679
|
};
|
|
@@ -656,7 +681,8 @@ export function buildStandaloneDtGuard() {
|
|
|
656
681
|
/** 独立评审工具全局注册:ocr_review + wiki 三原语(wiki_read/wiki_search/wiki_write)。
|
|
657
682
|
* 独立评审不经 agent-runner 角色组合,角色工具面不存在,故在插件全局 ctx 注册;
|
|
658
683
|
* caller 固定 actor='dt'(can('wiki-write','dt')=true),wiki_write 的会话级收紧由
|
|
659
|
-
* buildStandaloneDtGuard
|
|
684
|
+
* buildStandaloneDtGuard 完成(链上组合会话/swarm 主会话/W preset 会话放行,
|
|
685
|
+
* main/未知 GUI 拒绝)。
|
|
660
686
|
* wiki 客户端按 kbMode 构造(与 registerMainSessionTools 同源:remote 优先 ctx.get('wiki')
|
|
661
687
|
* 注入(测试 mock),生产热读取 getEffective().wikiVault;local 走 LocalWikiClient)。
|
|
662
688
|
* local 模式不注册 wiki_write:LocalWikiClient.abs 只接受 wiki/** 形态,而 wiki_write
|
|
@@ -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 {};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// src/services/evidence-replay.ts
|
|
2
|
+
/** DT 评审证据三级核验编排(PR2):L1 缺证标记 → L2 纸面核对(零执行)→ L3 重放(默认关,非沙箱)。
|
|
3
|
+
* 非阻塞:任何结果只返回留痕数据,不改 verdict、不 throw。 */
|
|
4
|
+
import { readFile } from 'node:fs/promises';
|
|
5
|
+
import { runGateCommands } from './gate-runner.js';
|
|
6
|
+
import { isAllowedCommand, paperCheck, parseIssueEvidence } from '../domain/evidence-check.js';
|
|
7
|
+
/** 沿父链找最近 execute 卡的 worktree_dir(重放 cwd=被评审代码所在处)。 */
|
|
8
|
+
export function resolveTargetWorktree(parents) {
|
|
9
|
+
for (const p of parents) {
|
|
10
|
+
if (p.assignee === 'd' && p.mode === 'execute') {
|
|
11
|
+
const wt = p.metadata?.['worktree_dir'];
|
|
12
|
+
if (typeof wt === 'string' && wt.trim())
|
|
13
|
+
return wt;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
export async function checkIssueEvidence(input) {
|
|
19
|
+
const list = Array.isArray(input.issues) ? input.issues : [];
|
|
20
|
+
const forbidden = input.forbidden ?? ['rm -rf /', 'git push'];
|
|
21
|
+
const out = [];
|
|
22
|
+
for (const rawIssue of list) {
|
|
23
|
+
// 评审 Important:per-issue 容错——被核验者塞 null/标量元素不得让整批核验无痕消失
|
|
24
|
+
if (typeof rawIssue !== 'object' || rawIssue === null) {
|
|
25
|
+
out.push({ title: '(invalid issue entry)', severity: 'unknown', state: 'could-not-replay', detail: 'issues 数组含非对象元素 — needs-human 核对' });
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
const issue = rawIssue;
|
|
29
|
+
const title = String(issue['title'] ?? '(untitled)');
|
|
30
|
+
const severity = String(issue['severity'] ?? 'low');
|
|
31
|
+
const ev = parseIssueEvidence(issue['evidence']);
|
|
32
|
+
if (!ev) {
|
|
33
|
+
const serious = severity === 'critical' || severity === 'high';
|
|
34
|
+
out.push(serious
|
|
35
|
+
? { title, severity, state: 'could-not-replay', detail: 'no evidence provided (critical/high) — needs-human 核对' }
|
|
36
|
+
: { title, severity, state: 'not-provided', detail: 'no evidence attached' });
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
// L2 纸面核对(零执行)
|
|
40
|
+
let content = null;
|
|
41
|
+
try {
|
|
42
|
+
content = await input.readFile(ev.file);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
content = null;
|
|
46
|
+
}
|
|
47
|
+
const paper = paperCheck(content, ev.exit);
|
|
48
|
+
if (paper === 'matches') {
|
|
49
|
+
out.push({ title, severity, state: 'matches', detail: `paper check ok: ${ev.file}` });
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
// L3 重放(对不上才动用;默认关)
|
|
53
|
+
if (!input.replayEnabled) {
|
|
54
|
+
out.push({ title, severity, state: 'could-not-replay', detail: `paper ${paper}; replay disabled by config — needs-human` });
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (!isAllowedCommand(ev.command, input.allowPrefixes)) {
|
|
58
|
+
out.push({ title, severity, state: 'could-not-replay', detail: `command not allowlisted: ${ev.command}` });
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (!input.worktreeDir) {
|
|
62
|
+
out.push({ title, severity, state: 'could-not-replay', detail: 'no target worktree resolved' });
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const cmd = { command: ev.command, cwd: input.worktreeDir, source: 'tdd' };
|
|
66
|
+
const report = await runGateCommands([cmd], { timeoutMs: input.timeoutMs, forbidden }, input.run);
|
|
67
|
+
// 评审 Important:TIMEOUT/SPAWN_FAIL/FORBIDDEN ≠ 假证据——超时/派生失败/黑名单命中落 could-not-replay,
|
|
68
|
+
// 仅真实执行(含非零退出)与声明不符才判 differs(四态语义准确性)
|
|
69
|
+
if (report.failure && report.failure.code !== 'NONZERO') {
|
|
70
|
+
out.push({ title, severity, state: 'could-not-replay', detail: `replay ${report.failure.code}: ${report.failure.detail} — needs-human` });
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const rerun = report.results.at(-1)?.exitCode;
|
|
74
|
+
out.push(rerun !== null && rerun === ev.exit
|
|
75
|
+
? { title, severity, state: 'matches', detail: `replay confirmed exit ${rerun}` }
|
|
76
|
+
: { title, severity, state: 'differs', detail: `replay exit=${rerun} ≠ claimed ${ev.exit} — 假证据疑点,needs-human` });
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// src/services/gate-evidence.ts
|
|
2
|
+
/** gate 实测日志落盘(PR1 P3 存档):pass 也留原始输出,事后可翻账对质(issue #2 verify.sh 模式的 D 侧落点)。
|
|
3
|
+
* 追加写(每轮一条时间戳头),单文件 per task,路径进 gate detail 供 agent/human 引用。 */
|
|
4
|
+
import { mkdir, appendFile } from 'node:fs/promises';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
export async function writeGateLog(dir, taskId, report) {
|
|
7
|
+
const logDir = join(dir, 'gate-logs');
|
|
8
|
+
await mkdir(logDir, { recursive: true });
|
|
9
|
+
const path = join(logDir, `${taskId}.log`);
|
|
10
|
+
const lines = [`== ${new Date().toISOString()} ok=${report.ok}`];
|
|
11
|
+
for (const r of report.results) {
|
|
12
|
+
lines.push(`-- ${r.command}`);
|
|
13
|
+
lines.push(`exit=${r.exitCode} durationMs=${r.durationMs} truncated=${r.truncated}`);
|
|
14
|
+
if (r.output)
|
|
15
|
+
lines.push(r.output);
|
|
16
|
+
}
|
|
17
|
+
if (report.failure)
|
|
18
|
+
lines.push(`-- failure: ${report.failure.code}: ${report.failure.detail}`);
|
|
19
|
+
await appendFile(path, lines.join('\n') + '\n');
|
|
20
|
+
return path;
|
|
21
|
+
}
|
|
@@ -72,6 +72,10 @@ export interface BotChoice {
|
|
|
72
72
|
via: BotVia;
|
|
73
73
|
fallbackSaved?: boolean;
|
|
74
74
|
}
|
|
75
|
+
/** 应答 → 候选 botId(宽松匹配,宁缺勿错):
|
|
76
|
+
* ① 令牌精确相等;② 与令牌互相包含(仅当令牌足够长,防 3~5 字符误命中)。
|
|
77
|
+
* 多义(>=2 个候选命中)或无法识别 → 空串(调用方 fail-closed 不投,绝不猜)。 */
|
|
78
|
+
export declare function matchBotAnswer(options: BotAskOption[], raw: string): string;
|
|
75
79
|
/** 机器人决策链:显式配置 → 唯一机器人 → 会话 preset 命中 → 默认机器人 → 交互选择。
|
|
76
80
|
* 每环都 fail-closed(宁可不投也不投错对象);探针不可用只失去自动匹配能力,不阻断投递。 */
|
|
77
81
|
export declare function chooseBot(im: DshImLike, cfg: {
|
|
@@ -32,6 +32,33 @@ function resolveDshIm(ctx, log) {
|
|
|
32
32
|
function askLabel(o) {
|
|
33
33
|
return o.label ? `${o.label}(${o.botId})` : o.botId;
|
|
34
34
|
}
|
|
35
|
+
/** 归一:去空白(含全角空格)+ 小写(GUI 可能回传带装饰/大小写变化的文本)。 */
|
|
36
|
+
function normAnswer(s) {
|
|
37
|
+
return s.replace(/[\s\u3000]/g, '').toLowerCase();
|
|
38
|
+
}
|
|
39
|
+
/** 一个候选的全部可识别令牌:botId / 展示 label 全文 / label 的展示名段(去 ·模式·离线 与 (botId)后缀)。 */
|
|
40
|
+
function idTokens(o) {
|
|
41
|
+
const out = [normAnswer(o.botId)];
|
|
42
|
+
if (o.label) {
|
|
43
|
+
const n = normAnswer(o.label);
|
|
44
|
+
out.push(n.split('(')[0], n.split('·')[0]);
|
|
45
|
+
}
|
|
46
|
+
return [...new Set(out.filter((t) => t.length > 0))];
|
|
47
|
+
}
|
|
48
|
+
/** 应答 → 候选 botId(宽松匹配,宁缺勿错):
|
|
49
|
+
* ① 令牌精确相等;② 与令牌互相包含(仅当令牌足够长,防 3~5 字符误命中)。
|
|
50
|
+
* 多义(>=2 个候选命中)或无法识别 → 空串(调用方 fail-closed 不投,绝不猜)。 */
|
|
51
|
+
export function matchBotAnswer(options, raw) {
|
|
52
|
+
const target = normAnswer(raw);
|
|
53
|
+
if (!target)
|
|
54
|
+
return '';
|
|
55
|
+
const exact = options.find((o) => idTokens(o).includes(target));
|
|
56
|
+
if (exact)
|
|
57
|
+
return exact.botId;
|
|
58
|
+
const partial = options.filter((o) => idTokens(o).some((t) => t.length >= 6 && (t.includes(target) || target.includes(t))));
|
|
59
|
+
const uniq = [...new Set(partial.map((o) => o.botId))];
|
|
60
|
+
return uniq.length === 1 ? uniq[0] : '';
|
|
61
|
+
}
|
|
35
62
|
/** 多机器人且无法自动决策时的 fail-closed 报错:列候选(有探针数据则带模式与连接态)。 */
|
|
36
63
|
function botCandidatesError(ids, probed) {
|
|
37
64
|
const byId = new Map(probed.map((b) => [b.botId, b]));
|
|
@@ -212,10 +239,11 @@ function buildBotAsk(ctx, agent, log) {
|
|
|
212
239
|
const pick = ans.answers?.find((a) => a.id === 'im-bot');
|
|
213
240
|
const typed = pick?.custom?.trim() ?? '';
|
|
214
241
|
const selected = pick?.selected?.[0]?.trim() ?? '';
|
|
215
|
-
//
|
|
216
|
-
const botId = options
|
|
242
|
+
// 选中标签与自由文本都接受,但必须唯一落到候选之一——防手写错 id 投到未知对象。
|
|
243
|
+
const botId = matchBotAnswer(options, selected) || matchBotAnswer(options, typed);
|
|
217
244
|
if (!botId) {
|
|
218
|
-
|
|
245
|
+
// 只记候选 id 与用户可见文本(公开标识,无敏感载荷),便于下次定位映射失败。
|
|
246
|
+
log(`[im-delivery] 机器人交互已应答但未映射到候选: selected=${JSON.stringify(selected).slice(0, 120)} custom=${JSON.stringify(typed).slice(0, 120)} candidates=${options.map((o) => o.botId).join(',')}`);
|
|
219
247
|
return null;
|
|
220
248
|
}
|
|
221
249
|
const setDefault = (ans.answers?.find((a) => a.id === 'im-bot-default')?.selected ?? []).includes(ASK_SET_DEFAULT_LABEL);
|
|
@@ -16,5 +16,6 @@ export declare class KanbanProvider extends Service {
|
|
|
16
16
|
/** 整链硬删除后的联动钩子(由 startDispatcher 装配注入):dispatcher 游标同步 + V 编排 entry 清理。
|
|
17
17
|
* purge 物理重排 events seq,若不同步则删链后新建链的可唤醒事件被运行中实例永久跳过。 */
|
|
18
18
|
onChainDeleted: ((chainId: string) => Promise<void> | void) | null;
|
|
19
|
+
private readonly gateLogDir;
|
|
19
20
|
constructor(ctx: Context, config: KanbanConfig, configProvider: ConfigProvider);
|
|
20
21
|
}
|