@hupan56/wlkj 3.1.31 → 3.2.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 (70) hide show
  1. package/package.json +1 -1
  2. package/templates/qoder/agents/insight-planning.md +67 -67
  3. package/templates/qoder/agents/prd-reference.md +47 -47
  4. package/templates/qoder/commands/optional/wl-insight.md +276 -276
  5. package/templates/qoder/commands/optional/wl-spec.md +10 -2
  6. package/templates/qoder/commands/wl-code.md +63 -4
  7. package/templates/qoder/commands/wl-design.md +1 -1
  8. package/templates/qoder/commands/wl-prd.md +87 -13
  9. package/templates/qoder/commands/wl-req.md +10 -3
  10. package/templates/qoder/commands/wl-task.md +613 -613
  11. package/templates/qoder/commands/wl-test.md +2 -2
  12. package/templates/qoder/contracts/CHANGELOG.md +418 -0
  13. package/templates/qoder/contracts/README.md +180 -0
  14. package/templates/qoder/contracts/code.md +82 -0
  15. package/templates/qoder/contracts/commit.md +86 -0
  16. package/templates/qoder/contracts/contract-header.md +76 -0
  17. package/templates/qoder/contracts/design.md +106 -0
  18. package/templates/qoder/contracts/fallback.md +126 -0
  19. package/templates/qoder/contracts/isolation.md +119 -0
  20. package/templates/qoder/contracts/prd.md +118 -0
  21. package/templates/qoder/contracts/schemas/design-spec.schema.json +46 -0
  22. package/templates/qoder/contracts/schemas/prd.schema.json +36 -0
  23. package/templates/qoder/contracts/schemas/test-cases.schema.json +40 -0
  24. package/templates/qoder/contracts/spec.md +112 -0
  25. package/templates/qoder/contracts/task.md +125 -0
  26. package/templates/qoder/contracts/test.md +112 -0
  27. package/templates/qoder/hooks/post-tool-use.py +20 -0
  28. package/templates/qoder/hooks/stop-eval.py +47 -0
  29. package/templates/qoder/rules/wl-pipeline.md +37 -0
  30. package/templates/qoder/scripts/deployment/setup/install_qoderwork.py +11 -0
  31. package/templates/qoder/scripts/deployment/setup/setup.py +70 -0
  32. package/templates/qoder/scripts/deployment/setup/wlkj_shim.py +104 -0
  33. package/templates/qoder/scripts/domain/deployment/deploy_to_test.py +298 -0
  34. package/templates/qoder/scripts/domain/kg/build/kg_build.py +241 -22
  35. package/templates/qoder/scripts/domain/kg/build/kg_incremental.py +108 -3
  36. package/templates/qoder/scripts/domain/kg/build/kg_signatures.py +169 -0
  37. package/templates/qoder/scripts/domain/kg/extract/ts_extract.py +111 -0
  38. package/templates/qoder/scripts/domain/kg/storage/kg_duckdb.py +43 -0
  39. package/templates/qoder/scripts/domain/requirement/req.py +134 -28
  40. package/templates/qoder/scripts/domain/task/zentao_panel.py +688 -53
  41. package/templates/qoder/scripts/foundation/core/paths.py +102 -0
  42. package/templates/qoder/scripts/protocol/mcp/zentao_mcp_server.py +23 -10
  43. package/templates/qoder/scripts/validation/eval/qwork_harness.py +1 -1
  44. package/templates/qoder/scripts/validation/eval/report-commands.md +2 -2
  45. package/templates/qoder/settings.json +27 -9
  46. package/templates/qoder/skills/design-import/SKILL.md +3 -3
  47. package/templates/qoder/skills/design-review/SKILL.md +1 -1
  48. package/templates/qoder/skills/prd-generator/SKILL.md +4 -4
  49. package/templates/qoder/skills/prd-review/SKILL.md +1 -1
  50. package/templates/qoder/skills/prototype-generator/SKILL.md +3 -3
  51. package/templates/qoder/skills/spec-coder/SKILL.md +1 -1
  52. package/templates/qoder/skills/spec-generator/SKILL.md +80 -23
  53. package/templates/qoder/skills/test-generator/SKILL.md +1 -1
  54. package/templates/qoder/skills/wl-code/SKILL.md +13 -1
  55. package/templates/qoder/skills/wl-commit/SKILL.md +1 -1
  56. package/templates/qoder/skills/wl-design/SKILL.md +6 -6
  57. package/templates/qoder/skills/wl-init/SKILL.md +2 -2
  58. package/templates/qoder/skills/wl-insight/SKILL.md +5 -5
  59. package/templates/qoder/skills/wl-prd/SKILL.md +60 -0
  60. package/templates/qoder/skills/wl-report/SKILL.md +2 -2
  61. package/templates/qoder/skills/wl-search/SKILL.md +1 -1
  62. package/templates/qoder/skills/wl-spec/SKILL.md +2 -2
  63. package/templates/qoder/skills/wl-status/SKILL.md +2 -2
  64. package/templates/qoder/skills/wl-task/SKILL.md +3 -3
  65. package/templates/qoder/skills/wl-test/SKILL.md +2 -2
  66. package/templates/qoder/templates/spec-template.md +124 -0
  67. package/templates/root/AGENTS.md +32 -4
  68. package/templates/qoder/skills/wl-prd-full/SKILL.md +0 -121
  69. package/templates/qoder/skills/wl-prd-quick/SKILL.md +0 -50
  70. package/templates/qoder/skills/wl-prd-review/SKILL.md +0 -47
