@furongjun1999/dsh-memory 0.6.0 → 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 (170) hide show
  1. package/README.md +588 -572
  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/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
  8. 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
  9. 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
  10. 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
  11. 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
  12. 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
  13. 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
  14. 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
  15. 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
  16. 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
  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_v20.md +283 -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_v21.md +224 -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_v22.md +223 -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_v23.md +293 -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_v24.md +303 -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_v25.md +230 -0
  23. 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
  24. 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
  25. package/docs/mdcg/README/350/257/246/347/273/206/347/211/210_v0.4.10.md +12 -0
  26. 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
  27. package/docs/mdcg//345/217/221/345/270/203/351/227/250/347/246/201/351/223/276_v0.1.md +21 -9
  28. 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
  29. 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
  30. 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
  31. 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
  32. 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
  33. 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
  34. package/lib/bridge.js +24 -2
  35. package/lib/hooks.d.ts +31 -2
  36. package/lib/hooks.js +219 -16
  37. package/lib/init.js +16 -1
  38. package/lib/lib/prompt_safety.d.ts +52 -1
  39. package/lib/lib/prompt_safety.js +76 -1
  40. package/md_cg/audit.py +652 -379
  41. package/md_cg/bench6_arms.py +1 -1
  42. package/md_cg/bench_p0.py +1 -1
  43. package/md_cg/branches.py +2 -2
  44. package/md_cg/ccgc.py +1071 -1006
  45. package/md_cg/chain.py +1 -1
  46. package/md_cg/consistency.py +97 -9
  47. package/md_cg/consolidate.py +34 -9
  48. package/md_cg/crypto.py +59 -26
  49. package/md_cg/docindex.py +16 -1
  50. package/md_cg/evolution.py +17 -1
  51. package/md_cg/export.py +1 -1
  52. package/md_cg/forgetting.py +309 -13
  53. package/md_cg/fsutil.py +454 -8
  54. package/md_cg/identity.py +3 -3
  55. package/md_cg/insight.py +24 -3
  56. package/md_cg/linkref.py +1 -1
  57. package/md_cg/logref.py +327 -0
  58. package/md_cg/mcp_server.py +4152 -3818
  59. package/md_cg/mdcg.py +4208 -3547
  60. package/md_cg/mdcos.py +4491 -4159
  61. package/md_cg/mreview/pipeline.py +15 -1
  62. package/md_cg/nodefile.py +639 -575
  63. package/md_cg/protect.py +158 -11
  64. package/md_cg/protocol.py +41 -2
  65. package/md_cg/provenance.py +22 -4
  66. package/md_cg/reach.py +4 -4
  67. package/md_cg/readcache.py +76 -15
  68. package/md_cg/reconcile.py +1 -1
  69. package/md_cg/refindex.py +246 -37
  70. package/md_cg/refine.py +1 -1
  71. package/md_cg/routing.py +32 -5
  72. package/md_cg/run_tests.py +17 -0
  73. package/md_cg/scrub.py +30 -3
  74. package/md_cg/security.py +47 -1
  75. package/md_cg/self_state.py +5 -5
  76. package/md_cg/sources.py +23 -6
  77. package/md_cg/srcindex.py +352 -0
  78. package/md_cg/stg.py +47 -3
  79. package/md_cg/subgraph.py +2 -2
  80. package/md_cg/sustain.py +1299 -1168
  81. package/md_cg/tasks.py +469 -470
  82. package/md_cg/test_b1_auto_id_multiproc.py +277 -0
  83. package/md_cg/test_b1b2_write_face.py +417 -0
  84. package/md_cg/test_b2_sensitivity_landing.py +223 -0
  85. package/md_cg/test_b3_merge_keeps_content.py +576 -0
  86. package/md_cg/test_b4_shard_dir_selfheal.py +843 -0
  87. package/md_cg/test_b4_shard_dir_selfheal_guard.py +238 -0
  88. package/md_cg/test_c3_transient_read_negative.py +793 -0
  89. package/md_cg/test_c8_search_rrf_gates.py +502 -0
  90. package/md_cg/test_ccg_form_parity.py +188 -0
  91. package/md_cg/test_ccgc.py +28 -3
  92. package/md_cg/test_govern_directread.py +17 -4
  93. package/md_cg/test_h2_session_view_norm.py +233 -0
  94. package/md_cg/test_h4_sustain_snapshot.py +1377 -0
  95. package/md_cg/test_hive_ingest.py +285 -285
  96. package/md_cg/test_issue39_utf8_stdio.py +307 -16
  97. package/md_cg/test_issue43_default_policy.py +865 -0
  98. package/md_cg/test_legacy_p3_node_id_type.py +375 -0
  99. package/md_cg/test_linkref.py +10 -3
  100. package/md_cg/test_lock.py +2 -2
  101. package/md_cg/test_logref.py +1109 -0
  102. package/md_cg/test_m3_h9_bucket_health_protect_mark.py +301 -0
  103. package/md_cg/test_m3_h9_semantic_guard.py +525 -0
  104. package/md_cg/test_mr_m2.py +15 -3
  105. package/md_cg/test_n130_verify_falsified_protect.py +1 -1
  106. package/md_cg/test_n139_dek_provision_failclosed.py +185 -0
  107. package/md_cg/test_n176_link_trust.py +221 -0
  108. package/md_cg/test_n178_units_jobid_gate.py +212 -0
  109. package/md_cg/test_n184_keys_concurrent_provision.py +307 -0
  110. package/md_cg/test_n195_writepath_reload.py +283 -0
  111. package/md_cg/test_n196_stale_gate_skip.py +169 -0
  112. package/md_cg/test_n197_n208_write_face_gates.py +471 -0
  113. package/md_cg/test_n198_tokens_corrupt_failclosed.py +225 -0
  114. package/md_cg/test_n199_tokens_concurrent_write.py +378 -0
  115. package/md_cg/test_n201_proposal_visibility.py +382 -0
  116. package/md_cg/test_n202_session_notes_visibility.py +461 -0
  117. package/md_cg/test_n204_n205_n226_n227_n228_n229_exit_gates.py +542 -0
  118. package/md_cg/test_n206_stdio_jsonrpc_type.py +403 -0
  119. package/md_cg/test_n209_verify_write_face_gates.py +461 -0
  120. package/md_cg/test_n212_n213_n224_generation_gates.py +513 -0
  121. package/md_cg/test_n214_n215_n221_n222_write_face_gates.py +563 -0
  122. package/md_cg/test_n225_nonobject_load.py +1110 -0
  123. package/md_cg/test_n62_tenant_bind_failclosed.py +200 -0
  124. package/md_cg/test_neg_condition_hits.py +333 -0
  125. package/md_cg/test_neg_tail_honesty.py +663 -0
  126. package/md_cg/test_none_id_write_guard.py +4 -3
  127. package/md_cg/test_opt_batch1_md_cg.py +451 -0
  128. package/md_cg/test_p1.py +5 -5
  129. package/md_cg/test_p11_consistency.py +161 -12
  130. package/md_cg/test_p26_refindex.py +2 -2
  131. package/md_cg/test_p27_docindex.py +777 -774
  132. package/md_cg/test_p28_refcheck.py +740 -13
  133. package/md_cg/test_p29_session_ingest_export.py +2 -2
  134. package/md_cg/test_p2_mcp.py +12 -1
  135. package/md_cg/test_p30_maintain.py +33 -2
  136. package/md_cg/test_p31_insight.py +1 -0
  137. package/md_cg/test_p9c_dedup_hints.py +276 -0
  138. package/md_cg/test_policy_required_ccg.py +426 -0
  139. package/md_cg/test_protocol.py +22 -0
  140. package/md_cg/test_rank_parity_score_mode.py +516 -0
  141. package/md_cg/test_read_face_input_gates.py +363 -0
  142. package/md_cg/test_read_face_semantics.py +489 -0
  143. package/md_cg/test_recall_face_guards.py +812 -0
  144. package/md_cg/test_rejected_credential_forms.py +188 -0
  145. package/md_cg/test_rejected_redact.py +146 -0
  146. package/md_cg/test_retr_s1b.py +2 -2
  147. package/md_cg/test_retr_s5.py +20 -7
  148. package/md_cg/test_review_conformance.py +21 -3
  149. package/md_cg/test_security_audit_v21.py +2 -2
  150. package/md_cg/test_server_version.py +67 -0
  151. package/md_cg/test_srcindex.py +171 -0
  152. package/md_cg/test_tenant_env_override_warn.py +63 -50
  153. package/md_cg/test_token_lowercase_form.py +315 -0
  154. package/md_cg/test_v21r1_package_version.py +310 -0
  155. package/md_cg/test_writepipe.py +10 -4
  156. package/md_cg/tokens.py +225 -95
  157. package/md_cg/tool_face.py +4 -4
  158. package/md_cg/trust.py +49 -5
  159. package/md_cg/units.py +204 -6
  160. package/md_cg/weights.py +4 -4
  161. package/md_cg/whitebox_kb/wisdom/knowledge_points.py +1 -1
  162. package/md_cg/writelimit.py +42 -8
  163. package/md_cg/writepipe.py +651 -554
  164. package/package.json +3 -2
  165. package/skills/plugin.json +1 -1
  166. package/src/bridge.ts +25 -2
  167. package/src/hooks.ts +229 -16
  168. package/src/init.ts +15 -1
  169. package/src/lib/prompt_safety.ts +88 -1
  170. package/zcode/AGENTS.md +10 -10
