144 lines
4.8 KiB
Python
144 lines
4.8 KiB
Python
#!/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()
|