@furongjun1999/dsh-memory 0.5.0 → 0.6.0

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 (156) hide show
  1. package/README.md +572 -465
  2. package/docs/README.md +1 -0
  3. 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
  4. 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
  5. package/docs/eval/DSH/347/253/257/347/274/272/351/231/267/344/270/223/351/241/271_v1.0.md +254 -0
  6. 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
  7. package/docs/eval/bench_lingshu_self/bench_self.py +140 -0
  8. package/docs/eval/bench_lingshu_self/self_bench_result.json +404 -0
  9. package/docs/eval/bench_lingshu_self//347/201/265/346/236/242/350/207/252/345/272/223/347/253/257/345/210/260/347/253/257/346/243/200/347/264/242/345/256/236/346/265/213_v1.0.md +38 -0
  10. package/docs/eval//344/270/215/345/217/257/351/235/240/346/200/247/350/220/275/345/234/260_P0_v1.0.md +185 -0
  11. 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
  12. package/docs/eval//346/225/205/351/232/234/346/263/250/345/205/245/345/256/236/346/265/213_v1.0.md +422 -0
  13. package/docs/eval//347/253/257/345/210/260/347/253/257LoCoMoQA/345/220/214/345/217/243/345/276/204/345/257/271/347/205/247_v1.0.md +100 -0
  14. package/docs/eval//347/253/257/345/210/260/347/253/257/345/271/262/346/211/260/346/261/240/350/257/204/346/265/213_/347/241/256/345/256/232/346/200/247/350/243/201/345/206/263vsLLM_judge_v1.1.md +197 -0
  15. 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_v1.md +156 -0
  16. 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_v10.md +210 -0
  17. 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_v11.md +227 -0
  18. 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_v12.md +203 -0
  19. 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_v13.md +233 -0
  20. 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_v14.md +191 -0
  21. 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_v15.md +213 -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_v16.md +214 -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_v17.md +199 -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_v18.md +183 -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_v19.md +207 -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_v2.md +156 -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_v3.md +152 -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_v4.md +128 -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_v5.md +114 -0
  30. 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_v6.md +192 -0
  31. 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_v7.md +187 -0
  32. 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_v8.md +207 -0
  33. 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_v9.md +203 -0
  34. package/docs/hive//345/256/211/345/205/250/345/256/241/350/256/241/345/256/236/351/224/232_v0.1.md +202 -0
  35. package/docs/hive//350/234/202/345/267/242/345/217/214/345/256/236/344/276/213/344/272/222/351/252/214_/350/256/276/350/256/241/345/256/232/347/250/277.md +19 -3
  36. package/docs/images/lingshu-moonlight-covenant-poster-preview.jpg +0 -0
  37. package/docs/images/lingshu-moonlight-covenant-poster.png +0 -0
  38. 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
  39. package/docs/theory//344/270/215/345/217/257/351/235/240/345/256/232/347/220/206/344/270/216/345/244/261/346/225/210/344/274/230/345/205/210/346/241/206/346/236/266_v0.3.md +365 -0
  40. package/docs//344/270/215/345/217/257/351/235/240/346/200/247/347/220/206/350/256/272_v0.1.md +343 -0
  41. package/dsh/cordis-patch-profile-web.example.yml +35 -0
  42. package/lib/bridge.d.ts +9 -0
  43. package/lib/bridge.js +35 -0
  44. package/lib/cli.d.ts +3 -0
  45. package/lib/cli.js +66 -0
  46. package/lib/index.js +7 -1
  47. package/lib/init.d.ts +88 -0
  48. package/lib/init.js +287 -0
  49. package/lib/lib/roleplay_web.js +530 -443
  50. package/lib/lib/token_store.d.ts +7 -1
  51. package/lib/lib/token_store.js +12 -3
  52. package/md_cg/audit.py +12 -1
  53. package/md_cg/backfill.py +16 -15
  54. package/md_cg/backfill_bucket_zh.py +35 -0
  55. package/md_cg/bench_e2e_judge.py +532 -0
  56. package/md_cg/bench_e2e_locomo_qa.py +368 -0
  57. package/md_cg/bench_e2e_qa.py +256 -0
  58. package/md_cg/branches.py +18 -2
  59. package/md_cg/ccgc.py +3 -2
  60. package/md_cg/chain.py +19 -4
  61. package/md_cg/consolidate.py +7 -6
  62. package/md_cg/crosscheck.py +4 -3
  63. package/md_cg/crypto.py +439 -437
  64. package/md_cg/datapath.py +395 -335
  65. package/md_cg/evidence.py +8 -3
  66. package/md_cg/export.py +3 -1
  67. package/md_cg/forgetting.py +2 -2
  68. package/md_cg/fsutil.py +48 -0
  69. package/md_cg/hotcache.py +255 -238
  70. package/md_cg/interop.py +161 -22
  71. package/md_cg/judgment_manifest.py +177 -0
  72. package/md_cg/links.py +655 -622
  73. package/md_cg/mcp_server.py +282 -31
  74. package/md_cg/mdcg.py +702 -126
  75. package/md_cg/mdcos.py +454 -68
  76. package/md_cg/mreview/govern.py +5 -4
  77. package/md_cg/postings.py +4 -2
  78. package/md_cg/readcache.py +76 -18
  79. package/md_cg/reconcile.py +228 -0
  80. package/md_cg/review_cli.py +215 -0
  81. package/md_cg/routing.py +28 -0
  82. package/md_cg/run_tests.py +211 -0
  83. package/md_cg/scrub.py +862 -852
  84. package/md_cg/security.py +385 -275
  85. package/md_cg/selfreport.py +3 -2
  86. package/md_cg/signer.py +565 -562
  87. package/md_cg/sources.py +3 -2
  88. package/md_cg/stg.py +6 -0
  89. package/md_cg/sustain.py +1168 -1138
  90. package/md_cg/test_access_hints.py +147 -0
  91. package/md_cg/test_branch_discard_tombstone.py +136 -0
  92. package/md_cg/test_branches.py +259 -249
  93. package/md_cg/test_chain_read_isolate.py +168 -0
  94. package/md_cg/test_datapath_device_name.py +203 -0
  95. package/md_cg/test_emit_negtail_cache.py +156 -0
  96. package/md_cg/test_en_pipeline.py +186 -166
  97. package/md_cg/test_govern_directread.py +421 -0
  98. package/md_cg/test_i32_hotcache_env_key.py +218 -0
  99. package/md_cg/test_identity_attribution.py +228 -147
  100. package/md_cg/test_index_crossprocess_reload.py +301 -0
  101. package/md_cg/test_index_durability.py +238 -224
  102. package/md_cg/test_interop.py +4 -2
  103. package/md_cg/test_interop_judgment.py +228 -0
  104. package/md_cg/test_issue39_utf8_stdio.py +273 -0
  105. package/md_cg/test_links_concurrent_write.py +188 -0
  106. package/md_cg/test_merge_upsert.py +168 -0
  107. package/md_cg/test_n123_derive_expiry_chain.py +205 -0
  108. package/md_cg/test_n130_verify_falsified_protect.py +185 -0
  109. package/md_cg/test_n131_merge_gate.py +205 -0
  110. package/md_cg/test_none_id_write_guard.py +165 -0
  111. package/md_cg/test_p1x_ref_root.py +160 -0
  112. package/md_cg/test_p27_docindex.py +774 -765
  113. package/md_cg/test_p2_mcp.py +3 -0
  114. package/md_cg/test_p32_backfill.py +304 -298
  115. package/md_cg/test_p39_verify_flow.py +90 -50
  116. package/md_cg/test_p47_session_view.py +60 -25
  117. package/md_cg/test_propose_tail_index.py +157 -0
  118. package/md_cg/test_read_scope_b27.py +277 -0
  119. package/md_cg/test_readcache_default_on.py +168 -0
  120. package/md_cg/test_readcache_precise_inval.py +270 -0
  121. package/md_cg/test_readcache_prodpath.py +55 -7
  122. package/md_cg/test_reconcile_v0.py +294 -0
  123. package/md_cg/test_retr_s1.py +6 -2
  124. package/md_cg/test_retr_s1b.py +67 -0
  125. package/md_cg/test_retr_s7.py +8 -0
  126. package/md_cg/test_retr_s9_entity_ctx.py +181 -175
  127. package/md_cg/test_retr_score_once.py +208 -0
  128. package/md_cg/test_review_cli_attribution.py +177 -0
  129. package/md_cg/test_review_cli_visibility.py +235 -0
  130. package/md_cg/test_review_onepass.py +170 -0
  131. package/md_cg/test_rrf_graph_seed_cache.py +154 -0
  132. package/md_cg/test_security_audit.py +155 -0
  133. package/md_cg/test_security_audit_b26.py +161 -0
  134. package/md_cg/test_security_audit_v21.py +250 -0
  135. package/md_cg/test_semantic_canonical.py +255 -241
  136. package/md_cg/test_session_isolation.py +168 -0
  137. package/md_cg/test_snapshot_autoclose.py +187 -0
  138. package/md_cg/test_tail_watermark_race.py +208 -0
  139. package/md_cg/test_tenant_env_override_warn.py +139 -0
  140. package/md_cg/test_tenant_registry_corrupt_warn.py +151 -0
  141. package/md_cg/test_v14_fixes.py +415 -397
  142. package/md_cg/test_verify_dirty_reconcile.py +157 -0
  143. package/md_cg/theory.py +276 -273
  144. package/md_cg/tokens.py +734 -677
  145. package/md_cg/units.py +3 -2
  146. package/md_cg/vision_evidence.py +4 -3
  147. package/md_cg/whitebox_kb/wisdom/code_solidified.json +6195 -6195
  148. package/md_cg/whitebox_kb/wisdom/multilang_ir.py +128 -124
  149. package/md_cg/writepipe.py +554 -550
  150. package/package.json +11 -2
  151. package/src/bridge.ts +434 -401
  152. package/src/cli.ts +65 -0
  153. package/src/index.ts +526 -518
  154. package/src/init.ts +347 -0
  155. package/src/lib/roleplay_web.ts +1019 -932
  156. package/src/lib/token_store.ts +202 -192
