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.
- package/extensions/skill-evolve/index.ts +2 -2
- package/extensions/skill-evolve/lib/command.ts +7 -5
- package/extensions/skill-evolve/lib/config.ts +45 -14
- package/extensions/skill-evolve/lib/dispatch.ts +26 -33
- package/extensions/skill-evolve/lib/drain-pending.ts +65 -0
- package/extensions/skill-evolve/lib/evolution-apply.ts +30 -1
- package/extensions/skill-evolve/lib/evolution.ts +7 -5
- package/extensions/skill-evolve/lib/flags.ts +32 -18
- package/extensions/skill-evolve/lib/queue.ts +2 -0
- package/extensions/skill-evolve/lib/replay.ts +2 -0
- package/extensions/skill-evolve/lib/report.ts +4 -5
- package/extensions/skill-evolve/lib/review-spawn.ts +11 -0
- package/extensions/skill-evolve/lib/shutdown-dispatch.ts +4 -0
- package/extensions/skill-evolve/lib/web/actions.ts +3 -3
- package/extensions/skill-evolve/lib/web/activity.ts +19 -2
- package/extensions/skill-evolve/lib/web/render-controls.ts +21 -9
- package/extensions/skill-evolve/test/approve.test.ts +63 -0
- package/extensions/skill-evolve/test/command.test.ts +33 -10
- package/extensions/skill-evolve/test/config.test.ts +75 -23
- package/extensions/skill-evolve/test/dispatch-busy.test.ts +3 -0
- package/extensions/skill-evolve/test/dispatch-supervisor.test.ts +28 -3
- package/extensions/skill-evolve/test/evolution.test.ts +57 -14
- package/extensions/skill-evolve/test/reject.test.ts +2 -0
- package/extensions/skill-evolve/test/review-spawn.test.ts +25 -2
- package/extensions/skill-evolve/test/stale.test.ts +1 -0
- package/extensions/skill-evolve/test/web-actions.test.ts +42 -3
- package/extensions/skill-evolve/test/web-data.test.ts +8 -3
- package/extensions/skill-evolve/test/web-render.test.ts +31 -8
- 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) /
|
|
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: "
|
|
28
|
-
{ value: "
|
|
29
|
-
{ value: "
|
|
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
|
|
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
|
-
*
|
|
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`
|
|
37
|
-
* - `telemetry`
|
|
38
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
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
|
-
|
|
173
|
-
|
|
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 {
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
|
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
|
|
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
|
-
?
|
|
261
|
-
:
|
|
262
|
+
? `技能进化已打开(自动复盘 + 自动评审;采集与自动采纳未改):${configFile(evolutionDir)}(当前会话立即生效)`
|
|
263
|
+
: `技能进化已全部关闭(采集 / 复盘 / 评审 / 采纳):${configFile(evolutionDir)}(当前会话立即生效)`;
|
|
262
264
|
};
|
|
263
265
|
|
|
264
|
-
/**
|
|
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
|
|
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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> = {
|
|
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
|
-
/** 命令名 →
|
|
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
|
-
|
|
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
|
-
/**
|
|
33
|
-
export function flagStateText(
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
/** 只开复盘却不采集时唯一的不一致组合:归因靠遥测,缺了就只能退到原始会话,加载率算不出来 */
|
|
@@ -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
|
|
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}
|
|
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
|
-
/**
|
|
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,
|