@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
package/md_cg/mdcos.py CHANGED
@@ -23,17 +23,20 @@ import json
23
23
  import os
24
24
  import re
25
25
  import time
26
+ import uuid
26
27
 
27
28
  from .mdcg import (MdCG, expand_query_terms, bigrams, normalize_en, STATE_ACCEPT,
28
29
  STATE_REJECT, STATE_DEFER, STATE_BLINDSPOT, TIER_BUCKET_LIKE,
29
30
  TIER_BUCKET_SCAN, TIER_GLOBAL_LIKE, TIER_GLOBAL_SCAN,
30
31
  GLOBAL_CAP, expand_query_terms_weighted,
31
- expand_query_terms_llm, en_zh_bigrams, semantic_on)
32
+ expand_query_terms_llm, en_zh_bigrams, semantic_on,
33
+ cut_by_relevance)
32
34
  from . import (nodefile, routing, chain, subgraph, forgetting, protect,
33
35
  identity, consistency, metacognition, crypto, sustain,
34
36
  self_state, predict, evolution, weights, pooling,
35
37
  writelimit)
36
- from .fsutil import FileLock, atomic_write, append_jsonl, read_jsonl
38
+ from .fsutil import (FileLock, atomic_write, append_jsonl, read_jsonl,
39
+ count_jsonl)
37
40
  from .security import (Principal, TenantRegistry, AccessDenied,
38
41
  SENSITIVITY_ORDER, DEFAULT_SENSITIVITY, _rank)
39
42
 
@@ -275,6 +278,7 @@ class MdCGOS(MdCG):
275
278
  super().__init__(root, autoflush=autoflush)
276
279
  self.actor = actor
277
280
  self.audit_log = os.path.join(self.root, "_audit.jsonl")
281
+ self.audit_archive = os.path.join(self.root, self.AUDIT_ARCHIVE)
278
282
  self.deletions_log = os.path.join(self.root, "_deletions.jsonl")
279
283
  self.hippocampus = os.path.join(self.root, self.HIPPOCAMPUS)
280
284
  self.inbox_log = os.path.join(self.hippocampus, "inbox.jsonl")
@@ -282,6 +286,8 @@ class MdCGOS(MdCG):
282
286
  self.trash_dir = os.path.join(self.root, self.TRASH)
283
287
  os.makedirs(self.hippocampus, exist_ok=True)
284
288
  os.makedirs(self.trash_dir, exist_ok=True)
289
+ self._audit_writes = 0 # 进程内写入计数(轮转探测节流,稳态零 stat)
290
+ self._audit_index = None # 归档索引缓存(懒加载)
285
291
 
286
292
  # ================= 6. payload-free 审计 =================
287
293
 
@@ -291,12 +297,216 @@ class MdCGOS(MdCG):
291
297
  "session": getattr(self, "session", None)}
292
298
  rec.update(meta)
293
299
  try:
300
+ self._rotate_audit_if_needed()
294
301
  append_jsonl(self.audit_log, rec)
295
302
  except OSError:
296
303
  pass
297
304
 
