SLOPSHOPPER

context-band

프롬프트 위에 현재 세션 컨텍스트를 분류별 막대로 보여준다 (/context 요약)

newband
v0.1.0no licenseupdated 2026-10-121Dohyeon/claude-sync/mods/context-band
A shopper browsing a rack in a slop shop
README

claude-sync

~/.claude/는 실제 폴더로 두고, 관리 대상 항목만 그 안으로 심링크합니다. Claude Code는 항상 ~/.claude/ 아래 고정 경로에서 읽으므로, 저장소를 어디에 clone하든 심링크가 위치 차이를 흡수합니다.

SETTINGS(Clone)

  1. Claude Code를 설치하고 로그인합니다.
  2. Claude에게 아래 한 줄을 그대로 전달합니다.
https://github.com/1Dohyeon/claude-sync 읽고 설치해줘

이후는 Claude가 SETTING_GUIDE.md를 따라 처리합니다. 저장소를 둘 위치와 기존 설정을 옮겨도 되는지만 답해 주면 됩니다.

  • 연결 상태는 ls -l ~/.claude로 확인합니다. -> 뒤가 저장소 경로면 연결된 것입니다.
  • 본인 저장소로 관리하려면 git remote set-url origin <자기 저장소 주소>로 원격만 바꿉니다.
  • 기존 설정은 지우지 않습니다. 실제 파일은 ~/claude-backup/으로 옮긴 뒤 링크합니다. 다른 도구가 관리하던 심링크는 덮어쓰기 전에 원래 경로를 알려주고 승인을 받습니다.
  • 절차는 git·ln·mv·mkdir·ls·find 명령을 씁니다. 기존 settings.json의 permissions.deny에 걸려 있으면 중간에 멈춥니다.
  • /planning-dev의 깊은 분석에서 과거 유사 태스크를 찾으려면 kiwipiepy·scikit-learn이 필요합니다. 없으면 그 검색만 건너뜁니다.

GLOBAL CLAUDE ~/.claude/

~/.claude/                                                    # 실제 폴더
├── agents/ commands/ hooks/ rules/ skills/ templates/        # → claude-sync로 심링크
├── CLAUDE.md  settings.json                                  # → claude-sync로 심링크
├── CLAUDE.local.md  settings.local.json                      # → claude-sync로 심링크 (gitignore 대상)
└── sessions/ projects/ plugins/ history.jsonl ...            # Claude Code 런타임, git이 모름

기기 전용 오버라이드 2개(CLAUDE.local.md, settings.local.json)도 저장소를 거쳐 연결하지만, gitignore 대상이라 내용은 커밋되지 않습니다. 공유되는 것은 "그 자리에 파일이 있다"는 구조뿐이고, 내용은 기기마다 다릅니다. 편집 지점이 저장소 폴더 하나로 통일되는 것이 이 방식의 이점입니다.

단, gitignore 대상이므로 git clean -dfx를 실행하면 이 두 파일은 지워집니다.

~/plans/는 계획 문서를 둘 수 있는 곳 가운데 하나입니다. 경로는 rules/environments.md의 PLANS_PATH로 바꿀 수 있으며, ~/.claude/와 claude-sync 밖의 별개 위치입니다.

claude

claude-sync/               # 설정 저장소
├── rules/                 # 항상 적용되는 규율 (세션 시작 시 자동 로드)
├── skills/                # 상황별 절차 (필요할 때만 로드, `/이름`으로 직접 호출)
├── templates/             # 문서 작성 시 참고할 템플릿
├── hooks/                 # settings.json에 등록된 훅 스크립트
├── agents/                # 커스텀 서브에이전트 정의
├── commands/              # 커스텀 슬래시 커맨드
├── CLAUDE.md              # Claude 응답·행동 규칙
├── settings.json          # Claude Code 앱 설정: 테마·권한·훅 등록 등
├── CLAUDE.local.md        # 기기 전용 규칙      ┐ gitignore: 커밋 안 됨,
├── settings.local.json    # 기기 전용 앱 설정   ┘ 기기마다 내용이 다름
├── SETTING_GUIDE.md       # Claude가 읽고 실행하는 세팅 절차 (심링크 대상 아님)
└── README.md              # 이 문서 (심링크 대상 아님)

Claude는 세션을 시작할 때 CLAUDE.md, rules/ 등을 컨텍스트에 주입합니다. 따라서 어떤 작업에서든 공통으로 지켜야 하는 규칙은 rules/에 담습니다.

그 외에 작업 종류가 갈리는 경우(개발이면 개발, 리서치면 리서치 등)에는 skills/를 활용하도록 CLAUDE.md에서 라우팅합니다.

요청 유형호출되는 skill
코드 작성·수정/development
요청사항 분석·설계/planning-dev
커밋 전 변경 리뷰/diff-review
브랜치 전체 리뷰(PR 전)/branch-review
PR 리뷰/pr-review
git 작업(worktree·commit·push 등)/git-workflow
조사·리서치·자료 종합/research
논문·긴 기술 문서 정독/paper-reading
세션 작업 일기/worklog

