@hupan56/wlkj 3.1.24 → 3.1.26
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 +1516 -1492
- package/package.json +1 -1
- package/templates/qoder/scripts/README.md +102 -102
- package/templates/qoder/scripts/deployment/setup/init_doctor.py +1 -1
- package/templates/qoder/scripts/deployment/setup/setup.py +1 -1
- package/templates/qoder/scripts/domain/report/add_session.py +2 -2
- package/templates/qoder/scripts/domain/task/task.py +1 -1
- package/templates/qoder/scripts/foundation/core/paths.py +705 -705
- package/templates/qoder/scripts/foundation/data/task_utils.py +427 -427
- package/templates/qoder/scripts/foundation/integrations/active_task.py +244 -244
- package/templates/root/AGENTS.md +1 -1
|
@@ -1,706 +1,706 @@
|
|
|
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:
|
|
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 _is_real_repo_root(path: Path) -> bool:
|
|
20
|
-
"""判定某目录是不是真正的 QODER 项目仓库根。
|
|
21
|
-
|
|
22
|
-
光有 ``.qoder/`` 不够 —— QoderWork 桌面端 / Qoder IDE 会在用户家目录建
|
|
23
|
-
``~/.qoder/``(客户端配置: agents/cache/logs/plugins/...),它**没有
|
|
24
|
-
scripts/ 子目录**。只有真仓库的 ``.qoder/`` 才含 ``scripts/``(引擎)。
|
|
25
|
-
用 ``scripts/`` 作为引擎标志区分二者,避免把客户端目录误判为仓库根
|
|
26
|
-
(那会导致 get_developer() 读到错误的 .developer → QoderWork 误报
|
|
27
|
-
"身份未初始化",即使项目里早已 /wl-init 过)。
|
|
28
|
-
"""
|
|
29
|
-
return (path / ".qoder").is_dir() and (path / ".qoder" / "scripts").is_dir()
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def _find_repo_root() -> Path:
|
|
33
|
-
"""Find project root by searching for .qoder/ directory.
|
|
34
|
-
|
|
35
|
-
优先看 cwd: AI/CLI 常在项目根调 `python .qoder/scripts/xxx.py`,
|
|
36
|
-
cwd 就是项目根。再用 __file__ 兜底 (脚本被 import 时 cwd 可能不对)。
|
|
37
|
-
|
|
38
|
-
⚠️ 必须用 _is_real_repo_root 校验: 仅 .qoder/ 不够, QoderWork/Qoder
|
|
39
|
-
会在家目录建无 scripts/ 的客户端 ~/.qoder/, 会造成误判。
|
|
40
|
-
"""
|
|
41
|
-
cwd = Path.cwd()
|
|
42
|
-
if _is_real_repo_root(cwd):
|
|
43
|
-
return cwd
|
|
44
|
-
current = cwd
|
|
45
|
-
for _ in range(10):
|
|
46
|
-
if _is_real_repo_root(current):
|
|
47
|
-
return current
|
|
48
|
-
parent = current.parent
|
|
49
|
-
if parent == current:
|
|
50
|
-
break
|
|
51
|
-
current = parent
|
|
52
|
-
current = Path(__file__).resolve().parent
|
|
53
|
-
for _ in range(10):
|
|
54
|
-
if _is_real_repo_root(current):
|
|
55
|
-
return current
|
|
56
|
-
parent = current.parent
|
|
57
|
-
if parent == current:
|
|
58
|
-
break
|
|
59
|
-
current = parent
|
|
60
|
-
return Path(__file__).resolve().parent.parent.parent.parent.parent
|
|
61
|
-
|
|
62
|
-
PROJECT_ROOT = _find_repo_root()
|
|
63
|
-
|
|
64
|
-
# =============================================================================
|
|
65
|
-
# Engine paths (.qoder/)
|
|
66
|
-
# =============================================================================
|
|
67
|
-
|
|
68
|
-
QODER_DIR = PROJECT_ROOT / ".qoder"
|
|
69
|
-
RUNTIME_DIR = QODER_DIR / ".runtime"
|
|
70
|
-
SESSIONS_DIR = RUNTIME_DIR / "sessions"
|
|
71
|
-
ARCHIVE_DIR = QODER_DIR / "archive"
|
|
72
|
-
RULES_DIR = QODER_DIR / "rules"
|
|
73
|
-
CONTEXT_DIR = QODER_DIR / "context"
|
|
74
|
-
SKILLS_DIR = QODER_DIR / "skills"
|
|
75
|
-
AGENTS_DIR = QODER_DIR / "agents"
|
|
76
|
-
HOOKS_DIR = QODER_DIR / "hooks"
|
|
77
|
-
SCRIPTS_DIR = QODER_DIR / "scripts"
|
|
78
|
-
|
|
79
|
-
# =============================================================================
|
|
80
|
-
# Workspace paths (workspace/)
|
|
81
|
-
# =============================================================================
|
|
82
|
-
|
|
83
|
-
WORKSPACE_DIR = PROJECT_ROOT / "workspace"
|
|
84
|
-
SPECS_DIR = WORKSPACE_DIR / "specs"
|
|
85
|
-
PRD_DIR = SPECS_DIR / "prd"
|
|
86
|
-
TASKS_DIR = WORKSPACE_DIR / "tasks"
|
|
87
|
-
CONSTITUTION_DIR = WORKSPACE_DIR / "constitution"
|
|
88
|
-
MEMBERS_DIR = WORKSPACE_DIR / "members"
|
|
89
|
-
|
|
90
|
-
# =============================================================================
|
|
91
|
-
# Developer files
|
|
92
|
-
# =============================================================================
|
|
93
|
-
|
|
94
|
-
DEVELOPER_FILE = QODER_DIR / ".developer"
|
|
95
|
-
CURRENT_TASK_FILE = QODER_DIR / ".current-task"
|
|
96
|
-
|
|
97
|
-
# =============================================================================
|
|
98
|
-
# Legacy compatibility constants (used by task.py, developer.py, etc.)
|
|
99
|
-
# =============================================================================
|
|
100
|
-
|
|
101
|
-
DIR_TASKS = "workspace/tasks"
|
|
102
|
-
DIR_ARCHIVE = ".qoder/archive"
|
|
103
|
-
DIR_WORKFLOW = ".qoder"
|
|
104
|
-
DIR_WORKSPACE = "workspace/members"
|
|
105
|
-
DIR_SPECS = "workspace/specs"
|
|
106
|
-
|
|
107
|
-
FILE_TASK_JSON = "task.json"
|
|
108
|
-
FILE_DEVELOPER = ".developer"
|
|
109
|
-
FILE_JOURNAL_PREFIX = "journal-"
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
# =============================================================================
|
|
113
|
-
# Index file registry — SINGLE SOURCE OF TRUTH for data/index/ filenames
|
|
114
|
-
# =============================================================================
|
|
115
|
-
# Why this exists: 17 index files used to be flat-named in data/index/ with
|
|
116
|
-
# 4 different naming styles, and two (module-map vs module-index) differed by
|
|
117
|
-
# one word but meant totally different things. Every consumer hardcoded the
|
|
118
|
-
# filename as a string literal. Now all names live here — rename = change once.
|
|
119
|
-
#
|
|
120
|
-
# Naming convention: <category>-<subject>.json
|
|
121
|
-
# code- source code layer (search, APIs, stats, dependency graph)
|
|
122
|
-
# ui- UI/style layer (page patterns, module names, design fingerprint)
|
|
123
|
-
# trace- call-chain (button click → API → controller)
|
|
124
|
-
# prd- product requirements
|
|
125
|
-
# wiki- semantic module docs
|
|
126
|
-
# ref- hand-curated static reference (NOT auto-built)
|
|
127
|
-
# Dotfiles (.meta, .cache) are machine-local bookkeeping.
|
|
128
|
-
|
|
129
|
-
DATA_INDEX_DIR = PROJECT_ROOT / "data" / "index"
|
|
130
|
-
BUILD_CACHE_DIR = DATA_INDEX_DIR / "_build_cache" # JSON 中间产物 (构建器写→kg_duckdb读→DuckDB)
|
|
131
|
-
KG_DB_PATH = DATA_INDEX_DIR / "kg.duckdb" # 知识图谱单文件存储 (消费层唯一查询入口)
|
|
132
|
-
|
|
133
|
-
# ── Learning / quality history (data/learning/) ──
|
|
134
|
-
# Why centralized: eval_prd.py writes here, status.py reads it. Past bug had
|
|
135
|
-
# writer at data/learning/ but reader at .qoder/learning/ (never existed) →
|
|
136
|
-
# EVA dimension in /wl-status was permanently dead. Single constant kills drift.
|
|
137
|
-
DATA_LEARNING_DIR = PROJECT_ROOT / "data" / "learning"
|
|
138
|
-
EVAL_HISTORY_PATH = DATA_LEARNING_DIR / "eval-history.jsonl" # eval_prd.py 写 / status.py 读
|
|
139
|
-
|
|
140
|
-
INDEX_FILES = {
|
|
141
|
-
# ── code layer (built by git_sync.py) ──
|
|
142
|
-
'code_keyword': 'code-keyword.json', # was code-keyword.json
|
|
143
|
-
'code_api': 'code-api.json', # was code-api.json
|
|
144
|
-
'code_stats': 'code-stats.json', # was code-stats.json (PROJECT FILE COUNTS)
|
|
145
|
-
'code_callgraph': 'code-code-callgraph.json', # was code-callgraph.json
|
|
146
|
-
# ── UI layer (built by build_style_index.py) ──
|
|
147
|
-
'ui_style': 'ui-style.json', # was ui-style.json
|
|
148
|
-
'ui_style_meta': 'ui-ui-style-meta.json', # was ui-style-meta.json
|
|
149
|
-
'ui_modules': 'ui-modules.json', # was ui-modules.json (BUSINESS MODULE NAMES)
|
|
150
|
-
# ── call-chain (built by build_style_index.py) ──
|
|
151
|
-
'trace_chain': 'trace-chain.json', # was trace-chain.json
|
|
152
|
-
# ── 浏览器测试锚点 (built/updated by autotest.py learn) ──
|
|
153
|
-
'test_pages': 'test-pages.json', # 页面稳定锚点(role/name/placeholder), 团队共享
|
|
154
|
-
# ── PRD layer (built by git_sync.py) ──
|
|
155
|
-
'prd_index': 'prd-index.json', # (name unchanged, already clear)
|
|
156
|
-
'prd_code_map': 'prd-code-map.json', # (name unchanged)
|
|
157
|
-
# ── semantic ──
|
|
158
|
-
'wiki_index': 'wiki-index.json', # (name unchanged)
|
|
159
|
-
# ── static reference (hand-curated, committed to git) ──
|
|
160
|
-
'ref_vben': 'ref-vben-style.json', # was ref-vben-style.json
|
|
161
|
-
'ref_chart': 'ref-chart-style.json', # was ref-chart-style.json
|
|
162
|
-
'ref_icon': 'ref-icon.json', # was ref-icon.json
|
|
163
|
-
# ── meta / cache (dotfiles, machine-local) ──
|
|
164
|
-
'meta_index': '.index-meta.json', # (name unchanged)
|
|
165
|
-
'meta_prd_collected': '.prd-collected.json', # (name unchanged)
|
|
166
|
-
'meta_last_sync': '.last-sync', # (name unchanged)
|
|
167
|
-
'cache_inverted': '.inverted-cache.json', # (name unchanged)
|
|
168
|
-
'cache_file_keys': '.file-keys.json', # (name unchanged)
|
|
169
|
-
'meta_req_counter': 'req-counter.json', # (name unchanged)
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
# Legacy → new name mapping (for migration + backward-compat lookups)
|
|
173
|
-
INDEX_NAME_MIGRATION = {
|
|
174
|
-
'code-keyword.json': 'code-keyword.json',
|
|
175
|
-
'code-api.json': 'code-api.json',
|
|
176
|
-
'code-stats.json': 'code-stats.json',
|
|
177
|
-
'code-callgraph.json': 'code-code-callgraph.json',
|
|
178
|
-
'ui-style.json': 'ui-style.json',
|
|
179
|
-
'ui-style-meta.json': 'ui-ui-style-meta.json',
|
|
180
|
-
'ui-modules.json': 'ui-modules.json',
|
|
181
|
-
'trace-chain.json': 'trace-chain.json',
|
|
182
|
-
'ref-vben-style.json': 'ref-vben-style.json',
|
|
183
|
-
'ref-chart-style.json': 'ref-chart-style.json',
|
|
184
|
-
'ref-icon.json': 'ref-icon.json',
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
def index_path(key: str) -> Path:
|
|
189
|
-
"""Get the full path for an index file by its registry key.
|
|
190
|
-
|
|
191
|
-
Usage: index_path('code_keyword') → data/index/code-keyword.json
|
|
192
|
-
"""
|
|
193
|
-
name = INDEX_FILES.get(key)
|
|
194
|
-
if name is None:
|
|
195
|
-
raise KeyError('Unknown index key: %r (valid: %s)' % (key, sorted(INDEX_FILES)))
|
|
196
|
-
return DATA_INDEX_DIR / name
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
def resolve_index_name(filename: str) -> str:
|
|
200
|
-
"""Resolve a (possibly legacy) index filename to its current name.
|
|
201
|
-
|
|
202
|
-
Falls back to the input unchanged if not in the migration map.
|
|
203
|
-
Used by load helpers for backward compatibility.
|
|
204
|
-
"""
|
|
205
|
-
return INDEX_NAME_MIGRATION.get(filename, filename)
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
# =============================================================================
|
|
209
|
-
# Script locator — SINGLE SOURCE OF TRUTH for finding .py files after subdir migration
|
|
210
|
-
# =============================================================================
|
|
211
|
-
# v3.0 子目录化后, 脚本从 scripts/ 根搬到了 scripts/{kg,report,mcp,...}/ 子包。
|
|
212
|
-
# 但调用方 (kg_mcp_server.py / kg.py) 还在用 os.path.join(SCRIPTS_DIR, 'xxx.py')
|
|
213
|
-
# 按旧扁平结构找 → FileNotFound。本函数扫所有子目录, 单一真源定位。
|
|
214
|
-
|
|
215
|
-
def find_script(name: str) -> Optional[Path]:
|
|
216
|
-
"""在 scripts/ 树下定位一个 .py 脚本 (跨子目录自动查找)。
|
|
217
|
-
|
|
218
|
-
先查 scripts/ 根, 再扫所有子目录 (kg/report/mcp/setup/test/...)。
|
|
219
|
-
消除调用方各写各的路径拼装导致的迁移断裂 bug。
|
|
220
|
-
|
|
221
|
-
Args:
|
|
222
|
-
name: 脚本文件名, 如 'fill_prototype.py' / 'gen_design_doc.py'
|
|
223
|
-
Returns:
|
|
224
|
-
找到的绝对路径, 或 None (脚本不存在)
|
|
225
|
-
"""
|
|
226
|
-
if not name:
|
|
227
|
-
return None
|
|
228
|
-
# 1. 先查 scripts/ 根 (老扁平结构残留的脚本)
|
|
229
|
-
root_hit = SCRIPTS_DIR / name
|
|
230
|
-
if root_hit.is_file():
|
|
231
|
-
return root_hit
|
|
232
|
-
# 2. 扫一级子目录 (kg/report/mcp/setup/test/...)
|
|
233
|
-
try:
|
|
234
|
-
for child in SCRIPTS_DIR.iterdir():
|
|
235
|
-
if not child.is_dir():
|
|
236
|
-
continue
|
|
237
|
-
cand = child / name
|
|
238
|
-
if cand.is_file():
|
|
239
|
-
return cand
|
|
240
|
-
except (OSError, PermissionError):
|
|
241
|
-
pass
|
|
242
|
-
return None
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
# =============================================================================
|
|
246
|
-
# Core functions
|
|
247
|
-
# =============================================================================
|
|
248
|
-
|
|
249
|
-
def get_repo_root() -> Path:
|
|
250
|
-
"""Get project root directory."""
|
|
251
|
-
return PROJECT_ROOT
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
def ensure_dirs():
|
|
255
|
-
"""Ensure all necessary directories exist."""
|
|
256
|
-
dirs = [
|
|
257
|
-
WORKSPACE_DIR, SPECS_DIR, PRD_DIR, TASKS_DIR,
|
|
258
|
-
CONSTITUTION_DIR, MEMBERS_DIR,
|
|
259
|
-
RUNTIME_DIR, SESSIONS_DIR, ARCHIVE_DIR,
|
|
260
|
-
]
|
|
261
|
-
for d in dirs:
|
|
262
|
-
d.mkdir(parents=True, exist_ok=True)
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
def _parse_developer_file(content: str) -> Dict[str, str]:
|
|
266
|
-
"""Parse .developer content. Accepts both 'name=x' and 'name: x' formats."""
|
|
267
|
-
info = {}
|
|
268
|
-
for line in content.splitlines():
|
|
269
|
-
line = line.strip()
|
|
270
|
-
if not line or line.startswith("#"):
|
|
271
|
-
continue
|
|
272
|
-
for sep in ("=", ":"):
|
|
273
|
-
if sep in line:
|
|
274
|
-
k, v = line.split(sep, 1)
|
|
275
|
-
info[k.strip()] = v.strip()
|
|
276
|
-
break
|
|
277
|
-
return info
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
def get_developer(repo_root: Optional[Path] = None) -> Optional[str]:
|
|
281
|
-
"""Get current developer name from .developer file."""
|
|
282
|
-
if repo_root is None:
|
|
283
|
-
repo_root = PROJECT_ROOT
|
|
284
|
-
dev_file = repo_root / ".qoder" / ".developer"
|
|
285
|
-
if not dev_file.is_file():
|
|
286
|
-
return None
|
|
287
|
-
try:
|
|
288
|
-
content = dev_file.read_text(encoding="utf-8")
|
|
289
|
-
except UnicodeDecodeError:
|
|
290
|
-
try:
|
|
291
|
-
content = dev_file.read_text(encoding="gbk")
|
|
292
|
-
except (OSError, IOError, UnicodeDecodeError):
|
|
293
|
-
return None
|
|
294
|
-
except (OSError, IOError):
|
|
295
|
-
return None
|
|
296
|
-
return _parse_developer_file(content).get("name") or None
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
def check_developer(repo_root: Optional[Path] = None) -> bool:
|
|
300
|
-
"""Check if developer is initialized."""
|
|
301
|
-
return get_developer(repo_root) is not None
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
def get_developer_info(repo_root: Optional[Path] = None) -> Optional[Dict]:
|
|
305
|
-
"""Get developer info as dict."""
|
|
306
|
-
if repo_root is None:
|
|
307
|
-
repo_root = PROJECT_ROOT
|
|
308
|
-
dev_file = repo_root / ".qoder" / ".developer"
|
|
309
|
-
if not dev_file.is_file():
|
|
310
|
-
return None
|
|
311
|
-
try:
|
|
312
|
-
content = dev_file.read_text(encoding="utf-8")
|
|
313
|
-
except UnicodeDecodeError:
|
|
314
|
-
try:
|
|
315
|
-
content = dev_file.read_text(encoding="gbk")
|
|
316
|
-
except (OSError, IOError, UnicodeDecodeError):
|
|
317
|
-
return None
|
|
318
|
-
except (OSError, IOError):
|
|
319
|
-
return None
|
|
320
|
-
info = _parse_developer_file(content)
|
|
321
|
-
return info if info else None
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
def get_developer_dir(developer_name: str) -> Path:
|
|
325
|
-
"""Get developer personal directory under workspace/members/."""
|
|
326
|
-
dev_dir = MEMBERS_DIR / developer_name
|
|
327
|
-
dev_dir.mkdir(parents=True, exist_ok=True)
|
|
328
|
-
return dev_dir
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
def get_developer_journal_dir(developer_name: str) -> Path:
|
|
332
|
-
"""Get developer journal directory."""
|
|
333
|
-
journal_dir = get_developer_dir(developer_name) / "journal"
|
|
334
|
-
journal_dir.mkdir(parents=True, exist_ok=True)
|
|
335
|
-
return journal_dir
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
def get_developer_drafts_dir(developer_name: str) -> Path:
|
|
339
|
-
"""Get developer drafts directory."""
|
|
340
|
-
drafts_dir = get_developer_dir(developer_name) / "drafts"
|
|
341
|
-
drafts_dir.mkdir(parents=True, exist_ok=True)
|
|
342
|
-
return drafts_dir
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
# =============================================================================
|
|
346
|
-
# 私有凭证目录 (M3: 收拢散落的敏感文件到 .private/)
|
|
347
|
-
# =============================================================================
|
|
348
|
-
# 现状问题: member根目录散落5个凭证文件(.signing_key/.secrets/auth-state.json/
|
|
349
|
-
# autotest-data.yaml), 和产出目录混在一起, 没层次。
|
|
350
|
-
# 解法: 全收进 .private/ (个人私有, 全gitignore), 根目录只留结构化产出。
|
|
351
|
-
#
|
|
352
|
-
# 向后兼容: get_private_file() 先查 .private/新位置, 没有则回退老位置。
|
|
353
|
-
|
|
354
|
-
PRIVATE_DIR_NAME = ".private"
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
def get_private_dir(developer_name: str = None) -> Path:
|
|
358
|
-
"""开发者的私有凭证目录 workspace/members/{dev}/.private/
|
|
359
|
-
|
|
360
|
-
所有敏感数据(signing_key/secrets/auth-state/autotest-data)收进这。
|
|
361
|
-
全 gitignore, 永不 push。
|
|
362
|
-
"""
|
|
363
|
-
if developer_name is None:
|
|
364
|
-
developer_name = get_developer() or "unknown"
|
|
365
|
-
p = get_developer_dir(developer_name) / PRIVATE_DIR_NAME
|
|
366
|
-
p.mkdir(parents=True, exist_ok=True)
|
|
367
|
-
return p
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
def get_private_file(filename: str, developer_name: str = None) -> Path:
|
|
371
|
-
"""取私有文件路径 (向后兼容: 先 .private/新位置, 没有回退老位置)。
|
|
372
|
-
|
|
373
|
-
Args:
|
|
374
|
-
filename: 文件名 (如 'signing_key', 'auth-state.json', 'autotest-data.yaml')
|
|
375
|
-
Returns:
|
|
376
|
-
.private/filename 路径 (如果老位置有但新位置没有, 返回老位置)
|
|
377
|
-
"""
|
|
378
|
-
if developer_name is None:
|
|
379
|
-
developer_name = get_developer() or "unknown"
|
|
380
|
-
dev_dir = get_developer_dir(developer_name)
|
|
381
|
-
new_path = get_private_dir(developer_name) / filename
|
|
382
|
-
# 向后兼容: 老位置的文件名映射
|
|
383
|
-
old_name_map = {
|
|
384
|
-
"signing_key": ".signing_key", # 老的带点前缀
|
|
385
|
-
}
|
|
386
|
-
old_name = old_name_map.get(filename, filename)
|
|
387
|
-
old_path = dev_dir / old_name
|
|
388
|
-
# 新位置有 → 用新的; 否则老位置有 → 用老的(渐进迁移); 都没有 → 返回新位置(待创建)
|
|
389
|
-
if new_path.exists():
|
|
390
|
-
return new_path
|
|
391
|
-
if old_path.exists():
|
|
392
|
-
return old_path
|
|
393
|
-
return new_path # 默认新位置
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
def get_secrets_dir(developer_name: str = None) -> Path:
|
|
397
|
-
"""开发者的 secrets 子目录 .private/secrets/ (蓝湖cookie等)。
|
|
398
|
-
|
|
399
|
-
标准路径: .private/secrets/ (v3.0 起, 收进 .private/ 统一管理)。
|
|
400
|
-
一次性迁移: 若标准路径不存在但老路径 .secrets/ 有文件, 把内容搬过来
|
|
401
|
-
(随后老路径即可删除, 消除"同一份 cookie 存两份"的冗余)。
|
|
402
|
-
"""
|
|
403
|
-
if developer_name is None:
|
|
404
|
-
developer_name = get_developer() or "unknown"
|
|
405
|
-
dev_dir = get_developer_dir(developer_name)
|
|
406
|
-
new_secrets = get_private_dir(developer_name) / "secrets"
|
|
407
|
-
old_secrets = dev_dir / ".secrets"
|
|
408
|
-
new_secrets.mkdir(parents=True, exist_ok=True)
|
|
409
|
-
# 一次性迁移: 老路径有文件且新路径缺该文件 → 搬过来 (不覆盖已有的)
|
|
410
|
-
if old_secrets.is_dir():
|
|
411
|
-
import shutil as _sh
|
|
412
|
-
for f in old_secrets.iterdir():
|
|
413
|
-
if f.is_file():
|
|
414
|
-
dst = new_secrets / f.name
|
|
415
|
-
if not dst.exists():
|
|
416
|
-
try:
|
|
417
|
-
_sh.copy2(str(f), str(dst))
|
|
418
|
-
except OSError:
|
|
419
|
-
pass
|
|
420
|
-
return new_secrets
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
# =============================================================================
|
|
424
|
-
# M3: 时间桶 + REQ-ID 容器 (workspace 二态重构)
|
|
425
|
-
# =============================================================================
|
|
426
|
-
# 理念: workspace = 个人草稿台. drafts(草稿,私有) / outputs(产出,进team_sync)
|
|
427
|
-
# 时间桶内建: drafts/2026/06/W4/REQ-ID/ (活区=本周桶, 历史=过去的桶, 无独立archive)
|
|
428
|
-
# 一个需求的所有产出(PRD/原型/任务/规格)聚合在 REQ-ID 容器里
|
|
429
|
-
|
|
430
|
-
import datetime as _dt
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
def get_time_bucket(when: Optional[_dt.date] = None) -> str:
|
|
434
|
-
"""返回当前(或指定)日期的时间桶相对路径: YYYY/MM/Ww
|
|
435
|
-
|
|
436
|
-
周定义: ISO 周 (周一为一周开始). W1-W53.
|
|
437
|
-
例: 2026-06-23 (周二, 第26周) → '2026/06/W26'
|
|
438
|
-
"""
|
|
439
|
-
d = when or _dt.date.today()
|
|
440
|
-
iso_year, iso_week, _ = d.isocalendar()
|
|
441
|
-
# 用 iso_year (跨年时可能 != d.year), 月份用 d.month (实际月份桶)
|
|
442
|
-
return "%d/%02d/W%d" % (d.year, d.month, iso_week)
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
def get_time_bucket_path(developer_name: str, area: str, when: Optional[_dt.date] = None) -> Path:
|
|
446
|
-
"""某开发者某区(drafts/outputs)的某时间桶绝对路径。
|
|
447
|
-
|
|
448
|
-
Args:
|
|
449
|
-
developer_name: 开发者名
|
|
450
|
-
area: 'drafts' 或 'outputs'
|
|
451
|
-
when: 日期(默认今天), 决定哪个时间桶
|
|
452
|
-
Returns:
|
|
453
|
-
workspace/members/{dev}/{area}/{YYYY}/{MM}/{Ww}/
|
|
454
|
-
"""
|
|
455
|
-
bucket = get_time_bucket(when)
|
|
456
|
-
p = get_developer_dir(developer_name) / area / bucket
|
|
457
|
-
p.mkdir(parents=True, exist_ok=True)
|
|
458
|
-
return p
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
def get_req_dir(req_id: str, developer_name: str = None, area: str = "drafts",
|
|
462
|
-
when: Optional[_dt.date] = None) -> Path:
|
|
463
|
-
"""某需求的容器目录 (一个需求的所有产出聚合处)。
|
|
464
|
-
|
|
465
|
-
workspace/members/{dev}/{area}/{YYYY}/{MM}/{Ww}/{REQ-ID}/
|
|
466
|
-
|
|
467
|
-
Args:
|
|
468
|
-
req_id: 需求编号 (REQ-2026-001 或 REQ-2026-001-保险OCR)
|
|
469
|
-
developer_name: 开发者名(默认当前)
|
|
470
|
-
area: 'drafts' 或 'outputs'
|
|
471
|
-
when: 日期(默认今天)
|
|
472
|
-
"""
|
|
473
|
-
if developer_name is None:
|
|
474
|
-
developer_name = get_developer() or "unknown"
|
|
475
|
-
# 规范化 req_id (只取 REQ-YYYY-NNN 部分, 去掉标题后缀)
|
|
476
|
-
import re
|
|
477
|
-
m = re.match(r"(REQ-\d{4}-\d{3,})", req_id)
|
|
478
|
-
clean = m.group(1) if m else req_id
|
|
479
|
-
bucket_path = get_time_bucket_path(developer_name, area, when)
|
|
480
|
-
req_path = bucket_path / clean
|
|
481
|
-
req_path.mkdir(parents=True, exist_ok=True)
|
|
482
|
-
return req_path
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
def get_outputs_dir(developer_name: str = None) -> Path:
|
|
486
|
-
"""开发者的 outputs 区根目录 (产出, 进 team_sync)。"""
|
|
487
|
-
if developer_name is None:
|
|
488
|
-
developer_name = get_developer() or "unknown"
|
|
489
|
-
p = get_developer_dir(developer_name) / "outputs"
|
|
490
|
-
p.mkdir(parents=True, exist_ok=True)
|
|
491
|
-
return p
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
def find_req_dirs(req_id: str, developer_name: str = None,
|
|
495
|
-
repo_root: Optional[Path] = None) -> list:
|
|
496
|
-
"""跨所有时间桶找某需求的所有容器 (drafts + outputs 都找)。
|
|
497
|
-
|
|
498
|
-
/wl-req show 的核心: 不管需求在哪个桶(drafts本周/outputs本周/历史桶),
|
|
499
|
-
全找出来。
|
|
500
|
-
|
|
501
|
-
Returns:
|
|
502
|
-
[{area, path, bucket}, ...] 列表
|
|
503
|
-
"""
|
|
504
|
-
if developer_name is None:
|
|
505
|
-
developer_name = get_developer(repo_root) or "unknown"
|
|
506
|
-
if repo_root is None:
|
|
507
|
-
repo_root = PROJECT_ROOT
|
|
508
|
-
import re
|
|
509
|
-
m = re.match(r"(REQ-\d{4}-\d{3,})", req_id)
|
|
510
|
-
clean = m.group(1) if m else req_id
|
|
511
|
-
|
|
512
|
-
dev_dir = MEMBERS_DIR / developer_name if (PROJECT_ROOT / "workspace" / "members").parent == PROJECT_ROOT / "workspace" else repo_root / "workspace" / "members" / developer_name
|
|
513
|
-
results = []
|
|
514
|
-
for area in ("drafts", "outputs"):
|
|
515
|
-
area_root = dev_dir / area
|
|
516
|
-
if not area_root.is_dir():
|
|
517
|
-
continue
|
|
518
|
-
# 递归找含 clean 的目录 (跨 YYYY/MM/Ww 桶)
|
|
519
|
-
for found in area_root.rglob(clean):
|
|
520
|
-
if found.is_dir():
|
|
521
|
-
# 桶 = found 相对 area_root 的父路径
|
|
522
|
-
rel = found.parent.relative_to(area_root)
|
|
523
|
-
results.append({"area": area, "path": found, "bucket": str(rel)})
|
|
524
|
-
return results
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
def get_workspace_dir(repo_root: Optional[Path] = None) -> Optional[Path]:
|
|
528
|
-
"""Get developer workspace directory."""
|
|
529
|
-
dev = get_developer(repo_root)
|
|
530
|
-
if not dev:
|
|
531
|
-
return None
|
|
532
|
-
return MEMBERS_DIR / dev
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
def get_active_journal_file(repo_root: Optional[Path] = None) -> Optional[Path]:
|
|
536
|
-
"""Get developer active journal file."""
|
|
537
|
-
ws = get_workspace_dir(repo_root)
|
|
538
|
-
if not ws:
|
|
539
|
-
return None
|
|
540
|
-
journal_dir = ws / "journal"
|
|
541
|
-
if not journal_dir.is_dir():
|
|
542
|
-
return None
|
|
543
|
-
journals = sorted(journal_dir.glob("journal-*.md"))
|
|
544
|
-
return journals[-1] if journals else None
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
def get_tasks_dir(repo_root: Optional[Path] = None) -> Path:
|
|
548
|
-
"""Get tasks directory (workspace/tasks/)."""
|
|
549
|
-
if repo_root is None:
|
|
550
|
-
repo_root = PROJECT_ROOT
|
|
551
|
-
tasks = repo_root / "workspace" / "tasks"
|
|
552
|
-
tasks.mkdir(parents=True, exist_ok=True)
|
|
553
|
-
return tasks
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
def _current_task_file(repo_root: Path, developer: Optional[str] = None) -> Path:
|
|
557
|
-
"""当前任务文件路径。
|
|
558
|
-
|
|
559
|
-
零信任隔离: 按开发者命名, 避免共享机器上 A 的 current-task 被 B 覆盖。
|
|
560
|
-
developer=None 时回退到旧的全局 .current-task (向后兼容读取)。
|
|
561
|
-
"""
|
|
562
|
-
repo_root = Path(repo_root) # 容忍 str 输入
|
|
563
|
-
runtime = repo_root / ".qoder" / ".runtime"
|
|
564
|
-
if developer:
|
|
565
|
-
# 文件名只允许安全字符 (member name 已校验, 但防御性 sanitize)
|
|
566
|
-
safe = "".join(c for c in developer if c.isalnum() or c in "_-") or "anon"
|
|
567
|
-
return runtime / f"current-task.{safe}"
|
|
568
|
-
return repo_root / ".qoder" / ".current-task"
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
def _read_task_file(path: Path) -> Optional[str]:
|
|
572
|
-
"""读任务文件, 支持 UTF-8 和 GBK fallback (审计 H6: Notepad 默认 GBK)。"""
|
|
573
|
-
if not path.is_file():
|
|
574
|
-
return None
|
|
575
|
-
for enc in ("utf-8", "gbk", "utf-8-sig"):
|
|
576
|
-
try:
|
|
577
|
-
content = path.read_text(encoding=enc).strip()
|
|
578
|
-
return content if content else None
|
|
579
|
-
except (OSError, IOError, UnicodeDecodeError):
|
|
580
|
-
continue
|
|
581
|
-
return None
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
def get_current_task(
|
|
585
|
-
repo_root: Optional[Path] = None,
|
|
586
|
-
developer: Optional[str] = None,
|
|
587
|
-
) -> Optional[str]:
|
|
588
|
-
"""Get current task.
|
|
589
|
-
|
|
590
|
-
优先读按开发者隔离的 .runtime/current-task.{dev};
|
|
591
|
-
若无则回退到旧的全局 .current-task (向后兼容, 会自动迁移)。
|
|
592
|
-
developer=None 时自动用 get_developer() 推断。
|
|
593
|
-
"""
|
|
594
|
-
if repo_root is None:
|
|
595
|
-
repo_root = PROJECT_ROOT
|
|
596
|
-
if developer is None:
|
|
597
|
-
developer = get_developer(repo_root)
|
|
598
|
-
# 1. 优先读 developer 隔离的文件
|
|
599
|
-
if developer:
|
|
600
|
-
ct = _read_task_file(_current_task_file(repo_root, developer))
|
|
601
|
-
if ct:
|
|
602
|
-
return ct
|
|
603
|
-
# 2. 回退到旧全局文件 (兼容历史)
|
|
604
|
-
legacy = _read_task_file(_current_task_file(repo_root, None))
|
|
605
|
-
return legacy
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
def set_current_task(
|
|
609
|
-
task_path: Optional[str],
|
|
610
|
-
repo_root: Optional[Path] = None,
|
|
611
|
-
developer: Optional[str] = None,
|
|
612
|
-
) -> None:
|
|
613
|
-
"""Set current task (按开发者隔离写入)。
|
|
614
|
-
|
|
615
|
-
零信任: 写入 .runtime/current-task.{dev}, 不再写全局 .current-task。
|
|
616
|
-
若 task_path=None 则清除该开发者的指针 (不影响他人)。
|
|
617
|
-
"""
|
|
618
|
-
if repo_root is None:
|
|
619
|
-
repo_root = PROJECT_ROOT
|
|
620
|
-
if developer is None:
|
|
621
|
-
developer = get_developer(repo_root)
|
|
622
|
-
ct_file = _current_task_file(repo_root, developer)
|
|
623
|
-
try:
|
|
624
|
-
ct_file.parent.mkdir(parents=True, exist_ok=True)
|
|
625
|
-
if task_path:
|
|
626
|
-
ct_file.write_text(task_path, encoding="utf-8")
|
|
627
|
-
else:
|
|
628
|
-
ct_file.unlink(missing_ok=True)
|
|
629
|
-
except (OSError, IOError) as e:
|
|
630
|
-
print(f"Warning: Failed to write current-task: {e}", file=sys.stderr)
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
def get_module_spec_dir(module: Optional[str] = None) -> Path:
|
|
634
|
-
"""Get module spec directory."""
|
|
635
|
-
if module:
|
|
636
|
-
spec_dir = SPECS_DIR / module
|
|
637
|
-
else:
|
|
638
|
-
spec_dir = SPECS_DIR
|
|
639
|
-
spec_dir.mkdir(parents=True, exist_ok=True)
|
|
640
|
-
return spec_dir
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
def get_task_dir(slug: str) -> Path:
|
|
644
|
-
"""Get task directory by slug."""
|
|
645
|
-
from datetime import datetime
|
|
646
|
-
date_prefix = datetime.now().strftime("%m-%d")
|
|
647
|
-
task_dir = TASKS_DIR / "{0}-{1}".format(date_prefix, slug)
|
|
648
|
-
task_dir.mkdir(parents=True, exist_ok=True)
|
|
649
|
-
return task_dir
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
def count_lines(file_path: Path) -> int:
|
|
653
|
-
"""Count lines in a file."""
|
|
654
|
-
try:
|
|
655
|
-
return sum(1 for _ in file_path.open("r", encoding="utf-8"))
|
|
656
|
-
except (OSError, IOError):
|
|
657
|
-
return 0
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
# =============================================================================
|
|
661
|
-
# CLI 入口: 供 skill 文档统一调用身份/活动任务/仓库根定位 (根治 QoderWork
|
|
662
|
-
# 桌面端 cwd 不在仓库根导致裸相对路径读不到身份的 bug)。
|
|
663
|
-
#
|
|
664
|
-
# 用法 (skill 文档里统一这样写, 不再用裸 `.qoder/.developer`):
|
|
665
|
-
# R=$(python ~/.qoderwork/repo_root.py 2>/dev/null) || R=.
|
|
666
|
-
# python "$R/.qoder/scripts/common/paths.py" whoami # 当前开发者名; 空则退出码1
|
|
667
|
-
# python "$R/.qoder/scripts/common/paths.py" current-task # 活动任务; 空则退出码1
|
|
668
|
-
# python "$R/.qoder/scripts/common/paths.py" repo-root # 打印仓库根绝对路径
|
|
669
|
-
# python "$R/.qoder/scripts/common/paths.py" test # 自检 (任意 cwd 下定位)
|
|
670
|
-
# =============================================================================
|
|
671
|
-
|
|
672
|
-
def _cli_main(argv=None):
|
|
673
|
-
import sys
|
|
674
|
-
argv = argv if argv is not None else sys.argv[1:]
|
|
675
|
-
cmd = argv[0] if argv else "whoami"
|
|
676
|
-
if cmd == "whoami":
|
|
677
|
-
d = get_developer()
|
|
678
|
-
if d:
|
|
679
|
-
print(d)
|
|
680
|
-
return 0
|
|
681
|
-
return 1 # 空身份 → 退出码1
|
|
682
|
-
elif cmd == "current-task":
|
|
683
|
-
t = get_current_task()
|
|
684
|
-
if t:
|
|
685
|
-
print(t)
|
|
686
|
-
return 0
|
|
687
|
-
return 1
|
|
688
|
-
elif cmd in ("repo-root", "root"):
|
|
689
|
-
print(str(get_repo_root()))
|
|
690
|
-
return 0
|
|
691
|
-
elif cmd == "test":
|
|
692
|
-
# 自检: 任意 cwd 下能否正确定位仓库 + 身份
|
|
693
|
-
print("root=%s" % get_repo_root())
|
|
694
|
-
print("developer=%s" % (get_developer() or "(空)"))
|
|
695
|
-
print("current-task=%s" % (get_current_task() or "(空)"))
|
|
696
|
-
return 0
|
|
697
|
-
else:
|
|
698
|
-
import sys
|
|
699
|
-
sys.stderr.write(
|
|
700
|
-
"用法: python paths.py [whoami|current-task|repo-root|test]\n")
|
|
701
|
-
return 2
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
if __name__ == "__main__":
|
|
705
|
-
import sys
|
|
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: engine/data separation 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 _is_real_repo_root(path: Path) -> bool:
|
|
20
|
+
"""判定某目录是不是真正的 QODER 项目仓库根。
|
|
21
|
+
|
|
22
|
+
光有 ``.qoder/`` 不够 —— QoderWork 桌面端 / Qoder IDE 会在用户家目录建
|
|
23
|
+
``~/.qoder/``(客户端配置: agents/cache/logs/plugins/...),它**没有
|
|
24
|
+
scripts/ 子目录**。只有真仓库的 ``.qoder/`` 才含 ``scripts/``(引擎)。
|
|
25
|
+
用 ``scripts/`` 作为引擎标志区分二者,避免把客户端目录误判为仓库根
|
|
26
|
+
(那会导致 get_developer() 读到错误的 .developer → QoderWork 误报
|
|
27
|
+
"身份未初始化",即使项目里早已 /wl-init 过)。
|
|
28
|
+
"""
|
|
29
|
+
return (path / ".qoder").is_dir() and (path / ".qoder" / "scripts").is_dir()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _find_repo_root() -> Path:
|
|
33
|
+
"""Find project root by searching for .qoder/ directory.
|
|
34
|
+
|
|
35
|
+
优先看 cwd: AI/CLI 常在项目根调 `python .qoder/scripts/xxx.py`,
|
|
36
|
+
cwd 就是项目根。再用 __file__ 兜底 (脚本被 import 时 cwd 可能不对)。
|
|
37
|
+
|
|
38
|
+
⚠️ 必须用 _is_real_repo_root 校验: 仅 .qoder/ 不够, QoderWork/Qoder
|
|
39
|
+
会在家目录建无 scripts/ 的客户端 ~/.qoder/, 会造成误判。
|
|
40
|
+
"""
|
|
41
|
+
cwd = Path.cwd()
|
|
42
|
+
if _is_real_repo_root(cwd):
|
|
43
|
+
return cwd
|
|
44
|
+
current = cwd
|
|
45
|
+
for _ in range(10):
|
|
46
|
+
if _is_real_repo_root(current):
|
|
47
|
+
return current
|
|
48
|
+
parent = current.parent
|
|
49
|
+
if parent == current:
|
|
50
|
+
break
|
|
51
|
+
current = parent
|
|
52
|
+
current = Path(__file__).resolve().parent
|
|
53
|
+
for _ in range(10):
|
|
54
|
+
if _is_real_repo_root(current):
|
|
55
|
+
return current
|
|
56
|
+
parent = current.parent
|
|
57
|
+
if parent == current:
|
|
58
|
+
break
|
|
59
|
+
current = parent
|
|
60
|
+
return Path(__file__).resolve().parent.parent.parent.parent.parent
|
|
61
|
+
|
|
62
|
+
PROJECT_ROOT = _find_repo_root()
|
|
63
|
+
|
|
64
|
+
# =============================================================================
|
|
65
|
+
# Engine paths (.qoder/)
|
|
66
|
+
# =============================================================================
|
|
67
|
+
|
|
68
|
+
QODER_DIR = PROJECT_ROOT / ".qoder"
|
|
69
|
+
RUNTIME_DIR = QODER_DIR / ".runtime"
|
|
70
|
+
SESSIONS_DIR = RUNTIME_DIR / "sessions"
|
|
71
|
+
ARCHIVE_DIR = QODER_DIR / "archive"
|
|
72
|
+
RULES_DIR = QODER_DIR / "rules"
|
|
73
|
+
CONTEXT_DIR = QODER_DIR / "context"
|
|
74
|
+
SKILLS_DIR = QODER_DIR / "skills"
|
|
75
|
+
AGENTS_DIR = QODER_DIR / "agents"
|
|
76
|
+
HOOKS_DIR = QODER_DIR / "hooks"
|
|
77
|
+
SCRIPTS_DIR = QODER_DIR / "scripts"
|
|
78
|
+
|
|
79
|
+
# =============================================================================
|
|
80
|
+
# Workspace paths (workspace/)
|
|
81
|
+
# =============================================================================
|
|
82
|
+
|
|
83
|
+
WORKSPACE_DIR = PROJECT_ROOT / "workspace"
|
|
84
|
+
SPECS_DIR = WORKSPACE_DIR / "specs"
|
|
85
|
+
PRD_DIR = SPECS_DIR / "prd"
|
|
86
|
+
TASKS_DIR = WORKSPACE_DIR / "tasks"
|
|
87
|
+
CONSTITUTION_DIR = WORKSPACE_DIR / "constitution"
|
|
88
|
+
MEMBERS_DIR = WORKSPACE_DIR / "members"
|
|
89
|
+
|
|
90
|
+
# =============================================================================
|
|
91
|
+
# Developer files
|
|
92
|
+
# =============================================================================
|
|
93
|
+
|
|
94
|
+
DEVELOPER_FILE = QODER_DIR / ".developer"
|
|
95
|
+
CURRENT_TASK_FILE = QODER_DIR / ".current-task"
|
|
96
|
+
|
|
97
|
+
# =============================================================================
|
|
98
|
+
# Legacy compatibility constants (used by task.py, developer.py, etc.)
|
|
99
|
+
# =============================================================================
|
|
100
|
+
|
|
101
|
+
DIR_TASKS = "workspace/tasks"
|
|
102
|
+
DIR_ARCHIVE = ".qoder/archive"
|
|
103
|
+
DIR_WORKFLOW = ".qoder"
|
|
104
|
+
DIR_WORKSPACE = "workspace/members"
|
|
105
|
+
DIR_SPECS = "workspace/specs"
|
|
106
|
+
|
|
107
|
+
FILE_TASK_JSON = "task.json"
|
|
108
|
+
FILE_DEVELOPER = ".developer"
|
|
109
|
+
FILE_JOURNAL_PREFIX = "journal-"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# =============================================================================
|
|
113
|
+
# Index file registry — SINGLE SOURCE OF TRUTH for data/index/ filenames
|
|
114
|
+
# =============================================================================
|
|
115
|
+
# Why this exists: 17 index files used to be flat-named in data/index/ with
|
|
116
|
+
# 4 different naming styles, and two (module-map vs module-index) differed by
|
|
117
|
+
# one word but meant totally different things. Every consumer hardcoded the
|
|
118
|
+
# filename as a string literal. Now all names live here — rename = change once.
|
|
119
|
+
#
|
|
120
|
+
# Naming convention: <category>-<subject>.json
|
|
121
|
+
# code- source code layer (search, APIs, stats, dependency graph)
|
|
122
|
+
# ui- UI/style layer (page patterns, module names, design fingerprint)
|
|
123
|
+
# trace- call-chain (button click → API → controller)
|
|
124
|
+
# prd- product requirements
|
|
125
|
+
# wiki- semantic module docs
|
|
126
|
+
# ref- hand-curated static reference (NOT auto-built)
|
|
127
|
+
# Dotfiles (.meta, .cache) are machine-local bookkeeping.
|
|
128
|
+
|
|
129
|
+
DATA_INDEX_DIR = PROJECT_ROOT / "data" / "index"
|
|
130
|
+
BUILD_CACHE_DIR = DATA_INDEX_DIR / "_build_cache" # JSON 中间产物 (构建器写→kg_duckdb读→DuckDB)
|
|
131
|
+
KG_DB_PATH = DATA_INDEX_DIR / "kg.duckdb" # 知识图谱单文件存储 (消费层唯一查询入口)
|
|
132
|
+
|
|
133
|
+
# ── Learning / quality history (data/learning/) ──
|
|
134
|
+
# Why centralized: eval_prd.py writes here, status.py reads it. Past bug had
|
|
135
|
+
# writer at data/learning/ but reader at .qoder/learning/ (never existed) →
|
|
136
|
+
# EVA dimension in /wl-status was permanently dead. Single constant kills drift.
|
|
137
|
+
DATA_LEARNING_DIR = PROJECT_ROOT / "data" / "learning"
|
|
138
|
+
EVAL_HISTORY_PATH = DATA_LEARNING_DIR / "eval-history.jsonl" # eval_prd.py 写 / status.py 读
|
|
139
|
+
|
|
140
|
+
INDEX_FILES = {
|
|
141
|
+
# ── code layer (built by git_sync.py) ──
|
|
142
|
+
'code_keyword': 'code-keyword.json', # was code-keyword.json
|
|
143
|
+
'code_api': 'code-api.json', # was code-api.json
|
|
144
|
+
'code_stats': 'code-stats.json', # was code-stats.json (PROJECT FILE COUNTS)
|
|
145
|
+
'code_callgraph': 'code-code-callgraph.json', # was code-callgraph.json
|
|
146
|
+
# ── UI layer (built by build_style_index.py) ──
|
|
147
|
+
'ui_style': 'ui-style.json', # was ui-style.json
|
|
148
|
+
'ui_style_meta': 'ui-ui-style-meta.json', # was ui-style-meta.json
|
|
149
|
+
'ui_modules': 'ui-modules.json', # was ui-modules.json (BUSINESS MODULE NAMES)
|
|
150
|
+
# ── call-chain (built by build_style_index.py) ──
|
|
151
|
+
'trace_chain': 'trace-chain.json', # was trace-chain.json
|
|
152
|
+
# ── 浏览器测试锚点 (built/updated by autotest.py learn) ──
|
|
153
|
+
'test_pages': 'test-pages.json', # 页面稳定锚点(role/name/placeholder), 团队共享
|
|
154
|
+
# ── PRD layer (built by git_sync.py) ──
|
|
155
|
+
'prd_index': 'prd-index.json', # (name unchanged, already clear)
|
|
156
|
+
'prd_code_map': 'prd-code-map.json', # (name unchanged)
|
|
157
|
+
# ── semantic ──
|
|
158
|
+
'wiki_index': 'wiki-index.json', # (name unchanged)
|
|
159
|
+
# ── static reference (hand-curated, committed to git) ──
|
|
160
|
+
'ref_vben': 'ref-vben-style.json', # was ref-vben-style.json
|
|
161
|
+
'ref_chart': 'ref-chart-style.json', # was ref-chart-style.json
|
|
162
|
+
'ref_icon': 'ref-icon.json', # was ref-icon.json
|
|
163
|
+
# ── meta / cache (dotfiles, machine-local) ──
|
|
164
|
+
'meta_index': '.index-meta.json', # (name unchanged)
|
|
165
|
+
'meta_prd_collected': '.prd-collected.json', # (name unchanged)
|
|
166
|
+
'meta_last_sync': '.last-sync', # (name unchanged)
|
|
167
|
+
'cache_inverted': '.inverted-cache.json', # (name unchanged)
|
|
168
|
+
'cache_file_keys': '.file-keys.json', # (name unchanged)
|
|
169
|
+
'meta_req_counter': 'req-counter.json', # (name unchanged)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
# Legacy → new name mapping (for migration + backward-compat lookups)
|
|
173
|
+
INDEX_NAME_MIGRATION = {
|
|
174
|
+
'code-keyword.json': 'code-keyword.json',
|
|
175
|
+
'code-api.json': 'code-api.json',
|
|
176
|
+
'code-stats.json': 'code-stats.json',
|
|
177
|
+
'code-callgraph.json': 'code-code-callgraph.json',
|
|
178
|
+
'ui-style.json': 'ui-style.json',
|
|
179
|
+
'ui-style-meta.json': 'ui-ui-style-meta.json',
|
|
180
|
+
'ui-modules.json': 'ui-modules.json',
|
|
181
|
+
'trace-chain.json': 'trace-chain.json',
|
|
182
|
+
'ref-vben-style.json': 'ref-vben-style.json',
|
|
183
|
+
'ref-chart-style.json': 'ref-chart-style.json',
|
|
184
|
+
'ref-icon.json': 'ref-icon.json',
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def index_path(key: str) -> Path:
|
|
189
|
+
"""Get the full path for an index file by its registry key.
|
|
190
|
+
|
|
191
|
+
Usage: index_path('code_keyword') → data/index/code-keyword.json
|
|
192
|
+
"""
|
|
193
|
+
name = INDEX_FILES.get(key)
|
|
194
|
+
if name is None:
|
|
195
|
+
raise KeyError('Unknown index key: %r (valid: %s)' % (key, sorted(INDEX_FILES)))
|
|
196
|
+
return DATA_INDEX_DIR / name
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def resolve_index_name(filename: str) -> str:
|
|
200
|
+
"""Resolve a (possibly legacy) index filename to its current name.
|
|
201
|
+
|
|
202
|
+
Falls back to the input unchanged if not in the migration map.
|
|
203
|
+
Used by load helpers for backward compatibility.
|
|
204
|
+
"""
|
|
205
|
+
return INDEX_NAME_MIGRATION.get(filename, filename)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# =============================================================================
|
|
209
|
+
# Script locator — SINGLE SOURCE OF TRUTH for finding .py files after subdir migration
|
|
210
|
+
# =============================================================================
|
|
211
|
+
# v3.0 子目录化后, 脚本从 scripts/ 根搬到了 scripts/{kg,report,mcp,...}/ 子包。
|
|
212
|
+
# 但调用方 (kg_mcp_server.py / kg.py) 还在用 os.path.join(SCRIPTS_DIR, 'xxx.py')
|
|
213
|
+
# 按旧扁平结构找 → FileNotFound。本函数扫所有子目录, 单一真源定位。
|
|
214
|
+
|
|
215
|
+
def find_script(name: str) -> Optional[Path]:
|
|
216
|
+
"""在 scripts/ 树下定位一个 .py 脚本 (跨子目录自动查找)。
|
|
217
|
+
|
|
218
|
+
先查 scripts/ 根, 再扫所有子目录 (kg/report/mcp/setup/test/...)。
|
|
219
|
+
消除调用方各写各的路径拼装导致的迁移断裂 bug。
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
name: 脚本文件名, 如 'fill_prototype.py' / 'gen_design_doc.py'
|
|
223
|
+
Returns:
|
|
224
|
+
找到的绝对路径, 或 None (脚本不存在)
|
|
225
|
+
"""
|
|
226
|
+
if not name:
|
|
227
|
+
return None
|
|
228
|
+
# 1. 先查 scripts/ 根 (老扁平结构残留的脚本)
|
|
229
|
+
root_hit = SCRIPTS_DIR / name
|
|
230
|
+
if root_hit.is_file():
|
|
231
|
+
return root_hit
|
|
232
|
+
# 2. 扫一级子目录 (kg/report/mcp/setup/test/...)
|
|
233
|
+
try:
|
|
234
|
+
for child in SCRIPTS_DIR.iterdir():
|
|
235
|
+
if not child.is_dir():
|
|
236
|
+
continue
|
|
237
|
+
cand = child / name
|
|
238
|
+
if cand.is_file():
|
|
239
|
+
return cand
|
|
240
|
+
except (OSError, PermissionError):
|
|
241
|
+
pass
|
|
242
|
+
return None
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
# =============================================================================
|
|
246
|
+
# Core functions
|
|
247
|
+
# =============================================================================
|
|
248
|
+
|
|
249
|
+
def get_repo_root() -> Path:
|
|
250
|
+
"""Get project root directory."""
|
|
251
|
+
return PROJECT_ROOT
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def ensure_dirs():
|
|
255
|
+
"""Ensure all necessary directories exist."""
|
|
256
|
+
dirs = [
|
|
257
|
+
WORKSPACE_DIR, SPECS_DIR, PRD_DIR, TASKS_DIR,
|
|
258
|
+
CONSTITUTION_DIR, MEMBERS_DIR,
|
|
259
|
+
RUNTIME_DIR, SESSIONS_DIR, ARCHIVE_DIR,
|
|
260
|
+
]
|
|
261
|
+
for d in dirs:
|
|
262
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _parse_developer_file(content: str) -> Dict[str, str]:
|
|
266
|
+
"""Parse .developer content. Accepts both 'name=x' and 'name: x' formats."""
|
|
267
|
+
info = {}
|
|
268
|
+
for line in content.splitlines():
|
|
269
|
+
line = line.strip()
|
|
270
|
+
if not line or line.startswith("#"):
|
|
271
|
+
continue
|
|
272
|
+
for sep in ("=", ":"):
|
|
273
|
+
if sep in line:
|
|
274
|
+
k, v = line.split(sep, 1)
|
|
275
|
+
info[k.strip()] = v.strip()
|
|
276
|
+
break
|
|
277
|
+
return info
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def get_developer(repo_root: Optional[Path] = None) -> Optional[str]:
|
|
281
|
+
"""Get current developer name from .developer file."""
|
|
282
|
+
if repo_root is None:
|
|
283
|
+
repo_root = PROJECT_ROOT
|
|
284
|
+
dev_file = repo_root / ".qoder" / ".developer"
|
|
285
|
+
if not dev_file.is_file():
|
|
286
|
+
return None
|
|
287
|
+
try:
|
|
288
|
+
content = dev_file.read_text(encoding="utf-8")
|
|
289
|
+
except UnicodeDecodeError:
|
|
290
|
+
try:
|
|
291
|
+
content = dev_file.read_text(encoding="gbk")
|
|
292
|
+
except (OSError, IOError, UnicodeDecodeError):
|
|
293
|
+
return None
|
|
294
|
+
except (OSError, IOError):
|
|
295
|
+
return None
|
|
296
|
+
return _parse_developer_file(content).get("name") or None
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def check_developer(repo_root: Optional[Path] = None) -> bool:
|
|
300
|
+
"""Check if developer is initialized."""
|
|
301
|
+
return get_developer(repo_root) is not None
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def get_developer_info(repo_root: Optional[Path] = None) -> Optional[Dict]:
|
|
305
|
+
"""Get developer info as dict."""
|
|
306
|
+
if repo_root is None:
|
|
307
|
+
repo_root = PROJECT_ROOT
|
|
308
|
+
dev_file = repo_root / ".qoder" / ".developer"
|
|
309
|
+
if not dev_file.is_file():
|
|
310
|
+
return None
|
|
311
|
+
try:
|
|
312
|
+
content = dev_file.read_text(encoding="utf-8")
|
|
313
|
+
except UnicodeDecodeError:
|
|
314
|
+
try:
|
|
315
|
+
content = dev_file.read_text(encoding="gbk")
|
|
316
|
+
except (OSError, IOError, UnicodeDecodeError):
|
|
317
|
+
return None
|
|
318
|
+
except (OSError, IOError):
|
|
319
|
+
return None
|
|
320
|
+
info = _parse_developer_file(content)
|
|
321
|
+
return info if info else None
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def get_developer_dir(developer_name: str) -> Path:
|
|
325
|
+
"""Get developer personal directory under workspace/members/."""
|
|
326
|
+
dev_dir = MEMBERS_DIR / developer_name
|
|
327
|
+
dev_dir.mkdir(parents=True, exist_ok=True)
|
|
328
|
+
return dev_dir
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def get_developer_journal_dir(developer_name: str) -> Path:
|
|
332
|
+
"""Get developer journal directory."""
|
|
333
|
+
journal_dir = get_developer_dir(developer_name) / "journal"
|
|
334
|
+
journal_dir.mkdir(parents=True, exist_ok=True)
|
|
335
|
+
return journal_dir
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def get_developer_drafts_dir(developer_name: str) -> Path:
|
|
339
|
+
"""Get developer drafts directory."""
|
|
340
|
+
drafts_dir = get_developer_dir(developer_name) / "drafts"
|
|
341
|
+
drafts_dir.mkdir(parents=True, exist_ok=True)
|
|
342
|
+
return drafts_dir
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
# =============================================================================
|
|
346
|
+
# 私有凭证目录 (M3: 收拢散落的敏感文件到 .private/)
|
|
347
|
+
# =============================================================================
|
|
348
|
+
# 现状问题: member根目录散落5个凭证文件(.signing_key/.secrets/auth-state.json/
|
|
349
|
+
# autotest-data.yaml), 和产出目录混在一起, 没层次。
|
|
350
|
+
# 解法: 全收进 .private/ (个人私有, 全gitignore), 根目录只留结构化产出。
|
|
351
|
+
#
|
|
352
|
+
# 向后兼容: get_private_file() 先查 .private/新位置, 没有则回退老位置。
|
|
353
|
+
|
|
354
|
+
PRIVATE_DIR_NAME = ".private"
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def get_private_dir(developer_name: str = None) -> Path:
|
|
358
|
+
"""开发者的私有凭证目录 workspace/members/{dev}/.private/
|
|
359
|
+
|
|
360
|
+
所有敏感数据(signing_key/secrets/auth-state/autotest-data)收进这。
|
|
361
|
+
全 gitignore, 永不 push。
|
|
362
|
+
"""
|
|
363
|
+
if developer_name is None:
|
|
364
|
+
developer_name = get_developer() or "unknown"
|
|
365
|
+
p = get_developer_dir(developer_name) / PRIVATE_DIR_NAME
|
|
366
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
367
|
+
return p
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def get_private_file(filename: str, developer_name: str = None) -> Path:
|
|
371
|
+
"""取私有文件路径 (向后兼容: 先 .private/新位置, 没有回退老位置)。
|
|
372
|
+
|
|
373
|
+
Args:
|
|
374
|
+
filename: 文件名 (如 'signing_key', 'auth-state.json', 'autotest-data.yaml')
|
|
375
|
+
Returns:
|
|
376
|
+
.private/filename 路径 (如果老位置有但新位置没有, 返回老位置)
|
|
377
|
+
"""
|
|
378
|
+
if developer_name is None:
|
|
379
|
+
developer_name = get_developer() or "unknown"
|
|
380
|
+
dev_dir = get_developer_dir(developer_name)
|
|
381
|
+
new_path = get_private_dir(developer_name) / filename
|
|
382
|
+
# 向后兼容: 老位置的文件名映射
|
|
383
|
+
old_name_map = {
|
|
384
|
+
"signing_key": ".signing_key", # 老的带点前缀
|
|
385
|
+
}
|
|
386
|
+
old_name = old_name_map.get(filename, filename)
|
|
387
|
+
old_path = dev_dir / old_name
|
|
388
|
+
# 新位置有 → 用新的; 否则老位置有 → 用老的(渐进迁移); 都没有 → 返回新位置(待创建)
|
|
389
|
+
if new_path.exists():
|
|
390
|
+
return new_path
|
|
391
|
+
if old_path.exists():
|
|
392
|
+
return old_path
|
|
393
|
+
return new_path # 默认新位置
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def get_secrets_dir(developer_name: str = None) -> Path:
|
|
397
|
+
"""开发者的 secrets 子目录 .private/secrets/ (蓝湖cookie等)。
|
|
398
|
+
|
|
399
|
+
标准路径: .private/secrets/ (v3.0 起, 收进 .private/ 统一管理)。
|
|
400
|
+
一次性迁移: 若标准路径不存在但老路径 .secrets/ 有文件, 把内容搬过来
|
|
401
|
+
(随后老路径即可删除, 消除"同一份 cookie 存两份"的冗余)。
|
|
402
|
+
"""
|
|
403
|
+
if developer_name is None:
|
|
404
|
+
developer_name = get_developer() or "unknown"
|
|
405
|
+
dev_dir = get_developer_dir(developer_name)
|
|
406
|
+
new_secrets = get_private_dir(developer_name) / "secrets"
|
|
407
|
+
old_secrets = dev_dir / ".secrets"
|
|
408
|
+
new_secrets.mkdir(parents=True, exist_ok=True)
|
|
409
|
+
# 一次性迁移: 老路径有文件且新路径缺该文件 → 搬过来 (不覆盖已有的)
|
|
410
|
+
if old_secrets.is_dir():
|
|
411
|
+
import shutil as _sh
|
|
412
|
+
for f in old_secrets.iterdir():
|
|
413
|
+
if f.is_file():
|
|
414
|
+
dst = new_secrets / f.name
|
|
415
|
+
if not dst.exists():
|
|
416
|
+
try:
|
|
417
|
+
_sh.copy2(str(f), str(dst))
|
|
418
|
+
except OSError:
|
|
419
|
+
pass
|
|
420
|
+
return new_secrets
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
# =============================================================================
|
|
424
|
+
# M3: 时间桶 + REQ-ID 容器 (workspace 二态重构)
|
|
425
|
+
# =============================================================================
|
|
426
|
+
# 理念: workspace = 个人草稿台. drafts(草稿,私有) / outputs(产出,进team_sync)
|
|
427
|
+
# 时间桶内建: drafts/2026/06/W4/REQ-ID/ (活区=本周桶, 历史=过去的桶, 无独立archive)
|
|
428
|
+
# 一个需求的所有产出(PRD/原型/任务/规格)聚合在 REQ-ID 容器里
|
|
429
|
+
|
|
430
|
+
import datetime as _dt
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def get_time_bucket(when: Optional[_dt.date] = None) -> str:
|
|
434
|
+
"""返回当前(或指定)日期的时间桶相对路径: YYYY/MM/Ww
|
|
435
|
+
|
|
436
|
+
周定义: ISO 周 (周一为一周开始). W1-W53.
|
|
437
|
+
例: 2026-06-23 (周二, 第26周) → '2026/06/W26'
|
|
438
|
+
"""
|
|
439
|
+
d = when or _dt.date.today()
|
|
440
|
+
iso_year, iso_week, _ = d.isocalendar()
|
|
441
|
+
# 用 iso_year (跨年时可能 != d.year), 月份用 d.month (实际月份桶)
|
|
442
|
+
return "%d/%02d/W%d" % (d.year, d.month, iso_week)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def get_time_bucket_path(developer_name: str, area: str, when: Optional[_dt.date] = None) -> Path:
|
|
446
|
+
"""某开发者某区(drafts/outputs)的某时间桶绝对路径。
|
|
447
|
+
|
|
448
|
+
Args:
|
|
449
|
+
developer_name: 开发者名
|
|
450
|
+
area: 'drafts' 或 'outputs'
|
|
451
|
+
when: 日期(默认今天), 决定哪个时间桶
|
|
452
|
+
Returns:
|
|
453
|
+
workspace/members/{dev}/{area}/{YYYY}/{MM}/{Ww}/
|
|
454
|
+
"""
|
|
455
|
+
bucket = get_time_bucket(when)
|
|
456
|
+
p = get_developer_dir(developer_name) / area / bucket
|
|
457
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
458
|
+
return p
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def get_req_dir(req_id: str, developer_name: str = None, area: str = "drafts",
|
|
462
|
+
when: Optional[_dt.date] = None) -> Path:
|
|
463
|
+
"""某需求的容器目录 (一个需求的所有产出聚合处)。
|
|
464
|
+
|
|
465
|
+
workspace/members/{dev}/{area}/{YYYY}/{MM}/{Ww}/{REQ-ID}/
|
|
466
|
+
|
|
467
|
+
Args:
|
|
468
|
+
req_id: 需求编号 (REQ-2026-001 或 REQ-2026-001-保险OCR)
|
|
469
|
+
developer_name: 开发者名(默认当前)
|
|
470
|
+
area: 'drafts' 或 'outputs'
|
|
471
|
+
when: 日期(默认今天)
|
|
472
|
+
"""
|
|
473
|
+
if developer_name is None:
|
|
474
|
+
developer_name = get_developer() or "unknown"
|
|
475
|
+
# 规范化 req_id (只取 REQ-YYYY-NNN 部分, 去掉标题后缀)
|
|
476
|
+
import re
|
|
477
|
+
m = re.match(r"(REQ-\d{4}-\d{3,})", req_id)
|
|
478
|
+
clean = m.group(1) if m else req_id
|
|
479
|
+
bucket_path = get_time_bucket_path(developer_name, area, when)
|
|
480
|
+
req_path = bucket_path / clean
|
|
481
|
+
req_path.mkdir(parents=True, exist_ok=True)
|
|
482
|
+
return req_path
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def get_outputs_dir(developer_name: str = None) -> Path:
|
|
486
|
+
"""开发者的 outputs 区根目录 (产出, 进 team_sync)。"""
|
|
487
|
+
if developer_name is None:
|
|
488
|
+
developer_name = get_developer() or "unknown"
|
|
489
|
+
p = get_developer_dir(developer_name) / "outputs"
|
|
490
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
491
|
+
return p
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def find_req_dirs(req_id: str, developer_name: str = None,
|
|
495
|
+
repo_root: Optional[Path] = None) -> list:
|
|
496
|
+
"""跨所有时间桶找某需求的所有容器 (drafts + outputs 都找)。
|
|
497
|
+
|
|
498
|
+
/wl-req show 的核心: 不管需求在哪个桶(drafts本周/outputs本周/历史桶),
|
|
499
|
+
全找出来。
|
|
500
|
+
|
|
501
|
+
Returns:
|
|
502
|
+
[{area, path, bucket}, ...] 列表
|
|
503
|
+
"""
|
|
504
|
+
if developer_name is None:
|
|
505
|
+
developer_name = get_developer(repo_root) or "unknown"
|
|
506
|
+
if repo_root is None:
|
|
507
|
+
repo_root = PROJECT_ROOT
|
|
508
|
+
import re
|
|
509
|
+
m = re.match(r"(REQ-\d{4}-\d{3,})", req_id)
|
|
510
|
+
clean = m.group(1) if m else req_id
|
|
511
|
+
|
|
512
|
+
dev_dir = MEMBERS_DIR / developer_name if (PROJECT_ROOT / "workspace" / "members").parent == PROJECT_ROOT / "workspace" else repo_root / "workspace" / "members" / developer_name
|
|
513
|
+
results = []
|
|
514
|
+
for area in ("drafts", "outputs"):
|
|
515
|
+
area_root = dev_dir / area
|
|
516
|
+
if not area_root.is_dir():
|
|
517
|
+
continue
|
|
518
|
+
# 递归找含 clean 的目录 (跨 YYYY/MM/Ww 桶)
|
|
519
|
+
for found in area_root.rglob(clean):
|
|
520
|
+
if found.is_dir():
|
|
521
|
+
# 桶 = found 相对 area_root 的父路径
|
|
522
|
+
rel = found.parent.relative_to(area_root)
|
|
523
|
+
results.append({"area": area, "path": found, "bucket": str(rel)})
|
|
524
|
+
return results
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def get_workspace_dir(repo_root: Optional[Path] = None) -> Optional[Path]:
|
|
528
|
+
"""Get developer workspace directory."""
|
|
529
|
+
dev = get_developer(repo_root)
|
|
530
|
+
if not dev:
|
|
531
|
+
return None
|
|
532
|
+
return MEMBERS_DIR / dev
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def get_active_journal_file(repo_root: Optional[Path] = None) -> Optional[Path]:
|
|
536
|
+
"""Get developer active journal file."""
|
|
537
|
+
ws = get_workspace_dir(repo_root)
|
|
538
|
+
if not ws:
|
|
539
|
+
return None
|
|
540
|
+
journal_dir = ws / "journal"
|
|
541
|
+
if not journal_dir.is_dir():
|
|
542
|
+
return None
|
|
543
|
+
journals = sorted(journal_dir.glob("journal-*.md"))
|
|
544
|
+
return journals[-1] if journals else None
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def get_tasks_dir(repo_root: Optional[Path] = None) -> Path:
|
|
548
|
+
"""Get tasks directory (workspace/tasks/)."""
|
|
549
|
+
if repo_root is None:
|
|
550
|
+
repo_root = PROJECT_ROOT
|
|
551
|
+
tasks = repo_root / "workspace" / "tasks"
|
|
552
|
+
tasks.mkdir(parents=True, exist_ok=True)
|
|
553
|
+
return tasks
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _current_task_file(repo_root: Path, developer: Optional[str] = None) -> Path:
|
|
557
|
+
"""当前任务文件路径。
|
|
558
|
+
|
|
559
|
+
零信任隔离: 按开发者命名, 避免共享机器上 A 的 current-task 被 B 覆盖。
|
|
560
|
+
developer=None 时回退到旧的全局 .current-task (向后兼容读取)。
|
|
561
|
+
"""
|
|
562
|
+
repo_root = Path(repo_root) # 容忍 str 输入
|
|
563
|
+
runtime = repo_root / ".qoder" / ".runtime"
|
|
564
|
+
if developer:
|
|
565
|
+
# 文件名只允许安全字符 (member name 已校验, 但防御性 sanitize)
|
|
566
|
+
safe = "".join(c for c in developer if c.isalnum() or c in "_-") or "anon"
|
|
567
|
+
return runtime / f"current-task.{safe}"
|
|
568
|
+
return repo_root / ".qoder" / ".current-task"
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _read_task_file(path: Path) -> Optional[str]:
|
|
572
|
+
"""读任务文件, 支持 UTF-8 和 GBK fallback (审计 H6: Notepad 默认 GBK)。"""
|
|
573
|
+
if not path.is_file():
|
|
574
|
+
return None
|
|
575
|
+
for enc in ("utf-8", "gbk", "utf-8-sig"):
|
|
576
|
+
try:
|
|
577
|
+
content = path.read_text(encoding=enc).strip()
|
|
578
|
+
return content if content else None
|
|
579
|
+
except (OSError, IOError, UnicodeDecodeError):
|
|
580
|
+
continue
|
|
581
|
+
return None
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def get_current_task(
|
|
585
|
+
repo_root: Optional[Path] = None,
|
|
586
|
+
developer: Optional[str] = None,
|
|
587
|
+
) -> Optional[str]:
|
|
588
|
+
"""Get current task.
|
|
589
|
+
|
|
590
|
+
优先读按开发者隔离的 .runtime/current-task.{dev};
|
|
591
|
+
若无则回退到旧的全局 .current-task (向后兼容, 会自动迁移)。
|
|
592
|
+
developer=None 时自动用 get_developer() 推断。
|
|
593
|
+
"""
|
|
594
|
+
if repo_root is None:
|
|
595
|
+
repo_root = PROJECT_ROOT
|
|
596
|
+
if developer is None:
|
|
597
|
+
developer = get_developer(repo_root)
|
|
598
|
+
# 1. 优先读 developer 隔离的文件
|
|
599
|
+
if developer:
|
|
600
|
+
ct = _read_task_file(_current_task_file(repo_root, developer))
|
|
601
|
+
if ct:
|
|
602
|
+
return ct
|
|
603
|
+
# 2. 回退到旧全局文件 (兼容历史)
|
|
604
|
+
legacy = _read_task_file(_current_task_file(repo_root, None))
|
|
605
|
+
return legacy
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def set_current_task(
|
|
609
|
+
task_path: Optional[str],
|
|
610
|
+
repo_root: Optional[Path] = None,
|
|
611
|
+
developer: Optional[str] = None,
|
|
612
|
+
) -> None:
|
|
613
|
+
"""Set current task (按开发者隔离写入)。
|
|
614
|
+
|
|
615
|
+
零信任: 写入 .runtime/current-task.{dev}, 不再写全局 .current-task。
|
|
616
|
+
若 task_path=None 则清除该开发者的指针 (不影响他人)。
|
|
617
|
+
"""
|
|
618
|
+
if repo_root is None:
|
|
619
|
+
repo_root = PROJECT_ROOT
|
|
620
|
+
if developer is None:
|
|
621
|
+
developer = get_developer(repo_root)
|
|
622
|
+
ct_file = _current_task_file(repo_root, developer)
|
|
623
|
+
try:
|
|
624
|
+
ct_file.parent.mkdir(parents=True, exist_ok=True)
|
|
625
|
+
if task_path:
|
|
626
|
+
ct_file.write_text(task_path, encoding="utf-8")
|
|
627
|
+
else:
|
|
628
|
+
ct_file.unlink(missing_ok=True)
|
|
629
|
+
except (OSError, IOError) as e:
|
|
630
|
+
print(f"Warning: Failed to write current-task: {e}", file=sys.stderr)
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def get_module_spec_dir(module: Optional[str] = None) -> Path:
|
|
634
|
+
"""Get module spec directory."""
|
|
635
|
+
if module:
|
|
636
|
+
spec_dir = SPECS_DIR / module
|
|
637
|
+
else:
|
|
638
|
+
spec_dir = SPECS_DIR
|
|
639
|
+
spec_dir.mkdir(parents=True, exist_ok=True)
|
|
640
|
+
return spec_dir
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def get_task_dir(slug: str) -> Path:
|
|
644
|
+
"""Get task directory by slug."""
|
|
645
|
+
from datetime import datetime
|
|
646
|
+
date_prefix = datetime.now().strftime("%m-%d")
|
|
647
|
+
task_dir = TASKS_DIR / "{0}-{1}".format(date_prefix, slug)
|
|
648
|
+
task_dir.mkdir(parents=True, exist_ok=True)
|
|
649
|
+
return task_dir
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def count_lines(file_path: Path) -> int:
|
|
653
|
+
"""Count lines in a file."""
|
|
654
|
+
try:
|
|
655
|
+
return sum(1 for _ in file_path.open("r", encoding="utf-8"))
|
|
656
|
+
except (OSError, IOError):
|
|
657
|
+
return 0
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
# =============================================================================
|
|
661
|
+
# CLI 入口: 供 skill 文档统一调用身份/活动任务/仓库根定位 (根治 QoderWork
|
|
662
|
+
# 桌面端 cwd 不在仓库根导致裸相对路径读不到身份的 bug)。
|
|
663
|
+
#
|
|
664
|
+
# 用法 (skill 文档里统一这样写, 不再用裸 `.qoder/.developer`):
|
|
665
|
+
# R=$(python ~/.qoderwork/repo_root.py 2>/dev/null) || R=.
|
|
666
|
+
# python "$R/.qoder/scripts/common/paths.py" whoami # 当前开发者名; 空则退出码1
|
|
667
|
+
# python "$R/.qoder/scripts/common/paths.py" current-task # 活动任务; 空则退出码1
|
|
668
|
+
# python "$R/.qoder/scripts/common/paths.py" repo-root # 打印仓库根绝对路径
|
|
669
|
+
# python "$R/.qoder/scripts/common/paths.py" test # 自检 (任意 cwd 下定位)
|
|
670
|
+
# =============================================================================
|
|
671
|
+
|
|
672
|
+
def _cli_main(argv=None):
|
|
673
|
+
import sys
|
|
674
|
+
argv = argv if argv is not None else sys.argv[1:]
|
|
675
|
+
cmd = argv[0] if argv else "whoami"
|
|
676
|
+
if cmd == "whoami":
|
|
677
|
+
d = get_developer()
|
|
678
|
+
if d:
|
|
679
|
+
print(d)
|
|
680
|
+
return 0
|
|
681
|
+
return 1 # 空身份 → 退出码1
|
|
682
|
+
elif cmd == "current-task":
|
|
683
|
+
t = get_current_task()
|
|
684
|
+
if t:
|
|
685
|
+
print(t)
|
|
686
|
+
return 0
|
|
687
|
+
return 1
|
|
688
|
+
elif cmd in ("repo-root", "root"):
|
|
689
|
+
print(str(get_repo_root()))
|
|
690
|
+
return 0
|
|
691
|
+
elif cmd == "test":
|
|
692
|
+
# 自检: 任意 cwd 下能否正确定位仓库 + 身份
|
|
693
|
+
print("root=%s" % get_repo_root())
|
|
694
|
+
print("developer=%s" % (get_developer() or "(空)"))
|
|
695
|
+
print("current-task=%s" % (get_current_task() or "(空)"))
|
|
696
|
+
return 0
|
|
697
|
+
else:
|
|
698
|
+
import sys
|
|
699
|
+
sys.stderr.write(
|
|
700
|
+
"用法: python paths.py [whoami|current-task|repo-root|test]\n")
|
|
701
|
+
return 2
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
if __name__ == "__main__":
|
|
705
|
+
import sys
|
|
706
706
|
sys.exit(_cli_main())
|