pi-skill-evolve 0.1.9 → 0.1.11

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 (35) 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/dispatch-types.ts +1 -1
  5. package/extensions/skill-evolve/lib/dispatch.ts +25 -2
  6. package/extensions/skill-evolve/lib/drain-pending.ts +3 -0
  7. package/extensions/skill-evolve/lib/replay-fixture.ts +5 -2
  8. package/extensions/skill-evolve/lib/review-post.ts +92 -21
  9. package/extensions/skill-evolve/lib/review-prompt.ts +5 -1
  10. package/extensions/skill-evolve/lib/review-record.ts +14 -1
  11. package/extensions/skill-evolve/lib/review-supervisor.ts +13 -1
  12. package/extensions/skill-evolve/lib/runs.ts +4 -0
  13. package/extensions/skill-evolve/lib/selftest.ts +22 -5
  14. package/extensions/skill-evolve/lib/shutdown-dispatch.ts +25 -2
  15. package/extensions/skill-evolve/lib/spawn-plan.ts +47 -4
  16. package/extensions/skill-evolve/lib/web/applied.ts +6 -1
  17. package/extensions/skill-evolve/lib/web/approvals.ts +7 -0
  18. package/extensions/skill-evolve/lib/web/done.ts +20 -6
  19. package/extensions/skill-evolve/lib/web/pending.ts +7 -1
  20. package/extensions/skill-evolve/lib/web/render-rows.ts +11 -2
  21. package/extensions/skill-evolve/lib/web/render-run.ts +46 -10
  22. package/extensions/skill-evolve/lib/web/reports.ts +11 -3
  23. package/extensions/skill-evolve/lib/web/run-detail.ts +41 -13
  24. package/extensions/skill-evolve/lib/web/style.ts +2 -0
  25. package/extensions/skill-evolve/test/dispatch.test.ts +11 -0
  26. package/extensions/skill-evolve/test/review-post.test.ts +53 -6
  27. package/extensions/skill-evolve/test/review-prompt.test.ts +9 -0
  28. package/extensions/skill-evolve/test/review-record.test.ts +59 -0
  29. package/extensions/skill-evolve/test/review-supervisor.test.ts +15 -1
  30. package/extensions/skill-evolve/test/selftest.test.ts +51 -16
  31. package/extensions/skill-evolve/test/web-redesign.test.ts +1 -1
  32. package/extensions/skill-evolve/test/web-render-run.test.ts +70 -1
  33. package/extensions/skill-evolve/test/web-run-detail.test.ts +27 -0
  34. package/extensions/skill-evolve/test/web-view-coverage.test.ts +100 -0
  35. package/package.json +1 -1
@@ -1,7 +1,10 @@
1
1
  /**
2
- * 必红自检单测(设计稿 12.4):三个坏样本必须被拦住,任何一条被 pass 就算评审器不可信。
2
+ * 必红自检单测(设计稿 12.4):三个坏样本必须被拦住,一个正样本必须放行且写出理由;
3
+ * 任何一条不符就算评审器不可信。
3
4
  *
4
5
  * 假 spawn 按 prompt 里的「技能名」回不同结论,所以用例不受 fixtures 目录枚举顺序影响。
6
+ * 回的 JSON 必须是能过 postProcessReview 的合法形状(summary 必填,pass 必须带 passed),
7
+ * 否则测的就不是「期望命中」而是「载荷校验」了。
5
8
  */
6
9
  import { test } from "node:test";
7
10
  import assert from "node:assert/strict";
@@ -20,11 +23,11 @@ function tmpDir(): string {
20
23
  }
21
24
 
22
25
  /** 按 prompt 末尾的「技能名:x」挑结论 */
