@furongjun1999/dsh-memory 0.5.1 → 0.6.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 (187) hide show
  1. package/README.md +588 -552
  2. package/codebuddy/CODEBUDDY.md +10 -10
  3. package/data/policy.json +27 -0
  4. package/docs/discipline/harnesses.yaml +31 -0
  5. package/docs/discipline/templates/full.md.tmpl +1 -1
  6. package/docs/discipline/templates/rules.mdc.tmpl +1 -1
  7. package/docs/eval/DSH/346/227/245/345/277/227/347/264/242/345/274/225v2_/345/217/202/350/200/203dsh-TUI_v1.0.md +248 -0
  8. package/docs/eval/DSH/346/227/245/345/277/227/347/264/242/345/274/225/346/225/210/346/236/234/351/252/214/350/257/201_v1.0.md +209 -0
  9. package/docs/eval/DSH/347/253/257/347/274/272/351/231/267/344/270/223/351/241/271_v1.0.md +254 -0
  10. package/docs/eval/N225_/347/264/242/345/274/225/346/227/245/345/277/227/351/235/236/345/257/271/350/261/241/350/243/205/350/275/275/351/235/242/347/261/273/345/236/213/351/227/270_v1.0.md +418 -0
  11. package/docs/eval/P1b2_/350/257/273/351/235/242/344/273/243/351/231/205/344/277/256/345/244/215_v1.0.md +124 -0
  12. package/docs/eval/issue43_/351/273/230/350/256/244/347/255/226/347/225/245/344/270/216/351/224/256/347/261/273/345/236/213/351/227/270_v1.1.md +441 -0
  13. package/docs/eval/issue43_/351/273/230/350/256/244/347/255/226/347/225/245/345/212/240/350/275/275/344/270/216/345/207/255/346/215/256/346/230/216/346/226/207/351/230/237/345/210/227_v1.0.md +398 -0
  14. package/docs/eval//344/274/230/345/214/226/347/254/254/344/270/200/346/211/271_/346/216/245/347/272/277/344/270/216/347/255/211/344/273/267/345/217/230/346/215/242_v1.0.md +498 -0
  15. package/docs/eval//344/274/230/345/214/226/347/254/254/344/270/211/346/211/271_/351/227/250/347/246/201/350/275/254/346/255/243/344/270/216/350/260/203/345/272/246/346/255/242/350/241/200/344/270/216/351/227/250/346/216/247/346/224/266/345/217/243_v1.0.md +669 -0
  16. package/docs/eval//344/274/230/345/214/226/347/254/254/344/272/214/346/211/271_/344/276/235/350/265/226/351/200/217/344/274/240/344/270/216/345/257/271/346/213/215/345/217/243/345/276/204/344/270/216/350/264/237/347/274/223/345/255/230_v1.0.md +683 -0
  17. package/docs/eval//345/217/221/345/270/20306_/344/270/200/351/224/256/351/205/215/347/275/256/344/270/216DSH0172_v1.0.md +171 -0
  18. package/docs/eval//345/217/221/345/270/20307_/345/244/226/351/203/250/346/212/245/345/221/212/345/233/233/346/211/271/344/277/256/345/244/215/344/270/216/346/217/222/344/273/266/351/235/242/345/212/240/345/233/272_v1.0.md +205 -0
  19. package/docs/eval//346/200/247/350/203/275/344/270/223/351/241/271_/345/206/267/346/237/245/350/257/242/344/270/216/345/206/205/345/255/230_v1.0.md +218 -0
  20. package/docs/eval//346/225/205/351/232/234/346/263/250/345/205/245/345/256/236/346/265/213_v1.0.md +10 -0
  21. package/docs/eval//347/274/226/347/240/201/351/235/242/345/211/215/347/275/256_/345/205/245/345/217/243/350/207/252/344/277/235/350/257/201UTF8/344/270/216/346/226/207/346/234/254open/345/256/210/345/215/253_v1.0.md +646 -0
  22. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v18.md +183 -0
  23. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v19.md +207 -0
  24. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v20.md +283 -0
  25. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v21.md +224 -0
  26. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v22.md +223 -0
  27. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v23.md +293 -0
  28. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v24.md +303 -0
  29. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v25.md +230 -0
  30. 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 +20 -0
  31. package/docs/hive//350/234/202/345/267/242M6_ingest/345/256/236/346/226/275/350/256/241/345/210/222_v0.1.md +1 -1
  32. package/docs/mdcg/README/350/257/246/347/273/206/347/211/210_v0.4.10.md +12 -0
  33. 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
  34. package/docs/mdcg//345/217/221/345/270/203/351/227/250/347/246/201/351/223/276_v0.1.md +21 -9
  35. package/docs/mdcg//347/201/265/346/236/242/350/256/260/345/277/206/345/212/250/350/257/215/345/215/217/350/256/256_v1.0-draft.md +7 -2
  36. 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 +39 -3
  37. package/docs/plans//345/205/250/344/270/255/346/226/207/347/274/226/347/240/201/344/270/216/350/234/202/345/267/242/344/273/273/345/212/241/346/240/207/350/257/206/345/245/221/347/272/246_v2.0.md +220 -0
  38. package/docs/plans//347/234/237/346/272/220/347/264/242/345/274/225_/351/200/232/347/224/250/346/234/272/345/210/266_v0.3.md +227 -0
  39. package/docs/plans//350/234/202/345/267/242/346/250/241/345/236/213/345/257/206/351/222/245/351/205/215/347/275/256/351/235/242_v1.0.md +385 -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 +460 -460
  41. package/dsh/cordis-patch-profile-web.example.yml +35 -0
  42. package/lib/bridge.js +24 -2
  43. package/lib/cli.d.ts +3 -0
  44. package/lib/cli.js +66 -0
  45. package/lib/hooks.d.ts +31 -2
  46. package/lib/hooks.js +219 -16
  47. package/lib/init.d.ts +88 -0
  48. package/lib/init.js +302 -0
  49. package/lib/lib/prompt_safety.d.ts +52 -1
  50. package/lib/lib/prompt_safety.js +76 -1
  51. package/md_cg/audit.py +652 -379
  52. package/md_cg/bench6_arms.py +1 -1
  53. package/md_cg/bench_p0.py +1 -1
  54. package/md_cg/branches.py +2 -2
  55. package/md_cg/ccgc.py +1071 -1006
  56. package/md_cg/chain.py +1 -1
  57. package/md_cg/consistency.py +97 -9
  58. package/md_cg/consolidate.py +34 -9
  59. package/md_cg/crypto.py +59 -26
  60. package/md_cg/docindex.py +16 -1
  61. package/md_cg/evidence.py +585 -582
  62. package/md_cg/evolution.py +17 -1
  63. package/md_cg/export.py +1 -1
  64. package/md_cg/forgetting.py +309 -13
  65. package/md_cg/fsutil.py +454 -8
  66. package/md_cg/identity.py +3 -3
  67. package/md_cg/insight.py +24 -3
  68. package/md_cg/linkref.py +1 -1
  69. package/md_cg/logref.py +327 -0
  70. package/md_cg/mcp_server.py +4152 -3818
  71. package/md_cg/mdcg.py +4208 -3462
  72. package/md_cg/mdcos.py +4491 -4137
  73. package/md_cg/mreview/pipeline.py +15 -1
  74. package/md_cg/nodefile.py +639 -575
  75. package/md_cg/protect.py +158 -11
  76. package/md_cg/protocol.py +41 -2
  77. package/md_cg/provenance.py +22 -4
  78. package/md_cg/reach.py +4 -4
  79. package/md_cg/readcache.py +76 -15
  80. package/md_cg/reconcile.py +1 -1
  81. package/md_cg/refindex.py +246 -37
  82. package/md_cg/refine.py +1 -1
  83. package/md_cg/review_cli.py +49 -4
  84. package/md_cg/routing.py +32 -5
  85. package/md_cg/run_tests.py +17 -0
  86. package/md_cg/scrub.py +30 -3
  87. package/md_cg/security.py +47 -1
  88. package/md_cg/self_state.py +5 -5
  89. package/md_cg/sources.py +23 -6
  90. package/md_cg/srcindex.py +352 -0
  91. package/md_cg/stg.py +47 -3
  92. package/md_cg/subgraph.py +2 -2
  93. package/md_cg/sustain.py +1299 -1168
  94. package/md_cg/tasks.py +469 -470
  95. package/md_cg/test_b1_auto_id_multiproc.py +277 -0
  96. package/md_cg/test_b1b2_write_face.py +417 -0
  97. package/md_cg/test_b2_sensitivity_landing.py +223 -0
  98. package/md_cg/test_b3_merge_keeps_content.py +576 -0
  99. package/md_cg/test_b4_shard_dir_selfheal.py +843 -0
  100. package/md_cg/test_b4_shard_dir_selfheal_guard.py +238 -0
  101. package/md_cg/test_c3_transient_read_negative.py +793 -0
  102. package/md_cg/test_c8_search_rrf_gates.py +502 -0
  103. package/md_cg/test_ccg_form_parity.py +188 -0
  104. package/md_cg/test_ccgc.py +28 -3
  105. package/md_cg/test_govern_directread.py +17 -4
  106. package/md_cg/test_h2_session_view_norm.py +233 -0
  107. package/md_cg/test_h4_sustain_snapshot.py +1377 -0
  108. package/md_cg/test_hive_ingest.py +285 -285
  109. package/md_cg/test_index_crossprocess_reload.py +301 -0
  110. package/md_cg/test_issue39_utf8_stdio.py +307 -16
  111. package/md_cg/test_issue43_default_policy.py +865 -0
  112. package/md_cg/test_legacy_p3_node_id_type.py +375 -0
  113. package/md_cg/test_linkref.py +10 -3
  114. package/md_cg/test_lock.py +2 -2
  115. package/md_cg/test_logref.py +1109 -0
  116. package/md_cg/test_m3_h9_bucket_health_protect_mark.py +301 -0
  117. package/md_cg/test_m3_h9_semantic_guard.py +525 -0
  118. package/md_cg/test_mr_m2.py +15 -3
  119. package/md_cg/test_n130_verify_falsified_protect.py +1 -1
  120. package/md_cg/test_n139_dek_provision_failclosed.py +185 -0
  121. package/md_cg/test_n176_link_trust.py +221 -0
  122. package/md_cg/test_n178_units_jobid_gate.py +212 -0
  123. package/md_cg/test_n184_keys_concurrent_provision.py +307 -0
  124. package/md_cg/test_n195_writepath_reload.py +283 -0
  125. package/md_cg/test_n196_stale_gate_skip.py +169 -0
  126. package/md_cg/test_n197_n208_write_face_gates.py +471 -0
  127. package/md_cg/test_n198_tokens_corrupt_failclosed.py +225 -0
  128. package/md_cg/test_n199_tokens_concurrent_write.py +378 -0
  129. package/md_cg/test_n201_proposal_visibility.py +382 -0
  130. package/md_cg/test_n202_session_notes_visibility.py +461 -0
  131. package/md_cg/test_n204_n205_n226_n227_n228_n229_exit_gates.py +542 -0
  132. package/md_cg/test_n206_stdio_jsonrpc_type.py +403 -0
  133. package/md_cg/test_n209_verify_write_face_gates.py +461 -0
  134. package/md_cg/test_n212_n213_n224_generation_gates.py +513 -0
  135. package/md_cg/test_n214_n215_n221_n222_write_face_gates.py +563 -0
  136. package/md_cg/test_n225_nonobject_load.py +1110 -0
  137. package/md_cg/test_n62_tenant_bind_failclosed.py +200 -0
  138. package/md_cg/test_neg_condition_hits.py +333 -0
  139. package/md_cg/test_neg_tail_honesty.py +663 -0
  140. package/md_cg/test_none_id_write_guard.py +166 -0
  141. package/md_cg/test_opt_batch1_md_cg.py +451 -0
  142. package/md_cg/test_p1.py +5 -5
  143. package/md_cg/test_p11_consistency.py +161 -12
  144. package/md_cg/test_p26_refindex.py +2 -2
  145. package/md_cg/test_p27_docindex.py +777 -774
  146. package/md_cg/test_p28_refcheck.py +740 -13
  147. package/md_cg/test_p29_session_ingest_export.py +2 -2
  148. package/md_cg/test_p2_mcp.py +12 -1
  149. package/md_cg/test_p30_maintain.py +33 -2
  150. package/md_cg/test_p31_insight.py +1 -0
  151. package/md_cg/test_p9c_dedup_hints.py +276 -0
  152. package/md_cg/test_policy_required_ccg.py +426 -0
  153. package/md_cg/test_protocol.py +22 -0
  154. package/md_cg/test_rank_parity_score_mode.py +516 -0
  155. package/md_cg/test_read_face_input_gates.py +363 -0
  156. package/md_cg/test_read_face_semantics.py +489 -0
  157. package/md_cg/test_recall_face_guards.py +812 -0
  158. package/md_cg/test_rejected_credential_forms.py +188 -0
  159. package/md_cg/test_rejected_redact.py +146 -0
  160. package/md_cg/test_retr_s1b.py +2 -2
  161. package/md_cg/test_retr_s5.py +20 -7
  162. package/md_cg/test_review_cli_attribution.py +177 -0
  163. package/md_cg/test_review_cli_visibility.py +235 -0
  164. package/md_cg/test_review_conformance.py +21 -3
  165. package/md_cg/test_security_audit_v21.py +2 -2
  166. package/md_cg/test_server_version.py +67 -0
  167. package/md_cg/test_srcindex.py +171 -0
  168. package/md_cg/test_tenant_env_override_warn.py +63 -50
  169. package/md_cg/test_token_lowercase_form.py +315 -0
  170. package/md_cg/test_v21r1_package_version.py +310 -0
  171. package/md_cg/test_writepipe.py +10 -4
  172. package/md_cg/tokens.py +225 -95
  173. package/md_cg/tool_face.py +4 -4
  174. package/md_cg/trust.py +49 -5
  175. package/md_cg/units.py +204 -6
  176. package/md_cg/weights.py +4 -4
  177. package/md_cg/whitebox_kb/wisdom/knowledge_points.py +1 -1
  178. package/md_cg/writelimit.py +42 -8
  179. package/md_cg/writepipe.py +651 -554
  180. package/package.json +8 -2
  181. package/skills/plugin.json +1 -1
  182. package/src/bridge.ts +25 -2
  183. package/src/cli.ts +65 -0
  184. package/src/hooks.ts +229 -16
  185. package/src/init.ts +361 -0
  186. package/src/lib/prompt_safety.ts +88 -1
  187. package/zcode/AGENTS.md +10 -10
