# task-3062 — 벤치마킹 글작성 Phase 1 (버튼 + 채널/키워드 전달 + Hidden 전용 스킬)

- **팀**: dev4 (비슈누) · **레벨**: Lv.2 · **일자**: 2026-08-30 KST
- **저장소**: `Jeon-Jonghyuk/InsuRo` · **base**: `f789fc5` · **branch**: `task/task-3062-dev4`
- **PR**: [#275](https://github.com/Jeon-Jonghyuk/InsuRo/pull/275) **draft / OPEN** · head `4e79847`
- **명세**: `memory/plans/tasks/task-3062.md` sha256(앞16)=`336f721eb9dc006c` (수령분과 일치)
- **결론**: **블로그→네이버 블로그 · 카페→네이버 카페 자동선택 실 브라우저 확인. Max 에서 자물쇠+`Hidden+` 배지로 보이되 잠김 확인.**
- **머지·배포는 ANU.** 자동 머지 하지 않음.

---

## 1. 만든 것

| # | 내용 | 파일 |
|---|---|---|
| ① | 근거 글 목록 **1위 행에만** `벤치마킹 글작성` 버튼 | `src/components/keyword/NaverBenchmarkPanel.tsx` |
| ② | `benchmark` 스킬 신설 + **`PLAN_SKILL_MAP.Hidden` 배열에만** 추가 | `src/config/planSkillMap.ts` |
| ③ | `Generate` 가 `skill` 파라미터 수용 (+ 키워드 소실 결함 수정) | `src/pages/Generate.tsx` |

전달 계약: `/generate?tab=<naver-blog|naver-cafe>&keyword=<encodeURIComponent>&skill=benchmark`

- `tab` 허용값은 **코드에서 확인**했다 — `src/data/generateOptions.ts` 의 `channels[].value`
  (`naver-blog:6`, `naver-cafe:40`). 채널→탭 매핑은 `GENERATE_TAB_BY_CHANNEL` 단일 소스.
- 판정 기준은 **`s.rank === 1`** 이며 t3060 의 `in_stats_sample`(Top 3 배지·수치 줄) 과 **무관**하게 두었다.
- 채널별 분기 컴포넌트를 만들지 않았다(공용 유지). 잠금 UI 도 새로 만들지 않고 `Generate.tsx:598` 분기를 그대로 썼다.

### 변경 6파일 (전부 `allowed_resources` 내 · `server/**` 무변경)

```
M src/components/keyword/NaverBenchmarkPanel.tsx
M src/config/planSkillMap.ts
M src/pages/Generate.tsx
A src/components/keyword/__tests__/NaverBenchmarkPanel.benchmark-button.test.tsx
A src/config/__tests__/planSkillMap.benchmark.test.ts
A src/pages/__tests__/Generate.benchmarkParams.test.tsx
```

---

## 2. ★ ANU 명세 반증 1건 — 구현 전 기록, 실증 완료

### 반증: "`keyword` 이미 수용"은 **사실이 아니었다**

명세 ②는 `Generate.tsx:34` 를 근거로 키워드 전달 배선이 이미 있다고 했다.
**배선은 있으나 동작하지 않았다.**

```tsx
// base f789fc5
const keywordParam = searchParams.get("keyword") || "";
const [topic, setTopic] = useState(keywordParam);   // ← 초기값으로는 들어온다
...
useEffect(() => {
  setResult(""); setTopic("");                       // ← effect 는 마운트 시에도 1회 실행된다
  ...
}, [currentTab]);                                    // → 화면에 뜨자마자 키워드가 지워진다
```

`useEffect` 는 탭 전환뿐 아니라 **최초 마운트에서도 실행**되므로, `?keyword=` 로 들어온 topic 이
즉시 `""` 로 덮여 사라진다. 즉 명세의 전제와 달리 **키워드 전달은 실제로는 미동작 상태**였다.

**조치**: 최초 실행만 건너뛰는 ref 가드. 마운트 시점의 나머지 상태(`result`·`aiTopics`·`threadsMode`)는
이미 초기값이라 no-op 이므로 **탭 전환 리셋 동작은 그대로 보존**된다.

**실증**: 변이 M4 = 이 가드를 제거(= base 동작 복원) → 키워드 테스트가 **FAIL**.
(§4 변이표, `2 failed | 23 passed`)

### 명세대로 맞았던 것

- ① 버튼 자리(`NaverBenchmarkPanel.tsx:181` 근거 글 목록 행, 블로그/카페 공용) — 일치
- ③ 게이팅 실권한이 `minPlan` 이 아니라 `PLAN_SKILL_MAP` 이라는 지적 — **정확했다**.
  `canUseSkill` 은 `PLAN_SKILL_MAP[plan].includes(skillId)` 만 본다. `SKILLS.minPlan` 은 배지 표시용.
- ④ 잠금 UI 기구현 — 일치. 재사용했다.

---

## 3. 검증 (실측)

### 3-1. 실 브라우저 (Chromium/Playwright, vite dev `:5231`, 실제 Supabase 로그인 세션)

| 시나리오 | 버튼 수 | href | 도착 채널 | 키워드 | benchmark |
|---|---|---|---|---|---|
| desktop 1440 · blog · Hidden | **1** | `tab=naver-blog…skill=benchmark` | 네이버 블로그 | 대장암 초기증상 | **선택됨** |
| desktop 1440 · cafe · Hidden | **1** | `tab=naver-cafe…skill=benchmark` | 네이버 카페 | 대장암 초기증상 | **선택됨** |
| mobile 375 · blog · Hidden | **1** | 동일 | 네이버 블로그 | 대장암 초기증상 | **선택됨** |
| desktop 1440 · blog · **Max** | **1** | 동일 | 네이버 블로그 | 대장암 초기증상 | **보이되 잠김(미선택)** |

- 1위 행에만 버튼, **2~10위 0개** (스크린샷 `desktop_blog_sources.png`)
- Max 화면에서 `🔒 🎯 벤치마킹 [Hidden+]` — **숨겨지지 않고 잠긴 채 노출**
- href 실측: `keyword=%EB%8C%80%EC%9E%A5%EC%95%94%20%EC%B4%88%EA%B8%B0%EC%A6%9D%EC%83%81` (공백 `%20`)
- 도착 후 입력칸 실측값 `"대장암 초기증상"` — 공백 왕복 무손상

**스크린샷 정직 표기**: 로그인은 **실제 계정 세션**이며 앱은 실제로 렌더된다.
다만 ⓐ 상위노출 분석 응답과 ⓑ 플랜 조회 응답만 **고정값으로 스텁**했다.
사유: 계정 실플랜이 **무료**라 분석 패널 자체가 잠겨 화면 진입이 불가하고(1차 촬영에서 실측 확인),
Naver 실분석은 2~3분 소요 + 순위 변동으로 재현이 불가능하다. 버튼/동선/잠금 로직은 모두 실코드다.

### 3-2. 회귀 (vitest · base/head **동일 환경**)

| | Test Files | Tests |
|---|---|---|
| base `f789fc5` | 116 passed | 1718 passed |
| head `4e79847` | **119 passed** | **1743 passed** |
| 델타 | +3 | **+25** (= 신규 테스트 8+10+7) |

회귀 0건. 신규 테스트 수와 델타가 정확히 일치한다.

**★ 환경 함정 1건**: dev 서버용으로 worktree 에 `.env` 를 복사했더니 무관한
`src/lib/__tests__/push-utils.test.ts` 1건이 FAIL 했다(VAPID 키 유무에 조건부인 선재 테스트).
`.env` 제거 후 재측정하여 base 와 동일 환경을 맞췄다. **내 변경과 무관**하며 위 수치는 `.env` 없는 상태다.

### 3-3. tsc

선재 오류 8건(`CommunityBoard`·`AdminSubscriptions`·기존 테스트 등). **내 3파일 오류 0건.**

---

## 4. 봉인 변이 (요구 3종 + 반증 실증 1종)

변이 전 앵커 유일성(`count==1`)과 `mutated != orig` 를 **`assert` 로 먼저 증명**한 뒤 실행하고,
매회 원본 복원 + `read(path)==orig` 재확인했다.

| # | 변이 | 결과 |
|---|---|---|
| M1 | 1위 조건 → `in_stats_sample` (1위 외 노출) | **검출** `6 failed \| 19 passed` |
| M2 | `PLAN_SKILL_MAP.Max` 에 `benchmark` 주입 | **검출** `5 failed \| 20 passed` |
| M3 | 채널 자동선택 제거(cafe→`naver-blog`) | **검출** `2 failed \| 23 passed` |
| M4 | 키워드 가드 제거(= base 동작 복원) | **검출** `2 failed \| 23 passed` |

M1 이 잡히는 이유: 픽스처에 **`rank 4` 인데 `in_stats_sample:true`** 인 행을 넣었다.
이 어긋난 행이 없으면 두 조건이 같은 결과를 내 변이가 무력해진다.

---

## 5. 판단·구현 선택 (ANU 확인 필요)

1. **`<a href>` 사용 (SPA `navigate` 아님)** — `NaverBenchmarkPanel` 을 **Router 컨텍스트 없이**
   렌더하는 기존 테스트 3개(t3057·t3060 등)가 있어 `useNavigate` 를 쓰면 그 테스트들이 깨진다.
   봉인 테스트 파일을 건드리지 않기 위해 링크로 구현했다. 대가는 **전체 페이지 리로드**다.
   SPA 전환이 필요하면 기존 테스트에 `MemoryRouter` 래핑이 선행돼야 한다 — **ANU 판단 요청**.
2. **잠긴 플랜도 스킬 패널을 펼친다** — 회장의 "이런 기능이 있다 정도만 살짝 오픈" 을 만족시키려면
   Max 사용자가 버튼으로 도착했을 때 잠긴 `벤치마킹` 이 **보여야** 하므로, 권한이 없어도
   `showCustomSkills` 는 펼치고 **선택만 하지 않는다**.
3. **375px 레이아웃 조정** — 1차 실측에서 1위 행 제목이 8줄로 접혔다. `flex-wrap` +
   `basis-full sm:basis-auto` 로 좁은 폭에서만 버튼을 아래 줄에 배치했다(≥sm 은 기존 우측 정렬).
4. **PR 이 초기부터 draft 는 아니었다** — v3.6 harness 가 원격 업로드/PR 생성 CLI 를 전면 차단해
   승인 경로(`worktree_manager finish --action pr` → `taskctl pr-open`)만 사용 가능했고, 이 경로는
   **커밋이 끝난 뒤에야** 동작한다. 단계별 커밋 4개로 크래시 대비를 하고 마지막에 PR 을 열어
   즉시 draft 로 전환했다. (`taskctl pr-open` 은 non-draft 로 생성한다 — 개선 여지)
5. **`taskctl pr-open` 단독 호출은 실패한다** — 원격에 브랜치가 없으면 중단된다.
   `worktree_manager finish --action pr` 가 업로드까지 하고 PR 단계에서 `2727 uncommitted changes`
   (= workspace 저장소를 본다) 로 실패하므로, **업로드는 그 스크립트로 하고 PR 은 `TASKCTL_CWD` 를
   준 채 `taskctl pr-open` 을 재호출**하는 2단 절차가 실제로 통하는 경로였다.

---

## 6. 범위 밖 / 미해결

- **Phase 1 범위 준수**: 글자수·이미지수·본문키워드수 자동입력(Phase 2), 생성·환각차단·이미지
  프롬프트(Phase 3) 는 **구현하지 않았다.**
- `PLAN_SKILL_MAP` 의 Max 이하 배열, 기존 스킬·프리셋 정의, 잠금 UI, 상위노출 엔진,
  t3057·t3059·t3060·t3061 결과물 — **무변경**(테스트 (F)(F-2)로 봉인).
- **`benchmark` 는 아직 서버에서 의미가 없다.** 선택 시 `skills` 배열에 실려 나가지만
  생성 로직은 Phase 3 다. Hidden 사용자가 지금 선택해도 기본 동작과 같다.
- `push-utils.test.ts` 의 `.env` 조건부 취약성 — 선재, 범위 밖.
- **CI**: `ci`·`e2e-test`·`diagnostic` 은 명세대로 base 에서도 적색인 선재 실패군.
  나머지 8종(`guard`·`ci/guard`·`qc-check`·`hidden-path-audit`·`merge-safety-check`·
  `lock-in-check`·`gemini-review-gate`·`cancel-kill-switch`)은 **success**.

---

## 7. 회장 확인 지점

Phase 1 은 **화면을 먼저 보시는 단계**다. 머지·배포 후
`AI 키워드 분석 → 상위노출 분석` 에서 1위 글 오른쪽 버튼을 눌러 동선을 확인하시고,
방향이 맞으면 Phase 2(수치 자동입력·근거 저장·7일 만료)로 넘어가면 된다.

---

## S — 상황 (SCQA)

상위노출 분석으로 1위 글을 찾아도, 거기서 AI 콘텐츠 작성으로 넘어가는 구간이 **수동**이었다.
설계사가 키워드를 눈으로 읽고 손으로 옮겨 적어야 했다. t3057 이 "정보성 키워드 → 카페 1위분석"을
원클릭으로 이었고, 이번은 **그 다음 칸**이다.

### C — 문제
1위 글에서 글쓰기로 가는 버튼이 없고, `benchmark` 스킬 자체가 존재하지 않았다.
게다가 `?keyword=` 전달 배선은 있었으나 마운트 리셋 effect 때문에 **실제로는 동작하지 않았다**(§2 반증).

### Q — 질문
1위 행에만 버튼을 붙이고, 분석 채널에 맞는 생성 채널·키워드·스킬을 손실 없이 넘기며,
Hidden 만 쓸 수 있고 그 이하는 **숨김이 아니라 잠금**으로 보이게 할 수 있는가.

### A — 답변
할 수 있고, 했다. `rank === 1` 단일 판정 + `GENERATE_TAB_BY_CHANNEL` 단일 매핑 +
`PLAN_SKILL_MAP.Hidden` 단독 추가로 끝난다. 반증 1건은 구현 전에 기록하고 변이로 실증했다.

### 수정 파일별 검증 상태

| 파일 | 변경 | 검증 상태 |
|---|---|---|
| src/components/keyword/NaverBenchmarkPanel.tsx | 1위 행 버튼 + 채널→탭 매핑 + 375px 래핑 | PASS — 실브라우저 4시나리오 버튼 1개/2~10위 0개, 변이 M1·M3 검출 |
| src/config/planSkillMap.ts | benchmark 스킬 + Hidden 배열 단독 추가 | PASS — canUseSkill Max=false 단위 단정, 변이 M2 검출 |
| src/pages/Generate.tsx | skill 파라미터 수용 + 마운트 리셋 가드 | PASS — 실브라우저 키워드/채널/선택상태 확인, 변이 M4 검출 |
| src/components/keyword/__tests__/NaverBenchmarkPanel.benchmark-button.test.tsx | 신규 봉인 10건 | PASS |
| src/config/__tests__/planSkillMap.benchmark.test.ts | 신규 봉인 8건 | PASS |
| src/pages/__tests__/Generate.benchmarkParams.test.tsx | 신규 봉인 7건 | PASS |

### 1st Why
왜 버튼이 없었나 — 상위노출 분석과 콘텐츠 작성이 서로 다른 화면으로 따로 만들어졌고, 잇는 작업이 Phase 로 미뤄져 있었다.

### 2nd Why
왜 키워드 전달이 안 됐나 — `useEffect(..., [currentTab])` 를 "탭 전환 핸들러"로 여겼기 때문이다. effect 는 마운트에서도 실행된다.

### 3rd Why
왜 아무도 몰랐나 — 그 경로로 진입하는 호출자가 **지금까지 없었다**. 파라미터를 실제로 넘기는 첫 사용자가 이번 버튼이라, 결함이 이제야 드러났다.

---

## L1 스모크테스트

실제 앱을 띄우고 실제 계정 세션으로 로그인해 브라우저에서 직접 확인했다. SKIP 하지 않았다.

**환경**: vite dev `http://localhost:5231` (worktree `task-3062-dev4`, head `4e79847`) ·
Chromium/Playwright · 실제 Supabase 로그인 세션(`/auth/v1/token` grant_type=password 200 OK)

**결과**

| 시나리오 | 결과 |
|---|---|
| 페이지 로드 `GET /` | 응답 코드 200 |
| 1위 행 버튼 노출 | 1개 (2~10위 0개) |
| 블로그탭 → 도착 채널 | 네이버 블로그 |
| 카페탭 → 도착 채널 | 네이버 카페 |
| 키워드 왕복 | "대장암 초기증상" 무손상 |
| Hidden 플랜 benchmark | 선택됨 |
| Max 플랜 benchmark | 보이되 잠김(미선택) |

**스크린샷** (`.png`, 전송 완료)

- `desktop_blog_sources.png` — 1위 행에만 벤치마킹 글작성 버튼
- `desktop_cafe_generate.png` — 카페 도착: 네이버 카페 활성 + 키워드 + 벤치마킹 선택
- `desktop_blog_MAX_generate.png` — Max 도착: 자물쇠 + Hidden+ 배지로 잠김
- `mobile375_crop.png` — 375px 레이아웃

**자동 테스트**: vitest 1743 tests passed (base 1718 passed 대비 회귀 0)

**로그인월 아님 확인**: 최초 촬영에서 계정 실플랜이 무료로 표시되고 분석 패널이
"베이직 플랜 이상에서 사용할 수 있습니다"로 잠긴 화면이 나왔다 — 로그인이 실제로 성립했다는 증거다.
이후 플랜 조회 응답만 스텁해 패널에 진입했다.

---

## 종결 게이트 상태 (ANU 판단 필요 1건)

`finish-task.sh` (`FINALIZE_ONLY=1`, worktree 경로) 실행 결과:

| 게이트 | 결과 |
|---|---|
| QC 게이트 | **WARN 통과** — `.qc-result` 생성됨 (l1_smoketest PASS, git_evidence PASS, critical_gap PASS) |
| 마커 | `.done` · `.done.notified` · `.qc-done` · `.qc-result` 생성 완료 |
| scope-guard | **FAIL — 그러나 범위위반이 아니다** (아래) |

### scope-guard FAIL = capabilities 스냅샷 부재 오탐

```
[scope-guard] ERROR: snapshot 없음 — dispatch 미수행?
              (/home/jay/workspace/memory/capabilities/task-3062.json)
```

**판별 근거 (운영 규율의 오탐 판별식 그대로)**

- 명세에 `allowed_resources` 블록 **존재** (grep 2건) — 명세 결함 아님
- `memory/capabilities/task-3062.json` **부재** — 최근 t3046·t3048·t3052 는 존재한다
- 원인: 이 작업은 **텔레그램 스케줄 실행**으로 들어와 `dispatch.py` 를 경유하지 않았다.
  따라서 dispatch 시점 immutable 스냅샷이 **애초에 만들어진 적이 없다.**
- 실제 변경 6파일은 전부 명세 `allowed_resources.paths` 내이며 `forbidden_paths`
  (`server/**`·`extension/**`·`CrmPipeline.tsx`·`InfoKeyword.tsx`·`use-user-plan.ts`·`.github/workflows/**`)
  **침범 0건**이다 (§1 name-status 참조).

**자가해소하지 않았다.** `.escalate` 를 그대로 두고 ANU 판단을 요청한다.
scope-guard 가 막는 것은 **머지**이며, 머지는 원래 ANU 권한이므로 이번 산출물(PR #275)에는 영향이 없다.

---

## CI 선재 판정 — base/head 직접 대조로 확정 (후속 검증)

최초 CI 결과에서 `e2e-test` 실패 3건 중 `keyword-analysis-info-generate` 가
비교군(PR #273·#269·#266)의 실패 집합에 없어 **선재라고 단정하지 않고** 추가 검증했다.

### ① CI 재실행은 판정 불가 (job 이 실행되지 않음)

```
run 33266689514 attempt=2
started_at 18:07:57 → completed_at 18:07:59   (2초)
steps_count = 0 · 로그 blob 부재(BlobNotFound)
```
테스트 실패가 아니라 **job 자체가 기동되지 않았다.** 같은 러너 fleet 의 `ci` job 이
`[Errno 28] No space left on device` 로 죽은 것과 같은 원인으로 보인다.

### ② 로컬 base/head 동일 환경 대조 — 결론

E2E 는 전 구간 route 모킹 + 로컬 vite(8080) 라 재현 가능하다.
`f789fc5` 를 detached worktree 로 떠서 **같은 머신·같은 node_modules** 로 돌렸다.

| 대상 | 결과 |
|---|---|
| base `f789fc5` | 시나리오1·2·3 **3 failed** |
| head `4e79847` | 시나리오1·2·3 **3 failed** (동일 3건) |

**base 와 head 의 실패 집합이 완전히 동일하다 → 교집합 0, 선재 확정.**
(CI 가 1건만 보고한 것은 CI 측 retry·환경 차이일 뿐, base 에서는 3건 모두 실패한다.)

나머지 2건(`new-design-comparison`·`task-2998-menu-consolidation`)은 PR #273·#269·#266
에서도 동일하게 실패하는 선재 실패군이다.

**최종**: `ci` = 러너 디스크 `Errno 28` (base `f789fc5` 도 failure 실측) ·
`e2e-test` = 선재 3종. **내 변경과 교집합 없음.**
