Files
company-haness/.claude/hooks/_workspace.py
T

119 lines
4.7 KiB
Python

"""_workspace.py — 작업 산출물의 '현재 프로젝트 워크스페이스' 경로 해석(중앙화).
org-os = SSOT(정의·계약)만. 생성물/런타임 상태는 프로젝트별 root 폴더로 나간다.
모든 훅이 하드코딩(org-os/06-agent-work/...) 대신 이 모듈로 경로를 얻는다.
현재 워크스페이스 결정 순서:
1. 환경변수 ORGOS_WORKSPACE (프로젝트명 또는 절대경로)
2. 포인터 파일 <repo>/.orgos-workspace (첫 non-comment·non-blank 줄: 프로젝트명)
둘 다 미설정/비어있으면 -> WorkspaceNotSetError 로 중단(과거의 test 프로젝트
하드코딩 기본값은 제거됨). 운영 실행이 조용히 test 워크스페이스로 떨어지는 것을 막는다.
프로젝트 폴더 레이아웃(자기완결):
<project>/
completion-records/<wf>/ evidence/<wf>/ reports/ state/ slack-inbox/ slack-outbox/ design-system/
"""
import os
import sys
ROOT = os.environ.get("CLAUDE_PROJECT_DIR") or os.path.dirname(
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
)
class WorkspaceNotSetError(RuntimeError):
"""워크스페이스가 명시되지 않았을 때 raise. 조용한 기본값 대신 명확히 중단."""
def _read_pointer(root):
"""포인터 파일에서 프로젝트명 해석. 첫 non-comment·non-blank 줄만 사용.
(# 로 시작하는 줄과 빈 줄은 무시 -> 포인터 파일에 설명 주석 허용.)"""
ptr = os.path.join(root, ".orgos-workspace")
try:
with open(ptr, encoding="utf-8") as fh:
for raw in fh:
line = raw.strip()
if not line or line.startswith("#"):
continue
return line
except OSError:
return None
return None
def workspace_name():
ws = os.environ.get("ORGOS_WORKSPACE")
if ws and ws.strip():
return ws.strip()
name = _read_pointer(ROOT)
if name:
return name
raise WorkspaceNotSetError(
"워크스페이스가 설정되지 않았습니다. "
"환경변수 ORGOS_WORKSPACE=<project> 를 설정하거나 "
f"{os.path.join(ROOT, '.orgos-workspace')} 파일에 프로젝트명을 기록하세요. "
"운영 실행(operational runs)은 test 워크스페이스로 조용히 기본 설정될 수 없습니다 "
"(set ORGOS_WORKSPACE=<project> or write the project name into "
"<repo>/.orgos-workspace; operational runs must not default to a test workspace)."
)
def work_root():
"""현재 워크스페이스의 절대 경로."""
name = workspace_name()
return name if os.path.isabs(name) else os.path.join(ROOT, name)
def require_workspace(hook_name="hook", advisory=False):
"""운영(operational) 훅의 fail-closed 게이트(finding P0-1).
워크스페이스가 설정돼 있으면 work_root 를 돌려주고, 미설정이면:
- advisory=False(기본): BLOCK 메시지를 stderr 로 쓰고 **exit 2** 로 중단한다.
상태 게이트·수락 원장·토큰 게이트처럼 '검증 불가면 통과시키면 안 되는' 훅이 쓴다.
과거엔 미설정 시 degrade(allow)해서 workspace 한 줄만 비우면 모든 게이트가
연쇄적으로 fail-open 됐다 — 그 구멍을 닫는다.
- advisory=True: 경고만 쓰고 None 을 돌려준다(호출부가 exit 0 로 degrade).
receipt 기록기(evidence_ledger)처럼 '게이트가 아닌 계측'이 쓴다.
읽기 전용 질의(current/allowed/dashboard 등)는 이 함수 대신 work_root()를 직접
호출하고 예외를 잡아 degrade 한다 — 스테일 상태로 리포트를 막지 않기 위함이다.
"""
try:
return work_root()
except WorkspaceNotSetError as e:
if advisory:
sys.stderr.write(
f"[{hook_name}] WARN: 워크스페이스 미설정 — 계측 degrade(allow). ({e})\n"
)
return None
sys.stderr.write(
f"[{hook_name}] BLOCK: 워크스페이스 미설정 — 운영 훅은 fail-closed(exit 2). "
f"ORGOS_WORKSPACE=<project> 를 설정하거나 <repo>/.orgos-workspace 에 프로젝트명을 "
f"기록하세요. 운영 실행은 검증 불가 상태로 통과할 수 없습니다. ({e})\n"
)
sys.exit(2)
def records_dir():
return os.path.join(work_root(), "completion-records")
def evidence_dir():
return os.path.join(work_root(), "evidence")
def reports_dir():
return os.path.join(work_root(), "reports")
def state_dir():
return os.path.join(work_root(), "state")
def slack_outbox():
return os.path.join(work_root(), "slack-outbox")
def slack_inbox():
return os.path.join(work_root(), "slack-inbox")