le-spec v1.0.0:Le SPEC 智能需求分析与 SPEC 生成总控 skill——真实集成 grilling/grill-with-docs(domain-modeling) + think + openspec(CLI 1.16.1 + 7 个 openspec-* skills,均经 skills.sh 装入 ~/.agents/skills 并软链四 runtime);七阶段工作流(识别/十类分拣/Grill 访谈/Think 挑战/反向验收/OpenSpec 生成/四视角审查)复杂度三档自适应;状态 state.md + 调用账本 ledger.jsonl 双持久化支撑中断恢复与真实调用核验;依赖检查 deps_check.py 分级报缺+精确安装命令,ledger.py append/check/tail;四类回合终止停点(批准前不编码为硬约束);测试 A-I 九项全过:两场景 fresh-context 子代理多轮实测(4 轮访谈+变更+中断恢复+strict validate 双项目通过),账本 28 条真实调用(grilling×9/think×2/openspec CLI×14),独立子代理审查实测抓出自审遗漏的 2 blocking;已知限制 7 项如实入库(reports/test-report.md)

This commit is contained in:
leroy
2026-11-08 12:00:02 +08:00
parent 4ec81e253f
commit b47dcf2ff2
12 changed files with 750 additions and 0 deletions
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""Le SPEC 依赖检查:skill 目录 + openspec CLI。
用法:
python3 deps_check.py # 人读输出, 退出码 0=required 全就位, 1=有缺失
python3 deps_check.py --json # 机器可读
"""
import argparse
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
# 依赖清单: (name, level, source, install_cmd)
SKILL_DEPS = [
("grilling", "required", "mattpocock/skills", "npx skills@latest add mattpocock/skills --skill grilling grill-me grill-with-docs domain-modeling -g"),
("domain-modeling", "recommended", "mattpocock/skills", "npx skills@latest add mattpocock/skills --skill domain-modeling -g"),
("grill-me", "optional", "mattpocock/skills", None), # 入口薄壳
("grill-with-docs", "optional", "mattpocock/skills", None), # 入口薄壳
("think", "required", "tw93/Waza", "npx skills@latest add tw93/Waza --skill think -g"),
("openspec-propose", "recommended", "Fission-AI/OpenSpec", "npx skills@latest add Fission-AI/OpenSpec --skill openspec-propose openspec-new-change openspec-update-change openspec-verify-change openspec-archive-change openspec-apply-change openspec-explore -g"),
("openspec-new-change", "recommended", "Fission-AI/OpenSpec", None),
("openspec-update-change", "recommended", "Fission-AI/OpenSpec", None),
("openspec-verify-change", "recommended", "Fission-AI/OpenSpec", None),
("openspec-archive-change", "recommended", "Fission-AI/OpenSpec", None),
]
# 候选 skill 根目录(顺序即优先级); 可用 LESPEC_SKILL_PATHS 覆盖, 冒号分隔
DEFAULT_ROOTS = [
"~/.agents/skills",
"~/.zcode/skills",
"~/.claude/skills",
"~/.codex/skills",
]
def skill_roots():
env = os.environ.get("LESPEC_SKILL_PATHS")
if env:
return [Path(p).expanduser() for p in env.split(":") if p.strip()]
return [Path(p).expanduser() for p in DEFAULT_ROOTS]
def find_skill(name):
"""返回 (resolved_skill_md_path | None, root_dir)。软链解析到真源。"""
for root in skill_roots():
cand = root / name / "SKILL.md"
if cand.exists():
try:
return cand.resolve(), root
except OSError:
return cand, root
return None, None
def check_openspec_cli():
exe = shutil.which("openspec")
if not exe:
return {"name": "openspec-cli", "level": "required", "ok": False,
"detail": "openspec not in PATH",
"install": "npm install -g @fission-ai/openspec@latest"}
try:
out = subprocess.run([exe, "--version"], capture_output=True, text=True,
timeout=15)
ver = (out.stdout or out.returncode and out.stderr or "").strip().splitlines()[-1] \
if (out.stdout or out.stderr) else "?"
return {"name": "openspec-cli", "level": "required", "ok": out.returncode == 0,
"detail": f"{exe} (v{ver})",
"install": "npm install -g @fission-ai/openspec@latest"}
except Exception as e: # timeout / spawn 失败
return {"name": "openspec-cli", "level": "required", "ok": False,
"detail": f"exec failed: {e}",
"install": "npm install -g @fission-ai/openspec@latest"}
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--json", action="store_true", help="JSON 输出")
args = ap.parse_args()
results = []
for name, level, source, install in SKILL_DEPS:
path, root = find_skill(name)
results.append({
"name": name, "kind": "skill", "level": level,
"ok": path is not None,
"detail": str(path) if path else f"not found under {':'.join(str(r) for r in skill_roots())}",
"source": source, "install": install,
})
results.append(check_openspec_cli())
missing_required = [r for r in results if not r["ok"] and r["level"] == "required"]
missing_recommended = [r for r in results if not r["ok"] and r["level"] == "recommended"]
if args.json:
print(json.dumps({
"ok": not missing_required,
"missing_required": [r["name"] for r in missing_required],
"missing_recommended": [r["name"] for r in missing_recommended],
"items": results,
}, ensure_ascii=False, indent=2))
else:
mark = lambda r: "✅" if r["ok"] else ("❌" if r["level"] == "required" else "⚠️ ")
print(f"{'':2}{'状态':4} {'级别':12} {'依赖':24} 详情")
for r in results:
print(f"{mark(r)} {r['level']:<12} {r['name']:<24} {r['detail']}")
print()
if not missing_required and not missing_recommended:
print("全部依赖就位。")
for r in missing_required + missing_recommended:
if r.get("install"):
print(f"补装 {r['name']}: {r['install']}")
print("提示: optional 级(grill-me/grill-with-docs)是入口薄壳, 缺失不影响运行。")
sys.exit(1 if missing_required else 0)
if __name__ == "__main__":
main()
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env python3
"""Le SPEC 调用账本: 记录每次依赖真实交互, 供审查与交付作证据。
用法:
python3 ledger.py append --dep grilling --kind skill-load --method file-read \
--summary "Phase2 R1 frontier 4问" [--cmd "..."] [--exit 0] [--file PATH]
python3 ledger.py check [--file PATH] [--json]
python3 ledger.py tail [-n 5] [--file PATH]
账本定位优先级: --file > openspec root 下 openspec/le-spec/ledger.jsonl > ./.le-spec/ledger.jsonl
"""
import argparse
import datetime
import json
import subprocess
import sys
from pathlib import Path
KINDS = {"skill-load", "cli", "subagent"}
METHODS = {"skill-tool", "file-read", "bash", "agent"}
def default_ledger():
try:
out = subprocess.run(["openspec", "context", "--json"],
capture_output=True, text=True, timeout=15)
ctx = json.loads(out.stdout or "{}")
root = (ctx.get("root") or {}).get("path")
if root:
p = Path(root) / "openspec" / "le-spec" / "ledger.jsonl"
return p, True
except Exception:
pass
return Path.cwd() / ".le-spec" / "ledger.jsonl", False
def resolve_ledger(args):
"""返回 (path, has_openspec_root)。"""
if getattr(args, "file", None):
return Path(args.file).expanduser(), None
return default_ledger()
def cmd_append(args):
if args.kind not in KINDS or args.method not in METHODS:
print(f"kind 须为 {KINDS}, method 须为 {METHODS}", file=sys.stderr)
return 2
path, has_root = resolve_ledger(args)
path.parent.mkdir(parents=True, exist_ok=True)
entry = {
"ts": datetime.datetime.now().isoformat(timespec="seconds"),
"phase": args.phase, "dep": args.dep, "kind": args.kind,
"method": args.method, "cmd": args.cmd, "exit": args.exit,
"summary": args.summary,
}
with path.open("a", encoding="utf-8") as f:
f.write(json.dumps({k: v for k, v in entry.items() if v is not None},
ensure_ascii=False) + "\n")
print(f"账本已记: {path}")
if has_root is False and not args.file:
print("(未找到 openspec root, 暂写 .le-spec/; Phase 5 init 后迁移)", file=sys.stderr)
return 0
def _load(path):
if not path.exists():
return []
rows = []
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line:
try:
rows.append(json.loads(line))
except json.JSONDecodeError:
rows.append({"_unparsed": line})
return rows
def cmd_check(args):
path, _ = resolve_ledger(args)
rows = _load(path)
by_dep = {}
for r in rows:
d = by_dep.setdefault(r.get("dep", "?"), {"count": 0, "last": None, "kinds": {}})
d["count"] += 1
d["last"] = r.get("ts", d["last"])
d["kinds"][r.get("kind", "?")] = d["kinds"].get(r.get("kind", "?"), 0) + 1
if args.json:
print(json.dumps({"ledger": str(path), "total": len(rows), "by_dep": by_dep},
ensure_ascii=False, indent=2))
else:
print(f"账本: {path} | 总计 {len(rows)} 条")
for dep, d in sorted(by_dep.items()):
kinds = ", ".join(f"{k}×{v}" for k, v in d["kinds"].items())
print(f" {dep:<16} {d['count']:>3} 次 ({kinds}) 末次 {d['last']}")
expected = ["grilling", "think", "openspec"]
missing = [e for e in expected if e not in by_dep]
if missing:
hint = {"think": "(若 Phase 3 合规跳过——无实质方案分歧——属正常,确认 state.md 有跳过记录即可)"}
for m in missing:
print(f"⚠️ 未见 {m} 的调用记录 {hint.get(m, '')} — 否则 Phase 6 审查前需真实补调或如实声明")
return 0
def cmd_tail(args):
path, _ = resolve_ledger(args)
rows = _load(path)[-args.n:]
for r in rows:
print(json.dumps(r, ensure_ascii=False))
return 0
def main():
ap = argparse.ArgumentParser()
sub = ap.add_subparsers(dest="cmd", required=True)
a = sub.add_parser("append")
a.add_argument("--dep", required=True)
a.add_argument("--kind", required=True)
a.add_argument("--method", required=True)
a.add_argument("--phase")
a.add_argument("--cmd")
a.add_argument("--exit", type=int)
a.add_argument("--summary")
a.add_argument("--file")
a.set_defaults(fn=cmd_append)
c = sub.add_parser("check")
c.add_argument("--file")
c.add_argument("--json", action="store_true")
c.set_defaults(fn=cmd_check)
t = sub.add_parser("tail")
t.add_argument("-n", type=int, default=5)
t.add_argument("--file")
t.set_defaults(fn=cmd_tail)
args = ap.parse_args()
sys.exit(args.fn(args))
if __name__ == "__main__":
main()