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.
- package/CHANGELOG.md +12 -0
- package/README.md +81 -2
- package/extensions/skill-evolve/fixtures/selftest/delete-ban/after.md +10 -0
- package/extensions/skill-evolve/fixtures/selftest/delete-ban/change.diff +8 -0
- package/extensions/skill-evolve/fixtures/selftest/delete-ban/fixture.json +6 -0
- package/extensions/skill-evolve/fixtures/selftest/rg-to-find/after.md +9 -0
- package/extensions/skill-evolve/fixtures/selftest/rg-to-find/change.diff +8 -0
- package/extensions/skill-evolve/fixtures/selftest/rg-to-find/fixture.json +6 -0
- package/extensions/skill-evolve/fixtures/selftest/weak-description/after.md +8 -0
- package/extensions/skill-evolve/fixtures/selftest/weak-description/change.diff +8 -0
- package/extensions/skill-evolve/fixtures/selftest/weak-description/fixture.json +6 -0
- package/extensions/skill-evolve/index.ts +300 -0
- package/extensions/skill-evolve/lib/apply-gates.ts +204 -0
- package/extensions/skill-evolve/lib/apply-targets.ts +90 -0
- package/extensions/skill-evolve/lib/apply.ts +141 -0
- package/extensions/skill-evolve/lib/attribution.ts +138 -0
- package/extensions/skill-evolve/lib/audit.ts +221 -0
- package/extensions/skill-evolve/lib/baseline.ts +176 -0
- package/extensions/skill-evolve/lib/child-env.ts +10 -0
- package/extensions/skill-evolve/lib/classify-action.ts +161 -0
- package/extensions/skill-evolve/lib/command.ts +100 -0
- package/extensions/skill-evolve/lib/config-command.ts +38 -0
- package/extensions/skill-evolve/lib/config.ts +194 -0
- package/extensions/skill-evolve/lib/detect.ts +62 -0
- package/extensions/skill-evolve/lib/diff-apply.ts +90 -0
- package/extensions/skill-evolve/lib/diff-gen.ts +78 -0
- package/extensions/skill-evolve/lib/dispatch.ts +300 -0
- package/extensions/skill-evolve/lib/evolution-apply.ts +265 -0
- package/extensions/skill-evolve/lib/evolution-model.ts +72 -0
- package/extensions/skill-evolve/lib/evolution-observe.ts +82 -0
- package/extensions/skill-evolve/lib/evolution-views.ts +89 -0
- package/extensions/skill-evolve/lib/evolution.ts +289 -0
- package/extensions/skill-evolve/lib/flags.ts +44 -0
- package/extensions/skill-evolve/lib/gate0.ts +78 -0
- package/extensions/skill-evolve/lib/hard-rules.ts +167 -0
- package/extensions/skill-evolve/lib/lock.ts +215 -0
- package/extensions/skill-evolve/lib/logfile.ts +52 -0
- package/extensions/skill-evolve/lib/mechanical-report.ts +95 -0
- package/extensions/skill-evolve/lib/mechanical.ts +136 -0
- package/extensions/skill-evolve/lib/metrics.ts +172 -0
- package/extensions/skill-evolve/lib/model-config.ts +95 -0
- package/extensions/skill-evolve/lib/notify.ts +132 -0
- package/extensions/skill-evolve/lib/panel.ts +174 -0
- package/extensions/skill-evolve/lib/pending.ts +34 -0
- package/extensions/skill-evolve/lib/proc.ts +65 -0
- package/extensions/skill-evolve/lib/prompt.ts +80 -0
- package/extensions/skill-evolve/lib/proposal.ts +227 -0
- package/extensions/skill-evolve/lib/prune-plan.ts +62 -0
- package/extensions/skill-evolve/lib/queue.ts +136 -0
- package/extensions/skill-evolve/lib/replay-fixture.ts +80 -0
- package/extensions/skill-evolve/lib/replay.ts +246 -0
- package/extensions/skill-evolve/lib/report.ts +181 -0
- package/extensions/skill-evolve/lib/review-post.ts +101 -0
- package/extensions/skill-evolve/lib/review-prompt.ts +82 -0
- package/extensions/skill-evolve/lib/review-record.ts +63 -0
- package/extensions/skill-evolve/lib/review-run.ts +131 -0
- package/extensions/skill-evolve/lib/review-spawn.ts +83 -0
- package/extensions/skill-evolve/lib/review-supervisor.ts +267 -0
- package/extensions/skill-evolve/lib/round.ts +116 -0
- package/extensions/skill-evolve/lib/run-watch.ts +34 -0
- package/extensions/skill-evolve/lib/runs.ts +208 -0
- package/extensions/skill-evolve/lib/selftest.ts +201 -0
- package/extensions/skill-evolve/lib/session-index.ts +145 -0
- package/extensions/skill-evolve/lib/shutdown-dispatch.ts +177 -0
- package/extensions/skill-evolve/lib/single-flight.ts +111 -0
- package/extensions/skill-evolve/lib/spawn-plan.ts +103 -0
- package/extensions/skill-evolve/lib/stale.ts +217 -0
- package/extensions/skill-evolve/lib/telemetry.ts +205 -0
- package/extensions/skill-evolve/lib/ui-gate.ts +34 -0
- package/extensions/skill-evolve/lib/web/actions.ts +299 -0
- package/extensions/skill-evolve/lib/web/activity.ts +57 -0
- package/extensions/skill-evolve/lib/web/applied.ts +64 -0
- package/extensions/skill-evolve/lib/web/approvals.ts +58 -0
- package/extensions/skill-evolve/lib/web/audit-log.ts +115 -0
- package/extensions/skill-evolve/lib/web/autostart.ts +177 -0
- package/extensions/skill-evolve/lib/web/data.ts +298 -0
- package/extensions/skill-evolve/lib/web/diff-lines.ts +141 -0
- package/extensions/skill-evolve/lib/web/filter.ts +203 -0
- package/extensions/skill-evolve/lib/web/http.ts +68 -0
- package/extensions/skill-evolve/lib/web/json-read.ts +33 -0
- package/extensions/skill-evolve/lib/web/panel-pid.ts +99 -0
- package/extensions/skill-evolve/lib/web/render-controls.ts +156 -0
- package/extensions/skill-evolve/lib/web/render-rows.ts +252 -0
- package/extensions/skill-evolve/lib/web/render-run.ts +188 -0
- package/extensions/skill-evolve/lib/web/render-skills.ts +164 -0
- package/extensions/skill-evolve/lib/web/render.ts +136 -0
- package/extensions/skill-evolve/lib/web/reports.ts +160 -0
- package/extensions/skill-evolve/lib/web/run-detail.ts +234 -0
- package/extensions/skill-evolve/lib/web/run-path.ts +21 -0
- package/extensions/skill-evolve/lib/web/server.ts +268 -0
- package/extensions/skill-evolve/lib/web/shell.ts +87 -0
- package/extensions/skill-evolve/lib/web/skills.ts +202 -0
- package/extensions/skill-evolve/lib/web/style.ts +107 -0
- package/extensions/skill-evolve/lib/web/text.ts +52 -0
- package/extensions/skill-evolve/lib/web/trash.ts +36 -0
- package/extensions/skill-evolve/lib/weekly.ts +285 -0
- package/extensions/skill-evolve/prompts//345/244/215/347/233/230.md +172 -0
- package/extensions/skill-evolve/scripts/replay.ts +93 -0
- package/extensions/skill-evolve/test/apply.test.ts +278 -0
- package/extensions/skill-evolve/test/approve.test.ts +248 -0
- package/extensions/skill-evolve/test/attribution.test.ts +133 -0
- package/extensions/skill-evolve/test/audit.test.ts +151 -0
- package/extensions/skill-evolve/test/baseline.test.ts +132 -0
- package/extensions/skill-evolve/test/classify-action.test.ts +133 -0
- package/extensions/skill-evolve/test/command.test.ts +267 -0
- package/extensions/skill-evolve/test/config-command.test.ts +73 -0
- package/extensions/skill-evolve/test/config.test.ts +154 -0
- package/extensions/skill-evolve/test/detect.test.ts +71 -0
- package/extensions/skill-evolve/test/diff-apply.test.ts +46 -0
- package/extensions/skill-evolve/test/dispatch-busy.test.ts +223 -0
- package/extensions/skill-evolve/test/dispatch-supervisor.test.ts +88 -0
- package/extensions/skill-evolve/test/dispatch.test.ts +306 -0
- package/extensions/skill-evolve/test/evolution.test.ts +378 -0
- package/extensions/skill-evolve/test/fake-review-spawn.ts +60 -0
- package/extensions/skill-evolve/test/hard-rules.test.ts +116 -0
- package/extensions/skill-evolve/test/lock.test.ts +138 -0
- package/extensions/skill-evolve/test/logfile.test.ts +85 -0
- package/extensions/skill-evolve/test/mechanical-wiring.test.ts +217 -0
- package/extensions/skill-evolve/test/metrics.test.ts +129 -0
- package/extensions/skill-evolve/test/model-config.test.ts +84 -0
- package/extensions/skill-evolve/test/notify.test.ts +133 -0
- package/extensions/skill-evolve/test/observe.test.ts +100 -0
- package/extensions/skill-evolve/test/panel-pid.test.ts +84 -0
- package/extensions/skill-evolve/test/panel.test.ts +115 -0
- package/extensions/skill-evolve/test/proposal.test.ts +159 -0
- package/extensions/skill-evolve/test/queue.test.ts +110 -0
- package/extensions/skill-evolve/test/reject.test.ts +134 -0
- package/extensions/skill-evolve/test/replay.test.ts +111 -0
- package/extensions/skill-evolve/test/report.test.ts +66 -0
- package/extensions/skill-evolve/test/review-post.test.ts +92 -0
- package/extensions/skill-evolve/test/review-prompt.test.ts +105 -0
- package/extensions/skill-evolve/test/review-spawn.test.ts +81 -0
- package/extensions/skill-evolve/test/review-supervisor.test.ts +243 -0
- package/extensions/skill-evolve/test/selftest.test.ts +113 -0
- package/extensions/skill-evolve/test/session-index.test.ts +128 -0
- package/extensions/skill-evolve/test/single-flight.test.ts +122 -0
- package/extensions/skill-evolve/test/stale.test.ts +272 -0
- package/extensions/skill-evolve/test/telemetry.test.ts +145 -0
- package/extensions/skill-evolve/test/ui-gate.test.ts +77 -0
- package/extensions/skill-evolve/test/web-actions.test.ts +262 -0
- package/extensions/skill-evolve/test/web-applied.test.ts +100 -0
- package/extensions/skill-evolve/test/web-audit.test.ts +87 -0
- package/extensions/skill-evolve/test/web-autostart.test.ts +268 -0
- package/extensions/skill-evolve/test/web-data.test.ts +420 -0
- package/extensions/skill-evolve/test/web-diff-lines.test.ts +155 -0
- package/extensions/skill-evolve/test/web-filter.test.ts +212 -0
- package/extensions/skill-evolve/test/web-panel-pid.test.ts +97 -0
- package/extensions/skill-evolve/test/web-render-run.test.ts +132 -0
- package/extensions/skill-evolve/test/web-render-skills.test.ts +134 -0
- package/extensions/skill-evolve/test/web-render.test.ts +300 -0
- package/extensions/skill-evolve/test/web-run-detail.test.ts +256 -0
- package/extensions/skill-evolve/test/web-server-views.test.ts +113 -0
- package/extensions/skill-evolve/test/web-server.test.ts +465 -0
- package/extensions/skill-evolve/test/web-shell.test.ts +67 -0
- package/extensions/skill-evolve/test/web-skills.test.ts +239 -0
- package/extensions/skill-evolve/test/weekly.test.ts +123 -0
- package/package.json +34 -4
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 设计稿 13.2 的五道闸:授权核对 → 备份 → 原子写 → 复验 → 生效边界。
|
|
3
|
+
*
|
|
4
|
+
* 逐闸失败即回滚(闸0 是例外:它还没写盘,没有可回滚的东西)。
|
|
5
|
+
* git / md-check / skills diagnostics 全部走注入 —— 单测里绝不起真进程(M4 那 51 个督导进程的教训)。
|
|
6
|
+
*/
|
|
7
|
+
import * as fs from "node:fs";
|
|
8
|
+
import * as path from "node:path";
|
|
9
|
+
import type { PlannedTarget } from "./apply-targets.ts";
|
|
10
|
+
import { renderUnifiedDiff } from "./diff-gen.ts";
|
|
11
|
+
import { captureGate0, gate0Drift, readGate0, type GitRunner } from "./gate0.ts";
|
|
12
|
+
import type { MutationQueue } from "./telemetry.ts";
|
|
13
|
+
|
|
14
|
+
export type GateId = "gate0" | "gate1" | "gate2" | "gate3" | "gate4";
|
|
15
|
+
|
|
16
|
+
export interface GateResult {
|
|
17
|
+
gate: GateId;
|
|
18
|
+
name: string;
|
|
19
|
+
ok: boolean;
|
|
20
|
+
ms: number;
|
|
21
|
+
detail?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface GateDeps {
|
|
25
|
+
evolutionDir: string;
|
|
26
|
+
cwd: string;
|
|
27
|
+
runId: string;
|
|
28
|
+
queue: MutationQueue;
|
|
29
|
+
now: () => number;
|
|
30
|
+
log: (msg: string) => void;
|
|
31
|
+
git: GitRunner;
|
|
32
|
+
/** 生产:loadSkillsFromDir({ dir: join(cwd,".pi/skills"), source:"project" }).diagnostics */
|
|
33
|
+
loadDiagnostics: () => { path: string; message: string }[];
|
|
34
|
+
/** 生产:spawnSync python3 .pi/skills/md-check/scripts/check_md.py <文件> */
|
|
35
|
+
runMdCheck: (absPath: string) => { code: number; output: string };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface GateRun {
|
|
39
|
+
ok: boolean;
|
|
40
|
+
/** 闸0 拒采:没写盘,调用方据此标 unauthorized_write 而不是 rolled-back */
|
|
41
|
+
unauthorized: boolean;
|
|
42
|
+
gates: GateResult[];
|
|
43
|
+
wrote: string[];
|
|
44
|
+
appliedDiff: string;
|
|
45
|
+
detail?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const BACKUP_RETENTION_DAYS = 30;
|
|
49
|
+
const DAY_MS = 86_400_000;
|
|
50
|
+
|
|
51
|
+
export function backupRoot(evolutionDir: string): string {
|
|
52
|
+
return path.join(evolutionDir, "backups");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function backupDir(evolutionDir: string, runId: string): string {
|
|
56
|
+
return path.join(backupRoot(evolutionDir), runId);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 备份 30 天清理:只在 session_start 调,删不掉就留到下次(不影响采纳) */
|
|
60
|
+
export function cleanupBackups(evolutionDir: string, now: number, log: (msg: string) => void): number {
|
|
61
|
+
let names: string[];
|
|
62
|
+
try {
|
|
63
|
+
names = fs.readdirSync(backupRoot(evolutionDir));
|
|
64
|
+
} catch {
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
67
|
+
let removed = 0;
|
|
68
|
+
for (const name of names) {
|
|
69
|
+
const dir = path.join(backupRoot(evolutionDir), name);
|
|
70
|
+
try {
|
|
71
|
+
if (now - fs.statSync(dir).mtimeMs <= BACKUP_RETENTION_DAYS * DAY_MS) continue;
|
|
72
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
73
|
+
removed += 1;
|
|
74
|
+
} catch {
|
|
75
|
+
// 单个目录清不掉不影响采纳
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (removed > 0) log(`[skill-evolve] 清理 ${removed} 个超过 ${BACKUP_RETENTION_DAYS} 天的备份`);
|
|
79
|
+
return removed;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function readTextOrNull(file: string): string | null {
|
|
83
|
+
try {
|
|
84
|
+
return fs.readFileSync(file, "utf8");
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function escapeRe(text: string): string {
|
|
91
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 新增技能的引用面(设计稿 10.4 的 ②③):缺 stub 或缺索引行就说明收口没做完 */
|
|
95
|
+
function missingReferences(cwd: string, skill: string): string[] {
|
|
96
|
+
const missing: string[] = [];
|
|
97
|
+
if (!fs.existsSync(path.join(cwd, ".cursor", "skills", skill, "SKILL.md"))) missing.push(`.cursor/skills/${skill}/SKILL.md`);
|
|
98
|
+
const agents = readTextOrNull(path.join(cwd, "AGENTS.md")) ?? "";
|
|
99
|
+
if (!new RegExp(`^\\s*[-*]\\s+\`${escapeRe(skill)}\``, "m").test(agents)) missing.push(`AGENTS.md 索引表(- \`${skill}\` …)`);
|
|
100
|
+
return missing;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function restoreTargets(deps: GateDeps, targets: readonly PlannedTarget[], wrote: readonly string[]): Promise<string> {
|
|
104
|
+
for (const rel of wrote) {
|
|
105
|
+
const target = targets.find((item) => item.path === rel);
|
|
106
|
+
if (!target) continue;
|
|
107
|
+
await deps.queue(target.abs, async () => {
|
|
108
|
+
if (target.before === null) await fs.promises.rm(target.abs, { force: true });
|
|
109
|
+
else {
|
|
110
|
+
await fs.promises.mkdir(path.dirname(target.abs), { recursive: true });
|
|
111
|
+
await fs.promises.writeFile(target.abs, target.before, "utf8");
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
return `已从备份回滚 ${wrote.length} 个文件`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function runGates(deps: GateDeps, targets: readonly PlannedTarget[]): Promise<GateRun> {
|
|
119
|
+
const gates: GateResult[] = [];
|
|
120
|
+
const wrote: string[] = [];
|
|
121
|
+
const appliedDiff = targets.map((target) => renderUnifiedDiff(target.path, target.before, target.after)).join("");
|
|
122
|
+
const fail = (detail: string, unauthorized = false): GateRun => ({ ok: false, unauthorized, gates, wrote, appliedDiff, detail });
|
|
123
|
+
|
|
124
|
+
// 闸0 · 授权核对:没写盘,所以这一闸失败时什么都没有落地
|
|
125
|
+
const t0 = deps.now();
|
|
126
|
+
const snapshot = readGate0(deps.evolutionDir);
|
|
127
|
+
const drift = gate0Drift(snapshot, captureGate0(deps.git, deps.cwd, deps.now()));
|
|
128
|
+
gates.push({ gate: "gate0", name: "授权核对", ok: drift.length === 0, ms: deps.now() - t0, ...(drift ? { detail: drift } : {}) });
|
|
129
|
+
if (drift.length > 0) {
|
|
130
|
+
deps.log(`[skill-evolve] ${drift}`);
|
|
131
|
+
return fail(drift, true);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 闸1 · 备份:回滚的唯一依据,备份写不进去就不许写盘
|
|
135
|
+
const t1 = deps.now();
|
|
136
|
+
try {
|
|
137
|
+
for (const target of targets) {
|
|
138
|
+
if (target.before === null) continue;
|
|
139
|
+
const dest = path.join(backupDir(deps.evolutionDir, deps.runId), target.path);
|
|
140
|
+
await deps.queue(dest, async () => {
|
|
141
|
+
await fs.promises.mkdir(path.dirname(dest), { recursive: true });
|
|
142
|
+
await fs.promises.writeFile(dest, target.before as string, "utf8");
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
for (const target of targets) {
|
|
146
|
+
if (target.before !== null && !fs.existsSync(path.join(backupDir(deps.evolutionDir, deps.runId), target.path))) {
|
|
147
|
+
throw new Error(`备份缺失:${target.path}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
gates.push({ gate: "gate1", name: "备份", ok: true, ms: deps.now() - t1 });
|
|
151
|
+
} catch (error) {
|
|
152
|
+
gates.push({ gate: "gate1", name: "备份", ok: false, ms: deps.now() - t1, detail: String(error) });
|
|
153
|
+
return fail(`闸1 备份失败:${String(error)}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// 闸2 · 原子写:整个「重读 → 比对 → 写」窗口都在 withFileMutationQueue 里
|
|
157
|
+
const t2 = deps.now();
|
|
158
|
+
for (const target of targets) {
|
|
159
|
+
try {
|
|
160
|
+
await deps.queue(target.abs, async () => {
|
|
161
|
+
const current = readTextOrNull(target.abs);
|
|
162
|
+
if (current !== target.before) throw new Error(`磁盘内容与计划不一致:${target.path}`);
|
|
163
|
+
await fs.promises.mkdir(path.dirname(target.abs), { recursive: true });
|
|
164
|
+
await fs.promises.writeFile(target.abs, target.after, "utf8");
|
|
165
|
+
});
|
|
166
|
+
wrote.push(target.path);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
gates.push({ gate: "gate2", name: "原子写", ok: false, ms: deps.now() - t2, detail: String(error) });
|
|
169
|
+
const detail = `${String(error)};${await restoreTargets(deps, targets, wrote)}`;
|
|
170
|
+
return fail(`闸2 写入失败:${detail}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
gates.push({ gate: "gate2", name: "原子写", ok: true, ms: deps.now() - t2 });
|
|
174
|
+
|
|
175
|
+
// 闸3 · 复验:diagnostics + md-check + 引用面
|
|
176
|
+
const t3 = deps.now();
|
|
177
|
+
const problems: string[] = [];
|
|
178
|
+
const diagnostics = deps.loadDiagnostics();
|
|
179
|
+
if (diagnostics.length > 0) problems.push(`skills diagnostics 非空:${diagnostics[0].message}`);
|
|
180
|
+
for (const target of targets.filter((item) => item.path.startsWith(".pi/skills/"))) {
|
|
181
|
+
const check = deps.runMdCheck(target.abs);
|
|
182
|
+
if (check.code !== 0) problems.push(`md-check 失败(${target.path}):${check.output.trim().split("\n").slice(-2).join(" / ")}`);
|
|
183
|
+
}
|
|
184
|
+
for (const target of targets.filter((item) => item.before === null && /^\.pi\/skills\/[^/]+\/SKILL\.md$/.test(item.path))) {
|
|
185
|
+
const skill = target.path.split("/")[2];
|
|
186
|
+
const missing = missingReferences(deps.cwd, skill);
|
|
187
|
+
if (missing.length > 0) problems.push(`新增技能引用面缺:${missing.join("、")}`);
|
|
188
|
+
}
|
|
189
|
+
gates.push({
|
|
190
|
+
gate: "gate3",
|
|
191
|
+
name: "复验",
|
|
192
|
+
ok: problems.length === 0,
|
|
193
|
+
ms: deps.now() - t3,
|
|
194
|
+
...(problems.length > 0 ? { detail: problems.join(";") } : {}),
|
|
195
|
+
});
|
|
196
|
+
if (problems.length > 0) {
|
|
197
|
+
const detail = `${problems.join(";")};${await restoreTargets(deps, targets, wrote)}`;
|
|
198
|
+
return fail(`闸3 复验失败:${detail}`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// 闸4 · 生效边界:不做 /reload,只提示新会话生效(技能在 session_start 重新发现)
|
|
202
|
+
gates.push({ gate: "gate4", name: "生效边界", ok: true, ms: 0, detail: "未 /reload:新会话生效" });
|
|
203
|
+
return { ok: true, unauthorized: false, gates, wrote, appliedDiff };
|
|
204
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 从 run 目录推出「这次采纳要写哪些文件、写成什么」。
|
|
3
|
+
*
|
|
4
|
+
* 为什么单独一层:apply 的五道闸只关心 (path, before, after),把「提案 → 目标」的解析
|
|
5
|
+
* 与闸门执行分开,边界用例(多文件 diff、新建技能、diff 应用失败)才能单独测。
|
|
6
|
+
*/
|
|
7
|
+
import * as fs from "node:fs";
|
|
8
|
+
import * as path from "node:path";
|
|
9
|
+
import { applyUnifiedDiff } from "./diff-apply.ts";
|
|
10
|
+
import { splitDiffByFile } from "./diff-gen.ts";
|
|
11
|
+
|
|
12
|
+
export interface PlannedTarget {
|
|
13
|
+
/** 相对仓库根,统一用 `/` 分隔 */
|
|
14
|
+
path: string;
|
|
15
|
+
abs: string;
|
|
16
|
+
/** null = 新建文件 */
|
|
17
|
+
before: string | null;
|
|
18
|
+
after: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface PlanResult {
|
|
22
|
+
ok: boolean;
|
|
23
|
+
reason?: string;
|
|
24
|
+
skill: string;
|
|
25
|
+
type: string;
|
|
26
|
+
targets: PlannedTarget[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ProposalMeta {
|
|
30
|
+
type: string;
|
|
31
|
+
skill: string;
|
|
32
|
+
diffFile: string;
|
|
33
|
+
candidateFile?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function readText(file: string): string | null {
|
|
37
|
+
try {
|
|
38
|
+
return fs.readFileSync(file, "utf8");
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function readProposalMeta(runDir: string): ProposalMeta | null {
|
|
45
|
+
const raw = readText(path.join(runDir, "02-proposal.json"));
|
|
46
|
+
if (raw === null) return null;
|
|
47
|
+
try {
|
|
48
|
+
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
49
|
+
if (typeof parsed.skill !== "string" || parsed.skill.trim().length === 0) return null;
|
|
50
|
+
return {
|
|
51
|
+
type: typeof parsed.type === "string" ? parsed.type : "improve",
|
|
52
|
+
skill: parsed.skill.trim(),
|
|
53
|
+
diffFile: typeof parsed.diffFile === "string" ? parsed.diffFile : "02-proposal.diff",
|
|
54
|
+
...(typeof parsed.candidateFile === "string" ? { candidateFile: parsed.candidateFile } : {}),
|
|
55
|
+
};
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function planTargets(runDir: string, cwd: string): PlanResult {
|
|
62
|
+
const meta = readProposalMeta(runDir);
|
|
63
|
+
if (!meta) return { ok: false, reason: "02-proposal.json 缺失或不可读", skill: "", type: "", targets: [] };
|
|
64
|
+
const base = { skill: meta.skill, type: meta.type };
|
|
65
|
+
const targets: PlannedTarget[] = [];
|
|
66
|
+
const diffText = readText(path.join(runDir, meta.diffFile)) ?? "";
|
|
67
|
+
|
|
68
|
+
if (meta.type === "create") {
|
|
69
|
+
const candidateFile = meta.candidateFile ?? path.posix.join("candidate", meta.skill, "SKILL.md");
|
|
70
|
+
const after = readText(path.join(runDir, candidateFile));
|
|
71
|
+
if (after === null) return { ok: false, reason: `候选文件缺失:${candidateFile}`, ...base, targets: [] };
|
|
72
|
+
const rel = `.pi/skills/${meta.skill}/SKILL.md`;
|
|
73
|
+
targets.push({ path: rel, abs: path.join(cwd, rel), before: readText(path.join(cwd, rel)), after });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
for (const { path: rel, chunk } of splitDiffByFile(diffText)) {
|
|
77
|
+
if (rel === null) return { ok: false, reason: "提案里含删除文件(+++ /dev/null),删除必须走 --prune 人批", ...base, targets: [] };
|
|
78
|
+
if (targets.some((target) => target.path === rel)) continue; // 新建技能的主文件已由 candidate 生成
|
|
79
|
+
const abs = path.join(cwd, rel);
|
|
80
|
+
const before = readText(abs);
|
|
81
|
+
const applied = applyUnifiedDiff(before ?? "", chunk);
|
|
82
|
+
if (!applied.ok) {
|
|
83
|
+
return { ok: false, reason: `diff 无法应用(${rel}):${applied.reason ?? "未知原因"}`, ...base, targets: [] };
|
|
84
|
+
}
|
|
85
|
+
targets.push({ path: rel, abs, before, after: applied.text });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (targets.length === 0) return { ok: false, reason: "提案没有任何可写目标", ...base, targets: [] };
|
|
89
|
+
return { ok: true, ...base, targets };
|
|
90
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 采纳一条队列条目:解析目标 → 过五道闸 → 写 05/06 留证 → auto 级单独一条 commit。
|
|
3
|
+
*
|
|
4
|
+
* 只在扩展进程里跑(`withFileMutationQueue` 只有 pi 运行时能提供)。督导进程只写 03-review.json,
|
|
5
|
+
* 所以主会话退出也不会丢:队列在 `.pi/evolution/queue.json`,下次 session_start 接着采纳。
|
|
6
|
+
*/
|
|
7
|
+
import * as fs from "node:fs";
|
|
8
|
+
import * as path from "node:path";
|
|
9
|
+
import { planTargets } from "./apply-targets.ts";
|
|
10
|
+
import { restoreTargets, runGates, type GateDeps, type GateResult } from "./apply-gates.ts";
|
|
11
|
+
import { markDone, type QueueItem } from "./queue.ts";
|
|
12
|
+
import { updateRunStatus } from "./runs.ts";
|
|
13
|
+
|
|
14
|
+
export type ApplyStatus = "applied" | "rolled-back" | "failed" | "unauthorized_write";
|
|
15
|
+
|
|
16
|
+
export interface ApplyOutcome {
|
|
17
|
+
runId: string;
|
|
18
|
+
status: ApplyStatus;
|
|
19
|
+
gates: GateResult[];
|
|
20
|
+
wrote: string[];
|
|
21
|
+
detail?: string;
|
|
22
|
+
/** auto 级成功后的 commit 短 hash(approval 级不自动提交,留给人手动提交) */
|
|
23
|
+
commit?: string | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ApplyDeps extends GateDeps {
|
|
27
|
+
/** auto 级成功后单独一条 `docs(skill): ...`;返回 ok=false 即回滚该条 */
|
|
28
|
+
commit?: (item: QueueItem, runDir: string) => { ok: boolean; hash?: string; detail?: string };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface VerifyRecord {
|
|
32
|
+
runId: string;
|
|
33
|
+
status: ApplyStatus;
|
|
34
|
+
at: number;
|
|
35
|
+
atIso: string;
|
|
36
|
+
ms: number;
|
|
37
|
+
gates: GateResult[];
|
|
38
|
+
wrote: string[];
|
|
39
|
+
detail?: string;
|
|
40
|
+
commit?: string | null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const APPLIED_DIFF_FILE = "05-applied.diff";
|
|
44
|
+
export const VERIFY_FILE = "06-verify.json";
|
|
45
|
+
|
|
46
|
+
export function appliedDiffFile(runDir: string): string {
|
|
47
|
+
return path.join(runDir, APPLIED_DIFF_FILE);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function verifyFile(runDir: string): string {
|
|
51
|
+
return path.join(runDir, VERIFY_FILE);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function readVerify(runDir: string): VerifyRecord | null {
|
|
55
|
+
try {
|
|
56
|
+
return JSON.parse(fs.readFileSync(verifyFile(runDir), "utf8")) as VerifyRecord;
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function writeText(deps: ApplyDeps, file: string, text: string): Promise<void> {
|
|
63
|
+
await deps.queue(file, async () => {
|
|
64
|
+
await fs.promises.mkdir(path.dirname(file), { recursive: true });
|
|
65
|
+
await fs.promises.writeFile(file, text, "utf8");
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function applyRun(item: QueueItem, deps: ApplyDeps): Promise<ApplyOutcome> {
|
|
70
|
+
const runDir = path.join(deps.evolutionDir, "runs", item.runId);
|
|
71
|
+
const started = deps.now();
|
|
72
|
+
const plan = planTargets(runDir, deps.cwd);
|
|
73
|
+
let outcome: ApplyOutcome;
|
|
74
|
+
|
|
75
|
+
if (!plan.ok) {
|
|
76
|
+
outcome = { runId: item.runId, status: "failed", gates: [], wrote: [], detail: plan.reason ?? "提案不可用" };
|
|
77
|
+
} else {
|
|
78
|
+
const run = await runGates({ ...deps, runId: item.runId }, plan.targets);
|
|
79
|
+
const status: ApplyStatus = run.ok
|
|
80
|
+
? "applied"
|
|
81
|
+
: run.unauthorized
|
|
82
|
+
? "unauthorized_write"
|
|
83
|
+
: run.wrote.length > 0
|
|
84
|
+
? "rolled-back"
|
|
85
|
+
: "failed";
|
|
86
|
+
outcome = { runId: item.runId, status, gates: run.gates, wrote: run.wrote, ...(run.detail ? { detail: run.detail } : {}) };
|
|
87
|
+
|
|
88
|
+
if (run.ok) {
|
|
89
|
+
// 05 只记真实落盘的 diff:被闸拦下的提案不该留下「已应用」的证据
|
|
90
|
+
await writeText(deps, appliedDiffFile(runDir), run.appliedDiff);
|
|
91
|
+
if (item.level === "auto" && deps.commit) {
|
|
92
|
+
const committed = deps.commit(item, runDir);
|
|
93
|
+
if (committed.ok) outcome = { ...outcome, commit: committed.hash ?? null };
|
|
94
|
+
else {
|
|
95
|
+
const reverted = await restoreTargets(deps, plan.targets, run.wrote);
|
|
96
|
+
outcome = { ...outcome, status: "rolled-back", detail: `commit 失败:${committed.detail ?? "未知原因"};${reverted}` };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const record: VerifyRecord = {
|
|
103
|
+
runId: item.runId,
|
|
104
|
+
status: outcome.status,
|
|
105
|
+
at: started,
|
|
106
|
+
atIso: new Date(started).toISOString(),
|
|
107
|
+
ms: deps.now() - started,
|
|
108
|
+
gates: outcome.gates,
|
|
109
|
+
wrote: outcome.wrote,
|
|
110
|
+
...(outcome.detail ? { detail: outcome.detail } : {}),
|
|
111
|
+
...(outcome.commit !== undefined ? { commit: outcome.commit } : {}),
|
|
112
|
+
};
|
|
113
|
+
await writeText(deps, verifyFile(runDir), `${JSON.stringify(record, null, 2)}\n`);
|
|
114
|
+
return outcome;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** 拒绝只改状态:队列条目 + run.json,绝不碰任何技能文件(和采纳走的是两条路) */
|
|
118
|
+
export interface RejectDeps {
|
|
119
|
+
evolutionDir: string;
|
|
120
|
+
queue: MutationQueue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface RejectOutcome {
|
|
124
|
+
ok: boolean;
|
|
125
|
+
detail: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 否掉一条待批提案:队列 pending → rejected,run.json → rejected。
|
|
130
|
+
*
|
|
131
|
+
* 为什么不做更多:拒绝的语义是「这条提案不要了」,不是「撤销一次已发生的写入」——
|
|
132
|
+
* 回滚已经应用过的改动是另一条路径(05-applied.diff + restoreTargets)。这里写盘只写状态文件。
|
|
133
|
+
*/
|
|
134
|
+
export async function rejectRun(item: QueueItem, deps: RejectDeps): Promise<RejectOutcome> {
|
|
135
|
+
if (item.status !== "pending") return { ok: false, detail: `当前状态是 ${item.status},只有 pending 能拒绝` };
|
|
136
|
+
// 顺序与 apply 相反:队列是外部可见的终态标志,run.json 缺了也不能让条目永远 pending
|
|
137
|
+
const marked = await markDone(deps, item.runId, "rejected", "人主动拒绝(未改任何技能文件)");
|
|
138
|
+
if (!marked) return { ok: false, detail: `队列里没有 ${item.runId}` };
|
|
139
|
+
const patched = await updateRunStatus(deps.evolutionDir, item.runId, "rejected", deps.queue);
|
|
140
|
+
return { ok: true, detail: patched ? "" : "run.json 不存在,只改了队列状态" };
|
|
141
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* G2 机械归因:把「曝光了但没加载」的技能判定成 A~E 中的一类,判不动的留给复盘 agent 做 F/G。
|
|
3
|
+
*
|
|
4
|
+
* 判定顺序是硬的(设计稿第八章):A → B → C → D → E → semantic,命中即停。
|
|
5
|
+
* 顺序颠倒的后果不是「少一条建议」,而是得到一堆「建议重写 description」的错误建议——
|
|
6
|
+
* 一个 frontmatter 坏掉、或者本轮根本没有 read 工具的会话,文案改成天花乱坠也加载不了。
|
|
7
|
+
*
|
|
8
|
+
* 本模块是纯函数:不读盘、不调 pi,输入全部由调用方(mechanical.ts)备好。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type AttributionCategory = "A" | "B" | "C" | "D" | "E" | "semantic";
|
|
12
|
+
|
|
13
|
+
/** 报告与 prompt 都按这个顺序展示,别处不要再自己排序 */
|
|
14
|
+
export const CATEGORY_ORDER: readonly AttributionCategory[] = ["A", "B", "C", "D", "E", "semantic"];
|
|
15
|
+
|
|
16
|
+
export interface ExposedSkill {
|
|
17
|
+
name: string;
|
|
18
|
+
filePath: string;
|
|
19
|
+
/** 对应 Skill.disableModelInvocation */
|
|
20
|
+
disabled?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface DiagnosticEntry {
|
|
24
|
+
file: string;
|
|
25
|
+
message: string;
|
|
26
|
+
/** pi 的 ResourceDiagnostic.type:warning / error / collision */
|
|
27
|
+
type?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface LoadedSkill {
|
|
31
|
+
name: string;
|
|
32
|
+
filePath: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface AttributionInput {
|
|
36
|
+
/** 本轮曝光但没加载的技能名 */
|
|
37
|
+
missed: string[];
|
|
38
|
+
/** 本轮 prompt 里的技能(含被禁用的) */
|
|
39
|
+
exposed: ExposedSkill[];
|
|
40
|
+
/** loadSkillsFromDir 的发现结果(同名多次 = 发现顺序冲突) */
|
|
41
|
+
loadedFromPi: LoadedSkill[];
|
|
42
|
+
diagnostics: DiagnosticEntry[];
|
|
43
|
+
/** 本轮会话声明的工具名(pi.getActiveTools()) */
|
|
44
|
+
activeTools: string[];
|
|
45
|
+
/** 磁盘上 .pi/skills/* 的目录名 */
|
|
46
|
+
diskSkillNames: string[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface Attribution {
|
|
50
|
+
skill: string;
|
|
51
|
+
category: AttributionCategory;
|
|
52
|
+
reason: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 每个类别的处置建议(设计稿第八章表格):报告里照抄,不自动执行 */
|
|
56
|
+
export const DISPOSITIONS: Record<AttributionCategory, { label: string; action: string; auto: string }> = {
|
|
57
|
+
A: { label: "设计如此", action: "提示「仅支持 /skill:名字 显式调用」,不视为缺陷", auto: "-" },
|
|
58
|
+
B: { label: "frontmatter 坏", action: "修 frontmatter(缺 description 或名字不合规)", auto: "auto" },
|
|
59
|
+
C: { label: "同名冲突", action: "改名或调整发现顺序(高风险,必须人批)", auto: "forbidden" },
|
|
60
|
+
D: { label: "不在发现范围", action: "只记「不在 .pi/skills 观测范围」,不清理", auto: "-" },
|
|
61
|
+
E: { label: "缺读工具", action: "提示会话配置缺 read/bash 工具,与技能内容无关", auto: "-" },
|
|
62
|
+
semantic: { label: "待语义判定", action: "由复盘 agent 判 F(兄弟竞争)/ G(文案路由词)", auto: "approval" },
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const TOOL_NAMES_FOR_READING = ["read", "bash"];
|
|
66
|
+
|
|
67
|
+
/** 路径比较:pi 的诊断里 file 可能是绝对路径也可能是相对的,末端一致就算同一个文件 */
|
|
68
|
+
function samePath(a: string, b: string): boolean {
|
|
69
|
+
const norm = (value: string): string => value.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
70
|
+
const left = norm(a);
|
|
71
|
+
const right = norm(b);
|
|
72
|
+
if (left.length === 0 || right.length === 0) return false;
|
|
73
|
+
return left === right || left.endsWith(right) || right.endsWith(left);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function namesSkill(message: string, name: string): boolean {
|
|
77
|
+
// 描述里出现 `name` 这个词才算,避免 "a" 命中 "schema"
|
|
78
|
+
return new RegExp(`(^|[^\\w-])${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}([^\\w-]|$)`).test(message);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 归类一个未命中技能。顺序即设计稿的决策树,改动前先读第八章 */
|
|
82
|
+
export function classifyOne(name: string, input: AttributionInput): Attribution {
|
|
83
|
+
const exposed = input.exposed.find((skill) => skill.name === name);
|
|
84
|
+
const found = input.loadedFromPi.filter((skill) => skill.name === name);
|
|
85
|
+
|
|
86
|
+
// A:disableModelInvocation —— 它本来就不进 prompt,不是缺陷
|
|
87
|
+
if (exposed?.disabled === true) {
|
|
88
|
+
return { skill: name, category: "A", reason: "disableModelInvocation=true,只能 /skill:<名> 显式调用" };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// B:frontmatter 坏(pi 只 warning 不报错,所以它「看起来存在」但没被正常加载)
|
|
92
|
+
const diagnostics = input.diagnostics.filter((entry) => samePath(entry.file, exposed?.filePath ?? ""));
|
|
93
|
+
if (diagnostics.length > 0) {
|
|
94
|
+
return { skill: name, category: "B", reason: `frontmatter/diagnostics:${diagnostics[0].message}` };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// C:同名被抢先(发现顺序问题)
|
|
98
|
+
if (found.length >= 2) {
|
|
99
|
+
return { skill: name, category: "C", reason: `同名在 ${found.length} 处被定义:${found.map((item) => item.filePath).join("、")}` };
|
|
100
|
+
}
|
|
101
|
+
const collision = input.diagnostics.find((entry) => entry.type === "collision" && namesSkill(entry.message, name));
|
|
102
|
+
if (collision) return { skill: name, category: "C", reason: `发现冲突:${collision.message}` };
|
|
103
|
+
|
|
104
|
+
// D:磁盘上有这个技能,但不在发现结果里 —— 不在观测范围
|
|
105
|
+
if (input.diskSkillNames.includes(name) && found.length === 0) {
|
|
106
|
+
return { skill: name, category: "D", reason: `磁盘上有 .pi/skills/${name}/,但 loadSkillsFromDir 没有返回它` };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// E:本轮根本没有读工具 —— 必须排在文案类之前,否则会得出「改 description」的错误建议
|
|
110
|
+
// 注意:activeTools 为空表示「没采到」,不是「工具全关了」;采集缺口不能拿来当结论
|
|
111
|
+
const hasReader = TOOL_NAMES_FOR_READING.some((tool) => input.activeTools.includes(tool));
|
|
112
|
+
if (input.activeTools.length > 0 && !hasReader) {
|
|
113
|
+
return {
|
|
114
|
+
skill: name,
|
|
115
|
+
category: "E",
|
|
116
|
+
reason: `本轮 activeTools 里既没有 read 也没有 bash(当前:${input.activeTools.join("、")}),模型读不到技能文件`,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return { skill: name, category: "semantic", reason: "机械层判不了,交给复盘 agent 判 F(兄弟竞争)/ G(文案路由词)" };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** 对一批未命中技能逐个归类,保持传入顺序 */
|
|
124
|
+
export function classifyMechanical(input: AttributionInput): Attribution[] {
|
|
125
|
+
return input.missed.map((name) => classifyOne(name, input));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 多轮合并时的归因去重:同一技能只留第一次的分类,避免一句话里出现两种结论 */
|
|
129
|
+
export function mergeAttributions(
|
|
130
|
+
a: readonly Attribution[] | undefined,
|
|
131
|
+
b: readonly Attribution[] | undefined,
|
|
132
|
+
): Attribution[] {
|
|
133
|
+
const merged: Attribution[] = [];
|
|
134
|
+
for (const item of [...(a ?? []), ...(b ?? [])]) {
|
|
135
|
+
if (!merged.some((existing) => existing.skill === item.skill)) merged.push(item);
|
|
136
|
+
}
|
|
137
|
+
return merged;
|
|
138
|
+
}
|