@hupan56/wlkj 2.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 (87) hide show
  1. package/bin/cli.js +213 -0
  2. package/package.json +11 -0
  3. package/templates/cli.js +198 -0
  4. package/templates/qoder/commands/wl-code.md +43 -0
  5. package/templates/qoder/commands/wl-commit.md +30 -0
  6. package/templates/qoder/commands/wl-init.md +80 -0
  7. package/templates/qoder/commands/wl-insight.md +51 -0
  8. package/templates/qoder/commands/wl-prd.md +199 -0
  9. package/templates/qoder/commands/wl-report.md +166 -0
  10. package/templates/qoder/commands/wl-search.md +52 -0
  11. package/templates/qoder/commands/wl-spec.md +18 -0
  12. package/templates/qoder/commands/wl-status.md +51 -0
  13. package/templates/qoder/commands/wl-task.md +71 -0
  14. package/templates/qoder/commands/wl-test.md +42 -0
  15. package/templates/qoder/config.toml +5 -0
  16. package/templates/qoder/config.yaml +141 -0
  17. package/templates/qoder/hooks/inject-workflow-state.py +117 -0
  18. package/templates/qoder/hooks/session-start.py +204 -0
  19. package/templates/qoder/rules/wl-pipeline.md +105 -0
  20. package/templates/qoder/scripts/add_session.py +245 -0
  21. package/templates/qoder/scripts/benchmark.py +209 -0
  22. package/templates/qoder/scripts/build_style_index.py +268 -0
  23. package/templates/qoder/scripts/code_index.py +41 -0
  24. package/templates/qoder/scripts/collect_prds.py +31 -0
  25. package/templates/qoder/scripts/common/__init__.py +0 -0
  26. package/templates/qoder/scripts/common/active_task.py +230 -0
  27. package/templates/qoder/scripts/common/atomicio.py +172 -0
  28. package/templates/qoder/scripts/common/developer.py +161 -0
  29. package/templates/qoder/scripts/common/eval_api.py +144 -0
  30. package/templates/qoder/scripts/common/feishu.py +278 -0
  31. package/templates/qoder/scripts/common/filelock.py +211 -0
  32. package/templates/qoder/scripts/common/identity.py +285 -0
  33. package/templates/qoder/scripts/common/mentions.py +134 -0
  34. package/templates/qoder/scripts/common/paths.py +311 -0
  35. package/templates/qoder/scripts/common/reqid.py +218 -0
  36. package/templates/qoder/scripts/common/search_engine.py +205 -0
  37. package/templates/qoder/scripts/common/task_utils.py +342 -0
  38. package/templates/qoder/scripts/common/terms.py +234 -0
  39. package/templates/qoder/scripts/common/utf8.py +38 -0
  40. package/templates/qoder/scripts/context_pack.py +196 -0
  41. package/templates/qoder/scripts/eval_prd.py +225 -0
  42. package/templates/qoder/scripts/export.py +487 -0
  43. package/templates/qoder/scripts/git_sync.py +1087 -0
  44. package/templates/qoder/scripts/handoff.py +22 -0
  45. package/templates/qoder/scripts/init_developer.py +76 -0
  46. package/templates/qoder/scripts/init_doctor.py +527 -0
  47. package/templates/qoder/scripts/install_qoderwork.py +339 -0
  48. package/templates/qoder/scripts/learn.py +67 -0
  49. package/templates/qoder/scripts/notify.py +5 -0
  50. package/templates/qoder/scripts/parse_prds.py +33 -0
  51. package/templates/qoder/scripts/report.py +281 -0
  52. package/templates/qoder/scripts/role.py +39 -0
  53. package/templates/qoder/scripts/run_weekly_update.bat +17 -0
  54. package/templates/qoder/scripts/run_weekly_update.sh +20 -0
  55. package/templates/qoder/scripts/search_index.py +352 -0
  56. package/templates/qoder/scripts/setup.py +453 -0
  57. package/templates/qoder/scripts/setup_weekly_cron.bat +22 -0
  58. package/templates/qoder/scripts/setup_weekly_cron.sh +19 -0
  59. package/templates/qoder/scripts/status.py +389 -0
  60. package/templates/qoder/scripts/syncgate.py +330 -0
  61. package/templates/qoder/scripts/task.py +954 -0
  62. package/templates/qoder/scripts/team.py +29 -0
  63. package/templates/qoder/scripts/team_sync.py +419 -0
  64. package/templates/qoder/scripts/workspace_init.py +102 -0
  65. package/templates/qoder/settings.json +53 -0
  66. package/templates/qoder/skills/design-review/SKILL.md +25 -0
  67. package/templates/qoder/skills/prd-generator/SKILL.md +180 -0
  68. package/templates/qoder/skills/prd-review/SKILL.md +36 -0
  69. package/templates/qoder/skills/prototype-generator/SKILL.md +141 -0
  70. package/templates/qoder/skills/spec-coder/SKILL.md +69 -0
  71. package/templates/qoder/skills/spec-generator/SKILL.md +67 -0
  72. package/templates/qoder/skills/test-generator/SKILL.md +72 -0
  73. package/templates/qoder/skills/wl-commit/SKILL.md +76 -0
  74. package/templates/qoder/skills/wl-init/SKILL.md +67 -0
  75. package/templates/qoder/skills/wl-insight/SKILL.md +81 -0
  76. package/templates/qoder/skills/wl-report/SKILL.md +87 -0
  77. package/templates/qoder/skills/wl-search/SKILL.md +75 -0
  78. package/templates/qoder/skills/wl-status/SKILL.md +61 -0
  79. package/templates/qoder/skills/wl-task/SKILL.md +58 -0
  80. package/templates/qoder/templates/prd-full-template.md +103 -0
  81. package/templates/qoder/templates/prd-quick-template.md +69 -0
  82. package/templates/qoder/templates/prototype-app.html +344 -0
  83. package/templates/qoder/templates/prototype-web.html +310 -0
  84. package/templates/root/AGENTS.md +182 -0
  85. package/templates/root/README-pipeline.md +56 -0
  86. package/templates/root/ROLES.md +85 -0
  87. package/templates/root//346/226/260/346/211/213/346/214/207/345/215/227.md +186 -0
