Files
vscode-workbench/PLANNING/tools/handoff_bundle.py
T

443 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Handoff Bundle 打包器 —— I-04 接力协议 §七 的实现(标准库,零依赖)。
把一个任务卡打成一个**可复制的自包含上下文包**,让接手模型不必再问路:
卡全文 + 协议要点 + 项目 README + 最近 2 份 PROGRESS
+ 相关源码清单(卡里点名过的文件,逐个核实存在性/大小/改动时间)
+ 基线测试命令与输出(默认只列命令;--run-baseline 才真跑)
用法:
python tools/handoff_bundle.py C-01 # 打到 stdout
python tools/handoff_bundle.py C-01 --out bundle.md # 落盘
python tools/handoff_bundle.py C-01 --run-baseline # 连基线输出一起抓
python tools/handoff_bundle.py --list # 可用任务 ID
设计约束:
- 只读:不修改项目任何文件,不执行 git 写操作。
- 基线命令默认不跑(全量测试套件可能跑很久);--run-baseline 才执行,带超时。
- 密钥零接触:不读 .env / (服务器数据) / ~/.dsh/secrets。
"""
from __future__ import annotations
import argparse
import json
import re
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1] # PLANNING/
WORKSPACE = ROOT.parent # vscode/
MANIFEST = ROOT / "task-manifest.json"
PROTOCOL = ROOT / "03-执行协议.md"
BASELINES = Path(__file__).resolve().parent / "project-baselines.json"
SECRET_BLOCK = re.compile(r"(\.env\b|服务器数据|secrets|\.credentials)", re.IGNORECASE)
PATH_TOKEN = re.compile(r"`([^`\n]+)`")
PATH_HINT = re.compile(r"(\.(py|ts|tsx|js|jsx|mjs|md|json|ya?ml|conf|ps1|sh|css|html|toml)$)"
r"|(^|/)(scripts?|tools?|config|src|tests?|docs?)/")
SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "venv",
".pnpm-store", ".npm-cache", ".next", "dist", "build", "android"}
# ------------------------------------------------------------------ 素材读取
def load_manifest() -> dict:
return json.loads(MANIFEST.read_text(encoding="utf-8"))
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 protocol_excerpt() -> str:
"""接单流程 + 质量红线 + 雷区(接手模型最需要的三段)。"""
if not PROTOCOL.exists():
return "(协议文件缺失)"
text = PROTOCOL.read_text(encoding="utf-8")
keep = ("## 一、接单流程", "## 二、PROGRESS", "## 三、质量红线", "## 四、各项目雷区")
blocks = [b.strip() for b in re.split(r"\n(?=## )", text) if b.startswith(keep)]
return "\n\n".join(blocks) if blocks else "\n".join(text.splitlines()[:80])
def read_head(path: Path, max_lines: int, max_bytes: int) -> str:
if not path.exists():
return "(文件不存在)"
text = path.read_text(encoding="utf-8", errors="replace")
lines = text.splitlines()
out = "\n".join(lines[:max_lines])
if len(out.encode("utf-8")) > max_bytes:
out = out.encode("utf-8")[:max_bytes].decode("utf-8", "ignore")
suffix = f"\n…(已截断:原文 {len(lines)} 行 / {len(text)} 字符)" \
if len(lines) > max_lines or len(text) > len(out) else ""
return out + suffix
def project_readme(root: Path) -> Path | None:
for name in ("README.md", "readme.md", "Readme.md", "AGENTS.md"):
p = root / name
if p.exists():
return p
return None
def recent_progress(root: Path, n: int = 2) -> list[Path]:
if not root.exists():
return []
found = list(root.glob("PROGRESS*.md")) + list(root.glob("*/PROGRESS*.md"))
uniq = {p.resolve(): p for p in found}
return sorted(uniq.values(), key=lambda f: f.stat().st_mtime, reverse=True)[:n]
# ------------------------------------------------ 相关源码清单(从卡里点名)
def extract_path_tokens(card_text: str) -> list[str]:
seen: list[str] = []
for m in PATH_TOKEN.finditer(card_text):
tok = m.group(1).strip()
if len(tok) > 160 or "\n" in tok:
continue
if SECRET_BLOCK.search(tok):
continue
if PATH_HINT.search(tok) and not tok.startswith("http"):
if tok not in seen:
seen.append(tok)
return seen
def resolve_clue(tok: str, roots: list[tuple[str, Path]], card_dir: Path) -> tuple[Path | None, str]:
"""把卡里的路径线索落到真实文件上。返回 (路径, 归属说明)。"""
clean = tok.strip().rstrip("::,,)。)")
clean = re.sub(r":\d+(-\d+)?$", "", clean) # 去掉 :12 行号
clean = clean.replace("\\", "/")
# 卡里常写 `vscode/...`,而 WORKSPACE 本身就是 vscode/ —— 去掉重复前缀
for prefix in ("vscode/", "./"):
if clean.startswith(prefix):
clean = clean[len(prefix):]
candidates: list[tuple[Path, str]] = []
for name, r in roots:
candidates.append((r / clean, name))
candidates.append((WORKSPACE / clean, "vscode"))
candidates.append((card_dir / clean, "card"))
for p, owner in candidates:
try:
if p.exists():
return p.resolve(), owner
except OSError:
continue
return None, ""
def fuzzy_candidates(tok: str, root: Path, limit: int = 3) -> list[Path]:
"""线索解析不到时,按文件名在同项目里找近似候选(卡里的路径常与实际有漂移)。"""
base = tok.strip().rstrip("::,,)。)").replace("\\", "/").split("/")[-1]
if not base or not root.exists() or len(base) < 4:
return []
hits: list[Path] = []
for p in root.rglob(base):
if len(p.relative_to(root).parts) > 4:
continue
if any(part in SKIP_DIRS for part in p.parts):
continue
hits.append(p)
if len(hits) >= limit * 4:
break
hits.sort(key=lambda p: (len(p.parts), str(p)))
return hits[:limit]
def source_inventory(tokens: list[str], roots: list[tuple[str, Path]],
card_dir: Path) -> tuple[list[str], list[str]]:
lines, unresolved = [], []
for tok in tokens:
p, owner = resolve_clue(tok, roots, card_dir)
if p is None:
cands: list[str] = []
for _name, r in roots[:2]:
cands.extend(_rel(c) for c in fuzzy_candidates(tok, r))
unresolved.append(f"`{tok}`" + (f" —— 近似候选:{'、'.join(cands[:3])}" if cands else ""))
continue
try:
st = p.stat()
except OSError:
unresolved.append(tok)
continue
kind = "目录" if p.is_dir() else "文件"
rel = _rel(p)
if p.is_dir():
lines.append(f"- `{tok}` → {kind} {rel}({owner})")
else:
ts = datetime.fromtimestamp(st.st_mtime).strftime("%Y-%m-%d %H:%M")
lines.append(f"- `{tok}` → {kind} {rel}({owner},{st.st_size} B,改于 {ts})")
return lines, unresolved
def _rel(p: Path) -> str:
try:
return str(p.relative_to(WORKSPACE)).replace("\\", "/")
except ValueError:
return str(p).replace("\\", "/")
def suggested_scan(roots: list[tuple[str, Path]], card_text: str, limit: int = 14) -> list[str]:
"""给接手模型的「先看哪儿」建议:项目根的一级结构(跳过噪声目录)。"""
out: list[str] = []
for name, r in roots:
if not r.exists():
continue
try:
entries = sorted(r.iterdir(), key=lambda p: (p.is_file(), p.name))
except OSError:
continue
shown = [f"{e.name}{'/' if e.is_dir() else ''}" for e in entries
if e.name not in SKIP_DIRS and not e.name.startswith(".")
and not SECRET_BLOCK.search(e.name)]
if shown:
out.append(f"- `{_rel(r)}` → " + ",".join(shown[:limit])
+ ("…" if len(shown) > limit else ""))
return out
# ------------------------------------------------------------------ 基线
def baselines_for(project: str, root: Path) -> list[dict]:
if not BASELINES.exists():
return []
cfg = json.loads(BASELINES.read_text(encoding="utf-8"))
entry = (cfg.get("projects") or {}).get(project)
if not entry:
return []
cmds = []
for c in entry.get("commands", []):
cwd = Path(c["cwd"]) if c.get("cwd") else root
cmds.append({"label": c.get("label", ""), "cwd": cwd, "cmd": c["cmd"],
"timeout": c.get("timeout", 300),
"heavy": bool(c.get("heavy", False)),
"expect": c.get("expect", "")})
return cmds
def run_baseline(cmds: list[dict], timeout_cap: int, include_heavy: bool = False) -> list[str]:
lines: list[str] = []
for c in cmds:
if c["heavy"] and not include_heavy:
lines.append(f"\n### {c['label']}(重)\n\n```bash\n"
f"cd {_rel(c['cwd'])} && {c['cmd']}\n```\n\n"
f"[SKIP] 标记为重型(全量套件可能跑很久)——打包时未执行,"
f"接手模型必须自己跑一遍并记录真实输出\n")
continue
cwd: Path = c["cwd"]
if not cwd.exists():
lines.append(f"\n### {c['label']}\n\n```bash\n$ cd {cwd} && {c['cmd']}\n"
f"```\n\n[SKIP] 工作目录不存在:`{cwd}`\n")
continue
to = min(c["timeout"], timeout_cap)
lines.append(f"\n### {c['label']}\n")
lines.append(f"```bash\n$ cd {_rel(cwd)} && {c['cmd']}\n```\n")
t0 = datetime.now()
try:
p = subprocess.run(c["cmd"], shell=True, cwd=str(cwd),
capture_output=True, text=True, timeout=to)
out = (p.stdout + p.stderr).strip()
dt = (datetime.now() - t0).total_seconds()
except subprocess.TimeoutExpired:
lines.append(f"\n```text\n[TIMEOUT] 超过 {to}s 未结束——"
f"请接手模型自行跑一遍并记录真实输出\n```\n")
continue
except Exception as e: # noqa: BLE001
lines.append(f"\n```text\n[ERROR] {type(e).__name__}: {e}\n```\n")
continue
tail = "\n".join(out.splitlines()[-40:])
lines.append(f"\n```text\n{tail}\n```\n")
verdict = _verdict(out)
lines.append(f"→ 退出码 {p.returncode},耗时 {dt:.1f}s,末行判读:{verdict}"
+ (f"(期望:{c['expect']})" if c.get("expect") else "") + "\n")
return lines
def _verdict(out: str) -> str:
last = next((ln.strip() for ln in reversed(out.splitlines()) if ln.strip()), "(无输出)")
return last[:160]
# ------------------------------------------------------------------ 组装
def build_bundle(tid: str, args) -> tuple[str, int]:
manifest = load_manifest()
task = find_task(manifest, tid)
if not task:
print(f"未找到任务 {tid}")
return "", 1
project = task["project"]
roots_map: dict[str, str] = manifest["project_roots"]
root = Path(roots_map.get(project, "?"))
card_file = WORKSPACE / task["card"]
card_text = card_file.read_text(encoding="utf-8") if card_file.exists() else "(卡缺失)"
card_dir = card_file.parent
# 线索解析的搜索根:本项目根优先,再补全体项目根
roots: list[tuple[str, Path]] = [(project, root)]
for k, v in roots_map.items():
if k != project:
roots.append((k, Path(v)))
tokens = extract_path_tokens(card_text)
src_lines, unresolved = source_inventory(tokens, roots, card_dir)
now = datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M %z")
P = args.max_bytes
parts: list[str] = []
parts.append(f"""# Handoff Bundle — {task['id']} {task['title']}
生成时间:{now}
项目:`{project}` 项目根:`{root}`
优先级:{task['priority']} · 里程碑 {task['milestone']} · 预估 {task['estimate_days']} 天
建议模型:{task['model_primary']} 复核:{task.get('model_review') or '—'}
依赖:{', '.join(task.get('depends_on') or []) or '无'}
进度文件:`{task['progress_file']}`(写到项目根)
> 这是一份**自包含**交接包:接手模型读完本节即可开工,不需要再问路径、命令、上下文。
> 卡内所有硬约束以第 1 节为准;本包的其余部分是上下文,**不得覆盖卡内约束**。
---
## 0. 接手须知(先读)
1. 本包是「{task['id']}」的完整上下文。若你是接手上游的部分成果,**不要重做已验证部分**——
先跑第 5 节的基线命令确认当前状态,再从断点继续。
2. 密钥零接触:`.env`、`(服务器数据)`、`~/.dsh/secrets` —— 只读都不行。
3. 交付 = 代码 + 项目根 `{task['progress_file']}`(格式见第 2 节)+ 关键命令的**原始输出**。
4. 验收标准全绿才可声明 done;跑不绿就写 partial/blocked 并附原始输出。
5. 完成后逐条对照第 1 节「验收标准」自检,未验证项必须写明——**虚报验收直接降级**。
""")
parts.append(f"\n---\n\n## 1. 任务卡全文\n\n{card_text}\n")
parts.append(f"\n---\n\n## 2. 作业规范(协议要点)\n\n{protocol_excerpt()}\n")
parts.append("\n---\n\n## 3. 项目上下文\n")
rm = project_readme(root)
if rm:
parts.append(f"\n### 3.1 项目 README:`{_rel(rm)}`\n\n```markdown\n"
f"{read_head(rm, args.readme_lines, P)}\n```\n")
else:
parts.append(f"\n### 3.1 项目 README\n\n(`{root}` 下未找到 README.md / AGENTS.md)\n")
prog = recent_progress(root)
parts.append(f"\n### 3.2 最近 PROGRESS({len(prog)} 份)\n")
if prog:
for f in prog:
parts.append(f"\n#### `{_rel(f)}`\n\n```markdown\n"
f"{read_head(f, args.progress_lines, P)}\n```\n")
else:
parts.append("\n(该项目尚无 PROGRESS —— 本卡可能是首张卡)\n")
parts.append("\n---\n\n## 4. 相关源码清单\n")
parts.append("\n卡内点名过的路径,逐个核实:\n\n")
if src_lines:
parts.extend(ln + "\n" for ln in src_lines)
else:
parts.append("(卡内未点名具体文件)\n")
if unresolved:
parts.append("\n**未解析的线索**(卡里提到但当前树中找不到 —— 可能是路径漂移,"
"接手时按候选或自行搜索确认):\n\n")
parts.extend(f"- {t}\n" for t in unresolved[:20])
scan = suggested_scan([roots[0]], card_text) # 只展本项目根,避免包体膨胀
if scan:
parts.append("\n**项目根一级结构**(先看哪儿):\n\n")
parts.extend(ln + "\n" for ln in scan)
parts.append("\n---\n\n## 5. 基线测试命令与输出\n")
cmds = baselines_for(project, root)
if not cmds:
parts.append(f"\n(`{project}` 未登记基线命令 —— 见 `PLANNING/03-执行协议.md` §五 命令速查)\n")
elif not args.run_baseline:
parts.append("\n打包时未执行(默认不跑,避免拖慢打包)。接手模型**必须先自己跑一遍**,"
"把开工前的真实状态记进 PROGRESS 的「基线对照」:\n")
for c in cmds:
tag = "(重)" if c["heavy"] else ""
parts.append(f"\n- **{c['label']}**{tag}\n\n```bash\n"
f"cd {_rel(c['cwd'])} && {c['cmd']}\n```\n")
if c.get("expect"):
parts.append(f" 期望:{c['expect']}\n")
else:
parts.append(f"\n> 以下为打包时实跑捕获(超时上限 {args.timeout}s/条"
f"{',含重型套件' if args.include_heavy else ',重型套件已跳过'})。"
f"开工前请自行复跑确认,不要直接抄这里的结论。\n")
parts.extend(run_baseline(cmds, args.timeout, args.include_heavy))
parts.append(f"""
---
## 6. 收工检查(提交前逐条打勾)
```text
[ ] 第 1 节「验收标准」逐条已满足,且每条都有原始命令输出支撑
[ ] 「边界」段列出的文件一个都没动
[ ] 既有测试没被删/没被跳过(只许更绿)
[ ] 卡外的一律没夹带(顺手发现的 bug 写进 PROGRESS「遗留问题」)
[ ] {task['progress_file']} 已写到项目根,格式符合第 2 节
[ ] 基线对照写了「开工前 → 完工后」两段真实数字
[ ] 未验证项已明确标注(不许虚报)
```
---
*本包由 `PLANNING/tools/handoff_bundle.py` 生成 · 规范见 `PLANNING/03-执行协议.md` §七*
""")
text = "".join(parts)
if args.out:
out = Path(args.out)
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(text, encoding="utf-8")
print(f"已写出 {out}({len(text)} 字符,{len(text.encode('utf-8'))} 字节)")
print(f" 卡:{task['id']} {task['title']}")
print(f" 源码线索:{len(src_lines)} 条已解析 / {len(unresolved)} 条未解析")
if cmds:
mode = "已实跑" if args.run_baseline else "仅列命令"
print(f" 基线:{len(cmds)} 条({mode})")
return text, 0
return text, 0
def main() -> int:
ap = argparse.ArgumentParser(description="Handoff Bundle 打包器")
ap.add_argument("task", nargs="?", help="任务 ID,如 C-01")
ap.add_argument("--out", default=None, help="写入文件(默认 stdout)")
ap.add_argument("--run-baseline", action="store_true",
help="真跑基线命令并捕获输出(默认只列命令;重型条目仍跳过)")
ap.add_argument("--include-heavy", action="store_true",
help="配合 --run-baseline:连重型的全量套件一起跑")
ap.add_argument("--timeout", type=int, default=300, help="每条基线命令超时秒数")
ap.add_argument("--max-bytes", type=int, default=24000, help="单个素材块字节上限")
ap.add_argument("--readme-lines", type=int, default=90)
ap.add_argument("--progress-lines", type=int, default=70)
ap.add_argument("--list", action="store_true", help="列出可用任务 ID")
args = ap.parse_args()
if args.list:
m = load_manifest()
for t in m["tasks"]:
print(f"{t['id']:<6} {t['project']:<14} {t['title']}")
return 0
if not args.task:
ap.print_help()
return 1
text, rc = build_bundle(args.task, args)
if rc == 0 and not args.out:
sys.stdout.reconfigure(encoding="utf-8") if hasattr(sys.stdout, "reconfigure") else None
print(text)
return rc
if __name__ == "__main__":
sys.exit(main())