package/md_cg/chain.py CHANGED
@@ -147,7 +147,7 @@ def adjacency(cg, include_hierarchy=True):
147
147
  from . import subgraph as _sg
148
148
  nodes = ((getattr(cg, "index", None) or {}).get("nodes") or {})
149
149
  adj = {}
150
- for nid in nodes:
150
+ for nid in list(nodes):
151
151
  if vis is not None and not vis(nid):
152
152
  continue # 不可见节点:其出边与条件整体不可见(不解析 fm)
153
153
  fm = _sg._fm(cg, nid)
@@ -32,8 +32,11 @@
32
32
  L1 反思 :一次条件级冲突检测(自否定 / 纪律违反 / 条件互斥)→ 四态
33
33
  L2 递归反思:L1 未决 → 沿关系链递归找「区分条件」;受深度/节点数/循环/增益门槛约束
34
34
 
35
- 冲突自动触发飞轮:verdict ∈ {REJECT, DEFER, BLINDSPOT} 且 auto_flywheel →
35
+ 冲突自动触发飞轮:verdict == REJECT(**真冲突**)且 auto_flywheel →
36
36
  `cg.flywheel_step({query, expected_state:"ACCEPT", actual_state:verdict, missing})`
37
+ (H11⑥ 2026-09-30:DEFER/BLINDSPOT 不再自动建单——DEFER 是「待确认」、
38
+ BLINDSPOT 是「检测前提不存在(无可比对节点,空库上恒成立)」,二者都不是
39
+ 已判定的冲突;把待确认写成待办工单只是把误判固化。判定本身仍如实返回。)
37
40
 
