본문으로 건너뛰기

silmari

에이전트용 md 문서의 흐름과 데이터를 검사하고 그래프로 보여주는 CLI · VS Code 확장

바이브 코딩으로 개발, 표기 결정은 실험으로 검증

개요

기간

2026.09

개요

개인 프로젝트

주요 기능

흐름 린트 26종, 그래프 뷰어, 그래프에서 본문 편집, sil init 진입점과 마이그레이션, sil run 서브에이전트 실행(claude · codex), VS Code 확장, 수만 문서 규모

사용 기술

TypeScriptReactNode.jsnpm PackageVS Code Extension

구현 기능 및 개발 과정

표준 Markdown 링크를 간선으로 쓰는 표기법 설계

remark 파싱간선보냄 · 받음서브에이전트md 문서IR링크값 표기헤딩 끝 라벨린트 규칙 26종그래프CLI · VS Code 확장
링크·값·라벨이 하나의 IR 로 모이고, 린트와 그래프가 같은 IR 을 읽어 CLI 와 VS Code 확장이 같은 결과를 냄.
demo-en 예제를 sil view 로 연 화면. 왼쪽 진입 문서에서 각 문서로 가는 호출 줄에 보냄·받음 값과 서브 에이전트 표시가 붙고, 없는 파일은 빨간 점선으로 감
문제

에이전트용 Markdown 문서에는 "이 문서를 읽고 이 값을 넘겨라" 같은 호출 지시가 일반 문장으로 적혀 있음. 문서가 늘어나면 어떤 문서가 다른 문서를 부르는지, 어떤 값이 오가는지 사람과 도구 모두 추적하기 어려워짐. 별도 문법을 추가하면 모델이 이를 알지 못하고 기존 문서도 모두 고쳐야 함.

분석

반복 전용 기호 `{{*}}`는 실험 후 제외함. 목록을 넘긴 뒤 호출된 문서가 항목을 하나씩 처리하게 하자 별도 기호가 없어도 모델이 3/3 모두 반복해, 기호를 추가해도 실행 결과가 달라지지 않음을 확인함.

해결

표준 Markdown 링크를 간선(문서 사이의 호출)으로 그대로 사용함. 전달할 값은 링크 뒤에 `{{>이름}}`(보냄)과 `{{<이름}}`(받음)으로 표시함. 서브에이전트를 사용할 구간은 헤딩 끝에 `((use a subagent))` 라벨을 붙임. 그 서브에이전트가 쓸 도구와 모델은 같은 문단에 "도구는 `{{+read}}`, 모델은 `{{#fast}}`를 사용한다"처럼 문장으로 적음.

해결

실행 순서는 줄 순서로, 조건은 헤딩으로, 반복은 조건 아래의 재호출로 표현함. 모든 표기가 기호이므로 내부에는 어떤 언어든 사용할 수 있으며, 표기법 자체에는 언어가 없음. remark(Markdown 문법을 읽는 파서)로 문서를 IR로 변환함. 린트와 그래프가 같은 IR을 사용하도록 core를 구성함.

결과

별도 표기가 없는 Markdown 문서도 오류로 처리하지 않아 기존 문서에 하나씩 적용할 수 있음. 파서·IR 변환·린트·그래프 생성을 순수 함수로 만들어 CLI와 VS Code 확장이 같은 결과를 냄.

호출하는 문서와 호출되는 문서의 계약 검사 (L-C01 · L-C02)

보내는 값받는 값{{>…}} · {{<…}} 목록없는 값없는 값일치호출 줄입력 목록 대조출력 목록 대조호출되는 문서L-C01 경고L-C02 경고통과
호출 줄이 보내고 받는 값을 호출되는 문서의 Inputs · Outputs 목록과 대조해 어긋나면 경고함.
같은 예제의 sil lint 출력. 없는 파일(L-N01), 호출되는 문서의 입력에 없는 값(L-C01), 아무도 부르지 않는 문서(L-G01)
문제

