값 시트 — `.claude/store-handoff.json`
[SKILL.md](../SKILL.md) §4 의 형식과 조회 명령이다. 앱마다 스토어에 넣을 값을 **그 앱 기준으로** 적고, 값마다 출처와 확정 여부를 붙인다. 스토어 작업(사람이 하든 봇 브리프로 하든)은 `confirmed` 값만 쓴다.
SKILL.md §4 의 형식과 조회 명령이다. 앱마다 스토어에 넣을 값을
그 앱 기준으로 적고, 값마다 출처와 확정 여부를 붙인다. 스토어 작업(사람이 하든 봇 브리프로 하든)은 confirmed 값만 쓴다.
- 위치: 앱 저장소 루트의
.claude/store-handoff.json. -
co-bricks(
cob create·cob generate)로 만든 프로젝트는 생성기가 초안을 남긴다. 생성기는 이미 있는 파일을 덮어쓰지 않는다. - 비밀값을 넣지 않는다. 비밀값이 필요한 칸은
secretRef에 시크릿의 이름 만 적는다.
1. 모양#
아래 값은 전부 가상의 예시다. 생성기가 만드는 모양을 따랐다.
{
"$comment": "비밀값 금지. 스토어 작업은 status 가 confirmed 인 값만 쓴다.",
"botAccount": { "value": null, "status": "pending", "note": "최소 권한 계정을 정한다" },
"play": { "developerName": { "value": "Example Corp", "source": "project", "status": "proposal" },
"developerId": { "value": null, "status": "pending" } },
"apple": { "teamName": { "value": "Example Corp", "source": "project", "status": "proposal" },
"teamId": { "value": "<team-id>", "source": "project", "status": "confirmed" },
"itcTeamId": { "value": null, "status": "pending" } },
"allowedDomains": ["play.google.com", "accounts.google.com",
"appstoreconnect.apple.com", "appleid.apple.com", "idmsa.apple.com"],
"rules": {
"doNotCopyDeclarations": ["dataSafety", "appPrivacy", "advertisingId",
"targetAudience", "contentRating", "appAccess"],
"status": { "confirmed": "…", "proposal": "…", "update-needed": "…", "replace": "…", "pending": "…" },
"source": "project | copy | console | manual",
"resultFormat": "엔티티 보고서 — id, type, store, field, value, source, status"
},
"developerAccount": { "legalName": { "value": "Example Corp", "source": "project", "status": "proposal" },
"address": { "value": null, "status": "pending" } },
"apps": {
"app": {
"directory": "app/sample",
"distribution": { "value": null, "status": "pending" },
"android": { "package": { "value": "com.example.sample", "source": "project", "status": "confirmed" } },
"ios": { "bundleIds": { "prod": { "value": "com.example.sample", "source": "project", "status": "confirmed" },
"stg": { "value": "com.example.sample.stg", "source": "project", "status": "confirmed" } },
"sku": { "value": "com.example.sample", "source": "project", "status": "proposal" },
"primaryLocale": { "value": "ko", "source": "manual", "status": "confirmed" } },
"store": {
"name": { "value": "Sample", "source": "project", "status": "confirmed" },
"defaultLanguage": { "value": "ko-KR", "source": "project", "status": "confirmed" },
"pricing": { "value": null, "status": "pending" },
"contactEmail": { "value": "support@source-app.example", "source": "copy", "status": "replace" },
"privacyPolicyUrl": { "value": "https://sample.example/privacy", "source": "project", "status": "update-needed",
"note": "이 앱이 실제로 수집하는 항목을 다루는지 확인" },
"accountDeletionUrl": { "value": null, "status": "pending" },
"reviewDemoAccount": { "secretRef": "REVIEW_ACCOUNT_*", "status": "pending" },
"dataSafety": { "value": null, "status": "pending", "note": "바이너리의 실제 동작으로 답한다" },
"appPrivacy": { "value": null, "status": "pending", "note": "바이너리의 실제 동작으로 답한다" }
}
}
}
}
-
필드 하나 =
{ "value", "source", "status", "note"? }.value는 문자열·null·객체 모두 된다(예: 스토어별 카테고리). - 앱 키(
app등)는 저장소의 앱 디렉터리마다 하나씩이다. -
생성기가 넣는 상태: 패키지·Bundle ID 는 빌드 파일에서 읽어
confirmed, Apple 팀 id 는 프로젝트 변수에서confirmed, 이름·기본 언어·웹사이트·방침 URL·SKU 는proposal, 나머지는null+pending. 자리표시자 값은 넣지 않는다. -
런북이 쓰는 칸 이름: 선언 답은
store.<선언 이름>— 생성기가 만드는dataSafety·appPrivacy와, 필요할 때 더하는advertisingId·targetAudience·contentRating·appAccess(rules.doNotCopyDeclarations와 같은 이름),containsAds·newsApp·governmentApp·financialFeatures·healthApp. Play 스토어 설정은store.category(생성기가 만든다)·store.tags(더한다). - 봇 계정(
botAccount)에는 저장소 공개 범위에 맞는 값을 둔다. 공개 저장소면 역할 별칭을 두고, 브리프에 넣을 실제 주소는 사람이 채운다. -
언어 코드는 스토어마다 형식이 다르다.
store.defaultLanguage는 Play 형식(예:ko-KR)이고, App Store 기본 언어는ios.primaryLocale(예:ko)을 더해 둔다(생성기는 만들지 않는다) — fastlaneproduce도 이 형식만 받는다. -
콘솔에서 새로 생긴 값은 확인한 뒤 source
console로 더한다. 예:android.playAppId,ios.ascAppleId. 봇 보고서만 보고 넣지 않는다. -
첫 AAB 의 서명 키를 무엇으로 할지 기록하려면
android.uploadKey를 더하고 결정만 적는다(생성기는 만들지 않는다). 키 파일·비밀번호는 넣지 않는다.
2. status · source#
| status | 뜻 | 브리프에 |
|---|---|---|
confirmed | 이 앱 기준으로 확정 | ✅ |
proposal |
이 앱 기준 제안값 — 사람이 확인하면 confirmed |
❌ |
update-needed |
이 앱 값이지만 내용을 고쳐야 한다(예: 방침이 새 앱을 다루지 않음) | ❌ |
replace | 다른 앱 값이 들어가 있다 — 이 앱 값으로 바꿔야 한다 | ❌ |
pending | 아직 없다. 만들어 넣지 않는다 | ❌ |
| source | 뜻 |
|---|---|
project | 이 앱 기준 값 |
copy | 다른 앱에서 옮긴 값 — status 가 confirmed 가 되기 전에는 초안이다 |
console | 콘솔이 새로 만든 값(앱 id 등) |
manual | 사람이 직접 정한 값 |
3. 조회 명령#
SHEET=.claude/store-handoff.json
APP=app # apps 아래 앱 키
모든 명령은 앱 키가 틀리면 값 시트에 앱 키가 없다 오류로 멈춘다(종료 코드 5). 오류가 난 점검은 통과가 아니다.
앱 한 개의 모든 값과 상태:
jq -r --arg app "$APP" '
(.apps[$app] // error("값 시트에 앱 키가 없다: \($app)"))
| paths(type == "object" and has("status")) as $p | getpath($p) as $f
| [($p | map(tostring) | join(".")), $f.status,
(if ($f | has("secretRef")) then "secretRef:" + $f.secretRef else ($f.value | tostring) end)]
| @tsv' "$SHEET"
전달 전 점검 — 칸 기준. 런북의 "전제" 에 적힌 칸이 전부 confirmed 인가. 출력되는 줄(칸과 그 상태, 칸이 없으면
없음)이 하나라도 있으면 전달하지 않는다. 계정 단위 칸(botAccount, play.developerName
등)도 같은 목록에 넣을 수 있다.
REQUIRED="store.name store.defaultLanguage store.pricing store.contactEmail" # 런북의 전제 칸
jq -r --arg app "$APP" --arg keys "$REQUIRED" '
. as $root
| ($root.apps[$app] // error("값 시트에 앱 키가 없다: \($app)")) as $a
| ($keys | split(" ") | map(select(length > 0)))[] as $k
| ($k | split(".")) as $p
| (($a | getpath($p)) // ($root | getpath($p))) as $f
| select(($f | type) != "object" or $f.status != "confirmed")
| "\($k)\t\(if ($f | type) == "object" then $f.status else "없음" end)"' "$SHEET" \
|| echo "⛔ 값 시트를 읽지 못했다 — 전달하지 않는다"
브리프 입력값 표(confirmed 만, secretRef 칸은 빠진다):
jq -r --arg app "$APP" '
(.apps[$app] // error("값 시트에 앱 키가 없다: \($app)"))
| paths(type == "object" and has("status")) as $p | getpath($p) as $f
| select($f.status == "confirmed" and ($f | has("value")) and $f.value != null)
| "| \($p | map(tostring) | join(".")) | \($f.value | if type == "string" then . else tojson end) |"' "$SHEET"
브리프에서 비워 두고 pending 으로 보고하라고 쓸 칸:
jq -r --arg app "$APP" '
(.apps[$app] // error("값 시트에 앱 키가 없다: \($app)"))
| paths(type == "object" and has("status")) as $p | getpath($p) as $f
| select($f.status != "confirmed") | "\($p | map(tostring) | join("."))\t\($f.status)"' "$SHEET"
전달 전 점검 — 값 기준. 확정되지 않은 값(이 앱과 계정 단위)이 브리프 본문에 섞였는가. 출력이 있으면 전달하지 않는다. 같은 값이 다른 칸에서
confirmed 이면 목록에서 빠진다(오탐 방지). 그래서 SKU 처럼 확정된 식별자와 값이 같은 제안값은 이 점검이 못 잡는다 — 칸 기준 점검이 먼저다.
NOT_CONFIRMED=$(mktemp)
if jq -r --arg app "$APP" '
(.apps[$app] // error("값 시트에 앱 키가 없다: \($app)")) as $a
| [ ($a | paths(type == "object" and has("status")) as $p | $a | getpath($p)),
(del(.apps, .rules) | paths(type == "object" and has("status")) as $p | getpath($p)) ] as $fs
| if ($fs | length) == 0 then error("상태를 가진 칸이 없다 — 시트 모양을 확인한다") else . end
| ([$fs[] | select(.status == "confirmed") | .value | tostring]) as $ok
| [$fs[] | select(.status != "confirmed" and has("value") and .value != null)
| (.value | tostring) | select(IN($ok[]) | not)] | unique[]' "$SHEET" > "$NOT_CONFIRMED"; then
[ -s "$NOT_CONFIRMED" ] && command grep -nF -f "$NOT_CONFIRMED" <브리프 파일>
else
echo "⛔ 값 시트를 읽지 못했다 — 전달하지 않는다"
fi
계정 단위 값(개발자 계정·팀·봇 계정)의 상태:
jq -r '
paths(type == "object" and has("status")) as $p | select(($p[0] | IN("apps", "rules")) | not)
| getpath($p) as $f | "\($p | map(tostring) | join("."))\t\($f.status)\t\($f.value // "-" | tostring)"' "$SHEET"
4. 규칙#
- 다른 앱 값은 source 를 남기고
replace로 둔다. 초안일 뿐 제출하지 않는다. -
선언 항목(
rules.doNotCopyDeclarations)은 초안으로도 복사하지 않는다. 답은 SKILL.md §5 방식으로 만든다. -
모르는 값은
null+pending. 계정 삭제 URL 처럼 아직 없는 페이지는 페이지가 생길 때까지pending이다. - 봇 보고서에서 얻은 값은 확인 전까지 후보다 — 권한 있는 계정의 API·화면으로 확인한 뒤에 넣는다.
- 한 번 넣으면 바꿀 수 없는 값(SKU·식별자)은 사람이
confirmed로 바꾼다. 생성기의 SKU 는proposal이다.