@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.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +141 -0
  3. package/README.md +135 -0
  4. package/client.js +2473 -0
  5. package/cordis.patch.yml +24 -0
  6. package/lib/artifact-writer.js +379 -0
  7. package/lib/command-parse.js +181 -0
  8. package/lib/command.js +5100 -0
  9. package/lib/dispatch-ledger.js +229 -0
  10. package/lib/index.js +15 -0
  11. package/lib/interception.js +266 -0
  12. package/lib/lead-toolface.js +179 -0
  13. package/lib/log-parse.js +181 -0
  14. package/lib/loop-guard.js +165 -0
  15. package/lib/metrics/collect.js +70 -0
  16. package/lib/metrics/render.js +100 -0
  17. package/lib/metrics/session-usage.js +319 -0
  18. package/lib/metrics/timing.js +188 -0
  19. package/lib/metrics/token-usage.js +352 -0
  20. package/lib/metrics/tokens.js +271 -0
  21. package/lib/routes/shared.js +83 -0
  22. package/lib/settings.js +289 -0
  23. package/lib/tier.js +190 -0
  24. package/lib/validate.js +681 -0
  25. package/lib/vocab.js +121 -0
  26. package/lib/write-tracer.js +58 -0
  27. package/package.json +119 -0
  28. package/presets/expert-team/agent.cordis.yml +542 -0
  29. package/presets/expert-team/preset.yml +3 -0
  30. package/skills/expert-team/SKILL.md +328 -0
  31. package/skills/expert-team/assets/templates/AUTHORITY.md +32 -0
  32. package/skills/expert-team/assets/templates/PLAN.md +27 -0
  33. package/skills/expert-team/assets/templates/RESEARCH.md +13 -0
  34. package/skills/expert-team/assets/templates/RETRO.md +24 -0
  35. package/skills/expert-team/assets/templates/REVIEW.md +10 -0
  36. package/skills/expert-team/assets/templates/ROSTER.json +6 -0
  37. package/skills/expert-team/assets/templates/SPEC.md +62 -0
  38. package/skills/expert-team/assets/templates/STATE.json +10 -0
  39. package/skills/expert-team/assets/templates/SUMMARY.md +25 -0
  40. package/skills/expert-team/assets/templates/TASK.md +23 -0
  41. package/skills/expert-team/assets/templates/TASKS.json +3 -0
  42. package/skills/expert-team/assets/templates/TEST.md +9 -0
  43. package/skills/expert-team/assets/templates//344/273/273/345/212/241/347/234/213/346/235/277.md +23 -0
  44. package/skills/expert-team/references/EFFICIENCY.md +79 -0
  45. package/skills/expert-team/references/LOGGING.md +82 -0
  46. package/skills/expert-team/references/PERSIST.md +57 -0
  47. package/skills/expert-team/references/PIPELINE.md +58 -0
  48. package/skills/expert-team/references/ROLES.md +297 -0
  49. package/skills/expert-team/references/WORKSPACE.md +123 -0
  50. package/skills/expert-team/references/workflow.team.js +97 -0
  51. package/skills/expert-team/scripts/scan-authority.mjs +114 -0
  52. package/skills/expert-team/scripts/scan-single-source.mjs +292 -0
