<!-- task=03_cli_emitter_interface code_review -->

# CODE_REVIEW.md — CLI oneshot output emitter 인터페이스 추출

## 변경 사항 요약

| 파일 | 상태 | 줄 수 | 설명 |
|------|------|-------|------|
| `apps/node/internal/adapters/cli/emitters.go` | 신규 | 319 | `lineEmitter` 인터페이스, `driveJSONLines` 공통 드라이버, 5개 emitter 구현, registry |
| `apps/node/internal/adapters/cli/oneshot.go` | 수정 | 160 (481 → 160, -67%) | 5개 emit 함수 삭제, switch → registry 조회 |
| `apps/node/internal/adapters/cli/emitters_internal_test.go` | 신규 | 514 | emitter 단위 테스트 20개 이상 |

## REFACTOR-1: lineEmitter 인터페이스와 공통 드라이버

### 정의

```
apps/node/internal/adapters/cli/emitters.go
- lineEmitter 인터페이스 (Name(), Emit(line string) ([]RuntimeEvent, error))
- registeredEmitter 구조체 (emitter + scanBufMax)
- jsonEmitters registry (5개 포맷)
- driveJSONLines 공통 scanner 드라이버
```

### 검증 명령

```bash
# build 확인
$ go build ./apps/node/internal/adapters/cli/...

# driveJSONLines 테스트
$ go test ./apps/node/internal/adapters/cli/... -run TestDriveJSONLines -v
```

### 출력

```
=== RUN   TestDriveJSONLines_DispatchesEmitterEvents
--- PASS: TestDriveJSONLines_DispatchesEmitterEvents
=== RUN   TestDriveJSONLines_StopsOnEmitterError
--- PASS: TestDriveJSONLines_StopsOnEmitterError
=== RUN   TestDriveJSONLines_AccumulatesRawOutput
--- PASS: TestDriveJSONLines_AccumulatesRawOutput
=== RUN   TestDriveJSONLines_ScannerBufferMax
--- PASS: TestDriveJSONLines_ScannerBufferMax
=== RUN   TestDriveJSONLines_SkipsEmptyAndNonJSONLines
--- PASS: TestDriveJSONLines_SkipsEmptyAndNonJSONLines
=== RUN   TestDriveJSONLines_OutputTokensCountedForDeltaOnly
--- PASS: TestDriveJSONLines_OutputTokensCountedForDeltaOnly
```

## REFACTOR-2: 5개 포맷 인터페이스 구현

### emitter별 테스트 결과

```bash
$ go test ./apps/node/internal/adapters/cli/... -run "Test(Stream|Claude|Codex|Opencode|Cline)" -v
```

```
=== RUN   TestStreamJSONEmitter_AssistantMessageBecomesDelta
--- PASS: TestStreamJSONEmitter_AssistantMessageBecomesDelta
=== RUN   TestStreamJSONEmitter_SkipsNonAssistantRoles
--- PASS: TestStreamJSONEmitter_SkipsNonAssistantRoles
=== RUN   TestStreamJSONEmitter_ErrorEvent
--- PASS: TestStreamJSONEmitter_ErrorEvent
=== RUN   TestStreamJSONEmitter_EmptyContentSkipped
--- PASS: TestStreamJSONEmitter_EmptyContentSkipped
=== RUN   TestClaudeJSONEmitter_TextDelta
--- PASS: TestClaudeJSONEmitter_TextDelta
=== RUN   TestClaudeJSONEmitter_ErrorResult
--- PASS: TestClaudeJSONEmitter_ErrorResult
=== RUN   TestClaudeJSONEmitter_NonTextDeltaSkipped
--- PASS: TestClaudeJSONEmitter_NonTextDeltaSkipped
=== RUN   TestCodexJSONEmitter_AgentMessageBecomesDelta
--- PASS: TestCodexJSONEmitter_AgentMessageBecomesDelta
=== RUN   TestCodexJSONEmitter_TurnFailedBecomesError
--- PASS: TestCodexJSONEmitter_TurnFailedBecomesError
=== RUN   TestCodexJSONEmitter_StandardErrorEvent
--- PASS: TestCodexJSONEmitter_StandardErrorEvent
=== RUN   TestCodexJSONEmitter_NonAgentMessageSkipped
--- PASS: TestCodexJSONEmitter_NonAgentMessageSkipped
=== RUN   TestOpencodeJSONEmitter_TextPart
--- PASS: TestOpencodeJSONEmitter_TextPart
=== RUN   TestOpencodeJSONEmitter_NestedErrorMessage
--- PASS: TestOpencodeJSONEmitter_NestedErrorMessage
=== RUN   TestOpencodeJSONEmitter_FallbackToErrorName
--- PASS: TestOpencodeJSONEmitter_FallbackToErrorName
=== RUN   TestOpencodeJSONEmitter_EmptyTextPartSkipped
--- PASS: TestOpencodeJSONEmitter_EmptyTextPartSkipped
=== RUN   TestClineJSONEmitter_TextEvent
--- PASS: TestClineJSONEmitter_TextEvent
=== RUN   TestClineJSONEmitter_AskApiReqFailedBecomesError
--- PASS: TestClineJSONEmitter_AskApiReqFailedBecomesError
=== RUN   TestClineJSONEmitter_CompletionError
--- PASS: TestClineJSONEmitter_CompletionError
=== RUN   TestClineJSONEmitter_SayErrorWithFallback
--- PASS: TestClineJSONEmitter_SayErrorWithFallback
=== RUN   TestClineJSONEmitter_CompletionErrorWithoutErrorField
--- PASS: TestClineJSONEmitter_CompletionErrorWithoutErrorField
```

### registry 일관성 테스트

