pi-skill-evolve 0.1.2 → 0.1.4

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 (29) hide show
  1. package/extensions/skill-evolve/index.ts +2 -2
  2. package/extensions/skill-evolve/lib/command.ts +7 -5
  3. package/extensions/skill-evolve/lib/config.ts +45 -14
  4. package/extensions/skill-evolve/lib/dispatch.ts +26 -33
  5. package/extensions/skill-evolve/lib/drain-pending.ts +65 -0
  6. package/extensions/skill-evolve/lib/evolution-apply.ts +30 -1
  7. package/extensions/skill-evolve/lib/evolution.ts +7 -5
  8. package/extensions/skill-evolve/lib/flags.ts +32 -18
  9. package/extensions/skill-evolve/lib/queue.ts +2 -0
  10. package/extensions/skill-evolve/lib/replay.ts +2 -0
  11. package/extensions/skill-evolve/lib/report.ts +4 -5
  12. package/extensions/skill-evolve/lib/review-spawn.ts +11 -0
  13. package/extensions/skill-evolve/lib/shutdown-dispatch.ts +4 -0
  14. package/extensions/skill-evolve/lib/web/actions.ts +3 -3
  15. package/extensions/skill-evolve/lib/web/activity.ts +19 -2
  16. package/extensions/skill-evolve/lib/web/render-controls.ts +21 -9
  17. package/extensions/skill-evolve/test/approve.test.ts +63 -0
  18. package/extensions/skill-evolve/test/command.test.ts +33 -10
  19. package/extensions/skill-evolve/test/config.test.ts +75 -23
  20. package/extensions/skill-evolve/test/dispatch-busy.test.ts +3 -0
  21. package/extensions/skill-evolve/test/dispatch-supervisor.test.ts +28 -3
  22. package/extensions/skill-evolve/test/evolution.test.ts +57 -14
  23. package/extensions/skill-evolve/test/reject.test.ts +2 -0
  24. package/extensions/skill-evolve/test/review-spawn.test.ts +25 -2
  25. package/extensions/skill-evolve/test/stale.test.ts +1 -0
  26. package/extensions/skill-evolve/test/web-actions.test.ts +42 -3
  27. package/extensions/skill-evolve/test/web-data.test.ts +8 -3
  28. package/extensions/skill-evolve/test/web-render.test.ts +31 -8
  29. package/package.json +1 -1
