@hupan56/wlkj 3.0.0 → 3.1.3

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 (247) hide show
  1. package/bin/cli.js +1435 -1373
  2. package/package.json +1 -1
  3. package/templates/qoder/commands/optional/wl-insight.md +6 -4
  4. package/templates/qoder/commands/optional/wl-report.md +20 -39
  5. package/templates/qoder/commands/optional/wl-status.md +4 -4
  6. package/templates/qoder/commands/wl-code.md +2 -2
  7. package/templates/qoder/commands/wl-design.md +5 -5
  8. package/templates/qoder/commands/wl-init.md +3 -3
  9. package/templates/qoder/commands/wl-prd.md +26 -13
  10. package/templates/qoder/commands/wl-req.md +3 -3
  11. package/templates/qoder/commands/wl-search.md +78 -47
  12. package/templates/qoder/commands/wl-task.md +343 -40
  13. package/templates/qoder/commands/wl-test.md +10 -10
  14. package/templates/qoder/config.yaml +35 -13
  15. package/templates/qoder/hooks/post-tool-use.py +181 -0
  16. package/templates/qoder/hooks/session-start.py +82 -1
  17. package/templates/qoder/hooks/stop-eval.py +207 -0
  18. package/templates/qoder/hooks/user-prompt-submit.py +139 -0
  19. package/templates/qoder/rules/wl-pipeline.md +70 -8
  20. package/templates/qoder/scripts/README.md +78 -115
  21. package/templates/qoder/scripts/capability/__init__.py +26 -0
  22. package/templates/qoder/scripts/capability/__main__.py +72 -0
  23. package/templates/qoder/scripts/capability/adapters/__init__.py +29 -0
  24. package/templates/qoder/scripts/capability/adapters/cli.py +316 -0
  25. package/templates/qoder/scripts/capability/adapters/mcp.py +334 -0
  26. package/templates/qoder/scripts/capability/adapters/qw.py +271 -0
  27. package/templates/qoder/scripts/capability/caps/__init__.py +27 -0
  28. package/templates/qoder/scripts/capability/caps/context.py +36 -0
  29. package/templates/qoder/scripts/capability/caps/cron.py +50 -0
  30. package/templates/qoder/scripts/capability/caps/identity.py +43 -0
  31. package/templates/qoder/scripts/capability/caps/memory.py +71 -0
  32. package/templates/qoder/scripts/capability/caps/notify.py +41 -0
  33. package/templates/qoder/scripts/capability/caps/present.py +48 -0
  34. package/templates/qoder/scripts/capability/caps/repo.py +31 -0
  35. package/templates/qoder/scripts/capability/caps/sandbox.py +43 -0
  36. package/templates/qoder/scripts/capability/chain.py +92 -0
  37. package/templates/qoder/scripts/capability/memory_chain.py +41 -0
  38. package/templates/qoder/scripts/capability/registry.py +246 -0
  39. package/templates/qoder/scripts/capability/registry_mcp.py +250 -0
  40. package/templates/qoder/scripts/capability/smoke_test.py +211 -0
  41. package/templates/qoder/scripts/capability/smoke_test_report.json +94 -0
  42. package/templates/qoder/scripts/{report → deployment/setup}/__init__.py +11 -11
  43. package/templates/qoder/scripts/{setup → deployment/setup}/carriers.py +33 -26
  44. package/templates/qoder/scripts/deployment/setup/carriers_verify.py +148 -0
  45. package/templates/qoder/scripts/{setup → deployment/setup}/init_doctor.py +55 -23
  46. package/templates/qoder/scripts/{setup → deployment/setup}/install_qoderwork.py +20 -12
  47. package/templates/qoder/scripts/{setup → deployment/setup}/platform_doctor.py +8 -2
  48. package/templates/qoder/scripts/{setup → deployment/setup}/repo_root.py +113 -112
  49. package/templates/qoder/scripts/{setup → deployment/setup}/setup.py +35 -12
  50. package/templates/qoder/scripts/{setup → deployment/setup}/setup_lanhu.py +13 -7
  51. package/templates/qoder/scripts/domain/__init__.py +0 -0
  52. package/templates/qoder/scripts/domain/design/__init__.py +0 -0
  53. package/templates/qoder/scripts/{report → domain/design}/fill_prototype.py +12 -6
  54. package/templates/qoder/scripts/{report → domain/design}/gen_design_doc.py +8 -2
  55. package/templates/qoder/scripts/{setup → domain/kg}/__init__.py +11 -11
  56. package/templates/qoder/scripts/domain/kg/build/__init__.py +0 -0
  57. package/templates/qoder/scripts/{kg → domain/kg/build}/build_entity_registry.py +7 -2
  58. package/templates/qoder/scripts/{kg → domain/kg/build}/build_relations.py +7 -2
  59. package/templates/qoder/scripts/{kg → domain/kg/build}/build_style_index.py +13 -7
  60. package/templates/qoder/scripts/{kg → domain/kg/build}/build_workflows.py +7 -2
  61. package/templates/qoder/scripts/{kg → domain/kg/build}/kg_build.py +76 -9
  62. package/templates/qoder/scripts/{kg → domain/kg/build}/kg_build_db.py +7 -2
  63. package/templates/qoder/scripts/{kg → domain/kg/build}/kg_incremental.py +11 -6
  64. package/templates/qoder/scripts/{report → domain/kg/build}/learn_aggregate.py +9 -4
  65. package/templates/qoder/scripts/domain/kg/extract/__init__.py +0 -0
  66. package/templates/qoder/scripts/{common → domain/kg/extract}/extract.py +430 -419
  67. package/templates/qoder/scripts/{common → domain/kg/extract}/test_extract.py +115 -115
  68. package/templates/qoder/scripts/{common → domain/kg/extract}/ts_extract.py +614 -536
  69. package/templates/qoder/scripts/domain/kg/graph/__init__.py +0 -0
  70. package/templates/qoder/scripts/{kg → domain/kg/graph}/kg_link_db.py +7 -2
  71. package/templates/qoder/scripts/domain/kg/graph/kg_semantic.py +683 -0
  72. package/templates/qoder/scripts/{kg → domain/kg}/kg.py +263 -30
  73. package/templates/qoder/scripts/{common → domain/kg}/kg_capabilities.py +182 -182
  74. package/templates/qoder/scripts/domain/kg/search/__init__.py +0 -0
  75. package/templates/qoder/scripts/{kg → domain/kg/search}/context_pack.py +30 -9
  76. package/templates/qoder/scripts/{kg → domain/kg/search}/enrich_prompt.py +10 -4
  77. package/templates/qoder/scripts/{kg → domain/kg/search}/prefetch.py +33 -8
  78. package/templates/qoder/scripts/{common → domain/kg/search}/search_engine.py +207 -205
  79. package/templates/qoder/scripts/{kg → domain/kg/search}/search_index.py +143 -77
  80. package/templates/qoder/scripts/domain/kg/server/__init__.py +0 -0
  81. package/templates/qoder/scripts/domain/kg/server/kgd.py +549 -0
  82. package/templates/qoder/scripts/domain/kg/server/perf_bench.py +197 -0
  83. package/templates/qoder/scripts/domain/kg/storage/__init__.py +0 -0
  84. package/templates/qoder/scripts/{kg → domain/kg/storage}/kg_duckdb.py +38 -14
  85. package/templates/qoder/scripts/domain/learning/__init__.py +0 -0
  86. package/templates/qoder/scripts/{report → domain/learning}/learn.py +8 -3
  87. package/templates/qoder/scripts/{mcp → domain/report}/__init__.py +11 -11
  88. package/templates/qoder/scripts/{report → domain/report}/add_session.py +9 -3
  89. package/templates/qoder/scripts/{report → domain/report}/export.py +10 -5
  90. package/templates/qoder/scripts/{report → domain/report}/report.py +9 -4
  91. package/templates/qoder/scripts/domain/report/report_snapshot.py +261 -0
  92. package/templates/qoder/scripts/{report → domain/report}/role.py +10 -4
  93. package/templates/qoder/scripts/{report → domain/report}/status.py +112 -9
  94. package/templates/qoder/scripts/domain/report/status_snapshot.py +191 -0
  95. package/templates/qoder/scripts/domain/requirement/__init__.py +0 -0
  96. package/templates/qoder/scripts/{report → domain/requirement}/archive_prd.py +8 -2
  97. package/templates/qoder/scripts/{report → domain/requirement}/req.py +228 -222
  98. package/templates/qoder/scripts/{kg → domain/task}/__init__.py +11 -11
  99. package/templates/qoder/scripts/domain/task/__pycache__/syncgate.cpython-39.pyc +0 -0
  100. package/templates/qoder/scripts/{task → domain/task}/git_sync.py +40 -21
  101. package/templates/qoder/scripts/{task → domain/task}/syncgate.py +13 -8
  102. package/templates/qoder/scripts/{task → domain/task}/task.py +13 -5
  103. package/templates/qoder/scripts/{task → domain/task}/task_lifecycle.py +606 -596
  104. package/templates/qoder/scripts/{task → domain/task}/task_query.py +162 -161
  105. package/templates/qoder/scripts/{task → domain/task}/task_relations.py +425 -424
  106. package/templates/qoder/scripts/{task → domain/task}/team_sync.py +9 -4
  107. package/templates/qoder/scripts/domain/task/zentao_sync.py +370 -0
  108. package/templates/qoder/scripts/foundation/__init__.py +0 -0
  109. package/templates/qoder/scripts/foundation/__pycache__/__init__.cpython-39.pyc +0 -0
  110. package/templates/qoder/scripts/foundation/__pycache__/bootstrap.cpython-39.pyc +0 -0
  111. package/templates/qoder/scripts/{common → foundation}/bootstrap.py +10 -10
  112. package/templates/qoder/scripts/foundation/core/__init__.py +0 -0
  113. package/templates/qoder/scripts/foundation/core/__pycache__/__init__.cpython-39.pyc +0 -0
  114. package/templates/qoder/scripts/foundation/core/__pycache__/paths.cpython-39.pyc +0 -0
  115. package/templates/qoder/scripts/{common → foundation/core}/cmd_registry.py +1 -1
  116. package/templates/qoder/scripts/{common → foundation/core}/config.py +1 -1
  117. package/templates/qoder/scripts/{common → foundation/core}/paths.py +706 -586
  118. package/templates/qoder/scripts/foundation/data/__init__.py +0 -0
  119. package/templates/qoder/scripts/{common → foundation/data}/contract.py +2 -2
  120. package/templates/qoder/scripts/{common → foundation/data}/events.py +2 -2
  121. package/templates/qoder/scripts/{common → foundation/data}/result.py +1 -1
  122. package/templates/qoder/scripts/{common → foundation/data}/task_utils.py +427 -404
  123. package/templates/qoder/scripts/foundation/identity/__init__.py +0 -0
  124. package/templates/qoder/scripts/foundation/identity/__pycache__/__init__.cpython-39.pyc +0 -0
  125. package/templates/qoder/scripts/foundation/identity/__pycache__/identity.cpython-39.pyc +0 -0
  126. package/templates/qoder/scripts/{common → foundation/identity}/check_publish.py +103 -98
  127. package/templates/qoder/scripts/{common → foundation/identity}/developer.py +239 -239
  128. package/templates/qoder/scripts/{common → foundation/identity}/guard.py +2 -2
  129. package/templates/qoder/scripts/{common → foundation/identity}/identity.py +14 -10
  130. package/templates/qoder/scripts/{common → foundation/identity}/roles.py +1 -1
  131. package/templates/qoder/scripts/foundation/integrations/__init__.py +0 -0
  132. package/templates/qoder/scripts/{common → foundation/integrations}/active_task.py +30 -15
  133. package/templates/qoder/scripts/{common → foundation/integrations}/feishu.py +2 -2
  134. package/templates/qoder/scripts/{common → foundation/integrations}/terms.py +3 -3
  135. package/templates/qoder/scripts/foundation/integrations/zentao_client.py +220 -0
  136. package/templates/qoder/scripts/foundation/io/__init__.py +0 -0
  137. package/templates/qoder/scripts/foundation/io/__pycache__/__init__.cpython-39.pyc +0 -0
  138. package/templates/qoder/scripts/foundation/io/__pycache__/atomicio.cpython-39.pyc +0 -0
  139. package/templates/qoder/scripts/foundation/io/__pycache__/filelock.cpython-39.pyc +0 -0
  140. package/templates/qoder/scripts/{common → foundation/io}/filelock.py +1 -1
  141. package/templates/qoder/scripts/{common → foundation/io}/reqid.py +1 -1
  142. package/templates/qoder/scripts/foundation/protocol/__init__.py +0 -0
  143. package/templates/qoder/scripts/foundation/protocol/duckdb_conn.py +39 -0
  144. package/templates/qoder/scripts/{common → foundation/protocol}/mcp_base.py +1 -1
  145. package/templates/qoder/scripts/foundation/utils/__init__.py +0 -0
  146. package/templates/qoder/scripts/{common → foundation/utils}/eval_api.py +12 -1
  147. package/templates/qoder/scripts/{common → foundation/utils}/pip_install.py +23 -6
  148. package/templates/qoder/scripts/{common → foundation/utils}/platform_guard.py +1 -1
  149. package/templates/qoder/scripts/orchestration/__init__.py +0 -0
  150. package/templates/qoder/scripts/orchestration/wlkj.py +185 -0
  151. package/templates/qoder/scripts/protocol/__init__.py +0 -0
  152. package/templates/qoder/scripts/protocol/browser/README.md +207 -0
  153. package/templates/qoder/scripts/protocol/browser/SKILL.md +265 -0
  154. package/templates/qoder/scripts/protocol/browser/config.env +9 -0
  155. package/templates/qoder/scripts/protocol/browser/references/cdp-api.md +110 -0
  156. package/templates/qoder/scripts/protocol/browser/references/migration-2.5.3.md +72 -0
  157. package/templates/qoder/scripts/protocol/browser/references/site-patterns/.gitkeep +0 -0
  158. package/templates/qoder/scripts/protocol/browser/scripts/browser-discovery.mjs +138 -0
  159. package/templates/qoder/scripts/protocol/browser/scripts/cdp-proxy.mjs +672 -0
  160. package/templates/qoder/scripts/protocol/browser/scripts/check-deps.mjs +206 -0
  161. package/templates/qoder/scripts/protocol/browser/scripts/find-url.mjs +253 -0
  162. package/templates/qoder/scripts/protocol/browser/scripts/match-site.mjs +46 -0
  163. package/templates/qoder/scripts/protocol/browser/templates/config.env.template +9 -0
  164. package/templates/qoder/scripts/protocol/mcp/__init__.py +11 -0
  165. package/templates/qoder/scripts/protocol/mcp/kg_mcp_server.py +478 -0
  166. package/templates/qoder/scripts/{mcp → protocol/mcp}/lanhu_stdio_wrapper.py +11 -5
  167. package/templates/qoder/scripts/{mcp → protocol/mcp}/mcp_doctor.py +47 -14
  168. package/templates/qoder/scripts/protocol/mcp/mcp_launcher.py +600 -0
  169. package/templates/qoder/scripts/{mcp → protocol/mcp}/mysql_mcp_server.py +181 -67
  170. package/templates/qoder/scripts/protocol/mcp/zentao_mcp_server.py +1920 -0
  171. package/templates/qoder/scripts/protocol/transports/__init__.py +56 -0
  172. package/templates/qoder/scripts/protocol/transports/base.py +87 -0
  173. package/templates/qoder/scripts/protocol/transports/cli.py +132 -0
  174. package/templates/qoder/scripts/protocol/transports/http.py +141 -0
  175. package/templates/qoder/scripts/protocol/transports/stdio.py +293 -0
  176. package/templates/qoder/scripts/run_weekly_update.bat +27 -18
  177. package/templates/qoder/scripts/run_weekly_update.sh +22 -13
  178. package/templates/qoder/scripts/validation/__init__.py +0 -0
  179. package/templates/qoder/scripts/validation/eval/__init__.py +0 -0
  180. package/templates/qoder/scripts/validation/eval/perf-report-2026-06-24.md +139 -0
  181. package/templates/qoder/scripts/validation/eval/qwork_harness.py +617 -0
  182. package/templates/qoder/scripts/validation/eval/report-commands.md +220 -0
  183. package/templates/qoder/scripts/validation/eval/transcript_timing.py +386 -0
  184. package/templates/qoder/scripts/validation/metrics/__init__.py +0 -0
  185. package/templates/qoder/scripts/{report → validation/metrics}/eval_prd.py +17 -10
  186. package/templates/qoder/scripts/validation/metrics/usability_score.py +750 -0
  187. package/templates/qoder/scripts/validation/test/__init__.py +11 -0
  188. package/templates/qoder/scripts/validation/test/assertion_gen.py +551 -0
  189. package/templates/qoder/scripts/{test → validation/test}/autotest.py +64 -4
  190. package/templates/qoder/scripts/{common → validation/test}/autotest_auth.py +1 -1
  191. package/templates/qoder/scripts/{test → validation/test}/autotest_batch.py +16 -3
  192. package/templates/qoder/scripts/{test → validation/test}/autotest_data.py +17 -12
  193. package/templates/qoder/scripts/validation/test/autotest_opencli.py +646 -0
  194. package/templates/qoder/scripts/{test → validation/test}/autotest_run.py +17 -3
  195. package/templates/qoder/scripts/validation/test/autotest_webaccess.py +571 -0
  196. package/templates/qoder/scripts/{test → validation/test}/benchmark.py +12 -6
  197. package/templates/qoder/scripts/{test → validation/test}/kg_auto_login.py +8 -2
  198. package/templates/qoder/scripts/{test → validation/test}/kg_test_runner.py +7 -2
  199. package/templates/qoder/scripts/{test → validation/test}/page_probe.py +8 -3
  200. package/templates/qoder/scripts/validation/test/smoke_all_commands.py +170 -0
  201. package/templates/qoder/settings.json +30 -0
  202. package/templates/qoder/skills/design-import/SKILL.md +22 -13
  203. package/templates/qoder/skills/design-review/SKILL.md +19 -10
  204. package/templates/qoder/skills/prd-generator/SKILL.md +22 -18
  205. package/templates/qoder/skills/prd-review/SKILL.md +6 -4
  206. package/templates/qoder/skills/prototype-generator/SKILL.md +63 -14
  207. package/templates/qoder/skills/spec-coder/SKILL.md +22 -13
  208. package/templates/qoder/skills/spec-generator/SKILL.md +22 -10
  209. package/templates/qoder/skills/test-generator/SKILL.md +7 -5
  210. package/templates/qoder/skills/wl-code/SKILL.md +23 -11
  211. package/templates/qoder/skills/wl-commit/SKILL.md +9 -8
  212. package/templates/qoder/skills/wl-design/SKILL.md +5 -3
  213. package/templates/qoder/skills/wl-init/SKILL.md +6 -6
  214. package/templates/qoder/skills/wl-insight/SKILL.md +23 -10
  215. package/templates/qoder/skills/wl-prd-full/SKILL.md +57 -6
  216. package/templates/qoder/skills/wl-prd-quick/SKILL.md +3 -3
  217. package/templates/qoder/skills/wl-prd-review/SKILL.md +16 -4
  218. package/templates/qoder/skills/wl-report/SKILL.md +25 -23
  219. package/templates/qoder/skills/wl-search/SKILL.md +149 -78
  220. package/templates/qoder/skills/wl-spec/SKILL.md +7 -5
  221. package/templates/qoder/skills/wl-status/SKILL.md +48 -16
  222. package/templates/qoder/skills/wl-task/SKILL.md +92 -10
  223. package/templates/qoder/skills/wl-test/SKILL.md +75 -29
  224. package/templates/qoder/templates/prd-full-template.md +7 -0
  225. package/templates/root/AGENTS.md +275 -236
  226. package/templates/root/requirements.txt +3 -0
  227. package/templates/root//344/275/277/347/224/250/350/257/264/346/230/216.md +3 -3
  228. package/templates/root//346/226/260/346/211/213/346/214/207/345/215/227.md +2 -2
  229. package/templates/.qoder/.runtime/ctx-cache-5660152f1d6dd819.md +0 -23
  230. package/templates/.qoder/.runtime/ctx-cache-afdce0dac06b25b0.md +0 -23
  231. package/templates/.qoder/.runtime/search-cache-eae7644e7b122f35.txt +0 -1
  232. package/templates/.qoder/learning/eval-history.jsonl +0 -28
  233. package/templates/data/index/wiki-index.json +0 -8
  234. package/templates/qoder/nul +0 -4
  235. package/templates/qoder/scripts/kg/extract.py +0 -398
  236. package/templates/qoder/scripts/kg/kg_semantic.py +0 -156
  237. package/templates/qoder/scripts/mcp/kg_mcp_server.py +0 -606
  238. package/templates/qoder/scripts/mcp/mcp_launcher.py +0 -442
  239. package/templates/qoder/scripts/mcp/zentao_mcp_server.py +0 -384
  240. package/templates/qoder/scripts/task/__init__.py +0 -11
  241. package/templates/qoder/scripts/test/__init__.py +0 -11
  242. package/templates/qoder/scripts/wlkj.py +0 -116
  243. /package/templates/qoder/scripts/{common → deployment}/__init__.py +0 -0
  244. /package/templates/qoder/scripts/{common → domain/kg/graph}/graph_traverse.py +0 -0
  245. /package/templates/qoder/scripts/{common → domain/kg/server}/repowiki.py +0 -0
  246. /package/templates/qoder/scripts/{common → foundation/io}/atomicio.py +0 -0
  247. /package/templates/qoder/scripts/{test → validation/test}/secure-ls.js +0 -0