38
41
  留痕 `_consistency.jsonl`(append-only):每条判定可审计「为什么冲突 / 为什么放行」。
39
42
 
@@ -70,6 +73,10 @@ SAME_COND_HIGH = 0.75 # 同侧条件重合 → 判定「同一条件空间」
70
73
  # 实测结论槽(子功能):同槽 1.0 / 同主语异属性 0.14 / 异主题 0.0 → 0.6 可分
71
74
  SLOT_HIGH = 0.6 # 结论槽(功能名/子功能)重合 → 判定「同一件事」
72
75
  # 实测:逐字重复 1.0 / 同槽不同值 0.61 / 同值异措辞 0.74 → 0.95 只排除逐字重复
76
+ # H8(2026-09-30):两侧先过同一套空白归一化(`_norm_ws`)再比;且去空白后逐字
77
+ # 相同者直接短路为「重复」。阈值本身**不动**——纯空白差异(探针实测未归一化时
78
+ # 0.9145~0.9436)曾把「同条件·同槽」压到 0.95 以下造成假冲突,修法是消除口径
79
+ # 不对称,不是放宽 0.95(放宽到 0.9436 以下才能救,会一并吞掉真分歧)。
73
80
  CONCLUSION_SAME = 0.95 # 正文几乎逐字相同 → 属「重复」(该合并),不算冲突
74
81
 
75
82
  EMO_AVOID = 0.70 # 冲突强度 ≥ 此值 → avoiding