@@ -51,6 +51,7 @@ from ..backfill import _as_cg, _entry_id, _readable_guard, _sha
51
51
  from ..fsutil import append_jsonl, read_jsonl
52
52
  from ..mdcos import ALL_ROLES, WORK_ROLES
53
53
  from .ruleset import _as_rules, _blank
54
+ from ..readcache import direct_read
54
55
 
55
56
  #: 留痕文件名(与 `backfill._backfill.jsonl` 分立:治理动作须能独立审计)
56
57
  GOVERN_LOG = "_govern.jsonl"
@@ -198,12 +199,12 @@ def _candidate_role(e, fm, *, sources, role_map) -> tuple:
198
199
  else REASON_NO_SOURCE)
199
200
 
200
201
 
201
- # 生效条件:按序判定——_readable_guard(cg, e) 为假返回 ("denied", None);cg._read(e) 的 fm 为 None 返回 ("unreadable", None);crypto.is_encrypted(content) 为真返回 ("locked", None);fm.get("role") 非空白返回 ("present", None);否则 _candidate_role(e, fm, sources=sources, role_map=role_map) 的 role 为假返回 ("unfillable", {id,layer,reason,writer}),role 为真返回 ("", {id,layer,role,source,basis,before,had_key,writer})。
202
+ # 生效条件:按序判定——_readable_guard(cg, e) 为假返回 ("denied", None);direct_read(cg, e) 的 fm 为 None 返回 ("unreadable", None);crypto.is_encrypted(content) 为真返回 ("locked", None);fm.get("role") 非空白返回 ("present", None);否则 _candidate_role(e, fm, sources=sources, role_map=role_map) 的 role 为假返回 ("unfillable", {id,layer,reason,writer}),role 为真返回 ("", {id,layer,role,source,basis,before,had_key,writer})。
202
203
  def _classify(cg, e, nid, *, sources, role_map) -> tuple:
203
204
  """单条裁决 → `(skip_reason, item|gap)`;skip_reason 为空串表示可回填。"""
204
205
  if not _readable_guard(cg, e):
205
206
  return "denied", None
206
- fm, content = cg._read(e)
207
+ fm, content = direct_read(cg, e)
207
208
  if fm is None:
208
209
  return "unreadable", None
209
210
  if crypto.is_encrypted(content):
@@ -344,7 +345,7 @@ def role_apply(x, ids=None, entry_ids=None, layer=None, limit=None,
344
345
  if not _readable_guard(cg, e):
345
346
  rep["skipped_denied"] += 1
346
347
  continue
347
- fm, content = cg._read(e)
348
+ fm, content = direct_read(cg, e)
348
349
  if fm is None:
349
350
  rep["skipped_missing"] += 1
350
351
  continue
@@ -407,7 +408,7 @@ def role_rollback(x, batch=None, entry_ids=None, actor=None) -> dict:
407
408
  if e is None:
408
409
  rep["missing"] += 1
409
410
  continue
410
- fm, content = cg._read(e)
411
+ fm, content = direct_read(cg, e)
411
412
  if fm is None or crypto.is_encrypted(content):
412
413
  rep["missing"] += 1
413
414
  continue
package/md_cg/postings.py CHANGED
@@ -25,6 +25,8 @@ import json
25
25
  import os
26
26
  import time
27
27
 
28
+ from .fsutil import publish
29
+
28
30
  SCHEMA = "mdcg-postings-2"
29
31
 
30
32
 
@@ -167,12 +169,12 @@ def load_meta(root: str) -> dict:
167
169
  return {}
168
170
 
169
171
 
170
- # 生效条件:无条件以 utf-8 原子写 path(tmp + os.replace),写入的字符串为 obj 的 JSON 序列化。
172
+ # 生效条件:无条件以 utf-8 原子写 path(tmp + publish,即带 Windows 短重试的 os.replace),写入的字符串为 obj 的 JSON 序列化。
171
173
  def _atomic_json(path: str, obj, indent=None) -> None:
172
174
  tmp = path + ".tmp"
173
175
  with io.open(tmp, "w", encoding="utf-8", newline=chr(10)) as f:
174
176
  json.dump(obj, f, ensure_ascii=False, indent=indent, separators=None if indent else (",", ":"))
175
- os.replace(tmp, path)
177
+ publish(tmp, path)
176
178
 
177
179
 
178
180
  # 生效条件:无条件读 root/_postings.json 并返回 dict(缺文件/解析失败返回 {};内容非 dict 亦返回 {})。
@@ -10,13 +10,18 @@
10
10
  耗时,~230µs/节点线性退化)——「理论没有落地」。本模块即落地。