호출하는 문서가 보내는 값과 호출되는 문서가 받는 값이 달라도 일반 문장만으로는 문제를 찾기 어려움. 모델이 받지 않는 값을 넘기거나, 호출된 문서가 돌려주지 않는 값을 기다리는 흐름도 그대로 실행됨.

해결

호출되는 문서에서 제목이 기호 하나로 된 `## {{>Inputs}}` / `## {{<Outputs}}` 아래 목록을 계약으로 정함. 제목의 단어는 어떤 언어든 사용할 수 있어 언어별 단어 목록이 필요 없음. 계약 항목이 `이름 — 설명` 형식이면 이름 부분만 읽음. 이름 뒤의 `(path)` `(json)` 힌트는 값의 종류를 뜻함.

해결

호출 줄의 `{{>}}`가 입력 목록에 없으면 L-C01, `{{<}}`가 출력 목록에 없으면 L-C02 경고를 냄. 린트는 이름만 대조함. `sil run`은 실행 직전에 경로 존재 여부와 JSON 파싱 가능 여부를 검사함.

결과

없는 파일(L-N01), 없는 앵커(L-N09), 아무도 호출하지 않는 문서(L-G01), 조건 없이 같은 문서를 다시 호출하는 끝나지 않는 흐름(L-G06)까지 26개 규칙으로 검사하게 됨. 계약이나 호출이 있는 문서에만 적용되어 일반 Markdown에서는 경고가 발생하지 않음. 진단 메시지는 컴파일러 관례에 맞춰 영어로 통일되고, 화면 UI만 사용자의 언어로 표시됨.

수만 문서를 위한 뷰어 구조 — 지도 · 접기 · WebGL + DOM 가상화

하나만 열기기본 접힘2,000 노드 초과 · 축소 화면배율 0.35 이상 · 화면 안 400개46,833 문서흐름 지도흐름 하나하위 트리 접기WebGL 렌더DOM 가상화화면에 60여 개
지도에서 흐름 하나만 열고, 멀리서는 WebGL, 가까이서는 화면 안 노드만 DOM 으로 그려 4만 7천 문서에서도 실제로 그리는 노드는 60여 개임.
2,747 문서 스트레스 코퍼스의 흐름 지도. 흐름 8개가 상자 하나씩으로 보이고, 하나를 골라야 그래프를 그림
문제

수백 개 문서까지는 dagre(그래프 위치를 자동으로 정하는 라이브러리)와 React Flow를 사용해 한 번에 그릴 수 있음. 그러나 대기업 규모인 수만 개 문서에서는 위치 계산과 DOM 렌더링(브라우저 화면 요소로 그리는 작업)에 몇 초 이상 걸려 사용하기 어려움. 스트레스 코퍼스 생성기로 438 · 2,747 · 15,573 · 46,833개 문서 묶음을 만들어 직접 측정함.

해결

서로 링크된 문서 묶음을 각각 하나의 흐름으로 나눔. 흐름이 둘 이상이면 흐름마다 상자 하나만 표시하는 지도에서 시작함. 사용자가 고른 흐름 하나만 열고 하위 트리는 기본으로 접어 둠. 접힘 상태는 `.sil/layout.json`에 사용자별로 저장함.

해결

노드가 2,000개를 넘으면 축소 화면에서 WebGL(GPU로 그래픽을 그리는 브라우저 API)이 상자와 선을 그림. 배율 0.35부터는 현재 화면과 주변 한 화면 안의 노드만 최대 400개까지 DOM으로 표시함. 확대할 때 바로 나타나도록 해당 DOM 영역을 미리 만들어 두고, 위치 계산은 Web Worker로 옮김.

결과

4,684개 노드가 있는 흐름을 여는 시간이 DOM만 사용하면 8초, WebGL과 DOM을 나눈 두 겹 구조에서는 2초가 됨. 4만 7천 문서에서도 화면에 실제로 그리는 노드는 60여 개로 줄어듦. 진단 목록은 100개씩 더 불러오는 무한 스크롤로 바뀌어 수만 개가 있어도 가볍게 작동함.

