@stardustlc/dsh-dream 0.5.1 → 0.6.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/lib/bridge.js CHANGED
@@ -1,24 +1,1071 @@
1
1
  /**
2
- * 记忆桥接:把梦境里的高频教训合并进 AGENTS.md(幂等,带标记块)。
2
+ * 记忆桥接(M2):preview / apply / rollback 三模式 + 应用记录与并发守卫。
3
+ *
4
+ * 兼容:文件底部的 legacy 直写 API(bridgeDreams / BRIDGE_START / BRIDGE_END)
5
+ * 保持 0.5.x 行为不变,供旧调用与既有测试使用;新流程一律走 preview → apply → rollback。
6
+ *
7
+ * 硬性策略(FREEZE-M2 §M2-1):
8
+ * - preview 零写入;apply 必须带 expectedSha256,当前文件不符 → conflict 零写入;
9
+ * - 只替换 <!-- dsh-dream:start --> … <!-- dsh-dream:end --> 管理块,块外文字 / BOM /
10
+ * 换行风格原样保留;同目录 temp + rename;应用记录写 <journalDir>/bridge/records.jsonl;
11
+ * - 两进程并行 apply 用 <journalDir>/bridge/.lock + expectedSha256 CAS 保证只有一个成功;
12
+ * - junction / 符号链接统一“跟随链接改真实文件、链接本身不变”(见 README);
13
+ * - 标记缺损/重复/嵌套/错位拒绝并给行号定位;UTF-16 / 二进制明确拒绝;
14
+ * - 经验资格:usable 且 scope 匹配当前项目,或显式 global 且已被用户采纳;频次不作准入。
3
15
  *
4
16
  * @module dsh-dream/bridge
5
17
  */
6
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
7
- import { dirname } from 'node:path';
18
+ import { accessSync, chmodSync, closeSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync, } from 'node:fs';
19
+ import { createHash, randomUUID } from 'node:crypto';
20
+ import { basename, dirname, isAbsolute, join, normalize, relative, resolve } from 'node:path';
8
21
  import { dreamStats } from './journal.js';