298
- def audit_records(self):
299
- return list(read_jsonl(self.audit_log))
305
+ def audit_records(self, limit: int = None):
306
+ """审计记录读取——轮转后跨分片按时间序(旧片在前)合并。
307
+
308
+ 默认保持全量语义(既有调用方零改动);limit=N 取尾部 N 条,供巡检使用
309
+ ——有界日志不该被读成新的 O(n) 全量。
310
+ """
311
+ paths = [os.path.join(self.audit_archive, n) for n in self._audit_shards()]
312
+ paths.append(self.audit_log)
313
+ if limit is None:
314
+ out = []
315
+ for p in paths:
316
+ out.extend(read_jsonl(p))
317
+ return out
318
+ out = []
319
+ for p in reversed(paths): # 从最新往回读,读满 limit 即停
320
+ if len(out) >= limit:
321
+ break
322
+ out = list(read_jsonl(p)) + out
323
+ return out[-limit:]
324
+
325
+ # ---------- 审计日志分片轮转(治本:给无上界增长装上界) ----------
326
+
327
+ def _audit_shards(self):
328
+ """归档分片名,序号零填充 ⇒ 字典序 == 时间序。"""
329
+ try:
330
+ names = os.listdir(self.audit_archive)
331
+ except OSError:
332
+ return []
333
+ return sorted(n for n in names
334
+ if n.startswith("_audit.") and n.endswith(".jsonl"))
335
+
336
+ def _load_audit_index(self) -> dict:
337
+ if self._audit_index is None:
338
+ idx = {}
339
+ try:
340
+ with open(os.path.join(self.audit_archive, self.AUDIT_INDEX),
341
+ "r", encoding="utf-8") as f:
342
+ data = json.load(f)
343
+ if isinstance(data, dict):
344
+ idx = {k: v for k, v in data.items() if isinstance(v, dict)}
345
+ except (OSError, ValueError):
346
+ idx = {} # 索引缺失/损坏 → 空起步,自愈补数
347
+ self._audit_index = idx
348
+ return self._audit_index
349
+
350
+ def _save_audit_index(self, idx: dict):
351
+ try:
352
+ os.makedirs(self.audit_archive, exist_ok=True)
353
+ atomic_write(os.path.join(self.audit_archive, self.AUDIT_INDEX),
354
+ json.dumps(idx, ensure_ascii=False, indent=1, sort_keys=True))
355
+ except OSError:
356
+ pass
357
+ self._audit_index = idx
358
+
359
+ def _rotate_audit_if_needed(self):
360
+ """写前闸门:活动日志达阈值即切分(把 stat 摊到 1/AUDIT_PROBE_EVERY)。
361
+
362
+ 为什么不逐条 stat:写路径要付的是「每次写入税」,而活动文件由本进程与同
363
+ root 其它进程共同增长,故按固定间隔读一次真实元数据校准——既不漏轮转,
364
+ 也不把 O(1) 纪律反向变成 O(n) 写入开销。
365
+ """
366
+ limit = self.AUDIT_ROTATE_BYTES
367
+ if limit <= 0:
368
+ return
369
+ self._audit_writes = (self._audit_writes or 0) + 1
370
+ if self._audit_writes % self.AUDIT_PROBE_EVERY:
371
+ return
372
+ try:
373
+ if os.path.getsize(self.audit_log) < limit:
374
+ return
375
+ except OSError:
376
+ return
377
+ self.rotate_audit()
378
+
379
+ def rotate_audit(self, reason: str = "size"):
380
+ """把活动审计日志切分为归档分片(os.replace 原子,不重写一个字节)。
381
+
382
+ 语义边界(诚实面):
383
+ · 分片内容与轮转前**逐行一致**(rename 不动字节);最新记录始终在活动文件
384
+ `_audit.jsonl` 中——读尾部取最新记录的调用方不受轮转影响;
385
+ · 并发由 FileLock + 「rename 前复检大小 / 失败即返回 None」兜住:抢输的
386
+ 进程不重复切分,也不丢记录(记录要么在旧片、要么在活动文件);
387
+ · 保留策略只淘汰**分片**,且淘汰名单写进审计(不静默丢证据)。
388
+ """
389
+ with FileLock(os.path.join(self.root, "_audit.rotate.lock"), timeout=5.0):
390
+ try:
391
+ size = os.path.getsize(self.audit_log)
392
+ except OSError:
393
+ return None
394
+ if size < self.AUDIT_ROTATE_BYTES:
395
+ return None # 已被并发写者轮转
396
+ os.makedirs(self.audit_archive, exist_ok=True)
397
+ name = "_audit.%06d.jsonl" % self._next_shard_seq()
398
+ dst = os.path.join(self.audit_archive, name)
399
+ try:
400
+ os.replace(self.audit_log, dst)
401
+ except OSError:
402
+ return None # 抢输(文件已被移走)→ 让位,不报错
403
+ scale = self._audit_count_shard(dst, size) # 分档:有界扫描 / 只读量级
404
+ events = scale["events"]
405
+ idx = self._load_audit_index()
406
+ idx[name] = {"bytes": size, "events": events, "exact": scale["exact"],
407
+ "t": round(time.time(), 3), "reason": reason}
408
+ self._save_audit_index(idx)
409
+ pruned = self._prune_audit_shards()
410
+ self._audit_writes = 0
411
+ try: # 自述留痕:轮转本身可审计
412
+ append_jsonl(self.audit_log,
413
+ {"t": time.time(), "op": "audit_rotate", "id": name,
414
+ "actor": self.actor, "bytes": size, "events": events,
415
+ "pruned": pruned, "reason": reason,
416
+ "session": getattr(self, "session", None)})
417
+ except OSError:
418
+ pass
419
+ return {"shard": name, "bytes": size, "events": events, "pruned": pruned}
420
+
421
+ def _audit_count_shard(self, path, size: int = None) -> dict:
422
+ """分片条数读数:有界分片给精确值,超大历史分片只给量级。
423
+
424
+ 为什么分档:阈值内的分片扫描是**有界**代价(≤ AUDIT_COUNT_MAX_BYTES);但
425
+ 历史遗留的超大文件(本机首个分片即 4.0 GB)若在轮转/体检路径上全量解析,
426
+ 就会把「一次调用堵死整条通道」原样复现——故超阈值退回 _log_scale 元数据
427
+ 口径并如实标注 exact=False(要精确值走离线工具,不在写路径上付 O(n))。
428
+ """
429
+ if size is None:
430
+ try:
431
+ size = os.path.getsize(path)
432
+ except OSError:
433
+ return {"bytes": 0, "events": 0, "exact": True}
434
+ if size <= self.AUDIT_COUNT_MAX_BYTES:
435
+ return {"bytes": size, "events": count_jsonl(path), "exact": True}
436
+ s = self._log_scale(path)
437
+ return {"bytes": size, "events": s["events"], "exact": False}
438
+
439
+ def _next_shard_seq(self) -> int:
440
+ top = 0
441
+ for n in self._audit_shards():
442
+ try:
443
+ top = max(top, int(n[len("_audit."):-len(".jsonl")]))
444
+ except ValueError:
445
+ continue
446
+ return top + 1
447
+
448
+ def _prune_audit_shards(self):
449
+ """保留最近 AUDIT_KEEP_SHARDS 个分片、淘汰更旧的(≤0 表示不淘汰)。"""
450
+ keep = self.AUDIT_KEEP_SHARDS
451
+ if keep <= 0:
452
+ return []
453
+ shards = self._audit_shards()
454
+ gone = shards[:-keep] if len(shards) > keep else []
455
+ if not gone:
456
+ return []
457
+ idx = self._load_audit_index()
458
+ for n in gone:
459
+ try:
460
+ os.remove(os.path.join(self.audit_archive, n))
461
+ except OSError:
462
+ continue # 删不掉就留着:不假装已淘汰
463
+ idx.pop(n, None)
464
+ self._save_audit_index(idx)
465
+ return gone
466
+
467
+ def audit_scale(self) -> dict:
468
+ """审计面量级读数(O(1) 稳态):活动文件实时读数 + 归档分片索引缓存。
469
+
470
+ 为什么不逐片重数(第 4 条:指标与代价匹配):轮转后「总计」= 活动 + 各分片,
471
+ 逐片全量数会把体检摊成 O(分片总字节)。分片是**封存文件**(内容不再变),故
472
+ 封存时数一次、落索引缓存即得稳态 O(1);目录里出现未登记分片(外部手工放入)
473
+ 时自愈补数一次——绝不假装与磁盘一致。
474
+ """
475
+ active = self._log_scale(self.audit_log)
476
+ present = self._audit_shards()
477
+ idx = self._load_audit_index()
478
+ healed = False
479
+ for n in present:
480
+ if n in idx:
481
+ continue
482
+ p = os.path.join(self.audit_archive, n)
483
+ try:
484
+ sc = self._audit_count_shard(p) # 分档:有界扫描 / 只读量级
485
+ idx[n] = {"bytes": sc["bytes"], "events": sc["events"],
486
+ "exact": sc["exact"], "t": round(time.time(), 3),
487
+ "reason": "adopted"}
488
+ healed = True
489
+ except OSError:
490
+ continue
491
+ if healed:
492
+ self._save_audit_index(idx)
493
+ shard_bytes = sum(int(v.get("bytes") or 0) for k, v in idx.items() if k in present)
494
+ shard_events = sum(int(v.get("events") or 0) for k, v in idx.items() if k in present)
495
+ oversized = sum(1 for n in present if not idx.get(n, {}).get("exact", True))
496
+ out = dict(active)
497
+ out.update({
498
+ "shards": len(present),
499
+ "shard_bytes": shard_bytes,
500
+ "shard_events": shard_events,
501
+ "oversized": oversized, # 非精确分片数(历史遗留超大文件)
502
+ "total_bytes": active["bytes"] + shard_bytes,
503
+ "total_events": (active["events"] + shard_events
504
+ if active["events"] is not None else None),
505
+ "total_exact": bool(active["exact"]) and oversized == 0,
506
+ "rotate_bytes": self.AUDIT_ROTATE_BYTES,
507
+ "keep_shards": self.AUDIT_KEEP_SHARDS,
508
+ })
509
+ return out
300
510
 
