pi-skill-evolve 0.1.9 → 0.1.11
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/fixtures/selftest/good-description/after.md +9 -0
- package/extensions/skill-evolve/fixtures/selftest/good-description/change.diff +13 -0
- package/extensions/skill-evolve/fixtures/selftest/good-description/fixture.json +6 -0
- package/extensions/skill-evolve/lib/dispatch-types.ts +1 -1
- package/extensions/skill-evolve/lib/dispatch.ts +25 -2
- package/extensions/skill-evolve/lib/drain-pending.ts +3 -0
- package/extensions/skill-evolve/lib/replay-fixture.ts +5 -2
- package/extensions/skill-evolve/lib/review-post.ts +92 -21
- package/extensions/skill-evolve/lib/review-prompt.ts +5 -1
- package/extensions/skill-evolve/lib/review-record.ts +14 -1
- package/extensions/skill-evolve/lib/review-supervisor.ts +13 -1
- package/extensions/skill-evolve/lib/runs.ts +4 -0
- package/extensions/skill-evolve/lib/selftest.ts +22 -5
- package/extensions/skill-evolve/lib/shutdown-dispatch.ts +25 -2
- package/extensions/skill-evolve/lib/spawn-plan.ts +47 -4
- package/extensions/skill-evolve/lib/web/applied.ts +6 -1
- package/extensions/skill-evolve/lib/web/approvals.ts +7 -0
- package/extensions/skill-evolve/lib/web/done.ts +20 -6
- package/extensions/skill-evolve/lib/web/pending.ts +7 -1
- package/extensions/skill-evolve/lib/web/render-rows.ts +11 -2
- package/extensions/skill-evolve/lib/web/render-run.ts +46 -10
- package/extensions/skill-evolve/lib/web/reports.ts +11 -3
- package/extensions/skill-evolve/lib/web/run-detail.ts +41 -13
- package/extensions/skill-evolve/lib/web/style.ts +2 -0
- package/extensions/skill-evolve/test/dispatch.test.ts +11 -0
- package/extensions/skill-evolve/test/review-post.test.ts +53 -6
- package/extensions/skill-evolve/test/review-prompt.test.ts +9 -0
- package/extensions/skill-evolve/test/review-record.test.ts +59 -0
- package/extensions/skill-evolve/test/review-supervisor.test.ts +15 -1
- package/extensions/skill-evolve/test/selftest.test.ts +51 -16
- package/extensions/skill-evolve/test/web-redesign.test.ts +1 -1
- package/extensions/skill-evolve/test/web-render-run.test.ts +70 -1
- package/extensions/skill-evolve/test/web-run-detail.test.ts +27 -0
- package/extensions/skill-evolve/test/web-view-coverage.test.ts +100 -0
- package/package.json +1 -1
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
--- a/.pi/skills/log-helper/SKILL.md
|
|
2
|
+
+++ b/.pi/skills/log-helper/SKILL.md
|
|
3
|
+
@@ -1,9 +1,9 @@
|
|
4
|
+
---
|
|
5
|
+
name: log-helper
|
|
6
|
+
-description: 查日志时使用
|
|
7
|
+
+description: 查服务端日志、客户端日志、构建日志时使用:先定位时间窗再 rg 关键字
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# 日志排查
|
|
11
|
+
|
|
12
|
+
- 凡是查日志一律用 `rg`,禁止 `grep -r` 全盘扫。
|
|
13
|
+
- 只读日志,不许改服务端配置。
|
|
@@ -51,7 +51,7 @@ export type DispatchOutcome =
|
|
|
51
51
|
| { status: "dispatched"; run: DispatchedRun }
|
|
52
52
|
| {
|
|
53
53
|
status: "skipped";
|
|
54
|
-
why: "disabled" | "cap" | "lock" | "lock-error" | "selftest-failed" | "busy";
|
|
54
|
+
why: "disabled" | "cap" | "lock" | "lock-error" | "selftest-failed" | "busy" | "pi-not-found";
|
|
55
55
|
ownerPid: number | null;
|
|
56
56
|
}
|
|
57
57
|
| { status: "superseded"; rounds: number }
|
|
@@ -16,7 +16,7 @@ import { acquireLock, releaseLock, writeOwnerSync } from "./lock.ts";
|
|
|
16
16
|
import { buildPrompt, readPromptTemplateSync } from "./prompt.ts";
|
|
17
17
|
import { writeRunRecord } from "./runs.ts";
|
|
18
18
|
import { writeSessionIndex } from "./session-index.ts";
|
|
19
|
-
import { CHILD_LOG, resolvePiBin, spawnDetached, uniqueRunId, sessionHashOf } from "./spawn-plan.ts";
|
|
19
|
+
import { CHILD_LOG, isExecutableFile, resolvePiBin, spawnDetached, uniqueRunId, sessionHashOf } from "./spawn-plan.ts";
|
|
20
20
|
import { mergePendingRounds } from "./pending.ts";
|
|
21
21
|
import { watchRunChild } from "./run-watch.ts";
|
|
22
22
|
import { spawnReviewSupervisor } from "./review-spawn.ts";
|
|
@@ -160,6 +160,23 @@ export function createDispatcher(deps: DispatchDeps): Dispatcher {
|
|
|
160
160
|
lockHeld = true;
|
|
161
161
|
|
|
162
162
|
try {
|
|
163
|
+
// 先验 pi 真的可执行:spawn 的 ENOENT 是**异步** emit 的,早于收尸 listener 挂载,
|
|
164
|
+
// 会把整个 pi 进程 uncaughtException 打死(2026-11-09 写死路径事故)。宁可不派,也不炸宿主。
|
|
165
|
+
// 注入了 spawnFn = 单测/嵌入式宿主,路径由调用方自负,这里不做磁盘校验。
|
|
166
|
+
const piBin = resolvePiBin(env);
|
|
167
|
+
if (!deps.spawnFn && !isExecutableFile(piBin)) {
|
|
168
|
+
log(`[skill-evolve] 未派生:找不到可执行的 pi(${piBin})。设 SKILL_EVOLVE_PI_BIN 或先装好 pi`);
|
|
169
|
+
deps.telemetry.append({
|
|
170
|
+
kind: "skipped",
|
|
171
|
+
sessionId: input.sessionId,
|
|
172
|
+
why: "pi-not-found",
|
|
173
|
+
ownerPid: lock.owner?.pid ?? null,
|
|
174
|
+
trigger,
|
|
175
|
+
});
|
|
176
|
+
await releaseOnce();
|
|
177
|
+
return { status: "skipped", why: "pi-not-found", ownerPid: lock.owner?.pid ?? null };
|
|
178
|
+
}
|
|
179
|
+
|
|
163
180
|
const indexFile = path.join(runDir, "00-session", "index.json");
|
|
164
181
|
const sessionFile = input.sessionFile ?? carried?.sessionFile;
|
|
165
182
|
let evidenceMode: RunRecord["evidenceMode"] = "telemetry-only";
|
|
@@ -199,7 +216,6 @@ export function createDispatcher(deps: DispatchDeps): Dispatcher {
|
|
|
199
216
|
dispatchedAt: at,
|
|
200
217
|
coverage,
|
|
201
218
|
});
|
|
202
|
-
const piBin = resolvePiBin(env);
|
|
203
219
|
const spawnFn: SpawnLike = deps.spawnFn ?? (spawn as unknown as SpawnLike);
|
|
204
220
|
const child = spawnDetached(spawnFn, piBin, ["-p", "--no-session", "-ns", prompt], {
|
|
205
221
|
cwd: deps.cwd,
|
|
@@ -207,6 +223,11 @@ export function createDispatcher(deps: DispatchDeps): Dispatcher {
|
|
|
207
223
|
// stdout+stderr 落到 run 目录:子进程怎么死的,只有这份日志答得出来
|
|
208
224
|
logFile: path.join(runDir, CHILD_LOG),
|
|
209
225
|
});
|
|
226
|
+
// 兜底 listener:spawn 仍可能失败(权限、架构不匹配),没 listener 的 error 事件在 Node 里
|
|
227
|
+
// 会直接抛成 uncaughtException。真正的收尸仍由 watchRunChild 负责,这里只保证不炸宿主。
|
|
228
|
+
child.once("error", (error) => {
|
|
229
|
+
log(`[skill-evolve] 复盘子进程启动失败:${String(error)}`);
|
|
230
|
+
});
|
|
210
231
|
|
|
211
232
|
record.pid = child.pid ?? null;
|
|
212
233
|
await writeRunRecord(runDir, record, deps.queue);
|
|
@@ -258,6 +279,8 @@ export function createDispatcher(deps: DispatchDeps): Dispatcher {
|
|
|
258
279
|
...(deps.reviewModel ? { reviewModel: deps.reviewModel } : {}),
|
|
259
280
|
...(deps.supervisorSpawnFn ? { supervisorSpawnFn: deps.supervisorSpawnFn } : {}),
|
|
260
281
|
...(deps.alive ? { alive: deps.alive } : {}),
|
|
282
|
+
// 注入了 spawnFn = 单测/嵌入式宿主:路径由调用方自负,退出补发也不做磁盘校验
|
|
283
|
+
...(deps.spawnFn ? { piBinExists: () => true } : {}),
|
|
261
284
|
log,
|
|
262
285
|
},
|
|
263
286
|
at,
|
|
@@ -26,6 +26,8 @@ export interface DrainPendingOptions {
|
|
|
26
26
|
reviewModel?: () => string;
|
|
27
27
|
supervisorSpawnFn?: SupervisorSpawn;
|
|
28
28
|
alive?: (pid: number) => boolean;
|
|
29
|
+
/** pi 可执行文件判定(dispatch 注入假 spawnFn 时直接传「都在」,单测不碰真磁盘) */
|
|
30
|
+
piBinExists?: (candidate: string) => boolean;
|
|
29
31
|
log: (msg: string) => void;
|
|
30
32
|
}
|
|
31
33
|
|
|
@@ -49,6 +51,7 @@ export function drainPending(pending: Map<string, PendingRounds>, options: Drain
|
|
|
49
51
|
...(options.reviewModel ? { reviewModel: options.reviewModel() } : {}),
|
|
50
52
|
...(options.supervisorSpawnFn ? { supervisorSpawnFn: options.supervisorSpawnFn } : {}),
|
|
51
53
|
...(options.alive ? { alive: options.alive } : {}),
|
|
54
|
+
...(options.piBinExists ? { piBinExists: options.piBinExists } : {}),
|
|
52
55
|
log: options.log,
|
|
53
56
|
},
|
|
54
57
|
at,
|
|
@@ -47,11 +47,14 @@ export const REPLAY_DIFF = [
|
|
|
47
47
|
"",
|
|
48
48
|
].join("\n");
|
|
49
49
|
|
|
50
|
-
/** 假评审结论:形状必须过 postProcessReview
|
|
50
|
+
/** 假评审结论:形状必须过 postProcessReview 的字段校验(summary 必填 + 证据三件套齐全 + pass 必须有 passed) */
|
|
51
51
|
export const FAKE_REVIEW_TEXT = JSON.stringify({
|
|
52
52
|
verdict: "pass",
|
|
53
|
+
summary: "新增条款只补了复演前的读取顺序,没有删改原有约束",
|
|
53
54
|
blocking: [],
|
|
54
|
-
|
|
55
|
+
passed: [
|
|
56
|
+
{ point: "没有删原有禁令", evidence: { file: ".pi/skills/replay-demo/SKILL.md", line: 9, quote: "复演时必须先看 00-session/index.json" } },
|
|
57
|
+
],
|
|
55
58
|
notes: ["复演用假评审:只验证流水线,不代表真实评审质量"],
|
|
56
59
|
});
|
|
57
60
|
|
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 原则是「不信 LLM 自觉」:verdict 只要有一个字段不合规就判 invalid(重评一次仍 invalid 就转人工),
|
|
5
5
|
* pass 但带 blocking 是自相矛盾,直接 invalid;机械复验失败则一票 reject,覆盖 LLM 的 pass。
|
|
6
|
+
*
|
|
7
|
+
* 结论必须写「为什么」:`summary` 一句话说明因为什么(面板上人看的就是这一句),
|
|
8
|
+
* `passed` 是「没问题的地方 + 证据」,verdict=pass 时至少 1 条 —— 只写 blocking 的评审
|
|
9
|
+
* 在 pass/revise 时等于什么都没说,用户只能看到「通过」两个字。
|
|
6
10
|
*/
|
|
7
11
|
|
|
8
12
|
export type ReviewVerdict = "pass" | "reject" | "revise";
|
|
@@ -18,12 +22,21 @@ export interface ReviewBlocking {
|
|
|
18
22
|
evidence: ReviewEvidence;
|
|
19
23
|
}
|
|
20
24
|
|
|
25
|
+
/** 通过理由:哪一点没问题(point)+ 支撑它的原文证据 */
|
|
26
|
+
export interface ReviewPassing {
|
|
27
|
+
point: string;
|
|
28
|
+
evidence: ReviewEvidence;
|
|
29
|
+
}
|
|
30
|
+
|
|
21
31
|
export type ReviewStatus = "ok" | "invalid" | "reject";
|
|
22
32
|
|
|
23
33
|
export interface ReviewResult {
|
|
24
34
|
status: ReviewStatus;
|
|
25
35
|
verdict?: ReviewVerdict;
|
|
26
36
|
blocking: ReviewBlocking[];
|
|
37
|
+
passed: ReviewPassing[];
|
|
38
|
+
/** 一句话结论(≤80 字,必须写「因为什么」);旧版评审输出没有这一项 */
|
|
39
|
+
summary?: string;
|
|
27
40
|
notes?: string;
|
|
28
41
|
/** invalid / reject 的具体原因(机器可读前缀 + 人话) */
|
|
29
42
|
detail?: string;
|
|
@@ -31,6 +44,9 @@ export interface ReviewResult {
|
|
|
31
44
|
|
|
32
45
|
const VERDICTS: readonly ReviewVerdict[] = ["pass", "reject", "revise"];
|
|
33
46
|
|
|
47
|
+
/** summary 上限:一句话结论,超了截断(80 字是面板上一行能读完的长度) */
|
|
48
|
+
export const SUMMARY_MAX = 80;
|
|
49
|
+
|
|
34
50
|
/** 从模型输出里抠 JSON:容忍 ```json 围栏与前后废话,抠不出就当解析失败 */
|
|
35
51
|
export function parseReviewJson(text: string): { json?: Record<string, unknown>; error?: string } {
|
|
36
52
|
const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(text);
|
|
@@ -63,39 +79,94 @@ function checkEvidence(raw: unknown): ReviewEvidence | string {
|
|
|
63
79
|
* 12.3 四步里的前三步(结构 / 证据 / 一致性)+ 可选的机械复验。
|
|
64
80
|
* `recheck` 由调用方注入(读盘 + loadSkillsFromDir),返回错误字符串表示复验失败。
|
|
65
81
|
*/
|
|
82
|
+
/** summary 归一:去首尾空白、压掉换行;空串 = 没写,超长按 80 字截断(含省略号本身不超过 80) */
|
|
83
|
+
function normalizeSummary(raw: unknown): string | null {
|
|
84
|
+
if (typeof raw !== "string") return null;
|
|
85
|
+
const text = raw.replace(/\s+/g, " ").trim();
|
|
86
|
+
if (text.length === 0) return null;
|
|
87
|
+
return text.length > SUMMARY_MAX ? `${text.slice(0, SUMMARY_MAX - 1)}…` : text;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 逐条读带证据的理由数组(blocking / passed 共用):结构不对、证据不全一律返回错误字符串。
|
|
92
|
+
* 顺序:先查 block 的证据,再查 passed 的证据 —— 两边共用同一个 checkEvidence,报错前缀区分来源。
|
|
93
|
+
*/
|
|
94
|
+
function readReasons<T>(
|
|
95
|
+
raw: unknown,
|
|
96
|
+
label: "blocking" | "passed",
|
|
97
|
+
build: (text: string, evidence: ReviewEvidence) => T,
|
|
98
|
+
): T[] | string {
|
|
99
|
+
if (raw !== undefined && !Array.isArray(raw)) return `bad-${label}: ${label} 不是数组`;
|
|
100
|
+
const items: T[] = [];
|
|
101
|
+
for (const item of (raw ?? []) as unknown[]) {
|
|
102
|
+
if (typeof item !== "object" || item === null) return `missing-evidence: ${label} 项不是对象`;
|
|
103
|
+
const evidence = checkEvidence((item as Record<string, unknown>).evidence);
|
|
104
|
+
if (typeof evidence === "string") return `missing-evidence: ${label} · ${evidence}`;
|
|
105
|
+
const key = label === "blocking" ? "issue" : "point";
|
|
106
|
+
const rawText = (item as Record<string, unknown>)[key];
|
|
107
|
+
const text = typeof rawText === "string" ? rawText.trim() : "";
|
|
108
|
+
items.push(build(text.length > 0 ? text : `(未写 ${key})`, evidence));
|
|
109
|
+
}
|
|
110
|
+
return items;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 校验顺序 = 从「根本读不出结论」到「结论缺理由」:
|
|
115
|
+
* JSON → verdict 白名单 → blocking 结构与证据 → passed 结构与证据 → pass 却带 blocking 的自相矛盾
|
|
116
|
+
* → summary 必填 → pass 必须给 passed → reject/revise 必须给 blocking → 机械复验。
|
|
117
|
+
* 越靠前的错误越致命,也让单测能一条一条钉住具体坏法。
|
|
118
|
+
*/
|
|
66
119
|
export function postProcessReview(text: string, opts: { recheck?: () => string | null } = {}): ReviewResult {
|
|
67
120
|
const parsed = parseReviewJson(text);
|
|
68
|
-
if (!parsed.json) return { status: "invalid", blocking: [], detail: parsed.error ?? "bad-json" };
|
|
121
|
+
if (!parsed.json) return { status: "invalid", blocking: [], passed: [], detail: parsed.error ?? "bad-json" };
|
|
69
122
|
|
|
70
123
|
const verdict = parsed.json.verdict;
|
|
71
124
|
if (typeof verdict !== "string" || !(VERDICTS as readonly string[]).includes(verdict)) {
|
|
72
|
-
return { status: "invalid", blocking: [], detail: `bad-verdict: ${String(verdict)}` };
|
|
125
|
+
return { status: "invalid", blocking: [], passed: [], detail: `bad-verdict: ${String(verdict)}` };
|
|
73
126
|
}
|
|
127
|
+
const typed = verdict as ReviewVerdict;
|
|
128
|
+
|
|
129
|
+
const blocked = readReasons(parsed.json.blocking, "blocking", (issue, evidence) => ({ issue, evidence }));
|
|
130
|
+
if (typeof blocked === "string") return { status: "invalid", blocking: [], passed: [], verdict: typed, detail: blocked };
|
|
74
131
|
|
|
75
|
-
const
|
|
76
|
-
if (
|
|
77
|
-
|
|
132
|
+
const passed = readReasons(parsed.json.passed, "passed", (point, evidence) => ({ point, evidence }));
|
|
133
|
+
if (typeof passed === "string") return { status: "invalid", blocking: blocked, passed: [], verdict: typed, detail: passed };
|
|
134
|
+
|
|
135
|
+
if (verdict === "pass" && blocked.length > 0) {
|
|
136
|
+
return { status: "invalid", blocking: blocked, passed, verdict: "pass", detail: "contradiction: pass 却有 blocking" };
|
|
78
137
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
84
|
-
const evidence = checkEvidence((item as Record<string, unknown>).evidence);
|
|
85
|
-
if (typeof evidence === "string") {
|
|
86
|
-
return { status: "invalid", blocking: [], verdict: verdict as ReviewVerdict, detail: `missing-evidence: ${evidence}` };
|
|
87
|
-
}
|
|
88
|
-
const issue = typeof (item as Record<string, unknown>).issue === "string" ? String((item as Record<string, unknown>).issue) : "";
|
|
89
|
-
blocked.push({ issue: issue.length > 0 ? issue : "(未写 issue)", evidence });
|
|
138
|
+
|
|
139
|
+
const summary = normalizeSummary(parsed.json.summary);
|
|
140
|
+
if (summary === null) {
|
|
141
|
+
return { status: "invalid", blocking: blocked, passed, verdict: typed, detail: "bad-summary: 没写 summary(一句话结论)" };
|
|
90
142
|
}
|
|
91
143
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
144
|
+
if (verdict === "pass" && passed.length === 0) {
|
|
145
|
+
return {
|
|
146
|
+
status: "invalid",
|
|
147
|
+
blocking: blocked,
|
|
148
|
+
passed,
|
|
149
|
+
verdict: typed,
|
|
150
|
+
summary,
|
|
151
|
+
detail: "missing-passed: verdict=pass 但没给 passed(通过理由)",
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
if (verdict !== "pass" && blocked.length === 0) {
|
|
155
|
+
return {
|
|
156
|
+
status: "invalid",
|
|
157
|
+
blocking: blocked,
|
|
158
|
+
passed,
|
|
159
|
+
verdict: typed,
|
|
160
|
+
summary,
|
|
161
|
+
detail: `missing-blocking: verdict=${verdict} 但没给 blocking`,
|
|
162
|
+
};
|
|
95
163
|
}
|
|
96
164
|
|
|
165
|
+
const notes = typeof parsed.json.notes === "string" ? parsed.json.notes : undefined;
|
|
97
166
|
const failure = opts.recheck?.() ?? null;
|
|
98
|
-
if (failure)
|
|
167
|
+
if (failure) {
|
|
168
|
+
return { status: "reject", blocking: blocked, passed, verdict: "reject", summary, notes, detail: `frontmatter-invalid: ${failure}` };
|
|
169
|
+
}
|
|
99
170
|
|
|
100
|
-
return { status: "ok", blocking: blocked, verdict:
|
|
171
|
+
return { status: "ok", blocking: blocked, passed, verdict: typed, summary, ...(notes ? { notes } : {}) };
|
|
101
172
|
}
|
|
@@ -72,11 +72,15 @@ export function buildReviewPrompt(input: ReviewPromptInput): string {
|
|
|
72
72
|
"",
|
|
73
73
|
"## 输出格式(严格遵守,只输出 JSON,不要代码围栏、不要解释)",
|
|
74
74
|
'{"verdict":"pass|reject|revise",',
|
|
75
|
+
' "summary":"一句话结论(≤80 字,必须写清因为什么)",',
|
|
75
76
|
' "blocking":[{"issue":"...","evidence":{"file":"...","line":42,"quote":"原文"}}],',
|
|
77
|
+
' "passed":[{"point":"...","evidence":{"file":"...","line":42,"quote":"原文"}}],',
|
|
76
78
|
' "notes":"..."}',
|
|
77
79
|
"",
|
|
78
80
|
`技能名:${input.skillName}`,
|
|
79
|
-
"说明:
|
|
81
|
+
"说明:summary 必填,不要写成「没问题」或「建议修改」这种没有原因的空话,要写「因为哪一句 / 少哪一项所以这个结论」。",
|
|
82
|
+
"blocking 每条都必须给 file/line/quote(quote 是被改动文件里的原文片段);verdict=reject/revise 时 blocking 至少 1 条。",
|
|
83
|
+
"passed 写「哪一点没问题 + 支撑它的原文」;verdict=pass 时 blocking=[] 且 passed 至少 1 条(只写「通过」两个字等于没说)。",
|
|
80
84
|
"",
|
|
81
85
|
].join("\n");
|
|
82
86
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* 单独一个文件是因为评审督导已经接近 300 行闸门;这里全是纯函数,单测可直接调。
|
|
5
5
|
*/
|
|
6
6
|
import type { ActionLevel } from "./classify-action.ts";
|
|
7
|
-
import type { ReviewBlocking, ReviewVerdict } from "./review-post.ts";
|
|
7
|
+
import type { ReviewBlocking, ReviewPassing, ReviewVerdict } from "./review-post.ts";
|
|
8
8
|
|
|
9
9
|
export type SupervisorStatus = "reviewed" | "rejected" | "review_failed" | "skipped";
|
|
10
10
|
|
|
@@ -13,6 +13,10 @@ export interface ReviewRecord {
|
|
|
13
13
|
reason?: string;
|
|
14
14
|
verdict?: ReviewVerdict;
|
|
15
15
|
blocking: ReviewBlocking[];
|
|
16
|
+
/** 通过理由(verdict=pass 时至少 1 条);旧 run 没有这个字段 —— 读的时候当空数组,不抛 */
|
|
17
|
+
passed?: ReviewPassing[];
|
|
18
|
+
/** 一句话结论(≤80 字);旧 run 没有这个字段 = undefined */
|
|
19
|
+
summary?: string;
|
|
16
20
|
notes?: string;
|
|
17
21
|
action?: ActionLevel;
|
|
18
22
|
reviewerModel: string;
|
|
@@ -45,6 +49,8 @@ export function renderReviewMarkdown(record: ReviewRecord, skill: string): strin
|
|
|
45
49
|
// 首行就要立警告:同模型自评的独立性天然有限,人一眼得看见
|
|
46
50
|
if (record.sameModel) lines.push("> 同模型自评,独立性有限(proposer 与 reviewer 是同一个模型,请人工复核)", "");
|
|
47
51
|
lines.push(`# 评审结论:${record.verdict ?? record.status}`, "");
|
|
52
|
+
// 结论摘要放在元信息之前:人看 md 的第一句就该是「为什么」
|
|
53
|
+
if (record.summary) lines.push(`**${record.summary}**`, "");
|
|
48
54
|
lines.push(`- 技能:${skill}`);
|
|
49
55
|
lines.push(`- status:${record.status}${record.detail ? `(${record.detail})` : ""}`);
|
|
50
56
|
lines.push(`- 建议动作:${record.action ?? "无"}(本版一律不写 .pi/skills)`);
|
|
@@ -57,6 +63,13 @@ export function renderReviewMarkdown(record: ReviewRecord, skill: string): strin
|
|
|
57
63
|
lines.push(`- ${item.issue}`, ` - 证据:${item.evidence.file}:${item.evidence.line} 「${item.evidence.quote}」`);
|
|
58
64
|
}
|
|
59
65
|
}
|
|
66
|
+
const passed = record.passed ?? [];
|
|
67
|
+
if (passed.length > 0) {
|
|
68
|
+
lines.push("", "## passed", "");
|
|
69
|
+
for (const item of passed) {
|
|
70
|
+
lines.push(`- ${item.point}`, ` - 证据:${item.evidence.file}:${item.evidence.line} 「${item.evidence.quote}」`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
60
73
|
if (record.notes) lines.push("", "## notes", "", record.notes);
|
|
61
74
|
lines.push("");
|
|
62
75
|
return lines.join("\n");
|
|
@@ -215,6 +215,8 @@ export async function runReviewSupervisor(deps: ReviewSupervisorDeps): Promise<S
|
|
|
215
215
|
status,
|
|
216
216
|
...(verdict ? { verdict } : {}),
|
|
217
217
|
blocking: outcome.result.blocking,
|
|
218
|
+
passed: outcome.result.passed,
|
|
219
|
+
...(outcome.result.summary ? { summary: outcome.result.summary } : {}),
|
|
218
220
|
...(outcome.result.notes ? { notes: outcome.result.notes } : {}),
|
|
219
221
|
action: classifyChange({ diffText, proposalType: proposal.type, skill: proposal.skill }).level,
|
|
220
222
|
attempts: outcome.attempts,
|
|
@@ -228,7 +230,17 @@ export async function runReviewSupervisor(deps: ReviewSupervisorDeps): Promise<S
|
|
|
228
230
|
try {
|
|
229
231
|
const runJson = JSON.parse(runRaw) as Record<string, unknown>;
|
|
230
232
|
runJson.status = status === "reviewed" ? "reviewed" : status === "rejected" ? "rejected" : "review_failed";
|
|
231
|
-
runJson.review = {
|
|
233
|
+
runJson.review = {
|
|
234
|
+
status,
|
|
235
|
+
verdict: verdict ?? null,
|
|
236
|
+
action: record.action,
|
|
237
|
+
reviewerModel,
|
|
238
|
+
proposerModel,
|
|
239
|
+
sameModel,
|
|
240
|
+
// 摘要写回 run.json:列表与详情页共用一存,不用为一句结论另开文件
|
|
241
|
+
summary: outcome.result.summary ?? null,
|
|
242
|
+
passed: outcome.result.passed.length,
|
|
243
|
+
};
|
|
232
244
|
write(runFile, `${JSON.stringify(runJson, null, 2)}\n`);
|
|
233
245
|
} catch (error) {
|
|
234
246
|
log(`[skill-evolve] 评审结论写不回 run.json:${(error as Error).message}`);
|
|
@@ -86,6 +86,10 @@ export interface RunReview {
|
|
|
86
86
|
reviewerModel: string;
|
|
87
87
|
proposerModel: string;
|
|
88
88
|
sameModel: boolean;
|
|
89
|
+
/** 一句话结论(旧 run 没有这一项 = undefined/null) */
|
|
90
|
+
summary?: string | null;
|
|
91
|
+
/** 通过理由条数:列表页不需要全文,详情页读 03-review.json */
|
|
92
|
+
passed?: number;
|
|
89
93
|
}
|
|
90
94
|
|
|
91
95
|
export interface RunSummary {
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 必红自检(设计稿 12.4
|
|
2
|
+
* 必红自检(设计稿 12.4):拿三个**已知该被拦住**的坏样本 + 一个**已知该放行**的正样本去考评审器。
|
|
3
3
|
*
|
|
4
4
|
* 这是整条流水线唯一的「评审器自己可信吗」证据:如果评审器把「可以用 find」判成 pass,
|
|
5
5
|
* 后面所有真提案的 pass 都没有意义,所以这里任何一条 expected 没命中就拿 `passed: false`
|
|
6
6
|
* 并在报告里打红字,派发也随之停摆。
|
|
7
7
|
*
|
|
8
|
+
* 正样本(good-description)拉的是另一头:只会说「通过」、写不出「因为哪一点没问题」的评审器,
|
|
9
|
+
* 面板上就是一句没有理由的判决;所以 pass 除了 verdict 要命中,还必须有 `passed` 理由。
|
|
10
|
+
*
|
|
8
11
|
* 注意:这里**故意不跑机械 P0 复验** —— 那几个坏样本里有两条本身就违反硬规则,
|
|
9
12
|
* 用规则引擎拒掉只能证明规则还活着,证明不了评审器会判断内容。
|
|
10
13
|
*/
|
|
@@ -12,6 +15,7 @@ import * as fs from "node:fs";
|
|
|
12
15
|
import * as path from "node:path";
|
|
13
16
|
import { fileURLToPath } from "node:url";
|
|
14
17
|
import { loadHardRules, type CompiledRules } from "./hard-rules.ts";
|
|
18
|
+
import type { ReviewResult } from "./review-post.ts";
|
|
15
19
|
import { buildReviewPrompt } from "./review-prompt.ts";
|
|
16
20
|
import { reviewWithRetry, type ReviewSpawn } from "./review-run.ts";
|
|
17
21
|
|
|
@@ -62,7 +66,7 @@ function readText(file: string): string | undefined {
|
|
|
62
66
|
}
|
|
63
67
|
}
|
|
64
68
|
|
|
65
|
-
/**
|
|
69
|
+
/** 读四个 fixture;缺文件 / 坏 JSON 的目录直接跳过(不猜、不补) */
|
|
66
70
|
export function loadFixtures(dir: string, log: (msg: string) => void = () => {}): SelftestFixture[] {
|
|
67
71
|
let entries: string[];
|
|
68
72
|
try {
|
|
@@ -134,7 +138,17 @@ export interface SelftestOptions {
|
|
|
134
138
|
log?: (msg: string) => void;
|
|
135
139
|
}
|
|
136
140
|
|
|
137
|
-
/**
|
|
141
|
+
/**
|
|
142
|
+
* 单样本判定:verdict 命中期望算合格;正样本(expected=pass)额外要求写出 passed 理由。
|
|
143
|
+
* 只判「通过」不判「为什么通过」,面板上仍然是一句没有理由的判决。
|
|
144
|
+
*/
|
|
145
|
+
function judgeResult(expected: SelftestExpectation, result: ReviewResult): boolean {
|
|
146
|
+
const verdictOk = result.status === "ok" && result.verdict === expected;
|
|
147
|
+
if (!verdictOk) return false;
|
|
148
|
+
return expected !== "pass" || result.passed.length > 0;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** 依次跑完全部样本;返回报告并写 `selftest.json`(写失败只记日志) */
|
|
138
152
|
export async function runSelftest(options: SelftestOptions): Promise<SelftestReport> {
|
|
139
153
|
const log = options.log ?? (() => {});
|
|
140
154
|
const now = options.now ?? Date.now;
|
|
@@ -161,8 +175,11 @@ export async function runSelftest(options: SelftestOptions): Promise<SelftestRep
|
|
|
161
175
|
now,
|
|
162
176
|
log,
|
|
163
177
|
});
|
|
164
|
-
|
|
165
|
-
|
|
178
|
+
// 正样本判 pass 但没给理由时,报告里必须看得出「差在哪」,不能只写期望 pass 实际 pass
|
|
179
|
+
// (缺 passed 会被 postProcessReview 判 invalid,但 verdict 仍写着 pass,所以不能靠 status 判断)
|
|
180
|
+
const missingPassed = outcome.result.verdict === "pass" && outcome.result.passed.length === 0;
|
|
181
|
+
const actual = missingPassed ? "pass(缺 passed 理由)" : (outcome.result.verdict ?? outcome.result.status);
|
|
182
|
+
const ok = judgeResult(fixture.expected, outcome.result);
|
|
166
183
|
results.push({
|
|
167
184
|
name: fixture.name,
|
|
168
185
|
expected: fixture.expected,
|
|
@@ -13,7 +13,7 @@ import { acquireLockSync, releaseLockSync, writeOwnerSync } from "./lock.ts";
|
|
|
13
13
|
import { buildPrompt, readPromptTemplateSync } from "./prompt.ts";
|
|
14
14
|
import { spawnReviewSupervisor, type SupervisorSpawn } from "./review-spawn.ts";
|
|
15
15
|
import { guardBusy } from "./single-flight.ts";
|
|
16
|
-
import { CHILD_LOG, uniqueRunId, sessionHashOf, resolvePiBin, spawnDetached, type SpawnLike } from "./spawn-plan.ts";
|
|
16
|
+
import { CHILD_LOG, isExecutableFile, uniqueRunId, sessionHashOf, resolvePiBin, spawnDetached, type SpawnLike } from "./spawn-plan.ts";
|
|
17
17
|
import { writeRunRecordSync, type RunRecord } from "./runs.ts";
|
|
18
18
|
import { writeSessionIndexSync } from "./session-index.ts";
|
|
19
19
|
import type { TelemetryWriter } from "./telemetry.ts";
|
|
@@ -46,6 +46,8 @@ export interface ShutdownDeps {
|
|
|
46
46
|
supervisorSpawnFn?: SupervisorSpawn;
|
|
47
47
|
/** pid 存活判定(全局单飞闸用;单测注入,测试里不起真子进程) */
|
|
48
48
|
alive?: (pid: number) => boolean;
|
|
49
|
+
/** pi 可执行文件判定(默认查真磁盘;单测注入避开机器差异) */
|
|
50
|
+
piBinExists?: (candidate: string) => boolean;
|
|
49
51
|
log: (msg: string) => void;
|
|
50
52
|
}
|
|
51
53
|
|
|
@@ -88,6 +90,23 @@ export function flushPendingSync(item: PendingRounds, deps: ShutdownDeps, now: n
|
|
|
88
90
|
});
|
|
89
91
|
if (occupant) return "skipped";
|
|
90
92
|
|
|
93
|
+
// 退出补发同样先验 pi 可执行:spawn 的 ENOENT 是异步 emit,没有 listener 就直接 uncaughtException,
|
|
94
|
+
// 那会把「正常退出」变成「崩溃退出」(2026-11-09 写死路径事故就是这条路径炸的)
|
|
95
|
+
const piBin = resolvePiBin(deps.env);
|
|
96
|
+
if (!(deps.piBinExists ?? isExecutableFile)(piBin)) {
|
|
97
|
+
deps.telemetry.appendSync({
|
|
98
|
+
kind: "skipped",
|
|
99
|
+
ts: now,
|
|
100
|
+
sessionId: item.sessionId,
|
|
101
|
+
why: "pi-not-found",
|
|
102
|
+
trigger: "shutdown",
|
|
103
|
+
ownerPid: null,
|
|
104
|
+
rounds: item.rounds,
|
|
105
|
+
});
|
|
106
|
+
deps.log(`[skill-evolve] 退出补发放弃:找不到可执行的 pi(${piBin})`);
|
|
107
|
+
return "skipped";
|
|
108
|
+
}
|
|
109
|
+
|
|
91
110
|
const runId = uniqueRunId(deps.evolutionDir, item.sessionId, now);
|
|
92
111
|
const runDir = path.join(deps.evolutionDir, "runs", runId);
|
|
93
112
|
const lock = acquireLockSync({
|
|
@@ -150,12 +169,16 @@ export function flushPendingSync(item: PendingRounds, deps: ShutdownDeps, now: n
|
|
|
150
169
|
dispatchedAt: now,
|
|
151
170
|
coverage: record.coverage,
|
|
152
171
|
});
|
|
153
|
-
const child = spawnDetached(deps.spawnFn,
|
|
172
|
+
const child = spawnDetached(deps.spawnFn, piBin, ["-p", "--no-session", "-ns", prompt], {
|
|
154
173
|
cwd: deps.cwd,
|
|
155
174
|
env: { ...deps.env, SKILL_EVOLVE_CHILD: "1" },
|
|
156
175
|
// 退出前补发这条路径最容易“静默失败”:日志落盘才知道子进程到底起没起来
|
|
157
176
|
logFile: path.join(runDir, CHILD_LOG),
|
|
158
177
|
});
|
|
178
|
+
// 异步 emit 的 error 没 listener 会直接炸进程;主进程马上要退出,这里只求不把退出变成崩溃
|
|
179
|
+
child.once("error", (error) => {
|
|
180
|
+
deps.log(`[skill-evolve] 退出补发子进程启动失败:${String(error)}`);
|
|
181
|
+
});
|
|
159
182
|
childPid = child.pid ?? null;
|
|
160
183
|
|
|
161
184
|
record.pid = childPid;
|
|
@@ -6,10 +6,38 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { createHash } from "node:crypto";
|
|
8
8
|
import * as fs from "node:fs";
|
|
9
|
+
import * as os from "node:os";
|
|
9
10
|
import * as path from "node:path";
|
|
10
11
|
|
|
11
|
-
/**
|
|
12
|
-
|
|
12
|
+
/**
|
|
13
|
+
* 兜底 pi 路径:按**当前用户 home** 拼,绝不写死某个人的家目录。
|
|
14
|
+
*
|
|
15
|
+
* 2026-11-09 事故:v0.1.10 把 Linux 家目录 `/home/suveng/.pi/agent/bin/pi` 写成常量,
|
|
16
|
+
* 换到 macOS 上 spawn 直接 ENOENT;而 spawn 的 error 事件是**异步** emit 的,
|
|
17
|
+
* 那一刻还没人挂 listener,于是 uncaughtException 把整个 pi 进程打死。
|
|
18
|
+
*/
|
|
19
|
+
export const DEFAULT_PI_BIN = path.join(os.homedir(), ".pi", "agent", "bin", "pi");
|
|
20
|
+
|
|
21
|
+
/** 可执行文件判定:不存在 / 无执行权限 / 是目录都算「不可用」,一律不抛 */
|
|
22
|
+
export function isExecutableFile(candidate: string): boolean {
|
|
23
|
+
try {
|
|
24
|
+
if (!fs.statSync(candidate).isFile()) return false;
|
|
25
|
+
fs.accessSync(candidate, fs.constants.X_OK);
|
|
26
|
+
return true;
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 在 PATH 里找 pi:GUI / 非交互启动的 pi 常常带着不完整的 PATH,所以只作第二选择 */
|
|
33
|
+
function findOnPath(name: string, env: NodeJS.ProcessEnv, exists: (candidate: string) => boolean): string | null {
|
|
34
|
+
const dirs = (env.PATH ?? "").split(path.delimiter).filter(Boolean);
|
|
35
|
+
for (const dir of dirs) {
|
|
36
|
+
const candidate = path.join(dir, name);
|
|
37
|
+
if (exists(candidate)) return candidate;
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
13
41
|
|
|
14
42
|
export interface SpawnedChild {
|
|
15
43
|
pid?: number;
|
|
@@ -20,8 +48,23 @@ export interface SpawnedChild {
|
|
|
20
48
|
|
|
21
49
|
export type SpawnLike = (command: string, args: string[], options: Record<string, unknown>) => SpawnedChild;
|
|
22
50
|
|
|
23
|
-
export
|
|
24
|
-
|
|
51
|
+
export interface ResolvePiBinOptions {
|
|
52
|
+
/** 单测注入的存在性判定(默认查真磁盘) */
|
|
53
|
+
exists?: (candidate: string) => boolean;
|
|
54
|
+
/** 可执行文件名(Windows 上是 pi.cmd / pi.exe,默认 "pi") */
|
|
55
|
+
name?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* pi 路径解析顺序:显式环境变量 → home 下的 `.pi/agent/bin/pi` → PATH 里的 `pi` → 兜底常量。
|
|
60
|
+
* 返回的路径**不保证存在**(调用方要用 isExecutableFile 先验一次,别直接 spawn)。
|
|
61
|
+
*/
|
|
62
|
+
export function resolvePiBin(env: NodeJS.ProcessEnv = process.env, options: ResolvePiBinOptions = {}): string {
|
|
63
|
+
const explicit = env.SKILL_EVOLVE_PI_BIN || env.PI_SUBAGENT_PI_BINARY;
|
|
64
|
+
if (explicit) return explicit;
|
|
65
|
+
const exists = options.exists ?? isExecutableFile;
|
|
66
|
+
if (exists(DEFAULT_PI_BIN)) return DEFAULT_PI_BIN;
|
|
67
|
+
return findOnPath(options.name ?? "pi", env, exists) ?? DEFAULT_PI_BIN;
|
|
25
68
|
}
|
|
26
69
|
|
|
27
70
|
/** 复盘子进程的日志文件名(落在 run 目录下,与 run.json 同级) */
|
|
@@ -10,12 +10,16 @@ import * as path from "node:path";
|
|
|
10
10
|
import { readVerify } from "../apply.ts";
|
|
11
11
|
import { readProposalSummary } from "../proposal.ts";
|
|
12
12
|
import type { QueueItem } from "../queue.ts";
|
|
13
|
+
import type { RunStatus } from "../runs.ts";
|
|
13
14
|
import { asNumber, asText, readJson } from "./json-read.ts";
|
|
14
15
|
import { readRunSession, type SessionRef } from "./run-session.ts";
|
|
15
16
|
|
|
16
17
|
/** 已采纳列表上限:与 DONE_LIMIT 同量级 —— 网页看近期,不是审计日志 */
|
|
17
18
|
export const APPLIED_LIMIT = 50;
|
|
18
19
|
|
|
20
|
+
/** 已采纳页收的 run 状态(导出给覆盖性测试):只有队列 item.status === "applied" 的条目 */
|
|
21
|
+
export const APPLIED_RUN_STATUSES: readonly RunStatus[] = ["applied"];
|
|
22
|
+
|
|
19
23
|
/** 一个已采纳条目:技能 · 提案类型 · +N/-M · 采纳时间 · 五闸是否全 ok */
|
|
20
24
|
export interface AppliedItem {
|
|
21
25
|
runId: string;
|
|
@@ -43,7 +47,8 @@ export interface AppliedItem {
|
|
|
43
47
|
export function buildApplied(items: readonly QueueItem[], evolutionDir: string): AppliedItem[] {
|
|
44
48
|
const rows: AppliedItem[] = [];
|
|
45
49
|
for (const item of items) {
|
|
46
|
-
|
|
50
|
+
// 判据用清单本体而不是写死 "applied":清单改了代码就跟着改(覆盖性测试读的也是这份)
|
|
51
|
+
if (!APPLIED_RUN_STATUSES.some((status) => status === item.status)) continue;
|
|
47
52
|
const runDir = path.join(evolutionDir, "runs", item.runId);
|
|
48
53
|
const proposal = readProposalSummary(runDir);
|
|
49
54
|
const verify = readVerify(runDir);
|
|
@@ -10,9 +10,16 @@
|
|
|
10
10
|
import * as path from "node:path";
|
|
11
11
|
import type { QueueItem } from "../queue.ts";
|
|
12
12
|
import { readProposalSummary } from "../proposal.ts";
|
|
13
|
+
import type { RunStatus } from "../runs.ts";
|
|
13
14
|
import { asNumber, asText, readJson } from "./json-read.ts";
|
|
14
15
|
import { readRunSession, type SessionRef } from "./run-session.ts";
|
|
15
16
|
|
|
17
|
+
/**
|
|
18
|
+
* 待审批页对应的 run 状态(导出给覆盖性测试):条目靠队列条件选(pending + approval 级),
|
|
19
|
+
* 能进队列就说明评审已过,所以这里只能是 reviewed。
|
|
20
|
+
*/
|
|
21
|
+
export const APPROVALS_RUN_STATUSES: readonly RunStatus[] = ["reviewed"];
|
|
22
|
+
|
|
16
23
|
export interface ApprovalItem {
|
|
17
24
|
runId: string;
|
|
18
25
|
/** 这条 run 是哪个会话跑出来的:行上「会话」列要用(缺 = NO_SESSION) */
|