@hupan56/wlkj 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/bin/cli.js +213 -0
  2. package/package.json +11 -0
  3. package/templates/cli.js +198 -0
  4. package/templates/qoder/commands/wl-code.md +43 -0
  5. package/templates/qoder/commands/wl-commit.md +30 -0
  6. package/templates/qoder/commands/wl-init.md +80 -0
  7. package/templates/qoder/commands/wl-insight.md +51 -0
  8. package/templates/qoder/commands/wl-prd.md +199 -0
  9. package/templates/qoder/commands/wl-report.md +166 -0
  10. package/templates/qoder/commands/wl-search.md +52 -0
  11. package/templates/qoder/commands/wl-spec.md +18 -0
  12. package/templates/qoder/commands/wl-status.md +51 -0
  13. package/templates/qoder/commands/wl-task.md +71 -0
  14. package/templates/qoder/commands/wl-test.md +42 -0
  15. package/templates/qoder/config.toml +5 -0
  16. package/templates/qoder/config.yaml +141 -0
  17. package/templates/qoder/hooks/inject-workflow-state.py +117 -0
  18. package/templates/qoder/hooks/session-start.py +204 -0
  19. package/templates/qoder/rules/wl-pipeline.md +105 -0
  20. package/templates/qoder/scripts/add_session.py +245 -0
  21. package/templates/qoder/scripts/benchmark.py +209 -0
  22. package/templates/qoder/scripts/build_style_index.py +268 -0
  23. package/templates/qoder/scripts/code_index.py +41 -0
  24. package/templates/qoder/scripts/collect_prds.py +31 -0
  25. package/templates/qoder/scripts/common/__init__.py +0 -0
  26. package/templates/qoder/scripts/common/active_task.py +230 -0
  27. package/templates/qoder/scripts/common/atomicio.py +172 -0
  28. package/templates/qoder/scripts/common/developer.py +161 -0
  29. package/templates/qoder/scripts/common/eval_api.py +144 -0
  30. package/templates/qoder/scripts/common/feishu.py +278 -0
  31. package/templates/qoder/scripts/common/filelock.py +211 -0
  32. package/templates/qoder/scripts/common/identity.py +285 -0
  33. package/templates/qoder/scripts/common/mentions.py +134 -0
  34. package/templates/qoder/scripts/common/paths.py +311 -0
  35. package/templates/qoder/scripts/common/reqid.py +218 -0
  36. package/templates/qoder/scripts/common/search_engine.py +205 -0
  37. package/templates/qoder/scripts/common/task_utils.py +342 -0
  38. package/templates/qoder/scripts/common/terms.py +234 -0
  39. package/templates/qoder/scripts/common/utf8.py +38 -0
  40. package/templates/qoder/scripts/context_pack.py +196 -0
  41. package/templates/qoder/scripts/eval_prd.py +225 -0
  42. package/templates/qoder/scripts/export.py +487 -0
  43. package/templates/qoder/scripts/git_sync.py +1087 -0
  44. package/templates/qoder/scripts/handoff.py +22 -0
  45. package/templates/qoder/scripts/init_developer.py +76 -0
  46. package/templates/qoder/scripts/init_doctor.py +527 -0
  47. package/templates/qoder/scripts/install_qoderwork.py +339 -0
  48. package/templates/qoder/scripts/learn.py +67 -0
  49. package/templates/qoder/scripts/notify.py +5 -0
  50. package/templates/qoder/scripts/parse_prds.py +33 -0
  51. package/templates/qoder/scripts/report.py +281 -0
  52. package/templates/qoder/scripts/role.py +39 -0
  53. package/templates/qoder/scripts/run_weekly_update.bat +17 -0
  54. package/templates/qoder/scripts/run_weekly_update.sh +20 -0
  55. package/templates/qoder/scripts/search_index.py +352 -0
  56. package/templates/qoder/scripts/setup.py +453 -0
  57. package/templates/qoder/scripts/setup_weekly_cron.bat +22 -0
  58. package/templates/qoder/scripts/setup_weekly_cron.sh +19 -0
  59. package/templates/qoder/scripts/status.py +389 -0
  60. package/templates/qoder/scripts/syncgate.py +330 -0
  61. package/templates/qoder/scripts/task.py +954 -0
  62. package/templates/qoder/scripts/team.py +29 -0
  63. package/templates/qoder/scripts/team_sync.py +419 -0
  64. package/templates/qoder/scripts/workspace_init.py +102 -0
  65. package/templates/qoder/settings.json +53 -0
  66. package/templates/qoder/skills/design-review/SKILL.md +25 -0
  67. package/templates/qoder/skills/prd-generator/SKILL.md +180 -0
  68. package/templates/qoder/skills/prd-review/SKILL.md +36 -0
  69. package/templates/qoder/skills/prototype-generator/SKILL.md +141 -0
  70. package/templates/qoder/skills/spec-coder/SKILL.md +69 -0
  71. package/templates/qoder/skills/spec-generator/SKILL.md +67 -0
  72. package/templates/qoder/skills/test-generator/SKILL.md +72 -0
  73. package/templates/qoder/skills/wl-commit/SKILL.md +76 -0
  74. package/templates/qoder/skills/wl-init/SKILL.md +67 -0
  75. package/templates/qoder/skills/wl-insight/SKILL.md +81 -0
  76. package/templates/qoder/skills/wl-report/SKILL.md +87 -0
  77. package/templates/qoder/skills/wl-search/SKILL.md +75 -0
  78. package/templates/qoder/skills/wl-status/SKILL.md +61 -0
  79. package/templates/qoder/skills/wl-task/SKILL.md +58 -0
  80. package/templates/qoder/templates/prd-full-template.md +103 -0
  81. package/templates/qoder/templates/prd-quick-template.md +69 -0
  82. package/templates/qoder/templates/prototype-app.html +344 -0
  83. package/templates/qoder/templates/prototype-web.html +310 -0
  84. package/templates/root/AGENTS.md +182 -0
  85. package/templates/root/README-pipeline.md +56 -0
  86. package/templates/root/ROLES.md +85 -0
  87. package/templates/root//346/226/260/346/211/213/346/214/207/345/215/227.md +186 -0