301
511
  # ================= 2. role 分层索引 =================
302
512
 
@@ -315,11 +525,14 @@ class MdCGOS(MdCG):
315
525
  return nid
316
526
 
317
527
  def _candidates(self, layer=None, roles=None, include_work=False,
318
- session=None):
528
+ session=None, branch=None):
319
529
  """候选池:按层 + role 过滤。默认剔除工作角色(工具输出/命令/编辑)。
320
530
 
321
531
  session:会话归属过滤(frontmatter.session,写入时自动落盘)——
322
532
  多会话共用一个 root 时,按它区分「本会话记忆 / 其他会话记忆」。
533
+ branch:分支可见性(记忆演化分支④)——None(默认)时分支实验
534
+ 节点全部隐身(实验不污染主支检索);branch=<id> 时主支 + 该分支
535
+ 可见、其他分支仍隐身。
323
536
  """
324
537
  out = []
325
538
  for e in self.index["nodes"].values():
@@ -327,6 +540,8 @@ class MdCGOS(MdCG):
327
540
  continue # 负记忆走覆盖标记;目标只做定向,都不进正排
328
541
  if session and e.get("session") != session:
329
542
  continue
543
+ if e.get("branch_id") not in (None, branch):
544
+ continue
330
545
  if layer and e.get("layer") != layer:
331
546
  continue
332
547
  r = e.get("role")
@@ -376,7 +591,7 @@ class MdCGOS(MdCG):
376
591
  context=None, min_results: int = 1, record: bool = True,
377
592
  include_neg: bool = True, judge: bool = True,
378
593
  roles=None, include_work: bool = False, pools=None,
379
- session=None):
594
+ session=None, branch=None):
380
595
  """在父类语义之上加 role 过滤(默认剔除工具输出/命令/编辑)。
381
596
 
382
597
  返回 (results, meta),与 MdCG.search 完全同构(T0–T3 阶梯 + 资格判定)。
@@ -387,7 +602,7 @@ class MdCGOS(MdCG):
387
602
  return [], {"tier": None, "reason": "empty_query", "scanned": 0}
388
603
  pool_cfg = pooling.resolve(pooling.from_env(pools))
389
604
  entries = self._candidates(layer=layer, roles=roles, include_work=include_work,
390
- session=session)
605
+ session=session, branch=branch)
391
606
  if not entries:
392
607
  return [], {"tier": None, "reason": "no_candidates", "scanned": 0}
393
608
 
@@ -428,25 +643,27 @@ class MdCGOS(MdCG):
428
643
  if out:
429
644
  return out
430
645
 
646
+ # 截断依据=相关度(同 MdCG.search:cap 值不变,改的是拿什么排序)
431
647
  docs_all = self._read_many(entries, stat)
432
648
  # 语义资格(MDCG_SEMANTIC=1):fm.semantic 节点无条件入池
433
649
  hits = [d for d in docs_all if self._like(d[2], d[1], terms)
434
650
  or (semantic_on() and d[1].get("semantic"))]
435
651
  stat["pre_cap"] = len(hits)
436
652
  stat["cap"] = GLOBAL_CAP
437
- hits, _rep = pooling.cut_report(hits, GLOBAL_CAP, pools=pool_cfg,
438
- key_of=pooling.doc_key)
653
+ hits, _rep = cut_by_relevance(hits, self._score(hits, q, qb, pool_cfg),
654
+ GLOBAL_CAP, pools=pool_cfg,
655
+ key_of=pooling.doc_key, stat=stat)
439
656
  pooling.record_audit(stat, _rep)
440
657
  out = try_stage(hits, TIER_GLOBAL_LIKE)
441
658
  if out:
442
659
  return out
443
660
 
444
- docs_all.sort(key=lambda d: (-float(d[1].get("importance") or 0),
445
- -float(d[1].get("created_at") or 0)))
446
661
  stat["pre_cap"] = len(docs_all)
447
662
  stat["cap"] = GLOBAL_CAP
448
- picked, _rep = pooling.cut_report(docs_all, GLOBAL_CAP, pools=pool_cfg,
449
- key_of=pooling.doc_key)
663
+ picked, _rep = cut_by_relevance(docs_all,
664
+ self._score(docs_all, q, qb, pool_cfg),
665
+ GLOBAL_CAP, pools=pool_cfg,
666
+ key_of=pooling.doc_key, stat=stat)
450
667
  pooling.record_audit(stat, _rep)
451
668
  scored = self._score(picked, q, qb, pool_cfg)
452
669
  return self._emit(scored, k, TIER_GLOBAL_SCAN, stat, route_bucket,
@@ -460,7 +677,9 @@ class MdCGOS(MdCG):
460
677
  LIKE 命中集沿 entries(目录枚举序)排列,插入序截断会让本路候选池
461
678
  随写入顺序漂移、不可复算,并可能把与查询最相关的节点随机丢弃。
462
679
  LIKE 全空时兜底池改用 importance/created_at 序(与 search 主路径同口径),
463
- 不再取插入序前 CAP。
680
+ 不再取插入序前 CAP。返回序**恒为相关度降序**——原实现仅在超 cap 时
681
+ 排序,≤cap 时直接返回 entries 枚举序,下游 seed/融合会拿到无语义依据
682
+ 的顺序。
464
683
  """
465
684
  terms = expand_query_terms(query)
466
685
  # qb 与文档侧 normalize_en 口径对齐(同 MdCGOS.search,防大小写断裂)