sil init 진입점 — Claude 전용 가져오기 대신 범용 한 문장

모델이 읽지 않음3/34/4SILMARI.md 에만 규칙표기 준수 0/3CLAUDE.md 가져오기한 줄Claude 전용시작 파일 넷에 한문장네 도구 모두 준수sil initSILMARI.md 골격
규칙은 도구가 실제로 읽는 자리에 있어야 함. 가져오기 문법은 Claude 전용이라 버리고 네 시작 파일에 같은 한 문장을 넣음.
문제

표기 규칙을 SILMARI.md에 적어도 모델이 해당 파일을 읽지 않으면 적용되지 않음. 실험에서 규칙을 SILMARI.md에만 두었을 때 모델이 표기를 따른 결과는 0/3이었고, CLAUDE.md에 두었을 때는 3/3이었음. 어떤 도구도 SILMARI.md를 자동으로 읽지 않으므로, 실제로 읽는 시작 파일에서 규칙의 위치를 알려야 함.

분석

처음에는 CLAUDE.md에 `@SILMARI.md` 가져오기 한 줄을 넣어 3/3 결과를 얻음. 하지만 `@path` 가져오기와 `.claude/agents` 등록은 Claude Code에서만 작동하며 Codex와 Gemini에서는 작동하지 않음. 여러 도구에서 쓸 수 있어야 하므로 이 방식을 제외함.

해결

`sil init`이 CLAUDE.md · AGENTS.md · GEMINI.md · copilot-instructions.md 네 시작 파일에 "SILMARI.md가 이 프로젝트의 모든 진입점이다. 작업 전에 읽어라"라는 문장을 넣도록 함.

해결

사용자 언어를 `.sil/config.yaml`에 기록해 에이전트가 같은 언어를 사용하도록 함. 계약 제목도 `{{>…}}` 기호로 바꿔 언어별 단어 목록을 없앰. 파서는 언어를 구분하지 않음. 기호 안에 한국어·일본어·중국어를 넣어도 같은 결과를 냄.

결과

별도의 가져오기 문법이 없어도 네 도구 모두 SILMARI.md를 읽고 라벨을 정확히 작성하게 됨. 기존 문서가 있는 폴더에서는 에이전트가 마이그레이션 여부를 먼저 묻게 됨. 동의하면 SILMARI.md의 Migration 절에 따라 에이전트 하나가 파일 하나를 맡아 옮김. 이후 `sil lint` 오류 0을 확인하고 끝내며, silmari 자체는 파일을 수정하지 않음.

Writer — 바뀐 바이트만 쓰는 편집기

PATCH /bodies + 해시다름같음그래프에서 본문 편집해시 대조거부백업 한 개헤딩 바이트 범위만교체IR 의 바이트 범위
해시가 다르면 쓰지 않고, 같으면 백업을 남긴 뒤 헤딩 본문의 바이트 범위만 교체함.
노드를 선택하면 오른쪽에 프롬프트와 호출 목록이 열리고, 편집 ✎ 을 누르면 본문이 텍스트 필드가 됨. 저장은 바뀐 헤딩 본문만 씀
문제

비개발자가 그래프에서 프롬프트 본문을 고치려면 Markdown 파일에 변경 내용을 저장해야 함. 하지만 파일 전체를 다시 쓰면 사용자가 건드리지 않은 공백과 줄바꿈까지 달라질 수 있음. 편집하는 사이 다른 곳에서 바뀐 내용까지 덮어쓸 위험도 있음.

분석

간선 끝을 끌어 링크 대상만 바꾸는 방식도 검토함 → 두 단계 편집 기능과 겹쳐 제외함.

해결