package/md_cg/scrub.py CHANGED
@@ -715,8 +715,8 @@ def decontaminate(cg, node_ids=None, *, kinds=None, dry_run: bool = True,
715
715
  # ④ 校准偏差
716
716
  # --------------------------------------------------------------------------
717
717
 
718
- # 生效条件:对 cg 中每个「layer 不在 SELF_LAYERS」且 evidence_count≥int(min_evidence)(min_evidence=0 时该比较恒假而不早退)的节点,若 protect.is_protected 为假或 override 为真,且 cg.get(nid) 未抛异常并返回真值节点,则取 fm.get("confidence", 0.6)(缺键才回落 0.6,键存在为 None/假值不回落)为 old,算出 round(max(0.0, min(0.99, old+float(offset))),4),与 old 差<1e-9 时跳过,否则写 fm["confidence"] 与 fm["calibration"] 并调用 cg._write_node 成功时 adjusted+1(写回异常被吞掉不计数),返回 (adjusted, skipped),其中 skipped 只累计「layer 属 SELF_LAYERS」或被 protect 拦下且非 override 的节点。
719
- def _apply_offset(cg, offset, *, override=False, min_evidence=1):
718
+ # 生效条件:对 cg 中每个「layer 不在 SELF_LAYERS」且 evidence_count≥int(min_evidence)(min_evidence=0 时该比较恒假而不早退)的节点,若 protect.is_protected 为假或 override 为真,且 cg.get(nid) 未抛异常并返回真值节点,则先以节点真层/敏感度过 protect.guard_overwrite(层闸 + 保护闸,override 为真时先快照 + 审计,越权抛 AccessDenied 且整批中止),再取 fm.get("confidence", 0.6)(缺键才回落 0.6,键存在为 None/假值不回落)为 old,算出 round(max(0.0, min(0.99, old+float(offset))),4),与 old 差<1e-9 时跳过,否则写 fm["confidence"] 与 fm["calibration"] 并调用 cg._write_node,成功后置 cg._dirty[nid]=e(**标脏**:推进读缓存代际与索引增量日志)并 adjusted+1(写回异常被吞掉不计数),返回 (adjusted, skipped),其中 skipped 只累计「layer 属 SELF_LAYERS」或被 protect 拦下且非 override 的节点。
719
+ def _apply_offset(cg, offset, *, override=False, min_evidence=1, actor=None):
720
720
  nodes = _nodes(cg)
721
721
  adjusted = skipped = 0
722
722
  for nid, e in nodes.items():
@@ -736,6 +736,20 @@ def _apply_offset(cg, offset, *, override=False, min_evidence=1):
736
736
  if not node:
737
737
  continue
738
738
  fm = node.get("frontmatter") or {}
739
+ # N222(2026-09-28,同族未接线写点):本函数是 calibrate 落库动作,此前
740
+ # 直调 `cg._write_node` 改写**全库** `layer∉(self,anchor)` 且
741
+ # `evidence_count≥1` 节点的 confidence/calibration——不过 principal 层写
742
+ # 白名单、不过引擎级保护闸、`override` 形参直通(含 importance≥0.7 的
743
+ # 不可遗忘节点),无快照无审计。持 sustain 令牌(`tokens.py:190`
744
+ # `layers_allow=('self',)`)即可改 knowledge 层节点置信度(实测
745
+ # `n_adjusted=5`,0.8→0.95;`override=True` 再 +1)。落盘前统一过
746
+ # `protect.guard_overwrite`:层/敏感度取**节点 fm 真值**(不信索引缓存),
747
+ # 解析与层闸由 `protect.require_layer` 单点承担,越权即 AccessDenied
748
+ # 整批中止(fail-closed,不静默跳过——否则越权面被伪装成「无可校准节点」);
749
+ # `override=True` 走同一闸的显式放行分支(快照 + `_protected_audit.jsonl`)。
750
+ protect.guard_overwrite(cg, nid, layer=fm.get("layer"),
751
+ sensitivity=fm.get("sensitivity"),
752
+ override=override, actor=actor)
739
753
  old = float(fm.get("confidence", 0.6))
740
754
  new = round(max(0.0, min(0.99, old + float(offset))), 4)
741
755
  if abs(new - old) < 1e-9:
@@ -746,6 +760,18 @@ def _apply_offset(cg, offset, *, override=False, min_evidence=1):
746
760
  try:
747
761
  cg._write_node(nid, os.path.join(cg.root, e["path"]), fm,
748
762
  node.get("content") or "")
763
+ # 标脏(N133 修复,对照先例 md_cg/mdcg.py 的 update_tags / verify
764
+ # 直写分支 `self._dirty[node_id] = e`):本函数原先写盘后**完全不
765
+ # 碰索引**(条目字段集不含 confidence,无需同步),但正因如此
766
+ # path_gen 不推进 ⇒ 读缓存(默认开)把写盘前的旧 fm 判新鲜——
767
+ # 同进程「calibrate 后读」拿旧 confidence(其余两处同族写点
768
+ # forgetting.reinforce / insight.verify 同批补标脏)。标脏同时
769
+ # 让本写进 `_dirty → flush → _index_log` 重放。**不下沉进
770
+ # `_write_node`**:该口另有「只对账索引不落盘」的调用方,下沉会
771
+ # 凭空产生写入代际与自重载。
772
+ _dirty = getattr(cg, "_dirty", None)
773
+ if isinstance(_dirty, dict):
774
+ _dirty[nid] = e
749
775
  adjusted += 1
750
776
  except Exception: # noqa: BLE001
751
777
  pass
@@ -777,7 +803,8 @@ def calibrate(cg, *, apply: bool = False, override: bool = False, actor=None,
777
803
  adjusted = skipped = 0
778
804
  if apply and abs(offset) > 1e-9:
779
805
  adjusted, skipped = _apply_offset(cg, offset, override=override,
780
- min_evidence=min_evidence)
806
+ min_evidence=min_evidence,
807
+ actor=actor)
781
808
  _log(cg, "calibrate", ok=True, offset=offset, gap=gap,
782
809
  adjusted=adjusted, actor=actor)
783
810
  blind = 0
package/md_cg/security.py CHANGED
@@ -382,4 +382,50 @@ def can_read_restricted(p) -> bool:
382
382
  return False
383
383
  if getattr(p, "can_admin", False):
384
384
  return True
385
- return getattr(p, "role", "") in _RESTRICTED_READER_ROLES
385
+ return getattr(p, "role", "") in _RESTRICTED_READER_ROLES
386
+
387
+
388
+ # --------------------------------------------------------------------------
389
+ # 读可见性可选钩子(跨层唯一实现,批次 68)
390
+ # --------------------------------------------------------------------------
391
+ # 为什么放在本模块:可见性单点 `_readable` 定义在**子类** `MdCGSecure`
392
+ # (mdcos.py:4028),而需要它的读数出口散在**下层/旁路模块**——trust(验证态
393
+ # 台账/describe)、provenance(派生边端点摘要与拓扑)、evolution(演化账本
394
+ # 自由文本)、forgetting(遗忘留痕 jsonl)。这些模块若 `from .mdcos import` 会
395
+ # 成环(mdcos 反向导入它们),故单点放在两侧都依赖的 security.py:
396
+ # · `visible_to(cg, entry)` —— 条目级判定(口径 = `_readable`);
397
+ # · `node_visible(cg, node_id)` —— 节点 id 级判定(索引取条目 + 缺席处置)。
398
+ # 口径与仓内既有三处可选钩子逐条一致:`mdcos._note_visible`(及本函数的
399
+ # 委派)、`backfill._readable_guard`、`linkref._known_ids`、`stg._scan/_preview`
400
+ # ——「无钩子(纯 MdCGOS,无身份/密级模型)= 不限制;有钩子 = 按判据;
401
+ # 判据异常 = 不可见(fail-closed);宁可少读,不可 fail-open 泄漏」。
402
+ # 生效条件:cg 有可调用的 _readable 时返回 bool(判定结果),判定抛异常返回 False;无该属性/不可调用(纯 MdCGOS)时返回 True。
403
+ def visible_to(cg, entry) -> bool:
404
+ """条目级读可见性(cg 无 `_readable` 钩子时不设限)。"""
405
+ fn = getattr(cg, "_readable", None)
406
+ if not callable(fn):
407
+ return True
408
+ try:
409
+ return bool(fn(entry))
410
+ except Exception: # noqa: BLE001 —— 判据异常=不可见
411
+ return False
412
+
413
+
414
+ # 生效条件:cg 无可调用 _readable 时返回 True;有钩子且索引条目为 dict 时返回 bool(visible_to(cg, 条目))(判定异常 False);索引无该条目/条目非 dict 时返回 bool(principal.can_admin)(有身份模型但不可证可见→fail-closed,设计者不受限,否则删除后的治理留痕反被清空)。
415
+ def node_visible(cg, node_id) -> bool:
416
+ """节点 id 级读可见性(索引取条目;**缺席 = 不可证可见 → fail-closed**)。
417
+
418
+ 缺席的两种实情:① 节点从未存在/拼错 id;② 节点已被 forget(索引无条目)。
419
+ 有身份模型时两者都不得被「当作可见」——否则 `_forgetting.jsonl` /
420
+ `trust` 台账 / 派生边摘要就成了「已删内容」的旁路读出(N205/N226/N229)。
421
+ 例外只给 can_admin(设计者=全局观测与治理本职):否则删除后的裁决留痕
422
+ 对唯一需要它的人也不可见(与 `_readable` 的 can_admin 豁免同款)。
423
+ """
424
+ fn = getattr(cg, "_readable", None)
425
+ if not callable(fn):
426
+ return True # 无身份模型:无「越权」可言
427
+ e = ((getattr(cg, "index", None) or {}).get("nodes") or {}).get(node_id)
428
+ if isinstance(e, dict):
429
+ return visible_to(cg, e)
430
+ p = getattr(cg, "principal", None)
431
+ return bool(p is not None and getattr(p, "can_admin", False))
@@ -355,7 +355,7 @@ def _relation_counts(cg, subject):
355
355
  slug = _slug(subject)
356
356
  nodes = ((getattr(cg, "index", None) or {}).get("nodes") or {})
357
357
  out = inn = 0
358
- for nid, e in nodes.items():
358
+ for nid, e in list(nodes.items()):
359
359
  tags = set(e.get("tags") or [])
360
360
  if TAG_RELATION not in tags and not nid.startswith(RELATION_PREFIX):
361
361
  continue
@@ -656,7 +656,7 @@ def relations(cg, subject=None, direction="both"):
656
656
  nodes = ((getattr(cg, "index", None) or {}).get("nodes") or {})
657
657
  slug = _slug(subject) if subject else None
658
658
  out = []
659
- for nid, e in nodes.items():
659
+ for nid, e in list(nodes.items()):
660
660
  tags = set(e.get("tags") or [])
661
661
  if TAG_RELATION not in tags and not nid.startswith(RELATION_PREFIX):
662
662
  continue
@@ -692,7 +692,7 @@ def index(cg, dim, value, limit=50, with_content=False):
692
692
  "allowed": list(DIMENSIONS)}