@@ -80,6 +87,12 @@ DISCIPLINE_TAGS = ("discipline", "纪律", "work_discipline", "rule", "规则",
80
87
 
81
88
  VERDICTS = ("ACCEPT", "REJECT", "DEFER", "BLINDSPOT")
82
89
 
90
+ #: 自动建单(飞轮)**唯一**的触发态——只有真冲突才落 unresolved(H11⑥,2026-09-30)。
91
+ #: 改前是 ("REJECT","DEFER","BLINDSPOT"):DEFER=「条件互斥/部分覆盖、待确认」、
92
+ #: BLINDSPOT=「无节点可比对(检测前提不存在)」——都不是「已判定的冲突」。
93
+ #: 单点:`check()` 与 `catalog()` 都读本元组。
94
+ FLYWHEEL_TRIGGERS = ("REJECT",)
95
+
83
96
 
84
97
  # 生效条件:以 verdict 与 reason 构造异常(消息 `[{verdict}] {reason}`),conflicts 传假值(None/空容器等,源码 `conflicts or []`)时 self.conflicts 为 [],传真值时原样保留;
85
98
  class ConsistencyError(Exception):
@@ -243,6 +256,37 @@ def _body_text(content):
243
256
  return "\n".join(out)
244
257
 
245
258
 
259
+ # 生效条件:text 假值时按空串处理,返回把任意连续空白(半角/全角空格、\t、\r、\n)折叠为单个半角空格并去掉首尾空白后的字符串;纯空白文本返回空串 "";
260
+ def _norm_ws(text):
261
+ """空白归一化(仓内既有惯用法 `" ".join(x.split())`,见 writelimit.py:233
262
+ 与 tool_face.py:136)——结论比对**两侧必须同一套**。
263
+
264
+ 为什么需要(H8):结论文本是被 `expand_query_terms_weighted` 当作**一个整段
265
+ 加权词**(`mdcg.py` 整句 `put(q, 1.0)`)+ 若干分词来比对的,而 `_term_degree`
266
+ 对「非整段命中」只给子串回退分 `0.5·L/n`。两侧若只差空白(`\r\n`↔`\n`、尾部
267
+ 多换行、行首缩进(半角/全角)、插空行、行尾空格、词间多空格),整段词就只能
268
+ 拿到部分分,`conclusion_overlap` 掉到 0.95 以下 → 被误判成「同条件·同槽·取值
269
+ 不同」(假冲突 + 假 unresolved 工单)。两侧先经同一归一化,纯空白差异不再进入
270
+ 结论比对。
271
+ """
272
+ return " ".join((text or "").split())
273
+
274
+
275
+ # 生效条件:text 假值时按空串处理,返回去掉全部空白字符(半角/全角空格、\t、\r、\n 等 str.split() 认可的空白)后的字符串;纯空白文本返回空串 "";
276
+ def _nows(text):
277
+ """去掉**全部**空白——只用于「两侧差异是不是只在空白」的相等短路判定。
278
+
279
+ 与 `_norm_ws` 的分工:`" ".join(x.split())` 只折叠空白,**词内插空白**
280
+ (「指数退避重连」↔「指数 退避重连」)归一化后仍非逐字相同(探针实测该情形
281
+ 归一化覆盖率 0.9388 < 0.95,仍会判分歧);去掉空白才判得出「差异只在空白」。
282
+
283
+ 安全性:它只用于**相等**判定(相等 ⇒ 必判重复),不参与覆盖率计算,故不会
284
+ 放宽「同槽不同值 / 同值异措辞」这两类真分歧——它们的差异不是空白,去空白后
285
+ 仍不相等,照旧走 `conclusion_overlap < CONCLUSION_SAME` 的判据。
286
+ """
287
+ return "".join((text or "").split())
288
+
289
+
246
290
  # 生效条件:content 给出时(假值按空串)解析 CCG 的 `# 功能名` 与 `# 子功能` 字段并返回两者;
247
291
  def _slot_text(content):
248
292
  """结论槽:CCG 声明的 `# 功能名` / `# 子功能`(结构字段,非正文词面)。
@@ -382,7 +426,7 @@ def _recursive_reflect(cg, seeds, tw_pos, tw_neg, max_depth=MAX_DEPTH,
382
426
  # 主入口:三级决策
383
427
  # --------------------------------------------------------------------------
384
428
 
385
- # 生效条件:以 cg.index.nodes 为既有节点、content(假值按 "")经 _new_terms 得 pos/neg 并算 tw_pos/tw_neg,按循环中 hard(自否定或纪律命中)→ divergences(同条件槽且 concl < CONCLUSION_SAME)→ strength ≥ CLASH_HIGH → strength ≥ CLASH_LOW → comparable==0 且 (pos or neg) → 否则 ACCEPT 的顺序定 verdict;DEFER 且 int(depth)>0 且 emo["bias"] != "approaching" 时调 _recursive_reflect 补 recursion,auto_flywheel 且 verdict∈{REJECT,DEFER,BLINDSPOT} 时加 unresolved_id,最后 log 并返回 rec;
429
+ # 生效条件:以 cg.index.nodes 为既有节点、content(假值按 "")经 _new_terms 得 pos/neg 并算 tw_pos/tw_neg(non_applicable_conditions 入参先按 mdcos._is_null_condition 剔除空值语义哨兵——⑧ 漏斗单点),按循环中 hard(自否定或纪律命中)→ divergences(同条件槽且 concl < CONCLUSION_SAME)→ strength ≥ CLASH_HIGH → strength ≥ CLASH_LOW → comparable==0 且 (pos or neg) → 否则 ACCEPT 的顺序定 verdict;DEFER 且 int(depth)>0 且 emo["bias"] != "approaching" 时调 _recursive_reflect 补 recursion,auto_flywheel 且 verdict == REJECT 时加 unresolved_id,最后 log 并返回 rec;
386
430
  def check(cg, content, layer=None, condition_space=None,
387
431
  non_applicable_conditions=None, tags=None, exclude=None,
388
432
  limit=MAX_SCAN, depth=MAX_DEPTH, auto_flywheel=False,
@@ -398,17 +442,35 @@ def check(cg, content, layer=None, condition_space=None,
398
442
  REJECT 硬冲突:自否定 / 违反纪律
399
443
  DEFER 条件互斥但可能可分辨(交给 L2 递归或飞轮)
400
444
  BLINDSPOT 有条件声明,但既有节点全无声明 → 无法建立比对路径(不假装确定)
445
+
446
+ non_applicable_conditions(⑧ 2026-09-30):入参里的**空值语义哨兵**
447
+ (`["无"]` / `["(无)"]` / `[""]`…)在本函数的入口统一剔除——判据复用
448
+ `mdcos._is_null_condition`(仓内唯一哨兵表,**不新造第二张词表**)。
449
+ 为何修在漏斗而不是各调用点:本函数是全部入口(`MdCG.add` 直连
450
+ `consistency.check`、`MdCGOS.check_consistency`、写链 `writepipe`)的
451
+ 公共汇聚点,只改一处即可同口径;此前 mdcos 侧已在 `_ccg_field` 与
452
+ `check_consistency` 两处收口,而 `mdcg.add` 的直连绕过了它们——实测
453
+ `consistency.check(..., non_applicable_conditions=["无"])` 仍判
454
+ DEFER/strength=1.0 并建工单,同一内容不传该列表则 ACCEPT/0.0/无工单。
401
455
  """
402
456
  _ccg_field, _declared, _neg_hit, _cov = _prims()
403
457
  content = content or ""
458
+ # ⑧:哨兵剔除单点(延迟导入,避免 consistency ← mdcos 的模块级循环依赖)。
459
+ if non_applicable_conditions:
460
+ from .mdcos import _is_null_condition
461
+ non_applicable_conditions = [x for x in non_applicable_conditions
462
+ if not _is_null_condition(x)]
404
463
  pos, neg = _new_terms(content, condition_space, non_applicable_conditions)
405
464
  tw_pos = expand_query_terms_weighted(" ".join(pos)) if pos else {}
406
465
  tw_neg = expand_query_terms_weighted(" ".join(neg)) if neg else {}
407
466
  # 结论文本词权(结论比对专用):**去 CCG 声明行**。模板行(`# 功能名` /
408
467
  # `# 子功能` 等)在两条节点间逐字相同,若混入会稀释结论覆盖率——实测
409
468
  # 逐字重复仅 0.32、同槽不同值 0.61,与异属性(0.62)不可分。去模板才可判。
410
- tw_content = (expand_query_terms_weighted(_body_text(content))
411
- if content else {})
469
+ # H8:**两侧还要同一套空白归一化**(`_norm_ws`)——见 `_norm_ws` 说明;
470
+ # `n_body_tight` 是去全部空白的形态,只供 L1-c 的相等短路用。
471
+ n_body = _body_text(content)
472
+ tw_content = expand_query_terms_weighted(_norm_ws(n_body)) if n_body else {}
473
+ n_body_tight = _nows(n_body)
412
474
  # 结论槽(CCG 结构字段)——「是否同一件事」的代理,见 _slot_text 说明
413
475
  n_fn, n_sb = _slot_text(content)
414
476
 
@@ -431,7 +493,7 @@ def check(cg, content, layer=None, condition_space=None,
431
493
  # ---- L1-b 与既有节点的条件级比对(反题) ----
432
494
  nodes = ((getattr(cg, "index", None) or {}).get("nodes") or {})
433
495
  seeds = []
434
- for nid, e in nodes.items():
496
+ for nid, e in list(nodes.items()):
435
497
  if exclude and nid == exclude:
436
498
  continue
437
499
  if layer and e.get("layer") != layer:
@@ -478,8 +540,17 @@ def check(cg, content, layer=None, condition_space=None,
478
540
  _cov(expand_query_terms_weighted(n_sb), e_sb)
479
541
  if (n_sb and e_sb) else 0.0)
480
542
  if slot >= SLOT_HIGH:
481
- # 同口径比对(正文↔正文);tw_content 已去模板行,见上文
482
- concl = _cov(tw_content, _body_text(body)) if tw_content else 0.0
543
+ # 同口径比对(正文↔正文):tw_content 已去模板行 + 两侧同一套
544
+ # 空白归一化(H8),见上文与 `_norm_ws` / `_nows` 说明。
545
+ e_body = _body_text(body)
546
+ if n_body_tight == _nows(e_body):
547
+ # 差异只在空白(\r\n↔\n / 缩进 / 空行 / 行尾空格 / 词内插空白)
548
+ # ⇒ 归一化后逐字相同,必判**重复**(该合并),不判分歧。
549
+ # 这不是放宽真分歧判据:取值真不同者去空白后仍不相等。
550
+ concl = 1.0
551
+ else:
552
+ concl = (_cov(tw_content, _norm_ws(e_body))
553
+ if tw_content else 0.0)
483
554
  if concl < CONCLUSION_SAME:
484
555
  divergences.append({
485
556
  "type": "same_condition_divergence", "with": nid,
@@ -564,7 +635,13 @@ def check(cg, content, layer=None, condition_space=None,
564
635
  "actor": getattr(cg, "actor", "unknown")}
565
636
 
566
637
  # ---- 冲突自动触发飞轮(误差 → 补条件 → 结构更新) ----
567
- if auto_flywheel and verdict in ("REJECT", "DEFER", "BLINDSPOT"):
638
+ # H11⑥(2026-09-30):**只对真冲突(REJECT)建单**。改前是
639
+ # verdict ∈ {REJECT, DEFER, BLINDSPOT} 全建——DEFER 是「条件互斥/部分覆盖、
640
+ # 待确认」,BLINDSPOT 是「既有节点全无声明 ⇒ 检测前提不存在」(空库上恒
641
+ # 成立),两者都不是「已判定的冲突」;自动落 unresolved 等于把待确认与
642
+ # 无法比对写成待办工单,污染裁决队列(实测:哨兵形态误判出的 DEFER 直接
643
+ # 固化成工单 unr_9af4803381)。判定本身照旧返回,缺的只是「自动建单」。
644
+ if auto_flywheel and verdict in FLYWHEEL_TRIGGERS:
568
645
  rec["unresolved_id"] = _fire_flywheel(cg, query or content, verdict,
569
646
  reason, missing, allc)
570
647
  log(cg, rec)
@@ -694,6 +771,9 @@ def catalog():
694
771
  "same_condition_divergence": "同侧比对(新正↔旧正 / 新负↔旧负)"
695
772
  "+ CCG 结论槽 = 同条件空间内的取值分歧(矛盾)",
696
773
  },
774
+ "conclusion_normalization": "结论比对两侧同一套空白归一化"
775
+ "(' '.join(x.split()))+ 去空白后逐字"
776
+ "相同 ⇒ 必判重复(H8)",
697
777
  "verdicts": list(VERDICTS),
698
778
  "thresholds": {"high": CLASH_HIGH, "low": CLASH_LOW,
699
779
  "same_condition": SAME_COND_HIGH, "slot": SLOT_HIGH,
@@ -710,7 +790,15 @@ def catalog():
710
790
  },
711
791
  "auto_flywheel": {
712
792
  "theory": "知识飞轮:误差 → 补条件 → 结构更新(智能论 :725)",
713
- "triggers_on": ["REJECT", "DEFER", "BLINDSPOT"],
793
+ # H11⑥(2026-09-30):建单只限**真冲突**(REJECT)。DEFER 是
794
+ # 「条件互斥/部分覆盖、待确认」,BLINDSPOT 是「检测前提不存在
795
+ # (无节点可与比对)」——两者都不是「已判定冲突」,自动落 unresolved
796
+ # 会把待确认项与无法比对项写成待办工单(且 BLINDSPOT 在空库上恒成立,
797
+ # 即每写第一条内容就建一张工单)。可复核的现场:本轮
798
+ # `consistency.check(..., non_applicable_conditions=["无"])` 实测
799
+ # DEFER/strength=1.0 → 建单 unr_9af4803381,而同一内容不传该列表
800
+ # 则 ACCEPT/0.0/无单——自动建单把「误判」直接固化成了待办。
801
+ "triggers_on": list(FLYWHEEL_TRIGGERS),
714
802
  },
715
803
  "discipline_detection": {"tags": list(DISCIPLINE_TAGS),
716
804
  "id_prefixes": ["discipline_", "work_discipline"]},
@@ -530,9 +530,10 @@ def replay_check(pos_terms, neg_terms, body: str) -> dict:
530
530
 
531
531
  # ---- 写盘(固化)---------------------------------------------------------
532
532
 
533
- # 生效条件:给定 content 与 field,当 content 含 "# field:" 或 "# field:" 时返回 True,否则 False。
533
+ # 生效条件:委托 nodefile.ccg_mark_present——content 含 "# field" 标题行(冒号可有可无)则返回 True,否则 False。
534
534
  def _has_ccg_line(content: str, field: str) -> bool:
535
- return f"# {field}:" in (content or "") or f"# {field}:" in (content or "")
535
+ # 判据单点在 nodefile(2026-09-28 收口径):冒号可有可无,与写入闸门同一语义。
536
+ return nodefile.ccg_mark_present(content, field)
536
537
 
537
538
 
538
539
  # 生效条件:给定 fm 与 content,对每个 CCG_FIELDS,若 frontmatter.comment 值非空或正文含对应 CCG 行则记入,返回已有字段字典。
@@ -551,7 +552,17 @@ def existing_fields(fm: dict, content: str) -> dict:
551
552
 
552
553
  # 生效条件:给定 content、field、value,若已有 "# field:" 行则替换并返回新正文;否则插在 "# 功能名" 之后,若无则该行前置。
553
554
  def _upsert_ccg_line(content: str, field: str, value: str) -> str:
554
- """在正文里写入/替换 `# <字段>:<值>`,优先插在「# 功能名」之后。"""
555
+ """在正文里写入/替换 `# <字段>:<值>`,优先插在「# 功能名」之后。
556
+
557
+ **值必须单行**(N208,2026-09-28):本函数此前只有 `ccgc` 那份副本做了
558
+ 加固而自身漏掉——而本副本被 `backfill` 与 `crosscheck` 共用,等于注入面
559
+ 在这两条路径上仍然开着。判据委托 `nodefile.ccg_value_has_break`(单点)。
560
+ """
561
+ if nodefile.ccg_value_has_break(value):
562
+ raise ValueError(
563
+ "ccg 行值含换行:%r——值必须是单行(换行会注入伪造的独立正文行,"
564
+ "正文读面按首个命中行取值即被顶替)。N208 fail-closed 拒写。"
565
+ % (str(value)[:80],))
555
566
  lines = (content or "").split("\n")
556
567
  for i, ln in enumerate(lines):
557
568
  s = ln.strip()
@@ -849,11 +860,20 @@ def fill_verification_basis(root: str, basis: str, layer: str = None,
849
860
  # 场景:情境层(contextual)里有些记忆被反复命中/并入——它们已经不是「一次情境」,
850
861
  # 而是稳定的规律。本动作把它们提升为长期知识(knowledge),并保留:
851
862
  # · 双向可追溯:promoted_from + 演化账本(KIND_LAYER_SHIFT);
852
- # · 条件门槛:四要素(CCG)不全者**不提升**(未可判定就不该升格为长期知识);
863
+ # · 条件门槛:六要素(CCG,真源 `nodefile.CCG_REQUIRED`:功能名/生效条件/
864
+ # 子功能/执行/验证方式/不适用条件)不全者**不提升**(未可判定就不该升格为
865
+ # 长期知识)——C-7 前此处只查四要素,缺「验证方式」的弱证据节点可蒙混升格;
853
866
  # · 可预演:apply=False 只出报表;可留痕:`_maintain.jsonl`。
854
867
 
855
868
  MAINTAIN_LOG = "_maintain.jsonl"
856
- CCG_REQUIRED = ("生效条件", "子功能", "执行", "不适用条件")
869
+ # C-7(2026-09-29):本模块此前自带**第二份**要素常量——4 元素
870
+ # ("生效条件","子功能","执行","不适用条件"),与真源 `nodefile.CCG_REQUIRED`
871
+ # 的**六要素**(多「功能名」「验证方式」)不一致 ⇒ 缺「验证方式」的弱证据节点
872
+ # `all(_has_ccg_line(...))` 仍判 complete,可被 `promote_memories` 升格进
873
+ # knowledge 层;`predict` 亦按此常量判 node_ready,同一缺口在两个消费面同时
874
+ # 放大。判据单点在 `nodefile`,本处只**转发**不复制——副本再抄一次就会再次
875
+ # 漂移(N208 同型教训:`_upsert_ccg_line` 的 ccgc 副本加固而本副本漏掉)。
876
+ CCG_REQUIRED = nodefile.CCG_REQUIRED
857
877
 
858
878
 
859
879
  # 生效条件:给定 cg、nid、e、fm、content、target_layer,把节点写入目标层(必要时按 routing 分桶)并删除旧路径,返回新相对路径与 bucket。
@@ -875,11 +895,16 @@ def _relocate_layer(cg, nid, e, fm, content, target_layer):
875
895
  "bucket": bucket}
876
896
 
877
897
 
878
- # 生效条件:给定 root,把 source_layer 中命中次数不小于 min_merge 或 importance 不小于 min_importance 且条件完整的节点提升到 target_layer,返回统计 rep。
898
+ # 生效条件:给定 root,把 source_layer 中命中次数不小于 min_merge 或 importance 不小于 min_importance 且**六要素完整**(真源 nodefile.CCG_REQUIRED,经模块常量 CCG_REQUIRED 转发)的节点提升到 target_layer,返回统计 rep;require_conditions 为真时六要素不全者计入 skipped_incomplete 不提升。
879
899
  def promote_memories(root, source_layer="contextual", target_layer="knowledge",
880
900
  min_merge=2, min_importance=0.6, require_conditions=True,
881
901
  limit=None, apply=False, actor="maintain") -> dict:
882
- """把反复命中的情境记忆批量提升为长期知识(可预演 / 可留痕 / 可追溯)。"""
902
+ """把反复命中的情境记忆批量提升为长期知识(可预演 / 可留痕 / 可追溯)。
903
+
904
+ 升格门槛(C-7,2026-09-29):要件清单与写入闸门**同源**
905
+ (`nodefile.CCG_REQUIRED` 六要素,模块常量转发)——原先本模块自带 4 元素
906
+ 副本,缺「验证方式」的弱证据节点会被判 complete 并升格。
907
+ """
883
908
  cg = MdCGOS(root)
884
909
  entries = cg._candidates(layer=source_layer)
885
910
  rep = {"root": root, "source_layer": source_layer, "target_layer": target_layer,
@@ -905,7 +930,7 @@ def promote_memories(root, source_layer="contextual", target_layer="knowledge",
905
930
  imp = float(fm.get("importance") or e.get("importance") or 0.0)
906
931
  complete = all(_has_ccg_line(content, f) for f in CCG_REQUIRED)
907
932
  if require_conditions and not complete:
908
- rep["skipped_incomplete"] += 1 # 四要素不全 → 不可判定,不升格
933
+ rep["skipped_incomplete"] += 1 # 六要素不全 → 不可判定,不升格
909
934
  continue
910
935
  hot = hits >= int(min_merge)
911
936
  if not hot and imp < float(min_importance):
@@ -1329,7 +1354,7 @@ def induce_memories(cg_or_root, source_layer="contextual", target_layer="knowled
1329
1354
  require_conditions = True
1330
1355
 
1331
1356
  nodes = (getattr(cg, "index", None) or {}).get("nodes") or {}
1332
- pool = [nid for nid, e in nodes.items()
1357
+ pool = [nid for nid, e in list(nodes.items())
1333
1358
  if (not source_layer or (e or {}).get("layer") == source_layer)
1334
1359
  and not (e or {}).get("protected")
1335
1360
  and not (set(INDUCE_SKIP_TAGS) & set((e or {}).get("tags") or []))]
package/md_cg/crypto.py CHANGED
@@ -43,9 +43,11 @@ import json
43
43
  import os
44
44
  import secrets
45
45
  import struct
46
+ import sys
46
47
  import time
47
48
 
48
49
  from .datapath import aux_root
50
+ from .fsutil import FileLock
49
51
 
50
52
  # ---- 常量 ----------------------------------------------------------------
51
53
 
@@ -259,24 +261,42 @@ def keys_path(root):
259
261
  return os.path.join(root, KEYS_FILE)
260
262
 
261
263
 
262
- # 生效条件:root 为入参;若 os.path.exists(keys_path(root)) 为假,返回 {"v": ENVELOPE_VERSION, "alg": ALG, "envelopes": {}};否则尝试 json.load,若结果为 dict 且其 "envelopes" 为 dict 则返回该 dict;若 json 解析 ValueError 或 OSError 则返回同样的空结构。
264
+ # 生效条件:root 为入参;keys_path(root) 不存在时返回 {"v": ENVELOPE_VERSION, "alg": ALG, "envelopes": {}}(缺文件=fresh install,零告警零标记);否则尝试 json.load,结果为 dict 且 "envelopes" 为 dict 时原样返回;json 解析 ValueError、OSError、或顶层为 dict 但 "envelopes" 缺失/非 dict(版本漂移)时向 stderr 写「密钥库损坏/不可读」告警(N139,2026-09-27 第 5 轮:静默回落会让 provision_dek 据空表重签新 DEK 并经 _save_keys 整份覆盖写回,旧信封无痕抹除、旧密文永久不可解——告警必须开口)并返回带 load_error 标记的空结构;provision_dek 见 load_error 即 fail-closed 拒绝重签,unwrap_dek 照旧按无信封抛 LockedError(读面零闸变)。
263
265
  def _load_keys(root):
264
266
  p = keys_path(root)
265
267
  if not os.path.exists(p):
266
268
  return {"v": ENVELOPE_VERSION, "alg": ALG, "envelopes": {}}
269
+ err = None
267
270
  try:
268
271
  with open(p, "r", encoding="utf-8") as f:
269
272
  d = json.load(f)
270
273
  if isinstance(d, dict) and isinstance(d.get("envelopes"), dict):
271
274
  return d
272
- except (ValueError, OSError):
273
- pass
274
- return {"v": ENVELOPE_VERSION, "alg": ALG, "envelopes": {}}
275
-
276
-
277
- # 生效条件:root、data 为入参,将 data 经 json.dumps(data, ensure_ascii=False, indent=1) 后由 atomic_write 写入 keys_path(root)。
275
+ err = "顶层不是对象或 envelopes 非映射(版本漂移?)"
276
+ except (ValueError, OSError) as e:
277
+ err = f"{type(e).__name__}: {e}"
278
+ sys.stderr.write(
279
+ f"[mdcg-crypto] ⚠ 密钥库损坏/不可读({err}):{p}"
280
+ f"——按回落口径返回空结构并置损坏标记(load_error);签发/重写在"
281
+ f"标记下拒绝(静默重签会无痕抹除旧信封,N139)。"
282
+ f"请修复或恢复该文件后重试。\n")
283
+ return {"v": ENVELOPE_VERSION, "alg": ALG, "envelopes": {},
284
+ "load_error": err}
285
+
286
+
287
+ # 生效条件:root、data 为入参;先调 _load_keys 读盘面写前对账(N184):盘面带 load_error 时抛 LockedError(N139 同闸延申到写点——解析不了的密钥库不得被整份覆盖),随后把盘面已有而 data["envelopes"] 没有的信封逐键并入(陈旧快照整份写回不得无痕抹除并发新增的他身份信封;键同时存在时以本次写回为准,rotate 语义不受影响),最后 json.dumps(data, ensure_ascii=False, indent=1) 由 atomic_write 写入 keys_path(root)。调用方须已持有 keys_path(root) 的 FileLock(provision_dek 持锁调用);对账合并是防线纵深,兜底任何未持锁的陈旧快照写者。
278
288
  def _save_keys(root, data):
279
289
  from .fsutil import atomic_write
290
+ disk = _load_keys(root)
291
+ if disk.get("load_error"):
292
+ raise LockedError(
293
+ f"密钥库损坏/不可读({disk['load_error']}):{keys_path(root)}"
294
+ f"——写前对账发现盘面不可解析,拒绝整份覆盖(解析不了的信封"
295
+ f"一概不能被写掉,N139/N184)。请先手工处理该文件。")
296
+ env = data.setdefault("envelopes", {})
297
+ for k, v in (disk.get("envelopes") or {}).items():
298
+ if k not in env:
299
+ env[k] = v
280
300
  atomic_write(keys_path(root),
281
301
  json.dumps(data, ensure_ascii=False, indent=1))
282
302
 
@@ -286,7 +306,7 @@ def _envelope_key(tenant, actor):
286
306
  return f"{tenant}|{actor}"
287
307
 
288
308
 
289
- # 生效条件:root、kek、tenant、actor、clearance="private"、rotate=False 为入参;若 kek 为假抛 LockedError;否则加载 keys,若 _envelope_key(tenant, actor) 已在 envelopes 中且 rotate 为假则返回 unwrap_dek(root, kek, tenant, actor, clearance);否则生成新 DEK 与 nonce,用 aead_encrypt(kek, nonce, dek, _dek_aad(tenant, actor)) 包裹后写入 envelopes[k] 并保存,返回 dek。
309
+ # 生效条件:root、kek、tenant、actor、clearance="private"、rotate=False 为入参;若 kek 为假抛 LockedError;否则以 FileLock(keys_path(root), strict=True) 持跨进程写锁进入临界区(N184,2026-09-28 第 23 轮:读快照→签发→写回全程持锁,写者互斥——原子替换只防撕裂不防丢失更新,两写者按各自陈旧快照整份写回会无痕抹除对方信封;锁竞争超时经 TimeoutError 转 LockedError fail-closed,绝不无锁放行整份写回);临界区内加载 keys,keys 带 load_error 损坏标记时抛 LockedError(N139:损坏/漂移回落下静默重签会经 _save_keys 整份覆盖写回并无痕抹除旧信封——旧密文永久不可解);若 _envelope_key(tenant, actor) 已在 envelopes 中且 rotate 为假则返回 unwrap_dek(root, kek, tenant, actor, clearance)(临界区内互见 → 同身份并发 provision 幂等同 DEK);否则生成新 DEK 与 nonce,用 aead_encrypt(kek, nonce, dek, _dek_aad(tenant, actor)) 包裹后写入 envelopes[k],经 _save_keys 写前对账合并并发新增信封(N184 防线纵深)后保存,返回 dek。
290
310
  def provision_dek(root, kek, tenant, actor, clearance="private", rotate=False):
291
311
  """为 (tenant, actor) 生成 / 取回 DEK,用 KEK 包裹后存入 `_keys.json`。
292
312
 
@@ -294,24 +314,37 @@ def provision_dek(root, kek, tenant, actor, clearance="private", rotate=False):
294
314
  """
295
315
  if not kek:
296
316
  raise LockedError("无主密钥(KEK):拒绝签发数据密钥")
297
- data = _load_keys(root)
298
- data.setdefault("envelopes", {})
299
- k = _envelope_key(tenant, actor)
300
- if k in data["envelopes"] and not rotate:
301
- return unwrap_dek(root, kek, tenant, actor, clearance)
302
- dek = secrets.token_bytes(KEY_LEN)
303
- nonce = secrets.token_bytes(NONCE_LEN)
304
- ct, tag = aead_encrypt(kek, nonce, dek, _dek_aad(tenant, actor))
305
- data["envelopes"][k] = {
306
- "id_fp": identity_fingerprint(tenant, actor),
307
- "clearance": clearance,
308
- "nonce": _b64e(nonce), "ct": _b64e(ct + tag),
309
- "created_at": time.time(),
310
- }
311
- data["v"] = ENVELOPE_VERSION
312
- data["alg"] = ALG
313
- _save_keys(root, data)
314
- return dek
317
+ try:
318
+ with FileLock(keys_path(root), timeout=10.0, strict=True):
319
+ data = _load_keys(root)
320
+ if data.get("load_error"):
321
+ raise LockedError(
322
+ f"密钥库损坏/不可读({data['load_error']}):{keys_path(root)}"
323
+ f"——损坏标记下拒绝重签数据密钥(静默整份覆盖会无痕抹除旧信封,"
324
+ f"N139)。请修复或恢复 _keys.json 后重试;若有意重置,请先手工"
325
+ f"处理该文件(备份/移除)再重试。")
326
+ data.setdefault("envelopes", {})
327
+ k = _envelope_key(tenant, actor)
328
+ if k in data["envelopes"] and not rotate:
329
+ return unwrap_dek(root, kek, tenant, actor, clearance)
330
+ dek = secrets.token_bytes(KEY_LEN)
331
+ nonce = secrets.token_bytes(NONCE_LEN)
332
+ ct, tag = aead_encrypt(kek, nonce, dek, _dek_aad(tenant, actor))
333
+ data["envelopes"][k] = {
334
+ "id_fp": identity_fingerprint(tenant, actor),
335
+ "clearance": clearance,
336
+ "nonce": _b64e(nonce), "ct": _b64e(ct + tag),
337
+ "created_at": time.time(),
338
+ }
339
+ data["v"] = ENVELOPE_VERSION
340
+ data["alg"] = ALG
341
+ _save_keys(root, data)
342
+ return dek
343
+ except TimeoutError as e:
344
+ raise LockedError(
345
+ f"密钥库写锁({keys_path(root)}.lock)竞争超时:并发 provision "
346
+ f"未在限时内获得互斥——fail-closed 拒绝签发(无锁整份写回会无痕"
347
+ f"抹除他身份信封,N184)。请稍后重试。") from e
315
348
 
316
349
 
317
350
  # 生效条件:root、kek、tenant、actor、clearance=None 为入参;若 kek 为假抛 LockedError;否则取 _load_keys(root)["envelopes"] 中 _envelope_key(tenant, actor) 的信封,无则抛 LockedError;若 env["id_fp"] 不等于 identity_fingerprint(tenant, actor) 抛 LockedError;否则从 env["ct"] 解出 raw,按 TAG_LEN 切出 ct/tag,调 aead_decrypt;若 aead_decrypt 抛 CryptoError 则转抛 LockedError;clearance 形参默认 None 但源码未在条件中使用。
package/md_cg/docindex.py CHANGED
@@ -196,7 +196,22 @@ def extract(source, path="", suffix=None):
196
196
  if info[k]["small"] or heads[k]["level"] > MAX_LEVEL]
197
197
  merged = [m for m in merged if m]
198
198
  if merged:
199
- summary = (summary + ";" + ";".join(merged))[:MAX_SUMMARY]
199
+ # N204(2026-09-28):合并段必须**真的进得去**。此前先 `_summary(direct)`
200
+ # 按 MAX_SUMMARY 取满(父节直接正文 ≥200 字时已占满 200),再拼接整体
201
+ # 截断 ⇒ 被合并子节的文字一个字都留不下(实测 parent_len=440/200/199
202
+ # 均「子节独有文本进摘要=False」)。而合并的**唯一目的**就是让这些
203
+ # 小节的正文可检索——等于静默丢索引。改为**预算预分配**:合并段最多
204
+ # 占 MAX_SUMMARY 的一半,父段按「总预算 − 合并段实占 − 分隔符」取,
205
+ # 于是合并段无论父段多长都保证进得去(父段过长时让位,最后仍整体截到
206
+ # MAX_SUMMARY 上限)。
207
+ # 如实边界:单个被合并子节(≤80 字)恒进得去;合并段超预算时只保前
208
+ # min(len, MAX_SUMMARY//2) 字——200 字上限下这是不可避免的上界。
209
+ merged_text = ";".join(merged)
210
+ merged_budget = min(len(merged_text), MAX_SUMMARY // 2)
211
+ parent_budget = max(0, MAX_SUMMARY - merged_budget - 1)
212
+ summary = ";".join(
213
+ x for x in (_summary(info[i]["direct"], parent_budget),
214
+ merged_text) if x)[:MAX_SUMMARY]
200
215
  if not summary:
201
216
  summary = "(该节无直接正文,见子节)"
202
217
  parent = _path_titles(heads, i)