@furongjun1999/dsh-memory 0.4.7 → 0.4.8

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 (172) hide show
  1. package/README.md +92 -37
  2. package/codebuddy/CODEBUDDY.md +195 -185
  3. package/docs/Pi/345/217/257/345/255/246/344/271/240/344/274/230/347/202/271_/347/201/265/346/236/242/345/244/247/350/204/221/346/224/271/350/277/233/344/272/244/346/216/245_20260915.md +144 -0
  4. package/docs/README.md +111 -0
  5. package/docs/discipline/harnesses.yaml +215 -152
  6. package/docs/discipline/templates/full.md.tmpl +60 -58
  7. package/docs/eval//347/254/254/344/270/211/346/226/271/351/252/214/350/257/201/346/212/245/345/221/212_LoCoMo_/347/201/265/346/236/242_.md +127 -0
  8. package/docs/eval//347/254/254/344/270/211/346/226/271/351/252/214/350/257/201/346/212/245/345/221/212_LoCoMo_/347/201/265/346/236/242_.png +0 -0
  9. package/docs/eval//347/254/254/344/270/211/346/226/271/351/252/214/350/257/201/346/212/245/345/221/212_/347/201/265/346/236/242_vs_dejavu_/347/273/237/344/270/200/350/257/204/345/210/206_v7.md +202 -0
  10. package/docs/experiments/m4-role-probe/census.py +86 -0
  11. package/docs/experiments/m4-role-probe/probe.py +89 -0
  12. package/docs/experiments/mapped_confidence/bench_mapped_conf.py +216 -0
  13. package/docs/experiments/mapped_confidence/post_check.py +75 -0
  14. package/docs/experiments/mapped_confidence/repro_thirdparty_tol.py +83 -0
  15. package/docs/experiments/mapped_confidence/result_mapped_conf.json +215 -0
  16. package/docs/{ → hive/}/350/234/202/345/267/242/345/267/245/344/275/234/350/256/260/345/277/206_/351/241/271/347/233/256/350/256/241/345/210/222.md +14 -2
  17. package/docs/{README → mdcg/README}/350/257/246/347/273/206/347/211/210_v0.4.5.md +9 -9
  18. package/docs/{ → mdcg/}/344/270/273/344/273/243/347/220/206/345/255/220/344/273/243/347/220/206/350/256/260/345/277/206/346/236/266/346/236/204/350/256/276/350/256/241.md +1 -1
  19. 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 +261 -0
  20. package/docs/{ → mdcg/}/345/215/225/345/205/203/350/207/252/346/210/221/351/224/232/347/202/271_/347/263/273/347/273/237/346/217/220/347/244/272/350/257/215/346/240/207/345/207/206_v0.3.md +1 -1
  21. package/docs/mdcg//345/255/220/344/273/243/347/220/206/351/205/215/347/275/256/346/240/207/345/207/206_v0.4.md +148 -0
  22. package/docs/{ → mdcg/}/346/272/220/347/240/201/347/272/247/346/236/266/346/236/204/345/256/241/350/256/241_GPT/346/211/271/350/257/204/345/257/271/347/205/247_v1.0.md +2 -2
  23. package/docs/{ → mdcg/}/347/201/265/346/236/242/344/270/211/345/261/202/346/213/206/345/210/206/350/247/204/345/210/222_v0.1.md +181 -181
  24. package/docs/{ → mdcg/}/347/274/272/345/217/243/345/215/225_P0/346/224/266/345/217/243_v0.1.md +2 -2
  25. package/docs/{ → mdcg/}/350/256/244/347/237/245/345/233/276_G4-G8/347/274/272/345/217/243/350/243/201/345/256/232/345/215/225_v0.1.md +1 -1
  26. package/docs/{ → mdcg/}/350/256/244/347/237/245/345/233/276_/347/264/242/345/274/225/344/270/216/345/267/245/347/250/213/350/247/204/350/214/203/345/214/226_/350/256/241/345/210/222_v0.1.md +4 -4
  27. package/docs/{ → mdcg/}/350/256/260/345/277/206/346/223/215/344/275/234/347/263/273/347/273/237_MdCGOS/344/270/216MCP/346/216/245/345/205/245_v0.1.md +2 -2
  28. package/docs/plans/GridWorld/346/234/200/345/260/217/351/227/255/347/216/257/350/247/204/346/240/274_v0.1.md +176 -0
  29. package/docs/{ → swarm/}/350/234/202/347/276/244/344/272/222/350/201/224_v0.1.md +3 -3
  30. package/docs/swarm//350/234/202/347/276/244/345/220/214/351/224/231/346/243/200/346/265/213/345/256/236/351/252/214/345/215/217/350/256/256_v0.1.md +242 -0
  31. package/docs/theory//345/215/225/347/272/277/347/250/213/344/270/216/346/263/250/346/204/217/345/212/233/351/233/206/344/270/255_/346/227/240/344/272/211/350/256/256/347/220/206/350/256/272/346/226/207/346/241/243_v1.0.md +129 -0
  32. package/docs/theory//345/271/266/345/217/221/345/277/205/347/204/266/346/200/247/347/220/206/350/256/272_v0.2.md +134 -0
  33. package/docs/theory//346/246/202/345/277/265/345/210/206/345/261/202/345/257/271/351/275/220/350/241/250_v0.1.md +145 -0
  34. package/docs/{ → theory/}/347/220/206/350/256/272_/346/234/272/345/210/266_/344/273/243/347/240/201_/345/256/236/351/252/214_/347/274/272/345/217/243/347/237/251/351/230/265_v0.1.md +3 -3
  35. package/docs/{ → theory/}/347/220/206/350/256/272/344/273/223/346/213/206/345/210/206/344/270/216/347/231/275/347/256/261/347/237/245/350/257/206/345/272/223/345/206/205/350/277/201_v0.1.md +1 -1
  36. package/docs/theory//350/256/244/347/237/245/344/273/243/347/220/206/344/270/216/346/224/266/346/225/233/347/273/223/346/236/204_/346/235/241/344/273/266/350/256/272/351/207/215/346/236/204_v0.1.md +221 -0
  37. package/docs/world_model//344/270/226/347/225/214/346/250/241/345/236/213_/347/245/236/347/273/217/347/275/221/347/273/234/345/272/225/345/261/202/346/236/266/346/236/204_v1.0.md +237 -0
  38. package/docs//345/255/230/347/256/227/344/270/200/344/275/223/346/236/266/346/236/204/345/255/246/344/271/240_/345/244/226/351/203/250/347/220/206/350/256/272/345/257/271/347/205/247/344/270/216/350/267/257/347/272/277/344/272/244/346/216/245_20260916.md +98 -0
  39. package/docs//345/255/230/347/256/227/344/270/200/344/275/223/347/245/236/347/273/217/347/275/221/347/273/234/346/236/266/346/236/204_/346/200/273/347/272/262/344/270/216/344/272/244/346/216/245_20260916.md +109 -0
  40. package/docs//345/267/245/344/275/234/347/272/252/345/276/213_/350/256/244/347/237/245/345/233/276/346/235/241/347/233/256_v1.1.json +34 -6
  41. package/docs//347/231/275/347/256/261/345/214/226/347/272/262/351/242/206_/344/270/211/351/241/271/347/233/256/345/255/246/344/271/240/346/200/273/346/236/266/346/236/204/344/270/216Ghidra/344/272/244/346/216/245_20260916.md +104 -0
  42. package/docs//350/256/260/345/277/206/350/257/204/345/256/241/347/263/273/347/273/237_/347/253/213/351/241/271/350/256/276/350/256/241/344/270/216/346/226/275/345/267/245/344/272/244/346/216/245_20260915.md +183 -0
  43. package/dsh/README.md +1 -1
  44. package/lib/hooks.d.ts +5 -0
  45. package/lib/hooks.js +10 -1
  46. package/lib/lib/prompt_safety.d.ts +48 -0
  47. package/lib/lib/prompt_safety.js +62 -0
  48. package/md_cg/audit.py +76 -2
  49. package/md_cg/bench_membench.py +10 -5
  50. package/md_cg/bench_progressive.py +48 -9
  51. package/md_cg/branches.py +275 -0
  52. package/md_cg/ccgc.py +884 -0
  53. package/md_cg/conformance.py +662 -0
  54. package/md_cg/consistency.py +1 -1
  55. package/md_cg/corpus.py +1 -1
  56. package/md_cg/eval_common.py +6 -5
  57. package/md_cg/forgetting.py +11 -3
  58. package/md_cg/fsutil.py +63 -1
  59. package/md_cg/identity.py +2 -2
  60. package/md_cg/insight.py +2 -2
  61. package/md_cg/lifecycle.py +262 -0
  62. package/md_cg/mcp_server.py +547 -140
  63. package/md_cg/mdcg.py +206 -18
  64. package/md_cg/mdcos.py +512 -50
  65. package/md_cg/metacognition.py +2 -2
  66. package/md_cg/mreview/__init__.py +25 -0
  67. package/md_cg/mreview/__main__.py +107 -0
  68. package/md_cg/mreview/bundle.py +170 -0
  69. package/md_cg/mreview/candidates.py +253 -0
  70. package/md_cg/mreview/govern.py +674 -0
  71. package/md_cg/mreview/locate.py +905 -0
  72. package/md_cg/mreview/pipeline.py +701 -0
  73. package/md_cg/mreview/rules/duplication.json +21 -0
  74. package/md_cg/mreview/rules/field_coverage.json +54 -0
  75. package/md_cg/mreview/rules/source_license.json +21 -0
  76. package/md_cg/mreview/rules/template_flow.json +21 -0
  77. package/md_cg/mreview/ruleset.py +238 -0
  78. package/md_cg/nodefile.py +38 -0
  79. package/md_cg/predict.py +24 -6
  80. package/md_cg/protect.py +4 -4
  81. package/md_cg/refindex.py +41 -2
  82. package/md_cg/scrub.py +5 -1
  83. package/md_cg/self_state.py +1 -1
  84. package/md_cg/semantic/en_normalizer.py +85 -25
  85. package/md_cg/sustain.py +18 -0
  86. package/md_cg/tasks.py +447 -0
  87. package/md_cg/test_action_derive.py +203 -0
  88. package/md_cg/test_audit_rotate.py +270 -0
  89. package/md_cg/test_branches.py +249 -0
  90. package/md_cg/test_ccg_perturb.py +1 -1
  91. package/md_cg/test_ccgc.py +423 -0
  92. package/md_cg/test_conformance.py +343 -0
  93. package/md_cg/test_en_pipeline.py +24 -0
  94. package/md_cg/test_health_scale.py +173 -0
  95. package/md_cg/test_identity_attribution.py +6 -0
  96. package/md_cg/test_index_durability.py +224 -0
  97. package/md_cg/test_lifecycle.py +309 -0
  98. package/md_cg/test_mr_m1.py +679 -0
  99. package/md_cg/test_mr_m2.py +587 -0
  100. package/md_cg/test_mr_m3.py +703 -0
  101. package/md_cg/test_mr_m4.py +485 -0
  102. package/md_cg/test_p1.py +1 -1
  103. package/md_cg/test_p11_consistency.py +1 -1
  104. package/md_cg/test_p12_metacognition.py +2 -2
  105. package/md_cg/test_p16_self_state.py +1 -1
  106. package/md_cg/test_p26_refindex.py +1 -1
  107. package/md_cg/test_p27_docindex.py +22 -2
  108. package/md_cg/test_p28_refcheck.py +1 -1
  109. package/md_cg/test_p29_session_ingest_export.py +1 -1
  110. package/md_cg/test_p2_mcp.py +7 -1
  111. package/md_cg/test_p38_contextualize.py +1 -1
  112. package/md_cg/test_p39_vision_evidence.py +1 -1
  113. package/md_cg/test_p40_refine_worklist.py +1 -1
  114. package/md_cg/test_p41_evolve_patrol.py +1 -1
  115. package/md_cg/test_p42_provenance.py +1 -1
  116. package/md_cg/test_p43_pooling.py +41 -13
  117. package/md_cg/test_read_clip.py +137 -0
  118. package/md_cg/test_review_conformance.py +310 -0
  119. package/md_cg/test_tasks.py +409 -0
  120. package/md_cg/test_tool_face.py +189 -0
  121. package/md_cg/test_twophase.py +286 -0
  122. package/md_cg/test_writepipe.py +210 -0
  123. package/md_cg/theory.py +1 -1
  124. package/md_cg/tokens.py +95 -5
  125. package/md_cg/tool_face.py +250 -0
  126. package/md_cg/twophase.py +221 -0
  127. package/md_cg/units.py +546 -0
  128. package/md_cg/vision_evidence.py +1 -1
  129. package/md_cg/weights.py +1 -1
  130. package/md_cg/whitebox.py +1 -1
  131. package/md_cg/whitebox_kb/data/verify_cache.json +28 -0
  132. package/md_cg/whitebox_kb/data/verify_savings.jsonl +46 -0
  133. package/md_cg/whitebox_kb/wisdom/audit_log/chain_heat.json +10 -10
  134. package/md_cg/whitebox_kb/wisdom/wisdom-book-cloud.db-shm +0 -0
  135. package/md_cg/whitebox_kb/wisdom/wisdom-book-cloud.db-wal +0 -0
  136. package/md_cg/writelimit.py +19 -3
  137. package/md_cg/writepipe.py +372 -0
  138. package/package.json +1 -1
  139. package/src/hooks.ts +10 -1
  140. package/src/lib/prompt_safety.ts +62 -0
  141. package/zcode/AGENTS.md +195 -185
  142. package/docs//345/212/237/350/203/275/350/260/203/347/224/250/346/230/240/345/260/204/350/241/250_v0.1.md +0 -221
  143. /package/docs/{AGI → eval/AGI}/344/270/203/347/273/264/350/257/204/345/210/206/346/212/245/345/221/212_md_cg_v1.0.md" +0 -0
  144. /package/docs/{AGI → eval/AGI}/344/270/203/347/273/264/350/257/204/345/210/206/346/212/245/345/221/212_md_cg_v2.0.md" +0 -0
  145. /package/docs/{ → eval/}/345/256/236/351/252/214/346/226/271/346/241/210_/345/255/246/344/271/240/351/227/255/347/216/257AB/344/270/216/346/250/252/350/257/204_v1.0.md" +0 -0
  146. /package/docs/{ → eval/}/346/250/252/350/257/204_/345/205/255/345/256/266100/351/242/230/344/270/255/350/213/261/345/217/214/346/237/245_v1.0.md" +0 -0
  147. /package/docs/{guardrail-charter.md → mdcg/guardrail-charter.md} +0 -0
  148. /package/docs/{lingshu_tutorial.html → mdcg/lingshu_tutorial.html} +0 -0
  149. /package/docs/{memory-assessment.html → mdcg/memory-assessment.html} +0 -0
  150. /package/docs/{memory_score.html → mdcg/memory_score.html} +0 -0
  151. /package/docs/{memory_score.png → mdcg/memory_score.png} +0 -0
  152. /package/docs/{release_v0.3.0.md → mdcg/release_v0.3.0.md} +0 -0
  153. /package/docs/{release_v0.4.5.md → mdcg/release_v0.4.5.md} +0 -0
  154. /package/docs/{tool_table_v0.3.0.md → mdcg/tool_table_v0.3.0.md} +0 -0
  155. /package/docs/{ → mdcg/}/344/273/244/347/211/214/344/270/216/350/247/222/350/211/262/346/235/203/350/201/214/345/210/206/347/246/273_v0.1.md" +0 -0
  156. /package/docs/{ → mdcg/}/347/201/265/346/236/24282/345/267/245/345/205/267_/345/212/237/350/203/275/346/225/264/347/220/206/344/270/216/350/277/201/347/247/273/346/230/240/345/260/204_v0.1.md" +0 -0
  157. /package/docs/{ → mdcg/}/347/201/265/346/236/242MCP/345/267/245/345/205/267/346/200/273/350/241/250_v3.4.md" +0 -0
  158. /package/docs/{ → mdcg/}/347/201/265/346/236/242_/350/207/252/346/210/221/345/261/202/345/256/232/344/271/211.md" +0 -0
  159. /package/docs/{ → mdcg/}/350/256/244/347/237/245/345/233/276_MD/347/233/256/345/275/225/346/226/271/346/241/210_v0.1.md" +0 -0
  160. /package/docs/{ → mdcg/}/350/256/244/347/237/245/345/233/276_/346/235/241/344/273/266/347/251/272/351/227/264/345/220/210/346/210/220/344/270/216/347/224/237/346/225/210/346/235/241/344/273/266/345/217/243/345/276/204_v0.1.md" +0 -0
  161. /package/docs/{ → mdcg/}/350/256/244/347/237/245/345/233/276/344/275/234/344/270/272/350/256/260/345/277/206/346/223/215/344/275/234/347/263/273/347/273/237_/350/257/204/344/274/260/344/270/216/350/267/257/347/272/277_v0.1.md" +0 -0
  162. /package/docs/{ → plans/}/345/221/275/345/220/215/346/262/273/347/220/206_/351/241/271/347/233/256/350/256/241/345/210/222.md" +0 -0
  163. /package/docs/{ → plans/}/347/237/245/350/257/206/345/233/276/350/260/261/351/241/271/347/233/256_/344/272/244/346/216/245/346/226/207/346/241/243_20260914.md" +0 -0
  164. /package/docs/{ → swarm/}/350/234/202/347/276/244/345/244/232/346/231/272/350/203/275/344/275/223_/344/275/277/347/224/250/346/212/245/345/221/212_20260913.md" +0 -0
  165. /package/docs/{ → swarm/}/350/234/202/347/276/244/345/244/232/346/231/272/350/203/275/344/275/223_/345/212/237/350/203/275/350/257/264/346/230/216_v0.6.md" +0 -0
  166. /package/docs/{ → swarm/}/350/234/202/347/276/244/347/233/262/345/214/272/346/240/207/350/256/260_v1.0.md" +0 -0
  167. /package/docs/{ → theory/}/344/277/241/346/201/257/345/267/256/344/270/272/344/273/200/344/271/210/345/277/205/347/204/266/345/255/230/345/234/250/344/270/224/350/207/252/347/204/266/346/211/251/345/244/247.md" +0 -0
  168. /package/docs/{ → theory/}/346/231/272/350/203/275/347/232/204/345/205/254/347/220/206/345/214/226/345/237/272/347/237/263.md" +0 -0
  169. /package/docs/{ → theory/}/346/231/272/350/203/275/347/232/204/350/256/244/347/237/245/350/277/207/347/250/213.md" +0 -0
  170. /package/docs/{ → theory/}/346/231/272/350/203/275/350/256/2723.4.md" +0 -0
  171. /package/docs/{ → theory/}/347/231/275/347/256/261/346/231/272/350/203/275/346/230/257/344/273/200/344/271/210/357/274/237.md" +0 -0
  172. /package/docs/{ → theory/}/347/231/275/347/256/261/346/231/272/350/203/275/347/263/273/345/210/227/302/267/347/254/254/344/272/224/347/257/207/357/274/232/350/256/251AI/347/234/237/346/255/243/350/243/205/344/270/212/350/256/260/345/277/206.md" +0 -0
