@hupan56/wlkj 2.7.11 → 3.0.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 (152) hide show
  1. package/bin/cli.js +375 -78
  2. package/package.json +29 -29
  3. package/templates/.qoder/.runtime/ctx-cache-5660152f1d6dd819.md +23 -0
  4. package/templates/.qoder/.runtime/ctx-cache-afdce0dac06b25b0.md +23 -0
  5. package/templates/.qoder/.runtime/search-cache-eae7644e7b122f35.txt +1 -0
  6. package/templates/.qoder/learning/eval-history.jsonl +28 -0
  7. package/templates/data/index/wiki-index.json +8 -0
  8. package/templates/qoder/agents/insight-planning.md +1 -1
  9. package/templates/qoder/agents/insight-research.md +28 -15
  10. package/templates/qoder/commands/optional/wl-insight.md +159 -161
  11. package/templates/qoder/commands/optional/wl-report.md +4 -4
  12. package/templates/qoder/commands/optional/wl-status.md +2 -2
  13. package/templates/qoder/commands/wl-code.md +2 -2
  14. package/templates/qoder/commands/wl-design.md +2 -2
  15. package/templates/qoder/commands/wl-init.md +3 -3
  16. package/templates/qoder/commands/wl-prd.md +2 -2
  17. package/templates/qoder/commands/wl-req.md +43 -0
  18. package/templates/qoder/commands/wl-search.md +8 -8
  19. package/templates/qoder/commands/wl-task.md +17 -17
  20. package/templates/qoder/commands/wl-test.md +41 -15
  21. package/templates/qoder/config.yaml +17 -1
  22. package/templates/qoder/hooks/session-start.py +12 -22
  23. package/templates/qoder/nul +4 -0
  24. package/templates/qoder/rules/wl-pipeline.md +22 -48
  25. package/templates/qoder/scripts/README.md +139 -0
  26. package/templates/qoder/scripts/common/autotest_auth.py +109 -0
  27. package/templates/qoder/scripts/common/bootstrap.py +145 -0
  28. package/templates/qoder/scripts/common/check_publish.py +98 -0
  29. package/templates/qoder/scripts/common/cmd_registry.py +112 -0
  30. package/templates/qoder/scripts/common/config.py +187 -0
  31. package/templates/qoder/scripts/common/contract.py +317 -0
  32. package/templates/qoder/scripts/common/developer.py +2 -1
  33. package/templates/qoder/scripts/common/feishu.py +10 -9
  34. package/templates/qoder/scripts/common/guard.py +159 -0
  35. package/templates/qoder/scripts/common/identity.py +121 -2
  36. package/templates/qoder/scripts/common/kg_capabilities.py +182 -0
  37. package/templates/qoder/scripts/common/mcp_base.py +268 -0
  38. package/templates/qoder/scripts/common/paths.py +187 -1
  39. package/templates/qoder/scripts/common/result.py +223 -0
  40. package/templates/qoder/scripts/common/roles.py +60 -0
  41. package/templates/qoder/scripts/common/task_utils.py +21 -9
  42. package/templates/qoder/scripts/common/test_extract.py +115 -0
  43. package/templates/qoder/scripts/kg/__init__.py +11 -0
  44. package/templates/qoder/scripts/kg/build_entity_registry.py +196 -0
  45. package/templates/qoder/scripts/kg/build_relations.py +127 -0
  46. package/templates/qoder/scripts/{build_style_index.py → kg/build_style_index.py} +39 -10
  47. package/templates/qoder/scripts/kg/build_workflows.py +144 -0
  48. package/templates/qoder/scripts/{context_pack.py → kg/context_pack.py} +18 -11
  49. package/templates/qoder/scripts/{enrich_prompt.py → kg/enrich_prompt.py} +232 -226
  50. package/templates/qoder/scripts/{extract_api_params.py → kg/extract.py} +398 -246
  51. package/templates/qoder/scripts/{kg.py → kg/kg.py} +638 -708
  52. package/templates/qoder/scripts/{kg_build.py → kg/kg_build.py} +618 -612
  53. package/templates/qoder/scripts/{kg_build_db.py → kg/kg_build_db.py} +333 -327
  54. package/templates/qoder/scripts/{kg_duckdb.py → kg/kg_duckdb.py} +38 -37
  55. package/templates/qoder/scripts/{kg_incremental.py → kg/kg_incremental.py} +420 -393
  56. package/templates/qoder/scripts/{kg_link_db.py → kg/kg_link_db.py} +230 -224
  57. package/templates/qoder/scripts/{kg_semantic.py → kg/kg_semantic.py} +156 -150
  58. package/templates/qoder/scripts/kg/prefetch.py +359 -0
  59. package/templates/qoder/scripts/{search_index.py → kg/search_index.py} +70 -14
  60. package/templates/qoder/scripts/mcp/__init__.py +11 -0
  61. package/templates/qoder/scripts/{kg_mcp_server.py → mcp/kg_mcp_server.py} +77 -272
  62. package/templates/qoder/scripts/{lanhu_stdio_wrapper.py → mcp/lanhu_stdio_wrapper.py} +125 -119
  63. package/templates/qoder/scripts/{check_mcp.py → mcp/mcp_doctor.py} +515 -298
  64. package/templates/qoder/scripts/{mcp_launcher.py → mcp/mcp_launcher.py} +442 -414
  65. package/templates/qoder/scripts/{mysql_mcp_server.py → mcp/mysql_mcp_server.py} +347 -396
  66. package/templates/qoder/scripts/{zentao_mcp_server.py → mcp/zentao_mcp_server.py} +384 -424
  67. package/templates/qoder/scripts/report/__init__.py +11 -0
  68. package/templates/qoder/scripts/{add_session.py → report/add_session.py} +250 -244
  69. package/templates/qoder/scripts/{archive_prd.py → report/archive_prd.py} +383 -377
  70. package/templates/qoder/scripts/{eval_prd.py → report/eval_prd.py} +73 -11
  71. package/templates/qoder/scripts/{export.py → report/export.py} +63 -0
  72. package/templates/qoder/scripts/{fill_prototype.py → report/fill_prototype.py} +6 -0
  73. package/templates/qoder/scripts/{gen_design_doc.py → report/gen_design_doc.py} +400 -394
  74. package/templates/qoder/scripts/{learn.py → report/learn.py} +152 -146
  75. package/templates/qoder/scripts/{learn_aggregate.py → report/learn_aggregate.py} +207 -201
  76. package/templates/qoder/scripts/{report.py → report/report.py} +287 -281
  77. package/templates/qoder/scripts/report/req.py +222 -0
  78. package/templates/qoder/scripts/report/role.py +33 -0
  79. package/templates/qoder/scripts/{status.py → report/status.py} +634 -628
  80. package/templates/qoder/scripts/setup/__init__.py +11 -0
  81. package/templates/qoder/scripts/setup/carriers.py +662 -0
  82. package/templates/qoder/scripts/{init_doctor.py → setup/init_doctor.py} +63 -26
  83. package/templates/qoder/scripts/{install_qoderwork.py → setup/install_qoderwork.py} +36 -26
  84. package/templates/qoder/scripts/{platform_doctor.py → setup/platform_doctor.py} +265 -259
  85. package/templates/qoder/scripts/{repo_root.py → setup/repo_root.py} +112 -106
  86. package/templates/qoder/scripts/{setup.py → setup/setup.py} +147 -0
  87. package/templates/qoder/scripts/{setup_lanhu.py → setup/setup_lanhu.py} +973 -963
  88. package/templates/qoder/scripts/task/__init__.py +11 -0
  89. package/templates/qoder/scripts/{git_sync.py → task/git_sync.py} +52 -31
  90. package/templates/qoder/scripts/{syncgate.py → task/syncgate.py} +6 -0
  91. package/templates/qoder/scripts/task/task.py +221 -0
  92. package/templates/qoder/scripts/task/task_lifecycle.py +596 -0
  93. package/templates/qoder/scripts/task/task_query.py +161 -0
  94. package/templates/qoder/scripts/task/task_relations.py +424 -0
  95. package/templates/qoder/scripts/{team_sync.py → task/team_sync.py} +93 -20
  96. package/templates/qoder/scripts/test/__init__.py +11 -0
  97. package/templates/qoder/scripts/{autotest.py → test/autotest.py} +1174 -1751
  98. package/templates/qoder/scripts/{autotest_batch.py → test/autotest_batch.py} +242 -224
  99. package/templates/qoder/scripts/test/autotest_data.py +675 -0
  100. package/templates/qoder/scripts/{autotest_run.py → test/autotest_run.py} +309 -297
  101. package/templates/qoder/scripts/{benchmark.py → test/benchmark.py} +6 -0
  102. package/templates/qoder/scripts/{kg_auto_login.py → test/kg_auto_login.py} +202 -196
  103. package/templates/qoder/scripts/{kg_test_runner.py → test/kg_test_runner.py} +7 -1
  104. package/templates/qoder/scripts/{page_probe.py → test/page_probe.py} +465 -459
  105. package/templates/qoder/scripts/wlkj.py +116 -0
  106. package/templates/qoder/settings.json +1 -10
  107. package/templates/qoder/skills/design-import/SKILL.md +226 -226
  108. package/templates/qoder/skills/design-review/SKILL.md +82 -82
  109. package/templates/qoder/skills/prd-generator/SKILL.md +26 -16
  110. package/templates/qoder/skills/prd-review/SKILL.md +5 -5
  111. package/templates/qoder/skills/prototype-generator/SKILL.md +256 -256
  112. package/templates/qoder/skills/spec-coder/SKILL.md +4 -4
  113. package/templates/qoder/skills/spec-generator/SKILL.md +4 -4
  114. package/templates/qoder/skills/test-generator/SKILL.md +5 -5
  115. package/templates/qoder/skills/wl-code/SKILL.md +4 -4
  116. package/templates/qoder/skills/wl-commit/SKILL.md +4 -4
  117. package/templates/qoder/skills/wl-design/SKILL.md +3 -3
  118. package/templates/qoder/skills/wl-init/SKILL.md +8 -8
  119. package/templates/qoder/skills/wl-insight/SKILL.md +5 -5
  120. package/templates/qoder/skills/wl-prd-full/SKILL.md +6 -6
  121. package/templates/qoder/skills/wl-prd-quick/SKILL.md +6 -6
  122. package/templates/qoder/skills/wl-prd-review/SKILL.md +4 -4
  123. package/templates/qoder/skills/wl-report/SKILL.md +7 -7
  124. package/templates/qoder/skills/wl-search/SKILL.md +13 -13
  125. package/templates/qoder/skills/wl-spec/SKILL.md +5 -5
  126. package/templates/qoder/skills/wl-status/SKILL.md +5 -5
  127. package/templates/qoder/skills/wl-task/SKILL.md +6 -6
  128. package/templates/qoder/skills/wl-test/SKILL.md +102 -39
  129. package/templates/root/AGENTS.md +39 -40
  130. package/templates/qoder/hooks/inject-workflow-state.py +0 -169
  131. package/templates/qoder/scripts/__pycache__/check_mcp_launch.cpython-39.pyc +0 -0
  132. package/templates/qoder/scripts/__pycache__/install_qoderwork.cpython-39.pyc +0 -0
  133. package/templates/qoder/scripts/__pycache__/mcp_launcher.cpython-39.pyc +0 -0
  134. package/templates/qoder/scripts/__pycache__/platform_doctor.cpython-39.pyc +0 -0
  135. package/templates/qoder/scripts/check_carriers.py +0 -238
  136. package/templates/qoder/scripts/check_mcp_launch.py +0 -183
  137. package/templates/qoder/scripts/check_qoderwork_consistency.py +0 -166
  138. package/templates/qoder/scripts/collect_prds.py +0 -31
  139. package/templates/qoder/scripts/common/mentions.py +0 -134
  140. package/templates/qoder/scripts/common/utf8.py +0 -38
  141. package/templates/qoder/scripts/extract_routes.py +0 -54
  142. package/templates/qoder/scripts/extract_routes_tree.py +0 -78
  143. package/templates/qoder/scripts/handoff.py +0 -22
  144. package/templates/qoder/scripts/init_developer.py +0 -76
  145. package/templates/qoder/scripts/parse_prds.py +0 -33
  146. package/templates/qoder/scripts/role.py +0 -51
  147. package/templates/qoder/scripts/sync_carriers.py +0 -259
  148. package/templates/qoder/scripts/task.py +0 -1261
  149. package/templates/qoder/scripts/workspace_init.py +0 -102
  150. package/templates/qoder/skills/prompt-enrich/SKILL.md +0 -90
  151. package/templates/qoder/skills/prototype-generator/SKILL.md.zcode-79180-2af4721f-f9a6-412c-88db-c0af680d211b.tmp +0 -0
  152. /package/templates/qoder/scripts/{secure-ls.js → test/secure-ls.js} +0 -0
