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,167 @@
1
+ /**
2
+ * G3 机械硬禁令扫描(设计稿 9.1 六条):只做「出现即 P0」的机械检查,不做语义判断。
3
+ *
4
+ * 规则正文在 .pi/evolution/rules/hard-rules.json(入库),这里只负责解析、编译、逐行扫描。
5
+ * 一条规则坏掉不许拖垮整条链路:逐条 try/catch,坏的记 invalid 后跳过。
6
+ */
7
+
8
+ import { readFileSync, readdirSync, existsSync } from "node:fs";
9
+ import { join } from "node:path";
10
+
11
+ export interface HardRule {
12
+ id: string;
13
+ level: string;
14
+ pattern: string;
15
+ expect: string;
16
+ /** 同一行里出现即豁免的写法:本仓技能正文大量引用「禁止 grep -r」这类反例 */
17
+ allow?: string;
18
+ flags?: string;
19
+ why?: string;
20
+ }
21
+
22
+ export interface HardRuleHit {
23
+ ruleId: string;
24
+ level: string;
25
+ skill: string;
26
+ /** SKILL.md 的 1 基行号 */
27
+ line: number;
28
+ quote: string;
29
+ }
30
+
31
+ export interface CompiledRule {
32
+ rule: HardRule;
33
+ regex: RegExp;
34
+ allowRegex?: RegExp;
35
+ }
36
+
37
+ export interface CompiledRules {
38
+ compiled: CompiledRule[];
39
+ /** 坏规则 / 缺字段的规则,形如 `<规则id>: <原因>` */
40
+ invalid: string[];
41
+ }
42
+
43
+ export interface HardRuleScan {
44
+ hits: HardRuleHit[];
45
+ invalid: string[];
46
+ }
47
+
48
+ const QUOTE_MAX = 120;
49
+
50
+ function compileOne(raw: unknown): CompiledRule | string {
51
+ if (typeof raw !== "object" || raw === null) return "无效规则:不是对象";
52
+ const rule = raw as Partial<HardRule>;
53
+ if (typeof rule.id !== "string" || rule.id.length === 0) return "无效规则:缺 id";
54
+ if (typeof rule.pattern !== "string" || rule.pattern.length === 0) return `${rule.id}: 缺 pattern`;
55
+ const level = typeof rule.level === "string" && rule.level.length > 0 ? rule.level : "P0";
56
+ const flags = typeof rule.flags === "string" ? rule.flags : "";
57
+ try {
58
+ const regex = new RegExp(rule.pattern, flags);
59
+ const allowRegex = typeof rule.allow === "string" && rule.allow.length > 0 ? new RegExp(rule.allow, flags) : undefined;
60
+ return {
61
+ rule: {
62
+ id: rule.id,
63
+ level,
64
+ pattern: rule.pattern,
65
+ expect: typeof rule.expect === "string" ? rule.expect : "",
66
+ why: typeof rule.why === "string" ? rule.why : "",
67
+ allow: rule.allow,
68
+ flags,
69
+ },
70
+ regex,
71
+ allowRegex,
72
+ };
73
+ } catch (error) {
74
+ return `${rule.id}: 正则编译失败(${(error as Error).message})`;
75
+ }
76
+ }
77
+
78
+ /** 编译规则集:坏的跳过并记原因,绝不抛 */
79
+ export function compileRules(raw: unknown): CompiledRules {
80
+ const list = Array.isArray((raw as { rules?: unknown })?.rules) ? ((raw as { rules: unknown[] }).rules) : [];
81
+ const compiled: CompiledRule[] = [];
82
+ const invalid: string[] = [];
83
+ for (const item of list) {
84
+ const result = compileOne(item);
85
+ if (typeof result === "string") invalid.push(result);
86
+ else compiled.push(result);
87
+ }
88
+ return { compiled, invalid };
89
+ }
90
+
91
+ /** 扫描一段 markdown 正文,返回命中行。纯函数,line 从 1 开始 */
92
+ export function scanText(text: string, rules: CompiledRule[], skill: string): HardRuleHit[] {
93
+ const hits: HardRuleHit[] = [];
94
+ const lines = text.split("\n");
95
+ for (let index = 0; index < lines.length; index += 1) {
96
+ const line = lines[index];
97
+ if (line.length === 0) continue;
98
+ for (const item of rules) {
99
+ if (!item.regex.test(line)) continue;
100
+ if (item.allowRegex !== undefined && item.allowRegex.test(line)) continue;
101
+ hits.push({
102
+ ruleId: item.rule.id,
103
+ level: item.rule.level,
104
+ skill,
105
+ line: index + 1,
106
+ quote: line.trim().slice(0, QUOTE_MAX),
107
+ });
108
+ }
109
+ }
110
+ return hits;
111
+ }
112
+
113
+ /** 读规则文件;文件缺失 / JSON 坏 / 读失败都只返回 invalid,不抛 */
114
+ export function loadHardRules(file: string): CompiledRules & { file: string } {
115
+ if (!existsSync(file)) return { file, compiled: [], invalid: [`规则文件不存在:${file}`] };
116
+ let text: string;
117
+ try {
118
+ text = readFileSync(file, "utf8");
119
+ } catch (error) {
120
+ return { file, compiled: [], invalid: [`规则文件读取失败:${(error as Error).message}`] };
121
+ }
122
+ let parsed: unknown;
123
+ try {
124
+ parsed = JSON.parse(text);
125
+ } catch (error) {
126
+ return { file, compiled: [], invalid: [`规则文件 JSON 解析失败:${(error as Error).message}`] };
127
+ }
128
+ return { file, ...compileRules(parsed) };
129
+ }
130
+
131
+ /** 扫 `.pi/skills/<名>/SKILL.md`;单个文件读失败只跳过它并记 invalid */
132
+ export function scanSkillsDir(skillsDir: string, rules: CompiledRule[]): HardRuleScan {
133
+ const hits: HardRuleHit[] = [];
134
+ const invalid: string[] = [];
135
+ if (!existsSync(skillsDir)) return { hits, invalid: [`技能目录不存在:${skillsDir}`] };
136
+ let entries: string[];
137
+ try {
138
+ entries = readdirSync(skillsDir, { withFileTypes: true })
139
+ .filter((entry) => entry.isDirectory())
140
+ .map((entry) => entry.name);
141
+ } catch (error) {
142
+ return { hits, invalid: [`技能目录读取失败:${(error as Error).message}`] };
143
+ }
144
+ for (const skill of entries) {
145
+ const file = join(skillsDir, skill, "SKILL.md");
146
+ if (!existsSync(file)) continue;
147
+ try {
148
+ hits.push(...scanText(readFileSync(file, "utf8"), rules, skill));
149
+ } catch (error) {
150
+ invalid.push(`${skill}: 读取失败(${(error as Error).message})`);
151
+ }
152
+ }
153
+ return { hits, invalid };
154
+ }
155
+
156
+ /** 磁盘上 `.pi/skills/*` 的目录名清单,用于 D 类判定 */
157
+ export function listSkillDirs(skillsDir: string): string[] {
158
+ if (!existsSync(skillsDir)) return [];
159
+ try {
160
+ return readdirSync(skillsDir, { withFileTypes: true })
161
+ .filter((entry) => entry.isDirectory())
162
+ .map((entry) => entry.name)
163
+ .sort();
164
+ } catch {
165
+ return [];
166
+ }
167
+ }
@@ -0,0 +1,215 @@
1
+ /**
2
+ * 跨进程目录锁:`mkdir .pi/evolution/locks/dispatch` 原子创建即拿到锁。
3
+ *
4
+ * 为什么不用内存锁:复盘子进程是独立的 pi 进程,同一时刻只允许一个 run。
5
+ * 为什么不用文件锁库:零第三方依赖,`mkdir` 在 POSIX 与 Windows 上都是原子的。
6
+ * 防死锁:kill -9 之后目录会留下,靠「owner 的 pid 已不存在」立即回收;只有 owner.json
7
+ * 本身读不出来时才退化成「超过 2×maxRuntimeMs」兜底。
8
+ */
9
+ import * as fs from "node:fs";
10
+ import * as path from "node:path";
11
+
12
+ export interface LockOwner {
13
+ pid: number;
14
+ /** 抢锁毫秒戳 */
15
+ ts: number;
16
+ sessionId?: string;
17
+ }
18
+
19
+ /** pid 是否存活:EPERM 说明进程存在但当前用户无权发信号,同样算活着 */
20
+ export function isPidAlive(pid: number): boolean {
21
+ if (!Number.isInteger(pid) || pid <= 0) return false;
22
+ try {
23
+ process.kill(pid, 0);
24
+ return true;
25
+ } catch (error) {
26
+ return (error as NodeJS.ErrnoException)?.code === "EPERM";
27
+ }
28
+ }
29
+
30
+ export function lockDir(evolutionDir: string): string {
31
+ return path.join(evolutionDir, "locks", "dispatch");
32
+ }
33
+
34
+ /**
35
+ * 是否回收这把锁(纯函数,可单测)。判据分两类:
36
+ * 1. owner.json 能读出合法 pid → **只看进程是否存活**:pid 已死立即回收,与等了多久无关。
37
+ * 理由:kill(pid, 0) 说「进程不存在」就说明没人持有这把锁;pid 复用导致的误判方向是「假活」
38
+ * (继续阻塞,安全),不是「假死」(抢锁,危险)。
39
+ * 2. owner.json 缺失 / JSON 坏 / pid 或 ts 非法 → 无法证明有活着的持有者,才用
40
+ * now - ts >= 2×maxRuntimeMs 兜底(ts 也读不出来时按残锁直接回收)。
41
+ */
42
+ export function shouldReclaim(
43
+ owner: LockOwner | null,
44
+ now: number,
45
+ maxRuntimeMs: number,
46
+ alive: (pid: number) => boolean = isPidAlive,
47
+ ): boolean {
48
+ const pid = owner?.pid;
49
+ const ts = owner?.ts;
50
+ if (owner && Number.isInteger(pid) && pid > 0 && Number.isFinite(ts)) {
51
+ return !alive(pid);
52
+ }
53
+ if (!owner || !Number.isFinite(ts)) return true;
54
+ return now - (ts as number) >= 2 * maxRuntimeMs;
55
+ }
56
+
57
+ export function readOwnerSync(dir: string): LockOwner | null {
58
+ try {
59
+ const raw: unknown = JSON.parse(fs.readFileSync(path.join(dir, "owner.json"), "utf8"));
60
+ if (!raw || typeof raw !== "object") return null;
61
+ const owner = raw as LockOwner;
62
+ return { pid: Number(owner.pid), ts: Number(owner.ts), sessionId: owner.sessionId };
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+
68
+ export async function readOwner(dir: string): Promise<LockOwner | null> {
69
+ return readOwnerSync(dir);
70
+ }
71
+
72
+ export interface AcquireOptions {
73
+ evolutionDir: string;
74
+ owner: LockOwner;
75
+ maxRuntimeMs: number;
76
+ log?: (msg: string) => void;
77
+ now?: () => number;
78
+ }
79
+
80
+ export interface AcquireResult {
81
+ ok: boolean;
82
+ why?: "busy" | "error";
83
+ /** 抢失败时把当前的持有者带出来,日志与 status 用得上 */
84
+ owner?: LockOwner | null;
85
+ }
86
+
87
+ export async function acquireLock(options: AcquireOptions): Promise<AcquireResult> {
88
+ const log = options.log ?? (() => {});
89
+ const dir = lockDir(options.evolutionDir);
90
+ const now = options.now?.() ?? Date.now();
91
+
92
+ for (let attempt = 0; attempt < 2; attempt += 1) {
93
+ try {
94
+ await fs.promises.mkdir(path.dirname(dir), { recursive: true });
95
+ await fs.promises.mkdir(dir);
96
+ } catch (error) {
97
+ if ((error as NodeJS.ErrnoException)?.code !== "EEXIST") {
98
+ log(`[skill-evolve] 抢锁失败(mkdir):${String(error)}`);
99
+ return { ok: false, why: "error" };
100
+ }
101
+ // 目录已存在:要么别人正拿着,要么是残锁
102
+ const owner = await readOwner(dir);
103
+ if (!shouldReclaim(owner, now, options.maxRuntimeMs)) return { ok: false, why: "busy", owner };
104
+ log(
105
+ `[skill-evolve] 回收孤儿锁:owner=${owner ? `pid ${owner.pid} @ ${new Date(owner.ts).toISOString()}` : "无 owner.json"}`,
106
+ );
107
+ try {
108
+ await fs.promises.rm(dir, { recursive: true, force: true });
109
+ } catch (rmError) {
110
+ log(`[skill-evolve] 回收残锁失败:${String(rmError)}`);
111
+ return { ok: false, why: "error" };
112
+ }
113
+ continue;
114
+ }
115
+ try {
116
+ await fs.promises.writeFile(path.join(dir, "owner.json"), `${JSON.stringify(options.owner)}\n`, "utf8");
117
+ log(`[skill-evolve] 起锁:pid ${options.owner.pid}(${dir})`);
118
+ return { ok: true };
119
+ } catch (error) {
120
+ // 目录建好了但 owner 写不进去:立刻还回去,避免留下无人认领的锁
121
+ log(`[skill-evolve] 写 owner.json 失败:${String(error)}`);
122
+ await releaseLock(options.evolutionDir, options.log);
123
+ return { ok: false, why: "error" };
124
+ }
125
+ }
126
+ return { ok: false, why: "busy" };
127
+ }
128
+
129
+ /** 释放锁(幂等):目录不在也算成功 */
130
+ export async function releaseLock(evolutionDir: string, log: (msg: string) => void = () => {}): Promise<void> {
131
+ const dir = lockDir(evolutionDir);
132
+ try {
133
+ await fs.promises.rm(dir, { recursive: true, force: true });
134
+ } catch (error) {
135
+ log(`[skill-evolve] 释放锁失败:${String(error)}`);
136
+ }
137
+ }
138
+
139
+ function writeOwnerFile(dir: string, owner: LockOwner): void {
140
+ fs.writeFileSync(path.join(dir, "owner.json"), `${JSON.stringify(owner)}\n`, "utf8");
141
+ }
142
+
143
+ /**
144
+ * 同步抢锁:只给 `session_shutdown` 用 —— 主进程马上要退出,不能 await。
145
+ * 语义与 acquireLock 完全一致(mkdir 原子抢占 + 两轮「回收孤儿再抢」)。
146
+ */
147
+ export function acquireLockSync(options: AcquireOptions): AcquireResult {
148
+ const log = options.log ?? (() => {});
149
+ const dir = lockDir(options.evolutionDir);
150
+ const now = options.now?.() ?? Date.now();
151
+
152
+ for (let attempt = 0; attempt < 2; attempt += 1) {
153
+ try {
154
+ fs.mkdirSync(path.dirname(dir), { recursive: true });
155
+ fs.mkdirSync(dir);
156
+ } catch (error) {
157
+ if ((error as NodeJS.ErrnoException)?.code !== "EEXIST") {
158
+ log(`[skill-evolve] 抢锁失败(mkdir):${String(error)}`);
159
+ return { ok: false, why: "error" };
160
+ }
161
+ const owner = readOwnerSync(dir);
162
+ if (!shouldReclaim(owner, now, options.maxRuntimeMs)) return { ok: false, why: "busy", owner };
163
+ log(
164
+ `[skill-evolve] 回收孤儿锁:owner=${owner ? `pid ${owner.pid} @ ${new Date(owner.ts).toISOString()}` : "无 owner.json"}`,
165
+ );
166
+ try {
167
+ fs.rmSync(dir, { recursive: true, force: true });
168
+ } catch (rmError) {
169
+ log(`[skill-evolve] 回收残锁失败:${String(rmError)}`);
170
+ return { ok: false, why: "error" };
171
+ }
172
+ continue;
173
+ }
174
+ try {
175
+ writeOwnerFile(dir, options.owner);
176
+ log(`[skill-evolve] 起锁:pid ${options.owner.pid}(${dir})`);
177
+ return { ok: true };
178
+ } catch (error) {
179
+ log(`[skill-evolve] 写 owner.json 失败:${String(error)}`);
180
+ releaseLockSync(options.evolutionDir, options.log);
181
+ return { ok: false, why: "error" };
182
+ }
183
+ }
184
+ return { ok: false, why: "busy" };
185
+ }
186
+
187
+ /** 换掉锁的持有者:退出补发后要把 owner 指向复盘子进程,避免下次把活着的 run 当孤儿回收 */
188
+ export function writeOwnerSync(
189
+ evolutionDir: string,
190
+ owner: LockOwner,
191
+ log: (msg: string) => void = () => {},
192
+ ): boolean {
193
+ try {
194
+ writeOwnerFile(lockDir(evolutionDir), owner);
195
+ return true;
196
+ } catch (error) {
197
+ log(`[skill-evolve] 改写 owner.json 失败:${String(error)}`);
198
+ return false;
199
+ }
200
+ }
201
+
202
+ /** 同步放锁:只给同步补发路径用(失败也只记日志) */
203
+ export function releaseLockSync(evolutionDir: string, log: (msg: string) => void = () => {}): void {
204
+ try {
205
+ fs.rmSync(lockDir(evolutionDir), { recursive: true, force: true });
206
+ } catch (error) {
207
+ log(`[skill-evolve] 释放锁失败:${String(error)}`);
208
+ }
209
+ }
210
+
211
+ export async function lockState(evolutionDir: string): Promise<{ held: boolean; owner: LockOwner | null }> {
212
+ const dir = lockDir(evolutionDir);
213
+ const held = fs.existsSync(dir);
214
+ return { held, owner: held ? await readOwner(dir) : null };
215
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * 扩展自己的日志文件:`.pi/evolution/logs/extension.log`。
3
+ *
4
+ * 为什么不打终端(用户 2026-11-08:「tui 的这些推送不看了,影响我」):每一次 settle 都可能派生
5
+ * 复盘 + 督导,台面上每轮刷两行进程号,等于把对话区当日志区。所以**默认一律落盘**,
6
+ * 只有 `PI_SKILL_EVOLVE_DEBUG=1` 才同步回显一份到 stdout(排查时才开)。
7
+ *
8
+ * 轮转:单文件超过 1MB 就改名成 `.1`(只留一份历史,够查一次事故,又不至于占满磁盘)。
9
+ * 写日志失败绝不向上抛:这条链路是旁路,不能因为盘满把派发搞挂。
10
+ */
11
+ import * as fs from "node:fs";
12
+ import * as path from "node:path";
13
+
14
+ /** 轮转阈值:1MB;超过就把当前文件换成 `.1` */
15
+ export const LOG_MAX_BYTES = 1024 * 1024;
16
+
17
+ /** 日志文件路径(父目录由 logLine 按需创建) */
18
+ export function logFile(evolutionDir: string): string {
19
+ return path.join(evolutionDir, "logs", "extension.log");
20
+ }
21
+
22
+ function rotateIfTooBig(file: string): void {
23
+ let size: number;
24
+ try {
25
+ size = fs.statSync(file).size;
26
+ } catch {
27
+ return; // 还没有日志文件:第一条 append 会自己建
28
+ }
29
+ if (size <= LOG_MAX_BYTES) return;
30
+ try {
31
+ // POSIX 下 rename 直接覆盖旧 `.1`:只留最近一轮历史,不做无限堆叠
32
+ fs.renameSync(file, `${file}.1`);
33
+ } catch {
34
+ // 轮转失败就继续追加:宁可文件变大,也不能丢日志
35
+ }
36
+ }
37
+
38
+ /**
39
+ * 追加一行日志。`debug` 缺省时看环境变量 `PI_SKILL_EVOLVE_DEBUG === "1"`;
40
+ * 显式传参只给单测用,免得测试之间互相污染环境变量。
41
+ */
42
+ export function logLine(evolutionDir: string, msg: string, debug?: boolean): void {
43
+ if (debug ?? process.env.PI_SKILL_EVOLVE_DEBUG === "1") console.log(msg);
44
+ const file = logFile(evolutionDir);
45
+ try {
46
+ fs.mkdirSync(path.dirname(file), { recursive: true });
47
+ rotateIfTooBig(file);
48
+ fs.appendFileSync(file, `${new Date().toISOString()} ${msg}\n`, "utf8");
49
+ } catch {
50
+ // 盘满 / 权限不足:日志是旁路,静默跳过比把主流程带崩更合适
51
+ }
52
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * 把机械层(归因 A~E + 硬禁令扫描)的结论写进遥测。
3
+ *
4
+ * 单独成文件的原因有两个:
5
+ * 1. evolution.ts 的 settle 只该负责「采集 + 门禁」,归因落盘的口径集中在一处更好查;
6
+ * 2. 这里能在单测里直接喂假遥测,验证「同一冲突只记一次」「故障只报一次」这两条去重规则。
7
+ */
8
+ import type { Attribution } from "./attribution.ts";
9
+ import { fingerprintOf } from "./baseline.ts";
10
+ import type { HardRuleHit } from "./hard-rules.ts";
11
+ import type { Mechanical } from "./mechanical.ts";
12
+ import type { TelemetryEvent, TelemetrySkill } from "./telemetry.ts";
13
+
14
+ export interface MechanicalReporterDeps {
15
+ mechanical: Mechanical;
16
+ /** 遥测 append(生产 = telemetry.append,测试 = 收集到数组) */
17
+ append: (event: TelemetryEvent) => void;
18
+ /** 命中是否属于存量债(设计稿 10.4):周报要能分开统计,不能混成一个数 */
19
+ stockOf?: (hit: HardRuleHit) => boolean;
20
+ log?: (message: string) => void;
21
+ }
22
+
23
+ export interface MechanicalReportRequest {
24
+ sessionId: string;
25
+ /** 本轮真正曝光的技能(已剔除 disabled) */
26
+ exposed: TelemetrySkill[];
27
+ /** 其中没被读过的技能名 */
28
+ missed: string[];
29
+ activeTools: string[];
30
+ }
31
+
32
+ export interface MechanicalReporter {
33
+ /** 发现失败只报一次,不要每轮重复撞同一面墙 */
34
+ noteFailure(): void;
35
+ /** 返回本轮归因结果(派发时带进 prompt,让复盘 agent 只做 F/G) */
36
+ report(request: MechanicalReportRequest): Attribution[];
37
+ /** 已上报过的冲突条数,仅用于测试与日志 */
38
+ conflictCount(): number;
39
+ }
40
+
41
+ export function createMechanicalReporter(deps: MechanicalReporterDeps): MechanicalReporter {
42
+ let failureReported = false;
43
+ /** 硬禁令是「文件现状」不是「本轮事件」:同一条命中只记一次,避免每轮刷屏 */
44
+ const reportedConflicts = new Set<string>();
45
+
46
+ const noteFailure = (): void => {
47
+ if (failureReported) return;
48
+ const failed = deps.mechanical.snapshot().failed;
49
+ if (failed === undefined) return;
50
+ failureReported = true;
51
+ deps.log?.(`[skill-evolve] 技能发现失败:${failed}`);
52
+ deps.append({ kind: "handler_error", where: "mechanical", message: failed });
53
+ };
54
+
55
+ const report = (request: MechanicalReportRequest): Attribution[] => {
56
+ noteFailure();
57
+ const attributions = deps.mechanical.attribute({
58
+ missed: request.missed,
59
+ exposed: request.exposed.map((skill) => ({
60
+ name: skill.name,
61
+ filePath: skill.filePath,
62
+ disabled: skill.disabled,
63
+ })),
64
+ activeTools: request.activeTools,
65
+ });
66
+ for (const item of attributions) {
67
+ deps.append({
68
+ kind: "attributed",
69
+ sessionId: request.sessionId,
70
+ skill: item.skill,
71
+ category: item.category,
72
+ reason: item.reason,
73
+ });
74
+ }
75
+ for (const hit of deps.mechanical.conflicts().hits) {
76
+ // 去重键用指纹(不含行号):行号漂移是同一条存量问题,不应被当成新命中再记一次
77
+ const key = fingerprintOf(hit);
78
+ if (reportedConflicts.has(key)) continue;
79
+ reportedConflicts.add(key);
80
+ deps.append({
81
+ kind: "conflict",
82
+ sessionId: request.sessionId,
83
+ ruleId: hit.ruleId,
84
+ level: hit.level,
85
+ skill: hit.skill,
86
+ line: hit.line,
87
+ quote: hit.quote,
88
+ stock: deps.stockOf?.(hit) ?? false,
89
+ });
90
+ }
91
+ return attributions;
92
+ };
93
+
94
+ return { noteFailure, report, conflictCount: () => reportedConflicts.size };
95
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * 机械层的现场勘查:把 pi 的技能发现结果、磁盘目录清单、硬禁令扫描结果缓存成一份快照,
3
+ * 再喂给纯函数做归因(attribution.ts)与冲突判定(hard-rules.ts)。
4
+ *
5
+ * 缓存与注入的理由:
6
+ * - `loadSkillsFromDir` 要解析 28 个 SKILL.md 的 frontmatter,每进程只做一次;
7
+ * - 硬禁令扫描要读 28 个文件(实测 8ms),只在第一次 settle 做一次,绝不放进 factory;
8
+ * - 所有依赖靠注入,这样本模块不 import pi 包,能被 `node --test` 直接跑。
9
+ */
10
+
11
+ import { join } from "node:path";
12
+ import {
13
+ classifyMechanical,
14
+ type Attribution,
15
+ type DiagnosticEntry,
16
+ type ExposedSkill,
17
+ } from "./attribution.ts";
18
+ import {
19
+ listSkillDirs,
20
+ loadHardRules,
21
+ scanSkillsDir,
22
+ type HardRuleHit,
23
+ type CompiledRules,
24
+ } from "./hard-rules.ts";
25
+
26
+ export interface SnapshotSkill {
27
+ name: string;
28
+ filePath: string;
29
+ }
30
+
31
+ export interface SkillsSnapshot {
32
+ skills: SnapshotSkill[];
33
+ diagnostics: DiagnosticEntry[];
34
+ /** 磁盘上 .pi/skills/* 的目录名 */
35
+ diskSkillNames: string[];
36
+ /** 发现失败的原因(此时 skills 为空,调用方要记 handler_error) */
37
+ failed?: string;
38
+ }
39
+
40
+ /** pi 的 ResourceDiagnostic → 本模块的 DiagnosticEntry(字段名是 path 不是 file) */
41
+ export interface RawDiagnostic {
42
+ path?: string;
43
+ message: string;
44
+ type?: string;
45
+ }
46
+
47
+ export interface MechanicalDeps {
48
+ cwd: string;
49
+ /** 生产实现:() => loadSkillsFromDir({ dir: join(cwd, ".pi/skills"), source: "project" }) */
50
+ loadSkills: () => { skills: SnapshotSkill[]; diagnostics: RawDiagnostic[] };
51
+ /** .pi/evolution/rules/hard-rules.json 绝对路径 */
52
+ rulesFile: string;
53
+ log: (message: string) => void;
54
+ }
55
+
56
+ export interface AttributeRequest {
57
+ /** 本轮曝光但没加载的技能名 */
58
+ missed: string[];
59
+ /** 本轮 prompt 里的技能 */
60
+ exposed: ExposedSkill[];
61
+ /** 本轮 activeTools(pi.getActiveTools()) */
62
+ activeTools: string[];
63
+ }
64
+
65
+ export interface ConflictScan {
66
+ hits: HardRuleHit[];
67
+ invalid: string[];
68
+ }
69
+
70
+ export interface Mechanical {
71
+ snapshot(): SkillsSnapshot;
72
+ attribute(request: AttributeRequest): Attribution[];
73
+ conflicts(): ConflictScan;
74
+ }
75
+
76
+ export function createMechanical(deps: MechanicalDeps): Mechanical {
77
+ const skillsDir = join(deps.cwd, ".pi", "skills");
78
+ let cachedSnapshot: SkillsSnapshot | undefined;
79
+ let cachedRules: CompiledRules | undefined;
80
+ let cachedConflicts: ConflictScan | undefined;
81
+
82
+ const snapshot = (): SkillsSnapshot => {
83
+ if (cachedSnapshot !== undefined) return cachedSnapshot;
84
+ const diskSkillNames = listSkillDirs(skillsDir);
85
+ try {
86
+ const result = deps.loadSkills();
87
+ cachedSnapshot = {
88
+ skills: result.skills.map((skill) => ({ name: skill.name, filePath: skill.filePath })),
89
+ diagnostics: result.diagnostics.map((entry) => ({
90
+ file: entry.path ?? "",
91
+ message: entry.message,
92
+ type: entry.type,
93
+ })),
94
+ diskSkillNames,
95
+ };
96
+ } catch (error) {
97
+ const message = (error as Error).message;
98
+ deps.log(`技能发现失败,机械归因降级为 D/semantic:${message}`);
99
+ cachedSnapshot = { skills: [], diagnostics: [], diskSkillNames, failed: message };
100
+ }
101
+ return cachedSnapshot;
102
+ };
103
+
104
+ const rules = (): CompiledRules => {
105
+ if (cachedRules !== undefined) return cachedRules;
106
+ const loaded = loadHardRules(deps.rulesFile);
107
+ for (const item of loaded.invalid) deps.log(`硬禁令规则不可用:${item}`);
108
+ cachedRules = { compiled: loaded.compiled, invalid: loaded.invalid };
109
+ return cachedRules;
110
+ };
111
+
112
+ return {
113
+ snapshot,
114
+
115
+ attribute(request: AttributeRequest): Attribution[] {
116
+ const current = snapshot();
117
+ return classifyMechanical({
118
+ missed: request.missed,
119
+ exposed: request.exposed,
120
+ loadedFromPi: current.skills,
121
+ diagnostics: current.diagnostics,
122
+ activeTools: request.activeTools,
123
+ diskSkillNames: current.diskSkillNames,
124
+ });
125
+ },
126
+
127
+ conflicts(): ConflictScan {
128
+ if (cachedConflicts !== undefined) return cachedConflicts;
129
+ const compiled = rules();
130
+ const scanned = scanSkillsDir(skillsDir, compiled.compiled);
131
+ for (const item of scanned.invalid) deps.log(`硬禁令扫描跳过:${item}`);
132
+ cachedConflicts = { hits: scanned.hits, invalid: [...compiled.invalid, ...scanned.invalid] };
133
+ return cachedConflicts;
134
+ },
135
+ };
136
+ }