@@ -0,0 +1,311 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Path management - central path config
4
+
5
+ All paths managed from one place:
6
+ - .qoder/ = engine (AI reads)
7
+ - workspace/ = work area (humans see)
8
+ - Strictly separated
9
+
10
+ Reference: Trellis path management
11
+ """
12
+
13
+ import os
14
+ import sys
15
+ from pathlib import Path
16
+ from typing import Optional, Dict
17
+
18
+ # Project root (auto-detect: find .qoder/ upward)
19
+ def _find_repo_root() -> Path:
20
+ """Find project root by searching for .qoder/ directory."""
21
+ current = Path(__file__).resolve().parent
22
+ for _ in range(10):
23
+ if (current / ".qoder").is_dir():
24
+ return current
25
+ parent = current.parent
26
+ if parent == current:
27
+ break
28
+ current = parent
29
+ return Path(__file__).resolve().parent.parent.parent.parent.parent
30
+
31
+ PROJECT_ROOT = _find_repo_root()
32
+
33
+ # =============================================================================
34
+ # Engine paths (.qoder/)
35
+ # =============================================================================
36
+
37
+ QODER_DIR = PROJECT_ROOT / ".qoder"
38
+ RUNTIME_DIR = QODER_DIR / ".runtime"
39
+ SESSIONS_DIR = RUNTIME_DIR / "sessions"
40
+ ARCHIVE_DIR = QODER_DIR / "archive"
41
+ RULES_DIR = QODER_DIR / "rules"
42
+ CONTEXT_DIR = QODER_DIR / "context"
43
+ SKILLS_DIR = QODER_DIR / "skills"
44
+ AGENTS_DIR = QODER_DIR / "agents"
45
+ HOOKS_DIR = QODER_DIR / "hooks"
46
+ SCRIPTS_DIR = QODER_DIR / "scripts"
47
+
48
+ # =============================================================================
49
+ # Workspace paths (workspace/)
50
+ # =============================================================================
51
+
52
+ WORKSPACE_DIR = PROJECT_ROOT / "workspace"
53
+ SPECS_DIR = WORKSPACE_DIR / "specs"
54
+ PRD_DIR = SPECS_DIR / "prd"
55
+ TASKS_DIR = WORKSPACE_DIR / "tasks"
56
+ CONSTITUTION_DIR = WORKSPACE_DIR / "constitution"
57
+ MEMBERS_DIR = WORKSPACE_DIR / "members"
58
+
59
+ # =============================================================================
60
+ # Developer files
61
+ # =============================================================================
62
+
63
+ DEVELOPER_FILE = QODER_DIR / ".developer"
64
+ CURRENT_TASK_FILE = QODER_DIR / ".current-task"
65
+
66
+ # =============================================================================
67
+ # Legacy compatibility constants (used by task.py, developer.py, etc.)
68
+ # =============================================================================
69
+
70
+ DIR_TASKS = "workspace/tasks"
71
+ DIR_ARCHIVE = ".qoder/archive"
72
+ DIR_WORKFLOW = ".qoder"
73
+ DIR_WORKSPACE = "workspace/members"
74
+ DIR_SPECS = "workspace/specs"
75
+
76
+ FILE_TASK_JSON = "task.json"
77
+ FILE_DEVELOPER = ".developer"
78
+ FILE_JOURNAL_PREFIX = "journal-"
79
+
80
+
81
+ # =============================================================================
82
+ # Core functions
83
+ # =============================================================================
84
+
85
+ def get_repo_root() -> Path:
86
+ """Get project root directory."""
87
+ return PROJECT_ROOT
88
+
89
+
90
+ def ensure_dirs():
91
+ """Ensure all necessary directories exist."""
92
+ dirs = [
93
+ WORKSPACE_DIR, SPECS_DIR, PRD_DIR, TASKS_DIR,
94
+ CONSTITUTION_DIR, MEMBERS_DIR,
95
+ RUNTIME_DIR, SESSIONS_DIR, ARCHIVE_DIR,
96
+ ]
97
+ for d in dirs:
98
+ d.mkdir(parents=True, exist_ok=True)
99
+
100
+
101
+ def _parse_developer_file(content: str) -> Dict[str, str]:
102
+ """Parse .developer content. Accepts both 'name=x' and 'name: x' formats."""
103
+ info = {}
104
+ for line in content.splitlines():
105
+ line = line.strip()
106
+ if not line or line.startswith("#"):
107
+ continue
108
+ for sep in ("=", ":"):
109
+ if sep in line:
110
+ k, v = line.split(sep, 1)
111
+ info[k.strip()] = v.strip()
112
+ break
113
+ return info
114
+
115
+
116
+ def get_developer(repo_root: Optional[Path] = None) -> Optional[str]:
117
+ """Get current developer name from .developer file."""
118
+ if repo_root is None:
119
+ repo_root = PROJECT_ROOT
120
+ dev_file = repo_root / ".qoder" / ".developer"
121
+ if not dev_file.is_file():
122
+ return None
123
+ try:
124
+ content = dev_file.read_text(encoding="utf-8")
125
+ except UnicodeDecodeError:
126
+ try:
127
+ content = dev_file.read_text(encoding="gbk")
128
+ except (OSError, IOError, UnicodeDecodeError):
129
+ return None
130
+ except (OSError, IOError):
131
+ return None
132
+ return _parse_developer_file(content).get("name") or None
133
+
134
+
135
+ def check_developer(repo_root: Optional[Path] = None) -> bool:
136
+ """Check if developer is initialized."""
137
+ return get_developer(repo_root) is not None
138
+
139
+
140
+ def get_developer_info(repo_root: Optional[Path] = None) -> Optional[Dict]:
141
+ """Get developer info as dict."""
142
+ if repo_root is None:
143
+ repo_root = PROJECT_ROOT
144
+ dev_file = repo_root / ".qoder" / ".developer"
145
+ if not dev_file.is_file():
146
+ return None
147
+ try:
148
+ content = dev_file.read_text(encoding="utf-8")
149
+ except UnicodeDecodeError:
150
+ try:
151
+ content = dev_file.read_text(encoding="gbk")
152
+ except (OSError, IOError, UnicodeDecodeError):
153
+ return None
154
+ except (OSError, IOError):
155
+ return None
156
+ info = _parse_developer_file(content)
157
+ return info if info else None
158
+
159
+
160
+ def get_developer_dir(developer_name: str) -> Path:
161
+ """Get developer personal directory under workspace/members/."""
162
+ dev_dir = MEMBERS_DIR / developer_name
163
+ dev_dir.mkdir(parents=True, exist_ok=True)
164
+ return dev_dir
165
+
166
+
167
+ def get_developer_journal_dir(developer_name: str) -> Path:
168
+ """Get developer journal directory."""
169
+ journal_dir = get_developer_dir(developer_name) / "journal"
170
+ journal_dir.mkdir(parents=True, exist_ok=True)
171
+ return journal_dir
172
+
173
+
174
+ def get_developer_drafts_dir(developer_name: str) -> Path:
175
+ """Get developer drafts directory."""
176
+ drafts_dir = get_developer_dir(developer_name) / "drafts"
177
+ drafts_dir.mkdir(parents=True, exist_ok=True)
178
+ return drafts_dir
179
+
180
+
181
+ def get_workspace_dir(repo_root: Optional[Path] = None) -> Optional[Path]:
182
+ """Get developer workspace directory."""
183
+ dev = get_developer(repo_root)
184
+ if not dev:
185
+ return None
186
+ return MEMBERS_DIR / dev
187
+
188
+
189
+ def get_active_journal_file(repo_root: Optional[Path] = None) -> Optional[Path]:
190
+ """Get developer active journal file."""
191
+ ws = get_workspace_dir(repo_root)
192
+ if not ws:
193
+ return None
194
+ journal_dir = ws / "journal"
195
+ if not journal_dir.is_dir():
196
+ return None
197
+ journals = sorted(journal_dir.glob("journal-*.md"))
198
+ return journals[-1] if journals else None
199
+
200
+
201
+ def get_tasks_dir(repo_root: Optional[Path] = None) -> Path:
202
+ """Get tasks directory (workspace/tasks/)."""
203
+ if repo_root is None:
204
+ repo_root = PROJECT_ROOT
205
+ tasks = repo_root / "workspace" / "tasks"
206
+ tasks.mkdir(parents=True, exist_ok=True)
207
+ return tasks
208
+
209
+
210
+ def _current_task_file(repo_root: Path, developer: Optional[str] = None) -> Path:
211
+ """当前任务文件路径。
212
+
213
+ 零信任隔离: 按开发者命名, 避免共享机器上 A 的 current-task 被 B 覆盖。
214
+ developer=None 时回退到旧的全局 .current-task (向后兼容读取)。
215
+ """
216
+ repo_root = Path(repo_root) # 容忍 str 输入
217
+ runtime = repo_root / ".qoder" / ".runtime"
218
+ if developer:
219
+ # 文件名只允许安全字符 (member name 已校验, 但防御性 sanitize)
220
+ safe = "".join(c for c in developer if c.isalnum() or c in "_-") or "anon"
221
+ return runtime / f"current-task.{safe}"
222
+ return repo_root / ".qoder" / ".current-task"
223
+
224
+
225
+ def _read_task_file(path: Path) -> Optional[str]:
226
+ """读任务文件, 支持 UTF-8 和 GBK fallback (审计 H6: Notepad 默认 GBK)。"""
227
+ if not path.is_file():
228
+ return None
229
+ for enc in ("utf-8", "gbk", "utf-8-sig"):
230
+ try:
231
+ content = path.read_text(encoding=enc).strip()
232
+ return content if content else None
233
+ except (OSError, IOError, UnicodeDecodeError):
234
+ continue
235
+ return None
236
+
237
+
238
+ def get_current_task(
239
+ repo_root: Optional[Path] = None,
240
+ developer: Optional[str] = None,
241
+ ) -> Optional[str]:
242
+ """Get current task.
243
+
244
+ 优先读按开发者隔离的 .runtime/current-task.{dev};
245
+ 若无则回退到旧的全局 .current-task (向后兼容, 会自动迁移)。
246
+ developer=None 时自动用 get_developer() 推断。
247
+ """
248
+ if repo_root is None:
249
+ repo_root = PROJECT_ROOT
250
+ if developer is None:
251
+ developer = get_developer(repo_root)
252
+ # 1. 优先读 developer 隔离的文件
253
+ if developer:
254
+ ct = _read_task_file(_current_task_file(repo_root, developer))
255
+ if ct:
256
+ return ct
257
+ # 2. 回退到旧全局文件 (兼容历史)
258
+ legacy = _read_task_file(_current_task_file(repo_root, None))
259
+ return legacy
260
+
261
+
262
+ def set_current_task(
263
+ task_path: Optional[str],
264
+ repo_root: Optional[Path] = None,
265
+ developer: Optional[str] = None,
266
+ ) -> None:
267
+ """Set current task (按开发者隔离写入)。
268
+
269
+ 零信任: 写入 .runtime/current-task.{dev}, 不再写全局 .current-task。
270
+ 若 task_path=None 则清除该开发者的指针 (不影响他人)。
271
+ """
272
+ if repo_root is None:
273
+ repo_root = PROJECT_ROOT
274
+ if developer is None:
275
+ developer = get_developer(repo_root)
276
+ ct_file = _current_task_file(repo_root, developer)
277
+ try:
278
+ ct_file.parent.mkdir(parents=True, exist_ok=True)
279
+ if task_path:
280
+ ct_file.write_text(task_path, encoding="utf-8")
281
+ else:
282
+ ct_file.unlink(missing_ok=True)
283
+ except (OSError, IOError) as e:
284
+ print(f"Warning: Failed to write current-task: {e}", file=sys.stderr)
285
+
286
+
287
+ def get_module_spec_dir(module: Optional[str] = None) -> Path:
288
+ """Get module spec directory."""
289
+ if module:
290
+ spec_dir = SPECS_DIR / module
291
+ else:
292
+ spec_dir = SPECS_DIR
293
+ spec_dir.mkdir(parents=True, exist_ok=True)
294
+ return spec_dir
295
+
296
+
297
+ def get_task_dir(slug: str) -> Path:
298
+ """Get task directory by slug."""
299
+ from datetime import datetime
300
+ date_prefix = datetime.now().strftime("%m-%d")
301
+ task_dir = TASKS_DIR / "{0}-{1}".format(date_prefix, slug)
302
+ task_dir.mkdir(parents=True, exist_ok=True)
303
+ return task_dir
304
+
305
+
306
+ def count_lines(file_path: Path) -> int:
307
+ """Count lines in a file."""
308
+ try:
309
+ return sum(1 for _ in file_path.open("r", encoding="utf-8"))
310
+ except (OSError, IOError):
311
+ return 0
@@ -0,0 +1,218 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ reqid.py - REQ-ID 原子分配器 + 撞号检测
4
+
5
+ 解决"两个 PM 同时扫到最大 REQ 号 +1, 都用同一个号"的生产事故。
6
+ (已在 data/docs/prd/ 出现两个 REQ-2026-003 的真实撞号)
7
+
8
+ 设计:
9
+ - 中央计数器 data/index/req-counter.json: {"2026": 7, "2025": 47}
10
+ - allocate_req_id(year) 在 FileLock 内读+1+写, 保证原子
11
+ - validate_req_ids(staged_prds) 在 team_sync push 前校验:
12
+ (a) 格式合法 REQ-{YYYY}-{NNN}
13
+ (b) NNN <= 当前计数器 (防"跳号"或"未来号")
14
+ (c) 同一批无重复 ID
15
+ - migrate_from_existing() 一次性扫描, 把计数器初始化为现有最大值
16
+
17
+ 核心 API:
18
+ allocate_req_id(year=None) -> int # 拿一个新 NNN
19
+ current_counter(year=None) -> int # 看当前最大 (不递增)
20
+ validate_req_ids(prd_paths, repo_root) # 校验, 不合法抛 ReqIdError
21
+ migrate_from_existing(repo_root) # 初始化计数器
22
+ parse_req_id(filename) -> (year, n) | None
23
+ """
24
+
25
+ import os
26
+ import re
27
+ import sys
28
+ from datetime import datetime
29
+ from pathlib import Path
30
+ from typing import Optional, Tuple, List, Union
31
+
32
+ _THIS_DIR = os.path.dirname(os.path.abspath(__file__))
33
+ if _THIS_DIR not in sys.path:
34
+ sys.path.insert(0, _THIS_DIR)
35
+
36
+ try:
37
+ from .atomicio import atomic_write_json, safe_read_json
38
+ from .filelock import FileLock, LockTimeoutError
39
+ from .paths import get_repo_root
40
+ except ImportError:
41
+ # 被当作脚本直接运行时
42
+ from atomicio import atomic_write_json, safe_read_json
43
+ from filelock import FileLock, LockTimeoutError
44
+ from paths import get_repo_root
45
+
46
+ __all__ = [
47
+ "ReqIdError",
48
+ "allocate_req_id",
49
+ "current_counter",
50
+ "validate_req_ids",
51
+ "migrate_from_existing",
52
+ "parse_req_id",
53
+ "REQ_ID_RE",
54
+ ]
55
+
56
+ # REQ-2026-007 格式
57
+ REQ_ID_RE = re.compile(r"REQ-(\d{4})-(\d{3,4})", re.IGNORECASE)
58
+
59
+
60
+ class ReqIdError(Exception):
61
+ """REQ-ID 相关错误 (撞号/非法/超界)。"""
62
+
63
+
64
+ def _counter_path(repo_root: Union[str, Path]) -> str:
65
+ return os.path.join(str(repo_root), "data", "index", "req-counter.json")
66
+
67
+
68
+ def _lock_path(repo_root: Union[str, Path]) -> str:
69
+ return os.path.join(str(repo_root), "data", "index", ".req-lock")
70
+
71
+
72
+ def _current_year() -> str:
73
+ return str(datetime.now().year)
74
+
75
+
76
+ def _read_counter(repo_root: Union[str, Path]) -> dict:
77
+ """读计数器 (不锁)。返回 {year: max_n} dict。"""
78
+ return safe_read_json(_counter_path(repo_root), default={}) or {}
79
+
80
+
81
+ def current_counter(year: Optional[str] = None, repo_root: Optional[Union[str, Path]] = None) -> int:
82
+ """看某年当前最大 NNN (不递增)。计数器不存在则返回 0。"""
83
+ if repo_root is None:
84
+ repo_root = get_repo_root()
85
+ if year is None:
86
+ year = _current_year()
87
+ return int(_read_counter(repo_root).get(str(year), 0))
88
+
89
+
90
+ def allocate_req_id(year: Optional[str] = None, repo_root: Optional[Union[str, Path]] = None) -> int:
91
+ """原子分配一个新 NNN (在文件锁内读+1+写)。
92
+
93
+ Returns:
94
+ 分配到的 NNN (整数)。
95
+ """
96
+ if repo_root is None:
97
+ repo_root = get_repo_root()
98
+ if year is None:
99
+ year = _current_year()
100
+ year = str(year)
101
+
102
+ lock = FileLock(_lock_path(repo_root), timeout=30, stale_seconds=300)
103
+ try:
104
+ lock.acquire()
105
+ except LockTimeoutError as e:
106
+ raise ReqIdError("REQ-ID 分配锁竞争超时 (多人同时创建?): %s" % e)
107
+ try:
108
+ counter = _read_counter(repo_root)
109
+ next_n = int(counter.get(year, 0)) + 1
110
+ counter[year] = next_n
111
+ atomic_write_json(_counter_path(repo_root), counter)
112
+ return next_n
113
+ finally:
114
+ lock.release()
115
+
116
+
117
+ def parse_req_id(filename_or_path: str) -> Optional[Tuple[int, int]]:
118
+ """从文件名或路径解析 REQ-{YYYY}-{NNN}。返回 (year, n) 或 None。"""
119
+ base = os.path.basename(filename_or_path)
120
+ m = REQ_ID_RE.search(base)
121
+ if m:
122
+ return int(m.group(1)), int(m.group(2))
123
+ return None
124
+
125
+
126
+ def validate_req_ids(prd_paths: List[str], repo_root: Union[str, Path]) -> None:
127
+ """校验一批 PRD 的 REQ-ID。不合法抛 ReqIdError。
128
+
129
+ 检查:
130
+ (a) 每个 PRD 文件名必须有 REQ-{YYYY}-{NNN}
131
+ (b) NNN 不能超过当前计数器 (防跳号/未来号; 但允许 NNN == 已分配的号,
132
+ 因为可能是修改已有 PRD)
133
+ (c) 同一批无重复 ID
134
+
135
+ 注意: 允许 NNN <= 计数器, 因为可能是更新已有 PRD (合法)。
136
+ 撞号只在"同一批出现两个相同 ID"或"NNN 超过计数器"时报错。
137
+ """
138
+ seen = {}
139
+ errors = []
140
+ for p in prd_paths:
141
+ parsed = parse_req_id(p)
142
+ if not parsed:
143
+ errors.append("%s: 文件名无 REQ-{YYYY}-{NNN}" % os.path.basename(p))
144
+ continue
145
+ year, n = parsed
146
+ # 同批重复检测
147
+ key = (year, n)
148
+ if key in seen:
149
+ errors.append(
150
+ "撞号: %s 和 %s 都是 REQ-%d-%03d"
151
+ % (os.path.basename(seen[key]), os.path.basename(p), year, n)
152
+ )
153
+ else:
154
+ seen[key] = p
155
+ # 超界检测 (NNN 不能超过计数器, 防止跳号)
156
+ max_n = current_counter(str(year), repo_root)
157
+ if max_n > 0 and n > max_n:
158
+ errors.append(
159
+ "%s: REQ-%d-%03d 超过计数器最大值 %d (跳号?)"
160
+ % (os.path.basename(p), year, n, max_n)
161
+ )
162
+ if errors:
163
+ raise ReqIdError("[gate] 拒绝: REQ-ID 校验失败:\n " + "\n ".join(errors))
164
+
165
+
166
+ def migrate_from_existing(repo_root: Optional[Union[str, Path]] = None) -> dict:
167
+ """一次性迁移: 扫描所有现有 PRD, 把计数器初始化为各年最大值。
168
+
169
+ 扫描位置:
170
+ - data/docs/prd/REQ-*.md
171
+ - workspace/specs/prd/REQ-*.md
172
+ - workspace/members/*/drafts/REQ-*.md
173
+
174
+ Returns:
175
+ 迁移后的计数器 dict, 同时写入 data/index/req-counter.json。
176
+ """
177
+ if repo_root is None:
178
+ repo_root = get_repo_root()
179
+ repo_root = Path(repo_root)
180
+
181
+ # 幂等: 用文件锁保证迁移本身不并发
182
+ lock = FileLock(_lock_path(repo_root), timeout=30, stale_seconds=300)
183
+ try:
184
+ lock.acquire()
185
+ except LockTimeoutError as e:
186
+ raise ReqIdError("迁移锁竞争: %s" % e)
187
+ try:
188
+ existing = _read_counter(repo_root)
189
+ counter = {k: int(v) for k, v in existing.items()}
190
+
191
+ scan_dirs = [
192
+ repo_root / "data" / "docs" / "prd",
193
+ repo_root / "workspace" / "specs" / "prd",
194
+ ]
195
+ # 加成员 drafts
196
+ members_dir = repo_root / "workspace" / "members"
197
+ if members_dir.is_dir():
198
+ for m in members_dir.iterdir():
199
+ d = m / "drafts"
200
+ if d.is_dir():
201
+ scan_dirs.append(d)
202
+
203
+ for d in scan_dirs:
204
+ if not d.is_dir():
205
+ continue
206
+ for f in d.iterdir():
207
+ if not f.is_file() or not f.name.lower().endswith(".md"):
208
+ continue
209
+ parsed = parse_req_id(f.name)
210
+ if parsed:
211
+ year, n = parsed
212
+ key = str(year)
213
+ counter[key] = max(int(counter.get(key, 0)), n)
214
+
215
+ atomic_write_json(_counter_path(repo_root), counter)
216
+ return counter
217
+ finally:
218
+ lock.release()