11
11
 
12
12
  与 `install_read_cache`(eval_common.py,benchmark 专用)的差异:
13
- 1. **写失效哨兵**:缓存条目携带 `len(cg._dirty)` 代际——所有写路径必然
14
- 标脏(flush 依赖 `_dirty`,add/_sync_edge_entry/负记忆化……无一例外),
15
- dirty 代际变化即视为「有写入发生」,该 path 重读一次。对 path-only
16
- 无失效的朴素实现能红(外部报告实测的陈旧读场景)。
13
+ 1. **写失效哨兵(脏集精确失效,缺陷迭代第 14 轮)**:缓存条目携带
14
+ 缓存时的 `write_gen`,新鲜度按 `_DirtyDict.path_gen[path]`(该 path
15
+ 最近标脏代际)与 `broad_gen`(tombstone/无 path 可辨变更的整池兜底)
16
+ 判定——**写谁失效谁**,单节点写不再使全部条目同时 miss(此前整池
17
+ 共用一个全局代际,10k 池写读交替 38.4× 退化、线性于 N)。所有写路径
18
+ 必然标脏(flush 依赖 `_dirty`,add/_sync_edge_entry/负记忆化……无一
19
+ 例外),标脏即 `_dirty[nid]=entry`(带 path)——簿记在 _DirtyDict
20
+ 钩子内自动完成,写面零挂载点,天然覆盖未来新增写路径(不会有
21
+ 「漏挂失效」型陈旧读)。对 path-only 无失效的朴素实现能红(外部报告
22
+ 实测的陈旧读场景)。
17
23
  语义与 MdStore 的「写方落盘后 reload」同构,但**懒清**:检索高频写少,
18
- 写后首次检索全量重装一次即再稳定,不必写时同步清(写面零挂载点,
19
- 天然覆盖未来新增写路径——不会有「漏挂失效」型陈旧读)。
24
+ 写后首次检索只重装被写节点,不必写时同步清。
20
25
  2. 缓存**解析产物** `(fm, content)`(benchmark 版只缓存原文,parse 仍逐次),
