에이전트 서피스
Tabula는 "AI 코딩 에이전트가 추측 없이 이 프로젝트를 올바르게 사용할 수 있다"를 부가적인 것이 아니라 빌드 요구사항으로 취급한다. 아래의 모든 아티팩트는 tabula build가 생성한다 — 그 어느 것도 손으로 유지보수되지 않으므로, 손으로 쓴 스타일 가이드처럼 레지스트리에서 어긋날 수 없다.
llms.txt와 llms-full.txt
.tabula/llms.txt는 짧은 형태다: 열 개의 법칙(concepts.md 참고)과 어떤 상황에 어떤 파일을 읽어야 하는지에 대한 지도다. 이는 2048바이트로 상한이 걸려 있다 — 렌더링된 파일이 그 예산을 초과하면 tabula build가 예외를 던진다, 그래서 모델이 시스템 프롬프트에서 안정적으로 주의를 기울일 수 있는 범위를 조용히 넘어서는 일이 결코 없다. 세션을 시작할 때 한 번 읽는다:
# Tabula — flat styling profile for reference-ui@1
## The ten laws
1. Only classes in vocabulary.txt exist; anything else emits NO CSS, silently. ...
...
## Files (.tabula/)
- vocabulary.txt — every legal class. READ BEFORE WRITING A CLASS.
- tokens.resolved.json — every literal, per axis. READ BEFORE CHOOSING A VALUE.
- registry.json — class → declarations, slots, rank. Ground truth.
- llms-full.txt — full reference (vocabulary, merge, bans)..tabula/llms-full.txt는 긴 형태로, 안정적인 ## 헤딩 아래 조직되어 있어 검색(retrieval) 단계가 파일 전체가 아니라 한 섹션만 끌어올 수 있다: ## The ten laws, with the reason each exists, ## The merge algorithm(손으로, 단계별로 풀어씀), ## Worked examples, ## Vocabulary — <family>(클래스 패밀리별 하나의 섹션, 각 클래스의 실제 declaration과 설명), ## Banned mechanisms, ## Error codes(모든 TAB-Exxx/TAB-Wxxx와 그 원인 및 해결법), 그리고 ## Exceptions — do not imitate these.
MCP 서버
@tabula-css/mcp(바이너리 tabula-mcp)는 여러분 프로젝트의 .tabula/ 아티팩트 위에서 동작하는 읽기 전용 stdio 서버다. 마지막 빌드 이후 토큰이나 설정이 바뀌었다면 모든 도구 호출을 STALE_REGISTRY 결과(수정 명령 포함)로 거부하므로, 에이전트가 더 이상 프로젝트를 설명하지 못하는 레지스트리로부터 답을 받는 일은 없다. 모든 응답은 { profileVersion, sourceHash, stale, staleCheck? }를 담고 있다 — 마지막 필드가 무엇을 의미하고 왜 에이전트가 그것을 읽어야 하는지는 오래됨과 아티팩트 전용 검사를 참고하라.
| 도구 | 답하는 것 |
|---|---|
resolve_classes | "이 엘리먼트는 실제로 어떻게 생겼는가?" — 클래스 문자열에 대한 완전한 로컬 스타일링 모델: 기본 선언, 조건부 밴드, 앰비언트(ambient) 속성, 원자적·알려지지 않은 클래스, 선언된 그룹 의존성. |
preview_merge | "이 cn() 호출은 무엇을 만들어낼까?" — 병합된 문자열과, 탈락한 모든 클래스, 무엇이 그것을 가렸는지, 어떤 CSS 프로퍼티에서 그랬는지. |
find_class_for | 의도(intent)에 의한 역방향 조회 — 여기서 가장 가치 있는 도구다. { intent: "raised card background" } → bg-surface-raised. 실제 클래스 이름, 패밀리, 선언된 값, 토큰 설명과 매치하며, 결코 동의어 테이블이나 퍼지 스코어가 아니다. |
get_tokens | 모든 토큰(또는 네임스페이스/부분 문자열 슬라이스)을, tokens.resolved.json으로부터 곧바로. |
get_vocabulary | 페이지네이션된 전체 클래스 목록(결코 조용히 잘리지 않는다 — hasMore/pages 필드가 명시적으로 알려준다). |
explain_ban | 클래스가 왜 금지되었거나 등록되지 않았는지, 대체안과 함께 — 결코 벌거벗은 "찾을 수 없음"이 아니다. 그것이야말로 에이전트를 arbitrary value 발명 쪽으로 떠미는 것이다. |
find_group_marker | 이름 있는 group/<name> 마커가 어디에 선언되어 있는지 — 엘리먼트 간 의존성이 트리 순회가 아니라 조회가 되도록. |
check_classes | 사전 점검(pre-flight): 클래스 속성을 작성하기 전에 호출하라. 알 수 없는 클래스(비슷한 이름 제안 포함), 금지된 메커니즘, 동일 문자열 내 슬롯 충돌을 보고한다. |
resolve_element | { file, line, col }로 JSX 엘리먼트를 지정하면 자신의 해석된 클래스, 같은 파일 안의 조상으로부터 상속받는 텍스트 컨텍스트, 그리고 참조하는 각 group/peer가 그 파일 안에 마커를 가지고 있는지를 얻는다. 컴포넌트 경계에서는 status: "unknown"과 함께 다음에 열어야 할 파일을 반환한다 — 컴포넌트가 무엇을 렌더링하는지 결코 추측하지 않는다. |
validate_source | 실제 strict ESLint 설정으로 레지스트리에 대해 소스 문자열을 린트한다. AST를 보기 때문에 check_classes가 구조적으로 할 수 없는 것을 잡아낸다: 런타임 클래스 구성, className 순서, group/peer 구조, 상속 경계, 예외 범위(exception scope). 인라인 eslint-disable 주석은 무시되므로, 스니펫이 말로 둘러대어 깨끗한 판정을 받을 수 없다. |
propose_exception | 실제 토큰 validator로 제안된 예외를 검증하고 패치를 반환한다 — 아무것도 기록하지 않는다. 요청된 값의 약 5% 이내에 기존 토큰이 있으면 그쪽으로 넛지(nudge)하기도 한다. |
propose_token | 일반 토큰에 대한 같은 기능 — 토큰은 영구적인 어휘이고 예외는 만료가 있는 페이퍼워크이므로, 가장 먼저 시도해야 할 것이다. 실제 토큰 문서에 스플라이스해 넣고 실제 validator를 돌려 검증하므로, 불완전한 축 맵이나 무의미한 값은 빌드 시점이 아니라 여기서 거부된다. 패치를 반환한다 — 아무것도 기록하지 않는다. |
doctor | 헬스 체크: 오래됨, 드리프트, 만료 예정 예외, escape 예산 — 오래된 상태에서도 답한다. 오래됨을 보고하는 도구가 오래됐다는 이유로 거부한다면 무엇이 드리프트했는지 결코 말할 수 없기 때문이다. |
explain | @tabula-css/core의 고정된 카탈로그로부터, TAB-Exxx/TAB-Wxxx 코드의 원인과 해결법. |
모든 도구는 예외 없이 한 가지 규칙을 따른다: 읽어낸 것이 아니라 추론된 값은 절대 반환하지 않는다. 알 수 없는 클래스는 제안과 함께 보고될 뿐 결코 조용히 교정되지 않으며, 넛지의 허용 오차 밖의 값은 잘못된 추측이 아니라 침묵으로 응답된다. 이 서피스 전체가 존재하는 이유는 모델이 한 번도 조회한 적 없는 그럴듯한 값을 자신 있게 내놓는 실패를 막기 위해서다 — 추측하는 MCP 도구는 도구 호출이라는 권위를 등에 업고 바로 그 실패를 재현할 것이다.
의존하기 전에 알아야 할 두 가지 한계.
resolve_element는 같은 파일 내부만 분석하며, 이를 감추지 않고 있는 그대로 말한다. 컴포넌트 조상이거나, 클래스 문자열이 런타임에 만들어지는 조상을 만나면status: "unknown"과 이름 붙은 다음 단계로 순회를 끝낸다. 그것이 정직한 답이다:<Card>가 무엇을 렌더링하는지는 그것을 사용하는 파일만으로는 알 수 없으며, 추측하는 도구는 바로 그런, 정말 중요한 경우에 틀릴 것이다. 또한axisValues를 완전히 생략한다 — 엘리먼트가 어떤 축 조합 아래 렌더링되는지는 소스가 아니라 실행 중인 문서에 대한 사실이므로, 그 대신 명시적인axes인자와 함께resolve_classes를 가리켜, 기본 조합의 숫자를 마치 그렇지 않은 것처럼 필드 이름 아래 인용하지 않는다.
validate_source는eslint와@typescript-eslint/parser가 설치되어 있어야 한다 (@tabula-css/mcp의 선택적 peer다). 없으면LINTER_UNAVAILABLE과 설치 명령을 반환한다 — 소스 린트인 척 꾸민 부분적인 레지스트리 전용 검사가 아니라. 그 답변은 또한ruleCount를 담고 있어서, 판정이 빈 설정이 아니라 N개의 실제 규칙에서 나왔음을 알 수 있다;ruleCount: 0인ok: true는 고무 도장(rubber stamp)일 뿐이며, 이것이 바로 그것을 드러나게 만드는 요소다.
오래됨과 아티팩트 전용 검사
오래됨(staleness)은 모든 요청에서 다시 계산되므로(아티팩트의 지문(fingerprint)이 바뀌면 호스트가 .tabula/를 다시 읽는다), 편집-후-재빌드 루프는 안전하다: 토큰을 편집하고 tabula build를 실행한 뒤 다시 물어보는 에이전트는 재시작 없이 같은 서버로부터 새 답을 얻는다.
이 검사는 세 개의 다리(leg)로 이루어진다:
- 자기 일관성(Self-consistency) —
manifest.inputsHash대registry.sourceHash: 이 아티팩트들이 한 번의 빌드로 작성되었는가? - 무결성(Integrity) — 각 아티팩트의 sha256 대 매니페스트의 다이제스트: 하나가 손편집되었거나, 빌드가 쓰는 도중에 중단되지는 않았는가?
- 소스 드리프트(Source drift) — 디스크 위의 토큰과 설정을 다시 해시하여
manifest.inputsHash와 비교한다. *"토큰이 편집되었지만 재빌드된 적이 없다"*는, 가장 흔한 경우를 잡아내는 것은 이 다리뿐이다.
다리 3은 설치된 tailwindcss와 @tabula-css/* 패키지의 버전을 해석해야 하는데, 이들이 입력 해시(inputs hash)의 일부이기 때문이다. 이 중 어느 것도 해석할 수 없으면 다리 3은 실행되지 않는다. 이는 격리되었거나 pnpm 스타일의 node_modules 레이아웃에서, 또는 프로젝트 소스가 서버의 작업 디렉터리에서 읽을 수 없을 때 발생한다.
서버는 이를 감추지 않는다. 다리 3이 건너뛰어졌을 때, 모든 응답은 다음을 담는다:
{ "profileVersion": "…", "sourceHash": "…", "stale": false, "staleCheck": "artifacts-only" }에이전트가 staleCheck: "artifacts-only"를 봤을 때 해야 할 일: stale: false를 평소보다 약한 증거로 취급하라. 이는 "이 아티팩트들은 내부적으로 일관되고 수정되지 않았다"는 뜻이지, "현재 토큰 파일과 일치한다"는 뜻은 아니다. 마지막 빌드 이후 편집된 토큰은 감지되지 않으며, 얻는 답은 최신이라고 주장하면서도 편집 이전의 프로파일을 설명할 것이다. 그런 상태에서 값을 신뢰하기 전에 tabula build(또는 드리프트가 있으면 종료 코드 1을 반환하고 아무것도 기록하지 않는 tabula build --check)를 실행하고 다시 물어보라. 그 필드가 부재하는 것이 강한 경우다: 다리 3이 실행되었고, stale: false는 레지스트리가 소스와 일치함을 뜻한다.
AGENTS.md.snippet
.tabula/AGENTS.md.snippet은 프로젝트의 AGENTS.md나 CLAUDE.md에 바로 붙여넣을 수 있는 블록이다: llms.txt와 같은 규칙을 직접적인 지시문으로 표현한 것에, 등록된 클래스의 실시간 개수를 더한 것이다(examples/reference-ui에서는 Only classes in .tabula/vocabulary.txt exist (477 of them)). 붙여넣는 것이 통합 절차의 전부다 — 매번 tabula build마다 재생성되므로, 어휘가 늘거나 줄어도 수동으로 유지보수할 필요가 전혀 없다.
실패가 보이는 것에 대한 보장
등록되지 않은 클래스는 기본적으로 결코 런타임 에러가 아니다 — 빌드의 source(none) + @source inline(...) 설정은 Tailwind가 닫힌 어휘 바깥의 무엇도 결코 스캔하지 않는다는 것을 의미하므로, 등록되지 않은 클래스는 그저 아무 CSS도 만들어내지 않는다. 이는 의도된 것이며, cn()이 환경에 따라 다르게 동작하는 이유이기도 하다:
- 개발 환경(
isDev()가 true):cn()은 알 수 없는 클래스에서 던진다(throws), 레벤슈타인 거리(Levenshtein distance)로 가장 가까운 등록된 이름들과 함께(TAB-E300) — 오타는 로컬 빌드를 시끄럽게, 즉시, 호출 지점에서 실패시킨다. - 프로덕션:
cn()은 결코 던지지 않는다. 알 수 없는 클래스는 opaque하게 유지된다 — 어떤 슬롯도 소유하지 않고, 결코 탈락하지 않고, 다른 클래스를 가리지도 않는다 — 그리고 라이브 렌더를 중단시키지 않으면서도 눈에 띄도록console.error로 한 번 로깅된다. 이는 또한<TabulaAudit>(개발 전용 DOM 정합성 체커)이 개발 중에 잡아낼 수 있도록 기록된다.
같은 비대칭이 dyn()(승인된 인라인 스타일 이스케이프 해치)에도 적용된다: 등록되지 않은 --d-* 키는 개발 환경에서 던지고(TAB-E304) 프로덕션에서는 조용히 버려진다. 이 프로파일에서 보이지 않게 실패하는 것은 없다 — 중요한 모든 게이트(알 수 없는 클래스, 병합 건전성, 등록되지 않은 동적 프로퍼티)는 개발자나 에이전트가 실제로 볼 수 있는 지점에서 시끄럽게 던진다.