22
+ import { maskSecrets } from './mask.js';
23
+ // ---------- 新管理块标记(新流程) ----------
24
+ /** 新管理块起始标记(整行)。 */
25
+ export const DREAM_BLOCK_START = '<!-- dsh-dream:start -->';
26
+ /** 新管理块结束标记(整行)。 */
27
+ export const DREAM_BLOCK_END = '<!-- dsh-dream:end -->';
28
+ /** 默认最多桥接条数。 */
29
+ export const BRIDGE_DEFAULT_MAX_LESSONS = 5;
30
+ /** 硬上限。 */
31
+ export const BRIDGE_HARD_MAX_LESSONS = 20;
32
+ const BRIDGE_LOCK_STALE_MS = 30000;
33
+ const BRIDGE_LOCK_TIMEOUT_MS = 2000;
34
+ const BRIDGE_BOOT_ID = randomUUID();
35
+ // ---------- 小工具 ----------
36
+ function sha256(buf) {
37
+ return createHash('sha256').update(buf).digest('hex');
38
+ }
39
+ function fail(code, message, extra = {}) {
40
+ const error = { code, message };
41
+ if (extra.line !== undefined)
42
+ error.line = extra.line;
43
+ if (extra.hint !== undefined)
44
+ error.hint = extra.hint;
45
+ const failure = { ok: false, error };
46
+ if (extra.action !== undefined)
47
+ failure.action = extra.action;
48
+ if (extra.expected !== undefined)
49
+ failure.expected = extra.expected;
50
+ if (extra.resolvedPath !== undefined)
51
+ failure.resolvedPath = extra.resolvedPath;
52
+ return failure;
53
+ }
54
+ function expectedOf(state) {
55
+ return { exists: state.exists, sha256: state.exists ? state.sha256 : null, size: state.size };
56
+ }
57
+ function missingExpected() {
58
+ return { exists: false, sha256: null, size: 0 };
59
+ }
60
+ function withPreviewShape(failure, resolvedPath, expected, action) {
61
+ if (failure.action === undefined)
62
+ failure.action = action === undefined ? 'create' : action;
63
+ if (failure.expected === undefined)
64
+ failure.expected = expected === undefined ? missingExpected() : expected;
65
+ if (failure.resolvedPath === undefined && resolvedPath !== undefined)
66
+ failure.resolvedPath = resolvedPath;
67
+ return failure;
68
+ }
69
+ function stateMatchesExpected(state, expected) {
70
+ if (expected === null)
71
+ return state.exists === false;
72
+ return state.exists && state.sha256 === expected;
73
+ }
74
+ function messageOf(error) {
75
+ return error instanceof Error ? error.message : String(error);
76
+ }
77
+ function ioCodeOf(error) {
78
+ return error.code === undefined ? '' : String(error.code);
79
+ }
80
+ function sleepSync(ms) {
81
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
82
+ }
83
+ function isProcessAlive(pid) {
84
+ try {
85
+ process.kill(pid, 0);
86
+ return true;
87
+ }
88
+ catch (error) {
89
+ return error.code === 'EPERM';
90
+ }
91
+ }
92
+ function stableJson(value) {
93
+ if (value === null || typeof value !== 'object') {
94
+ const text = JSON.stringify(value);
95
+ return text === undefined ? 'null' : text;
96
+ }
97
+ if (Array.isArray(value))
98
+ return '[' + value.map((item) => stableJson(item)).join(',') + ']';
99
+ const record = value;
100
+ return '{' + Object.keys(record).sort().map((key) => JSON.stringify(key) + ':' + stableJson(record[key])).join(',') + '}';
101
+ }
102
+ function containsDotDot(path) {
103
+ return path.split(/[\\/]+/).some((part) => part === '..');
104
+ }
105
+ function isInside(root, path) {
106
+ const rel = relative(root, path);
107
+ return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
108
+ }
109
+ /** 路径等价(尽量 realpath;Windows 大小写不敏感)。 */
110
+ function samePath(a, b) {
111
+ const real = (value) => {
112
+ try {
113
+ return realpathSync(value);
114
+ }
115
+ catch {
116
+ return resolve(value);
117
+ }
118
+ };
119
+ const left = real(a);
120
+ const right = real(b);
121
+ return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right;
122
+ }
123
+ function sanitizeBlockText(text, mask) {
124
+ const masked = mask ? maskSecrets(text) : text;
125
+ return masked
126
+ .split(DREAM_BLOCK_START).join('[标记已移除]')
127
+ .split(DREAM_BLOCK_END).join('[标记已移除]')
128
+ .replace(/<!--/g, '(<!--')
129
+ .replace(/\s+/g, ' ')
130
+ .trim();
131
+ }
132
+ function scopeLabelOf(lesson) {
133
+ if (lesson.scope.global === true)
134
+ return '全局';
135
+ if (typeof lesson.scope.projectId === 'string' && lesson.scope.projectId !== '')
136
+ return '项目 ' + lesson.scope.projectId;
137
+ if (typeof lesson.scope.workspaceRoot === 'string' && lesson.scope.workspaceRoot !== '')
138
+ return '工作区 ' + lesson.scope.workspaceRoot;
139
+ return '未限定';
140
+ }
141
+ function toView(lesson, mask) {
142
+ return {
143
+ lessonId: lesson.id,
144
+ revision: lesson.revision,
145
+ title: sanitizeBlockText(lesson.title, mask),
146
+ when: sanitizeBlockText(lesson.when, mask),
147
+ action: sanitizeBlockText(lesson.action, mask),
148
+ exceptions: lesson.exceptions.map((item) => sanitizeBlockText(item, mask)).filter((item) => item !== ''),
149
+ scopeLabel: scopeLabelOf(lesson),
150
+ };
151
+ }
152
+ // ---------- 资格筛选 ----------
153
+ /**
154
+ * 经验资格:state=usable;scope 匹配当前项目;或显式 global 且 review.decision=accepted。
155
+ * 解释为空或不满足一律进 skipped,绝不因频次入选或落选。
156
+ */
157
+ export function selectBridgeLessons(lessons, scope, options = {}) {
158
+ const rawMax = options.maxLessons;
159
+ const maxLessons = typeof rawMax === 'number' && Number.isFinite(rawMax)
160
+ ? Math.min(BRIDGE_HARD_MAX_LESSONS, Math.max(1, Math.floor(rawMax)))
161
+ : BRIDGE_DEFAULT_MAX_LESSONS;
162
+ const skipped = [];
163
+ const eligible = [];
164
+ const reasonFor = (lesson) => {
165
+ if (lesson.state !== 'usable')
166
+ return 'not-usable';
167
+ if (lesson.scope.global === true) {
168
+ return lesson.review.decision === 'accepted' ? undefined : 'not-accepted-global';
169
+ }
170
+ const projectMatch = typeof scope.projectId === 'string' && scope.projectId !== '' && lesson.scope.projectId === scope.projectId;
171
+ const workspaceMatch = typeof scope.workspaceRoot === 'string' && scope.workspaceRoot !== '' &&
172
+ typeof lesson.scope.workspaceRoot === 'string' && samePath(scope.workspaceRoot, lesson.scope.workspaceRoot);
173
+ if (!projectMatch && !workspaceMatch)
174
+ return 'scope-mismatch';
175
+ return undefined;
176
+ };
177
+ if (options.lessonIds !== undefined && options.lessonIds.length > 0) {
178
+ const byId = new Map(lessons.map((lesson) => [lesson.id, lesson]));
179
+ for (const id of options.lessonIds) {
180
+ const lesson = byId.get(id);
181
+ if (lesson === undefined) {
182
+ skipped.push({ lessonId: id, reason: 'unknown-lesson' });
183
+ continue;
184
+ }
185
+ const reason = reasonFor(lesson);
186
+ if (reason !== undefined) {
187
+ skipped.push({ lessonId: id, reason });
188
+ continue;
189
+ }
190
+ if (eligible.length >= maxLessons) {
191
+ skipped.push({ lessonId: id, reason: 'limit' });
192
+ continue;
193
+ }
194
+ eligible.push(lesson);
195
+ }
196
+ return { eligible, skipped };
197
+ }
198
+ const sorted = lessons.slice().sort((a, b) => (a.updatedAt === b.updatedAt ? a.id.localeCompare(b.id) : b.updatedAt.localeCompare(a.updatedAt)));
199
+ for (const lesson of sorted) {
200
+ const reason = reasonFor(lesson);
201
+ if (reason !== undefined) {
202
+ skipped.push({ lessonId: lesson.id, reason });
203
+ continue;
204
+ }
205
+ if (eligible.length >= maxLessons) {
206
+ skipped.push({ lessonId: lesson.id, reason: 'limit' });
207
+ continue;
208
+ }
209
+ eligible.push(lesson);
210
+ }
211
+ return { eligible, skipped };
212
+ }
213
+ function splitLines(text) {
214
+ const out = [];
215
+ let index = 0;
216
+ while (index < text.length) {
217
+ const newline = text.indexOf('\n', index);
218
+ if (newline < 0) {
219
+ out.push({ text: text.slice(index).replace(/\r$/, ''), start: index, end: text.length });
220
+ break;
221
+ }
222
+ let end = newline;
223
+ if (end > index && text[end - 1] === '\r')
224
+ end -= 1;
225
+ out.push({ text: text.slice(index, end), start: index, end });
226
+ index = newline + 1;
227
+ }
228
+ return out;
229
+ }
230
+ function countOccurrences(text, needle) {
231
+ return text.split(needle).length - 1;
232
+ }
233
+ /** 定位管理块;缺损/重复/嵌套/错位给出位置。 */
234
+ export function locateDreamBlock(text) {
235
+ const startCount = countOccurrences(text, DREAM_BLOCK_START);
236
+ const endCount = countOccurrences(text, DREAM_BLOCK_END);
237
+ if (startCount === 0 && endCount === 0)
238
+ return { status: 'none' };
239
+ if (startCount === 0)
240
+ return { status: 'broken', message: '发现结束标记但没有起始标记(' + DREAM_BLOCK_START + ')', line: lineOfFirst(text, DREAM_BLOCK_END) };
241
+ if (endCount === 0)
242
+ return { status: 'broken', message: '发现起始标记但没有结束标记(' + DREAM_BLOCK_END + ')', line: lineOfFirst(text, DREAM_BLOCK_START) };
243
+ const lines = splitLines(text);
244
+ const startLines = [];
245
+ const endLines = [];
246
+ lines.forEach((line, i) => {
247
+ if (line.text.includes(DREAM_BLOCK_START))
248
+ startLines.push(i);
249
+ if (line.text.includes(DREAM_BLOCK_END))
250
+ endLines.push(i);
251
+ });
252
+ const lineNumber = (i) => i + 1;
253
+ if (startLines.length > 1) {
254
+ return { status: 'broken', message: '起始标记重复出现(第 ' + startLines.map(lineNumber).join('、') + ' 行)', line: lineNumber(startLines[0]) };
255
+ }
256
+ if (endLines.length > 1) {
257
+ return { status: 'broken', message: '结束标记重复出现(第 ' + endLines.map(lineNumber).join('、') + ' 行)', line: lineNumber(endLines[0]) };
258
+ }
259
+ if (startCount > 1 || endCount > 1) {
260
+ return { status: 'broken', message: '标记出现次数异常(start=' + startCount + ',end=' + endCount + '),可能是嵌套或书写在同一行', line: lineNumber(startLines[0] ?? endLines[0] ?? 0) };
261
+ }
262
+ if (startLines.length === 0 || endLines.length === 0) {
263
+ return { status: 'broken', message: '管理块标记不完整(start=' + startCount + ',end=' + endCount + ')' };
264
+ }
265
+ const startLine = startLines[0];
266
+ const endLine = endLines[0];
267
+ if (lines[startLine].text.trim() !== DREAM_BLOCK_START) {
268
+ return { status: 'broken', message: '起始标记必须独立成行', line: lineNumber(startLine) };
269
+ }
270
+ if (lines[endLine].text.trim() !== DREAM_BLOCK_END) {
271
+ return { status: 'broken', message: '结束标记必须独立成行', line: lineNumber(endLine) };
272
+ }
273
+ if (endLine < startLine) {
274
+ return { status: 'broken', message: '结束标记出现在起始标记之前(第 ' + lineNumber(endLine) + ' 行 < 第 ' + lineNumber(startLine) + ' 行)', line: lineNumber(endLine) };
275
+ }
276
+ if (endLine === startLine) {
277
+ return { status: 'broken', message: '起始/结束标记写在同一行', line: lineNumber(startLine) };
278
+ }
279
+ const start = lines[startLine].start;
280
+ const end = lines[endLine].end;
281
+ return { status: 'ok', start, end, text: text.slice(start, end), startLine: lineNumber(startLine), endLine: lineNumber(endLine) };
282
+ }
283
+ function lineOfFirst(text, needle) {
284
+ const index = text.indexOf(needle);
285
+ if (index < 0)
286
+ return undefined;
287
+ return text.slice(0, index).split('\n').length;
288
+ }
289
+ /** 生成管理块(内部使用 \n;落盘前按目标换行风格转换)。 */
290
+ export function buildDreamBlock(lessons) {
291
+ const lines = [DREAM_BLOCK_START, '', '## 梦境经验(dsh-dream 自动生成,请勿手工编辑块内内容)', ''];
292
+ for (const lesson of lessons) {
293
+ // 最后一道保险:无论调用方是否已脱敏,管理块内文本都必须过 mask.ts(R10)。
294
+ lines.push('- ' + maskSecrets(lesson.title) + '(' + lesson.scopeLabel + ')');
295
+ lines.push(' - 适用:' + maskSecrets(lesson.when));
296
+ lines.push(' - 动作:' + maskSecrets(lesson.action));
297
+ for (const exception of lesson.exceptions)
298
+ lines.push(' - 例外:' + maskSecrets(exception));
299
+ }
300
+ lines.push('', DREAM_BLOCK_END);
301
+ return lines.join('\n');
302
+ }
303
+ function withEol(block, eol) {
304
+ return block.split('\n').join(eol);
305
+ }
306
+ // ---------- 路径解析(跟随链接) ----------
307
+ function resolveFollowingLinks(path, visited = new Set()) {
308
+ let current = path;
309
+ const rest = [];
310
+ for (;;) {
311
+ if (visited.has(current))
312
+ return join(current, ...rest.slice().reverse());
313
+ visited.add(current);
314
+ if (existsSync(current)) {
315
+ try {
316
+ return join(realpathSync(current), ...rest.slice().reverse());
317
+ }
318
+ catch {
319
+ return join(current, ...rest.slice().reverse());
320
+ }
321
+ }
322
+ let linkTarget;
323
+ try {
324
+ if (lstatSync(current).isSymbolicLink())
325
+ linkTarget = readlinkSync(current);
326
+ }
327
+ catch {
328
+ linkTarget = undefined;
329
+ }
330
+ if (linkTarget !== undefined) {
331
+ const absolute = isAbsolute(linkTarget) ? linkTarget : resolve(dirname(current), linkTarget);
332
+ return resolveFollowingLinks(join(absolute, ...rest.slice().reverse()), visited);
333
+ }
334
+ const parent = dirname(current);
335
+ if (parent === current)
336
+ return join(current, ...rest.slice().reverse());
337
+ rest.push(basename(current));
338
+ current = parent;
339
+ }
340
+ }
341
+ export function resolveBridgeTarget(projectRoot, rawPath) {
342
+ if (typeof projectRoot !== 'string' || projectRoot.trim() === '') {
343
+ return fail('invalid', 'projectRoot 必须是非空路径', { action: 'create', expected: missingExpected() });
344
+ }
345
+ if (!existsSync(projectRoot)) {
346
+ return fail('invalid', '项目目录不存在:' + projectRoot, { action: 'create', expected: missingExpected() });
347
+ }
348
+ let realRoot;
349
+ try {
350
+ realRoot = realpathSync(projectRoot);
351
+ }
352
+ catch (error) {
353
+ return fail('invalid', '无法解析项目目录:' + messageOf(error), { action: 'create', expected: missingExpected() });
354
+ }
355
+ const relativeOrAbsolute = typeof rawPath === 'string' && rawPath.trim() !== '' ? rawPath.trim() : 'AGENTS.md';
356
+ if (containsDotDot(relativeOrAbsolute)) {
357
+ return fail('path-outside-project', '路径不得包含 ..:' + relativeOrAbsolute, { action: 'create', expected: missingExpected(), resolvedPath: relativeOrAbsolute });
358
+ }
359
+ const originalRoot = resolve(projectRoot);
360
+ const originalPath = isAbsolute(relativeOrAbsolute) ? normalize(relativeOrAbsolute) : resolve(originalRoot, relativeOrAbsolute);
361
+ // 绝对路径可能使用项目 junction 的字面路径;映射到真实根后再做边界校验。
362
+ const lexical = isInside(originalRoot, originalPath) ? join(realRoot, relative(originalRoot, originalPath)) : originalPath;
363
+ if (!isInside(realRoot, lexical)) {
364
+ return fail('path-outside-project', '解析后的路径不在当前项目内:' + lexical, { action: 'create', expected: missingExpected(), resolvedPath: lexical });
365
+ }
366
+ const followed = resolveFollowingLinks(lexical);
367
+ if (!isInside(realRoot, followed)) {
368
+ return fail('path-outside-project', '路径经链接解析后不在当前项目内(拒绝越界):' + followed, { action: 'create', expected: missingExpected(), resolvedPath: followed });
369
+ }
370
+ return { ok: true, target: { path: followed, projectRoot: realRoot, lexical } };
371
+ }
372
+ // ---------- 读写与编码 ----------
373
+ function decodeTargetBuffer(buf) {
374
+ if (buf.length >= 2 && ((buf[0] === 0xff && buf[1] === 0xfe) || (buf[0] === 0xfe && buf[1] === 0xff))) {
375
+ return fail('unsupported-encoding', '目标文件是 UTF-16(含 BOM),本工具只支持 UTF-8;请先转换为 UTF-8。');
376
+ }
377
+ if (buf.includes(0)) {
378
+ return fail('unsupported-encoding', '目标文件包含 NUL 字节,看起来是二进制或 UTF-16;拒绝改写。');
379
+ }
380
+ let hadBom = false;
381
+ let body = buf;
382
+ if (buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf) {
383
+ hadBom = true;
384
+ body = buf.subarray(3);
385
+ }
386
+ const text = body.toString('utf8');
387
+ if (!Buffer.from(text, 'utf8').equals(body)) {
388
+ return fail('unsupported-encoding', '目标文件不是合法 UTF-8;拒绝改写。');
389
+ }
390
+ return { ok: true, text, hadBom, eol: text.includes('\r\n') ? '\r\n' : '\n' };
391
+ }
392
+ function readTargetState(target) {
393
+ const path = target.path;
394
+ let stat;
395
+ try {
396
+ stat = statSync(path);
397
+ }
398
+ catch (error) {
399
+ const code = ioCodeOf(error);
400
+ if (code === 'ENOENT' || code === 'ENOTDIR') {
401
+ return { ok: true, state: { exists: false, sha256: null, size: 0, text: '', hadBom: false, eol: '\n' } };
402
+ }
403
+ return failFromIo(error, path);
404
+ }
405
+ if (stat.isDirectory())
406
+ return fail('target-is-directory', '目标是目录,不能写入:' + path, { action: 'create', expected: { exists: true, sha256: null, size: 0 }, resolvedPath: path });
407
+ if (!stat.isFile())
408
+ return fail('invalid', '目标不是普通文件:' + path);
409
+ let buf;
410
+ try {
411
+ buf = readFileSync(path);
412
+ }
413
+ catch (error) {
414
+ const code = ioCodeOf(error);
415
+ if (code === 'ENOENT')
416
+ return { ok: true, state: { exists: false, sha256: null, size: 0, text: '', hadBom: false, eol: '\n' } };
417
+ return failFromIo(error, path);
418
+ }
419
+ const decoded = decodeTargetBuffer(buf);
420
+ if (!('ok' in decoded) || decoded.ok !== true)
421
+ return decoded;
422
+ return {
423
+ ok: true,
424
+ state: {
425
+ exists: true,
426
+ sha256: sha256(buf),
427
+ size: buf.length,
428
+ text: decoded.text,
429
+ hadBom: decoded.hadBom,
430
+ eol: decoded.eol,
431
+ mode: stat.mode & 0o7777,
432
+ },
433
+ };
434
+ }
435
+ function failFromIo(error, path) {
436
+ const code = ioCodeOf(error);
437
+ if (code === 'EACCES' || code === 'EPERM') {
438
+ return fail('read-only', '没有权限写入目标文件:' + path + '(' + messageOf(error) + ')', {
439
+ hint: '取消只读属性后重试(Windows: attrib -R "' + path + '";Unix: chmod u+w "' + path + '")。',
440
+ action: 'create',
441
+ expected: missingExpected(),
442
+ resolvedPath: path,
443
+ });
444
+ }
445
+ return fail('io', '读写目标文件失败:' + messageOf(error), { action: 'create', expected: missingExpected(), resolvedPath: path });
446
+ }
447
+ function isReadOnlyTarget(targetPath) {
448
+ let mode = 0;
449
+ try {
450
+ const stat = statSync(targetPath);
451
+ mode = stat.mode;
452
+ if (!stat.isFile())
453
+ return false;
454
+ }
455
+ catch {
456
+ return false;
457
+ }
458
+ if ((mode & 0o200) === 0)
459
+ return true;
460
+ try {
461
+ accessSync(targetPath, 4 /* W_OK */);
462
+ return false;
463
+ }
464
+ catch (error) {
465
+ const code = ioCodeOf(error);
466
+ return code === 'EACCES' || code === 'EPERM';
467
+ }
468
+ }
469
+ function encodeTarget(text, hadBom) {
470
+ const body = Buffer.from(text, 'utf8');
471
+ if (!hadBom)
472
+ return body;
473
+ return Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), body]);
474
+ }
475
+ function writeAtomicPreserveMode(targetPath, buf, mode, beforeRename) {
476
+ const dir = dirname(targetPath);
477
+ mkdirSync(dir, { recursive: true });
478
+ const tmp = join(dir, '.dsh-dream-' + process.pid + '-' + randomUUID() + '.tmp');
479
+ try {
480
+ const fd = openSync(tmp, 'wx');
481
+ try {
482
+ writeFileSync(fd, buf);
483
+ if (mode !== undefined)
484
+ chmodSync(tmp, mode);
485
+ fsyncSync(fd);
486
+ }
487
+ finally {
488
+ closeSync(fd);
489
+ }
490
+ beforeRename?.();
491
+ renameSync(tmp, targetPath);
492
+ }
493
+ catch (error) {
494
+ try {
495
+ rmSync(tmp, { force: true });
496
+ }
497
+ catch {
498
+ // 清理失败不覆盖原始错误
499
+ }
500
+ throw error;
501
+ }
502
+ }
503
+ // ---------- 计划与 diff ----------
504
+ function planBridgeWrite(state, block) {
505
+ if (!state.exists) {
506
+ return { ok: true, plan: { action: 'create', nextText: block, unchanged: false, separator: '', previousBlock: null } };
507
+ }
508
+ const located = locateDreamBlock(state.text);
509
+ if (located.status === 'broken') {
510
+ return fail('marker', '管理块标记异常,拒绝写入:' + located.message, { line: located.line });
511
+ }
512
+ if (located.status === 'none') {
513
+ let separator = '';
514
+ if (state.text !== '') {
515
+ separator = state.text.endsWith('\n') ? state.eol : state.eol + state.eol;
516
+ }
517
+ return { ok: true, plan: { action: 'append', nextText: state.text + separator + block, unchanged: false, separator, previousBlock: null } };
518
+ }
519
+ const nextText = state.text.slice(0, located.start) + block + state.text.slice(located.end);
520
+ return {
521
+ ok: true,
522
+ plan: { action: 'replace', nextText, unchanged: nextText === state.text, separator: '', previousBlock: located.text },
523
+ };
524
+ }
525
+ function unifiedDiff(before, after, label) {
526
+ if (before === after)
527
+ return '';
528
+ const a = before.split(/\r?\n/);
529
+ const b = after.split(/\r?\n/);
530
+ let start = 0;
531
+ while (start < a.length && start < b.length && a[start] === b[start])
532
+ start++;
533
+ let endA = a.length;
534
+ let endB = b.length;
535
+ while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
536
+ endA--;
537
+ endB--;
538
+ }
539
+ const context = 3;
540
+ const from = Math.max(0, start - context);
541
+ const toA = Math.min(a.length, endA + context);
542
+ const toB = Math.min(b.length, endB + context);
543
+ const lines = ['--- a/' + label, '+++ b/' + label, '@@ -' + (from + 1) + ',' + (toA - from) + ' +' + (from + 1) + ',' + (toB - from) + ' @@'];
544
+ for (let i = from; i < start; i++)
545
+ lines.push(' ' + a[i]);
546
+ for (let i = start; i < endA; i++)
547
+ lines.push('-' + a[i]);
548
+ for (let i = start; i < endB; i++)
549
+ lines.push('+' + b[i]);
550
+ for (let i = endA; i < toA; i++)
551
+ lines.push(' ' + a[i]);
552
+ return lines.join('\n');
553
+ }
554
+ function previewIdOf(targetPath, action, expectedSha256, block) {
555
+ return 'pvw_' + sha256(stableJson({ targetPath, action, expectedSha256: expectedSha256 === null ? '' : expectedSha256, block })).slice(0, 24);
556
+ }
557
+ // ---------- 应用记录 ----------
558
+ function recordsPathOf(journalDir) {
559
+ return join(journalDir, 'bridge', 'records.jsonl');
560
+ }
561
+ function appendBridgeRecord(journalDir, record) {
562
+ const file = recordsPathOf(journalDir);
563
+ mkdirSync(dirname(file), { recursive: true });
564
+ const fd = openSync(file, 'a');
565
+ try {
566
+ // 上次进程可能留下半行;不能把本次有效记录接到半行后面。
567
+ const stat = statSync(file);
568
+ if (stat.size > 0) {
569
+ const bytes = readFileSync(file);
570
+ if (bytes[bytes.length - 1] !== 10)
571
+ writeSync(fd, '\n');
572
+ }
573
+ writeFileSync(fd, JSON.stringify(record) + '\n', 'utf8');
574
+ fsyncSync(fd);
575
+ }
576
+ finally {
577
+ closeSync(fd);
578
+ }
579
+ }
580
+ function readBridgeRecords(journalDir) {
581
+ const file = recordsPathOf(journalDir);
582
+ let text;
583
+ try {
584
+ text = readFileSync(file, 'utf8');
585
+ }
586
+ catch {
587
+ return [];
588
+ }
589
+ const found = new Map();
590
+ for (const line of text.split('\n')) {
591
+ if (line.trim() === '')
592
+ continue;
593
+ try {
594
+ const parsed = JSON.parse(line);
595
+ if (parsed === null || typeof parsed !== 'object')
596
+ continue;
597
+ const r = parsed;
598
+ if (r.schemaVersion !== 1 || typeof r.backupId !== 'string' || typeof r.at !== 'string' || typeof r.target !== 'string' || typeof r.projectRoot !== 'string')
599
+ continue;
600
+ if (r.action !== 'create' && r.action !== 'replace' && r.action !== 'append')
601
+ continue;
602
+ if (r.phase !== undefined && r.phase !== 'pending' && r.phase !== 'applied' && r.phase !== 'aborted')
603
+ continue;
604
+ if ((r.beforeBlock !== null && typeof r.beforeBlock !== 'string') || typeof r.separator !== 'string' || typeof r.afterBlockSha256 !== 'string' || typeof r.afterFileSha256 !== 'string' || !Array.isArray(r.lessons))
605
+ continue;
606
+ found.set(r.backupId, parsed);
607
+ }
608
+ catch {
609
+ // 坏行跳过,不阻断历史记录读取
610
+ }
611
+ }
612
+ return [...found.values()];
613
+ }
614
+ function loadBridgeRecord(journalDir, backupId) {
615
+ return readBridgeRecords(journalDir).find(record => record.backupId === backupId);
616
+ }
617
+ function readBridgeLock(lockPath) {
618
+ let raw;
619
+ try {
620
+ raw = readFileSync(lockPath, 'utf8');
621
+ }
622
+ catch {
623
+ return undefined;
624
+ }
625
+ let at = Date.now();
626
+ try {
627
+ at = statSync(lockPath).mtimeMs;
628
+ }
629
+ catch {
630
+ // 用 mtime 失败时退回 now
631
+ }
632
+ let pid;
633
+ try {
634
+ const parsed = JSON.parse(raw);
635
+ if (parsed !== null && typeof parsed === 'object') {
636
+ const record = parsed;
637
+ if (typeof record.pid === 'number' && Number.isInteger(record.pid) && record.pid > 0)
638
+ pid = record.pid;
639
+ if (typeof record.at === 'string') {
640
+ const parsedAt = Date.parse(record.at);
641
+ if (!Number.isNaN(parsedAt))
642
+ at = parsedAt;
643
+ }
644
+ }
645
+ }
646
+ catch {
647
+ // 内容损坏:只能按 mtime 判断
648
+ }
649
+ const alive = pid === undefined ? undefined : isProcessAlive(pid);
650
+ return { raw, pid, ageMs: Date.now() - at, alive };
651
+ }
652
+ function acquireBridgeLock(lockPath) {
653
+ const token = randomUUID();
654
+ const deadline = Date.now() + BRIDGE_LOCK_TIMEOUT_MS;
655
+ mkdirSync(dirname(lockPath), { recursive: true });
656
+ for (;;) {
657
+ let acquired = false;
658
+ try {
659
+ const fd = openSync(lockPath, 'wx');
660
+ try {
661
+ writeSync(fd, JSON.stringify({ pid: process.pid, at: new Date().toISOString(), token, bootId: BRIDGE_BOOT_ID }));
662
+ }
663
+ finally {
664
+ closeSync(fd);
665
+ }
666
+ acquired = true;
667
+ }
668
+ catch (error) {
669
+ if (ioCodeOf(error) !== 'EEXIST')
670
+ throw error;
671
+ }
672
+ if (acquired) {
673
+ return () => {
674
+ try {
675
+ const parsed = JSON.parse(readFileSync(lockPath, 'utf8'));
676
+ if (parsed === null || typeof parsed !== 'object' || parsed.token !== token)
677
+ return;
678
+ }
679
+ catch {
680
+ return;
681
+ }
682
+ try {
683
+ unlinkSync(lockPath);
684
+ }
685
+ catch {
686
+ // 已被接管则保持现状
687
+ }
688
+ };
689
+ }
690
+ const info = readBridgeLock(lockPath);
691
+ if (info !== undefined) {
692
+ const stale = info.alive === false || (info.pid === undefined && info.ageMs > BRIDGE_LOCK_STALE_MS);
693
+ if (stale) {
694
+ const recheck = readBridgeLock(lockPath);
695
+ if (recheck !== undefined && recheck.raw === info.raw) {
696
+ try {
697
+ unlinkSync(lockPath);
698
+ continue;
699
+ }
700
+ catch {
701
+ // 抢占失败:走重试/超时
702
+ }
703
+ }
704
+ }
705
+ }
706
+ if (Date.now() >= deadline)
707
+ throw new Error('另一个桥接操作正在进行(锁:' + lockPath + '),请稍后重试。');
708
+ sleepSync(25);
709
+ }
710
+ }
711
+ // ---------- 三个模式 ----------
712
+ function normalizeScope(options) {
713
+ const scope = {};
714
+ if (typeof options.projectId === 'string' && options.projectId.trim() !== '')
715
+ scope.projectId = options.projectId;
716
+ if (typeof options.workspaceRoot === 'string' && options.workspaceRoot.trim() !== '')
717
+ scope.workspaceRoot = options.workspaceRoot;
718
+ return scope;
719
+ }
720
+ /** preview:只读计算 diff / expected / 管理块;绝不写盘。 */
721
+ export function previewBridge(options) {
722
+ if (options === null || typeof options !== 'object') {
723
+ return fail('invalid', 'preview 需要 options 对象', { action: 'create', expected: missingExpected() });
724
+ }
725
+ const target = resolveBridgeTarget(options.projectRoot, options.path);
726
+ if (!('ok' in target) || target.ok !== true)
727
+ return target;
728
+ const read = readTargetState(target.target);
729
+ if (!('ok' in read) || read.ok !== true)
730
+ return withPreviewShape(read, target.target.path);
731
+ const state = read.state;
732
+ const selection = selectBridgeLessons(Array.isArray(options.lessons) ? options.lessons : [], normalizeScope(options), {
733
+ lessonIds: options.lessonIds,
734
+ maxLessons: options.maxLessons,
735
+ });
736
+ const views = selection.eligible.map((lesson) => toView(lesson, options.maskSecrets !== false));
737
+ const block = withEol(buildDreamBlock(views), state.eol);
738
+ const planned = planBridgeWrite(state, block);
739
+ if (!('ok' in planned) || planned.ok !== true)
740
+ return withPreviewShape(planned, target.target.path, expectedOf(state));
741
+ const expected = expectedOf(state);
742
+ const action = planned.plan.unchanged ? 'unchanged' : planned.plan.action;
743
+ return {
744
+ ok: true,
745
+ action,
746
+ expected,
747
+ resolvedPath: target.target.path,
748
+ diff: unifiedDiff(state.text, planned.plan.nextText, basename(target.target.path)),
749
+ lessons: views,
750
+ skipped: selection.skipped,
751
+ previewId: previewIdOf(target.target.path, planned.plan.action, expected.sha256, block),
752
+ blockPreview: block,
753
+ };
754
+ }
755
+ /** apply:必须带 expectedSha256;CAS 冲突 → conflict 零写入;只替换管理块。 */
756
+ export function applyBridge(options) {
757
+ if (options === null || typeof options !== 'object')
758
+ return fail('invalid', 'apply 需要 options 对象');
759
+ if (typeof options.expectedSha256 !== 'string' && options.expectedSha256 !== null) {
760
+ return fail('invalid', 'apply 必须提供 expectedSha256(preview.expected.sha256 原样回传即可;文件不存在时为 null)。', {
761
+ action: 'create',
762
+ expected: missingExpected(),
763
+ });
764
+ }
765
+ let release;
766
+ try {
767
+ if (typeof options.journalDir !== 'string' || options.journalDir.trim() === '')
768
+ return fail('invalid', 'journalDir 必须是非空路径');
769
+ release = acquireBridgeLock(join(options.journalDir, 'bridge', '.lock'));
770
+ const target = resolveBridgeTarget(options.projectRoot, options.path);
771
+ if (!('ok' in target) || target.ok !== true)
772
+ return target;
773
+ const read = readTargetState(target.target);
774
+ if (!('ok' in read) || read.ok !== true)
775
+ return read;
776
+ const state = read.state;
777
+ const selection = selectBridgeLessons(Array.isArray(options.lessons) ? options.lessons : [], normalizeScope(options), {
778
+ lessonIds: options.lessonIds,
779
+ maxLessons: options.maxLessons,
780
+ });
781
+ const views = selection.eligible.map((lesson) => toView(lesson, options.maskSecrets !== false));
782
+ const block = withEol(buildDreamBlock(views), state.eol);
783
+ const planned = planBridgeWrite(state, block);
784
+ if (!('ok' in planned) || planned.ok !== true)
785
+ return planned;
786
+ const plan = planned.plan;
787
+ const expected = expectedOf(state);
788
+ // ⑨ 幂等重试:当前管理块已经等于本次要写入的块 → 成功且 unchanged,字节不变。
789
+ // 这不与并发冲突矛盾:只有"要么写不进去、要么没机会写"的情况才拒绝。
790
+ if (plan.unchanged) {
791
+ return {
792
+ ok: true,
793
+ action: 'unchanged',
794
+ unchanged: true,
795
+ file: expected,
796
+ path: target.target.path,
797
+ resolvedPath: target.target.path,
798
+ lessons: views,
799
+ skipped: selection.skipped,
800
+ };
801
+ }
802
+ if (!stateMatchesExpected(state, options.expectedSha256)) {
803
+ return fail('conflict', '目标文件已变化(expectedSha256 不符),已零写入;请重新 preview 后再 apply。', {
804
+ action: plan.action,
805
+ expected,
806
+ resolvedPath: target.target.path,
807
+ hint: '当前 sha256=' + (state.exists ? (state.sha256 ?? '').slice(0, 12) + '…' : '(文件不存在)'),
808
+ });
809
+ }
810
+ const previewId = previewIdOf(target.target.path, plan.action, state.sha256, block);
811
+ if (options.previewId !== undefined && options.previewId !== previewId) {
812
+ return fail('preview-mismatch', 'previewId 与本次要写入的内容不匹配,拒绝 apply。', {
813
+ action: plan.action,
814
+ expected,
815
+ resolvedPath: target.target.path,
816
+ hint: '请重新 preview。',
817
+ });
818
+ }
819
+ if (state.exists && isReadOnlyTarget(target.target.path)) {
820
+ return fail('read-only', '目标文件是只读的,拒绝写入:' + target.target.path, {
821
+ action: plan.action,
822
+ expected,
823
+ resolvedPath: target.target.path,
824
+ hint: '取消只读属性后重试(Windows: attrib -R;Unix: chmod u+w)。',
825
+ });
826
+ }
827
+ const buf = encodeTarget(plan.nextText, state.hadBom);
828
+ const backupId = 'brg_' + randomUUID();
829
+ const lessonsMeta = views.map((view) => ({ lessonId: view.lessonId, revision: view.revision }));
830
+ const record = {
831
+ schemaVersion: 1,
832
+ backupId,
833
+ at: new Date().toISOString(),
834
+ target: target.target.path,
835
+ projectRoot: target.target.projectRoot,
836
+ action: plan.action,
837
+ before: expected,
838
+ beforeBlock: plan.previousBlock,
839
+ separator: plan.separator,
840
+ afterBlockSha256: sha256(block),
841
+ afterFileSha256: sha256(buf),
842
+ lessons: lessonsMeta,
843
+ previewId,
844
+ phase: 'pending',
845
+ };
846
+ // Write-ahead:恢复信息先 fsync;失败则目标字节仍完全不变。
847
+ appendBridgeRecord(options.journalDir, record);
848
+ try {
849
+ writeAtomicPreserveMode(target.target.path, buf, state.mode, () => {
850
+ const currentTarget = resolveBridgeTarget(options.projectRoot, options.path);
851
+ const current = currentTarget.ok ? readTargetState(currentTarget.target) : currentTarget;
852
+ if (!currentTarget.ok || currentTarget.target.path !== target.target.path || !current.ok || !stateMatchesExpected(current.state, state.sha256)) {
853
+ throw Object.assign(new Error('目标文件在写入准备期间发生变化,请重新预览。'), { code: 'DREAM_CONFLICT' });
854
+ }
855
+ });
856
+ }
857
+ catch (error) {
858
+ try {
859
+ appendBridgeRecord(options.journalDir, { ...record, phase: 'aborted' });
860
+ }
861
+ catch { /* pending 可由当前文件状态识别为未完成 */ }
862
+ if (ioCodeOf(error) === 'DREAM_CONFLICT')
863
+ return fail('conflict', messageOf(error));
864
+ return failFromIo(error, target.target.path);
865
+ }
866
+ let warning;
867
+ try {
868
+ appendBridgeRecord(options.journalDir, { ...record, phase: 'applied' });
869
+ }
870
+ catch (error) {
871
+ warning = '目标已应用,最终审计确认未能追加:' + messageOf(error) + '。恢复记录已保存,可用 backupId=' + backupId + ' 回滚。';
872
+ }
873
+ return {
874
+ ok: true,
875
+ action: plan.action,
876
+ unchanged: false,
877
+ backupId,
878
+ file: { exists: true, sha256: sha256(buf), size: buf.length },
879
+ path: target.target.path,
880
+ resolvedPath: target.target.path,
881
+ lessons: views,
882
+ skipped: selection.skipped,
883
+ previewId,
884
+ ...(warning !== undefined ? { warning } : {}),
885
+ };
886
+ }
887
+ catch (error) {
888
+ return fail('io', messageOf(error), { action: 'create', expected: missingExpected() });
889
+ }
890
+ finally {
891
+ if (release !== undefined)
892
+ release();
893
+ }
894
+ }
895
+ /** rollback:仅当当前管理块仍等于该次 apply 的结果时恢复原管理块。 */
896
+ export function rollbackBridge(options) {
897
+ if (options === null || typeof options !== 'object' || typeof options.backupId !== 'string' || options.backupId.trim() === '') {
898
+ return fail('invalid', 'rollback 需要非空 backupId');
899
+ }
900
+ let release;
901
+ try {
902
+ if (typeof options.journalDir !== 'string' || options.journalDir.trim() === '')
903
+ return fail('invalid', 'journalDir 必须是非空路径');
904
+ release = acquireBridgeLock(join(options.journalDir, 'bridge', '.lock'));
905
+ const record = loadBridgeRecord(options.journalDir, options.backupId);
906
+ if (record === undefined)
907
+ return fail('not-found', '找不到应用记录:' + options.backupId);
908
+ if (record.phase === 'aborted')
909
+ return fail('not-rollbackable', '这次应用未完成,目标没有写入。');
910
+ const rootCheck = resolveBridgeTarget(record.projectRoot, 'AGENTS.md');
911
+ if (!('ok' in rootCheck) || rootCheck.ok !== true) {
912
+ return fail('not-rollbackable', '记录里的项目目录已不可用:' + record.projectRoot);
913
+ }
914
+ const targetRoot = rootCheck.target.projectRoot;
915
+ if (!isInside(targetRoot, record.target)) {
916
+ return fail('not-rollbackable', '记录目标不在项目内,拒绝回滚:' + record.target);
917
+ }
918
+ const currentTarget = resolveBridgeTarget(targetRoot, record.target);
919
+ if (!currentTarget.ok || currentTarget.target.path !== record.target)
920
+ return fail('not-rollbackable', '目标路径已被重新链接,拒绝回滚。');
921
+ if (options.projectRoot !== undefined && options.projectRoot.trim() !== '') {
922
+ let requestedRoot;
923
+ try {
924
+ requestedRoot = realpathSync(options.projectRoot);
925
+ }
926
+ catch (error) {
927
+ return fail('invalid', '无法解析 projectRoot:' + messageOf(error));
928
+ }
929
+ if (requestedRoot !== targetRoot)
930
+ return fail('not-rollbackable', 'projectRoot 与该应用记录不一致,拒绝回滚。');
931
+ }
932
+ if (typeof options.path === 'string' && options.path.trim() !== '') {
933
+ const requested = resolveBridgeTarget(targetRoot, options.path);
934
+ if (!('ok' in requested) || requested.ok !== true)
935
+ return requested;
936
+ if (requested.target.path !== record.target)
937
+ return fail('not-rollbackable', '目标路径与该应用记录不一致,拒绝回滚。');
938
+ }
939
+ const read = readTargetState({ path: record.target, projectRoot: targetRoot, lexical: record.target });
940
+ if (!('ok' in read) || read.ok !== true)
941
+ return read;
942
+ const state = read.state;
943
+ if (!state.exists)
944
+ return fail('conflict', '目标文件已不存在,拒绝回滚。', { hint: '该 apply 曾被外部删除或移动。' });
945
+ const located = locateDreamBlock(state.text);
946
+ if (located.status === 'broken')
947
+ return fail('marker', '当前文件管理块标记异常,拒绝回滚:' + located.message, { line: located.line });
948
+ if (located.status === 'none')
949
+ return fail('conflict', '当前文件已没有 Dream 管理块,拒绝回滚(可能已被人工移除)。');
950
+ const currentBlockSha = sha256(Buffer.from(located.text, 'utf8'));
951
+ if (currentBlockSha !== record.afterBlockSha256) {
952
+ return fail('conflict', '当前管理块已被外部修改,拒绝回滚(不会覆盖人工改动)。', {
953
+ hint: '如确需恢复,请重新 preview/apply,而不是强制 rollback。',
954
+ });
955
+ }
956
+ if (isReadOnlyTarget(record.target)) {
957
+ return fail('read-only', '目标文件是只读的,拒绝回滚:' + record.target, { hint: '取消只读属性后重试。' });
958
+ }
959
+ let nextText;
960
+ if (record.beforeBlock === null) {
961
+ let start = located.start;
962
+ if (record.separator !== '' && state.text.slice(0, start).endsWith(record.separator)) {
963
+ start -= record.separator.length;
964
+ }
965
+ nextText = state.text.slice(0, start) + state.text.slice(located.end);
966
+ }
967
+ else {
968
+ nextText = state.text.slice(0, located.start) + record.beforeBlock + state.text.slice(located.end);
969
+ }
970
+ if (record.action === 'create' && record.beforeBlock === null && nextText === '') {
971
+ try {
972
+ unlinkSync(record.target);
973
+ }
974
+ catch (error) {
975
+ return failFromIo(error, record.target);
976
+ }
977
+ return { ok: true, backupId: record.backupId, file: { exists: false, sha256: null, size: 0 }, path: record.target, resolvedPath: record.target };
978
+ }
979
+ const buf = encodeTarget(nextText, state.hadBom);
980
+ try {
981
+ writeAtomicPreserveMode(record.target, buf, state.mode);
982
+ }
983
+ catch (error) {
984
+ return failFromIo(error, record.target);
985
+ }
986
+ return { ok: true, backupId: record.backupId, file: { exists: true, sha256: sha256(buf), size: buf.length }, path: record.target, resolvedPath: record.target };
987
+ }
988
+ catch (error) {
989
+ return fail('io', messageOf(error));
990
+ }
991
+ finally {
992
+ if (release !== undefined)
993
+ release();
994
+ }
995
+ }
996
+ /**
997
+ * 只读列出应用记录(按 at 降序)。目录/文件缺失返回 [],绝不写盘。
998
+ * rollbackable 在桥接层按当前文件的管理块重算,供面板直接展示。
999
+ */
1000
+ export function listBridgeApplications(journalDir) {
1001
+ if (typeof journalDir !== 'string' || journalDir.trim() === '')
1002
+ return [];
1003
+ const records = readBridgeRecords(journalDir);
1004
+ records.sort((a, b) => (a.at === b.at ? b.backupId.localeCompare(a.backupId) : b.at.localeCompare(a.at)));
1005
+ return records.map((record) => {
1006
+ let rollbackable = false;
1007
+ let rollbackReason;
1008
+ try {
1009
+ const resolved = resolveBridgeTarget(record.projectRoot, record.target);
1010
+ if (record.phase === 'aborted')
1011
+ throw new Error('应用未完成');
1012
+ if (!resolved.ok || resolved.target.path !== record.target)
1013
+ throw new Error('目标路径已被重新链接');
1014
+ const read = readTargetState({ path: record.target, projectRoot: record.projectRoot, lexical: record.target });
1015
+ if (!('ok' in read) || read.ok !== true) {
1016
+ rollbackReason = '目标文件不可读';
1017
+ }
1018
+ else if (!read.state.exists) {
1019
+ rollbackReason = '文件不存在';
1020
+ }
1021
+ else {
1022
+ const located = locateDreamBlock(read.state.text);
1023
+ if (located.status === 'broken')
1024
+ rollbackReason = '管理块标记异常';
1025
+ else if (located.status === 'none')
1026
+ rollbackReason = '管理块已被移除';
1027
+ else if (sha256(Buffer.from(located.text, 'utf8')) !== record.afterBlockSha256)
1028
+ rollbackReason = '管理块已被外部修改';
1029
+ else
1030
+ rollbackable = true;
1031
+ }
1032
+ }
1033
+ catch (error) {
1034
+ rollbackReason = messageOf(error);
1035
+ }
1036
+ const view = {
1037
+ backupId: record.backupId,
1038
+ at: record.at,
1039
+ target: record.target,
1040
+ action: record.action,
1041
+ lessons: Array.isArray(record.lessons) ? record.lessons.map((item) => ({ lessonId: String(item.lessonId), revision: Number(item.revision) })) : [],
1042
+ beforeSha256: typeof record.before === 'object' && record.before !== null && typeof record.before.sha256 === 'string'
1043
+ ? String(record.before.sha256)
1044
+ : null,
1045
+ afterSha256: record.afterFileSha256,
1046
+ rollbackable,
1047
+ };
1048
+ if (rollbackReason !== undefined)
1049
+ view.rollbackReason = rollbackReason;
1050
+ return view;
1051
+ });
1052
+ }
1053
+ // ---------- legacy(0.5.x 直写;保留一期) ----------
1054
+ /** 旧版标记块起始行(历史文件用;新流程不碰它)。 */
9
1055
  export const BRIDGE_START = '<!-- dsh-dream:lessons:start(自动生成,请勿手工编辑块内内容) -->';
