@mingmaple/dsh-handover 0.9.14 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/README.md +123 -92
  2. package/docs/migrate.md +52 -41
  3. package/docs/releases/0.9.12.md +2 -3
  4. package/docs/releases/0.9.14.md +1 -5
  5. package/docs/releases/0.9.22.md +34 -0
  6. package/docs/releases/1.0.0.md +33 -0
  7. package/docs/troubleshoot.md +230 -46
  8. package/package.json +74 -71
  9. package/scripts/behavior.mjs +398 -0
  10. package/scripts/blank-dsh.mjs +41 -25
  11. package/scripts/blank-smoke.mjs +278 -109
  12. package/scripts/check-docs.mjs +7 -0
  13. package/scripts/e2e-migration.mjs +2 -2
  14. package/scripts/office.mjs +3 -3
  15. package/scripts/portability.mjs +71 -0
  16. package/scripts/style-gate.mjs +571 -0
  17. package/src/client.js +1028 -955
  18. package/src/index.mjs +313 -142
  19. package/src/lib/absrefs.mjs +1 -1
  20. package/src/lib/api.mjs +18 -10
  21. package/src/lib/archive.mjs +1 -1
  22. package/src/lib/brief.mjs +8 -6
  23. package/src/lib/config.mjs +23 -8
  24. package/src/lib/consent.mjs +54 -1
  25. package/src/lib/docmap.mjs +140 -0
  26. package/src/lib/drift.mjs +1 -1
  27. package/src/lib/events.mjs +1 -1
  28. package/src/lib/facts.mjs +19 -12
  29. package/src/lib/gates.mjs +30 -6
  30. package/src/lib/global-task.mjs +69 -0
  31. package/src/lib/guide.mjs +81 -85
  32. package/src/lib/handover-io.mjs +69 -6
  33. package/src/lib/identity.mjs +330 -0
  34. package/src/lib/legacy.mjs +89 -0
  35. package/src/lib/lint.mjs +17 -13
  36. package/src/lib/manual.mjs +135 -59
  37. package/src/lib/mover.mjs +23 -19
  38. package/src/lib/names.mjs +14 -19
  39. package/src/lib/office.mjs +63 -15
  40. package/src/lib/pack-ops.mjs +11 -11
  41. package/src/lib/pathkey.mjs +24 -0
  42. package/src/lib/peer.mjs +4 -4
  43. package/src/lib/phase.mjs +8 -13
  44. package/src/lib/profile.mjs +1 -1
  45. package/src/lib/reminder.mjs +110 -0
  46. package/src/lib/role.mjs +9 -6
  47. package/src/lib/scaffold.mjs +68 -26
  48. package/src/lib/schedule.mjs +5 -6
  49. package/src/lib/session-create.mjs +3 -3
  50. package/src/lib/session-ops.mjs +80 -51
  51. package/src/lib/sessions.mjs +41 -3
  52. package/src/lib/sevenzip.mjs +10 -1
  53. package/src/lib/status.mjs +337 -176
  54. package/src/lib/succession.mjs +16 -16
  55. package/src/lib/system.mjs +364 -0
  56. package/src/lib/zstd-log.mjs +1 -1
  57. package/src/selftest.mjs +6 -6
  58. package/src/templates/Changelog.skeleton.md +3 -3
  59. package/src/templates/Charter.skeleton.md +13 -9
  60. package/src/templates/Handover.global.skeleton.md +20 -13
  61. package/src/templates/Handover.template.md +89 -0
  62. package/src/templates/Plan.skeleton.md +34 -0
  63. package/src/templates/Prompt_Templates.md +101 -20
  64. package/src/templates/Runtime_Manual.md +263 -122
  65. package/src/templates/System.skeleton.md +31 -0
  66. package/src/templates/Systems.global.skeleton.md +13 -0
  67. package/src/templates/rules/Delegation_Rules.md +45 -0
  68. package/src/templates/rules/Handover_Rules.md +46 -23
  69. package/src/templates/rules/Migration_Rules.md +7 -7
  70. package/src/templates/rules/Misc_Task_Standard.md +17 -10
  71. package/src/templates/rules/Writing_Rules.md +33 -16
  72. package/src/templates/standards/README.md +37 -0
  73. package/src/templates/standards//345/206/231/344/275/234/350/247/204/350/214/203/README.md +120 -0
  74. package/src/templates/standards//345/206/231/344/275/234/350/247/204/350/214/203//344/276/235/346/215/256_/350/256/272/346/226/207.md +389 -0
  75. package/src/tools/ho_backup.mjs +135 -0
  76. package/src/tools/ho_changelog.mjs +4 -4
  77. package/src/tools/ho_charter.mjs +7 -6
  78. package/src/tools/ho_checkpoint.mjs +36 -32
  79. package/src/tools/ho_docmap.mjs +239 -0
  80. package/src/tools/ho_gate.mjs +11 -5
  81. package/src/tools/ho_handover.mjs +61 -35
  82. package/src/tools/ho_office.mjs +46 -5
  83. package/src/tools/ho_profile.mjs +3 -3
  84. package/src/tools/ho_sessions.mjs +46 -26
  85. package/src/tools/ho_status.mjs +251 -0
  86. package/src/tools/ho_succeed.mjs +3 -3
  87. package/src/tools/ho_workspace.mjs +146 -42
  88. package/src/tools/peer.mjs +416 -0
  89. package/src/tools/ho_brief.mjs +0 -57
  90. package/src/tools/ho_diag.mjs +0 -87
  91. package/src/tools/ho_drift.mjs +0 -41
  92. package/src/tools/ho_init.mjs +0 -82
  93. package/src/tools/ho_pack.mjs +0 -38
  94. package/src/tools/ho_phase.mjs +0 -37
  95. package/src/tools/ho_snapshot.mjs +0 -104
  96. package/src/tools/ho_tokens.mjs +0 -57
  97. package/src/tools/peer_list.mjs +0 -37
  98. package/src/tools/peer_new.mjs +0 -75
  99. package/src/tools/peer_peek.mjs +0 -41
  100. package/src/tools/peer_rename.mjs +0 -31
  101. package/src/tools/peer_send.mjs +0 -52
  102. package/src/tools/peer_status.mjs +0 -46