@@ -99,7 +99,7 @@ export default function skillEvolve(pi: ExtensionAPI): void {
99
99
  ...(port === undefined ? {} : { port }),
100
100
  onApprove: (runId) => evo.apply.approve(runId, false),
101
101
  onReject: (runId) => evo.apply.reject(runId),
102
- onToggle: (field, next) => evo.setFlag(field, next), // 两个开关各自一个入口
102
+ onToggle: (field, next) => evo.setFlag(field, next), // 四个开关各自一个入口,网页点哪个只切哪个
103
103
  // 会话文件为空是允许的:派发时按「没有索引」处理,不会因为从网页点就少走一道
104
104
  onRunNow: () => evo.runNow({ sessionId: "web-panel" }),
105
105
  onCleanup: async (runId) => moveRunToTrash(evolutionDirOf(cwd), runId),
@@ -246,7 +246,7 @@ export default function skillEvolve(pi: ExtensionAPI): void {
246
246
 
247
247
  pi.registerCommand("skill-evolve", {
248
248
  description:
249
- "技能进化:status 状态 / report 近 7 天报告(--week 周报) / run 立即复盘 / baseline 接受存量 P0 / audit 机械审计 / approve 采纳某条 run / queue 采纳队列 / web 网页面板(--port N 换端口,--once 不写配置) / selftest 评审必红自检 / config 选模型 / collect 只开采集遥测(不花 token) / review 只开自动复盘(花 token) / on / off 两个一起开关",
249
+ "技能进化:status 状态 / report 近 7 天报告(--week 周报) / run 立即复盘 / baseline 接受存量 P0 / audit 机械审计 / approve 采纳某条 run / queue 采纳队列 / web 网页面板(--port N 换端口,--once 不写配置) / selftest 评审必红自检 / config 选模型 / collect 只开采集遥测(不花 token) / dispatch 只开自动复盘(花 token) / review 只开自动评审(提案后再起督导,再花一份 token) / apply 只开自动采纳(auto 提案自动落盘) / on 复盘+评审两开(不动采集与采纳) / off 四个全关",
250
250
  getArgumentCompletions: (prefix) => matchSubcommand(prefix),
251
251
  handler: async (args, ctx) => {
252
252
  try {
@@ -24,9 +24,11 @@ export const SUBCOMMANDS: readonly SubcommandItem[] = [
24
24
  { value: "web", label: "web", description: "启动本机 Web 面板(127.0.0.1:7788,--port N 换端口,--once 不写进配置)" },
25
25
  { value: "config", label: "config", description: "选提案/评审模型(inherit = 跟随主会话)" },
26
26
  { value: "collect", label: "collect", description: "只开/关遥测采集(on|off;只写本地日志,不花 token)" },
27
- { value: "review", label: "review", description: "只开/关自动复盘(on|off;会派生子进程,花 token)" },
28
- { value: "on", label: "on", description: "采集与派生一起打开" },
29
- { value: "off", label: "off", description: "采集与派生一起关闭" },
27
+ { value: "dispatch", label: "dispatch", description: "只开/关自动复盘(on|off;会派生子进程,花 token)" },
28
+ { value: "review", label: "review", description: "只开/关自动评审(on|off;提案后再起督导,再花一份 token)" },
29
+ { value: "apply", label: "apply", description: "只开/关自动采纳(on|off;auto 提案自动落盘,可回滚)" },
30
+ { value: "on", label: "on", description: "只打开自动复盘(采集 / 评审 / 采纳都不动)" },
31
+ { value: "off", label: "off", description: "四个开关(采集/复盘/评审/采纳)一起关闭" },
30
32
  ];
31
33
 
32
34
  /** 补全:前缀命中就只给命中项;一个都没命中时给全量,避免用户看到空列表 */
@@ -78,8 +80,8 @@ export async function runCommand(args: string, evo: Evolution, target: CommandTa
78
80
  // 非 TUI 兜底:真正能交互选模型的路径在 index.ts(那里才拿得到 ctx.ui.select)
79
81
  if (command === "config") return runConfigCommand(null, evo.model, arg);
80
82
  if (command === "on" || command === "off") return evo.setEnabled(command === "on");
81
- // `collect on|off` 只动 telemetry、`review on|off` 只动 enabled:缺参数或写错就给用法,一概不改配置
82
- if (command === "collect" || command === "review") {
83
+ // `collect / dispatch / review / apply on|off` 各自只动一个字段:缺参数或写错就给用法,一概不改配置
84
+ if (command === "collect" || command === "dispatch" || command === "review" || command === "apply") {
83
85
  const field: ConfigFlag = COMMAND_FLAG[command];
84
86
  const value = arg === "on" ? true : arg === "off" ? false : null;
85
87
  if (value === null) {
@@ -1,7 +1,15 @@
1
1
  /**
2
2
  * 技能进化配置:唯一来源是 `.pi/evolution/config.json`。
3
3
  *
4
- * **默认全关**(`enabled:false` + `telemetry:false`):别人 clone 下来什么都不写、不派生、不留痕迹;
4
+ * **四个开关默认全关**(采集 `telemetry` / 复盘 `enabled` / 评审 `autoReview` / 采纳 `autoApply` 都是 false,
5
+ * 2026-11-08 用户定「autoReview 默认也关」):别人 clone 下来不采集、不派生、不评审、不落盘,零 token 开销。
6
+ * 要用就一条一条显式打开(`collect` / `dispatch` / `review` / `apply`),谁开谁负责。
7
+ * 每一档花不花 token:
8
+ * - 采集遥测:只写本地 jsonl,**不花 token**;
9
+ * - 自动复盘:起子进程跑 agent,**花 token**;
10
+ * - 自动评审:复盘出了提案再起一个督导子进程,**再花一份 token**;
11
+ * - 自动采纳:改 `.pi/skills/` 与 `.cursor/` 文件,**不花 token,但会动仓库文件**(可回滚)。
12
+ *
5
13
  * 只有显式 `/skill-evolve on` 才写这个文件(`config.json` 也是本机偏好,进 `.gitignore`,断然不能入库
6
14
  * —— 否则一个人开了,提交一推,整个团队都被打开)。
7
15
  */
@@ -32,15 +40,17 @@ export interface WebConfig {
32
40
  }
33
41
 
34
42
  /**
35
- * 两个独立开关的字段名:
36
- * - `enabled` = 派生复盘(会起子进程跑 agent,**花 token**)
37
- * - `telemetry` = 采集遥测(每轮一条曝光/加载/派生记录,只写本地 jsonl,**不花 token**)
38
- * 字段级分开之后,命令(collect / review)与 Web 控制条都只切其中一个。
43
+ * 四个独立开关的字段名(用户 2026-11-08:「自动复盘后,还有自动评审、自动采纳的流程,开关也独立出来」):
44
+ * - `enabled` = 派生复盘(起子进程跑 agent,**花 token**)
45
+ * - `telemetry` = 采集遥测(只写本地 jsonl,**不花 token**)
46
+ * - `autoReview` = 提案后的自动评审(再起一个督导子进程,**再花一份 token**)
47
+ * - `autoApply` = auto 级提案自动落盘(改 `.pi/skills/` 与 `.cursor/`,**可回滚**)
48
+ * 字段级分开之后,命令(collect / dispatch / review / apply)与 Web 控制条都只切其中一个。
39
49
  */
40
- export type ConfigFlag = "enabled" | "telemetry";
50
+ export type ConfigFlag = "enabled" | "telemetry" | "autoReview" | "autoApply";
41
51
 
42
52
  export interface EvolutionConfig {
43
- /** 派生总开关:false 时一律不派生(`/skill-evolve on|off` 会与 telemetry 一起切;`review` 只切它) */
53
+ /** 派生总开关:false 时一律不派生(`/skill-evolve on` 与自动评审一起开、`off` 全关;`dispatch` 只切它) */
44
54
  enabled: boolean;
45
55
  /** 去抖窗口(毫秒):窗口内再次 settled 只记 superseded,不叠加派生 */
46
56
  idleMs: number;
@@ -50,6 +60,10 @@ export interface EvolutionConfig {
50
60
  perSessionCap: number;
51
61
  /** L0 遥测采集开关(默认关:采集会在 `.pi/evolution/telemetry/` 落盘;`collect` 只切它) */
52
62
  telemetry: boolean;
63
+ /** 自动评审开关(默认开:复盘出了提案就起评审督导;关掉后 run 停在 proposed 等人工批准) */
64
+ autoReview: boolean;
65
+ /** 自动采纳开关(默认关:只有 description / `.cursor` stub / AGENTS.md 索引行这三类 auto 提案会落盘,且须人工先开) */
66
+ autoApply: boolean;
53
67
  /** 界面推送开关:默认全关(想回 TUI 面板就显式打开) */
54
68
  ui: UiConfig;
55
69
  /** Web 面板:默认不起(谁也没要过就不该占端口),端口按约定值 7788 */
@@ -62,6 +76,11 @@ export const DEFAULT_CONFIG: Readonly<EvolutionConfig> = Object.freeze({
62
76
  maxRuntimeMs: 20 * 60_000,
63
77
  perSessionCap: 20,
64
78
  telemetry: false,
79
+ // 自动评审默认关(2026-11-08 用户定):评审本身也要起子进程再花一份 token,
80
+ // 不能让「打开复盘」顺手把 token 翻倍。要评审就显式 `/skill-evolve review on`。
81
+ autoReview: false,
82
+ // 自动采纳默认关:它改的是仓库文件,必须人工先开一次;默认开等于替用户做决定。
83
+ autoApply: false,
65
84
  ui: Object.freeze({ widget: false, entries: false }),
66
85
  // Web 面板是「管理界面」,默认随 pi 启动;它与采集/派生(enabled / telemetry)是两回事,
67
86
  // 后者默认关闭——面板起来不等于开始花 token。2026-11-08 用户定。
@@ -104,6 +123,8 @@ export function normalizeConfig(raw: unknown, log: (msg: string) => void = () =>
104
123
  maxRuntimeMs: positiveInt(input.maxRuntimeMs, DEFAULT_CONFIG.maxRuntimeMs),
105
124
  perSessionCap: positiveInt(input.perSessionCap, DEFAULT_CONFIG.perSessionCap),
106
125
  telemetry: bool(input.telemetry, DEFAULT_CONFIG.telemetry),
126
+ autoReview: bool(input.autoReview, DEFAULT_CONFIG.autoReview),
127
+ autoApply: bool(input.autoApply, DEFAULT_CONFIG.autoApply),
107
128
  ui: {
108
129
  widget: bool(ui.widget, DEFAULT_CONFIG.ui.widget),
109
130
  entries: bool(ui.entries, DEFAULT_CONFIG.ui.entries),
@@ -139,8 +160,8 @@ export function loadConfig(evolutionDir: string, log: (msg: string) => void = ()
139
160
  }
140
161
 
141
162
  /**
142
- * 只写一个开关(`/skill-evolve collect|review`、Web 控制条的单个开关):
143
- * 目标字段按传入值写,其余键(另一个开关、去抖 / 上限、ui / web 块)原样保留。
163
+ * 只写一个开关(`/skill-evolve collect|dispatch|review|apply`、Web 控制条的单个开关):
164
+ * 目标字段按传入值写,其余键(其它三个开关、去抖 / 上限、ui / web 块)原样保留。
144
165
  * 返回归一化后的新配置,调用方用 Object.assign 原地更新正在用的那份。
145
166
  */
146
167
  export async function saveFlag(
@@ -160,17 +181,27 @@ export async function saveFlag(
160
181
  }
161
182
 
162
183
  /**
163
- * 一键全开/全关(`/skill-evolve on|off`):两次单字段写入,语义仍是「采集与派生一起切」。
164
- * 保留这个入口是因为「先全关再走」比连点两个开关更不容易漏 —— 但它不再是唯一的开关路径。
165
- * 两次写在同一队列里顺序执行,最终状态一定是两个字段都等于 `on`。
184
+ * 一键切换(`/skill-evolve on|off`):
185
+ * - `on` = 复盘 + 评审两开(**不动采集,也不动自动采纳**):这两项才是「花 token 的那条流水线」;
186
+ * - `off` = 四个全关(连采集与自动采纳一起停),因为「先全关再走」比连点四个开关更不容易漏。
187
+ * 一次读 + 一次写,四个键落在同一个事务里,不会出现「写了一半」的中间态。
166
188
  */
167
189
  export async function saveConfigSwitch(
168
190
  evolutionDir: string,
169
191
  on: boolean,
170
192
  queue: MutationQueue,
171
193
  ): Promise<EvolutionConfig> {
172
- await saveFlag(evolutionDir, "enabled", on, queue);
173
- return saveFlag(evolutionDir, "telemetry", on, queue);
194
+ const raw = readRawConfig(evolutionDir);
195
+ const patch = on
196
+ ? { enabled: true }
197
+ : { enabled: false, telemetry: false, autoReview: false, autoApply: false };
198
+ const next = normalizeConfig({ ...raw, ...patch });
199
+ const file = configFile(evolutionDir);
200
+ await queue(file, async () => {
201
+ await fs.promises.mkdir(evolutionDir, { recursive: true });
202
+ await fs.promises.writeFile(file, `${JSON.stringify({ ...raw, ...next }, null, 2)}\n`, "utf8");
203
+ });
204
+ return next;
174
205
  }
175
206
 
176
207
  /**
@@ -21,7 +21,8 @@ import { mergePendingRounds } from "./pending.ts";
21
21
  import { watchRunChild } from "./run-watch.ts";
22
22
  import { spawnReviewSupervisor, type SupervisorSpawn } from "./review-spawn.ts";
23
23
  import { guardBusy } from "./single-flight.ts";
24
- import { flushPendingSync, type PendingRounds, type ShutdownResult } from "./shutdown-dispatch.ts";
24
+ import { drainPending } from "./drain-pending.ts";
25
+ import type { PendingRounds } from "./shutdown-dispatch.ts";
25
26
  import type { MutationQueue, TelemetryWriter } from "./telemetry.ts";
26
27
 
27
28
  export interface DispatchInput {
@@ -241,6 +242,7 @@ export function createDispatcher(deps: DispatchDeps): Dispatcher {
241
242
  dispatchCount.set(input.sessionId, (dispatchCount.get(input.sessionId) ?? 0) + 1);
242
243
  watchRunChild(child, { runDir, queue: deps.queue, log, onExit: releaseOnce });
243
244
  // 同一次派发再起一个独立督导:等 02-proposal.json → 另起评审进程 → 写 03-review.*
245
+ // 开关在这里现读(用户可能中途 review on/off);关着时由 spawnReviewSupervisor 自己拒绝并记日志
244
246
  spawnReviewSupervisor({
245
247
  runDir,
246
248
  cwd: deps.cwd,
@@ -248,6 +250,7 @@ export function createDispatcher(deps: DispatchDeps): Dispatcher {
248
250
  reviewModel: deps.reviewModel?.() ?? "inherit",
249
251
  spawnFn: deps.supervisorSpawnFn,
250
252
  sessionId: input.sessionId, telemetry: deps.telemetry, ...(deps.alive ? { alive: deps.alive } : {}),
253
+ autoReview: deps.config.autoReview,
251
254
  log,
252
255
  });
253
256
  return { status: "dispatched", run: { runId, runDir, pid: record.pid } };
@@ -261,39 +264,29 @@ export function createDispatcher(deps: DispatchDeps): Dispatcher {
261
264
  const flushPending = (): number => {
262
265
  if (pending.size === 0) return 0;
263
266
  const at = now();
264
- let dispatched = 0;
265
- for (const item of [...pending.values()]) {
266
- let result: ShutdownResult = "failed";
267
- try {
268
- result = flushPendingSync(
269
- item,
270
- {
271
- evolutionDir: deps.evolutionDir,
272
- cwd: deps.cwd,
273
- promptTemplatePath: deps.promptTemplatePath,
274
- maxRuntimeMs: deps.config.maxRuntimeMs,
275
- telemetry: deps.telemetry,
276
- env,
277
- spawnFn: deps.spawnFn ?? (spawn as unknown as SpawnLike),
278
- ...(deps.reviewModel ? { reviewModel: deps.reviewModel() } : {}),
279
- ...(deps.supervisorSpawnFn ? { supervisorSpawnFn: deps.supervisorSpawnFn } : {}), ...(deps.alive ? { alive: deps.alive } : {}),
280
- log,
281
- },
282
- at,
283
- );
284
- } catch (error) {
285
- // flushPendingSync 自己已 try/catch;这里再兜一层,退出路径不允许抛
286
- log(`[skill-evolve] 退出补发失败:${String(error)}`);
287
- }
288
- if (result === "dispatched") {
289
- dispatched += 1;
290
- lastDispatchAt.set(item.sessionId, at);
291
- dispatchCount.set(item.sessionId, (dispatchCount.get(item.sessionId) ?? 0) + 1);
292
- }
293
- // 退出是最后一次机会:无论成败都从这里清掉,避免 shutdown 被调用两次时重复派发
294
- pending.delete(item.sessionId);
267
+ const sessions = drainPending(
268
+ pending,
269
+ {
270
+ evolutionDir: deps.evolutionDir,
271
+ cwd: deps.cwd,
272
+ promptTemplatePath: deps.promptTemplatePath,
273
+ maxRuntimeMs: deps.config.maxRuntimeMs,
274
+ autoReview: deps.config.autoReview,
275
+ telemetry: deps.telemetry,
276
+ env,
277
+ spawnFn: deps.spawnFn ?? (spawn as unknown as SpawnLike),
278
+ ...(deps.reviewModel ? { reviewModel: deps.reviewModel } : {}),
279
+ ...(deps.supervisorSpawnFn ? { supervisorSpawnFn: deps.supervisorSpawnFn } : {}),
280
+ ...(deps.alive ? { alive: deps.alive } : {}),
281
+ log,
282
+ },
283
+ at,
284
+ );
285
+ for (const sessionId of sessions) {
286
+ lastDispatchAt.set(sessionId, at);
287
+ dispatchCount.set(sessionId, (dispatchCount.get(sessionId) ?? 0) + 1);
295
288
  }
296
- return dispatched;
289
+ return sessions.length;
297
290
  };
298
291
 
299
292
  return { dispatch: run, flushPending };
@@ -0,0 +1,65 @@
1
+ /**
2
+ * 把「待复盘区间」一次性补发干净(dispatch 的 flushPending 只留状态更新)。
3
+ *
4
+ * 为什么单独一个文件:`dispatch.ts` 已经贴着 300 行闸门,而这段是**独立职责** ——
5
+ * 它只管遍历 pending 映射、逐条走同步补发、无论成败都把条目摘掉;
6
+ * 派发成功后的「上次派发时刻 / 本会话已派次数」属于 dispatch 自己的状态,用返回值交回去。
7
+ *
8
+ * 任何异常都不许冒出来:这条路径的调用方是退出钩子,抛出去等于把退出流程也弄坏。
9
+ */
10
+ import { flushPendingSync, type PendingRounds, type ShutdownResult } from "./shutdown-dispatch.ts";
11
+ import type { SupervisorSpawn } from "./review-spawn.ts";
12
+ import type { SpawnLike } from "./spawn-plan.ts";
13
+ import type { TelemetryWriter } from "./telemetry.ts";
14
+
15
+ export interface DrainPendingOptions {
16
+ evolutionDir: string;
17
+ cwd: string;
18
+ promptTemplatePath: string;
19
+ maxRuntimeMs: number;
20
+ /** 自动评审开关(配置现读):关着时补发照跑,但不起督导 */
21
+ autoReview: boolean;
22
+ telemetry: TelemetryWriter;
23
+ env: NodeJS.ProcessEnv;
24
+ spawnFn: SpawnLike;
25
+ /** 每个会话现算一次的评审模型("inherit" 或 provider/model) */
26
+ reviewModel?: () => string;
27
+ supervisorSpawnFn?: SupervisorSpawn;
28
+ alive?: (pid: number) => boolean;
29
+ log: (msg: string) => void;
30
+ }
31
+
32
+ /** 返回这次真的派发成功的 sessionId(按 pending 的遍历顺序) */
33
+ export function drainPending(pending: Map<string, PendingRounds>, options: DrainPendingOptions, at: number): string[] {
34
+ const dispatched: string[] = [];
35
+ for (const item of [...pending.values()]) {
36
+ let result: ShutdownResult = "failed";
37
+ try {
38
+ result = flushPendingSync(
39
+ item,
40
+ {
41
+ evolutionDir: options.evolutionDir,
42
+ cwd: options.cwd,
43
+ promptTemplatePath: options.promptTemplatePath,
44
+ maxRuntimeMs: options.maxRuntimeMs,
45
+ telemetry: options.telemetry,
46
+ env: options.env,
47
+ spawnFn: options.spawnFn,
48
+ autoReview: options.autoReview,
49
+ ...(options.reviewModel ? { reviewModel: options.reviewModel() } : {}),
50
+ ...(options.supervisorSpawnFn ? { supervisorSpawnFn: options.supervisorSpawnFn } : {}),
51
+ ...(options.alive ? { alive: options.alive } : {}),
52
+ log: options.log,
53
+ },
54
+ at,
55
+ );
56
+ } catch (error) {
57
+ // flushPendingSync 自己已 try/catch;这里再兜一层,退出路径不允许抛
58
+ options.log(`[skill-evolve] 退出补发失败:${String(error)}`);
59
+ }
60
+ if (result === "dispatched") dispatched.push(item.sessionId);
61
+ // 退出是最后一次机会:无论成败都从这里清掉,避免 shutdown 被调用两次时重复派发
62
+ pending.delete(item.sessionId);
63
+ }
64
+ return dispatched;
65
+ }
@@ -17,11 +17,18 @@ import { enqueue, findItem, markDone, readQueue, takePending, type QueueItem, ty
17
17
  import { logLine } from "./logfile.ts";
18
18
  import { updateRunStatus } from "./runs.ts";
19
19
  import type { MutationQueue } from "./telemetry.ts";
20
+ import type { EvolutionConfig } from "./config.ts";
20
21
 
21
22
  export interface ApplyToolsDeps {
22
23
  evolutionDir: string;
23
24
  cwd: string;
24
25
  queue: MutationQueue;
26
+ /**
27
+ * 开关读取(每次调用现读,**不许拿会话起点的缓存**):
28
+ * `autoReview` 决定 proposed 的 run 要不要入队,`autoApply` 决定 auto 条要不要落盘 ——
29
+ * 用户中途 `/skill-evolve apply on`,下一轮扫描就该生效。
30
+ */
31
+ flags: () => Pick<EvolutionConfig, "autoReview" | "autoApply">;
25
32
  now?: () => number;
26
33
  log?: (msg: string) => void;
27
34
  git: GitRunner;
@@ -46,6 +53,8 @@ export interface ApplyTools {
46
53
 
47
54
  /** 只有评审通过(reviewed)的 run 才进队列;rejected / review_failed 一律不排 */
48
55
  const READY_STATUS = "reviewed";
56
+ /** 评审关着时 run 停在这里:提案写完了、没人看过,仍然入队等人工批准 */
57
+ const PROPOSED_STATUS = "proposed";
49
58
  const MAX_SCAN = 50;
50
59
 
51
60
  function readText(file: string): string | null {
@@ -101,6 +110,8 @@ export function createApplyTools(deps: ApplyToolsDeps): ApplyTools {
101
110
  };
102
111
 
103
112
  const enqueueReady: ApplyTools["enqueueReady"] = async () => {
113
+ // 开关现读(不缓存):中途 review off 就新提案进队列标 reviewSkipped,review on 就恢复只收 reviewed
114
+ const autoReview = deps.flags().autoReview;
104
115
  const runsDir = path.join(deps.evolutionDir, "runs");
105
116
  let names: string[];
106
117
  try {
@@ -114,7 +125,10 @@ export function createApplyTools(deps: ApplyToolsDeps): ApplyTools {
114
125
  if (known.has(name)) continue;
115
126
  const runDir = path.join(runsDir, name);
116
127
  const raw = readRunJson(runDir);
117
- if (!raw || raw.status !== READY_STATUS) continue;
128
+ if (!raw) continue;
129
+ // 评审关着时 run 停在 proposed:照旧入队(「待审批」页要看得见),只是标 reviewSkipped
130
+ const reviewSkipped = !autoReview && raw.status === PROPOSED_STATUS;
131
+ if (raw.status !== READY_STATUS && !reviewSkipped) continue;
118
132
  const meta = readProposalMeta(runDir);
119
133
  if (!meta) continue;
120
134
  const diffText = readText(path.join(runDir, meta.diffFile)) ?? "";
@@ -129,8 +143,10 @@ export function createApplyTools(deps: ApplyToolsDeps): ApplyTools {
129
143
  skill: meta.skill,
130
144
  targets: targetsOf(runDir, meta.skill, meta.type, graded.targets),
131
145
  proposalType: meta.type,
146
+ ...(reviewSkipped ? { reviewSkipped: true } : {}),
132
147
  });
133
148
  if (result.supersededBy) log(`[skill-evolve] ${name} 被更早的 pending 顶掉(${result.supersededBy})`);
149
+ else if (reviewSkipped) log(`[skill-evolve] ${name} 入队(评审已关,待人工批准):${graded.level}(${graded.reasons[0] ?? ""})`);
134
150
  else log(`[skill-evolve] ${name} 入队:${graded.level}(${graded.reasons[0] ?? ""})`);
135
151
  added += 1;
136
152
  }
@@ -180,7 +196,20 @@ export function createApplyTools(deps: ApplyToolsDeps): ApplyTools {
180
196
  return outcome;
181
197
  };
182
198
 
199
+ // 「自动采纳已关」只提示一次:settle 每轮都会调到,同一条不变就别反复刷日志
200
+ let applyOffNotice = "";
201
+
183
202
  const autoApplyPending: ApplyTools["autoApplyPending"] = async () => {
203
+ // 开关现读:`/skill-evolve apply on` 之后下一轮扫描就落盘,不用重开会话
204
+ if (!deps.flags().autoApply) {
205
+ const waiting = takePending(readQueue(deps.evolutionDir), "auto").length;
206
+ const notice = `[skill-evolve] 自动采纳已关:${waiting} 条 auto 提案在队列里等人工批准`;
207
+ if (waiting > 0 && notice !== applyOffNotice) {
208
+ log(notice);
209
+ applyOffNotice = notice;
210
+ }
211
+ return 0;
212
+ }
184
213
  let applied = 0;
185
214
  for (const item of takePending(readQueue(deps.evolutionDir), "auto")) {
186
215
  try {
@@ -84,7 +84,7 @@ export interface Evolution {
84
84
  /** `/skill-evolve baseline`:把当前命中全部接受为存量;reset 清空清单 */
85
85
  acceptBaseline(mode: "accept" | "reset"): Promise<string>;
86
86
  setEnabled(enabled: boolean): Promise<string>;
87
- /** `/skill-evolve collect|review` 与 Web 控制条:只切一个开关(另一个保持原值) */
87
+ /** `/skill-evolve collect|dispatch|review|apply` 与 Web 控制条:只切一个开关(其余三个保持原值) */
88
88
  setFlag(field: ConfigFlag, value: boolean): Promise<string>;
89
89
  /** 模型配置 + 必红自检(`/skill-evolve config|selftest`) */
90
90
  model: ModelTools;
@@ -144,6 +144,8 @@ export function createEvolution(deps: EvolutionDeps): Evolution {
144
144
  evolutionDir,
145
145
  cwd: deps.cwd,
146
146
  queue: deps.queue,
147
+ // 传 getter:review / apply 中途开关后,下一轮扫描立刻按新值走
148
+ flags: () => ({ autoReview: config.autoReview, autoApply: config.autoApply }),
147
149
  log,
148
150
  now,
149
151
  git: deps.git ?? createGitRunner(),
@@ -257,14 +259,14 @@ export function createEvolution(deps: EvolutionDeps): Evolution {
257
259
  const setEnabled = async (on: boolean): Promise<string> => {
258
260
  Object.assign(config, await saveConfigSwitch(evolutionDir, on, deps.queue));
259
261
  return on
260
- ? `技能进化已打开(采集 + 自动复盘):${configFile(evolutionDir)}(当前会话立即生效)`
261
- : `技能进化已全部关闭(采集也停):${configFile(evolutionDir)}(当前会话立即生效)`;
262
+ ? `技能进化已打开(自动复盘 + 自动评审;采集与自动采纳未改):${configFile(evolutionDir)}(当前会话立即生效)`
263
+ : `技能进化已全部关闭(采集 / 复盘 / 评审 / 采纳):${configFile(evolutionDir)}(当前会话立即生效)`;
262
264
  };
263
265
 
264
- /** 只切一个开关:另一个保持原值(`collect` = telemetry、`review` = enabled) */
266
+ /** 只切一个开关:其余三个保持原值(`collect` = telemetry、`dispatch` = enabled、`review` = autoReview、`apply` = autoApply) */
265
267
  const setFlag = async (field: ConfigFlag, value: boolean): Promise<string> => {
266
268
  Object.assign(config, await saveFlag(evolutionDir, field, value, deps.queue));
267
- return `${FLAG_LABEL[field]}已${value ? "开启" : "关闭"}:${configFile(evolutionDir)}(当前会话立即生效)\n${flagStateText(config.enabled, config.telemetry)}`;
269
+ return `${FLAG_LABEL[field]}已${value ? "开启" : "关闭"}:${configFile(evolutionDir)}(当前会话立即生效)\n${flagStateText(config)}`;
268
270
  };
269
271
 
270
272
  return {
@@ -1,39 +1,53 @@
1
1
  /**
2
- * 「采集遥测」与「自动复盘」两个开关的口径:字段名、标签、后果说明、状态文案只此一份。
2
+ * 四个开关(采集遥测 / 自动复盘 / 自动评审 / 自动采纳)的口径:字段名、标签、后果说明、状态文案只此一份。
3
3
  *
4
- * 两个字段在配置里本来就是独立的(`telemetry` 只写本地 jsonl,不花 token;`enabled` 派生子进程复盘,花 token),
5
- * 命令文案、Web 控制条、HTTP 的 `field` 校验都从这里取 —— 三处各写一份字符串,
6
- * 迟早出现「命令里叫遥测、网页上叫采集、日志里又叫 telemetry」这种对不上的情况。
4
+ * 四个字段在配置里本来就是独立的(`telemetry` 只写本地 jsonl;`enabled` 派生子进程复盘;
5
+ * `autoReview` 再起一个督导子进程;`autoApply` 改仓库文件),命令文案、Web 控制条、
6
+ * HTTP 的 `field` 校验都从这里取 —— 三处各写一份字符串,
7
+ * 迟早出现「命令里叫评审、网页上叫审核、日志里又叫 autoReview」这种对不上的情况。
7
8
  */
8
- import type { ConfigFlag } from "./config.ts";
9
+ import type { ConfigFlag, EvolutionConfig } from "./config.ts";
9
10
 
10
- /** 字段顺序即界面顺序:先采集(不花钱的),再复盘(花钱的) */
11
- export const FLAG_FIELDS: readonly ConfigFlag[] = ["telemetry", "enabled"];
11
+ /** 字段顺序即界面顺序:先不花钱的(采集),再花钱的(复盘 → 评审),最后动文件的(采纳) */
12
+ export const FLAG_FIELDS: readonly ConfigFlag[] = ["telemetry", "enabled", "autoReview", "autoApply"];
12
13
 
13
- export const FLAG_LABEL: Record<ConfigFlag, string> = { telemetry: "采集遥测", enabled: "自动复盘" };
14
+ export const FLAG_LABEL: Record<ConfigFlag, string> = {
15
+ telemetry: "采集遥测",
16
+ enabled: "自动复盘",
17
+ autoReview: "自动评审",
18
+ autoApply: "自动采纳",
19
+ };
14
20
 
15
- /** 后果说明:两个开关长得一样,不写清各自花什么,人只会以为都是「开关插件」 */
21
+ /** 后果说明:四个开关长得一样,不写清各自花什么,人只会以为都是「开关插件」 */
16
22
  export const FLAG_EFFECT: Record<ConfigFlag, string> = {
17
23
  telemetry: "只写本地日志,不花 token",
18
24
  enabled: "会派生子进程,花 token",
25
+ autoReview: "提案后再起一个督导子进程,再花一份 token",
26
+ autoApply: "改 .pi/skills/ 与 .cursor/ 文件,可回滚",
19
27
  };
20
28
 
21
- /** 命令名 → 字段:`collect` 管采集,`review` 管复盘(命令名与字段名故意不同,别在别处再映射一遍) */
22
- export const COMMAND_FLAG: Record<"collect" | "review", ConfigFlag> = {
29
+ /** 命令名 → 字段(命令名与字段名故意不同,别在别处再映射一遍):`dispatch` 是复盘、`review` 是评审 */
30
+ export const COMMAND_FLAG: Record<"collect" | "dispatch" | "review" | "apply", ConfigFlag> = {
23
31
  collect: "telemetry",
24
- review: "enabled",
32
+ dispatch: "enabled",
33
+ review: "autoReview",
34
+ apply: "autoApply",
25
35
  };
26
36
 
27
37
  /** 参数 → 字段:不认的值一律 null(调用方回用法提示或 400,不猜一个默认值) */
28
38
  export function parseFlag(value: string | null | undefined): ConfigFlag | null {
29
- return value === "telemetry" || value === "enabled" ? value : null;
39
+ return value === "telemetry" || value === "enabled" || value === "autoReview" || value === "autoApply" ? value : null;
30
40
  }
31
41
 
32
- /** 一行两段的状态文案:`status` 与命令回执共用,免得同一句话在两处写法不同 */
33
- export function flagStateText(enabled: boolean, telemetry: boolean): string {
34
- const collect = telemetry ? "采集:开(遥测写入 .pi/evolution/telemetry/)" : "采集:关(不写遥测)";
35
- const review = enabled ? "复盘:开(会派生 agent)" : "复盘:关(不会派生 agent)";
36
- return `${collect}|${review}`;
42
+ /** `status` 与命令回执共用的四段状态文案:四个字段各自一句话,合成一行 */
43
+ export function flagStateText(
44
+ config: Pick<EvolutionConfig, "telemetry" | "enabled" | "autoReview" | "autoApply">,
45
+ ): string {
46
+ const collect = config.telemetry ? "采集:开(遥测写入 .pi/evolution/telemetry/)" : "采集:关(不写遥测)";
47
+ const dispatch = config.enabled ? "复盘:开(会派生 agent)" : "复盘:关(不会派生 agent)";
48
+ const review = config.autoReview ? "评审:开(提案后自动起督导)" : "评审:关(提案停在 proposed 等人工)";
49
+ const apply = config.autoApply ? "采纳:开(auto 提案自动落盘)" : "采纳:关(auto 提案等人工批准)";
50
+ return `${collect}|${dispatch}|${review}|${apply}`;
37
51
  }
38
52
 
39
53
  /** 只开复盘却不采集时唯一的不一致组合:归因靠遥测,缺了就只能退到原始会话,加载率算不出来 */
@@ -25,6 +25,8 @@ export interface QueueItem {
25
25
  status: QueueStatus;
26
26
  proposalType?: string;
27
27
  detail?: string;
28
+ /** 自动评审关着时入队的条目:run 没有 03-review.json,等人工 `/skill-evolve approve`(待审批页可见) */
29
+ reviewSkipped?: boolean;
28
30
  }
29
31
 
30
32
  export const QUEUE_FILE = "queue.json";
@@ -203,6 +203,8 @@ export async function runReplay(options: ReplayOptions): Promise<ReplayOutcome>
203
203
  evolutionDir,
204
204
  cwd: options.cwd,
205
205
  queue,
206
+ // 复演不做开关判定:它本来就是人手动触发的全链路
207
+ flags: () => ({ autoReview: true, autoApply: true }),
206
208
  now,
207
209
  log,
208
210
  git,
@@ -90,12 +90,11 @@ export interface StatusInput {
90
90
  }
91
91
 
92
92
  /**
93
- * 开关行:采集与复盘分两段写。两个字段本来就是独立的,合成一句「已开」只会让人以为只有一个总开关;
94
- * 第四种组合(只开复盘不采集)再补一句警告 —— 归因靠遥测,缺了就不能静默。
93
+ * 开关行:采集 / 复盘 / 评审 / 采纳四段各自写。四个字段本来就是独立的,合成一句「已开」只会让人
94
+ * 以为只有一个总开关;「只开复盘不采集」这个组合再补一句警告 —— 归因靠遥测,缺了就不能静默。
95
95
  */
96
96
  export function switchLine(config: EvolutionConfig): string {
97
- const hint = config.enabled && config.telemetry ? "/skill-evolve off 全关" : "/skill-evolve on 全开";
98
- const line = `${flagStateText(config.enabled, config.telemetry)}(${hint};collect / review 单独控制)`;
97
+ const line = `${flagStateText(config)}(collect / dispatch / review / apply 单独控制;on / off 批量切)`;
99
98
  return telemetryOffWhileEnabled(config.enabled, config.telemetry) ? `${line} ⚠ ${TELEMETRY_OFF_WARNING}` : line;
100
99
  }
101
100
 
@@ -111,7 +110,7 @@ export function renderStatus(input: StatusInput): string {
111
110
  return [
112
111
  "技能进化 · 状态",
113
112
  ` ${switchLine(config)}`,
114
- ` 配置:${input.configPath}(不存在即默认全关)· 遥测目录:${input.telemetryDir}`,
113
+ ` 配置:${input.configPath}(不存在即默认只开自动评审,采集 / 复盘 / 采纳关)· 遥测目录:${input.telemetryDir}`,
115
114
  ` 去抖 ${Math.round(config.idleMs / 1000)}s · 单 run 上限 ${Math.round(config.maxRuntimeMs / 60_000)}min · 单会话派生上限 ${config.perSessionCap}`,
116
115
  ` 锁:${lockText}`,
117
116
  ...webPanelLine(input.webPanel),
@@ -30,6 +30,13 @@ export type SupervisorSpawn = (command: string, args: string[], options: Record<
30
30
 
31
31
  export interface SpawnSupervisorOptions {
32
32
  runDir: string;
33
+ /**
34
+ * 自动评审开关(`/skill-evolve review on|off`):关着就不起督导,只留一句日志。
35
+ *
36
+ * 门禁放在这里而不是两个调用点各写一遍 —— dispatch 与退出补发是同一条语义,
37
+ * 分开写迟早有一边忘改。调用方必须在**真正下手前**现读配置(用户可能中途切换)。
38
+ */
39
+ autoReview: boolean;
33
40
  cwd: string;
34
41
  env?: NodeJS.ProcessEnv;
35
42
  /** "inherit" / 空 → 不传参,督导按默认模型评审 */
@@ -48,6 +55,10 @@ export interface SpawnSupervisorOptions {
48
55
  /** 返回 pid;被单飞闸挡住或起不来返回 null(只记日志,绝不抛到主会话) */
49
56
  export function spawnReviewSupervisor(options: SpawnSupervisorOptions): number | null {
50
57
  const runId = path.basename(options.runDir);
58
+ if (!options.autoReview) {
59
+ options.log?.(`[skill-evolve] 自动评审已关,run ${runId} 停在 proposed 待人工 /skill-evolve approve`);
60
+ return null;
61
+ }
51
62
  const occupant = guardBusy({
52
63
  evolutionDir: path.dirname(path.dirname(options.runDir)),
53
64
  sessionId: options.sessionId ?? runId,
@@ -38,6 +38,8 @@ export interface ShutdownDeps {
38
38
  telemetry: TelemetryWriter;
39
39
  env: NodeJS.ProcessEnv;
40
40
  spawnFn: SpawnLike;
41
+ /** 自动评审开关(配置现读):false 时不超督导,run 落成 proposed 等人工批准 */
42
+ autoReview: boolean;
41
43
  /** 评审模型("inherit" 或 provider/model),退出补发这条路径同样要起督导 */
42
44
  reviewModel?: string;
43
45
  supervisorSpawnFn?: SupervisorSpawn;
@@ -147,6 +149,7 @@ export function flushPendingSync(item: PendingRounds, deps: ShutdownDeps, now: n
147
149
  rounds: item.rounds,
148
150
  });
149
151
  deps.log(`[skill-evolve] 退出前补发复盘:runId ${runId} pid ${childPid ?? "未知"}(合并 ${item.rounds} 轮)`);
152
+ // 退出前现读一次开关(与 dispatch 同一口径):关着由 spawnReviewSupervisor 自己拒绝并记日志
150
153
  spawnReviewSupervisor({
151
154
  runDir,
152
155
  cwd: deps.cwd,
@@ -156,6 +159,7 @@ export function flushPendingSync(item: PendingRounds, deps: ShutdownDeps, now: n
156
159
  sessionId: item.sessionId,
157
160
  telemetry: deps.telemetry,
158
161
  sync: true,
162
+ autoReview: deps.autoReview,
159
163
  ...(deps.alive ? { alive: deps.alive } : {}),
160
164
  log: deps.log,
161
165
  });
@@ -29,7 +29,7 @@ export type ActionName = (typeof ACTION_NAMES)[number];
29
29
 
30
30
  /** 一个写动作:返回给人看的结论(首行进 303 的 msg / err) */
31
31
  export type WebAction = (runId: string) => Promise<string>;
32
- /** 单个开关:`field` = telemetry(采集,不花 token)/ enabled(复盘,花 token);`next` 由这里取反后传入 */
32
+ /** 单个开关:字段名 → /skill-evolve collect / dispatch / review / apply 各切一个;`next` 由这里取反后传入 */
33
33
  export type ToggleHook = (field: ConfigFlag, next: boolean) => Promise<string>;
34
34
  /** 立即复盘 / 重跑:都是「触发一次新的复盘」,由 index.ts 接 evo.runNow */
35
35
  export type RunHook = () => Promise<string>;
@@ -194,8 +194,8 @@ async function handleNamedAction(
194
194
  }
195
195
 
196
196
  /**
197
- * `POST /toggle?field=telemetry|enabled`:只翻一个字段。五道校验与 /action 共用,
198
- * 查询串只决定「翻哪一个」,不影响任何一道校验。
197
+ * `POST /toggle?field=telemetry|enabled|autoReview|autoApply`:只翻一个字段。五道校验与 /action 共用,
198
+ * 查询串只决定「翻哪一个」,不影响任何一道校验;四个开关互不联动(要批量切只有命令 on|off)。
199
199
  */
200
200
  async function handleToggleField(
201
201
  ctx: WriteContext,