capture-evidence.py 는 명령을 subprocess 로 직접 돌리고 그 프로세스의 반환값을 그대로 메타에 적는다. 종료 코드를 인자로 받지 않으므로 손으로 적을 경로가 없다. raw 원문과 실행 메타가 같은 이름으로 함께 떨어져 「raw 는 있는데 meta 가 없다」가 구조적으로 안 생긴다. skill-versions.py 는 스킬 9개의 metadata.version 과 검사기 17개의 내용 해시를 한 장으로 낸다. 통과 판정을 검증기 버전에 묶으려면 묶을 값이 있어야 한다. 버전 칸이 없던 스킬 여덟에 1.0.0 을 붙였다. 산문은 한 줄도 안 바꿨다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4vKjQo9KKBBokzxqXLCfk
137 lines
5.8 KiB
Python
137 lines
5.8 KiB
Python
#!/usr/bin/env python3
|
|
"""명령을 실제로 돌려 원문과 실행 메타를 함께 적립한다.
|
|
|
|
`final/evidence/raw/` 는 정본이고 `final/evidence/meta/` 는 그 실행의
|
|
command·cwd·executedAt·exitCode·revision 이다. 둘을 사람이 따로 적으면 갈라진다 —
|
|
`verify-project-layout.py` 가 「raw 는 있는데 meta 가 없다」로 세는 자리가 그것이다.
|
|
|
|
이 도구는 **종료 코드를 손으로 적을 수 없게 만든다.** 명령을 여기서 돌리고, 그 프로세스의
|
|
반환값을 그대로 meta 에 적는다. 돌리지 않은 검증을 완료로 적는 경로가 없어야 한다.
|
|
|
|
기존 경로를 지우지 않는다 — 손으로 만든 raw/meta 도 그대로 유효하고, 이 도구는 선택적으로 쓴다.
|
|
|
|
python3 scripts/capture-evidence.py <프로젝트> <증거 id> -- <명령...>
|
|
python3 scripts/capture-evidence.py <프로젝트> <증거 id> --cwd <경로> \
|
|
--proves "<이 출력이 뒷받침하는 것>" --does-not-prove "<뒷받침하지 못하는 것>" -- <명령...>
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import datetime
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
|
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
|
|
def _revision(cwd: str) -> str | None:
|
|
"""그 작업 디렉터리 저장소의 HEAD. 저장소가 아니면 None 이다."""
|
|
try:
|
|
out = subprocess.run(["git", "rev-parse", "HEAD"], cwd=cwd,
|
|
capture_output=True, text=True, timeout=15)
|
|
except (OSError, subprocess.SubprocessError):
|
|
return None
|
|
return out.stdout.strip() if out.returncode == 0 else None
|
|
|
|
|
|
def _dirty(cwd: str) -> bool | None:
|
|
"""작업 트리에 커밋 안 된 변경이 있나. 있으면 revision 이 출력을 설명하지 못한다."""
|
|
try:
|
|
out = subprocess.run(["git", "status", "--porcelain"], cwd=cwd,
|
|
capture_output=True, text=True, timeout=15)
|
|
except (OSError, subprocess.SubprocessError):
|
|
return None
|
|
return bool(out.stdout.strip()) if out.returncode == 0 else None
|
|
|
|
|
|
def capture(project: str, eid: str, command: list[str], cwd: str,
|
|
proves: str, does_not_prove: str, kind: str,
|
|
timeout: int, subdir: str) -> int:
|
|
base = os.path.join(ROOT, "docs", project, "final", "evidence")
|
|
raw_dir = os.path.join(base, "raw", subdir) if subdir else os.path.join(base, "raw")
|
|
meta_dir = os.path.join(base, "meta")
|
|
os.makedirs(raw_dir, exist_ok=True)
|
|
os.makedirs(meta_dir, exist_ok=True)
|
|
|
|
started = datetime.datetime.now().astimezone()
|
|
try:
|
|
proc = subprocess.run(command, cwd=cwd, capture_output=True,
|
|
text=True, timeout=timeout)
|
|
exit_code, out = proc.returncode, proc.stdout + proc.stderr
|
|
except subprocess.TimeoutExpired as e:
|
|
exit_code = 124
|
|
out = (e.stdout or "") + (e.stderr or "") + f"\n[timeout {timeout}s]\n"
|
|
except OSError as e:
|
|
print(f"명령을 실행하지 못했다: {e}", file=sys.stderr)
|
|
return 2
|
|
|
|
raw_rel = os.path.join("raw", subdir, f"{eid}.txt") if subdir else os.path.join("raw", f"{eid}.txt")
|
|
raw_path = os.path.join(base, raw_rel)
|
|
with open(raw_path, "w", encoding="utf-8") as fh:
|
|
fh.write(out)
|
|
|
|
meta = {
|
|
"id": eid,
|
|
"kind": kind,
|
|
"sourceRevision": _revision(cwd),
|
|
"sourceDirty": _dirty(cwd),
|
|
"executedAt": started.isoformat(timespec="seconds"),
|
|
"executedAtSource": "이 도구가 명령을 실행한 시각",
|
|
"command": " ".join(command),
|
|
"cwd": os.path.relpath(cwd, ROOT) if cwd.startswith(ROOT) else cwd,
|
|
"exitCode": exit_code,
|
|
"exitCodeSource": "실행한 프로세스의 반환값. 손으로 적지 않는다",
|
|
"rawPath": f"evidence/{raw_rel}",
|
|
"presentationPath": None,
|
|
"proves": proves,
|
|
"doesNotProve": does_not_prove,
|
|
"sha256": hashlib.sha256(out.encode("utf-8")).hexdigest(),
|
|
"bytes": len(out.encode("utf-8")),
|
|
}
|
|
meta_path = os.path.join(meta_dir, f"{eid}.json")
|
|
tmp = meta_path + ".tmp"
|
|
with open(tmp, "w", encoding="utf-8") as fh:
|
|
json.dump(meta, fh, ensure_ascii=False, indent=2)
|
|
fh.write("\n")
|
|
os.replace(tmp, meta_path)
|
|
|
|
print(f"{os.path.relpath(raw_path, ROOT)} exit={exit_code} {meta['bytes']}B")
|
|
print(f"{os.path.relpath(meta_path, ROOT)}")
|
|
return 0
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser(
|
|
description="명령을 돌려 raw 원문과 실행 메타를 함께 적립한다.")
|
|
ap.add_argument("project")
|
|
ap.add_argument("evidence_id")
|
|
ap.add_argument("--cwd", default=ROOT)
|
|
ap.add_argument("--subdir", default="", help="raw/ 아래 하위 폴더")
|
|
ap.add_argument("--kind", default="terminal",
|
|
choices=["terminal", "browser", "query-plan", "benchmark", "other"])
|
|
ap.add_argument("--proves", default="", help="이 출력이 뒷받침하는 것 (경계까지)")
|
|
ap.add_argument("--does-not-prove", default="", help="이 출력이 뒷받침하지 못하는 것")
|
|
ap.add_argument("--timeout", type=int, default=600)
|
|
|
|
# `--` 앞뒤를 먼저 가른다. argparse.REMAINDER 에 맡기면 옵션이 명령으로 딸려 간다
|
|
argv = sys.argv[1:]
|
|
if "-h" in argv or "--help" in argv:
|
|
ap.parse_args(["--help"])
|
|
if "--" not in argv:
|
|
ap.error("돌릴 명령이 없다. `-- <명령...>` 으로 준다")
|
|
cut = argv.index("--")
|
|
args = ap.parse_args(argv[:cut])
|
|
command = argv[cut + 1:]
|
|
if not command:
|
|
ap.error("돌릴 명령이 없다. `-- <명령...>` 으로 준다")
|
|
return capture(args.project, args.evidence_id, command,
|
|
os.path.abspath(args.cwd), args.proves, args.does_not_prove,
|
|
args.kind, args.timeout, args.subdir)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|