코칭 엔진 편지 품질 개선 — 개발 원자단위 WBS (빌드 플랜)

🗄️ ARCHIVED — 빌드 플랜 종료 (2026-06-21)
87 원자 중 64 완료 + 20 해소. 미완료로 보이던 20개는 대부분 더 나은 방식으로 달성·대체됨 — plan_detail jsonb(D-04 reco_balance 대체)·foodGraph/comboMatrix 일원화(G-02)·OPEN_MATERIALS daySeed 회전(H-02)·arin-replay + 랄프위검 워크플로 + vitest 537 회귀(J-01·J-08·J-09·I-10). 이번 세션 마무리: K-10·K-09·D-07(5a5a258) + 카테고리 정합·부모 질문 최우선·추천-무시 배제·F-18b·동기부여 코칭.
이월(별도 세션) — 핑퐁(rank1): F-10·I-07·I-08 = table↔exposure 오실레이션. 주간 focus 선택(applyFocusFatigue/candidateUnits) 재설계가 필요하고 커리큘럼 상태기계 전반에 영향(회귀위험)이라 신중한 격리 작업으로 분리. 진척 가시화·정체 동기부여는 핑퐁 해소 후 비로소 안정 작동.
이 문서는 종료·아카이브되었습니다(아래 원자 명세는 이력 보존용). 코칭 엔진 현행 상태 = 모듈 명세 8종 + 엔진 상태 문서 + 통합 지도.

2026-06-18 · 근거 = 적대감사 26 근본원인(라이브 코드 정독 + 아린 weekly_plans·편지 DB 실측) + 이사님 지적(target_pool 빈약·커리큘럼 진척 미배선). 진단 문서 = 통합 아키텍처 지도 외 모듈 명세 8종의 '🔧 증상 개선' 섹션. 본 문서는 그 수정안을 구현 가능한 원자 단위로 분해한 작업 명세서다. 각 원자는 {목적·요구·구현명세·유즈케이스·테스트·DoD·의존·규모·우선순위}를 갖고, 원자 ID로 커밋·테스트를 추적한다.

🚀 새 세션 시작 가이드 — 이 문서만 보고 개발 착수
전체 규모 요약 — EPIC 11개(+가드감사 K) · 원자 107개 · 테스트 382+개 · P0(즉시) 47개 · 마일스톤 D1~D4.
🧭 핵심 설계 — 최상위 단일 근본원인 봉합

일간 두뇌(route.ts:628-630)가 brainPick.scenarioId만 있으면 planFor(forceScenarioId)로 주간 닻(타깃·lever·push·teaching arc)을 통째로 덮어쓰던 파이프라인 역전이 5개 증상(치킨 반복·잔소리 연속·앵무새·괴식·커리큘럼 소멸)의 공통 상류다. EPIC A(뇌↔주간 닻 계약)가 P0 핵심 — 뇌를 주간 닻의 하위 결정으로 계약화하면 B~J가 그 위에 쌓인다. 영양평가 누락은 '뇌 문제'가 아니라 배선 누락(EPIC E)으로 확정.

📌 개발 지시 프로토콜 — 이 문서가 작업 지시의 단일 진실
📊 진척 대시보드 (원자 완료 시 '완료' 카운트+상태칩 갱신 후 커밋·푸시 — 작성 2026-06-18 · 전 원자 ⬜ 대기) __F__ine)
EPIC원자완료테스트P0마일스톤상태
A 뇌↔주간 닻 계약 (Decision Subordination)1010/10414D1✅ 완료 1237f36(vitest 398)
B 타깃 결핍군 필터 단일점 (refExposable Unification)80/8245D1⬜ 대기
C 음식 잔소리 케이던스 캡 (Food Nag Cadence)90/9385D1⬜ 대기
D target_pool 두 트랙 확장 (Supply/Challenge Portfolio)80/8354D2⬜ 대기
E 영양평가 편지 배선 (Nutrition→Letter Wiring)108/10405D2✅ 8/10 08806ff(E-07·10 폴리시 대기)
100/10336D2⬜ 대기
G 괴식·동일군 추천 게이트 (Combo Gate Live)80/8395D3⬜ 대기
H 앵무새 해체 (Anti-Parrot Variation)100/10375D3⬜ 대기
I 주간계획 위생 + 아이현황 스토어 (Weekly Hygiene & State Store)100/10401D3⬜ 대기
J 검증·하네스·컷오버 (Verify Harness & Cutover)120/12557D4⬜ 대기
K 가드 정합·과잉억제 정리 (적대감사)127/121감사🔨 K-01·02·03·04·05·06·11 ✅ · 나머지 5
합계10725/10738248D1~D4🔨 진행 중 — EPIC A ✅ · K 가드정합 6/12 ✅ · 다음 EPIC E(영양배선)·F(커리큘럼)
🔗 EPIC 바로가기 A · B · C · D · E · F · G · H · I · J · K

EPIC A — 뇌↔주간 닻 계약 (Decision Subordination) D1

일간 두뇌가 주간 닻(lever·mission_target·target_pool·push 캡·teaching arc)을 통째로 폐기하던 단일 최상위 근본원인을 봉합한다. route.ts:628-630의 무조건 `precomputed = planFor({forceScenarioId})` override를 '닻 종속 게이트'로 교체: 닻 lever를 두뇌 스코프로 끌어올려(weekCtx.lever) 비-food 주에는 food 시나리오 override를 금지하고, food 주이거나 닻 시나리오 일치이거나 SAFE_INTERRUPT이거나 주당 FOOD_OVERRIDE_CAP 이내일 때만 허용하며, 허용 시 plan·weeklyArc·타깃풀을 닻 안에서 일관 전환(planFromWeekly에 forceScenarioId 경로 추가)한다. 또한 두뇌가 트리거 미충족 시나리오를 강제하지 못하게 게이트하고, 연속 food날 캡과 buildBrainContext에 닻/useFood 이력을 주입해 프롬프트가 아닌 결정론으로 다양성을 보증한다.

A-01 닻 lever를 두뇌 블록 스코프로 노출 (weekCtx.lever)코드규모 S

목적 최상위 근본원인의 선행조건 — 두뇌 override 게이트가 닻 lever를 알아야 비-food 주를 식별할 수 있는데, 현재 lever/anchor는 if(anchor && anchor.mission_target) 블록(route.ts:573-575) 내부 const라 두뇌 블록(615-632)에서 접근 불가.
요구 두뇌 블록이 anchorLever·anchor.ledger·target_pool을 읽을 수 있어야 한다. 닻이 없거나 mission_target이 없으면 'food'로 폴백(안전 제1원칙·기존 동작 보존).
구현 app/api/cron/coach/route.ts: (1) weekCtx 타입(423행)에 lever: string·mission_target: string|null·targetPool: string[]·ledger: WeeklyLedger|null 필드 추가. (2) 607행 weekCtx = { weekKey, fromWeekly: true, impression, pushApplied, arc }에 lever, mission_target: tgt, targetPool: anchor.target_pool || [], ledger: newLedger를 함께 적재(608행에서 만든 newLedger를 weekCtx에도 실음). (3) 440행 캐시 복원 경로(weekCtx = pctx?.weekly)는 과거 ctx 타입이라 신규 필드 optional 처리(?? 폴백). 두뇌 블록(615~)에서 const anchorLever = weekCtx?.lever || 'food' 정의.
파일 app/api/cron/coach/route.ts
유즈케이스 아린 W25 닻(lever=environment, mission_target=콩류) → 두뇌 블록에서 anchorLever='environment'·targetPool=[콩류,...]를 읽어 6/17 치킨 override를 차단할 근거 확보.
테스트
  • weekCtx.lever가 닻 budget.lever로 채워진다(food/environment)
  • 닻 없으면 weekCtx=null → anchorLever 폴백 food
  • 캐시 복원 시 신규 필드 미존재여도 폴백 동작
DoD 두뇌 블록 진입 시점에 anchorLever·weekCtx.targetPool·weekCtx.ledger가 닻 값으로 읽힌다(닻 environment 자녀 시뮬에서 anchorLever==='environment' 단언). tsc 그린.
의존: 없음 · 규모 S · 우선순위 P0
⚠ weekCtx 타입 확장이 어드민 context 직렬화(713행 인근)에 영향 — 신규 필드는 직렬화돼도 무해(읽기 전용 가시화).

A-02 LEVER_SCENARIO export + 레버↔시나리오 호환 판정 헬퍼코드규모 S

목적 override 게이트가 '두뇌 시나리오가 닻 lever와 호환인가'를 판정하려면 비-food 레버별 전용 프레임 맵이 필요한데 현재 coachWeekly.ts:55 LEVER_SCENARIO가 const(미export)라 route에서 못 씀(중복 정의 시 동기화 깨짐).
요구 LEVER_SCENARIO를 단일 진실로 export하고, route가 import해 leverScenario(anchorLever)를 얻을 수 있어야 한다. SAFE_INTERRUPTS(닻 무관 항상 허용 시나리오) 집합도 단일 소스로 정의.
구현 lib/coachWeekly.ts:55 const LEVER_SCENARIO → export const LEVER_SCENARIO. planFromWeekly의 안전 인터럽트 목록(coachWeekly.ts:298 progress-celebrate·neophobia-arfid-watch·low-data-gap)을 export const SAFE_INTERRUPT_SCENARIOS = new Set([...])로 추출하고 298행을 이 Set 참조로 교체(중복 제거). route.ts:26 import 구문에 LEVER_SCENARIO, SAFE_INTERRUPT_SCENARIOS 추가.
파일 lib/coachWeekly.ts · app/api/cron/coach/route.ts
유즈케이스 anchorLever='environment' → leverScenario='mealtime-atmosphere'. 두뇌가 mealtime-atmosphere를 고르면 호환(허용), re-exposure-timing을 고르면 비호환(food 시나리오 → 게이트 적용).
테스트
  • LEVER_SCENARIO[environment]==='mealtime-atmosphere'
  • SAFE_INTERRUPT_SCENARIOS = planFromWeekly 인터럽트 목록과 동일(단일 소스 정합)
  • 추출 후 planFromWeekly 인터럽트 동작 byte 무변경
DoD route에서 import { LEVER_SCENARIO, SAFE_INTERRUPT_SCENARIOS }가 컴파일되고, 인터럽트 회귀 테스트(coach-plan 기존)가 그린.
의존: 없음 · 규모 S · 우선순위 P0
⚠ 인터럽트 목록 추출 시 누락하면 planFromWeekly 안전 인터럽트가 깨짐 — 추출 후 동일성 테스트 필수.

A-03 planFromWeekly에 forceScenarioId 인자 추가 (닻 안에서 시나리오만 교체)코드규모 M

목적 override 허용 시 plan을 닻 밖 planFor(forceScenarioId)로 새로 뽑으면 채근 캡·행동지연·타깃 잠금이 무력화됨(coach.ts:445-451은 닻 무지). 닻 경로 안에서 시나리오만 바꾼 plan을 산출해야 잠금이 보존된다.
요구 planFromWeekly가 선택적 forceScenarioId를 받아, food 레버 경로(coachWeekly.ts:336-357)의 frame을 두뇌 시나리오로 교체하되 타깃 잠금(pool[0])·push 캡·행동지연·weeklyArc는 그대로 적용한 plan을 반환. forceScenarioId 미전달 시 기존 동작 byte 무변경.
구현 lib/coachWeekly.ts:284 planFromWeekly 시그니처에 forceScenarioId?: string | null 추가. food 레버 경로(336행 frame 결정 직후)에서 forceScenarioId가 있고 SCENARIOS에 존재하면 frame = forceScenarioId로 덮되, 타깃(334행 pool[0])·budget·ledger·push 게이트(342-356)는 그대로 통과. 비-food 레버 경로(316-325)에는 forceScenarioId 미적용(레버 프레임 잠금 유지 — override는 route 게이트가 차단). 반환 weeklyArc는 A-05에서 일관 처리.
파일 lib/coachWeekly.ts
유즈케이스 아린 food 주에 두뇌가 nutrient-gap을 고르면, planFromWeekly가 콩류 타깃·push 캡을 유지한 채 frame만 nutrient-gap으로 산출 → '잠금 보존 + 시나리오 교체' 동시 달성.
테스트
  • forceScenarioId 미전달 = 기존 plan byte 동일(대조군)
  • food 레버 + forceScenarioId=re-exposure-timing → frame 교체되되 target=닻 mission_target 유지
  • push 캡: pushUsed=true면 forceScenarioId여도 push 무브 강등(채근 캡 보존)
  • 행동지연: targetExposeWtd=0이면 push 안 됨(forceScenarioId 무관)
DoD 닻 mission_target=콩류·forceScenarioId=nutrient-gap 호출 시 plan.target==='콩류'(닻 잠금 유지)·push 캡 적용. 미전달 시 기존 테스트 전부 그린.
의존: A-02 · 규모 M · 우선순위 P0
⚠ 비-food 경로에 forceScenarioId를 잘못 흘리면 레버 잠금이 깨짐 — food 경로에만 적용하도록 분기 명확히.

A-04 route.ts 628-630 무조건 override → 닻 종속 게이트로 교체 (핵심)코드규모 L

목적 최상위 단일 근본원인 봉합 — brainPick.scenarioId만 있으면 닻 무지 planFor로 통째 재할당하던 629행을, 닻 lever 종속 + FOOD_OVERRIDE_CAP 게이트로 교체. 비-food 주의 매일 음식 잔소리(6/15콩·16과일·17치킨)와 타깃 잠금 무력화의 근원.
요구 override 허용 조건 = (anchorLever==='food') OR (brain 시나리오===leverScenario) OR (SAFE_INTERRUPT) OR (foodOverrideUsedThisWeek < FOOD_OVERRIDE_CAP). 허용 시 닻이 살아있으면 planFromWeekly(forceScenarioId)로, 닻 없으면 기존 planFor(forceScenarioId)로 산출. 불허 시 두뇌 시나리오 무시하고 직전 precomputed(=닻 레버 프레임/폴백) 유지. food override 1회 소진 시 ledger에 카운트 적재.
구현 app/api/cron/coach/route.ts:628-630 교체. const anchorLever = weekCtx?.lever || 'food'; const leverScenario = LEVER_SCENARIO[anchorLever]; const sid = brainPick.scenarioId; const isLeverCompat = anchorLever==='food' || sid===leverScenario || SAFE_INTERRUPT_SCENARIOS.has(sid); const fov = (weekCtx?.ledger?.foodOverrideUsed) ?? 0; const allow = isLeverCompat || fov < FOOD_OVERRIDE_CAP; allow일 때: weekCtx?.fromWeekly이고 닻 anchor가 있으면 planFromWeekly(forceScenarioId=sid)로 precomputed/weekCtx.arc 재산출(A-03·A-05), 아니면 기존 planFor(forceScenarioId=sid). food override 소진(anchorLever!=='food' && !isLeverCompat)이면 weekly_plans.ledger.foodOverrideUsed+1 update(608행 update 경로 재사용). FOOD_OVERRIDE_CAP 상수=2(주당). 닻 anchor를 두뇌 블록에서 다시 loadAnchor하거나 A-01에서 상위 let으로 노출.
파일 app/api/cron/coach/route.ts · lib/coachWeekly.ts
유즈케이스 6/17 닻=콩류/environment, 두뇌가 re-exposure-timing(치킨) 선택 → isLeverCompat=false, fov가 이미 2면 override 차단 → 편지가 환경(mealtime-atmosphere) 프레임 유지. cap 이내면 콩류 타깃 잠금된 nutrient-gap만 허용(치킨 아님).
테스트
  • 비-food 닻 + 두뇌 food 시나리오 + cap 소진 → override 차단(precomputed=레버 프레임 유지)
  • 비-food 닻 + cap 미소진 → override 허용 + foodOverrideUsed+1
  • food 닻 → 항상 override 허용(기존 다양성 보존)
  • 두뇌 시나리오===leverScenario → cap 소비 없이 허용
  • SAFE_INTERRUPT(progress-celebrate) → 닻 무관 항상 허용
  • 닻 없음(폴백) → 기존 planFor(forceScenarioId) 경로 byte 동일
DoD 아린 6/15~17 시뮬 재현: 비-food 주 3일 연속 food 시나리오 발행이 0건(cap=2 초과분 차단). foodOverrideUsed가 weekly_plans.ledger에 누적. tsc·기존 vitest 그린.
의존: A-01, A-02, A-03, A-05 · 규모 L · 우선순위 P0
⚠ 닻 update 충돌(608행 ledger update와 foodOverrideUsed update 중복) — 단일 ledger 객체에 병합 후 1회 update로. weekly_coaching 컬럼 미적용 환경 silent reject(route.ts:545 사례) → update 결과 검사.

A-05 override 시 weeklyArc 일관 전환 (food override 날 환경 arc 충돌 제거)코드규모 S

목적 food override를 허용한 날, weekCtx.arc는 여전히 환경 teaching arc라 한 편지에 'food 추천 + 환경 코칭'이 동시 주입돼 '한 번에 하나' 위반(앵무새·잡탕 편지). override 시 plan뿐 아니라 arc도 함께 전환해야 함.
요구 비-food 닻에서 food override가 발동한 날은 weekCtx.arc를 null로 비우거나(그날만), 닻 레버와 호환 시나리오로 override한 날은 arc 유지. base.weeklyArc(681행)가 충돌 없는 단일 신호만 받게.
구현 app/api/cron/coach/route.ts A-04 게이트 내부: food override 발동 분기(anchorLever!=='food' && sid!==leverScenario && !SAFE_INTERRUPT)에서 weekCtx = { ...weekCtx, arc: null }로 그날만 arc 제거. A-03에서 planFromWeekly(forceScenarioId)가 반환하는 weeklyArc도 food 시나리오면 arc 톤이 환경 behaviorGoal과 어긋나므로, food override일 때는 wk.weeklyArc 대신 null 채택. 681행 base.weeklyArc는 weekCtx?.arc ?? null 그대로(이미 null 안전).
파일 app/api/cron/coach/route.ts
유즈케이스 아린 environment 주에 cap 이내 food 날을 끼울 때, 그날 편지는 '콩류 추천'만 담고 환경 arc('식탁에 앉히기' 톤레이어)는 빠져 잡탕 방지.
테스트
  • food override 날 → base.weeklyArc===null(환경 arc 미주입)
  • 레버 호환 override(mealtime-atmosphere 유지) → arc 보존
  • food 닻 → arc 영향 없음
DoD 비-food 닻 자녀의 food override 편지에서 환경 teaching arc 구절이 0개(arc=null 단언). 호환 override는 arc 유지.
의존: A-04 · 규모 S · 우선순위 P1
⚠ arc를 과도하게 null 처리하면 정상 환경 주의 teaching arc까지 사라질 수 있음 — food override 분기에서만 적용.

A-06 두뇌 트리거 미충족 시나리오 강제 차단 (safeTrigger 게이트)코드규모 S

목적 pickActionByBrain은 scenarioId가 SCEN_IDS에 속하기만 하면 반환(coachBrain.ts:109, 트리거 미검)하고 force 경로도 트리거 재검 없음 → daycareRefused=[]인데 re-exposure-timing(기관 거부 전용) 강제 가능(6/17 실측: home 거부를 '기관 재노출' 각도로 오풀이).
요구 override 적용 전 두뇌 시나리오의 trigger(signals)가 충족되는지 검사. 미충족이면 두뇌 시나리오 무시하고 결정론 precomputed 유지. plateau(trigger:()=>true)·SAFE_INTERRUPT는 항상 통과.
구현 app/api/cron/coach/route.ts A-04 게이트의 allow 조건에 && safeTrigger(sid, signals) 추가. safeTrigger = const sc = SCENARIOS.find(s=>s.id===sid); if(!sc) return false; try { return sc.trigger(signals); } catch { return false; }. SCENARIOS는 coachScenarios에서 import(이미 A-06의 safeTrigger용 import 필요). 미충족이면 override 자체를 건너뛰고 line 492/606의 precomputed 유지(트리거 충족 결정론 시나리오).
파일 app/api/cron/coach/route.ts
유즈케이스 아린 6/17: daycareRefused=[]인데 두뇌가 re-exposure-timing 선택 → safeTrigger=false → 차단 → 결정론 콩류 nutrient-gap 유지.
테스트
  • daycareRefused=[]에서 두뇌가 re-exposure-timing 강제 → 트리거 미충족 → override 차단
  • trigger 충족 시나리오는 통과
  • plateau(항상 trigger) override는 허용
  • trigger throw 시 안전하게 차단(폴백)
DoD daycareRefused 빈 자녀 시뮬에서 re-exposure-timing 발행 0건. 트리거 충족 시나리오는 정상 override.
의존: A-04 · 규모 S · 우선순위 P1
⚠ 트리거가 raw signals 기준이라 A-08(거부 결핍필터) 적용 후엔 충족 양상 변동 — A-08과 정합 확인.

A-07 연속 food날 캡 + buildBrainContext에 닻/useFood 이력 주입코드규모 M

목적 두뇌 useFood가 결핍 상존하는 한 매일 true 가능(coachBrain.ts:107 캡 없음)·연속 food날 캡 부재로 6/15콩·16과일·17치킨 3연속. 프롬프트 '영영 미루지 마라' 압력만 있고 결정론 캡이 없음. 또한 두뇌가 닻 lever/최근 useFood를 보지 못해 비-food 주에 food 시나리오를 고름.
요구 (1) 최근 N일 ctx.brain.useFood 이력을 적재하고 두뇌 호출 후 '직전 2일 모두 useFood===true면 brainPick.useFood=false' 강등(SNACK_COOLDOWN 동형). (2) buildBrainContext 입력에 anchorLever·targetPool·최근 useFood 시퀀스를 추가해 두뇌가 비-food 주·연일 food를 함께 판단.
구현 app/api/cron/coach/route.ts: (1) 상단 이력 적재부(162·175행 인근 recentSnackDates 패턴)에 recentBrainUseFood[cid] = 최근 letters의 ctx.brain?.useFood 적재(ctx는 713행에서 저장된 brain). 두뇌 블록 brainPick 산출 직후 if ((recentBrainUseFood[cid]||[]).slice(0,2).filter(Boolean).length>=2) brainPick.useFood=false;. (2) lib/coachBrain.ts buildBrainContext(48-98) 파라미터에 anchorLever?: string; anchorTargetPool?: string[]; recentUseFood?: boolean[] 추가, 본문에 '[주간 닻] 이번 주 주력=anchorLever(비-food면 음식 시나리오 자제)'·'[최근 음식날] recentUseFood'블록 삽입. route.ts:619 buildBrainContext 호출에 weekCtx?.lever·weekCtx?.targetPool·recentBrainUseFood 전달.
파일 app/api/cron/coach/route.ts · lib/coachBrain.ts
유즈케이스 아린 6/15(콩 useFood)·6/16(과일 useFood) 후 6/17 → 직전 2일 모두 true → useFood 강제 false → 환경 날로 전환(치킨 잔소리 차단).
테스트
  • 직전 2일 useFood=true → 오늘 brainPick.useFood 강제 false
  • 직전 1일만 true → 강등 안 함
  • buildBrainContext에 anchorLever=environment 블록이 포함된다
  • recentUseFood 시퀀스가 컨텍스트에 렌더된다
DoD 3일 연속 food 시뮬에서 3일째 useFood가 결정론으로 false. buildBrainContext 출력에 닻 lever·useFood 이력 문자열 존재. 기존 두뇌 테스트 그린.
의존: A-01 · 규모 M · 우선순위 P1
⚠ useFood=false 강등이 bridgeFacts를 끄지만(662행) mirrorBlock은 안 끔 — 영양거울 누락과 무관(별도 EPIC). recentBrainUseFood 적재가 ctx.brain 구버전(null) 안전 처리 필요.

A-08 일간 거부 타깃 결핍군 필터 (refExposable 동형) — targetPoolForScenario 게이트코드규모 M

목적 refExposable(주식제외+결핍군 거부만)이 weekly(route.ts:523)에만 적용되고 daily signals(485행)·planFor·두뇌 force 경로엔 미적용 → 치킨(미매핑·비결핍·catOf=undefined)이 일간 re-exposure 타깃으로 누수. override 게이트(A-04)를 통과한 food 시나리오도 결국 비결핍 타깃을 뽑으면 무의미.
요구 targetPoolForScenario(coach.ts:386-390)의 거부 풀(new-refusal/re-exposure-timing)이 '주식제외 + 결핍군 소속 거부'만 남기도록 단일 수정점에 게이트. 폴백·두뇌 두 경로를 한 번에 막음. signals.refused 원본은 유지(자율성 다툼 트리거·거부 보고용), 타깃 선정만 정제.
구현 단일수정점 권장: route.ts에서 signals를 만들 때(485행) homeRefused/daycareRefused/refused와 별도로 signals.refusedExposable 신설 — sanitizeRefusals 후 !staple && catOf(r)이 결핍군(homeFg.missing∪fg.missing 매핑) 소속만 필터(354-358 refExposable 로직 재사용·함수 추출 const exposable). targetPoolForScenario(coach.ts:388)가 new-refusal/re-exposure-timing일 때 s.refusedExposable(있으면)을 우선 참조. ⚠️근본원인 #15 보정: catOf는 카테고리('고기')를 반환하고 결핍군은 식품군('고기·계란')이라 CATEGORY_GROUP 매핑 1회 거쳐 비교(과교정 방지). signals.refused 원본은 트리거·deficit set(coachWeekly.ts:328)에 그대로 유지.
파일 app/api/cron/coach/route.ts · lib/coach.ts · lib/coachScenarios.ts
유즈케이스 아린: 치킨(home 거부·미매핑)이 refusedExposable에서 빠져 targetPoolForScenario('re-exposure-timing')=[]→ 치킨 타깃 불가. 두뇌가 re-exposure 골라도(A-06 트리거 게이트와 함께) 타깃이 결핍군으로만 한정.
테스트
  • 치킨(비결핍·catOf 미매핑) → refusedExposable에서 제외 → 일간 re-exposure 타깃 안 됨
  • 콩류(결핍군) 거부 → refusedExposable 통과
  • signals.refused 원본 유지(자율성 다툼 trigger s.refused.length>=2 정상)
  • catOf→CATEGORY_GROUP 매핑으로 진짜 결핍군 생선 거부는 차단 안 됨(과교정 방지)