@@ -0,0 +1,179 @@
1
+ // lead 工具面(A 线 · token 成本治理)· **纯函数,零宿主依赖**。
2
+ //
3
+ // ── 为什么要收 lead 的工具面 ────────────────────────────────────────────────
4
+ // 对真实会话做全量遥测审计的结论(一次性脚本,未随包发布):
5
+ // · lead 累积上下文的 **89%~98% 是工具结果**,其中 `read` + `bash` + `edit` 约占 **93%**;
6
+ // 真实用户需求只占 2%~11%。
7
+ // · 上下文首次越过 10 万 tokens 只发生在全程 **第 1%~2% 的步**,而**越线之后**的步数
8
+ // 贡献了 **97%** 的总 prompt 成本 —— 贵在"带着大上下文反复走",不在"上下文变大"。
9
+ // · 最贵的 run 里 **lead 占该 run prompt 总量的 84%**(另一个 92%),而它 3,244 次工具调用里
10
+ // `subagent` 派工只有 11 次。
11
+ // ⇒ 杠杆不是"少说话"(输出 tokens 只占总量的 0.34%),而是**让执行类工具默认不在 lead 手上**。
12
+ //
13
+ // ── 机制(已由宿主源码闭环证明,不是推断)────────────────────────────────
14
+ // 给 **lead 自己的 agent scope** 施加 `agent.ctx.tools.restrict({deny})` 即可。安全性依据:
15
+ // `dsh-subagent.applyChildComposition` → `agentPresets.composeFrom(childCtx, parent.ctx)`
16
+ // → `standingMountFor(parentCtx)` = `scopeParentOf(parentAgentKey)`(= preset 常驻作用域)
17
+ // → `bindScopeParent(childKey, standingKey)` → `dsh-scope: scopeParents.set(childKey, standingKey)`
18
+ // `dsh-scope.scopeChainOf()` 沿 `scopeParents` 向上走 ⇒ `scopeChainOf(child) = [child, standing, …]`。
19
+ // **lead 的 agent 作用域不在子级的祖先链上** ⇒ 收 lead 不会波及角色子代理。
20
+ // (旁证:本 preset 的 `toolFilter: deny: [bash]`(pm/architect)走的正是同一行代码。)
21
+ //
22
+ // ── 本模块只做"算出该 deny 什么",不碰宿主 ────────────────────────────────
23
+ // 施加动作在接线处(agent setup 阶段,照宿主自己的时序),本模块保持纯函数以便单测。
24
+ //
25
+ // ── 两条本仓纪律,写死在返回值形状里 ──────────────────────────────────────
26
+ // ① **两种零必须可区分**:`deny: []` 可能是"真的没什么可收",也可能是"我根本不知道宿主有哪些工具"。
27
+ // 两者**不能长得一样** ⇒ 用 `status` 区分(`ready` / `nothing-to-deny` / `no-known-names`)。
28
+ // ② **不静默**:本该存在的名字缺席(核心工具、本平台 shell)必须进 `unexpectedAbsent` 并出声;
29
+ // 只有"另一个平台的 shell"缺席是预期的,不当作异常。
30
+
31
+ import path from 'node:path';
32
+
33
+ /** 候选:lead 默认不该持有的**执行/探索**类全局工具名(真实名字取自宿主各 tool 包)。 */
34
+ export const LEAD_DENY_CANDIDATES = Object.freeze(['bash', 'pwsh', 'write', 'edit', 'grep', 'glob']);
35
+
36
+ /**
37
+ * 候选里**每个平台都应当存在**的那些。
38
+ * 它们缺席是异常(插件组合变了 / 没装全),必须出声 —— 与"另一个平台的 shell 缺席"区分开。
39
+ */
40
+ export const CORE_DENY_TOOLS = Object.freeze(['write', 'edit', 'grep', 'glob']);
41
+
42
+ /** shell 工具是**平台二选一**的(preset 里 `disabled:` 按平台切);同时写两个就会让 `restrict()` 抛错。 */
43
+ export const SHELL_TOOL_BY_PLATFORM = Object.freeze({ win32: 'pwsh', posix: 'bash' });
44
+
45
+ /**
46
+ * lead **保留**但受路径约束的只读工具:只允许读工件目录内的东西。
47
+ * 为什么不是直接 deny:lead 必须能读 SPEC/PLAN/REVIEW 才能做阶段门控与裁决;
48
+ * 要挡的是"整读大源码"这种**探索性**读取(那属于角色子代理的活)。
49
+ */
50
+ export const LEAD_GUARDED_READ_TOOLS = Object.freeze(['read', 'read_image']);
51
+
52
+ /** 工件目录名(相对工作区根)。lead 读这个子树以内的路径一律放行。 */
53
+ export const ARTIFACT_SUBDIR = 'team';
54
+
55
+ /** 归一平台标识:只关心"是不是 Windows"。 */
56
+ export function normalizePlatform(platform) {
57
+ const p = String(platform ?? '').toLowerCase();
58
+ return p === 'win32' || p === 'windows' ? 'win32' : 'posix';
59
+ }
60
+
61
+ /**
62
+ * 算出 lead 该 deny 哪些工具。
63
+ *
64
+ * @param {object} input
65
+ * @param {string} [input.platform] - `process.platform`
66
+ * @param {Iterable<string>} [input.knownNames] - 宿主 `tools.view(scope).restrictableNames`
67
+ * @returns {{deny: string[], absent: string[], unexpectedAbsent: string[], expectedShell: string,
68
+ * expectedShellPresent: boolean, effective: boolean, status: string, notes: string[]}}
69
+ */
70
+ export function planLeadToolFace({ platform, knownNames } = {}) {
71
+ const expectedShell = SHELL_TOOL_BY_PLATFORM[normalizePlatform(platform)];
72
+ const otherShell = expectedShell === 'bash' ? 'pwsh' : 'bash';
73
+
74
+ // ── 两种零之一:**不知道宿主有哪些工具** ──────────────────────────────────
75
+ // 这一支绝不能返回"看起来像成功"的结果。`knownNames` 缺失时若返回 `deny: []` 而不加区分,
76
+ // 调用方会以为"工具面已收窄、只是没什么可收"—— 那是本包最讨厌的静默失败。
77
+ //
78
+ // **空数组也算"不知道"**(不是"没什么可收"):真实部署里 `restrictableNames` 不可能是空的
79
+ // (至少 shell 与 fs 工具都在),所以空清单只可能是"查不到 / 查错了作用域"。
80
+ // 而且 `no-known-names`("我不知道有什么")在两种情况下都**真实**,
81
+ // `nothing-to-deny`("宿主里这些名字都没有")只在一种情况下真实 —— 拿不准时说那句一定真的。
82
+ const known = knownNames === undefined || knownNames === null ? new Set() : new Set(knownNames);
83
+ if (known.size === 0) {
84
+ return {
85
+ deny: [], absent: [...LEAD_DENY_CANDIDATES], unexpectedAbsent: [],
86
+ expectedShell, expectedShellPresent: false, effective: false,
87
+ status: 'no-known-names',
88
+ notes: ['拿不到有效的宿主工具名清单(knownNames 缺失或为空)⇒ **没有**收窄工具面。这不是"没什么可收",是"我不知道有什么"。'],
89
+ };
90
+ }
91
+
92
+ const deny = [];
93
+ const absent = [];
94
+ const unexpectedAbsent = [];
95
+ const notes = [];
96
+
97
+ for (const name of LEAD_DENY_CANDIDATES) {
98
+ if (known.has(name)) { deny.push(name); continue; }
99
+ absent.push(name);
100
+ // 另一个平台的 shell 缺席是**预期**的(preset 按平台 disabled)—— 不算异常。
101
+ if (name === otherShell) continue;
102
+ unexpectedAbsent.push(name);
103
+ }
104
+
105
+ const expectedShellPresent = known.has(expectedShell);
106
+ if (!expectedShellPresent) {
107
+ notes.push(`本平台(${normalizePlatform(platform)})的 shell 工具 \`${expectedShell}\` **不在**宿主工具清单里 —— 执行面收窄的前提不成立,请先确认 shell 插件已挂载。`);
108
+ }
109
+ if (unexpectedAbsent.length > 0) {
110
+ notes.push(`本该存在的工具缺席:${unexpectedAbsent.map((n) => `\`${n}\``).join(' · ')} —— 插件组合可能变了或没装全(缺席不是"已经收掉了")。`);
111
+ }
112
+
113
+ // ── 两种零之二:**确实没什么可收** ───────────────────────────────────────
114
+ if (deny.length === 0) {
115
+ return {
116
+ deny, absent, unexpectedAbsent, expectedShell, expectedShellPresent,
117
+ effective: false, status: 'nothing-to-deny',
118
+ notes: [...notes, 'deny 名单为空 ⇒ **本次没有收窄任何工具**(宿主里这些名字一个都不存在)。'],
119
+ };
120
+ }
121
+
122
+ return { deny, absent, unexpectedAbsent, expectedShell, expectedShellPresent, effective: true, status: 'ready', notes };
123
+ }
124
+
125
+ /**
126
+ * lead 的只读工具**是否放行**(`lead` 只读工件)。
127
+ *
128
+ * 口径(刻意 fail-open):判不出来就**放行** —— 与本包既有拦截器的口径一致
129
+ * ("降级要留痕,但不能把动作变红"):宁可放过一次探索性读取,也不要因为路径判据写窄
130
+ * 而把 lead 的合法门控动作卡死。
131
+ *
132
+ * @param {object} input
133
+ * @param {string} [input.filePath] - 模型要读的路径
134
+ * @param {string} [input.cwd] - 工作区根
135
+ * @param {string[]} [input.extraRoots] - 额外放行的绝对路径前缀
136
+ * @returns {{allow: boolean, reason: 'artifact'|'outside-artifacts'|'undecidable'}}
137
+ */
138
+ export function artifactReadDecision({ filePath, cwd, extraRoots } = {}) {
139
+ if (typeof filePath !== 'string' || filePath === '' || typeof cwd !== 'string' || cwd === '') {
140
+ return { allow: true, reason: 'undecidable' };
141
+ }
142
+ const root = path.resolve(cwd);
143
+ const target = path.resolve(root, filePath);
144
+ const roots = [path.join(root, ARTIFACT_SUBDIR)];
145
+ if (Array.isArray(extraRoots)) for (const r of extraRoots) if (typeof r === 'string' && r !== '') roots.push(path.resolve(r));
146
+
147
+ for (const r of roots) {
148
+ // 加分隔符再比前缀:否则 `/w/teamx` 会被 `/w/team` 误判为"在里面"。
149
+ if (target === r || target.startsWith(r + path.sep)) return { allow: true, reason: 'artifact' };
150
+ }
151
+ return { allow: false, reason: 'outside-artifacts' };
152
+ }
153
+
154
+ /** 本 preset 的 id(**实测值**:34 个真实会话头的 `agentPreset` 都是这个串,另 6 个是 `standard`)。 */
155
+ export const LEAD_PRESET_ID = 'expert-team';
156
+
157
+ /**
158
+ * 该不该对**这个** agent 收窄工具面。
159
+ *
160
+ * 判据两条,缺一不可:
161
+ * ① **preset 必须是专家团**。读法 `agentPresets.composedPreset(agent.ctx)` —— 宿主写明它
162
+ * "read from the live scope chain rather than from the session",因此对**还没写会话头**的
163
+ * agent 也答得出来。非专家团返回 `false`(**静默返回**,不是失败:绝大多数 agent 都无关)。
164
+ * ② **必须是根 agent(lead),不能是角色子代理**。这一条是安全闸门,不是优化:
165
+ * 角色子代理是用 `composeFrom` 从**同一个 preset** 组合出来的 ⇒ 它们的
166
+ * `composedPreset` **同样是 `expert-team`**。若不查 ②,A2 会把角色的 bash 一起收掉 ——
167
+ * 正好是要避免的事故。判据用 `agents.roots()`(宿主文档:"created **without an owning
168
+ * agent context**"),它按运行时归属判定,不受会话血缘影响。
169
+ *
170
+ * @param {object} input
171
+ * @param {string|undefined} [input.presetId] - `agentPresets.composedPreset(agent.ctx)`
172
+ * @param {boolean} [input.isRoot] - `agents.roots().includes(agent)`
173
+ * @returns {{apply: boolean, reason: 'lead'|'not-expert-team'|'is-subagent'}}
174
+ */
175
+ export function shouldNarrowLeadToolFace({ presetId, isRoot } = {}) {
176
+ if (presetId !== LEAD_PRESET_ID) return { apply: false, reason: 'not-expert-team' };
177
+ if (isRoot !== true) return { apply: false, reason: 'is-subagent' };
178
+ return { apply: true, reason: 'lead' };
179
+ }
@@ -0,0 +1,181 @@
1
+ // 运行日志解析(B 线第 8 项 · `log-parse.js`):把 `RUN.log.md` 的一行变成结构化事件。
2
+ //
3
+ // 为什么单独成模块:**同一个解析器被至少四个消费者用** —— METRICS 聚合、`/team check` 的阶段记账、
4
+ // 命令侧的状态行、以及测试里的 `_live`。它以前住在 5000 行的编排器里,而 2026-09-13 我为"阶段记账"
5
+ // 检查**临时抄了一份简化版**(`validate.js` 的 `parseLogLineLite`)—— 那正是本仓头号返工源
6
+ // 「一个事实多份拷贝」的形态(两份解析器迟早分叉,而"哪边对"没人说得清)。搬出来之后**只此一份**。
7
+ //
8
+ // 口径(与 LOGGING.md 逐字对应,**不要在这里"顺手改好一点"**):
9
+ // · 时间戳宽容:`HH:MM:SS` 是规范写法,但 `[now]` 这类历史写法**仍然计入事件**(否则当年那些
10
+ // run 的决策/阶段会被静默丢掉);
11
+ // · 判决**只认 `verdict=<token>`**;`verdict=` 一旦出现就**绝不回落**到从中文散文里猜
12
+ // (历史 bug:`verdict=needs_revision;…通过 10…` 曾被散文正则提升成 pass);
13
+ // · 角色名归一(`pm(repair)` → `pm`)与"从 `【中文标签】` 精确表取角色"分开:前者模糊、后者精确。
14
+ //
15
+ // 零 IO、零依赖(只用 vocab.js 的词表)⇒ 可单测。
16
+
17
+ import { ROLE_LABELS_ZH, SUB_ROLE_WORDS } from './vocab.js';
18
+
19
+ /**
20
+ * **从日志散文里**找角色名用的表(含 `lead`)。
21
+ * ⚠️ 它与 `vocab.js` 的 `KNOWN_ROLES` / 固定 12 角色**不是**同一件事:那份是"编制里有哪些角色",
22
+ * 这份是"一行日志文本里出现了哪个角色词"(所以多一个 `lead`)。搬迁时**原样保留**,不合并——
23
+ * 合并会改变 `normalizeRoleName` 的行为(例如把 `competitive-analyst` 归一成 `competitive`)。
24
+ * 这是一处**已知的重复**,记在这里以免下次有人"顺手统一"。
25
+ */
26
+ const ROLE_NAMES = ['pm', 'architect', 'researcher', 'backend', 'frontend', 'ui', 'dba', 'sec', 'devops', 'docs', 'reviewer', 'qa', 'lead'];
27
+ export { ROLE_NAMES };
28
+
29
+ // Parse STATE.members — the authorative per-run roster bindings written by the
30
+ // lead at dispatch time. TWO formats observed in real runs:
31
+ // ["<agentSessionId>:<role>", ...] ← precise (multi-run isolation!)
32
+ // ["pm:Mia", ...] ← role:name (legacy, no agent id)
33
+ // Returns { byRole: Map<role, agentId>, names: Map<role, name> }.
34
+ export function roleNorm(r) {
35
+ const rr = String(r || '').trim().toLowerCase();
36
+ const bare = rr.split('-')[0];
37
+ if (['pm', 'architect', 'researcher', 'backend', 'frontend', 'reviewer', 'qa', 'ui', 'dba', 'sec', 'devops', 'docs'].includes(bare)) return bare;
38
+ for (const [role, words] of SUB_ROLE_WORDS) {
39
+ if (words.some((w) => rr.includes(w))) return role;
40
+ }
41
+ return rr;
42
+ }
43
+
44
+ // ── learn: aggregate RUN.log.md events into metrics + distilled learnings ──
45
+
46
+ export function parseLogLine(line) {
47
+ // Tolerant timestamp: `HH:MM:SS` (canonical) or ANY marker (real logs use
48
+ // `[now]`); unknown markers still count the EVENT so aggregations do not
49
+ // silently drop decisions/phases written by the lead.
50
+ const m = line.match(/^- \[([^\]]+)\] (\S+) — (.*)$/);
51
+ return m ? { time: m[1], type: m[2], detail: m[3] } : null;
52
+ }
53
+
54
+ /** `error:external-write` → 'error';`role:pm` → 'role';`phase:clarify` → 'phase';无冒号则原样。 */
55
+ export function eventFamily(type) {
56
+ const s = String(type ?? '');
57
+ const i = s.indexOf(':');
58
+ return i < 0 ? s : s.slice(0, i);
59
+ }
60
+
61
+ /**
62
+ * R10:按**码点**截断,不按 UTF-16 码元。
63
+ * 旧 `.slice(0, 120)` 会把代理对(emoji)切成半个,落盘后变成 `U+FFFD` 乱码
64
+ * (T-04 实测:`'x'.repeat(119) + '🙂'` ⇒ 样例末尾出现 `U+FFFD`)。长度上限语义不变。
65
+ */
66
+ export function truncateCodepoints(s, max) {
67
+ const str = String(s ?? '');
68
+ const n = Number(max) > 0 ? Math.floor(Number(max)) : 120;
69
+ const cps = [...str];
70
+ return cps.length <= n ? str : cps.slice(0, n).join('');
71
+ }
72
+
73
+ /** 事件子类计数:`{count, sample}`,sample = 首条 detail 去换行截 120 码点。 */
74
+ export function tallyEvent(map, type, detail) {
75
+ const cur = map.get(type) ?? { count: 0, sample: '' };
76
+ cur.count += 1;
77
+ if (!cur.sample) cur.sample = truncateCodepoints(String(detail ?? '').replace(/[\r\n]+/g, ' ').trim(), 120);
78
+ map.set(type, cur);
79
+ }
80
+
81
+ /** 渲染 `- \`<子类>\` × <n> — <样例>`(按次数降序)。 */
82
+ export function eventTallyLines(map, emptyText) {
83
+ if (!map.size) return `- ${emptyText}`;
84
+ return [...map.entries()].sort((a, b) => b[1].count - a[1].count)
85
+ .map(([k, v]) => `- \`${k}\` × ${v.count}${v.sample ? ` — ${v.sample}` : ''}`).join('\n');
86
+ }
87
+
88
+ /**
89
+ * R11:角色名归一 —— 取到**第一个 `(`、`:` 或空白**之前为止。
90
+ * `role:pm(repair)` → `pm`(旧实现 `split(':')[0]` 会造出 `pm(repair)` 这个幽灵桶,
91
+ * 真实 php/school run 里有 15 条这种行,带 verdict= 时就渲染成幽灵角色行);
92
+ * `role:frontend-F4` 这类**合法后缀**保留(`-` 不是分隔符)。
93
+ */
94
+ export function normalizeRoleName(raw) {
95
+ const m = String(raw ?? '').trim().match(/^[^(:\s]+/);
96
+ return m ? m[0] : '';
97
+ }
98
+
99
+ /**
100
+ * R2(repair-1 最重要的正确性修复):`verdict=` 一旦出现,判决**完全由 token 决定**。
101
+ *
102
+ * T-04 用真实 php/school run 实测到的失真:3 条 `role:reviewer … verdict=needs_revision`
103
+ * 里 2 条被记成 pass、1 条被整条丢弃。根因是 `extractVerdict` 把 token 与整条 detail 拼成
104
+ * haystack 再跑 prose 正则 —— detail 里的「通过」把 `needs_revision` 提升成了 `pass`。
105
+ *
106
+ * R18(repair-2):解析前**先剥掉包裹字符**再整词匹配。真实 `php/school` RUN.log **第 163 行**
107
+ * 写的是 ```verdict=`needs_revision` ```(反引号 + markdown 粗体包裹),旧正则要求 token 紧跟
108
+ * `=` ⇒ 解析失败,而该分支**无 prose 兜底** ⇒ 判决被静默丢弃;若它是唯一返工证据 ⇒
109
+ * `返工/失败率:0%`(返工率虚低)。剥完仍按整词规则匹配,**剥不出合法 token 仍不计判决**。
110
+ *
111
+ * 映射逐字、大小写不敏感、**整词**匹配(`verdict=pass` / `verdict=pass,` / `verdict=pass)`
112
+ * 都算 pass;`verdict=pass_unverified` **不得**判 pass)。
113
+ * R19:词表补 `conditionally-pass` / `conditional` / `conditionally` → `pass`(该 token 在本仓
114
+ * 真实日志与 `smoke.test.mjs` 里存在;R2 之后它从"记 pass"退化成"不计判决",属净减少,必须补回)。
115
+ * @returns {'pass'|'rework'|'fail'|''|null} `null` = detail 里没有 `verdict=`(调用点自行决定
116
+ * 是否走 prose);`''` = 有 `verdict=` 但 token 无法识别 ⇒ **不计判决,绝不回落 prose**。
117
+ */
118
+ export const VERDICT_TOKEN_WRAPPERS = '`*_"\'「」『』“”‘’()()【】[]<>《》 \t';
119
+
120
+ // 用 Map 而非普通对象:`constructor` / `toString` 这类继承键不会漏成一个"判决"。
121
+ export const VERDICT_TOKENS = new Map([
122
+ ['pass', 'pass'],
123
+ ['conditionally-pass', 'pass'], ['conditional', 'pass'], ['conditionally', 'pass'],
124
+ ['needs_revision', 'rework'], ['rework', 'rework'],
125
+ ['fail', 'fail'],
126
+ ]);
127
+
128
+ export function verdictFromToken(detail) {
129
+ const s = String(detail ?? '');
130
+ const at = s.search(/verdict=/i);
131
+ if (at < 0) return null; // 没有 `verdict=`(注意:`verdict:` 不算)
132
+ let rest = s.slice(at + 'verdict='.length);
133
+ // R18:剥掉前导包裹字符(markdown 粗体 `**`、反引号、全角/半角引号括号、前后空白)
134
+ while (rest.length > 0 && VERDICT_TOKEN_WRAPPERS.includes(rest[0])) rest = rest.slice(1);
135
+ const m = rest.match(/^([A-Za-z_][A-Za-z0-9_-]*)/);
136
+ if (!m) return ''; // 剥不出合法 token ⇒ 不计判决(不回落 prose)
137
+ return VERDICT_TOKENS.get(m[1].toLowerCase()) ?? ''; // 词表外(含 `constructor` 这类)⇒ 不计判决
138
+ }
139
+
140
+ export function extractRole(detail) {
141
+ let v = (detail.match(/role=(\S+)/) ?? [])[1] ?? '';
142
+ if (v) return v.replace(/[,)]/g, '');
143
+ const hit = ROLE_NAMES.find((r) => new RegExp(`(^|[^a-z])${r}([^a-z]|$)`).test(detail));
144
+ return hit ?? 'unknown';
145
+ }
146
+
147
+ export function extractVerdict(detail) {
148
+ let v = (detail.match(/verdict=(\S+)/) ?? [])[1] ?? '';
149
+ v = v.split('(')[0];
150
+ const hay = v + ' ' + detail;
151
+ if (/fail|失败|报错/.test(hay)) return 'fail';
152
+ if (/rework|返工|重跑|不一致|缺陷/.test(hay)) return 'rework';
153
+ if (/pass|通过|可行|成功|conditionally/.test(hay)) return 'pass';
154
+ return '';
155
+ }
156
+
157
+ /**
158
+ * 「环境/平台类」错误族(C 线第 15 项:返工口径三分账)。
159
+ *
160
+ * 判据只有一条:**换个环境、或没有并发,就不会发生**。这类返工不是团队过程的问题,
161
+ * 混进同一个分子里会让"过程改好了没有"看不出来;反过来,把它们**剔除**出分子又会
162
+ * 让数字与历史不可比 —— 所以本包的做法是**不改上面那个率,只做性质分解**。
163
+ *
164
+ * 未列出的族**一律算"过程类"**(宁可算过程,不放过):把未知当环境类会系统性地美化指标。
165
+ */
166
+ export const ENV_ERROR_FAMILIES = new Set([
167
+ 'error:external-write', // 另一个进程/会话改了同一文件(本仓实测最高频:×9)
168
+ 'error:stale-runtime', // 运行时副本落后于源树
169
+ 'error:platform', // 平台/调度故障
170
+ 'error:dispatch', // 派工失败
171
+ 'error:timeout', // 超时
172
+ ]);
173
+
174
+ /**
175
+ * 把一个 RUN.log 的 `error:<子类>` 事件归类。
176
+ * @param type - 事件类型(如 `error:external-write`)。
177
+ * @returns `'env'` = 环境/平台类;`'process'` = 过程类(含全部未列出的族)。
178
+ */
179
+ export function classifyErrorFamily(type) {
180
+ return ENV_ERROR_FAMILIES.has(String(type || '')) ? 'env' : 'process';
181
+ }
@@ -0,0 +1,165 @@
1
+ // 振荡检测(C 线第 16 项的**收窄版**):同工具重复调用已由宿主负责,这里只管"A→B→A→B 来回横跳"。
2
+ //
3
+ // 为什么只做这一种(取证在 `docs/Qoder对标/02-Experts编排与提示词.md` 与宿主自带包的 README):
4
+ // 把"一直循环返工"拆成三种形态后逐一对照:
5
+ // ① 同工具、同参数的**重复调用** ⇒ **宿主已覆盖**:`@deepseek-ai/dsh-repeat-tool-reminder`
6
+ // 在 dsh-base 里默认启用(thresholds `[3, 5, 8]`),按 agent 记链并注入提醒。
7
+ // **不要重写它**(用户明确要求过"不要每次都是自己造轮子")。
8
+ // ② 同一 finding **跨轮未闭环** ⇒ 本包四支柱的 `FINDING_REOPENED`(读侧违规)已覆盖。
9
+ // ③ **A→B→A→B 振荡** ⇒ **没人管**。宿主那份 README 写死了它的边界:
10
+ // "Exact-match detection only — near-identical variants evade the chain",
11
+ // 且没有任何交替检测;而 Qoder 平台内核里有一条专门的 `[ALTERNATING LOOP DETECTED]`。
12
+ // 这一条正是用户最初抱怨的形态(不是原地重复,而是在两个修法之间来回)。
13
+ //
14
+ // 与宿主那份的**关键差异(为什么这里用"阻断"而不是"只提醒")**:
15
+ // 宿主那份是 advisory(`never blocks or delays a legitimate repeated call`)—— 因为**完全相同的
16
+ // 轮询/等待调用可能是合法的**。但**振荡从来不是合法行为**:在两个互斥方案之间来回本身就说明
17
+ // 至少有一个判断是错的,继续切只会烧预算。Qoder 对它的处理也是硬性的(`You MUST: 1) STOP
18
+ // switching between approaches...`)。因此这里用 `block{feedback}` 把结果变成错误 + 模型可见理由。
19
+ //
20
+ // 安全纪律(2026-09-12 监听器签名事故之后立的规矩,本模块逐条遵守):
21
+ // ① 按宿主签名 `(exec, result, next)` 声明;② `next` 不是函数时降级为"不干涉",绝不抛错;
22
+ // ③ 自身逻辑全部包在 try/catch 里 —— **绝不允许**成为工具调用的故障源。
23
+
24
+ /** 参数预览的截断长度(只用于提醒文案,不参与判定)。 */
25
+ const DEFAULT_PREVIEW_CHARS = 200;
26
+
27
+ // 只对**会改文件内容**的工具记链(`write`/`edit`),并且额外要求四次调用指向**同一个文件**。
28
+ //
29
+ // 为什么必须这么窄(写完第一版才意识到的误报):`编辑 a.js` → `编辑 b.js` → `编辑 a.js` → `编辑 b.js`
30
+ // 形式上也是 A→B→A→B,但那是**完全正常的干活**(两个文件交替改)。窄化后真正的判据是:
31
+ // **在同一个文件上,用两种不同内容来回覆盖 ≥2 个来回** —— 那才是 Qoder 那条
32
+ // `[ALTERNATING LOOP DETECTED]` 说的"在两个互斥方案之间来回"。
33
+ // 误报的代价不是"多一条提醒":它会把守卫本身变成噪声(本包实测教训 —— 噪声门禁 = 没有门禁)。
34
+ import { WRITE_TOOLS, targetOf } from './interception.js';
35
+
36
+ /** 本工具的 `file_path`(非写入类工具返回 null ⇒ 不参与记链)。 */
37
+ export function trackedPath(exec) {
38
+ if (!exec || !WRITE_TOOLS.has(String(exec.name || ''))) return null;
39
+ return targetOf(exec);
40
+ }
41
+
42
+ /**
43
+ * 归一化一次调用的身份:工具名 + 深度键序无关的参数 JSON。
44
+ * 与宿主那份同思路(深键序排序),但**不追求完全一致** —— 我们只关心"两次调用是不是同一个意图"。
45
+ * @param exec - 工具执行记录。
46
+ * @returns 身份串(参数不可序列化时退回 `String(arguments)`)。
47
+ */
48
+ export function callKey(exec) {
49
+ const name = String((exec && exec.name) || '');
50
+ let canon;
51
+ try {
52
+ canon = JSON.stringify(sortDeep(exec && exec.arguments));
53
+ } catch {
54
+ canon = String(exec && exec.arguments);
55
+ }
56
+ return `${name}\u0000${canon}`;
57
+ }
58
+
59
+ /** 深度键序排序(数组保持原序,只稳定化对象键序)。 */
60
+ export function sortDeep(v) {
61
+ if (Array.isArray(v)) return v.map(sortDeep);
62
+ if (v && typeof v === 'object') {
63
+ const out = {};
64
+ for (const k of Object.keys(v).sort()) out[k] = sortDeep(v[k]);
65
+ return out;
66
+ }
67
+ return v;
68
+ }
69
+
70
+ /**
71
+ * 从调用身份序列尾部判定是否在振荡。
72
+ * 判据:最后 4 条形如 `a b a b`(且 `a !== b`)—— 即"回到刚才否掉的方案,又回到刚才那个"。
73
+ * 只认**紧邻**的 4 条,不做模糊匹配:误报会把提醒变成噪声(本包实测过的教训,噪声门禁 = 没有门禁)。
74
+ * @param history - 最近的调用身份(按时间先后)。
75
+ * @returns 振荡对的稳定 key(`a|b`,字典序);未振荡返回 null。
76
+ */
77
+ export function oscillationPair(history) {
78
+ const h = Array.isArray(history) ? history : [];
79
+ if (h.length < 4) return null;
80
+ const [x1, x2, x3, x4] = h.slice(-4);
81
+ if (x1 === x2) return null;
82
+ if (x1 !== x3 || x2 !== x4) return null;
83
+ return [x1, x2].sort().join('\u0001');
84
+ }
85
+
86
+ /**
87
+ * 创建一个 `tools/post-execute` 振荡守卫。
88
+ *
89
+ * @param deps - 注入依赖:
90
+ * `onEvent(type, payload)`(可观测:让"守卫被行使"看得见)、
91
+ * `enabled`(默认 true)、`historySize`(默认 8)、`previewChars`(默认 200)、
92
+ * `makeFeedback(text)`(把文案包成宿主认的 `ContentBlock[]`;默认 `[{type:'text',text}]`)。
93
+ * @returns `(exec, result, next) => Promise<PostToolDecision>`
94
+ */
95
+ export function createLoopGuard(deps = {}) {
96
+ const {
97
+ onEvent = () => {},
98
+ enabled = true,
99
+ historySize = 8,
100
+ previewChars = DEFAULT_PREVIEW_CHARS,
101
+ makeFeedback = (text) => [{ type: 'text', text }],
102
+ } = deps;
103
+
104
+ /** 每个 agent 一条链(用 agent 对象本身做键 ⇒ 不泄漏、不串号)。 */
105
+ const chains = new WeakMap();
106
+
107
+ function stateFor(agent) {
108
+ if (!agent || (typeof agent !== 'object' && typeof agent !== 'function')) return null;
109
+ let st = chains.get(agent);
110
+ if (!st) { st = { history: [], fired: new Set() }; chains.set(agent, st); }
111
+ return st;
112
+ }
113
+
114
+ return async function loopGuard(exec, result, next) {
115
+ // ① 签名纪律:不是瀑布就降级为不干涉(写错签名曾让整个会话的工具链瘫痪)
116
+ if (typeof next !== 'function') return { kind: 'accept' };
117
+ const downstream = await next();
118
+ try {
119
+ if (!enabled) return downstream;
120
+ // ② 工具本身已失败 ⇒ 不插嘴(沿用边界拦截器的同一条纪律:不拿无关理由掩盖真错因)
121
+ if (result && (result.isError === true || result.error)) return downstream;
122
+ // ③ 只对**会改文件内容**的工具记链(写/编辑);读类工具交替调用是正常干活
123
+ const path = trackedPath(exec);
124
+ if (!path) return downstream;
125
+ // ④ 只对**agent 发起的**调用记链;直接调用的宿主工具不参与(无法归因到某个子代理)
126
+ const agent = exec && exec.agent;
127
+ const st = stateFor(agent);
128
+ if (!st) return downstream;
129
+
130
+ const key = callKey(exec);
131
+ st.history.push({ key, path });
132
+ if (st.history.length > historySize) st.history.splice(0, st.history.length - historySize);
133
+
134
+ const pair = oscillationPair(st.history.map((e) => e.key));
135
+ if (!pair) return downstream;
136
+ // ⑤ **必须同一个文件**:`编辑 a.js`/`编辑 b.js` 交替是正常干活,不是振荡
137
+ if (new Set(st.history.slice(-4).map((e) => e.path)).size !== 1) return downstream;
138
+ // ⑥ 同一对**每个 agent 会话只提醒一次**:重复提醒会把守卫变成噪声(宿主那份的已知痛点)
139
+ if (st.fired.has(pair)) return downstream;
140
+ st.fired.add(pair);
141
+
142
+ const name = String((exec && exec.name) || '');
143
+ const preview = String(exec && exec.arguments ? JSON.stringify(exec.arguments) : '').slice(0, previewChars);
144
+ const text = [
145
+ `⚠️ 检测到**振荡**:你在两个互斥的做法之间来回切换(最近四次调用形如 A→B→A→B,工具「${name}」)。`,
146
+ '来回切本身说明其中至少一次判断是错的。请按顺序做:',
147
+ '1) **停止切换**:从两者中选一个最可能正确的,明确写出为什么选它;',
148
+ '2) 若两者都不成立,**退一步找共同根因**,而不是在两者之间继续换;',
149
+ '3) 若确实无法推进,**停下来给结论**:已试过什么、各自失败在哪、还剩什么没试。',
150
+ `(最近一次调用的参数预览:${preview || '(无)'})`,
151
+ ].join('\n');
152
+
153
+ onEvent('loop-guard-oscillation', { tool: name, pair });
154
+ const feedback = makeFeedback(text);
155
+ // ④ 折叠下游结论:下游已 block 就保留它的理由再追加我们的;否则自己 block
156
+ if (downstream && downstream.kind === 'block') {
157
+ return { kind: 'block', feedback: [...(downstream.feedback || []), ...feedback] };
158
+ }
159
+ return { kind: 'block', feedback };
160
+ } catch (e) {
161
+ onEvent('loop-guard-error', { error: String((e && e.message) || e) });
162
+ return downstream;
163
+ }
164
+ };
165
+ }
@@ -0,0 +1,70 @@
1
+ // run 的**采集层**(B 线 10b 第二刀):只做 IO,不做判定。
2
+ //
3
+ // `aggregate()` 原本把六件事压在一个函数里(枚举 / 读取 / 解析 / 计算 / 渲染 / 写盘)。
4
+ // 渲染已切到 `render.js`;这里把**枚举与读取**切出来,让「怎么读盘」与「读出来怎么算」分开 ——
5
+ // 值在于 ① 采集的边界(什么算 run、缺文件怎么办)可以单测;② 计算主体将来搬走时,
6
+ // 依赖面只剩一个 `readRunInputs` 返回值,而不是散落各处的 `readFile`。
7
+ //
8
+ // 设计:**逐 run 读取**(不是"一次读全部")—— 保持与旧实现相同的内存行为:
9
+ // 旧实现每轮读一个 run 就丢掉;收集成一个大数组会让长日志全留在内存里。
10
+
11
+ import { readdir, readFile, stat } from 'node:fs/promises';
12
+ import { join } from 'node:path';
13
+
14
+ /** 判定「真 run 目录」的标记文件(含任一即可)。 */
15
+ export const RUN_MARKER_FILES = ['STATE.json', 'TASK.md', 'RUN.log.md'];
16
+
17
+ /** 读 JSON,失败返回 null(不抛)。 */
18
+ async function readJsonSafe(p) {
19
+ try { return JSON.parse(await readFile(p, 'utf8')); } catch { return null; }
20
+ }
21
+
22
+ /**
23
+ * 列出 `<root>` 下的**真 run** 目录名。
24
+ *
25
+ * 为什么不能直接 `readdir` 全收:`team/` 里还躺着 METRICS.md / LEARNINGS.md / REPOWIKI.md /
26
+ * CODEINDEX.json 这些**文件**,以及可能的空目录。旧实现把它们也算进「总 run 数」,
27
+ * 于是用户看到「总 run 数:8」而真实只有 2(D4 实测)。
28
+ *
29
+ * @param root - `<cwd>/team` 的绝对路径。
30
+ * @returns `{ ok, names, skipped }`;`ok:false` 表示 root 读不了(调用方按「零 run」处理)。
31
+ */
32
+ export async function listRunNames(root) {
33
+ const names = [];
34
+ let skipped = 0;
35
+ try {
36
+ const entries = await readdir(root, { withFileTypes: true });
37
+ for (const ent of entries) {
38
+ if (!ent.isDirectory()) { skipped += 1; continue; }
39
+ const dir = join(root, ent.name);
40
+ let isRun = false;
41
+ for (const marker of RUN_MARKER_FILES) {
42
+ if (await stat(join(dir, marker)).then(() => true).catch(() => false)) { isRun = true; break; }
43
+ }
44
+ if (isRun) names.push(ent.name); else skipped += 1;
45
+ }
46
+ names.sort();
47
+ } catch {
48
+ return { ok: false, names: [], skipped: 0 };
49
+ }
50
+ return { ok: true, names, skipped };
51
+ }
52
+
53
+ /**
54
+ * 读一个 run 的四份输入(缺哪份就返回该份的「空值」,**不抛**)。
55
+ * @param root - `<cwd>/team` 的绝对路径。
56
+ * @param name - run 目录名。
57
+ * @returns `{ dir, state, log, tasksDoc, rosterDoc }`
58
+ * —— `state`/`tasksDoc`/`rosterDoc` 读不到或不是合法 JSON 时为 `null`;`log` 读不到时为 `''`。
59
+ */
60
+ export async function readRunInputs(root, name) {
61
+ const dir = join(root, name);
62
+ let state = null;
63
+ let log = '';
64
+ let tasksDoc = null;
65
+ try { state = JSON.parse(await readFile(join(dir, 'STATE.json'), 'utf8')); } catch { /* 缺件按 null */ }
66
+ try { log = await readFile(join(dir, 'RUN.log.md'), 'utf8'); } catch { /* 缺件按空串 */ }
67
+ try { tasksDoc = JSON.parse(await readFile(join(dir, 'TASKS.json'), 'utf8')); } catch { /* 缺件按 null */ }
68
+ const rosterDoc = await readJsonSafe(join(dir, 'ROSTER.json'));
69
+ return { dir, state, log, tasksDoc, rosterDoc };
70
+ }