@a9i5k4/dsh-auto-memory 3.1.5 → 3.1.6

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 (92) hide show
  1. package/docs/CONTINUITY-FLOW.md +6 -6
  2. package/docs/HANDBOOK.md +54 -53
  3. package/docs/M-CM-PLAN.md +1 -1
  4. package/docs/M-CM-STATE.md +1 -1
  5. package/docs/M-CM7-HANDOFF-LAYERED-RETRIEVAL.md +1 -1
  6. package/docs/M3B-CONTRACT.md +7 -7
  7. package/docs/M7-AUTONOMOUS-STATE.md +1 -1
  8. package/docs/M7-TASKSET-DISPATCH.md +1 -1
  9. package/docs/PROMPT-PACK-LAYERED-RECALL.md +3 -3
  10. package/docs/PROMPT-SET-STRICT.md +3 -3
  11. package/docs/RELEASE-GO-NOGO.md +1 -1
  12. package/docs/STATUS-BOARD.md +1 -1
  13. package/docs/USER-GUIDE.en.md +21 -21
  14. package/docs/USER-GUIDE.zh-CN.md +21 -21
  15. package/docs/WHITEPAPER.md +1 -1
  16. package/docs/prompts/FIX-AGENT-P12-FULL-REGRESSION.md +1 -1
  17. package/docs/prompts/LIVE-VERIFY-ZCODE.md +2 -2
  18. package/docs/prompts/P2-semantic-recall.md +2 -2
  19. package/docs/prompts/P7-write-fix.md +1 -1
  20. package/docs/prompts/ZCODE-DROPIN.md +1 -1
  21. package/lib/acceptance.js +6 -6
  22. package/lib/activation-host.js +15 -14
  23. package/lib/activation-inbox-state.js +4 -4
  24. package/lib/activation-inbox.js +44 -44
  25. package/lib/board-mode.js +3 -3
  26. package/lib/client.js +193 -34
  27. package/lib/config-io.js +12 -12
  28. package/lib/context-bridge.js +22 -22
  29. package/lib/context-host.js +24 -23
  30. package/lib/datadir.js +95 -0
  31. package/lib/degrade.js +21 -21
  32. package/lib/dsh-home.js +8 -8
  33. package/lib/engine-identity.js +12 -12
  34. package/lib/engine-switch.js +3 -3
  35. package/lib/episodic-store.js +13 -13
  36. package/lib/evidence-agg.js +4 -4
  37. package/lib/evidence-store.js +12 -12
  38. package/lib/fact-store.js +47 -47
  39. package/lib/handoff-anchor.js +4 -4
  40. package/lib/hub-io.js +356 -3
  41. package/lib/index-sync.js +5 -5
  42. package/lib/index.js +381 -236
  43. package/lib/intent-clean-safe.js +3 -3
  44. package/lib/intent-clean.js +1 -1
  45. package/lib/l0-extract.js +15 -15
  46. package/lib/l0-index-sync.js +8 -8
  47. package/lib/l0-index.js +11 -11
  48. package/lib/ledger-criteria.js +8 -8
  49. package/lib/m4-corpus.js +1 -1
  50. package/lib/m7-index-sync-host.js +2 -2
  51. package/lib/m7-wire.js +24 -24
  52. package/lib/memory-anchor.js +1 -1
  53. package/lib/memory-envelope.js +15 -15
  54. package/lib/memory-hub.js +95 -50
  55. package/lib/memory-importance.js +7 -7
  56. package/lib/memory-mutation.js +5 -5
  57. package/lib/note-status-apply.js +2 -2
  58. package/lib/note-status.js +7 -7
  59. package/lib/policies/activation_policy_v2.json +2 -2
  60. package/lib/policies/recall_intent_lr_v1.json +1 -1
  61. package/lib/procedure-observation.js +2 -2
  62. package/lib/procedure-store.js +117 -28
  63. package/lib/python-setup.js +6 -5
  64. package/lib/python-sidecar-client.js +26 -26
  65. package/lib/recall-fusion.js +12 -12
  66. package/lib/recall-stats.js +36 -1
  67. package/lib/rerank-host.js +11 -11
  68. package/lib/rules-edit.js +9 -9
  69. package/lib/rules-layer.js +26 -26
  70. package/lib/semantic-decide.js +7 -7
  71. package/lib/semantic-js.js +13 -13
  72. package/lib/shadow-host.js +7 -6
  73. package/lib/shadow-retrieval.js +40 -40
  74. package/lib/skill-export-host.js +7 -7
  75. package/lib/skill-export.js +6 -6
  76. package/lib/state-commit.js +10 -10
  77. package/lib/storage-manage.js +6 -6
  78. package/lib/tier-layer-inject.js +28 -28
  79. package/lib/tier0-catalog.js +4 -4
  80. package/lib/wb-contract.js +45 -45
  81. package/lib/wb-sidecar.js +11 -11
  82. package/package.json +3 -4
  83. package/python/m7_activation_features_v2.py +7 -7
  84. package/python/m7_embedding_v1.py +12 -12
  85. package/python/policies/activation_policy_v2.json +2 -2
  86. package/python/policies/decision-record-activation-v2-delta-exp-override-20260824.json +1 -1
  87. package/python/policies/decision-record-reasoning-kind-admission-20260826.json +2 -2
  88. package/python/policies/decision-record-stale-gate-per-candidate-20260825.json +2 -2
  89. package/python/policies/recall_intent_lr_v1.json +1 -1
  90. package/python/verify_policy_artifact.py +3 -3
  91. package/python/worker_semantic_v1.py +18 -18
  92. package/python/worker_v1.py +18 -18
package/lib/board-mode.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * board-mode-pre —— WB-GRAPH 白板线总开关(2026-09-16, board_mode_v1)。
2
+ * board-mode-pre —— WB-GRAPH 白板线总开关(2026-09-16, board_mode_pre_v1)。
3
3
  *
4
4
  * 裁定来源: WB-GRAPH-DECISIONS-20260914.md §E + 用户 2026-09-16 凌晨原话:
5
5
  * 「一口气全做完,但是线先别着急接。可以先在设置里,或者在接续面板上设置一个按钮,一键切换旧版和新版。」
@@ -13,8 +13,8 @@
13
13
  * - fail closed: 非法值一律按 legacy(旧行为), 绝不猜。
14
14
  */
15
15
 
16
- export const BOARD_MODE_VERSION = 'board_mode_v1'
17
- export const BOARD_MODES_V1 = Object.freeze(['legacy', 'graph'])
16
+ export const BOARD_MODE_VERSION = 'board_mode_pre_v1'
17
+ export const BOARD_MODES_PRE_V1 = Object.freeze(['legacy', 'graph'])
18
18
 
19
19
  /**
20
20
  * 解析白板模式。非法/缺省 → legacy(旧行为, fail closed)。
package/lib/client.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * 3. settings.section — 自动记忆设置页(存储位置、注入、反思风格)
8
8
  * Data flows over /api/dsh-auto-memory/* (loopback-only host routes).
9
9
  */
