@furongjun1999/dsh-memory 0.6.1 → 0.7.1

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 (105) hide show
  1. package/README.md +49 -18
  2. package/docs/README.md +1 -0
  3. package/docs/eval/cons200_/345/206/262/347/252/201/346/243/200/346/265/213/351/200/211/351/235/242_/345/256/236/346/226/275/350/256/260/345/275/225_v1.0.md +223 -0
  4. package/docs/eval/cons200_/345/206/262/347/252/201/346/243/200/346/265/213/351/200/211/351/235/242_/345/256/236/346/226/275/350/256/260/345/275/225_v1.1.md +340 -0
  5. package/docs/eval/issue50_/345/205/203/346/225/260/346/215/256/351/200/217/344/274/240/344/270/216/345/205/234/345/272/225_/344/277/256/345/244/215/350/256/260/345/275/225_v1.0.md +437 -0
  6. package/docs/eval/issue50_/345/215/212/351/207/215/345/244/215/345/276/205/345/256/232/345/244/215/346/240/270_/344/277/256/345/244/215/350/256/260/345/275/225_v1.0.md +488 -0
  7. package/docs/eval/issue50_/345/276/205/345/256/232/345/244/215/346/240/270/345/205/245/351/230/237_/344/277/256/345/244/215/350/256/260/345/275/225_v1.0.md +428 -0
  8. package/docs/eval/issue50_/350/257/273/351/235/242/344/277/235/346/212/244/345/217/252/350/256/244/346/230/276/345/274/217/346/235/245/346/272/220_/344/277/256/345/244/215/350/256/260/345/275/225_v1.0.md +271 -0
  9. package/docs/eval/issue50_/351/207/215/350/246/201/345/272/246/345/220/214/346/272/220/344/270/216/344/277/235/346/212/244/350/257/255/344/271/211_/344/277/256/345/244/215/350/256/260/345/275/225_v1.0.md +306 -0
  10. package/docs/eval/issue51_/344/270/200/351/224/256/345/256/211/350/243/205/345/244/261/350/264/245_/345/275/222/345/261/236/345/210/244/345/256/232_v1.0.md +58 -0
  11. package/docs/eval/issue52_/346/235/241/344/273/266/345/205/210/350/241/214/344/270/216/346/210/252/346/226/255/345/217/257/350/247/202/346/265/213_/344/277/256/345/244/215/350/256/260/345/275/225_v1.0.md +360 -0
  12. package/docs/eval//344/270/211/346/241/243/350/207/252/346/262/273_/346/255/245/351/252/244/342/221/241/346/241/243/344/275/215/345/215/225/344/270/200/345/205/245/345/217/243_/350/220/275/347/240/201/350/256/260/345/275/225_v1.0.md +651 -0
  13. package/docs/eval//344/270/211/346/241/243/350/207/252/346/262/273_/346/255/245/351/252/244/342/221/242/345/217/230/346/233/264/345/215/225/344/270/216/345/233/236/346/273/232/345/216/237/350/257/255_/350/220/275/347/240/201/350/256/260/345/275/225_v1.0.md +439 -0
  14. package/docs/eval//344/270/211/346/241/243/350/207/252/346/262/273_/346/255/245/351/252/244/342/221/243/345/207/206/345/205/245/350/257/273/346/225/260_/350/220/275/347/240/201/350/256/260/345/275/225_v1.0.md +1337 -0
  15. package/docs/eval//344/270/211/346/241/243/350/207/252/346/262/273_/346/255/245/351/252/244/342/221/244/346/224/266/345/256/230/344/270/216/345/205/250/351/223/276/351/252/214/346/224/266_/350/220/275/347/240/201/350/256/260/345/275/225_v1.0.md +1817 -0
  16. package/docs/eval//345/207/272/350/264/247/351/235/242/345/206/222/347/203/237_/350/277/233/350/264/247/351/227/250/347/246/201_v1.0.md +460 -0
  17. package/docs/eval//345/217/221/345/270/20308_/350/207/252/350/277/255/344/273/243/344/270/216/347/235/241/347/234/240_/345/233/276/346/243/200/347/264/242/350/267/257/344/270/216/346/235/203/351/207/215_v1.0.md +97 -0
  18. package/docs/eval//345/217/221/345/270/20309_/346/243/200/347/264/242/351/235/242/344/270/211/346/211/271/346/224/266/345/217/243_v1.0.md +66 -0
  19. package/docs/eval//345/275/222/344/270/200/345/261/202/347/274/272/347/234/201/347/277/273/345/205/263_/344/277/256/345/244/215/350/256/260/345/275/225_v1.0.md +458 -0
  20. package/docs/eval//347/254/2543/345/261/202stg/347/273/223/346/236/204/347/264/242/345/274/225_/345/256/236/346/226/275/350/256/260/345/275/225_v1.0.md +250 -0
  21. package/docs/hive//346/243/200/347/264/242/347/256/227/346/263/225/345/217/243/345/276/204/345/257/271/347/205/247_v0.1.md +136 -11
  22. package/docs/hive//346/243/200/347/264/242/350/267/257/345/276/204/344/270/216/350/256/244/347/237/245/347/273/223/346/236/204/345/245/221/347/272/246_v0.1.md +32 -2
  23. package/docs/mdcg/README/350/257/246/347/273/206/347/211/210_v0.4.10.md +88 -0
  24. package/docs/mdcg//345/212/237/350/203/275/350/260/203/347/224/250/346/230/240/345/260/204/350/241/250_v0.1.md +40 -40
  25. package/docs/mdcg//345/217/221/345/270/203/351/227/250/347/246/201/351/223/276_v0.1.md +47 -11
  26. package/docs/mdcg//347/235/241/347/234/240/345/221/250/346/234/237_/350/277/220/347/273/264/345/211/215/346/217/220/344/270/216/347/273/264/346/212/244/346/214/207/345/215/227_v1.0.md +183 -0
  27. package/docs/plans/stg/346/235/241/344/273/266/345/214/226/344/270/216/347/273/223/346/236/204/347/264/242/345/274/225_/350/256/276/350/256/241_v0.1.md +124 -0
  28. package/docs/plans//347/235/241/347/234/240/344/270/216/350/207/252/350/277/255/344/273/243_/345/212/237/350/203/275/344/274/230/345/214/226/350/256/276/350/256/241_v0.4.md +547 -0
  29. package/docs/plans//350/256/260/345/277/206/350/207/252/345/244/204/347/220/206/344/270/211/346/241/243/350/207/252/346/262/273_/350/256/276/350/256/241_v0.2.md +207 -0
  30. package/md_cg/admission.py +718 -0
  31. package/md_cg/autonomy_modes.py +642 -0
  32. package/md_cg/bench_e2e_locomo_qa.py +11 -4
  33. package/md_cg/chain.py +47 -0
  34. package/md_cg/consistency.py +133 -8
  35. package/md_cg/forgetting.py +38 -6
  36. package/md_cg/freshness.py +527 -0
  37. package/md_cg/generation.py +409 -0
  38. package/md_cg/hotcache.py +4 -1
  39. package/md_cg/lifecycle.py +30 -2
  40. package/md_cg/mcp_server.py +191 -26
  41. package/md_cg/mdcg.py +594 -39
  42. package/md_cg/mdcos.py +869 -38
  43. package/md_cg/nodefile.py +74 -1
  44. package/md_cg/protect.py +79 -4
  45. package/md_cg/provenance.py +1 -1
  46. package/md_cg/review_cli.py +31 -2
  47. package/md_cg/rollback.py +411 -0
  48. package/md_cg/rollback_cli.py +104 -0
  49. package/md_cg/semantic/canonical.py +22 -0
  50. package/md_cg/semantic/unify.py +73 -22
  51. package/md_cg/semantic/unify_fixture.json +25 -0
  52. package/md_cg/sleep.py +1297 -0
  53. package/md_cg/stg.py +400 -50
  54. package/md_cg/stgidx.py +281 -0
  55. package/md_cg/sustain.py +146 -9
  56. package/md_cg/test_auto_defaults.py +424 -0
  57. package/md_cg/test_autonomy_admission.py +1098 -0
  58. package/md_cg/test_autonomy_modes.py +1972 -0
  59. package/md_cg/test_b1_auto_id_multiproc.py +7 -0
  60. package/md_cg/test_b1b2_write_face.py +7 -0
  61. package/md_cg/test_b3_merge_keeps_content.py +7 -0
  62. package/md_cg/test_boundary_hit.py +410 -0
  63. package/md_cg/test_cons200_scan_selection.py +791 -0
  64. package/md_cg/test_en_pipeline.py +22 -13
  65. package/md_cg/test_generation_guard.py +352 -0
  66. package/md_cg/test_h4_sustain_snapshot.py +14 -4
  67. package/md_cg/test_i50a_half_dup_defer.py +408 -0
  68. package/md_cg/test_i50b_defer_to_review_queue.py +545 -0
  69. package/md_cg/test_i50c_meta_passthrough.py +608 -0
  70. package/md_cg/test_i50d_importance_source.py +531 -0
  71. package/md_cg/test_i50e_readside_protection.py +656 -0
  72. package/md_cg/test_issue39_utf8_stdio.py +9 -1
  73. package/md_cg/test_issue52_scan_condition_first.py +677 -0
  74. package/md_cg/test_linkref.py +7 -0
  75. package/md_cg/test_mode_parity.py +1393 -0
  76. package/md_cg/test_mutation_rollback.py +805 -0
  77. package/md_cg/test_n204_n205_n226_n227_n228_n229_exit_gates.py +1 -1
  78. package/md_cg/test_n212_n213_n224_generation_gates.py +14 -8
  79. package/md_cg/test_n214_n215_n221_n222_write_face_gates.py +7 -0
  80. package/md_cg/test_n230_dirty_replay.py +375 -0
  81. package/md_cg/test_p2_mcp.py +8 -0
  82. package/md_cg/test_p2_six_elements.py +463 -0
  83. package/md_cg/test_p3_legacy_closure.py +433 -0
  84. package/md_cg/test_p4_freshness.py +680 -0
  85. package/md_cg/test_p8_subgraph_chain.py +14 -1
  86. package/md_cg/test_p9_forget_protect.py +7 -0
  87. package/md_cg/test_p9c_dedup_hints.py +7 -0
  88. package/md_cg/test_policy_required_ccg.py +5 -1
  89. package/md_cg/test_protocol.py +7 -0
  90. package/md_cg/test_rank_parity_score_mode.py +6 -0
  91. package/md_cg/test_semantic_canonical.py +5 -3
  92. package/md_cg/test_sleep.py +611 -0
  93. package/md_cg/test_sleep_p1.py +784 -0
  94. package/md_cg/test_stgidx_index_parity.py +1020 -0
  95. package/md_cg/test_time_core_lint.py +968 -0
  96. package/md_cg/test_unify_default_off.py +701 -0
  97. package/md_cg/test_unify_scope.py +182 -0
  98. package/md_cg/test_writelimit.py +36 -9
  99. package/md_cg/test_writepipe.py +5 -2
  100. package/md_cg/weights.py +15 -1
  101. package/md_cg/whitebox_kb/aeis_core/time_core.py +8 -0
  102. package/md_cg/writepipe.py +101 -2
  103. package/package.json +3 -2
  104. package/skills/plugin.json +1 -1
  105. package/utf8_boot.py +237 -0
