gajae-code · 컨텍스트·프롬프트 조립 (System Prompt + AGENTS.md 주입)

한 줄 요약

AI 코딩 에이전트(GJC)가 일을 시작하기 전에 “넌 누구고, 이 프로젝트는 뭐고, 어떤 규칙을 지켜야 하는지”를 적은 지시문 한 뭉치(system prompt) 를 자동으로 조립해 주는 부품이다. 왜 배우나 — 프롬프트를 코드에 박지 않고 파일·폴더에서 자동으로 긁어모으는 이 방식을 알아야, AI가 “왜 이 프로젝트 규칙을 알고 있는지”와 “왜 가까운 폴더 규칙이 이기는지”를 이해할 수 있다.

그림

flowchart TD
  T["buildSystemPrompt() 호출"] --> P{"NULL_PROMPT?"}
  P -->|"예 (테스트용)"| Z["빈 배열 반환"]
  P -->|아니오| W["6개 준비 작업 병렬 실행 (각각 5초 제한)"]
  W --> A["프로젝트 컨텍스트 수집 (폴더 거슬러 올라가기)"]
  W --> B["SYSTEM.md 읽기"]
  W --> C["작업공간 트리 만들기 (최대 200개)"]
  W --> D["스킬 목록 읽기"]
  A --> DD["깊이 순 정렬 + 똑같은 본문 제거"]
  DD --> M["data 객체 조립"]
  B --> M
  C --> M
  D --> M
  M --> R["prompt.render(.md 템플릿, data)"]
  R --> S["systemPrompt = ["정체성 블록, 프로젝트 블록"]"]
  S --> LLM["모델의 system 메시지로 전달"]
  LLM --> RUN["대화 진행"]
  RUN --> PR{"토큰 압박?"}
  PR -->|예| PRUNE["낡은 도구 출력부터 잘라내기 (영수증 한 줄로 치환)"]

쉽게 풀기

새 직원이 출근 첫날 받는 사내 안내문 한 묶음을 떠올리면 쉽다. “당신의 역할은 이것입니다(정체성), 이 회사 규칙은 이렇습니다(권한·도구), 그리고 이 팀의 별도 규칙은 이렇습니다(프로젝트 규칙).” 이 부품은 그 안내문을 매번 손으로 쓰지 않고 자동으로 묶어 준다. 핵심은 두 가지 습관이다.

1) 안내문 문장을 코드에 직접 타이핑하지 않는다. 프롬프트 문장은 전부 .md 파일에 따로 두고, 코드는 그 파일을 통째로 불러와(with { type: "text" }) 빈칸(변수)만 채운다(prompt.render). 회사 안내문 양식을 워드 파일로 관리하고, 직원 이름·부서만 끼워 넣는 것과 같다. 양식 고칠 때 코드를 안 건드려도 된다.

2) 흩어진 규칙 파일을 폴더를 거슬러 올라가며 자동으로 줍는다. 프로젝트 폴더에서 시작해 그 위 부모·조부모 폴더까지 올라가면서 AGENTS.md, CLAUDE.md, GEMINI.md 같은 여러 AI 도구의 표준 규칙 파일을 긁어모은다(walk-up). 큰 회사로 치면 “전사 규정 → 본부 규정 → 우리 팀 규정”을 다 모으는 것이다. 그런데 충돌이 나면? 가까운 폴더(우리 팀) 규칙이 먼 폴더(전사) 규칙을 이긴다(deeper overrides higher). 우리 팀에 더 구체적인 사정이 있으니까.

가까운 규칙이 이기게 만드는 기술은 의외로 단순하다. 프롬프트는 뒤에 적힌 내용일수록 더 강하게 작동하므로, 일부러 먼 파일을 앞에, 가까운 파일을 뒤에 배치한다(깊이 내림차순 정렬). 같은 내용이 두 곳에 똑같이 적혀 있으면 가까운 것 하나만 남기고 버린다.