10
- console.log('[dsh-auto-memory] client v3.1.5 fingerprint: tail-brace-neutralize')
10
+ console.log('[dsh-auto-memory] client v3.1.6 fingerprint: config-migration-emergency-fix')
11
11
  window.__ModuleLoader__.load({
12
12
  id: '@a9i5k4/dsh-auto-memory',
13
13
  factory: (require) => {
@@ -265,7 +265,7 @@ window.__ModuleLoader__.load({
265
265
  fReasoning: '思维链监听', fReasoningHint: '把模型思维链纳入实时观测(重启后生效)。默认开——闭源模型的概括式思维链同样纳入。',
266
266
  fChildObs: '分支会话观测', fChildObsHint: '跨天续接的会话会被标记为分支;开启后同样纳入记忆观测。默认开。',
267
267
  fTauHi: '唤起门槛 tauHi', fDeltaExp: '明确召回余量 deltaExp', fDeltaPro: '主动预取余量 deltaPro',
268
- fTuningHint: '阈值由校准策略 JSON 权威控制;此处为高级调参入口,修改保存后随下轮生效。',
268
+ fTuningHint: '阈值由校准策略 JSON 权威控制;此处为高级调参入口,修改保存后随下轮生效(预览版)。',
269
269
  refineTitle: '唤起记录与语料精修', refineSub: '对每次唤起判断给出你的裁定(A 该激活/P 只预取/S 应抑制/H 有害/E 改目标),审批队列将用于离线重放与策略演进。',
270
270
  refineEmpty: '(暂无唤起记录——需要 shadow 观测产生数据)', refineLoadErr: '加载失败: ',
271
271
  sent1: '已入审批队列', semReady: '内置语义已就绪', semMissing: '语义包未下载(约130MB)', semStatusErr: '状态未知',
@@ -325,6 +325,33 @@ window.__ModuleLoader__.load({
325
325
  hubStageDeprecated: '已弃用',
326
326
  hubWhyTitle: '为什么还不能晋升:',
327
327
  hubWhyCanPromote: '条件已满足,可以晋升。',
328
+ // ★M8-B 第6步(2026-09-23):双库视图(工作区库 / 通用库)。
329
+ hubScopeTitle: '技能库归属',
330
+ hubScopeGlobal: '通用库',
331
+ hubScopeWorkspace: '本工作区库',
332
+ hubScopeGlobalHint: '所有项目都能用(跨工作区共享)。',
333
+ hubScopeWorkspaceHint: '只有当前工作区能用。',
334
+ hubScopeDefault: '缺省落库:通用库',
335
+ hubScopeUnknownWs: '当前工作区未知 —— 暂不能收纳到工作区库(转移会明确报错,不会猜目录写入)。',
336
+ hubScopeCounts: function (g, w) {
337
+ var gs = (g < 0) ? '不可读' : (g + ' 条')
338
+ var ws = (w < 0) ? '不可归属' : (w + ' 条')
339
+ return '通用库 ' + gs + ' · 本工作区库 ' + ws
340
+ },
341
+ hubScopeCorrupt: '有库文件解析失败 —— 已拒写该库(避免静默清空),请先修复或备份。',
342
+ hubToGlobal: '提升为全局',
343
+ hubToWorkspace: '收纳到本工作区',
344
+ hubScopeBusy: '转移中…',
345
+ hubScopeMoved: '已转移:',
346
+ hubScopeNoop: '已在目标库(无需搬运)。',
347
+ hubScopeReasons: function (r) {
348
+ if (r === 'ws-unknown') return '当前工作区未知,无法写入工作区库。'
349
+ if (r === 'foreign-workspace') return '该条目属于别的工作区,不能直接搬到本工作区。'
350
+ if (r === 'not-found') return '两库中都没找到这条技能。'
351
+ if (r === 'phase-failed') return '两阶段提交失败,已回滚两库到转移前状态。'
352
+ if (r === 'scoped-io-unavailable') return '宿主未提供双库 IO,本次无法转移。'
353
+ return String(r || '未知原因')
354
+ },
328
355
  hubWhyDiversity: function (d, need) { return '需要 ' + need + ' 个不同会话都用过它,目前只有 ' + d + ' 个 —— 换个场景再用一次它就能推进。' },
329
356
  hubWhySuccess: function (n, need) { return '需要成功 ' + need + ' 次,目前 ' + n + ' 次 —— 再成功用一次它会自动变好。' },
330
357
  hubWhyCorrectionRate: function (r, cap) { return '它的纠正率是 ' + r + ',超过了上限 ' + cap + ' —— 说明这套流程本身有问题,需要先改掉。' },
@@ -563,6 +590,33 @@ window.__ModuleLoader__.load({
563
590
  hubStageDeprecated: 'deprecated',
564
591
  hubWhyTitle: 'Why it cannot be promoted yet: ',
565
592
  hubWhyCanPromote: 'All conditions met — ready to promote.',
593
+ // ★M8-B step 6 (2026-09-23): dual-library view (workspace / global).
594
+ hubScopeTitle: 'Skill library ownership',
595
+ hubScopeGlobal: 'Global library',
596
+ hubScopeWorkspace: 'This workspace',
597
+ hubScopeGlobalHint: 'Usable from every project (shared across workspaces).',
598
+ hubScopeWorkspaceHint: 'Only usable in the current workspace.',
599
+ hubScopeDefault: 'Default library: global',
600
+ hubScopeUnknownWs: 'Current workspace unknown — cannot move into the workspace library yet (a transfer fails explicitly instead of guessing a directory).',
601
+ hubScopeCounts: function (g, w) {
602
+ var gs = (g < 0) ? 'unreadable' : (g + '')
603
+ var ws = (w < 0) ? 'n/a' : (w + '')
604
+ return 'global ' + gs + ' · this workspace ' + ws
605
+ },
606
+ hubScopeCorrupt: 'A library file failed to parse — writes to it are refused (to avoid silently wiping it). Repair or back it up first.',
607
+ hubToGlobal: 'Promote to global',
608
+ hubToWorkspace: 'Move into workspace',
609
+ hubScopeBusy: 'Moving…',
610
+ hubScopeMoved: 'Moved: ',
611
+ hubScopeNoop: 'Already in the target library (nothing to move).',
612
+ hubScopeReasons: function (r) {
613
+ if (r === 'ws-unknown') return 'Current workspace is unknown, so the workspace library cannot be written.'
614
+ if (r === 'foreign-workspace') return 'This entry belongs to another workspace and cannot be moved into this one.'
615
+ if (r === 'not-found') return 'Not found in either library.'
616
+ if (r === 'phase-failed') return 'Two-phase commit failed; both libraries were rolled back to their pre-move state.'
617
+ if (r === 'scoped-io-unavailable') return 'Host provides no dual-library IO, so the move is unavailable.'
618
+ return String(r || 'unknown reason')
619
+ },
566
620
  hubWhyDiversity: function (d, need) { return 'Needs ' + need + ' distinct sessions, currently ' + d + '.' },
567
621
  hubWhySuccess: function (n, need) { return 'Needs ' + need + ' successes, currently ' + n + '.' },
568
622
  hubWhyCorrectionRate: function (r, cap) { return 'Correction rate ' + r + ' exceeds cap ' + cap + '.' },
@@ -771,11 +825,11 @@ window.__ModuleLoader__.load({
771
825
  // (设置页「提示词层级」展示的就是这份镜像,漂移会让用户看到的与实际注入不符)。
772
826
  snapshotInscription: '[铭文 · 每轮提醒 {date}]\n'
773
827
  + '【收尾自检】本轮有实质产出才做;纯只读/闲聊轮跳过。三个方向 + 分类别丢:\n'
774
- + '① 写记忆文件 memory_log —— kind 按性质选,**不要一律 fact**:rule=用户约束/约定(会被规则层当硬约束注入)、preference=偏好、fact=事实记录、todo=待办;\n'
775
- + '② 更新白板与账本 —— ★**硬映射(满足即必须做,别自行判断"算不算"变化)**:\n · 本轮**改过 lib/ 下任何文件**(含测试/工具) ⇒ 必写 **memory_note(kind=handoff)** 四段式账本;\n · 白板"当前进度"与本轮结束时的**事实不符**(回归数字/已完成项/下一步) ⇒ 必做 **memory_note(kind=plan)** 重写白板;\n · 仅补充一条可复用结论 ⇒ 才用 **memory_note(kind=note, action=append)**。\n ⚠️ **kind=note 不等于白板/账本**——只写 note 却宣称"已更新白板与账本"是**失职**;write 直接写 docs/ 的 md 也**不算**插件记忆。\n'
776
- + '③ 长期记忆判断(三个去处)—— 跨会话有用的 → memory_note / 跨项目规则 → memory_user / **规则条目增删改(用户级硬约束,含过时条目真删)→ memory_rules** / **跑通且可复用的多步流程 → memory_procedure**(技能库唯一模型入口,不写就没有;建议填 successCriteria);\n'
777
- + '④ 结论失效/被取代 —— 传 `supersedes=` 旧条目 mem_id 标 **superseded(被更新结论取代)**;**当时就做错了要撤回**则传 `retract=` 标 **retracted(撤回)**并尽量附 `retractReason=` 说明错在哪(两者不同:前者有后继,后者本身就是教训);标错了用 `restore=` 撤回;\n'
778
- + '⑤ 看板落列 —— 白板/账本内容要进面板看板泳道,须在标题或正文写 tag:`type:goal` / `type:state` / `type:dead-end` / `type:progress`(5 条泳道含「版本归档」,靠归档动作而非 tag);不写 tag 系统只能按标题文字猜,常落空;\n'
828
+ + '① 写记忆文件 memory_log —— kind 按性质选,**不要一律 fact**:rule=用户约束/约定、preference=偏好、fact=事实记录、todo=待办;\n'
829
+ + '② 更新白板与账本 —— ★**硬映射(满足即必须做,别自行判断"算不算"变化)**:\n · 本轮**改过 lib/ 下任何文件**(含测试/工具) ⇒ 必写 **memory_note(kind=handoff)** 四段式账本;\n · 白板"当前进度"与本轮结束时的**事实不符**(回归数字/已完成项/下一步) ⇒ 必做 **memory_note(kind=plan)** 重写白板;\n · 仅补充一条可复用结论 ⇒ 才用 **memory_note(kind=note, action=append)**。\n ⚠️ **kind=note 不等于白板/账本**——只写 note 不得宣称"已更新白板与账本"(失职);write 直接写 docs/ 的 md 不算插件记忆。\n'
830
+ + '③ 长期记忆判断(三个去处)—— 跨会话有用的 → memory_note / 跨项目规则 → memory_user / **规则条目增删改(用户级硬约束,含过时条目真删)→ memory_rules** / **跑通且可复用的多步流程 → memory_procedure**(技能库入口;建议填 successCriteria;**写前先用 `memory_procedure_list` 查重**:只读,能看它属哪个库、为何未晋升);\n'
831
+ + '④ 结论失效/被取代 —— 传 `supersedes=` 旧条目 mem_id 标 **superseded(被更新结论取代)**;**当时就做错了要撤回**则传 `retract=` 标 **retracted(撤回)**并附 `retractReason=` 说明错在哪(前者有后继;后者即教训);标错了用 `restore=` 撤回;\n'
832
+ + '⑤ 看板落列 —— 白板/账本内容要进面板看板泳道,须在标题或正文写 tag:`type:goal` / `type:state` / `type:dead-end` / `type:progress`(含「版本归档」泳道);不写则系统按标题猜,常落空;\n'
779
833
  + '⑥ 语体 —— 客观陈述、第三人称,只留可复用的事实/决策/规则/路径(不写"我考虑/我排查/我想")。',
780
834
  snapshotTail: '</memory_system>',
781
835
  }
@@ -806,6 +860,21 @@ window.__ModuleLoader__.load({
806
860
 
807
861
  // ───────────────────────── 更新弹窗 / 首次指导 ─────────────────────────
808
862
  var CHANGELOG = {
863
+ '3.1.6': { zh: [
864
+ '★★ 紧急修复:配置迁移自 3.1.0 起从未真正执行——宿主每次加载都因内部变量作用域错误抛异常,而异常被兜底逻辑静默吞掉。结果是"你保存过的设置会变回默认值"(注入预算/记忆根目录/看板模式/容量上限等),且日志里没有任何报错,极易被误判成"自己没配"。',
865
+ '现在配置改名与补齐已改到唯一的加载汇聚点,并强制走损坏隔离读。升级后你此前的设置会从旧配置文件自动补回来,无需手动重配。',
866
+ '重要:本版修的是宿主加载期代码,**必须重启 dsh web 才生效**。',
867
+ '同时修复:记忆中枢的数据目录(hub)曾因一个预先存在的空壳目录被判为"已存在"而跳过迁移,导致 56 条技能与 9.3 MB 事实在重启后不可见;现改为逐文件语义补齐,绝不覆盖真实数据。',
868
+ '内部重构:全仓"删净 pre"——源码名即发布名,发布构建退化为纯复制;配置文件名与前端本地存储键一并改名,均带旧名兼容读与首次迁移,你的主题/字体/面板几何不丢。',
869
+ '构建线修复三处(回归测不到、只有真跑构建才暴露):过渡垫片会被覆盖到真实模块上(整包报废)、上游回流清单写旧名导致闸门拒构建、残留闸门引用了已删除的变量导致构建崩溃。',
870
+ ], en: [
871
+ '★★ Emergency fix: config migration has never actually run since 3.1.0 — the host threw an internal scope error on every load and the fail-soft handler silently swallowed it. Result: "settings you saved silently revert to defaults" (inject budget, memory root, board mode, capacity caps), with no error in the logs, easily misread as "I never configured it".',
872
+ 'Migration now lives at the single load choke point and goes through the corruption-quarantine reader. On upgrade, your previous settings are merged back from the legacy config file automatically.',
873
+ 'Important: this fixes host load-time code, so **dsh web must be restarted** to take effect.',
874
+ 'Also fixed: the memory-hub data directory was skipped because a pre-existing empty shell dir looked like a valid target, hiding 56 skills and 9.3 MB of facts after restart; now files are filled in semantically and real data is never overwritten.',
875
+ 'Internal refactor: full "de-pre" — source names are now the published names and the release build is a plain copy; config filename and frontend localStorage keys were renamed with legacy fallback reads and first-read migration, so your theme/fonts/panel geometry are preserved.',
876
+ 'Three build-line fixes that the test suite cannot catch (only a real build exposes them): shim files overwriting real modules (would ship a broken package), an upstream-reconcile manifest still listing old names (gate refused to build), and a residual gate referencing a deleted variable (build crashed).',
877
+ ] },
809
878
  '3.1.5': { zh: [
810
879
  '★ 修复:记忆正文里出现半角双花括号(例如记下「Vue 模板用 {{ count }} 做插值」),会让该会话之后**每一次模型请求全部失败**。现在渲染尾注前会先把它们换成同形全角字符,不再触发宿主插值抛错。',
811
880
  '说明:代价是这类括号在注入文本里会显示成全角形({{ count }}),内容不丢、含义不变。',
@@ -1351,7 +1420,26 @@ window.__ModuleLoader__.load({
1351
1420
 
1352
1421
  // ───────────────────────── API ─────────────────────────
1353
1422
  // 路由前缀唯一常量(2026-09-10 第 0 步止血):表中每条路径由它拼出,不再逐个重复全串。
1354
- var ROUTE_PREFIX = '/api/dsh-auto-memory'
1423
+ // ★去 pre(2026-09-23):localStorage 键 dsh-auto-memory-pre.* → dsh-auto-memory.*。
1424
+ // 一次性迁移:首次载入把旧前缀键搬到新前缀(字体/主题/面板几何/引导进度等不丢)。
1425
+ // 幂等(搬完删旧键)+ fail-soft(任何异常都不得阻断前端启动)。
1426
+ ;(function migrateLegacyLsKeys() {
1427
+ try {
1428
+ var OLD_LS = 'dsh-auto-memory-pre.', NEW_LS = 'dsh-auto-memory.'
1429
+ var doomed = []
1430
+ for (var li = 0; li < localStorage.length; li++) {
1431
+ var lk = localStorage.key(li)
1432
+ if (lk && lk.indexOf(OLD_LS) === 0) doomed.push(lk)
1433
+ }
1434
+ for (var lj = 0; lj < doomed.length; lj++) {
1435
+ var nk = NEW_LS + doomed[lj].slice(OLD_LS.length)
1436
+ if (localStorage.getItem(nk) === null) localStorage.setItem(nk, localStorage.getItem(doomed[lj]))
1437
+ localStorage.removeItem(doomed[lj])
1438
+ }
1439
+ if (doomed.length) console.info('[dsh-auto-memory] migrated ' + doomed.length + ' legacy localStorage keys')
1440
+ } catch (e) { /* fail-soft */ }
1441
+ })();
1442
+ var ROUTE_PREFIX = '/api/dsh-auto-memory'
1355
1443
  // 宿主半边(lib/index.js 的 API)声明的是同一批路径 —— 两个半边是独立 bundle(本文件经 __ModuleLoader__ 手写加载,
1356
1444
  // 无法 import 宿主模块),所以"只有一份"这件事由 tests/smoke/ 下的路径表一致性锁保证:
1357
1445
  // 客户端路径 ⊆ 宿主路径、宿主独有路径只允许显式白名单、本文件表外不得再出现裸路径字面量。
@@ -3732,12 +3820,70 @@ window.__ModuleLoader__.load({
3732
3820
  })
3733
3821
  .catch(function (e) { setActMsg(action + ' failed: ' + String(e && e.message)) })
3734
3822
  }
3823
+ // ★M8-B 第6步(2026-09-23):归属转移(提升为全局 / 收纳到工作区)。
3824
+ // 与 hubAct 分开:动作名与参数不同(scope 而非 v),且必须把 `migrate()` 的逐条结果
3825
+ // (含 rolledBack / reason)显示出来 —— 两阶段提交的失败是要让人看见的,
3826
+ // 静默成 "done" 等于把回滚藏起来,正是用户列为「功能坏了」的那种表现。
3827
+ function hubScopeTransfer(procedureId, toScope) {
3828
+ setActMsg(t('hubScopeBusy'))
3829
+ fetch(API.memoryHub, { method: 'POST',
3830
+ headers: { 'Content-Type': 'application/json' },
3831
+ body: JSON.stringify({ action: 'transfer-scope', procedureId: procedureId, scope: toScope }) })
3832
+ .then(function (r) { return r.json() })
3833
+ .then(function (j) {
3834
+ var one = (j && Array.isArray(j.results) && j.results[0]) || {}
3835
+ if (j && j.ok && one.ok) {
3836
+ setActMsg(t('hubScopeMoved') + (toScope === 'workspace' ? t('hubScopeWorkspace') : t('hubScopeGlobal')) + (one.noop ? '(' + t('hubScopeNoop') + ')' : ''))
3837
+ } else {
3838
+ // 失败必须带原因 + 是否已回滚;两者都不许省略。
3839
+ setActMsg(t('hubScopeReasons')(one.reason || (j && j.reason) || (j && j.error))
3840
+ + (j && j.rolledBack ? ' ⚠ ' + t('hubScopeReasons')('phase-failed') : ''))
3841
+ }
3842
+ setRefresh(function (x) { return x + 1 })
3843
+ })
3844
+ .catch(function (e) { setActMsg(t('hubScopeReasons')(String(e && e.message))) })
3845
+ }
3735
3846
  if (err) return h('div', { 'data-dam-hint': '' }, t('searchFailed') + err)
3736
3847
  if (!data) return h(Loading, { label: t('loading') })
3737
3848
  var rows = []
3738
3849
  var procs = data.procedures
3739
3850
  var facts = data.facts
3740
3851
  var epis = data.episodic
3852
+ // ★M8-B 第6步(2026-09-23):**双库视图** —— 回答用户三问:
3853
+ // ① 这条技能属于哪个库?(归属徽标,逐条)
3854
+ // ② 当前是哪个库、各库几条、工作区库现在能不能写?(本区块头部)
3855
+ // ③ 怎么改判归属?(提升为全局 / 收纳到本工作区,逐条按钮)
3856
+ // 数据源 = 宿主 /memory-hub 的 `scopeView`(只读、无真实路径)+ 每行的 scope 字段。
3857
+ // 工作区未知时按钮仍显示但**点击会明确报错**(宿主 migrate 返回 ws-unknown),
3858
+ // 比灰掉按钮更能说明「为什么不行」—— 与 hubScopeUnknownWs 文案配套。
3859
+ var sv = data.scopeView
3860
+ if (sv) {
3861
+ var svHint = (sv.workspaceReady ? t('hubScopeWorkspaceHint') : t('hubScopeUnknownWs'))
3862
+ + ' ' + (sv.globalDirCorrupt || sv.workspaceDirCorrupt ? '⚠ ' + t('hubScopeCorrupt') : '')
3863
+ rows.push(h(Card, { title: t('hubScopeTitle') },
3864
+ h('div', { 'data-dam-content': '' },
3865
+ h('div', null, h('b', null, t('hubScopeDefault')), h('span', { 'data-dam-hint': '', style: { marginLeft: '6px' } }, t('hubScopeCounts')(sv.globalCount, sv.workspaceCount))),
3866
+ h('div', { 'data-dam-hint': '', style: { marginTop: '3px', opacity: .8 } }, svHint))))
3867
+ }
3868
+ // 归属徽标:通用库 / 本工作区库。缺省 global 由宿主投影补齐,这里不再兜判。
3869
+ function scopeBadge(p) {
3870
+ var isWs = p && p.scope === 'workspace'
3871
+ return h('span', {
3872
+ 'data-dam-scope': isWs ? 'workspace' : 'global',
3873
+ 'data-dam-hint': '',
3874
+ title: isWs ? t('hubScopeWorkspaceHint') : t('hubScopeGlobalHint'),
3875
+ style: { marginLeft: '6px', opacity: .85 },
3876
+ }, '[' + (isWs ? t('hubScopeWorkspace') : t('hubScopeGlobal')) + (p && p.addedBy ? ' · ' + p.addedBy : '') + ']')
3877
+ }
3878
+ // 转移按钮:目标库 = 与当前归属相反的那个。已在目标库时宿主会回 noop(幂等)。
3879
+ function scopeButtons(p) {
3880
+ var isWs = p && p.scope === 'workspace'
3881
+ return h('button', {
3882
+ 'data-dam-btn': '', 'data-dam-scope-to': isWs ? 'global' : 'workspace',
3883
+ style: { fontSize: 'calc(10.5px * var(--dam-scale))', padding: '3px 10px' },
3884
+ onClick: function () { hubScopeTransfer(p.procedureId, isWs ? 'global' : 'workspace') },
3885
+ }, isWs ? t('hubToGlobal') : t('hubToWorkspace'))
3886
+ }
3741
3887
  // 技能层(最精彩: 反复成功的流程固化为 skill)
3742
3888
  rows.push(h('div', { 'data-dam-hint': '', style: { marginTop: '8px' } }, t('hubSkills')))
3743
3889
  var activeList = (procs && procs.active) || []
@@ -3749,6 +3895,8 @@ window.__ModuleLoader__.load({
3749
3895
  var risk = p.riskLevel === 'high' ? (locale === 'zh' ? '· 高风险需确认' : '· high-risk') : ''
3750
3896
  return h('div', { 'data-dam-content': '', key: p.procedureId, style: { padding: '4px 0', borderBottom: '1px solid color-mix(in srgb, var(--dsw-alias-border-l1, rgba(255,255,255,.16)) 40%, transparent)' } },
3751
3897
  h('div', null, h('b', null, p.title), h('span', { 'data-dam-hint': '', style: { marginLeft: '6px' } }, (locale === 'zh' ? '成功 ' : 'success ') + (p.evidence ? p.evidence.success : 0) + ' · ' + (locale === 'zh' ? '会话 ' : 'sessions ') + (p.evidence ? p.evidence.sessions : 0) + ' ' + risk),
3898
+ // ★M8-B 第6步:已激活技能也要显示归属库(用户要「在当前库时,显示激活了哪些 skill」)
3899
+ scopeBadge(p),
3752
3900
  h('button', { 'data-dam-btn': '', style: { marginLeft: '8px', fontSize: 'calc(10px * var(--dam-scale))', padding: '2px 8px' }, onClick: function () { hubAct('deprecate', p.procedureId) } }, locale === 'zh' ? '弃用' : 'deprecate'),
3753
3901
  h('button', { 'data-dam-btn': '', style: { marginLeft: '4px', fontSize: 'calc(10px * var(--dam-scale))', padding: '2px 8px' }, onClick: function () { hubAct('pin', p.procedureId, !(procs && procs.pipeline && procs.pipeline.concat(activeList).find(function (x) { return x.procedureId === p.procedureId && x.pinned }))) } }, locale === 'zh' ? '置顶' : 'pin')))
3754
3902
  })))
@@ -3765,6 +3913,8 @@ window.__ModuleLoader__.load({
3765
3913
  '[' + stageLabel(p.stage) + ']'
3766
3914
  + (p.observationOnly ? (locale === 'zh' ? ' · 观察(不参与自动晋升)' : ' · observation-only') : '')
3767
3915
  + (p.pinned ? ' 📌' : '') + (p.riskLevel === 'high' ? ' ⚠' : '')),
3916
+ // ★M8-B 第6步:归属库徽标(这条技能住在哪个库)
3917
+ scopeBadge(p),
3768
3918
  // ★R5:evidence 给人话(不是裸数字)
3769
3919
  h('span', { 'data-dam-hint': '', style: { marginLeft: '6px' } }, t('hubEvLine')(ev))),
3770
3920
  // ★R2:**为什么不能晋升** —— 必须在界面上说清楚
@@ -3786,12 +3936,15 @@ window.__ModuleLoader__.load({
3786
3936
  : null))
3787
3937
  : h('div', { 'data-dam-hint': '', style: { marginTop: '4px', opacity: .7 } }, t('hubNoSteps')),
3788
3938
  h('div', { style: { display: 'flex', gap: '6px', marginTop: '5px' } },
3939
+ // ★M8-B 第6步:归属转移(提升为全局 / 收纳到本工作区)——与晋升是同级的**库归属**操作,
3940
+ // 不是晋升门限,因此不受 p.promotion/observationOnly 影响(只读线索也能改判归属)。
3941
+ scopeButtons(p),
3789
3942
  // issue #30:纯 episode 观察行**结构上**不可能晋升(无 successCriteria),
3790
3943
  // 继续显示"晋升"按钮只会让人反复点击却看不到变化 ⇒ 直接隐藏并打观察标。
3791
3944
  // ★ 2026-09-21 bugfix(A-9):原条件只挡 observationOnly,与 store 真实门限**不一致** ——
3792
3945
  // 缺 successCriteria / 有 correction / correctionRate 超限的条目照样显示按钮,
3793
3946
  // 用户点下去只得到一行 keep 灰字,感受即「批准不了」。
3794
- // 现改为**直接依赖只读投影** `p.promotion`(宿主 memory-hub 用 evaluatePromotion 生成,
3947
+ // 现改为**直接依赖只读投影** `p.promotion`(宿主 memory-hub-pre 用 evaluatePromotion 生成,
3795
3948
  // 纯只读、不写盘):decision==='promote' 才给按钮,否则界面已由 R2 区块说明原因。
3796
3949
  // fail-soft:投影缺失(旧宿主/异常)时退回原 observationOnly 判据,不做过度拦截。
3797
3950
  (!p.observationOnly && (!p.promotion || p.promotion.decision === 'promote'))
@@ -3803,7 +3956,7 @@ window.__ModuleLoader__.load({
3803
3956
  (p.promotion && p.promotion.decision === 'ask')
3804
3957
  && h('button', { 'data-dam-btn': '', style: { fontSize: 'calc(10.5px * var(--dam-scale))', padding: '3px 10px', fontWeight: 650 }, onClick: function () { hubAct('approve', p.procedureId) } }, locale === 'zh' ? '批准(人工)' : 'approve (manual)'),
3805
3958
  // ★B/C(2026-09-22):人工**强制晋升** —— 只在「拦它的门**全是统计门**」时给出。
3806
- // 判据来自宿主只读投影 p.promotion.overridable(memory-hub 用 promotionOverrideView 生成,
3959
+ // 判据来自宿主只读投影 p.promotion.overridable(memory-hub-pre 用 promotionOverrideView 生成,
3807
3960
  // 只读、零副作用、且**不重复实现门限** ⇒ 结构上不可能与 promote() 漂移)。
3808
3961
  // 结构门拦(已弃用 / 观察型 / 无 successCriteria / 有纠正记录)时**不给按钮** ——
3809
3962
  // 否则就是再造一个「点了没反应」的假通道(A-9 收掉旧按钮的正是这个原因)。
@@ -4842,11 +4995,19 @@ function StatsTab() {
4842
4995
 
4843
4996
  useEffect(function () {
4844
4997
  var alive = true
4845
- fetch(API.recallStats)
4846
- .then(function (res) { return res.json() })
4847
- .then(function (j) { if (alive) setData(j) })
4848
- .catch(function (ex) { if (alive) setErr(String((ex && ex.message) || ex)) })
4849
- return function () { alive = false }
4998
+ function load() {
4999
+ fetch(API.recallStats)
5000
+ .then(function (res) { return res.json() })
5001
+ .then(function (j) { if (alive) { setErr(''); setData(j) } })
5002
+ .catch(function (ex) { if (alive) setErr(String((ex && ex.message) || ex)) })
5003
+ }
5004
+ load()
5005
+ // ★A-11b(2026-09-23):面板开着时**自己刷新** —— 此前只在挂载/点「复位」时拉一次,
5006
+ // 用户盯着统计页数字也永远不动(实测「召回统计不更新」的第二个真因,另一个是
5007
+ // 宿主侧从不落盘,见 recall-stats.js 的 maybeSave)。
5008
+ // 15s 轮询是纯 GET 只读查询;卸载时清掉,不留悬挂定时器。
5009
+ var iv = setInterval(load, 15000)
5010
+ return function () { alive = false; clearInterval(iv) }
4850
5011
  }, [tick])
4851
5012
 
4852
5013
  function reset() {
@@ -5990,11 +6151,8 @@ function WorkspaceTab() {
5990
6151
  // 恒供给布尔 procedureInjectEnabled 而**结构性不可达** ⇒ 该开关此前是摆设(会翻面、写盘成功、宿主读不到)。
5991
6152
  { key: 'procedureInjectEnabled', name: locale === 'zh' ? '技能固化与注入' : 'Skill crystallization', sub: locale === 'zh' ? '重复流程固化为 checklist 自动附上(本开关只管「附不附」;晋升审批在「唤起回顾」页,与本开关无关)' : 'Turn repeated flows into auto-attached checklists (this switch only controls attaching; promotion review lives in the Recall review tab and is unrelated)', where: locale === 'zh' ? '记忆中枢' : 'Memory Hub' },
5992
6153
  // ★T10(2026-09-20):机械 procedure 切片开关。
5993
- // 背景:`crossFeed()` 会把 episode 意图**机械截断**成技能名与步骤(无模型介入),
5994
- // 产出的观察行「既不像技能名也不像步骤」——用户报「审批里看不懂」的来源。
5995
- // 默认关:procedural 线只留模型直写(memory_procedure)与人工两条正经通路。
5996
- // ⚠️ 解耦:本项**只**影响 procedure 切片,fact 分支/episode 巩固/judgement 消费不动。
5997
- { key: 'hubMechanicalProcedureFeedEnabled', name: locale === 'zh' ? '机械流程切片(不推荐)' : 'Mechanical flow slicing (not recommended)', sub: locale === 'zh' ? '把情节意图截断成"技能",无模型介入、可读性差;关=只保留模型自己写的技能(推荐关)' : 'Truncates episode intent into "skills" with no model involvement; off = only model-authored skills (recommended)', where: locale === 'zh' ? '记忆中枢' : 'Memory Hub' },
6154
+ // ★A-1(2026-09-23):机械切片已整条删除(用户裁定弃用)⇒ 向导清单同步去掉该项,
6155
+ // 避免向导承诺一个已不存在的功能(点开找不到控件)。
5998
6156
  ] },
5999
6157
  { art: 'rocket', core: '', kicker: locale === 'zh' ? '完成' : 'READY', final: true,
6000
6158
  title: locale === 'zh' ? '一切就绪' : 'All set',
@@ -6459,9 +6617,9 @@ function WorkspaceTab() {
6459
6617
  return null
6460
6618
  }
6461
6619
  // ★P10-T1/T2(2026-09-22):13 个注入分区的**显示文案**。成员与顺序以宿主下发的
6462
- // `promptSections` 为准(宿主常量 PROMPT_SECTION_KEYS_V1 是唯一真源),本表只提供文案;
6620
+ // `promptSections` 为准(宿主常量 PROMPT_SECTION_KEYS_PRE_V1 是唯一真源),本表只提供文案;
6463
6621
  // 键集合必须与宿主常量相等 —— 由守卫 smoke-test-t1-prompt-sections-pre.mjs 断言。
6464
- // offZh/offEn 只给 must 段(宿主 PROMPT_SECTION_MUST_V1),其余用通用提示。
6622
+ // offZh/offEn 只给 must 段(宿主 PROMPT_SECTION_MUST_PRE_V1),其余用通用提示。
6465
6623
  var PROMPT_SECTION_TEXT = {
6466
6624
  'rules-section': { zh: '规则段(用户级硬约束)', en: 'Rules section (hard constraints)' },
6467
6625
  'tier0-catalog': { zh: 'Tier-0 常驻目录(指引层)', en: 'Tier-0 catalog (index layer)' },
@@ -7064,9 +7222,10 @@ function WorkspaceTab() {
7064
7222
  // 2026-09-22 补接线(设置页缺口审计 P0-1):procedureInjectEnabled 才是宿主真读的总闸
7065
7223
  // (procedure-switch.js 先判它);此前设置页只有旧键 ⇒ 用户改了也无效(灰控件)。
7066
7224
  field(t('fProcInject'), h('input', { type: 'checkbox', 'data-dam-key': 'procedureInjectEnabled', checked: cfg.procedureInjectEnabled !== false, onChange: function (e) { set('procedureInjectEnabled', e.target.checked) } }), t('fProcInjectHint')),
7067
- // ★v3.1.3 补控件(引导审计 K-3):本键功能是通的(index.js 有 getter → memory-hub 消费),
7068
- // 但此前**只有向导里有控件** ⇒ 用户在向导关掉后无处回改。与 procedureInjectEnabled 同节。
7069
- field(locale === 'zh' ? '机械流程切片' : 'Mechanical flow slicing', h('input', { type: 'checkbox', 'data-dam-key': 'hubMechanicalProcedureFeedEnabled', checked: cfg.hubMechanicalProcedureFeedEnabled === true, onChange: function (e) { set('hubMechanicalProcedureFeedEnabled', e.target.checked) } }), locale === 'zh' ? '把情节意图机械截断成"技能"(无模型介入、可读性差)。关=只保留模型自己写的技能(推荐关)。' : 'Truncates episode intent into "skills" with no model involvement (poor readability). Off = only model-authored skills (recommended).'),
7225
+ // ★A-1(2026-09-23):此处原有「机械流程切片」复选框 —— 该功能已整条删除
7226
+ // (用户裁定「机械通路已被我弃用」,memory-hub.js 分支 + index.js getter 同步移除)。
7227
+ // 控件一并删掉:留着它会「写盘成功但界面无变化」(读到的键已无消费者)——
7228
+ // 正是用户明确列为「功能坏了」的那种表现。
7070
7229
  field(t('fProcLevel'), h('select', { 'data-dam-select': '', value: cfg.procedureActiveLevel || 'checklist', onChange: function (e) { set('procedureActiveLevel', e.target.value) } },
7071
7230
  h('option', { value: 'checklist' }, locale === 'zh' ? 'checklist 完整步骤' : 'checklist (full steps)'),
7072
7231
  h('option', { value: 'excerpt' }, locale === 'zh' ? 'excerpt 摘要' : 'excerpt (summary)'),
@@ -7172,7 +7331,7 @@ function WorkspaceTab() {
7172
7331
  mdlOpen ? buildModelDrawer() : null,
7173
7332
  ]),
7174
7333
  section('about', sectionLabels.about, [
7175
- // WB-GRAPH 白板线一键切换(2026-09-16, board_mode_v1): 旧版白板(默认) / 新版看板(dsh-graph vendor
7334
+ // WB-GRAPH 白板线一键切换(2026-09-16, board_mode_pre_v1): 旧版白板(默认) / 新版看板(dsh-graph vendor
7176
7335
  // + sidecar + 遍历工具, 工具数 14→16)。开关解耦: 只管白板线; 切换需重启 dsh web 生效(工具注册在启动期)。
7177
7336
  field(locale === 'zh' ? '白板模式(旧版白板 / 新版看板)' : 'Board mode (legacy / graph)', (function () {
7178
7337
  var cur = cfg.boardMode === 'graph' ? 'graph' : 'legacy'
@@ -7468,19 +7627,19 @@ function WorkspaceTab() {
7468
7627
  function registerSurfaces() {
7469
7628
  try {
7470
7629
  surfaceDisposers.push(slots.inject('sidebar.footer.action', function () {
7471
- return slots.register({ name: 'sidebar.footer.action', id: 'auto-memory', order: 5, label: t('memory') + ' (pre)' }, function () { return h(SidebarButton) })
7630
+ return slots.register({ name: 'sidebar.footer.action', id: 'auto-memory-pre', order: 5, label: t('memory') + ' (pre)' }, function () { return h(SidebarButton) })
7472
7631
  }))
7473
7632
  surfaceDisposers.push(slots.inject('shell.overlay', function () {
7474
- return slots.register({ name: 'shell.overlay', id: 'auto-memory', order: 5 }, function () { return h(MemoryPanel) })
7633
+ return slots.register({ name: 'shell.overlay', id: 'auto-memory-pre', order: 5 }, function () { return h(MemoryPanel) })
7475
7634
  }))
7476
7635
  surfaceDisposers.push(slots.inject('shell.overlay', function () {
7477
- return slots.register({ name: 'shell.overlay', id: 'auto-memory-dialogs', order: 6 }, function () { return h(DialogHost) })
7636
+ return slots.register({ name: 'shell.overlay', id: 'auto-memory-pre-dialogs', order: 6 }, function () { return h(DialogHost) })
7478
7637
  }))
7479
7638
  surfaceDisposers.push(slots.inject('shell.overlay', function () {
7480
- return slots.register({ name: 'shell.overlay', id: 'auto-memory-autocont', order: 7 }, function () { return h(AutoContinueHost) })
7639
+ return slots.register({ name: 'shell.overlay', id: 'auto-memory-pre-autocont', order: 7 }, function () { return h(AutoContinueHost) })
7481
7640
  }))
7482
7641
  surfaceDisposers.push(slots.inject('settings.section', function () {
7483
- return slots.register({ name: 'settings.section', id: 'auto-memory', order: 25, label: t('autoMemory') + ' (pre)' }, function (props) { return h(SettingsPage, { close: props && props.close }) })
7642
+ return slots.register({ name: 'settings.section', id: 'auto-memory-pre', order: 25, label: t('autoMemory') + ' (pre)' }, function (props) { return h(SettingsPage, { close: props && props.close }) })
7484
7643
  }))
7485
7644
  // ★2026-09-21 二次修正:「记忆」会话页 —— 与「对话轨迹」「上下文」「白板看板」并列的一页。
7486
7645
  // 用户实测反馈:「它现在是浮在整个页面上方的,而不是和对话轨迹、上下文、白板看板在一起,作为一个单独的一页」。
@@ -7490,7 +7649,7 @@ function WorkspaceTab() {
7490
7649
  if (controller.panelPos() === 'page' || controller.panelPos() === 'both') {
7491
7650
  surfaceDisposers.push(slots.inject('conversation.view', function () {
7492
7651
  return slots.register(
7493
- { name: 'conversation.view', id: 'auto-memory-panel', order: 78, label: function () { return locale === 'zh' ? '记忆' : 'Memory' } },
7652
+ { name: 'conversation.view', id: 'auto-memory-pre-panel', order: 78, label: function () { return locale === 'zh' ? '记忆' : 'Memory' } },
7494
7653
  function (props) { return h(MemoryPageView, props || {}) },
7495
7654
  )
7496
7655
  }))
@@ -7501,7 +7660,7 @@ function WorkspaceTab() {
7501
7660
  // 不改变 legacy 档下顶栏是否出现该 Tab 的行为(那由宿主/开关层决定, 本处不加判断)。
7502
7661
  surfaceDisposers.push(slots.inject('conversation.view', function () {
7503
7662
  return slots.register(
7504
- { name: 'conversation.view', id: 'auto-memory-kanban', order: 80, label: function () { return locale === 'zh' ? '白板看板' : 'Whiteboard' } },
7663
+ { name: 'conversation.view', id: 'auto-memory-pre-kanban', order: 80, label: function () { return locale === 'zh' ? '白板看板' : 'Whiteboard' } },
7505
7664
  function (props) { return h(KanbanView, props || {}) },
7506
7665
  )
7507
7666
  }))
@@ -7521,7 +7680,7 @@ function WorkspaceTab() {
7521
7680
  if (WBG_ENABLED) {
7522
7681
  surfaceDisposers.push(slots.inject('conversation.view', function () {
7523
7682
  return slots.register(
7524
- { name: 'conversation.view', id: 'auto-memory-graph', order: 81, label: function () { return locale === 'zh' ? '白板画布' : 'Graph' } },
7683
+ { name: 'conversation.view', id: 'auto-memory-pre-graph', order: 81, label: function () { return locale === 'zh' ? '白板画布' : 'Graph' } },
7525
7684
  function (props) { return h(WhiteboardGraphView, props || {}) },
7526
7685
  )
7527
7686
  }))
package/lib/config-io.js CHANGED
@@ -32,7 +32,7 @@ import { setTimeout as sleepDelayPre } from 'node:timers/promises'
32
32
  import path from 'node:path'
33
33
 
34
34
  /** 临时文件后缀。与既有 hub 落盘的 `.tmp` 同族,便于排查时一眼认出。 */
35
- export const ATOMIC_TMP_SUFFIX_V1 = '.tmp'
35
+ export const ATOMIC_TMP_SUFFIX_PRE_V1 = '.tmp'
36
36
 
37
37
  /**
38
38
  * ★2026-09-22(并发写修复):tmp 路径的**唯一段** = pid + 进程内自增序号。
@@ -41,7 +41,7 @@ export const ATOMIC_TMP_SUFFIX_V1 = '.tmp'
41
41
  * 先完成者把 tmp 改名走,后到者的 rename 找不到源文件而抛 **ENOENT**(非瞬时错误、
42
42
  * 按纪律不得重试)⇒ 后一次保存静默失败(只回 `{ok:false}`,界面看起来"改了没生效")。
43
43
  * 实测复现:对同一目标并发 5 次 `writeTextAtomicPre`,部分调用返回
44
- * `{ok:false, error:'ENOENT … rename … .tmp -> …'}`(见 `tests/smoke/smoke-test-p3-batchb-guards-pre.mjs` P3B-2d)。
44
+ * `{ok:false, error:'ENOENT … rename … .tmp -> …'}`(见 `tests/smoke/smoke-test-p3-batchb-guards.mjs` P3B-2d)。
45
45
  *
46
46
  * 处方:每次调用生成一个独立的 tmp 名 —— 同进程并发各写各的,跨进程并发靠 pid 区分;
47
47
  * 最后一次 rename 胜出,**原子性不变量不变**(目标任意时刻仍是旧完整版或新完整版)。
@@ -56,7 +56,7 @@ function tmpUniqueSuffixPre() {
56
56
  }
57
57
 
58
58
  /** 损坏留存的默认后缀前缀。 */
59
- export const CORRUPT_QUARANTINE_PREFIX_V1 = '.corrupt-'
59
+ export const CORRUPT_QUARANTINE_PREFIX_PRE_V1 = '.corrupt-'
60
60
 
61
61
  /** 时间戳串(用于损坏留存命名)。绝不影响主流程。 */
62
62
  function tsPre(now) {
@@ -76,7 +76,7 @@ function tsPre(now) {
76
76
  */
77
77
  export function quarantineFilePreSync(file, opts = {}) {
78
78
  try {
79
- const suffix = (opts && opts.suffix) || (CORRUPT_QUARANTINE_PREFIX_V1 + tsPre(opts && opts.now))
79
+ const suffix = (opts && opts.suffix) || (CORRUPT_QUARANTINE_PREFIX_PRE_V1 + tsPre(opts && opts.now))
80
80
  const dest = file + suffix
81
81
  // 同一秒内坏两次 ⇒ 再加一段随机后缀,避免覆盖上一份证据
82
82
  const finalPath = existsSync(dest) ? dest + '.' + Math.random().toString(36).slice(2, 6) : dest
@@ -92,7 +92,7 @@ export function quarantineFilePreSync(file, opts = {}) {
92
92
  * @returns {{ok: boolean, path?: string, error?: string}} 结构化结果(fail-soft,不抛)。
93
93
  */
94
94
  export function writeTextAtomicPreSync(file, text) {
95
- const tmp = file + ATOMIC_TMP_SUFFIX_V1 + tmpUniqueSuffixPre()
95
+ const tmp = file + ATOMIC_TMP_SUFFIX_PRE_V1 + tmpUniqueSuffixPre()
96
96
  try {
97
97
  mkdirSync(path.dirname(file), { recursive: true })
98
98
  writeFileSync(tmp, String(text), 'utf8')
@@ -113,22 +113,22 @@ export function writeTextAtomicPreSync(file, text) {
113
113
  * 处方:仅对这三个**瞬时**错误码退避重试;首项延迟为 0 ⇒ **正常路径零延迟变化**。
114
114
  *
115
115
  * ⚠️ **为什么不 import `fs-retry.js`**:本模块的**零运行时依赖纪律**(见文件头)
116
- * 被 `tests/smoke/smoke-test-t8-configio-pre.mjs` 的 T8c-6 钉死 —— 配置 I/O 层不得因为
116
+ * 被 `tests/smoke/smoke-test-t8-configio.mjs` 的 T8c-6 钉死 —— 配置 I/O 层不得因为
117
117
  * 任何兄弟模块的加载期故障而失效。故此处**内联**同一套退避刻度(只用 node: 内置),
118
118
  * 语义与 `fs-retry.js` 的 `retryRename` 保持一致。
119
119
  */
120
- const RENAME_RETRY_DELAYS_V1 = Object.freeze([0, 50, 150, 400, 1000])
121
- const TRANSIENT_RENAME_CODES_V1 = new Set(['EPERM', 'EACCES', 'EBUSY'])
120
+ const RENAME_RETRY_DELAYS_PRE_V1 = Object.freeze([0, 50, 150, 400, 1000])
121
+ const TRANSIENT_RENAME_CODES_PRE_V1 = new Set(['EPERM', 'EACCES', 'EBUSY'])
122
122
  async function renameBoundedRetryPre(from, to) {
123
- const last = RENAME_RETRY_DELAYS_V1.length - 1
123
+ const last = RENAME_RETRY_DELAYS_PRE_V1.length - 1
124
124
  for (let attempt = 0; attempt <= last; attempt++) {
125
- const wait = RENAME_RETRY_DELAYS_V1[attempt]
125
+ const wait = RENAME_RETRY_DELAYS_PRE_V1[attempt]
126
126
  if (wait) await sleepDelayPre(wait)
127
127
  try {
128
128
  await rename(from, to)
129
129
  return
130
130
  } catch (e) {
131
- const transient = TRANSIENT_RENAME_CODES_V1.has(e && e.code)
131
+ const transient = TRANSIENT_RENAME_CODES_PRE_V1.has(e && e.code)
132
132
  if (!transient || attempt === last) throw e
133
133
  }
134
134
  }
@@ -143,7 +143,7 @@ async function renameBoundedRetryPre(from, to) {
143
143
  * 同步版 `writeTextAtomicPreSync` 的契约是同步,保持 `renameSync` 不动。
144
144
  */
145
145
  export async function writeTextAtomicPre(file, text) {
146
- const tmp = file + ATOMIC_TMP_SUFFIX_V1 + tmpUniqueSuffixPre()
146
+ const tmp = file + ATOMIC_TMP_SUFFIX_PRE_V1 + tmpUniqueSuffixPre()
147
147
  try {
148
148
  mkdirSync(path.dirname(file), { recursive: true })
149
149
  await writeFile(tmp, String(text), 'utf8')