feat(scripts): 종료 코드를 손으로 적을 수 없게 만들고 스킬에 버전을 붙인다

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
This commit is contained in:
DongHyeonka
2026-09-10 11:06:07 +09:00
co-authored by Claude Opus 5
parent 43e1aadef0
commit edd45dfec6
10 changed files with 243 additions and 0 deletions
+136
View File
@@ -0,0 +1,136 @@
#!/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())
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""스킬과 검사기의 버전을 한 장으로 뽑는다.
통과 판정은 「어느 문서를 어느 검사기로 봤는가」에 묶여야 한다. 스킬은 `SKILL.md` 의
`metadata.version` 이 그 값이고, 스크립트 검사기는 버전 칸이 없으므로 파일 내용의 sha256
앞 12자를 쓴다. 버전을 올리는 것을 잊어도 sha 는 따라 움직인다.
python3 scripts/skill-versions.py # 사람이 읽는 표
python3 scripts/skill-versions.py --json # 판정에 붙일 값
"""
from __future__ import annotations
import argparse
import glob
import hashlib
import json
import os
import re
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
GATE_SCRIPTS = [
"scripts/verify-tech-log-tree.py", "scripts/verify-project-layout.py",
"scripts/verify-pipeline-run.py", "scripts/verify-pipeline.py",
"scripts/audit-records.py", "scripts/check-figure-text.py",
"scripts/check-figure-overlap.py", "scripts/build-tech-log-tree.py",
"scripts/studio-body.py", "scripts/capture-evidence.py",
]
def _sha12(path: str) -> str | None:
try:
with open(path, "rb") as fh:
return hashlib.sha256(fh.read()).hexdigest()[:12]
except OSError:
return None
def collect() -> dict:
skills = {}
for path in sorted(glob.glob(os.path.join(ROOT, ".agents/skills/*/SKILL.md"))):
name = os.path.basename(os.path.dirname(path))
text = open(path, encoding="utf-8").read()
m = re.search(r"^metadata:\n(?: .*\n)*? version:\s*\"?([^\"\n]+)\"?",
text, re.M)
skills[name] = {"version": m.group(1).strip() if m else None,
"sha12": _sha12(path)}
checkers = {}
for rel in GATE_SCRIPTS:
checkers[rel] = {"version": None, "sha12": _sha12(os.path.join(ROOT, rel))}
for path in sorted(glob.glob(os.path.join(ROOT, ".agents/skills/*/scripts/*.mjs"))):
rel = os.path.relpath(path, ROOT)
checkers[rel] = {"version": None, "sha12": _sha12(path)}
return {"skills": skills, "checkers": checkers}
def main() -> int:
ap = argparse.ArgumentParser(description="스킬·검사기의 버전과 내용 해시를 뽑는다.")
ap.add_argument("--json", action="store_true")
args = ap.parse_args()
data = collect()
if args.json:
print(json.dumps(data, ensure_ascii=False, indent=2))
return 0
missing = 0
print("스킬")
for name, v in data["skills"].items():
mark = " " if v["version"] else ""
if not v["version"]:
missing += 1
print(f" {mark} {name:<40} {v['version'] or '버전 없음':<10} {v['sha12']}")
print("\n검사기 — 버전 칸이 없어 내용 해시로 묶는다")
for name, v in data["checkers"].items():
print(f" {name:<70} {v['sha12']}")
print(f"\nSKILL VERSIONS: {'FAIL' if missing else 'PASS'}"
f"스킬 {len(data['skills'])} · 버전 없음 {missing} · 검사기 {len(data['checkers'])}")
return 1 if missing else 0
if __name__ == "__main__":
raise SystemExit(main())