파서가 각 헤딩 본문의 바이트 범위를 IR에 기록함. Writer는 해당 범위만 새 본문으로 바꾸고 나머지 바이트를 그대로 둠. 요청에 파일 해시를 함께 보내 서버의 해시와 다르면 저장을 거부함. 저장 전에는 파일마다 최신 백업 하나를 남김.

해결

편집을 두 단계로 나눔. 1단계에서는 선택한 문서와 해당 문서가 호출하는 문서의 헤딩 본문을 한 번에 저장함. 2단계에서는 파일 전체 원문을 편집함. 여러 헤딩을 한 번에 저장하는 PATCH /bodies에도 같은 규칙을 적용함.

결과

비개발자도 그래프에서 프롬프트를 수정할 수 있음. silmari는 사용자가 바꾼 부분에 해당하는 바이트만 씀.

직각 경로 · 불러오기 행 · 두 층 라벨 — 간선 렌더링 재설계

넓은 라벨 밑에 숨음 · 펼친 노드 관통입력은 읽는 노드 옆베지어 곡선 간선세로줄을 열 사이여백에 고정경로 네 종류를route.ts 하나로DOM 과 WebGL 이 같은꺾임점불러오기 라벨이 남의블록에 끼움노드 위 빈 행읽기 전용 파일도 그행에같은 대상 라벨 두개가 똑같음출발 → 도착 · 헤딩 →헤딩 두 줄
곡선이 라벨 밑에 숨고 펼친 노드를 뚫던 것을 세로줄을 열 사이 여백에 고정해 없애고, 불러오기 라벨은 읽는 노드 위 빈 행으로 옮겨 입력이 읽는 작업 옆에서 읽히게 함.
demo-en 흐름. 세로줄이 열 사이 여백에 서고, out/report.md 를 불러오는 wrap-up 위에 불러오기 행이 있으며, 라벨은 `출발 → 도착` 과 `## 헤딩 → ## 헤딩` 두 줄로 읽힘
문제

베지어 곡선 간선이 두 노드의 중점을 지나도록 그려져 넓은 라벨 밑에 숨었다가 엉뚱한 곳에서 나왔고, 노드를 펼치면 접힌 이웃에서 나온 선이 펼친 상자를 관통함. 라벨이 없는 참조 간선도 중점을 라벨처럼 지나 두 번 꺾여 물결처럼 보임. 불러오기 라벨은 파일 쪽 높이에 놓여 파일을 만든 스크립트 블록에 끼었고, 같은 문서의 같은 헤딩을 두 곳에서 참조하면 라벨 두 개가 똑같이 보임.

해결

모든 간선을 직각과 라운드 코너로 바꾸고, 세로 구간을 노드 열과 라벨 열 사이의 여백에 고정함. 라벨 경유·한 번 꺾음·같은 열·되돌아가는 통로의 네 경로를 `route.ts` 하나에서 꺾임점 목록으로 만듦. DOM과 WebGL이 같은 목록을 사용하도록 함. 앞 열로 되돌아가는 간선은 종류와 무관하게 통로를 타며, 통로는 노드와 이미 배치된 라벨을 모두 피함.

해결

불러오기 간선은 읽는 노드 위에 라벨 높이만큼 빈 행을 예약하고 위쪽 핸들로 들어오도록 함. 아무도 저장하지 않는 파일은 그 행에서 첫 읽는 노드 바로 위에 세움. 파일·라벨·노드가 세로 한 줄이 되게 함. 라벨 머리는 `출발 → 도착`, 둘째 줄은 `## 출발 헤딩 → ## 도착 헤딩`으로 구성해 두 층을 나눔.

결과

라벨 밑에 숨는 선과 펼친 노드를 관통하는 선이 사라짐. 불러오기 라벨은 읽는 작업 옆에 배치됨. 줄 순서(☰) 모드에서는 자식이 라벨 열과 맞물려 내려가 첫 호출이 항상 직선이 됨. 레이아웃 테스트 16건이 통과됨.

트러블 슈팅

서브에이전트 라벨 `[…]` 이 Markdown 참조 링크 검사와 충돌

