pi-skill-evolve 0.1.9 → 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.
Files changed (29) hide show
  1. package/extensions/skill-evolve/fixtures/selftest/good-description/after.md +9 -0
  2. package/extensions/skill-evolve/fixtures/selftest/good-description/change.diff +13 -0
  3. package/extensions/skill-evolve/fixtures/selftest/good-description/fixture.json +6 -0
  4. package/extensions/skill-evolve/lib/replay-fixture.ts +5 -2
  5. package/extensions/skill-evolve/lib/review-post.ts +92 -21
  6. package/extensions/skill-evolve/lib/review-prompt.ts +5 -1
  7. package/extensions/skill-evolve/lib/review-record.ts +14 -1
  8. package/extensions/skill-evolve/lib/review-supervisor.ts +13 -1
  9. package/extensions/skill-evolve/lib/runs.ts +4 -0
  10. package/extensions/skill-evolve/lib/selftest.ts +22 -5
  11. package/extensions/skill-evolve/lib/web/applied.ts +6 -1
  12. package/extensions/skill-evolve/lib/web/approvals.ts +7 -0
  13. package/extensions/skill-evolve/lib/web/done.ts +20 -6
  14. package/extensions/skill-evolve/lib/web/pending.ts +7 -1
  15. package/extensions/skill-evolve/lib/web/render-rows.ts +11 -2
  16. package/extensions/skill-evolve/lib/web/render-run.ts +46 -10
  17. package/extensions/skill-evolve/lib/web/reports.ts +11 -3
  18. package/extensions/skill-evolve/lib/web/run-detail.ts +41 -13
  19. package/extensions/skill-evolve/lib/web/style.ts +2 -0
  20. package/extensions/skill-evolve/test/review-post.test.ts +53 -6
  21. package/extensions/skill-evolve/test/review-prompt.test.ts +9 -0
  22. package/extensions/skill-evolve/test/review-record.test.ts +59 -0
  23. package/extensions/skill-evolve/test/review-supervisor.test.ts +15 -1
  24. package/extensions/skill-evolve/test/selftest.test.ts +51 -16
  25. package/extensions/skill-evolve/test/web-redesign.test.ts +1 -1
  26. package/extensions/skill-evolve/test/web-render-run.test.ts +70 -1
  27. package/extensions/skill-evolve/test/web-run-detail.test.ts +27 -0
  28. package/extensions/skill-evolve/test/web-view-coverage.test.ts +100 -0
  29. package/package.json +1 -1