@@ -0,0 +1,161 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """task_query.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_list(args: argparse.Namespace) -> int:
30
+ """列出活跃任务。支持 --mine / --status / --ready / --blocked 过滤。"""
31
+ repo_root = get_repo_root()
32
+ tasks_dir = get_tasks_dir(repo_root)
33
+ developer = get_developer(repo_root)
34
+ active = resolve_active_task(repo_root)
35
+
36
+ filter_mine = args.mine
37
+ filter_status = args.status
38
+ filter_ready = getattr(args, "ready", False)
39
+ filter_blocked = getattr(args, "blocked", False)
40
+
41
+ if not tasks_dir.is_dir():
42
+ print("No tasks directory found")
43
+ return 0
44
+
45
+ # 预加载所有任务 (用于 --ready/--blocked 判断依赖是否完成)
46
+ all_tasks = {}
47
+ for d in sorted(tasks_dir.iterdir()):
48
+ if d.is_dir():
49
+ data = load_task_json(d)
50
+ if data:
51
+ all_tasks[d.name] = data
52
+
53
+ def is_dep_completed(dep_name: str) -> bool:
54
+ dep = all_tasks.get(dep_name)
55
+ return bool(dep and dep.get("status") == "completed")
56
+
57
+ tasks = []
58
+ for name, task_data in all_tasks.items():
59
+ d = tasks_dir / name
60
+
61
+ # 过滤: --mine
62
+ if filter_mine and developer not in (
63
+ task_data.get("assignee"), task_data.get("creator")
64
+ ):
65
+ continue
66
+ if filter_status and task_data.get("status") != filter_status:
67
+ continue
68
+
69
+ # --ready: 未完成 且 所有 blocked_by 已完成
70
+ if filter_ready:
71
+ if task_data.get("status") == "completed":
72
+ continue
73
+ blocked_by = task_data.get("blocked_by") or []
74
+ if any(not is_dep_completed(dep) for dep in blocked_by):
75
+ continue
76
+
77
+ # --blocked: 有未完成的 blocked_by
78
+ if filter_blocked:
79
+ blocked_by = task_data.get("blocked_by") or []
80
+ open_blocks = [dep for dep in blocked_by if not is_dep_completed(dep)]
81
+ if not open_blocks:
82
+ continue
83
+
84
+ tasks.append((name, task_data))
85
+
86
+ if not tasks:
87
+ print("No tasks found" + (
88
+ " (no tasks ready to start)" if filter_ready else
89
+ " (no blocked tasks)" if filter_blocked else ""
90
+ ))
91
+ return 0
92
+
93
+ print(f"Tasks ({len(tasks)}):")
94
+ for name, data in tasks:
95
+ is_current = active.task_path and name in active.task_path
96
+ marker = " *" if is_current else " "
97
+ assignee = data.get("assignee", "?")
98
+ status = data.get("status", "?")
99
+ priority = data.get("priority", "?")
100
+ title = data.get("title", name)
101
+ extras = []
102
+ due = data.get("due_date")
103
+ if due:
104
+ extras.append(f"due:{due}")
105
+ blocked_by = data.get("blocked_by") or []
106
+ open_blocks = [dep for dep in blocked_by if not is_dep_completed(dep)]
107
+ if open_blocks:
108
+ extras.append(f"blocked:{','.join(open_blocks)}")
109
+ extra_str = f" [{', '.join(extras)}]" if extras else ""
110
+ print(f"{marker} [{priority}] [{status}] {name} ({assignee}) - {title}{extra_str}")
111
+
112
+ return 0
113
+
114
+
115
+ # =============================================================================
116
+ # 命令: show
117
+ # =============================================================================
118
+
119
+ def cmd_show(args: argparse.Namespace) -> int:
120
+ """显示任务详情。"""
121
+ repo_root = get_repo_root()
122
+ full_path = resolve_task_dir(args.dir, repo_root)
123
+
124
+ if not full_path.is_dir():
125
+ print(f"Error: Task not found: {args.dir}", file=sys.stderr)
126
+ return 1
127
+
128
+ task_data = load_task_json(full_path)
129
+ if not task_data:
130
+ print(f"Error: Missing task.json in {full_path.name}", file=sys.stderr)
131
+ return 1
132
+
133
+ print(f"Task: {full_path.name}")
134
+ for key in ("title", "status", "priority", "rice", "assignee", "creator",
135
+ "created_at", "updated_at", "parent", "tags",
136
+ "due_date", "start_date", "estimate_hours"):
137
+ val = task_data.get(key)
138
+ if val not in (None, [], ""):
139
+ print(f" {key}: {val}")
140
+ children = task_data.get("children") or []
141
+ if children:
142
+ print(f" children: {', '.join(children)}")
143
+ blocked_by = task_data.get("blocked_by") or []
144
+ if blocked_by:
145
+ print(f" blocked_by: {', '.join(blocked_by)}")
146
+ blocks = task_data.get("blocks") or []
147
+ if blocks:
148
+ print(f" blocks: {', '.join(blocks)}")
149
+ stage_ts = task_data.get("stage_ts") or {}
150
+ if any(stage_ts.values()):
151
+ print(f" stage_ts: {stage_ts}")
152
+ prd_file = full_path / "prd.md"
153
+ if prd_file.is_file():
154
+ print(f" prd: workspace/tasks/{full_path.name}/prd.md")
155
+ return 0
156
+
157
+
158
+ # =============================================================================
159
+ # 命令: add-subtask / remove-subtask
160
+ # =============================================================================
161
+
@@ -0,0 +1,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__))), '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
+