@@ -472,16 +691,23 @@ class MdCGOS(MdCG):
472
691
  hits = [d for d in docs if self._like(d[2], d[1], terms)
473
692
  or (semantic_on() and d[1].get("semantic"))]
474
693
  if not hits:
694
+ # 兜底池(LIKE 全空 = 无相关度信号):截断依据=importance/created_at
695
+ # 序,确定可复算;此时 bigram 部分匹配不足以定序(共现噪声),
696
+ # 故本路不做「先全量打分再截断」。
475
697
  hits = sorted(
476
698
  docs, key=lambda d: (-float(d[1].get("importance") or 0),
477
699
  -float(d[1].get("created_at") or 0))
478
700
  )[:GLOBAL_CAP]
701
+ stat["cut_order"] = "importance_fallback"
479
702
  scored = self._score(hits, query, qb)
703
+ # 恒按相关度排序(原实现仅超 cap 时排序 → ≤cap 时返回 entries 枚举序,
704
+ # 下游 seed/融合拿到无语义依据的顺序,不可复算)
705
+ scored.sort(key=lambda x: (-x[1],
706
+ -float(x[0]["frontmatter"].get("importance") or 0)))
480
707
  if len(scored) > GLOBAL_CAP:
481
708
  stat["pre_cap"] = len(scored)
482
709
  stat["cap"] = GLOBAL_CAP
483
- scored.sort(key=lambda x: (-x[1],
484
- -float(x[0]["frontmatter"].get("importance") or 0)))
710
+ stat["cut_order"] = "relevance"
485
711
  return scored[:GLOBAL_CAP]
486
712
  return scored
487
713
 
@@ -728,7 +954,7 @@ class MdCGOS(MdCG):
728
954
  record: bool = True, query_expand=None,
729
955
  path_weights=None, recall_only=None, fusion: str = "sum",
730
956
  goal_text=None, judge_ranking: bool = False,
731
- session=None):
957
+ session=None, branch=None):
732
958
  """并行多路召回 + RRF 融合。返回 (results, meta)。
733
959
 
734
960
  每路各自排序 → Reciprocal Rank Fusion:
@@ -763,7 +989,7 @@ class MdCGOS(MdCG):
763
989
  if not q:
764
990
  return [], {"tier": None, "reason": "empty_query", "paths": {}}
765
991
  entries = self._candidates(layer=layer, roles=roles, include_work=include_work,
766
- session=session)
992
+ session=session, branch=branch)
767
993
  if not entries:
768
994
  return [], {"tier": None, "reason": "no_candidates", "paths": {}}
769
995
 
@@ -873,7 +1099,7 @@ class MdCGOS(MdCG):
873
1099
  include_work: bool = False, judge: bool = True, use_rrf: bool = True,
874
1100
  paths=None, query_expand=None, fusion=None,
875
1101
  goal_text=None, include_recent=False, recent_limit: int = 10,
876
- judge_ranking: bool = False, session=None,
1102
+ judge_ranking: bool = False, session=None, branch=None,
877
1103
  max_item_tokens: int = DEFAULT_MAX_ITEM_TOKENS):
878
1104
  """按 token 预算装包:装到预算花完为止。
879
1105
 
@@ -906,11 +1132,13 @@ class MdCGOS(MdCG):
906
1132
  if goal_text is not None:
907
1133
  kw["goal_text"] = goal_text
908
1134
  kw["judge_ranking"] = judge_ranking
1135
+ if branch is not None:
1136
+ kw["branch"] = branch
909
1137
  results, meta = self.search_rrf(query, **kw)
910
1138
  items = [(r[0], r[1], r[2], r[3]) for r in results]
911
1139
  else:
912
1140
  res, meta = self.search(query, layer=layer, k=k, context=context,
913
- judge=judge, session=session)
1141
+ judge=judge, session=session, branch=branch)
914
1142
  items = [(r[0], r[1], r[2], []) for r in res]
915
1143
 
916
1144
  pack, skipped, used = [], [], 0
@@ -1033,24 +1261,67 @@ class MdCGOS(MdCG):
1033
1261
  # ================= 4. 审核队列(inbox → decisions) =================
1034
1262
 
1035
1263
  def propose(self, node_id: str, content: str, layer: str = "knowledge",
1036
- tags=None, condition_space=None, verify=None, **kw):
1264
+ tags=None, condition_space=None, verify=None,
1265
+ info: bool = False, **kw):
1037
1266
  """把一个候选记忆放入海马体 inbox,等待审核(不直接持久化)。
1038
1267
 
1039
1268
  verify —— 验收判据(内联声明,裁决阶段只读),形如:
1040
1269
  {"kind": "code", "assertions": ["pytest -k foo 通过"], "cmd": "..."}
1041
1270
  判据指纹随提案落盘,复核者只能按原判据裁决,不能放宽标准。
1271
+
1272
+ 幂等对账(两段式):入队动作在 strict 锁内「查重 → 入队」原子完成。
1273
+ 对账键 = payload_hash(内容签名 _sig(content)),同内容提案若已存在
1274
+ (无论 pending 还是已裁决 accepted/rejected)→ 幂等返回既有 pid,
1275
+ 不再入队。语义:重试与崩溃恢复无害——入队落盘成功而响应丢失时
1276
+ (MCP 客户端超时重试的真正机制),重试对账命中既有记录并返回原 pid。
1277
+
1278
+ 返回值:info=False(默认)返回 pid 字符串(保形,存量调用零变化);
1279
+ info=True 返回 {"pid", "dedup", "dup_of", "dup_status"}。
1042
1280
  """