@@ -0,0 +1,9 @@
1
+ ---
2
+ name: log-helper
3
+ description: 查服务端日志、客户端日志、构建日志时使用:先定位时间窗再 rg 关键字
4
+ ---
5
+
6
+ # 日志排查
7
+
8
+ - 凡是查日志一律用 `rg`,禁止 `grep -r` 全盘扫。
9
+ - 只读日志,不许改服务端配置。
@@ -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
+ - 只读日志,不许改服务端配置。
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "good-description",
3
+ "skill": "log-helper",
4
+ "expected": "pass",
5
+ "why": "只给 description 补触发场景词,正文与既有禁令一字未动,评审应给 pass 并写出通过理由(passed ≥1)"
6
+ }
@@ -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
- evidence: [{ file: ".pi/skills/replay-demo/SKILL.md", line: 9, quote: "复演时必须先看 00-session/index.json" }],
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 rawBlocking = parsed.json.blocking;
76
- if (rawBlocking !== undefined && !Array.isArray(rawBlocking)) {
77
- return { status: "invalid", blocking: [], verdict: verdict as ReviewVerdict, detail: "bad-blocking: blocking 不是数组" };
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
- const blocked: ReviewBlocking[] = [];
80
- for (const item of (rawBlocking ?? []) as unknown[]) {
81
- if (typeof item !== "object" || item === null) {
82
- return { status: "invalid", blocking: [], verdict: verdict as ReviewVerdict, detail: "missing-evidence: blocking 项不是对象" };
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
- const notes = typeof parsed.json.notes === "string" ? parsed.json.notes : undefined;
93
- if (verdict === "pass" && blocked.length > 0) {
94
- return { status: "invalid", blocking: blocked, verdict: "pass", detail: "contradiction: pass 却有 blocking" };
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) return { status: "reject", blocking: blocked, verdict: "reject", notes, detail: `frontmatter-invalid: ${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: verdict as ReviewVerdict, ...(notes ? { notes } : {}) };
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
- "说明:blocking 每条都必须给 file/line/quote(quote 是被改动文件里的原文片段);没有问题时 verdict=pass 且 blocking=[]。",
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 = { status, verdict: verdict ?? null, action: record.action, reviewerModel, proposerModel, sameModel };
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
- /** 读三个 fixture;缺文件 / 坏 JSON 的目录直接跳过(不猜、不补) */
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
- /** 依次跑完三个坏样本;返回报告并写 `selftest.json`(写失败只记日志) */
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
- const actual = outcome.result.verdict ?? outcome.result.status;
165
- const ok = outcome.result.status === "ok" && outcome.result.verdict === fixture.expected;
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
- if (item.status !== "applied") continue;
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 = new Set(["rolled-back", "failed", "rejected", "superseded"]);
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 (!DONE_STATUSES.has(run.status)) continue;
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 (!DONE_STATUSES.has(item.status)) continue;
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,
@@ -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;
@@ -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
- rolledback: { text: "已回滚", tone: "faint" },
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,
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import type { WebState } from "./data.ts";
10
10
  import { DIFF_PREVIEW_LINES, renderDiffLines, parseDiffLines } from "./diff-lines.ts";
11
- import type { RunBlocking, RunDetail } from "./run-detail.ts";
11
+ import type { RunBlocking, RunDetail, RunPassing } from "./run-detail.ts";
12
12
  import { sessionHref } from "./run-session.ts";
13
13
  import { shellPage } from "./shell.ts";
14
14
  import { escapeHtml, formatStamp } from "./text.ts";
@@ -89,20 +89,42 @@ function candidateSection(detail: RunDetail): string {
89
89
  </section>`;
90
90
  }
91
91
 
92
- function blockingRows(blocking: readonly RunBlocking[]): string {
93
- if (blocking.length === 0) return `<p class="hint">blocking:0 条。</p>`;
94
- const rows = blocking.map((item) => {
95
- const where = item.file.length > 0 ? `${item.file}${item.line === null ? "" : `:${item.line}`}` : "(未写位置)";
96
- return `<tr><td>${escapeHtml(item.issue)}</td><td><code>${escapeHtml(where)}</code></td><td>${escapeHtml(item.quote)}</td></tr>`;
92
+ /** 旧 run 的兼容文案:03-review.json 没有 summary / passed 时明说「不是页面坏了」 */
93
+ const LEGACY_REVIEW_NOTE = "该 run 由旧版评审产出,没有结论摘要(重跑一次就有)";
94
+ /** 新版评审也确实可能两块全空(比如 skipped):那时不能说「旧版」,要说「没给理由」 */
95
+ const NO_REASON_NOTE = "03-review.json 里没有 blocking / passed:这一轮评审只写了结论,没写理由。";
96
+
97
+ /** 理由表的一行:blocking 用 issue、passed 用 point,但三列形状一样(说明 / 证据位置 / 原文引用) */
98
+ interface ReasonRow {
99
+ text: string;
100
+ file: string;
101
+ line: number | null;
102
+ quote: string;
103
+ }
104
+
105
+ function reasonTable(head: string, rows: readonly ReasonRow[]): string {
106
+ if (rows.length === 0) return "";
107
+ const body = rows.map((row) => {
108
+ const where = row.file.length > 0 ? `${row.file}${row.line === null ? "" : `:${row.line}`}` : "(未写位置)";
109
+ return `<tr><td>${escapeHtml(row.text)}</td><td><code>${escapeHtml(where)}</code></td><td>${escapeHtml(row.quote)}</td></tr>`;
97
110
  });
98
- return `<table class="tbl">
99
- <thead><tr><th>问题</th><th>证据位置</th><th>原文引用</th></tr></thead>
111
+ return `<h3>${head}</h3>
112
+ <table class="tbl">
113
+ <thead><tr><th>${head === "阻断意见" ? "问题" : "通过点"}</th><th>证据位置</th><th>原文引用</th></tr></thead>
100
114
  <tbody>
101
- ${rows.join("\n")}
115
+ ${body.join("\n")}
102
116
  </tbody>
103
117
  </table>`;
104
118
  }
105
119
 
120
+ function blockingRows(blocking: readonly RunBlocking[]): ReasonRow[] {
121
+ return blocking.map((item) => ({ text: item.issue, file: item.file, line: item.line, quote: item.quote }));
122
+ }
123
+
124
+ function passedRows(passed: readonly RunPassing[]): ReasonRow[] {
125
+ return passed.map((item) => ({ text: item.point, file: item.file, line: item.line, quote: item.quote }));
126
+ }
127
+
106
128
  function reviewSection(detail: RunDetail): string {
107
129
  if (detail.review === null) {
108
130
  return `<section>
@@ -111,6 +133,17 @@ function reviewSection(detail: RunDetail): string {
111
133
  </section>`;
112
134
  }
113
135
  const review = detail.review;
136
+ // 结论摘要:用户问的第一句永远是「为什么」,所以它排在 verdict 那一行之后、理由表之前,且字号大一档
137
+ // legacy 看的是 03-review.json 里有没有新契约的键,不是「summary 空不空」:
138
+ // 新版评审失败时同样没有 summary,那时要说「没给理由」,不能说成旧版
139
+ const legacy = review.legacy;
140
+ const summary = legacy
141
+ ? `<p class="hint">${LEGACY_REVIEW_NOTE}</p>`
142
+ : `<p class="verdict-summary">${escapeHtml(review.summary ?? "")}</p>`;
143
+ const blocked = reasonTable("阻断意见", blockingRows(detail.blocking));
144
+ const passed = reasonTable("通过理由", passedRows(detail.passed));
145
+ // 旧 run 已经有上面那句兼容文案了,不再叠一句「没给理由」
146
+ const emptyNote = !legacy && blocked === "" && passed === "" ? `<p class="hint">${NO_REASON_NOTE}</p>` : "";
114
147
  return `<section>
115
148
  <h2>评审结论</h2>
116
149
  <p class="kv-line">
@@ -119,7 +152,10 @@ action <b>${escapeHtml(review.action)}</b> ·
119
152
  状态 ${escapeHtml(review.status)} ·
120
153
  评审模型 <code>${escapeHtml(review.reviewerModel)}</code>
121
154
  </p>
122
- ${blockingRows(detail.blocking)}
155
+ ${summary}
156
+ ${emptyNote}
157
+ ${blocked}
158
+ ${passed}
123
159
  </section>`;
124
160
  }
125
161