세션 흐름

개발 태스크가 들어왔을 때의 예시입니다. 요청에 따라 일부 단계만 거치기도 합니다. 단계와 축을 나눠 두는 목적은, 각 지점에서 할 일과 하지 말 일을 좁혀 한 번에 과하게 파고드는 것(overthink)을 막는 데 있습니다.

flowchart TD
    A["세션 시작<br/>rules 자동 주입"] --> B["요청"]
    B --> C{"CLAUDE.md 라우팅"}
    C -->|"요청사항 분석·설계"| D["planning-dev<br/>분석 → 문서 위치 선택 → 세 문서"]
    C -->|"코드 작성·수정"| E["development<br/>저장소 규칙 우선, TDD"]
    D -.->|"요청하거나 보강할 때"| DD["deep-dive<br/>과거 유사 태스크, 서브에이전트 분석"]
    D --> E
    E --> F["검증<br/>테스트·린트·실행, tasks.md 갱신"]
    F --> G{"리뷰 요청?"}
    G -->|"예"| H["diff-review / branch-review / pr-review<br/>공통부는 review-common"]
    G -->|"아니오"| I["완료<br/>끝났는지 사용자에게 확인"]
    H --> I
    I --> J["PLANS_PATH에 둔 문서는<br/>done/으로 이동"]
  1. 세션 시작: rules/는 자동으로 주입되고, 계획 문서는 필요할 때 /planning-dev의 "계획 문서 찾기" 순서로 찾아 읽습니다.
  2. 요청: "○○ 요청사항 분석해줘"
  3. 분석: /planning-dev가 목적, 유형, 규모, 할 일을 보고합니다. 깊은 분석은 요청하거나 계획 문서를 보강할 때만 합니다.
  4. 계획 문서: 문서를 둘 위치를 묻고, requirements.md·design.md·tasks.md를 모두 쓴 뒤 한 번에 확인받습니다.
  5. 구현: /development가 저장소 규칙을 우선하며 TDD로 작업하고, 작업 브랜치는 /git-workflow대로 만듭니다.
  6. 검증: 테스트, 린트, 실제 실행으로 확인하고 tasks.md를 갱신합니다.
  7. 리뷰: 커밋 전이면 /diff-review, PR을 올리기 전 브랜치 전체면 /branch-review, 올라온 PR이면 /pr-review입니다. 축 구성과 그렇게 나눈 이유는 skills/review-common/README.md에 있습니다.
  8. 완료: 사용자가 끝났다고 확인하면 tasks.md를 완료로 바꾸고, $PLANS_PATH에 둔 문서는 done/으로 옮깁니다.
  9. 이어받기: 다음 세션은 위치를 묻지 않고 정해진 순서로 계획 문서를 찾아 이어서 작업합니다.
