달력

8

« 2026/8 »

  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31

실수로 공개된 512,000줄의 TypeScript가 알려주는 프로덕션 에이전트가 긴 대화 속에서 살아남는 법

📅 발행일: 2026년 8월 2일 ・ 🏷️ 태그: #번역 #소스코드분석 #ClaudeCode #AI에이전트 #LLM #컨텍스트윈도우 #컴팩션 #메모리 ・ 📚 원문: Medium 원문 보기

📌 핵심 요약 (TL;DR)
- 컴팩션(Compaction) 8단계 캐스케이드: 긴 대화에서 컨텍스트 윈도우가 가득 차면, 하나의 도구가 아니라 비용이 싼 순서대로 8가지 메커니즘이 순차 실행됩니다.
- 메모리 3계층: 컨텍스트 내부 메모리(Tier 1) → 파일 기반 영구 메모리(Tier 2, MEMORY.md) → 지침 메모리(Tier 3, CLAUDE.md)로 나뉩니다.
- 44개 기능 플래그: 이미 배포됐지만 문서화되지 않은 것(가짜 도구, 잠행 모드, 모델 자동 다운그레이드 등)과 아직 미공개인 것(KAIROS, ULTRAPLAN 등)이 섞여 있습니다.
- 서킷 브레이커: MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES = 3 — 자동 컴팩션이 3번 연속 실패하면 세션 종료까지 비활성화됩니다. "에이전트가 조용히 멍해지는" 순간의 정체입니다.

긴 클로드 코드(Claude Code) 세션을 진행하다 보면, 작업 중이던 무언가가 조용히 개선을 멈추는 순간이 옵니다. 에이전트는 크래시하지도 않고, 경고도 하지 않습니다. 그저 자신의 컨텍스트 윈도우에서 여유 공간을 확보하는 능력을 잃고, 그 시점부터 모든 답변이 조금씩 나빠지기 시작합니다.

유출된 클로드 코드 소스에는 이 현상이 정확히 언제 발생하는지 설명하는 한 줄이 있습니다. autoCompact.ts 파일 안에 있는 이 코드입니다:

const MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES = 3;

그것뿐입니다. 세 번 실패하면 시스템은 포기합니다.

컴팩션(Compaction)은 컨텍스트 윈도우가 차오르기 시작할 때 에이전트가 긴 대화에서 공간을 확보하는 과정을 가리키는 용어입니다. @anthropic-ai/claude-code 버전 2.1.88이 npm에 배포될 때, 59.8MB짜리 JavaScript 소스 맵이 패키지 안에 함께 번들되었습니다. 배포 스크립트에 .map 파일에 대한 제외 규칙이 빠져 있었던 것입니다. 그 작은 실수로 약 512,000줄에 달하는 복잡한 클라이언트 소스가 세상에 공개되었습니다.

Anthropic은 몇 시간 만에 패키지를 내렸습니다. 회사는 보안 침해가 아닌 패키징 실수라고 확인했습니다. 그러나 npm 레지스트리는 관대하지 않았고, 자동 미러들이 이미 아티팩트를 복제해 두었습니다.

Anthropic이 4월 23일 품질 보고서에 대한 사후 분석(postmortem)을 공개할 무렵, 소스 맵을 가진 사람은 누구나 모든 개발자 머신에서 실제로 실행되고 있는 코드를 읽을 수 있었습니다.

Anthropic이 공개적으로 "모델을 감싸는 얇은 레이어"라고 설명한 것은, 실제로는 하나의 대화를 유지하기 위해 여덟 가지 다른 일을 하는 시스템이었습니다. 이 유출은 악의적인 음모를 드러내는 것이 아닙니다. 언어 모델을 궤도에서 벗어나지 않게 유지하는 데 얼마나 많은 작업이 필요한지를 드러냅니다.

클로드 코드가 긴 대화를 붙잡아 두는 방법

긴 에이전트 대화는 끊임없이 토큰 한도에 부딪힙니다. 컨텍스트 윈도우는 파일 읽기, bash 출력, 에러 트레이스로 가득 찹니다. 원본 대화록을 매번 통째로 모델에 전달하는 것은 결국 크래시를 보장합니다.

이를 처리하는 순진한 방법은 대화가 너무 길어지면 전체를 요약하는 것입니다. 유출된 코드는 Anthropic이 그 접근을 첫 번째 방어선으로 명시적으로 거부했음을 보여줍니다. 요약은 값비싼 언어 모델 호출이 필요하고, 프롬프트 캐시까지 손상시킵니다. 대화 기록을 다시 쓰면 캐시된 프리픽스가 무효화되어 API 비용이 치솟고 지연 시간이 악화됩니다.

