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.
Files changed (157) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +81 -2
  3. package/extensions/skill-evolve/fixtures/selftest/delete-ban/after.md +10 -0
  4. package/extensions/skill-evolve/fixtures/selftest/delete-ban/change.diff +8 -0
  5. package/extensions/skill-evolve/fixtures/selftest/delete-ban/fixture.json +6 -0
  6. package/extensions/skill-evolve/fixtures/selftest/rg-to-find/after.md +9 -0
  7. package/extensions/skill-evolve/fixtures/selftest/rg-to-find/change.diff +8 -0
  8. package/extensions/skill-evolve/fixtures/selftest/rg-to-find/fixture.json +6 -0
  9. package/extensions/skill-evolve/fixtures/selftest/weak-description/after.md +8 -0
  10. package/extensions/skill-evolve/fixtures/selftest/weak-description/change.diff +8 -0
  11. package/extensions/skill-evolve/fixtures/selftest/weak-description/fixture.json +6 -0
  12. package/extensions/skill-evolve/index.ts +300 -0
  13. package/extensions/skill-evolve/lib/apply-gates.ts +204 -0
  14. package/extensions/skill-evolve/lib/apply-targets.ts +90 -0
  15. package/extensions/skill-evolve/lib/apply.ts +141 -0
  16. package/extensions/skill-evolve/lib/attribution.ts +138 -0
  17. package/extensions/skill-evolve/lib/audit.ts +221 -0
  18. package/extensions/skill-evolve/lib/baseline.ts +176 -0
  19. package/extensions/skill-evolve/lib/child-env.ts +10 -0
  20. package/extensions/skill-evolve/lib/classify-action.ts +161 -0
  21. package/extensions/skill-evolve/lib/command.ts +100 -0
  22. package/extensions/skill-evolve/lib/config-command.ts +38 -0
  23. package/extensions/skill-evolve/lib/config.ts +194 -0
  24. package/extensions/skill-evolve/lib/detect.ts +62 -0
  25. package/extensions/skill-evolve/lib/diff-apply.ts +90 -0
  26. package/extensions/skill-evolve/lib/diff-gen.ts +78 -0
  27. package/extensions/skill-evolve/lib/dispatch.ts +300 -0
  28. package/extensions/skill-evolve/lib/evolution-apply.ts +265 -0
  29. package/extensions/skill-evolve/lib/evolution-model.ts +72 -0
  30. package/extensions/skill-evolve/lib/evolution-observe.ts +82 -0
  31. package/extensions/skill-evolve/lib/evolution-views.ts +89 -0
  32. package/extensions/skill-evolve/lib/evolution.ts +289 -0
  33. package/extensions/skill-evolve/lib/flags.ts +44 -0
  34. package/extensions/skill-evolve/lib/gate0.ts +78 -0
  35. package/extensions/skill-evolve/lib/hard-rules.ts +167 -0
  36. package/extensions/skill-evolve/lib/lock.ts +215 -0
  37. package/extensions/skill-evolve/lib/logfile.ts +52 -0
  38. package/extensions/skill-evolve/lib/mechanical-report.ts +95 -0
  39. package/extensions/skill-evolve/lib/mechanical.ts +136 -0
  40. package/extensions/skill-evolve/lib/metrics.ts +172 -0
  41. package/extensions/skill-evolve/lib/model-config.ts +95 -0
  42. package/extensions/skill-evolve/lib/notify.ts +132 -0
  43. package/extensions/skill-evolve/lib/panel.ts +174 -0
  44. package/extensions/skill-evolve/lib/pending.ts +34 -0
  45. package/extensions/skill-evolve/lib/proc.ts +65 -0
  46. package/extensions/skill-evolve/lib/prompt.ts +80 -0
  47. package/extensions/skill-evolve/lib/proposal.ts +227 -0
  48. package/extensions/skill-evolve/lib/prune-plan.ts +62 -0
  49. package/extensions/skill-evolve/lib/queue.ts +136 -0
  50. package/extensions/skill-evolve/lib/replay-fixture.ts +80 -0
  51. package/extensions/skill-evolve/lib/replay.ts +246 -0
  52. package/extensions/skill-evolve/lib/report.ts +181 -0
  53. package/extensions/skill-evolve/lib/review-post.ts +101 -0
  54. package/extensions/skill-evolve/lib/review-prompt.ts +82 -0
  55. package/extensions/skill-evolve/lib/review-record.ts +63 -0
  56. package/extensions/skill-evolve/lib/review-run.ts +131 -0
  57. package/extensions/skill-evolve/lib/review-spawn.ts +83 -0
  58. package/extensions/skill-evolve/lib/review-supervisor.ts +267 -0
  59. package/extensions/skill-evolve/lib/round.ts +116 -0
  60. package/extensions/skill-evolve/lib/run-watch.ts +34 -0
  61. package/extensions/skill-evolve/lib/runs.ts +208 -0
  62. package/extensions/skill-evolve/lib/selftest.ts +201 -0
  63. package/extensions/skill-evolve/lib/session-index.ts +145 -0
  64. package/extensions/skill-evolve/lib/shutdown-dispatch.ts +177 -0
  65. package/extensions/skill-evolve/lib/single-flight.ts +111 -0
  66. package/extensions/skill-evolve/lib/spawn-plan.ts +103 -0
  67. package/extensions/skill-evolve/lib/stale.ts +217 -0
  68. package/extensions/skill-evolve/lib/telemetry.ts +205 -0
  69. package/extensions/skill-evolve/lib/ui-gate.ts +34 -0
  70. package/extensions/skill-evolve/lib/web/actions.ts +299 -0
  71. package/extensions/skill-evolve/lib/web/activity.ts +57 -0
  72. package/extensions/skill-evolve/lib/web/applied.ts +64 -0
  73. package/extensions/skill-evolve/lib/web/approvals.ts +58 -0
  74. package/extensions/skill-evolve/lib/web/audit-log.ts +115 -0
  75. package/extensions/skill-evolve/lib/web/autostart.ts +177 -0
  76. package/extensions/skill-evolve/lib/web/data.ts +298 -0
  77. package/extensions/skill-evolve/lib/web/diff-lines.ts +141 -0
  78. package/extensions/skill-evolve/lib/web/filter.ts +203 -0
  79. package/extensions/skill-evolve/lib/web/http.ts +68 -0
  80. package/extensions/skill-evolve/lib/web/json-read.ts +33 -0
  81. package/extensions/skill-evolve/lib/web/panel-pid.ts +99 -0
  82. package/extensions/skill-evolve/lib/web/render-controls.ts +156 -0
  83. package/extensions/skill-evolve/lib/web/render-rows.ts +252 -0
  84. package/extensions/skill-evolve/lib/web/render-run.ts +188 -0
  85. package/extensions/skill-evolve/lib/web/render-skills.ts +164 -0
  86. package/extensions/skill-evolve/lib/web/render.ts +136 -0
  87. package/extensions/skill-evolve/lib/web/reports.ts +160 -0
  88. package/extensions/skill-evolve/lib/web/run-detail.ts +234 -0
  89. package/extensions/skill-evolve/lib/web/run-path.ts +21 -0
  90. package/extensions/skill-evolve/lib/web/server.ts +268 -0
  91. package/extensions/skill-evolve/lib/web/shell.ts +87 -0
  92. package/extensions/skill-evolve/lib/web/skills.ts +202 -0
  93. package/extensions/skill-evolve/lib/web/style.ts +107 -0
  94. package/extensions/skill-evolve/lib/web/text.ts +52 -0
  95. package/extensions/skill-evolve/lib/web/trash.ts +36 -0
  96. package/extensions/skill-evolve/lib/weekly.ts +285 -0
  97. package/extensions/skill-evolve/prompts//345/244/215/347/233/230.md +172 -0
  98. package/extensions/skill-evolve/scripts/replay.ts +93 -0
  99. package/extensions/skill-evolve/test/apply.test.ts +278 -0
  100. package/extensions/skill-evolve/test/approve.test.ts +248 -0
  101. package/extensions/skill-evolve/test/attribution.test.ts +133 -0
  102. package/extensions/skill-evolve/test/audit.test.ts +151 -0
  103. package/extensions/skill-evolve/test/baseline.test.ts +132 -0
  104. package/extensions/skill-evolve/test/classify-action.test.ts +133 -0
  105. package/extensions/skill-evolve/test/command.test.ts +267 -0
  106. package/extensions/skill-evolve/test/config-command.test.ts +73 -0
  107. package/extensions/skill-evolve/test/config.test.ts +154 -0
  108. package/extensions/skill-evolve/test/detect.test.ts +71 -0
  109. package/extensions/skill-evolve/test/diff-apply.test.ts +46 -0
  110. package/extensions/skill-evolve/test/dispatch-busy.test.ts +223 -0
  111. package/extensions/skill-evolve/test/dispatch-supervisor.test.ts +88 -0
  112. package/extensions/skill-evolve/test/dispatch.test.ts +306 -0
  113. package/extensions/skill-evolve/test/evolution.test.ts +378 -0
  114. package/extensions/skill-evolve/test/fake-review-spawn.ts +60 -0
  115. package/extensions/skill-evolve/test/hard-rules.test.ts +123 -0
  116. package/extensions/skill-evolve/test/lock.test.ts +138 -0
  117. package/extensions/skill-evolve/test/logfile.test.ts +85 -0
  118. package/extensions/skill-evolve/test/mechanical-wiring.test.ts +217 -0
  119. package/extensions/skill-evolve/test/metrics.test.ts +129 -0
  120. package/extensions/skill-evolve/test/model-config.test.ts +84 -0
  121. package/extensions/skill-evolve/test/notify.test.ts +133 -0
  122. package/extensions/skill-evolve/test/observe.test.ts +100 -0
  123. package/extensions/skill-evolve/test/panel-pid.test.ts +84 -0
  124. package/extensions/skill-evolve/test/panel.test.ts +115 -0
  125. package/extensions/skill-evolve/test/proposal.test.ts +159 -0
  126. package/extensions/skill-evolve/test/queue.test.ts +110 -0
  127. package/extensions/skill-evolve/test/reject.test.ts +134 -0
  128. package/extensions/skill-evolve/test/replay.test.ts +111 -0
  129. package/extensions/skill-evolve/test/report.test.ts +66 -0
  130. package/extensions/skill-evolve/test/review-post.test.ts +92 -0
  131. package/extensions/skill-evolve/test/review-prompt.test.ts +105 -0
  132. package/extensions/skill-evolve/test/review-spawn.test.ts +81 -0
  133. package/extensions/skill-evolve/test/review-supervisor.test.ts +243 -0
  134. package/extensions/skill-evolve/test/selftest.test.ts +113 -0
  135. package/extensions/skill-evolve/test/session-index.test.ts +128 -0
  136. package/extensions/skill-evolve/test/single-flight.test.ts +122 -0
  137. package/extensions/skill-evolve/test/stale.test.ts +272 -0
  138. package/extensions/skill-evolve/test/telemetry.test.ts +145 -0
  139. package/extensions/skill-evolve/test/ui-gate.test.ts +77 -0
  140. package/extensions/skill-evolve/test/web-actions.test.ts +262 -0
  141. package/extensions/skill-evolve/test/web-applied.test.ts +100 -0
  142. package/extensions/skill-evolve/test/web-audit.test.ts +87 -0
  143. package/extensions/skill-evolve/test/web-autostart.test.ts +268 -0
  144. package/extensions/skill-evolve/test/web-data.test.ts +420 -0
  145. package/extensions/skill-evolve/test/web-diff-lines.test.ts +155 -0
  146. package/extensions/skill-evolve/test/web-filter.test.ts +212 -0
  147. package/extensions/skill-evolve/test/web-panel-pid.test.ts +97 -0
  148. package/extensions/skill-evolve/test/web-render-run.test.ts +132 -0
  149. package/extensions/skill-evolve/test/web-render-skills.test.ts +134 -0
  150. package/extensions/skill-evolve/test/web-render.test.ts +300 -0
  151. package/extensions/skill-evolve/test/web-run-detail.test.ts +256 -0
  152. package/extensions/skill-evolve/test/web-server-views.test.ts +113 -0
  153. package/extensions/skill-evolve/test/web-server.test.ts +465 -0
  154. package/extensions/skill-evolve/test/web-shell.test.ts +67 -0
  155. package/extensions/skill-evolve/test/web-skills.test.ts +239 -0
  156. package/extensions/skill-evolve/test/weekly.test.ts +123 -0
  157. package/package.json +34 -4
