pi-skill-evolve 0.1.11 → 0.1.13

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.
@@ -0,0 +1,141 @@
1
+ /**
2
+ * 流水线渲染单测:五段各挑一次真实故障当用例 —— 缺 01 / 有提案没督导 / 驳回 / 采纳完 / 自动关着。
3
+ * 断言只压「状态 + 下一步」,不锁具体措辞(文案会改,状态语义不该改)。
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 { buildPipeline, pipelineProgress, renderPipelineLine, type PipelineInput } from "../lib/pipeline.ts";
11
+
12
+ function runDir(): string {
13
+ return fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-pipeline-"));
14
+ }
15
+
16
+ function write(dir: string, name: string, content: string): void {
17
+ fs.writeFileSync(path.join(dir, name), content);
18
+ }
19
+
20
+ function input(dir: string, patch: Partial<PipelineInput> = {}): PipelineInput {
21
+ return {
22
+ runDir: dir,
23
+ status: "analyzed",
24
+ pid: null,
25
+ supervisorPid: null,
26
+ dispatchedAt: 1_800_000_000_000,
27
+ ...patch,
28
+ };
29
+ }
30
+
31
+ const stateOf = (pipeline: ReturnType<typeof buildPipeline>, key: string): string =>
32
+ pipeline.stages.find((stage) => stage.key === key)?.state ?? "missing";
33
+
34
+ test("只有 run.json(分析没出结论、子进程也死了):分析等收尾,不提评审", () => {
35
+ const dir = runDir();
36
+ const pipeline = buildPipeline(input(dir, { status: "dispatched" }));
37
+ assert.equal(stateOf(pipeline, "dispatch"), "done");
38
+ assert.equal(stateOf(pipeline, "analyze"), "waiting");
39
+ assert.equal(stateOf(pipeline, "propose"), "waiting");
40
+ assert.equal(stateOf(pipeline, "review"), "waiting");
41
+ assert.equal(stateOf(pipeline, "apply"), "waiting");
42
+ assert.match(pipeline.hint, /下一步「分析」/);
43
+ });
44
+
45
+ test("分析在跑:分析=进行中,提案=进行中(同一子进程)", () => {
46
+ const dir = runDir();
47
+ const pipeline = buildPipeline(input(dir, { status: "dispatched", pid: 4242, alive: (pid) => pid === 4242 }));
48
+ assert.equal(stateOf(pipeline, "analyze"), "running");
49
+ assert.equal(stateOf(pipeline, "propose"), "running");
50
+ assert.match(pipeline.stages[0]!.detail, /4242 在跑/);
51
+ });
52
+
53
+ test("分析完成没提案:写明「多半判定不该改」,不是错误", () => {
54
+ const dir = runDir();
55
+ write(dir, "01-findings.json", "{}");
56
+ const pipeline = buildPipeline(input(dir));
57
+ assert.equal(stateOf(pipeline, "analyze"), "done");
58
+ assert.equal(stateOf(pipeline, "propose"), "waiting");
59
+ assert.match(pipeline.stages[2]!.detail, /没写提案/);
60
+ assert.match(pipeline.hint, /下一步「提案」/);
61
+ });
62
+
63
+ test("提案在、督导已死:评审判 blocked 且指向自动重挂(2026-11-09 事故现场)", () => {
64
+ const dir = runDir();
65
+ write(dir, "01-findings.json", "{}");
66
+ write(dir, "01-findings.json", "{}");
67
+ write(dir, "02-proposal.json", JSON.stringify({ type: "improve", skill: "demo" }));
68
+ const pipeline = buildPipeline(input(dir, { autoReview: true }));
69
+ assert.equal(stateOf(pipeline, "review"), "blocked");
70
+ assert.match(pipeline.stages[3]!.detail, /下次会话起点自动重挂/);
71
+ assert.match(pipeline.hint, /下一步「评审」/);
72
+ });
73
+
74
+ test("提案在、督导在跑:评审=进行中", () => {
75
+ const dir = runDir();
76
+ write(dir, "02-proposal.json", JSON.stringify({ type: "improve", skill: "demo" }));
77
+ const pipeline = buildPipeline(input(dir, { status: "proposed", supervisorPid: 777, alive: (pid) => pid === 777 }));
78
+ assert.equal(stateOf(pipeline, "review"), "running");
79
+ assert.match(pipeline.stages[1]!.detail, /01-findings.json/);
80
+ });
81
+
82
+ test("自动评审关着:评审=跳过而不是卡住", () => {
83
+ const dir = runDir();
84
+ write(dir, "01-findings.json", "{}");
85
+ write(dir, "02-proposal.json", "{}");
86
+ const pipeline = buildPipeline(input(dir, { autoReview: false }));
87
+ assert.equal(stateOf(pipeline, "review"), "skipped");
88
+ assert.match(pipeline.hint, /下一步「采纳」/);
89
+ });
90
+
91
+ test("评审驳回:采纳=跳过,hint 明说不会采纳", () => {
92
+ const dir = runDir();
93
+ write(dir, "01-findings.json", "{}");
94
+ write(dir, "02-proposal.json", "{}");
95
+ write(dir, "03-review.json", JSON.stringify({ status: "rejected", verdict: "revise" }));
96
+ const pipeline = buildPipeline(input(dir, { status: "rejected" }));
97
+ assert.equal(stateOf(pipeline, "review"), "done");
98
+ assert.match(pipeline.stages[3]!.detail, /rejected \/ revise/);
99
+ assert.equal(stateOf(pipeline, "apply"), "skipped");
100
+ assert.match(pipeline.hint, /驳回|不会采纳|结束/);
101
+ });
102
+
103
+ test("评审通过 + 队列 pending + 自动采纳开着:采纳=进行中", () => {
104
+ const dir = runDir();
105
+ write(dir, "03-review.json", JSON.stringify({ status: "approved", verdict: "accept" }));
106
+ const pipeline = buildPipeline(input(dir, { status: "approved", queued: "pending", autoApply: true }));
107
+ assert.equal(stateOf(pipeline, "apply"), "running");
108
+ });
109
+
110
+ test("评审通过但自动采纳关着:采纳=卡住等人工批准", () => {
111
+ const dir = runDir();
112
+ write(dir, "03-review.json", JSON.stringify({ status: "approved" }));
113
+ const pipeline = buildPipeline(input(dir, { status: "approved", queued: "pending", autoApply: false }));
114
+ assert.equal(stateOf(pipeline, "apply"), "blocked");
115
+ assert.match(pipeline.stages[4]!.detail, /等面板批准/);
116
+ });
117
+
118
+ test("采纳完成:五段全 done,hint 说走完", () => {
119
+ const dir = runDir();
120
+ write(dir, "01-findings.json", "{}");
121
+ write(dir, "02-proposal.json", "{}");
122
+ write(dir, "03-review.json", JSON.stringify({ status: "approved" }));
123
+ write(dir, "04-decision.json", "{}");
124
+ const pipeline = buildPipeline(input(dir, { status: "applied" }));
125
+ assert.equal(pipeline.stages.every((stage) => stage.state === "done"), true);
126
+ assert.match(pipeline.hint, /走完/);
127
+ assert.deepEqual(pipelineProgress(pipeline), { done: 5, total: 5 });
128
+ });
129
+
130
+ test("卡死状态:分析=失败,不再假装在跑", () => {
131
+ const dir = runDir();
132
+ const pipeline = buildPipeline(input(dir, { status: "stale" }));
133
+ assert.equal(stateOf(pipeline, "analyze"), "failed");
134
+ assert.deepEqual(pipelineProgress(pipeline), { done: 1, total: 5 });
135
+ });
136
+
137
+ test("一行进度与符号:派发✓ 分析✗ …", () => {
138
+ const dir = runDir();
139
+ const line = renderPipelineLine(buildPipeline(input(dir, { status: "stale" })));
140
+ assert.equal(line, "派发✓ 分析✗ 提案· 评审· 采纳·");
141
+ });
@@ -0,0 +1,177 @@
1
+ /**
2
+ * 重启恢复单测:这里的每一条断言都对应一次「用户看到已分析却永远没提案」的真实死法。
3
+ * 纪律是保守:能推状态就推,能重挂督导就重挂,**绝不重跑复盘**。
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 { recoverRuns, renderRecovery, RECOVER_WINDOW_MS } from "../lib/recover.ts";
11
+ import type { SpawnSupervisorOptions } from "../lib/review-spawn.ts";
12
+
13
+ const NOW = 1_800_000_000_000;
14
+ const queue = (file: string, run: () => Promise<void>): Promise<void> => run();
15
+
16
+ function setup(): { evolutionDir: string; write: (runId: string, record: Record<string, unknown>, files?: string[]) => string } {
17
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-recover-"));
18
+ const evolutionDir = path.join(root, ".pi", "evolution");
19
+ fs.mkdirSync(path.join(evolutionDir, "runs"), { recursive: true });
20
+ const write = (runId: string, record: Record<string, unknown>, files: string[] = []): string => {
21
+ const runDir = path.join(evolutionDir, "runs", runId);
22
+ fs.mkdirSync(runDir, { recursive: true });
23
+ fs.writeFileSync(
24
+ path.join(runDir, "run.json"),
25
+ JSON.stringify({ runId, status: "dispatched", dispatchedAt: NOW - 60_000, pid: null, supervisorPid: null, ...record }, null, 2),
26
+ );
27
+ for (const name of files) fs.writeFileSync(path.join(runDir, name), "{}");
28
+ return runDir;
29
+ };
30
+ return { evolutionDir, write };
31
+ }
32
+
33
+ function base(deps: Partial<Parameters<typeof recoverRuns>[0]> & { evolutionDir: string }) {
34
+ return {
35
+ cwd: "/repo",
36
+ queue,
37
+ now: () => NOW,
38
+ alive: () => false,
39
+ autoReview: () => true,
40
+ reviewModel: () => "inherit",
41
+ log: () => {},
42
+ ...deps,
43
+ } as Parameters<typeof recoverRuns>[0];
44
+ }
45
+
46
+ test("分析产物在但状态还写 dispatched:补成 analyzed,不重跑", async () => {
47
+ const { evolutionDir, write } = setup();
48
+ const runDir = write("r-1", { pid: 4242 }, ["01-findings.json"]);
49
+ const actions = await recoverRuns(base({ evolutionDir, alive: () => false }));
50
+ assert.deepEqual(actions.map((a) => a.kind), ["advanced-analyzed", "interrupted"]);
51
+ const record = JSON.parse(fs.readFileSync(path.join(runDir, "run.json"), "utf8")) as { status: string };
52
+ assert.equal(record.status, "analyzed");
53
+ });
54
+
55
+ test("提案在、督导已死:重挂一个督导接着评审", async () => {
56
+ const { evolutionDir, write } = setup();
57
+ const runDir = write("r-2", { status: "analyzed", pid: null, supervisorPid: 777 }, ["01-findings.json", "02-proposal.json"]);
58
+ const seen: SpawnSupervisorOptions[] = [];
59
+ const actions = await recoverRuns(
60
+ base({
61
+ evolutionDir,
62
+ alive: (pid) => pid !== 777, // 只有督导死了
63
+ spawnSupervisor: (options) => {
64
+ seen.push(options);
65
+ return 9999;
66
+ },
67
+ }),
68
+ );
69
+ assert.deepEqual(actions.map((a) => a.kind), ["supervisor-respawned"]);
70
+ assert.equal(actions[0]!.detail.includes("9999"), true);
71
+ assert.equal(seen.length, 1);
72
+ assert.equal(seen[0]!.runDir, runDir);
73
+ assert.equal(seen[0]!.autoReview, true, "重挂时明确开着评审");
74
+ assert.equal(seen[0]!.reviewModel, "inherit");
75
+ });
76
+
77
+ test("督导还活着:不重挂、不打扰", async () => {
78
+ const { evolutionDir, write } = setup();
79
+ write("r-3", { status: "analyzed", supervisorPid: 888 }, ["01-findings.json", "02-proposal.json"]);
80
+ let spawned = 0;
81
+ const actions = await recoverRuns(
82
+ base({
83
+ evolutionDir,
84
+ alive: (pid) => pid === 888,
85
+ spawnSupervisor: () => {
86
+ spawned += 1;
87
+ return 1;
88
+ },
89
+ }),
90
+ );
91
+ assert.deepEqual(actions.map((a) => a.kind), ["waiting-review"]);
92
+ assert.equal(spawned, 0);
93
+ });
94
+
95
+ test("自动评审关着:不偷偷打开,只报告待人工", async () => {
96
+ const { evolutionDir, write } = setup();
97
+ write("r-4", { status: "analyzed", supervisorPid: 1 }, ["02-proposal.json"]);
98
+ let spawned = 0;
99
+ const actions = await recoverRuns(
100
+ base({
101
+ evolutionDir,
102
+ autoReview: () => false,
103
+ spawnSupervisor: () => {
104
+ spawned += 1;
105
+ return 1;
106
+ },
107
+ }),
108
+ );
109
+ assert.deepEqual(actions.map((a) => a.kind), ["respawn-blocked"]);
110
+ assert.equal(spawned, 0);
111
+ });
112
+
113
+ test("复盘子进程还活着且没提案:等它,不插一脚", async () => {
114
+ const { evolutionDir, write } = setup();
115
+ write("r-5", { pid: 555 }, []);
116
+ const actions = await recoverRuns(base({ evolutionDir, alive: (pid) => pid === 555 }));
117
+ assert.deepEqual(actions.map((a) => a.kind), ["waiting-proposal"]);
118
+ });
119
+
120
+ test("已有 03-review.json:什么都不做,交给入队/自动采纳", async () => {
121
+ const { evolutionDir, write } = setup();
122
+ write("r-6", { status: "proposed" }, ["01-findings.json", "02-proposal.json", "03-review.json"]);
123
+ const actions = await recoverRuns(base({ evolutionDir }));
124
+ assert.deepEqual(actions.map((a) => a.kind), ["review-done"]);
125
+ });
126
+
127
+ test("终态与超窗 run 一律不碰", async () => {
128
+ const { evolutionDir, write } = setup();
129
+ write("r-applied", { status: "applied", pid: null }, ["01-findings.json", "02-proposal.json", "03-review.json"]);
130
+ write("r-old", { status: "analyzed", dispatchedAt: NOW - RECOVER_WINDOW_MS - 1 }, ["02-proposal.json"]);
131
+ write("r-stale", { status: "stale", pid: null }, ["01-findings.json"]);
132
+ const actions = await recoverRuns(base({ evolutionDir }));
133
+ assert.deepEqual(actions, []);
134
+ assert.equal(renderRecovery([]), "重启恢复:没有需要接手的 run");
135
+ });
136
+
137
+ test("坏 run.json 当没有,不崩", async () => {
138
+ const { evolutionDir } = setup();
139
+ const broken = path.join(evolutionDir, "runs", "r-broken");
140
+ fs.mkdirSync(broken, { recursive: true });
141
+ fs.writeFileSync(path.join(broken, "run.json"), "{not json");
142
+ assert.deepEqual(await recoverRuns(base({ evolutionDir })), []);
143
+ });
144
+
145
+ test("面板文案带 kinds 与明细", () => {
146
+ const text = renderRecovery([
147
+ { runId: "r-1", kind: "supervisor-respawned", detail: "重挂 pid 9" },
148
+ { runId: "r-2", kind: "interrupted", detail: "没提案" },
149
+ ]);
150
+ assert.match(text, /重启恢复:2 条 run 被检查/);
151
+ assert.match(text, /r-1 {2}重挂督导 — 重挂 pid 9/);
152
+ assert.match(text, /r-2 {2}已中断 — 没提案/);
153
+ });
154
+
155
+ test("一个工作区两个 pi 同时启动恢复:督导只起一个(另一个看到锁里已经挂上了)", async () => {
156
+ const { evolutionDir, write } = setup();
157
+ write("r-9", { status: "analyzed", pid: null, supervisorPid: null }, ["01-findings.json", "02-proposal.json"]);
158
+ let spawns = 0;
159
+ const spawnSupervisor = (options: SpawnSupervisorOptions): number => {
160
+ spawns += 1;
161
+ // 模拟真实现:spawn 之后立刻把 supervisorPid 写回 run.json(这就是后到者的判断依据)
162
+ const file = path.join(options.runDir, "run.json");
163
+ const raw = JSON.parse(fs.readFileSync(file, "utf8")) as Record<string, unknown>;
164
+ fs.writeFileSync(file, JSON.stringify({ ...raw, supervisorPid: process.pid }, null, 2));
165
+ return process.pid;
166
+ };
167
+ // 督导 pid 就是本进程 pid:重挂后它「活着」,这正是后到者的判断依据
168
+ const deps = base({ evolutionDir, spawnSupervisor, alive: (pid) => pid === process.pid });
169
+ const both = await Promise.all([recoverRuns(deps), recoverRuns(deps)]);
170
+ assert.equal(spawns, 1, "两个实例各起一个督导 = 双花 token + 双写 03-review");
171
+ const kinds = both.flat().map((action) => action.kind);
172
+ assert.ok(
173
+ kinds.includes("supervisor-respawned") || kinds.includes("respawn-blocked"),
174
+ `应当有人挂上、有人让位(实际 ${kinds.join(",")})`,
175
+ );
176
+ assert.ok(!kinds.includes("interrupted"), "提案在、督导被重挂,不该判成中断");
177
+ });
@@ -7,6 +7,9 @@ import assert from "node:assert/strict";
7
7
  import * as path from "node:path";
8
8
  import { SUPERVISOR_SCRIPT, spawnReviewSupervisor } from "../lib/review-spawn.ts";
9
9
 
10
+ /** 测试里不搬家:直接返回包内入口(搬家用 supervisor-runtime.test.ts 单独覆盖) */
11
+ const direct = ({ scriptPath }: { scriptPath: string }): string => scriptPath;
12
+
10
13
  function capture(): { calls: { command: string; args: string[]; options: Record<string, unknown> }[]; unrefs: number } {
11
14
  return { calls: [], unrefs: 0 };
12
15
  }
