pi-skill-evolve 0.0.0-stage → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README.md +81 -2
- package/extensions/skill-evolve/fixtures/selftest/delete-ban/after.md +10 -0
- package/extensions/skill-evolve/fixtures/selftest/delete-ban/change.diff +8 -0
- package/extensions/skill-evolve/fixtures/selftest/delete-ban/fixture.json +6 -0
- package/extensions/skill-evolve/fixtures/selftest/rg-to-find/after.md +9 -0
- package/extensions/skill-evolve/fixtures/selftest/rg-to-find/change.diff +8 -0
- package/extensions/skill-evolve/fixtures/selftest/rg-to-find/fixture.json +6 -0
- package/extensions/skill-evolve/fixtures/selftest/weak-description/after.md +8 -0
- package/extensions/skill-evolve/fixtures/selftest/weak-description/change.diff +8 -0
- package/extensions/skill-evolve/fixtures/selftest/weak-description/fixture.json +6 -0
- package/extensions/skill-evolve/index.ts +300 -0
- package/extensions/skill-evolve/lib/apply-gates.ts +204 -0
- package/extensions/skill-evolve/lib/apply-targets.ts +90 -0
- package/extensions/skill-evolve/lib/apply.ts +141 -0
- package/extensions/skill-evolve/lib/attribution.ts +138 -0
- package/extensions/skill-evolve/lib/audit.ts +221 -0
- package/extensions/skill-evolve/lib/baseline.ts +176 -0
- package/extensions/skill-evolve/lib/child-env.ts +10 -0
- package/extensions/skill-evolve/lib/classify-action.ts +161 -0
- package/extensions/skill-evolve/lib/command.ts +100 -0
- package/extensions/skill-evolve/lib/config-command.ts +38 -0
- package/extensions/skill-evolve/lib/config.ts +194 -0
- package/extensions/skill-evolve/lib/detect.ts +62 -0
- package/extensions/skill-evolve/lib/diff-apply.ts +90 -0
- package/extensions/skill-evolve/lib/diff-gen.ts +78 -0
- package/extensions/skill-evolve/lib/dispatch.ts +300 -0
- package/extensions/skill-evolve/lib/evolution-apply.ts +265 -0
- package/extensions/skill-evolve/lib/evolution-model.ts +72 -0
- package/extensions/skill-evolve/lib/evolution-observe.ts +82 -0
- package/extensions/skill-evolve/lib/evolution-views.ts +89 -0
- package/extensions/skill-evolve/lib/evolution.ts +289 -0
- package/extensions/skill-evolve/lib/flags.ts +44 -0
- package/extensions/skill-evolve/lib/gate0.ts +78 -0
- package/extensions/skill-evolve/lib/hard-rules.ts +167 -0
- package/extensions/skill-evolve/lib/lock.ts +215 -0
- package/extensions/skill-evolve/lib/logfile.ts +52 -0
- package/extensions/skill-evolve/lib/mechanical-report.ts +95 -0
- package/extensions/skill-evolve/lib/mechanical.ts +136 -0
- package/extensions/skill-evolve/lib/metrics.ts +172 -0
- package/extensions/skill-evolve/lib/model-config.ts +95 -0
- package/extensions/skill-evolve/lib/notify.ts +132 -0
- package/extensions/skill-evolve/lib/panel.ts +174 -0
- package/extensions/skill-evolve/lib/pending.ts +34 -0
- package/extensions/skill-evolve/lib/proc.ts +65 -0
- package/extensions/skill-evolve/lib/prompt.ts +80 -0
- package/extensions/skill-evolve/lib/proposal.ts +227 -0
- package/extensions/skill-evolve/lib/prune-plan.ts +62 -0
- package/extensions/skill-evolve/lib/queue.ts +136 -0
- package/extensions/skill-evolve/lib/replay-fixture.ts +80 -0
- package/extensions/skill-evolve/lib/replay.ts +246 -0
- package/extensions/skill-evolve/lib/report.ts +181 -0
- package/extensions/skill-evolve/lib/review-post.ts +101 -0
- package/extensions/skill-evolve/lib/review-prompt.ts +82 -0
- package/extensions/skill-evolve/lib/review-record.ts +63 -0
- package/extensions/skill-evolve/lib/review-run.ts +131 -0
- package/extensions/skill-evolve/lib/review-spawn.ts +83 -0
- package/extensions/skill-evolve/lib/review-supervisor.ts +267 -0
- package/extensions/skill-evolve/lib/round.ts +116 -0
- package/extensions/skill-evolve/lib/run-watch.ts +34 -0
- package/extensions/skill-evolve/lib/runs.ts +208 -0
- package/extensions/skill-evolve/lib/selftest.ts +201 -0
- package/extensions/skill-evolve/lib/session-index.ts +145 -0
- package/extensions/skill-evolve/lib/shutdown-dispatch.ts +177 -0
- package/extensions/skill-evolve/lib/single-flight.ts +111 -0
- package/extensions/skill-evolve/lib/spawn-plan.ts +103 -0
- package/extensions/skill-evolve/lib/stale.ts +217 -0
- package/extensions/skill-evolve/lib/telemetry.ts +205 -0
- package/extensions/skill-evolve/lib/ui-gate.ts +34 -0
- package/extensions/skill-evolve/lib/web/actions.ts +299 -0
- package/extensions/skill-evolve/lib/web/activity.ts +57 -0
- package/extensions/skill-evolve/lib/web/applied.ts +64 -0
- package/extensions/skill-evolve/lib/web/approvals.ts +58 -0
- package/extensions/skill-evolve/lib/web/audit-log.ts +115 -0
- package/extensions/skill-evolve/lib/web/autostart.ts +177 -0
- package/extensions/skill-evolve/lib/web/data.ts +298 -0
- package/extensions/skill-evolve/lib/web/diff-lines.ts +141 -0
- package/extensions/skill-evolve/lib/web/filter.ts +203 -0
- package/extensions/skill-evolve/lib/web/http.ts +68 -0
- package/extensions/skill-evolve/lib/web/json-read.ts +33 -0
- package/extensions/skill-evolve/lib/web/panel-pid.ts +99 -0
- package/extensions/skill-evolve/lib/web/render-controls.ts +156 -0
- package/extensions/skill-evolve/lib/web/render-rows.ts +252 -0
- package/extensions/skill-evolve/lib/web/render-run.ts +188 -0
- package/extensions/skill-evolve/lib/web/render-skills.ts +164 -0
- package/extensions/skill-evolve/lib/web/render.ts +136 -0
- package/extensions/skill-evolve/lib/web/reports.ts +160 -0
- package/extensions/skill-evolve/lib/web/run-detail.ts +234 -0
- package/extensions/skill-evolve/lib/web/run-path.ts +21 -0
- package/extensions/skill-evolve/lib/web/server.ts +268 -0
- package/extensions/skill-evolve/lib/web/shell.ts +87 -0
- package/extensions/skill-evolve/lib/web/skills.ts +202 -0
- package/extensions/skill-evolve/lib/web/style.ts +107 -0
- package/extensions/skill-evolve/lib/web/text.ts +52 -0
- package/extensions/skill-evolve/lib/web/trash.ts +36 -0
- package/extensions/skill-evolve/lib/weekly.ts +285 -0
- package/extensions/skill-evolve/prompts//345/244/215/347/233/230.md +172 -0
- package/extensions/skill-evolve/scripts/replay.ts +93 -0
- package/extensions/skill-evolve/test/apply.test.ts +278 -0
- package/extensions/skill-evolve/test/approve.test.ts +248 -0
- package/extensions/skill-evolve/test/attribution.test.ts +133 -0
- package/extensions/skill-evolve/test/audit.test.ts +151 -0
- package/extensions/skill-evolve/test/baseline.test.ts +132 -0
- package/extensions/skill-evolve/test/classify-action.test.ts +133 -0
- package/extensions/skill-evolve/test/command.test.ts +267 -0
- package/extensions/skill-evolve/test/config-command.test.ts +73 -0
- package/extensions/skill-evolve/test/config.test.ts +154 -0
- package/extensions/skill-evolve/test/detect.test.ts +71 -0
- package/extensions/skill-evolve/test/diff-apply.test.ts +46 -0
- package/extensions/skill-evolve/test/dispatch-busy.test.ts +223 -0
- package/extensions/skill-evolve/test/dispatch-supervisor.test.ts +88 -0
- package/extensions/skill-evolve/test/dispatch.test.ts +306 -0
- package/extensions/skill-evolve/test/evolution.test.ts +378 -0
- package/extensions/skill-evolve/test/fake-review-spawn.ts +60 -0
- package/extensions/skill-evolve/test/hard-rules.test.ts +123 -0
- package/extensions/skill-evolve/test/lock.test.ts +138 -0
- package/extensions/skill-evolve/test/logfile.test.ts +85 -0
- package/extensions/skill-evolve/test/mechanical-wiring.test.ts +217 -0
- package/extensions/skill-evolve/test/metrics.test.ts +129 -0
- package/extensions/skill-evolve/test/model-config.test.ts +84 -0
- package/extensions/skill-evolve/test/notify.test.ts +133 -0
- package/extensions/skill-evolve/test/observe.test.ts +100 -0
- package/extensions/skill-evolve/test/panel-pid.test.ts +84 -0
- package/extensions/skill-evolve/test/panel.test.ts +115 -0
- package/extensions/skill-evolve/test/proposal.test.ts +159 -0
- package/extensions/skill-evolve/test/queue.test.ts +110 -0
- package/extensions/skill-evolve/test/reject.test.ts +134 -0
- package/extensions/skill-evolve/test/replay.test.ts +111 -0
- package/extensions/skill-evolve/test/report.test.ts +66 -0
- package/extensions/skill-evolve/test/review-post.test.ts +92 -0
- package/extensions/skill-evolve/test/review-prompt.test.ts +105 -0
- package/extensions/skill-evolve/test/review-spawn.test.ts +81 -0
- package/extensions/skill-evolve/test/review-supervisor.test.ts +243 -0
- package/extensions/skill-evolve/test/selftest.test.ts +113 -0
- package/extensions/skill-evolve/test/session-index.test.ts +128 -0
- package/extensions/skill-evolve/test/single-flight.test.ts +122 -0
- package/extensions/skill-evolve/test/stale.test.ts +272 -0
- package/extensions/skill-evolve/test/telemetry.test.ts +145 -0
- package/extensions/skill-evolve/test/ui-gate.test.ts +77 -0
- package/extensions/skill-evolve/test/web-actions.test.ts +262 -0
- package/extensions/skill-evolve/test/web-applied.test.ts +100 -0
- package/extensions/skill-evolve/test/web-audit.test.ts +87 -0
- package/extensions/skill-evolve/test/web-autostart.test.ts +268 -0
- package/extensions/skill-evolve/test/web-data.test.ts +420 -0
- package/extensions/skill-evolve/test/web-diff-lines.test.ts +155 -0
- package/extensions/skill-evolve/test/web-filter.test.ts +212 -0
- package/extensions/skill-evolve/test/web-panel-pid.test.ts +97 -0
- package/extensions/skill-evolve/test/web-render-run.test.ts +132 -0
- package/extensions/skill-evolve/test/web-render-skills.test.ts +134 -0
- package/extensions/skill-evolve/test/web-render.test.ts +300 -0
- package/extensions/skill-evolve/test/web-run-detail.test.ts +256 -0
- package/extensions/skill-evolve/test/web-server-views.test.ts +113 -0
- package/extensions/skill-evolve/test/web-server.test.ts +465 -0
- package/extensions/skill-evolve/test/web-shell.test.ts +67 -0
- package/extensions/skill-evolve/test/web-skills.test.ts +239 -0
- package/extensions/skill-evolve/test/weekly.test.ts +123 -0
- package/package.json +34 -4
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 派生复盘子进程:抢锁 → 写 run.json 与会话索引 → detached spawn → unref。
|
|
3
|
+
*
|
|
4
|
+
* 主会话侧的铁律(设计稿 4.3):`dispatch()` 背后的长活儿全在 Promise 里,所有错误在内部吞掉,
|
|
5
|
+
* 绝不惊动用户正在做的事。
|
|
6
|
+
*
|
|
7
|
+
* 去抖语义(设计稿 4.4):`idleMs` 窗口内又结束一轮,不叠加新进程,而是把这一轮**记进 pending**;
|
|
8
|
+
* 窗口过去后的下一次结算,把 pending 合并成一次派发,子进程读同一个会话文件,自然覆盖整段。
|
|
9
|
+
* 若整个会话再也没等到那个「下一次」,`flushPending()` 在退出前补发最后一次。
|
|
10
|
+
*/
|
|
11
|
+
import { spawn } from "node:child_process";
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
import { mergeAttributions, type Attribution } from "./attribution.ts";
|
|
14
|
+
import type { EvolutionConfig } from "./config.ts";
|
|
15
|
+
import { acquireLock, releaseLock, writeOwnerSync } from "./lock.ts";
|
|
16
|
+
import { buildPrompt, readPromptTemplateSync } from "./prompt.ts";
|
|
17
|
+
import { writeRunRecord, type RunRecord } from "./runs.ts";
|
|
18
|
+
import { writeSessionIndex } from "./session-index.ts";
|
|
19
|
+
import { CHILD_LOG, resolvePiBin, spawnDetached, uniqueRunId, sessionHashOf, type SpawnLike } from "./spawn-plan.ts";
|
|
20
|
+
import { mergePendingRounds } from "./pending.ts";
|
|
21
|
+
import { watchRunChild } from "./run-watch.ts";
|
|
22
|
+
import { spawnReviewSupervisor, type SupervisorSpawn } from "./review-spawn.ts";
|
|
23
|
+
import { guardBusy } from "./single-flight.ts";
|
|
24
|
+
import { flushPendingSync, type PendingRounds, type ShutdownResult } from "./shutdown-dispatch.ts";
|
|
25
|
+
import type { MutationQueue, TelemetryWriter } from "./telemetry.ts";
|
|
26
|
+
|
|
27
|
+
export interface DispatchInput {
|
|
28
|
+
sessionId: string;
|
|
29
|
+
skills: string[];
|
|
30
|
+
sessionFile?: string;
|
|
31
|
+
/** 已经机械归因过的技能(A~E):写进 prompt,复盘 agent 不必重判 */
|
|
32
|
+
attributions?: Attribution[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface DispatchDeps {
|
|
36
|
+
evolutionDir: string;
|
|
37
|
+
cwd: string;
|
|
38
|
+
config: EvolutionConfig;
|
|
39
|
+
telemetry: TelemetryWriter;
|
|
40
|
+
queue: MutationQueue;
|
|
41
|
+
promptTemplatePath: string;
|
|
42
|
+
log?: (msg: string) => void;
|
|
43
|
+
now?: () => number;
|
|
44
|
+
env?: NodeJS.ProcessEnv;
|
|
45
|
+
spawnFn?: SpawnLike;
|
|
46
|
+
/** 评审模型("inherit" 或 provider/model):每次派生现取,配置改动立即生效 */
|
|
47
|
+
reviewModel?: () => string;
|
|
48
|
+
/** 必红自检没过 → 整条流水线停用(设计稿 12.4) */
|
|
49
|
+
selftestFailed?: () => boolean;
|
|
50
|
+
/** 评审督导的 spawn(默认 node spawn;单测注入假实现) */
|
|
51
|
+
supervisorSpawnFn?: SupervisorSpawn;
|
|
52
|
+
/** pid 存活判定(全局单飞闸用;单测注入,测试里不起真子进程) */
|
|
53
|
+
alive?: (pid: number) => boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface DispatchedRun {
|
|
57
|
+
runId: string;
|
|
58
|
+
runDir: string;
|
|
59
|
+
pid: number | null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 派发结果:调用方(命令 / 事件)要据此出文案,所以不能再只用 null 表示「没派」 */
|
|
63
|
+
export type DispatchOutcome =
|
|
64
|
+
| { status: "dispatched"; run: DispatchedRun }
|
|
65
|
+
| { status: "skipped"; why: "disabled" | "cap" | "lock" | "lock-error" | "selftest-failed" | "busy"; ownerPid: number | null }
|
|
66
|
+
| { status: "superseded"; rounds: number };
|
|
67
|
+
|
|
68
|
+
export interface DispatchOptions {
|
|
69
|
+
/** 手动 `/skill-evolve run`:跳过去抖与单会话上限,但**仍必须守全局锁** */
|
|
70
|
+
bypassThrottle?: boolean;
|
|
71
|
+
trigger?: RunRecord["trigger"];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 去抖:窗口内又结束一轮,只更新 pending,不再叠加进程 */
|
|
75
|
+
export function shouldDebounce(lastDispatchAt: number | undefined, now: number, idleMs: number): boolean {
|
|
76
|
+
return typeof lastDispatchAt === "number" && now - lastDispatchAt < idleMs;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface Dispatcher {
|
|
80
|
+
dispatch(input: DispatchInput, options?: DispatchOptions): Promise<DispatchOutcome>;
|
|
81
|
+
/** 退出前同步补发所有 pending,返回真正派发成功的条数;任何失败都不抛 */
|
|
82
|
+
flushPending(): number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function createDispatcher(deps: DispatchDeps): Dispatcher {
|
|
86
|
+
const log = deps.log ?? (() => {});
|
|
87
|
+
const env = deps.env ?? process.env;
|
|
88
|
+
const now = (): number => deps.now?.() ?? Date.now();
|
|
89
|
+
const lastDispatchAt = new Map<string, number>();
|
|
90
|
+
const dispatchCount = new Map<string, number>();
|
|
91
|
+
/** 被去抖挡下的轮次,等窗口过去后在下一个结算点合并派发 */
|
|
92
|
+
const pending = new Map<string, PendingRounds>();
|
|
93
|
+
/** 一次进程生命周期内最多持有一把锁;释放后置回 false */
|
|
94
|
+
let lockHeld = false;
|
|
95
|
+
|
|
96
|
+
const releaseOnce = async (): Promise<void> => {
|
|
97
|
+
if (!lockHeld) return;
|
|
98
|
+
lockHeld = false;
|
|
99
|
+
await releaseLock(deps.evolutionDir, log);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const mergePending = (input: DispatchInput, at: number): PendingRounds => {
|
|
103
|
+
const merged = mergePendingRounds(pending.get(input.sessionId), input, at);
|
|
104
|
+
pending.set(input.sessionId, merged);
|
|
105
|
+
return merged;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const run = async (input: DispatchInput, options: DispatchOptions = {}): Promise<DispatchOutcome> => {
|
|
109
|
+
const at = now();
|
|
110
|
+
const bypass = options.bypassThrottle === true;
|
|
111
|
+
const trigger: RunRecord["trigger"] = options.trigger ?? (bypass ? "manual" : "auto");
|
|
112
|
+
|
|
113
|
+
if (!deps.config.enabled) {
|
|
114
|
+
deps.telemetry.append({ kind: "skipped", sessionId: input.sessionId, why: "disabled", trigger });
|
|
115
|
+
return { status: "skipped", why: "disabled", ownerPid: null };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// 必红自检没过就别再产出提案:评审器不可信时,继续派发只是在制造假结论
|
|
119
|
+
if (deps.selftestFailed?.() === true) {
|
|
120
|
+
deps.telemetry.append({ kind: "skipped", sessionId: input.sessionId, why: "selftest-failed", trigger });
|
|
121
|
+
log("[skill-evolve] 跳过派生:必红自检未通过(评审器不可信,流水线停用)");
|
|
122
|
+
return { status: "skipped", why: "selftest-failed", ownerPid: null };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (!bypass) {
|
|
126
|
+
const last = lastDispatchAt.get(input.sessionId);
|
|
127
|
+
if (shouldDebounce(last, at, deps.config.idleMs)) {
|
|
128
|
+
const carried = mergePending(input, at);
|
|
129
|
+
deps.telemetry.append({
|
|
130
|
+
kind: "superseded",
|
|
131
|
+
sessionId: input.sessionId,
|
|
132
|
+
lastDispatchAt: last ?? 0,
|
|
133
|
+
idleMs: deps.config.idleMs,
|
|
134
|
+
rounds: carried.rounds,
|
|
135
|
+
});
|
|
136
|
+
log(`[skill-evolve] 去抖:并入待复盘区间(已累计 ${carried.rounds} 轮,静默窗口过后补一次)`);
|
|
137
|
+
return { status: "superseded", rounds: carried.rounds };
|
|
138
|
+
}
|
|
139
|
+
const used = dispatchCount.get(input.sessionId) ?? 0;
|
|
140
|
+
if (used >= deps.config.perSessionCap) {
|
|
141
|
+
deps.telemetry.append({ kind: "skipped", sessionId: input.sessionId, why: "cap", cap: deps.config.perSessionCap });
|
|
142
|
+
return { status: "skipped", why: "cap", ownerPid: null };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// 全局单飞(跨会话也成立):已有复盘或督导在跑这一轮就不叠进程;放去抖 / 上限之后,那两条是「本轮不派」的既定语义
|
|
147
|
+
const occupant = guardBusy({ evolutionDir: deps.evolutionDir, sessionId: input.sessionId, telemetry: deps.telemetry, trigger, alive: deps.alive, log });
|
|
148
|
+
if (occupant) return { status: "skipped", why: "busy", ownerPid: occupant.pid };
|
|
149
|
+
|
|
150
|
+
const carried = pending.get(input.sessionId);
|
|
151
|
+
// 合并派发:本轮 + 之前被去抖挡下的所有轮次,覆盖起点取第一次被挡下的时刻
|
|
152
|
+
const skills = carried
|
|
153
|
+
? [...carried.skills, ...input.skills.filter((name) => !carried.skills.includes(name))]
|
|
154
|
+
: [...input.skills];
|
|
155
|
+
const attributions = mergeAttributions(carried?.attributions, input.attributions);
|
|
156
|
+
const coverage = carried ? { rounds: carried.rounds + 1, since: carried.since } : undefined;
|
|
157
|
+
|
|
158
|
+
const runId = uniqueRunId(deps.evolutionDir, input.sessionId, at);
|
|
159
|
+
const runDir = path.join(deps.evolutionDir, "runs", runId);
|
|
160
|
+
const lock = await acquireLock({
|
|
161
|
+
evolutionDir: deps.evolutionDir,
|
|
162
|
+
owner: { pid: process.pid, ts: at, sessionId: input.sessionId },
|
|
163
|
+
maxRuntimeMs: deps.config.maxRuntimeMs,
|
|
164
|
+
log,
|
|
165
|
+
now: () => at,
|
|
166
|
+
});
|
|
167
|
+
if (!lock.ok) {
|
|
168
|
+
const why = lock.why === "error" ? "lock-error" : "lock";
|
|
169
|
+
deps.telemetry.append({
|
|
170
|
+
kind: "skipped",
|
|
171
|
+
sessionId: input.sessionId,
|
|
172
|
+
why,
|
|
173
|
+
ownerPid: lock.owner?.pid ?? null,
|
|
174
|
+
trigger,
|
|
175
|
+
});
|
|
176
|
+
log(`[skill-evolve] 跳过派生:锁不可用(owner pid ${lock.owner?.pid ?? "未知"})`);
|
|
177
|
+
return { status: "skipped", why, ownerPid: lock.owner?.pid ?? null };
|
|
178
|
+
}
|
|
179
|
+
lockHeld = true;
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
const indexFile = path.join(runDir, "00-session", "index.json");
|
|
183
|
+
const sessionFile = input.sessionFile ?? carried?.sessionFile;
|
|
184
|
+
let evidenceMode: RunRecord["evidenceMode"] = "telemetry-only";
|
|
185
|
+
if (sessionFile) {
|
|
186
|
+
const index = await writeSessionIndex({ sessionFile, outDir: path.dirname(indexFile), queue: deps.queue, log });
|
|
187
|
+
if (index) evidenceMode = "session+telemetry";
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const record: RunRecord = {
|
|
191
|
+
runId,
|
|
192
|
+
status: "dispatched",
|
|
193
|
+
trigger,
|
|
194
|
+
sessionId: input.sessionId,
|
|
195
|
+
sessionHash: sessionHashOf(input.sessionId),
|
|
196
|
+
sessionFile: sessionFile ?? null,
|
|
197
|
+
evidenceMode,
|
|
198
|
+
dispatchedAt: at,
|
|
199
|
+
dispatchedAtIso: new Date(at).toISOString(),
|
|
200
|
+
pid: null,
|
|
201
|
+
cwd: deps.cwd,
|
|
202
|
+
skills,
|
|
203
|
+
coverage,
|
|
204
|
+
stages: [{ name: "dispatch", model: null, usage: null, ms: 0 }],
|
|
205
|
+
artifacts: evidenceMode === "session+telemetry" ? { "00-session/index.json": indexFile } : {},
|
|
206
|
+
};
|
|
207
|
+
await writeRunRecord(runDir, record, deps.queue);
|
|
208
|
+
|
|
209
|
+
const prompt = buildPrompt({
|
|
210
|
+
template: readPromptTemplateSync(deps.promptTemplatePath, log),
|
|
211
|
+
sessionFile,
|
|
212
|
+
// telemetry-only 时 00-session/index.json 根本没生成,别把不存在的路径写进 prompt
|
|
213
|
+
indexFile: evidenceMode === "session+telemetry" ? indexFile : undefined,
|
|
214
|
+
runDir,
|
|
215
|
+
sessionId: input.sessionId,
|
|
216
|
+
skills,
|
|
217
|
+
attributions,
|
|
218
|
+
dispatchedAt: at,
|
|
219
|
+
coverage,
|
|
220
|
+
});
|
|
221
|
+
const piBin = resolvePiBin(env);
|
|
222
|
+
const spawnFn: SpawnLike = deps.spawnFn ?? (spawn as unknown as SpawnLike);
|
|
223
|
+
const child = spawnDetached(spawnFn, piBin, ["-p", "--no-session", "-ns", prompt], {
|
|
224
|
+
cwd: deps.cwd,
|
|
225
|
+
env: { ...env, SKILL_EVOLVE_CHILD: "1" },
|
|
226
|
+
// stdout+stderr 落到 run 目录:子进程怎么死的,只有这份日志答得出来
|
|
227
|
+
logFile: path.join(runDir, CHILD_LOG),
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
record.pid = child.pid ?? null;
|
|
231
|
+
await writeRunRecord(runDir, record, deps.queue);
|
|
232
|
+
// 锁的 owner 换成子进程:主进程若先死,owner.pid 指向死进程会让下一个派发把还在跑的 run 当孤儿回收
|
|
233
|
+
writeOwnerSync(deps.evolutionDir, { pid: child.pid ?? process.pid, ts: at, sessionId: input.sessionId }, log);
|
|
234
|
+
deps.telemetry.append({ kind: "dispatched", sessionId: input.sessionId, runId, pid: record.pid, trigger, rounds: coverage?.rounds ?? 1 });
|
|
235
|
+
log(
|
|
236
|
+
`[skill-evolve] 已派生复盘:runId ${runId} pid ${record.pid ?? "未知"}(${piBin},覆盖 ${coverage?.rounds ?? 1} 轮)`,
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
pending.delete(input.sessionId);
|
|
240
|
+
lastDispatchAt.set(input.sessionId, at);
|
|
241
|
+
dispatchCount.set(input.sessionId, (dispatchCount.get(input.sessionId) ?? 0) + 1);
|
|
242
|
+
watchRunChild(child, { runDir, queue: deps.queue, log, onExit: releaseOnce });
|
|
243
|
+
// 同一次派发再起一个独立督导:等 02-proposal.json → 另起评审进程 → 写 03-review.*
|
|
244
|
+
spawnReviewSupervisor({
|
|
245
|
+
runDir,
|
|
246
|
+
cwd: deps.cwd,
|
|
247
|
+
env,
|
|
248
|
+
reviewModel: deps.reviewModel?.() ?? "inherit",
|
|
249
|
+
spawnFn: deps.supervisorSpawnFn,
|
|
250
|
+
sessionId: input.sessionId, telemetry: deps.telemetry, ...(deps.alive ? { alive: deps.alive } : {}),
|
|
251
|
+
log,
|
|
252
|
+
});
|
|
253
|
+
return { status: "dispatched", run: { runId, runDir, pid: record.pid } };
|
|
254
|
+
} catch (error) {
|
|
255
|
+
log(`[skill-evolve] 派生失败:${String(error)}`);
|
|
256
|
+
await releaseOnce();
|
|
257
|
+
return { status: "skipped", why: "lock-error", ownerPid: null };
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
const flushPending = (): number => {
|
|
262
|
+
if (pending.size === 0) return 0;
|
|
263
|
+
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);
|
|
295
|
+
}
|
|
296
|
+
return dispatched;
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
return { dispatch: run, flushPending };
|
|
300
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 采纳侧的胶水:扫「评审通过」的 run → 入队 → auto 自动应用 / approve 手动应用 → 人话输出。
|
|
3
|
+
*
|
|
4
|
+
* 为什么扫描在扩展进程:闸2 需要 withFileMutationQueue(只有 pi 运行时能给),
|
|
5
|
+
* 所以评审督导只写 03-review.json,真正写盘永远回到这里。主会话退出也不丢:
|
|
6
|
+
* 队列落在 `.pi/evolution/queue.json`,下次 session_start 接着采纳。
|
|
7
|
+
*/
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
import { applyRun, rejectRun, type ApplyDeps, type ApplyOutcome } from "./apply.ts";
|
|
11
|
+
import { cleanupBackups } from "./apply-gates.ts";
|
|
12
|
+
import { readProposalMeta } from "./apply-targets.ts";
|
|
13
|
+
import { classifyChange } from "./classify-action.ts";
|
|
14
|
+
import { captureGate0, saveGate0, type GitRunner } from "./gate0.ts";
|
|
15
|
+
import { buildPrunePlan, renderPrunePlan } from "./prune-plan.ts";
|
|
16
|
+
import { enqueue, findItem, markDone, readQueue, takePending, type QueueItem, type QueueStatus } from "./queue.ts";
|
|
17
|
+
import { logLine } from "./logfile.ts";
|
|
18
|
+
import { updateRunStatus } from "./runs.ts";
|
|
19
|
+
import type { MutationQueue } from "./telemetry.ts";
|
|
20
|
+
|
|
21
|
+
export interface ApplyToolsDeps {
|
|
22
|
+
evolutionDir: string;
|
|
23
|
+
cwd: string;
|
|
24
|
+
queue: MutationQueue;
|
|
25
|
+
now?: () => number;
|
|
26
|
+
log?: (msg: string) => void;
|
|
27
|
+
git: GitRunner;
|
|
28
|
+
loadDiagnostics: () => { path: string; message: string }[];
|
|
29
|
+
runMdCheck: (absPath: string) => { code: number; output: string };
|
|
30
|
+
commit?: ApplyDeps["commit"];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ApplyTools {
|
|
34
|
+
/** session_start:闸0 快照 + 备份清理 + 扫队列并应用 auto 条 */
|
|
35
|
+
sessionStart(): void;
|
|
36
|
+
/** settle:评审可能刚写完,扫一遍(只应用 auto 条) */
|
|
37
|
+
settle(): void;
|
|
38
|
+
approve(runId: string, prune: boolean): Promise<string>;
|
|
39
|
+
/** 拒绝一条待批提案:只改队列与 run.json 状态,不动任何技能文件 */
|
|
40
|
+
reject(runId: string): Promise<string>;
|
|
41
|
+
queueText(): string;
|
|
42
|
+
/** 供测试与内部复用:返回实际应用成功的条数 */
|
|
43
|
+
autoApplyPending(): Promise<number>;
|
|
44
|
+
enqueueReady(): Promise<number>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 只有评审通过(reviewed)的 run 才进队列;rejected / review_failed 一律不排 */
|
|
48
|
+
const READY_STATUS = "reviewed";
|
|
49
|
+
const MAX_SCAN = 50;
|
|
50
|
+
|
|
51
|
+
function readText(file: string): string | null {
|
|
52
|
+
try {
|
|
53
|
+
return fs.readFileSync(file, "utf8");
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function readRunJson(runDir: string): Record<string, unknown> | null {
|
|
60
|
+
const raw = readText(path.join(runDir, "run.json"));
|
|
61
|
+
if (raw === null) return null;
|
|
62
|
+
try {
|
|
63
|
+
return JSON.parse(raw) as Record<string, unknown>;
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function stamp(ts: number): string {
|
|
70
|
+
return ts > 0 ? new Date(ts).toISOString() : "时间未知";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function renderOutcome(outcome: ApplyOutcome, item: QueueItem): string {
|
|
74
|
+
const head =
|
|
75
|
+
outcome.status === "applied"
|
|
76
|
+
? `已采纳 ${item.runId}(${item.level} · ${item.skill})`
|
|
77
|
+
: `采纳未完成 ${item.runId}:${outcome.status}`;
|
|
78
|
+
const lines = [head];
|
|
79
|
+
for (const gate of outcome.gates) {
|
|
80
|
+
lines.push(` ${gate.ok ? "✔" : "✘"} ${gate.gate} ${gate.name} ${gate.ms}ms${gate.detail ? ` — ${gate.detail}` : ""}`);
|
|
81
|
+
}
|
|
82
|
+
if (outcome.wrote.length > 0) lines.push(`写入:${outcome.wrote.join("、")}`);
|
|
83
|
+
if (outcome.commit) lines.push(`commit:${outcome.commit}(docs(skill): …)`);
|
|
84
|
+
if (outcome.detail) lines.push(`说明:${outcome.detail}`);
|
|
85
|
+
if (outcome.status === "applied") lines.push("生效:未 reload,新会话生效(技能在 session_start 重新发现)");
|
|
86
|
+
if (outcome.status === "applied" && item.level === "approval") lines.push("提醒:approval 级不自动提交,请人工检查后 git commit");
|
|
87
|
+
if (outcome.status === "unauthorized_write") lines.push("告警:`.pi/skills` 在会话开始后被改动过,本次未写盘;证据见 06-verify.json");
|
|
88
|
+
return lines.join("\n");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function createApplyTools(deps: ApplyToolsDeps): ApplyTools {
|
|
92
|
+
const now = deps.now ?? (() => Date.now());
|
|
93
|
+
// 默认只落盘(`.pi/evolution/logs/extension.log`):TUI 默认零推送
|
|
94
|
+
const log = deps.log ?? ((msg: string) => logLine(deps.evolutionDir, msg));
|
|
95
|
+
const queueDeps = { evolutionDir: deps.evolutionDir, queue: deps.queue, now };
|
|
96
|
+
|
|
97
|
+
const targetsOf = (runDir: string, skill: string, type: string, fromDiff: readonly string[]): string[] => {
|
|
98
|
+
const known = readProposalMeta(runDir);
|
|
99
|
+
const head = type === "create" ? [`.pi/skills/${skill}/SKILL.md`] : [];
|
|
100
|
+
return [...new Set([...head, ...(known ? fromDiff : [])])];
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const enqueueReady: ApplyTools["enqueueReady"] = async () => {
|
|
104
|
+
const runsDir = path.join(deps.evolutionDir, "runs");
|
|
105
|
+
let names: string[];
|
|
106
|
+
try {
|
|
107
|
+
names = fs.readdirSync(runsDir).slice(0, MAX_SCAN);
|
|
108
|
+
} catch {
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
111
|
+
const known = new Set(readQueue(deps.evolutionDir).map((item) => item.runId));
|
|
112
|
+
let added = 0;
|
|
113
|
+
for (const name of names) {
|
|
114
|
+
if (known.has(name)) continue;
|
|
115
|
+
const runDir = path.join(runsDir, name);
|
|
116
|
+
const raw = readRunJson(runDir);
|
|
117
|
+
if (!raw || raw.status !== READY_STATUS) continue;
|
|
118
|
+
const meta = readProposalMeta(runDir);
|
|
119
|
+
if (!meta) continue;
|
|
120
|
+
const diffText = readText(path.join(runDir, meta.diffFile)) ?? "";
|
|
121
|
+
const graded = classifyChange({ diffText, proposalType: meta.type, skill: meta.skill });
|
|
122
|
+
if (graded.level === "forbidden") {
|
|
123
|
+
log(`[skill-evolve] ${name} 判 forbidden,不入队:${graded.reasons[0] ?? ""}`);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const result = await enqueue(queueDeps, {
|
|
127
|
+
runId: name,
|
|
128
|
+
level: graded.level,
|
|
129
|
+
skill: meta.skill,
|
|
130
|
+
targets: targetsOf(runDir, meta.skill, meta.type, graded.targets),
|
|
131
|
+
proposalType: meta.type,
|
|
132
|
+
});
|
|
133
|
+
if (result.supersededBy) log(`[skill-evolve] ${name} 被更早的 pending 顶掉(${result.supersededBy})`);
|
|
134
|
+
else log(`[skill-evolve] ${name} 入队:${graded.level}(${graded.reasons[0] ?? ""})`);
|
|
135
|
+
added += 1;
|
|
136
|
+
}
|
|
137
|
+
return added;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const writeDecision = async (runDir: string, item: QueueItem, outcome: ApplyOutcome): Promise<void> => {
|
|
141
|
+
const record = {
|
|
142
|
+
runId: item.runId,
|
|
143
|
+
level: item.level,
|
|
144
|
+
skill: item.skill,
|
|
145
|
+
approver: item.level === "auto" ? "auto" : "command",
|
|
146
|
+
status: outcome.status,
|
|
147
|
+
at: now(),
|
|
148
|
+
atIso: new Date(now()).toISOString(),
|
|
149
|
+
...(outcome.detail ? { detail: outcome.detail } : {}),
|
|
150
|
+
};
|
|
151
|
+
const file = path.join(runDir, "04-decision.json");
|
|
152
|
+
await deps.queue(file, async () => {
|
|
153
|
+
await fs.promises.mkdir(runDir, { recursive: true });
|
|
154
|
+
await fs.promises.writeFile(file, `${JSON.stringify(record, null, 2)}\n`, "utf8");
|
|
155
|
+
});
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const applyOne = async (item: QueueItem): Promise<ApplyOutcome> => {
|
|
159
|
+
const applyDeps: ApplyDeps = {
|
|
160
|
+
evolutionDir: deps.evolutionDir,
|
|
161
|
+
cwd: deps.cwd,
|
|
162
|
+
runId: item.runId,
|
|
163
|
+
queue: deps.queue,
|
|
164
|
+
now,
|
|
165
|
+
log,
|
|
166
|
+
git: deps.git,
|
|
167
|
+
loadDiagnostics: deps.loadDiagnostics,
|
|
168
|
+
runMdCheck: deps.runMdCheck,
|
|
169
|
+
...(deps.commit ? { commit: deps.commit } : {}),
|
|
170
|
+
};
|
|
171
|
+
const outcome = await applyRun(item, applyDeps);
|
|
172
|
+
const status: QueueStatus =
|
|
173
|
+
outcome.status === "applied" ? "applied" : outcome.status === "rolled-back" ? "rolled-back" : "failed";
|
|
174
|
+
// 顺序不能换:先落决策记录,再改 run.json,最后改队列 / run 状态。
|
|
175
|
+
// 队列状态是外部可见的「终态标志」,进程若在中间被 kill,早写队列会留下
|
|
176
|
+
// 「已 applied 但读不到 04-decision.json」的空窗(approve.test.ts 的偶发 ENOENT 就是它)。
|
|
177
|
+
await writeDecision(path.join(deps.evolutionDir, "runs", item.runId), item, outcome);
|
|
178
|
+
await updateRunStatus(deps.evolutionDir, item.runId, outcome.status, deps.queue);
|
|
179
|
+
await markDone(queueDeps, item.runId, status, outcome.detail);
|
|
180
|
+
return outcome;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const autoApplyPending: ApplyTools["autoApplyPending"] = async () => {
|
|
184
|
+
let applied = 0;
|
|
185
|
+
for (const item of takePending(readQueue(deps.evolutionDir), "auto")) {
|
|
186
|
+
try {
|
|
187
|
+
const outcome = await applyOne(item);
|
|
188
|
+
if (outcome.status === "applied") applied += 1;
|
|
189
|
+
log(`[skill-evolve] auto 采纳 ${item.runId}:${outcome.status}${outcome.detail ? `(${outcome.detail})` : ""}`);
|
|
190
|
+
} catch (error) {
|
|
191
|
+
// 单条失败不能拖垮整批:记 failed 后继续下一条
|
|
192
|
+
await markDone(queueDeps, item.runId, "failed", String(error));
|
|
193
|
+
log(`[skill-evolve] auto 采纳 ${item.runId} 异常:${String(error)}`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return applied;
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const scanAndApply = (): void => {
|
|
200
|
+
void enqueueReady()
|
|
201
|
+
.then(() => autoApplyPending())
|
|
202
|
+
.catch((error: unknown) => log(`[skill-evolve] 采纳扫描失败:${String(error)}`));
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const sessionStart: ApplyTools["sessionStart"] = () => {
|
|
206
|
+
const time = now();
|
|
207
|
+
try {
|
|
208
|
+
cleanupBackups(deps.evolutionDir, time, log);
|
|
209
|
+
} catch (error) {
|
|
210
|
+
log(`[skill-evolve] 备份清理失败:${String(error)}`);
|
|
211
|
+
}
|
|
212
|
+
void saveGate0(deps.evolutionDir, captureGate0(deps.git, deps.cwd, time), deps.queue)
|
|
213
|
+
.then(() => enqueueReady())
|
|
214
|
+
.then(() => autoApplyPending())
|
|
215
|
+
.catch((error: unknown) => log(`[skill-evolve] 会话起点初始化失败:${String(error)}`));
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const approve: ApplyTools["approve"] = async (runId, prune) => {
|
|
219
|
+
if (!runId) return "用法:/skill-evolve approve <runId> [--prune]";
|
|
220
|
+
const runDir = path.join(deps.evolutionDir, "runs", runId);
|
|
221
|
+
if (prune) {
|
|
222
|
+
const plan = buildPrunePlan(runDir, deps.cwd);
|
|
223
|
+
return plan ? renderPrunePlan(plan) : `找不到 ${runId} 的提案(02-proposal.json),无法生成删除清单`;
|
|
224
|
+
}
|
|
225
|
+
const item = findItem(readQueue(deps.evolutionDir), runId);
|
|
226
|
+
if (!item) return `队列里没有 ${runId}:先 /skill-evolve queue 看有哪些,或等评审通过后自动入队`;
|
|
227
|
+
if (item.status === "applied") return `${runId} 已经应用过(${item.detail ?? "无详情"}),无需重复操作`;
|
|
228
|
+
if (item.status !== "pending") return `${runId} 当前状态是 ${item.status},只有 pending 能采纳`;
|
|
229
|
+
if (item.level === "forbidden") return `${runId} 被判 forbidden(删技能 / 改语义字段 / 路径越界),插件不执行`;
|
|
230
|
+
const outcome = await applyOne(item);
|
|
231
|
+
return renderOutcome(outcome, item);
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const reject: ApplyTools["reject"] = async (runId) => {
|
|
235
|
+
if (!runId) return "用法:/skill-evolve approve <runId>(拒绝入口在 Web 面板的待审批页)";
|
|
236
|
+
const item = findItem(readQueue(deps.evolutionDir), runId);
|
|
237
|
+
if (!item) return `队列里没有 ${runId}:先 /skill-evolve queue 看有哪些,或等评审通过后自动入队`;
|
|
238
|
+
const outcome = await rejectRun(item, queueDeps);
|
|
239
|
+
if (!outcome.ok) return `拒绝未完成 ${runId}:${outcome.detail}`;
|
|
240
|
+
log(`[skill-evolve] 拒绝 ${item.runId}(${item.level} · ${item.skill}):只改队列与 run 状态,未动技能文件`);
|
|
241
|
+
const tail = outcome.detail ? `(${outcome.detail})` : "";
|
|
242
|
+
return `已拒绝 ${runId}(${item.level} · ${item.skill})${tail}:技能文件未改动`;
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const queueText: ApplyTools["queueText"] = () => {
|
|
246
|
+
const items = readQueue(deps.evolutionDir);
|
|
247
|
+
if (items.length === 0) return `采纳队列为空:${path.join(deps.evolutionDir, "queue.json")}(评审通过后自动入队)`;
|
|
248
|
+
const lines = [`采纳队列:${path.join(deps.evolutionDir, "queue.json")}`];
|
|
249
|
+
const groups: QueueStatus[] = ["pending", "applied", "rolled-back", "failed", "superseded", "rejected"];
|
|
250
|
+
for (const status of groups) {
|
|
251
|
+
const group = items.filter((item) => item.status === status).sort((a, b) => b.createdAt - a.createdAt);
|
|
252
|
+
if (group.length === 0) continue;
|
|
253
|
+
lines.push(`${status} ${group.length} 条`);
|
|
254
|
+
for (const item of group.slice(0, 5)) {
|
|
255
|
+
const detail = item.detail ? ` · ${item.detail}` : "";
|
|
256
|
+
lines.push(` · ${item.runId} ${item.level} ${item.skill} ${stamp(item.createdAt)}${detail}`);
|
|
257
|
+
if (status === "pending") lines.push(` targets:${item.targets.join("、")}`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
lines.push("auto 级在 session_start / settle 自动执行;approval 用 /skill-evolve approve <runId> 或 Web 面板待审批页");
|
|
261
|
+
return lines.join("\n");
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
return { sessionStart, settle: scanAndApply, approve, reject, queueText, autoApplyPending, enqueueReady };
|
|
265
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 模型配置 + 必红自检的「动作层」:把 `model-config.ts`(读写配置)与 `selftest.ts`(跑自检)
|
|
3
|
+
* 拼成 evolution 与命令能直接用的三件事——取评审模型、改模型、跑自检。
|
|
4
|
+
*
|
|
5
|
+
* 放这里而不是塞进 evolution.ts:那边已经贴着 300 行闸门,而这一层与采集/门禁无关。
|
|
6
|
+
*/
|
|
7
|
+
import { loadModelConfig, resolveModel, saveModelSpec, type ModelConfig, type ModelKind } from "./model-config.ts";
|
|
8
|
+
import { readSelftestReport, renderSelftestReport, runSelftest, type SelftestReport } from "./selftest.ts";
|
|
9
|
+
import type { ReviewSpawn } from "./review-run.ts";
|
|
10
|
+
import type { MutationQueue } from "./telemetry.ts";
|
|
11
|
+
|
|
12
|
+
export interface ModelToolsDeps {
|
|
13
|
+
evolutionDir: string;
|
|
14
|
+
cwd: string;
|
|
15
|
+
queue: MutationQueue;
|
|
16
|
+
log: (msg: string) => void;
|
|
17
|
+
now?: () => number;
|
|
18
|
+
env?: NodeJS.ProcessEnv;
|
|
19
|
+
/** 主会话当前模型:inherit 时用它(拿不到就仍返回 inherit,让 pi 自己挑默认模型) */
|
|
20
|
+
hostModel?: () => { provider?: string; id?: string } | undefined;
|
|
21
|
+
reviewSpawnFn?: ReviewSpawn;
|
|
22
|
+
selftestTimeoutMs?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ModelTools {
|
|
26
|
+
config(): ModelConfig;
|
|
27
|
+
/** 评审该用哪个模型:`provider/id` 或 "inherit" */
|
|
28
|
+
reviewModel(): string;
|
|
29
|
+
setModel(kind: ModelKind, spec: string): Promise<string>;
|
|
30
|
+
/** `/skill-evolve selftest`:跑三个坏样本并回显 */
|
|
31
|
+
selftestText(): Promise<string>;
|
|
32
|
+
/** 派发门禁用:最近一次自检明确没过 → true(没跑过不算没过) */
|
|
33
|
+
selftestFailed(): boolean;
|
|
34
|
+
lastSelftest(): SelftestReport | undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function createModelTools(deps: ModelToolsDeps): ModelTools {
|
|
38
|
+
const log = deps.log;
|
|
39
|
+
const config = (): ModelConfig => loadModelConfig(deps.evolutionDir);
|
|
40
|
+
const reviewModel = (): string => resolveModel("review", config(), { model: deps.hostModel?.() });
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
config,
|
|
44
|
+
reviewModel,
|
|
45
|
+
async setModel(kind: ModelKind, spec: string): Promise<string> {
|
|
46
|
+
const next = await saveModelSpec(deps.evolutionDir, kind, spec, deps.queue);
|
|
47
|
+
const label = kind === "propose" ? "提案" : "评审";
|
|
48
|
+
const warn = next.propose === next.review && next.review !== "inherit" ? "(提案与评审是同一个模型,独立性天然有限)" : "";
|
|
49
|
+
return `${label}模型已设为 ${next[kind]}${warn}(下次派发生效)`;
|
|
50
|
+
},
|
|
51
|
+
async selftestText(): Promise<string> {
|
|
52
|
+
const model = reviewModel();
|
|
53
|
+
const report = await runSelftest({
|
|
54
|
+
evolutionDir: deps.evolutionDir,
|
|
55
|
+
cwd: deps.cwd,
|
|
56
|
+
...(model !== "inherit" ? { model } : {}),
|
|
57
|
+
...(deps.env ? { env: deps.env } : {}),
|
|
58
|
+
...(deps.reviewSpawnFn ? { spawnFn: deps.reviewSpawnFn } : {}),
|
|
59
|
+
...(deps.now ? { now: deps.now } : {}),
|
|
60
|
+
...(deps.selftestTimeoutMs ? { timeoutMs: deps.selftestTimeoutMs } : {}),
|
|
61
|
+
log,
|
|
62
|
+
});
|
|
63
|
+
return renderSelftestReport(report);
|
|
64
|
+
},
|
|
65
|
+
selftestFailed(): boolean {
|
|
66
|
+
return readSelftestReport(deps.evolutionDir)?.passed === false;
|
|
67
|
+
},
|
|
68
|
+
lastSelftest(): SelftestReport | undefined {
|
|
69
|
+
return readSelftestReport(deps.evolutionDir);
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|