클로드 코드는 하나의 도구 대신 8가지 서로 다른 컴팩션 메커니즘을 사용합니다. 이들은 "가장 저렴한 것 우선(cheapest-first)" 원칙에 따라 엄격한 우선순위 순서로 실행됩니다. 모델 호출 없이 실행되는 모든 메커니즘은 토큰이 드는 어떤 메커니즘보다 먼저 실행됩니다. 시스템은 마지막에 스스로 요약하기 전에, 값싼 구조적 트릭으로 컨텍스트를 우아하게(gracefully) 저하시키려 시도합니다.

코드를 어떻게 자르느냐에 따라 5개 또는 7개로 셀 수 있습니다. 저는 8개로 셉니다. 캐시 기반 마이크로컴팩트와 시간 기반 마이크로컴팩트가 완전히 다른 신호와 상호 배타적인 코드 경로에서 실행되기 때문입니다.

다음은 정확히 발화 순서대로 나열한 8가지 메커니즘입니다:

  • Tool Result Budget: 개별 도구 출력을 50,000자로 제한합니다. 전체 출력은 디스크에 저장하고, 컨텍스트에는 2KB 미리보기만 유지합니다.
  • Snip (HISTORY_SNIP): LLM 호출 없이 오래된 메시지를 잘라내는 슬라이딩 윈도우 메시지 트리머입니다.
  • Cached Microcompact: 서버 측 캐시된 프롬프트에서 로컬 메시지 목록을 다시 쓰지 않고 오래된 도구 결과를 외과적으로 삭제합니다.
  • Time-based Microcompact: 사용자가 60분 이상 유휴 상태일 때 오래된 도구 결과를 지웁니다.
  • Context Collapse (Marble Origami): 비파괴적인 append-only 커밋 로그로, 대화의 압축된 뷰를 투영합니다.
  • Auto-Compact: 컨텍스트 윈도우의 약 83.5% 시점에서 트리거되는 전체 LLM 요약으로, 서브에이전트를 포크해 구조화된 요약을 생성합니다.
  • Reactive Compact: API가 prompt_too_long 오류를 반환할 때의 비상 폴백입니다.
  • Compaction Circuit Breaker: 무한 루프를 방지하기 위해 3회 연속 실패 후 자동 컴팩션을 비활성화합니다.

Tool Result Budget은 첫 번째 방어선입니다. 큰 터미널 출력을 공격적으로 다듬습니다. 4,000줄짜리 로그 파일을 요청하면, 파일이 기술적으로 여전히 디스크에 있더라도 나머지 대화는 짧은 미리보기만 보게 됩니다.

Snip은 두 번째 방어선입니다. 메시지가 토크나이저에 도달하기도 전에 배열에서 오래된 메시지를 버립니다. 그래서 에이전트가 터미널 창을 닫은 적이 없는데도, 같은 세션에서 앞서 논의한 특정 변수명을 갑자기 언급하지 않게 됩니다.

Cached Microcompact는 베타 cache_edits API를 사용합니다. 서버 측 프롬프트 캐시에서 오래된 도구 결과를 외과적으로 제거합니다. Read, Bash, Grep, Glob, WebSearch 같은 고정된 도구 허용 목록에 대해 매 턴 실행됩니다. 실제로 어떤 모습인지 보여드리면: 디버깅 세션의 청구 비용이 대화의 원시 토큰 수가 암시하는 것보다 훨씬 낮습니다.

Time-based Microcompact는 벽시계를 기준으로 오래된 도구 결과를 지웁니다. 무거운 텍스트를 리터럴 문자열 [Old tool result content cleared]로 교체합니다. 캐시 기반 버전과 상호 배타적입니다. 점심 먹고 돌아왔는데 세션의 오래된 도구 출력이 모두 사라져 있다면, 이 메커니즘이 발화한 것입니다.

Context Collapse는 내부 코드네임 Marble Origami로 불립니다. 비파괴적입니다. 원시 대화 배열을 영구히 다시 쓰는 대신, 축소(콜랩스)의 커밋 로그를 유지하고 매 턴 압축된 뷰를 투영합니다. UI에는 40개의 메시지 기록이 표시되지만, 에이전트는 그것들의 고수준 요약만 본 것처럼 답변합니다.

Auto-Compact는 무거운 일꾼(heavy lifter)입니다. 약 33,000토큰의 예약 버퍼를 두고 트리거됩니다. 9개의 고정 섹션으로 구조화된 요약을 생성하는 서브에이전트를 포크합니다. 그 신호는 명확합니다 — 30분쯤 지나면 에이전트가 갑자기 지금까지 한 일을 요약하고, 그 요약에서 이어서 진행합니다.

