@morehao/dsh-skills-console 0.4.0 → 0.5.1

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.
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { contentHash } from './hash.js';
4
3
  import { agentPresence } from './exporter.js';
4
+ import { hashWithoutInvocation, inspectPark } from './invocation.js';
5
5
 
6
6
  /**
7
7
  * CLI 全局 lock(v3)。读不到、版本不认识、结构损坏都返回空账 ——
@@ -128,22 +128,34 @@ export function buildLedger({ paths, state, registry, lock, visibility, now = ne
128
128
  }
129
129
 
130
130
  const brokenExports = exportsRows.filter((item) => item.broken);
131
+ const park = install && entry.kind === 'dir' ? inspectPark(entry.path) : null;
132
+ // A′ 停用期间目录里没有 SKILL.md,但多了一个 marker 与 disabled 文件;不能按「真身缺失」算。
133
+ const parked = Boolean(park?.parked);
134
+ const manual = install?.mode === 'manual' ? install.manual : null;
135
+ const visible = visibility?.byName?.get(entry.name) || null;
136
+ // 只要存在 winner 就判「赢的是不是这一份」:技能自己声明 manual-only 时也不可见,
137
+ // 但它确实在扫描根里,不能被 UI 说成「扫描目录里没有它」。
138
+ const winnerIsCanonical = visible?.winnerPath
139
+ ? path.resolve(visible.winnerPath) === path.resolve(entry.path)
140
+ : null;
141
+ // 作者在 frontmatter 声明了 true、但账本还是 auto:展示层按作者只读处理,不改 state。
142
+ // (state 里的 manual 才是插件写过的;这里纯推断,避免把作者声明显示成「自动」。)
143
+ const authorOnly = !parked && !manual && entry.kind === 'dir'
144
+ && Boolean(visible?.foreignHidden) && winnerIsCanonical === true;
131
145
  let currentHash = null;
132
146
  let locallyModified = false;
133
- if (install && entry.kind === 'dir') {
134
- currentHash = contentHash(entry.path);
135
- locallyModified = Boolean(install.installedHash) && currentHash !== install.installedHash;
147
+ if (install && entry.kind === 'dir' && !parked) {
148
+ // manual 的标记行是插件写的,比较前先按 state 还原;还原失败按「本地有改动」保守处理。
149
+ currentHash = hashWithoutInvocation(entry.path, manual);
150
+ locallyModified = Boolean(install.installedHash)
151
+ && (currentHash === null || currentHash !== install.installedHash);
136
152
  }
137
153
 
138
154
  const check = state.checks?.[entry.name] || null;
139
- const visible = visibility?.byName?.get(entry.name) || null;
140
155
  const subscribed = Boolean(install?.subscriptionId) && subscriptions.has(install.subscriptionId);
141
156
  const enabled = install ? install.enabled !== false : true;
142
- // 只要存在 winner 就判「赢的是不是这一份」:技能自己声明 manual-only 时也不可见,
143
- // 但它确实在扫描根里,不能被 UI 说成「扫描目录里没有它」。
144
- const winnerIsCanonical = visible?.winnerPath
145
- ? path.resolve(visible.winnerPath) === path.resolve(entry.path)
146
- : null;
157
+ const mode = parked ? 'hidden' : authorOnly ? 'manual' : (install?.mode || 'auto');
158
+ const legacy = install?.legacy === 'dsh-off' ? 'dsh-off' : null;
147
159
  return {
148
160
  name: entry.name,
149
161
  path: entry.path,
@@ -166,8 +178,22 @@ export function buildLedger({ paths, state, registry, lock, visibility, now = ne
166
178
  installedAt: install?.installedAt ?? null,
167
179
  updatedAt: install?.updatedAt ?? null,
168
180
  enabled,
169
- /** @deprecated v2 起用 `enabled`;留着只为一份 minor 内的缓存客户端不白屏 */
170
- mode: enabled ? 'on' : 'off',
181
+ /** auto / manual / hidden;hidden 以目录里的 marker 为准,state 丢了也能恢复 */
182
+ mode,
183
+ legacy,
184
+ manual: manual || null,
185
+ origin: authorOnly ? 'author' : (manual?.origin || null),
186
+ drift: Boolean(manual?.drift),
187
+ hidden: install?.hidden || null,
188
+ park: park
189
+ ? {
190
+ parked,
191
+ recorded: Boolean(park.marker),
192
+ marker: park.marker || null,
193
+ previousMode: park.marker?.previousMode || install?.hidden?.previousMode || 'auto',
194
+ disabledFile: park.marker?.disabledFile || 'SKILL.md.disabled',
195
+ }
196
+ : null,
171
197
  exports: exportsRows,