1043
- pid = "prop_" + _sig(node_id + str(time.time()))
1044
- _norm, vhash = _verify_norm(verify)
1045
- rec = {"t": time.time(), "pid": pid, "id": node_id, "content": content,
1046
- "layer": layer, "tags": list(tags or []),
1047
- "condition_space": condition_space or {},
1048
- "verify": verify or {}, "verify_hash": vhash,
1049
- "extra": kw, "actor": self.actor,
1050
- "session": getattr(self, "session", None)}
1051
- append_jsonl(self.inbox_log, rec)
1281
+ phash = _sig(content)
1282
+ with FileLock(self.inbox_log, strict=True):
1283
+ st = self._pid_status()
1284
+ dup = None
1285
+ for r in read_jsonl(self.inbox_log):
1286
+ # 存量记录无 payload_hash 字段 → 现算兼容(对账覆盖旧账)
1287
+ rh = r.get("payload_hash") or _sig(r.get("content") or "")
1288
+ if rh != phash:
1289
+ continue
1290
+ s = st.get(r.get("pid")) or {}
1291
+ dup = {"pid": r.get("pid"),
1292
+ "status": s.get("status") or "pending",
1293
+ "node_id": r.get("id")}
1294
+ if dup["status"] in ("accepted", "rejected"):
1295
+ break # 已裁决的最有信息量,优先返回
1296
+ if dup:
1297
+ self._audit("propose_dedup", node_id, dup_of=dup["pid"],
1298
+ dup_status=dup["status"], payload_hash=phash)
1299
+ if info:
1300
+ return {"pid": dup["pid"], "dedup": True,
1301
+ "dup_of": dup["pid"], "dup_status": dup["status"]}
1302
+ return dup["pid"]
1303
+ # 唯一性取证(2026-09-16):旧式 _sig(node_id + time.time()) 只带
1304
+ # 「节点 id + 时间戳」,无进程熵——多 worker 在同一时刻用相同
1305
+ # node_id 入队即产生 pid 碰撞(test_review_conformance【8】「pid
1306
+ # 互不重复」偶发红,4 进程同用 w-node-K 时命中)。幂等由
1307
+ # payload_hash 对账保证、与 pid 取值无关,故此处补熵是
1308
+ # 只增不减契约(20 条互异由 test_review_conformance【8c】守卫)。
1309
+ pid = "prop_" + _sig(node_id + str(time.time())
1310
+ + uuid.uuid4().hex)
1311
+ _norm, vhash = _verify_norm(verify)
1312
+ rec = {"t": time.time(), "pid": pid, "id": node_id, "content": content,
1313
+ "layer": layer, "tags": list(tags or []),
1314
+ "condition_space": condition_space or {},
1315
+ "payload_hash": phash,
1316
+ "verify": verify or {}, "verify_hash": vhash,
1317
+ "extra": kw, "actor": self.actor,
1318
+ "session": getattr(self, "session", None)}
1319
+ append_jsonl(self.inbox_log, rec)
1052
1320
  self._audit("propose", node_id, pid=pid, layer=layer,
1053
- payload_hash=_sig(content), verify_hash=vhash)
1321
+ payload_hash=phash, verify_hash=vhash)
1322
+ if info:
1323
+ return {"pid": pid, "dedup": False,
1324
+ "dup_of": None, "dup_status": None}
1054
1325
  return pid
1055
1326
 
1056
1327
  def _pid_status(self):
@@ -1160,7 +1431,10 @@ class MdCGOS(MdCG):
1160
1431
  rec["record_hash"] = self._record_hash(rec)
1161
1432
  rec["result"] = {k: v for k, v in result.items()
1162
1433
  if k in ("ok", "node_id", "error", "state")}
1163
- append_jsonl(self.decisions_log, rec)
1434
+ # 裁决记录不能丢:strict 锁内追加(并发裁决不交错;
1435
+ # 丢一条裁决会让提案回 pending → 重复落盘,比让裁决者等一下代价大)
1436
+ with FileLock(self.decisions_log, strict=True):
1437
+ append_jsonl(self.decisions_log, rec)
1164
1438
  # md 审计节点写失败不影响裁决(jsonl 仍是权威来源)
1165
1439
  try:
1166
1440
  self._write_review_record(rec)
@@ -1176,6 +1450,43 @@ class MdCGOS(MdCG):
1176
1450
  record_node=rec["record_node_id"])
1177
1451
  return result
1178
1452
 
1453
+ def _cascade_dedup(self, pid: str, phash: str, decision: str):
1454
+ """主提案终态裁决后,同 payload_hash 的其余纯 pending 提案自动出清。
1455
+
1456
+ 兄弟提案多为超时重试的重复入队产物(存量账本里已有实例):主提案已
1457
+ 裁决后,兄弟再走一遍裁决只会重复落盘/重复入库。只在 decisions 锁内
1458
+ 「查兄弟最新状态 → 未有任何裁决记录才出清」,与并发裁决互斥,不会
1459
+ 覆盖兄弟自己的 accept。needs_reapproval 的兄弟有独立红队轮次历史,
1460
+ 不动(留人工处置)。级联裁决的权威源同为本文件(jsonl),
1461
+ 不写 md 审计节点(可能批量,self 层只留 jsonl + audit 簿记)。
1462
+ """
1463
+ if not phash:
1464
+ return []
1465
+ closed = []
1466
+ with FileLock(self.decisions_log, strict=True):
1467
+ st = self._pid_status()
1468
+ for r in read_jsonl(self.inbox_log):
1469
+ bpid = r.get("pid")
1470
+ if not bpid or bpid == pid or bpid in st:
1471
+ continue # 自己 / 已有裁决记录(含 needs_reapproval)跳过
1472
+ rh = r.get("payload_hash") or _sig(r.get("content") or "")
1473
+ if rh != phash:
1474
+ continue
1475
+ rec = {"t": time.time(), "pid": bpid, "decision": "reject",
1476
+ "status": "rejected", "round": 1,
1477
+ "redteam_verdict": "absent", "issues": [],
1478
+ "verify_hash": r.get("verify_hash") or "",
1479
+ "reason": f"cascade_dedup: 同内容提案已由 {pid} "
1480
+ f"{decision}(自动出清重复入队,无需再裁决)",
1481
+ "actor": self.actor, "target_id": r.get("id"),
1482
+ "record_node_id": self._audit_node_id(bpid, 1)}
1483
+ rec["record_hash"] = self._record_hash(rec)
1484
+ append_jsonl(self.decisions_log, rec)
1485
+ closed.append(bpid)
1486
+ for b in closed:
1487
+ self._audit("review_cascade", b, cascade_of=pid, decision=decision)
1488
+ return closed
1489
+
1179
1490
  def review_records(self, pid: str = None):
1180
1491
  """列出裁决记录节点(self 层 / audit 标签),供外部来源审计。"""