1056
+ /** 旧版标记块结束行。 */
10
1057
  export const BRIDGE_END = '<!-- dsh-dream:lessons:end -->';
11
- /** 生成教训块文本。 */
1058
+ /** 生成教训块文本(legacy)。 */
12
1059
  export function buildLessonsBlock(lessons) {
13
1060
  const lines = [BRIDGE_START, '', '## 梦境沉淀(dsh-dream 自动生成)', ''];
14
1061
  for (const item of lessons) {
15
- lines.push('- ' + item.lesson + (item.count > 1 ? '(反复梦到 ' + item.count + ' 次)' : ''));
1062
+ lines.push('- ' + maskSecrets(item.lesson) + (item.count > 1 ? '(反复梦到 ' + item.count + ' 次)' : ''));
16
1063
  }
17
1064
  lines.push('', BRIDGE_END);
18
1065
  return lines.join('\n');
19
1066
  }
20
1067
  /**
21
- * 把梦境教训合并进目标文件:无文件则创建,有标记块则替换,无标记块则追加。
1068
+ * legacy:把梦境教训合并进目标文件(0.5.x 行为,直接写;新代码请用 preview/apply)。
22
1069
  * @returns 动作类型与写入的教训数。
23
1070
  */
24
1071
  export function bridgeDreams(journalDir, targetPath, maxLessons) {