@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
package/lib/validate.js
ADDED
|
@@ -0,0 +1,681 @@
|
|
|
1
|
+
// 校验器与上限(B 线第 8 项「拆 command.js」的第一步:**纯函数、零 IO**)。
|
|
2
|
+
//
|
|
3
|
+
// 为什么先切这一刀:`/team check` 的七类违规、DAG 三色环检测、容量/轮次上限、schema 只读校验,
|
|
4
|
+
// 全部是**纯函数**(给定输入必有确定输出、不碰盘)—— 它们既是门禁的核心,也是最容易单测的部分。
|
|
5
|
+
// 切出来之后:① 这些判定可以脱离 5000 行的编排器单独验证;② `lib/interception.js` 注入的
|
|
6
|
+
// `validateTaskGraph` 与写侧门禁继续用**同一份**实现(本仓吃过"两套各写一套、迟早不同步"的亏)。
|
|
7
|
+
//
|
|
8
|
+
// 依赖:`./vocab.js`(词表唯一真源)与 `./artifact-writer.js`(schema guards)。
|
|
9
|
+
// 边界:**这里不放编排逻辑**(不读盘、不写盘、不认识 `/team` 命令)—— 只放"给一份数据判对错"。
|
|
10
|
+
|
|
11
|
+
import { ALLOWED_KINDS, ALLOWED_KINDS_ZH, KIND_ZH, PHASES, kindZh, statusZh, verdictZh, phaseZh } from './vocab.js';
|
|
12
|
+
// 日志解析**只此一份**(B 线第 8 项):validate.js 曾为阶段记账临时抄过一份 `parseLogLineLite`,
|
|
13
|
+
// 现已删除并改用真源 —— 两份解析器迟早分叉,而「哪边对」没人说得清。
|
|
14
|
+
import { parseLogLine } from './log-parse.js';
|
|
15
|
+
import { DEFAULT_SCHEMA_GUARDS } from './artifact-writer.js';
|
|
16
|
+
|
|
17
|
+
// ── O-3 容量上限(fail-loud,禁止静默截断)────────────────────────────────
|
|
18
|
+
//
|
|
19
|
+
// 竞品分析 P0 的最后一项。原判断(`GAP-ANALYSIS.md` §5.1 D-5):本包有 `quota`(runs/maxRuns/deadline)
|
|
20
|
+
// 却**没有任何成员/任务数量上限** ⇒ "无上限即无护栏":一次编排脚本抽风可以派出 200 个成员、
|
|
21
|
+
// 生成 5000 条任务,而系统**不会说一个字**。官方有 `TEAM_MEMBER_LIMIT` / `TEAM_TASK_LIMIT` 且**显式报错**。
|
|
22
|
+
//
|
|
23
|
+
// 设计要点(对齐官方、并遵守本仓的"诚实"取向):
|
|
24
|
+
// · **fail loud,不静默截断** —— 超限时返回**显式错误码**(`TEAM_MEMBER_LIMIT` / `TEAM_TASK_LIMIT`),
|
|
25
|
+
// 而不是"悄悄丢掉多余的"。
|
|
26
|
+
// · **只写拦截 + 读可见**:写入路径(plan 路由 / 任务路由)在**落盘前**拒;
|
|
27
|
+
// `/team check` 对**存量**超限 run **只报告不阻断**(与 L1-4′ 同口径:先落盘再告警,旧 run 不消失)。
|
|
28
|
+
// · **上限可在 config 覆写**:`apply(ctx, config)` 的 `config.limits`,并支持环境变量兜底
|
|
29
|
+
// (`DSH_EXPERT_TEAM_MAX_MEMBERS` / `DSH_EXPERT_TEAM_MAX_TASKS`)。
|
|
30
|
+
export const DEFAULT_LIMITS = { maxMembers: 32, maxTasks: 200 };
|
|
31
|
+
|
|
32
|
+
export const LIMITS = { ...DEFAULT_LIMITS };
|
|
33
|
+
|
|
34
|
+
export const LIMIT_ENV = { maxMembers: 'DSH_EXPERT_TEAM_MAX_MEMBERS', maxTasks: 'DSH_EXPERT_TEAM_MAX_TASKS' };
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 上限值解析(**容量上限与轮次上限共用**,唯一口径 —— 两份各写一套迟早不同步,
|
|
38
|
+
* 而且会让"同一条纪律"在两边悄悄分叉)。
|
|
39
|
+
* 只接受 number / 非空 string;`min` 是**下界**:容量用 `0`("显式禁止任何成员/任务"是合法上限),
|
|
40
|
+
* 轮次用 `1`("零轮评审"没有意义)。合法值**向下取整**后返回;非法值一律 `null`,由调用方回默认。
|
|
41
|
+
*
|
|
42
|
+
* ⚠️ 只接受 number / 非空 string。**不能用 `Number(v)` 一把收**:`Number(null) === 0`、
|
|
43
|
+
* `Number('') === 0`、`Number([]) === 0` —— JSON 里 `"maxMembers": null` 是极自然的"未设置"写法,
|
|
44
|
+
* 若被判成 0 就会**拒绝每一个计划**(fail-closed 的静默灾难)。这里显式排除。
|
|
45
|
+
*/
|
|
46
|
+
export function pickLimitValue(v, min) {
|
|
47
|
+
if (typeof v !== 'number' && typeof v !== 'string') return null;
|
|
48
|
+
if (typeof v === 'string' && v.trim() === '') return null;
|
|
49
|
+
const n = Number(v);
|
|
50
|
+
return Number.isFinite(n) && n >= min ? Math.floor(n) : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 解析并落地容量上限,优先级 **config > env > base > 内置默认**。
|
|
55
|
+
*
|
|
56
|
+
* `base`(可选)是**设置控制台**存下来的值(F 线):设置是"用户偏好",不该压过部署方的
|
|
57
|
+
* `config` 或环境变量(那两者是显式运维动作)—— 所以它排在它们之后、内置默认之前。
|
|
58
|
+
* 语义刻意简单可预测:某一档**给了合法值**就用它;**没给或给了非法值**(负数/NaN/非数字)
|
|
59
|
+
* → **回到下一优先级**(而不是"保留上一次",那会让 `resolveLimits(null)` 残留上一次的值,
|
|
60
|
+
* 既难测也难解释)。每次都完整重算两个键 ⇒ 同进程内重复调用幂等。
|
|
61
|
+
*/
|
|
62
|
+
export function resolveLimits(config, base) {
|
|
63
|
+
const fromCfg = (config && typeof config === 'object' && config.limits) || {};
|
|
64
|
+
const env = (typeof process !== 'undefined' && process.env) || {};
|
|
65
|
+
const fromBase = (base && typeof base === 'object') ? base : {};
|
|
66
|
+
for (const k of Object.keys(DEFAULT_LIMITS)) {
|
|
67
|
+
// `min = 0`:容量侧 `0` 是**合法上限**(显式禁止任何成员/任务)
|
|
68
|
+
const cfgV = pickLimitValue(fromCfg[k], 0);
|
|
69
|
+
const envV = pickLimitValue(env[LIMIT_ENV[k]], 0);
|
|
70
|
+
const baseV = pickLimitValue(fromBase[k], 0);
|
|
71
|
+
LIMITS[k] = cfgV !== null ? cfgV : (envV !== null ? envV : (baseV !== null ? baseV : DEFAULT_LIMITS[k]));
|
|
72
|
+
}
|
|
73
|
+
return { ...LIMITS };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 容量检查(纯函数)。返回 `[{code, message, actual, limit}]`,空数组 = 合规。
|
|
78
|
+
* `code` 用官方同名;`roles` 可为 undefined(只管任务)。
|
|
79
|
+
*/
|
|
80
|
+
export function capacityViolations(input, limits) {
|
|
81
|
+
const lim = { ...LIMITS, ...(limits || {}) };
|
|
82
|
+
const out = [];
|
|
83
|
+
const roles = Array.isArray(input && input.roles) ? input.roles : null;
|
|
84
|
+
const tasks = Array.isArray(input && input.tasks) ? input.tasks : null;
|
|
85
|
+
if (roles && roles.length > lim.maxMembers) {
|
|
86
|
+
out.push({ code: 'TEAM_MEMBER_LIMIT', actual: roles.length, limit: lim.maxMembers, message: `成员数 ${roles.length} 超过上限 ${lim.maxMembers}(TEAM_MEMBER_LIMIT)—— 未落盘;请减少角色或调高上限(config.limits.maxMembers / DSH_EXPERT_TEAM_MAX_MEMBERS)` });
|
|
87
|
+
}
|
|
88
|
+
if (tasks && tasks.length > lim.maxTasks) {
|
|
89
|
+
out.push({ code: 'TEAM_TASK_LIMIT', actual: tasks.length, limit: lim.maxTasks, message: `任务数 ${tasks.length} 超过上限 ${lim.maxTasks}(TEAM_TASK_LIMIT)—— 未落盘;请拆分 run 或调高上限(config.limits.maxTasks / DSH_EXPERT_TEAM_MAX_TASKS)` });
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── P3 收敛硬门禁:maxReviewRounds / maxTestRounds 从**文档口号**变成**代码强制** ──────────
|
|
95
|
+
// 事故证据(真实,非假设):`php/school` 的一个 run —— 68 任务 / **32 条 repair** / maxRound=8,
|
|
96
|
+
// 评审每轮都在**新增** finding,永不收敛。根因之一:`maxReviewRounds` / `maxTestRounds` 在
|
|
97
|
+
// `lib/command.js` 里**零命中**(只活在 SKILL/PIPELINE/EFFICIENCY/WORKSPACE 文档里当规则喊)
|
|
98
|
+
// ⇒ 循环没有硬停止点,只能靠人喊停。
|
|
99
|
+
//
|
|
100
|
+
// 设计要点(**逐字对齐**上面容量上限的风格,两套上限是同一套纪律):
|
|
101
|
+
// · **fail loud,不静默截断** —— 写侧命中即返回显式错误码(`REWORK_LOOP_LIMIT`)并**不写盘**;
|
|
102
|
+
// · **只写拦截 + 读可见** —— `/team check` 对**存量**超轮次 run **只报告不阻断**(旧 run 不因新规消失);
|
|
103
|
+
// · **上限可在 config 覆写** —— `config.limits.maxReviewRounds` / `maxTestRounds`,env 兜底;
|
|
104
|
+
// · **到顶的正确动作是升级用户**(写 `STATE.pendingDecision`),**不是再派一轮修复** —— V2 把
|
|
105
|
+
// "同一 finding 连续两轮未闭环"直接判为**规格歧义**,因为那说明规格没写清楚,再修也修不完。
|
|
106
|
+
export const DEFAULT_ROUND_LIMITS = { maxReviewRounds: 3, maxTestRounds: 3 };
|
|
107
|
+
|
|
108
|
+
export const ROUND_LIMITS = { ...DEFAULT_ROUND_LIMITS };
|
|
109
|
+
|
|
110
|
+
export const ROUND_LIMIT_ENV = {
|
|
111
|
+
maxReviewRounds: 'DSH_EXPERT_TEAM_MAX_REVIEW_ROUNDS',
|
|
112
|
+
maxTestRounds: 'DSH_EXPERT_TEAM_MAX_TEST_ROUNDS',
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 解析并落地**轮次**上限,优先级 **config > env > 默认**(与 `resolveLimits` 同语义:每次都完整
|
|
117
|
+
* 重算两个键 ⇒ 无粘性、幂等)。
|
|
118
|
+
* 非法值(负数 / 0 / NaN / 非数字 / 空串 / null)一律**忽略并回到默认** —— 这里的 `0` **不**是合法上限
|
|
119
|
+
*("零轮评审/零轮测试"没有任何意义),而 `Number(null) === 0` 这类强制转换陷阱会把"未设置"变成"禁止"。
|
|
120
|
+
*/
|
|
121
|
+
export function resolveRoundLimits(config, base) {
|
|
122
|
+
const fromCfg = (config && typeof config === 'object' && config.limits) || {};
|
|
123
|
+
const env = (typeof process !== 'undefined' && process.env) || {};
|
|
124
|
+
const fromBase = (base && typeof base === 'object') ? base : {};
|
|
125
|
+
for (const k of Object.keys(DEFAULT_ROUND_LIMITS)) {
|
|
126
|
+
// `min = 1`:轮次下限是 1 轮 —— `0` / 负数 / 取整后为 0 的小数(如 `0.5`)一律非法 → 回默认
|
|
127
|
+
const cfgV = pickLimitValue(fromCfg[k], 1);
|
|
128
|
+
const envV = pickLimitValue(env[ROUND_LIMIT_ENV[k]], 1);
|
|
129
|
+
const baseV = pickLimitValue(fromBase[k], 1);
|
|
130
|
+
ROUND_LIMITS[k] = cfgV !== null ? cfgV : (envV !== null ? envV : (baseV !== null ? baseV : DEFAULT_ROUND_LIMITS[k]));
|
|
131
|
+
}
|
|
132
|
+
return { ...ROUND_LIMITS };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* 质量任务 kind → 用哪一档轮次上限(**唯一口径**:读侧违规 / 写侧拦截 / METRICS 都走这里,
|
|
137
|
+
* 不得各写一套 —— 三份各写一套的映射迟早不同步)。`review` 与 `requirements`(spec-review)
|
|
138
|
+
* 吃 review 档;`verification` 与 `quality`(自检)吃 test 档。
|
|
139
|
+
*/
|
|
140
|
+
export const ROUND_LIMIT_OF_KIND = {
|
|
141
|
+
review: 'maxReviewRounds',
|
|
142
|
+
requirements: 'maxReviewRounds',
|
|
143
|
+
verification: 'maxTestRounds',
|
|
144
|
+
quality: 'maxTestRounds',
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/** 任务轮次(**唯一口径**):缺失/非法一律视为第 1 轮 —— "没写轮次"不是"第 0 轮",更不是"超限"。 */
|
|
148
|
+
export const roundOf = (t) => { const r = Number(t && t.round); return Number.isFinite(r) && r > 0 ? r : 1; };
|
|
149
|
+
|
|
150
|
+
/** 质量类任务判定(轮次门禁只管这些 kind)。 */
|
|
151
|
+
export const isQualityTask = (t) => !!(t && ROUND_LIMIT_OF_KIND[String((t && t.kind) || '')]);
|
|
152
|
+
|
|
153
|
+
/** finding 标题归一(分组键):trim + 空白归一 + 截 60 字符。 */
|
|
154
|
+
export const normTitle = (x) => String(x).trim().replace(/\s+/g, ' ').slice(0, 60);
|
|
155
|
+
|
|
156
|
+
/** 单个 finding 元素的可读标题:`title || detail || String(f)`。 */
|
|
157
|
+
export const findingText = (f) => (f && typeof f === 'object') ? String(f.title || f.detail || f) : String(f);
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* 读侧违规(**纯函数**)。返回 `[{code,id,actual,limit,message}]`,空数组 = 合规。
|
|
161
|
+
*
|
|
162
|
+
* V1 `REWORK_LOOP_UNESCALATED`:质量类任务已进入第 N 轮(N > 上限)却仍无 `pendingDecision`
|
|
163
|
+
* ⇒ 返工循环**没人升级给用户** —— 事故里正是这样一轮接一轮地派修复,直到人喊停。
|
|
164
|
+
* V2 `FINDING_REOPENED`:同一 finding(归一标题)出现在**相邻两轮**、且这两轮对应的质量任务
|
|
165
|
+
* 裁决**都不是 `pass`** ⇒ 这不是"还没修完",而是**规格没写清楚**(每轮都在新增/翻旧账),
|
|
166
|
+
* 正确动作是升级用户裁定,**不是再派一轮修复**。
|
|
167
|
+
*
|
|
168
|
+
* ⚠️ `state` 看不见时调用方**不应调用**本函数(无法判断 pendingDecision,报了就是误报);
|
|
169
|
+
* `checkTasks` 因此把它放在 `state !== undefined` 分支里。
|
|
170
|
+
*/
|
|
171
|
+
export function roundLimitViolations(tasks, state, limits) {
|
|
172
|
+
// `limits` 与容量上限共用调用点(SPEC §1.3 传的是 `LIMITS`)⇒ 先铺 ROUND_LIMITS 再让显式值覆盖:
|
|
173
|
+
// 传进来的对象里**没有**轮次键时,轮次口径仍取自 `ROUND_LIMITS`(否则传 LIMITS 会把上限读成 undefined)。
|
|
174
|
+
const lim = { ...ROUND_LIMITS, ...(limits || {}) };
|
|
175
|
+
const arr = Array.isArray(tasks) ? tasks : [];
|
|
176
|
+
const out = [];
|
|
177
|
+
const hasPending = !!(state && state.pendingDecision);
|
|
178
|
+
// ── V1:超过上限却仍未升级用户 ──
|
|
179
|
+
if (!hasPending) {
|
|
180
|
+
for (const t of arr) {
|
|
181
|
+
if (!isQualityTask(t)) continue;
|
|
182
|
+
const kind = String((t && t.kind) || '');
|
|
183
|
+
const n = roundOf(t);
|
|
184
|
+
const m = lim[ROUND_LIMIT_OF_KIND[kind]];
|
|
185
|
+
if (!Number.isFinite(m) || n <= m) continue; // `>` 而非 `>=`:恰好等于上限仍算收敛中
|
|
186
|
+
const id = String((t && t.id) || '');
|
|
187
|
+
out.push({
|
|
188
|
+
code: 'REWORK_LOOP_UNESCALATED', id, actual: n, limit: m,
|
|
189
|
+
message: `[${id}] 已进入第 ${n} 轮(上限 ${m},${kind})仍无 pendingDecision ⇒ 返工循环未升级给用户`,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
// ── V2:同一 finding 连续两轮未闭环(规格歧义的信号)──
|
|
194
|
+
// 分组键 = 归一标题;组内记 `round → { kind, allPass }`(allPass = 该轮**含此 finding 的**
|
|
195
|
+
// 质量任务**全部** pass —— 只要有一条没 pass,该轮就算「未闭环」)。
|
|
196
|
+
//
|
|
197
|
+
// ⚠️ **判定域与口径(SG-5 裁决,写死为 AND)**:判定域 = **含该 finding 的任务**(不是"该轮的全部
|
|
198
|
+
// 质量任务");该轮**任一**此类任务非 `pass` ⇒ 该轮算「未闭环」(即"该轮全部此类任务都 pass"才闭合)。
|
|
199
|
+
// 选最窄读法的理由:报得最少、误报最低 —— 只有当同一个 finding 真的跨相邻两轮都没被解决时才升级,
|
|
200
|
+
// 不会因为同轮里另一条毫不相干的 finding 没通过就把这一条也判成"规格歧义"。
|
|
201
|
+
// 反过来说:同轮里"一个 pass + 一个 needs_revision"且都带同一 finding ⇒ 该轮**未闭环**(用例 ⑤-6 钉死)。
|
|
202
|
+
const groups = new Map();
|
|
203
|
+
for (const t of arr) {
|
|
204
|
+
if (!isQualityTask(t)) continue;
|
|
205
|
+
const kind = String((t && t.kind) || '');
|
|
206
|
+
const r = roundOf(t);
|
|
207
|
+
const pass = String((t && t.verdict) || '') === 'pass';
|
|
208
|
+
for (const f of (Array.isArray(t && t.findings) ? t.findings : [])) {
|
|
209
|
+
const key = normTitle(findingText(f));
|
|
210
|
+
// 空/无意义标题**不成组**(不臆造):`''`、字面量 `undefined`/`null`,以及**无 title/detail 的
|
|
211
|
+
// 对象 finding** —— `String({severity:'high'})` 恒为 `[object Object]`,拿它当分组键会把两条毫不
|
|
212
|
+
// 相干的 finding 归成一组、**误报**"规格歧义"(宁可漏报,不可错指;这类 finding 本就没有可比的标题)。
|
|
213
|
+
if (!key || key === 'undefined' || key === 'null' || key === '[object Object]') continue;
|
|
214
|
+
if (!groups.has(key)) groups.set(key, new Map());
|
|
215
|
+
const g = groups.get(key);
|
|
216
|
+
const at = g.get(r) || { allPass: true, kind };
|
|
217
|
+
at.allPass = at.allPass && pass;
|
|
218
|
+
at.kind = kind;
|
|
219
|
+
g.set(r, at);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
for (const [title, g] of groups) {
|
|
223
|
+
const rs = [...g.keys()].sort((a, b) => a - b);
|
|
224
|
+
for (let i = 0; i + 1 < rs.length; i += 1) {
|
|
225
|
+
const r = rs[i], r1 = rs[i + 1];
|
|
226
|
+
if (r1 !== r + 1) continue; // 只认**相邻两轮**(1→3 这种跨轮不算连续)
|
|
227
|
+
const a = g.get(r), b = g.get(r1);
|
|
228
|
+
if (a.allPass || b.allPass) continue; // 任一轮已 pass ⇒ 该 finding 已闭环,不报
|
|
229
|
+
const kind = b.kind || a.kind;
|
|
230
|
+
out.push({
|
|
231
|
+
// `id` 用 finding 标题(这条违规的**主体是 finding**,不是某个任务)
|
|
232
|
+
code: 'FINDING_REOPENED', id: title, actual: r1, limit: lim[ROUND_LIMIT_OF_KIND[kind]],
|
|
233
|
+
message: `[${title}] 连续第 ${r}/${r1} 轮仍未闭环 ⇒ 判为规格歧义,应升级用户裁定而非再派修复`,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return out;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* R25(SG-3):V2 的**无输入告警** —— **提示级,不是违规**(绝不进 `violations`,普通 run 不得被它判红)。
|
|
242
|
+
*
|
|
243
|
+
* 为什么需要:V2(`FINDING_REOPENED`)代码正确、可达,但**14 个真实 run 全部命中 0** —— 事故 run 有
|
|
244
|
+
* 16 个质量任务 / 73 条 finding,却是 **73 个互不相同的标题**(0 条跨相邻轮重复)。于是「门禁失明」与
|
|
245
|
+
* 「已收敛」在输出上**完全同形**:用户看到"没有 FINDING_REOPENED",无从判断是"真收敛"还是"V2 没数据可吃"。
|
|
246
|
+
* 这与 D7(函数写了没人调)、C5(指标没有写路径)同族:**代码写了但没人喂数据**,必须至少让"没数据"显形。
|
|
247
|
+
*
|
|
248
|
+
* 触发(两条同时成立):
|
|
249
|
+
* ① 该 run 有 **≥2 个** `verdict !== 'pass'` 的质量任务(说明真的返工过 —— 没返工就不存在"该报没报");
|
|
250
|
+
* ② 全部质量任务的 findings 归一标题里,**没有任何一条出现在 ≥2 个不同轮次**(V2 的输入是"标题跨轮稳定")。
|
|
251
|
+
* 返 null = 不提示(含"没返工过"与"V2 有输入"两种正常情况)。
|
|
252
|
+
* @returns {string|null} 提示行(不含前缀符号)
|
|
253
|
+
*/
|
|
254
|
+
export function findingReopenInputMissing(tasks) {
|
|
255
|
+
const arr = Array.isArray(tasks) ? tasks : [];
|
|
256
|
+
const quality = arr.filter((t) => isQualityTask(t));
|
|
257
|
+
const nonPass = quality.filter((t) => String((t && t.verdict) || '') !== 'pass');
|
|
258
|
+
if (nonPass.length < 2) return null; // 没返工过 ⇒ 不存在"该吃却吃不到输入"
|
|
259
|
+
const roundsOfTitle = new Map(); // 归一标题 -> 出现过的轮次集合
|
|
260
|
+
let findings = 0;
|
|
261
|
+
for (const t of quality) {
|
|
262
|
+
const r = roundOf(t);
|
|
263
|
+
for (const f of (Array.isArray(t && t.findings) ? t.findings : [])) {
|
|
264
|
+
findings += 1;
|
|
265
|
+
const key = normTitle(findingText(f));
|
|
266
|
+
if (!key || key === 'undefined' || key === 'null' || key === '[object Object]') continue; // 与 V2 同口径
|
|
267
|
+
if (!roundsOfTitle.has(key)) roundsOfTitle.set(key, new Set());
|
|
268
|
+
roundsOfTitle.get(key).add(r);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
const hasStableTitle = [...roundsOfTitle.values()].some((s) => s.size >= 2);
|
|
272
|
+
if (hasStableTitle) return null; // V2 有输入 ⇒ 它没命中就是真的没命中
|
|
273
|
+
return `FINDING_REOPENED_INPUT_MISSING — 本 run 有 ${nonPass.length} 个非 pass 质量任务、${findings} 条 finding,但没有任何 finding 标题跨轮重复 ⇒ V2(同一 finding 连续两轮未闭环)无输入可判。若确已收敛请忽略;否则说明 finding 编号未跨轮沿用(见 SKILL 的「finding 编号跨轮稳定」)`;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ── /team check [<run>]: host-side state-machine + quality-gate validator ──
|
|
277
|
+
// Lets the lead/you verify TASKS.json is consistent WITHOUT trusting prompt-following.
|
|
278
|
+
// `research` / `design` are first-class kinds because the pipeline's own stages and
|
|
279
|
+
// roles use that vocabulary (client.js already renders both labels) — the closed
|
|
280
|
+
// enum previously rejected real, healthy runs. `quality` is the leader's self-check kind.
|
|
281
|
+
// `ALLOWED_KINDS` / `ALLOWED_KINDS_ZH` 已搬到 `lib/vocab.js`(B 线 11b),且**中文清单由
|
|
282
|
+
// `KIND_ZH` 推导**——此前「合法 kind」这一个事实在这里有**三份**(英文 Set、手写中文字符串、
|
|
283
|
+
// `KIND_ZH` 的键),加一个 kind 就得记得改三处。
|
|
284
|
+
/**
|
|
285
|
+
* Non-blocking taxonomy warnings for an unknown `kind`.
|
|
286
|
+
*
|
|
287
|
+
* An unknown kind is a vocabulary mismatch, not a quality failure: the task is
|
|
288
|
+
* still tracked and its status still flows. Failing the whole gate on it froze
|
|
289
|
+
* healthy runs (T-01…T-06 of 做竞品分析-分析dsh官方的te-145629), so it warns —
|
|
290
|
+
* and says what the task loses (its kind-specific gates cannot apply).
|
|
291
|
+
* @param tasks - task list.
|
|
292
|
+
* @returns warning lines; empty when every kind is known.
|
|
293
|
+
*/
|
|
294
|
+
export function checkKindWarnings(tasks) {
|
|
295
|
+
const warnings = [];
|
|
296
|
+
for (const t of tasks) if (t.kind && !ALLOWED_KINDS.has(t.kind)) warnings.push(`[${t.id}] 任务类型「${t.kind}」不在词表内(不阻断门禁,但该任务的 kind 专属门禁不生效,等同「${KIND_ZH.work}」)——允许:${ALLOWED_KINDS_ZH}`);
|
|
297
|
+
return warnings;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* 全图任务依赖校验(批 2-2 · L2-2)。
|
|
302
|
+
*
|
|
303
|
+
* 检测三类**结构性**错误 —— 这三类过去都被静默吞掉,代价各不相同:
|
|
304
|
+
* · `missing-dependency`:dependsOn 指向不存在的 id。
|
|
305
|
+
* —— 计划路由曾用 `.filter(d2 => ids.has(d2))` **直接删掉**(用户以为依赖生效,其实没有);
|
|
306
|
+
* —— `checkTasks` 曾 `if (!dep) continue` **静默放行**(门禁出现盲区)。
|
|
307
|
+
* · `duplicate-id`:重复 id 会让 `byId` 相互覆盖、依赖指向歧义。
|
|
308
|
+
* · `cycle`:环路会让 implement 阶段整体死锁(环内任务永远 ready=false),
|
|
309
|
+
* 而 `/team check` 此前**不报任何违规** —— 表现为面板「待开始」永久不动。
|
|
310
|
+
*
|
|
311
|
+
* 纯函数(不碰 IO),便于单测与变异验证。
|
|
312
|
+
* @returns `{ok, errors:[{code, detail}]}`
|
|
313
|
+
*/
|
|
314
|
+
export function validateTaskGraph(tasks) {
|
|
315
|
+
const errors = [];
|
|
316
|
+
const arr = Array.isArray(tasks) ? tasks : [];
|
|
317
|
+
const idCount = new Map();
|
|
318
|
+
for (const t of arr) {
|
|
319
|
+
const id = String((t && t.id) || '').trim();
|
|
320
|
+
if (!id) { errors.push({ code: 'missing-id', detail: '存在没有 id 的任务' }); continue; }
|
|
321
|
+
idCount.set(id, (idCount.get(id) || 0) + 1);
|
|
322
|
+
}
|
|
323
|
+
for (const [id, n] of idCount) {
|
|
324
|
+
if (n > 1) errors.push({ code: 'duplicate-id', detail: `任务 id 重复 ${n} 次:${id}` });
|
|
325
|
+
}
|
|
326
|
+
const idSet = new Set(idCount.keys());
|
|
327
|
+
const adj = new Map();
|
|
328
|
+
for (const t of arr) {
|
|
329
|
+
const id = String((t && t.id) || '').trim();
|
|
330
|
+
if (!id || !idSet.has(id)) continue;
|
|
331
|
+
const deps = (Array.isArray(t.dependsOn) ? t.dependsOn : []).map((d) => String(d).trim()).filter(Boolean);
|
|
332
|
+
for (const d of deps) {
|
|
333
|
+
if (d === id) errors.push({ code: 'self-dependency', detail: `[${id}] 依赖自身` });
|
|
334
|
+
else if (!idSet.has(d)) errors.push({ code: 'missing-dependency', detail: `[${id}] 依赖不存在的任务 [${d}]` });
|
|
335
|
+
}
|
|
336
|
+
if (!adj.has(id)) adj.set(id, []);
|
|
337
|
+
for (const d of deps) {
|
|
338
|
+
if (d !== id && idSet.has(d) && !adj.get(id).includes(d)) adj.get(id).push(d);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
// 三色 DFS 检环(沿 dependsOn 边走)
|
|
342
|
+
const WHITE = 0, GRAY = 1, BLACK = 2;
|
|
343
|
+
const color = new Map([...idSet].map((id) => [id, WHITE]));
|
|
344
|
+
const stack = [];
|
|
345
|
+
const reported = new Set();
|
|
346
|
+
const visit = (id) => {
|
|
347
|
+
color.set(id, GRAY); stack.push(id);
|
|
348
|
+
for (const d of (adj.get(id) || [])) {
|
|
349
|
+
const c = color.get(d);
|
|
350
|
+
if (c === GRAY) {
|
|
351
|
+
const at = stack.indexOf(d);
|
|
352
|
+
const cyc = (at >= 0 ? stack.slice(at) : [d]).concat(d);
|
|
353
|
+
const key = [...new Set(cyc)].sort().join('|'); // 同一环只报一次
|
|
354
|
+
if (!reported.has(key)) {
|
|
355
|
+
reported.add(key);
|
|
356
|
+
errors.push({ code: 'cycle', detail: `检测到循环依赖:${cyc.join(' → ')}` });
|
|
357
|
+
}
|
|
358
|
+
} else if (c === WHITE) {
|
|
359
|
+
visit(d);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
stack.pop(); color.set(id, BLACK);
|
|
363
|
+
};
|
|
364
|
+
for (const id of idSet) if (color.get(id) === WHITE) visit(id);
|
|
365
|
+
return { ok: errors.length === 0, errors };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* C4:把**写入点的 schema 告警**接进 `/team check`(只读校验,复用同一份 guard)。
|
|
370
|
+
*
|
|
371
|
+
* 原先 guard 只在**写入那一刻**经 `schema-warn` 事件上报(`console.warn` + 活动流)⇒
|
|
372
|
+
* 一份**存量**违规工件(旧 run、或被别的写入路径改脏的文件)`/team check` 永远查不出来 ——
|
|
373
|
+
* 「少了东西却没有任何信号」正是这个插件最危险的失败模式。
|
|
374
|
+
* 这里用**同一份** `DEFAULT_SCHEMA_GUARDS` 对磁盘上的对象做只读校验,口径与写入侧一致:
|
|
375
|
+
* **只告警、不阻断**(写侧也不硬拒,见 L1-4′ 仲裁)。
|
|
376
|
+
*/
|
|
377
|
+
export function schemaViolations(stateObj, tasksObj) {
|
|
378
|
+
const out = [];
|
|
379
|
+
const run = (name, obj) => {
|
|
380
|
+
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return;
|
|
381
|
+
const guard = DEFAULT_SCHEMA_GUARDS[name];
|
|
382
|
+
if (typeof guard !== 'function') return;
|
|
383
|
+
for (const m of guard(obj) || []) out.push(`[schema ${name}] ${m}`);
|
|
384
|
+
};
|
|
385
|
+
try { run('STATE.json', stateObj); } catch { /* best-effort */ }
|
|
386
|
+
try { run('TASKS.json', tasksObj); } catch { /* best-effort */ }
|
|
387
|
+
return out;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export function checkTasks(tasks, phase, state) {
|
|
391
|
+
const byId = {}; tasks.forEach((t) => { byId[t.id] = t });
|
|
392
|
+
const violations = [];
|
|
393
|
+
// 批 2-2(L2-2):先把**图结构**错误报出来 —— 过去 missing/duplicate/cycle 全被静默吞掉
|
|
394
|
+
for (const e of validateTaskGraph(tasks).errors) violations.push(`[图结构] ${e.detail}`);
|
|
395
|
+
|
|
396
|
+
// 批 2-5(L1-5′)断链即违规:质量类任务(review/verification/requirements/quality)判 failed 或
|
|
397
|
+
// needs_revision 后,**必须**同时留下"接续"——要么有后继 repair 任务,要么已升级为用户决策
|
|
398
|
+
// (pendingDecision)。过去两者都没有时,修复链就断在那里而 /team check 不报任何违规:
|
|
399
|
+
// 表现为评审判不过、却没人被派去修、也没人被告知要拍板。
|
|
400
|
+
// 仅在能看见 state 的调用点启用(否则无法判断 pendingDecision,会产生误报)。
|
|
401
|
+
if (state !== undefined) {
|
|
402
|
+
const QUALITY_KINDS = new Set(['requirements', 'verification', 'review', 'quality']);
|
|
403
|
+
const hasPending = !!(state && state.pendingDecision);
|
|
404
|
+
if (!hasPending) {
|
|
405
|
+
for (const t of tasks) {
|
|
406
|
+
const kind = String((t && t.kind) || '');
|
|
407
|
+
const st2 = String((t && t.status) || '');
|
|
408
|
+
const vd = String((t && t.verdict) || '');
|
|
409
|
+
const bad = st2 === 'failed' || vd === 'needs_revision' || vd === 'fail';
|
|
410
|
+
if (!QUALITY_KINDS.has(kind) || !bad) continue;
|
|
411
|
+
const id = String((t && t.id) || '');
|
|
412
|
+
// 「接续」的判定必须**忠于 SKILL 约定**:`repair-N` 是**依赖指向被审实现**、而**不依赖**
|
|
413
|
+
// 那个 failed review(failed 是终态,不该 gate 住修复)。所以不能用 `dependsOn(failedId)`
|
|
414
|
+
// 当唯一判据 —— e2e 场景 rv-1(failed) ← repair-1(依赖 be-1) ← rv-2 就是这么连的,只认依赖方向会误报。
|
|
415
|
+
// 三种形式任一成立即视为已接续:
|
|
416
|
+
// ① repair 任务显式依赖该失败任务(严格形式,保留支持)
|
|
417
|
+
// ② repair 任务的 round **大于**失败任务的 round(repair-N 响应第 N 轮评审 —— SKILL 约定)
|
|
418
|
+
// ③ 存在更高 round 的质量类后续任务(独立 review-N+1 / 重新验证)
|
|
419
|
+
// 轮次口径**唯一来源**是模块级的 `roundOf`(SPEC §1.2:不得各写一套 —— 这里原有第二份实现)
|
|
420
|
+
const failedRound = roundOf(t);
|
|
421
|
+
const successor = tasks.some((x) => {
|
|
422
|
+
const xid = String((x && x.id) || '');
|
|
423
|
+
if (!xid || xid === id) return false;
|
|
424
|
+
const isRepair = String((x && x.kind) || '') === 'repair' || /^repair[-_]/i.test(xid);
|
|
425
|
+
const deps = Array.isArray(x && x.dependsOn) ? x.dependsOn.map(String) : [];
|
|
426
|
+
if (isRepair && deps.includes(id)) return true;
|
|
427
|
+
if (isRepair && roundOf(x) > failedRound) return true;
|
|
428
|
+
if (QUALITY_KINDS.has(String((x && x.kind) || '')) && roundOf(x) > failedRound) return true;
|
|
429
|
+
return false;
|
|
430
|
+
});
|
|
431
|
+
if (!successor) {
|
|
432
|
+
violations.push(`[${id}] ${KIND_ZH[kind] || kind}任务已判「${vd || st2}」但**既无后继 repair 任务、也无 pendingDecision**(断链:修复链没接上、也没升级给用户拍板)`);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
// P3 收敛硬门禁(SPEC §1.3):返工循环的**读侧**违规,接在既有断链检查之后 —— 同点同口径,
|
|
438
|
+
// 也**同样只在能看见 state 时**启用(看不见 pendingDecision 就判不了"是否已升级用户",报了就误报)。
|
|
439
|
+
// 只报告不阻断(存量 run 不因新规消失);本函数返回值仍是 `string[]`(既有契约不变)。
|
|
440
|
+
if (state !== undefined) {
|
|
441
|
+
violations.push(...roundLimitViolations(tasks, state, LIMITS).map((v) => v.message));
|
|
442
|
+
}
|
|
443
|
+
// Phase freeze gate: once implementation has started, a task that is READY
|
|
444
|
+
// (all dependencies terminal) but still `pending` means the lead dispatched
|
|
445
|
+
// and never wrote the status back — the FLOAT panel would show 待开始 forever.
|
|
446
|
+
const frozenPhase = ['implement', 'review', 'test', 'deliver'].includes(phase || '');
|
|
447
|
+
for (const t of tasks) {
|
|
448
|
+
const st = t.status || 'pending';
|
|
449
|
+
// ownership gate (log-driven): verification → qa, review → reviewer,
|
|
450
|
+
// quality (leader's own kind for 安全自检/评审) → qa|reviewer, never implementers.
|
|
451
|
+
if ((t.kind === 'verification' || t.kind === 'quality') && t.owner && !['qa', 'reviewer'].includes(t.owner)) violations.push(`[${t.id}] 质量任务(${kindZh(t.kind)})归属 ${t.owner}(应为 qa/reviewer)`);
|
|
452
|
+
if (t.kind === 'review' && t.owner && t.owner !== 'reviewer') violations.push(`[${t.id}] 评审任务归属 ${t.owner}(应为 reviewer)`);
|
|
453
|
+
if (st === 'completed' || st === 'done') {
|
|
454
|
+
if ((t.kind === 'review' || t.kind === 'requirements') && t.verdict !== 'pass') violations.push(`[${t.id}] ${kindZh(t.kind)}任务为${statusZh(st)}但裁决为「${verdictZh(t.verdict)}」(须通过)`);
|
|
455
|
+
if ((t.kind === 'implementation' || t.kind === 'repair') && (!Array.isArray(t.verify) || t.verify.length === 0)) violations.push(`[${t.id}] 状态为${statusZh(st)}但缺少 verify 命令`);
|
|
456
|
+
if (Array.isArray(t.changedPaths) && Array.isArray(t.inScope) && t.inScope.length) {
|
|
457
|
+
const bad = t.changedPaths.filter((p) => !t.inScope.some((s) => { const r = s.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*\*/g, '.*').replace(/\*/g, '[^/]*'); try { return new RegExp('^' + r + '$').test(p) || new RegExp('^' + r).test(p); } catch { return false; } }));
|
|
458
|
+
if (bad.length) violations.push(`[${t.id}] 越界改动:${bad.join(', ')}(inScope: ${t.inScope.join(', ')})`);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
if (frozenPhase) {
|
|
462
|
+
const deps = Array.isArray(t.dependsOn) ? t.dependsOn : [];
|
|
463
|
+
const ready = deps.every((d) => { const dep = byId[d]; return !dep || ['completed', 'done', 'failed', 'cancelled'].includes(dep.status) });
|
|
464
|
+
if (st === 'pending' && ready) violations.push(`[${t.id}] 已进入${phaseZh(phase)}阶段但仍为待开始(依赖已就绪、状态未回写=状态冻结)`);
|
|
465
|
+
if (st === 'in_progress' && phase === 'deliver') violations.push(`[${t.id}] 交付阶段仍有任务在进行中`);
|
|
466
|
+
}
|
|
467
|
+
// dependency gate: only upstream completed unlocks; failed/cancelled never unlock
|
|
468
|
+
const depList = Array.isArray(t.dependsOn) ? t.dependsOn : [];
|
|
469
|
+
for (const d of depList) {
|
|
470
|
+
const dep = byId[d];
|
|
471
|
+
if (!dep) continue;
|
|
472
|
+
if (['failed', 'cancelled'].includes(dep.status) && ['in_progress', 'claimed', 'completed', 'done'].includes(st)) violations.push(`[${t.id}] 依赖 [${d}] 已${statusZh(dep.status)},但它仍为${statusZh(st)}(失败不解锁下游)`);
|
|
473
|
+
if (!['completed', 'done'].includes(dep.status) && ['in_progress', 'claimed', 'completed'].includes(st)) violations.push(`[${t.id}] 依赖 [${d}] 为${statusZh(dep.status)}(未完成),不应进入${statusZh(st)}`);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
return violations;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// ── E2 的**记账前提**:阶段事件缺失必须可机判(2026-09-13 由真实数据点名)────────────────
|
|
480
|
+
//
|
|
481
|
+
// 背景(两次真实 run 的实测):SKILL §7.33 ① 要求"每个阶段流转都必须写 `phase:<阶段名>`,尤其
|
|
482
|
+
// 不能漏 review/test",但实测里 **clarify 走完了却一条 phase 事件都没有**,于是 METRICS 的
|
|
483
|
+
// 「收尾预算」只能显示"分不开" —— 机制在、**采纳度不在**,而漏记这件事**没有任何地方会报**。
|
|
484
|
+
// 本仓的判据很直白:**写了没人执行、又没人发现,等于没有这条规则。**
|
|
485
|
+
//
|
|
486
|
+
// 判定刻意收得很窄(只报无歧义的两类,避免把门禁变成噪声):
|
|
487
|
+
// R1(硬):STATE.phase 已是 `review`/`test`/`deliver`,而日志里**既无** `phase:review`
|
|
488
|
+
// 也**无** `phase:test` ⇒ 冻结观测点缺失,E2 的收尾预算算不出来。
|
|
489
|
+
// R2(硬):日志里有角色事件(`role:*`)却**一条 `phase:*` 都没有** ⇒ 整条流水线零阶段记账。
|
|
490
|
+
//
|
|
491
|
+
// 解析口径**故意与聚合器同形**(`phase:<阶段名>`;兼容 `phase:started/completed — design …`
|
|
492
|
+
// 取详情里第一个 ASCII 词;不在 `PHASES` 词表内一律不计)。`phase-accounting.test.mjs` 里有一条
|
|
493
|
+
// **两套实现一致性**断言盯着它,防止哪天悄悄分叉。
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* 从 RUN.log 文本里取出**已记账的阶段集合**(按流水线词表过滤)。
|
|
497
|
+
* @param logText - RUN.log.md 全文。
|
|
498
|
+
* @returns `Set<string>`(可能为空 —— 那就是"零记账")。
|
|
499
|
+
*/
|
|
500
|
+
export function loggedPhases(logText) {
|
|
501
|
+
const out = new Set();
|
|
502
|
+
for (const line of String(logText || '').split('\n')) {
|
|
503
|
+
const ev = parseLogLine(line);
|
|
504
|
+
if (!ev) continue;
|
|
505
|
+
const idx = ev.type.indexOf(':');
|
|
506
|
+
if (idx < 0 || ev.type.slice(0, idx) !== 'phase') continue;
|
|
507
|
+
const head = ev.type.slice(idx + 1).split(':')[0];
|
|
508
|
+
let name = head;
|
|
509
|
+
if (!head || head === 'started' || head === 'completed') {
|
|
510
|
+
const dm = String(ev.detail || '').match(/([A-Za-z][A-Za-z0-9_-]*)/);
|
|
511
|
+
if (!dm) continue;
|
|
512
|
+
name = dm[1];
|
|
513
|
+
}
|
|
514
|
+
if (PHASES.includes(name)) out.add(name);
|
|
515
|
+
}
|
|
516
|
+
return out;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* 阶段记账违规(**纯函数**:给日志全文与当前阶段,判"该记的记了没有")。
|
|
521
|
+
* @param logText - RUN.log.md 全文。
|
|
522
|
+
* @param phase - STATE.phase。
|
|
523
|
+
* @returns `string[]`(空数组 = 没问题或还不到判的时候)。
|
|
524
|
+
*/
|
|
525
|
+
export function phaseAccountingViolations(logText, phase) {
|
|
526
|
+
const text = String(logText || '');
|
|
527
|
+
if (!text.trim()) return [];
|
|
528
|
+
const seen = loggedPhases(text);
|
|
529
|
+
const out = [];
|
|
530
|
+
const cur = String(phase || '');
|
|
531
|
+
const frozen = ['review', 'test', 'deliver'].includes(cur);
|
|
532
|
+
// 零记账时**只报 R2**(更根本的那条):R1 与 R2 那时是同一个根因,两条一起报只是噪声。
|
|
533
|
+
if (frozen && seen.size > 0 && !seen.has('review') && !seen.has('test')) {
|
|
534
|
+
out.push(`[阶段记账] 已进入「${phaseZh(cur)}」却**没有** \`phase:review\` / \`phase:test\` 事件 ⇒ 代码冻结的观测点缺失,METRICS 的「收尾预算」只能显示"分不开"(SKILL §7.33 ①)。请补记实际时刻,**不要**事后编一条。`);
|
|
535
|
+
}
|
|
536
|
+
const hasRole = /\n\s*-\s*\[[^\]]+\]\s+role:/.test(text) || /^\s*-\s*\[[^\]]+\]\s+role:/.test(text);
|
|
537
|
+
if (hasRole && seen.size === 0) {
|
|
538
|
+
out.push('[阶段记账] 日志里已有角色事件,却**一条 `phase:*` 都没有** ⇒ 整条流水线零阶段记账(阶段覆盖与收尾预算都会失真)。SKILL §7.33 ①:每次阶段流转都要追加一行。');
|
|
539
|
+
}
|
|
540
|
+
return out;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// ── 工件单源化:`AUTHORITY.md` 的权威表校验(设计稿 §十二 第 2 步)─────────────────────
|
|
544
|
+
//
|
|
545
|
+
// 依据(真实数据):某真实 run 的 14 条返工里 **9 条(64%)**命中「同一事实多份拷贝 / 多写者 /
|
|
546
|
+
// 口径漂移」,其中一条原文是「CONTRACT 单写者收口(**并发事故收敛** + 6 处已核实缺陷)」——
|
|
547
|
+
// **事故发生了才去收口**。本校验的目的是把它提到**事前**:先声明唯一权威与唯一写者,再写。
|
|
548
|
+
//
|
|
549
|
+
// ⚠️ 只在 `AUTHORITY.md` **存在时**校验(老 run 没有这个文件,一律报"缺声明"会制造满屏假阳性,
|
|
550
|
+
// 而本仓的教训是**大量假阳性会让门禁被整体忽略**)。因此"删掉文件以逃避校验"是**已知缺口**,
|
|
551
|
+
// 如实记在这里,而不是假装它不存在。
|
|
552
|
+
|
|
553
|
+
/** 核心工件:只要它在 run 里存在,就必须出现在权威表的某一行(否则它没被纳入单源化治理)。 */
|
|
554
|
+
export const AUTHORITY_CORE_FILES = ['SPEC.md', 'CONTRACT.md', 'PRD.md', 'RULES-CORE.md'];
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* 解析 `AUTHORITY.md` 的权威表。
|
|
558
|
+
* @returns `{ rows: [{ line, category, file, writer, allowed }], malformed: [{ line, text }] }`
|
|
559
|
+
*/
|
|
560
|
+
export function parseAuthorityRows(text) {
|
|
561
|
+
const rows = [];
|
|
562
|
+
const malformed = [];
|
|
563
|
+
const lines = String(text || '').split('\n');
|
|
564
|
+
for (let i = 0; i < lines.length; i++) {
|
|
565
|
+
const raw = lines[i];
|
|
566
|
+
if (!/^\s*\|.*\|\s*$/.test(raw)) continue;
|
|
567
|
+
const cells = raw.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map((c) => c.trim());
|
|
568
|
+
// 表头与分隔行(先判,否则 `|---|` 这种单列行会被当成"列数不足"误报)
|
|
569
|
+
if (/^事实类别$/.test(cells[0])) continue;
|
|
570
|
+
if (cells.every((c) => /^:?-{2,}:?$/.test(c) || c === '')) continue;
|
|
571
|
+
// 模板里留的空行(整行都空)不算数据行,也不算错
|
|
572
|
+
if (cells.every((c) => c === '')) continue;
|
|
573
|
+
if (cells.length !== 4 || cells.some((c) => c === '')) {
|
|
574
|
+
malformed.push({ line: i + 1, text: raw.trim() });
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
rows.push({ line: i + 1, category: cells[0], file: cells[1], writer: cells[2], allowed: cells[3] });
|
|
578
|
+
}
|
|
579
|
+
return { rows, malformed };
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/** 从「唯一权威文件」单元格里取出文件名(允许 `SPEC.md §术语表` 这种带小节的写法)。 */
|
|
583
|
+
export function authorityFileName(cell) {
|
|
584
|
+
const s = String(cell || '').trim();
|
|
585
|
+
const m = s.match(/^[`"']?([^\s`"'§||]+\.(?:md|json|ya?ml|txt))[`"']?/i);
|
|
586
|
+
return m ? m[1] : '';
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* 权威表违规(**纯函数**:给文本 + run 里实际存在的文件 + 合法角色,判"声明有没有问题")。
|
|
591
|
+
* @param args.authorityText - `AUTHORITY.md` 全文(空/缺 ⇒ 返回 `[]`,见上方说明)。
|
|
592
|
+
* @param args.presentFiles - run 目录里真实存在的文件名列表。
|
|
593
|
+
* @param args.knownRoles - 编制里合法的角色 id 列表。
|
|
594
|
+
* @returns `string[]`(每条以 `[单源化]` 开头)
|
|
595
|
+
*/
|
|
596
|
+
export function authorityViolations({ authorityText, presentFiles = [], knownRoles = [] } = {}) {
|
|
597
|
+
const text = String(authorityText || '');
|
|
598
|
+
if (!text.trim()) return [];
|
|
599
|
+
const out = [];
|
|
600
|
+
const { rows, malformed } = parseAuthorityRows(text);
|
|
601
|
+
for (const m of malformed) {
|
|
602
|
+
out.push(`[单源化] AUTHORITY.md 第 ${m.line} 行**列数/内容不完整**(必须 4 列且都非空):\`${m.text.slice(0, 80)}\``);
|
|
603
|
+
}
|
|
604
|
+
if (!rows.length) {
|
|
605
|
+
out.push('[单源化] AUTHORITY.md 里**一条有效声明都没有**(表是空的或只有表头)⇒ 单源化没有任何约定,等于没做');
|
|
606
|
+
}
|
|
607
|
+
const present = new Set((presentFiles || []).map((f) => String(f).trim()));
|
|
608
|
+
const known = new Set((knownRoles || []).map((r) => String(r).trim().toLowerCase()));
|
|
609
|
+
const byFile = new Map(); // file -> Set(writer)
|
|
610
|
+
const byCategory = new Map(); // category -> Set(file)
|
|
611
|
+
for (const r of rows) {
|
|
612
|
+
// ⓪ 模板没改(示例行留着)—— 不判出来,"把模板原样交上去"就能混过校验
|
|
613
|
+
if (/示例/.test(r.category)) {
|
|
614
|
+
out.push(`[单源化] AUTHORITY.md 第 ${r.line} 行**还是模板里的示例行**(未替换成本 run 的真实事实)`);
|
|
615
|
+
}
|
|
616
|
+
const fname = authorityFileName(r.file);
|
|
617
|
+
if (!fname) {
|
|
618
|
+
out.push(`[单源化] AUTHORITY.md 第 ${r.line} 行的「唯一权威文件」里**读不出文件名**:\`${r.file.slice(0, 60)}\``);
|
|
619
|
+
} else if (!present.has(fname)) {
|
|
620
|
+
out.push(`[单源化] AUTHORITY.md 第 ${r.line} 行声明权威文件 \`${fname}\`,但**本 run 里没有这个文件**(声明指向了不存在的权威)`);
|
|
621
|
+
} else {
|
|
622
|
+
if (!byFile.has(fname)) byFile.set(fname, new Set());
|
|
623
|
+
byFile.get(fname).add(r.writer);
|
|
624
|
+
}
|
|
625
|
+
if (known.size && !known.has(String(r.writer).toLowerCase())) {
|
|
626
|
+
out.push(`[单源化] AUTHORITY.md 第 ${r.line} 行的写者 \`${r.writer}\` **不是编制里的角色**(编制:${[...known].slice(0, 8).join('/')}…)`);
|
|
627
|
+
}
|
|
628
|
+
if (!byCategory.has(r.category)) byCategory.set(r.category, new Set());
|
|
629
|
+
if (fname) byCategory.get(r.category).add(fname);
|
|
630
|
+
}
|
|
631
|
+
// 同一权威文件两个写者 ⇒ 正是"并发写同一份文件"事故的形状
|
|
632
|
+
for (const [f, writers] of byFile) {
|
|
633
|
+
if (writers.size > 1) {
|
|
634
|
+
out.push(`[单源化] \`${f}\` 声明了 **${writers.size} 个写者**(${[...writers].join(' / ')})⇒ 单写者不成立,` +
|
|
635
|
+
'而"两个角色并发写同一份文件"正是本仓实测过的返工事故形态(T29「并发事故收敛」)');
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
// 同一事实类别指向两个不同权威 ⇒ 自相矛盾
|
|
639
|
+
for (const [cat, fs] of byCategory) {
|
|
640
|
+
if (fs.size > 1) {
|
|
641
|
+
out.push(`[单源化] 事实类别「${cat.slice(0, 40)}」指向了 **${fs.size} 个不同的权威文件**(${[...fs].join(' / ')})⇒ 权威必须唯一`);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
// 核心工件必须被纳入治理
|
|
645
|
+
const declared = new Set([...byFile.keys()]);
|
|
646
|
+
for (const core of AUTHORITY_CORE_FILES) {
|
|
647
|
+
if (present.has(core) && !declared.has(core)) {
|
|
648
|
+
out.push(`[单源化] 本 run 存在 \`${core}\`,但它**没有出现在权威表的任何一行**里 ⇒ 没被纳入单源化治理,迟早与别处分叉`);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
return out;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// ── 返工成因:**单源化类占比**(设计稿 §十二 的验收指标)──────────────────────────────
|
|
655
|
+
//
|
|
656
|
+
// 为什么单列这一格:真实数据里 14 条 repair 有 **9 条(64%)** 的标题命中「单源化 / 唯一权威 /
|
|
657
|
+
// 单写者 / 同源漂移 / 口径」这类词,而它们**散落在普通 repair 计数里**,看不出"返工的主因是什么"。
|
|
658
|
+
// §十二 的验收标准就是这一格:**下一个真实 run 从 64% 降到 ≤30%**。
|
|
659
|
+
//
|
|
660
|
+
// ⚠️ 口径如实说明:这是**标题关键词**判定,是**下界近似**(写得含蓄的返工不会被算进来)——
|
|
661
|
+
// 宁可少算不可多算,避免把这条指标做成"想降就能降"。要更准得靠人工在 RETRO 里归类。
|
|
662
|
+
|
|
663
|
+
/** 单源化类返工的关键词(与 SKILL 规则 34/35 的用语一致)。 */
|
|
664
|
+
export const SINGLE_SOURCE_WORDS = ['单源化', '唯一权威', '单写者', '同源', '漂移', '口径', '逐值一致', '唯一化'];
|
|
665
|
+
|
|
666
|
+
/** 这条返工是不是"单源化类"(按标题/摘要判定)。 */
|
|
667
|
+
export function isSingleSourceRework(task) {
|
|
668
|
+
const s = `${(task && task.title) || ''} ${(task && task.detail) || ''} ${(task && task.summary) || ''}`;
|
|
669
|
+
return SINGLE_SOURCE_WORDS.some((w) => s.includes(w));
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* 统计返工里"单源化类"的占比。
|
|
674
|
+
* @param tasks - 任务数组(只数 `kind === 'repair'`)。
|
|
675
|
+
* @returns `{ repairs, singleSource, share }`(`share` 为 0..1;无返工时为 `null` —— **不编造 0%**)
|
|
676
|
+
*/
|
|
677
|
+
export function singleSourceShare(tasks) {
|
|
678
|
+
const reps = (Array.isArray(tasks) ? tasks : []).filter((t) => t && t.kind === 'repair');
|
|
679
|
+
const ss = reps.filter(isSingleSourceRework).length;
|
|
680
|
+
return { repairs: reps.length, singleSource: ss, share: reps.length ? ss / reps.length : null };
|
|
681
|
+
}
|