Claude Code에서 npm test나 pytest를 실행하면 수백 줄의 PASS 메시지가 쏟아지고 정작 중요한 실패 한 줄은 그 사이에 묻힐 때가 있다. 에이전트는 실패 지점을 찾기 위해 도구를 다시 호출하거나 로그를 재조회한다. 마지막 몇십 줄만 보는 것도 해결책은 아니다. 예외가 중간에 발생했다면 마지막 부분엔 아무것도 없을 수 있다.

Hacker News의 Show HN에서 Context Diet를 발견했다. Claude Code의 Mod Hook으로 긴 도구 결과를 요약하고, 필요할 때 전체 로그를 다시 읽을 수 있는 파일 경로를 남기는 프로젝트다. 이런 접근은 마음에 들었다. 모델에게 무작정 줄여서 보여주는 대신, 원문으로 돌아갈 수 있는 색인을 주는 셈이니까.

다만 README에 적힌 “every failure”가 걸렸다. 문자 수 예산이 정해져 있는데 서로 다른 오류가 수십 개라면 정말 하나도 빠뜨리지 않을 수 있을까? 그래서 v0.3.0, commit 689284a로 버전을 고정한 뒤 Hook과 압축기, 테스트 코드를 읽고 범용 로그 축약 함수의 핵심 부분을 JavaScript로 옮겨 인위적인 로그에 적용했다.

먼저 범위를 분명히 하자. 실제 Claude Code에 Context Diet Mod를 설치하거나 실행하지 않았다. Claude 모델 A/B 테스트도 하지 않았다. 아래 숫자는 인공 입력에 대한 소스 기반 알고리즘 실험 결과이지, 실제 플러그인의 오류 누락률이나 토큰 절감률이 아니다.

모델이 보는 도구 결과를 줄이는 방식

고정 버전의 session.append Hook은 일정 길이를 넘는 Bash, Grep, Glob, JSON 형태 MCP 결과 등을 대상으로 한다. Read, Edit, Write, WebFetch와 JSON이 아닌 MCP 설명문은 이 축약 경로에 포함되지 않는다. 기본 트리거 임계값은 8,000자다. 8,000 토큰이 아니다.

무조건 tail처럼 끝부분만 남기지도 않는다. 도구별 파서는 Jest, Vitest, pytest, Go test, Cargo, TypeScript 검사기 출력을 식별하여 성공한 테스트보다 오류 위치와 주변 문맥을 우선한다. JSON은 뼈대만 남기고 Diff, Grep, 파일 목록도 서로 다른 방식으로 줄인다. 이 내용은 소스에서 확인한 처리 전략이며, 내가 실제 Claude Code에서 각 프레임워크를 실행한 호환성 결과는 아니다.

더 중요한 건 원본을 되찾는 방식이다. Hook은 요약으로 대체하기 전에 전체 결과를 .context-diet/out-NNN.txt에 저장하려고 한다. 파일 이름은 300개 슬롯을 순환한다. 저장에 실패하면 존재하지 않는 경로를 만들어 알려주는 대신 원본 출력을 그대로 통과시킨다.

하지만 디스크에 저장됐다는 것과 영구 보관은 다르다. 이전 슬롯은 재사용되고, 로그 안의 인증 정보·환경 변수·프로젝트 경로가 파일에 남을 수 있다. .gitignore는 Git에 실수로 추가할 위험을 줄일 뿐, 암호화나 백업을 보장하지 않는다. 운영 사고에 중요한 로그라면 별도 보관이 필요하다.

오류를 먼저 고른다고 모든 오류가 보이는 것은 아니다

범용 로그를 처리하는 diet.ts를 살펴봤다. 반복되는 줄을 묶고, 앞뒤 출력에 일정한 문자 예산을 할당하고, 뒤쪽 오류부터 찾아 주변 줄과 함께 보존하려고 한다.

결정적인 부분은 다음 조건문이다.

const cost = window.reduce((n, i) => n + (folded[i]?.text.length ?? 0) + 1, 0)
if (used + cost > target) continue

오류 구간이 남은 예산에 들어가지 않으면 그 구간을 건너뛴다. 오류가 한 건이면 꽤 효율적일 수 있지만, 서로 다른 실패가 여러 개 나오고 스택 트레이스까지 길면 일부 오류는 요약에 들어가지 못한다.

Jest/pytest처럼 식별 가능한 프레임워크 출력은 별도 파서를 사용하며 그쪽도 길이 제한이 있다. 아래 실험은 범용 로그의 fallback 경로를 대상으로 한다. Jest나 pytest 전용 파서에서 오류가 얼마나 남는지는 시험하지 않았다. 145개 합성 오류의 비율을 실제 Jest 오류 누락률로 해석하면 안 된다.

README의 “every failure”를 기술적으로 해석한다면 “실패를 우선 보여주고 전체 원문으로 돌아갈 수 있게 한다”에 가깝다. “어떤 입력에서도 모든 오류 줄을 유한한 길이의 요약에 보여준다”는 보증으로 볼 수 없다.