21
26
  **并缓存文档侧检索派生物** `_doc_norm_bigrams`(归一化 bigram——批次 21
22
27
  实测本机热点 88% 在此而非 I/O:内容不变则派生物不变,随读缓存常驻;
@@ -24,28 +29,81 @@
24
29
  3. 进程内一致性边界(与 MdStore 相同的诚实边界):跨进程/外部直接改写
25
30
  md 文件不保证可见——认知图的多进程形态(每智能体一进程)各持快照。
26
31
 
27
- 开关:`MDCG_READ_CACHE=1` 显式启用(默认关,零变更纪律,与 #28 hotcache
28
- 同款装配);不设或非 "1" 时 `install` 不被调用,行为与改动前逐位一致。
32
+ 开关:默认**开**(issue #31 后续——生产检索入口 MdCGOS.search 默认态每查询
33
+ 全池 open+realpath+parse,3300 池实测中位 ~602ms/查询、O(n) 线性;读缓存
34
+ 早已实现却默认关,生产恒不装配,故默认翻转);`MDCG_READ_CACHE=0` 显式
35
+ 关闭(opt-out 零变更退出阀——跨进程改写 md 文件须本进程立即可见的部署,
36
+ 或内存受限场景,可一关回到逐次读盘的旧行为)。
29
37
  """
30
38
  import os
31
39
 
32
- # 生效条件:无 required 形参,锚定环境变量名 MDCG_READ_CACHE;当 os.environ.get("MDCG_READ_CACHE") == "1" 时返回 True,否则返回 False。
40
+ # 生效条件:无 required 形参,锚定环境变量名 MDCG_READ_CACHE;未设或值为 "1" 时返回 True(默认开,issue #31 生产 O(n) 全池扫描修复),显式设为其它值(含 "0")时返回 False。
33
41
  def enabled() -> bool:
34
- """读缓存开关(默认关;=1 显式启用——零变更纪律)。"""
35
- return os.environ.get("MDCG_READ_CACHE") == "1"
42
+ """读缓存开关(默认开;=0 显式关闭——opt-out 退出阀)。"""
43
+ return os.environ.get("MDCG_READ_CACHE", "1") == "1"
36
44
 
37
45
 
38
- # 生效条件:cg._read 可调用时以 (path → (dirty 代际, 解析产物)) 常驻字典包装之——命中条件为 path 存在且代际等于当前 len(cg._dirty);cg._doc_norm_bigrams 亦存在时同款包装其派生物(_score 热点:文档侧归一化 bigram 只依赖 content,随读缓存一并常驻);包装后 cg._read/_doc_norm_bigrams 为包装函数、cg._read_cache/_norm_bigrams_cache 为缓存字典;返回缓存字典。
46
+ # 生效条件:cg._read_uncached 属性存在时返回其以 entry 调用的结果(穿透读缓存的盘上真值直读),否则返回 cg._read(entry)(未装缓存时两者等价,零变更)。
47
+ def direct_read(cg, entry):
48
+ """治理/写前重查专用直读——穿透读缓存,读盘上当前真值。
49
+
50
+ 读缓存默认开(issue #31 后续)后 `cg._read` 是进程内快照;治理面的
51
+ 「写前重查」「回滚比对」语义上要求盘上真值——预演与执行之间节点
52
+ 可能被他人/外部进程改写(test_mr_m4 D12「被人改动→不覆盖」形态:
53
+ path-only 命中会让重查读到预演时装入的旧值,漏判 drift)。
54
+ """
55
+ fn = getattr(cg, "_read_uncached", None)
56
+ if fn is None:
57
+ return cg._read(entry)
58
+ return fn(entry)
59
+
60
+
61
+ # 生效条件:cg._read 可调用时以 (path → (缓存时 write_gen, 解析产物)) 常驻字典包装之——_dirty 带 path_gen 簿记(_DirtyDict 形态)时命中条件为「该 path 最近标脏代际(path_gen)与 broad_gen 均 ≤ 缓存时 write_gen」(脏集精确失效:单节点写只失效该节点),否则回落整代际相等校验(旧口径,非 _DirtyDict 防御);cg._doc_norm_bigrams 亦存在时同款包装其派生物(_score 热点:文档侧归一化 bigram 只依赖 content,随读缓存一并常驻);包装后 cg._read/_doc_norm_bigrams 为包装函数、cg._read_cache/_norm_bigrams_cache 为缓存字典、cg._read_uncached 为未被包装的原始 _read(direct_read 的真源);返回缓存字典。
39
62
  def install(cg):
40
- """把 `cg._read`(与派生物钩子)包成脏代际校验的常驻缓存。"""
63
+ """把 `cg._read`(与派生物钩子)包成**脏集精确失效**的常驻缓存。
64
+
65
+ 失效口径(缺陷迭代第 14 轮,high):此前哨兵取全局单调 write_gen——
66
+ 整池共用一个代际值,任意一次单节点写使**全部**条目同时 miss,下一条
67
+ 查询全池重装(10k 池写读交替 3rep 中位 1677.6ms vs 稳态 43.7ms,
68
+ 38.4× 退化、线性于 N;生产暴露面:mcp_server 读 op=read 与写 op=write
69
+ 同一常驻实例)。`_DirtyDict` 现簿记 `path_gen`(path → 最近标脏时的
70
+ write_gen)与 `broad_gen`(无 path 可辨变更的整池兜底代际),本包装
71
+ 据此按 path 判新鲜:**写谁失效谁**,其余条目原对象复用。写路径不必
72
+ 再改一处(标脏即 `_dirty[nid]=entry` 带 path,簿记在 _DirtyDict 钩子
73
+ 内自动完成)。
74
+ """
41
75
  cache = {}
42
- orig = cg._read
76
+ # 重复 install(如显式再调)不叠加包装层,_read_uncached 恒指真原始。
77
+ orig = getattr(cg, "_read_uncached", None) or cg._read
78
+ cg._read_uncached = orig
79
+
80
+ def _fresh(p, hit):
81
+ """hit=(缓存时 write_gen, val) 对 path p 是否仍新鲜(不陈旧)。"""
82
+ dirty = cg._dirty
83
+ pg = getattr(dirty, "path_gen", None)
84
+ if pg is None:
85
+ # D-4 修复(批次 23 / v20):代际改读 _DirtyDict.write_gen(单调、
86
+ # 永不回退)——len(_dirty) 在 flush 清零后可被新写入凑回旧值,代际
87
+ # 巧合回退 → 陈旧读(v20_d4_repro stale=True 实测)。防御回落兼容
88
+ # 非 _DirtyDict 形态(整代际相等,旧口径)。
89
+ return hit[0] == getattr(dirty, "write_gen", len(dirty))
90
+ # 脏集精确失效(第 14 轮):该 path 最近标脏代际 ≤ 缓存代际即新鲜
91
+ # ——单节点写只失效该节点;broad_gen(tombstone/无 path 可辨变更)
92
+ # 高于缓存代际时整池保守失效(与旧整代际口径等价的安全兜底)。
93
+ # flush 的 clear() 不推进 broad_gen:flush 只落索引派生物,节点
94
+ # 文件变更已在各写路径 _dirty[nid]=entry(带 path)时精确失效,
95
+ # 且生产写路径每次写后 flush(writepipe._commit_visibility)——
96
+ # 整池失效会让精确失效在生产恒不生效;rebuild_index 收尾走
97
+ # clear(broad=True) 推进 broad_gen(盘面重扫口径:直写文件 +
98
+ # rebuild 收尾的写方靠它对读缓存可见)。
99
+ return (pg.get(p, 0) <= hit[0]
100
+ and dirty.broad_gen <= hit[0])
43
101
 
44
102
  def _cached(entry):
45
103
  p = entry["path"]
46
- gen = len(cg._dirty)
104
+ gen = getattr(cg._dirty, "write_gen", len(cg._dirty))
47
105
  hit = cache.get(p)
48
- if hit is not None and hit[0] == gen:
106
+ if hit is not None and _fresh(p, hit):
49
107
  return hit[1]
50
108
  val = orig(entry)
51
109
  cache[p] = (gen, val)
@@ -60,9 +118,9 @@ def install(cg):
60
118
 
61
119
  def _cached_nb(entry, c):
62
120
  p = entry["path"]
63
- gen = len(cg._dirty)
121
+ gen = getattr(cg._dirty, "write_gen", len(cg._dirty))
64
122
  hit = nb_cache.get(p)
65
- if hit is not None and hit[0] == gen:
123
+ if hit is not None and _fresh(p, hit):
66
124
  return hit[1]
67
125
  val = orig_nb(entry, c)
68
126
  nb_cache[p] = (gen, val)
@@ -0,0 +1,228 @@
1
+ # -*- coding: utf-8 -*-
2
+ """启动对账 reconcile v0(v0.3 T9 自稳定定理落地,落地清单 P0-3)。
3
+
4
+ **定位(与 twophase 的分工)**:`twophase.reconcile` 是**写账本清账**——只回答
5
+ 「有 intent 无 outcome 的那笔写入到底落盘没有」;本模块是它之上的**全量 diff
6
+ 对账**:索引(派生态)vs 盘面真源(节点 .md)逐条比对,把「索引与盘面背离」
7
+ 收敛回自稳定合法态(T9:任意瞬态扰动后仅凭真源与协议在有限步内回到合法态)。
8
+
9
+ **三类 diff(v0 判据面)**:
10
+ 1. **多索引条目**(幽灵):索引有条目、盘面无其文件(或该文件已归属他 id)
11
+ → 摘除条目(tombstone,与 `_unstage` 同口径);
12
+ 2. **缺索引条目**:盘面有节点文件、索引无条目(非合作写者/日志丢失)
13
+ → 以 `_node_entry` 重建条目补入(与 rebuild_index 逐字段同源);
14
+ 3. **内容漂移**:条目 path 与盘面落点不符、或 content_hash 与盘面重算不符
15
+ → 以盘面为真源重建条目(**增量 upsert**,不触发全量 rebuild)。
16
+
17
+ **T9 边界(结构合法 ≠ 内容正确)**:真源自身损坏——读失败(OSError,含
18
+ Windows 上 Defender/索引器持句柄的瞬态形态)、解码失败(非 UTF-8 字节)、
19
+ frontmatter 结构非法(`---` 无闭合)——**一律不自动改写**:既不据损坏文件
20
+ 建条目,也不因「读不出」摘除既有条目(瞬态读失败若被当「文件没了」就会把
21
+ 一次性抖动放大成索引面永久损伤,与 N134 同型——宁可漏修,不可误伤),只
22
+ stderr 告警 + `_reconcile.jsonl` 留痕,是否处置交人工/上层裁决。
23
+
24
+ **耗时可控**:清单 diff 是 O(n) 索引操作(os.walk 计数级);哈希核验对每个
25
+ 盘面 .md 恰一次顺序读 + 轻量 frontmatter 切分(`nodefile.loads` 是纯字符串
26
+ 切分,无 YAML/全文解析、无检索打分/嵌入)——不是 `rebuild_index` 式全量重扫,
27
+ 修复只对漂移条目做定向 upsert。整体 O(n);`MDCG_RECONCILE=0` 一键关闭
28
+ (缺省开)。**告警口径**:diff 非空才写 stderr 与留痕,干净库零输出零写盘。
29
+
30
+ **诚实边界(v0 不覆盖面)**:
31
+ · frontmatter **字段值**漂移(如盘上 importance 被直改、索引仍旧值)不在
32
+ v0 判据面——索引没有 fm 指纹可比(content_hash 刻意不含 frontmatter,见
33
+ nodefile.content_hash 文档串),该面仍由 rebuild_index 全量兜底(v1 候选);
34
+ · 对账是**增益**不是服务前提:本模块任何异常都不得阻塞启动(调用方
35
+ mcp_server.main 以 try/except 包裹,与两段式对账同风格)。
36
+ """
37
+ import os
38
+ import time
39
+
40
+ from . import nodefile
41
+ from .fsutil import append_jsonl
42
+
43
+ #: 关闭开关:仅当 `MDCG_RECONCILE` 的 strip 后取值**恰好为 "0"** 时关闭
44
+ #: (缺省开;其它取值一律视为开——开关只回答「关没关」,不承担配置语义)。
45
+ ENV_SWITCH = "MDCG_RECONCILE"
46
+
47
+ #: 留痕文件(root 下,append-only,与 `_write_2pc.jsonl` / `_protected_audit.jsonl`
48
+ #: 同一「独立审计文件」惯例;**仅在 diff 非空时写入**,干净库零写盘)。
49
+ TRACE_FILE = "_reconcile.jsonl"
50
+
51
+ #: 留痕/告警里每类异常的采样上限(防大库首轮修复把日志/告警撑成洪水)。
52
+ SAMPLE_CAP = 20
53
+
54
+
55
+ # 生效条件:读 MDCG_RECONCILE 环境变量(未设按空串),strip 后等于 "0" 返回 False,其余(含未设)返回 True。
56
+ def enabled() -> bool:
57
+ """对账开关(缺省开;`MDCG_RECONCILE=0` 关闭)。"""
58
+ return os.environ.get(ENV_SWITCH, "").strip() != "0"
59
+
60
+
61
+ # 生效条件:给定 cg 且其 root 属性可参与 os.path.join 时,恒返回 os.path.join(cg.root, TRACE_FILE),无分支与早退。
62
+ def trace_path(cg) -> str:
63
+ return os.path.join(cg.root, TRACE_FILE)
64
+
65
+
66
+ # 生效条件:text 为假值(None/"")时按空串处理,返回去尾换行后的字符串(仅剥 "\n")。
67
+ def _norm(text: str) -> str:
68
+ return (text or "").rstrip("\n")
69
+
70
+
71
+ # 生效条件:content(nodefile.loads 切出的盘面正文,含尾换行)非 None 时返回 (content_hash(content), content_hash(_norm(content))) 二元组——**双形态接受集**:add 写路径入索引的指纹按封存原文(mdcg.py `_stage` 面,正文不带尾换行时等于 norm 形态),rebuild 重建路径按 loads 读回原文(带尾换行)——两 producers 各自稳定,diff 两侧都认,否则无尾换行节点会在每次启动被误报漂移(假阳性洪水,twophase._norm 文档串同款教训)。
72
+ def _disk_hashes(content: str) -> tuple:
73
+ raw = nodefile.content_hash(content or "")
74
+ return raw, nodefile.content_hash(_norm(content))
75
+
76
+
77
+ # 生效条件:遍历 mdcg.LAYERS 各层目录树(os.walk,与 _scan_nodes 同序同口径),逐个 .md 读取并 nodefile.loads 切分;成功解析的以 nid(fm.get("id") 或文件名去 .md)登入 by_nid(同 nid 后到覆盖,与 _scan_nodes dict 覆盖语义一致)并登记 path_owner[relpath]=nid;读失败(OSError)/解码失败(UnicodeDecodeError)/frontmatter 无闭合(fm 为空且原文以 "---\n" 开头)的登入 problems(kind=read_error/decode_error/frontmatter_unterminated,附 relpath),不进 by_nid 与 path_owner;返回 (by_nid, path_owner, problems)。
78
+ def _scan_disk(cg):
79
+ by_nid = {}
80
+ path_owner = {}
81
+ problems = []
82
+ for layer in _layers():
83
+ base = os.path.join(cg.root, layer)
84
+ for dirpath, _dirs, files in os.walk(base):
85
+ for fn in files:
86
+ if not fn.endswith(".md"):
87
+ continue
88
+ p = os.path.join(dirpath, fn)
89
+ rel = os.path.relpath(p, cg.root).replace("\\", "/")
90
+ try:
91
+ with open(p, encoding="utf-8") as f:
92
+ text = f.read()
93
+ except OSError as exc: # 瞬态/持久读失败:不猜测
94
+ problems.append({"path": rel, "kind": "read_error",
95
+ "detail": str(exc)[:120]})
96
+ continue
97
+ except UnicodeDecodeError as exc: # 非 UTF-8 字节:真源损坏
98
+ problems.append({"path": rel, "kind": "decode_error",
99
+ "detail": str(exc)[:120]})
100
+ continue
101
+ fm, content = nodefile.loads(text)
102
+ if not fm and text.startswith("---\n"):
103
+ # 结构非法:有 frontmatter 起始符但无闭合——loads 无法切出
104
+ # 字段(正常节点必有 frontmatter;无 frontmatter 的裸文件
105
+ # 按 _scan_nodes 旧例仍以文件名登入,不判损坏)。
106
+ problems.append({"path": rel,
107
+ "kind": "frontmatter_unterminated",
108
+ "detail": "--- 无闭合"})
109
+ continue
110
+ nid = fm.get("id") or fn[:-3]
111
+ by_nid[nid] = {"path": rel, "abs": p, "layer": layer,
112
+ "fm": fm, "content": content}
113
+ path_owner[rel] = nid
114
+ return by_nid, path_owner, problems
115
+
116
+
117
+ # 生效条件:延迟导入 mdcg 取模块级 LAYERS 并返回(与 _scan_nodes 的遍历集合同一真源;函数内导入避免 mdcg ↔ reconcile 模块环)。
118
+ def _layers():
119
+ from .mdcg import LAYERS
120
+ return LAYERS
121
+
122
+
123
+ # 生效条件:把单条异常以 JSONL 追加进 trace_path(cg)(best-effort:OSError 吞掉不抛——留痕是增益,不得反过来炸启动序列),无返回值。
124
+ def _trace(cg, record: dict) -> None:
125
+ try:
126
+ append_jsonl(trace_path(cg), record)
127
+ except OSError:
128
+ pass
129
+
130
+
131
+ # 生效条件:向 stderr 写一行告警(try/except 全包——stderr 为 None/closed/断管时静默放弃,N136 教训:告警通道失效不得伪装成新异常炸穿调用方),无返回值。
132
+ def _warn(msg: str) -> None:
133
+ try:
134
+ import sys
135
+ sys.stderr.write("[mdcg-reconcile] %s\n" % msg)
136
+ except Exception: # noqa: BLE001 —— 告警失效不追责
137
+ pass
138
+
139
+
140
+ # 生效条件:cg.index 须含 "nodes" 映射;enabled() 为 False 时直接返回 {"skipped": True, "applied": False} 不触盘;否则 _scan_disk 后做三类 diff:①nid 不在盘面且其 entry.path 既非问题文件也非无主——判幽灵(apply 时逐条 _unstage);②盘面 nid 不在索引——判缺失(apply 时以 cg._node_entry 构条目 _stage);③两上面向交的 nid 中 entry.path 与盘面落点不符或 content_hash 不在盘面双形态接受集——判漂移(apply 时以盘面重建条目 _stage);apply 为真时收尾 cg.flush() 持久化;diff 非空时写 stderr 告警并 _trace 留痕(各类采样 ≤SAMPLE_CAP);返回含 skipped/applied/scanned/entries/ghost_entries/missing_entries/hash_drift/unwritable_source/repaired 的报告 dict,applied=bool(apply)。
141
+ def reconcile_state(cg, apply: bool = True) -> dict:
142
+ """索引(派生态)vs 盘面真源的三类 diff 对账(reconcile v0 主入口)。
143
+
144
+ `apply=False` 为 dry-run(只 diff 报告,不修复、不写盘)。返回报告:
145
+ `{"skipped","applied","scanned","entries","ghost_entries",
146
+ "missing_entries","hash_drift","unwritable_source","repaired"}`。
147
+ 幂等:修复后的索引面与盘面一致,二次扫描 diff 为空、零输出零写盘。
148
+ """
149
+ if not enabled():
150
+ return {"skipped": True, "applied": False}
151
+ index_nodes = cg.index.get("nodes") or {}
152
+ by_nid, path_owner, problems = _scan_disk(cg)
153
+
154
+ problem_paths = {p["path"] for p in problems}
155
+ ghost, missing, drift = [], [], []
156
+ kept_unreadable = [] # 条目在、真源暂不可读——保留只告警
157
+
158
+ # ①③ 以索引为基准走一遍:多索引条目 + 内容漂移
159
+ for nid, entry in index_nodes.items():
160
+ if nid not in by_nid:
161
+ ep = entry.get("path") or ""
162
+ if ep in problem_paths:
163
+ kept_unreadable.append(nid) # 真源读不出 ≠ 文件没了:不摘条目
164
+ else:
165
+ ghost.append(nid) # 盘面无其文件(或已归属他 id)
166
+ continue
167
+ d = by_nid[nid]
168
+ h_raw, h_norm = _disk_hashes(d["content"])
169
+ if entry.get("path") != d["path"] \
170
+ or entry.get("content_hash") not in (h_raw, h_norm):
171
+ drift.append(nid)
172
+
173
+ # ② 以盘面为基准走一遍:缺索引条目(仅成功解析的真源参与)
174
+ for nid in by_nid:
175
+ if nid not in index_nodes:
176
+ missing.append(nid)
177
+
178
+ repaired = {"unstaged": 0, "staged": 0}
179
+ if apply:
180
+ for nid in ghost:
181
+ cg._unstage(nid) # tombstone + 立即持久化
182
+ repaired["unstaged"] += 1
183
+ for nid in missing + drift:
184
+ d = by_nid[nid]
185
+ cg._stage(nid, cg._node_entry(d["abs"], d["layer"],
186
+ d["fm"], d["content"]))
187
+ repaired["staged"] += 1
188
+ if repaired["unstaged"] or repaired["staged"]:
189
+ cg.flush() # 启动期一次收尾落账(不赖 autoflush)
190
+
191
+ rep = {
192
+ "skipped": False,
193
+ "applied": bool(apply),
194
+ "scanned": len(by_nid) + len(problems),
195
+ "entries": len(index_nodes),
196
+ "ghost_entries": ghost,
197
+ "missing_entries": missing,
198
+ "hash_drift": drift,
199
+ "unwritable_source": problems,
200
+ "repaired": repaired,
201
+ }
202
+ if kept_unreadable:
203
+ rep["kept_unreadable"] = kept_unreadable
204
+
205
+ # 告警 + 留痕(diff 非空才发声;干净库零输出零写盘)
206
+ if ghost or missing or drift or problems or kept_unreadable:
207
+ _warn("启动对账 v0: 幽灵条目=%s 缺失条目=%s 哈希漂移=%s "
208
+ "真源损坏/不可读=%s(%s)"
209
+ % (len(ghost), len(missing), len(drift), len(problems),
210
+ "已按盘面增量修复索引面" if apply else "dry-run 未修复"))
211
+ for nid in ghost[:SAMPLE_CAP]:
212
+ _warn(" 幽灵条目(盘面无文件→摘除): %s" % (nid,))
213
+ for nid in kept_unreadable[:SAMPLE_CAP]:
214
+ _warn(" 条目保留(真源暂不可读,不猜测不摘除): %s" % (nid,))
215
+ for pr in problems[:SAMPLE_CAP]:
216
+ _warn(" 真源损坏/不可读(只告警不改写——T9 边界): %s [%s]"
217
+ % (pr["path"], pr["kind"]))
218
+ _trace(cg, {
219
+ "t": time.time(), "op": "reconcile_v0", "apply": bool(apply),
220
+ "scanned": rep["scanned"], "entries": rep["entries"],
221
+ "ghost": ghost[:SAMPLE_CAP], "ghost_n": len(ghost),
222
+ "missing": missing[:SAMPLE_CAP], "missing_n": len(missing),
223
+ "drift": drift[:SAMPLE_CAP], "drift_n": len(drift),
224
+ "problems": problems[:SAMPLE_CAP], "problems_n": len(problems),
225
+ "kept_unreadable": kept_unreadable[:SAMPLE_CAP],
226
+ "repaired": repaired,
227
+ })
228
+ return rep
@@ -0,0 +1,215 @@
1
+ # -*- coding: utf-8 -*-
2
+ """review_cli · 审核队列裁决命令行(设计者/管理权限专用)
3
+
4
+ **包内入口**(2026-09-24 修复):原实现只在 `scripts/review_cli.py`,而出货包
5
+ `files` 不含 `scripts/` —— 于是包内所有给用户/智能体的提示(writepipe 的
6
+ 「已入审核队列 → 请裁决」)都指向一个安装态不存在的文件。本模块把同一实现
7
+ 搬进包内,统一入口 `python -m md_cg.review_cli`;`scripts/review_cli.py`
8
+ 在源码树内仍可用(同一代码路径:那里 _HERE 同为插件根)。
9
+
10
+ 背景:本机未配置外部验证器(MDCG_VERIFIER_MODULES)时,写入恒判 DEFER
11
+ 入审核队列——这是诚实行为(不假装通过)。裁决权专属 can_admin 角色
12
+ (写入者不得自裁自决),agent 端被 AccessDenied 拒绝是设计行为;
13
+ 由设计者在本机直接运行本命令完成裁决。
14
+
15
+ 用法(root 须与待裁决部署一致:--root 或环境变量 MDCG_ROOT):
16
+ python -m md_cg.review_cli list
17
+ python -m md_cg.review_cli accept <pid> --session <会话id> --reason "实跑测试证据"
18
+ python -m md_cg.review_cli reject <pid> --reason "内容有误"
19
+ python -m md_cg.review_cli edit <pid> --content "修正后内容" --reason "..."
20
+ python -m md_cg.review_cli merge <pid> --into <已有节点id> --reason "..."
21
+ python -m md_cg.review_cli noop <pid> --reason "已评估,判定无需改动"
22
+ python -m md_cg.review_cli rounds <pid> # 某提案裁决轮次历史
23
+ python -m md_cg.review_cli stats # 裁决动作统计(含 noop)
24
+
25
+ noop 语义:**已评估、判定不改变任何现有记忆**——只留痕(decisions.jsonl +
26
+ 审计 md 节点)并关闭提案,不落业务节点、不进负记忆。它与 reject 的区别是
27
+ 「评估过了、无需改动」而非「否掉这条候选」,故不可借 noop 绕过 accept 门控。
28
+
29
+ 裁决留痕:decisions.jsonl + 审计 md 节点(由 review_decide 内部完成)。
30
+
31
+ 落盘归因(P1 修复,2026-09-26,DSH 端在役实测回告):
32
+ · 会话:--session(各子命令通用)缺省取环境变量 MDCG_SESSION,仍无则维持
33
+ 现状随机会话并 stderr 告警一行。同一批裁决传同一 session,落盘节点的
34
+ frontmatter.session 才稳定一致(此前每次随机 sess_<uuid12>,同批 5 节点
35
+ 5 个会话 id)。
36
+ · 写入者:accept/edit 落盘节点的 frontmatter.writer 保留**提案原始写入者**
37
+ (propose 时库端快照的 actor,如 dsh-memory);裁决者身份不丢失,记入
38
+ frontmatter.reviewer(designer-cli)。
39
+ · DSH 前置条件(环境侧自行核对,本 CLI 不校验):MCP 写入面对 DSH 形态会话
40
+ id(session-<8>-<4>-<4>-<4>-<12>)有 `_normalize_session` 防编造校验
41
+ (md_cg/mcp_server.py)——会话目录须真存在于 MDCG_DSH_SESSIONS_ROOT 或
42
+ ~/.dsh/sessions 之下,否则该会话在 MCP 面降级为 anonymous。本 CLI 直构
43
+ Principal 不经该校验,但若裁决的会话 id 与 MCP 面共用,请先确认目录在位,
44
+ 否则两侧归因对不上。
45
+ """
46
+ import argparse
47
+ import json
48
+ import os
49
+ import sys
50
+
51
+ try:
52
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
53
+ except AttributeError:
54
+ pass
55
+
56
+ _HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
57
+ if _HERE not in sys.path:
58
+ sys.path.insert(0, _HERE)
59
+
60
+ from md_cg.mdcos import MdCGSecure # noqa: E402
61
+ from md_cg.security import Principal # noqa: E402
62
+
63
+
64
+ # 生效条件:args 的 root 属性为真值(含 getattr 缺省 None 时回落)否则回落 os.environ.get("MDCG_ROOT", ""),两者皆空串或该值经 os.path.isdir 判定不是目录时 sys.exit 退出,否则返回该 root。
65
+ def _root(args):
66
+ root = getattr(args, "root", None) or os.environ.get("MDCG_ROOT", "")
67
+ if not root:
68
+ sys.exit("错误:未指定存储根(--root 或环境变量 MDCG_ROOT)。\n"
69
+ "root 必须与待裁决的部署一致——猜错会裁决到另一个空库。")
70
+ if not os.path.isdir(root):
71
+ sys.exit("错误:root 不存在:%s" % root)
72
+ return root
73
+
74
+
75
+ # 生效条件:args 的 session 属性为真值(含 getattr 缺省 None 回落)或环境变量 MDCG_SESSION 去空白后非空时返回该值(前者优先),两者皆无返回 None。
76
+ def _session_of(args):
77
+ """裁决会话归属:--session > 环境变量 MDCG_SESSION > None(随机 + 告警)。"""
78
+ s = str(getattr(args, "session", None)
79
+ or os.environ.get("MDCG_SESSION", "") or "").strip()
80
+ return s or None
81
+
82
+
83
+ # 生效条件:args 就绪时先经 _session_of 取裁决会话归属,取到 None 时向 stderr 告警一行(缺省随机会话、同批裁决请传同一 --session)后维持现状;随后构造写死权限的 Principal(actor="designer-cli", clearance="secret", can_write=True, can_admin=True, role="designer", auth_mode="local-cli", session=<上述归属>),再以 _root(args) 取到的存储根返回 MdCGSecure(root, principal=p, autoflush=1)。
84
+ def _cg(args):
85
+ session = _session_of(args)
86
+ if session is None:
87
+ # P1 归因(2026-09-26,DSH 端在役实测):缺省随机会话会让同一批裁决
88
+ # 得到互不相同的落盘归属(5 节点 5 个 sess_* 实录)。不拒绝、告警一行
89
+ # 后维持现状(向后兼容);要稳定归属请显式传同一 --session。
90
+ sys.stderr.write("警告:未指定 --session / 环境变量 MDCG_SESSION,"
91
+ "本次裁决落盘使用随机会话 id——同一批裁决请传同一 "
92
+ "--session 以稳定归属。\n")
93
+ p = Principal(actor="designer-cli", clearance="secret",
94
+ can_write=True, can_admin=True, role="designer",
95
+ auth_mode="local-cli", session=session)
96
+ # 索引可见性(P1b,2026-09-26):autoflush=1 —— 与 mcp_server.py:3695-3702
97
+ # 同款(server 级兜底 2026-09-16 已修),本处是同一兜底的**第二落点**,
98
+ # DSH 端在役实测 read null 后补齐。根因:缺省 autoflush=64 时写入先经
99
+ # _stage 入内存 _dirty,攒够阈值才 flush() 落分片日志 `_index_log/`;
100
+ # 本 CLI 正常退出有 main 的 finally cg.close() 与库层 atexit 双兜底
101
+ # (mdcg.py _LIVE_CGS),但兜底刻意只覆盖正常退出——被 kill / 崩溃时
102
+ # 两者都不执行,裁决节点停在内存索引、日志从未落,盘面文件在而索引
103
+ # 链路断,在役旧读面 `_load_index` 盲信既有快照即 read null。逐条落
104
+ # 日志后任意退出形态下写入对其它进程立即可见(review_decide 自身的
105
+ # 显式 flush 只覆盖其目标/审计节点,且与节点文件落盘之间仍有窗口)。
106
+ return MdCGSecure(_root(args), principal=p, autoflush=1)
107
+
108
+
109
+ # 生效条件:rec 支持 .get 时,取 rec.get("content") 为假值则取 rec.get("statement")、再为假值则取空串,把其中的换行替换为空格,并按 width(缺省 66)切片后仅当 len(text) > width 才追加 "…",返回该字符串。
110
+ def _brief(rec, width=66):
111
+ text = (rec.get("content") or rec.get("statement") or "").replace("\n", " ")
112
+ return text[:width] + ("…" if len(text) > width else "")
113
+
114
+
115
+ # 生效条件:cg 与 args 就绪时按 args.cmd 分派——"list" 时 cg.review_list() 为空则打印空队列并返回 0、非空则逐条打印(tags 取真值拼接、layer/round 为假值显示 "?"/0)后返回 0;"rounds" 时打印 cg.review_rounds(args.pid) 并返回 0;"stats" 时打印 cg.review_stats() 的记录数/提案数/待审数/已关闭数与动作分布(含 noop 计数)并返回 0;"edit" 时以 args.content 加真值 args.tags(按逗号分割并剔除空项)/args.layer 组成 edits 调 cg.review_decide;其余 cmd(含 noop)以 getattr(args, "into", None) 与 args.reason 调 cg.review_decide;后两类再按 out.get("ok") 为真返回 0,否则打印 out 并返回 1。
116
+ def _execute(cg, args):
117
+ """按子命令执行裁决(cg 的生命周期由 main 统一收尾)。"""
118
+ if args.cmd == "list":
119
+ pend = cg.review_list()
120
+ if not pend:
121
+ print("审核队列为空(0 条待审)。")
122
+ return 0
123
+ print("待审 %d 条:" % len(pend))
124
+ for r in pend:
125
+ tags = (", tags=" + ",".join(r.get("tags") or [])) if r.get("tags") else ""
126
+ print(" [%s] %s · %s 层%s · round=%s\n %s" % (
127
+ r.get("pid"), r.get("status"), r.get("layer") or "?",
128
+ tags, r.get("round") or 0, _brief(r)))
129
+ print('\n裁决示例:python -m md_cg.review_cli accept <pid> --reason "实跑测试证据"')
130
+ return 0
131
+
132
+ if args.cmd == "rounds":
133
+ print(json.dumps(cg.review_rounds(args.pid), ensure_ascii=False, indent=1))
134
+ return 0
135
+
136
+ if args.cmd == "stats":
137
+ st = cg.review_stats()
138
+ by = st.get("by_decision") or {}
139
+ print("裁决记录 %d 条 · 提案 %d 个 · 待审 %d 条 · 已关闭 %d 个" % (
140
+ st.get("records", 0), st.get("proposals", 0),
141
+ st.get("pending", 0), st.get("closed", 0)))
142
+ print(" 动作分布:%s" % ("、".join(
143
+ "%s=%d" % (k, by[k]) for k in sorted(by)) or "(无记录)"))
144
+ print(" 其中 noop(已评估、判定无需改动)= %d 条" % st.get("noop", 0))
145
+ return 0
146
+
147
+ if args.cmd == "edit":
148
+ edits = {"content": args.content}
149
+ if args.tags:
150
+ edits["tags"] = [t.strip() for t in args.tags.split(",") if t.strip()]
151
+ if args.layer:
152
+ edits["layer"] = args.layer
153
+ out = cg.review_decide(args.pid, "edit", edits=edits, reason=args.reason)
154
+ else:
155
+ out = cg.review_decide(args.pid, args.cmd,
156
+ merge_into=getattr(args, "into", None),
157
+ reason=args.reason)
158
+
159
+ if out.get("ok"):
160
+ print("已裁决:%s → %s%s" % (
161
+ args.pid, args.cmd,
162
+ (",落盘节点 " + out["node_id"]) if out.get("node_id") else ""))
163
+ return 0
164
+ print("裁决未生效:%s" % json.dumps(out, ensure_ascii=False))
165
+ return 1
166
+
167
+
168
+ # 生效条件:argv 为 None(默认)时由 argparse 解析 sys.argv、否则解析传入的 argv(子命令 dest="cmd" 为 required,已注册 list/accept/reject/edit/merge/rounds 并带 --root 等参数),解析成功后构造 cg=_cg(args) 并返回 _execute(cg, args),finally 中执行 cg.close()。
169
+ def main(argv=None):
170
+ ap = argparse.ArgumentParser(
171
+ prog="python -m md_cg.review_cli",
172
+ description="灵枢审核队列裁决(designer 权限)")
173
+ common = argparse.ArgumentParser(add_help=False)
174
+ common.add_argument("--root", help="存储根目录(默认环境变量 MDCG_ROOT)")
175
+ common.add_argument("--session", default=None,
176
+ help="裁决会话 id(落盘归属 frontmatter.session;缺省取"
177
+ "环境变量 MDCG_SESSION,仍无则随机会话并 stderr 告警。"
178
+ "同一批裁决传同一值即得同一归属)")
179
+ sub = ap.add_subparsers(dest="cmd", required=True)
180
+
181
+ sub.add_parser("list", help="列出待审条目", parents=[common])
182
+ sub.add_parser("stats", help="裁决动作统计(含 noop)", parents=[common])
183
+ for name, help_ in (("accept", "按原样写入落盘"),
184
+ ("reject", "丢弃(只记裁决)"),
185
+ ("noop", "已评估、判定不改变现有记忆(只留痕)")):
186
+ s = sub.add_parser(name, help=help_, parents=[common])
187
+ s.add_argument("pid")
188
+ s.add_argument("--reason", default="", help="裁决理由(进留痕)")
189
+ s = sub.add_parser("edit", help="修订后写入", parents=[common])
190
+ s.add_argument("pid")
191
+ s.add_argument("--content", required=True)
192
+ s.add_argument("--tags", default=None, help="逗号分隔")
193
+ s.add_argument("--layer", default=None)
194
+ s.add_argument("--reason", default="")
195
+ s = sub.add_parser("merge", help="合并进已有节点", parents=[common])
196
+ s.add_argument("pid")
197
+ s.add_argument("--into", required=True, help="目标节点 id")
198
+ s.add_argument("--reason", default="")
199
+ s = sub.add_parser("rounds", help="某提案的裁决轮次历史", parents=[common])
200
+ s.add_argument("pid")
201
+ args = ap.parse_args(argv)
202
+
203
+ cg = _cg(args)
204
+ try:
205
+ return _execute(cg, args)
206
+ finally:
207
+ # 收尾(2026-09-16 取证):裁决写入先进内存 _dirty,不落盘则「节点在盘上
208
+ # 但索引无条目」——已有 _index.json 的根重开不重扫目录,其它进程与重载后的
209
+ # 长驻进程都检索不到(只能靠某次全量 rebuild 偶然救回)。库层另有 atexit
210
+ # 兜底,但显式收尾才是正路:兜底只覆盖「正常退出」这一条路径。
211
+ cg.close()
212
+
213
+
214
+ if __name__ == "__main__":
215
+ sys.exit(main())