1181
1492
  out = []
@@ -1299,6 +1610,12 @@ class MdCGOS(MdCG):
1299
1610
  extra["verify_hash"] = expect
1300
1611
  nid = self.add(item["id"], content, layer=layer, tags=tags,
1301
1612
  condition_space=item.get("condition_space"), **extra)
1613
+ # 索引增量收尾(2026-09-16 取证):add 只把条目放进本进程内存 _dirty,
1614
+ # 未达 autoflush(64) 阈值时进程退出即永久丢失——裁决进程(review_cli /
1615
+ # MCP op=review)通常只写 1~2 条,不 flush 则「节点在盘上但索引无条目」,
1616
+ # 其他进程与重载后的长驻进程都检索不到,只能靠某次全量 rebuild 偶然救回。
1617
+ # merge 分支绕开 add 直写节点文件,故其收尾同为索引重建(同因不同法)。
1618
+ self.flush()
1302
1619
  result.update(ok=True, node_id=nid)
1303
1620
  else: # merge
1304
1621
  target = merge_into or (item.get("extra") or {}).get("merge_into")
@@ -1319,10 +1636,23 @@ class MdCGOS(MdCG):
1319
1636
  self.rebuild_index()
1320
1637
  result.update(ok=True, node_id=target)
1321
1638
 
1322
- return self._record_decision(
1639
+ result = self._record_decision(
1323
1640
  pid, item, decision,
1324
1641
  "rejected" if decision == "reject" else "accepted",
1325
1642
  reason, round_no, rt_v, rt_issues, expect, result)
1643
+ # 级联出清:主提案已终态,同内容兄弟提案(重复入队产物)自动关闭
1644
+ casc = self._cascade_dedup(
1645
+ pid, item.get("payload_hash") or _sig(item.get("content") or ""),
1646
+ decision)
1647
+ if casc:
1648
+ result["cascade_closed"] = casc
1649
+ # 裁决链路统一收尾(2026-09-16 取证):上面 accept/edit 分支的 flush 只覆盖
1650
+ # 目标节点,而 `_record_decision → _write_review_record → add` 在这里又写了一个
1651
+ # **审计记录节点**(self 层),reject 路径更是**只**写它——两者同样停留在内存
1652
+ # `_dirty` 中。不在此收尾则「审计记录在盘上但索引无条目」,复核方与其它进程都
1653
+ # 检索不到(与目标节点同因,只是漏点不同)。flush 幂等(`_dirty` 空即返回)。
1654
+ self.flush()
1655
+ return result
1326
1656
 
1327
1657
  def decisions(self):
1328
1658
  return list(read_jsonl(self.decisions_log))
@@ -1352,8 +1682,9 @@ class MdCGOS(MdCG):
1352
1682
  "actor": self.actor,
1353
1683
  "trash": os.path.relpath(dst, self.root)
1354
1684
  .replace("\\", "/")})
1355
- self.index["nodes"].pop(node_id, None)
1356
- self._dirty.pop(node_id, None)
1685
+ # 摘索引必须**落盘**(写删除记录):只 pop 内存会让条目在下次启动
1686
+ # 重放 _index_log 时复活成幽灵条目(索引有条目、文件已进 trash/)。
1687
+ self._unstage(node_id)
1357
1688
  subgraph.invalidate_cache(self)
1358
1689
  chain.invalidate_cache(self)
1359
1690
  self._audit("forget", node_id, reason=reason, payload_hash=h)
@@ -1480,14 +1811,21 @@ class MdCGOS(MdCG):
1480
1811
  budget_tokens=1200, include_state=True):
1481
1812
  """按需恢复:一次调用返回「可续接的上下文包」(替代 hook 自动注入)。
1482
1813
 
1483
- 内容 = 最近会话要点 + 活跃目标 + 近期事件 + 未解问题 (+ 自我状态卡)。
1484
- 纯只读、无副作用;返回体受 budget_tokens 约束(超出即裁剪并显式上报)。
1485
- 无 hook 的载体应在会话开始时显式调用本 op 一次。
1814
+ 内容 = 最近会话要点 + 活跃目标 + **任务台账(进行中 + 近期完成)** + 近期事件
1815
+ + 未解问题 (+ 自我状态卡)。纯只读、无副作用;返回体受 budget_tokens 约束
1816
+ (超出即裁剪并显式上报)。无 hook 的载体应在会话开始时显式调用本 op 一次。
1817
+
1818
+ 任务段(2026-09-16 新增)是「忘记已实现的工程」的直接解药:新会话开机即见
1819
+ 「还在做的」与「刚做完的」,不必先想到去查。任务属结构层、跨会话稳定,
1820
+ 故**不按 session 过滤**——工程台账跟着工程走,不跟着会话走。
1486
1821
  """
1487
1822
  limit = max(1, min(int(limit or 5), 50))
1488
1823
  budget = max(200, int(budget_tokens or 1200))
1489
1824
  pack = {"ok": True, "session": session, "source": "session_recall",
1490
- "notes": [], "goals": [], "recent": [], "unresolved": [],
1825
+ "notes": [], "goals": [],
1826
+ "tasks": {"active": [], "done": [], "active_total": 0,
1827
+ "done_total": 0},
1828
+ "recent": [], "unresolved": [],
1491
1829
  "degraded": []}
1492
1830
  # ① 会话要点
1493
1831
  try:
@@ -1501,6 +1839,22 @@ class MdCGOS(MdCG):
1501
1839
  for g in self.active_goals(limit=5)]
1502
1840
  except Exception: # noqa: BLE001
1503
1841
  pack["degraded"].append("goals")
1842
+ # ②.5 任务台账(structural 层)——见 docstring:不按 session 过滤
1843
+ try:
1844
+ from . import tasks as _tasks
1845
+ ts = _tasks.session_tasks(self, active_limit=5, done_limit=5)
1846
+ pack["tasks"] = {
1847
+ "active": [{"id": t["id"], "name": t["name"], "status": t["status"],
1848
+ "plan": (t.get("plan") or "")[:300],
1849
+ "updated_at": t.get("updated_at")}
1850
+ for t in ts["active"]],
1851
+ "done": [{"id": t["id"], "name": t["name"], "status": t["status"],
1852
+ "result": (t.get("result") or "")[:300],
1853
+ "updated_at": t.get("updated_at")}
1854
+ for t in ts["done"]],
1855
+ "active_total": ts["active_total"], "done_total": ts["done_total"]}
1856
+ except Exception: # noqa: BLE001
1857
+ pack["degraded"].append("tasks")
1504
1858
  # ③ 近期事件(原始滚动窗口)