오류 하나와 서로 다른 오류 145개

이 문자 예산 문제를 확인하려고 Claude Code를 새로 구성하지는 않았다. 고정된 원본에서 범용 dietLog와 텍스트 헬퍼를 수동으로 JavaScript에 옮겨, 외부 의존성 없는 Node.js v22.16.0 격리 환경에서 돌렸다. 상대적으로 완전한 합성 테스트는 4/4 통과, 종료 코드 0이다. 공식 Mod 통합 테스트와는 별개다.

인공으로 만든 입력원본 길이요약 길이관찰
긴 로그 중앙에 핵심 오류 하나32,087자1,175자오류 표식 1/1개 보임
서로 다른 오류 145줄, 예산 1,450자23,146자1,419자원본 오류 줄 7/145개만 보임
짧은 출력45자45자변경 없음
PASS errorHandler.test.ts 분류 대조52자52자PASS를 오류로 오판하지 않음

두 번째 합성 사례에서는 원본 오류 138줄이 요약에 나타나지 않았다. 다음은 실제로 저장한 인공 로그 요약의 일부다. 실제 운영 프로젝트에서 발생한 오류가 아니다.

… [225 lines cut] …
ERROR CASEei unhandled category_ts in /app/modules/amy/workflow.ts:40 expected stable result bci
ERROR CASEej unhandled category_tt in /app/modules/amz/workflow.ts:40 expected stable result bcj
ERROR CASEek unhandled category_tu in /app/modules/ana/workflow.ts:40 expected stable result bck
…
… [79 lines cut] …

여기서 한 가지 계산상의 함정이 있었다. 압축기가 남긴 설명은 “5 of 145 error lines with context”였지만, 최종 요약에서 보이는 원본 오류 줄을 직접 세면 7개였다. 앞부분 또는 뒷부분에 남겨둔 출력에도 오류가 포함될 수 있기 때문이다. “선택된 오류 문맥 묶음 수”와 “최종적으로 눈에 보이는 오류 줄 수”는 같은 지표가 아니다.

실험의 1,450자 예산은 의도적으로 제한한 값이지 플러그인의 기본값이 아니다. 고정된 v0.3.0에서 압축 트리거는 기본 8,000자, 일반 긴 출력의 요약 목표는 Math.max(2_000, Math.round(threshold / 2))에 따라 4,000자다. Claude Code가 이미 파일에 저장한 출력은 3,000자 목표를 사용한다. 예산이 커지면 더 많은 오류를 담을 수 있지만 합성 실험만으로 실제 환경의 오류 누락률을 산출할 수는 없다.

독자가 직접 확인할 수 있게 문자 예산 선택 부분만 재현한 2개짜리 Node 표준 라이브러리 테스트도 실행했다. 이 작은 대조군에서는 합성 오류 줄이 좀 더 길어서 8/145줄이 남았고 2/2 통과했다. 앞선 7/145 사례와 입력·구현 범위가 달라 성능 비교처럼 섞어서는 안 된다.

아래는 검증한 최소 재현 코드 전체다. budget-replay.test.mjs로 저장한 다음 node --test budget-replay.test.mjs를 실행하면 된다.

/**
 * First-party negative control for Context Diet v0.3.0's generic dietLog budget path.
 * Pin: drkokorev/context-diet @ 689284a893f1aa509b9c6c1c06b91ef1ca52279c.
 * Only the error-window budget logic is mirrored, for controlled ASCII log lines.
 * This is NOT an upstream Mod invocation, a benchmark, or a production loss rate.
 * The full adapted dietLog fixture and observed digests are in the separate evidence bundle.
 */
import assert from 'node:assert/strict';
import test from 'node:test';

