336 lines
12 KiB
Python
336 lines
12 KiB
Python
#!/usr/bin/env python3
|
||
"""PLANNING 任务调度器(参考实现,Python 标准库,零依赖)。
|
||
|
||
用法:
|
||
python tasks.py list # 全部任务 + 状态
|
||
python tasks.py show C-01 # 任务卡摘要
|
||
python tasks.py dispatch C-01 # 产出可直接粘贴的派单提示词
|
||
python tasks.py dispatch C-01 --model deepseek-v4.1-flash
|
||
python tasks.py bundle C-01 # 交接包(卡 + 协议 + 项目上下文)
|
||
python tasks.py collect # 扫描各项目根 PROGRESS_*.md
|
||
python tasks.py status # 看板(里程碑分组)
|
||
python tasks.py start C-01 # 标记执行中
|
||
python tasks.py accept C-01 # 标记完成
|
||
python tasks.py block C-01 "原因" # 标记阻塞
|
||
python tasks.py reset C-01 # 状态还原为 todo
|
||
|
||
状态存储:manifest 为源 + state.json 覆盖层(本脚本只写 state.json)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import os
|
||
import re
|
||
import sys
|
||
import tempfile
|
||
from datetime import date
|
||
from pathlib import Path
|
||
|
||
ROOT = Path(__file__).resolve().parent
|
||
MANIFEST = ROOT / "task-manifest.json"
|
||
STATE = ROOT / "state.json"
|
||
PROTOCOL = ROOT / "03-执行协议.md"
|
||
|
||
STATUS_ICON = {
|
||
"todo": "○",
|
||
"doing": "◐",
|
||
"review": "◑",
|
||
"done": "●",
|
||
"blocked": "✗",
|
||
}
|
||
|
||
PROGRESS_RE = re.compile(r"PROGRESS[_-]([A-Z]-\d{2})\.md$", re.IGNORECASE)
|
||
STATUS_LINE_RE = re.compile(r"状态[::]\s*(done|partial|blocked|doing|review)", re.IGNORECASE)
|
||
|
||
|
||
# ---------------------------------------------------------------- 基础读写
|
||
|
||
def load_manifest() -> dict:
|
||
return json.loads(MANIFEST.read_text(encoding="utf-8"))
|
||
|
||
|
||
def load_state() -> dict:
|
||
if STATE.exists():
|
||
return json.loads(STATE.read_text(encoding="utf-8"))
|
||
return {}
|
||
|
||
|
||
def save_state(state: dict) -> None:
|
||
# 原子写:先落同目录临时文件再 os.replace,避免并发写或中断产生半截 state.json
|
||
payload = json.dumps(state, ensure_ascii=False, indent=2) + "\n"
|
||
fd, tmp = tempfile.mkstemp(dir=str(STATE.parent), prefix=".state-", suffix=".tmp")
|
||
try:
|
||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||
f.write(payload)
|
||
os.replace(tmp, STATE)
|
||
except BaseException:
|
||
try:
|
||
os.unlink(tmp)
|
||
except OSError:
|
||
pass
|
||
raise
|
||
|
||
|
||
def effective_status(task: dict, state: dict) -> str:
|
||
return state.get(task["id"], {}).get("status", task.get("status", "todo"))
|
||
|
||
|
||
def find_task(manifest: dict, tid: str) -> dict | None:
|
||
for t in manifest["tasks"]:
|
||
if t["id"].upper() == tid.upper():
|
||
return t
|
||
return None
|
||
|
||
|
||
def card_path(task: dict) -> Path:
|
||
return ROOT.parent / task["card"]
|
||
|
||
|
||
def project_root(manifest: dict, task: dict) -> Path:
|
||
return Path(manifest["project_roots"].get(task["project"], "?"))
|
||
|
||
|
||
# ---------------------------------------------------------------- 命令实现
|
||
|
||
def cmd_list(manifest: dict, state: dict, args) -> int:
|
||
tasks = manifest["tasks"]
|
||
if args.project:
|
||
tasks = [t for t in tasks if t["project"] == args.project]
|
||
print(f"{'ID':<5} {'状态':<4} {'优先级':<5} {'里程碑':<5} {'项目':<14} 标题")
|
||
print("-" * 78)
|
||
for t in tasks:
|
||
st = effective_status(t, state)
|
||
icon = STATUS_ICON.get(st, "?")
|
||
print(f"{t['id']:<5} {icon}{st:<3} {t['priority']:<5} {t['milestone']:<5} {t['project']:<14} {t['title']}")
|
||
n_todo = sum(1 for t in tasks if effective_status(t, state) == "todo")
|
||
print("-" * 78)
|
||
print(f"共 {len(tasks)} 项,待派 {n_todo} 项。图例:○待派 ◐执行中 ◑待验收 ●完成 ✗阻塞")
|
||
return 0
|
||
|
||
|
||
def cmd_show(manifest: dict, state: dict, args) -> int:
|
||
t = find_task(manifest, args.id)
|
||
if not t:
|
||
print(f"未找到任务 {args.id}")
|
||
return 1
|
||
p = card_path(t)
|
||
print(f"# {t['id']} {t['title']}")
|
||
print(f"项目={t['project']} 优先级={t['priority']} 里程碑={t['milestone']} "
|
||
f"状态={effective_status(t, state)} 预估={t['estimate_days']}天")
|
||
print(f"建议模型={t['model_primary']} 复核={t.get('model_review', '—')}")
|
||
print(f"依赖={t.get('depends_on') or '无'}")
|
||
print(f"卡文件={p}")
|
||
if p.exists():
|
||
print("-" * 78)
|
||
print(p.read_text(encoding="utf-8"))
|
||
else:
|
||
print("!! 卡文件缺失")
|
||
return 1
|
||
return 0
|
||
|
||
|
||
def _protocol_excerpt() -> str:
|
||
if not PROTOCOL.exists():
|
||
return "(协议文件缺失)"
|
||
text = PROTOCOL.read_text(encoding="utf-8")
|
||
# 取「红线」章节 + 接单流程;无章节则给全文前 60 行
|
||
out: list[str] = []
|
||
for block in re.split(r"\n(?=## )", text):
|
||
if block.startswith("## 一、接单流程") or block.startswith("## 三、质量红线"):
|
||
out.append(block.strip())
|
||
return "\n\n".join(out) if out else "\n".join(text.splitlines()[:60])
|
||
|
||
|
||
def cmd_dispatch(manifest: dict, state: dict, args) -> int:
|
||
t = find_task(manifest, args.id)
|
||
if not t:
|
||
print(f"未找到任务 {args.id}")
|
||
return 1
|
||
p = card_path(t)
|
||
if not p.exists():
|
||
print(f"卡文件缺失:{p}")
|
||
return 1
|
||
model = args.model or t["model_primary"]
|
||
root = project_root(manifest, t)
|
||
card = p.read_text(encoding="utf-8")
|
||
|
||
print(f"""你是执行模型 {model}。下面是你的任务卡与作业规范,按规范执行到验收全绿。
|
||
|
||
==================== 任务卡 ====================
|
||
{card}
|
||
==================== 作业规范(要点) ====================
|
||
{_protocol_excerpt()}
|
||
|
||
==================== 项目上下文 ====================
|
||
项目根:{root}
|
||
开工前:先读项目根 README / AGENTS 与最近 2 份 PROGRESS(或迭代报告),然后跑基线测试确认开工状态。
|
||
交付:代码 + 项目根 {t['progress_file']}(格式见协议)+ 关键命令的原始输出。
|
||
完成后请逐条对照任务卡「验收标准」自检,并在 PROGRESS 中逐条标注验证状态(未验证项必须写明)。""")
|
||
return 0
|
||
|
||
|
||
def cmd_bundle(manifest: dict, state: dict, args) -> int:
|
||
"""交接包:卡 + 协议 + 项目上下文(README 摘要 + 最近 PROGRESS 清单 + 源码目录树)。"""
|
||
t = find_task(manifest, args.id)
|
||
if not t:
|
||
print(f"未找到任务 {args.id}")
|
||
return 1
|
||
p = card_path(t)
|
||
root = project_root(manifest, t)
|
||
parts: list[str] = []
|
||
parts.append(f"# 交接包(Handoff Bundle)— {t['id']} {t['title']}")
|
||
parts.append(f"生成时间:{date.today().isoformat()} · 项目根:{root}")
|
||
|
||
parts.append("\n## 1. 任务卡全文\n")
|
||
parts.append(p.read_text(encoding="utf-8") if p.exists() else "(卡缺失)")
|
||
|
||
parts.append("\n## 2. 作业规范要点\n")
|
||
parts.append(_protocol_excerpt())
|
||
|
||
parts.append("\n## 3. 项目上下文\n")
|
||
if root.exists():
|
||
readme = next(
|
||
(root / n for n in ("README.md", "readme.md", "AGENTS.md") if (root / n).exists()),
|
||
None,
|
||
)
|
||
parts.append(f"### 3.1 README:{readme.name if readme else '(未找到)'}\n")
|
||
if readme:
|
||
text = readme.read_text(encoding="utf-8", errors="replace")
|
||
parts.append("\n".join(text.splitlines()[:80]))
|
||
|
||
prog = sorted(root.glob("PROGRESS*.md"), key=lambda f: f.stat().st_mtime, reverse=True)
|
||
parts.append(f"\n### 3.2 最近 PROGRESS({len(prog)} 份,取最近 2 份)\n")
|
||
for f in prog[:2]:
|
||
text = f.read_text(encoding="utf-8", errors="replace")
|
||
parts.append(f"#### {f.name}\n")
|
||
parts.append("\n".join(text.splitlines()[:60]))
|
||
parts.append("")
|
||
else:
|
||
parts.append(f"(项目根不存在:{root}——交接前请核对路径)")
|
||
|
||
print("\n".join(parts))
|
||
return 0
|
||
|
||
|
||
def cmd_collect(manifest: dict, state: dict, args) -> int:
|
||
print("扫描各项目根 PROGRESS 文件……\n")
|
||
found: dict[str, Path] = {}
|
||
for proj, root_s in manifest["project_roots"].items():
|
||
root = Path(root_s)
|
||
if not root.exists():
|
||
continue
|
||
for f in root.glob("PROGRESS*.md"):
|
||
m = PROGRESS_RE.search(f.name)
|
||
if m:
|
||
found[m.group(1).upper()] = f
|
||
# 也扫一层子目录(如 backend/、frontend/)
|
||
for f in root.glob("*/PROGRESS*.md"):
|
||
m = PROGRESS_RE.search(f.name)
|
||
if m and m.group(1).upper() not in found:
|
||
found[m.group(1).upper()] = f
|
||
|
||
if not found:
|
||
print("未发现任何 PROGRESS 文件(任务尚未开始执行)。")
|
||
return 0
|
||
|
||
print(f"{'ID':<5} {'PROGRESS 声明':<14} {'解析':<10} 文件")
|
||
print("-" * 78)
|
||
for tid in sorted(found):
|
||
f = found[tid]
|
||
text = f.read_text(encoding="utf-8", errors="replace")
|
||
m = STATUS_LINE_RE.search(text)
|
||
declared = m.group(1).lower() if m else "未识别"
|
||
cur = state.get(tid, {}).get("status", "todo")
|
||
print(f"{tid:<5} {declared:<14} {cur:<10} {f}")
|
||
print("-" * 78)
|
||
print("提示:PROGRESS 存在即表示模型已交付;请由规划者按任务卡「验收标准」核对后再 accept。")
|
||
return 0
|
||
|
||
|
||
def cmd_status(manifest: dict, state: dict, args) -> int:
|
||
by_ms: dict[str, list[dict]] = {}
|
||
for t in manifest["tasks"]:
|
||
by_ms.setdefault(t["milestone"], []).append(t)
|
||
total = len(manifest["tasks"])
|
||
done = sum(1 for t in manifest["tasks"] if effective_status(t, state) == "done")
|
||
doing = sum(1 for t in manifest["tasks"] if effective_status(t, state) == "doing")
|
||
print(f"== PLANNING 看板 == 总 {total} · 完成 {done} · 执行中 {doing}\n")
|
||
for ms in sorted(by_ms):
|
||
tasks = by_ms[ms]
|
||
d = sum(1 for t in tasks if effective_status(t, state) == "done")
|
||
print(f"[{ms}] {d}/{len(tasks)}")
|
||
for t in tasks:
|
||
st = effective_status(t, state)
|
||
print(f" {STATUS_ICON.get(st, '?')} {t['id']:<5} {t['title']}")
|
||
print()
|
||
return 0
|
||
|
||
|
||
def _set_status(tid: str, manifest: dict, state: dict, status: str, note: str = "") -> int:
|
||
t = find_task(manifest, tid)
|
||
if not t:
|
||
print(f"未找到任务 {tid}")
|
||
return 1
|
||
entry = state.get(t["id"], {})
|
||
entry["status"] = status
|
||
entry["updated"] = date.today().isoformat()
|
||
if note:
|
||
entry["note"] = note
|
||
state[t["id"]] = entry
|
||
save_state(state)
|
||
print(f"{t['id']} → {status}" + (f"({note})" if note else ""))
|
||
return 0
|
||
|
||
|
||
# ---------------------------------------------------------------- 入口
|
||
|
||
def main() -> int:
|
||
ap = argparse.ArgumentParser(description="PLANNING 任务调度器")
|
||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||
|
||
p = sub.add_parser("list", help="列出任务")
|
||
p.add_argument("--project", help="按项目过滤(chunyu/englishdrill/dealerhub/dsp/dsh)")
|
||
|
||
p = sub.add_parser("show", help="显示任务卡")
|
||
p.add_argument("id")
|
||
|
||
p = sub.add_parser("dispatch", help="产出派单提示词")
|
||
p.add_argument("id")
|
||
p.add_argument("--model", help="覆盖建议模型")
|
||
|
||
p = sub.add_parser("bundle", help="生成交接包")
|
||
p.add_argument("id")
|
||
|
||
sub.add_parser("collect", help="扫描 PROGRESS")
|
||
sub.add_parser("status", help="看板")
|
||
|
||
p = sub.add_parser("start", help="标记执行中")
|
||
p.add_argument("id")
|
||
p = sub.add_parser("accept", help="标记完成")
|
||
p.add_argument("id")
|
||
p = sub.add_parser("block", help="标记阻塞")
|
||
p.add_argument("id")
|
||
p.add_argument("reason", nargs="?", default="")
|
||
p = sub.add_parser("reset", help="还原为 todo")
|
||
p.add_argument("id")
|
||
|
||
args = ap.parse_args()
|
||
manifest = load_manifest()
|
||
state = load_state()
|
||
|
||
handlers = {
|
||
"list": cmd_list, "show": cmd_show, "dispatch": cmd_dispatch,
|
||
"bundle": cmd_bundle, "collect": cmd_collect, "status": cmd_status,
|
||
"start": lambda m, s, a: _set_status(a.id, m, s, "doing"),
|
||
"accept": lambda m, s, a: _set_status(a.id, m, s, "done"),
|
||
"block": lambda m, s, a: _set_status(a.id, m, s, "blocked", a.reason),
|
||
"reset": lambda m, s, a: _set_status(a.id, m, s, "todo"),
|
||
}
|
||
return handlers[args.cmd](manifest, state, args)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|