pi-skill-evolve 0.0.0-stage → 0.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.
Files changed (157) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +81 -2
  3. package/extensions/skill-evolve/fixtures/selftest/delete-ban/after.md +10 -0
  4. package/extensions/skill-evolve/fixtures/selftest/delete-ban/change.diff +8 -0
  5. package/extensions/skill-evolve/fixtures/selftest/delete-ban/fixture.json +6 -0
  6. package/extensions/skill-evolve/fixtures/selftest/rg-to-find/after.md +9 -0
  7. package/extensions/skill-evolve/fixtures/selftest/rg-to-find/change.diff +8 -0
  8. package/extensions/skill-evolve/fixtures/selftest/rg-to-find/fixture.json +6 -0
  9. package/extensions/skill-evolve/fixtures/selftest/weak-description/after.md +8 -0
  10. package/extensions/skill-evolve/fixtures/selftest/weak-description/change.diff +8 -0
  11. package/extensions/skill-evolve/fixtures/selftest/weak-description/fixture.json +6 -0
  12. package/extensions/skill-evolve/index.ts +300 -0
  13. package/extensions/skill-evolve/lib/apply-gates.ts +204 -0
  14. package/extensions/skill-evolve/lib/apply-targets.ts +90 -0
  15. package/extensions/skill-evolve/lib/apply.ts +141 -0
  16. package/extensions/skill-evolve/lib/attribution.ts +138 -0
  17. package/extensions/skill-evolve/lib/audit.ts +221 -0
  18. package/extensions/skill-evolve/lib/baseline.ts +176 -0
  19. package/extensions/skill-evolve/lib/child-env.ts +10 -0
  20. package/extensions/skill-evolve/lib/classify-action.ts +161 -0
  21. package/extensions/skill-evolve/lib/command.ts +100 -0
  22. package/extensions/skill-evolve/lib/config-command.ts +38 -0
  23. package/extensions/skill-evolve/lib/config.ts +194 -0
  24. package/extensions/skill-evolve/lib/detect.ts +62 -0
  25. package/extensions/skill-evolve/lib/diff-apply.ts +90 -0
  26. package/extensions/skill-evolve/lib/diff-gen.ts +78 -0
  27. package/extensions/skill-evolve/lib/dispatch.ts +300 -0
  28. package/extensions/skill-evolve/lib/evolution-apply.ts +265 -0
  29. package/extensions/skill-evolve/lib/evolution-model.ts +72 -0
  30. package/extensions/skill-evolve/lib/evolution-observe.ts +82 -0
  31. package/extensions/skill-evolve/lib/evolution-views.ts +89 -0
  32. package/extensions/skill-evolve/lib/evolution.ts +289 -0
  33. package/extensions/skill-evolve/lib/flags.ts +44 -0
  34. package/extensions/skill-evolve/lib/gate0.ts +78 -0
  35. package/extensions/skill-evolve/lib/hard-rules.ts +167 -0
  36. package/extensions/skill-evolve/lib/lock.ts +215 -0
  37. package/extensions/skill-evolve/lib/logfile.ts +52 -0
  38. package/extensions/skill-evolve/lib/mechanical-report.ts +95 -0
  39. package/extensions/skill-evolve/lib/mechanical.ts +136 -0
  40. package/extensions/skill-evolve/lib/metrics.ts +172 -0
  41. package/extensions/skill-evolve/lib/model-config.ts +95 -0
  42. package/extensions/skill-evolve/lib/notify.ts +132 -0
  43. package/extensions/skill-evolve/lib/panel.ts +174 -0
  44. package/extensions/skill-evolve/lib/pending.ts +34 -0
  45. package/extensions/skill-evolve/lib/proc.ts +65 -0
  46. package/extensions/skill-evolve/lib/prompt.ts +80 -0
  47. package/extensions/skill-evolve/lib/proposal.ts +227 -0
  48. package/extensions/skill-evolve/lib/prune-plan.ts +62 -0
  49. package/extensions/skill-evolve/lib/queue.ts +136 -0
  50. package/extensions/skill-evolve/lib/replay-fixture.ts +80 -0
  51. package/extensions/skill-evolve/lib/replay.ts +246 -0
  52. package/extensions/skill-evolve/lib/report.ts +181 -0
  53. package/extensions/skill-evolve/lib/review-post.ts +101 -0
  54. package/extensions/skill-evolve/lib/review-prompt.ts +82 -0
  55. package/extensions/skill-evolve/lib/review-record.ts +63 -0
  56. package/extensions/skill-evolve/lib/review-run.ts +131 -0
  57. package/extensions/skill-evolve/lib/review-spawn.ts +83 -0
  58. package/extensions/skill-evolve/lib/review-supervisor.ts +267 -0
  59. package/extensions/skill-evolve/lib/round.ts +116 -0
  60. package/extensions/skill-evolve/lib/run-watch.ts +34 -0
  61. package/extensions/skill-evolve/lib/runs.ts +208 -0
  62. package/extensions/skill-evolve/lib/selftest.ts +201 -0
  63. package/extensions/skill-evolve/lib/session-index.ts +145 -0
  64. package/extensions/skill-evolve/lib/shutdown-dispatch.ts +177 -0
  65. package/extensions/skill-evolve/lib/single-flight.ts +111 -0
  66. package/extensions/skill-evolve/lib/spawn-plan.ts +103 -0
  67. package/extensions/skill-evolve/lib/stale.ts +217 -0
  68. package/extensions/skill-evolve/lib/telemetry.ts +205 -0
  69. package/extensions/skill-evolve/lib/ui-gate.ts +34 -0
  70. package/extensions/skill-evolve/lib/web/actions.ts +299 -0
  71. package/extensions/skill-evolve/lib/web/activity.ts +57 -0
  72. package/extensions/skill-evolve/lib/web/applied.ts +64 -0
  73. package/extensions/skill-evolve/lib/web/approvals.ts +58 -0
  74. package/extensions/skill-evolve/lib/web/audit-log.ts +115 -0
  75. package/extensions/skill-evolve/lib/web/autostart.ts +177 -0
  76. package/extensions/skill-evolve/lib/web/data.ts +298 -0
  77. package/extensions/skill-evolve/lib/web/diff-lines.ts +141 -0
  78. package/extensions/skill-evolve/lib/web/filter.ts +203 -0
  79. package/extensions/skill-evolve/lib/web/http.ts +68 -0
  80. package/extensions/skill-evolve/lib/web/json-read.ts +33 -0
  81. package/extensions/skill-evolve/lib/web/panel-pid.ts +99 -0
  82. package/extensions/skill-evolve/lib/web/render-controls.ts +156 -0
  83. package/extensions/skill-evolve/lib/web/render-rows.ts +252 -0
  84. package/extensions/skill-evolve/lib/web/render-run.ts +188 -0
  85. package/extensions/skill-evolve/lib/web/render-skills.ts +164 -0
  86. package/extensions/skill-evolve/lib/web/render.ts +136 -0
  87. package/extensions/skill-evolve/lib/web/reports.ts +160 -0
  88. package/extensions/skill-evolve/lib/web/run-detail.ts +234 -0
  89. package/extensions/skill-evolve/lib/web/run-path.ts +21 -0
  90. package/extensions/skill-evolve/lib/web/server.ts +268 -0
  91. package/extensions/skill-evolve/lib/web/shell.ts +87 -0
  92. package/extensions/skill-evolve/lib/web/skills.ts +202 -0
  93. package/extensions/skill-evolve/lib/web/style.ts +107 -0
  94. package/extensions/skill-evolve/lib/web/text.ts +52 -0
  95. package/extensions/skill-evolve/lib/web/trash.ts +36 -0
  96. package/extensions/skill-evolve/lib/weekly.ts +285 -0
  97. package/extensions/skill-evolve/prompts//345/244/215/347/233/230.md +172 -0
  98. package/extensions/skill-evolve/scripts/replay.ts +93 -0
  99. package/extensions/skill-evolve/test/apply.test.ts +278 -0
  100. package/extensions/skill-evolve/test/approve.test.ts +248 -0
  101. package/extensions/skill-evolve/test/attribution.test.ts +133 -0
  102. package/extensions/skill-evolve/test/audit.test.ts +151 -0
  103. package/extensions/skill-evolve/test/baseline.test.ts +132 -0
  104. package/extensions/skill-evolve/test/classify-action.test.ts +133 -0
  105. package/extensions/skill-evolve/test/command.test.ts +267 -0
  106. package/extensions/skill-evolve/test/config-command.test.ts +73 -0
  107. package/extensions/skill-evolve/test/config.test.ts +154 -0
  108. package/extensions/skill-evolve/test/detect.test.ts +71 -0
  109. package/extensions/skill-evolve/test/diff-apply.test.ts +46 -0
  110. package/extensions/skill-evolve/test/dispatch-busy.test.ts +223 -0
  111. package/extensions/skill-evolve/test/dispatch-supervisor.test.ts +88 -0
  112. package/extensions/skill-evolve/test/dispatch.test.ts +306 -0
  113. package/extensions/skill-evolve/test/evolution.test.ts +378 -0
  114. package/extensions/skill-evolve/test/fake-review-spawn.ts +60 -0
  115. package/extensions/skill-evolve/test/hard-rules.test.ts +116 -0
  116. package/extensions/skill-evolve/test/lock.test.ts +138 -0
  117. package/extensions/skill-evolve/test/logfile.test.ts +85 -0
  118. package/extensions/skill-evolve/test/mechanical-wiring.test.ts +217 -0
  119. package/extensions/skill-evolve/test/metrics.test.ts +129 -0
  120. package/extensions/skill-evolve/test/model-config.test.ts +84 -0
  121. package/extensions/skill-evolve/test/notify.test.ts +133 -0
  122. package/extensions/skill-evolve/test/observe.test.ts +100 -0
  123. package/extensions/skill-evolve/test/panel-pid.test.ts +84 -0
  124. package/extensions/skill-evolve/test/panel.test.ts +115 -0
  125. package/extensions/skill-evolve/test/proposal.test.ts +159 -0
  126. package/extensions/skill-evolve/test/queue.test.ts +110 -0
  127. package/extensions/skill-evolve/test/reject.test.ts +134 -0
  128. package/extensions/skill-evolve/test/replay.test.ts +111 -0
  129. package/extensions/skill-evolve/test/report.test.ts +66 -0
  130. package/extensions/skill-evolve/test/review-post.test.ts +92 -0
  131. package/extensions/skill-evolve/test/review-prompt.test.ts +105 -0
  132. package/extensions/skill-evolve/test/review-spawn.test.ts +81 -0
  133. package/extensions/skill-evolve/test/review-supervisor.test.ts +243 -0
  134. package/extensions/skill-evolve/test/selftest.test.ts +113 -0
  135. package/extensions/skill-evolve/test/session-index.test.ts +128 -0
  136. package/extensions/skill-evolve/test/single-flight.test.ts +122 -0
  137. package/extensions/skill-evolve/test/stale.test.ts +272 -0
  138. package/extensions/skill-evolve/test/telemetry.test.ts +145 -0
  139. package/extensions/skill-evolve/test/ui-gate.test.ts +77 -0
  140. package/extensions/skill-evolve/test/web-actions.test.ts +262 -0
  141. package/extensions/skill-evolve/test/web-applied.test.ts +100 -0
  142. package/extensions/skill-evolve/test/web-audit.test.ts +87 -0
  143. package/extensions/skill-evolve/test/web-autostart.test.ts +268 -0
  144. package/extensions/skill-evolve/test/web-data.test.ts +420 -0
  145. package/extensions/skill-evolve/test/web-diff-lines.test.ts +155 -0
  146. package/extensions/skill-evolve/test/web-filter.test.ts +212 -0
  147. package/extensions/skill-evolve/test/web-panel-pid.test.ts +97 -0
  148. package/extensions/skill-evolve/test/web-render-run.test.ts +132 -0
  149. package/extensions/skill-evolve/test/web-render-skills.test.ts +134 -0
  150. package/extensions/skill-evolve/test/web-render.test.ts +300 -0
  151. package/extensions/skill-evolve/test/web-run-detail.test.ts +256 -0
  152. package/extensions/skill-evolve/test/web-server-views.test.ts +113 -0
  153. package/extensions/skill-evolve/test/web-server.test.ts +465 -0
  154. package/extensions/skill-evolve/test/web-shell.test.ts +67 -0
  155. package/extensions/skill-evolve/test/web-skills.test.ts +239 -0
  156. package/extensions/skill-evolve/test/weekly.test.ts +123 -0
  157. package/package.json +34 -4
