pi-skill-evolve 0.1.8 → 0.1.10
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/extensions/skill-evolve/fixtures/selftest/good-description/after.md +9 -0
- package/extensions/skill-evolve/fixtures/selftest/good-description/change.diff +13 -0
- package/extensions/skill-evolve/fixtures/selftest/good-description/fixture.json +6 -0
- package/extensions/skill-evolve/lib/replay-fixture.ts +5 -2
- package/extensions/skill-evolve/lib/review-post.ts +92 -21
- package/extensions/skill-evolve/lib/review-prompt.ts +5 -1
- package/extensions/skill-evolve/lib/review-record.ts +14 -1
- package/extensions/skill-evolve/lib/review-supervisor.ts +13 -1
- package/extensions/skill-evolve/lib/runs.ts +4 -0
- package/extensions/skill-evolve/lib/selftest.ts +22 -5
- package/extensions/skill-evolve/lib/web/applied.ts +6 -1
- package/extensions/skill-evolve/lib/web/approvals.ts +7 -0
- package/extensions/skill-evolve/lib/web/done.ts +20 -6
- package/extensions/skill-evolve/lib/web/filter.ts +0 -15
- package/extensions/skill-evolve/lib/web/pending.ts +7 -1
- package/extensions/skill-evolve/lib/web/render-controls.ts +7 -16
- package/extensions/skill-evolve/lib/web/render-rows.ts +11 -2
- package/extensions/skill-evolve/lib/web/render-run.ts +46 -10
- package/extensions/skill-evolve/lib/web/reports.ts +11 -3
- package/extensions/skill-evolve/lib/web/run-detail.ts +41 -13
- package/extensions/skill-evolve/lib/web/style.ts +2 -0
- package/extensions/skill-evolve/test/review-post.test.ts +53 -6
- package/extensions/skill-evolve/test/review-prompt.test.ts +9 -0
- package/extensions/skill-evolve/test/review-record.test.ts +59 -0
- package/extensions/skill-evolve/test/review-supervisor.test.ts +15 -1
- package/extensions/skill-evolve/test/selftest.test.ts +51 -16
- package/extensions/skill-evolve/test/web-filter-labels.test.ts +31 -28
- package/extensions/skill-evolve/test/web-redesign.test.ts +1 -1
- package/extensions/skill-evolve/test/web-render-run.test.ts +70 -1
- package/extensions/skill-evolve/test/web-run-detail.test.ts +27 -0
- package/extensions/skill-evolve/test/web-view-coverage.test.ts +100 -0
- package/package.json +1 -1
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
--- a/.pi/skills/log-helper/SKILL.md
|
|
2
|
+
+++ b/.pi/skills/log-helper/SKILL.md
|
|
3
|
+
@@ -1,9 +1,9 @@
|
|
4
|
+
---
|
|
5
|
+
name: log-helper
|
|
6
|
+
-description: 查日志时使用
|
|
7
|
+
+description: 查服务端日志、客户端日志、构建日志时使用:先定位时间窗再 rg 关键字
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# 日志排查
|
|
11
|
+
|
|
12
|
+
- 凡是查日志一律用 `rg`,禁止 `grep -r` 全盘扫。
|
|
13
|
+
- 只读日志,不许改服务端配置。
|
|
@@ -47,11 +47,14 @@ export const REPLAY_DIFF = [
|
|
|
47
47
|
"",
|
|
48
48
|
].join("\n");
|
|
49
49
|
|
|
50
|
-
/** 假评审结论:形状必须过 postProcessReview
|
|
50
|
+
/** 假评审结论:形状必须过 postProcessReview 的字段校验(summary 必填 + 证据三件套齐全 + pass 必须有 passed) */
|
|
51
51
|
export const FAKE_REVIEW_TEXT = JSON.stringify({
|
|
52
52
|
verdict: "pass",
|
|
53
|
+
summary: "新增条款只补了复演前的读取顺序,没有删改原有约束",
|
|
53
54
|
blocking: [],
|
|
54
|
-
|
|
55
|
+
passed: [
|
|
56
|
+
{ point: "没有删原有禁令", evidence: { file: ".pi/skills/replay-demo/SKILL.md", line: 9, quote: "复演时必须先看 00-session/index.json" } },
|
|
57
|
+
],
|
|
55
58
|
notes: ["复演用假评审:只验证流水线,不代表真实评审质量"],
|
|
56
59
|
});
|
|
57
60
|
|
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 原则是「不信 LLM 自觉」:verdict 只要有一个字段不合规就判 invalid(重评一次仍 invalid 就转人工),
|
|
5
5
|
* pass 但带 blocking 是自相矛盾,直接 invalid;机械复验失败则一票 reject,覆盖 LLM 的 pass。
|
|
6
|
+
*
|
|
7
|
+
* 结论必须写「为什么」:`summary` 一句话说明因为什么(面板上人看的就是这一句),
|
|
8
|
+
* `passed` 是「没问题的地方 + 证据」,verdict=pass 时至少 1 条 —— 只写 blocking 的评审
|
|
9
|
+
* 在 pass/revise 时等于什么都没说,用户只能看到「通过」两个字。
|
|
6
10
|
*/
|
|
7
11
|
|
|
8
12
|
export type ReviewVerdict = "pass" | "reject" | "revise";
|
|
@@ -18,12 +22,21 @@ export interface ReviewBlocking {
|
|
|
18
22
|
evidence: ReviewEvidence;
|
|
19
23
|
}
|
|
20
24
|
|
|
25
|
+
/** 通过理由:哪一点没问题(point)+ 支撑它的原文证据 */
|
|
26
|
+
export interface ReviewPassing {
|
|
27
|
+
point: string;
|
|
28
|
+
evidence: ReviewEvidence;
|
|
29
|
+
}
|
|
30
|
+
|
|
21
31
|
export type ReviewStatus = "ok" | "invalid" | "reject";
|
|
22
32
|
|
|
23
33
|
export interface ReviewResult {
|
|
24
34
|
status: ReviewStatus;
|
|
25
35
|
verdict?: ReviewVerdict;
|
|
26
36
|
blocking: ReviewBlocking[];
|
|
37
|
+
passed: ReviewPassing[];
|
|
38
|
+
/** 一句话结论(≤80 字,必须写「因为什么」);旧版评审输出没有这一项 */
|
|
39
|
+
summary?: string;
|
|
27
40
|
notes?: string;
|
|
28
41
|
/** invalid / reject 的具体原因(机器可读前缀 + 人话) */
|
|
29
42
|
detail?: string;
|
|
@@ -31,6 +44,9 @@ export interface ReviewResult {
|
|
|
31
44
|
|
|
32
45
|
const VERDICTS: readonly ReviewVerdict[] = ["pass", "reject", "revise"];
|
|
33
46
|
|
|
47
|
+
/** summary 上限:一句话结论,超了截断(80 字是面板上一行能读完的长度) */
|
|
48
|
+
export const SUMMARY_MAX = 80;
|
|
49
|
+
|
|
34
50
|
/** 从模型输出里抠 JSON:容忍 ```json 围栏与前后废话,抠不出就当解析失败 */
|
|
35
51
|
export function parseReviewJson(text: string): { json?: Record<string, unknown>; error?: string } {
|
|
36
52
|
const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(text);
|
|
@@ -63,39 +79,94 @@ function checkEvidence(raw: unknown): ReviewEvidence | string {
|
|
|
63
79
|
* 12.3 四步里的前三步(结构 / 证据 / 一致性)+ 可选的机械复验。
|
|
64
80
|
* `recheck` 由调用方注入(读盘 + loadSkillsFromDir),返回错误字符串表示复验失败。
|
|
65
81
|
*/
|
|
82
|
+
/** summary 归一:去首尾空白、压掉换行;空串 = 没写,超长按 80 字截断(含省略号本身不超过 80) */
|
|
83
|
+
function normalizeSummary(raw: unknown): string | null {
|
|
84
|
+
if (typeof raw !== "string") return null;
|
|
85
|
+
const text = raw.replace(/\s+/g, " ").trim();
|
|
86
|
+
if (text.length === 0) return null;
|
|
87
|
+
return text.length > SUMMARY_MAX ? `${text.slice(0, SUMMARY_MAX - 1)}…` : text;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 逐条读带证据的理由数组(blocking / passed 共用):结构不对、证据不全一律返回错误字符串。
|
|
92
|
+
* 顺序:先查 block 的证据,再查 passed 的证据 —— 两边共用同一个 checkEvidence,报错前缀区分来源。
|
|
93
|
+
*/
|
|
94
|
+
function readReasons<T>(
|
|
95
|
+
raw: unknown,
|
|
96
|
+
label: "blocking" | "passed",
|
|
97
|
+
build: (text: string, evidence: ReviewEvidence) => T,
|
|
98
|
+
): T[] | string {
|
|
99
|
+
if (raw !== undefined && !Array.isArray(raw)) return `bad-${label}: ${label} 不是数组`;
|
|
100
|
+
const items: T[] = [];
|
|
101
|
+
for (const item of (raw ?? []) as unknown[]) {
|
|
102
|
+
if (typeof item !== "object" || item === null) return `missing-evidence: ${label} 项不是对象`;
|
|
103
|
+
const evidence = checkEvidence((item as Record<string, unknown>).evidence);
|
|
104
|
+
if (typeof evidence === "string") return `missing-evidence: ${label} · ${evidence}`;
|
|
105
|
+
const key = label === "blocking" ? "issue" : "point";
|
|
106
|
+
const rawText = (item as Record<string, unknown>)[key];
|
|
107
|
+
const text = typeof rawText === "string" ? rawText.trim() : "";
|
|
108
|
+
items.push(build(text.length > 0 ? text : `(未写 ${key})`, evidence));
|
|
109
|
+
}
|
|
110
|
+
return items;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 校验顺序 = 从「根本读不出结论」到「结论缺理由」:
|
|
115
|
+
* JSON → verdict 白名单 → blocking 结构与证据 → passed 结构与证据 → pass 却带 blocking 的自相矛盾
|
|
116
|
+
* → summary 必填 → pass 必须给 passed → reject/revise 必须给 blocking → 机械复验。
|
|
117
|
+
* 越靠前的错误越致命,也让单测能一条一条钉住具体坏法。
|
|
118
|
+
*/
|
|
66
119
|
export function postProcessReview(text: string, opts: { recheck?: () => string | null } = {}): ReviewResult {
|
|
67
120
|
const parsed = parseReviewJson(text);
|
|
68
|
-
if (!parsed.json) return { status: "invalid", blocking: [], detail: parsed.error ?? "bad-json" };
|
|
121
|
+
if (!parsed.json) return { status: "invalid", blocking: [], passed: [], detail: parsed.error ?? "bad-json" };
|
|
69
122
|
|
|
70
123
|
const verdict = parsed.json.verdict;
|
|
71
124
|
if (typeof verdict !== "string" || !(VERDICTS as readonly string[]).includes(verdict)) {
|
|
72
|
-
return { status: "invalid", blocking: [], detail: `bad-verdict: ${String(verdict)}` };
|
|
125
|
+
return { status: "invalid", blocking: [], passed: [], detail: `bad-verdict: ${String(verdict)}` };
|
|
73
126
|
}
|
|
127
|
+
const typed = verdict as ReviewVerdict;
|
|
128
|
+
|
|
129
|
+
const blocked = readReasons(parsed.json.blocking, "blocking", (issue, evidence) => ({ issue, evidence }));
|
|
130
|
+
if (typeof blocked === "string") return { status: "invalid", blocking: [], passed: [], verdict: typed, detail: blocked };
|
|
74
131
|
|
|
75
|
-
const
|
|
76
|
-
if (
|
|
77
|
-
|
|
132
|
+
const passed = readReasons(parsed.json.passed, "passed", (point, evidence) => ({ point, evidence }));
|
|
133
|
+
if (typeof passed === "string") return { status: "invalid", blocking: blocked, passed: [], verdict: typed, detail: passed };
|
|
134
|
+
|
|
135
|
+
if (verdict === "pass" && blocked.length > 0) {
|
|
136
|
+
return { status: "invalid", blocking: blocked, passed, verdict: "pass", detail: "contradiction: pass 却有 blocking" };
|
|
78
137
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
84
|
-
const evidence = checkEvidence((item as Record<string, unknown>).evidence);
|
|
85
|
-
if (typeof evidence === "string") {
|
|
86
|
-
return { status: "invalid", blocking: [], verdict: verdict as ReviewVerdict, detail: `missing-evidence: ${evidence}` };
|
|
87
|
-
}
|
|
88
|
-
const issue = typeof (item as Record<string, unknown>).issue === "string" ? String((item as Record<string, unknown>).issue) : "";
|
|
89
|
-
blocked.push({ issue: issue.length > 0 ? issue : "(未写 issue)", evidence });
|
|
138
|
+
|
|
139
|
+
const summary = normalizeSummary(parsed.json.summary);
|
|
140
|
+
if (summary === null) {
|
|
141
|
+
return { status: "invalid", blocking: blocked, passed, verdict: typed, detail: "bad-summary: 没写 summary(一句话结论)" };
|
|
90
142
|
}
|
|
91
143
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
144
|
+
if (verdict === "pass" && passed.length === 0) {
|
|
145
|
+
return {
|
|
146
|
+
status: "invalid",
|
|
147
|
+
blocking: blocked,
|
|
148
|
+
passed,
|
|
149
|
+
verdict: typed,
|
|
150
|
+
summary,
|
|
151
|
+
detail: "missing-passed: verdict=pass 但没给 passed(通过理由)",
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
if (verdict !== "pass" && blocked.length === 0) {
|
|
155
|
+
return {
|
|
156
|
+
status: "invalid",
|
|
157
|
+
blocking: blocked,
|
|
158
|
+
passed,
|
|
159
|
+
verdict: typed,
|
|
160
|
+
summary,
|
|
161
|
+
detail: `missing-blocking: verdict=${verdict} 但没给 blocking`,
|
|
162
|
+
};
|
|
95
163
|
}
|
|
96
164
|
|
|
165
|
+
const notes = typeof parsed.json.notes === "string" ? parsed.json.notes : undefined;
|
|
97
166
|
const failure = opts.recheck?.() ?? null;
|
|
98
|
-
if (failure)
|
|
167
|
+
if (failure) {
|
|
168
|
+
return { status: "reject", blocking: blocked, passed, verdict: "reject", summary, notes, detail: `frontmatter-invalid: ${failure}` };
|
|
169
|
+
}
|
|
99
170
|
|
|
100
|
-
return { status: "ok", blocking: blocked, verdict:
|
|
171
|
+
return { status: "ok", blocking: blocked, passed, verdict: typed, summary, ...(notes ? { notes } : {}) };
|
|
101
172
|
}
|
|
@@ -72,11 +72,15 @@ export function buildReviewPrompt(input: ReviewPromptInput): string {
|
|
|
72
72
|
"",
|
|
73
73
|
"## 输出格式(严格遵守,只输出 JSON,不要代码围栏、不要解释)",
|
|
74
74
|
'{"verdict":"pass|reject|revise",',
|
|
75
|
+
' "summary":"一句话结论(≤80 字,必须写清因为什么)",',
|
|
75
76
|
' "blocking":[{"issue":"...","evidence":{"file":"...","line":42,"quote":"原文"}}],',
|
|
77
|
+
' "passed":[{"point":"...","evidence":{"file":"...","line":42,"quote":"原文"}}],',
|
|
76
78
|
' "notes":"..."}',
|
|
77
79
|
"",
|
|
78
80
|
`技能名:${input.skillName}`,
|
|
79
|
-
"说明:
|
|
81
|
+
"说明:summary 必填,不要写成「没问题」或「建议修改」这种没有原因的空话,要写「因为哪一句 / 少哪一项所以这个结论」。",
|
|
82
|
+
"blocking 每条都必须给 file/line/quote(quote 是被改动文件里的原文片段);verdict=reject/revise 时 blocking 至少 1 条。",
|
|
83
|
+
"passed 写「哪一点没问题 + 支撑它的原文」;verdict=pass 时 blocking=[] 且 passed 至少 1 条(只写「通过」两个字等于没说)。",
|
|
80
84
|
"",
|
|
81
85
|
].join("\n");
|
|
82
86
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* 单独一个文件是因为评审督导已经接近 300 行闸门;这里全是纯函数,单测可直接调。
|
|
5
5
|
*/
|
|
6
6
|
import type { ActionLevel } from "./classify-action.ts";
|
|
7
|
-
import type { ReviewBlocking, ReviewVerdict } from "./review-post.ts";
|
|
7
|
+
import type { ReviewBlocking, ReviewPassing, ReviewVerdict } from "./review-post.ts";
|
|
8
8
|
|
|
9
9
|
export type SupervisorStatus = "reviewed" | "rejected" | "review_failed" | "skipped";
|
|
10
10
|
|
|
@@ -13,6 +13,10 @@ export interface ReviewRecord {
|
|
|
13
13
|
reason?: string;
|
|
14
14
|
verdict?: ReviewVerdict;
|
|
15
15
|
blocking: ReviewBlocking[];
|
|
16
|
+
/** 通过理由(verdict=pass 时至少 1 条);旧 run 没有这个字段 —— 读的时候当空数组,不抛 */
|
|
17
|
+
passed?: ReviewPassing[];
|
|
18
|
+
/** 一句话结论(≤80 字);旧 run 没有这个字段 = undefined */
|
|
19
|
+
summary?: string;
|
|
16
20
|
notes?: string;
|
|
17
21
|
action?: ActionLevel;
|
|
18
22
|
reviewerModel: string;
|
|
@@ -45,6 +49,8 @@ export function renderReviewMarkdown(record: ReviewRecord, skill: string): strin
|
|
|
45
49
|
// 首行就要立警告:同模型自评的独立性天然有限,人一眼得看见
|
|
46
50
|
if (record.sameModel) lines.push("> 同模型自评,独立性有限(proposer 与 reviewer 是同一个模型,请人工复核)", "");
|
|
47
51
|
lines.push(`# 评审结论:${record.verdict ?? record.status}`, "");
|
|
52
|
+
// 结论摘要放在元信息之前:人看 md 的第一句就该是「为什么」
|
|
53
|
+
if (record.summary) lines.push(`**${record.summary}**`, "");
|
|
48
54
|
lines.push(`- 技能:${skill}`);
|
|
49
55
|
lines.push(`- status:${record.status}${record.detail ? `(${record.detail})` : ""}`);
|
|
50
56
|
lines.push(`- 建议动作:${record.action ?? "无"}(本版一律不写 .pi/skills)`);
|
|
@@ -57,6 +63,13 @@ export function renderReviewMarkdown(record: ReviewRecord, skill: string): strin
|
|
|
57
63
|
lines.push(`- ${item.issue}`, ` - 证据:${item.evidence.file}:${item.evidence.line} 「${item.evidence.quote}」`);
|
|
58
64
|
}
|
|
59
65
|
}
|
|
66
|
+
const passed = record.passed ?? [];
|
|
67
|
+
if (passed.length > 0) {
|
|
68
|
+
lines.push("", "## passed", "");
|
|
69
|
+
for (const item of passed) {
|
|
70
|
+
lines.push(`- ${item.point}`, ` - 证据:${item.evidence.file}:${item.evidence.line} 「${item.evidence.quote}」`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
60
73
|
if (record.notes) lines.push("", "## notes", "", record.notes);
|
|
61
74
|
lines.push("");
|
|
62
75
|
return lines.join("\n");
|
|
@@ -215,6 +215,8 @@ export async function runReviewSupervisor(deps: ReviewSupervisorDeps): Promise<S
|
|
|
215
215
|
status,
|
|
216
216
|
...(verdict ? { verdict } : {}),
|
|
217
217
|
blocking: outcome.result.blocking,
|
|
218
|
+
passed: outcome.result.passed,
|
|
219
|
+
...(outcome.result.summary ? { summary: outcome.result.summary } : {}),
|
|
218
220
|
...(outcome.result.notes ? { notes: outcome.result.notes } : {}),
|
|
219
221
|
action: classifyChange({ diffText, proposalType: proposal.type, skill: proposal.skill }).level,
|
|
220
222
|
attempts: outcome.attempts,
|
|
@@ -228,7 +230,17 @@ export async function runReviewSupervisor(deps: ReviewSupervisorDeps): Promise<S
|
|
|
228
230
|
try {
|
|
229
231
|
const runJson = JSON.parse(runRaw) as Record<string, unknown>;
|
|
230
232
|
runJson.status = status === "reviewed" ? "reviewed" : status === "rejected" ? "rejected" : "review_failed";
|
|
231
|
-
runJson.review = {
|
|
233
|
+
runJson.review = {
|
|
234
|
+
status,
|
|
235
|
+
verdict: verdict ?? null,
|
|
236
|
+
action: record.action,
|
|
237
|
+
reviewerModel,
|
|
238
|
+
proposerModel,
|
|
239
|
+
sameModel,
|
|
240
|
+
// 摘要写回 run.json:列表与详情页共用一存,不用为一句结论另开文件
|
|
241
|
+
summary: outcome.result.summary ?? null,
|
|
242
|
+
passed: outcome.result.passed.length,
|
|
243
|
+
};
|
|
232
244
|
write(runFile, `${JSON.stringify(runJson, null, 2)}\n`);
|
|
233
245
|
} catch (error) {
|
|
234
246
|
log(`[skill-evolve] 评审结论写不回 run.json:${(error as Error).message}`);
|
|
@@ -86,6 +86,10 @@ export interface RunReview {
|
|
|
86
86
|
reviewerModel: string;
|
|
87
87
|
proposerModel: string;
|
|
88
88
|
sameModel: boolean;
|
|
89
|
+
/** 一句话结论(旧 run 没有这一项 = undefined/null) */
|
|
90
|
+
summary?: string | null;
|
|
91
|
+
/** 通过理由条数:列表页不需要全文,详情页读 03-review.json */
|
|
92
|
+
passed?: number;
|
|
89
93
|
}
|
|
90
94
|
|
|
91
95
|
export interface RunSummary {
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 必红自检(设计稿 12.4
|
|
2
|
+
* 必红自检(设计稿 12.4):拿三个**已知该被拦住**的坏样本 + 一个**已知该放行**的正样本去考评审器。
|
|
3
3
|
*
|
|
4
4
|
* 这是整条流水线唯一的「评审器自己可信吗」证据:如果评审器把「可以用 find」判成 pass,
|
|
5
5
|
* 后面所有真提案的 pass 都没有意义,所以这里任何一条 expected 没命中就拿 `passed: false`
|
|
6
6
|
* 并在报告里打红字,派发也随之停摆。
|
|
7
7
|
*
|
|
8
|
+
* 正样本(good-description)拉的是另一头:只会说「通过」、写不出「因为哪一点没问题」的评审器,
|
|
9
|
+
* 面板上就是一句没有理由的判决;所以 pass 除了 verdict 要命中,还必须有 `passed` 理由。
|
|
10
|
+
*
|
|
8
11
|
* 注意:这里**故意不跑机械 P0 复验** —— 那几个坏样本里有两条本身就违反硬规则,
|
|
9
12
|
* 用规则引擎拒掉只能证明规则还活着,证明不了评审器会判断内容。
|
|
10
13
|
*/
|
|
@@ -12,6 +15,7 @@ import * as fs from "node:fs";
|
|
|
12
15
|
import * as path from "node:path";
|
|
13
16
|
import { fileURLToPath } from "node:url";
|
|
14
17
|
import { loadHardRules, type CompiledRules } from "./hard-rules.ts";
|
|
18
|
+
import type { ReviewResult } from "./review-post.ts";
|
|
15
19
|
import { buildReviewPrompt } from "./review-prompt.ts";
|
|
16
20
|
import { reviewWithRetry, type ReviewSpawn } from "./review-run.ts";
|
|
17
21
|
|
|
@@ -62,7 +66,7 @@ function readText(file: string): string | undefined {
|
|
|
62
66
|
}
|
|
63
67
|
}
|
|
64
68
|
|
|
65
|
-
/**
|
|
69
|
+
/** 读四个 fixture;缺文件 / 坏 JSON 的目录直接跳过(不猜、不补) */
|
|
66
70
|
export function loadFixtures(dir: string, log: (msg: string) => void = () => {}): SelftestFixture[] {
|
|
67
71
|
let entries: string[];
|
|
68
72
|
try {
|
|
@@ -134,7 +138,17 @@ export interface SelftestOptions {
|
|
|
134
138
|
log?: (msg: string) => void;
|
|
135
139
|
}
|
|
136
140
|
|
|
137
|
-
/**
|
|
141
|
+
/**
|
|
142
|
+
* 单样本判定:verdict 命中期望算合格;正样本(expected=pass)额外要求写出 passed 理由。
|
|
143
|
+
* 只判「通过」不判「为什么通过」,面板上仍然是一句没有理由的判决。
|
|
144
|
+
*/
|
|
145
|
+
function judgeResult(expected: SelftestExpectation, result: ReviewResult): boolean {
|
|
146
|
+
const verdictOk = result.status === "ok" && result.verdict === expected;
|
|
147
|
+
if (!verdictOk) return false;
|
|
148
|
+
return expected !== "pass" || result.passed.length > 0;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** 依次跑完全部样本;返回报告并写 `selftest.json`(写失败只记日志) */
|
|
138
152
|
export async function runSelftest(options: SelftestOptions): Promise<SelftestReport> {
|
|
139
153
|
const log = options.log ?? (() => {});
|
|
140
154
|
const now = options.now ?? Date.now;
|
|
@@ -161,8 +175,11 @@ export async function runSelftest(options: SelftestOptions): Promise<SelftestRep
|
|
|
161
175
|
now,
|
|
162
176
|
log,
|
|
163
177
|
});
|
|
164
|
-
|
|
165
|
-
|
|
178
|
+
// 正样本判 pass 但没给理由时,报告里必须看得出「差在哪」,不能只写期望 pass 实际 pass
|
|
179
|
+
// (缺 passed 会被 postProcessReview 判 invalid,但 verdict 仍写着 pass,所以不能靠 status 判断)
|
|
180
|
+
const missingPassed = outcome.result.verdict === "pass" && outcome.result.passed.length === 0;
|
|
181
|
+
const actual = missingPassed ? "pass(缺 passed 理由)" : (outcome.result.verdict ?? outcome.result.status);
|
|
182
|
+
const ok = judgeResult(fixture.expected, outcome.result);
|
|
166
183
|
results.push({
|
|
167
184
|
name: fixture.name,
|
|
168
185
|
expected: fixture.expected,
|
|
@@ -10,12 +10,16 @@ import * as path from "node:path";
|
|
|
10
10
|
import { readVerify } from "../apply.ts";
|
|
11
11
|
import { readProposalSummary } from "../proposal.ts";
|
|
12
12
|
import type { QueueItem } from "../queue.ts";
|
|
13
|
+
import type { RunStatus } from "../runs.ts";
|
|
13
14
|
import { asNumber, asText, readJson } from "./json-read.ts";
|
|
14
15
|
import { readRunSession, type SessionRef } from "./run-session.ts";
|
|
15
16
|
|
|
16
17
|
/** 已采纳列表上限:与 DONE_LIMIT 同量级 —— 网页看近期,不是审计日志 */
|
|
17
18
|
export const APPLIED_LIMIT = 50;
|
|
18
19
|
|
|
20
|
+
/** 已采纳页收的 run 状态(导出给覆盖性测试):只有队列 item.status === "applied" 的条目 */
|
|
21
|
+
export const APPLIED_RUN_STATUSES: readonly RunStatus[] = ["applied"];
|
|
22
|
+
|
|
19
23
|
/** 一个已采纳条目:技能 · 提案类型 · +N/-M · 采纳时间 · 五闸是否全 ok */
|
|
20
24
|
export interface AppliedItem {
|
|
21
25
|
runId: string;
|
|
@@ -43,7 +47,8 @@ export interface AppliedItem {
|
|
|
43
47
|
export function buildApplied(items: readonly QueueItem[], evolutionDir: string): AppliedItem[] {
|
|
44
48
|
const rows: AppliedItem[] = [];
|
|
45
49
|
for (const item of items) {
|
|
46
|
-
|
|
50
|
+
// 判据用清单本体而不是写死 "applied":清单改了代码就跟着改(覆盖性测试读的也是这份)
|
|
51
|
+
if (!APPLIED_RUN_STATUSES.some((status) => status === item.status)) continue;
|
|
47
52
|
const runDir = path.join(evolutionDir, "runs", item.runId);
|
|
48
53
|
const proposal = readProposalSummary(runDir);
|
|
49
54
|
const verify = readVerify(runDir);
|
|
@@ -10,9 +10,16 @@
|
|
|
10
10
|
import * as path from "node:path";
|
|
11
11
|
import type { QueueItem } from "../queue.ts";
|
|
12
12
|
import { readProposalSummary } from "../proposal.ts";
|
|
13
|
+
import type { RunStatus } from "../runs.ts";
|
|
13
14
|
import { asNumber, asText, readJson } from "./json-read.ts";
|
|
14
15
|
import { readRunSession, type SessionRef } from "./run-session.ts";
|
|
15
16
|
|
|
17
|
+
/**
|
|
18
|
+
* 待审批页对应的 run 状态(导出给覆盖性测试):条目靠队列条件选(pending + approval 级),
|
|
19
|
+
* 能进队列就说明评审已过,所以这里只能是 reviewed。
|
|
20
|
+
*/
|
|
21
|
+
export const APPROVALS_RUN_STATUSES: readonly RunStatus[] = ["reviewed"];
|
|
22
|
+
|
|
16
23
|
export interface ApprovalItem {
|
|
17
24
|
runId: string;
|
|
18
25
|
/** 这条 run 是哪个会话跑出来的:行上「会话」列要用(缺 = NO_SESSION) */
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 「已处理」视图的数据:回滚 / 失败 / 否决 /
|
|
2
|
+
* 「已处理」视图的数据:回滚 / 失败 / 否决 / 被取代 / 评审失败 / 已放弃 / 越权写入这七种终态。
|
|
3
3
|
*
|
|
4
4
|
* 为什么不和「已采纳」混:采纳有自己的页(见 ./applied.ts),两边都收就等于两个 tab 各算一遍。
|
|
5
5
|
* 队列记录会覆盖同一个 runId 的 run.json 版本:它有层级(auto/approval)与失败详情,比只有状态有用。
|
|
6
6
|
*/
|
|
7
7
|
import type { QueueItem } from "../queue.ts";
|
|
8
|
-
import type { RunSummary } from "../runs.ts";
|
|
8
|
+
import type { RunStatus, RunSummary } from "../runs.ts";
|
|
9
9
|
import { asNumber, asText } from "./json-read.ts";
|
|
10
10
|
import { sessionForRun, sessionIndex, sessionOfRun, type SessionRef } from "./run-session.ts";
|
|
11
11
|
|
|
@@ -26,16 +26,30 @@ export interface DoneItem {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
|
-
* 「已处理」收的终态:回滚 / 失败 / 否决 /
|
|
29
|
+
* 「已处理」收的终态:回滚 / 失败 / 否决 / 被取代,以及过去漏收的评审失败 / 已放弃 / 越权写入。
|
|
30
30
|
* **不含 applied** —— 采纳有自己的页(见 ./applied.ts),两边都收就等于两个 tab 都算一遍。
|
|
31
|
+
*
|
|
32
|
+
* 导出成数组是为了「状态覆盖性」测试(test/web-view-coverage.test.ts):
|
|
33
|
+
* 新增一个 run 状态却没有任何视图收它,那类 run 会在面板上直接消失、只能翻目录 ——
|
|
34
|
+
* review_failed / abandoned / unauthorized_write 就是这么丢的,所以这个清单必须可被测试读取。
|
|
31
35
|
*/
|
|
32
|
-
const DONE_STATUSES
|
|
36
|
+
export const DONE_STATUSES: readonly RunStatus[] = [
|
|
37
|
+
"rolled-back",
|
|
38
|
+
"failed",
|
|
39
|
+
"rejected",
|
|
40
|
+
"superseded",
|
|
41
|
+
"review_failed",
|
|
42
|
+
"abandoned",
|
|
43
|
+
"unauthorized_write",
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
const DONE_STATUS_SET = new Set<string>(DONE_STATUSES);
|
|
33
47
|
|
|
34
48
|
export function buildDone(items: readonly QueueItem[], runs: readonly RunSummary[], evolutionDir: string): DoneItem[] {
|
|
35
49
|
const byRun = new Map<string, DoneItem>();
|
|
36
50
|
const sessions = sessionIndex(runs);
|
|
37
51
|
for (const run of runs) {
|
|
38
|
-
if (!
|
|
52
|
+
if (!DONE_STATUS_SET.has(run.status)) continue;
|
|
39
53
|
byRun.set(run.runId, {
|
|
40
54
|
runId: run.runId,
|
|
41
55
|
status: run.status,
|
|
@@ -49,7 +63,7 @@ export function buildDone(items: readonly QueueItem[], runs: readonly RunSummary
|
|
|
49
63
|
}
|
|
50
64
|
// 队列记录覆盖同 runId:它有层级(auto/approval)与失败详情,比 run.json 只有状态有用
|
|
51
65
|
for (const item of items) {
|
|
52
|
-
if (!
|
|
66
|
+
if (!DONE_STATUS_SET.has(item.status)) continue;
|
|
53
67
|
byRun.set(item.runId, {
|
|
54
68
|
runId: item.runId,
|
|
55
69
|
status: item.status,
|
|
@@ -29,21 +29,6 @@ export const RANGE_LABEL: Record<RangeKey, string> = {
|
|
|
29
29
|
/** 状态下拉的可选值:既有 run 终态,也有待处理页自己的两种(running / stale) */
|
|
30
30
|
export const STATUS_OPTIONS = ["running", "stale", "reviewed", "applied", "rejected", "failed", "analyzed"] as const;
|
|
31
31
|
|
|
32
|
-
/**
|
|
33
|
-
* 状态下拉的中文标签(用户 2026-11-08:「这里的下拉应该是中文」)。
|
|
34
|
-
* 值仍然是状态机原文(URL 与过滤逻辑不变),只改给人看的那一侧;
|
|
35
|
-
* 与徽章文案保持同一套词——同一个概念全站只能有一个中文名。
|
|
36
|
-
*/
|
|
37
|
-
export const STATUS_LABEL: Record<(typeof STATUS_OPTIONS)[number], string> = {
|
|
38
|
-
running: "复盘中",
|
|
39
|
-
stale: "卡死",
|
|
40
|
-
reviewed: "待入队",
|
|
41
|
-
applied: "已采纳",
|
|
42
|
-
rejected: "已拒绝",
|
|
43
|
-
failed: "失败",
|
|
44
|
-
analyzed: "待评审",
|
|
45
|
-
};
|
|
46
|
-
|
|
47
32
|
export interface ListFilter {
|
|
48
33
|
skill: string;
|
|
49
34
|
/** 空串 = 全部 */
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import * as fs from "node:fs";
|
|
9
9
|
import * as path from "node:path";
|
|
10
10
|
import type { QueueItem } from "../queue.ts";
|
|
11
|
-
import type { RunSummary } from "../runs.ts";
|
|
11
|
+
import type { RunStatus, RunSummary } from "../runs.ts";
|
|
12
12
|
import { CHILD_LOG } from "../spawn-plan.ts";
|
|
13
13
|
import { classifyStale, readLogTail, readRunError } from "../stale.ts";
|
|
14
14
|
import type { WebStateDeps } from "./data.ts";
|
|
@@ -30,6 +30,12 @@ function defaultAlive(pid: number): boolean {
|
|
|
30
30
|
/** 待处理五来源:正在跑 / 已判卡死 / 队列待批 / 已出结论等评审 / 评审通过等入队 */
|
|
31
31
|
export type PendingKind = "running" | "approval" | "stale" | "to-review" | "reviewed";
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* 待处理页收的 run 状态(导出给 test/web-view-coverage.test.ts 做状态覆盖性检查)。
|
|
35
|
+
* running 的判据是 dispatched + pid 还活着,所以 dispatched 算这里收;reviewed 是「等入队」。
|
|
36
|
+
*/
|
|
37
|
+
export const PENDING_RUN_STATUSES: readonly RunStatus[] = ["dispatched", "analyzed", "proposed", "reviewed", "stale"];
|
|
38
|
+
|
|
33
39
|
export interface PendingItem {
|
|
34
40
|
runId: string;
|
|
35
41
|
kind: PendingKind;
|
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
telemetryOffWhileEnabled,
|
|
22
22
|
} from "../flags.ts";
|
|
23
23
|
import type { WebView } from "./data.ts";
|
|
24
|
-
import { RANGE_KEYS, RANGE_LABEL,
|
|
24
|
+
import { RANGE_KEYS, RANGE_LABEL, type ListFilter, type ViewPage } from "./filter.ts";
|
|
25
25
|
import { listHref, type ListExtras } from "./list-query.ts";
|
|
26
26
|
import { escapeHtml, formatStamp, formatTimeSeconds } from "./text.ts";
|
|
27
27
|
import type { WebControls } from "./activity.ts";
|
|
@@ -106,16 +106,7 @@ ${run}</div>
|
|
|
106
106
|
${warn}${backlog}`;
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
-
/**
|
|
110
|
-
function statusSelect(current: string): string {
|
|
111
|
-
const options = [`<option value="">全部状态</option>`];
|
|
112
|
-
for (const status of STATUS_OPTIONS) {
|
|
113
|
-
// value 仍是状态机原文(过滤逻辑不变),只把展示换中文
|
|
114
|
-
options.push(`<option value="${status}"${status === current ? " selected" : ""}>${STATUS_LABEL[status]}</option>`);
|
|
115
|
-
}
|
|
116
|
-
return options.join("");
|
|
117
|
-
}
|
|
118
|
-
|
|
109
|
+
/** 时间下拉:只有「看多久内」需要人手动换,状态与技能已在 tab 与技能页面各自解决了 */
|
|
119
110
|
function rangeSelect(current: string): string {
|
|
120
111
|
return RANGE_KEYS.map(
|
|
121
112
|
(key) => `<option value="${key}"${key === current ? " selected" : ""}>${RANGE_LABEL[key]}</option>`,
|
|
@@ -123,16 +114,16 @@ function rangeSelect(current: string): string {
|
|
|
123
114
|
}
|
|
124
115
|
|
|
125
116
|
/**
|
|
126
|
-
*
|
|
127
|
-
*
|
|
117
|
+
* 筛选表单:只留「时间」一个条件。
|
|
118
|
+
* 技能:每个列表页的行里已经有技能列,技能页可以按技能点进来,再摆一个模糊搜索框没人知道该填什么;
|
|
119
|
+
* 状态:顶部 tab 本身就是按状态分的(待处理 / 待审批 / 已采纳 / 已处理 / 复盘报告),重复选毫无意义。
|
|
120
|
+
* 两个参数(skill / status)仍由 `parseListQuery` 支持,旧链接与代码内部跳转不会断,只是不再出输入框。
|
|
128
121
|
*/
|
|
129
122
|
export function renderFilterForm(view: WebView, filter: ListFilter, extras: ListExtras): string {
|
|
130
123
|
const keep = extraInputs(extras);
|
|
131
124
|
return `<form class="filter" method="get">
|
|
132
125
|
<input type="hidden" name="view" value="${view}">
|
|
133
|
-
${keep}<label
|
|
134
|
-
<label>状态 <select name="status">${statusSelect(filter.status)}</select></label>
|
|
135
|
-
<label>时间 <select name="range">${rangeSelect(filter.range)}</select></label>
|
|
126
|
+
${keep}<label>时间 <select name="range">${rangeSelect(filter.range)}</select></label>
|
|
136
127
|
<button type="submit">筛选</button>
|
|
137
128
|
<a class="btn" href="${escapeHtml(listHref(view, filter, 1, extras))}">重置</a>
|
|
138
129
|
</form>`;
|
|
@@ -123,13 +123,22 @@ export function pendingRows(items: readonly PendingItem[], ctx: RowContext): Lis
|
|
|
123
123
|
}));
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
-
/**
|
|
126
|
+
/** 已处理:终态各一色;被拒绝是暗红(是结论,不是故障),失败才标题红色
|
|
127
|
+
* 键必须与 run.json / 队列里的 status 一字不差:抄错一个字符(比如 rolledback)徽章就永远匹配不上,
|
|
128
|
+
* 页面上会回落到淡灰 + 原始英文,而不是中文文案。 */
|
|
127
129
|
export function doneRows(items: readonly DoneItem[]): ListRow[] {
|
|
128
130
|
const tone: Record<string, { text: string; tone: BadgeTone }> = {
|
|
129
131
|
applied: { text: "已采纳", tone: "green" },
|
|
130
132
|
rejected: { text: "已拒绝", tone: "darkred" },
|
|
131
133
|
failed: { text: "失败", tone: "red" },
|
|
132
|
-
|
|
134
|
+
"rolled-back": { text: "已回滚", tone: "faint" },
|
|
135
|
+
superseded: { text: "已被取代", tone: "faint" },
|
|
136
|
+
// 评审器自己出问题:是故障,不是结论,所以给红(同 failed),不给暗红
|
|
137
|
+
review_failed: { text: "评审失败", tone: "red" },
|
|
138
|
+
// 提案者没下文 / 放弃:没发生坏事,用淡灰
|
|
139
|
+
abandoned: { text: "已放弃", tone: "faint" },
|
|
140
|
+
// 越权写入是纪律事故,必须红
|
|
141
|
+
unauthorized_write: { text: "越权写入", tone: "red" },
|
|
133
142
|
};
|
|
134
143
|
return items.map((item) => ({
|
|
135
144
|
runId: item.runId,
|