@@ -0,0 +1,246 @@
1
+ /**
2
+ * 端到端复演(设计稿 15 章 M6 验收硬指标):一条假技能从发现走到复验,六段状态可断言。
3
+ *
4
+ * 只允许在临时工作区跑(调用方负责建目录),因为这条链路会真的写 `.pi/skills/` 下的技能文件。
5
+ * 评审可用 `fakeReview` 注入假结论(省 token);真跑走 `reviewSpawn`,其余环节一律真写盘。
6
+ */
7
+ import * as fs from "node:fs";
8
+ import * as path from "node:path";
9
+ import { createApplyTools } from "./evolution-apply.ts";
10
+ import { captureGate0, saveGate0, type GitRunner } from "./gate0.ts";
11
+ import { loadHardRules, type CompiledRule } from "./hard-rules.ts";
12
+ import { createGitRunner } from "./proc.ts";
13
+ import { validateProposal } from "./proposal.ts";
14
+ import { postProcessReview } from "./review-post.ts";
15
+ import { buildReviewPrompt } from "./review-prompt.ts";
16
+ import { runReviewChild, type ReviewSpawn } from "./review-run.ts";
17
+ import { updateRunStatus, writeRunRecord, type RunStatus } from "./runs.ts";
18
+ import { buildSessionIndex } from "./session-index.ts";
19
+ import { directQueue, type MutationQueue } from "./telemetry.ts";
20
+ import { FAKE_REVIEW_TEXT, REPLAY_AFTER, REPLAY_BEFORE, REPLAY_DIFF, fakeSessionText } from "./replay-fixture.ts";
21
+
22
+ export const REPLAY_STAGES = ["discover", "propose", "review", "approve", "apply", "verify"] as const;
23
+ export type ReplayStage = (typeof REPLAY_STAGES)[number];
24
+
25
+ const STAGE_LABEL: Record<ReplayStage, string> = {
26
+ discover: "发现",
27
+ propose: "提案",
28
+ review: "评审",
29
+ approve: "人批",
30
+ apply: "采纳",
31
+ verify: "复验",
32
+ };
33
+
34
+ export interface ReplayOptions {
35
+ cwd: string;
36
+ evolutionDir: string;
37
+ /** true = 用内置假评审结论(不烧 token);false = 真起 `pi -p` */
38
+ fakeReview: boolean;
39
+ skillName?: string;
40
+ now?: () => number;
41
+ log?: (msg: string) => void;
42
+ queue?: MutationQueue;
43
+ git?: GitRunner;
44
+ reviewSpawn?: ReviewSpawn;
45
+ reviewModel?: string;
46
+ reviewTimeoutMs?: number;
47
+ fakeReviewText?: string;
48
+ rulesFile?: string;
49
+ }
50
+
51
+ export interface ReplayOutcome {
52
+ ok: boolean;
53
+ runId: string;
54
+ /** 六段各自的结果(人话) */
55
+ stages: Record<ReplayStage, string>;
56
+ /** run.json 的 status 变迁序列 */
57
+ statuses: string[];
58
+ failures: string[];
59
+ lines: string[];
60
+ }
61
+
62
+ function fakeGit(): GitRunner {
63
+ return () => ({ code: 0, out: "" });
64
+ }
65
+
66
+ async function writeText(queue: MutationQueue, file: string, text: string): Promise<void> {
67
+ await queue(file, async () => {
68
+ await fs.promises.mkdir(path.dirname(file), { recursive: true });
69
+ await fs.promises.writeFile(file, text, "utf8");
70
+ });
71
+ }
72
+
73
+ function readJson<T>(file: string): T | null {
74
+ try {
75
+ return JSON.parse(fs.readFileSync(file, "utf8")) as T;
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ export async function runReplay(options: ReplayOptions): Promise<ReplayOutcome> {
82
+ const now = options.now ?? (() => Date.now());
83
+ const log = options.log ?? (() => {});
84
+ const queue = options.queue ?? directQueue;
85
+ const git = options.git ?? createGitRunner();
86
+ const skill = options.skillName ?? "replay-demo";
87
+ const started = now();
88
+ const runId = `replay-${started.toString(36)}`;
89
+ const evolutionDir = options.evolutionDir;
90
+ const runDir = path.join(evolutionDir, "runs", runId);
91
+ const skillFile = path.join(options.cwd, ".pi", "skills", skill, "SKILL.md");
92
+
93
+ const stages = Object.fromEntries(REPLAY_STAGES.map((stage) => [stage, "未执行"])) as Record<ReplayStage, string>;
94
+ const statuses: string[] = [];
95
+ const failures: string[] = [];
96
+
97
+ const recordStatus = (): void => {
98
+ const record = readJson<{ status?: string }>(path.join(runDir, "run.json"));
99
+ if (record?.status) statuses.push(record.status);
100
+ };
101
+ const fail = (stage: ReplayStage, why: string): void => {
102
+ stages[stage] = `失败:${why}`;
103
+ failures.push(`${stage}:${why}`);
104
+ };
105
+
106
+ // ── 1. 发现:技能在盘上 + 会话索引 + run.json(dispatched → analyzed)
107
+ await writeText(queue, skillFile, REPLAY_BEFORE);
108
+ const sessionFile = path.join(evolutionDir, "session", "session.jsonl");
109
+ await writeText(queue, sessionFile, fakeSessionText(options.cwd, started));
110
+ const index = buildSessionIndex(fs.readFileSync(sessionFile, "utf8"));
111
+
112
+ await writeRunRecord(
113
+ runDir,
114
+ {
115
+ runId,
116
+ status: "dispatched" as RunStatus,
117
+ trigger: "manual",
118
+ sessionId: "replay-session",
119
+ sessionHash: "replay",
120
+ sessionFile,
121
+ evidenceMode: "session+telemetry",
122
+ dispatchedAt: started,
123
+ dispatchedAtIso: new Date(started).toISOString(),
124
+ pid: process.pid,
125
+ cwd: options.cwd,
126
+ skills: [skill],
127
+ stages: [],
128
+ artifacts: {},
129
+ },
130
+ queue,
131
+ );
132
+ recordStatus();
133
+ await writeText(queue, path.join(runDir, "00-session", "index.json"), `${JSON.stringify(index, null, 2)}\n`);
134
+ await updateRunStatus(evolutionDir, runId, "analyzed", queue);
135
+ recordStatus();
136
+ stages.discover = `技能 ${skill} · 会话索引 ${index.stats.lines} 行 · run ${runId}`;
137
+
138
+ // ── 2. 提案:写 02-proposal.{json,diff}
139
+ const diff = REPLAY_DIFF;
140
+ await writeText(
141
+ queue,
142
+ path.join(runDir, "02-proposal.json"),
143
+ `${JSON.stringify({ type: "improve", skill, diffFile: "02-proposal.diff", rationale: "复演:补一条索引优先" }, null, 2)}\n`,
144
+ );
145
+ await writeText(queue, path.join(runDir, "02-proposal.diff"), diff);
146
+ const proposal = validateProposal(runDir);
147
+ if (proposal.status !== "ok") fail("propose", `提案校验 ${proposal.status}(${proposal.detail ?? "无详情"})`);
148
+ else {
149
+ await updateRunStatus(evolutionDir, runId, "proposed", queue, { proposal });
150
+ recordStatus();
151
+ stages.propose = `improve ${skill}(diff ${diff.split("\n").length} 行)`;
152
+ }
153
+
154
+ // ── 3. 评审
155
+ if (failures.length === 0) {
156
+ let text: string;
157
+ if (options.fakeReview) text = options.fakeReviewText ?? FAKE_REVIEW_TEXT;
158
+ else {
159
+ const compiled = loadHardRules(options.rulesFile ?? path.join(evolutionDir, "rules", "hard-rules.json"));
160
+ for (const invalid of compiled.invalid) log(`[复演] 硬规则不可用:${invalid}`);
161
+ const rules: readonly CompiledRule[] = compiled.compiled;
162
+ const prompt = buildReviewPrompt({ skillName: skill, skillText: REPLAY_AFTER, diffApplied: true, diff, hardRules: rules });
163
+ const outcome = await runReviewChild({
164
+ prompt,
165
+ cwd: options.cwd,
166
+ ...(options.reviewModel ? { model: options.reviewModel } : {}),
167
+ ...(options.reviewSpawn ? { spawnFn: options.reviewSpawn } : {}),
168
+ ...(options.reviewTimeoutMs ? { timeoutMs: options.reviewTimeoutMs } : {}),
169
+ log,
170
+ });
171
+ if (outcome.status !== "text") {
172
+ fail("review", `评审子进程 ${outcome.status}:${outcome.detail ?? "无详情"}`);
173
+ text = "";
174
+ } else text = outcome.text;
175
+ }
176
+ if (failures.length === 0) {
177
+ const reviewed = postProcessReview(text);
178
+ const verdict = (reviewed.detail && reviewed.status === "invalid") ? `invalid(${reviewed.detail})` : String(reviewed.verdict ?? "unknown");
179
+ const reviewRecord = {
180
+ status: reviewed.status === "ok" && reviewed.verdict === "pass" ? "reviewed" : "rejected",
181
+ verdict: reviewed.verdict ?? null,
182
+ blocking: reviewed.blocking ?? [],
183
+ notes: reviewed.notes ?? [],
184
+ reviewerModel: options.reviewModel ?? "inherit",
185
+ proposerModel: "inherit",
186
+ sameModel: (options.reviewModel ?? "inherit") === "inherit",
187
+ at: now(),
188
+ };
189
+ await writeText(queue, path.join(runDir, "03-review.json"), `${JSON.stringify(reviewRecord, null, 2)}\n`);
190
+ if (reviewRecord.status !== "reviewed") fail("review", `评审未通过:${verdict}`);
191
+ else {
192
+ await updateRunStatus(evolutionDir, runId, "reviewed", queue, { review: reviewRecord });
193
+ recordStatus();
194
+ stages.review = `verdict=${verdict}(${options.fakeReview ? "假评审" : "真评审"})`;
195
+ }
196
+ }
197
+ }
198
+
199
+ // ── 4. 人批:闸0 快照 → 入队 → approve
200
+ if (failures.length === 0) {
201
+ await saveGate0(evolutionDir, captureGate0(git, options.cwd, now()), queue);
202
+ const tools = createApplyTools({
203
+ evolutionDir,
204
+ cwd: options.cwd,
205
+ queue,
206
+ now,
207
+ log,
208
+ git,
209
+ loadDiagnostics: () => [],
210
+ runMdCheck: () => ({ code: 0, output: "复演跳过 md-check(临时目录没有仓库脚本)" }),
211
+ });
212
+ await tools.enqueueReady();
213
+ const text = await tools.approve(runId, false);
214
+ const item = readJson<unknown>(path.join(evolutionDir, "queue.json"));
215
+ const queueText = JSON.stringify(item ?? {});
216
+ if (!queueText.includes('"applied"')) fail("approve", `approve 未落实:${text.split("\n")[0] ?? ""}`);
217
+ else stages.approve = `approve ${runId} → applied(${text.split("\n")[0] ?? ""})`;
218
+ }
219
+
220
+ // ── 5. 采纳:05-applied.diff + 技能文件真的变了
221
+ if (failures.length === 0) {
222
+ const applied = fs.existsSync(path.join(runDir, "05-applied.diff"));
223
+ const text = fs.existsSync(skillFile) ? fs.readFileSync(skillFile, "utf8") : "";
224
+ if (!applied) fail("apply", "05-applied.diff 缺失");
225
+ else if (!text.includes("index.json 再读会话原文")) fail("apply", "技能文件未按提案改动");
226
+ else {
227
+ recordStatus();
228
+ stages.apply = `05-applied.diff 已写,技能文件已更新(${text.length} 字节)`;
229
+ }
230
+ }
231
+
232
+ // ── 6. 复验:06-verify.json 里每道闸都要 ok
233
+ if (failures.length === 0) {
234
+ const verify = readJson<{ status?: string; gates?: { id: string; ok: boolean }[] }>(path.join(runDir, "06-verify.json"));
235
+ if (!verify) fail("verify", "06-verify.json 缺失");
236
+ else {
237
+ const bad = (verify.gates ?? []).filter((gate) => !gate.ok);
238
+ if (verify.status !== "applied") fail("verify", `06-verify.json status=${verify.status ?? "?"}`);
239
+ else if (bad.length > 0) fail("verify", `闸未过:${bad.map((gate) => gate.id).join("、")}`);
240
+ else stages.verify = `${verify.gates?.length ?? 0} 道闸全 ok`;
241
+ }
242
+ }
243
+
244
+ const lines = REPLAY_STAGES.map((stage) => `${STAGE_LABEL[stage]}(${stage}):${stages[stage]}`);
245
+ return { ok: failures.length === 0, runId, stages, statuses, failures, lines };
246
+ }
@@ -0,0 +1,181 @@
1
+ /**
2
+ * `/skill-evolve status|report` 的纯文本渲染(无 IO,序列化口径集中在这里)。
3
+ *
4
+ * 拆出来是为了把 evolution.ts 压在 300 行以内:那边只管读盘与状态,
5
+ * 这里只管「拿到数据怎么排版」,所以本文件能被单测直接喂假数据。
6
+ */
7
+ import type { EvolutionConfig } from "./config.ts";
8
+ import type { DispatchOutcome } from "./dispatch.ts";
9
+ import { flagStateText, TELEMETRY_OFF_WARNING, telemetryOffWhileEnabled } from "./flags.ts";
10
+ import type { ProposalSummary } from "./proposal.ts";
11
+ import type { RunReview, RunSummary } from "./runs.ts";
12
+ import type { StaleRun } from "./stale.ts";
13
+ import { UNTRUSTED_NOTE, type SelftestReport } from "./selftest.ts";
14
+ import type { TelemetrySummary } from "./telemetry.ts";
15
+
16
+ /** 提案摘要:只显示不应用(M3 只生成提案,评审/应用不在本版) */
17
+ function proposalText(proposal: ProposalSummary): string {
18
+ const size = proposal.added + proposal.removed > 0 ? ` +${proposal.added}/-${proposal.removed}` : "";
19
+ return `|提案 ${proposal.type} ${proposal.skill} [${proposal.status}]${size}`;
20
+ }
21
+
22
+ /** 评审摘要:结论 + 建议动作;同模型自评单独标出来,避免被当成独立复核 */
23
+ function reviewText(review: RunReview): string {
24
+ const verdict = review.verdict ? `/${review.verdict}` : "";
25
+ const model = review.sameModel ? "|同模型自评" : "";
26
+ return `|评审 ${review.status}${verdict}(建议 ${review.action ?? "无"}${model})`;
27
+ }
28
+
29
+ function runLine(run: RunSummary): string {
30
+ const extra = `${run.proposal ? proposalText(run.proposal) : ""}${run.review ? reviewText(run.review) : ""}`;
31
+ return ` ${run.runId} ${run.status} ${stamp(run.dispatchedAt)}${extra}`;
32
+ }
33
+
34
+ /** 自检红字:没过就把流水线停用这件事顶到 status 的最前面 */
35
+ function selftestLine(selftest: SelftestReport | undefined): string[] {
36
+ if (!selftest) return [" 自检:还没跑过(/skill-evolve selftest)"];
37
+ if (!selftest.passed) return [` 【红】${UNTRUSTED_NOTE}`];
38
+ return [` 自检:通过(${selftest.atIso})`];
39
+ }
40
+
41
+ /** 时间戳统一按 ISO 输出;null 表示历史 run 缺字段,不要伪造成当前时间 */
42
+ function stamp(ts: number | null): string {
43
+ return ts ? new Date(ts).toISOString() : "时间未知";
44
+ }
45
+
46
+ /** runId 前半段就是会话哈希:状态行只留它,否则一条就占满整行 */
47
+ export function shortRunId(runId: string): string {
48
+ return runId.split("-")[0] ?? runId;
49
+ }
50
+
51
+ /** 超长日志行截断:状态行不能被一行 panic 撞满 */
52
+ function elide(text: string, max: number): string {
53
+ return text.length > max ? `${text.slice(0, max)}…` : text;
54
+ }
55
+
56
+ /**
57
+ * 卡死条目:`卡死 1 条:354df7db(child-exited,日志最后一行:…)`。
58
+ * 行内就要带原因与最后一句日志 —— “复盘失败”四个字本身没法定位问题。
59
+ */
60
+ function staleLine(runs: readonly StaleRun[]): string[] {
61
+ if (runs.length === 0) return [];
62
+ // 同一会话可能派生多条 run(runId 前缀就是会话哈希):撞车时退回完整 runId,免得两条长得一模一样
63
+ const prefixCount = new Map<string, number>();
64
+ for (const run of runs) {
65
+ const key = shortRunId(run.runId);
66
+ prefixCount.set(key, (prefixCount.get(key) ?? 0) + 1);
67
+ }
68
+ const items = runs.map((run) => {
69
+ const short = shortRunId(run.runId);
70
+ const id = (prefixCount.get(short) ?? 0) > 1 ? run.runId : short;
71
+ const tail = run.lastLine === null ? "无日志(child.log 不存在或为空)" : elide(run.lastLine, 80);
72
+ return `${id}(${run.reason},日志最后一行:${tail})`;
73
+ });
74
+ return [` 卡死 ${runs.length} 条:${items.join(",")}`];
75
+ }
76
+
77
+ export interface StatusInput {
78
+ config: EvolutionConfig;
79
+ /** 配置文件路径,让用户知道 on/off 改的是哪个文件 */
80
+ configPath: string;
81
+ telemetryDir: string;
82
+ /** undefined = 锁空闲;null = 锁在但 owner.json 读不出 pid */
83
+ lockPid: number | null | undefined;
84
+ /** 活着的 Web 面板(locks/web.json 且 pid 存活);null/undefined 都表示不显示这一行 */
85
+ webPanel?: { url: string; pid: number } | null;
86
+ runs: RunSummary[];
87
+ /** 已判卡死的 run(lib/stale.ts):有就单列一行,带原因与日志尾部 */
88
+ staleRuns?: StaleRun[];
89
+ selftest?: SelftestReport;
90
+ }
91
+
92
+ /**
93
+ * 开关行:采集与复盘分两段写。两个字段本来就是独立的,合成一句「已开」只会让人以为只有一个总开关;
94
+ * 第四种组合(只开复盘不采集)再补一句警告 —— 归因靠遥测,缺了就不能静默。
95
+ */
96
+ export function switchLine(config: EvolutionConfig): string {
97
+ const hint = config.enabled && config.telemetry ? "/skill-evolve off 全关" : "/skill-evolve on 全开";
98
+ const line = `${flagStateText(config.enabled, config.telemetry)}(${hint};collect / review 单独控制)`;
99
+ return telemetryOffWhileEnabled(config.enabled, config.telemetry) ? `${line} ⚠ ${TELEMETRY_OFF_WARNING}` : line;
100
+ }
101
+
102
+ /** Web 面板一行:只在记录活着时出现,死记录当作没起过(清理动作别照着它动手) */
103
+ function webPanelLine(panel: { url: string; pid: number } | null | undefined): string[] {
104
+ return panel ? [` Web 面板:${panel.url}(pid ${panel.pid})`] : [];
105
+ }
106
+
107
+ export function renderStatus(input: StatusInput): string {
108
+ const { config, lockPid } = input;
109
+ const lockText =
110
+ lockPid === undefined ? "空闲" : `持有中(pid ${lockPid ?? "未知"})`;
111
+ return [
112
+ "技能进化 · 状态",
113
+ ` ${switchLine(config)}`,
114
+ ` 配置:${input.configPath}(不存在即默认全关)· 遥测目录:${input.telemetryDir}`,
115
+ ` 去抖 ${Math.round(config.idleMs / 1000)}s · 单 run 上限 ${Math.round(config.maxRuntimeMs / 60_000)}min · 单会话派生上限 ${config.perSessionCap}`,
116
+ ` 锁:${lockText}`,
117
+ ...webPanelLine(input.webPanel),
118
+ ...selftestLine(input.selftest),
119
+ ...staleLine(input.staleRuns ?? []),
120
+ ...(input.runs.length === 0
121
+ ? [" 最近 run:无"]
122
+ : [" 最近 run:", ...input.runs.map(runLine)]),
123
+ ].join("\n");
124
+ }
125
+
126
+ /** 报告固定的那句免责:加载率只覆盖「读了文件」这条证据链,读数时别当技能有效性的判决 */
127
+ export const LOAD_RATE_NOTE = [
128
+ "注意:加载率低 ≠ 技能没用。只读了 description 就照做、读了文件却没照做,这两类都不在「读文件」这条证据链里;",
129
+ " 结论要配合 runs/<runId>/01-findings.json 一起看。",
130
+ ];
131
+
132
+ export interface ReportInput {
133
+ days: number;
134
+ summary: TelemetrySummary;
135
+ runs: RunSummary[];
136
+ selftest?: SelftestReport;
137
+ }
138
+
139
+ export function renderReport(input: ReportInput): string {
140
+ const { summary, days } = input;
141
+ const missed = summary.missedTop;
142
+ return [
143
+ `技能进化 · 近 ${days} 天报告`,
144
+ ` 曝光:${summary.rounds} 轮 / ${summary.advertised} 次`,
145
+ ` 加载:${summary.loaded} 次(读取失败 ${summary.failed} 次)`,
146
+ ` 加载率:${summary.advertised > 0 ? `${(summary.loadRate * 100).toFixed(1)}%` : "无曝光数据"}`,
147
+ ...(missed.length === 0
148
+ ? [` 未命中:${summary.missed} 次`]
149
+ : [
150
+ ` 未命中:${summary.missed} 次,top${missed.length}:`,
151
+ ...missed.map((item, index) => ` ${index + 1}. ${item.skill} × ${item.count}`),
152
+ ]),
153
+ ...(input.runs.length === 0
154
+ ? [" run:无"]
155
+ : [" run:", ...input.runs.map(runLine)]),
156
+ ...selftestLine(input.selftest),
157
+ ...(input.runs.some((run) => run.proposal)
158
+ ? [" 提案只生成不应用:看 runs/<runId>/02-proposal.json 与 02-proposal.diff(或 candidate/),本版不评审不写入 .pi/skills"]
159
+ : []),
160
+ "",
161
+ ...LOAD_RATE_NOTE,
162
+ ].join("\n");
163
+ }
164
+
165
+ /** 手动 `/skill-evolve run` 的回执:把三种 outcome 翻译成一句人话(CLI 回显用) */
166
+ export function renderManualDispatch(outcome: DispatchOutcome): string {
167
+ if (outcome.status === "dispatched") {
168
+ const { runId, pid, runDir } = outcome.run;
169
+ return [`已派生复盘:${runId}(pid ${pid ?? "未知"})`, ` 目录:${runDir}`].join("\n");
170
+ }
171
+ if (outcome.status === "skipped") {
172
+ if (outcome.why === "disabled") return "技能进化总开关是关的:先 /skill-evolve on";
173
+ if (outcome.why === "lock") return `已有复盘在跑(pid ${outcome.ownerPid ?? "未知"}),本次未派发`;
174
+ if (outcome.why === "selftest-failed") return `必红自检未通过,流水线停用:先 /skill-evolve selftest 看哪条坏样本没被拦住`;
175
+ if (outcome.why === "busy") {
176
+ return `已有子进程在跑(pid ${outcome.ownerPid ?? "未知"}),全局同时只允许一个;本次未派发`;
177
+ }
178
+ return `本次未派发:${outcome.why}`;
179
+ }
180
+ return `本轮已在待复盘区间里(累计 ${outcome.rounds} 轮),静默窗口过后会自动补一次`;
181
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * 评审后处理(设计稿 12.3):结构、证据、一致性由代码判,机械复验再独立跑一遍。
3
+ *
4
+ * 原则是「不信 LLM 自觉」:verdict 只要有一个字段不合规就判 invalid(重评一次仍 invalid 就转人工),
5
+ * pass 但带 blocking 是自相矛盾,直接 invalid;机械复验失败则一票 reject,覆盖 LLM 的 pass。
6
+ */
7
+
8
+ export type ReviewVerdict = "pass" | "reject" | "revise";
9
+
10
+ export interface ReviewEvidence {
11
+ file: string;
12
+ line: number;
13
+ quote: string;
14
+ }
15
+
16
+ export interface ReviewBlocking {
17
+ issue: string;
18
+ evidence: ReviewEvidence;
19
+ }
20
+
21
+ export type ReviewStatus = "ok" | "invalid" | "reject";
22
+
23
+ export interface ReviewResult {
24
+ status: ReviewStatus;
25
+ verdict?: ReviewVerdict;
26
+ blocking: ReviewBlocking[];
27
+ notes?: string;
28
+ /** invalid / reject 的具体原因(机器可读前缀 + 人话) */
29
+ detail?: string;
30
+ }
31
+
32
+ const VERDICTS: readonly ReviewVerdict[] = ["pass", "reject", "revise"];
33
+
34
+ /** 从模型输出里抠 JSON:容忍 ```json 围栏与前后废话,抠不出就当解析失败 */
35
+ export function parseReviewJson(text: string): { json?: Record<string, unknown>; error?: string } {
36
+ const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(text);
37
+ const body = (fenced ? fenced[1] : text).trim();
38
+ const start = body.indexOf("{");
39
+ const end = body.lastIndexOf("}");
40
+ if (start < 0 || end <= start) return { error: `bad-json: 输出里没有 JSON 对象(${body.slice(0, 80)})` };
41
+ try {
42
+ const parsed: unknown = JSON.parse(body.slice(start, end + 1));
43
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return { error: "bad-json: 顶层不是对象" };
44
+ return { json: parsed as Record<string, unknown> };
45
+ } catch (error) {
46
+ return { error: `bad-json: ${(error as Error).message}` };
47
+ }
48
+ }
49
+
50
+ function checkEvidence(raw: unknown): ReviewEvidence | string {
51
+ if (typeof raw !== "object" || raw === null) return "evidence 不是对象";
52
+ const item = raw as Record<string, unknown>;
53
+ const file = typeof item.file === "string" ? item.file.trim() : "";
54
+ if (file.length === 0) return "evidence.file 为空";
55
+ // 行号必须是正整数:字符串 "42"、0、-1、"第 42 行" 一律不算可定位证据
56
+ if (typeof item.line !== "number" || !Number.isInteger(item.line) || item.line <= 0) return "evidence.line 不是正整数";
57
+ const quote = typeof item.quote === "string" ? item.quote.trim() : "";
58
+ if (quote.length === 0) return "evidence.quote 为空";
59
+ return { file, line: item.line, quote };
60
+ }
61
+
62
+ /**
63
+ * 12.3 四步里的前三步(结构 / 证据 / 一致性)+ 可选的机械复验。
64
+ * `recheck` 由调用方注入(读盘 + loadSkillsFromDir),返回错误字符串表示复验失败。
65
+ */
66
+ export function postProcessReview(text: string, opts: { recheck?: () => string | null } = {}): ReviewResult {
67
+ const parsed = parseReviewJson(text);
68
+ if (!parsed.json) return { status: "invalid", blocking: [], detail: parsed.error ?? "bad-json" };
69
+
70
+ const verdict = parsed.json.verdict;
71
+ if (typeof verdict !== "string" || !(VERDICTS as readonly string[]).includes(verdict)) {
72
+ return { status: "invalid", blocking: [], detail: `bad-verdict: ${String(verdict)}` };
73
+ }
74
+
75
+ const rawBlocking = parsed.json.blocking;
76
+ if (rawBlocking !== undefined && !Array.isArray(rawBlocking)) {
77
+ return { status: "invalid", blocking: [], verdict: verdict as ReviewVerdict, detail: "bad-blocking: blocking 不是数组" };
78
+ }
79
+ const blocked: ReviewBlocking[] = [];
80
+ for (const item of (rawBlocking ?? []) as unknown[]) {
81
+ if (typeof item !== "object" || item === null) {
82
+ return { status: "invalid", blocking: [], verdict: verdict as ReviewVerdict, detail: "missing-evidence: blocking 项不是对象" };
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 });
90
+ }
91
+
92
+ const notes = typeof parsed.json.notes === "string" ? parsed.json.notes : undefined;
93
+ if (verdict === "pass" && blocked.length > 0) {
94
+ return { status: "invalid", blocking: blocked, verdict: "pass", detail: "contradiction: pass 却有 blocking" };
95
+ }
96
+
97
+ const failure = opts.recheck?.() ?? null;
98
+ if (failure) return { status: "reject", blocking: blocked, verdict: "reject", notes, detail: `frontmatter-invalid: ${failure}` };
99
+
100
+ return { status: "ok", blocking: blocked, verdict: verdict as ReviewVerdict, ...(notes ? { notes } : {}) };
101
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * 评审 prompt 拼装(设计稿 12.2):只给四样东西 —— 仓库硬约束、diff、改后全文、验收标准。
3
+ *
4
+ * 五条硬约束里的第 2 条是这里的唯一职责:**不给提案理由、不给 findings**,
5
+ * 否则评审员会顺着提案者的推理复述一遍,评不出真问题。所以入参里根本没有这两个字段,
6
+ * 单测直接断言产物里不出现这两个来源的痕迹。
7
+ */
8
+ import type { CompiledRule } from "./hard-rules.ts";
9
+
10
+ export interface ReviewRuleInput {
11
+ id: string;
12
+ level: string;
13
+ expect: string;
14
+ why?: string;
15
+ }
16
+
17
+ export interface ReviewPromptInput {
18
+ skillName: string;
19
+ /** 改动后的技能全文;diff 应用失败时是改动前的全文,靠 diffApplied 标注 */
20
+ skillText: string;
21
+ diffApplied: boolean;
22
+ diff: string;
23
+ hardRules: readonly ReviewRuleInput[] | readonly CompiledRule[];
24
+ /** 覆盖默认验收标准(必红自检用不到,但保留注入点便于单测) */
25
+ acceptance?: readonly string[];
26
+ }
27
+
28
+ export const ACCEPTANCE_STANDARDS: readonly string[] = [
29
+ "不得引入与硬约束冲突的条款",
30
+ "不得删除原有的明确禁止项",
31
+ "新增条款必须能用一句话说清「什么时候适用」",
32
+ "描述(description)必须包含触发场景词,且不超过 1024 字",
33
+ ];
34
+
35
+ function ruleLines(items: readonly ReviewRuleInput[] | readonly CompiledRule[]): string[] {
36
+ if (items.length === 0) return ["(规则文件缺失:没有可注入的硬约束,此时应按验收标准判,拿不准的给 revise)"];
37
+ return items.map((item) => {
38
+ const rule = "rule" in item ? item.rule : item;
39
+ const impl = "rule" in item ? `(匹配 ${rule.pattern})` : "";
40
+ return `- ${rule.id}(${rule.level}):${rule.expect}${impl}${rule.why ? ` —— ${rule.why}` : ""}`;
41
+ });
42
+ }
43
+
44
+ export function buildReviewPrompt(input: ReviewPromptInput): string {
45
+ const acceptance = input.acceptance ?? ACCEPTANCE_STANDARDS;
46
+ const textHeader = input.diffApplied
47
+ ? "## 改动后的技能全文"
48
+ : "## 技能全文(diff 无法自动应用,下面是**改动前**的版本,只有上面的 diff 可信)";
49
+ return [
50
+ "你是本仓库的技能评审员。你只看到一个候选改动和仓库硬约束,看不到提案者的理由。",
51
+ "",
52
+ "## 仓库硬约束(违反任一条 = reject)",
53
+ ...ruleLines(input.hardRules),
54
+ "",
55
+ "## 候选改动(unified diff)",
56
+ "```diff",
57
+ input.diff.trimEnd(),
58
+ "```",
59
+ "",
60
+ textHeader,
61
+ "```markdown",
62
+ input.skillText.trimEnd(),
63
+ "```",
64
+ "",
65
+ "## 验收标准",
66
+ ...acceptance.map((item, index) => `${index + 1}. ${item}`),
67
+ "",
68
+ "## verdict 怎么选(别把能改的小毛病判成 reject)",
69
+ "- reject:删掉了原有的明确禁止项 / 引入与硬约束冲突的内容 / 改动方向本身就是错的",
70
+ "- revise:方向可以接受,但有具体的可修点(如 description 太笼统、缺触发场景词、某句表述含糊)",
71
+ "- pass:没有阻塞问题",
72
+ "",
73
+ "## 输出格式(严格遵守,只输出 JSON,不要代码围栏、不要解释)",
74
+ '{"verdict":"pass|reject|revise",',
75
+ ' "blocking":[{"issue":"...","evidence":{"file":"...","line":42,"quote":"原文"}}],',
76
+ ' "notes":"..."}',
77
+ "",
78
+ `技能名:${input.skillName}`,
79
+ "说明:blocking 每条都必须给 file/line/quote(quote 是被改动文件里的原文片段);没有问题时 verdict=pass 且 blocking=[]。",
80
+ "",
81
+ ].join("\n");
82
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * 评审结果的落盘形态(`03-review.json` / `03-review.md`)与从 run.json 取宿主模型名。
3
+ *
4
+ * 单独一个文件是因为评审督导已经接近 300 行闸门;这里全是纯函数,单测可直接调。
5
+ */
6
+ import type { ActionLevel } from "./classify-action.ts";
7
+ import type { ReviewBlocking, ReviewVerdict } from "./review-post.ts";
8
+
9
+ export type SupervisorStatus = "reviewed" | "rejected" | "review_failed" | "skipped";
10
+
11
+ export interface ReviewRecord {
12
+ status: SupervisorStatus;
13
+ reason?: string;
14
+ verdict?: ReviewVerdict;
15
+ blocking: ReviewBlocking[];
16
+ notes?: string;
17
+ action?: ActionLevel;
18
+ reviewerModel: string;
19
+ proposerModel: string;
20
+ sameModel: boolean;
21
+ attempts?: number;
22
+ detail?: string;
23
+ reviewedAt: number;
24
+ reviewedAtIso: string;
25
+ }
26
+
27
+ /** 提案者的实际模型:取 run.json 里最后一个带 model 的 stage(复盘子进程收尾会写 analyze) */
28
+ export function stageModel(runRaw: string | undefined): string {
29
+ if (!runRaw) return "";
30
+ try {
31
+ const stages = (JSON.parse(runRaw) as Record<string, unknown>).stages;
32
+ if (!Array.isArray(stages)) return "";
33
+ for (let index = stages.length - 1; index >= 0; index -= 1) {
34
+ const stage = stages[index] as Record<string, unknown>;
35
+ if (typeof stage.model === "string" && stage.model.length > 0) return stage.model;
36
+ }
37
+ } catch {
38
+ return "";
39
+ }
40
+ return "";
41
+ }
42
+
43
+ export function renderReviewMarkdown(record: ReviewRecord, skill: string): string {
44
+ const lines: string[] = [];
45
+ // 首行就要立警告:同模型自评的独立性天然有限,人一眼得看见
46
+ if (record.sameModel) lines.push("> 同模型自评,独立性有限(proposer 与 reviewer 是同一个模型,请人工复核)", "");
47
+ lines.push(`# 评审结论:${record.verdict ?? record.status}`, "");
48
+ lines.push(`- 技能:${skill}`);
49
+ lines.push(`- status:${record.status}${record.detail ? `(${record.detail})` : ""}`);
50
+ lines.push(`- 建议动作:${record.action ?? "无"}(本版一律不写 .pi/skills)`);
51
+ lines.push(`- 模型:reviewer=${record.reviewerModel} / proposer=${record.proposerModel}`);
52
+ if (record.blocking.length === 0) {
53
+ lines.push("", "blocking:无");
54
+ } else {
55
+ lines.push("", "## blocking", "");
56
+ for (const item of record.blocking) {
57
+ lines.push(`- ${item.issue}`, ` - 证据:${item.evidence.file}:${item.evidence.line} 「${item.evidence.quote}」`);
58
+ }
59
+ }
60
+ if (record.notes) lines.push("", "## notes", "", record.notes);
61
+ lines.push("");
62
+ return lines.join("\n");
63
+ }