<!-- task=cli_gemini_usage_status plan=0 tag=API -->

# Gemini Usage Status Plan

## 이 파일을 읽는 구현 에이전트에게

**구현 완료 후 `CODE_REVIEW-*-G??.md`의 모든 섹션을 채우는 것이 필수 최종 단계입니다. 이 파일을 채우기 전까지 작업은 완료가 아닙니다.** 아래 체크리스트와 중간/최종 검증을 실제로 실행하고, 구현 내용과 명령 출력을 `CODE_REVIEW-cloud-G07.md`에 기록하세요. `CODE_REVIEW-cloud-G07.md`의 `이 파일을 읽는 리뷰 에이전트에게` 섹션에 있는 아카이브 지시(`*.log` 변경, `complete.log` 작성)는 구현 에이전트가 수행하지 않습니다.

## 배경

`/status` console 명령은 Codex와 Claude 사용량을 TUI에서 조회해 edge console에 표시하지만, Gemini는 아직 `gemini status not implemented`를 반환한다. 실제 Gemini CLI `0.42.0` probe와 설치된 CLI 소스 확인 결과, `/stats model`에 표시되는 전체 daily quota 블록만 이번 요구 범위에 해당한다. Gemini의 `/model`에 나오는 모델별 quota와 세션별 model stats 표는 이번 구현에서 제외한다.

## 분석 결과

### 읽은 파일

- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/project/domain/node/rules.md`
- `agent-ops/skills/common/plan/SKILL.md`
- `agent-task/cli_usage_status_command/plan_0.log`
- `agent-task/cli_claude_usage_status/plan_local_G06_0.log`
- `bin/edge.sh`
- `bin/node.sh`
- `configs/edge.yaml`
- `configs/node.yaml`
- `apps/node/internal/adapters/cli/cli.go`
- `apps/node/internal/adapters/cli/status/gemini.go`
- `apps/node/internal/adapters/cli/status/status.go`
- `apps/node/internal/adapters/cli/status/parser.go`
- `apps/node/internal/adapters/cli/status/status_test.go`
- `apps/node/internal/adapters/cli/status/parser_test.go`
- `apps/node/internal/adapters/cli/status/codex_test.go`
- `apps/node/internal/adapters/cli/status/claude_test.go`
- `apps/edge/cmd/edge/console.go`
- `apps/edge/cmd/edge/console_test.go`
- `/config/.npm-global/lib/node_modules/@google/gemini-cli/bundle/chunk-CHERUG6W.js`
- `/config/.npm-global/lib/node_modules/@google/gemini-cli/bundle/interactiveCli-AG2YWL2O.js`

### 테스트 커버리지 공백

- Gemini checker 선택: 기존 `status_test.go`는 Codex/Claude command 보존만 검증한다. Gemini도 profile command를 보존하고 빈 command는 `"gemini"`로 보정하는 테스트가 필요하다.
- Gemini `/stats model` quota 파싱: 기존 `parser_test.go`는 Codex와 Claude 출력만 검증한다. `0% used (Limit resets in 24h)`와 `Usage limit: 200`을 전체 daily quota로 파싱하는 테스트가 필요하다.
- Gemini PTY 조작: `gemini.go`는 stub이라 fake TUI로 `/stats model` 입력, quota 출력 파싱, 종료 입력 흐름을 검증하는 테스트가 없다.
- Edge `/status` 표시: `formatUsageStatus`는 `DailyLimit`/`WeeklyLimit`만 표시한다. Gemini metadata의 `used_percent`와 `usage_limit`이 있을 때 `Used`와 `Usage limit`이 출력되는 테스트가 필요하다.
- Bin smoke: 기존 테스트는 package 단위다. `./bin/edge.sh`와 `./bin/node.sh`를 통해 `target=gemini`의 `/status`가 node command 경로로 연결되는지 deterministic fake Gemini로 검증해야 한다.

### 심볼 참조

- renamed/removed symbol 없음.
- 참조 확인 명령: `rg --sort path -n "NewGeminiChecker|GeminiChecker|ParseStatusOutput|formatUsageStatus|CheckUsage|NewChecker|DailyLimit|WeeklyLimit|daily_label|weekly_label|UsageStatus|AgentUsageStatus" apps/node/internal/adapters/cli apps/edge/cmd/edge proto/iop proto/gen/iop packages configs`
- `NewGeminiChecker` 호출부는 현재 `apps/node/internal/adapters/cli/status/status.go:61` 1곳이다. 시그니처 변경 시 `status.go`, `status_test.go`, 새 `gemini_test.go`를 함께 갱신한다.
- `formatUsageStatus` 호출부는 `apps/edge/cmd/edge/console.go:352` 1곳이고 테스트는 `apps/edge/cmd/edge/console_test.go:170`, `:187`, `:208` 주변이다.

### 범위 결정 근거

- `proto/iop/runtime.proto`와 `proto/gen/iop/*.pb.go`는 수정하지 않는다. Gemini의 `used_percent`와 `usage_limit`은 기존 `AgentUsageStatus.metadata`로 전달한다.
- Gemini `/model`의 모델별 quota는 구현하지 않는다. 사용자가 이번 후속에서 전체 quota만 필요하다고 범위를 좁혔고, `/status`의 기존 구조도 daily/weekly summary 중심이다.
- Gemini status 조회는 실행 profile args(`--approval-mode`, `-p`, `--output-format`)를 재사용하지 않는다. Claude/Codex status와 같이 profile command path만 재사용하는 별도 TUI 조회로 둔다.
- `configs/edge.yaml`의 기본 `console.target`은 변경하지 않는다. Bin smoke는 `bin/edge.sh`의 config override와 `/tmp` 임시 config로 `target=gemini`를 검증한다.
- 실제 Gemini CLI `0.42.0`은 `/stats model` 세션에 API 호출이 없으면 `No API calls have been made in this session.`만 렌더한다. 구현은 이를 parsed quota로 가장하지 않고 raw fallback 또는 명확한 미파싱 상태로 드러낸다.

### 빌드 등급

- build lane: `cloud-G07` — Gemini TUI/PTY 자동화, stdout repaint/스크린리더 출력 파싱, bin script orchestration이 성공 조건에 포함된다.
- review lane: `cloud-G07` — fake TUI unit test와 bin smoke 출력 신뢰성, `/stats model` no-quota 조건 처리를 실제 구현과 대조해야 한다.

## 의존 관계 및 구현 순서

1. `API-1`에서 Gemini quota parser를 먼저 추가한다.
2. `API-2`에서 GeminiChecker가 `/stats model`을 실행하고 parser를 호출하게 한다.
3. `API-3`에서 edge console이 Gemini quota metadata를 출력하게 한다.
4. `API-4`에서 bin smoke가 임시 Gemini config로 재현 가능하도록 `bin/edge.sh` config override를 추가한다.

### [API-1] Gemini `/stats model` 전체 quota parser 추가

#### 문제

`apps/node/internal/adapters/cli/status/parser.go:10-24`는 Codex `5h limit`/`Weekly limit`와 Claude `Current session`/`Current week`만 인식한다. Gemini `/stats model`의 전체 quota는 `0% used (Limit resets in 24h)`와 `Usage limit: 200` 형태라 현재 parser는 `DailyLimit`과 reset을 채우지 못하고 raw fallback만 남긴다. 이번 요구는 모델별 usage가 아니라 전체 daily quota이므로 Gemini output은 `DailyLimit`에 remaining percent, `DailyResetTime`에 reset interval, metadata에 used percent와 numeric limit을 넣는다.

Before (`apps/node/internal/adapters/cli/status/parser.go:10`):

```go
var (
	dailyLimitRegex  = regexp.MustCompile(`5h limit:.*?\]\s*([^%\n]+%)[^\(]*\(resets\s+([^\)]+)\)`)
	weeklyLimitRegex = regexp.MustCompile(`Weekly limit:.*?\]\s*([^%\n]+%)[^\(]*\(resets\s+([^\)]+)\)`)

	// Section header locators — they only locate the start of each block; they
	// do NOT span forward into other blocks. This prevents the previous lazy
	// `.*?` regex from accidentally pairing a session header with the week's
	// "X% used / Resets Y" payload when cursor positioning in the raw PTY
	// stream interleaves the two sections out of order.
	claudeSessionHeaderRegex = regexp.MustCompile(`(?i)Current\s+session\b`)
	claudeWeekHeaderRegex    = regexp.MustCompile(`(?i)Current\s+week\b(?:\s*\(all\s+models\))?`)

	// Payload extractor used *within* a single section slice.
	claudePayloadRegex = regexp.MustCompile(`(?is)(\d+(?:\.\d+)?)%\s+used.*?Resets\s+([^\n]+)`)
)
```

Before (`apps/node/internal/adapters/cli/status/parser.go:34`):

```go
func ParseStatusOutput(raw string) (*UsageStatus, error) {
	visible := renderVisibleScreen(raw, 60, 200)
	cleanRaw := cleanANSI(raw)

	// Prefer whichever input yields a more complete Claude parse: a full
	// session+week match beats a partial one, which beats nothing.
	visStatus := &UsageStatus{RawOutput: visible}
	rawStatus := &UsageStatus{RawOutput: cleanRaw}
	visStatus.parseClaudeUsage(visible)
	rawStatus.parseClaudeUsage(cleanRaw)

	if pick := pickMoreComplete(visStatus, rawStatus); pick != nil {
		return pick, nil
	}

	// Neither input produced Claude data — try Codex.
	rawStatus.parseCodexUsage(cleanRaw)
	return rawStatus, nil
}
```

#### 해결 방법

Gemini 전용 parser를 추가한다. `0% used (Limit resets in 24h)`는 `used_percent=0%`, `DailyLimit=100%`, `DailyResetTime=24h`로 변환한다. `Usage limit: 200`은 metadata `usage_limit=200`으로 보존한다. `Limit reached, resets in 5h`도 boundary로 처리해 `DailyLimit=0%`, `used_percent=100%`, `DailyResetTime=5h`가 되게 한다. `No API calls have been made in this session.`은 파싱 실패가 맞으므로 raw output만 유지한다.

After:

```go
var (
	geminiUsedQuotaRegex    = regexp.MustCompile(`(?i)(\d+(?:\.\d+)?)%\s+used\s+\(Limit resets in ([^)]+)\)`)
	geminiLimitReachedRegex = regexp.MustCompile(`(?i)Limit reached,\s*resets in ([^\n]+)`)
	geminiUsageLimitRegex   = regexp.MustCompile(`(?i)Usage limit:\s*([0-9][0-9,]*)`)
)

func (s *UsageStatus) parseGeminiQuota(cleanRaw string) bool {
	// Fill DailyLimit as remaining percent. Store Gemini-only fields in Metadata.
}
```

`ParseStatusOutput` 순서는 Claude visible/raw 선택 뒤 Gemini quota, 그 다음 Codex로 둔다. Gemini는 daily 전체 quota만 채우고 `WeeklyLimit`은 비워 둔다.

#### 수정 파일 및 체크리스트

- [ ] `apps/node/internal/adapters/cli/status/parser.go` - Gemini quota regex 추가
- [ ] `apps/node/internal/adapters/cli/status/parser.go` - `parseGeminiQuota` 추가, `DailyLimit`, `DailyResetTime`, metadata 설정
- [ ] `apps/node/internal/adapters/cli/status/parser.go` - `ParseStatusOutput`에서 Claude 실패 후 Gemini, Codex 순서로 파싱
- [ ] `apps/node/internal/adapters/cli/status/parser_test.go` - `TestParseStatusOutput_GeminiStatsQuota` 추가
- [ ] `apps/node/internal/adapters/cli/status/parser_test.go` - `TestParseStatusOutput_GeminiLimitReached` 추가
- [ ] `apps/node/internal/adapters/cli/status/parser_test.go` - `TestParseStatusOutput_GeminiNoAPICallsLeavesRawOnly` 추가

#### 테스트 작성

- `apps/node/internal/adapters/cli/status/parser_test.go` - `TestParseStatusOutput_GeminiStatsQuota`: sample에 `0% used (Limit resets in 24h)`, `Usage limit: 200`을 넣고 `DailyLimit == "100%"`, `DailyResetTime == "24h"`, metadata `daily_label == "Daily quota"`, `used_percent == "0%"`, `usage_limit == "200"`을 검증한다.
- `apps/node/internal/adapters/cli/status/parser_test.go` - `TestParseStatusOutput_GeminiLimitReached`: `Limit reached, resets in 5h`, `Usage limit: 200`에서 `DailyLimit == "0%"`, metadata `used_percent == "100%"`를 검증한다.
- `apps/node/internal/adapters/cli/status/parser_test.go` - `TestParseStatusOutput_GeminiNoAPICallsLeavesRawOnly`: `No API calls have been made in this session.`에서 parsed limit이 비어 있고 raw output은 남는지 검증한다.

#### 중간 검증

```bash
go test ./apps/node/internal/adapters/cli/status/... -run 'TestParseStatusOutput_(Gemini|Codex|Claude)' -count=1
```

기대 결과: 새 Gemini parser tests와 기존 Codex/Claude parser tests가 모두 통과한다. 캐시 출력은 허용하지 않으므로 `-count=1`을 유지한다.

### [API-2] GeminiChecker를 PTY 기반 `/stats model` 조회로 구현

#### 문제

`apps/node/internal/adapters/cli/status/gemini.go:8-16`은 stub이라 `/status`가 `cli/gemini` target에 대해 항상 `gemini status not implemented`를 반환한다. `apps/node/internal/adapters/cli/status/status.go:60-61`도 `NewGeminiChecker()`에 profile command를 전달하지 않아 config의 command path를 보존하지 않는다.

Before (`apps/node/internal/adapters/cli/status/gemini.go:1`):

```go
package status

import (
	"context"
	"fmt"
)

type GeminiChecker struct{}

func NewGeminiChecker() *GeminiChecker {
	return &GeminiChecker{}
}

func (g *GeminiChecker) Check(ctx context.Context) (*UsageStatus, error) {
	return nil, fmt.Errorf("gemini status not implemented")
}
```

Before (`apps/node/internal/adapters/cli/status/status.go:60`):

```go
case agent == "gemini" || cmdBase == "gemini":
	return NewGeminiChecker(), nil
```

#### 해결 방법

Codex/Claude checker 구조를 따라 `GeminiChecker`에 `command string`을 보관하고, 빈 command는 `"gemini"`로 보정한다. `Check`는 `exec.CommandContext(ctx, g.command, "--skip-trust", "--screen-reader")`를 PTY로 띄운 뒤 ready prompt를 기다리고, `Ctrl+U`, `/stats model`, Enter를 전송한다. `Usage limit:`, `Limit reached`, 또는 `No API calls have been made in this session.` 중 하나를 기다린 뒤 flush하고 `ParseStatusOutput`을 호출한다. 종료는 `Esc`, `Ctrl+U`, `/quit\r` 순서로 시도하고 defer kill을 유지한다.

After:

```go
type GeminiChecker struct {
	command string
}

func NewGeminiChecker(command string) *GeminiChecker {
	if command == "" {
		command = "gemini"
	}
	return &GeminiChecker{command: command}
}
```

`--screen-reader`는 실제 probe에서 `/model` full name과 `/stats model` 텍스트를 파싱하기 쉬운 형태로 출력했다. 이 flag는 status 조회 전용이며 profile args와 섞지 않는다.

#### 수정 파일 및 체크리스트

- [ ] `apps/node/internal/adapters/cli/status/gemini.go` - imports 추가: `context`, `fmt`, `io`, `os`, `os/exec`, `regexp`, `strings`, `time`, `github.com/creack/pty`
- [ ] `apps/node/internal/adapters/cli/status/gemini.go` - command field와 constructor 보정 추가
- [ ] `apps/node/internal/adapters/cli/status/gemini.go` - PTY startup, chunks reader, `waitForAny`, `sendText`, `/stats model` 입력, output flush, graceful quit 구현
- [ ] `apps/node/internal/adapters/cli/status/status.go` - `NewGeminiChecker(profile.Command)` 호출로 변경
- [ ] `apps/node/internal/adapters/cli/status/status_test.go` - `TestNewChecker_GeminiUsesProfileCommand` 추가
- [ ] `apps/node/internal/adapters/cli/status/status_test.go` - `TestNewChecker_GeminiEmptyCommandDefaultsToGemini` 추가
- [ ] `apps/node/internal/adapters/cli/status/gemini_test.go` - fake TUI 기반 checker tests 추가

#### 테스트 작성

- `apps/node/internal/adapters/cli/status/status_test.go` - `TestNewChecker_GeminiUsesProfileCommand`: `NewChecker("gemini", config.CLIProfileConf{Command: "/tmp/mygemini"})`가 `*GeminiChecker`를 반환하고 `command == "/tmp/mygemini"`인지 검증한다.
- `apps/node/internal/adapters/cli/status/status_test.go` - `TestNewChecker_GeminiEmptyCommandDefaultsToGemini`: 빈 command에서 `command == "gemini"`인지 검증한다.
- `apps/node/internal/adapters/cli/status/gemini_test.go` - `TestGeminiCheckerRequestsStatsModel`: fake `gemini`가 `/stats model`을 받았는지 확인하고 quota block을 출력하면 checker가 `DailyLimit == "100%"`, `DailyResetTime == "24h"`, metadata `usage_limit == "200"`을 반환하는지 검증한다.
- `apps/node/internal/adapters/cli/status/gemini_test.go` - `TestGeminiCheckerNoAPICallsReturnsRawUnparsed`: fake가 `No API calls have been made in this session.`을 출력하면 error 없이 raw fallback status가 반환되고 parsed limit은 비는지 검증한다.

#### 중간 검증

```bash
go test ./apps/node/internal/adapters/cli/status/... -run 'Test(NewChecker_Gemini|GeminiChecker)' -count=1
```

기대 결과: Gemini checker factory와 fake TUI tests가 통과한다. 외부 real `gemini` binary에는 의존하지 않는다.

### [API-3] Edge `/status`에 Gemini quota metadata 출력 추가

#### 문제

`apps/edge/cmd/edge/console.go:364-374`는 `DailyLimit`과 `WeeklyLimit`만 출력한다. Gemini 전체 quota의 `Usage limit: 200`과 `0% used`는 `AgentUsageStatus.metadata`로 전달해야 protobuf 변경 없이 유지할 수 있지만, 현재 formatter는 metadata를 label 외에는 보여주지 않는다.

Before (`apps/edge/cmd/edge/console.go:364`):

```go
hasParsedLimits := false
dailyLabel := usageStatusLabel(status.GetMetadata(), "daily_label", "Daily limit")
if status.GetDailyLimit() != "" {
	fmt.Fprintf(out, "%s: %s remaining%s\n", dailyLabel, status.GetDailyLimit(), resetSuffix(status.GetDailyResetTime()))
	hasParsedLimits = true
}
weeklyLabel := usageStatusLabel(status.GetMetadata(), "weekly_label", "Weekly limit")
if status.GetWeeklyLimit() != "" {
	fmt.Fprintf(out, "%s: %s remaining%s\n", weeklyLabel, status.GetWeeklyLimit(), resetSuffix(status.GetWeeklyResetTime()))
	hasParsedLimits = true
}
```

#### 해결 방법

`formatUsageStatus`에 metadata 출력 helper를 작게 추가한다. `used_percent`가 있으면 `Used: 0%`, `usage_limit`이 있으면 `Usage limit: 200`을 parsed limits 아래에 출력한다. 이 metadata는 Gemini parser만 채우므로 Codex/Claude 출력은 바뀌지 않는다.

After:

```go
metadata := status.GetMetadata()
if used := usageStatusMetadata(metadata, "used_percent"); used != "" {
	fmt.Fprintf(out, "Used: %s\n", used)
}
if limit := usageStatusMetadata(metadata, "usage_limit"); limit != "" {
	fmt.Fprintf(out, "Usage limit: %s\n", limit)
}
```

#### 수정 파일 및 체크리스트

- [ ] `apps/edge/cmd/edge/console.go` - metadata lookup helper 추가
- [ ] `apps/edge/cmd/edge/console.go` - `formatUsageStatus`에서 `used_percent`, `usage_limit` 출력
- [ ] `apps/edge/cmd/edge/console_test.go` - `TestFormatUsageStatus_PrintsGeminiQuotaMetadata` 추가
- [ ] `apps/edge/cmd/edge/console_test.go` - 기존 Codex/Claude output tests가 변하지 않는지 확인

#### 테스트 작성

- `apps/edge/cmd/edge/console_test.go` - `TestFormatUsageStatus_PrintsGeminiQuotaMetadata`: `DailyLimit: "100%"`, `DailyResetTime: "24h"`, metadata `daily_label: "Daily quota"`, `used_percent: "0%"`, `usage_limit: "200"`에서 output이 `Daily quota: 100% remaining (resets 24h)`, `Used: 0%`, `Usage limit: 200`을 포함하는지 검증한다.

#### 중간 검증

```bash
go test ./apps/edge/cmd/edge/... -run 'TestFormatUsageStatus' -count=1
```

기대 결과: 기존 Codex/Claude formatter tests와 새 Gemini formatter test가 모두 통과한다.

### [API-4] Bin shell smoke를 위한 edge config override 추가

#### 문제

`bin/edge.sh:1-8`은 항상 `configs/edge.yaml`만 사용한다. 기본 `configs/edge.yaml:20`의 `console.target`은 `"claude"`라서, 사용자가 요구한 bin shell 기준 Gemini `/status` smoke를 재현하려면 tracked config를 직접 바꿔야 한다. 계획 범위상 기본 config 변경은 피해야 하므로, bin smoke용 임시 config를 주입할 작은 override가 필요하다.

Before (`bin/edge.sh:1`):

```bash
#!/usr/bin/env bash
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
CONFIG_FILE="$REPO_ROOT/configs/edge.yaml"

cd "$REPO_ROOT"
echo "[edge] config=$CONFIG_FILE"
exec go run ./apps/edge/cmd/edge console --config "$CONFIG_FILE"
```

#### 해결 방법

`bin/node.sh`가 `IOP_EDGE_ADDR`를 허용하듯, `bin/edge.sh`도 `IOP_EDGE_CONFIG`가 있으면 그 파일을 사용하고 없으면 기존 config를 유지하게 한다. 이 변경은 기본 실행 동작을 바꾸지 않는다.

After:

```bash
CONFIG_FILE="${IOP_EDGE_CONFIG:-$REPO_ROOT/configs/edge.yaml}"
```

최종 smoke는 `/tmp`에 fake Gemini와 임시 edge config를 만들고, `IOP_EDGE_CONFIG=<tmp>`로 `./bin/edge.sh`, `IOP_EDGE_ADDR=127.0.0.1:19090`로 `./bin/node.sh`를 실행한다.

#### 수정 파일 및 체크리스트

- [ ] `bin/edge.sh` - `IOP_EDGE_CONFIG` override 추가
- [ ] 최종 검증 script - `/tmp` fake Gemini와 `/tmp` edge config로 `target=gemini` `/status` smoke 실행

#### 테스트 작성

별도 unit test는 작성하지 않는다. 변경은 1줄 shell fallback이고, 최종 bin smoke가 override 사용 여부를 검증한다.

#### 중간 검증

```bash
IOP_EDGE_CONFIG=/tmp/non-existent-iop-edge.yaml ./bin/edge.sh
```

기대 결과: 파일이 없어서 `go run ./apps/edge/cmd/edge console --config /tmp/non-existent-iop-edge.yaml` 경로의 config load error가 발생한다. 즉 `bin/edge.sh`가 `IOP_EDGE_CONFIG` 값을 실제로 전달한다. 이 명령은 실패가 기대 결과이므로, 구현 에이전트는 실제 stdout/stderr와 exit status를 `CODE_REVIEW-cloud-G07.md`에 기록한다.

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `apps/node/internal/adapters/cli/status/parser.go` | API-1 |
| `apps/node/internal/adapters/cli/status/parser_test.go` | API-1 |
| `apps/node/internal/adapters/cli/status/gemini.go` | API-2 |
| `apps/node/internal/adapters/cli/status/gemini_test.go` | API-2 |
| `apps/node/internal/adapters/cli/status/status.go` | API-2 |
| `apps/node/internal/adapters/cli/status/status_test.go` | API-2 |
| `apps/edge/cmd/edge/console.go` | API-3 |
| `apps/edge/cmd/edge/console_test.go` | API-3 |
| `bin/edge.sh` | API-4 |

## 최종 검증

```bash
gofmt -l apps/node/internal/adapters/cli/status/parser.go apps/node/internal/adapters/cli/status/parser_test.go apps/node/internal/adapters/cli/status/gemini.go apps/node/internal/adapters/cli/status/gemini_test.go apps/node/internal/adapters/cli/status/status.go apps/node/internal/adapters/cli/status/status_test.go apps/edge/cmd/edge/console.go apps/edge/cmd/edge/console_test.go
```

기대 결과: 출력이 없어야 한다.

```bash
go test ./apps/node/internal/adapters/cli/status/... ./apps/node/internal/adapters/cli/... ./apps/edge/cmd/edge/... -count=1
```

기대 결과: status, cli, edge console tests가 모두 통과한다. 캐시 출력은 허용하지 않는다.

```bash
if rg --sort path -n 'gemini status not implemented|NewGeminiChecker\(\)' apps/node/internal/adapters/cli/status; then exit 1; fi
```

기대 결과: 출력 없이 exit 0이어야 한다. `NewGeminiChecker(command string)` 선언은 이 pattern에 걸리지 않아야 한다.

```bash
tmpdir="$(mktemp -d)"
mkfifo "$tmpdir/edge.in"
cat > "$tmpdir/gemini" <<'SH'
#!/usr/bin/env sh
printf 'Gemini CLI v0.42.0\n'
printf '? for shortcuts\n'
IFS= read -r command
cmd="${command#?}"
if [ "$cmd" != "/stats model" ] && [ "$command" != "/stats model" ]; then
  printf 'unexpected command: %s\n' "$command"
  exit 3
fi
printf 'Auto (Gemini 3) Stats For Nerds\n'
printf '0%% used (Limit resets in 24h)\n'
printf 'Usage limit: 200\n'
printf 'Usage limits span all sessions and reset daily.\n'
IFS= read -r exit_command
exit 0
SH
chmod +x "$tmpdir/gemini"
perl -0pe 's/listen: "0\.0\.0\.0:9090"/listen: "127.0.0.1:19090"/; s/port: 9092/port: 19092/; s/target: "claude"/target: "gemini"/; s#command: "gemini"#command: "'"$tmpdir"'/gemini"#' configs/edge.yaml > "$tmpdir/edge.yaml"
IOP_EDGE_CONFIG="$tmpdir/edge.yaml" ./bin/edge.sh < "$tmpdir/edge.in" > "$tmpdir/edge.out" 2> "$tmpdir/edge.err" &
edge_pid=$!
exec 3> "$tmpdir/edge.in"
IOP_EDGE_ADDR=127.0.0.1:19090 ./bin/node.sh > "$tmpdir/node.out" 2> "$tmpdir/node.err" &
node_pid=$!
sleep 8
printf '/status\n/exit\n' >&3
wait "$edge_pid"
kill "$node_pid" 2>/dev/null || true
sed -n '1,220p' "$tmpdir/edge.out"
sed -n '1,120p' "$tmpdir/edge.err"
sed -n '1,160p' "$tmpdir/node.out"
```

기대 결과: edge output에 `[node-local-node-status] target=gemini session=default`, `Daily quota: 100% remaining (resets 24h)`, `Used: 0%`, `Usage limit: 200`이 포함된다. `node.out`에는 edge reachable 이후 node serve 로그가 있어야 하고, fake Gemini가 `/stats model` 외 명령을 받았다는 `unexpected command`가 없어야 한다. 임시 도구는 `/tmp` 아래에만 남고 repo에는 생성하지 않는다.

모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 전체 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