23
- function spawnBySkill(verdicts: Record<string, string>): ReviewSpawn {
26
+ function spawnBySkill(verdicts: Record<string, string>, opts: { dropPassed?: boolean } = {}): ReviewSpawn {
24
27
  return (_command, args) => {
25
28
  const prompt = args[3] ?? "";
26
29
  const skill = Object.keys(verdicts).find((name) => prompt.includes(`技能名:${name}`));
27
- const reply = JSON.stringify({ verdict: skill ? verdicts[skill] : "pass", blocking: [], notes: "自检" });
30
+ const reply = fakeReply(skill ? (verdicts[skill] ?? "pass") : "pass", { dropPassed: opts.dropPassed === true });
28
31
  const child = new FakeReviewChild();
29
32
  setTimeout(() => {
30
33
  child.stdout.emit("data", reply);
@@ -34,42 +37,74 @@ function spawnBySkill(verdicts: Record<string, string>): ReviewSpawn {
34
37
  };
35
38
  }
36
39
 
37
- test("三个坏样本都能被读到,期望分别是 reject/reject/revise", () => {
40
+ const FAKE_EVIDENCE = { file: ".pi/skills/demo/SKILL.md", line: 9, quote: "凡是查日志一律用 rg" };
41
+
42
+ /** 造一份合法评审输出:reject/revise 带 blocking,pass 带 passed(可故意抽掉,测正样本的必红判定) */
43
+ function fakeReply(verdict: string, opts: { dropPassed: boolean }): string {
44
+ const summary = "假评审:只校验自检链路,不代表真实评审质量";
45
+ if (verdict === "pass") {
46
+ const passed = opts.dropPassed ? [] : [{ point: "原有禁令仍完整保留", evidence: FAKE_EVIDENCE }];
47
+ return JSON.stringify({ verdict, summary, blocking: [], passed, notes: "自检" });
48
+ }
49
+ return JSON.stringify({ verdict, summary, blocking: [{ issue: "有问题", evidence: FAKE_EVIDENCE }], passed: [], notes: "自检" });
50
+ }
51
+
52
+ test("三个坏样本 + 一个正样本都能被读到,期望分别是 reject/reject/revise/pass", () => {
38
53
  const fixtures = loadFixtures(FIXTURES);
39
- assert.deepEqual(fixtures.map((item) => item.name).sort(), ["delete-ban", "rg-to-find", "weak-description"]);
54
+ assert.deepEqual(fixtures.map((item) => item.name).sort(), ["delete-ban", "good-description", "rg-to-find", "weak-description"]);
40
55
  const expected = Object.fromEntries(fixtures.map((item) => [item.name, item.expected]));
41
- assert.deepEqual(expected, { "delete-ban": "reject", "rg-to-find": "reject", "weak-description": "revise" });
56
+ assert.deepEqual(expected, {
57
+ "delete-ban": "reject",
58
+ "good-description": "pass",
59
+ "rg-to-find": "reject",
60
+ "weak-description": "revise",
61
+ });
42
62
  for (const fixture of fixtures) {
43
63
  assert.ok(fixture.diff.length > 0, `${fixture.name} 缺 diff`);
44
64
  assert.ok(fixture.after.length > 0, `${fixture.name} 缺改后全文`);
45
65
  }
46
66
  });
47
67
 
48
- test("三样本全部被正确拦住 → passed:true 并落盘 selftest.json", async () => {
68
+ test("四样本全部命中期望 → passed:true 并落盘 selftest.json", async () => {
49
69
  const dir = tmpDir();
50
70
  const report = await runSelftest({
51
71
  evolutionDir: dir,
52
72
  cwd: dir,
53
73
  fixturesDir: FIXTURES,
54
- spawnFn: spawnBySkill({ "ui-verify-lite": "reject", "rg-retrieval": "reject", "misc-helper": "revise" }),
55
- pollMs: 1,
74
+ spawnFn: spawnBySkill({ "ui-verify-lite": "reject", "rg-retrieval": "reject", "misc-helper": "revise", "log-helper": "pass" }),
56
75
  });
57
76
  assert.equal(report.passed, true);
58
- assert.equal(report.results.length, 3);
59
- assert.deepEqual(report.results.map((item) => item.actual), ["reject", "reject", "revise"]);
77
+ assert.equal(report.results.length, 4);
78
+ assert.deepEqual(report.results.map((item) => item.actual), ["reject", "pass", "reject", "revise"]);
60
79
  const saved = JSON.parse(fs.readFileSync(selftestFile(dir), "utf8")) as { passed: boolean };
61
80
  assert.equal(saved.passed, true);
62
81
  assert.ok(!renderSelftestReport(report).includes("【红】"));
63
82
  });
64
83
 
84
+ test("正样本判 pass 但没给 passed 理由 → 判不合格并写清差在哪(必红自检要能拉住无理由的通过)", async () => {
85
+ const dir = tmpDir();
86
+ const report = await runSelftest({
87
+ evolutionDir: dir,
88
+ cwd: dir,
89
+ fixturesDir: FIXTURES,
90
+ spawnFn: spawnBySkill({ "ui-verify-lite": "reject", "rg-retrieval": "reject", "misc-helper": "revise" }, { dropPassed: true }),
91
+ });
92
+ assert.equal(report.passed, false);
93
+ const good = report.results.find((item) => item.name === "good-description");
94
+ assert.equal(good?.ok, false);
95
+ assert.equal(good?.actual, "pass(缺 passed 理由)");
96
+ const text = renderSelftestReport(report);
97
+ assert.ok(text.startsWith(`【红】${UNTRUSTED_NOTE}`), "红字必须在首行");
98
+ assert.match(text, /✗ good-description/);
99
+ });
100
+
65
101
  test("有一个被 pass → passed:false,报告首行是红字", async () => {
66
102
  const dir = tmpDir();
67
103
  const report = await runSelftest({
68
104
  evolutionDir: dir,
69
105
  cwd: dir,
70
106
  fixturesDir: FIXTURES,
71
- spawnFn: spawnBySkill({ "ui-verify-lite": "reject", "rg-retrieval": "reject", "misc-helper": "pass" }),
72
- pollMs: 1,
107
+ spawnFn: spawnBySkill({ "ui-verify-lite": "reject", "rg-retrieval": "reject", "misc-helper": "pass", "log-helper": "pass" }),
73
108
  });
74
109
  assert.equal(report.passed, false);
75
110
  const text = renderSelftestReport(report);
@@ -83,11 +118,11 @@ test("单个样本就 pass(其余 reject)也判不可信", async () => {
83
118
  evolutionDir: dir,
84
119
  cwd: dir,
85
120
  fixturesDir: FIXTURES,
86
- spawnFn: spawnBySkill({ "ui-verify-lite": "pass", "rg-retrieval": "reject", "misc-helper": "revise" }),
87
- pollMs: 1,
121
+ spawnFn: spawnBySkill({ "ui-verify-lite": "pass", "rg-retrieval": "reject", "misc-helper": "revise", "log-helper": "pass" }),
88
122
  });
89
123
  assert.equal(report.passed, false);
90
- assert.equal(report.results.filter((item) => item.ok).length, 2);
124
+ // 四个样本里 3 条命中(含正样本)也不算过:只要有一个坏样本被放行就是不可信
125
+ assert.equal(report.results.filter((item) => item.ok).length, 3);
91
126
  });
92
127
 
93
128
  test("fixture 缺 change.diff 被跳过,不猜也不补", () => {
@@ -144,7 +144,7 @@ test("徽章:七种状态各一色(待入队灰 / 待审批蓝 / 复盘中
144
144
  assert.match(pendingCase("stale", "跑了 10 小时"), /<span class="badge red" title="跑了 10 小时">卡死<\/span>/);
145
145
  assert.match(doneCase("applied"), /<span class="badge green" title="">已采纳<\/span>/);
146
146
  assert.match(doneCase("rejected"), /<span class="badge darkred" title="">已拒绝<\/span>/);
147
- assert.match(doneCase("rolledback"), /<span class="badge faint" title="">已回滚<\/span>/);
147
+ assert.match(doneCase("rolled-back"), /<span class="badge faint" title="">已回滚<\/span>/);
148
148
  assert.match(doneCase("failed"), /<span class="badge red" title="">失败<\/span>/);
149
149
  });
150
150
 
@@ -43,7 +43,15 @@ function detail(parts: Partial<RunDetail> = {}): RunDetail {
43
43
  diff: DIFF_TEXT,
44
44
  candidateSkill: "demo",
45
45
  candidateText: "---\nname: demo\n---\n# 改后\n",
46
- review: { status: "rejected", verdict: "revise", action: "forbidden", reviewerModel: "deepseek/deepseek-flash" },
46
+ // 默认形态 = 仓库里真实的老 run(354df7db-1794133942):03-review.json 没有 summary / passed
47
+ review: {
48
+ status: "rejected",
49
+ verdict: "revise",
50
+ action: "forbidden",
51
+ reviewerModel: "deepseek/deepseek-flash",
52
+ summary: null,
53
+ legacy: true,
54
+ },
47
55
  blocking: [
48
56
  {
49
57
  issue: "描述承诺大于正文能力",
@@ -52,6 +60,7 @@ function detail(parts: Partial<RunDetail> = {}): RunDetail {
52
60
  quote: "<script> 引用",
53
61
  },
54
62
  ],
63
+ passed: [],
55
64
  gates: [
56
65
  { gate: "gate0", name: "授权核对", ok: true, detail: "" },
57
66
  { gate: "gate3", name: "复验", ok: false, detail: "复验失败" },
@@ -101,11 +110,71 @@ test("评审结论:verdict/action/模型 + blocking 表格(file:line 与引
101
110
  assert.match(html, /verdict <b class="fail">revise<\/b>/);
102
111
  assert.match(html, /action <b>forbidden<\/b>/);
103
112
  assert.match(html, /<code>deepseek\/deepseek-flash<\/code>/);
113
+ assert.match(html, /<h3>阻断意见<\/h3>/);
104
114
  assert.match(html, /<td>描述承诺大于正文能力<\/td><td><code>\.pi\/skills\/demo\/SKILL\.md:3<\/code><\/td>/);
105
115
  assert.match(html, /&lt;script&gt; 引用/, "引用原文也要转义");
106
116
  assert.equal(html.includes('"blocking"'), false, "blocking 要做成表格,不许把 JSON 直接倒出来");
107
117
  });
108
118
 
119
+ test("评审结论:summary 用大一号的字单独一行,排在 verdict 之后、理由表之前", () => {
120
+ const html = renderRunPage(
121
+ detail({ review: { ...detail().review!, summary: "因为 description 承诺了正文没有的能力", legacy: false } }),
122
+ { state: state() },
123
+ );
124
+ assert.match(html, /<p class="verdict-summary">因为 description 承诺了正文没有的能力<\/p>/);
125
+ const summaryAt = html.indexOf('class="verdict-summary"');
126
+ assert.ok(summaryAt > html.indexOf('class="kv-line"'), "摘要要在 verdict 那一行之后");
127
+ assert.ok(summaryAt < html.indexOf("<h3>阻断意见</h3>"), "摘要要在理由表之前");
128
+ });
129
+
130
+ test("评审结论:通过时列通过理由表(通过点 + 证据),不写阻断意见段", () => {
131
+ const html = renderRunPage(
132
+ detail({
133
+ status: "reviewed",
134
+ review: { status: "reviewed", verdict: "pass", action: "auto", reviewerModel: "m", summary: "新增条款只约束脚本用法,未碰原有禁令", legacy: false },
135
+ blocking: [],
136
+ passed: [{ point: "没有删除任何明确禁止项", file: "<b>", line: 5, quote: "一律用 rg" }],
137
+ }),
138
+ { state: state() },
139
+ );
140
+ assert.match(html, /verdict <b class="ok">pass<\/b>/);
141
+ assert.match(html, /<h3>通过理由<\/h3>/);
142
+ assert.match(html, /<th>通过点<\/th>/);
143
+ assert.match(html, /<td>没有删除任何明确禁止项<\/td>/);
144
+ assert.match(html, /&lt;b&gt;:5/, "证据里的尖括号也要转义");
145
+ assert.equal(html.includes("阻断意见"), false, "没有 blocking 就没有阻断意见段");
146
+ });
147
+
148
+ test("评审结论:旧 run 没有 summary / passed 时显示兜底文案,不把空表当结论", () => {
149
+ const html = renderRunPage(detail(), { state: state() });
150
+ assert.match(html, /该 run 由旧版评审产出,没有结论摘要(重跑一次就有)/);
151
+ assert.equal(html.includes("通过理由"), false);
152
+ assert.equal(html.includes("只写了结论,没写理由"), false, "旧 run 已有兜底文案,不再叠一句");
153
+ });
154
+
155
+ test("评审结论:新版评审失败(无 summary,但写了 passed 键)说「没给理由」,不说成旧版", () => {
156
+ const html = renderRunPage(
157
+ detail({
158
+ status: "review_failed",
159
+ review: { status: "review_failed", verdict: "(未写 verdict)", action: "(未写 action)", reviewerModel: "m", summary: null, legacy: false },
160
+ blocking: [],
161
+ passed: [],
162
+ }),
163
+ { state: state() },
164
+ );
165
+ assert.match(html, /这一轮评审只写了结论,没写理由/);
166
+ assert.equal(html.includes("旧版评审产出"), false, "新版评审失败不能标成旧版");
167
+ });
168
+
169
+ test("评审结论:新版评审两个理由表都空时说「只写了结论,没写理由」,不要说成旧版", () => {
170
+ const html = renderRunPage(
171
+ detail({ review: { ...detail().review!, summary: "跳过评审(配置关闭)", legacy: false }, blocking: [], passed: [] }),
172
+ { state: state() },
173
+ );
174
+ assert.match(html, /03-review\.json 里没有 blocking \/ passed:这一轮评审只写了结论,没写理由。/);
175
+ assert.equal(html.includes("旧版评审产出"), false);
176
+ });
177
+
109
178
  test("五闸:逐条 ok/fail 上色,失败那条带说明", () => {
110
179
  const html = renderRunPage(detail(), { state: state() });
111
180
  assert.match(html, /<td><code>gate0<\/code><\/td><td>授权核对<\/td><td class="ok">ok<\/td>/);
@@ -61,6 +61,8 @@ function fullRun(): { evolutionDir: string; runDir: string } {
61
61
  action: "forbidden",
62
62
  reviewerModel: "deepseek/deepseek-flash",
63
63
  reviewedAt: NOW + 5_000,
64
+ // 新版契约:结论要带一句「为什么」,通过理由也落盘
65
+ summary: "描述承诺了正文没有的能力",
64
66
  notes: "证据不足",
65
67
  blocking: [
66
68
  {
@@ -69,6 +71,7 @@ function fullRun(): { evolutionDir: string; runDir: string } {
69
71
  },
70
72
  { issue: "缺少证据", evidence: {} },
71
73
  ],
74
+ passed: [{ point: "没有删除原有禁令", evidence: { file: ".pi/skills/demo/SKILL.md", line: 9, quote: "一律用 rg" } }],
72
75
  });
73
76
  writeJson(path.join(runDir, "04-decision.json"), { status: "rejected", approver: "auto", at: NOW + 6_000 });
74
77
  writeJson(path.join(runDir, "06-verify.json"), {
@@ -133,8 +136,13 @@ test("readRunDetail:六块证据(概要 / diff / 候选全文 / 评审 / 五
133
136
  verdict: "revise",
134
137
  action: "forbidden",
135
138
  reviewerModel: "deepseek/deepseek-flash",
139
+ summary: "描述承诺了正文没有的能力",
140
+ legacy: false,
136
141
  });
137
142
  assert.equal(detail.blocking.length, 2);
143
+ assert.equal(detail.passed.length, 1, "通过理由要能读出来(revise 也可能有没问题的点)");
144
+ assert.equal(detail.passed[0]!.point, "没有删除原有禁令");
145
+ assert.equal(detail.passed[0]!.line, 9);
138
146
  assert.equal(detail.blocking[0]!.line, 3, "证据行号要能直接跳过去");
139
147
  assert.match(detail.blocking[1]!.issue, /缺少证据/);
140
148
  assert.equal(detail.blocking[1]!.file, "", "缺 evidence 的文件位置不能编一个");
@@ -154,6 +162,25 @@ test("readRunDetail:六块证据(概要 / diff / 候选全文 / 评审 / 五
154
162
  assert.equal(detail.notes, "只差两处");
155
163
  });
156
164
 
165
+ test("readRunDetail:旧 run 的 03-review.json 没有 summary / passed 时不抛,读成 null / 空数组", () => {
166
+ // 真实样本:.pi/evolution/runs/354df7db-1794133942(v0.1.9 之前产出的评审记录)
167
+ const { evolutionDir, runDir } = workspace();
168
+ writeJson(path.join(runDir, "run.json"), { runId: RUN_ID, status: "rejected", trigger: "auto", dispatchedAt: NOW });
169
+ writeJson(path.join(runDir, "03-review.json"), {
170
+ status: "rejected",
171
+ verdict: "revise",
172
+ action: "forbidden",
173
+ reviewerModel: "deepseek/deepseek-flash",
174
+ notes: "旧版只有 notes",
175
+ blocking: [{ issue: "旧版意见", evidence: { file: "a.md", line: 1, quote: "x" } }],
176
+ });
177
+ const detail = readRunDetail(evolutionDir, RUN_ID)!;
178
+ assert.equal(detail.review?.summary, null, "缺字段就是 null,不编也不抛");
179
+ assert.equal(detail.review?.legacy, true, "两个键都没有才算旧记录,详情页要据此说旧版");
180
+ assert.deepEqual(detail.passed, []);
181
+ assert.equal(detail.blocking.length, 1);
182
+ });
183
+
157
184
  test("readRunDetail:runId 不合法(../、绝对路径、子路径)一律 null,不读盘越界", () => {
158
185
  const { evolutionDir } = fullRun();
159
186
  for (const runId of ["../x", "../../etc", "/etc/passwd", "a/b", "", "."]) {
@@ -0,0 +1,100 @@
1
+ /**
2
+ * 视图覆盖性单测(用户 2026-11-08:「tab 包含所有状态了吗?」)。
3
+ *
4
+ * `review_failed` / `abandoned` / `unauthorized_write` 这三种终态曾没有任何视图收,
5
+ * 这些 run 在面板上直接消失、只能翻 `runs/` 目录。本文件钉住两件事:
6
+ * 1. 行为:七种终态都真的能进「已处理」,徽章文案与颜色对得上;
7
+ * 2. 结构:`RUN_STATUSES` 每一项至少被五个视图之一的判据清单收 ——
8
+ * 以后新增状态却忘了接视图,这条测试会红并打印漏掉的状态,不会再静默消失。
9
+ *
10
+ * 五个清单都是各视图「判据旁边」的导出常量,而不是测试里另抄一份:抄一份的话,
11
+ * 代码改了测试不会发现(rolledback 那种抄错字符的坑就是这么漏过去的)。
12
+ */
13
+ import assert from "node:assert/strict";
14
+ import * as fs from "node:fs";
15
+ import * as os from "node:os";
16
+ import * as path from "node:path";
17
+ import test from "node:test";
18
+ import { RUN_STATUSES, type RunSummary } from "../lib/runs.ts";
19
+ import { APPLIED_RUN_STATUSES } from "../lib/web/applied.ts";
20
+ import { APPROVALS_RUN_STATUSES } from "../lib/web/approvals.ts";
21
+ import { buildDone, DONE_STATUSES } from "../lib/web/done.ts";
22
+ import { PENDING_RUN_STATUSES } from "../lib/web/pending.ts";
23
+ import { REPORTS_RUN_STATUSES } from "../lib/web/reports.ts";
24
+ import { doneRows } from "../lib/web/render-rows.ts";
25
+
26
+ const NOW = Date.UTC(2026, 10, 8, 12);
27
+
28
+ function workspace(): string {
29
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-coverage-"));
30
+ const evolutionDir = path.join(cwd, ".pi", "evolution");
31
+ fs.mkdirSync(path.join(evolutionDir, "runs"), { recursive: true });
32
+ return evolutionDir;
33
+ }
34
+
35
+ function run(runId: string, status: string, at: number): RunSummary {
36
+ return {
37
+ runId,
38
+ status,
39
+ dispatchedAt: at,
40
+ sessionId: `session-${runId}`,
41
+ sessionFile: null,
42
+ coverage: null,
43
+ pid: null,
44
+ supervisorPid: null,
45
+ };
46
+ }
47
+
48
+ test("三个曾静默消失的终态都进「已处理」,徽章文案与颜色对得上,排序仍是时间倒序", () => {
49
+ const evolutionDir = workspace();
50
+ const runs = [
51
+ run("r-review-failed", "review_failed", NOW),
52
+ run("r-abandoned", "abandoned", NOW + 1_000),
53
+ run("r-unauthorized", "unauthorized_write", NOW + 2_000),
54
+ ];
55
+ const done = buildDone([], runs, evolutionDir);
56
+ assert.deepEqual(
57
+ done.map((item) => item.status),
58
+ ["unauthorized_write", "abandoned", "review_failed"],
59
+ );
60
+ const rows = doneRows(done);
61
+ const badge = (runId: string) => rows.find((row) => row.runId === runId)?.badge;
62
+ assert.deepEqual(badge("r-review-failed"), { text: "评审失败", tone: "red", title: "" });
63
+ assert.equal(badge("r-abandoned")?.text, "已放弃");
64
+ assert.equal(badge("r-abandoned")?.tone, "faint");
65
+ assert.equal(badge("r-unauthorized")?.text, "越权写入");
66
+ assert.equal(badge("r-unauthorized")?.tone, "red");
67
+ });
68
+
69
+ test("覆盖性:RUN_STATUSES 每一项至少被一个视图的判据清单收", () => {
70
+ const views: Record<string, readonly string[]> = {
71
+ 待处理: PENDING_RUN_STATUSES,
72
+ 待审批: APPROVALS_RUN_STATUSES,
73
+ 已采纳: APPLIED_RUN_STATUSES,
74
+ 已处理: DONE_STATUSES,
75
+ 复盘报告: REPORTS_RUN_STATUSES,
76
+ };
77
+ const missing = RUN_STATUSES.filter((status) => !Object.values(views).some((list) => list.includes(status)));
78
+ assert.deepEqual(missing, [], `这些状态没有任何 tab 收,会在面板上直接消失:${missing.join("、")}`);
79
+ });
80
+
81
+ test("「已处理」清单里的每个状态都能真的被 buildDone 收到(防 rolledback 这种抄错字符)", () => {
82
+ const evolutionDir = workspace();
83
+ for (const status of DONE_STATUSES) {
84
+ const done = buildDone([], [run(`r-${status}`, status, NOW)], evolutionDir);
85
+ assert.equal(done.length, 1, `${status} 没进已处理`);
86
+ assert.equal(done[0]?.status, status);
87
+ }
88
+ });
89
+
90
+ test("七个终态都有中文徽章文案,不许回落成原始英文 status", () => {
91
+ const evolutionDir = workspace();
92
+ const runs = DONE_STATUSES.map((status, index) => run(`r-${status}`, status, NOW + index));
93
+ const rows = doneRows(buildDone([], runs, evolutionDir));
94
+ for (const status of DONE_STATUSES) {
95
+ const badge = rows.find((row) => row.runId === `r-${status}`)?.badge;
96
+ assert.ok(badge, `${status} 缺行`);
97
+ assert.notEqual(badge.text, status, `${status} 回落成了原始英文:tone 表的键与真实 status 不一致`);
98
+ assert.match(badge.text, /^[\u4e00-\u9fa5]+$/, `${status} 的文案不是中文`);
99
+ }
100
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-skill-evolve",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "技能进化:采集会话中的技能曝光/加载,异步复盘、生成改进提案并独立评审,附本机 Web 管理面板",
5
5
  "keywords": [
6
6
  "pi-package",