60.7 KB · 수정 2026-06-20 15:17
목차

user_brief 신규 구축 매뉴얼 (Claude Code 자동 실행용)

이 문서를 읽는 주체 = Claude Code(에이전트) 자신입니다. MCP·커스텀 스킬·외부 도구가 하나도 없는 초기 상태를 전제로, 내장 도구(Bash / Read / Write / Edit / Glob / Grep)와 직접 설치한 Python·git만으로 개인용 "두 번째 뇌(시드 메모리) + 선택적 RAG" 시스템을 처음부터 끝까지 구축합니다.

핵심 철학: 토큰0 결정형 시드 복원. LLM 호출 없이, 정해진 순서로 파일 몇 개만 읽으면 프로젝트 맥락이 그대로 복원됩니다. RAG는 그 위에 얹는 선택적 검색 레이어입니다.


0. 에이전트를 위한 실행 규약 (먼저 읽기)

당신(Claude Code)은 이 매뉴얼을 다음 순서로 처리합니다.

  1. 이 문서를 끝까지 1회 통독한다. (구조를 머리에 넣고 시작)
  2. §2 사용자 인터뷰의 카테고리별 질문을 사용자에게 제시한다. - AskUserQuestion 도구가 있으면 그걸로 묻는다. (이건 MCP가 아니라 하니스 내장 도구라 초기 상태에도 보통 사용 가능) - 없으면 질문을 번호 매긴 선택지로 채팅에 출력하고 사용자의 답을 기다린다. - 한 번에 다 묻지 말고 카테고리 묶음(A~F) 단위로 묻는다. 사용자가 "기본값으로"라고 하면 각 질문의 (추천) 선택지를 따른다.
  3. 답변을 프로파일 표로 정리해 사용자에게 1회 확인받는다. (§3)
  4. §4 구축 절차를 답변에 따라 분기 실행한다. 각 단계 끝에 검증 명령을 돌려 결과를 보고한다.
  5. 막히면 멈추고 전체 출력과 함께 보고한다. (임의로 우회하지 않는다)

0-1. 도구·셸 사용 원칙

0-2. 절대 규칙


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 검색

폴더 분류만 쓰는 사람과의 차이: 폴더는 "한 파일 = 한 위치"라 교차 맥락이 사라진다. 이 시스템은 토픽(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 1FTS5 키워드 전문검색(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별 설치:

(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

.gitignoreWrite 도구로 작성(아래 §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와 디스크의 .md1: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

각 단계 후 사용자에게 3단계 보고: ① 무엇을 했는지 ② 어떻게 검증했는지 ③ 다음 작업.


5. 일상 운영 런북 (구축 후)

이 절을 CLAUDE.md(§10-B)에도 요약해 둔다 — 어느 세션이 와도 동일 절차.


6. (선택) 자동 재빌드 스케줄러

입력(docs_manifest.json·projects/**/*.md·data/*.json) 변경을 감지해 주기적으로 build.sh를 돌린다.

공통 감시 스크립트 scripts/auto_rebuild.sh(§10-N): 입력들의 해시를 .autorebuild_hash와 비교, 바뀌었으면 build.sh 실행.

OS별 5분 주기 등록:

등록 후 로그(/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로 랭킹.

빌드·검색:

$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-vecvec0 테이블 적재(+ FTS5도 함께 유지). - scripts/search_hybrid.py(§10-R): 질의를 임베딩해 벡터 KNNFTS5 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와 시드의 역할 분담 (혼동 금지)


8. (선택) 기존 자료 마이그레이션 (F3)


9. 트러블슈팅 & 함정 (초기 클로드 코드 공통)


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.jsongit 추적 시드라 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) 후 위로 올린다.