feat: PLANNING体系首版(00路线图/02任务总表/03执行协议/I-03/I-04/registry42口径)+M1止血交付

This commit is contained in:
2026-09-12 14:25:25 +08:00
commit 39af400fc8
472 changed files with 36277 additions and 0 deletions
+442
View File
@@ -0,0 +1,442 @@
#!/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())