package/md_cg/nodefile.py CHANGED
@@ -637,4 +637,77 @@ def cond_terms(text: str) -> list[str]:
637
637
  if seg not in seen:
638
638
  seen.add(seg)
639
639
  out.append(seg)
640
- return out
640
+ return out
641
+
642
+
643
+ # ---- §5.1 六要素的**索引角色**:那张表从 4 行长成 6 行(P2-1)------------------
644
+ #
645
+ # 理论真源 `docs/theory/智能论3.4.md:3053-3059` 的「要素 | 标记 | 内容 |
646
+ # **图的索引角色**」表**只有 4 行**(功能名/生效条件/子功能/执行)。v0.4 §5.1
647
+ # 依裁定 2 把表补成 6 行,后两行为**新增检索维度**;本常量即那 6 行的
648
+ # **代码侧唯一真源**(检索面、索引条目、守卫三处共用一份,禁止各写一份)。
649
+ #
650
+ # 表的两列语义:
651
+ # · 字段名 = CCG 六要素之一(`CCG_MARKS` 的子集,顺序即理论表序);
652
+ # · 索引键 = 该要素在检索面上的**键名**(前 4 行既有,后 2 行本批新增)。
653
+ # 「索引键」进索引条目(`MdCG._node_entry`)成为**免读文件的扁指标量**——
654
+ # 与既有 `time_window` / `observation_position` 同款理由(检索期不读盘)。
655
+ CCG_INDEX_ROLES = (
656
+ ("功能名", "语义符号", "已有"),
657
+ ("生效条件", "条件词", "已有(S2 门控)"),
658
+ ("子功能", "结构词", "已有"),
659
+ ("执行", "机制词", "已有"),
660
+ ("验证方式", "后置条件词", "P2-1 新增:按验证手段检索"),
661
+ ("不适用条件", "拒绝域词", "P2-1 新增:按边界检索(boundary_hit)"),
662
+ )
663
+
664
+ #: 新增两行的**字段名 → 索引键名**映射(索引条目里的扁平键;单一真源)。
665
+ #: 键名刻意带 `_terms` 后缀:它们是**词项列表**(扁指标量),不是正文行原样。
666
+ POSTCONDITION_FIELD = "验证方式"
667
+ REJECTION_FIELD = "不适用条件"
668
+ POSTCONDITION_TERMS_KEY = "postcondition_terms"
669
+ REJECTION_TERMS_KEY = "rejection_terms"
670
+ INDEX_TERMS_KEYS = (POSTCONDITION_TERMS_KEY, REJECTION_TERMS_KEY)
671
+
672
+ #: 索引词项的最小长度。口径与 `mdcg.NEG_MIN_TERM`(负条件判据的词长下限)
673
+ #: **同值同义**:单字符碎片(的/与/3)不是检索键。两处不可各自取值——
674
+ #: `md_cg/test_p2_six_elements.py` 有交叉断言钉住两常量相等。
675
+ ELEMENT_TERM_MIN = 2
676
+
677
+ #: 索引词项的切分面:槽分隔(;;)、短语分隔(,,、/)、括号与空白。
678
+ #: 为什么与 `cond_terms` 不同:那两个要素是**自由文本行**(不是条件空间四槽
679
+ #: 合成串),没有「槽标签:」前缀,故不需要剥标签那一步;切分面本身同族。
680
+ _ELEMENT_TERM_SPLIT_RE = re.compile(r"[;;,,、/()()\[\]【】{}\s]+")
681
+
682
+
683
+ # 生效条件:value 为假值(None/空串/纯空白)时按空文本处理返回 [];否则按「;;,,、/()()[]【】{}空白」切分、逐段 strip、丢弃长度 < ELEMENT_TERM_MIN 的段与纯数字段、命中 is_dep_sentinel(空值语义哨兵)或 is_placeholder_text(骨架占位)的段、以及已入选的重复段,返回保序去重的 out;
684
+ def element_terms_from_text(value, min_len: int = ELEMENT_TERM_MIN) -> list[str]:
685
+ """要素文本 → 索引词项(**确定性切分,无语义猜测**)。
686
+
687
+ 这是「六要素索引键」的**切分单点**:`MdCG._node_entry` 与守卫共用。
688
+ 只做形态切分 + 空值语义剔除,不做任何同义/近义扩展(同 `cond_terms` 纪律)。
689
+ """
690
+ out, seen = [], set()
691
+ for seg in _ELEMENT_TERM_SPLIT_RE.split(str(value or "")):
692
+ seg = seg.strip().strip("。..::")
693
+ if len(seg) < int(min_len) or seg.isdigit():
694
+ continue
695
+ if is_dep_sentinel(seg) or is_placeholder_text(seg):
696
+ continue
697
+ if seg not in seen:
698
+ seen.add(seg)
699
+ out.append(seg)
700
+ return out
701
+
702
+
703
+ # 生效条件:field_name 为 CCG 要素名且 content 含该行时,取该行值(走 ccg_field_value 单点)→ 经 element_terms_from_text 切分成词项列表;该行缺失/值为空时返回 [];
704
+ def ccg_element_terms(content: str, field_name: str,
705
+ min_len: int = ELEMENT_TERM_MIN) -> list[str]:
706
+ """CCG 要素文本 → 索引词项(**取值走既有单点**,检索面不得另写正则)。
707
+
708
+ 取值**必须**经 `ccg_field_value`(仓内 CCG 行解析的唯一真源:冒号可有可无、
709
+ 首个命中行为准、与写入闸门 `data/policy.json` 同一行语义);本函数只在其上
710
+ 叠加**切分**,绝不自己 `re.match(r"^#\\s*验证方式")` ——那会立刻长出第二套
711
+ 行语义(`ccg_mark_present` 的取单点动因即此类分叉)。
712
+ """
713
+ return element_terms_from_text(ccg_field_value(content, field_name), min_len)
package/md_cg/protect.py CHANGED
@@ -82,7 +82,7 @@ def _entry(cg, node_id):
82
82
  return ((getattr(cg, "index", None) or {}).get("nodes") or {}).get(node_id)
