"""_workspace.py — 작업 산출물의 '현재 프로젝트 워크스페이스' 경로 해석(중앙화). org-os = SSOT(정의·계약)만. 생성물/런타임 상태는 프로젝트별 root 폴더로 나간다. 모든 훅이 하드코딩(org-os/06-agent-work/...) 대신 이 모듈로 경로를 얻는다. 현재 워크스페이스 결정 순서: 1. 환경변수 ORGOS_WORKSPACE (프로젝트명 또는 절대경로) 2. 포인터 파일 /.orgos-workspace (첫 non-comment·non-blank 줄: 프로젝트명) 둘 다 미설정/비어있으면 -> WorkspaceNotSetError 로 중단(과거의 test 프로젝트 하드코딩 기본값은 제거됨). 운영 실행이 조용히 test 워크스페이스로 떨어지는 것을 막는다. 프로젝트 폴더 레이아웃(자기완결): / completion-records// evidence// 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= 를 설정하거나 " f"{os.path.join(ROOT, '.orgos-workspace')} 파일에 프로젝트명을 기록하세요. " "운영 실행(operational runs)은 test 워크스페이스로 조용히 기본 설정될 수 없습니다 " "(set ORGOS_WORKSPACE= or write the project name into " "/.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= 를 설정하거나 /.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")