@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,100 @@
|
|
|
1
|
+
// METRICS.md 的**纯渲染**(B 线 10b 的第一刀)。
|
|
2
|
+
//
|
|
3
|
+
// 为什么先切这一段:`aggregate()` 一个函数压了六件事(枚举 / 读取 / 解析 / 计算 / **渲染** / 写盘),
|
|
4
|
+
// 其中**渲染**是 C 线一直在动的地方(未闭环、返工性质分解、撤销率都往这里加节)—— 而它只需要
|
|
5
|
+
// 一个数据对象、零 IO ⇒ 切出去之后 METRICS 的格式可以**纯函数单测**,不用造 run 夹具。
|
|
6
|
+
//
|
|
7
|
+
// ⚠️ **提取的代价(必须由测试补偿)**:原来是闭包里的局部变量,写错名字会 `ReferenceError` 当场炸;
|
|
8
|
+
// 现在改成 `stats` 对象的字段,**写错名字静默变 `undefined`**、直接印进用户看的报告里。
|
|
9
|
+
// 所以配套的 `metrics-render.test.mjs` 有两道机器检查:
|
|
10
|
+
// ① 渲染结果里**不得出现 `undefined` / `NaN`**;
|
|
11
|
+
// ② 本模块解构出的每个字段名,调用点**必须**都传(源码级集合比对)。
|
|
12
|
+
// 这两条是这次提取的安全网,不是可选项。
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 渲染 `team/METRICS.md` 全文。
|
|
16
|
+
* @param stats - 见下(字段名即契约;缺一个就会被上面的测试拦住)。
|
|
17
|
+
* @returns markdown 全文(不含尾换行,与旧实现一致)。
|
|
18
|
+
*/
|
|
19
|
+
export function renderMetrics(stats) {
|
|
20
|
+
const {
|
|
21
|
+
iso, runCount, skipped, completed, runsWithLogs, reworkRate, reworkRuns,
|
|
22
|
+
sourceLines, reworkNatureLines, phaseLines, firstRunnableLines, closingBudgetLines, roleLines, closureLines,
|
|
23
|
+
errLines, askLines, decLines, scanLines, maxReviewRound, maxTestRound, revertLine, tokenLines,
|
|
24
|
+
} = stats;
|
|
25
|
+
return [
|
|
26
|
+
'# 团队指标(METRICS)',
|
|
27
|
+
'',
|
|
28
|
+
`> 由 \`/team learn\` 自动聚合,最后更新:${iso}`,
|
|
29
|
+
'',
|
|
30
|
+
'## 总览',
|
|
31
|
+
'',
|
|
32
|
+
`- 总 run 数:${runCount}`,
|
|
33
|
+
`- 非 run 条目(文件 / 无 STATE.json·TASK.md·RUN.log.md 的目录,未计入):${skipped} 个`,
|
|
34
|
+
`- 已完成:${completed}`,
|
|
35
|
+
`- 有日志的 run:${runsWithLogs}`,
|
|
36
|
+
`- 返工/失败率:${reworkRate}%(= ${reworkRuns}/${runsWithLogs},分母是「有日志的 run」而非全部 run)`,
|
|
37
|
+
`- 返工判定口径(多源,可审计):${sourceLines}`,
|
|
38
|
+
' · 旧实现只统计 RUN.log 的 role:result 与 error 事件,而 LOGGING 约定从不写 role:result ⇒ 恒 0;',
|
|
39
|
+
' 现改由 TASKS.json 的 repair 任务 / round>1 / verdict / rounds 与日志共同判定。',
|
|
40
|
+
reworkNatureLines,
|
|
41
|
+
'',
|
|
42
|
+
'## 阶段覆盖',
|
|
43
|
+
'',
|
|
44
|
+
phaseLines,
|
|
45
|
+
'',
|
|
46
|
+
// E 线 E1(SKILL §7.32):首产物必须**紧挨着阶段覆盖**渲染 —— 二者是同一个问题的两面
|
|
47
|
+
//("阶段都覆盖了"不等于"用户早就拿到能跑的东西")。文案由 `timing.js` 生成(纯函数可单测)。
|
|
48
|
+
'## 首产物(首个可运行产物耗时)',
|
|
49
|
+
'',
|
|
50
|
+
firstRunnableLines,
|
|
51
|
+
'',
|
|
52
|
+
// E 线 E2(SKILL §7.33):收尾预算。与首产物同属"时间去哪了"这一族,放在阶段覆盖之后,
|
|
53
|
+
// 让读者连着读:阶段都覆盖了 → 多久拿到能跑的东西 → 实现与收尾各花了多久。
|
|
54
|
+
'## 收尾预算(实现期 = 首产物→冻结 · 收尾 = 冻结→交付)',
|
|
55
|
+
'',
|
|
56
|
+
closingBudgetLines,
|
|
57
|
+
'',
|
|
58
|
+
'## 角色结果(pass=交付 / rework=返工件 / fail=失败)',
|
|
59
|
+
'',
|
|
60
|
+
roleLines,
|
|
61
|
+
'',
|
|
62
|
+
'## 未闭环(不计入返工率,但必须可见)',
|
|
63
|
+
'',
|
|
64
|
+
closureLines,
|
|
65
|
+
'',
|
|
66
|
+
'## 高频卡点(error / 返工,去重)',
|
|
67
|
+
'',
|
|
68
|
+
errLines,
|
|
69
|
+
'',
|
|
70
|
+
'## 用户高频提问(ask,去重)',
|
|
71
|
+
'',
|
|
72
|
+
askLines,
|
|
73
|
+
'',
|
|
74
|
+
'## 决策记录(decision,去重)',
|
|
75
|
+
'',
|
|
76
|
+
decLines,
|
|
77
|
+
'',
|
|
78
|
+
// E 线 E3(SKILL §7.34):单源化总扫。这条义务**没有代码能强制**(它要求 lead 在发现
|
|
79
|
+
// 第一个副本时当轮做全仓总扫),所以唯一的抓手是"让它可见"——没人登记本身就是一种可见状态。
|
|
80
|
+
'## 单源化总扫(见一个,扫全部)',
|
|
81
|
+
'',
|
|
82
|
+
scanLines,
|
|
83
|
+
'',
|
|
84
|
+
// P4(SPEC §1.5):评审效率 —— 轮次到顶与"撤销幻觉"必须**可见**,否则噪声治理无从度量。
|
|
85
|
+
// 无撤销登记时**整行如实说明**(`- (暂无撤销登记:…)`),**不得静默省略** ——
|
|
86
|
+
// 沉默会被读成"没有撤销",而事实可能只是"没人登记"。
|
|
87
|
+
'## 评审效率(轮次 / 撤销率)',
|
|
88
|
+
'',
|
|
89
|
+
`- 评审轮次:review ${maxReviewRound} 轮 · test ${maxTestRound} 轮`,
|
|
90
|
+
revertLine,
|
|
91
|
+
'',
|
|
92
|
+
// P5 线:token 成本与上下文峰值。放在**最后**是刻意的:它回答的是"这套流程总共花了多少资源",
|
|
93
|
+
// 属于"总量"视角,前面各节回答的是"质量与进度"。空态**必须如实写"没测到"**,
|
|
94
|
+
// 否则会被读成"消耗很低"(本包在撤销率/首产物上反复吃过这个亏)。
|
|
95
|
+
'## token 成本与上下文峰值',
|
|
96
|
+
'',
|
|
97
|
+
tokenLines,
|
|
98
|
+
'',
|
|
99
|
+
].join('\n');
|
|
100
|
+
}
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
// token 记账的 **IO 层**:从 DSH 会话遥测里读真实用量,按 run 的时间窗归属。
|
|
2
|
+
//
|
|
3
|
+
// 为什么放在单独文件:`lib/metrics/tokens.js` 是纯计算(可纯单测),这里全是"读盘 + 解压",
|
|
4
|
+
// 而**读盘是要失败的**(会话目录可能是别的机器、可能被清理、可能没有 zstd)——
|
|
5
|
+
// 失败必须能被如实报告("读不到" ≠ "消耗低"),不能让渲染层去猜。
|
|
6
|
+
//
|
|
7
|
+
// 格式(2026-09 实测,`~/.dsh/sessions/--Users-…--/<sid>/session.v3.jsonl.zstd`):
|
|
8
|
+
// 首行 `{"type":"session", "createdAt":<ms>, "delegationDepth":0|1, "cwd":…, "agentPreset":…}`
|
|
9
|
+
// 其后每行一个事件;`{"type":"assistant/message","data":{"usage":{inputTokens,cacheReadTokens,outputTokens,reasoningTokens,totalTokens}}}`
|
|
10
|
+
// 注意:**usage 挂在 `data` 下**,不是顶层 —— 读错这一层会让所有数静默变成 0(本模块首轮就踩过)。
|
|
11
|
+
//
|
|
12
|
+
// 两条性能纪律(都来自实测体量:单个大会话解压后 ~10MB,一个工作区有几十个会话):
|
|
13
|
+
// ① **头与用量分两趟读**:列会话只读首行(起 `zstd -dc`,拿到第一行即杀进程);
|
|
14
|
+
// 只有**落在某 run 时间窗内**的会话才做全量解压。
|
|
15
|
+
// ② 两趟都按 `(路径, mtime, size)` 缓存 —— `/team learn` 会被反复调用。
|
|
16
|
+
//
|
|
17
|
+
// 压缩:优先 `zstd -dc`(本机已有),不可用时退回 Node 内置 `zlib.zstdDecompressSync`
|
|
18
|
+
//(Node ≥ 23.8 / 22.15)。两者都没有 ⇒ 如实报 `unsupported`,不猜。
|
|
19
|
+
|
|
20
|
+
import { readdir, stat, readFile } from 'node:fs/promises';
|
|
21
|
+
import { existsSync } from 'node:fs';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
import { spawn } from 'node:child_process';
|
|
24
|
+
|
|
25
|
+
/** DSH 会话根目录(`<DSH_HOME>/sessions`)。 */
|
|
26
|
+
export function sessionsRoot(dshHome) {
|
|
27
|
+
const home = dshHome || process.env.DSH_HOME || join(process.env.HOME || '', '.dsh');
|
|
28
|
+
return join(home, 'sessions');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 工作区路径 → DSH 会话目录名(slug)。
|
|
33
|
+
*
|
|
34
|
+
* 实测编码规则(来自既有会话目录):
|
|
35
|
+
* `/home/dev/code/my-app` → `--home-dev-code-my-app--`
|
|
36
|
+
* `/home/dev/code/packages/api` → `--home-dev-code-packages-api--`
|
|
37
|
+
* `/home/dev/code/我的项目` → `/home-dev-code-~6211~7684~9879~76EE--`
|
|
38
|
+
* 即:分隔符与 `_` → `-`;非 ASCII 码点 → `~` + 大写十六进制;外层再包 `--` … `--`。
|
|
39
|
+
*
|
|
40
|
+
* ⚠️ 这是**逆向出来**的规则,不是从宿主源码读到的 ⇒ 找不到目录时调用方必须**列出候选目录并报告**,
|
|
41
|
+
* 不能默默返回空("读不到"与"没消耗"必须可区分)。
|
|
42
|
+
*/
|
|
43
|
+
export function workspaceSlug(cwd) {
|
|
44
|
+
const s = String(cwd ?? '');
|
|
45
|
+
// 前导分隔符**原样留着**(它自己就是 slug 开头那两个短横线里的一个),其余分隔符与 `_` → `-`,
|
|
46
|
+
// 最后补 `--` 收口。首轮实现把前导 `/` 也换成了 `-` 再补 `--` ⇒ 得到 `---Users-…`(多一个横杠,
|
|
47
|
+
// 于是**永远找不到会话目录**,而且失败是静默的 —— 正是本模块注释里警告的那类"读不到")。
|
|
48
|
+
const head = /^[/\\]/.test(s) ? s[0] : '';
|
|
49
|
+
let body = '';
|
|
50
|
+
for (const ch of s.slice(head ? 1 : 0)) {
|
|
51
|
+
const cp = ch.codePointAt(0);
|
|
52
|
+
if (cp > 127) body += `~${cp.toString(16).toUpperCase().padStart(4, '0')}`;
|
|
53
|
+
else if (ch === '/' || ch === '\\' || ch === '_') body += '-';
|
|
54
|
+
else body += ch;
|
|
55
|
+
}
|
|
56
|
+
return `${head}${body}--`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let _zstd = null;
|
|
60
|
+
/** `zstd -dc` 是否可用(只探测一次,结果缓存)。 */
|
|
61
|
+
export async function hasZstd() {
|
|
62
|
+
if (_zstd !== null) return _zstd;
|
|
63
|
+
_zstd = await new Promise((resolve) => {
|
|
64
|
+
try {
|
|
65
|
+
const p = spawn('zstd', ['--version'], { stdio: 'ignore' });
|
|
66
|
+
p.on('error', () => resolve(false));
|
|
67
|
+
p.on('close', (code) => resolve(code === 0));
|
|
68
|
+
} catch { resolve(false); }
|
|
69
|
+
});
|
|
70
|
+
return _zstd;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 只读首行(`type: session` 的头):拿到第一行即杀掉解压进程,不做全量解压。 */
|
|
74
|
+
async function readSessionHeader(file) {
|
|
75
|
+
if (!(await hasZstd())) {
|
|
76
|
+
try {
|
|
77
|
+
const { zstdDecompressSync } = await import('node:zlib');
|
|
78
|
+
const text = zstdDecompressSync(await readFile(file)).toString('utf8');
|
|
79
|
+
const nl = text.indexOf('\n');
|
|
80
|
+
return JSON.parse(nl < 0 ? text : text.slice(0, nl));
|
|
81
|
+
} catch { return null; }
|
|
82
|
+
}
|
|
83
|
+
return new Promise((resolve) => {
|
|
84
|
+
let done = false;
|
|
85
|
+
const finish = (v) => {
|
|
86
|
+
if (done) return;
|
|
87
|
+
done = true;
|
|
88
|
+
try { p.kill('SIGKILL'); } catch { /* 已退出 */ }
|
|
89
|
+
resolve(v);
|
|
90
|
+
};
|
|
91
|
+
const p = spawn('zstd', ['-dc', file], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
92
|
+
let buf = '';
|
|
93
|
+
p.stdout.on('data', (c) => {
|
|
94
|
+
buf += c.toString('utf8');
|
|
95
|
+
const nl = buf.indexOf('\n');
|
|
96
|
+
if (nl >= 0) { try { finish(JSON.parse(buf.slice(0, nl))); } catch { finish(null); } }
|
|
97
|
+
});
|
|
98
|
+
p.on('error', () => finish(null));
|
|
99
|
+
p.on('close', () => finish(null));
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** 全量解压 → 事件数组(坏行跳过:半写状态是常态)。 */
|
|
104
|
+
async function readSessionEvents(file) {
|
|
105
|
+
let text;
|
|
106
|
+
if (!(await hasZstd())) {
|
|
107
|
+
const { zstdDecompressSync } = await import('node:zlib');
|
|
108
|
+
text = zstdDecompressSync(await readFile(file)).toString('utf8');
|
|
109
|
+
} else {
|
|
110
|
+
text = await new Promise((resolve, reject) => {
|
|
111
|
+
const p = spawn('zstd', ['-dc', file], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
112
|
+
const chunks = [];
|
|
113
|
+
p.stdout.on('data', (c) => chunks.push(c));
|
|
114
|
+
p.on('error', reject);
|
|
115
|
+
p.on('close', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
// ⚠️ 不要用 `readline` 去"流式"切行:文本本来就已经整份在内存里,而
|
|
119
|
+
// `createInterface({ input: Readable.from([text]) })` 把整份文本当成**一个 chunk**,
|
|
120
|
+
// 遍历时会抛 `readline was closed` —— 而外层 `catch { return null }` 会把它变成
|
|
121
|
+
// 「读不到」,也就是**所有会话的用量静默变成 0**(本模块实测踩过这个坑:
|
|
122
|
+
// 表现是 46 个会话角色认不出 + 大量数据缺失,而没有任何报错)。
|
|
123
|
+
const out = [];
|
|
124
|
+
for (const line of text.split('\n')) {
|
|
125
|
+
if (!line) continue;
|
|
126
|
+
try { out.push(JSON.parse(line)); } catch { /* 坏行跳过 */ }
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** 取一条消息的文本。
|
|
132
|
+
*
|
|
133
|
+
* ⚠️ **两种形状都存在**(实测,同一份日志里):
|
|
134
|
+
* · `system/message` / `assistant/message`:`data.message.content[]`
|
|
135
|
+
* · `user/message`:**`data.content[]`**(没有 `message` 包装层)
|
|
136
|
+
* 首轮只认前者 ⇒ 用户提示词一条都读不到,于是"角色标签"与"runId 标记"双双失效
|
|
137
|
+
* (表现正是 28 个会话角色认不出、45 个会话无归属)。所以两种都收。
|
|
138
|
+
*/
|
|
139
|
+
function messageText(data) {
|
|
140
|
+
if (!data || typeof data !== 'object') return '';
|
|
141
|
+
const content = (data.message && data.message.content) || data.content || [];
|
|
142
|
+
let s = '';
|
|
143
|
+
for (const x of content) if (x && x.type === 'text') s += String(x.text || '');
|
|
144
|
+
return s;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** 单会话的用量汇总(与会话文件一一对应)。 */
|
|
148
|
+
function summarizeEvents(events) {
|
|
149
|
+
let inTok = 0; let cacheTok = 0; let outTok = 0; let reasonTok = 0; let steps = 0;
|
|
150
|
+
let peak = 0; let first = 0; let sysChars = 0; let userChars = 0;
|
|
151
|
+
// 前几条 user 消息的文本:**角色标签**(`【测试工程师】…`)与 **runId 标记**都在里面。
|
|
152
|
+
// 为什么要采它:会话头**没有** role,也没有 runId —— 归属只能从提示词里读;
|
|
153
|
+
// 而 `STATE.members` 只记部分成员(实测一个 run 的 14 个会话里只有 4 个在 members 里)。
|
|
154
|
+
// 取**前 4 条**而不是第 1 条:真实子会话的第 1 条用户消息可能是"运行环境说明",
|
|
155
|
+
// 角色标签在后面那条任务提示词里(实测就是这么排的)。
|
|
156
|
+
const promptSample = [];
|
|
157
|
+
for (const o of events) {
|
|
158
|
+
if (!o || typeof o !== 'object') continue;
|
|
159
|
+
if (o.type === 'system/message') {
|
|
160
|
+
sysChars += messageText(o.data).length;
|
|
161
|
+
} else if (o.type === 'user/message') {
|
|
162
|
+
const txt = messageText(o.data);
|
|
163
|
+
userChars += txt.length;
|
|
164
|
+
if (promptSample.length < 4 && txt) promptSample.push(txt.slice(0, 4000));
|
|
165
|
+
} else if (o.type === 'assistant/message' && o.data && o.data.usage) {
|
|
166
|
+
const u = o.data.usage;
|
|
167
|
+
const prompt = (Number(u.inputTokens) || 0) + (Number(u.cacheReadTokens) || 0);
|
|
168
|
+
steps += 1;
|
|
169
|
+
inTok += Number(u.inputTokens) || 0;
|
|
170
|
+
cacheTok += Number(u.cacheReadTokens) || 0;
|
|
171
|
+
outTok += Number(u.outputTokens) || 0;
|
|
172
|
+
reasonTok += Number(u.reasoningTokens) || 0;
|
|
173
|
+
if (prompt > peak) peak = prompt;
|
|
174
|
+
if (!first) first = prompt; // 「首个带 usage 的 assistant 消息」= 每会话固定开销
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return { in: inTok, cache: cacheTok, out: outTok, reasoning: reasonTok, steps, peak, first, sysChars, userChars, promptSample };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const HEADER_CACHE = new Map(); // file -> {mtimeMs, size, entry}
|
|
181
|
+
const USAGE_CACHE = new Map(); // file -> {mtimeMs, size, summary}
|
|
182
|
+
const DIR_CACHE = new Map(); // cwd -> {dir, reason}
|
|
183
|
+
let _lastListError = '';
|
|
184
|
+
|
|
185
|
+
/** 会话文件名(两种都在用:新会话 `session.v3.jsonl.zstd`,更早的 `session.jsonl.zstd`)。 */
|
|
186
|
+
const SESSION_FILES = ['session.v3.jsonl.zstd', 'session.jsonl.zstd'];
|
|
187
|
+
|
|
188
|
+
/** 找到目录里第一个会话文件(两种命名都认)。 */
|
|
189
|
+
function firstSessionFile(sessionDir) {
|
|
190
|
+
for (const f of SESSION_FILES) {
|
|
191
|
+
const p = join(sessionDir, f);
|
|
192
|
+
if (existsSync(p)) return p;
|
|
193
|
+
}
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* 解析一个工作区的会话目录。
|
|
199
|
+
*
|
|
200
|
+
* **不再靠猜 path→slug 的编码**:首轮按逆向出的规则拼 slug,两次都拼错(前导分隔符与
|
|
201
|
+
* 非 ASCII 的处理都与我猜的不同),而拼错的后果是**静默找不到目录** —— 正是本模块注释里
|
|
202
|
+
* 反复警告的那类失败。改成**内容验证**:候选目录里读一个会话头,比对头里的 `cwd`
|
|
203
|
+
* (会话头自带 `cwd`,这是**权威**,不是推出来的)。slug 只作为**快路径提示**,命中不了就扫。
|
|
204
|
+
*
|
|
205
|
+
* 代价:冷启动最多扫一遍 sessions 根(本机 9 个目录 × 1 次头部读取 ≈ 几十毫秒),结果按 cwd 缓存。
|
|
206
|
+
*
|
|
207
|
+
* @returns `{ok, dir, reason, candidates}`
|
|
208
|
+
*/
|
|
209
|
+
export async function resolveWorkspaceDir(cwd, opts = {}) {
|
|
210
|
+
const root = sessionsRoot(opts.dshHome);
|
|
211
|
+
const key = String(cwd ?? '');
|
|
212
|
+
const cached = DIR_CACHE.get(key);
|
|
213
|
+
if (cached && (cached.ok ? existsSync(cached.dir) : true)) return cached;
|
|
214
|
+
|
|
215
|
+
let candidates = [];
|
|
216
|
+
try { candidates = (await readdir(root)).filter((d) => d.startsWith('--')); } catch {
|
|
217
|
+
const r = { ok: false, dir: '', reason: 'sessions-root-unreadable', candidates: [] };
|
|
218
|
+
_lastListError = `会话根目录读不了:${root}`;
|
|
219
|
+
return r;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// 快路径:slug 猜中且头里的 cwd 一致才认(**必须验证**,否则会把别人的 run 算进来)。
|
|
223
|
+
const guess = join(root, workspaceSlug(cwd));
|
|
224
|
+
const order = [guess, ...candidates.map((d) => join(root, d)).filter((d) => d !== guess)];
|
|
225
|
+
for (const dir of order) {
|
|
226
|
+
if (!existsSync(dir)) continue;
|
|
227
|
+
let names = [];
|
|
228
|
+
try { names = await readdir(dir); } catch { continue; }
|
|
229
|
+
for (const name of names) {
|
|
230
|
+
const file = firstSessionFile(join(dir, name));
|
|
231
|
+
if (!file) continue;
|
|
232
|
+
const header = await readSessionHeader(file);
|
|
233
|
+
if (!header || header.type !== 'session') continue;
|
|
234
|
+
if (String(header.cwd || '') === String(cwd)) {
|
|
235
|
+
const r = { ok: true, dir, reason: '', candidates };
|
|
236
|
+
DIR_CACHE.set(key, r);
|
|
237
|
+
return r;
|
|
238
|
+
}
|
|
239
|
+
break; // 该目录的第一个会话头已经能判定归属,不必再读同目录其它会话
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const r = { ok: false, dir: '', reason: 'no-session-for-workspace', candidates };
|
|
243
|
+
DIR_CACHE.set(key, r);
|
|
244
|
+
_lastListError = `sessions 根下没有属于本工作区(${cwd})的会话目录;已扫 ${candidates.length} 个候选目录`;
|
|
245
|
+
return r;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* 列出一个工作区下的会话(**只读头**,不解压正文)。
|
|
250
|
+
*
|
|
251
|
+
* @param cwd - 工作区绝对路径。
|
|
252
|
+
* @param opts - `{dshHome}`。
|
|
253
|
+
* @returns `{ok, slug, dir, sessions:[{id, file, createdAt, depth, preset, mtimeMs, size}], candidates, reason}`
|
|
254
|
+
* `ok:false` 时 `candidates` 是 sessions 根下**实际存在的 slug**(供报告指出"找不到哪个目录")。
|
|
255
|
+
*/
|
|
256
|
+
export async function listWorkspaceSessions(cwd, opts = {}) {
|
|
257
|
+
const slug = workspaceSlug(cwd);
|
|
258
|
+
const resolved = await resolveWorkspaceDir(cwd, opts);
|
|
259
|
+
if (!resolved.ok) {
|
|
260
|
+
return { ok: false, slug, dir: resolved.dir, sessions: [], candidates: resolved.candidates, reason: resolved.reason };
|
|
261
|
+
}
|
|
262
|
+
const dir = resolved.dir;
|
|
263
|
+
let names = [];
|
|
264
|
+
try { names = await readdir(dir); } catch {
|
|
265
|
+
_lastListError = `会话目录读不了:${dir}`;
|
|
266
|
+
return { ok: false, slug, dir, sessions: [], candidates: resolved.candidates, reason: 'unreadable' };
|
|
267
|
+
}
|
|
268
|
+
const sessions = [];
|
|
269
|
+
for (const name of names) {
|
|
270
|
+
const file = firstSessionFile(join(dir, name));
|
|
271
|
+
if (!file) continue;
|
|
272
|
+
let st;
|
|
273
|
+
try { st = await stat(file); } catch { continue; }
|
|
274
|
+
const cached = HEADER_CACHE.get(file);
|
|
275
|
+
if (cached && cached.mtimeMs === st.mtimeMs && cached.size === st.size) {
|
|
276
|
+
sessions.push(cached.entry);
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
const header = await readSessionHeader(file);
|
|
280
|
+
if (!header || header.type !== 'session') continue;
|
|
281
|
+
const entry = {
|
|
282
|
+
id: String(header.id || name),
|
|
283
|
+
file,
|
|
284
|
+
createdAt: Number(header.createdAt) || 0,
|
|
285
|
+
depth: Number(header.delegationDepth) || 0,
|
|
286
|
+
preset: String(header.agentPreset || ''),
|
|
287
|
+
cwd: String(header.cwd || ''),
|
|
288
|
+
mtimeMs: st.mtimeMs,
|
|
289
|
+
size: st.size,
|
|
290
|
+
};
|
|
291
|
+
HEADER_CACHE.set(file, { mtimeMs: st.mtimeMs, size: st.size, entry });
|
|
292
|
+
sessions.push(entry);
|
|
293
|
+
}
|
|
294
|
+
sessions.sort((a, b) => a.createdAt - b.createdAt);
|
|
295
|
+
_lastListError = '';
|
|
296
|
+
return { ok: true, slug, dir, sessions, candidates: [], reason: '' };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** 上一次 `listWorkspaceSessions` 的失败原因(供报告引用;成功时为空串)。 */
|
|
300
|
+
export function lastListError() { return _lastListError; }
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* 读一个会话的**用量汇总**(会全量解压;结果按 mtime/size 缓存)。
|
|
304
|
+
* @returns 用量对象,或 `null`(解压/读取失败 —— 调用方按"读不到"处理,**不得**当成 0)。
|
|
305
|
+
*/
|
|
306
|
+
export async function readSessionUsage(file) {
|
|
307
|
+
try {
|
|
308
|
+
const st = await stat(file);
|
|
309
|
+
const cached = USAGE_CACHE.get(file);
|
|
310
|
+
if (cached && cached.mtimeMs === st.mtimeMs && cached.size === st.size) return cached.summary;
|
|
311
|
+
const events = await readSessionEvents(file);
|
|
312
|
+
const summary = summarizeEvents(events);
|
|
313
|
+
USAGE_CACHE.set(file, { mtimeMs: st.mtimeMs, size: st.size, summary });
|
|
314
|
+
return summary;
|
|
315
|
+
} catch { return null; }
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** 清缓存(测试用;也便于命令侧强制重读)。 */
|
|
319
|
+
export function clearSessionCache() { HEADER_CACHE.clear(); USAGE_CACHE.clear(); DIR_CACHE.clear(); _lastListError = ''; }
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// 阶段计时(E 线 **E1/E2 共用的唯一一份时钟算术**)。
|
|
2
|
+
//
|
|
3
|
+
// 为什么单独成模块:E1(首个可运行产物耗时)与 E2(阶段时长分解 / 收尾定额)都要把
|
|
4
|
+
// `RUN.log.md` 行首的 `[HH:MM:SS]` 相减 —— 而**真实 run 是跨午夜的**(实测样本
|
|
5
|
+
// 22:21:03 起、次日 08:15:30 收尾,全程 9h54m)。裸减会得到 **负数**:`00:32:30 - 22:21:03`
|
|
6
|
+
// = -78273 秒。跨午夜回绕是这类算术最容易写错、也最容易被复制到第二处的地方,而本仓的
|
|
7
|
+
// **头号返工源正是「一个事实多份拷贝」**(一次真实 run 里出现 6 例,占返工 14/29)。
|
|
8
|
+
// 所以:**只此一份**,另一处一律 import;两份实现在这里等于故意埋返工。
|
|
9
|
+
//
|
|
10
|
+
// 本模块**零 IO、零依赖**、全部纯函数 ⇒ 可以纯单测(不需要造 run 夹具)。
|
|
11
|
+
|
|
12
|
+
/** 一天的秒数(跨午夜回绕用)。 */
|
|
13
|
+
const SECONDS_PER_DAY = 86400;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `HH:MM:SS` → 当日秒数(0..86399)。不可解析(含 `[now]` 这类历史写法、空值、越界值)返回 `null`。
|
|
17
|
+
* @param hhmmss - 形如 `22:21:03` 的字符串。
|
|
18
|
+
*/
|
|
19
|
+
export function clockSeconds(hhmmss) {
|
|
20
|
+
const m = String(hhmmss ?? '').match(/^(\d{1,2}):(\d{2}):(\d{2})$/);
|
|
21
|
+
if (!m) return null;
|
|
22
|
+
const h = Number(m[1]);
|
|
23
|
+
const mi = Number(m[2]);
|
|
24
|
+
const s = Number(m[3]);
|
|
25
|
+
if (h > 23 || mi > 59 || s > 59) return null;
|
|
26
|
+
return h * 3600 + mi * 60 + s;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 从 `from` 到 `to` 的分钟数(四舍五入到整数分钟)。
|
|
31
|
+
* **负值按跨午夜处理(+24h)** —— 真实 run 跨午夜是常态,不处理会把 2h11m 算成 −22h。
|
|
32
|
+
* 任一时刻不可解析(如历史 run 的 `[now]`)返回 `null` —— 调用方必须把 `null` 当
|
|
33
|
+
* 「算不出」而不是 0(把算不出当 0 会系统性美化指标)。
|
|
34
|
+
*/
|
|
35
|
+
export function clockDeltaMinutes(from, to) {
|
|
36
|
+
const a = clockSeconds(from);
|
|
37
|
+
const b = clockSeconds(to);
|
|
38
|
+
if (a === null || b === null) return null;
|
|
39
|
+
let d = b - a;
|
|
40
|
+
if (d < 0) d += SECONDS_PER_DAY;
|
|
41
|
+
return Math.round(d / 60);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 中位数:偶数个时取**偏小**的那个(整数分钟,不做平均 —— 平均出来的 23.5 分钟既不是
|
|
46
|
+
* 任何一次真实观测,又会被读成精确值)。
|
|
47
|
+
* @param nums - 数字数组(调用方保证非空)。
|
|
48
|
+
*/
|
|
49
|
+
export function medianLower(nums) {
|
|
50
|
+
const s = [...nums].sort((a, b) => a - b);
|
|
51
|
+
return s[Math.floor((s.length - 1) / 2)];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* E1「首个可运行产物」小节的正文(纯函数 ⇒ 版面与"两种零"的区分可单测)。
|
|
56
|
+
*
|
|
57
|
+
* ⚠️ **必须区分的两种零**(本仓在撤销率上吃过这个亏):`0 个 run 登记` 与
|
|
58
|
+
* `登记了但算不出` 是两件事,都不能被读成「首产物很快」。所以三种形态各有各的文案。
|
|
59
|
+
*
|
|
60
|
+
* @param args.samples - `[{ run, minutes }]`,只含**算得出**的那些 run。
|
|
61
|
+
* @param args.runsWithLogs - 有日志的 run 总数(分母)。
|
|
62
|
+
* @param args.unparsable - 写了 `first-runnable` 但**算不出**耗时(缺 `run:started` / 时间戳非 `HH:MM:SS`)的 run 数。
|
|
63
|
+
* @param args.thresholdMinutes - 阈值(默认 10 分钟)。
|
|
64
|
+
* @returns 小节正文(不含小节标题)。
|
|
65
|
+
*/
|
|
66
|
+
export function summarizeFirstRunnable({ samples, runsWithLogs, unparsable = 0, thresholdMinutes = 10 }) {
|
|
67
|
+
const bad = unparsable > 0
|
|
68
|
+
? [` · ⚠️ ${unparsable} 个 run 写了 \`first-runnable\` 但**算不出耗时**(缺 \`run:started\`,或时间戳不是 \`HH:MM:SS\`)⇒ 按**未登记**处理,不计入上面的分位数`]
|
|
69
|
+
: [];
|
|
70
|
+
const missing = runsWithLogs - samples.length - unparsable;
|
|
71
|
+
const missNote = (n) => ` · 「未登记」${n} 个 run = 该 run 没有可运行产物 **或** lead 没写 \`first-runnable\` 事件 —— 日志**无法区分**这两种情况`;
|
|
72
|
+
|
|
73
|
+
if (!samples.length) {
|
|
74
|
+
return [
|
|
75
|
+
`- (暂无 first-runnable 登记:有日志的 run 共 ${runsWithLogs} 个,但没有一个算得出「首个可运行产物耗时」—— **这不等于"首产物很快"**)`,
|
|
76
|
+
...bad,
|
|
77
|
+
...(missing > 0 ? [missNote(missing)] : []),
|
|
78
|
+
].join('\n');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const mins = samples.map((s) => s.minutes);
|
|
82
|
+
const over = samples.filter((s) => s.minutes > thresholdMinutes).sort((a, b) => b.minutes - a.minutes);
|
|
83
|
+
return [
|
|
84
|
+
`- 首个可运行产物耗时:有登记 ${samples.length} / 有日志 ${runsWithLogs} 个 run · 中位 ${medianLower(mins)} 分钟 · 最快 ${Math.min(...mins)} 分钟 · 最慢 ${Math.max(...mins)} 分钟(阈值 ${thresholdMinutes} 分钟)`,
|
|
85
|
+
...bad,
|
|
86
|
+
...(over.length
|
|
87
|
+
? [` · 超阈值(> ${thresholdMinutes} 分钟):${over.map((s) => `${s.run}(${s.minutes} 分钟)`).join('、')}`]
|
|
88
|
+
: [` · 全部 ≤ ${thresholdMinutes} 分钟`]),
|
|
89
|
+
...(missing > 0 ? [missNote(missing)] : []),
|
|
90
|
+
].join('\n');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── E 线 E2:收尾预算(冻结后追加的阶段 ≤ 实现期 × 50%)─────────────────────────
|
|
94
|
+
//
|
|
95
|
+
// 实测依据(同一个真实 run,见 `docs/专家团-开发计划.md`):实现期 3h23m,**代码冻结之后的
|
|
96
|
+
// 收尾 3h35m** —— 收尾比实现还长(1.06 倍),而它的成因不是"活多",是「P3 不阻塞交付」这条
|
|
97
|
+
// 规则当时**只写在 SKILL 里、没有定额**,于是没人知道该在第几小时停手。
|
|
98
|
+
//
|
|
99
|
+
// ⚠️ 更糟的一点:那个 run 的 `RUN.log.md` 里**一条 `phase:review` / `phase:test` 事件都没有**
|
|
100
|
+
// ⇒ 光靠日志,聚合器连"收尾有多长"都算不出来。所以本节的**头号产出不是那个比率,而是
|
|
101
|
+
// 「缺记账」这件事必须可见** —— 隐形比超标更难修。
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 从**按日志顺序**的阶段事件里算出「开工 / 实现 / 收尾」三段时长。
|
|
105
|
+
*
|
|
106
|
+
* 口径(**近似,必须显式说明**):把**首个 `review` 或 `test` 阶段事件**当作"代码冻结"的
|
|
107
|
+
* 观测点 —— 冻结本身没有独立事件,而 review/test 按流水线必然发生在 freeze 之后。
|
|
108
|
+
* 缺 review/test 时**不猜**:`implementMinutes` / `closingMinutes` 一律返回 `null`。
|
|
109
|
+
*
|
|
110
|
+
* @param args.started - `run:started` 的时间戳(可 `null`)。
|
|
111
|
+
* @param args.phases - `[{ phase, time }]`,按日志出现顺序;同一阶段可重复出现。
|
|
112
|
+
* @returns `{ preCodeMinutes, implementMinutes, closingMinutes, firstImplement, firstClosing, deliver }`
|
|
113
|
+
* —— 任一环节算不出就是 `null`(**不是 0**:把算不出当 0 会让缺失看起来像"很快")。
|
|
114
|
+
*/
|
|
115
|
+
export function runTimeline({ started, phases, firstRunnable = null }) {
|
|
116
|
+
const first = {};
|
|
117
|
+
for (const p of (Array.isArray(phases) ? phases : [])) {
|
|
118
|
+
// 只认**时间戳可解析**的那一条:`[now]` 这类历史写法不能参与算术,否则整条链变 null。
|
|
119
|
+
if (first[p.phase] === undefined && clockSeconds(p.time) !== null) first[p.phase] = p.time;
|
|
120
|
+
}
|
|
121
|
+
// 实现期**起点**:`first-runnable` **优先**,回退 `phase:implement`。
|
|
122
|
+
//
|
|
123
|
+
// 2026-09-13 真实数据修正:原先只认 `phase:implement`,而 SKILL §7.33 ⓿ 与 METRICS 标题写的口径是
|
|
124
|
+
// 「实现期 = **首产物** → 冻结」。实测那个跑完的 run **有 `phase:review`/`phase:test` 却从没写
|
|
125
|
+
// `phase:implement`** ⇒ 口径与代码不一致,整段收尾预算算不出来,还被误报成「一条阶段事件都没写」。
|
|
126
|
+
// 现在以首产物为准;老 run 没有 `first-runnable` 时仍回落 `phase:implement`(向后兼容,且**来源可见**)。
|
|
127
|
+
const implement = firstRunnable ?? first.implement ?? null;
|
|
128
|
+
const implementSource = firstRunnable ? 'first-runnable' : (first.implement ? 'phase:implement' : null);
|
|
129
|
+
// 冻结观测点:review 优先,其次 test(两者都没有 ⇒ null,绝不退化成 deliver)。
|
|
130
|
+
const closingStart = first.review ?? first.test ?? null;
|
|
131
|
+
const deliver = first.deliver ?? null;
|
|
132
|
+
return {
|
|
133
|
+
firstImplement: implement,
|
|
134
|
+
implementSource,
|
|
135
|
+
firstClosing: closingStart,
|
|
136
|
+
deliver,
|
|
137
|
+
preCodeMinutes: clockDeltaMinutes(started, implement),
|
|
138
|
+
implementMinutes: clockDeltaMinutes(implement, closingStart),
|
|
139
|
+
closingMinutes: clockDeltaMinutes(closingStart, deliver),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* E2「收尾预算」小节的正文(纯函数 ⇒ 版面与三种缺失形态可单测)。
|
|
145
|
+
*
|
|
146
|
+
* @param args.rows - `[{ run, implementMinutes, closingMinutes }]`,只含**两段都算得出**的 run。
|
|
147
|
+
* @param args.missingClosing - 有 `implement` 但**没有** `review`/`test` 阶段事件的 run 数。
|
|
148
|
+
* @param args.noPhases - 有日志但**连 `implement` 都没有**的 run 数(阶段记账整体缺失)。
|
|
149
|
+
* @param args.runsWithLogs - 有日志的 run 总数(分母)。
|
|
150
|
+
* @param args.ratio - 预算比例(默认 0.5,即收尾 ≤ 实现期的一半)。
|
|
151
|
+
* @returns 小节正文(不含小节标题)。
|
|
152
|
+
*/
|
|
153
|
+
export function summarizeClosingBudget({ rows, missingClosing = 0, missingStart = 0, noPhases = 0, runsWithLogs, ratio = 0.5 }) {
|
|
154
|
+
const pct = Math.round(ratio * 100);
|
|
155
|
+
const notes = [];
|
|
156
|
+
if (missingClosing > 0) {
|
|
157
|
+
notes.push(` · ⚠️ ${missingClosing} 个 run **有起点(首产物 / \`phase:implement\`)却没有 \`review\`/\`test\` 阶段事件** ⇒ 实现期与收尾期**分不开**(本仓实测:某真实 run 的 3h35m 收尾就是这样在指标上完全隐形的。聚合器**不猜**,宁可显示"分不开")`);
|
|
158
|
+
}
|
|
159
|
+
if (missingStart > 0) {
|
|
160
|
+
notes.push(` · ⚠️ ${missingStart} 个 run **既无 \`first-runnable\` 也无 \`phase:implement\`** ⇒ **实现期起点缺失**(这跟"没有收尾"是两回事;SKILL §7.33 ⓿ 要求开工时就登记首产物)`);
|
|
161
|
+
}
|
|
162
|
+
if (noPhases > 0) {
|
|
163
|
+
notes.push(` · 「未登记」${noPhases} 个 run = 一条阶段事件都没写 —— **不等于"收尾很短"**`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (!rows.length) {
|
|
167
|
+
return [
|
|
168
|
+
`- (暂无收尾预算可算:有日志的 run 共 ${runsWithLogs} 个,但没有一个同时算得出「实现期」与「收尾期」—— 缺的是 \`phase:review\`/\`phase:test\` 记账,不是"没有收尾")`,
|
|
169
|
+
...notes,
|
|
170
|
+
].join('\n');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const withRatio = rows.map((r) => ({ ...r, p: r.implementMinutes > 0 ? r.closingMinutes / r.implementMinutes : null }));
|
|
174
|
+
const over = withRatio
|
|
175
|
+
.filter((r) => r.implementMinutes > 0 && r.closingMinutes > r.implementMinutes * ratio)
|
|
176
|
+
.sort((a, b) => b.p - a.p);
|
|
177
|
+
return [
|
|
178
|
+
// 口径写在这里、也写在 render 的小节标题里:**实现期 = 首产物 → 首个 review/test**,
|
|
179
|
+
// **收尾 = 首个 review/test → 交付**。澄清/调研/设计那一段(`preCodeMinutes`)**不进实现期** ——
|
|
180
|
+
// 2026-09-13 真实 run 实测:把 pre-code 算进去会把 51% 读成 48%,**跨过 50% 红线、结论翻转**。
|
|
181
|
+
`- 收尾预算(实现期 = 首产物→冻结,收尾 = 冻结→交付;收尾 ≤ 实现期 × ${pct}%):可算 ${rows.length} / 有日志 ${runsWithLogs} 个 run · 超预算 ${over.length} 个`,
|
|
182
|
+
...notes,
|
|
183
|
+
...(over.length
|
|
184
|
+
? [` · 超预算:${over.map((r) => `${r.run}(实现 ${r.implementMinutes} 分钟 / 收尾 ${r.closingMinutes} 分钟 = ${Math.round(r.p * 100)}%)`).join('、')}`]
|
|
185
|
+
: [` · 全部 ≤ ${pct}%(或实现期为 0,无法成比例,不计入超预算)`]),
|
|
186
|
+
' · 「冻结」的观测点是**首个 `review`/`test` 阶段事件**(冻结本身没有独立事件,这是近似口径)。',
|
|
187
|
+
].join('\n');
|
|
188
|
+
}
|