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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.13] - 2026-11-09
4
+
5
+ 同一个工作区开两个 pi(两个窗口 / 一个窗口执行中另开一个)时的两条竞态。
6
+
7
+ - 修复:两个实例的 `session_start` 都会重挂督导,单飞闸只认「已经写进 `run.json` 的活 pid」,两边 spawn 之间的空档会各起一个督导(双花 token、双写 `03-review.json`)。现在重挂前先抢 `locks/supervisor`,抢到后再复核一次 `supervisorPid`,后到者直接写 `respawn-blocked`「另一个 pi 实例正在重挂督导」
8
+ - 修复:两个实例同时扫队列采纳会互相覆盖 `queue.json`(读-改-写),同一份 diff 甚至可能被各应用一次。`session_start` / `settle` 的「扫队列 → 应用」整段现在抢 `locks/apply`,抢不到就跳过,下一次会话起点再来
9
+ - 新增:`lockDir(evolutionDir, name)` 支持多把锁(`dispatch` / `supervisor` / `apply`,缺省仍是 `dispatch`,老行为不变);`withLock()` 跨进程互斥执行,异常也保证释放
10
+ - 测试:685 → 688(新增「两个 pi 同时恢复只起一个督导」「两把锁互不干扰」「withLock 忙时让位 + 抛错也释放」);另用两个真实 node 进程并发跑恢复验证:`spawn` 计数 1,一个 `supervisor-respawned`、一个 `respawn-blocked`
11
+
12
+ ## [0.1.12] - 2026-11-09
13
+
14
+ 按 npm 包安装时「评审 → 采纳」整条链原本一次都跑不起来,这个版本修掉它,并把每轮复盘画成五段流水线。
15
+
16
+ - 修复(事故):`review-supervisor` 在 `node_modules` 里被 `node --experimental-strip-types` 拒绝(`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`),督导一起就崩。新增 `lib/supervisor-runtime.ts`:把督导依赖闭包先复制到 `.pi/evolution/runtime/supervisor/<版本-指纹>/`(排除 `*.test.*`、`.ready` 标记、升级清旧目录),搬不动回退原路径只记日志
17
+ - 修复:督导「起来但什么都不做,退出码 0」——`import.meta.url` 与 `process.argv[1]` 直接比字符串,macOS `/tmp` 软链(`/private/tmp`)会判成「不是直接运行」。改 `isDirectInvocation()` 用 `realpathSync` 双向比对
18
+ - 新增:`lib/pipeline.ts` 五段流水线(派发 → 分析 → 提案 → 评审 → 采纳),每段状态取 `done/running/waiting/blocked/failed/skipped`,只看磁盘产物与 pid,不猜进度;卡住那段直接写「下次会话起点自动重挂」之类的接手方式
19
+ - 面板:run 详情页顶部新增「流水线」一排格子(状态色 + 明细 + 时刻 + 一句话 hint),排在概要之前;待处理页的「正在跑」条目多一行 `流水线:派发✓ 分析… 提案· 评审· 采纳·`
20
+ - 新增:`lib/recover.ts` 重启 / `/reload` 后自动接手 —— 分析产物在而状态还是 `dispatched` 就补成 `analyzed`;有提案但督导已死就重挂督导;复盘子进程还活着则不打扰;**绝不重跑复盘**
21
+ - 会话起点(`session_start`)会调 `recover()`,有动作时用一行通知说明补了什么
22
+ - 测试:664 → 685 条(新增 `supervisor-runtime`、`recover`、`pipeline` 三组用例)
23
+
3
24
  ## [0.1.0] - 2026-11-08
4
25
 
5
26
  首个可用版本。
package/README.md CHANGED
@@ -36,6 +36,31 @@ pi install git:github.com/suveng/pi-skill-evolve
36
36
 
37
37
  只绑 `127.0.0.1`、零鉴权、写操作带 CSRF 与同源校验;远程看用 `ssh -L 7788:127.0.0.1:7788`。
38
38
 
