@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/tasks.py CHANGED
@@ -1,471 +1,470 @@
1
- """结构层任务实体 —— structural 层正式业务写入口(2026-09-16)。
2
-
3
- 使用者裁定(按既有记忆分层,三层分工):
4
-
5
- 任务 → structural 层(与「协议/自我/信任」同级;
6
- 跨会话稳定、不可遗忘——`DEMOTE_CONFIDENCE`
7
- 的降级面只覆盖 knowledge 层)
8
- 执行任务的中间信息 → contextual 层(不落库,由 AI 上下文自理)
9
- 任务知识 / 外部参考 / 交接文档 → knowledge 层(被验证的重要信息,按需调用;
10
- 节点 tags 记 `task:<slug>` 实现反向关联)
11
-
12
- 消除 AI 失忆四症状:
13
-
14
- ① 忘记已实现的工程 → `session_tasks()` 供 `session_recall` 装配
15
- 「进行中 + 近期完成」,新会话开机即见
16
- ② 计划与实际不符 → `plan_add()` 累积「计划变更」节,偏差可追溯
17
- ③ 缺核验 → 状态迁 `done` 时「结果」节必填(缺一不收,
18
- 对齐 `branches.BRANCH_MARKS` 的同款闸)
19
- ④ 换表述即新任务 → 身份判据 = 语义命名 slug(**刻意不用内容哈希**);
20
- `find_similar()` 只提示疑似同族,不自动合并
21
-
22
- 为什么身份不用内容哈希:`add_goal` 的 `goal_<sha1(text)>` 正是第 ④ 点的病根——
23
- 同一目标换个说法就变成新 goal、重复开工。任务名是使用者给的稳定标识,
24
- 重复登记必须更新原卡而不是再开一张。
25
-
26
- 与 goals 的关系:**不双写**。goals 是「检索定向槽」(谁该被检索到),任务是
27
- 「工程台账」(做到哪一步、结果是什么)——语义不同,双写必漂移(既有实证:
28
- 覆盖写会让库正文回退首版)。会话装配面各自成段。
29
-
30
- 与 branches 同哲学:库层函数收 `cg`;写盘走 `cg.add`(`require_layer_write`
31
- 权限闸自动生效,库层不绕闸);生命周期事件走 `cg._audit`(不新造日志格式,
32
- 审计失败绝不阻断主流程)。
33
-
34
- 正文格式 = CCG 六要素 + 三节(计划 / 计划变更 / 结果):
35
-
36
- # 功能名:<任务名>
37
- # 生效条件:<任务适用范围;填「无条件」则豁免资格判定中的正条件确认>
38
- # 子功能:<任务目标>
39
- # 执行:<状态>|<进度说明>
40
- # 验证方式:<验收判据>
41
- # 不适用条件:<边界>
42
-
43
- ## 计划
44
- ## 计划变更
45
- ## 结果
46
- """
47
- from __future__ import annotations
48
-
49
- import re
50
- import time
51
-
52
- TASK_STATUSES = ("active", "blocked", "done", "dropped")
53
-
54
- #: 状态中文投影(只用于正文可读性,机械判据始终用英文值)
55
- STATUS_ZH = {"active": "进行中", "blocked": "受阻", "done": "完成", "dropped": "放弃"}
56
-
57
- #: 任务节点统一标签(第一个是身份标记,第二个是任务族标记)
58
- TASK_TAG = "task"
59
- TASK_PREFIX = "task_"
60
-
61
- #: ≥0.7 会被 protect 自动打「不可遗忘」标记——即「任务结果必须得到维护」的
62
- #: 架构层保障:普通 forget 搬不走它,只有显式解保护才动得了。
63
- DEFAULT_IMPORTANCE = 0.8
64
-
65
- #: 正文节名
66
- SEC_PLAN = "计划"
67
- SEC_CHANGE = "计划变更"
68
- SEC_RESULT = "结果"
69
-
70
- #: 语义 slug 守卫——首字符须为中文/字母/数字,其后允许 `_ . -`,总长 ≤64。
71
- #: 与 `branches._BRANCH_RE` 同风格(防路径穿越与非法文件名),但放宽到 Unicode:
72
- #: 任务名多为中文,缩到 ASCII 会逼使用者起英文别名,反而制造第二个身份。
73
- _SLUG_RE = re.compile(r"^[0-9A-Za-z\u4e00-\u9fff][0-9A-Za-z\u4e00-\u9fff_.-]{0,63}$")
74
- _ILLEGAL_RE = re.compile(r"[^0-9A-Za-z\u4e00-\u9fff_.-]+")
75
-
76
- # ---------------------------------------------------------------- 命名与解析
77
-
78
- # 生效条件:name 为 None/空串或 strip 后为空时返回空串,否则把 `/`、`\`、`..` 及 _ILLEGAL_RE 命中字符折叠为 `-`、压缩连续 `-` 并去首尾 `-.` 后取前 64 字符再去首尾 `-.` 返回;
79
- def slugify(name: str) -> str:
80
- """任务名 → 语义 slug(稳定标识;同 slug 即同任务)。
81
-
82
- 只做「可安全落文件名」的归一:路径分隔符与非法字符折叠为 `-`,连续 `-`
83
- 压成一个。**不做语义改写**(不翻译、不去停用词)——slug 是对外可见的
84
- 身份,擅自改写会让使用者按原名检索时对不上号。
85
- """
86
- s = (name or "").strip()
87
- if not s:
88
- return ""
89
- s = s.replace("/", "-").replace("\\", "-").replace("..", "-")
90
- s = _ILLEGAL_RE.sub("-", s)
91
- s = re.sub(r"-{2,}", "-", s).strip("-.")
92
- return s[:64].strip("-.")
93
-
94
-
95
- # 生效条件:name(`name or ""` 后 strip)先剥掉已有的 TASK_PREFIX 再 slugify,结果非空且被 _SLUG_RE.match 命中时返回 TASK_PREFIX + slug;name 为假值或归一化后为空、不匹配时返回 ""。
96
- def task_node_id(name: str) -> str:
97
- """任务名或节点 id → 规范节点 id(`task_<slug>`)。非法名返回空串。"""
98
- s = (name or "").strip()
99
- if s.startswith(TASK_PREFIX):
100
- s = s[len(TASK_PREFIX):]
101
- slug = slugify(s)
102
- if not slug or not _SLUG_RE.match(slug):
103
- return ""
104
- return TASK_PREFIX + slug
105
-
106
-
107
- # 生效条件:v 为 None 或 str(v).strip() 为 "" 时返回 False,否则返回 True。
108
- def _has(v) -> bool:
109
- """「本次调用是否提供了该字段」——空串/None 一律视为未提供(不静默清空)。"""
110
- return v is not None and str(v).strip() != ""
111
-
112
-
113
- # 生效条件:无必需形参且无模块级常量约束,恒返回 time.strftime("%Y-%m-%d %H:%M") 的当前时间文本。
114
- def _today() -> str:
115
- return time.strftime("%Y-%m-%d %H:%M")
116
-
117
-
118
- # 生效条件:content(`content or ""` 后 splitlines)中有某行 strip 后以 `# ` + field + `:` 开头时,返回该行该前缀之后的去空白内容;content 为假值或无此匹配行时返回 ""。
119
- def _field_line(content: str, field: str) -> str:
120
- """从正文抽 `# <字段>:` 行的值(与 `mdcos._ccg_field` 同源口径,
121
- 本模块不反向 import mdcos,避免包内循环依赖)。"""
122
- pre = "# " + field + ":"
123
- for line in (content or "").splitlines():
124
- s = line.strip()
125
- if s.startswith(pre):
126
- return s[len(pre):].strip()
127
- return ""
128
-
129
-
130
- # 生效条件:(content or "") 的行中 strip 后以 "## " 开头者成为节名 s[3:].strip() 并切换当前节,其余行累入当前节,返回各节内容以 "\n" join 后 strip 的字典;无任何标题行时仅返回 {"__body__": 全篇 strip};content 为 None/空串时返回 {"__body__": ""}。
131
- def sections(content: str) -> dict:
132
- """正文 → `{节名: 节内容}`;无标题部分归入 `__body__`。"""
133
- out: dict = {"__body__": []}
134
- cur = "__body__"
135
- for line in (content or "").splitlines():
136
- s = line.strip()
137
- if s.startswith("## "):
138
- cur = s[3:].strip()
139
- out.setdefault(cur, [])
140
- continue
141
- out.setdefault(cur, []).append(line)
142
- return {k: "\n".join(v).strip() for k, v in out.items()}
143
-
144
-
145
- # 生效条件:t = (text or "").strip(),t 为 ""/"(无)"/"(未填)" 时返回 "",否则 len(t) <= n(默认 200)时返回 t,超出时返回 t[:n].rstrip() + "…"。
146
- def _brief(text: str, n: int = 200) -> str:
147
- t = (text or "").strip()
148
- if t in ("", "(无)", "(未填)"):
149
- return ""
150
- return t if len(t) <= n else t[:n].rstrip() + "…"
151
-
152
-
153
- # 生效条件:以 (old_text or "").strip() 为 base(base 为「(无)」或「(未填)」时置空),_has(change) 判定为假时返回 base or "(无)",为真时拼出 `- [今日] change.strip()`,base 非空返回 base+"\n"+该行再 strip,base 为空只返回该行;
154
- def _append_change(old_text: str, change: str) -> str:
155
- """「计划变更」节追加一行(累积式,不覆盖历史)。"""
156
- base = (old_text or "").strip()
157
- if base in ("(无)", "(未填)"):
158
- base = ""
159
- if not _has(change):
160
- return base or "(无)"
161
- line = "- [%s] %s" % (_today(), str(change).strip())
162
- return (base + "\n" + line).strip() if base else line
163
-
164
-
165
- # 生效条件:name 为必需形参(`name or ""` 后 strip 填 `# 功能名:` 行);condition、goal、acceptance、boundary、plan、changes、result 各经 _has 判定,未提供时分别落「无条件」「(未填:任务目标待补)」「other」「任务转 done/dropped 终态后不再作为进行中任务参与装配」「(未填)」「(无)」与空串;status 经 STATUS_ZH.get(status, status) 映射、未命中时原样输出,note 经 _has 为真时以 `|` 拼在执行行后。
166
- def render(name: str, *, condition: str = "", goal: str = "", status: str = "active",
167
- note: str = "", acceptance: str = "", boundary: str = "",
168
- plan: str = "", changes: str = "", result: str = "") -> str:
169
- """按 CCG 六要素 + 三节渲染任务正文。"""
170
- exec_line = STATUS_ZH.get(status, status)
171
- if _has(note):
172
- exec_line = exec_line + "|" + str(note).strip()
173
- return "\n".join([
174
- "# 功能名:%s" % (name or "").strip(),
175
- "# 生效条件:%s" % (condition.strip() if _has(condition) else "无条件"),
176
- "# 子功能:%s" % (goal.strip() if _has(goal) else "(未填:任务目标待补)"),
177
- "# 执行:%s" % exec_line,
178
- "# 验证方式:%s" % (acceptance.strip() if _has(acceptance) else "other"),
179
- "# 不适用条件:%s" % (boundary.strip() if _has(boundary)
180
- else "任务转 done/dropped 终态后不再作为进行中任务参与装配"),
181
- "",
182
- "## %s" % SEC_PLAN,
183
- (plan.strip() if _has(plan) else "(未填)"),
184
- "",
185
- "## %s" % SEC_CHANGE,
186
- (changes.strip() if _has(changes) else "(无)"),
187
- "",
188
- "## %s" % SEC_RESULT,
189
- (result.strip() if _has(result) else ""),
190
- "",
191
- ])
192
-
193
-
194
- # ---------------------------------------------------------------- 读写
195
-
196
- # 生效条件:nid 为假值(None/空串)时返回 None;否则 cg.get(nid) 命中且其 frontmatter.layer == "structural"、TASK_TAG 在 frontmatter.tags(`or []`)中时,返回 {id, fm, content, path, sec}(sec 为 sections(content));记录缺失或层/标签不符时返回 None。
197
- def _read_task(cg, nid: str):
198
- """读回任务卡;层或标签不符一律视为不存在(防串号:别的节点占用了同 id)。"""
199
- if not nid:
200
- return None
201
- rec = cg.get(nid)
202
- if not rec:
203
- return None
204
- fm = rec.get("frontmatter") or {}
205
- if fm.get("layer") != "structural" or TASK_TAG not in (fm.get("tags") or []):
206
- return None
207
- content = rec.get("content") or ""
208
- return {"id": nid, "fm": fm, "content": content,
209
- "path": rec.get("path"), "sec": sections(content)}
210
-
211
-
212
- # 生效条件:以 layer="structural"、tags=list(tags)、importance=float(importance)、override=True、task_name=name、task_status=status、task_updated_at=time.time()、actor=actor or "task" 等构成 kw,extra 为真值时经 kw.update(extra) 追加覆盖,随后调用 cg.add(nid, content, **kw) 并返回其结果;
213
- def _write(cg, nid: str, name: str, content: str, status: str, tags,
214
- importance: float, actor, extra: dict = None):
215
- """唯一写盘点——走 `cg.add`(权限闸 / 归属注入全部复用既有链路)。
216
-
217
- `override=True` 的含义:任务更新是**系统自身的幂等更新**(同 slug 即同任务),
218
- 显式声明该意图后,若节点已带 `protected`/`self_state` 标记,protect 会走
219
- 「旧版本快照 + 审计留痕」的放行分支而不是静默改写——任务台账天然获得版本快照。
220
- """
221
- kw = dict(layer="structural", tags=list(tags), importance=float(importance),
222
- confidence=1.0, verification_basis="other",
223
- override=True, consistency=False,
224
- task_name=name, task_status=status,
225
- task_updated_at=time.time(), actor=actor or "task")
226
- if extra:
227
- kw.update(extra)
228
- return cg.add(nid, content, **kw)
229
-
230
-
231
- # 生效条件:cg 具有 _audit 属性(getattr(cg, "_audit", None) 非 None)时以 (op, nid, **meta) 调用它,且其中抛出的任何异常被吞掉;cg 无该属性时不调用,两种路径均不返回内容。
232
- def _audit(cg, op: str, nid: str, **meta) -> None:
233
- """生命周期留痕;审计失败绝不阻断主流程(与 branches._audit 同哲学)。"""
234
- a = getattr(cg, "_audit", None)
235
- if a is None:
236
- return
237
- try:
238
- a(op, nid, **meta)
239
- except Exception: # noqa: BLE001
240
- pass
241
-
242
-
243
- # 生效条件:_read_task(cg, nid) 命中任务卡时返回摘要条目——id 取 rec['id'],name 取 fm.task_name 或(缺失/假值时)rec['id'],status 取 fm.task_status 或 "active",plan/changes/result 取对应节的 _brief 摘要(缺节回落 ""),并带 created_at/updated_at/path;未命中时返回 None。
244
- def _entry(cg, nid: str):
245
- """节点 id → 对外任务条目(摘要形态;全字段查 `get_task`)。"""
246
- rec = _read_task(cg, nid)
247
- if not rec:
248
- return None
249
- fm, sec = rec["fm"], rec["sec"]
250
- return {"id": rec["id"],
251
- "name": fm.get("task_name") or rec["id"],
252
- "status": fm.get("task_status") or "active",
253
- "plan": _brief(sec.get(SEC_PLAN, "")),
254
- "changes": _brief(sec.get(SEC_CHANGE, "")),
255
- "result": _brief(sec.get(SEC_RESULT, "")),
256
- "created_at": fm.get("task_created_at"),
257
- "updated_at": fm.get("task_updated_at"),
258
- "path": rec["path"]}
259
-
260
-
261
- # ---------------------------------------------------------------- 写操作
262
-
263
- # 生效条件:slugify(name) 为空串或不匹配 _SLUG_RE 时返回 {'ok': False, 含 slug 的非法名 error};否则 st = str(status if _has(status) else (旧卡 task_status or "active")).strip().lower(),st 不在 TASK_STATUSES 时返回未知状态错误,st == "done" 且合并后结果节 _has(new_res) 为假时返回「转 done 必须填结果」拒收,其余情况渲染写入并返回按 old 是否为 None 区分新建/更新的 out。
264
- def upsert(cg, name: str, *, plan: str = None, status: str = None,
265
- result: str = None, condition: str = None, goal: str = None,
266
- acceptance: str = None, boundary: str = None, change: str = None,
267
- note: str = None, tags=None, importance: float = None,
268
- actor: str = None) -> dict:
269
- """登记或更新一张任务卡(同 slug 即同任务;重复登记不新建卡)。
270
-
271
- 未提供的字段**沿用旧值**(不静默清空);`status="done"` 且合并后「结果」节
272
- 仍为空 → 拒收,盘上内容不变。
273
-
274
- :param note: 进度说明,落 `# 执行:` 行(`|` 之后)。
275
- :param change: 本次发现的新问题/计划偏差,追加到「计划变更」节。
276
- """
277
- slug = slugify(name)
278
- if not slug or not _SLUG_RE.match(slug):
279
- return {"ok": False,
280
- "error": "任务名非法(slug=%r);要求:中文/字母/数字起头,"
281
- "仅含中文/字母/数字/_ . -,长度 ≤64" % (slug,)}
282
-
283
- nid = TASK_PREFIX + slug
284
- old = _read_task(cg, nid)
285
- prev_fm = old["fm"] if old else {}
286
- prev = old["sec"] if old else {}
287
-
288
- display = (name or "").strip() or prev_fm.get("task_name") or slug
289
- st = str(status if _has(status) else (prev_fm.get("task_status") or "active")).strip().lower()
290
- if st not in TASK_STATUSES:
291
- return {"ok": False, "node_id": nid,
292
- "error": "未知任务状态:%r(可选 %s)" % (st, list(TASK_STATUSES))}
293
-
294
- # 未提供 → 沿用旧值(结果与计划绝不被静默清空)
295
- new_plan = plan if _has(plan) else prev.get(SEC_PLAN, "")
296
- new_res = result if _has(result) else prev.get(SEC_RESULT, "")
297
- new_cond = condition if _has(condition) else _field_line(old["content"], "生效条件") if old else ""
298
- new_goal = goal if _has(goal) else _field_line(old["content"], "子功能") if old else ""
299
- new_acc = acceptance if _has(acceptance) else _field_line(old["content"], "验证方式") if old else ""
300
- new_bnd = boundary if _has(boundary) else _field_line(old["content"], "不适用条件") if old else ""
301
- new_note = note if _has(note) else _exec_note(old["content"]) if old else ""
302
- new_changes = _append_change(prev.get(SEC_CHANGE, ""), change)
303
-
304
- if st == "done" and not _has(new_res):
305
- return {"ok": False, "node_id": nid,
306
- "error": "任务转 done 必须填写「结果」——结果不可缺失(缺一不收)",
307
- "hint": "补 result 后重试;盘上内容未变更"}
308
-
309
- content = render(display, condition=new_cond, goal=new_goal, status=st,
310
- note=new_note, acceptance=new_acc, boundary=new_bnd,
311
- plan=new_plan, changes=new_changes, result=new_res)
312
- tg = list(dict.fromkeys([TASK_TAG, "%s:%s" % (TASK_TAG, slug)]
313
- + [str(t) for t in (tags or []) if str(t).strip()]))
314
- imp = DEFAULT_IMPORTANCE if importance is None else float(importance)
315
- created = prev_fm.get("task_created_at") or time.time()
316
- _write(cg, nid, display, content, st, tg, imp, actor,
317
- extra={"task_created_at": created, "task_slug": slug})
318
- _audit(cg, "task_open" if old is None else "task_update", nid,
319
- status=st, slug=slug, name=display)
320
- out = {"ok": True, "node_id": nid, "name": display, "status": st,
321
- "created": old is None,
322
- "result_present": _has(new_res),
323
- "hint": "新建任务卡" if old is None else "已更新原卡(同 slug 即同任务)"}
324
- if slug != (name or "").strip():
325
- # 身份被归一化了就必须说出来——否则调用方按原名去找会找不到卡
326
- out["slug_normalized"] = slug
327
- return out
328
-
329
-
330
- # 生效条件:content 中 `# 执行:` 行的值含 "|" 时返回第一个 "|" 之后去空白的内容;content 为假值、无该行或该行值不含 "|" 时返回 ""。
331
- def _exec_note(content: str) -> str:
332
- """从 `# 执行:` 行取 `|` 之后的进度说明。"""
333
- val = _field_line(content, "执行")
334
- return val.split("|", 1)[1].strip() if "|" in val else ""
335
-
336
-
337
- # 生效条件:node_id 经 task_node_id 得到非空 nid 且 _read_task 命中该卡时,以旧卡 task_name(缺失/假值回落 nid)、status、result、note、actor 转调 upsert 并返回其结果;node_id 非法时返回 ok=False「非法任务标识」,卡不存在时返回 ok=False「任务不存在」。
338
- def set_status(cg, node_id: str, status: str, *, result: str = None,
339
- note: str = None, actor: str = None) -> dict:
340
- """任务状态迁移(进行中/受阻/完成/放弃)。迁 done 且无结果 → 拒收。"""
341
- nid = task_node_id(node_id)
342
- if not nid:
343
- return {"ok": False, "error": "非法任务标识:%r" % (node_id,)}
344
- old = _read_task(cg, nid)
345
- if not old:
346
- return {"ok": False, "node_id": nid, "error": "任务不存在:%s" % nid}
347
- return upsert(cg, old["fm"].get("task_name") or nid, status=status,
348
- result=result, note=note, actor=actor)
349
-
350
-
351
- # 生效条件:change 经 _has 判定为已提供、且 node_id 经 task_node_id 非空、_read_task 命中该卡时,以旧卡 task_name(缺失/假值回落 nid)与 change、actor 转调 upsert;change 为 None/空串/纯空白时返回 ok=False「计划变更内容为空」,node_id 非法或卡不存在时返回 ok=False 对应错误。
352
- def plan_add(cg, node_id: str, change: str, *, actor: str = None) -> dict:
353
- """计划变更追加——执行中发现的错误/新问题/偏差,累积进「计划变更」节。"""
354
- if not _has(change):
355
- return {"ok": False, "error": "计划变更内容为空"}
356
- nid = task_node_id(node_id)
357
- if not nid:
358
- return {"ok": False, "error": "非法任务标识:%r" % (node_id,)}
359
- old = _read_task(cg, nid)
360
- if not old:
361
- return {"ok": False, "node_id": nid, "error": "任务不存在:%s" % nid}
362
- return upsert(cg, old["fm"].get("task_name") or nid, change=change, actor=actor)
363
-
364
-
365
- # ---------------------------------------------------------------- 读操作
366
-
367
- # 生效条件:node_id 经 task_node_id 得到的 nid 对应一张 _read_task 命中的任务卡时返回 ok=True 的全字段(condition/goal/acceptance/boundary 由 _field_line 抽取、note 由 _exec_note 抽取、plan/changes/result 取节原文不截断、tags 取 fm.tags 或 []);否则返回 ok=False,error 中的标识取 nid 或原 node_id。
368
- def get_task(cg, node_id: str) -> dict:
369
- """单卡全字段读回(计划/变更/结果不截断)。"""
370
- nid = task_node_id(node_id)
371
- rec = _read_task(cg, nid)
372
- if not rec:
373
- return {"ok": False, "error": "任务不存在:%s" % (nid or node_id)}
374
- fm, sec = rec["fm"], rec["sec"]
375
- return {"ok": True, "id": rec["id"], "name": fm.get("task_name") or rec["id"],
376
- "status": fm.get("task_status") or "active",
377
- "condition": _field_line(rec["content"], "生效条件"),
378
- "goal": _field_line(rec["content"], "子功能"),
379
- "note": _exec_note(rec["content"]),
380
- "acceptance": _field_line(rec["content"], "验证方式"),
381
- "boundary": _field_line(rec["content"], "不适用条件"),
382
- "plan": sec.get(SEC_PLAN, ""),
383
- "changes": sec.get(SEC_CHANGE, ""),
384
- "result": sec.get(SEC_RESULT, ""),
385
- "tags": list(fm.get("tags") or []),
386
- "created_at": fm.get("task_created_at"),
387
- "updated_at": fm.get("task_updated_at"),
388
- "path": rec["path"]}
389
-
390
-
391
- # 生效条件:status 经 `str(status or "").strip().lower()` 为非空且不在 TASK_STATUSES 时返回 ok=False 未知状态;否则收集 cg.index["nodes"] 中各 _entry 可读任务、按 updated_at 倒序,status 非空时再按该状态过滤,total 为过滤后截断前的条数,limit 为真值时取前 max(int(limit), 1) 条——limit 为 None、0 或空串(假值)时不截断而返回全量 filtered 列表。
392
- def list_tasks(cg, status: str = None, limit: int = None) -> dict:
393
- """任务清单(按 updated_at 倒序);status 可选 active/blocked/done/dropped。"""
394
- st = str(status or "").strip().lower() or None
395
- if st and st not in TASK_STATUSES:
396
- return {"ok": False, "error": "未知任务状态:%r(可选 %s)" % (st, list(TASK_STATUSES))}
397
- items = []
398
- for nid in list((cg.index.get("nodes") or {}).keys()):
399
- t = _entry(cg, nid)
400
- if t:
401
- items.append(t)
402
- items.sort(key=lambda x: -(x.get("updated_at") or 0))
403
- if st:
404
- items = [t for t in items if t["status"] == st]
405
- total = len(items)
406
- if limit:
407
- items = items[:max(int(limit), 1)]
408
- return {"ok": True, "count": len(items), "total": total, "tasks": items}
409
-
410
-
411
- # 生效条件:遍历 cg.index["nodes"],把 _entry 可读且 status 为 active/blocked 的归入 active、status 为 done 的归入 done,各自按 updated_at 倒序;返回 active[:max(int(active_limit), 0)] 与 done[:max(int(done_limit), 0)](active_limit/done_limit 传 0 或负数时对应列表为空)以及两组截断前的 active_total/done_total。
412
- def session_tasks(cg, active_limit: int = 5, done_limit: int = 5) -> dict:
413
- """会话装配用:进行中(active/blocked)+ 近期完成(done,按 updated_at 倒序)。
414
-
415
- 这是第 ① 点(忘记已实现的工程)的机制解:上下文装配面在会话开始时把
416
- 「还没做完的」与「刚做完的」一起带出来,agent 不必先想到去查。
417
- """
418
- active, done = [], []
419
- for nid in list((cg.index.get("nodes") or {}).keys()):
420
- t = _entry(cg, nid)
421
- if not t:
422
- continue
423
- if t["status"] in ("active", "blocked"):
424
- active.append(t)
425
- elif t["status"] == "done":
426
- done.append(t)
427
- # 终键 id:同 updated_at 并列时定序,否则顺序回落到 cg.index["nodes"]
428
- # 的物理序(增量路径=写入序,重建路径=nid 序)。
429
- key = lambda x: (-(x.get("updated_at") or 0), # noqa: E731
430
- str(x.get("id") or ""))
431
- active.sort(key=key)
432
- done.sort(key=key)
433
- return {"ok": True,
434
- "active": active[:max(int(active_limit), 0)],
435
- "done": done[:max(int(done_limit), 0)],
436
- "active_total": len(active), "done_total": len(done)}
437
-
438
-
439
- # 生效条件:name 经 task_node_id 得 nid,exists 为同 id 任务卡是否被 _read_task 命中;k 经 `int(k or 5)` 再 max(…, 1)(k 为 None/0/空串回落 5,负数取 1)得到 kk,cg.search 抛异常时按无结果处理,只保留元素长度 ≥3、frontmatter.tags 含 TASK_TAG 且 node.id != nid 的条目,返回 out[:kk] 与 note。
440
- def find_similar(cg, name: str, k: int = 5) -> dict:
441
- """同族任务提示(**只提示,不自动合并**——是否同一任务由调用方裁决)。
442
-
443
- 身份判据是命名而非相似度:这里只回答「已经有一张同名的卡吗」与
444
- 「还有哪些卡看起来像」。相似度只能产生候选,不能授予「同一任务」的资格。
445
- """
446
- nid = task_node_id(name)
447
- exact = _read_task(cg, nid) is not None
448
- kk = max(int(k or 5), 1)
449
- res = []
450
- try:
451
- res, _meta = cg.search(name, layer="structural", k=kk + (1 if exact else 0),
452
- record=False)
453
- except Exception: # noqa: BLE001
454
- res = []
455
- out = []
456
- for item in res or []:
457
- if not (isinstance(item, (tuple, list)) and len(item) >= 3):
458
- continue
459
- node, score, qual = item[0], item[1], item[2]
460
- fm = (node.get("frontmatter") or {}) if isinstance(node, dict) else {}
461
- if TASK_TAG not in (fm.get("tags") or []):
462
- continue
463
- if node.get("id") == nid:
464
- continue
465
- out.append({"id": node.get("id"),
466
- "name": fm.get("task_name") or node.get("id"),
467
- "status": fm.get("task_status"),
468
- "score": round(float(score), 4),
469
- "qualification": (qual or {}).get("state") if isinstance(qual, dict) else None})
470
- return {"ok": True, "node_id": nid, "exists": exact, "similar": out[:kk],
1
+ """结构层任务实体 —— structural 层正式业务写入口(2026-09-16)。
2
+
3
+ 使用者裁定(按既有记忆分层,三层分工):
4
+
5
+ 任务 → structural 层(与「协议/自我/信任」同级;
6
+ 跨会话稳定、不可遗忘——`DEMOTE_CONFIDENCE`
7
+ 的降级面只覆盖 knowledge 层)
8
+ 执行任务的中间信息 → contextual 层(不落库,由 AI 上下文自理)
9
+ 任务知识 / 外部参考 / 交接文档 → knowledge 层(被验证的重要信息,按需调用;
10
+ 节点 tags 记 `task:<slug>` 实现反向关联)
11
+
12
+ 消除 AI 失忆四症状:
13
+
14
+ ① 忘记已实现的工程 → `session_tasks()` 供 `session_recall` 装配
15
+ 「进行中 + 近期完成」,新会话开机即见
16
+ ② 计划与实际不符 → `plan_add()` 累积「计划变更」节,偏差可追溯
17
+ ③ 缺核验 → 状态迁 `done` 时「结果」节必填(缺一不收,
18
+ 对齐 `branches.BRANCH_MARKS` 的同款闸)
19
+ ④ 换表述即新任务 → 身份判据 = 语义命名 slug(**刻意不用内容哈希**);
20
+ `find_similar()` 只提示疑似同族,不自动合并
21
+
22
+ 为什么身份不用内容哈希:`add_goal` 的 `goal_<sha1(text)>` 正是第 ④ 点的病根——
23
+ 同一目标换个说法就变成新 goal、重复开工。任务名是使用者给的稳定标识,
24
+ 重复登记必须更新原卡而不是再开一张。
25
+
26
+ 与 goals 的关系:**不双写**。goals 是「检索定向槽」(谁该被检索到),任务是
27
+ 「工程台账」(做到哪一步、结果是什么)——语义不同,双写必漂移(既有实证:
28
+ 覆盖写会让库正文回退首版)。会话装配面各自成段。
29
+
30
+ 与 branches 同哲学:库层函数收 `cg`;写盘走 `cg.add`(`require_layer_write`
31
+ 权限闸自动生效,库层不绕闸);生命周期事件走 `cg._audit`(不新造日志格式,
32
+ 审计失败绝不阻断主流程)。
33
+
34
+ 正文格式 = CCG 六要素 + 三节(计划 / 计划变更 / 结果):
35
+
36
+ # 功能名:<任务名>
37
+ # 生效条件:<任务适用范围;填「无条件」则豁免资格判定中的正条件确认>
38
+ # 子功能:<任务目标>
39
+ # 执行:<状态>|<进度说明>
40
+ # 验证方式:<验收判据>
41
+ # 不适用条件:<边界>
42
+
43
+ ## 计划
44
+ ## 计划变更
45
+ ## 结果
46
+ """
47
+ from __future__ import annotations
48
+
49
+ import re
50
+ import time
51
+
52
+ from . import nodefile
53
+
54
+ TASK_STATUSES = ("active", "blocked", "done", "dropped")
55
+
56
+ #: 状态中文投影(只用于正文可读性,机械判据始终用英文值)
57
+ STATUS_ZH = {"active": "进行中", "blocked": "受阻", "done": "完成", "dropped": "放弃"}
58
+
59
+ #: 任务节点统一标签(第一个是身份标记,第二个是任务族标记)
60
+ TASK_TAG = "task"
61
+ TASK_PREFIX = "task_"
62
+
63
+ #: ≥0.7 会被 protect 自动打「不可遗忘」标记——即「任务结果必须得到维护」的
64
+ #: 架构层保障:普通 forget 搬不走它,只有显式解保护才动得了。
65
+ DEFAULT_IMPORTANCE = 0.8
66
+
67
+ #: 正文节名
68
+ SEC_PLAN = "计划"
69
+ SEC_CHANGE = "计划变更"
70
+ SEC_RESULT = "结果"
71
+
72
+ #: 语义 slug 守卫——首字符须为中文/字母/数字,其后允许 `_ . -`,总长 ≤64。
73
+ #: 与 `branches._BRANCH_RE` 同风格(防路径穿越与非法文件名),但放宽到 Unicode:
74
+ #: 任务名多为中文,缩到 ASCII 会逼使用者起英文别名,反而制造第二个身份。
75
+ _SLUG_RE = re.compile(r"^[0-9A-Za-z\u4e00-\u9fff][0-9A-Za-z\u4e00-\u9fff_.-]{0,63}$")
76
+ _ILLEGAL_RE = re.compile(r"[^0-9A-Za-z\u4e00-\u9fff_.-]+")
77
+
78
+ # ---------------------------------------------------------------- 命名与解析
79
+
80
+ # 生效条件:name 为 None/空串或 strip 后为空时返回空串,否则把 `/`、`\`、`..` 及 _ILLEGAL_RE 命中字符折叠为 `-`、压缩连续 `-` 并去首尾 `-.` 后取前 64 字符再去首尾 `-.` 返回;
81
+ def slugify(name: str) -> str:
82
+ """任务名 → 语义 slug(稳定标识;同 slug 即同任务)。
83
+
84
+ 只做「可安全落文件名」的归一:路径分隔符与非法字符折叠为 `-`,连续 `-`
85
+ 压成一个。**不做语义改写**(不翻译、不去停用词)——slug 是对外可见的
86
+ 身份,擅自改写会让使用者按原名检索时对不上号。
87
+ """
88
+ s = (name or "").strip()
89
+ if not s:
90
+ return ""
91
+ s = s.replace("/", "-").replace("\\", "-").replace("..", "-")
92
+ s = _ILLEGAL_RE.sub("-", s)
93
+ s = re.sub(r"-{2,}", "-", s).strip("-.")
94
+ return s[:64].strip("-.")
95
+
96
+
97
+ # 生效条件:name(`name or ""` 后 strip)先剥掉已有的 TASK_PREFIX 再 slugify,结果非空且被 _SLUG_RE.match 命中时返回 TASK_PREFIX + slug;name 为假值或归一化后为空、不匹配时返回 ""。
98
+ def task_node_id(name: str) -> str:
99
+ """任务名或节点 id → 规范节点 id(`task_<slug>`)。非法名返回空串。"""
100
+ s = (name or "").strip()
101
+ if s.startswith(TASK_PREFIX):
102
+ s = s[len(TASK_PREFIX):]
103
+ slug = slugify(s)
104
+ if not slug or not _SLUG_RE.match(slug):
105
+ return ""
106
+ return TASK_PREFIX + slug
107
+
108
+
109
+ # 生效条件:v 为 None 或 str(v).strip() 为 "" 时返回 False,否则返回 True。
110
+ def _has(v) -> bool:
111
+ """「本次调用是否提供了该字段」——空串/None 一律视为未提供(不静默清空)。"""
112
+ return v is not None and str(v).strip() != ""
113
+
114
+
115
+ # 生效条件:无必需形参且无模块级常量约束,恒返回 time.strftime("%Y-%m-%d %H:%M") 的当前时间文本。
116
+ def _today() -> str:
117
+ return time.strftime("%Y-%m-%d %H:%M")
118
+
119
+
120
+ # 生效条件:委托 nodefile.ccg_field_value——content 含 `# field` 标题行时返回其值(冒号形态取行内值,无冒号取标题后首个非空非标题行);content 为假值或无该行时返回 ""。
121
+ def _field_line(content: str, field: str) -> str:
122
+ """从正文抽 `# <字段>` 行的值——委托 `nodefile.ccg_field_value`(判据与
123
+ 取值单点,2026-09-28 收口径;此前是本仓第三份同源实现)。返回契约保持
124
+ `str`(缺行回落空串)。本模块不 import mdcos(避免包内循环依赖),
125
+ nodefile 只依赖标准库,无环。"""
126
+ return nodefile.ccg_field_value(content, field) or ""
127
+
128
+
129
+ # 生效条件:(content or "") 的行中 strip 后以 "## " 开头者成为节名 s[3:].strip() 并切换当前节,其余行累入当前节,返回各节内容以 "\n" join 后 strip 的字典;无任何标题行时仅返回 {"__body__": 全篇 strip};content 为 None/空串时返回 {"__body__": ""}。
130
+ def sections(content: str) -> dict:
131
+ """正文 → `{节名: 节内容}`;无标题部分归入 `__body__`。"""
132
+ out: dict = {"__body__": []}
133
+ cur = "__body__"
134
+ for line in (content or "").splitlines():
135
+ s = line.strip()
136
+ if s.startswith("## "):
137
+ cur = s[3:].strip()
138
+ out.setdefault(cur, [])
139
+ continue
140
+ out.setdefault(cur, []).append(line)
141
+ return {k: "\n".join(v).strip() for k, v in out.items()}
142
+
143
+
144
+ # 生效条件:t = (text or "").strip(),t 为 ""/"(无)"/"(未填)" 时返回 "",否则 len(t) <= n(默认 200)时返回 t,超出时返回 t[:n].rstrip() + "…"。
145
+ def _brief(text: str, n: int = 200) -> str:
146
+ t = (text or "").strip()
147
+ if t in ("", "(无)", "(未填)"):
148
+ return ""
149
+ return t if len(t) <= n else t[:n].rstrip() + "…"
150
+
151
+
152
+ # 生效条件:以 (old_text or "").strip() 为 base(base 为「(无)」或「(未填)」时置空),_has(change) 判定为假时返回 base or "(无)",为真时拼出 `- [今日] change.strip()`,base 非空返回 base+"\n"+该行再 strip,base 为空只返回该行;
153
+ def _append_change(old_text: str, change: str) -> str:
154
+ """「计划变更」节追加一行(累积式,不覆盖历史)。"""
155
+ base = (old_text or "").strip()
156
+ if base in ("(无)", "(未填)"):
157
+ base = ""
158
+ if not _has(change):
159
+ return base or "(无)"
160
+ line = "- [%s] %s" % (_today(), str(change).strip())
161
+ return (base + "\n" + line).strip() if base else line
162
+
163
+
164
+ # 生效条件:name 为必需形参(`name or ""` 后 strip 填 `# 功能名:` 行);condition、goal、acceptance、boundary、plan、changes、result 各经 _has 判定,未提供时分别落「无条件」「(未填:任务目标待补)」「other」「任务转 done/dropped 终态后不再作为进行中任务参与装配」「(未填)」「(无)」与空串;status 经 STATUS_ZH.get(status, status) 映射、未命中时原样输出,note 经 _has 为真时以 `|` 拼在执行行后。
165
+ def render(name: str, *, condition: str = "", goal: str = "", status: str = "active",
166
+ note: str = "", acceptance: str = "", boundary: str = "",
167
+ plan: str = "", changes: str = "", result: str = "") -> str:
168
+ """按 CCG 六要素 + 三节渲染任务正文。"""
169
+ exec_line = STATUS_ZH.get(status, status)
170
+ if _has(note):
171
+ exec_line = exec_line + "|" + str(note).strip()
172
+ return "\n".join([
173
+ "# 功能名:%s" % (name or "").strip(),
174
+ "# 生效条件:%s" % (condition.strip() if _has(condition) else "无条件"),
175
+ "# 子功能:%s" % (goal.strip() if _has(goal) else "(未填:任务目标待补)"),
176
+ "# 执行:%s" % exec_line,
177
+ "# 验证方式:%s" % (acceptance.strip() if _has(acceptance) else "other"),
178
+ "# 不适用条件:%s" % (boundary.strip() if _has(boundary)
179
+ else "任务转 done/dropped 终态后不再作为进行中任务参与装配"),
180
+ "",
181
+ "## %s" % SEC_PLAN,
182
+ (plan.strip() if _has(plan) else "(未填)"),
183
+ "",
184
+ "## %s" % SEC_CHANGE,
185
+ (changes.strip() if _has(changes) else "(无)"),
186
+ "",
187
+ "## %s" % SEC_RESULT,
188
+ (result.strip() if _has(result) else ""),
189
+ "",
190
+ ])
191
+
192
+
193
+ # ---------------------------------------------------------------- 读写
194
+
195
+ # 生效条件:nid 为假值(None/空串)时返回 None;否则 cg.get(nid) 命中且其 frontmatter.layer == "structural"、TASK_TAG 在 frontmatter.tags(`or []`)中时,返回 {id, fm, content, path, sec}(sec 为 sections(content));记录缺失或层/标签不符时返回 None。
196
+ def _read_task(cg, nid: str):
197
+ """读回任务卡;层或标签不符一律视为不存在(防串号:别的节点占用了同 id)。"""
198
+ if not nid:
199
+ return None
200
+ rec = cg.get(nid)
201
+ if not rec:
202
+ return None
203
+ fm = rec.get("frontmatter") or {}
204
+ if fm.get("layer") != "structural" or TASK_TAG not in (fm.get("tags") or []):
205
+ return None
206
+ content = rec.get("content") or ""
207
+ return {"id": nid, "fm": fm, "content": content,
208
+ "path": rec.get("path"), "sec": sections(content)}
209
+
210
+
211
+ # 生效条件:以 layer="structural"、tags=list(tags)、importance=float(importance)、override=True、task_name=name、task_status=status、task_updated_at=time.time()、actor=actor or "task" 等构成 kw,extra 为真值时经 kw.update(extra) 追加覆盖,随后调用 cg.add(nid, content, **kw) 并返回其结果;
212
+ def _write(cg, nid: str, name: str, content: str, status: str, tags,
213
+ importance: float, actor, extra: dict = None):
214
+ """唯一写盘点——走 `cg.add`(权限闸 / 归属注入全部复用既有链路)。
215
+
216
+ `override=True` 的含义:任务更新是**系统自身的幂等更新**(同 slug 即同任务),
217
+ 显式声明该意图后,若节点已带 `protected`/`self_state` 标记,protect 会走
218
+ 「旧版本快照 + 审计留痕」的放行分支而不是静默改写——任务台账天然获得版本快照。
219
+ """
220
+ kw = dict(layer="structural", tags=list(tags), importance=float(importance),
221
+ confidence=1.0, verification_basis="other",
222
+ override=True, consistency=False,
223
+ task_name=name, task_status=status,
224
+ task_updated_at=time.time(), actor=actor or "task")
225
+ if extra:
226
+ kw.update(extra)
227
+ return cg.add(nid, content, **kw)
228
+
229
+
230
+ # 生效条件:cg 具有 _audit 属性(getattr(cg, "_audit", None) 非 None)时以 (op, nid, **meta) 调用它,且其中抛出的任何异常被吞掉;cg 无该属性时不调用,两种路径均不返回内容。
231
+ def _audit(cg, op: str, nid: str, **meta) -> None:
232
+ """生命周期留痕;审计失败绝不阻断主流程(与 branches._audit 同哲学)。"""
233
+ a = getattr(cg, "_audit", None)
234
+ if a is None:
235
+ return
236
+ try:
237
+ a(op, nid, **meta)
238
+ except Exception: # noqa: BLE001
239
+ pass
240
+
241
+
242
+ # 生效条件:_read_task(cg, nid) 命中任务卡时返回摘要条目——id 取 rec['id'],name 取 fm.task_name 或(缺失/假值时)rec['id'],status 取 fm.task_status 或 "active",plan/changes/result 取对应节的 _brief 摘要(缺节回落 ""),并带 created_at/updated_at/path;未命中时返回 None。
243
+ def _entry(cg, nid: str):
244
+ """节点 id → 对外任务条目(摘要形态;全字段查 `get_task`)。"""
245
+ rec = _read_task(cg, nid)
246
+ if not rec:
247
+ return None
248
+ fm, sec = rec["fm"], rec["sec"]
249
+ return {"id": rec["id"],
250
+ "name": fm.get("task_name") or rec["id"],
251
+ "status": fm.get("task_status") or "active",
252
+ "plan": _brief(sec.get(SEC_PLAN, "")),
253
+ "changes": _brief(sec.get(SEC_CHANGE, "")),
254
+ "result": _brief(sec.get(SEC_RESULT, "")),
255
+ "created_at": fm.get("task_created_at"),
256
+ "updated_at": fm.get("task_updated_at"),
257
+ "path": rec["path"]}
258
+
259
+
260
+ # ---------------------------------------------------------------- 写操作
261
+
262
+ # 生效条件:slugify(name) 为空串或不匹配 _SLUG_RE 时返回 {'ok': False, 含 slug 的非法名 error};否则 st = str(status if _has(status) else (旧卡 task_status or "active")).strip().lower(),st 不在 TASK_STATUSES 时返回未知状态错误,st == "done" 且合并后结果节 _has(new_res) 为假时返回「转 done 必须填结果」拒收,其余情况渲染写入并返回按 old 是否为 None 区分新建/更新的 out。
263
+ def upsert(cg, name: str, *, plan: str = None, status: str = None,
264
+ result: str = None, condition: str = None, goal: str = None,
265
+ acceptance: str = None, boundary: str = None, change: str = None,
266
+ note: str = None, tags=None, importance: float = None,
267
+ actor: str = None) -> dict:
268
+ """登记或更新一张任务卡(同 slug 即同任务;重复登记不新建卡)。
269
+
270
+ 未提供的字段**沿用旧值**(不静默清空);`status="done"` 且合并后「结果」节
271
+ 仍为空 → 拒收,盘上内容不变。
272
+
273
+ :param note: 进度说明,落 `# 执行:` 行(`|` 之后)。
274
+ :param change: 本次发现的新问题/计划偏差,追加到「计划变更」节。
275
+ """
276
+ slug = slugify(name)
277
+ if not slug or not _SLUG_RE.match(slug):
278
+ return {"ok": False,
279
+ "error": "任务名非法(slug=%r);要求:中文/字母/数字起头,"
280
+ "仅含中文/字母/数字/_ . -,长度 ≤64" % (slug,)}
281
+
282
+ nid = TASK_PREFIX + slug
283
+ old = _read_task(cg, nid)
284
+ prev_fm = old["fm"] if old else {}
285
+ prev = old["sec"] if old else {}
286
+
287
+ display = (name or "").strip() or prev_fm.get("task_name") or slug
288
+ st = str(status if _has(status) else (prev_fm.get("task_status") or "active")).strip().lower()
289
+ if st not in TASK_STATUSES:
290
+ return {"ok": False, "node_id": nid,
291
+ "error": "未知任务状态:%r(可选 %s)" % (st, list(TASK_STATUSES))}
292
+
293
+ # 未提供 → 沿用旧值(结果与计划绝不被静默清空)
294
+ new_plan = plan if _has(plan) else prev.get(SEC_PLAN, "")
295
+ new_res = result if _has(result) else prev.get(SEC_RESULT, "")
296
+ new_cond = condition if _has(condition) else _field_line(old["content"], "生效条件") if old else ""
297
+ new_goal = goal if _has(goal) else _field_line(old["content"], "子功能") if old else ""
298
+ new_acc = acceptance if _has(acceptance) else _field_line(old["content"], "验证方式") if old else ""
299
+ new_bnd = boundary if _has(boundary) else _field_line(old["content"], "不适用条件") if old else ""
300
+ new_note = note if _has(note) else _exec_note(old["content"]) if old else ""
301
+ new_changes = _append_change(prev.get(SEC_CHANGE, ""), change)
302
+
303
+ if st == "done" and not _has(new_res):
304
+ return {"ok": False, "node_id": nid,
305
+ "error": "任务转 done 必须填写「结果」——结果不可缺失(缺一不收)",
306
+ "hint": "补 result 后重试;盘上内容未变更"}
307
+
308
+ content = render(display, condition=new_cond, goal=new_goal, status=st,
309
+ note=new_note, acceptance=new_acc, boundary=new_bnd,
310
+ plan=new_plan, changes=new_changes, result=new_res)
311
+ tg = list(dict.fromkeys([TASK_TAG, "%s:%s" % (TASK_TAG, slug)]
312
+ + [str(t) for t in (tags or []) if str(t).strip()]))
313
+ imp = DEFAULT_IMPORTANCE if importance is None else float(importance)
314
+ created = prev_fm.get("task_created_at") or time.time()
315
+ _write(cg, nid, display, content, st, tg, imp, actor,
316
+ extra={"task_created_at": created, "task_slug": slug})
317
+ _audit(cg, "task_open" if old is None else "task_update", nid,
318
+ status=st, slug=slug, name=display)
319
+ out = {"ok": True, "node_id": nid, "name": display, "status": st,
320
+ "created": old is None,
321
+ "result_present": _has(new_res),
322
+ "hint": "新建任务卡" if old is None else "已更新原卡(同 slug 即同任务)"}
323
+ if slug != (name or "").strip():
324
+ # 身份被归一化了就必须说出来——否则调用方按原名去找会找不到卡
325
+ out["slug_normalized"] = slug
326
+ return out
327
+
328
+
329
+ # 生效条件:content 中 `# 执行:` 行的值含 "|" 时返回第一个 "|" 之后去空白的内容;content 为假值、无该行或该行值不含 "|" 时返回 ""。
330
+ def _exec_note(content: str) -> str:
331
+ """从 `# 执行:` 行取 `|` 之后的进度说明。"""
332
+ val = _field_line(content, "执行")
333
+ return val.split("|", 1)[1].strip() if "|" in val else ""
334
+
335
+
336
+ # 生效条件:node_id 经 task_node_id 得到非空 nid 且 _read_task 命中该卡时,以旧卡 task_name(缺失/假值回落 nid)、status、result、note、actor 转调 upsert 并返回其结果;node_id 非法时返回 ok=False「非法任务标识」,卡不存在时返回 ok=False「任务不存在」。
337
+ def set_status(cg, node_id: str, status: str, *, result: str = None,
338
+ note: str = None, actor: str = None) -> dict:
339
+ """任务状态迁移(进行中/受阻/完成/放弃)。迁 done 且无结果 → 拒收。"""
340
+ nid = task_node_id(node_id)
341
+ if not nid:
342
+ return {"ok": False, "error": "非法任务标识:%r" % (node_id,)}
343
+ old = _read_task(cg, nid)
344
+ if not old:
345
+ return {"ok": False, "node_id": nid, "error": "任务不存在:%s" % nid}
346
+ return upsert(cg, old["fm"].get("task_name") or nid, status=status,
347
+ result=result, note=note, actor=actor)
348
+
349
+
350
+ # 生效条件:change 经 _has 判定为已提供、且 node_id 经 task_node_id 非空、_read_task 命中该卡时,以旧卡 task_name(缺失/假值回落 nid)与 change、actor 转调 upsert;change 为 None/空串/纯空白时返回 ok=False「计划变更内容为空」,node_id 非法或卡不存在时返回 ok=False 对应错误。
351
+ def plan_add(cg, node_id: str, change: str, *, actor: str = None) -> dict:
352
+ """计划变更追加——执行中发现的错误/新问题/偏差,累积进「计划变更」节。"""
353
+ if not _has(change):
354
+ return {"ok": False, "error": "计划变更内容为空"}
355
+ nid = task_node_id(node_id)
356
+ if not nid:
357
+ return {"ok": False, "error": "非法任务标识:%r" % (node_id,)}
358
+ old = _read_task(cg, nid)
359
+ if not old:
360
+ return {"ok": False, "node_id": nid, "error": "任务不存在:%s" % nid}
361
+ return upsert(cg, old["fm"].get("task_name") or nid, change=change, actor=actor)
362
+
363
+
364
+ # ---------------------------------------------------------------- 读操作
365
+
366
+ # 生效条件:node_id 经 task_node_id 得到的 nid 对应一张 _read_task 命中的任务卡时返回 ok=True 的全字段(condition/goal/acceptance/boundary 由 _field_line 抽取、note 由 _exec_note 抽取、plan/changes/result 取节原文不截断、tags 取 fm.tags 或 []);否则返回 ok=False,error 中的标识取 nid 或原 node_id。
367
+ def get_task(cg, node_id: str) -> dict:
368
+ """单卡全字段读回(计划/变更/结果不截断)。"""
369
+ nid = task_node_id(node_id)
370
+ rec = _read_task(cg, nid)
371
+ if not rec:
372
+ return {"ok": False, "error": "任务不存在:%s" % (nid or node_id)}
373
+ fm, sec = rec["fm"], rec["sec"]
374
+ return {"ok": True, "id": rec["id"], "name": fm.get("task_name") or rec["id"],
375
+ "status": fm.get("task_status") or "active",
376
+ "condition": _field_line(rec["content"], "生效条件"),
377
+ "goal": _field_line(rec["content"], "子功能"),
378
+ "note": _exec_note(rec["content"]),
379
+ "acceptance": _field_line(rec["content"], "验证方式"),
380
+ "boundary": _field_line(rec["content"], "不适用条件"),
381
+ "plan": sec.get(SEC_PLAN, ""),
382
+ "changes": sec.get(SEC_CHANGE, ""),
383
+ "result": sec.get(SEC_RESULT, ""),
384
+ "tags": list(fm.get("tags") or []),
385
+ "created_at": fm.get("task_created_at"),
386
+ "updated_at": fm.get("task_updated_at"),
387
+ "path": rec["path"]}
388
+
389
+
390
+ # 生效条件:status 经 `str(status or "").strip().lower()` 为非空且不在 TASK_STATUSES 时返回 ok=False 未知状态;否则收集 cg.index["nodes"] 中各 _entry 可读任务、按 updated_at 倒序,status 非空时再按该状态过滤,total 为过滤后截断前的条数,limit 为真值时取前 max(int(limit), 1) 条——limit 为 None、0 或空串(假值)时不截断而返回全量 filtered 列表。
391
+ def list_tasks(cg, status: str = None, limit: int = None) -> dict:
392
+ """任务清单(按 updated_at 倒序);status 可选 active/blocked/done/dropped。"""
393
+ st = str(status or "").strip().lower() or None
394
+ if st and st not in TASK_STATUSES:
395
+ return {"ok": False, "error": "未知任务状态:%r(可选 %s)" % (st, list(TASK_STATUSES))}
396
+ items = []
397
+ for nid in list((cg.index.get("nodes") or {}).keys()):
398
+ t = _entry(cg, nid)
399
+ if t:
400
+ items.append(t)
401
+ items.sort(key=lambda x: -(x.get("updated_at") or 0))
402
+ if st:
403
+ items = [t for t in items if t["status"] == st]
404
+ total = len(items)
405
+ if limit:
406
+ items = items[:max(int(limit), 1)]
407
+ return {"ok": True, "count": len(items), "total": total, "tasks": items}
408
+
409
+
410
+ # 生效条件:遍历 cg.index["nodes"],把 _entry 可读且 status 为 active/blocked 的归入 active、status 为 done 的归入 done,各自按 updated_at 倒序;返回 active[:max(int(active_limit), 0)] 与 done[:max(int(done_limit), 0)](active_limit/done_limit 传 0 或负数时对应列表为空)以及两组截断前的 active_total/done_total。
411
+ def session_tasks(cg, active_limit: int = 5, done_limit: int = 5) -> dict:
412
+ """会话装配用:进行中(active/blocked)+ 近期完成(done,按 updated_at 倒序)。
413
+
414
+ 这是第 ① 点(忘记已实现的工程)的机制解:上下文装配面在会话开始时把
415
+ 「还没做完的」与「刚做完的」一起带出来,agent 不必先想到去查。
416
+ """
417
+ active, done = [], []
418
+ for nid in list((cg.index.get("nodes") or {}).keys()):
419
+ t = _entry(cg, nid)
420
+ if not t:
421
+ continue
422
+ if t["status"] in ("active", "blocked"):
423
+ active.append(t)
424
+ elif t["status"] == "done":
425
+ done.append(t)
426
+ # 终键 id:同 updated_at 并列时定序,否则顺序回落到 cg.index["nodes"]
427
+ # 的物理序(增量路径=写入序,重建路径=nid 序)。
428
+ key = lambda x: (-(x.get("updated_at") or 0), # noqa: E731
429
+ str(x.get("id") or ""))
430
+ active.sort(key=key)
431
+ done.sort(key=key)
432
+ return {"ok": True,
433
+ "active": active[:max(int(active_limit), 0)],
434
+ "done": done[:max(int(done_limit), 0)],
435
+ "active_total": len(active), "done_total": len(done)}
436
+
437
+
438
+ # 生效条件:name 经 task_node_id 得 nid,exists 为同 id 任务卡是否被 _read_task 命中;k 经 `int(k or 5)` 再 max(…, 1)(k 为 None/0/空串回落 5,负数取 1)得到 kk,cg.search 抛异常时按无结果处理,只保留元素长度 ≥3、frontmatter.tags 含 TASK_TAG 且 node.id != nid 的条目,返回 out[:kk] 与 note。
439
+ def find_similar(cg, name: str, k: int = 5) -> dict:
440
+ """同族任务提示(**只提示,不自动合并**——是否同一任务由调用方裁决)。
441
+
442
+ 身份判据是命名而非相似度:这里只回答「已经有一张同名的卡吗」与
443
+ 「还有哪些卡看起来像」。相似度只能产生候选,不能授予「同一任务」的资格。
444
+ """
445
+ nid = task_node_id(name)
446
+ exact = _read_task(cg, nid) is not None
447
+ kk = max(int(k or 5), 1)
448
+ res = []
449
+ try:
450
+ res, _meta = cg.search(name, layer="structural", k=kk + (1 if exact else 0),
451
+ record=False)
452
+ except Exception: # noqa: BLE001
453
+ res = []
454
+ out = []
455
+ for item in res or []:
456
+ if not (isinstance(item, (tuple, list)) and len(item) >= 3):
457
+ continue
458
+ node, score, qual = item[0], item[1], item[2]
459
+ fm = (node.get("frontmatter") or {}) if isinstance(node, dict) else {}
460
+ if TASK_TAG not in (fm.get("tags") or []):
461
+ continue
462
+ if node.get("id") == nid:
463
+ continue
464
+ out.append({"id": node.get("id"),
465
+ "name": fm.get("task_name") or node.get("id"),
466
+ "status": fm.get("task_status"),
467
+ "score": round(float(score), 4),
468
+ "qualification": (qual or {}).get("state") if isinstance(qual, dict) else None})
469
+ return {"ok": True, "node_id": nid, "exists": exact, "similar": out[:kk],
471
470
  "note": "仅提示疑似同族任务,不自动合并——终裁权在调用方"}