@@ -0,0 +1,169 @@
1
+ # -*- coding: utf-8 -*-
2
+ # v3.0 路径自举: 引导到 common/bootstrap, 统一 sys.path 逻辑
3
+ import os as _o, sys as _s
4
+ _f = _o.path.abspath(__file__)
5
+ for _ in range(10):
6
+ _f = _o.path.dirname(_f)
7
+ _cp = _o.path.join(_f, 'foundation')
8
+ if _o.path.isfile(_o.path.join(_cp, 'bootstrap.py')):
9
+ break
10
+ if _cp not in _s.path: _s.path.insert(0, _cp)
11
+ from bootstrap import setup; setup()
12
+
13
+ """
14
+ File-signature based incremental change detection (P0: fingerprint 三级增量).
15
+
16
+ Goal: skip re-extraction for files whose changes don't affect the graph.
17
+ The knowledge graph only records *topology* (who clicks/calls/handles whom),
18
+ not implementations — so a file that only changed comments/whitespace has
19
+ zero impact on the graph and can be safely skipped.
20
+
21
+ Three levels (mirrors Understand-Anything's fingerprint approach, adapted to
22
+ our regex/topology-only graph):
23
+ - NONE : content_hash unchanged → file is byte-identical, skip entirely.
24
+ - COSMETIC : structural_hash unchanged but content_hash changed
25
+ → only comments/whitespace differ, skip re-extraction.
26
+ - STRUCTURAL : structural_hash changed → real code changed, re-extract.
27
+
28
+ The signature is intentionally coarse (strip comments + all whitespace, hash
29
+ the rest) — language-agnostic and robust. Worst case (e.g. pure variable
30
+ rename) degrades to STRUCTURAL = re-extract = current behaviour, never wrong.
31
+ """
32
+
33
+ import hashlib
34
+ import os
35
+ import re
36
+
37
+
38
+ # ── Comment stripping (language-aware, single regex each) ────────────────────
39
+ # Block comments /* ... */ and HTML comments <!-- ... --> span both JS-family
40
+ # (.ts/.vue/.tsx/.jsx) and SQL (-- / /* */). Line comments vary by language.
41
+
42
+ # /* block */ and <!-- block --> — shared by JS-family + SQL + Vue template
43
+ _RE_BLOCK = re.compile(r'/\*.*?\*/', re.DOTALL)
44
+ _RE_HTML_BLOCK = re.compile(r'<!--.*?-->', re.DOTALL)
45
+
46
+ # // line comment — TS/JS/Vue script + Java. Robust against URL-ish tokens
47
+ # (only strips when // is at start of a token, not inside a string — see below).
48
+ _RE_LINE_SLASH = re.compile(r'(^|[\s;{}()\[\],])//[^\n]*')
49
+
50
+ # # line comment — Python / shell / YAML / .properties
51
+ _RE_LINE_HASH = re.compile(r'(^|[\s;{}()\[\],])#[^\n]*')
52
+
53
+ # -- line comment — SQL / XML DTD
54
+ _RE_LINE_DASH = re.compile(r'(^|[\s;{}()\[\],])--[^\n]*')
55
+
56
+
57
+ def strip_comments(content, suffix):
58
+ """Remove comments from source text by file suffix.
59
+
60
+ Args:
61
+ content: source text (str)
62
+ suffix: file suffix lowercased with dot, e.g. '.vue', '.ts', '.java'
63
+
64
+ Returns:
65
+ Text with comments removed. Block comments always stripped for
66
+ JS-family/SQL; line-comment style picked by language.
67
+ """
68
+ s = content
69
+ # Block comments: JS-family + SQL share /* */; Vue/HTML share <!-- -->.
70
+ if suffix in ('.ts', '.tsx', '.js', '.jsx', '.vue', '.java', '.sql'):
71
+ s = _RE_BLOCK.sub(' ', s)
72
+ if suffix in ('.vue', '.html', '.xml'):
73
+ s = _RE_HTML_BLOCK.sub(' ', s)
74
+ # Line comments by language family.
75
+ if suffix in ('.ts', '.tsx', '.js', '.jsx', '.vue', '.java'):
76
+ s = _RE_LINE_SLASH.sub(r'\1', s)
77
+ elif suffix in ('.py', '.sh', '.yml', '.yaml', '.properties', '.cfg', '.ini'):
78
+ s = _RE_LINE_HASH.sub(r'\1', s)
79
+ elif suffix == '.sql':
80
+ s = _RE_LINE_DASH.sub(r'\1', s)
81
+ return s
82
+
83
+
84
+ def _strip_all_whitespace(s):
85
+ """Remove every whitespace char so formatting/indentation never affects hash."""
86
+ return re.sub(r'\s+', '', s)
87
+
88
+
89
+ def compute_hashes(filepath):
90
+ """Compute (content_hash, structural_hash) for a file.
91
+
92
+ Args:
93
+ filepath: absolute or relative path to the source file.
94
+
95
+ Returns:
96
+ (content_hash, structural_hash) — both hex sha256, or (None, None) if
97
+ the file can't be read.
98
+ """
99
+ try:
100
+ with open(filepath, 'rb') as f:
101
+ raw = f.read()
102
+ except Exception:
103
+ return (None, None)
104
+ content_hash = hashlib.sha256(raw).hexdigest()
105
+
106
+ suffix = os.path.splitext(filepath)[1].lower()
107
+ try:
108
+ text = raw.decode('utf-8', errors='replace')
109
+ except Exception:
110
+ text = ''
111
+ stripped = strip_comments(text, suffix)
112
+ structural = _strip_all_whitespace(stripped)
113
+ structural_hash = hashlib.sha256(structural.encode('utf-8')).hexdigest()
114
+ return (content_hash, structural_hash)
115
+
116
+
117
+ def classify(old_sig, new_sig):
118
+ """Compare old vs new (content_hash, structural_hash) → change level.
119
+
120
+ Args:
121
+ old_sig: dict {content_hash, structural_hash}, or tuple of the two,
122
+ or None (never seen → STRUCTURAL).
123
+ new_sig: (content_hash, structural_hash) tuple from compute_hashes.
124
+
125
+ Returns:
126
+ 'NONE' | 'COSMETIC' | 'STRUCTURAL'.
127
+ Never-seen files (old_sig is None) → 'STRUCTURAL' (safe default: extract).
128
+ """
129
+ if old_sig is None:
130
+ return 'STRUCTURAL'
131
+ # Accept both dict (from get_file_signature) and tuple (from compute_hashes)
132
+ # so callers don't have to convert.
133
+ if isinstance(old_sig, dict):
134
+ old_content = old_sig.get('content_hash')
135
+ old_struct = old_sig.get('structural_hash')
136
+ else:
137
+ old_content, old_struct = old_sig[0], old_sig[1]
138
+ new_content, new_struct = new_sig
139
+ if old_content == new_content:
140
+ return 'NONE'
141
+ if old_struct == new_struct:
142
+ return 'COSMETIC'
143
+ return 'STRUCTURAL'
144
+
145
+
146
+ # ── Suffixes the incremental builder cares about ─────────────────────────────
147
+ # Keep in sync with kg_incremental.py's filter: ('.vue', '.ts', '.java').
148
+ TRACKED_SUFFIXES = ('.vue', '.ts', '.java')
149
+
150
+
151
+ if __name__ == '__main__':
152
+ # Quick self-test: same code + extra comment → COSMETIC; changed code → STRUCTURAL.
153
+ import tempfile
154
+ tmp = tempfile.NamedTemporaryFile(suffix='.ts', delete=False, mode='w', encoding='utf-8')
155
+ tmp.write('export function add(a, b) {\n return a + b\n}\n')
156
+ tmp.close()
157
+ h1 = compute_hashes(tmp.name)
158
+ print('original:', h1)
159
+
160
+ with open(tmp.name, 'w', encoding='utf-8') as f:
161
+ f.write('// leading comment\nexport function add(a, b) {\n return a + b\n}\n')
162
+ h2 = compute_hashes(tmp.name)
163
+ print('comment-only change:', h2, '→', classify(h1, h2))
164
+
165
+ with open(tmp.name, 'w', encoding='utf-8') as f:
166
+ f.write('export function add(a, b, c) {\n return a + b + c\n}\n')
167
+ h3 = compute_hashes(tmp.name)
168
+ print('structural change:', h3, '→', classify(h1, h3))
169
+ os.unlink(tmp.name)
@@ -65,6 +65,117 @@ def _find_calls_in_function_ast(func_node, source_bytes):
65
65
  return calls
