dsh-plugin-tool-management 0.16.5 → 0.17.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 +94 -0
- package/README.md +2 -1
- package/README_EN.md +2 -1
- package/docs/images/1-EN.png +0 -0
- package/docs/images/1.png +0 -0
- package/docs/images/2-EN.png +0 -0
- package/docs/images/2.png +0 -0
- package/docs/images/3-EN.png +0 -0
- package/docs/images/3.png +0 -0
- package/docs/images/4-EN.png +0 -0
- package/docs/images/4.png +0 -0
- package/docs/images/5-EN.png +0 -0
- package/docs/images/5.png +0 -0
- package/docs/images/6-EN.png +0 -0
- package/docs/images/6.png +0 -0
- package/docs/images/7-EN.png +0 -0
- package/docs/images/7.png +0 -0
- package/docs/images/8-EN.png +0 -0
- package/docs/images/8.png +0 -0
- package/docs/update.md +53 -0
- package/lib/audit-log.js +6 -2
- package/lib/client.js +44 -17
- package/lib/compat/probe.js +75 -24
- package/lib/context-inject.js +73 -7
- package/lib/host-names.js +150 -0
- package/lib/hub.js +82 -11
- package/lib/imports/parsers.js +40 -15
- package/lib/index.js +168 -74
- package/lib/mcp/loader-token.js +26 -0
- package/lib/mcp/manager.js +314 -87
- package/lib/mcp/patch-yaml.js +207 -40
- package/lib/mcp/secret-guard.js +122 -16
- package/lib/memories/archive-engine.js +21 -11
- package/lib/memories/index-io.js +125 -12
- package/lib/memories/service.js +163 -40
- package/lib/memories/snapshot.js +32 -9
- package/lib/op-registry.js +51 -8
- package/lib/ops/compat.js +6 -2
- package/lib/ops/prompts.js +18 -5
- package/lib/ops/scene-records.js +117 -10
- package/lib/ops/sessions.js +50 -4
- package/lib/ops/snapshot.js +105 -12
- package/lib/ops/trash.js +45 -9
- package/lib/prompts/service.js +7 -2
- package/lib/request-gate.js +115 -56
- package/lib/scene-prompt-sync.js +46 -7
- package/lib/scenes/candidates.js +21 -3
- package/lib/sessions/workspace.js +40 -5
- package/lib/skills/core.js +206 -43
- package/lib/skills/service.js +9 -4
- package/lib/subagents/service.js +6 -1
- package/package.json +1 -1
package/lib/ops/snapshot.js
CHANGED
|
@@ -14,8 +14,9 @@
|
|
|
14
14
|
// 导出物不该顺手把它们带走;要完整导出必须显式传 `includeSecrets:true`(界面二次确认)。
|
|
15
15
|
// 打码值导回来由 `secret-guard` 拿目标机的旧真值顶替(`mcpm-import` 既有的行为)。
|
|
16
16
|
import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises';
|
|
17
|
-
import { join, resolve } from 'node:path';
|
|
17
|
+
import { basename, join, resolve } from 'node:path';
|
|
18
18
|
import { unzipSync, zipSync } from 'fflate';
|
|
19
|
+
import { maskSecretValue, maskUrlQuery } from '../mcp/secret-guard.js';
|
|
19
20
|
const SNAP_PREFIX = 'dsh-tool-management-snapshot-';
|
|
20
21
|
/** 快照目录里各部分的名字(导入侧按这些名字找东西,别改)。 */
|
|
21
22
|
const F_MANIFEST = 'manifest.json';
|
|
@@ -29,7 +30,6 @@ const D_SKILLS = 'skills';
|
|
|
29
30
|
/** 三个"文件域":整个目录打进一份 zip,键是相对路径。 */
|
|
30
31
|
const FILE_DOMAINS = [D_MEMORIES, D_SUBAGENTS, D_PROMPTS];
|
|
31
32
|
const ALL_DOMAINS = ['mcp', D_MEMORIES, D_SUBAGENTS, D_PROMPTS, D_SKILLS, 'settings', 'scenes'];
|
|
32
|
-
const MASKED = '••••••';
|
|
33
33
|
function stamp() {
|
|
34
34
|
return new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
35
35
|
}
|
|
@@ -110,6 +110,43 @@ function namesOf(res, prefer) {
|
|
|
110
110
|
}
|
|
111
111
|
return [];
|
|
112
112
|
}
|
|
113
|
+
/**
|
|
114
|
+
* 从 `skill-state` 的返回里取「<来源>/<技能名>」清单。
|
|
115
|
+
*
|
|
116
|
+
* 为什么不能复用 `namesOf`(2026-09-30 审查 P1-6):`skill-state` 的形状是
|
|
117
|
+
* `{ ok, data: { roots: [{ key, skills: [{ name, declaredName, … }] }] } }` —— 顶层键是
|
|
118
|
+
* `roots`,而 `namesOf` 只找 `rows / personas / presets / scenes / skills / list / entries /
|
|
119
|
+
* items` 这些**数组**键,一个都对不上 → 恒返回 `[]` → 技能域**永远导出 0 项**,而界面显示的
|
|
120
|
+
* 是"迁移完成"。key 用**声明名**(frontmatter `name`),口径与 `index.ts` 的
|
|
121
|
+
* `skillArchiveKeysOfRoot` 一致(档案里存的就是声明名,用条目名拼会在 stale 校验里被丢掉)。
|
|
122
|
+
*/
|
|
123
|
+
function skillKeysOf(res) {
|
|
124
|
+
if (!res || res.ok === false)
|
|
125
|
+
return [];
|
|
126
|
+
for (const bag of [res.data, res].filter(Boolean)) {
|
|
127
|
+
const roots = bag.roots;
|
|
128
|
+
if (!Array.isArray(roots))
|
|
129
|
+
continue;
|
|
130
|
+
const out = [];
|
|
131
|
+
for (const root of roots) {
|
|
132
|
+
const rootKey = String(root?.key || '');
|
|
133
|
+
if (!rootKey)
|
|
134
|
+
continue;
|
|
135
|
+
const skills = root.skills;
|
|
136
|
+
if (!Array.isArray(skills))
|
|
137
|
+
continue;
|
|
138
|
+
for (const sk of skills) {
|
|
139
|
+
const s = sk;
|
|
140
|
+
const name = String((s && (s.declaredName || s.name)) || '');
|
|
141
|
+
if (name)
|
|
142
|
+
out.push(rootKey + '/' + name);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (out.length)
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
return [];
|
|
149
|
+
}
|
|
113
150
|
/** 场景清单(带 label / description / 提示词绑定):`rules-list` 的 scenes 那一栏,读不到就是空。 */
|
|
114
151
|
function sceneRecordsOf(res) {
|
|
115
152
|
const bags = [res && res.data, res].filter(Boolean);
|
|
@@ -128,11 +165,18 @@ function sceneRecordsOf(res) {
|
|
|
128
165
|
}
|
|
129
166
|
return [];
|
|
130
167
|
}
|
|
168
|
+
/**
|
|
169
|
+
* 键值对打码:**走 `secret-guard` 的唯一口径**(`$VAR` / `!!js` 这类间接引用原样保留)。
|
|
170
|
+
*
|
|
171
|
+
* 这里曾经自己写了一份「所有键一律打成 `••••••`」的版本 —— 与 MCP 列表视图的口径不一致,
|
|
172
|
+
* 结果是配置里的间接引用被当成密钥导出、导回来时 `resolveMaskedKv` 顶替不出真值只能丢键。
|
|
173
|
+
* 打码形态只允许有一处定义(审查 P0-4)。
|
|
174
|
+
*/
|
|
131
175
|
function maskKv(map) {
|
|
132
176
|
const out = {};
|
|
133
177
|
if (map && typeof map === 'object' && !Array.isArray(map)) {
|
|
134
178
|
for (const k of Object.keys(map))
|
|
135
|
-
out[k] =
|
|
179
|
+
out[k] = maskSecretValue(map[k]);
|
|
136
180
|
}
|
|
137
181
|
return out;
|
|
138
182
|
}
|
|
@@ -175,8 +219,14 @@ export function createSnapshotOps(deps) {
|
|
|
175
219
|
copy.env = maskKv(copy.env);
|
|
176
220
|
if (copy.headers)
|
|
177
221
|
copy.headers = maskKv(copy.headers);
|
|
222
|
+
// URL 查询串走 `secret-guard.maskUrlQuery`(与 MCP 列表视图同一口径)。这里原先是**自写的
|
|
223
|
+
// 一条窄正则** `([?&](?:key|token|secret|password)=)`,只认四个固定参数名 ——
|
|
224
|
+
// `?api_key=` / `?access_token=` / `?sig=` / `?client_secret=` 这类 MCP 托管服务最常见的
|
|
225
|
+
// 形态全部漏掉,而这正是「凭据挂在 URL 上」的主流写法。后果不是界面显示问题:默认
|
|
226
|
+
// (`includeSecrets` 缺省 false)导出的 `mcp.json` 里就是**明文凭据**,而这份目录的用途
|
|
227
|
+
// 就是拷去 U 盘 / 网盘 / 另一台机器(审查 P0-4)。
|
|
178
228
|
if (typeof copy.url === 'string')
|
|
179
|
-
copy.url = copy.url
|
|
229
|
+
copy.url = maskUrlQuery(copy.url);
|
|
180
230
|
return copy;
|
|
181
231
|
});
|
|
182
232
|
try {
|
|
@@ -215,7 +265,7 @@ export function createSnapshotOps(deps) {
|
|
|
215
265
|
}
|
|
216
266
|
// ── 技能:源目录在外部(不在 hub),交给既有的 `bundle-export` 走它自己的收集路径 ──
|
|
217
267
|
const skillState = await deps.invokeOp('skill-state', {});
|
|
218
|
-
const skillKeys =
|
|
268
|
+
const skillKeys = skillKeysOf(skillState);
|
|
219
269
|
if (!skillKeys.length)
|
|
220
270
|
notes.push('技能这次没导出:没从 skill-state 读到「<来源>/<名字>」形态的清单');
|
|
221
271
|
if (skillKeys.length) {
|
|
@@ -385,13 +435,23 @@ export function createSnapshotOps(deps) {
|
|
|
385
435
|
return { ok: false, error: read.error };
|
|
386
436
|
const { files } = read;
|
|
387
437
|
const report = [];
|
|
388
|
-
/**
|
|
438
|
+
/**
|
|
439
|
+
* 一个域失败只记在这一行上:迁移的意义正是「哪域没过来」看得清,不是整批回滚。
|
|
440
|
+
*
|
|
441
|
+
* `warning` 也要透传(2026-09-30 审查 P2-9):各域的 import op 会用它报"带过来了但有
|
|
442
|
+
* 东西被跳过"(`mcpm-import` 的「这些密钥已是打码占位符,请在编辑里重新填写」就是其中
|
|
443
|
+
* 最重要的一条)。此前只取 imported/skipped,于是"迁移成功"的界面下藏着一份**需要用户
|
|
444
|
+
* 动手补的密钥清单**,用户在另一台机器上等到调用工具时才发现。
|
|
445
|
+
*/
|
|
389
446
|
async function run(domain, task) {
|
|
390
447
|
if (domains.indexOf(domain) < 0)
|
|
391
448
|
return;
|
|
392
449
|
try {
|
|
393
450
|
const r = await task();
|
|
394
|
-
report.push({
|
|
451
|
+
report.push({
|
|
452
|
+
domain, imported: r.imported, skipped: r.skipped,
|
|
453
|
+
...(r.warning ? { warnings: [r.warning] } : {}),
|
|
454
|
+
});
|
|
395
455
|
}
|
|
396
456
|
catch (e) {
|
|
397
457
|
report.push({ domain, imported: 0, skipped: 0, error: deps.message(e) });
|
|
@@ -404,7 +464,12 @@ export function createSnapshotOps(deps) {
|
|
|
404
464
|
const res = await deps.invokeOp('mcpm-import', overwrite ? { json: raw, conflict: 'overwrite' } : { json: raw });
|
|
405
465
|
if (!res || res.ok === false)
|
|
406
466
|
throw new Error(String((res && res.error) || 'mcpm-import 失败'));
|
|
407
|
-
return {
|
|
467
|
+
return {
|
|
468
|
+
imported: (res.added || []).length + (res.overwritten || []).length,
|
|
469
|
+
skipped: (res.skipped || []).length,
|
|
470
|
+
// 「打码占位符被跳过」这类结论必须跟着走:它是用户要动手补的清单,不是内部细节。
|
|
471
|
+
...(typeof res.warning === 'string' && res.warning ? { warning: res.warning } : {}),
|
|
472
|
+
};
|
|
408
473
|
});
|
|
409
474
|
await run(D_SUBAGENTS, async () => {
|
|
410
475
|
const zip = files[D_SUBAGENTS + '.zip'];
|
|
@@ -499,6 +564,7 @@ export function createSnapshotOps(deps) {
|
|
|
499
564
|
(byScene[scene] || (byScene[scene] = [])).push({ name, data: Buffer.from(bytes).toString('base64') });
|
|
500
565
|
}
|
|
501
566
|
let imported = 0;
|
|
567
|
+
const reasons = [];
|
|
502
568
|
for (const scene of Object.keys(byScene)) {
|
|
503
569
|
const res = await deps.invokeOp('rules-import', { scene, files: byScene[scene] });
|
|
504
570
|
if (!res || res.ok === false) {
|
|
@@ -507,9 +573,19 @@ export function createSnapshotOps(deps) {
|
|
|
507
573
|
}
|
|
508
574
|
const data = res.data || res;
|
|
509
575
|
imported += (data.imported || []).length;
|
|
510
|
-
|
|
576
|
+
const skippedRows = Array.isArray(data.skipped) ? data.skipped : [];
|
|
577
|
+
skipped += skippedRows.length;
|
|
578
|
+
// `rules-import` 的 skipped 是 `{name, reason}` —— 只数个数会把「为什么没过来」
|
|
579
|
+
// 丢掉,而那正是用户要照着修的东西(名字不合法 / 正文超限 / 附件重名)。
|
|
580
|
+
// 上限 5 条:这是提示不是日志,界面上一行放不下更多(审查 P2-9 同一条口径)。
|
|
581
|
+
for (const row of skippedRows.slice(0, 5)) {
|
|
582
|
+
const name = String((row && row.name) || '');
|
|
583
|
+
const reason = String((row && row.reason) || '');
|
|
584
|
+
if (reason)
|
|
585
|
+
reasons.push(`${name ? name + ':' : ''}${reason}`);
|
|
586
|
+
}
|
|
511
587
|
}
|
|
512
|
-
return { imported, skipped };
|
|
588
|
+
return { imported, skipped, ...(reasons.length ? { warning: reasons.join(';') } : {}) };
|
|
513
589
|
});
|
|
514
590
|
await run('scenes', async () => {
|
|
515
591
|
if (!files[F_SCENES])
|
|
@@ -577,9 +653,26 @@ export function createSnapshotOps(deps) {
|
|
|
577
653
|
let imported = 0;
|
|
578
654
|
let skipped = 0;
|
|
579
655
|
for (const zip of zips) {
|
|
580
|
-
|
|
656
|
+
// 走 `skill-upload`(把 zip 读成 base64)而**不是** `skill-import`(2026-09-30 审查 P1-6):
|
|
657
|
+
// 导出侧产出的是 `bundle-export` 打的**一个 zip**(内含 `<技能名>/<文件>`),而
|
|
658
|
+
// `skill-import` 的 `analyzeSource` 用 `lstat` + `endsWith('.md')` 判定来源,**完全不认识
|
|
659
|
+
// `.zip`** —— 喂过去必然 `error.source.unrecognized`,该域整行报错。zip 的解包只存在于
|
|
660
|
+
// `skill-upload` 这条链(`importUploadedSkill` → `prepareUploadedSource` → `writeUploadedZip`);
|
|
661
|
+
// 已实测它能正确吃下这种形态(`<技能名>/SKILL.md` → 落 `hub/skills/<技能名>/`)。
|
|
662
|
+
let b64;
|
|
663
|
+
try {
|
|
664
|
+
b64 = (await readFile(zip)).toString('base64');
|
|
665
|
+
}
|
|
666
|
+
catch (e) {
|
|
667
|
+
throw new Error(`读取技能包失败:${deps.message(e)}`);
|
|
668
|
+
}
|
|
669
|
+
const res = await deps.invokeOp('skill-upload', {
|
|
670
|
+
name: basename(zip).replace(/\.zip$/i, ''),
|
|
671
|
+
zip: b64,
|
|
672
|
+
...(overwrite ? { conflict: 'overwrite' } : {}),
|
|
673
|
+
});
|
|
581
674
|
if (!res || res.ok === false)
|
|
582
|
-
throw new Error(String((res && res.error) || 'skill-
|
|
675
|
+
throw new Error(String((res && res.error) || 'skill-upload 失败'));
|
|
583
676
|
const data = res.data || {};
|
|
584
677
|
imported += (data.imported || []).length;
|
|
585
678
|
skipped += (data.skipped || []).length + (data.failed || []).length;
|
package/lib/ops/trash.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
//
|
|
3
3
|
// 删除进回收站、从回收站恢复、回收站列出与彻底删除。与记忆正文 ops 分域,
|
|
4
4
|
// 但共用 copyIntoMemoriesTrash 等入口,经 MemoriesOpsCtx 显式传入。
|
|
5
|
-
import { copyFile, lstat, mkdir, readFile, readdir, rm, stat } from 'node:fs/promises';
|
|
5
|
+
import { copyFile, cp, lstat, mkdir, readFile, readdir, rm, stat } from 'node:fs/promises';
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
import { isValidTrashId } from '../hub.js';
|
|
8
8
|
import { LEGACY_BUNDLE_DOC, bundleDocName, isValidGroupSegment, isValidGroupPath, fail, MEMORIES_TRASH_DIR } from '../memories/constants.js';
|
|
@@ -45,7 +45,14 @@ export function buildTrashOps(rc) {
|
|
|
45
45
|
catch {
|
|
46
46
|
return fail('error.rules.notFound', `回收站条目不存在:${trashId}`);
|
|
47
47
|
}
|
|
48
|
-
if (!manifest || typeof manifest.name !== 'string' || typeof manifest.group !== 'string'
|
|
48
|
+
if (!manifest || typeof manifest.name !== 'string' || typeof manifest.group !== 'string'
|
|
49
|
+
|| !isValidGroupSegment(manifest.name)
|
|
50
|
+
// `group` 是**磁盘数据**(manifest.json 可能被手改 / 别的进程写 / 随一份恶意档案包进来),
|
|
51
|
+
// 而它是拼进 `join(memoriesRoot, group)` 的那一段 —— 只判类型的话 `../..` 就能把正文写到
|
|
52
|
+
// 记忆根之外(`mkdir({recursive:true})` 还会顺手把目录建出来)。同文件 `rulesTrashList`
|
|
53
|
+
// 一直是有这道判据的(`if (group !== '' && !isValidGroupPath(group)) continue`),
|
|
54
|
+
// 两条路径口径必须一致(审查 P0-3)。
|
|
55
|
+
|| (manifest.group !== '' && !isValidGroupPath(manifest.group))) {
|
|
49
56
|
return fail('error.rules.notFound', `回收站条目损坏:${trashId}`);
|
|
50
57
|
}
|
|
51
58
|
// 恢复目标已存在(期间用户重建了同名规则)→ 拒绝,避免覆盖。
|
|
@@ -54,14 +61,43 @@ export function buildTrashOps(rc) {
|
|
|
54
61
|
return fail('error.rules.exists', `同名记忆已存在:${conflict.id},请先移除后再恢复`);
|
|
55
62
|
}
|
|
56
63
|
const form = manifest.form === 'bundle' ? 'bundle' : 'flat';
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
64
|
+
// 落点根内断言(realpath 口径):`isValidGroupPath` 只挡 `..` 与分隔符,挡不住
|
|
65
|
+
// 「中间目录是指向根外的链接」。与 `rulesRemove` 的删除前断言同一道闸。
|
|
66
|
+
const groupDir = join(memoriesRoot, manifest.group);
|
|
67
|
+
const denied = await refuseOutsideRoot(groupDir);
|
|
68
|
+
if (denied)
|
|
69
|
+
return denied;
|
|
70
|
+
// 恢复 = 先复制、后删回收站条目。**复制中途失败必须回滚**(2026-09-30 审查 F11):
|
|
71
|
+
// 原来这里 `cp` / `copyFile` 都没有 try/catch,失败时目标留下半份内容、回收站条目仍在,
|
|
72
|
+
// 于是下一次点恢复会撞上面那条「同名记忆已存在」—— 这条记忆**永远恢复不了**,只能人工
|
|
73
|
+
// 去删目录。场景回收站(`sceneTrashRestore`)早有这条回滚(`moveIntoTrash` 反向搬回),
|
|
74
|
+
// 记忆回收站这条缺了。这里方向相反:源还在回收站,所以回滚是**删掉没放完整的副本**。
|
|
75
|
+
const target = form === 'bundle'
|
|
76
|
+
? join(groupDir, manifest.name)
|
|
77
|
+
: join(groupDir, manifest.name + '.md');
|
|
78
|
+
try {
|
|
79
|
+
await mkdir(groupDir, { recursive: true });
|
|
80
|
+
if (form === 'bundle')
|
|
81
|
+
await cp(join(trashDir, 'bundle'), target, { recursive: true });
|
|
82
|
+
else
|
|
83
|
+
await copyFile(join(trashDir, 'rule.md'), target);
|
|
61
84
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
85
|
+
catch (e) {
|
|
86
|
+
let cleaned = true;
|
|
87
|
+
try {
|
|
88
|
+
await rm(target, { recursive: true, force: true });
|
|
89
|
+
}
|
|
90
|
+
catch (cleanupError) {
|
|
91
|
+
// 落点**本来就没建成**时(父路径不是目录 → ENOTDIR;`force` 已吞掉 ENOENT),
|
|
92
|
+
// 没有残留要清,别把它报成「清理失败」——那会让用户去手工删一个不存在的路径。
|
|
93
|
+
const code = cleanupError?.code;
|
|
94
|
+
cleaned = code === 'ENOENT' || code === 'ENOTDIR';
|
|
95
|
+
}
|
|
96
|
+
// 清理失败也如实说清楚,别让用户以为还能原样重试。
|
|
97
|
+
const tail = cleaned
|
|
98
|
+
? '(未放回完整的落点已清理,回收站条目仍在,可直接重试)'
|
|
99
|
+
: `(未放回完整的落点没能清理:${target},请先手工删除再重试)`;
|
|
100
|
+
return fail('error.rules.ioFailed', `恢复记忆失败:${String(e?.message ?? e)}${tail}`);
|
|
65
101
|
}
|
|
66
102
|
await rm(trashDir, { recursive: true, force: true });
|
|
67
103
|
invalidateSnapshot();
|
package/lib/prompts/service.js
CHANGED
|
@@ -17,6 +17,11 @@ import { join } from 'node:path';
|
|
|
17
17
|
import { createHash } from 'node:crypto';
|
|
18
18
|
import { isValidPresetId, LAST_APPLIED_PRESET_ID, normalizePresetId } from './preset-id.js';
|
|
19
19
|
import { listTrashEntries, moveOutOfTrash, moveToTrash, purgeTrashEntry, readTrashEntry } from '../hub.js';
|
|
20
|
+
// 全局 AGENTS.md 的落盘走**原子写**(temp + rename,见 `memories/index-io.ts`)。
|
|
21
|
+
// 为什么非它不可(2026-09-30 审查 P2-2):这个文件是宿主**每个 agent/pre-step 都会 stat 并
|
|
22
|
+
// 重读**的,裸 `writeFile` 在中断/断电时会留下**半截内容** —— 而半截的提示词会原样进模型
|
|
23
|
+
// 上下文(截断点还可能在代码块中间)。原子写保证读到的是「要么旧、要么新」的完整内容。
|
|
24
|
+
import { writeFileAtomically } from '../memories/index-io.js';
|
|
20
25
|
const FILENAME = 'AGENTS.md';
|
|
21
26
|
/** 描述侧车:与 AGENTS.md 同目录,避免描述的文字被当成提示词正文注入。 */
|
|
22
27
|
const META_FILE = 'meta.json';
|
|
@@ -321,7 +326,7 @@ export function createPromptsService(_ctx, deps) {
|
|
|
321
326
|
}
|
|
322
327
|
const prev = await readGlobal();
|
|
323
328
|
const backedUp = await backupGlobal(prev);
|
|
324
|
-
await
|
|
329
|
+
await writeFileAtomically(await deps.getGlobalAgentsMdPath(), content);
|
|
325
330
|
await writeAppliedId(safeId);
|
|
326
331
|
return { ok: true, id: safeId, backedUp };
|
|
327
332
|
}
|
|
@@ -335,7 +340,7 @@ export function createPromptsService(_ctx, deps) {
|
|
|
335
340
|
const prev = await readGlobal();
|
|
336
341
|
const backedUp = await backupGlobal(prev);
|
|
337
342
|
try {
|
|
338
|
-
await
|
|
343
|
+
await writeFileAtomically(await deps.getGlobalAgentsMdPath(), text);
|
|
339
344
|
}
|
|
340
345
|
catch (e) {
|
|
341
346
|
return { ok: false, error: '写入全局 AGENTS.md 失败:' + message(e) };
|
package/lib/request-gate.js
CHANGED
|
@@ -4,18 +4,19 @@
|
|
|
4
4
|
// * 访问令牌(可选)—— 两条用途:宿主浏览器鉴权的逃生口,以及写操作的纵深防御;
|
|
5
5
|
// * 写 / 敏感 op 白名单 —— 哪些 op 要凭令牌(含"只读但会泄露明文"与"只读但会改宿主状态");
|
|
6
6
|
// 清单本身在 `./op-registry.ts`,本文件只拼装(`opsWith` / `frozenOps`)。
|
|
7
|
-
// * 场景锁定 —— 任一场景 locked=true
|
|
7
|
+
// * 场景锁定 —— 任一场景 locked=true 时五个管理域整体冻结。判据是 `createFrozenGate`,
|
|
8
|
+
// HTTP handlers 与**模型侧 op 表**各装一次,用的是同一份函数;
|
|
8
9
|
// * handlers 后处理 —— 把上面三条装到 op 表上(包装顺序即语义,见 installHandlerGuards)。
|
|
9
10
|
//
|
|
10
11
|
// 为什么抽出来:四段分散在 apply() 的 220 / 902 / 1650 / 1928,靠闭包共享 handlers 与
|
|
11
12
|
// memoriesService。抽成工厂后依赖改为显式入参,index.ts 只剩四行装配 —— 而引用点用解构
|
|
12
13
|
// 保持原名字,一行没动(改名才是这类搬迁最容易出静默错误的地方)。
|
|
13
14
|
//
|
|
14
|
-
// **一处必须守住的不变量**:handlers
|
|
15
|
+
// **一处必须守住的不变量**:handlers 的后处理顺序。冻结包装 / `syncSceneArchiveOnSwitch`
|
|
15
16
|
// 都是「读原函数 → 换包装」的原地改写,先装的在内层、后装的在外层;`annotateLocked` 只挂在
|
|
16
17
|
// 五个读 op 上。整段照搬、顺序未变。
|
|
17
18
|
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
18
|
-
import { frozenOps, opsWith } from './op-registry.js';
|
|
19
|
+
import { OP_REGISTRY, frozenOps, opsWith } from './op-registry.js';
|
|
19
20
|
/**
|
|
20
21
|
* 建访问令牌三件套。**必须**从 apply 的第二个参数取配置 ——
|
|
21
22
|
* 读 `ctx.config` 不是注入服务,启动时会抛 "cannot get property without inject"。
|
|
@@ -114,10 +115,13 @@ export function createOpWhitelist(deps) {
|
|
|
114
115
|
}
|
|
115
116
|
// ---------- 场景锁定守卫(v0.8)----------
|
|
116
117
|
// 任一场景 locked=true = 五个管理域(MCP/技能/子智能体/记忆/提示词)整体冻结:
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
118
|
+
// 冻结清单里的写 op 一律拒绝;界面按钮同步禁用,这里是兜底(防止绕过界面直接打 op)。
|
|
119
|
+
//
|
|
120
|
+
// 装在**哪一层**:HTTP handlers 一层 + 模型侧 op 表一层(index.ts 的 `auditOps`),
|
|
121
|
+
// 两层用同一个判据(`createFrozenGate`)。**不装在 service.ops 上**是有意的:进/退模式的
|
|
122
|
+
// 运行时应用走的是内部函数与 service.ops(applyMcpServerSwitches / applySkills /
|
|
123
|
+
// applySubagentSwitches / patchIndex),不经过 handlers —— 锁定就是为了让场景能按原样启动,
|
|
124
|
+
// 运行时应用不能被自己挡住。
|
|
121
125
|
// 例外里的例外:scene-archive-save / rules-remove-scene 只对**被锁的那个场景**拒绝。
|
|
122
126
|
export function createSceneLock(deps) {
|
|
123
127
|
async function lockedSceneNames() {
|
|
@@ -144,12 +148,104 @@ export function createSceneLock(deps) {
|
|
|
144
148
|
}
|
|
145
149
|
return { lockedSceneNames, activeSceneName, lockedSceneGuard };
|
|
146
150
|
}
|
|
151
|
+
/** 「改它」的兜底动词(登记表里没有专门文案时用)。 */
|
|
152
|
+
const FROZEN_DEFAULT_VERB = '修改';
|
|
153
|
+
const ACTIVE_SCENE_LABEL = { 'rules-set-active': '切换场景' };
|
|
154
|
+
const SELF_SCENE_LABEL = { 'scene-archive-save': '改档案', 'rules-remove-scene': '删除' };
|
|
155
|
+
/**
|
|
156
|
+
* 场景冻结的**唯一判据**:`op` + `args` → 拒绝文案(null = 放行)。
|
|
157
|
+
*
|
|
158
|
+
* 三种口径的语义登记在 `./op-registry.ts` 的 `frozenScope` 上,这里只实现:
|
|
159
|
+
* all —— 只要有场景锁着就拒(不看 args)。
|
|
160
|
+
* active-scene —— 只在"锁着的场景正在生效"且这次提交**改了启用集合**时拒
|
|
161
|
+
* (同集合的重复提交放行:它没改变任何东西,不该被"冻结"挡下来)。
|
|
162
|
+
* self-scene —— 只挡被锁的那个场景自己(目标从 `args.scene` / `args.name` 取)。
|
|
163
|
+
*
|
|
164
|
+
* 为什么抽成一个函数:HTTP handlers 与**模型侧 op 表**(index.ts 的 `auditOps`)必须用
|
|
165
|
+
* 同一份判据。此前只包 handlers —— 模型工具拿的是 `service.ops`,于是"锁一个**非活动**
|
|
166
|
+
* 场景"在 HTTP 上被拒、在模型侧五个域全放行(审查 F3,0.17.0 修)。判据只写一遍,
|
|
167
|
+
* 两侧就不可能再分家。
|
|
168
|
+
*
|
|
169
|
+
* 判据只读 `args`、不落盘,所以对同一次调用可以安全地判两次(handlers 与模型侧各一次)。
|
|
170
|
+
*/
|
|
171
|
+
export function createFrozenGate(deps) {
|
|
172
|
+
const { lockedSceneNames, activeSceneName } = deps;
|
|
173
|
+
return async function frozenRejection(op, args) {
|
|
174
|
+
const cls = OP_REGISTRY[op];
|
|
175
|
+
if (!cls || cls.frozen !== true)
|
|
176
|
+
return null;
|
|
177
|
+
const scope = cls.frozenScope ?? 'all';
|
|
178
|
+
if (scope === 'active-scene') {
|
|
179
|
+
const scene = await activeSceneName();
|
|
180
|
+
if (!scene || !(await lockedSceneNames()).includes(scene))
|
|
181
|
+
return null;
|
|
182
|
+
const current = new Set([scene]);
|
|
183
|
+
const next = new Set((Array.isArray(args && args.scenes) ? args.scenes : [])
|
|
184
|
+
.map((n) => String(n == null ? '' : n).trim())
|
|
185
|
+
.filter((n) => n !== ''));
|
|
186
|
+
const same = current.size === next.size && [...current].every((n) => next.has(n));
|
|
187
|
+
if (same)
|
|
188
|
+
return null;
|
|
189
|
+
const what = ACTIVE_SCENE_LABEL[op] ?? '改它';
|
|
190
|
+
return `场景「${scene}」已锁定:先到场景页解锁再${what}(锁定期间改启用集合会让模型侧的写门禁失效)`;
|
|
191
|
+
}
|
|
192
|
+
if (scope === 'self-scene') {
|
|
193
|
+
const scene = String((args && (args.scene || args.name)) || '').trim();
|
|
194
|
+
if (!scene)
|
|
195
|
+
return null;
|
|
196
|
+
if (!(await lockedSceneNames()).includes(scene))
|
|
197
|
+
return null;
|
|
198
|
+
return `场景「${scene}」已锁定:先解锁再${SELF_SCENE_LABEL[op] ?? '改它'}`;
|
|
199
|
+
}
|
|
200
|
+
const locked = await lockedSceneNames();
|
|
201
|
+
return locked.length ? `场景已锁定(${locked.join('、')}):先到场景页解锁再${FROZEN_DEFAULT_VERB}` : null;
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
// ── 3.6 模型侧 op 表的冻结闸(与 handlers 同一份判据)────────────────────────
|
|
205
|
+
/**
|
|
206
|
+
* 给**模型侧**的 op 表装冻结闸(审查 F3,0.17.0)。
|
|
207
|
+
*
|
|
208
|
+
* 为什么抽成一个函数、而不是在 index.ts 里手写 for 循环:模型侧与 HTTP 侧必须用**同一个**
|
|
209
|
+
* `frozen` 判据对象。写成两个循环、各自去 `createFrozenGate`,就给了"两边又分家"的机会 ——
|
|
210
|
+
* 而 F3 的成因正是两侧判据不同。抽出来之后,契约测试可以直接拿真实的 `createFrozenGate`
|
|
211
|
+
* 与真实的 `installHandlerGuards` 对同一个 op 比对两侧结论。
|
|
212
|
+
*
|
|
213
|
+
* **不改动传入的表**:返回新表。场景引擎直调的是原表(`applySkills` → `service.ops`),
|
|
214
|
+
* 装在原表上会把场景自己锁死。
|
|
215
|
+
*
|
|
216
|
+
* @param ops 原始 op 表
|
|
217
|
+
* @param frozen `createFrozenGate(...)` 的返回值
|
|
218
|
+
* @param after 成功后的回调(挂流水用)。被拒时**不调用** —— 什么都没改。
|
|
219
|
+
*/
|
|
220
|
+
export function guardModelOps(ops, frozen, after) {
|
|
221
|
+
const out = {};
|
|
222
|
+
for (const [opName, fn] of Object.entries(ops)) {
|
|
223
|
+
if (typeof fn !== 'function') {
|
|
224
|
+
out[opName] = fn;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
out[opName] = async (args) => {
|
|
228
|
+
const denied = await frozen(opName, args || {});
|
|
229
|
+
if (denied)
|
|
230
|
+
return { ok: false, error: denied };
|
|
231
|
+
const result = await fn(args || {});
|
|
232
|
+
if (after)
|
|
233
|
+
after(opName, args || {}, result);
|
|
234
|
+
return result;
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
return out;
|
|
238
|
+
}
|
|
239
|
+
/** 单个 op 版本(`guardModelOps` 的一元特例):给不经 op 表的调用点用。 */
|
|
240
|
+
export function guardModelOp(opName, fn, frozen, after) {
|
|
241
|
+
return guardModelOps({ [opName]: fn }, frozen, after)[opName];
|
|
242
|
+
}
|
|
147
243
|
export function installHandlerGuards(deps) {
|
|
148
244
|
const { handlers, lockedSceneNames, activeSceneName, syncSwitchToScene, subagentToolFailures } = deps;
|
|
149
245
|
/**
|
|
150
246
|
* 包一层:开关成功后把改动同步进当前场景档案(见 syncSwitchToScene)。
|
|
151
247
|
* 失败只挂 `sceneSyncError`,不改写原操作的成功结论 —— 运行时确实改了,档案没跟上要说得清。
|
|
152
|
-
*
|
|
248
|
+
* 锁定由更靠内的冻结包装挡住(先装的在内层,它返回 `ok:false` 时这里不会去同步档案)。
|
|
153
249
|
*/
|
|
154
250
|
function syncSceneArchiveOnSwitch(opNames) {
|
|
155
251
|
for (const opName of opNames) {
|
|
@@ -165,64 +261,27 @@ export function installHandlerGuards(deps) {
|
|
|
165
261
|
};
|
|
166
262
|
}
|
|
167
263
|
}
|
|
168
|
-
|
|
169
|
-
|
|
264
|
+
// 冻结清单与档案同步清单都来自 `./op-registry.ts`:加 op 时在那里登记一条,这里不再抄。
|
|
265
|
+
// 三种口径(all / active-scene / self-scene)现在合成**一个**判据函数(`createFrozenGate`),
|
|
266
|
+
// 由 `frozenOps()`(不带 scope = 全部冻结 op)一次装完。原先它们分散成三段手写包装,
|
|
267
|
+
// 而模型侧 op 表压根没装 —— 这正是 F3 的成因(见 `createFrozenGate` 的注释)。
|
|
268
|
+
// 包装顺序不变:冻结仍在**内层**,`syncSceneArchiveOnSwitch` 在外层(前者返回 ok:false 时
|
|
269
|
+
// 后者不会去同步档案)。
|
|
270
|
+
{
|
|
271
|
+
const frozen = createFrozenGate({ lockedSceneNames, activeSceneName });
|
|
272
|
+
for (const opName of frozenOps()) {
|
|
170
273
|
const original = handlers[opName];
|
|
171
274
|
if (typeof original !== 'function')
|
|
172
275
|
continue;
|
|
173
276
|
handlers[opName] = async (args) => {
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
return { ok: false, error: `场景已锁定(${locked.join('、')}):先到场景页解锁再${what}` };
|
|
177
|
-
return original(args);
|
|
277
|
+
const denied = await frozen(opName, args);
|
|
278
|
+
return denied ? { ok: false, error: denied } : original(args);
|
|
178
279
|
};
|
|
179
280
|
}
|
|
180
281
|
}
|
|
181
|
-
// 冻结清单与档案同步清单都来自 `./op-registry.ts`:加 op 时在那里登记一条,这里不再抄。
|
|
182
|
-
guardLockedOps(frozenOps('all'), '修改');
|
|
183
282
|
// 场景内「开关」类操作(用户裁定 2026-09-17):未锁定时**可用**,改动同步进当前场景档案。
|
|
184
283
|
// 与「锁定」正交:锁定冻结全部写操作,这里只是把页面开关的意图也写进档案。
|
|
185
284
|
syncSceneArchiveOnSwitch(opsWith('syncsArchive'));
|
|
186
|
-
// 口径 `frozenScope: 'active-scene'` 的那几条(判据与理由登记在 op-registry.ts 的
|
|
187
|
-
// `rules-set-active` 条目上)。没有锁定场景在生效时(全局态 / 场景未锁)照旧可用。
|
|
188
|
-
const ACTIVE_SCENE_LABEL = { 'rules-set-active': '切换场景' };
|
|
189
|
-
for (const opName of frozenOps('active-scene')) {
|
|
190
|
-
const what = ACTIVE_SCENE_LABEL[opName] ?? '改它';
|
|
191
|
-
const original = handlers[opName];
|
|
192
|
-
if (typeof original !== 'function')
|
|
193
|
-
continue;
|
|
194
|
-
handlers[opName] = async (args) => {
|
|
195
|
-
const scene = await activeSceneName();
|
|
196
|
-
if (scene && (await lockedSceneNames()).includes(scene)) {
|
|
197
|
-
// 同集合的重复提交放行:它没有改变任何东西,不该被"冻结"挡下来。
|
|
198
|
-
const current = new Set([scene]);
|
|
199
|
-
const next = new Set((Array.isArray(args && args.scenes) ? args.scenes : [])
|
|
200
|
-
.map((n) => String(n == null ? '' : n).trim())
|
|
201
|
-
.filter((n) => n !== ''));
|
|
202
|
-
const same = current.size === next.size && [...current].every((n) => next.has(n));
|
|
203
|
-
if (!same) {
|
|
204
|
-
return { ok: false, error: `场景「${scene}」已锁定:先到场景页解锁再${what}(锁定期间改启用集合会让模型侧的写门禁失效)` };
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
return original(args);
|
|
208
|
-
};
|
|
209
|
-
}
|
|
210
|
-
// 被锁场景自身的档案与删除:只挡它自己,别的场景照常(口径 `frozenScope: 'self-scene'`)。
|
|
211
|
-
// 目标场景一律从 `args.scene` / `args.name` 里取 —— 这两个键名就是本插件 op 的惯例。
|
|
212
|
-
const SELF_SCENE_LABEL = { 'scene-archive-save': '改档案', 'rules-remove-scene': '删除' };
|
|
213
|
-
for (const opName of frozenOps('self-scene')) {
|
|
214
|
-
const what = SELF_SCENE_LABEL[opName] ?? '改它';
|
|
215
|
-
const original = handlers[opName];
|
|
216
|
-
if (typeof original !== 'function')
|
|
217
|
-
continue;
|
|
218
|
-
handlers[opName] = async (args) => {
|
|
219
|
-
const scene = String((args && (args.scene || args.name)) || '').trim();
|
|
220
|
-
const locked = await lockedSceneNames();
|
|
221
|
-
if (scene && locked.includes(scene))
|
|
222
|
-
return { ok: false, error: `场景「${scene}」已锁定:先解锁再${what}` };
|
|
223
|
-
return original(args);
|
|
224
|
-
};
|
|
225
|
-
}
|
|
226
285
|
// 各页列表响应带上 anyLocked + activeScene:界面据此禁用写控件、并说明
|
|
227
286
|
// 「当前处于场景 X,开关请到档案里改」(读 op,附加字段不影响既有消费方)。
|
|
228
287
|
async function annotateLocked(res) {
|
package/lib/scene-prompt-sync.js
CHANGED
|
@@ -24,8 +24,11 @@
|
|
|
24
24
|
// `driver()` —— 谁在驱动基线(提示词页「应用」据此判断:应用别的份 = 换掉该场景的绑定);
|
|
25
25
|
// `refs()` —— 每个预设被谁引用(删除保护的唯一依据:场景绑定 / 基线当前内容 / 退出恢复目标);
|
|
26
26
|
// `baseline()` —— 进场景前的基线快照(退出场景后要恢复的那一份)。
|
|
27
|
-
import { mkdir, readFile,
|
|
27
|
+
import { mkdir, readFile, stat } from 'node:fs/promises';
|
|
28
28
|
import { dirname } from 'node:path';
|
|
29
|
+
// 基线快照走原子写(temp + rename):它是「退出场景时恢复 AGENTS.md」的**唯一依据**,
|
|
30
|
+
// 裸写中断留下半截 JSON 会让 `readBaseline` 解析失败(审查 P2-16)。
|
|
31
|
+
import { writeFileAtomically } from './memories/index-io.js';
|
|
29
32
|
export function createScenePromptSync(deps) {
|
|
30
33
|
const warn = (message) => { try {
|
|
31
34
|
deps.logger?.warn?.(message);
|
|
@@ -48,13 +51,35 @@ export function createScenePromptSync(deps) {
|
|
|
48
51
|
return null;
|
|
49
52
|
}
|
|
50
53
|
}
|
|
54
|
+
/** 快照文件是否**存在**(不看内容)。用来把「没有快照」与「快照读不出来」分开。 */
|
|
55
|
+
async function baselineExists() {
|
|
56
|
+
try {
|
|
57
|
+
return (await stat(deps.baselineFile)).isFile();
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* 写基线快照。**失败必须让调用方看得见**(2026-09-30 审查 F14)。
|
|
65
|
+
*
|
|
66
|
+
* 原来这里 catch 后只 `warn` 并返回 void,于是「快照没写成」与「写成了」在调用方眼里
|
|
67
|
+
* 完全一样 —— 结果就是:AGENTS.md 被切到场景态、而退出场景时恢复不回来,用户看到的是
|
|
68
|
+
* 「没场景驱动所以什么都没做」。这正是「看起来成功」那类失败。
|
|
69
|
+
* @returns 失败原因(原样回给调用方拼进提示);成功返回 null。
|
|
70
|
+
*/
|
|
51
71
|
async function writeBaseline(next) {
|
|
52
72
|
try {
|
|
53
73
|
await mkdir(dirname(deps.baselineFile), { recursive: true });
|
|
54
|
-
|
|
74
|
+
// 原子写(2026-09-30 审查 P2-16):裸写在中断/断电时会留下半截 JSON → `readBaseline`
|
|
75
|
+
// 解析失败返回 null → 退出场景**不恢复** AGENTS.md,用户的提示词基线悄悄留在场景态。
|
|
76
|
+
await writeFileAtomically(deps.baselineFile, JSON.stringify(next));
|
|
77
|
+
return null;
|
|
55
78
|
}
|
|
56
79
|
catch (e) {
|
|
57
|
-
|
|
80
|
+
const reason = String(e);
|
|
81
|
+
warn(`scene-prompt: could not persist baseline: ${reason}`);
|
|
82
|
+
return reason;
|
|
58
83
|
}
|
|
59
84
|
}
|
|
60
85
|
async function state() {
|
|
@@ -157,25 +182,39 @@ export function createScenePromptSync(deps) {
|
|
|
157
182
|
return { applied: current.presetId, unchanged: true };
|
|
158
183
|
// 进场景的第一笔写入之前,先记下当时的基线(已有快照就不覆盖:连续切场景时
|
|
159
184
|
// 基线始终是"进场景之前"那一份)。
|
|
185
|
+
let baselineError;
|
|
160
186
|
const saved = await readBaseline();
|
|
161
187
|
if (!saved || saved.content === null) {
|
|
162
188
|
const cur = await deps.prompts.getCurrent();
|
|
163
|
-
await writeBaseline({
|
|
189
|
+
const failed = await writeBaseline({
|
|
164
190
|
v: 1,
|
|
165
191
|
at: Date.now(),
|
|
166
192
|
presetId: cur && cur.ok && cur.presetId ? String(cur.presetId) : null,
|
|
167
193
|
content: cur && cur.ok && cur.exists ? String(cur.content) : null,
|
|
168
194
|
});
|
|
195
|
+
// 基线写不进去 = 退出场景时恢复不回来。**照旧进场景**(那是用户的要求),
|
|
196
|
+
// 但必须当场说出来,别让用户以为还能退回去(审查 F14)。
|
|
197
|
+
if (failed !== null) {
|
|
198
|
+
baselineError = `进场景前的提示词基线没能写入(${failed}):AGENTS.md 已切到场景「${current.scene}」绑定的预设,但退出场景时恢复不回原来的内容(${deps.baselineFile})。`;
|
|
199
|
+
}
|
|
169
200
|
}
|
|
170
201
|
const res = await deps.prompts.apply(current.presetId);
|
|
171
202
|
if (res && res.ok === false)
|
|
172
|
-
return { error: String(res.error || '写入 AGENTS.md 失败') };
|
|
173
|
-
return { applied: current.presetId };
|
|
203
|
+
return { error: String(res.error || '写入 AGENTS.md 失败'), ...(baselineError ? { baselineError } : {}) };
|
|
204
|
+
return { applied: current.presetId, ...(baselineError ? { baselineError } : {}) };
|
|
174
205
|
}
|
|
175
206
|
// 没有场景驱动 → 如果刚从驱动态退出,把进场景前的基线写回去。
|
|
176
207
|
const saved = await readBaseline();
|
|
177
|
-
if (!saved || saved.content === null)
|
|
208
|
+
if (!saved || saved.content === null) {
|
|
209
|
+
// 「没有快照」是常态(没进过场景);「快照文件在、但读不出来」是**故障** ——
|
|
210
|
+
// 那意味着恢复目标丢了,AGENTS.md 的提示词基线会悄悄留在场景态。这里曾经一律
|
|
211
|
+
// 静默 `unchanged`,用户看到的是"没场景驱动所以什么都没做"(审查 P2-16)。
|
|
212
|
+
if (await baselineExists()) {
|
|
213
|
+
warn(`scene-prompt: baseline snapshot exists but is unreadable: ${deps.baselineFile}`);
|
|
214
|
+
return { error: `基线快照读不出来(文件可能损坏),未能恢复 AGENTS.md:${deps.baselineFile}` };
|
|
215
|
+
}
|
|
178
216
|
return { unchanged: true };
|
|
217
|
+
}
|
|
179
218
|
const res = await deps.prompts.restore(saved.content);
|
|
180
219
|
if (res && res.ok === false)
|
|
181
220
|
return { error: String(res.error || '恢复 AGENTS.md 失败') };
|