Reactive Compact는 비상 폴백입니다. API가 엄격히 prompt_too_long 오류를 던질 때만 실행됩니다. 모든 것을 공격적으로 압축합니다. 요약기 자체가 넘치면, 프롬프트가 들어갈 때까지 가장 오래된 API 라운드 그룹을 버립니다. 에이전트가 맥락을 완전히 잃지 않고 회복하기 전에, 터미널 트레이스에 "prompt too long" 오류가 잠깐 깜빡이는 것을 볼 수 있습니다.

Compaction Circuit Breaker는 이 글의 서두에 나온 상수입니다. 3회 연속 자동 컴팩션 실패 후, 세션의 나머지 동안 자동 컴팩션을 비활성화합니다. 이것이 바로 에이전트가 작동을 멈추고 다시는 회복되지 않는 상황을 만드는 정확한 메커니즘입니다.

그 순서 — pre-flight(사전), post-flight(사후), post-failure(실패 후) — 가 캐스케이드 디스패처가 실제로 인코딩하는 내용입니다:

/**
 * compaction/cascade.ts
 * Illustrative reconstruction of Claude Code's eight-mechanism compaction
 * cascade as observed in the leaked source map. Identifiers differ from the
 * leak, but this version preserves the priority order, the pre-flight /
 * post-flight / post-failure split, the mutual exclusion, and the breaker.
 */
import type { ConversationState } from "../state";
import { toolResultBudget } from "./mechanisms/tool-result-budget";
import { snip } from "./mechanisms/snip";
import { cachedMicrocompact } from "./mechanisms/cached-microcompact";
import { timeBasedMicrocompact } from "./mechanisms/time-based-microcompact";
import { contextCollapse } from "./mechanisms/context-collapse";
import { autoCompact } from "./mechanisms/auto-compact";
import { reactiveCompact } from "./mechanisms/reactive-compact";
import { circuitBreaker } from "./mechanisms/circuit-breaker";

export const MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES = 3;

export type MechanismName =
  | "tool_result_budget"
  | "snip"
  | "cached_microcompact"
  | "time_based_microcompact"
  | "context_collapse"
  | "auto_compact"
  | "reactive_compact"
  | "circuit_breaker";

export type CompactionResult =
  | { kind: "noop"; mechanism: MechanismName }
  | { kind: "applied"; mechanism: MechanismName; tokensFreed: number }
  | { kind: "skipped"; mechanism: MechanismName; reason: string }
  | { kind: "failed"; mechanism: MechanismName; error: Error };

export type Phase = "pre_flight" | "post_flight" | "post_failure";

export interface Mechanism {
  readonly name: MechanismName;
  readonly phase: Phase;
  readonly priority: number;

  shouldRun(state: ConversationState): boolean;
  apply(state: ConversationState): Promise<CompactionResult>;
}

const MECHANISMS = [
  toolResultBudget,        // 1
  snip,                    // 2
  cachedMicrocompact,      // 3
  timeBasedMicrocompact,   // 4
  contextCollapse,         // 5
  autoCompact,             // 6
  reactiveCompact,         // 7
  circuitBreaker,          // 8
] as const satisfies readonly Mechanism[];

export interface CascadeContext {
  state: ConversationState;
  consecutiveFailures: number;
  lastUserActivityMs: number;
}

export async function runPreFlight(
  ctx: CascadeContext,
): Promise<readonly CompactionResult[]> {
  return runPhase("pre_flight", ctx);
}

export async function runPostFlight(
  ctx: CascadeContext,
  modelCallSucceeded: boolean,
): Promise<readonly CompactionResult[]> {
  if (ctx.consecutiveFailures >= MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES) {
    return [{
      kind: "skipped",
      mechanism: "auto_compact",
      reason: "circuit_breaker_open",
    }];
  }
  return runPhase(modelCallSucceeded ? "post_flight" : "post_failure", ctx);
}

async function runPhase(
  phase: Phase,
  ctx: CascadeContext,
): Promise<readonly CompactionResult[]> {
  const results: CompactionResult[] = [];
  const ordered = MECHANISMS
    .filter((m): m is Mechanism => m.phase === phase)
    .toSorted((a, b) => a.priority - b.priority);

  for (const mechanism of ordered) {
    if (!mechanism.shouldRun(ctx.state)) {
      continue;
    }
    if (
      mechanism.name === "cached_microcompact" &&
      timeBasedAlreadyApplied(results)
    ) {
      results.push({
        kind: "skipped",
        mechanism: "cached_microcompact",
        reason: "cold_cache",
      });
      continue;
    }
    try {
      const result = await mechanism.apply(ctx.state);
      results.push(result);

      // Stop the cascade once we have headroom
      if (result.kind === "applied" && fitsInWindow(ctx.state)) {
        return results;
      }
    } catch (caught) {
      const error = caught instanceof Error ? caught : new Error(String(caught));
      results.push({ kind: "failed", mechanism: mechanism.name, error });

      if (mechanism.name === "auto_compact") {
        ctx.consecutiveFailures += 1;
      }
    }
  }
  return results;
}

