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,262 @@
1
+ /**
2
+ * `POST /action` 的三个动作:run / rerun / cleanup,以及开关入口 `POST /toggle?field=` ——
3
+ * 参数校验、回调调用次数、以及「成功与失败都写一条审计」。真监听 127.0.0.1(port=0),不起子进程。
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 { readAudit } from "../lib/web/audit-log.ts";
11
+ import { startWebPanel, type WebPanel, type WebPanelOptions } from "../lib/web/server.ts";
12
+ import { moveRunToTrash } from "../lib/web/trash.ts";
13
+
14
+ const FORM = "application/x-www-form-urlencoded";
15
+
16
+ function workspace(options: { enabled?: boolean; telemetry?: boolean; runs?: { runId: string; status: string }[] } = {}): string {
17
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-webaction-"));
18
+ const evolutionDir = path.join(cwd, ".pi", "evolution");
19
+ fs.mkdirSync(path.join(evolutionDir, "runs"), { recursive: true });
20
+ fs.writeFileSync(
21
+ path.join(evolutionDir, "config.json"),
22
+ JSON.stringify({ enabled: options.enabled === true, telemetry: options.telemetry === true }),
23
+ "utf8",
24
+ );
25
+ for (const run of options.runs ?? []) {
26
+ const runDir = path.join(evolutionDir, "runs", run.runId);
27
+ fs.mkdirSync(runDir, { recursive: true });
28
+ fs.writeFileSync(
29
+ path.join(runDir, "run.json"),
30
+ JSON.stringify({ runId: run.runId, status: run.status, dispatchedAt: Date.now() - 60_000 }),
31
+ "utf8",
32
+ );
33
+ }
34
+ return evolutionDir;
35
+ }
36
+
37
+ async function open(evolutionDir: string, extra: Partial<WebPanelOptions> = {}): Promise<WebPanel> {
38
+ const result = await startWebPanel({ evolutionDir, port: 0, ...extra });
39
+ assert.equal(result.ok, true, `面板应该起来:${result.ok ? "" : result.message}`);
40
+ if (!result.ok) throw new Error("unreachable");
41
+ return result.panel;
42
+ }
43
+
44
+ async function post(panel: WebPanel, pathname: string, form: Record<string, string>): Promise<Response> {
45
+ return fetch(`${panel.url}${pathname}`, {
46
+ method: "POST",
47
+ headers: { "content-type": FORM },
48
+ body: new URLSearchParams(form).toString(),
49
+ redirect: "manual",
50
+ });
51
+ }
52
+
53
+ /** 记一次调用的假回调:断言「恰一次」比断言返回值更能发现重复执行 */
54
+ function spy<T>(result: T): { calls: unknown[]; fn: (...args: unknown[]) => Promise<T> } {
55
+ const calls: unknown[] = [];
56
+ return {
57
+ calls,
58
+ fn: async (...args: unknown[]) => {
59
+ calls.push(args[0]);
60
+ return result;
61
+ },
62
+ };
63
+ }
64
+
65
+ /** 两个参数的开关回调:采集与复盘是同一类型的钩子(field, next),要同时看两个参数 */
66
+ function spyToggle(result: string): { calls: [string, boolean][]; fn: (field: string, next: boolean) => Promise<string> } {
67
+ const calls: [string, boolean][] = [];
68
+ return { calls, fn: async (field: string, next: boolean) => (calls.push([field, next]), result) };
69
+ }
70
+
71
+ test("POST /toggle?field=enabled:只调 onToggle(\"enabled\", 取反),审计记 field", async () => {
72
+ const evolutionDir = workspace({ enabled: true });
73
+ const toggle = spyToggle("自动复盘已关闭");
74
+ const panel = await open(evolutionDir, { onToggle: toggle.fn });
75
+ try {
76
+ const response = await post(panel, "/toggle?field=enabled", { csrf: panel.csrf, view: "done" });
77
+ assert.equal(response.status, 303);
78
+ assert.deepEqual(toggle.calls, [["enabled", false]], "当前是开的 → 传 false(取反,不是传页面上的旧文字)");
79
+ const location = new URL(response.headers.get("location") ?? "", panel.url);
80
+ assert.equal(location.searchParams.get("view"), "done", "回跳到点按钮时那一页");
81
+ assert.equal(location.searchParams.get("msg"), "自动复盘已关闭");
82
+ const audit = readAudit(evolutionDir);
83
+ assert.equal(audit.length, 1);
84
+ assert.equal(audit[0]?.action, "toggle");
85
+ assert.equal(audit[0]?.field, "enabled");
86
+ assert.equal(audit[0]?.result, "ok");
87
+ assert.equal(audit[0]?.via, "web");
88
+ } finally {
89
+ await panel.close();
90
+ }
91
+ });
92
+
93
+ test("POST /toggle?field=telemetry:只动采集,不动复盘", async () => {
94
+ const evolutionDir = workspace({ enabled: true, telemetry: false });
95
+ const toggle = spyToggle("采集遥测已开启");
96
+ const panel = await open(evolutionDir, { onToggle: toggle.fn });
97
+ try {
98
+ assert.equal((await post(panel, "/toggle?field=telemetry", { csrf: panel.csrf })).status, 303);
99
+ assert.deepEqual(toggle.calls, [["telemetry", true]], "当前采集是关的 → 传 true,且 field 只能是 telemetry");
100
+ assert.equal(readAudit(evolutionDir).at(-1)?.field, "telemetry");
101
+ } finally {
102
+ await panel.close();
103
+ }
104
+ });
105
+
106
+ test("POST /toggle 的 field 越界 / 缺失:400,不调回调,写一条带原值的 error 审计", async () => {
107
+ const evolutionDir = workspace({ enabled: true, telemetry: true });
108
+ const toggle = spyToggle("不该被调用");
109
+ const panel = await open(evolutionDir, { onToggle: toggle.fn });
110
+ try {
111
+ const wrong = await post(panel, "/toggle?field=propose", { csrf: panel.csrf });
112
+ assert.equal(wrong.status, 400);
113
+ assert.match(await wrong.text(), /field 只认 telemetry \/ enabled/);
114
+ const missing = await post(panel, "/toggle", { csrf: panel.csrf });
115
+ assert.equal(missing.status, 400, "缺 field 不能猜一个默认值");
116
+ assert.deepEqual(toggle.calls, [], "越界不放行到回调");
117
+ const audit = readAudit(evolutionDir);
118
+ assert.deepEqual(
119
+ audit.map((entry) => [entry.action, entry.field, entry.result.startsWith("error: ")]),
120
+ [["toggle", "propose", true], ["toggle", undefined, true]],
121
+ );
122
+ } finally {
123
+ await panel.close();
124
+ }
125
+ });
126
+
127
+ test("POST /action name=run:onRunNow 恰一次;派发了记 ok、没派发记 partial", async () => {
128
+ const evolutionDir = workspace();
129
+ const runNow = spy("已派生复盘:run-new(pid 4242)");
130
+ const panel = await open(evolutionDir, { onRunNow: runNow.fn as () => Promise<string> });
131
+ try {
132
+ assert.equal((await post(panel, "/action", { csrf: panel.csrf, name: "run" })).status, 303);
133
+ assert.equal(runNow.calls.length, 1, "点一次只能派发一次");
134
+ assert.equal(readAudit(evolutionDir).at(-1)?.result, "ok");
135
+ } finally {
136
+ await panel.close();
137
+ }
138
+ const skipped = spy("技能进化总开关是关的:不改配置就只读历史");
139
+ const second = await open(evolutionDir, { onRunNow: skipped.fn as () => Promise<string> });
140
+ try {
141
+ assert.equal((await post(second, "/action", { csrf: second.csrf, name: "run" })).status, 303);
142
+ const audit = readAudit(evolutionDir).at(-1);
143
+ assert.equal(audit?.action, "run");
144
+ assert.equal(audit?.result, "partial", "没派发就是 partial:否则「点了十次都没跑」查不出来");
145
+ } finally {
146
+ await second.close();
147
+ }
148
+ });
149
+
150
+ test("POST /action name=rerun:不带旧 runId 派发(不复用),审计记下点的是哪一条", async () => {
151
+ const evolutionDir = workspace({ runs: [{ runId: "run-a", status: "reviewed" }] });
152
+ const runNow = spy("已派生复盘:run-b(pid 7)");
153
+ const panel = await open(evolutionDir, { onRunNow: runNow.fn as () => Promise<string> });
154
+ try {
155
+ assert.equal((await post(panel, "/action", { csrf: panel.csrf, name: "rerun", runId: "run-a" })).status, 303);
156
+ assert.equal(runNow.calls.length, 1);
157
+ const audit = readAudit(evolutionDir).at(-1);
158
+ assert.equal(audit?.action, "rerun");
159
+ assert.equal(audit?.runId, "run-a");
160
+ assert.equal(audit?.result, "ok");
161
+ } finally {
162
+ await panel.close();
163
+ }
164
+ });
165
+
166
+ test("POST /action name=rerun:runId 不存在 → 400、不派发、写一条 error 审计", async () => {
167
+ const evolutionDir = workspace();
168
+ const runNow = spy("已派生复盘:run-b");
169
+ const panel = await open(evolutionDir, { onRunNow: runNow.fn as () => Promise<string> });
170
+ try {
171
+ const response = await post(panel, "/action", { csrf: panel.csrf, name: "rerun", runId: "run-missing" });
172
+ assert.equal(response.status, 400);
173
+ assert.match(await response.text(), /找不到这个 run/);
174
+ assert.deepEqual(runNow.calls, [], "目标不存在时一次都不能派发");
175
+ const audit = readAudit(evolutionDir).at(-1);
176
+ assert.equal(audit?.action, "rerun");
177
+ assert.match(audit?.result ?? "", /^error: 找不到这个 run/);
178
+ } finally {
179
+ await panel.close();
180
+ }
181
+ });
182
+
183
+ test("POST /action name=cleanup:stale 的 run 被移进 runs/.trash/,审计 ok", async () => {
184
+ const evolutionDir = workspace({ runs: [{ runId: "run-dead", status: "stale" }] });
185
+ const cleanup = spy("");
186
+ const panel = await open(evolutionDir, {
187
+ onCleanup: (runId: string) => {
188
+ cleanup.calls.push(runId);
189
+ return Promise.resolve(moveRunToTrash(evolutionDir, runId));
190
+ },
191
+ });
192
+ try {
193
+ const response = await post(panel, "/action", { csrf: panel.csrf, name: "cleanup", runId: "run-dead" });
194
+ assert.equal(response.status, 303);
195
+ assert.deepEqual(cleanup.calls, ["run-dead"]);
196
+ assert.equal(fs.existsSync(path.join(evolutionDir, "runs", "run-dead")), false, "原目录不该还在");
197
+ const trash = fs.readdirSync(path.join(evolutionDir, "runs", ".trash"));
198
+ assert.equal(trash.length, 1);
199
+ assert.match(trash[0]!, /^run-dead-\d{8}-\d{6}$/);
200
+ assert.equal(fs.existsSync(path.join(evolutionDir, "runs", ".trash", trash[0]!, "run.json")), true, "是移动不是删除");
201
+ const audit = readAudit(evolutionDir).at(-1);
202
+ assert.equal(audit?.action, "cleanup");
203
+ assert.equal(audit?.runId, "run-dead");
204
+ assert.equal(audit?.result, "ok");
205
+ } finally {
206
+ await panel.close();
207
+ }
208
+ });
209
+
210
+ test("POST /action name=cleanup:非 stale 一律拒绝(不移动、写 error 审计)", async () => {
211
+ const evolutionDir = workspace({ runs: [{ runId: "run-live", status: "applied" }] });
212
+ const cleanup = spy("");
213
+ const panel = await open(evolutionDir, { onCleanup: cleanup.fn as (runId: string) => Promise<string> });
214
+ try {
215
+ const response = await post(panel, "/action", { csrf: panel.csrf, name: "cleanup", runId: "run-live" });
216
+ assert.equal(response.status, 400);
217
+ assert.match(await response.text(), /只有卡死(stale)的 run 能清理,当前状态:applied/);
218
+ assert.deepEqual(cleanup.calls, [], "非 stale 不能调清理");
219
+ assert.equal(fs.existsSync(path.join(evolutionDir, "runs", "run-live")), true, "目录还在");
220
+ const audit = readAudit(evolutionDir).at(-1);
221
+ assert.equal(audit?.action, "cleanup");
222
+ assert.match(audit?.result ?? "", /^error: 只有卡死/);
223
+ } finally {
224
+ await panel.close();
225
+ }
226
+ });
227
+
228
+ test("回调抛错:303 带 err 而不是 500,同时写一条 error 审计", async () => {
229
+ const evolutionDir = workspace({ enabled: false });
230
+ const panel = await open(evolutionDir, {
231
+ onToggle: () => Promise.reject(new Error("config.json 不可写:EACCES")),
232
+ });
233
+ try {
234
+ const response = await post(panel, "/toggle?field=telemetry", { csrf: panel.csrf });
235
+ assert.equal(response.status, 303);
236
+ const location = new URL(response.headers.get("location") ?? "", panel.url);
237
+ assert.equal(location.searchParams.get("err"), "Error: config.json 不可写:EACCES");
238
+ assert.equal((await fetch(panel.url)).status, 200, "面板本身照常工作");
239
+ const audit = readAudit(evolutionDir).at(-1);
240
+ assert.equal(audit?.action, "toggle");
241
+ assert.equal(audit?.field, "telemetry", "失败审计也要能看出点的是哪个开关");
242
+ assert.equal(audit?.result, "error: Error: config.json 不可写:EACCES");
243
+ } finally {
244
+ await panel.close();
245
+ }
246
+ });
247
+
248
+ test("未注入写回调:/action 与 /toggle 一律 403「本实例只读」,且不写审计(什么都没发生)", async () => {
249
+ const evolutionDir = workspace();
250
+ const panel = await open(evolutionDir);
251
+ try {
252
+ const response = await post(panel, "/action", { csrf: panel.csrf, name: "run" });
253
+ assert.equal(response.status, 403);
254
+ assert.match(await response.text(), /本实例只读/);
255
+ const toggle = await post(panel, "/toggle?field=enabled", { csrf: panel.csrf });
256
+ assert.equal(toggle.status, 403);
257
+ assert.match(await toggle.text(), /本实例只读/);
258
+ assert.deepEqual(readAudit(evolutionDir), []);
259
+ } finally {
260
+ await panel.close();
261
+ }
262
+ });
@@ -0,0 +1,100 @@
1
+ /**
2
+ * 「已采纳」单独成 tab:列表只列 applied,已处理页一条 applied 都不许有。
3
+ * 行里的五闸要分三种说法:全 ok / N 项失败 / 无记录(判不了 ≠ 没过)。
4
+ */
5
+ import assert from "node:assert/strict";
6
+ import test from "node:test";
7
+ import type { AppliedItem } from "../lib/web/applied.ts";
8
+ import type { DoneItem, WebState } from "../lib/web/data.ts";
9
+ import { renderPage } from "../lib/web/render.ts";
10
+ import { appliedRows } from "../lib/web/render-rows.ts";
11
+
12
+ const NOW = new Date(2026, 10, 8, 12).getTime();
13
+
14
+ function item(parts: Partial<AppliedItem> = {}): AppliedItem {
15
+ return {
16
+ runId: "run-a-1794133942",
17
+ level: "approval",
18
+ skill: "demo",
19
+ type: "improve",
20
+ added: 3,
21
+ removed: 1,
22
+ appliedAt: NOW,
23
+ gates: [
24
+ { gate: "gate0", name: "授权核对", ok: true },
25
+ { gate: "gate1", name: "备份", ok: true },
26
+ ],
27
+ gatesOk: true,
28
+ ...parts,
29
+ };
30
+ }
31
+
32
+ function done(runId: string, status: string, skill: string): DoneItem {
33
+ return { runId, status, source: "run", level: "-", skill, at: NOW, detail: "" };
34
+ }
35
+
36
+ function state(parts: Partial<WebState> = {}): WebState {
37
+ return {
38
+ pending: [],
39
+ approvals: [],
40
+ applied: [],
41
+ done: [],
42
+ reports: [],
43
+ summary: { reviewing: 0, stale: 0, pendingApprovals: 0, appliedTotal: 1, rollbackRate: 0 },
44
+ ...parts,
45
+ };
46
+ }
47
+
48
+ test("已采纳行:技能 · 提案类型 · +N/-M · 采纳时间 · 五闸全 ok", () => {
49
+ const html = appliedRows([item()]);
50
+ assert.match(html, /\[applied\] <a class="run" href="\/\?view=run&amp;runId=run-a-1794133942">run-a-1794133942<\/a>/);
51
+ assert.match(html, / · demo · improve \+3\/-1 · 2026-11-08 12:00 · 五闸:全 ok/);
52
+ assert.match(html, /gate0 授权核对 ✓/);
53
+ assert.match(html, /完整 diff:点上方 runId 进详情页/);
54
+ assert.match(html, /看 diff/);
55
+ });
56
+
57
+ test("已采纳行:有一闸没过要说几项失败,并提示这是异常", () => {
58
+ const html = appliedRows([
59
+ item({ gates: [{ gate: "gate3", name: "复验", ok: false }], gatesOk: false }),
60
+ ]);
61
+ assert.match(html, /五闸:1 项失败/);
62
+ assert.match(html, /gate3 复验 ✗/);
63
+ assert.match(html, /注意:有闸没过却仍被采纳,先看 06-verify\.json/);
64
+ });
65
+
66
+ test("已采纳行:没有 06-verify.json 时写「无记录」,不写成「失败」", () => {
67
+ const html = appliedRows([item({ gates: [], gatesOk: null })]);
68
+ assert.match(html, /五闸:无记录/);
69
+ assert.match(html, /五闸:没有 06-verify\.json(无法判定)/);
70
+ assert.equal(html.includes("项失败"), false);
71
+ });
72
+
73
+ test("已采纳页只列 applied;已处理页一条 applied 都没有(两页不再混)", () => {
74
+ const shared = state({
75
+ applied: [item({ runId: "run-applied" })],
76
+ done: [done("run-rolled", "rolled-back", "demo"), done("run-rejected", "rejected", "other")],
77
+ });
78
+ const appliedPage = renderPage(shared, "applied", { now: NOW });
79
+ assert.match(appliedPage, /<title>技能进化 · 已采纳<\/title>/);
80
+ assert.match(appliedPage, /<a href="\/\?view=applied" class="active">已采纳<\/a>/);
81
+ assert.match(appliedPage, /run-applied/);
82
+ assert.equal(appliedPage.includes("run-rolled"), false, "回滚的不算已采纳");
83
+
84
+ const donePage = renderPage(shared, "done", { now: NOW });
85
+ assert.match(donePage, /<title>技能进化 · 已处理<\/title>/);
86
+ assert.match(donePage, /run-rolled/);
87
+ assert.match(donePage, /run-rejected/);
88
+ assert.equal(donePage.includes("run-applied"), false, "applied 已经移出已处理页");
89
+ });
90
+
91
+ test("已采纳页空态:说清采纳过的连 diff 一起留在这里", () => {
92
+ const html = renderPage(state(), "applied", { now: NOW });
93
+ assert.match(html, /<p class="empty">还没有已采纳的提案:采纳过的会连 diff 一起留在这里。<\/p>/);
94
+ });
95
+
96
+ test("已采纳页有筛选与分页,但没有自动刷新(历史页刷新会把人弹回顶部)", () => {
97
+ const html = renderPage(state({ applied: [item()] }), "applied", { now: NOW });
98
+ assert.match(html, /<form class="filter"/);
99
+ assert.equal(html.includes('http-equiv="refresh"'), false);
100
+ });
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Web 操作审计(`.pi/evolution/actions.log`):只追加、坏行跳过、只读尾部 limit 条。
3
+ * 全部用临时目录,不起任何子进程。
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 { AUDIT_FILE, appendAudit, auditFile, readAudit, type AuditEntry } from "../lib/web/audit-log.ts";
11
+
12
+ function evolutionDir(): string {
13
+ return path.join(fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-audit-")), ".pi", "evolution");
14
+ }
15
+
16
+ function entry(overrides: Partial<AuditEntry> = {}): AuditEntry {
17
+ return { at: 1_760_000_000_000, action: "run", result: "ok", via: "web", ...overrides };
18
+ }
19
+
20
+ test("appendAudit:目录不存在也建,一行一条 JSONL,字段齐全", () => {
21
+ const dir = evolutionDir();
22
+ appendAudit(dir, entry());
23
+ const text = fs.readFileSync(auditFile(dir), "utf8");
24
+ assert.equal(text.split("\n").filter((line) => line.length > 0).length, 1);
25
+ assert.deepEqual(JSON.parse(text.trim()), { at: 1_760_000_000_000, action: "run", result: "ok", via: "web" });
26
+ assert.equal(path.basename(auditFile(dir)), AUDIT_FILE);
27
+ });
28
+
29
+ test("appendAudit:追加不覆盖,readAudit 按文件顺序(老的在前)返回", () => {
30
+ const dir = evolutionDir();
31
+ appendAudit(dir, entry({ at: 1, action: "toggle" }));
32
+ appendAudit(dir, entry({ at: 2, action: "cleanup", runId: "run-a" }));
33
+ const entries = readAudit(dir);
34
+ assert.deepEqual(
35
+ entries.map((item) => item.action),
36
+ ["toggle", "cleanup"],
37
+ );
38
+ assert.equal(entries[1]?.runId, "run-a");
39
+ assert.equal("runId" in entries[0]!, false, "没有 runId 的条目不写这个字段");
40
+ });
41
+
42
+ test("readAudit:文件不存在返回空数组(没发生过动作不是错误)", () => {
43
+ assert.deepEqual(readAudit(evolutionDir()), []);
44
+ });
45
+
46
+ test("readAudit:坏行跳过(半行 / 非 JSON / 未知动作 / 缺字段 / via 不是 web),好行照常返回", () => {
47
+ const dir = evolutionDir();
48
+ fs.mkdirSync(dir, { recursive: true });
49
+ const good = JSON.stringify(entry({ action: "reject" }));
50
+ const lines = [
51
+ good,
52
+ '{"at": 1, "action": "tog', // 写入中途被杀留下的半行
53
+ "not json at all",
54
+ JSON.stringify({ at: 3, action: "not-an-action", result: "ok", via: "web" }),
55
+ JSON.stringify({ action: "run", result: "ok", via: "web" }), // 缺 at
56
+ JSON.stringify({ at: 4, action: "run", result: "", via: "web" }), // 空 result
57
+ JSON.stringify({ at: 5, action: "run", result: "ok", via: "tui" }),
58
+ "",
59
+ ];
60
+ fs.writeFileSync(auditFile(dir), `${lines.join("\n")}\n`, "utf8");
61
+ const entries = readAudit(dir);
62
+ assert.equal(entries.length, 1);
63
+ assert.equal(entries[0]?.action, "reject");
64
+ });
65
+
66
+ test("readAudit:limit 只取尾部 N 条(默认 20)", () => {
67
+ const dir = evolutionDir();
68
+ for (let index = 1; index <= 25; index += 1) appendAudit(dir, entry({ at: index }));
69
+ assert.deepEqual(
70
+ readAudit(dir, 3).map((item) => item.at),
71
+ [23, 24, 25],
72
+ );
73
+ const all = readAudit(dir);
74
+ assert.equal(all.length, 20, "默认 limit=20");
75
+ assert.equal(all[0]?.at, 6);
76
+ assert.equal(all[19]?.at, 25);
77
+ assert.deepEqual(readAudit(dir, 0), [], "limit=0 = 一条都不要");
78
+ });
79
+
80
+ test("readAudit:error 结果原样保留(带原因),供页面区分 ok / partial / 失败", () => {
81
+ const dir = evolutionDir();
82
+ appendAudit(dir, entry({ result: "partial" }));
83
+ appendAudit(dir, entry({ result: "error: 只有卡死(stale)的 run 能清理,当前状态:applied", runId: "run-b" }));
84
+ const [, failed] = readAudit(dir);
85
+ assert.equal(failed?.result, "error: 只有卡死(stale)的 run 能清理,当前状态:applied");
86
+ assert.equal(failed?.runId, "run-b");
87
+ });