@hupan56/wlkj 2.7.12 → 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 +344 -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} +113 -4
  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
@@ -1,377 +1,383 @@
1
- #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
- """
4
- archive_prd.py - PRD 按时间归档到个人工作区
5
-
6
- 把刚生成的 PRD 复制一份到当前成员的个人归档区,按"天"分文件夹:
7
- workspace/members/{name}/archive/{YYYY-MM-DD}/REQ-{编号}-{中文标题}.md
8
- 并在 archive/_index.md 汇总成"周/月"视图,方便人翻查"我那天做了啥"。
9
-
10
- 设计原则(非侵入):
11
- - 只**复制**,不移动;drafts/ 原件保留,data/docs/prd/ 完全不碰
12
- - 归属看当前操作者(.developer),不依赖 PRD "产品负责人"字段 → 开发/测试/产品都能用
13
- - 全程 try/except,失败只 print 一句提示,绝不阻塞 PRD 生成
14
- - 用户没用 PRD 命令就完全不触发(本脚本只在 prd-generator 调用时跑)
15
-
16
- Usage:
17
- python .qoder/scripts/archive_prd.py <prd 文件路径>
18
- python .qoder/scripts/archive_prd.py <prd1> <prd2> ... # 批量
19
- """
20
-
21
- import os
22
- import re
23
- import sys
24
- from datetime import datetime, date, timedelta
25
- from pathlib import Path
26
-
27
- # UTF-8 stdio(防御性:Windows cmd 默认 GBK,stdout 被捕获时不崩)
28
- try:
29
- sys.stdout.reconfigure(encoding="utf-8", errors="replace")
30
- except (AttributeError, TypeError, OSError, IOError):
31
- try:
32
- sys.stdout.reconfigure(encoding="utf-8")
33
- except Exception:
34
- pass
35
-
36
- # common/ 可被 import(直接当脚本跑时)
37
- _THIS_DIR = os.path.dirname(os.path.abspath(__file__))
38
- _COMMON = os.path.join(_THIS_DIR, "common")
39
- if _COMMON not in sys.path:
40
- sys.path.insert(0, _COMMON)
41
-
42
- try:
43
- from paths import get_repo_root, get_developer, MEMBERS_DIR
44
- except ImportError:
45
- # 极端兜底:直接算路径
46
- def get_repo_root():
47
- current = Path(_THIS_DIR).resolve()
48
- for _ in range(10):
49
- if (current / ".qoder").is_dir():
50
- return current
51
- parent = current.parent
52
- if parent == current:
53
- break
54
- current = parent
55
- return Path(_THIS_DIR).parent.parent
56
-
57
- def get_developer(repo_root=None):
58
- dev = (repo_root or get_repo_root()) / ".qoder" / ".developer"
59
- if not dev.is_file():
60
- return None
61
- for enc in ("utf-8", "gbk"):
62
- try:
63
- for line in dev.read_text(encoding=enc).splitlines():
64
- line = line.strip()
65
- if "=" in line and line.split("=")[0].strip() == "name":
66
- return line.split("=", 1)[1].strip() or None
67
- if ":" in line and line.split(":")[0].strip() == "name":
68
- return line.split(":", 1)[1].strip() or None
69
- except (OSError, IOError, UnicodeDecodeError):
70
- continue
71
- return None
72
-
73
- MEMBERS_DIR = None # 下面用 repo_root
74
-
75
-
76
- # ============================================================
77
- # 解析:中文标题 + 日期(带 fallback 链,健壮)
78
- # ============================================================
79
-
80
- # REQ-2026-005(标准)/ REQ-012(老格式单段)都能匹配
81
- _REQ_RE = re.compile(r"REQ-(\d{2,4})(?:-(\d{2,4}))?", re.IGNORECASE)
82
- # 创建日期: 2026-06-12 / 2026.06.12 / 2026/06/12
83
- _DATE_RE = re.compile(
84
- r"(20\d{2})[./\-年](\d{1,2})[./\-月](\d{1,2})"
85
- )
86
-
87
-
88
- def _read_text_safe(path: Path) -> str:
89
- """读文件,UTF-8 优先,GBK 兜底。"""
90
- for enc in ("utf-8", "utf-8-sig", "gbk"):
91
- try:
92
- return path.read_text(encoding=enc)
93
- except (OSError, IOError, UnicodeDecodeError):
94
- continue
95
- return ""
96
-
97
-
98
- def extract_title(content: str, fallback_name: str) -> str:
99
- """提取 PRD 中文标题。fallback 链:
100
- ① H1 标题(去掉 REQ-xxxx 前缀)
101
- 正文「需求名称」「需求标题」
102
- ③ 文件名 slug
103
- ④ "未命名"
104
- """
105
- # H1:# REQ-2026-005 异常中心全国看板 → 取「异常中心全国看板」
106
- for line in content.splitlines():
107
- s = line.strip()
108
- if s.startswith("# "):
109
- title = s[2:].strip()
110
- # 去掉开头的 REQ-xxxx-xxx 编号前缀
111
- title = _REQ_RE.sub("", title).strip(" -—|")
112
- if title:
113
- return _clean_title(title)
114
- break # H1 存在但没正文,走下一条 fallback
115
- # 正文 - **需求名称**: xxx / **需求标题**: xxx
116
- m = re.search(r"\*{0,2}\s*(?:需求名称|需求标题|标题)\s*\*{0,2}\s*[::]\s*(.+)", content)
117
- if m:
118
- return _clean_title(m.group(1).strip())
119
- # ③ 文件名 slug
120
- return fallback_name
121
-
122
-
123
- def _clean_title(title: str) -> str:
124
- """清理标题:去尾部 markdown / 标点 / 多余空白。"""
125
- title = title.splitlines()[0].strip()
126
- title = re.sub(r"\s+", " ", title)
127
- # 去掉行尾的 | 或多余修饰
128
- title = title.rstrip("| -—·•")
129
- return title or "未命名"
130
-
131
-
132
- def extract_date(content: str, file_mtime: float) -> str:
133
- """提取 PRD 创建日期 (YYYY-MM-DD)。fallback 链:
134
- 正文「创建日期 / 创建时间」
135
- 正文任意 2026-06-12 格式日期
136
- ③ 文件 mtime
137
- ④ 今天
138
- """
139
- # 显式「创建日期」/「创建时间」
140
- m = re.search(
141
- r"(?:创建日期|创建时间|创建日期)\s*[::]\s*" + _DATE_RE.pattern, content
142
- )
143
- if m:
144
- d = _normalize_date(m.group(1), m.group(2), m.group(3))
145
- if d:
146
- return d
147
- # 任意日期(取第一个)
148
- m = _DATE_RE.search(content)
149
- if m:
150
- d = _normalize_date(m.group(1), m.group(2), m.group(3))
151
- if d:
152
- return d
153
- # mtime
154
- try:
155
- return datetime.fromtimestamp(file_mtime).strftime("%Y-%m-%d")
156
- except (OSError, ValueError, OverflowError):
157
- pass
158
- # ④ 今天
159
- return date.today().strftime("%Y-%m-%d")
160
-
161
-
162
- def _normalize_date(y: str, m: str, d: str) -> str:
163
- try:
164
- return "%s-%02d-%02d" % (y.zfill(4), int(m), int(d))
165
- except (ValueError, TypeError):
166
- return ""
167
-
168
-
169
- def extract_reqno(filename: str) -> str:
170
- """从文件名提取 REQ 编号段,如 'REQ-2026-005'。取不到则用文件名去扩展名。
171
- 兼容标准格式 REQ-2026-005 和老格式 REQ-012(单段)。
172
- """
173
- m = _REQ_RE.search(filename)
174
- if m:
175
- year = m.group(1).zfill(4)
176
- if m.group(2): # 标准格式 REQ-2026-005
177
- return "REQ-%s-%s" % (year, m.group(2))
178
- return "REQ-%s" % year # 老格式 REQ-012
179
- base = os.path.splitext(filename)[0]
180
- return base[:40]
181
-
182
-
183
- # ============================================================
184
- # 文件名安全化(中文保留,去掉文件系统非法字符)
185
- # ============================================================
186
-
187
- _UNSAFE_FS = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
188
-
189
-
190
- def _sanitize_filename(name: str, max_len: int = 60) -> str:
191
- """文件名安全化:保留中文/字母/数字/连字符,去掉非法字符,限长。"""
192
- name = _UNSAFE_FS.sub("", name).strip()
193
- name = re.sub(r"\s+", "-", name) # 空格转连字符
194
- if len(name) > max_len:
195
- name = name[:max_len].rstrip("-")
196
- return name or "未命名"
197
-
198
-
199
- # ============================================================
200
- # 核心:归档单个 PRD
201
- # ============================================================
202
-
203
- def archive_one(prd_path: str, repo_root: Path) -> bool:
204
- """把一个 PRD 归档到当前成员的个人区。返回是否成功。"""
205
- src = Path(prd_path)
206
- if not src.is_absolute():
207
- src = (Path.cwd() / src).resolve()
208
- if not src.is_file():
209
- print("[archive] 跳过: 文件不存在 %s" % prd_path)
210
- return False
211
-
212
- developer = get_developer(repo_root)
213
- if not developer:
214
- print("[archive] 跳过: 未初始化开发者身份 (跑 /wl-init)")
215
- return False
216
-
217
- members = MEMBERS_DIR if MEMBERS_DIR else (repo_root / "workspace" / "members")
218
- archive_root = Path(members) / developer / "archive"
219
-
220
- content = _read_text_safe(src)
221
- fname = src.name
222
- reqno = extract_reqno(fname)
223
- # 文件名里的 slug(REQ-2026-005-xxx.md xxx 部分)作为最后兜底标题
224
- slug_fallback = _REQ_RE.sub("", os.path.splitext(fname)[0]).lstrip("-_") or "未命名"
225
- title = extract_title(content, slug_fallback)
226
- prd_date = extract_date(content, src.stat().st_mtime)
227
-
228
- # 目标:members/{dev}/archive/{YYYY-MM-DD}/REQ-{编号}-{中文标题}.md
229
- day_dir = archive_root / prd_date
230
- day_dir.mkdir(parents=True, exist_ok=True)
231
- safe_title = _sanitize_filename(title)
232
- dst_name = "%s-%s.md" % (reqno, safe_title)
233
- dst = day_dir / dst_name
234
-
235
- import shutil
236
- shutil.copy2(src, dst)
237
-
238
- # 更新当天 _index.md
239
- _write_day_index(day_dir, prd_date)
240
- # 更新 archive 根的 _index.md(周/月汇总)
241
- _write_overview_index(archive_root, developer)
242
-
243
- rel = dst.relative_to(repo_root) if str(dst).startswith(str(repo_root)) else dst
244
- print("[archive] 已归档: %s" % rel)
245
- return True
246
-
247
-
248
- def _collect_day_prds(day_dir: Path):
249
- """收集某天文件夹里的 PRD(.md 且不是 _index)。返回 [(reqno, title, fname), ...]"""
250
- items = []
251
- for f in sorted(day_dir.iterdir()):
252
- if not f.is_file() or not f.name.lower().endswith(".md"):
253
- continue
254
- if f.name.startswith("_"):
255
- continue
256
- stem = f.stem # REQ-2026-005-异常中心全国看板
257
- m = _REQ_RE.search(stem)
258
- reqno = "REQ-%s-%s" % (m.group(1).zfill(4), m.group(2)) if m else stem
259
- title = _REQ_RE.sub("", stem).lstrip("-_ ") or stem
260
- items.append((reqno, title, f.name))
261
- return items
262
-
263
-
264
- def _write_day_index(day_dir: Path, prd_date: str):
265
- """写当天的 _index.md:一个清爽的当天需求清单。"""
266
- items = _collect_day_prds(day_dir)
267
- lines = ["# %s 的需求产出\n" % prd_date, "> 共 %d 个需求\n" % len(items)]
268
- if items:
269
- for reqno, title, fname in items:
270
- lines.append("- **%s** %s — [`%s`](./%s)" % (reqno, title, fname, fname))
271
- else:
272
- lines.append("(无)")
273
- (day_dir / "_index.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
274
-
275
-
276
- def _iso_week_range(d: date):
277
- """返回 d 所在自然周的 周一~周日(ISO 周,周一为起点)。"""
278
- monday = d - timedelta(days=d.weekday()) # weekday() 周一=0
279
- sunday = monday + timedelta(days=6)
280
- return monday, sunday
281
-
282
-
283
- def _write_overview_index(archive_root: Path, developer: str):
284
- """写 archive 根的 _index.md:按 / 月 汇总,给「我这阵子做了啥」一个总览。"""
285
- if not archive_root.is_dir():
286
- return
287
- # 扫所有日期文件夹
288
- days = []
289
- for d in sorted(archive_root.iterdir()):
290
- if not d.is_dir():
291
- continue
292
- try:
293
- day = date.fromisoformat(d.name) # 只认 YYYY-MM-DD
294
- except ValueError:
295
- continue
296
- items = _collect_day_prds(d)
297
- if items:
298
- days.append((day, items))
299
- if not days:
300
- return
301
-
302
- lines = ["# %s 的需求归档\n" % developer, "> 按时间翻查你的需求产出。物理按天归档,下面是周/月汇总。\n"]
303
-
304
- # ---- 周视图(按自然周聚合)----
305
- lines.append("## 按周\n")
306
- # 把连续的天聚到同一周
307
- weeks = {} # week_key(周一) -> [(day, items)]
308
- order = []
309
- for day, items in days:
310
- monday, _ = _iso_week_range(day)
311
- if monday not in weeks:
312
- weeks[monday] = []
313
- order.append(monday)
314
- weeks[monday].append((day, items))
315
- for monday in sorted(order, reverse=True):
316
- sunday = monday + timedelta(days=6)
317
- week_items = weeks[monday]
318
- total = sum(len(it) for _, it in week_items)
319
- lines.append(
320
- "### %s 第 %d 周(%s ~ %s)— %d 个需求\n"
321
- % (monday.year, monday.isocalendar()[1], monday.isoformat(), sunday.isoformat(), total)
322
- )
323
- for day, items in sorted(week_items, reverse=True):
324
- summary = "、".join("%s %s" % (r, t) for r, t, _ in items)
325
- lines.append("- **%s**:%s" % (day.isoformat(), summary))
326
- lines.append("")
327
-
328
- # ---- 月视图 ----
329
- lines.append("## 按月\n")
330
- months = {}
331
- morder = []
332
- for day, items in days:
333
- mk = (day.year, day.month)
334
- if mk not in months:
335
- months[mk] = []
336
- morder.append(mk)
337
- months[mk].append((day, items))
338
- for mk in sorted(morder, reverse=True):
339
- m_items = months[mk]
340
- total = sum(len(it) for _, it in m_items)
341
- lines.append("### %d年%d月 — %d 个需求" % (mk[0], mk[1], total))
342
- for day, items in sorted(m_items, reverse=True):
343
- summary = "、".join("%s" % t for _, t, _ in items)
344
- lines.append(" - %s:%s" % (day.isoformat(), summary))
345
- lines.append("")
346
-
347
- (archive_root / "_index.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
348
-
349
-
350
- # ============================================================
351
- # main
352
- # ============================================================
353
-
354
- def main():
355
- args = sys.argv[1:]
356
- # 过滤 flag
357
- prd_args = [a for a in args if not a.startswith("-")]
358
- if not prd_args:
359
- print("Usage: python .qoder/scripts/archive_prd.py <prd 文件路径> [<prd2> ...]")
360
- print("把 PRD 归档到当前成员的 workspace/members/{name}/archive/{日期}/")
361
- sys.exit(1)
362
-
363
- repo_root = get_repo_root()
364
- ok = 0
365
- for p in prd_args:
366
- try:
367
- if archive_one(p, repo_root):
368
- ok += 1
369
- except Exception as e:
370
- # 非侵入:归档失败绝不影响 PRD 生成
371
- print("[archive] 跳过 %s: %s" % (p, str(e)[:120]))
372
- print("[archive] 完成: %d/%d 已归档" % (ok, len(prd_args)))
373
- sys.exit(0)
374
-
375
-
376
- if __name__ == "__main__":
377
- main()
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ # v3.0 路径自举: 引导到 common/bootstrap, 统一 sys.path 逻辑
4
+ import os as _o, sys as _s
5
+ _cp = _o.path.join(_o.path.dirname(_o.path.dirname(_o.path.abspath(__file__))), 'common')
6
+ if _cp not in _s.path: _s.path.insert(0, _cp)
7
+ from bootstrap import setup; setup()
8
+
9
+ """
10
+ archive_prd.py - PRD 按时间归档到个人工作区
11
+
12
+ 把刚生成的 PRD 复制一份到当前成员的个人归档区,按""分文件夹:
13
+ workspace/members/{name}/archive/{YYYY-MM-DD}/REQ-{编号}-{中文标题}.md
14
+ 并在 archive/_index.md 汇总成"周/月"视图,方便人翻查"我那天做了啥"。
15
+
16
+ 设计原则(非侵入):
17
+ - 只**复制**,不移动;drafts/ 原件保留,data/docs/prd/ 完全不碰
18
+ - 归属看当前操作者(.developer),不依赖 PRD 的"产品负责人"字段 开发/测试/产品都能用
19
+ - 全程 try/except,失败只 print 一句提示,绝不阻塞 PRD 生成
20
+ - 用户没用 PRD 命令就完全不触发(本脚本只在 prd-generator 调用时跑)
21
+
22
+ Usage:
23
+ python .qoder/scripts/archive_prd.py <prd 文件路径>
24
+ python .qoder/scripts/archive_prd.py <prd1> <prd2> ... # 批量
25
+ """
26
+
27
+ import os
28
+ import re
29
+ import sys
30
+ from datetime import datetime, date, timedelta
31
+ from pathlib import Path
32
+
33
+ # UTF-8 stdio(防御性:Windows cmd 默认 GBK,stdout 被捕获时不崩)
34
+ try:
35
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
36
+ except (AttributeError, TypeError, OSError, IOError):
37
+ try:
38
+ sys.stdout.reconfigure(encoding="utf-8")
39
+ except Exception:
40
+ pass
41
+
42
+ # 让 common/ 可被 import(直接当脚本跑时)
43
+ _THIS_DIR = os.path.dirname(os.path.abspath(__file__))
44
+ _COMMON = os.path.join(_THIS_DIR, "common")
45
+ if _COMMON not in sys.path:
46
+ sys.path.insert(0, _COMMON)
47
+
48
+ try:
49
+ from paths import get_repo_root, get_developer, MEMBERS_DIR
50
+ except ImportError:
51
+ # 极端兜底:直接算路径
52
+ def get_repo_root():
53
+ current = Path(_THIS_DIR).resolve()
54
+ for _ in range(10):
55
+ if (current / ".qoder").is_dir():
56
+ return current
57
+ parent = current.parent
58
+ if parent == current:
59
+ break
60
+ current = parent
61
+ return Path(_THIS_DIR).parent.parent
62
+
63
+ def get_developer(repo_root=None):
64
+ dev = (repo_root or get_repo_root()) / ".qoder" / ".developer"
65
+ if not dev.is_file():
66
+ return None
67
+ for enc in ("utf-8", "gbk"):
68
+ try:
69
+ for line in dev.read_text(encoding=enc).splitlines():
70
+ line = line.strip()
71
+ if "=" in line and line.split("=")[0].strip() == "name":
72
+ return line.split("=", 1)[1].strip() or None
73
+ if ":" in line and line.split(":")[0].strip() == "name":
74
+ return line.split(":", 1)[1].strip() or None
75
+ except (OSError, IOError, UnicodeDecodeError):
76
+ continue
77
+ return None
78
+
79
+ MEMBERS_DIR = None # 下面用 repo_root 拼
80
+
81
+
82
+ # ============================================================
83
+ # 解析:中文标题 + 日期(带 fallback 链,健壮)
84
+ # ============================================================
85
+
86
+ # REQ-2026-005(标准)/ REQ-012(老格式单段)都能匹配
87
+ _REQ_RE = re.compile(r"REQ-(\d{2,4})(?:-(\d{2,4}))?", re.IGNORECASE)
88
+ # 创建日期: 2026-06-12 / 2026.06.12 / 2026/06/12
89
+ _DATE_RE = re.compile(
90
+ r"(20\d{2})[./\-年](\d{1,2})[./\-月](\d{1,2})"
91
+ )
92
+
93
+
94
+ def _read_text_safe(path: Path) -> str:
95
+ """读文件,UTF-8 优先,GBK 兜底。"""
96
+ for enc in ("utf-8", "utf-8-sig", "gbk"):
97
+ try:
98
+ return path.read_text(encoding=enc)
99
+ except (OSError, IOError, UnicodeDecodeError):
100
+ continue
101
+ return ""
102
+
103
+
104
+ def extract_title(content: str, fallback_name: str) -> str:
105
+ """提取 PRD 中文标题。fallback 链:
106
+ H1 标题(去掉 REQ-xxxx 前缀)
107
+ 正文「需求名称」「需求标题」
108
+ 文件名 slug
109
+ "未命名"
110
+ """
111
+ # H1:# REQ-2026-005 异常中心全国看板 → 取「异常中心全国看板」
112
+ for line in content.splitlines():
113
+ s = line.strip()
114
+ if s.startswith("# "):
115
+ title = s[2:].strip()
116
+ # 去掉开头的 REQ-xxxx-xxx 编号前缀
117
+ title = _REQ_RE.sub("", title).strip(" -—|")
118
+ if title:
119
+ return _clean_title(title)
120
+ break # H1 存在但没正文,走下一条 fallback
121
+ # ② 正文 - **需求名称**: xxx / **需求标题**: xxx
122
+ m = re.search(r"\*{0,2}\s*(?:需求名称|需求标题|标题)\s*\*{0,2}\s*[::]\s*(.+)", content)
123
+ if m:
124
+ return _clean_title(m.group(1).strip())
125
+ # 文件名 slug
126
+ return fallback_name
127
+
128
+
129
+ def _clean_title(title: str) -> str:
130
+ """清理标题:去尾部 markdown / 标点 / 多余空白。"""
131
+ title = title.splitlines()[0].strip()
132
+ title = re.sub(r"\s+", " ", title)
133
+ # 去掉行尾的 | 或多余修饰
134
+ title = title.rstrip("| -—·•")
135
+ return title or "未命名"
136
+
137
+
138
+ def extract_date(content: str, file_mtime: float) -> str:
139
+ """提取 PRD 创建日期 (YYYY-MM-DD)。fallback 链:
140
+ 正文「创建日期 / 创建时间」
141
+ 正文任意 2026-06-12 格式日期
142
+ ③ 文件 mtime
143
+ 今天
144
+ """
145
+ # ① 显式「创建日期」/「创建时间」
146
+ m = re.search(
147
+ r"(?:创建日期|创建时间|创建日期)\s*[::]\s*" + _DATE_RE.pattern, content
148
+ )
149
+ if m:
150
+ d = _normalize_date(m.group(1), m.group(2), m.group(3))
151
+ if d:
152
+ return d
153
+ # 任意日期(取第一个)
154
+ m = _DATE_RE.search(content)
155
+ if m:
156
+ d = _normalize_date(m.group(1), m.group(2), m.group(3))
157
+ if d:
158
+ return d
159
+ # ③ mtime
160
+ try:
161
+ return datetime.fromtimestamp(file_mtime).strftime("%Y-%m-%d")
162
+ except (OSError, ValueError, OverflowError):
163
+ pass
164
+ # 今天
165
+ return date.today().strftime("%Y-%m-%d")
166
+
167
+
168
+ def _normalize_date(y: str, m: str, d: str) -> str:
169
+ try:
170
+ return "%s-%02d-%02d" % (y.zfill(4), int(m), int(d))
171
+ except (ValueError, TypeError):
172
+ return ""
173
+
174
+
175
+ def extract_reqno(filename: str) -> str:
176
+ """从文件名提取 REQ 编号段,如 'REQ-2026-005'。取不到则用文件名去扩展名。
177
+ 兼容标准格式 REQ-2026-005 和老格式 REQ-012(单段)。
178
+ """
179
+ m = _REQ_RE.search(filename)
180
+ if m:
181
+ year = m.group(1).zfill(4)
182
+ if m.group(2): # 标准格式 REQ-2026-005
183
+ return "REQ-%s-%s" % (year, m.group(2))
184
+ return "REQ-%s" % year # 老格式 REQ-012
185
+ base = os.path.splitext(filename)[0]
186
+ return base[:40]
187
+
188
+
189
+ # ============================================================
190
+ # 文件名安全化(中文保留,去掉文件系统非法字符)
191
+ # ============================================================
192
+
193
+ _UNSAFE_FS = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
194
+
195
+
196
+ def _sanitize_filename(name: str, max_len: int = 60) -> str:
197
+ """文件名安全化:保留中文/字母/数字/连字符,去掉非法字符,限长。"""
198
+ name = _UNSAFE_FS.sub("", name).strip()
199
+ name = re.sub(r"\s+", "-", name) # 空格转连字符
200
+ if len(name) > max_len:
201
+ name = name[:max_len].rstrip("-")
202
+ return name or "未命名"
203
+
204
+
205
+ # ============================================================
206
+ # 核心:归档单个 PRD
207
+ # ============================================================
208
+
209
+ def archive_one(prd_path: str, repo_root: Path) -> bool:
210
+ """把一个 PRD 归档到当前成员的个人区。返回是否成功。"""
211
+ src = Path(prd_path)
212
+ if not src.is_absolute():
213
+ src = (Path.cwd() / src).resolve()
214
+ if not src.is_file():
215
+ print("[archive] 跳过: 文件不存在 %s" % prd_path)
216
+ return False
217
+
218
+ developer = get_developer(repo_root)
219
+ if not developer:
220
+ print("[archive] 跳过: 未初始化开发者身份 (跑 /wl-init)")
221
+ return False
222
+
223
+ members = MEMBERS_DIR if MEMBERS_DIR else (repo_root / "workspace" / "members")
224
+ archive_root = Path(members) / developer / "archive"
225
+
226
+ content = _read_text_safe(src)
227
+ fname = src.name
228
+ reqno = extract_reqno(fname)
229
+ # 文件名里的 slug(REQ-2026-005-xxx.md xxx 部分)作为最后兜底标题
230
+ slug_fallback = _REQ_RE.sub("", os.path.splitext(fname)[0]).lstrip("-_") or "未命名"
231
+ title = extract_title(content, slug_fallback)
232
+ prd_date = extract_date(content, src.stat().st_mtime)
233
+
234
+ # 目标:members/{dev}/archive/{YYYY-MM-DD}/REQ-{编号}-{中文标题}.md
235
+ day_dir = archive_root / prd_date
236
+ day_dir.mkdir(parents=True, exist_ok=True)
237
+ safe_title = _sanitize_filename(title)
238
+ dst_name = "%s-%s.md" % (reqno, safe_title)
239
+ dst = day_dir / dst_name
240
+
241
+ import shutil
242
+ shutil.copy2(src, dst)
243
+
244
+ # 更新当天 _index.md
245
+ _write_day_index(day_dir, prd_date)
246
+ # 更新 archive 根的 _index.md(周/月汇总)
247
+ _write_overview_index(archive_root, developer)
248
+
249
+ rel = dst.relative_to(repo_root) if str(dst).startswith(str(repo_root)) else dst
250
+ print("[archive] 已归档: %s" % rel)
251
+ return True
252
+
253
+
254
+ def _collect_day_prds(day_dir: Path):
255
+ """收集某天文件夹里的 PRD(.md 且不是 _index)。返回 [(reqno, title, fname), ...]"""
256
+ items = []
257
+ for f in sorted(day_dir.iterdir()):
258
+ if not f.is_file() or not f.name.lower().endswith(".md"):
259
+ continue
260
+ if f.name.startswith("_"):
261
+ continue
262
+ stem = f.stem # REQ-2026-005-异常中心全国看板
263
+ m = _REQ_RE.search(stem)
264
+ reqno = "REQ-%s-%s" % (m.group(1).zfill(4), m.group(2)) if m else stem
265
+ title = _REQ_RE.sub("", stem).lstrip("-_ ") or stem
266
+ items.append((reqno, title, f.name))
267
+ return items
268
+
269
+
270
+ def _write_day_index(day_dir: Path, prd_date: str):
271
+ """写当天的 _index.md:一个清爽的当天需求清单。"""
272
+ items = _collect_day_prds(day_dir)
273
+ lines = ["# %s 的需求产出\n" % prd_date, "> 共 %d 个需求\n" % len(items)]
274
+ if items:
275
+ for reqno, title, fname in items:
276
+ lines.append("- **%s** %s — [`%s`](./%s)" % (reqno, title, fname, fname))
277
+ else:
278
+ lines.append("(无)")
279
+ (day_dir / "_index.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
280
+
281
+
282
+ def _iso_week_range(d: date):
283
+ """返回 d 所在自然周的 周一~周日(ISO 周,周一为起点)。"""
284
+ monday = d - timedelta(days=d.weekday()) # weekday() 周一=0
285
+ sunday = monday + timedelta(days=6)
286
+ return monday, sunday
287
+
288
+
289
+ def _write_overview_index(archive_root: Path, developer: str):
290
+ """写 archive 根的 _index.md:按 周 / 月 汇总,给「我这阵子做了啥」一个总览。"""
291
+ if not archive_root.is_dir():
292
+ return
293
+ # 扫所有日期文件夹
294
+ days = []
295
+ for d in sorted(archive_root.iterdir()):
296
+ if not d.is_dir():
297
+ continue
298
+ try:
299
+ day = date.fromisoformat(d.name) # 只认 YYYY-MM-DD
300
+ except ValueError:
301
+ continue
302
+ items = _collect_day_prds(d)
303
+ if items:
304
+ days.append((day, items))
305
+ if not days:
306
+ return
307
+
308
+ lines = ["# %s 的需求归档\n" % developer, "> 按时间翻查你的需求产出。物理按天归档,下面是周/月汇总。\n"]
309
+
310
+ # ---- 周视图(按自然周聚合)----
311
+ lines.append("## 按周\n")
312
+ # 把连续的天聚到同一周
313
+ weeks = {} # week_key(周一) -> [(day, items)]
314
+ order = []
315
+ for day, items in days:
316
+ monday, _ = _iso_week_range(day)
317
+ if monday not in weeks:
318
+ weeks[monday] = []
319
+ order.append(monday)
320
+ weeks[monday].append((day, items))
321
+ for monday in sorted(order, reverse=True):
322
+ sunday = monday + timedelta(days=6)
323
+ week_items = weeks[monday]
324
+ total = sum(len(it) for _, it in week_items)
325
+ lines.append(
326
+ "### %s 第 %d 周(%s ~ %s)— %d 个需求\n"
327
+ % (monday.year, monday.isocalendar()[1], monday.isoformat(), sunday.isoformat(), total)
328
+ )
329
+ for day, items in sorted(week_items, reverse=True):
330
+ summary = "、".join("%s %s" % (r, t) for r, t, _ in items)
331
+ lines.append("- **%s**:%s" % (day.isoformat(), summary))
332
+ lines.append("")
333
+
334
+ # ---- 月视图 ----
335
+ lines.append("## 按月\n")
336
+ months = {}
337
+ morder = []
338
+ for day, items in days:
339
+ mk = (day.year, day.month)
340
+ if mk not in months:
341
+ months[mk] = []
342
+ morder.append(mk)
343
+ months[mk].append((day, items))
344
+ for mk in sorted(morder, reverse=True):
345
+ m_items = months[mk]
346
+ total = sum(len(it) for _, it in m_items)
347
+ lines.append("### %d年%d月 — %d 个需求" % (mk[0], mk[1], total))
348
+ for day, items in sorted(m_items, reverse=True):
349
+ summary = "、".join("%s" % t for _, t, _ in items)
350
+ lines.append(" - %s:%s" % (day.isoformat(), summary))
351
+ lines.append("")
352
+
353
+ (archive_root / "_index.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
354
+
355
+
356
+ # ============================================================
357
+ # main
358
+ # ============================================================
359
+
360
+ def main():
361
+ args = sys.argv[1:]
362
+ # 过滤 flag
363
+ prd_args = [a for a in args if not a.startswith("-")]
364
+ if not prd_args:
365
+ print("Usage: python .qoder/scripts/archive_prd.py <prd 文件路径> [<prd2> ...]")
366
+ print("把 PRD 归档到当前成员的 workspace/members/{name}/archive/{日期}/")
367
+ sys.exit(1)
368
+
369
+ repo_root = get_repo_root()
370
+ ok = 0
371
+ for p in prd_args:
372
+ try:
373
+ if archive_one(p, repo_root):
374
+ ok += 1
375
+ except Exception as e:
376
+ # 非侵入:归档失败绝不影响 PRD 生成
377
+ print("[archive] 跳过 %s: %s" % (p, str(e)[:120]))
378
+ print("[archive] 完成: %d/%d 已归档" % (ok, len(prd_args)))
379
+ sys.exit(0)
380
+
381
+
382
+ if __name__ == "__main__":
383
+ main()