@hupan56/wlkj 2.2.1 → 2.2.3
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 +76 -6
- package/package.json +1 -1
- package/templates/qoder/scripts/common/reqid.py +55 -4
- package/templates/qoder/scripts/common/task_utils.py +46 -0
- package/templates/qoder/scripts/syncgate.py +7 -4
- package/templates/qoder/scripts/task.py +117 -63
- package/templates/qoder/scripts/team_sync.py +23 -3
package/bin/cli.js
CHANGED
|
@@ -337,6 +337,8 @@ function doHelp() {
|
|
|
337
337
|
console.log("=== 环境安装 (什么都没装时先跑这个) ===");
|
|
338
338
|
console.log(" npx @hupan56/wlkj install-env 检测+自动装 Node/Python/git");
|
|
339
339
|
console.log(" npx @hupan56/wlkj install-env --check 只检测不装");
|
|
340
|
+
console.log(" npx @hupan56/wlkj install-env --location D:\\wldev 指定安装目录");
|
|
341
|
+
console.log(" npx @hupan56/wlkj add-path D:\\wldev\\nodejs 把目录加到系统 PATH");
|
|
340
342
|
console.log("");
|
|
341
343
|
console.log("=== 一键安装 (装好环境后) ===");
|
|
342
344
|
console.log(" npx @hupan56/wlkj init [你的名字] 安装完整引擎 + 自动初始化");
|
|
@@ -364,9 +366,20 @@ function doHelp() {
|
|
|
364
366
|
console.log("");
|
|
365
367
|
}
|
|
366
368
|
|
|
367
|
-
function doInstallEnv(checkOnly = false) {
|
|
369
|
+
function doInstallEnv(checkOnly = false, location = null) {
|
|
368
370
|
const { execSync } = require("child_process");
|
|
371
|
+
const isWin = process.platform === "win32";
|
|
372
|
+
const isMac = process.platform === "darwin";
|
|
373
|
+
|
|
374
|
+
// 解析 --location(CLI 入口已提取,这里兜底)
|
|
369
375
|
console.log("\n=== 环境检测 ===\n");
|
|
376
|
+
if (location) {
|
|
377
|
+
console.log(` 安装位置: ${location}${isWin ? "" : " (仅 Windows winget 支持指定目录)"}`);
|
|
378
|
+
// Windows: 预建目录
|
|
379
|
+
if (isWin) {
|
|
380
|
+
try { fs.mkdirSync(location, { recursive: true }); } catch { /* */ }
|
|
381
|
+
}
|
|
382
|
+
}
|
|
370
383
|
|
|
371
384
|
// 检测函数
|
|
372
385
|
function check(cmd) {
|
|
@@ -377,15 +390,25 @@ function doInstallEnv(checkOnly = false) {
|
|
|
377
390
|
try { execSync(`where ${cmd}`, { encoding: "utf-8", timeout: 3000, stdio: "pipe" }); return true; }
|
|
378
391
|
catch { try { execSync(`which ${cmd}`, { encoding: "utf-8", timeout: 3000, stdio: "pipe" }); return true; } catch { return false; } }
|
|
379
392
|
}
|
|
393
|
+
// Windows: 把目录加到用户 PATH(幂等,已存在不重复加)
|
|
394
|
+
function addToPathWin(dir) {
|
|
395
|
+
if (!isWin) return false;
|
|
396
|
+
try {
|
|
397
|
+
// 用 setx 会截断超长 PATH,改用 PowerShell 读 User PATH 追加
|
|
398
|
+
const ps = `powershell -NoProfile -Command "$p=[Environment]::GetEnvironmentVariable('Path','User'); if($p -notlike '*{dir}*'){[Environment]::SetEnvironmentVariable('Path',$p.TrimEnd(';')+';{dir}','User')}"`.replace(/{dir}/g, dir.replace(/\\/g, "\\\\"));
|
|
399
|
+
execSync(ps, { stdio: "pipe", timeout: 15000 });
|
|
400
|
+
return true;
|
|
401
|
+
} catch (e) { console.log(` [WARN] 加 PATH 失败(可手动加): ${dir}`); return false; }
|
|
402
|
+
}
|
|
380
403
|
function install(name, wingetId, brewPkg) {
|
|
381
404
|
if (checkOnly) return false;
|
|
382
|
-
const isWin = process.platform === "win32";
|
|
383
|
-
const isMac = process.platform === "darwin";
|
|
384
405
|
try {
|
|
385
406
|
if (isWin && has("winget")) {
|
|
386
407
|
console.log(` 用 winget 装 ${name}...`);
|
|
387
|
-
|
|
388
|
-
|
|
408
|
+
// winget --location 不一定被安装包尊重,但先传上
|
|
409
|
+
let cmd = `winget install --id ${wingetId} -e --source winget --accept-source-agreements --accept-package-agreements`;
|
|
410
|
+
if (location) cmd += ` --location "${location}"`;
|
|
411
|
+
execSync(cmd, { stdio: "inherit", timeout: 300000 });
|
|
389
412
|
return true;
|
|
390
413
|
} else if (isMac && has("brew")) {
|
|
391
414
|
console.log(` 用 brew 装 ${name}...`);
|
|
@@ -435,6 +458,20 @@ function doInstallEnv(checkOnly = false) {
|
|
|
435
458
|
else { need.push("git (手动: https://git-scm.com, 可选)"); }
|
|
436
459
|
}
|
|
437
460
|
|
|
461
|
+
// --location 模式: 提示用户验证安装位置 + 手动加 PATH
|
|
462
|
+
// (winget 的 --location 对 Node/Python 不一定生效,需用户确认)
|
|
463
|
+
if (location && isWin) {
|
|
464
|
+
console.log(`\n--- 安装位置确认 ---`);
|
|
465
|
+
console.log(` 你指定了: ${location}`);
|
|
466
|
+
console.log(` 注意: winget 的 --location 对 Node.js/Python 不一定生效`);
|
|
467
|
+
console.log(` (取决于安装包是否支持自定义路径)`);
|
|
468
|
+
console.log(` 请检查实际装到哪了:`);
|
|
469
|
+
console.log(` 重新打开终端后跑: node --version python --version git --version`);
|
|
470
|
+
console.log(` 如果某软件没进 PATH, 手动把它加进去:`);
|
|
471
|
+
console.log(` 设置 → 系统 → 关于 → 高级系统设置 → 环境变量 → Path → 新建`);
|
|
472
|
+
console.log(` 或用命令: npx @hupan56/wlkj add-path "D:\\wldev\\nodejs"`);
|
|
473
|
+
}
|
|
474
|
+
|
|
438
475
|
console.log("\n=== 结果 ===");
|
|
439
476
|
if (need.length === 0) {
|
|
440
477
|
console.log(" 环境就绪! 下一步: npx @hupan56/wlkj init");
|
|
@@ -445,13 +482,46 @@ function doInstallEnv(checkOnly = false) {
|
|
|
445
482
|
}
|
|
446
483
|
}
|
|
447
484
|
|
|
485
|
+
// add-path: 把目录加到 Windows 用户 PATH(幂等)
|
|
486
|
+
function doAddPath(dir) {
|
|
487
|
+
if (!dir) { console.log("用法: npx @hupan56/wlkj add-path <目录>"); return; }
|
|
488
|
+
if (process.platform !== "win32") {
|
|
489
|
+
console.log("add-path 仅支持 Windows。Mac/Linux 请手动加到 ~/.zshrc 或 ~/.bashrc");
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
const abs = path.resolve(dir);
|
|
493
|
+
if (!fs.existsSync(abs)) {
|
|
494
|
+
console.log(`[警告] 目录不存在: ${abs}(仍会加到 PATH)`);
|
|
495
|
+
}
|
|
496
|
+
try {
|
|
497
|
+
const ps = `powershell -NoProfile -Command "$p=[Environment]::GetEnvironmentVariable('Path','User'); if($p -notlike '*${abs.replace(/\\/g,"\\\\")}*'){[Environment]::SetEnvironmentVariable('Path',$p.TrimEnd(';')+';${abs.replace(/\\/g,"\\\\")}','User'); Write-Output 'ADDED'} else { Write-Output 'EXISTS' }"`;
|
|
498
|
+
const out = execSync(ps, { encoding: "utf-8", timeout: 15000 }).trim();
|
|
499
|
+
console.log(` ${out === "ADDED" ? "已加入" : "已存在(无需重复加)"}: ${abs}`);
|
|
500
|
+
console.log(` ⚠ 需要重新打开终端才生效`);
|
|
501
|
+
} catch (e) {
|
|
502
|
+
console.log(` 失败: ${(e.message || "").slice(0, 100)}`);
|
|
503
|
+
console.log(` 手动加: 设置 → 系统 → 关于 → 高级系统设置 → 环境变量 → Path → 新建 → ${abs}`);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
448
507
|
const [,, cmd, ...rest] = process.argv;
|
|
449
508
|
const mapped = rest.map(a => a === "-p" ? "--priority" : a);
|
|
450
509
|
|
|
451
510
|
switch (cmd) {
|
|
452
511
|
case "init": doInit(rest[0]); break;
|
|
453
512
|
case "update": case "upgrade": doUpdate(); break;
|
|
454
|
-
case "install-env":
|
|
513
|
+
case "install-env": {
|
|
514
|
+
const checkOnly = rest.includes("--check");
|
|
515
|
+
// 提取 --location <dir> 或 --location=<dir>
|
|
516
|
+
let loc = null;
|
|
517
|
+
const li = rest.indexOf("--location");
|
|
518
|
+
if (li !== -1 && rest[li + 1]) loc = rest[li + 1];
|
|
519
|
+
const eq = rest.find(a => a.startsWith("--location="));
|
|
520
|
+
if (eq) loc = eq.slice("--location=".length);
|
|
521
|
+
doInstallEnv(checkOnly, loc);
|
|
522
|
+
break;
|
|
523
|
+
}
|
|
524
|
+
case "add-path": doAddPath(rest[0]); break;
|
|
455
525
|
case "task": process.stdout.write(py("task.py", mapped)); break;
|
|
456
526
|
case "status": doStatus(); break;
|
|
457
527
|
case "session": process.stdout.write(py("add_session.py", rest)); break;
|
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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]),
|
|
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
|
-
% (
|
|
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
|
# =============================================================================
|
|
@@ -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
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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,
|
|
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
|
-
|
|
638
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
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
|
-
|
|
696
|
-
|
|
697
|
-
|
|
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
|
-
#
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|