옵시디언 업무일지 자동화 구현 명세

맥 LaunchAgent 2개 + Claude API로 매일 아침 업무일지를 만들고 초안까지 채우는 시스템 — 다른 환경에서 그대로 재구현할 수 있도록 소스 원문과 함께 정리한 문서

기준일 2026-08-19 대상: Obsidian 볼트 + macOS launchd + Claude(anthropic SDK) 보안: 캘린더 주소·SMB 비밀번호·OAuth 토큰 값은 이 문서에 없음(플레이스홀더 처리)

1개요

평일 아침 07:40, 맥의 launchd(LaunchAgent)가 옵시디언 볼트 ~/LifeVault/work/journal/에 그날치 YYYY-MM-DD 업무일지.md를 자동으로 만든다. 프론트매터, 빈 "## 할 일"·"## 메모" 칸, 그리고 맨 아래에 그달 캘린더 일정 전체(지난 날은 취소선, 오늘은 굵게)가 채워진 상태다. 07:45, 두 번째 LaunchAgent가 그 파일의 "## 할 일" 칸만 골라 Claude API로 초안을 작성해 채운다 — 직전 일지의 미완료 항목, 이번 달 일정, 사내 ERP(아마란스) 색인, 과거 대화 기록(챗볼트) 네 가지를 파이썬이 모아 한 번의 호출로 넘긴다. 결과에는 > 자동초안 … 확인 후 이 줄을 지우면 확정 표식이 붙어, 사람이 출근해서 검토한 뒤 그 줄을 지우면 확정된다. 윈도우 PC의 Obsidian은 이 파일을 복제해서 동기화하는 게 아니라, SMB 공유로 맥의 볼트 원본을 그대로 열어서 본다 — 그래서 두 기기 사이에 충돌이라는 게 애초에 없다.

07:40 뼈대 생성 note.py — 오늘 일지 파일 생성 (이미 있으면 손대지 않음) 07:45 AI 초안 fill.py — '## 할 일' 칸에 자동초안 표식 붙여 채움 출근 후 검토·확정 사람이 초안을 보고 표식 줄을 지우면 확정 언제든 윈도우 열람 SMB로 같은 파일을 Obsidian이 그대로 열람
하루 타임라인 — 뼈대 생성과 AI 초안은 5분 간격의 별개 실행이고, 확정은 사람이 한다

실제로 만들어지는 파일 (구성 예시)

아래는 실제 시스템이 만든 파일의 구조를 보여주기 위한 일반화된 예시다. 실제 업무일지에는 재단의 실제 문서번호·금액·인명이 들어가지만, 이 문서는 구현 명세이므로 그 값들은 전부 OO/자리표시자로 바꿨다 — 형식(프론트매터 → 할 일 → 메모 → 일정)만 보면 된다.

---
title: "업무일지 2026-08-19"
date: 2026-08-19
tags: [work, journal]
type: work-journal
---

# 2026-08-19 (수) 업무일지

## 할 일

> 자동초안 (Claude claude-sonnet-4-6, 검토·확정 필요) — 2026-08-19 07:45. 확인 후 이 줄을 지우면 확정.

- [ ] **OO 정기예금 만기 조치 — 만기 8/27(목)** ⚠ D-8
  - ※ OO은행 OO억 O.OO% (기간 4개월) [[재단사무국-OOO]]「정기예금 신규가입」
  - [ ] 8/20(목)까지 금리 제안서 징구 → 조치방안 기안
  - [ ] 기안 합의·상신 → 해지예상 조회 → 신규 가입 실행
- [ ] **OO 용역 계약 연장 — 8/21(금) 품의서 상신** ⚠ D-2
  - ※ 예년 상신 : '25 [[재단사무국-OOO]] (계약기간 2025.08.21~2026.08.20)
  - [ ] 8/21(금) 품의서 상신

## 메모
-

---

## 8월 일정
- ~~8/17 (월) 10:30 주간업무회의~~
- **8/19 (수) 종일 OO은행 정기예금 만기 (이자율 O.OO%) ← 오늘**
- 8/21 (금) 09:30 OO 건 확인
- 8/24 (월) 10:30 주간업무회의

세 가지 상태 표기가 한 파일 안에 다 나온다: 지난 일정은 ~~취소선~~, 오늘 일정은 **굵게** ← 오늘, 미래 일정은 그대로. "## 할 일"의 자동초안 표식 줄과 그 아래 체크박스는 work_journal_fill.py가 넣은 것이고, "## 메모"와 프론트매터·일정 블록은 work_journal_note.py가 07:40에 만든 뒤로 fill 단계가 절대 건드리지 않는다.

2아키텍처 다이어그램

calendar-url.txt (비밀 600, 본인 값 교체) calendar_ics.py iCal 파서 (표준 lib만) work_journal_note.py 07:40 평일 · notify-wrap 오늘 업무일지.md frontmatter ## 할 일 (자동초안 표식) ## 메모 ## N월 일정(취소선/굵게) 읽기 fetch_events() 최초 생성 (있으면 절대 안 건드림) 직전 영업일 업무일지.md '## 할 일'의 미완료만 아마란스 SQLite (search.db) 최근45일+예년3개년, 민감문서 제외 챗볼트 raw/*/*/*.md 키워드매칭 120일, 5000자 캡 work_journal_fill.py 07:45 평일 · notify-wrap 이어받기 최근+예년 대조 대화 스니펫 ## N월 일정 tail 재추출 (캘린더 재호출 아님) '## 할 일'만 교체 + 초안 표식 (tmp→os.replace 원자적 쓰기) Claude API claude-sonnet-4-6 (구독 OAuth) 1회 호출 (system 6원칙) 초안 텍스트 notify-wrap.sh 성공/실패 판정 (둘 다 감쌈) 디스코드 알림 실패 시에만 (quiet-ok 미매칭) 종료코드 종료코드 SMB 공유 (lifeshare 계정) \\<맥 Tailscale IP>\LifeVault 윈도우 Obsidian .obsidian-win/ (설정 분리) 같은 파일을 그대로 열람/수정 (동기화 아님, 원본은 맥 하나) UNC 마운트
맥 위 LaunchAgent 2개가 서로 다른 시각에 같은 파일을 만들고 채우며, 4종 컨텍스트를 모아 Claude를 1회 호출한다. 완성된 파일은 SMB로 윈도우 Obsidian이 그대로 열람한다(복제가 아니라 공유).

다이어그램에서 놓치기 쉬운 지점 셋:

3디렉토리 구조

맥(원본)

