@hupan56/wlkj 3.1.25 → 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.
@@ -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: 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 _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())