ygc-dsh-sync 1.0.2 → 1.2.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/api.js CHANGED
@@ -23,7 +23,17 @@ import {
23
23
  scanLocalSessions,
24
24
  } from './backup.js';
25
25
  import { attachMany, attachedIds, findPulledUnattached, findUnattached } from './attach.js';
26
- import { ensureOwnFolder, joinMachineId, repairAll, scanFolders, splitMachineId } from './patrol.js';
26
+ import { joinMachineId, repairAll, scanFolders, splitMachineId } from './patrol.js';
27
+ import {
28
+ adoptFeature,
29
+ decisionOf,
30
+ dropFeature,
31
+ featurePath,
32
+ hasFeature,
33
+ inactiveReason,
34
+ planFeatures,
35
+ rememberDecision,
36
+ } from './features.js';
27
37
  import {
28
38
  LINEAGE_FILE,
29
39
  LOCAL_SKILLS_ROOT,
@@ -218,7 +228,21 @@ export function createApi(deps) {
218
228
  const matched = pickWorkspaces(all, state.folders);
219
229
  const localIndex = scanLocalSessions();
220
230
 
221
- const workspaces = matched.map((ws) => {
231
+ // 1.2.0:匹配的工作区里只把「有本机特征文件夹」的算作参与同步的;
232
+ // 其余的单独列一份「未参与」清单,面板上给原因和采纳按钮。
233
+ const active = matched.filter((ws) => hasFeature(ws.path, machineId));
234
+ const inactive = matched
235
+ .filter((ws) => !hasFeature(ws.path, machineId))
236
+ .map((ws) => ({
237
+ id: ws.id,
238
+ path: ws.path,
239
+ title: ws.title || ws.path,
240
+ featurePath: featurePath(ws.path, machineId),
241
+ declined: decisionOf(state, ws.path) === 'decline',
242
+ reason: inactiveReason(ws.path, machineId, state),
243
+ }));
244
+
245
+ const workspaces = active.map((ws) => {
222
246
  const machines = listMachines(ws.path);
223
247
  const own = machines.find((m) => m.machineId === machineId);
224
248
  const others = machines.filter((m) => m.machineId !== machineId);
@@ -227,6 +251,7 @@ export function createApi(deps) {
227
251
  path: ws.path,
228
252
  title: ws.title || ws.path,
229
253
  matchedPrefix: ws.matchedPrefix,
254
+ featurePath: featurePath(ws.path, machineId),
230
255
  own: own ? { sessions: own.sessions, bytes: own.bytes, newest: own.newest } : { sessions: 0, bytes: 0, newest: 0 },
231
256
  others: others.map((o) => ({
232
257
  machineId: o.machineId,
@@ -237,6 +262,20 @@ export function createApi(deps) {
237
262
  };
238
263
  });
239
264
 
265
+ // 待人工确认的采纳候选:未注册的匹配目录 + 升级时检出的存量工作区
266
+ const scan = scanFolders({
267
+ folders: state.folders,
268
+ workspaces: all,
269
+ machineId,
270
+ });
271
+ const plan = planFeatures({
272
+ folders: state.folders,
273
+ workspaces: all,
274
+ discover: scan.discover,
275
+ state,
276
+ machineId,
277
+ });
278
+
240
279
  json(res, 200, {
241
280
  ok: true,
242
281
  machineId,
@@ -247,6 +286,14 @@ export function createApi(deps) {
247
286
  lastBackupAt: state.lastBackupAt,
248
287
  lastPullAt: state.lastPullAt,
249
288
  patrol: state.patrol,
289
+ // 特征文件夹采纳:待人工确认清单 + 已有格子的工作区数
290
+ features: {
291
+ initialized: state.features.initialized === true,
292
+ pending: plan.pending,
293
+ pendingCount: plan.pending.length,
294
+ activeCount: active.length,
295
+ inactiveCount: inactive.length,
296
+ },
250
297
  skills: { ...state.skills, localRoot: LOCAL_SKILLS_ROOT },
251
298
  // 插件清单共享(1.0.0 从自动更新器迁来):配置 + 运行态 + 口径声明
252
299
  plugins: {
@@ -258,6 +305,8 @@ export function createApi(deps) {
258
305
  history: state.history.slice(0, 20),
259
306
  workspaceCount: all.length,
260
307
  matchedCount: workspaces.length,
308
+ matchedTotal: matched.length,
309
+ inactive,
261
310
  workspaces,
262
311
  totals: {
263
312
  ownSessions: workspaces.reduce((n, w) => n + w.own.sessions, 0),
@@ -309,13 +358,23 @@ export function createApi(deps) {
309
358
  });
310
359
  },
311
360
 
312
- /** 立即备份:全部匹配工作区,或指定 id 列表。 */
361
+ /** 立即备份:只备份「有本机特征文件夹」的工作区。 */
313
362
  'POST /backup': async (req, res) => {
314
363
  const body = await readJson(req);
315
364
  const state = loadState();
316
365
  const machineId = deps.machineId() || state.machineId;
317
366
  const all = listWorkspaces();
318
- const matched = pickWorkspaces(all, state.folders);
367
+ // 1.2.0:只对「特征文件夹存在」的工作区干活;没建格子的跳过并报原因
368
+ const matchedAll = pickWorkspaces(all, state.folders);
369
+ const matched = matchedAll.filter((w) => hasFeature(w.path, machineId));
370
+ const skipped = matchedAll
371
+ .filter((w) => !hasFeature(w.path, machineId))
372
+ .map((w) => ({
373
+ id: w.id,
374
+ path: w.path,
375
+ title: w.title || w.path,
376
+ reason: '没有本机特征文件夹(未参与备份)',
377
+ }));
319
378
  const chosen =
320
379
  Array.isArray(body.workspaceIds) && body.workspaceIds.length
321
380
  ? matched.filter((w) => body.workspaceIds.includes(w.id))
@@ -336,24 +395,41 @@ export function createApi(deps) {
336
395
  pushHistory(
337
396
  next,
338
397
  'backup',
339
- `备份 ${chosen.length} 个工作区:新增 ${created}、更新 ${updated}、${humanBytes(bytes)}`,
398
+ `备份 ${chosen.length} 个工作区:新增 ${created}、更新 ${updated}、${humanBytes(bytes)}` +
399
+ `${skipped.length ? `;跳过 ${skipped.length} 个没有本机特征文件夹的` : ''}`,
340
400
  );
341
401
  saveState(next);
342
402
 
343
403
  json(res, 200, {
344
404
  ok: true,
345
405
  results,
346
- summary: { workspaces: chosen.length, created, updated, bytes },
406
+ skipped,
407
+ summary: { workspaces: chosen.length, created, updated, bytes, skipped: skipped.length },
347
408
  });
348
409
  },
349
410
 
350
- /** 一键拉取:多选工作区。 */
411
+ /**
412
+ * 一键拉取:多选工作区。
413
+ *
414
+ * 1.2.0 起只拉「有本机特征文件夹」的工作区——没建格子的本机不参与同步,
415
+ * 往里拉会话等于替人做决定。拉完的会话归属登记是拉取的一部分,不再单列按钮:
416
+ * 只写文件不登记,DSH 侧栏只会把它显示成「未分组」。
417
+ */
351
418
  'POST /pull': async (req, res) => {
352
419
  const body = await readJson(req);
353
420
  const state = loadState();
354
421
  const machineId = deps.machineId() || state.machineId;
355
422
  const all = listWorkspaces();
356
- const matched = pickWorkspaces(all, state.folders);
423
+ const matchedAll = pickWorkspaces(all, state.folders);
424
+ const matched = matchedAll.filter((w) => hasFeature(w.path, machineId));
425
+ const skipped = matchedAll
426
+ .filter((w) => !hasFeature(w.path, machineId))
427
+ .map((w) => ({
428
+ id: w.id,
429
+ path: w.path,
430
+ title: w.title || w.path,
431
+ reason: '没有本机特征文件夹(未参与拉取)',
432
+ }));
357
433
  const chosen =
358
434
  Array.isArray(body.workspaceIds) && body.workspaceIds.length
359
435
  ? matched.filter((w) => body.workspaceIds.includes(w.id))
@@ -364,22 +440,33 @@ export function createApi(deps) {
364
440
  *
365
441
  * 这是「拉取后显示在未分组」最常见的原因之一:目录里有别的机器的
366
442
  * 会话备份,可它压根不在 workspaceRegistry 里,/pull 的匹配范围就没有它。
367
- * 用户点「一键拉取」本身就是明确意图,这里先注册再拉。
443
+ *
444
+ * 但它同时是「本机还没建特征文件夹」的目录 —— 1.2.0 起**不再自动注册**:
445
+ * 注册 + 建格子必须由人在面板上确认(POST /features/adopt)。
446
+ * 这里只把它们列在 unregistered 里交回去。
368
447
  */
369
- const autoRegister = body.autoRegister !== false;
448
+ const autoRegister = body.autoRegister === true;
370
449
  const registered = [];
371
450
  const registerErrors = [];
372
- if (autoRegister && typeof deps.registerWorkspace === 'function') {
451
+ const unregistered = [];
452
+ if (typeof deps.registerWorkspace === 'function') {
373
453
  const scan = scanFolders({
374
454
  folders: state.folders,
375
455
  workspaces: all,
376
456
  machineId,
377
457
  });
378
458
  for (const item of scan.discover) {
379
- // 只自动处理「确实有别的机器的备份」那些,别把空壳目录也注册进来
459
+ // 只关心「确实有别的机器的备份」那些,空壳目录不提
380
460
  const hasRemote = listMachines(item.path).some((m) => m.machineId !== machineId);
381
461
  if (!hasRemote) continue;
462
+ if (hasFeature(item.path, machineId)) continue;
463
+ if (!autoRegister) {
464
+ unregistered.push({ path: item.path, title: item.title, why: '需要先建本机特征文件夹' });
465
+ continue;
466
+ }
467
+ // 显式 autoRegister:true(供脚本/测试用),仍然要先建格子再注册
382
468
  try {
469
+ adoptFeature(item.path, machineId);
383
470
  const out = await deps.registerWorkspace(item.path, item.title);
384
471
  registered.push({ path: item.path, id: out?.id });
385
472
  all.push({ id: out?.id, path: item.path, title: item.title });
@@ -389,8 +476,8 @@ export function createApi(deps) {
389
476
  }
390
477
  }
391
478
 
392
- // 注册完重新匹配一次,把新注册的目录纳入拉取范围
393
- const matched2 = pickWorkspaces(all, state.folders);
479
+ // 注册完重新匹配一次,把新注册的目录纳入拉取范围(仍只含有格子的)
480
+ const matched2 = pickWorkspaces(all, state.folders).filter((w) => hasFeature(w.path, machineId));
394
481
  const chosen2 =
395
482
  Array.isArray(body.workspaceIds) && body.workspaceIds.length
396
483
  ? matched2.filter((w) =>
@@ -443,8 +530,10 @@ export function createApi(deps) {
443
530
  json(res, 200, {
444
531
  ok: true,
445
532
  results,
533
+ skipped,
446
534
  registered,
447
535
  registerErrors,
536
+ unregistered,
448
537
  summary: {
449
538
  workspaces: chosen2.length,
450
539
  imported,
@@ -453,27 +542,162 @@ export function createApi(deps) {
453
542
  attached,
454
543
  attachFailed,
455
544
  registered: registered.length,
545
+ skipped: skipped.length,
456
546
  },
457
547
  // 全部登记成功就不需要重开会话索引:会话列表是实时读盘 + 归属表的
458
548
  needsRestart: imported + forked > attached,
459
549
  });
460
550
  },
461
551
 
552
+ /**
553
+ * 采纳:给选中的目录建本机特征文件夹;未注册的顺带注册成工作区。
554
+ *
555
+ * 这是「检测到匹配的工作区文件夹 → 人工选择要不要在里面建本机特征文件夹」
556
+ * 的那个人工入口。同意之后立刻生效:进侧边栏、参与备份与拉取,
557
+ * 不需要再点一次注册。
558
+ */
559
+ 'POST /features/adopt': async (req, res) => {
560
+ const body = await readJson(req);
561
+ const state = loadState();
562
+ const machineId = deps.machineId() || state.machineId;
563
+ const wanted = Array.isArray(body.paths)
564
+ ? body.paths.filter((p) => typeof p === 'string' && p.trim())
565
+ : [];
566
+ if (!wanted.length) {
567
+ json(res, 400, { ok: false, error: '未选择任何目录' });
568
+ return;
569
+ }
570
+
571
+ const existing = new Set(listWorkspaces().map((w) => pathKey(w.path)));
572
+ const adopted = [];
573
+ const registeredList = [];
574
+ const errors = [];
575
+
576
+ for (const raw of wanted) {
577
+ const p = normSlash(raw);
578
+ try {
579
+ const target = adoptFeature(p, machineId);
580
+ rememberDecision(state, p, 'adopt');
581
+ let registeredId;
582
+ if (!existing.has(pathKey(p))) {
583
+ if (typeof deps.registerWorkspace === 'function') {
584
+ const out = await deps.registerWorkspace(p, path.basename(p));
585
+ registeredId = out?.id;
586
+ existing.add(pathKey(p));
587
+ registeredList.push({ path: p, id: registeredId });
588
+ }
589
+ } else {
590
+ registeredId = listWorkspaces().find((w) => pathKey(w.path) === pathKey(p))?.id;
591
+ }
592
+ adopted.push({ path: p, id: registeredId, feature: target });
593
+ } catch (err) {
594
+ errors.push({ path: p, message: String((err && err.message) || err) });
595
+ }
596
+ }
597
+
598
+ // 基线补上:刚采纳的立刻算「已知」,否则下一轮会被当成新建/缺失再处理一遍
599
+ if (!state.features) state.features = { initialized: true, decisions: {}, known: {} };
600
+ for (const item of adopted) {
601
+ if (item.id !== undefined && item.id !== null) state.features.known[String(item.id)] = item.path;
602
+ }
603
+ pushHistory(
604
+ state,
605
+ 'features',
606
+ `按人工选择建本机特征文件夹 ${adopted.length} 个${
607
+ registeredList.length ? `(其中注册工作区 ${registeredList.length} 个)` : ''
608
+ }`,
609
+ );
610
+ saveState(state);
611
+
612
+ json(res, 200, { ok: true, adopted, registered: registeredList, errors });
613
+ },
614
+
615
+ /** 拒绝:记录「这个目录本机不要建格子」,之后巡检不再反复建。 */
616
+ 'POST /features/decline': async (req, res) => {
617
+ const body = await readJson(req);
618
+ const state = loadState();
619
+ const wanted = Array.isArray(body.paths)
620
+ ? body.paths.filter((p) => typeof p === 'string' && p.trim())
621
+ : [];
622
+ if (!wanted.length) {
623
+ json(res, 400, { ok: false, error: '未选择任何目录' });
624
+ return;
625
+ }
626
+ for (const raw of wanted) rememberDecision(state, normSlash(raw), 'decline');
627
+ pushHistory(state, 'features', `跳过 ${wanted.length} 个目录(不建本机特征文件夹)`);
628
+ saveState(state);
629
+ json(res, 200, { ok: true, declined: wanted.map((p) => normSlash(p)) });
630
+ },
631
+
632
+ /**
633
+ * 删除:删本机特征文件夹(等于本机不再参与这个工作区的同步)。
634
+ *
635
+ * body.removeWorkspace=true 时连工作区注册一起去掉(侧边栏不再显示);
636
+ * 默认只删格子,保留工作区,因为「不想同步」和「不想在这里干活」是两件事。
637
+ */
638
+ 'POST /features/drop': async (req, res) => {
639
+ const body = await readJson(req);
640
+ const state = loadState();
641
+ const machineId = deps.machineId() || state.machineId;
642
+ const wanted = Array.isArray(body.paths)
643
+ ? body.paths.filter((p) => typeof p === 'string' && p.trim())
644
+ : [];
645
+ if (!wanted.length) {
646
+ json(res, 400, { ok: false, error: '未选择任何目录' });
647
+ return;
648
+ }
649
+ const removeWorkspace = body.removeWorkspace === true;
650
+ const all = listWorkspaces();
651
+ const dropped = [];
652
+ const errors = [];
653
+
654
+ for (const raw of wanted) {
655
+ const p = normSlash(raw);
656
+ try {
657
+ const out = dropFeature(p, machineId, { log });
658
+ if (!out.removed && out.reason !== '本来就没有') {
659
+ errors.push({ path: p, message: out.reason || '删除失败' });
660
+ continue;
661
+ }
662
+ rememberDecision(state, p, 'decline');
663
+ let removedWorkspace = false;
664
+ if (removeWorkspace && typeof deps.deleteWorkspace === 'function') {
665
+ const hit = all.find((w) => pathKey(w.path) === pathKey(p));
666
+ if (hit) {
667
+ await deps.deleteWorkspace(hit.id);
668
+ removedWorkspace = true;
669
+ }
670
+ }
671
+ for (const [id, knownPath] of Object.entries(state.features.known || {})) {
672
+ if (pathKey(knownPath) === pathKey(p)) delete state.features.known[id];
673
+ }
674
+ dropped.push({ path: p, removed: true, removedWorkspace });
675
+ } catch (err) {
676
+ errors.push({ path: p, message: String((err && err.message) || err) });
677
+ }
678
+ }
679
+
680
+ pushHistory(state, 'features', `删除本机特征文件夹 ${dropped.length} 个`);
681
+ saveState(state);
682
+ json(res, 200, { ok: true, dropped, errors });
683
+ },
684
+
462
685
  /**
463
686
  * 补登记:把「本插件拉进来、但没进归属账」的会话补进 sessionIds。
464
687
  *
465
688
  * 这是历史遗留数据的清理入口——旧版拉取只写文件不登记,
466
- * 那些会话至今仍散在「未分组」里。
689
+ * 那些会话至今仍散在「未分组」里。**日常不需要点**:/pull 拉完自己会登记。
467
690
  *
468
691
  * 默认只补拉取产物(别的机器备份目录里有它),不会把自动化插件和
469
692
  * 子代理留下的会话一起刷进来。要全量补必须显式传 all:true。
693
+ * 1.2.0 起只处理有本机特征文件夹的工作区。
470
694
  */
471
695
  'POST /attach/repair': async (req, res) => {
472
696
  const body = await readJson(req);
473
697
  const state = loadState();
474
698
  const machineId = deps.machineId() || state.machineId;
475
699
  const all = listWorkspaces();
476
- const matched = pickWorkspaces(all, state.folders);
700
+ const matched = pickWorkspaces(all, state.folders).filter((w) => hasFeature(w.path, machineId));
477
701
  const chosen =
478
702
  Array.isArray(body.workspaceIds) && body.workspaceIds.length
479
703
  ? matched.filter((w) => body.workspaceIds.includes(w.id))
@@ -527,27 +751,60 @@ export function createApi(deps) {
527
751
  });
528
752
  },
529
753
 
530
- /** 巡检:发现待注册工作区 + 缺本机特征文件夹的。只扫描,不改任何东西。 */
754
+ /**
755
+ * 巡检:只扫描,不动任何文件。
756
+ *
757
+ * 返回四类:
758
+ * discover 符合插件结构、但本机还没注册的目录(要人点)
759
+ * pending 需要人工决定「要不要在本机建特征文件夹」的(含 discover 与存量工作区)
760
+ * restore/drop 下一轮巡检会自动做的(外部删除自愈 / 随工作区删除)
761
+ * repair 已注册但缺格子——1.2.0 起不再自动补,归到 pending 让人点头
762
+ */
531
763
  'POST /patrol/scan': async (req, res) => {
532
764
  const state = loadState();
533
765
  const machineId = deps.machineId() || state.machineId;
766
+ const all = listWorkspaces();
534
767
  const result = scanFolders({
535
768
  folders: state.folders,
536
- workspaces: listWorkspaces(),
769
+ workspaces: all,
770
+ machineId,
771
+ });
772
+ const plan = planFeatures({
773
+ folders: state.folders,
774
+ workspaces: all,
775
+ discover: result.discover,
776
+ state,
537
777
  machineId,
538
778
  });
539
779
 
540
780
  const next = loadState();
541
- next.patrol = { ...next.patrol, lastRunAt: Date.now(), lastResult: {
542
- discover: result.discover.length,
543
- repair: result.repair.length,
544
- } };
781
+ next.patrol = {
782
+ ...next.patrol,
783
+ lastRunAt: Date.now(),
784
+ lastResult: {
785
+ discover: result.discover.length,
786
+ repair: result.repair.length,
787
+ pending: plan.pending.length,
788
+ },
789
+ };
545
790
  saveState(next);
546
791
 
547
- json(res, 200, { ok: true, ...result, machineId });
792
+ json(res, 200, {
793
+ ok: true,
794
+ ...result,
795
+ pending: plan.pending,
796
+ autoAdopt: plan.adopt,
797
+ autoRestore: plan.restore,
798
+ autoDrop: plan.drop,
799
+ machineId,
800
+ });
548
801
  },
549
802
 
550
- /** 注册:把选中的目录注册为工作区(用户确认后才调用)。 */
803
+ /**
804
+ * 注册:把选中的目录注册为工作区,并建本机特征文件夹。
805
+ *
806
+ * 与 /features/adopt 等价(保留旧路由给已有面板/脚本用)。
807
+ */
551
808
  'POST /patrol/register': async (req, res) => {
552
809
  const body = await readJson(req);
553
810
  const state = loadState();
@@ -569,26 +826,35 @@ export function createApi(deps) {
569
826
  for (const p of paths) {
570
827
  try {
571
828
  if (existing.has(pathKey(p))) {
572
- registered.push({ path: p, skipped: '已注册' });
829
+ // 已注册但可能缺格子:这次点了同意就等于确认建格子
830
+ const target = adoptFeature(p, machineId);
831
+ rememberDecision(state, p, 'adopt');
832
+ registered.push({ path: p, skipped: '已注册', own: target });
573
833
  continue;
574
834
  }
575
835
  const out = await deps.registerWorkspace(p, path.basename(p));
576
- // 注册完顺手补齐本机特征文件夹
577
- const target = ensureOwnFolder(p, machineId);
836
+ // 注册 + 建格子一起做:人点了同意,就该立刻参与
837
+ const target = adoptFeature(p, machineId);
838
+ rememberDecision(state, p, 'adopt');
839
+ if (out?.id !== undefined) state.features.known[String(out.id)] = normSlash(p);
578
840
  registered.push({ path: p, id: out?.id, created: !!out?.created, own: target });
579
841
  } catch (err) {
580
842
  errors.push({ path: p, message: String((err && err.message) || err) });
581
843
  }
582
844
  }
583
845
 
584
- const next = loadState();
585
- pushHistory(next, 'register', `注册 ${registered.length} 个工作区`);
586
- saveState(next);
846
+ pushHistory(state, 'register', `注册 ${registered.length} 个工作区(含建本机特征文件夹)`);
847
+ saveState(state);
587
848
 
588
849
  json(res, 200, { ok: true, registered, errors });
589
850
  },
590
851
 
591
- /** 补全:给缺本机特征文件夹的工作区补上。 */
852
+ /**
853
+ * 补建:给选中的工作区建本机特征文件夹。
854
+ *
855
+ * 1.2.0 起**不再**「发现问题就自动补」:只补传进来的 paths,
856
+ * 没传就什么都不做(历史上这里会对全部匹配工作区动手)。
857
+ */
592
858
  'POST /patrol/repair': async (req, res) => {
593
859
  const body = await readJson(req);
594
860
  const state = loadState();
@@ -596,12 +862,17 @@ export function createApi(deps) {
596
862
  const all = listWorkspaces();
597
863
  const targets = Array.isArray(body.paths) && body.paths.length
598
864
  ? all.filter((w) => body.paths.some((p) => pathKey(p) === pathKey(w.path)))
599
- : pickWorkspaces(all, state.folders);
865
+ : [];
600
866
 
601
867
  const out = repairAll(targets, machineId);
602
- const next = loadState();
603
- pushHistory(next, 'repair', `补全 ${out.created.length} 个特征文件夹`);
604
- saveState(next);
868
+ for (const p of body.paths || []) rememberDecision(state, p, 'adopt');
869
+ if (!state.features) state.features = { initialized: true, decisions: {}, known: {} };
870
+ for (const ws of targets) {
871
+ if (!hasFeature(ws.path, machineId)) continue;
872
+ state.features.known[String(ws.id)] = normSlash(ws.path);
873
+ }
874
+ pushHistory(state, 'repair', `补建 ${out.created.length} 个本机特征文件夹(人工指定)`);
875
+ saveState(state);
605
876
 
606
877
  json(res, 200, { ok: true, ...out });
607
878
  },
package/lib/core.js CHANGED
@@ -182,6 +182,23 @@ const DEFAULT_STATE = {
182
182
  lastResult: null,
183
183
  lastError: '',
184
184
  },
185
+ /**
186
+ * 本机特征文件夹的采纳状态(1.2.0 新增)。
187
+ *
188
+ * 判据永远是磁盘:`<工作区>/.dsh-sync/sessions/<本机标识>/` 存在 = 已采纳。
189
+ * 这里只记两样辅助信息:
190
+ * decisions 人对某个路径的表态(adopt / decline),防止每轮重复问;
191
+ * known 上一轮看到的工作区清单,用来判「本机新建」与「本机已删除」。
192
+ */
193
+ features: {
194
+ /** 1.2.0 首次运行是否已把「存量工作区」归过档:没有就一律问人,不自己建。 */
195
+ initialized: false,
196
+ /** 路径(小写归一)→ { action, path, at } */
197
+ decisions: {},
198
+ /** 工作区 id → 路径;只记已采纳过的 */
199
+ known: {},
200
+ lastPlanAt: 0,
201
+ },
185
202
  };
186
203
 
187
204
  export function loadState() {
@@ -196,6 +213,7 @@ export function loadState() {
196
213
  patrol: false,
197
214
  patrolInterval: false,
198
215
  plugins: false,
216
+ features: false,
199
217
  };
200
218
  try {
201
219
  const raw = fs.readFileSync(STATE_FILE, 'utf8');
@@ -209,6 +227,7 @@ export function loadState() {
209
227
  patrol: parsed.patrol !== undefined,
210
228
  patrolInterval: !!(parsed.patrol && parsed.patrol.intervalMinutes !== undefined),
211
229
  plugins: parsed.plugins !== undefined,
230
+ features: parsed.features !== undefined,
212
231
  };
213
232
  state = { ...state, ...parsed };
214
233
  // skills 与 patrol 需要深拷贝,否则后续就地修改会改到 parsed
@@ -220,6 +239,14 @@ export function loadState() {
220
239
  }
221
240
  if (parsed.patrol && typeof parsed.patrol === 'object') state.patrol = { ...parsed.patrol };
222
241
  if (parsed.plugins && typeof parsed.plugins === 'object') state.plugins = { ...parsed.plugins };
242
+ if (parsed.features && typeof parsed.features === 'object') {
243
+ state.features = {
244
+ initialized: parsed.features.initialized === true,
245
+ decisions: { ...(parsed.features.decisions || {}) },
246
+ known: { ...(parsed.features.known || {}) },
247
+ lastPlanAt: Number(parsed.features.lastPlanAt) || 0,
248
+ };
249
+ }
223
250
  if (!Array.isArray(state.folders)) state.folders = [];
224
251
  if (!Array.isArray(state.history)) state.history = [];
225
252
  } catch {
@@ -285,6 +312,19 @@ export function loadState() {
285
312
  state.plugins.lastResult && typeof state.plugins.lastResult === 'object' ? state.plugins.lastResult : null;
286
313
  state.plugins.lastError = typeof state.plugins.lastError === 'string' ? state.plugins.lastError.slice(0, 800) : '';
287
314
 
315
+ // 本机特征文件夹的采纳状态(1.2.0 新增;老 state 没有这一段)
316
+ if (!state.features || typeof state.features !== 'object' || Array.isArray(state.features)) {
317
+ state.features = { initialized: false, decisions: {}, known: {}, lastPlanAt: 0 };
318
+ }
319
+ state.features.initialized = state.features.initialized === true;
320
+ if (!state.features.decisions || typeof state.features.decisions !== 'object' || Array.isArray(state.features.decisions)) {
321
+ state.features.decisions = {};
322
+ }
323
+ if (!state.features.known || typeof state.features.known !== 'object' || Array.isArray(state.features.known)) {
324
+ state.features.known = {};
325
+ }
326
+ state.features.lastPlanAt = Number(state.features.lastPlanAt) || 0;
327
+
288
328
  if (!state.machineHashGeneratedAt) state.machineHashGeneratedAt = 0;
289
329
 
290
330
  /**
@@ -323,7 +363,8 @@ export function loadState() {
323
363
  !diskHas.modes ||
324
364
  !diskHas.mnemonAuto ||
325
365
  !diskHas.patrol ||
326
- !diskHas.patrolInterval;
366
+ !diskHas.patrolInterval ||
367
+ !diskHas.features;
327
368
 
328
369
  if (needsWrite) saveState(state);
329
370
  return state;