#!/usr/bin/env python3 # -*- coding: utf-8 -*- """split_skill.py — 技能重组:把千行级技能的 SKILL.md 拆成「主干 + references/ 详情档」。 用法: split_skill.py --spec [--dry-run] 设计(照 `dsh-knowledge-upkeep` §10): · **纯机械搬运** —— 只按行号区间搬移,逐行未改;不重写、不润色、不删字。 · **可重复跑(幂等)** —— 永远以 `/-SKILL.md` 为拆前基准; 首次运行自动生成该备份。⚠️ 绝不以「活文件」为基准(否则第二次跑会二次切割)。 · **四项自校验**:① 行数守恒 ② 逐行包含 0 丢失 ③ 节可寻址 ④ frontmatter 唯一。 spec.json: { "skills_dir": "E:/ProgramData/.workbuddy/skills", "backup_dir": "…/tmp/_keep-YYYYMMDD/split-bak", "skill": "dsh-change-workflow", "main_desc": "§1 … · §2 …", // 写进详情档头的「主文件」说明 "moves": [ {"ranges": [[14,92]], "ref": "00-平台速查.md", "title": "平台速查(硬编码事实,勿猜)", "covers": ["平台速查(硬编码事实,勿猜)"], // 用于主干索引表的「覆盖的原章节」列 "pointer": "> 📂 **…已下沉** → `references/00-平台速查.md`"} ] } ranges = 1-based 闭区间,可多段,**不得重叠**;pointer 支持 \n 多行。 """ import argparse import hashlib import json import os import re import sys from collections import Counter HDR = ("# {title}\n\n" "> **归属**:技能 `{skill}` 的详情档(**按需读**,不是每次都要读)。\n" "> **本档覆盖**:{covers}(原行 {rng})。\n" "> **主文件 / 判据与流程主干** = `../SKILL.md`({main})。\n" "> **来源**:技能重组把 `../SKILL.md` 的 {rng} 段**逐行原样**下沉到本文件,未改一字。\n" "> **跨档引用**:正文里的「§N / 见 §8 坑 N / 见下表」等编号,用 `../SKILL.md` 末节「详情索引」的原章节列定位。\n\n---\n") md5 = lambda b: hashlib.md5(b).hexdigest() def main(): ap = argparse.ArgumentParser() ap.add_argument("--spec", required=True) ap.add_argument("--dry-run", action="store_true") a = ap.parse_args() cfg = json.load(open(a.spec, encoding="utf-8")) skill = cfg["skill"] d = os.path.join(cfg["skills_dir"], skill) src = os.path.join(d, "SKILL.md") os.makedirs(cfg["backup_dir"], exist_ok=True) bak = os.path.join(cfg["backup_dir"], "%s-SKILL.md" % skill) if not os.path.exists(bak): # 首次:以活文件为基准建备份 open(bak, "wb").write(open(src, "rb").read()) orig_bytes = open(bak, "rb").read() # 之后永远以备份为基准(幂等) orig = orig_bytes.decode("utf-8").split("\n") if orig and orig[-1] == "": orig = orig[:-1] n = len(orig) # ── 区间校验 moved, starts = {}, {} for mv in cfg["moves"]: for rng in mv["ranges"]: lo, hi = rng assert 1 <= lo <= hi <= n, "区间越界 %s(原文件 %d 行)" % (rng, n) for i in range(lo, hi + 1): assert i not in moved, "区间重叠于第 %d 行" % i moved[i] = mv["ref"] starts[min(r[0] for r in mv["ranges"])] = mv # ── 生成主干 out, ptrs = [], [] for i in range(1, n + 1): if i in starts: out.append(starts[i]["pointer"]) out.append("") ptrs.append(starts[i]["pointer"]) if i not in moved: out.append(orig[i - 1]) main_len = len(out) # ── 生成详情档 refs = {} for mv in cfg["moves"]: refs.setdefault(mv["ref"], []).append(mv) index, ref_body = [], {} for refname, mvs in sorted(refs.items()): rngs = sorted(r for mv in mvs for r in mv["ranges"]) body = [l for (lo, hi) in rngs for l in orig[lo - 1:hi]] rng_txt = " + ".join("L%d–L%d" % (lo, hi) for (lo, hi) in rngs) covers = " · ".join(dict.fromkeys(c for mv in mvs for c in mv.get("covers", []))) head = HDR.format(title=mvs[0]["title"], skill=skill, covers=covers, rng=rng_txt, main=cfg.get("main_desc", "")) ref_body[refname] = body index.append((refname, covers, len(body), rng_txt)) if not a.dry_run: rd = os.path.join(d, "references") os.makedirs(rd, exist_ok=True) with open(os.path.join(rd, refname), "w", encoding="utf-8", newline="\n") as f: f.write(head + "\n".join(body) + "\n") # ── 主干末节:详情索引(= 跨档引用的定位表) out += ["", "## 详情索引(references/)", "", "> 本技能 = **主干(本文件)+ 详情档**。主干只留判据 / 流程主干 / 命令骨架;长表、案例、实测记录、历史细节在下面各档。", ">", "> **跨档引用怎么查**:正文里的「§N」「见 §8 坑 N」「见下表」等编号,按本表「覆盖的原章节」列定位。", "", "| 详情档 | 覆盖的原章节 | 原行段 | 行数 |", "|---|---|---|---|"] for refname, covers, ln, rng_txt in index: out.append("| `references/%s` | %s | %s | %d |" % (refname, covers.replace("|", "/"), rng_txt, ln)) out.append("") idx_block = len(out) - main_len text = "\n".join(out).rstrip("\n") + "\n" new_lines = text.split("\n")[:-1] # ── 四项自校验 core = main_len - 2 * len(cfg["moves"]) det = sum(len(v) for v in ref_body.values()) A = Counter(orig) B = Counter([l for l in new_lines if l not in ptrs] + [l for rn in ref_body for l in ref_body[rn]]) lost = [k for k in A if A[k] > B[k]] heads = [re.sub(r"^#+\s*", "", l).strip() for l in orig if re.match(r"^#{1,3} ", l)] blob = text + "".join("\n".join(v) for v in ref_body.values()) miss = [h for h in heads if h and h not in blob] fm = [l for l in new_lines[:14] if l.startswith("version:")] checks = [ ("① 行数守恒", core + det == n, "主干内容 %d + 详情 %d = %d(原 %d)" % (core, det, core + det, n)), ("② 逐行包含", not lost, "丢失 %d 行;新增(指针 %d + 索引 %d)" % (len(lost), 2 * len(cfg["moves"]), idx_block)), ("③ 节可寻址", not miss, "标题 %d 个,未命中 %d%s" % (len(heads), len(miss), (" " + repr(miss[:3])) if miss else "")), ("④ frontmatter", len(fm) == 1, "version 行 = %s" % (fm or "缺失/重复")), ] print("=" * 68) print("技能 %s:原 %d 行 md5=%s" % (skill, n, md5(orig_bytes))) for name, ok, info in checks: print(" %s %s ── %s" % ("OK " if ok else "FAIL", name, info)) print(" 主干 SKILL.md = %d 行 %s" % (len(new_lines), "(dry-run,未落盘)" if a.dry_run else "md5=%s" % md5(text.encode("utf-8")))) for refname, covers, ln, rng_txt in index: print(" - %-40s %4d 行 %s" % (refname, ln, rng_txt)) if not a.dry_run: with open(src, "w", encoding="utf-8", newline="\n") as f: f.write(text) bad = [c[0] for c in checks if not c[1]] if bad: print("⛔ 未通过:%s ⇒ 停下来看,别硬推" % "、".join(bad)) return 1 print("✅ 四项校验全绿" + ("(dry-run)" if a.dry_run else ";记得三处同步 + 三方 md5 对账")) return 0 if __name__ == "__main__": sys.exit(main())