function timeBasedAlreadyApplied(
  results: readonly CompactionResult[],
): boolean {
  return results.some(r =>
    r.kind === "applied" && r.mechanism === "time_based_microcompact"
  );
}

function fitsInWindow(state: ConversationState): boolean {
  const RESERVED_BUFFER = 33_000;
  return state.estimatedTokens <= state.contextWindowMax - RESERVED_BUFFER;
}

API 표면은 아주 작습니다. runPreFlightrunPostFlight 두 함수가 애플리케이션의 나머지가 접촉하는 모든 것을 처리합니다. 전체 오케스트레이션은 그 뒤에 숨겨져 있습니다. 캐시 기반/시간 기반 마이크로컴팩트 사이의 상호 배타성 검사는 단 두 줄입니다. 차가운 캐시(cold cache)에는 외과적 편집을 할 수 없으므로, 시간 기반 버전이 이미 발화했다면 시스템은 캐시 버전을 건너뜁니다.

맨 아래의 실패 카운팅 로직에 주목하세요. 서킷 브레이커에 집계되는 것은 오직 auto-compact 실패뿐입니다. 더 저렴한 메커니즘들은 세션을 영구히 불구로 만들지 않고 오류를 던질 수 있습니다.

auto-compact가 실제로 실행될 때, 캐시 안정성을 유지하기 위한 특정 트릭을 수행합니다. 요약을 작성하기 위해 서브에이전트(별도의 백그라운드 LLM 호출)를 포크하지만, 그 서브에이전트가 정확히 9개의 고정 섹션을 출력하도록 강제합니다. 부모 대화의 캐시 키를 서브에이전트에 직접 전달합니다. 요약기 호출은 메인 대화와 똑같은 시스템 프롬프트와 도구 정의를 보므로, 두 번째 패스에서 프리픽스 공유 컨텍스트는 거의 0토큰 비용이 듭니다.

/**
 * compaction/mechanisms/auto-compact.ts
 *
 * Illustrative reconstruction of Claude Code's mechanism #6.
 * The forked subagent emits a fixed nine-section summary.
 * The fork passes through the parent conversation's cache-key parameters.
 */
import type { ConversationState } from "../../state";
import type { ModelClient } from "../../model";

export const SUMMARY_SECTIONS = [
  "Primary Request and Intent",
  "Key Technical Concepts",
  "Files and Code Sections",
  "Errors and Fixes",
  "Problem Solving",
  "All User Messages",
  "Pending Tasks",
  "Current Work",
  "Optional Next Step",
] as const;

export type SummarySection = (typeof SUMMARY_SECTIONS)[number];

export type AutoCompactSummary = {
  readonly [K in SummarySection]: string;
};

export interface CachePrefix {
  readonly systemPrompt: string;
  readonly toolDefinitionsHash: string;
  readonly userContextHash: string;
}

export interface AutoCompactInput {
  readonly state: ConversationState;
  readonly model: ModelClient;
  readonly cacheKey: CachePrefix;
}

const SUMMARIZER_INSTRUCTIONS = `You are summarizing a long conversation so it can be replaced with this
summary. Emit exactly nine sections, each beginning with the literal
section header on its own line. Do not add commentary. Do not omit a
section. If a section has no content, write "(none)" beneath the header.
Sections, in order:
${SUMMARY_SECTIONS.map((s, i) => `  ${i + 1}. ${s}`).join("\n")}`.trim();

export async function runAutoCompact(
  input: AutoCompactInput,
): Promise<AutoCompactSummary> {
  const { state, model, cacheKey } = input;
  // The fork inherits the cache prefix verbatim.
  const response = await model.complete({
    cacheKey,
    systemPrompt: cacheKey.systemPrompt,
    messages: [
      ...state.messages,
      { role: "user", content: SUMMARIZER_INSTRUCTIONS },
    ],
    maxOutputTokens: 8_000,
  });
  return parseSummary(response.text);
}