83
83
 
84
84
 
85
- # 生效条件:cg 索引中无 node_id 条目时返回 None;有条目时先取 layer/importance(缺 importance 键回落 0.5)/protected/protection_reason/immutable/self_state,仅当条目缺 protected 或 immutable、或(缺 self_state 且条目 layer∈PROTECTED_LAYERS)、或(条目 layer∈PROTECTED_LAYERS 且 _GATE_UNKNOWN_KEYS 中任一键的值为 None)时再经 cg.get(node_id) 用 frontmatter 覆盖这四个键(cg.get 抛异常或返回假值时保留索引值;layer 取 frontmatter.layer or 索引 layer,importance 缺键时回落索引 importance)。
85
+ # 生效条件:cg 索引中无 node_id 条目时返回 None;有条目时先取 layer/importance(缺 importance 键回落 0.5)/protected/protection_reason/immutable/self_state/importance_source(issue50-e:缺键读 None),仅当条目缺 protected 或 immutable、或(缺 self_state 且条目 layer∈PROTECTED_LAYERS)、或(条目 layer∈PROTECTED_LAYERS 且 _GATE_UNKNOWN_KEYS 中任一键的值为 None)时再经 cg.get(node_id) 用 frontmatter 覆盖这些键(cg.get 抛异常或返回假值时保留索引值;layer 取 frontmatter.layer or 索引 layer,importance 缺键时回落索引 importance)。
86
86
  def _fm(cg, node_id):