DoD 치킨이 거부로 기록돼도 일간 타깃 풀에서 0건. 진짜 결핍군 거부는 재노출 타깃으로 정상 등판. 자율성 다툼 트리거 회귀 없음.
의존: 없음 · 규모 M · 우선순위 P1
⚠ catOf/식품군 네임스페이스 버그(근본원인 #15) 미보정 시 진짜 결핍 재노출까지 영구 차단(과교정) — CATEGORY_GROUP 매핑 필수. CoachSignals 타입에 refusedExposable 추가 필요.

A-09 WeeklyLedger에 foodOverrideUsed 필드 + 주경계 리셋코드규모 S

목적 FOOD_OVERRIDE_CAP(A-04)이 '주당' 캡으로 동작하려면 비-food 주에 소진한 food override 횟수를 ledger에 영속하고 주 시작 시 0으로 리셋해야 한다. ledger는 이미 pushUsed·targetAccepts·stallWeeks 등 주간 상태 원장.
요구 WeeklyLedger 타입에 foodOverrideUsed?: number 추가. 닻 생성 시 DEFAULT_LEDGER에 0, A-04 override 소진 시 +1 적재. 새 주 닻 synth(route.ts:540) 시 자동 0(DEFAULT_LEDGER). 구버전 ledger(필드 없음)는 ?? 0 폴백.
구현 lib/coachWeekly.ts:36 WeeklyLedger 타입에 foodOverrideUsed?: number 추가. DEFAULT_LEDGER(56행)에 foodOverrideUsed: 0 추가. route.ts:608 newLedger 병합에 foodOverrideUsed 보존, A-04 소진 시 foodOverrideUsed: (anchor.ledger?.foodOverrideUsed ?? 0) + 1로 update(608행 update 경로와 단일 객체 병합). 주 첫 synth(540행 ledger: { ...DEFAULT_LEDGER, stallWeeks })는 이미 foodOverrideUsed=0 포함.
파일 lib/coachWeekly.ts · app/api/cron/coach/route.ts
유즈케이스 아린 W25(environment): 월화 food override 2회 소진 → foodOverrideUsed=2 → 수목금 food 시나리오 차단(환경 코칭 복귀). 다음 주 W26 닻에서 0 리셋.
테스트
  • DEFAULT_LEDGER.foodOverrideUsed===0
  • 구버전 ledger(필드 없음) → ?? 0 폴백
  • 새 주 synth 시 foodOverrideUsed 리셋(0)
  • override 소진 후 ledger.foodOverrideUsed===1
DoD 비-food 주에 food override 2회 후 3회째 차단(A-04 cap). 일요일 새 주 닻에서 카운트 0 리셋. 구닻 ledger 호환.
의존: A-04 · 규모 S · 우선순위 P1
⚠ ledger update가 weekly_coaching 컬럼 미적용 환경에서 silent reject 가능(route.ts:545 사례) — update 결과 검사·issues 적재 재사용.

A-10 EPIC A 통합 회귀 테스트 (닻 종속 계약 골든)테스트규모 M

목적 최상위 근본원인 봉합이 회귀로 되살아나지 않게 박제 — 비-food 주 매일 음식 잔소리·타깃 잠금 무력화·트리거 미충족 강제가 다시 발생하면 prebuild 게이트에서 적발.
요구 tests/coach-anchor-subordination.test.ts 신설. A-04~A-09를 자녀 시뮬(아린 6/15~17 닻 environment/콩류)로 엔드투엔드 검증. 기존 coach-plan.test.ts 톤(describe 한국어·결정론 시드)에 맞춤.
구현 tests/coach-anchor-subordination.test.ts: planFromWeekly(forceScenarioId)·targetPoolForScenario(refusedExposable)·override 게이트 순수함수 단위 + 시나리오 조합 테스트. 라이브 route는 직접 호출 불가하므로 게이트 판정 로직을 순수함수(예: anchorOverrideAllowed(anchorLever, sid, leverScenario, fov, cap, signals))로 추출해 단위 테스트(A-04에서 함수 추출 권장). LEVER_SCENARIO·SAFE_INTERRUPT_SCENARIOS·exposable 필터 정합 단언.
파일 tests/coach-anchor-subordination.test.ts · app/api/cron/coach/route.ts · lib/coachWeekly.ts
유즈케이스 아린 W25 시나리오를 골든으로 박제 — 향후 두뇌 프롬프트/시나리오 변경이 닻 종속을 깨면 즉시 RED.
테스트
  • 비-food 닻 + food 시나리오 + cap 소진 → 차단(닻 프레임 유지)
  • 비-food 닻 + cap 미소진 → 허용 + 카운트 증가
  • 트리거 미충족 시나리오 강제 차단
  • 3일 연속 useFood → 3일째 강등
  • 치킨 비결핍 거부 → 타깃 풀 제외
  • food 닻 override = 기존 다양성 보존(대조군)
DoD 신규 테스트 전부 그린 + 기존 vitest 무회귀(369+ 그린). prebuild 게이트 통과.
의존: A-04, A-05, A-06, A-07, A-08, A-09 · 규모 M · 우선순위 P1
⚠ 게이트 로직이 route 인라인이면 테스트 불가 — A-04에서 anchorOverrideAllowed 순수함수 추출이 선행(테스트 가능성 확보).

EPIC B — 타깃 결핍군 필터 단일점 (refExposable Unification) D1

현재 refExposable(주식제외 + 결핍군 소속 거부만)은 route.ts:354-358에서 계산되지만 runWeeklyPlanning 입력(523행)에만 적용되고, 일간 signals(485)·targetPoolForScenario(coach.ts:388)·두뇌 강제 경로(629)·reexposurePick(398)·온디맨드(app/api/coach:46)는 전부 무필터 sanitizeRefusals를 써서 치킨(미매핑·비결핍군)이 일간 타깃·재노출로 누수된다. 더하여 현행 refExposable 자체에 네임스페이스 버그가 있다 — catOf(r)은 풀 카테고리(고기·계란·콩_콩제품)를 돌려주는데 _deficientGroups는 8-식품군(고기·계란·콩류)을 담아 직접 비교가 어긋난다. 이 EPIC은 (1) catOf 결과를 CATEGORY_GROUP으로 한 번 더 그룹화한 단일 공유 함수 exposableRefusals를 lib에 추출하고, (2) 일간 signals·폴백·두뇌·재노출 3+경로가 같은 잠금을 보게 단일 수정점으로 배선해 치킨 누수를 차단하되, (3) 거부 '보고' 신호(autonomy 트리거·거부 언급)는 죽이지 않고 '타깃·재노출 선정'에만 필터를 건다.

B-01 exposableRefusals 공유 함수 추출 (CATEGORY_GROUP 네임스페이스 정합)코드규모 M

목적 근본원인 #1·#14·#21·#22의 공통 뿌리 = refExposable 로직이 route.ts 인라인에 묻혀 weekly에만 적용되고, 게다가 catOf(풀카테고리) vs 결핍군(8-식품군) 직접 비교 버그가 있음. 단일 진실 함수로 추출하고 네임스페이스를 맞춰 모든 경로가 재사용할 수 있게 한다.
요구 lib에 export function exposableRefusals(refusals: string[], deficientGroups: Set<string>, catOf: (ing:string)=>string|undefined): string[] 를 신설. 내부: sanitizeRefusals(refusals)로 정제 → 각 r에 대해 (a) 주식 제외(_STAPLE_WORD 정규식 OR STAPLE_FORMS[r]) (b) const g = FOOD_GROUP[r] ?? CATEGORY_GROUP[catOf(r) ?? ''] 로 8-식품군으로 매핑(computeFoodGroups:308·groupOf:196과 동일 우선순위) (c) g && deficientGroups.has(g) 인 것만 통과. 주식 정규식·STAPLE_FORMS·CATEGORY_GROUP·FOOD_GROUP을 nutrition/coachRecos에서 import. catOf=undefined(치킨)이거나 g 없으면 탈락.
구현 lib/coach.ts에 추가(route가 이미 coach에서 sanitizeRefusals·planFor import 중). 핵심 수정: 현행 route.ts:356-357 const g = catOf(r); return !!g && _deficientGroups.has(g) 를 const g = FOOD_GROUP[r] ?? CATEGORY_GROUP[catOf(r) ?? '']; return !!g && deficientGroups.has(g) 로 교체(nutrition.ts:196 groupOf·308 computeFoodGroups와 동일 우선순위). CATEGORY_GROUP=nutrition.ts:127(export), FOOD_GROUP=nutrition.ts:291(현재 비export → export 필요), STAPLE_FORMS=coachRecos.ts:32, sanitizeRefusals=coach.ts:296. _STAPLE_WORD 정규식은 route.ts:352 리터럴 모듈 상수화. catOf 시그니처는 route.ts:103 (ing)=>catMap[ing]와 호환.
파일 web/lib/coach.ts · web/lib/nutrition.ts · web/lib/coachRecos.ts
유즈케이스 아린 거부 기록에 '치킨' 있어도 catOf('치킨')=undefined라 탈락. 반면 콩류 결핍+'두부' 거부면 통과(정당 재노출). 현행 refExposable은 catOf('두부')=콩_콩제품을 결핍군 '콩류'와 비교해 어긋나 두부 정당 재노출을 놓치던 버그도 동시 수복.
테스트
  • exposableRefusals: 치킨(catOf=undefined) 탈락
  • exposableRefusals: 밥/면/떡 주식 탈락(STAPLE_FORMS·정규식 양쪽)
  • exposableRefusals: 돼지고기(catOf=고기→CATEGORY_GROUP=고기·계란) 고기·계란 결핍시 통과
  • exposableRefusals: 돼지고기 고기·계란 비결핍시 탈락(과교정 방지)
  • exposableRefusals: 시금치(catOf=잎채소→비타민A채소) 결핍시 통과
  • exposableRefusals: catOf 결과를 그룹 변환 없이 직접 비교하면 모두 탈락(회귀 가드)
DoD vitest 6케이스 그린. 함수가 catOf→CATEGORY_GROUP→8식품군 변환을 거쳐 비교하고, 변환 없는 직접 비교는 실패(회귀 가드로 명시). tsc 0.
의존: 없음 · 규모 M · 우선순위 P0
⚠ FOOD_GROUP을 export로 노출 시 기존 import 충돌 가능 → grep으로 사용처 확인. CATEGORY_GROUP·FOOD_GROUP 우선순위(정확→범주) computeFoodGroups와 일치 유지.

B-02 route.ts 인라인 refExposable을 B-01 공유 함수로 교체코드규모 S

목적 weekly synth 입력(523행)의 기존 refExposable이 네임스페이스 버그를 그대로 갖고 있으므로 공유 함수로 교체해 weekly 경로도 정합 버전을 쓰게 하고 인라인 중복을 제거(단일 진실).
요구 route.ts:352-358의 인라인 _STAPLE_WORD/refExposable 필터 블록을 const refExposable = exposableRefusals(uniqRef, _deficientGroups, catOf) 한 줄로 대체. _deficientGroups = new Set([...homeFg.missing, ...fg.missing])(route.ts:353)는 유지. 523행 refused: refExposable는 변경 없이 동작.
구현 route.ts:353 _deficientGroups 유지(homeFg.missing∪fg.missing=8-식품군). route.ts:354-358 화살표 필터 전체를 한 줄 치환. route.ts:21 import 목록에 exposableRefusals 추가. 동작 차이: 기존 네임스페이스 버그로 누락되던 정당 거부(두부 등)가 weekly 후보에 들어옴 → B-08 골든으로 회귀 확인.
파일 web/app/api/cron/coach/route.ts
유즈케이스 아린: weekly synth refused 입력이 이제 결핍군 소속 거부만 + 두부 같은 정당 거부도 정확히 포함.
테스트
  • route refExposable 회귀: 치킨 제외·콩류 결핍시 두부 포함(공유함수 경유)
DoD route.ts에 refExposable 인라인 필터 0개(grep). weekly synth가 공유함수 결과를 받음. tsc·next build 0. 기존 coach 테스트 그린.
의존: B-01 · 규모 S · 우선순위 P0
⚠ 네임스페이스 수정으로 weekly 후보가 늘어 mission_target 선택이 미세 변동 → B-08 골든 리플레이. EPIC A supersede와 충돌 없는지 확인.

B-03 signals에 refusedExposable 파생 필드 신설 (거부 보고는 원본 유지)코드규모 M

목적 근본원인 #1 보강(2)·(3): signals.refused/homeRefused/daycareRefused를 전면 필터하면 autonomy-power-struggle 트리거(s.refused.length>=2, coachScenarios.ts:89)·new-refusal/re-exposure 트리거·거부 보고 인용까지 죽는다. '거부 보고' 필드는 원본 유지하고 '타깃 선정 전용' 정제 필드를 별도 추가해 부작용을 격리한다.
요구 CoachSignals 타입(coach.ts:176~190, homeRefused:182)에 homeRefusedExposable?: string[]; daycareRefusedExposable?: string[] 추가. route.ts:483-489 signals 객체에서 기존 homeRefused/daycareRefused/refused(무필터 sanitizeRefusals)는 그대로 두고, homeRefusedExposable: exposableRefusals(homeRef, _deficientGroups, catOf), daycareRefusedExposable: exposableRefusals(daycareRef, _deficientGroups, catOf) 추가 주입.
구현 coach.ts CoachSignals interface(176-190, homeRefused:182·daycareRefused 부근)에 옵셔널 필드 2개 추가. route.ts:483-489 signals 리터럴에 두 줄 추가. homeRef/daycareRef는 route.ts에서 이미 분리 계산됨(485행이 sanitizeRefusals(homeRef)/sanitizeRefusals(daycareRef) 사용 중). _deficientGroups는 B-02에서 살아있는 스코프 변수.
파일 web/app/api/cron/coach/route.ts · web/lib/coach.ts
유즈케이스 아린: refused=['치킨','두부'](보고 유지·autonomy 살아있음) / refusedExposable=['두부'](콩류 결핍시) → 타깃 선정엔 두부만.
테스트
  • signals: refused/homeRefused 원본은 무필터 유지(거부 보고·autonomy 트리거 보존)
  • signals: refusedExposable은 치킨 제외·결핍군만
DoD signals.refused가 여전히 autonomy 트리거(>=2)를 충족하는 무필터 값. refusedExposable은 결핍군 거부만. tsc 0. 트리거 회귀 테스트 그린.
의존: B-01 · 규모 M · 우선순위 P0
⚠ 필드 추가가 buildBrainContext·온디맨드에 영향 없도록 옵셔널 유지. homeRefused/daycareRefused가 targetPoolForScenario에서 union되므로 두 필드 분리가 안전.

B-04 targetPoolForScenario를 결핍군 정제 풀 사용으로 전환코드규모 M

목적 근본원인 #21·#14 핵심: forceScenarioId·selectScenario·재시도(refresh) 모든 경로가 targetPoolForScenario(coach.ts:388)를 거치는 단일 병목. 여기를 결핍군 정제 풀로 바꾸면 폴백·두뇌 두 경로를 한 번에 막는다(근본원인 #1 보강(3)·#21 fix 권장안).
요구 coach.ts:386-390 targetPoolForScenario의 new-refusal/re-exposure-timing 분기(388행)가 [...homeRefused, ...daycareRefused] 대신 [...(s.homeRefusedExposable ?? s.homeRefused ?? []), ...(s.daycareRefusedExposable ?? s.daycareRefused ?? [])] 를 반환. nutrient-gap/home-daycare-gap 분기(387)는 이미 missing(8-식품군)이라 변경 없음. Exposable 필드 없으면(온디맨드 콜드) 기존 필드 폴백 → B-07이 온디맨드도 채움.
구현 coach.ts:388 한 줄 수정: return [...new Set([...(s.homeRefusedExposable ?? s.homeRefused ?? []), ...(s.daycareRefusedExposable ?? s.daycareRefused ?? [])])]. 이로써 planFor:448(force)·453·459·462(폴백/refresh) 전부 정제 풀 사용 → 치킨이 buildCoachPlan targetPool[0]이 될 수 없음. lockedTargetPool 인자 도입(#21 fix 대안)은 닻 통합(EPIC A) 책임이라 제외 — 본 EPIC은 '결핍군 정제'에 집중.
파일 web/lib/coach.ts
유즈케이스 아린 6/17: 두뇌 forceScenarioId=re-exposure-timing이어도 targetPool에서 치킨이 빠져 plan.target이 콩류 등 결핍군으로만 산출.
테스트
  • targetPoolForScenario: re-exposure-timing이 refusedExposable만 반환(치킨 제외)
  • targetPoolForScenario: Exposable 미설정시 기존 homeRefused 폴백
  • planFor(forceScenarioId=re-exposure-timing): 치킨 거부여도 plan.target≠치킨
DoD planFor force 경로·폴백 경로 모두 치킨을 타깃으로 못 뽑음(vitest). nutrient-gap 풀은 불변. tsc 0.
의존: B-03 · 규모 M · 우선순위 P0
⚠ 닻 잠금(EPIC A) 없는 상태에서도 치킨 누수는 차단되나, 주간 mission_target 잠금은 EPIC A 책임 — EPIC A 닻 게이트 완료 시 lockedTargetPool로 추가 강화 가능(후속).

B-05 두뇌 강제 경로(route.ts:629)가 정제 signals를 보게 보장배선규모 S

목적 근본원인 #21 fix(2): route.ts:628-630 두뇌 override가 planFor(forceScenarioId)를 호출할 때 같은 signals 객체를 넘긴다. B-03/B-04로 signals.refusedExposable이 채워지고 targetPoolForScenario가 그걸 쓰면 두뇌 경로도 자동으로 정제 풀을 본다 — 배선이 끊기지 않았는지 검증·고정한다.
요구 route.ts:629 planFor 호출의 signals 인자가 B-03에서 refusedExposable이 채워진 동일 signals임을 확인(재할당 없음). 추가 코드 변경 없고 회귀 방지 주석 추가. 두뇌가 trigger 미충족 시나리오(근본원인 #4)를 골라도 targetPool이 정제돼 치킨이 안 나옴을 통합 테스트로 고정.
구현 route.ts:483 signals 선언→615-632 두뇌 블록까지 동일 const signals 참조(재할당 없음 확인). 629행은 인자 변경 불요(signals.refusedExposable 이미 포함). 주석 1줄: '// signals.refusedExposable(B-03)이 targetPoolForScenario(B-04)를 정제 → 두뇌 force 경로도 치킨 누수 차단'. 통합 검증: brainPick.scenarioId='re-exposure-timing' + 거부=['치킨'] 모킹 → precomputed.plan.target≠치킨.
파일 web/app/api/cron/coach/route.ts
유즈케이스 아린 6/17 실측 시나리오 픽스: 두뇌가 치킨 재노출을 골라도 타깃에서 치킨 소거.
테스트
  • 통합: 두뇌 re-exposure-timing 강제+거부 치킨 → 발행 plan.target에 치킨 없음
  • 통합: 두뇌 force 경로 signals가 refusedExposable 포함
DoD 두뇌 force 경로에서 치킨이 타깃으로 누수되지 않음(vitest 통합). signals 재할당 없음 확인. tsc·build 0.
의존: B-03, B-04 · 규모 S · 우선순위 P0
⚠ EPIC A의 닻 통합(629행 plan 보존)과 동일 라인을 건드림 → 머지 충돌. 본 EPIC은 signals 정제만 책임지고 plan 보존 로직은 EPIC A에 위임(주석으로 경계 명시).

B-06 reexposurePick 입력을 결핍군 정제(refExposable)로 교체코드규모 S

목적 근본원인 #22·#14: route.ts:398 rxRefs = sanitizeRefusals(uniqRef)(무필터)가 reexposurePick(399)에 들어가 치킨 '5일 전 재노출 적기' fact를 timeseries에 push(400) → 편지가 off-target 치킨 숫자를 인용. 재노출 fact화를 결핍군 거부로 한정한다(#22 fix(a)).
요구 route.ts:398 const rxRefs = sanitizeRefusals(uniqRef).filter((f) => !transitioned.has(f)) 를 const rxRefs = refExposable.filter((f) => !transitioned.has(f)) 로 교체(refExposable=B-02 결핍군 정제본·이미 sanitize됨). transitioned 제외 가드 유지(축하 vs 재노출 모순 차단).
구현 route.ts:398 한 줄 교체. refExposable은 route.ts:354(B-02 후 공유함수 결과)에서 이미 선언돼 스코프 내. offerCount/offerLast(391-396)는 무변경(전체 노출 카운트는 정확해야 함). 추가 안전망(#22 fix(c) composeLetter:775 tsFiltered 정규식 제거)은 본 EPIC 범위 밖(작문 가드 EPIC)으로 둠.
파일 web/app/api/cron/coach/route.ts
유즈케이스 아린 6/17 편지의 '치킨 마지막 권한 게 5일 전' 구절(=reexposurePick fact) 소거. 콩류 결핍+두부 거부면 '두부 N번 만났고 M일 전' 정상 인용.
테스트
  • reexposure 입력: 치킨 결핍군 아님 → ts에 치킨 재노출 fact 미주입
  • reexposure 입력: 콩류 결핍+두부 거부 → 두부 재노출 fact 정상 주입
  • reexposure 입력: transitioned 식재료 제외 유지
DoD ts에 비결핍군(치킨) 재노출 사실이 push되지 않음(vitest). 결핍군 거부의 재노출은 정상 동작. tsc 0.
의존: B-02 · 규모 S · 우선순위 P1
⚠ refExposable이 비면(결핍 0/콜드스타트) 재노출 fact가 안 떠 다양성 저하 가능 → 의도된 동작(결핍 없으면 재노출 채근 안 함). transitioned 가드 유지 확인.

B-07 온디맨드 경로(app/api/coach)에 결핍군 정제 배선배선규모 M

목적 근본원인 #21 fix(2) 단서: 온디맨드 편지 경로(app/api/coach/route.ts:43-55)도 planFor→targetPoolForScenario를 거치는데 signals에 refusedExposable가 없어 폴백(무필터)으로 떨어지면 selectScenario 경로로 치킨이 다시 타깃이 된다. 온디맨드도 동일 잠금을 보게 한다(3경로 단일 수정점의 세 번째).
요구 app/api/coach/route.ts:43-46 signals 생성부에서 미싱(b.missing/b.homeMissing 등)으로 _deficientGroups = new Set([...(b.missing||[]), ...(b.homeMissing||[])]) 구성하고, exposableRefusals로 homeRefusedExposable/daycareRefusedExposable을 채워 signals에 주입. 온디맨드는 catOf 부재 가능 → B-01 함수가 catOf optional 허용(FOOD_GROUP 직접 매핑만으로도 동작).
구현 app/api/coach/route.ts:43 signals 리터럴(46행 homeRefused/daycareRefused 인접)에 exposable 필드 2개 추가. catOf 없으면 B-01이 FOOD_GROUP[r]만으로 매핑(CATEGORY_GROUP 분기 스킵) → 온디맨드는 정확 매핑 식재료만 정제, 미매핑은 보수적 탈락. 미입력 결핍(b.missing 없음)이면 _deficientGroups 비어 exposable=[] → targetPoolForScenario가 폴백(B-04 ??)으로 기존 homeRefused 사용: 온디맨드 결핍 신호 약하면 기존 동작 보존(안전).
파일 web/app/api/coach/route.ts
유즈케이스 온디맨드 미리보기에서도 결핍 입력시 치킨이 타깃으로 안 뜸. 결핍 정보 없는 비로그인 미리보기는 기존대로 동작.
테스트
  • 온디맨드: 결핍 입력시 refusedExposable 채워짐(치킨 제외)
  • 온디맨드: catOf 없이 FOOD_GROUP만으로 두부→콩류 정제
  • 온디맨드: 결핍 미입력시 기존 homeRefused 폴백(회귀 보존)
DoD 온디맨드 planFor도 결핍군 정제 풀을 봄(결핍 입력시). 결핍 미입력시 기존 동작 보존. tsc·build 0.
의존: B-01, B-04 · 규모 M · 우선순위 P1
⚠ 온디맨드는 catOf 부재 가능 → CATEGORY_GROUP 분기 못 타 일부 정당 거부 탈락(보수적). 온디맨드는 미리보기성이라 영향 제한적 — DoD에 폴백 동작 명시.

B-08 3경로 단일점 통합 가드 테스트 + 아린 골든 리플레이테스트규모 M

목적 이 EPIC의 핵심 주장('일간 signals·폴백·두뇌·재노출·온디맨드가 같은 잠금을 본다')을 한 곳에서 회귀 고정하고, 아린 6/15~6/17 실데이터(콩·과일·치킨 3일)에서 치킨 누수 0을 증명한다(근본원인 #1·#14·#21·#22 통합).
요구 단일 통합 테스트 스위트: 거부=['치킨','두부']+결핍={콩류} 입력으로 (1)targetPoolForScenario (2)planFor 폴백 (3)planFor forceScenarioId (4)reexposurePick 입력 (5)온디맨드 signals 5경로가 모두 치킨 배제·두부 허용. 네임스페이스 회귀 가드(catOf 직접비교 금지) 포함. 아린 픽스처(거부에 치킨)로 'plan.target≠치킨 AND ts에 치킨 재노출 fact 없음' 단언.
구현 tests/coach-refexposable-unify.test.ts(신규). 기존 tests/coach-hybrid-rotation.test.ts·coach-recos.test.ts 패턴. CoachSignals 모킹 + exposableRefusals·targetPoolForScenario·planFor 직접 호출. 아린 시나리오는 메모리 실측(집 ate_well true 4·치킨 거부)을 픽스처화. 경로별 단언으로 '단일 수정점' 회귀 방지(한 경로 수정 누락시 실패).
파일 web/tests/coach-refexposable-unify.test.ts
유즈케이스 아린 3일 연속 음식 잔소리(콩·과일·치킨) 중 치킨 누수가 5경로 어디서도 재발 안 함을 CI가 영구 보증.
테스트
  • 통합: 치킨은 5경로(targetPool/폴백/force/reexpo/온디맨드) 전부에서 배제
  • 통합: 두부는 콩류 결핍시 5경로 전부 통과
  • 통합: 네임스페이스 직접비교(catOf vs 8식품군)는 두부도 탈락(버그 재현 가드)
  • 아린 골든: 6/15~17 거부 치킨 포함시 plan.target≠치킨·ts 치킨 재노출 0
DoD 신규 스위트 4+케이스 그린. vitest 전체 그린(기존 369→증가). 한 원자라도 미배선이면 통합 테스트가 실패하도록 경로별 단언 작성.
의존: B-02, B-04, B-05, B-06, B-07 · 규모 M · 우선순위 P1
⚠ 아린 실데이터 직접 DB 접근은 테스트에서 불가 → 메모리 실측을 픽스처 상수로 박제. 픽스처가 실제 CoachSignals 타입과 어긋나지 않게 맞춤.

EPIC C — 음식 잔소리 케이던스 캡 (Food Nag Cadence) D1

일간 두뇌(coachBrain)의 useFood가 진짜 결핍이 0이 될 때까지 매일 true가 될 수 있고 '연속 N일 food 금지' 캡이 없어, 비-food 주(lever≠food)에도 며칠 연속 음식 추천 잔소리가 발행된다(아린 6/15 콩·6/16 과일·6/17 치킨 3연속, 닻 lever=environment). 현 가드(route.ts:647)는 '결과 scenarioId가 STRUCTURAL일 때'만 useFood=false로 잠그는데 두뇌가 시나리오를 food로 override하면 탈출한다. 이 EPIC은 프롬프트 신뢰 대신 결정론 캡 두 겹을 박는다 — (1) 닻 lever를 두뇌 스코프로 끌어와 비-food 주 useFood 강제 클램프, (2) 직전 편지 context.brain.useFood 이력을 적재해 직전 N일 연속 true면 강제 휴지. 채근 캡(push=0)·간식 쿨다운(SNACK_COOLDOWN=2)과 동형 패턴.

C-01 닻 lever를 weekCtx에 실어 두뇌 블록 스코프로 노출코드규모 S

목적 근본원인 idx 8/20: 두뇌 override 게이트(C-03)와 useFood 클램프(C-02)가 '닻 lever'를 알아야 하는데, 현재 lever는 if(anchor && anchor.mission_target) 블록(route.ts:573-611) 안에만 살아 두뇌 블록(615-632)에서 접근 불가. lever를 weekCtx로 끌어올려 하류 가드가 결정론적으로 읽게 한다.
요구 weekCtx 객체가 닻의 budget.lever를 carry하고, 비-food 닻이 살아있을 때 두뇌 블록·useFood 클램프·간식 게이트가 이 값을 읽을 수 있어야 한다. 닻이 없으면 lever 미정의(=폴백 'food' 취급).
구현 route.ts:423 weekCtx 타입에 `lever: WeeklyLever | null` 추가. route.ts:575 `const lever = anchor.budget?.lever || 'food';`로 이미 계산된 값을, route.ts:607 `weekCtx = { weekKey, fromWeekly: true, impression, pushApplied: wk.pushApplied, arc: wk.weeklyArc }`에 `lever` 필드 추가. 재사용 경로(route.ts:435 pctx 타입, 440 weekCtx 복원)와 letterCtx 저장(712 weekly: weekCtx)에도 lever 필드 동반(저장 shape 일관). WeeklyLever는 coachWeekly에서 이미 import 라인(31행 인근)에 type 추가. 닻 미적용(anchor null/타깃 없음) 시 weekCtx 자체가 null이므로 하류는 `weekCtx?.lever ?? 'food'`로 읽음.
파일 app/api/cron/coach/route.ts
유즈케이스 아린 6/17 W25 닻 lever=environment → weekCtx.lever='environment'가 두뇌 블록에 노출되어 C-02/C-03 가드 발동 가능.
테스트
  • weekCtx_carries_anchor_lever_when_non_food
  • weekCtx_lever_null_when_no_anchor
  • letterCtx_persists_lever_field
DoD 비-food 닻(lever=environment)이 있는 자녀의 발행 편지 context.weekly.lever === 'environment'. 닻 없는 자녀는 weekly === null. tsc 0·기존 테스트 그린.
의존: A-01 · 규모 S · 우선순위 P0
⚠ 재사용 경로·letterCtx 저장 shape를 같이 안 고치면 다음날 lever 유실 → 가드 우회. 저장/복원 3곳 동시 수정 필수.

C-02 비-food 주 useFood 결정론 클램프 (lever 기반)코드규모 S

목적 근본원인 idx 2 fix(1)+idx 8: 현 가드 route.ts:647은 '결과 scenarioId가 STRUCTURAL_FRAMES일 때'만 useFood=false로 잠그는데, 두뇌가 시나리오를 food(nutrient-gap/re-exposure-timing)로 override하면 STRUCTURAL이 아니라 탈출 → 비-food 주에도 음식 잔소리. 가드 기준을 scenarioId가 아니라 '닻 lever'로 바꾼다.
요구 닻 lever가 비-food(environment/autonomy/texture)이면 두뇌 useFood를 결정론으로 false로 클램프(주당 예외 캡은 C-04). 닻이 food이거나 닻이 없으면 두뇌 useFood 존중. 기존 STRUCTURAL_FRAMES 가드(647)는 보조로 유지(닻 없는 자녀 보호).
구현 route.ts:628-630 brainPick 적용 직후·647 STRUCTURAL 가드 자리를 clampUseFood 헬퍼(C-05) 호출로 통합. anchorLever는 `weekCtx?.lever ?? 'food'`. 비-food면 brainPick.useFood=false(단 C-04 예산 캡 잔여 있으면 예외). 클램프된 useFood는 route.ts:662 bridgeFacts 게이트(`brainPick.useFood === false ? '' : buildRecoFacts(...)`)를 통해 음식 추천 본문을 자동 제거.
파일 app/api/cron/coach/route.ts · lib/coachBrain.ts
유즈케이스 아린 6/16(닻 environment, 두뇌 nutrient-gap·useFood=true) → useFood 강제 false → 과일 추천 본문 제거, 환경 코칭만 남음.
테스트
  • useFood_clamped_false_on_environment_week
  • useFood_clamped_false_on_autonomy_week
  • useFood_respected_on_food_week
  • useFood_respected_when_no_anchor
  • bridgeFacts_empty_when_useFood_clamped
DoD 닻 lever=environment인 자녀의 발행 편지: context.brain.useFood===false, bridgeFacts==='', 본문에 음식 추천 문구 0. lever=food면 종전대로 음식 추천 유지.
의존: C-01, C-05, A-01 · 규모 S · 우선순위 P0
⚠ 닻이 비-food여도 food override를 영영 0으로 막으면 '음식 결핍 영영 미룸' 부작용 → C-04 주당 예외 캡으로 완화 필수. 클램프를 STRUCTURAL 가드(647)보다 먼저 두면 순서 충돌 → C-05 헬퍼로 단일화.

C-03 비-food 주 food 시나리오 override 차단 게이트코드규모 M

목적 근본원인 idx 8 fix(2)+idx 20: route.ts:628-629가 brainPick.scenarioId 있으면 무조건 precomputed=planFor({forceScenarioId})로 닻 프레임을 통쨰 덮어씀. 비-food 주에 두뇌가 food 프레임(re-exposure-timing/new-refusal/nutrient-gap)을 고르면 환경 teaching arc가 사라지고 음식 추천만 남는다. useFood 클램프(C-02)만으로는 시나리오 프레임 자체는 food로 바뀌어 톤 불일치 잔존 → 시나리오 override도 lever에 종속.
요구 닻 lever가 비-food일 때 두뇌가 food 시나리오를 골랐으면 precomputed override를 차단하고 planFromWeekly가 정한 닻 프레임(precomputed) 유지. 단 안전 인터럽트(progress-celebrate/neophobia-arfid-watch/low-data-gap)와 lever 호환 시나리오(LEVER_SCENARIO[lever])는 override 허용. food 주에서는 종전대로 override 허용.
구현 LEVER_SCENARIO를 coachWeekly.ts:55에서 `export`로 승격(현재 비export). route.ts:628 가드 추가: `const anchorLever = weekCtx?.lever ?? 'food'; const SAFE_INTERRUPTS = new Set(['progress-celebrate','neophobia-arfid-watch','low-data-gap']); const leverScen = LEVER_SCENARIO[anchorLever];` 후 `const allowOverride = anchorLever === 'food' || brainPick.scenarioId === leverScen || SAFE_INTERRUPTS.has(brainPick.scenarioId) || (foodOverrideUsedThisWeek < FOOD_OVERRIDE_CAP);` 그 다음 `if (brainPick.scenarioId && allowOverride) { precomputed = planFor({...forceScenarioId}); }`. 불허 시 닻 precomputed(=레버 프레임) 유지하고 brainPick.scenarioId는 어드민 노출용으로만 보존. 캡 카운트는 C-04.
파일 app/api/cron/coach/route.ts · lib/coachWeekly.ts
유즈케이스 아린 6/17 닻 environment·두뇌 re-exposure-timing(치킨) → override 차단 → mealtime-atmosphere 환경 편지 발행, 치킨 추천 사라짐.
테스트
  • override_blocked_food_scenario_on_environment_week
  • override_allowed_for_lever_compatible_scenario
  • override_allowed_for_safe_interrupt
  • override_allowed_on_food_week
  • anchor_frame_preserved_when_override_blocked
DoD 닻 lever=environment·두뇌 scenarioId=re-exposure-timing → precomputed.scenario.id === 'mealtime-atmosphere'(닻 프레임 유지), context.brain.scenarioId='re-exposure-timing'(감사용 보존). progress-celebrate는 환경 주에도 통과.
의존: C-01, C-04, A-01 · 규모 M · 우선순위 P0
⚠ override 차단 시 weeklyArc(환경)와 precomputed(환경)가 이제 일치하므로 톤 충돌 없음. 단 override 허용한 날(food 예외)은 arc(환경)+food 본문 충돌 가능 → 그 경우 C-08에서 arc 강등 처리.

C-04 주당 food override 예산 캡 (FOOD_OVERRIDE_CAP, ledger 적재)코드규모 M

목적 근본원인 idx 2/8: '음식 결핍을 영영 미루지 마라'(SYSTEM_BRAIN 39-42) 의도를 비-food 주에도 살리되 결정론으로 통제 — 비-food 주에 주 1~2회만 food override 허용하고 나머지는 차단. 프롬프트의 '며칠에 한 번 food'를 코드 캡으로 강제.
요구 비-food 주 1주에 food override(useFood=true + food 시나리오)가 FOOD_OVERRIDE_CAP회(기본 2)까지만 허용. 캡 소진 후엔 C-02/C-03이 차단. 사용 횟수는 weekly_plans.ledger에 영속(주 경계 리셋).
구현 coachWeekly.ts WeeklyLedger 타입(36행)에 `foodOverrideUsed?: number` 추가, DEFAULT_LEDGER(56행)에 `foodOverrideUsed: 0`. route.ts: 닻 로드 시(573-611) `const foodOverrideUsed = anchor.ledger?.foodOverrideUsed ?? 0;`를 weekCtx 또는 별도 let으로 노출. C-03 allowOverride 마지막 조건 `(anchorLever!=='food' && foodOverrideUsed < FOOD_OVERRIDE_CAP)`. override가 비-food 주에 실제 발동하면(useFood=true·food 시나리오) ledger.foodOverrideUsed+1을 route.ts:608-609 newLedger 패치 경로(이미 weekly_plans.update 존재)에 합산. FOOD_OVERRIDE_CAP=2 상수는 route.ts 상단 또는 coach 상수 모듈.
파일 app/api/cron/coach/route.ts · lib/coachWeekly.ts
유즈케이스 아린 비-food 주 — 콩·과일·치킨 3연속 대신 주 2회만 음식날 허용, 나머지는 환경 코칭.
테스트
  • food_override_allowed_under_cap_on_nonfood_week
  • food_override_blocked_at_cap
  • foodOverrideUsed_increments_on_override
  • foodOverrideUsed_resets_new_week
  • legacy_ledger_without_field_defaults_zero
DoD 비-food 주에 food override 2회까지는 useFood=true 통과·ledger.foodOverrideUsed 누적, 3회쨰는 useFood=false 강제. 주 경계(week_key 변경) 후 카운트 0 리셋. 구버전 ledger(필드 없음)는 0으로 안전 시작.
의존: C-01 · 규모 M · 우선순위 P1
⚠ ledger 적재 경로(608)는 food 닻 분기 안. 비-food 닻도 이 update가 도는지 확인 필요 — 안 돌면 별도 update 추가. 주 경계 리셋은 새 week_key 닻이 새 ledger(DEFAULT)로 시작하므로 자연 리셋.

C-05 clampUseFood 순수 함수로 캡 로직 단일화 (coachBrain.ts)코드규모 M

목적 근본원인 idx 2: useFood 캡이 (1)lever 클램프 (2)연속 쿨다운 (3)STRUCTURAL 가드 (4)주당 예산 4갈래로 route.ts에 흩어지면 순서 충돌·테스트 불가. coachBrain.ts는 현재 테스트 0건. 결정론 캡을 순수 함수로 뽑아 단위테스트하고 route.ts는 호출만.
요구 lever·연속 useFood 이력·STRUCTURAL 여부·주당 override 잔여를 입력받아 최종 useFood(boolean)와 사유를 반환하는 순수 함수. route.ts:628-647의 흩어진 클램프를 이 함수 1콜로 대체.
구현 coachBrain.ts에 `export function clampUseFood(p: { brainUseFood: boolean; anchorLever: WeeklyLever | null; structuralFrame: boolean; recentUseFood: boolean[]; cooldownDays: number; foodOverrideRemaining: number }): { useFood: boolean; reason: string }` 신설. 우선순위: ①structuralFrame이면 false('구조 프레임') ②anchorLever 비-food && foodOverrideRemaining<=0 이면 false('비-food 주 예산 소진') ③recentUseFood 직전 cooldownDays개 모두 true면 false('연속 food 휴지') ④그 외 brainUseFood 그대로. route.ts에서 brainPick.useFood = clampUseFood({...}).useFood로 647·C-02 가드 대체, reason은 brainPick.why에 부기하거나 context 저장. WeeklyLever import from coachWeekly.
파일 lib/coachBrain.ts · app/api/cron/coach/route.ts
유즈케이스 아린 6/15~17 입력 시퀀스를 clampUseFood에 흘리면 6/17 'recentUseFood=[true,true]→false' 반환(연속 휴지 검증).
테스트
  • clampUseFood_structural_frame_false
  • clampUseFood_nonfood_budget_exhausted_false
  • clampUseFood_consecutive_cooldown_false
  • clampUseFood_food_week_passthrough
  • clampUseFood_returns_reason_string
  • clampUseFood_priority_order
DoD clampUseFood 단위테스트 6+ 그린(coachBrain.ts 최초 테스트 파일 생성). route.ts useFood 분기가 단일 함수 호출로 통일. 동일 입력 결정론(같은 인자→같은 출력).
의존: 없음 · 규모 M · 우선순위 P0
⚠ 우선순위 순서가 C-02/C-03/C-04 게이트 의미와 어긋나면 캡 우회. structural→budget→cooldown→passthrough 순서를 테스트로 고정.

C-06 연속 food날 쿨다운 이력 적재 (recentBrainUseFood)코드규모 S

목적 근본원인 idx 2 fix(2): context.brain.useFood는 이미 route.ts:713에 저장되나 이력으로 되읽는 누산기가 없다. recentSnackDates(162·175)·recentRecoIng(165·170)와 동형으로 직전 N일 useFood 시퀀스를 적재해, 직전 cooldownDays일 연속 true면 강제 휴지(clampUseFood ③).
요구 최근 3일 편지 context.brain.useFood를 child별 최신순 boolean[]로 적재(과거 편지만, 오늘/미래 자기참조 제외). clampUseFood의 recentUseFood 입력으로 전달.
구현 route.ts:160-165 누산기 선언부에 `const recentBrainUseFood: Record<string, boolean[]> = {};` 추가. route.ts:169 ctx 타입에 `brain?: { useFood?: boolean }` 추가, 168 과거-편지 가드(`l.letter_date >= today` return) 아래 forEach(166-176) 안에 `if (typeof ctx?.brain?.useFood === 'boolean') (recentBrainUseFood[l.child_id] ||= []).push(ctx.brain.useFood);` 추가(recentLetters는 letter_date desc 정렬이라 push 순서=최신순). clampUseFood 호출 시 `recentUseFood: (recentBrainUseFood[cid] || []).slice(0, COOLDOWN_DAYS)`. COOLDOWN_DAYS=2 상수(SNACK_COOLDOWN 동형).
파일 app/api/cron/coach/route.ts
유즈케이스 아린 6/15(콩 true)·6/16(과일 true) → 6/17 적재 시 recentUseFood=[true,true] → clampUseFood가 false 반환 → 6/17 음식 잔소리 차단.
테스트
  • recentBrainUseFood_accumulates_past_only
  • recentBrainUseFood_excludes_today
  • recentBrainUseFood_newest_first_order
  • consecutive_two_food_days_force_rest
DoD 직전 2일 편지가 모두 useFood=true였던 자녀의 오늘 발행 편지 context.brain.useFood===false(연속 휴지). recentBrainUseFood가 오늘/미래 편지를 포함하지 않음(자기참조 0).
의존: C-05 · 규모 S · 우선순위 P0
⚠ 168행 과거-편지 가드 위에 두면 오늘 편지가 이력에 섞여 자기참조(쿨다운 오발동). 반드시 168 return 아래 forEach 내부에 적재. brain이 null인 옛 편지는 boolean 아니라 자연 스킵.

C-07 buildBrainContext에 최근 food 사용 이력 주입 (다양성 가드를 useFood 축으로)코드규모 S

목적 근본원인 idx 2 fix(3): 두뇌 다양성 가드는 현재 scenarioId 축에만 작동(최근사용 플래그, coachBrain.ts:63). useFood 축엔 가드가 없어 SYSTEM_BRAIN '영영 미루지 마라' 압력으로 연일 true 선택. 최근 며칠 food 사용 시퀀스를 프롬프트에 보여 두뇌가 '연일 금지'를 스스로 반영하게(결정론 캡 C-05의 보강책, 단독 불충분).
요구 buildBrainContext 입력에 recentUseFood 시퀀스를 추가하고, 프롬프트에 '최근 N일 음식 사용 이력'과 '연속이면 비-food 각도로 전환' 지침 한 줄을 표시. 닻 lever도 표시(비-food 주임을 두뇌가 인지).
구현 coachBrain.ts:48 buildBrainContext 파라미터에 `recentUseFood?: boolean[]; anchorLever?: string | null` 추가. 75-97 반환 템플릿에 최근 음식 사용 라벨(`true=음식날·false=비음식날, 최신순`)과 이번 주 주력 레버(`비-food면 음식은 배경, 며칠에 한 번만`) 블록 삽입. route.ts:619 buildBrainContext 호출에 `recentUseFood: recentBrainUseFood[cid] || [], anchorLever: weekCtx?.lever ?? null` 전달. SYSTEM_BRAIN(34-42)은 무변경(코드 캡이 진짜 게이트).
파일 lib/coachBrain.ts · app/api/cron/coach/route.ts
유즈케이스 아린 6/17 프롬프트에 '최근 음식 사용: 음식 → 음식'이 보여 두뇌가 비-food 각도를 선호(캡 발동 전 자율 회피).
테스트
  • brainContext_includes_recent_food_history
  • brainContext_includes_anchor_lever
  • brainContext_empty_history_label
DoD buildBrainContext 출력 문자열에 '최근 음식 사용' 라벨과 '이번 주 주력 레버' 라벨 포함. 이력 빈 배열이면 '없음' 표시. tsc 0.
의존: C-01, C-06 · 규모 S · 우선순위 P1
⚠ 프롬프트 신뢰 단독은 불충분(근본원인 명시) — 반드시 C-05 결정론 캡과 병행. 이 원자만 구현하면 잔소리 재발 가능.

C-08 override 발동일 weeklyArc 톤 정합 (food 예외날 arc 강등)코드규모 S

목적 근본원인 idx 8 fix(3): C-04로 비-food 주에 food override를 허용한 날엔, 환경 weeklyArc(behavior_goal/teaching_arc)와 food actionBlock이 한 편지에 동시 주입돼 '한 번에 하나' 위반. override 허용 시 precomputed뿐 아니라 arc도 일관 전환해야 충돌·앵무새 봉합.
요구 비-food 주에 food override가 실제 발동한 날(useFood=true·food 시나리오 통과)은 weekCtx.arc를 그날만 null로 비우거나 한 구절 리마인드로 강등해, base.weeklyArc와 food 본문이 충돌하지 않게.
구현 route.ts: C-03 allowOverride가 비-food 주 food 예외로 통과한 분기에서 `weekCtx = weekCtx ? { ...weekCtx, arc: null } : weekCtx;` (그날만 arc 제거). base.weeklyArc(route.ts:681 `weekCtx?.arc ?? null`)가 자동으로 null이 되어 composeLetter가 arc 블록 미주입. 비-food 주 일반날(override 미발동)은 arc 유지(환경 프레임+환경 arc 일치). food 닻 주는 영향 없음.
파일 app/api/cron/coach/route.ts
유즈케이스 아린 비-food 주 '주 2회 음식날' 중 하루 — 그날만 환경 arc 비우고 콩 추천만, 나머지 5일은 환경 arc 유지.
테스트
  • arc_nulled_on_food_override_day
  • arc_preserved_on_nonfood_normal_day
  • arc_intact_on_food_week
DoD 비-food 주 food override 발동일: context.weekly.arc===null, 편지에 환경 teaching arc 문구 0(food 본문만). override 미발동 비-food날: arc 유지.
의존: C-03, C-04 · 규모 S · 우선순위 P1
⚠ arc null 처리를 letterCtx 저장(712) 전에 해야 다음날 firstOfWeek/arcStage 이력 일관. 저장 후 처리하면 prevArcStage(171) 오판 가능.

C-09 캡 발동 어드민 가시화 + 리플레이 골든 (food 케이던스)테스트규모 M

목적 근본원인 idx 2/8: 캡이 조용히 발동/우회해도 모니터 수단이 없으면 회귀를 못 잡는다. clampUseFood reason과 override 차단 사유를 context/issues에 적재하고, 아린 6/15~17 시퀀스를 리플레이 골든으로 고정해 '3일 연속 음식날 0' 회귀 게이트화.
요구 캡 발동 시 사유(연속휴지/예산소진/구조프레임)를 context.brain 또는 cron_runs.issues에 기록. 아린 다회차 시퀀스 리플레이에서 비-food 주 연속 food날이 cooldownDays를 넘지 않음을 단언하는 골든 테스트.
구현 route.ts:713 letterCtx.brain에 clampUseFood reason 부기(brainPick.why에 ` [캡:${reason}]` 또는 별도 필드). route.ts:692 인근 issues push 패턴 재사용해 캡 발동 시 `issues.push('food캡 ${nickname}: ${reason}')`. 테스트: 기존 tests/coach-hybrid-rotation.test.ts 패턴 차용해 14일 시퀀스 시뮬(planFor+clampUseFood+recentBrainUseFood 누산)로 '비-food 주 연속 useFood=true ≤ COOLDOWN_DAYS' 단언. SYSTEM_BRAIN 호출은 모킹(useFood=true 고정 입력)해 캡이 결정론으로 잘리는지 검증.
파일 app/api/cron/coach/route.ts · tests/coach-food-cadence.test.ts
유즈케이스 아린 6/15~17 리플레이 → 6/17 useFood=false·issues에 'food캡 아린: 연속 food 휴지' → 회귀 시 테스트 레드.
테스트
  • replay_nonfood_week_no_3_consecutive_food_days
  • cap_reason_recorded_in_context
  • cap_firing_pushed_to_issues
  • food_week_allows_consecutive_food
DoD 리플레이: 두뇌가 매일 useFood=true를 요청해도 비-food 주 발행 결과 연속 food날 ≤ 2(COOLDOWN_DAYS). 캡 발동 편지의 context에 사유 문자열 존재. 어드민 issues에 'food캡' 라인 노출.
의존: C-05, C-06, C-04 · 규모 M · 우선순위 P1
⚠ 리플레이가 LLM 실콜이면 비결정·느림 → 두뇌 모킹 필수. 골든이 너무 타이트하면 정상 food 주 예외까지 잡을 수 있어 'food 주는 연속 허용' 대조 케이스 동반.

EPIC D — target_pool 두 트랙 확장 (Supply/Challenge Portfolio) D2

현재 주간 닻의 후보(cands = homeMissing∪missing∪refused, coachWeekly.ts:192)가 '결핍군(supply)'만이라 결핍 적은 아이는 target_pool이 1~2개뿐이고(slice(0,4) 무용지물), yellow(조금부족)·challenge(사촌·공출현 신규 식품)는 후보에 없어 '음식 목표 ~3종 보장'이 깨진다. 이 EPIC은 후보 산출을 supply(결핍·yellow) + challenge(verifiedCousinsOf/strongPairsOf 신규 식품) 두 트랙으로 확장하고, weekly_plans.reco_balance(supply/challenge 비율)를 닻에 영속, 일간 recoTrack(타깃유지·경로회전)으로 풀 안에서 매일 다른 음식을 돌려 결핍 적은 아이도 풀이 3종으로 차게 만든다. EPIC A의 닻 게이트(brain override가 닻 풀을 보존)가 선행 — 풀을 넓혀도 두뇌가 무시하면 무효.

D-01 yellow(조금부족) 식품군을 후보로 — challengeCandidates supply 트랙 1차 확장코드규모 M

목적 근본원인(target_pool이 1~2개뿐): cands가 결핍군(missing=red)만이라 yellow(조금부족)군이 후보에서 누락. nutrition.computeGroupSignals는 이미 green/yellow/red 3단계를 산출하지만(nutrition.ts:208-216) coachWeekly.candidateTargets(coachWeekly.ts:192)는 fg.missing(=red 전부) + homeFg.missing + refused만 받는다. yellow군 대표 식재료를 supply 후보에 더해 풀을 채운다.
요구 결핍이 적은(red 0~1군) 아이도 supply 트랙에서 yellow군 대표 식재료가 후보에 들어와 음식 목표가 1종 이상 추가로 확보된다. 과일·유제품(간식 채널)은 supply 끼니 후보에서 제외(SNACK_CHANNEL 정합).
구현 lib/coachRecos.ts에 신규 export `supplyCandidates(groupSignals, opts)`: nutrition.computeGroupSignals가 반환하는 GroupSignal[](nutrition.ts:189)을 받아 level!=='green'인 군을 MEAL_GROUPS(coachRecos.ts:103)로 필터(과일 제외)하고 buildIngredientPool(coachRecos.ts:130-161)과 동일한 심각도 정렬(red 0 vs yellow 10 + vegBonus + weeklyEst, line 138)로 정렬 후 각 군의 GROUP_INGREDIENTS[group] 대표를 식품군명 단위로 반환(예 ['콩류','기타채소']). cron route.ts에서 이미 계산되는 computeGroupSignals(homeDays.length?homeDays:byDay, catOf).signals(route.ts:651)를 재사용해 호출. candidateTargets(coachWeekly.ts:192)에 yellowGroups를 인자로 받아 all 집합에 union(중복 제거는 기존 Set 유지).
파일 lib/coachRecos.ts · lib/coachWeekly.ts · app/api/cron/coach/route.ts
유즈케이스 아린 실측: 집40끼니 확신 liked 0개·결핍군도 빈약 → 현재 target_pool 1~2개. yellow군(예 콩류 weeklyEst 2~4)을 supply에 더하면 콩류가 후보에 들어와 두부 목표 확보.
테스트
  • D-01-1 supplyCandidates: yellow군만 있는 아이도 후보 비지 않음
  • D-01-2 supplyCandidates: green군은 제외
  • D-01-3 supplyCandidates: 과일(SNACK_CHANNEL)은 끼니 후보에서 제외
  • D-01-4 supplyCandidates: 심각도 정렬 red>yellow·채소 우선
  • D-01-5 candidateTargets: yellowGroups union 후 결핍 적은 입력에서 후보 1+ 보장
DoD 결핍군 0~1개인 합성 입력에서 supplyCandidates가 yellow군 1개 이상 반환하고 candidateTargets 결과 길이가 기존 대비 증가. vitest D-01-* 전부 green.
의존: 없음 · 규모 M · 우선순위 P0
⚠ 식품군명(콩류)과 거부음식명(치킨)이 한 풀에 섞이면 planFromWeekly deficit set 매칭(coachWeekly.ts:328-329)에서 식품군명만 catOf로 매핑됨 — 기존과 동일 동작이라 회귀 없음. 단 yellow를 과하게 넣으면 풀이 yellow로만 차 결핍 우선순위가 흐려질 수 있어 D-04에서 supply 상한 적용.

D-02 challenge 트랙 — 사촌(verifiedCousinsOf)·공출현(strongPairsOf) 신규 식품 후보 산출코드규모 M

목적 근본원인('challenge(사촌/공출현 신규)는 후보에 없음'): 결핍이 거의 없는 균형 잡힌 아이는 supply 트랙이 비어 풀이 0~1개가 된다. buildIngredientPool은 이미 mode==='challenge'에서 cousinsOfLiked()를 쓰지만(coachRecos.ts:143,151-153) 이는 '일일 추천 식재료' 산출이지 '주간 후보 풀(candidateTargets)'에 반영되지 않는다. challenge 후보를 candidateTargets에 정식 트랙으로 추가.
요구 잘 먹는 식재료(liked)의 verifiedCousinsOf 중 아직 안 먹고(likedSet 제외) MEAL_GROUPS에 속하는 신규 식품을 challenge 후보로 산출. 같은 식품군 중복(돼지고기→닭고기) 방지를 위해 앵커와 다른 식품군 사촌만 통과(근본원인 [12] '단백질↔단백질' 정합). 결핍 적은 아이도 challenge로 풀이 ~3종까지 차게 한다.
구현 lib/coachRecos.ts에 신규 export `challengeCandidates(likedIngredients, opts?)`: 기존 buildIngredientPool의 cousinsOfLiked() 로직(coachRecos.ts:143)을 재사용하되 — for lk in liked.slice(0,8): for c in verifiedCousinsOf(lk)(foodGraph.ts:54): groupOfIngredient(c.nm)!=null && MEAL_GROUPS.includes(group) && !likedSet.has(c.nm) && groupOfIngredient(c.nm)!==groupOfIngredient(lk)(같은군 사촌 배제). strongPairsOf(foodGraph.ts:49)에서 likedSet 밖 cross-group pair도 보조 후보로(strength>=PAIR_MIN_STRENGTH 이미 보장). 반환은 식재료명 배열(콩류 대표 등). candidateTargets(coachWeekly.ts:192)에 challengeCands 인자 추가해 supply 뒤에 append(supply 우선·challenge는 풀 채우기용). cron route.ts:520 runWeeklyPlanning 호출부에 challengeCands: challengeCandidates(likedIng) 추가(likedIng는 route.ts:337에서 이미 산출).
파일 lib/coachRecos.ts · lib/coachWeekly.ts · app/api/cron/coach/route.ts
유즈케이스 아린: liked에 소고기·돼지고기류가 있으면 same-group 닭고기는 배제, cross-group 사촌(예 두부·시금치)만 challenge로 → 단백질↔단백질 괴식 차단하며 풀 확장.
테스트
  • D-02-1 challengeCandidates: liked의 cross-group 사촌만 반환(같은군 사촌 배제)
  • D-02-2 challengeCandidates: 이미 먹는 식재료(likedSet) 제외
  • D-02-3 challengeCandidates: 비-MEAL_GROUP(과일/간식) 사촌 제외
  • D-02-4 challengeCandidates: liked 없으면 빈 배열(graceful)
  • D-02-5 candidateTargets: 결핍 0이어도 challenge로 후보 1+ 확보
DoD 결핍 0·liked만 있는 합성 입력에서 challengeCandidates가 cross-group 신규 식품 1개 이상 반환하고 candidateTargets가 비지 않음. 같은 식품군 사촌은 절대 미포함(D-02-1). vitest green.
의존: D-01 · 규모 M · 우선순위 P0
⚠ verifiedCousinsOf는 데이터상 same-group 비율이 높음(confirmed [12]: same 10 vs cross 2). cross-group만 통과시키면 challenge 후보가 희박할 수 있음 → 그 경우 strongPairsOf cross-group으로 보강(implSpec 반영). likedIngredients가 노이즈(아린 미상 80/86)면 엉뚱한 사촌 — 단 D-02는 후보 풀 확장이고 최종 타깃은 닻 종합·planFromWeekly deficit 게이트가 한 번 더 거름(coachWeekly.ts:329).

D-03 candidateTargets 두 트랙 병합 + 음식 목표 ~3종 보장 로직코드규모 M

목적 근본원인(slice(0,4)지만 후보가 결핍군만이라 1~2개): candidateTargets(coachWeekly.ts:192-198)·runWeeklyPlanning의 pool 산출(coachWeekly.ts:235 slice(0,4))·coldSynth(coachWeekly.ts:270 slice(0,4))을 supply+challenge 병합 + 최소 보장으로 재구성. '목표 2~3개'(프롬프트 137행)는 커리큘럼 goals지 음식이므로, 음식 target_pool도 독립적으로 ~3종을 보장한다.
요구 target_pool은 supply 우선 + challenge 보충으로 최소 2종·이상적 3종을 채운다(결핍 풍부하면 supply만으로 4까지). mission_target은 여전히 supply(결핍) 최우선에서 선택(challenge가 주 타깃을 빼앗지 않음). 후보 전무(supply·challenge 모두 0)일 때만 coldSynth 관찰 닻.
구현 lib/coachWeekly.ts candidateTargets를 supply+challenge 병합으로 재작성: `const supply=[...homeMissing,...missing,...yellowGroups,...refused]; const challenge=challengeCands||[]; const all=[...new Set([...supply,...challenge])]`. stalledTarget 후행 로직(line 194-196) 유지. runWeeklyPlanning(line 235): `const pool = target ? dedup([target, ...supplyRest, ...challengeRest]).slice(0,4) : []` — supply를 challenge보다 앞에 두어 결핍 우선. 신규 헬퍼 `ensureFoodTargets(supply, challenge, min=2, cap=4)`로 풀이 min 미만이면 challenge로 채움. WeeklyInput에 yellowGroups?:string[]·challengeCands?:string[] 필드 추가(coachWeekly.ts:144-157). coldSynth(line 261-273)도 동일 병합 사용.
파일 lib/coachWeekly.ts
유즈케이스 아린(결핍 빈약): supply 콩류 1 + challenge 두부·시금치 2 → target_pool 3종, mission_target=콩류(supply 우선).
테스트
  • D-03-1 결핍 3군 아이: target_pool=4 (supply만으로 충족·challenge 미사용)
  • D-03-2 결핍 1군 아이: supply 1 + challenge 2 = pool 3종 보장
  • D-03-3 결핍 0 아이: challenge만으로 pool 2+ 채움
  • D-03-4 mission_target은 항상 supply(결핍)에서 우선 선택
  • D-03-5 supply·challenge 모두 0 → coldSynth 관찰 닻(빈 pool)
  • D-03-6 stalledTarget 후행 정렬 회귀(기존 동작 유지)
DoD 결핍 군 수 0/1/3 세 시나리오에서 target_pool 길이가 각각 2+/3/4이고 mission_target은 결핍 우선. coldSynth도 동일 병합. vitest D-03-* green, 기존 coachWeekly 테스트 회귀 0.
의존: D-01, D-02 · 규모 M · 우선순위 P0
⚠ challenge를 pool에 넣으면 planFromWeekly deficit 게이트(coachWeekly.ts:329 deficit.has(t))에서 challenge 식재료가 reds/missing에 없어 걸러질 수 있음 → D-05에서 deficit set에 challenge 풀을 포함하도록 보강 필요(의존 관계). cap 4 초과 방지로 풀 폭주 차단.

D-04 weekly_plans.reco_balance 컬럼 — supply/challenge 비율 닻 영속SQL규모 S

목적 근본원인 EPIC 범위: target_pool이 supply만이라 추천이 결핍 보강 일변도. reco_balance(supply N·challenge M)를 닻에 기록해야 일간 recoTrack 회전(D-06)이 '오늘은 supply, 내일은 challenge'를 결정론으로 돌릴 수 있고 어드민이 두 트랙 분포를 검증한다. 기존 weekly_plans(sql/2026-06-09_weekly_plans.sql:7)에 컬럼 추가.
요구 weekly_plans에 reco_balance jsonb 컬럼 추가(예 {"supply":["콩류"],"challenge":["두부","시금치"],"ratio":"1:2"}). 컬럼 미적용 환경에서도 upsert가 죽지 않게(route.ts:548 기존 양분기 패턴) legacy upsert에서 제외. RLS는 기존 정책(부모 select 없음) 유지.
구현 신규 sql/2026-06-18_reco_balance.sql: `alter table public.weekly_plans add column if not exists reco_balance jsonb;` + 주석(supply=결핍·yellow 보급군, challenge=사촌·공출현 도전). WeeklyAnchor 타입(coachWeekly.ts:45-52)·WeeklySynthesis(coachWeekly.ts:158)에 reco_balance/recoBalance 필드 추가. runWeeklyPlanning·coldSynth가 {supply, challenge, ratio} 산출해 반환. cron route.ts:537-543 row에 reco_balance: synth.recoBalance 추가, route.ts:551-552 legacy upsert 분기의 delete 목록에 reco_balance 추가(컬럼 미적용 시 폴백). admin page(app/admin/[childId]/page.tsx:104 select)에 reco_balance 추가.
파일 sql/2026-06-18_reco_balance.sql · lib/coachWeekly.ts · app/api/cron/coach/route.ts · app/admin/[childId]/page.tsx
유즈케이스 아린 W26 닻: reco_balance={supply:[콩류], challenge:[두부,시금치], ratio:1:2} → 어드민이 '결핍 보급 1·도전 2'로 검증.
테스트
  • D-04-1 runWeeklyPlanning: recoBalance.supply/challenge 분리 기록
  • D-04-2 coldSynth: recoBalance 산출(LLM 없이도)
  • D-04-3 recoBalance.ratio 문자열 포맷 정상
DoD SQL 실행 후 닻 upsert가 reco_balance를 채우고, 컬럼 없는 환경에서도 legacy 폴백으로 닻 저장 성공(route.ts upsert error 0). 어드민 패널에 supply/challenge 분포 노출. vitest D-04-* green.
의존: D-03 · 규모 S · 우선순위 P1
⚠ SQL은 이사님 수동 실행(route.ts:545 사고 사례 — 컬럼 미적용 시 조용한 거부). 반드시 legacy upsert 폴백에 reco_balance를 delete 목록에 추가해야 닻이 영영 저장 안 되는 회귀 방지.

D-05 planFromWeekly deficit 게이트에 challenge 풀 포함 — challenge 타깃 일간 통과코드규모 M

목적 근본원인 보강: planFromWeekly(coachWeekly.ts:327-329)의 deficit set은 signals.reds∪missing∪homeMissing∪refused로만 구성돼 challenge 식재료(사촌 신규)가 항상 걸러진다(deficit.has(t)===false) → D-03이 challenge를 pool에 넣어도 일간 편지에 도달 못함. challenge 트랙이 닻에 잠겼으면 deficit 게이트를 통과시킨다.
요구 anchor의 target_pool/reco_balance.challenge에 든 식재료는 deficit set에 없어도 planFromWeekly에서 유효 타깃으로 인정(닻이 의도적으로 challenge로 잠근 것이므로). 단 supply 결핍이 여전히 남아 있으면 supply 우선(challenge는 supply 소진 후·또는 recoTrack 회전일에만).
구현 lib/coachWeekly.ts planFromWeekly: line 328 deficit set 구성 후, line 329 pool 필터를 `[anchor.mission_target,...target_pool].filter(t => deficit.has(t) || challengeSet.has(t))`로 완화. challengeSet=new Set(anchor.reco_balance?.challenge||[]). frame 선택(line 336-338)에서 challenge 타깃은 결핍이 아니므로 'new-refusal'/'home-daycare-gap'이 아닌 'nutrient-gap'(=도전 노출 프레임)으로 고정. recoTrack 인자(D-06)가 'supply'면 challengeSet 무시(supply 타깃만), 'challenge'면 challenge 우선.
파일 lib/coachWeekly.ts
유즈케이스 아린: 결핍 콩류 소진 후 challenge 시금치(소고기 사촌)를 일간 타깃으로 — plateau 대신 도전 노출 편지 발행.
테스트
  • D-05-1 challenge 타깃이 deficit에 없어도 planFromWeekly가 유효 타깃으로 산출
  • D-05-2 supply 결핍 남으면 supply 우선(challenge 후순위)
  • D-05-3 challenge 타깃 frame=nutrient-gap(거부 프레임 아님)
  • D-05-4 challenge 풀 비면 기존 deficit-only 동작 회귀
DoD challenge만 든 합성 닻에서 planFromWeekly가 plateau로 빠지지 않고 challenge 타깃 plan을 반환(D-05-1). supply 우선순위 유지(D-05-2). 기존 deficit-only 닻 회귀 0.
의존: D-03, D-04 · 규모 M · 우선순위 P0
⚠ challenge 타깃이 사촌이라 본문 추천(buildRecoFacts)이 같은군 곁들임 괴식을 낼 위험 — 단 challengeCandidates(D-02)가 이미 cross-group만 통과시키고 buildRecoFacts 정합은 별도 EPIC. challenge를 deficit으로 인정하되 mirrorBlock(영양거울)이 '부족'으로 단정하지 않게 frame=nutrient-gap의 도전 톤 유지.

D-06 일간 recoTrack 회전 — 타깃유지·경로(supply↔challenge)회전코드규모 M

목적 근본원인 EPIC 범위('일간 recoTrack 회전(타깃유지·경로회전)'): supply만 매일 반복하면 결핍 보강만 잔소리(아린 6/15콩·16과일·17치킨 식 연속 음식날). recoTrack을 요일·이력 기반으로 supply/challenge를 돌려, 같은 주 안에서 '오늘은 결핍 보급, 내일은 도전 노출'로 음식 추천 각도를 회전한다(MEMORY: 다축 설계 '타깃유지·경로회전').
요구 recoTrack ∈ {supply, challenge}를 결정론(daySeed·dow·ledger)으로 산출해 planFromWeekly에 전달. mission_target(supply 결핍)은 주 내내 유지하되 recoTrack==='challenge'인 날은 challenge 풀에서 타깃을 골라 노출 각도를 바꾼다. supply 결핍이 심각(red 2+)하면 challenge 회전 빈도를 낮춤(보급 우선).
구현 lib/coachWeekly.ts planFromWeekly args에 recoTrack?:'supply'|'challenge' 추가. 신규 헬퍼 `pickRecoTrack(reco_balance, ledger, dow, daySeed)`: challenge 풀 비면 항상 'supply'; supply red 2+면 supply:challenge=2:1(dow%3===2일만 challenge); 균형이면 1:1 교대. cron route.ts:604 planFromWeekly 호출부에 recoTrack 산출 전달. planFromWeekly 내 pool 선택(line 329-334)에서 recoTrack==='challenge'면 challengeSet 우선, 'supply'면 deficit 우선. ledgerPatch에 lastRecoTrack 적재해 연속 같은 트랙 방지. WeeklyLedger 타입(coachWeekly.ts:36-39)에 lastRecoTrack?:string 추가.
파일 lib/coachWeekly.ts · app/api/cron/coach/route.ts
유즈케이스 아린 W26: 월=supply(콩류 두부), 수=challenge(시금치 노출), 금=supply — 같은 닻 안에서 결핍 보급과 도전 노출이 회전해 '매일 같은 음식 잔소리' 해소.
테스트
  • D-06-1 pickRecoTrack: challenge 풀 비면 항상 supply
  • D-06-2 pickRecoTrack: red 2+면 challenge 빈도 낮음(2:1)
  • D-06-3 pickRecoTrack: 균형이면 supply↔challenge 교대
  • D-06-4 planFromWeekly: recoTrack=challenge면 challenge 타깃, supply면 결핍 타깃
  • D-06-5 같은 트랙 직전 연속 방지(lastRecoTrack 회피)
DoD 한 주 7일 시뮬에서 recoTrack이 supply/challenge로 회전하고(결핍 심각 시 supply 비중↑), challenge 풀 없는 아이는 항상 supply. mission_target은 주 내내 불변. vitest D-06-* green.
의존: D-05 · 규모 M · 우선순위 P1
⚠ EPIC A의 두뇌 override(route.ts:629)가 planFromWeekly plan을 폐기하면 recoTrack 회전이 무력화(근본원인 [0]) — A 닻 게이트가 선행. recoTrack을 ledger에 적재하므로 일요일 synth가 ledger를 DEFAULT로 덮으면 초기화될 수 있음(route.ts:540 carriedStall 패턴 참고해 이월 보존).

D-07 weekly_plans.status supersede — 동시 active 닻 다행 차단코드규모 S

목적 근본원인('weekly_plans.status supersede 없이 W22~W25 4행 동시 active'): 닻 풀을 넓혀도 어느 주 닻이 진짜인지 모호하면 supply/challenge 트랙 검증·이월이 깨진다. 새 주 닻 종합 시 직전 active 닻을 superseded로 내려 단일 active를 보장한다.
요구 synthAndStoreAnchor가 새 주 닻을 active로 저장할 때 같은 child의 그 이전 week_key active 행들을 status='superseded'로 일괄 갱신. planFromWeekly/loadAnchor는 이번 주(weekKey) 닻만 읽으므로 동작 무변경(이력 정합·어드민 가시화 목적). cold_synth/degraded는 supersede 대상에서 제외(관찰 닻 보존).
구현 app/api/cron/coach/route.ts synthAndStoreAnchor(route.ts:500-557) upsert 성공 후: `await supabase.from('weekly_plans').update({status:'superseded', updated_at:...}).eq('child_id',cid).lt('week_key',wk).eq('status','active')`. status enum에 'superseded' 허용(sql/2026-06-09_weekly_plans.sql:11 주석 text라 스키마 변경 불필요). loadAnchor(route.ts:558)는 eq('week_key',wk) 그대로라 영향 없음. admin select(page.tsx:104)는 이미 status 노출.
파일 app/api/cron/coach/route.ts
유즈케이스 아린: W22~W25 4행 active → W26 종합 시 W22~W25 superseded, W26만 active로 정리.
테스트
  • D-07-1 새 닻 active 저장 시 직전 active→superseded (통합/모킹)
  • D-07-2 cold_synth 닻은 supersede 대상 제외
  • D-07-3 이번 주 닻 로드는 supersede 영향 없음
DoD 닻 종합 후 같은 child에 active 행이 정확히 1개(이번 주). cold_synth는 보존. 어드민 닻 패널에 superseded 표기. vitest/통합 D-07-* green.
의존: 없음 · 규모 S · 우선순위 P1
⚠ QA date 시뮬(route.ts:564 일요일 분기 차단)에서 과거 주 재실행 시 supersede가 라이브 active를 잘못 내릴 수 있음 → !qp.get('date') 가드를 supersede에도 적용(route.ts:564 동일 패턴). best-effort(실패해도 닻 동작 무영향) try/catch.

D-08 온디맨드(앱) 경로 challenge 풀 정합 + recoIng 회전에 challenge 반영배선규모 M

목적 근본원인 일관성: cron(야간)과 온디맨드(app/api/coach) 두 경로가 같은 닻 풀을 봐야 한다. 또 일간 추천 식재료 회전(route.ts:651-660 buildIngredientPool→recoIng)이 supply 풀만 돌려 challenge 트랙이 본문 추천에 안 닿는다. recoTrack==='challenge'인 날 recoIng도 challenge 풀에서 회전.
요구 recoTrack 회전 결과가 일간 추천 식재료(recoIng)·bridgeFacts(route.ts:662)에 반영돼, challenge 날엔 challenge 식재료(cross-group 사촌)가 본문 추천 앵커가 된다. 온디맨드 경로도 동일 닻·recoTrack을 읽어 cron과 byte 정합(또는 동일 산출).
구현 app/api/cron/coach/route.ts:651-660 _alignGroups 산출에 recoTrack 분기 추가: recoTrack==='challenge'면 _alignGroups를 anchor.reco_balance.challenge의 식재료 소속군으로, supply면 기존(planCtx.target||gMissing||gHomeMissing). buildIngredientPool 호출(route.ts:651)에 challenge 모드 강제 옵션 전달 또는 challenge 풀을 직접 recoPoolArr로 사용. 온디맨드 경로(app/api/coach/route.ts — grep로 위치 확인 필요) 동일 recoTrack 산출 호출. recentRecoIng 회전(route.ts:660) 유지(연속 추천 방지).
파일 app/api/cron/coach/route.ts · app/api/coach/route.ts
유즈케이스 아린 challenge 날: recoIng=시금치(소고기 사촌·cross-group) → 본문 '잘 먹는 소고기에 데친 시금치 곁들이기' 도전 노출, 단백질↔단백질 회피.
테스트
  • D-08-1 recoTrack=challenge 날 recoIng이 challenge 풀에서 선택
  • D-08-2 recoTrack=supply 날 recoIng이 결핍군에서 선택(회귀)
  • D-08-3 challenge 풀 비면 supply 폴백(graceful)
  • D-08-4 온디맨드·cron recoTrack 동일 입력서 동일 산출
DoD challenge 회전일에 bridgeFacts 앵커가 cross-group challenge 식재료가 되고, supply 날은 결핍군 식재료 유지. 온디맨드/cron 동일. vitest D-08-* green, next build·tsc 0.
의존: D-06 · 규모 M · 우선순위 P2
⚠ 온디맨드 경로 파일 경로(app/api/coach/route.ts) 정독 필요 — challenge 산출이 cron에만 있고 온디맨드에 없으면 두 편지가 갈림. recoIng 회전을 challenge로 바꾸면 buildRecoFacts 같은군 곁들임 위험(별도 EPIC 정합)이라 cross-group 보장(D-02) 전제 필수.

EPIC E — 영양평가 편지 배선 (Nutrition→Letter Wiring) D2

식품군 거울은 coach.ts:621-630 mirrorBlock으로 손 프롬프트에 항상 주입되나 '포함 강제'가 없어 손 LLM이 누락 가능(어제 치킨편지 실측 누락)하고, BMI(bmiBand)·성장곡선 추종도(growthTracking)는 route.ts에서 bmiBandFor가 snackStance에만 쓰여 편지 파이프라인에 아예 미배선이다. 이 EPIC은 ① BMI/성장 신호를 P10·비수치 문구로 CoachContext.nutrition(growthMirror)으로 산출→base→growthBlock으로 손에 주입하고 must-weave(결정론 포함검증+검증자 항목)로 강제하며, ② 식품군 거울에도 동일 포함검증을 걸고, ③ 성장 신호 격주·2주연속금지 케이던스와 간식 stance축 분리를 적용한다. 영양평가 누락은 배선 누락이지 뇌 문제가 아니다.

E-01 growth_logs 기준+최신 2점 추출 — growthBaseline 보관코드규모 S

목적 성장곡선 추종도(growthTracking)는 baseline·current 2점이 필요한데 route.ts:194-195가 latestGrowth(최신 1행)만 보관해 추종도 산출 자체가 불가 — 성장 신호 미배선의 데이터 선행 근본원인(confirmed #5·#15·#25)을 봉합.
요구 자녀별 growth_logs 시계열에서 '첫 측정(기준)+최신(비교)'을 각각 measured_on·height_cm·weight_kg와 함께 보관한다. baseline===latest(측정 1회)인 자녀는 추종도 불가(이후 원자가 정보부족 처리).
구현 route.ts:194 grRows select는 이미 child_id,measured_on,height_cm,weight_kg를 ascending:false로 받는다. 195 forEach에서 latestGrowth만 채우는 대신 growthByChild[cid] 배열에 전부 push(이미 measured_on desc), 루프 후 firstGrowth[cid]=배열 마지막(=가장 오래된=기준), latestGrowth[cid]=배열 첫(=최신)로 분리. firstGrowth는 {measured_on,height_cm,weight_kg}, latestGrowth는 기존 shape 유지(bmiBandFor 213-217이 그대로 읽음). grErr(테이블 없음) 시 둘 다 빈 맵 — 기존 안전처리 유지.
파일 web/app/api/cron/coach/route.ts
유즈케이스 아린: growth_logs 시계열 첫 측정으로 채널 잡고 최신과 비교(MEMORY 6fd1629 앱 영양점수 모달과 동일 baseline=첫·current=최신 규약).
테스트
  • firstGrowth=가장 오래된 측정·latestGrowth=최신으로 분리된다
  • 측정 1회 자녀는 first===latest
  • growth_logs 0행 자녀는 둘 다 미보관(undefined)
DoD firstGrowth·latestGrowth 두 맵이 자녀별 채워지고 측정 2회+ 자녀에서 first.measured_on < latest.measured_on. tsc·vitest 그린.
의존: 없음 · 규모 S · 우선순위 P0
⚠ 정렬 가정 오류 시 baseline/current 뒤바뀜 추종도 부호 반전. measured_on asc/desc 단정 테스트로 봉합.

E-02 growthTrackToPhrase — BMI/성장곡선 → P10 비수치 한 구절 헬퍼코드규모 M

목적 BMI(bmiBand/bmiPercentile)·성장곡선 추종도(growthTracking)를 손에 넘길 '한 구절'로 문구화하는 단일 진입점이 없음. 체중·살·다이어트·BMI·숫자·등급 단어 없이(유아 체중 민감) 방향·추세만 표현해 confirmed #5·#15·#25 '비수치 표현' 보정 요구 충족.
요구 (band, heightTrack, weightTrack) → growthMirror 문자열(없으면 null) 순수 함수. 추종도 status가 '주의/경고'면 해당 metric을 부드럽게 환기(키 또는 체중이 자기 곡선에서 천천히 처지는 신호), '양호/정보부족'이면 침묵. 체중·살·BMI 단어 금지.
구현 lib/growth-reference.ts에 export function growthTrackToPhrase(args:{band:BmiBand|null; height:GrowthTrack|null; weight:GrowthTrack|null}):string|null 신설. growthTracking(283행) 반환 GrowthTrack.status('양호'|'주의'|'경고'|'정보부족')·metric으로 분기. 경고>주의 우선 1개만 선택. 문구 예 주의='최근 키(체중)가 또래 곡선을 조금 천천히 따라가는 듯해, 식사 양·균형을 한 번 살펴보면 좋아요'(숫자·퍼센타일·BMI 단어 0). isShortStature(199)는 절대위치라 추종도와 별개로 미사용(채널 추종이 핵심). bmiPhrase/heightPhrase(180·190)는 '묵직/가벼움' 표현이라 편지엔 미사용·stance 방향만 참고. null=신호 없음.
파일 web/lib/growth-reference.ts
유즈케이스 아린: 키 추종 양호+체중 주의면 '체중이 또래 곡선을 조금 천천히 따라가는 듯' 한 구절만, 키 언급은 침묵.
테스트
  • zDrift<=-1.34(경고)→경고 톤 문구
  • zDrift -0.67~-1.34(주의)→주의 톤
  • status 양호→null(침묵)
  • 정보부족(gap<1mo)→null
  • 반환 문구에 BMI·살·다이어트·%·점수·등급 어휘 0
  • 키·체중 둘 다 경고면 경고 1개만
DoD growthTrackToPhrase 단위테스트 전 케이스 통과·금지어휘 정규식 검사 그린. tsc 그린.
의존: E-01 · 규모 M · 우선순위 P0
⚠ 문구가 수치/체중 단어 누수 시 유아 민감 위반. 금지어휘 정규식 테스트로 가드.

E-03 cron에서 growthMirror 산출 → base 주입 (격주 케이던스 게이트 포함)배선규모 M

목적 bmiBandFor(213-225)가 snackStance에만 쓰이고 성장 추종도는 호출조차 안 됨 — route.ts에서 growthTracking(첫·최신 2점)+band를 growthMirror 문구로 만들어 base에 전달해야 편지에 닿는다(confirmed #5·#15·#25 핵심 배선). 측정 저빈도라 매일 노출은 잔소리 → 격주·2주연속금지 케이던스(MEMORY '3대영양소 macro 잔소리 격주').
요구 재사용 아닌 발행 경로(route.ts:446 else)에서: ① firstGrowth/latestGrowth+meta(sex·birth_year·birth_month)로 height/weight growthTracking 산출, ② band=bmiBandFor 재사용, ③ growthTrackToPhrase로 문구화, ④ 케이던스 게이트(E-09) 통과한 경우에만 base.growthMirror 세팅·context.growthShown 적재.
구현 route.ts:347-348 snackBand 산출 직후에 growthTracking 호출 추가: height growthTracking({value:firstGrowth.height_cm, ageMonths:monthsAt(first.measured_on)},{value:latest.height_cm, ageMonths:monthsAt(today)}, sex,'height'), weight 동일. ageMonths는 birth_year·birth_month로 산출(bmiBandFor 221-222 방식 재사용). growthTrackToPhrase로 phrase. 케이던스: recentSnackDates(162) 패턴 모방—recentGrowthDates 맵 신설(166-176 recentLetters forEach에서 ctx.growthShown 적재), E-09 growthCadenceOk 통과 시에만 base(671-682)에 growthMirror 추가·growthShownCtx=!!phrase. context 저장부(696+ letterCtx)에 growthShown 추가.
파일 web/app/api/cron/coach/route.ts
유즈케이스 아린: 체중 추종 주의 신호가 2주에 1번만 편지에 등장, 노출한 주는 다음 주 자동 생략.
테스트
  • 측정 2회+·status 주의 자녀에 base.growthMirror 세팅
  • 최근 13일 내 성장 멘트 노출 이력 있으면 오늘 생략
  • 측정 1회 자녀는 growthMirror=null
  • growthShown context 적재돼 다음 날 케이던스가 읽는다
  • growth_logs 0행이면 growthMirror=null·에러 없음
DoD 라이브 경로에서 status 주의/경고+케이던스 통과 시 base.growthMirror 채워지고 격주 비번/최근노출 시 생략됨이 테스트로 입증. growth_logs 부재 안전. tsc·vitest 그린.
의존: E-01, E-02, E-09 · 규모 M · 우선순위 P0
⚠ 케이던스가 너무 빡빡하면 영원 침묵·느슨하면 매일 잔소리. 임계는 snack 쿨다운(2일)과 별도 상수(E-09).

E-04 LetterInput.growthMirror 필드 + growthBlock 손 프롬프트 신설코드규모 S

목적 base.growthMirror(E-03)가 와도 coach.ts buildLetterUser가 읽는 필드·프롬프트 블록이 없으면 손에 안 닿음. mirrorBlock(621-630)과 동형의 growthBlock으로 '한 구절 반드시 녹여라'(단 숫자·등급·체중·살·BMI 단어 금지)추가(confirmed #25 (a) 권고).
요구 LetterInput에 growthMirror?:string|null 추가. 값이 있을 때만 buildLetterUser가 growthBlock(mirrorBlock 톤·P10·강요/숫자/등급/체중·살·BMI 단어 금지)을 프롬프트에 끼움. 없으면 블록 자체 비워 토큰·잔소리 0.
구현 lib/coach.ts:206 mirror 필드 옆에 growthMirror?:string|null 추가(주석: 성장곡선/BMI 평가 한 구절·route가 growthTrackToPhrase로 산출). buildLetterUser 내 mirrorBlock(621-630) 직후 const growthBlock = b.growthMirror ? `\n[⚠️ 성장 신호 — 이 평가를 편지에 한 구절로 자연스럽게 녹여라(숫자·퍼센타일·점수·등급·체중·살·다이어트·BMI 단어 절대 금지·다그치지 말 것). 식사 양·균형을 부드럽게 살펴보자는 정보 제공 톤]\n${b.growthMirror}\n` : ''; 631 return 템플릿의 ${mirrorBlock} 뒤에 ${growthBlock} 삽입. safetyBlock(584)에 '성장/키/체중 추세는 숫자·BMI 없이 부드럽게만' 한 줄 보강.
파일 web/lib/coach.ts
유즈케이스 아린: 성장 주의 주에만 손 프롬프트에 성장 한 구절이 들어가고 비번 주엔 블록 자체가 사라짐.
테스트
  • growthMirror 있을 때 프롬프트에 성장 블록·해당 문구 포함
  • growthMirror null이면 성장 블록 미출력
  • 성장 블록 지시문에 숫자·등급·체중·살·BMI 금지 명시
DoD buildLetterUser가 growthMirror 유무에 따라 growthBlock 조건부 출력. 스냅샷/문자열 포함 테스트 그린. tsc 그린.
의존: E-03 · 규모 S · 우선순위 P0
⚠ 프롬프트 비대화. growthMirror 있을 때만 출력해 평소 토큰 0.

E-05 식품군 거울 must-weave — 결정론 포함검증→재생성 fixNotes코드규모 M

목적 mirrorBlock(621-630)이 '반드시 녹여라'라고 지시만 하고 composeLetter(763-836)에 결정론 포함검증이 없어 손 LLM이 누락 가능(어제 치킨편지 실측 누락·confirmed #6·#17). 발동 분기(allMiss/homeMiss/일반)에 맞는 키워드 포함을 코드가 확인해 누락 시 fixNotes로 1회 재생성.
요구 composeLetter가 발행 본문이 식품군 거울을 실제 담았는지 결정론으로 검사. mirror 분기가 allMiss/homeMiss(식품군명 존재)일 때 본문에 그 식품군명 중 하나 또는 '부족/드물'류 표현이 없으면 누락으로 판정→fixNotes 재생성(det·유사도 재통과 시에만 채택·S1).
구현 lib/coach.ts에 mirrorWoven(letter,b):boolean 신설 — mirrorBlock(621-630) phrase 분기 로직 재사용해 발동 분기 재계산(allMiss=missing.slice(0,3) / homeMiss / 일반). allMiss/homeMiss 분기면 해당 식품군명 배열 중 letter.includes 하나라도 OR /부족|드물|채워|다양/.test 면 woven, 일반 분기(둘 다 빈)는 always woven. composeLetter(816 유사도 가드 직후)에 if(!mirrorWoven(gen.letter,letterInput)&&timeLeft()){g=generateLetter({...letterInput,fixNotes:['식품군 거울(영양 다양성 평가) 한 구절 포함']}); if(mirrorWoven(g.letter)&&!detBad&&simBadness<1){gen=g;coachRegen=true;}} 추가. fixBlock(605-606)이 이미 fixNotes 프롬프트 반영.
파일 web/lib/coach.ts
유즈케이스 아린: 집 부족=콩류인데 손이 콩류 언급을 빠뜨리면 코드가 잡아 '콩류 한 구절 포함' 재생성.
테스트
  • allMiss 분기인데 본문에 식품군명·부족 없음→mirrorWoven false
  • 본문에 부족 식품군명 포함→true
  • 일반 분기→항상 true(재생성 안 함)
  • 누락 본문이 재생성으로 식품군 거울 포함(LLM mock)
  • 재생성 산출이 det 가드 실패면 미채택(S1)
DoD 식품군 거울 누락 본문이 1회 재생성으로 포함되고 일반 분기는 재생성 0. mirrorWoven 단위·composeLetter 통합 테스트 그린. tsc·vitest 그린.
의존: 없음 · 규모 M · 우선순위 P0
⚠ 키워드 검증이 어휘적이라 의역(비타민A채소→당근류) 시 오탐 재생성. /부족|드물|채워|다양/ OR 식품군명 OR 분기 식재료명까지 허용·재생성 1회·deadline 가드.

E-06 성장 거울 must-weave — growthMirror 포함검증→재생성코드규모 S

목적 E-03/E-04로 성장 신호가 주입돼도 손이 누락하면 식품군 거울과 같은 누락 사고가 성장에서 재발. growthMirror가 주입된 날만(케이던스 통과한 날) 포함을 강제.
요구 growthMirror가 base에 있는 날에 한해 발행 본문이 성장/키/체중·식사 양·균형 환기를 담았는지 검사하고 누락 시 fixNotes로 1회 재생성. growthMirror null인 날은 검사 0.
구현 lib/coach.ts에 growthWoven(letter,b):boolean — b.growthMirror 없으면 true(강제 없음), 있으면 /키|몸무게|체중|성장|곡선|또래|식사\s*양|균형|골고루/.test(letter)면 true(숫자·BMI는 본문 금지라 우회 어휘로 검사). composeLetter의 E-05 mirrorWoven 검증 블록 옆에 if(b.growthMirror&&!growthWoven(gen.letter)&&timeLeft()){재생성 fixNotes:['성장(키·체중 추세) 한 구절을 숫자 없이 포함']} 추가(채택 조건 동일·S1). E-05와 같은 재생성 콜에 묶어 LLM 콜 1회로 합칠 수 있으면 fixNotes 2줄 합침.
파일 web/lib/coach.ts
유즈케이스 아린: 성장 주의 주에 손이 성장 언급을 빠뜨리면 '식사 양·균형 한 구절 포함' 재생성.
테스트
  • growthMirror 있는데 본문에 성장/식사양/균형 없음→growthWoven false
  • 본문에 식사 양·균형 포함→true
  • growthMirror null→항상 true(검사 0)
  • 성장 누락 본문이 재생성으로 성장 한 구절 포함(mock)
DoD 성장 멘트 노출 결정된 날의 누락 본문이 재생성으로 보완되고 비노출 날은 재생성 0. growthWoven 테스트 그린.
의존: E-04, E-05 · 규모 S · 우선순위 P1
⚠ E-05와 합치지 않으면 누락 2종 동시 발생 시 재생성 2콜·deadline 압박. 가능하면 fixNotes 합산 1콜로.

E-07 검증자에 영양/성장 거울 항목 추가 + verifyFacts에 거울 phrase 전달코드규모 S

목적 SYSTEM_VERIFY(716-723)는 4개 항목만 검사하고 식품군/성장 거울 누락을 의미 수준에서 못 잡으며, verifyFacts(739-754)가 mirror/growth phrase를 검증자에 안 넘김 — 결정론 포함검증(E-05·E-06)의 의미 수준 백업(confirmed #6 ②).
요구 verifyFacts가 발동한 식품군 거울 phrase와 growthMirror를 '제공 데이터'에 포함. SYSTEM_VERIFY에 검사 항목 5(영양/성장 거울 누락: 제공된 거울 평가를 편지가 한 구절도 안 담으면 위반·단 숫자/등급 강요는 아님) 추가. 거울 누락은 '확실한 위반만' 원칙 유지.
구현 lib/coach.ts:739 verifyFacts에 두 줄 추가: b.growthMirror ? `성장 평가(한 구절 포함 기대): ${b.growthMirror}` : '', 와 식품군 거울 분기 phrase(E-05 mirrorWoven 로직에서 도출)를 `식품군 거울(한 구절 포함 기대): ${phrase}`. SYSTEM_VERIFY(716-723) [검사 항목]에 '5) 거울 포함: 제공 데이터에 식품군 거울/성장 평가가 있으면 편지가 그 취지를 한 구절이라도 담아야 한다(숫자·등급·강요는 오히려 위반)' 추가. verifyLetter(726-736) 인자 무변경.
파일 web/lib/coach.ts
유즈케이스 아린: 결정론 포함검증을 어휘 우회로 통과한 누락도 검증자가 의미 수준에서 한 번 더 포착.
테스트
  • verifyFacts 출력에 growthMirror·식품군 거울 phrase 포함
  • growthMirror null이면 성장 줄 미포함
  • SYSTEM_VERIFY 프롬프트에 거울 포함 검사 항목 5 존재
DoD verifyFacts가 두 거울 phrase를 검증자에 전달하고 SYSTEM_VERIFY가 5항목. tsc·기존 verify 테스트 그린(회귀 없음).
의존: E-04, E-05 · 규모 S · 우선순위 P1
⚠ 검증자가 거울 강요로 오버피팅해 숫자·등급 권하면 역효과. 항목 문구에 '숫자·등급·강요는 오히려 위반' 명시로 봉합.

E-08 간식 stance축과 성장 신호 충돌 게이팅 — '한 번에 하나' 보존코드규모 M

목적 snackStance(growth-reference.bmiBand→snack.ts:74)와 새 growthMirror가 같은 BMI band에서 동시에 방향 멘트를 내면 편지에 체격 관련 요구 2개가 겹쳐 '한 번에 하나' 위반·중복 잔소리. 간식 stance(체격→간식 교체)과 성장 추종(이탈→식사 양·균형)을 축 분리·동시 노출 게이팅.
요구 한 편지에서 간식 stance 멘트(snackText)와 성장 멘트(growthMirror)가 동시에 나가지 않게. 우선순위: 추종도 경고>주의(이탈)면 성장 우선·간식 stance 방향 줄 생략(초가공/간섭성 신호는 유지), 추종 양호/정보부족이면 기존 snack stance 그대로.
구현 route.ts:637-644 snackText 산출부에서 growthMirror가 세팅됐고(E-03) 그 근거가 추종도 주의/경고면 snackText의 stance 방향 줄을 끄도록. 방법 A(최소): snackEvalToPrompt(snack.ts:229) 호출 직전 snackEval.stance를 'maintain'으로 클론하면 stance 방향 줄(246-248)만 빠지고 초가공/간섭성은 유지(208 concern은 ultra/간섭성으로도 true). growthMirror 없으면 무변경. snackShownCtx는 초가공/간섭성 있으면 그대로. 주석으로 '성장 신호 발동 주엔 성장 한 구절만, 간식 방향 중복 억제·초가공만 환기' 명시.
파일 web/app/api/cron/coach/route.ts
유즈케이스 아린: 체중 추종 주의 주엔 '식사 양·균형 살펴보자'(성장)만 나가고, 같은 날 간식 '과자→좋은간식' 방향 줄은 생략(초가공 N회 사실은 유지).
테스트
  • 추종도 주의+간식 stance reduce 동시→성장 멘트 출력·간식 stance 방향 줄 생략
  • 추종도 주의여도 초가공 간식 있으면 초가공 환기 유지
  • 추종 양호면 snack stance 그대로(클램프 없음)
  • growthMirror null이면 snackText 무변경(회귀 0)
DoD 성장 신호 발동 주에 체격 관련 멘트가 편지에 1개(성장 또는 간식 stance, 둘 다 아님)만 나가고 초가공 신호는 보존됨이 테스트로 입증. tsc·vitest 그린.
의존: E-03 · 규모 M · 우선순위 P1
⚠ stance 클램프가 저체중(encourage) '든든한 간식 보태기'까지 끄면 영양 보탬 손실. encourage는 성장과 정합(둘 다 양 늘리기)이라 굳이 끄지 말고 reduce 충돌만 게이팅하는 정밀화 고려.

E-09 성장 거울 케이던스 헬퍼 growthCadenceOk (격주·2주연속금지)코드규모 S

목적 MEMORY '3대영양소 macro 잔소리 격주·2주연속금지·측정 저빈도' 정책을 성장 거울에 적용. 케이던스를 명시 상수·헬퍼로 정리해 테스트 가능하게 하고, 식품군 거울(매일 정보형)와 성장 거울(격주 환기형)의 케이던스 차이를 코드로 봉음.
요구 성장 거울 케이던스를 단일 상수(GROWTH_COOLDOWN_DAYS≈13)와 isoWeek 격주 게이트로 표현. 직전 노출 주 다음 주는 강제 침묵(2주연속금지). 식품군 거울은 케이던스 무적용(매일 정보) 명시.
구현 route.ts에 const GROWTH_COOLDOWN_DAYS=13 상수 + growthCadenceOk(recentGrowthDates:string[], weekKey:string, recentGrowthWeekKeys:string[]):boolean 헬퍼. recentGrowthDates(166-176 forEach에서 ctx.growthShown→push)와 isoWeekKey(today)로 판정: 최근 13일 내 노출 없음 AND 직전 노출 week_key가 직전 주 아님(2주연속금지). E-03의 base.growthMirror 세팅 조건을 이 헬퍼로 교체. 식품군 mirrorBlock(coach.ts:621-630)은 케이던스 없이 매일 유지(주석 명시).
파일 web/app/api/cron/coach/route.ts
유즈케이스 아린: 성장 신호를 한 주 노출하면 다음 주는 자동 침묵, 격주 리듬으로만 환기.
테스트
  • 직전 주에 성장 노출했으면 이번 주 growthCadenceOk false
  • 최근 13일 노출 없고 직전 주도 아니면 true
  • 노출 이력 전무면 true(첫 노출 허용)
  • 식품군 거울은 케이던스 게이트 없이 매일 주입(회귀)
DoD growthCadenceOk가 격주·2주연속금지를 정확히 판정하고 상수로 조정 가능. 단위테스트 전 케이스 그린. tsc·vitest 그린.
의존: 없음 · 규모 S · 우선순위 P1
⚠ isoWeek 경계(일요일 주경계·MEMORY week_key=isoWeekKey(today+1))와 13일 쿨다운이 어긋나면 격주가 3주로. 두 게이트 AND로 묶어 과다침묵 방지.

E-10 온디맨드(api/coach) 경로 동등성 — growthMirror 동일 배선배선규모 M

목적 composeLetter는 크론·온디맨드(api/coach) 공유 작성기(coach.ts:756-762 DRY 원칙)인데 성장 거울 산출이 cron route에만 있으면 온디맨드 호출(앱 새로고침) 시 성장 신호가 빠져 경로 편차 발생.
요구 온디맨드 편지 생성 경로도 동일하게 firstGrowth/latestGrowth+growthTrackToPhrase로 base.growthMirror를 산출(케이던스 포함)해 composeLetter에 넘긴다. growth 데이터 부재 시 안전 폴백.
구현 app/api/coach 라우트(grep 'composeLetter' app/로 위치 확인)에 cron route.ts:347·671-682와 동일한 growthTracking 산출+growthTrackToPhrase+케이던스(E-09 공유) 적용. 가능하면 base.growthMirror 산출을 공용 헬퍼(buildGrowthMirror(supabase,cid,meta,today,recentGrowthDates))로 추출해 cron·온디맨드가 동일 호출(코드 중복 제거·경로 동등). 온디맨드에 growth_logs/케이던스 이력 조회 추가.
파일 web/app/api/coach/route.ts · web/app/api/cron/coach/route.ts · web/lib/growth-reference.ts
유즈케이스 아린: 부모가 앱에서 편지를 새로고침해도 cron 편지와 동일한 성장 거울·케이던스 적용.
테스트
  • 온디맨드 base.growthMirror가 cron과 동일 입력에서 동일 산출
  • 온디맨드 growth_logs 부재 시 growthMirror null·에러 없음
  • buildGrowthMirror 공용 헬퍼가 양쪽에서 동일 결과
DoD 온디맨드 새로고침 편지도 성장 거울을 포함(케이던스 통과 시)하고 cron과 입력 동등. tsc·vitest·next build 그린.
의존: E-03, E-09 · 규모 M · 우선순위 P2
⚠ 온디맨드는 케이던스 이력(growthShown) 갱신 타이밍이 cron과 달라 중복 노출 가능. 공용 헬퍼가 같은 recentGrowthDates 소스를 읽게 해 일관화.

EPIC F — 커리큘럼 진척 스토어 라이브 (Curriculum Progress Engine) D2

상태기계(lib/curriculum.ts advanceProgress·evolveRow)·12유닛 레지스트리(lib/curriculumUnits.ts steps/passWhen/holdWeeks/relapseWhen)·후보 산출기(coachWeekly candidateUnits/resolveGoals)는 전부 구현·테스트(tests/curriculum.test.ts 93케이스) 완료돼 있으나 크론(app/api/cron/coach/route.ts)이 curriculum_progress 테이블을 한 번도 읽거나 쓰지 않는다(grep -c=0). 따라서 step++·status 전이(active→progressing→maintenance→mastered, 정체→pivoted, 재발→relapsed)가 영구히 안 일어나고, daily_questions 답변→evidence 파서(parseProbeAnswers·G-04)도 미호출, runWeeklyPlanning에 progress/focusHistory가 안 들어가 candidateUnits가 매주 빈 진척으로 goals를 새로 픽(같은 유닛 무한 재픽·졸업 불가), behavior_goal은 step.behavior가 아니라 LLM/폴백 텍스트라 일간 편지가 진척과 무관한 행동을 가르친다. 이 EPIC은 크론이 진척을 로드→advanceProgress→upsert하고, parseProbeAnswers로 답변을 evidence화하고, progress/focusHistory를 주간에 주입해 advance/hold/reanchor·같은 유닛 재픽 금지하며, behavior_goal·일간 weeklyArc를 focus 유닛의 현 step.behavior로 결정론화하는 순수-함수-배선 작업이다.

F-01 크론에 curriculum 상태기계 import + ProgressRow 이름충돌 해소배선규모 S

목적 근본원인 '커리큘럼: cron이 curriculum_progress를 읽지/쓰지 않음'의 전제 — 상태기계 함수가 크론 스코프에 없으면 이후 원자가 전부 불가. ProgressRow가 progress.ts(기간요약)·curriculumUnits.ts(진척) 두 곳에 동명이라 충돌 방지가 선행.
요구 route.ts에서 lib/curriculum의 advanceProgress·blankRow와 lib/curriculumUnits의 ProgressRow/Goal/UnitId를 충돌 없이 사용 가능해야 한다.
구현 app/api/cron/coach/route.ts 상단 import 추가: `import { advanceProgress, blankRow, normalizeGoals, goalsOf, type DailyDecision } from '@/lib/curriculum';` 및 `import { UNITS, type UnitId, type Goal, type ProgressRow as CurriculumProgressRow } from '@/lib/curriculumUnits';`. 기존 22행 `import { ... type ProgressRow } from '@/lib/progress'`의 ProgressRow는 785-786행 period_summaries 집계에서만 쓰이므로 그대로 두고, 커리큘럼용은 별칭(CurriculumProgressRow)으로 분리. 30행은 현재 buildCandSignals만 import 중 → `import { buildCandSignals, parseProbeAnswers } from '@/lib/coachDaily';`로 확장.
파일 app/api/cron/coach/route.ts
유즈케이스 아린: 현재 advanceProgress 심볼 자체가 크론에 없어 진척 0건 — 이 배선이 모든 후속의 게이트.
테스트
  • F-01-1 tsc: route.ts가 두 ProgressRow를 별칭으로 구분해 컴파일된다
  • F-01-2 next build 그린(미사용 import 경고 0)
DoD pnpm tsc --noEmit 0 에러, next build 그린, route.ts에 advanceProgress·parseProbeAnswers·UNITS 심볼이 import돼 있음.
의존: 없음 · 규모 S · 우선순위 P0
⚠ progress.ts·curriculumUnits.ts ProgressRow 혼동 시 period_summaries(785) 타입 깨짐 — 별칭으로 격리.

F-02 자녀별 curriculum_progress 로드(루프 진입 전 일괄 prefetch)배선규모 S

목적 근본원인 '주간 goals는 매주 새로 픽(진척 영속X)' 봉합의 데이터 원천. 현재 admin/[childId]/page.tsx:105만 이 테이블을 읽고 cron은 0회(grep -c=0) → 항상 빈 진척으로 동작.
요구 activeIds 전체의 curriculum_progress 행을 한 번에 읽어 child_id→{unit_id→ProgressRow} 맵으로 구성. 테이블/컬럼 미적용(SQL 전)이어도 try/catch로 빈 맵 degrade.
구현 route.ts 자녀 루프(237행 `for (const cid of activeIds)`) 직전, recentLetters/kids prefetch 구간(154-235행)과 동일 패턴으로 1회 select: `const { data: progRows } = await supabase.from('curriculum_progress').select('child_id,unit_id,status,step,evidence,started_at,mastered_at,last_signal_at,stop_reason,relapse_count').in('child_id', activeIds);` → `const progByChild: Record<string, Partial<Record<UnitId, CurriculumProgressRow>>> = {};` 채움. 에러 시 빈 객체 유지(admin page 105행 select 컬럼셋과 동일). date형 컬럼(started_at 등)은 string으로 들어옴(curriculum.ts dayAge가 Date.parse).
파일 app/api/cron/coach/route.ts
유즈케이스 아린: 첫 실행 시 progByChild['43942d34…']={} → blankRow로 시작, 다음 실행부터 누적 로드.
테스트
  • F-02-1 progByChild가 select 결과를 child→unit 맵으로 구성
  • F-02-2 테이블 미존재(에러) 시 빈 맵 degrade·throw 안 함
DoD weekly 종합·daily advance 분기에서 progByChild[cid] 참조 가능. SQL 미적용 환경에서 크론 에러 없이 완주(cron_runs status≠failure).
의존: F-01 · 규모 S · 우선순위 P0
⚠ RLS 정책 없음(2026-06-12_curriculum.sql:27) = 서버 service role만 접근 — weekly_plans와 동일 패턴(createSupabaseServer)이라 OK.

F-03 daily_questions 답변 → ProbeAnswer 적립(parseProbeAnswers 배선)배선규모 S

목적 근본원인 '단계전진 함수 없음·passWhen 평가 불가'의 보조 신호원. parseProbeAnswers(coachDaily.ts:52)와 각 유닛 probes가 구현됐으나 호출처 0 → 부모 칩 답변이 evidence(envTablePct·selfPct 등)로 영영 안 쌓여 passWhen이 표본부족(null)에 고착.
요구 자녀별 최근 7일 daily_questions(answer not null·context.unitProbe)를 읽어 parseProbeAnswers로 ProbeAnswer[] 생성, advanceProgress의 answers 인자로 전달.
구현 route.ts 자녀 루프 안(진척 advance 직전), 거부→수용 전환 감지 블록(369행 try) 인근에 select 추가: `const { data: ansRows } = await supabase.from('daily_questions').select('q_date,answer,context').eq('child_id', cid).gte('q_date', dAgo(7)).not('answer','is',null);` → `const probeAnswers = parseProbeAnswers((ansRows||[]).map(r => ({ q_date: r.q_date, answer: r.answer, context: r.context })));`. parseProbeAnswers는 context.unitProbe.{unit_id,signal,probeId} + chips 정확일치만 적립(coachDaily.ts:52-66). 기존 daily_questions 생성부(747행 qCtx)에는 unitProbe 니가 없음 → F-04가 쓰기 시작(이 원자는 읽기·파싱 배선).
파일 app/api/cron/coach/route.ts
유즈케이스 아린: '식탁에서 화면 없이' 칩 답이 table-stage envTablePct 표본을 보충해 step1 passWhen 판정 가능.
테스트
  • F-03-1 unitProbe 없는 기존 답변은 ProbeAnswer 0개(보수적 미적립)
  • F-03-2 unitProbe+정확칩 답변은 evidence로 적립돼 passWhen이 null→판정으로 전환
DoD advanceProgress 호출에 probeAnswers 주입됨. 기존 답변(unitProbe 없음)으로는 적립 0(회귀 없음).
의존: F-01, F-02 · 규모 S · 우선순위 P0
⚠ unitProbe가 daily_questions에 안 박히면(F-04 미완) probeAnswers 항상 빈 배열 — extract가 칩 없이도 rows만으로 동작하므로 무해(점진 활성).

F-04 일간 질문에 unitProbe 컨텍스트 주입(진척 측정 질문 정렬)코드규모 M

목적 F-03의 evidence 적립이 실제 채워지려면 daily_questions가 focus 유닛의 probe를 unitProbe 컨텍스트로 달고 발행돼야 한다. 현재 731-753행 질문 생성은 ICFQ 또는 generateQuestion만 쓰고 unitProbe 미주입.
요구 ICFQ 주가 아닌 날, focus 유닛 probe 중 하나(표본 부족 신호 우선)를 결정론 선택해 question·chips로 발행하고 context.unitProbe={unit_id,signal,probeId} 적재. probe 없거나 focus 미정이면 기존 generateQuestion 폴백.
구현 route.ts 731-753행 질문 분기에서 icfq가 null인 경로(741행 else)에 우선순위 추가: 오늘 decision.unit(F-05 산출)의 UNITS[unit].probes에서 daySeed/cidHash로 1개 회전 선택 → `q = { question: probe.q, chips: probe.chips }; const unitProbe = { unit_id: decision.unit, signal: probe.signal, probeId: probe.id };`. qCtx(747행)에 unitProbe 병합. focus/probe 없으면 기존 generateQuestion 유지. 결정론 선택은 pickQuestionTopic(744행) 시드 패턴 재사용. ICFQ 주(2주 주기)는 안전 스크리너 우선이므로 unitProbe 미주입.
파일 app/api/cron/coach/route.ts
유즈케이스 아린: focus=table-stage면 'ts-env'(어디서 먹었어요?) 칩 질문 발행 → 답이 envTablePct 적립.
테스트
  • F-04-1 focus 유닛 있는 날 질문에 context.unitProbe.{unit_id,signal,probeId} 박힘
  • F-04-2 ICFQ 주는 unitProbe 미주입(안전 스크리너 우선)
  • F-04-3 focus 미정/probe 없으면 generateQuestion 폴백
DoD F-05 decision이 있는 날 daily_questions.context.unitProbe가 채워지고, 그 답변이 다음날 F-03→evidence로 적립(루프 닫힘).
의존: F-03, F-05 · 규모 M · 우선순위 P1
⚠ probe 질문 매일 같은 유닛으로 단조 가능 — probes 배열 회전 + ICFQ 격주로 다양성 확보. generateQuestion 대체로 LLM 1콜 절감(부수효과).

F-05 일간 advanceProgress 호출 — 진척 진화 + DailyDecision 산출코드규모 L

목적 근본원인 핵심 '단계전진 함수 없음·status 전이 안 일어남' 직접 봉합. 정상 편지 경로(byDay≥3)에서 매일 1회 advanceProgress를 돌려 step++·status 전이·재발/정체 감지를 수행하고 오늘 전개(advance/deepen/pivot/maintain/celebrate/observe)를 결정.
요구 닻 로드(F-06가 goals 영속) 후, 진척맵·goals·rs(CRow)·probeAnswers·coachedDays·coachedYesterday·pivotsThisWeek를 모아 advanceProgress 호출. 결과 updates는 F-07이 upsert, decision은 F-08이 behavior_goal/렌더에 사용.
구현 route.ts 정상 경로(reusedThis=false, 446행 이후) 주간 닻 처리(495-612행) 직후에 블록 추가. (a) goals: anchor.goals 우선, 없으면 goalsOf(anchor)(curriculum.ts:122). (b) coachedYesterday: lastLetter/recentLetters context.curriculum.unit을 추출(prevArcStage 패턴 166-176행처럼 직전 편지 context 파싱). (c) coachedDays: 최근 stallDays(6일) 편지들 decision.unit별 발행 일수 카운트(recentLetters context 순회). (d) pivotsThisWeek: 이번 주(isoWeekKey) 편지 중 decision.mode==='pivot' 수. (e) foodTarget: precomputed.plan?.target. 호출: `const adv = advanceProgress({ childId: cid, goals, progress: progByChild[cid]||{}, rows: rs as unknown as CRow[], answers: probeAnswers, coachedDays, coachedYesterday, pivotsThisWeek, foodTarget, today });`. rs(Row[])→CRow 캐스팅(필드 log_date/menus/note/ate_well/refused/place/slot/texture/autonomy/meal_time 전부 존재). 결과는 외부 let curriculumAdv에 저장(F-07~F-09 참조).
파일 app/api/cron/coach/route.ts
유즈케이스 아린 6/15콩·16과일·17치킨: focus=exposure-savings면 콩 노출일 targetExposeDays7d 적립→step1 passWhen(주2회) 평가 — 연속 food 누수와 별개로 진척은 정직하게 기록.
테스트
  • F-05-1 advanceProgress가 매 정상 편지 1회 호출되고 decision 반환
  • F-05-2 focus 유닛 passStreak 7일 충족 시 step++·decision.mode='advance'
  • F-05-3 mastered 유닛 relapseWhen 14일 연속 시 status='relapsed'·재발 감지
  • F-05-4 coachedDays≥3·신호 무관측 시 isStalled→pivot 또는 observe
DoD 정상 경로에서 adv.updates·adv.decision·adv.goalsAfter 산출. 빈 진척(첫 실행)이면 focus 유닛이 active step1로 시작.
의존: F-01, F-02, F-03, F-06 · 규모 L · 우선순위 P0
⚠ Row→CRow 캐스팅 안전(필드 동일 확인 완료). coachedDays/coachedYesterday를 context에서 못 읽으면(구편지) 0/빈 → isStalled 미발동, step만 진전(보수적·무해).

F-06 주간 종합에 progress/focusHistory 주입 + goalsAfter 닻 영속코드규모 M

목적 근본원인 '주간 goals 매주 새 픽(진척 영속X)' + '같은 유닛 재픽 금지' 봉합. runWeeklyPlanning(coachWeekly.ts:227)·candidateUnits(:68)·resolveGoals(:201)·applyFocusFatigue(:97)는 progress/focusHistory 인자를 받게 설계(WeeklyInput:154-156)됐으나 크론이 항상 미전달(529-534행 candSignals만) → mastered/maintenance 제외·relapsed+3/pivoted+1 부스트·focus 피로 강등이 영영 안 먹음.
요구 일요일 synthAndStoreAnchor·평일 lazy synth가 runWeeklyPlanning에 progress(progByChild[cid])·week(가입후 주차)·focusHistory(최근 주 focus·stepAdvanced)를 넘긴다. advanceProgress가 피벗으로 goals를 바꿔으면(adv.goalsAfter) 그 결과를 닻에 영속(정적 goals가 다음날 피벗을 되돌리는 버그·curriculum.ts:210-214 주석).
구현 (1) runWeeklyPlanning 호출(520-534행)에 추가: `progress: progByChild[cid] || {}, week: weekSinceSignup, focusHistory`. weekSinceSignup = 첫 meal_log(histDays 최소일)부터 주차 — histDays[cid] 최소값 또는 children.created_at. focusHistory = 최근 N개 닻(weekly_plans.goals focus + ledger 진전)에서 추출. (2) candSignals.missingCount는 이미 fg.missing으로 덮음(531)·그대로. (3) 영속: advanceProgress mode='pivot'일 때 adv.goalsAfter를 weekly_plans.update({ goals: adv.goalsAfter })로 당주 닻에 기록(609행 ledger update 인근 트랜잭션). 일요일 synth는 차주 닻이라 progress=현재값으로 candidateUnits 평가→mastered 유닛 후보 제외→졸업한 수업은 다음 주에 안 재픽.
파일 app/api/cron/coach/route.ts · lib/coachWeekly.ts
유즈케이스 아린: table-stage가 envTablePct 0.14로 18일 정체 → focusHistory='table-stage·stepAdvanced=false' 2주 → 3주째 standby 강등, 음식/다른 축 focus 승격(이사님 원칙 '환경 안 먹히면 목표 바꿔').
테스트
  • F-06-1 mastered 유닛은 candidateUnits 후보에서 제외(크론 주입 경로)
  • F-06-2 relapsed 유닛은 score+3로 차주 최우선 재선발
  • F-06-3 같은 focus 2주 연속 step전진0이면 applyFocusFatigue로 3주째 강등
  • F-06-4 advanceProgress pivot 시 goalsAfter가 닻 goals로 영속(다음날 안 되돌림)
DoD runWeeklyPlanning이 비어있지 않은 progress를 받음. mastered 수업이 차주 goals에 재등장하지 않음. 피벗이 닻에 남아 다음날 유지.
의존: F-02, F-05 · 규모 M · 우선순위 P0
⚠ weekSinceSignup 정확도(histDays 30일 창 한계) — 부정확하면 goalsCapForWeek가 보수적으로 캡(무해). focusHistory 추출 실패 시 빈 배열→피로캡 미작동(기존과 동일·회귀 없음).

F-07 curriculum_progress upsert(진화 결과 영속) + 재사용/온보딩/휴면 경로 무영향배선규모 M

목적 근본원인 'cron이 curriculum_progress를 쓰지 않음' 직접 봉합. advanceProgress가 산출한 updates(ProgressRow[])를 테이블에 멱등 upsert해 step·status·evidence·last_signal_at·relapse_count 영속.
요구 adv.updates 각 행을 curriculum_progress upsert(onConflict child_id,unit_id). evidence는 jsonb. 재사용(reusedThis)·온보딩(byDay<3)·휴면(dormancy) 경로는 advance 미수행이므로 진척 미변경(데이터 보존). 실패해도 편지 발행 무영향(try/catch).
구현 F-05 advanceProgress 직후 배열 upsert 1콜: `if (adv.updates.length) await supabase.from('curriculum_progress').upsert(adv.updates.map(u => ({ child_id: u.child_id, unit_id: u.unit_id, status: u.status, step: u.step, evidence: u.evidence, started_at: u.started_at, mastered_at: u.mastered_at, last_signal_at: u.last_signal_at, stop_reason: u.stop_reason, relapse_count: u.relapse_count, updated_at: new Date().toISOString() })), { onConflict: 'child_id,unit_id' });` try/catch로 테이블 미존재 degrade(weekly_plans upsert 결과검사 547-555 패턴 참조). 재사용 분기(432-445행)는 advanceProgress 자체를 안 도므로 자동 무변경. QA date 시뮬(qp.get('date'))에서는 라이브 진척 파괴 방지 위해 upsert 스킵(일요일 synth 564행 date 차단 원칙과 동일).
파일 app/api/cron/coach/route.ts
유즈케이스 아린: 첫 영속 후 exposure-savings status=active·step1·evidence.targetExposeDays7d 누적 → admin 패널에 진척 가시화(현재 188행 def 렌더가 텅 빈).
테스트
  • F-07-1 advance 결과 행이 curriculum_progress에 upsert(멱등·2회 무해)
  • F-07-2 재사용 편지 경로선 진척 upsert 미수행(데이터 보존)
  • F-07-3 테이블 미존재 시 upsert 실패해도 편지 발행됨
  • F-07-4 QA date 시뮬에선 라이브 진척 미파괴
DoD 다음 크론 실행 시 progByChild에 비어있지 않은 진척이 로드(쓰기→읽기 루프 폐쇄). admin/[childId]에 step·status 표시(현재 항상 빈 테이블).
의존: F-05 · 규모 M · 우선순위 P0
⚠ N유닛×자녀 upsert = 쓰기량 — adv.updates는 보통 focus+standby+mastered만(전 12유닛 아님)이라 소량. 배열 upsert 1콜로 압축.

F-08 behavior_goal·일간 weeklyArc를 focus 유닛 현 step.behavior로 결정론화코드규모 M

목적 근본원인 'behavior_goal=현 step.behavior 결정론·일간 편지에 step.behavior 렌더'(EPIC 범위 명시) 봉합. 현재 일간 편지 weeklyArc.behaviorGoal은 anchor.behavior_goal(LLM/defaultBehaviorGoal 텍스트·planFromWeekly:310-311)이라 진척 step과 무관 — step1을 졸업해 step2여도 같은 문구를 가르침.
요구 오늘 decision.unit·decision.step에 해당하는 UNITS[unit].steps[step-1].behavior를 일간 편지가 가르치는 행동으로 렌더. mode가 celebrate/maintain이면 톤 조정(졸업 축하/유지). 닻 behavior_goal도 focus 유닛 현 step.behavior로 결정론 산출(LLM 폴백은 step 없을 때만).
구현 (1) 일간: composeLetter base(671-682행)의 weeklyArc는 weekCtx.arc(planFromWeekly 산출). route.ts에서 planFromWeekly 호출(604행) 직후·weekCtx 세팅(607행) 인근에 오버라이드: `if (weekCtx?.arc && curriculumAdv?.decision) weekCtx.arc.behaviorGoal = UNITS[curriculumAdv.decision.unit].steps[Math.max(0,curriculumAdv.decision.step-1)]?.behavior ?? weekCtx.arc.behaviorGoal;`. (2) 닻 behaviorGoal: coachWeekly defaultBehaviorGoal(:161)을 focus 유닛 step.behavior 우선으로 — runWeeklyPlanning(242행 focus)·coldSynth(267행)에서 `focus ? UNITS[focus.unit_id].steps[0].behavior : defaultBehaviorGoal(lever,target)`(신주=step1). (3) mode='celebrate'(졸업)·'maintain'(유지)·'relapsed' 시 weeklyArc.stage·progressNote로 톤 분기(planFromWeekly stage 로직 308행 연계).
파일 app/api/cron/coach/route.ts · lib/coachWeekly.ts
유즈케이스 아린: exposure-savings step1='새 음식 아주 조금씩 말없이 식탁에' 졸업→step2='익숙해진 음식 티스푼 맛보기'로 일간 코칭 문구가 진척 따라 진화.
테스트
  • F-08-1 focus 유닛 step2면 일간 behaviorGoal이 steps[1].behavior로 렌더
  • F-08-2 닻 behavior_goal이 focus 유닛 steps[0].behavior로 결정론 산출
  • F-08-3 mode=celebrate면 졸업 톤(같은 행동 재가르침 금지)
  • F-08-4 focus 없으면 기존 defaultBehaviorGoal 폴백(회귀 없음)
DoD 일간 편지가 진척 step에 맞는 step.behavior를 가르침(step1→step2 전진 시 문구 변화). 닻 behavior_goal이 유닛 사다리와 일치.
의존: F-05, F-06 · 규모 M · 우선순위 P1
⚠ step.behavior 문구가 부모 편지 톤과 안 맞을 수 있음 — 작문(손)이 weeklyArc.behaviorGoal을 '재료'로 받아 자연어화(고정 슬롯 아님·기존 weeklyArc 주입과 동일 경로)라 안전.

F-09 decision/진척 스냅샷을 coach_letters.context.curriculum에 적재(원장)배선규모 S

목적 F-05의 coachedYesterday/coachedDays/pivotsThisWeek 산출이 다음날 편지 context에서 어제 decision.unit·mode를 읽어 계산되도록 원장을 남긴다(상태기계의 '코칭 일수' 판정 근거·B-19). admin 검증·재사용 분기 보존용.
요구 letterCtx(698-715행)에 curriculum={ unit, step, mode, pivotTo } 스냅샷 추가. 재사용 분기(432-445행)는 prev.context.curriculum 보존(weekly·plan 보존 패턴과 동일). recentLetters context 파싱(169행)에 curriculum 타입 추가.
구현 route.ts letterCtx(698행 객체)에 `curriculum: curriculumAdv?.decision ? { unit: curriculumAdv.decision.unit, step: curriculumAdv.decision.step, mode: curriculumAdv.decision.mode, pivotTo: curriculumAdv.decision.pivotTo } : null` 추가. 재사용 분기(435행 pctx 파싱 타입)에 `curriculum?: {...}` 추가하고 보존. recentLetters 순회(166-176행) ctx 타입(169행)에 curriculum 추가 → coachedByUnit/coachedYesterdayUnit 맵을 여기서 채워 F-05에 전달. F-05와 동일 키(context.curriculum.unit/mode) 합의해 동시 구현.
파일 app/api/cron/coach/route.ts
유즈케이스 아린: 어제 편지가 table-stage를 코칭했으면 context.curriculum.unit='table-stage' → 오늘 coachedDays['table-stage']++ → 6일 누적 후 신호 0이면 정체 판정.
테스트
  • F-09-1 발행 편지 context.curriculum.{unit,step,mode} 기록
  • F-09-2 재사용 편지는 prev.context.curriculum 보존
  • F-09-3 어제 편지 context.curriculum.unit이 오늘 coachedYesterday로 읽힘
DoD coach_letters.context.curriculum이 채워지고, 다음날 coachedDays/coachedYesterday가 그 원장에서 계산(isStalled 판정 정상화).
의존: F-05 · 규모 S · 우선순위 P1
⚠ F-05와 키 합의 필요(같은 PR 권장). 구편지(curriculum 없음)는 coachedDays 0 기여 — isStalled 보수적(무해).

F-10 통합/리플레이 테스트 — 진척 루프 14일 시뮬(졸업·정체·재발·재픽금지)테스트규모 M

목적 EPIC 범위 전부(읽기→advance→쓰기→주간 재픽 금지→step 렌더)가 닫힌 루프로 동작함을 측정 가능하게 증명. tests/curriculum.test.ts(93케이스)는 순수함수만 — 크론 배선 통합은 미검증. B-26 리플레이가 과거 이중적립 버그를 적발한 선례.
요구 진척맵을 메모리로 들고 14일 advanceProgress를 굴려 (a) step1→step2→maintenance→mastered 졸업 (b) 정체→pivot→goalsAfter 영속 (c) mastered→relapsed (d) 같은 유닛 차주 재픽 금지(candidateUnits mastered 제외) 검증. 가능하면 크론 진척 배선부를 순수 헬퍼로 추출해 직접 테스트.
구현 tests/curriculum.test.ts에 describe('F 통합 — 진척 라이브 루프') 추가. advanceProgress를 14일 반복 호출하며 progress 맵을 updates로 갱신(크론 F-07 upsert 모사). 시나리오: exposure-savings foodTarget='콩류'에 매일 hit rows 주입 → passStreakDays 7→step++. coachedDays≥3·신호 끊김 → isStalled→pickPivot. F-06 검증: 14일 후 mastered 유닛을 candidateUnits({progress})에 넣어 후보 제외 확인. F-08 검증: decision.step→UNITS[unit].steps[step-1].behavior 매핑. parseProbeAnswers로 만든 answers가 evidence 표본 보충하는지(F-03).
파일 tests/curriculum.test.ts
유즈케이스 아린 18일 정체 케이스를 골든 시나리오로 박제 — limping(passStreak0·coached6) 피벗이 재현되는지 회귀 가드.
테스트
  • F-10-1 14일 시뮬: 노출 충족 시 step1→step2→maintenance→mastered 졸업
  • F-10-2 정체(coachedDays≥3·무신호) 시 pivot→goalsAfter 닻 영속
  • F-10-3 mastered 유닛 relapseWhen 14일 연속 시 relapsed
  • F-10-4 mastered 유닛은 차주 candidateUnits 후보에서 제외(재픽 금지)
  • F-10-5 decision.step→steps[step-1].behavior 매핑 정합(F-08)
DoD vitest 그린(기존 93 + 신규 F 케이스). 졸업·정체·재발·재픽금지 4대 전이가 통합 루프에서 재현.
의존: F-05, F-06, F-07, F-08 · 규모 M · 우선순위 P1
⚠ 메모리 시뮬이 실제 크론 배선과 drift 가능 — F-05 배선부를 순수 함수로 최대한 추출해 동일 코드 경로 테스트 권장.

F-11 온보딩 아크를 커리큘럼 모듈로 흡수 (이사님 2026-06-18)코드규모 M

목적 온보딩 아크가 3곳에 흩어짐(generateOnboardingLetter <3일 별도경로 route.ts:323 / 주간 톤아크 intro→reinforce / 휴면 커리큘럼 램프 goalsCapForWeek·minWeek) — 주인이 없음. 커리큘럼 모듈(F)이 온보딩을 첫 유닛 step1로 흡수해 일관 진행.
구현 F 배선 후: 온보딩 기간(week1~2)을 pressure-off·table-stage step1 등 기초 유닛으로 모델링(goalsCapForWeek 1→2→3 이미 존재). <3일 generateOnboardingLetter를 커리큘럼 구동 편지로 대체(또는 데이터 0일만 유지 후 즉시 커리큘럼 인계). 주간 톤아크(intro→reinforce)도 커리큘럼 step에서 파생.
우선순위 P1 · 의존 F-05·F-06 · 규모 M

F-12 주간계획 다채로움 — 과거 echo 참조·focusFatigue 활성·arc_week 증가 (이사님 어드민 피드백 2026-06-18)코드규모 M

목적 어드민 실측: W22~25 포트폴리오 동일(table-stage·exposure-savings)·전부 1주차. 원인=arc_week 하드코딩(route.ts:569)·runWeeklyPlanning에 progress/week/focusHistory 미전달 → candidateUnits 매번 동일·focusFatigue 휴면.
구현 arc_week=가입후주차·runWeeklyPlanning에 progress/week/focusHistory 주입(=F-06)·applyFocusFatigue 활성(2주 정체 유닛 강등→차순위 승격)·과거 4주 echo로 진척없을 때 같은 초점 반복 회피.
우선순위 P0 · 의존 F-05·F-06·F-07 · 규모 M

F-13 주간계획 콘텐츠 확장 — 추천 메뉴·식재료 이야기·온보딩 아크 노출코드규모 M

목적 주간계획에 추천 메뉴/식재료 서사·온보딩 아크 필드가 없어 빈약. 주간 단위 추천 메뉴 1~2 + 식재료 한 줄을 닻에 산출·적재.
구현 weekly synth가 결핍군 인기 메뉴로 주간 추천 1~2 산출→weekly_plans reco_menus 필드·어드민 렌더. 온보딩 아크(F-11)도 주간계획 표시.
우선순위 P2 · 의존 F-11 · 규모 M

F-16 커리큘럼 유닛 ↔ 일간 무브 결속 (연속성 plateau 핵심)코드규모 L

목적 연속성 3차(52/100) 발견: F가 유닛 피벗(table-stage↔exposure-savings)을 하는데 편지 본문은 같은 환경무브 3종만 회전 → 독자가 피벗을 못 느낌. 유닛이 일간 무브/시나리오를 실제로 결정해야 함.
구현 curriculumDecision.unit→해당 lever 시나리오·무브 풀로 plan 결속(환경유닛=식탁/간식 무브·노출유닛=음식 곁들임 무브). weekCtx.arc.behaviorGoal뿐 아니라 precomputed.plan도 유닛 기반으로. 두뇌 override와 정합.
✅ 구현(2026-06-18) 3겹: ①effectiveLever 스레딩(coachWeekly.planFromWeekly에 선택적 effectiveLever 추가 — 오늘 코칭 유닛의 레버로 프레임/무브 결정·미전달=주간 레버 byte동일) ②goalsAfter 영속화(route.ts — 피벗 시 focus 플립을 weekly_plans.goals에 저장. 누락 시 정적 goals가 다음날 피벗을 되돌려 1-pivot 캡 소진 후 table-stage 재고착=피벗이 하루만 유지. curriculum.ts:210 계약 이행) ③두뇌 게이트 양방향화(anchorOverrideAllowed: 기존엔 food주가 모든 시나리오 호환 → 두뇌가 환경 시나리오로 lever:food 주간을 무제한 덮음=F-16 무효화. 이제 food주의 구조 override 차단·SAFE_INTERRUPT만 예외). 게이트=mode!==celebrate&&mode!==maintain && unitLever!==weeklyLever·레버 전환날 progressNote=null(잡탕 차단).
🔁 자가정독(아린 23통) 연속성 52→50(1차: 두뇌가 음식유닛을 환경으로 덮어 무효화)→58(게이트 후). 음식 유닛 6/6일(06-08·09·10·15·16·17) 음식 본문 결속 회복·환경 유닛은 환경 무브 정상·레버↔본문 본질 불일치 0건. 잔존: (a)06-08·09 도입부가 plateau 환경톤=약결속, (b)weekCtx.lever 메타가 table-stage날도 food로 찍힘(본문은 정상 환경·정합 잔버그, 무해), (c)병목이 step 1단 23일 고착=F-17로 이동. 비용 ₩336/자녀·월.
테스트 vitest 413(+6 coach-effective-lever, +3 양방향 게이트 골든). tsc 0.
우선순위 P0 · 의존 F-05 · 규모 L · ✅ 완료

F-17 step 누적 서사 — "지난주 X→이번주 Y" 졸업감 ▲ F-16 후 현 최상위 병목코드규모 M

목적 step 23일 1단 고정 → 부모가 진도감 0. 같은 유닛 며칠 성공 시 step 승급 + 편지에 이전단계 회고+다음단계 명시(누적 서사). passWhen 충족 시 실제 step++ 확인.
🔁 자가정독 보강(F-16 라운드) F-16으로 무브-유닛 결속이 회복되자 연속성 병목이 명확히 F-17로 이동(독립 감사관: "진짜 병목은 이제 F-16이 아니라 step 1단 23일 고착"). 증거: ①아린 23통 메타 step 전부 1단(05-29~06-18 단계 상승 0회) — passWhen 미충족이 정당(실제 행동 신호 미달)하나 본문에 진도감이 0줄. ②유닛이 table-stage↔exposure-savings 왕복하며 pivot→table-stage(자기 자신으로 피벗) 같은 공허한 표기 발생 → 졸업/재앵커 임계로 처리 필요. ③06-08·09 음식 유닛 진입부가 plateau "잔잔히 쉬어가기" 톤 2일 연속이라 새 유닛 추진력이 죽음 → F-17 누적서사가 plateau를 '다음 단계 준비'로 전환해야. ④behavior_goal이 7일 연속 토씨 동일("TV 끄고…밥과 미역국") — step 고착이라 갱신 트리거 없음. 요구: (a)passWhen 장기 미충족 시 holdWeeks 후 자동 step 진급 또는 유닛 졸업/재앵커 강제 (b)손에 focusHistory/step 누적 요약 must-weave 주입해 "지난주 X→이번주 Y" 비교 1줄 강제(현재 23통 0건) (c)plateau를 정체가 아닌 전환점으로 서사화.
우선순위 P0 · 의존 F-16 · 규모 M

K-04b 영양거울 빈도 쿨다운 (어휘회전 → 빈도 감축)가드규모 M

목적 K-04(어휘회전) 후에도 클로징 결핍줄(콩류·비타민A)이 15통+ 메시지 동일. 매일 must-weave가 아니라 격일/주1회로 쿨다운(같은 결핍 N일 쿨다운)·본문 무브와 결합한 1회성 구체로 전환.
🔁 자가정독 보강(F-16 라운드) F-16 후에도 변화 0(직전 미해결 그대로): 클로징 "어린이집 덕분에 전체 영양은 잘 채워지고 있으니, 집에서는…충분합니다"가 14/23통 거의 복붙(06-01·03·05·07~18 다수) — 자가정독이 꼽은 최대 단일 반복원. 결핍줄 "집 끼니엔 콩류가 좀 드무니"도 06-03·05·10·16·17 보름 반복. K-04 어휘회전(분기별 3변형)은 표현만 돌릴 뿐 메시지·빈도 동일이라 무력. 요구: (a)같은 결핍군 메시지 최근 N일(≥3) 출력 금지 쿨다운(child_daily_state에 결핍군별 mirrorShownLedger 마지막노출일) (b)의미단위 dedup(어휘회전만으론 부족) (c)"어린이집 덕분에…" 안심 클로징을 매일 must-weave에서 제외하고 격일/주1 빈도 제한. ⚠️연계: 추천 두부 11/23 과집중(D 두트랙)·06-15~17 '간식으로 따로 내기' 동일 무브 3일도 결핍 단일고정의 산물 → 추천 회전과 동기화.
우선순위 P1 · 의존 K-03 · 규모 M

EPIC G — 괴식·동일군 추천 게이트 (Combo Gate Live) D3

comboMatrix.scoreCombo/isComboOk·comboGuard 괴식 게이트가 작성·테스트는 됐으나 라이브 추천 경로(buildRecoFacts·cron route.ts:618/662)엔 미배선(휴면)이라, 6/17 '돼지불고기 옆 닭가슴살'(단백질 위 단백질)·'미역국+당근'류 괴식이 곁들임으로 새어나간다. 근본은 (1) buildRecoFacts (a)곁들임·(b)사촌 경로가 어떤 combo 게이트도 안 거치고 strongPairsOf/verifiedCousinsOf를 그대로 출력, (2) bridge(사촌)는 본질적으로 동일 식품군(데이터상 same-group 10:cross 2)이라 '결핍 보강'이 아닌 같은-군 곁들임을 낳고, (3) groupOfIngredient가 운반체 음식명(치킨·돼지불고기·닭가슴살)에 null을 줘 same-group 가드 자체가 발화 못 한다. 이 EPIC은 휴면 게이트 1종(comboGuard로 단일화)을 buildRecoFacts에 배선하고, '곁들임 앵커는 타깃과 다른 군에서만·bridge는 결핍 보강일 때만 통과' 규칙과 운반체명→식품군 정규화 보정을 더해 괴식 0을 라이브에서 보장한다.

G-01 운반체 음식명 → 식품군 정규화 (groupOfIngredient 보정)코드규모 S

목적 근본원인 #12/#3: groupOfIngredient(coachRecos.ts:120)가 GROUP_INGREDIENTS 7군의 표준명만 역색인해 운반체 음식명(치킨·돼지불고기·닭가슴살·너겟)에 null 반환 → 이후 'same-group 곁들임 차단' 가드가 발화 못 하고 단백질 위 단백질이 통과. 모든 후속 가드(G-03·G-04)의 전제.
요구 치킨/돼지불고기/닭가슴살/소시지/너겟 등 운반체·복합 음식명을 입력해도 소속 식품군(고기·계란 등)을 반환해야 한다. 표준 식재료명은 기존과 byte 동일하게 동작.
구현 lib/coachRecos.ts:118-120. 현재 ING_GROUP은 GROUP_INGREDIENTS만 펼친 7군 역색인이라 '치킨'→null. nutrition.ts에 이미 FOOD_GROUP(291)+CATEGORY_GROUP(127, '고기'→'고기·계란')+catOf 체인이 있고, foodGroupOf(nutrition.ts:196 `FOOD_GROUP[ing] || CATEGORY_GROUP[catOf(ing)]`)가 운반체→범주→식품군을 푼다. → groupOfIngredient를 'ING_GROUP[ing] || foodGroupOf(ing, catOf) || null'로 확장(catOf 주입). 단 coachRecos는 순수함수 유지가 원칙이므로 catOf 의존을 새 인자로 받는 오버로드(`groupOfIngredient(ing, catOf?)`)로 추가하고 기존 무인자 호출(coachRecos 자체 143행·184행·cron 658행)은 catOf 주입으로 갱신. ⚠️#14의 네임스페이스 버그('고기' vs '고기·계란')는 CATEGORY_GROUP이 이미 '고기·계란'으로 매핑하므로 foodGroupOf 경로를 타면 해소됨.
파일 lib/coachRecos.ts · lib/nutrition.ts · app/api/cron/coach/route.ts
유즈케이스 아린 6/17: 두뇌 타깃 '치킨'(고기·계란군) 곁들임 앵커가 '돼지불고기'(같은 군)로 뽑혀도, 이제 groupOfIngredient가 둘 다 '고기·계란'을 줘 G-03가 same-group으로 차단.
테스트
  • G-01-1 groupOfIngredient('치킨',catOf)==='고기·계란'
  • G-01-2 groupOfIngredient('돼지불고기',catOf)==='고기·계란'
  • G-01-3 groupOfIngredient('닭가슴살',catOf)==='고기·계란'
  • G-01-4 표준명 회귀: groupOfIngredient('두부')==='콩류'·groupOfIngredient('당근')==='비타민A채소' (catOf 없이도 byte 동일)
  • G-01-5 미상명은 여전히 null
DoD vitest 5/5 그린. 운반체명 3종이 '고기·계란' 반환·표준명 역색인 무변경(기존 combo/reco 테스트 회귀 0). tsc 0.
의존: 없음 · 규모 S · 우선순위 P0
⚠ catOf 시그니처가 cron과 coachFacts 양쪽에서 다르게 주입될 수 있음 → coachFacts.ts:124의 pickFoodReco 경로엔 catOf 미주입이라 표준명 폴백만 동작(허용·무회귀).

G-02 휴면 괴식 게이트 단일화 — comboGuard로 일원화 + 라이브 어댑터 export코드규모 S

목적 근본원인 #13: 동일 임계(2)·동일 사상의 괴식 게이트가 comboMatrix(scoreCombo/isComboOk)·comboGuard(dishIngredientFit/ingredientPairFit) 2벌 병존하나 외부 라이브 호출 0건. 배선 전 단일 진실로 정리(중복 게이트 병존 금지).
요구 dish×식재료(괴식 본체)·식재료×식재료(곁들임 궁합) 두 경계를 하나의 모듈에서 일관 임계로 판정하는 라이브용 함수 노출. 기존 두 모듈의 공개 시그니처·테스트는 무변경(EPIC A·H 골든 보존).
구현 lib/comboGuard.ts 채택(이미 dishIngredientFit:27[dish×ing, kit-matrix]·ingredientPairFit:41[ing×ing, strongPairsOf]·validCombos:51로 경계가 명시적이고 식재료×식재료 분리됨). comboMatrix.scoreCombo(31)는 coachMaterials(휴면 v2)만 소비하므로 그대로 두되, comboGuard에 라이브 단일 진입점 `comboGateOk(dish, ing)`(= dishIngredientFit(dish,ing).ok, threshold=COMBO_THRESHOLD=2)와 `pairGateOk(a,b)`(= ingredientPairFit(a,b).ok) 추가 export. ⚠️ scoreCombo는 cells<8 강등(comboMatrix.ts:36)이 들어있고 dishIngredientFit은 dishesForIngredient(minScore=0) 경유라 cells 강등이 없음 — 두 게이트의 미역국+당근 판정이 동일(1<2 차단)임을 테스트로 박제하고, 차이가 나는 borderline은 comboGuard 기준을 라이브 정본으로 고정.
파일 lib/comboGuard.ts
유즈케이스 라이브 cron이 추천 곁들임을 만들 때 호출할 단일 게이트 확보 — '미역국+당근' 같은 과거 박제 괴식을 추천 경로에서 동일하게 차단.
테스트
  • G-02-1 comboGateOk('미역국','당근')===false (괴식 박제)
  • G-02-2 comboGateOk('볶음밥','당근')===true·comboGateOk('카레','당근')===true
  • G-02-3 comboGateOk('미수록음식zzz','당근')===false (보수적 금지)
  • G-02-4 pairGateOk: 강한 궁합 통과·약신호(두부+당근 weak) 차단
  • G-02-5 comboMatrix 골든(combo-matrix.test.ts)과 comboGuard 미역국+당근 판정 동일
DoD vitest 5/5. 라이브 진입점 2개 export·기존 comboMatrix/comboGuard 골든 테스트 전부 그린(회귀 0). 라이브 정본=comboGuard 확정 주석.
의존: 없음 · 규모 S · 우선순위 P0
⚠ scoreCombo와 dishIngredientFit의 borderline(cells 강등) 케이스가 갈리면 골든 불일치 가능 → G-02-5로 사전 적발, 갈리면 라이브는 comboGuard로 통일하고 문서화.

G-03 buildRecoFacts (a) 곁들임 앵커 — 타깃과 다른 군 + 괴식 게이트코드규모 M

목적 근본원인 #3/#12: buildRecoFacts (a)경로(coachRecos.ts:185)가 타깃 식재료의 strongPairsOf 중 잘먹는 것을 곁들임으로 그대로 출력 → 게이트 없음·같은 군 여부 미검사. '치킨에 돼지불고기 곁들임'(단백질 위 단백질) 누수.
요구 (a) 곁들임 앵커(pairWithLiked)는 ① pairGateOk(타깃식재료, 곁들임) 통과 ② groupOfIngredient(곁들임) ≠ groupOfIngredient(타깃식재료)(같은 식품군 곁들임 금지)인 것만 남긴다. 통과분 없으면 곁들임 절(' · 잘 먹는 …곁들이면 좋아요')을 통째로 생략(빈 곁들임 강요 금지).
구현 lib/coachRecos.ts:185. 현행 `pairWithLiked = strongPairsOf(ing).filter(n=>likedSet.has(n.nm))…`. → 여기 두 조건 추가: `.filter(n => pairGateOk(ing, n.nm) && groupOfIngredient(n.nm, catOf) !== groupOfIngredient(ing, catOf))`. catOf는 buildRecoFacts args에 신규 옵션 인자로 받아 주입(cron 662·api/coach 56 호출부 갱신). 187행 템플릿은 pairWithLiked.length 가드가 이미 있으므로 빈 배열이면 곁들임 절 자동 생략(무변경). comboGuard import 추가.
파일 lib/coachRecos.ts · app/api/cron/coach/route.ts · app/api/coach/route.ts
유즈케이스 아린 '치킨' 타깃 편지: 곁들임 앵커가 돼지불고기(같은 군)면 제외, 잘먹는 밥/김(다른 군·궁합)만 남거나 곁들임 절 자체 생략.
테스트
  • G-03-1 타깃 닭고기 + 잘먹는 돼지고기 → 같은 군(고기·계란)이라 곁들임 제외(절 생략)
  • G-03-2 타깃 당근(비타민A채소) + 잘먹는 밥(곡물) → pairGateOk·다른 군이면 곁들임 유지
  • G-03-3 미역국+당근류 괴식 pair는 pairGateOk false로 제외
  • G-03-4 곁들임 0개면 '[오늘 타깃 …] (또래 인기 음식: …)'만 출력하고 곁들임 절 미생성
  • G-03-5 기존 정상 곁들임(다른 군·강한 궁합) 회귀 통과
DoD vitest 5/5. (a)경로에 같은-군 곁들임·괴식 pair 0건. tsc 0·next build 0. api/coach·cron 양 호출부 catOf 주입 완료.
의존: G-01, G-02 · 규모 M · 우선순위 P0
⚠ catOf 미주입 경로(api/coach route.ts:56)에서 groupOfIngredient가 표준명만 풀면 운반체 곁들임이 잠깐 통과할 수 있음 → 표준 식재료 곁들임이 대부분이라 영향 작고, cron(라이브)은 catOf 주입으로 완전 차단.

G-04 buildRecoFacts (b) 사촌 — 결핍 보강일 때만 곁들임 통과코드규모 M

목적 근본원인 #12(high): buildRecoFacts (b)경로(coachRecos.ts:192-205)가 잘먹는 식재료의 verifiedCousinsOf를 무조건 곁들임으로 출력. bridge는 본질적으로 동일 식품군(데이터상 same-group 10:cross 2)이라 '돼지고기→닭고기'(둘 다 고기·계란) 같은 결핍 보강 아닌 같은-군 사촌이 곁들임 짝으로 새어나감.
요구 사촌(verifiedCousinsOf) 곁들임 출력은 그 사촌이 ① 결핍군(target/missing/homeMissing 중 하나)에 속할 때만 통과(결핍 보강) ② 앵커(잘먹는 식재료)와 다른 식품군일 때만. 같은 군 사촌(돼지고기→닭고기)은 '교체 사촌'이지 곁들임 짝이 아니므로 제외. 단 고구마↔단호박처럼 결핍 채소군 내 교차는 결핍군 게이트로 정상 통과해야 함.
구현 lib/coachRecos.ts:192-205. buildRecoFacts args에 `deficientGroups?: string[]`(target∪missing∪homeMissing) 신규 인자. 194행 `cs = verifiedCousinsOf(ing).filter(...).slice(0,1)`에 조건 추가: `const cg = groupOfIngredient(c.nm, catOf); cg && deficientGroups.includes(cg) && cg !== groupOfIngredient(ing, catOf)`. 사촌이 결핍군 아니거나 앵커와 같은 군이면 그 c는 드롭. cs 비고 pr(궁합)도 비면 195-196의 `if(!cs.length && !pr.length) continue`로 그 앵커 줄 자체 생략(기존 가드 재사용). 호출부(cron 662)에서 deficientGroups=`[...(planCtx?.target?[planCtx.target]:[]), ...gMissing, ...gHomeMissing]` 주입.
파일 lib/coachRecos.ts · app/api/cron/coach/route.ts
유즈케이스 아린 6/17: 잘먹는 돼지고기의 사촌 닭고기를 곁들임으로 권하던 줄이 사라지고, 결핍 채소군(비타민A채소) 사촌만 남아 실제 결핍을 보강.
테스트
  • G-04-1 앵커 돼지고기 + 사촌 닭고기(같은 고기·계란군) → 곁들임 제외
  • G-04-2 앵커 고구마 + 사촌 단호박(둘 다 비타민A채소·결핍군) → 결핍 보강이면 통과
  • G-04-3 사촌이 비결핍군이면 곁들임 제외(결핍 보강 아님)
  • G-04-4 cs·pr 모두 비면 앵커 줄 통째 생략
  • G-04-5 deficientGroups 미주입 시 보수적(사촌 곁들임 미출력) 폴백
DoD vitest 5/5. (b)경로에 단백질↔단백질·비결핍 사촌 곁들임 0건. cousins 배열엔 결핍군 사촌만. tsc 0.
의존: G-01, G-02 · 규모 M · 우선순위 P0
⚠ deficientGroups가 빈 배열이면 사촌 곁들임 전부 사라져 (b)경로가 빈약해질 수 있음 → red 없는 균형 아이에선 의도된 보수적 동작(곁들임 강요 금지 원칙과 일치).

G-05 사촌 인기음식(popularDishesFor) × 본 재료 괴식 게이트코드규모 M

목적 근본원인 #13 확장: buildRecoFacts (b)에서 사촌의 popularDishesFor(coachRecos.ts:201-202)·(a)의 dishStr(186)가 인기음식을 그대로 본문에 노출 → 음식×식재료 dish×ing 괴식 게이트 미적용이라 부적합 조합 음식이 셀 수 있음.
요구 popularDishesFor가 반환하는 음식 각각이 해당 사촌/타깃 식재료와 comboGateOk 통과하는 것만 노출(인기 빈도≥4 + 괴식 게이트). 통과 음식 없으면 '사촌 X'만(인기음식 절 생략).
구현 lib/coachRecos.ts:201 `popularDishesFor(c.nm, freqMap)` 결과를 `.filter(d => comboGateOk(d, c.nm))`로 후처리. popularDishesFor 자체(63-70)는 isSpicyDish만 거르므로, comboGuard.comboGateOk를 통과 필터로 추가. (a)경로 dishStr(186)도 동일 적용: `const dishes = popularDishesFor(ing,freqMap).filter(d=>comboGateOk(d,ing))`. 빈 배열이면 STAPLE_FORMS 폴백 유지·둘 다 없으면 dishStr 절 생략(183행 continue 가드 재사용).
파일 lib/coachRecos.ts
유즈케이스 사촌 단호박(또래 인기: 단호박죽·찐 단호박)은 게이트 통과·노출, 어색한 조합 음식은 제외돼 편지의 음식 예시가 전부 자연스러움.
테스트
  • G-05-1 사촌 식재료의 인기음식 중 괴식 조합 제외
  • G-05-2 통과 음식 0개면 '사촌 X'만 출력(인기 절 생략)
  • G-05-3 정상 인기음식(볶음밥+당근류)은 유지
  • G-05-4 주식 곡물 사촌은 STAPLE_FORMS 형태 유지(게이트 무관)
  • G-05-5 spicy+combo 이중 필터 동작
DoD vitest 5/5. (a)·(b) 인기음식 노출이 전부 comboGateOk 통과. tsc 0.
의존: G-02, G-04 · 규모 M · 우선순위 P1
⚠ dishesForIngredient(kit-matrix)에 음식이 있어도 scores에 dish×ing이 미수록이면 comboGateOk가 보수적 false를 줘 인기음식이 과도하게 사라질 수 있음 → kit-matrix는 음식×식재료 cells가 채워진 코퍼스라 실측 충돌은 낮음, G-05-3로 정상 보존 확인.

G-06 라이브 배선 — cron route.ts:662 buildRecoFacts에 게이트 인자 주입배선규모 S

목적 근본원인 #13(high): G-03~G-05 게이트가 coachRecos에 들어가도 라이브 cron(route.ts:662)이 catOf·deficientGroups를 주입하지 않으면 폴백(표준명·보수적)으로만 동작. 휴면→라이브 실배선.
요구 cron route.ts:662 buildRecoFacts 호출에 catOf와 deficientGroups를 주입. catOf=route 상단에 이미 구성된 catOf(refExposable 354에서 사용 중). deficientGroups=target∪gMissing∪gHomeMissing. api/coach route.ts:56 온디맨드도 동일 시그니처로 갱신(catOf 없으면 표준명 폴백).
구현 app/api/cron/coach/route.ts:662 `buildRecoFacts({ likedIngredients: likedIng, target: planCtx?.target, targetIngredient: recoIng, freqMap })` → `…, catOf, deficientGroups: [...(planCtx?.target?[planCtx.target]:[]), ...gMissing, ...gHomeMissing] }`. catOf는 354행 refExposable에서 쓰는 동일 catOf 클로저 재사용(필요 시 자녀 루프 상단으로 호이스트). app/api/coach/route.ts:56은 catOf 미보유라 deficientGroups=[precomputed.plan.target].filter(Boolean)만 주입(곁들임 보수적). buildRecoFacts 시그니처(coachRecos.ts:169)에 catOf?·deficientGroups? optional 추가.
파일 app/api/cron/coach/route.ts · app/api/coach/route.ts · lib/coachRecos.ts
유즈케이스 라이브 야간 크론이 아린 편지를 만들 때 buildRecoFacts가 실제로 게이트를 거쳐 괴식·같은-군 곁들임을 0으로 발행.
테스트
  • G-06-1 cron 통합: 닭고기 타깃 편지 bridgeFacts에 같은-군 곁들임 0건(스냅샷)
  • G-06-2 api/coach 온디맨드도 게이트 적용 호출(인자 전달 검증)
  • G-06-3 deficientGroups 주입 시 (b)경로가 결핍군 사촌만 산출
  • G-06-4 freqMap 미주입(graceful) 폴백 무회귀
DoD 양 호출부 catOf·deficientGroups 주입. tsc 0·next build 0. 아린 6/17 재생성 시 단백질↔단백질 곁들임 0(라이브 검증).
의존: G-03, G-04, G-05, A-anchor-gate · 규모 S · 우선순위 P0
⚠ catOf 클로저가 refExposable 스코프 안에만 있으면 662행에서 접근 불가 → catOf를 cron 자녀 루프 상단으로 호이스트 필요(소규모 리팩터).

G-07 결정론 작문 가드에 same-group/괴식 곁들임 검출 추가 (letterDeterministicBad)코드규모 M

목적 근본원인 #13/#16 보강: G-03~G-06이 추천 '재료'를 막아도 손(LLM)이 한 문장에 '단백질 옆 단백질' 곁들임을 자유작문할 수 있음. letterDeterministicBad(coach.ts:485)는 해조류↔생선·과일↔끼니 등은 잡지만 같은-군 단백질 곁들임 규칙은 없음 → 사후 재생성 안전망 추가.
요구 생성된 편지 본문에 '고기/생선/단백질 음식 + 곁들/옆에/함께 + 또 다른 고기·계란·생선군 음식'이 한 문장에 동시 등장하면 letterDeterministicBad=true(cron 재생성). 정상(고기+채소 곁들임)은 통과(오탐 0).
구현 lib/coach.ts:484-501 letterDeterministicBad에 신규 정규식 절 추가. 고기·계란/생선군 음식 어휘 RE(소고기|돼지|불고기|닭|치킨|계란|달걀|생선|고등어|연어|새우 …)와 곁들임 동사 RE(곁들|옆에|함께\s?(차려|내|올려|두))를 쓰되, 한 문장(split /[.!?。\n]/)에 PROTEIN_RE가 2회 이상 + PAIR_VERB면 true. 채소·곡물 단어가 한쪽이면 false(같은 군일 때만 발화). 478행 fruitSaltyMix 패턴 재사용 스타일. NO_MIX 기존 절들과 독립.
파일 lib/coach.ts
유즈케이스 손이 게이트를 우회해 '돼지불고기에 닭가슴살 살짝 곁들여' 문장을 써도 cron이 letterDeterministicBad로 잡아 재생성.
테스트
  • G-07-1 '돼지불고기에 닭가슴살 곁들여' → bad=true
  • G-07-2 '닭고기에 익힌 당근 곁들여' → bad=false(채소 곁들임 정상)
  • G-07-3 '고등어구이 옆에 새우' → bad=true(생선·해산물 동일군)
  • G-07-4 단백질 음식 1개만 등장 → bad=false
  • G-07-5 기존 letterDeterministicBad 회귀(해조류·과일·김치 절 무변경)
DoD vitest 5/5. 같은-군 단백질 곁들임 문장 검출·정상 곁들임 오탐 0. 기존 가드 테스트 그린.
의존: G-01 · 규모 M · 우선순위 P1
⚠ 어휘 RE가 넓으면 '닭고기와 소고기 둘 다 잘 먹어요'(곁들임 아님) 오탐 → PAIR_VERB(곁들/옆에/함께차려) 동반 조건으로 한정, G-07-2/G-07-4로 오탐 검증.

G-08 괴식 게이트 라이브 통합 회귀 스위트 (아린 6/17 박제)테스트규모 S

목적 근본원인 #3/#12/#13의 실증 사례(6/17 치킨 타깃 → 돼지불고기/닭가슴살 곁들임 괴식)를 골든으로 박제해 회귀 방지. EPIC G 전체 DoD의 측정 가능한 종합 게이트.
요구 아린 6/17 입력형(타깃=치킨/고기·계란군, 잘먹는=돼지고기·밥 등)으로 buildRecoFacts(게이트 인자 주입)를 호출했을 때 출력 lines에 '단백질↔단백질 곁들임' 0건·괴식 pair 0건·결핍군 사촌만 등장함을 단언. comboGateOk/pairGateOk 직접 호출 골든도 포함.
구현 tests/coach-combo-gate-live.test.ts 신규. (1) buildRecoFacts({likedIngredients:['돼지고기','밥'], target:'고기·계란', catOf, deficientGroups:['비타민A채소','콩류']}) → lines에 '닭고기/닭가슴살/돼지' 곁들임 토큰 부재 단언. (2) deficientGroups에 결핍 채소군 주면 사촌 곁들임이 그 군에서만 나옴. (3) comboGateOk('미역국','당근')===false·('볶음밥','당근')===true 박제. coach-hybrid-combo.test.ts 스타일(골든 fixture) 차용.
파일 tests/coach-combo-gate-live.test.ts
유즈케이스 이후 누구든 buildRecoFacts·comboGuard를 건드리면 아린 6/17 괴식이 다시 새는지 prebuild에서 즉시 적발.
테스트
  • G-08-1 아린 6/17 재현: buildRecoFacts 출력에 단백질↔단백질 곁들임 0
  • G-08-2 결핍군 사촌만 곁들임 등장
  • G-08-3 미역국+당근 게이트 차단 박제
  • G-08-4 정상 채소 곁들임은 보존(오탐 0)
  • G-08-5 운반체명 타깃도 group 정규화로 same-group 차단
DoD vitest 5/5 그린. EPIC G 전 게이트(G-01~G-07)를 통과한 빌드에서 아린 6/17 괴식 시나리오 0 재현. prebuild 게이트 편입.
의존: G-03, G-04, G-05, G-06, G-07 · 규모 S · 우선순위 P1
⚠ 골든이 freqMap/그래프 데이터 변경에 취약 → 게이트 판정(ok/차단)만 단언하고 구체 음식명 문자열은 최소 단언(데이터 진화 허용).

EPIC H — 앵무새 해체 (Anti-Parrot Variation) D3

발행 편지가 매일 같은 골격(scenario별 고정 opener·prebaked 영양거울 한 구절·"또래들은 OO"/"부모가 먼저 맛있게"/"작은 접시 따로" 곁들임 템플릿)으로 수렴하는 '앵무새' 증상을 봉합한다. 근본은 ① SCEN_OPEN이 시나리오당 단일 고정 도입(coach.ts:304-318) ② varyOpener가 직전 1편만 검사(coach.ts:449·467) ③ mirrorBlock이 쿨다운 없이 고정 문구를 매일 must-weave(coach.ts:621-630) ④ 작성 지침이 "인기 음식 이름 반드시 한 번+또래들은 OO"를 매 편지 강제(coach.ts:638-639) ⑤ 반복 탐지가 어휘 3-gram 자카드뿐(letterSimilarity/simBadness/simToPrev)이라 식재료만 바꾼 동일 골격을 못 잡음. 설계 축: opener를 daySeed+cidHash 회전 변형으로, 영양거울·곁들임을 쿨다운/회전으로 강등, 그리고 어휘가 아닌 '구조'(문장유형 시퀀스·상투 슬롯 n-gram) 시그니처로 앵무새를 독립 측정해 composeLetter 재생성 루프와 route.ts 발행 경보 양쪽에 배선한다.

H-01 구조 시그니처 추출기 — 문장유형 시퀀스 + 상투 슬롯 n-gram코드규모 M

목적 앵무새 자동탐지를 어휘(letterSimilarity 3-gram 자카드)에서 '구조' 축으로 확장하는 측정 기반. 식재료만 바꾼(닭가슴살→방울토마토, 돼지불고기→귤) 동일 골격이 lexical 임계 0.45/0.6을 통과해 조용히 발행되는 것을 잡기 위함(confirmed.json '앵무새 골격'·'Parrot auto-detection is lexical-only' 두 건).
요구 편지 본문을 받아 (a) 문장유형 시퀀스(인정/공감·사실·행동·영양거울·곁들임추천·끝줄권유 등 역할 라벨의 순서 배열)와 (b) 상투 슬롯 존재 비트마스크(작은접시 따로·부모먼저 맛있게·또래들은 OO·잘게 섞어·N일 전 재노출)를 정규식으로 추출해 안정적 구조 시그니처 문자열을 반환한다. 두 편지의 구조 유사도(0~1)를 반환하는 structuralSimilarity(a,b)도 함께 export.
구현 lib/coach.ts에 letterSimilarity(269-281) 바로 아래 신설. ① SLOT_PATTERNS: Record<string,RegExp> = { smallPlate:/작은\s?접시|따로\s?(담|덜|내)/, parentModel:/부모(님)?가?\s?먼저|보는\s?앞에서.{0,8}한\s?입|자연스럽게\s?맛있게/, peers:/또래(들)?(은|도)|친구(들)?(이|도).{0,12}(먹|좋아)/, mixIn:/잘게\s?(섞|다져|으깨)|소량\s?섞/, reexpose:/[0-9]+\s?일\s?전.{0,12}(다시|재노출|권)/, mirror:/다양성|골고루|부족한?\s?식품군|채워(주|지)/ } (코드 정독: 이 토큰들이 confirmed.json '작은접시·부모먼저·또래들은·잘게섞·N일전'·coach.ts:638-639·SYSTEM_COACH:39 Food Dudes 출처). ② skeletonSig(L): 문장 split(/[.!?。\n]/) 각 문장을 SENTENCE_ROLE 정규식(인정:/덕분|고생|괜찮|자연스/·사실:/[0-9]+(회|번|일)|기록|먹었|거부/·행동:/해\s?보세요|권해|담아|곁들|섞/·끝줄권유:/기록.{0,6}채워|알려주시면/)으로 라벨링한 시퀀스 join('>') + '|' + 6개 SLOT 비트('1'/'0' 연결). ③ export function structuralSimilarity(a,b): 시퀀스 라벨 LCS 비율(0.6 가중) + 슬롯 비트 일치율(0.4 가중). 순수함수·LLM 0콜·결정론.
파일 lib/coach.ts
테스트
  • HS-01-1 같은 골격(인정>사실>행동>또래들은) 식재료만 다른 두 편지 structuralSimilarity ≥ 0.8
  • HS-01-2 다른 골격(축하만 vs 사실>행동>곁들임) ≈ 0.3 이하
  • HS-01-3 skeletonSig 결정론: 동일 본문 두 번 호출 동일 문자열
  • HS-01-4 슬롯 정규식: '또래들은 순두부찌개로도'·'부모가 먼저 맛있게'·'작은 접시에 따로' 각각 비트 점등
DoD structuralSimilarity·skeletonSig export·순수함수, vitest HS-01 4케이스 그린, 식재료만 바꾼 6/17·6/16류 골격쌍이 ≥0.8로 판정됨이 테스트로 박제.
의존: 없음 · 규모 M · 우선순위 P0
⚠ 정규식 슬롯 오탐(false positive)으로 정상 다양한 편지가 재생성 트리거될 수 있음 → 임계는 H-04/H-08에서 보수적으로(0.8+) 설정하고 슬롯 비트는 보조 가중으로만.

H-02 SCEN_OPEN 시나리오당 2~3 변형 + daySeed·cidHash 회전코드규모 M

목적 시나리오당 고정 단일 도입(coach.ts:304-318 SCEN_OPEN)이 같은 시나리오가 비인접일에 재등장하거나 food 타깃이 nutrient-gap/new-refusal로 수렴할 때 매번 동일 톤으로 열리는 것을 차단(confirmed.json 'openBlock fixes a per-scenario opener').
요구 SCEN_OPEN을 시나리오당 2~3개 변형 배열로 확장하고, openBlock(coach.ts:550-554)이 (daySeed+cidHash) 시드로 변형 1개를 결정론 선택하도록 한다. 변형은 같은 시나리오 의도(예: re-exposure는 'N일 전 숫자 인용')를 유지하되 진입 각도(데이터 인용/질문/작은 변화 포착)를 달리한다.
구현 coach.ts:304 const SCEN_OPEN: Record<string,string> → Record<string,string[]>로 변경, 각 시나리오 2~3변형(예: 're-exposure-timing':['재노출 타이밍(N일 전 숫자)을 데이터로 인용하며 열어라','"마지막으로 만난 지 N일"을 부드러운 질문으로 던지며 열어라','그 식재료를 다시 만나기 좋은 시점이 됐다는 관찰로 열어라']). LetterInput에 openVariant?:number 추가. buildLetterUser엔 daySeed가 없으므로 composeLetter(coach.ts:769는 p.daySeed·p.cidHash 보유)가 letterInput 조립(776) 시 openVariant=(daySeed+cidHash) 세팅. openBlock(550-554)의 SCEN_OPEN[sid] 참조를 SCEN_OPEN[sid][(b.openVariant??0)%SCEN_OPEN[sid].length]로. 시드는 buildCoachPlan(coach.ts:395)과 동일하게 (daySeed+cidHash)(자녀별 변형 분산).
파일 lib/coach.ts
테스트
  • HS-02-1 SCEN_OPEN 모든 시나리오 변형 ≥2개 보유
  • HS-02-2 openVariant 회전 결정론: 같은 (daySeed,cidHash) 동일 변형 인덱스
  • HS-02-3 연속 3일(daySeed+1씩) 같은 시나리오라도 변형 인덱스 최소 2종 등장
  • HS-02-4 buildLetterUser 출력에 선택된 변형 문구가 실제 포함
DoD SCEN_OPEN 배열화·openVariant 회전 배선, varyOpener=false 경로에서도 도입이 daySeed로 회전, vitest HS-02 그린, tsc 통과.
의존: 없음 · 규모 M · 우선순위 P0
⚠ 변형 문구 작성 품질(같은 의도 유지)이 사람 손 — 변형이 의도를 벗어나면 시나리오-도입 불일치. 변형은 기존 단일 문구의 패러프레이즈로 보수적 작성.

H-03 varyOpener를 직전 1편 → 최근 N편(3) 동일 프레임 검사로 확장코드규모 S

목적 varyOpener가 recentPlans[0](직전 1편)의 frame만 비교(coach.ts:449 brain 경로·467 결정론 경로)해, 같은 시나리오가 하루 걸러(D-2, D-3) 재등장하면 정형 도입이 다시 새는 것을 봉합(confirmed.json 'varyOpener only checks the previous letter').
요구 varyOpener 판정을 recentPlans[0] 단일 비교에서 최근 N편(기본 3, recentPlans는 최근 3일치 적재됨) 중 동일 frame 존재 여부로 확장한다. brain 경로(449)와 결정론 경로(467) 양쪽 동시 적용(한쪽만 고치면 forced 경로 재발).
구현 coach.ts planFor(442): 두 곳 `p.recentPlans[0]?.frame === bp.frame`(449·467)를 헬퍼 `const sameFrameRecently = (frame) => p.recentPlans.slice(0,3).some((q)=>q.frame===frame)`로 교체. 449: `varyOpener: sameFrameRecently(bp.frame)`, 467: `const varyOpener = sameFrameRecently(bp.frame)`. recentPlans는 route.ts:174에서 최근 3일 ctx.plan 적재되므로 윈도 일치. history 블록 LLM 지시(coach.ts:536-541 '최근 3일과 겹치지 마라')와 윈도를 3으로 통일(confirmed.json 권고 (1) 윈도 정합).
파일 lib/coach.ts
테스트
  • HS-03-1 최근 3편 중 D-2가 동일 frame이면 varyOpener=true
  • HS-03-2 최근 3편 모두 다른 frame이면 varyOpener=false
  • HS-03-3 forceScenarioId(brain) 경로도 동일 N편 검사 적용
  • HS-03-4 recentPlans 빈 배열 안전(varyOpener=false)
DoD planFor 449·467 두 경로 모두 최근 3편 윈도 검사로 통일, vitest HS-03 그린, forceScenarioId 미사용 경로의 기존 동작 유지(byte 대조군 무영향).
의존: 없음 · 규모 S · 우선순위 P0
⚠ varyOpener=true가 더 자주 켜지면 도입 다양화 압력↑(부작용 적음). H-02 변형 회전과 결합해 'true면 정형 회피·false면 daySeed 변형'으로 이중 다양화.

H-04 composeLetter 재생성 루프에 구조 유사도 항 추가코드규모 M

목적 composeLetter의 simBadness 재생성 가드(coach.ts:792-815)가 어휘(letterSimilarity 0.45/0.40)뿐이라 동일 골격을 절대 트리거하지 못함. 구조 유사도(H-01)를 badness에 가산해 어휘를 통과해도 구조가 같으면 무브 회전 재생성이 돌게 함(confirmed.json fix ③ '유사도 재생성 루프에 골격 동일 시 bestBad 가산').
요구 simBadness(coach.ts:798)에 structuralSimilarity 기반 항을 max로 합성한다. structuralSimilarity(t, 과거편지)/STRUCT_THRESHOLD(예: 0.8)를 기존 wholeMax/openMax 비율과 함께 Math.max로 묶어 ≥1이면 재생성. 재생성은 기존 무브/regenAvoid 회전 루프(804-815)를 재사용하고 채택 조건(det 가드 재통과·S1 대칭)을 그대로 유지.
구현 coach.ts:794-798 pastLetters 루프 내 `const structMax = (t)=>Math.max(...pastLetters.map((q)=>structuralSimilarity(t, q.letter)))` 추가, simBadness(798)를 `(t)=>Math.max(wholeMax(t)/0.45, openMax(t)/0.40, structMax(t)/0.80)`로 확장. 804-815 재생성 루프는 무변경(simBadness가 구조항 포함하므로 골격 동일이면 bestBad≥1 → altMove+regenAvoid로 재작성). 822 검증자 채택조건도 simBadness<1을 이미 보므로 구조항 자동 반영. STRUCT_THRESHOLD 상수는 보수적 0.80(H-01 오탐 위험 완화).
파일 lib/coach.ts
테스트
  • HS-04-1 어휘 낮음+구조 동일(0.85) 편지 simBadness≥1로 재생성 트리거
  • HS-04-2 어휘·구조 모두 낮음 → simBadness<1 통과
  • HS-04-3 pastLetters 없으면 simBadness=0(기존 안전 폴백 유지)
  • HS-04-4 재생성 산출이 det 가드 재통과 못하면 미채택(S1 대칭 유지)
DoD simBadness가 구조항 포함, 구조 동일 편지가 재생성 루프를 실제로 통과(테스트로 박제), 기존 어휘 가드 동작 회귀 없음, vitest HS-04 그린.
의존: H-01 · 규모 M · 우선순위 P0
⚠ 재생성 콜 증가로 LLM 비용·deadline 압박 → timeLeft()/attempt<2 기존 캡이 그대로 적용되어 폭증 차단(coach.ts:804). 임계 0.80 보수 설정으로 오탐 재생성 최소화.

H-05 route.ts 발행 경보에 구조 유사도 축 추가(simToPrev 보강)코드규모 S

목적 발행 시점 자동 반복 경보(route.ts:688-693)가 어휘 simToPrev(≥0.6)와 sigRun(시그니처 2연속)뿐이라 동일 골격을 못 잡음. 시그니처는 buildCoachPlan이 의도적으로 매일 회전시켜 구조적으로 무력(confirmed.json 보정). 구조 유사도를 어드민 경보·context에 적재해 모니터링 가능하게.
요구 route.ts 발행 경보에 structToPrev = max(structuralSimilarity(letter, 과거편지)) 측정을 추가하고, 임계(예: 0.8) 이상이면 repeatAlert=true·issues에 '구조반복' 사유 기록. letterCtx.context에 structToPrev 필드 적재(어드민 노출).
구현 app/api/cron/coach/route.ts:688 simToPrev 측정 직후 `const structToPrev = pastLetters.length && letter ? Math.round(Math.max(...pastLetters.map((q)=>structuralSimilarity(letter, q.letter)))*1000)/1000 : null;` 추가(coach.ts에서 structuralSimilarity import). 690 조건을 `if ((simToPrev ?? 0) >= 0.6 || sigRun >= 2 || (structToPrev ?? 0) >= 0.8)`로 확장, 692 issues 문구에 `· 구조유사도 ${structToPrev ?? 0}` 추가. letterCtx(698-715)의 `simToPrev, repeatAlert` 줄(709)에 structToPrev 추가.
파일 app/api/cron/coach/route.ts
테스트
  • HR-05-1 structToPrev≥0.8이면 repeatAlert=true·issues에 구조유사도 기록(route 헬퍼 단위 또는 리플레이)
  • HR-05-2 어휘·시그니처 정상이어도 구조 동일이면 경보(어휘 단독 무력 케이스)
  • HR-05-3 context.structToPrev 적재 확인
DoD 발행 경보가 구조 축 포함, 어드민 보고서/context에 structToPrev 노출, 골격 동일 편지가 어휘 통과해도 경보됨(리플레이로 확인), vitest/리플레이 그린.
의존: H-01 · 규모 S · 우선순위 P1
⚠ 경보는 발행을 막지 않는 모니터링이라 저위험. 임계 0.8 오탐 시 issues 노이즈 → H-04 재생성이 선행 차단하므로 경보까지 가는 케이스는 잔여분만.

H-06 영양거울(mirrorBlock) 쿨다운 + 문구 daySeed 회전 + must→optional 강등코드규모 M

목적 mirrorBlock(coach.ts:621-630)이 쿨다운 없이 같은 prebaked 한 구절을 매일 must-weave로 주입해(daycare+home-deficit 아이는 매번 '어린이집 덕에…집 끼니엔 OO가 드무니' 동일 문장) history 블록의 '최근 쓴 안심문구 빼라' 호소를 강제로 덮어쓰는, 앵무새의 always-on 꼬리(confirmed.json 'mirrorBlock always emitted as prebaked fixed phrase').
요구 (a) 쿨다운: 최근 N일(2-3) 같은 분기(allMiss/homeMiss/일반) 영양거울을 이미 실었으면 오늘은 must를 optional로 강등(최근 실은 경우만 생략 허용). (b) 같은 분기 내 문구를 daySeed로 2~3 변형 회전. (c) snackShown 쿨다운(route.ts:637-638 SNACK_COOLDOWN=2)과 동일 패턴 재사용. 영양거울 정보 자체가 사라지진 않게(생략은 최근 실은 경우만).
구현 route.ts: recentLetters 적재부(174 인근)에 `if (ctx?.mirrorShown) (recentMirrorDates[cid] ||= []).push(l.letter_date)` 추가(snackShown 패턴 모사). base 조립(671-682) 전 `const mirrorRecently = (recentMirrorDates[cid]||[]).some((d)=>d>=dAgo(2))` 계산, base에 mirrorMode: mirrorRecently?'optional':'force' 전달. coach.ts: LetterInput에 mirrorMode?·mirrorVariant? 추가(composeLetter가 (daySeed+cidHash)로 mirrorVariant 세팅). mirrorBlock IIFE(621-630): phrase 3분기 각각 daySeed 변형 2개 배열로(예: allMiss→['전체적으로 ${allMiss}가 좀 부족해서, 집에서 조금씩 채워주면 좋아요','${allMiss} 쪽을 집 끼니에 가끔 더해주면 다양성에 보탬이 돼요']), `[(b.mirrorVariant??0)%len]` 선택. 라벨 '반드시 자연스럽게 녹여라'를 mirrorMode==='optional'이면 '가능하면 한 구절로, 최근에 이미 비슷한 말을 했으면 생략 가능'으로 분기. letterCtx에 mirrorShown 적재.
파일 lib/coach.ts · app/api/cron/coach/route.ts
테스트
  • HM-06-1 mirrorMode=optional일 때 mirrorBlock 라벨이 '생략 가능'으로 강등
  • HM-06-2 같은 분기라도 daySeed 다르면 다른 변형 문구 출력
  • HM-06-3 allMiss/homeMiss/일반 3분기 모두 변형 ≥2개 보유
  • HM-06-4 mirrorRecently 판정: 최근 2일 mirrorShown 있으면 optional
DoD mirrorBlock이 쿨다운·변형 회전·optional 강등 지원, daycare+home-deficit 아이의 영양거울 문구가 매일 동일하지 않음(테스트 박제), vitest HM-06 그린.
의존: 없음 · 규모 M · 우선순위 P0
⚠ 영양거울 누락 강제(EPIC G의 must-weave 포함검증)와 충돌 가능 — optional 강등은 '포함검증' 분기와 정합되게(최근 실은 경우만 생략 허용). G의 포함검증이 선행 배선됐다면 그 쿨다운 윈도와 일치시킬 것.

H-07 곁들임 템플릿("또래들은 OO"·인기음식 이름) 매일강제 → daySeed 회전 요소로 강등코드규모 M

목적 작성 지침(coach.ts:638-639)이 '검증된 추천의 인기 음식 이름을 본문에 반드시 한 번 넣어라(a)' + '또래들은 OO로도 자주 먹어요'를 매 편지 강제해, LLM이 매번 '잘먹는음식+곁들임+또래인기음식' 동일 슬롯으로 수렴(confirmed.json fix ② '인기 음식 이름 반드시 한 번을 daySeed 회전 요소로 강등').
요구 639(a)의 '반드시 한 번 넣어라' + '또래들은 OO'를 매일 필수가 아닌 daySeed 회전 요소로 강등한다. 곁들임만/함께만들기만/조리법바꾸기만/또래제시만 등 단독 편지를 날짜로 배치하되, 추천 정보(bridgeFacts) 자체는 유지하고 '표현 방식'만 회전. 기존 SCEN_MOVES·MOVE_KEYS 회전 로직을 본문 슬롯 레벨로 확장.
구현 coach.ts buildLetterUser 작성 지침(638-639): 639(a)의 '그 줄의 인기 음식 이름을 본문에 반드시 한 번 넣어라'·'또래들은 OO처럼 그 음식을 확장 선택지로 함께 제시'를 LetterInput.recoStyle(daySeed로 산출한 'peers'|'mix-only'|'cook-together'|'name-only' 중 1)에 따라 분기 주입. composeLetter(769-783)에서 `recoStyle = RECO_STYLES[((p.daySeed+p.cidHash)%RECO_STYLES.length)]` 산출(plan.move 회전과 별개 축). recoStyle==='peers'일 때만 '또래들은 OO' 지시, 'mix-only'면 곁들임만, 'name-only'면 음식 이름 1개만 등. 단 brainPick.useFood===false(bridgeFacts='')면 곁들임 슬롯 자체 생략(route.ts:662 기존 게이트 존중).
파일 lib/coach.ts
테스트
  • HC-07-1 recoStyle 회전 결정론: (daySeed,cidHash) 동일 시 동일 스타일
  • HC-07-2 연속 4일 recoStyle 최소 3종 등장
  • HC-07-3 recoStyle=peers일 때만 '또래' 지시 포함, mix-only면 미포함
  • HC-07-4 bridgeFacts 비면 곁들임 슬롯 미주입(useFood=false 정합)
DoD 곁들임 표현이 daySeed로 회전(매일 '또래들은 OO' 강제 해제), 추천 정보 손실 없음, vitest HC-07 그린, tsc 통과.
의존: 없음 · 규모 M · 우선순위 P1
⚠ 추천 음식 이름 노출 빈도↓로 '인기음식 추천' 효과가 묽어질 수 있음 → name-only/peers 스타일이 회전 안에 충분히 포함되게 가중. 이사님이 '추천 항상 포함'을 강조했으므로 정보는 유지하고 표현만 회전임을 명확히.

H-08 구조 유사도 발행전 가드 — 검증자/composeLetter에 구조 위반 항목 추가코드규모 S

목적 H-04(재생성 가산)·H-05(경보)에 더해, 발행 직전 최종 안전망으로 '구조 동일'을 명시적 위반으로 잡아 1회 추가 재작성을 보장. 어휘·시그니처가 전부 통과한 잔여 골격 편지를 봉합(confirmed.json fix ① '구조 검증자 신설').
요구 composeLetter 마지막 단계(verifyLetter 직후·822-833)에서 구조 유사도가 임계 이상이면 fixNotes에 '골격 다양화' 지시를 주입해 1회 재작성하되, 채택은 구조 유사도가 실제로 낮아지고 det·어휘 가드를 전부 재통과할 때만(S1 대칭). 비용 폭증 방지를 위해 timeLeft()·1회 캡 준수.
구현 coach.ts composeLetter: 검증자 블록(820-834) 내부 또는 직후에, structMax(gen.letter) ≥ 0.80 && timeLeft()이면 `const g = await generateLetter({ ...letterInput, fixNotes: ['직전 편지와 문장 구성·곁들임 슬롯 골격이 똑같다 — 도입·문장 순서·곁들임 방식을 완전히 다른 구조로 다시 써라'], regenAvoid: closest(gen.letter).letter }, model)` 1회, `if (structMax(g.letter) < structMax(gen.letter) && !detBad(g.letter) && simBadness(g.letter) < 1) { gen = g; coachRegen = true; }`. structMax는 H-04에서 정의한 클로저 재사용(pastLetters 없으면 0). 검증자 재작성이 이미 발동한 경우 중복 콜 피하려 verify.regen 미발동일 때만 시도.
파일 lib/coach.ts
테스트
  • HG-08-1 구조 0.85 편지가 1회 재작성 후 구조↓면 채택
  • HG-08-2 재작성이 구조 못 낮추거나 det 위반이면 미채택(원본 유지)
  • HG-08-3 timeLeft=false면 추가 콜 생략(발행 우선)
  • HG-08-4 pastLetters 없으면 구조 가드 미발동
DoD 구조 동일 잔여 편지가 발행전 1회 재작성으로 봉합, 채택 시 모든 가드 재통과(S1), deadline 강등 준수, vitest HG-08 그린.
의존: H-01, H-04 · 규모 S · 우선순위 P1
⚠ 재작성 1회 추가 콜 비용 → 1회 캡·timeLeft·verify.regen 중복회피로 제한. H-04 simBadness 가산이 대부분을 선행 처리하므로 이 단계 발동 빈도는 낮음.

H-09 14일 리플레이 — 앵무새(구조 회전) 통주 골든 게이트테스트규모 M

목적 함수 단위 테스트만으로는 'daySeed가 매일 바뀌는 다일 시계열에서 실제로 골격이 회전하는가'를 보장 못함(6/11 교훈: 다일 리플레이가 통합버그 적발). EPIC H 산출(opener 회전·mirror 쿨다운·recoStyle 회전·구조 시그니처)이 14일 통주에서 동일 골격 연속을 만들지 않음을 박제.
요구 고정 결핍·고정 시나리오 입력으로 14일 시뮬을 돌려 (a) skeletonSig가 3일 연속 동일하지 않음 (b) opener 변형/mirror 변형/recoStyle이 회전함 (c) 인접 편지 structuralSimilarity가 임계 미만으로 수렴 0건임을 검증. coach-hybrid-rotation.test.ts(14일 회전 패턴)와 동형 구조로 작성.
구현 tests/coach-anti-parrot.test.ts 신설. coach-hybrid-rotation.test.ts의 simulate 패턴 차용: 14일 동안 daySeed=base+d, cidHash 고정, 동일 시나리오('home-daycare-gap')·동일 결핍 입력으로 (1) SCEN_OPEN[sid][(daySeed+cidHash)%len] 인덱스 시퀀스 (2) mirrorVariant 회전 (3) recoStyle 회전 (4) skeletonSig 시퀀스 수집. assert: opener/mirror/recoStyle 각 14일 내 ≥3 distinct, 3일 연속 동일 skeletonSig 0건. LLM 실호출 없이 결정론 산출물(H-02·H-06·H-07 함수)만 호출(generateLetter mock 불필요한 순수 회전 함수 단위로).
파일 tests/coach-anti-parrot.test.ts
테스트
  • HP-09-1 14일 opener 변형 ≥3종
  • HP-09-2 14일 mirror 변형 ≥2종(쿨다운 반영)
  • HP-09-3 14일 recoStyle ≥3종
  • HP-09-4 3일 연속 동일 skeletonSig 0건
DoD 14일 통주 리플레이가 그린이고 prebuild 게이트에 편입(고정 입력에서 골격 회전 보장), 회귀 시 빌드 RED.
의존: H-01, H-02, H-03, H-06, H-07 · 규모 M · 우선순위 P1
⚠ 리플레이가 결정론 회전 함수만 검증하고 실제 LLM 출력 골격은 미검증 → H-01 skeletonSig가 LLM 산출 본문 기준이므로 보완책으로 합성 본문 샘플 골격쌍도 1~2건 포함.

H-10 어드민 앵무새 모니터 — 구조유사도 패널 노출배선규모 S

목적 H-05가 context.structToPrev·issues에 적재한 구조 반복 신호를 어드민에서 실제로 보이게 해 이사님이 앵무새 재발을 모니터링하게 함(기존 simToPrev·repeatAlert 어드민 노출과 동형).
요구 어드민 코칭 스레드/리뷰 화면에서 편지별 simToPrev 옆에 structToPrev를 함께 표시하고, repeatAlert 사유에 '구조반복'을 구분 표기. 기존 simToPrev 표시 지점을 찾아 동일 패턴으로 추가.
구현 app/admin/[childId]/page.tsx(또는 코칭 리뷰 컴포넌트)에서 letterCtx.simToPrev를 읽어 표시하는 기존 지점에 structToPrev 추가 렌더. context.structToPrev 필드(H-05 적재)를 읽어 '어휘 ${simToPrev} · 구조 ${structToPrev}'로 병기. cron_runs.issues의 '구조반복' 문구(H-05)는 기존 issues 리스트 렌더에 자동 노출되므로 추가 작업은 편지 카드의 구조유사도 칩만.
파일 app/admin/[childId]/page.tsx
테스트
  • HA-10-1 structToPrev 필드가 있는 편지 카드에 구조유사도 칩 렌더
  • HA-10-2 structToPrev null이면 칩 미표시(구버전 편지 안전)
DoD 어드민에서 편지별 구조유사도 가시화, 구버전(structToPrev 없는) 편지 안전 폴백, 렌더 검증.
의존: H-05 · 규모 S · 우선순위 P2
⚠ 어드민 코드 경로 미확인(파일 정확 위치는 구현 시 grep으로 simToPrev 렌더 지점 확정 필요). 저위험 표시 전용.

EPIC I — 주간계획 위생 + 아이현황 스토어 (Weekly Hygiene & State Store) D3

주간 닻(weekly_plans)이 일간의 상류인데 ① status가 매주 'active'로만 적재돼 supersede 없이 다중 active(W22~W25 4행) ② focusHistory·progress·week가 runWeeklyPlanning에 미주입돼 applyFocusFatigue가 dormant → 같은 focus 유닛이 영원히 잡혀 lever=environment·behavior_goal='식탁' 고착 ③ advanceProgress/evolveRow 상태기계(curriculum.ts)가 완성·테스트됐으나 curriculum_progress에 write 경로가 0건이라 '환경이 안 먹히면 음식으로 진행'(stall/limping→pivot)이 닫히지 않음 ④ behavior_goal이 lever와 불일치(environment 주에 '콩 노출' food 행동). 이 EPIC은 status supersede·focusHistory 이월·teaching_arc 진행(advanceProgress 배선)·재앵커/stall 전환을 닫고, 주간+일간+편지+진척을 매일 1행으로 머티리얼라이즈하는 아이현황스토어(child_daily_state)를 신설한다. ⭐브레인이 닻을 덮어쓰는 근본봉합(planFromWeekly forceScenarioId)은 EPIC A의 닻 게이트(A-xx) 소관이라 본 EPIC 다수 원자가 그것을 선행 의존한다.

I-01 weekly_plans.status supersede — 신규 닻 저장 시 직전 주 active 일괄 archived(1 active/child 불변식)코드규모 S

목적 근본원인#24(W22~W25 4행 동시 active·status 변별력 상실) 봉합 — status 컬럼에 의미 복원, '활성 주간 1개' 불변식으로 어드민/운영 가시성 확보.
요구 synthAndStoreAnchor가 신규 닻 upsert 직후 직전 주 active를 archived로 일괄 update. 일간 동작 byte 무변경.
구현 app/api/cron/coach/route.ts synthAndStoreAnchor(L500~557) 안, L547 upsert(row) 성공 직후: `await supabase.from('weekly_plans').update({ status: 'archived', updated_at: new Date().toISOString() }).eq('child_id', cid).lt('week_key', wk).neq('status', 'archived')`. try/catch로 감싸 실패해도 닻 저장 무영향(안전 제1원칙). 정렬키는 week_key 텍스트 비교(isoWeekKey가 'YYYY-Www' 형식이라 lexicographic=시간순). sql/2026-06-09_weekly_plans.sql L10 status 주석에 'archived' 추가(default·CHECK 없음이라 스키마 변경 불필요). loadAnchor(L558~561)는 미변경.
파일 app/api/cron/coach/route.ts · sql/2026-06-09_weekly_plans.sql
유즈케이스 아린 weekly_plans W22~W25 4행 active → W25만 active, W22~W24 archived. 어드민 [childId] L182 status 표시가 변별력 회복.
테스트
  • I-01-1 신규 닻 upsert 후 직전 주 active 행이 archived로 전환
  • I-01-2 현재 주 행 status는 보존(자기 archive 금지)
  • I-01-3 supersede update 실패해도 닻 저장은 성공(degrade)
  • I-01-4 loadAnchor는 여전히 현재 week_key 행만 반환(일간 동작 회귀 0)
DoD 임의 자녀 2주치 크론 시뮬 후 weekly_plans에 active 행 1개/자녀, 직전 주들은 archived. 동일 입력 일간 편지 byte 무변경. vitest 그린.
의존: 없음 · 규모 S · 우선순위 P1
⚠ week_key가 isoWeekKey라 텍스트 정렬=시간순임을 가정 — 연말 ISO week 53/01 경계 lexicographic 역전 가능(2025-W53 < 2026-W01 OK이나 동일년 W09<W10 확인). 영향 작음(직전 주만 대상).

I-02 focusHistory 이월 — 직전 닻들의 focus 유닛·step 전진을 runWeeklyPlanning에 주입해 applyFocusFatigue 활성화코드규모 M

목적 근본원인#23(lever 고착·매주 environment/식탁 behavior_goal) 봉합 — applyFocusFatigue(coachWeekly.ts:96-108)가 focusHistory 입력이 없어 dormant라 같은 focus 유닛이 영원히 잡힘. 직전 주 닻에서 focusHistory를 산출해 주입하면 '같은 유닛 2주 연속 정체 시 차순위 승격'이 가동.
요구 직전 닻들에서 focusHistory 산출·주입해 applyFocusFatigue 활성화. progress·week도 동시 주입.
구현 route.ts synthAndStoreAnchor(L505-507) 닻 조회 select에 goals,ledger 추가(현재 week_key,mission_target,budget,ledger). 각 직전 닻에서 `goalsOf(a).find(g=>g.status==='focus')?.unit_id`와 stepAdvanced(직전 대비 ledger.exposeCount 증가 또는 targetAccepts>0를 step 전진 proxy로) 산출 → `focusHistory: ra.map(a => ({ unit_id: focusUnitOf(a), stepAdvanced: ... }))`. runWeeklyPlanning 호출(L520-534)에 focusHistory·week(가입 후 주차=첫 meal_log 기준, histDays 최소 log_date에서 isoWeek 차)·progress(현행 candSignals로 충분) 추가. coachWeekly.ts:207 `if (i.focusHistory?.length) goals = applyFocusFatigue(...)`가 이미 resolveGoals에서 호출하므로 입력만 채우면 활성. focusUnitOf 헬퍼 신설(goalsOf import는 coachWeekly에 이미 있음).
파일 app/api/cron/coach/route.ts · lib/coachWeekly.ts
유즈케이스 아린 table-stage가 envBadPct 0.14로 W22~W24 연속 focus·전진0 → W25 닻이 차순위(hunger-rhythm 등)로 자동 전환 → lever·behavior_goal이 environment 고착에서 벗어남.
테스트
  • I-02-1 focusHistory에 같은 unit 2주 연속·stepAdvanced=false면 resolveGoals가 차순위 standby 승격
  • I-02-2 step 전진 중이면 focus 유지(딥다이브 허용)
  • I-02-3 standby 대체 없으면 focus 유지(L104 대안없음)
  • I-02-4 focusHistory 빈 배열이면 applyFocusFatigue no-op(레거시 호환)
DoD focusHistory 2주 정체 fixture에서 W3 닻의 focus가 차순위로 바뀜(unit_id 변경). lever 고착 시나리오 회귀 테스트 그린. tsc·vitest 그린.
의존: 없음 · 규모 M · 우선순위 P1
⚠ stepAdvanced proxy(exposeCount/targetAccepts)가 진짜 step 전진과 다를 수 있음 — I-03(advanceProgress 배선) 후에는 curriculum_progress.step로 정밀화 권장(deps 아님·증분 개선).

I-03 advanceProgress 크론 배선 — curriculum_progress 로드→상태진화→upsert(첫 write 경로) + goalsAfter 닻 영속코드규모 L

목적 근본원인#10·#23 봉합 — advanceProgress/evolveRow(curriculum.ts) 상태기계가 완성·테스트됐으나 curriculum_progress에 write 0건이라 진척이 영속 안 됨. 배선하면 stall/limping→pivot(L200-215: '환경이 안 먹히면 음식 standby로 진행')이 닫혀 lever 고착이 데이터로 풀린다.
요구 curriculum_progress 로드→advanceProgress→upsert + goalsAfter 닻 영속. upsert 실패 폴백.
구현 route.ts 닻 블록(L573-611) 안, planFromWeekly 호출 후: `const { data: progRows } = await supabase.from('curriculum_progress').select('*').eq('child_id', cid)` → Partial<Record<UnitId,ProgressRow>> map. coachedDays/coachedYesterday는 recentLetters의 context.weekly.arc 또는 context.decision(I-03 신설)에서 유닛별 발행 일수 집계(없으면 빈 객체=보수적). answers는 daily_questions(topic=probe) 파싱(parseProbeAnswers in coachDaily.ts — 이미 존재). foodTarget=anchor.mission_target. advanceProgress(curriculum.ts:140)는 순수 함수. 결과 `res.updates`를 `curriculum_progress` upsert(onConflict 'child_id,unit_id', updated_at 갱신), `res.goalsAfter !== anchor.goals`면 weekly_plans.goals update + healAnchor 재적용. upsert 결과 error 검사→issues.push(가시화). import advanceProgress·blankRow from '@/lib/curriculum'.
파일 app/api/cron/coach/route.ts · lib/curriculum.ts
유즈케이스 아린 envTablePct 0.14 18일 고착 → W22~W24 limping 감지 → W25에 exposure-savings(food)로 피벗·콩류 진행 재개. curriculum_progress가 어드민 L105 패널에 실데이터로 채워짐.
테스트
  • I-03-1 신규 자녀 focus 유닛 active 활성화·curriculum_progress 1행 생성
  • I-03-2 limping(passStreak0·coachedDays≥3) → mode='pivot'·goalsAfter focus 플립·weekly_plans.goals 갱신
  • I-03-3 진전 신호 시 step++·passStreakDays 리셋·upsert step 증가
  • I-03-4 upsert 실패 시 issues 적재·코칭 계속(degrade)
  • I-03-5 재발 스캔: mastered 유닛이 relapseWindowDays 충족 시 relapsed로 전이·step 재개
DoD 아린 18일 리플레이에서 table-stage 18일 deepen 고착이 maxPivotsPerWeek 내 food standby로 피벗되고 curriculum_progress에 status='pivoted'·goalsAfter 영속. tsc·vitest 그린.
의존: I-02 · 규모 L · 우선순위 P0
⚠ coachedDays/coachedYesterday 원장이 부정확하면 isStalled 오판 — context.decision 신설(I-04)이 정밀 원장 제공. 초기엔 보수적(빈 객체=정체 미판정)으로 안전.

I-04 teaching_arc 단계 진행 — focus 유닛의 현재 step.behavior를 weeklyArc.behaviorGoal로 채우고 arc.stage를 mode 연동코드규모 M

목적 근본원인#10 봉합 — 12유닛 사다리(UNITS[u].steps[].behavior)가 일간 편지에 전혀 렌더 안 됨. 편지의 부모행동은 weeklyArc.behaviorGoal 한 줄뿐이고 유닛별 단계 teaching_arc는 설계만 있고 미배선. advanceProgress decision(step·mode)으로 그날 behavior 문구·아크 단계를 결정.
요구 focus 유닛 현재 step.behavior를 behaviorGoal로 결정론 세팅·arc.stage를 mode 연동·decision을 context 저장.
구현 route.ts L604 planFromWeekly가 반환하는 weeklyArc(coachWeekly.ts:310-312)에서 behaviorGoal=anchor.behavior_goal을 쓰던 것을, I-03 decision 존재 시 `UNITS[d.unit].steps[Math.max(0,d.step-1)]?.behavior ?? anchor.behavior_goal`로 override. WeeklyArcStage 매핑 헬퍼: celebrate→reinforce, maintain→reinforce, advance→how, deepen→obstacle/observe(요일 회전 유지), pivot→intro(새 유닛 도입), observe→observe. planFromWeekly 시그니처에 decision?: DailyDecision 인자 추가(옵셔널·없으면 현행 요일 회전). decision을 letterCtx.decision(L698-715)에 저장. UNITS import from '@/lib/curriculumUnits'.
파일 app/api/cron/coach/route.ts · lib/coachWeekly.ts
유즈케이스 아린 table-stage가 step1→step2 전진하면 편지 행동지침이 '식탁에서'→'주 5끼+ 같은 자리·같은 시간'으로 사다리 따라 진화. 매번 '콩 차려라' 반복 탈피.
테스트
  • I-04-1 focus 유닛 step1이면 behaviorGoal=steps[0].behavior(진실원천)
  • I-04-2 mode='advance'면 arc.stage='how', 'celebrate'면 'reinforce'
  • I-04-3 mode='pivot'이면 새 유닛 도입(stage='intro')
  • I-04-4 decision 없으면(I-03 미실행) 현행 요일 회전·anchor.behavior_goal 폴백(회귀 0)
DoD 아린 편지에 table-stage step1 '하루 한 끼 화면 끄고 식탁에서'가 실제 본문 톤레이어로 등장, step++ 시 step2 문구로 전환. arc.stage가 mode 연동. vitest 그린.
의존: I-03 · 규모 M · 우선순위 P1
⚠ step.behavior 문구가 너무 처방적이면 작문기(⑥손)가 톤을 깎아야 함 — composeLetter arcBlock 톤레이어가 이미 윤문하므로 위험 낮음.

I-05 behavior_goal ↔ lever 정합 가드 — 비-food 레버 주에 음식 노출 behavior_goal 차단(runWeeklyPlanning·healAnchor)코드규모 M

목적 근본원인#9 봉합 — lever는 focus 유닛으로 강제(coachWeekly.ts:243)하나 behaviorGoal은 Sonnet 원문(L245) 그대로라 lever=environment인데 behavior_goal='콩 3-5알 따로 담기'(food 행동)로 저장. teaching arc가 이 문구를 톤레이어 주입해 '부모 행동 코칭'이 또 음식 잔소리가 됨.
요구 비-food 레버 주에 음식 노출 behavior_goal을 defaultBehaviorGoal로 대체. healAnchor도 교정. 프롬프트 병행 보강.
구현 coachWeekly.ts L245 직후 정합 함수 `leverAlignedGoal(lever, raw, target)`: lever==='food'면 raw 그대로, 비-food면 food 정규식(식재료명+동작[곁들/올려/담/차려/뿌려/섞] 동시 조건) 매칭 시 defaultBehaviorGoal(lever,target) 반환. L245 behaviorGoal 계산을 이 함수로 래핑. healAnchor(L177-188): behavior_goal 존재 시 그대로 두던 것을, lever 비-food && food 신호 매칭이면 defaultBehaviorGoal로 교정(needPersist 트리거로 영속). SYSTEM_WEEKLY 프롬프트(L132-135)에 비-food 레버 시 'behavior_goal에 음식 차리기/노출 행동 금지' 한 줄 보강(코드 가드와 병행).
파일 lib/coachWeekly.ts
유즈케이스 아린 W25 닻 lever=environment·focus=table-stage인데 behavior_goal='콩 3-5알 담기'였던 실측 → '하루 한 끼는 화면 끄고 식탁에 앉아서 먹기'로 교정 → teaching arc가 진짜 환경 코칭.
테스트
  • I-05-1 lever=environment·behaviorGoal='콩 따로 담기' → defaultBehaviorGoal(environment)로 대체
  • I-05-2 lever=food면 Sonnet behaviorGoal 그대로(food 행동 정당)
  • I-05-3 합법 환경 문장('식탁에 함께 차려진 자리에서')은 오탐 안 함
  • I-05-4 healAnchor가 구닻의 lever 불일치 behavior_goal 교정
DoD lever=environment 닻의 behavior_goal에 음식 노출 문구가 0건. 합법 환경 문장 오탐 0. vitest 그린.
의존: 없음 · 규모 M · 우선순위 P1
⚠ 정규식 오탐(합법 환경 문장에 식재료명 등장) — 매칭은 '식재료+동작' 동시 조건으로 좁히고, I-04 적용 후엔 step.behavior가 진실원천이라 가드는 후방 안전망(과차단 영향 최소).

I-06 재앵커/stall 전환 강건화 — stalledTarget 후보 강등 + focusStep 진척 게이트 정밀화(advanceProgress 연동)코드규모 M

목적 근본원인#23 재앵커/stall 항목 봉합 — stalledTarget(route.ts:519) 자동탐지는 ledger.targetAccepts 기반인데 구버전 데이터 미기록 시 streak 끊김(L514). advanceProgress(I-03)가 진척을 step으로 영속하므로, stall 판정을 ledger.targetAccepts + step 전진 둘 다로 강건화해 false stall을 차단·진짜 정체만 전환.
요구 stall 판정에 focusStep 전진을 결합해 false stall 차단·진짜 정체만 전환.
구현 route.ts L608 newLedger에 `focusStep: decision?.step ?? null` 적재. synthAndStoreAnchor priorStall 루프(L510-517): 현재 `if (acc > 0) break`만 보는 것에, 직전 닻 ledger.focusStep이 그 이전보다 증가했으면(=step 전진) `break`(진짜 진척 보호) 추가. 조회 select(L506)에 ledger 이미 있으므로 focusStep 추출 가능. STALL_PIVOT_WEEKS(3)·STALL_LOOKBACK(4) 상수 유지. carriedStall 산출(L536)도 동일 게이트 반영.
파일 app/api/cron/coach/route.ts
유즈케이스 아린 콩류 타깃이 집 수용 0이나 step 전진은 있던 케이스 → 3주여도 전환 안 함(진척 보호), step0이면 3주째 식감/환경 축 전환.
테스트
  • I-06-1 focusStep 전진 있으면 streak 끊김(진척 보호)
  • I-06-2 focusStep 전진 없고 targetAccepts0 3주 연속이면 stalledTarget 산출
  • I-06-3 비-food 레버 주는 streak 미집계(현행 유지)
  • I-06-4 focusStep 스냅샷 미기록(구닻)이면 단정 금지(자연 가동)
DoD step 전진 중인 타깃은 3주여도 전환 안 함(false stall 0), 진짜 정체만 stalledTarget→축 전환. vitest 그린.
의존: I-03 · 규모 M · 우선순위 P2
⚠ focusStep을 ledger 단일 적재로 일원화해 curriculum_progress와의 이중 진실원천 회피.

I-07 child_daily_state 테이블 — 아이현황스토어 스키마(주간+일간+편지+진척 파생 1행/일)SQL규모 S

목적 EPIC I 핵심 산출물 — 주간 닻·일간 결정·편지·진척이 coach_letters.context·weekly_plans·curriculum_progress에 흩어져 있어 '오늘 이 아이 상태'를 단일 조회 불가(브레인·어드민·CoachContext가 매번 재계산). 매일 1행으로 머티리얼라이즈하는 SSOT 신설.
요구 주간+일간+편지+진척 파생을 매일 1행으로 담는 child_daily_state 테이블(RLS 비노출·멱등·idx).
구현 신규 sql/2026-06-18_child_daily_state.sql: weekly_plans.sql(L7-36) 패턴 모방 — create table if not exists, PK(child_id,state_date), `alter table enable row level security`(정책 0 = 서버만), idx(child_id, state_date desc). 컬럼은 letterCtx(route.ts:698-715)에서 파생되는 핵심 평면 필드(week_key text, lever text, mission_target text, target_pool text[], arc_stage text, behavior_goal text, focus_unit text, focus_step smallint, decision_mode text, reds text[], missing text[], home_missing text[], reco_ing text, push_applied boolean, letter_oneliner text) + flags jsonb(coldStart·riskScreen·reused). schema_version int default 1(신선도·마이그레이션). materialized_at timestamptz. 롤백 주석(drop은 운영데이터 후 금지·소프트 리셋). children FK on delete cascade.
파일 sql/2026-06-18_child_daily_state.sql
유즈케이스 아린 매일 1행 = {lever:environment, focus_unit:table-stage, focus_step:1, decision_mode:deepen, reco_ing:두부, push_applied:false}. CoachContext 조립이 7개 테이블 재계산 대신 1행 read.
테스트
  • I-07-1 REST 프로브: anon/auth select 0행(RLS 비노출 검증)
  • I-07-2 service_role upsert 후 PK 멱등(2회 upsert 무해)
  • I-07-3 CHECK/NOT NULL sanity(child_id·state_date 필수)
DoD Supabase SQL Editor 1회 실행 후 테이블 존재·RLS 활성·정책 0·idx 생성. anon 프로브 0행. 멱등 확인.
의존: 없음 · 규모 S · 우선순위 P1
⚠ 과잉정규화 경고(DB 감사: 정당 jsonb는 child_daily_state) — 핵심은 평면 컬럼(브레인/어드민 조회용), 가변·희소만 flags jsonb. coach_letters.context와 중복이나 이건 '파생 SSOT'(읽기 최적화)라 의도적.

I-08 child_daily_state 머티리얼라이즈 — 크론 자녀 루프 말미에 그날 파생 상태 1행 upsert코드규모 M

목적 I-07 테이블을 매일 채우는 write 경로 — letterCtx·anchor·decision·recoIng을 평면화해 child_daily_state upsert. 브레인/어드민/CoachContext가 흩어진 소스 재계산 대신 1행 read하게 하는 SSOT 적재.
요구 편지 upsert 직후 그날 파생 상태를 child_daily_state에 멱등 upsert(LLM 0콜·실패 무영향).
구현 route.ts L728 편지 upsert 직후(if(letter) 블록 안): `await supabase.from('child_daily_state').upsert({ child_id: cid, state_date: today, week_key: weekKey, lever: anchor?.budget?.lever ?? null, mission_target: planCtx?.target ?? null, target_pool: anchor?.target_pool ?? null, arc_stage: weekCtx?.arc?.stage ?? null, behavior_goal: weekCtx?.arc?.behaviorGoal ?? null, focus_unit: decision?.unit ?? null, focus_step: decision?.step ?? null, decision_mode: decision?.mode ?? null, reds: gReds, missing: gMissing, home_missing: gHomeMissing, reco_ing: recoIng, push_applied: weekCtx?.pushApplied ?? false, letter_oneliner: oneliner||null, flags: { coldStart: byDay.length<3, reused: reusedThis, riskScreen: (icfqRiskCount??0)>=2 }, schema_version: 1, materialized_at: new Date().toISOString() }, { onConflict: 'child_id,state_date' })` try/catch. 재사용 분기(reusedThis)에서도 머티리얼라이즈(현황은 매일 최신).
파일 app/api/cron/coach/route.ts
유즈케이스 아린 18일 백필 후 child_daily_state 18행 = 일별 lever·focus·decision_mode 시계열 → 어드민 '아이현황' 패널·CoachContext.history 소스.
테스트
  • I-08-1 정상 편지 발행 시 child_daily_state 1행 생성(파생 필드 매핑)
  • I-08-2 재사용 분기에서도 upsert(현황 최신화)
  • I-08-3 테이블 미존재/upsert 실패 시 코칭 계속(degrade)
  • I-08-4 같은 날 재실행 멱등(upsert)
DoD 아린 크론 1회 후 child_daily_state에 오늘 1행, lever/focus_unit/decision_mode가 weekCtx·decision과 일치. 테이블 drop 후에도 편지 발행 정상. vitest(목 supabase) 그린.
의존: I-04, I-07 · 규모 M · 우선순위 P1
⚠ decision은 I-04에서 노출 — 미실행 시 focus_unit/step null(안전). 재사용 분기에선 decision 미산출이라 일부 필드 null 허용.

I-09 어드민 아이현황 패널 — child_daily_state 시계열 + 활성 닻(supersede) 가시화배선규모 S

목적 I-01 supersede·I-08 머티리얼라이즈 산출을 운영이 검증할 수 있게 어드민에 노출 — '활성 주간 1개·일별 lever/focus/mode 시계열·재앵커 이력'을 한눈에. status 변별력(근본원인#24 (a))과 진척 진행(#10)을 가시화.
요구 child_daily_state 14행 시계열 + 활성 닻 강조를 어드민 [childId]에 렌더.
구현 app/admin/[childId]/page.tsx L104 인근에 `const { data: dailyState } = await db.from('child_daily_state').select('state_date,lever,focus_unit,focus_step,decision_mode,reco_ing,push_applied,arc_stage,behavior_goal').eq('child_id', childId).order('state_date',{ascending:false}).limit(14)`. 렌더: 표 컴포넌트(server component·기존 weekly 패널 스타일 모방 L180-186). weekly_plans 매핑(L182)에서 status==='active'면 진하게·archived면 opacity 0.5. 테이블 미존재 시 빈 배열 폴백(에러 안 던짐).
파일 app/admin/[childId]/page.tsx
유즈케이스 이사님이 어드민에서 아린 14일 lever 시계열을 보고 'environment 고착이 W25에 food로 전환됨'을 한눈에 검증.
테스트
  • I-09-1 child_daily_state 14행 시계열 렌더(목 데이터)
  • I-09-2 테이블 미존재 시 패널 빈 상태(에러 0)
  • I-09-3 active 닻 강조·archived 흐림 스타일
DoD 어드민에서 아린 일별 lever/focus/mode 시계열 표시, 활성 닻 1개 강조. 테이블 없어도 페이지 정상 렌더. tsc·build 그린.
의존: I-01, I-08 · 규모 S · 우선순위 P2
⚠ 어드민은 service_role(RLS 우회)이라 비노출 정책 영향 없음. 표 폭 모바일 오버플로 — 가로 스크롤 처리.

I-10 주간 위생 회귀 하네스 — supersede·focusHistory·advanceProgress·머티리얼라이즈 통합 리플레이 게이트테스트규모 M

목적 EPIC I 통합 회귀 봉합 — 개별 원자가 그린이어도 통합 시 ledger/goals/decision 원장이 어긋나면 lever 고착·진척 미영속이 재발(코칭엔진 다중 갈아엎음 이력). 다주 시뮬로 supersede·피로캡·피벗·머티리얼라이즈를 한 흐름에서 검증하는 prebuild 게이트.
요구 4주 다주 리플레이로 supersede·피로캡·피벗·정합·머티리얼라이즈를 통합 검증하는 prebuild 게이트.
구현 tests/coach-weekly-hygiene.test.ts 신설: synthAndStoreAnchor 핵심 로직을 순수 함수로 추출(supersedePlan(prev,wk)·focusHistoryOf(anchors))해 단위 테스트, advanceProgress(curriculum.ts·순수)는 직접 호출. 4주 fixture(envBadPct 고정·식품 결핍 고정)로 W1→W4 진화 시뮬. tests/curriculum.test.ts 패턴(describe per 원자) 따름. vitest. 기존 package.json prebuild가 tests 전체를 돌리므로 신규 스크립트 불필요.
파일 tests/coach-weekly-hygiene.test.ts · lib/coachWeekly.ts
유즈케이스 아린 W22~W25 실데이터를 fixture화해 회귀 고정 — 다음 리팩터에서 lever 고착·supersede 깨짐이 재발하면 prebuild가 즉시 적발.
테스트
  • I-10-1 4주 시뮬 supersede 불변식: 매주 active 1개
  • I-10-2 focusHistory 2주 정체→W3 focus 전환
  • I-10-3 limping→pivot·goalsAfter 영속 라운드트립
  • I-10-4 lever 정합: environment 닻 음식 behavior_goal 0건
  • I-10-5 child_daily_state 파생 lever=decision 유닛 lever 일관
DoD 4주 리플레이 전 케이스 그린, prebuild 게이트 통과(RED 시 배포 차단). 통합 버그(원장 어긋남) 0건.
의존: I-01, I-02, I-03, I-04, I-05, I-08 · 규모 M · 우선순위 P1
⚠ supersede/focusHistory 로직이 route.ts에 인라인이라 순수 함수 추출이 선행 — coachWeekly.ts로 추출해 테스트 가능화(부수효과 0).

EPIC J — 검증·하네스·컷오버 (Verify Harness & Cutover) D4

EPIC A~I가 봉합한 불변식(주간 닻 잠금·push=0·결핍군 필터·must-weave·괴식0·앵무새0·커리큘럼 진척)을 회귀 테스트와 다일 리플레이 하네스로 박제해, '고쳤다가 재발'(11세션 18회) 패턴을 prebuild 게이트로 영구 차단한다. 라이브 cron route.ts(628-630 두뇌 override·354 refExposable·621-630 mirrorBlock)의 의사결정 파이프라인을 DB·LLM 없는 순수 함수 lib/replayRunner.ts로 추출해, 30가정×14일 합성 리플레이 + 아린 골든(현 4사고 재현0)을 단일 진실로 삼는다. 컷오버는 weekly_plans supersede·플래그·롤백·런북으로 안전하게 수행하고 WBS 상태칩을 갱신한다.

J-01 순수 리플레이 러너 추출 — DB·LLM 없는 의사결정 파이프라인코드규모 L

목적 근본원인#0. route.ts 의사결정 흐름(닻 종합→planFromWeekly→두뇌 override→planFor→snack/reco/combo→arc)은 Next route 안에 묶여 DB·LLM에 강결합돼 단위 회귀가 불가능하다. 모든 J 불변식·리플레이·골든이 호출할 순수 함수 1개로 추출하지 않으면 EPIC J 전체가 라이브 DB에 의존하게 된다.
요구 rows(meal_logs)·anchor·meta·signals·recentPlans/recentScenarios·freqMap·growth 시계열·daySeed/cidHash/dow를 입력받아 그날 {scenario, plan, useFood, mirrorPhrase, growthFact, recoIngredient, snackText?, weeklyArc, pushApplied, ledgerPatch}를 결정론으로 산출하는 순수 함수를 lib/replayRunner.ts에 만든다. LLM 호출(generateLetter/verifyLetter/pickActionByBrain)은 주입형 stub(brainPick·verify 결과를 인자로 받음)으로 대체해 0콜.
구현 신규 lib/replayRunner.ts. route.ts 483-693의 결정 로직을 발췌·재구성: (1) signals 조립(483-489, defMature 게이트 478 포함) (2) planFor(492) (3) 닻 적용 블록(567-611)을 planFromWeekly(coachWeekly.ts:284) 호출로 (4) 두뇌 override 지점(628-630)을 인자 brainPick?:{scenarioId,useFood}로 받아 EPIC A 수정안(닻 plan 보존·비-food 닻이면 food override 차단) 분기 (5) STRUCTURAL_FRAMES useFood 클램프(647) + 비-food lever 클램프 (6) recoPool/recoIngredient 산출(651-662, buildIngredientPool+groupOfIngredient) (7) mirrorPhrase·growthFact·snackText 게이팅(637-643). export type ReplayDay·function replayDay(input):ReplayDay.
파일 web/lib/replayRunner.ts · web/app/api/cron/coach/route.ts · web/lib/coachWeekly.ts · web/lib/coach.ts · web/lib/coachRecos.ts
유즈케이스 아린 6/15~6/17 rows를 replayDay에 통과시키면 콩→과일→치킨 3일 연속 food가 재현되고(현재 버그), EPIC A 수정 후엔 비-food 닻 주에 useFood=false로 클램프됨을 동일 함수로 대조.
테스트
  • RPL-01-1 동일 입력 두 번 = byte-동일 ReplayDay(결정론)
  • RPL-01-2 LLM 0콜(generateLetter/verifyLetter/pickActionByBrain spy 미호출)
  • RPL-01-3 anchor=null이면 planFor 폴백 결과와 동일(degrade 안전)
  • RPL-01-4 route.ts 라이브 분기와 동형(같은 fixture 1일 입력 → scenario.id·plan.target 일치)
DoD vitest로 replayDay가 결정론(byte-동일)이고 LLM stub 0콜임을 검증. tsc·next build 그린. route.ts는 가능하면 replayDay를 재사용(중복 로직 단일화)하되 최소한 동형성 테스트 RPL-01-4 통과.
의존: A-anchor-lock · 규모 L · 우선순위 P0
⚠ route 로직을 복제하면 드리프트 위험 → RPL-01-4 동형성 테스트로 게이트. 이상적으로는 route.ts가 replayDay를 import해 단일 소스화(권장).

J-02 불변식: 두뇌가 주간 닻을 덮어쓰지 못함(타깃 잠금·push 보존)테스트규모 M

목적 근본원인#0,#8,#20,#21. 최상위 근본원인(route.ts:628-630)이 재발하지 않도록 박제. 두뇌가 food 시나리오를 골라도 닻의 mission_target·budget.push·lever·firstServe 게이트가 발행 결정에 살아남아야 한다.
요구 비-food 닻(lever=environment) 주에 brainPick이 food 시나리오(예: re-exposure-timing)를 골라도 ReplayDay.useFood=false이고 scenario가 닻 lever 프레임을 유지. food 닻 주엔 brainPick.scenarioId가 와도 plan.target이 닻 mission_target(또는 pool 내)으로 유지되고 두뇌가 닻 밖 타깃을 새로 못 뽑음.
구현 tests/coach-anchor-lock.test.ts 신규. replayDay(J-01) 사용. anchor fixture는 coach-plan.test.ts:15 anchor() 패턴 재사용(lever:'environment', mission_target:'콩류', target_pool:['콩류'], budget.push:1). 케이스: (a) brainPick={scenarioId:'re-exposure-timing',useFood:true} 주입 → expect useFood=false·plan.frame이 LEVER_SCENARIO['environment']='mealtime-atmosphere' (b) food 닻 + brainPick food 시나리오 → plan.target∈[mission_target,...target_pool] (c) ledger.pushUsed=true·pushWindow 밖 dow → pushApplied=false(planFromWeekly:344 게이트). 현행 route.ts:629 경로(planFor forceScenarioId)는 이 케이스에서 닻을 무시함을 '버그 재현' it.skip으로 1개 남겨 수정 전/후 대조.
파일 web/tests/coach-anchor-lock.test.ts · web/lib/replayRunner.ts · web/lib/coachWeekly.ts
유즈케이스 아린 6/17: 닻=콩류/environment인데 발행 편지=re-exposure-timing/치킨이었던 실측 사고를 AL-01이 RED로 잡아 영구 차단.
테스트
  • AL-01 비-food 닻+두뇌 food시나리오 → useFood=false·프레임=lever 프레임
  • AL-02 food 닻 → plan.target∈닻 pool(두뇌가 닻 밖 타깃 못 뽑음)
  • AL-03 pushUsed/윈도우 밖 → pushApplied=false(채근 캡 준수)
  • AL-04 firstServe 전(targetExposeWtd=0) → push 무브 강등(planFromWeekly:349)
DoD EPIC A 수정 적용 시 AL-01~04 그린. 미수정 상태에선 AL-01이 RED(가드가 실제로 회귀를 잡음을 증명). prebuild에 포함.
의존: J-01, A-anchor-lock · 규모 M · 우선순위 P0
⚠ planFromWeekly 인터럽트(progress-celebrate 등 297-302)가 닻보다 우선하는 정당 경로를 false-positive로 잡지 않도록 케이스 격리.

J-03 불변식: 결핍군 필터(refExposable) 일관 + 비-food 연속 캡테스트규모 M

목적 근본원인#1,#2,#14,#22. 치킨(미매핑·비결핍) 일간 타깃 누수와 음식 잔소리 며칠 연속을 박제. refExposable 필터가 weekly에만 적용되고 daily signals.refused엔 미적용이던 비대칭을 회귀로 잠근다.
요구 signals.refused/homeRefused/daycareRefused에 주식제외+결핍군소속 필터(route.ts:352-358)가 적용돼 치킨·돼지불고기 같은 비결핍군 거부가 ReplayDay.plan.target이 되지 않음. 또한 연속 N일(예 2일) food 결정 후 그 다음날 useFood가 강제 false로 캡됨(비-food 닻 주는 더 강하게).
구현 tests/coach-deficient-filter.test.ts 신규. (A) 거부필터: rows에 치킨 거부 + missing 결핍군(콩류) 입력 → replayDay.plan.target!=='치킨'(refExposable 함수 추출본을 daily에도 적용한 J-01 산출 검증). _STAPLE_WORD(coach.ts 라인 352 정규식)·STAPLE_FORMS·catOf∈_deficientGroups 3조건. (B) 연속캡: recentDecisions(직전 useFood 이력) 인자에 [true,true] → 오늘 useFood=false. coachBrain.ts:34-42·94의 useFood 산출에 캡을 추가(근본원인#2 fix)했다는 전제로 replayDay 레벨 검증. 14일 시뮬에서 food 연속 최대 길이≤캡.
파일 web/tests/coach-deficient-filter.test.ts · web/lib/replayRunner.ts · web/lib/coachBrain.ts
유즈케이스 아린 6/15~6/17 콩·과일·치킨 3연속 food + 치킨 타깃 반복을 DF-01·DF-04가 RED로 잡음.
테스트
  • DF-01 비결핍군 거부(치킨·돼지불고기)는 일간 타깃 제외
  • DF-02 주식 거부(밥·면·빵)는 타깃 제외
  • DF-03 결핍군 소속 거부만 타깃 후보
  • DF-04 연속 food N일 후 다음날 useFood=false(잔소리 캡)
  • DF-05 14일 시뮬 food 연속 최대길이<=캡
DoD DF-01~05 그린. 미수정(daily 필터 누락) 상태에선 DF-01 RED. prebuild 포함.
의존: J-01 · 규모 M · 우선순위 P0
⚠ 결핍군 산출이 defMature(>=7일) 게이트(route.ts:478)에 의존 → 1주차 미만 가정은 결핍 빈 채로 통과해야 함(false-positive 방지 케이스 추가).

J-04 불변식: 괴식0 — 곁들임 combo 게이트 라이브 배선 회귀테스트규모 M

목적 근본원인#3,#11,#12,#13. 단백질↔단백질(돼지불고기+닭가슴살) 등 괴식 추천을 박제. comboMatrix.isComboOk/comboGuard.validCombos가 휴면(외부 호출 0건)이던 것을 라이브 reco 경로에 배선했음을 회귀로 잠근다.
요구 replayDay가 산출하는 곁들임/추천 쌍(잘먹는 음식 dish × 추천 식재료 ing)이 isComboOk(dish,ing,2)=false면 출력되지 않음. 같은 식품군 곁들임(돼지불고기+닭가슴살=둘다 고기·계란군)이 차단됨. comboGolden.json fixture(기존)의 block 케이스가 reco 경로를 통과하지 못함.
구현 tests/coach-combo-live.test.ts 신규(기존 coach-hybrid-combo.test.ts는 함수 단위, 이건 라이브 경로 단위). replayDay 출력 reco 쌍에 isComboOk(comboMatrix.ts:47, threshold=2) 또는 validCombos(comboGuard.ts:51) 게이트를 적용한 J-01 산출을 검증. 추가로 같은-군 차단: groupOfIngredient(dish 대표재료)===groupOfIngredient(ing)면 차단(근본원인#3 (B)방어층). comboGolden block 6쌍이 replayDay reco에서 0건 출현. 중복 게이트(comboMatrix vs comboGuard) 단일화 여부도 테스트로 고정(둘 다 import 시 동일 판정 검증).
파일 web/tests/coach-combo-live.test.ts · web/lib/replayRunner.ts · web/lib/comboMatrix.ts · web/lib/comboGuard.ts · web/tests/fixtures/comboGolden.json
유즈케이스 아린 6/17 '돼지불고기 옆에 닭가슴살'(단백질 위 단백질) 추천을 CL-02가 RED로 잡음.
테스트
  • CL-01 comboGolden block 6쌍 라이브 reco 경로 0건 출현
  • CL-02 같은 식품군 dish×ing 곁들임 차단(돼지불고기+닭가슴살)
  • CL-03 comboGolden ok 케이스는 통과(과소차단 0)
  • CL-04 comboMatrix·comboGuard 동일 판정(게이트 일관)
DoD CL-01~04 그린. 게이트 미배선 상태에선 CL-01 RED. prebuild 포함.
의존: J-01 · 규모 M · 우선순위 P0
⚠ comboMatrix·comboGuard 중복 게이트 병존 → 한쪽으로 단일화 권장(EPIC A/근본원인#13 fix). CL-04로 드리프트 감지.

J-05 불변식: 영양거울·성장 must-weave 포함 강제테스트규모 M

목적 근본원인#5,#6,#15,#25. 식품군 영양거울 누락·성장곡선/BMI 미배선을 박제. mirrorBlock이 프롬프트에 주입돼도 손 LLM이 누락 가능하던 것을 결정론 포함검증으로 잠근다.
요구 replayDay가 mirrorPhrase·growthFact를 산출하면 composeLetter 경로의 결정론 포함검증(letterDeterministicBad 또는 신규 mirrorMissing)이 본문에 mirror 키워드 부재 시 true(재생성 트리거)를 반환. growthFact는 baseline+latest 2점이 있을 때만 산출되고 격주·2주연속금지 게이트를 통과.
구현 tests/coach-mustweave.test.ts 신규. (A) mirror 포함검증: composeLetter(coach.ts:763)에 결정론 검사 추가 전제 — mirrorBlock 발동 분기(allMiss/homeMiss 식품군명 또는 일반)에 맞춰 본문에 '부족/채워/다양성/골고루' 또는 실제 군명 부재 시 fixNotes 재생성. 누락 본문 입력 → 검증 함수 true. (B) 성장: replayRunner에 growthTracking(growth-reference.ts) + heightPercentile/weightPercentile를 baseline(첫 측정)+latest 2점으로 산출하는 분기. 1점이면 growthFact=null. 격주 게이트(daySeed/주차 기반)·직전 2주 주입 이력 시 skip 검증.
파일 web/tests/coach-mustweave.test.ts · web/lib/coach.ts · web/lib/replayRunner.ts · web/lib/growth-reference.ts
유즈케이스 아린 6/16 치킨편지에서 식품군 거울이 통째 누락된 실측을 MW-01이 잡고, growthTracking(키 추종 경고)이 편지에 한 번도 안 실린 #5를 MW-04가 잡음.
테스트
  • MW-01 mirror 키워드 누락 본문 → 포함검증 true(재생성)
  • MW-02 mirror 키워드 포함 본문 → 통과
  • MW-03 growth 측정 1점 → growthFact=null(미주입)
  • MW-04 growth 2점 → growthTracking 점수·격주 게이트 통과 시만 주입
  • MW-05 직전 2주 성장 주입됨 → 이번주 skip(2주연속금지)
DoD MW-01~05 그린. 포함검증·성장 배선 미적용 상태에선 MW-01·MW-04 RED. prebuild 포함.
의존: J-01 · 규모 M · 우선순위 P1
⚠ mirror 일반 분기('잘 채워지고')는 환언 폭이 커 오탐 위험 → 일반 분기는 느슨/생략(근본원인#6 fix 정밀화). 성장 주입은 체중단어 금지 정책과 충돌 가능 → 이사님 확정 전 P1.

J-06 불변식: 앵무새0 — 구조 골격 동일성 탐지테스트규모 M

목적 근본원인#16,#17,#18,#19. 본문 골격(N일전·작은 접시 따로·부모 먼저·또래들은)이 어휘만 바뀌고 구조는 동일한 앵무새를 박제. letterSimilarity 어휘 가드가 못 잡는 '같은 골격 다른 단어'를 구조 시그니처로 측정.
요구 본문에서 상투 슬롯(작은 접시·부모가 먼저·또래도 먹음·N일 전)을 정규식으로 추출해 slotSignature를 만들고, 직전 N편과 slotSignature가 동일하면 isParrotSkeleton=true. composeLetter 재생성 루프와 route.ts 발행 경보 양쪽에 적용. mirrorBlock 쿨다운·varyOpener 직전 1편→N편 확장도 검증.
구현 tests/coach-parrot.test.ts 신규. 신규 함수 coach.ts slotSignature(letter):string — 정규식 슬롯 추출(작은\s?접시|따로|부모.{0,4}먼저|또래|[0-9]일\s?전). composeLetter(coach.ts:804-815) 재생성 루프에 slotSignature===직전 시그니처면 bestBad 가산(근본원인#19 fix). route.ts:688-693 발행경보에 sigRun과 별개로 skeletonRun 추가. 테스트: 어휘만 다르고 골격 동일한 2편 → letterSimilarity<0.45(어휘 통과)이지만 slotSignature 동일 → isParrotSkeleton=true. mirrorBlock 동일 문구 3일 연속 → 쿨다운으로 3일째 생략.
파일 web/tests/coach-parrot.test.ts · web/lib/coach.ts · web/app/api/cron/coach/route.ts
유즈케이스 아린 6/16·6/17 편지가 '작은 접시 따로·부모 먼저·또래들은 OO도' 골격을 어휘만 바꿔 반복한 것을 PR-02가 RED로 잡음.
테스트
  • PR-01 어휘 다르고 골격 동일 → letterSimilarity<0.45·slotSignature 동일(어휘 가드 한계 증명)
  • PR-02 isParrotSkeleton 직전 N편 동일 골격 탐지
  • PR-03 mirrorBlock 동일 문구 3일째 쿨다운 생략
  • PR-04 varyOpener 직전 N편(1편 아님) 검사
  • PR-05 다른 골격 편지는 false(과탐 0)
DoD PR-01~05 그린. slotSignature 미구현 상태에선 PR-02 RED. prebuild 포함.
의존: J-01 · 규모 M · 우선순위 P1
⚠ 슬롯 정규식이 정상 다양성을 과탐하면 발행 차단 연쇄 → P1, 발행경보는 fail-open(차단 아닌 issues 기록)으로 시작.

J-07 불변식: 커리큘럼 진척 영속·단계 전진테스트규모 M

목적 근본원인#10,#23. 12유닛 커리큘럼이 일간 편지에 미렌더되고 주간 goals가 매주 새로 픽혀 진척이 영속되지 않던 것을 박제. advanceProgress가 호출되고 curriculum_progress가 write됨을 회귀로 잠근다.
요구 replayDay(또는 주간 종합 경로)가 advanceProgress(curriculum.ts:140)를 호출해 focus 유닛 step을 산출하고, weeklyArc.behaviorGoal이 해당 유닛 현재 step에서 파생됨. passWhen 충족 시 step 전진·다음 유닛 focus 승격이 결정론으로 일어남. 14일 시뮬에서 진척이 누적(매일 step1 리셋 0).
구현 tests/coach-curriculum-progress.test.ts 신규(기존 curriculum.test.ts 보강). advanceProgress 시그니처(curriculum.ts:140-175: childId·goals·progress·rows·answers·coachedDays·coachedYesterday·today) 사용. 케이스: (a) passWhen 충족 rows → updates에 step 전진(stepAdvanced) (b) 14일 연속 호출 시 progress가 누적(이전 step 보존, B-26 이중적립 버그 회귀 — curriculum.ts:165 preEvolved 가드) (c) focus 유닛 step → weeklyArc.behaviorGoal 매핑 함수가 UNITS[u] step 텍스트 반환. cron이 curriculum_progress upsert를 한다는 전제로 write 페이로드 형태 검증(DB는 mock).
파일 web/tests/coach-curriculum-progress.test.ts · web/lib/curriculum.ts · web/lib/replayRunner.ts · web/lib/curriculumUnits.ts
유즈케이스 아린: 주간 goals가 매주 새로 픽혀 '콩 노출' 진척이 누적 안 되던 것을 CP-02가 잡아 진척 기반 단계 코칭 보장.
테스트
  • CP-01 passWhen 충족 → step 전진(stepAdvanced=true)
  • CP-02 14일 연속 → 진척 누적(매일 리셋 0·이중적립 0)
  • CP-03 focus step → weeklyArc.behaviorGoal 파생
  • CP-04 passWhen 미충족 → holdWeeks 동안 같은 step 유지
  • CP-05 graduate → 다음 유닛 focus 승격
DoD CP-01~05 그린. cron이 advanceProgress 미호출(현행)이면 통합 테스트가 write 0건을 RED로 표시. prebuild 포함.
의존: J-01 · 규모 M · 우선순위 P1
⚠ advanceProgress는 이미 존재하나 cron write 경로가 전무 → 배선이 EPIC 다른 원자 선행(deps). 테스트는 함수 레벨 + write 페이로드로 한정.

J-08 리플레이 하네스 — 30가정×14일 주간↔일간 정합테스트규모 L

목적 근본원인#0,#1,#2,#3,#8,#20. 통합 버그는 단위 테스트가 아니라 다일 시계열에서만 드러난다(6/11 교훈·메모리 23차). synthetic-families.json(30가정×28일)을 replayDay로 통주해 주간 닻과 일간 발행이 14일 내내 정합하는지 게이트화.
요구 30가정 각각을 14일(또는 28일) 리플레이해 가정별로 다음 불변식이 0위반: (1) 비-food 닻 주 food 발행 0(또는 캡 이내) (2) 같은 타깃 채근 캡 위반 0 (3) push 예산 초과 0 (4) 인접일 byte-동일 결정 0 (5) 괴식 조합 0 (6) 결핍군 밖 타깃 0. 전체 리플레이 결정론(byte-동일).
구현 tests/coach-replay-14d.test.ts 신규. tests/fixtures/synthetic-families.json(families[30], 각 {id,arc,attendsDaycare,rows,...}) 로드. 가정별 루프: 일별로 replayDay(J-01) 호출하며 anchor/ledger/recentPlans/recentDecisions를 시뮬레이션 상태로 carry(route.ts:604-609 ledger writeback 모사·일요일 synthAndStoreAnchor는 결정론 coldSynth로 대체). 각 가정×14일 ReplayDay 배열을 수집해 6불변식 assert. coach-hybrid-rotation.test.ts:18 simulate 패턴 확장.
파일 web/tests/coach-replay-14d.test.ts · web/lib/replayRunner.ts · web/tests/fixtures/synthetic-families.json
유즈케이스 30가정 중 비-food 닻 가정들에서 두뇌 food override가 14일간 0회임을 통주로 보증 — 아린류 사고의 일반화 차단.
테스트
  • R14-01 30가정 비-food 닻 주 food 발행 캡 이내
  • R14-02 채근(push) 캡 위반 0건(전 가정)
  • R14-03 인접일 byte-동일 결정 0건
  • R14-04 괴식 조합 출현 0건
  • R14-05 결핍군 밖 타깃 0건
  • R14-06 리플레이 결정론(전체 byte-동일 재현)
DoD R14-01~06 전 가정 그린. 통주가 단위 테스트 미포착 통합 버그를 적발(메모리 D5 교훈: 리플레이가 5종 적발)하면 해당 원자로 환류. prebuild 포함.
의존: J-01, J-02, J-03, J-04 · 규모 L · 우선순위 P0
⚠ ledger/anchor carry 시뮬이 라이브 route와 어긋나면 위양성 → J-01 RPL-01-4 동형성으로 1일 단위는 이미 검증, carry 로직만 별도 주의.

J-09 아린 골든 — 현 4사고 재현0테스트규모 M

목적 근본원인#0,#1,#3,#6,#12. 실데이터(아린) 6/15~6/17의 4사고(닻 무력화·치킨 타깃 반복·단백질 괴식·식품군 거울 누락)가 EPIC A~I 수정 후 재현되지 않음을 박제. real-arin.json 골든이 단일 진실.
요구 real-arin.json(또는 신규 arin 6/15~6/17 fixture)을 replayDay로 통주해 4사고가 0건: (1) 닻=비-food인 날 food 발행 0 (2) 치킨 일간 타깃 0 (3) 같은-군 곁들임 추천 0 (4) 식품군 거울 누락 0. 동시에 정상 산출(콩류 타깃·환경 코칭·콩 곁들임)이 살아있음.
구현 tests/coach-arin-golden.test.ts 신규. tests/fixtures/real-arin.json(기존)을 baseline으로, 필요 시 6/15~6/17 rows를 추가한 arin-golden-j.json 생성(arin-golden-b.json:2 _doc 패턴·재생성 경로 주석). replayDay 통주 후 4사고 assert. arin-golden-b.json(과거 v3 약점 박제)과 동형으로 'incidents=현 4사고 0건' 박제.
파일 web/tests/coach-arin-golden.test.ts · web/tests/fixtures/real-arin.json · web/tests/fixtures/arin-golden-j.json · web/lib/replayRunner.ts
유즈케이스 아린 6/15~6/17 콩→과일→치킨 + 돼지불고기+닭가슴살 + 거울누락 = 4사고가 골든에서 0건으로 잠김.
테스트
  • AG-01 닻 비-food 날 food 발행 0(6/16·6/17)
  • AG-02 치킨 일간 타깃 0건
  • AG-03 같은-군 곁들임(단백질+단백질) 0건
  • AG-04 식품군 거울 누락 0건(must-weave)
  • AG-05 정상 산출 보존(콩류 타깃·환경 코칭 살아있음)
DoD AG-01~05 그린. 수정 전 상태에선 AG-01~04 RED(골든이 실사고를 잡음 증명). prebuild 포함. 골든 재생성 스크립트 경로 주석으로 명시.
의존: J-01, J-02, J-03, J-04, J-05 · 규모 M · 우선순위 P0
⚠ 아린 단일 가정(n=1) → 일반화는 J-08 합성 30가정이 보완. 골든은 회귀 박제용.

J-10 prebuild 게이트 + 야간 자가진단 불변식 알람배선규모 M

목적 근본원인#0,#1,#16,#19. 테스트가 있어도 게이트에 안 걸리면 회귀가 재발한다(메모리: prebuild=vitest run). J 불변식·리플레이·골든을 prebuild 게이트로 묶고, 라이브에서도 coach-selfcheck가 새 불변식(닻 일치·결핍군 밖 타깃·ʹ괴식·앵무새 골격)을 매일 자가탐지하게 한다.
요구 package.json prebuild가 J-02~J-09 테스트를 포함해 실패 시 배포 차단. coach-selfcheck/route.ts에 신규 알람: 발행 편지의 context(scenario·plan.target·mirror)를 닻(weekly_plans)과 대조해 '닻 불일치'·'결핍군 밖 타깃'·'골격 반복'을 cron_runs.meta.alerts에 기록.
구현 package.json:'prebuild':'vitest run'은 이미 전체 실행이므로 신규 테스트 파일은 자동 포함 — 확인만. coach-selfcheck/route.ts(현재 letterSimilarity·oneliner·mirrorRate 4지표, 54-74행 루프)에 추가: weekly_plans 닻 로드 후 letter.context.plan.target이 닻 mission_target/target_pool 밖이면 flags.push('닻불일치'), slotSignature(coach.ts J-06) 직전 동일이면 flags.push('골격반복'). letterSimilarity는 이미 있음(mirrorRate<0.8도). LLM 0콜 유지.
파일 web/package.json · web/app/api/cron/coach-selfcheck/route.ts · web/lib/coach.ts
유즈케이스 아린 닻불일치(콩류 닻↔치킨 편지)를 매일 새벽 selfcheck가 어드민 alerts에 자동 노출 — 사람 제보 의존 제거.
테스트
  • SC-01 닻 불일치 letter → alerts에 '닻불일치' 기록
  • SC-02 결핍군 밖 타깃 → alert
  • SC-03 골격 반복(slotSignature 동일) → alert
  • SC-04 정상 편지 → alert 0(노이즈 0)
  • SC-05 weekly_plans/letter_feedback 미존재 → graceful(throw 0)
DoD prebuild가 J 전체 테스트를 실행·실패 시 RED. selfcheck SC-01~05 그린(라우트 단위 mock). 라이브 야간 1회 alerts 정상 기록 확인.
의존: J-02, J-03, J-06 · 규모 M · 우선순위 P0
⚠ selfcheck는 발행 후 탐지(차단 아님) → 사전 차단은 J-02~09 prebuild가 담당. 이중 안전망.

J-11 weekly_plans supersede + 컷오버 플래그·롤백코드규모 M

목적 근본원인#24. W22~W25 4행 동시 active(데드 컬럼)를 1 active/child 불변식으로 바로잡고, EPIC A~I 묶음 컷오버를 플래그·즉시 롤백으로 안전화한다.
요구 신규 닻 upsert 직후 같은 child의 week_key<현재인 active 행을 status='archived'로 일괄 update(loadAnchor가 week_key로만 조회하므로 일간 무영향). 컷오버는 env 플래그로 게이트해 OFF=직전 동작, ON=A~I 통합 경로. 플래그 제거=즉시 롤백.
구현 route.ts synthAndStoreAnchor(500-557) upsert(547) 직후: supabase.from('weekly_plans').update({status:'archived'}).eq('child_id',cid).lt('week_key',wk).eq('status','active'). 컷오버 플래그: route.ts 두뇌 override 분기(628-630)와 daily 필터·combo 게이트를 process.env.COACH_VERIFY_CUTOVER(또는 기존 플래그)로 감싸 OFF면 현행, ON면 J-01 replayDay 경로. 테스트는 supersede SQL 결과·플래그 분기 동형성.
파일 web/app/api/cron/coach/route.ts · web/sql/2026-06-18_weekly_supersede.sql
유즈케이스 아린 W22~W25 4행 동시 active를 W25만 active로 정리 → 어드민 가시성·스톨 판정 정확.
테스트
  • SP-01 신규 닻 upsert 후 이전 active → archived(1 active/child)
  • SP-02 loadAnchor는 week_key 조회라 supersede 무영향(일간 발행 동일)
  • SP-03 플래그 OFF → 현행 byte-동일 산출(대조군)
  • SP-04 플래그 ON → A~I 통합 경로 산출
DoD SP-01~04 그린. 컷오버 후 weekly_plans 동시 active 1행/child. 플래그 제거 시 즉시 직전 동작 복귀 검증.
의존: J-01, J-08, J-09 · 규모 M · 우선순위 P1
⚠ supersede는 운영 가시성·무결성 용(일간 무영향) → low severity지만 컷오버 가시성에 중요. 플래그는 EPIC J 산출이 충분히 그린일 때만 ON.

J-12 컷오버 런북 + WBS 상태칩·문서 갱신문서규모 S

목적 운영·인계. 컷오버 절차·롤백 트리거·모니터 지표를 단일 런북으로 박제하고, coaching-v2-hybrid-build-plan.html(작업 단일 진실)의 EPIC J 원자 상태칩을 갱신해 'WBS 보고 J-## 구현' 지시가 가능하게 한다.
요구 런북: ① SQL 실행 순서(2026-06-18_weekly_supersede.sql 등) ② 플래그 ON 절차 ③ 카나리아(아린 먼저)→전자녀 승격 기준 ④ 롤백 트리거(selfcheck alert 임계·repeatAlert·괴식 발견) ⑤ 모니터 지표(닻일치율·food연속·괴식0·앵무새0). WBS HTML EPIC J 섹션에 J-01~J-12 상태칩·DoD 반영.
구현 레포 정책상 신규 .md 생성은 지양 → 기존 docs.html 또는 WBS HTML 내 적색박스 섹션에 런북 통합. coaching-v2-hybrid-build-plan.html(landing_page/deploy)의 EPIC J 카드에 12원자 추가·상태='대기'. 모니터 지표는 coach-selfcheck alerts(J-10)·cron_runs.issues(route.ts:690-694 repeatAlert)와 매핑.
파일 coaching-v2-hybrid-build-plan.html · docs.html
유즈케이스 이사님이 'WBS 보고 J-08 진행해'로 지시 가능 + 컷오버 시 롤백 트리거를 런북에서 즉시 참조.
테스트
  • DOC-01 WBS HTML에 J-01~J-12 카드·상태칩 렌더(헤드리스 검증)
  • DOC-02 런북 5항목(SQL순서·플래그·카나리아·롤백·지표) 존재
  • DOC-03 docs.html 링크/렌더 무오류
DoD WBS HTML EPIC J 12원자 라이브 렌더, 런북 5항목 명시, 헤드리스 렌더 검증 통과. 컷오버 후 상태칩 '완료'로 갱신 가능.
의존: J-11 · 규모 S · 우선순위 P1
⚠ 문서-코드 드리프트 → 컷오버 완료 시 상태칩 일괄 갱신 필수.

EPIC K — 가드 정합·과잉억제 정리 (가드 모순 적대감사) 감사

⭐ 이사님 지시(임시 가드가 모순·과잉억제 일으키는지 점검) → 35 에이전트 적대감사. 메타목표(①영양/편식 개선 ②부모 리텐션=공허·앵무새 방지)로 가드 약40개 채점. 유지 20 · 조정 12 · 폐기 0. 적대 검증이 '그럴듯하나 버그 재발하는 완화'(bridgeFacts 약화 등)를 전부 기각 — 안전가드는 진짜 버그(괴식·환각·질식·거짓결핍·과채근)를 막아 폐기 불가. 정리 대상은 전부 '임시방편 조정'(고정문장·네임스페이스 불일치·lever-blind 캡)이며 가드 의도는 보존.

⚠️ 최우선 — 이미 출하된 가드의 과잉억제
  • catOf↔식품군 네임스페이스 불일치(route.ts:358): catOf=18카테고리 vs _deficientGroups=8식품군, 일치 3개뿐 → 단백질·콩·생선·비타민A채소 결핍 거부의 재노출이 영구 차단(이미 출하된 A-08 refusedExposable이 광범위 과잉억제 중). nutrition.ts:195 groupOf 사상으로 한 단계 매핑 보정 필요. 메타① 직접 훼손
  • 영양거울 포함검증 부재(coach.ts:716-739·485-505 전부 '빼라'만, 포함 0): must-weave돼도 손LLM이 누락하면 모든 가드 통과해 영양안내 0 발행. composeLetter 결정론 soft 포함검증→누락시 fixNotes 1회 재생성 추가. 과소검증=메타① 방치
  • mirrorBlock 고정문장(coach.ts:625-634) 매일 동일 + 곁들임 슬롯 강제(coach.ts:642-643 '인기음식 반드시 한 번+또래들은 OO')가 4슬롯 골격을 고정해 식재료만 회전되는 앵무새 → 메타② 리텐션 훼손. daySeed 회전요소로 강등
  • 두뇌 nutritionMirror(route.ts:629)·간식엔진(route.ts:350)이 raw fg.missing/reds를 받아 본문(gMissing 게이트)과 결핍인식 모순 — 게이트값 통일(raw 빼는 방향=더 안전)
  • pushWindow [2,3,4](coachWeekly.ts:54)가 firstServe와 직렬AND라 차림 늦은 주 push 영영0 → [1,2,3,4,5] 확장(§13 firstServe·주1회캡·강등 보전, 26근본원인 무관)
🔁 가드 간 모순(상쇄·이중차단)
  • verify 채택 AND-게이트(coach.ts:834): (v2개선)&&!detBad&&simBadness<1 — 의미위반 고친 수정본이 어휘유사도 한 줄로 기각되어 위반 원본이 발행됨. det는 하드차단 유지, simBadness는 '둘 다 위반시 덜 나쁜 쪽' 타이브레이커로 강등해 안전(det)/미용(유사도)/의미(verify)를 분리해야 함. 의미위반↔발행의 단일 관문이라 영향 큼
  • mirrorBlock force(coach.ts:633 '반드시 녹여라')가 history-block plea(coach.ts:545 '위안 문구 최근 썼으면 빼라')를 무력화 — 등원+집결핍 아동에게 동일 영양거울 구절이 매일 verbatim 발행(메타② 앵무새). must는 유지하되 prebaked 고정문장→daySeed 회전+쿨다운으로 모순 해소
  • rxRefs 무게이트(route.ts:400) vs refExposable 결핍군 필터(route.ts:356) — 같은 거부를 타깃경로는 막고 재노출-사실 경로는 통과시켜 치킨이 본문에 '5일 전·재노출 적기'로 재등판(off-target). 단일 필터 헬퍼로 통일 필요
  • STRUCTURAL_FRAMES useFood=false(route.ts:679)가 texture-refusal까지 포함해 음식 형태변경이 본질인 텍스처 날 음식추천을 끔 — NO_FOOD_ACTION_FRAMES(coach.ts:364)로 좁히면 해소
  • A-07 연속 food날 캡(route.ts:638)이 lever-blind라 food 닻 주에도 3일째 bridgeFacts를 강제로 비워 food주 핵심콘텐츠와 충돌 — food주 캡 면제로 닻 의도와의 상쇄 제거
유지(KEEP 20) — 환각·괴식·질식·거짓결핍·과채근을 막는 안전가드는 전부 유지(폐기 0). 정리는 조정만.

K-01 catOf↔식품군 네임스페이스 보정: nutrition.ts에서 FOOD_GROUP(또는 groupOf) export → route.ts:358 refExposable 게이트를 `const g = FOOD_GROUP[r] ?? CATEGORY_GROUP[catOf(r)??''] ?? catOf(r); _deficientGroups가드A

우선순위 P0 · 매핑 A · ✅ 완료 a594be5(vitest 400)

K-02 rxRefs off-target 누수 봉합: route.ts:400 rxRefs를 K-01 보정 포함 단일 exposable 헬퍼로 교체(refExposable과 통일·!transitioned 유지). 보강으로 tsFiltered(coach.ts:779)에 비-re-exposure-timing 시 재노출 fact 정규식(/재노출 주기상|잊히기 전에가드A

우선순위 P0 · 매핑 A

K-03 영양거울 포함검증 추가(confirmed #7): composeLetter에 결정론 soft 검증 — mirrorBlock 분기 판정 helper 추출, allMiss/homeMiss 분기면 식품군명 또는 '부족/채워/다양성' 키워드 본문존재 검사, 누락시 fixNotes 1회만 재생성(timeLeft 강등 편승). 일반분기·de가드C

우선순위 P0 · 매핑 C

K-04 mirrorBlock 앵무새 해소: prebaked 고정문장(coach.ts:629-632)을 daySeed 어휘회전(2~3변형), 일반분기('잘 채워지고')는 직전2~3일 쿨다운(snackShown 패턴·recentSnackDates 재사용·letterCtx에 mirrorPhraseKey 적재). allMiss/homeMiss 가드C

우선순위 P1 · 매핑 C

K-05 verify 채택 게이트 분리(coach.ts:834): detBad만 하드차단 유지(S1), simBadness<1을 하드 AND에서 제거하고 '둘 다 위반시 덜 나쁜 쪽' 타이브레이커로 강등. 원본이 verify 위반을 안고 g가 그 위반 줄인 det-clean이면 유사도 높아도 g 채택. 의미위반↔발행 단일관문이라 우선가드D

우선순위 P1 · 매핑 D

K-06 A-07 연속 food날 캡 lever 인지화(route.ts:638): food 닻 주는 캡 면제 또는 임계3일(weekCtx.lever 이미 스코프 내). 비-food주 2일캡 유지=원래버그(잔소리연속) 보존. 테스트: food주 3일연속 false·2일 유지 / 비-food주 2일 false가드A

우선순위 P1 · 매핑 A

K-07 곁들임 슬롯 daySeed 회전(coach.ts:642-643): 안전코어(목록한정·조리법접두) 유지, '인기음식 반드시 한 번+또래들은 OO'를 MOVE_KEYS/SCEN_MOVES daySeed 회전을 본문슬롯 레벨로 확장해 강등. 640 '음식추천 항상포함'은 형태만 회전으로 함께 수정해 모순 방지. K-0가드C

우선순위 P1 · 매핑 C

K-08 채널 모순 통일: 두뇌 nutritionMirror(route.ts:629)·간식엔진(route.ts:350)을 gMissing/gHomeMissing/gReds 게이트값으로 통일(raw 결핍 빼는 방향=더 안전). defMature일 때만 fc.mirror 두뇌입력 전달(1주차 결핍끄기 정합)가드A

우선순위 P2 · 매핑 A

K-09 recoIng 회전 풀크기 종속(route.ts:692): slice(0,Math.max(1,Math.min(5,_basis.length-1)))로 변경+_basis≤1이면 회전스킵. buildIngredientPool max=7 병행은 비추(군당 대표 4~5개·supply 라운드로빈 누수)가드A

우선순위 P2 · 매핑 A

K-10 pushWindow [2,3,4]→[1,2,3,4,5] 확장(coachWeekly.ts:54): line366 게이트·firstServe(§13)·주1회캡·강등 무변경. 차림 늦은 주 push 1회 기회 확보. 26근본원인 무관가드A

우선순위 P2 · 매핑 A

K-11 STRUCTURAL_FRAMES useFood=false(route.ts:679)를 NO_FOOD_ACTION_FRAMES(coach.ts:364·texture 제외)로 좁힘 — 텍스처날 음식추천 허용(음식형태변경이 본질)가드B

우선순위 P2 · 매핑 B

K-12 context.mirror 정직 적재 + ARFID push=0 졸업: (a) K-03/K-04 구현 시 '본문에 거울 실렸다' 판정결과를 context.mirror로 적재(naive truthy 금지=역방향 오탐)→어드민/셀프체크 mirrorRate 복구. (b) icfqRiskCount 완화신호 시 push잠금 졸업+arfid가드E

우선순위 P2 · 매핑 E