Files
document-haness/scripts/run-ledger.py
T
DongHyeonkaandClaude Opus 5 ab59130196 chore: 이전 세션이 남긴 변경을 커밋한다
이번 파이프라인 작업과 무관하게 작업 트리에 남아 있던 것을 그대로 올린다.
사용자가 「전부 커밋」으로 정했고, 이번 작업과 섞이지 않게 커밋만 나눴다.

대부분은 clean-architecture-backend-template 의 그림 정본 재배치다 —
final/assets/diagrams/<이름>/ 에 있던 것이 CLAUDE.md 가 적은 배치인
final/assets/<이름>/ 로 옮겨졌고 .techviz/<이름>/ 이 함께 들어왔다.
삽입 줄의 대부분(3.15M)이 그 .techviz context.json 이다.

그 밖에 ca-tmpl·document-haness 의 정리, .claude/agents/ 열한 개,
writing-practitioner-guides 스킬, .playwright-mcp 세션 산출물,
scripts/check-ssot-facts.py 와 그 시험이 들어 있다.

이 커밋의 내용은 내가 만든 것이 아니라 이전 세션이 남긴 것이고 검증하지 않았다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 11:02:02 +09:00

369 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""런 원장을 쓰는 도구. 반쪽 상태를 만들지 않고, 끊긴 자리에서 다시 시작한다.
`runs/<프로젝트>/<runId>/run.json` 은 지금까지 손으로 썼다. 그래서 셋이 없었다.
- **반쪽 파일.** `json.dump` 로 바로 쓰면 중간에 끊긴 원장이 남는다. 다음 세션이 그것을
읽으면 런이 통째로 사라진 것처럼 보인다
- **어디서 끊겼는지.** 단계마다 시작·끝이 없어 「S5 를 돌다 말았다」와 「S5 를 아직 안
시작했다」가 `PENDING` 하나로 같아 보인다
- **원장 밖의 시간.** 런 도중에 끼어든 일(라이더)이 어디에도 안 남는다. 이 배치에서
검증 한 작업의 누적 24분 가운데 17분 50초가 라이더였는데 상태 파일에 그 구분이 없었다
`bin/task.py` 가 이 계약의 초안이다 — flock 을 잡고 임시 파일에 완성한 뒤 `os.replace` 로
바꾸고, `startedAt` 부터 지금까지를 누적에 더한다. 여기서는 그것을 `runs/` 쪽으로 넓힌다.
**새로 만드는 것이 아니라 같은 규약을 옮기는 것이다.**
python3 scripts/run-ledger.py open <원장> --project P --record R
python3 scripts/run-ledger.py begin <원장> --stage S3 # runBy 는 계약값을 쓴다
python3 scripts/run-ledger.py gate <원장> --stage S3 --cmd "..." --exit 0
python3 scripts/run-ledger.py end <원장> --stage S3 --status DONE --echo "..." --output ...
python3 scripts/run-ledger.py rider <원장> --id R11 --why "..." --seconds 1070
python3 scripts/run-ledger.py status <원장>
"""
from __future__ import annotations
import argparse
import datetime
import fcntl
import json
import os
import subprocess
import sys
import tempfile
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
TEMPLATE = os.path.join(
ROOT, ".agents/skills/running-tech-log-pipeline/templates/run.json")
STAGES = ["S1", "S2", "S3", "S4", "S5", "S6", "S7"]
UNSKIPPABLE = {"S3", "S5", "S6"}
OPEN_STATES = {"RUNNING"}
REVISION_FIELD = "skillRevision"
class Contract(Exception):
"""계약 위반. 도구가 거절한 것이지 실패가 아니다."""
def _now() -> str:
return datetime.datetime.now().astimezone().isoformat(timespec="seconds")
def _git(*args: str) -> str | None:
"""저장소에 묻는다. 실패는 빈 문자열이 아니라 `None` 이다."""
try:
p = subprocess.run(["git", "-C", ROOT, *args], capture_output=True,
encoding="utf-8", errors="replace", timeout=30)
except (OSError, subprocess.SubprocessError):
return None
return p.stdout if p.returncode == 0 else None
def _skill_revision(skill: str) -> str | None:
"""지금 이 스킬의 글자를 담고 있는 커밋.
영수증(`skillEcho`)은 그때 SKILL.md 에 있던 문장인데, 스킬은 나중에 고쳐진다. 그러면
검사기가 「위조」와 「그 뒤에 고쳐졌다」를 가르려고 이력을 훑어야 한다. 그 시점 커밋을
여기서 적어 두면 훑지 않고 그 커밋 하나만 본다.
작업 트리가 그 커밋과 다르면 `None` 이다 — 모르는 리비전을 지어내지 않는다.
그런 원장은 검사기에서 이력 훑기로 떨어지고, 그것이 맞는 결과다.
"""
if not skill:
return None
rel = f".agents/skills/{skill}"
dirty = _git("status", "--porcelain", "--", rel)
if dirty is None or dirty.strip():
return None
out = _git("log", "-n", "1", "--format=%H", "--", rel)
return (out or "").strip() or None
class Ledger:
"""flock 을 잡고 읽어서, 임시 파일에 완성한 뒤 원자적으로 바꾼다.
`os.replace` 는 같은 파일 시스템에서 원자적이다. 중간에 끊겨도 읽는 쪽은 **이전 판이나
다음 판 중 하나**를 본다. 반쪽 파일을 볼 수 없다.
"""
def __init__(self, path: str, create: bool = False) -> None:
self.path = os.path.abspath(path)
if not create and not os.path.exists(self.path):
raise Contract(f"그런 원장이 없다: {path}")
os.makedirs(os.path.dirname(self.path), exist_ok=True)
self._lockpath = self.path + ".lock"
self._fh = open(self._lockpath, "a+")
def __enter__(self) -> "Ledger":
fcntl.flock(self._fh, fcntl.LOCK_EX)
self.data = (json.load(open(self.path, encoding="utf-8"))
if os.path.exists(self.path) else {})
return self
def __exit__(self, *exc) -> None:
fcntl.flock(self._fh, fcntl.LOCK_UN)
self._fh.close()
def save(self) -> None:
self.data["revision"] = int(self.data.get("revision", 0)) + 1
self.data["updatedAt"] = _now()
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(self.path),
prefix=".run.", suffix=".json")
try:
with os.fdopen(fd, "w", encoding="utf-8") as fh:
json.dump(self.data, fh, ensure_ascii=False, indent=2)
fh.write("\n")
fh.flush()
os.fsync(fh.fileno())
os.replace(tmp, self.path)
except BaseException:
os.path.exists(tmp) and os.unlink(tmp)
raise
def stage(self, sid: str) -> dict:
for st in self.data.get("stages", []):
if st["id"] == sid:
return st
raise Contract(f"그런 단계가 없다: {sid}. 단계는 {', '.join(STAGES)} 뿐이다")
def running(self) -> dict | None:
return next((s for s in self.data.get("stages", [])
if s.get("status") in OPEN_STATES), None)
def _require_owner(st: dict, session: str | None, generation: int | None) -> None:
"""이 단계를 지금 쥔 세션인지 본다.
`plan/02` B-005 — 「이전 작업자의 종료를 확인하고 재배정한 시도 번호와 맞는 결과만
받는다.」 원장에는 그것이 없어서, 이어받은 단계에 앞 세션이 관문을 써도 들어갔다.
**파일은 안 깨진다. 깨지지 않은 채로 두 세션의 기록이 섞인다.**
"""
cur_gen = int(st.get("generation", 0))
if generation is not None and generation != cur_gen:
raise Contract(
f"{st['id']} 의 현재 세대는 {cur_gen} 인데 쓰기는 {generation} 이다. "
f"그 사이에 다른 세션이 이어받았다 — begin 으로 다시 잡는다")
owner = st.get("owner")
if owner and session and session != owner:
raise Contract(
f"{st['id']} 은(는) 지금 {owner} 가 쥐고 있다. {session} 의 쓰기는 받지 않는다 — "
f"이어받으려면 begin 으로 세대를 올린다")
if owner and not session:
raise Contract(
f"{st['id']} 은(는) {owner} 가 쥐고 있다. --session 으로 누가 쓰는지 밝힌다")
def _accrue(node: dict) -> int:
"""`startedAt` 부터 지금까지를 누적에 더한다. 세션이 다시 떠도 이어진다."""
started = node.get("startedAt")
if started:
delta = (datetime.datetime.now().astimezone()
- datetime.datetime.fromisoformat(started)).total_seconds()
node["elapsedSeconds"] = int(node.get("elapsedSeconds", 0) + max(0.0, delta))
node["startedAt"] = None
return int(node.get("elapsedSeconds", 0))
def cmd_open(args) -> int:
if os.path.exists(os.path.abspath(args.ledger)) and not args.force:
raise Contract(f"이미 있는 원장이다: {args.ledger}. 이어서 하려면 status 로 본다")
with Ledger(args.ledger, create=True) as led:
led.data = json.load(open(TEMPLATE, encoding="utf-8"))
led.data.update({
"runId": args.run_id or os.path.basename(os.path.dirname(
os.path.abspath(args.ledger))),
"project": args.project, "record": args.record,
"startedAt": _now(), "finishedAt": None,
"riders": [],
"sessions": [{"session": args.session, "openedAt": _now()}],
})
for st in led.data["stages"]:
st.update({"startedAt": None, "finishedAt": None, "elapsedSeconds": 0,
REVISION_FIELD: _skill_revision(str(st.get("skill") or ""))})
led.save()
print(f"런을 열었다: {args.ledger} (runId={led.data['runId']})")
return 0
def cmd_begin(args) -> int:
with Ledger(args.ledger) as led:
open_stage = led.running()
if open_stage and open_stage["id"] != args.stage:
raise Contract(
f"{open_stage['id']} 이(가) 아직 RUNNING 이다. 단계를 겹쳐 열지 않는다 — "
f"end 로 닫거나 status 로 어디서 끊겼는지 본다")
st = led.stage(args.stage)
if st.get("status") in ("DONE", "SKIPPED"):
raise Contract(f"{args.stage} 은(는) 이미 {st['status']} 다")
if st.get("startedAt"):
# 앞 세션이 이 단계를 열어 둔 채 끊겼다. 그 구간을 닫고 새로 연다.
# **닫은 구간에는 세션이 죽어 있던 시간이 섞인다.** 그래서 누적에만 더하지 않고
# 따로 적는다 — 「이 단계가 오래 걸렸다」와 「중간에 끊겼다」는 다른 말이다
was = st["startedAt"]
span = _accrue(st)
st.setdefault("interruptions", []).append({
"openedAt": was, "resumedAt": _now(),
"accruedSeconds": span,
"note": "앞 세션이 닫지 않고 끊겼다. 이 구간에는 세션이 없던 시간이 섞여 있다",
})
print(f"{args.stage} 이(가) 열린 채였다 — 그 구간을 닫고 잇는다")
st["status"] = "RUNNING"
# `--runby` 를 안 주면 **틀이 적어 둔 계약값을 그대로 둔다.** 여기서 기본값으로
# 덮어쓰면 단계마다 배정된 관리 에이전트 이름이 매번 지워지고, 원장은 다시
# 「서브에이전트가 돌렸다」까지만 말하게 된다 — 누가 돌렸는지는 안 남는다
if args.runby:
st["runBy"] = args.runby
st["startedAt"] = _now()
# 이어받을 때마다 세대를 올리고 주인을 적는다. `bin/task.py` 의 attempt 와 같은 자리다 —
# 앞 세션이 아직 살아 있어도 낮은 세대의 쓰기는 이 단계에 못 들어온다.
# 파일이 안 깨지는 것과 두 세션의 기록이 안 섞이는 것은 다른 일이다
st["generation"] = int(st.get("generation", 0)) + 1
st["owner"] = args.session or None
led.data.setdefault("sessions", []).append(
{"session": args.session or None, "stage": args.stage,
"generation": st["generation"], "beganAt": _now()})
led.save()
print(f"{args.stage} RUNNING · 세대 {st['generation']} · 주인 {st['owner']}"
f" · 누적 {st.get('elapsedSeconds', 0)}초")
return 0
def cmd_gate(args) -> int:
with Ledger(args.ledger) as led:
st = led.stage(args.stage)
_require_owner(st, args.session, args.generation)
st.setdefault("gates", []).append({
"cmd": args.cmd, "exit": args.exit_code,
"session": args.session or None, "generation": int(st.get("generation", 0)),
"at": _now()})
led.save()
print(f"{args.stage} 관문 {len(st['gates'])}개 · 방금 것 exit={args.exit_code}"
f" · {args.session or '세션 미기재'}")
return 0
def cmd_end(args) -> int:
with Ledger(args.ledger) as led:
st = led.stage(args.stage)
_require_owner(st, args.session, args.generation)
if args.status == "SKIPPED" and args.stage in UNSKIPPABLE:
raise Contract(f"{args.stage} 은(는) 건너뛸 수 없다")
if args.status == "SKIPPED" and not args.why:
raise Contract("건너뛴 단계는 사유를 적는다. --why 를 준다")
if args.status == "DONE" and not (args.echo or st.get("skillEcho")):
raise Contract(
"DONE 인 단계는 스킬 영수증을 적는다. --echo 로 그 SKILL.md 의 문장을 "
"원문 그대로 준다 — 안 연 스킬의 영수증을 적으면 그것이 지어낸 것이다")
used = _accrue(st)
st["status"] = args.status
st["finishedAt"] = _now()
st["finishedBy"] = args.session or None
if args.echo:
st["skillEcho"] = args.echo
# 영수증을 적는 자리에서 그 영수증이 어느 판의 스킬에서 나왔는지도 적는다.
# 여는 시각이 아니라 읽은 시각이라 이쪽이 더 맞다
st[REVISION_FIELD] = _skill_revision(str(st.get("skill") or ""))
if args.why:
st["skipReason"] = args.why
for key, values in (("inputs", args.input), ("outputs", args.output)):
if values:
st.setdefault(key, []).extend(values)
if args.note:
st["notes"] = (st.get("notes", "") + " " + args.note).strip()
if all(s.get("status") in ("DONE", "SKIPPED") for s in led.data["stages"]):
led.data["finishedAt"] = _now()
led.save()
print(f"{args.stage} {args.status} · 이 단계 누적 {used}초")
return 0
def cmd_rider(args) -> int:
"""런 도중에 끼어든 일. 단계가 아니라서 어느 칸에도 안 남던 것이다."""
with Ledger(args.ledger) as led:
led.data.setdefault("riders", []).append({
"id": args.id, "why": args.why, "seconds": args.seconds,
"addedAt": _now(), "duringStage": (led.running() or {}).get("id"),
})
total = sum(r.get("seconds") or 0 for r in led.data["riders"])
led.save()
print(f"라이더 {len(led.data['riders'])}건 · 합계 {total}초 ({total // 60}분)")
return 0
def cmd_status(args) -> int:
with Ledger(args.ledger) as led:
d = led.data
print(f"{d.get('runId')} · {d.get('project')} · revision {d.get('revision', 0)}")
print(f"기록 {d.get('record')}")
for st in d.get("stages", []):
mark = {"DONE": "✓", "SKIPPED": "", "RUNNING": "▶", "FAILED": "✗"}.get(
st.get("status"), " ")
gates = st.get("gates") or []
bad = [g for g in gates if g.get("exit") not in (0, "0")]
print(f" {mark} {st['id']} {st.get('status'):<8} "
f"{st.get('elapsedSeconds', 0):>5}초 · 관문 {len(gates)}"
+ (f" (exit≠0 {len(bad)})" if bad else ""))
riders = d.get("riders") or []
if riders:
total = sum(r.get("seconds") or 0 for r in riders)
print(f" 라이더 {len(riders)}건 · {total}초 ({total // 60}분) — 단계 밖의 시간이다")
for r in riders:
print(f" {r['id']} · {r.get('seconds') or 0}초 · {r.get('why', '')[:60]}")
run = led.running()
if run:
print(f"\n끊긴 자리: {run['id']} 이(가) RUNNING 이다 "
f"(시작 {run.get('startedAt')})")
print(f"이어서 하려면: run-ledger.py end {args.ledger} --stage {run['id']} …")
return 1
pending = [s["id"] for s in d.get("stages", []) if s.get("status") == "PENDING"]
if pending:
print(f"\n아직 안 연 단계: {', '.join(pending)}")
return 1
print("\n일곱 단계가 다 닫혔다")
return 0
def main() -> int:
ap = argparse.ArgumentParser(description="런 원장을 원자적으로 쓰고 끊긴 자리에서 잇는다.")
sub = ap.add_subparsers(dest="cmd", required=True)
p = sub.add_parser("open"); p.add_argument("ledger")
p.add_argument("--project", required=True); p.add_argument("--record", required=True)
p.add_argument("--run-id"); p.add_argument("--session", default=os.environ.get("USER", ""))
p.add_argument("--force", action="store_true"); p.set_defaults(fn=cmd_open)
p = sub.add_parser("begin"); p.add_argument("ledger")
p.add_argument("--stage", required=True); p.add_argument("--runby", default=None)
p.add_argument("--session", default=""); p.set_defaults(fn=cmd_begin)
p = sub.add_parser("gate"); p.add_argument("ledger")
p.add_argument("--stage", required=True); p.add_argument("--cmd", required=True)
p.add_argument("--exit", dest="exit_code", type=int, required=True)
p.add_argument("--session", default=""); p.add_argument("--generation", type=int)
p.set_defaults(fn=cmd_gate)
p = sub.add_parser("end"); p.add_argument("ledger")
p.add_argument("--stage", required=True)
p.add_argument("--status", required=True, choices=["DONE", "SKIPPED", "FAILED"])
p.add_argument("--echo"); p.add_argument("--why"); p.add_argument("--note")
p.add_argument("--input", action="append"); p.add_argument("--output", action="append")
p.add_argument("--session", default=""); p.add_argument("--generation", type=int)
p.set_defaults(fn=cmd_end)
p = sub.add_parser("rider"); p.add_argument("ledger")
p.add_argument("--id", required=True); p.add_argument("--why", required=True)
p.add_argument("--seconds", type=int); p.set_defaults(fn=cmd_rider)
p = sub.add_parser("status"); p.add_argument("ledger"); p.set_defaults(fn=cmd_status)
args = ap.parse_args()
try:
return args.fn(args)
except Contract as e:
print(f"거절: {e}", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())