3) 대화가 길어지면 낡은 메모를 지운다(프루닝). 일하다 보면 “아까 읽은 파일 내용”이 쌓이는데, 그 파일을 나중에 또 읽거나 수정했다면 옛날에 읽은 결과는 낡은(stale) 정보다. 토큰(기억 용량)이 부족해지면 이런 낡은 메모부터 잘라내고, 그 자리에 “여기 N토큰 잘렸음, 요약: …” 영수증 한 줄만 남긴다. 단, 잘라서 절약되는 양이 너무 적으면 아예 자르지 않는다(괜히 흔들지 않기).

핵심 정리

ContextFile 한 장의 구조 (capability/context-file.ts)

필드타입설명
pathstring컨텍스트 파일 절대경로 (필수)
contentstring파일 본문 전체 (필수)
level"user" | "project"홈 레벨인지 프로젝트 레벨인지 (필수)
depthnumber?cwd로부터 거리. 0=cwd, 1=부모, 2=조부모 (project만)

중복 제거 key 규칙 (가장 중요)

  • user 파일이면 "user" 하나로 묶는다.
  • project 파일이면 `project:${Math.max(0, depth ?? 0)}` — 즉 깊이 단위로 1개씩만 채택.
  • 같은 깊이에서는 priority 높은 provider가 낮은 provider를 가린다(shadow).
  • depth가 음수면 0으로 clamp → 조상의 설정 하위폴더(예: .github/)는 조상 자신과 같은 스코프로 취급.
  • _source(SourceMeta) 필드는 “어느 provider가 어디서 읽었는지” 출처 영수증으로 항상 붙는다.

어떤 AI 도구의 파일을 줍나 (멀티벤더, 실측)