172
198
  brokenExports: brokenExports.length,
173
199
  status: statusOf({ install, check, locallyModified, brokenExports, subscribed }),
@@ -194,11 +220,13 @@ export function buildLedger({ paths, state, registry, lock, visibility, now = ne
194
220
  // 这条差异必须让 UI 说得出来。键名保留一版,语义改为「停用不了副本」。
195
221
  shadowScopeMismatch: !enabled && winnerIsCanonical === false,
196
222
  },
197
- note: entry.kind === 'broken'
198
- ? 'canonical 里是一个悬空链接'
199
- : entry.hasSkillFile
200
- ? null
201
- : '缺少 SKILL.md,DSH 无法解析',
223
+ note: parked
224
+ ? '已完全停用:所有 Agent 都看不到;恢复后回到原来的模式'
225
+ : entry.kind === 'broken'
226
+ ? '技能目录里是一条失效的链接'
227
+ : entry.hasSkillFile
228
+ ? null
229
+ : '缺少技能文件,DSH 无法解析',
202
230
  scannedAt: now,
203
231
  };
204
232
  });
@@ -0,0 +1,578 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ import { contentHashWith } from './hash.js';
6
+
7
+ /**
8
+ * 「自动 / 仅手动 / 完全停用」的落盘层。
9
+ *
10
+ * - 仅手动:只改 canonical `SKILL.md` 的 frontmatter,插入 / 替换 / 移除
11
+ * `disable-model-invocation` 一行;保留 BOM、CRLF、注释与其它字段。
12
+ * - 完全停用:A′ park —— `SKILL.md` → `SKILL.md.disabled`,目录里留
13
+ * `.dsh-skills-console.park.json`;内容一个字节都不改,恢复就是改名回来。
14
+ * - 归一化哈希:把插件写入的那一行(或改回 `false`)从 `SKILL.md` 里去掉再算目录哈希,
15
+ * 这样 manual 不会被误判成「本地有改动」,和更新计划的基线对得上。
16
+ *
17
+ * 这里不碰 state / 路由:文件事务与回滚由调用方(service)编排。
18
+ */
19
+
20
+ export const INVOCATION_KEY = 'disable-model-invocation';
21
+ export const PARK_MARKER = '.dsh-skills-console.park.json';
22
+ export const PARK_DISABLED = 'SKILL.md.disabled';
23
+ export const PARK_BY = 'dsh-skills-console';
24
+ export const PARK_VERSION = 1;
25
+ export const PARK_KEEP = 5;
26
+
27
+ const TOP_KEY = /^([A-Za-z0-9_-]+)\s*:/;
28
+
29
+ export class InvocationError extends Error {
30
+ constructor(code, message, detail = null, status = 400) {
31
+ super(message);
32
+ this.code = code;
33
+ this.detail = detail;
34
+ this.status = status;
35
+ }
36
+ }
37
+
38
+ function unsupported(message, detail = null) {
39
+ return new InvocationError('error.skills.unsupported', message, detail, 400);
40
+ }
41
+
42
+ function notWritable(message, detail = null) {
43
+ return new InvocationError('error.skills.not-writable', message, detail, 409);
44
+ }
45
+
46
+ function drift(message, detail = null) {
47
+ return new InvocationError('error.skills.drift', message, detail, 409);
48
+ }
49
+
50
+ function splitLines(text) {
51
+ const bom = text.charCodeAt(0) === 0xfeff;
52
+ const body = bom ? text.slice(1) : text;
53
+ const crlf = body.includes('\r\n');
54
+ const eol = crlf ? '\r\n' : '\n';
55
+ return { bom, eol, lines: body.split(eol) };
56
+ }
57
+
58
+ function splitValue(raw) {
59
+ let quote = null;
60
+ let commentAt = -1;
61
+ for (let index = 0; index < raw.length; index += 1) {
62
+ const ch = raw[index];
63
+ if (quote) {
64
+ if (ch === quote && raw[index - 1] !== '\\') quote = null;
65
+ continue;
66
+ }
67
+ if (ch === '"' || ch === "'") {
68
+ quote = ch;
69
+ continue;
70
+ }
71
+ if (ch === '#' && (index === 0 || /\s/.test(raw[index - 1]))) {
72
+ commentAt = index;
73
+ break;
74
+ }
75
+ }
76
+ return {
77
+ raw: (commentAt >= 0 ? raw.slice(0, commentAt) : raw).trim(),
78
+ comment: commentAt >= 0 ? raw.slice(commentAt).trimEnd() : '',
79
+ };
80
+ }
81
+
82
+ function unquote(value) {
83
+ if (value.length >= 2) {
84
+ const first = value[0];
85
+ const last = value[value.length - 1];
86
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
87
+ return value.slice(1, -1);
88
+ }
89
+ }
90
+ return value;
91
+ }
92
+
93
+ function valueKind(raw) {
94
+ const { raw: value } = splitValue(raw);
95
+ if (value === '') return 'empty';
96
+ const plain = unquote(value);
97
+ if (plain === 'true' || plain === 'True' || plain === 'TRUE') return 'true';
98
+ if (plain === 'false' || plain === 'False' || plain === 'FALSE') return 'false';
99
+ return 'other';
100
+ }
101
+
102
+ function renderLine(line, kind) {
103
+ const colon = line.indexOf(':');
104
+ const { comment } = splitValue(line.slice(colon + 1));
105
+ return `${line.slice(0, colon + 1)} ${kind}${comment ? ` ${comment}` : ''}`;
106
+ }
107
+
108
+ function checkFrontmatter(lines) {
109
+ if (lines.length === 0 || lines[0].trim() !== '---') {
110
+ throw unsupported('技能文件没有可编辑的设置区', { reason: 'no-frontmatter' });
111
+ }
112
+ let closing = -1;
113
+ for (let index = 1; index < lines.length; index += 1) {
114
+ if (lines[index].trim() === '---') {
115
+ closing = index;
116
+ break;
117
+ }
118
+ }
119
+ if (closing < 0) throw unsupported('技能文件的设置区格式不完整', { reason: 'no-closing' });
120
+ return closing;
121
+ }
122
+
123
+ /**
124
+ * 解析 SKILL.md 的 frontmatter 结构(不做 YAML 全量解析)。
125
+ * 返回 `keyIndex` / `value`('true' | 'false' | 'other' | 'absent')。
126
+ */
127
+ export function parseInvocation(text) {
128
+ const { bom, eol, lines } = splitLines(String(text ?? ''));
129
+ const closing = checkFrontmatter(lines);
130
+ const hits = [];
131
+ for (let index = 1; index < closing; index += 1) {
132
+ const line = lines[index];
133
+ if (!line || /^\s/.test(line)) continue;
134
+ const match = TOP_KEY.exec(line);
135
+ if (!match || match[1] !== INVOCATION_KEY) continue;
136
+ hits.push(index);
137
+ }
138
+ if (hits.length > 1) {
139
+ throw unsupported(`SKILL.md 里有重复的 ${INVOCATION_KEY}`, { reason: 'duplicate-key' });
140
+ }
141
+ const keyIndex = hits.length === 1 ? hits[0] : -1;
142
+ return {
143
+ bom,
144
+ eol,
145
+ lines,
146
+ closing,
147
+ keyIndex,
148
+ value: keyIndex < 0 ? 'absent' : valueKind(lines[keyIndex].slice(lines[keyIndex].indexOf(':') + 1)),
149
+ keyLine: keyIndex < 0 ? null : lines[keyIndex],
150
+ };
151
+ }
152
+
153
+ function descriptionBlockEnd(lines, start, closing) {
154
+ let index = start;
155
+ while (index + 1 < closing) {
156
+ const next = lines[index + 1];
157
+ if (next === '' || /^\s/.test(next)) {
158
+ index += 1;
159
+ continue;
160
+ }
161
+ break;
162
+ }
163
+ return index;
164
+ }
165
+
166
+ function insertIndexFor(parsed) {
167
+ const { lines, closing } = parsed;
168
+ const descriptions = [];
169
+ for (let index = 1; index < closing; index += 1) {
170
+ const line = lines[index];
171
+ if (!line || /^\s/.test(line)) continue;
172
+ const match = TOP_KEY.exec(line);
173
+ if (match && match[1] === 'description') descriptions.push(index);
174
+ }
175
+ if (descriptions.length === 0) return closing;
176
+ const description = descriptions[0];
177
+ const raw = lines[description].slice(lines[description].indexOf(':') + 1);
178
+ const { raw: value } = splitValue(raw);
179
+ if (/^[|>][+-]?$/.test(value)) return descriptionBlockEnd(lines, description, closing) + 1;
180
+ return description + 1;
181
+ }
182
+
183
+ function join(parsed, lines) {
184
+ return `${parsed.bom ? '\uFEFF' : ''}${lines.join(parsed.eol)}`;
185
+ }
186
+
187
+ /**
188
+ * 写「仅手动」的计划。返回 `{ changed, author, original, nextText }`:
189
+ * - 已有 true:作者声明,不写(author=true,original='true');
190
+ * - 已有 false:替换成 true,original='false';
191
+ * - 不存在:在 description 之后插入,original='absent'。
192
+ */
193
+ export function planManualWrite(text) {
194
+ const parsed = parseInvocation(text);
195
+ if (parsed.value === 'true') {
196
+ return { changed: false, author: true, original: 'true', nextText: text };
197
+ }
198
+ if (parsed.value === 'false') {
199
+ const lines = [...parsed.lines];
200
+ lines[parsed.keyIndex] = renderLine(lines[parsed.keyIndex], 'true');
201
+ return { changed: true, author: false, original: 'false', nextText: join(parsed, lines) };
202
+ }
203
+ if (parsed.value !== 'absent') {
204
+ throw unsupported(`SKILL.md 的 ${INVOCATION_KEY} 不是布尔值`, { reason: 'non-boolean' });
205
+ }
206
+ const lines = [...parsed.lines];
207
+ const at = insertIndexFor(parsed);
208
+ lines.splice(at, 0, `${INVOCATION_KEY}: true`);
209
+ return { changed: true, author: false, original: 'absent', nextText: join(parsed, lines) };
210
+ }
211
+
212
+ /**
213
+ * 写「自动」的恢复计划。`original` 来自 state:
214
+ * - absent:移除插件插入的行;
215
+ * - false:把 true 改回 false;
216
+ * - 当前值不是 true / 行被删掉 / 重复 key:抛 drift,由调用方把 origin 标成 author 并报告;
217
+ * - origin='author':调用方在进来之前就该拒绝。
218
+ */
219
+ export function planManualRestore(text, original) {
220
+ const parsed = parseInvocation(text);
221
+ if (original === 'true') {
222
+ throw new InvocationError(
223
+ 'error.skills.author',
224
+ '这个技能由作者设为仅手动,插件不能替你改回自动;请编辑技能文件',
225
+ { reason: 'author' },
226
+ 409,
227
+ );
228
+ }
229
+ if (parsed.value === 'absent') {
230
+ if (original === 'absent') return { changed: false, nextText: text };
231
+ throw drift('技能文件里的「仅手动」标记已被移除,与记录不一致', { reason: 'missing' });
232
+ }
233
+ if (parsed.value !== 'true') {
234
+ throw drift('技能文件里的标记被改过,可能出自作者;请先检查文件', {
235
+ reason: 'value-changed',
236
+ value: parsed.value,
237
+ });
238
+ }
239
+ const lines = [...parsed.lines];
240
+ if (original === 'absent') {
241
+ lines.splice(parsed.keyIndex, 1);
242
+ } else {
243
+ lines[parsed.keyIndex] = renderLine(lines[parsed.keyIndex], 'false');
244
+ }
245
+ return { changed: true, nextText: join(parsed, lines) };
246
+ }
247
+
248
+ /** 只读一份 SKILL.md;文件不存在/读不到抛 not-writable,由 service 转成 409。 */
249
+ export function readSkillText(skillDir) {
250
+ const file = path.join(skillDir, 'SKILL.md');
251
+ let stat;
252
+ try {
253
+ stat = fs.lstatSync(file);
254
+ } catch {
255
+ throw notWritable(`${file} 不存在`);
256
+ }
257
+ if (stat.isSymbolicLink()) throw notWritable('技能文件是符号链接,拒绝编辑', { reason: 'symlink' });
258
+ if (!stat.isFile()) throw notWritable('技能文件不是普通文件', { reason: 'not-file' });
259
+ return fs.readFileSync(file, 'utf8');
260
+ }
261
+
262
+ /** 原子写:同目录临时文件 → fsync → rename;保留原文件权限。 */
263
+ export function writeFileAtomic(file, content) {
264
+ const dir = path.dirname(file);
265
+ const stat = fs.statSync(file);
266
+ const tmp = path.join(dir, `.${path.basename(file)}.${process.pid}.${Date.now()}.tmp`);
267
+ let fd;
268
+ try {
269
+ fd = fs.openSync(tmp, 'w', stat.mode & 0o777);
270
+ fs.writeSync(fd, content);
271
+ fs.fsyncSync(fd);
272
+ fs.closeSync(fd);
273
+ fd = undefined;
274
+ fs.renameSync(tmp, file);
275
+ try {
276
+ const dirFd = fs.openSync(dir, 'r');
277
+ fs.fsyncSync(dirFd);
278
+ fs.closeSync(dirFd);
279
+ } catch {
280
+ /* 目录 fsync 在部分平台不可用;rename 已完成 */
281
+ }
282
+ } catch (error) {
283
+ try {
284
+ if (fd !== undefined) fs.closeSync(fd);
285
+ } catch {
286
+ /* ignore */
287
+ }
288
+ try {
289
+ fs.rmSync(tmp, { force: true });
290
+ } catch {
291
+ /* ignore */
292
+ }
293
+ throw notWritable(`写 SKILL.md 失败:${error.message}`, { reason: 'write-failed' });
294
+ }
295
+ }
296
+
297
+ function safeName(name) {
298
+ return name.replace(/[^A-Za-z0-9._-]+/g, '_').slice(0, 80);
299
+ }
300
+
301
+ /**
302
+ * 写前备份到插件备份目录(与安装备份分开放,避免把更新备份挤出保留窗口)。
303
+ * 每个技能最多留 PARK_KEEP 份。
304
+ */
305
+ export function backupInvocation({ backupDir, name, content }) {
306
+ const dir = path.join(backupDir, 'invocation');
307
+ fs.mkdirSync(dir, { recursive: true });
308
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
309
+ const key = `${safeName(name)}-${stamp}-${crypto.randomBytes(3).toString('hex')}`;
310
+ const file = path.join(dir, `${key}.md`);
311
+ const at = new Date().toISOString();
312
+ fs.writeFileSync(file, content, { encoding: 'utf8', mode: 0o600 });
313
+ fs.writeFileSync(`${file}.json`, `${JSON.stringify({ key, name, file, at }, null, 2)}\n`, 'utf8');
314
+ try {
315
+ const keep = fs
316
+ .readdirSync(dir)
317
+ .filter((entry) => entry.endsWith('.md') && entry.startsWith(`${safeName(name)}-`))
318
+ .sort()
319
+ .reverse();
320
+ for (const stale of keep.slice(PARK_KEEP)) {
321
+ fs.rmSync(path.join(dir, stale), { force: true });
322
+ fs.rmSync(path.join(dir, `${stale}.json`), { force: true });
323
+ }
324
+ } catch {
325
+ /* 清理失败不影响主流程 */
326
+ }
327
+ return { key, file, at };
328
+ }
329
+
330
+ function isRealDir(dir) {
331
+ try {
332
+ const stat = fs.lstatSync(dir);
333
+ return stat.isDirectory() && !stat.isSymbolicLink();
334
+ } catch {
335
+ return false;
336
+ }
337
+ }
338
+
339
+ function isRegularFile(file) {
340
+ try {
341
+ return fs.lstatSync(file).isFile();
342
+ } catch {
343
+ return false;
344
+ }
345
+ }
346
+
347
+ function sha256File(file) {
348
+ return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
349
+ }
350
+
351
+ function sameBytes(a, b) {
352
+ try {
353
+ return sha256File(a) === sha256File(b);
354
+ } catch {
355
+ return false;
356
+ }
357
+ }
358
+
359
+ function fsyncDir(dir) {
360
+ try {
361
+ const fd = fs.openSync(dir, 'r');
362
+ fs.fsyncSync(fd);
363
+ fs.closeSync(fd);
364
+ } catch {
365
+ /* 部分平台不支持目录 fsync */
366
+ }
367
+ }
368
+
369
+ function writeJsonAtomic(file, value) {
370
+ const dir = path.dirname(file);
371
+ const tmp = path.join(dir, `.${path.basename(file)}.${process.pid}.${Date.now()}.tmp`);
372
+ const fd = fs.openSync(tmp, 'w', 0o600);
373
+ try {
374
+ fs.writeSync(fd, `${JSON.stringify(value, null, 2)}\n`);
375
+ fs.fsyncSync(fd);
376
+ fs.closeSync(fd);
377
+ fs.renameSync(tmp, file);
378
+ fsyncDir(dir);
379
+ } catch (error) {
380
+ try {
381
+ fs.closeSync(fd);
382
+ } catch {
383
+ /* ignore */
384
+ }
385
+ try {
386
+ fs.rmSync(tmp, { force: true });
387
+ } catch {
388
+ /* ignore */
389
+ }
390
+ throw error;
391
+ }
392
+ }
393
+
394
+ /** 读 park marker;只认本插件写的、结构完整的。 */
395
+ export function readParkMarker(skillDir) {
396
+ const file = path.join(skillDir, PARK_MARKER);
397
+ try {
398
+ const stat = fs.lstatSync(file);
399
+ if (!stat.isFile() || stat.isSymbolicLink()) return null;
400
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
401
+ if (!parsed || parsed.by !== PARK_BY || parsed.version !== PARK_VERSION) return null;
402
+ return parsed;
403
+ } catch {
404
+ return null;
405
+ }
406
+ }
407
+
408
+ export function inspectPark(skillDir) {
409
+ const marker = readParkMarker(skillDir);
410
+ const hasSkill = isRegularFile(path.join(skillDir, 'SKILL.md'));
411
+ const hasDisabled = isRegularFile(path.join(skillDir, PARK_DISABLED));
412
+ // marker 在、文件没改名(崩在 rename 前):实际仍是可用状态,不算 parked;
413
+ // 反过来说,disabled 在就算 parked,即使 marker 被外部删了也能恢复。
414
+ return { parked: Boolean(hasDisabled || (marker && !hasSkill)), marker, hasSkill, hasDisabled, dir: skillDir };
415
+ }
416
+
417
+ /**
418
+ * A′ park:marker-first。先把 marker 落盘,再 rename SKILL.md。
419
+ * 崩在 rename 前:marker + SKILL.md(启动按「未 park」收敛);
420
+ * 崩在 rename 后:marker + disabled(正常 parked)。
421
+ * 内容哈希在写 marker 之前算,恢复后用它核对内容没被改过。
422
+ */
423
+ export function parkSkill({ dir, name, previousMode = 'auto' }) {
424
+ if (!isRealDir(dir)) throw notWritable('只有真实目录型技能可以完全停用', { reason: 'not-dir' });
425
+ const skillFile = path.join(dir, 'SKILL.md');
426
+ const disabledFile = path.join(dir, PARK_DISABLED);
427
+ if (!isRegularFile(skillFile)) {
428
+ if (isRegularFile(disabledFile)) return { already: true, marker: readParkMarker(dir) };
429
+ throw notWritable('目录里没有技能文件,无法完全停用', { reason: 'no-skill-file' });
430
+ }
431
+ if (isRegularFile(disabledFile)) {
432
+ throw new InvocationError(
433
+ 'error.skills.park-conflict',
434
+ '目录里已有停用文件;请先处理它再完全停用',
435
+ { reason: 'disabled-exists' },
436
+ 409,
437
+ );
438
+ }
439
+ const existing = readParkMarker(dir);
440
+ if (existing) throw new InvocationError('error.skills.park-conflict', '这个技能已经有停用标记', { reason: 'marker-exists' }, 409);
441
+
442
+ const canonicalHash = contentHashWith(dir);
443
+ const marker = {
444
+ by: PARK_BY,
445
+ version: PARK_VERSION,
446
+ name,
447
+ disabledFile: PARK_DISABLED,
448
+ previousMode: previousMode === 'manual' ? 'manual' : 'auto',
449
+ canonicalHash,
450
+ at: new Date().toISOString(),
451
+ };
452
+ writeJsonAtomic(path.join(dir, PARK_MARKER), marker);
453
+ try {
454
+ fs.renameSync(skillFile, disabledFile);
455
+ fsyncDir(dir);
456
+ } catch (error) {
457
+ try {
458
+ fs.rmSync(path.join(dir, PARK_MARKER), { force: true });
459
+ } catch {
460
+ /* ignore */
461
+ }
462
+ throw notWritable(`停用失败:${error.message}`, { reason: 'rename-failed' });
463
+ }
464
+ return { marker, canonicalHash };
465
+ }
466
+
467
+ /**
468
+ * A′ unpark。允许 state 丢失(marker 仍在)时 best-effort 恢复;`fallbackMode`
469
+ * 由调用方按 state 提供。
470
+ *
471
+ * 半状态:
472
+ * - 两份都在且内容相同 → 删 disabled,收敛;
473
+ * - 两份都在且内容不同 → 默认 409;`keep='current'` 保留当前 SKILL.md,
474
+ * `keep='parked'` 把当前另存为 SKILL.md.conflict-* 后恢复 parked 版本。
475
+ */
476
+ export function unparkSkill({ dir, name, fallbackMode = 'auto', keep = null }) {
477
+ if (!isRealDir(dir)) throw notWritable('只有真实目录型技能可以恢复', { reason: 'not-dir' });
478
+ const skillFile = path.join(dir, 'SKILL.md');
479
+ const disabledFile = path.join(dir, PARK_DISABLED);
480
+ const markerFile = path.join(dir, PARK_MARKER);
481
+ const marker = readParkMarker(dir);
482
+ const hasSkill = isRegularFile(skillFile);
483
+ const hasDisabled = isRegularFile(disabledFile);
484
+ if (!hasSkill && !hasDisabled) {
485
+ throw new InvocationError(
486
+ 'error.skills.park-broken',
487
+ '技能文件不在;内容可能已被删除,无法恢复',
488
+ { name, reason: 'both-missing' },
489
+ 409,
490
+ );
491
+ }
492
+
493
+ let kept = null;
494
+ let removed = null;
495
+ if (hasSkill && hasDisabled) {
496
+ if (sameBytes(skillFile, disabledFile)) {
497
+ fs.rmSync(disabledFile, { force: true });
498
+ removed = 'duplicate-disabled';
499
+ } else if (keep === 'current') {
500
+ const conflict = path.join(dir, `${PARK_DISABLED}.conflict-${Date.now()}`);
501
+ fs.renameSync(disabledFile, conflict);
502
+ kept = conflict;
503
+ removed = 'parked-copy';
504
+ } else if (keep === 'parked') {
505
+ const conflict = path.join(dir, `SKILL.md.conflict-${Date.now()}`);
506
+ fs.renameSync(skillFile, conflict);
507
+ fs.renameSync(disabledFile, skillFile);
508
+ kept = conflict;
509
+ removed = 'current-copy';
510
+ } else {
511
+ throw new InvocationError(
512
+ 'error.skills.park-conflict',
513
+ '停用期间外部又写回了技能文件,两份内容不同;请选择保留当前文件还是停用前的版本',
514
+ { reason: 'both-present', current: sha256File(skillFile), parked: sha256File(disabledFile) },
515
+ 409,
516
+ );
517
+ }
518
+ } else if (hasDisabled) {
519
+ fs.renameSync(disabledFile, skillFile);
520
+ } else {
521
+ // 只有 SKILL.md:可能外部已经手动恢复,或崩在 rename 前;清掉 marker 收敛。
522
+ removed = 'marker-stale';
523
+ }
524
+ fs.rmSync(markerFile, { force: true });
525
+ fsyncDir(dir);
526
+
527
+ let hashMismatch = false;
528
+ if (marker?.canonicalHash) {
529
+ const current = contentHashWith(dir);
530
+ hashMismatch = current !== null && current !== marker.canonicalHash;
531
+ }
532
+ const previousMode = (marker?.previousMode === 'manual' || marker?.previousMode === 'auto')
533
+ ? marker.previousMode
534
+ : fallbackMode;
535
+ return {
536
+ name,
537
+ mode: previousMode === 'manual' ? 'manual' : 'auto',
538
+ previousMode,
539
+ hashMismatch,
540
+ kept,
541
+ removed,
542
+ marker,
543
+ };
544
+ }
545
+
546
+ /**
547
+ * 归一化哈希:把插件写入的 `disable-model-invocation: true` 按 state 还原后再算目录哈希。
548
+ * 只有 origin='plugin' 才归一化;author 声明属于上游内容,必须原样计入。
549
+ * 解析失败 / 当前值不是 true 时返回 null —— 调用方保守判为「本地有改动」。
550
+ */
551
+ export function hashWithoutInvocation(dir, manual) {
552
+ if (!manual || manual.origin !== 'plugin') return contentHashWith(dir);
553
+ const expected = manual.original;
554
+ if (expected !== 'absent' && expected !== 'false') return null;
555
+ return contentHashWith(dir, (relative, buffer) => {
556
+ if (relative !== 'SKILL.md') return buffer;
557
+ let parsed;
558
+ try {
559
+ parsed = parseInvocation(buffer.toString('utf8'));
560
+ } catch {
561
+ return null;
562
+ }
563
+ if (parsed.value !== 'true') return null;
564
+ const lines = [...parsed.lines];
565
+ if (expected === 'absent') lines.splice(parsed.keyIndex, 1);
566
+ else lines[parsed.keyIndex] = renderLine(lines[parsed.keyIndex], 'false');
567
+ return Buffer.from(join(parsed, lines), 'utf8');
568
+ });
569
+ }
570
+
571
+ /** 供 Host 层判断「这行到底是不是被 park 了」,不读 state。 */
572
+ export function parkedName(skillDir) {
573
+ const marker = readParkMarker(skillDir);
574
+ const disabled = isRegularFile(path.join(skillDir, PARK_DISABLED));
575
+ if (marker) return { parked: true, marker, recorded: true };
576
+ if (disabled) return { parked: true, marker: null, recorded: false };
577
+ return { parked: false, marker: null, recorded: false };
578
+ }
package/lib/core/paths.js CHANGED
@@ -40,6 +40,8 @@ export function createPaths(env = process.env, overrides = {}) {
40
40
  /** DSH 原生 user-dsh 根(rank 400) */
41
41
  dshGlobalSkills: path.join(dshHome, 'skills'),
42
42
  stateFile: path.join(stateDir, 'state.json'),
43
+ /** 技能介绍缓存(模型生成、可丢;不进 state.json 的版本化结构) */
44
+ introFile: path.join(stateDir, 'intros.json'),
43
45
  backupsDir: path.join(stateDir, 'backups'),
44
46
  /** state 迁移的备份与 manifest(v1 → v2),与技能备份分开存 */
45
47
  migrationsDir: path.join(stateDir, 'migrations'),