function parseSummary(raw: string): AutoCompactSummary {
  const out = {} as Record<SummarySection, string>;
  const lines = raw.split("\n");
  let currentSection: SummarySection | null = null;
  let buffer: string[] = [];

  const flush = (): void => {
    if (currentSection !== null) {
      out[currentSection] = buffer.join("\n").trim();
    }
  };

  for (const line of lines) {
    const trimmed = line.trim();
    const matched = SUMMARY_SECTIONS.find(
      s => s.toLowerCase() === trimmed.toLowerCase(),
    );
    if (matched !== undefined) {
      flush();
      currentSection = matched;
      buffer = [];
    } else if (currentSection !== null) {
      buffer.push(line);
    }
  }
  flush();

  // Fail closed. Any missing section is a parser error.
  for (const section of SUMMARY_SECTIONS) {
    if (!(section in out)) {
      throw new Error(
        `auto_compact: missing section "${section}" in summary output`,
      );
    }
  }
  return out as AutoCompactSummary;
}

9개 중 8개 섹션만 나오는 것은 저하된 요약이 아니라 버그이며, 파서는 그렇게 취급합니다. 파서는 실패 시 닫힘(fail closed) 방식입니다. 모델이 9개 대신 8개 섹션을 출력하면 파서는 특정 오류를 던집니다. 조용한 빈 문자열 기본값은 없습니다. 다운스트림 소비자는 9개 필드가 모두 존재한다고 가정하고, 파서는 그 계약을 엄격하게 강제합니다.

클로드 코드가 실제로 기억하는 곳

에이전트는 다음 메시지를 위해, 다음 세션을 위해, 프로젝트의 수명 동안 기억해야 합니다. 서로 다른 수명은 서로 다른 저장소를 필요로 합니다.

유출된 코드는 Anthropic이 메모리에 벡터 데이터베이스를 명시적으로 거부했음을 보여줍니다. 그들은 원시 파일, grep, 마크다운 인덱스를 선호했습니다. 그 이유는 아키텍처에 드러나 있습니다. 벡터 검색은 불투명하고 매 읽기마다 임베딩 모델이 필요하지만, 파일은 아무것도 필요로 하지 않습니다. 그리고 벡터가 조용히 최신성(recency)을 보상하는 반면, 마크다운 파일은 유지하는 한 구조를 보존합니다.

Tier 1: 컨텍스트 내 메모리(In-context memory). 활성 대화, 시스템 프롬프트, 도구 정의를 담습니다. 메모리 인덱스의 처음 200줄과 퇴거되지 않은(unevicted) 도구 결과를 담습니다. Tier 1을 아키텍처적으로 다른 두 계층과 구분 짓는 것은, 에이전트가 능동적으로 추론하는 유일한 계층이라는 점입니다. 나머지 두 계층은 이 계층으로 읽혀 들어옵니다. 영속성은 일시적이며, --continue--resume 플래그를 쓰지 않는 한 세션이 끝나면 사라집니다. 퇴거는 앞서 다룬 8가지 컴팩션 메커니즘에 전적으로 지배됩니다. 대화록은 로컬 .jsonl 파일로 기록되어 재개(resume)가 작동하지만, 런타임 메모리는 순수하게 프로세스 내부에만 있습니다. 작업 중간에 터미널을 닫으면 다음 세션은 방금 무엇을 하고 있었는지 전혀 모릅니다.

Tier 2: 영구 파일 메모리(Persistent file memory). MEMORY.md라는 포인터 인덱스와 debugging.md 같은 특정 주제 파일을 담습니다. 세션 대화록과 Tier 1의 도구 결과 오버플로(spillover)를 담습니다. 영속성은 세션 재시작, 머신 재시작, 명시적 클리어 명령에서도 살아남습니다. 이 계층은 자가 치유(self-healing)입니다. autoDream이라는 포크된 백그라운드 서브에이전트를 사용합니다. 이 에이전트는 삼중 관문(triple-gate)을 통과한 후에만 실행됩니다: 마지막 통합 후 최소 24시간 경과, 마지막 주기 후 최소 5개 세션, 그리고 파일 기반 조언 잠금(advisory lock) 획득. 관문이 세 개인 이유는, 활성 세션 중간에 메모리를 통합하는 것이 전혀 통합하지 않는 것보다 나쁘기 때문입니다. 최근 신호를 읽고, 통합하고, 인덱스를 정리합니다. 그래서 에이전트가 아무도 다시 언급하지 않았는데 3주 전 다른 브랜치에서 알려준 사실을 갑자기 언급하는 것입니다.

Tier 2의 파일 시스템 레이아웃은 소스에 명시적으로 이름이 붙어 있습니다:

~/.claude/projects/<project>/memory/MEMORY.md
~/.claude/projects/<project>/memory/<topic>.md
~/.claude/projects/<project>/sessions/*.jsonl

중요한 점은 MEMORY.md가 정보를 직접 저장하지 않는다는 것입니다. 정보의 위치를 저장합니다. 줄당 약 150자로 제한된 포인터 인덱스입니다. 처음 25KB는 세션 시작 시 Tier 1로 스트리밍됩니다. 매 세션 시작마다 전체 메모리 디렉터리를 로드하는 것은 컴팩션의 목적을 완전히 무너뜨리기 때문입니다.

Tier 3: 지침 메모리(Instruction memory). 이는 CLAUDE.md 계층 구조입니다. 인간이 작성한 프로젝트 규칙, 컨벤션, 아키텍처 노트를 담습니다. 매 세션 시작마다 읽힙니다. 이 계층이 불균형적으로 중요한 이유는 시스템 프롬프트의 동적 경계(dynamic boundary) 위에 있기 때문입니다. auto-compact에서 완전히 변경되지 않고 살아남는 유일한 것입니다. 그래서 에이전트가 항상 당신의 빌드 명령을 아는 것입니다.

Tier 3의 해석 체인(resolution chain)은 가장 구체적인 파일이 이기는 엄격한 우선순위 순서를 따릅니다:

  • /etc/claude-code/CLAUDE.md — 조직 전역 규칙용.
  • ~/.claude/CLAUDE.md — 모든 프로젝트에 걸친 사용자 규칙용.
  • <project-root>/CLAUDE.md — 버전 관리되는 프로젝트 규칙용.
  • <project-root>/.claude/rules/*.md — 모듈식 규칙용.
  • <project-root>/<subdirectory>/CLAUDE.md — 디렉터리별 지침용.
  • <project-root>/CLAUDE.local.md — 개인 gitignored 노트용.

이 해석 체인이 코드로 구현된 방식은 다음과 같습니다.

/**
 * memory/claude-md-resolver.ts
 *
 * Illustrative reconstruction of Claude Code's CLAUDE.md resolution chain.
 * Six layers where most-specific wins. Results are returned in apply-order
 * so callers fold them with later-overrides-earlier semantics.
 */
import { readFile, readdir, stat } from "node:fs/promises";
import { homedir } from "node:os";
import { dirname, join, resolve, sep } from "node:path";

export type ResolutionLayer =
  | "global"
  | "user"
  | "project_root"
  | "project_rules"
  | "subdirectory"
  | "personal";

export interface ResolvedInstruction {
  readonly source: ResolutionLayer;
  readonly path: string;
  readonly content: string;
}

export interface ResolveOptions {
  readonly projectRoot: string;
  readonly currentFile?: string;
}

export async function resolveClaudeMd(
  opts: ResolveOptions,
): Promise<readonly ResolvedInstruction[]> {
  const root = resolve(opts.projectRoot);
  const found: ResolvedInstruction[] = [];

  await tryAdd(found, "global", "/etc/claude-code/CLAUDE.md");
  await tryAdd(found, "user", join(homedir(), ".claude", "CLAUDE.md"));
  await tryAdd(found, "project_root", join(root, "CLAUDE.md"));
  await addRulesDir(found, join(root, ".claude", "rules"));

  if (opts.currentFile !== undefined) {
    const leaf = resolve(root, opts.currentFile);

    // Confine the walk to the project root.
    if (leaf === root || leaf.startsWith(root + sep)) {
      const chain: string[] = [];
      let dir = dirname(leaf);
      while (dir.startsWith(root) && dir !== root) {
        chain.unshift(join(dir, "CLAUDE.md")); // root-to-leaf order
        dir = dirname(dir);
      }
      for (const path of chain) {
        await tryAdd(found, "subdirectory", path);
      }
    }
  }

  await tryAdd(found, "personal", join(root, "CLAUDE.local.md"));
  return found;
}

async function tryAdd(
  out: ResolvedInstruction[],
  source: ResolutionLayer,
  path: string,
): Promise<void> {
  try {
    const s = await stat(path);
    if (!s.isFile()) return;
    const content = await readFile(path, "utf8");
    out.push({ source, path, content });
  } catch {
    // Missing files are normal. Most layers are empty.
  }
}

async function addRulesDir(
  out: ResolvedInstruction[],
  dir: string,
): Promise<void> {
  let entries;
  try {
    entries = await readdir(dir, { withFileTypes: true });
  } catch {
    return;
  }
  const files = entries
    .filter(e => e.isFile() && e.name.endsWith(".md"))
    .map(e => e.name)
    .toSorted();
  for (const name of files) {
    const path = join(dir, name);
    const content = await readFile(path, "utf8");
    out.push({ source: "project_rules", path, content });
  }
}