Source 3 files
hooks/register.tsx 95 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { formatTokens, headerOf, labelOf, layoutBar, legendRows, toSnapshot } from './view'
5
6const snapshot = atom({ plugin: 'context-band', key: 'snapshot' } as const, null)
7
8// summary는 로컬 추정이라 API 호출이 없다. 실패하면 band는 직전 값을 그대로 보여준다.
9const refresh = async ($: EngineInterface): Promise<void> => {
10  try {
11    const { context } = await $.session.usage({ breakdown: 'summary' })
12    if (!context.breakdown) return
13    const value = toSnapshot(context.breakdown)
14    await update($, snapshot, () => value)
15  } catch {
16    // 세션이 아직 묶이지 않은 시점 등
17  }
18}
19
20export const register: Register = on => {
21  on('session.start', async ($, e, next) => {
22    const result = await next(e)
23    await refresh($)
24    return result
25  })
26
27  on('turn.complete', async ($, e, next) => {
28    const result = await next(e)
29    await refresh($)
30    return result
31  })
32
33  // 압축을 막는 일이 없도록, 실패하면 재생 안전한 next로 원래 결과를 돌려준다.
34  on('session.compact', async ($, e, next) => {
35    const result = await next(e)
36    await refresh($)
37    return result
38  }).catch(($, e, next) => next(e))
39
40  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
41    const snap = await read($, snapshot)
42    if (e.props.hasSurvey || snap === null) {
43      return next(e)
44    }
45
46    const { Box, Text } = $.ui.resolve(e)
47
48    // 터미널은 글자 한 개가 한 칸이라 █를 칸 수만큼 잇는다.
49    // Desktop은 글꼴 폭이 칸과 달라 글자 막대가 넘쳐 접히므로, 배경색 Box를 토큰 비율로 나눈다.
50    // flexGrow는 10000 이하만, 퍼센트 폭은 소수점 없이만 받아서 만분율 정수로 넣는다.
51    const sum = snap.rows.reduce((acc, r) => acc + r.tokens, 0) || 1
52    const bar =
53      e.surface === 'terminal' ? (
54        <Box key="bar">
55          {layoutBar(snap.rows, Math.max(10, e.props.bodyColumns)).map((s, i) => (
56            <Text key={`bar-${i}`} color={s.color}>
57              {'█'.repeat(s.width)}
58            </Text>
59          ))}
60        </Box>
61      ) : (
62        <Box key="bar" width="100%" height={1}>
63          {snap.rows.map(r => (
64            <Box
65              key={`bar-${r.name}`}
66              flexGrow={Math.round((r.tokens / sum) * 10000)}
67              width={0}
68              height={1}
69              backgroundColor={r.color}
70            />
71          ))}
72        </Box>
73      )
74
75    return (
76      <Box flexDirection="column">
77        <Text>
78          <Text bold>컨텍스트</Text> <Text dimColor>{headerOf(snap)}</Text>
79        </Text>
80        {bar}
81        <Box key="legend" flexWrap="wrap">
82          {legendRows(snap.rows).map(r => (
83            <Box key={`legend-${r.name}`} marginRight={2}>
84              <Text color={r.color}>■ </Text>
85              <Text dimColor>
86                {labelOf(r.name)} {formatTokens(r.tokens)}
87              </Text>
88            </Box>
89          ))}
90        </Box>
91      </Box>
92    )
93  })
94}
95
hooks/view.ts 70 lines
1import type { SessionContextBreakdown } from 'claude-code'
2
3import type { ContextRow, ContextSnapshot } from '../types'
4
5// /context가 쓰는 영문 행 이름 → band 라벨. 모르는 이름은 원문 그대로 둔다.
6const LABELS: Record<string, string> = {
7  'System prompt': '시스템 프롬프트',
8  'System tools': '시스템 도구',
9  'MCP tools': 'MCP 도구',
10  'MCP server instructions': 'MCP 서버 지침',
11  'Custom agents': '커스텀 에이전트',
12  'Memory files': '메모리 파일',
13  Skills: '스킬',
14  Messages: '대화',
15  'Free space': '남은 공간',
16  'Autocompact buffer': '자동 압축 예비 공간',
17}
18
19export const labelOf = (name: string): string => LABELS[name] ?? name
20
21// deferred 행은 필요할 때만 불러오는 도구 스키마라 창을 차지하지 않으므로 뺀다.
22export const toSnapshot = (b: SessionContextBreakdown): ContextSnapshot => ({
23  model: b.model,
24  totalTokens: b.totalTokens,
25  maxTokens: b.rawMaxTokens,
26  percentage: b.percentage,
27  rows: b.categories.flatMap(c =>
28    c.kind === 'deferred' ? [] : [{ name: c.name, tokens: c.tokens, color: c.color, kind: c.kind }],
29  ),
30})
31
32const trim = (n: number): string => n.toFixed(1).replace(/\.0$/, '')
33
34export const formatTokens = (n: number): string => {
35  if (n < 1000) return String(Math.round(n))
36  if (n < 1_000_000) return `${trim(n / 1000)}k`
37  return `${trim(n / 1_000_000)}m`
38}
39
40// 2k 이하 분류는 막대에서 거의 보이지 않아 색으로 구분되지 않으므로 범례에서도 뺀다.
41export const LEGEND_MIN_TOKENS = 2_000
42
43export const legendRows = (rows: ContextRow[]): ContextRow[] =>
44  rows.filter(r => r.tokens > LEGEND_MIN_TOKENS)
45
46export type Segment = { color: string; width: number }
47
48// 누적 경계를 반올림해 나누므로 칸 수의 합이 항상 width와 같다. 0칸인 분류는 막대에서 빠진다.
49export const layoutBar = (rows: ContextRow[], width: number): Segment[] => {
50  const sum = rows.reduce((acc, r) => acc + r.tokens, 0)
51  if (sum <= 0) return []
52
53  let cum = 0
54  const segments = rows.map(r => {
55    const start = Math.round((cum / sum) * width)
56    cum += r.tokens
57    const end = Math.round((cum / sum) * width)
58    return { color: r.color, width: end - start }
59  })
60  return segments.filter(s => s.width > 0)
61}
62
63export const headerOf = (s: ContextSnapshot): string => {
64  const free = s.rows.find(r => r.kind === 'free')
65  const parts = [`${formatTokens(s.totalTokens)} / ${formatTokens(s.maxTokens)} (${s.percentage}%)`]
66  if (free) parts.push(`남은 공간 ${formatTokens(free.tokens)}`)
67  parts.push(s.model)
68  return parts.join(' · ')
69}
70
types/index.d.ts 21 lines
1export type ContextRow = {
2  name: string
3  tokens: number
4  color: string
5  kind: 'used' | 'free' | 'buffer'
6}
7
8export type ContextSnapshot = {
9  model: string
10  totalTokens: number
11  maxTokens: number
12  percentage: number
13  rows: ContextRow[]
14}
15
16declare module 'claude-code' {
17  interface PluginState {
18    'context-band': { snapshot: ContextSnapshot | null }
19  }
20}
21