const id = i => { let s='',n=i+1; while(n){ n--;s=String.fromCharCode(97+n%26)+s;n=Math.floor(n/26); } return s; };
const noise = n => Array.from({length:n},(_,i)=>'step '+id(i)+' completed '+id(i+1200)+' '+i);
const critical = line => /^ERROR\b|^\s+(Expected|Received):/.test(line);
function select(raw,target){
  const folded=raw.split('\n').map(text=>({text,level:critical(text)?2:0}));
  const errorHits=folded.map((l,i)=>l.level===2?i:-1).filter(i=>i>=0);
  const keep=new Set();let used=0;
  const headBudget=target*(errorHits.length?0.1:0.2);
  const tailBudget=target*(errorHits.length?0.25:0.35);
  for(let i=0;i<folded.length&&used<headBudget;i++){keep.add(i);used+=folded[i].text.length+1;}
  let tailUsed=0;
  for(let i=folded.length-1;i>=0&&tailUsed<tailBudget;i--){
    if(!keep.has(i)){keep.add(i);tailUsed+=folded[i].text.length+1;}
  }
  used+=tailUsed;
  let windowsShown=0;
  for(let h=errorHits.length-1;h>=0;h--){
    const at=errorHits[h];const window=[];
    for(let i=at-2;i<=at+3;i++)if(i>=0&&i<folded.length&&!keep.has(i))window.push(i);
    const cost=window.reduce((n,i)=>n+folded[i].text.length+1,0);
    if(used+cost>target)continue; // EXACT budget guard from upstream dietLog.take()
    window.forEach(i=>keep.add(i));used+=cost;windowsShown++;
  }
  return {visible:folded.filter((_,i)=>keep.has(i)).map(l=>l.text).join('\n'),totalCritical:errorHits.length,windowsShown};
}
test('single late/central failure remains visible',()=>{
  const text=[...noise(310),'ERROR ECASEONE impossible transaction transition','  Expected: completed','  Received: pending',...noise(310)].join('\n');
  const out=select(text,2000);
  assert.ok(out.visible.includes('ECASEONE'));
});
test('many distinct failures cannot all fit finite digest budget',()=>{
  const failures=Array.from({length:145},(_,i)=>'ERROR CASE'+id(i)+' unhandled category_'+id(i+400)+' in /app/modules/'+id(i+900)+'/workflow.ts:40 expected stable result '+id(i+1300));
  const raw=[...noise(90),...failures,...noise(90)].join('\n');
  const out=select(raw,1450);
  const retained=failures.filter(line=>out.visible.includes(line)).length;
  assert.ok(retained>0&&retained<failures.length);
  process.stdout.write(JSON.stringify({failure_lines:failures.length,retained,missing:failures.length-retained,digest_budget_chars:1450,mode:'source-budget-window-control-not-plugin-runtime'})+'\n');
});

이 코드는 범용 로그의 문자 예산 선택 로직을 단순 재현한다. Claude Code Mod API를 호출하지 않는다. 테스트가 통과했다고 해서 실제 Agent의 문제 해결이나 외부 플러그인 동작까지 확인한 건 아니다.

내가 얻은 결론은 압축을 버리자는 게 아니다. 요약은 원본 증거를 찾아가기 위한 색인이지, 원본 증거 자체가 아니다. 이 구분을 놓치지 않아야 한다.

dry부터 확인하고 capture는 별도로 판단하자

Mod를 지원하는 Claude Code 환경이라면 고정 버전의 README와 현재 클라이언트 문서를 함께 확인하길 권한다. 당시 요구 버전은 Claude Code **2.1.287+**다. 나는 설치 과정을 실사용 환경에서 검증하지 않았다.

/plugin marketplace add drkokorev/context-diet
/plugin install context-diet@context-diet

시작은 /diet dry와 /diet report가 적절하다. dry 모드는 원문을 바꾸지 않은 채 얼마나 잘릴지 계산하는 용도다. 반드시 모든 로그를 읽어야 하는 명령은 /diet keep <명령 문자열 일부>로 제외하거나 /diet off로 기능을 끌 수 있다.

/diet capture on은 다른 문제를 다룬다. Claude Code 자체가 지나치게 긴 결과를 먼저 잘라 버릴 때, 특정 단일 테스트·빌드 Bash 명령을 감싸서 stdout과 stderr를 .context-diet/run-NN.log에 함께 기록하고 다시 출력하며 종료 코드를 보존하려고 한다. 기본값은 꺼짐이다.

명령을 감싸는 과정에서 Bash(pytest:*) 같은 기존 권한 허용 규칙과 일치하지 않아 추가 확인이 나올 수 있다. 두 스트림을 하나로 합치면 로그의 보이는 순서도 달라질 수 있다. 파이프, 여러 명령, 리다이렉션, watch/백그라운드 실행은 자동 capture 대상이 아니다. 권한 관리나 실시간 출력을 중시하는 환경이라면 무작정 켜지 않는 편이 좋다. 되돌릴 때는 /diet capture off를 사용한다.

원저자가 공개한 결과에 따르면 7개 Q&A 과제는 두 조건 모두 정답, 총 도구 호출은 13회에서 8회로 줄었다. 별도의 18회 Agent 수정 실행에서는 평균 호출이 미사용 8.0, 사용 6.0, capture 포함 5.8로 보고됐다. 이는 원저자의 소규모 집계이며 내가 모델 실행 로그나 비용을 독립적으로 재검증한 결과가 아니다.

테스트 통과 로그가 컨텍스트를 계속 차지한다면 Context Diet를 평가해 볼 만하다. 반면 사고 분석, 복잡한 동시 오류, 자동 PR 병합에서는 요약만으로 실패 목록이 완전하다고 판단하면 안 된다. Agent가 실제로 전체 파일을 다시 읽는지, 몇 개 보이는 실패만 고쳐서 PR을 만드는지는 따로 연구할 행동 문제다.

이전에 정리한 CLAUDE.md / AGENTS.md / Skills 지침 분리와 Agent 상태 인계는 무엇을 로드하고 누가 맡는지를 다뤘다. 이번 문제는 더 작고 직접적이다. 모델이 보고 있는 도구 결과가 완전한 증거인가, 아니면 증거로 돌아가는 경로가 적힌 메모인가?