@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.
- package/bin/cli.js +213 -0
- package/package.json +11 -0
- package/templates/cli.js +198 -0
- package/templates/qoder/commands/wl-code.md +43 -0
- package/templates/qoder/commands/wl-commit.md +30 -0
- package/templates/qoder/commands/wl-init.md +80 -0
- package/templates/qoder/commands/wl-insight.md +51 -0
- package/templates/qoder/commands/wl-prd.md +199 -0
- package/templates/qoder/commands/wl-report.md +166 -0
- package/templates/qoder/commands/wl-search.md +52 -0
- package/templates/qoder/commands/wl-spec.md +18 -0
- package/templates/qoder/commands/wl-status.md +51 -0
- package/templates/qoder/commands/wl-task.md +71 -0
- package/templates/qoder/commands/wl-test.md +42 -0
- package/templates/qoder/config.toml +5 -0
- package/templates/qoder/config.yaml +141 -0
- package/templates/qoder/hooks/inject-workflow-state.py +117 -0
- package/templates/qoder/hooks/session-start.py +204 -0
- package/templates/qoder/rules/wl-pipeline.md +105 -0
- package/templates/qoder/scripts/add_session.py +245 -0
- package/templates/qoder/scripts/benchmark.py +209 -0
- package/templates/qoder/scripts/build_style_index.py +268 -0
- package/templates/qoder/scripts/code_index.py +41 -0
- package/templates/qoder/scripts/collect_prds.py +31 -0
- package/templates/qoder/scripts/common/__init__.py +0 -0
- package/templates/qoder/scripts/common/active_task.py +230 -0
- package/templates/qoder/scripts/common/atomicio.py +172 -0
- package/templates/qoder/scripts/common/developer.py +161 -0
- package/templates/qoder/scripts/common/eval_api.py +144 -0
- package/templates/qoder/scripts/common/feishu.py +278 -0
- package/templates/qoder/scripts/common/filelock.py +211 -0
- package/templates/qoder/scripts/common/identity.py +285 -0
- package/templates/qoder/scripts/common/mentions.py +134 -0
- package/templates/qoder/scripts/common/paths.py +311 -0
- package/templates/qoder/scripts/common/reqid.py +218 -0
- package/templates/qoder/scripts/common/search_engine.py +205 -0
- package/templates/qoder/scripts/common/task_utils.py +342 -0
- package/templates/qoder/scripts/common/terms.py +234 -0
- package/templates/qoder/scripts/common/utf8.py +38 -0
- package/templates/qoder/scripts/context_pack.py +196 -0
- package/templates/qoder/scripts/eval_prd.py +225 -0
- package/templates/qoder/scripts/export.py +487 -0
- package/templates/qoder/scripts/git_sync.py +1087 -0
- package/templates/qoder/scripts/handoff.py +22 -0
- package/templates/qoder/scripts/init_developer.py +76 -0
- package/templates/qoder/scripts/init_doctor.py +527 -0
- package/templates/qoder/scripts/install_qoderwork.py +339 -0
- package/templates/qoder/scripts/learn.py +67 -0
- package/templates/qoder/scripts/notify.py +5 -0
- package/templates/qoder/scripts/parse_prds.py +33 -0
- package/templates/qoder/scripts/report.py +281 -0
- package/templates/qoder/scripts/role.py +39 -0
- package/templates/qoder/scripts/run_weekly_update.bat +17 -0
- package/templates/qoder/scripts/run_weekly_update.sh +20 -0
- package/templates/qoder/scripts/search_index.py +352 -0
- package/templates/qoder/scripts/setup.py +453 -0
- package/templates/qoder/scripts/setup_weekly_cron.bat +22 -0
- package/templates/qoder/scripts/setup_weekly_cron.sh +19 -0
- package/templates/qoder/scripts/status.py +389 -0
- package/templates/qoder/scripts/syncgate.py +330 -0
- package/templates/qoder/scripts/task.py +954 -0
- package/templates/qoder/scripts/team.py +29 -0
- package/templates/qoder/scripts/team_sync.py +419 -0
- package/templates/qoder/scripts/workspace_init.py +102 -0
- package/templates/qoder/settings.json +53 -0
- package/templates/qoder/skills/design-review/SKILL.md +25 -0
- package/templates/qoder/skills/prd-generator/SKILL.md +180 -0
- package/templates/qoder/skills/prd-review/SKILL.md +36 -0
- package/templates/qoder/skills/prototype-generator/SKILL.md +141 -0
- package/templates/qoder/skills/spec-coder/SKILL.md +69 -0
- package/templates/qoder/skills/spec-generator/SKILL.md +67 -0
- package/templates/qoder/skills/test-generator/SKILL.md +72 -0
- package/templates/qoder/skills/wl-commit/SKILL.md +76 -0
- package/templates/qoder/skills/wl-init/SKILL.md +67 -0
- package/templates/qoder/skills/wl-insight/SKILL.md +81 -0
- package/templates/qoder/skills/wl-report/SKILL.md +87 -0
- package/templates/qoder/skills/wl-search/SKILL.md +75 -0
- package/templates/qoder/skills/wl-status/SKILL.md +61 -0
- package/templates/qoder/skills/wl-task/SKILL.md +58 -0
- package/templates/qoder/templates/prd-full-template.md +103 -0
- package/templates/qoder/templates/prd-quick-template.md +69 -0
- package/templates/qoder/templates/prototype-app.html +344 -0
- package/templates/qoder/templates/prototype-web.html +310 -0
- package/templates/root/AGENTS.md +182 -0
- package/templates/root/README-pipeline.md +56 -0
- package/templates/root/ROLES.md +85 -0
- 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()
|