@@ -0,0 +1,330 @@
1
+ /**
2
+ * 身份互转(1.0):项目与杂项互换时先按归档规范做整库快照,再移动体系;
3
+ * 也支持「直接建」:旧体系移入备份目录,不搬入新位置,新体系由会话按模板建立。
4
+ *
5
+ * 本模块只处理文件与登记,不改角色表;角色表由调用方写。
6
+ */
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { NL, readUtf8, isDir, isFile } from './text.mjs';
10
+ import { stampNow, loadOffice, saveOffice, lineageKeyOf } from './office.mjs';
11
+ import { packDirTarGz } from './archive.mjs';
12
+ import { sevenZipBin, pack as pack7z } from './sevenzip.mjs';
13
+
14
+ function p2(n) { return String(n).padStart(2, '0'); }
15
+
16
+ function stampCompact() {
17
+ const d = new Date();
18
+ return d.getFullYear() + p2(d.getMonth() + 1) + p2(d.getDate()) + '_' + p2(d.getHours()) + p2(d.getMinutes()) + p2(d.getSeconds());
19
+ }
20
+
21
+ function dateOnly() {
22
+ const d = new Date();
23
+ return d.getFullYear() + '-' + p2(d.getMonth() + 1) + '-' + p2(d.getDate());
24
+ }
25
+
26
+ export function backupDirFor(ws) {
27
+ return path.join(String(ws || ''), '_Archives', '_Global', 'Backups', '身份迁移_' + stampCompact());
28
+ }
29
+
30
+ /** 整库快照:有 7-Zip 出 .7z,否则出内置 .tar.gz。返回 { dir, made }。 */
31
+ export function snapshotDirs(ws, dirs) {
32
+ const dir = backupDirFor(ws);
33
+ const made = [];
34
+ const bin = sevenZipBin();
35
+ let usedDir = false;
36
+ for (const item of dirs || []) {
37
+ const abs = String(item.abs || '');
38
+ if (!abs || !isDir(abs)) continue;
39
+ usedDir = true;
40
+ fs.mkdirSync(dir, { recursive: true });
41
+ const name = String(item.name || path.basename(abs));
42
+ if (bin) {
43
+ const out = path.join(dir, name + '.7z');
44
+ const r = pack7z(bin, out, abs);
45
+ if (r.code !== 0) throw new Error('快照失败(7-Zip,' + name + '):' + r.out);
46
+ made.push(out);
47
+ } else {
48
+ const out = path.join(dir, name + '.tar.gz');
49
+ packDirTarGz(abs, out);
50
+ made.push(out);
51
+ }
52
+ }
53
+ return { dir: usedDir ? dir : '', made };
54
+ }
55
+
56
+ function listDir(dir) {
57
+ try { return fs.readdirSync(dir); } catch { return []; }
58
+ }
59
+
60
+ /** 把 srcDir 下的条目移入 destDir;冲突即中止。移完删除空源目录。 */
61
+ function moveContents(srcDir, destDir) {
62
+ if (!isDir(srcDir)) return [];
63
+ fs.mkdirSync(destDir, { recursive: true });
64
+ const moved = [];
65
+ for (const name of listDir(srcDir)) {
66
+ const s = path.join(srcDir, name);
67
+ const d = path.join(destDir, name);
68
+ if (fs.existsSync(d)) throw new Error('目标已存在同名条目:' + d);
69
+ fs.renameSync(s, d);
70
+ moved.push(name);
71
+ }
72
+ try { fs.rmdirSync(srcDir); } catch { /* 非空或已删,保留 */ }
73
+ return moved;
74
+ }
75
+
76
+ function moveOne(srcFile, destFile) {
77
+ if (!isFile(srcFile)) return false;
78
+ if (fs.existsSync(destFile)) throw new Error('目标已存在同名文件:' + destFile);
79
+ fs.mkdirSync(path.dirname(destFile), { recursive: true });
80
+ fs.renameSync(srcFile, destFile);
81
+ return true;
82
+ }
83
+
84
+ /** 改骨架档案:文件名与 root 字段。 */
85
+ function rewriteProfile(srcFile, destFile, root) {
86
+ if (!isFile(srcFile)) return false;
87
+ let v = {};
88
+ try { v = JSON.parse(readUtf8(srcFile)); } catch { v = {}; }
89
+ v.root = root;
90
+ fs.mkdirSync(path.dirname(destFile), { recursive: true });
91
+ fs.writeFileSync(destFile, JSON.stringify(v, null, 2) + NL, 'utf8');
92
+ fs.rmSync(srcFile, { force: true });
93
+ return true;
94
+ }
95
+
96
+ /** 把注册与交接里的旧根路径改为新根,减少迁移后的失效条目。 */
97
+ function rewritePaths(file, from, to) {
98
+ if (!isFile(file)) return false;
99
+ let text = '';
100
+ try { text = readUtf8(file); } catch { return false; }
101
+ const next = text.split(from).join(to);
102
+ if (next !== text) fs.writeFileSync(file, next, 'utf8');
103
+ return next !== text;
104
+ }
105
+
106
+ // ---------------------------------------------------------------- 名册(Registry.md)
107
+
108
+ function registryFile(ws) {
109
+ return path.join(String(ws || ''), '_Project_Meta', 'Registry.md');
110
+ }
111
+
112
+ function backupOnce(file) {
113
+ try { if (isFile(file)) fs.copyFileSync(file, file + '.bak-' + stampCompact()); } catch { /* 备份失败不阻断,调用方另报 */ }
114
+ }
115
+
116
+ /** 从项目清单删除一行;找不到返回 false。 */
117
+ export function removeRegistryProject(ws, name) {
118
+ const file = registryFile(ws);
119
+ if (!isFile(file)) return false;
120
+ const text = readUtf8(file).replace(/^\uFEFF/, '');
121
+ const lines = text.split(NL);
122
+ let inTable = false;
123
+ let removed = false;
124
+ const out = [];
125
+ for (const line of lines) {
126
+ if (line.startsWith('## ')) inTable = line.indexOf('项目清单') !== -1;
127
+ if (inTable && line.startsWith('|')) {
128
+ const cells = line.replace(/^\|/, '').replace(/\|$/, '').split('|').map((c) => c.trim());
129
+ if (cells[0] === String(name).trim()) { removed = true; continue; }
130
+ }
131
+ out.push(line);
132
+ }
133
+ if (removed) { backupOnce(file); fs.writeFileSync(file, out.join(NL), 'utf8'); }
134
+ return removed;
135
+ }
136
+
137
+ /** 追加一行项目;已有同名行则返回 false。 */
138
+ export function addRegistryProject(ws, name) {
139
+ const file = registryFile(ws);
140
+ if (!isFile(file)) return false;
141
+ const text = readUtf8(file).replace(/^\uFEFF/, '');
142
+ const lines = text.split(NL);
143
+ const row = '| ' + name + ' | `Projects/' + name + '/` | — | 进行中 | 无 | — | ' + dateOnly() + ' | general |';
144
+ let inTable = false;
145
+ let lastRow = -1;
146
+ for (let i = 0; i < lines.length; i++) {
147
+ const line = lines[i];
148
+ if (line.startsWith('## ')) inTable = line.indexOf('项目清单') !== -1;
149
+ if (!inTable || !line.startsWith('|')) continue;
150
+ const cells = line.replace(/^\|/, '').replace(/\|$/, '').split('|').map((c) => c.trim());
151
+ if (cells[0] === String(name).trim()) return false;
152
+ lastRow = i;
153
+ }
154
+ if (lastRow === -1) return false;
155
+ lines.splice(lastRow + 1, 0, row);
156
+ backupOnce(file);
157
+ fs.writeFileSync(file, lines.join(NL), 'utf8');
158
+ return true;
159
+ }
160
+
161
+ // ---------------------------------------------------------------- 在任登记改名
162
+
163
+ function moveOfficeLineage(ws, prev, next) {
164
+ const store = loadOffice(ws);
165
+ const oldKey = lineageKeyOf(prev.kind || prev.role, prev.project, prev.task);
166
+ const newKey = lineageKeyOf(next.kind || next.role, next.project, next.task);
167
+ if (!oldKey || !newKey) return { moved: false };
168
+ const lin = store.lineages[oldKey];
169
+ if (!lin) return { moved: false };
170
+ if (oldKey === newKey) {
171
+ // 同名互转:类别键不变,但类别、项目与任务字段要跟着改。
172
+ const copy = JSON.parse(JSON.stringify(lin));
173
+ copy.kind = next.kind || next.role;
174
+ copy.project = String(next.project || '');
175
+ copy.task = String(next.task || '');
176
+ store.lineages[oldKey] = copy;
177
+ backupOnce(path.join(String(ws || ''), '_Control_Center', 'records', 'Office.json'));
178
+ saveOffice(ws, store);
179
+ return { moved: false, updated: true, oldKey, newKey };
180
+ }
181
+ if (store.lineages[newKey]) {
182
+ throw new Error('名册里已有「' + newKey + '」:先在设置页清理或在职会话处理,再转换身份');
183
+ }
184
+ const copy = JSON.parse(JSON.stringify(lin));
185
+ copy.kind = next.kind || next.role;
186
+ copy.project = String(next.project || '');
187
+ copy.task = String(next.task || '');
188
+ store.lineages[newKey] = copy;
189
+ delete store.lineages[oldKey];
190
+ backupOnce(path.join(String(ws || ''), '_Control_Center', 'records', 'Office.json'));
191
+ saveOffice(ws, store);
192
+ return { moved: true, oldKey, newKey };
193
+ }
194
+
195
+ // ---------------------------------------------------------------- 主流程
196
+
197
+ /**
198
+ * 执行身份互转。
199
+ * @param {object} input { ws, prev, next, mode: 'auto'|'direct' }
200
+ * @returns {{ mode, snapshot, moved, summary }}
201
+ */
202
+ export function migrateIdentity(input) {
203
+ const i = input || {};
204
+ const ws = String(i.ws || '');
205
+ const prev = i.prev || {};
206
+ const next = i.next || {};
207
+ const mode = i.mode === 'direct' ? 'direct' : 'auto';
208
+ if (!ws) throw new Error('缺工作区根');
209
+ const prevKind = prev.role;
210
+ const nextKind = next.role;
211
+ const prevOk = prevKind === 'project' || prevKind === 'misc' || prevKind === 'global-task';
212
+ if (!prevOk || (nextKind !== 'project' && nextKind !== 'misc')) {
213
+ throw new Error('身份互转只处理项目、杂项与全局任务');
214
+ }
215
+ if (prevKind === nextKind) throw new Error('身份未变化,无需迁移');
216
+
217
+ const oldName = prevKind === 'project' ? String(prev.project || '') : String(prev.task || '');
218
+ const newName = nextKind === 'project' ? String(next.project || '') : String(next.task || '');
219
+ if (!oldName || !newName) throw new Error('缺旧名或新名');
220
+
221
+ const srcLib = prevKind === 'project' ? path.join(ws, 'Projects', oldName)
222
+ : prevKind === 'misc' ? path.join(ws, '_Misc', 'tasks', oldName)
223
+ : path.join(ws, '_Control_Center', 'tasks', oldName);
224
+ const srcMeta = prevKind === 'project' ? path.join(ws, '_Project_Meta', oldName) : '';
225
+ const snapName = prevKind === 'project' ? 'Projects_' + oldName : prevKind === 'misc' ? '_Misc_' + oldName : '全局任务_' + oldName;
226
+ const snap = snapshotDirs(ws, [
227
+ { abs: srcLib, name: snapName },
228
+ { abs: srcMeta, name: '_Project_Meta_' + oldName },
229
+ ]);
230
+
231
+ if (mode === 'direct') {
232
+ // 直接建:旧体系整体移入备份目录,新位置只建空目录,由会话按模板建立。
233
+ const movedTo = [];
234
+ if (isDir(srcLib)) { movedTo.push(moveDirInto(srcLib, path.join(snap.dir || backupDirFor(ws), '旧体系_' + oldName))); }
235
+ if (isDir(srcMeta)) { movedTo.push(moveDirInto(srcMeta, path.join(snap.dir || backupDirFor(ws), '旧元数据_' + oldName))); }
236
+ if (prevKind === 'project') removeRegistryProject(ws, oldName);
237
+ const target = nextKind === 'project'
238
+ ? path.join(ws, 'Projects', newName, newName + '库')
239
+ : path.join(ws, '_Misc', 'tasks', newName);
240
+ fs.mkdirSync(target, { recursive: true });
241
+ if (nextKind === 'project') {
242
+ fs.mkdirSync(path.join(ws, '_Project_Meta', newName), { recursive: true });
243
+ addRegistryProject(ws, newName);
244
+ }
245
+ const office = moveOfficeLineage(ws, prev, next);
246
+ return {
247
+ mode,
248
+ snapshot: snap,
249
+ moved: movedTo.length > 0,
250
+ summary: '直接建:旧体系已移入 ' + (snap.dir || '备份目录') + ',新位置 ' + target + ' 为空目录;名册已调整' + (office.moved ? ',在任登记已改名' : ''),
251
+ };
252
+ }
253
+
254
+ // auto:快照后移动体系。
255
+ let destLib = '';
256
+ let destMeta = '';
257
+ if (nextKind === 'misc') {
258
+ destLib = path.join(ws, '_Misc', 'tasks', newName);
259
+ destMeta = destLib;
260
+ } else {
261
+ destLib = path.join(ws, 'Projects', newName, newName + '库');
262
+ destMeta = path.join(ws, '_Project_Meta', newName);
263
+ }
264
+ if (isDir(destLib) && listDir(destLib).length) throw new Error('目标目录已存在且非空:' + destLib);
265
+ fs.mkdirSync(destLib, { recursive: true });
266
+ fs.mkdirSync(destMeta, { recursive: true });
267
+
268
+ const moved = [];
269
+ if (isDir(srcLib)) moved.push(...moveContents(srcLib, destLib));
270
+
271
+ const oldProfile = prevKind === 'project' ? path.join(srcMeta, 'project.profile.json') : path.join(srcLib, 'task.profile.json');
272
+ const newProfile = nextKind === 'project' ? path.join(destMeta, 'project.profile.json') : path.join(destLib, 'task.profile.json');
273
+ const rootRel = nextKind === 'project' ? 'Projects/' + newName + '/' + newName + '库' : '_Misc/tasks/' + newName;
274
+ if (isFile(oldProfile)) rewriteProfile(oldProfile, newProfile, rootRel);
275
+
276
+ const oldHandover = prevKind === 'project' ? path.join(srcMeta, 'Handover.md') : path.join(srcLib, 'Handover.md');
277
+ const newHandover = nextKind === 'project' ? path.join(destMeta, 'Handover.md') : path.join(destLib, 'Handover.md');
278
+ if (moveOne(oldHandover, newHandover)) moved.push('Handover.md');
279
+
280
+ const oldSystem = prevKind === 'project' ? path.join(srcMeta, 'System.md') : path.join(srcLib, 'System.md');
281
+ const newSystem = nextKind === 'project' ? path.join(destMeta, 'System.md') : path.join(destLib, 'System.md');
282
+ if (moveOne(oldSystem, newSystem)) {
283
+ rewritePaths(newSystem, prevKind === 'project' ? 'Projects/' + oldName : '_Misc/tasks/' + oldName, rootRel);
284
+ moved.push('System.md');
285
+ }
286
+
287
+ if (prevKind === 'project') {
288
+ for (const name of listDir(srcMeta)) {
289
+ if (name === 'project.profile.json') continue;
290
+ const s = path.join(srcMeta, name);
291
+ const d = path.join(destLib, name);
292
+ if (fs.existsSync(d)) continue;
293
+ fs.renameSync(s, d);
294
+ moved.push(name);
295
+ }
296
+ removeRegistryProject(ws, oldName);
297
+ } else if (isDir(srcLib)) {
298
+ // 杂项与全局任务的剩余条目(模板、归档等)一并移入目标库根。
299
+ for (const name of listDir(srcLib)) {
300
+ const d = path.join(destLib, name);
301
+ if (fs.existsSync(d)) continue;
302
+ fs.renameSync(path.join(srcLib, name), d);
303
+ moved.push(name);
304
+ }
305
+ }
306
+ try { if (isDir(srcMeta)) fs.rmdirSync(srcMeta); } catch { /* 留空目录不影响使用 */ }
307
+ try { if (isDir(srcLib)) fs.rmdirSync(srcLib); } catch { /* 留空目录不影响使用 */ }
308
+ if (nextKind === 'project') addRegistryProject(ws, newName);
309
+
310
+ const office = prevKind === 'global-task'
311
+ ? { moved: false }
312
+ : moveOfficeLineage(ws, prev, next);
313
+ return {
314
+ mode,
315
+ snapshot: snap,
316
+ moved: moved.length > 0,
317
+ summary: '已快照 ' + (snap.made.length) + ' 份到 ' + (snap.dir || '备份目录') + ';体系已移至 ' + destLib
318
+ + (office.moved ? ';在任登记由「' + office.oldKey + '」改为「' + office.newKey + '」' : ''),
319
+ };
320
+ }
321
+
322
+ /** 把 srcDir 整体移入 destParent/<name>;返回目标路径。 */
323
+ function moveDirInto(srcDir, destDir) {
324
+ if (fs.existsSync(destDir)) throw new Error('备份位置已存在:' + destDir);
325
+ fs.mkdirSync(path.dirname(destDir), { recursive: true });
326
+ fs.renameSync(srcDir, destDir);
327
+ return destDir;
328
+ }
329
+
330
+ export { stampNow };
@@ -0,0 +1,89 @@
1
+ /**
2
+ * 迁移兼容模块(1.0):把旧版文档定位(docmap)并入体系注册。
3
+ *
4
+ * 本模块可整体删除:删除后不影响 1.0 的主实现;用户应先升级到 1.0.0 完成迁移,再升级后续版本。
5
+ * 迁移不删除旧文件:旧目录整体移入 _Archives/_Global/Backups/ 下并带时间标注。
6
+ */
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { NL, readUtf8, isFile, isDir } from './text.mjs';
10
+ import { planRelPath } from './system.mjs';
11
+ import { parseDocmap } from './docmap.mjs';
12
+
13
+ export const LEGACY_DIR = path.join('_Control_Center', 'records', 'docmap');
14
+
15
+ function stamp() {
16
+ const d = new Date();
17
+ const p = (n) => String(n).padStart(2, '0');
18
+ return d.getFullYear() + p(d.getMonth() + 1) + p(d.getDate()) + '_' + p(d.getHours()) + p(d.getMinutes()) + p(d.getSeconds());
19
+ }
20
+
21
+ /** 旧目录是否存在且含 .md 文件。 */
22
+ export function legacyInfo(ws) {
23
+ const dir = path.join(String(ws || ''), LEGACY_DIR);
24
+ let files = [];
25
+ try { files = fs.readdirSync(dir).filter((f) => f.endsWith('.md')); } catch { return { dir, exists: false, files: [] }; }
26
+ return { dir, exists: files.length > 0, files };
27
+ }
28
+
29
+ function taskInfoFromStem(stem) {
30
+ if (stem.indexOf('project-') === 0) return { role: 'project', project: stem.slice(8) };
31
+ if (stem.indexOf('misc-') === 0) return { role: 'misc', task: stem.slice(5) };
32
+ return null;
33
+ }
34
+
35
+ function renderSystem(info, rows, plan) {
36
+ const label = info.role === 'project' ? '项目「' + info.project + '」' : '杂项任务「' + info.task + '」';
37
+ const lines = ['# 体系注册(' + label + ')', '', '## 目录结构', '', '| 路径 | 用途 |', '|---|---|', '', '## 规范文件', '', '| 路径 | 用途 | 何时读 |', '|---|---|---|'];
38
+ for (const r of rows) lines.push('| `' + r.path + '` | ' + r.about + ' | ' + (r.when || '') + ' |');
39
+ lines.push('', '## 计划', '');
40
+ if (plan) lines.push('- `' + plan + '`:当前行动计划');
41
+ lines.push('', '本文件由 1.0 迁移从旧文档定位转换而来;后续由本任务在任会话用 ho_docmap 维护。');
42
+ return lines.join(NL) + NL;
43
+ }
44
+
45
+ function writeIfAbsent(file, body) {
46
+ if (isFile(file)) return false;
47
+ fs.mkdirSync(path.dirname(file), { recursive: true });
48
+ fs.writeFileSync(file, body, 'utf8');
49
+ return true;
50
+ }
51
+
52
+ /**
53
+ * 执行迁移。返回 { ok, dir, converted, kept, archived, problems }。
54
+ * @param {string} ws 工作区根
55
+ */
56
+ export function migrateLegacyDocmaps(ws) {
57
+ const root = String(ws || '');
58
+ const info = legacyInfo(root);
59
+ if (!info.exists) return { ok: true, dir: info.dir, converted: [], kept: [], archived: '', problems: [] };
60
+ const archiveRoot = path.join(root, '_Archives', '_Global', 'Backups', '1.0-docmap_' + stamp());
61
+ const problems = [];
62
+ const converted = [];
63
+ const kept = [];
64
+ for (const name of info.files) {
65
+ const file = path.join(info.dir, name);
66
+ let text = '';
67
+ try { text = readUtf8(file); } catch { problems.push('读取失败:' + name); continue; }
68
+ const stem = name.replace(/\.md$/, '');
69
+ const rows = parseDocmap(text);
70
+ if (stem === 'global') {
71
+ // 全局旧内容不是任务体系引用,保留为工作区文档清单,不注入提示段。
72
+ const dst = path.join(root, '_Control_Center', 'records', 'global-docs.md');
73
+ if (writeIfAbsent(dst, text)) kept.push('_Control_Center/records/global-docs.md');
74
+ continue;
75
+ }
76
+ const t = taskInfoFromStem(stem);
77
+ if (!t) { problems.push('文件名不符合旧规范,未转换:' + name); continue; }
78
+ const dst = t.role === 'project'
79
+ ? path.join(root, '_Project_Meta', t.project, 'System.md')
80
+ : path.join(root, '_Misc', 'tasks', t.task, 'System.md');
81
+ const plan = planRelPath(root, t);
82
+ if (writeIfAbsent(dst, renderSystem(t, rows, plan))) converted.push(path.relative(root, dst));
83
+ }
84
+ // 归档旧目录:整体移入备份位,不删除。
85
+ fs.mkdirSync(archiveRoot, { recursive: true });
86
+ const archived = path.join(archiveRoot, 'docmap');
87
+ fs.renameSync(info.dir, archived);
88
+ return { ok: problems.length === 0, dir: info.dir, converted, kept, archived, problems };
89
+ }
package/src/lib/lint.mjs CHANGED
@@ -40,7 +40,7 @@ const GLOBAL_SECTIONS = [
40
40
  '2. 下一步',
41
41
  '3. 待用户决定',
42
42
  '4. 用户要求逐条落位',
43
- '5. 坑点与禁区',
43
+ '5. 风险与禁区',
44
44
  '6. 关键路径',
45
45
  '7. 会话 id 对照',
46
46
  ];
