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,234 @@
1
+ /**
2
+ * run 详情页(`?view=run&runId=...`)的数据:一次读全这一轮复盘留下的证据。
3
+ *
4
+ * 一个 run 目录里能看的东西固定为六类,正好对应页面上六块:
5
+ * run.json(概要)· 02-proposal.diff(改了什么)· candidate/<技能>/SKILL.md(改后全文)
6
+ * · 03-review.json(评审结论与 blocking)· 06-verify.json(五闸)· 01-findings.json(发现了什么)。
7
+ *
8
+ * 纪律:
9
+ * 1. **只读**;2. **坏文件当空**(六块各自独立退化,缺一块不影响其它块);
10
+ * 3. **runId 必须过白名单**:走 resolveRunDir()(已有闸:解析后的父目录必须是 runs/),
11
+ * 否则 `?view=run&runId=../../..` 就能把仓库外的文件读出来。
12
+ */
13
+ import * as fs from "node:fs";
14
+ import * as path from "node:path";
15
+ import type { ServerResponse } from "node:http";
16
+ import { readVerify } from "../apply.ts";
17
+ import { readProposalSummary } from "../proposal.ts";
18
+ import type { RunRecord } from "../runs.ts";
19
+ import { asNumber, asRecord, asText, readJson, reasonLabel } from "./json-read.ts";
20
+ import { sendText, TEXT_TYPE } from "./http.ts";
21
+ import { resolveRunDir } from "./run-path.ts";
22
+
23
+ /** findings 最多展示 20 条:一轮复盘可能列出几十个技能,页面只用来快速定位问题 */
24
+ export const FINDING_LIMIT = 20;
25
+
26
+ /** 技能名白名单:candidate 目录名来自 run.json 里的提案,绝不能让 `../` 参与拼路径 */
27
+ const SAFE_SKILL = /^[A-Za-z0-9._-]+$/;
28
+
29
+ export interface RunBlocking {
30
+ issue: string;
31
+ file: string;
32
+ line: number | null;
33
+ quote: string;
34
+ }
35
+
36
+ export interface RunGate {
37
+ gate: string;
38
+ name: string;
39
+ ok: boolean;
40
+ detail: string;
41
+ }
42
+
43
+ export interface RunFinding {
44
+ skill: string;
45
+ /** 已加载 / 未加载的原因(G 没触发 之类),搬 reports.ts 的同一套口径 */
46
+ reason: string;
47
+ confidence: string;
48
+ /** evidence 里的行号(01-findings.json 只写 line,没有 file) */
49
+ lines: number[];
50
+ }
51
+
52
+ export interface RunDetail {
53
+ runId: string;
54
+ status: string;
55
+ trigger: string;
56
+ /** 提案目标技能;没有提案时为空串 */
57
+ skill: string;
58
+ startedAt: number | null;
59
+ /** 结束时间:run.json 不写 finishedAt,取 03/04/06 里最后一个时刻(没有就是 null) */
60
+ endedAt: number | null;
61
+ pid: number | null;
62
+ /** 有没有 child.log(决定要不要给日志链接) */
63
+ hasChildLog: boolean;
64
+ /** 02-proposal.diff 原文;没有这个文件为 null */
65
+ diff: string | null;
66
+ /** candidate 目录里的技能名(与 skill 一致才读全文) */
67
+ candidateSkill: string | null;
68
+ candidateText: string | null;
69
+ review: { status: string; verdict: string; action: string; reviewerModel: string } | null;
70
+ blocking: RunBlocking[];
71
+ gates: RunGate[];
72
+ verifyStatus: string | null;
73
+ findings: RunFinding[];
74
+ findingsTotal: number;
75
+ notes: string;
76
+ }
77
+
78
+ function readTextFile(file: string): string | null {
79
+ try {
80
+ return fs.readFileSync(file, "utf8");
81
+ } catch {
82
+ return null;
83
+ }
84
+ }
85
+
86
+ function exists(file: string): boolean {
87
+ try {
88
+ return fs.statSync(file).isFile();
89
+ } catch {
90
+ return false;
91
+ }
92
+ }
93
+
94
+ /** 03-review.json → verdict / action / reviewerModel + blocking(每条带 file:line 与原文引用) */
95
+ function readReview(runDir: string): Pick<RunDetail, "review" | "blocking"> {
96
+ const raw = asRecord(readJson<unknown>(path.join(runDir, "03-review.json")));
97
+ if (!raw) return { review: null, blocking: [] };
98
+ const blocking: RunBlocking[] = [];
99
+ for (const item of Array.isArray(raw.blocking) ? raw.blocking : []) {
100
+ const record = asRecord(item);
101
+ if (!record) continue;
102
+ const evidence = asRecord(record.evidence);
103
+ blocking.push({
104
+ issue: asText(record.issue, "(未写问题描述)"),
105
+ file: asText(evidence?.file),
106
+ line: asNumber(evidence?.line),
107
+ quote: asText(evidence?.quote),
108
+ });
109
+ }
110
+ return {
111
+ review: {
112
+ status: asText(raw.status, "(未写状态)"),
113
+ verdict: asText(raw.verdict, "(未写 verdict)"),
114
+ action: asText(raw.action, "(未写 action)"),
115
+ reviewerModel: asText(raw.reviewerModel, "(未写模型)"),
116
+ },
117
+ blocking,
118
+ };
119
+ }
120
+
121
+ /** 五闸:06-verify.json 的 gates(apply.ts 写的唯一一处闸门记录) */
122
+ function readGates(runDir: string): Pick<RunDetail, "gates" | "verifyStatus"> {
123
+ const verify = readVerify(runDir);
124
+ if (!verify) return { gates: [], verifyStatus: null };
125
+ const gates = (Array.isArray(verify.gates) ? verify.gates : []).map((gate) => ({
126
+ gate: asText(gate.gate),
127
+ name: asText(gate.name),
128
+ ok: gate.ok === true,
129
+ detail: asText(gate.detail),
130
+ }));
131
+ return { gates, verifyStatus: asText(verify.status, "(未写状态)") };
132
+ }
133
+
134
+ /** 01-findings.json:每条技能的归因 + evidence 行号,最多 20 条 */
135
+ function readFindings(runDir: string): Pick<RunDetail, "findings" | "findingsTotal" | "notes"> {
136
+ const raw = asRecord(readJson<unknown>(path.join(runDir, "01-findings.json")));
137
+ if (!raw) return { findings: [], findingsTotal: 0, notes: "" };
138
+ const skills = Array.isArray(raw.skills) ? raw.skills : [];
139
+ const rows: RunFinding[] = [];
140
+ for (const item of skills.slice(0, FINDING_LIMIT)) {
141
+ const record = asRecord(item);
142
+ if (!record) continue;
143
+ const loaded = record.loaded === true;
144
+ const code = asText(record.whyNotLoaded);
145
+ const lines: number[] = [];
146
+ for (const evidence of Array.isArray(record.evidence) ? record.evidence : []) {
147
+ const line = asNumber(asRecord(evidence)?.line);
148
+ if (line !== null) lines.push(line);
149
+ }
150
+ rows.push({
151
+ skill: asText(record.name, "(未写技能名)"),
152
+ reason: loaded ? "已加载" : code.length > 0 ? reasonLabel(code) : "未写归因",
153
+ confidence: asText(record.confidence, "—"),
154
+ lines,
155
+ });
156
+ }
157
+ return { findings: rows, findingsTotal: skills.length, notes: asText(raw.notes) };
158
+ }
159
+
160
+ /** 改后全文:candidate/<技能>/SKILL.md,技能名过白名单 + 解析后父目录必须是 candidate/ */
161
+ function readCandidate(runDir: string, skill: string): Pick<RunDetail, "candidateSkill" | "candidateText"> {
162
+ if (!SAFE_SKILL.test(skill)) return { candidateSkill: null, candidateText: null };
163
+ const candidateRoot = path.join(runDir, "candidate");
164
+ const dir = path.join(candidateRoot, skill);
165
+ if (path.dirname(path.resolve(dir)) !== path.resolve(candidateRoot)) {
166
+ return { candidateSkill: null, candidateText: null };
167
+ }
168
+ const text = readTextFile(path.join(dir, "SKILL.md"));
169
+ return text === null ? { candidateSkill: null, candidateText: null } : { candidateSkill: skill, candidateText: text };
170
+ }
171
+
172
+ /** 判目录是否存在:resolveRunDir 只管「不越界」,不保证目录真存在(由调用方按 404 处理) */
173
+ function isDir(dir: string): boolean {
174
+ try {
175
+ return fs.statSync(dir).isDirectory();
176
+ } catch {
177
+ return false;
178
+ }
179
+ }
180
+
181
+ /**
182
+ * 读一个 run 的详情。runId 不合法(含 `../`、绕对路径)**或目录不存在**一律返回 null ——
183
+ * 调用方据此回 404:不读盘越界,也不返回一个全是空值的假页。
184
+ */
185
+ export function readRunDetail(evolutionDir: string, runId: string): RunDetail | null {
186
+ const runDir = resolveRunDir(evolutionDir, runId);
187
+ if (!runDir || !isDir(runDir)) return null;
188
+ const record = readJson<RunRecord>(path.join(runDir, "run.json"));
189
+ const review = readReview(runDir);
190
+ const gates = readGates(runDir);
191
+ const findings = readFindings(runDir);
192
+ // 子进程常常只写 02-proposal.json 而没回写 run.json(真实 run 就是这样),两处都要看
193
+ const skill = asText(record?.proposal?.skill ?? readProposalSummary(runDir)?.skill);
194
+ const candidate = readCandidate(runDir, skill);
195
+ const decision = asNumber(asRecord(readJson<unknown>(path.join(runDir, "04-decision.json")))?.at);
196
+ const verifyAt = asNumber(asRecord(readJson<unknown>(path.join(runDir, "06-verify.json")))?.at);
197
+ const reviewAt = asNumber(asRecord(readJson<unknown>(path.join(runDir, "03-review.json")))?.reviewedAt);
198
+ const stamps = [decision, verifyAt, reviewAt].filter((value): value is number => value !== null);
199
+ const hasChildLog = exists(path.join(runDir, "child.log"));
200
+ return {
201
+ runId,
202
+ status: asText(record?.status, "(无 run.json)"),
203
+ trigger: asText(record?.trigger, "—"),
204
+ skill,
205
+ startedAt: asNumber(record?.dispatchedAt),
206
+ endedAt: stamps.length === 0 ? null : Math.max(...stamps),
207
+ pid: asNumber(record?.pid),
208
+ hasChildLog,
209
+ diff: readTextFile(path.join(runDir, "02-proposal.diff")),
210
+ ...candidate,
211
+ ...review,
212
+ ...gates,
213
+ ...findings,
214
+ };
215
+ }
216
+
217
+ /**
218
+ * `GET /diff?runId=...`:返回 02-proposal.diff 原文(text/plain),给「完整 diff」链接用。
219
+ * 不合法 / 不存在一律 404,文案与 /log 同一套口径(说清缺什么,别只说 not found)。
220
+ */
221
+ export function handleDiff(evolutionDir: string, response: ServerResponse, url: URL): void {
222
+ const runId = url.searchParams.get("runId") ?? "";
223
+ const runDir = resolveRunDir(evolutionDir, runId);
224
+ if (!runDir) {
225
+ sendText(response, 404, `404:没有这个 run 的 diff(runId=${runId})`, TEXT_TYPE);
226
+ return;
227
+ }
228
+ const text = readTextFile(path.join(runDir, "02-proposal.diff"));
229
+ if (text === null) {
230
+ sendText(response, 404, `404:这个 run 没写 02-proposal.diff(runId=${runId})`, TEXT_TYPE);
231
+ return;
232
+ }
233
+ sendText(response, 200, text, TEXT_TYPE);
234
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * runId → run 目录的唯一解析口。
3
+ *
4
+ * 为什么要有这一层:runId 来自 URL query(`/log?runId=`)与表单(`name=rerun`),是**用户可控输入**。
5
+ * `path.join(runs, "../../etc/passwd")` 是完全合法的 join,所以「拼路径」之前必须先做两道:
6
+ * 字符白名单 + 解析后必须仍在 `runs/` 下。少一道就有目录穿越 —— 面板能读的日志盘上任何文件都能读。
7
+ */
8
+ import * as path from "node:path";
9
+
10
+ /** 白名单:`/`、`\`、`..`(单独作为一段)都会被这道挡掉,`.` 与 `-` 是 runId 里真会出现的 */
11
+ export const SAFE_RUN_ID = /^[A-Za-z0-9._-]+$/;
12
+
13
+ /** 合法且不越界返回绝对路径,否则 null(调用方一律按 404 / 400 处理) */
14
+ export function resolveRunDir(evolutionDir: string, runId: string): string | null {
15
+ const trimmed = runId.trim();
16
+ if (!SAFE_RUN_ID.test(trimmed)) return null;
17
+ // 白名单已经排除了分隔符,真正兜底的是这一句:解析后父目录必须正好是 runs/
18
+ const runsRoot = path.resolve(evolutionDir, "runs");
19
+ const runDir = path.resolve(runsRoot, trimmed);
20
+ return path.dirname(runDir) === runsRoot ? runDir : null;
21
+ }
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Web 面板的 HTTP 外壳(`/skill-evolve web` 的落地实现)。
3
+ *
4
+ * 六条硬边界:
5
+ * 1. **只绑 127.0.0.1 且不做鉴权**:面板能看到会话摘要与技能 diff,凭据换不来安全,绑地址才是;
6
+ * 2. **写操作只有四个入口**:`POST /approve`、`POST /reject`、`POST /action`、`POST /toggle?field=`,
7
+ * 其余路径一律 405;
8
+ * GET 路由是白名单(`/`、`/api/state`、`/log`、`/diff`),绝不把 runs/ 当静态文件服务 ——
9
+ * 那等于把 `.pi/evolution` 整个暴露成文件下载器(`/log` 只回两个日志文件的末 200 行、
10
+ * `/diff` 只回一个 run 的 02-proposal.diff,且 runId 都过了白名单 + 目录校验);
11
+ * 3. **写操作的会话保护**:启动时生成 csrf 嵌进表单,POST 必须带对;Host / Origin 只认本机当前端口。
12
+ * 面板是「本机同一个浏览器点按钮」用的,任何跨站请求都该被拒在门外;
13
+ * 4. **web 层不写盘**:开关 / 派发 / 采纳 / 清理都是 `index.ts` 注入的回调,走与 TUI 完全相同的路径;
14
+ * 本层唯一自己做的写入是审计日志(见 ./audit-log.ts)—— 那是「谁点了什么」的旁证,不属于业务;
15
+ * 5. **端口占用是常态不是异常**:`EADDRINUSE` 返回结构化结果让命令回一句人话,不把栈甩进会话;
16
+ * 6. **不阻止进程退出**:`unref()` + 幂等 `close()`,面板是附属物,pi 退出时不能因为它挂住。
17
+ */
18
+ import * as crypto from "node:crypto";
19
+ import * as http from "node:http";
20
+ import { handleLog, handlePost, type WriteContext, type WriteHooks } from "./actions.ts";
21
+ import { collectControls } from "./activity.ts";
22
+ import { readAudit } from "./audit-log.ts";
23
+ import { collectWebState } from "./data.ts";
24
+ import { parseListQuery, parsePanelView } from "./filter.ts";
25
+ import { HTML_TYPE, MAX_BODY_BYTES, TEXT_TYPE, sendText } from "./http.ts";
26
+ import { clearWebPid, isAlive, readWebPid, writeWebPid } from "./panel-pid.ts";
27
+ import { renderPage } from "./render.ts";
28
+ import { renderRunPage } from "./render-run.ts";
29
+ import { renderSkillPage, renderSkillsPage } from "./render-skills.ts";
30
+ import { handleDiff, readRunDetail } from "./run-detail.ts";
31
+ import { parseDays, readSkillDetail, readSkills } from "./skills.ts";
32
+
33
+ /** 默认端口:7788 是面板约定值(TUI 提示与文档里都写这个),改它得同时改文档 */
34
+ export const DEFAULT_WEB_PORT = 7788;
35
+ export const DEFAULT_WEB_HOST = "127.0.0.1";
36
+
37
+ export { MAX_BODY_BYTES };
38
+ export type { WebAction, ToggleHook, RunHook } from "./actions.ts";
39
+
40
+ export interface WebPanelOptions extends WriteHooks {
41
+ evolutionDir: string;
42
+ port?: number;
43
+ host?: string;
44
+ /** CSRF 令牌:不给就启动时随机生成;单测注入固定值便于构造合法请求 */
45
+ csrf?: string;
46
+ }
47
+
48
+ export interface WebPanel {
49
+ url: string;
50
+ host: string;
51
+ port: number;
52
+ /** 本实例的表单令牌:同一个面板单例复用同一个值(index.ts 单例打开) */
53
+ csrf: string;
54
+ /** 幂等:重复调用都返回同一已完成状态,不抛 ERR_SERVER_NOT_RUNNING */
55
+ close(): Promise<void>;
56
+ }
57
+
58
+ export type WebPanelErrorCode = "EADDRINUSE" | "EACCES" | "UNKNOWN";
59
+
60
+ export type WebPanelResult =
61
+ | { ok: true; panel: WebPanel }
62
+ | { ok: false; code: WebPanelErrorCode; message: string };
63
+
64
+ /** 「最近操作」显示几条:10 条够看出刚才谁点了什么,再多就要翻日志了 */
65
+ const RECENT_ACTIONS = 10;
66
+
67
+ /** 路由层的上下文就是写操作上下文:读写共用一份(evolutionDir / csrf / 回调 / 端口) */
68
+ type RouteContext = WriteContext;
69
+
70
+ /** 一个 GET 的完整应答:详情页的 404 要带状态码,不能只用返回字符串 */
71
+ interface GetResult {
72
+ status: number;
73
+ body: string;
74
+ type: string;
75
+ }
76
+
77
+ /**
78
+ * 读盘渲染:控制条、最近操作、筛选都在这里一次性读好传给渲染层(渲染层不碰磁盘)。
79
+ *
80
+ * 四类页面:`?view=run&runId=`(run 详情)/ `?view=skills`(技能管理)/ `?view=skill&name=`(技能详情)/ 五个列表页。
81
+ * 详情页的 runId 不合法或目录不存在一律 404 —— 靠 resolveRunDir 的目录校验,不靠字符串过滤。
82
+ */
83
+ function renderGet(ctx: RouteContext, url: URL): GetResult {
84
+ const err = url.searchParams.get("err");
85
+ const msg = url.searchParams.get("msg");
86
+ const notice = err !== null ? { kind: "err" as const, text: err } : msg !== null ? { kind: "msg" as const, text: msg } : null;
87
+ const controls = collectControls(ctx.evolutionDir);
88
+ const state = collectWebState({ evolutionDir: ctx.evolutionDir });
89
+ const view = parsePanelView(url.searchParams.get("view"));
90
+ const days = parseDays(url.searchParams.get("days"));
91
+ if (view === "run") {
92
+ const runId = url.searchParams.get("runId") ?? "";
93
+ const detail = readRunDetail(ctx.evolutionDir, runId);
94
+ if (!detail) return { status: 404, body: `404:没有这个 run(runId=${runId})\n`, type: TEXT_TYPE };
95
+ return { status: 200, body: renderRunPage(detail, { state }), type: HTML_TYPE };
96
+ }
97
+ if (view === "skills") {
98
+ const skills = readSkills(ctx.evolutionDir, { days, telemetryOn: controls.telemetry });
99
+ return {
100
+ status: 200,
101
+ body: renderSkillsPage(skills, state, { csrf: ctx.csrf, canWrite: writable(ctx), controls }),
102
+ type: HTML_TYPE,
103
+ };
104
+ }
105
+ if (view === "skill") {
106
+ const name = url.searchParams.get("name") ?? "";
107
+ // 没给技能名就当点了「技能管理」:一个空名字的详情页没有任何信息可看
108
+ if (name.length === 0) {
109
+ const skills = readSkills(ctx.evolutionDir, { days, telemetryOn: controls.telemetry });
110
+ return {
111
+ status: 200,
112
+ body: renderSkillsPage(skills, state, { csrf: ctx.csrf, canWrite: writable(ctx), controls }),
113
+ type: HTML_TYPE,
114
+ };
115
+ }
116
+ const detail = readSkillDetail(ctx.evolutionDir, name, { days, telemetryOn: controls.telemetry });
117
+ return { status: 200, body: renderSkillPage(detail, state), type: HTML_TYPE };
118
+ }
119
+ return {
120
+ status: 200,
121
+ body: renderPage(state, view, {
122
+ csrf: ctx.csrf,
123
+ // 一对审批动作要么都能点、要么都不显示:只显示一个会让人以为另一半也能用
124
+ canWrite: writable(ctx),
125
+ notice,
126
+ controls,
127
+ audit: readAudit(ctx.evolutionDir, RECENT_ACTIONS),
128
+ filter: parseListQuery(url.searchParams),
129
+ }),
130
+ type: HTML_TYPE,
131
+ };
132
+ }
133
+
134
+ /** 面板是否可写:审批回调成对注入才算(历史口径,保持这一条,别按单个动作判断) */
135
+ function writable(ctx: RouteContext): boolean {
136
+ return Boolean(ctx.onApprove && ctx.onReject);
137
+ }
138
+
139
+ /** 路由白名单:任何不在表里的路径都是 404,不拼磁盘路径 */
140
+ function route(ctx: RouteContext, request: http.IncomingMessage, response: http.ServerResponse): void {
141
+ const method = (request.method ?? "GET").toUpperCase();
142
+ // 用固定 base 解析:req.url 是相对路径,直接 new URL 会抛
143
+ let url: URL;
144
+ try {
145
+ url = new URL(request.url ?? "/", `http://${DEFAULT_WEB_HOST}`);
146
+ } catch {
147
+ sendText(response, 400, "400:请求路径解析失败\n");
148
+ return;
149
+ }
150
+ if (method === "POST") {
151
+ // 405 / 415 / 403 等各道校验都在 handlePost 里,这里只负责别把拒绝变成未捕获异常
152
+ void handlePost(ctx, request, response, url).catch((error: unknown) => {
153
+ console.error(`[skill-evolve] Web 面板写操作处理失败:${String(error)}`);
154
+ if (!response.headersSent) sendText(response, 500, "500:写操作处理失败,详见 pi 日志\n");
155
+ else response.end();
156
+ });
157
+ return;
158
+ }
159
+ if (method !== "GET") {
160
+ response.setHeader("allow", "GET");
161
+ sendText(response, 405, "405:面板只接受 GET 与 /approve、/reject、/action、/toggle 的 POST\n");
162
+ return;
163
+ }
164
+ if (url.pathname === "/") {
165
+ const result = renderGet(ctx, url);
166
+ sendText(response, result.status, result.body, result.type);
167
+ return;
168
+ }
169
+ if (url.pathname === "/api/state") {
170
+ sendText(response, 200, `${JSON.stringify(collectWebState({ evolutionDir: ctx.evolutionDir }), null, 2)}\n`, "application/json; charset=utf-8");
171
+ return;
172
+ }
173
+ if (url.pathname === "/log") {
174
+ handleLog(ctx.evolutionDir, response, url);
175
+ return;
176
+ }
177
+ if (url.pathname === "/diff") {
178
+ handleDiff(ctx.evolutionDir, response, url);
179
+ return;
180
+ }
181
+ sendText(response, 404, `404:没有 ${url.pathname}(只提供 /、/api/state、/log 与 /diff)\n`);
182
+ }
183
+
184
+ function describeFailure(
185
+ code: WebPanelErrorCode,
186
+ host: string,
187
+ port: number,
188
+ detail: string,
189
+ evolutionDir: string,
190
+ ): string {
191
+ if (code === "EADDRINUSE") {
192
+ // 先判读「占端口的是不是本机面板」:命中就给地址与 pid;看不出来才提醒别乱 kill
193
+ const record = readWebPid(evolutionDir);
194
+ if (record && isAlive(record.pid)) {
195
+ return `Web 面板已在 ${record.url} 运行(pid ${record.pid},起于 ${new Date(record.startedAt).toISOString()})。要换端口:/skill-evolve web --port 7799`;
196
+ }
197
+ return `Web 面板没起来:${host}:${port} 已被占用(多半是已经有一个面板在跑)。换端口:/skill-evolve web --port 7799(不是本机面板记录的 pid,可能是别的服务;不要直接 kill,先 lsof -nP -iTCP:${port} -sTCP:LISTEN 看 COMMAND)`;
198
+ }
199
+ if (code === "EACCES") return `Web 面板没起来:没有权限监听 ${host}:${port}(换一个 >1024 的端口)`;
200
+ return `Web 面板没起来:${detail}`;
201
+ }
202
+
203
+ export function startWebPanel(options: WebPanelOptions): Promise<WebPanelResult> {
204
+ const port = options.port ?? DEFAULT_WEB_PORT;
205
+ const host = options.host ?? DEFAULT_WEB_HOST;
206
+ // 每次启动新令牌:面板重启后旧页面的表单立刻失效(旧页面本来就点不动了)
207
+ const csrf = options.csrf ?? crypto.randomUUID();
208
+ let actualPort = port;
209
+ const ctx: RouteContext = {
210
+ evolutionDir: options.evolutionDir,
211
+ csrf,
212
+ ...(options.onApprove ? { onApprove: options.onApprove } : {}),
213
+ ...(options.onReject ? { onReject: options.onReject } : {}),
214
+ ...(options.onToggle ? { onToggle: options.onToggle } : {}),
215
+ ...(options.onRunNow ? { onRunNow: options.onRunNow } : {}),
216
+ ...(options.onCleanup ? { onCleanup: options.onCleanup } : {}),
217
+ port: () => actualPort,
218
+ };
219
+ const server = http.createServer((request, response) => {
220
+ try {
221
+ route(ctx, request, response);
222
+ } catch (error) {
223
+ // 读盘炸了也只回一句:网页永远不该把栈吐给用户,日志里有原文
224
+ console.error(`[skill-evolve] Web 面板渲染失败:${String(error)}`);
225
+ if (!response.headersSent) sendText(response, 500, "500:读 .pi/evolution 出错,详见 pi 日志\n");
226
+ else response.end();
227
+ }
228
+ });
229
+
230
+ return new Promise<WebPanelResult>((resolve) => {
231
+ let settled = false;
232
+ const onError = (error: NodeJS.ErrnoException): void => {
233
+ if (settled) return;
234
+ settled = true;
235
+ server.removeListener("listening", onListening);
236
+ const code: WebPanelErrorCode =
237
+ error.code === "EADDRINUSE" ? "EADDRINUSE" : error.code === "EACCES" ? "EACCES" : "UNKNOWN";
238
+ resolve({ ok: false, code, message: describeFailure(code, host, port, error.message, options.evolutionDir) });
239
+ };
240
+ const onListening = (): void => {
241
+ if (settled) return;
242
+ settled = true;
243
+ server.removeListener("error", onError);
244
+ // 起监听之后再出错(客户端重置等)只记日志:不能让它变成未捕获异常
245
+ server.on("error", (error) => console.error(`[skill-evolve] Web 面板运行时错误:${String(error)}`));
246
+ // 面板是附属物:pi 退出时不该被它挂着
247
+ server.unref();
248
+ const address = server.address();
249
+ actualPort = address && typeof address === "object" ? address.port : port;
250
+ const url = `http://${host}:${actualPort}`;
251
+ // 落盘「谁持有面板」:端口被占时命令能分清是本机面板还是别的服务(见 ./panel-pid.ts)
252
+ writeWebPid(options.evolutionDir, { pid: process.pid, host, port: actualPort, url, startedAt: Date.now() });
253
+ let closed = false;
254
+ const close = async (): Promise<void> => {
255
+ if (closed) return;
256
+ closed = true;
257
+ server.closeAllConnections();
258
+ await new Promise<void>((done) => server.close(() => done()));
259
+ // 只删自己写的记录:文件里是别的 pid 就留着,别替另一个实例抹掉活面板
260
+ clearWebPid(options.evolutionDir, process.pid);
261
+ };
262
+ resolve({ ok: true, panel: { url, host, port: actualPort, csrf, close } });
263
+ };
264
+ server.once("error", onError);
265
+ server.once("listening", onListening);
266
+ server.listen(port, host);
267
+ });
268
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * 页面骨架与顶部导航:五个列表页 + 技能管理页 + 两个详情页共用同一份。
3
+ *
4
+ * 为什么抽出来:run 详情页与技能详情页也要状态栏、六个 tab、内联样式,
5
+ * 各自复制一遍必然出现「点进详情页 tab 高亮错页、状态栏数字少算一个」。
6
+ * 这里只拼字符串:不读盘、不发请求、不引外部资源 —— 数据由调用方读好传进来。
7
+ */
8
+ import type { WebState, WebView } from "./data.ts";
9
+ import { STYLE } from "./style.ts";
10
+
11
+ /** 导航里的 tab 与顺序(用户 2026-11-08 点名要的顺序:已采纳独立、技能管理排最后) */
12
+ export const TAB_ORDER = ["pending", "approvals", "applied", "done", "reports", "skills"] as const;
13
+ export type TabView = (typeof TAB_ORDER)[number];
14
+
15
+ export const VIEW_LABEL: Record<TabView, string> = {
16
+ pending: "待处理",
17
+ approvals: "待审批",
18
+ applied: "已采纳",
19
+ done: "已处理",
20
+ reports: "复盘报告",
21
+ skills: "技能管理",
22
+ };
23
+
24
+ /**
25
+ * 当前高亮的 tab。详情页传 `null`:它们不在导航里(要 runId / 技能名才能进来),
26
+ * 硬highlight 某个 tab 等于骗人 —— 点那个 tab 回不到这一页。
27
+ */
28
+ export type ActiveTab = WebView | "skills" | null;
29
+
30
+ /** 导航:`<a href>` 只用枚举值拼,绝不把 query 原样回显 */
31
+ export function tabs(active: ActiveTab): string {
32
+ return TAB_ORDER.map((item) => {
33
+ const className = item === active ? ' class="active"' : "";
34
+ return `<a href="/?view=${item}"${className}>${VIEW_LABEL[item]}</a>`;
35
+ }).join("\n");
36
+ }
37
+
38
+ /** 状态栏:全是数字,安全;「待批 N」「已采纳 N」都直接链到对应页(用户就是点那个数字的) */
39
+ export function statusBar(state: WebState): string {
40
+ const percent = (state.summary.rollbackRate * 100).toFixed(0);
41
+ return [
42
+ `复盘中 ${state.summary.reviewing}`,
43
+ `卡死 ${state.summary.stale}`,
44
+ `待批 <a href="/?view=approvals">${state.summary.pendingApprovals}</a>`,
45
+ `已采纳 <a href="/?view=applied">${state.summary.appliedTotal}</a>`,
46
+ `回滚率 ${percent}%`,
47
+ ].join(" · ");
48
+ }
49
+
50
+ export interface ShellInput {
51
+ state: WebState;
52
+ /** 页面标题:进 `<title>`,页面上不再重复一遍 */
53
+ title: string;
54
+ active: ActiveTab;
55
+ /** `<head>` 里的额外标签(目前只有待处理页的 5 秒自刷新) */
56
+ head?: string;
57
+ /** 导航上方的「控制条 + 横幅」 */
58
+ top?: string;
59
+ /** `<main>` 内容 */
60
+ body: string;
61
+ /** 页脚内容(列表页传「最近操作 + 只读说明」) */
62
+ foot?: string;
63
+ }
64
+
65
+ export function shellPage(input: ShellInput): string {
66
+ return `<!doctype html>
67
+ <html lang="zh-CN">
68
+ <head>
69
+ <meta charset="utf-8">
70
+ <meta name="viewport" content="width=device-width, initial-scale=1">
71
+ ${input.head ?? ""}<title>技能进化 · ${input.title}</title>
72
+ <style>${STYLE}</style>
73
+ </head>
74
+ <body>
75
+ <h1>技能进化面板</h1>
76
+ <p class="bar">${statusBar(input.state)}</p>
77
+ ${input.top ?? ""}<nav>
78
+ ${tabs(input.active)}
79
+ </nav>
80
+ ${input.body}
81
+ <footer>
82
+ ${input.foot ?? ""}
83
+ </footer>
84
+ </body>
85
+ </html>
86
+ `;
87
+ }