1505
1859
  try:
1506
1860
  evs = self.recent_events(limit=max(1, int(recent_limit or 10)))
@@ -1528,18 +1882,29 @@ class MdCGOS(MdCG):
1528
1882
  pack["self_state"] = _ss.summary(self, session=session)
1529
1883
  except Exception: # noqa: BLE001
1530
1884
  pack["degraded"].append("self_state")
1531
- # ⑥ 预算裁剪:交替丢 recent / notes 尾部,超出预算则显式上报
1885
+ # ⑥ 预算裁剪:交替丢 recent / notes 尾部;任务段**最后才让位**
1886
+ # (任务台账是结构性结论,事件流水是易失过程——先丢过程),
1887
+ # 被裁的事实在 tasks_truncated 里显式上报,不静默丢。
1888
+ tasks_trimmed = False
1532
1889
  pack["tokens"] = est_tokens(json.dumps(pack, ensure_ascii=False))
1533
- while pack["tokens"] > budget and (pack["recent"] or pack["notes"]):
1534
- if len(pack["recent"]) >= len(pack["notes"]):
1890
+ while pack["tokens"] > budget and (pack["recent"] or pack["notes"]
1891
+ or pack["tasks"]["active"]
1892
+ or pack["tasks"]["done"]):
1893
+ if pack["recent"] and len(pack["recent"]) >= len(pack["notes"]):
1535
1894
  pack["recent"].pop()
1536
- else:
1895
+ elif pack["notes"]:
1537
1896
  pack["notes"].pop()
1897
+ elif pack["tasks"]["done"] or pack["tasks"]["active"]:
1898
+ (pack["tasks"]["done"] or pack["tasks"]["active"]).pop()
1899
+ tasks_trimmed = True
1900
+ else:
1901
+ pack["recent"].pop()
1538
1902
  pack["tokens"] = est_tokens(json.dumps(pack, ensure_ascii=False))
1539
1903
  pack["budget_tokens"] = budget
1540
1904
  pack["truncated"] = pack["tokens"] > budget
1541
- pack["note"] = ("只读上下文包:会话要点 + 目标 + 近期事件 + 未解问题"
1542
- "(+自我状态卡)。库侧替代 hook 自动注入;"
1905
+ pack["tasks_truncated"] = tasks_trimmed
1906
+ pack["note"] = ("只读上下文包:会话要点 + 目标 + 任务台账(进行中/近期完成)"
1907
+ "+ 近期事件 + 未解问题(+自我状态卡)。库侧替代 hook 自动注入;"
1543
1908
  "会话开始时显式调用本 op 一次即可续接。")
1544
1909
  return pack
1545
1910
 
@@ -1859,7 +2224,9 @@ class MdCGOS(MdCG):
1859
2224
  # 三者都不进默认召回热路径,只在显式调用时工作。
1860
2225
 
1861
2226
  INSIGHT_ACTIONS = ("window", "record", "verify", "list", "report",
1862
- "reconstruct", "learn", "outlook", "catalog")
2227
+ "reconstruct", "learn", "outlook", "catalog",
2228
+ "fork", "branch_rewrite", "branch_search",
2229
+ "branch_merge", "branch_discard", "branches")
1863
2230
 
1864
2231
  def insight(self, action="outlook", **kw):
