@furongjun1999/dsh-memory 0.6.0 → 0.7.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 (202) hide show
  1. package/README.md +618 -572
  2. package/codebuddy/CODEBUDDY.md +10 -10
  3. package/data/policy.json +27 -0
  4. package/docs/README.md +1 -0
  5. package/docs/discipline/harnesses.yaml +31 -0
  6. package/docs/discipline/templates/full.md.tmpl +1 -1
  7. package/docs/discipline/templates/rules.mdc.tmpl +1 -1
  8. 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
  9. 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
  10. 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
  11. 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
  12. 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
  13. 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
  14. package/docs/eval//345/207/272/350/264/247/351/235/242/345/206/222/347/203/237_/350/277/233/350/264/247/351/227/250/347/246/201_v1.0.md +460 -0
  15. 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
  16. package/docs/eval//345/217/221/345/270/20308_/350/207/252/350/277/255/344/273/243/344/270/216/347/235/241/347/234/240_/345/233/276/346/243/200/347/264/242/350/267/257/344/270/216/346/235/203/351/207/215_v1.0.md +97 -0
  17. package/docs/eval//345/275/222/344/270/200/345/261/202/347/274/272/347/234/201/347/277/273/345/205/263_/344/277/256/345/244/215/350/256/260/345/275/225_v1.0.md +458 -0
  18. 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
  19. 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
  20. 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
  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_v20.md +283 -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_v21.md +224 -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_v22.md +223 -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_v23.md +293 -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_v24.md +303 -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_v25.md +230 -0
  27. package/docs/hive//346/243/200/347/264/242/347/256/227/346/263/225/345/217/243/345/276/204/345/257/271/347/205/247_v0.1.md +136 -11
  28. 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 +52 -2
  29. 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
  30. package/docs/mdcg/README/350/257/246/347/273/206/347/211/210_v0.4.10.md +100 -0
  31. 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
  32. package/docs/mdcg//345/217/221/345/270/203/351/227/250/347/246/201/351/223/276_v0.1.md +57 -9
  33. 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
  34. package/docs/mdcg//347/235/241/347/234/240/345/221/250/346/234/237_/350/277/220/347/273/264/345/211/215/346/217/220/344/270/216/347/273/264/346/212/244/346/214/207/345/215/227_v1.0.md +183 -0
  35. 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
  36. 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
  37. 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
  38. package/docs/plans//347/235/241/347/234/240/344/270/216/350/207/252/350/277/255/344/273/243_/345/212/237/350/203/275/344/274/230/345/214/226/350/256/276/350/256/241_v0.4.md +547 -0
  39. package/docs/plans//350/234/202/345/267/242/346/250/241/345/236/213/345/257/206/351/222/245/351/205/215/347/275/256/351/235/242_v1.0.md +385 -0
  40. package/docs//345/267/245/344/275/234/347/272/252/345/276/213_/350/256/244/347/237/245/345/233/276/346/235/241/347/233/256_v1.1.json +460 -460
  41. package/lib/bridge.js +24 -2
  42. package/lib/hooks.d.ts +31 -2
  43. package/lib/hooks.js +219 -16
  44. package/lib/init.js +16 -1
  45. package/lib/lib/prompt_safety.d.ts +52 -1
  46. package/lib/lib/prompt_safety.js +76 -1
  47. package/md_cg/audit.py +652 -379
  48. package/md_cg/bench6_arms.py +1 -1
  49. package/md_cg/bench_e2e_locomo_qa.py +11 -4
  50. package/md_cg/bench_p0.py +1 -1
  51. package/md_cg/branches.py +2 -2
  52. package/md_cg/ccgc.py +1071 -1006
  53. package/md_cg/chain.py +48 -1
  54. package/md_cg/consistency.py +97 -9
  55. package/md_cg/consolidate.py +34 -9
  56. package/md_cg/crypto.py +59 -26
  57. package/md_cg/docindex.py +16 -1
  58. package/md_cg/evolution.py +17 -1
  59. package/md_cg/export.py +1 -1
  60. package/md_cg/forgetting.py +309 -13
  61. package/md_cg/freshness.py +511 -0
  62. package/md_cg/fsutil.py +454 -8
  63. package/md_cg/generation.py +409 -0
  64. package/md_cg/hotcache.py +4 -1
  65. package/md_cg/identity.py +3 -3
  66. package/md_cg/insight.py +24 -3
  67. package/md_cg/linkref.py +1 -1
  68. package/md_cg/logref.py +327 -0
  69. package/md_cg/mcp_server.py +4261 -3818
  70. package/md_cg/mdcg.py +4695 -3547
  71. package/md_cg/mdcos.py +4672 -4159
  72. package/md_cg/mreview/pipeline.py +15 -1
  73. package/md_cg/nodefile.py +713 -576
  74. package/md_cg/protect.py +158 -11
  75. package/md_cg/protocol.py +41 -2
  76. package/md_cg/provenance.py +22 -4
  77. package/md_cg/reach.py +4 -4
  78. package/md_cg/readcache.py +76 -15
  79. package/md_cg/reconcile.py +1 -1
  80. package/md_cg/refindex.py +246 -37
  81. package/md_cg/refine.py +1 -1
  82. package/md_cg/routing.py +32 -5
  83. package/md_cg/run_tests.py +17 -0
  84. package/md_cg/scrub.py +30 -3
  85. package/md_cg/security.py +47 -1
  86. package/md_cg/self_state.py +5 -5
  87. package/md_cg/semantic/canonical.py +22 -0
  88. package/md_cg/semantic/unify.py +73 -22
  89. package/md_cg/semantic/unify_fixture.json +25 -0
  90. package/md_cg/sleep.py +1297 -0
  91. package/md_cg/sources.py +23 -6
  92. package/md_cg/srcindex.py +352 -0
  93. package/md_cg/stg.py +47 -3
  94. package/md_cg/subgraph.py +2 -2
  95. package/md_cg/sustain.py +1436 -1168
  96. package/md_cg/tasks.py +469 -470
  97. package/md_cg/test_auto_defaults.py +424 -0
  98. package/md_cg/test_b1_auto_id_multiproc.py +277 -0
  99. package/md_cg/test_b1b2_write_face.py +417 -0
  100. package/md_cg/test_b2_sensitivity_landing.py +223 -0
  101. package/md_cg/test_b3_merge_keeps_content.py +576 -0
  102. package/md_cg/test_b4_shard_dir_selfheal.py +843 -0
  103. package/md_cg/test_b4_shard_dir_selfheal_guard.py +238 -0
  104. package/md_cg/test_boundary_hit.py +410 -0
  105. package/md_cg/test_c3_transient_read_negative.py +793 -0
  106. package/md_cg/test_c8_search_rrf_gates.py +502 -0
  107. package/md_cg/test_ccg_form_parity.py +188 -0
  108. package/md_cg/test_ccgc.py +28 -3
  109. package/md_cg/test_en_pipeline.py +22 -13
  110. package/md_cg/test_generation_guard.py +352 -0
  111. package/md_cg/test_govern_directread.py +17 -4
  112. package/md_cg/test_h2_session_view_norm.py +233 -0
  113. package/md_cg/test_h4_sustain_snapshot.py +1387 -0
  114. package/md_cg/test_hive_ingest.py +285 -285
  115. package/md_cg/test_issue39_utf8_stdio.py +307 -16
  116. package/md_cg/test_issue43_default_policy.py +865 -0
  117. package/md_cg/test_legacy_p3_node_id_type.py +375 -0
  118. package/md_cg/test_linkref.py +10 -3
  119. package/md_cg/test_lock.py +2 -2
  120. package/md_cg/test_logref.py +1109 -0
  121. package/md_cg/test_m3_h9_bucket_health_protect_mark.py +301 -0
  122. package/md_cg/test_m3_h9_semantic_guard.py +525 -0
  123. package/md_cg/test_mr_m2.py +15 -3
  124. package/md_cg/test_n130_verify_falsified_protect.py +1 -1
  125. package/md_cg/test_n139_dek_provision_failclosed.py +185 -0
  126. package/md_cg/test_n176_link_trust.py +221 -0
  127. package/md_cg/test_n178_units_jobid_gate.py +212 -0
  128. package/md_cg/test_n184_keys_concurrent_provision.py +307 -0
  129. package/md_cg/test_n195_writepath_reload.py +283 -0
  130. package/md_cg/test_n196_stale_gate_skip.py +169 -0
  131. package/md_cg/test_n197_n208_write_face_gates.py +471 -0
  132. package/md_cg/test_n198_tokens_corrupt_failclosed.py +225 -0
  133. package/md_cg/test_n199_tokens_concurrent_write.py +378 -0
  134. package/md_cg/test_n201_proposal_visibility.py +382 -0
  135. package/md_cg/test_n202_session_notes_visibility.py +461 -0
  136. package/md_cg/test_n204_n205_n226_n227_n228_n229_exit_gates.py +542 -0
  137. package/md_cg/test_n206_stdio_jsonrpc_type.py +403 -0
  138. package/md_cg/test_n209_verify_write_face_gates.py +461 -0
  139. package/md_cg/test_n212_n213_n224_generation_gates.py +519 -0
  140. package/md_cg/test_n214_n215_n221_n222_write_face_gates.py +563 -0
  141. package/md_cg/test_n225_nonobject_load.py +1110 -0
  142. package/md_cg/test_n230_dirty_replay.py +375 -0
  143. package/md_cg/test_n62_tenant_bind_failclosed.py +200 -0
  144. package/md_cg/test_neg_condition_hits.py +333 -0
  145. package/md_cg/test_neg_tail_honesty.py +663 -0
  146. package/md_cg/test_none_id_write_guard.py +4 -3
  147. package/md_cg/test_opt_batch1_md_cg.py +451 -0
  148. package/md_cg/test_p1.py +5 -5
  149. package/md_cg/test_p11_consistency.py +161 -12
  150. package/md_cg/test_p26_refindex.py +2 -2
  151. package/md_cg/test_p27_docindex.py +777 -774
  152. package/md_cg/test_p28_refcheck.py +740 -13
  153. package/md_cg/test_p29_session_ingest_export.py +2 -2
  154. package/md_cg/test_p2_mcp.py +12 -1
  155. package/md_cg/test_p2_six_elements.py +463 -0
  156. package/md_cg/test_p30_maintain.py +33 -2
  157. package/md_cg/test_p31_insight.py +1 -0
  158. package/md_cg/test_p3_legacy_closure.py +433 -0
  159. package/md_cg/test_p4_freshness.py +680 -0
  160. package/md_cg/test_p8_subgraph_chain.py +14 -1
  161. package/md_cg/test_p9c_dedup_hints.py +276 -0
  162. package/md_cg/test_policy_required_ccg.py +426 -0
  163. package/md_cg/test_protocol.py +22 -0
  164. package/md_cg/test_rank_parity_score_mode.py +522 -0
  165. package/md_cg/test_read_face_input_gates.py +363 -0
  166. package/md_cg/test_read_face_semantics.py +489 -0
  167. package/md_cg/test_recall_face_guards.py +812 -0
  168. package/md_cg/test_rejected_credential_forms.py +188 -0
  169. package/md_cg/test_rejected_redact.py +146 -0
  170. package/md_cg/test_retr_s1b.py +2 -2
  171. package/md_cg/test_retr_s5.py +20 -7
  172. package/md_cg/test_review_conformance.py +21 -3
  173. package/md_cg/test_security_audit_v21.py +2 -2
  174. package/md_cg/test_semantic_canonical.py +5 -3
  175. package/md_cg/test_server_version.py +67 -0
  176. package/md_cg/test_sleep.py +611 -0
  177. package/md_cg/test_sleep_p1.py +784 -0
  178. package/md_cg/test_srcindex.py +171 -0
  179. package/md_cg/test_tenant_env_override_warn.py +63 -50
  180. package/md_cg/test_time_core_lint.py +968 -0
  181. package/md_cg/test_token_lowercase_form.py +315 -0
  182. package/md_cg/test_unify_default_off.py +701 -0
  183. package/md_cg/test_unify_scope.py +182 -0
  184. package/md_cg/test_v21r1_package_version.py +310 -0
  185. package/md_cg/test_writepipe.py +10 -4
  186. package/md_cg/tokens.py +225 -95
  187. package/md_cg/tool_face.py +4 -4
  188. package/md_cg/trust.py +49 -5
  189. package/md_cg/units.py +204 -6
  190. package/md_cg/weights.py +4 -4
  191. package/md_cg/whitebox_kb/aeis_core/time_core.py +8 -0
  192. package/md_cg/whitebox_kb/wisdom/knowledge_points.py +1 -1
  193. package/md_cg/writelimit.py +42 -8
  194. package/md_cg/writepipe.py +651 -554
  195. package/package.json +4 -2
  196. package/skills/plugin.json +1 -1
  197. package/src/bridge.ts +25 -2
  198. package/src/hooks.ts +229 -16
  199. package/src/init.ts +15 -1
  200. package/src/lib/prompt_safety.ts +88 -1
  201. package/utf8_boot.py +237 -0
  202. package/zcode/AGENTS.md +10 -10
