@hupan56/wlkj 2.2.2 → 2.2.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hupan56/wlkj",
3
- "version": "2.2.2",
3
+ "version": "2.2.4",
4
4
  "description": "AI Product R&D Workflow - PRD/Prototype/Search/Task/Report",
5
5
  "bin": {
6
6
  "wlkj": "bin/cli.js"
@@ -131,33 +131,46 @@ def validate_req_ids(prd_paths: List[str], repo_root: Union[str, Path]) -> None:
131
131
  (b) NNN 不能超过当前计数器 (防跳号/未来号; 但允许 NNN == 已分配的号,
132
132
  因为可能是修改已有 PRD)
133
133
  (c) 同一批无重复 ID
134
+ (d) 与全仓库已存在的 REQ 文件无撞号 (跨人离线分叉检测)
134
135
 
135
136
  注意: 允许 NNN <= 计数器, 因为可能是更新已有 PRD (合法)。
136
- 撞号只在"同一批出现两个相同 ID""NNN 超过计数器"时报错。
137
+ 撞号在"同一批出现两个相同 ID""NNN 超过计数器"、或"与全库已有同号文件冲突"时报错。
138
+ 例外: 同号但文件名完全相同 (修改自己已 push 的 PRD) 视为合法更新。
137
139
  """
138
140
  seen = {}
139
141
  errors = []
142
+ # 全库已有 REQ-ID (用于跨人撞号检测)
143
+ existing = scan_existing_req_ids(repo_root)
144
+
140
145
  for p in prd_paths:
141
146
  parsed = parse_req_id(p)
142
147
  if not parsed:
143
148
  errors.append("%s: 文件名无 REQ-{YYYY}-{NNN}" % os.path.basename(p))
144
149
  continue
145
150
  year, n = parsed
146
- # 同批重复检测
147
151
  key = (year, n)
152
+ basename = os.path.basename(p)
153
+ # 同批重复检测
148
154
  if key in seen:
149
155
  errors.append(
150
156
  "撞号: %s 和 %s 都是 REQ-%d-%03d"
151
- % (os.path.basename(seen[key]), os.path.basename(p), year, n)
157
+ % (os.path.basename(seen[key]), basename, year, n)
152
158
  )
153
159
  else:
154
160
  seen[key] = p
161
+ # 跨人/全库撞号检测: 该号已存在于仓库, 且不是同一个文件 (允许修改自己的)
162
+ if key in existing and os.path.basename(existing[key]) != basename:
163
+ errors.append(
164
+ "撞号: %s 与仓库已有的 %s 都是 REQ-%d-%03d "
165
+ "(两人离线各建了同号 PRD? 请改其中一个的编号)"
166
+ % (basename, os.path.basename(existing[key]), year, n)
167
+ )
155
168
  # 超界检测 (NNN 不能超过计数器, 防止跳号)
156
169
  max_n = current_counter(str(year), repo_root)
157
170
  if max_n > 0 and n > max_n:
158
171
  errors.append(
159
172
  "%s: REQ-%d-%03d 超过计数器最大值 %d (跳号?)"
160
- % (os.path.basename(p), year, n, max_n)
173
+ % (basename, year, n, max_n)
161
174
  )
162
175
  if errors:
163
176
  raise ReqIdError("[gate] 拒绝: REQ-ID 校验失败:\n " + "\n ".join(errors))
@@ -216,3 +229,41 @@ def migrate_from_existing(repo_root: Optional[Union[str, Path]] = None) -> dict:
216
229
  return counter
217
230
  finally:
218
231
  lock.release()
232
+
233
+
234
+ def scan_existing_req_ids(repo_root: Optional[Union[str, Path]] = None) -> dict:
235
+ """扫描全仓库已存在的 REQ-{YYYY}-{NNN} 文件, 返回 {(year, n): filepath} 字典。
236
+
237
+ 用于 push 门禁时的跨人撞号检测: 两个成员离线各建了 REQ-005,
238
+ 单看各自批次不重复, 但全库已有该号 → 撞号。
239
+ """
240
+ if repo_root is None:
241
+ repo_root = Path(get_repo_root())
242
+ else:
243
+ repo_root = Path(repo_root)
244
+
245
+ scan_dirs = [
246
+ repo_root / "data" / "docs" / "prd",
247
+ repo_root / "workspace" / "specs" / "prd",
248
+ ]
249
+ members_dir = repo_root / "workspace" / "members"
250
+ if members_dir.is_dir():
251
+ for m in members_dir.iterdir():
252
+ d = m / "drafts"
253
+ if d.is_dir():
254
+ scan_dirs.append(d)
255
+
256
+ existing = {}
257
+ for d in scan_dirs:
258
+ if not d.is_dir():
259
+ continue
260
+ try:
261
+ for f in d.iterdir():
262
+ if not f.is_file() or not f.name.lower().endswith(".md"):
263
+ continue
264
+ parsed = parse_req_id(f.name)
265
+ if parsed:
266
+ existing[parsed] = str(f)
267
+ except OSError:
268
+ continue
269
+ return existing
@@ -147,6 +147,52 @@ def write_task_json(task_dir: Path, data: dict) -> bool:
147
147
  return False
148
148
 
149
149
 
150
+ def modify_task_json(task_dir: Path, mutator, timeout: float = 15.0):
151
+ """在文件锁内 load → mutator(data) → write, 保证并发 read-modify-write 原子。
152
+
153
+ 解决两个成员同时改同一任务 (一个改 status, 一个改 due) 的丢更新问题。
154
+ 锁是每任务粒度 (task_dir/.task.lock), 不同任务互不阻塞。
155
+
156
+ Args:
157
+ task_dir: 任务目录。
158
+ mutator: 接收 data dict, 就地修改它 (返回值忽略)。
159
+ timeout: 获取锁的等待秒数。
160
+
161
+ Returns:
162
+ 成功 (修改并写入) 返回 True; 加载失败返回 False。
163
+ """
164
+ try:
165
+ from .filelock import FileLock, LockTimeoutError
166
+ except ImportError:
167
+ # filelock 不可用 → 降级为无锁 (保留旧行为, 不阻塞功能)
168
+ data = load_task_json(task_dir)
169
+ if data is None:
170
+ return False
171
+ mutator(data)
172
+ return write_task_json(task_dir, data)
173
+
174
+ lock_path = task_dir / ".task.lock"
175
+ lock = FileLock(str(lock_path), timeout=timeout, stale_seconds=300)
176
+ try:
177
+ lock.acquire()
178
+ except LockTimeoutError as e:
179
+ print(f"Warning: task lock busy ({task_dir.name}): {e}", file=sys.stderr)
180
+ # 降级: 不阻塞, 直接写 (好过完全失败)
181
+ data = load_task_json(task_dir)
182
+ if data is None:
183
+ return False
184
+ mutator(data)
185
+ return write_task_json(task_dir, data)
186
+ try:
187
+ data = load_task_json(task_dir)
188
+ if data is None:
189
+ return False
190
+ mutator(data)
191
+ return write_task_json(task_dir, data)
192
+ finally:
193
+ lock.release()
194
+
195
+
150
196
  # =============================================================================
151
197
  # ACL: 任务操作权限校验 (零信任)
152
198
  # =============================================================================
@@ -248,6 +294,11 @@ def run_task_hooks(
248
294
  except ImportError:
249
295
  # 没有 PyYAML, 用简单解析
250
296
  config = _simple_yaml_parse(config_path)
297
+ except Exception as e:
298
+ # config.yaml 语法错误等: 不阻塞任务操作, 只是跳过 hooks
299
+ # (用户可能手编 config.yaml 引入了语法错误, 不该让整个任务系统崩溃)
300
+ print(f"Warning: .qoder/config.yaml 解析失败, 跳过 hooks ({type(e).__name__})", file=sys.stderr)
301
+ return
251
302
 
252
303
  if not config:
253
304
  return
@@ -324,7 +324,10 @@ def run_gates(
324
324
  except GateFailure as e:
325
325
  return False, str(e)
326
326
  except Exception as e:
327
- # ReqIdError 等也包装成 GateFailure 风格
328
- if 'REQ-ID' in str(e) or '撞号' in str(e):
329
- return False, str(e)
330
- raise
327
+ # ReqIdError / AtomicIOError 等也包装成 GateFailure 风格, 不让原始
328
+ # traceback 冒泡到产品经理眼前
329
+ msg = str(e)
330
+ if 'REQ-ID' in msg or '撞号' in msg or 'REQ' in msg:
331
+ return False, msg
332
+ # 其他意外异常: 返回友好错误而非崩溃栈
333
+ return False, '[gate] 门禁检查遇到意外错误 (非门禁失败, 请重试或检查环境): ' + msg[:200]
@@ -57,6 +57,7 @@ from common.task_utils import (
57
57
  run_task_hooks,
58
58
  load_task_json,
59
59
  write_task_json,
60
+ modify_task_json,
60
61
  assert_can_modify_task,
61
62
  )
62
63
 
@@ -222,18 +223,17 @@ def cmd_start(args: argparse.Namespace) -> int:
222
223
  print(f"Current task set to: {task_dir}")
223
224
  print(f"Source: {active.source}")
224
225
 
225
- # 更新 task.json 状态
226
- task_data = load_task_json(full_path)
227
- if task_data and task_data.get("status") == "planning":
228
- now_iso = datetime.now().isoformat()
229
- task_data["status"] = "in_progress"
230
- task_data["updated_at"] = now_iso
231
- # B3: 记录阶段时间戳
232
- stage_ts = task_data.get("stage_ts") or {}
233
- stage_ts["started"] = now_iso
234
- task_data["stage_ts"] = stage_ts
235
- write_task_json(full_path, task_data)
236
- print("Status: planning -> in_progress")
226
+ # 更新 task.json 状态 (锁内 load-modify-write, 防并发丢更新)
227
+ def _start_mutator(data):
228
+ if data.get("status") == "planning":
229
+ now_iso = datetime.now().isoformat()
230
+ data["status"] = "in_progress"
231
+ data["updated_at"] = now_iso
232
+ stage_ts = data.get("stage_ts") or {}
233
+ stage_ts["started"] = now_iso
234
+ data["stage_ts"] = stage_ts
235
+ print("Status: planning -> in_progress")
236
+ modify_task_json(full_path, _start_mutator)
237
237
 
238
238
  # 运行 after_start 钩子
239
239
  run_task_hooks("after_start", full_path / FILE_TASK_JSON, repo_root)
@@ -293,33 +293,37 @@ def cmd_finish(args: argparse.Namespace) -> int:
293
293
  # 已清了 active 指针, 但不改 status —— 让用户重新 start 再由正确的人 finish
294
294
  return 4 # authz_denied
295
295
 
296
- task_data = load_task_json(task_dir)
297
- if task_data and task_data.get("status") != "completed":
298
- now_iso = datetime.now().isoformat()
299
- task_data["status"] = "completed"
300
- task_data["updated_at"] = now_iso
301
- # B3: 记录完成时间戳
302
- stage_ts = task_data.get("stage_ts") or {}
303
- stage_ts["completed"] = now_iso
304
- task_data["stage_ts"] = stage_ts
305
- write_task_json(task_dir, task_data)
306
- print("Status: -> completed")
307
-
308
- # D2: 飞书通知 (任务完成)
296
+ # 锁内 load-modify-write, 防并发丢更新; 副作用 (飞书) 在锁外做
297
+ _finish_ctx = {}
298
+ def _finish_mutator(data):
299
+ if data.get("status") != "completed":
300
+ now_iso = datetime.now().isoformat()
301
+ data["status"] = "completed"
302
+ data["updated_at"] = now_iso
303
+ stage_ts = data.get("stage_ts") or {}
304
+ stage_ts["completed"] = now_iso
305
+ data["stage_ts"] = stage_ts
306
+ print("Status: -> completed")
307
+ _finish_ctx["now_iso"] = now_iso
308
+ _finish_ctx["stage_ts"] = stage_ts
309
+ _finish_ctx["assignee"] = data.get("assignee", "?")
310
+ modify_task_json(task_dir, _finish_mutator)
311
+
312
+ # D2: 飞书通知 (任务完成) —— 在锁外, 用 mutator 捕获的值
313
+ if _finish_ctx:
309
314
  try:
310
315
  from common.feishu import notify_task_finished
311
- # 算用时
312
316
  hours = None
313
- started = stage_ts.get("started")
317
+ started = _finish_ctx["stage_ts"].get("started")
314
318
  if started:
315
319
  try:
316
320
  from datetime import datetime as _dt
317
321
  t_start = _dt.fromisoformat(started)
318
- t_end = _dt.fromisoformat(now_iso)
322
+ t_end = _dt.fromisoformat(_finish_ctx["now_iso"])
319
323
  hours = (t_end - t_start).total_seconds() / 3600
320
324
  except (ValueError, TypeError):
321
325
  pass
322
- notify_task_finished(task_dir.name, task_data.get("assignee", "?"), hours)
326
+ notify_task_finished(task_dir.name, _finish_ctx["assignee"], hours)
323
327
  except Exception:
324
328
  pass # 飞书失败不阻塞任务完成
325
329
 
@@ -330,6 +334,44 @@ def cmd_finish(args: argparse.Namespace) -> int:
330
334
  return 0
331
335
 
332
336
 
337
+ def _cleanup_orphaned_refs(archived_task_name: str, repo_root) -> int:
338
+ """归档任务后, 清理其它任务中对它的引用 (blocked_by/blocks/children/parent)。
339
+
340
+ 不清理 → 僵尸任务永远 blocked, resolve_task_dir 找不到引用对象。
341
+ 返回清理的引用数。
342
+ """
343
+ tasks_dir = get_tasks_dir(repo_root)
344
+ if not tasks_dir.is_dir():
345
+ return 0
346
+ cleaned = 0
347
+ for task_subdir in tasks_dir.iterdir():
348
+ if not task_subdir.is_dir() or task_subdir.name == archived_task_name:
349
+ continue
350
+ task_json = task_subdir / FILE_TASK_JSON
351
+ if not task_json.is_file():
352
+ continue
353
+ changed = False
354
+
355
+ def _purge_refs(data):
356
+ nonlocal changed
357
+ for field in ("blocked_by", "blocks", "children"):
358
+ lst = data.get(field)
359
+ if isinstance(lst, list) and archived_task_name in lst:
360
+ data[field] = [x for x in lst if x != archived_task_name]
361
+ changed = True
362
+ # parent 是单值字段
363
+ if data.get("parent") == archived_task_name:
364
+ data["parent"] = None
365
+ changed = True
366
+ if changed:
367
+ data["updated_at"] = datetime.now().isoformat()
368
+
369
+ modify_task_json(task_subdir, _purge_refs)
370
+ if changed:
371
+ cleaned += 1
372
+ return cleaned
373
+
374
+
333
375
  # =============================================================================
334
376
  # 命令: archive
335
377
  # =============================================================================
@@ -379,12 +421,18 @@ def cmd_archive(args: argparse.Namespace) -> int:
379
421
  # 运行 after_archive 钩子
380
422
  run_task_hooks("after_archive", dest / FILE_TASK_JSON, repo_root)
381
423
 
424
+ # 清理孤儿依赖: 其它任务可能引用了本任务 (blocked_by/blocks/children/parent),
425
+ # 归档后这些引用变成死链 → 僵尸任务永远 blocked。扫描并移除。
426
+ cleaned = _cleanup_orphaned_refs(full_path.name, repo_root)
427
+
382
428
  # 清除可能指向此任务的会话
383
429
  active = resolve_active_task(repo_root)
384
430
  if active.task_path and task_input in active.task_path:
385
431
  clear_active_task(repo_root)
386
432
 
387
433
  print(f"Task archived: {full_path.name} -> archive/{year_month}/{full_path.name}")
434
+ if cleaned:
435
+ print(f" (已清理 {cleaned} 个其它任务中对它的依赖引用)")
388
436
  return 0
389
437
 
390
438
 
@@ -634,13 +682,12 @@ def cmd_set_due(args: argparse.Namespace) -> int:
634
682
  except PermissionError as e:
635
683
  print(str(e), file=sys.stderr)
636
684
  return 4
637
- task_data = load_task_json(full_path)
638
- if not task_data:
685
+ def _set_due_mutator(data):
686
+ data["due_date"] = args.due_date
687
+ data["updated_at"] = datetime.now().isoformat()
688
+ if not modify_task_json(full_path, _set_due_mutator):
639
689
  print("Error: Missing task.json", file=sys.stderr)
640
690
  return 1
641
- task_data["due_date"] = args.due_date
642
- task_data["updated_at"] = datetime.now().isoformat()
643
- write_task_json(full_path, task_data)
644
691
  print(f"Set due_date: {args.due_date} ({full_path.name})")
645
692
  return 0
646
693
 
@@ -672,29 +719,32 @@ def cmd_block(args: argparse.Namespace) -> int:
672
719
  file=sys.stderr)
673
720
  return 1
674
721
 
675
- # 更新 task.blocked_by
722
+ # 检查是否已存在依赖关系 (先读, 幂等)
676
723
  task_data = load_task_json(task_dir)
677
724
  if not task_data:
678
725
  print("Error: Missing task.json", file=sys.stderr)
679
726
  return 1
680
- blocked_by = task_data.get("blocked_by") or []
681
- if blocker_dir.name in blocked_by:
727
+ if blocker_dir.name in (task_data.get("blocked_by") or []):
682
728
  print(f"Already blocked by {blocker_dir.name}")
683
729
  return 0
684
- blocked_by.append(blocker_dir.name)
685
- task_data["blocked_by"] = blocked_by
686
- task_data["updated_at"] = datetime.now().isoformat()
687
- write_task_json(task_dir, task_data)
688
730
 
689
- # 反向更新 blocker.blocks
690
- blocker_data = load_task_json(blocker_dir)
691
- if blocker_data:
692
- blocks = blocker_data.get("blocks") or []
731
+ # 锁内更新 task.blocked_by
732
+ def _block_mutator(data):
733
+ blocked_by = data.get("blocked_by") or []
734
+ if blocker_dir.name not in blocked_by:
735
+ blocked_by.append(blocker_dir.name)
736
+ data["blocked_by"] = blocked_by
737
+ data["updated_at"] = datetime.now().isoformat()
738
+ modify_task_json(task_dir, _block_mutator)
739
+
740
+ # 锁内反向更新 blocker.blocks
741
+ def _block_reverse_mutator(data):
742
+ blocks = data.get("blocks") or []
693
743
  if task_dir.name not in blocks:
694
744
  blocks.append(task_dir.name)
695
- blocker_data["blocks"] = blocks
696
- blocker_data["updated_at"] = datetime.now().isoformat()
697
- write_task_json(blocker_dir, blocker_data)
745
+ data["blocks"] = blocks
746
+ data["updated_at"] = datetime.now().isoformat()
747
+ modify_task_json(blocker_dir, _block_reverse_mutator)
698
748
 
699
749
  print(f"{task_dir.name} now blocked by {blocker_dir.name}")
700
750
  return 0
@@ -713,21 +763,25 @@ def cmd_unblock(args: argparse.Namespace) -> int:
713
763
  return 4
714
764
 
715
765
  removed = False
716
- # task.blocked_by 删
717
- task_data = load_task_json(task_dir) if task_dir.is_dir() else None
718
- if task_data and blocker_dir.name in (task_data.get("blocked_by") or []):
719
- task_data["blocked_by"] = [x for x in task_data["blocked_by"] if x != blocker_dir.name]
720
- task_data["updated_at"] = datetime.now().isoformat()
721
- write_task_json(task_dir, task_data)
722
- removed = True
723
-
724
- # 从 blocker.blocks 删
725
- blocker_data = load_task_json(blocker_dir) if blocker_dir.is_dir() else None
726
- if blocker_data and task_dir.name in (blocker_data.get("blocks") or []):
727
- blocker_data["blocks"] = [x for x in blocker_data["blocks"] if x != task_dir.name]
728
- blocker_data["updated_at"] = datetime.now().isoformat()
729
- write_task_json(blocker_dir, blocker_data)
730
- removed = True
766
+ # 锁内从 task.blocked_by 删
767
+ if task_dir.is_dir():
768
+ def _unblock_mutator(data):
769
+ nonlocal removed
770
+ if blocker_dir.name in (data.get("blocked_by") or []):
771
+ data["blocked_by"] = [x for x in (data.get("blocked_by") or []) if x != blocker_dir.name]
772
+ data["updated_at"] = datetime.now().isoformat()
773
+ removed = True
774
+ modify_task_json(task_dir, _unblock_mutator)
775
+
776
+ # 锁内从 blocker.blocks
777
+ if blocker_dir.is_dir():
778
+ def _unblock_reverse_mutator(data):
779
+ nonlocal removed
780
+ if task_dir.name in (data.get("blocks") or []):
781
+ data["blocks"] = [x for x in (data.get("blocks") or []) if x != task_dir.name]
782
+ data["updated_at"] = datetime.now().isoformat()
783
+ removed = True
784
+ modify_task_json(blocker_dir, _unblock_reverse_mutator)
731
785
 
732
786
  if removed:
733
787
  print(f"Removed block: {task_dir.name} no longer blocked by {blocker_dir.name}")
@@ -136,9 +136,20 @@ def _notify_prd_publications(staged_files):
136
136
 
137
137
  def report_conflict(stderr):
138
138
  """rebase 冲突: 中止并输出结构化标记, 由会话中的 AI 接手解决"""
139
- git('rebase', '--abort')
139
+ if rebase_in_progress():
140
+ r = git('rebase', '--abort')
141
+ if r.returncode != 0:
142
+ print('WARNING: rebase --abort 失败 (rc={}): {}'.format(r.returncode, (r.stderr or '').strip()[:120]))
143
+ print('仓库可能处于半 rebase 状态。AI 请手动跑: git rebase --abort')
140
144
  print('SYNC_CONFLICT: 自动同步遇到冲突, 已安全回退 (本地产出未丢失)。')
141
- print('AI 请执行: git pull --rebase origin {} 并解决冲突后 git push。'.format(current_branch()))
145
+ branch = current_branch()
146
+ print('AI 请按以下步骤解决冲突:')
147
+ print(' 1. git status # 看哪些文件冲突')
148
+ print(' 2. git pull --rebase origin {} # 重新拉取'.format(branch))
149
+ print(' 3. 编辑冲突文件 (<<<<<<< 标记处), 保留双方改动')
150
+ print(' 4. git add <解决后的文件> # 标记已解决')
151
+ print(' 5. git rebase --continue # 继续 rebase')
152
+ print(' 6. git push # 推送')
142
153
  print('冲突详情: ' + stderr.strip()[:300])
143
154
  return 3
144
155
 
@@ -309,7 +320,10 @@ def _do_push_locked(message, dev, skip_eval, skip_secret):
309
320
  if not passed:
310
321
  print(reason)
311
322
  # 撤销 staging (用户修完再来)
312
- git('reset', 'HEAD', '--')
323
+ rr = git('reset', 'HEAD', '--')
324
+ if rr.returncode != 0:
325
+ print('WARNING: 门禁失败后 git reset 失败, staged 文件可能仍暂存!')
326
+ print(' 请手动跑: git reset HEAD --')
313
327
  return 1
314
328
 
315
329
  # 4. commit
@@ -347,7 +361,13 @@ def _do_push_locked(message, dev, skip_eval, skip_secret):
347
361
  if r.returncode != 0:
348
362
  if 'CONFLICT' in (r.stdout + r.stderr) or rebase_in_progress():
349
363
  return report_conflict(r.stderr or r.stdout)
364
+ # 非冲突的 pull 失败 (网络/auth/fetch 拒绝): 不往下 push (仓库可能处于
365
+ # 半 rebase 状态), 重试本循环
350
366
  print('Pull failed (attempt {}): {}'.format(attempt, (r.stderr or r.stdout).strip()[:150]))
367
+ if rebase_in_progress():
368
+ # 兜底: 万一 pull 留下了未完成的 rebase, abort 掉防仓库损坏
369
+ git('rebase', '--abort')
370
+ continue
351
371
 
352
372
  r = git('push', 'origin', branch)
353
373
  if r.returncode == 0: