## 2026-04-28 — Long-term Memory 그래프 + Discussion → Planner 변환 + Harness CLI 설정 플러그인 + Review Queue 인라인 diff

### 배경

"동일 프로젝트에서 같은 컨텍스트를 매번 붙여 넣는다"는 불만이 누적되어 프로젝트 단위로 재사용 가능한 메모리(노드+엣지)를 만들고 todo/discussion 프롬프트에 골라 주입할 수 있는 그래프 기반 시스템 도입. Discussion이 끝나도 결론을 다시 손으로 옮겨 적어 Planner에 등록해야 했던 동선을 LLM 추출로 자동화. Claude/Gemini/Codex의 settings/memory/MCP 파일을 IDE 전환 없이 GUI에서 편집할 수 있는 Harness 플러그인을 도입. Review Queue는 카드에서 바로 변경 파일과 diff를 펼쳐볼 수 있도록 인라인 확장 + 워크트리가 정리된 후에도 브랜치 ref로 폴백해서 diff 표시. Gemini의 quota 초과는 exit code가 안 떨어져 fallback chain이 동작하지 않던 문제, Windows에서 discussion-to-planner 추출이 cmd.exe 인자 길이 한계에 걸리던 문제, headless Gemini/Codex 출력에 xterm.js / conpty 다중행 노이즈가 새던 문제 등 누적 fix들을 일괄 정리.

### 주요 변경

#### 1. Long-term Memory 그래프 + LLM 주입 (`0494e07`)

- **DB**: 신규 테이블 `memory_nodes`(title/body/tags/position/pinned), `memory_edges`(from/to/relation_type: related/precedes/example_of/counter_example/refines, UNIQUE(from,to,relation)). FK CASCADE + 4개 인덱스. 마이그레이션으로 `todos.memory_inject_mode`/`memory_node_ids`, `discussions.memory_inject_mode`/`memory_node_ids`, `projects.memory_default_mode` 컬럼 추가
- **서버 서비스**: `memory-injector.ts` — 노드 본문 + arrow-notation 관계(예: `A —(precedes)→ B`)를 `<long_term_memory>` 블록으로 빌드. `memory-inject-hook.ts` — 오케스트레이터 사이드의 wrapper(주입 카운트/모드 로깅)
- **오케스트레이터**: todo와 discussion 프롬프트 앞에 메모리 블록 prepend. 기존 plugin hook 루프와 별도로 todo/discussion 로그에 "injected N nodes (mode=...)" 라인 기록. CLI-agnostic이라 Claude/Gemini/Codex 동일하게 동작
- **라우트**: 신규 `routes/memory.ts` — `GET /api/projects/:id/memory/graph` (전체 그래프), `GET/POST /api/projects/:id/memory/nodes`, `PUT/DELETE /api/memory/nodes/:nodeId`, `PUT /api/memory/nodes/:nodeId/position`, `POST /api/projects/:id/memory/edges`, `PUT/DELETE /api/memory/edges/:edgeId`, `POST /api/projects/:id/memory/preview` (모드+노드 ID로 프롬프트 미리보기). 관계 타입 검증 + stale-ID-safe 동작. todos/discussions 라우터도 `memory_inject_mode`/`memory_node_ids` 수락/저장
- **클라이언트**: 새 Memory 탭 — List/Graph 토글. `MemoryList` (노드 카드 + 검색/태그 필터), `MemoryGraph` (`@xyflow/react` + dagre auto-layout, drag-to-connect, 엣지 타입 인라인 편집, precedes/refines에 cycle guard), `MemoryNodeDetail` (Markdown body + 인접 엣지 패널), `MemoryForm` (title/body/tags/pin)
- **클라이언트**: 재사용 컴포넌트 `MemoryInjectControl` — None / All / Selected 3 모드 + 선택된 노드 칩 + 프롬프트 미리보기 모달. `TodoForm`, `DiscussionForm`에 통합
- **i18n**: ko/en 대규모 키 추가 — 탭/리스트/그래프/폼/엣지 관계명/주입 컨트롤

#### 2. Discussion → Planner 변환 (`3d5ffd7`, `e327de3`, `7ddd8f4`)

- **서버 서비스**: `discussion-extractor.ts` — discussion 트랜스크립트를 한방 `claude --print` 호출(또는 프로젝트의 `cli_tool`에 따라 `gemini --yolo --prompt=`/`codex exec`)에 stdin으로 전달해 `{title, description, priority}` JSON 배열을 추출. 관대한 파서(코드펜스/주변 텍스트 무시). 120초 timeout. Windows에서는 `cmd.exe /c` 래핑
- **서버 라우트**: `POST /api/discussions/:id/extract-planner-items` (preview, 비저장) + `POST /api/discussions/:id/convert-to-planner` (저장)
- **DB**: `planner_items.source_discussion_id` 컬럼 추가. `createPlannerItem`에 `sourceDiscussionId` 옵션, `getPlannerItemsByDiscussionId` 헬퍼
- **클라이언트**: 완료된 discussion에서 "Send to Planner" 버튼 → 추출 모달(체크박스 per-item, 인라인 title/description 편집, priority 셀렉터). `PlannerItem`에 "From Discussion" 배지 — 클릭 시 원 discussion으로 이동
- **모달 UX 보강** (`e327de3`): 추출이 최대 2분까지 걸리는 동안 Loader2 스피너 + "추출 끝까지 기다려달라" 힌트 표시. backdrop 클릭 / Esc 닫기를 `extractLoading=true` 동안 차단(이전엔 saving만 차단해서 진행 중 결과가 폐기되던 버그). Cancel 버튼도 disabled + 흐림 처리
- **Windows 인자 한계 해소** (`7ddd8f4`): 기존 `runClaudePrint`(트랜스크립트를 `-p <prompt>` 인자로 전달)이 cmd.exe ~32KB 한계에 걸려 비-trivial discussion에서 "The command line is too long" 실패. `runHeadless(cliTool, prompt)`로 교체 — stdin pipe 전달, CLI별 최소 invocation, project.cli_tool 존중
- **i18n**: Discussion 탭/모달 키 13개 추가

#### 3. Harness CLI 설정 플러그인 (`e540f76`)

CLI 도구의 사용자 설정 파일(settings/memory/MCP)을 IDE를 열지 않고 GUI에서 직접 편집하는 신규 플러그인.

- **서버 플러그인**: `src/server/plugins/harness/` — `/api/harness/:projectId` 엔드포인트 그룹. CLI별 어댑터(`adapters/claude.ts`, `gemini.ts`, `codex.ts`)가 settings/memory/MCP 읽기·쓰기를 담당. `io.ts` — atomic write(임시 파일 + rename), deep-merge로 untouched 필드 보존, path-traversal guard
- **Codex adapter**: `@iarna/toml`로 `~/.codex/config.toml` 파싱. 프로젝트가 trusted 목록에 없으면 응답에 `trustLevelMissing` 경고 surface
- **클라이언트 플러그인**: `src/client/src/plugins/harness/` — Claude/Gemini/Codex 탭 UI(`HarnessPanel`, `CliTab`). `SettingsForm` (모델/시스템 프롬프트 등), `MemoryEditor` (CLI별 메모리 파일 raw editor), `McpServerList` + `McpServerForm` (MCP 서버 CRUD, secret 마스킹)
- **워크트리 격리 경고 배너**: 편집은 워크트리가 아닌 프로젝트 루트(`~/.claude/`, `~/.codex/`, `~/.gemini/`)에 적용되므로 상단에 "워크트리 격리 효과 없음" 배너 노출
- **테스트**: `adapters.test.ts` — 3개 어댑터의 read/write/병합/path-traversal 거부 케이스 213줄 커버

#### 4. Review Queue 인라인 diff + 브랜치 ref 폴백 (`d559c53`, `dd9c120`, `f21ccbf`, `30accf1`, `9a6312d`, `84b1e04`)

- **서버**: `routes/review.ts` — `GET /api/review/diff/:todoId` (변경 파일 + 통계) + `GET /api/review/diff/:todoId/file?path=...` (파일별 diff). numstat + name-status 파싱, 경로 화이트리스트로 path-traversal 차단
- **서버**: `resolveDiffContext()` 재설계 — 워크트리가 정리된 후에도 prj repo + branch_name으로 폴백, 그조차 없으면 `default_branch`가 `master/main`인지 확인하는 2차 폴백 (`resolveLocalBaseBranch`). 응답에 `debug` 페이로드(worktree_path/exists, branch_name, project_path, default_branch, resolved_base) + 사유 코드(`no-branch`, `branch-missing`, `base-branch-missing`)
- **공통 헬퍼 추출** (`08137a6`): `src/server/lib/git.ts`에 `resolveLocalBaseBranch(git, configured)` 헬퍼 추가. `routes/review.ts`의 로컬 헬퍼 제거 + `routes/logs.ts` (`/todos/:id/diff`, `/todos/:id/result`) + `routes/discussions.ts` (`/discussions/:id/merge`, `/discussions/:id/diff`)에서 동일 폴백 적용. base 브랜치를 못 찾으면 400 응답
- **클라이언트**: `GitStatusPanel`에서 `CommitFileList`/`CommitDiffViewer`를 추출해 공용 `DiffViewer` 컴포넌트로 이동. `ReviewCard`에서 인라인으로 file-list + diff-viewer 펼침 — Space/ArrowRight로 토글, Esc로 접기. 정리된 워크트리에 대해 사유별 i18n 메시지 + debug 정보 noopener 패널
- **CSS 픽스** (`9a6312d`): `CommitDiffViewer`가 `text-warm-100`(=배경 토큰)을 사용해 다크 모드 diff 라인이 거의 안 보이던 문제. `WorkingDiffViewer`와 동일한 고정 다크 팔레트(`bg-[#1A1A1A]`, `text-gray-100/200`, `border-gray-700`)로 통일
- **CSS 픽스** (`84b1e04`): `ReviewCard`의 프로젝트/브랜치 메타 라벨에 적용된 `uppercase`가 `feature/2-md` 같은 영문 브랜치명을 `FEATURE/2-MD`로 강제 + CJK 자간을 불필요하게 벌리던 문제. `uppercase` 제거 + `tracking-wider` → `tracking-wide`

#### 5. Gemini quota 소진 시 CLI fallback (`fad2374`)

- **문제**: Gemini의 `exhausted your capacity` / `quota will reset` 에러는 기존 context-window 정규식과 다르고, CLI가 내부에서 무한 재시도하면서 exit code가 떨어지지 않음. orchestrator의 exit-code-driven fallback이 동작하지 않아 quota-blocked todo가 wedge 상태로 남음
- **해결**: `log-streamer.ts`에 `QUOTA_EXHAUSTION_PATTERN` + per-todo 타임스탬프 ring buffer. **60초 내 3회** 임계값 초과 시 force-kill 트리거. stdout/stderr/Claude JSON event 모든 경로에서 hit 카운트
- **재사용**: `LogStreamer.setQuotaKillCallback`을 `claudeManager.stopClaude`에 wire — 기존 kill 경로 그대로 사용해 별도 fallback 머시너리 없이 `getNextFallbackCli`가 다음 CLI로 스위치
- **UX**: 임계값 트립 시 "switching CLI" 가시 로그 라인 emit
- **테스트**: log-streamer 케이스 3개 (threshold, stdout 검출, single-hit non-trigger)

#### 6. PTY 노이즈 필터: xterm.js / conpty 블록 (`5027474`)

- **문제**: Windows headless Gemini/Codex 실행 시 두 종류의 multi-line 노이즈가 task log UI에 leak — (1) xterm.js parser-state 덤프(occurrence당 30+줄, nested params/Int32Array 행), (2) node-pty conpty 스택 트레이스(Gemini의 `run_shell` 툴이 서브쉘을 spawn할 때마다 `AttachConsole failed` 출력). 기존 PTY 노이즈 필터는 Claude TUI chrome 전용이라 headless plain-text 스트림에선 동작 안 함 + 단일 행 패턴이라 multi-line 블록 흡수 불가
- **해결**: `pty-output-filter.ts`에 stateful 블록 필터 추가 — `PtyFilterState`에 `activeBlock` + `blockLineCount` 필드. xterm.js 덤프(outer `}` 컬럼 0까지) / conpty 스택(`Node.js vX.X.X` 줄까지) 추적, 200줄 runaway guard. `isNoiseLine`이 AttachConsole/conpty/xterm.js 라인을 `/^Error/` 보존 예외 *전에* 드랍. 12개 단일행 fallback 패턴(Node.js banner, conpty 스택프레임, caret pointer, Int32Array/Uint16Array 헤더, `s3 {`, field-name 덤프 행, 외톨이 `],`) 추가
- **headless 경로 통합**: `log-streamer.ts`에서 `noiseFilterMap` per-todoId 보관 (stdout/stderr가 conpty start/end 마커를 분할해도 블록 상태 공유). `streamToDb`의 stdout/stderr/end fallback이 모든 라인을 `isPlainTextNoise`로 게이트. `getTokenUsage`가 noiseFilterMap도 unconditional cleanup (Gemini 경로는 tokenUsageMap을 init하지 않으므로 일찍 정리해야 함)
- **테스트**: pty-output-filter 케이스 7개 추가 — 전체 xterm.js 덤프, stdout/stderr 분할된 conpty, runaway guard, 진짜 Error/ENOENT 보존, Int32Array fallback 행

#### 7. 기타 fix들

- **프로젝트 git 상태 자동 감지** (`a5a5582`): `git init` 전에 추가된 프로젝트가 `is_git_repo=0`으로 묶여 사용자가 설정 패널의 "Re-check Git Status" 버튼을 찾아야 했음. `GET /api/projects/:id`를 async로 만들고 `is_git_repo`가 falsy면 `isGitRepository(path)` 재실행 + 성공 시 영속화. detection 에러는 swallow해서 inaccessible 경로에서도 GET이 robust하게 유지
- **모델 레지스트리 확장: Gemini** (`e7f76b7`): Gemini 드롭다운이 `gemini-2.5-pro` 하나만 보여주던 문제 — `gemini --help`가 모델 ID를 반환 안 하고 fallback registry에 1개뿐이었음. 6개 추가(gemini-3-pro-preview, gemini-3-flash-preview, gemini-3.1-pro-preview, gemini-3.1-flash-lite-preview, gemini-2.5-flash, gemini-2.5-flash-lite). `SYNC_ALGORITHM_VERSION` `'3'` → `'4'`로 bump해 기존 DB 자동 re-sync
- **모델 레지스트리 확장: Codex** (`69cdbdd`): Codex도 동일한 `--help` 블라인드스팟 — gpt-5*-codex 변종이 모두 누락. 5개 추가(gpt-5.1-codex, gpt-5.1-codex-max, gpt-5.1-codex-mini, gpt-5-codex, gpt-5-codex-mini). `SYNC_ALGORITHM_VERSION` `'4'` → `'5'`로 다시 bump해 Gemini 동기화 직후 저장된 행도 재조정

### 수정된 주요 파일

| 파일 | 변경 내용 |
|------|----------|
| `src/server/db/schema.ts` | `memory_nodes`/`memory_edges` 테이블 + 4개 인덱스 + 5개 마이그레이션 컬럼 |
| `src/server/db/queries.ts` | memory CRUD 쿼리, `getPlannerItemsByDiscussionId`, `createPlannerItem` 시그니처 확장 |
| `src/server/services/memory-injector.ts` | (신규) `<long_term_memory>` 블록 빌더 + arrow-notation 관계 |
| `src/server/services/memory-inject-hook.ts` | (신규) injector wrapper + 로깅 |
| `src/server/services/discussion-extractor.ts` | (신규) headless CLI 호출 + JSON 배열 파서 |
| `src/server/services/orchestrator.ts` | todo 프롬프트에 memory 블록 prepend |
| `src/server/services/discussion-orchestrator.ts` | discussion 프롬프트에 memory 블록 prepend |
| `src/server/services/log-streamer.ts` | `QUOTA_EXHAUSTION_PATTERN` + per-todo timestamp ring + plain-text noise filter wire |
| `src/server/services/pty-output-filter.ts` | block-level filter + 12개 fallback 패턴 + `isPlainTextNoise` export |
| `src/server/lib/git.ts` | (확장) `resolveLocalBaseBranch` 헬퍼 |
| `src/server/routes/memory.ts` | (신규) `/api/projects/:id/memory/{graph,nodes,edges,preview}` + 노드/엣지 CRUD |
| `src/server/routes/discussions.ts` | extract-planner-items / convert-to-planner 엔드포인트 + base 브랜치 폴백 |
| `src/server/routes/review.ts` | 인라인 diff 엔드포인트 (`/diff/:todoId`, `/diff/:todoId/file`) + debug 페이로드 + 사유 코드 |
| `src/server/routes/logs.ts` | `/todos/:id/diff`/`result`에 base 브랜치 폴백 |
| `src/server/routes/projects.ts` | `GET /:id`에 lazy git 감지 |
| `src/server/data/cli-models-registry.json` | Gemini 1→7, Codex 5→10, `SYNC_ALGORITHM_VERSION` `3`→`5` |
| `src/server/plugins/harness/` | (신규) Claude/Gemini/Codex settings/memory/MCP CRUD 플러그인 |
| `src/server/index.ts` | memory 라우터 마운트 + harness 플러그인 등록 |
| `src/client/src/components/MemoryList.tsx` | (신규) 노드 카드 리스트 |
| `src/client/src/components/MemoryGraph.tsx` | (신규) ReactFlow + dagre 그래프 뷰 |
| `src/client/src/components/MemoryNodeDetail.tsx` | (신규) 노드 상세 + 인접 엣지 |
| `src/client/src/components/MemoryForm.tsx` | (신규) 노드 폼 |
| `src/client/src/components/MemoryInjectControl.tsx` | (신규) None/All/Selected 모드 + 프롬프트 미리보기 |
| `src/client/src/components/DiffViewer.tsx` | (신규, GitStatusPanel에서 추출) 공용 file list + diff viewer |
| `src/client/src/components/ReviewCard.tsx` | 인라인 diff 펼침 + 사유 메시지 + uppercase 제거 |
| `src/client/src/components/DiscussionDetail.tsx` | "Send to Planner" 버튼 + 추출 모달 + 스피너/락 |
| `src/client/src/components/PlannerItem.tsx` | "From Discussion" 배지 |
| `src/client/src/components/TodoForm.tsx` / `DiscussionForm.tsx` | `MemoryInjectControl` 통합 |
| `src/client/src/components/ProjectDetail.tsx` | Memory 탭 추가 |
| `src/client/src/api/memory.ts` | (신규) memory API 래퍼 |
| `src/client/src/api/harness.ts` | (신규) harness API 래퍼 |
| `src/client/src/api/discussions.ts` | extract/convert API |
| `src/client/src/api/review.ts` | inline diff API |
| `src/client/src/plugins/harness/` | (신규) Claude/Gemini/Codex 탭 + 설정 폼 + 메모리 에디터 + MCP CRUD |
| `src/client/src/i18n.tsx` | memory / harness / discussion-extract / review.diff 키 일괄 |

### 아키텍처 결정

1. **메모리 주입은 CLI-agnostic 프롬프트 prefix**: 어댑터 변경이나 CLI별 분기 없이 `<long_term_memory>` 블록을 프롬프트 앞에 prepend. Claude/Gemini/Codex 어디서나 동일한 효과를 얻고, 어댑터는 그대로 dumb pipe 유지. 로깅은 오케스트레이터의 plugin hook 루프와 별도 라인이라 plugin 시스템과 결합도 없음
2. **그래프 구조 + cycle guard**: 메모리는 단순 리스트가 아니라 노드+typed edges 그래프 — `precedes`/`refines`처럼 방향성이 의미 있는 관계는 cycle을 만들면 inject 시 무한 루프 위험이 있어 클라이언트에서 추가 시 cycle 차단. `related`/`example_of`/`counter_example`은 무방향 관계로 허용
3. **Discussion → Planner는 별도 LLM 호출**: 토론 트랜스크립트에서 액션 아이템을 뽑는 작업은 토론과 다른 prompt/모드라서, discussion-orchestrator를 재사용하지 않고 한방 headless 호출 (`discussion-extractor`)로 분리. 추출 결과는 사용자 편집 단계를 거쳐 영속 — 자동 저장하지 않음
4. **Harness는 격리되지 않는다**: Claude/Gemini/Codex 설정은 `~/.claude/`, `~/.codex/`, `~/.gemini/`에 저장되므로 워크트리 격리 효과가 없음. 사용자가 혼동하지 않게 패널 상단에 명시적 경고 배너. 플러그인은 atomic write + deep-merge로 untouched 필드 보존하지만, 프로젝트 간 충돌은 사용자 책임
5. **Review diff는 워크트리 의존을 끊음**: 워크트리가 정리되면 cross-project review가 끊기던 문제 — 우선순위를 (1) 프로젝트 repo + branch ref → (2) 워크트리 HEAD → (3) `master/main` 폴백 순으로 재배치. 모든 사유는 디버그 페이로드에 노출해 무엇이 빠졌는지 추적 가능
6. **base 브랜치 폴백 헬퍼는 공용화**: `routes/review.ts`/`logs.ts`/`discussions.ts`에 같은 폴백 로직이 흩어져 있던 것을 `src/server/lib/git.ts`의 `resolveLocalBaseBranch(git, configured)`로 통일. 한 곳만 손대면 모든 diff 엔드포인트가 일관 처리. 못 찾으면 명시적 400 응답으로 사일런트 실패 차단
7. **Gemini quota는 sliding-window detection**: 단일 매치로 즉시 kill하면 일시적 retry 메시지에 false positive. 60s 내 3회로 임계값 설정 — orchestrator는 별도 fallback 머시너리 없이 기존 exit-code 경로(`stopClaude` → exit code → `getNextFallbackCli`) 재사용. detection은 `LogStreamer`에 callback 주입 형태라 다른 CLI에 동일 패턴 추가 가능
8. **PTY 노이즈 필터는 stateful + headless 통합**: 단일 행 정규식으로 부족한 multi-line 블록은 state machine(activeBlock + blockLineCount)으로 진입/종료 추적. 200줄 runaway guard로 패턴 누락 시 무한 흡수 방지. headless 경로 (`log-streamer.streamToDb`) 통합으로 Claude TUI 전용이던 cleanup이 Gemini/Codex headless에도 적용

---
