@hupan56/wlkj 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.js +213 -0
- package/package.json +11 -0
- package/templates/cli.js +198 -0
- package/templates/qoder/commands/wl-code.md +43 -0
- package/templates/qoder/commands/wl-commit.md +30 -0
- package/templates/qoder/commands/wl-init.md +80 -0
- package/templates/qoder/commands/wl-insight.md +51 -0
- package/templates/qoder/commands/wl-prd.md +199 -0
- package/templates/qoder/commands/wl-report.md +166 -0
- package/templates/qoder/commands/wl-search.md +52 -0
- package/templates/qoder/commands/wl-spec.md +18 -0
- package/templates/qoder/commands/wl-status.md +51 -0
- package/templates/qoder/commands/wl-task.md +71 -0
- package/templates/qoder/commands/wl-test.md +42 -0
- package/templates/qoder/config.toml +5 -0
- package/templates/qoder/config.yaml +141 -0
- package/templates/qoder/hooks/inject-workflow-state.py +117 -0
- package/templates/qoder/hooks/session-start.py +204 -0
- package/templates/qoder/rules/wl-pipeline.md +105 -0
- package/templates/qoder/scripts/add_session.py +245 -0
- package/templates/qoder/scripts/benchmark.py +209 -0
- package/templates/qoder/scripts/build_style_index.py +268 -0
- package/templates/qoder/scripts/code_index.py +41 -0
- package/templates/qoder/scripts/collect_prds.py +31 -0
- package/templates/qoder/scripts/common/__init__.py +0 -0
- package/templates/qoder/scripts/common/active_task.py +230 -0
- package/templates/qoder/scripts/common/atomicio.py +172 -0
- package/templates/qoder/scripts/common/developer.py +161 -0
- package/templates/qoder/scripts/common/eval_api.py +144 -0
- package/templates/qoder/scripts/common/feishu.py +278 -0
- package/templates/qoder/scripts/common/filelock.py +211 -0
- package/templates/qoder/scripts/common/identity.py +285 -0
- package/templates/qoder/scripts/common/mentions.py +134 -0
- package/templates/qoder/scripts/common/paths.py +311 -0
- package/templates/qoder/scripts/common/reqid.py +218 -0
- package/templates/qoder/scripts/common/search_engine.py +205 -0
- package/templates/qoder/scripts/common/task_utils.py +342 -0
- package/templates/qoder/scripts/common/terms.py +234 -0
- package/templates/qoder/scripts/common/utf8.py +38 -0
- package/templates/qoder/scripts/context_pack.py +196 -0
- package/templates/qoder/scripts/eval_prd.py +225 -0
- package/templates/qoder/scripts/export.py +487 -0
- package/templates/qoder/scripts/git_sync.py +1087 -0
- package/templates/qoder/scripts/handoff.py +22 -0
- package/templates/qoder/scripts/init_developer.py +76 -0
- package/templates/qoder/scripts/init_doctor.py +527 -0
- package/templates/qoder/scripts/install_qoderwork.py +339 -0
- package/templates/qoder/scripts/learn.py +67 -0
- package/templates/qoder/scripts/notify.py +5 -0
- package/templates/qoder/scripts/parse_prds.py +33 -0
- package/templates/qoder/scripts/report.py +281 -0
- package/templates/qoder/scripts/role.py +39 -0
- package/templates/qoder/scripts/run_weekly_update.bat +17 -0
- package/templates/qoder/scripts/run_weekly_update.sh +20 -0
- package/templates/qoder/scripts/search_index.py +352 -0
- package/templates/qoder/scripts/setup.py +453 -0
- package/templates/qoder/scripts/setup_weekly_cron.bat +22 -0
- package/templates/qoder/scripts/setup_weekly_cron.sh +19 -0
- package/templates/qoder/scripts/status.py +389 -0
- package/templates/qoder/scripts/syncgate.py +330 -0
- package/templates/qoder/scripts/task.py +954 -0
- package/templates/qoder/scripts/team.py +29 -0
- package/templates/qoder/scripts/team_sync.py +419 -0
- package/templates/qoder/scripts/workspace_init.py +102 -0
- package/templates/qoder/settings.json +53 -0
- package/templates/qoder/skills/design-review/SKILL.md +25 -0
- package/templates/qoder/skills/prd-generator/SKILL.md +180 -0
- package/templates/qoder/skills/prd-review/SKILL.md +36 -0
- package/templates/qoder/skills/prototype-generator/SKILL.md +141 -0
- package/templates/qoder/skills/spec-coder/SKILL.md +69 -0
- package/templates/qoder/skills/spec-generator/SKILL.md +67 -0
- package/templates/qoder/skills/test-generator/SKILL.md +72 -0
- package/templates/qoder/skills/wl-commit/SKILL.md +76 -0
- package/templates/qoder/skills/wl-init/SKILL.md +67 -0
- package/templates/qoder/skills/wl-insight/SKILL.md +81 -0
- package/templates/qoder/skills/wl-report/SKILL.md +87 -0
- package/templates/qoder/skills/wl-search/SKILL.md +75 -0
- package/templates/qoder/skills/wl-status/SKILL.md +61 -0
- package/templates/qoder/skills/wl-task/SKILL.md +58 -0
- package/templates/qoder/templates/prd-full-template.md +103 -0
- package/templates/qoder/templates/prd-quick-template.md +69 -0
- package/templates/qoder/templates/prototype-app.html +344 -0
- package/templates/qoder/templates/prototype-web.html +310 -0
- package/templates/root/AGENTS.md +182 -0
- package/templates/root/README-pipeline.md +56 -0
- package/templates/root/ROLES.md +85 -0
- package/templates/root//346/226/260/346/211/213/346/214/207/345/215/227.md +186 -0
|
@@ -0,0 +1,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
|