pi-skill-evolve 0.0.0-stage → 0.1.2

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 +123 -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,420 @@
1
+ /**
2
+ * 只读 Web 面板的数据聚合:空目录 / 坏 JSON / 三类待处理 / 已处理合并 / 报告字段 / 上限截断。
3
+ * 全部喂临时目录,不起 server、不碰真仓库。
4
+ */
5
+ import assert from "node:assert/strict";
6
+ import * as fs from "node:fs";
7
+ import * as os from "node:os";
8
+ import * as path from "node:path";
9
+ import test from "node:test";
10
+ import { collectControls } from "../lib/web/activity.ts";
11
+ import { collectWebState, DONE_LIMIT } from "../lib/web/data.ts";
12
+ import { APPLIED_LIMIT } from "../lib/web/applied.ts";
13
+
14
+ const NOW = Date.UTC(2026, 10, 8, 12);
15
+
16
+ function workspace(): string {
17
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-webdata-"));
18
+ const evolutionDir = path.join(cwd, ".pi", "evolution");
19
+ fs.mkdirSync(path.join(evolutionDir, "runs"), { recursive: true });
20
+ return evolutionDir;
21
+ }
22
+
23
+ function writeRun(evolutionDir: string, runId: string, record: Record<string, unknown> = {}): string {
24
+ const runDir = path.join(evolutionDir, "runs", runId);
25
+ fs.mkdirSync(runDir, { recursive: true });
26
+ const base = { runId, status: "dispatched", dispatchedAt: NOW, sessionId: "s", trigger: "auto" };
27
+ fs.writeFileSync(path.join(runDir, "run.json"), JSON.stringify({ ...base, ...record }), "utf8");
28
+ return runDir;
29
+ }
30
+
31
+ function writeQueue(evolutionDir: string, items: Record<string, unknown>[]): void {
32
+ fs.mkdirSync(evolutionDir, { recursive: true });
33
+ fs.writeFileSync(path.join(evolutionDir, "queue.json"), JSON.stringify(items), "utf8");
34
+ }
35
+
36
+ function writeJson(file: string, value: unknown): void {
37
+ fs.mkdirSync(path.dirname(file), { recursive: true });
38
+ fs.writeFileSync(file, JSON.stringify(value), "utf8");
39
+ }
40
+
41
+ function item(runId: string, extra: Record<string, unknown> = {}): Record<string, unknown> {
42
+ return {
43
+ runId,
44
+ level: "approval",
45
+ skill: "demo",
46
+ targets: [".pi/skills/demo/SKILL.md"],
47
+ createdAt: NOW,
48
+ status: "pending",
49
+ ...extra,
50
+ };
51
+ }
52
+
53
+ test("collectWebState:空目录返回五个空集合与全零 summary", () => {
54
+ const state = collectWebState({ evolutionDir: workspace() });
55
+ assert.deepEqual(state.pending, []);
56
+ assert.deepEqual(state.approvals, []);
57
+ assert.deepEqual(state.applied, []);
58
+ assert.deepEqual(state.done, []);
59
+ assert.deepEqual(state.reports, []);
60
+ assert.deepEqual(state.summary, { reviewing: 0, stale: 0, pendingApprovals: 0, appliedTotal: 0, rollbackRate: 0 });
61
+ });
62
+
63
+ test("collectWebState:队列 / run.json / findings 全是坏 JSON 也不抛,一律当空", () => {
64
+ const evolutionDir = workspace();
65
+ const runDir = path.join(evolutionDir, "runs", "bad");
66
+ fs.mkdirSync(runDir, { recursive: true });
67
+ fs.writeFileSync(path.join(runDir, "run.json"), "{ 坏掉", "utf8");
68
+ fs.writeFileSync(path.join(runDir, "01-findings.json"), "{ 也坏", "utf8");
69
+ fs.writeFileSync(path.join(evolutionDir, "queue.json"), "not json", "utf8");
70
+ const state = collectWebState({ evolutionDir });
71
+ assert.deepEqual(state.pending, [], "坏队列不产生待处理");
72
+ assert.equal(state.reports.length, 0, "坏 findings 不进报告");
73
+ assert.equal(state.summary.appliedTotal, 0);
74
+ });
75
+
76
+ test("pending:只收 approval 级 pending,detail 里带采纳命令", () => {
77
+ const evolutionDir = workspace();
78
+ writeRun(evolutionDir, "r1", { status: "reviewed" });
79
+ writeQueue(evolutionDir, [item("r1")]);
80
+ const state = collectWebState({ evolutionDir });
81
+ assert.equal(state.pending.length, 1);
82
+ assert.equal(state.pending[0]!.kind, "approval");
83
+ assert.equal(state.pending[0]!.skill, "demo");
84
+ assert.match(state.pending[0]!.detail, /\/skill-evolve approve r1/);
85
+ assert.equal(state.summary.pendingApprovals, 1);
86
+ });
87
+
88
+ test("pending:auto 级 pending 不算待批(不需要人),也不进待处理列表", () => {
89
+ const evolutionDir = workspace();
90
+ writeRun(evolutionDir, "r2", { status: "reviewed" });
91
+ writeQueue(evolutionDir, [item("r2", { level: "auto" })]);
92
+ const state = collectWebState({ evolutionDir });
93
+ assert.deepEqual(state.pending, []);
94
+ assert.equal(state.summary.pendingApprovals, 0);
95
+ });
96
+
97
+ test("pending:stale 的 run 单列一类,带原因与日志末行", () => {
98
+ const evolutionDir = workspace();
99
+ const runDir = writeRun(evolutionDir, "dead", { status: "stale", pid: 4242 });
100
+ writeJson(path.join(runDir, "run-error.json"), {
101
+ reason: "timeout",
102
+ at: NOW + 1_000,
103
+ childLogTail: ["启动复盘子进程", "mcp timeout"],
104
+ });
105
+ const state = collectWebState({ evolutionDir });
106
+ assert.equal(state.pending.length, 1);
107
+ assert.equal(state.pending[0]!.kind, "stale");
108
+ assert.match(state.pending[0]!.detail, /超时/);
109
+ assert.match(state.pending[0]!.detail, /mcp timeout/);
110
+ assert.equal(state.summary.stale, 1);
111
+ });
112
+
113
+ test("pending:reviewed 且未入队算待入队,已在队列里的(含 superseded)不重复出现", () => {
114
+ const evolutionDir = workspace();
115
+ writeRun(evolutionDir, "r4", { status: "reviewed" });
116
+ writeRun(evolutionDir, "r5", { status: "reviewed" });
117
+ writeQueue(evolutionDir, [item("r5", { status: "superseded" })]);
118
+ const state = collectWebState({ evolutionDir });
119
+ assert.deepEqual(
120
+ state.pending.map((entry) => `${entry.runId}:${entry.kind}`),
121
+ ["r4:reviewed"],
122
+ );
123
+ });
124
+
125
+ test("done:只剩回滚 / 失败 / 否决 / 被顶替四种终态,applied 不再进 done", () => {
126
+ const evolutionDir = workspace();
127
+ writeRun(evolutionDir, "r5", { status: "applied" });
128
+ writeRun(evolutionDir, "r7", { status: "rejected", dispatchedAt: NOW + 4_000 });
129
+ writeQueue(evolutionDir, [
130
+ item("r5", { level: "auto", status: "applied", createdAt: NOW + 2_000, detail: "闸门全绿" }),
131
+ item("r6", { status: "superseded", createdAt: NOW + 3_000 }),
132
+ ]);
133
+ const state = collectWebState({ evolutionDir });
134
+ assert.deepEqual(
135
+ state.done.map((entry) => `${entry.runId}:${entry.status}`),
136
+ ["r7:rejected", "r6:superseded"],
137
+ "已采纳单独成 tab,混进已处理就分不清「留下了什么」和「撤了什么」",
138
+ );
139
+ assert.deepEqual(
140
+ state.applied.map((entry) => entry.runId),
141
+ ["r5"],
142
+ );
143
+ });
144
+
145
+ test("applied:读 04-decision.at 当采纳时间,带五闸结论,按时间倒序", () => {
146
+ const evolutionDir = workspace();
147
+ const older = writeRun(evolutionDir, "a-old", { status: "applied" });
148
+ const newer = writeRun(evolutionDir, "a-new", { status: "applied" });
149
+ for (const [runId, runDir, at] of [
150
+ ["a-old", older, NOW - 60_000],
151
+ ["a-new", newer, NOW],
152
+ ] as const) {
153
+ writeJson(path.join(runDir, "02-proposal.json"), {
154
+ type: "improve",
155
+ skill: "demo",
156
+ status: "ok",
157
+ added: 3,
158
+ removed: 1,
159
+ file: "02-proposal.diff",
160
+ });
161
+ // 增删行数是数 diff 来的,不是读 JSON 里的数字
162
+ fs.writeFileSync(
163
+ path.join(runDir, "02-proposal.diff"),
164
+ "--- a/.pi/skills/demo/SKILL.md\n+++ b/.pi/skills/demo/SKILL.md\n@@ -1,2 +1,4 @@\n ---\n-old\n+new1\n+new2\n+new3\n",
165
+ "utf8",
166
+ );
167
+ writeJson(path.join(runDir, "04-decision.json"), { status: "applied", approver: "auto", at, detail: "ok" });
168
+ writeJson(path.join(runDir, "06-verify.json"), {
169
+ runId,
170
+ status: "applied",
171
+ at,
172
+ ms: 3,
173
+ wrote: [],
174
+ gates: [
175
+ { gate: "gate0", name: "授权核对", ok: true, ms: 1 },
176
+ { gate: "gate3", name: "复验", ok: false, ms: 1, detail: "复验失败" },
177
+ ],
178
+ });
179
+ }
180
+ writeQueue(evolutionDir, [
181
+ item("a-old", { status: "applied", createdAt: NOW - 60_000 }),
182
+ item("a-new", { status: "applied", createdAt: NOW }),
183
+ // 还没采纳的不该出现在已采纳页
184
+ item("a-pending", { status: "pending" }),
185
+ ]);
186
+ const state = collectWebState({ evolutionDir });
187
+ assert.deepEqual(
188
+ state.applied.map((entry) => entry.runId),
189
+ ["a-new", "a-old"],
190
+ );
191
+ const [newest] = state.applied;
192
+ assert.equal(newest!.appliedAt, NOW, "采纳时间取 04-decision.at");
193
+ assert.equal(newest!.type, "improve");
194
+ assert.equal(newest!.added, 3);
195
+ assert.equal(newest!.removed, 1);
196
+ assert.equal(newest!.gatesOk, false, "有一闸失败就不是全 ok");
197
+ assert.equal(state.applied[1]!.gatesOk, false);
198
+ assert.deepEqual(newest!.gates.map((gate) => gate.gate), ["gate0", "gate3"]);
199
+ });
200
+
201
+ test("reports:只收有 01-findings.json 的 run,前 5 条归因 + 提案/评审/决策/五闸", () => {
202
+ const evolutionDir = workspace();
203
+ const runDir = writeRun(evolutionDir, "r8", {
204
+ status: "applied",
205
+ trigger: "manual",
206
+ proposal: { type: "improve", skill: "demo", status: "ok", added: 5, removed: 2, file: "02-proposal.diff" },
207
+ review: { status: "reviewed", verdict: "pass", action: "auto", reviewerModel: "m", proposerModel: "m", sameModel: true },
208
+ });
209
+ writeJson(path.join(runDir, "01-findings.json"), {
210
+ skills: [
211
+ { name: "demo", advertised: true, loaded: false, whyNotLoaded: "B", confidence: "high" },
212
+ { name: "s2", advertised: true, loaded: true },
213
+ { name: "s3", advertised: false, loaded: false },
214
+ { name: "s4", advertised: true, loaded: false, whyNotLoaded: "G" },
215
+ { name: "s5", advertised: true, loaded: false, whyNotLoaded: "F" },
216
+ { name: "s6", advertised: true, loaded: false },
217
+ ],
218
+ notes: [],
219
+ });
220
+ writeJson(path.join(runDir, "03-review.json"), { status: "reviewed", verdict: "pass", blocking: [{ issue: "x" }] });
221
+ writeJson(path.join(runDir, "04-decision.json"), { status: "applied", approver: "auto", at: NOW, detail: "ok" });
222
+ writeJson(path.join(runDir, "06-verify.json"), {
223
+ runId: "r8",
224
+ status: "applied",
225
+ at: NOW,
226
+ ms: 12,
227
+ wrote: [".pi/skills/demo/SKILL.md"],
228
+ gates: [
229
+ { gate: "gate0", name: "授权核对", ok: true, ms: 1 },
230
+ { gate: "gate1", name: "备份", ok: true, ms: 1 },
231
+ { gate: "gate2", name: "原子写", ok: true, ms: 1 },
232
+ { gate: "gate3", name: "诊断", ok: false, ms: 1 },
233
+ { gate: "gate4", name: "生效边界", ok: true, ms: 0 },
234
+ ],
235
+ });
236
+ // 另一个没有 findings 的 run:不产生报告
237
+ writeRun(evolutionDir, "r9", { status: "reviewed" });
238
+
239
+ const state = collectWebState({ evolutionDir });
240
+ assert.equal(state.reports.length, 1);
241
+ const report = state.reports[0]!;
242
+ assert.equal(report.runId, "r8");
243
+ assert.equal(report.trigger, "manual");
244
+ assert.equal(report.findings.total, 6);
245
+ assert.equal(report.findings.rows.length, 5, "只列前 5 条");
246
+ assert.deepEqual(report.findings.rows[0], { skill: "demo", kind: "未加载", reason: "B frontmatter 坏" });
247
+ assert.deepEqual(report.findings.rows[1], { skill: "s2", kind: "已加载", reason: "—" });
248
+ assert.equal(report.findings.rows[2]!.kind, "未曝光");
249
+ assert.equal(report.proposal?.added, 5);
250
+ assert.equal(report.proposal?.removed, 2);
251
+ assert.equal(report.review?.blocking, 1);
252
+ assert.equal(report.decision?.approver, "auto");
253
+ assert.equal(report.verify?.gates.length, 5);
254
+ assert.equal(report.verify?.gates[3]!.ok, false);
255
+ });
256
+
257
+ test("done:超过 50 条只留最近 50 条", () => {
258
+ const evolutionDir = workspace();
259
+ const items = Array.from({ length: DONE_LIMIT + 10 }, (_unused, index) =>
260
+ item(`run-${index}`, { status: "superseded", createdAt: NOW + index * 1_000 }),
261
+ );
262
+ writeQueue(evolutionDir, items);
263
+ const state = collectWebState({ evolutionDir });
264
+ assert.equal(state.done.length, DONE_LIMIT);
265
+ assert.equal(state.done[0]!.runId, `run-${DONE_LIMIT + 9}`, "第一条是最新的");
266
+ assert.equal(state.done.at(-1)!.runId, "run-10", "最早的十条被截掉");
267
+ });
268
+
269
+ test("applied:超过 50 条只留最近 50 条(看不过来的历史不当成待处理)", () => {
270
+ const evolutionDir = workspace();
271
+ const items = Array.from({ length: APPLIED_LIMIT + 10 }, (_unused, index) =>
272
+ item(`run-${index}`, { status: "applied", createdAt: NOW + index * 1_000 }),
273
+ );
274
+ writeQueue(evolutionDir, items);
275
+ const state = collectWebState({ evolutionDir });
276
+ assert.equal(state.applied.length, APPLIED_LIMIT);
277
+ assert.equal(state.applied[0]!.runId, `run-${APPLIED_LIMIT + 9}`, "第一条是最新的");
278
+ assert.equal(state.applied.at(-1)!.runId, "run-10", "最早的十条被截掉");
279
+ assert.equal(state.applied[0]!.gatesOk, null, "没有 06-verify.json 就是「无记录」,不是「全 ok」");
280
+ assert.equal(state.applied[0]!.appliedAt, NOW + (APPLIED_LIMIT + 9) * 1_000, "没有 04-decision 就退回入队时刻");
281
+ });
282
+
283
+ test("summary:已采纳数取队列 applied,回滚率 = 回滚 / (采纳 + 回滚)", () => {
284
+ const evolutionDir = workspace();
285
+ writeQueue(evolutionDir, [
286
+ item("a1", { status: "applied" }),
287
+ item("a2", { status: "applied" }),
288
+ item("a3", { status: "applied" }),
289
+ item("b1", { status: "rolled-back" }),
290
+ ]);
291
+ const state = collectWebState({ evolutionDir });
292
+ assert.equal(state.summary.appliedTotal, 3);
293
+ assert.equal(state.summary.rollbackRate, 0.25);
294
+ });
295
+
296
+ /** 造 child.log 并回拨 mtime:模拟「子进程上次输出在 N 秒前」 */
297
+ function writeChildLog(runDir: string, lines: string[], mtimeMs: number): void {
298
+ const file = path.join(runDir, "child.log");
299
+ fs.writeFileSync(file, `${lines.join("\n")}\n`, "utf8");
300
+ const at = new Date(mtimeMs);
301
+ fs.utimesSync(file, at, at);
302
+ }
303
+
304
+ test("pending:正在跑的 run 排最前,阶段 / 进程 / 最近输出 / 看日志四行齐全", () => {
305
+ const evolutionDir = workspace();
306
+ writeRun(evolutionDir, "live", { status: "dispatched", pid: 1234, dispatchedAt: NOW - 90_000 });
307
+ writeQueue(evolutionDir, [item("q1")]);
308
+ const state = collectWebState({ evolutionDir, now: NOW, alive: (pid) => pid === 1234, maxRuntimeMs: 20 * 60_000 });
309
+ assert.equal(state.pending[0]!.kind, "running", "正在跑的排最前,不排在待批后面");
310
+ assert.equal(state.pending[0]!.runId, "live");
311
+ const detail = state.pending[0]!.detail;
312
+ assert.match(detail, /阶段:复盘产出中/);
313
+ assert.match(detail, /进程:pid 1234 · 已跑 1m 30s(上限 20 分钟)/);
314
+ assert.match(detail, /最近输出:(还没有输出)/, "child.log 还没生成时明确说没有输出");
315
+ assert.match(detail, /日志:tail -f \.pi\/evolution\/runs\/live\/child\.log/);
316
+ assert.equal(state.pending[1]!.kind, "approval", "待批排在运行中之后");
317
+ });
318
+
319
+ test("pending:督导活着 → 阶段写评审中,child.log 的 mtime 与末行都渲染出来", () => {
320
+ const evolutionDir = workspace();
321
+ const runDir = writeRun(evolutionDir, "live", {
322
+ status: "dispatched",
323
+ pid: 1234,
324
+ supervisorPid: 5678,
325
+ dispatchedAt: NOW - 5_000,
326
+ });
327
+ writeChildLog(runDir, ["第一行", "最后一行"], NOW - 12_000);
328
+ const state = collectWebState({ evolutionDir, now: NOW, alive: (pid) => pid === 1234 || pid === 5678 });
329
+ const detail = state.pending[0]!.detail;
330
+ assert.match(detail, /阶段:评审中/, "督导在跑就是评审阶段");
331
+ assert.match(detail, /末行:最后一行/);
332
+ const seconds = Number(detail.match(/最近输出:(\d+)s 前/)![1]);
333
+ assert.ok(Math.abs(seconds - 12) <= 1, `mtime 距今应按秒渲染(实际 ${seconds}s)`);
334
+ });
335
+
336
+ test("pending:pid 已死且没超时 → 不出现在待处理页(不当成在跑)", () => {
337
+ const evolutionDir = workspace();
338
+ writeRun(evolutionDir, "dead", { status: "dispatched", pid: 4321, dispatchedAt: NOW });
339
+ const state = collectWebState({ evolutionDir, now: NOW, alive: () => false, maxRuntimeMs: 20 * 60_000 });
340
+ assert.deepEqual(state.pending, []);
341
+ });
342
+
343
+ test("reports:卡死且没有 findings 的 run 也进档案,标 stale 并写明原因与日志末行", () => {
344
+ const evolutionDir = workspace();
345
+ const runDir = writeRun(evolutionDir, "stuck", { status: "stale", pid: 999, dispatchedAt: NOW - 600_000 });
346
+ writeJson(path.join(runDir, "run-error.json"), {
347
+ reason: "child-exited",
348
+ at: NOW,
349
+ childLogTail: ["跑了一半", "Error: 模板缺失"],
350
+ });
351
+ // reviewed 且无 findings 的 run 仍然不进档案(不是「失败」,只是没产出)
352
+ writeRun(evolutionDir, "r9", { status: "reviewed" });
353
+ const state = collectWebState({ evolutionDir });
354
+ assert.equal(state.reports.length, 1, "每条 run 在档案里都要能找到一行,但不能把 reviewed 也算进来");
355
+ const report = state.reports[0]!;
356
+ assert.equal(report.runId, "stuck");
357
+ assert.equal(report.status, "stale");
358
+ assert.equal(report.findings.total, 0);
359
+ assert.match(report.failure ?? "", /原因:子进程已退出/);
360
+ assert.match(report.failure ?? "", /日志末行:Error: 模板缺失/);
361
+ });
362
+
363
+ test("approvals:只收 pending + approval 级,auto 级与已处理的行都不出现", () => {
364
+ const evolutionDir = workspace();
365
+ writeQueue(evolutionDir, [
366
+ item("run-b", { level: "approval", createdAt: NOW + 2 }),
367
+ item("run-a", { level: "approval", createdAt: NOW + 1 }),
368
+ item("run-auto", { level: "auto", createdAt: NOW + 3 }),
369
+ item("run-applied", { createdAt: NOW + 4, status: "applied" }),
370
+ item("run-rejected", { createdAt: NOW + 5, status: "rejected" }),
371
+ ]);
372
+ const state = collectWebState({ evolutionDir });
373
+ assert.deepEqual(
374
+ state.approvals.map((row) => row.runId),
375
+ ["run-a", "run-b"],
376
+ "只留 approval 且 pending,按 createdAt 升序(先来的先批)",
377
+ );
378
+ });
379
+
380
+ test("approvals:带 proposal 增删行与 review 的 verdict/blocking 条数", () => {
381
+ const evolutionDir = workspace();
382
+ const runDir = writeRun(evolutionDir, "run-full");
383
+ // 增删行数是数 02-proposal.diff 的 +/- 行,不是读 json 字段
384
+ writeJson(path.join(runDir, "02-proposal.json"), { type: "improve", skill: "demo" });
385
+ fs.writeFileSync(
386
+ path.join(runDir, "02-proposal.diff"),
387
+ "--- a/.pi/skills/demo/SKILL.md\n+++ b/.pi/skills/demo/SKILL.md\n@@ -1,1 +1,2 @@\n-旧\n+新\n+又一行\n",
388
+ "utf8",
389
+ );
390
+ writeJson(path.join(runDir, "03-review.json"), {
391
+ verdict: "pass",
392
+ blocking: [{ id: "P1", text: "缺成功征兆" }, { id: "P2", text: "步骤不完整" }],
393
+ });
394
+ writeQueue(evolutionDir, [item("run-full", { detail: "正文改动,需人批" })]);
395
+ const row = collectWebState({ evolutionDir }).approvals[0]!;
396
+ assert.equal(row.skill, "demo");
397
+ assert.deepEqual(row.targets, [".pi/skills/demo/SKILL.md"]);
398
+ assert.equal(row.proposal?.type, "improve");
399
+ assert.equal(row.proposal?.added, 2);
400
+ assert.equal(row.proposal?.removed, 1);
401
+ assert.deepEqual(row.review, { verdict: "pass", blocking: 2 });
402
+ assert.equal(row.detail, "正文改动,需人批");
403
+ });
404
+
405
+ test("approvals:缺 proposal / review 文件时给 null,不抛也不编造数字", () => {
406
+ const evolutionDir = workspace();
407
+ writeQueue(evolutionDir, [item("run-bare")]);
408
+ const row = collectWebState({ evolutionDir }).approvals[0]!;
409
+ assert.equal(row.proposal, null);
410
+ assert.equal(row.review, null);
411
+ assert.equal(row.createdAt, NOW);
412
+ });
413
+
414
+ test("collectControls:两个开关各读各的字段(采集关、复盘开这个中间态要能显示出来)", () => {
415
+ const evolutionDir = workspace();
416
+ fs.writeFileSync(path.join(evolutionDir, "config.json"), JSON.stringify({ enabled: true, telemetry: false }), "utf8");
417
+ const controls = collectControls(evolutionDir);
418
+ assert.deepEqual([controls.telemetry, controls.enabled], [false, true]);
419
+ assert.equal(controls.lastActivityAt, null, "没有任何遥测 / run 时最后活动为 null,不编造时间");
420
+ });
@@ -0,0 +1,155 @@
1
+ /**
2
+ * 统一 diff 的解析与渲染:行号推进、着色 class、转义、400 行截断。
3
+ * 全部是字符串进出,不读盘、不起子进程、不占端口。
4
+ */
5
+ import assert from "node:assert/strict";
6
+ import test from "node:test";
7
+ import {
8
+ DIFF_PREVIEW_LINES,
9
+ parseDiffLines,
10
+ renderDiff,
11
+ renderDiffLines,
12
+ } from "../lib/web/diff-lines.ts";
13
+
14
+ /** 一份最小的单 hunk diff:文件头 2 行 + hunk 头 1 行 + 上下文/删/增/增/上下文 */
15
+ const SAMPLE = [
16
+ "--- a/.pi/skills/demo/SKILL.md",
17
+ "+++ b/.pi/skills/demo/SKILL.md",
18
+ "@@ -1,3 +1,4 @@",
19
+ " ---",
20
+ "-old line",
21
+ "+new line",
22
+ "+another new line",
23
+ " tail",
24
+ "",
25
+ ].join("\n");
26
+
27
+ test("parseDiffLines:文件头 / hunk / 增 / 删 / 上下文各自归类", () => {
28
+ const lines = parseDiffLines(SAMPLE);
29
+ assert.deepEqual(
30
+ lines.map((line) => line.kind),
31
+ ["file", "file", "hunk", "ctx", "del", "add", "add", "ctx"],
32
+ );
33
+ assert.deepEqual(
34
+ lines.slice(0, 3).map((line) => [line.oldLine, line.newLine]),
35
+ [
36
+ [null, null],
37
+ [null, null],
38
+ [null, null],
39
+ ],
40
+ "文件头与 hunk 头不给行号",
41
+ );
42
+ });
43
+
44
+ test("parseDiffLines:@@ -1,3 +1,4 @@ 之后删行只推旧号、增行只推新号", () => {
45
+ const lines = parseDiffLines(SAMPLE);
46
+ assert.deepEqual(
47
+ lines.slice(3).map((line) => [line.text, line.oldLine, line.newLine]),
48
+ [
49
+ [" ---", 1, 1],
50
+ ["-old line", 2, null],
51
+ ["+new line", null, 2],
52
+ ["+another new line", null, 3],
53
+ [" tail", 3, 4],
54
+ ],
55
+ "算错这里,用户就会对着行号去改文件、改错行",
56
+ );
57
+ });
58
+
59
+ test("parseDiffLines:多个 hunk 各自从自己的起点重新计数", () => {
60
+ const text = [
61
+ "--- a/x",
62
+ "+++ b/x",
63
+ "@@ -1,1 +1,1 @@",
64
+ "-a",
65
+ "+b",
66
+ "@@ -10,1 +20,2 @@",
67
+ " keep",
68
+ "+added",
69
+ ].join("\n");
70
+ const lines = parseDiffLines(text);
71
+ const second = lines.slice(lines.findIndex((line) => line.text.startsWith("@@ -10")));
72
+ assert.deepEqual(
73
+ second.map((line) => [line.text, line.oldLine, line.newLine]),
74
+ [
75
+ ["@@ -10,1 +20,2 @@", null, null],
76
+ [" keep", 10, 20],
77
+ ["+added", null, 21],
78
+ ],
79
+ );
80
+ });
81
+
82
+ test("parseDiffLines:hunk 外的行(diff --git / index)与 \\ No newline 都不占行号", () => {
83
+ const text = [
84
+ "diff --git a/x b/x",
85
+ "index 123..456 100644",
86
+ "--- a/x",
87
+ "+++ b/x",
88
+ "@@ -1,1 +1,1 @@",
89
+ "-a",
90
+ "\",
91
+ "+b",
92
+ ].join("\n");
93
+ const lines = parseDiffLines(text);
94
+ assert.deepEqual(
95
+ lines.map((line) => [line.kind, line.oldLine, line.newLine]),
96
+ [
97
+ ["meta", null, null],
98
+ ["meta", null, null],
99
+ ["file", null, null],
100
+ ["file", null, null],
101
+ ["hunk", null, null],
102
+ ["del", 1, null],
103
+ ["meta", null, null],
104
+ ["add", null, 1],
105
+ ],
106
+ "「\\ No newline」不是一行内容,推进它会把它后面的行号整体错一位",
107
+ );
108
+ });
109
+
110
+ test("parseDiffLines:末尾换行不额外造出一个空行", () => {
111
+ assert.equal(parseDiffLines(SAMPLE).length, 8);
112
+ assert.equal(parseDiffLines("one\n").length, 1, "只有一个换行不是两行");
113
+ assert.equal(parseDiffLines("one\n\n").length, 2, "真正的空行仍然保留");
114
+ });
115
+
116
+ test("renderDiffLines:+ / - / @@ 三类 class 与两列行号都渲染出来", () => {
117
+ const html = renderDiffLines(parseDiffLines(SAMPLE));
118
+ assert.match(html, /^<pre class="diff">/);
119
+ assert.match(html, /<span class="dl file"><span class="ln old"> {1}<\/span><span class="ln new"> {1}<\/span><span class="tx">--- a\/\.pi\/skills\/demo\/SKILL\.md<\/span><\/span>/);
120
+ assert.match(html, /<span class="dl hunk">/);
121
+ assert.match(html, /<span class="dl add"><span class="ln old"> {1}<\/span><span class="ln new">2<\/span>/);
122
+ assert.match(html, /<span class="dl del"><span class="ln old">2<\/span><span class="ln new"> {1}<\/span>/);
123
+ assert.match(html, /<span class="dl ctx"><span class="ln old">3<\/span><span class="ln new">4<\/span>/);
124
+ });
125
+
126
+ test("renderDiffLines:diff 里的 <script> 与 & 一律转义(这里最容易被当成可信内容)", () => {
127
+ const text = [
128
+ "--- a/x",
129
+ "+++ b/x",
130
+ "@@ -1,1 +1,1 @@",
131
+ "-<b>old</b>",
132
+ "+<script>alert('a&b')</script>",
133
+ ].join("\n");
134
+ const html = renderDiffLines(parseDiffLines(text));
135
+ assert.equal(html.includes("<script>"), false, "页面里不许出现裸 <script>");
136
+ assert.equal(html.includes("<b>old</b>"), false);
137
+ assert.match(html, /&lt;script&gt;alert\(&#39;a&amp;b&#39;\)&lt;\/script&gt;/);
138
+ });
139
+
140
+ test("renderDiff:超过 400 行只渲前 400 行并标 truncated", () => {
141
+ const body = Array.from({ length: 500 }, (_unused, index) => ` line ${index + 1}`);
142
+ const text = ["--- a/x", "+++ b/x", "@@ -1,500 +1,500 @@", ...body].join("\n");
143
+ const view = renderDiff(text);
144
+ assert.equal(view.total, 503);
145
+ assert.equal(view.truncated, true);
146
+ assert.equal(view.html.match(/class="dl /g)?.length, DIFF_PREVIEW_LINES);
147
+ assert.equal(view.html.includes("line 401"), false, "第 401 行不渲染");
148
+ });
149
+
150
+ test("renderDiff:不到 400 行不截断,也不加下载提示的依据(truncated=false)", () => {
151
+ const view = renderDiff(SAMPLE);
152
+ assert.equal(view.total, 8);
153
+ assert.equal(view.truncated, false);
154
+ assert.equal(view.html.match(/class="dl /g)?.length, 8);
155
+ });