await tryAdd 호출 순서가 곧 우선순위 체인입니다. 위에서 아래로 읽으면 6계층 목록이 나옵니다. tryAdd 함수는 디자인상 대부분의 계층이 비어 있을 것을 예상하므로 누락된 파일을 조용히 삼킵니다. 하위 디렉터리 탐색은 chain.unshift를 사용해 잎→뿌리(leaf-to-root) 탐색을 뿌리→잎(root-to-leaf) 삽입 순서로 뒤집으므로, 결과를 접는(folding) 호출자는 올바른 우선순위를 얻습니다. 이것이 auto-compact에서 살아남는 것입니다. 6번 메커니즘은 Tier 1을 지우고 요약으로 다시 쓰지만, 이 리졸버가 반환한 것은 Tier 3에 손대지 않은 채 보존됩니다.

어떤 문서 페이지도 언급하지 않는 클로드 코드의 기능들

유출된 소스에서 Tengu라는 문자열을 grep하면 1,000개가 넘는 결과가 나옵니다. Tengu는 클로드 코드의 내부 프로젝트 코드네임입니다. 그 프리픽스를 벗기면 그 아래의 다음 레이어는 Anthropic이 동작을 배포하고 게이트하기 위해 사용한 기능 플래그 집합입니다. 44개가 있습니다.

44개 플래그는 빠르게 움직이는 팀이 어떻게 배포하는지 보여줍니다. 핵심 질문은 각 플래그가 실제로 무엇을 켜는지입니다. 두 그룹으로 나뉩니다: 배포됐지만 문서화되지 않은 것완전히 미공개인 것.

오늘 고객 머신에서 이미 실행 중이지만, 어떤 문서 페이지에도 언급된 적 없는 플래그들입니다.

안티 증류 및 가짜 도구(Anti-distillation and fake tools). ANTI_DISTILLATION_CC 플래그가 켜져 있으면 API 요청에 anti_distillation: ['fake_tools'] 지시문이 포함됩니다. 서버는 시스템 프롬프트에 미끼(decoys) 도구 정의를 주입합니다. 목적은 클로드 코드의 동작을 복제하려고 API 트래픽을 기록하는 사람에게 잡힌 훈련 데이터를 오염시키는 것입니다. 에이전트가 문서화된 적 없고 공식 도구 목록에도 없는 도구 이름을 가끔 언급하는 것은 이 때문입니다.

좌절 감지 정규식(Frustration regex). userPromptKeywords.ts 파일에는 "wtf", "this sucks" 같은 욕설과 좌절 표현을 매칭하는 정규식이 있습니다. 매칭은 다운스트림 동작을 바꿉니다. 개발자가 좌절하며 에이전트에게 욕을 한 후, 다음 답변은 눈에 띄게 더 신중하고 사과하는 톤이 되지만, 그 한 번뿐입니다.

잠행 모드(Undercover mode). 환경 변수 CLAUDE_CODE_UNDERCOVER=1은 클로드 코드를 출력에서 Anthropic 식별자를 제거하는 모드로 전환합니다. Anthropic 엔지니어들이 AI 저자임을 드러내지 않고 오픈소스 소프트웨어에 기여할 때 사용합니다. 비대칭적입니다. 환경 변수가 모드를 켜도록 강제할 수는 있지만, 끄도록 강제할 수는 없습니다. 공개 저장소의 Anthropic 직원 커밋에는 다른 모든 클로드 코드 커밋에 붙는 Co-Authored-By: Claude 줄이 없습니다.

조용한 모델 다운그레이드(Silent model downgrade). 특정 서버 오류가 발생하면 클로드 코드는 요청의 나머지 동안 Opus에서 Sonnet으로 조용히 폴백합니다. 사용자는 저하된 응답이 아닌 성공적인 응답을 봅니다. Opus 세션이 중간쯤에서 조용히 약간 멍해지고, 오류 메시지가 없었는데도 결코 회복되지 않는 것은 이 때문입니다.

직원 전용 검증 게이트(Employee-only verification gate). 생성된 diff를 실제로 컴파일되는지 재실행해 확인하는 검증 루프를 게이트하는 플래그입니다. Anthropic 엔지니어들은 이것을 실행했습니다. 고객은 실행하지 못했습니다. Anthropic 엔지니어들의 생성 diff는 외부 사용자가 로컬에서 같은 모델을 실행할 때보다 눈에 띄게 더 신뢰할 만합니다.

그리고 아직 배포되지 않은 플래그들도 있습니다. 소스에는 존재하지만 고객 빌드에는 연결되지 않았습니다.