package/md_cg/units.py CHANGED
@@ -19,7 +19,15 @@ crosscheck 同款 verdicts 通道回填)。理由:使用者的复核是昂
19
19
  (同 hive/serve_start.serve_alive;四路口径由
20
20
  hive/test_serve_entry.py 机械守卫)
21
21
  job 目录 = jobs/<job_id>/{spec.json,status.json,result.json,kill}
22
- job_id = h<java_ms>_<uuid6> (同 _submit)
22
+ job_id = 本模块自造旧形态 h<java_ms>_<uuid6>(`_job_id`,**不是** `_submit` 同款);
23
+ hive 提交面自 id 契约 v2(B8)起为四槽 h_<身份>_<任务>_<单元>_<编号>,由
24
+ Rust 侧 `hive alloc-id` 分配——两形态都过 job.rs::valid_job_id(存量零迁移)
25
+ id 字符判据 = **区块白名单**:唯一真源 hive/id_charset_blocks.txt 的区间并集
26
+ ([`_valid_job_id`];本模块**读这份数据文件**、不 import hive,
27
+ 与 job.rs / mcp_server.py 三处共读同一份 ⇒ 不查任何 Unicode 属性库,
28
+ 两侧版本差结构性不可能)。本闸**有意保留**两处放宽(既有对外契约):
29
+ 不要求 h 前缀、允许 `-`;拒收面(零宽/双向控制/控制字符/路径成分/尾点/
30
+ 首尾空白/Windows 保留设备名)逐条显式断,不靠「白名单外顺带拒」。
23
31
  result.json = {"ok":true,"content":...} | {"ok":false,"error":...}