66
66
 
67
67
 
68
+ # ── P1: calls / imports 边抽取 ────────────────────────────────────────────
69
+ # 把 tree-sitter 已能识别的 call_expression 和 import 语句导出成图边,
70
+ # 让知识图谱从"按钮→接口→控制器"单条业务链升级为带函数级调用的代码图。
71
+ # 返回结构供 kg_build / kg_incremental 写成 entities(FUNCTION) + edges(calls/imports)。
72
+
73
+ # 函数定义节点类型 (TS/JS): 命名函数 / const fn = () => {} / 类方法
74
+ _FN_NODE_TYPES = (
75
+ 'function_declaration', # function foo() {}
76
+ 'method_definition', # class { foo() {} }
77
+ # lexical_declaration 内的 arrow_function 由下面单独处理 (export const fn = () => {})
78
+ )
79
+
80
+
81
+ def _fn_name_from_node(node, source_bytes):
82
+ """Extract a function's name from a definition node, or None."""
83
+ if node.type == 'function_declaration':
84
+ name_node = node.child_by_field_name('name')
85
+ return _node_text(name_node, source_bytes) if name_node else None
86
+ if node.type == 'method_definition':
87
+ name_node = node.child_by_field_name('name')
88
+ return _node_text(name_node, source_bytes) if name_node else None
89
+ if node.type == 'lexical_declaration':
90
+ # export const fnName = (...) => { ... } / const fnName = function() {}
91
+ for child in node.children:
92
+ if child.type == 'variable_declarator':
93
+ name_node = child.child_by_field_name('name')
94
+ value = child.child_by_field_name('value')
95
+ if name_node and value and value.type in ('arrow_function', 'function_expression'):
96
+ return _node_text(name_node, source_bytes)
97
+ return None
98
+
99
+
100
+ def ts_extract_calls(content):
101
+ """Extract function-level call edges from TS/JS/Vue-script source.
102
+
103
+ Walks every function definition in the AST, then reuses
104
+ _find_calls_in_function_ast to collect each function's callees.
105
+
106
+ Returns:
107
+ [(from_fn, callee), ...] — caller function name and callee text.
108
+ Callee text may be qualified (e.g. 'obj.method', 'useStore()');
109
+ callers choose how to resolve. Tree-sitter unavailable → [] .
110
+ """
111
+ if not _TS_AVAILABLE or not content:
112
+ return []
113
+ try:
114
+ source = content.encode('utf-8')
115
+ tree = _PARSER.parse(source)
116
+ except Exception:
117
+ return []
118
+
119
+ edges = []
120
+ # Collect every function definition (named + arrow + method).
121
+ fns = []
122
+
123
+ def collect_fns(node):
124
+ if node.type in _FN_NODE_TYPES:
125
+ fns.append(node)
126
+ # lexical_declaration may hold `const fn = () => {}` — handled in _fn_name_from_node
127
+ if node.type == 'lexical_declaration':
128
+ fns.append(node)
129
+ for child in node.children:
130
+ collect_fns(child)
131
+
132
+ collect_fns(tree.root_node)
133
+
134
+ for fn_node in fns:
135
+ caller = _fn_name_from_node(fn_node, source)
136
+ if not caller:
137
+ continue
138
+ for callee, _full in _find_calls_in_function_ast(fn_node, source):
139
+ # Skip trivial self-references and empty callees.
140
+ callee = callee.strip()
141
+ if callee and callee != caller:
142
+ edges.append((caller, callee))
143
+ return edges
144
+
145
+
146
+ def ts_extract_imports(content):
147
+ """Extract import edges from TS/JS source (local module imports only).
148
+
149
+ Returns:
150
+ [(imported_name, source_path), ...] for local imports (relative paths
151
+ or @/ #/ alias). Third-party bare specifiers (e.g. 'vue', 'axios') are
152
+ filtered out — they aren't part of the project graph.
153
+ """
154
+ if not content:
155
+ return []
156
+ out = []
157
+ # Generic local import: import { a, b } from './x' | import x from '@/y' | import * as z from '../z'
158
+ for m in re.finditer(
159
+ r"import\s+(?:\{([^}]*)\}|\*\s+as\s+(\w+)|(\w+))\s*(?:,\s*\{([^}]*)\})?\s+from\s+['\"]([^'\"]+)['\"]",
160
+ content,
161
+ ):
162
+ names_group = m.group(1) or m.group(4) # named imports {a, b}
163
+ default_name = m.group(2) or m.group(3) # * as z or default x
164
+ src = (m.group(5) or '').strip()
165
+ # Only local imports (relative or alias); skip bare packages like 'vue'.
166
+ if not src or not (src.startswith('.') or src.startswith('@/') or src.startswith('#/')):
167
+ continue
168
+ names = []
169
+ if names_group:
170
+ names.extend(n.strip().split(' as ')[-1].strip() for n in names_group.split(',') if n.strip())
171
+ if default_name:
172
+ names.append(default_name.strip())
173
+ for name in names:
174
+ if name:
175
+ out.append((name, src))
176
+ return out
177
+
178
+
68
179
  def ts_build_api_fn_table(api_dir):