참조 링크로 오해검사 끄기기호 15개 × 한·영 × 8회홑괄호 · 대시 2~3/8대괄호 14/16겹괄호 16/16헤딩 끝 대괄호 라벨Markdown 검사 경고사용자마다 설정실험탈락겹괄호 라벨 채택
검사를 끄는 대신 Markdown 이 뜻을 두지 않는 기호를 실험으로 골라 겹괄호 라벨로 교체함.
문제

헤딩 끝의 `[use a subagent]` 라벨을 VS Code 기본 Markdown 검사와 markdownlint가 "정의 없는 참조 링크"로 경고함. Markdown 규격에서 `[…]`는 참조 링크 문법이므로 검사기 관점에서는 올바른 경고임.

분석

시도 1: 확장 설정에서 참조 링크 검사만 끄는 방안을 검토함 → 사용자마다 설정해야 하며 markdownlint도 별도로 꺼야 하므로 제외함.

분석

시도 2: Markdown 문법에 영향을 주지 않고, 모델이 지시로 이해하며, 헤딩 끝에 우연히 들어갈 가능성이 낮은 기호로 후보를 좁힘 → `<…>`는 GitHub에서 HTML 태그로 사라지고, `~~…~~`는 취소선, `[[…]]`는 위키 링크로 쓰이므로 제외함.

분석

시도 3: 기호 15개를 한국어와 영어로 각각 8회씩 실행해 서브에이전트가 실제로 실행되는지 집계함 → 홑괄호·대시·화살표는 부가 설명으로 해석돼 2~3/8, 대괄호는 6/8, `((…))`는 8/8이었음. 상위 후보 넷을 표본 16으로 재측정해 `((…))` 16/16, `[…]` 14/16을 얻음. `!…!`와 `=…=`도 16/16이었으나 느낌표는 문장 끝에, 등호는 코드나 수식에 자주 나오므로 제외함.

분석

처음 두 차례 실험에서는 병렬 실행기(xargs -L 1)가 라벨의 띄어쓰기를 인자 구분으로 처리해 `[서브`만 헤딩에 들어갔음. 해당 결과는 버리고 라벨을 파일에서 읽도록 고친 뒤 다시 측정함.

해결

라벨을 `((use a subagent))`로 교체함. 기존 `[…]`는 그대로 읽되 L-I05로 새 표기를 안내함.

결과

모델(두 언어 8/8 · 16/16), Markdown 중립성(글자로만 남아 검사기 경고 없음), 우연한 충돌 없음, ASCII 입력의 네 조건을 모두 만족한 유일한 후보임.

4만 7천 문서 첫 로드 13초 — 60MB HTML 은 병목이 아니었음

가정: 60MB HTML측정첫 로드 13초부분 로딩 계획파싱 13.0초 · 조립0.2초mtime 캐시 +fs.watchgzip + 첫 페이지분리워커 스레드 파싱브라우저 O(n²) 연산둘진단 집계 ·union-find 수정변경 반영 0.6초지도 0.5초첫 파싱 4.5초
가정했던 60MB 대신 측정으로 찾은 재파싱·폴링·O(n²) 연산을 고쳐 지도 8.5초 → 0.5초.
흐름 하나(344 문서)를 전부 펼쳐 축소한 화면. 2,000 노드를 넘으면 WebGL 이 상자와 선만 그리고, 확대해야 화면 안 노드만 DOM 으로 바뀜
문제

46,833 문서 코퍼스에서 페이지를 열어 지도가 뜨기까지 8.5초, md 파일 하나를 고친 뒤 화면에 반영되기까지 13초가 걸림. 서버가 보내는 60MB HTML을 원인으로 가정함.

분석

부분 로딩을 구현하기 전에 시간을 측정함 → 첫 응답 13초 중 파일 읽기와 파싱에 13.0초, 그래프 조립에 0.2초, JSON 직렬화에 0.1초, 브라우저 파싱에 0.27초가 걸려 60MB는 병목이 아니었음. 실제 병목은 변경할 때마다 4만 7천 파일을 다시 파싱하는 작업과 2초마다 폴더 전체의 stat을 확인하는 폴링이었음.