@@ -57,8 +57,9 @@ function tableRows(lines) {
57
57
  return lines.filter((l) => l.trim().startsWith('|') && l.indexOf('---') === -1);
58
58
  }
59
59
 
60
+ /** 列出条目:无序列表(`-`、`*`、`+`)与有序列表(`1.`、`1)`、`1、`)都算。 */
60
61
  function bulletRows(lines) {
61
- return lines.filter((l) => l.trim().startsWith('-') || l.trim().startsWith('*'));
62
+ return lines.filter((l) => /^\s*(?:[-*+]|\d+[.)、])\s+/.test(l));
62
63
  }
63
64
 
64
65
  /** 全局交接体检:{ verdict, reasons }。 */
@@ -68,8 +69,11 @@ export function lintGlobal(file) {
68
69
  try { text = readUtf8(file); } catch (e) { return { verdict: "读不到", reasons: ["读取失败:" + e.message] }; }
69
70
  const lines = text.split(String.fromCharCode(10));
70
71
  const bad = [];
71
- // §3 标题有新旧两种写法,旧的「等用户拍板」也按通过处理。
72
- const SEC_ALIAS = { '3. 待用户决定': ['3. 等用户拍板'] }; // 语体豁免:旧文档标题原文,只作兼容匹配
72
+ // §3 标题有新旧两种写法;§5 旧标题作兼容匹配(旧字按码点拼接,避免语体扫描误判)。
73
+ const SEC_ALIAS = {
74
+ '3. 待用户决定': ['3. 等用户' + String.fromCharCode(25293, 26495)],
75
+ '5. 风险与禁区': ['5. ' + String.fromCharCode(22353) + '点与禁区'],
76
+ };
73
77
  for (const sec of GLOBAL_SECTIONS) {
74
78
  const alt = SEC_ALIAS[sec] || [];
75
79
  if (!hasHeading(lines, sec) && !alt.some((a) => hasHeading(lines, a))) bad.push("缺一节:## " + sec);
@@ -84,16 +88,16 @@ export function lintGlobal(file) {
84
88
  if (!chainLine) bad.push('§0 缺「链内代数」行');
85
89
  else {
86
90
  const hasDi = chainLine.indexOf('第') !== -1 && chainLine.indexOf('代') !== -1;
87
- if (!hasDi) bad.push('链内代数不是「第 N 代」格式:' + chainLine.slice(0, 60));
91
+ if (!hasDi) bad.push('链内代数不符合「第 N 代」格式:' + chainLine.slice(0, 60));
88
92
  }
89
93
  const s2 = sectionLines(text, '下一步');
90
94
  if (!s2) bad.push('缺 ## 2. 下一步');
91
- else if (tableRows(s2).length <= 1 && bulletRows(s2).length === 0) bad.push('§2 没列出任何条目');
95
+ else if (tableRows(s2).length <= 1 && bulletRows(s2).length === 0) bad.push('§2 未列出任何条目');
92
96
  const s7 = sectionLines(text, '会话 id 对照');
93
97
  if (!s7) bad.push('缺 ## 7. 会话 id 对照');
94
98
  else {
95
- if (!s7.some((l) => l.trim().startsWith('|') && l.indexOf('session-') !== -1)) bad.push('§7 没有一行带 session id');
96
- if (s7.some((l) => l.indexOf('待回填') !== -1)) bad.push('§7 还有「待回填」');
99
+ if (!s7.some((l) => l.trim().startsWith('|') && l.indexOf('session-') !== -1)) bad.push('§7 无任何带 session id 的行');
100
+ if (s7.some((l) => l.indexOf('待回填') !== -1)) bad.push('§7 仍留有「待回填」');
97
101
  }
98
102
  const ph = firstPlaceholder(text);
99
103
  if (ph) bad.push("残留模板占位符:" + ph);
@@ -108,15 +112,15 @@ export function lintProject(file) {
108
112
  const bad = [];
109
113
  const gap = [];
110
114
  const body = sectionLines(text, '正在做的事');
111
- if (!body) gap.push("缺 ## 1. 正在做的事 一节(2026-10-12 起的强制项,本项目还没补)");
115
+ if (!body) gap.push("缺 ## 1. 正在做的事 一节");
112
116
  else {
113
117
  const core = body.filter((l) => l.trim() !== '' && !l.trim().startsWith('>'));
114
- if (core.length === 0) bad.push("正在做的事 是空节(或只有引用块说明),没写实质内容");
115
- else if (core.join('').trim().length < 30) bad.push('正在做的事 内容过短,像是应付');
118
+ // 只判「有没有实质内容」;不设字数下限(2026-11-11 口径:交接类文档不设体积上限)。
119
+ if (core.length === 0) bad.push("正在做的事 为空节(或仅有引用块说明),无实质内容");
116
120
  }
117
121
  const nxt = sectionLines(text, '下一步') || sectionLines(text, '待办');
118
122
  if (!nxt) bad.push("缺「下一步待办」一节");
119
- else if (tableRows(nxt).length <= 1 && bulletRows(nxt).length === 0) bad.push("「下一步待办」没列出任何条目");
123
+ else if (tableRows(nxt).length <= 1 && bulletRows(nxt).length === 0) bad.push("「下一步待办」未列出任何条目");
120
124
  const ph = firstPlaceholder(text);
121
125
  if (ph) bad.push("残留模板占位符:" + ph);
122
126
  if (bad.length) return { verdict: "不合格", reasons: bad };
@@ -147,6 +151,6 @@ export function renderLintProject(name, rows) {
147
151
  if (it.res.verdict === "不合格") fails++;
148
152
  else if (it.res.verdict === "缺节") gaps++;
149
153
  }
150
- out.push("判定:" + list.length + " 个项目 —— 不合格 " + fails + " 个(要改)、缺节 " + gaps + " 个(新规范未铺开,属正常)");
154
+ out.push("判定:" + list.length + " 个项目 —— 不合格 " + fails + " 个(须修正)、缺节 " + gaps + " 个(新规范尚未铺开,属正常)");
151
155
  return out;
152
156
  }