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,151 @@
1
+ /**
2
+ * `/skill-audit` 文本单测(零依赖,node --test)。
3
+ *
4
+ * 运行:
5
+ * cd .pi/extensions/skill-evolve
6
+ * node --experimental-strip-types --test test/audit.test.ts
7
+ *
8
+ * 覆盖:六类都出现(0 也要列)、attributed 事件按次数累计、
9
+ * 无归因记录的历史 missed 走现算并计入来源、硬禁令 P0 列表、加载率注解文案。
10
+ */
11
+ import { test } from "node:test";
12
+ import assert from "node:assert/strict";
13
+ import { fingerprintOf } from "../lib/baseline.ts";
14
+
15
+ import { AUDIT_LOAD_RATE_NOTE, activeToolsFromEvents, auditText, buildAuditModel, exposedFromEvents } from "../lib/audit.ts";
16
+ import type { TelemetryEvent } from "../lib/telemetry.ts";
17
+
18
+ function event(partial: Partial<TelemetryEvent> & { kind: TelemetryEvent["kind"] }): TelemetryEvent {
19
+ return { ts: 1_700_000_000_000, iso: "2023-11-14T22:13:20.000Z", ...partial } as TelemetryEvent;
20
+ }
21
+
22
+ const EVENTS: TelemetryEvent[] = [
23
+ event({
24
+ kind: "advertised",
25
+ sessionId: "s1",
26
+ skills: [
27
+ { name: "alpha", filePath: "/r/.pi/skills/alpha/SKILL.md", disabled: false, source: "project" },
28
+ { name: "beta", filePath: "/r/.pi/skills/beta/SKILL.md", disabled: false, source: "project" },
29
+ ],
30
+ activeTools: ["read", "bash"],
31
+ }),
32
+ event({ kind: "loaded", sessionId: "s1", skill: "alpha" }),
33
+ event({ kind: "missed", sessionId: "s1", skill: "beta" }),
34
+ event({ kind: "missed", sessionId: "s1", skill: "beta" }),
35
+ event({ kind: "attributed", sessionId: "s1", skill: "beta", category: "B", reason: "frontmatter 坏" }),
36
+ event({ kind: "attributed", sessionId: "s1", skill: "beta", category: "B", reason: "frontmatter 坏" }),
37
+ // 历史数据(M2 之前):missed 有记录,但没有任何 attributed
38
+ event({ kind: "missed", sessionId: "s0", skill: "gamma" }),
39
+ event({ kind: "missed", sessionId: "s0", skill: "gamma" }),
40
+ event({ kind: "missed", sessionId: "s0", skill: "gamma" }),
41
+ event({ kind: "missed", sessionId: "s0", skill: "delta" }),
42
+ ];
43
+
44
+ test("buildAuditModel:attributed 按次数累计,历史 missed 走现算", () => {
45
+ const model = buildAuditModel({
46
+ days: 7,
47
+ events: EVENTS,
48
+ classify: (skill) => (skill === "gamma" ? "E" : "semantic"),
49
+ conflicts: [],
50
+ invalidRules: [],
51
+ });
52
+ const byCategory = new Map(model.rows.map((row) => [row.category, row.count]));
53
+ assert.equal(byCategory.get("B"), 2);
54
+ assert.equal(byCategory.get("E"), 3);
55
+ assert.equal(byCategory.get("semantic"), 1);
56
+ assert.equal(model.fromTelemetry, 2);
57
+ assert.equal(model.fromLive, 4);
58
+ assert.deepEqual(model.unattributed, []);
59
+ assert.equal(model.summary.advertised, 2);
60
+ assert.equal(model.summary.loaded, 1);
61
+ });
62
+
63
+ test("buildAuditModel:不给 classify 时历史 missed 进「未归因」,不硬编类别", () => {
64
+ const model = buildAuditModel({ days: 7, events: EVENTS, conflicts: [], invalidRules: [] });
65
+ assert.deepEqual(model.unattributed, [{ skill: "gamma", count: 3 }, { skill: "delta", count: 1 }]);
66
+ const total = model.rows.reduce((sum, row) => sum + row.count, 0);
67
+ assert.equal(total, 2); // 只有 attributed 的 2 条
68
+ });
69
+
70
+ test("auditText:六类全列、P0 列表、行号、加载率注解都在", () => {
71
+ const text = auditText(
72
+ buildAuditModel({
73
+ days: 7,
74
+ events: EVENTS,
75
+ classify: (skill) => (skill === "gamma" ? "E" : "semantic"),
76
+ conflicts: [
77
+ { ruleId: "rg-over-find", level: "P0", skill: "deploy-test", line: 129, quote: "grep -rho 对账" },
78
+ ],
79
+ invalidRules: ["broken-rule: 正则编译失败"],
80
+ }),
81
+ );
82
+ for (const category of ["A", "B", "C", "D", "E", "semantic"]) {
83
+ assert.match(text, new RegExp(`^\\s+${category === "semantic" ? "semantic" : category}\\s`, "m"), `缺类别 ${category}`);
84
+ }
85
+ assert.match(text, /加载率 50\.0%/);
86
+ assert.match(text, /B\s+frontmatter 坏\s+2/);
87
+ assert.match(text, /rg-over-find\s+deploy-test:129\s+P0/);
88
+ assert.match(text, /规则不可用:/);
89
+ assert.ok(text.includes(AUDIT_LOAD_RATE_NOTE));
90
+ assert.match(text, /加载率低 ≠ 技能没用/);
91
+ });
92
+
93
+ test("auditText:无冲突、无数据时也要有明确结论而不是空白", () => {
94
+ const text = auditText(buildAuditModel({ days: 7, events: [], conflicts: [], invalidRules: [] }));
95
+ assert.match(text, /P0 命中 0 条/);
96
+ assert.match(text, /无曝光数据/);
97
+ assert.match(text, /没有可归因的未命中/);
98
+ });
99
+
100
+ test("exposedFromEvents / activeToolsFromEvents 取最近一次 advertised", () => {
101
+ const events = [
102
+ ...EVENTS,
103
+ event({
104
+ kind: "advertised",
105
+ sessionId: "s2",
106
+ skills: [{ name: "omega", filePath: "", disabled: true, source: "embedded" }],
107
+ activeTools: ["edit"],
108
+ }),
109
+ ];
110
+ assert.deepEqual(exposedFromEvents(events), [{ name: "omega", filePath: "", disabled: true }]);
111
+ assert.deepEqual(activeToolsFromEvents(events), ["edit"]);
112
+ // 老数据没有 activeTools 字段:返回空数组,调用方据此不判 E
113
+ assert.deepEqual(activeToolsFromEvents(EVENTS), ["read", "bash"]);
114
+ assert.deepEqual(activeToolsFromEvents([event({ kind: "missed", skill: "x" })]), []);
115
+ });
116
+
117
+ test("auditText:存量命中与新增命中分两段,只有新增算失败信号", () => {
118
+ const stockHit = { ruleId: "rg-over-find", level: "P0", skill: "deploy-test", line: 129, quote: "老问题" };
119
+ const addedHit = { ruleId: "file-size-limit", level: "P0", skill: "big-skill", line: 12, quote: "600 行" };
120
+ const text = auditText(
121
+ buildAuditModel({
122
+ days: 7,
123
+ events: [],
124
+ conflicts: [stockHit, addedHit],
125
+ knownFingerprints: new Set([fingerprintOf(stockHit)]),
126
+ invalidRules: [],
127
+ }),
128
+ );
129
+ assert.match(text, /P0 命中 2 条:存量(baseline)1 条 · 新增 1 条/);
130
+ assert.match(text, /新增\s+file-size-limit\s+big-skill:12\s+P0/);
131
+ assert.match(text, /存量\s+rg-over-find\s+deploy-test:129\s+P0/);
132
+ assert.match(text, /确认无害后跑 \/skill-evolve baseline 接受/);
133
+ });
134
+
135
+ test("auditText:全是存量时不出现「新增命中」提示", () => {
136
+ const hits = [
137
+ { ruleId: "rg-over-find", level: "P0", skill: "deploy-test", line: 129, quote: "老问题一" },
138
+ { ruleId: "rg-over-find", level: "P0", skill: "deploy-test", line: 196, quote: "老问题二" },
139
+ ];
140
+ const text = auditText(
141
+ buildAuditModel({
142
+ days: 7,
143
+ events: [],
144
+ conflicts: hits,
145
+ knownFingerprints: new Set(hits.map((hit) => fingerprintOf(hit))),
146
+ invalidRules: [],
147
+ }),
148
+ );
149
+ assert.match(text, /P0 命中 2 条:存量(baseline)2 条 · 新增 0 条/);
150
+ assert.doesNotMatch(text, /新增命中:/);
151
+ });
@@ -0,0 +1,132 @@
1
+ /**
2
+ * 存量债基线单测(设计稿 10.4):指纹不含行号、首次审计 bootstrap、新命中算新增、--reset 清空。
3
+ *
4
+ * 全部用临时目录 + directQueue(直写)跑真实 IO —— 这段逻辑的坑在「文件在不在、指纹对不对」,
5
+ * 只测纯函数测不出 bootstrap 到底有没有落盘。
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 {
13
+ buildBaseline,
14
+ createBaselineGate,
15
+ fingerprintOf,
16
+ fingerprintsOf,
17
+ readBaseline,
18
+ saveBaseline,
19
+ splitHits,
20
+ } from "../lib/baseline.ts";
21
+ import type { HardRuleHit } from "../lib/hard-rules.ts";
22
+ import { directQueue } from "../lib/telemetry.ts";
23
+
24
+ const NOW = 1_760_000_000_000;
25
+
26
+ function hit(over: Partial<HardRuleHit> = {}): HardRuleHit {
27
+ return {
28
+ ruleId: "rg-over-find",
29
+ level: "P0",
30
+ skill: "deploy-test",
31
+ line: 129,
32
+ quote: "grep -rho 'env::var(\"[A-Z_]*\"' rust_server/src",
33
+ ...over,
34
+ };
35
+ }
36
+
37
+ function tempDir(): string {
38
+ return fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-baseline-"));
39
+ }
40
+
41
+ function gateFor(dir: string, hits: HardRuleHit[]) {
42
+ const file = path.join(dir, "baseline.json");
43
+ return {
44
+ file,
45
+ gate: createBaselineGate({ file, hits: () => hits, now: () => NOW, queue: directQueue, log: () => {} }),
46
+ };
47
+ }
48
+
49
+ test("fingerprintOf:行号不进指纹,quote 只算前 120 字", () => {
50
+ const base = hit();
51
+ assert.equal(fingerprintOf(base), fingerprintOf(hit({ line: 999 })));
52
+ const longQuote = "长".repeat(200);
53
+ assert.equal(fingerprintOf(hit({ quote: longQuote })), fingerprintOf(hit({ quote: `${longQuote}追加字符` })));
54
+ assert.notEqual(fingerprintOf(base), fingerprintOf(hit({ skill: "other" })));
55
+ assert.notEqual(fingerprintOf(base), fingerprintOf(hit({ ruleId: "file-size-limit" })));
56
+ });
57
+
58
+ test("首次审计:文件不存在时 bootstrap,把当时全部命中都当存量并落盘", async () => {
59
+ const dir = tempDir();
60
+ const { file, gate } = gateFor(dir, [hit(), hit({ line: 196, quote: "ssh x \"grep -rl '文案' dist\"" })]);
61
+
62
+ const first = gate.baseline();
63
+ assert.equal(first.bootstrap, true);
64
+ assert.equal(first.violations.length, 2);
65
+ await gate.settled();
66
+
67
+ const onDisk = readBaseline(file);
68
+ assert.ok(onDisk);
69
+ assert.equal(onDisk?.bootstrap, true);
70
+ assert.equal(onDisk?.violations.length, 2);
71
+
72
+ // 第一次审计后:两条全是存量,新增 0
73
+ const { stock, added } = splitHits(
74
+ [hit(), hit({ line: 196, quote: "ssh x \"grep -rl '文案' dist\"" })],
75
+ fingerprintsOf(first),
76
+ );
77
+ assert.equal(stock.length, 2);
78
+ assert.equal(added.length, 0);
79
+ });
80
+
81
+ test("新命中算新增:基线里已有的算存量,多出来的一条算新增", async () => {
82
+ const dir = tempDir();
83
+ const { file, gate } = gateFor(dir, [hit()]);
84
+ const baseline = buildBaseline([hit()], NOW, false);
85
+ await saveBaseline(file, baseline, directQueue);
86
+
87
+ const current = [hit(), hit({ ruleId: "file-size-limit", quote: "12.3K 行" })];
88
+ const { stock, added } = splitHits(current, fingerprintsOf(gate.baseline()));
89
+ assert.deepEqual(stock.map((item) => item.ruleId), ["rg-over-find"]);
90
+ assert.deepEqual(added.map((item) => item.ruleId), ["file-size-limit"]);
91
+ });
92
+
93
+ test("行号漂移不算新增:同一条问题从 129 行挪到 140 行仍是存量", async () => {
94
+ const dir = tempDir();
95
+ const { gate, file } = gateFor(dir, [hit({ line: 140 })]);
96
+ await saveBaseline(file, buildBaseline([hit({ line: 129 })], NOW, false), directQueue);
97
+
98
+ assert.equal(gate.isStock(hit({ line: 140 })), true);
99
+ assert.equal(splitHits([hit({ line: 140 })], fingerprintsOf(gate.baseline())).added.length, 0);
100
+ });
101
+
102
+ test("acceptAll:把当前全部命中写进基线,bootstrap 标 false", async () => {
103
+ const dir = tempDir();
104
+ const { gate, file } = gateFor(dir, [hit(), hit({ quote: "新增的一条" })]);
105
+
106
+ const message = await gate.acceptAll();
107
+ assert.match(message, /已接受 2 条/);
108
+ const onDisk = readBaseline(file);
109
+ assert.equal(onDisk?.bootstrap, false);
110
+ assert.equal(onDisk?.violations.length, 2);
111
+ });
112
+
113
+ test("reset:清空清单但不删文件(删了下次审计又会自动 bootstrap 回存量)", async () => {
114
+ const dir = tempDir();
115
+ const { gate, file } = gateFor(dir, [hit()]);
116
+ await saveBaseline(file, buildBaseline([hit()], NOW, false), directQueue);
117
+
118
+ const message = await gate.reset();
119
+ assert.match(message, /已清空存量基线/);
120
+ const onDisk = readBaseline(file);
121
+ assert.ok(onDisk, "reset 之后文件仍在");
122
+ assert.deepEqual(onDisk?.violations, []);
123
+ assert.equal(splitHits([hit()], fingerprintsOf(gate.baseline())).added.length, 1);
124
+ });
125
+
126
+ test("基线文件坏了当不存在处理,不抛", () => {
127
+ const dir = tempDir();
128
+ const file = path.join(dir, "baseline.json");
129
+ fs.writeFileSync(file, "{ 坏 json", "utf8");
130
+ const { gate } = gateFor(dir, [hit()]);
131
+ assert.equal(gate.baseline().bootstrap, true);
132
+ });
@@ -0,0 +1,133 @@
1
+ /**
2
+ * 分级采纳的代码判定单测(13.1):只看 diff 真改了什么,不看模型写的 action。
3
+ *
4
+ * 重点是边界:只改 description 才能 auto;同时碰正文就退成 approval;
5
+ * 路径越界 / 删文件 / 改 frontmatter 语义字段一律 forbidden。
6
+ */
7
+ import { test } from "node:test";
8
+ import assert from "node:assert/strict";
9
+ import { classifyAction, classifyChange, parseSections } from "../lib/classify-action.ts";
10
+
11
+ function diff(path: string, body: string, oldPath = path, created = false): string {
12
+ const head = `--- ${created ? "/dev/null" : `a/${oldPath}`}\n+++ b/${path}\n@@ -1,3 +1,3 @@\n`;
13
+ return `${head}${body}\n`;
14
+ }
15
+
16
+ const SKILL = ".pi/skills/deploy-test/SKILL.md";
17
+
18
+ test("只改 description 一行 → auto", () => {
19
+ const result = classifyChange({
20
+ diffText: diff(SKILL, "-description: 部署测试服\n+description: 部署测服并给出验收清单"),
21
+ proposalType: "improve",
22
+ skill: "deploy-test",
23
+ });
24
+ assert.equal(result.level, "auto");
25
+ assert.deepEqual(result.targets, [SKILL]);
26
+ });
27
+
28
+ test("description 改完再碰一条正文条款 → approval(不能只因为有一行 description 就放过)", () => {
29
+ const result = classifyChange({
30
+ diffText: diff(SKILL, "-description: 部署测试服\n+description: 部署测服并给出验收清单\n-禁止 pkill -f\n+禁止用 pkill -f"),
31
+ proposalType: "improve",
32
+ skill: "deploy-test",
33
+ });
34
+ assert.equal(result.level, "approval");
35
+ });
36
+
37
+ test("description 续行(缩进 ≥2)与纯空白改动仍算 auto", () => {
38
+ const result = classifyChange({
39
+ diffText: diff(SKILL, "-description: 部署测试服\n+description: 部署测试服,\n+ 并在成功后给出验收清单\n- \n+ "),
40
+ proposalType: "improve",
41
+ skill: "deploy-test",
42
+ });
43
+ assert.equal(result.level, "auto");
44
+ });
45
+
46
+ test("路径越界(技能目录下的非 SKILL.md)→ forbidden", () => {
47
+ const result = classifyChange({
48
+ diffText: diff(".pi/skills/deploy-test/notes.md", "-旧\n+新"),
49
+ proposalType: "improve",
50
+ skill: "deploy-test",
51
+ });
52
+ assert.equal(result.level, "forbidden");
53
+ assert.match(result.reasons[0] ?? "", /路径越界/);
54
+ });
55
+
56
+ test("删除技能文件(+++ /dev/null)→ forbidden", () => {
57
+ const text = `--- a/${SKILL}\n+++ /dev/null\n@@ -1,1 +0,0 @@\n--description: x\n`;
58
+ assert.equal(classifyChange({ diffText: text, proposalType: "improve", skill: "deploy-test" }).level, "forbidden");
59
+ });
60
+
61
+ test("改 frontmatter 的 name / allowed-tools / disable-model-invocation → forbidden", () => {
62
+ for (const field of ["name: 别的名字", "allowed-tools: bash", "disable-model-invocation: true"]) {
63
+ const result = classifyChange({
64
+ diffText: diff(SKILL, `-旧值\n+${field}`),
65
+ proposalType: "improve",
66
+ skill: "deploy-test",
67
+ });
68
+ assert.equal(result.level, "forbidden", `${field} 应 forbidden`);
69
+ }
70
+ });
71
+
72
+ test("动插件自身目录 → forbidden", () => {
73
+ const result = classifyChange({
74
+ diffText: diff(".pi/extensions/skill-evolve/prompts/复盘.md", "-旧\n+新"),
75
+ proposalType: "improve",
76
+ skill: "skill-evolve",
77
+ });
78
+ assert.equal(result.level, "forbidden");
79
+ assert.match(result.reasons[0] ?? "", /插件自身/);
80
+ });
81
+
82
+ test("prune(删技能提案)不论 diff 内容都 forbidden:插件只出清单不真删", () => {
83
+ const result = classifyChange({ diffText: diff(SKILL, "-旧\n+新"), proposalType: "prune", skill: "deploy-test" });
84
+ assert.equal(result.level, "forbidden");
85
+ assert.match(result.reasons[0] ?? "", /prune/);
86
+ });
87
+
88
+ test("新建 .cursor 路由 stub → auto", () => {
89
+ const text = `--- /dev/null\n+++ b/.cursor/skills/new-skill/SKILL.md\n@@ -0,0 +1,3 @@\n+唯一 SSOT:.pi/skills/new-skill/SKILL.md\n+动手前先 Read 它\n+# 路由\n`;
90
+ const result = classifyChange({ diffText: text, proposalType: "create", skill: "new-skill" });
91
+ assert.equal(result.level, "auto");
92
+ assert.deepEqual(result.targets, [".cursor/skills/new-skill/SKILL.md"]);
93
+ });
94
+
95
+ test("AGENTS.md 索引表只增一行 → auto;增两行 → approval", () => {
96
+ const one = diff("AGENTS.md", "+- `new-skill` —— `.pi/skills/new-skill/SKILL.md`");
97
+ assert.equal(classifyChange({ diffText: one, proposalType: "create", skill: "new-skill" }).level, "auto");
98
+ const two = diff("AGENTS.md", "+- `new-skill` —— `.pi/skills/new-skill/SKILL.md`\n+- `other` —— `.pi/skills/other/SKILL.md`");
99
+ assert.equal(classifyChange({ diffText: two, proposalType: "create", skill: "new-skill" }).level, "approval");
100
+ });
101
+
102
+ test("新增技能正文(.pi/skills/<新名>/SKILL.md 纯新建)→ approval", () => {
103
+ const text = `--- /dev/null\n+++ b/.pi/skills/new-skill/SKILL.md\n@@ -0,0 +1,2 @@\n+---\n+description: 新技能\n`;
104
+ assert.equal(classifyChange({ diffText: text, proposalType: "create", skill: "new-skill" }).level, "approval");
105
+ });
106
+
107
+ test("空 diff / 无增删行 → approval(判不动就不许自动写盘)", () => {
108
+ assert.equal(classifyChange({ diffText: "", proposalType: "improve", skill: "x" }).level, "approval");
109
+ const onlyContext = `--- a/${SKILL}\n+++ b/${SKILL}\n@@ -1,1 +1,1 @@\n 只有上下文\n`;
110
+ assert.equal(classifyChange({ diffText: onlyContext, proposalType: "improve", skill: "x" }).level, "approval");
111
+ });
112
+
113
+ test("多文件 diff 逐段判:技能文件 + 同 skill 的 .cursor stub 都只改描述/新建 → 仍 auto 需逐段合规", () => {
114
+ const text =
115
+ `--- a/${SKILL}\n+++ b/${SKILL}\n@@ -1,1 +1,1 @@\n-description: 旧\n+description: 新\n` +
116
+ `--- /dev/null\n+++ b/.cursor/skills/deploy-test/SKILL.md\n@@ -0,0 +1,1 @@\n+唯一 SSOT\n`;
117
+ const result = classifyChange({ diffText: text, proposalType: "improve", skill: "deploy-test" });
118
+ assert.equal(result.level, "auto");
119
+ assert.deepEqual(result.targets, [SKILL, ".cursor/skills/deploy-test/SKILL.md"]);
120
+ });
121
+
122
+ test("parseSections 跳过头部元信息与 @@ / \\ 行,保留改动的符号", () => {
123
+ const text = `diff --git a/${SKILL} b/${SKILL}\nindex 111..222 100644\n--- a/${SKILL}\n+++ b/${SKILL}\n@@ -1,2 +1,2 @@\n-old\n+new\n\\ No newline\n`;
124
+ const sections = parseSections(text);
125
+ assert.equal(sections.length, 1);
126
+ assert.deepEqual(sections[0]?.lines.map((line) => line.sign), ["-", "+"]);
127
+ });
128
+
129
+ test("classifyAction 兼容旧调用面(只看级别)", () => {
130
+ assert.equal(classifyAction(diff(SKILL, "-description: 旧\n+description: 新"), "improve"), "auto");
131
+ assert.equal(classifyAction(diff(SKILL, "-正文\n+正文2"), "improve"), "approval");
132
+ assert.equal(classifyAction(diff(SKILL, "-a\n+b"), "prune"), "forbidden");
133
+ });
@@ -0,0 +1,267 @@
1
+ /**
2
+ * /skill-evolve 子命令路由单测:只验证「参数 → 展示文本」,用假 Evolution 对象隔离 lib/evolution。
3
+ */
4
+ import { test } from "node:test";
5
+ import assert from "node:assert/strict";
6
+ import { matchSubcommand, parseWebPort, runCommand } from "../lib/command.ts";
7
+ import type { Evolution } from "../lib/evolution.ts";
8
+
9
+ const TARGET = { sessionId: "sess-1", sessionFile: "/tmp/session.jsonl" };
10
+
11
+ function fakeEvolution(calls: string[]): Evolution {
12
+ const stub = {
13
+ reportText: (days: number) => {
14
+ calls.push(`report:${days}`);
15
+ return `报告 ${days} 天`;
16
+ },
17
+ statusText: () => {
18
+ calls.push("status");
19
+ return "状态文本";
20
+ },
21
+ setEnabled: (enabled: boolean) => {
22
+ calls.push(`enabled:${enabled}`);
23
+ return `总开关:${enabled ? "开" : "关"}`;
24
+ },
25
+ setFlag: (field: string, value: boolean) => {
26
+ calls.push(`flag:${field}=${value}`);
27
+ return `开关:${field}=${value}`;
28
+ },
29
+ runNow: (target: { sessionId: string; sessionFile?: string }) => {
30
+ calls.push(`run:${target.sessionId}`);
31
+ return `已派生复盘:${target.sessionId}`;
32
+ },
33
+ acceptBaseline: (mode: "accept" | "reset") => {
34
+ calls.push(`baseline:${mode}`);
35
+ return `基线:${mode}`;
36
+ },
37
+ flush: async () => {},
38
+ model: {
39
+ config: () => ({ propose: "inherit", review: "inherit" }),
40
+ reviewModel: () => "inherit",
41
+ setModel: async (kind: string, spec: string) => {
42
+ calls.push(`model:${kind}=${spec}`);
43
+ return `已设 ${kind}=${spec}`;
44
+ },
45
+ selftestText: async () => {
46
+ calls.push("selftest");
47
+ return "自检:通过";
48
+ },
49
+ selftestFailed: () => false,
50
+ lastSelftest: () => undefined,
51
+ },
52
+ apply: {
53
+ sessionStart: () => {},
54
+ settle: () => {},
55
+ approve: async (runId: string, prune: boolean) => {
56
+ calls.push(`approve:${runId}${prune ? ":prune" : ""}`);
57
+ return `采纳 ${runId}`;
58
+ },
59
+ queueText: () => {
60
+ calls.push("queue");
61
+ return "队列文本";
62
+ },
63
+ autoApplyPending: async () => 0,
64
+ enqueueReady: async () => 0,
65
+ },
66
+ observe: {
67
+ refresh: () => ({ reviewing: 0, staleCount: 0, pendingApprovals: 0, lastAppliedAt: null, rollbackRate: 0, appliedSamples: 0 }),
68
+ noteDispatched: () => {},
69
+ weekReportText: async () => {
70
+ calls.push("week");
71
+ return "周报文本";
72
+ },
73
+ },
74
+ };
75
+ return stub as unknown as Evolution;
76
+ }
77
+
78
+ test("补全:空前缀给全量,命中前缀只给命中项,乱前缀也给全量", () => {
79
+ assert.deepEqual(
80
+ matchSubcommand("").map((item) => item.value),
81
+ ["status", "report", "run", "baseline", "selftest", "approve", "queue", "web", "config", "collect", "review", "on", "off"],
82
+ );
83
+ assert.deepEqual(
84
+ matchSubcommand("re").map((item) => item.value),
85
+ ["report", "review"],
86
+ );
87
+ assert.deepEqual(
88
+ matchSubcommand("co").map((item) => item.value),
89
+ ["config", "collect"],
90
+ );
91
+ assert.deepEqual(
92
+ matchSubcommand("qu").map((item) => item.value),
93
+ ["queue"],
94
+ );
95
+ assert.deepEqual(
96
+ matchSubcommand("ap").map((item) => item.value),
97
+ ["approve"],
98
+ );
99
+ assert.deepEqual(
100
+ matchSubcommand("o").map((item) => item.value),
101
+ ["on", "off"],
102
+ );
103
+ assert.deepEqual(
104
+ matchSubcommand("b").map((item) => item.value),
105
+ ["baseline"],
106
+ );
107
+ assert.deepEqual(
108
+ matchSubcommand("se").map((item) => item.value),
109
+ ["selftest"],
110
+ );
111
+ assert.deepEqual(
112
+ matchSubcommand("zzz").map((item) => item.value),
113
+ ["status", "report", "run", "baseline", "selftest", "approve", "queue", "web", "config", "collect", "review", "on", "off"],
114
+ );
115
+ assert.deepEqual(
116
+ matchSubcommand("w").map((item) => item.value),
117
+ ["web"],
118
+ );
119
+ });
120
+
121
+ test("空参数默认走 status", async () => {
122
+ const calls: string[] = [];
123
+ assert.equal(await runCommand("", fakeEvolution(calls), TARGET), "状态文本");
124
+ assert.deepEqual(calls, ["status"]);
125
+ });
126
+
127
+ test("report 带天数用天数,非法天数回落 7 天", async () => {
128
+ const calls: string[] = [];
129
+ const evo = fakeEvolution(calls);
130
+ assert.equal(await runCommand("report 3", evo, TARGET), "报告 3 天");
131
+ assert.equal(await runCommand("report abc", evo, TARGET), "报告 7 天");
132
+ assert.equal(await runCommand("report 0", evo, TARGET), "报告 7 天");
133
+ assert.equal(await runCommand("report", evo, TARGET), "报告 7 天");
134
+ assert.deepEqual(calls, ["report:3", "report:7", "report:7", "report:7"]);
135
+ });
136
+
137
+ test("on / off 透传布尔", async () => {
138
+ const calls: string[] = [];
139
+ const evo = fakeEvolution(calls);
140
+ assert.equal(await runCommand("on", evo, TARGET), "总开关:开");
141
+ assert.equal(await runCommand("off", evo, TARGET), "总开关:关");
142
+ assert.deepEqual(calls, ["enabled:true", "enabled:false"]);
143
+ });
144
+
145
+ test("run 子命令把手机会话透传给 runNow", async () => {
146
+ const calls: string[] = [];
147
+ assert.equal(await runCommand("run", fakeEvolution(calls), TARGET), "已派生复盘:sess-1");
148
+ assert.deepEqual(calls, ["run:sess-1"]);
149
+ });
150
+
151
+ test("baseline 子命令:默认接受,--reset 清空", async () => {
152
+ const calls: string[] = [];
153
+ const evo = fakeEvolution(calls);
154
+ assert.equal(await runCommand("baseline", evo, TARGET), "基线:accept");
155
+ assert.equal(await runCommand("baseline --reset", evo, TARGET), "基线:reset");
156
+ assert.deepEqual(calls, ["baseline:accept", "baseline:reset"]);
157
+ });
158
+
159
+ test("selftest 走 model.selftestText", async () => {
160
+ const calls: string[] = [];
161
+ assert.equal(await runCommand("selftest", fakeEvolution(calls), TARGET), "自检:通过");
162
+ assert.deepEqual(calls, ["selftest"]);
163
+ });
164
+
165
+ test("非 TUI 的 config 子命令打印当前值而不改配置", async () => {
166
+ const calls: string[] = [];
167
+ const text = await runCommand("config", fakeEvolution(calls), TARGET);
168
+ assert.match(text, /无法交互选择/);
169
+ assert.deepEqual(calls, []);
170
+ });
171
+
172
+ test("queue 子命令列采纳队列", async () => {
173
+ const calls: string[] = [];
174
+ assert.equal(await runCommand("queue", fakeEvolution(calls), TARGET), "队列文本");
175
+ assert.deepEqual(calls, ["queue"]);
176
+ });
177
+
178
+ test("approve 传 runId;带 --prune 时标记 prune", async () => {
179
+ const calls: string[] = [];
180
+ const evo = fakeEvolution(calls);
181
+ assert.equal(await runCommand("approve run-abc", evo, TARGET), "采纳 run-abc");
182
+ assert.equal(await runCommand("approve run-abc --prune", evo, TARGET), "采纳 run-abc");
183
+ assert.deepEqual(calls, ["approve:run-abc", "approve:run-abc:prune"]);
184
+ });
185
+
186
+ test("approve 缺 runId 时仍走 approve(由实现给用法串)", async () => {
187
+ const calls: string[] = [];
188
+ await runCommand("approve", fakeEvolution(calls), TARGET);
189
+ assert.deepEqual(calls, ["approve:"]);
190
+ });
191
+
192
+ test("未知子命令返回用法串且不碰 Evolution", async () => {
193
+ const calls: string[] = [];
194
+ const text = await runCommand("propose", fakeEvolution(calls), TARGET);
195
+ assert.match(text, /未知子命令:propose/);
196
+ assert.match(text, /status\|report\|run\|baseline\|selftest\|approve\|queue\|web\|config\|collect\|review\|on\|off/);
197
+ assert.deepEqual(calls, []);
198
+ });
199
+
200
+ test("collect on|off:只动采集(telemetry),不碰复盘", async () => {
201
+ const calls: string[] = [];
202
+ const evo = fakeEvolution(calls);
203
+ assert.equal(await runCommand("collect on", evo, TARGET), "开关:telemetry=true");
204
+ assert.equal(await runCommand("collect off", evo, TARGET), "开关:telemetry=false");
205
+ assert.deepEqual(calls, ["flag:telemetry=true", "flag:telemetry=false"]);
206
+ });
207
+
208
+ test("review on|off:只动复盘(enabled),不碰采集", async () => {
209
+ const calls: string[] = [];
210
+ const evo = fakeEvolution(calls);
211
+ assert.equal(await runCommand("review on", evo, TARGET), "开关:enabled=true");
212
+ assert.equal(await runCommand("review off", evo, TARGET), "开关:enabled=false");
213
+ assert.deepEqual(calls, ["flag:enabled=true", "flag:enabled=false"]);
214
+ });
215
+
216
+ test("collect / review 参数非法(缺失或写错):给用法串且不改配置", async () => {
217
+ const calls: string[] = [];
218
+ const evo = fakeEvolution(calls);
219
+ for (const args of ["collect", "collect yes", "review", "review 1"]) {
220
+ const text = await runCommand(args, evo, TARGET);
221
+ assert.match(text, /用法:\/skill-evolve (collect|review) on\|off/, args);
222
+ assert.match(text, /配置未改动/, args);
223
+ }
224
+ assert.deepEqual(calls, [], "非法参数不该调 setFlag");
225
+ });
226
+
227
+ test("report --week 走周报,不把 --week 当天数", async () => {
228
+ const calls: string[] = [];
229
+ const evo = fakeEvolution(calls);
230
+ assert.equal(await runCommand("report --week", evo, TARGET), "周报文本");
231
+ assert.deepEqual(calls, ["week"]);
232
+ });
233
+
234
+ test("web:注入启动器时透传端口与 --once,没注入时给用法串", async () => {
235
+ const calls: string[] = [];
236
+ const evo = fakeEvolution(calls);
237
+ const withoutLauncher = await runCommand("web", evo, TARGET);
238
+ assert.match(withoutLauncher, /用法:\/skill-evolve web \[--port N\]/);
239
+ assert.match(withoutLauncher, /没有注入面板启动器/);
240
+
241
+ const requests: { port?: number; once: boolean }[] = [];
242
+ const launcher = async (request: { port?: number; once: boolean }): Promise<string> => {
243
+ requests.push(request);
244
+ return `面板:${request.port ?? "默认"}${request.once ? "(once)" : ""}`;
245
+ };
246
+ const withLauncher = { ...TARGET, web: launcher };
247
+ assert.equal(await runCommand("web", evo, withLauncher), "面板:默认");
248
+ assert.equal(await runCommand("web --port 7799", evo, withLauncher), "面板:7799");
249
+ assert.equal(await runCommand("web 7800", evo, withLauncher), "面板:7800");
250
+ assert.equal(await runCommand("web --once", evo, withLauncher), "面板:默认(once)");
251
+ assert.equal(await runCommand("web --port 7799 --once", evo, withLauncher), "面板:7799(once)");
252
+ assert.deepEqual(requests, [
253
+ { once: false },
254
+ { once: false, port: 7799 },
255
+ { once: false, port: 7800 },
256
+ { once: true },
257
+ { once: true, port: 7799 },
258
+ ]);
259
+ });
260
+
261
+ test("parseWebPort:只认 1~65535 的整数", () => {
262
+ assert.equal(parseWebPort(undefined), undefined);
263
+ assert.equal(parseWebPort("7788"), 7788);
264
+ assert.equal(parseWebPort("0"), undefined);
265
+ assert.equal(parseWebPort("65536"), undefined);
266
+ assert.equal(parseWebPort("abc"), undefined);
267
+ });