69
180
  """Tree-sitter version: build {fnName: {url, verb, file}} from api/*.ts.
70
181
 
@@ -29,6 +29,7 @@ Schema:
29
29
  import json
30
30
  import os
31
31
  import sys
32
+ import time
32
33
 
33
34
  import duckdb
34
35
 
@@ -102,6 +103,19 @@ def init_schema(con):
102
103
  value TEXT
103
104
  )
104
105
  """)
106
+ # ── 文件签名表 (P0: fingerprint 三级增量) ──
107
+ # 记录每个被索引文件的 content_hash (全文) 和 structural_hash (去注释去空白)。
108
+ # 增量构建前比对: NONE=完全没变; COSMETIC=只改注释/空白(跳过重算);
109
+ # STRUCTURAL=真改结构(正常重算)。图谱只记拓扑不记实现 → COSMETIC 跳过 100% 安全。
110
+ con.execute("""
111
+ CREATE TABLE IF NOT EXISTS file_signatures (
112
+ file_path TEXT PRIMARY KEY,
113
+ content_hash TEXT,
114
+ structural_hash TEXT,
115
+ level TEXT,
116
+ updated_at TEXT
117
+ )
118
+ """)
105
119
  # ── 搜索层表 ──
106
120
  con.execute("CREATE TABLE IF NOT EXISTS keywords(keyword TEXT, file_path TEXT)")
