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,300 @@
1
+ /** 只读面板的服务端渲染:HTML 转义(技能正文里可能有 <script>)、各视图标题与空态、状态栏数字。 */
2
+ import assert from "node:assert/strict";
3
+ import test from "node:test";
4
+ import type { WebState } from "../lib/web/data.ts";
5
+ import { escapeHtml, formatStamp, parseView, renderPage } from "../lib/web/render.ts";
6
+
7
+ function withApproval(): WebState {
8
+ const state = emptyState();
9
+ state.approvals.push({
10
+ runId: "run-a",
11
+ skill: "demo",
12
+ level: "approval",
13
+ targets: [".pi/skills/demo/SKILL.md"],
14
+ createdAt: new Date(2026, 10, 8, 12).getTime(),
15
+ proposal: { type: "improve", added: 3, removed: 1 },
16
+ review: { verdict: "pass", blocking: 0 },
17
+ detail: "",
18
+ });
19
+ return state;
20
+ }
21
+
22
+ function emptyState(): WebState {
23
+ return {
24
+ pending: [],
25
+ approvals: [],
26
+ applied: [],
27
+ done: [],
28
+ reports: [],
29
+ summary: { reviewing: 0, stale: 0, pendingApprovals: 0, appliedTotal: 0, rollbackRate: 0 },
30
+ };
31
+ }
32
+
33
+ function withPending(head: string, detail: string): WebState {
34
+ const state = emptyState();
35
+ state.pending.push({ runId: "run-x", kind: "approval", skill: head, at: 0, detail });
36
+ return state;
37
+ }
38
+
39
+ test("escapeHtml:五个字符全转,且 & 先转", () => {
40
+ assert.equal(escapeHtml(`& < > " '`), "&amp; &lt; &gt; &quot; &#39;");
41
+ assert.equal(escapeHtml("<script>alert(1)</script>"), "&lt;script&gt;alert(1)&lt;/script&gt;");
42
+ assert.equal(escapeHtml("a&amp;b"), "a&amp;amp;b", "已转义文本再转一次也不会被二次解码");
43
+ });
44
+
45
+ test("渲染:技能名与详情里的 <script> 一律转义,页面里没有裸 <script>", () => {
46
+ const html = renderPage(withPending("<script>alert(1)</script>", "证据:<script>x()</script>"), "pending");
47
+ assert.equal(/<script/i.test(html), false, "不能出现裸 <script>");
48
+ assert.match(html, /&lt;script&gt;alert\(1\)&lt;\/script&gt;/);
49
+ assert.match(html, /&lt;script&gt;x\(\)&lt;\/script&gt;/);
50
+ });
51
+
52
+ test("渲染:findings 里的 HTML 也被转义(报告页同样不可注入)", () => {
53
+ const state = emptyState();
54
+ state.reports.push({
55
+ runId: "run-1",
56
+ status: "applied",
57
+ trigger: "auto",
58
+ startedAt: 0,
59
+ findings: { total: 1, rows: [{ skill: "<img src=x onerror=alert(1)>", kind: "未加载", reason: "<b>没读</b>" }] },
60
+ proposal: { type: "improve", skill: "demo", added: 3, removed: 1 },
61
+ review: { status: "reviewed", verdict: "pass", blocking: 0 },
62
+ decision: { status: "applied", approver: "auto", at: 0, detail: "<script>d</script>" },
63
+ verify: { status: "applied", at: 0, gates: [{ gate: "gate0", name: "授权核对", ok: true }] },
64
+ });
65
+ const html = renderPage(state, "reports");
66
+ assert.equal(/<img/i.test(html), false);
67
+ assert.equal(/<script/i.test(html), false);
68
+ assert.match(html, /&lt;img src=x onerror=alert\(1\)&gt;/);
69
+ });
70
+
71
+ test("四个视图:标题、active 标签与列表内容各就各位", () => {
72
+ const state = withPending("demo", "详情");
73
+ const pending = renderPage(state, "pending");
74
+ assert.match(pending, /<title>技能进化 · 待处理<\/title>/);
75
+ assert.match(pending, /<a href="\/\?view=pending" class="active">待处理<\/a>/);
76
+ assert.match(pending, /\[待批\] run-x · demo/);
77
+
78
+ const approvals = renderPage(withApproval(), "approvals");
79
+ assert.match(approvals, /<title>技能进化 · 待审批<\/title>/);
80
+ assert.match(approvals, /<a href="\/\?view=approvals" class="active">待审批<\/a>/);
81
+ assert.equal(approvals.includes("run-x"), false, "待审批页只收 approvals,不串待处理条目");
82
+
83
+ const done = renderPage(state, "done");
84
+ assert.match(done, /<title>技能进化 · 已处理<\/title>/);
85
+ assert.match(done, /<a href="\/\?view=done" class="active">已处理<\/a>/);
86
+ assert.equal(done.includes("[待批] run-x"), false, "别的视图不能串内容");
87
+
88
+ const reports = renderPage(state, "reports");
89
+ assert.match(reports, /<title>技能进化 · 复盘报告<\/title>/);
90
+ assert.match(reports, /<a href="\/\?view=reports" class="active">复盘报告<\/a>/);
91
+ });
92
+
93
+ test("六个视图:tab 顺序是 待处理 / 待审批 / 已采纳 / 已处理 / 复盘报告 / 技能管理", () => {
94
+ const html = renderPage(emptyState(), "pending");
95
+ // 只扫 nav:状态栏与页脚里也有 /?view=approvals 链接,整页扫会把它们算进来
96
+ const nav = html.slice(html.indexOf("<nav>"), html.indexOf("</nav>"));
97
+ const order = [...nav.matchAll(/<a href="\/\?view=(\w+)"/g)].map((match) => match[1]);
98
+ assert.deepEqual(order, ["pending", "approvals", "applied", "done", "reports", "skills"]);
99
+ });
100
+
101
+ test("空态:每个列表视图各有一句人话", () => {
102
+ for (const [view, text] of [
103
+ ["pending", "暂无待处理:所有复盘都已完成,也没有待人批的改动。"],
104
+ ["approvals", "没有待批:auto 级不需要人批,approval 级的会出现在这里。"],
105
+ ["applied", "还没有已采纳的提案:采纳过的会连 diff 一起留在这里。"],
106
+ ["done", "暂无已处理记录:还没有回滚、失败或被否决的提案。"],
107
+ ["reports", "暂无复盘报告:还没有 run 写过 01-findings.json。"],
108
+ ] as const) {
109
+ const html = renderPage(emptyState(), view);
110
+ assert.match(html, new RegExp(text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
111
+ assert.equal(html.includes("<ul>"), false, `${view} 空态不该有列表`);
112
+ }
113
+ });
114
+
115
+ test("状态栏:五个数字按顺序,回滚率取整百分比,待批数字链到待审批页", () => {
116
+ const state = emptyState();
117
+ state.summary = { reviewing: 2, stale: 1, pendingApprovals: 3, appliedTotal: 4, rollbackRate: 0.256 };
118
+ const html = renderPage(state, "pending");
119
+ assert.match(
120
+ html,
121
+ /复盘中 2 · 卡死 1 · 待批 <a href="\/\?view=approvals">3<\/a> · 已采纳 <a href="\/\?view=applied">4<\/a> · 回滚率 26%/,
122
+ );
123
+ });
124
+
125
+ test("报告详情:findings 条数、提案增删行、五闸对错都在展开区里", () => {
126
+ const state = emptyState();
127
+ state.reports.push({
128
+ runId: "run-2",
129
+ status: "applied",
130
+ trigger: "manual",
131
+ startedAt: 0,
132
+ findings: { total: 7, rows: [{ skill: "demo", kind: "未加载", reason: "B frontmatter 坏" }] },
133
+ proposal: { type: "improve", skill: "demo", added: 5, removed: 2 },
134
+ review: { status: "reviewed", verdict: "pass", blocking: 1 },
135
+ decision: null,
136
+ verify: {
137
+ status: "applied",
138
+ at: 0,
139
+ gates: [
140
+ { gate: "gate0", name: "授权核对", ok: true },
141
+ { gate: "gate3", name: "诊断", ok: false },
142
+ ],
143
+ },
144
+ });
145
+ const html = renderPage(state, "reports");
146
+ assert.match(html, /findings:7 条/);
147
+ assert.match(html, /…另有 6 条(见 01-findings\.json)/);
148
+ assert.match(html, /提案:improve demo \+5\/-2/);
149
+ assert.match(html, /blocking 1 条/);
150
+ assert.match(html, /决策:未采纳(没有 04-decision\.json)/);
151
+ assert.match(html, /gate0✓ gate3✗/);
152
+ });
153
+
154
+ test("页脚写清只读与采纳入口", () => {
155
+ const html = renderPage(emptyState(), "pending");
156
+ assert.match(html, /只读面板/);
157
+ assert.match(html, /\/skill-evolve approve &lt;runId&gt;/);
158
+ assert.match(html, /href="\/api\/state"/);
159
+ });
160
+
161
+ test("parseView:认 approvals / done / reports,其余回落待处理", () => {
162
+ assert.equal(parseView("approvals"), "approvals");
163
+ assert.equal(parseView("done"), "done");
164
+ assert.equal(parseView("reports"), "reports");
165
+ assert.equal(parseView("pending"), "pending");
166
+ assert.equal(parseView(null), "pending");
167
+ assert.equal(parseView("bogus"), "pending");
168
+ });
169
+
170
+ test("formatStamp:null 给破折号,时间按本地 YYYY-MM-DD HH:mm", () => {
171
+ assert.equal(formatStamp(null), "—");
172
+ assert.equal(formatStamp(new Date(2026, 10, 8, 9, 5).getTime()), "2026-11-08 09:05");
173
+ });
174
+
175
+ test("待处理页:5 秒自刷新只加在这一页,且刷完还在待处理", () => {
176
+ const state = emptyState();
177
+ assert.match(renderPage(state, "pending"), /<meta http-equiv="refresh" content="5; url=\?view=pending">/);
178
+ assert.equal(renderPage(state, "done").includes('http-equiv="refresh"'), false, "看档案时刷新会把页面跳回去");
179
+ assert.equal(renderPage(state, "reports").includes('http-equiv="refresh"'), false);
180
+ });
181
+
182
+ test("待处理页:运行中的条目有自己的徽章,不是只给个数字", () => {
183
+ const state = emptyState();
184
+ state.pending.push({ runId: "run-live", kind: "running", skill: "", at: 0, detail: "阶段:复盘产出中" });
185
+ const html = renderPage(state, "pending");
186
+ assert.match(html, /\[进行中\] run-live/);
187
+ });
188
+
189
+ test("复盘报告:卡死的 run 行首标 [卡死],detail 里带失败原因", () => {
190
+ const state = emptyState();
191
+ state.reports.push({
192
+ runId: "run-dead",
193
+ status: "stale",
194
+ trigger: "auto",
195
+ startedAt: 0,
196
+ findings: { total: 0, rows: [] },
197
+ proposal: null,
198
+ review: null,
199
+ decision: null,
200
+ verify: null,
201
+ failure: "原因:子进程已退出\n日志末行:Error: 模板缺失",
202
+ });
203
+ const html = renderPage(state, "reports");
204
+ assert.match(html, /\[卡死\] <a class="run" href="\/\?view=run&amp;runId=run-dead">run-dead<\/a>/);
205
+ assert.match(html, /原因:子进程已退出/);
206
+ assert.match(html, /日志末行:Error: 模板缺失/);
207
+ });
208
+
209
+ test("待审批页:条目上是 targets / 提案增删行 / verdict 与 blocking 条数", () => {
210
+ const html = renderPage(withApproval(), "approvals");
211
+ assert.match(html, /\[approval\] run-a · demo · improve \+3\/-1 · pass\/blocking 0/);
212
+ assert.match(html, /目标:\.pi\/skills\/demo\/SKILL\.md/);
213
+ assert.match(html, /提案:improve \+3\/-1 行/);
214
+ assert.match(html, /评审:verdict=pass · blocking 0 条/);
215
+ assert.match(html, /runId:run-a/, "详情第一行是完整 runId");
216
+ });
217
+
218
+ test("待审批页:注入回调 + csrf 时渲染批准与拒绝表单,csrf 与 runId 都是 hidden", () => {
219
+ const html = renderPage(withApproval(), "approvals", { canWrite: true, csrf: "tok-123" });
220
+ assert.match(html, /<form method="post" action="\/approve">/);
221
+ assert.match(html, /<form method="post" action="\/reject">/);
222
+ assert.equal(html.match(/name="csrf" value="tok-123"/g)?.length, 2, "两个表单各带一份 csrf");
223
+ assert.equal(html.match(/name="runId" value="run-a"/g)?.length, 2, "两个表单各带一份 runId");
224
+ assert.match(html, />批准</);
225
+ assert.match(html, />拒绝</);
226
+ });
227
+
228
+ test("待审批页:没注入回调(canWrite 缺省)不渲染按钮,页脚写「本实例只读」", () => {
229
+ const html = renderPage(withApproval(), "approvals");
230
+ assert.equal(html.includes("<form"), false, "只读实例不能出现任何表单");
231
+ assert.equal(html.includes("/approve"), false, "连 action 都不该出现");
232
+ assert.match(html, /本实例只读/);
233
+ });
234
+
235
+ test("待审批页:显式 canWrite 但没有 csrf 也不渲染按钮(否则点了必 403)", () => {
236
+ const html = renderPage(withApproval(), "approvals", { canWrite: true });
237
+ assert.equal(html.includes("<form"), false);
238
+ });
239
+
240
+ test("待审批页脚:批准走同一套五道闸,拒绝不动任何技能文件", () => {
241
+ const html = renderPage(withApproval(), "approvals", { canWrite: true, csrf: "t" });
242
+ assert.match(html, /点「批准」= 在 TUI 敲/);
243
+ assert.match(html, /走同一套五道闸/);
244
+ assert.match(html, /「拒绝」只改队列状态,不动任何技能文件/);
245
+ });
246
+
247
+ test("待处理页脚:保留只读口径,并指向待审批页", () => {
248
+ const html = renderPage(emptyState(), "pending");
249
+ assert.match(html, /只读面板/);
250
+ assert.match(html, /去<a href="\/\?view=approvals">待审批页<\/a>直接批/);
251
+ });
252
+
253
+ test("动作结果横幅:msg 与 err 都渲染且转义", () => {
254
+ const ok = renderPage(emptyState(), "approvals", { notice: { kind: "msg", text: "已采纳 run-a" } });
255
+ assert.match(ok, /<p class="notice">已采纳 run-a<\/p>/);
256
+ const bad = renderPage(emptyState(), "approvals", { notice: { kind: "err", text: "<script>x</script>" } });
257
+ assert.match(bad, /<p class="notice err">&lt;script&gt;x&lt;\/script&gt;<\/p>/);
258
+ assert.equal(/<script/i.test(bad), false);
259
+ });
260
+
261
+ test("待审批页不做 5 秒自刷新:刷新会打断正在点的按钮", () => {
262
+ assert.equal(renderPage(emptyState(), "approvals").includes('http-equiv="refresh"'), false);
263
+ });
264
+
265
+ test("控制条:采集与复盘是两个独立开关,各自 post 到 /toggle?field=", () => {
266
+ const html = renderPage(emptyState(), "pending", {
267
+ controls: { telemetry: true, enabled: false, lastActivityAt: null },
268
+ csrf: "tok",
269
+ canWrite: true,
270
+ });
271
+ assert.match(html, /采集遥测:开/);
272
+ assert.match(html, /自动复盘:关/);
273
+ // 两个按钮各自打自己的字段:一个按钮同时翻两个,就回到了「捆在一起」的老问题
274
+ assert.equal(html.includes('action="/toggle?field=telemetry"'), true);
275
+ assert.equal(html.includes('action="/toggle?field=enabled"'), true);
276
+ assert.match(html, /只写本地日志,不花 token/);
277
+ assert.match(html, /会派生子进程,花 token/);
278
+ assert.match(html, /关闭采集遥测/, "按钮文案是动作(当前开 → 关闭),不是状态的复读");
279
+ assert.match(html, /开启自动复盘/);
280
+ assert.equal(html.includes("复盘在跑但采集关着"), false, "两个状态正常时不带警告");
281
+ // 只读(没 csrf)时两个开关只显示状态,不渲染表单
282
+ const readonly = renderPage(emptyState(), "pending", { controls: { telemetry: false, enabled: false, lastActivityAt: null } });
283
+ assert.match(readonly, /采集遥测:关/);
284
+ assert.equal(readonly.includes("/toggle?field="), false);
285
+ });
286
+
287
+ test("控制条:只开复盘不采集时给一行警告,两个都开后警告消失", () => {
288
+ const warn = renderPage(emptyState(), "pending", {
289
+ controls: { telemetry: false, enabled: true, lastActivityAt: null },
290
+ csrf: "tok",
291
+ canWrite: true,
292
+ });
293
+ assert.match(warn, /<p class="warn">⚠ 复盘在跑但采集关着:归因只能靠原始会话,加载率不可得<\/p>/);
294
+ const ok = renderPage(emptyState(), "pending", {
295
+ controls: { telemetry: true, enabled: true, lastActivityAt: null },
296
+ csrf: "tok",
297
+ canWrite: true,
298
+ });
299
+ assert.equal(ok.includes("复盘在跑但采集关着"), false);
300
+ });
@@ -0,0 +1,256 @@
1
+ /**
2
+ * run 详情页的数据读取:六块证据各自退化、runId 越界一律 404、/diff 只回 text/plain。
3
+ * 全部喂临时目录 + 假 res 对象,不起子进程、不占端口。
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 type { ServerResponse } from "node:http";
10
+ import test from "node:test";
11
+ import { handleDiff, readRunDetail } from "../lib/web/run-detail.ts";
12
+
13
+ const NOW = Date.UTC(2026, 10, 8, 12);
14
+ const RUN_ID = "354df7db-1794133942";
15
+
16
+ const DIFF_TEXT = [
17
+ "--- a/.pi/skills/demo/SKILL.md",
18
+ "+++ b/.pi/skills/demo/SKILL.md",
19
+ "@@ -1,2 +1,2 @@",
20
+ " ---",
21
+ "-description: 旧",
22
+ "+description: 新",
23
+ ].join("\n") + "\n";
24
+
25
+ function workspace(): { evolutionDir: string; runDir: string; cwd: string } {
26
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-rundetail-"));
27
+ const evolutionDir = path.join(cwd, ".pi", "evolution");
28
+ const runDir = path.join(evolutionDir, "runs", RUN_ID);
29
+ fs.mkdirSync(runDir, { recursive: true });
30
+ return { evolutionDir, runDir, cwd };
31
+ }
32
+
33
+ function writeJson(file: string, value: unknown): void {
34
+ fs.mkdirSync(path.dirname(file), { recursive: true });
35
+ fs.writeFileSync(file, JSON.stringify(value), "utf8");
36
+ }
37
+
38
+ function writeText(file: string, text: string): void {
39
+ fs.mkdirSync(path.dirname(file), { recursive: true });
40
+ fs.writeFileSync(file, text, "utf8");
41
+ }
42
+
43
+ /** 造一个「证据齐全」的 run:run.json + diff + candidate + 评审 + 决策 + 五闸 + 发现 + child.log */
44
+ function fullRun(): { evolutionDir: string; runDir: string } {
45
+ const { evolutionDir, runDir } = workspace();
46
+ writeJson(path.join(runDir, "run.json"), {
47
+ runId: RUN_ID,
48
+ status: "rejected",
49
+ trigger: "auto",
50
+ dispatchedAt: NOW,
51
+ pid: 65757,
52
+ sessionId: "s",
53
+ proposal: { type: "improve", skill: "demo", status: "ok", added: 1, removed: 1, file: "02-proposal.diff" },
54
+ });
55
+ writeText(path.join(runDir, "02-proposal.diff"), DIFF_TEXT);
56
+ writeText(path.join(runDir, "child.log"), "启动复盘子进程\nmcp timeout\n");
57
+ writeText(path.join(runDir, "candidate", "demo", "SKILL.md"), "---\nname: demo\n---\n# 改后\n");
58
+ writeJson(path.join(runDir, "03-review.json"), {
59
+ status: "rejected",
60
+ verdict: "revise",
61
+ action: "forbidden",
62
+ reviewerModel: "deepseek/deepseek-flash",
63
+ reviewedAt: NOW + 5_000,
64
+ notes: "证据不足",
65
+ blocking: [
66
+ {
67
+ issue: "描述承诺大于正文能力",
68
+ evidence: { file: ".pi/skills/demo/SKILL.md", line: 3, quote: "含 <script> 的引用" },
69
+ },
70
+ { issue: "缺少证据", evidence: {} },
71
+ ],
72
+ });
73
+ writeJson(path.join(runDir, "04-decision.json"), { status: "rejected", approver: "auto", at: NOW + 6_000 });
74
+ writeJson(path.join(runDir, "06-verify.json"), {
75
+ runId: RUN_ID,
76
+ status: "applied",
77
+ at: NOW + 7_000,
78
+ ms: 12,
79
+ wrote: [],
80
+ gates: [
81
+ { gate: "gate0", name: "授权核对", ok: true, ms: 1 },
82
+ { gate: "gate3", name: "复验", ok: false, ms: 2, detail: "复验失败" },
83
+ ],
84
+ });
85
+ writeJson(path.join(runDir, "01-findings.json"), {
86
+ skills: [
87
+ { name: "demo", advertised: true, loaded: false, whyNotLoaded: "G", confidence: "high", evidence: [{ line: 578 }, { line: 622 }] },
88
+ { name: "other", advertised: true, loaded: true, confidence: "medium" },
89
+ ],
90
+ notes: "只差两处",
91
+ });
92
+ return { evolutionDir, runDir };
93
+ }
94
+
95
+ interface FakeResponse {
96
+ status: number;
97
+ headers: Record<string, string>;
98
+ body: string;
99
+ }
100
+
101
+ function fakeResponse(): { res: ServerResponse; out: FakeResponse } {
102
+ const out: FakeResponse = { status: 0, headers: {}, body: "" };
103
+ const res = {
104
+ writeHead(status: number, headers: Record<string, string>) {
105
+ out.status = status;
106
+ out.headers = headers;
107
+ return res;
108
+ },
109
+ end(text: string) {
110
+ out.body = text;
111
+ return res;
112
+ },
113
+ } as unknown as ServerResponse;
114
+ return { res, out };
115
+ }
116
+
117
+ test("readRunDetail:六块证据(概要 / diff / 候选全文 / 评审 / 五闸 / 发现)一次读全", () => {
118
+ const { evolutionDir } = fullRun();
119
+ const detail = readRunDetail(evolutionDir, RUN_ID)!;
120
+ assert.equal(detail.runId, RUN_ID);
121
+ assert.equal(detail.status, "rejected");
122
+ assert.equal(detail.trigger, "auto");
123
+ assert.equal(detail.skill, "demo");
124
+ assert.equal(detail.pid, 65757);
125
+ assert.equal(detail.startedAt, NOW);
126
+ assert.equal(detail.endedAt, NOW + 7_000, "结束时间取最后一个产出时刻");
127
+ assert.equal(detail.hasChildLog, true);
128
+ assert.equal(detail.diff, DIFF_TEXT);
129
+ assert.equal(detail.candidateSkill, "demo");
130
+ assert.match(detail.candidateText ?? "", /# 改后/);
131
+ assert.deepEqual(detail.review, {
132
+ status: "rejected",
133
+ verdict: "revise",
134
+ action: "forbidden",
135
+ reviewerModel: "deepseek/deepseek-flash",
136
+ });
137
+ assert.equal(detail.blocking.length, 2);
138
+ assert.equal(detail.blocking[0]!.line, 3, "证据行号要能直接跳过去");
139
+ assert.match(detail.blocking[1]!.issue, /缺少证据/);
140
+ assert.equal(detail.blocking[1]!.file, "", "缺 evidence 的文件位置不能编一个");
141
+ assert.deepEqual(
142
+ detail.gates.map((gate) => `${gate.gate}:${gate.ok}`),
143
+ ["gate0:true", "gate3:false"],
144
+ );
145
+ assert.equal(detail.gates[1]!.detail, "复验失败");
146
+ assert.equal(detail.verifyStatus, "applied");
147
+ assert.equal(detail.findingsTotal, 2);
148
+ assert.deepEqual(
149
+ detail.findings[0],
150
+ // F / G 是复盘 agent 自己判的码,DISPOSITIONS 里没有(那里只有 A-E + semantic),原样显示
151
+ { skill: "demo", reason: "G", confidence: "high", lines: [578, 622] },
152
+ );
153
+ assert.equal(detail.findings[1]!.reason, "已加载");
154
+ assert.equal(detail.notes, "只差两处");
155
+ });
156
+
157
+ test("readRunDetail:runId 不合法(../、绝对路径、子路径)一律 null,不读盘越界", () => {
158
+ const { evolutionDir } = fullRun();
159
+ for (const runId of ["../x", "../../etc", "/etc/passwd", "a/b", "", "."]) {
160
+ assert.equal(readRunDetail(evolutionDir, runId), null, `runId=${JSON.stringify(runId)} 必须被挡住`);
161
+ }
162
+ });
163
+
164
+ test("readRunDetail:目录不存在 → null(不抛异常)", () => {
165
+ const { evolutionDir } = fullRun();
166
+ assert.equal(readRunDetail(evolutionDir, "00000000-0"), null);
167
+ });
168
+
169
+ test("readRunDetail:只剩 run.json 时六块各自空着,不是抛错也不是编数字", () => {
170
+ const { evolutionDir, runDir } = workspace();
171
+ writeJson(path.join(runDir, "run.json"), { runId: RUN_ID, status: "dispatched", trigger: "manual", dispatchedAt: NOW });
172
+ const detail = readRunDetail(evolutionDir, RUN_ID)!;
173
+ assert.equal(detail.diff, null);
174
+ assert.equal(detail.review, null);
175
+ assert.deepEqual(detail.blocking, []);
176
+ assert.deepEqual(detail.gates, []);
177
+ assert.equal(detail.verifyStatus, null);
178
+ assert.equal(detail.findingsTotal, 0);
179
+ assert.equal(detail.hasChildLog, false);
180
+ assert.equal(detail.candidateText, null);
181
+ assert.equal(detail.endedAt, null);
182
+ assert.equal(detail.pid, null);
183
+ });
184
+
185
+ test("readRunDetail:提案技能名含 ../ 时不读 candidate(拼路径越界)", () => {
186
+ const { evolutionDir, runDir } = workspace();
187
+ writeJson(path.join(runDir, "run.json"), {
188
+ runId: RUN_ID,
189
+ status: "reviewed",
190
+ trigger: "auto",
191
+ dispatchedAt: NOW,
192
+ proposal: { type: "improve", skill: "../../secret" },
193
+ });
194
+ writeText(path.join(evolutionDir, "secret", "SKILL.md"), "SECRET\n");
195
+ const detail = readRunDetail(evolutionDir, RUN_ID)!;
196
+ assert.equal(detail.skill, "../../secret", "展示原值即可,但不去读那个文件");
197
+ assert.equal(detail.candidateText, null);
198
+ });
199
+
200
+ test("readRunDetail:发现超过 20 条只收 20 条,总数照实报", () => {
201
+ const { evolutionDir, runDir } = workspace();
202
+ writeJson(path.join(runDir, "run.json"), { runId: RUN_ID, status: "analyzed", trigger: "auto", dispatchedAt: NOW });
203
+ const skills = Array.from({ length: 25 }, (_unused, index) => ({
204
+ name: `skill-${index + 1}`,
205
+ advertised: true,
206
+ loaded: false,
207
+ whyNotLoaded: "G",
208
+ confidence: "low",
209
+ evidence: [{ line: index + 1 }],
210
+ }));
211
+ writeJson(path.join(runDir, "01-findings.json"), { skills, notes: [] });
212
+ const detail = readRunDetail(evolutionDir, RUN_ID)!;
213
+ assert.equal(detail.findings.length, 20);
214
+ assert.equal(detail.findingsTotal, 25);
215
+ assert.equal(detail.findings[19]!.skill, "skill-20");
216
+ });
217
+
218
+ test("readRunDetail:run.json 没写 proposal 时用 02-proposal.json 的技能名(真实 run 就是这种)", () => {
219
+ const { evolutionDir } = workspace();
220
+ const runDir = path.join(evolutionDir, "runs", "run-real");
221
+ fs.mkdirSync(path.join(runDir, "candidate", "demo"), { recursive: true });
222
+ fs.writeFileSync(path.join(runDir, "run.json"), JSON.stringify({ runId: "run-real", status: "rejected" }), "utf8");
223
+ fs.writeFileSync(
224
+ path.join(runDir, "02-proposal.json"),
225
+ JSON.stringify({ type: "improve", skill: "demo", rationale: "", evidence: [], diffFile: "02-proposal.diff" }),
226
+ "utf8",
227
+ );
228
+ fs.writeFileSync(path.join(runDir, "candidate", "demo", "SKILL.md"), "改后全文\n", "utf8");
229
+ const detail = readRunDetail(evolutionDir, "run-real");
230
+ assert.equal(detail?.skill, "demo");
231
+ assert.equal(detail?.candidateSkill, "demo");
232
+ assert.equal(detail?.candidateText, "改后全文\n");
233
+ });
234
+
235
+ test("handleDiff:命中时回 200 + text/plain + diff 原文", () => {
236
+ const { evolutionDir } = fullRun();
237
+ const { res, out } = fakeResponse();
238
+ handleDiff(evolutionDir, res, new URL(`http://x/diff?runId=${RUN_ID}`));
239
+ assert.equal(out.status, 200);
240
+ assert.equal(out.headers["content-type"], "text/plain; charset=utf-8");
241
+ assert.equal(out.body, DIFF_TEXT);
242
+ });
243
+
244
+ test("handleDiff:没有 02-proposal.diff 与 runId 越界都是 404,文案说清缺什么", () => {
245
+ const { evolutionDir, runDir } = workspace();
246
+ writeJson(path.join(runDir, "run.json"), { runId: RUN_ID, status: "dispatched" });
247
+ const noDiff = fakeResponse();
248
+ handleDiff(evolutionDir, noDiff.res, new URL(`http://x/diff?runId=${RUN_ID}`));
249
+ assert.equal(noDiff.out.status, 404);
250
+ assert.match(noDiff.out.body, /404:这个 run 没写 02-proposal\.diff/);
251
+
252
+ const bad = fakeResponse();
253
+ handleDiff(evolutionDir, bad.res, new URL("http://x/diff?runId=../x"));
254
+ assert.equal(bad.out.status, 404);
255
+ assert.match(bad.out.body, /404:没有这个 run 的 diff/);
256
+ });
@@ -0,0 +1,113 @@
1
+ /**
2
+ * 三个新地址的 HTTP 接线:`?view=skills`、`?view=run&runId=`、`/diff?runId=`。
3
+ * 用 port 0 由内核分配随机端口(与 web-server.test.ts 同一手法),不起任何子进程、不抢固定端口。
4
+ * 渲染与读盘的细节在各自单测里,这一层只验证「路由接对了、状态码与 content-type 对了」。
5
+ */
6
+ import assert from "node:assert/strict";
7
+ import * as fs from "node:fs";
8
+ import * as os from "node:os";
9
+ import * as path from "node:path";
10
+ import test from "node:test";
11
+ import { startWebPanel, type WebPanel } from "../lib/web/server.ts";
12
+
13
+ const DIFF = [
14
+ "--- a/.pi/skills/demo/SKILL.md",
15
+ "+++ b/.pi/skills/demo/SKILL.md",
16
+ "@@ -1,1 +1,1 @@",
17
+ "-description: 旧",
18
+ "+description: 新",
19
+ ].join("\n");
20
+
21
+ /** 工作区里放一个技能与一个带 diff 的 run,够三个地址都点得动 */
22
+ function workspace(): string {
23
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-views-"));
24
+ const evolutionDir = path.join(cwd, ".pi", "evolution");
25
+ const skillDir = path.join(cwd, ".pi", "skills", "demo");
26
+ fs.mkdirSync(skillDir, { recursive: true });
27
+ fs.writeFileSync(path.join(skillDir, "SKILL.md"), "---\nname: demo\n---\n# demo\n", "utf8");
28
+ const runDir = path.join(evolutionDir, "runs", "run-diff");
29
+ fs.mkdirSync(runDir, { recursive: true });
30
+ fs.writeFileSync(
31
+ path.join(runDir, "run.json"),
32
+ JSON.stringify({
33
+ runId: "run-diff",
34
+ status: "reviewed",
35
+ trigger: "auto",
36
+ pid: 1234,
37
+ dispatchedAt: Date.UTC(2026, 10, 8, 10),
38
+ skills: ["demo"],
39
+ proposal: { type: "improve", skill: "demo", status: "ok", added: 1, removed: 1, file: "02-proposal.diff" },
40
+ }),
41
+ "utf8",
42
+ );
43
+ fs.writeFileSync(path.join(runDir, "02-proposal.diff"), DIFF, "utf8");
44
+ return evolutionDir;
45
+ }
46
+
47
+ async function open(evolutionDir: string): Promise<WebPanel> {
48
+ const result = await startWebPanel({ evolutionDir, port: 0 });
49
+ assert.equal(result.ok, true, `面板应该起来:${result.ok ? "" : result.message}`);
50
+ if (!result.ok) throw new Error("unreachable");
51
+ return result.panel;
52
+ }
53
+
54
+ test("GET /?view=skills:技能管理页显式可访问,表格里有 .pi/skills 下的技能", async () => {
55
+ const panel = await open(workspace());
56
+ try {
57
+ const response = await fetch(`${panel.url}/?view=skills`);
58
+ assert.equal(response.status, 200);
59
+ assert.match(response.headers.get("content-type") ?? "", /text\/html/);
60
+ const html = await response.text();
61
+ assert.match(html, /<title>技能进化 · 技能管理<\/title>/);
62
+ assert.match(html, /<a href="\/\?view=skill&amp;name=demo&amp;days=30">demo<\/a>/);
63
+ } finally {
64
+ await panel.close();
65
+ }
66
+ });
67
+
68
+ test("GET /?view=skill&name=:带名字进详情,不带名字回技能管理(不让 ?view=skill 空页)", async () => {
69
+ const panel = await open(workspace());
70
+ try {
71
+ const detail = await (await fetch(`${panel.url}/?view=skill&name=demo`)).text();
72
+ assert.match(detail, /<title>技能进化 · 技能 demo<\/title>/);
73
+ assert.match(detail, /<a href="\/\?view=run&amp;runId=run-diff">run-diff<\/a>/);
74
+ const noName = await (await fetch(`${panel.url}/?view=skill`)).text();
75
+ assert.match(noName, /<title>技能进化 · 技能管理<\/title>/);
76
+ } finally {
77
+ await panel.close();
78
+ }
79
+ });
80
+
81
+ test("GET /?view=run&runId=:真 run 出 diff 视图,不存在的 run 是 404 text/plain", async () => {
82
+ const panel = await open(workspace());
83
+ try {
84
+ const response = await fetch(`${panel.url}/?view=run&runId=run-diff`);
85
+ assert.equal(response.status, 200);
86
+ const html = await response.text();
87
+ assert.match(html, /<title>技能进化 · run run-diff<\/title>/);
88
+ assert.match(html, /<pre class="diff">/);
89
+ assert.match(html, /<span class="dl add">/);
90
+ for (const bad of ["run-nope", "../../etc/passwd", "a/b"]) {
91
+ const missing = await fetch(`${panel.url}/?view=run&runId=${encodeURIComponent(bad)}`);
92
+ assert.equal(missing.status, 404, `runId=${bad} 必须 404`);
93
+ assert.match(missing.headers.get("content-type") ?? "", /text\/plain/);
94
+ assert.equal((await missing.text()).includes("description"), false, "404 页不能带出 diff 内容");
95
+ }
96
+ } finally {
97
+ await panel.close();
98
+ }
99
+ });
100
+
101
+ test("GET /diff:完整 diff 是 text/plain 原文;越界与没有 diff 的 run 都 404", async () => {
102
+ const panel = await open(workspace());
103
+ try {
104
+ const response = await fetch(`${panel.url}/diff?runId=run-diff`);
105
+ assert.equal(response.status, 200);
106
+ assert.match(response.headers.get("content-type") ?? "", /text\/plain; charset=utf-8/);
107
+ assert.equal(await response.text(), DIFF);
108
+ assert.equal((await fetch(`${panel.url}/diff?runId=../queue.json`)).status, 404);
109
+ assert.equal((await fetch(`${panel.url}/diff?runId=run-nope`)).status, 404);
110
+ } finally {
111
+ await panel.close();
112
+ }
113
+ });