39
+ ### 流水线
40
+
41
+ 每条 run 都走五段,run 详情页顶部会画出这五格(状态词 + 明细 + 时刻 + 一句话 hint):
42
+
43
+ | 段 | 完成的标志 | 卡住时谁接手 |
44
+ |---|---|---|
45
+ | 派发 | `run.json` | — |
46
+ | 分析 | `01-findings.json` | 子进程退出没产物 → 卡死判定收尾 |
47
+ | 提案 | `02-proposal.json` | 分析已结束却没提案 = 判定「不该改」,正常结束 |
48
+ | 评审 | `03-review.json` | 提案在、督导不在 → 下次会话起点自动重挂 |
49
+ | 采纳 | `04-decision.json` | 自动采纳关着 → 面板待审批页批准 |
50
+
51
+ ### 一个工作区开两个 pi
52
+
53
+ 同时开两个 pi 窗口(或一个在跑、另开一个)时:
54
+
55
+ - **复盘派发**:`locks/dispatch` 原子锁,任意时刻只有一个 run 在跑,另一个只写一条 `skipped(busy)`
56
+ - **重挂督导**:`locks/supervisor` + 抢到锁后复核 `supervisorPid`,只有一边起督导,另一边记「另一个 pi 实例正在重挂督导」
57
+ - **扫队列 / 采纳**:`locks/apply` 独占,「读-改-写 `queue.json`」与应用 diff 不会被两边同时做;抢不到的那一边跳过,下次 `settle` / 会话起点再来
58
+ - **Web 面板**:`locks/web.json` 记持有者,第二个实例不抢端口,只在日志里说「由 pid N 持有」;`/skill-evolve status` 在任何一边都能看到面板地址
59
+
60
+ ### 重启恢复
61
+
62
+ `/reload` 或重启会丢掉内存里的收尸逻辑(`watchRunChild` 的回调),所以 `session_start` 会跑一次恢复:补 `analyzed` 状态、重挂被带走的督导、其余不动。**永不重跑复盘**(花 token 且会覆盖已有产物),有动作时用一行通知说明补了什么。
63
+
39
64
  ## 配置
40
65
 
41
66
  `.pi/evolution/config.json`(建议 gitignore,开关是本机偏好):
@@ -16,6 +16,7 @@ import { createEvolution, type Evolution } from "./lib/evolution.js";
16
16
  import { logLine } from "./lib/logfile.js";
17
17
  import { deliverNotifications } from "./lib/notify.js";
18
18
  import { entryLines, type PanelEntry, type PanelState } from "./lib/panel.js";
19
+ import { renderRecovery } from "./lib/recover.js";
19
20
  import { renderPanel, writeEntry, type UiPanelHost } from "./lib/ui-gate.js";
20
21
  import { startWebPanel } from "./lib/web/server.js";
21
22
  import { createWebLifecycle } from "./lib/web/autostart.js";