107
121
  con.execute("CREATE TABLE IF NOT EXISTS endpoints(endpoint TEXT PRIMARY KEY, controller_file TEXT)")
@@ -182,6 +196,8 @@ def init_schema(con):
182
196
  con.execute("CREATE INDEX IF NOT EXISTS idx_alias ON aliases(alias)")
183
197
  con.execute("CREATE INDEX IF NOT EXISTS idx_edge_from ON edges(from_id)")
184
198
  con.execute("CREATE INDEX IF NOT EXISTS idx_edge_to ON edges(to_id)")
199
+ # P1: 复合索引加速按类型的边查询 (calls/imports 边量大, multi_hop 按类型过滤)
200
+ con.execute("CREATE INDEX IF NOT EXISTS idx_edge_from_type ON edges(from_id, edge_type)")
185
201
  con.execute("CREATE INDEX IF NOT EXISTS idx_kw ON keywords(keyword)")
186
202
  con.execute("CREATE INDEX IF NOT EXISTS idx_page_type ON pages(page_type)")
187
203
  con.execute("CREATE INDEX IF NOT EXISTS idx_field ON fields(field)")
@@ -192,6 +208,7 @@ def init_schema(con):
192
208
  con.execute("CREATE INDEX IF NOT EXISTS idx_learn_dev ON learning_events(dev)")
