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,172 @@
1
+ /**
2
+ * 趋势指标:把遥测事件按 ISO 周聚合,落 `.pi/evolution/metrics.jsonl`(已 gitignore)。
3
+ *
4
+ * 两条纪律:
5
+ * 1. 只做聚合,不做解释——归因结论在 01-findings.json / 周报里;
6
+ * 2. 除零一律返回 0(`ratio`),指标里出现 NaN 比没指标更糟。
7
+ */
8
+ import * as fs from "node:fs";
9
+ import * as path from "node:path";
10
+ import { ratio, type MutationQueue, type TelemetryEvent } from "./telemetry.ts";
11
+
12
+ /** 归因分布只统计 A~G 七类(semantic 是「判不动」的兜底,不算一类原因) */
13
+ export const ATTRIBUTION_KEYS = ["A", "B", "C", "D", "E", "F", "G"] as const;
14
+ export type AttributionKey = (typeof ATTRIBUTION_KEYS)[number];
15
+
16
+ export interface MetricsRow {
17
+ /** 落盘时刻(毫秒) */
18
+ ts: number;
19
+ /** ISO 周,如 `2026-W45` */
20
+ week: string;
21
+ advertised: number;
22
+ loaded: number;
23
+ attemptedFailed: number;
24
+ /** loaded / advertised */
25
+ loadRate: number;
26
+ conflicts: { p0: number; p1: number };
27
+ proposed: number;
28
+ applied: number;
29
+ rolledBack: number;
30
+ attribution: Record<AttributionKey, number>;
31
+ /** 单 run 行才带;按周汇总行省略(去重键:week + runId) */
32
+ runId?: string;
33
+ }
34
+
35
+ /** 指标要看的 run 侧面:只取状态与派发时刻,其余字段与趋势无关 */
36
+ export interface MetricsRun {
37
+ runId: string;
38
+ status: string;
39
+ dispatchedAt: number | null;
40
+ }
41
+
42
+ /** 走到过提案阶段的 run(用来算采纳率的分母) */
43
+ const PROPOSED_STATUSES = new Set(["proposed", "reviewed", "rejected", "review_failed", "applied", "rolled-back", "unauthorized_write", "failed"]);
44
+
45
+ /** ISO-8601 周编号:`2026-W45`。用 UTC 计算,避免本地时区跨年把同一周拆成两周 */
46
+ export function isoWeek(ts: number): string {
47
+ const date = new Date(ts);
48
+ const thursday = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
49
+ const weekday = thursday.getUTCDay() || 7;
50
+ thursday.setUTCDate(thursday.getUTCDate() + 4 - weekday);
51
+ const yearStart = Date.UTC(thursday.getUTCFullYear(), 0, 1);
52
+ const week = Math.ceil(((thursday.getTime() - yearStart) / 86_400_000 + 1) / 7);
53
+ return `${thursday.getUTCFullYear()}-W${String(week).padStart(2, "0")}`;
54
+ }
55
+
56
+ function emptyRow(week: string, ts: number): MetricsRow {
57
+ return {
58
+ ts,
59
+ week,
60
+ advertised: 0,
61
+ loaded: 0,
62
+ attemptedFailed: 0,
63
+ loadRate: 0,
64
+ conflicts: { p0: 0, p1: 0 },
65
+ proposed: 0,
66
+ applied: 0,
67
+ rolledBack: 0,
68
+ attribution: { A: 0, B: 0, C: 0, D: 0, E: 0, F: 0, G: 0 },
69
+ };
70
+ }
71
+
72
+ function skillsOf(event: TelemetryEvent): { name: string; disabled: boolean }[] {
73
+ const raw = Array.isArray(event.skills) ? (event.skills as { name?: unknown; disabled?: unknown }[]) : [];
74
+ return raw
75
+ .filter((skill) => typeof skill?.name === "string")
76
+ .map((skill) => ({ name: String(skill.name), disabled: skill.disabled === true }));
77
+ }
78
+
79
+ /**
80
+ * 按 ISO 周聚合。`runs` 可选:提案数 / 采纳数 / 回滚数只有 run.json 知道(复盘子进程不写遥测)。
81
+ */
82
+ export function buildMetrics(events: readonly TelemetryEvent[], now: number, runs: readonly MetricsRun[] = []): MetricsRow[] {
83
+ const weeks = new Map<string, MetricsRow>();
84
+ const rowOf = (ts: number): MetricsRow => {
85
+ const week = isoWeek(ts);
86
+ let row = weeks.get(week);
87
+ if (!row) {
88
+ row = emptyRow(week, now);
89
+ weeks.set(week, row);
90
+ }
91
+ return row;
92
+ };
93
+
94
+ for (const event of events) {
95
+ if (typeof event.ts !== "number" || !Number.isFinite(event.ts)) continue;
96
+ const row = rowOf(event.ts);
97
+ if (event.kind === "advertised") {
98
+ row.advertised += skillsOf(event).filter((skill) => !skill.disabled).length;
99
+ } else if (event.kind === "loaded") {
100
+ row.loaded += 1;
101
+ } else if (event.kind === "failed") {
102
+ row.attemptedFailed += 1;
103
+ } else if (event.kind === "conflict") {
104
+ if (event.level === "P0") row.conflicts.p0 += 1;
105
+ else if (event.level === "P1") row.conflicts.p1 += 1;
106
+ } else if (event.kind === "attributed") {
107
+ const category = typeof event.category === "string" ? event.category : "";
108
+ if ((ATTRIBUTION_KEYS as readonly string[]).includes(category)) {
109
+ row.attribution[category as AttributionKey] += 1;
110
+ }
111
+ }
112
+ }
113
+
114
+ for (const run of runs) {
115
+ if (run.dispatchedAt === null) continue;
116
+ const row = rowOf(run.dispatchedAt);
117
+ if (PROPOSED_STATUSES.has(run.status)) row.proposed += 1;
118
+ if (run.status === "applied") row.applied += 1;
119
+ if (run.status === "rolled-back") row.rolledBack += 1;
120
+ }
121
+
122
+ for (const row of weeks.values()) row.loadRate = ratio(row.loaded, row.advertised);
123
+ return [...weeks.values()].sort((a, b) => a.week.localeCompare(b.week));
124
+ }
125
+
126
+ export function metricsFile(evolutionDir: string): string {
127
+ return path.join(evolutionDir, "metrics.jsonl");
128
+ }
129
+
130
+ /** 去重键:同一周的汇总行只写一次;单 run 行按 week + runId 区分 */
131
+ function keyOf(row: MetricsRow): string {
132
+ return `${row.week}|${row.runId ?? ""}`;
133
+ }
134
+
135
+ export function readMetrics(evolutionDir: string): MetricsRow[] {
136
+ let text: string;
137
+ try {
138
+ text = fs.readFileSync(metricsFile(evolutionDir), "utf8");
139
+ } catch {
140
+ return [];
141
+ }
142
+ const rows: MetricsRow[] = [];
143
+ for (const line of text.split("\n")) {
144
+ if (line.trim().length === 0) continue;
145
+ try {
146
+ const parsed = JSON.parse(line) as MetricsRow;
147
+ if (typeof parsed.week === "string") rows.push(parsed);
148
+ } catch {
149
+ // 坏行跳过:旁路数据不值得让整份趋势报错
150
+ }
151
+ }
152
+ return rows;
153
+ }
154
+
155
+ /** 追加一行;同 week(+runId)已有则跳过,返回是否真的写入 */
156
+ export async function appendMetrics(evolutionDir: string, row: MetricsRow, queue: MutationQueue): Promise<boolean> {
157
+ const file = metricsFile(evolutionDir);
158
+ const key = keyOf(row);
159
+ if (readMetrics(evolutionDir).some((existing) => keyOf(existing) === key)) return false;
160
+ await queue(file, async () => {
161
+ await fs.promises.mkdir(path.dirname(file), { recursive: true });
162
+ await fs.promises.appendFile(file, `${JSON.stringify(row)}\n`, "utf8");
163
+ });
164
+ return true;
165
+ }
166
+
167
+ /** 回滚率 = rolledBack / applied;没有采纳样本时为 0(「样本不足」由调用方判断) */
168
+ export function rollbackRate(rows: readonly MetricsRow[]): number {
169
+ const applied = rows.reduce((sum, row) => sum + row.applied, 0);
170
+ const rolledBack = rows.reduce((sum, row) => sum + row.rolledBack, 0);
171
+ return ratio(rolledBack, applied);
172
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * 模型配置(设计稿 12.5):`propose.model` / `review.model` 两个键,值 `"inherit"` 或 `provider/model`。
3
+ *
4
+ * `inherit` = 跟随主会话当前模型(`ctx.model`)。默认两边都是 inherit —— 用户不配置也能跑,
5
+ * 但同模型自评的独立性天然有限,所以评审产物里必须写清 `sameModel`。
6
+ */
7
+ import * as fs from "node:fs";
8
+ import { configFile, readRawConfig } from "./config.ts";
9
+ import type { MutationQueue } from "./telemetry.ts";
10
+
11
+ export const INHERIT = "inherit";
12
+ export type ModelKind = "propose" | "review";
13
+
14
+ export interface ModelConfig {
15
+ propose: string;
16
+ review: string;
17
+ }
18
+
19
+ export const DEFAULT_MODEL_CONFIG: Readonly<ModelConfig> = Object.freeze({ propose: INHERIT, review: INHERIT });
20
+
21
+ /** provider/id,id 允许带 `:thinking` 后缀(pi 的 --model 语法) */
22
+ const SPEC = /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._:-]*$/;
23
+
24
+ /** 非法值一律退回 inherit:配置错误不该让整条流水线停摆 */
25
+ export function normalizeModelSpec(value: unknown): string {
26
+ return typeof value === "string" && SPEC.test(value.trim()) ? value.trim() : INHERIT;
27
+ }
28
+
29
+ /** 从 config.json 的原始对象里取模型配置(`{propose:{model},review:{model}}`) */
30
+ export function readModelConfig(raw: unknown): ModelConfig {
31
+ const input = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
32
+ const pick = (key: ModelKind): string => {
33
+ const node = input[key];
34
+ if (!node || typeof node !== "object") return INHERIT;
35
+ return normalizeModelSpec((node as Record<string, unknown>).model);
36
+ };
37
+ return { propose: pick("propose"), review: pick("review") };
38
+ }
39
+
40
+ export function loadModelConfig(evolutionDir: string): ModelConfig {
41
+ return readModelConfig(readRawConfig(evolutionDir));
42
+ }
43
+
44
+ export interface ModelHost {
45
+ model?: { provider?: string; id?: string };
46
+ }
47
+
48
+ /** inherit → 主会话当前的 `provider/id`;主会话没有模型时仍返回 inherit(调用方不传 --model) */
49
+ export function resolveModel(kind: ModelKind, config: ModelConfig, host: ModelHost): string {
50
+ const spec = config[kind] ?? INHERIT;
51
+ if (spec !== INHERIT) return spec;
52
+ const provider = host.model?.provider;
53
+ const id = host.model?.id;
54
+ return provider && id ? `${provider}/${id}` : INHERIT;
55
+ }
56
+
57
+ /** `/skill-evolve config` 的选择项:第一项固定是「跟随主会话」 */
58
+ export function modelChoices(models: readonly { provider?: string; id?: string }[]): string[] {
59
+ const choices = [`跟随主会话(${INHERIT})`];
60
+ for (const model of models) {
61
+ if (model.provider && model.id) choices.push(`${model.provider}/${model.id}`);
62
+ }
63
+ return choices;
64
+ }
65
+
66
+ /** 把选择项还原成配置值 */
67
+ export function specFromChoice(choice: string): string {
68
+ return choice.startsWith("跟随主会话") ? INHERIT : normalizeModelSpec(choice);
69
+ }
70
+
71
+ /** 写回一个键:只改 `propose.model` / `review.model`,其余字段原样保留 */
72
+ export async function saveModelSpec(
73
+ evolutionDir: string,
74
+ kind: ModelKind,
75
+ spec: string,
76
+ queue: MutationQueue,
77
+ ): Promise<ModelConfig> {
78
+ const raw = readRawConfig(evolutionDir);
79
+ const next = readModelConfig({ ...raw, [kind]: { ...(raw[kind] as object | undefined), model: normalizeModelSpec(spec) } });
80
+ const merged = {
81
+ ...raw,
82
+ propose: { ...(raw.propose as object | undefined), model: next.propose },
83
+ review: { ...(raw.review as object | undefined), model: next.review },
84
+ };
85
+ const file = configFile(evolutionDir);
86
+ await queue(file, async () => {
87
+ await fs.promises.mkdir(evolutionDir, { recursive: true });
88
+ await fs.promises.writeFile(file, `${JSON.stringify(merged, null, 2)}\n`, "utf8");
89
+ });
90
+ return next;
91
+ }
92
+
93
+ export function describeModelConfig(config: ModelConfig): string {
94
+ return `提案模型 ${config.propose} · 评审模型 ${config.review}(${INHERIT} = 跟随主会话)`;
95
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * 失败 / 待批 / 自动采纳的会话送达(设计稿 14.1「会话内可见」的补丁)。
3
+ *
4
+ * 为什么必须有:用户从飞书进来时没有 TUI,widget 与状态栏一眼都看不到,唯一能看见的是会话消息。
5
+ * 复盘子进程悄悄死掉、评审悄悄通过等人批、auto 悄悄改了技能 —— 这三件事不写进会话,
6
+ * 就等于「静默失败」(见 AGENTS.md 的个人约定第 8 条)。
7
+ *
8
+ * 两条纪律:
9
+ * 1. 一律 `pi.sendMessage`(CustomMessage,不触发新回合),绝不用 `sendUserMessage`
10
+ * —— 那会让 agent 为一条通知再跑一整轮,烧 token 还打断用户;
11
+ * 2. 同一 run 的同一类事件只发一次:判据落 `runs/<runId>/notified.json`,跨进程重启也认。
12
+ */
13
+ import * as fs from "node:fs";
14
+ import * as path from "node:path";
15
+ import { readQueue } from "./queue.ts";
16
+ import { listRuns } from "./runs.ts";
17
+ import { readRunError, type StaleReason } from "./stale.ts";
18
+
19
+ export type NotifyKind = "stale" | "pending-approval" | "auto-applied";
20
+
21
+ export interface NotifyEvent {
22
+ runId: string;
23
+ kind: NotifyKind;
24
+ /** 直接交给 pi.sendMessage 的正文:最多两行,不带 diff */
25
+ text: string;
26
+ }
27
+
28
+ export const NOTIFIED_FILE = "notified.json";
29
+ /** 日志末行截断长度:会话消息要短,长尾巴进 run-error.json 看 */
30
+ const TAIL_CHARS = 120;
31
+
32
+ export interface NotifyDeps {
33
+ evolutionDir: string;
34
+ now?: number;
35
+ /** 生产传 `text => pi.sendMessage(...)`;拿不到(老版本 / 无 UI)就不传,只写日志 */
36
+ send?: (text: string) => void;
37
+ log?: (msg: string) => void;
38
+ }
39
+
40
+ const REASON_LABEL: Record<StaleReason, string> = { "child-exited": "子进程已退出", timeout: "超时" };
41
+
42
+ function readJson<T>(file: string): T | null {
43
+ try {
44
+ return JSON.parse(fs.readFileSync(file, "utf8")) as T;
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ function readNotified(runDir: string): NotifyKind[] {
51
+ const raw = readJson<{ kinds?: unknown }>(path.join(runDir, NOTIFIED_FILE));
52
+ const kinds = Array.isArray(raw?.kinds) ? raw.kinds : [];
53
+ return kinds.filter((kind): kind is NotifyKind => kind === "stale" || kind === "pending-approval" || kind === "auto-applied");
54
+ }
55
+
56
+ function clip(text: string): string {
57
+ const trimmed = text.trim();
58
+ return trimmed.length > TAIL_CHARS ? `${trimmed.slice(0, TAIL_CHARS)}…` : trimmed;
59
+ }
60
+
61
+ /** 三类事件的判定全部只看「已落盘的事实」:队列状态 + run.json 状态,不猜中间态 */
62
+ export function collectNotifications(evolutionDir: string): NotifyEvent[] {
63
+ const events: NotifyEvent[] = [];
64
+ const runs = listRuns(evolutionDir, 50);
65
+ const runIds = new Set(runs.map((run) => run.runId));
66
+ for (const run of runs) {
67
+ if (run.status !== "stale") continue;
68
+ const error = readRunError(path.join(evolutionDir, "runs", run.runId));
69
+ const reason = error?.reason ?? "child-exited";
70
+ const tail = error && error.childLogTail.length > 0 ? clip(error.childLogTail[error.childLogTail.length - 1]!) : "";
71
+ events.push({
72
+ runId: run.runId,
73
+ kind: "stale",
74
+ text: `技能复盘失败:${run.runId}(${REASON_LABEL[reason]})\n日志末行:${tail.length > 0 ? tail : "(子进程没留下输出)"}`,
75
+ });
76
+ }
77
+ for (const item of readQueue(evolutionDir)) {
78
+ // 队列里没有对应 run 目录的条目(手工塞的)不推送:没有证据就没必要打扰用户
79
+ if (!runIds.has(item.runId)) continue;
80
+ if (item.status === "pending" && item.level === "approval") {
81
+ events.push({
82
+ runId: item.runId,
83
+ kind: "pending-approval",
84
+ text: `待批:${item.runId} 改动 ${item.skill || "(未知技能)"}\n/skill-evolve approve ${item.runId}`,
85
+ });
86
+ } else if (item.status === "applied" && item.level === "auto") {
87
+ events.push({
88
+ runId: item.runId,
89
+ kind: "auto-applied",
90
+ text: `已自动采纳:${item.runId}(${item.skill || "未知技能"})`,
91
+ });
92
+ }
93
+ }
94
+ return events;
95
+ }
96
+
97
+ function writeNotified(runDir: string, kinds: NotifyKind[], at: number, log?: (msg: string) => void): void {
98
+ try {
99
+ fs.mkdirSync(runDir, { recursive: true });
100
+ fs.writeFileSync(path.join(runDir, NOTIFIED_FILE), `${JSON.stringify({ kinds, at }, null, 2)}\n`, "utf8");
101
+ } catch (error) {
102
+ // 写不进去只会导致下次重复推送,不该把刷新流程搞挂
103
+ log?.(`[skill-evolve] 记 notified.json 失败(${runDir}):${String(error)}`);
104
+ }
105
+ }
106
+
107
+ /**
108
+ * 发一遍「还没发过」的事件。返回本次真的发出去的条目(单测靠它断言去重)。
109
+ * `send` 抛错时**不**记 notified:下一轮还能重试,丢通知比重复一次更糟。
110
+ */
111
+ export function deliverNotifications(deps: NotifyDeps): NotifyEvent[] {
112
+ const delivered: NotifyEvent[] = [];
113
+ for (const event of collectNotifications(deps.evolutionDir)) {
114
+ const runDir = path.join(deps.evolutionDir, "runs", event.runId);
115
+ const known = readNotified(runDir);
116
+ if (known.includes(event.kind)) continue;
117
+ if (deps.send) {
118
+ try {
119
+ deps.send(event.text);
120
+ } catch (error) {
121
+ deps.log?.(`[skill-evolve] 会话消息发送失败(${event.runId}):${String(error)}`);
122
+ continue;
123
+ }
124
+ } else {
125
+ // 无 UI / 老版本 pi:降级成日志,行为与「发出去了」等价(不再重试,免得刷日志)
126
+ deps.log?.(`[skill-evolve] ${event.text.replace(/\n/g, " | ")}`);
127
+ }
128
+ writeNotified(runDir, [...known, event.kind], deps.now ?? Date.now(), deps.log);
129
+ delivered.push(event);
130
+ }
131
+ return delivered;
132
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * 面板层(设计稿 14.1 的前三层):实时 widget / 状态栏 / `/tree` 条目。
3
+ *
4
+ * 三条纪律:
5
+ * 1. 没数据就不占位——widget 返回 null 由调用方清空,不显示「0」这种噪音;
6
+ * 2. 状态栏只显示最紧急的一件事,其余一律不挤进去;
7
+ * 3. 这里只读盘算数值,不建定时器、不写文件(刷新时机由 index.ts 决定)。
8
+ */
9
+ import * as fs from "node:fs";
10
+ import * as path from "node:path";
11
+ import { isPidAlive } from "./lock.ts";
12
+ import { readQueue } from "./queue.ts";
13
+ import { listRuns } from "./runs.ts";
14
+ import { classifyStale, type StaleDeps } from "./stale.ts";
15
+
16
+ /** 回滚率告警线:设计稿 14.2 定的 5%,样本少于 5 次不下结论 */
17
+ export const ROLLBACK_ALERT = 0.05;
18
+ export const MIN_APPLY_SAMPLES = 5;
19
+
20
+ /** 进行中的「复盘」:派发出去还没出评审结论的那些 run */
21
+ const IN_FLIGHT = new Set(["dispatched", "analyzed", "proposed", "review_failed"]);
22
+
23
+ export interface PanelState {
24
+ reviewing: number;
25
+ /** 已判定卡死的 run 数(run.json status=stale):它在状态栏/ widget 上压过「待批」 */
26
+ staleCount: number;
27
+ pendingApprovals: number;
28
+ lastAppliedAt: number | null;
29
+ rollbackRate: number;
30
+ appliedSamples: number;
31
+ }
32
+
33
+ function fileText(file: string): string | null {
34
+ try {
35
+ return fs.readFileSync(file, "utf8");
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ /** 采纳时刻取 04-decision.json 的 at;读不到才退回入队时刻(宁可偏早,也不编一个时间) */
42
+ function appliedAtOf(evolutionDir: string, runId: string, fallback: number): number {
43
+ const raw = fileText(path.join(evolutionDir, "runs", runId, "04-decision.json"));
44
+ if (raw === null) return fallback;
45
+ try {
46
+ const at = (JSON.parse(raw) as { at?: unknown }).at;
47
+ return typeof at === "number" && Number.isFinite(at) ? at : fallback;
48
+ } catch {
49
+ return fallback;
50
+ }
51
+ }
52
+
53
+ export function collectPanelState(input: {
54
+ evolutionDir: string;
55
+ now?: number;
56
+ /** 单 run 上限(来自配置):pid 还活着但跑太久也算卡死 */
57
+ maxRuntimeMs?: number;
58
+ /** pid 存活判定,单测注入(默认 kill(pid,0)) */
59
+ alive?: (pid: number) => boolean;
60
+ }): PanelState {
61
+ const queue = readQueue(input.evolutionDir);
62
+ const runs = listRuns(input.evolutionDir, 50);
63
+ const now = input.now ?? Date.now();
64
+ const staleDeps: StaleDeps = {};
65
+ if (input.maxRuntimeMs !== undefined) staleDeps.maxRuntimeMs = input.maxRuntimeMs;
66
+ if (input.alive !== undefined) staleDeps.alive = input.alive;
67
+ const aliveOf = input.alive ?? isPidAlive;
68
+ const applied = queue.filter((item) => item.status === "applied");
69
+ const rolledBack = queue.filter((item) => item.status === "rolled-back").length;
70
+ const lastApplied = applied.reduce<{ at: number } | null>((best, item) => {
71
+ const at = appliedAtOf(input.evolutionDir, item.runId, item.createdAt);
72
+ return best === null || at > best.at ? { at } : best;
73
+ }, null);
74
+ return {
75
+ // 只数活着的:子进程死掉的 run 不该继续挂着「复盘 N」,否则面板永远在骗人。
76
+ // dispatched 保留宽限期(刚派发就判死会误报);analyzed / proposed 已经是「结论写完了」,
77
+ // 进程退出属正常 —— 只有督导还活着(评审中)才算「复盘」,否则 8 条做完的 would-be 复盘
78
+ // 会永远挂在面板头上(2026-11-08 真事:报「复盘中 8」,实际一个进程都没在跑)。
79
+ reviewing: runs.filter((run) => {
80
+ if (!IN_FLIGHT.has(run.status)) return false;
81
+ if (classifyStale(run, now, staleDeps).stale) return false;
82
+ if (run.status === "dispatched") return true;
83
+ return (run.pid !== null && aliveOf(run.pid)) || (run.supervisorPid !== null && aliveOf(run.supervisorPid));
84
+ }).length,
85
+ // 已经落成 stale 的 + 判据命中但还没落盘的都要算:只数 run.json 里的 status,会让
86
+ // 「网页说卡死 3、实际列表 4 条」这种自相矛盾的数字漏出去(web 面板只读,不在那里补写盘)
87
+ staleCount: runs.filter((run) => run.status === "stale" || classifyStale(run, now, staleDeps).stale).length,
88
+ pendingApprovals: queue.filter((item) => item.status === "pending" && item.level === "approval").length,
89
+ lastAppliedAt: lastApplied?.at ?? null,
90
+ rollbackRate: applied.length + rolledBack > 0 ? rolledBack / (applied.length + rolledBack) : 0,
91
+ appliedSamples: applied.length + rolledBack,
92
+ };
93
+ }
94
+
95
+ /** `MM-DD`:面板只有一格宽,不写年份 */
96
+ export function shortDate(ts: number): string {
97
+ const date = new Date(ts);
98
+ return `${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
99
+ }
100
+
101
+ /** widget 内容;全零返回 null(调用方用 undefined 清空,不留一行「复盘 0 / 待批 0」) */
102
+ export function panelWidget(state: PanelState): string[] | null {
103
+ const parts: string[] = [];
104
+ if (state.staleCount > 0) parts.push(`复盘失败 ${state.staleCount}`);
105
+ if (state.reviewing > 0) parts.push(`复盘 ${state.reviewing}`);
106
+ if (state.pendingApprovals > 0) parts.push(`待批 ${state.pendingApprovals}`);
107
+ if (state.lastAppliedAt !== null) parts.push(`上次采纳 ${shortDate(state.lastAppliedAt)}`);
108
+ return parts.length > 0 ? [parts.join(" / ")] : null;
109
+ }
110
+
111
+ /** 状态栏:只放最紧急的一件(失败 > 待批 > 回滚率告警),都没有就清空 */
112
+ export function statusLine(state: PanelState): string | null {
113
+ if (state.staleCount > 0) return `复盘失败 ${state.staleCount}`;
114
+ if (state.pendingApprovals > 0) return `待批 ${state.pendingApprovals}`;
115
+ if (state.appliedSamples >= MIN_APPLY_SAMPLES && state.rollbackRate > ROLLBACK_ALERT) {
116
+ const percent = (state.rollbackRate * 100).toFixed(0);
117
+ return `回滚率 ${percent}% 超 5%,建议关 auto`;
118
+ }
119
+ return null;
120
+ }
121
+
122
+ /** `/tree` 条目的展示行(数据由 pi.appendEntry 落进会话,不参与 LLM 上下文) */
123
+ export interface PanelEntry {
124
+ kind: "dispatched" | "reviewed" | "applied" | "rolled-back" | "stale";
125
+ runId: string;
126
+ detail?: string;
127
+ at: number;
128
+ }
129
+
130
+ const ENTRY_LABEL: Record<PanelEntry["kind"], string> = {
131
+ dispatched: "派发复盘",
132
+ reviewed: "评审结论",
133
+ applied: "采纳",
134
+ "rolled-back": "回滚",
135
+ stale: "卡死",
136
+ };
137
+
138
+ export function entryLines(entry: PanelEntry): string[] {
139
+ const stamp = shortDate(entry.at);
140
+ const detail = entry.detail ? ` · ${entry.detail}` : "";
141
+ return [`[skill-evolve] ${ENTRY_LABEL[entry.kind]} ${entry.runId} · ${stamp}${detail}`];
142
+ }
143
+
144
+ /** 状态变迁 → `/tree` 条目:只认「评审结论 / 采纳 / 回滚」,派发由调用方单独记(它先于 run.json 出现) */
145
+ const STATUS_ENTRY: Record<string, PanelEntry["kind"] | undefined> = {
146
+ reviewed: "reviewed",
147
+ rejected: "reviewed",
148
+ review_failed: "reviewed",
149
+ applied: "applied",
150
+ "rolled-back": "rolled-back",
151
+ stale: "stale",
152
+ };
153
+
154
+ /**
155
+ * 把 run 的状态与上次见到的比,产出新条目。`seen` 会被就地更新:
156
+ * 调用方每次刷新传同一份 Map,就能保证一条状态只发一次条目(含重启后的第一轮「补记」)。
157
+ */
158
+ export function statusEntries(
159
+ runs: readonly { runId: string; status: string }[],
160
+ seen: Map<string, string>,
161
+ now: number,
162
+ ): PanelEntry[] {
163
+ const entries: PanelEntry[] = [];
164
+ for (const run of runs) {
165
+ const previous = seen.get(run.runId);
166
+ if (previous === run.status) continue;
167
+ seen.set(run.runId, run.status);
168
+ const kind = STATUS_ENTRY[run.status];
169
+ if (!kind) continue;
170
+ const detail = run.status === kind ? undefined : run.status;
171
+ entries.push({ kind, runId: run.runId, at: now, ...(detail ? { detail } : {}) });
172
+ }
173
+ return entries;
174
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * 去抖期间把新一轮并进「待复盘区间」:轮数累加、技能去重、起点保留第一次被挡下的时刻。
3
+ *
4
+ * 纯函数,单独放一个文件让 dispatch.ts 守住 300 行闸门,也便于直接单测合并语义。
5
+ */
6
+ import { mergeAttributions, type Attribution } from "./attribution.ts";
7
+ import type { PendingRounds } from "./shutdown-dispatch.ts";
8
+
9
+ export interface PendingInput {
10
+ sessionId: string;
11
+ skills: string[];
12
+ sessionFile?: string;
13
+ attributions?: Attribution[];
14
+ }
15
+
16
+ export function mergePendingRounds(carried: PendingRounds | undefined, input: PendingInput, at: number): PendingRounds {
17
+ if (!carried) {
18
+ return {
19
+ sessionId: input.sessionId,
20
+ since: at,
21
+ rounds: 1,
22
+ skills: [...input.skills],
23
+ sessionFile: input.sessionFile,
24
+ attributions: mergeAttributions(undefined, input.attributions),
25
+ };
26
+ }
27
+ return {
28
+ ...carried,
29
+ rounds: carried.rounds + 1,
30
+ skills: [...carried.skills, ...input.skills.filter((name) => !carried.skills.includes(name))],
31
+ sessionFile: input.sessionFile ?? carried.sessionFile,
32
+ attributions: mergeAttributions(carried.attributions, input.attributions),
33
+ };
34
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * 生产环境的子进程封装:git 摘要、md-check 复验、auto 级的单独 commit、候选技能的引用面扫描。
3
+ *
4
+ * 全部同步(spawnSync):采纳是人触发的短路径,阻塞一两百毫秒可以接受;
5
+ * 用异步反而会拉宽「写盘成功但 commit 还没跑」的窗口。
6
+ * 单测不 import 本文件 —— 闸门里的 git / md-check / commit 都是注入点。
7
+ */
8
+ import { spawnSync } from "node:child_process";
9
+ import * as fs from "node:fs";
10
+ import * as path from "node:path";
11
+ import type { GitRunner } from "./gate0.ts";
12
+ import type { QueueItem } from "./queue.ts";
13
+ import type { ReferenceScan } from "./weekly.ts";
14
+
15
+ export function createGitRunner(): GitRunner {
16
+ return (args, cwd) => {
17
+ const result = spawnSync("git", args, { cwd, encoding: "utf8" });
18
+ return { code: result.status ?? 1, out: `${result.stdout ?? ""}${result.stderr ?? ""}` };
19
+ };
20
+ }
21
+
22
+ export function createMdCheckRunner(cwd: string): (absPath: string) => { code: number; output: string } {
23
+ const script = path.join(cwd, ".pi", "skills", "md-check", "scripts", "check_md.py");
24
+ return (absPath) => {
25
+ const result = spawnSync("python3", [script, absPath], { cwd, encoding: "utf8" });
26
+ return { code: result.status ?? 1, output: `${result.stdout ?? ""}${result.stderr ?? ""}` };
27
+ };
28
+ }
29
+
30
+ /** auto 级成功后单独一条 `docs(skill): ...`;失败由调用方回滚该条 */
31
+ export function createCommitRunner(cwd: string): (item: QueueItem) => { ok: boolean; hash?: string; detail?: string } {
32
+ return (item) => {
33
+ const add = spawnSync("git", ["add", "--", ...item.targets], { cwd, encoding: "utf8" });
34
+ if ((add.status ?? 1) !== 0) return { ok: false, detail: `git add 失败:${(add.stderr ?? "").trim()}` };
35
+ const message = `docs(skill): ${item.skill} ${item.proposalType ?? "improve"}(${item.runId})`;
36
+ const commit = spawnSync("git", ["commit", "-m", message, "--", ...item.targets], { cwd, encoding: "utf8" });
37
+ if ((commit.status ?? 1) !== 0) return { ok: false, detail: `git commit 失败:${(commit.stderr ?? "").trim()}` };
38
+ const head = spawnSync("git", ["rev-parse", "--short", "HEAD"], { cwd, encoding: "utf8" });
39
+ return { ok: true, hash: (head.stdout ?? "").trim() };
40
+ };
41
+ }
42
+
43
+ /**
44
+ * 零加载候选的引用面清单(设计稿 10.4 的五处收口里能自动查的三处):
45
+ * `.cursor` stub、`AGENTS.md` 索引行、其它技能里的提及。`rg` 不在 PATH 时按 0 处理并记日志。
46
+ */
47
+ export function createReferenceScan(cwd: string): (skill: string) => ReferenceScan {
48
+ return (skill) => {
49
+ const cursorStub = fs.existsSync(path.join(cwd, ".cursor", "skills", skill, "SKILL.md"));
50
+ let agentsIndex = false;
51
+ for (const file of [path.join(cwd, "AGENTS.md"), path.join(cwd, ".pi", "AGENTS.md")]) {
52
+ try {
53
+ const rows = fs.readFileSync(file, "utf8").split("\n");
54
+ if (rows.some((row) => row.includes(`\`${skill}\``) && row.trimStart().startsWith("-"))) agentsIndex = true;
55
+ } catch {
56
+ // 索引文件不存在是正常情况(AGENTS.md 只在部分仓里)
57
+ }
58
+ }
59
+ const result = spawnSync("rg", ["-l", "-F", skill, path.join(cwd, ".pi", "skills")], { cwd, encoding: "utf8" });
60
+ const hits = (result.stdout ?? "").split("\n").filter((line) => line.trim().length > 0);
61
+ const ownDir = `${path.join(cwd, ".pi", "skills", skill)}${path.sep}`;
62
+ const mentions = hits.filter((file) => !file.startsWith(ownDir)).length;
63
+ return { cursorStub, agentsIndex, mentions };
64
+ };
65
+ }