user_brief 신규 구축 매뉴얼 (Claude Code 자동 실행용)
이 문서를 읽는 주체 = Claude Code(에이전트) 자신입니다. MCP·커스텀 스킬·외부 도구가 하나도 없는 초기 상태를 전제로, 내장 도구(Bash / Read / Write / Edit / Glob / Grep)와 직접 설치한 Python·git만으로 개인용 "두 번째 뇌(시드 메모리) + 선택적 RAG" 시스템을 처음부터 끝까지 구축합니다.
핵심 철학: 토큰0 결정형 시드 복원. LLM 호출 없이, 정해진 순서로 파일 몇 개만 읽으면 프로젝트 맥락이 그대로 복원됩니다. RAG는 그 위에 얹는 선택적 검색 레이어입니다.
0. 에이전트를 위한 실행 규약 (먼저 읽기)
당신(Claude Code)은 이 매뉴얼을 다음 순서로 처리합니다.
- 이 문서를 끝까지 1회 통독한다. (구조를 머리에 넣고 시작)
- §2 사용자 인터뷰의 카테고리별 질문을 사용자에게 제시한다.
-
AskUserQuestion도구가 있으면 그걸로 묻는다. (이건 MCP가 아니라 하니스 내장 도구라 초기 상태에도 보통 사용 가능) - 없으면 질문을 번호 매긴 선택지로 채팅에 출력하고 사용자의 답을 기다린다. - 한 번에 다 묻지 말고 카테고리 묶음(A~F) 단위로 묻는다. 사용자가 "기본값으로"라고 하면 각 질문의 (추천) 선택지를 따른다. - 답변을 프로파일 표로 정리해 사용자에게 1회 확인받는다. (§3)
- §4 구축 절차를 답변에 따라 분기 실행한다. 각 단계 끝에 검증 명령을 돌려 결과를 보고한다.
- 막히면 멈추고 전체 출력과 함께 보고한다. (임의로 우회하지 않는다)
0-1. 도구·셸 사용 원칙
- 파일 생성·수정은 Write / Edit로 한다. (
echo >, 셸 heredoc로 코드 파일 쓰기 금지 — 인코딩 사고) - 명령 실행은 Bash 도구(POSIX sh) 우선. Windows라도 git-bash 경유가 안전하다.
- 이유: PowerShell은 한글·UTF-8·exit code 캡처에서 사고가 잦다. 부득이 PowerShell이면
$()서브표현식 금지. - Python 실행은
python또는python3중 있는 것을 쓴다. (§4-1에서 판별) - 모든 스크립트는 UTF-8 고정으로 쓴다. 스크립트 상단에
sys.stdout.reconfigure(encoding="utf-8")를 둔다(Windows cp949 콘솔 보호).
0-2. 절대 규칙
BRIEF.*.md는 빌드 산출물 — 수동 편집 금지.build_brief.py가 매번 재생성한다. 내용은projects/<P>/*.md에서 고친다.- 민감정보(토큰·비번·API키)는 시드 본문·커밋에 절대 넣지 않는다.
.secrets/로 분리하고.gitignore처리. - 원격 push는 사용자가 명시 허용할 때만. 기본은 로컬 커밋만.
1. 이 시스템이 무엇인가 (30초 개념)
새 대화방 시작
│ (LLM 호출 0 — 그냥 파일을 순서대로 Read)
▼
HANDOFF.md ─→ CLAUDE.md ─→ BRIEF.global.md ─→ BRIEF.<작업프로젝트>.md
(얇은 진입점) (작업 규약) (전 프로젝트 인덱스) (그 프로젝트 압축 시드)
│ 필요 시
▼
projects/<P>/{handoff,memory,design,archive,playbook}.md
│ 토픽으로 핀포인트
▼
mem_find.py / (선택) RAG 검색
- 시드(seed) = 에이전트가 "빌드 없이 읽기만 하면" 맥락이 복원되는 git 추적 파일들.
- manifest(
docs_manifest.json) = 모든 문서의 단일 카탈로그. 여기 없으면 파이프라인이 처리하지 않는다. build_brief.py= manifest + 프로젝트 문서 + 결정 로그를 결정형으로 이어붙여 압축 시드 BRIEF를 만든다. (토큰0)- 카테고리(category):
index · handoff · memory · memory-detail · design · archive · playbook— 문서의 역할이자 BRIEF 조립 규칙. - RAG(선택): 노트가 많아져 "어디 적었더라"가 안 잡힐 때 얹는 검색 레이어. 규모가 작으면 안 쓴다(§7).
폴더 분류만 쓰는 사람과의 차이: 폴더는 "한 파일 = 한 위치"라 교차 맥락이 사라진다. 이 시스템은 토픽(faceted) + 포인터 + (선택)검색으로 교차 연결을 살린다.
2. 사용자 인터뷰 — 카테고리별 선택 질문
에이전트: 아래를 A~F 묶음 단위로 사용자에게 묻는다. 각 질문은 하나 선택(별도 표기 시 복수). (추천) 은 1인 개발자/연구자 표준 기본값. 사용자가 "추천대로"라 하면 그대로 채택.
묶음 A — 환경 & 기본
A1. 운영체제
- (1) Windows (2) macOS (3) Linux (4) 잘 모름 → 에이전트가 uname/ver로 판별
A2. 시스템을 둘 폴더(저장소 경로)
- (1) ~/brain 또는 C:\dev\brain (추천) (2) 직접 지정 (3) 기존 작업 폴더 안
A3. 주 사용 언어 (문서 본문) - (1) 한국어 (추천: 한국어 사용자) (2) English (3) 한·영 혼용 - → RAG 토크나이저·임베딩 모델 선택에 영향(§7).
A4. 작업 보고 언어 (에이전트 응답) - (1) 한국어 존댓말 (추천) (2) English (3) 본문과 동일
묶음 B — 목적 & 규모
B1. 주 사용 목적 (복수 선택 가능) - (1) 소프트웨어 개발 (2) 연구·논문 (3) 글쓰기·집필 (4) 학습·강의 정리 (5) 업무 잡식(메모·결정 기록) (6) 팀 협업 - → playbook/decision/세션문서 사용 강도에 반영.
B2. 다룰 프로젝트 수
- (1) 단일 프로젝트 (가벼움) (2) 소수(2~5개) (추천) (3) 다수(멀티프로젝트)
- → projects/<P>/ 분리 정도와 BRIEF.global.md 인덱스 활용도 결정.
B3. 예상 노트 분량(6개월 후 체감) - (1) 적음(~30 노트) (2) 보통(30~150) (3) 많음(150+) - → RAG 도입 시점의 1차 기준(§7 임계표).
묶음 C — 시드 깊이 & 운영
C1. 시드 깊이(구축 범위)
- (1) 미니멀 — HANDOFF.md + CLAUDE.md만. 빌드 스크립트 없이 수기 시드.
- (2) 표준(추천) — 미니멀 + docs_manifest.json + build_brief.py(BRIEF 자동조립) + validate_manifest.py + register_doc.py.
- (3) 풀 — 표준 + memory-detail(토픽 핀포인트) + mem_find.py + 세션 종료 문서 체계 + (선택)관계도.
C2. 결정 로그(data/decisions.json) 사용
- (1) 사용 (추천) — "무엇을 왜 정했는지"를 1줄씩 누적, BRIEF에 자동 노출. (2) 미사용
C3. 세션 종료 문서화
- (1) 매 세션 개별 문서(session-YYYY-MM-DD-주제.md) (추천: 개발/연구)
- (2) 루트 HANDOFF의 NOW만 갱신 (가벼움)
- (3) 안 함
C4. 토픽(faceted) 분류 사용
- (1) 사용 (추천: 표준/풀) — kind/domain/env/svc 네임스페이스로 교차검색.
- (2) 미사용(폴더+카테고리만)
C5. 자동 재빌드
- (1) 수동 — 필요할 때 build 스크립트 1회 (추천: 초기)
- (2) 자동 스케줄러 — 5분 주기 감시 재빌드(§6, OS별 등록).
묶음 D — 버전관리 & 보안
D1. git 사용 - (1) git 로컬 커밋만 (추천) (2) 로컬 + 원격(GitHub 등) push (3) git 미사용(파일만) - → push 선택 시 민감정보 점검을 더 엄격히.
D2. 민감정보 분리
- (1) .secrets/ 분리 + .gitignore (추천) (2) 해당 없음
D3. 협업 에이전트 수
- (1) 단일 에이전트 (2) 다중 에이전트 공통 런북(여러 AI/세션이 같은 시드를 공유)
- → (2)면 CLAUDE.md에 "모든 에이전트 동일 절차" 런북을 강화.
묶음 E — 컨텍스트 & 알림
E1. 컨텍스트 사용량 안내 정책 - (1) 40%부터 10%마다 안내 + 60%에 새 세션 권고 (추천) (2) 80%에 1회만 (3) 안내 안 함
E2. 외부 알림 통합 - (1) 없음 (추천: 신규) (2) 텔레그램 등 broker 연동(이미 구축돼 있을 때만)
묶음 F — RAG 융합 수준 (핵심 선택)
F1. 검색·회상 방식 (§7 상세 — 규모와 직결)
- (1) Phase 0 (추천 시작점) — RAG 없음. 토큰0 결정형 시드 + 토픽 핀포인트(mem_find.py)만. 노트 ~50개 이하면 이걸로 충분.
- (2) Phase 1 — FTS5 키워드 전문검색(BM25). 추가 설치 0(파이썬 내장 sqlite). 노트 50~150개에서 보조검색.
- (3) Phase 2 — 하이브리드(벡터 임베딩 + BM25 + RRF 융합). pip 설치 필요. 노트 150+ 또는 회상 실측 부족 시.
F2. (F1=Phase2일 때만) 임베딩 모델
- (1) 한국어 특화 nlpai-lab/KURE-v1 (추천: 한국어) (2) 다국어 BAAI/bge-m3 (3) OpenAI text-embedding-3-small(API키·과금)
- → 로컬 모델(1·2)은 인터넷·디스크 필요, API(3)는 키 필요.
F3. 기존 자료 마이그레이션
- (1) 없음(새로 시작) (추천) (2) 기존 폴더의 .md들을 가져오기 (3) 노션/기타 외부 export
인터뷰 종료 후: §3 프로파일 표로 정리 → 사용자 확인 → §4 실행.
3. 프로파일 정리 (확인용 표 템플릿)
에이전트는 답변을 아래 표로 채워 1회 확인받는다.
| 항목 | 선택 | 빌드에 미치는 영향 |
|---|---|---|
| OS / 셸 | _ | 설치 명령·스케줄러 등록 방식 |
| 저장 경로 | _ | 모든 경로의 BASE |
| 본문/보고 언어 | _ | 템플릿 문구·RAG 토크나이저 |
| 목적·규모 | _ | playbook/decision 강도, RAG 시점 |
| 시드 깊이 | 미니멀/표준/풀 | 생성할 스크립트 범위 |
| 결정 로그 | 사용/미사용 | data/decisions.json 생성 |
| 세션 문서화 | _ | templates/session.md·sessions.md |
| 토픽 분류 | 사용/미사용 | topics_vocab.json·mem_find.py |
| 자동 재빌드 | 수동/자동 | 스케줄러 등록(§6) |
| git / push | _ | git init·.gitignore·push 여부 |
| RAG 수준 | P0/P1/P2 | §7 레이어 생성 |
| 임베딩 모델 | _ | (P2) pip 설치·모델 |
| 마이그레이션 | _ | import 단계 추가 |
확인되면 다음으로.
4. 구축 절차 (분기 실행)
모든 경로는 사용자가 고른 저장 경로(BASE) 기준. 아래 예시는
C:\dev\brain(= git-bash에서/c/dev/brain). 표기$BASE는 그 경로로 치환.
4-1. 전제 도구 설치 (MCP 없이, 직접)
(a) Python·git 존재 확인 — Bash 도구로:
python --version 2>/dev/null || python3 --version 2>/dev/null || echo "PYTHON_MISSING"
git --version 2>/dev/null || echo "GIT_MISSING"
(b) 없으면 OS별 설치:
- Windows (winget 권장):
bash winget install -e --id Python.Python.3.12 winget install -e --id Git.Gitwinget이 없으면 https://www.python.org/downloads/ , https://git-scm.com/download/win 에서 설치 안내. 설치 시 "Add python.exe to PATH" 체크 필수. - macOS:
bash brew install python git # Homebrew 없으면 https://brew.sh 안내 - Linux (Debian/Ubuntu):
bash sudo apt-get update && sudo apt-get install -y python3 python3-pip git
(c) 어느 python 명령을 쓸지 고정: 이후 매뉴얼에서 PY로 표기. python이 3.x면 PY=python, 아니면 PY=python3.
# FTS5(=Phase 1 RAG) 가용 여부도 함께 확인
$PY - <<'EOF'
import sqlite3
con = sqlite3.connect(":memory:")
try:
con.execute("CREATE VIRTUAL TABLE t USING fts5(x)")
print("FTS5 OK")
except Exception as e:
print("FTS5 MISSING:", e)
print("python sqlite ok")
EOF
FTS5가 MISSING이면 Phase 1은 불가 → python.org 공식 빌드 재설치 권장, 또는 Phase 0으로 운영.
4-2. 디렉터리 골격 + git 초기화
mkdir -p "$BASE"/{scripts,projects,templates,data,inbox}
cd "$BASE"
# .gitignore
.gitignore는 Write 도구로 작성(아래 §10-G 참조). 그 후:
cd "$BASE"
git init
git config user.name "<사용자 이름>"
git config user.email "<사용자 이메일>"
git add -A && git commit -m "chore: user_brief skeleton"
D1=git미사용이면 git 단계 생략(파일만 운용). D2=secrets 분리면
mkdir -p "$BASE/.secrets"추가(이 폴더는 .gitignore에 포함).
4-3. 시드 파일 작성 (Write 도구)
깊이에 따라 생성 목록:
| 파일 | 미니멀 | 표준 | 풀 | 출처(§) |
|---|---|---|---|---|
HANDOFF.md |
✅ | ✅ | ✅ | §10-A |
CLAUDE.md |
✅ | ✅ | ✅ | §10-B |
docs_manifest.json (초기 []) |
✅ | ✅ | §10-C | |
topics_vocab.json (C4=사용) |
✅* | ✅ | §10-D | |
data/decisions.json (C2=사용) |
✅* | ✅ | §10-E | |
templates/session.md (C3=개별) |
✅* | ✅ | §10-F | |
.gitignore |
✅ | ✅ | ✅ | §10-G |
(✱ = 해당 옵션 선택 시)
4-4. 스크립트 작성 (표준·풀)
scripts/에 Write로 생성:
| 스크립트 | 역할 | 표준 | 풀 | 출처 |
|---|---|---|---|---|
build_brief.py |
manifest+문서+결정 → BRIEF 조립(토큰0) | ✅ | ✅ | §10-H |
validate_manifest.py |
manifest↔파일 1:1·필드·토픽 검증 | ✅ | ✅ | §10-I |
register_doc.py |
문서 1건 manifest 등록(검증 포함) | ✅ | ✅ | §10-J |
mem_find.py |
토픽(AND)으로 노트 경로 찾기 | ✅ | §10-K | |
build.sh |
검증→빌드 1커맨드 | ✅ | ✅ | §10-L |
4-5. 첫 프로젝트 생성
예: 프로젝트 슬러그 myapp.
mkdir -p "$BASE/projects/myapp"
⚠️ 중요 — "파일 1개 작성 → 즉시 등록"을 반복한다.
validate_manifest.py는 manifest와 디스크의.md가 1:1이어야 통과한다(register_doc.py가 등록 직후 이 검증을 돌린다). 여러 파일을 먼저 만들어 두고 나중에 한꺼번에 등록하면, 첫 등록 시 "아직 미등록 파일이 남았다"며 검증 실패·롤백된다. 그러니 반드시 하나씩 만들고 바로 등록한다.
(1) 허브 문서(§10-M)를 Write로 작성 → 등록 (category=index, seed=true):
cd "$BASE"
# 먼저 Write 도구로 projects/myapp/hub.md 작성, 그 다음:
$PY scripts/register_doc.py --project myapp --file hub.md --title "myapp 허브" --category index --seed
(2) 인수인계 문서(§10-M)를 Write로 작성 → 등록 (category=handoff, seed=true):
# 먼저 Write 도구로 projects/myapp/handoff.md 작성, 그 다음:
$PY scripts/register_doc.py --project myapp --file handoff.md --title "myapp 인수인계" --category handoff --seed
4-6. 빌드 & 검증
cd "$BASE"
$PY scripts/validate_manifest.py # exit 0 = OK
$PY scripts/build_brief.py # BRIEF.*.md 생성, 캡 초과 시 non-zero
ls -1 BRIEF.*.md
루트 HANDOFF.md의 시드 체인에 BRIEF.global.md·BRIEF.myapp.md가 가리켜지는지 확인.
git 사용 시 커밋:
git add -A && git commit -m "feat: first project (myapp) + built BRIEFs"
4-7. (선택) 자동 재빌드 / RAG
- C5=자동 → §6
- F1=Phase1/2 → §7
- F3=마이그레이션 → §8
각 단계 후 사용자에게 3단계 보고: ① 무엇을 했는지 ② 어떻게 검증했는지 ③ 다음 작업.
5. 일상 운영 런북 (구축 후)
이 절을
CLAUDE.md(§10-B)에도 요약해 둔다 — 어느 세션이 와도 동일 절차.
- 세션 시작:
HANDOFF.md → CLAUDE.md → BRIEF.global.md → BRIEF.<P>.md순서로 Read. (보통 여기까지면 충분) - 작업 전: ① 필요한 도구 점검·로드 ② 프로젝트
playbook에서 유사작업 절차 확인 ③ 포인터(handoff/design/memory-detail)만 열어 정보수집. - 작업 중·후(시드 갱신, 기본 동작): 상태·결정·진행이 바뀌면 그 답변 끝에
projects/<P>/handoff.md의 NOW + 루트HANDOFF.mdNOW를 갱신하고 (git이면) 로컬 커밋. - 결정이 생기면:
data/decisions.json에 1건 append(§10-E 형식) → 재빌드 시 BRIEF에 노출. - 문서 추가:
projects/<P>/<name>.md작성 →register_doc.py로 등록 → (git) 커밋. - 세션 종료(C3=개별):
templates/session.md복제 →projects/<P>/session-YYYY-MM-DD-주제.md작성 →register_doc.py --category archive→sessions.md인덱스에 1줄 추가. - 컨텍스트 안내(E1=추천): 40%부터 10%마다 안내, 60%에 새 세션 권고 + 현재 NOW·다음작업 요약(시드 먼저 갱신).
- 재빌드: 수동이면
bash scripts/build.sh. 자동(스케줄러)이면 입력 변경 후 5분 내 자동 — 수동 재빌드 금지.
6. (선택) 자동 재빌드 스케줄러
입력(docs_manifest.json·projects/**/*.md·data/*.json) 변경을 감지해 주기적으로 build.sh를 돌린다.
공통 감시 스크립트 scripts/auto_rebuild.sh(§10-N): 입력들의 해시를 .autorebuild_hash와 비교, 바뀌었으면 build.sh 실행.
OS별 5분 주기 등록:
- Windows (작업 스케줄러) — git-bash 경로 주의:
bash SCHTASKS_CMD='C:\Program Files\Git\bin\bash.exe -lc "cd /c/dev/brain && bash scripts/auto_rebuild.sh"' schtasks //Create //SC MINUTE //MO 5 //TN brain_autorebuild //TR "$SCHTASKS_CMD" //F # 확인: schtasks //Query //TN brain_autorebuild # 해제: schtasks //Delete //TN brain_autorebuild //F - macOS / Linux (cron):
bash ( crontab -l 2>/dev/null; echo "*/5 * * * * cd $BASE && bash scripts/auto_rebuild.sh >> /tmp/brain_autorebuild.log 2>&1" ) | crontab -
등록 후 로그(
/tmp/brain_autorebuild.log또는 Windows는 build.sh가 남기는 로그)에서FAIL build_brief.py(=BRIEF 캡 초과 등)를 주기 점검.
7. RAG 융합 — 단계안과 도입 기준 (핵심)
결론 먼저: 작은 규모에 풀 벡터RAG는 과투자다. 토큰0 결정형 시드 + 토픽 핀포인트가 작은 코퍼스에서는 더 정확하고 비용 0이다. RAG는 회상이 실제로 새기 시작할 때 얹는다.
7-0. 도입 임계표 (어느 Phase를 쓸지)
| 코퍼스 규모(체감) | 권장 | 이유 |
|---|---|---|
| ~50 노트 / ~100K 토큰 이하 | Phase 0 | 시드 체인 + mem_find 토픽으로 충분. RAG 불필요. |
| 50~150 노트 / 100K~300K 토큰 | Phase 1 (FTS5 BM25) | 추가 설치 0. 키워드 회상 보조. 한국어는 trigram 토크나이저. |
| 150+ 노트 / 300K 토큰↑ 또는 회상 실측 부족 | Phase 2 (하이브리드) | 의미 검색이 필요. 벡터+BM25+RRF. |
Phase 0 → 상위로 올리기 전 "미니 eval"(§7-3)로 recall@k를 실측한 뒤 결정한다. 감으로 올리지 말 것. (실제 운영 사례에서도 "현 규모에선 풀 벡터RAG 과투자" 결론.)
7-1. Phase 1 — FTS5 전문검색 (추가 설치 0)
파이썬 내장 sqlite3의 FTS5만 사용. 문서를 청크로 쪼개 BM25로 랭킹.
scripts/build_fts.py(§10-O):projects/**/*.md를 청크화해 FTS5 테이블 적재. 기본 토크나이저unicode61(한국어 2자 단어·영어 모두 매칭).- 한국어 조사·활용(롤백/롤백을/롤백이)은
search_brief.py가 질의어에 접두 매칭(*) 을 붙여 흡수한다 →롤백질의가롤백을까지 매칭. - ⚠️
trigram토크나이저는 부분일치엔 강하지만 2자 미만 질의(배포·설계·포트·절차 같은 한국어 2자 단어)를 전부 놓친다(실측). 한국어 노트는 2자 키워드가 흔하므로 기본은unicode61권장. 공백 없는 CJK 부분검색이 꼭 필요할 때만TOKENIZER=trigram(질의는 3자 이상으로). scripts/search_brief.py(§10-P): 질의 → 상위 k 청크의projects/<P>/<file>포인터 출력. 질의어는 자동으로 접두 매칭(*)·암묵 AND로 변환된다.- 운영: 시드 체인 복원 후, "어디 적었더라" 싶을 때
search_brief.py "키워드"→ 나온 파일만 Read. (검색은 위치만 찾고, 본문은 결정형 Read)
빌드·검색:
$PY scripts/build_fts.py
$PY scripts/search_brief.py "배포 롤백 절차" -k 5
7-2. Phase 2 — 하이브리드(벡터 + BM25 + RRF)
의미 기반 회상이 필요할 때. 설치 필요:
$PY -m pip install sentence-transformers sqlite-vec
# 한국어: 모델 nlpai-lab/KURE-v1 / 다국어: BAAI/bge-m3
구성:
- scripts/build_vec.py(§10-Q): 청크 임베딩 → sqlite-vec의 vec0 테이블 적재(+ FTS5도 함께 유지).
- scripts/search_hybrid.py(§10-R): 질의를 임베딩해 벡터 KNN과 FTS5 BM25 두 랭킹을 뽑고 RRF로 융합.
- RRF: score(d) = Σ 1/(k + rank_d), k=60. 두 랭커의 순위를 합산해 상위 재정렬.
- Windows 게이트: sqlite-vec 로드는 sqlite3 확장 로딩(enable_load_extension)이 켜진 빌드라야 한다. python.org 공식 빌드는 보통 OK. 실패 시 Phase 1로 폴백하고 사용자에게 보고.
임베딩 모델 메모(F2): KURE-v1=한국어 검색 SOTA급·로컬·무과금. bge-m3=다국어·롱컨텍스트. OpenAI=설치 가벼우나 키·과금·외부전송(민감자료 주의).
7-3. 미니 eval (올리기 전 필수 점검)
scripts/rag_eval.py(§10-S): 사용자가 "이 질문엔 이 파일이 답"이라고 적은 작은 정답셋(data/eval.jsonl, 10~20문항)에 대해 recall@k를 측정.
- Phase 0(토픽/수기) vs Phase 1 vs Phase 2를 같은 정답셋으로 비교 → 실측 이득이 있을 때만 상위 Phase 채택.
{"q": "배포 롤백 어떻게 했지", "answer_files": ["projects/myapp/handoff.md"]}
{"q": "DB 인덱스 결정", "answer_files": ["projects/myapp/memory.md"]}
7-4. RAG와 시드의 역할 분담 (혼동 금지)
- 시드 체인 = 1차 컨텍스트: 항상 결정형으로 먼저 복원(토큰0). RAG는 이를 대체하지 않는다.
- 토픽 그래프 = 무료 근사:
topics_vocab+mem_find로 GraphRAG 비슷한 교차연결을 비용 0으로 흉내. 풀 GraphRAG는 비도입 권장(작은 규모 과투자). - RAG = 보조 회상: 시드에 안 들어온 오래된·세부 노트를 "위치"로 찾아주는 역할. 찾은 파일은 여전히 Read로 정확히 읽는다.
- RAG가 가장 빛나는 곳 = 대량·반정형 로그성 데이터(대화로그·수집물). 정제된 시드 문서는 결정형 복원이 더 낫다.
8. (선택) 기존 자료 마이그레이션 (F3)
- F3=(2) 기존 폴더 .md 가져오기:
1. 원본 폴더를 Glob으로 훑어 목록화 → 사용자에게 "어느 프로젝트로 편입할지" 매핑 확인.
2.
inbox/로 복사(원본 불변) → 검토 후projects/<P>/로 승격. 3. 각 파일register_doc.py등록(카테고리 판단: 현재상태=handoff, 레퍼런스=memory, 완료이력=archive). - F3=(3) 노션 등 외부: 외부 export(.md/.html)를
inbox/에 staging → 정제 후 승격. (라이브projects/를 덮어쓰지 않는다.) - 마이그레이션 후
build.sh로 재빌드·검증, BRIEF 캡 초과 시 §10-H의 본문 앞쪽 핵심 배치 규칙으로 정리.
9. 트러블슈팅 & 함정 (초기 클로드 코드 공통)
- "파일 만들었다는데 디스크에 없음": 컨테이너 전용 도구(
create_file류)는 사용자 PC 경로에 안 쓰고도 성공 메시지를 낸다. 반드시 Write/Edit로 쓰고 직후ls/Read로 실재 확인. - 한글 깨짐(콘솔·스크립트): 스크립트 상단
sys.stdout.reconfigure(encoding="utf-8"). 파일 입출력 전부encoding="utf-8". 셸 인라인 한글은 피하고 Write로. - manifest 검증 실패:
projects/<P>/*.md와 manifest의 filename이 1:1이어야 한다. 파일만 있고 미등록 → 등록. 등록인데 파일 없음 → 파일 작성 또는 항목 제거. - BRIEF 캡 초과(
<<OVER CAP>>): handoff/memory가 비대. 완료 이력을archive.md로 cut 이관하고 handoff엔 1줄 포인터만. (캡 수치 임의 증가로 회피 금지) - FTS5/sqlite-vec 로드 실패: §4-1·§7-2 게이트 참고. 안 되면 한 단계 낮은 Phase로 폴백하고 사용자에게 보고.
- push 사고: 기본 로컬 커밋만. 원격은 사용자 명시 허용 시에만. push 전
git grep -iE "password|secret|token|api[_-]?key"로 민감값 스캔.
10. 부록 — 파일·스크립트 전문 (복붙용)
에이전트는 아래를 Write 도구로 그대로 작성한다(경로/슬러그만 사용자 값으로 치환). 모든
.py는 Python 3.8+ 표준 라이브러리만 쓴다(Phase 2 제외).
§10-A. HANDOFF.md (루트 시드 — 얇은 진입점)
# brain — 루트 시드 (얇은 진입점)
> 새 대화 진입점. 상세는 빌드 산출 BRIEF와 projects/ 문서에 있다.
> 갱신일: <YYYY-MM-DD>
## 새 대화 시드 체인 (토큰0 복원)
순서대로 읽는다:
1. CLAUDE.md — 작업 규약 + 도구 제약 + 세션 런북
2. BRIEF.global.md — 전 프로젝트 인덱스 (자동 생성)
3. BRIEF.<프로젝트>.md — 해당 프로젝트 압축 시드 (보통 이거면 충분)
필요 시 상세: projects/<P>/{handoff,design,archive,playbook,memory}.md
세부 메모리는 BRIEF에 본문이 없다 → `python scripts/mem_find.py --project <P> --topic domain:<X>`로 경로만 찾아 그 파일만 Read.
(선택) 검색: `python scripts/search_brief.py "<키워드>"` → 나온 파일만 Read.
## 작업 전 필수
① 도구 확인·로드 ② 프로젝트 playbook에서 유사작업 절차 확인 ③ 포인터만 열어 정보수집 후 작업.
## NOW (한눈에)
- (여기에 현재 진행/완료/미결을 1줄씩. 가장 최근이 위로.)
§10-B. CLAUDE.md (작업 규약 + 세션 런북)
# brain — 리포 지침 (모든 에이전트 공통)
## 절대 규칙
- BRIEF.*.md 는 빌드 산출물 — 수동 편집 금지(build_brief.py가 재생성). 내용은 projects/<P>/*.md에서 고친다.
- 민감정보(토큰·비번·키)는 시드·커밋에 넣지 않는다. .secrets/로 분리(.gitignore).
- 원격 push는 사용자 명시 허용 시에만. 기본 로컬 커밋.
- 파일 생성·수정은 Write/Edit로. 직후 실재 확인(ls/Read).
- 스크립트·파일 입출력은 UTF-8 고정.
## 세션 운영 런북 (단일 출처)
- 세션 시작: HANDOFF.md → CLAUDE.md → BRIEF.global.md → BRIEF.<P>.md 순 Read.
- 작업 전: ① 도구 점검·로드 ② playbook 유사작업 확인 ③ 포인터만 정보수집.
- 매 답변 후 시드 갱신(기본): 상태·결정·진행이 바뀌면 projects/<P>/handoff.md NOW + 루트 HANDOFF.md NOW 갱신 + (git)커밋. 단순 조회는 갱신 안 함.
- 결정 생기면: data/decisions.json에 1건 append.
- 문서 추가: projects/<P>/<name>.md 작성 → register_doc.py 등록 → 커밋.
- 세션 종료: templates/session.md 복제 → projects/<P>/session-YYYY-MM-DD-주제.md → register_doc.py --category archive → sessions.md에 1줄.
- 컨텍스트: 40%부터 10%마다 안내, 60%에 새 세션 권고(시드 먼저 갱신).
- 재빌드: 수동이면 bash scripts/build.sh. 자동 스케줄러 운용 시 수동 재빌드 금지.
## 카테고리 정의
index(허브·한줄목표) · handoff(현재상태·NOW·불변핵심) · memory(레퍼런스) ·
memory-detail(토픽 핀포인트 세부) · design(설계) · archive(완료이력) · playbook(유사작업 매뉴얼).
## 도구 함정 (매 세션 발동)
- 컨테이너 전용 도구로 만든 파일은 사용자 PC에 없을 수 있다 → Write/Edit + 실재 확인.
- 한글 알림/문구는 UTF-8 바이트로(셸 인라인 한글 금지).
§10-C. docs_manifest.json (초기값)
[]
§10-D. topics_vocab.json (C4=토픽 사용 시)
{
"kind": ["decision", "fact", "runbook", "learning", "config", "spec", "open"],
"domain": ["infra", "db", "deploy", "ops", "agent", "architecture", "conventions", "ui", "safety", "debug", "research", "writing"],
"env": ["prod", "test", "local"],
"svc": [],
"entity": [],
"ver": []
}
kind/domain/env는 통제 어휘(여기 없는 값은 검증 실패).svc/entity/ver는 자유값(형식만 검사). 새 domain이 필요하면 이 파일에 먼저 추가하고 등록한다.
§10-E. data/decisions.json (C2=결정 로그 시)
[
{
"id": "dec-example",
"project": "global",
"status": "active",
"title": "예시 결정",
"decision": "한 줄 요약 — 무엇을 어떻게 정했는지",
"source": "HANDOFF.md",
"essential": true
}
]
project는 프로젝트 슬러그 또는global.essential:true인 전역 결정만 모든 BRIEF에 인라인, 나머지 전역은 포인터로만 노출(반복 bloat 방지). 폐기 시status를"superseded"등으로.
§10-F. templates/session.md (C3=개별 문서 시)
<!-- 복제: projects/<P>/session-YYYY-MM-DD-<주제슬러그>.md (평면, 서브폴더 금지).
본문에 그 주제의 domain·svc 키워드를 실제로 써라(검색·관계 발췌가 잡힌다).
등록: register_doc.py --category archive --topics domain:<D> svc:<S> -->
# <날짜> 세션 — <주제 표시명>
## 논의·대안
<무엇을 논의했고 어떤 대안이 있었는지.>
## 결정
<확정된 결정.>
## 산출물·커밋
<만든 파일/문서, (git)커밋 해시.>
## 다음
<후속 작업.>
## 관계
- 관련 문서: <id1>, <id2>
§10-G. .gitignore
# 빌드/런타임 산출·캐시
__pycache__/
*.pyc
.venv/
data/*.sqlite
.autorebuild_hash
inbox/
# 민감정보 (D2)
.secrets/
*.env
# 잡파일
.DS_Store
Thumbs.db
주의:
BRIEF.*.md·docs_manifest.json·data/decisions.json은 git 추적 시드라 ignore 하지 않는다(에이전트가 빌드 없이 읽음).
§10-H. scripts/build_brief.py
#!/usr/bin/env python
"""manifest + 프로젝트 문서 + 결정 로그 -> BRIEF.<project>.md / BRIEF.global.md.
토큰0·결정형(LLM 호출 0). 로컬 파일만 읽어 카테고리 규칙대로 이어붙인다.
표준 라이브러리만 사용. 실행: python scripts/build_brief.py
"""
import json
import sys
from pathlib import Path
sys.stdout.reconfigure(encoding="utf-8") # windows cp949 콘솔 보호
BASE = Path(__file__).resolve().parent.parent
MANIFEST = BASE / "docs_manifest.json"
PROJECTS_DIR = BASE / "projects"
DECISIONS = BASE / "data" / "decisions.json"
CHAR_CAP = 20000 # 프로젝트 BRIEF 상한(초과 시 빌드 실패)
HEADROOM_WARN = 2000 # 여유가 이 미만이면 경고
BODY_EXCERPT_CHARS = 6000 # handoff/memory 본문 삽입 상한
INDEX_EXCERPT_CHARS = 800
DESIGN_EXCERPT_CHARS = 700
PLAYBOOK_EXCERPT_CHARS = 1400
def load_manifest():
return json.loads(MANIFEST.read_text(encoding="utf-8")) if MANIFEST.exists() else []
def load_decisions():
return json.loads(DECISIONS.read_text(encoding="utf-8")) if DECISIONS.exists() else []
def doc_text(project, filename):
return (PROJECTS_DIR / project / filename).read_text(encoding="utf-8").replace("\r\n", "\n").strip()
def pointer(project, filename):
return "projects/%s/%s" % (project, filename)
def excerpt(text, n):
text = text.strip()
return text if len(text) <= n else text[:n].rstrip() + " …(중략)"
def clip_body(text, n, ptr):
"""줄 경계에서 잘라 앞 n자만 삽입 + 전문 포인터(내용은 자르기만, 재작성 없음)."""
text = text.strip()
if len(text) <= n:
return text
head = text[:n]
blank = head.rfind("\n\n")
nl = head.rfind("\n")
cut = blank if blank >= int(n * 0.7) else (nl if nl > 0 else n)
return head[:cut].rstrip() + "\n\n…(중략 — 앞 %d자만 수록, 이하 전문 참조)\n→ 전문: %s" % (n, ptr)
def first_goal_line(text):
for line in text.splitlines():
s = line.strip()
if s and not s.startswith(("#", ">", "-", "|", "```")):
return s[:90] + ("…" if len(s) > 90 else "")
return ""
def by_cat(entries, cat):
return [e for e in entries if e["category"] == cat]
def build_project_brief(project, entries, decisions):
out = ["# BRIEF — %s" % project,
"> 자동 생성(토큰0·결정형). 출처: docs_manifest.json · data/decisions.json. 수동 편집 금지.", ""]
for e in by_cat(entries, "index"):
out += ["## 핵심 목표 — %s (index)" % e["title"],
excerpt(doc_text(project, e["filename"]), INDEX_EXCERPT_CHARS),
"→ 전문: %s" % pointer(project, e["filename"]), ""]
for e in by_cat(entries, "handoff"):
out += ["## %s (handoff · 본문)" % e["title"],
clip_body(doc_text(project, e["filename"]), BODY_EXCERPT_CHARS, pointer(project, e["filename"])), ""]
for e in by_cat(entries, "playbook"):
out += ["## %s (playbook · 유사작업 매뉴얼)" % e["title"],
excerpt(doc_text(project, e["filename"]), PLAYBOOK_EXCERPT_CHARS),
"→ 전문: %s" % pointer(project, e["filename"]), ""]
for e in by_cat(entries, "memory"):
out += ["## %s (memory · 본문)" % e["title"],
clip_body(doc_text(project, e["filename"]), BODY_EXCERPT_CHARS, pointer(project, e["filename"])), ""]
designs = by_cat(entries, "design")
if designs:
out.append("## 설계 문서 (design)")
for e in designs:
if e.get("seed"):
out += ["### %s" % e["title"],
excerpt(doc_text(project, e["filename"]), DESIGN_EXCERPT_CHARS),
"→ 전문: %s" % pointer(project, e["filename"])]
else:
out.append("- %s → %s" % (e["title"], pointer(project, e["filename"])))
out.append("")
mds = by_cat(entries, "memory-detail")
if mds:
out.append("## 세부 메모리 (memory-detail · 포인터)")
out += ["\n".join("- %s — %s" % (pointer(project, e["filename"]), " ".join(e.get("topics", []) or []))
for e in sorted(mds, key=lambda x: x["order"])), ""]
archives = by_cat(entries, "archive")
if archives:
out.append("## 아카이브 (archive · 포인터)")
for e in archives:
out.append("- %s → %s" % (e["title"], pointer(project, e["filename"])))
out.append("")
proj = [d for d in decisions if d.get("status") == "active" and d.get("project") == project]
g_ess = [d for d in decisions if d.get("status") == "active" and d.get("project") == "global" and d.get("essential")]
g_rest = [d for d in decisions if d.get("status") == "active" and d.get("project") == "global" and not d.get("essential")]
inline = proj + g_ess
if inline or g_rest:
out.append("## 활성 결정 (decisions)")
for d in inline:
scope = "전역" if d["project"] == "global" else project
out.append("- [%s] %s — %s (출처: %s)" % (scope, d["title"], d["decision"], d.get("source", "")))
if g_rest:
out.append("- (그 외 전역 결정 %d건 — 전문: data/decisions.json) %s"
% (len(g_rest), ", ".join(d["id"] for d in g_rest)))
out.append("")
return "\n".join(out).rstrip() + "\n"
def build_global(projects, by_proj):
out = ["# BRIEF — 글로벌 인덱스",
"> 자동 생성(토큰0). 프로젝트명 · 한 줄 목표.", "",
"## ⚙️ 작업 전 필수",
"1. 도구 확인·로드 2. 프로젝트 playbook 확인 3. 포인터만 수집 후 작업", ""]
for p in projects:
entries = by_proj[p]
idx = by_cat(entries, "index") or by_cat(entries, "memory") or entries
goal = first_goal_line(doc_text(p, idx[0]["filename"])) if idx else ""
out.append("- **%s** — %s" % (p, goal or "(목표 미기재)"))
return "\n".join(out).rstrip() + "\n"
def main():
entries = load_manifest()
decisions = load_decisions()
projects = sorted({e["project"] for e in entries})
by_proj = {p: sorted([e for e in entries if e["project"] == p], key=lambda e: e["order"]) for p in projects}
oversize = []
for p in projects:
text = build_project_brief(p, by_proj[p], decisions)
path = BASE / ("BRIEF.%s.md" % p)
path.write_text(text, encoding="utf-8")
n = len(text)
flag = " <<OVER CAP>>" if n > CHAR_CAP else ""
print("wrote %s : %d chars (cap %d)%s" % (path.name, n, CHAR_CAP, flag))
if n > CHAR_CAP:
oversize.append((p, n))
elif CHAR_CAP - n < HEADROOM_WARN:
print("WARN: %s headroom %d chars (<%d) — 완료 이력을 archive로 이관 권장" % (p, CHAR_CAP - n, HEADROOM_WARN))
gpath = BASE / "BRIEF.global.md"
gpath.write_text(build_global(projects, by_proj), encoding="utf-8")
print("wrote %s" % gpath.name)
if oversize:
raise SystemExit("BRIEF over cap: %s" % ", ".join("%s=%d" % (p, n) for p, n in oversize))
if __name__ == "__main__":
main()
§10-I. scripts/validate_manifest.py
#!/usr/bin/env python
"""docs_manifest.json ↔ projects/<P>/*.md 1:1 + 필드·토픽 검증. 위반 시 SystemExit(비0).
importable: from validate_manifest import validate_manifest
standalone : python scripts/validate_manifest.py
"""
import json
import re
import sys
from pathlib import Path
sys.stdout.reconfigure(encoding="utf-8")
BASE = Path(__file__).resolve().parent.parent
MANIFEST = BASE / "docs_manifest.json"
PROJECTS_DIR = BASE / "projects"
TOPICS_VOCAB = BASE / "topics_vocab.json"
ALLOWED = {"index", "handoff", "memory", "memory-detail", "design", "archive", "playbook"}
NAMESPACES = {"kind", "domain", "env", "svc", "entity", "ver"}
CONTROLLED = {"kind", "domain", "env"}
def load_vocab():
return json.loads(TOPICS_VOCAB.read_text(encoding="utf-8")) if TOPICS_VOCAB.exists() else {}
def validate_manifest():
entries = json.loads(MANIFEST.read_text(encoding="utf-8")) if MANIFEST.exists() else []
errors = []
REQUIRED = {"id": str, "project": str, "filename": str, "category": str,
"order": int, "title": str, "topics": list, "seed": bool}
slug_re = re.compile(r"^[a-z0-9-]+$")
topic_re = re.compile(r"^[a-z]+:[a-z0-9-]+$")
for i, e in enumerate(entries):
ref = e.get("id") or ("#%d" % i)
for key, typ in REQUIRED.items():
if key not in e:
errors.append("entry %r missing field %r" % (ref, key))
elif typ is int and isinstance(e[key], bool):
errors.append("entry %r field %r must be int, got bool" % (ref, key))
elif not isinstance(e[key], typ):
errors.append("entry %r field %r must be %s" % (ref, key, typ.__name__))
elif typ is list and not all(isinstance(x, str) for x in e[key]):
errors.append("entry %r field %r must be list[str]" % (ref, key))
eid = e.get("id")
if isinstance(eid, str) and not slug_re.match(eid):
errors.append("entry %r id not a slug" % eid)
seen = {}
for e in entries:
if not (e.get("title") or "").strip():
errors.append("entry id=%r blank title" % e.get("id"))
seen[e.get("id")] = seen.get(e.get("id"), 0) + 1
for eid, n in seen.items():
if n > 1:
errors.append("duplicate id %r (x%d)" % (eid, n))
by_proj = {}
for e in entries:
by_proj.setdefault(e["project"], set()).add(e["filename"])
on_disk = {}
if PROJECTS_DIR.exists():
for p in PROJECTS_DIR.glob("*/*.md"):
on_disk.setdefault(p.parent.name, set()).add(p.name)
for project in sorted(set(by_proj) | set(on_disk)):
listed = by_proj.get(project, set())
actual = on_disk.get(project, set())
for m in sorted(listed - actual):
errors.append("[%s] manifest lists %r but file missing" % (project, m))
for o in sorted(actual - listed):
errors.append("[%s] file %r exists but not in manifest" % (project, o))
for e in entries:
if e.get("category") not in ALLOWED:
errors.append("entry id=%r bad category %r" % (e.get("id"), e.get("category")))
vocab = load_vocab()
for e in entries:
topics = e.get("topics", []) or []
for t in topics:
if not topic_re.match(t):
errors.append("[%s] topic %r malformed" % (e.get("id"), t))
continue
ns, val = t.split(":", 1)
if ns not in NAMESPACES:
errors.append("[%s] topic %r bad namespace" % (e.get("id"), t))
elif ns in CONTROLLED and val not in vocab.get(ns, []):
errors.append("[%s] topic %r not in topics_vocab[%s]" % (e.get("id"), t, ns))
if "kind:learning" in topics and not any(t.startswith("domain:") for t in topics):
errors.append("[%s] kind:learning needs >=1 domain:" % e.get("id"))
if errors:
raise SystemExit("manifest validation FAILED (%d):\n - %s" % (len(errors), "\n - ".join(errors)))
return entries
def main():
entries = validate_manifest()
projects = sorted({e["project"] for e in entries})
print("manifest OK: %d entries, projects=%s" % (len(entries), ",".join(projects) or "(none)"))
if __name__ == "__main__":
main()
§10-J. scripts/register_doc.py (추가 전용, 검증 포함)
#!/usr/bin/env python
"""docs_manifest.json에 문서 1건 등록(추가 전용). 쓰고 나서 검증, 실패 시 롤백.
.md 파일은 먼저 존재해야 한다(이 도구는 파일을 만들지 않는다).
사용:
python scripts/register_doc.py --project P --file NAME.md --title "제목" --category CAT \
[--topics ns:val ...] [--id ID] [--order N] [--seed]
"""
import argparse
import json
import re
import sys
from pathlib import Path
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
BASE = Path(__file__).resolve().parent.parent
MANIFEST = BASE / "docs_manifest.json"
PROJECTS_DIR = BASE / "projects"
SCRIPTS_DIR = Path(__file__).resolve().parent
ALLOWED = {"index", "handoff", "memory", "memory-detail", "design", "archive", "playbook"}
SLUG = re.compile(r"^[a-z0-9-]+$")
TOPIC = re.compile(r"^[a-z]+:[a-z0-9-]+$")
NAMESPACES = {"kind", "domain", "env", "svc", "entity", "ver"}
CONTROLLED = {"kind", "domain", "env"}
def die(m):
print("register_doc ERROR: " + m, file=sys.stderr)
raise SystemExit(2)
def slugify(s):
return re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-")
def load_vocab():
p = BASE / "topics_vocab.json"
return json.loads(p.read_text(encoding="utf-8")) if p.exists() else {}
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--project", required=True)
ap.add_argument("--file", required=True)
ap.add_argument("--title", required=True)
ap.add_argument("--category", required=True)
ap.add_argument("--topics", nargs="*", default=[])
ap.add_argument("--id", default=None)
ap.add_argument("--order", type=int, default=None)
ap.add_argument("--seed", action="store_true")
a = ap.parse_args()
entries = json.loads(MANIFEST.read_text(encoding="utf-8")) if MANIFEST.exists() else []
backup = json.dumps(entries, ensure_ascii=False, indent=2) + "\n"
note = PROJECTS_DIR / a.project / a.file
if not note.is_file():
die("file not found: %s (.md를 먼저 작성하라)" % note)
if any(e.get("project") == a.project and e.get("filename") == a.file for e in entries):
die("already registered: %s/%s" % (a.project, a.file))
stem = Path(a.file).stem
eid = a.id or slugify("%s-%s" % (a.project, stem))
if not eid or not SLUG.match(eid):
die("id not a slug: %r (비ASCII 파일명은 --id 지정)" % eid)
if eid in {e.get("id") for e in entries}:
die("id exists: %s" % eid)
if a.category not in ALLOWED:
die("bad category %r (허용: %s)" % (a.category, sorted(ALLOWED)))
if not a.title.strip():
die("blank title")
vocab = load_vocab()
for t in a.topics:
if not TOPIC.match(t):
die("bad topic %r" % t)
ns, val = t.split(":", 1)
if ns not in NAMESPACES:
die("bad namespace %r" % t)
if ns in CONTROLLED and val not in vocab.get(ns, []):
die("topic %r not in topics_vocab[%s] (vocab에 먼저 추가)" % (t, ns))
if "kind:learning" in a.topics and not any(t.startswith("domain:") for t in a.topics):
die("kind:learning needs >=1 domain:")
if a.order is not None:
order = a.order
else:
po = [e["order"] for e in entries if e.get("project") == a.project
and isinstance(e.get("order"), int) and not isinstance(e.get("order"), bool)]
order = (max(po) + 1) if po else 1
entries.append({"id": eid, "project": a.project, "filename": a.file, "category": a.category,
"order": order, "title": a.title, "topics": a.topics, "seed": bool(a.seed)})
MANIFEST.write_text(json.dumps(entries, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
sys.path.insert(0, str(SCRIPTS_DIR))
from validate_manifest import validate_manifest
try:
validate_manifest()
except SystemExit as ex:
MANIFEST.write_text(backup, encoding="utf-8") # 롤백
die("validation failed — ROLLED BACK:\n%s" % ex)
print("registered %r (project=%s, category=%s, order=%d) — validate OK, %d entries"
% (eid, a.project, a.category, order, len(entries)))
if __name__ == "__main__":
main()
§10-K. scripts/mem_find.py (풀 — 토픽 핀포인트)
#!/usr/bin/env python
"""manifest topics(AND)로 노트 경로를 찾는다(jq 비의존). 에이전트가 필요한 노트만 Read.
예) python scripts/mem_find.py --project myapp --topic domain:deploy --kind decision
"""
import argparse
import json
from pathlib import Path
BASE = Path(__file__).resolve().parent.parent
MANIFEST = BASE / "docs_manifest.json"
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--project")
ap.add_argument("--topic", action="append", default=[]) # 반복 = AND
ap.add_argument("--kind")
ap.add_argument("--domain")
a = ap.parse_args()
docs = json.loads(MANIFEST.read_text(encoding="utf-8")) if MANIFEST.exists() else []
want = list(a.topic)
if a.kind:
want.append("kind:%s" % a.kind)
if a.domain:
want.append("domain:%s" % a.domain)
for e in docs:
if a.project and e.get("project") != a.project:
continue
topics = set(e.get("topics", []) or [])
if all(t in topics for t in want):
print("projects/%s/%s" % (e["project"], e["filename"]))
if __name__ == "__main__":
main()
§10-L. scripts/build.sh (검증→빌드 1커맨드)
#!/usr/bin/env bash
# 검증 -> BRIEF 재조립. (RAG를 쓰면 인덱스 재빌드도 이어붙인다.)
set -e
cd "$(dirname "$0")/.."
PY="${PY:-python}"
command -v "$PY" >/dev/null 2>&1 || PY=python3
echo "== validate =="
"$PY" scripts/validate_manifest.py
echo "== build_brief =="
"$PY" scripts/build_brief.py
# RAG Phase1 사용 시 주석 해제:
# echo "== build_fts =="; "$PY" scripts/build_fts.py
# RAG Phase2 사용 시:
# echo "== build_vec =="; "$PY" scripts/build_vec.py
echo "== done =="
§10-M. 첫 프로젝트 문서 템플릿
projects/<P>/hub.md (index):
# <P> — 프로젝트 허브
> <한 줄 목표>. 새 대화 진입점 = handoff → memory → design/archive/sessions.
---
*최종 갱신: <YYYY-MM-DD>*
projects/<P>/handoff.md (handoff):
# <P> — 인수인계 (현재 상태)
## NOW
- (진행 중 / 완료 / 미결을 1줄씩. 최신이 위로.)
## 불변 핵심 (경로·포트·인증·위험규칙·확정사실)
- (바뀌지 않는 사실. 여기 둔 건 archive로 안 옮긴다.)
## 다음 할 일
- (후속 작업.)
---
*최종 갱신: <YYYY-MM-DD>*
본문 작성 규칙: 핵심·NOW·미결은 앞쪽, 완료이력·부록은 뒤쪽(BRIEF 본문 클립이 앞부분을 살리므로).
§10-N. scripts/auto_rebuild.sh (C5=자동 시)
#!/usr/bin/env bash
# 입력 해시가 바뀌었을 때만 build.sh 실행. cron/작업스케줄러가 5분마다 호출.
cd "$(dirname "$0")/.."
PY="${PY:-python}"
command -v "$PY" >/dev/null 2>&1 || PY=python3
HASH_FILE=".autorebuild_hash"
# 입력: manifest + 프로젝트 문서 + 데이터(json). 산출물(BRIEF.*.md)·sqlite는 제외.
NEW_HASH="$(
{ find projects -name '*.md' -type f -print0 2>/dev/null;
printf '%s\0' docs_manifest.json topics_vocab.json data/decisions.json; } \
| sort -z | xargs -0 cat 2>/dev/null | "$PY" -c 'import sys,hashlib;print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())'
)"
OLD_HASH="$(cat "$HASH_FILE" 2>/dev/null || echo none)"
if [ "$NEW_HASH" = "$OLD_HASH" ]; then
exit 0
fi
echo "$(date '+%F %T') change detected -> rebuild"
if bash scripts/build.sh; then
echo "$NEW_HASH" > "$HASH_FILE"
echo "OK build"
else
echo "FAIL build_brief.py (캡 초과 등 — 본문 정리 필요)"
fi
§10-O. scripts/build_fts.py (RAG Phase 1 — 추가 설치 0)
#!/usr/bin/env python
"""projects/**/*.md를 청크화해 FTS5(BM25) 인덱스를 만든다. 표준 라이브러리만.
기본 토크나이저 unicode61 — 한국어 2자 단어(배포·설계·포트…)와 영어를 모두 매칭한다.
조사·활용(롤백/롤백을)은 search_brief.py가 접두 매칭(*)으로 흡수한다.
공백 없는 CJK 부분검색이 꼭 필요하면 TOKENIZER=trigram (단 2자 미만 질의는 매칭 안 됨).
"""
import os
import sqlite3
import sys
from pathlib import Path
sys.stdout.reconfigure(encoding="utf-8")
BASE = Path(__file__).resolve().parent.parent
PROJECTS_DIR = BASE / "projects"
DB = BASE / "data" / "rag.sqlite"
TOK = os.environ.get("TOKENIZER", "unicode61") # unicode61(기본) | trigram
CHUNK, OVERLAP = 1200, 200
def chunks(text):
text = text.replace("\r\n", "\n")
i, out = 0, []
while i < len(text):
out.append(text[i:i + CHUNK])
i += CHUNK - OVERLAP
return out or [""]
def main():
DB.parent.mkdir(parents=True, exist_ok=True)
con = sqlite3.connect(DB)
cur = con.cursor()
try:
cur.execute("CREATE VIRTUAL TABLE IF NOT EXISTS _probe USING fts5(x)")
cur.execute("DROP TABLE _probe")
except sqlite3.OperationalError as e:
raise SystemExit("이 Python sqlite3에 FTS5 없음: %s — python.org 공식 빌드 권장 또는 Phase 0." % e)
cur.execute("DROP TABLE IF EXISTS chunks")
try:
cur.execute("CREATE VIRTUAL TABLE chunks USING fts5(project, filename, body, tokenize='%s')" % TOK)
except sqlite3.OperationalError:
cur.execute("CREATE VIRTUAL TABLE chunks USING fts5(project, filename, body)") # 토크나이저 미지원 폴백
n = 0
for p in sorted(PROJECTS_DIR.glob("*/*.md")):
for ch in chunks(p.read_text(encoding="utf-8")):
cur.execute("INSERT INTO chunks(project, filename, body) VALUES(?,?,?)",
(p.parent.name, p.name, ch))
n += 1
con.commit()
con.close()
print("indexed %d chunks (tokenizer=%s) -> %s" % (n, TOK, DB))
if __name__ == "__main__":
main()
§10-P. scripts/search_brief.py (RAG Phase 1 — 질의)
#!/usr/bin/env python
"""FTS5 BM25로 상위 k 청크를 찾아 'projects/<P>/<file>' 포인터를 출력.
검색은 위치만 찾는다 — 본문은 에이전트가 그 파일을 Read로 정확히 읽는다.
질의어는 자동으로 접두 매칭(*)·암묵 AND로 변환 → 한국어 2자 단어·조사/활용까지 잡는다.
예) python scripts/search_brief.py "배포 롤백" -k 5 --project myapp
"""
import argparse
import re
import sqlite3
import sys
from pathlib import Path
sys.stdout.reconfigure(encoding="utf-8")
BASE = Path(__file__).resolve().parent.parent
DB = BASE / "data" / "rag.sqlite"
def to_query(user_q):
"""토큰(한글·영숫자)만 뽑아 각 토큰에 접두(*)를 붙인다. 공백=암묵 AND.
unicode61에서 '롤백'이 '롤백을'까지 매칭되게 하고, 사용자 구두점으로 인한
FTS5 문법 오류도 회피한다. 토큰이 없으면 원문 그대로."""
toks = re.findall(r"[0-9A-Za-z가-힣]+", user_q)
return " ".join(t + "*" for t in toks) if toks else user_q
def main():
ap = argparse.ArgumentParser()
ap.add_argument("query")
ap.add_argument("-k", type=int, default=5)
ap.add_argument("--project")
a = ap.parse_args()
if not DB.exists():
raise SystemExit("rag.sqlite 없음 — 먼저 build_fts.py 실행")
con = sqlite3.connect(DB)
sql = ("SELECT project, filename, snippet(chunks,2,'[',']','…',12), bm25(chunks) "
"FROM chunks WHERE chunks MATCH ?")
params = [to_query(a.query)]
if a.project:
sql += " AND project = ?"
params.append(a.project)
sql += " ORDER BY bm25(chunks) LIMIT ?"
params.append(a.k)
rows = con.execute(sql, params).fetchall()
if not rows:
print("(검색 결과 없음 — 키워드를 바꾸거나 trigram 인덱스 확인)")
for proj, fn, snip, score in rows:
print("[%.2f] projects/%s/%s\n %s\n" % (score, proj, fn, " ".join(snip.split())))
con.close()
if __name__ == "__main__":
main()
§10-Q. scripts/build_vec.py (RAG Phase 2 — 벡터, 설치 필요)
#!/usr/bin/env python
"""청크 임베딩을 sqlite-vec(vec0)에 적재. FTS5도 함께 유지(하이브리드용).
설치: python -m pip install sentence-transformers sqlite-vec
모델: 한국어 nlpai-lab/KURE-v1 / 다국어 BAAI/bge-m3 (MODEL 환경변수로 교체)
"""
import os
import sqlite3
import struct
import sys
from pathlib import Path
sys.stdout.reconfigure(encoding="utf-8")
BASE = Path(__file__).resolve().parent.parent
PROJECTS_DIR = BASE / "projects"
DB = BASE / "data" / "rag.sqlite"
MODEL = os.environ.get("EMBED_MODEL", "nlpai-lab/KURE-v1")
CHUNK, OVERLAP = 1200, 200
def chunks(text):
text = text.replace("\r\n", "\n")
i, out = 0, []
while i < len(text):
out.append(text[i:i + CHUNK])
i += CHUNK - OVERLAP
return out or [""]
def main():
try:
import sqlite_vec
from sentence_transformers import SentenceTransformer
except ImportError as e:
raise SystemExit("의존성 없음(%s) — pip install sentence-transformers sqlite-vec" % e)
DB.parent.mkdir(parents=True, exist_ok=True)
con = sqlite3.connect(DB)
try:
con.enable_load_extension(True)
sqlite_vec.load(con)
except Exception as e:
raise SystemExit("sqlite-vec 로드 실패(%s) — Windows는 확장로딩 가능한 python.org 빌드 필요. Phase 1로 폴백." % e)
model = SentenceTransformer(MODEL)
dim = model.get_sentence_embedding_dimension()
cur = con.cursor()
cur.execute("DROP TABLE IF EXISTS vchunks")
cur.execute("CREATE TABLE vchunks(id INTEGER PRIMARY KEY, project TEXT, filename TEXT, body TEXT)")
cur.execute("DROP TABLE IF EXISTS vec_idx")
cur.execute("CREATE VIRTUAL TABLE vec_idx USING vec0(embedding float[%d])" % dim)
rid = 0
rows = []
for p in sorted(PROJECTS_DIR.glob("*/*.md")):
for ch in chunks(p.read_text(encoding="utf-8")):
rows.append((rid, p.parent.name, p.name, ch))
rid += 1
if rows:
embs = model.encode([r[3] for r in rows], normalize_embeddings=True)
for (i, proj, fn, body), emb in zip(rows, embs):
cur.execute("INSERT INTO vchunks VALUES(?,?,?,?)", (i, proj, fn, body))
cur.execute("INSERT INTO vec_idx(rowid, embedding) VALUES(?,?)",
(i, struct.pack("%df" % dim, *emb)))
con.commit()
con.close()
print("embedded %d chunks (dim=%d, model=%s) -> %s" % (rid, dim, MODEL, DB))
if __name__ == "__main__":
main()
§10-R. scripts/search_hybrid.py (RAG Phase 2 — 벡터+BM25+RRF)
#!/usr/bin/env python
"""벡터 KNN과 FTS5 BM25 두 랭킹을 RRF로 융합해 상위 포인터 출력.
RRF: score(d) = Σ 1/(k + rank_d), k=60. (build_fts.py + build_vec.py 둘 다 선행)
예) python scripts/search_hybrid.py "인덱스 설계 결정" -k 5
"""
import argparse
import re
import sqlite3
import struct
import sys
from pathlib import Path
sys.stdout.reconfigure(encoding="utf-8")
BASE = Path(__file__).resolve().parent.parent
DB = BASE / "data" / "rag.sqlite"
RRF_K = 60
def to_query(user_q):
toks = re.findall(r"[0-9A-Za-z가-힣]+", user_q)
return " ".join(t + "*" for t in toks) if toks else user_q
def vec_rank(con, query, model, n=20):
import sqlite_vec # noqa
emb = model.encode([query], normalize_embeddings=True)[0]
dim = len(emb)
rows = con.execute(
"SELECT v.project, v.filename FROM vec_idx i JOIN vchunks v ON v.id=i.rowid "
"WHERE i.embedding MATCH ? ORDER BY distance LIMIT ?",
(struct.pack("%df" % dim, *emb), n)).fetchall()
return [(p, f) for p, f in rows]
def fts_rank(con, query, n=20):
rows = con.execute(
"SELECT project, filename FROM chunks WHERE chunks MATCH ? ORDER BY bm25(chunks) LIMIT ?",
(to_query(query), n)).fetchall()
return [(p, f) for p, f in rows]
def main():
ap = argparse.ArgumentParser()
ap.add_argument("query")
ap.add_argument("-k", type=int, default=5)
a = ap.parse_args()
if not DB.exists():
raise SystemExit("rag.sqlite 없음 — build_fts.py + build_vec.py 먼저")
try:
import sqlite_vec
from sentence_transformers import SentenceTransformer
import os
model = SentenceTransformer(os.environ.get("EMBED_MODEL", "nlpai-lab/KURE-v1"))
except Exception as e:
raise SystemExit("Phase2 의존성/모델 로드 실패(%s) — search_brief.py(Phase1) 사용" % e)
con = sqlite3.connect(DB)
con.enable_load_extension(True)
sqlite_vec.load(con)
scores = {}
for ranking in (vec_rank(con, a.query, model), fts_rank(con, a.query)):
for rank, key in enumerate(ranking):
scores[key] = scores.get(key, 0.0) + 1.0 / (RRF_K + rank)
con.close()
top = sorted(scores.items(), key=lambda kv: -kv[1])[:a.k]
if not top:
print("(결과 없음)")
for (proj, fn), sc in top:
print("[RRF %.4f] projects/%s/%s" % (sc, proj, fn))
if __name__ == "__main__":
main()
§10-S. scripts/rag_eval.py (도입 전 recall 실측)
#!/usr/bin/env python
"""작은 정답셋(data/eval.jsonl)으로 recall@k를 측정해 Phase 승급 근거를 만든다.
각 줄: {"q": "...", "answer_files": ["projects/myapp/handoff.md", ...]}
검색기 교체: --mode fts | hybrid (기본 fts)
"""
import argparse
import json
import subprocess
import sys
from pathlib import Path
sys.stdout.reconfigure(encoding="utf-8")
BASE = Path(__file__).resolve().parent.parent
EVAL = BASE / "data" / "eval.jsonl"
PY = sys.executable
def run_search(mode, q, k):
script = "search_hybrid.py" if mode == "hybrid" else "search_brief.py"
out = subprocess.run([PY, str(BASE / "scripts" / script), q, "-k", str(k)],
capture_output=True, text=True, encoding="utf-8").stdout
hits = []
for ln in out.splitlines():
if "projects/" in ln:
hits.append("projects/" + ln.split("projects/", 1)[1].strip())
return hits
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--mode", choices=["fts", "hybrid"], default="fts")
ap.add_argument("-k", type=int, default=5)
a = ap.parse_args()
if not EVAL.exists():
raise SystemExit("data/eval.jsonl 없음 — 10~20문항 정답셋을 먼저 작성")
total, got = 0, 0
for ln in EVAL.read_text(encoding="utf-8").splitlines():
ln = ln.strip()
if not ln:
continue
item = json.loads(ln)
hits = run_search(a.mode, item["q"], a.k)
want = set(item["answer_files"])
ok = any(any(w in h for h in hits) for w in want)
total += 1
got += 1 if ok else 0
print("%s q=%r -> %s" % ("HIT " if ok else "miss", item["q"], hits[:a.k]))
print("\nrecall@%d (%s): %d/%d = %.0f%%" % (a.k, a.mode, got, total, 100.0 * got / max(total, 1)))
if __name__ == "__main__":
main()
11. 빠른 시작 (요약 체크리스트 — 표준 프로파일)
에이전트가 "추천 기본값"으로 갈 때의 최단 경로:
[ ] §2 A~F 인터뷰 → §3 프로파일 확인
[ ] §4-1 python·git 확인/설치, FTS5 가용 확인
[ ] §4-2 mkdir 골격 + .gitignore(§10-G) + git init
[ ] §4-3 HANDOFF.md(§10-A) · CLAUDE.md(§10-B) 작성
[ ] §4-3 docs_manifest.json `[]`(§10-C) · topics_vocab.json(§10-D) · decisions.json(§10-E) · templates/session.md(§10-F)
[ ] §4-4 scripts: build_brief / validate_manifest / register_doc / mem_find / build.sh (§10-H~L)
[ ] §4-5 첫 프로젝트 hub.md · handoff.md(§10-M) 작성 + register_doc 등록
[ ] §4-6 validate_manifest → build_brief → BRIEF.*.md 확인 → 커밋
[ ] (C5 자동) §6 스케줄러 등록
[ ] (F1≥P1) §7 build_fts → search_brief, 필요시 §7-3 rag_eval로 승급 판단
[ ] 3단계 보고: 무엇을/어떻게 검증/다음
이 매뉴얼은 "토큰0 결정형 시드"를 1차, "토픽 핀포인트"를 2차, "RAG 검색"을 3차 회상층으로 둔 3단 구조다. 작게 시작(Phase 0)하고, 회상이 실제로 샐 때만 실측(rag_eval) 후 위로 올린다.