193
209
  con.execute("CREATE INDEX IF NOT EXISTS idx_learn_pat_cat ON learning_patterns(category)")
194
210
  con.execute("CREATE INDEX IF NOT EXISTS idx_wf_mod ON workflows(module)")
211
+ con.execute("CREATE INDEX IF NOT EXISTS idx_fsig ON file_signatures(file_path)")
195
212
 
196
213
 
197
214
  def _sql_escape(val):
@@ -244,6 +261,32 @@ def delete_edges_from(con, from_id, edge_type=None):
244
261
  con.execute("DELETE FROM edges WHERE from_id = ?", [from_id])
245
262
 
246
263
 
264
+ def upsert_file_signature(con, file_path, content_hash, structural_hash, level):
265
+ """Insert or replace a file's signature (P0: fingerprint incremental).
266
+
267
+ Called after classifying a changed file; stored so the next incremental
268
+ run can compare and skip COSMETIC-only changes.
269
+ """
270
+ con.execute(
271
+ "INSERT OR REPLACE INTO file_signatures VALUES (?, ?, ?, ?, ?)",
272
+ [file_path, content_hash, structural_hash, level, str(int(time.time()))]
273
+ )
274
+
275
+
276
+ def get_file_signature(con, file_path):
277
+ """Read a file's stored signature, or None if never seen."""
278
+ row = con.execute(
279
+ "SELECT content_hash, structural_hash FROM file_signatures WHERE file_path = ?",
280
+ [file_path]
281
+ ).fetchone()
282
+ return {'content_hash': row[0], 'structural_hash': row[1]} if row else None
283
+
284
+
285
+ def clear_file_signatures(con):
286
+ """Wipe all signatures (full rebuild resets the baseline)."""
287
+ con.execute("DELETE FROM file_signatures")
288
+
289
+
247
290
  def resolve_entity(con, alias):
248
291
  """Resolve any alias to its canonical entity ID."""