@@ -0,0 +1,674 @@
1
+ # -*- coding: utf-8 -*-
2
+ """记忆评审流水线 · 级 4:M4 落库治理(确定性部分 · rule 驱动 · 永不删除)。
3
+
4
+ 真源:`docs/记忆评审系统_立项设计与施工交接_20260915.md` §3 第 4 级 + §6 验收 + §7 负面清单。
5
+ 本模块只做**机械可裁决**的治理动作(回填等);LLM 语义类(合并 / 降权)不在其列——
6
+ 那属 M2 建议 + designer 终裁,本轮不落地。
7
+
8
+ **规则驱动,引擎不写死**:回填哪个字段、作用于哪些层,不写在本文件里,从 M1 规则库
9
+ (`rules/*.json`,数据真源)读——`R-ROLE-MISSING` 的 `matcher.layer` 定作用域、
10
+ `mechanical[check=field_absent].field` 定字段。改靶子 = 改数据,引擎冻结。
11
+
12
+ **来源链(只搬运已声明证据)**:
13
+
14
+ ① `tags` `role:<值>` 标签——显式成文声明;
15
+ ② `map` `frontmatter.writer` → role 的**声明映射表**(`WRITER_ROLE_MAP`)。
16
+ 当前**故意为空**:库内无任何成文映射能把 writer(`MDCG_ACTOR`:
17
+ dsh-memory / codebuddy / zcode / designer-cli…)落到
18
+ `mdcos.ALL_ROLES`;`scripts/review_cli.py` 的
19
+ `Principal(role="designer")` 属**权限角色**(`tokens.ROLE_SPECS`),
20
+ 与节点 role 是两套词汇,混填即污染域。故留空 + 通道保留,待终裁补表;
21
+ ③ `layer_default` 层默认投影——真源 = `mdcos.MdCGOS.add` 成文语义「默认 role=None
22
+ (知识)」,即缺省来源 = knowledge。该档是「隐含默认的显式化」,
23
+ 断言强度高于 ①②,故单独分档上报(`by_source`);只认显式成文声明时
24
+ 用 `sources=("tags","map")` 关掉它。
25
+
26
+ **域封闭**:写入值必须 ∈ `mdcos.ALL_ROLES`;域外值一律不写,记 `*_out_of_domain` 且
27
+ **停止降级**(显式声明了域外值 ≠ 无声明,回退到默认档会把待裁决冲突悄悄抹平)。
28
+ `tool-output/command/edit` 属工作角色(`WORK_ROLES`,默认不参与正排,见
29
+ `mdcos._candidates`)。故 ② `map` 档**显式拒绝**工作角色(命中即
30
+ `map_role_is_work_role`,不降级)——映射表是本引擎的推断,把知识节点推成工作角色
31
+ 等于亲手把它从默认召回里摘掉;① `tags` 档是**写入者成文声明**,予以尊重(治理层
32
+ 不替作者改主意)。
33
+
34
+ **五纪律**(对齐 `backfill`):不猜测(无来源 → `unfillable` + 原因分布,绝不编造)/
35
+ 可预演(`role_plan` 只出报表,`role_apply` 写前重查,已有值计 `skipped_drift`)/
36
+ 可留痕(`_govern.jsonl`:前后值 / 依据 / 来源档 / 批次 / 操作者 / 规则 id)/
37
+ 可回滚(只在当前值 == 写入值时撤销,否则 `conflict`;原本无键 → 删键还原)/
38
+ fail-closed(密文与不可读节点跳过,绝不解密回写;靶规则缺失抛错)。
39
+ §7 负面清单:只写 frontmatter 一个字段,不动正文、永不删除。
40
+ """
41
+ from __future__ import annotations
42
+
43
+ import argparse
44
+ import json
45
+ import os
46
+ import sys
47
+ import time
48
+
49
+ from .. import crypto
50
+ from ..backfill import _as_cg, _entry_id, _readable_guard, _sha
51
+ from ..fsutil import append_jsonl, read_jsonl
52
+ from ..mdcos import ALL_ROLES, WORK_ROLES
53
+ from .ruleset import _as_rules, _blank
54
+
55
+ #: 留痕文件名(与 `backfill._backfill.jsonl` 分立:治理动作须能独立审计)
56
+ GOVERN_LOG = "_govern.jsonl"
57
+
58
+ #: 默认靶规则;`field_absent` 检查名(与 `ruleset.MECH_CHECKS` 同名)
59
+ RULE_ROLE = "R-ROLE-MISSING"
60
+ CHECK_FIELD_ABSENT = "field_absent"
61
+
62
+ #: 来源档
63
+ SOURCE_TAGS = "tags"
64
+ SOURCE_MAP = "map"
65
+ SOURCE_LAYER = "layer_default"
66
+ ALL_SOURCES = (SOURCE_TAGS, SOURCE_MAP, SOURCE_LAYER)
67
+ #: 默认只开「成文声明」两档;③ `layer_default` **必须显式开启**。
68
+ #: 依据(2026-09-16 真实库实测):writer/tags 面无任何成文声明,故默认档在真实库上
69
+ #: 产出 `targeted=0 / unfillable=11120`(原因逐一列出)——这是**诚实结论**,而不是
70
+ #: 用 ③ 把指标刷绿:role"覆盖≥90%"若由常量填满即失去判据意义,且 ③ 会关掉 M1 的
71
+ #: `field_absent`,使 M2 的 `role_inferable` 语义判定**永不发生**——「字段有值」冒充
72
+ #: 「来源已知」。终裁开启前请先读 `LAYER_DEFAULT_WARNING`。
73
+ DEFAULT_SOURCES = (SOURCE_TAGS, SOURCE_MAP)
74
+
75
+ #: 开启 ③ `layer_default` 前**必读**的终裁提示(`role_plan`/CLI 在 `sources` 含 ③ 时透出)。
76
+ #: 它填的是**常量**而非**来源**:`role_ratio_kn` 会立刻跳到 100%(层内几乎全是 knowledge),
77
+ #: 但 ① M1 的 `R-ROLE-MISSING`(`field_absent`)随之永不命中——规则失去发现能力;
78
+ #: ② M2 的 `role_inferable` 语义判定(LLM 从 writer/tags/正文判 role)永无输入——
79
+ #: 即以「字段有值」冒充「来源已知」。③ 若 writer 有声明而库内无法映射(当前真实库
80
+ #: 全部如此),本档会把该冲突抹平成「已治理」,掩盖待裁决项。
81
+ LAYER_DEFAULT_WARNING = (
82
+ "开启 layer_default 是把『来源未知』填成常量 knowledge:role 覆盖率立刻达标,"
83
+ "但 M1 field_absent 与 M2 role_inferable 同时失效(字段有值冒充来源已知),"
84
+ "并以层默认抹平 writer→role 待裁决冲突。须 designer 终裁、显式传 "
85
+ "sources=tags,map,layer_default、留痕署名后方可使用。")
86
+
87
+ #: 标签前缀:`role:<值>` 为成文 role 声明
88
+ ROLE_TAG_PREFIX = "role:"
89
+
90
+ #: `writer` → role 的**声明映射表**(见模块 docstring ②:当前故意为空)
91
+ WRITER_ROLE_MAP: dict = {}
92
+
93
+ #: 层默认投影表:真源 = `mdcos.MdCGOS.add` docstring「默认 role=None(知识)」。
94
+ #: 只有层名本身就在 `ALL_ROLES` 里时才投影(knowledge 是唯一的一个)。
95
+ LAYER_DEFAULT_ROLE = {"knowledge": "knowledge"}
96
+
97
+ BASIS_TAG = "frontmatter.tags(role:<值> 显式声明)"
98
+ BASIS_LAYER = "mdcos.MdCGOS.add 默认语义(role=None ⇒ knowledge)"
99
+
100
+ REASON_NO_SOURCE = "no_source_declared"
101
+ REASON_TAG_OOD = "tag_role_out_of_domain"
102
+ REASON_MAP_OOD = "map_role_out_of_domain"
103
+ REASON_MAP_WORK = "map_role_is_work_role"
104
+ REASON_WRITER_NO_MAP = "writer_no_mapping"
105
+
106
+ ACTION_ROLE = "role"
107
+ ACTION_ROLE_ROLLBACK = "role_rollback"
108
+ BATCH_DEFAULT = "m4-role"
109
+
110
+ ACTIONS = ("role", "role_rollback", "role_history", "role_stats")
111
+
112
+
113
+ # ---- 通用工具 ------------------------------------------------------------
114
+
115
+ def _log_path(cg) -> str:
116
+ return os.path.join(cg.root, GOVERN_LOG)
117
+
118
+
119
+ def _tags(fm: dict) -> list:
120
+ return [t for t in ((fm or {}).get("tags") or []) if isinstance(t, str)]
121
+
122
+
123
+ def _reason_key(kind: str, detail: str = None) -> str:
124
+ return kind if not detail else "%s:%s" % (kind, detail)
125
+
126
+
127
+ def _bump(box: dict, key: str) -> None:
128
+ box[key] = box.get(key, 0) + 1
129
+
130
+
131
+ # ---- 靶子定位(rule 驱动)------------------------------------------------
132
+
133
+ def role_rule(*, rules=None, rules_dir=None, rule_id: str = RULE_ROLE) -> dict:
134
+ """从 M1 规则库取「role 回填」靶规则 → `{field, layers, remedy, …}`。
135
+
136
+ 规则库是靶子的**唯一真源**:缺规则 / 缺 `matcher.layer` / 缺
137
+ `field_absent.field` 一律抛 ValueError(fail-closed)——规则消失不是
138
+ 「没有要治理的东西」,而是治理靶子失效,必须显式红灯而非静默空跑。
139
+ """
140
+ rl = _as_rules(rules, rules_dir)
141
+ hit = next((r for r in rl if r.get("id") == rule_id), None)
142
+ if hit is None:
143
+ raise ValueError("规则库无 %s(可用:%s)"
144
+ % (rule_id, [r.get("id") for r in rl]))
145
+ layers = list((hit.get("matcher") or {}).get("layer") or [])
146
+ field = None
147
+ for spec in hit.get("mechanical") or []:
148
+ if spec.get("check") == CHECK_FIELD_ABSENT and spec.get("field"):
149
+ field = spec["field"]
150
+ break
151
+ if not layers or not field:
152
+ raise ValueError("规则 %s 未声明 matcher.layer / %s.field——无法定位回填靶"
153
+ % (rule_id, CHECK_FIELD_ABSENT))
154
+ return {"rule_id": rule_id, "title": hit.get("title"), "field": field,
155
+ "layers": layers, "severity": hit.get("severity"),
156
+ "remedy": hit.get("remedy"), "llm": list(hit.get("llm") or [])}
157
+
158
+
159
+ # ---- 取值推导(唯一入口:只搬运已声明的证据)------------------------------
160
+
161
+ def _candidate_role(e, fm, *, sources, role_map) -> tuple:
162
+ """→ `(role, basis, source, reason)`:有来源则 reason=None;无来源则 role=None。
163
+
164
+ 按声明强度优先:`tags`(成文)> `map`(声明表)> `layer_default`(隐含默认显式化)。
165
+ 命中**域外值**时立即返回且**不降级**:显式声明了域外值 ≠ 无声明——继续回退到更弱的
166
+ 来源会把一个待 designer 裁决的冲突悄悄抹平。
167
+ """
168
+ if SOURCE_TAGS in sources:
169
+ for t in _tags(fm):
170
+ if t.startswith(ROLE_TAG_PREFIX):
171
+ v = t[len(ROLE_TAG_PREFIX):].strip().lower()
172
+ if v in ALL_ROLES:
173
+ return v, BASIS_TAG, SOURCE_TAGS, None
174
+ return None, None, None, _reason_key(REASON_TAG_OOD, v or "空")
175
+ if SOURCE_MAP in sources:
176
+ w = str(fm.get("writer") or "").strip().lower()
177
+ v = str((role_map or {}).get(w) or "").strip().lower() if w else ""
178
+ if v:
179
+ if v in WORK_ROLES:
180
+ # 映射表是**我们的推断**:把 knowledge 层节点推成工作角色会让它在默认
181
+ # 召回中被剔除(`_candidates` 默认排除 WORK_ROLES)——故意不做,红灯。
182
+ return None, None, None, _reason_key(REASON_MAP_WORK, v)
183
+ if v in ALL_ROLES:
184
+ return v, "WRITER_ROLE_MAP[%s]" % w, SOURCE_MAP, None
185
+ return None, None, None, _reason_key(REASON_MAP_OOD, v)
186
+ if SOURCE_LAYER in sources:
187
+ v = LAYER_DEFAULT_ROLE.get(str(e.get("layer") or ""))
188
+ if v:
189
+ return v, BASIS_LAYER, SOURCE_LAYER, None
190
+ w = str(fm.get("writer") or "").strip()
191
+ return None, None, None, (_reason_key(REASON_WRITER_NO_MAP, w) if w
192
+ else REASON_NO_SOURCE)
193
+
194
+
195
+ def _classify(cg, e, nid, *, sources, role_map) -> tuple:
196
+ """单条裁决 → `(skip_reason, item|gap)`;skip_reason 为空串表示可回填。"""
197
+ if not _readable_guard(cg, e):
198
+ return "denied", None
199
+ fm, content = cg._read(e)
200
+ if fm is None:
201
+ return "unreadable", None
202
+ if crypto.is_encrypted(content):
203
+ return "locked", None
204
+ if not _blank(fm.get("role")):
205
+ return "present", None
206
+ role, basis, src, reason = _candidate_role(e, fm, sources=sources,
207
+ role_map=role_map)
208
+ if not role:
209
+ return "unfillable", {"id": nid, "layer": e.get("layer"),
210
+ "reason": reason, "writer": fm.get("writer")}
211
+ return "", {"id": nid, "layer": e.get("layer"), "role": role, "source": src,
212
+ "basis": basis, "before": fm.get("role"),
213
+ "had_key": "role" in fm, "writer": fm.get("writer")}
214
+
215
+
216
+ def _iter_scope(cg, *, layers, layer=None, prefix=None, ids=None) -> list:
217
+ """按规则作用域遍历索引条目 → `[(nid, entry)]`(只读、nid 稳定序)。
218
+
219
+ `layer` 收窄只能收窄到规则已声明的层内——跨层即抛错(同 `ruleset` 的
220
+ 「执行时按同一条件过滤,绝不跨层套用」)。
221
+ """
222
+ want = set(layers)
223
+ if layer:
224
+ if layer not in want:
225
+ raise ValueError("layer=%s 不在靶规则作用域 %s 内(绝不跨层套用)"
226
+ % (layer, sorted(want)))
227
+ want = {layer}
228
+ idw = set(ids) if ids else None
229
+ out = []
230
+ for nid in sorted((cg.index.get("nodes") or {})):
231
+ if idw is not None and nid not in idw:
232
+ continue
233
+ if prefix and not nid.startswith(prefix):
234
+ continue
235
+ e = cg.index["nodes"][nid]
236
+ if str(e.get("layer")) not in want:
237
+ continue
238
+ out.append((nid, e))
239
+ return out
240
+
241
+
242
+ # ---- 预演 -----------------------------------------------------------------
243
+
244
+ def role_plan(x, layer=None, limit=None, ids=None, prefix=None, *,
245
+ sources=DEFAULT_SOURCES, role_map=None, rule_id=RULE_ROLE,
246
+ rules=None, rules_dir=None, sample=0) -> dict:
247
+ """预演:产出可回填清单 + 不可回填原因分布,**不写盘**。
248
+
249
+ `sources` 见模块 docstring 的三档来源;`role_map` 覆盖 `WRITER_ROLE_MAP`
250
+ (designer 终裁补声明表的入口,不改库)。
251
+ """
252
+ cg = _as_cg(x)
253
+ rule = role_rule(rules=rules, rules_dir=rules_dir, rule_id=rule_id)
254
+ rm = dict(WRITER_ROLE_MAP)
255
+ rm.update({str(k).strip().lower(): v for k, v in (role_map or {}).items()})
256
+ rep = {"root": cg.root, "dry_run": True, "action": ACTION_ROLE,
257
+ "rule": rule, "sources": list(sources), "role_map": rm,
258
+ "layer": layer, "prefix": prefix,
259
+ "nodes_scanned": 0, "skipped_out_of_scope": 0,
260
+ "skipped_locked": 0, "skipped_denied": 0, "skipped_unreadable": 0,
261
+ "skipped_present": 0, "unfillable": 0, "unfillable_by_reason": {},
262
+ "targeted": 0, "by_source": {}, "items": []}
263
+ scope_layers = set(rule["layers"])
264
+ idw = set(ids) if ids else None
265
+ for nid in sorted((cg.index.get("nodes") or {})):
266
+ if idw is not None and nid not in idw:
267
+ continue
268
+ if prefix and not str(nid).startswith(prefix):
269
+ continue
270
+ e = cg.index["nodes"][nid]
271
+ if str(e.get("layer")) not in scope_layers:
272
+ rep["skipped_out_of_scope"] += 1
273
+ for nid, e in _iter_scope(cg, layers=rule["layers"], layer=layer,
274
+ prefix=prefix, ids=ids):
275
+ rep["nodes_scanned"] += 1
276
+ skip, item = _classify(cg, e, nid, sources=sources, role_map=rm)
277
+ if skip == "unfillable":
278
+ rep["unfillable"] += 1
279
+ # 原因键带明细(如 writer_no_mapping:dsh-memory)——聚合即得逐来源分布
280
+ _bump(rep["unfillable_by_reason"], str(item["reason"]))
281
+ continue
282
+ if skip:
283
+ rep["skipped_%s" % skip] = rep.get("skipped_%s" % skip, 0) + 1
284
+ continue
285
+ item["entry_id"] = _entry_id(ACTION_ROLE, nid)
286
+ item["ref"] = e.get("ref")
287
+ rep["targeted"] += 1
288
+ _bump(rep["by_source"], item["source"])
289
+ if limit is None or len(rep["items"]) < limit:
290
+ rep["items"].append(item)
291
+ rep["planned_ids"] = [i["id"] for i in rep["items"]]
292
+ if sample and rep["planned_ids"]:
293
+ step = max(1, len(rep["planned_ids"]) // int(sample))
294
+ rep["sample"] = rep["planned_ids"][::step][:int(sample)]
295
+ rep["rule_layers"] = sorted(scope_layers)
296
+ return rep
297
+
298
+
299
+ # ---- 执行 / 回滚 / 留痕 / 对照 -------------------------------------------
300
+
301
+ def role_apply(x, ids=None, entry_ids=None, layer=None, limit=None,
302
+ batch=BATCH_DEFAULT, sources=DEFAULT_SOURCES, role_map=None,
303
+ rule_id=RULE_ROLE, rules=None, rules_dir=None, actor=None,
304
+ prefix=None) -> dict:
305
+ """执行回填:逐节点改写 md 的 role 字段,写 `_govern.jsonl` 留痕。
306
+
307
+ 写前**重查**一次(预演 → 执行之间节点可能被改动):已有 role 值 → `skipped_drift`
308
+ 一律不动;密文 / 不可读 → `skipped_locked` / `skipped_missing`,**绝不解密回写**。
309
+ `ids` / `prefix` / `entry_ids` 三重收窄,`entry_ids` 优先(工单式精确执行)。
310
+ """
311
+ cg = _as_cg(x)
312
+ batch = batch or BATCH_DEFAULT
313
+ p = role_plan(cg, layer=layer, limit=limit, ids=ids, prefix=prefix,
314
+ sources=sources, role_map=role_map, rule_id=rule_id,
315
+ rules=rules, rules_dir=rules_dir)
316
+ items = p["items"]
317
+ if entry_ids:
318
+ want = set(entry_ids)
319
+ items = [i for i in items if i["entry_id"] in want]
320
+ rep = {"root": cg.root, "dry_run": False, "action": ACTION_ROLE,
321
+ "batch": batch, "actor": actor, "rule_id": rule_id,
322
+ "field": p["rule"]["field"], "sources": list(sources),
323
+ "planned": len(items), "written": 0, "by_source": {},
324
+ "skipped_drift": 0, "skipped_locked": 0, "skipped_missing": 0,
325
+ "skipped_denied": 0, "entry_ids": [],
326
+ "unfillable": p["unfillable"],
327
+ "unfillable_by_reason": p["unfillable_by_reason"]}
328
+ for it in items:
329
+ nid = it["id"]
330
+ e = (cg.index.get("nodes") or {}).get(nid)
331
+ if e is None:
332
+ rep["skipped_missing"] += 1
333
+ continue
334
+ if not _readable_guard(cg, e):
335
+ rep["skipped_denied"] += 1
336
+ continue
337
+ fm, content = cg._read(e)
338
+ if fm is None:
339
+ rep["skipped_missing"] += 1
340
+ continue
341
+ if crypto.is_encrypted(content):
342
+ rep["skipped_locked"] += 1
343
+ continue
344
+ if not _blank(fm.get("role")):
345
+ rep["skipped_drift"] += 1
346
+ continue
347
+ fm_before = {"role": fm.get("role"), "had_key": "role" in fm}
348
+ fm["role"] = it["role"]
349
+ ts = time.time()
350
+ cg._write_node(nid, os.path.join(cg.root, e["path"]), fm, content,
351
+ durable=True)
352
+ wid = _sha("%s|%s|%.6f" % (nid, batch, ts))
353
+ append_jsonl(_log_path(cg), {
354
+ "action": ACTION_ROLE, "ts": ts, "batch": batch, "actor": actor,
355
+ "entry_id": it["entry_id"], "write_id": wid, "node": nid,
356
+ "layer": e.get("layer"), "rule_id": rule_id, "field": "role",
357
+ "value": it["role"], "source": it["source"], "basis": it["basis"],
358
+ "writer": it.get("writer"), "fm_before": fm_before,
359
+ "content_hash_after": _sha(content)})
360
+ rep["written"] += 1
361
+ _bump(rep["by_source"], it["source"])
362
+ rep["entry_ids"].append(it["entry_id"])
363
+ if rep["written"]:
364
+ cg.rebuild_index()
365
+ rep["plan_remaining"] = max(0, p["targeted"] - len(items))
366
+ return rep
367
+
368
+
369
+ def role_rollback(x, batch=None, entry_ids=None, actor=None) -> dict:
370
+ """按留痕反向应用:撤销 role 回填。
371
+
372
+ **只在当前值仍等于当初写入值时**撤销;否则计 `conflict` 不覆盖(此后可能已有
373
+ 人工修正 / 后续批次写入)。原本无该键 → 删键还原(不是写空串)。
374
+ """
375
+ cg = _as_cg(x)
376
+ want = set(entry_ids) if entry_ids else None
377
+ rep = {"root": cg.root, "action": ACTION_ROLE_ROLLBACK, "actor": actor,
378
+ "batch": batch, "reverted": 0, "conflict": 0, "missing": 0,
379
+ "skipped_done": 0, "entry_ids": []}
380
+ log = list(read_jsonl(_log_path(cg)) or [])
381
+ done = {r.get("write_id") for r in log
382
+ if r.get("action") == ACTION_ROLE_ROLLBACK and r.get("write_id")}
383
+ for rec in log:
384
+ if rec.get("action") != ACTION_ROLE:
385
+ continue
386
+ if batch and rec.get("batch") != batch:
387
+ continue
388
+ if want is not None and rec.get("entry_id") not in want:
389
+ continue
390
+ wid = rec.get("write_id")
391
+ if wid and wid in done:
392
+ rep["skipped_done"] += 1
393
+ continue
394
+ nid = rec.get("node")
395
+ e = (cg.index.get("nodes") or {}).get(nid)
396
+ if e is None:
397
+ rep["missing"] += 1
398
+ continue
399
+ fm, content = cg._read(e)
400
+ if fm is None or crypto.is_encrypted(content):
401
+ rep["missing"] += 1
402
+ continue
403
+ if _blank(fm.get("role")) or str(fm.get("role")) != str(rec.get("value")):
404
+ rep["conflict"] += 1
405
+ continue
406
+ before = rec.get("fm_before") or {}
407
+ if before.get("had_key"):
408
+ fm["role"] = before.get("role")
409
+ else:
410
+ fm.pop("role", None)
411
+ cg._write_node(nid, os.path.join(cg.root, e["path"]), fm, content,
412
+ durable=True)
413
+ append_jsonl(_log_path(cg), {
414
+ "action": ACTION_ROLE_ROLLBACK, "ts": time.time(), "actor": actor,
415
+ "batch": rec.get("batch"), "entry_id": rec.get("entry_id"),
416
+ "write_id": wid, "node": nid, "field": "role",
417
+ "restored": before.get("role"),
418
+ "had_key": bool(before.get("had_key")), "reason": "rollback"})
419
+ rep["reverted"] += 1
420
+ rep["entry_ids"].append(rec.get("entry_id"))
421
+ if rep["reverted"]:
422
+ cg.rebuild_index()
423
+ return rep
424
+
425
+
426
+ def history(x, limit=100, action=None, batch=None) -> dict:
427
+ """读 `_govern.jsonl` 留痕(治理动作的可审计面)。"""
428
+ cg = _as_cg(x)
429
+ recs = [r for r in (read_jsonl(_log_path(cg)) or [])
430
+ if (not action or r.get("action") == action)
431
+ and (not batch or r.get("batch") == batch)]
432
+ tail = recs[-int(limit):] if limit else recs
433
+ by_action = {}
434
+ for r in recs:
435
+ _bump(by_action, str(r.get("action")))
436
+ return {"root": cg.root, "action": "role_history", "total": len(recs),
437
+ "by_action": by_action, "records": tail}
438
+
439
+
440
+ def role_stats(x, *, rule_id=RULE_ROLE, rules=None, rules_dir=None) -> dict:
441
+ """只读对照:role 覆盖率 + 取值分布(治理前后量化用)。
442
+
443
+ 判据**不复刻**——直接复用 `conformance`(真源:`_coverage_metrics` 的
444
+ knowledge 层口径 `role_ratio_kn` 与 `THRESHOLDS["role_coverage_min"]`),
445
+ 本函数只补「逐层 / 逐值」分布,供 M4 复跑报告引用。
446
+ """
447
+ from .. import conformance as CONF # 延迟导入:读侧判据真源,按需加载
448
+ cg = _as_cg(x)
449
+ rule = role_rule(rules=rules, rules_dir=rules_dir, rule_id=rule_id)
450
+ nodes = CONF.load_index(cg.root)
451
+ cov = CONF._coverage_metrics(nodes)
452
+ thr = float(CONF.THRESHOLDS["role_coverage_min"])
453
+ by_layer, by_value = {}, {}
454
+ for e in nodes.values():
455
+ _bump(by_layer, str(e.get("layer")))
456
+ if not _blank(e.get("role")):
457
+ _bump(by_value, str(e.get("role")))
458
+ kn = float(cov.get("role_ratio_kn") or 0.0)
459
+ return {"root": cg.root, "action": "role_stats", "rule_id": rule_id,
460
+ "layers": rule["layers"], "nodes": cov.get("nodes"),
461
+ "knowledge": cov.get("knowledge"), "role_ratio": cov.get("role_ratio"),
462
+ "role_ratio_kn": cov.get("role_ratio_kn"), "threshold": thr,
463
+ "target_met": kn >= thr, "gap_to_target": max(0.0, thr - kn),
464
+ "by_layer": by_layer, "by_value": by_value}
465
+
466
+
467
+ # ---- 统一入口 ------------------------------------------------------------
468
+
469
+ def run(x, action, **kw) -> dict:
470
+ """`role`(`apply=True` 则执行)/ `role_rollback` / `role_history` / `role_stats`。"""
471
+ if action not in ACTIONS:
472
+ raise ValueError("未知 action=%s(可用:%s)" % (action, list(ACTIONS)))
473
+ if action == ACTION_ROLE:
474
+ if kw.pop("apply", False):
475
+ return role_apply(x, **kw)
476
+ return role_plan(x, **kw)
477
+ fn = {ACTION_ROLE_ROLLBACK: role_rollback, "role_history": history,
478
+ "role_stats": role_stats}[action]
479
+ return fn(x, **kw)
480
+
481
+
482
+ # ---- 命令行 --------------------------------------------------------------
483
+ #
484
+ # 形态约定:`--root` / `--rules-dir` / `--json` 均为**子命令参数**(`parents=[common]`),
485
+ # 须写在子命令**之后**:`govern plan --root <root>`。写在子命令之前会被顶层解析器
486
+ # 当作位置参数吃掉(`scripts/review_cli.py` 的同型坑,2026-09-15 实测更正)。
487
+
488
+ def _parse_sources(s) -> tuple:
489
+ """CLI 侧来源档解析:逗号分隔,值域封闭(非法即抛,不静默降级)。"""
490
+ if not s:
491
+ return DEFAULT_SOURCES
492
+ vals = tuple(v.strip() for v in str(s).split(",") if v.strip())
493
+ bad = [v for v in vals if v not in ALL_SOURCES]
494
+ if bad:
495
+ raise ValueError("未知来源档 %s(可用:%s)" % (bad, list(ALL_SOURCES)))
496
+ if not vals:
497
+ raise ValueError("sources 为空(缺省即 %s)" % ",".join(DEFAULT_SOURCES))
498
+ return vals
499
+
500
+
501
+ def _common_parser() -> argparse.ArgumentParser:
502
+ ap = argparse.ArgumentParser(add_help=False)
503
+ ap.add_argument("--root", default=None, help="真源根(缺省读环境变量 MDCG_ROOT)")
504
+ ap.add_argument("--rules-dir", default=None, help="规则库目录(缺省 mreview/rules)")
505
+ ap.add_argument("--json", action="store_true", help="输出 JSON(缺省人读摘要)")
506
+ return ap
507
+
508
+
509
+ def _add_scope_args(p) -> None:
510
+ p.add_argument("--layer", help="收窄到该层(须在规则 matcher 层之内)")
511
+ p.add_argument("--prefix", help="只处理该 id 前缀")
512
+ p.add_argument("--ids", help="逗号分隔的节点 id 白名单")
513
+ p.add_argument("--limit", type=int, help="本次最多处理条数")
514
+ p.add_argument("--sources",
515
+ help="来源档逗号分隔:tags,map[,layer_default](缺省 %s)"
516
+ % ",".join(DEFAULT_SOURCES))
517
+
518
+
519
+ def _scope_kw(a) -> dict:
520
+ return {"layer": a.layer, "prefix": a.prefix, "limit": a.limit,
521
+ "ids": [i.strip() for i in (a.ids or "").split(",") if i.strip()] or None,
522
+ "sources": _parse_sources(a.sources), "rules_dir": a.rules_dir}
523
+
524
+
525
+ def _warn_layer_default(sources) -> None:
526
+ if SOURCE_LAYER in sources:
527
+ print("警告:" + LAYER_DEFAULT_WARNING, file=sys.stderr)
528
+
529
+
530
+ def _die(msg: str) -> int:
531
+ print(msg, file=sys.stderr)
532
+ return 2
533
+
534
+
535
+ def main(argv=None) -> int:
536
+ ap = argparse.ArgumentParser(
537
+ prog="python -m md_cg.mreview.govern",
538
+ description="记忆评审 M4 · 落库治理(确定性部分:rule 驱动 · 永不删除)")
539
+ sub = ap.add_subparsers(dest="cmd")
540
+ common = _common_parser()
541
+
542
+ p = sub.add_parser("plan", parents=[common], help="预演(只出报表,不写盘)")
543
+ _add_scope_args(p)
544
+ p.add_argument("--sample", type=int, default=0, help="抽样条数(人工核对样板)")
545
+
546
+ p = sub.add_parser("apply", parents=[common], help="执行回填(写盘 + 留痕 + 可回滚)")
547
+ _add_scope_args(p)
548
+ p.add_argument("--batch", default=BATCH_DEFAULT, help="批次名(留痕 / 回滚锚点)")
549
+ p.add_argument("--actor", default=None, help="执行者署名(留痕)")
550
+ p.add_argument("--entry-id", action="append", default=[],
551
+ help="只执行该条目(可多次;工单式精确执行)")
552
+ p.add_argument("--yes", action="store_true", help="显式确认写盘(缺省拒绝)")
553
+
554
+ p = sub.add_parser("rollback", parents=[common], help="按留痕回滚")
555
+ p.add_argument("--batch", default=None, help="只回滚该批次")
556
+ p.add_argument("--entry-id", action="append", default=[], help="只回滚该条目(可多次)")
557
+ p.add_argument("--actor", default=None, help="执行者署名(留痕)")
558
+
559
+ p = sub.add_parser("history", parents=[common], help="读 `_govern.jsonl` 留痕")
560
+ p.add_argument("--limit", type=int, default=100)
561
+ p.add_argument("--batch", default=None)
562
+ p.add_argument("--action", default=None)
563
+
564
+ sub.add_parser("stats", parents=[common],
565
+ help="覆盖率对照(复用 conformance 判据)")
566
+
567
+ a = ap.parse_args(argv)
568
+ if not a.cmd:
569
+ # `--root` 是**子命令**参数(`parents=[common]`):无子命令时顶层 Namespace
570
+ # 根本没有该属性——须先判 cmd 再取 root,否则 `govern` 裸跑即 AttributeError。
571
+ ap.print_help()
572
+ return 2
573
+ root = a.root or os.environ.get("MDCG_ROOT")
574
+ if not root:
575
+ return _die("需要 --root 或环境变量 MDCG_ROOT(fail-closed)")
576
+
577
+ if a.cmd == "plan":
578
+ try:
579
+ kw = _scope_kw(a)
580
+ except ValueError as ex:
581
+ return _die(str(ex))
582
+ _warn_layer_default(kw["sources"])
583
+ try:
584
+ rep = role_plan(root, sample=a.sample, **kw)
585
+ except ValueError as ex:
586
+ return _die("预演失败:%s" % ex)
587
+ if a.json:
588
+ print(json.dumps(rep, ensure_ascii=False, indent=2))
589
+ return 0
590
+ print("[M4 预演·只读] root=%s" % rep["root"])
591
+ print(" 靶规则 %s|层 %s|字段 %s|来源档 %s"
592
+ % (rep["rule"]["rule_id"], ",".join(rep["rule_layers"]),
593
+ rep["rule"]["field"], ",".join(rep["sources"])))
594
+ print(" 扫描 %d|可回填 %d|不可回填 %d|域外 %d|已锁 %d|已有值 %d|无权限 %d"
595
+ % (rep["nodes_scanned"], rep["targeted"], rep["unfillable"],
596
+ rep["skipped_out_of_scope"], rep["skipped_locked"],
597
+ rep["skipped_present"], rep["skipped_denied"]))
598
+ for k, v in sorted(rep["by_source"].items()):
599
+ print(" 来源 %-16s %d" % (k, v))
600
+ for k, v in sorted(rep["unfillable_by_reason"].items(), key=lambda kv: -kv[1]):
601
+ print(" 挡下 %-32s %d" % (k, v))
602
+ if rep.get("sample"):
603
+ print(" 抽样:%s" % ", ".join(rep["sample"]))
604
+ return 0
605
+
606
+ if a.cmd == "apply":
607
+ if not a.yes:
608
+ return _die("apply 会写盘:先跑 plan 核对清单,再以 --yes 显式确认(fail-closed)")
609
+ try:
610
+ kw = _scope_kw(a)
611
+ except ValueError as ex:
612
+ return _die(str(ex))
613
+ _warn_layer_default(kw["sources"])
614
+ try:
615
+ rep = role_apply(root, batch=a.batch, actor=a.actor,
616
+ entry_ids=list(a.entry_id) or None, **kw)
617
+ except ValueError as ex:
618
+ return _die("执行失败:%s" % ex)
619
+ if a.json:
620
+ print(json.dumps(rep, ensure_ascii=False, indent=2))
621
+ return 0
622
+ print("[M4 执行] batch=%s actor=%s" % (rep["batch"], rep["actor"]))
623
+ print(" 写入 %d|漂移跳过 %d|密文跳过 %d|缺失 %d|无权限 %d|余量 %d"
624
+ % (rep["written"], rep["skipped_drift"], rep["skipped_locked"],
625
+ rep["skipped_missing"], rep["skipped_denied"], rep["plan_remaining"]))
626
+ for k, v in sorted(rep["by_source"].items()):
627
+ print(" 来源 %-16s %d" % (k, v))
628
+ return 0
629
+
630
+ if a.cmd == "rollback":
631
+ if not a.batch and not a.entry_id:
632
+ return _die("rollback 需要 --batch 或 --entry-id(防全量误回滚)")
633
+ rep = role_rollback(root, batch=a.batch, actor=a.actor,
634
+ entry_ids=list(a.entry_id) or None)
635
+ if a.json:
636
+ print(json.dumps(rep, ensure_ascii=False, indent=2))
637
+ return 0
638
+ print("[M4 回滚] batch=%s|还原 %d|冲突跳过 %d|缺失 %d|已回滚 %d"
639
+ % (rep["batch"], rep["reverted"], rep["conflict"], rep["missing"],
640
+ rep["skipped_done"]))
641
+ return 0
642
+
643
+ if a.cmd == "history":
644
+ rep = history(root, limit=a.limit, action=a.action, batch=a.batch)
645
+ if a.json:
646
+ print(json.dumps(rep, ensure_ascii=False, indent=2))
647
+ return 0
648
+ print("[M4 留痕] 命中 %d 条|分布 %s" % (rep["total"], rep["by_action"]))
649
+ for r in rep["records"]:
650
+ print(" %-14s %-10s %-22s %s=%s"
651
+ % (time.strftime("%m-%d %H:%M:%S", time.localtime(r.get("ts") or 0)),
652
+ r.get("action"), str(r.get("node")), r.get("field"),
653
+ r.get("value", r.get("restored"))))
654
+ return 0
655
+
656
+ try:
657
+ rep = role_stats(root)
658
+ except ValueError as ex:
659
+ return _die("对照失败:%s" % ex)
660
+ if a.json:
661
+ print(json.dumps(rep, ensure_ascii=False, indent=2))
662
+ return 0
663
+ print("[M4 对照] root=%s 判据源=conformance" % rep["root"])
664
+ print(" 节点 %s|knowledge %s|role 覆盖 %s|knowledge 层覆盖 %s(阈值 %.2f,%s)"
665
+ % (rep["nodes"], rep["knowledge"], rep["role_ratio"],
666
+ rep["role_ratio_kn"], rep["threshold"],
667
+ "达标" if rep["target_met"] else "未达标,缺口 %.4f" % rep["gap_to_target"]))
668
+ print(" 逐层 %s" % rep["by_layer"])
669
+ print(" 逐值 %s" % rep["by_value"])
670
+ return 0
671
+
672
+
673
+ if __name__ == "__main__":
674
+ sys.exit(main())