Provider 파일읽는 파일priority
claude.ts.claude/CLAUDE.md80
codex.ts.codex/AGENTS.md70
agents-md.ts루트 AGENTS.md(walk-up)10
gemini.ts~/.gemini/GEMINI.md, .gemini/GEMINI.md(자체)
cursor.ts.cursor/rules/*.mdc, .cursorrulesrule capability
windsurf.ts.windsurf/rules/*.mdrule capability

context-file vs rule 구분 (본 것만)

cursor / windsurf는 contextFileCapability아니라 ruleCapability로 등록된다. AGENTS.md / CLAUDE.md / GEMINI.md / .codex/AGENTS.md 만 context-file로 들어와 <context> 블록에 직접 박힌다.

프루닝(컨텍스트 창 관리) 설정 (compaction/pruning.ts)

필드기본값설명
protectTokens40,000최근 도구 출력 이 토큰만큼은 무조건 보존
minimumSavings20,000절약량이 이 미만이면 프루닝 안 함(히스테리시스)
protectedTools["skill","read"]절대 안 자르는 도구
staleOverridableTools["read"]낡으면 보호 해제되는 도구

정렬·우세·프루닝 동작 요약

  • 정렬: files.sort((a,b)=> (b.depth ?? -1) - (a.depth ?? -1)) — depth 큰(먼) 파일이 앞, 가까운 파일이 뒤. 프롬프트는 뒤쪽=더 prominent → 이것이 “deeper overrides higher”의 물리적 구현. project-prompt.md<dir-context>에도 “Deeper rules override higher ones” 명시.
  • stale 판정: 같은 target key의 더 나중 결과가 있으면 이전 결과는 stale. read 결과는 그 파일을 나중에 edit/write/apply_patch/ast_edit 하면 stale. 단 target별 마지막(최신) 결과는 절대 stale 아님.
  • 절단: protectTokens 윈도우 안이라도 stale이면 잘라낸다. 잘린 자리는 [Output truncated - N tokens; <digest>] 영수증으로 치환. 총 절약이 minimumSavings 미만이면 통째로 취소.

실제 예시

1) 정적 .md 임포트 + 조립 (인라인 프롬프트 금지)

// packages/coding-agent/src/system-prompt.ts
import customSystemPromptTemplate from "./prompts/system/custom-system-prompt.md" with { type: "text" };
import projectPromptTemplate from "./prompts/system/project-prompt.md" with { type: "text" };
import systemPromptTemplate from "./prompts/system/system-prompt.md" with { type: "text" };
// ...
const rendered = prompt.render(resolvedCustomPrompt ? customSystemPromptTemplate : systemPromptTemplate, data);
const systemPrompt = [rendered];
const projectPrompt = resolvedCustomPrompt ? "" : prompt.render(projectPromptTemplate, data).trim();
if (projectPrompt) {
	systemPrompt.push(projectPrompt);
}
return { systemPrompt };  // 블록 배열 — provider가 각각 별도 메시지로 보존

프롬프트 본문은 코드에 없고 전부 .md 파일에 있다. 코드는 data 객체만 만들어 Handlebars-류 prompt.render에 넘긴다. customPrompt가 있으면 custom-system-prompt.md 한 장으로 대체, 없으면 system-prompt.md(정체성/도구/워크플로) + project-prompt.md(환경/컨텍스트파일/트리) 2블록 구조.

2) depth 계산 (helpers.ts:513)

// packages/coding-agent/src/discovery/helpers.ts
export function calculateDepth(cwd: string, targetDir: string, separator: string): number {
	return cwd.split(separator).length - targetDir.split(separator).length;
}
// cwd보다 위(조상)면 양수, cwd면 0. → 깊이가 클수록 cwd에서 먼 파일.

3) 계층 컨텍스트 수집 (walk-up)

// packages/coding-agent/src/discovery/agents-md.ts (요지)
let current = ctx.cwd;
while (true) {
	const candidate = path.join(current, "AGENTS.md");
	const content = await readFile(candidate);
	if (content !== null && !path.basename(current).startsWith(".")) {
		items.push({ path: candidate, content, level: "project",
			depth: calculateDepth(ctx.cwd, current, path.sep),
			_source: createSourceMeta("agents-md", candidate, "project") });
	}
	if (current === (ctx.repoRoot ?? ctx.home)) break;   // 레포 루트/홈에서 정지
	const parent = path.dirname(current);
	if (parent === current) break;                        // 파일시스템 루트
	current = parent;
}

4) always-apply rule 중복 제거 로직

// packages/coding-agent/src/system-prompt.ts
function splitComparablePromptBlocks(content) {            // 정규화 후 빈 줄 2개 기준으로 블록 분할
	return normalizePromptBlock(content).split(/\n{2,}/).map(b => b.trim()).filter(Boolean);
}
function promptSourceContainsRule(source, ruleContent) {   // rule 블록 시퀀스가 source 안에 연속으로 존재?
	const sourceBlocks = splitComparablePromptBlocks(source);
	const ruleBlocks = splitComparablePromptBlocks(ruleContent);
	if (ruleBlocks.length > sourceBlocks.length) return false;
	for (let s = 0; s <= sourceBlocks.length - ruleBlocks.length; s++)
		if (ruleBlocks.every((b, o) => sourceBlocks[s + o] === b)) return true;
	return false;
}
// alwaysApplyRules 중 customPrompt/append/SYSTEM.md 어디에도 안 들어있는 것만 살림 → 같은 규칙 두 번 안 박음

5) .md 템플릿 직접 만들 때 (인라인 금지, 파일로 분리)

<!-- prompts/system/system-prompt.md -->
<my-agent>
<identity>You are X. Optimize for correctness first.</identity>
{{#if systemPromptCustomization}}
<custom>{{systemPromptCustomization}}</custom>
{{/if}}
{{#if toolInfo.length}}
<tools>{{#each toolInfo}}- `{{name}}`{{/each}}</tools>
{{/if}}
</my-agent>

직접 만들 때 체크리스트

  • 프롬프트 문장은 전부 .md로, 코드에는 with { type: "text" } 임포트만.
  • custom 있으면 custom 템플릿 1장으로 대체, 없으면 system+project 2블록.
  • context 파일은 cwd→repoRoot/home walk-up, .으로 시작하는 폴더의 루트 AGENTS.md는 제외.
  • depth 부여 후 내림차순 정렬(가까운 파일이 뒤=우세), 바이트 동일 본문은 가장 가까운 1개만.
  • capability key로 user 1개 + depth별 1개만 채택, 동일 depth는 priority로 shadow.
  • always-apply rule은 customPrompt/append/SYSTEM.md에 이미 있으면 주입 생략.
  • 준비 단계는 deadline(예: 5s)으로 게이팅, 타임아웃 시 최소 fallback.
  • AGENTS.md 노출 수는 상한(예: 200, AGENTS_MD_LIMIT)으로 cap.
  • 프루닝은 protectTokens 윈도우 + minimumSavings 히스테리시스 + stale 우선, 잘린 자리에 digest 영수증.

요약 & 셀프체크

3줄 요약:

  1. 프롬프트 문장은 코드가 아니라 .md 파일에 두고, 코드는 변수만 끼워 prompt.render로 조립한다(인라인 금지).
  2. 폴더를 거슬러 올라가며(walk-up) 여러 AI 도구의 규칙 파일을 줍고, 깊이 내림차순 정렬로 가까운 폴더 규칙이 이기게(deeper overrides higher) 만든다.
  3. 대화가 길어져 토큰이 부족하면 낡은(stale) 도구 출력부터 잘라내되, protectTokens 보호 윈도우와 minimumSavings 히스테리시스로 과도한 절단을 막는다.

스스로 답해 보기:

  • 같은 규칙이 전사 AGENTS.md와 우리 팀 AGENTS.md에 둘 다 있을 때, 왜 우리 팀 것이 이기는가? (힌트: 정렬 방향과 “프롬프트는 뒤쪽이 더 강하다”)
  • cursor/windsurf 규칙은 왜 <context> 블록에 안 들어가는가?
  • 방금 read한 파일을 나중에 edit하면 그 read 결과는 어떻게 되는가? 토큰 보호 윈도우 안이어도 잘릴 수 있는 이유는?

연결

GJ_개요 · _분석축_루브릭 · GJ_10_agent-loop · GJ_30_extension-points-hooks-skills-commands · GJ_80_gajae-receipts-and-workflow-state

근거 파일

  • /home/seunghyeong/harness-work/gajae-code/packages/coding-agent/src/system-prompt.ts
  • /home/seunghyeong/harness-work/gajae-code/packages/coding-agent/src/discovery/agents-md.ts
  • /home/seunghyeong/harness-work/gajae-code/packages/coding-agent/src/discovery/claude.ts
  • /home/seunghyeong/harness-work/gajae-code/packages/coding-agent/src/discovery/codex.ts (priority 70 확인)
  • /home/seunghyeong/harness-work/gajae-code/packages/coding-agent/src/discovery/gemini.ts / cursor.ts / windsurf.ts (벤더/capability 구분 확인)
  • /home/seunghyeong/harness-work/gajae-code/packages/coding-agent/src/discovery/helpers.ts (calculateDepth, createSourceMeta)
  • /home/seunghyeong/harness-work/gajae-code/packages/coding-agent/src/capability/context-file.ts
  • /home/seunghyeong/harness-work/gajae-code/packages/coding-agent/src/prompts/system/system-prompt.md
  • /home/seunghyeong/harness-work/gajae-code/packages/coding-agent/src/prompts/system/project-prompt.md
  • /home/seunghyeong/harness-work/gajae-code/packages/coding-agent/src/prompts/system/custom-system-prompt.md
  • /home/seunghyeong/harness-work/gajae-code/packages/coding-agent/src/workspace-tree.ts (AGENTS_MD_LIMIT=200)
  • /home/seunghyeong/harness-work/gajae-code/packages/agent/src/compaction/pruning.ts

Codex 교차검증 (원본 분석 보존)

본 노트의 동작·수치는 원본 기능분해 노트에서 실측·근거 파일과 함께 확인된 내용이다. 정렬은 files.sort((a,b)=> (b.depth ?? -1) - (a.depth ?? -1))로 depth 큰(먼) 파일이 앞·가까운 파일이 뒤=더 prominent하게 배치되어 “deeper overrides higher”를 물리적으로 구현한다. 멀티벤더 priority(claude 80 / codex 70 / agents-md 10)와 cursor·windsurf의 ruleCapability 분리, 프루닝 기본값(protectTokens 40,000 / minimumSavings 20,000 / protectedTools ["skill","read"] / staleOverridableTools ["read"])도 근거 파일 기준 실측치다. 추측으로 사실을 바꾸지 않았다.