#!/usr/bin/env python3 """Harness -> Slack notification layer (redact -> format -> deliver). Notifies only the events org-os policy allows: blocker / human-review / critical / digest / task / review. Sensitive data is masked before it ever leaves. Delivery (Claude Code hooks cannot call MCP directly, so two paths): - if $SLACK_WEBHOOK_URL set -> POST directly (fully autonomous, headless-safe) - else -> enqueue to slack-outbox/*.json (main session flushes via mcp__slack__slack_post_message) Usage: notify_slack.py [report.yaml] [--title "..."] [--channel C0BCN9H9ABH] in: task | review | blocker | human-review | critical | digest """ import json import os import re import sys import urllib.request from datetime import datetime ROOT = os.environ.get("CLAUDE_PROJECT_DIR") or os.path.dirname( os.path.dirname(os.path.dirname(os.path.abspath(__file__))) ) DEFAULT_CHANNEL = "C0BCN9H9ABH" # #clean-architecture-전체 (사전 승인) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import _workspace as W # noqa: E402 OUTBOX = W.slack_outbox() # redact-before-slack: mask secrets/PII before anything leaves the harness REDACT = [ (re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+"), "***@masked"), (re.compile(r"xox[baprs]-[A-Za-z0-9-]+"), "***REDACTED***"), (re.compile(r"sk-(ant-)?[A-Za-z0-9._-]{20,}"), "***REDACTED***"), (re.compile(r"AKIA[0-9A-Z]{16}"), "***REDACTED***"), (re.compile(r"gh[opsu]_[A-Za-z0-9]{30,}"), "***REDACTED***"), (re.compile(r"(?i)bearer\s+[A-Za-z0-9._-]{10,}"), "bearer ***REDACTED***"), (re.compile(r"(?i)\b(password|passwd|secret|token|api[_-]?key)\b\s*[:=]\s*\S+"), r"\1: ***REDACTED***"), (re.compile(r"\b[0-9a-fA-F]{32,}\b"), "***REDACTED***"), ] EMOJI = {"task": ":memo:", "review": ":mag:", "blocker": ":rotating_light:", "human-review": ":raising_hand:", "critical": ":red_circle:", "digest": ":bar_chart:", "report": ":round_pushpin:"} def redact(text): for pat, repl in REDACT: text = pat.sub(repl, text) return text def header(): ts = datetime.now().strftime("%Y-%m-%d %H:%M") return f"`repo: {os.path.basename(ROOT)}` · `branch: (no git)` · `{ts}`" def load_doc(report_path): if not report_path or report_path == "-" or not os.path.exists(report_path): return {} import yaml d = yaml.safe_load(open(report_path)) or {} return d if isinstance(d, dict) else {} def load_header(report_path): return load_doc(report_path).get("report-header", {}) def _max_grade(rh): gs = [str(e.get("grade", "")) for e in (rh.get("evidence") or []) if isinstance(e, dict)] gs = [g for g in gs if g.startswith("E")] return max(gs) if gs else None def build_report(doc, title): """템플릿 3(agent-report): 직무 정체성 → BLUF → 근거·산출물 → 결정필요/승인자 → 동료 cc.""" rh = doc.get("report-header", {}) or {} role = doc.get("role-name") or doc.get("role-id") or "-" lens = doc.get("lens") or "-" fam = doc.get("role-id") or doc.get("synthesized-by") or "AGENT" lines = [f":round_pushpin: *[{fam}] {title}*", f"`role: {role} · lens: {lens}` · {header()}", ""] bl = rh.get("bottom-line") conf = rh.get("confidence") or {} cval = conf.get("value", "?") if isinstance(conf, dict) else str(conf) g = _max_grade(rh) if bl: lines += [f"*BLUF* — {str(bl).strip()} (신뢰도: {cval}{' · 근거 ' + g if g else ''})", ""] did = doc.get("findings") or ([doc["work-summary"]] if doc.get("work-summary") else []) \ or ([doc["recommendation"]] if doc.get("recommendation") else []) if did: lines += ["*무엇을 했나 / 근거*"] + [f"• {str(d).strip()}" for d in did[:4]] + [""] outs = doc.get("output-artifacts") or doc.get("linked-reports") or [] if outs: lines += ["*산출물*"] + [f"• `{o.get('uri') if isinstance(o, dict) else o}`" for o in outs[:4]] + [""] dn = rh.get("decision-needed") or {} if isinstance(dn, dict) and dn.get("needed"): lines.append(f":vertical_traffic_light: *결정 필요* — 승인자: *{dn.get('approver', '?')}*") risks = rh.get("risks") or [] if risks: lines.append(f":warning: *리스크* — {str(risks[0]).strip()}") tags = doc.get("tags") or [] if tags: lines += ["", f":handshake: cc {' '.join('*#' + str(t) + '*' for t in tags[:5])}"] return redact("\n".join(lines).rstrip()) def build(event, rh, title): emoji = EMOJI.get(event, ":memo:") label = {"blocker": "Blocker", "human-review": "검토 요청", "critical": "Critical", "digest": "Daily Digest", "review": "리뷰", "task": "작업"}.get(event, event) lines = [f"{emoji} *[{label}] {title}*", header(), ""] bl = rh.get("bottom-line") if bl: lines += ["*핵심(BLUF)*", f"• {str(bl).strip()}", ""] dn = rh.get("decision-needed") or {} if isinstance(dn, dict) and dn.get("needed"): lines += [f"*결정 필요* — 승인자: `{dn.get('approver', '?')}`", ""] risks = rh.get("risks") or [] if risks: lines += ["*리스크*"] + [f"• {r}" for r in risks[:3]] + [""] return redact("\n".join(lines).rstrip()) def deliver(channel, text): webhook = os.environ.get("SLACK_WEBHOOK_URL") if webhook: req = urllib.request.Request(webhook, data=json.dumps({"text": text}).encode(), headers={"Content-Type": "application/json"}) urllib.request.urlopen(req, timeout=10) return "webhook" os.makedirs(OUTBOX, exist_ok=True) stamp = datetime.now().strftime("%Y%m%d-%H%M%S-%f") with open(os.path.join(OUTBOX, f"{stamp}.json"), "w") as f: json.dump({"channel_id": channel, "text": text}, f, ensure_ascii=False, indent=2) return "outbox" def main(): args = [a for a in sys.argv[1:]] if not args: sys.stderr.write("usage: notify_slack.py [report.yaml] [--title ..] [--channel ..]\n") sys.exit(1) event = args[0] report = None title = None channel = DEFAULT_CHANNEL i = 1 while i < len(args): if args[i] == "--title": title = args[i + 1]; i += 2 elif args[i] == "--channel": channel = args[i + 1]; i += 2 else: report = args[i]; i += 1 title = title or (report and os.path.basename(report)) or event if event == "report": text = build_report(load_doc(report), title) else: text = build(event, load_header(report), title) mode = deliver(channel, text) print(f"[notify_slack] {event} -> {mode}\n{text}") if __name__ == "__main__": main()