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,80 @@
1
+ /**
2
+ * 复盘提示词拼装:模板正文 + 本轮输入(会话原文路径、索引、run 目录、覆盖轮数)。
3
+ *
4
+ * 单独成文件是为了让 dispatch.ts(常规派发)与 shutdown-dispatch.ts(退出前补发)
5
+ * 共用同一份拼装逻辑,同时不把两者卷进循环 import。
6
+ */
7
+ import * as fs from "node:fs";
8
+ import type { Attribution } from "./attribution.ts";
9
+
10
+ /** 被去抖合并掉的轮次范围:起点 + 合并了几轮 */
11
+ export interface PromptCoverage {
12
+ rounds: number;
13
+ since: number;
14
+ }
15
+
16
+ export interface PromptInput {
17
+ template: string;
18
+ sessionFile?: string;
19
+ /** 缺省表示没有会话索引(telemetry-only):prompt 里要明说索引不可用,别让子进程去读不存在的文件 */
20
+ indexFile?: string;
21
+ runDir: string;
22
+ sessionId: string;
23
+ skills: string[];
24
+ dispatchedAt: number;
25
+ /** 缺省表示机械层没给结论(发现失败 / 无归因),全部交给复盘 agent 判 */
26
+ attributions?: Attribution[];
27
+ /** 缺省表示只覆盖刚刚结束的那一轮 */
28
+ coverage?: PromptCoverage;
29
+ }
30
+
31
+ /** 模板缺失时的最小指令:宁可少说,也不能因为读不到模板就不派生 */
32
+ export const FALLBACK_TEMPLATE = [
33
+ "你在复盘上一轮会话里技能为什么没被加载。",
34
+ "只按索引抽查,禁止整文件读会话原文。",
35
+ "只写本 run 目录下的 01-findings.json,禁止改 .pi/skills/。",
36
+ ].join("\n");
37
+
38
+ export function buildPrompt(input: PromptInput): string {
39
+ const evidence = input.sessionFile
40
+ ? `会话原文:${input.sessionFile}`
41
+ : "会话原文:不可用(evidenceMode=telemetry-only,只按遥测做保守结论)";
42
+ const index = input.indexFile
43
+ ? `索引:${input.indexFile}`
44
+ : "索引:不可用(evidenceMode=telemetry-only,只按遥测事件下保守结论,evidence 一律留空)";
45
+ const coverage = input.coverage
46
+ ? `覆盖轮数:合并了 ${input.coverage.rounds} 轮待复盘区间(起点 ts=${new Date(input.coverage.since).toISOString()}),结论要覆盖整段,不要只看最后一轮`
47
+ : "覆盖轮数:1 轮(刚刚结束的这一轮)";
48
+ // 机械层已经定死的结论不要让复盘 agent 重判:它重判只会把「frontmatter 坏」改成「改写 description」
49
+ const mechanical = (input.attributions ?? []).length === 0
50
+ ? ["机械归因:无(本轮全部技能需要你判 F/G)"]
51
+ : [
52
+ `已机械归因(${(input.attributions ?? []).length} 条,结论直接沿用,不要重判):`,
53
+ ...(input.attributions ?? []).map((item) => ` ${item.category} ${item.skill} —— ${item.reason}`),
54
+ "你只对上面没出现的技能做 F/G 判定;已归因为 A~E 的不要重复判,也不要在 findings 里改它的 category。",
55
+ ];
56
+ return [
57
+ input.template.trimEnd(),
58
+ "",
59
+ "---",
60
+ `会话ID:${input.sessionId}`,
61
+ evidence,
62
+ `技能清单(逐个判定,不许遗漏也不许新增):${input.skills.length > 0 ? input.skills.join("、") : "(本轮无曝光技能)"}`,
63
+ coverage,
64
+ ...mechanical,
65
+ index,
66
+ `run 目录:${input.runDir}`,
67
+ `派生时间:${new Date(input.dispatchedAt).toISOString()}`,
68
+ "",
69
+ ].join("\n");
70
+ }
71
+
72
+ /** 同步读模板:派发时读一次就够,模板很小;读不到回退最小指令而不是放弃派发 */
73
+ export function readPromptTemplateSync(file: string, log: (msg: string) => void): string {
74
+ try {
75
+ return fs.readFileSync(file, "utf8");
76
+ } catch (error) {
77
+ log(`[skill-evolve] 复盘模板读取失败,改用内置最小指令:${String(error)}`);
78
+ return FALLBACK_TEMPLATE;
79
+ }
80
+ }
@@ -0,0 +1,227 @@
1
+ /**
2
+ * 提案产物的解析与校验(设计稿 九、十):M3 只做「生成 + 校验」,不评审、不写 `.pi/skills`。
3
+ *
4
+ * 复盘子进程把提案写在 `runs/<runId>/` 下:
5
+ * - 改现有技能:`02-proposal.json`(元信息)+ `02-proposal.diff`(unified diff,不是整篇)
6
+ * - 新建技能:`02-proposal.json` + `candidate/<名>/SKILL.md`
7
+ * 这里只做纯解析(diff / frontmatter)与读盘一层,方便 `node --test` 直接跑。
8
+ */
9
+ import * as fs from "node:fs";
10
+ import * as path from "node:path";
11
+
12
+ export const PROPOSAL_TYPES = ["improve", "create", "merge", "prune"] as const;
13
+ export type ProposalType = (typeof PROPOSAL_TYPES)[number];
14
+
15
+ /** 增删行合计超过它不拒绝生成,但标记转人工:一次改 40 行以上的技能文案没人能审 */
16
+ export const MAX_DIFF_LINES = 40;
17
+ /** 提案只许动技能目录,别的路径一律 bad_path */
18
+ export const SKILL_PATH_PREFIX = ".pi/skills/";
19
+
20
+ export type ProposalStatus = "ok" | "missing" | "too_large" | "bad_path" | "invalid_candidate";
21
+
22
+ export interface DiffFile {
23
+ path: string;
24
+ hunks: number;
25
+ }
26
+
27
+ export interface DiffParseResult {
28
+ ok: boolean;
29
+ reason?: "empty" | "bad_path" | "malformed";
30
+ detail?: string;
31
+ files: DiffFile[];
32
+ added: number;
33
+ removed: number;
34
+ }
35
+
36
+ /** 去掉 git 的 `a/` `b/` 前缀与行尾 tab 时间戳;`/dev/null` 返回 null(新建/删除文件时会出现) */
37
+ function normalizeDiffPath(raw: string): string | null {
38
+ const head = raw.split("\t")[0].trim();
39
+ if (head === "/dev/null" || head.length === 0) return null;
40
+ return head.replace(/^[ab]\//, "");
41
+ }
42
+
43
+ function isSkillPath(filePath: string): boolean {
44
+ return filePath.startsWith(SKILL_PATH_PREFIX);
45
+ }
46
+
47
+ /**
48
+ * 解析 unified diff,只统计增删行与文件路径,不重建内容。
49
+ * 注意:hunk 里以 `--- ` 开头的内容行会被当成文件头 —— 技能文档里出现这种行的概率可忽略,
50
+ * 真要写这种内容就得用转义形式,属于可接受的解析边界。
51
+ */
52
+ export function parseUnifiedDiff(text: string): DiffParseResult {
53
+ const files: DiffFile[] = [];
54
+ let added = 0;
55
+ let removed = 0;
56
+ let badPath: string | null = null;
57
+ let current: DiffFile | null = null;
58
+
59
+ for (const line of text.split(/\r?\n/)) {
60
+ if (line.startsWith("--- ")) {
61
+ const oldPath = normalizeDiffPath(line.slice(4));
62
+ if (oldPath !== null && !isSkillPath(oldPath)) badPath ??= oldPath;
63
+ continue;
64
+ }
65
+ if (line.startsWith("+++ ")) {
66
+ const newPath = normalizeDiffPath(line.slice(4));
67
+ if (newPath === null) {
68
+ current = null;
69
+ continue;
70
+ }
71
+ if (!isSkillPath(newPath)) {
72
+ badPath ??= newPath;
73
+ current = null;
74
+ continue;
75
+ }
76
+ current = { path: newPath, hunks: 0 };
77
+ files.push(current);
78
+ continue;
79
+ }
80
+ if (line.startsWith("@@")) {
81
+ if (current) current.hunks += 1;
82
+ continue;
83
+ }
84
+ // `diff --git` / `index abc..def` 之类的头对统计无意义,直接跳过
85
+ if (line.startsWith("diff ") || line.startsWith("index ")) continue;
86
+ if (!current) continue;
87
+ if (line.startsWith("+")) added += 1;
88
+ else if (line.startsWith("-")) removed += 1;
89
+ }
90
+
91
+ if (badPath !== null) return { ok: false, reason: "bad_path", detail: badPath, files, added, removed };
92
+ if (files.length === 0) return { ok: false, reason: "empty", detail: "没有可用文件头", files, added, removed };
93
+ return { ok: true, files, added, removed };
94
+ }
95
+
96
+ export interface CandidateCheck {
97
+ ok: boolean;
98
+ detail: string;
99
+ name?: string;
100
+ }
101
+
102
+ /** 读 frontmatter 的 name / description:只认 `key: value` 单行形式(技能 frontmatter 就这两种) */
103
+ export function readFrontmatter(text: string): Record<string, string> {
104
+ const lines = text.split(/\r?\n/);
105
+ if (lines[0]?.trim() !== "---") return {};
106
+ const fields: Record<string, string> = {};
107
+ for (const line of lines.slice(1)) {
108
+ if (line.trim() === "---") break;
109
+ const match = /^([A-Za-z_-]+):\s*(.*)$/.exec(line);
110
+ if (!match) continue;
111
+ fields[match[1]] = match[2].trim().replace(/^["']|["']$/g, "");
112
+ }
113
+ return fields;
114
+ }
115
+
116
+ export function validateCandidate(dir: string): CandidateCheck {
117
+ let text: string;
118
+ try {
119
+ text = fs.readFileSync(path.join(dir, "SKILL.md"), "utf8");
120
+ } catch {
121
+ return { ok: false, detail: `缺 SKILL.md:${dir}` };
122
+ }
123
+ const fields = readFrontmatter(text);
124
+ const name = fields.name ?? "";
125
+ if (name.length === 0) return { ok: false, detail: "frontmatter 缺 name" };
126
+ if ((fields.description ?? "").length === 0) return { ok: false, detail: "frontmatter 缺 description" };
127
+ if (name.length > 20) return { ok: false, detail: `name 超过 20 字:${name}` };
128
+ if (!/^[a-z0-9-]+$/.test(name)) return { ok: false, detail: `name 不合规(只许小写字母/数字/短横线):${name}` };
129
+ const dirName = path.basename(dir);
130
+ if (name !== dirName) return { ok: false, detail: `name(${name}) 与目录名(${dirName}) 不一致` };
131
+ return { ok: true, detail: `${name} 校验通过`, name };
132
+ }
133
+
134
+ export interface ProposalCheck {
135
+ status: ProposalStatus;
136
+ detail: string;
137
+ type?: ProposalType;
138
+ skill?: string;
139
+ added?: number;
140
+ removed?: number;
141
+ /** 相对 run 目录的产物文件名(diff 或 candidate 的 SKILL.md) */
142
+ file?: string;
143
+ }
144
+
145
+ function readProposalJson(runDir: string): { json: Record<string, unknown> | null; error?: string } {
146
+ try {
147
+ const raw = fs.readFileSync(path.join(runDir, "02-proposal.json"), "utf8");
148
+ const parsed = JSON.parse(raw) as unknown;
149
+ if (typeof parsed !== "object" || parsed === null) return { json: null, error: "02-proposal.json 不是对象" };
150
+ return { json: parsed as Record<string, unknown> };
151
+ } catch (error) {
152
+ const code = (error as NodeJS.ErrnoException).code;
153
+ return { json: null, error: code === "ENOENT" ? "没有 02-proposal.json" : `02-proposal.json 解析失败:${String(error)}` };
154
+ }
155
+ }
156
+
157
+ /** 校验一个 run 目录的提案:只有 status==="ok" 才允许后续(人工)评审 */
158
+ export function validateProposal(runDir: string): ProposalCheck {
159
+ const { json, error } = readProposalJson(runDir);
160
+ if (!json) return { status: "missing", detail: error ?? "没有提案" };
161
+
162
+ const type = String(json.type ?? "");
163
+ const skill = String(json.skill ?? "");
164
+ if (!(PROPOSAL_TYPES as readonly string[]).includes(type)) {
165
+ return { status: "missing", detail: `02-proposal.json 的 type 非法:${type || "(空)"}` };
166
+ }
167
+ if (skill.length === 0) return { status: "missing", detail: "02-proposal.json 缺 skill" };
168
+ const typed = type as ProposalType;
169
+
170
+ if (typed === "create") {
171
+ const rel = path.join("candidate", skill, "SKILL.md");
172
+ const check = validateCandidate(path.join(runDir, "candidate", skill));
173
+ return check.ok
174
+ ? { status: "ok", detail: check.detail, type: typed, skill, file: rel, added: 0, removed: 0 }
175
+ : { status: "invalid_candidate", detail: check.detail, type: typed, skill, file: rel };
176
+ }
177
+
178
+ const diffRel = typeof json.diffFile === "string" && json.diffFile.length > 0 ? json.diffFile : "02-proposal.diff";
179
+ let text: string;
180
+ try {
181
+ text = fs.readFileSync(path.resolve(runDir, diffRel), "utf8");
182
+ } catch {
183
+ return { status: "missing", detail: `缺 diff:${diffRel}`, type: typed, skill };
184
+ }
185
+ const parsed = parseUnifiedDiff(text);
186
+ if (!parsed.ok) {
187
+ return {
188
+ status: parsed.reason === "bad_path" ? "bad_path" : "missing",
189
+ detail: parsed.reason === "bad_path" ? `diff 改了技能目录外的路径:${parsed.detail}` : (parsed.detail ?? "diff 为空"),
190
+ type: typed,
191
+ skill,
192
+ added: parsed.added,
193
+ removed: parsed.removed,
194
+ file: diffRel,
195
+ };
196
+ }
197
+ const total = parsed.added + parsed.removed;
198
+ const base = { type: typed, skill, added: parsed.added, removed: parsed.removed, file: diffRel };
199
+ if (total > MAX_DIFF_LINES) {
200
+ return { status: "too_large", detail: `增删 ${total} 行 > ${MAX_DIFF_LINES},转人工`, ...base };
201
+ }
202
+ return { status: "ok", detail: `增删 ${total} 行,路径 ${parsed.files.map((item) => item.path).join("、")}`, ...base };
203
+ }
204
+
205
+ export interface ProposalSummary {
206
+ type: string;
207
+ skill: string;
208
+ status: ProposalStatus;
209
+ added: number;
210
+ removed: number;
211
+ file: string | null;
212
+ }
213
+
214
+ /** 报告里显示提案摘要:没有 `02-proposal.json` 返回 undefined(这一轮本来就没提案,不是错) */
215
+ export function readProposalSummary(runDir: string): ProposalSummary | undefined {
216
+ const { json } = readProposalJson(runDir);
217
+ if (!json) return undefined;
218
+ const check = validateProposal(runDir);
219
+ return {
220
+ type: String(json.type ?? "?"),
221
+ skill: check.skill ?? String(json.skill ?? "?"),
222
+ status: check.status,
223
+ added: check.added ?? 0,
224
+ removed: check.removed ?? 0,
225
+ file: check.file ?? null,
226
+ };
227
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * 删技能的「待执行清单」(设计稿 10.4):插件只出清单,真删留给人手动执行。
3
+ *
4
+ * 为什么不在插件里真删:删技能是不可逆操作,插件能做的只有把「要动哪五处、当前哪处还缺」
5
+ * 摊开给人看。人批(`approve <runId> --prune`)只换取这份清单,不换取删除动作。
6
+ */
7
+ import * as fs from "node:fs";
8
+ import * as path from "node:path";
9
+ import { readProposalMeta } from "./apply-targets.ts";
10
+
11
+ export interface PrunePlan {
12
+ skill: string;
13
+ steps: string[];
14
+ /** 当前就不存在 / 已经缺的引用面(清单里标出来,别让人白跑) */
15
+ missing: string[];
16
+ }
17
+
18
+ function exists(file: string): boolean {
19
+ return fs.existsSync(file);
20
+ }
21
+
22
+ function readText(file: string): string {
23
+ try {
24
+ return fs.readFileSync(file, "utf8");
25
+ } catch {
26
+ return "";
27
+ }
28
+ }
29
+
30
+ export function buildPrunePlan(runDir: string, cwd: string): PrunePlan | null {
31
+ const meta = readProposalMeta(runDir);
32
+ if (!meta || meta.skill.length === 0) return null;
33
+ const skill = meta.skill;
34
+ const missing: string[] = [];
35
+ const stub = path.join(cwd, ".cursor", "skills", skill, "SKILL.md");
36
+ if (!exists(stub)) missing.push(`.cursor/skills/${skill}/SKILL.md(已不存在)`);
37
+ const agents = readText(path.join(cwd, "AGENTS.md"));
38
+ if (agents.length > 0 && !new RegExp(`^\\s*[-*]\\s+\`${skill.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\``, "m").test(agents)) {
39
+ missing.push(`AGENTS.md 索引表(已无 ${skill} 那一行)`);
40
+ }
41
+ const steps = [
42
+ `① 删除技能目录:rm -rf .pi/skills/${skill}/`,
43
+ `② 删除 .cursor 路由 stub:rm -f .cursor/skills/${skill}/SKILL.md`,
44
+ `③ 从 AGENTS.md 的 Skill 索引表删掉 ${skill} 那一行(先 rg -n "\`${skill}\`" AGENTS.md 定位)`,
45
+ `④ .dsh/skills/${skill}/ 不动(SSOT 只认 .pi,见设计稿 17.1)`,
46
+ `⑤ 扫其余引用:rg -n "${skill}" --no-ignore -uu . —— 逐条改,只允许剩进化记录里的历史引用`,
47
+ `校验:rg -n "${skill}" --no-ignore -uu . 只剩 .pi/evolution/ 下的历史记录`,
48
+ `收尾:git add -A && git commit -m "docs(skill): 移除技能 ${skill}"`,
49
+ ];
50
+ return { skill, steps, missing };
51
+ }
52
+
53
+ export function renderPrunePlan(plan: PrunePlan): string {
54
+ const lines = [`删技能待执行清单:${plan.skill}(插件不执行,请手动逐条确认后再删)`, ""];
55
+ lines.push(...plan.steps);
56
+ if (plan.missing.length > 0) {
57
+ lines.push("", "已有缺口(不必重复处理):");
58
+ lines.push(...plan.missing.map((item) => ` · ${item}`));
59
+ }
60
+ lines.push("", "删除不可逆:确认没有其它技能/文档仍依赖它之后再动手。");
61
+ return lines.join("\n");
62
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * 采纳队列:`.pi/evolution/queue.json`(运行态,不入库)。
3
+ *
4
+ * 为什么要有队列而不是评审完立刻写盘:评审督导是独立 node 进程、没有 withFileMutationQueue
5
+ * (闸2 只能在 pi 运行时里用)。督导只写 03-review.json,把「可采纳」排进队列;扩展在
6
+ * session_start 与 settle 时扫队列并执行,所以主会话退出也不会丢。
7
+ *
8
+ * superseded 规则:同一个 skill 已经有更早的 pending 时,新条目直接标 superseded ——
9
+ * 否则两次改动会落到同一文件上,谁先谁后都不对。要采纳新的,先把旧的处理掉。
10
+ */
11
+ import * as fs from "node:fs";
12
+ import * as path from "node:path";
13
+ import type { ActionLevel } from "./classify-action.ts";
14
+ import type { MutationQueue } from "./telemetry.ts";
15
+
16
+ export type QueueStatus = "pending" | "applied" | "rolled-back" | "failed" | "superseded" | "rejected";
17
+
18
+ export interface QueueItem {
19
+ runId: string;
20
+ level: ActionLevel;
21
+ skill: string;
22
+ /** 相对仓库根的路径(.pi/skills/… / .cursor/skills/… / AGENTS.md) */
23
+ targets: string[];
24
+ createdAt: number;
25
+ status: QueueStatus;
26
+ proposalType?: string;
27
+ detail?: string;
28
+ }
29
+
30
+ export const QUEUE_FILE = "queue.json";
31
+ const LEVELS: readonly ActionLevel[] = ["auto", "approval", "forbidden"];
32
+ // rejected 是人主动否掉(网页点「拒绝」/TUI),与 superseded(被更早的 pending 顶掉)不是一回事
33
+ const STATUSES: readonly QueueStatus[] = ["pending", "applied", "rolled-back", "failed", "superseded", "rejected"];
34
+
35
+ export function queueFile(evolutionDir: string): string {
36
+ return path.join(evolutionDir, QUEUE_FILE);
37
+ }
38
+
39
+ function isItem(raw: unknown): raw is QueueItem {
40
+ if (typeof raw !== "object" || raw === null) return false;
41
+ const item = raw as Record<string, unknown>;
42
+ return (
43
+ typeof item.runId === "string" &&
44
+ typeof item.skill === "string" &&
45
+ LEVELS.includes(item.level as ActionLevel) &&
46
+ STATUSES.includes(item.status as QueueStatus)
47
+ );
48
+ }
49
+
50
+ /** 读队列:文件缺 / JSON 坏 / 单条字段不合规都只丢那一条,绝不让命令挂掉 */
51
+ export function readQueue(evolutionDir: string): QueueItem[] {
52
+ try {
53
+ const raw = JSON.parse(fs.readFileSync(queueFile(evolutionDir), "utf8")) as unknown;
54
+ return Array.isArray(raw) ? raw.filter(isItem) : [];
55
+ } catch {
56
+ return [];
57
+ }
58
+ }
59
+
60
+ function writeJson(evolutionDir: string, items: readonly QueueItem[]): void {
61
+ fs.mkdirSync(evolutionDir, { recursive: true });
62
+ fs.writeFileSync(queueFile(evolutionDir), `${JSON.stringify(items, null, 2)}\n`, "utf8");
63
+ }
64
+
65
+ export interface QueueDeps {
66
+ evolutionDir: string;
67
+ queue: MutationQueue;
68
+ now?: () => number;
69
+ }
70
+
71
+ export interface EnqueueResult {
72
+ item: QueueItem;
73
+ /** 新条目被更早的 pending 顶掉时给出对方 runId */
74
+ supersededBy?: string;
75
+ }
76
+
77
+ /** 入队(幂等:同一个 runId 已存在就直接返回原条目) */
78
+ export async function enqueue(
79
+ deps: QueueDeps,
80
+ input: Omit<QueueItem, "createdAt" | "status">,
81
+ ): Promise<EnqueueResult> {
82
+ const now = deps.now ?? (() => Date.now());
83
+ let result: EnqueueResult | undefined;
84
+ await deps.queue(queueFile(deps.evolutionDir), async () => {
85
+ // 队列内重读:并发入队时后写的一方不会用旧快照覆盖前一方
86
+ const items = readQueue(deps.evolutionDir);
87
+ const existing = items.find((item) => item.runId === input.runId);
88
+ if (existing) {
89
+ result = { item: existing };
90
+ return;
91
+ }
92
+ const older = items.find((item) => item.skill === input.skill && item.status === "pending");
93
+ const item: QueueItem = {
94
+ ...input,
95
+ createdAt: now(),
96
+ status: older ? "superseded" : "pending",
97
+ ...(older ? { detail: `同一技能已有更早的 pending:${older.runId}` } : {}),
98
+ };
99
+ items.push(item);
100
+ writeJson(deps.evolutionDir, items);
101
+ result = older ? { item, supersededBy: older.runId } : { item };
102
+ });
103
+ if (!result) throw new Error("入队失败:队列写回调未返回结果");
104
+ return result;
105
+ }
106
+
107
+ /** 取待办(按入队时间升序;给 level 就只看该级) */
108
+ export function takePending(items: readonly QueueItem[], level?: ActionLevel): QueueItem[] {
109
+ return items
110
+ .filter((item) => item.status === "pending" && (level === undefined || item.level === level))
111
+ .sort((a, b) => a.createdAt - b.createdAt);
112
+ }
113
+
114
+ export function findItem(items: readonly QueueItem[], runId: string): QueueItem | undefined {
115
+ return items.find((item) => item.runId === runId);
116
+ }
117
+
118
+ /** 改某条状态;返回是否找到了该 runId(命令据此区分「没有这条」与「已处理」) */
119
+ export async function markDone(
120
+ deps: QueueDeps,
121
+ runId: string,
122
+ status: QueueStatus,
123
+ detail?: string,
124
+ ): Promise<boolean> {
125
+ let found = false;
126
+ await deps.queue(queueFile(deps.evolutionDir), async () => {
127
+ const items = readQueue(deps.evolutionDir);
128
+ const target = items.find((item) => item.runId === runId);
129
+ if (!target) return;
130
+ found = true;
131
+ target.status = status;
132
+ if (detail) target.detail = detail;
133
+ writeJson(deps.evolutionDir, items);
134
+ });
135
+ return found;
136
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * 端到端复演的固定数据:假技能前后文、提案 diff、假会话 JSONL、假评审结论。
3
+ *
4
+ * 单独一个文件是为了让 `replay.ts` 只留流程(300 行闸门),也方便测试直接引用这些常量。
5
+ */
6
+
7
+ /** 复演用技能:改动前 */
8
+ export const REPLAY_BEFORE = [
9
+ "---",
10
+ "name: replay-demo",
11
+ "description: 复演流水线时使用的临时技能,别在真实会话里用",
12
+ "---",
13
+ "",
14
+ "# replay-demo",
15
+ "",
16
+ "- 只给 scripts/replay.ts 用。",
17
+ "",
18
+ ].join("\n");
19
+
20
+ /** 复演用技能:改动后(只加正文条款 → approval 级,正好走「人批」这一段) */
21
+ export const REPLAY_AFTER = [
22
+ "---",
23
+ "name: replay-demo",
24
+ "description: 复演流水线时使用的临时技能,别在真实会话里用",
25
+ "---",
26
+ "",
27
+ "# replay-demo",
28
+ "",
29
+ "- 只给 scripts/replay.ts 用。",
30
+ "- 复演时必须先看 00-session/index.json 再读会话原文。",
31
+ "",
32
+ ].join("\n");
33
+
34
+ /**
35
+ * 复演用的提案 diff:**带上下文行**的真实形状(提案由复盘子 agent 写,不是整文件重渲染)。
36
+ * 整文件重渲染那种 diff 里 frontmatter 会变成增删行,分级会误判 forbidden —— 那不是真实提案的样子。
37
+ */
38
+ export const REPLAY_DIFF = [
39
+ "--- a/.pi/skills/replay-demo/SKILL.md",
40
+ "+++ b/.pi/skills/replay-demo/SKILL.md",
41
+ "@@ -5,4 +5,5 @@",
42
+ " ",
43
+ " # replay-demo",
44
+ " ",
45
+ " - 只给 scripts/replay.ts 用。",
46
+ "+- 复演时必须先看 00-session/index.json 再读会话原文。",
47
+ "",
48
+ ].join("\n");
49
+
50
+ /** 假评审结论:形状必须过 postProcessReview 的字段校验(证据三件套齐全) */
51
+ export const FAKE_REVIEW_TEXT = JSON.stringify({
52
+ verdict: "pass",
53
+ blocking: [],
54
+ evidence: [{ file: ".pi/skills/replay-demo/SKILL.md", line: 9, quote: "复演时必须先看 00-session/index.json" }],
55
+ notes: ["复演用假评审:只验证流水线,不代表真实评审质量"],
56
+ });
57
+
58
+ /** 假会话 JSONL:只要够 buildSessionIndex 抽出结构,不复制真实会话 */
59
+ export function fakeSessionText(cwd: string, at: number): string {
60
+ const iso = new Date(at).toISOString();
61
+ return [
62
+ JSON.stringify({ type: "session", version: 3, id: "replay-session", timestamp: iso, cwd }),
63
+ JSON.stringify({
64
+ type: "message",
65
+ id: "r1",
66
+ parentId: null,
67
+ timestamp: iso,
68
+ message: { role: "user", content: [{ type: "text", text: "帮我看看 replay-demo 这个技能该怎么改" }] },
69
+ }),
70
+ JSON.stringify({
71
+ type: "message",
72
+ id: "r2",
73
+ parentId: "r1",
74
+ timestamp: iso,
75
+ message: { role: "assistant", content: [{ type: "text", text: "先按索引抽查,再给提案。" }] },
76
+ }),
77
+ "",
78
+ ].join("\n");
79
+ }
80
+