1865
2232
  """洞察条件层 + 情景重构 + 盲区学习 + 结构洞察(P2)。
@@ -1909,6 +2276,34 @@ class MdCGOS(MdCG):
1909
2276
  max_nodes=(kw.get("max_nodes") or subgraph.RECON_MAX_NODES),
1910
2277
  neighbors=(True if kw.get("neighbors") is None
1911
2278
  else bool(kw.get("neighbors"))))
2279
+ if act in ("fork", "branch_rewrite", "branch_search", "branch_merge",
2280
+ "branch_discard", "branches"):
2281
+ # 记忆演化分支(Pi 移植④):fork / 分支改写(唯一正路,归属强制
2282
+ # 继承)/ 分支内检索 / 溯源合并 / 教训归档冷收 / 盘点。
2283
+ # discard 属写操作(MCP 分发层 require_admin)。
2284
+ from . import branches as _br
2285
+ if act == "fork":
2286
+ return _br.fork(self, kw.get("node_ids") or kw.get("ids"),
2287
+ branch_id=kw.get("branch_id"),
2288
+ note=kw.get("note"))
2289
+ if act == "branch_rewrite":
2290
+ return _br.rewrite(self,
2291
+ kw.get("node_id") or kw.get("pid") or "",
2292
+ kw.get("content") or "",
2293
+ tags=kw.get("tags"),
2294
+ importance=kw.get("importance"))
2295
+ if act == "branch_search":
2296
+ return _br.search(self,
2297
+ kw.get("content") or kw.get("query") or "",
2298
+ kw.get("branch_id") or "")
2299
+ if act == "branch_merge":
2300
+ return _br.merge(self, kw.get("branch_id"),
2301
+ reason=kw.get("reason"))
2302
+ if act == "branch_discard":
2303
+ return _br.discard(self, kw.get("branch_id"),
2304
+ summary=kw.get("content")
2305
+ or kw.get("summary") or "")
2306
+ return _br.list_branches(self)
1912
2307
  if act == "explore":
1913
2308
  # 信息差驱动自主探索(opt-in):提案 → 五态验证 → 回写 gap_hint
1914
2309
  from . import autonomy
@@ -1947,14 +2342,81 @@ class MdCGOS(MdCG):
1947
2342
 
1948
2343
  # ================= 健康度(并入 OS 指标) =================
1949
2344
 
2345
+ AUDIT_COUNT_MAX_BYTES = 64 << 20 # 超过此规模不再全量精确计数(O(1) 体检纪律)
2346
+ # ---- 审计日志分片轮转(治本面,2026-09-16)----
2347
+ # 元数据读数(_log_scale)只让「指标与代价错配」不再显形,**有界性的来源是轮转**:
2348
+ # 单文件 ≤ AUDIT_ROTATE_BYTES、分片数 ≤ AUDIT_KEEP_SHARDS ⇒ 单片读取代价与总量
2349
+ # 都有上界,体检/getsize 不再随运行时长线性劣化。
2350
+ AUDIT_ROTATE_BYTES = 64 << 20 # 活动日志轮转阈值(≤0 关闭轮转,退回无上界)
2351
+ AUDIT_KEEP_SHARDS = 8 # 归档分片保留数(≤0 不淘汰;淘汰必留审计痕)
2352
+ AUDIT_ARCHIVE = "_audit_archive" # 分片归档目录(不在 LAYERS 内,不参与节点索引)
2353
+ AUDIT_INDEX = "_index.json" # 归档索引:分片 bytes/events 缓存(稳态 O(1))
2354
+ AUDIT_PROBE_EVERY = 32 # 每 N 次写入探测一次大小(把写入税摊到 1/N)
2355
+
2356
+ def _log_scale(self, path, est_sample=256 << 10):
2357
+ """日志量级读数(O(1)):以元数据为主,条数只在与规模相称时才精确。
2358
+
2359
+ 为什么不去全量数条数(2026-09-16 使用者裁定「python 审查 4GB 数据是个
2360
+ 不明智的选择」+ 第 4 条现场取证):
2361
+
2362
+ 信息需求 = 量级(这条日志多大、多久没动)→ O(1) 元数据即可满足;
2363
+ 实际代价 = 全量解析/扫描 → O(n) 磁盘 IO(本机 4.0 GB 实测数十秒);
2364
+ 且日志**无上界增长**(实测 ~2 条/s 持续写入、累计 ~21.5 M 条 / 4.0 GB)。
2365
+ 指标与代价错配,而「无上界」使任何「每次体检全量扫」的实现随运行时长
2366
+ 线性劣化——4 GB 只是让错配显形,不是错配本身。
2367
+
2368
+ 故:有界规模内给精确行数(count_jsonl 流式、内存恒定);超阈值只读元数据
2369
+ + 尾部采样估算,**显式标注 exact=False 不假装精确**。要精确值走离线
2370
+ count_jsonl 或审计日志分片轮转(属方向性决策,待定夺)。
2371
+ """
2372
+ try:
2373
+ st = os.stat(path)
2374
+ except OSError:
2375
+ return {"bytes": 0, "mtime": None, "events": 0, "exact": True}
2376
+ size = st.st_size
2377
+ out = {"bytes": size, "mtime": st.st_mtime}
2378
+ if size <= self.AUDIT_COUNT_MAX_BYTES:
2379
+ out.update({"events": count_jsonl(path), "exact": True})
2380
+ return out
2381
+ est = None
2382
+ try:
2383
+ with open(path, "rb") as f:
2384
+ f.seek(max(0, size - est_sample))
2385
+ tail = f.read()
2386
+ lines = [ln for ln in tail.split(b"\n") if ln.strip()]
2387
+ if lines:
2388
+ est = int(size / (len(tail) / float(len(lines))))
2389
+ except OSError:
2390
+ pass
2391
+ out.update({
2392
+ "events": est, "exact": False,
2393
+ "note": ("超过 %.0f MB 不给全量精确计数(O(n) 磁盘 IO 与量级体检不匹配);"
2394
+ "events 为尾部 %d KB 采样的估算值"
2395
+ % (self.AUDIT_COUNT_MAX_BYTES / 1048576.0, est_sample >> 10))})
2396
+ return out
2397
+
1950
2398
  def health_os(self):
1951
2399
  h = self.health()
2400
+ audit = self.audit_scale()
1952
2401
  h["os"] = {
1953
2402
  "roles": self._role_counts(),
1954
2403
  "review_pending": len(self.review_list()),
1955
2404
  "review_records": len(self.review_records()),
1956
- "tombstones": len(list(read_jsonl(self.deletions_log))),
1957
- "audit_events": len(list(read_jsonl(self.audit_log))),
2405
+ # 审计/墓碑面走 _log_scale(O(1) 量级读数)而非 list(read_jsonl(...))
2406
+ # 也不再全量流式数行:物化读让只读体检把整条通道拖死(实测 4.0 GB
2407
+ # 日志 → RSS 5 GB+、数十分钟不返回),而全量流式扫仍要 O(n) 磁盘 IO
2408
+ # 且日志无上界增长(~2 条/s)→ 随运行时长线性劣化。见 _log_scale。
2409
+ "tombstones": self._log_scale(self.deletions_log)["events"],
2410
+ "audit_events": audit["events"], # 超阈值时为估算值
2411
+ "audit_events_exact": audit["exact"], # False = 上面是估算,非精确
2412
+ "audit_bytes": audit["bytes"], # 量级看这个(O(1) 精确)
2413
+ "audit_events_note": audit.get("note"),
2414
+ # 轮转面(有界性证据):活动文件 ≤rotate_bytes、分片 ≤keep_shards
2415
+ "audit_shards": audit["shards"], # 归档分片数
2416
+ "audit_total_bytes": audit["total_bytes"], # 活动 + 全部分片
2417
+ "audit_total_events": audit["total_events"], # 全量条数(分片走索引缓存)
2418
+ "audit_total_exact": audit["total_exact"], # False = 含超大分片的估算
2419
+ "audit_oversized": audit["oversized"], # 超大历史分片数(待离线切分)
1958
2420
  "reflections": len(self.last_d_records()),
1959
2421
  # 七件套覆盖度(第 5 篇):目标槽 + 近期事件窗口
1960
2422
  "goals": {"total": len(self.list_goals()),
@@ -2632,9 +3094,9 @@ class MdCGSecure(MdCGOS):
2632
3094
  return super().clear_recent()
2633
3095
 
2634
3096
  def _candidates(self, layer=None, roles=None, include_work=False,
2635
- session=None):
3097
+ session=None, branch=None):
2636
3098
  out = super()._candidates(layer=layer, roles=roles, include_work=include_work,
2637
- session=session)
3099
+ session=session, branch=branch)
2638
3100
  return [e for e in out if self._readable(e)]
2639
3101
 
2640
3102
  def _neg_coverage(self, terms):