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,138 @@
1
+ /**
2
+ * 跨进程目录锁单测(零依赖,node --test)。
3
+ *
4
+ * 运行:
5
+ * cd .pi/extensions/skill-evolve
6
+ * node --experimental-strip-types --test test/lock.test.ts
7
+ *
8
+ * 覆盖三种回收判据(活 pid / 死 pid / 超时)+ 抢锁、忙等待、残锁回收。
9
+ */
10
+ import { test } from "node:test";
11
+ import assert from "node:assert/strict";
12
+ import { spawnSync } from "node:child_process";
13
+ import * as fs from "node:fs";
14
+ import * as os from "node:os";
15
+ import * as path from "node:path";
16
+
17
+ import { acquireLock, acquireLockSync, isPidAlive, lockDir, readOwnerSync, releaseLock, releaseLockSync, shouldReclaim, writeOwnerSync } from "../lib/lock.ts";
18
+
19
+ const MAX = 20 * 60_000;
20
+ const NOW = 1_760_000_000_000;
21
+
22
+ function tmpDir(): string {
23
+ return fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-lock-"));
24
+ }
25
+
26
+ test("shouldReclaim:没有 owner.json 视为残锁", () => {
27
+ assert.equal(shouldReclaim(null, NOW, MAX), true);
28
+ });
29
+
30
+ test("shouldReclaim:pid 已死 → 立即回收,不看年龄(3 秒前才写的也回收)", () => {
31
+ assert.equal(shouldReclaim({ pid: 4242, ts: NOW - 3000 }, NOW, MAX, () => false), true);
32
+ });
33
+
34
+ test("shouldReclaim:pid 活着 → 永不回收,ts 再老也不回收(宁可阻塞不可抢锁)", () => {
35
+ assert.equal(shouldReclaim({ pid: 4242, ts: NOW - 1000 }, NOW, MAX, () => true), false);
36
+ assert.equal(shouldReclaim({ pid: 4242, ts: NOW - 2 * MAX }, NOW, MAX, () => true), false);
37
+ assert.equal(shouldReclaim({ pid: 4242, ts: NOW - 100 * MAX }, NOW, MAX, () => true), false);
38
+ });
39
+
40
+ test("shouldReclaim:owner 字段坏(pid 非法但 ts 可读)→ 才按年龄兜底", () => {
41
+ assert.equal(shouldReclaim({ pid: Number.NaN, ts: NOW - 2 * MAX }, NOW, MAX, () => true), true);
42
+ assert.equal(shouldReclaim({ pid: 0, ts: NOW - 2 * MAX }, NOW, MAX), true);
43
+ assert.equal(shouldReclaim({ pid: Number.NaN, ts: NOW - 1000 }, NOW, MAX, () => true), false);
44
+ assert.equal(shouldReclaim({ pid: Number.NaN, ts: Number.NaN }, NOW, MAX, () => true), true);
45
+ });
46
+
47
+ test("isPidAlive:当前进程活着,0 与负值直接判死", () => {
48
+ assert.equal(isPidAlive(process.pid), true);
49
+ assert.equal(isPidAlive(0), false);
50
+ assert.equal(isPidAlive(-1), false);
51
+ });
52
+
53
+ test("acquireLock:抢到后写 owner.json,第二次抢返回 busy,释放后可再抢", async () => {
54
+ const dir = tmpDir();
55
+ const first = await acquireLock({ evolutionDir: dir, owner: { pid: process.pid, ts: NOW, sessionId: "s1" }, maxRuntimeMs: MAX, now: () => NOW });
56
+ assert.equal(first.ok, true);
57
+ const ownerFile = path.join(lockDir(dir), "owner.json");
58
+ assert.deepEqual(JSON.parse(fs.readFileSync(ownerFile, "utf8")), { pid: process.pid, ts: NOW, sessionId: "s1" });
59
+
60
+ const second = await acquireLock({ evolutionDir: dir, owner: { pid: process.pid, ts: NOW }, maxRuntimeMs: MAX, now: () => NOW });
61
+ assert.equal(second.ok, false);
62
+ assert.equal(second.why, "busy");
63
+ assert.equal(second.owner?.sessionId, "s1");
64
+
65
+ await releaseLock(dir);
66
+ assert.equal(fs.existsSync(lockDir(dir)), false);
67
+ const third = await acquireLock({ evolutionDir: dir, owner: { pid: process.pid, ts: NOW }, maxRuntimeMs: MAX, now: () => NOW });
68
+ assert.equal(third.ok, true);
69
+ await releaseLock(dir);
70
+ });
71
+
72
+ test("acquireLock:回收死 pid 残锁并成功抢到(不看年龄)", async () => {
73
+ const dir = tmpDir();
74
+ fs.mkdirSync(lockDir(dir), { recursive: true });
75
+ // 真跑一个 node 子进程拿它的 pid,退出后这个 pid 必然已死:模拟 kill -9 后留下的残锁
76
+ const deadPid = spawnSync(process.execPath, ["-e", ""]).pid ?? 0;
77
+ assert.equal(isPidAlive(deadPid), false);
78
+ // ts 就是 NOW(年龄为 0)也必须回收——这正是「派发后主进程立刻退出」的死锁场景
79
+ fs.writeFileSync(path.join(lockDir(dir), "owner.json"), JSON.stringify({ pid: deadPid, ts: NOW, sessionId: "dead" }), "utf8");
80
+ const logs: string[] = [];
81
+ const result = await acquireLock({
82
+ evolutionDir: dir,
83
+ owner: { pid: process.pid, ts: NOW },
84
+ maxRuntimeMs: MAX,
85
+ now: () => NOW,
86
+ log: (msg) => logs.push(msg),
87
+ });
88
+ assert.equal(result.ok, true);
89
+ assert.equal(logs.some((line) => line.includes("回收孤儿锁")), true);
90
+ await releaseLock(dir);
91
+ });
92
+
93
+ test("acquireLock:活 pid 的锁 ts 再老也不抢(宁可阻塞,不可抢正在跑的 run)", async () => {
94
+ const dir = tmpDir();
95
+ fs.mkdirSync(lockDir(dir), { recursive: true });
96
+ fs.writeFileSync(path.join(lockDir(dir), "owner.json"), JSON.stringify({ pid: process.pid, ts: NOW - 3 * MAX }), "utf8");
97
+ const result = await acquireLock({ evolutionDir: dir, owner: { pid: process.pid, ts: NOW }, maxRuntimeMs: MAX, now: () => NOW });
98
+ assert.deepEqual([result.ok, result.why, result.owner?.pid], [false, "busy", process.pid]);
99
+ await releaseLock(dir);
100
+ });
101
+
102
+ test("releaseLock:目录不存在也当成功(幂等)", async () => {
103
+ const dir = tmpDir();
104
+ await releaseLock(dir);
105
+ assert.equal(fs.existsSync(lockDir(dir)), false);
106
+ });
107
+
108
+ test("acquireLockSync:同步抢锁、忙时让位、写 owner.json", () => {
109
+ const dir = tmpDir();
110
+ const first = acquireLockSync({ evolutionDir: dir, owner: { pid: process.pid, ts: NOW, sessionId: "s1" }, maxRuntimeMs: MAX, now: () => NOW });
111
+ assert.equal(first.ok, true);
112
+ assert.deepEqual(readOwnerSync(lockDir(dir)), { pid: process.pid, ts: NOW, sessionId: "s1" });
113
+
114
+ const second = acquireLockSync({ evolutionDir: dir, owner: { pid: 999_999, ts: NOW, sessionId: "s2" }, maxRuntimeMs: MAX, now: () => NOW });
115
+ assert.equal(second.ok, false);
116
+ assert.equal(second.why, "busy");
117
+ assert.equal(second.owner?.pid, process.pid);
118
+ releaseLockSync(dir);
119
+ assert.equal(fs.existsSync(lockDir(dir)), false);
120
+ });
121
+
122
+ test("acquireLockSync:死 pid 的残锁会被回收后抢到", () => {
123
+ const dir = tmpDir();
124
+ fs.mkdirSync(lockDir(dir), { recursive: true });
125
+ fs.writeFileSync(path.join(lockDir(dir), "owner.json"), `${JSON.stringify({ pid: 999_999, ts: NOW, sessionId: "old" })}\n`);
126
+ const result = acquireLockSync({ evolutionDir: dir, owner: { pid: process.pid, ts: NOW, sessionId: "s1" }, maxRuntimeMs: MAX, now: () => NOW });
127
+ assert.equal(result.ok, true);
128
+ assert.equal(readOwnerSync(lockDir(dir))?.sessionId, "s1");
129
+ releaseLockSync(dir);
130
+ });
131
+
132
+ test("writeOwnerSync:把持有者换成子进程 pid,供 status 与孤儿判定使用", () => {
133
+ const dir = tmpDir();
134
+ acquireLockSync({ evolutionDir: dir, owner: { pid: process.pid, ts: NOW, sessionId: "s1" }, maxRuntimeMs: MAX, now: () => NOW });
135
+ assert.equal(writeOwnerSync(dir, { pid: 4321, ts: NOW + 1, sessionId: "s1" }), true);
136
+ assert.deepEqual(readOwnerSync(lockDir(dir)), { pid: 4321, ts: NOW + 1, sessionId: "s1" });
137
+ releaseLockSync(dir);
138
+ });
@@ -0,0 +1,85 @@
1
+ /**
2
+ * 扩展日志落盘(用户 2026-11-08:「tui 的这些推送不看了,影响我」):
3
+ * 默认只写 `.pi/evolution/logs/extension.log`,`PI_SKILL_EVOLVE_DEBUG=1` 才回显终端,超 1MB 轮转成 `.1`。
4
+ *
5
+ * 全部喂临时目录;console.log 用临时替换的假函数断言,不起任何子进程。
6
+ */
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 test from "node:test";
12
+ import { LOG_MAX_BYTES, logFile, logLine } from "../lib/logfile.ts";
13
+
14
+ function workspace(): string {
15
+ return fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-logfile-"));
16
+ }
17
+
18
+ /** 临时接管 console.log:返回调用记录与还原函数(测试之间不许互相污染) */
19
+ function spyConsole(): { calls: string[]; restore: () => void } {
20
+ const original = console.log;
21
+ const calls: string[] = [];
22
+ console.log = (...args: unknown[]) => {
23
+ calls.push(args.map((arg) => String(arg)).join(" "));
24
+ };
25
+ return { calls, restore: () => (console.log = original) };
26
+ }
27
+
28
+ /** 环境变量按需设 / 删,并在 finally 里还原成测试前的样子 */
29
+ function withDebugEnv(value: string | undefined, run: () => void): void {
30
+ const before = process.env.PI_SKILL_EVOLVE_DEBUG;
31
+ if (value === undefined) delete process.env.PI_SKILL_EVOLVE_DEBUG;
32
+ else process.env.PI_SKILL_EVOLVE_DEBUG = value;
33
+ try {
34
+ run();
35
+ } finally {
36
+ if (before === undefined) delete process.env.PI_SKILL_EVOLVE_DEBUG;
37
+ else process.env.PI_SKILL_EVOLVE_DEBUG = before;
38
+ }
39
+ }
40
+
41
+ test("logLine:自动建 logs/ 目录,一行一条带 ISO 时间戳", () => {
42
+ const dir = workspace();
43
+ withDebugEnv(undefined, () => logLine(dir, "[skill-evolve] 已派生复盘 pid 1", false));
44
+ const file = logFile(dir);
45
+ assert.equal(file, path.join(dir, "logs", "extension.log"));
46
+ const text = fs.readFileSync(file, "utf8");
47
+ assert.match(text, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z \[skill-evolve\] 已派生复盘 pid 1\n$/);
48
+ });
49
+
50
+ test("logLine:DEBUG 未设时一次都不调 console.log,但文件照写", () => {
51
+ const dir = workspace();
52
+ const spy = spyConsole();
53
+ try {
54
+ withDebugEnv(undefined, () => logLine(dir, "静默一行"));
55
+ } finally {
56
+ spy.restore();
57
+ }
58
+ assert.deepEqual(spy.calls, [], "默认零推送:终端不该出现任何一行");
59
+ assert.match(fs.readFileSync(logFile(dir), "utf8"), /静默一行/);
60
+ });
61
+
62
+ test("logLine:PI_SKILL_EVOLVE_DEBUG=1 时同时回显终端", () => {
63
+ const dir = workspace();
64
+ const spy = spyConsole();
65
+ try {
66
+ withDebugEnv("1", () => logLine(dir, "可见一行"));
67
+ } finally {
68
+ spy.restore();
69
+ }
70
+ assert.deepEqual(spy.calls, ["可见一行"]);
71
+ assert.match(fs.readFileSync(logFile(dir), "utf8"), /可见一行/);
72
+ });
73
+
74
+ test("logLine:单文件超 1MB 轮转成 .1,新文件只留新的一行", () => {
75
+ const dir = workspace();
76
+ const file = logFile(dir);
77
+ fs.mkdirSync(path.dirname(file), { recursive: true });
78
+ // 刚好等于阈值不轮转,超一个字节才轮转:这里直接写 MAX+1
79
+ fs.writeFileSync(file, "x".repeat(LOG_MAX_BYTES + 1), "utf8");
80
+ withDebugEnv(undefined, () => logLine(dir, "轮转后的一行", false));
81
+ assert.equal(fs.statSync(`${file}.1`).size, LOG_MAX_BYTES + 1, "旧内容整体挪到 .1");
82
+ const current = fs.readFileSync(file, "utf8");
83
+ assert.equal(current.includes("xxxx"), false, "新文件里没有旧内容");
84
+ assert.match(current, /轮转后的一行/);
85
+ });
@@ -0,0 +1,217 @@
1
+ /**
2
+ * 机械层的接线单测:settle 之后遥测里必须真的出现 attributed / conflict,
3
+ * 且派生 prompt 里带着「已机械归因」清单(复盘 agent 据此只做 F/G)。
4
+ *
5
+ * 运行:
6
+ * cd .pi/extensions/skill-evolve
7
+ * node --experimental-strip-types --test test/mechanical-wiring.test.ts
8
+ */
9
+ import { test } from "node:test";
10
+ import assert from "node:assert/strict";
11
+ import * as fs from "node:fs";
12
+ import * as os from "node:os";
13
+ import * as path from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+
16
+ import { fakeSupervisorSpawn } from "./fake-review-spawn.ts";
17
+ import { createEvolution } from "../lib/evolution.ts";
18
+ import { directQueue, type TelemetryEvent, type TelemetrySkill } from "../lib/telemetry.ts";
19
+ import type { SpawnedChild, SpawnLike } from "../lib/spawn-plan.ts";
20
+
21
+ const TEMPLATE = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "prompts", "复盘.md");
22
+ const NOW = 1_760_000_000_000;
23
+
24
+ const RULES = {
25
+ version: 1,
26
+ rules: [
27
+ { id: "rg-over-find", level: "P0", pattern: "\\bgrep\\s+-[a-zA-Z]*r", expect: "用 rg", why: "实测 find 全盘 180s" },
28
+ ],
29
+ };
30
+
31
+ function skill(name: string, disabled = false): TelemetrySkill {
32
+ return { name, filePath: `/repo/.pi/skills/${name}/SKILL.md`, disabled, source: "project" };
33
+ }
34
+
35
+ /** 生产默认全关(clone 即静默):这三个用例要验证采集,必须在夹具里显式打开 */
36
+ function enableEvolution(dir: string): void {
37
+ fs.mkdirSync(path.join(dir, ".pi", "evolution"), { recursive: true });
38
+ fs.writeFileSync(
39
+ path.join(dir, ".pi", "evolution", "config.json"),
40
+ `${JSON.stringify({ enabled: true, telemetry: true }, null, 2)}\n`,
41
+ "utf8",
42
+ );
43
+ }
44
+
45
+ function readTelemetry(dir: string): TelemetryEvent[] {
46
+ const shard = path.join(dir, ".pi", "evolution", "telemetry");
47
+ let names: string[];
48
+ try {
49
+ names = fs.readdirSync(shard);
50
+ } catch {
51
+ return [];
52
+ }
53
+ const events: TelemetryEvent[] = [];
54
+ for (const name of names.sort()) {
55
+ for (const line of fs.readFileSync(path.join(shard, name), "utf8").split("\n")) {
56
+ if (line.trim().length > 0) events.push(JSON.parse(line));
57
+ }
58
+ }
59
+ return events;
60
+ }
61
+
62
+ class FakeChild {
63
+ readonly pid = process.pid;
64
+ unref(): void {}
65
+ once(): this {
66
+ return this;
67
+ }
68
+ }
69
+
70
+ test("settle 写 attributed + conflict,prompt 带上机械归因清单", async () => {
71
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-wire-"));
72
+ enableEvolution(dir);
73
+ // 磁盘上真的放一个会命中硬禁令的技能,让 conflict 走真实扫描而不是桩
74
+ fs.mkdirSync(path.join(dir, ".pi/skills/alpha"), { recursive: true });
75
+ fs.writeFileSync(path.join(dir, ".pi/skills/alpha/SKILL.md"), "# alpha\n用 grep -r 搜全仓\n", "utf8");
76
+ fs.mkdirSync(path.join(dir, ".pi/evolution/rules"), { recursive: true });
77
+ fs.writeFileSync(path.join(dir, ".pi/evolution/rules/hard-rules.json"), JSON.stringify(RULES), "utf8");
78
+
79
+ const prompts: string[] = [];
80
+ // 技能 filePath 必须指向这个临时 cwd,否则 B 类的路径比较对不上(这正是它该做的事)
81
+ const at = (name: string): TelemetrySkill => ({
82
+ name,
83
+ filePath: path.join(dir, ".pi/skills", name, "SKILL.md"),
84
+ disabled: false,
85
+ source: "project",
86
+ });
87
+ const spawnFn: SpawnLike = (_bin, args) => {
88
+ prompts.push(args[args.length - 1]);
89
+ return new FakeChild() as unknown as SpawnedChild;
90
+ };
91
+ const evo = createEvolution({
92
+ cwd: dir,
93
+ promptTemplatePath: TEMPLATE,
94
+ queue: directQueue,
95
+ log: () => {},
96
+ now: () => NOW,
97
+ env: {},
98
+ spawnFn,
99
+ supervisorSpawnFn: fakeSupervisorSpawn([]),
100
+ // 注入发现结果:alpha 正常发现,beta 的 frontmatter 坏(诊断点名它)
101
+ loadSkills: () => ({
102
+ skills: [{ name: "alpha", filePath: path.join(dir, ".pi/skills/alpha/SKILL.md") }],
103
+ diagnostics: [{ path: path.join(dir, ".pi/skills/beta/SKILL.md"), message: "缺少 description", type: "warning" }],
104
+ }),
105
+ });
106
+
107
+ evo.recordAdvertised({
108
+ sessionId: "s1",
109
+ cwd: dir,
110
+ prompt: "查一下技能",
111
+ skills: [at("alpha"), at("beta")],
112
+ activeTools: ["read", "bash"],
113
+ });
114
+ evo.settle({ sessionId: "s1" });
115
+ await new Promise((resolve) => setTimeout(resolve, 200));
116
+
117
+ const events = readTelemetry(dir);
118
+ const advertised = events.find((item) => item.kind === "advertised");
119
+ assert.deepEqual(advertised?.activeTools, ["read", "bash"], "activeTools 必须落进遥测,否则类 E 无据可依");
120
+
121
+ // directQueue 是直写(生产用 withFileMutationQueue 串行),事件落盘顺序不保证,所以按技能名排序比对
122
+ const attributed = events.filter((item) => item.kind === "attributed");
123
+ assert.deepEqual(
124
+ attributed.map((item) => [item.skill, item.category]).sort((a, b) => a[0].localeCompare(b[0])),
125
+ [
126
+ ["alpha", "semantic"],
127
+ ["beta", "B"],
128
+ ],
129
+ );
130
+ assert.ok(attributed.every((item) => typeof item.reason === "string" && item.reason.length > 0));
131
+
132
+ const conflicts = events.filter((item) => item.kind === "conflict");
133
+ assert.equal(conflicts.length, 1);
134
+ assert.equal(conflicts[0].ruleId, "rg-over-find");
135
+ assert.equal(conflicts[0].line, 2);
136
+ assert.equal(conflicts[0].level, "P0");
137
+ // 首次 settle 会自动 bootstrap 存量基线(把当时的命中当存量接受),所以这条标 stock
138
+ assert.equal(conflicts[0].stock, true);
139
+
140
+ assert.equal(prompts.length, 1);
141
+ assert.match(prompts[0], /已机械归因(2 条/);
142
+ assert.match(prompts[0], /B beta/);
143
+ assert.match(prompts[0], /只对上面没出现的技能做 F\/G 判定/);
144
+ fs.rmSync(dir, { recursive: true, force: true });
145
+ });
146
+
147
+ test("同一冲突只记一次:两轮 settle 不重复刷同一条", async () => {
148
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-wire2-"));
149
+ enableEvolution(dir);
150
+ fs.mkdirSync(path.join(dir, ".pi/skills/alpha"), { recursive: true });
151
+ fs.writeFileSync(path.join(dir, ".pi/skills/alpha/SKILL.md"), "# alpha\n用 grep -r 搜全仓\n", "utf8");
152
+ fs.mkdirSync(path.join(dir, ".pi/evolution/rules"), { recursive: true });
153
+ fs.writeFileSync(path.join(dir, ".pi/evolution/rules/hard-rules.json"), JSON.stringify(RULES), "utf8");
154
+
155
+ const evo = createEvolution({
156
+ cwd: dir,
157
+ promptTemplatePath: TEMPLATE,
158
+ queue: directQueue,
159
+ log: () => {},
160
+ now: () => NOW,
161
+ env: {},
162
+ spawnFn: () => new FakeChild() as unknown as SpawnedChild,
163
+ supervisorSpawnFn: fakeSupervisorSpawn([]),
164
+ loadSkills: () => ({ skills: [{ name: "alpha", filePath: path.join(dir, ".pi/skills/alpha/SKILL.md") }], diagnostics: [] }),
165
+ });
166
+
167
+ for (let round = 0; round < 2; round += 1) {
168
+ evo.recordAdvertised({ sessionId: "s1", cwd: dir, prompt: "再来一次", skills: [skill("alpha")], activeTools: ["read"] });
169
+ evo.settle({ sessionId: "s1" });
170
+ }
171
+ await new Promise((resolve) => setTimeout(resolve, 200));
172
+
173
+ const conflicts = readTelemetry(dir).filter((item) => item.kind === "conflict");
174
+ assert.equal(conflicts.length, 1);
175
+ fs.rmSync(dir, { recursive: true, force: true });
176
+ });
177
+
178
+ test("conflict 事件带 stock:基线里没有的命中标成新增(stock=false)", async () => {
179
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-wire3-"));
180
+ enableEvolution(dir);
181
+ fs.mkdirSync(path.join(dir, ".pi/skills/alpha"), { recursive: true });
182
+ fs.writeFileSync(path.join(dir, ".pi/skills/alpha/SKILL.md"), "# alpha\n用 grep -r 搜全仓\n", "utf8");
183
+ fs.mkdirSync(path.join(dir, ".pi/evolution/rules"), { recursive: true });
184
+ fs.writeFileSync(path.join(dir, ".pi/evolution/rules/hard-rules.json"), JSON.stringify(RULES), "utf8");
185
+ // 预置一份「只含别的存量」的基线:alpha 这条不在其中,应判新增
186
+ fs.writeFileSync(
187
+ path.join(dir, ".pi/evolution/baseline.json"),
188
+ JSON.stringify({ version: 1, generatedAt: NOW, bootstrap: false, violations: [{ ruleId: "rg-over-find", level: "P0", skill: "other", line: 9, quote: "别的", fingerprint: "deadbeefdeadbeef" }] }),
189
+ "utf8",
190
+ );
191
+
192
+ const evo = createEvolution({
193
+ cwd: dir,
194
+ promptTemplatePath: TEMPLATE,
195
+ queue: directQueue,
196
+ log: () => {},
197
+ now: () => NOW,
198
+ env: {},
199
+ spawnFn: (() => new FakeChild() as unknown as SpawnedChild) as SpawnLike,
200
+ supervisorSpawnFn: fakeSupervisorSpawn([]),
201
+ loadSkills: () => ({ skills: [], diagnostics: [] }),
202
+ });
203
+ evo.recordAdvertised({
204
+ sessionId: "s1",
205
+ cwd: dir,
206
+ prompt: "查一下技能",
207
+ skills: [skill("alpha")],
208
+ activeTools: ["read", "bash"],
209
+ });
210
+ evo.settle({ sessionId: "s1" });
211
+ await new Promise((resolve) => setTimeout(resolve, 200));
212
+
213
+ const conflicts = readTelemetry(dir).filter((item) => item.kind === "conflict");
214
+ assert.equal(conflicts.length, 1);
215
+ assert.equal(conflicts[0].stock, false);
216
+ fs.rmSync(dir, { recursive: true, force: true });
217
+ });
@@ -0,0 +1,129 @@
1
+ /**
2
+ * M6 指标:按 ISO 周聚合、去重落盘、除零不算 NaN。
3
+ * 用例编号对应设计稿 14.2(趋势指标)与 5.1(metrics.jsonl)。
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 { appendMetrics, buildMetrics, isoWeek, metricsFile, readMetrics, rollbackRate } from "../lib/metrics.ts";
11
+ import { directQueue, type TelemetryEvent } from "../lib/telemetry.ts";
12
+
13
+ const DAY = 86_400_000;
14
+
15
+ function ev(kind: TelemetryEvent["kind"], ts: number, extra: Record<string, unknown> = {}): TelemetryEvent {
16
+ return { kind, ts, iso: new Date(ts).toISOString(), ...extra };
17
+ }
18
+
19
+ function tmpDir(): string {
20
+ return fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-metrics-"));
21
+ }
22
+
23
+ test("isoWeek:UTC 周四分周,跨年边界不拆成两周", () => {
24
+ // 2026-01-01 是周四 → 属于 2026-W01
25
+ assert.equal(isoWeek(Date.UTC(2026, 0, 1)), "2026-W01");
26
+ // 2026-01-04(周日)仍属 2026-W01
27
+ assert.equal(isoWeek(Date.UTC(2026, 0, 4)), "2026-W01");
28
+ // 2026-01-05(周一)进入 2026-W02
29
+ assert.equal(isoWeek(Date.UTC(2026, 0, 5)), "2026-W02");
30
+ // 2025-12-29(周一)属 2026-W01:ISO 周跨年跟周四走
31
+ assert.equal(isoWeek(Date.UTC(2025, 11, 29)), "2026-W01");
32
+ });
33
+
34
+ test("buildMetrics:按周聚合曝光 / 加载 / 尝试失败 / 冲突 / 归因", () => {
35
+ const now = Date.UTC(2026, 10, 5, 9);
36
+ const events = [
37
+ ev("advertised", now, {
38
+ skills: [
39
+ { name: "a", filePath: "/a/SKILL.md", disabled: false, source: "project" },
40
+ { name: "b", filePath: "/b/SKILL.md", disabled: true, source: "project" },
41
+ ],
42
+ }),
43
+ ev("advertised", now - DAY, { skills: [{ name: "a", filePath: "/a/SKILL.md", disabled: false, source: "project" }] }),
44
+ ev("loaded", now, { skill: "a" }),
45
+ ev("failed", now, { skill: "a" }),
46
+ ev("conflict", now, { level: "P0", ruleId: "rg-over-find" }),
47
+ ev("conflict", now, { level: "P1", ruleId: "x" }),
48
+ ev("attributed", now, { skill: "a", category: "G" }),
49
+ ev("attributed", now, { skill: "a", category: "semantic" }),
50
+ ];
51
+ const rows = buildMetrics(events, now);
52
+ assert.equal(rows.length, 1);
53
+ const row = rows[0]!;
54
+ assert.equal(row.advertised, 2, "disabled 的技能不算曝光");
55
+ assert.equal(row.loaded, 1);
56
+ assert.equal(row.attemptedFailed, 1);
57
+ assert.equal(row.loadRate, 0.5);
58
+ assert.deepEqual(row.conflicts, { p0: 1, p1: 1 });
59
+ assert.equal(row.attribution.G, 1);
60
+ assert.equal(Object.values(row.attribution).reduce((sum, n) => sum + n, 0), 1, "semantic 不算 A~G 之一");
61
+ });
62
+
63
+ test("buildMetrics:空数据返回空数组,零曝光周不产生 NaN", () => {
64
+ assert.deepEqual(buildMetrics([], Date.now()), []);
65
+ const rows = buildMetrics([ev("missed", Date.UTC(2026, 10, 5), { skill: "a" })], Date.UTC(2026, 10, 5));
66
+ assert.equal(rows.length, 1);
67
+ assert.equal(rows[0]!.loadRate, 0);
68
+ assert.ok(Number.isFinite(rows[0]!.loadRate));
69
+ });
70
+
71
+ test("buildMetrics:run 状态按派发日归周,提案 / 采纳 / 回滚分开数", () => {
72
+ const now = Date.UTC(2026, 10, 5);
73
+ const runs = [
74
+ { runId: "r1", status: "proposed", dispatchedAt: now },
75
+ { runId: "r2", status: "applied", dispatchedAt: now },
76
+ { runId: "r3", status: "rolled-back", dispatchedAt: now },
77
+ { runId: "r4", status: "skipped-before-dispatch", dispatchedAt: null },
78
+ ];
79
+ const row = buildMetrics([], now, runs)[0]!;
80
+ assert.equal(row.proposed, 3, "proposed / applied / rolled-back 都算走到过提案阶段");
81
+ assert.equal(row.applied, 1);
82
+ assert.equal(row.rolledBack, 1);
83
+ });
84
+
85
+ test("buildMetrics:多周按周升序返回", () => {
86
+ const now = Date.UTC(2026, 10, 20);
87
+ const rows = buildMetrics([ev("loaded", now), ev("loaded", now - 14 * DAY)], now);
88
+ assert.equal(rows.length, 2);
89
+ assert.ok(rows[0]!.week < rows[1]!.week);
90
+ });
91
+
92
+ test("appendMetrics:同 week + runId 只落一行(去重)", async () => {
93
+ const dir = tmpDir();
94
+ const now = Date.UTC(2026, 10, 5);
95
+ const week = isoWeek(now);
96
+ const row = { ...emptyRow(week, now) };
97
+ assert.equal(await appendMetrics(dir, row, directQueue), true);
98
+ assert.equal(await appendMetrics(dir, row, directQueue), false, "重复写同周汇总必须被跳过");
99
+ const single = { ...row, runId: "r1" };
100
+ assert.equal(await appendMetrics(dir, single, directQueue), true, "同周不同 runId 是另一行");
101
+ assert.equal(await appendMetrics(dir, single, directQueue), false);
102
+ assert.equal(readMetrics(dir).length, 2);
103
+ assert.equal(fs.readFileSync(metricsFile(dir), "utf8").trim().split("\n").length, 2);
104
+ });
105
+
106
+ test("rollbackRate:没有采纳样本返回 0,不返回 NaN", () => {
107
+ assert.equal(rollbackRate([]), 0);
108
+ const now = Date.now();
109
+ assert.equal(rollbackRate([emptyRow("2026-W45", now)]), 0);
110
+ const row = { ...emptyRow("2026-W45", now), applied: 4, rolledBack: 1 };
111
+ assert.equal(rollbackRate([row]), 0.25);
112
+ });
113
+
114
+ /** 造一行空指标:数值字段全零,避免各用例重复写字段 */
115
+ function emptyRow(week: string, ts: number) {
116
+ return {
117
+ ts,
118
+ week,
119
+ advertised: 0,
120
+ loaded: 0,
121
+ attemptedFailed: 0,
122
+ loadRate: 0,
123
+ conflicts: { p0: 0, p1: 0 },
124
+ proposed: 0,
125
+ applied: 0,
126
+ rolledBack: 0,
127
+ attribution: { A: 0, B: 0, C: 0, D: 0, E: 0, F: 0, G: 0 },
128
+ };
129
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * 模型配置单测(设计稿 12.5):值只有两种形态——`provider/model` 或 `inherit`。
3
+ * 非法值一律退 inherit 而不是让派发失败,这条兜底要有用例钉住。
4
+ */
5
+ import { test } from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import * as fs from "node:fs";
8
+ import * as os from "node:os";
9
+ import * as path from "node:path";
10
+ import {
11
+ DEFAULT_MODEL_CONFIG,
12
+ INHERIT,
13
+ describeModelConfig,
14
+ loadModelConfig,
15
+ modelChoices,
16
+ normalizeModelSpec,
17
+ readModelConfig,
18
+ resolveModel,
19
+ saveModelSpec,
20
+ specFromChoice,
21
+ } from "../lib/model-config.ts";
22
+ import { directQueue } from "../lib/telemetry.ts";
23
+
24
+ function tmpDir(): string {
25
+ return fs.mkdtempSync(path.join(os.tmpdir(), "sk-model-"));
26
+ }
27
+
28
+ test("合法 provider/model 原样保留,非法值退 inherit", () => {
29
+ assert.equal(normalizeModelSpec("deepseek/deepseek-v3.2"), "deepseek/deepseek-v3.2");
30
+ assert.equal(normalizeModelSpec(" openai/gpt-5:high "), "openai/gpt-5:high");
31
+ for (const bad of ["", "inherit", "no-slash", "/missing-provider", "provider/", 42, null, undefined]) {
32
+ assert.equal(normalizeModelSpec(bad), INHERIT, `非法值 ${String(bad)} 应退 inherit`);
33
+ }
34
+ });
35
+
36
+ test("readModelConfig 缺字段 / 坏结构都当 inherit", () => {
37
+ assert.deepEqual(readModelConfig(undefined), DEFAULT_MODEL_CONFIG);
38
+ assert.deepEqual(readModelConfig({ propose: "not-an-object", review: { model: 3 } }), { propose: INHERIT, review: INHERIT });
39
+ assert.deepEqual(readModelConfig({ propose: { model: "a/b" }, review: { model: "c/d" } }), { propose: "a/b", review: "c/d" });
40
+ });
41
+
42
+ test("loadModelConfig:配置文件不存在时不报错也不建文件", () => {
43
+ const dir = tmpDir();
44
+ assert.deepEqual(loadModelConfig(dir), DEFAULT_MODEL_CONFIG);
45
+ assert.equal(fs.existsSync(path.join(dir, "config.json")), false);
46
+ });
47
+
48
+ test("resolveModel:inherit 取主会话模型,显式值优先于主会话", () => {
49
+ const host = { model: { provider: "deepseek", id: "deepseek-v3.2" } };
50
+ assert.equal(resolveModel("review", DEFAULT_MODEL_CONFIG, host), "deepseek/deepseek-v3.2");
51
+ assert.equal(resolveModel("review", { propose: INHERIT, review: "openai/gpt-5" }, host), "openai/gpt-5");
52
+ });
53
+
54
+ test("resolveModel:主会话没模型时仍返回 inherit,不伪造成空串", () => {
55
+ assert.equal(resolveModel("propose", DEFAULT_MODEL_CONFIG, {}), INHERIT);
56
+ assert.equal(resolveModel("propose", DEFAULT_MODEL_CONFIG, { model: { provider: "x" } }), INHERIT);
57
+ });
58
+
59
+ test("选择项第一项是跟随主会话,回读能还原成 inherit", () => {
60
+ const choices = modelChoices([{ provider: "deepseek", id: "deepseek-v3.2" }, { provider: "only-provider" }]);
61
+ assert.equal(choices.length, 2, "缺 id 的模型不进候选");
62
+ assert.match(choices[0], /跟随主会话/);
63
+ assert.equal(specFromChoice(choices[0]), INHERIT);
64
+ assert.equal(specFromChoice(choices[1]), "deepseek/deepseek-v3.2");
65
+ });
66
+
67
+ test("saveModelSpec 只改目标键,其余配置原样保留", async () => {
68
+ const dir = tmpDir();
69
+ fs.writeFileSync(path.join(dir, "config.json"), JSON.stringify({ enabled: false, idleMs: 1000, review: { model: "old/x" } }), "utf8");
70
+ const next = await saveModelSpec(dir, "propose", "deepseek/deepseek-v3.2", directQueue);
71
+ assert.equal(next.propose, "deepseek/deepseek-v3.2");
72
+ assert.equal(next.review, "old/x");
73
+ const saved = JSON.parse(fs.readFileSync(path.join(dir, "config.json"), "utf8")) as Record<string, unknown>;
74
+ assert.equal(saved.enabled, false, "其他键不得被覆盖");
75
+ assert.equal(saved.idleMs, 1000);
76
+ assert.deepEqual(saved.propose, { model: "deepseek/deepseek-v3.2" });
77
+ });
78
+
79
+ test("saveModelSpec 写入非法值时落 inherit 而不是坏字符串", async () => {
80
+ const dir = tmpDir();
81
+ const next = await saveModelSpec(dir, "review", "随便写点什么", directQueue);
82
+ assert.equal(next.review, INHERIT);
83
+ assert.match(describeModelConfig(next), /跟随主会话/);
84
+ });