맥 LaunchAgent 2개 + Claude API로 매일 아침 업무일지를 만들고 초안까지 채우는 시스템 — 다른 환경에서 그대로 재구현할 수 있도록 소스 원문과 함께 정리한 문서
평일 아침 07:40, 맥의 launchd(LaunchAgent)가 옵시디언 볼트 ~/LifeVault/work/journal/에 그날치 YYYY-MM-DD 업무일지.md를 자동으로 만든다. 프론트매터, 빈 "## 할 일"·"## 메모" 칸, 그리고 맨 아래에 그달 캘린더 일정 전체(지난 날은 취소선, 오늘은 굵게)가 채워진 상태다. 07:45, 두 번째 LaunchAgent가 그 파일의 "## 할 일" 칸만 골라 Claude API로 초안을 작성해 채운다 — 직전 일지의 미완료 항목, 이번 달 일정, 사내 ERP(아마란스) 색인, 과거 대화 기록(챗볼트) 네 가지를 파이썬이 모아 한 번의 호출로 넘긴다. 결과에는 > 자동초안 … 확인 후 이 줄을 지우면 확정 표식이 붙어, 사람이 출근해서 검토한 뒤 그 줄을 지우면 확정된다. 윈도우 PC의 Obsidian은 이 파일을 복제해서 동기화하는 게 아니라, SMB 공유로 맥의 볼트 원본을 그대로 열어서 본다 — 그래서 두 기기 사이에 충돌이라는 게 애초에 없다.
아래는 실제 시스템이 만든 파일의 구조를 보여주기 위한 일반화된 예시다. 실제 업무일지에는 재단의 실제 문서번호·금액·인명이 들어가지만, 이 문서는 구현 명세이므로 그 값들은 전부 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 단계가 절대 건드리지 않는다.
다이어그램에서 놓치기 쉬운 지점 셋:
calendar_ics.py를 재호출해서 얻는 게 아니라, note.py가 07:40에 이미 써 놓은 오늘 파일의 "## N월 일정" 꼬리 블록을 정규식으로 다시 읽어오는 것이다. 캘린더 호출은 하루에 한 번, note.py에서만 일어난다.윈도우 쪽에 "LifeVault 사본"이라고 부를 만한 폴더가 없다. UNC 경로 자체가 곧 볼트이고, 그 안의 파일은 맥 디스크에 있는 바로 그 파일이다. 로컬 복제·git 동기화는 2026-08-12 구조 전환으로 이 용도에서는 완전히 빠졌다.
StartCalendarInterval 5개(요일별로 각각 등록 — launchd는 "평일"이라는 값을 직접 지원하지 않는다).notify-wrap.sh로 감싼 python3 work_journal_note.py. wrap의 --quiet-ok WORK_JOURNAL_OK는 "출력에 이 문자열이 있고 exit 0이면 디스코드에 알리지 말라"는 뜻 — 정상 생성·이미 있어서 스킵·쉬는 날 스킵이 전부 이 토큰을 찍으므로 평상시엔 조용하다.~/Library/Logs/lifevault-work-journal.log 한 파일로 합친다.<?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>
--quiet-ok WORK_JOURNAL_FILL_SKIP — 뼈대 생성과 달리 OK가 아니라 SKIP을 침묵 기준으로 잡았다. 이유는 4.4절: 이 스크립트는 "이미 채워져 있음"·"쉬는 날이라 파일 없음"이 정상적인 하루의 대부분이고, 진짜 성공(WORK_JOURNAL_FILL_OK)은 오히려 하루 한 번 뿐인 사건이라 굳이 침묵시키지 않아도 스팸이 안 된다 — 오히려 "오늘 초안이 만들어졌다"는 알림 자체가 사람이 검토하러 갈 신호가 된다.<?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>
skip_reason) — 순서대로: 토·일 → holidays.KR(라이브러리 없으면 조용히 건너뜀) → ~/attendance/config.json의 매년공휴일/추가공휴일 → ~/attendance/blocked.txt(휴가 등 수동 등록). 출퇴근 자동화와 판정 기준을 그대로 공유한다.WORK_JOURNAL_OK 이미 있음 → 건드리지 않음을 찍고 종료. work/journal은 "HC의 날것"이라는 볼트 규약(work/CLAUDE.md) 때문에 덮어쓰기가 원천적으로 금지돼 있다.month_schedule_lines) — 이번 달 1일부터 말일까지 calendar_ics.fetch_events로 통째로 받아, 날짜별로 묶고 오늘 기준으로 지난 날은 ~~취소선~~, 오늘은 **굵게** ← 오늘, 미래는 그대로 렌더링한다.try/except로 잡아 일정 자리에 경고 한 줄(⚠ 캘린더를 읽지 못했습니다: …)만 넣고 파일은 정상적으로 만든다. 예외 메시지에 URL 조각(http:///https://)이 섞여 있으면 그 뒤를 잘라 로그·알림으로 주소가 새어나가지 않게 막는다.path + ".tmp"에 먼저 쓰고 os.replace()로 최종 경로에 옮긴다. 쓰다가 죽어도 반쪽짜리 파일이 볼트에 남지 않는다.#!/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())
WORK_JOURNAL_FILL_SKIP.--force로만 덮어쓴다.## 제목(보통 "## 메모") 직전까지로 정확히 한정된다. frontmatter·메모·일정·HC가 쓴 문장은 물리적으로 다른 줄 구간이라 절대 안 건드려진다.clean_output 결과가 빈 리스트) 원본을 그대로 두고 WORK_JOURNAL_FILL_FAIL로 종료 — notify-wrap이 실패를 알린다.| 소스 | 함수 | 범위 | 없을 때 |
|---|---|---|---|
| 직전 영업일 일지 | find_prev_journal + todo_body | 가장 최근 과거 날짜의 파일 하나, "## 할 일"에서 미완료(- [ ])만 | "(직전 일지의 할 일 없음)" |
| 이번 달 일정 | 오늘 파일 자체의 tail 정규식 추출 | note.py가 이미 써 놓은 "## N월 일정" 블록 그대로 (재호출 없음) | "(이번 달 일정 블록 없음)" |
| 아마란스 SQLite | amaranth_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) 캡 | "(챗볼트 검색 생략)" / "(관련 챗볼트 대화 없음)" |
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.
코드에 박힌 SYSTEM_RULES 문자열이 실제 판단 로직이다. 여섯 규칙을 풀어 쓰면:
- [x])는 옮기지 않는다 — 완료 기록은 그날 일지 자체에 남아 있으니 중복시키지 않는다는 뜻. 이어받은 항목의 근거(※) 메모는 보존한다.- [x] ~~취소선~~ → [[재단사무국-번호]] (날짜, 기안자) 형태로 정리해 완료 처리한다.※ 예년 상신 : …을 반드시 붙인다.(대조 필요)라고 적는다.이 밖에 출력 형식 규칙(제목 줄·코드펜스 금지, 체크박스는 - [ ]/하위는 - [ ], 근거는 - ※ …, 아마란스 인용은 반드시 [[재단사무국-번호]] 위키링크)과 경계 규칙("업무" 일지이므로 개인 일기·신앙·건강은 절대 넣지 않는다, 출력은 사람이 검토할 초안이라는 전제)이 시스템 프롬프트에 함께 들어 있다 — 전문은 아래 소스에 그대로 있다.
모델 출력에서 제목 줄·코드펜스를 걷어낸(clean_output) 다음, "## 할 일" 바로 다음 줄에 > 자동초안 (Claude <모델>, 검토·확정 필요) — <타임스탬프>. 확인 후 이 줄을 지우면 확정. 표식을 붙이고, 원본의 그 앞뒤 줄(frontmatter~할일제목, 다음 섹션부터 끝)은 그대로 이어붙여 원자적으로 저장한다. HC가 표식 줄을 지우면 "확정"이라는 상태는 코드가 아니라 사람의 편집 행위 자체로 표현된다.
#!/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)
urllib.request, datetime, zoneinfo, re, calendar) — 외부 iCal 파싱 패키지에 의존하지 않는다. Python 3.9 호환을 명시적으로 지켜 match문·X | Y 타입힌트를 쓰지 않는다._unfold)부터 RRULE 전개(DAILY/WEEKLY/MONTHLY/YEARLY, BYDAY, INTERVAL, COUNT/UNTIL)까지 직접 구현했다. _MAX_RRULE_CANDIDATES = 1000으로 무한/장기 반복 일정이 서버를 오래 잡아먹지 않게 안전상한을 둔다.RECURRENCE-ID override와 EXDATE 제외까지 처리해, "매주 반복이지만 이번 주만 시간이 바뀐" 이벤트나 "이번 회차만 취소된" 이벤트를 정확히 반영한다.error 필드로 보고한다(침묵 실패 금지 원칙) — 호출자(note.py)가 이 값을 보고 경고 문구로 바꿔 일지에 남긴다.read_calendar_sources(path)/read_calendar_url(path) — calendar-url.txt류 시크릿 파일을 읽는 공용 헬퍼. # 주석 줄과 빈 줄은 무시하고, URL | 표시이름 파이프 문법을 지원한다(업무일지 쪽은 첫 번째 URL 하나만 사용)."""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))
Obsidian의 "고유 노트 만들기(Unique note creator)" 같은 플러그인에서 쓰는 정적 템플릿이다. 자동화가 죽어 있거나 즉석에서 만들어야 할 때, 같은 이름·같은 골격(프론트매터 + 할 일 + 메모)의 파일을 사람이 손으로 만들 수 있게 해 둔 대비책 — 단, 여기엔 일정 블록이 붙지 않는다(그건 자동화만의 기능).
---
title: "업무일지 {{date}}"
date: {{date}}
tags: [work, journal]
type: work-journal
---
# {{date:YYYY-MM-DD (ddd)}} 업무일지
## 할 일
-
## 메모
-
notify-wrap.sh <job_label> [--tail N] [--quiet-ok <pattern>] -- <command...>. 감싼 명령의 표준출력·에러를 로그 파일(~/.cokacdir/logs/wrap/<job>-<타임스탬프>.log)로 모으고, 종료코드를 그대로 반환한다(launchd·후속 자동화가 참조 가능).--quiet-ok로 준 패턴이 로그에 있으면 알림을 생략한다. 실패는 이 패턴과 무관하게 항상 알린다("침묵실패 금지" 원칙) — 이 시스템에서 두 LaunchAgent가 각각 다른 패턴(WORK_JOURNAL_OK / WORK_JOURNAL_FILL_SKIP)을 쓰는 이유가 여기 있다.DISCORD_CHAT/DISCORD_KEY 값은 이 문서에서 마스킹했다 — 재구현 시에는 본인의 알림 채널(디스코드 웹훅, 슬랙 웹훅, 텔레그램 봇 등 무엇이든)로 통째로 갈아 끼우면 된다. 계약은 "exit 0이 아니면 알린다"와 "--quiet-ok 매칭 시 조용히 넘어간다" 두 가지뿐이다.~/.cokacdir/mute-claudemae-discord) — 있으면 실제 발송 대신 로그 파일에만 적재한다. 이 시스템 고유의 운영 정책이라 재구현 시엔 없어도 무방하다.#!/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"
역할과 절차, 함정은 5장(윈도우 연결)에서 자세히 다룬다. 여기서는 소스만 원문 그대로 싣는다 — 비밀번호는 credential.txt 2번째 줄에서 sed -n 2p로 읽어 오는 구조라, 스크립트 자체에는 평문 비밀번호가 등장하지 않는다.
#!/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"
보안 — 절대 원문 게재 금지이 파일의 실제 내용은 이 문서 어디에도 없다. 구글 캘린더 설정 → 캘린더 통합 → "비공개 주소(iCal 형식)"에서 발급받은 URL을 mode 600으로 저장한 본인의 파일로 새로 만들어야 한다. calendar_ics.read_calendar_sources()가 인식하는 형식은 다음과 같다(아래는 형식 예시일 뿐 실재 URL이 아니다):
# 줄 앞이 # 이면 주석, 빈 줄 무시. 한 줄에 URL 하나.
# "URL | 표시이름" 형태로 캘린더 이름을 붙일 수도 있다(파이프 뒤는 라벨).
https://calendar.google.com/calendar/ical/<본인-계정-값>/private-<본인-비밀-토큰>/basic.ics
보안 — 절대 원문 게재 금지setup.sh가 sed -n 2p로 2번째 줄만 읽으므로, 형식은 "1번째 줄은 자유(메모용), 2번째 줄 = lifeshare 계정 비밀번호"다. 파일 권한은 chmod 600으로 본인만 읽게 만들 것.
윈도우 쪽 Obsidian은 맥 원본을 SMB로 직접 여는 것이지 별도 동기화가 아니다. 2026-08-12 GitHub 동기화 방식에서 전환됐다 — 기기 간 git 충돌이 구조적으로 사라지는 대신, 맥이 꺼져 있으면 윈도우에서 볼트 자체가 안 열린다(이 트레이드오프는 이 시스템의 설계자가 수용한 것).
setup.sh가 sysadminctl -addUser lifeshare로 셸 /usr/bin/false, 홈 /var/empty인 숨김 계정을 만든다 — 실제 로그인은 불가능하고 SMB 인증 전용이다. 진짜 맥 관리자 비밀번호는 밖으로 나가지 않는다.pwpolicy -u lifeshare -sethashtypes SMB-NT on 후 비밀번호를 재설정해 SMB(NTLM) 인증에 필요한 해시를 반드시 생성시킨다 — 그냥 계정만 만들면 이 해시가 없을 수 있다(함정 ① 참고).LifeVault 아래 모든 디렉터리·파일에 lifeshare는 읽기/쓰기/상속 ACE를, hc에도 동일한 상속 ACE를 추가한다 — 후자가 없으면 윈도우가 만든 파일을 맥의 자동화(work_journal_fill.py 등)가 못 건드리게 된다.sharing -a "$VAULT" -S LifeVault -n LifeVault로 공유점을 만들고 com.apple.smbd를 로드·기동한다.lifevault-smb-connect.ps1이 cmdkey로 자격증명을 등록하고 UNC 경로 연결을 확인한다.\\<맥의 Tailscale IP>\LifeVault를 볼트로 열고, "Override config folder" 옵션으로 .obsidian-win/을 쓰도록 1회 GUI 설정한다 — 맥과 윈도우가 서로 다른 플러그인 설정을 써도 충돌하지 않는다.| # | 증상 | 원인 | 해법 |
|---|---|---|---|
| ① | 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/) |
다른 환경에서 이 시스템을 재현하는 순서. 윈도우 SMB 연동(5장)은 완전히 선택 사항이라 이 체크리스트에서 뺐다 — 필요하면 별도로 이어 붙인다.
<볼트>/work/journal/, <볼트>/template/업무일지.md(4.6 소스 그대로 또는 자기 양식으로).work_journal_note.py, work_journal_fill.py, calendar_ics.py를 같은 디렉터리에 놓는다(세 파일 다 그 디렉터리를 sys.path에 넣고 서로를 import하므로 위치를 흩뜨리면 안 됨). 경로 상수(JOURNAL_DIR, AMARANTH_DB 등)를 자기 환경에 맞게 고친다.calendar-url.txt 채우기 — 구글 캘린더 "비공개 주소(iCal)"를 발급받아 mode 600으로 저장(형식은 4.8절). 캘린더 자체가 필요 없으면 note.py의 month_schedule_lines 호출부만 빼도 된다(7장 참고).claude setup-token으로 구독 토큰을 발급하고, 그 토큰을 stdout에 출력하는 get-claude-token.sh를 준비한다(계약은 4.4절). 더 간단히 가려면 7장의 대안(API 키 방식)을 쓴다.~/Library/LaunchAgents/에 넣고 launchctl load -w <plist 경로> 두 번(뼈대·초안 채우기 각각). 알림 래퍼가 필요 없으면 notify-wrap.sh 부분을 빼고 파이썬을 직접 부르게 해도 된다.python3 work_journal_note.py --dry-run으로 오늘치 파일 내용을 화면에 찍어 확인, 이어서 실제 파일을 만든 뒤 python3 work_journal_fill.py --dry-run으로 "## 할 일" 초안 미리보기를 확인한다. 둘 다 파일을 건드리지 않고 결과만 보여준다.~/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회 실행해볼 수 있다.이 시스템의 상당 부분은 이미 "없으면 조용히 빠지는" 방어 코드로 짜여 있다. 아래 표는 어디를 그대로 두고, 어디를 갈아 끼우면 되는지 정리한 것이다.
| 부품 | 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·알림은 전부 이 최소 구성 위에 얹는 선택적 레이어다.
| 스크립트 | --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 패턴과 짝을 이뤄 "평상시엔 조용히, 실패만 알림"을 구현한다.
| 토큰 | 스크립트 | 의미 | 이 실행의 exit | notify-wrap 알림? |
|---|---|---|---|---|
| WORK_JOURNAL_OK | note.py | 정상 생성 / 이미 있어서 스킵 / 쉬는 날 스킵 | 0 | 침묵(quiet-ok 매칭) |
| WORK_JOURNAL_FAIL | note.py | 파일은 만들었지만 캘린더 조회 실패 | 1 | 알림 |
| WORK_JOURNAL_FILL_SKIP | fill.py | 일지 파일 없음 / 양식 이상 / 이미 채워져 있음(멱등) | 0 | 침묵(quiet-ok 매칭) |
| WORK_JOURNAL_FILL_OK | fill.py | 초안 작성 완료(검토 요망) | 0 | 알림(패턴 불일치라 그냥 통과) |
| WORK_JOURNAL_FILL_FAIL | fill.py | Claude 응답 비어 있음 / 예외 발생 — 원본 유지 | 1 | 알림 |
이 문서는 실제 가동 중인 시스템의 소스를 그대로 옮겨 구성했다. calendar-url.txt·credential.txt·Claude OAuth 토큰 값은 어디에도 싣지 않았고, notify-wrap.sh의 디스코드 chat id·key는 마스킹했다 — 재구현 시 이 세 가지는 반드시 본인 값으로 새로 준비해야 한다.