693
693
  tag = dim_tag(d, value)
694
694
  nodes = ((getattr(cg, "index", None) or {}).get("nodes") or {})
695
- hits = [(nid, e) for nid, e in nodes.items()
695
+ hits = [(nid, e) for nid, e in list(nodes.items())
696
696
  if tag in (e.get("tags") or [])]
697
697
  hits.sort(key=lambda kv: (-float(kv[1].get("importance") or 0),
698
698
  -float(kv[1].get("created_at") or 0),
@@ -740,7 +740,7 @@ def audit(cg, subject=DEFAULT_SUBJECT, window=RECENT_WINDOW):
740
740
 
741
741
  # 1 单例:同 subject 的状态卡只能有一张
742
742
  slug = _slug(subject)
743
- cards = [n for n, e in nodes.items()
743
+ cards = [n for n, e in list(nodes.items())
744
744
  if TAG_STATE in (e.get("tags") or [])
745
745
  and f"state_subject:{slug}" in (e.get("tags") or [])]
746
746
  if len(cards) > 1:
@@ -878,7 +878,7 @@ def audit(cg, subject=DEFAULT_SUBJECT, window=RECENT_WINDOW):
878
878
  continue
879
879
  for v in (vals or []):
880
880
  tag = dim_tag(d, v)
881
- holders = [n for n, e in nodes.items()
881
+ holders = [n for n, e in list(nodes.items())
882
882
  if tag in (e.get("tags") or [])]
883
883
  if not holders:
884
884
  issues.append(_issue("dimension_orphan", "warn",
package/md_cg/sources.py CHANGED
@@ -273,8 +273,17 @@ class HiveJobsSource(Source):
273
273
  result.json 终态 → **权威确认事件**(progress 可能因强杀缺失 final):
274
274
  done → 「任务完成(job=…)」;error/timeout/killed → 「任务失败(job=…)」
275
275
 
276
- 排序:job_id 名升序 = 时间升序(job.rs 命名保证 h<unix_ms>_<pid>),
277
- 跨 job 全序成立;seq 由本源按枚举顺序递增(ingest 去重键 = (session, seq))。
276
+ 排序:**job_id 名升序**(`sorted(listdir)`,确定性),**不是**时间序——
277
+ 契约 v2(`docs/plans/全中文编码与蜂巢任务标识契约_v2.0.md` §四.5)起 id 改为
278
+ 语义四槽 `h_<身份>_<任务>_<单元>_<编号>`,名序不再等于提交时序:旧形态
279
+ `h<unix_ms>_<pid>` 才是「名序 = 时间序」的**巧合代理**。据实订正(C2:本仓
280
+ 禁止把代理当结构保证)——本源的时序由来源行自带的 ts 承载,排序只求
281
+ **跨 job 全序且确定**(seq 由本源按枚举顺序递增,ingest 去重键 = (session, seq),
282
+ 名序的确定性保证该键跨轮稳定)。
283
+ 诚实边界(**已发现、未在本批动码**):本源的 `_jobs()` 若需要**真时间序**,
284
+ 应改走与 Rust 侧同判据的 created_ts 真值(`hive/src/job.rs::list_jobs_by_created`
285
+ 的语义,Python 侧对应 `mcp_server._list_jobs_by_created`)——契约 §四.5 的消费者
286
+ 清单未列本源,且改动牵动 md_cg 摄入链语义与其守卫,故留待单独裁决,不静默改。
278
287
  解析失败的行/条目计入 self.skipped,不终杀批次。
279
288
  """
280
289
 
@@ -626,11 +635,14 @@ class FileDispatcher:
626
635
 
627
636
  # ---- stat:看水位与支持面 ----
628
637
 
629
- # 生效条件:from . import refindex 与 refindex.Ledger(self.cg.root).stat() 均不抛异常时返回该 stat 结果,抛任何异常时返回 {}。
638
+ # 生效条件:from . import refindex 与 refindex.Ledger(self.cg.root).summary() 均不抛异常时返回该 summary 结果,抛任何异常时返回 {}。
630
639
  def _ledger_stat(self):
640
+ # `Ledger` 只有 `summary()`、**没有** `stat()`:此前写 `.stat()` 撞
641
+ # AttributeError 被下面的 except 吞成 `{}`,于是 `ingest stat` 的水位面
642
+ # 永远是空且无人知晓(唯一实现面是 `refindex.Ledger.summary`)。
631
643
  try:
632
644
  from . import refindex
633
- return refindex.Ledger(self.cg.root).stat()
645
+ return refindex.Ledger(self.cg.root).summary()
634
646
  except Exception: # noqa: BLE001
635
647
  return {}
636
648
 
@@ -702,7 +714,7 @@ class FileDispatcher:
702
714
  "counts": counts,
703
715
  "note": "预演:仅统计各链文件数,未做任何写入"}
704
716
 
705
- # 生效条件:root 非目录时返回 ok=False 的「目录不存在」;dry_run 为真时返回 _dry_dir(root);否则对 doc_ref/code_ref 两链各以 patterns/max_files/max_items/incremental/ledger 调 refindex.index_dir 与 add_items,并把 root 下 **/*.jsonl 前 max_files 个逐个 ingest_jsonl 后返回 out。
717
+ # 生效条件:root 非目录时返回 ok=False 的「目录不存在」;dry_run 为真时返回 _dry_dir(root);否则对 doc_ref/code_ref 两链各以 patterns/max_files/max_items/incremental/ledger 调 refindex.index_dir(commit=False) 与 add_items 后 ledger.save(),并把 root 下 **/*.jsonl 前 max_files 个逐个 ingest_jsonl 后返回 out。
706
718
  def ingest_dir(self, root, layer=None, sensitivity=None, patterns=None,
707
719
  max_files=500, max_items=2000, incremental=False,
708
720
  dry_run=False):
@@ -716,13 +728,18 @@ class FileDispatcher:
716
728
  for ref_kind, key in (("doc_ref", "doc"), ("code_ref", "code")):
717
729
  items, errors, stats = refindex.index_dir(
718
730
  root, kind=ref_kind, patterns=patterns, max_files=max_files,
719
- max_items=max_items, incremental=incremental, ledger=ledger)
731
+ max_items=max_items, incremental=incremental, ledger=ledger,
732
+ commit=False)
720
733
  ids, sens = refindex.add_items(self.cg, items, kind=ref_kind,
721
734
  root=root, layer=layer,
722
735
  sensitivity=sensitivity)
736
+ # 写序(同 op=index_code/index_doc):节点先落盘,水位随后——
737
+ # 中途被杀只会留下「节点新 + 水位旧」,下次增量重切,不会静默漏漂移。
738
+ ledger.save()
723
739
  out["chains"][key] = {
724
740
  "indexed": len(ids), "errors": len(errors),
725
741
  "files": stats.get("files"), "truncated": stats.get("truncated"),
742
+ "empty_scan": stats.get("empty_scan"),
726
743
  "skipped_unchanged": stats.get("skipped_unchanged", 0),
727
744
  "skipped_suffixes": stats.get("skipped_suffixes", []),
728
745
  "sensitivity": sens}
@@ -0,0 +1,352 @@
1
+ # -*- coding: utf-8 -*-
2
+ """md_cg · 真源索引契约层(P0)——通用层 + 细化层,零解码 / 零模型 / 零第三方依赖。
3
+
4
+ 设计依据:`docs/plans/真源索引_通用机制_v0.3.md`(使用者裁定 2026-09-28)。
5
+ 本模块只做**契约与纯函数**:把任意文件变成「可寻址单位」,并提供按单位回读。
6
+ 落库(写认知图 ref 节点)在 P1 接 `refindex.add_items`,本模块不写任何状态。
7
+
8
+ 三层(v0.3):
9
+ ① **通用层**(任意文件):条目 = {path, name, type, size, mtime, file_hash},span=whole,
10
+ text_view = 文件名 + 路径 + 类型(+ 伴随文本)。类型只进检索面,**不决定能否索引**。
11
+ ② **细化层**(仅当真源本身可读文本):按章节切成 line 区间单位(每单位自带区间哈希),
12
+ 并产摘要/简介;带 `# 生效条件:` 一类 CCG 行的走既有 condition_space 同源机制。
13
+ ③ **伴随文本**(多模态的语义通道):同名 sidecar / 目录说明 —— 它们**本身即真源**,
14
+ 由调用方照常索引;本模块负责**发现并挂到通用层条目上**(`companions()`)。
15
+
16
+ 为什么不需要解码器与模型(v0.3 撤回项):通用层是**整文件粒度**,不做时间点/区域切片;
17
+ 语义面走伴随文本而非模型产出。二者降级为后续可选(方案 §12)。
18
+
19
+ 区间单位(`unit`)本版只有两种:`whole`(整文件)与 `line`(文本行区间)。`time` / `region` /
20
+ `byte` 三种 locator 属后续可选,**本模块显式不实现**(不静默假装支持)。
21
+
22
+ 生效条件:入参 path 指向常规文件(`os.path.isfile` 为真);`units()` 对不可读/无权限文件返回
23
+ 只含通用层单位的列表(不抛),`read_unit('whole')` 一律返回原始字节。
24
+ 不适用于:无文件实体的真源(须先物化);跨机指针(路径为绝对路径,跨机需另立锚)。
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import hashlib
29
+ import io
30
+ import os
31
+
32
+ CHUNK = 1 << 20 # 文件哈希分块(大文件不整读进内存)
33
+ SNIFF_BYTES = 4096 # 类型嗅探与「可读文本」判定取的前缀长度
34
+
35
+ # 本版实现的 unit 类型;time/region/byte 属后续可选(方案 §12),此处显式声明未实现
36
+ UNITS = ("whole", "line")
37
+
38
+ TEXT_SUFFIX = {
39
+ ".md": "text/markdown", ".markdown": "text/markdown", ".txt": "text/plain",
40
+ ".json": "application/json", ".jsonl": "application/x-ndjson",
41
+ ".yaml": "application/yaml", ".yml": "application/yaml",
42
+ ".csv": "text/csv", ".tsv": "text/tab-separated-values",
43
+ ".py": "text/x-python", ".rs": "text/x-rust", ".ts": "text/x-typescript",
44
+ ".js": "text/javascript", ".sh": "text/x-shellscript", ".toml": "application/toml",
45
+ ".ini": "text/plain", ".cfg": "text/plain", ".log": "text/plain",
46
+ ".html": "text/html", ".htm": "text/html", ".xml": "text/xml",
47
+ ".sql": "text/x-sql", ".c": "text/x-c", ".h": "text/x-c", ".cpp": "text/x-c++",
48
+ }
49
+ # 魔数 → 类型(只列常见;未命中即回落扩展名 / octet-stream,绝不因未知类型拒绝入索引)
50
+ MAGIC = (
51
+ (b"\x89PNG\r\n\x1a\n", "image/png"),
52
+ (b"\xff\xd8\xff", "image/jpeg"),
53
+ (b"GIF87a", "image/gif"), (b"GIF89a", "image/gif"),
54
+ (b"RIFF", "audio-or-video/riff"), # 细分再看子类型(WAVE / AVI)
55
+ (b"ID3", "audio/mpeg"), (b"\xff\xfb", "audio/mpeg"),
56
+ (b"OggS", "audio-or-video/ogg"),
57
+ (b"fLaC", "audio/flac"),
58
+ (b"\x00\x00\x00\x18ftyp", "video/mp4"),
59
+ (b"\x1aE\xdf\xa3", "video/webm-or-matroska"),
60
+ (b"PK\x03\x04", "application/zip"),
61
+ (b"%PDF-", "application/pdf"),
62
+ (b"BZh", "application/x-bzip2"),
63
+ (b"\x1f\x8b", "application/gzip"),
64
+ (b"\x7fELF", "application/x-elf"),
65
+ (b"MZ", "application/x-dosexec"),
66
+ )
67
+
68
+
69
+ # 生效条件:path 为字符串且可 os.stat;返回 {path, name, dir, suffix, size, mtime, file_hash},
70
+ # 路径取 abspath(真源身份要跨调用稳定);file_hash 为文件级 sha256(P0 的 staleness 判据)。
71
+ def src_id(path: str) -> dict:
72
+ """真源身份:**文件名 / 路径 / 大小 / 时间 / 文件哈希**(索引的通用层主键面)。"""
73
+ ap = os.path.abspath(path)
74
+ st = os.stat(ap)
75
+ return {
76
+ "path": ap,
77
+ "name": os.path.basename(ap),
78
+ "dir": os.path.dirname(ap),
79
+ "suffix": os.path.splitext(ap)[1].lower(),
80
+ "size": st.st_size,
81
+ "mtime": st.st_mtime,
82
+ "file_hash": file_hash(ap),
83
+ }
84
+
85
+
86
+ def file_hash(path: str) -> str:
87
+ h = hashlib.sha256()
88
+ with open(path, "rb") as f:
89
+ while True:
90
+ b = f.read(CHUNK)
91
+ if not b:
92
+ break
93
+ h.update(b)
94
+ return h.hexdigest()
95
+
96
+
97
+ # 生效条件:prefix 为 bytes 时按 MAGIC 表逐条 startswith 判定,首个命中即返回其类型;
98
+ # 未命中回落 suffix 表;仍未命中返回 "application/octet-stream"(未知类型**不是**拒绝理由)。
99
+ def sniff_type(path: str, prefix: bytes = None) -> str:
100
+ """类型判据=魔数优先、扩展名兜底(零依赖);只用于检索面与分组,不决定可索引性。"""
101
+ if prefix is None:
102
+ try:
103
+ with open(path, "rb") as f:
104
+ prefix = f.read(SNIFF_BYTES)
105
+ except OSError:
106
+ prefix = b""
107
+ for magic, t in MAGIC:
108
+ if prefix.startswith(magic):
109
+ if magic == b"RIFF":
110
+ if prefix[8:12] == b"WAVE":
111
+ return "audio/wav"
112
+ if prefix[8:12] == b"AVI ":
113
+ return "video/x-msvideo"
114
+ return t
115
+ return TEXT_SUFFIX.get(os.path.splitext(path)[1].lower(),
116
+ "application/octet-stream")
117
+
118
+
119
+ # 生效条件:prefix 含 NUL 字节即判不可读;否则尝试 utf-8 严格解码,成功即判可读文本。
120
+ # 语义边界:只读前缀,宁少判不多判(截断在多字节字符中间的假阴性可接受——细化层缺席不致命)。
121
+ def is_text_readable(prefix: bytes) -> bool:
122
+ if b"\x00" in prefix:
123
+ return False
124
+ try:
125
+ prefix.decode("utf-8")
126
+ return True
127
+ except UnicodeDecodeError:
128
+ return False
129
+
130
+
131
+ def _read_prefix(path: str) -> bytes:
132
+ try:
133
+ with open(path, "rb") as f:
134
+ return f.read(SNIFF_BYTES)
135
+ except OSError:
136
+ return b""
137
+
138
+
139
+ # 生效条件:text 与 1-based 行区间给定;返回该区间的哈希——**委托唯一实现**
140
+ # `codeindex.region_hash`(sha1 前 12 位)。其 docstring 明写「必须是唯一定义:索引侧与回读侧
141
+ # 共用同一个函数」,两侧各写一份就会让漂移检测悄悄失效(永远 hash_match=True)。
142
+ # 本模块 P0 初版在此自写了 sha256 第二份,被守卫 5b/5c 当场抓出——本函数即那次修正的产物。
143
+ def region_hash(text: str, lineno: int, end: int) -> str:
144
+ from . import codeindex
145
+ return codeindex.region_hash(text.split("\n"), lineno, end)
146
+
147
+
148
+ # 生效条件:path 为常规文件时返回单位列表——**至少一条**通用层单位(span=whole,任意文件);
149
+ # 若前缀可读文本则追加细化层 line 单位(章节来自 docindex.extract,无提取器后缀则不切)。
150
+ # 返回的每条单位形如 {src, span, span_hash, text_view, reader};不写任何状态。
151
+ def units(path: str, *, with_sections: bool = True) -> list[dict]:
152
+ """把任意文件变成可寻址单位:通用层恒在(含伴随文本挂载),细化层仅对可读文本追加。"""
153
+ sid = src_id(path)
154
+ prefix = _read_prefix(path)
155
+ typ = sniff_type(path, prefix)
156
+ sid["type"] = typ
157
+ out = [{
158
+ "src": sid,
159
+ "span": {"unit": "whole"},
160
+ "span_hash": sid["file_hash"],
161
+ "text_view": "文件 %s(%s)|路径 %s" % (sid["name"], typ, sid["path"]),
162
+ "reader": {"kind": "whole"},
163
+ }]
164
+ # 伴随文本属**通用层**:多模态文件的语义面就靠它(P0 初版误挂在「可读文本」分支里,
165
+ # 而媒体根本不进那个分支 ⇒ 恰好废掉方案的 §10.3 语义通道,被守卫 6b 抓出)
166
+ comps = companions(sid["path"])
167
+ if comps:
168
+ out[0]["text_view"] += "|伴随文本 " + "、".join(c["name"] for c in comps)
169
+ out[0]["companions"] = [c["path"] for c in comps]
170
+ out[0]["companion_kinds"] = sorted({c["kind"] for c in comps})
171
+ if not is_text_readable(prefix):
172
+ return out
173
+ try:
174
+ text = io.open(path, encoding="utf-8").read()
175
+ except (OSError, UnicodeDecodeError):
176
+ return out
177
+ if not with_sections:
178
+ return out
179
+ try:
180
+ from . import docindex
181
+ items = docindex.extract(text, path=sid["path"])
182
+ except Exception: # noqa: BLE001 —— 无提取器/解析失败:停在通用层,不抛
183
+ return out
184
+ lines = text.split("\n")
185
+ for it in items:
186
+ lineno = int(it.get("lineno") or 1)
187
+ end = int(it.get("end") or lineno)
188
+ head = it.get("heading") or it.get("name") or ""
189
+ body = "\n".join(lines[lineno - 1:end])
190
+ out.append({
191
+ "src": sid,
192
+ "span": {"unit": "line", "start": lineno, "end": end,
193
+ "anchor": it.get("anchor"), "level": it.get("level"),
194
+ "heading_path": it.get("heading_path")},
195
+ # 哈希单一实现:优先用 docindex 已算的(与 region_hash 同源),缺则现算
196
+ "span_hash": it.get("hash") or region_hash(text, lineno, end),
197
+ "text_view": ("%s | %s" % (sid["name"], head)).strip(),
198
+ "reader": {"kind": "line"},
199
+ # CCG 条件面:与正文同源(docindex.condition_space 解析 `# 生效条件:` 行)
200
+ "condition_space": (docindex.condition_space(it)
201
+ if hasattr(docindex, "condition_space") else None),
202
+ "summary": body[:200],
203
+ })
204
+ return out
205
+
206
+
207
+ # 生效条件:同目录下存在与媒体同主的可读文本→视为伴随文本;判定=同 stem 且后缀属
208
+ # 可读文本集合(.md/.txt/.json/.caption/.prompt),另加同目录 README/_index.md(目录说明)。
209
+ # 只发现与返回路径,不索引、不改动任何文件。
210
+ def companions(path: str) -> list[dict]:
211
+ """伴随文本发现(多模态的语义通道):同名 sidecar + 目录说明。"""
212
+ d = os.path.dirname(os.path.abspath(path))
213
+ stem = os.path.splitext(os.path.basename(path))[0]
214
+ found = []
215
+ for suffix in (".md", ".txt", ".json", ".caption", ".prompt"):
216
+ p = os.path.join(d, stem + suffix)
217
+ if os.path.isfile(p) and os.path.abspath(p) != os.path.abspath(path):
218
+ found.append({"path": os.path.abspath(p), "name": os.path.basename(p),
219
+ "kind": "sidecar"})
220
+ for name in ("README.md", "_index.md", "INDEX.md"):
221
+ p = os.path.join(d, name)
222
+ if os.path.isfile(p):
223
+ found.append({"path": os.path.abspath(p), "name": name, "kind": "dir_doc"})
224
+ break
225
+ return found
226
+
227
+
228
+ # 生效条件:unit 含 span;unit.span.unit == "whole" 时返回原文件字节;== "line" 时按
229
+ # src.path 读文本并返回 [start, end] 行;未知 unit 返回 ok=False 与 error(**不静默**)。
230
+ # with_hash=True 时附带 span_hash 复核结果(回读文本重算 vs unit 内记录)。
231
+ def read_unit(unit: dict, *, with_hash: bool = True) -> dict:
232
+ """按单位回读真源:通用层返回整文件字节,细化层返回行区间文本。"""
233
+ src = unit.get("src") or {}
234
+ path = src.get("path") or ""
235
+ span = unit.get("span") or {}
236
+ kind = span.get("unit")
237
+ if kind == "whole":
238
+ try:
239
+ with open(path, "rb") as f:
240
+ data = f.read()
241
+ except OSError as e:
242
+ return {"ok": False, "error": "读取失败:%s" % e}
243
+ out = {"ok": True, "bytes": len(data), "hash": hashlib.sha256(data).hexdigest()}
244
+ if with_hash:
245
+ out["hash_match"] = out["hash"] == unit.get("span_hash")
246
+ return out
247
+ if kind == "line":
248
+ try:
249
+ text = io.open(path, encoding="utf-8").read()
250
+ except (OSError, UnicodeDecodeError) as e:
251
+ return {"ok": False, "error": "读取失败:%s" % e}
252
+ a = int(span.get("start") or 1)
253
+ b = int(span.get("end") or 1)
254
+ seg = "\n".join(text.split("\n")[max(0, a - 1):max(max(0, a - 1), b)])
255
+ out = {"ok": True, "text": seg, "lines": b - a + 1, "hash": region_hash(text, a, b)}
256
+ if with_hash:
257
+ out["hash_match"] = out["hash"] == unit.get("span_hash")
258
+ return out
259
+ return {"ok": False,
260
+ "error": "未知 span.unit=%r(本版支持 %s;time/region/byte 属后续可选)"
261
+ % (kind, "/".join(UNITS))}
262
+
263
+
264
+ # ==========================================================================
265
+ # 日志真源适配器(P1):DSH 会话日志 → 真源身份 + 区间表
266
+ #
267
+ # 日志这一路与普通文件的差别只有一处(v0.3 §9 裁定①):**真源是 zstd JSONL,
268
+ # 可读区间却落在确定性转写上**。所以适配器把两件事分开交付——
269
+ # · 真源身份(`session_src_id`):日志本体的 7 键 + 会话身份 4 键;
270
+ # · 区间表(`log_units`):span 取自转写(docindex 切分),src 指向日志本体。
271
+ # 二者都只是**把既有单点拼起来**:身份仍由 `src_id` 定、区间仍由 `docindex.extract`
272
+ # 切、哈希仍由 `region_hash` 算。本模块不写任何状态、不 import scripts。
273
+ # ==========================================================================
274
+
275
+ #: `srcindex.session_src_id` 认的 path_mode——见函数 docstring 的暴露面说明
276
+ SRC_PATH_MODES = ("abs", "rel", "hash")
277
+
278
+
279
+ # 生效条件:log_path 为可 stat 的常规文件;path_mode 属 SRC_PATH_MODES(否则抛 ValueError);
280
+ # 返回 src_id 的 7 键 + {kind, session_uuid, workspace, sensitivity} 4 键,共 11 键。
281
+ def session_src_id(log_path: str, *, session_uuid: str = None,
282
+ workspace: str = None, sensitivity: str = "internal",
283
+ path_mode: str = "abs", base: str = None) -> dict:
284
+ """DSH 会话日志的真源身份:`src_id` 7 键 + 会话身份 4 键(只做加法)。
285
+
286
+ `file_hash` 是 staleness 的**唯一**判据(P0 裁定),故任何降级都不得动它。
287
+
288
+ `path_mode` 是**暴露面降级开关**:src 含真源绝对路径 + 会话 uuid,而节点是
289
+ internal 档(跨会话共享可见,dsh_log_index.py:16-21 的共享档语义)⇒ 任何能读
290
+ 该节点的会话都拿到本机日志绝对路径与会话 uuid。三档语义:
291
+ · `abs`(缺省)——绝对路径,探测能力完整;
292
+ · `rel`——相对 `base`(缺省取日志自身目录)且 **`dir` 一并置空**:留下绝对
293
+ 目录等于把 rel 刚省下的又落回库里;
294
+ · `hash`——**path 置空**。
295
+ rel 与 hash 两档都不支持探测(`logref.probe_src` 返回 unresolved,属「明确
296
+ 不探测」而非静默通过——不猜、不假装能核);探测能力完整只有 `abs`。
297
+ """
298
+ sid = src_id(log_path)
299
+ ap = sid["path"]
300
+ if path_mode == "rel":
301
+ b = os.path.abspath(base) if base else os.path.dirname(ap)
302
+ sid["path"] = os.path.relpath(ap, b).replace("\\", "/")
303
+ sid["dir"] = ""
304
+ elif path_mode == "hash":
305
+ sid["path"] = ""
306
+ sid["dir"] = ""
307
+ elif path_mode != "abs":
308
+ raise ValueError("未知 path_mode=%r(支持 %s)"
309
+ % (path_mode, "/".join(SRC_PATH_MODES)))
310
+ return {**sid, "kind": "dsh_session_log", "session_uuid": session_uuid,
311
+ "workspace": workspace, "sensitivity": sensitivity}
312
+
313
+
314
+ # 生效条件:transcript_path 为可读 md、src 为日志真源身份(session_src_id 的产物);
315
+ # 返回按 docindex 章节切分的区间表,每条 {src, span(unit=line), span_hash, text_view,
316
+ # reader, item};`item` 是**落库口径**的原始条目(path 已按入参 path 定)。
317
+ def log_units(transcript_path: str, src: dict, *, path: str = None) -> list[dict]:
318
+ """日志真源的区间表:span 落在**确定性转写**上,src 指向 zstd 日志本体。
319
+
320
+ `path` 是 docindex 的寻址键面(`path#heading_path` 里的 path,须与最终写进
321
+ `doc_ref.path` 的值同源),缺省回落转写的 basename——**缺省只用于单会话、
322
+ 单转写根的临时场景**:多工作区同 sid 时会撞 id(见 `md_cg/test_logref.py`
323
+ 的 path 口径守卫)。
324
+ """
325
+ from . import docindex # 局部 import:与 units() 同款,避免模块级循环依赖
326
+ text = io.open(transcript_path, encoding="utf-8").read()
327
+ p = path or os.path.basename(transcript_path)
328
+ out = []
329
+ for it in docindex.extract(text, path=p):
330
+ lineno = int(it.get("lineno") or 1)
331
+ end = int(it.get("end") or lineno)
332
+ out.append({
333
+ "src": src,
334
+ "span": {"unit": "line", "start": lineno, "end": end,
335
+ "anchor": it.get("anchor"), "level": it.get("level"),
336
+ "heading_path": it.get("heading_path")},
337
+ "span_hash": it.get("hash") or region_hash(text, lineno, end),
338
+ "text_view": "%s | %s" % (src.get("name") or "", it.get("heading") or ""),
339
+ "reader": {"kind": "line"},
340
+ "item": it,
341
+ })
342
+ return out
343
+
344
+
345
+ # 生效条件:无(诊断用);返回本模块对「通用层/细化层」的能力自陈,供文档与守卫比对。
346
+ def capability() -> dict:
347
+ return {"units": list(UNITS),
348
+ "universal_layer": "任意常规文件(span=whole)",
349
+ "detail_layer": "仅可读文本(line 区间 + 摘要 + CCG 条件面)",
350
+ "companions": ["sidecar", "dir_doc"],
351
+ "not_implemented": ["time", "region", "byte"],
352
+ "deps": [], "writes_state": False}
package/md_cg/stg.py CHANGED
@@ -210,7 +210,49 @@ def relation(cg, a_id, b_id, time_axis="observed"):
210
210
  "space_known": ba is not None and bb is not None}}
211
211
 
212
212
 
213
- # 生效条件:以 _scan(cg,layer=layer,max_scan=max_scan) 为范围,session 去空白后非空且不为 "*" 时仅保留 frontmatter.session 精确相等的节点,_interval(n["frontmatter"], time_axis) 为 None 的节点被跳过,其余按 (start,end) 以 reverse=bool(desc) 排序,返回 count=全部命中数、limit=传入 limit、session=生效的会话过滤值(跨会话时为 None)、items 为排序后前 limit 项(limit=0 时为空列表)且每项附 session 归属与 _preview(cg,id)(time_axis 缺省 observed,与旧行为逐位一致;非法轴抛 ValueError)。
213
+ # 生效条件:session 为 None 或 str(session).strip() 为空串时返回 ""(跨会话视图的内部表示,与旧实现 `"" if session is None else str(session).strip()` 逐位一致);去空白后恰为 "*" 时返回 "*";否则延迟导入 mcp_server._normalize_session 并返回其归一结果;该导入抛任何异常(ImportError 等)时返回去空白原值(fail-soft 退回旧行为)。
214
+ def _view_session(session):
215
+ """读侧会话视图值 → 过滤值:**与写侧同一把尺**(H2,2026-09-30)。
216
+
217
+ 动机(端到端实测,见 `md_cg/test_h2_session_view_norm.py`):写侧落盘值 =
218
+ `_normalize_session(请求声明值)`(`MdCGSecure._attribution` 取 `cg.session`,
219
+ 而 `cg.session` 由 `call_tool` → `_declared_session` 归一),读侧若拿
220
+ **未归一的原值**做等值比较,同一条记忆就「写进去查不出」——DSH 形态的
221
+ 会话 id 在会话根下不存在时,写侧落 `anonymous`、读侧按 `session-…`
222
+ 精确匹配 ⇒ `count=0`。
223
+
224
+ 由此本函数**只归一「具体会话值」这一态**,三态语义逐位不变:
225
+ · None / 空串 → 跨会话(缺省不过滤,向后兼容);
226
+ · `"*"` → 跨会话(显式意图;`"*"` 不是会话名,**不归一**);
227
+ · 其它值 → 过 `_normalize_session`(本会话视图与写侧同尺)。
228
+
229
+ 真源只有一份(`mcp_server._normalize_session`),此处**消费而不重写**:
230
+ 写侧读侧各写一份校验必然造出第三种不等值。延迟导入的副作用为零——正常
231
+ 形态下 stg 本就被 mcp_server 调用(模块早已加载);`stg` 被独立使用时导入
232
+ 失败即 fail-soft 返回原值(退回旧行为,不报错、不改变既有语义)。
233
+
234
+ 不适用条件:`cg` 侧读路径(search/recall/`cg(op=read)`)的请求 `session`
235
+ 走 `MdCGSecure._candidates` 的**身份判定**(issue #35 定稿:身份不可自报),
236
+ 不经本函数——那不是视图过滤,两处不得互相「对齐」。
237
+
238
+ 返回值口径:None 与空串一律映射为 `""`(旧实现 `"" if session is None
239
+ else str(session).strip()` 的内部表示,跨会话分支判据 `sid in ("", "*")`
240
+ 依赖它——返回 None 会让 `cross` 判假、把缺省视图变成「只看 session 为
241
+ 空的节点」,是一处会静默清空整块自动召回的坑)。
242
+ """
243
+ if session is None:
244
+ return ""
245
+ s = str(session).strip()
246
+ if s in ("", "*"):
247
+ return s
248
+ try:
249
+ from .mcp_server import _normalize_session
250
+ except Exception: # noqa: BLE001 fail-soft:保旧行为
251
+ return s
252
+ return _normalize_session(s)
253
+
254
+
255
+ # 生效条件:以 _scan(cg,layer=layer,max_scan=max_scan) 为范围,session 经 _view_session 归一后(None/空/"*"=跨会话不过滤,其它值=归一后的具体会话)非跨会话时仅保留 frontmatter.session 精确相等的节点,_interval(n["frontmatter"], time_axis) 为 None 的节点被跳过,其余按 (start,end) 以 reverse=bool(desc) 排序,返回 count=全部命中数、limit=传入 limit、session=生效的会话过滤值(跨会话时为 None)、items 为排序后前 limit 项(limit=0 时为空列表)且每项附 session 归属与 _preview(cg,id)(time_axis 缺省 observed,与旧行为逐位一致;非法轴抛 ValueError)。
214
256
  def timeline(cg, layer=None, limit=50, desc=True, max_scan=5000,
215
257
  time_axis="observed", session=None):
216
258
  """按时间排序的节点列表。`time_axis` 决定排序依据的时间区间(见 `_interval`)。
@@ -220,11 +262,13 @@ def timeline(cg, layer=None, limit=50, desc=True, max_scan=5000,
220
262
  · `"*"` → 同上语义,但把「我要看所有会话做了什么」写成**显式意图**,与
221
263
  「忘了传参」区分开,审计里也看得出这是一次跨会话读取;
222
264
  · 其它值 → 只取 `frontmatter.session` 精确相等的节点(本会话视图,
223
- 自动召回用它防串台)。
265
+ 自动召回用它防串台)。**该值先过 `_view_session` 归一**(H2)——
266
+ 写侧落盘时已过 `_normalize_session`,读侧不过同一把尺就会出现
267
+ 「写进去查不出」;返回体 `session` 回带的是**归一后**的生效值。
224
268
  `items` 一并回带 `session`:跨会话视图下「这条是哪个会话做的」必须可辨,
225
269
  否则「能读到所有会话做了什么」只剩内容、丢了归属。
226
270
  """
227
- sid = "" if session is None else str(session).strip()
271
+ sid = _view_session(session)
228
272
  cross = sid in ("", "*") # 跨会话:显式 "*" 与缺省同义
229
273
  items = []
230
274
  for n in _scan(cg, layer=layer, max_scan=max_scan):