@@ -18,6 +21,7 @@ test("督导用 node + --experimental-strip-types 起,参数里带 runDir", ()
18
21
  cwd: "/repo",
19
22
  env: {},
20
23
  autoReview: true,
24
+ resolveScript: direct,
21
25
  spawnFn: (command, args, options) => {
22
26
  seen.calls.push({ command, args, options });
23
27
  return {
@@ -45,8 +49,8 @@ test("inherit 不追加 --model,显式模型追加在 runDir 之后", () => {
45
49
  explicitArgs.push(args);
46
50
  return { pid: 1 };
47
51
  };
48
- spawnReviewSupervisor({ runDir: "/r", cwd: "/repo", env: {}, autoReview: true, reviewModel: "inherit", spawnFn });
49
- spawnReviewSupervisor({ runDir: "/r", cwd: "/repo", env: {}, autoReview: true, reviewModel: "deepseek/deepseek-v3.2", spawnFn });
52
+ spawnReviewSupervisor({ runDir: "/r", cwd: "/repo", env: {}, autoReview: true, reviewModel: "inherit", spawnFn, resolveScript: direct });
53
+ spawnReviewSupervisor({ runDir: "/r", cwd: "/repo", env: {}, autoReview: true, reviewModel: "deepseek/deepseek-v3.2", spawnFn, resolveScript: direct });
50
54
  assert.deepEqual(inheritArgs[0], ["--experimental-strip-types", SUPERVISOR_SCRIPT, "/r"]);
51
55
  assert.deepEqual(explicitArgs[1], ["--experimental-strip-types", SUPERVISOR_SCRIPT, "/r", "deepseek/deepseek-v3.2"]);
52
56
  });
@@ -58,6 +62,7 @@ test("env 里去掉 SKILL_EVOLVE_CHILD,其他变量照旧带走", () => {
58
62
  cwd: "/repo",
59
63
  env: { SKILL_EVOLVE_CHILD: "1", PATH: "/usr/bin" },
60
64
  autoReview: true,
65
+ resolveScript: direct,
61
66
  spawnFn: (_command, _args, options) => {
62
67
  env = options.env as NodeJS.ProcessEnv;
63
68
  return { pid: 1 };
@@ -67,6 +72,27 @@ test("env 里去掉 SKILL_EVOLVE_CHILD,其他变量照旧带走", () => {
67
72
  assert.equal(env.PATH, "/usr/bin");
68
73
  });
69
74
 
75
+ test("督导入口解析拿到 evolutionDir(node_modules 里要搬到工作区 runtime 下再跑)", () => {
76
+ const seen: { evolutionDir?: string; scriptPath?: string }[] = [];
77
+ spawnReviewSupervisor({
78
+ runDir: "/repo/.pi/evolution/runs/run-9",
79
+ cwd: "/repo",
80
+ env: {},
81
+ autoReview: true,
82
+ resolveScript: (options) => {
83
+ seen.push(options);
84
+ return "/tmp/copied/review-supervisor.ts";
85
+ },
86
+ spawnFn: (_c, args) => {
87
+ assert.equal(args[1], "/tmp/copied/review-supervisor.ts", "spawn 用的必须是解析后的路径");
88
+ return { pid: 7 };
89
+ },
90
+ });
91
+ assert.equal(seen.length, 1);
92
+ assert.equal(seen[0]!.evolutionDir, "/repo/.pi/evolution");
93
+ assert.equal(seen[0]!.scriptPath, SUPERVISOR_SCRIPT);
94
+ });
95
+
70
96
  test("自动评审关着:一个进程都不起,只留一句「停在 proposed」日志", () => {
71
97
  const logs: string[] = [];
72
98
  let spawned = 0;
@@ -75,6 +101,7 @@ test("自动评审关着:一个进程都不起,只留一句「停在 propose
75
101
  cwd: "/repo",
76
102
  env: {},
77
103
  autoReview: false,
104
+ resolveScript: direct,
78
105
  log: (msg) => logs.push(msg),
79
106
  spawnFn: () => {
80
107
  spawned += 1;
@@ -94,6 +121,7 @@ test("起不来只记日志返回 null,不抛", () => {
94
121
  cwd: "/repo",
95
122
  env: {},
96
123
  autoReview: true,
124
+ resolveScript: direct,
97
125
  log: (msg) => logs.push(msg),
98
126
  spawnFn: () => {
99
127
  throw new Error("EMFILE");
@@ -0,0 +1,107 @@
1
+ /**
2
+ * 督导运行目录单测:npm 包装形态下督导脚本在 node_modules 里,Node 的 --experimental-strip-types
3
+ * 会拒绝它(ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING)。这一层唯一的职责就是「先搬家再跑」,
4
+ * 所以测试只盯三件事:**该搬的搬**、**搬家后内容完整**、**搬不了就退回原路径**。
5
+ */
6
+ import { test } from "node:test";
7
+ import assert from "node:assert/strict";
8
+ import * as fs from "node:fs";
9
+ import * as os from "node:os";
10
+ import * as path from "node:path";
11
+ import {
12
+ ensureSupervisorRuntime,
13
+ needsCopy,
14
+ packageVersionOf,
15
+ RUNTIME_DIRNAME,
16
+ runtimeDirOf,
17
+ runtimeKey,
18
+ } from "../lib/supervisor-runtime.ts";
19
+
20
+ function tmpRoot(): string {
21
+ return fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-runtime-"));
22
+ }
23
+
24
+ /** 造一份「装在 node_modules 里」的包:lib/ 两个文件 + 一个测试文件(不该被拷) */
25
+ function fakePackage(): { root: string; script: string } {
26
+ const root = path.join(tmpRoot(), "node_modules", "pi-skill-evolve");
27
+ const lib = path.join(root, "extensions", "skill-evolve", "lib");
28
+ fs.mkdirSync(lib, { recursive: true });
29
+ fs.writeFileSync(path.join(lib, "review-supervisor.ts"), "export const a = 1;\n");
30
+ fs.writeFileSync(path.join(lib, "runs.ts"), "export const b = 2;\n");
31
+ fs.writeFileSync(path.join(lib, "runs.test.ts"), "test('x', () => {});\n");
32
+ fs.writeFileSync(path.join(root, "package.json"), JSON.stringify({ name: "pi-skill-evolve", version: "0.1.12" }));
33
+ return { root, script: path.join(lib, "review-supervisor.ts") };
34
+ }
35
+
36
+ test("只有 node_modules 里的脚本需要搬家", () => {
37
+ assert.equal(needsCopy("/repo/.pi/npm/node_modules/pi-skill-evolve/extensions/skill-evolve/lib/review-supervisor.ts"), true);
38
+ assert.equal(needsCopy("C:\\repo\\node_modules\\pkg\\lib\\review-supervisor.ts"), true);
39
+ assert.equal(needsCopy("/repo/.pi/extensions/skill-evolve/lib/review-supervisor.ts"), false);
40
+ });
41
+
42
+ test("本地扩展原路径直接用,一个文件都不拷", () => {
43
+ const evolutionDir = tmpRoot();
44
+ const script = "/repo/.pi/extensions/skill-evolve/lib/review-supervisor.ts";
45
+ assert.equal(ensureSupervisorRuntime({ evolutionDir, scriptPath: script }), script);
46
+ assert.equal(fs.existsSync(path.join(evolutionDir, "runtime")), false);
47
+ });
48
+
49
+ test("node_modules 里的督导搬到 runtime 下,内容齐全且跳过测试文件", () => {
50
+ const { script } = fakePackage();
51
+ const evolutionDir = tmpRoot();
52
+ const entry = ensureSupervisorRuntime({ evolutionDir, scriptPath: script });
53
+ assert.notEqual(entry, script, "必须换成副本路径");
54
+ const key = runtimeKey(script, "0.1.12");
55
+ assert.equal(entry, path.join(runtimeDirOf(evolutionDir, key), "review-supervisor.ts"));
56
+ assert.equal(fs.readFileSync(entry, "utf8"), "export const a = 1;\n");
57
+ assert.equal(fs.existsSync(path.join(path.dirname(entry), "runs.ts")), true, "同目录的相对 import 必须一起搬");
58
+ assert.equal(fs.existsSync(path.join(path.dirname(entry), "runs.test.ts")), false);
59
+ assert.equal(fs.existsSync(path.join(path.dirname(entry), ".ready")), true);
60
+ });
61
+
62
+ test("重复调用复用同一份副本,不重复拷贝", () => {
63
+ const { script } = fakePackage();
64
+ const evolutionDir = tmpRoot();
65
+ const first = ensureSupervisorRuntime({ evolutionDir, scriptPath: script });
66
+ const stamp = fs.statSync(first).mtimeMs;
67
+ const second = ensureSupervisorRuntime({ evolutionDir, scriptPath: script });
68
+ assert.equal(second, first);
69
+ assert.equal(fs.statSync(second).mtimeMs, stamp, "第二次不该重写文件");
70
+ });
71
+
72
+ test("内容变了(size/mtime 变)就换新目录,旧目录被清掉", () => {
73
+ const { script } = fakePackage();
74
+ const evolutionDir = tmpRoot();
75
+ const first = ensureSupervisorRuntime({ evolutionDir, scriptPath: script });
76
+ fs.writeFileSync(script, "export const a = 123456;\n");
77
+ const future = new Date(Date.now() + 5000);
78
+ fs.utimesSync(script, future, future);
79
+ const second = ensureSupervisorRuntime({ evolutionDir, scriptPath: script });
80
+ assert.notEqual(second, first);
81
+ assert.equal(fs.existsSync(first), false, "旧版本副本要清掉");
82
+ assert.equal(fs.readFileSync(second, "utf8"), "export const a = 123456;\n");
83
+ });
84
+
85
+ test("搬不了(目标目录建不出来)就退回原路径,只记日志不抛", () => {
86
+ const { script } = fakePackage();
87
+ const blocked = path.join(tmpRoot(), "file-not-dir");
88
+ fs.writeFileSync(blocked, "x");
89
+ const logs: string[] = [];
90
+ const entry = ensureSupervisorRuntime({
91
+ evolutionDir: path.join(blocked, "sub"),
92
+ scriptPath: script,
93
+ log: (msg) => logs.push(msg),
94
+ });
95
+ assert.equal(entry, script);
96
+ assert.match(logs.join("\n"), /督导运行目录准备失败/);
97
+ });
98
+
99
+ test("缓存键带版本与入口文件指纹,包版本读得到就直接用", () => {
100
+ const { script } = fakePackage();
101
+ assert.equal(packageVersionOf(script), "0.1.12");
102
+ const key = runtimeKey(script, "0.1.12");
103
+ assert.match(key, /^lib-0\.1\.12-\d+-\d+$/);
104
+ assert.equal(runtimeKey(script), key, "不传版本时从最近的 package.json 取");
105
+ assert.equal(RUNTIME_DIRNAME, "supervisor");
106
+ assert.equal(packageVersionOf(path.join(tmpRoot(), "lonely", "x.ts")), "", "找不到 package.json 就是空串");
107
+ });
@@ -313,6 +313,8 @@ test("pending:正在跑的 run 排最前,阶段 / 进程 / 最近输出 /
313
313
  assert.match(detail, /进程:pid 1234 · 已跑 1m 30s(上限 20 分钟)/);
314
314
  assert.match(detail, /最近输出:(还没有输出)/, "child.log 还没生成时明确说没有输出");
315
315
  assert.match(detail, /日志:tail -f \.pi\/evolution\/runs\/live\/child\.log/);
316
+ // 五段进度:分析在跑 ⇒ 分析/提案都该是「…」,评审判「等提案」而不是含糊的「在进行」
317
+ assert.match(detail, /流水线:派发✓ 分析… 提案… 评审· 采纳·/);
316
318
  assert.equal(state.pending[1]!.kind, "approval", "待批排在运行中之后");
317
319
  });
318
320
 
@@ -69,6 +69,17 @@ function detail(parts: Partial<RunDetail> = {}): RunDetail {
69
69
  findings: [{ skill: "demo", reason: "G", confidence: "high", lines: [578, 622] }],
70
70
  findingsTotal: 1,
71
71
  notes: "",
72
+ // 流水线固定在详情页顶部:夹具给一条「已驳回」的完整链路,断言单独在 test/pipeline.test.ts
73
+ pipeline: {
74
+ stages: [
75
+ { key: "dispatch", label: "派发", state: "done", detail: "复盘子进程 pid 65757 已退出", at: 1794133942865 },
76
+ { key: "analyze", label: "分析", state: "done", detail: "01-findings.json 已落盘", at: 1794134000000 },
77
+ { key: "propose", label: "提案", state: "done", detail: "improve → demo", at: 1794134050000 },
78
+ { key: "review", label: "评审", state: "done", detail: "rejected / revise", at: 1794134158913 },
79
+ { key: "apply", label: "采纳", state: "skipped", detail: "评审驳回,不会采纳", at: null },
80
+ ],
81
+ hint: "流程结束:卡在「采纳」— 评审驳回,不会采纳",
82
+ },
72
83
  ...parts,
73
84
  };
74
85
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-skill-evolve",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "技能进化:采集会话中的技能曝光/加载,异步复盘、生成改进提案并独立评审,附本机 Web 管理面板",
5
5
  "keywords": [
6
6
  "pi-package",