@@ -0,0 +1,268 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ Build style index from Vue/HTML files.
5
+ Extracts UI patterns: tables, forms, layouts, colors, components.
6
+
7
+ Usage:
8
+ python build_style_index.py
9
+ """
10
+ import os, json, re, sys
11
+ from datetime import datetime
12
+
13
+ # UTF-8 stdio (防御性: stdout 被捕获时不崩溃)
14
+ try:
15
+ sys.stdout.reconfigure(encoding='utf-8', errors='replace')
16
+ except (AttributeError, TypeError, OSError, IOError):
17
+ try:
18
+ sys.stdout.reconfigure(encoding='utf-8')
19
+ except Exception:
20
+ pass
21
+
22
+ BASE = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
23
+ DATA_CODE = os.path.join(BASE, 'data', 'code')
24
+ INDEX_DIR = os.path.join(BASE, 'data', 'index')
25
+ STYLE_INDEX_PATH = os.path.join(INDEX_DIR, 'style-index.json')
26
+ STYLE_META_PATH = os.path.join(INDEX_DIR, 'style-meta.json')
27
+
28
+
29
+ def extract_vue_patterns(filepath, content):
30
+ """Extract UI patterns from a Vue SFC file."""
31
+ patterns = {
32
+ 'file': filepath.replace('\\', '/'),
33
+ 'type': 'unknown',
34
+ 'components': [],
35
+ 'table_columns': [],
36
+ 'form_fields': [],
37
+ 'form_components': [],
38
+ 'layout': {},
39
+ }
40
+
41
+ # Detect page type. Detectors cover multiple UI stacks so the index
42
+ # generalizes beyond this repo (Vben/VxeGrid, Ant Design, Element, Vant).
43
+ TABLE_MARKS = ('VxeGrid', 'vxe-grid', 'useVbenVxeGrid', '<a-table', '<el-table',
44
+ '<vxe-table', '<van-list')
45
+ FORM_MARKS = ('useVbenForm', 'useForm', '<a-form', '<el-form', '<van-form')
46
+ if any(m in content for m in TABLE_MARKS):
47
+ patterns['type'] = 'table-page'
48
+ elif any(m in content for m in FORM_MARKS):
49
+ patterns['type'] = 'form-page'
50
+ elif 'detail' in filepath.lower() or 'Detail' in content:
51
+ patterns['type'] = 'detail-page'
52
+ elif 'modal' in filepath.lower() or 'drawer' in filepath.lower():
53
+ patterns['type'] = 'modal'
54
+ elif 'dashboard' in filepath.lower() or 'chart' in content.lower():
55
+ patterns['type'] = 'dashboard'
56
+
57
+ # Extract Ant Design component imports
58
+ ant_matches = re.findall(r"import\s+\{([^}]+)\}\s+from\s+['\"]ant-design-vue['\"]", content)
59
+ for m in ant_matches:
60
+ comps = [c.strip() for c in m.split(',') if c.strip()]
61
+ patterns['components'].extend(comps)
62
+
63
+ # Extract table columns: title + field
64
+ col_matches = re.findall(r"title:\s*['\"]([^'\"]+)['\"]\s*,\s*field:\s*['\"]([^'\"]+)['\"]", content)
65
+ if not col_matches:
66
+ col_matches = re.findall(r"field:\s*['\"]([^'\"]+)['\"]\s*,\s*title:\s*['\"]([^'\"]+)['\"]", content)
67
+ col_matches = [(t, f) for f, t in col_matches]
68
+ for title, field in col_matches:
69
+ patterns['table_columns'].append({'title': title, 'field': field})
70
+
71
+ # Extract form fields: fieldName + label
72
+ form_matches = re.findall(r"fieldName:\s*['\"]([^'\"]+)['\"]\s*,\s*label:\s*['\"]([^'\"]+)['\"]", content)
73
+ for field, label in form_matches:
74
+ patterns['form_fields'].append({'field': field, 'label': label})
75
+
76
+ # Extract form component types
77
+ comp_matches = re.findall(r"component:\s*['\"]([^'\"]+)['\"]", content)
78
+ patterns['form_components'] = list(set(comp_matches))
79
+
80
+ # Extract layout
81
+ label_w = re.findall(r'labelWidth:\s*(\d+)', content)
82
+ if label_w:
83
+ patterns['layout']['label_width'] = int(label_w[0])
84
+ grid_c = re.findall(r'grid-cols-(\d+)', content)
85
+ if grid_c:
86
+ patterns['layout']['grid_cols'] = [int(x) for x in grid_c]
87
+
88
+ return patterns
89
+
90
+
91
+ def scan_project(proj_name, proj_path):
92
+ """Scan a single frontend project."""
93
+ pages = []
94
+ component_counter = {}
95
+ field_map = {}
96
+ form_components = {}
97
+ vue_count = 0
98
+ data_count = 0
99
+
100
+ for root, dirs, files in os.walk(proj_path):
101
+ dirs[:] = [d for d in dirs if d not in ['node_modules', 'dist', '.git', '__pycache__', '.svn']]
102
+
103
+ for f in files:
104
+ filepath = os.path.join(root, f)
105
+ rel_path = os.path.relpath(filepath, proj_path).replace('\\', '/')
106
+
107
+ if f.endswith('.vue'):
108
+ vue_count += 1
109
+ try:
110
+ content = open(filepath, encoding='utf-8', errors='ignore').read()
111
+ p = extract_vue_patterns(proj_name + '/' + rel_path, content)
112
+ pages.append(p)
113
+
114
+ for comp in p.get('components', []):
115
+ component_counter[comp] = component_counter.get(comp, 0) + 1
116
+ for col in p.get('table_columns', []):
117
+ k = col['field']
118
+ if k not in field_map:
119
+ field_map[k] = {'field': k, 'titles': set(), 'files': []}
120
+ field_map[k]['titles'].add(col['title'])
121
+ field_map[k]['files'].append(proj_name + '/' + rel_path)
122
+ for comp in p.get('form_components', []):
123
+ form_components[comp] = form_components.get(comp, 0) + 1
124
+ except Exception as e:
125
+ pass # Skip unreadable files
126
+
127
+ elif f.endswith(('.tsx', '.ts')) and f.startswith('data'):
128
+ data_count += 1
129
+ try:
130
+ content = open(filepath, encoding='utf-8', errors='ignore').read()
131
+ if 'columns' in content or 'schema' in content:
132
+ col_m = re.findall(r"title:\s*['\"]([^'\"]+)['\"]\s*,\s*field:\s*['\"]([^'\"]+)['\"]", content)
133
+ for title, field in col_m:
134
+ k = field
135
+ if k not in field_map:
136
+ field_map[k] = {'field': k, 'titles': set(), 'files': []}
137
+ field_map[k]['titles'].add(title)
138
+ field_map[k]['files'].append(proj_name + '/' + rel_path)
139
+
140
+ comp_m = re.findall(r"component:\s*['\"]([^'\"]+)['\"]", content)
141
+ for comp in comp_m:
142
+ form_components[comp] = form_components.get(comp, 0) + 1
143
+ except:
144
+ pass
145
+
146
+ return {
147
+ 'pages': pages,
148
+ 'component_counter': component_counter,
149
+ 'field_map': field_map,
150
+ 'form_components': form_components,
151
+ 'vue_count': vue_count,
152
+ 'data_count': data_count,
153
+ }
154
+
155
+
156
+ def main():
157
+ print('Building style index...')
158
+ print('Code dir: ' + DATA_CODE)
159
+
160
+ if not os.path.isdir(DATA_CODE):
161
+ print('ERROR: data/code not found!')
162
+ return
163
+
164
+ style_index = {
165
+ 'version': '1.0',
166
+ 'updated': datetime.now().isoformat(),
167
+ 'projects': {},
168
+ 'common_components': {},
169
+ 'form_components': {},
170
+ 'field_map': {},
171
+ 'page_templates': {},
172
+ }
173
+
174
+ total_component_counter = {}
175
+ total_form_components = {}
176
+ total_field_map = {}
177
+
178
+ for proj in sorted(os.listdir(DATA_CODE)):
179
+ proj_path = os.path.join(DATA_CODE, proj)
180
+ if not os.path.isdir(proj_path):
181
+ continue
182
+
183
+ print('Scanning: ' + proj + '...')
184
+ result = scan_project(proj, proj_path)
185
+ print(' Vue: ' + str(result['vue_count']) + ', Data: ' + str(result['data_count']))
186
+
187
+ style_index['projects'][proj] = {
188
+ 'vue_files': result['vue_count'],
189
+ 'data_files': result['data_count'],
190
+ 'page_types': {},
191
+ 'page_examples': {},
192
+ }
193
+
194
+ # Count page types and keep sample files per type so
195
+ # `search_index.py --style <type>` can return real reference pages.
196
+ # Prefer richer pages (more table columns / form fields) as examples.
197
+ pages_by_type = {}
198
+ for page in result['pages']:
199
+ pt = page.get('type', 'unknown')
200
+ style_index['projects'][proj]['page_types'][pt] = style_index['projects'][proj]['page_types'].get(pt, 0) + 1
201
+ pages_by_type.setdefault(pt, []).append(page)
202
+ for pt, pages in pages_by_type.items():
203
+ pages.sort(key=lambda p: len(p.get('table_columns', [])) + len(p.get('form_fields', [])), reverse=True)
204
+ style_index['projects'][proj]['page_examples'][pt] = sorted(
205
+ [p['file'] for p in pages[:15]])
206
+
207
+ # Merge component counters
208
+ for comp, count in result['component_counter'].items():
209
+ total_component_counter[comp] = total_component_counter.get(comp, 0) + count
210
+
211
+ for comp, count in result['form_components'].items():
212
+ total_form_components[comp] = total_form_components.get(comp, 0) + count
213
+
214
+ for k, v in result['field_map'].items():
215
+ if k not in total_field_map:
216
+ total_field_map[k] = v
217
+ else:
218
+ total_field_map[k]['titles'].update(v['titles'])
219
+ total_field_map[k]['files'].extend(v['files'])
220
+
221
+ # Sort and save
222
+ style_index['common_components'] = dict(sorted(total_component_counter.items(), key=lambda x: x[1], reverse=True)[:30])
223
+ style_index['form_components'] = dict(sorted(total_form_components.items(), key=lambda x: x[1], reverse=True))
224
+
225
+ # Convert sets to lists for JSON
226
+ field_map_serializable = {}
227
+ for k, v in total_field_map.items():
228
+ field_map_serializable[k] = {
229
+ 'field': k,
230
+ 'titles': sorted(v['titles']),
231
+ 'count': len(v['files']),
232
+ 'sample_files': v['files'][:5]
233
+ }
234
+ style_index['field_map'] = field_map_serializable
235
+
236
+ # Save
237
+ os.makedirs(INDEX_DIR, exist_ok=True)
238
+
239
+ with open(STYLE_INDEX_PATH, 'w', encoding='utf-8') as f:
240
+ json.dump(style_index, f, ensure_ascii=False, indent=2, sort_keys=True)
241
+
242
+ # Meta (small) - this is what hooks read at session start
243
+ meta = {
244
+ 'version': style_index['version'],
245
+ 'updated': style_index['updated'],
246
+ 'projects': style_index['projects'],
247
+ 'common_components': style_index['common_components'],
248
+ 'form_components': style_index['form_components'],
249
+ 'field_count': len(style_index['field_map']),
250
+ }
251
+ # Keep meta light: drop per-type example file lists
252
+ meta['projects'] = {
253
+ p: {k: v for k, v in pdata.items() if k != 'page_examples'}
254
+ for p, pdata in meta['projects'].items()
255
+ }
256
+ with open(STYLE_META_PATH, 'w', encoding='utf-8') as f:
257
+ json.dump(meta, f, ensure_ascii=False, indent=2, sort_keys=True)
258
+
259
+ print()
260
+ print('=== Done ===')
261
+ print('Common components: ' + str(len(style_index['common_components'])))
262
+ print('Form components: ' + str(len(style_index['form_components'])))
263
+ print('Field map: ' + str(len(style_index['field_map'])) + ' entries')
264
+ print('Saved to: ' + STYLE_META_PATH)
265
+
266
+
267
+ if __name__ == '__main__':
268
+ main()
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ DEPRECATED: code_index.py used to keep its own (cruder) index builder
5
+ whose schema conflicted with git_sync.py's - running it would overwrite
6
+ the real indexes. It now delegates to the single implementation.
7
+
8
+ Usage:
9
+ python code_index.py scan # full index rebuild (= git_sync.py --index-only)
10
+ python code_index.py search <kw> # use search_index.py instead
11
+ """
12
+
13
+ import os
14
+ import sys
15
+
16
+ # UTF-8 stdio (防御性: stdout 被捕获时不崩溃)
17
+ try:
18
+ sys.stdout.reconfigure(encoding='utf-8', errors='replace')
19
+ except (AttributeError, TypeError, OSError, IOError):
20
+ try:
21
+ sys.stdout.reconfigure(encoding='utf-8')
22
+ except Exception:
23
+ pass
24
+
25
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
26
+
27
+ if __name__ == '__main__':
28
+ cmd = sys.argv[1] if len(sys.argv) > 1 else ''
29
+ if cmd == 'scan':
30
+ from git_sync import build_full_indexes, FAILURES
31
+ build_full_indexes()
32
+ sys.exit(1 if FAILURES else 0)
33
+ elif cmd == 'search' and len(sys.argv) > 2:
34
+ import subprocess
35
+ script = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'search_index.py')
36
+ sys.exit(subprocess.call([sys.executable, script] + sys.argv[2:]))
37
+ else:
38
+ print('Usage:')
39
+ print(' python code_index.py scan # full index rebuild')
40
+ print(' python code_index.py search <kw> # -> search_index.py')
41
+ sys.exit(1)
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ Collect PRDs from all user workspaces to data/docs/prd/
5
+
6
+ THIN WRAPPER: the actual logic lives in git_sync.py (single source of
7
+ truth). This entry point is kept for backwards compatibility.
8
+
9
+ Usage:
10
+ python collect_prds.py # same as the collect step of: python git_sync.py --prd-only
11
+ """
12
+
13
+ import os
14
+ import sys
15
+
16
+ # UTF-8 stdio (防御性: stdout 被捕获时不崩溃)
17
+ try:
18
+ sys.stdout.reconfigure(encoding='utf-8', errors='replace')
19
+ except (AttributeError, TypeError, OSError, IOError):
20
+ try:
21
+ sys.stdout.reconfigure(encoding='utf-8')
22
+ except Exception:
23
+ pass
24
+
25
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
26
+
27
+ from git_sync import collect_prds, FAILURES
28
+
29
+ if __name__ == '__main__':
30
+ collect_prds()
31
+ sys.exit(1 if FAILURES else 0)
File without changes
@@ -0,0 +1,230 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ QODER Pipeline - 会话级活跃任务管理
5
+
6
+ 核心概念: "活跃任务" 是按 AI 会话/窗口存储的, 不是全局的。
7
+
8
+ 存储位置: .qoder/.runtime/sessions/<session-id>
9
+ 每个会话文件只包含一行: 活跃任务的相对路径。
10
+
11
+ 会话身份来源 (按优先级):
12
+ 1. 环境变量: QODER_SESSION_ID, CODEX_SESSION_ID, CODEX_THREAD_ID 等
13
+ 2. Hook 输入中的 session_id 字段
14
+ 3. TRELLIS_CONTEXT_ID 环境变量
15
+ 4. 后备: 全局 .qoder/.current-task 文件
16
+
17
+ 参考: Trellis 的 common/active_task.py 设计
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import hashlib
23
+ import json
24
+ import os
25
+ import sys
26
+ from dataclasses import dataclass
27
+ from pathlib import Path
28
+ from typing import Any
29
+
30
+ # 支持的 AI 平台及其会话环境变量
31
+ _ENV_SESSION_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = (
32
+ ("qoder", ("QODER_SESSION_ID",)),
33
+ ("codex", ("CODEX_SESSION_ID", "CODEX_THREAD_ID")),
34
+ ("claude", ("CLAUDE_SESSION_ID", "CLAUDE_CODE_SESSION_ID")),
35
+ ("cursor", ("CURSOR_SESSION_ID",)),
36
+ ("opencode", ("OPENCODE_SESSION_ID", "OPENCODE_RUN_ID")),
37
+ ("gemini", ("GEMINI_SESSION_ID",)),
38
+ ("copilot", ("COPILOT_SESSION_ID",)),
39
+ )
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class ActiveTask:
44
+ """活跃任务状态。
45
+
46
+ Attributes:
47
+ task_path: 任务相对路径, None 表示无活跃任务。
48
+ source_type: 来源类型 (session / global-fallback / none)。
49
+ context_key: 会话标识键。
50
+ stale: 是否过期。
51
+ """
52
+ task_path: str | None
53
+ source_type: str # "session" | "global-fallback" | "none"
54
+ context_key: str | None = None
55
+ stale: bool = False
56
+
57
+ @property
58
+ def source(self) -> str:
59
+ """人类可读的来源标签。"""
60
+ if self.source_type == "session" and self.context_key:
61
+ return f"session:{self.context_key}"
62
+ if self.source_type == "global-fallback":
63
+ return "global-fallback"
64
+ return "none"
65
+
66
+
67
+ # =============================================================================
68
+ # 会话身份解析
69
+ # =============================================================================
70
+
71
+ def resolve_context_key() -> str | None:
72
+ """解析当前会话的唯一标识键。
73
+
74
+ 按平台优先级检查环境变量, 返回第一个找到的值。
75
+
76
+ Returns:
77
+ 会话 ID 字符串, 无则返回 None。
78
+ """
79
+ # 1. 检查通用环境变量
80
+ for env_var in ("TRELLIS_CONTEXT_ID", "QODER_CONTEXT_ID"):
81
+ val = os.environ.get(env_var)
82
+ if val:
83
+ return val
84
+
85
+ # 2. 检查各平台专用环境变量
86
+ for _platform, env_vars in _ENV_SESSION_KEYS:
87
+ for env_var in env_vars:
88
+ val = os.environ.get(env_var)
89
+ if val:
90
+ return val
91
+
92
+ return None
93
+
94
+
95
+ def _get_session_file(session_key: str, repo_root: Path) -> Path:
96
+ """获取会话状态文件路径。
97
+
98
+ Args:
99
+ session_key: 会话标识。
100
+ repo_root: 项目根目录。
101
+
102
+ Returns:
103
+ 会话文件路径。
104
+ """
105
+ # 对 session_key 做 hash 避免特殊字符问题
106
+ key_hash = hashlib.sha256(session_key.encode()).hexdigest()[:16]
107
+ sessions_dir = repo_root / ".qoder" / ".runtime" / "sessions"
108
+ sessions_dir.mkdir(parents=True, exist_ok=True)
109
+ return sessions_dir / f"session-{key_hash}"
110
+
111
+
112
+ # =============================================================================
113
+ # 活跃任务读写
114
+ # =============================================================================
115
+
116
+ def resolve_active_task(repo_root: Path | None = None) -> ActiveTask:
117
+ """解析当前会话的活跃任务。
118
+
119
+ 优先级:
120
+ 1. 会话级状态文件
121
+ 2. 全局 .current-task 文件 (后备)
122
+
123
+ Args:
124
+ repo_root: 项目根目录, 默认自动检测。
125
+
126
+ Returns:
127
+ ActiveTask 对象。
128
+ """
129
+ if repo_root is None:
130
+ from common.paths import get_repo_root
131
+ repo_root = get_repo_root()
132
+
133
+ # 1. 尝试会话级
134
+ session_key = resolve_context_key()
135
+ if session_key:
136
+ session_file = _get_session_file(session_key, repo_root)
137
+ if session_file.is_file():
138
+ try:
139
+ task_path = session_file.read_text(encoding="utf-8").strip()
140
+ if task_path:
141
+ return ActiveTask(
142
+ task_path=task_path,
143
+ source_type="session",
144
+ context_key=session_key,
145
+ )
146
+ except (OSError, IOError):
147
+ pass
148
+
149
+ # 2. 后备: 全局文件
150
+ from common.paths import get_current_task
151
+ global_task = get_current_task(repo_root)
152
+ if global_task:
153
+ return ActiveTask(
154
+ task_path=global_task,
155
+ source_type="global-fallback",
156
+ context_key=session_key,
157
+ )
158
+
159
+ return ActiveTask(task_path=None, source_type="none")
160
+
161
+
162
+ def set_active_task(task_path: str, repo_root: Path | None = None) -> ActiveTask | None:
163
+ """设置当前会话的活跃任务。
164
+
165
+ Args:
166
+ task_path: 任务相对路径。
167
+ repo_root: 项目根目录, 默认自动检测。
168
+
169
+ Returns:
170
+ ActiveTask 对象, 无会话身份则返回 None。
171
+ """
172
+ if repo_root is None:
173
+ from common.paths import get_repo_root
174
+ repo_root = get_repo_root()
175
+
176
+ session_key = resolve_context_key()
177
+ if not session_key:
178
+ # 无会话身份, 使用全局后备
179
+ from common.paths import set_current_task
180
+ set_current_task(task_path, repo_root)
181
+ return ActiveTask(
182
+ task_path=task_path,
183
+ source_type="global-fallback",
184
+ context_key=None,
185
+ )
186
+
187
+ session_file = _get_session_file(session_key, repo_root)
188
+ try:
189
+ session_file.parent.mkdir(parents=True, exist_ok=True)
190
+ session_file.write_text(task_path, encoding="utf-8")
191
+ return ActiveTask(
192
+ task_path=task_path,
193
+ source_type="session",
194
+ context_key=session_key,
195
+ )
196
+ except (OSError, IOError) as e:
197
+ print(f"Warning: Failed to write session file: {e}", file=sys.stderr)
198
+ return None
199
+
200
+
201
+ def clear_active_task(repo_root: Path | None = None) -> ActiveTask:
202
+ """清除当前会话的活跃任务。
203
+
204
+ Args:
205
+ repo_root: 项目根目录, 默认自动检测。
206
+
207
+ Returns:
208
+ 清除前的 ActiveTask 状态。
209
+ """
210
+ if repo_root is None:
211
+ from common.paths import get_repo_root
212
+ repo_root = get_repo_root()
213
+
214
+ # 获取清除前的状态
215
+ active = resolve_active_task(repo_root)
216
+
217
+ # 清除会话文件
218
+ session_key = resolve_context_key()
219
+ if session_key:
220
+ session_file = _get_session_file(session_key, repo_root)
221
+ try:
222
+ session_file.unlink(missing_ok=True)
223
+ except (OSError, IOError):
224
+ pass
225
+
226
+ # 也清除全局后备
227
+ from common.paths import set_current_task
228
+ set_current_task(None, repo_root)
229
+
230
+ return active