87
87
  """取判定所需的 frontmatter 字段;索引快照门控字段**未知**时回退读文件。"""
88
88
  e = _entry(cg, node_id)
@@ -95,6 +95,9 @@ def _fm(cg, node_id):
95
95
  "protection_reason": e.get("protection_reason"),
96
96
  "immutable": e.get("immutable"),
97
97
  "self_state": e.get("self_state"),
98
+ # issue50-e:重要度来源进判定面(_node_entry/_stage 恒落该键,值可为
99
+ # None ⇒「缺省来源」;老索引条目缺键时 .get() 同样得 None,等价缺省)。
100
+ "importance_source": e.get("importance_source"),
98
101
  }
99
102
  # 回退读文件:索引快照缺字段时。self_state 只在受保护层(self/anchor)
100
103
  # 需要,回退代价被限制在少量节点上,不影响全量统计性能。
@@ -128,12 +131,30 @@ def _fm(cg, node_id):
128
131
  fm["self_state"] = f2.get("self_state")
129
132
  fm["layer"] = f2.get("layer") or fm["layer"]
130
133
  fm["importance"] = f2.get("importance", fm["importance"])
134
+ fm["importance_source"] = f2.get("importance_source")
131
135
  return fm
132
136
 
133
137
 
134
- # 生效条件:cg 索引无 node_id 条目(_fm 直接返回 None,不走回退读文件)时返回 (False, '');有条目时按 layer∈PROTECTED_LAYERS 返回 (True, 层保护);否则 protected is True 时返回 (True, protection_reason 或 '显式保护标记');否则 importance(缺失/假值/float 转换异常一律按 0.0)≥AUTO_PROTECT_IMPORTANCE 时返回 (True, 重要性保护);其余返回 (False, '')。
138
+ # 生效条件:cg 索引无 node_id 条目(_fm 直接返回 None,不走回退读文件)时返回 (False, '');有条目时按 layer∈PROTECTED_LAYERS 返回 (True, 层保护);否则 protected is True 时返回 (True, protection_reason 或 '显式保护标记');否则 importance(缺失/假值/float 转换异常一律按 0.0)≥AUTO_PROTECT_IMPORTANCE 且 importance_source 为 None(键缺省)或 'hint' 时返回 (True, 重要性保护)——issue50-e:'heuristic' 等显式非 hint 来源不得由分数触发;其余返回 (False, '')。
135
139
  def is_protected(cg, node_id):