@@ -233,6 +234,15 @@ export default function skillEvolve(pi: ExtensionAPI): void {
233
234
  pi.on("session_start", async (_event, ctx) => {
234
235
  guarded(ctx, "session_start", (evo) => {
235
236
  evo.apply.sessionStart();
237
+ // `/reload` 与重启会丢掉内存里的收尸逻辑:这里补状态、重挂被带走的督导(不重跑复盘)
238
+ void evo.recover().then((actions) => {
239
+ if (actions.length > 0) {
240
+ // 通知只有一行:把多行恢复报告压成 ` / ` 分隔
241
+ const brief = renderRecovery(actions).replace(/\n\s*/g, " / ");
242
+ ctx.ui.notify(brief, "info");
243
+ }
244
+ evo.observe.refresh();
245
+ });
236
246
  evo.observe.refresh();
237
247
  // 上一轮没送出去的失败/待批,这里补发(notified.json 保证不重复)
238
248
  notifyEvents(ctx.cwd);
@@ -14,6 +14,7 @@ import { classifyChange } from "./classify-action.ts";
14
14
  import { captureGate0, saveGate0, type GitRunner } from "./gate0.ts";
15
15
  import { buildPrunePlan, renderPrunePlan } from "./prune-plan.ts";
16
16
  import { enqueue, findItem, markDone, readQueue, takePending, type QueueItem, type QueueStatus } from "./queue.ts";
17
+ import { withLock } from "./lock.ts";
17
18
  import { logLine } from "./logfile.ts";
18
19
  import { updateRunStatus } from "./runs.ts";
19
20
  import type { MutationQueue } from "./telemetry.ts";
@@ -56,6 +57,8 @@ const READY_STATUS = "reviewed";
56
57
  /** 评审关着时 run 停在这里:提案写完了、没人看过,仍然入队等人工批准 */
57
58
  const PROPOSED_STATUS = "proposed";
58
59
  const MAX_SCAN = 50;
60
+ /** 采纳链的锁上限:一次扫队列 + 应用几份 diff,正常两秒内完成;锁主崩了 5 分钟后可回收 */
61
+ const APPLY_LOCK_MS = 5 * 60_000;
59
62
 
60
63
  function readText(file: string): string | null {
61
64
  try {
@@ -225,10 +228,33 @@ export function createApplyTools(deps: ApplyToolsDeps): ApplyTools {
225
228
  return applied;
226
229
  };
227
230
 
231
+ /**
232
+ * 采纳链(扫队列 → 应用)跨实例互斥。
233
+ *
234
+ * 为什么需要:同一个工作区开两个 pi 时,两边的 `session_start` / `settle` 都会扫同一份 queue.json。
235
+ * 队列是「读-改-写」,两个进程同时写会互相覆盖;`autoApplyPending` 更糟 —— 同一份 diff 可能被
236
+ * 两个进程各应用一次。抢不到锁的那一方直接跳过,下一次 settle / 会话起点还会再来。
237
+ */
238
+ const withApplyLock = async (run: () => Promise<unknown>): Promise<void> => {
239
+ const got = await withLock(
240
+ {
241
+ evolutionDir: deps.evolutionDir,
242
+ name: "apply",
243
+ owner: { pid: process.pid, ts: now() },
244
+ maxRuntimeMs: APPLY_LOCK_MS,
245
+ log,
246
+ },
247
+ run,
248
+ );
249
+ if (!got.ok) {
250
+ log(`[skill-evolve] 采纳链由另一个 pi 实例持有(locks/apply pid ${got.owner?.pid ?? "?"}),本次跳过`);
251
+ }
252
+ };
253
+
228
254
  const scanAndApply = (): void => {
229
- void enqueueReady()
230
- .then(() => autoApplyPending())
231
- .catch((error: unknown) => log(`[skill-evolve] 采纳扫描失败:${String(error)}`));
255
+ void withApplyLock(() => enqueueReady().then(() => autoApplyPending())).catch((error: unknown) =>
256
+ log(`[skill-evolve] 采纳扫描失败:${String(error)}`),
257
+ );
232
258
  };
233
259
 
234
260
  const sessionStart: ApplyTools["sessionStart"] = () => {
@@ -239,8 +265,7 @@ export function createApplyTools(deps: ApplyToolsDeps): ApplyTools {
239
265
  log(`[skill-evolve] 备份清理失败:${String(error)}`);
240
266
  }
241
267
  void saveGate0(deps.evolutionDir, captureGate0(deps.git, deps.cwd, time), deps.queue)
242
- .then(() => enqueueReady())
243
- .then(() => autoApplyPending())
268
+ .then(() => withApplyLock(() => enqueueReady().then(() => autoApplyPending())))
244
269
  .catch((error: unknown) => log(`[skill-evolve] 会话起点初始化失败:${String(error)}`));
245
270
  };
246
271
 
@@ -14,6 +14,7 @@ import { createDispatcher } from "./dispatch.ts";
14
14
  import { createApplyTools, type ApplyTools } from "./evolution-apply.ts";
15
15
  import { createModelTools, type ModelTools } from "./evolution-model.ts";
16
16
  import { createObserver, type Observer, type PanelDeps } from "./evolution-observe.ts";
17
+ import { recoverRuns, type RecoveryAction } from "./recover.ts";
17
18
  import { createViews } from "./evolution-views.ts";
18
19
  import { flagStateText, FLAG_LABEL } from "./flags.ts";
19
20
  import type { GitRunner } from "./gate0.ts";
@@ -92,6 +93,13 @@ export interface Evolution {
92
93
  apply: ApplyTools;
93
94
  /** M6:面板刷新(状态变迁转 /tree 条目)与周报 + 指标落盘 */
94
95
  observe: Observer;
96
+ /**
97
+ * 重启 / `/reload` 后的接手:补状态、重挂督导师(详见 lib/recover.ts)。
98
+ * 返回本次做了什么,面板与日志都靠它告诉用户「后台任务没丢」。
99
+ */
100
+ recover(sessionId?: string): Promise<RecoveryAction[]>;
101
+ /** 最近一次恢复的结果(面板可视化用;本进程内有效) */
102
+ lastRecovery(): RecoveryAction[];
95
103
  }
96
104
 
97
105
  export function createEvolution(deps: EvolutionDeps): Evolution {
@@ -270,6 +278,33 @@ export function createEvolution(deps: EvolutionDeps): Evolution {
270
278
  return `${FLAG_LABEL[field]}已${value ? "开启" : "关闭"}:${configFile(evolutionDir)}(当前会话立即生效)\n${flagStateText(config)}`;
271
279
  };
272
280
 
281
+ /**
282
+ * 重启 / 重载后的接手:只补状态与重挂督导师,绝不重跑复盘。
283
+ * 同步抛错也在这里吞:会话起点不能被恢复流程搞挂。
284
+ */
285
+ let lastActions: RecoveryAction[] = [];
286
+ const recover = async (sessionId?: string): Promise<RecoveryAction[]> => {
287
+ try {
288
+ lastActions = await recoverRuns({
289
+ evolutionDir,
290
+ cwd: deps.cwd,
291
+ queue: deps.queue,
292
+ now,
293
+ env,
294
+ autoReview: () => config.autoReview,
295
+ reviewModel: modelTools.reviewModel,
296
+ ...(deps.alive ? { alive: deps.alive } : {}),
297
+ ...(deps.supervisorSpawnFn ? { spawnSupervisor: deps.supervisorSpawnFn } : {}),
298
+ ...(sessionId ? { sessionId } : {}),
299
+ log,
300
+ });
301
+ } catch (error) {
302
+ log(`[skill-evolve] 重启恢复失败:${String(error)}`);
303
+ lastActions = [];
304
+ }
305
+ return lastActions;
306
+ };
307
+
273
308
  return {
274
309
  recordAdvertised,
275
310
  noteToolCall,
@@ -288,5 +323,7 @@ export function createEvolution(deps: EvolutionDeps): Evolution {
288
323
  model: modelTools,
289
324
  apply: applyTools,
290
325
  observe: observer,
326
+ recover,
327
+ lastRecovery: () => lastActions,
291
328
  };
292
329
  }
@@ -27,8 +27,11 @@ export function isPidAlive(pid: number): boolean {
27
27
  }
28
28
  }
29
29
 
30
- export function lockDir(evolutionDir: string): string {
31
- return path.join(evolutionDir, "locks", "dispatch");
30
+ /** 锁名 → 目录名。`dispatch` 是复盘派发的老锁;`supervisor` / `apply` 是 0.1.13 加的跨实例锁 */
31
+ export type LockName = "dispatch" | "supervisor" | "apply";
32
+
33
+ export function lockDir(evolutionDir: string, name: LockName = "dispatch"): string {
34
+ return path.join(evolutionDir, "locks", name);
32
35
  }
33
36
 
34
37
  /**
@@ -71,6 +74,8 @@ export async function readOwner(dir: string): Promise<LockOwner | null> {
71
74
 
72
75
  export interface AcquireOptions {
73
76
  evolutionDir: string;
77
+ /** 哪把锁,缺省 `dispatch`(老行为不变) */
78
+ name?: LockName;
74
79
  owner: LockOwner;
75
80
  maxRuntimeMs: number;
76
81
  log?: (msg: string) => void;
@@ -86,7 +91,7 @@ export interface AcquireResult {
86
91
 
87
92
  export async function acquireLock(options: AcquireOptions): Promise<AcquireResult> {
88
93
  const log = options.log ?? (() => {});
89
- const dir = lockDir(options.evolutionDir);
94
+ const dir = lockDir(options.evolutionDir, options.name);
90
95
  const now = options.now?.() ?? Date.now();
91
96
 
92
97
  for (let attempt = 0; attempt < 2; attempt += 1) {
@@ -119,7 +124,7 @@ export async function acquireLock(options: AcquireOptions): Promise<AcquireResul
119
124
  } catch (error) {
120
125
  // 目录建好了但 owner 写不进去:立刻还回去,避免留下无人认领的锁
121
126
  log(`[skill-evolve] 写 owner.json 失败:${String(error)}`);
122
- await releaseLock(options.evolutionDir, options.log);
127
+ await releaseLock(options.evolutionDir, options.log, options.name);
123
128
  return { ok: false, why: "error" };
124
129
  }
125
130
  }
@@ -127,8 +132,12 @@ export async function acquireLock(options: AcquireOptions): Promise<AcquireResul
127
132
  }
128
133
 
129
134
  /** 释放锁(幂等):目录不在也算成功 */
130
- export async function releaseLock(evolutionDir: string, log: (msg: string) => void = () => {}): Promise<void> {
131
- const dir = lockDir(evolutionDir);
135
+ export async function releaseLock(
136
+ evolutionDir: string,
137
+ log: (msg: string) => void = () => {},
138
+ name: LockName = "dispatch",
139
+ ): Promise<void> {
140
+ const dir = lockDir(evolutionDir, name);
132
141
  try {
133
142
  await fs.promises.rm(dir, { recursive: true, force: true });
134
143
  } catch (error) {
@@ -146,7 +155,7 @@ function writeOwnerFile(dir: string, owner: LockOwner): void {
146
155
  */
147
156
  export function acquireLockSync(options: AcquireOptions): AcquireResult {
148
157
  const log = options.log ?? (() => {});
149
- const dir = lockDir(options.evolutionDir);
158
+ const dir = lockDir(options.evolutionDir, options.name);
150
159
  const now = options.now?.() ?? Date.now();
151
160
 
152
161
  for (let attempt = 0; attempt < 2; attempt += 1) {
@@ -177,7 +186,7 @@ export function acquireLockSync(options: AcquireOptions): AcquireResult {
177
186
  return { ok: true };
178
187
  } catch (error) {
179
188
  log(`[skill-evolve] 写 owner.json 失败:${String(error)}`);
180
- releaseLockSync(options.evolutionDir, options.log);
189
+ releaseLockSync(options.evolutionDir, options.log, options.name);
181
190
  return { ok: false, why: "error" };
182
191
  }
183
192
  }
@@ -189,9 +198,10 @@ export function writeOwnerSync(
189
198
  evolutionDir: string,
190
199
  owner: LockOwner,
191
200
  log: (msg: string) => void = () => {},
201
+ name: LockName = "dispatch",
192
202
  ): boolean {
193
203
  try {
194
- writeOwnerFile(lockDir(evolutionDir), owner);
204
+ writeOwnerFile(lockDir(evolutionDir, name), owner);
195
205
  return true;
196
206
  } catch (error) {
197
207
  log(`[skill-evolve] 改写 owner.json 失败:${String(error)}`);
@@ -200,9 +210,13 @@ export function writeOwnerSync(
200
210
  }
201
211
 
202
212
  /** 同步放锁:只给同步补发路径用(失败也只记日志) */
203
- export function releaseLockSync(evolutionDir: string, log: (msg: string) => void = () => {}): void {
213
+ export function releaseLockSync(
214
+ evolutionDir: string,
215
+ log: (msg: string) => void = () => {},
216
+ name: LockName = "dispatch",
217
+ ): void {
204
218
  try {
205
- fs.rmSync(lockDir(evolutionDir), { recursive: true, force: true });
219
+ fs.rmSync(lockDir(evolutionDir, name), { recursive: true, force: true });
206
220
  } catch (error) {
207
221
  log(`[skill-evolve] 释放锁失败:${String(error)}`);
208
222
  }
@@ -213,3 +227,30 @@ export async function lockState(evolutionDir: string): Promise<{ held: boolean;
213
227
  const held = fs.existsSync(dir);
214
228
  return { held, owner: held ? await readOwner(dir) : null };
215
229
  }
230
+
231
+ export interface WithLockResult<T> {
232
+ ok: boolean;
233
+ value?: T;
234
+ why?: "busy" | "error";
235
+ /** 抢失败时的持有者:调用方可以据此说「另一个 pi 实例正在做」 */
236
+ owner?: LockOwner | null;
237
+ }
238
+
239
+ /**
240
+ * 跨实例互斥执行:抢到锁就执行并保证在 finally 里释放;抢不到立刻返回(不排队、不重试)。
241
+ *
242
+ * 为什么不做「等一会儿再抢」:这些锁护的都是毫秒级动作(spawn 督导、扫一遍队列采纳),
243
+ * 排队只会把两个 pi 会话互相拖慢;抢不到的那一方跳过即可,下一次 session_start 还会再来。
244
+ */
245
+ export async function withLock<T>(
246
+ options: AcquireOptions & { name: LockName },
247
+ run: () => Promise<T> | T,
248
+ ): Promise<WithLockResult<T>> {
249
+ const got = await acquireLock(options);
250
+ if (!got.ok) return { ok: false, why: got.why ?? "busy", owner: got.owner ?? null };
251
+ try {
252
+ return { ok: true, value: await run() };
253
+ } finally {
254
+ await releaseLock(options.evolutionDir, options.log, options.name);
255
+ }
256
+ }
@@ -0,0 +1,206 @@
1
+ /**
2
+ * 五段流水线:派发 → 分析 → 提案 → 评审 → 采纳。
3
+ *
4
+ * 为什么单列一层:用户看「已分析」四个字永远不知道后台卡在哪一步(2026-11-09 真实困惑:
5
+ * 面板写「已分析」,提案却永远不出现,其实督导崩在 import 阶段)。这里把「一步做什么、
6
+ * 现在到哪一步、卡住时下一步谁来接」摊平成五个格子,面板与 `/tree` 都读同一份结论。
7
+ *
8
+ * 纪律:只看磁盘产物与 pid 存活,**不猜进度**。没有的产物就是没有,缺文件写缺哪个。
9
+ */
10
+ import * as fs from "node:fs";
11
+ import * as path from "node:path";
12
+
13
+ /** done=已完成 / running=正在跑 / waiting=等上一步 / blocked=卡住等人或等重启 / failed=失败 / skipped=按配置跳过 */
14
+ export type StageState = "done" | "running" | "waiting" | "blocked" | "failed" | "skipped";
15
+
16
+ export type StageKey = "dispatch" | "analyze" | "propose" | "review" | "apply";
17
+
18
+ export interface PipelineStage {
19
+ key: StageKey;
20
+ label: string;
21
+ state: StageState;
22
+ /** 一句话说清「这一步现在是什么情况」:谁在跑、缺哪个文件、等谁 */
23
+ detail: string;
24
+ /** 该步完成/开始的时刻:没有就 null(不编时间) */
25
+ at: number | null;
26
+ }
27
+
28
+ export interface Pipeline {
29
+ stages: PipelineStage[];
30
+ /** 第一处不是 done 的说明 + 下一步动作;全 done 时给结论 */
31
+ hint: string;
32
+ }
33
+
34
+ export interface PipelineInput {
35
+ runDir: string;
36
+ /** run.json 的 status */
37
+ status: string;
38
+ pid: number | null;
39
+ supervisorPid: number | null;
40
+ dispatchedAt: number | null;
41
+ /** 采纳队列里这条 run 的状态(pending / applied / rejected …);不在队列为 null */
42
+ queued?: string | null;
43
+ /** 自动评审 / 自动采纳开关;null = 面板不知道(CLI 上下文)就不下结论 */
44
+ autoReview?: boolean | null;
45
+ autoApply?: boolean | null;
46
+ alive?: (pid: number) => boolean;
47
+ }
48
+
49
+ const LABELS: Record<StageKey, string> = {
50
+ dispatch: "派发",
51
+ analyze: "分析",
52
+ propose: "提案",
53
+ review: "评审",
54
+ apply: "采纳",
55
+ };
56
+
57
+ /** 终态里不该再往后走的那些 */
58
+ const DEAD_STATUS = new Set(["stale", "abandoned", "failed", "unauthorized_write"]);
59
+
60
+ function statOf(file: string): { at: number } | null {
61
+ try {
62
+ const stat = fs.statSync(file);
63
+ return stat.isFile() ? { at: stat.mtimeMs } : null;
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ function jsonOf(file: string): Record<string, unknown> | null {
70
+ try {
71
+ const raw = JSON.parse(fs.readFileSync(file, "utf8")) as unknown;
72
+ return typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : null;
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ function textOf(value: unknown, fallback = ""): string {
79
+ return typeof value === "string" && value.length > 0 ? value : fallback;
80
+ }
81
+
82
+ /**
83
+ * 五段状态。同一份产物只读一次:这里的判断全在文件系统上,多一次 stat 只是浪费。
84
+ */
85
+ export function buildPipeline(input: PipelineInput): Pipeline {
86
+ const alive = input.alive ?? (() => false);
87
+ const dir = input.runDir;
88
+ const findings = statOf(path.join(dir, "01-findings.json"));
89
+ const proposalFile = statOf(path.join(dir, "02-proposal.json"));
90
+ const reviewFile = statOf(path.join(dir, "03-review.json"));
91
+ const decision = statOf(path.join(dir, "04-decision.json"));
92
+ const verify = statOf(path.join(dir, "06-verify.json"));
93
+ const proposal = proposalFile ? jsonOf(path.join(dir, "02-proposal.json")) : null;
94
+ const review = reviewFile ? jsonOf(path.join(dir, "03-review.json")) : null;
95
+ const dead = DEAD_STATUS.has(input.status);
96
+ const childAlive = input.pid !== null && alive(input.pid);
97
+ const supervisorAlive = input.supervisorPid !== null && alive(input.supervisorPid);
98
+
99
+ const stages: PipelineStage[] = [];
100
+
101
+ // 1. 派发:run.json 存在就算完成(时间取 dispatchedAt,没有就 null)
102
+ stages.push({
103
+ key: "dispatch",
104
+ label: LABELS.dispatch,
105
+ state: "done",
106
+ detail: input.pid === null ? "已派发(pid 未记)" : childAlive ? `复盘子进程 pid ${input.pid} 在跑` : `复盘子进程 pid ${input.pid} 已退出`,
107
+ at: input.dispatchedAt,
108
+ });
109
+
110
+ // 2. 分析:产物是 01-findings.json
111
+ const analyze: PipelineStage = findings
112
+ ? { key: "analyze", label: LABELS.analyze, state: "done", detail: "01-findings.json 已落盘", at: findings.at }
113
+ : dead
114
+ ? { key: "analyze", label: LABELS.analyze, state: "failed", detail: `分析没出结论就结束了(${input.status})`, at: null }
115
+ : childAlive
116
+ ? { key: "analyze", label: LABELS.analyze, state: "running", detail: `子进程 pid ${input.pid} 正在读会话`, at: null }
117
+ : { key: "analyze", label: LABELS.analyze, state: "waiting", detail: "子进程已退出但没写 01-findings.json(等卡死判定收尾)", at: null };
118
+ stages.push(analyze);
119
+
120
+ // 3. 提案:产物是 02-proposal.json(候选 diff 由同一步写入 candidate/)
121
+ const propose: PipelineStage = proposalFile
122
+ ? {
123
+ key: "propose",
124
+ label: LABELS.propose,
125
+ state: "done",
126
+ detail: `${textOf(proposal?.type, "提案")} → ${textOf(proposal?.skill, "未写技能")}`,
127
+ at: proposalFile.at,
128
+ }
129
+ : dead
130
+ ? { key: "propose", label: LABELS.propose, state: "waiting", detail: "分析没走完,没到提案", at: null }
131
+ : childAlive
132
+ ? { key: "propose", label: LABELS.propose, state: "running", detail: "分析在跑,提案随后写", at: null }
133
+ : findings
134
+ ? { key: "propose", label: LABELS.propose, state: "waiting", detail: "分析已结束但没写提案(多半是判定「不该改」)", at: null }
135
+ : { key: "propose", label: LABELS.propose, state: "waiting", detail: "等分析结论", at: null };
136
+ stages.push(propose);
137
+
138
+ // 4. 评审:产物是 03-review.json;有提案但督导不在 = 需要重启恢复接手
139
+ const reviewStatus = review ? textOf(review.status, "未知") : null;
140
+ let reviewStage: PipelineStage;
141
+ if (reviewFile) {
142
+ const failed = reviewStatus === "review_failed" || reviewStatus === "error";
143
+ reviewStage = {
144
+ key: "review",
145
+ label: LABELS.review,
146
+ state: failed ? "failed" : "done",
147
+ detail: `${reviewStatus}${textOf(review?.verdict) ? ` / ${textOf(review?.verdict)}` : ""}`,
148
+ at: reviewFile.at,
149
+ };
150
+ } else if (proposalFile) {
151
+ reviewStage = supervisorAlive
152
+ ? { key: "review", label: LABELS.review, state: "running", detail: `督导 pid ${input.supervisorPid} 正在评审`, at: null }
153
+ : input.autoReview === false
154
+ ? { key: "review", label: LABELS.review, state: "skipped", detail: "自动评审关着:等人工 /skill-evolve approve", at: null }
155
+ : { key: "review", label: LABELS.review, state: "blocked", detail: "提案在但督导不在:下次会话起点自动重挂", at: null };
156
+ } else {
157
+ reviewStage = { key: "review", label: LABELS.review, state: "waiting", detail: "等提案出来再评审", at: null };
158
+ }
159
+ stages.push(reviewStage);
160
+
161
+ // 5. 采纳:需要 04-decision.json(队列 applied / 五闸);rejected 的到此为止
162
+ let applyStage: PipelineStage;
163
+ if (decision) {
164
+ applyStage = { key: "apply", label: LABELS.apply, state: "done", detail: "04-decision.json 已写(技能文件已改)", at: decision.at };
165
+ } else if (reviewStatus === "rejected" || input.status === "rejected") {
166
+ applyStage = { key: "apply", label: LABELS.apply, state: "skipped", detail: "评审驳回,不会采纳", at: null };
167
+ } else if (reviewStatus === "approved" || reviewStatus === "needs_human") {
168
+ applyStage = input.queued === "pending" && input.autoApply !== false
169
+ ? { key: "apply", label: LABELS.apply, state: "running", detail: "队列 pending,自动采纳正在走五闸", at: null }
170
+ : { key: "apply", label: LABELS.apply, state: "blocked", detail: input.autoApply === false ? "自动采纳关着:等面板批准" : "已入队 pending,等采纳扫描", at: null };
171
+ } else if (verify) {
172
+ applyStage = { key: "apply", label: LABELS.apply, state: "blocked", detail: "五闸跑过但没写决定:看 06-verify.json", at: verify.at };
173
+ } else {
174
+ applyStage = { key: "apply", label: LABELS.apply, state: "waiting", detail: "等评审通过", at: null };
175
+ }
176
+ stages.push(applyStage);
177
+
178
+ return { stages, hint: hintOf(stages) };
179
+ }
180
+
181
+ /**
182
+ * 「下一步」的挑选顺序:先看**真在跑 / 真卡住 / 真失败**的那一步(用户能立刻行动),
183
+ * 再退回第一处「等上一步」;全 done 时明说结束。只按顺序找第一处会让「分析等收尾」
184
+ * 盖掉「提案在、督导死了」这种更该被看见的状态。
185
+ */
186
+ function hintOf(stages: PipelineStage[]): string {
187
+ const first =
188
+ stages.find((stage) => stage.state === "running" || stage.state === "blocked" || stage.state === "failed") ??
189
+ stages.find((stage) => stage.state === "waiting");
190
+ if (!first) {
191
+ const skipped = stages.find((stage) => stage.state === "skipped");
192
+ return skipped ? `流程结束:卡在「${skipped.label}」— ${skipped.detail}` : "流程走完:五段全部完成";
193
+ }
194
+ return `下一步「${first.label}」— ${first.detail}`;
195
+ }
196
+
197
+ /** 面板用的一句话:`派发✓ 分析✓ 提案✓ 评审… 采纳·`,一眼看出卡在哪 */
198
+ export function renderPipelineLine(pipeline: Pipeline): string {
199
+ const mark: Record<StageState, string> = { done: "✓", running: "…", waiting: "·", blocked: "!", failed: "✗", skipped: "–" };
200
+ return pipeline.stages.map((stage) => `${stage.label}${mark[stage.state]}`).join(" ");
201
+ }
202
+
203
+ /** 把 stages 折成「进度 3/5」这种数字,列表列用 */
204
+ export function pipelineProgress(pipeline: Pipeline): { done: number; total: number } {
205
+ return { done: pipeline.stages.filter((stage) => stage.state === "done").length, total: pipeline.stages.length };
206
+ }