@@ -1,424 +1,425 @@
1
- #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
- """task_relations.py - task 关系/排期层 (v3.0 f6 从 task.py 拆出)"""
4
- # v3.0 路径自举
5
- import os as _o, sys as _s, argparse
6
- from pathlib import Path
7
- from datetime import datetime
8
- _cp = _o.path.join(_o.path.dirname(_o.path.dirname(_o.path.abspath(__file__))), 'common')
9
- if _cp not in _s.path: _s.path.insert(0, _cp)
10
- from bootstrap import setup; setup()
11
-
12
- import os, sys, json, shutil
13
- from datetime import datetime
14
- from pathlib import Path
15
- from common.paths import (
16
- DIR_TASKS, DIR_ARCHIVE, FILE_TASK_JSON,
17
- get_repo_root, get_developer, get_tasks_dir,
18
- )
19
- from common.active_task import (
20
- clear_active_task, resolve_active_task, resolve_context_key, set_active_task,
21
- )
22
- from common.task_utils import (
23
- resolve_task_dir, run_task_hooks, load_task_json, write_task_json,
24
- modify_task_json, assert_can_modify_task,
25
- )
26
- from common.atomicio import atomic_write_json, safe_read_json, atomic_write_text
27
-
28
-
29
- def cmd_add_subtask(args: argparse.Namespace) -> int:
30
- """添加子任务关联。"""
31
- repo_root = get_repo_root()
32
- parent_dir = resolve_task_dir(args.parent, repo_root)
33
- child_dir = resolve_task_dir(args.child, repo_root)
34
-
35
- if not parent_dir.is_dir():
36
- print(f"Error: Parent task not found: {args.parent}", file=sys.stderr)
37
- return 1
38
- if not child_dir.is_dir():
39
- print(f"Error: Child task not found: {args.child}", file=sys.stderr)
40
- return 1
41
-
42
- # 零信任 ACL: 只有 parent 的 creator/assignee/admin 能加子任务
43
- try:
44
- assert_can_modify_task(parent_dir, "add-subtask", repo_root)
45
- except PermissionError as e:
46
- print(str(e), file=sys.stderr)
47
- return 4
48
-
49
- parent_data = load_task_json(parent_dir)
50
- child_data = load_task_json(child_dir)
51
-
52
- if not parent_data or not child_data:
53
- print("Error: Missing task.json", file=sys.stderr)
54
- return 1
55
-
56
- child_name = child_dir.name
57
- children = parent_data.get("children", [])
58
- if child_name not in children:
59
- children.append(child_name)
60
- parent_data["children"] = children
61
- parent_data["updated_at"] = datetime.now().isoformat()
62
- write_task_json(parent_dir, parent_data)
63
-
64
- # 子任务记录父任务
65
- child_data["parent"] = parent_dir.name
66
- child_data["updated_at"] = datetime.now().isoformat()
67
- write_task_json(child_dir, child_data)
68
-
69
- print(f"Subtask added: {parent_dir.name} -> {child_dir.name}")
70
- return 0
71
-
72
-
73
- def cmd_remove_subtask(args: argparse.Namespace) -> int:
74
- """移除子任务关联。"""
75
- repo_root = get_repo_root()
76
- parent_dir = resolve_task_dir(args.parent, repo_root)
77
- child_dir = resolve_task_dir(args.child, repo_root)
78
-
79
- # 零信任 ACL
80
- if parent_dir.is_dir():
81
- try:
82
- assert_can_modify_task(parent_dir, "remove-subtask", repo_root)
83
- except PermissionError as e:
84
- print(str(e), file=sys.stderr)
85
- return 4
86
-
87
- parent_data = load_task_json(parent_dir)
88
- child_data = load_task_json(child_dir)
89
-
90
- if parent_data:
91
- children = parent_data.get("children", [])
92
- if child_dir.name in children:
93
- children.remove(child_dir.name)
94
- parent_data["children"] = children
95
- parent_data["updated_at"] = datetime.now().isoformat()
96
- write_task_json(parent_dir, parent_data)
97
-
98
- if child_data:
99
- child_data["parent"] = None
100
- child_data["updated_at"] = datetime.now().isoformat()
101
- write_task_json(child_dir, child_data)
102
-
103
- print(f"Subtask removed: {parent_dir.name} -x- {child_dir.name}")
104
- return 0
105
-
106
-
107
- # =============================================================================
108
- # 工具函数
109
- # =============================================================================
110
-
111
- def _generate_slug(title: str) -> str:
112
- """从标题生成 URL-safe slug。"""
113
- # 简单处理: 小写, 空格转连字符, 只保留字母数字和连字符
114
- slug = title.lower().strip()
115
- slug = "".join(c if c.isalnum() or c in " -_" else "" for c in slug)
116
- slug = slug.replace(" ", "-").replace("_", "-")
117
- slug = "-".join(part for part in slug.split("-") if part)
118
- return slug[:50] if slug else "untitled"
119
-
120
-
121
- # =============================================================================
122
- # 命令: set-due / block / unblock / gantt (B2 能力增强)
123
- # =============================================================================
124
-
125
- def cmd_set_due(args: argparse.Namespace) -> int:
126
- """设置任务截止日期。用法: task.py set-due <task> <YYYY-MM-DD>"""
127
- repo_root = get_repo_root()
128
- full_path = resolve_task_dir(args.task, repo_root)
129
- if not full_path.is_dir():
130
- print(f"Error: Task not found: {args.task}", file=sys.stderr)
131
- return 1
132
- # ACL
133
- try:
134
- assert_can_modify_task(full_path, "set-due", repo_root)
135
- except PermissionError as e:
136
- print(str(e), file=sys.stderr)
137
- return 4
138
- def _set_due_mutator(data):
139
- data["due_date"] = args.due_date
140
- data["updated_at"] = datetime.now().isoformat()
141
- if not modify_task_json(full_path, _set_due_mutator):
142
- print("Error: Missing task.json", file=sys.stderr)
143
- return 1
144
- print(f"Set due_date: {args.due_date} ({full_path.name})")
145
- return 0
146
-
147
-
148
- def cmd_reassign(args: argparse.Namespace) -> int:
149
- """改派任务负责人。用法: task.py reassign <task> <new_assignee>
150
-
151
- 场景: PM 建任务后改派给开发(多角色协作必需)。
152
- ACL: 只有 creator/admin 能改派(防恶意抢占/甩锅)。
153
- 保留改派历史(assignee_history), 便于追溯。
154
- """
155
- repo_root = get_repo_root()
156
- full_path = resolve_task_dir(args.task, repo_root)
157
- if not full_path.is_dir():
158
- print(f"Error: Task not found: {args.task}", file=sys.stderr)
159
- return 1
160
- # ACL: 只有 creator/admin 能改派 (assignee 自己不能转手甩锅)
161
- try:
162
- assert_can_modify_task(full_path, "reassign", repo_root)
163
- except PermissionError as e:
164
- print(str(e), file=sys.stderr)
165
- return 4
166
-
167
- new_assignee = (args.new_assignee or "").strip()
168
- if not new_assignee:
169
- print("Error: new_assignee 不能为空", file=sys.stderr)
170
- return 1
171
-
172
- changed = {"from": None, "to": new_assignee}
173
-
174
- def _reassign_mutator(data):
175
- old = data.get("assignee")
176
- changed["from"] = old
177
- data["assignee"] = new_assignee
178
- data["updated_at"] = datetime.now().isoformat()
179
- # 记录改派历史 (追溯用)
180
- hist = data.get("assignee_history") or []
181
- hist.append({
182
- "from": old,
183
- "to": new_assignee,
184
- "by": get_developer(repo_root),
185
- "at": datetime.now().isoformat(),
186
- })
187
- data["assignee_history"] = hist
188
-
189
- if not modify_task_json(full_path, _reassign_mutator):
190
- print("Error: Missing task.json", file=sys.stderr)
191
- return 1
192
-
193
- if changed["from"] == new_assignee:
194
- print(f"Note: assignee 已是 {new_assignee}, 无变化 ({full_path.name})")
195
- else:
196
- print(f"Reassigned: {changed['from']} -> {new_assignee} ({full_path.name})")
197
- print(f" 改派人: {get_developer(repo_root)} (已记录到 assignee_history)")
198
- return 0
199
-
200
-
201
- def cmd_block(args: argparse.Namespace) -> int:
202
- """标记任务被另一任务阻塞。自动维护反向 blocks 关系。
203
- 用法: task.py block <task> <blocked-by-task>
204
- 语义: <task> <blocked-by-task> 阻塞 (要等它完成)。
205
- """
206
- repo_root = get_repo_root()
207
- task_dir = resolve_task_dir(args.task, repo_root)
208
- blocker_dir = resolve_task_dir(args.blocker, repo_root)
209
- if not task_dir.is_dir():
210
- print(f"Error: Task not found: {args.task}", file=sys.stderr)
211
- return 1
212
- if not blocker_dir.is_dir():
213
- print(f"Error: Blocker task not found: {args.blocker}", file=sys.stderr)
214
- return 1
215
- # ACL: 只有 task 的 owner 能加依赖
216
- try:
217
- assert_can_modify_task(task_dir, "block", repo_root)
218
- except PermissionError as e:
219
- print(str(e), file=sys.stderr)
220
- return 4
221
-
222
- # 检测循环依赖
223
- if _would_create_cycle(task_dir.name, blocker_dir.name, repo_root):
224
- print(f"Error: 循环依赖检测到 ({blocker_dir.name} 已直接/间接依赖 {task_dir.name})",
225
- file=sys.stderr)
226
- return 1
227
-
228
- # 检查是否已存在依赖关系 (先读, 幂等)
229
- task_data = load_task_json(task_dir)
230
- if not task_data:
231
- print("Error: Missing task.json", file=sys.stderr)
232
- return 1
233
- if blocker_dir.name in (task_data.get("blocked_by") or []):
234
- print(f"Already blocked by {blocker_dir.name}")
235
- return 0
236
-
237
- # 锁内更新 task.blocked_by
238
- def _block_mutator(data):
239
- blocked_by = data.get("blocked_by") or []
240
- if blocker_dir.name not in blocked_by:
241
- blocked_by.append(blocker_dir.name)
242
- data["blocked_by"] = blocked_by
243
- data["updated_at"] = datetime.now().isoformat()
244
- modify_task_json(task_dir, _block_mutator)
245
-
246
- # 锁内反向更新 blocker.blocks
247
- def _block_reverse_mutator(data):
248
- blocks = data.get("blocks") or []
249
- if task_dir.name not in blocks:
250
- blocks.append(task_dir.name)
251
- data["blocks"] = blocks
252
- data["updated_at"] = datetime.now().isoformat()
253
- modify_task_json(blocker_dir, _block_reverse_mutator)
254
-
255
- print(f"{task_dir.name} now blocked by {blocker_dir.name}")
256
- return 0
257
-
258
-
259
- def cmd_unblock(args: argparse.Namespace) -> int:
260
- """移除阻塞关系。用法: task.py unblock <task> <blocked-by-task>"""
261
- repo_root = get_repo_root()
262
- task_dir = resolve_task_dir(args.task, repo_root)
263
- blocker_dir = resolve_task_dir(args.blocker, repo_root)
264
- if task_dir.is_dir():
265
- try:
266
- assert_can_modify_task(task_dir, "unblock", repo_root)
267
- except PermissionError as e:
268
- print(str(e), file=sys.stderr)
269
- return 4
270
-
271
- removed = False
272
- # 锁内从 task.blocked_by 删
273
- if task_dir.is_dir():
274
- def _unblock_mutator(data):
275
- nonlocal removed
276
- if blocker_dir.name in (data.get("blocked_by") or []):
277
- data["blocked_by"] = [x for x in (data.get("blocked_by") or []) if x != blocker_dir.name]
278
- data["updated_at"] = datetime.now().isoformat()
279
- removed = True
280
- modify_task_json(task_dir, _unblock_mutator)
281
-
282
- # 锁内从 blocker.blocks 删
283
- if blocker_dir.is_dir():
284
- def _unblock_reverse_mutator(data):
285
- nonlocal removed
286
- if task_dir.name in (data.get("blocks") or []):
287
- data["blocks"] = [x for x in (data.get("blocks") or []) if x != task_dir.name]
288
- data["updated_at"] = datetime.now().isoformat()
289
- removed = True
290
- modify_task_json(blocker_dir, _unblock_reverse_mutator)
291
-
292
- if removed:
293
- print(f"Removed block: {task_dir.name} no longer blocked by {blocker_dir.name}")
294
- else:
295
- print(f"No such block relationship found")
296
- return 0
297
-
298
-
299
- def _would_create_cycle(task_name: str, blocker_name: str, repo_root) -> bool:
300
- """检测添加 task←blocker 后是否会形成循环依赖。
301
- 即: blocker 是否(直接/间接)已经依赖 task?
302
- """
303
- if task_name == blocker_name:
304
- return True
305
- # BFS: 从 blocker 出发, 沿 blocked_by 走, 看能否回到 task
306
- visited = set()
307
- queue = [blocker_name]
308
- tasks_dir = get_tasks_dir(repo_root)
309
- while queue:
310
- current = queue.pop(0)
311
- if current in visited:
312
- continue
313
- visited.add(current)
314
- if current == task_name:
315
- return True
316
- cur_dir = tasks_dir / current
317
- data = load_task_json(cur_dir) if cur_dir.is_dir() else None
318
- if data:
319
- for dep in (data.get("blocked_by") or []):
320
- if dep not in visited:
321
- queue.append(dep)
322
- return False
323
-
324
-
325
- def _is_task_ready(task_data: dict) -> bool:
326
- """任务是否"可立即开始": 未完成 无未完成的 blocked_by。"""
327
- if task_data.get("status") == "completed":
328
- return False
329
- blocked_by = task_data.get("blocked_by") or []
330
- # blocked_by 为空 = 可开始 (这里简化: 不深入查依赖是否完成, 由调用方保证)
331
- return len(blocked_by) == 0
332
-
333
-
334
- def cmd_gantt(args: argparse.Namespace) -> int:
335
- """渲染 markdown 甘特图 (按 start_date/due_date 排期)。
336
- 用法: task.py gantt [--weeks 4]
337
- """
338
- repo_root = get_repo_root()
339
- tasks_dir = get_tasks_dir(repo_root)
340
- if not tasks_dir.is_dir():
341
- print("No tasks directory")
342
- return 0
343
-
344
- from datetime import date, timedelta
345
- today = date.today()
346
- weeks = args.weeks if hasattr(args, "weeks") else 4
347
- end = today + timedelta(weeks=weeks)
348
-
349
- # 收集有日期的任务
350
- items = []
351
- for d in sorted(tasks_dir.iterdir()):
352
- if not d.is_dir():
353
- continue
354
- data = load_task_json(d)
355
- if not data:
356
- continue
357
- due = data.get("due_date")
358
- start = data.get("start_date")
359
- if not due and not start:
360
- continue
361
- items.append({
362
- "name": d.name,
363
- "title": data.get("title", d.name),
364
- "start": start,
365
- "due": due,
366
- "status": data.get("status", "?"),
367
- "assignee": data.get("assignee", "?"),
368
- })
369
-
370
- if not items:
371
- print("No tasks with dates. 用 'task.py set-due <task> <YYYY-MM-DD>' 添加截止日期。")
372
- return 0
373
-
374
- # 按开始/截止日期排序
375
- items.sort(key=lambda x: (x.get("start") or x.get("due") or "9999"))
376
-
377
- print(f"# 任务排期 ({today} ~ {end})\n")
378
- print("| 任务 | 负责人 | 开始 | 截止 | 状态 |")
379
- print("|------|--------|------|------|------|")
380
- for it in items:
381
- start = it.get("start") or "-"
382
- due = it.get("due") or "-"
383
- # 标记逾期
384
- if it.get("due") and it["status"] != "completed":
385
- try:
386
- due_date = date.fromisoformat(it["due"])
387
- if due_date < today:
388
- due = f"⚠️ {due} (逾期)"
389
- except ValueError:
390
- pass
391
- print(f"| {it['name']} | {it['assignee']} | {start} | {due} | {it['status']} |")
392
-
393
- # 简单时间轴 (ASCII)
394
- print(f"\n## 时间轴 ({weeks} 周)\n")
395
- print("```")
396
- for it in items:
397
- start_s = it.get("start") or it.get("due")
398
- if not start_s:
399
- continue
400
- try:
401
- start_d = date.fromisoformat(start_s) if it.get("start") else today
402
- except ValueError:
403
- start_d = today
404
- due_s = it.get("due")
405
- try:
406
- due_d = date.fromisoformat(due_s) if due_s else start_d + timedelta(days=7)
407
- except ValueError:
408
- due_d = start_d + timedelta(days=7)
409
- # 计算相对今天的偏移
410
- start_off = max(0, (start_d - today).days)
411
- due_off = min(weeks * 7, (due_d - today).days)
412
- if due_off < 0:
413
- bar = "[逾期]" + "=" * 5
414
- else:
415
- bar = " " * (start_off // 2) + "[" + "=" * max(1, (due_off - start_off) // 2) + "]"
416
- print(f"{it['name'][:20]:20} {bar}")
417
- print("```")
418
- return 0
419
-
420
-
421
- # =============================================================================
422
- # Main
423
- # =============================================================================
424
-
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """task_relations.py - task 关系/排期层 (v3.0 f6 从 task.py 拆出)"""
4
+ # v3.0 路径自举
5
+ import os as _o, sys as _s, argparse
6
+ from pathlib import Path
7
+ from datetime import datetime
8
+ _cp = _o.path.join(_o.path.dirname(_o.path.dirname(_o.path.abspath(__file__))), 'foundation')
9
+ if _cp not in _s.path: _s.path.insert(0, _cp)
10
+ from bootstrap import setup; setup()
11
+ from foundation.core.paths import get_repo_root
12
+
13
+ import os, sys, json, shutil
14
+ from datetime import datetime
15
+ from pathlib import Path
16
+ from foundation.core.paths import (
17
+ DIR_TASKS, DIR_ARCHIVE, FILE_TASK_JSON,
18
+ get_repo_root, get_developer, get_tasks_dir,
19
+ )
20
+ from foundation.integrations.active_task import (
21
+ clear_active_task, resolve_active_task, resolve_context_key, set_active_task,
22
+ )
23
+ from foundation.data.task_utils import (
24
+ resolve_task_dir, run_task_hooks, load_task_json, write_task_json,
25
+ modify_task_json, assert_can_modify_task,
26
+ )
27
+ from foundation.io.atomicio import atomic_write_json, safe_read_json, atomic_write_text
28
+
29
+
30
+ def cmd_add_subtask(args: argparse.Namespace) -> int:
31
+ """添加子任务关联。"""
32
+ repo_root = get_repo_root()
33
+ parent_dir = resolve_task_dir(args.parent, repo_root)
34
+ child_dir = resolve_task_dir(args.child, repo_root)
35
+
36
+ if not parent_dir.is_dir():
37
+ print(f"Error: Parent task not found: {args.parent}", file=sys.stderr)
38
+ return 1
39
+ if not child_dir.is_dir():
40
+ print(f"Error: Child task not found: {args.child}", file=sys.stderr)
41
+ return 1
42
+
43
+ # 零信任 ACL: 只有 parent 的 creator/assignee/admin 能加子任务
44
+ try:
45
+ assert_can_modify_task(parent_dir, "add-subtask", repo_root)
46
+ except PermissionError as e:
47
+ print(str(e), file=sys.stderr)
48
+ return 4
49
+
50
+ parent_data = load_task_json(parent_dir)
51
+ child_data = load_task_json(child_dir)
52
+
53
+ if not parent_data or not child_data:
54
+ print("Error: Missing task.json", file=sys.stderr)
55
+ return 1
56
+
57
+ child_name = child_dir.name
58
+ children = parent_data.get("children", [])
59
+ if child_name not in children:
60
+ children.append(child_name)
61
+ parent_data["children"] = children
62
+ parent_data["updated_at"] = datetime.now().isoformat()
63
+ write_task_json(parent_dir, parent_data)
64
+
65
+ # 子任务记录父任务
66
+ child_data["parent"] = parent_dir.name
67
+ child_data["updated_at"] = datetime.now().isoformat()
68
+ write_task_json(child_dir, child_data)
69
+
70
+ print(f"Subtask added: {parent_dir.name} -> {child_dir.name}")
71
+ return 0
72
+
73
+
74
+ def cmd_remove_subtask(args: argparse.Namespace) -> int:
75
+ """移除子任务关联。"""
76
+ repo_root = get_repo_root()
77
+ parent_dir = resolve_task_dir(args.parent, repo_root)
78
+ child_dir = resolve_task_dir(args.child, repo_root)
79
+
80
+ # 零信任 ACL
81
+ if parent_dir.is_dir():
82
+ try:
83
+ assert_can_modify_task(parent_dir, "remove-subtask", repo_root)
84
+ except PermissionError as e:
85
+ print(str(e), file=sys.stderr)
86
+ return 4
87
+
88
+ parent_data = load_task_json(parent_dir)
89
+ child_data = load_task_json(child_dir)
90
+
91
+ if parent_data:
92
+ children = parent_data.get("children", [])
93
+ if child_dir.name in children:
94
+ children.remove(child_dir.name)
95
+ parent_data["children"] = children
96
+ parent_data["updated_at"] = datetime.now().isoformat()
97
+ write_task_json(parent_dir, parent_data)
98
+
99
+ if child_data:
100
+ child_data["parent"] = None
101
+ child_data["updated_at"] = datetime.now().isoformat()
102
+ write_task_json(child_dir, child_data)
103
+
104
+ print(f"Subtask removed: {parent_dir.name} -x- {child_dir.name}")
105
+ return 0
106
+
107
+
108
+ # =============================================================================
109
+ # 工具函数
110
+ # =============================================================================
111
+
112
+ def _generate_slug(title: str) -> str:
113
+ """从标题生成 URL-safe slug。"""
114
+ # 简单处理: 小写, 空格转连字符, 只保留字母数字和连字符
115
+ slug = title.lower().strip()
116
+ slug = "".join(c if c.isalnum() or c in " -_" else "" for c in slug)
117
+ slug = slug.replace(" ", "-").replace("_", "-")
118
+ slug = "-".join(part for part in slug.split("-") if part)
119
+ return slug[:50] if slug else "untitled"
120
+
121
+
122
+ # =============================================================================
123
+ # 命令: set-due / block / unblock / gantt (B2 能力增强)
124
+ # =============================================================================
125
+
126
+ def cmd_set_due(args: argparse.Namespace) -> int:
127
+ """设置任务截止日期。用法: task.py set-due <task> <YYYY-MM-DD>"""
128
+ repo_root = get_repo_root()
129
+ full_path = resolve_task_dir(args.task, repo_root)
130
+ if not full_path.is_dir():
131
+ print(f"Error: Task not found: {args.task}", file=sys.stderr)
132
+ return 1
133
+ # ACL
134
+ try:
135
+ assert_can_modify_task(full_path, "set-due", repo_root)
136
+ except PermissionError as e:
137
+ print(str(e), file=sys.stderr)
138
+ return 4
139
+ def _set_due_mutator(data):
140
+ data["due_date"] = args.due_date
141
+ data["updated_at"] = datetime.now().isoformat()
142
+ if not modify_task_json(full_path, _set_due_mutator):
143
+ print("Error: Missing task.json", file=sys.stderr)
144
+ return 1
145
+ print(f"Set due_date: {args.due_date} ({full_path.name})")
146
+ return 0
147
+
148
+
149
+ def cmd_reassign(args: argparse.Namespace) -> int:
150
+ """改派任务负责人。用法: task.py reassign <task> <new_assignee>
151
+
152
+ 场景: PM 建任务后改派给开发(多角色协作必需)。
153
+ ACL: 只有 creator/admin 能改派(防恶意抢占/甩锅)
154
+ 保留改派历史(assignee_history), 便于追溯。
155
+ """
156
+ repo_root = get_repo_root()
157
+ full_path = resolve_task_dir(args.task, repo_root)
158
+ if not full_path.is_dir():
159
+ print(f"Error: Task not found: {args.task}", file=sys.stderr)
160
+ return 1
161
+ # ACL: 只有 creator/admin 能改派 (assignee 自己不能转手甩锅)
162
+ try:
163
+ assert_can_modify_task(full_path, "reassign", repo_root)
164
+ except PermissionError as e:
165
+ print(str(e), file=sys.stderr)
166
+ return 4
167
+
168
+ new_assignee = (args.new_assignee or "").strip()
169
+ if not new_assignee:
170
+ print("Error: new_assignee 不能为空", file=sys.stderr)
171
+ return 1
172
+
173
+ changed = {"from": None, "to": new_assignee}
174
+
175
+ def _reassign_mutator(data):
176
+ old = data.get("assignee")
177
+ changed["from"] = old
178
+ data["assignee"] = new_assignee
179
+ data["updated_at"] = datetime.now().isoformat()
180
+ # 记录改派历史 (追溯用)
181
+ hist = data.get("assignee_history") or []
182
+ hist.append({
183
+ "from": old,
184
+ "to": new_assignee,
185
+ "by": get_developer(repo_root),
186
+ "at": datetime.now().isoformat(),
187
+ })
188
+ data["assignee_history"] = hist
189
+
190
+ if not modify_task_json(full_path, _reassign_mutator):
191
+ print("Error: Missing task.json", file=sys.stderr)
192
+ return 1
193
+
194
+ if changed["from"] == new_assignee:
195
+ print(f"Note: assignee 已是 {new_assignee}, 无变化 ({full_path.name})")
196
+ else:
197
+ print(f"Reassigned: {changed['from']} -> {new_assignee} ({full_path.name})")
198
+ print(f" 改派人: {get_developer(repo_root)} (已记录到 assignee_history)")
199
+ return 0
200
+
201
+
202
+ def cmd_block(args: argparse.Namespace) -> int:
203
+ """标记任务被另一任务阻塞。自动维护反向 blocks 关系。
204
+ 用法: task.py block <task> <blocked-by-task>
205
+ 语义: <task> 被 <blocked-by-task> 阻塞 (要等它完成)。
206
+ """
207
+ repo_root = get_repo_root()
208
+ task_dir = resolve_task_dir(args.task, repo_root)
209
+ blocker_dir = resolve_task_dir(args.blocker, repo_root)
210
+ if not task_dir.is_dir():
211
+ print(f"Error: Task not found: {args.task}", file=sys.stderr)
212
+ return 1
213
+ if not blocker_dir.is_dir():
214
+ print(f"Error: Blocker task not found: {args.blocker}", file=sys.stderr)
215
+ return 1
216
+ # ACL: 只有 task 的 owner 能加依赖
217
+ try:
218
+ assert_can_modify_task(task_dir, "block", repo_root)
219
+ except PermissionError as e:
220
+ print(str(e), file=sys.stderr)
221
+ return 4
222
+
223
+ # 检测循环依赖
224
+ if _would_create_cycle(task_dir.name, blocker_dir.name, repo_root):
225
+ print(f"Error: 循环依赖检测到 ({blocker_dir.name} 已直接/间接依赖 {task_dir.name})",
226
+ file=sys.stderr)
227
+ return 1
228
+
229
+ # 检查是否已存在依赖关系 (先读, 幂等)
230
+ task_data = load_task_json(task_dir)
231
+ if not task_data:
232
+ print("Error: Missing task.json", file=sys.stderr)
233
+ return 1
234
+ if blocker_dir.name in (task_data.get("blocked_by") or []):
235
+ print(f"Already blocked by {blocker_dir.name}")
236
+ return 0
237
+
238
+ # 锁内更新 task.blocked_by
239
+ def _block_mutator(data):
240
+ blocked_by = data.get("blocked_by") or []
241
+ if blocker_dir.name not in blocked_by:
242
+ blocked_by.append(blocker_dir.name)
243
+ data["blocked_by"] = blocked_by
244
+ data["updated_at"] = datetime.now().isoformat()
245
+ modify_task_json(task_dir, _block_mutator)
246
+
247
+ # 锁内反向更新 blocker.blocks
248
+ def _block_reverse_mutator(data):
249
+ blocks = data.get("blocks") or []
250
+ if task_dir.name not in blocks:
251
+ blocks.append(task_dir.name)
252
+ data["blocks"] = blocks
253
+ data["updated_at"] = datetime.now().isoformat()
254
+ modify_task_json(blocker_dir, _block_reverse_mutator)
255
+
256
+ print(f"{task_dir.name} now blocked by {blocker_dir.name}")
257
+ return 0
258
+
259
+
260
+ def cmd_unblock(args: argparse.Namespace) -> int:
261
+ """移除阻塞关系。用法: task.py unblock <task> <blocked-by-task>"""
262
+ repo_root = get_repo_root()
263
+ task_dir = resolve_task_dir(args.task, repo_root)
264
+ blocker_dir = resolve_task_dir(args.blocker, repo_root)
265
+ if task_dir.is_dir():
266
+ try:
267
+ assert_can_modify_task(task_dir, "unblock", repo_root)
268
+ except PermissionError as e:
269
+ print(str(e), file=sys.stderr)
270
+ return 4
271
+
272
+ removed = False
273
+ # 锁内从 task.blocked_by 删
274
+ if task_dir.is_dir():
275
+ def _unblock_mutator(data):
276
+ nonlocal removed
277
+ if blocker_dir.name in (data.get("blocked_by") or []):
278
+ data["blocked_by"] = [x for x in (data.get("blocked_by") or []) if x != blocker_dir.name]
279
+ data["updated_at"] = datetime.now().isoformat()
280
+ removed = True
281
+ modify_task_json(task_dir, _unblock_mutator)
282
+
283
+ # 锁内从 blocker.blocks 删
284
+ if blocker_dir.is_dir():
285
+ def _unblock_reverse_mutator(data):
286
+ nonlocal removed
287
+ if task_dir.name in (data.get("blocks") or []):
288
+ data["blocks"] = [x for x in (data.get("blocks") or []) if x != task_dir.name]
289
+ data["updated_at"] = datetime.now().isoformat()
290
+ removed = True
291
+ modify_task_json(blocker_dir, _unblock_reverse_mutator)
292
+
293
+ if removed:
294
+ print(f"Removed block: {task_dir.name} no longer blocked by {blocker_dir.name}")
295
+ else:
296
+ print(f"No such block relationship found")
297
+ return 0
298
+
299
+
300
+ def _would_create_cycle(task_name: str, blocker_name: str, repo_root) -> bool:
301
+ """检测添加 task←blocker 后是否会形成循环依赖。
302
+ 即: blocker 是否(直接/间接)已经依赖 task?
303
+ """
304
+ if task_name == blocker_name:
305
+ return True
306
+ # BFS: 从 blocker 出发, 沿 blocked_by 走, 看能否回到 task
307
+ visited = set()
308
+ queue = [blocker_name]
309
+ tasks_dir = get_tasks_dir(repo_root)
310
+ while queue:
311
+ current = queue.pop(0)
312
+ if current in visited:
313
+ continue
314
+ visited.add(current)
315
+ if current == task_name:
316
+ return True
317
+ cur_dir = tasks_dir / current
318
+ data = load_task_json(cur_dir) if cur_dir.is_dir() else None
319
+ if data:
320
+ for dep in (data.get("blocked_by") or []):
321
+ if dep not in visited:
322
+ queue.append(dep)
323
+ return False
324
+
325
+
326
+ def _is_task_ready(task_data: dict) -> bool:
327
+ """任务是否"可立即开始": 未完成 且 无未完成的 blocked_by。"""
328
+ if task_data.get("status") == "completed":
329
+ return False
330
+ blocked_by = task_data.get("blocked_by") or []
331
+ # blocked_by 为空 = 可开始 (这里简化: 不深入查依赖是否完成, 由调用方保证)
332
+ return len(blocked_by) == 0
333
+
334
+
335
+ def cmd_gantt(args: argparse.Namespace) -> int:
336
+ """渲染 markdown 甘特图 (按 start_date/due_date 排期)。
337
+ 用法: task.py gantt [--weeks 4]
338
+ """
339
+ repo_root = get_repo_root()
340
+ tasks_dir = get_tasks_dir(repo_root)
341
+ if not tasks_dir.is_dir():
342
+ print("No tasks directory")
343
+ return 0
344
+
345
+ from datetime import date, timedelta
346
+ today = date.today()
347
+ weeks = args.weeks if hasattr(args, "weeks") else 4
348
+ end = today + timedelta(weeks=weeks)
349
+
350
+ # 收集有日期的任务
351
+ items = []
352
+ for d in sorted(tasks_dir.iterdir()):
353
+ if not d.is_dir():
354
+ continue
355
+ data = load_task_json(d)
356
+ if not data:
357
+ continue
358
+ due = data.get("due_date")
359
+ start = data.get("start_date")
360
+ if not due and not start:
361
+ continue
362
+ items.append({
363
+ "name": d.name,
364
+ "title": data.get("title", d.name),
365
+ "start": start,
366
+ "due": due,
367
+ "status": data.get("status", "?"),
368
+ "assignee": data.get("assignee", "?"),
369
+ })
370
+
371
+ if not items:
372
+ print("No tasks with dates. 用 'task.py set-due <task> <YYYY-MM-DD>' 添加截止日期。")
373
+ return 0
374
+
375
+ # 按开始/截止日期排序
376
+ items.sort(key=lambda x: (x.get("start") or x.get("due") or "9999"))
377
+
378
+ print(f"# 任务排期 ({today} ~ {end})\n")
379
+ print("| 任务 | 负责人 | 开始 | 截止 | 状态 |")
380
+ print("|------|--------|------|------|------|")
381
+ for it in items:
382
+ start = it.get("start") or "-"
383
+ due = it.get("due") or "-"
384
+ # 标记逾期
385
+ if it.get("due") and it["status"] != "completed":
386
+ try:
387
+ due_date = date.fromisoformat(it["due"])
388
+ if due_date < today:
389
+ due = f"⚠️ {due} (逾期)"
390
+ except ValueError:
391
+ pass
392
+ print(f"| {it['name']} | {it['assignee']} | {start} | {due} | {it['status']} |")
393
+
394
+ # 简单时间轴 (ASCII)
395
+ print(f"\n## 时间轴 ({weeks} 周)\n")
396
+ print("```")
397
+ for it in items:
398
+ start_s = it.get("start") or it.get("due")
399
+ if not start_s:
400
+ continue
401
+ try:
402
+ start_d = date.fromisoformat(start_s) if it.get("start") else today
403
+ except ValueError:
404
+ start_d = today
405
+ due_s = it.get("due")
406
+ try:
407
+ due_d = date.fromisoformat(due_s) if due_s else start_d + timedelta(days=7)
408
+ except ValueError:
409
+ due_d = start_d + timedelta(days=7)
410
+ # 计算相对今天的偏移
411
+ start_off = max(0, (start_d - today).days)
412
+ due_off = min(weeks * 7, (due_d - today).days)
413
+ if due_off < 0:
414
+ bar = "[逾期]" + "=" * 5
415
+ else:
416
+ bar = " " * (start_off // 2) + "[" + "=" * max(1, (due_off - start_off) // 2) + "]"
417
+ print(f"{it['name'][:20]:20} {bar}")
418
+ print("```")
419
+ return 0
420
+
421
+
422
+ # =============================================================================
423
+ # Main
424
+ # =============================================================================
425
+