136
- """**不可遗忘**判定 → (是否受保护, 原因)。节点不存在返回 (False, "")。"""
140
+ """**不可遗忘**判定 → (是否受保护, 原因)。节点不存在返回 (False, "")。
141
+
142
+ issue50-e(2026-10-02,使用者裁定「读面也只认显式来源」):按分自动保护
143
+ 只对**缺省来源或显式 hint** 的分数生效。issue50-d 落盘了启发式真分
144
+ (importance=0.7x + importance_source="heuristic"、不打保护位),若本
145
+ 判定不看来源,读面(遗忘/搬迁闸、protect.check、scrub 净化与 confidence
146
+ 校准的 skip 判定、stats 盘点)会把机器推断的分数重新认回受保护——写侧
147
+ 「只落分不打位」被读侧单方面推翻。边界:
148
+ ① 键缺省(None)= 存量节点(既有 cg.add(importance=0.9) 直写、维护路径
149
+ 调分等从未有过该键)⇒ 行为一字不变——按「缺省即不认」会大规模改变
150
+ 既有保护面,禁止;
151
+ ② "hint" = 显式声明(写侧对 hint 过线本就打位,按分分支只是其无位形态
152
+ 的兜底,语义不变);
153
+ ③ "heuristic" 及其它显式来源不得由分数触发——机器推断的重要度不构成
154
+ 不可遗忘的依据。层保护(PROTECTED_LAYERS)、fm.protected 位、
155
+ is_immutable 一律不动;MERGE 强化(forgetting.reinforce 跨 0.7 置
156
+ protected=True)是「重复确认」的显式动作,不经本分支,不受影响。
157
+ """
137
158
  fm = _fm(cg, node_id)
