Initial Commit
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env python
|
||||
"""Stop hook 안전망: 이번 세션에서 코드/문서 고쳐놓고 worklog 안 남겼으면 딱 한 번 붙잡는다.
|
||||
|
||||
훅이 문장을 대신 못 써준다(그냥 셸이라). 대신 "안 적었으면 멈추지 못하게" 막아서
|
||||
내가 직접 한 줄 남기게 만든다. gen_worklog.py add 흔적이 마지막 편집보다 뒤에 있으면
|
||||
통과, 아니면 block(한 턴 더 돌려서 남기게 함).
|
||||
|
||||
stdin : Claude Code Stop hook JSON (transcript_path, stop_hook_active)
|
||||
stdout: 통과면 아무것도. 걸리면 {"decision":"block","reason":...}
|
||||
|
||||
python worklog_guard.py --selftest 로 자체검증.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
TAIL = 256 * 1024 # ponytail: 트랜스크립트 꼬리만 스캔. 이보다 오래된 미기재 편집은 못 잡음 —
|
||||
# 편집은 보통 그 턴에 바로 붙잡히니 실무상 충분. 느려지면 전체읽기로.
|
||||
REPO = Path(__file__).resolve().parents[2].as_posix().lower() # 레포 루트 — 이 안의 편집만 프로젝트 스텝
|
||||
|
||||
|
||||
def tool_uses(obj):
|
||||
"""파싱된 한 줄 안의 모든 tool_use dict 를 훑어서 뽑는다(중첩 상관없이)."""
|
||||
out = []
|
||||
|
||||
def walk(x):
|
||||
if isinstance(x, dict):
|
||||
if x.get("type") == "tool_use":
|
||||
out.append(x)
|
||||
for v in x.values():
|
||||
walk(v)
|
||||
elif isinstance(x, list):
|
||||
for v in x:
|
||||
walk(v)
|
||||
|
||||
walk(obj)
|
||||
return out
|
||||
|
||||
|
||||
def scan(transcript_path):
|
||||
"""미기재면 마지막 편집 파일경로, 아니면 None."""
|
||||
p = Path(transcript_path)
|
||||
if not p.exists():
|
||||
return None
|
||||
data = p.read_bytes()
|
||||
if len(data) > TAIL:
|
||||
data = data[-TAIL:]
|
||||
data = data[data.find(b"\n") + 1 :] # 잘린 첫 줄 버림
|
||||
last_edit_i, last_edit_file, last_log_i = -1, None, -1
|
||||
for i, raw in enumerate(data.decode("utf-8", "ignore").splitlines()):
|
||||
if not raw.strip():
|
||||
continue
|
||||
try:
|
||||
obj = json.loads(raw)
|
||||
except ValueError:
|
||||
continue
|
||||
for tu in tool_uses(obj):
|
||||
name = tu.get("name", "")
|
||||
inp = tu.get("input", {}) or {}
|
||||
if name in ("Edit", "Write", "NotebookEdit"):
|
||||
fp = str(inp.get("file_path", "")).replace("\\", "/")
|
||||
low = fp.lower()
|
||||
if not low.startswith(REPO):
|
||||
continue # 레포 밖(메모리·스크래치패드 등)은 프로젝트 스텝 아님
|
||||
if "z-my-docs" in low:
|
||||
continue # worklog 자체 편집은 self-trigger 방지로 제외
|
||||
last_edit_i, last_edit_file = i, fp
|
||||
elif name in ("Bash", "PowerShell") and "gen_worklog.py add" in str(
|
||||
inp.get("command", "")
|
||||
):
|
||||
# 윈도우 세션은 Bash 대신 PowerShell 툴로 돎 — 둘 다 인정해야 헛붙잡기 안 함
|
||||
last_log_i = i
|
||||
if last_edit_i >= 0 and last_edit_i > last_log_i:
|
||||
return last_edit_file or "코드"
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
payload = json.load(sys.stdin)
|
||||
except (ValueError, OSError):
|
||||
return
|
||||
if payload.get("stop_hook_active"):
|
||||
return # 이미 한 번 붙잡았음 — 두 번은 안 함(덫·무한루프 방지). 그래도 안 적으면 그냥 통과.
|
||||
edited = scan(payload.get("transcript_path", ""))
|
||||
if not edited:
|
||||
return
|
||||
hint = Path(edited).name if edited != "코드" else "코드"
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"decision": "block",
|
||||
"reason": (
|
||||
f"이번에 {hint} 고쳤는데 worklog 를 안 남겼어. "
|
||||
f'`python z-my-docs/work-log/gen_worklog.py add "<한 일 한 줄>"` 로 '
|
||||
f"한 줄 남기고 끝내. 진짜 남길 것 없으면 그냥 다시 멈추면 통과됨."
|
||||
),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _selftest():
|
||||
import tempfile
|
||||
|
||||
def w(objs):
|
||||
f = tempfile.NamedTemporaryFile("w", suffix=".jsonl", delete=False, encoding="utf-8")
|
||||
for o in objs:
|
||||
f.write(json.dumps(o) + "\n")
|
||||
f.close()
|
||||
return f.name
|
||||
|
||||
def tu(name, **inp):
|
||||
return {"message": {"content": [{"type": "tool_use", "name": name, "input": inp}]}}
|
||||
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
edit = tu("Edit", file_path=str(root / "1_backend/src/x.py"))
|
||||
log = tu("Bash", command='python z-my-docs/work-log/gen_worklog.py add "x"')
|
||||
pslog = tu("PowerShell", command='python z-my-docs/work-log/gen_worklog.py add "x"')
|
||||
logedit = tu("Edit", file_path=str(root / "z-my-docs/work-log/2026-07/x.tsv"))
|
||||
memedit = tu("Edit", file_path="C:/Users/user/.claude/projects/foo/memory/bar.md")
|
||||
|
||||
r = scan(w([edit]))
|
||||
assert r and r.endswith("x.py"), r # 레포 안 편집만 → 미기재
|
||||
assert scan(w([edit, log])) is None # 편집 뒤 로그 → 통과
|
||||
assert scan(w([edit, pslog])) is None # PowerShell 툴로 남긴 로그도 인정(윈도우 세션)
|
||||
assert scan(w([log, edit])) is not None # 로그 뒤 편집 → 미기재
|
||||
assert scan(w([logedit])) is None # worklog 편집만 → 무시
|
||||
assert scan(w([memedit])) is None # 레포 밖(메모리) 편집 → 무시
|
||||
assert scan(w([])) is None # 빈 트랜스크립트 → 통과
|
||||
print("selftest ok")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if sys.argv[1:2] == ["--selftest"]:
|
||||
_selftest()
|
||||
else:
|
||||
main()
|
||||
Reference in New Issue
Block a user