pi-skill-evolve 0.0.0-stage → 0.1.0

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 +116 -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,38 @@
1
+ /**
2
+ * `/skill-evolve config`:交互选模型(TUI)或打印当前值(非 TUI / RPC / print 模式)。
3
+ *
4
+ * 选择项列表来自 `ctx.modelRegistry.getAvailable()` —— 只有 pi 知道当前真能用的模型,
5
+ * 本模块只拿到「选项数组 + select 函数」,所以可以在 node --test 里直接喂假 host。
6
+ */
7
+ import { describeModelConfig, modelChoices, specFromChoice, type ModelKind } from "./model-config.ts";
8
+ import type { ModelTools } from "./evolution-model.ts";
9
+
10
+ export interface ConfigHost {
11
+ models: readonly { provider?: string; id?: string }[];
12
+ select(title: string, options: string[]): Promise<string | undefined>;
13
+ }
14
+
15
+ const LABELS: Record<ModelKind, string> = { propose: "提案", review: "评审" };
16
+
17
+ /** which:propose / review / both,默认 both */
18
+ export async function runConfigCommand(
19
+ host: ConfigHost | null,
20
+ tools: ModelTools,
21
+ which: string = "both",
22
+ ): Promise<string> {
23
+ const kinds: ModelKind[] = which === "propose" ? ["propose"] : which === "review" ? ["review"] : ["propose", "review"];
24
+ const current = describeModelConfig(tools.config());
25
+ if (!host) {
26
+ return [
27
+ `技能进化 · 模型配置:${current}`,
28
+ "当前模式无法交互选择(非 TUI):直接改 .pi/evolution/config.json 的 propose.model / review.model,值写 provider/model 或 inherit。",
29
+ ].join("\n");
30
+ }
31
+ const messages: string[] = [];
32
+ for (const kind of kinds) {
33
+ const choice = await host.select(`技能进化:${LABELS[kind]}模型`, modelChoices(host.models));
34
+ if (choice === undefined) return [...messages, "已取消,配置未改动"].join("\n");
35
+ messages.push(await tools.setModel(kind, specFromChoice(choice)));
36
+ }
37
+ return [...messages, `当前配置:${describeModelConfig(tools.config())}`].join("\n");
38
+ }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * 技能进化配置:唯一来源是 `.pi/evolution/config.json`。
3
+ *
4
+ * **默认全关**(`enabled:false` + `telemetry:false`):别人 clone 下来什么都不写、不派生、不留痕迹;
5
+ * 只有显式 `/skill-evolve on` 才写这个文件(`config.json` 也是本机偏好,进 `.gitignore`,断然不能入库
6
+ * —— 否则一个人开了,提交一推,整个团队都被打开)。
7
+ */
8
+ import * as fs from "node:fs";
9
+ import * as path from "node:path";
10
+ import type { MutationQueue } from "./telemetry.ts";
11
+
12
+ /**
13
+ * TUI 推送开关(用户 2026-11-08:「tui 的这些推送不看了,影响我」):两个都默认 false。
14
+ * 实时状态改由 `/skill-evolve status`、Web 面板与 `.pi/evolution/logs/extension.log` 承载。
15
+ */
16
+ export interface UiConfig {
17
+ /** false → 不调 ctx.ui.setWidget / setStatus(面板与状态栏都不出现) */
18
+ widget: boolean;
19
+ /** false → 不写 `/tree` 条目(registerEntryRenderer 留着,但没有条目可渲染) */
20
+ entries: boolean;
21
+ }
22
+
23
+ /**
24
+ * Web 面板开关(用户 2026-11-08:「期望是 pi 启动,他也启动,reload 就退出重启」)。
25
+ * 面板是 pi 的附属物,所以开关与端口必须落盘 —— 否则每次启动都得手敲 `/skill-evolve web`。
26
+ */
27
+ export interface WebConfig {
28
+ /** true → 每个 `session_start`(含 `/reload` 后的新实例)自动起面板 */
29
+ enabled: boolean;
30
+ /** 监听端口;1..65535,非法值回落到 7788 并留一行日志 */
31
+ port: number;
32
+ }
33
+
34
+ /**
35
+ * 两个独立开关的字段名:
36
+ * - `enabled` = 派生复盘(会起子进程跑 agent,**花 token**)
37
+ * - `telemetry` = 采集遥测(每轮一条曝光/加载/派生记录,只写本地 jsonl,**不花 token**)
38
+ * 字段级分开之后,命令(collect / review)与 Web 控制条都只切其中一个。
39
+ */
40
+ export type ConfigFlag = "enabled" | "telemetry";
41
+
42
+ export interface EvolutionConfig {
43
+ /** 派生总开关:false 时一律不派生(`/skill-evolve on|off` 会与 telemetry 一起切;`review` 只切它) */
44
+ enabled: boolean;
45
+ /** 去抖窗口(毫秒):窗口内再次 settled 只记 superseded,不叠加派生 */
46
+ idleMs: number;
47
+ /** 单次 run 最长存活(毫秒):孤儿锁按 2 倍判死 */
48
+ maxRuntimeMs: number;
49
+ /** 单会话派生次数上限(进程内计数);跨会话的总量由 lib/single-flight.ts 的全局闸兜底 */
50
+ perSessionCap: number;
51
+ /** L0 遥测采集开关(默认关:采集会在 `.pi/evolution/telemetry/` 落盘;`collect` 只切它) */
52
+ telemetry: boolean;
53
+ /** 界面推送开关:默认全关(想回 TUI 面板就显式打开) */
54
+ ui: UiConfig;
55
+ /** Web 面板:默认不起(谁也没要过就不该占端口),端口按约定值 7788 */
56
+ web: WebConfig;
57
+ }
58
+
59
+ export const DEFAULT_CONFIG: Readonly<EvolutionConfig> = Object.freeze({
60
+ enabled: false,
61
+ idleMs: 90_000,
62
+ maxRuntimeMs: 20 * 60_000,
63
+ perSessionCap: 20,
64
+ telemetry: false,
65
+ ui: Object.freeze({ widget: false, entries: false }),
66
+ // Web 面板是「管理界面」,默认随 pi 启动;它与采集/派生(enabled / telemetry)是两回事,
67
+ // 后者默认关闭——面板起来不等于开始花 token。2026-11-08 用户定。
68
+ web: Object.freeze({ enabled: true, port: 7788 }),
69
+ });
70
+
71
+ export function configFile(evolutionDir: string): string {
72
+ return path.join(evolutionDir, "config.json");
73
+ }
74
+
75
+ function positiveInt(value: unknown, fallback: number): number {
76
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
77
+ }
78
+
79
+ function bool(value: unknown, fallback: boolean): boolean {
80
+ return typeof value === "boolean" ? value : fallback;
81
+ }
82
+
83
+ /**
84
+ * 端口校验:必须是 1..65535 的整数。非法值回落 7788 并留一行 warn ——
85
+ * 写错一个端口号不该让面板整个起不来,也不该牵连其它配置项。
86
+ */
87
+ function webPort(value: unknown, log: (msg: string) => void): number {
88
+ const fallback = DEFAULT_CONFIG.web.port;
89
+ if (typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 65535) return value;
90
+ // 只有「写了但写错」才告警:老配置里根本没有 web 块属于正常情况,不该刷日志
91
+ if (value !== undefined) log(`[skill-evolve] 配置里的 web.port 非法(${JSON.stringify(value)}),已回落到 ${fallback}`);
92
+ return fallback;
93
+ }
94
+
95
+ /** 逐字段校验:坏值与缺字段各自回退默认,不让配置错误中断采集 */
96
+ export function normalizeConfig(raw: unknown, log: (msg: string) => void = () => {}): EvolutionConfig {
97
+ const input = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
98
+ // ui / web 是嵌套对象:整块缺 / 类型不对时整块回退,个别字段坏只回退那个字段
99
+ const ui = (input.ui && typeof input.ui === "object" ? input.ui : {}) as Record<string, unknown>;
100
+ const web = (input.web && typeof input.web === "object" ? input.web : {}) as Record<string, unknown>;
101
+ return {
102
+ enabled: bool(input.enabled, DEFAULT_CONFIG.enabled),
103
+ idleMs: positiveInt(input.idleMs, DEFAULT_CONFIG.idleMs),
104
+ maxRuntimeMs: positiveInt(input.maxRuntimeMs, DEFAULT_CONFIG.maxRuntimeMs),
105
+ perSessionCap: positiveInt(input.perSessionCap, DEFAULT_CONFIG.perSessionCap),
106
+ telemetry: bool(input.telemetry, DEFAULT_CONFIG.telemetry),
107
+ ui: {
108
+ widget: bool(ui.widget, DEFAULT_CONFIG.ui.widget),
109
+ entries: bool(ui.entries, DEFAULT_CONFIG.ui.entries),
110
+ },
111
+ web: {
112
+ enabled: bool(web.enabled, DEFAULT_CONFIG.web.enabled),
113
+ port: webPort(web.port, log),
114
+ },
115
+ };
116
+ }
117
+
118
+ /** 读原始字段(保留后续阶段可能追加的键);解析失败返回空对象 */
119
+ export function readRawConfig(evolutionDir: string): Record<string, unknown> {
120
+ try {
121
+ const raw: unknown = JSON.parse(fs.readFileSync(configFile(evolutionDir), "utf8"));
122
+ return raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {};
123
+ } catch {
124
+ return {};
125
+ }
126
+ }
127
+
128
+ /** 读配置:文件不存在 → 默认值(静默);文件坏 → 默认值 + 一行日志 */
129
+ export function loadConfig(evolutionDir: string, log: (msg: string) => void = () => {}): EvolutionConfig {
130
+ const file = configFile(evolutionDir);
131
+ try {
132
+ return normalizeConfig(JSON.parse(fs.readFileSync(file, "utf8")), log);
133
+ } catch (error) {
134
+ if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") {
135
+ log(`[skill-evolve] 配置读取失败,改用默认值:${String(error)}`);
136
+ }
137
+ return { ...DEFAULT_CONFIG };
138
+ }
139
+ }
140
+
141
+ /**
142
+ * 只写一个开关(`/skill-evolve collect|review`、Web 控制条的单个开关):
143
+ * 目标字段按传入值写,其余键(另一个开关、去抖 / 上限、ui / web 块)原样保留。
144
+ * 返回归一化后的新配置,调用方用 Object.assign 原地更新正在用的那份。
145
+ */
146
+ export async function saveFlag(
147
+ evolutionDir: string,
148
+ field: ConfigFlag,
149
+ value: boolean,
150
+ queue: MutationQueue,
151
+ ): Promise<EvolutionConfig> {
152
+ const raw = readRawConfig(evolutionDir);
153
+ const next = normalizeConfig({ ...raw, [field]: value });
154
+ const file = configFile(evolutionDir);
155
+ await queue(file, async () => {
156
+ await fs.promises.mkdir(evolutionDir, { recursive: true });
157
+ await fs.promises.writeFile(file, `${JSON.stringify({ ...raw, ...next }, null, 2)}\n`, "utf8");
158
+ });
159
+ return next;
160
+ }
161
+
162
+ /**
163
+ * 一键全开/全关(`/skill-evolve on|off`):两次单字段写入,语义仍是「采集与派生一起切」。
164
+ * 保留这个入口是因为「先全关再走」比连点两个开关更不容易漏 —— 但它不再是唯一的开关路径。
165
+ * 两次写在同一队列里顺序执行,最终状态一定是两个字段都等于 `on`。
166
+ */
167
+ export async function saveConfigSwitch(
168
+ evolutionDir: string,
169
+ on: boolean,
170
+ queue: MutationQueue,
171
+ ): Promise<EvolutionConfig> {
172
+ await saveFlag(evolutionDir, "enabled", on, queue);
173
+ return saveFlag(evolutionDir, "telemetry", on, queue);
174
+ }
175
+
176
+ /**
177
+ * 只改 web 块(`/skill-evolve web` 写 enabled=true、`off` 写 enabled=false):
178
+ * 与 `saveConfigSwitch` 一样保留其余键 —— 开关面板不该顺手改掉去抖、上限或模型配置。
179
+ */
180
+ export async function saveWebConfig(
181
+ evolutionDir: string,
182
+ patch: { enabled?: boolean; port?: number },
183
+ queue: MutationQueue,
184
+ ): Promise<EvolutionConfig> {
185
+ const raw = readRawConfig(evolutionDir);
186
+ const web = (raw.web && typeof raw.web === "object" ? raw.web : {}) as Record<string, unknown>;
187
+ const next = normalizeConfig({ ...raw, web: { ...web, ...patch } });
188
+ const file = configFile(evolutionDir);
189
+ await queue(file, async () => {
190
+ await fs.promises.mkdir(evolutionDir, { recursive: true });
191
+ await fs.promises.writeFile(file, `${JSON.stringify({ ...raw, ...next }, null, 2)}\n`, "utf8");
192
+ });
193
+ return next;
194
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * L0 采集的技能文件识别(纯函数,零依赖,可直接被 node --test 跑)。
3
+ *
4
+ * 只做事实判定:这一条工具调用是否读了某个技能文件、读的是哪个技能。
5
+ * 「读到了有没有照做」是复盘阶段的事,这里不猜。
6
+ */
7
+ import * as path from "node:path";
8
+
9
+ export interface SkillHit {
10
+ /** 技能名(= SKILL.md 的父目录名) */
11
+ skill: string;
12
+ /** 识别途径:read 高置信、bash 中置信 */
13
+ how: "read" | "bash";
14
+ /** 与命中有关的原始路径(绝对路径或命令里的路径片段),写进遥测做证据 */
15
+ raw: string;
16
+ }
17
+
18
+ const SKILL_FILE = "SKILL.md";
19
+ const SKILL_SUFFIX = `/${SKILL_FILE}`;
20
+
21
+ function normalize(filePath: string): string {
22
+ return filePath.replace(/\\/g, "/");
23
+ }
24
+
25
+ /** `.../<名>/SKILL.md` → 名;不符合返回 null */
26
+ export function skillNameFromPath(filePath: string): string | null {
27
+ const normalized = normalize(String(filePath ?? "")).replace(/\/+$/, "");
28
+ if (normalized.length <= SKILL_SUFFIX.length) return null;
29
+ if (normalized.slice(-SKILL_SUFFIX.length).toLowerCase() !== SKILL_SUFFIX.toLowerCase()) return null;
30
+ const dir = normalized.slice(0, normalized.length - SKILL_SUFFIX.length);
31
+ const name = dir.slice(dir.lastIndexOf("/") + 1);
32
+ if (name.length === 0 || name === "." || name === "..") return null;
33
+ return name;
34
+ }
35
+
36
+ /** read 工具:去掉 `@` 前缀后按 cwd 解析成绝对路径,必须以 `/SKILL.md` 结尾 */
37
+ export function detectFromRead(rawPath: string, cwd: string): SkillHit | null {
38
+ const raw = String(rawPath ?? "").trim().replace(/^@/, "");
39
+ if (raw.length === 0) return null;
40
+ const absolute = normalize(path.resolve(cwd, raw));
41
+ const skill = skillNameFromPath(absolute);
42
+ return skill ? { skill, how: "read", raw: absolute } : null;
43
+ }
44
+
45
+ /**
46
+ * bash 工具:命令串里出现 `skills/<名>/SKILL.md`。
47
+ * 路径两侧的引号不在字符集里,所以 `cat ".pi/skills/x/SKILL.md"` 与不带引号都能命中。
48
+ */
49
+ export function detectFromBash(command: string): SkillHit[] {
50
+ const text = String(command ?? "");
51
+ if (!text.includes(SKILL_FILE)) return [];
52
+ // 正则放在函数内:带 g 的正则共享 lastIndex,模块级复用会在并发调用间串味。
53
+ const pattern = /[^\s"'`]*skills\/[A-Za-z0-9._-]+\/SKILL\.md/gi;
54
+ const hits = new Map<string, SkillHit>();
55
+ for (const match of text.matchAll(pattern)) {
56
+ const raw = match[0];
57
+ const skill = skillNameFromPath(raw);
58
+ if (!skill || hits.has(skill)) continue;
59
+ hits.set(skill, { skill, how: "bash", raw });
60
+ }
61
+ return [...hits.values()];
62
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * 把 unified diff 应用到「改动前的技能全文」,给评审员一份「改后全文」。
3
+ *
4
+ * 为什么不用 git apply:评审督导是独立 node 进程、只拿到 run 目录里的 diff 文本,
5
+ * 不能假设工作区是干净的(更不能在这里 git 操作 — 设计稿闸0 要求改动面可核对)。
6
+ * 这里只做最小实现:优先逐行精确匹配(忽略行尾空白),匹配不上就如实返回失败,
7
+ * 由调用方在 prompt 里写明「全文是改动前的版本,只有 diff 可信」,绝不猜。
8
+ */
9
+
10
+ export interface ApplyResult {
11
+ ok: boolean;
12
+ /** 应用成功是改后全文;失败时返回原文(调用方据此在 prompt 里说明) */
13
+ text: string;
14
+ /** diff 里声明的新文件路径(去掉 a/ b/ 前缀),用于回读原文 */
15
+ file: string | null;
16
+ reason?: string;
17
+ }
18
+
19
+ interface Hunk {
20
+ oldStart: number;
21
+ body: string[];
22
+ }
23
+
24
+ const HUNK_HEAD = /^@@ -(\d+)(?:,\d+)? \+\d+(?:,\d+)? @@/;
25
+
26
+ /** 只取第一段文件头的 hunk:技能提案本来就是单文件改动 */
27
+ function parseHunks(diffText: string): { hunks: Hunk[]; path: string | null } {
28
+ const hunks: Hunk[] = [];
29
+ let path: string | null = null;
30
+ let current: Hunk | null = null;
31
+ for (const raw of diffText.split(/\r?\n/)) {
32
+ const line = raw.replace(/\t$/, "");
33
+ if (line.startsWith("+++ ")) {
34
+ const head = line.slice(4).split("\t")[0].trim();
35
+ if (head !== "/dev/null" && head.length > 0) path ??= head.replace(/^[ab]\//, "");
36
+ continue;
37
+ }
38
+ const head = HUNK_HEAD.exec(line);
39
+ if (head) {
40
+ current = { oldStart: Number.parseInt(head[1], 10), body: [] };
41
+ hunks.push(current);
42
+ continue;
43
+ }
44
+ if (!current) continue;
45
+ if (line.startsWith("\\ ")) continue; //
46
+ if (line.length === 0) continue; // 夹在 hunk 之间的空行
47
+ current.body.push(line);
48
+ }
49
+ return { hunks, path };
50
+ }
51
+
52
+ function sameLine(a: string | undefined, b: string): boolean {
53
+ return a !== undefined && a.replace(/\s+$/, "") === b.replace(/\s+$/, "");
54
+ }
55
+
56
+ export function applyUnifiedDiff(baseText: string, diffText: string): ApplyResult {
57
+ const { hunks, path } = parseHunks(diffText);
58
+ if (hunks.length === 0) return { ok: false, text: baseText, file: path, reason: "diff 里没有 hunk" };
59
+ const lines = baseText.split("\n");
60
+ const out: string[] = [];
61
+ let cursor = 0;
62
+ for (const hunk of hunks) {
63
+ // 新建文件的 hunk 头是 `@@ -0,0 +1,N @@`:旧文件没有行,起点必须是 0 而不是 -1
64
+ const start = hunk.oldStart === 0 ? 0 : hunk.oldStart - 1;
65
+ if (start < cursor || start > lines.length) {
66
+ return { ok: false, text: baseText, file: path, reason: `hunk 位置越界(第 ${hunk.oldStart} 行)` };
67
+ }
68
+ out.push(...lines.slice(cursor, start));
69
+ cursor = start;
70
+ for (const entry of hunk.body) {
71
+ const tag = entry[0];
72
+ const content = entry.slice(1);
73
+ if (tag === " " || tag === "-") {
74
+ if (!sameLine(lines[cursor], content)) {
75
+ return { ok: false, text: baseText, file: path, reason: `第 ${cursor + 1} 行与原文件不一致:${content.trim().slice(0, 60)}` };
76
+ }
77
+ if (tag === " ") out.push(lines[cursor]);
78
+ cursor += 1;
79
+ continue;
80
+ }
81
+ if (tag === "+") {
82
+ out.push(content);
83
+ continue;
84
+ }
85
+ return { ok: false, text: baseText, file: path, reason: `无法识别的 diff 行:${entry.slice(0, 40)}` };
86
+ }
87
+ }
88
+ out.push(...lines.slice(cursor));
89
+ return { ok: true, text: out.join("\n"), file: path };
90
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * diff 的两个小工具(不是通用 diff 实现):
3
+ * ① `splitDiffByFile`:把多文件 diff 按文件切开,逐文件应用到各自的原文;
4
+ * ② `renderUnifiedDiff`:把「本次真实写盘前/后内容」渲染成 `05-applied.diff`(整文件单 hunk)。
5
+ *
6
+ * 为什么要自己渲染 05:设计要求它是**实际落盘的 diff**,不是提案里的那份 ——
7
+ * 提案可能被闸3拦下或回滚,落盘 diff 必须来自真实内容。
8
+ */
9
+
10
+ export interface DiffChunk {
11
+ /** null = 删除文件(`+++ /dev/null`) */
12
+ path: string | null;
13
+ chunk: string;
14
+ }
15
+
16
+ export function splitDiffByFile(text: string): DiffChunk[] {
17
+ const lines = text.split(/\r?\n/);
18
+ const chunks: DiffChunk[] = [];
19
+ let head: string[] = [];
20
+ let body: string[] = [];
21
+ let path: string | null = null;
22
+ let inHunk = false;
23
+
24
+ const flush = (): void => {
25
+ if (path !== null || body.length > 0) chunks.push({ path, chunk: [...head, ...body].join("\n") });
26
+ head = [];
27
+ body = [];
28
+ path = null;
29
+ inHunk = false;
30
+ };
31
+
32
+ for (let index = 0; index < lines.length; index += 1) {
33
+ const line = lines[index];
34
+ // 文件头成对出现:`--- a/x` 紧跟 `+++ b/x` —— 见到这样的 `---` 就收掉上一段
35
+ if (line.startsWith("--- ") && lines[index + 1]?.startsWith("+++ ")) {
36
+ flush();
37
+ head.push(line);
38
+ continue;
39
+ }
40
+ if (line.startsWith("+++ ")) {
41
+ const raw = line.slice(4).split("\t")[0].trim();
42
+ path = raw === "/dev/null" || raw.length === 0 ? null : raw.replace(/^[ab]\//, "");
43
+ head.push(line);
44
+ continue;
45
+ }
46
+ if (line.startsWith("@@")) {
47
+ inHunk = true;
48
+ body.push(line);
49
+ continue;
50
+ }
51
+ if (!inHunk && (line.startsWith("diff ") || line.startsWith("index ") || line.startsWith("new file mode") || line.startsWith("deleted file mode"))) {
52
+ head.push(line);
53
+ continue;
54
+ }
55
+ body.push(line);
56
+ }
57
+ flush();
58
+ return chunks;
59
+ }
60
+
61
+ function toLines(text: string | null): string[] {
62
+ if (text === null) return [];
63
+ const body = text.endsWith("\n") ? text.slice(0, -1) : text;
64
+ return body.length === 0 ? [] : body.split("\n");
65
+ }
66
+
67
+ /** 整文件单 hunk 的 diff:只用于留证,不参与再应用(应用走 diff-apply.ts) */
68
+ export function renderUnifiedDiff(relPath: string, before: string | null, after: string): string {
69
+ const oldLines = toLines(before);
70
+ const newLines = toLines(after);
71
+ const header = [
72
+ `--- ${before === null ? "/dev/null" : `a/${relPath}`}`,
73
+ `+++ b/${relPath}`,
74
+ `@@ -1,${oldLines.length} +1,${newLines.length} @@`,
75
+ ];
76
+ const body = [...oldLines.map((line) => `-${line}`), ...newLines.map((line) => `+${line}`)];
77
+ return `${[...header, ...body].join("\n")}\n`;
78
+ }