249
292
  row = con.execute(
@@ -29,7 +29,7 @@ for _ in range(10):
29
29
  break
30
30
  if _cp not in _s.path: _s.path.insert(0, _cp)
31
31
  from bootstrap import setup; setup()
32
- from foundation.core.paths import get_repo_root
32
+ from foundation.core.paths import get_repo_root
33
33
 
34
34
  import os
35
35
  import sys
@@ -164,10 +164,43 @@ def cmd_promote(req_id, dev=None):
164
164
  return 0
165
165
 
166
166
 
167
- def cmd_publish(req_id, dev=None):
168
- """发布 = promote + 发禅道。PM 只用这个。"""
167
+ def _parse_prd_meta(prd_path):
168
+ """ PRD 提取发布所需元信息: 标题(首个#行) + spec(正文) + 可选 product/plan/build。
169
+
170
+ PRD 可在首行用 frontmatter 风格标注禅道目标:
171
+ <!-- zentao: product=1 plan=12 build=8 -->
172
+ 没标则返回 None, 由发布方补。
173
+ """
174
+ text = prd_path.read_text(encoding="utf-8")
175
+ meta = {"product_id": None, "plan_id": None, "build_id": None,
176
+ "title": "", "spec": ""}
177
+ for line in text.splitlines():
178
+ s = line.strip()
179
+ if s.startswith("<!--") and "zentao" in s:
180
+ for kv in s.replace("<!--", "").replace("-->", "").split():
181
+ if "=" in kv:
182
+ k, v = kv.split("=", 1)
183
+ kk = {"product": "product_id", "plan": "plan_id", "build": "build_id"}.get(k)
184
+ if kk and v.isdigit():
185
+ meta[kk] = int(v)
186
+ # 标题: 第一个非空 # 行; spec: 全文(截断到 8000 防 API 截断)
187
+ for line in text.splitlines():
188
+ if line.startswith("# ") and line[2:].strip():
189
+ meta["title"] = line[2:].strip()[:80]
190
+ break
191
+ meta["spec"] = text[:8000]
192
+ return meta
193
+
194
+
195
+ def cmd_publish(req_id, dev=None, product_id=None, plan_id=None, build_id=None, confirm=False):
196
+ """发布 = promote + 发禅道(建需求+关联计划+纳入版本)。
197
+
198
+ 默认 dry-run(只打印不写禅道); --confirm 才真发。
199
+ 产品/计划/版本: 优先用参数 > PRD里<!-- zentao: -->标注 > 缺则停在第该步问PM。
200
+ 用 ZentaoClient 直连 HTTP(不走 MCP), AI 只触发一次, 省 token。
201
+ """
169
202
  dev = dev or get_developer(BASE) or "unknown"
170
- # 先确保在 outputs (不在则 promote)
203
+ # 1. promote (草稿→产出)
171
204
  out_found = [e for e in find_req_dirs(req_id, dev, BASE) if e["area"] == "outputs"]
172
205
  if not out_found:
173
206
  print("草稿 → 产出...")
@@ -179,30 +212,92 @@ def cmd_publish(req_id, dev=None):
179
212
  print("✗ 发布失败: 产出区找不到 %s" % req_id)
180
213
  return 1
181
214
  out_path = out_found[0]["path"]
182
- print()
183
- print("发禅道 (从产出区: %s)" % out_path)
184
- print("-" * 40)
185
- # 找禅道 MCP 创建需求
186
- try:
187
- sys.path.insert(0, str(BASE / ".qoder" / "scripts" / "mcp"))
188
- # 禅道发布: 读 PRD, 调 zentao MCP 建 story
189
- prd_file = None
190
- for f in out_path.iterdir():
191
- if f.is_file() and (f.name.endswith(".md") and "prd" in f.name.lower()):
215
+
216
+ # 2. PRD
217
+ prd_file = None
218
+ for f in out_path.iterdir():
219
+ if f.is_file() and f.suffix == ".md" and not f.name.startswith("_"):
220
+ if "prd" in f.name.lower() or prd_file is None:
192
221
  prd_file = f
193
- break
194
- if f.is_file() and f.suffix == ".md" and not f.name.startswith("_"):
195
- prd_file = f # 第一个 md 当 PRD
196
- if prd_file:
197
- print(" PRD: %s" % prd_file.name)
198
- print()
199
- print("⚠️ 禅道发布需要 QoderWork 的禅道 MCP (mcp__qoder-zentao__create_story)。")
200
- print(" AI 会读产出的 PRD, 调禅道建需求。")
201
- print(" 如果你在 QoderWork 里说'%s 发布', AI 会自动完成这步。" % req_id)
222
+ if not prd_file:
223
+ print("✗ 发布失败: 产出区没有 .md (PRD)。")
224
+ return 1
225
+ meta = _parse_prd_meta(prd_file)
226
+ pid = product_id or meta["product_id"]
227
+ plan = plan_id or meta["plan_id"]
228
+ build = build_id or meta["build_id"]
229
+
230
+ mode = "★真发" if confirm else "DRY-RUN(只预览不写禅道, --confirm 真发)"
231
+ print("发禅道 [%s] 需求: %s PRD: %s" % (mode, req_id, prd_file.name))
232
+ print("-" * 50)
233
+ print(" 标题: %s" % meta["title"] or "(未提取到#标题)")
234
+ print(" 产品: %s" % (pid if pid else "⚠️ 未指定 — 用 --product=N 或 PRD标 <!-- zentao: product=N -->"))
235
+
236
+ if not pid:
237
+ print("\n✗ 缺产品ID, 已停(禅道需求必须挂某产品)。")
238
+ print(" 补: python req.py %s 发布 --product=N [--plan=M --build=K]" % req_id)
239
+ return 2
240
+ if not confirm:
241
+ print("\n [预览] 将: 建 story(product=%s) → %s%s" % (
242
+ pid,
243
+ "关联计划#%s → " % plan if plan else "",
244
+ "纳入版本#%s" % build if build else ""))
245
+ print(" 真发加 --confirm")
202
246
  return 0
247
+
248
+ # 3. 真发: 直连禅道 HTTP
249
+ try:
250
+ from foundation.integrations.zentao_client import ZentaoClient
203
251
  except Exception as e:
204
- print("✗ 发布出错: %s" % e)
205
- return 1
252
+ print("✗ 加载禅道客户端失败: %s" % e)
253
+ return 3
254
+ client = ZentaoClient.from_config()
255
+ if client is None:
256
+ print("✗ 禅道未配置(凭据/内网)。先 /wl-init 或检查 ~/.qoderwork/.secrets/zentao.env")
257
+ return 3
258
+ ok, msg = client.check_intranet()
259
+ if not ok:
260
+ print("✗ 禅道不可达: %s" % msg)
261
+ return 3
262
+
263
+ # 3a. 建 story
264
+ payload = {"title": meta["title"] or req_id, "spec": meta["spec"], "pri": 3, "category": "feature"}
265
+ r = client.api("POST", "/products/%s/stories" % pid, payload)
266
+ if r.status_code not in (200, 201):
267
+ print("✗ 建需求失败: %s %s" % (r.status_code, r.text[:120]))
268
+ return 3
269
+ story_id = r.json().get("id")
270
+ if not story_id:
271
+ print("✗ 建需求异常(无id): %s" % str(r.json())[:150])
272
+ return 3
273
+ print(" ✅ 需求已建 #%s" % story_id)
274
+
275
+ # 3b. 关联计划 (可选)
276
+ if plan:
277
+ r2 = client.api("POST", "/plans/%s/linkStory" % plan, {"stories": [story_id]})
278
+ if r2.status_code in (200, 201):
279
+ print(" ✅ 已关联计划 #%s" % plan)
280
+ else:
281
+ print(" ⚠️ 关联计划#%s 失败: %s (不影响,可手动)" % (plan, r2.status_code))
282
+
283
+ # 3c. 纳入版本 (可选)
284
+ if build:
285
+ r3 = client.api("POST", "/builds/%s/linkStory" % build, {"stories": [story_id]})
286
+ if r3.status_code in (200, 201):
287
+ print(" ✅ 已纳入版本 #%s" % build)
288
+ else:
289
+ print(" ⚠️ 纳入版本#%s 失败: %s (不影响,可手动)" % (build, r3.status_code))
290
+
291
+ # 3d. 回写 story_id 到 PRD (留痕, 防重复建)
292
+ try:
293
+ with open(prd_file, "a", encoding="utf-8") as fh:
294
+ fh.write("\n\n<!-- zentao: product=%s story=%s plan=%s build=%s -->\n" % (
295
+ pid, story_id, plan or 0, build or 0))
296
+ except Exception:
297
+ pass
298
+
299
+ print("\n🎉 发布完成! 禅道需求 #%s" % story_id)
300
+ return 0
206
301
 
207
302
 
208
303
  def main():
@@ -211,10 +306,21 @@ def main():
211
306
  return cmd_list()
212
307
  req_id = args[0]
213
308
  action = args[1] if len(args) > 1 else "show"
214
- if action == "show":
309
+ # 发布支持 --product/--plan/--build/--confirm
310
+ if action in ("发布", "publish", "fabu"):
311
+ opts = {"product_id": None, "plan_id": None, "build_id": None, "confirm": False}
312
+ for a in args[2:]:
313
+ if a == "--confirm":
314
+ opts["confirm"] = True
315
+ elif a.startswith("--product="):
316
+ opts["product_id"] = int(a.split("=", 1)[1])
317
+ elif a.startswith("--plan="):
318
+ opts["plan_id"] = int(a.split("=", 1)[1])
319
+ elif a.startswith("--build="):
320
+ opts["build_id"] = int(a.split("=", 1)[1])
321
+ return cmd_publish(req_id, **opts)
322
+ elif action == "show":
215
323
  return cmd_show(req_id)
216
- elif action in ("发布", "publish", "fabu"):
217
- return cmd_publish(req_id)
218
324
  elif action == "promote":
219
325
  return cmd_promote(req_id)
220
326
  elif action == "list":