24
32
  终态 = done | error | timeout | killed
25
33
  拉起 serve = <repo>/hive/target/release/hive serve --jobs <jobs>(detached)
@@ -269,10 +277,189 @@ def plan(jobs: str = "", model: str = "", fresh_s: float = FRESH_S,
269
277
 
270
278
  # 生效条件:无入参,恒返回 "h"+int(time.time()*1000)+"_"+uuid.uuid4().hex 前 6 位组成的字符串。
271
279
  def _job_id() -> str:
272
- """同 _submit:serve 侧按 'h' 前缀识别任务目录。"""
280
+ """本地 id 生成(旧形态 `h<毫秒>_<uuid 前 6 位>`):serve 侧按 'h' 前缀识别任务目录。
281
+
282
+ 诚实边界(id 契约 v2 · C2 同类措辞订正):**本函数已不是 `_submit` 的同款实现**
283
+ ——`hive/hive_mcp/mcp_server.py::_submit` 自 B8 起调 Rust 侧 `hive alloc-id` 分配
284
+ `h_<身份>_<任务>_<单元>_<编号>`,**不再自造 id**;本模块是 md_cg 的复核派发面,
285
+ **不在 B8 点名的改动面**(契约只列 CLI / MCP 提交面 / orch 透传),故保留旧形态。
286
+ 旧形态 id 在新契约下**仍然合法**(存量零迁移:`job.rs::valid_job_id` 收),故此处
287
+ 不闯闸、不破坏 serve 领取;但它绕过了分配器的独占创建与五单元闭集 ⇒ 与「分配器
288
+ 唯一实现在 Rust 侧」(契约 §五 裁决 3)不齐,宜单独裁决(牵动 md_cg 派发/等待链
289
+ 语义与其守卫 `md_cg/test_units_poll.py`,不在本批「只论证订正」的范围内)。
290
+ """
273
291
  return "h%d_%s" % (int(time.time() * 1000), uuid.uuid4().hex[:6])
274
292
 
275
293
 
294
+ # ------------------------------------------------ id 字符判据(区块白名单 · 唯一真源)
295
+
296
+ #: 区块表数据文件(**唯一真源**,与 `hive/src/job.rs` / `hive/hive_mcp/mcp_server.py`
297
+ #: **共读同一份数据**;本模块**读数据文件**而不是 import hive ⇒ 不破零依赖家法)。
298
+ _CHARSET_BLOCKS_REL = "hive/id_charset_blocks.txt"
299
+ _MAX_CP = 0x10FFFF
300
+
301
+
302
+ # 生效条件:text 为表文件全文时返回区间列表 [(起, 止)]——**唯一解析配方**(与 Rust
303
+ # `job.rs::parse_id_charset_blocks`、`mcp_server._parse_charset_blocks` 同一条,不许各写
304
+ # 一套):每行取 `#` 之前部分后 strip;空行跳过;余下 split('-') 两段 int(x, 16)。
305
+ # 任一行不合形态 / 越界 / 逆序 / 未归并到最小 / 空表 → 抛 ValueError(fail-closed:
306
+ # 绝不静默跳过坏行——跳过一行的表是另一张表,三处读者随即不同判)。
307
+ def _parse_charset_blocks(text: str) -> list:
308
+ """区块表解析(唯一配方)。"""
309
+ out: list = []
310
+ for lineno, line in enumerate(text.split("\n"), 1):
311
+ body = line.split("#", 1)[0].strip()
312
+ if not body:
313
+ continue
314
+ parts = body.split("-")
315
+ if len(parts) != 2:
316
+ raise ValueError("区块表第 %d 行不是 LO-HI 形态:%r" % (lineno, line))
317
+ lo, hi = int(parts[0], 16), int(parts[1], 16)
318
+ if not (0 <= lo <= hi <= _MAX_CP):
319
+ raise ValueError("区块表第 %d 行区间非法:%r" % (lineno, line))
320
+ if out and lo <= out[-1][1] + 1:
321
+ raise ValueError("区块表第 %d 行未归并到最小(相邻/相交/乱序):%r"
322
+ % (lineno, line))
323
+ out.append((lo, hi))
324
+ if not out:
325
+ raise ValueError("区块表解析出空区间集(fail-closed:空表绝不放行)")
326
+ return out
327
+
328
+
329
+ # 生效条件:无入参,返回 (区间表, 错误说明)——文件可读且解析通过 → (tuple 区间表, None);
330
+ # 表缺失 / 不可读 / 解析失败 → ((), 原因)。后两者让判据 fail-closed(对一切字符 False)。
331
+ def _load_charset_blocks() -> tuple:
332
+ """**模块导入时读一次**(不做每调用 I/O);路径基准 = 本文件上溯两级(= 仓根)。"""
333
+ path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
334
+ "hive", "id_charset_blocks.txt")
335
+ try:
336
+ with open(path, encoding="utf-8") as fh:
337
+ text = fh.read()
338
+ except OSError as exc:
339
+ return (), "%s 不可读:%s" % (_CHARSET_BLOCKS_REL, exc)
340
+ try:
341
+ return tuple(_parse_charset_blocks(text)), None
342
+ except ValueError as exc:
343
+ return (), "%s 解析失败:%s" % (_CHARSET_BLOCKS_REL, exc)
344
+
345
+
346
+ #: 判据数据(模块导入时求值一次):区间表 + 读取/解析错误(错误非 None ⇒ 判据一律 False)。
347
+ CHARSET_BLOCKS, CHARSET_BLOCKS_ERROR = _load_charset_blocks()
348
+
349
+
350
+ # 生效条件:c 为单字符且其码点落在区间表内 → True;表缺失 / 坏行 / 空表 → False
351
+ # (fail-closed:绝不放行)。
352
+ def _charset_member(c: str) -> bool:
353
+ u = ord(c)
354
+ return any(lo <= u <= hi for lo, hi in CHARSET_BLOCKS)
355
+
356
+
357
+ # Windows 保留设备名(B4 拒收项;与 `job.rs::RESERVED_DEVICE_NAMES`、
358
+ # `mcp_server.RESERVED_DEVICE_NAMES` 逐项同集——md_cg 只复制这 22 个常量,**还是读数据
359
+ # 而不是 import hive**)。Win32 对**单个路径分量**做设备名解析且**忽略扩展名**
360
+ # (`CON.txt` 与 `CON` 同指设备),故比对的是「首个 `.` 之前」部分;比对**ASCII 大小写
361
+ # 不敏感**(不用 str.upper():它的 Unicode 折叠面比 Rust 的 eq_ignore_ascii_case 宽,
362
+ # 会让两侧分叉)。
363
+ RESERVED_DEVICE_NAMES = (
364
+ "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6",
365
+ "COM7", "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6",
366
+ "LPT7", "LPT8", "LPT9",
367
+ )
368
+ _ASCII_UPPER = str.maketrans("abcdefghijklmnopqrstuvwxyz",
369
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZ")
370
+
371
+
372
+ # 生效条件:s 的「首个 `.` 之前」部分与任一保留设备名 **ASCII 大小写不敏感**相等 → True
373
+ # (含 `CON.txt` 这类带扩展名形态)——与 rust `job::is_reserved_device_name` 同判。
374
+ def _is_reserved_device_name(s: str) -> bool:
375
+ base = s.split(".")[0].translate(_ASCII_UPPER)
376
+ return base in RESERVED_DEVICE_NAMES
377
+
378
+
379
+ # 生效条件:jid 为 str、长度 1..128、strip() 前后无差异(首尾无空白)、首尾字符都不是
380
+ # "."(拒 "."/".."/"..." 与 Win32 会剥尾点的 "abc." 形态)、逐字符过显式拒收集合后落在
381
+ # **区块白名单**([`_charset_member`],唯一真源 `hive/id_charset_blocks.txt`,与 hive 两侧
382
+ # 共读同一份数据)或属结构字符 `_` `-` `.`,且整 id 与其按 `_` 分段的任一段都不是
383
+ # Windows 保留设备名、且按 `_` 分段的**任一段都不等于** `.` 或 `..`(c7 段级)时返回 True,
384
+ # 否则 False。
385
+ def _valid_job_id(jid) -> bool:
386
+ r"""job_id 结构闸(N178,2026-09-28;2026-09-30 字符集判据换面为**区块白名单**)。
387
+
388
+ 同族家法:`hive/hive_mcp/mcp_server.py::_valid_job_id`、
389
+ `hive/src/job.rs::valid_job_id`(三处**共读同一份区块表**,不是三套常量)。
390
+ 本闸**保留**的既有放宽(都是既有对外契约,**不得**为「同口径」收紧):
391
+ · **不要求 "h" 前缀**——`md_cg/test_units_poll.py` 的 "j_empty"/"j_good"/
392
+ "j_fail" 是本模块对外契约(poll 面向任意宿主派发器写出的 job 目录),前缀
393
+ 收紧会误杀;
394
+ · 允许 `-`(hive 侧拒它:不在区块表内、也不是它的结构字符);
395
+ · 长度上限 128、首尾无空白、首尾非点(拒 "."/".."/"..." 与 "x." 尾点形态)
396
+ 一并保留。
397
+ 非 str 一律拒(**不** str() 归一:与 legacy P3 同纪律,未校验的强制转换会把对象
398
+ 形态洗成合法路径成分)。
399
+ 字符类判据 = **区块白名单**(与 hive 两侧同源)——白名单外的一切字符一律拒。
400
+ 放宽带来的静默风险**逐条显式挡住**(c9:「isalpha」类判据下 Cf/Cc 天然不入,但
401
+ **不能靠这个**):零宽 U+200B..U+200F/U+2060/U+FEFF、双向控制
402
+ U+202A..U+202E/U+2066..U+2069、控制字符(C0/DEL/C1)**逐条显式判**;`/` `\` `:`
403
+ 与 NUL 同样显式拒(路径成分:相对父段 "../outside"、分隔符、盘符/ADS、绝对路径
404
+ 会被 os.path.join 吸附);Windows 保留设备名(CON/PRN/AUX/NUL/COM1..9/LPT1..9,
405
+ 含 `CON.txt` 带扩展名形态与 ASCII 大小写变体)**整 id 与按 `_` 分段的任一段**逐名
406
+ 显式拒——旧 ASCII 白名单下 `CON` 这类全字母 id 是**放行**的,故这是本闸换面时
407
+ **收紧**的那一面(与 hive 两侧同集同判,语料 80-84 行逐例钉死)。
408
+ 表缺失 / 坏表 ⇒ 判据一律 False(fail-closed,绝不放行)。
409
+ 本闸本批**新增收紧**(c7,2026-09-30):按 `_` 分段的**任一段**等于 `.` 或 `..` 即拒
410
+ ——整串口径下 `h_.._x` / `h_a_.._b` 是被收的;段级是面向重构的纵深加固(段可能被当
411
+ 父目录段用),与 hive 两侧同判。范围只加这一条:空段(`h__x`)等形态不动。
412
+ """
413
+ if not isinstance(jid, str) or not jid or len(jid) > 128:
414
+ return False
415
+ if jid != jid.strip():
416
+ return False
417
+ if jid[0] == "." or jid[-1] == ".":
418
+ return False
419
+ for c in jid:
420
+ u = ord(c)
421
+ if u <= 0x1F or 0x7F <= u <= 0x9F or c in "/\\:":
422
+ return False # 控制字符(C0/DEL/C1,含 NUL)与路径成分(显式判,c9)
423
+ if "\u200b" <= c <= "\u200f" or c in ("\u2060", "\ufeff"):
424
+ return False # 零宽/不可见注入载体(显式判,c9)
425
+ if "\u202a" <= c <= "\u202e" or "\u2066" <= c <= "\u2069":
426
+ return False # 双向控制(显式判,c9)
427
+ if c in "_.-":
428
+ continue # 结构字符:槽分隔符 / 点(首尾点已在上方拒)/ 既有放宽的 `-`
429
+ if not _charset_member(c):
430
+ return False
431
+ # Windows 保留设备名(显式判,c7/c9):整 id(首个 `.` 之前部分,Win32 忽略扩展名)
432
+ # 与按 `_` 分段的**任一段**(Win32 的设备名解析是按路径分量做的)——与
433
+ # `job.rs::valid_job_id` / `mcp_server._job_id_reject_reason` 同集同判。
434
+ if _is_reserved_device_name(jid):
435
+ return False
436
+ if any(_is_reserved_device_name(seg) for seg in jid.split("_")):
437
+ return False
438
+ # 「. / ..」的**段级**判据(c7,2026-09-30 加固):按 `_` 分段,任一段等于 `.` 或 `..`
439
+ # 即拒——与 `job.rs::valid_job_id` / `mcp_server._job_id_reject_reason` **三处同判**。
440
+ # 段级而非整串(本面原有的「首尾非点」只挡整串与尾点形态):id 的段可能被宿主当
441
+ # **目录名**或**父目录段**用(契约 §四.4「目录可作它的父段」)。现场定性:`h_.._x` /
442
+ # `h_a_.._b` 这类「含 `..` 段但不以点结尾」的 id 拼出的路径停在池内、不构成穿越 ⇒
443
+ # 面向重构的纵深加固。范围**只加这一条**:不拒空段(`h__x`)等未被裁定的形态。
444
+ if any(seg in (".", "..") for seg in jid.split("_")):
445
+ return False
446
+ return True
447
+
448
+
449
+ # 生效条件:无入参,返回 job_id 非法的结构化拒绝 dict(ok=False、state="invalid_job_id"、terminal=False、job_dir/content/status 均为 None、error 含非法 id 与本闸口径),字段集与 poll 的返回同构。
450
+ def _bad_job_id(job_id, *, where: str) -> dict:
451
+ """非法 job_id 的统一拒答(poll/wait/submit 同构,便于调用方机械判别)。"""
452
+ return {"job_id": job_id, "job_dir": None, "state": "invalid_job_id",
453
+ "terminal": False, "ok": False, "content": None, "status": None,
454
+ "error": ("job_id 非法:%r(%s)——job_id 须为**单个路径分量**:区块白名单"
455
+ "(hive/id_charset_blocks.txt)内的字母/数字,或结构字符 `_` `-` `.`;"
456
+ "首尾非点,不含 / \\ : 与控制/零宽/双向控制字符/NUL,"
457
+ "且整 id 与按 `_` 分段的任一段都不得是 Windows 保留设备名"
458
+ "(CON/PRN/AUX/NUL/COM1..9/LPT1..9,含 CON.txt 形态)"
459
+ "(N178:拒 ../victim 型越池读 result.json、"
460
+ "拒任意目录建目录写 spec.json)" % (job_id, where))}
461
+
462
+
276
463
  # 生效条件:cg.root 与 cg.cg.root 都取不到真值时不写、直接返回 None;取到 root 时向 root/LOG_NAME 追加一行 rec(副本,setdefault ts)的 JSON,写入抛 OSError 时被吞掉静默返回 None。
277
464
  def _log(cg, rec: dict) -> None:
278
465
  """留痕 _units.jsonl(对齐 _crosscheck.jsonl / _backfill.jsonl 纪律)。"""
@@ -288,7 +475,7 @@ def _log(cg, rec: dict) -> None:
288
475
  pass
289
476
 
290
477
 
291
- # 生效条件:role 不在 ROLES 时返回 ok=False 的未知角色 error;否则 model_name(model) 为空时返回 ok=False 的未配置复核模型;否则 str(prompt or "").strip() 为空时返回 ok=False 的 prompt 为空;否则在 jobs_dir(jobs)/_job_id() 下写 spec.json 与 status.json(context_files 为真、temperature 非 None、extra 为真时才并入 spec),OSError 时返回 ok=False 的写入失败,全部成功返回 ok=True 与 job_id/job_dir/spec/model。
478
+ # 生效条件:role 不在 ROLES 时返回 ok=False 的未知角色 error;否则 model_name(model) 为空时返回 ok=False 的未配置复核模型;否则 str(prompt or "").strip() 为空时返回 ok=False 的 prompt 为空;否则落盘前先过 _valid_job_id 结构闸(未过返回 ok=False 的 job_id 非法,**不建目录不写文件**——N178:job_id 来源一旦不是 _job_id(),join 后就是「任意目录 + 生成名」的建目录/写文件原语);闸过则在 jobs_dir(jobs)/job_id 下写 spec.json 与 status.json(context_files 为真、temperature 非 None、extra 为真时才并入 spec),OSError 时返回 ok=False 的写入失败,全部成功返回 ok=True 与 job_id/job_dir/spec/model。
292
479
  def submit(*, prompt: str, role: str = REFLECT, model: str = "", system_prompt: str = "",
293
480
  context_files=None, timeout_s: int = DEFAULT_TIMEOUT_S,
294
481
  max_tokens: int = DEFAULT_MAX_TOKENS, temperature=None, jobs: str = "",
@@ -302,6 +489,10 @@ def submit(*, prompt: str, role: str = REFLECT, model: str = "", system_prompt:
302
489
  if not str(prompt or "").strip():
303
490
  return {"ok": False, "error": "prompt 为空"}
304
491
  jd, job_id = jobs_dir(jobs), _job_id()
492
+ # N178(2026-09-28):**落盘前**过闸——makedirs 之后再过就等于已经建了目录。
493
+ if not _valid_job_id(job_id):
494
+ return {"ok": False, "job_dir": None, "job_id": job_id,
495
+ "error": _bad_job_id(job_id, where="submit 落点")["error"]}
305
496
  d = os.path.join(jd, job_id)
306
497
  spec = {"model": mdl, "user_prompt": str(prompt), "system_prompt": str(system_prompt or ""),
307
498
  "timeout_s": int(timeout_s), "max_tokens": int(max_tokens),
@@ -330,10 +521,14 @@ def submit(*, prompt: str, role: str = REFLECT, model: str = "", system_prompt:
330
521
  "unit_role": role, "model": mdl}
331
522
 
332
523
 
333
- # 生效条件:jobs_dir(jobs)/str(job_id or "") 不是目录时返回 state=missing、terminal=False 的目录不存在 error;是目录时读 STATUS_FILE(读失败则 status 置 None)并用其 state 覆盖 state/terminal(state 属 TERMINAL_STATES 才 terminal=True);RESULT_FILE 被 isfile 命中则 terminal=True、state=st or "done",解析抛 OSError/ValueError 时提前返回该 error;解析为 dict 时取 ok/content/error/usage/model,且 ok 为真而 content 去空白为空时把 ok 改 False 并写空正文 error,解析为非 dict 时 ok=True 且 content 为原值。
524
+ # 生效条件:job_id 先过 _valid_job_id 结构闸(未过返回 state="invalid_job_id" 的结构化拒绝,**不触盘**——N178:修前 "../victim" 会被 join 成池外目录并读出其 result.json 全文);闸过时 jobs_dir(jobs)/job_id 不是目录返回 state=missing、terminal=False 的目录不存在 error;是目录时读 STATUS_FILE(读失败则 status 置 None)并用其 state 覆盖 state/terminal(state 属 TERMINAL_STATES 才 terminal=True);RESULT_FILE 被 isfile 命中则 terminal=True、state=st or "done",解析抛 OSError/ValueError 时提前返回该 error;解析为 dict 时取 ok/content/error/usage/model,且 ok 为真而 content 去空白为空时把 ok 改 False 并写空正文 error,解析为非 dict 时 ok=True 且 content 为原值。
334
525
  def poll(job_id: str, jobs: str = "") -> dict:
335
526
  """读 job 终态视图:**以 result.json 出现为终态主判据**,status.json 仅作辅助。"""
336
- d = os.path.join(jobs_dir(jobs), str(job_id or ""))
527
+ # N178(2026-09-28):调用方给的 job_id 是路径成分,**先过闸再 join**。
528
+ # 修前此处无闸,且 `str(job_id or "")` 还会把非 str 洗成合法成分。
529
+ if not _valid_job_id(job_id):
530
+ return _bad_job_id(job_id, where="poll 池路径成分")
531
+ d = os.path.join(jobs_dir(jobs), job_id)
337
532
  out = {"job_id": job_id, "job_dir": d, "state": "missing", "terminal": False,
338
533
  "ok": False, "content": None, "error": None, "status": None}
339
534
  if not os.path.isdir(d):
@@ -377,10 +572,13 @@ def poll(job_id: str, jobs: str = "") -> dict:
377
572
  return out
378
573
 
379
574
 
380
- # 生效条件:循环 poll(job_id, jobs),结果 terminal 为真即补 waited_s 后返回;否则 time.time()-t0 >= float(timeout_s) 时返回 terminal=False、timeout=True 与超时 error;两者皆不满足则 sleep(float(poll_s)) 后重试(timeout_s=0 时首次 poll 非终态即超时返回)。
575
+ # 生效条件:job_id 未过 _valid_job_id 结构闸时**立即**返回 poll 的同一拒绝结构(N178:否则非法 id 会空转满 timeout_s 才超时返回——拒绝不该以等待为代价);闸过时循环 poll(job_id, jobs),结果 terminal 为真即补 waited_s 后返回;否则 time.time()-t0 >= float(timeout_s) 时返回 terminal=False、timeout=True 与超时 error;两者皆不满足则 sleep(float(poll_s)) 后重试(timeout_s=0 时首次 poll 非终态即超时返回)。
381
576
  def wait(job_id: str, *, jobs: str = "", timeout_s: float = DEFAULT_TIMEOUT_S,
382
577
  poll_s: float = DEFAULT_POLL_S) -> dict:
383
578
  """阻塞等终态;超时如实返回(不假装成功、不强杀 job)。"""
579
+ if not _valid_job_id(job_id):
580
+ # N178(2026-09-28):fail-fast——复用 poll 的拒绝结构,绝不进轮询。
581
+ return poll(job_id, jobs)
384
582
  t0 = time.time()
385
583
  while True:
386
584
  cur = poll(job_id, jobs)
package/md_cg/weights.py CHANGED
@@ -405,8 +405,8 @@ def basis_trust(basis):
405
405
  def coverage_index(cg) -> dict:
406
406
  """入度表:nid → 被多少节点指向(覆盖度,O(N) 免读文件)。"""
407
407
  nodes = (getattr(cg, "index", None) or {}).get("nodes") or {}
408
- indeg = {nid: 0 for nid in nodes}
409
- for nid, e in nodes.items():
408
+ indeg = {nid: 0 for nid in list(nodes)}
409
+ for nid, e in list(nodes.items()):
410
410
  for t in _targets(e):
411
411
  if t in indeg and t != nid:
412
412
  indeg[t] += 1
@@ -422,7 +422,7 @@ def redundancy_map(cg, layer=None) -> dict:
422
422
  """
423
423
  nodes = (getattr(cg, "index", None) or {}).get("nodes") or {}
424
424
  seen, out = {}, {}
425
- for nid, e in nodes.items():
425
+ for nid, e in list(nodes.items()):
426
426
  if layer and e.get("layer") != layer:
427
427
  continue
428
428
  h = e.get("content_hash") or ""
@@ -480,7 +480,7 @@ def recalc(cg, layer=None, limit=None, apply=False, min_delta=APPLY_DELTA,
480
480
  nodes = (getattr(cg, "index", None) or {}).get("nodes") or {}
481
481
  indeg = coverage_index(cg)
482
482
  red = redundancy_map(cg, layer=layer)
483
- ids = [nid for nid, e in nodes.items()
483
+ ids = [nid for nid, e in list(nodes.items())
484
484
  if not layer or e.get("layer") == layer]
485
485
  ids.sort()
486
486
  if limit:
@@ -7,6 +7,14 @@
7
7
  衰减率 γ 按对象配置;任何在本模块之外出现的衰减核实现
8
8
  (exp(-t/τ)、×(1-factor)、EMA 保持率)都是 bug——
9
9
  由 tools/time_core_lint.py 按 E5 口径机械化审计。
10
+ (P3 落地:该审计器此前全仓不存在,现落在 **`md_cg/test_time_core_lint.py`**
11
+ ——`python -X utf8 -m md_cg.test_time_core_lint` 审计全仓四形态,指数核的
12
+ **调用式 `exp(-…` 与点调用 `.exp(…)/.exp()`(Rust 写法)两种形态都入面**;
13
+ 另带登记表 + 等价性断言:`links.py` 同族半衰期(G2),以及 **Rust 读侧
14
+ `rust/src/freshness.rs` 的指数核**(G11——从 Rust 源码抽常量与核表达式逐 Δt
15
+ 点与 `cred_factor` 对拍,因 Rust 无法被 Python 调用)。自带 `--mutate` 定点
16
+ 变异自证,其中 P4-M9 就是「把该 Rust 登记摘掉必须转红」那条。
17
+ **不新开顶层 `tools/` 目录**:那会改 `WORKSPACE_INDEX.md` 的结构面。)
10
18
  规范依据:docs/概念钉死批_GPT四点评审_v0.1.md 钉死 3。
11
19
  """
12
20
  import math
@@ -160,7 +160,7 @@ class KnowledgePointSplitter:
160
160
  existing = set()
161
161
  if self.cg is not None:
162
162
  pref = f"card:{card_id[:16]}"
163
- for nid, e in self.cg.index["nodes"].items():
163
+ for nid, e in list(self.cg.index["nodes"].items()):
164
164
  if pref not in (e.get("tags") or []):
165
165
  continue
166
166
  try:
@@ -32,7 +32,7 @@ import os
32
32
  import re
33
33
  import time
34
34
 
35
- from . import lifecycle
35
+ from . import lifecycle, protect
36
36
  from .fsutil import FileLock, atomic_write
37
37
 
38
38
  STATE_FILE = "_writelimit.json"
@@ -207,30 +207,64 @@ def record_accepted(cg, node_id, content, now=None) -> None:
207
207
  _save(cg, st)
208
208
 
209
209
 
210
- # 生效条件:cg.get(target) 为假值返回 {"ok": False, "error": "target_missing"};否则以 content 空白归一后截 80 字追加 "【聚合 stamp】" 行、fm["merge_count"]=int(fm.get("merge_count") or 0)+1 后 _write_node 落盘(重要性不变),并返回 {"ok": True, "merge_count": 新值}。
211
- def converge_into(cg, target: str, content: str) -> dict:
210
+ # 生效条件:cg.get(target) 为假值返回 {"ok": False, "error": "target_missing"};随后以节点真层/敏感度过 protect.guard_overwrite(层闸 + 保护闸,override 为真时先快照 + 审计);通过后按 forgetting.aggregate_line(单点)把新正文里目标还没有的行追加为聚合行、fm["merge_count"]=int(fm.get("merge_count") or 0)+1 后 _write_node 落盘(重要性不变),并把 session/actor 归属并列记进 fm(forgetting.note_source);返回 {"ok": True, "merge_count": 新值, "content_sink": 去向, "source": 归属}。
211
+ def converge_into(cg, target: str, content: str, override=False, actor=None,
212
+ session=None) -> dict:
212
213
  """同构聚合落库:正文追加一行【聚合】摘要,merge_count+1。
213
214
 
214
215
  importance **不变**——流水不该越聚越重要(与 forgetting.reinforce
215
- 的 +0.05 相反);原始内容截 80 字入行,全文仍在 recent log 可溯。
216
+ 的 +0.05 相反)。
217
+
218
+ B3(2026-09-30):追加内容改由 `forgetting.aggregate_line`(**单点**)
219
+ 生成——原口径 `" ".join(content.split())[:80]` 会把落在 80 字外的新值
220
+ 整体丢掉(P-3 实测:端口 9090 变体在库中 0 次落盘),与「新正文不许被
221
+ 静默丢弃」同病;现改为「目标正文还没有的行」逐行追加、不截断,纯重复
222
+ 零追加。**不变**的部分:目标节点不变、merge_count+1、importance 不动、
223
+ 层闸/保护闸仍在写盘之前。
224
+ H3:`session` 槽把新写入方归属并列落 fm(`forgetting.note_source`)。
225
+
226
+ N215(2026-09-28,同族未接线写点):本函数此前**裸调 `cg._write_node`**
227
+ 追加既有节点正文,唯一入口 `mdcos.remember_gated` 的 CONVERGE 分支(:3336)
228
+ 全程无 principal 层闸、无保护闸、无快照无审计。`writelimit.check` 的形参层
229
+ 判据(:141)只看调用方声明的 layer、签名映射落的是 `node_id`——攻击链:
230
+ 先以 `(node_id=<anchor 节点>, layer='contextual')` 预占位签名(该次 `add`
231
+ 被保护闸拒,但 `sig→anchor id` 已落盘),再换正文重发 → CONVERGE →
232
+ **anchor 层不可篡改节点被无痕追加聚合行**(可反复追加、merge_count 递增)。
233
+ 落盘前统一过 `protect.guard_overwrite`(单点)。
216
234
  """
217
235
  e = cg.get(target)
218
236
  if not e:
219
237
  return {"ok": False, "error": "target_missing"}
220
238
  fm = dict(e.get("frontmatter") or {})
221
- body = " ".join((content or "").split())[:80]
222
- stamp = time.strftime("%m-%d %H:%M", time.localtime())
239
+ protect.guard_overwrite(cg, target, layer=fm.get("layer"),
240
+ sensitivity=fm.get("sensitivity"),
241
+ override=override, actor=actor)
242
+ # 延迟导入:forgetting 是聚合行/归属的单点实现(writelimit 不反向被它依赖,
243
+ # 无环);放函数内避免模块导入期把 md_cg.forgetting 的依赖面(mdcg/bigrams)
244
+ # 拉进本模块的加载链。
245
+ from . import forgetting
246
+ new_body, line, line_no = forgetting.aggregate_line(e.get("content"), content)
223
247
  fm["merge_count"] = int(fm.get("merge_count") or 0) + 1
224
248
  fm["last_merge_at"] = time.time()
249
+ source = forgetting.note_source(fm, session=session, actor=actor)
225
250
  cg._write_node(target, os.path.join(cg.root, e["path"]), fm,
226
- (e.get("content") or "") + f"\n- 【聚合 {stamp}】{body}",
251
+ new_body if new_body is not None else (e.get("content") or ""),
227
252
  durable=True)
228
253
  entry = cg.index["nodes"].get(target)
229
254
  if entry is not None:
230
255
  entry.update({"merge_count": fm["merge_count"],
231
256
  "last_merge_at": fm["last_merge_at"]})
257
+ if fm.get("merge_sources"):
258
+ entry["merge_sources"] = fm["merge_sources"]
232
259
  cg._dirty[target] = entry
233
- return {"ok": True, "merge_count": fm["merge_count"]}
260
+ return {"ok": True, "merge_count": fm["merge_count"],
261
+ "content_sink": {
262
+ "node_id": target,
263
+ "action": "appended" if line else "already_covered",
264
+ "line": line, "line_no": line_no,
265
+ "chars": len(new_body if new_body is not None
266
+ else (e.get("content") or ""))},
267
+ "source": source}
234
268
 
235
269
 
236
270
  # ---------- 读侧:已落盘同构组的周期整理(sustain 巡检入口)----------