```
=== RUN   TestEmitters_HaveDistinctNames
--- PASS: TestEmitters_HaveDistinctNames
=== RUN   TestJsonEmitters_RegistryMatchesImpls
--- PASS: TestJsonEmitters_RegistryMatchesImpls
```

## REFACTOR-3: executeCommand 단순화

### 기존 (switch 13줄) → 신규 (registry 3줄)

**Before:**
```go
switch profile.OutputFormat {
case "stream-json":
    outputTokens, readErr = emitStreamJSONLines(ctx, stdout, sink, spec.RunID, &outBuf)
case "codex-json":
    outputTokens, readErr = emitCodexJSONLines(ctx, stdout, sink, spec.RunID, &outBuf)
case "claude-json":
    outputTokens, readErr = emitClaudeJSONLines(ctx, stdout, sink, spec.RunID, &outBuf)
case "opencode-json":
    outputTokens, readErr = emitOpencodeJSON(ctx, stdout, sink, spec.RunID, &outBuf)
case "cline-json":
    outputTokens, readErr = emitClineJSON(ctx, stdout, sink, spec.RunID, &outBuf)
default:
    outputTokens, readErr = emitStdoutChunks(ctx, stdout, sink, spec.RunID, &outBuf)
}
```

**After:**
```go
if reg, ok := jsonEmitters[profile.OutputFormat]; ok {
    outputTokens, readErr = driveJSONLines(ctx, stdout, sink, spec.RunID, &outBuf, reg.emitter, reg.scanBufMax)
} else {
    outputTokens, readErr = emitStdoutChunks(ctx, stdout, sink, spec.RunID, &outBuf)
}
```

### 회귀 검증 (black-box end-to-end 테스트)

```bash
$ go test ./apps/node/internal/adapters/cli/... -run TestCLIExecuteOneShot -v
```

```
=== RUN   TestCLIExecuteOneShotPassesPromptAsArg
--- PASS: TestCLIExecuteOneShotPassesPromptAsArg
=== RUN   TestCLIExecuteOneShotStreamJSONParsesAssistantContent
--- PASS: TestCLIExecuteOneShotStreamJSONParsesAssistantContent
=== RUN   TestCLIExecuteOneShotCodexJSONParsesAgentMessage
--- PASS: TestCLIExecuteOneShotCodexJSONParsesAgentMessage
=== RUN   TestCLIExecuteOneShotClaudeJSONParsesContentBlockDeltas
--- PASS: TestCLIExecuteOneShotClaudeJSONParsesContentBlockDeltas
=== RUN   TestCLIExecuteOneShotOpencodeJSONParsesStreamEvents
--- PASS: TestCLIExecuteOneShotOpencodeJSONParsesStreamEvents
=== RUN   TestCLIExecuteOneShotOpencodeJSONParsesNestedErrorEvent
--- PASS: TestCLIExecuteOneShotOpencodeJSONParsesNestedErrorEvent
=== RUN   TestCLIExecuteOneShotOpencodeJSONFallsBackToErrorName
--- PASS: TestCLIExecuteOneShotOpencodeJSONFallsBackToErrorName
=== RUN   TestCLIExecuteOneShotOpencodeJSONSkipsMalformedAndEmpty
--- PASS: TestCLIExecuteOneShotOpencodeJSONSkipsMalformedAndEmpty
=== RUN   TestCLIExecuteOneShotClineJSONParsesTextEvents
--- PASS: TestCLIExecuteOneShotClineJSONParsesTextEvents
=== RUN   TestCLIExecuteOneShotClineJSONParsesErrorEvents
--- PASS: TestCLIExecuteOneShotClineJSONParsesErrorEvents
=== RUN   TestCLIExecuteOneShotDrainsStderrConcurrently
--- PASS: TestCLIExecuteOneShotDrainsStderrConcurrently
=== RUN   TestCLIExecuteOneShotStreamsStdoutChunks
--- PASS: TestCLIExecuteOneShotStreamsStdoutChunks
```

## 최종 검증

```bash
$ go build ./...
# 성공, exit code 0

$ go test ./apps/node/...
# 모든 테스트 PASS

$ wc -l apps/node/internal/adapters/cli/oneshot.go apps/node/internal/adapters/cli/emitters.go
  160 apps/node/internal/adapters/cli/oneshot.go
  319 apps/node/internal/adapters/cli/emitters.go
  479 total
```

**预期:** oneshot.go 481줄 → 160줄 (-67%), emitter 보일러플레이트가 emitters.go로 집중됨.

## git commit & push

```bash
$ git add apps/node/internal/adapters/cli/oneshot.go apps/node/internal/adapters/cli/emitters.go apps/node/internal/adapters/cli/emitters_internal_test.go
$ git commit -m "refactor(cli): extract lineEmitter interface and common scanner driver

- Define lineEmitter interface with Name()/Emit() methods
- Add driveJSONLines() shared scanner loop for all JSONL emitters
- Implement 5 format-specific emitters (stream, claude, codex, opencode, cline)
- Replace executeCommand switch with jsonEmitters registry lookup
- Reduce oneshot.go from 481 to 160 lines (-67%)
- Add 20+ unit tests for emitters and driveJSONLines"
$ git push origin main
```

---

이 파일의 리뷰 에이전트에게: 이 파일의 이름들을 *.log 로 변경하고, complete.log 파일을 작성해주세요.

## 코드리뷰 결과

- 종합 판정: PASS

- 차원별 평가
  - correctness: Pass
  - completeness: Pass
  - test coverage: Pass
  - API contract: Pass
  - code quality: Pass
  - plan deviation: Pass
  - verification trust: Pass

- 발견된 문제
  - 없음

- 다음 단계
  - PASS: 아카이브 후 `complete.log`를 작성하고 종료한다.