138
159
  if fm is None:
139
160
  return False, ""
@@ -146,7 +167,11 @@ def is_protected(cg, node_id):
146
167
  imp = float(fm.get("importance") or 0.0)
147
168
  except Exception:
148
169
  imp = 0.0
149
- if imp >= AUTO_PROTECT_IMPORTANCE:
170
+ # issue50-e(2026-10-02):按分保护**只认缺省或显式 hint 来源**——
171
+ # 语义与边界见 docstring ①②③;_fm 恒带 importance_source 键(缺省读
172
+ # None),老索引条目缺键同样落 None ⇒ 存量按分保护一字不变。
173
+ _src = fm.get("importance_source")
174
+ if imp >= AUTO_PROTECT_IMPORTANCE and (_src is None or _src == "hint"):
150
175
  return True, f"重要性保护:importance={imp:.2f}≥{AUTO_PROTECT_IMPORTANCE}"
151
176
  return False, ""
152
177
 
@@ -220,6 +245,56 @@ def snapshot(cg, node_id):
220
245
  return rel
221
246
 
222
247
 
248
+ # 生效条件:cg.get(node_id) 抛异常或返回假值时返回 None(与 snapshot 同口径——目标不存在=动作不会发生,由调用方按既有错误路径处理);否则在 cg.root/HISTORY_DIR/node_id 下以「秒级时间戳-微秒后缀」命名写入当前 frontmatter 与 content(写盘形态与 snapshot 逐位一致:同目录、同 nodefile 序列化、同 _write_node 封装钩子),_write_node 抛异常时返回 None,成功则追加一条 action="preimage" 的审计(reason 交代动作类与 pid)并返回相对 cg.root 且以 '/' 分隔的路径;
249
+ def snapshot_preimage(cg, node_id, action="", pid=None, reason="", actor=None):
250
+ """执行时点前像(设计 §四「回滚原语通用化」:快照面推广为一切 C/D/B)。
251
+
252
+ 与 `snapshot()` 的关系(**面复用、语义泛化**,既有行为一字不动):
253
+ `_protected_history/<id>/` + `_protected_audit.jsonl` 这一对从「仅受保护节点」
254
+ 推广为「一切 C/D/B 动作」的**前像面**——本函数是新入口,`snapshot()` /
255
+ `guard_*` 的既有形态(文件名 `%Y%m%d-%H%M%S.md`、审计 action="snapshot"、
256
+ reason「显式快照(rel)」、覆盖路径快照数)**不改一行**(守卫钉死)。
257
+ 两条差别都是为「执行时点前像」的场景补强:
258
+ · 文件名带微秒后缀——同一节点的多个变更单可能在**同一秒**内先后执行,
259
+ 秒级名会互相覆盖(探针实测:同秒二次 snapshot 返回同一路径)——前像
260
+ 被后来的前像踩掉,早先那张的回滚句柄就指向错误时点的内容;
261
+ · 审计 action="preimage" 且 reason 交代动作类/pid——回滚与 §七 R3 读数
262
+ (「存在实测回滚记录」)要能把「变更单前像」与既有显式快照区分开。
263
+
264
+ 为什么前像必须在**执行时点**拍(设计 §四 明文):变更单在提议时点构造,
265
+ 从提议到 accept 之间目标可能被第三方改动——回滚必须撤销**本变更本身**,
266
+ 而不是一并抹掉第三方改动。故由执行桥(`mdcos._mutation_execute`)在动作
267
+ 原语落盘**之前**拍摄,作为载荷 `before` 字段(执行时点快照引用)。
268
+
269
+ 边界(如实):加密库(覆写了 `_seal_content` 的实例)里本文件的密文层
270
+ 因每次封装熵不同**不保证逐字节可复现**——前像的语义是「明文内容 + 结构
271
+ 的可恢复」,逐字节比对在非加密库(含一切守卫合成库)上成立(探针实测)。
272
+ """
273
+ try:
274
+ node = cg.get(node_id)
275
+ except Exception:
276
+ node = None
277
+ if not node:
278
+ return None
279
+ d = os.path.join(cg.root, HISTORY_DIR, node_id)
280
+ os.makedirs(d, exist_ok=True)
281
+ ts = (time.strftime("%Y%m%d-%H%M%S", time.localtime())
282
+ + "-%06d" % (time.time_ns() // 1000 % 1000000))
283
+ p = os.path.join(d, f"{ts}.md")
284
+ try:
285
+ cg._write_node(node.get("id"), p, node.get("frontmatter") or {},
286
+ node.get("content") or "")
287
+ except Exception:
288
+ return None
289
+ rel = os.path.relpath(p, cg.root).replace("\\", "/")
290
+ _audit(cg, "preimage", node_id,
291
+ reason or ("执行时点前像(动作类 %s,pid=%s)"
292
+ % (action or "?", pid or "?")),
293
+ actor=actor if actor is not None else getattr(cg, "actor", None),
294
+ snapshot=rel)
295
+ return rel
296
+
297
+
223
298
  # 生效条件:cg.root/HISTORY_DIR/node_id 不是目录时返回 [];是目录时返回该目录下以 .md 结尾(不递归)的文件按名称排序后的 `HISTORY_DIR/node_id/文件名` 列表,无匹配文件则列表为空。
224
299
  def history(cg, node_id):
225
300
  """受保护节点的历史版本列表(按时间升序)。"""
@@ -431,7 +431,7 @@ def _node_digest(cg, nid) -> dict:
431
431
  N226(2026-09-28):此前直读 `cg.index` 无可见性判定 ⇒ 端点摘要(layer/
432
432
  tags/importance/writer/session/temporal)把读闸拒绝节点的元数据照返回
433
433
  (实测 guest 经 `cg(op="edges", expand_nodes=true)` 拿到私密节点摘要)。
434
- 同库同身份的 `stg.timeline`(stg.py:133/172)早已接线 `_readable`——本处
434
+ 同库同身份的 `stg.timeline`(stg.py:144/238)早已接线 `_readable`——本处
435
435
  是漏网的旁路出口,改为同一个跨层单点(`security.node_visible`)。
436
436
  """
437
437
  nodes = (getattr(cg, "index", None) or {}).get("nodes") or {}
@@ -26,6 +26,13 @@ noop 语义:**已评估、判定不改变任何现有记忆**——只留痕
26
26
  审计 md 节点)并关闭提案,不落业务节点、不进负记忆。它与 reject 的区别是
27
27
  「评估过了、无需改动」而非「否掉这条候选」,故不可借 noop 绕过 accept 门控。
28
28
 
29
+ 变更单(三档自治批次②,kind=mutation):队列里除「提案」外还有**变更单**
30
+ ——对既有记忆的 B 合并 / C 改写 / D 删除(设计 v0.2 §四)。`list` 会把它显式
31
+ 标为 `变更单(动作名→目标)`:accept = **执行**对应动作(B reinforce/converge、
32
+ C 覆写落盘、D 软删),reject = 原样留痕不执行;edit/merge/noop 对变更单未定义
33
+ (fail-closed 报错,不会静默当已处理)。存量条目无 `kind` 键,一律按提案走原
34
+ 路径(零迁移)。
35
+
29
36
  裁决留痕:decisions.jsonl + 审计 md 节点(由 review_decide 内部完成)。
30
37
 
31
38
  落盘归因(P1 修复,2026-09-26,DSH 端在役实测回告):
@@ -112,6 +119,27 @@ def _brief(rec, width=66):
112
119
  return text[:width] + ("…" if len(text) > width else "")
113
120
 
114
121
 
122
+ # 生效条件:rec 支持 .get 且其 kind 为 "mutation" 时返回「变更单(动作名→目标)」标签,其余(含缺键 = 存量提案)返回「提案」;本函数只读,不产生任何副作用;
123
+ def _kind_label(rec):
124
+ """队列条目类型标签(三档自治批次②:变更单与提案在同一队列里可分辨)。
125
+
126
+ 为什么要有:设计 §四「两类条目共用一个队列,靠类型字段区分」——显示面若
127
+ 不区分,裁决者面对一张 `[pid] pending · contextual 层` 的单子看不出它是
128
+ 「新写入候选」还是「对既有记忆的 B/C/D 变更单」,也无从知道确认后会发生
129
+ 什么。缺键(存量条目)一律按提案显示(零迁移)。
130
+ """
131
+ try:
132
+ kind = str(rec.get("kind") or "").strip()
133
+ slot = (rec.get("extra") or {}).get("mutation") or {}
134
+ except AttributeError:
135
+ return "提案"
136
+ if kind != "mutation":
137
+ return "提案"
138
+ name = slot.get("action_name") or slot.get("action") or "?"
139
+ tgt = slot.get("target") or rec.get("id")
140
+ return "变更单(%s%s)" % (name, ("→ " + str(tgt)) if tgt else "")
141
+
142
+
115
143
  # 生效条件:cg 与 args 就绪时按 args.cmd 分派——"list" 时 cg.review_list() 为空则打印空队列并返回 0、非空则逐条打印(tags 取真值拼接、layer/round 为假值显示 "?"/0)后返回 0;"rounds" 时打印 cg.review_rounds(args.pid) 并返回 0;"stats" 时打印 cg.review_stats() 的记录数/提案数/待审数/已关闭数与动作分布(含 noop 计数)并返回 0;"edit" 时以 args.content 加真值 args.tags(按逗号分割并剔除空项)/args.layer 组成 edits 调 cg.review_decide;其余 cmd(含 noop)以 getattr(args, "into", None) 与 args.reason 调 cg.review_decide;后两类再按 out.get("ok") 为真返回 0,否则打印 out 并返回 1。
116
144
  def _execute(cg, args):
117
145
  """按子命令执行裁决(cg 的生命周期由 main 统一收尾)。"""
@@ -123,8 +151,9 @@ def _execute(cg, args):
123
151
  print("待审 %d 条:" % len(pend))
124
152
  for r in pend:
125
153
  tags = (", tags=" + ",".join(r.get("tags") or [])) if r.get("tags") else ""
126
- print(" [%s] %s · %s 层%s · round=%s\n %s" % (
127
- r.get("pid"), r.get("status"), r.get("layer") or "?",
154
+ print(" [%s] %s · %s · %s 层%s · round=%s\n %s" % (
155
+ r.get("pid"), r.get("status"), _kind_label(r),
156
+ r.get("layer") or "?",
128
157
  tags, r.get("round") or 0, _brief(r)))
129
158
  print('\n裁决示例:python -m md_cg.review_cli accept <pid> --reason "实跑测试证据"')
130
159
  return 0