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,133 @@
1
+ /**
2
+ * 会话送达:三类事件各发一条、同 runId 不重复、send 抛错不炸主流程、无 UI 时降级为日志。
3
+ */
4
+ import assert from "node:assert/strict";
5
+ import * as fs from "node:fs";
6
+ import * as os from "node:os";
7
+ import * as path from "node:path";
8
+ import test from "node:test";
9
+ import { collectNotifications, deliverNotifications, NOTIFIED_FILE } from "../lib/notify.ts";
10
+
11
+ const NOW = Date.UTC(2026, 10, 8, 12);
12
+
13
+ function workspace(): string {
14
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-notify-"));
15
+ const evolutionDir = path.join(cwd, ".pi", "evolution");
16
+ fs.mkdirSync(path.join(evolutionDir, "runs"), { recursive: true });
17
+ return evolutionDir;
18
+ }
19
+
20
+ function writeRun(evolutionDir: string, runId: string, status: string): string {
21
+ const runDir = path.join(evolutionDir, "runs", runId);
22
+ fs.mkdirSync(runDir, { recursive: true });
23
+ const record = { runId, status, dispatchedAt: NOW, sessionId: "s", trigger: "auto", pid: 1 };
24
+ fs.writeFileSync(path.join(runDir, "run.json"), JSON.stringify(record), "utf8");
25
+ return runDir;
26
+ }
27
+
28
+ function writeQueue(evolutionDir: string, items: Record<string, unknown>[]): void {
29
+ fs.writeFileSync(path.join(evolutionDir, "queue.json"), JSON.stringify(items), "utf8");
30
+ }
31
+
32
+ function item(runId: string, status: string, level: string, skill = "demo"): Record<string, unknown> {
33
+ return { runId, level, skill, targets: [`.pi/skills/${skill}/SKILL.md`], createdAt: NOW, status };
34
+ }
35
+
36
+ test("stale:一条消息,带原因与日志末行", () => {
37
+ const evolutionDir = workspace();
38
+ const runDir = writeRun(evolutionDir, "run-dead", "stale");
39
+ fs.writeFileSync(
40
+ path.join(runDir, "run-error.json"),
41
+ JSON.stringify({ reason: "timeout", at: NOW, childLogTail: ["准备中", "子进程 12 分钟没动静"] }),
42
+ "utf8",
43
+ );
44
+ const sent: string[] = [];
45
+ deliverNotifications({ evolutionDir, send: (text) => sent.push(text) });
46
+ assert.equal(sent.length, 1);
47
+ assert.match(sent[0]!, /^技能复盘失败:run-dead(超时)/);
48
+ assert.match(sent[0]!, /日志末行:子进程 12 分钟没动静/);
49
+ assert.equal(sent[0]!.split("\n").length, 2, "最多两行");
50
+ });
51
+
52
+ test("待批:一条消息,附采纳命令", () => {
53
+ const evolutionDir = workspace();
54
+ writeRun(evolutionDir, "run-wait", "reviewed");
55
+ writeQueue(evolutionDir, [item("run-wait", "pending", "approval", "rust")]);
56
+ const sent: string[] = [];
57
+ deliverNotifications({ evolutionDir, send: (text) => sent.push(text) });
58
+ assert.equal(sent.length, 1);
59
+ assert.match(sent[0]!, /^待批:run-wait 改动 rust/);
60
+ assert.match(sent[0]!, /\/skill-evolve approve run-wait/);
61
+ });
62
+
63
+ test("auto 采纳:一条消息,没有 diff", () => {
64
+ const evolutionDir = workspace();
65
+ writeRun(evolutionDir, "run-auto", "applied");
66
+ writeQueue(evolutionDir, [item("run-auto", "applied", "auto")]);
67
+ const sent: string[] = [];
68
+ deliverNotifications({ evolutionDir, send: (text) => sent.push(text) });
69
+ assert.deepEqual(sent, ["已自动采纳:run-auto(demo)"]);
70
+ });
71
+
72
+ test("去重:同一 runId 的同一类事件只发一次,跨调用也认(notified.json)", () => {
73
+ const evolutionDir = workspace();
74
+ writeRun(evolutionDir, "run-once", "stale");
75
+ const first: string[] = [];
76
+ const second: string[] = [];
77
+ assert.equal(deliverNotifications({ evolutionDir, now: NOW, send: (text) => first.push(text) }).length, 1);
78
+ assert.equal(deliverNotifications({ evolutionDir, now: NOW + 1, send: (text) => second.push(text) }).length, 0);
79
+ assert.equal(first.length, 1);
80
+ assert.deepEqual(second, []);
81
+ const notified = JSON.parse(fs.readFileSync(path.join(evolutionDir, "runs", "run-once", NOTIFIED_FILE), "utf8"));
82
+ assert.deepEqual(notified.kinds, ["stale"]);
83
+ });
84
+
85
+ test("send 抛错不炸主流程,且不记 notified(下一轮还能重试)", () => {
86
+ const evolutionDir = workspace();
87
+ writeRun(evolutionDir, "run-throw", "stale");
88
+ const logs: string[] = [];
89
+ const delivered = deliverNotifications({
90
+ evolutionDir,
91
+ send: () => {
92
+ throw new Error("no ui");
93
+ },
94
+ log: (msg) => logs.push(msg),
95
+ });
96
+ assert.deepEqual(delivered, [], "这条没送出去,不算已送达");
97
+ assert.equal(logs.length, 1);
98
+ assert.match(logs[0]!, /会话消息发送失败(run-throw)/);
99
+ assert.equal(fs.existsSync(path.join(evolutionDir, "runs", "run-throw", NOTIFIED_FILE)), false);
100
+
101
+ const retried: string[] = [];
102
+ assert.equal(deliverNotifications({ evolutionDir, send: (text) => retried.push(text) }).length, 1);
103
+ assert.equal(retried.length, 1, "重试成功");
104
+ });
105
+
106
+ test("拿不到 send(老版本 / 无 UI):降级成日志,且不再重复刷", () => {
107
+ const evolutionDir = workspace();
108
+ writeRun(evolutionDir, "run-log", "stale");
109
+ const logs: string[] = [];
110
+ assert.equal(deliverNotifications({ evolutionDir, log: (msg) => logs.push(msg) }).length, 1);
111
+ assert.equal(logs.length, 1);
112
+ assert.match(logs[0]!, /技能复盘失败:run-log/, "多行文本压成一行");
113
+ assert.equal(logs[0]!.includes("\n"), false);
114
+ assert.equal(deliverNotifications({ evolutionDir, log: (msg) => logs.push(msg) }).length, 0);
115
+ });
116
+
117
+ test("队列里没有对应 run 目录的条目(手工塞的)不推送", () => {
118
+ const evolutionDir = workspace();
119
+ writeQueue(evolutionDir, [item("run-ghost", "pending", "approval")]);
120
+ assert.deepEqual(collectNotifications(evolutionDir), []);
121
+ const sent: string[] = [];
122
+ assert.deepEqual(deliverNotifications({ evolutionDir, send: (text) => sent.push(text) }), []);
123
+ assert.deepEqual(sent, []);
124
+ });
125
+
126
+ test("三类事件混在一起时各发一条,顺序稳定", () => {
127
+ const evolutionDir = workspace();
128
+ writeRun(evolutionDir, "run-mixed", "stale");
129
+ writeRun(evolutionDir, "run-ok", "applied");
130
+ writeQueue(evolutionDir, [item("run-ok", "applied", "auto")]);
131
+ const kinds = collectNotifications(evolutionDir).map((event) => `${event.runId}:${event.kind}`);
132
+ assert.deepEqual(kinds, ["run-mixed:stale", "run-ok:auto-applied"]);
133
+ });
@@ -0,0 +1,100 @@
1
+ /**
2
+ * M6 可观测胶水:面板刷新把状态变迁转成条目、派发立刻记一条、周报顺带落指标。
3
+ * 不 import pi 包,也不需要真子进程(周报在没有候选技能时不会去跑 rg)。
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 { createObserver } from "../lib/evolution-observe.ts";
11
+ import { metricsFile, readMetrics } from "../lib/metrics.ts";
12
+ import { directQueue } from "../lib/telemetry.ts";
13
+
14
+ const NOW = Date.UTC(2026, 10, 8, 12);
15
+
16
+ function tmpWorkspace(): { cwd: string; evolutionDir: string; telemetryDir: string } {
17
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-observe-"));
18
+ const evolutionDir = path.join(cwd, ".pi", "evolution");
19
+ fs.mkdirSync(path.join(cwd, ".pi", "skills", "demo"), { recursive: true });
20
+ fs.writeFileSync(path.join(cwd, ".pi", "skills", "demo", "SKILL.md"), "---\nname: demo\ndescription: 演示\n---\n", "utf8");
21
+ return { cwd, evolutionDir, telemetryDir: path.join(evolutionDir, "telemetry") };
22
+ }
23
+
24
+ function writeRun(evolutionDir: string, runId: string, status: string): void {
25
+ const runDir = path.join(evolutionDir, "runs", runId);
26
+ fs.mkdirSync(runDir, { recursive: true });
27
+ fs.writeFileSync(path.join(runDir, "run.json"), JSON.stringify({ runId, status, dispatchedAt: NOW, sessionId: "s" }), "utf8");
28
+ }
29
+
30
+ test("refresh:状态变迁发条目并推面板状态", () => {
31
+ const { evolutionDir, cwd, telemetryDir } = tmpWorkspace();
32
+ writeRun(evolutionDir, "r1", "reviewed");
33
+ writeRun(evolutionDir, "r2", "dispatched");
34
+ const entries: string[] = [];
35
+ const states: number[] = [];
36
+ const observer = createObserver({
37
+ cwd,
38
+ evolutionDir,
39
+ telemetryDir,
40
+ queue: directQueue,
41
+ now: () => NOW,
42
+ panel: {
43
+ onEntry: (entry) => entries.push(`${entry.kind}:${entry.runId}`),
44
+ onState: (state) => states.push(state.reviewing),
45
+ },
46
+ });
47
+ observer.refresh();
48
+ assert.deepEqual(entries, ["reviewed:r1"], "只有出了结论的 run 发条目");
49
+ assert.deepEqual(states, [1], "dispatched 的 run 算进行中");
50
+ observer.refresh();
51
+ assert.deepEqual(entries, ["reviewed:r1"], "重复刷新不重复发");
52
+ });
53
+
54
+ test("noteDispatched:派发立刻记一条,下一轮 refresh 不会重复记同一状态", () => {
55
+ const { evolutionDir, cwd, telemetryDir } = tmpWorkspace();
56
+ writeRun(evolutionDir, "r9", "dispatched");
57
+ const entries: string[] = [];
58
+ const observer = createObserver({
59
+ cwd,
60
+ evolutionDir,
61
+ telemetryDir,
62
+ queue: directQueue,
63
+ now: () => NOW,
64
+ panel: { onEntry: (entry) => entries.push(`${entry.kind}:${entry.runId}`) },
65
+ });
66
+ observer.noteDispatched("r9");
67
+ assert.deepEqual(entries, ["dispatched:r9"]);
68
+ observer.refresh();
69
+ assert.deepEqual(entries, ["dispatched:r9"]);
70
+ });
71
+
72
+ test("weekReportText:出周报并把当周指标落一行(重复调用不重复写)", async () => {
73
+ const { evolutionDir, cwd, telemetryDir } = tmpWorkspace();
74
+ fs.mkdirSync(telemetryDir, { recursive: true });
75
+ const line = {
76
+ ts: NOW,
77
+ iso: new Date(NOW).toISOString(),
78
+ kind: "advertised",
79
+ sessionId: "s",
80
+ skills: [{ name: "demo", filePath: "/x/demo/SKILL.md", disabled: false, source: "project" }],
81
+ };
82
+ fs.writeFileSync(path.join(telemetryDir, "2026-11-08.jsonl"), `${JSON.stringify(line)}\n`, "utf8");
83
+ const observer = createObserver({ cwd, evolutionDir, telemetryDir, queue: directQueue, now: () => NOW });
84
+ const first = await observer.weekReportText();
85
+ assert.match(first, /加载率 0\.0%(加载 0 \/ 曝光 1)/);
86
+ const rows = readMetrics(evolutionDir);
87
+ assert.equal(rows.length, 1);
88
+ assert.equal(rows[0]!.advertised, 1);
89
+ await observer.weekReportText();
90
+ assert.equal(readMetrics(evolutionDir).length, 1, "同周同 runId 不重复写");
91
+ assert.equal(fs.existsSync(metricsFile(evolutionDir)), true);
92
+ });
93
+
94
+ test("weekReportText:没有任何遥测也不抛,报告写样本不足", async () => {
95
+ const { evolutionDir, cwd, telemetryDir } = tmpWorkspace();
96
+ const observer = createObserver({ cwd, evolutionDir, telemetryDir, queue: directQueue, now: () => NOW });
97
+ const text = await observer.weekReportText();
98
+ assert.match(text, /技能进化周报:2026-W45/);
99
+ assert.match(text, /候选表只是提醒,不是判决/);
100
+ });
@@ -0,0 +1,84 @@
1
+ /**
2
+ * 「谁持有 Web 面板」的记录文件(`.pi/evolution/locks/web.json`):
3
+ * 坏数据当没有、写失败不抛、删记录要比 pid。只碰临时目录,不起任何子进程。
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 { clearWebPid, isAlive, readWebPid, WEB_PID_FILE, webPidFile, writeWebPid } from "../lib/web/panel-pid.ts";
11
+
12
+ function workspace(): string {
13
+ return fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-panelpid-"));
14
+ }
15
+
16
+ /** 不存在的 pid:Linux 的 pid_max 远小于 INT32_MAX,这个号系统里不会有进程 */
17
+ const DEAD_PID = 2_147_483_646;
18
+
19
+ function record(pid: number): { pid: number; host: string; port: number; url: string; startedAt: number } {
20
+ return { pid, host: "127.0.0.1", port: 7788, url: "http://127.0.0.1:7788", startedAt: 1_760_000_000_000 };
21
+ }
22
+
23
+ test("记录落在 locks/web.json:writeWebPid 建目录、原子写、能原样读回", () => {
24
+ const dir = workspace();
25
+ const info = record(process.pid);
26
+ writeWebPid(dir, info);
27
+ assert.equal(WEB_PID_FILE, path.join("locks", "web.json"));
28
+ assert.deepEqual(readWebPid(dir), info);
29
+ // 原子写的临时文件不能留在目录里
30
+ assert.deepEqual(fs.readdirSync(path.join(dir, "locks")), ["web.json"]);
31
+ assert.equal(fs.existsSync(`${webPidFile(dir)}.tmp`), false);
32
+ });
33
+
34
+ test("坏 JSON / 字段不全 / 不是对象 → readWebPid 一律 null", () => {
35
+ const dir = workspace();
36
+ const file = webPidFile(dir);
37
+ fs.mkdirSync(path.dirname(file), { recursive: true });
38
+ const good = record(process.pid);
39
+ const bad = [
40
+ "{ 不是 JSON",
41
+ "",
42
+ "[]",
43
+ "null",
44
+ JSON.stringify({ ...good, pid: 0 }),
45
+ JSON.stringify({ ...good, port: "7788" }),
46
+ JSON.stringify({ ...good, url: "" }),
47
+ JSON.stringify({ ...good, startedAt: "昨天" }),
48
+ JSON.stringify({ pid: good.pid, host: good.host }),
49
+ ];
50
+ for (const raw of bad) {
51
+ fs.writeFileSync(file, raw, "utf8");
52
+ assert.equal(readWebPid(dir), null, `坏数据必须当没有:${raw}`);
53
+ }
54
+ fs.unlinkSync(file);
55
+ assert.equal(readWebPid(dir), null, "文件不存在同样是 null");
56
+ });
57
+
58
+ test("isAlive:本进程算活,不存在的 pid / 非法 pid 算死", () => {
59
+ assert.equal(isAlive(process.pid), true);
60
+ assert.equal(isAlive(DEAD_PID), false);
61
+ assert.equal(isAlive(0), false);
62
+ assert.equal(isAlive(-1), false);
63
+ });
64
+
65
+ test("写记录失败只记日志不抛:locks 位置被文件占住时面板照常运行", () => {
66
+ const dir = workspace();
67
+ fs.writeFileSync(path.join(dir, "locks"), "占位", "utf8");
68
+ const logs: string[] = [];
69
+ assert.doesNotThrow(() => writeWebPid(dir, record(process.pid), (msg) => logs.push(msg)));
70
+ assert.equal(logs.length, 1);
71
+ assert.match(logs[0]!, /写 Web 面板记录失败/);
72
+ });
73
+
74
+ test("clearWebPid:pid 对上才删,别人的记录与读不出来的坏记录都留着", () => {
75
+ const dir = workspace();
76
+ writeWebPid(dir, record(4321));
77
+ clearWebPid(dir, process.pid);
78
+ assert.equal(readWebPid(dir)?.pid, 4321, "不是自己写的记录不能删");
79
+ clearWebPid(dir, 4321);
80
+ assert.equal(fs.existsSync(webPidFile(dir)), false);
81
+ fs.writeFileSync(webPidFile(dir), "{坏", "utf8");
82
+ clearWebPid(dir, process.pid);
83
+ assert.equal(fs.existsSync(webPidFile(dir)), true, "坏记录无法证明归属,也不清");
84
+ });
@@ -0,0 +1,115 @@
1
+ /**
2
+ * M6 面板:没数据不显示 0、状态栏只放最紧急一件、状态变迁只发一次 `/tree` 条目。
3
+ * 用例编号对应设计稿 14.1(四层可见性)。
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 { collectPanelState, entryLines, panelWidget, statusEntries, statusLine } from "../lib/panel.ts";
11
+
12
+ const NOW = Date.UTC(2026, 10, 8, 12);
13
+
14
+ function tmpDir(): string {
15
+ return fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-panel-"));
16
+ }
17
+
18
+ function writeRun(dir: string, runId: string, status: string, dispatchedAt = NOW, extra: Record<string, unknown> = {}): void {
19
+ const runDir = path.join(dir, "runs", runId);
20
+ fs.mkdirSync(runDir, { recursive: true });
21
+ fs.writeFileSync(path.join(runDir, "run.json"), JSON.stringify({ runId, status, dispatchedAt, sessionId: "s", ...extra }), "utf8");
22
+ }
23
+
24
+ function writeQueue(dir: string, items: unknown[]): void {
25
+ fs.writeFileSync(path.join(dir, "queue.json"), JSON.stringify(items), "utf8");
26
+ }
27
+
28
+ test("panelWidget:全零返回 null,不显示「复盘 0 / 待批 0」", () => {
29
+ assert.equal(panelWidget({ reviewing: 0, staleCount: 0, pendingApprovals: 0, lastAppliedAt: null, rollbackRate: 0, appliedSamples: 0 }), null);
30
+ });
31
+
32
+ test("panelWidget:有哪项就拼哪项,缺项不占位", () => {
33
+ const state = { reviewing: 3, staleCount: 0, pendingApprovals: 0, lastAppliedAt: Date.UTC(2026, 10, 7), rollbackRate: 0, appliedSamples: 2 };
34
+ assert.deepEqual(panelWidget(state), ["复盘 3 / 上次采纳 11-07"]);
35
+ assert.deepEqual(panelWidget({ ...state, pendingApprovals: 2, reviewing: 0 }), ["待批 2 / 上次采纳 11-07"]);
36
+ });
37
+
38
+ test("statusLine:待批优先于回滚率告警,只显示一件事", () => {
39
+ const state = { reviewing: 4, staleCount: 0, pendingApprovals: 2, lastAppliedAt: NOW, rollbackRate: 0.5, appliedSamples: 10 };
40
+ assert.equal(statusLine(state), "待批 2");
41
+ });
42
+
43
+ test("statusLine:样本少于 5 次不下回滚率结论", () => {
44
+ assert.equal(statusLine({ reviewing: 0, staleCount: 0, pendingApprovals: 0, lastAppliedAt: NOW, rollbackRate: 0.4, appliedSamples: 4 }), null);
45
+ });
46
+
47
+ test("statusLine:样本够且超 5% 才告警(含建议关 auto)", () => {
48
+ const state = { reviewing: 1, staleCount: 0, pendingApprovals: 0, lastAppliedAt: NOW, rollbackRate: 0.06, appliedSamples: 6 };
49
+ assert.equal(statusLine(state), "回滚率 6% 超 5%,建议关 auto");
50
+ assert.equal(statusLine({ ...state, rollbackRate: 0.05 }), null, "正好 5% 不算「超」");
51
+ });
52
+
53
+ test("collectPanelState:进行中的 run 算「复盘」,待批只数 approval 级 pending", () => {
54
+ const dir = tmpDir();
55
+ writeRun(dir, "r1", "dispatched");
56
+ writeRun(dir, "r2", "proposed", NOW, { supervisorPid: 1 });
57
+ writeRun(dir, "r5", "proposed", NOW, { pid: 999999 });
58
+ writeRun(dir, "r3", "reviewed");
59
+ writeQueue(dir, [
60
+ { runId: "r3", level: "approval", skill: "a", targets: [], proposalType: "improve", status: "pending", createdAt: NOW },
61
+ { runId: "r4", level: "auto", skill: "b", targets: [], proposalType: "improve", status: "pending", createdAt: NOW },
62
+ ]);
63
+ const state = collectPanelState({ evolutionDir: dir, now: NOW, alive: (pid) => pid === 1 });
64
+ assert.equal(state.reviewing, 2, "dispatched 宽限期内 + 督导活着的 proposed 算;结论已出且进程不在的不算");
65
+ assert.equal(state.pendingApprovals, 1, "auto 条不等审批");
66
+ });
67
+
68
+ test("collectPanelState:上次采纳取 04-decision.json 的 at,采纳 / 回滚率按队列算", () => {
69
+ const dir = tmpDir();
70
+ const appliedAt = Date.UTC(2026, 10, 7, 8);
71
+ writeRun(dir, "r1", "applied");
72
+ fs.writeFileSync(path.join(dir, "runs", "r1", "04-decision.json"), JSON.stringify({ at: appliedAt }), "utf8");
73
+ writeRun(dir, "r2", "rolled-back");
74
+ writeQueue(dir, [
75
+ { runId: "r1", level: "approval", skill: "a", targets: [], proposalType: "improve", status: "applied", createdAt: NOW },
76
+ { runId: "r2", level: "approval", skill: "a", targets: [], proposalType: "improve", status: "rolled-back", createdAt: NOW },
77
+ ]);
78
+ const state = collectPanelState({ evolutionDir: dir, now: NOW });
79
+ assert.equal(state.lastAppliedAt, appliedAt);
80
+ assert.equal(state.appliedSamples, 2);
81
+ assert.equal(state.rollbackRate, 0.5);
82
+ });
83
+
84
+ test("statusEntries:同一状态只发一次,dispatched 由调用方单独记", () => {
85
+ const seen = new Map<string, string>();
86
+ const runs = [
87
+ { runId: "r1", status: "dispatched" },
88
+ { runId: "r2", status: "reviewed" },
89
+ { runId: "r3", status: "applied" },
90
+ { runId: "r4", status: "rolled-back" },
91
+ { runId: "r5", status: "rejected" },
92
+ ];
93
+ const first = statusEntries(runs, seen, NOW);
94
+ assert.deepEqual(
95
+ first.map((entry) => [entry.runId, entry.kind]),
96
+ [
97
+ ["r2", "reviewed"],
98
+ ["r3", "applied"],
99
+ ["r4", "rolled-back"],
100
+ ["r5", "reviewed"],
101
+ ],
102
+ );
103
+ assert.equal(first.find((entry) => entry.runId === "r5")?.detail, "rejected", "非 pass 的结论要带上原因");
104
+ assert.deepEqual(statusEntries(runs, seen, NOW), [], "同状态不重复发");
105
+ assert.deepEqual(
106
+ statusEntries([...runs, { runId: "r6", status: "applied" }], seen, NOW).map((entry) => entry.runId),
107
+ ["r6"],
108
+ );
109
+ });
110
+
111
+ test("entryLines:/tree 条目单行、带短日期与动作", () => {
112
+ const lines = entryLines({ kind: "applied", runId: "run-1", at: NOW, detail: "approval · a" });
113
+ assert.equal(lines.length, 1);
114
+ assert.match(lines[0]!, /\[skill-evolve\] 采纳 run-1 · 11-08 · approval · a/);
115
+ });
@@ -0,0 +1,159 @@
1
+ /**
2
+ * 提案解析与校验单测(设计稿 九、十):正常 diff、超 40 行、越界路径、candidate frontmatter。
3
+ *
4
+ * 全部走临时 run 目录:校验函数本来就是「读盘 + 判定」,只测纯函数会漏掉 diff 文件名约定。
5
+ */
6
+ import { test } from "node:test";
7
+ import assert from "node:assert/strict";
8
+ import * as fs from "node:fs";
9
+ import * as os from "node:os";
10
+ import * as path from "node:path";
11
+ import {
12
+ MAX_DIFF_LINES,
13
+ parseUnifiedDiff,
14
+ readProposalSummary,
15
+ validateCandidate,
16
+ validateProposal,
17
+ } from "../lib/proposal.ts";
18
+
19
+ const DIFF_OK = [
20
+ "--- a/.pi/skills/deploy-test/SKILL.md",
21
+ "+++ b/.pi/skills/deploy-test/SKILL.md",
22
+ "@@ -1,4 +1,5 @@",
23
+ " name: deploy-test",
24
+ "-description: 老的描述",
25
+ "+description: 部署验收、验收清单、测服部署",
26
+ "+路由:部署验收",
27
+ ].join("\n");
28
+
29
+ function runDir(files: Record<string, string>): string {
30
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-proposal-"));
31
+ for (const [name, content] of Object.entries(files)) {
32
+ const file = path.join(dir, name);
33
+ fs.mkdirSync(path.dirname(file), { recursive: true });
34
+ fs.writeFileSync(file, content, "utf8");
35
+ }
36
+ return dir;
37
+ }
38
+
39
+ function proposalJson(over: Record<string, unknown> = {}): string {
40
+ return JSON.stringify({
41
+ type: "improve",
42
+ skill: "deploy-test",
43
+ rationale: "description 缺「验收」路由词",
44
+ evidence: [1234],
45
+ diffFile: "02-proposal.diff",
46
+ ...over,
47
+ });
48
+ }
49
+
50
+ test("parseUnifiedDiff:统计增删行与文件路径", () => {
51
+ const parsed = parseUnifiedDiff(DIFF_OK);
52
+ assert.equal(parsed.ok, true);
53
+ assert.equal(parsed.added, 2);
54
+ assert.equal(parsed.removed, 1);
55
+ assert.deepEqual(parsed.files.map((item) => item.path), [".pi/skills/deploy-test/SKILL.md"]);
56
+ assert.equal(parsed.files[0].hunks, 1);
57
+ });
58
+
59
+ test("parseUnifiedDiff:空文本判空,不是 ok", () => {
60
+ const parsed = parseUnifiedDiff("");
61
+ assert.equal(parsed.ok, false);
62
+ assert.equal(parsed.reason, "empty");
63
+ });
64
+
65
+ test("validateProposal:正常 diff 判 ok 并带增删行数", () => {
66
+ const dir = runDir({ "02-proposal.json": proposalJson(), "02-proposal.diff": DIFF_OK });
67
+ const check = validateProposal(dir);
68
+ assert.equal(check.status, "ok");
69
+ assert.equal(check.type, "improve");
70
+ assert.equal(check.skill, "deploy-test");
71
+ assert.equal(check.added, 2);
72
+ assert.equal(check.removed, 1);
73
+ assert.equal(check.file, "02-proposal.diff");
74
+ });
75
+
76
+ test("validateProposal:没有 02-proposal.json 判 missing", () => {
77
+ const dir = runDir({});
78
+ assert.equal(validateProposal(dir).status, "missing");
79
+ });
80
+
81
+ test("validateProposal:增删超过 40 行判 too_large(不拒绝生成,标给人工)", () => {
82
+ const long = [
83
+ "--- a/.pi/skills/deploy-test/SKILL.md",
84
+ "+++ b/.pi/skills/deploy-test/SKILL.md",
85
+ "@@ -1,60 +1,60 @@",
86
+ ...Array.from({ length: MAX_DIFF_LINES }, (_, index) => `-旧行 ${index}`),
87
+ ...Array.from({ length: MAX_DIFF_LINES }, (_, index) => `+新行 ${index}`),
88
+ ].join("\n");
89
+ const dir = runDir({ "02-proposal.json": proposalJson(), "02-proposal.diff": long });
90
+ const check = validateProposal(dir);
91
+ assert.equal(check.status, "too_large");
92
+ assert.equal(check.added, MAX_DIFF_LINES);
93
+ assert.equal(check.removed, MAX_DIFF_LINES);
94
+ assert.match(check.detail, /转人工/);
95
+ });
96
+
97
+ test("validateProposal:diff 动了技能目录外的路径判 bad_path", () => {
98
+ const diff = [
99
+ "--- a/rust_server/src/x.rs",
100
+ "+++ b/rust_server/src/x.rs",
101
+ "@@ -1,1 +1,2 @@",
102
+ " fn main() {}",
103
+ "+// 顺手改一下",
104
+ ].join("\n");
105
+ const dir = runDir({ "02-proposal.json": proposalJson(), "02-proposal.diff": diff });
106
+ const check = validateProposal(dir);
107
+ assert.equal(check.status, "bad_path");
108
+ assert.match(check.detail, /rust_server\/src\/x\.rs/);
109
+ });
110
+
111
+ test("validateProposal:create 缺 candidate 目录判 invalid_candidate", () => {
112
+ const dir = runDir({ "02-proposal.json": proposalJson({ type: "create", skill: "deploy-check" }) });
113
+ const check = validateProposal(dir);
114
+ assert.equal(check.status, "invalid_candidate");
115
+ assert.match(check.detail, /缺 SKILL\.md/);
116
+ });
117
+
118
+ test("validateProposal:create 的 candidate 合规判 ok", () => {
119
+ const dir = runDir({
120
+ "02-proposal.json": proposalJson({ type: "create", skill: "deploy-check" }),
121
+ "candidate/deploy-check/SKILL.md": "---\nname: deploy-check\ndescription: 部署后逐项验收\n---\n\n正文\n",
122
+ });
123
+ const check = validateProposal(dir);
124
+ assert.equal(check.status, "ok");
125
+ assert.equal(check.file, "candidate/deploy-check/SKILL.md");
126
+ });
127
+
128
+ test("validateCandidate:name 不合规 / 缺 description / 与目录名不一致都判坏", () => {
129
+ const badName = runDir({ "SKILL.md": "---\nname: Deploy_Check\ndescription: x\n---\n" });
130
+ assert.match(validateCandidate(badName).detail, /name 不合规/);
131
+
132
+ const noDesc = runDir({ "SKILL.md": "---\nname: deploy-check\n---\n" });
133
+ assert.equal(validateCandidate(noDesc).ok, false);
134
+ assert.match(validateCandidate(noDesc).detail, /缺 description/);
135
+
136
+ const mismatch = runDir({ "SKILL.md": "---\nname: other-name\ndescription: x\n---\n" });
137
+ assert.match(validateCandidate(mismatch).detail, /不一致/);
138
+
139
+ const okDir = path.join(runDir({}), "deploy-check");
140
+ fs.mkdirSync(okDir, { recursive: true });
141
+ fs.writeFileSync(path.join(okDir, "SKILL.md"), "---\nname: deploy-check\ndescription: 部署后逐项验收\n---\n", "utf8");
142
+ const ok = validateCandidate(okDir);
143
+ assert.equal(ok.ok, true);
144
+ assert.equal(ok.name, "deploy-check");
145
+ });
146
+
147
+ test("readProposalSummary:没提案返回 undefined,有提案返回摘要(报告只显示不应用)", () => {
148
+ assert.equal(readProposalSummary(runDir({})), undefined);
149
+ const dir = runDir({ "02-proposal.json": proposalJson(), "02-proposal.diff": DIFF_OK });
150
+ const summary = readProposalSummary(dir);
151
+ assert.deepEqual(summary, {
152
+ type: "improve",
153
+ skill: "deploy-test",
154
+ status: "ok",
155
+ added: 2,
156
+ removed: 1,
157
+ file: "02-proposal.diff",
158
+ });
159
+ });