분석

브라우저 프로파일에서 작업량이 제곱으로 늘어나는 연산 두 곳을 찾음 → 흐름별 진단 수를 셀 때마다 진단 목록 전체를 다시 훑는 연산(47k × 47k)과 경로 압축 없이 긴 연결을 따라가는 union-find였음.

해결

파싱 결과를 경로별로 캐시해 mtime과 크기가 같은 파일은 다시 읽지 않음. 폴링 대신 fs.watch로 변경된 파일만 다시 읽고 30초 간격의 안전 확인을 더함. 변경 없는 요청에는 ETag로 304를 보내며, 그래프 JSON은 gzip으로 압축함. 첫 페이지에서는 그래프 데이터를 제외함. 남은 첫 파싱은 워커 스레드로 나누고, 첫 그래프가 나올 때까지 현재 단계와 경과 시간을 표시하는 스피너를 붙임.

결과

페이지에서 지도까지 8.5초 → 0.5초, 파일 하나 수정 후 반영 13초 → 0.6초, 첫 파싱 13초 → 4.5초로 단축됨. 전송량은 첫 페이지 60MB → 450KB, 그래프 JSON 56MB → 5.7MB(gzip)로 감소함.

☰ 줄 순서 라벨이 칸 아래로 밀리는 문제

앞 줄로 세어짐세로줄 안에서만 세기☰ 줄 순서 배치라벨 순서 계산다른 세로줄의 참조라벨뒤 라벨이 아래로 밀림같은 세로줄 기준 계산빈 공간 없이 정렬
다른 세로줄의 참조 라벨이 앞 줄로 세어져 밀어내던 것을 같은 세로줄 안에서만 세도록 고침.
문제

라벨을 문서의 줄 순서(☰)로 배치하면 일부 라벨이 칸 아래로 처지고 빈 공간이 생김. ⌥(대상 노드 옆) 모드에서는 정상적으로 배치됨.

분석

라벨 순서를 정할 때 다른 세로줄에 있는 참조 라벨까지 앞선 항목으로 계산해 뒤쪽 라벨을 밀어내고 있었음.

해결

라벨 순서와 칸 높이를 같은 세로줄 안에서만 계산하도록 고침. 흐름 목록에서 Flow 10을 Flow 2보다 앞에 두던 문제도 숫자 기준 정렬로 함께 수정함.

결과

☰ 모드에서도 라벨이 빈 공간 없이 정렬됨. 흐름 목록 정렬(Flow 2 → Flow 10)도 함께 바로잡음.

결과 및 회고

결과

npm 패키지 silmari 0.1.2 와 VS Code 마켓플레이스 확장 0.1.1 을 배포함

린트 규칙 26종을 골든 코퍼스와 규칙 단위 테스트로 검증함

실제 오케스트레이터 4개(에이전트 16)를 새 표기로 옮겨 표기법이 실제 문서에서 통함을 확인함

46,833 문서 코퍼스에서 지도 0.5초, 변경 반영 0.6초, 첫 파싱 4.5초를 달성함

표기 실험 7세트를 기록으로 남겨 각 기호 결정의 근거를 추적할 수 있게 함

회고

16/16 도 보장이 아님. 모델이 판단하는 한 표기는 확률이며, 100% 에 가까운 것은 문구가 아니라 구조(등록·진입점)임을 확인함

최적화 전에 측정함. 60MB HTML 이라는 가정은 틀렸고, 실제 병목은 재파싱과 폴링이었음

Claude 전용 가져오기 문법 대신 어떤 도구든 읽는 한 문장을 택해 범용성을 지킴

커밋마다 "왜 그렇게 했는지" 를 DECISIONS.md 에 남겨 결정의 근거를 나중에도 확인할 수 있게 함

라이선스