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,278 @@
1
+ /**
2
+ * 五道闸单测(13.2):逐闸失败各一条 + 成功路径 + 回滚逐字节还原。
3
+ *
4
+ * 全部在临时目录里跑:`.pi/skills` 只允许被测试的临时副本碰,真仓库一个字节都不动。
5
+ * git / md-check / commit 都是注入点,测试里绝不起真实子进程。
6
+ */
7
+ import { test } from "node:test";
8
+ import assert from "node:assert/strict";
9
+ import * as fs from "node:fs";
10
+ import * as os from "node:os";
11
+ import * as path from "node:path";
12
+ import { APPLIED_DIFF_FILE, VERIFY_FILE, appliedDiffFile, applyRun, readVerify, verifyFile } from "../lib/apply.ts";
13
+ import type { GateDeps } from "../lib/apply-gates.ts";
14
+ import { backupDir, cleanupBackups, runGates } from "../lib/apply-gates.ts";
15
+ import { planTargets } from "../lib/apply-targets.ts";
16
+ import { saveGate0 } from "../lib/gate0.ts";
17
+ import type { QueueItem } from "../lib/queue.ts";
18
+ import { directQueue } from "../lib/telemetry.ts";
19
+
20
+ const NOW = 1_760_000_000_000;
21
+ const SKILL_REL = ".pi/skills/demo/SKILL.md";
22
+ const SKILL_TEXT = "---\nname: demo\ndescription: 演示技能\n---\n\n## 步骤\n1. 先读文档\n";
23
+ const DIFF = `--- a/${SKILL_REL}\n+++ b/${SKILL_REL}\n@@ -3,1 +3,1 @@\n-description: 演示技能\n+description: 演示技能(改后)\n`;
24
+
25
+ interface Fixture {
26
+ cwd: string;
27
+ evolutionDir: string;
28
+ runDir: string;
29
+ skillAbs: string;
30
+ }
31
+
32
+ function setup(options: { type?: string; diff?: string; skillText?: string } = {}): Fixture {
33
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-apply-"));
34
+ const evolutionDir = path.join(cwd, ".pi", "evolution");
35
+ const runDir = path.join(evolutionDir, "runs", "run-1");
36
+ const skillAbs = path.join(cwd, SKILL_REL);
37
+ fs.mkdirSync(path.dirname(skillAbs), { recursive: true });
38
+ fs.writeFileSync(skillAbs, options.skillText ?? SKILL_TEXT, "utf8");
39
+ fs.mkdirSync(runDir, { recursive: true });
40
+ fs.writeFileSync(path.join(runDir, "02-proposal.json"), JSON.stringify({ type: options.type ?? "improve", skill: "demo" }), "utf8");
41
+ fs.writeFileSync(path.join(runDir, "02-proposal.diff"), options.diff ?? DIFF, "utf8");
42
+ return { cwd, evolutionDir, runDir, skillAbs };
43
+ }
44
+
45
+ function item(over: Partial<QueueItem> = {}): QueueItem {
46
+ return { runId: "run-1", level: "auto", skill: "demo", targets: [SKILL_REL], createdAt: NOW, status: "pending", proposalType: "improve", ...over };
47
+ }
48
+
49
+ /** git 假实现:默认干净(status / diff 都空串) */
50
+ function fakeGit(status = "", diff = "") {
51
+ return (args: string[]) => ({ code: 0, out: args[0] === "status" ? status : diff });
52
+ }
53
+
54
+ function deps(fixture: Fixture, over: Partial<GateDeps> = {}): GateDeps {
55
+ return {
56
+ evolutionDir: fixture.evolutionDir,
57
+ cwd: fixture.cwd,
58
+ runId: "run-1",
59
+ queue: directQueue,
60
+ now: () => NOW,
61
+ log: () => {},
62
+ git: fakeGit(),
63
+ loadDiagnostics: () => [],
64
+ runMdCheck: () => ({ code: 0, output: "" }),
65
+ ...over,
66
+ };
67
+ }
68
+
69
+ async function seedGate0(fixture: Fixture, status = "", diff = ""): Promise<void> {
70
+ await saveGate0(fixture.evolutionDir, { at: NOW, status, diff }, directQueue);
71
+ }
72
+
73
+ test("成功路径:五闸全绿,文件真改成 after,05 与 06 都落盘", async () => {
74
+ const fixture = setup();
75
+ await seedGate0(fixture);
76
+ const outcome = await applyRun(item(), deps(fixture));
77
+ assert.equal(outcome.status, "applied");
78
+ assert.deepEqual(outcome.gates.map((gate) => gate.gate), ["gate0", "gate1", "gate2", "gate3", "gate4"]);
79
+ assert.equal(fs.readFileSync(fixture.skillAbs, "utf8"), SKILL_TEXT.replace("演示技能\n", "演示技能(改后)\n"));
80
+
81
+ const applied = fs.readFileSync(appliedDiffFile(fixture.runDir), "utf8");
82
+ assert.match(applied, /^\+\+\+ b\/\.pi\/skills\/demo\/SKILL\.md$/m);
83
+ assert.match(applied, /^\+description: 演示技能(改后)$/m);
84
+ const verify = readVerify(fixture.runDir);
85
+ assert.equal(verify?.status, "applied");
86
+ assert.equal(verify?.gates.length, 5);
87
+ assert.deepEqual(verify?.wrote, [SKILL_REL]);
88
+ assert.equal(fs.existsSync(verifyFile(fixture.runDir)), true);
89
+ assert.equal(APPLIED_DIFF_FILE, "05-applied.diff");
90
+ assert.equal(VERIFY_FILE, "06-verify.json");
91
+ });
92
+
93
+ test("闸1 把改动前的原文备份到 backups/<runId>/<相对路径>", async () => {
94
+ const fixture = setup();
95
+ await seedGate0(fixture);
96
+ await applyRun(item(), deps(fixture));
97
+ const backup = path.join(backupDir(fixture.evolutionDir, "run-1"), SKILL_REL);
98
+ assert.equal(fs.readFileSync(backup, "utf8"), SKILL_TEXT);
99
+ });
100
+
101
+ test("闸0 多出改动(会话起点后 .pi/skills 被改过)→ unauthorized_write,一个字节都不写", async () => {
102
+ const fixture = setup();
103
+ await seedGate0(fixture);
104
+ const outcome = await applyRun(item(), deps(fixture, { git: fakeGit(" M .pi/skills/demo/SKILL.md") }));
105
+ assert.equal(outcome.status, "unauthorized_write");
106
+ assert.deepEqual(outcome.wrote, []);
107
+ assert.equal(fs.readFileSync(fixture.skillAbs, "utf8"), SKILL_TEXT);
108
+ assert.equal(fs.existsSync(appliedDiffFile(fixture.runDir)), false);
109
+ assert.match(outcome.detail ?? "", /闸0/);
110
+ assert.equal(readVerify(fixture.runDir)?.status, "unauthorized_write");
111
+ });
112
+
113
+ test("闸0 没有会话起点快照 → 同样拒采(宁可拒采,不赌)", async () => {
114
+ const fixture = setup();
115
+ const outcome = await applyRun(item(), deps(fixture));
116
+ assert.equal(outcome.status, "unauthorized_write");
117
+ assert.equal(fs.readFileSync(fixture.skillAbs, "utf8"), SKILL_TEXT);
118
+ });
119
+
120
+ test("闸1 备份写不进去(backups 路径被占成文件)→ failed,不写目标文件", async () => {
121
+ const fixture = setup();
122
+ await seedGate0(fixture);
123
+ const blocker = backupDir(fixture.evolutionDir, "run-1");
124
+ fs.mkdirSync(path.dirname(blocker), { recursive: true });
125
+ fs.writeFileSync(blocker, "占位", "utf8");
126
+ const outcome = await applyRun(item(), deps(fixture));
127
+ assert.equal(outcome.status, "failed");
128
+ assert.match(outcome.detail ?? "", /闸1/);
129
+ assert.equal(fs.readFileSync(fixture.skillAbs, "utf8"), SKILL_TEXT);
130
+ });
131
+
132
+ test("闸2 原子写失败 → failed,磁盘内容不变(写入前 gate2 就抛,wrote 为空)", async () => {
133
+ const fixture = setup();
134
+ await seedGate0(fixture);
135
+ const queue = async <T>(file: string, run: () => Promise<T>): Promise<T> => {
136
+ if (file === fixture.skillAbs) throw new Error("模拟磁盘忙");
137
+ return run();
138
+ };
139
+ const outcome = await applyRun(item(), deps(fixture, { queue }));
140
+ assert.equal(outcome.status, "failed");
141
+ assert.match(outcome.detail ?? "", /闸2/);
142
+ assert.equal(fs.readFileSync(fixture.skillAbs, "utf8"), SKILL_TEXT);
143
+ });
144
+
145
+ test("闸3 diagnostics 非空 → rolled-back,文件逐字节还原", async () => {
146
+ const fixture = setup();
147
+ await seedGate0(fixture);
148
+ const before = fs.readFileSync(fixture.skillAbs);
149
+ const outcome = await applyRun(item(), deps(fixture, { loadDiagnostics: () => [{ path: SKILL_REL, message: "frontmatter 缺 description" }] }));
150
+ assert.equal(outcome.status, "rolled-back");
151
+ assert.match(outcome.detail ?? "", /diagnostics 非空/);
152
+ assert.deepEqual(fs.readFileSync(fixture.skillAbs), before);
153
+ });
154
+
155
+ test("闸3 md-check 退出码非 0 → rolled-back", async () => {
156
+ const fixture = setup();
157
+ await seedGate0(fixture);
158
+ const outcome = await applyRun(item(), deps(fixture, { runMdCheck: () => ({ code: 2, output: "P1 标题层级跳级" }) }));
159
+ assert.equal(outcome.status, "rolled-back");
160
+ assert.match(outcome.detail ?? "", /md-check 失败/);
161
+ assert.equal(fs.readFileSync(fixture.skillAbs, "utf8"), SKILL_TEXT);
162
+ });
163
+
164
+ test("闸4 只提示「新会话生效」,不 reload", async () => {
165
+ const fixture = setup();
166
+ await seedGate0(fixture);
167
+ const outcome = await applyRun(item(), deps(fixture));
168
+ const gate4 = outcome.gates.find((gate) => gate.gate === "gate4");
169
+ assert.equal(gate4?.ok, true);
170
+ assert.match(gate4?.detail ?? "", /新会话生效/);
171
+ });
172
+
173
+ test("auto 级 commit 失败 → 回滚该条(文件还原、状态 rolled-back)", async () => {
174
+ const fixture = setup();
175
+ await seedGate0(fixture);
176
+ const outcome = await applyRun(item({ level: "auto" }), deps(fixture, { commit: () => ({ ok: false, detail: "hook 拒绝" }) }));
177
+ assert.equal(outcome.status, "rolled-back");
178
+ assert.match(outcome.detail ?? "", /commit 失败/);
179
+ assert.equal(fs.readFileSync(fixture.skillAbs, "utf8"), SKILL_TEXT);
180
+ });
181
+
182
+ test("auto 级 commit 成功 → outcome.commit 记短 hash", async () => {
183
+ const fixture = setup();
184
+ await seedGate0(fixture);
185
+ const outcome = await applyRun(item({ level: "auto" }), deps(fixture, { commit: () => ({ ok: true, hash: "abc1234" }) }));
186
+ assert.equal(outcome.status, "applied");
187
+ assert.equal(outcome.commit, "abc1234");
188
+ });
189
+
190
+ test("approval 级即使给了 commit 也不调用(不自动提交)", async () => {
191
+ const fixture = setup();
192
+ await seedGate0(fixture);
193
+ let called = 0;
194
+ const outcome = await applyRun(item({ level: "approval" }), deps(fixture, { commit: () => { called += 1; return { ok: true, hash: "x" }; } }));
195
+ assert.equal(outcome.status, "applied");
196
+ assert.equal(called, 0);
197
+ });
198
+
199
+ test("新建技能缺引用面(无 .cursor stub / 无 AGENTS.md 索引行)→ 闸3 rolled-back 并删掉新文件", async () => {
200
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-apply-create-"));
201
+ const evolutionDir = path.join(cwd, ".pi", "evolution");
202
+ const runDir = path.join(evolutionDir, "runs", "run-1");
203
+ const newRel = ".pi/skills/newbie/SKILL.md";
204
+ const candidate = "---\nname: newbie\ndescription: 新技能\n---\n\n正文\n";
205
+ fs.mkdirSync(path.join(runDir, "candidate", "newbie"), { recursive: true });
206
+ fs.writeFileSync(path.join(runDir, "02-proposal.json"), JSON.stringify({ type: "create", skill: "newbie" }), "utf8");
207
+ fs.writeFileSync(path.join(runDir, "02-proposal.diff"), `--- /dev/null\n+++ b/${newRel}\n@@ -0,0 +1,1 @@\n+占位\n`, "utf8");
208
+ fs.writeFileSync(path.join(runDir, "candidate", "newbie", "SKILL.md"), candidate, "utf8");
209
+ const fixture: Fixture = { cwd, evolutionDir, runDir, skillAbs: path.join(cwd, newRel) };
210
+ await seedGate0(fixture);
211
+ const outcome = await applyRun(item({ skill: "newbie", targets: [newRel], proposalType: "create" }), deps(fixture));
212
+ assert.equal(outcome.status, "rolled-back");
213
+ assert.match(outcome.detail ?? "", /引用面/);
214
+ assert.equal(fs.existsSync(path.join(cwd, newRel)), false);
215
+ });
216
+
217
+ test("提案目录不可用(无 02-proposal.json)→ failed 且不写盘", async () => {
218
+ const fixture = setup();
219
+ fs.rmSync(path.join(fixture.runDir, "02-proposal.json"));
220
+ const outcome = await applyRun(item(), deps(fixture));
221
+ assert.equal(outcome.status, "failed");
222
+ assert.equal(outcome.gates.length, 0);
223
+ assert.equal(fs.readFileSync(fixture.skillAbs, "utf8"), SKILL_TEXT);
224
+ });
225
+
226
+ test("planTargets 规划出 before / after,且 diff 应用不上时如实失败", async () => {
227
+ const fixture = setup();
228
+ const plan = planTargets(fixture.runDir, fixture.cwd);
229
+ assert.equal(plan.ok, true);
230
+ assert.equal(plan.targets[0]?.before, SKILL_TEXT);
231
+ assert.match(plan.targets[0]?.after ?? "", /改后/);
232
+
233
+ const broken = setup({ diff: `--- a/${SKILL_REL}\n+++ b/${SKILL_REL}\n@@ -3,1 +3,1 @@\n-description: 对不上\n+description: 新\n` });
234
+ const failed = planTargets(broken.runDir, broken.cwd);
235
+ assert.equal(failed.ok, false);
236
+ assert.match(failed.reason ?? "", /不一致/);
237
+ });
238
+
239
+ test("排除删除提案:+++ /dev/null 不在这里执行(删技能必须走 --prune 人批)", async () => {
240
+ const fixture = setup({ diff: `--- a/${SKILL_REL}\n+++ /dev/null\n@@ -1,1 +0,0 @@\n-第一行\n` });
241
+ const plan = planTargets(fixture.runDir, fixture.cwd);
242
+ assert.equal(plan.ok, false);
243
+ assert.match(plan.reason ?? "", /--prune/);
244
+ });
245
+
246
+ test("runGates 备份清理只删超过 30 天的目录", () => {
247
+ const fixture = setup();
248
+ const root = path.join(fixture.evolutionDir, "backups");
249
+ const oldDir = path.join(root, "old");
250
+ const freshDir = path.join(root, "fresh");
251
+ fs.mkdirSync(oldDir, { recursive: true });
252
+ fs.mkdirSync(freshDir, { recursive: true });
253
+ const old = new Date(NOW - 40 * 86_400_000);
254
+ fs.utimesSync(oldDir, old, old);
255
+ const messages: string[] = [];
256
+ assert.equal(cleanupBackups(fixture.evolutionDir, NOW, (msg) => messages.push(msg)), 1);
257
+ assert.equal(fs.existsSync(oldDir), false);
258
+ assert.equal(fs.existsSync(freshDir), true);
259
+ assert.equal(messages.length, 1);
260
+ });
261
+
262
+ test("runGates 闸2 前会复核磁盘内容:中途被改过就拒写,不给别人覆盖", async () => {
263
+ const fixture = setup();
264
+ await seedGate0(fixture);
265
+ const queue = async <T>(file: string, run: () => Promise<T>): Promise<T> => {
266
+ // 备份阶段之后、写盘之前把文件改掉:模拟并发编辑
267
+ if (file.endsWith(SKILL_REL) && !fs.readFileSync(fixture.skillAbs, "utf8").includes("并发")) {
268
+ fs.writeFileSync(fixture.skillAbs, `${SKILL_TEXT}并发编辑\n`, "utf8");
269
+ }
270
+ return run();
271
+ };
272
+ const plan = planTargets(fixture.runDir, fixture.cwd);
273
+ assert.equal(plan.ok, true);
274
+ const run = await runGates(deps(fixture, { queue }), plan.targets);
275
+ assert.equal(run.ok, false);
276
+ assert.match(run.detail ?? "", /磁盘内容与计划不一致/);
277
+ assert.equal(fs.readFileSync(fixture.skillAbs, "utf8").includes("并发编辑"), true);
278
+ });
@@ -0,0 +1,248 @@
1
+ /**
2
+ * 采纳胶水单测:扫 reviewed → 入队 → auto 自动应用 / approve 手动应用 / 队列文本。
3
+ *
4
+ * 全部临时目录 + 注入的 git/md-check/commit 假实现:不起任何真实子进程,
5
+ * 也不碰仓库里的 `.pi/skills`。
6
+ */
7
+ import { test } from "node:test";
8
+ import assert from "node:assert/strict";
9
+ import * as fs from "node:fs";
10
+ import * as os from "node:os";
11
+ import * as path from "node:path";
12
+ import { gate0Path, readGate0, saveGate0 } from "../lib/gate0.ts";
13
+ import { createApplyTools } from "../lib/evolution-apply.ts";
14
+ import { readQueue } from "../lib/queue.ts";
15
+ import { directQueue } from "../lib/telemetry.ts";
16
+
17
+ const NOW = 1_760_000_000_000;
18
+ const SKILL_REL = ".pi/skills/demo/SKILL.md";
19
+ const SKILL_TEXT = "---\nname: demo\ndescription: 演示技能\n---\n\n## 步骤\n1. 先读文档\n";
20
+ const AUTO_DIFF = `--- a/${SKILL_REL}\n+++ b/${SKILL_REL}\n@@ -3,1 +3,1 @@\n-description: 演示技能\n+description: 演示技能(改后)\n`;
21
+ const BODY_DIFF = `--- a/${SKILL_REL}\n+++ b/${SKILL_REL}\n@@ -7,1 +7,1 @@\n-1. 先读文档\n+1. 先读文档,再确认\n`;
22
+
23
+ interface Fixture {
24
+ cwd: string;
25
+ evolutionDir: string;
26
+ skillAbs: string;
27
+ }
28
+
29
+ function setup(): Fixture {
30
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-approve-"));
31
+ const skillAbs = path.join(cwd, SKILL_REL);
32
+ fs.mkdirSync(path.dirname(skillAbs), { recursive: true });
33
+ fs.writeFileSync(skillAbs, SKILL_TEXT, "utf8");
34
+ return { cwd, evolutionDir: path.join(cwd, ".pi", "evolution"), skillAbs };
35
+ }
36
+
37
+ /** 造一个评审通过的 run:run.json status + 提案 json/diff */
38
+ function seedRun(fixture: Fixture, runId: string, status: string, diffText = AUTO_DIFF, type = "improve"): string {
39
+ const runDir = path.join(fixture.evolutionDir, "runs", runId);
40
+ fs.mkdirSync(runDir, { recursive: true });
41
+ fs.writeFileSync(path.join(runDir, "run.json"), JSON.stringify({ runId, status, dispatchedAt: NOW }), "utf8");
42
+ fs.writeFileSync(path.join(runDir, "02-proposal.json"), JSON.stringify({ type, skill: "demo" }), "utf8");
43
+ fs.writeFileSync(path.join(runDir, "02-proposal.diff"), diffText, "utf8");
44
+ return runDir;
45
+ }
46
+
47
+ /** 闸0 需要会话起点快照:测试里直接写一份「干净」的,不去真跑 session_start */
48
+ async function seedGate0(fixture: Fixture): Promise<void> {
49
+ await saveGate0(fixture.evolutionDir, { at: NOW, status: "", diff: "" }, directQueue);
50
+ }
51
+
52
+ function tools(fixture: Fixture, over: Partial<Parameters<typeof createApplyTools>[0]> = {}) {
53
+ return createApplyTools({
54
+ evolutionDir: fixture.evolutionDir,
55
+ cwd: fixture.cwd,
56
+ queue: directQueue,
57
+ now: () => NOW,
58
+ log: () => {},
59
+ git: (args: string[]) => ({ code: 0, out: args[0] === "status" ? "" : "" }),
60
+ loadDiagnostics: () => [],
61
+ runMdCheck: () => ({ code: 0, output: "" }),
62
+ ...over,
63
+ });
64
+ }
65
+
66
+ test("只给 reviewed 的 run 入队,级别由代码判(description 改动 → auto)", async () => {
67
+ const fixture = setup();
68
+ seedRun(fixture, "run-ok", "reviewed");
69
+ seedRun(fixture, "run-rejected", "rejected", BODY_DIFF);
70
+ const added = await tools(fixture).enqueueReady();
71
+ assert.equal(added, 1);
72
+ const queue = readQueue(fixture.evolutionDir);
73
+ assert.deepEqual(queue.map((item) => item.runId), ["run-ok"]);
74
+ assert.equal(queue[0]?.level, "auto");
75
+ assert.deepEqual(queue[0]?.targets, [SKILL_REL]);
76
+ });
77
+
78
+ test("正文改动 → approval 级入队(不自动写盘)", async () => {
79
+ const fixture = setup();
80
+ seedRun(fixture, "run-body", "reviewed", BODY_DIFF);
81
+ await tools(fixture).enqueueReady();
82
+ assert.equal(readQueue(fixture.evolutionDir)[0]?.level, "approval");
83
+ });
84
+
85
+ test("路径越界的 run 判 forbidden:不入队并记日志", async () => {
86
+ const fixture = setup();
87
+ const badDiff = `--- a/.pi/skills/demo/notes.md\n+++ b/.pi/skills/demo/notes.md\n@@ -1,1 +1,1 @@\n-旧\n+新\n`;
88
+ seedRun(fixture, "run-bad", "reviewed", badDiff);
89
+ const logs: string[] = [];
90
+ const added = await tools(fixture, { log: (msg) => logs.push(msg) }).enqueueReady();
91
+ assert.equal(added, 0);
92
+ assert.deepEqual(readQueue(fixture.evolutionDir), []);
93
+ assert.match(logs.join("\n"), /forbidden/);
94
+ });
95
+
96
+ test("重复扫描幂等:第二次不再重复入队", async () => {
97
+ const fixture = setup();
98
+ seedRun(fixture, "run-ok", "reviewed");
99
+ const instance = tools(fixture);
100
+ assert.equal(await instance.enqueueReady(), 1);
101
+ assert.equal(await instance.enqueueReady(), 0);
102
+ assert.equal(readQueue(fixture.evolutionDir).length, 1);
103
+ });
104
+
105
+ test("session_start:写闸0 快照 + 自动应用 auto 条(改文件、队列 applied、run.json applied、04-decision)", async () => {
106
+ const fixture = setup();
107
+ seedRun(fixture, "run-ok", "reviewed");
108
+ tools(fixture).sessionStart();
109
+ for (let waited = 0; waited < 100 && readQueue(fixture.evolutionDir)[0]?.status !== "applied"; waited += 1) {
110
+ await new Promise((resolve) => setTimeout(resolve, 10));
111
+ }
112
+ assert.equal(fs.existsSync(gate0Path(fixture.evolutionDir)), true);
113
+ assert.equal(readGate0(fixture.evolutionDir)?.status, "");
114
+ assert.match(fs.readFileSync(fixture.skillAbs, "utf8"), /改后/);
115
+ assert.equal(readQueue(fixture.evolutionDir)[0]?.status, "applied");
116
+ const runJson = JSON.parse(fs.readFileSync(path.join(fixture.evolutionDir, "runs", "run-ok", "run.json"), "utf8")) as { status: string };
117
+ assert.equal(runJson.status, "applied");
118
+ const decision = JSON.parse(fs.readFileSync(path.join(fixture.evolutionDir, "runs", "run-ok", "04-decision.json"), "utf8")) as { approver: string; level: string };
119
+ assert.equal(decision.approver, "auto");
120
+ assert.equal(decision.level, "auto");
121
+ });
122
+
123
+ test("approve:pending 的 approval 条走五道闸写盘,成功后队列 applied", async () => {
124
+ const fixture = setup();
125
+ seedRun(fixture, "run-body", "reviewed", BODY_DIFF);
126
+ const instance = tools(fixture);
127
+ instance.sessionStart();
128
+ await new Promise((resolve) => setTimeout(resolve, 30));
129
+ assert.equal(readQueue(fixture.evolutionDir)[0]?.status, "pending");
130
+ const text = await instance.approve("run-body", false);
131
+ assert.match(text, /已采纳 run-body/);
132
+ assert.match(text, /gate0 授权核对/);
133
+ assert.match(fs.readFileSync(fixture.skillAbs, "utf8"), /再确认/);
134
+ assert.equal(readQueue(fixture.evolutionDir)[0]?.status, "applied");
135
+ });
136
+
137
+ test("approve 已 applied 的 run 幂等提示,不重复写盘", async () => {
138
+ const fixture = setup();
139
+ seedRun(fixture, "run-ok", "reviewed");
140
+ await seedGate0(fixture);
141
+ const instance = tools(fixture);
142
+ await instance.enqueueReady();
143
+ await instance.autoApplyPending();
144
+ const once = fs.readFileSync(fixture.skillAbs, "utf8");
145
+ const text = await instance.approve("run-ok", false);
146
+ assert.match(text, /已经应用过/);
147
+ assert.equal(fs.readFileSync(fixture.skillAbs, "utf8"), once);
148
+ });
149
+
150
+ test("approve 队列里没有的 run:给出下一步提示,不动文件", async () => {
151
+ const fixture = setup();
152
+ const text = await tools(fixture).approve("run-404", false);
153
+ assert.match(text, /队列里没有 run-404/);
154
+ assert.equal(fs.readFileSync(fixture.skillAbs, "utf8"), SKILL_TEXT);
155
+ });
156
+
157
+ test("approve forbidden 条直接拒绝执行", async () => {
158
+ const fixture = setup();
159
+ seedRun(fixture, "run-fb", "reviewed", AUTO_DIFF);
160
+ // 正常路径根本不会把 forbidden 入队(enqueueReady 已拦),手工塞一条验证 approve 自己的兜底
161
+ fs.mkdirSync(fixture.evolutionDir, { recursive: true });
162
+ fs.writeFileSync(
163
+ path.join(fixture.evolutionDir, "queue.json"),
164
+ JSON.stringify([{ runId: "run-fb", level: "forbidden", skill: "demo", targets: [SKILL_REL], createdAt: NOW, status: "pending" }]),
165
+ "utf8",
166
+ );
167
+ const text = await tools(fixture).approve("run-fb", false);
168
+ assert.match(text, /forbidden/);
169
+ assert.deepEqual(readQueue(fixture.evolutionDir).map((item) => item.runId), ["run-fb"]);
170
+ });
171
+
172
+ test("approve --prune:只出删技能清单,不真删", async () => {
173
+ const fixture = setup();
174
+ const runDir = seedRun(fixture, "run-prune", "reviewed", AUTO_DIFF, "prune");
175
+ fs.mkdirSync(path.join(fixture.cwd, ".cursor", "skills", "demo"), { recursive: true });
176
+ fs.writeFileSync(path.join(fixture.cwd, ".cursor", "skills", "demo", "SKILL.md"), "stub\n", "utf8");
177
+ fs.writeFileSync(path.join(fixture.cwd, "AGENTS.md"), "- `demo` —— `.pi/skills/demo/SKILL.md`\n", "utf8");
178
+ const text = await tools(fixture).approve("run-prune", true);
179
+ assert.match(text, /删技能待执行清单:demo/);
180
+ assert.match(text, /rm -rf \.pi\/skills\/demo\//);
181
+ assert.match(text, /\.dsh\/skills\/demo\/ 不动/);
182
+ // 一个字节都没真删
183
+ assert.equal(fs.existsSync(fixture.skillAbs), true);
184
+ assert.equal(fs.existsSync(path.join(fixture.cwd, ".cursor", "skills", "demo", "SKILL.md")), true);
185
+ assert.equal(fs.existsSync(path.join(runDir, "02-proposal.diff")), true);
186
+ });
187
+
188
+ test("queueText 分组列出 pending / applied 并给 pending 的 targets", async () => {
189
+ const fixture = setup();
190
+ seedRun(fixture, "run-body", "reviewed", BODY_DIFF);
191
+ const instance = tools(fixture);
192
+ await instance.enqueueReady();
193
+ const text = instance.queueText();
194
+ assert.match(text, /采纳队列:/);
195
+ assert.match(text, /pending 1 条/);
196
+ assert.match(text, /run-body\s+approval\s+demo/);
197
+ assert.match(text, /targets:\.pi\/skills\/demo\/SKILL\.md/);
198
+ assert.match(text, /approve <runId>/);
199
+ });
200
+
201
+ test("queueText 空队列给明确提示", () => {
202
+ const fixture = setup();
203
+ assert.match(tools(fixture).queueText(), /采纳队列为空/);
204
+ });
205
+
206
+ test("autoApplyPending 单条异常不拖垮整批:第一条失败第二条仍应用", async () => {
207
+ const fixture = setup();
208
+ const otherAbs = path.join(fixture.cwd, ".pi/skills/other/SKILL.md");
209
+ fs.mkdirSync(path.dirname(otherAbs), { recursive: true });
210
+ fs.writeFileSync(otherAbs, "---\nname: other\ndescription: 另一个\n---\n", "utf8");
211
+ seedRun(fixture, "run-a", "reviewed", AUTO_DIFF);
212
+ const otherDiff = `--- a/.pi/skills/other/SKILL.md\n+++ b/.pi/skills/other/SKILL.md\n@@ -3,1 +3,1 @@\n-description: 另一个\n+description: 另一个(改后)\n`;
213
+ const otherDir = path.join(fixture.evolutionDir, "runs", "run-b");
214
+ fs.mkdirSync(otherDir, { recursive: true });
215
+ fs.writeFileSync(path.join(otherDir, "run.json"), JSON.stringify({ runId: "run-b", status: "reviewed", dispatchedAt: NOW }), "utf8");
216
+ fs.writeFileSync(path.join(otherDir, "02-proposal.json"), JSON.stringify({ type: "improve", skill: "other" }), "utf8");
217
+ fs.writeFileSync(path.join(otherDir, "02-proposal.diff"), otherDiff, "utf8");
218
+ const queue = async <T>(file: string, run: () => Promise<T>): Promise<T> => {
219
+ if (file === fixture.skillAbs) throw new Error("模拟写失败");
220
+ return run();
221
+ };
222
+ await seedGate0(fixture);
223
+ const instance = tools(fixture, { queue });
224
+ await instance.enqueueReady();
225
+ const applied = await instance.autoApplyPending();
226
+ assert.equal(applied, 1);
227
+ assert.match(fs.readFileSync(otherAbs, "utf8"), /改后/);
228
+ const statuses = readQueue(fixture.evolutionDir).map((item) => `${item.runId}:${item.status}`).sort();
229
+ assert.deepEqual(statuses, ["run-a:failed", "run-b:applied"]);
230
+ });
231
+
232
+ test("同一技能有更早 pending 时,新 run 入队即 superseded 且不会被自动应用", async () => {
233
+ const fixture = setup();
234
+ seedRun(fixture, "run-body", "reviewed", BODY_DIFF);
235
+ const instance = tools(fixture);
236
+ await instance.enqueueReady();
237
+ // 再放一条同 skill 的 reviewed run(前一条还没处理)
238
+ const second = path.join(fixture.evolutionDir, "runs", "run-second");
239
+ fs.mkdirSync(second, { recursive: true });
240
+ fs.writeFileSync(path.join(second, "run.json"), JSON.stringify({ runId: "run-second", status: "reviewed" }), "utf8");
241
+ fs.writeFileSync(path.join(second, "02-proposal.json"), JSON.stringify({ type: "improve", skill: "demo" }), "utf8");
242
+ fs.writeFileSync(path.join(second, "02-proposal.diff"), AUTO_DIFF, "utf8");
243
+ await instance.enqueueReady();
244
+ const queue = readQueue(fixture.evolutionDir);
245
+ assert.equal(queue.find((item) => item.runId === "run-second")?.status, "superseded");
246
+ assert.equal(await instance.autoApplyPending(), 0); // 前一条是 approval,不等自动
247
+ assert.equal(fs.readFileSync(fixture.skillAbs, "utf8"), SKILL_TEXT);
248
+ });
@@ -0,0 +1,133 @@
1
+ /**
2
+ * G2 机械归因单测(零依赖,node --test)。
3
+ *
4
+ * 运行:
5
+ * cd .pi/extensions/skill-evolve
6
+ * node --experimental-strip-types --test test/attribution.test.ts
7
+ *
8
+ * 重点不是「每类能判出来」,而是**顺序**:A 与 E 同时成立必须判 A,E 与 semantic 同时成立必须判 E。
9
+ * 顺序错了会得到「建议改写 description」这种最误导人的结论。
10
+ */
11
+ import { test } from "node:test";
12
+ import assert from "node:assert/strict";
13
+
14
+ import { CATEGORY_ORDER, DISPOSITIONS, classifyMechanical, classifyOne, type AttributionInput } from "../lib/attribution.ts";
15
+
16
+ const SKILLS = "/repo/.pi/skills";
17
+
18
+ function base(overrides: Partial<AttributionInput> = {}): AttributionInput {
19
+ return {
20
+ missed: ["target"],
21
+ exposed: [{ name: "target", filePath: `${SKILLS}/target/SKILL.md` }],
22
+ loadedFromPi: [{ name: "target", filePath: `${SKILLS}/target/SKILL.md` }],
23
+ diagnostics: [],
24
+ activeTools: ["read", "bash", "edit"],
25
+ diskSkillNames: ["target"],
26
+ ...overrides,
27
+ };
28
+ }
29
+
30
+ test("A:disableModelInvocation=true", () => {
31
+ const input = base({ exposed: [{ name: "target", filePath: `${SKILLS}/target/SKILL.md`, disabled: true }] });
32
+ assert.equal(classifyOne("target", input).category, "A");
33
+ });
34
+
35
+ test("B:诊断里点到该技能的 file", () => {
36
+ const input = base({
37
+ diagnostics: [{ file: `${SKILLS}/target/SKILL.md`, message: "缺少 description", type: "warning" }],
38
+ });
39
+ assert.equal(classifyOne("target", input).category, "B");
40
+ });
41
+
42
+ test("B:诊断给的是相对路径也能对上(末端路径比较)", () => {
43
+ const input = base({ diagnostics: [{ file: ".pi/skills/target/SKILL.md", message: "YAML 解析失败" }] });
44
+ assert.equal(classifyOne("target", input).category, "B");
45
+ });
46
+
47
+ test("C:同名技能出现在两处", () => {
48
+ const input = base({
49
+ loadedFromPi: [
50
+ { name: "target", filePath: `${SKILLS}/target/SKILL.md` },
51
+ { name: "target", filePath: "/other/skills/target/SKILL.md" },
52
+ ],
53
+ });
54
+ assert.equal(classifyOne("target", input).category, "C");
55
+ });
56
+
57
+ test("C:collision 诊断点名也算(名字是子串时不误判)", () => {
58
+ const collision = base({
59
+ loadedFromPi: [],
60
+ diskSkillNames: [],
61
+ diagnostics: [{ file: "", message: "Skill name collision: target 与 target-extra 冲突", type: "collision" }],
62
+ });
63
+ assert.equal(classifyOne("target", collision).category, "C");
64
+ const unrelated = base({
65
+ loadedFromPi: [],
66
+ diskSkillNames: [],
67
+ diagnostics: [{ file: "", message: "Skill name collision: schema 与 schema-v2", type: "collision" }],
68
+ });
69
+ // "a" 不能命中 "schema" 里的那个 a:词边界判定
70
+ assert.equal(classifyOne("a", unrelated).category, "semantic");
71
+ });
72
+
73
+ test("D:磁盘上有目录但不在发现结果里", () => {
74
+ const input = base({ loadedFromPi: [], diskSkillNames: ["target", "other"] });
75
+ assert.equal(classifyOne("target", input).category, "D");
76
+ });
77
+
78
+ test("E:本轮 activeTools 既没 read 也没 bash", () => {
79
+ const input = base({ activeTools: ["edit", "write"] });
80
+ assert.equal(classifyOne("target", input).category, "E");
81
+ });
82
+
83
+ test("E:activeTools 为空表示没采到,不许判 E(退回 semantic)", () => {
84
+ assert.equal(classifyOne("target", base({ activeTools: [] })).category, "semantic");
85
+ });
86
+
87
+ test("都不命中 → semantic", () => {
88
+ assert.equal(classifyOne("target", base()).category, "semantic");
89
+ });
90
+
91
+ test("顺序:同时满足 A 与 E 时判 A", () => {
92
+ const input = base({
93
+ exposed: [{ name: "target", filePath: `${SKILLS}/target/SKILL.md`, disabled: true }],
94
+ activeTools: ["edit"],
95
+ });
96
+ assert.equal(classifyOne("target", input).category, "A");
97
+ });
98
+
99
+ test("顺序:同时满足 E 与 semantic 时判 E", () => {
100
+ assert.equal(classifyOne("target", base({ activeTools: ["edit"] })).category, "E");
101
+ });
102
+
103
+ test("顺序:同时满足 D 与 E 时判 D(范围问题比工具问题更根本)", () => {
104
+ const input = base({ loadedFromPi: [], activeTools: ["edit"] });
105
+ assert.equal(classifyOne("target", input).category, "D");
106
+ });
107
+
108
+ test("classifyMechanical 保持传入顺序,且每条都带 reason", () => {
109
+ const input = base({
110
+ missed: ["target", "second"],
111
+ exposed: [
112
+ { name: "target", filePath: `${SKILLS}/target/SKILL.md`, disabled: true },
113
+ { name: "second", filePath: `${SKILLS}/second/SKILL.md` },
114
+ ],
115
+ loadedFromPi: [
116
+ { name: "target", filePath: `${SKILLS}/target/SKILL.md` },
117
+ { name: "second", filePath: `${SKILLS}/second/SKILL.md` },
118
+ ],
119
+ diskSkillNames: ["target", "second"],
120
+ });
121
+ const result = classifyMechanical(input);
122
+ assert.deepEqual(result.map((item) => item.skill), ["target", "second"]);
123
+ assert.deepEqual(result.map((item) => item.category), ["A", "semantic"]);
124
+ assert.ok(result.every((item) => item.reason.length > 0));
125
+ });
126
+
127
+ test("六类都有展示标签,顺序固定 A~E → semantic", () => {
128
+ assert.deepEqual([...CATEGORY_ORDER], ["A", "B", "C", "D", "E", "semantic"]);
129
+ for (const category of CATEGORY_ORDER) {
130
+ assert.ok(DISPOSITIONS[category].label.length > 0);
131
+ assert.ok(DISPOSITIONS[category].action.length > 0);
132
+ }
133
+ });