~/LifeVault/ 옵시디언 볼트 루트 ├── work/ 업무 기록 미니 볼트 (자체 CLAUDE.md) │ ├── journal/ 날짜별 업무일지 원본 │ │ └── YYYY-MM-DD 업무일지.md note.py가 생성, fill.py가 '## 할 일'만 편집 │ ├── raw/ 회의 메모 등 날것 (이 시스템은 안 건드림) │ ├── wiki/ 정리본 (이 시스템은 안 건드림) │ └── CLAUDE.md 이 미니볼트의 운영 규약(양식·워크플로우·경계) ├── template/ │ └── 업무일지.md 옵시디언 수동 백업용 템플릿(고유 노트 만들기) └── .obsidian/ 맥 쪽 Obsidian 설정(윈도우와 분리, 5장 참고) ~/.hermes/simple-board/ 자동화 스크립트 본체 ├── work_journal_note.py 뼈대 생성기 (07:40) ├── work_journal_fill.py 할 일 AI 초안 채우기 (07:45) ├── calendar_ics.py 구글 캘린더 비공개 iCal 파서(공용 모듈) └── calendar-url.txt 비밀, mode 600 — 본인 값으로 교체 ~/Library/LaunchAgents/ ├── com.hc.lifevault-work-journal.plist 평일 07:40 → work_journal_note.py └── com.hc.lifevault-work-journal-fill.plist 평일 07:45 → work_journal_fill.py ~/Library/Logs/ ├── lifevault-work-journal.log note.py 표준출력/에러 └── lifevault-work-journal-fill.log fill.py 표준출력/에러 ~/.cokacdir/scripts/ └── notify-wrap.sh 크론잡 감시 래퍼(실패 시 알림, 성공은 조건부 침묵) ~/.config/lifevault-share/ ├── setup.sh SMB 공유 계정·ACL·공유점 설정 └── credential.txt 비밀, mode 600 — 2번째 줄이 lifeshare 비밀번호 ~/AmaranthVault/index/ (선택 — 없으면 자동 폴백) ├── search.db docs(date, doc_num, drafter, title) 테이블 └── sensitive_docs.csv 제외할 문서번호 목록(doc_no 컬럼) ~/ChatVault/raw/<연도>/<월>/*.md (선택 — 없으면 자동 생략) ~/Projects/lexdiff/lib/fc-rag/ └── get-claude-token.sh Claude 구독 OAuth 토큰 헬퍼(계약은 4.4절)

윈도우(SMB로 원본을 여는 쪽 — 복제본 없음)

C:\Users\<win-user>\ ├── lifevault-smb-connect.ps1 cmdkey 자격증명 등록 + 연결 확인 └── .lifevault-smb-cred 자격증명 사본(SID로 ACL 제한) 작업 스케줄러 등록 작업: "LifeVault SMB Connect" 트리거: 로그온 시 / 잠금 해제 시 / 30분 반복 실행 계정: InteractiveToken (대화형 세션 — 이유는 5장 함정 ②) \\<맥의 Tailscale IP>\LifeVault\ 볼트 자체 (맥 원본을 UNC로 직접 마운트) └── .obsidian-win\ 윈도우 전용 Obsidian 설정(Override config folder)
핵심

윈도우 쪽에 "LifeVault 사본"이라고 부를 만한 폴더가 없다. UNC 경로 자체가 곧 볼트이고, 그 안의 파일은 맥 디스크에 있는 바로 그 파일이다. 로컬 복제·git 동기화는 2026-08-12 구조 전환으로 이 용도에서는 완전히 빠졌다.

4부품별 상세

4.1 LaunchAgent — 뼈대 생성 (07:40)~/Library/LaunchAgents/com.hc.lifevault-work-journal.plist

전체 소스 ~/Library/LaunchAgents/com.hc.lifevault-work-journal.plist (31줄)
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.hc.lifevault-work-journal</string>
    <key>ProgramArguments</key>
    <array>
        <string>/bin/bash</string>
        <string>/Users/hc/.cokacdir/scripts/notify-wrap.sh</string>
        <string>lifevault-work-journal</string>
        <string>--quiet-ok</string>
        <string>WORK_JOURNAL_OK</string>
        <string>--</string>
        <string>/usr/bin/python3</string>
        <string>/Users/hc/.hermes/simple-board/work_journal_note.py</string>
    </array>
    <key>StartCalendarInterval</key>
    <array>
        <dict><key>Weekday</key><integer>1</integer><key>Hour</key><integer>7</integer><key>Minute</key><integer>40</integer></dict>
        <dict><key>Weekday</key><integer>2</integer><key>Hour</key><integer>7</integer><key>Minute</key><integer>40</integer></dict>
        <dict><key>Weekday</key><integer>3</integer><key>Hour</key><integer>7</integer><key>Minute</key><integer>40</integer></dict>
        <dict><key>Weekday</key><integer>4</integer><key>Hour</key><integer>7</integer><key>Minute</key><integer>40</integer></dict>
        <dict><key>Weekday</key><integer>5</integer><key>Hour</key><integer>7</integer><key>Minute</key><integer>40</integer></dict>
    </array>
    <key>StandardOutPath</key>
    <string>/Users/hc/Library/Logs/lifevault-work-journal.log</string>
    <key>StandardErrorPath</key>
    <string>/Users/hc/Library/Logs/lifevault-work-journal.log</string>
</dict>
</plist>

4.2 LaunchAgent — 할 일 초안 채우기 (07:45)~/Library/LaunchAgents/com.hc.lifevault-work-journal-fill.plist

전체 소스 ~/Library/LaunchAgents/com.hc.lifevault-work-journal-fill.plist (31줄)
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.hc.lifevault-work-journal-fill</string>
    <key>ProgramArguments</key>
    <array>
        <string>/bin/bash</string>
        <string>/Users/hc/.cokacdir/scripts/notify-wrap.sh</string>
        <string>lifevault-work-journal-fill</string>
        <string>--quiet-ok</string>
        <string>WORK_JOURNAL_FILL_SKIP</string>
        <string>--</string>
        <string>/usr/bin/python3</string>
        <string>/Users/hc/.hermes/simple-board/work_journal_fill.py</string>
    </array>
    <key>StartCalendarInterval</key>
    <array>
        <dict><key>Weekday</key><integer>1</integer><key>Hour</key><integer>7</integer><key>Minute</key><integer>45</integer></dict>
        <dict><key>Weekday</key><integer>2</integer><key>Hour</key><integer>7</integer><key>Minute</key><integer>45</integer></dict>
        <dict><key>Weekday</key><integer>3</integer><key>Hour</key><integer>7</integer><key>Minute</key><integer>45</integer></dict>
        <dict><key>Weekday</key><integer>4</integer><key>Hour</key><integer>7</integer><key>Minute</key><integer>45</integer></dict>
        <dict><key>Weekday</key><integer>5</integer><key>Hour</key><integer>7</integer><key>Minute</key><integer>45</integer></dict>
    </array>
    <key>StandardOutPath</key>
    <string>/Users/hc/Library/Logs/lifevault-work-journal-fill.log</string>
    <key>StandardErrorPath</key>
    <string>/Users/hc/Library/Logs/lifevault-work-journal-fill.log</string>
</dict>
</plist>

4.3 work_journal_note.py — 일지 뼈대 생성기~/.hermes/simple-board/work_journal_note.py

전체 소스 ~/.hermes/simple-board/work_journal_note.py (199줄)
#!/usr/bin/env python3
"""업무일지 자동 생성 — 평일 아침 LifeVault/work/journal/에 그날 일지 한 장.

HC 요청(2026-07-29): 업무일지가 자동으로 만들어지고, 맨 아래에 이번 달
일정이 정리되어 붙기를 원함. 지나간 날은 취소선(~~), 오늘은 굵게.
캘린더 부품은 같은 폴더 calendar_ics.py 재사용, 주소는 시크릿에서만 읽는다.

- 이미 그날 일지가 있으면 절대 건드리지 않는다 (work/journal은 HC의 날것).
- 캘린더를 못 읽어도 일지는 만든다 — 일정 자리에 경고 한 줄만 남긴다.
- 주말·공휴일·휴가(HC 요청 2026-07-29)는 만들지 않는다. 기준은 출퇴근 자동화와
  동일: holidays.KR + ~/attendance/config.json(매년공휴일·추가공휴일) + blocked.txt.

사용: python3 work_journal_note.py [--date YYYY-MM-DD] [--dry-run] [--force]
"""

import argparse
import calendar
import datetime
import os
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)

import calendar_ics  # noqa: E402

URL_FILE = os.path.join(HERE, "calendar-url.txt")
JOURNAL_DIR = os.path.expanduser("~/LifeVault/work/journal")
ATTENDANCE_DIR = os.path.expanduser("~/attendance")

WEEKDAYS = ["월", "화", "수", "목", "금", "토", "일"]


def skip_reason(today):
    """쉬는 날이면 이유 문자열, 일하는 날이면 None. 출퇴근 자동화와 같은 기준."""
    if today.weekday() >= 5:
        return "주말"
    try:
        import holidays
        kr = holidays.KR(years=today.year)
        if today in kr:
            return "공휴일(%s)" % kr.get(today)
    except ImportError:
        pass  # 라이브러리가 없으면 아래 수동 목록만으로 거른다
    try:
        import json
        with open(os.path.join(ATTENDANCE_DIR, "config.json"), encoding="utf-8") as fp:
            cfg = json.load(fp)
        if today.strftime("%m-%d") in cfg.get("매년공휴일", []):
            return "공휴일(설정에 등록된 날)"
        if today.isoformat() in cfg.get("추가공휴일", []):
            return "공휴일(설정에 등록된 날)"
    except (OSError, ValueError):
        pass
    blocked = os.path.join(ATTENDANCE_DIR, "blocked.txt")
    if os.path.exists(blocked):
        for line in open(blocked, encoding="utf-8"):
            token = line.split("#")[0].strip().split()
            if token and token[0] == today.isoformat():
                return "휴가(blocked.txt)"
    return None


def read_url():
    # calendar_ics.read_calendar_url()을 재사용 — calendar-url.txt가 여러 줄이거나
    # 'URL | 표시이름' 파이프 문법을 쓰더라도 첫 URL만 안전하게 뽑아온다(2026-08-15,
    # calendar_note.py 다중 캘린더 확장에 맞춰 3곳의 파서를 공용 헬퍼로 통일).
    # 업무일지는 여전히 첫 번째 캘린더 하나만 사용한다 — 다중 병합은 이번 범위 밖.
    url = calendar_ics.read_calendar_url(URL_FILE)
    if not url:
        raise RuntimeError("주소 파일이 비어 있습니다: " + URL_FILE)
    return url


def fmt_event(ev, day):
    start = datetime.datetime.fromisoformat(ev["start"])
    end = datetime.datetime.fromisoformat(ev["end"])
    if ev["all_day"]:
        last = (end - datetime.timedelta(seconds=1)).date()
        when = "종일" if last == day else "종일 (~%d/%d)" % (last.month, last.day)
    else:
        when = "%02d:%02d" % (start.hour, start.minute)
    title = ev["title"] or "(제목 없음)"
    where = " — %s" % ev["location"] if ev["location"] else ""
    return "%d/%d (%s) %s %s%s" % (
        day.month, day.day, WEEKDAYS[day.weekday()], when, title, where)


def month_schedule_lines(today):
    """이번 달 일정 목록. 지난 날 취소선, 오늘 굵게."""
    first = datetime.datetime(today.year, today.month, 1, tzinfo=calendar_ics.SEOUL)
    n_days = calendar.monthrange(today.year, today.month)[1]
    result = calendar_ics.fetch_events(read_url(), days=n_days, now=first)
    if result.get("error"):
        raise RuntimeError(result["error"])
    events = result.get("events") or []

    by_day = {}
    for ev in events:
        d = datetime.datetime.fromisoformat(ev["start"]).date()
        if d.month != today.month:
            continue
        by_day.setdefault(d, []).append(ev)

    if not by_day:
        return ["- 등록된 일정 없음"]
    lines = []
    for d in sorted(by_day):
        for ev in by_day[d]:
            text = fmt_event(ev, d)
            if d < today:
                lines.append("- ~~%s~~" % text)
            elif d == today:
                lines.append("- **%s** ← 오늘" % text)
            else:
                lines.append("- %s" % text)
    return lines


def build_note(today, schedule_lines):
    date_s = today.isoformat()
    head = [
        "---",
        'title: "업무일지 %s"' % date_s,
        "date: %s" % date_s,
        "tags: [work, journal]",
        "type: work-journal",
        "---",
        "",
        "# %s (%s) 업무일지" % (date_s, WEEKDAYS[today.weekday()]),
        "",
        "## 할 일",
        "- ",
        "",
        "## 메모",
        "- ",
        "",
        "---",
        "",
        "## %d월 일정" % today.month,
    ]
    return "\n".join(head + schedule_lines) + "\n"


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--date", help="YYYY-MM-DD (기본: 오늘)")
    ap.add_argument("--dry-run", action="store_true")
    ap.add_argument("--force", action="store_true", help="주말·공휴일·휴가여도 만든다")
    args = ap.parse_args()

    if args.date:
        today = datetime.date.fromisoformat(args.date)
    else:
        today = datetime.datetime.now(calendar_ics.SEOUL).date()

    if not args.force:
        reason = skip_reason(today)
        if reason:
            print("WORK_JOURNAL_OK 쉬는 날이라 건너뜀: %s" % reason)
            return 0

    path = os.path.join(JOURNAL_DIR, "%s 업무일지.md" % today.isoformat())
    if os.path.exists(path):
        print("WORK_JOURNAL_OK 이미 있음 → 건드리지 않음: %s" % path)
        return 0

    try:
        schedule = month_schedule_lines(today)
        warn = ""
    except Exception as exc:
        msg = str(exc)
        for token in ("http://", "https://"):  # 주소 조각 유출 차단
            if token in msg:
                msg = msg.split(token)[0] + "(주소 생략)"
        schedule = ["> ⚠ 캘린더를 읽지 못했습니다: %s" % msg]
        warn = " (캘린더 실패)"

    note = build_note(today, schedule)
    if args.dry_run:
        print(note)
        print("WORK_JOURNAL_OK (dry-run)%s" % warn)
        return 0

    os.makedirs(JOURNAL_DIR, exist_ok=True)
    tmp = path + ".tmp"
    with open(tmp, "w", encoding="utf-8") as fp:
        fp.write(note)
    os.replace(tmp, path)

    if warn:
        print("WORK_JOURNAL_FAIL 일지는 만들었지만 캘린더 실패: %s" % path)
        return 1
    print("WORK_JOURNAL_OK 생성 → %s" % path)
    return 0


if __name__ == "__main__":
    sys.exit(main())

4.4 work_journal_fill.py — 할 일 AI 초안 채우기~/.hermes/simple-board/work_journal_fill.py

안전장치 4가지

컨텍스트 4종 수집

소스함수범위없을 때
직전 영업일 일지find_prev_journal + todo_body가장 최근 과거 날짜의 파일 하나, "## 할 일"에서 미완료(- [ ])만"(직전 일지의 할 일 없음)"
이번 달 일정오늘 파일 자체의 tail 정규식 추출note.py가 이미 써 놓은 "## N월 일정" 블록 그대로 (재호출 없음)"(이번 달 일정 블록 없음)"
아마란스 SQLiteamaranth_context최근 45일(RECENT_DAYS) 전체 + 과거 3개년(PRIOR_YEARS) 같은 시기 전후 14/31일(PRIOR_BACK/FWD), 각 상한 150/60건, sensitive_docs.csv 제외"(아마란스 색인 없음 — 이 부분은 비워 둠)"
챗볼트chat_context + extract_keywords일정·직전할일 텍스트에서 한글 2~12자 단어 빈도순 상위 25개를 키워드로 뽑아, 최근 120일(CHAT_DAYS) 내 매칭 파일에서 스니펫, 총 5000자(CHAT_CHAR_CAP) 캡"(챗볼트 검색 생략)" / "(관련 챗볼트 대화 없음)"

Claude 호출 1회

anthropic SDK를 API 키가 아니라 구독 OAuth 토큰으로 쓴다 — Anthropic(auth_token=get_token(), default_headers={"anthropic-beta": "oauth-2025-04-20"}). get_token()은 get-claude-token.sh를 서브프로세스로 불러 stdout을 받는데, 그 스크립트의 계약은 "claude setup-token으로 발급한 유효한 OAuth 토큰 문자열을 한 줄 출력한다, 만료됐으면 TOKEN_EXPIRED를 출력한다"이다. 토큰이 비었거나 TOKEN_EXPIRED면 즉시 RuntimeError로 죽는다. 모델은 WORK_JOURNAL_FILL_MODEL 환경변수로 바꿀 수 있고 기본값은 claude-sonnet-4-6.

시스템 프롬프트의 작성 규칙 6가지

코드에 박힌 SYSTEM_RULES 문자열이 실제 판단 로직이다. 여섯 규칙을 풀어 쓰면:

  1. 직전 일지 미완료만 이어받기. 완료(- [x])는 옮기지 않는다 — 완료 기록은 그날 일지 자체에 남아 있으니 중복시키지 않는다는 뜻. 이어받은 항목의 근거(※) 메모는 보존한다.
  2. 일정에서 마감 역산. "정기예금 만기"처럼 이번 달 일정에 날짜가 박힌 이벤트가 있으면, 그 앞에 필요한 준비 작업(금리 제안서 징구 → 해지예상 조회 → 기안 → 합의)을 하위 체크박스로 미리 펼쳐 둔다.
  3. 이미 처리된 건은 취소선 처리. "최근 아마란스 문서"에 이미 상신된 문서번호가 보이면, 그 할 일은 - [x] ~~취소선~~ → [[재단사무국-번호]] (날짜, 기안자) 형태로 정리해 완료 처리한다.
  4. 예년 대조로 누락 탐지. "예년 같은 시기 아마란스 문서"를 보고 매년 반복되는데 올해 아직 안 보이는 정례 업무를 새 할 일로 올린다. 근거로 ※ 예년 상신 : …을 반드시 붙인다.
  5. 지어내지 않는다. 문서번호·금액·날짜·이자율은 제공된 데이터에 실제로 있는 값만 쓴다. 확인이 안 되면 단정하지 말고 (대조 필요)라고 적는다.
  6. 임박한 것을 위로. 다음 달 마감이라도 준비 시점을 계산해 미리 걸어 둔다.

이 밖에 출력 형식 규칙(제목 줄·코드펜스 금지, 체크박스는 - [ ]/하위는 - [ ], 근거는 - ※ …, 아마란스 인용은 반드시 [[재단사무국-번호]] 위키링크)과 경계 규칙("업무" 일지이므로 개인 일기·신앙·건강은 절대 넣지 않는다, 출력은 사람이 검토할 초안이라는 전제)이 시스템 프롬프트에 함께 들어 있다 — 전문은 아래 소스에 그대로 있다.

되쓰기

모델 출력에서 제목 줄·코드펜스를 걷어낸(clean_output) 다음, "## 할 일" 바로 다음 줄에 > 자동초안 (Claude <모델>, 검토·확정 필요) — <타임스탬프>. 확인 후 이 줄을 지우면 확정. 표식을 붙이고, 원본의 그 앞뒤 줄(frontmatter~할일제목, 다음 섹션부터 끝)은 그대로 이어붙여 원자적으로 저장한다. HC가 표식 줄을 지우면 "확정"이라는 상태는 코드가 아니라 사람의 편집 행위 자체로 표현된다.

전체 소스 ~/.hermes/simple-board/work_journal_fill.py (381줄)
#!/usr/bin/env python3
"""업무일지 '할 일' 자동 초안 — work_journal_note.py가 뼈대를 만든 뒤,
그날 일지의 '## 할 일' 칸을 Claude(구독 OAuth)로 채운다.

HC 요청(2026-08-07): 아침 자동 생성 때 '할 일'도 자동으로 채워지게.
  - 범위: 전체 워크플로우 + '자동초안' 표식 (사람이 검토·확정) + 디스코드 알림
  - 런타임: 맥 파이썬 에이전트 (anthropic SDK + LexDiff OAuth 토큰). 컨텍스트는 파이썬이 수집.

워크플로우(work/CLAUDE.md '할 일 채우기')를 파이썬이 재료로 모아 Claude 1회 호출로 옮긴다:
  1) 직전 일지 '## 할 일'의 미완료(- [ ])만 이어받는다.
  2) 이번 달 일정에서 마감 역산.
  3) 아마란스(공식 절차) — 최근 문서 + 예년 같은 시기 문서를 색인에서 뽑아 넘긴다.
  4) 예년 대조로 정례 업무 누락/기상신 탐지.
  5) 챗볼트(HC가 이야기·결정한 것) — 일정·직전할일에서 뽑은 키워드로 최근 대화 검색.
  6) 확인 안 된 숫자는 단정하지 않는다.

안전장치:
  - 그날 일지가 없으면(쉬는 날·미생성) 아무것도 안 한다 -> SKIP.
  - '## 할 일'이 이미 채워져 있으면 건드리지 않는다(멱등) -> SKIP. (--force로 덮어쓰기)
  - '## 할 일' 칸만 교체. frontmatter·'## 메모'·일정·HC 문장은 절대 건드리지 않는다.
  - Claude 실패/빈 응답이면 원본 그대로 두고 실패 코드 반환(notify-wrap이 디스코드 알림).
  - 아마란스 민감문서(sensitive_docs.csv)는 컨텍스트에서 제외.

사용: python3 work_journal_fill.py [--date YYYY-MM-DD] [--dry-run] [--force]
"""

import argparse
import csv
import datetime
import glob
import os
import re
import sqlite3
import subprocess
import sys
from zoneinfo import ZoneInfo

SEOUL = ZoneInfo("Asia/Seoul")
HOME = os.path.expanduser("~")
JOURNAL_DIR = os.path.join(HOME, "LifeVault/work/journal")
AMARANTH_DB = os.path.join(HOME, "AmaranthVault/index/search.db")
SENSITIVE_CSV = os.path.join(HOME, "AmaranthVault/index/sensitive_docs.csv")
CHAT_RAW = os.path.join(HOME, "ChatVault/raw")
TOKEN_HELPER = os.path.join(HOME, "Projects/lexdiff/lib/fc-rag/get-claude-token.sh")
MODEL = os.environ.get("WORK_JOURNAL_FILL_MODEL", "claude-sonnet-4-6")
IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude."

RECENT_DAYS = 45
PRIOR_BACK, PRIOR_FWD = 14, 31
PRIOR_YEARS = 3
AMA_RECENT_CAP = 150
AMA_PRIOR_CAP = 60
CHAT_DAYS = 120
CHAT_CHAR_CAP = 5000
TITLE_CAP = 64

STOPWORDS = {
    "업무", "회의", "오늘", "일정", "확인", "완료", "작성", "보고", "재단",
    "사무국", "지원팀", "사업팀", "관련", "진행", "예정", "저녁", "식사",
    "교육", "주간업무회의", "종일",
}

SYSTEM_RULES = """너는 신용카드사회공헌재단에서 일하는 서현철(HC)의 '업무일지 할 일' 초안을 작성한다.
아래 데이터는 파이썬이 미리 모아 준 것이다. 이걸 근거로 오늘자 '## 할 일' 칸의 본문(마크다운)만 출력한다.

[출력 형식]
- '## 할 일' 제목 줄이나 코드펜스는 절대 붙이지 말고, 항목 본문만 출력한다.
- 한국어. 최상위 항목은 '- [ ]' 체크박스. 큰 건은 굵게(**...**) + 하위 '  - [ ]' 세부 체크박스로 편다.
- 근거·메모는 '  - ※ ...' 줄로 남긴다. 불확실한 것은 '(대조 필요)'로 표시한다.
- 아마란스 문서는 반드시 '[[재단사무국-번호]]' 위키링크로 인용한다.

[작성 규칙 — 반드시 지킨다]
1. 직전 업무일지의 '## 할 일'에서 미완료(- [ ])만 이어받는다. 완료(- [x])는 옮기지 않는다. 이어받은 항목의 ※ 근거 메모는 보존한다.
2. '이번 달 일정'에서 마감을 역산해 준비 작업을 하위 체크박스로 편다(예: 정기예금 만기 -> 금리 제안서 징구·해지예상 조회·기안·합의).
3. '최근 아마란스 문서'에 이미 상신된 건이 보이면, 그 할 일은 '- [x] ~~취소선~~ -> [[재단사무국-번호]] (날짜, 기안자)' 형태로 정리한다.
4. '예년 같은 시기 아마란스 문서'와 대조해, 매년 반복되는데 올해 아직 안 보이는 정례 업무를 할 일로 올린다. 근거로 '  - ※ 예년 상신 : ...'을 붙인다.
5. 절대 지어내지 않는다. 문서번호·금액·날짜·이자율은 제공된 데이터에 실제로 있는 것만 쓴다. 확인 안 되면 단정하지 말고 '(대조 필요)'라고 적는다.
6. 임박한 것을 위로. 다음 달 마감도 준비 시점을 계산해 미리 걸어둔다.

[경계]
- 이건 재단 '업무' 일지다. 개인 일기·신앙·건강 등은 절대 넣지 않는다.
- 네 출력은 사람이 검토할 '초안'이다. 애매하면 확정 대신 '(대조 필요)'로 남겨라.
- 데이터가 비어 있는 항목(예: 아마란스 색인 없음)은 근거 없이 지어내지 말고, 직전 일지 이월 + 일정 역산만으로 작성한다."""


def log(msg):
    print(msg, flush=True)


JOURNAL_RE = re.compile(r"(\d{4}-\d{2}-\d{2}) 업무일지\.md$")


def journal_path(day):
    return os.path.join(JOURNAL_DIR, "%s 업무일지.md" % day.isoformat())


def split_todo(text):
    lines = text.splitlines()
    i_todo = None
    for i, l in enumerate(lines):
        if l.strip() == "## 할 일":
            i_todo = i
            break
    if i_todo is None:
        return None
    i_next = None
    for j in range(i_todo + 1, len(lines)):
        if lines[j].startswith("## "):
            i_next = j
            break
    if i_next is None:
        return None
    return lines, i_todo, i_next


def todo_body(text):
    s = split_todo(text)
    if not s:
        return None
    lines, i_todo, i_next = s
    return "\n".join(lines[i_todo + 1:i_next]).strip()


def todo_is_empty(body):
    for l in body.splitlines():
        s = l.strip()
        if s and s not in ("-", "- [ ]", "-[]"):
            return False
    return True


def find_prev_journal(today):
    best = None
    for p in glob.glob(os.path.join(JOURNAL_DIR, "*.md")):
        m = JOURNAL_RE.search(os.path.basename(p))
        if not m:
            continue
        d = datetime.date.fromisoformat(m.group(1))
        if d < today and (best is None or d > best[0]):
            best = (d, p)
    return best


def load_sensitive():
    out = set()
    if not os.path.exists(SENSITIVE_CSV):
        return out
    try:
        with open(SENSITIVE_CSV, encoding="utf-8") as fp:
            for row in csv.DictReader(fp):
                dn = (row.get("doc_no") or "").strip()
                if dn:
                    out.add(dn)
    except Exception:
        pass
    return out


def _fmt_rows(rows, sensitive):
    out = []
    for d, num, drafter, title in rows:
        num = (num or "").strip()
        if num in sensitive:
            continue
        title = (title or "").replace("\n", " ").strip()
        if len(title) > TITLE_CAP:
            title = title[:TITLE_CAP] + "…"
        out.append("%s | %s | %s | %s" % (d, num, (drafter or "").strip(), title))
    return out


def amaranth_context(today, sensitive):
    if not os.path.exists(AMARANTH_DB):
        return "(아마란스 색인 없음 — 이 부분은 비워 둠)"
    con = sqlite3.connect("file:%s?mode=ro" % AMARANTH_DB, uri=True)
    parts = []
    try:
        since = (today - datetime.timedelta(days=RECENT_DAYS)).isoformat()
        rows = con.execute(
            "select date(date), doc_num, drafter, title from docs "
            "where date(date) >= ? order by date desc limit ?",
            (since, AMA_RECENT_CAP)).fetchall()
        lines = _fmt_rows(rows, sensitive)
        parts.append("### 최근 아마란스 문서 (최근 %d일, 최신순)\n%s"
                     % (RECENT_DAYS, "\n".join(lines) if lines else "(없음)"))

        prior = []
        for k in range(1, PRIOR_YEARS + 1):
            y = today.year - k
            try:
                anchor = datetime.date(y, today.month, today.day)
            except ValueError:
                anchor = datetime.date(y, today.month, 28)
            lo = (anchor - datetime.timedelta(days=PRIOR_BACK)).isoformat()
            hi = (anchor + datetime.timedelta(days=PRIOR_FWD)).isoformat()
            rows = con.execute(
                "select date(date), doc_num, drafter, title from docs "
                "where date(date) between ? and ? order by date limit ?",
                (lo, hi, AMA_PRIOR_CAP)).fetchall()
            lines = _fmt_rows(rows, sensitive)
            prior.append("#### %d년 (%s ~ %s)\n%s"
                         % (y, lo, hi, "\n".join(lines) if lines else "(없음)"))
        parts.append("### 예년 같은 시기 아마란스 문서 (정례 업무 대조용)\n"
                     + "\n\n".join(prior))
    finally:
        con.close()
    return "\n\n".join(parts)


def extract_keywords(*texts):
    freq = {}
    for t in texts:
        for w in re.findall(r"[가-힣]{2,}", t or ""):
            if w in STOPWORDS or len(w) > 12:
                continue
            freq[w] = freq.get(w, 0) + 1
    return [w for w, _ in sorted(freq.items(), key=lambda kv: -kv[1])[:25]]


def chat_context(today, keywords):
    if not os.path.isdir(CHAT_RAW) or not keywords:
        return "(챗볼트 검색 생략)"
    since = today - datetime.timedelta(days=CHAT_DAYS)
    kw = set(keywords)
    hits = []
    for p in glob.glob(os.path.join(CHAT_RAW, "*", "*", "*.md")):
        base = os.path.basename(p)
        try:
            fd = datetime.date.fromisoformat(base[:10])
        except ValueError:
            continue
        if fd < since:
            continue
        try:
            with open(p, encoding="utf-8", errors="ignore") as fp:
                body = fp.read()
        except OSError:
            continue
        matched = [w for w in kw if w in body]
        if not matched:
            continue
        idx = min((body.find(w) for w in matched if body.find(w) >= 0), default=0)
        snip = body[max(0, idx - 80): idx + 320].replace("\n", " ").strip()
        rel = os.path.relpath(p, CHAT_RAW)
        hits.append((fd, rel, snip, len(matched)))
    if not hits:
        return "(관련 챗볼트 대화 없음)"
    hits.sort(key=lambda h: (h[3], h[0].isoformat()), reverse=True)
    out, total = [], 0
    for fd, rel, snip, _ in hits:
        block = "- [%s] %s\n  %s" % (fd.isoformat(), rel, snip)
        if total + len(block) > CHAT_CHAR_CAP:
            break
        out.append(block)
        total += len(block)
    return "검색 키워드: %s\n\n%s" % (", ".join(sorted(kw)), "\n".join(out))


def get_token():
    r = subprocess.run(["bash", TOKEN_HELPER], capture_output=True, text=True, timeout=25)
    tok = (r.stdout or "").strip()
    if not tok or tok == "TOKEN_EXPIRED":
        raise RuntimeError("Claude OAuth 토큰 만료/없음 — 맥에서 claude setup-token 재로그인 필요")
    return tok


def call_claude(user_content, max_tokens=5000):
    from anthropic import Anthropic
    client = Anthropic(auth_token=get_token(),
                       default_headers={"anthropic-beta": "oauth-2025-04-20"},
                       max_retries=2, timeout=180)
    resp = client.messages.create(
        model=MODEL, max_tokens=max_tokens,
        system=[{"type": "text", "text": IDENTITY},
                {"type": "text", "text": SYSTEM_RULES}],
        messages=[{"role": "user", "content": user_content}])
    return "".join(getattr(b, "text", "") for b in resp.content).strip()


def clean_output(text):
    out = []
    for l in text.strip().splitlines():
        s = l.strip()
        if s == "## 할 일":
            continue
        if s.startswith("~~~") or s.startswith("```"):
            continue
        out.append(l.rstrip())
    while out and not out[0].strip():
        out.pop(0)
    while out and not out[-1].strip():
        out.pop()
    return out


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--date", help="YYYY-MM-DD (기본: 오늘)")
    ap.add_argument("--dry-run", action="store_true")
    ap.add_argument("--force", action="store_true")
    args = ap.parse_args()

    today = (datetime.date.fromisoformat(args.date) if args.date
             else datetime.datetime.now(SEOUL).date())

    path = journal_path(today)
    if not os.path.exists(path):
        log("WORK_JOURNAL_FILL_SKIP 그날 일지 없음(쉬는 날/미생성): %s" % path)
        return 0

    with open(path, encoding="utf-8") as fp:
        text = fp.read()
    s = split_todo(text)
    if not s:
        log("WORK_JOURNAL_FILL_SKIP 할 일/다음 섹션 못 찾음 — 양식 확인: %s" % path)
        return 0
    lines, i_todo, i_next = s
    body = "\n".join(lines[i_todo + 1:i_next]).strip()
    if not todo_is_empty(body) and not args.force:
        log("WORK_JOURNAL_FILL_SKIP 이미 채워져 있음(멱등): %s" % path)
        return 0

    prev = find_prev_journal(today)
    prev_todo = todo_body(open(prev[1], encoding="utf-8").read()) if prev else None
    prev_desc = ("직전 일지 %s 의 할 일:\n%s" % (prev[0].isoformat(), prev_todo)
                 if prev_todo else "(직전 일지의 할 일 없음)")

    tail = "\n".join(lines[i_next:])
    m_sched = re.search(r"^##\s*\d+월 일정.*", tail, re.M)
    sched_block = tail[m_sched.start():].strip() if m_sched else "(이번 달 일정 블록 없음)"

    sensitive = load_sensitive()
    ama = amaranth_context(today, sensitive)
    keywords = extract_keywords(sched_block, prev_todo or "")
    chat = chat_context(today, keywords)

    user_content = (
        "# 오늘 날짜\n%s (%s)\n\n"
        "# 직전 업무일지 할 일 (미완료만 이어받을 것)\n%s\n\n"
        "# 이번 달 일정 (마감 역산용)\n%s\n\n"
        "# 아마란스 색인 (공식 절차·기상신·정례 대조)\n%s\n\n"
        "# 챗볼트 (HC가 이야기·결정한 것)\n%s\n\n"
        "위 데이터로 오늘자 '## 할 일' 본문 마크다운만 작성하라."
        % (today.isoformat(), "월화수목금토일"[today.weekday()],
           prev_desc, sched_block, ama, chat))

    draft = call_claude(user_content)
    model_lines = clean_output(draft)
    if not model_lines:
        log("WORK_JOURNAL_FILL_FAIL Claude 응답 비어 있음 — 원본 유지: %s" % path)
        return 1

    stamp = datetime.datetime.now(SEOUL).strftime("%Y-%m-%d %H:%M")
    marker = ("> 자동초안 (Claude %s, 검토·확정 필요) — %s. 확인 후 이 줄을 지우면 확정."
              % (MODEL, stamp))
    new_lines = lines[:i_todo + 1] + ["", marker, ""] + model_lines + [""] + lines[i_next:]
    out_text = "\n".join(new_lines).rstrip("\n") + "\n"

    n_tasks = sum(1 for l in model_lines if l.lstrip().startswith(("- [ ]", "- [x]")))
    if args.dry_run:
        log("----- DRY RUN 초안 미리보기 -----")
        log("\n".join(["## 할 일", "", marker, ""] + model_lines))
        log("----- /DRY RUN -----")
        log("WORK_JOURNAL_FILL_OK (dry-run) 항목 %d개, keywords=%d, prev=%s"
            % (n_tasks, len(keywords), prev[0].isoformat() if prev else "없음"))
        return 0

    tmp = path + ".tmp"
    with open(tmp, "w", encoding="utf-8") as fp:
        fp.write(out_text)
    os.replace(tmp, path)
    log("WORK_JOURNAL_FILL_OK 초안 작성(검토 요망) 항목 %d개 -> %s" % (n_tasks, path))
    return 0


if __name__ == "__main__":
    try:
        sys.exit(main())
    except Exception as exc:
        log("WORK_JOURNAL_FILL_FAIL 예외: %s: %s" % (type(exc).__name__, exc))
        sys.exit(1)

4.5 calendar_ics.py — 구글 캘린더 비공개 iCal 파서~/.hermes/simple-board/calendar_ics.py

전체 소스 ~/.hermes/simple-board/calendar_ics.py (753줄)
"""calendar_ics.py

Google Calendar 비공개 iCal 주소(.ics)를 내려받아 파싱하고,
지정한 날짜 구간의 일정 목록을 dict로 돌려주는 데이터 계층 모듈.

- 표준 라이브러리만 사용 (urllib.request, datetime, zoneinfo, re, calendar).
- Python 3.9 호환 (match문·`X | Y` 타입힌트 등 3.10+ 문법 금지).
- 최종 출력 타임존은 Asia/Seoul.
- 네트워크 실패·파싱 실패는 예외를 던지지 않고 반환 dict의 "error" 필드로 알린다.

공개 API:
    fetch_events(url, days=7, now=None, timeout=15)
    parse_ics(text, days=7, now=None)
"""

import calendar
import re
import urllib.error
import urllib.request
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo

SEOUL = ZoneInfo("Asia/Seoul")

# RRULE 전개 시 후보 회차 안전 상한 (무한/장기 반복 일정 보호)
_MAX_RRULE_CANDIDATES = 1000

_WEEKDAY_MAP = {"MO": 0, "TU": 1, "WE": 2, "TH": 3, "FR": 4, "SA": 5, "SU": 6}

_BYDAY_RE = re.compile(r"^([+-]?\d+)?(MO|TU|WE|TH|FR|SA|SU)$")

_DURATION_RE = re.compile(
    r"^([+-]?)P(?:(\d+)W)?(?:(\d+)D)?"
    r"(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$"
)


# ---------------------------------------------------------------------------
# 라인 언폴딩 / 프로퍼티 파싱
# ---------------------------------------------------------------------------

def _unfold(text):
    """RFC 5545 라인 언폴딩. 다음 줄이 스페이스/탭으로 시작하면 이전 줄에 이어붙인다."""
    text = text.replace("\r\n", "\n").replace("\r", "\n")
    raw_lines = text.split("\n")
    lines = []
    for line in raw_lines:
        if line[:1] in (" ", "\t"):
            if lines:
                lines[-1] += line[1:]
            else:
                lines.append(line[1:])
        else:
            lines.append(line)
    return lines


def _parse_property_line(line):
    """"NAME;PARAM=VAL;...:VALUE" 형태의 한 줄을 (name, params, value)로 분해.
    값 안의 콜론(:)은 보존하기 위해 "첫 번째 (따옴표 밖) 콜론"에서만 분리한다."""
    idx = None
    in_quotes = False
    for i, ch in enumerate(line):
        if ch == '"':
            in_quotes = not in_quotes
        elif ch == ":" and not in_quotes:
            idx = i
            break
    if idx is None:
        return None
    head = line[:idx]
    value = line[idx + 1:]
    segs = head.split(";")
    name = segs[0].strip().upper()
    if not name:
        return None
    params = {}
    for seg in segs[1:]:
        if "=" in seg:
            k, v = seg.split("=", 1)
            v = v.strip()
            if len(v) >= 2 and v.startswith('"') and v.endswith('"'):
                v = v[1:-1]
            params[k.strip().upper()] = v
    return name, params, value


def _unescape_text(s):
    """SUMMARY/LOCATION 등 TEXT 값의 이스케이프 해제: \\, \\; \\, \\n"""
    if not s:
        return ""
    out = []
    i = 0
    n = len(s)
    while i < n:
        ch = s[i]
        if ch == "\\" and i + 1 < n:
            nxt = s[i + 1]
            if nxt in ("n", "N"):
                out.append("\n")
                i += 2
                continue
            if nxt in (",", ";", "\\"):
                out.append(nxt)
                i += 2
                continue
            out.append(ch)
            i += 1
            continue
        out.append(ch)
        i += 1
    return "".join(out)


# ---------------------------------------------------------------------------
# 날짜/시간 파싱
# ---------------------------------------------------------------------------

def _parse_datetime_value(value, params):
    """DTSTART/DTEND/EXDATE/RECURRENCE-ID/UNTIL 공통 값 파서.
    반환: ("date", date) 또는 ("datetime", Asia/Seoul aware datetime)"""
    value = (value or "").strip()
    if params.get("VALUE") == "DATE" or (len(value) == 8 and value.isdigit()):
        d = datetime.strptime(value[:8], "%Y%m%d").date()
        return "date", d
    if value.endswith("Z"):
        dt = datetime.strptime(value, "%Y%m%dT%H%M%SZ")
        dt = dt.replace(tzinfo=ZoneInfo("UTC"))
        return "datetime", dt.astimezone(SEOUL)
    tzid = params.get("TZID")
    dt = datetime.strptime(value, "%Y%m%dT%H%M%S")
    if tzid:
        try:
            tz = ZoneInfo(tzid)
        except Exception:
            tz = SEOUL
        dt = dt.replace(tzinfo=tz)
        return "datetime", dt.astimezone(SEOUL)
    dt = dt.replace(tzinfo=SEOUL)
    return "datetime", dt


def _to_comparable(val, val_type):
    """date/datetime 표현을 비교·연산 가능한 Asia/Seoul aware datetime으로 통일."""
    if val_type == "date":
        return datetime(val.year, val.month, val.day, 0, 0, 0, tzinfo=SEOUL)
    return val


def _as_date(val, val_type):
    if val_type == "date":
        return val
    return val.date()


def _parse_duration(s):
    m = _DURATION_RE.match((s or "").strip())
    if not m:
        return timedelta(0)
    sign, weeks, days, hours, minutes, seconds = m.groups()
    total = timedelta(
        weeks=int(weeks) if weeks else 0,
        days=int(days) if days else 0,
        hours=int(hours) if hours else 0,
        minutes=int(minutes) if minutes else 0,
        seconds=int(seconds) if seconds else 0,
    )
    if sign == "-":
        total = -total
    return total


# ---------------------------------------------------------------------------
# 날짜 산술 헬퍼 (월/년 반복용)
# ---------------------------------------------------------------------------

def _add_months_ym(year, month, add):
    total = (month - 1) + add
    y = year + total // 12
    m = total % 12 + 1
    return y, m


def _add_months_date(d, months):
    y, m = _add_months_ym(d.year, d.month, months)
    day = min(d.day, calendar.monthrange(y, m)[1])
    return date(y, m, day)


def _add_years_date(d, years):
    y = d.year + years
    m = d.month
    day = min(d.day, calendar.monthrange(y, m)[1])
    return date(y, m, day)


def _nth_weekday_of_month(year, month, weekday, ordinal):
    """weekday: 0=월 ... 6=일. ordinal>0: n번째, ordinal<0: 뒤에서 n번째."""
    days_in_month = calendar.monthrange(year, month)[1]
    if ordinal > 0:
        first = date(year, month, 1)
        offset = (weekday - first.weekday()) % 7
        day = 1 + offset + (ordinal - 1) * 7
        if day > days_in_month:
            return None
        return date(year, month, day)
    if ordinal < 0:
        last = date(year, month, days_in_month)
        offset = (last.weekday() - weekday) % 7
        day = days_in_month - offset + (ordinal + 1) * 7
        if day < 1:
            return None
        return date(year, month, day)
    return None


def _shift(val, val_type, days):
    return val + timedelta(days=days)


def _combine(d, dtstart_val, dtstart_type):
    if dtstart_type == "date":
        return d
    return datetime(
        d.year, d.month, d.day,
        dtstart_val.hour, dtstart_val.minute, dtstart_val.second,
        tzinfo=dtstart_val.tzinfo,
    )


# ---------------------------------------------------------------------------
# RRULE
# ---------------------------------------------------------------------------

def _parse_rrule(value):
    parts = {}
    for chunk in (value or "").split(";"):
        if "=" in chunk:
            k, v = chunk.split("=", 1)
            parts[k.strip().upper()] = v.strip()
    return parts


def _parse_byday(byday_str):
    result = []
    for tok in (byday_str or "").split(","):
        tok = tok.strip().upper()
        if not tok:
            continue
        m = _BYDAY_RE.match(tok)
        if not m:
            continue
        ord_part, wd = m.groups()
        ordinal = int(ord_part) if ord_part else None
        result.append((ordinal, _WEEKDAY_MAP[wd]))
    return result


def _jump_start_index(freq, interval, dtstart_type, dtstart_val, byday, range_start):
    """COUNT가 없는(=사실상 무한 반복) 룰에 한해, range_start 근처까지 안전하게
    건너뛸 시작 인덱스를 계산한다 (수년 전 시작한 매일/매주 반복 일정도 빠르게 처리)."""
    base_date = _as_date(dtstart_val, dtstart_type)
    target_date = _as_date(range_start, "datetime")
    if target_date <= base_date:
        return 0
    if freq == "DAILY":
        idx = (target_date - base_date).days // interval
    elif freq == "WEEKLY" and not byday:
        idx = (target_date - base_date).days // (interval * 7)
    elif freq == "WEEKLY" and byday:
        week0 = base_date - timedelta(days=base_date.weekday())
        targetweek = target_date - timedelta(days=target_date.weekday())
        idx = ((targetweek - week0).days // 7) // interval
    elif freq == "MONTHLY":
        diff_months = (target_date.year - base_date.year) * 12 + (target_date.month - base_date.month)
        idx = diff_months // interval
    elif freq == "YEARLY":
        idx = (target_date.year - base_date.year) // interval
    else:
        idx = 0
    return max(0, idx - 2)  # 여유 버퍼 2주기


def _iter_rrule_candidates(dtstart_type, dtstart_val, rrule_params, start_idx=0):
    freq = (rrule_params.get("FREQ") or "").upper()
    interval = int(rrule_params.get("INTERVAL") or 1)
    if interval <= 0:
        interval = 1
    byday = _parse_byday(rrule_params["BYDAY"]) if rrule_params.get("BYDAY") else None
    base_date = _as_date(dtstart_val, dtstart_type)

    if freq == "DAILY":
        idx = start_idx
        while True:
            yield _shift(dtstart_val, dtstart_type, idx * interval)
            idx += 1
    elif freq == "WEEKLY":
        if byday:
            weekdays = sorted(set(wd for _, wd in byday))
            week0_monday = base_date - timedelta(days=base_date.weekday())
            week_idx = start_idx
            while True:
                week_start = week0_monday + timedelta(weeks=week_idx * interval)
                for wd in weekdays:
                    d = week_start + timedelta(days=wd)
                    if d >= base_date:
                        yield _combine(d, dtstart_val, dtstart_type)
                week_idx += 1
        else:
            idx = start_idx
            while True:
                yield _shift(dtstart_val, dtstart_type, idx * interval * 7)
                idx += 1
    elif freq == "MONTHLY":
        if byday:
            month_idx = start_idx
            while True:
                year, month = _add_months_ym(base_date.year, base_date.month, month_idx * interval)
                candidates = []
                for ordinal, wd in byday:
                    d = _nth_weekday_of_month(year, month, wd, ordinal if ordinal is not None else 1)
                    if d and d >= base_date:
                        candidates.append(d)
                for d in sorted(candidates):
                    yield _combine(d, dtstart_val, dtstart_type)
                month_idx += 1
        else:
            idx = start_idx
            while True:
                d = _add_months_date(base_date, idx * interval)
                yield _combine(d, dtstart_val, dtstart_type)
                idx += 1
    elif freq == "YEARLY":
        idx = start_idx
        while True:
            d = _add_years_date(base_date, idx * interval)
            yield _combine(d, dtstart_val, dtstart_type)
            idx += 1
    else:
        return


def _expand_rrule(dtstart_type, dtstart_val, rrule_params, range_start, range_end):
    freq = (rrule_params.get("FREQ") or "").upper()
    if freq not in ("DAILY", "WEEKLY", "MONTHLY", "YEARLY"):
        # 미지원 FREQ(예: SECONDLY/MINUTELY/HOURLY) -> 원본 회차만 그대로 취급
        return [dtstart_val]

    interval = int(rrule_params.get("INTERVAL") or 1)
    if interval <= 0:
        interval = 1
    count = int(rrule_params["COUNT"]) if rrule_params.get("COUNT") else None

    until_cmp = None
    if rrule_params.get("UNTIL"):
        u_type, u_val = _parse_datetime_value(rrule_params["UNTIL"], {})
        until_cmp = _to_comparable(u_val, u_type)
        if u_type == "date" and dtstart_type == "datetime":
            until_cmp = until_cmp + timedelta(hours=23, minutes=59, seconds=59)

    byday = _parse_byday(rrule_params["BYDAY"]) if rrule_params.get("BYDAY") else None
    range_end_cmp = _to_comparable(range_end, "datetime")

    occurrences = []
    if count is not None:
        n = 0
        for occ in _iter_rrule_candidates(dtstart_type, dtstart_val, rrule_params, start_idx=0):
            n += 1
            if n > count or n > _MAX_RRULE_CANDIDATES:
                break
            occ_cmp = _to_comparable(occ, dtstart_type)
            if until_cmp is not None and occ_cmp > until_cmp:
                break
            if occ_cmp > range_end_cmp:
                break
            occurrences.append(occ)
    else:
        start_idx = _jump_start_index(freq, interval, dtstart_type, dtstart_val, byday, range_start)
        steps = 0
        for occ in _iter_rrule_candidates(dtstart_type, dtstart_val, rrule_params, start_idx=start_idx):
            steps += 1
            if steps > _MAX_RRULE_CANDIDATES:
                break
            occ_cmp = _to_comparable(occ, dtstart_type)
            if until_cmp is not None and occ_cmp > until_cmp:
                break
            if occ_cmp > range_end_cmp:
                break
            occurrences.append(occ)
    return occurrences


# ---------------------------------------------------------------------------
# VEVENT 추출
# ---------------------------------------------------------------------------

def _store_property(current, name, params, value):
    if name == "EXDATE":
        current.setdefault("EXDATE", []).append((params, value))
    else:
        current[name] = (params, value)


def _build_event_from_props(current):
    ev = {}
    ev["UID"] = current.get("UID", ({}, ""))[1]
    status_raw = current.get("STATUS")
    ev["STATUS"] = status_raw[1].strip().upper() if status_raw else None
    ev["SUMMARY"] = _unescape_text(current.get("SUMMARY", ({}, ""))[1])
    ev["LOCATION"] = _unescape_text(current.get("LOCATION", ({}, ""))[1])

    if "DTSTART" not in current:
        return None
    dtstart_params, dtstart_value = current["DTSTART"]
    dtstart_type, dtstart_val = _parse_datetime_value(dtstart_value, dtstart_params)
    ev["DTSTART_TYPE"] = dtstart_type
    ev["DTSTART"] = dtstart_val

    if "DTEND" in current:
        dtend_params, dtend_value = current["DTEND"]
        dtend_type, dtend_val = _parse_datetime_value(dtend_value, dtend_params)
    elif "DURATION" in current:
        dur = _parse_duration(current["DURATION"][1])
        dtend_type = dtstart_type
        dtend_val = dtstart_val + dur
    else:
        if dtstart_type == "date":
            dtend_type = "date"
            dtend_val = dtstart_val + timedelta(days=1)
        else:
            dtend_type = "datetime"
            dtend_val = dtstart_val + timedelta(hours=1)
    ev["DTEND_TYPE"] = dtend_type
    ev["DTEND"] = dtend_val

    if "RRULE" in current:
        ev["RRULE"] = _parse_rrule(current["RRULE"][1])

    ev["RECURRENCE_ID_KEY"] = None
    if "RECURRENCE-ID" in current:
        rid_params, rid_value = current["RECURRENCE-ID"]
        rid_type, rid_val = _parse_datetime_value(rid_value, rid_params)
        ev["RECURRENCE_ID_KEY"] = _to_comparable(rid_val, rid_type).isoformat()

    exdate_keys = set()
    for params, value in current.get("EXDATE", []):
        for part in value.split(","):
            part = part.strip()
            if not part:
                continue
            t, v = _parse_datetime_value(part, params)
            exdate_keys.add(_to_comparable(v, t).isoformat())
    ev["EXDATE_KEYS"] = exdate_keys

    return ev


def _extract_vevents(lines):
    """VEVENT 블록들을 정규화된 dict 리스트로, VCALENDAR 레벨 X-WR-CALNAME을 함께 추출."""
    calname = ""
    events = []
    current = None
    in_sub = False  # VALARM 등 VEVENT 내부의 하위 컴포넌트 방어

    for line in lines:
        if not line:
            continue
        parsed = _parse_property_line(line)
        if not parsed:
            continue
        name, params, value = parsed

        if name == "BEGIN":
            if value.strip().upper() == "VEVENT":
                current = {}
                in_sub = False
            elif current is not None:
                in_sub = True
            continue

        if name == "END":
            if value.strip().upper() == "VEVENT":
                if current is not None:
                    built = _build_event_from_props(current)
                    if built is not None:
                        events.append(built)
                current = None
                in_sub = False
            elif current is not None:
                in_sub = False
            continue

        if current is not None:
            if not in_sub:
                _store_property(current, name, params, value)
        else:
            if name == "X-WR-CALNAME":
                calname = _unescape_text(value)

    return events, calname


# ---------------------------------------------------------------------------
# 공개 API
# ---------------------------------------------------------------------------

def _normalize_now(now):
    if now is None:
        return datetime.now(SEOUL)
    if now.tzinfo is None:
        return now.replace(tzinfo=SEOUL)
    return now.astimezone(SEOUL)


def parse_ics(text, days=7, now=None):
    """ICS 문자열만 파싱 (네트워크 없음). fetch_events가 내부에서 사용."""
    now_dt = _normalize_now(now)
    result = {"events": [], "fetched_at": now_dt.isoformat(), "error": None}

    try:
        if not isinstance(text, str):
            result["error"] = "ICS 본문이 문자열이 아닙니다"
            return result
        if "BEGIN:VCALENDAR" not in text.upper():
            result["error"] = "ICS 형식이 아닌 것 같습니다 (BEGIN:VCALENDAR 없음)"
            return result

        range_start = now_dt
        range_end = now_dt + timedelta(days=days)
        range_start_cmp = range_start
        range_end_cmp = range_end

        lines = _unfold(text)
        raw_events, calname = _extract_vevents(lines)

        masters = {}
        overrides = {}
        singles = []

        for ev in raw_events:
            if ev is None:
                continue
            uid = ev.get("UID") or ""
            if ev.get("RECURRENCE_ID_KEY") is not None:
                overrides.setdefault(uid, {})[ev["RECURRENCE_ID_KEY"]] = ev
                continue
            if ev.get("STATUS") == "CANCELLED":
                continue
            if ev.get("RRULE"):
                masters[uid] = ev
            else:
                singles.append(ev)

        out_events = []

        def make_event_dict(start_cmp, end_cmp, all_day, title, location, cal):
            return {
                "start": start_cmp.isoformat(),
                "end": end_cmp.isoformat(),
                "all_day": all_day,
                "title": title,
                "location": location,
                "calendar": cal,
            }

        def overlaps(start_cmp, end_cmp):
            return start_cmp < range_end_cmp and end_cmp > range_start_cmp

        # 1) 단일(비반복) 이벤트
        for ev in singles:
            start_cmp = _to_comparable(ev["DTSTART"], ev["DTSTART_TYPE"])
            end_cmp = _to_comparable(ev["DTEND"], ev["DTEND_TYPE"])
            if overlaps(start_cmp, end_cmp):
                out_events.append(make_event_dict(
                    start_cmp, end_cmp, ev["DTSTART_TYPE"] == "date",
                    ev["SUMMARY"], ev["LOCATION"], calname,
                ))

        # 2) 반복 이벤트 (RRULE 전개 + EXDATE 제외 + RECURRENCE-ID override 치환)
        for uid, ev in masters.items():
            s_type, s_val = ev["DTSTART_TYPE"], ev["DTSTART"]
            e_type, e_val = ev["DTEND_TYPE"], ev["DTEND"]
            duration = _to_comparable(e_val, e_type) - _to_comparable(s_val, s_type)

            occ_list = _expand_rrule(s_type, s_val, ev["RRULE"], range_start, range_end)
            uid_overrides = overrides.get(uid, {})
            exdate_keys = ev.get("EXDATE_KEYS", set())
            seen_keys = set()

            for occ in occ_list:
                key = _to_comparable(occ, s_type).isoformat()
                if key in seen_keys:
                    continue
                seen_keys.add(key)
                if key in exdate_keys:
                    continue

                if key in uid_overrides:
                    ov = uid_overrides[key]
                    if ov.get("STATUS") == "CANCELLED":
                        continue
                    start_cmp = _to_comparable(ov["DTSTART"], ov["DTSTART_TYPE"])
                    end_cmp = _to_comparable(ov["DTEND"], ov["DTEND_TYPE"])
                    if overlaps(start_cmp, end_cmp):
                        out_events.append(make_event_dict(
                            start_cmp, end_cmp, ov["DTSTART_TYPE"] == "date",
                            ov["SUMMARY"], ov["LOCATION"], calname,
                        ))
                else:
                    start_cmp = _to_comparable(occ, s_type)
                    end_cmp = start_cmp + duration
                    if overlaps(start_cmp, end_cmp):
                        out_events.append(make_event_dict(
                            start_cmp, end_cmp, s_type == "date",
                            ev["SUMMARY"], ev["LOCATION"], calname,
                        ))

            # 전개 범위 밖으로 밀려나 seen_keys에 없는 override (예: 아주 먼 회차가
            # 이번 주로 옮겨진 경우)도 자체 시간 기준으로 한 번 더 확인
            for key, ov in uid_overrides.items():
                if key in seen_keys or ov.get("STATUS") == "CANCELLED":
                    continue
                start_cmp = _to_comparable(ov["DTSTART"], ov["DTSTART_TYPE"])
                end_cmp = _to_comparable(ov["DTEND"], ov["DTEND_TYPE"])
                if overlaps(start_cmp, end_cmp):
                    out_events.append(make_event_dict(
                        start_cmp, end_cmp, ov["DTSTART_TYPE"] == "date",
                        ov["SUMMARY"], ov["LOCATION"], calname,
                    ))

        # 3) 마스터(RRULE)가 피드에 없는 "고아" RECURRENCE-ID override
        master_uids = set(masters.keys())
        for uid, ov_map in overrides.items():
            if uid in master_uids:
                continue
            for ov in ov_map.values():
                if ov.get("STATUS") == "CANCELLED":
                    continue
                start_cmp = _to_comparable(ov["DTSTART"], ov["DTSTART_TYPE"])
                end_cmp = _to_comparable(ov["DTEND"], ov["DTEND_TYPE"])
                if overlaps(start_cmp, end_cmp):
                    out_events.append(make_event_dict(
                        start_cmp, end_cmp, ov["DTSTART_TYPE"] == "date",
                        ov["SUMMARY"], ov["LOCATION"], calname,
                    ))

        out_events.sort(key=lambda e: (e["start"], 0 if e["all_day"] else 1))
        result["events"] = out_events

    except Exception as e:  # 침묵 실패 금지: 무엇이든 실패하면 error 필드로 정직하게 보고
        result["error"] = "ICS 파싱 실패: %s: %s" % (type(e).__name__, e)
        result["events"] = []

    return result


def read_calendar_sources(path):
    """calendar-url.txt류 시크릿 파일을 읽어 [(url, label_or_None), ...]로 반환.

    - '#'로 시작하는 줄과 빈 줄은 무시한다.
    - 'URL | 표시이름' 문법을 지원한다 — 파이프 뒤 텍스트를 표시이름으로 분리하고,
      URL 쪽에서는 그 꼬리표를 제거한다(첫 줄에 파이프를 써도 안전).
    - 파일이 비어 있으면(유효한 줄 없음) 빈 리스트를 반환한다 — 예외를 던질지는
      호출자가 결정한다(단일 URL만 필요한 호출자는 read_calendar_url을 쓸 것).
    """
    entries = []
    with open(path, encoding="utf-8") as fp:
        for raw in fp:
            line = raw.strip()
            if not line or line.startswith("#"):
                continue
            if "|" in line:
                url_part, _, label_part = line.partition("|")
                url = url_part.strip()
                label = label_part.strip() or None
            else:
                url = line
                label = None
            if url:
                entries.append((url, label))
    return entries


def read_calendar_url(path):
    """첫 번째 유효한 URL 하나만 필요한 호출자용 (표시이름 파이프 문법은 떼어내고 URL만)."""
    entries = read_calendar_sources(path)
    return entries[0][0] if entries else ""


def fetch_events(url, days=7, now=None, timeout=15):
    """ICS를 내려받아 now(기본 현재 서울시각)부터 days일 구간의 일정을 반환."""
    now_dt = _normalize_now(now)

    if not url:
        return {"events": [], "fetched_at": now_dt.isoformat(), "error": "URL이 비어 있습니다"}

    try:
        req = urllib.request.Request(
            url, headers={"User-Agent": "simple-board-calendar-widget/1.0"}
        )
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            raw = resp.read()
        try:
            text = raw.decode("utf-8")
        except UnicodeDecodeError:
            text = raw.decode("utf-8", errors="replace")
    except urllib.error.HTTPError as e:
        return {
            "events": [], "fetched_at": now_dt.isoformat(),
            "error": "다운로드 실패 (HTTP %s): %s" % (e.code, e.reason),
        }
    except urllib.error.URLError as e:
        return {
            "events": [], "fetched_at": now_dt.isoformat(),
            "error": "다운로드 실패: %s" % e.reason,
        }
    except Exception as e:
        return {
            "events": [], "fetched_at": now_dt.isoformat(),
            "error": "다운로드 실패: %s: %s" % (type(e).__name__, e),
        }

    return parse_ics(text, days=days, now=now_dt)


# ---------------------------------------------------------------------------
# 사람이 눈으로 확인하기 위한 CLI
# ---------------------------------------------------------------------------

if __name__ == "__main__":
    import sys

    if len(sys.argv) < 2:
        print("사용법: python3 calendar_ics.py <ICS_URL> [days]")
        sys.exit(1)

    cli_url = sys.argv[1]
    cli_days = int(sys.argv[2]) if len(sys.argv) > 2 else 7

    cli_result = fetch_events(cli_url, days=cli_days)

    if cli_result["error"]:
        print("에러:", cli_result["error"])
        sys.exit(1)

    print("조회 시각:", cli_result["fetched_at"])
    print("일정 %d건 (%d일 구간)" % (len(cli_result["events"]), cli_days))
    print("-" * 60)
    for ev in cli_result["events"]:
        marker = "[종일]" if ev["all_day"] else "[시간]"
        cal = " (%s)" % ev["calendar"] if ev["calendar"] else ""
        loc = " @ %s" % ev["location"] if ev["location"] else ""
        print("%s %s ~ %s  %s%s%s" % (marker, ev["start"], ev["end"], ev["title"], loc, cal))

4.6 템플릿 — 옵시디언 수동 백업용~/LifeVault/template/업무일지.md

Obsidian의 "고유 노트 만들기(Unique note creator)" 같은 플러그인에서 쓰는 정적 템플릿이다. 자동화가 죽어 있거나 즉석에서 만들어야 할 때, 같은 이름·같은 골격(프론트매터 + 할 일 + 메모)의 파일을 사람이 손으로 만들 수 있게 해 둔 대비책 — 단, 여기엔 일정 블록이 붙지 않는다(그건 자동화만의 기능).

전체 소스 ~/LifeVault/template/업무일지.md (14줄)
---
title: "업무일지 {{date}}"
date: {{date}}
tags: [work, journal]
type: work-journal
---

# {{date:YYYY-MM-DD (ddd)}} 업무일지

## 할 일
- 

## 메모
- 

4.7 notify-wrap.sh — 크론잡 감시 래퍼~/.cokacdir/scripts/notify-wrap.sh

전체 소스 ~/.cokacdir/scripts/notify-wrap.sh (86줄, 디스코드 chat id·key 마스킹)
#!/bin/bash
# notify-wrap.sh — LLM/cokacdir 세션 없이 순수 스케줄(LaunchAgent) 실행되는 크론잡을
# 감싸서, 성공/실패와 무관하게 결과를 한 줄 알림으로 디스코드에 보낸다.
# (2026-07-10 cokacdir 러너형 크론 → 순수 스케줄 전환 2단계. 침묵실패 금지 원칙.
#  HC 지시: 알림 채널은 텔레그램이 아니라 디스코드(<DISCORD_CHAT_ID-마스킹>)로 통일.)
#
# 알림 경로는 기존에 검증된 자산을 그대로 재사용한다:
#   cokacdir --message --to <bot> --chat <id> --key <hash>
#   (ThreadFolio/scripts/notify.sh와 동일 패턴 — 클로드매 채널)
#
# 사용:
#   notify-wrap.sh <job_label> [--tail N] -- <command...>
#
# exit code는 감싼 명령의 것을 그대로 반환한다(launchd/후속 자동화가 참조 가능).
set -u

if [ "$#" -lt 3 ]; then
  echo "usage: notify-wrap.sh <job_label> [--tail N] -- <command...>" >&2
  exit 64
fi

JOB="$1"; shift

TAIL_N=6
QUIET_OK=""
while [ "${1:-}" != "--" ]; do
  case "${1:-}" in
    --tail)     TAIL_N="${2:-6}"; shift 2 ;;
    --quiet-ok) QUIET_OK="${2:-}"; shift 2 ;;
    *)
      echo "usage: notify-wrap.sh <job_label> [--tail N] [--quiet-ok <pattern>] -- <command...>" >&2
      exit 64 ;;
  esac
done
shift

if [ "$#" -eq 0 ]; then
  echo "notify-wrap.sh: 실행할 명령이 없습니다" >&2
  exit 64
fi

LOG_DIR="$HOME/.cokacdir/logs/wrap"
mkdir -p "$LOG_DIR"
TS="$(date +%Y%m%d-%H%M%S)"
LOG="$LOG_DIR/${JOB}-${TS}.log"

START=$(date +%s)
"$@" > "$LOG" 2>&1
EXIT=$?
END=$(date +%s)
ELAPSED=$((END - START))

TAIL_TEXT="$(tail -n "$TAIL_N" "$LOG" 2>/dev/null | sed 's/^/  /')"

if [ "$EXIT" -eq 0 ]; then
  MSG="✅ [${JOB}] 완료 (${ELAPSED}s)
${TAIL_TEXT}"
else
  MSG="❌ [${JOB}] 실패 (exit ${EXIT}, ${ELAPSED}s)
${TAIL_TEXT}"
fi
MSG="${MSG:0:1500}"

# --quiet-ok: 성공(exit 0)이고 로그가 패턴과 일치하는 무변화 실행이면 알림 생략.
# 실패는 항상 알린다(침묵실패 금지). 5분 주기 폴러의 "정상 완료" 스팸 방지용 (2026-07-23).
if [ "$EXIT" -eq 0 ] && [ -n "$QUIET_OK" ] && grep -q "$QUIET_OK" "$LOG"; then
  exit 0
fi

DISCORD_BOT="클로드매"
DISCORD_CHAT="<DISCORD_CHAT_ID-마스킹>"
DISCORD_KEY="<DISCORD_KEY-마스킹>"
CK="$HOME/.local/bin/cokacdir"

# 2026-08-17 HC 지시: 클로드매 디스코드 DM 자동발신 음소거.
# ~/.cokacdir/mute-claudemae-discord 플래그가 있으면 실제 발송 대신 로그만 남긴다
# (사람이 직접 채팅하는 cokacdir 서버 본체 경로와는 무관 — 이 스크립트의 발신만 막음).
if [ -f "$HOME/.cokacdir/mute-claudemae-discord" ]; then
  mkdir -p "$HOME/.cokacdir/logs"
  printf '[%s] (muted, %s) %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$(basename "$0") job=${JOB}" "$MSG" \
    >> "$HOME/.cokacdir/logs/muted-discord-notify.log"
else
  "$CK" --message "$MSG" --to "$DISCORD_BOT" --chat "$DISCORD_CHAT" --key "$DISCORD_KEY" >/dev/null 2>&1
fi

exit "$EXIT"

4.8 setup.sh — 맥 SMB 공유 설정 스크립트~/.config/lifevault-share/setup.sh

역할과 절차, 함정은 5장(윈도우 연결)에서 자세히 다룬다. 여기서는 소스만 원문 그대로 싣는다 — 비밀번호는 credential.txt 2번째 줄에서 sed -n 2p로 읽어 오는 구조라, 스크립트 자체에는 평문 비밀번호가 등장하지 않는다.

전체 소스 ~/.config/lifevault-share/setup.sh (40줄)
#!/bin/bash
# LifeVault SMB 직접 공유 셋업 (2026-08-11, HC 승인 — 깃허브 동기화 대체)
# 하는 일: 공유 전용 계정 lifeshare 생성 → LifeVault ACL 부여 → SMB 공유점 등록 → smbd 기동.
# 비밀번호는 ~/.config/lifevault-share/credential.txt(600, 2번째 줄)에서 읽는다.
# 되돌리기: sharing -r LifeVault → sysadminctl -deleteUser lifeshare → 볼트 ACL은
#   find로 동일 규칙을 chmod -a 로 제거(추가만 했으므로 원상복구 가능).
set -uo pipefail

PW="$(sed -n 2p /Users/hc/.config/lifevault-share/credential.txt)"
VAULT="/Users/hc/LifeVault"
[ -n "$PW" ] || { echo "credential.txt에서 비밀번호를 못 읽음"; exit 1; }

echo "== 1) 공유 전용 계정 lifeshare =="
if ! id lifeshare >/dev/null 2>&1; then
  sysadminctl -addUser lifeshare -fullName "LifeVault Share" -password "$PW" -shell /usr/bin/false -home /var/empty
fi
dscl . -create /Users/lifeshare IsHidden 1
# SMB(NTLM) 인증 해시 생성 보장 — 해시타입 지정 후 비번 재설정으로 재생성
pwpolicy -u lifeshare -sethashtypes SMB-NT on 2>/dev/null || true
sysadminctl -resetPasswordFor lifeshare -newPassword "$PW" 2>/dev/null || dscl . -passwd /Users/lifeshare "$PW"

echo "== 2) ACL — lifeshare 읽기/쓰기 + hc(윈도우가 만든 파일 접근 보장) =="
chmod +a "user:lifeshare allow execute" /Users/hc 2>/dev/null || true   # 경로 통과만(내용 열람 불가)
find "$VAULT" -type d -exec chmod +a "user:lifeshare allow list,search,add_file,add_subdirectory,delete_child,delete,readattr,writeattr,readextattr,writeextattr,readsecurity,file_inherit,directory_inherit" {} + 2>/dev/null
find "$VAULT" -type f -exec chmod +a "user:lifeshare allow read,write,append,delete,readattr,writeattr,readextattr,writeextattr,readsecurity" {} + 2>/dev/null
find "$VAULT" -type d -exec chmod +a "user:hc allow list,search,add_file,add_subdirectory,delete_child,delete,readattr,writeattr,readextattr,writeextattr,readsecurity,file_inherit,directory_inherit" {} + 2>/dev/null

echo "== 3) SMB 공유점 등록 =="
sharing -l 2>/dev/null | grep -q "LifeVault" || sharing -a "$VAULT" -S LifeVault -n LifeVault -s 001 -g 000

echo "== 4) smbd 기동 =="
launchctl load -w /System/Library/LaunchDaemons/com.apple.smbd.plist 2>/dev/null || true
launchctl kickstart -k system/com.apple.smbd 2>/dev/null || true
sleep 2

echo "== 검증 =="
sharing -l | head -12
nc -z 127.0.0.1 445 >/dev/null 2>&1 && echo "PORT445-OK" || echo "PORT445-FAIL"
/usr/libexec/ApplicationFirewall/socketfilterfw --getglobalstate 2>/dev/null || true
echo "SETUP-DONE"

calendar-url.txt — 필요한 형식(내용은 본인 값으로)

보안 — 절대 원문 게재 금지이 파일의 실제 내용은 이 문서 어디에도 없다. 구글 캘린더 설정 → 캘린더 통합 → "비공개 주소(iCal 형식)"에서 발급받은 URL을 mode 600으로 저장한 본인의 파일로 새로 만들어야 한다. calendar_ics.read_calendar_sources()가 인식하는 형식은 다음과 같다(아래는 형식 예시일 뿐 실재 URL이 아니다):

# 줄 앞이 # 이면 주석, 빈 줄 무시. 한 줄에 URL 하나.
# "URL | 표시이름" 형태로 캘린더 이름을 붙일 수도 있다(파이프 뒤는 라벨).
https://calendar.google.com/calendar/ical/<본인-계정-값>/private-<본인-비밀-토큰>/basic.ics

credential.txt — 필요한 형식

보안 — 절대 원문 게재 금지setup.sh가 sed -n 2p로 2번째 줄만 읽으므로, 형식은 "1번째 줄은 자유(메모용), 2번째 줄 = lifeshare 계정 비밀번호"다. 파일 권한은 chmod 600으로 본인만 읽게 만들 것.

5윈도우 연결(SMB)

윈도우 쪽 Obsidian은 맥 원본을 SMB로 직접 여는 것이지 별도 동기화가 아니다. 2026-08-12 GitHub 동기화 방식에서 전환됐다 — 기기 간 git 충돌이 구조적으로 사라지는 대신, 맥이 꺼져 있으면 윈도우에서 볼트 자체가 안 열린다(이 트레이드오프는 이 시스템의 설계자가 수용한 것).

설정 절차

  1. 맥: 공유 전용 계정 생성. setup.sh가 sysadminctl -addUser lifeshare로 셸 /usr/bin/false, 홈 /var/empty인 숨김 계정을 만든다 — 실제 로그인은 불가능하고 SMB 인증 전용이다. 진짜 맥 관리자 비밀번호는 밖으로 나가지 않는다.
  2. 맥: SMB-NT 해시 보장. pwpolicy -u lifeshare -sethashtypes SMB-NT on 후 비밀번호를 재설정해 SMB(NTLM) 인증에 필요한 해시를 반드시 생성시킨다 — 그냥 계정만 만들면 이 해시가 없을 수 있다(함정 ① 참고).
  3. 맥: ACL 부여. LifeVault 아래 모든 디렉터리·파일에 lifeshare는 읽기/쓰기/상속 ACE를, hc에도 동일한 상속 ACE를 추가한다 — 후자가 없으면 윈도우가 만든 파일을 맥의 자동화(work_journal_fill.py 등)가 못 건드리게 된다.
  4. 맥: 공유점 등록·smbd 기동. sharing -a "$VAULT" -S LifeVault -n LifeVault로 공유점을 만들고 com.apple.smbd를 로드·기동한다.
  5. 맥: Tailscale IP 확인. 윈도우가 접속할 주소는 맥의 Tailscale IP(사설 도메인이 아니라 IP를 그대로 쓴다).
  6. 윈도우: 예약 작업 등록. "LifeVault SMB Connect"를 로그온 시/잠금 해제 시/30분 반복 트리거로, 대화형(InteractiveToken) 계정으로 등록한다. 실행 스크립트 lifevault-smb-connect.ps1이 cmdkey로 자격증명을 등록하고 UNC 경로 연결을 확인한다.
  7. 윈도우: Obsidian에서 볼트 열기. \\<맥의 Tailscale IP>\LifeVault를 볼트로 열고, "Override config folder" 옵션으로 .obsidian-win/을 쓰도록 1회 GUI 설정한다 — 맥과 윈도우가 서로 다른 플러그인 설정을 써도 충돌하지 않는다.

재발 주의 — 함정 4개

#증상원인해법
① lifeshare 계정으로 SMB 인증이 계속 실패한다 macOS 계정에 SMB-NT 해시가 없음(그냥 계정만 만들면 생기지 않을 수 있다) pwpolicy -u lifeshare -sethashtypes SMB-NT on 실행 후 dscl . -passwd /Users/lifeshare로 비밀번호를 재설정 → 해시를 강제로 재생성. 검증은 dscl . -read /Users/lifeshare AuthenticationAuthority로 SMB-NT 항목이 보이는지 확인
② SSH로 원격 접속해 예약 작업을 만들면 실제 로그온 때 cmdkey 자격증명이 안 먹는다 SSH(네트워크 로그온) 세션은 사용자 자격증명 저장소(Credential Manager)에 대화형 세션과 같은 접근 권한이 없다 예약 작업의 실행 계정을 대화형(InteractiveToken)으로 만들고, cmdkey 등록도 실제 로그온 세션 안에서(원격 데스크톱 등으로) 수행한다
③ icacls로 권한을 줬는데 예상과 다르게(엉뚱한 계정에) 적용된다 윈도우 호스트명이 ADMIN이면 icacls /grant admin:F의 admin이 계정명이 아니라 내장 그룹/별칭으로 해석될 수 있음 계정명 대신 항상 SID로 icacls /grant *S-1-5-...:F 형태로 grant한다
④ 파일이 500개 넘는 폴더를 윈도우 탐색기/Obsidian에서 열면 목록이 통째로 비거나 거부된다 macOS SMB 서명(signing) 구현이 대량 디렉터리 열거에서 버그를 낸다(검증된 재현 사례) SMB로 여는 폴더는 파일 400개 이하로 유지 — 연도별 하위 폴더로 샤딩하는 식(예: qt/2026/, qt/2027/)

6설치 절차

다른 환경에서 이 시스템을 재현하는 순서. 윈도우 SMB 연동(5장)은 완전히 선택 사항이라 이 체크리스트에서 뺐다 — 필요하면 별도로 이어 붙인다.

  1. 1. 볼트 폴더 만들기 — <볼트>/work/journal/, <볼트>/template/업무일지.md(4.6 소스 그대로 또는 자기 양식으로).
  2. 2. 스크립트 배치 — work_journal_note.py, work_journal_fill.py, calendar_ics.py를 같은 디렉터리에 놓는다(세 파일 다 그 디렉터리를 sys.path에 넣고 서로를 import하므로 위치를 흩뜨리면 안 됨). 경로 상수(JOURNAL_DIR, AMARANTH_DB 등)를 자기 환경에 맞게 고친다.
  3. 3. calendar-url.txt 채우기 — 구글 캘린더 "비공개 주소(iCal)"를 발급받아 mode 600으로 저장(형식은 4.8절). 캘린더 자체가 필요 없으면 note.py의 month_schedule_lines 호출부만 빼도 된다(7장 참고).
  4. 4. Claude OAuth 토큰 준비 — claude setup-token으로 구독 토큰을 발급하고, 그 토큰을 stdout에 출력하는 get-claude-token.sh를 준비한다(계약은 4.4절). 더 간단히 가려면 7장의 대안(API 키 방식)을 쓴다.
  5. 5. LaunchAgent 등록 — plist 2개(4.1·4.2)의 경로를 자기 환경으로 고쳐 ~/Library/LaunchAgents/에 넣고 launchctl load -w <plist 경로> 두 번(뼈대·초안 채우기 각각). 알림 래퍼가 필요 없으면 notify-wrap.sh 부분을 빼고 파이썬을 직접 부르게 해도 된다.
  6. 6. dry-run 검증 — python3 work_journal_note.py --dry-run으로 오늘치 파일 내용을 화면에 찍어 확인, 이어서 실제 파일을 만든 뒤 python3 work_journal_fill.py --dry-run으로 "## 할 일" 초안 미리보기를 확인한다. 둘 다 파일을 건드리지 않고 결과만 보여준다.
  7. 7. 로그 확인 — ~/Library/Logs/lifevault-work-journal.log, ~/Library/Logs/lifevault-work-journal-fill.log에서 첫 실전 실행 결과를 확인한다. launchctl list | grep lifevault-work-journal로 잡이 로드됐는지, launchctl start com.hc.lifevault-work-journal로 스케줄을 기다리지 않고 즉시 1회 실행해볼 수 있다.

7환경 적응 가이드

이 시스템의 상당 부분은 이미 "없으면 조용히 빠지는" 방어 코드로 짜여 있다. 아래 표는 어디를 그대로 두고, 어디를 갈아 끼우면 되는지 정리한 것이다.

부품HC 특화 정도없을 때 실제 동작(이미 처리돼 있음)갈아 끼우는 법
아마란스 SQLite 색인
(AMARANTH_DB)
완전 사내 전용(ERP 색인) amaranth_context()가 os.path.exists로 먼저 확인 → 없으면 "(아마란스 색인 없음 — 이 부분은 비워 둠)" 문자열을 그대로 반환. 에러 없이 넘어간다. 자기 조직의 결재/문서 시스템 색인이 있다면 같은 함수 시그니처(날짜·문서번호·기안자·제목)로 SQL만 바꿔 낀다. 없다면 amaranth_context 호출부를 지워도 나머지는 그대로 동작.
챗볼트
(~/ChatVault/raw)
완전 개인 전용(대화 아카이브) chat_context()가 폴더 존재·키워드 유무를 먼저 확인 → 없으면 "(챗볼트 검색 생략)", 매칭 0건이면 "(관련 챗볼트 대화 없음)". 자기 조직의 채팅 로그·회의록 저장소가 있으면 CHAT_RAW glob 패턴만 바꾼다. 없으면 이 블록 자체를 삭제해도 나머지 로직에 영향 없음.
notify-wrap.sh 디스코드 알림 이 환경의 개인 봇(cokacdir)에 종속 스크립트가 없으면 launchd가 파이썬을 직접 부르게만 바꾸면 됨 — 알림만 없어질 뿐 본 기능엔 영향 없음. 맨 아래 발신 3줄("$CK" --message ...)만 Slack/텔레그램 webhook curl 호출로 바꾼다. 지켜야 할 계약은 딱 둘: exit 0이 아니면 반드시 알린다, --quiet-ok 패턴 매칭 시엔 조용히 넘어간다.
holidays 라이브러리(holidays.KR) 표준 pip 패키지지만 국가 로케일 지정 필요 skip_reason()이 ImportError를 그냥 pass — 라이브러리가 없으면 공휴일 판정을 건너뛰고 수동 목록(config.json/blocked.txt)만으로 판단. 다른 나라면 holidays.US() 등으로 교체. pip install 자체를 안 하고 수동 blocked.txt만 운용해도 시스템은 정상 동작한다.
SMB 윈도우 연결 전체(5장) 완전 선택 안 하면 그냥 맥 로컬에서만 Obsidian을 쓰는 시스템이 된다 — 1~4장·6장·8장에는 아무 영향 없음. 5장을 통째로 생략. 다른 기기 접근이 필요해지면 그때 붙인다.
Claude OAuth 토큰 헬퍼
(get-claude-token.sh)
구독 로그인 흐름이라 이 환경 특유의 구조 토큰을 못 구하면 get_token()이 RuntimeError → fill.py 실패 → notify-wrap 알림. note.py(뼈대 생성)에는 전혀 영향 없음 — 이 실패는 fill 단계에만 국한된다. 훨씬 단순한 대안: Anthropic(auth_token=..., default_headers=...) 대신 Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])로 바꾸고 get_token()·anthropic-beta 헤더를 통째로 제거한다. 구독 대신 종량제 API 키를 쓰는 조직이면 이쪽이 훨씬 간단하다.
최소 구성

work_journal_note.py + calendar_ics.py 두 파일만으로도 "매일 아침 뼈대 자동 생성 + 그달 일정 자동 삽입"까지는 완성된다. 캘린더마저 필요 없다면 month_schedule_lines() 호출부를 ["- 등록된 일정 없음"] 같은 고정값으로 바꾸면 외부 의존성 없이(표준 라이브러리만으로) 돌아간다. AI 초안(fill.py)·아마란스·챗볼트·SMB·알림은 전부 이 최소 구성 위에 얹는 선택적 레이어다.

8운영 노트

멱등 규칙

--force의 의미

스크립트--force 없이--force로
work_journal_note.py주말·공휴일·휴가면 파일을 안 만든다쉬는 날이어도 강제로 오늘치 파일을 만든다
work_journal_fill.py"## 할 일"이 이미 채워져 있으면 SKIP채워져 있어도 Claude로 다시 만들어 덮어쓴다

수동 상위 호환 — "할 일 채워줘"

자동 초안(fill.py)은 파이썬이 미리 정해 둔 규칙(최근 45일, 상한 150/60건, 키워드 상위 25개 …)대로만 컨텍스트를 모으는 정형화된 1회 호출이다. 이게 얕다 싶을 때 — 예를 들어 아마란스 원문을 직접 열어 별첨·결재선까지 봐야 하거나, 챗볼트에서 더 깊이 뒤져야 할 때 — 는 자동화를 다시 돌리는 대신 사람이 대화창에서 "업무일지 할 일 채워줘"라고 요청해 MCP 도구(amaranth_search → amaranth_read, vault_raw_search(vault="chat", days=120) 등)로 직접 문서를 열어가며 다시 작성하게 한다. work/CLAUDE.md는 이걸 "수동이 상위 호환"이라고 명시한다 — 같은 6원칙을 따르되, 파이썬이 자동으로 긁어온 요약이 아니라 사람이 그 자리에서 원문을 확인해 가며 짠다는 차이다.

아마란스 색인은 맥이 원본

경고

이 시스템에서 참조하는 ~/AmaranthVault/index/search.db는 맥에만 있는 게 원본이다. 다른 기기에 있는 사본(예: 윈도우의 다운로드 폴더 사본)은 갱신이 멈춰 있을 수 있고, 실제로 한 달 가까이 갱신이 멈춰 최근 문서가 통째로 안 보인 사고가 있었다. fill.py가 "최근 아마란스 문서" 항목을 이상하게 못 찾으면 색인 자체가 오래됐는지부터 의심할 것 — 코드 버그가 아니라 색인 신선도 문제인 경우가 실제로 더 많았다.

출력 토큰 규약

각 스크립트가 stdout 맨 앞에 찍는 접두 토큰이 notify-wrap.sh의 --quiet-ok 패턴과 짝을 이뤄 "평상시엔 조용히, 실패만 알림"을 구현한다.

토큰스크립트의미이 실행의 exitnotify-wrap 알림?
WORK_JOURNAL_OKnote.py정상 생성 / 이미 있어서 스킵 / 쉬는 날 스킵0침묵(quiet-ok 매칭)
WORK_JOURNAL_FAILnote.py파일은 만들었지만 캘린더 조회 실패1알림
WORK_JOURNAL_FILL_SKIPfill.py일지 파일 없음 / 양식 이상 / 이미 채워져 있음(멱등)0침묵(quiet-ok 매칭)
WORK_JOURNAL_FILL_OKfill.py초안 작성 완료(검토 요망)0알림(패턴 불일치라 그냥 통과)
WORK_JOURNAL_FILL_FAILfill.pyClaude 응답 비어 있음 / 예외 발생 — 원본 유지1알림

이 문서는 실제 가동 중인 시스템의 소스를 그대로 옮겨 구성했다. calendar-url.txt·credential.txt·Claude OAuth 토큰 값은 어디에도 싣지 않았고, notify-wrap.sh의 디스코드 chat id·key는 마스킹했다 — 재구현 시 이 세 가지는 반드시 본인 값으로 새로 준비해야 한다.