@@ -0,0 +1,81 @@
1
+ /**
2
+ * 督导启动单测:这一层决定「用 execPath 而不是 pi」「带 --experimental-strip-types」
3
+ * 「env 里去掉 SKILL_EVOLVE_CHILD」三件事,接错任何一条都会让评审永远不启动。
4
+ */
5
+ import { test } from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import * as path from "node:path";
8
+ import { SUPERVISOR_SCRIPT, spawnReviewSupervisor } from "../lib/review-spawn.ts";
9
+
10
+ function capture(): { calls: { command: string; args: string[]; options: Record<string, unknown> }[]; unrefs: number } {
11
+ return { calls: [], unrefs: 0 };
12
+ }
13
+
14
+ test("督导用 node + --experimental-strip-types 起,参数里带 runDir", () => {
15
+ const seen = capture();
16
+ const pid = spawnReviewSupervisor({
17
+ runDir: "/repo/.pi/evolution/runs/run-1",
18
+ cwd: "/repo",
19
+ env: {},
20
+ spawnFn: (command, args, options) => {
21
+ seen.calls.push({ command, args, options });
22
+ return {
23
+ pid: 4321,
24
+ unref: () => {
25
+ seen.unrefs += 1;
26
+ },
27
+ };
28
+ },
29
+ });
30
+ assert.equal(pid, 4321);
31
+ assert.equal(seen.calls[0].command, process.execPath);
32
+ assert.deepEqual(seen.calls[0].args, ["--experimental-strip-types", SUPERVISOR_SCRIPT, "/repo/.pi/evolution/runs/run-1"]);
33
+ assert.equal(seen.calls[0].options.detached, true);
34
+ assert.equal(seen.calls[0].options.stdio, "ignore");
35
+ assert.equal(seen.unrefs, 1, "必须 unref,否则主进程退出被拖住");
36
+ assert.equal(path.basename(SUPERVISOR_SCRIPT), "review-supervisor.ts");
37
+ });
38
+
39
+ test("inherit 不追加 --model,显式模型追加在 runDir 之后", () => {
40
+ const inheritArgs: string[][] = [];
41
+ const explicitArgs: string[][] = [];
42
+ const spawnFn = (command: string, args: string[], options: Record<string, unknown>) => {
43
+ inheritArgs.push(args);
44
+ explicitArgs.push(args);
45
+ return { pid: 1 };
46
+ };
47
+ spawnReviewSupervisor({ runDir: "/r", cwd: "/repo", env: {}, reviewModel: "inherit", spawnFn });
48
+ spawnReviewSupervisor({ runDir: "/r", cwd: "/repo", env: {}, reviewModel: "deepseek/deepseek-v3.2", spawnFn });
49
+ assert.deepEqual(inheritArgs[0], ["--experimental-strip-types", SUPERVISOR_SCRIPT, "/r"]);
50
+ assert.deepEqual(explicitArgs[1], ["--experimental-strip-types", SUPERVISOR_SCRIPT, "/r", "deepseek/deepseek-v3.2"]);
51
+ });
52
+
53
+ test("env 里去掉 SKILL_EVOLVE_CHILD,其他变量照旧带走", () => {
54
+ let env: NodeJS.ProcessEnv = {};
55
+ spawnReviewSupervisor({
56
+ runDir: "/r",
57
+ cwd: "/repo",
58
+ env: { SKILL_EVOLVE_CHILD: "1", PATH: "/usr/bin" },
59
+ spawnFn: (_command, _args, options) => {
60
+ env = options.env as NodeJS.ProcessEnv;
61
+ return { pid: 1 };
62
+ },
63
+ });
64
+ assert.equal(env.SKILL_EVOLVE_CHILD, undefined);
65
+ assert.equal(env.PATH, "/usr/bin");
66
+ });
67
+
68
+ test("起不来只记日志返回 null,不抛", () => {
69
+ const logs: string[] = [];
70
+ const pid = spawnReviewSupervisor({
71
+ runDir: "/r",
72
+ cwd: "/repo",
73
+ env: {},
74
+ log: (msg) => logs.push(msg),
75
+ spawnFn: () => {
76
+ throw new Error("EMFILE");
77
+ },
78
+ });
79
+ assert.equal(pid, null);
80
+ assert.match(logs.join("\n"), /启动失败/);
81
+ });
@@ -0,0 +1,243 @@
1
+ /**
2
+ * 评审督导单测(设计稿 12):不真起 pi、不真等 900/300 秒。
3
+ *
4
+ * 覆盖四条最容易静默失败的路径:等提案超时、评审进程超时、invalid 重评、机械复验一票否决。
5
+ * 文件系统用内存 Map 替代,readText/writeText 都可注入。
6
+ */
7
+ import { test } from "node:test";
8
+ import assert from "node:assert/strict";
9
+ import * as path from "node:path";
10
+ import { compileRules } from "../lib/hard-rules.ts";
11
+ import { checkCandidateFrontmatter, parseProposal, runReviewSupervisor } from "../lib/review-supervisor.ts";
12
+ import { fakeClock, fakeReviewSpawn, hangingReviewSpawn } from "./fake-review-spawn.ts";
13
+
14
+ const RUN_DIR = "/repo/.pi/evolution/runs/run-1";
15
+ const SKILL_FILE = "/repo/.pi/skills/demo/SKILL.md";
16
+ const SKILL_TEXT = ["---", "name: demo", "description: 旧描述", "---", "", "检索一律用 rg,禁止 find。"].join("\n");
17
+ const DESC_DIFF = [
18
+ "--- a/.pi/skills/demo/SKILL.md",
19
+ "+++ b/.pi/skills/demo/SKILL.md",
20
+ "@@ -3,1 +3,1 @@",
21
+ "-description: 旧描述",
22
+ "+description: 检索日志时用 rg,禁止 find 全盘扫",
23
+ ].join("\n");
24
+ const PASS = JSON.stringify({ verdict: "pass", blocking: [], notes: "没问题" });
25
+ const RUN_JSON = JSON.stringify({ runId: "run-1", status: "dispatched", cwd: "/repo", stages: [{ name: "analyze", model: "deepseek/flash", usage: {}, ms: 10 }] });
26
+ /** 只放一条与仓库同形的 rg-over-find:用例不依赖 .pi/evolution/rules 文件是否在场 */
27
+ const RULES = compileRules({
28
+ rules: [{ id: "rg-over-find", level: "P0", pattern: "\\bfind\\s+[.~/]", expect: "禁止 find 全盘扫", why: "慢" }],
29
+ });
30
+
31
+ function memFs(files: Record<string, string>) {
32
+ const store = new Map(Object.entries(files).map(([file, text]) => [path.resolve(file), text]));
33
+ return {
34
+ readText: (file: string) => store.get(path.resolve(file)),
35
+ writeText: (file: string, text: string) => {
36
+ store.set(path.resolve(file), text);
37
+ },
38
+ text: (file: string) => store.get(path.resolve(file)),
39
+ json: (file: string) => {
40
+ const raw = store.get(path.resolve(file));
41
+ return raw === undefined ? undefined : (JSON.parse(raw) as Record<string, unknown>);
42
+ },
43
+ };
44
+ }
45
+
46
+ function baseFiles(): Record<string, string> {
47
+ return {
48
+ [`${RUN_DIR}/run.json`]: RUN_JSON,
49
+ [`${RUN_DIR}/02-proposal.json`]: JSON.stringify({ type: "improve", skill: "demo", rationale: "理由不该进评审", evidence: [3], diffFile: "02-proposal.diff" }),
50
+ [`${RUN_DIR}/02-proposal.diff`]: DESC_DIFF,
51
+ [SKILL_FILE]: SKILL_TEXT,
52
+ };
53
+ }
54
+
55
+ test("正常路径:pass → reviewed,写 03-review.json/.md 并回写 run.json", async () => {
56
+ const fs = memFs(baseFiles());
57
+ const prompts: string[] = [];
58
+ const outcome = await runReviewSupervisor({
59
+ runDir: RUN_DIR,
60
+ evolutionDir: "/repo/.pi/evolution",
61
+ reviewModel: "deepseek/flash",
62
+ readText: fs.readText,
63
+ writeText: fs.writeText,
64
+ spawnFn: fakeReviewSpawn([PASS]),
65
+ pollMs: 1,
66
+ // 记下发给评审的 prompt,验证独立性(不给 rationale)
67
+ ...{},
68
+ });
69
+ assert.equal(outcome.status, "reviewed");
70
+ const review = fs.json(`${RUN_DIR}/03-review.json`) as Record<string, unknown>;
71
+ assert.equal(review.status, "reviewed");
72
+ assert.equal(review.verdict, "pass");
73
+ assert.equal(review.reviewerModel, "deepseek/flash");
74
+ assert.equal(review.proposerModel, "deepseek/flash");
75
+ assert.equal(review.sameModel, true);
76
+ assert.equal(review.action, "auto");
77
+ const md = fs.text(`${RUN_DIR}/03-review.md`) ?? "";
78
+ assert.ok(md.startsWith("> 同模型自评"), "同模型自评警告必须在首行");
79
+ const run = fs.json(`${RUN_DIR}/run.json`) as Record<string, unknown>;
80
+ assert.equal(run.status, "reviewed");
81
+ assert.deepEqual((run.review as Record<string, unknown>).sameModel, true);
82
+ assert.equal(prompts.length, 0);
83
+ });
84
+
85
+ test("评审 prompt 里不含提案 rationale", async () => {
86
+ const fs = memFs(baseFiles());
87
+ let captured = "";
88
+ const spawnFn = ((_cmd: string, args: string[]) => {
89
+ captured = args[3] ?? "";
90
+ return fakeReviewSpawn([PASS])(_cmd, args, {});
91
+ }) as unknown as Parameters<typeof runReviewSupervisor>[0]["spawnFn"];
92
+ await runReviewSupervisor({
93
+ runDir: RUN_DIR,
94
+ evolutionDir: "/repo/.pi/evolution",
95
+ readText: fs.readText,
96
+ writeText: fs.writeText,
97
+ ...(spawnFn ? { spawnFn } : {}),
98
+ pollMs: 1,
99
+ });
100
+ assert.ok(captured.length > 0, "应当真的组装并传了 prompt");
101
+ assert.ok(!captured.includes("理由不该进评审"), "提案 rationale 不得进评审 prompt");
102
+ assert.ok(!captured.includes("01-findings"));
103
+ });
104
+
105
+ test("提案永不出现:900s 后写 skipped/proposal-timeout", async () => {
106
+ const fs = memFs({ [`${RUN_DIR}/run.json`]: RUN_JSON });
107
+ const clock = fakeClock();
108
+ const outcome = await runReviewSupervisor({
109
+ runDir: RUN_DIR,
110
+ evolutionDir: "/repo/.pi/evolution",
111
+ readText: fs.readText,
112
+ writeText: fs.writeText,
113
+ now: clock.now,
114
+ sleep: clock.sleep,
115
+ pollMs: 2000,
116
+ });
117
+ assert.equal(outcome.status, "skipped");
118
+ const review = fs.json(`${RUN_DIR}/03-review.json`) as Record<string, unknown>;
119
+ assert.equal(review.status, "skipped");
120
+ assert.equal(review.reason, "proposal-timeout");
121
+ assert.ok(clock.elapsed() >= 900_000, `应当等满 900s,实际推进 ${clock.elapsed()}ms`);
122
+ });
123
+
124
+ test("提案文件是坏 JSON:skipped/proposal-unreadable", async () => {
125
+ const fs = memFs({ ...baseFiles(), [`${RUN_DIR}/02-proposal.json`]: "{坏掉的" });
126
+ const clock = fakeClock();
127
+ const outcome = await runReviewSupervisor({
128
+ runDir: RUN_DIR,
129
+ evolutionDir: "/repo/.pi/evolution",
130
+ readText: fs.readText,
131
+ writeText: fs.writeText,
132
+ now: clock.now,
133
+ sleep: clock.sleep,
134
+ });
135
+ assert.equal(outcome.status, "skipped");
136
+ assert.equal((fs.json(`${RUN_DIR}/03-review.json`) as Record<string, unknown>).reason, "proposal-unreadable");
137
+ });
138
+
139
+ test("评审进程超时 300s → review_failed 且 kill 被调用", async () => {
140
+ const fs = memFs(baseFiles());
141
+ const clock = fakeClock();
142
+ const outcome = await runReviewSupervisor({
143
+ runDir: RUN_DIR,
144
+ evolutionDir: "/repo/.pi/evolution",
145
+ readText: fs.readText,
146
+ writeText: fs.writeText,
147
+ spawnFn: hangingReviewSpawn(),
148
+ now: clock.now,
149
+ sleep: clock.sleep,
150
+ pollMs: 250,
151
+ });
152
+ assert.equal(outcome.status, "review_failed");
153
+ assert.match(outcome.record.detail ?? "", /review-timeout/);
154
+ assert.equal(outcome.record.attempts, 1, "超时不重试");
155
+ });
156
+
157
+ test("invalid 一次后重评成功:attempts=2 且 reviewed", async () => {
158
+ const fs = memFs(baseFiles());
159
+ const outcome = await runReviewSupervisor({
160
+ runDir: RUN_DIR,
161
+ evolutionDir: "/repo/.pi/evolution",
162
+ readText: fs.readText,
163
+ writeText: fs.writeText,
164
+ spawnFn: fakeReviewSpawn([JSON.stringify({ verdict: "maybe", blocking: [] }), PASS]),
165
+ pollMs: 1,
166
+ });
167
+ assert.equal(outcome.status, "reviewed");
168
+ assert.equal(outcome.record.attempts, 2);
169
+ });
170
+
171
+ test("两次都 invalid → review_failed,run.json 也是 review_failed", async () => {
172
+ const fs = memFs(baseFiles());
173
+ const bad = JSON.stringify({ verdict: "maybe", blocking: [] });
174
+ const outcome = await runReviewSupervisor({
175
+ runDir: RUN_DIR,
176
+ evolutionDir: "/repo/.pi/evolution",
177
+ readText: fs.readText,
178
+ writeText: fs.writeText,
179
+ spawnFn: fakeReviewSpawn([bad, bad]),
180
+ pollMs: 1,
181
+ });
182
+ assert.equal(outcome.status, "review_failed");
183
+ assert.equal(outcome.record.attempts, 2);
184
+ assert.match(outcome.record.detail ?? "", /bad-verdict/);
185
+ assert.equal((fs.json(`${RUN_DIR}/run.json`) as Record<string, unknown>).status, "review_failed");
186
+ });
187
+
188
+ test("机械复验:diff 新增 P0 命中时一票 reject(即使评审说 pass)", async () => {
189
+ const badDiff = [
190
+ "--- a/.pi/skills/demo/SKILL.md",
191
+ "+++ b/.pi/skills/demo/SKILL.md",
192
+ "@@ -6,1 +6,1 @@",
193
+ "-检索一律用 rg,禁止 find。",
194
+ "+找不到就 find . -name 文件名 全盘扫一遍。",
195
+ ].join("\n");
196
+ const fs = memFs({ ...baseFiles(), [`${RUN_DIR}/02-proposal.diff`]: badDiff });
197
+ const outcome = await runReviewSupervisor({
198
+ runDir: RUN_DIR,
199
+ evolutionDir: "/repo/.pi/evolution",
200
+ rules: RULES,
201
+ readText: fs.readText,
202
+ writeText: fs.writeText,
203
+ spawnFn: fakeReviewSpawn([PASS]),
204
+ pollMs: 1,
205
+ });
206
+ assert.equal(outcome.status, "rejected");
207
+ assert.equal(outcome.record.verdict, "reject");
208
+ assert.match(outcome.record.detail ?? "", /frontmatter-invalid: 新增 P0 命中/);
209
+ });
210
+
211
+ test("create 提案:candidate frontmatter 缺 description → reject", async () => {
212
+ const candidate = ["---", "name: demo", "---", "正文"].join("\n");
213
+ const fs = memFs({
214
+ [`${RUN_DIR}/run.json`]: RUN_JSON,
215
+ [`${RUN_DIR}/02-proposal.json`]: JSON.stringify({ type: "create", skill: "demo", candidateFile: "candidate/demo/SKILL.md" }),
216
+ [`${RUN_DIR}/candidate/demo/SKILL.md`]: candidate,
217
+ });
218
+ const outcome = await runReviewSupervisor({
219
+ runDir: RUN_DIR,
220
+ evolutionDir: "/repo/.pi/evolution",
221
+ rules: RULES,
222
+ readText: fs.readText,
223
+ writeText: fs.writeText,
224
+ spawnFn: fakeReviewSpawn([PASS]),
225
+ pollMs: 1,
226
+ });
227
+ assert.equal(outcome.status, "rejected");
228
+ assert.match(outcome.record.detail ?? "", /frontmatter 缺 description/);
229
+ assert.equal(outcome.record.action, "approval");
230
+ });
231
+
232
+ test("parseProposal 只认真实字段,缺 type/skill 返回 undefined", () => {
233
+ assert.equal(parseProposal(undefined), undefined);
234
+ assert.equal(parseProposal("{}"), undefined);
235
+ assert.equal(parseProposal(JSON.stringify({ type: "improve", skill: "demo" }))?.skill, "demo");
236
+ });
237
+
238
+ test("checkCandidateFrontmatter 三种坏法都能识别", () => {
239
+ assert.equal(checkCandidateFrontmatter("没有 frontmatter"), "缺 frontmatter");
240
+ assert.equal(checkCandidateFrontmatter("---\ndescription: x\n---\n"), "frontmatter 缺 name");
241
+ assert.equal(checkCandidateFrontmatter("---\nname: x\n---\n"), "frontmatter 缺 description");
242
+ assert.equal(checkCandidateFrontmatter("---\nname: x\ndescription: y\n---\n"), null);
243
+ });
@@ -0,0 +1,113 @@
1
+ /**
2
+ * 必红自检单测(设计稿 12.4):三个坏样本必须被拦住,任何一条被 pass 就算评审器不可信。
3
+ *
4
+ * 假 spawn 按 prompt 里的「技能名」回不同结论,所以用例不受 fixtures 目录枚举顺序影响。
5
+ */
6
+ import { test } from "node:test";
7
+ import assert from "node:assert/strict";
8
+ import * as fs from "node:fs";
9
+ import * as os from "node:os";
10
+ import * as path from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import type { ReviewSpawn } from "../lib/review-run.ts";
13
+ import { loadFixtures, renderSelftestReport, runSelftest, selftestFile, UNTRUSTED_NOTE } from "../lib/selftest.ts";
14
+ import { FakeReviewChild } from "./fake-review-spawn.ts";
15
+
16
+ const FIXTURES = fileURLToPath(new URL("../fixtures/selftest", import.meta.url));
17
+
18
+ function tmpDir(): string {
19
+ return fs.mkdtempSync(path.join(os.tmpdir(), "sk-selftest-"));
20
+ }
21
+
22
+ /** 按 prompt 末尾的「技能名:x」挑结论 */
23
+ function spawnBySkill(verdicts: Record<string, string>): ReviewSpawn {
24
+ return (_command, args) => {
25
+ const prompt = args[3] ?? "";
26
+ const skill = Object.keys(verdicts).find((name) => prompt.includes(`技能名:${name}`));
27
+ const reply = JSON.stringify({ verdict: skill ? verdicts[skill] : "pass", blocking: [], notes: "自检" });
28
+ const child = new FakeReviewChild();
29
+ setTimeout(() => {
30
+ child.stdout.emit("data", reply);
31
+ child.emit("exit", 0);
32
+ }, 1);
33
+ return child;
34
+ };
35
+ }
36
+
37
+ test("三个坏样本都能被读到,期望分别是 reject/reject/revise", () => {
38
+ const fixtures = loadFixtures(FIXTURES);
39
+ assert.deepEqual(fixtures.map((item) => item.name).sort(), ["delete-ban", "rg-to-find", "weak-description"]);
40
+ 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" });
42
+ for (const fixture of fixtures) {
43
+ assert.ok(fixture.diff.length > 0, `${fixture.name} 缺 diff`);
44
+ assert.ok(fixture.after.length > 0, `${fixture.name} 缺改后全文`);
45
+ }
46
+ });
47
+
48
+ test("三样本全部被正确拦住 → passed:true 并落盘 selftest.json", async () => {
49
+ const dir = tmpDir();
50
+ const report = await runSelftest({
51
+ evolutionDir: dir,
52
+ cwd: dir,
53
+ fixturesDir: FIXTURES,
54
+ spawnFn: spawnBySkill({ "ui-verify-lite": "reject", "rg-retrieval": "reject", "misc-helper": "revise" }),
55
+ pollMs: 1,
56
+ });
57
+ assert.equal(report.passed, true);
58
+ assert.equal(report.results.length, 3);
59
+ assert.deepEqual(report.results.map((item) => item.actual), ["reject", "reject", "revise"]);
60
+ const saved = JSON.parse(fs.readFileSync(selftestFile(dir), "utf8")) as { passed: boolean };
61
+ assert.equal(saved.passed, true);
62
+ assert.ok(!renderSelftestReport(report).includes("【红】"));
63
+ });
64
+
65
+ test("有一个被 pass → passed:false,报告首行是红字", async () => {
66
+ const dir = tmpDir();
67
+ const report = await runSelftest({
68
+ evolutionDir: dir,
69
+ cwd: dir,
70
+ fixturesDir: FIXTURES,
71
+ spawnFn: spawnBySkill({ "ui-verify-lite": "reject", "rg-retrieval": "reject", "misc-helper": "pass" }),
72
+ pollMs: 1,
73
+ });
74
+ assert.equal(report.passed, false);
75
+ const text = renderSelftestReport(report);
76
+ assert.ok(text.startsWith(`【红】${UNTRUSTED_NOTE}`), `红字必须在首行,实际:${text.slice(0, 40)}`);
77
+ assert.match(text, /✗ weak-description/);
78
+ });
79
+
80
+ test("单个样本就 pass(其余 reject)也判不可信", async () => {
81
+ const dir = tmpDir();
82
+ const report = await runSelftest({
83
+ evolutionDir: dir,
84
+ cwd: dir,
85
+ fixturesDir: FIXTURES,
86
+ spawnFn: spawnBySkill({ "ui-verify-lite": "pass", "rg-retrieval": "reject", "misc-helper": "revise" }),
87
+ pollMs: 1,
88
+ });
89
+ assert.equal(report.passed, false);
90
+ assert.equal(report.results.filter((item) => item.ok).length, 2);
91
+ });
92
+
93
+ test("fixture 缺 change.diff 被跳过,不猜也不补", () => {
94
+ const dir = tmpDir();
95
+ fs.mkdirSync(path.join(dir, "broken"), { recursive: true });
96
+ fs.writeFileSync(path.join(dir, "broken", "fixture.json"), JSON.stringify({ name: "broken", skill: "x", expected: "reject" }));
97
+ fs.writeFileSync(path.join(dir, "broken", "after.md"), "正文");
98
+ const logs: string[] = [];
99
+ const fixtures = loadFixtures(dir, (msg) => logs.push(msg));
100
+ assert.deepEqual(fixtures, []);
101
+ assert.match(logs.join("\n"), /缺 change\.diff/);
102
+ });
103
+
104
+ test("expected 非法同样跳过", () => {
105
+ const dir = tmpDir();
106
+ fs.mkdirSync(path.join(dir, "bad"), { recursive: true });
107
+ fs.writeFileSync(path.join(dir, "bad", "fixture.json"), JSON.stringify({ name: "bad", skill: "x", expected: "maybe" }));
108
+ fs.writeFileSync(path.join(dir, "bad", "change.diff"), "+++ b/x\n");
109
+ fs.writeFileSync(path.join(dir, "bad", "after.md"), "正文");
110
+ const logs: string[] = [];
111
+ assert.deepEqual(loadFixtures(dir, (msg) => logs.push(msg)), []);
112
+ assert.match(logs.join("\n"), /expected 非法/);
113
+ });
@@ -0,0 +1,128 @@
1
+ /**
2
+ * 会话索引单测(零依赖,node --test)。
3
+ *
4
+ * 运行:
5
+ * cd .pi/extensions/skill-evolve
6
+ * node --experimental-strip-types --test test/session-index.test.ts
7
+ *
8
+ * 覆盖:行号 1 起算、compaction 标记、坏行只计数不崩、摘要 ≤80 字、
9
+ * 以及「索引里不出现会话原文」这条铁律(设计稿 5.4)。
10
+ */
11
+ import { test } from "node:test";
12
+ import assert from "node:assert/strict";
13
+ import * as fs from "node:fs";
14
+ import * as os from "node:os";
15
+ import * as path from "node:path";
16
+
17
+ import { buildSessionIndex, excerptOf, writeSessionIndex } from "../lib/session-index.ts";
18
+ import { directQueue } from "../lib/telemetry.ts";
19
+
20
+ const LONG_TEXT = "这是一段用来撑长度的用户原话。".repeat(20);
21
+
22
+ /** 贴近真实会话的一小段 JSONL:头部 + 用户 + 助手带工具调用 + 工具结果 + compaction */
23
+ const SESSION_JSONL = [
24
+ JSON.stringify({ type: "session", version: 3, id: "sess-1", timestamp: "2026-11-08T02:48:52.675Z", cwd: "/repo" }),
25
+ JSON.stringify({ type: "model_change", id: "e0", timestamp: "2026-11-08T02:48:53.000Z" }),
26
+ JSON.stringify({
27
+ type: "message",
28
+ id: "e1",
29
+ parentId: "e0",
30
+ timestamp: "2026-11-08T02:49:00.000Z",
31
+ message: { role: "user", content: [{ type: "text", text: "帮我把需求 R-20261108 推进到开发中" }] },
32
+ }),
33
+ JSON.stringify({
34
+ type: "message",
35
+ id: "e2",
36
+ parentId: "e1",
37
+ timestamp: "2026-11-08T02:49:05.000Z",
38
+ message: {
39
+ role: "assistant",
40
+ content: [
41
+ { type: "text", text: "先看流程" },
42
+ { type: "toolCall", id: "call-1", name: "read", arguments: { path: ".pi/skills/kb-test-map/SKILL.md" } },
43
+ ],
44
+ },
45
+ }),
46
+ JSON.stringify({
47
+ type: "message",
48
+ id: "e3",
49
+ parentId: "e2",
50
+ timestamp: "2026-11-08T02:49:06.000Z",
51
+ message: { role: "toolResult", toolCallId: "call-1", toolName: "read", isError: false, content: [{ type: "text", text: "# kb-test-map" }] },
52
+ }),
53
+ JSON.stringify({ type: "compaction", id: "e4", timestamp: "2026-11-08T02:50:00.000Z", summary: "先前讨论了需求推进规则", firstKeptEntryId: "e2", tokensBefore: 42000 }),
54
+ "",
55
+ ].join("\n");
56
+
57
+ test("buildSessionIndex:行号 1 起算、类型与角色正确", () => {
58
+ const index = buildSessionIndex(SESSION_JSONL);
59
+ assert.equal(index.stats.lines, 6);
60
+ assert.equal(index.stats.bytes, Buffer.byteLength(SESSION_JSONL, "utf8"));
61
+ assert.equal(index.lines[0].line, 1);
62
+ assert.equal(index.lines[0].type, "session");
63
+ assert.equal(index.lines[0].excerpt, "/repo");
64
+ assert.equal(index.lines[2].line, 3);
65
+ assert.equal(index.lines[2].role, "user");
66
+ assert.equal(index.lines[2].excerpt.includes("推进到开发中"), true);
67
+ assert.equal(index.lines[2].ts, "2026-11-08T02:49:00.000Z");
68
+ // 助手消息里的工具调用占位进摘要,方便复盘按行号回读
69
+ assert.equal(index.lines[3].excerpt.includes("[调用 read]"), true);
70
+ // 工具结果摘要前缀工具名
71
+ assert.equal(index.lines[4].excerpt.startsWith("[read]"), true);
72
+ });
73
+
74
+ test("buildSessionIndex:compaction 被标记且摘要进索引", () => {
75
+ const index = buildSessionIndex(SESSION_JSONL);
76
+ assert.equal(index.stats.hasCompaction, true);
77
+ const compaction = index.lines.find((line) => line.type === "compaction");
78
+ assert.equal(compaction?.line, 6);
79
+ assert.equal(compaction?.excerpt, "先前讨论了需求推进规则");
80
+ });
81
+
82
+ test("buildSessionIndex:坏行只计数不进索引,不整份失败", () => {
83
+ const index = buildSessionIndex(`${SESSION_JSONL}\n{"type":"message","id":broken\n`);
84
+ assert.equal(index.stats.lines, 7);
85
+ assert.equal(index.lines.length, 6);
86
+ });
87
+
88
+ test("buildSessionIndex:空行不计入 lines 统计", () => {
89
+ const index = buildSessionIndex("\n\n\n");
90
+ assert.equal(index.stats.lines, 0);
91
+ assert.deepEqual(index.lines, []);
92
+ assert.equal(index.stats.hasCompaction, false);
93
+ });
94
+
95
+ test("excerptOf:折叠空白并截到 80 字", () => {
96
+ assert.equal(excerptOf("a\n\n b\tc"), "a b c");
97
+ const short = excerptOf(LONG_TEXT);
98
+ assert.equal(short.length, 80);
99
+ assert.equal(short.endsWith("…"), true);
100
+ assert.equal(excerptOf("x".repeat(80)).length, 80);
101
+ });
102
+
103
+ test("索引里不出现会话原文(长文本被截断,原文不入库)", () => {
104
+ const long = JSON.stringify({ type: "message", id: "x", message: { role: "user", content: [{ type: "text", text: LONG_TEXT }] } });
105
+ const index = buildSessionIndex(long);
106
+ assert.equal(index.lines.length, 1);
107
+ assert.equal(index.lines[0].chars, long.length);
108
+ assert.equal(JSON.stringify(index).includes(LONG_TEXT), false);
109
+ });
110
+
111
+ test("writeSessionIndex:生成 index.json 并从原文取字节数", async () => {
112
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-index-"));
113
+ const sessionFile = path.join(dir, "session.jsonl");
114
+ fs.writeFileSync(sessionFile, SESSION_JSONL, "utf8");
115
+ const outDir = path.join(dir, "00-session");
116
+ const index = await writeSessionIndex({ sessionFile, outDir, queue: directQueue });
117
+ assert.equal(index?.stats.lines, 6);
118
+ const written = JSON.parse(fs.readFileSync(path.join(outDir, "index.json"), "utf8"));
119
+ assert.equal(written.stats.hasCompaction, true);
120
+ assert.equal(written.lines.length, 6);
121
+ });
122
+
123
+ test("writeSessionIndex:原文读不到时返回 null(调用方降级 telemetry-only)", async () => {
124
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-index-"));
125
+ const index = await writeSessionIndex({ sessionFile: path.join(dir, "不存在.jsonl"), outDir: path.join(dir, "00-session"), queue: directQueue, log: () => {} });
126
+ assert.equal(index, null);
127
+ assert.equal(fs.existsSync(path.join(dir, "00-session", "index.json")), false);
128
+ });