@yangdcm/dsh-expert-team 1.1.0
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/LICENSE +21 -0
- package/README.en.md +141 -0
- package/README.md +135 -0
- package/client.js +2473 -0
- package/cordis.patch.yml +24 -0
- package/lib/artifact-writer.js +379 -0
- package/lib/command-parse.js +181 -0
- package/lib/command.js +5100 -0
- package/lib/dispatch-ledger.js +229 -0
- package/lib/index.js +15 -0
- package/lib/interception.js +266 -0
- package/lib/lead-toolface.js +179 -0
- package/lib/log-parse.js +181 -0
- package/lib/loop-guard.js +165 -0
- package/lib/metrics/collect.js +70 -0
- package/lib/metrics/render.js +100 -0
- package/lib/metrics/session-usage.js +319 -0
- package/lib/metrics/timing.js +188 -0
- package/lib/metrics/token-usage.js +352 -0
- package/lib/metrics/tokens.js +271 -0
- package/lib/routes/shared.js +83 -0
- package/lib/settings.js +289 -0
- package/lib/tier.js +190 -0
- package/lib/validate.js +681 -0
- package/lib/vocab.js +121 -0
- package/lib/write-tracer.js +58 -0
- package/package.json +119 -0
- package/presets/expert-team/agent.cordis.yml +542 -0
- package/presets/expert-team/preset.yml +3 -0
- package/skills/expert-team/SKILL.md +328 -0
- package/skills/expert-team/assets/templates/AUTHORITY.md +32 -0
- package/skills/expert-team/assets/templates/PLAN.md +27 -0
- package/skills/expert-team/assets/templates/RESEARCH.md +13 -0
- package/skills/expert-team/assets/templates/RETRO.md +24 -0
- package/skills/expert-team/assets/templates/REVIEW.md +10 -0
- package/skills/expert-team/assets/templates/ROSTER.json +6 -0
- package/skills/expert-team/assets/templates/SPEC.md +62 -0
- package/skills/expert-team/assets/templates/STATE.json +10 -0
- package/skills/expert-team/assets/templates/SUMMARY.md +25 -0
- package/skills/expert-team/assets/templates/TASK.md +23 -0
- package/skills/expert-team/assets/templates/TASKS.json +3 -0
- package/skills/expert-team/assets/templates/TEST.md +9 -0
- package/skills/expert-team/assets/templates//344/273/273/345/212/241/347/234/213/346/235/277.md +23 -0
- package/skills/expert-team/references/EFFICIENCY.md +79 -0
- package/skills/expert-team/references/LOGGING.md +82 -0
- package/skills/expert-team/references/PERSIST.md +57 -0
- package/skills/expert-team/references/PIPELINE.md +58 -0
- package/skills/expert-team/references/ROLES.md +297 -0
- package/skills/expert-team/references/WORKSPACE.md +123 -0
- package/skills/expert-team/references/workflow.team.js +97 -0
- package/skills/expert-team/scripts/scan-authority.mjs +114 -0
- package/skills/expert-team/scripts/scan-single-source.mjs +292 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// 派工即回写(C 线第 17 项):**派工成功后由代码把任务置为 `in_progress` + `owner`**,
|
|
2
|
+
// 而不是靠模型记得改。
|
|
3
|
+
//
|
|
4
|
+
// 为什么需要:对照 Qoder —— 它派工成功后由**服务端**自动置 `in_progress` + `owner`;
|
|
5
|
+
// 我们这边 `SKILL.md §7.10` 要求 lead「每次派工和每次结算都必须立即回写 TASKS.json」,
|
|
6
|
+
// 但那是**散文规则 + 模型自觉**。实测代价:`/team check` 与浮层红条把「依赖已就绪却仍
|
|
7
|
+
// pending」判为「状态冻结」违规 —— 也就是说,**规则没被执行的后果由用户看见**(红条),
|
|
8
|
+
// 而执行它的动作全靠模型记得。本模块把"派工 → in_progress + owner"这一半交给代码。
|
|
9
|
+
//
|
|
10
|
+
// 落点选择(很重要):**挂在宿主 `tools/post-execute` 上,不是在读接口里顺手写**。
|
|
11
|
+
// 理由:本仓刚把 `GET /state` 的写副作用摘掉(B 线 10a)—— 读接口每 3 秒轮询,
|
|
12
|
+
// 在里面写盘 = 周期性改写用户工件 + 与 lead 的写并发。**派工是一次真实事件,就该由事件驱动。**
|
|
13
|
+
//
|
|
14
|
+
// 安全边界(宁可少记,不可错记;错了比不记更贵):
|
|
15
|
+
// ① **只认 `subagent*` 工具的调用**,且**只解析 `label`**(不解析 prompt —— 提示词里
|
|
16
|
+
// 「依赖 T01 已完成」这类提及会把别人正在做的任务也标成 in_progress)。
|
|
17
|
+
// ② 标签里的任务 id 必须**逐字命中 TASKS.json 里已存在的 id**(按词边界,`T2` 不命中 `T24`)。
|
|
18
|
+
// ③ **只翻 `pending → in_progress`**:终态(completed/failed/cancelled…)一律不动;
|
|
19
|
+
// 已 `in_progress` 且是**别人**在做 ⇒ 不覆盖,只记一条冲突事件(归属不许被代码改掉)。
|
|
20
|
+
// ④ `owner` 只在任务**没有 owner** 时写入;已有 owner 与本次派工角色不一致时保留原 owner。
|
|
21
|
+
// ⑤ 派工**失败**(工具结果 isError)⇒ 不记账(没派出去就不该显示"在做")。
|
|
22
|
+
// ⑥ 任何异常都**吞掉并放行**:本监听器绝不允许成为工具调用的故障源(与 B2-1 同一条纪律)。
|
|
23
|
+
|
|
24
|
+
/** 派工类工具名(角色工具 `subagent_backend` / 通用 `subagent`)。 */
|
|
25
|
+
export const DISPATCH_TOOL_RE = /^subagent(\b|_|$)/;
|
|
26
|
+
|
|
27
|
+
/** `workflow` 扇出工具名(**one-shot 模式的默认派工路径**,SKILL §5)。 */
|
|
28
|
+
export const WORKFLOW_TOOL = 'workflow';
|
|
29
|
+
|
|
30
|
+
/** 是否是派工工具调用(角色工具 / 通用 subagent)。 */
|
|
31
|
+
export function isDispatchTool(name) {
|
|
32
|
+
return DISPATCH_TOOL_RE.test(String(name || ''));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 是否是 `workflow` 扇出(一次调用派多腿,每腿的 `label` 自带任务 id)。 */
|
|
36
|
+
export function isWorkflowTool(name) {
|
|
37
|
+
return String(name || '') === WORKFLOW_TOOL;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 从 `workflow` **脚本正文**里抽出所有 leg 的 `label`。
|
|
42
|
+
*
|
|
43
|
+
* 为什么是这里:one-shot 模式(`/team` 的**默认**模式)不调 `subagent_*`,而是把整支团队写成
|
|
44
|
+
* 一段脚本一次扇出 —— 只看 `subagent*` 的话,这个功能在默认路径上**一次都不会生效**
|
|
45
|
+
*(2026-09-13 真实 run 实测:29 个任务、`mode=one-shot`、带自动认领记号的 **0** 个)。
|
|
46
|
+
*
|
|
47
|
+
* ⚠️ 只认 `label`,**不认 prompt**(与 subagent 那条同一纪律:提示词里"依赖 T01 已完成"这类提及
|
|
48
|
+
* 会把别人正在做的任务也标成在做)。SKILL §7.22 ④ 本来就要求 leg 的 `label` 用
|
|
49
|
+
* `【<中文角色>】<中文任务>` 且带任务 id —— 这条从"建议"变成了**代码依赖**。
|
|
50
|
+
*
|
|
51
|
+
* @param script - `workflow` 的 `script` 参数(JS 源码文本)。
|
|
52
|
+
* @returns label 字符串数组(按出现顺序)。
|
|
53
|
+
*/
|
|
54
|
+
export function labelsInWorkflowScript(script) {
|
|
55
|
+
const out = [];
|
|
56
|
+
// 先剥注释:脚本里写 `// label: '示例'` 或块注释里的示例不该被当成真派工。
|
|
57
|
+
// (顺序上先块注释后行注释;`//` 出现在字符串里的情况极少,且剥错了只会**少记**不会错记。)
|
|
58
|
+
const text = String(script || '')
|
|
59
|
+
.replace(/\/\*[\s\S]*?\*\//g, ' ')
|
|
60
|
+
.replace(/(^|[^:])\/\/[^\n]*/g, '$1');
|
|
61
|
+
const re = /label\s*:\s*(['"`])((?:\\.|(?!\1)[^\\])*)\1/g;
|
|
62
|
+
let m;
|
|
63
|
+
while ((m = re.exec(text)) !== null) out.push(m[2]);
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 正则元字符转义(任务 id 来自文件,不假设它一定干净)。 */
|
|
68
|
+
function escapeRegExp(s) {
|
|
69
|
+
return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 从**派工标签**里找出本 run 真实存在的任务 id(按词边界,避免 `T2` 命中 `T24`)。
|
|
74
|
+
*
|
|
75
|
+
* 只认"已存在的 id"这一条,让本函数天然保守:编造/写错的 id 一律不产生副作用。
|
|
76
|
+
*
|
|
77
|
+
* @param label - 派工标签(形如 `【后端工程师】实现 B4/B1`)。
|
|
78
|
+
* @param knownIds - 该 run `TASKS.json` 里全部任务 id。
|
|
79
|
+
* @returns 命中的 id 数组(按已知 id 的顺序,去重)。
|
|
80
|
+
*/
|
|
81
|
+
export function taskIdsInLabel(label, knownIds) {
|
|
82
|
+
const text = String(label || '');
|
|
83
|
+
const out = [];
|
|
84
|
+
const seen = new Set();
|
|
85
|
+
for (const raw of Array.isArray(knownIds) ? knownIds : []) {
|
|
86
|
+
const id = String(raw || '').trim();
|
|
87
|
+
if (!id || seen.has(id)) continue;
|
|
88
|
+
if (new RegExp(`(^|[^\\w-])${escapeRegExp(id)}($|[^\\w-])`).test(text)) { out.push(id); seen.add(id); }
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 终态:任何自动记账都不许碰这些状态。 */
|
|
94
|
+
const TERMINAL = new Set(['completed', 'done', 'failed', 'cancelled']);
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 算出「这次派工该把哪些任务置为 in_progress」——**纯函数**,不改入参。
|
|
98
|
+
*
|
|
99
|
+
* @param args.tasks - `TASKS.json` 的 `tasks` 数组。
|
|
100
|
+
* @param args.ids - 本次派工标签里命中的 id(来自 `taskIdsInLabel`)。
|
|
101
|
+
* @param args.role - 本次派工的角色 id(空串表示认不出 ⇒ 不写 owner)。
|
|
102
|
+
* @param args.now - 时间戳(ISO 串)。
|
|
103
|
+
* @returns `{ next, claimed, conflicts, skipped }`
|
|
104
|
+
* - `claimed`:真正被翻成 `in_progress` 的 `{id, owner}`;
|
|
105
|
+
* - `conflicts`:**别人正在做**、因此**没有**被覆盖的 `{id, owner}`(调用方应留痕);
|
|
106
|
+
* - `skipped`:命中了但状态是终态(不动的)id。
|
|
107
|
+
*/
|
|
108
|
+
export function planAutoClaim({ tasks, ids, role = '', now = '' }) {
|
|
109
|
+
const arr = Array.isArray(tasks) ? tasks : [];
|
|
110
|
+
const claimed = [];
|
|
111
|
+
const conflicts = [];
|
|
112
|
+
const skipped = [];
|
|
113
|
+
const next = arr.map((t) => ({ ...(t && typeof t === 'object' ? t : {}) }));
|
|
114
|
+
for (const id of Array.isArray(ids) ? ids : []) {
|
|
115
|
+
const sid = String(id || '');
|
|
116
|
+
const i = next.findIndex((t) => String(t.id || '') === sid);
|
|
117
|
+
if (i < 0) continue;
|
|
118
|
+
const t = next[i];
|
|
119
|
+
const status = String(t.status || '');
|
|
120
|
+
if (TERMINAL.has(status)) { skipped.push(sid); continue; }
|
|
121
|
+
if (status === 'in_progress' || status === 'claimed') {
|
|
122
|
+
// 已经有人在做了。**同一个角色再派一次不算冲突**(可能是补一轮),换角色才算。
|
|
123
|
+
const owner = String(t.owner || '');
|
|
124
|
+
if (owner && role && owner !== role) conflicts.push({ id: sid, owner });
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
// pending(或空状态)⇒ 认领。owner 只在缺失时写入,绝不改掉已有归属。
|
|
128
|
+
const ownerBefore = String(t.owner || '');
|
|
129
|
+
t.status = 'in_progress';
|
|
130
|
+
if (!ownerBefore && role) t.owner = role;
|
|
131
|
+
if (now) t.claimedAt = now;
|
|
132
|
+
claimed.push({ id: sid, owner: String(t.owner || '') });
|
|
133
|
+
}
|
|
134
|
+
return { next, claimed, conflicts, skipped };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 创建一个 `tools/post-execute` 监听器:派工成功后自动把任务置为 `in_progress` + `owner`。
|
|
139
|
+
*
|
|
140
|
+
* @param deps - 注入依赖(IO 全部由调用方给,便于单测):
|
|
141
|
+
* `roleOfLabel(label) => string`(中文标签 → 角色 id,复用 host 的 `ROLE_LABELS_ZH`,不另造一份)、
|
|
142
|
+
* `runFor(exec) => Promise<{cwd, runId}|null>`(**定位不到就返回 null,绝不猜**)、
|
|
143
|
+
* `readTasks({cwd, runId}) => Promise<doc|null>`、
|
|
144
|
+
* `writeTasks({cwd, runId}, doc) => Promise<void>`(走受控写入口 `ARTIFACT.must`)、
|
|
145
|
+
* `now() => string`、`onEvent(type, payload)`。
|
|
146
|
+
* @returns `(exec, result, next) => Promise<PostToolDecision>`
|
|
147
|
+
*/
|
|
148
|
+
export function createDispatchLedger(deps) {
|
|
149
|
+
const {
|
|
150
|
+
roleOfLabel = () => '',
|
|
151
|
+
runFor = async () => null,
|
|
152
|
+
readTasks = async () => null,
|
|
153
|
+
writeTasks = async () => {},
|
|
154
|
+
now = () => new Date().toISOString(),
|
|
155
|
+
onEvent = () => {},
|
|
156
|
+
} = deps || {};
|
|
157
|
+
|
|
158
|
+
return async function dispatchLedger(exec, result, next) {
|
|
159
|
+
// 宿主签名是 `(exec, result, next)`;next 不是函数时降级为"不干涉"而不是抛错。
|
|
160
|
+
if (typeof next !== 'function') return { kind: 'accept' };
|
|
161
|
+
const downstream = await next();
|
|
162
|
+
try {
|
|
163
|
+
const name = String((exec && exec.name) || '');
|
|
164
|
+
const isSub = isDispatchTool(name);
|
|
165
|
+
const isWf = isWorkflowTool(name);
|
|
166
|
+
if (!isSub && !isWf) return downstream;
|
|
167
|
+
if (result && (result.isError === true || result.error)) {
|
|
168
|
+
onEvent('dispatch-ledger-skipped', { reason: 'dispatch-failed', tool: name });
|
|
169
|
+
return downstream;
|
|
170
|
+
}
|
|
171
|
+
// 两种派工形态,同一套下游:
|
|
172
|
+
// · `subagent_*`:一次一腿,标签在 `arguments.label`;
|
|
173
|
+
// · `workflow`:一次多腿,标签在**脚本正文**里(每腿一条 `label:`)。
|
|
174
|
+
const labels = isWf
|
|
175
|
+
? labelsInWorkflowScript(exec && exec.arguments && exec.arguments.script)
|
|
176
|
+
: [String((exec && exec.arguments && exec.arguments.label) || '')];
|
|
177
|
+
const usable = labels.filter((l) => String(l || '').trim());
|
|
178
|
+
if (!usable.length) {
|
|
179
|
+
onEvent('dispatch-ledger-skipped', { reason: isWf ? 'no-label-in-script' : 'no-label', tool: name });
|
|
180
|
+
return downstream;
|
|
181
|
+
}
|
|
182
|
+
const target = await runFor(exec);
|
|
183
|
+
if (!target || !target.cwd || !target.runId) {
|
|
184
|
+
onEvent('dispatch-ledger-skipped', { reason: 'no-run', tool: name, labels: usable.length });
|
|
185
|
+
return downstream;
|
|
186
|
+
}
|
|
187
|
+
const doc = await readTasks(target);
|
|
188
|
+
const tasks = doc && Array.isArray(doc.tasks) ? doc.tasks : null;
|
|
189
|
+
if (!tasks) {
|
|
190
|
+
onEvent('dispatch-ledger-skipped', { reason: 'no-tasks', runId: target.runId });
|
|
191
|
+
return downstream;
|
|
192
|
+
}
|
|
193
|
+
// 多腿时**逐腿**取 id 与角色,并按角色分组各自认领 —— 一个 workflow 里
|
|
194
|
+
// 腿 1 可能是 `【研究员】… T03`、腿 2 是 `【后端工程师】… T24/B1`,
|
|
195
|
+
// 用"第一个角色"给整批定 owner 会把 T24 记成研究员(归属错记比不记更贵)。
|
|
196
|
+
const known = tasks.map((t) => t && t.id);
|
|
197
|
+
const byRole = new Map(); // role -> ids[]
|
|
198
|
+
for (const l of usable) {
|
|
199
|
+
const gids = taskIdsInLabel(l, known);
|
|
200
|
+
if (!gids.length) continue;
|
|
201
|
+
const r = String(roleOfLabel(l) || '');
|
|
202
|
+
if (!byRole.has(r)) byRole.set(r, []);
|
|
203
|
+
for (const id of gids) if (!byRole.get(r).includes(id)) byRole.get(r).push(id);
|
|
204
|
+
}
|
|
205
|
+
if (!byRole.size) {
|
|
206
|
+
onEvent('dispatch-ledger-skipped', { reason: 'no-task-id-in-labels', runId: target.runId, labels: usable.length });
|
|
207
|
+
return downstream;
|
|
208
|
+
}
|
|
209
|
+
let cur = tasks;
|
|
210
|
+
const claimed = [];
|
|
211
|
+
const conflicts = [];
|
|
212
|
+
for (const [r, gids] of byRole) {
|
|
213
|
+
const p = planAutoClaim({ tasks: cur, ids: gids, role: r, now: now() });
|
|
214
|
+
cur = p.next;
|
|
215
|
+
claimed.push(...p.claimed.map((c) => ({ ...c, role: r })));
|
|
216
|
+
conflicts.push(...p.conflicts);
|
|
217
|
+
}
|
|
218
|
+
for (const c of conflicts) onEvent('dispatch-ledger-conflict', { runId: target.runId, ...c });
|
|
219
|
+
if (!claimed.length) return downstream; // 无事发生 ⇒ **不写盘**(避免无谓改写与并发)
|
|
220
|
+
doc.tasks = cur;
|
|
221
|
+
await writeTasks(target, doc);
|
|
222
|
+
onEvent('dispatch-ledger-claimed', { runId: target.runId, tool: name, claimed, conflicts: conflicts.length, labels: usable.length });
|
|
223
|
+
} catch (e) {
|
|
224
|
+
// 记账失败**绝不能**影响工具调用本身(B2-1 的同一条纪律:监听器不许成为故障源)。
|
|
225
|
+
onEvent('dispatch-ledger-error', { error: String((e && e.message) || e).slice(0, 160) });
|
|
226
|
+
}
|
|
227
|
+
return downstream;
|
|
228
|
+
};
|
|
229
|
+
}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// @yangdcm/dsh-expert-team — bundle entry.
|
|
2
|
+
// The bundle's composition lives in cordis.patch.yml (the dsh.bundle.patch file).
|
|
3
|
+
// This module is the `.` export. It is ALSO a deliberate inert cordis plugin: the
|
|
4
|
+
// bundle patch adds a bundle-root row named exactly `@yangdcm/dsh-expert-team` so
|
|
5
|
+
// dsh-client-modules' Loader scan (which only considers exact package specifiers)
|
|
6
|
+
// discovers this package's `dsh.client` and serves the live overlay. It registers
|
|
7
|
+
// no service — the real work is `./command` (the /team command) and the skill.
|
|
8
|
+
export const name = 'dsh-expert-team';
|
|
9
|
+
export const inject = [];
|
|
10
|
+
export function apply() {
|
|
11
|
+
/* inert marker row: exists so the client module scan discovers dsh.client */
|
|
12
|
+
}
|
|
13
|
+
// P4 清理(2026-09-13):此处原有 `export const BUNDLE_NAME = '@yangdcm/dsh-expert-team';` ——
|
|
14
|
+
// 全仓(含 cordis.patch.yml / presets / 三副本)**无任何引用**,是纯死导出;已删除。
|
|
15
|
+
// 注意:bundle 的标识不靠它,靠 cordis.patch.yml 里那一行**逐字的包名**(见文件头注释)。
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// 边界拦截器(B2-1 · O-C 形态):把「台账契约」与「规格边界」两条**散文规则**搬到
|
|
2
|
+
// 宿主的 `tools/post-execute` 瀑布上,让违规**当场顶回**(模型可见反馈),而不是等
|
|
3
|
+
// `/team check` 事后报红、或等用户走查才发现。
|
|
4
|
+
//
|
|
5
|
+
// 为什么是"落地后纠偏"而不是"写前拒绝"(设计见 docs/Qoder对标/07-B2-1真拦截设计稿.md §二):
|
|
6
|
+
// 本插件**不注册任何模型可调用的工具**,今天的协议是让 lead **直接改文件**
|
|
7
|
+
// (SKILL.md:24/95/99/157 明写"你落盘 / 你统一更新 TASKS.json")。
|
|
8
|
+
// ⇒ 一刀 deny 直写会让整条协议当场断掉。因此第 1 阶段只做"落地后 block + 反馈":
|
|
9
|
+
// 写仍然发生(不破坏任何现存写法),但工具结果变成 isError,且逐条告诉模型哪条不合法。
|
|
10
|
+
//
|
|
11
|
+
// 与既有仲裁的关系(`lib/command.js:68` 的 L1-4′:「schema 告警**只上报、不阻断**」):
|
|
12
|
+
// 本模块**不推翻**该仲裁 —— schema 形状告警仍然只上报。这里只拦**两个永不合法**的
|
|
13
|
+
// DAG 不变量(id 重复、循环依赖,含自依赖),它们在任何"边写边补"的中间态下都不成立;
|
|
14
|
+
// `missing-dependency` 可能是"我下一条 edit 就补上"的合法中间态,故**不拦**,降为告警。
|
|
15
|
+
//
|
|
16
|
+
// 本模块零 `command.js` 依赖:校验器(`validateTaskGraph`)由调用方注入,
|
|
17
|
+
// 既避免循环 import,也让它可以被单测直接实例化(也顺手为 B2-2 的模块拆分留好边界)。
|
|
18
|
+
|
|
19
|
+
import { resolve, sep } from 'node:path';
|
|
20
|
+
|
|
21
|
+
/** 会产生文件内容变更的工具名(`@deepseek-ai/dsh-tool-fs` 的 write / edit,参数名都是 `file_path`)。 */
|
|
22
|
+
export const WRITE_TOOLS = new Set(['write', 'edit']);
|
|
23
|
+
|
|
24
|
+
/** 边界章节标题(与 `skills/expert-team/assets/templates/SPEC.md` 的标题逐字一致)。 */
|
|
25
|
+
export const SPEC_BOUNDARY_HEADING = '边界与禁止项';
|
|
26
|
+
|
|
27
|
+
/** SPEC 必须已经写完边界的阶段 —— 即 spec-review 及其之后。clarify/research/design 期间只告警。 */
|
|
28
|
+
export const SPEC_COMPLETE_PHASES = new Set(['spec-review', 'implement', 'review', 'test', 'deliver', '方案确认']);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* **已终止的 run 状态** —— 处于这些状态的 run,本拦截器一律放行。
|
|
32
|
+
*
|
|
33
|
+
* 理由是"拦了只有坏处":写已经落盘(`post-execute` 的 `block` 不回滚),改的又是冻结的历史工件,
|
|
34
|
+
* 于是拦截唯一的效果是**让编辑者以为没改成**。已终止 run 的工件正确性由 `/team check` 按需报告。
|
|
35
|
+
*/
|
|
36
|
+
export const TERMINAL_RUN_STATUSES = new Set(['complete', 'completed', 'done', 'failed', 'cancelled', 'canceled', 'discarded']);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 取本次工具调用的目标文件路径(只认 write/edit 的 `file_path`)。
|
|
40
|
+
* @param exec - 工具执行记录(`{ name, arguments }`)。
|
|
41
|
+
* @returns 去掉首尾空白的路径字符串;不是文件写入类调用则返回 null。
|
|
42
|
+
*/
|
|
43
|
+
export function targetOf(exec) {
|
|
44
|
+
if (!exec || typeof exec !== 'object') return null;
|
|
45
|
+
if (!WRITE_TOOLS.has(String(exec.name || ''))) return null;
|
|
46
|
+
const args = exec.arguments;
|
|
47
|
+
const p = args && typeof args === 'object' ? args.file_path : null;
|
|
48
|
+
return typeof p === 'string' && p.trim() ? p.trim() : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 判断目标是否正好落在 `<teamRoot>/<runId>/` 的**直接子文件**上。
|
|
53
|
+
* 只认正好 2 段(runId + 文件名)—— 更深的路径(如 run 目录里的子目录)一律不管,
|
|
54
|
+
* 避免误伤 run 内其它同名工件。
|
|
55
|
+
* @param absPath - 已解析的绝对路径。
|
|
56
|
+
* @param teamRootAbs - `<cwd>/team` 的绝对路径。
|
|
57
|
+
* @returns `{ runId, kind: 'tasks'|'spec', abs }`;不匹配返回 null。
|
|
58
|
+
*/
|
|
59
|
+
export function runScopedTarget(absPath, teamRootAbs) {
|
|
60
|
+
if (typeof absPath !== 'string' || typeof teamRootAbs !== 'string') return null;
|
|
61
|
+
const abs = resolve(absPath);
|
|
62
|
+
const root = resolve(teamRootAbs);
|
|
63
|
+
if (!abs.startsWith(root + sep)) return null;
|
|
64
|
+
const parts = abs.slice(root.length + sep.length).split(sep).filter(Boolean);
|
|
65
|
+
if (parts.length !== 2) return null;
|
|
66
|
+
const [runId, base] = parts;
|
|
67
|
+
if (base === 'TASKS.json') return { runId, kind: 'tasks', abs };
|
|
68
|
+
if (base === 'SPEC.md') return { runId, kind: 'spec', abs };
|
|
69
|
+
// 其余 run 内文件:**看得见,但不拦**(`violationsFor` 对非 tasks/spec 一律返回空)。
|
|
70
|
+
// 为什么现在要「看见」它们(2026-09-13,设计稿 §十二 第 3 步):并发写留痕需要观察**所有** run 工件 ——
|
|
71
|
+
// 而实测那次并发事故发生在 `CONTRACT.md` 上(T29「CONTRACT 单写者收口」),恰恰不是这两份受门禁保护的文件。
|
|
72
|
+
// ⚠️ 这是**只读扩展**:对 tasks/spec 的判定与拦截行为**逐字未变**。
|
|
73
|
+
return { runId, kind: 'other', abs };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 读 SPEC 的「边界与禁止项」章节状态。
|
|
78
|
+
*
|
|
79
|
+
* **"填了没有"的判据是列内容、不是行非空**:模板(`assets/templates/SPEC.md:36-47`)已经
|
|
80
|
+
* 预置 10 行边界族表格,`期望拒绝` 与 `验收方式` 两列**故意留空**。所以"章节非空"这种判据
|
|
81
|
+
* 会被模板本身满足 —— 必须要求**至少一行**的 `期望拒绝` 列非空,才算真的定义了边界。
|
|
82
|
+
* @param text - SPEC.md 全文。
|
|
83
|
+
* @returns `{ heading, filled, totalRows, filledRows }`
|
|
84
|
+
*/
|
|
85
|
+
export function specBoundaryState(text) {
|
|
86
|
+
const lines = String(text ?? '').split('\n');
|
|
87
|
+
// **"提及"不等于"定义"**:章节标题必须**以**「边界与禁止项」开头,后面只允许紧跟括号/冒号/结束。
|
|
88
|
+
// 反例(2026-09-12 实测):`# SPEC(探针:故意不写「边界与禁止项」章节)` 这种**标题里提到**它的
|
|
89
|
+
// 写法,用 `includes()` 会被判成"有章节"——于是"缺失"永远不会被发现。这与本包证据门禁那条
|
|
90
|
+
// 「片段命中 ≠ 锚点命中」是同一类错误:判定必须锚在**结构位置**上,不能锚在"出现过"。
|
|
91
|
+
const HEAD_RE = /^(#{1,6})\s+(.+?)\s*$/;
|
|
92
|
+
const sectionIndexOf = (raw) => {
|
|
93
|
+
const m = raw.match(HEAD_RE);
|
|
94
|
+
if (!m) return -1;
|
|
95
|
+
const title = m[2].trim();
|
|
96
|
+
if (!title.startsWith(SPEC_BOUNDARY_HEADING)) return -1;
|
|
97
|
+
const rest = title.slice(SPEC_BOUNDARY_HEADING.length);
|
|
98
|
+
return rest === '' || /^[((::]/.test(rest) ? m[1].length : -1;
|
|
99
|
+
};
|
|
100
|
+
let hIdx = -1;
|
|
101
|
+
let level = 0;
|
|
102
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
103
|
+
const lv = sectionIndexOf(lines[i]);
|
|
104
|
+
if (lv > 0) { hIdx = i; level = lv; break; }
|
|
105
|
+
}
|
|
106
|
+
if (hIdx < 0) return { heading: false, filled: false, totalRows: 0, filledRows: 0 };
|
|
107
|
+
let end = lines.length;
|
|
108
|
+
for (let i = hIdx + 1; i < lines.length; i += 1) {
|
|
109
|
+
const m = lines[i].match(/^(#+)\s/);
|
|
110
|
+
if (m && m[1].length <= level) { end = i; break; }
|
|
111
|
+
}
|
|
112
|
+
let totalRows = 0;
|
|
113
|
+
let filledRows = 0;
|
|
114
|
+
for (const raw of lines.slice(hIdx + 1, end)) {
|
|
115
|
+
const row = raw.trim();
|
|
116
|
+
if (!row.startsWith('|')) continue;
|
|
117
|
+
const cells = row.replace(/^\|/, '').replace(/\|$/, '').split('|').map((c) => c.trim());
|
|
118
|
+
// 跳过表头与分隔行:分隔行只由 - / : / 空格组成
|
|
119
|
+
if (cells.every((c) => /^:?-{2,}:?$/.test(c) || c === '')) continue;
|
|
120
|
+
if (cells[0] === '边界族' || cells[0] === '规则') continue;
|
|
121
|
+
if (cells.length < 3) continue;
|
|
122
|
+
totalRows += 1;
|
|
123
|
+
if (cells[2]) filledRows += 1;
|
|
124
|
+
}
|
|
125
|
+
return { heading: true, filled: filledRows > 0, totalRows, filledRows };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 永不合法、任何中间态都不成立的 DAG 不变量(见文件头注释的取舍说明)。 */
|
|
129
|
+
export const HARD_GRAPH_CODES = new Set(['missing-id', 'duplicate-id', 'self-dependency', 'cycle']);
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 从 TASKS.json 文本里取任务数组(`{tasks:[…]}` 或裸数组都接受)。
|
|
133
|
+
* @returns 数组;形状不对返回 null。
|
|
134
|
+
*/
|
|
135
|
+
export function tasksArrayOf(doc) {
|
|
136
|
+
if (Array.isArray(doc)) return doc;
|
|
137
|
+
if (doc && typeof doc === 'object' && Array.isArray(doc.tasks)) return doc.tasks;
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 创建一个 `tools/post-execute` 监听器。
|
|
143
|
+
*
|
|
144
|
+
* 决策合并遵循宿主的"最严格优先":先 `await next()` 让下游有机会阻断,再把自己的
|
|
145
|
+
* 阻断折上去(下游已 block 则保留其 feedback 并追加我们的),保证监听器顺序不影响最终结论。
|
|
146
|
+
*
|
|
147
|
+
* @param deps - 注入依赖:
|
|
148
|
+
* `readText(abs) => Promise<string|null>`(读落盘后内容;读不到返回 null ⇒ **降级放行**)、
|
|
149
|
+
* `validateTaskGraph(tasks) => { ok, errors:[{code,detail}] }`(复用读侧同一份实现)、
|
|
150
|
+
* `teamRootFor(exec) => string|null`、`phaseFor(runId) => Promise<string>`、
|
|
151
|
+
* `onEvent(type, payload)`(可观测:让"门禁被行使"看得见)。
|
|
152
|
+
* @returns `(exec, next) => Promise<PostToolDecision>`
|
|
153
|
+
*/
|
|
154
|
+
export function createBoundaryInterceptor(deps) {
|
|
155
|
+
const {
|
|
156
|
+
readText,
|
|
157
|
+
validateTaskGraph,
|
|
158
|
+
cwdFor,
|
|
159
|
+
teamRootFor = (cwd) => (cwd ? resolve(cwd, 'team') : null),
|
|
160
|
+
phaseFor = async () => '',
|
|
161
|
+
// 已交付/已终止的 run 要放行(理由见监听器里的长注释)。缺省返回空串 ⇒ **语义与从前完全一致**。
|
|
162
|
+
statusFor = async () => '',
|
|
163
|
+
// 并发写留痕(设计稿 §十二 第 3 步):`whoFor` 给出写者身份、`writeTracer` 判「同窗口内不同写者写同一文件」。
|
|
164
|
+
// 两个都缺省 ⇒ **语义与从前完全一致**(不传就不追踪)。
|
|
165
|
+
whoFor = async () => '',
|
|
166
|
+
writeTracer = null,
|
|
167
|
+
onEvent = () => {},
|
|
168
|
+
} = deps || {};
|
|
169
|
+
|
|
170
|
+
/** 收集本次写入违反的硬规则(空数组 = 合规)。 */
|
|
171
|
+
async function violationsFor(target, cwd) {
|
|
172
|
+
const text = await readText(target.abs);
|
|
173
|
+
if (typeof text !== 'string') {
|
|
174
|
+
// 读不回来(宿主虚拟路径 / 权限)⇒ 不阻断,但要留痕:门禁自身降级必须可见,
|
|
175
|
+
// 否则"没报错"会被误读成"检查过了"。这是 E 类假绿的典型形态。
|
|
176
|
+
onEvent('boundary-gate-degraded', { runId: target.runId, kind: target.kind, abs: target.abs });
|
|
177
|
+
return [];
|
|
178
|
+
}
|
|
179
|
+
if (target.kind === 'tasks') {
|
|
180
|
+
let doc;
|
|
181
|
+
try { doc = JSON.parse(text); } catch (e) {
|
|
182
|
+
return [`TASKS.json 不是合法 JSON:${String((e && e.message) || e).slice(0, 120)}`];
|
|
183
|
+
}
|
|
184
|
+
const arr = tasksArrayOf(doc);
|
|
185
|
+
if (arr === null) return ['TASKS.json 结构必须是 {"tasks":[…]} 或裸数组'];
|
|
186
|
+
const g = validateTaskGraph(arr) || { errors: [] };
|
|
187
|
+
return (g.errors || []).filter((e) => HARD_GRAPH_CODES.has(String(e && e.code))).map((e) => String(e.detail || e.code));
|
|
188
|
+
}
|
|
189
|
+
// 只有 spec 才走下面这套;其余 kind(`other`)**一律不判** ——
|
|
190
|
+
// 否则会给普通文件报「缺边界章节」这类假违规。
|
|
191
|
+
if (target.kind !== 'spec') return [];
|
|
192
|
+
// kind === 'spec'
|
|
193
|
+
const st = specBoundaryState(text);
|
|
194
|
+
if (!st.heading) {
|
|
195
|
+
return [`SPEC.md 缺「${SPEC_BOUNDARY_HEADING}」章节(模板见 skills/expert-team/assets/templates/SPEC.md:24)——规格沉默等于允许,这正是头号返工源`];
|
|
196
|
+
}
|
|
197
|
+
const phase = String((await phaseFor(target.runId, cwd)) || '');
|
|
198
|
+
if (SPEC_COMPLETE_PHASES.has(phase) && !st.filled) {
|
|
199
|
+
return [`SPEC.md 的「${SPEC_BOUNDARY_HEADING}」章节已进入阶段「${phase}」但仍无任何一行填写「期望拒绝」——没有拒绝码的边界视为未定义(已填 ${st.filledRows}/${st.totalRows} 行)`];
|
|
200
|
+
}
|
|
201
|
+
return [];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return async function boundaryInterceptor(exec, result, next) {
|
|
205
|
+
// ⚠️ 宿主 `tools/post-execute` 的监听器签名是 **(exec, result, next)**。2026-09-12 真实事故:
|
|
206
|
+
// 这里曾写成 `(exec, next)` ⇒ `next` 实际拿到的是 result ⇒ `next is not a function` 抛出
|
|
207
|
+
// ⇒ 宿主把**每一次工具调用**都归一化成失败(全工具瘫痪)。两道防线:
|
|
208
|
+
// ① 按正确签名声明;② 即使 next 不是函数,也降级为"不干涉"而不是抛错。
|
|
209
|
+
// 本监听器**绝不允许**成为工具调用的故障源。
|
|
210
|
+
if (typeof next !== 'function') return { kind: 'accept' };
|
|
211
|
+
const downstream = await next();
|
|
212
|
+
// **工具本身已经失败时不要插嘴**:这时文件并没有变成"我以为的样子"(例如被 fs 观察策略
|
|
213
|
+
// 拒绝的覆盖写、磁盘满、权限错),拿磁盘上的**旧内容**去校验只会给出**误导性**理由并把
|
|
214
|
+
// 真错因盖掉。实测(2026-09-12):一次"未先 read 就覆盖"的失败写,被本拦截器报成
|
|
215
|
+
// 「任务 id 重复」,而真实原因是观察策略拒绝 —— 这种"用一个错误替换另一个错误"正是
|
|
216
|
+
// 本项目最忌讳的失败模式。失败就让它原样呈现。
|
|
217
|
+
if (result && (result.isError === true || result.error)) return downstream;
|
|
218
|
+
let msgs = [];
|
|
219
|
+
try {
|
|
220
|
+
const raw = targetOf(exec);
|
|
221
|
+
if (!raw) return downstream;
|
|
222
|
+
const cwd = cwdFor(exec);
|
|
223
|
+
if (!cwd) return downstream;
|
|
224
|
+
const root = teamRootFor(cwd);
|
|
225
|
+
if (!root) return downstream;
|
|
226
|
+
// `file_path` 由宿主按会话 cwd 解析(可能是相对路径)⇒ 这里用同一基准解析。
|
|
227
|
+
const scoped = runScopedTarget(resolve(cwd, raw), root);
|
|
228
|
+
if (!scoped) return downstream;
|
|
229
|
+
// ── 已完成/已终止的 run:**放行**(2026-09-13 实测撞到的误报)──────────────────
|
|
230
|
+
// 实测场景:改一份**历史 run**(`phase=deliver status=complete`)的 `SPEC.md` 引用
|
|
231
|
+
// (B 线 11b 搬词表后重锚行号),拦截器当场把工具结果改成「✗ SPEC.md 缺边界章节」。
|
|
232
|
+
// 两个理由让这个拦截**只有坏处**:
|
|
233
|
+
// ① 已交付 run 的 SPEC 是**冻结的历史工件**,改它属于"更正引用",不是"规格漂移";
|
|
234
|
+
// ② `post-execute` 的 `block` **不回滚写入**(已验证)⇒ 它唯一的效果是让编辑者
|
|
235
|
+
// 以为没改成(我差点据此重做一遍)。
|
|
236
|
+
// 缺省语义**不变**(`statusFor` 不传或读不到 ⇒ 按"非终态"处理,门禁照旧 fail-closed)。
|
|
237
|
+
const status = String((await statusFor(scoped.runId, cwd)) || '');
|
|
238
|
+
if (TERMINAL_RUN_STATUSES.has(status)) {
|
|
239
|
+
onEvent('boundary-gate-skipped-terminal', { runId: scoped.runId, kind: scoped.kind, status });
|
|
240
|
+
return downstream;
|
|
241
|
+
}
|
|
242
|
+
// 设计稿 §十二 第 3 步:**并发写留痕** —— 只记录、只告警,**绝不阻断**
|
|
243
|
+
// (本仓教训:一上来就拦会把门禁变成故障源;先观测真实频率,再决定要不要收紧)。
|
|
244
|
+
// 放在"终态 run 已放行"之后:改历史 run 的工件属于更正引用,不该算并发事故。
|
|
245
|
+
if (writeTracer) {
|
|
246
|
+
try {
|
|
247
|
+
const who = String((await whoFor(exec, scoped)) || '');
|
|
248
|
+
const tr = writeTracer.record({ runId: scoped.runId, abs: scoped.abs, who });
|
|
249
|
+
if (tr && tr.conflict) onEvent('concurrent-write', tr.conflict);
|
|
250
|
+
} catch { /* 追踪失败绝不影响工具调用本身 */ }
|
|
251
|
+
}
|
|
252
|
+
msgs = await violationsFor(scoped, cwd);
|
|
253
|
+
if (msgs.length > 0) onEvent('boundary-gate-blocked', { runId: scoped.runId, kind: scoped.kind, count: msgs.length });
|
|
254
|
+
} catch (e) {
|
|
255
|
+
// 拦截器自身绝不能让工具调用变红(宿主约定:监听器抛错会被收敛为失败结果)。
|
|
256
|
+
onEvent('boundary-gate-error', { error: String((e && e.message) || e) });
|
|
257
|
+
return downstream;
|
|
258
|
+
}
|
|
259
|
+
if (msgs.length === 0) return downstream;
|
|
260
|
+
const feedback = msgs.map((m) => `✗ ${m}`).join('\n');
|
|
261
|
+
if (downstream && downstream.kind === 'block') {
|
|
262
|
+
return { kind: 'block', feedback: [...(downstream.feedback || []), { type: 'text', text: feedback }] };
|
|
263
|
+
}
|
|
264
|
+
return { kind: 'block', feedback: [{ type: 'text', text: feedback }] };
|
|
265
|
+
};
|
|
266
|
+
}
|