KAIROS. 세션을 가로질러 지속되는 항상 켜진(always-on) 백그라운드 데몬입니다. 주기적인 틱(tick) 프롬프트를 받고, GitHub 웹훅을 모니터링하며, 독립적으로 행동을 취할 수 있습니다. 소스에 150개가 넘는 참조가 있습니다.

autoDream. 앞서 다룬 Tier 2 통합 서브에이전트입니다. KAIROS의 유휴 모드 아래에 게이트되어 있습니다. 현재 배포된 빌드에서 활성인지는 불분명하지만, 게이트는 크게 제한되어 있음을 시사합니다.

ULTRAPLAN. 깊은 계획 세션을 최대 30분 동안 원격 Opus 인스턴스에 오프로드합니다. 전용 플래닝 모드 변형입니다.

COORDINATOR_MODE. 구조화된 연구, 종합, 구현 단계를 가진 멀티 에이전트 스웜입니다.

유출은 내부 모델 코드네임도 드러냈습니다. Tengu는 클로드 코드 자체입니다. Capybara는 Mythos 변형으로 보입니다. Fennec은 Opus 4.6에 대응합니다. Numbat은 아직 테스트 중인 미공개 모델입니다. 이들은 소스 코드 라우팅 로직을 통해 전파되는 이름들일 뿐입니다.

유출이 알려주는 것과 알려주지 않는 것

소스 맵에 무엇이 들어 있는지에는 분명한 한계가 있습니다. npm 패키지에는 클라이언트 CLI만 있으므로 모델 가중치, 파인튜닝 데이터, 강화학습 커리큘럼, 프로덕션 서버 코드가 없습니다. 고객 데이터도 자격 증명도 없습니다. 이것은 하네스(harness)이지, 모델이 아닙니다.

하지만 하네스야말로 빌더가 정확히 봐야 하는 것입니다. 8가지 컴팩션 메커니즘, 3가지 메모리 계층, 44개 플래그는 특정 모델 때문이 아니라 에이전트 문제의 형태 때문에 존재합니다. Anthropic이 내일 Opus를 차세대 모델로 교체해도 하네스는 정확히 같은 8가지를 수행할 것입니다. 같은 3계층에 걸쳐 저장할 것입니다. 대부분의 플래그를 그대로 둘 것입니다.

컴팩션은 계층화되어야 하고(layer), 메모리는 계층화되어야 하며(tier), 플래그는 계속 늘어날 것입니다. 이 유출은 Anthropic을 들여다보는 창이 아닙니다. 2027년에 당신의 스택이 무엇이 될지에 대한 미리보기입니다.


💡 한국어 독자를 위한 보충: 왜 이 글이 중요한가

이 유출이 개발자 커뮤니티에서 크게 주목받는 이유는 단순한 호기심이 아닙니다:

  1. "에이전트가 갑자기 멍해지는" 현상의 정체: "세션이 길어지면 답변이 나빠진다"는 경험은 LLM 에이전트를 쓰는 모든 개발자가 겪는 문제입니다. MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES = 3라는 상수 하나가 그 현상을 정확히 설명합니다. 3번 연속 실패하면 남은 세션 동안 자동 컴팩션이 꺼집니다.
  2. 비용 설계의 교훈: 가장 싼 메커니즘부터 순서대로 실행하는 "cheapest-first" 캐스케이드는, 실제 프로덕션 에이전트가 토큰 비용을 아끼기 위해 얼마나 정교한 장치를 만드는지 보여줍니다. 직접 에이전트를 만든다면 같은 패턴을 그대로 적용할 수 있습니다.
  3. 메모리 설계의 교훈: 벡터 DB를 버리고 파일+grep+마크다운 인덱스를 택한 결정은, 소규모 프로젝트에서 "메모리"라고 하면 꼭 벡터 DB여야 한다는 착각에 대한 좋은 반례입니다.
  4. CLAUDE.md의 중요성: Tier 3 지침 메모리가 auto-compact에서 유일하게 온전히 살아남는 계층이라는 사실은, 프로젝트 규칙을 CLAUDE.md에 잘 정리해 두는 것이 얼마나 중요한지를 설명합니다.

🔚 마무리

클로드 코드의 유출 소스맵은 AI 에이전트의 "내부 장기"를 한눈에 보여준 최초의 사례입니다. 모델 자체가 아니라 모델을 둘러싼 하네스 — 컴팩션 캐스케이드, 메모리 계층, 기능 플래그 — 가 실제로 에이전트의 생존 능력을 결정합니다. 그리고 그 하네스의 모양은 곧 모든 AI 에이전트 스택의 표준이 될 것입니다.

:
Posted by Ritz®™