diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 4ccdb74..6c0f5e0 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -12,4 +12,23 @@ ## Validation -- +- 실행 명령 / 실제 결과 / 로그 또는 CI 링크: +- 검사한 commit SHA: +- 산출물·asset 경고: + +## Merge decision (별도 사람 결정) + +- 결정 담당자: +- 요청하는 결정 한 가지: +- 대기 시작 시각: +- 결정 기한 / 기한 초과 시 처리: +- `operations/decision-queue.json` 항목 ID: + +## Release (작업 병합과 별도) + +- 대상 episode / phase: +- 원본 Release Approval 상태 / 증거 경로: +- 남은 QA·오버레이·페이지 매핑 문제: + +CI 성공이나 체크박스는 사람 승인·발행 완료의 근거가 아니다. +`node scripts/decision-status.js --github` 결과로 검증·병합·공개 상태를 분리해서 보고한다. diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 246744e..8c6fe2c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -29,6 +29,11 @@ jobs: url: ${{ steps.deploy.outputs.page_url }} steps: - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '24' + - name: Require human release approval, QA and final output evidence + run: node scripts/validate-episode-output.js EP001 --require-release - uses: actions/configure-pages@v5 - name: Assemble public site with approved EP001 assets run: | diff --git a/.github/workflows/validate-assets.yml b/.github/workflows/validate-assets.yml index c0aa1c5..d324a6c 100644 --- a/.github/workflows/validate-assets.yml +++ b/.github/workflows/validate-assets.yml @@ -5,6 +5,14 @@ on: paths: - 'episodes/**' - 'schemas/**' + - 'operations/**' + - 'config/decision-policy.json' + - 'scripts/decision-status.js' + - 'scripts/release-readiness.js' + - 'tests/decision-status.test.js' + - '.github/workflows/validate-assets.yml' + - '.github/workflows/deploy.yml' + - 'state.json' - 'config/panel-generation-policy.json' - 'config/asset-storage-policy.json' - 'scripts/build-panel-jobs.js' @@ -22,6 +30,14 @@ on: paths: - 'episodes/**' - 'schemas/**' + - 'operations/**' + - 'config/decision-policy.json' + - 'scripts/decision-status.js' + - 'scripts/release-readiness.js' + - 'tests/decision-status.test.js' + - '.github/workflows/validate-assets.yml' + - '.github/workflows/deploy.yml' + - 'state.json' - 'config/panel-generation-policy.json' - 'config/asset-storage-policy.json' - 'scripts/build-panel-jobs.js' @@ -38,6 +54,11 @@ on: jobs: validate-assets: runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + checks: read + statuses: read steps: - uses: actions/checkout@v4 with: @@ -49,7 +70,14 @@ jobs: run: node scripts/check-panel-assets.js - name: Validate episode discovery, decisions, and approvals run: node scripts/validate-episode-governance.js + - name: Validate decision owners, deadlines and evidence + run: node scripts/decision-status.js --validate - name: Validate episode output manifests and release assets - run: node scripts/validate-episode-output.js EP001 + run: node scripts/validate-episode-output.js EP001 --require-release - name: Test creation memory context compiler run: node --test tests/*.test.js + - name: Show decision waiting report + if: always() + env: + GH_TOKEN: ${{ github.token }} + run: node scripts/decision-status.js --github >> "$GITHUB_STEP_SUMMARY" diff --git a/AGENTS.md b/AGENTS.md index ef98dac..3849d6b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,6 +45,20 @@ Clean panel borders, dynamic perspective. `provisional`로 표시한다. - 상세 운영 규칙: `docs/AI-COLLABORATION-PROTOCOL.md` +## 검증·병합·공개와 결정 대기 + +- 작업 시작 시 `node scripts/decision-status.js --github`로 결정 담당자, 기한, + CI 근거, 병합 대기와 공개 사전조건을 확인한다. 상세 계약은 + `docs/DECISION-OPERATIONS.md`를 따른다. +- 자동 생성 배치 선택에는 `run-panel-jobs.js --respect-review-limit`을 붙인다. + `--write-plan`은 항상 같은 WIP 검사를 실행한다. 미승인 PR 2개 이상 또는 + GitHub 상태 미확인이면 새 생성 명령을 내보내지 않고 기존 검증·후처리를 우선한다. +- 검증 성공은 병합 승인이나 Release Approval이 아니다. 사람 결정은 원본 + `approvals/gates.json`에 증거와 함께 기록하고 대기 목록은 그 원본을 참조한다. +- 기한 초과는 보고·해당 의존 작업 보류 사유이며 자동 승인 근거가 아니다. +- 공개 전 `node scripts/validate-episode-output.js EP001 --require-release`를 + 실행한다. 다른 에피소드 공개는 별도 QA·승인·배포 범위 변경이 필요하다. + ## Phase 1 상세: 4단계 스토리 각색 워크플로우 Phase 1(Story)는 다음 4단계로 내부 세분화됩니다: diff --git a/config/decision-policy.json b/config/decision-policy.json new file mode 100644 index 0000000..832d651 --- /dev/null +++ b/config/decision-policy.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "repository": "Daegu-Agent-Crew/creative-loop-engineering3", + "owners": { + "cle3-maintainer": "현재 CLE3 운영자(이 작업을 요청한 사용자); 담당자를 위임하면 이 항목을 변경한다." + }, + "max_unapproved_prs": 2, + "required_checks": ["validate-assets"], + "review_sla_hours": 24, + "overdue_action": "담당자에게 기한 초과와 증거를 표시하고 해당 결정에 의존하는 작업을 보류한다. 자동 승인·병합·기억 승격은 하지 않는다." +} diff --git a/docs/AI-COLLABORATION-PROTOCOL.md b/docs/AI-COLLABORATION-PROTOCOL.md index f6d133b..5a8d9fe 100644 --- a/docs/AI-COLLABORATION-PROTOCOL.md +++ b/docs/AI-COLLABORATION-PROTOCOL.md @@ -49,6 +49,13 @@ episodes/EPxxx/ 과거 Phase가 완료되어 있어도 명시적인 사람 승인 기록이 없으면 `approved`로 추정하지 않고 `provisional`로 표시한다. +## 결정 대기 운영 + +`operations/decision-queue.json`은 결정 담당자·단일 요청·대기 시작 시각·기한과 +기한 초과 처리의 원본이다. 승인 자체는 기존 `approvals/gates.json`에 남긴다. +검증 통과, 작업 병합과 공개 발행을 구분하는 보고 명령·WIP 상한·마감 운영은 +`docs/DECISION-OPERATIONS.md`를 따른다. + ## 검증 ```bash diff --git a/docs/DECISION-OPERATIONS.md b/docs/DECISION-OPERATIONS.md new file mode 100644 index 0000000..7babf1d --- /dev/null +++ b/docs/DECISION-OPERATIONS.md @@ -0,0 +1,109 @@ +# CLE3 결정 대기와 완료 증거 + +CI 성공은 검사한 커밋에 대한 결과다. PR 병합은 작업 통합 결정이고, 공개 발행은 +Phase 5 QA와 사람 Release Approval을 갖춘 별도 결정이다. 셋을 하나의 +`complete` 플래그로 올리지 않는다. + +## 매일 읽는 보고서 + +```bash +node scripts/decision-status.js --github +node scripts/decision-status.js --github --json +node scripts/decision-status.js --validate +``` + +보고서는 파일을 수정하거나 메시지를 전송하지 않는다. 원본 승인, QA 점수와 최종 +파일 목록, PNG/SVG의 존재, 오버레이 검토 상태를 대조한다. GitHub 조회는 `gh`의 +로그인 또는 `GH_TOKEN`을 사용한다. 토큰은 출력하지 않는다. `--github`를 생략하거나 +네트워크가 실패하면 CI·WIP는 `unknown`이며 이전 성공값을 재사용하지 않는다. +전체 PR 목록이 1,000개 제한에 도달하면 불완전한 목록으로 보고 WIP를 미확인 처리한다. + +PR별로 최신 head SHA, check 결과와 URL, 충돌·리뷰 상태를 보고한다. 필수 검사 +`validate-assets`가 없으면 `unknown`, skipped/neutral/진행 중이면 `pending`이다. +검사 통과·충돌 없음이어도 `human_decision_required`일 뿐 병합하지 않는다. +열린 목록에서 사라진 PR은 `not_open_verify_outcome`으로 표시한다. 담당자는 +`gh pr view <번호> --json state,mergedAt,url`로 병합/취소 여부를 확인하고 목록을 정리한다. +PR이 merged라는 확인도 에피소드의 공개 상태를 바꾸지 않는다. + +GitHub Actions의 Validate panel assets 실행 요약에도 이 보고서를 표시한다. +보고 시점의 최신 PR head를 읽으므로, 현재 실행 자체가 진행 중이면 pending이 +정상이다. 이것은 해당 workflow 실행의 최종 결과를 대신하지 않는다. + +## 대기 항목의 소유자와 기한 + +`config/decision-policy.json`의 `cle3-maintainer`는 현재 CLE3 운영자, 즉 작업을 +요청한 사용자 역할이다. 외부 담당자에게 임의 배정한 것이 아니다. 위임 시 역할의 +설명을 바꾸고 대기 항목 `owner`를 갱신한다. + +`operations/decision-queue.json`의 각 항목에는 다음을 기록한다. + +- `request`: 지금 요청하는 결정 한 가지 +- `owner`: 정책에 등록한 담당자 역할 +- `waiting_since`, `waiting_since_basis`: 대기 시각과 그 근거 +- `due_at`, `overdue_action`: 기한과 초과 시 해당 의존 작업 처리 +- `source_path`, `evidence`: 원본 게이트/후보/맥락과 검토 파일 + +초기 범위는 EP002의 병합·매핑·게이트, EP001의 보류 중인 동률 후보 2건이다. +EP003~EP005를 재개할 때 해당 에피소드의 결정 항목도 추가한다. PR #39는 GitHub의 +생성 시각을 사용했다. 다른 항목은 과거 요청 시각을 확인할 수 없어 2026-09-08을 +`tracking_started`로 기록했다. 이는 과거에 요청이나 승인이 있었다는 뜻이 아니다. + +초기 운영 기한은 추적 시작 후 24시간인 2026-09-09 10:11 KST다. 새 요청은 정책의 +`review_sla_hours`를 기준으로 명시적 기한을 정한다. 매일 실행할 때 마감을 자동 +연장하지 않는다. 기한 초과 보고에는 `overdue=true`와 처리 문구가 함께 나타난다. +알림 발송이나 자동 승인은 하지 않는다. + +Release 항목은 Phase 5 완료 전 `waiting_prerequisite`이고 사람 결정 지연으로 +집계하지 않는다. Phase 5를 완료하는 작업자는 실제 검토 요청 시각과 새 기한을 +기록한다. 페이지 매핑 충돌은 해당 패널의 오버레이를 보류하고, 독립 작업은 계속한다. + +결정이 도착하면 원본 `approvals/gates.json` 또는 후보 선택 기록을 먼저 갱신한다. +이후 큐를 `resolved`로 바꾸고 `resolution`에 `decided_by`, `decided_at`, `decision`, +`evidence`를 기록한다. 큐만 resolved로 바꿔 미승인 게이트를 우회하면 검증 실패다. +원본 게이트만 결정된 경우 보고서는 `source_decided_sync_required`로 정리를 요청한다. +기존 패널 유지 결정도 증거와 함께 남기며, 기억 승격은 별도 정책을 따른다. + +## 미승인 PR 상한 시험 운영 + +```bash +node scripts/decision-status.js --check-start --json +node scripts/run-panel-jobs.js --episode EP002 --dry-run --max-jobs 3 --respect-review-limit +``` + +열린 PR 중 `reviewDecision != APPROVED`인 것을 미승인으로 센다. 리뷰가 없거나 +수정 요청 상태인 PR, draft PR도 포함한다. API에서 리뷰 결정을 제공하지 않을 때도 +승인으로 추정하지 않는다. 기본 상한은 2개다. 상한 도달 또는 조회 실패 시 종료 코드 +2와 빈 `selected_jobs`를 반환하고 새 생성·plan 저장을 보류한다. 검사와 결정 정리, +기존 산출물 후처리는 계속할 수 있다. + +`--write-plan`은 항상 상한을 검사한다. 기존의 플래그 없는 `--dry-run`은 로컬 +진단·테스트 호환성을 위해 네트워크 없이 동작하므로, 자동화에서는 반드시 +`--respect-review-limit`을 사용한다. 이 값은 병렬 패널 수(최대 3개)와 별개다. + +## 공개 직전 강제 검사 + +```bash +node scripts/validate-episode-output.js EP001 --require-release +``` + +현재 Pages workflow는 EP001만 포장한다. 포장·업로드 전에 이 검사를 실행한다. +수동 workflow 실행에도 동일하게 적용한다. 기존 승인과 QA를 읽으며 새 승인을 쓰지 않는다. + +- Phase 4·5 완료, Release Approval의 `approved`·담당자·시각·실제 증거 파일 +- QA 5개 점수 합계 42/50 이상, 최종 이미지 목록의 정확한 일치 +- 패널/오버레이 ID의 일대일 대응, 생성 상태와 실제 소스·SVG 파일 +- `draft`/`needs_review` 차단, 오버레이 소스 경로 일치 +- 기존 공개 뷰어·Bible export·asset manifest 검사 + +기존 EP001의 `rendered`는 텍스트 패널에도 쓰인다. 이 상태 자체를 승인으로 읽지 +않고 에피소드 QA와 사람 공개 승인을 함께 요구한다. 이 검사는 구조와 기록의 +일치 여부를 확인한다. 실제 시각 품질을 새로 평가하거나 과거 QA가 파일의 바이트 +변경까지 검토했는지 증명하지는 않는다. 공개 산출물을 수정하면 QA·승인 범위를 +사람이 다시 확인해야 한다. + +## CLE5와의 경계 + +CLE3는 원작 각색·패널 제작·QA·사람 승인·발행의 원본을 관리한다. CLE5에 전달할 +관측은 검증 명령, 실행 결과, 커밋 SHA, 산출물 경로와 보류 사유다. CLE5의 제품 +피드백과 승인된 기억은 CLE5의 독립 정책을 따른다. CLE3 CI 성공이나 후보 점수는 +CLE5의 승인된 선호로 자동 승격하지 않는다. diff --git a/episodes/EP002/decisions/implementation-notes.json b/episodes/EP002/decisions/implementation-notes.json index 5d80ec7..c237796 100644 --- a/episodes/EP002/decisions/implementation-notes.json +++ b/episodes/EP002/decisions/implementation-notes.json @@ -2,6 +2,80 @@ "$schema": "../../../schemas/decision-log.schema.json", "episode_id": "EP002", "decisions": [ + { + "id": "DEC-20260907-001", + "made_at": "2026-09-07T21:03:37Z", + "phase": "phase4_panels", + "decision": "p10-3의 단일 생성 결과를 정식 패널로 선택하고 원본 후보를 별도 보존했다.", + "rationale": "왕먀오의 큰 둥근 뿔테 안경, 단정한 검은 머리, 마른 체형과 청회색 셔츠를 캐릭터 기준에 맞췄다. 새벽 포장마차의 차가운 청색 거리와 오른쪽의 절제된 호박색 조명이 p10-1·p10-2의 시간·장소 연속성을 유지한다. 얼굴의 굳은 눈썹·벌어진 입·불안한 눈빛이 화난 접근 아래의 도움 요청을 전달하며, 상단 좌측의 비어 있는 청색 영역은 후처리 대사 여백이다. 읽을 수 있는 문자·숫자·대사·경보는 없다.", + "evidence": [ + ".candidates/EP002/p10-3/iteration-01/p10-3-v01.png", + "episodes/EP002/panels/assets/p10-3.png", + "episodes/EP002/panels/final/p10-3.svg", + "episodes/EP002/panels/assets/p10-1.png", + "episodes/EP002/panels/assets/p10-2.png" + ], + "confidence": "medium", + "uncertainties": [ + "전달 PNG의 파일 크기는 자산 검사에서 1MB 목표 대비 경고 여부를 확인해야 한다.", + "p10-1과 p10-4의 draft 오버레이를 포함한 페이지 10 전체 연속성은 페이지 단위 QA에서 다시 확인한다.", + "단일 생성 관찰은 Series Bible 선호로 승격하지 않는다." + ], + "alternatives_considered": [ + "추가 변형 후보를 생성해 비교", + "기존 pending 상태 유지" + ], + "human_approval": "not_required", + "impact": "p10-3을 generated 상태로 기록하고 후보 원본은 .candidates에 보존한다. 페이지 10 generation job은 네 패널의 PNG가 확인되어 completed로 동기화했다. p10-3에는 대사·자막이 없어 빈 오버레이 레코드와 최종 SVG를 렌더링했다." + }, + { + "id": "DEC-20260906-001", + "made_at": "2026-09-06T21:04:19Z", + "phase": "phase4_panels", + "decision": "p10-2의 단일 생성 결과를 정식 패널로 선택하고 원본 후보를 별도 보존했다.", + "rationale": "스치친의 헝클어진 검은 머리, 수염 자국, 묵직한 체형, 낡은 재킷과 열린 흰 셔츠를 캐릭터 기준 및 p10-1의 직전 장면과 맞췄다. 새벽 거리의 차가운 청색과 포장마차의 따뜻한 호박색 조명, 국수 그릇과 젓가락을 든 설명 제스처가 숫자가 줄어드는 의미를 해석하는 단일 비트를 명확히 전달한다. 상단 좌측에는 후처리 대사 여백이 있으며 읽을 수 있는 문자·숫자·대사·경보는 없다.", + "evidence": [ + ".candidates/EP002/p10-2/iteration-01/p10-2-v01.png", + "episodes/EP002/panels/assets/p10-2.png", + "episodes/EP002/panels/final/p10-2.svg", + "episodes/EP002/panels/assets/p10-1.png" + ], + "confidence": "medium", + "uncertainties": [ + "전달 PNG의 파일 크기는 자산 검사에서 1MB 목표 대비 경고 여부를 확인해야 한다.", + "페이지 10의 인접 패널과 인물·조명 연속성은 페이지 단위 QA에서 다시 확인한다.", + "단일 생성 관찰은 Series Bible 선호로 승격하지 않는다." + ], + "alternatives_considered": [ + "추가 변형 후보를 생성해 비교", + "기존 pending 상태 유지" + ], + "human_approval": "not_required", + "impact": "p10-2를 generated 상태로 기록하고 후보 원본은 .candidates에 보존한다. 페이지 10 generation job은 p10-3과 p10-4가 남아 partial을 유지한다. p10-2에는 대사·자막이 없어 빈 오버레이 레코드와 최종 SVG를 렌더링했다." + }, + { + "id": "DEC-20260905-001", + "made_at": "2026-09-05T21:04:53Z", + "phase": "phase4_panels", + "decision": "p10-1의 단일 생성 결과를 정식 패널로 선택하고 원본 후보를 별도 보존했다.", + "rationale": "스치친의 헝클어진 검은 머리, 수염 자국, 묵직한 체형과 왕먀오의 큰 뿔테 안경, 마른 체형을 참조 자산에 맞췄다. 새벽 거리의 차가운 청색과 포장마차의 따뜻한 호박색 조명, 국수를 먹는 스치친과 서 있는 왕먀오의 대비가 첫 페이지-10 비트를 명확히 전달한다. 읽을 수 있는 문자·숫자·대사·경보는 없다.", + "evidence": [ + ".candidates/EP002/p10-1/iteration-01/p10-1-v01.png", + "episodes/EP002/panels/assets/p10-1.png" + ], + "confidence": "medium", + "uncertainties": [ + "전달 PNG는 2.70MB로 정책의 1MB 목표를 초과하지만 4MB 하드 한도 이하다.", + "다인물 광각 구도이므로 페이지 10의 인접 패널과 인물·조명 연속성은 페이지 단위 QA에서 다시 확인한다.", + "단일 생성 관찰은 Series Bible 선호로 승격하지 않는다." + ], + "alternatives_considered": [ + "추가 변형 후보를 생성해 비교", + "기존 pending 상태 유지" + ], + "human_approval": "not_required", + "impact": "p10-1을 generated 상태로 기록하고 후보 원본은 .candidates에 보존한다. 페이지 10 generation job은 p10-2와 p10-3이 남아 partial을 유지하며, p10-1은 오버레이·최종 SVG 렌더링 대상으로 전환한다." + }, { "id": "DEC-20260831-001", "made_at": "2026-08-31T21:04:04Z", @@ -211,5 +285,78 @@ "human_approval": "not_required", "impact": "페이지 6의 4개 패널이 모두 생성 단계에서 완료되어, 후처리·페이지 단위 QA 대상으로 전환한다." } + , + { + "id": "DEC-20260902-001", + "made_at": "2026-09-02T21:02:56Z", + "phase": "phase4_panels", + "decision": "p8-2의 단일 생성 결과를 정식 패널로 선택하고 원본 후보를 별도 보존했다.", + "rationale": "왕먀오의 큰 뿔테 안경, 짧은 검은 머리와 마른 학자 인상을 참조 자산에 맞췄다. 젓가락과 접시의 작은 접촉, 가족의 시선, 따뜻한 식탁 조명으로 사적 공포가 일상에 스미는 비트를 명확히 전달한다. 이미지 안에는 읽을 수 있는 문자·숫자·대사·경보가 없다.", + "evidence": [ + ".candidates/EP002/p8-2/iteration-01/p8-2-v01.png", + "episodes/EP002/panels/assets/p8-2.png" + ], + "confidence": "medium", + "uncertainties": [ + "전달 PNG는 2.66MB로 정책의 1MB 목표를 초과하지만 4MB 하드 한도 이하다.", + "페이지 8 인접 패널과의 인물 수·조명·구도 연속성은 페이지 단위 QA에서 다시 확인한다.", + "자동 추출된 p8-2 스크립트 대사가 의사 장면을 가리켜 식탁 장면과 충돌한다. rebaseline 전에는 오버레이를 needs_review로 보류한다.", + "단일 생성 관찰은 Series Bible 선호로 승격하지 않는다." + ], + "alternatives_considered": [ + "추가 변형 후보를 생성해 비교", + "기존 pending 상태 유지" + ], + "human_approval": "not_required", + "impact": "p8-2를 generated 상태로 기록하고, 후보 원본은 .candidates에 보존한 채 페이지 8 오버레이·최종 SVG 렌더링 대상으로 전환한다. 페이지 8 generation job은 p8-3과 p8-4가 남아 partial을 유지한다." + }, + { + "id": "DEC-20260903-001", + "made_at": "2026-09-03T21:03:52Z", + "phase": "phase4_panels", + "decision": "p8-3의 단일 생성 결과를 정식 패널로 선택하고 원본 후보를 별도 보존했다.", + "rationale": "쯔쯔의 둥근 얼굴, 짧은 검은 단발, 노랑·분홍 실내복, 큰 맑은 눈을 캐릭터 참조 자산에 맞췄다. 가족 식탁의 따뜻한 조명과 상단의 비어 있는 여백이 일상의 온기에 불안이 스미는 페이지 8 비트와 후처리 오버레이 정책을 충족한다. 이미지 안에는 읽을 수 있는 문자·숫자·대사·경보가 없다.", + "evidence": [ + ".candidates/EP002/p8-3/iteration-01/p8-3-v01.png", + "episodes/EP002/panels/assets/p8-3.png" + ], + "confidence": "medium", + "uncertainties": [ + "전달 PNG는 1.61MB로 정책의 1MB 목표를 초과하지만 4MB 하드 한도 이하다.", + "페이지 8 인접 패널과의 조명·감정 연속성은 페이지 단위 QA에서 다시 확인한다.", + "자동 추출된 p8-3 스크립트 대사가 병원 장면과 카운트다운을 가리켜 가족 식탁 장면과 충돌한다. rebaseline 전에는 오버레이를 needs_review로 보류한다.", + "단일 생성 관찰은 Series Bible 선호로 승격하지 않는다." + ], + "alternatives_considered": [ + "추가 변형 후보를 생성해 비교", + "기존 pending 상태 유지" + ], + "human_approval": "not_required", + "impact": "p8-3를 generated 상태로 기록하고, 후보 원본은 .candidates에 보존한 채 페이지 8 오버레이·최종 SVG 렌더링 대상으로 전환한다. script/storyboard 재기준선 전에는 p8-2 및 p8-3 SVG 렌더링을 보류한다. 페이지 8 generation job은 p8-4가 남아 partial을 유지한다." + }, + { + "id": "DEC-20260904-001", + "made_at": "2026-09-04T21:04:17Z", + "phase": "phase4_panels", + "decision": "p8-4의 단일 생성 결과를 정식 패널로 선택하고 원본 후보를 별도 보존했다.", + "rationale": "왕먀오의 큰 뿔테 안경, 단정한 검은 머리, 청회색 셔츠와 마른 학자 인상을 참조 자산에 맞췄다. 따뜻한 가족 식탁에서 아이의 크레용 가족 그림을 손으로 덮는 동작이 보호이자 회피라는 패널 비트를 명확히 전달하며, 읽을 수 있는 문자·숫자·대사·경보는 없다.", + "evidence": [ + ".candidates/EP002/p8-4/iteration-01/p8-4-v01.png", + "episodes/EP002/panels/assets/p8-4.png" + ], + "confidence": "medium", + "uncertainties": [ + "전달 PNG는 약 2.6MB로 정책의 1MB 목표를 초과하지만 4MB 하드 한도 이하다.", + "페이지 8 인접 패널과의 조명·감정 연속성은 페이지 단위 QA에서 다시 확인한다.", + "페이지 8의 script/storyboard 매핑 충돌이 p8-2와 p8-3에서 이미 확인됐으므로, p8-4도 rebaseline 전에는 오버레이를 needs_review로 보류한다.", + "단일 생성 관찰은 Series Bible 선호로 승격하지 않는다." + ], + "alternatives_considered": [ + "추가 변형 후보를 생성해 비교", + "기존 pending 상태 유지" + ], + "human_approval": "not_required", + "impact": "p8-4를 generated 상태로 기록하고, 후보 원본은 .candidates에 보존한다. 페이지 8 generation job은 completed로 갱신하며, script/storyboard 재기준선 전에는 p8-2~p8-4 SVG 렌더링을 보류한다." + } ] } diff --git a/episodes/EP002/memory/creation-memory.json b/episodes/EP002/memory/creation-memory.json index 1dc65ba..7d6ea5d 100644 --- a/episodes/EP002/memory/creation-memory.json +++ b/episodes/EP002/memory/creation-memory.json @@ -2,7 +2,7 @@ "$schema": "../../../schemas/creation-memory.schema.json", "schema_version": 1, "episode_id": "EP002", - "updated_at": "2026-08-31T21:04:04Z", + "updated_at": "2026-09-07T21:03:37Z", "creator_contract": { "creative_actor": "codex", "sole_creative_actor": true, @@ -132,6 +132,8 @@ "phase3 완료" ], "next_actions": [ + "p10-1의 텍스트 오버레이를 생성·검토하고 페이지 10 SVG 렌더링 가능 여부를 확인한다.", + "p8-2~p8-4의 script/storyboard 매핑을 rebaseline한 뒤 페이지 8 오버레이를 검토한다.", "p7-2와 p7-3의 페이지 7 인접 패널 연속성과 텍스트 오버레이를 QA한다.", "p5-3과 p5-4의 draft 텍스트 오버레이 위치를 시각 검토하고 QA 상태를 확정한다.", "16개의 draft 텍스트 오버레이를 시각 검토하고 위치·문구를 확정한다.", diff --git a/episodes/EP002/panels/MANIFEST.md b/episodes/EP002/panels/MANIFEST.md index f4e05b1..6699559 100644 --- a/episodes/EP002/panels/MANIFEST.md +++ b/episodes/EP002/panels/MANIFEST.md @@ -4,8 +4,8 @@ - phase: phase4_panels - total_panels: 57 -- generated_panels: 43 -- pending_panels: 14 +- generated_panels: 49 +- pending_panels: 8 - asset_dir: `episodes/EP002/panels/assets/` - workflow_rule: CLE3 내부 storyboard + characters 산출물만 사용. 외부 저장소 생성 결과 재사용 금지. diff --git a/episodes/EP002/panels/assets/p10-1.png b/episodes/EP002/panels/assets/p10-1.png new file mode 100644 index 0000000..e9c2dbc Binary files /dev/null and b/episodes/EP002/panels/assets/p10-1.png differ diff --git a/episodes/EP002/panels/assets/p10-2.png b/episodes/EP002/panels/assets/p10-2.png new file mode 100644 index 0000000..71d024b Binary files /dev/null and b/episodes/EP002/panels/assets/p10-2.png differ diff --git a/episodes/EP002/panels/assets/p10-3.png b/episodes/EP002/panels/assets/p10-3.png new file mode 100644 index 0000000..287de2c Binary files /dev/null and b/episodes/EP002/panels/assets/p10-3.png differ diff --git a/episodes/EP002/panels/assets/p8-2.png b/episodes/EP002/panels/assets/p8-2.png new file mode 100644 index 0000000..b0ab789 Binary files /dev/null and b/episodes/EP002/panels/assets/p8-2.png differ diff --git a/episodes/EP002/panels/assets/p8-3.png b/episodes/EP002/panels/assets/p8-3.png new file mode 100644 index 0000000..9e42508 Binary files /dev/null and b/episodes/EP002/panels/assets/p8-3.png differ diff --git a/episodes/EP002/panels/assets/p8-4.png b/episodes/EP002/panels/assets/p8-4.png new file mode 100644 index 0000000..2c9166e Binary files /dev/null and b/episodes/EP002/panels/assets/p8-4.png differ diff --git a/episodes/EP002/panels/final/p10-2.svg b/episodes/EP002/panels/final/p10-2.svg new file mode 100644 index 0000000..be599e9 --- /dev/null +++ b/episodes/EP002/panels/final/p10-2.svg @@ -0,0 +1,3 @@ + + + diff --git a/episodes/EP002/panels/final/p10-3.svg b/episodes/EP002/panels/final/p10-3.svg new file mode 100644 index 0000000..6ba7c3a --- /dev/null +++ b/episodes/EP002/panels/final/p10-3.svg @@ -0,0 +1,3 @@ + + + diff --git a/episodes/EP002/panels/generation-jobs.json b/episodes/EP002/panels/generation-jobs.json index b9edfa6..323399c 100644 --- a/episodes/EP002/panels/generation-jobs.json +++ b/episodes/EP002/panels/generation-jobs.json @@ -27,9 +27,9 @@ "retry_count": 0, "worker_tier": "low_performance", "image_model": "gpt-image-2", - "started_at": null, - "completed_at": null, - "generation_duration_seconds": null, + "started_at": "2026-09-05T21:03:36Z", + "completed_at": "2026-09-05T21:04:53Z", + "generation_duration_seconds": 77.5, "qa_score": null, "last_error": null, "status": "completed", @@ -297,12 +297,12 @@ "retry_count": 0, "worker_tier": "low_performance", "image_model": "gpt-image-2", - "started_at": null, - "completed_at": null, - "generation_duration_seconds": null, + "started_at": "2026-09-04T21:03:35Z", + "completed_at": "2026-09-04T21:04:17Z", + "generation_duration_seconds": 42.2, "qa_score": null, "last_error": null, - "status": "partial", + "status": "completed", "decision_rationale": "같은 페이지의 4개 패널을 장면, 조명, 캐릭터 연속성을 공유하는 작업 그룹으로 선택한다.", "confidence": "high", "assumptions": [ @@ -370,12 +370,12 @@ "retry_count": 0, "worker_tier": "low_performance", "image_model": "gpt-image-2", - "started_at": null, - "completed_at": null, - "generation_duration_seconds": null, + "started_at": "2026-09-07T21:02:11Z", + "completed_at": "2026-09-07T21:03:37Z", + "generation_duration_seconds": 48.1, "qa_score": null, "last_error": null, - "status": "partial", + "status": "completed", "decision_rationale": "같은 페이지의 4개 패널을 장면, 조명, 캐릭터 연속성을 공유하는 작업 그룹으로 선택한다.", "confidence": "medium", "assumptions": [ diff --git a/episodes/EP002/panels/panels.json b/episodes/EP002/panels/panels.json index 29eedae..cf54e4b 100644 --- a/episodes/EP002/panels/panels.json +++ b/episodes/EP002/panels/panels.json @@ -574,7 +574,16 @@ "image_path": "episodes/EP002/panels/assets/p8-2.png", "generation_prompt": "Bold clean ink linework, flat vibrant cell-shaded colors,\nrounded expressive character designs, dramatic but playful composition,\ndistinctive 1980s Akira Toriyama manga aesthetic.\nSimple but expressive backgrounds with bold black shadows.\nClean panel borders, dynamic perspective.\n\n--- SCENE ---\nEpisode: EP002\nPage: 8\nPanel ID: p8-2\nStoryboard description: 왕먀오의 젓가락이 접시를 건드려 작은 소리를 낸다. 가족들이 그를 본다.\nVisible cast: 왕먀오\n\n--- CHARACTER REFERENCES ---\n- 왕먀오 (汪淼): Reference asset: episodes/EP001/characters/assets/wangmiao-v1.png Appearance: 30대 중국 남성, 단정한 짧은 검은 머리, 큰 둥근 뿔테 안경, 마른 체형. 캐주얼 셔츠와 슬랙스, 연구실 장면에서는 흰 가운. 안경 렌즈의 반사광이 시그니처. Style notes: Toriyama style: clean geometric silhouette, large round glasses, neat simplified hair, restrained facial acting that breaks into exaggerated panic during countdown beats. Cool blue-grey cell-shaded palette, lens highlights emphasized. Inner tone: 정밀한 측정과 합리적 설명을 믿는 나노소재 연구자. 가족을 지키려는 책임감이 강하지만 설명 불가능한 현상 앞에서는 공포를 부정하려 한다.\n\n--- COMPOSITION ---\nCamera: close_up\nPanel type: single panel\nVisual emphasis: normal\nBalance character readability, environment clarity, and story momentum.\n\n--- OUTPUT RULES ---\nDeliver one finished comic panel only, not a reference sheet.\nKeep the face, silhouette, costume, and color logic consistent with the referenced CLE3 character assets.\nRespect the storyboard beat exactly and keep the panel readable inside a serialized web-comic layout.", "style": "dr-slump-toriyama", - "generation_status": "pending", + "generation_status": "generated", + "attempt_count": 1, + "complexity": "standard", + "worker_tier": "low_performance", + "image_model": "gpt-image-2", + "started_at": "2026-09-02T21:01:45Z", + "completed_at": "2026-09-02T21:02:56Z", + "generation_duration_seconds": 70.3, + "qa_score": null, + "last_error": null, "text_strategy": "postprocess_overlay", "ai_score": null, "human_score": null @@ -594,7 +603,16 @@ "image_path": "episodes/EP002/panels/assets/p8-3.png", "generation_prompt": "Bold clean ink linework, flat vibrant cell-shaded colors,\nrounded expressive character designs, dramatic but playful composition,\ndistinctive 1980s Akira Toriyama manga aesthetic.\nSimple but expressive backgrounds with bold black shadows.\nClean panel borders, dynamic perspective.\n\n--- SCENE ---\nEpisode: EP002\nPage: 8\nPanel ID: p8-3\nStoryboard description: 쯔쯔가 걱정스러운 얼굴로 묻는다. 아이의 눈이 둥글고 맑다.\nVisible cast: 쯔쯔\n\n--- CHARACTER REFERENCES ---\n- 쯔쯔: Reference asset: episodes/EP002/characters/assets/zizi-v1.png Appearance: 초등학생 또래의 중국 여자아이, 둥근 얼굴, 큰 눈, 짧은 검은 단발 또는 양갈래, 밝은 노란색/분홍색 실내복. 손에는 크레용 가족 그림. Style notes: Toriyama style: very rounded child proportions, oversized expressive eyes, simple bright clothing shapes, playful gesture language. Warm yellow and pink cell-shaded palette. Inner tone: 밝고 장난스럽다. 왕먀오가 지키고 싶은 일상의 온기이며, 카운트다운 공포가 사적 세계에 침투했음을 보여주는 감정 앵커.\n\n--- COMPOSITION ---\nCamera: close_up\nPanel type: single panel\nVisual emphasis: normal\nBalance character readability, environment clarity, and story momentum.\n\n--- OUTPUT RULES ---\nDeliver one finished comic panel only, not a reference sheet.\nKeep the face, silhouette, costume, and color logic consistent with the referenced CLE3 character assets.\nRespect the storyboard beat exactly and keep the panel readable inside a serialized web-comic layout.", "style": "dr-slump-toriyama", - "generation_status": "pending", + "generation_status": "generated", + "attempt_count": 1, + "complexity": "standard", + "worker_tier": "low_performance", + "image_model": "gpt-image-2", + "started_at": "2026-09-03T21:03:23Z", + "completed_at": "2026-09-03T21:03:52Z", + "generation_duration_seconds": 29.4, + "qa_score": null, + "last_error": null, "text_strategy": "postprocess_overlay", "ai_score": null, "human_score": null @@ -614,7 +632,16 @@ "image_path": "episodes/EP002/panels/assets/p8-4.png", "generation_prompt": "Bold clean ink linework, flat vibrant cell-shaded colors,\nrounded expressive character designs, dramatic but playful composition,\ndistinctive 1980s Akira Toriyama manga aesthetic.\nSimple but expressive backgrounds with bold black shadows.\nClean panel borders, dynamic perspective.\n\n--- SCENE ---\nEpisode: EP002\nPage: 8\nPanel ID: p8-4\nStoryboard description: 왕먀오가 그림을 뒤집어 놓는다. 보호이자 회피인 손동작.\nVisible cast: 왕먀오\n\n--- CHARACTER REFERENCES ---\n- 왕먀오 (汪淼): Reference asset: episodes/EP001/characters/assets/wangmiao-v1.png Appearance: 30대 중국 남성, 단정한 짧은 검은 머리, 큰 둥근 뿔테 안경, 마른 체형. 캐주얼 셔츠와 슬랙스, 연구실 장면에서는 흰 가운. 안경 렌즈의 반사광이 시그니처. Style notes: Toriyama style: clean geometric silhouette, large round glasses, neat simplified hair, restrained facial acting that breaks into exaggerated panic during countdown beats. Cool blue-grey cell-shaded palette, lens highlights emphasized. Inner tone: 정밀한 측정과 합리적 설명을 믿는 나노소재 연구자. 가족을 지키려는 책임감이 강하지만 설명 불가능한 현상 앞에서는 공포를 부정하려 한다.\n\n--- COMPOSITION ---\nCamera: close_up\nPanel type: single panel\nVisual emphasis: normal\nBalance character readability, environment clarity, and story momentum.\n\n--- OUTPUT RULES ---\nDeliver one finished comic panel only, not a reference sheet.\nKeep the face, silhouette, costume, and color logic consistent with the referenced CLE3 character assets.\nRespect the storyboard beat exactly and keep the panel readable inside a serialized web-comic layout.", "style": "dr-slump-toriyama", - "generation_status": "pending", + "generation_status": "generated", + "attempt_count": 1, + "complexity": "standard", + "worker_tier": "low_performance", + "image_model": "gpt-image-2", + "started_at": "2026-09-04T21:03:35Z", + "completed_at": "2026-09-04T21:04:17Z", + "generation_duration_seconds": 42.2, + "qa_score": null, + "last_error": null, "text_strategy": "postprocess_overlay", "ai_score": null, "human_score": null @@ -656,7 +683,7 @@ "image_path": "episodes/EP002/panels/assets/p10-1.png", "generation_prompt": "Bold clean ink linework, flat vibrant cell-shaded colors,\nrounded expressive character designs, dramatic but playful composition,\ndistinctive 1980s Akira Toriyama manga aesthetic.\nSimple but expressive backgrounds with bold black shadows.\nClean panel borders, dynamic perspective.\n\n--- SCENE ---\nEpisode: EP002\nPage: 10\nPanel ID: p10-1\nStoryboard description: 새벽 포장마차. 스치친은 국수를 먹고 왕먀오는 서 있다.\nVisible cast: 스치친, 왕먀오\n\n--- CHARACTER REFERENCES ---\n- 스치친 (史强): Reference asset: episodes/EP001/characters/assets/shiqiang-v1.png Appearance: 40대 중국 남성, 헝클어진 검은 머리, 무거운 수염 자국, 피곤한 좁은 눈, 묵직한 체형. 낡은 작업복 재킷과 열린 흰 셔츠, 헐렁한 바지, 담배 또는 포장마차 젓가락이 시그니처 소품. Style notes: Toriyama style: rugged rounded bulk, exaggerated weary posture, stubble dots, small shrewd eyes. Earthy olive-brown palette with amber cigarette or food-stall light. Inner tone: 거칠고 무례하지만 직감이 날카로운 형사. 왕먀오를 불편하게 만들면서도 공포를 관찰 가능한 단서로 바꾸는 안내자 역할.\n- 왕먀오 (汪淼): Reference asset: episodes/EP001/characters/assets/wangmiao-v1.png Appearance: 30대 중국 남성, 단정한 짧은 검은 머리, 큰 둥근 뿔테 안경, 마른 체형. 캐주얼 셔츠와 슬랙스, 연구실 장면에서는 흰 가운. 안경 렌즈의 반사광이 시그니처. Style notes: Toriyama style: clean geometric silhouette, large round glasses, neat simplified hair, restrained facial acting that breaks into exaggerated panic during countdown beats. Cool blue-grey cell-shaded palette, lens highlights emphasized. Inner tone: 정밀한 측정과 합리적 설명을 믿는 나노소재 연구자. 가족을 지키려는 책임감이 강하지만 설명 불가능한 현상 앞에서는 공포를 부정하려 한다.\n\n--- COMPOSITION ---\nCamera: wide\nPanel type: single panel\nVisual emphasis: normal\nBalance character readability, environment clarity, and story momentum.\n\n--- OUTPUT RULES ---\nDeliver one finished comic panel only, not a reference sheet.\nKeep the face, silhouette, costume, and color logic consistent with the referenced CLE3 character assets.\nRespect the storyboard beat exactly and keep the panel readable inside a serialized web-comic layout.", "style": "dr-slump-toriyama", - "generation_status": "pending", + "generation_status": "generated", "text_strategy": "postprocess_overlay", "ai_score": null, "human_score": null @@ -676,7 +703,7 @@ "image_path": "episodes/EP002/panels/assets/p10-2.png", "generation_prompt": "Bold clean ink linework, flat vibrant cell-shaded colors,\nrounded expressive character designs, dramatic but playful composition,\ndistinctive 1980s Akira Toriyama manga aesthetic.\nSimple but expressive backgrounds with bold black shadows.\nClean panel borders, dynamic perspective.\n\n--- SCENE ---\nEpisode: EP002\nPage: 10\nPanel ID: p10-2\nStoryboard description: 스치친이 숫자가 줄어드는 것은 아직 시간이 남았다는 뜻이라고 말한다.\nVisible cast: 스치친\n\n--- CHARACTER REFERENCES ---\n- 스치친 (史强): Reference asset: episodes/EP001/characters/assets/shiqiang-v1.png Appearance: 40대 중국 남성, 헝클어진 검은 머리, 무거운 수염 자국, 피곤한 좁은 눈, 묵직한 체형. 낡은 작업복 재킷과 열린 흰 셔츠, 헐렁한 바지, 담배 또는 포장마차 젓가락이 시그니처 소품. Style notes: Toriyama style: rugged rounded bulk, exaggerated weary posture, stubble dots, small shrewd eyes. Earthy olive-brown palette with amber cigarette or food-stall light. Inner tone: 거칠고 무례하지만 직감이 날카로운 형사. 왕먀오를 불편하게 만들면서도 공포를 관찰 가능한 단서로 바꾸는 안내자 역할.\n\n--- COMPOSITION ---\nCamera: medium\nPanel type: single panel\nVisual emphasis: normal\nBalance character readability, environment clarity, and story momentum.\n\n--- OUTPUT RULES ---\nDeliver one finished comic panel only, not a reference sheet.\nKeep the face, silhouette, costume, and color logic consistent with the referenced CLE3 character assets.\nRespect the storyboard beat exactly and keep the panel readable inside a serialized web-comic layout.", "style": "dr-slump-toriyama", - "generation_status": "pending", + "generation_status": "generated", "text_strategy": "postprocess_overlay", "ai_score": null, "human_score": null @@ -696,7 +723,7 @@ "image_path": "episodes/EP002/panels/assets/p10-3.png", "generation_prompt": "Bold clean ink linework, flat vibrant cell-shaded colors,\nrounded expressive character designs, dramatic but playful composition,\ndistinctive 1980s Akira Toriyama manga aesthetic.\nSimple but expressive backgrounds with bold black shadows.\nClean panel borders, dynamic perspective.\n\n--- SCENE ---\nEpisode: EP002\nPage: 10\nPanel ID: p10-3\nStoryboard description: 왕먀오가 화난 얼굴로 다가선다. 분노는 사실 도움 요청이다.\nVisible cast: 왕먀오\n\n--- CHARACTER REFERENCES ---\n- 왕먀오 (汪淼): Reference asset: episodes/EP001/characters/assets/wangmiao-v1.png Appearance: 30대 중국 남성, 단정한 짧은 검은 머리, 큰 둥근 뿔테 안경, 마른 체형. 캐주얼 셔츠와 슬랙스, 연구실 장면에서는 흰 가운. 안경 렌즈의 반사광이 시그니처. Style notes: Toriyama style: clean geometric silhouette, large round glasses, neat simplified hair, restrained facial acting that breaks into exaggerated panic during countdown beats. Cool blue-grey cell-shaded palette, lens highlights emphasized. Inner tone: 정밀한 측정과 합리적 설명을 믿는 나노소재 연구자. 가족을 지키려는 책임감이 강하지만 설명 불가능한 현상 앞에서는 공포를 부정하려 한다.\n\n--- COMPOSITION ---\nCamera: close_up\nPanel type: single panel\nVisual emphasis: normal\nBalance character readability, environment clarity, and story momentum.\n\n--- OUTPUT RULES ---\nDeliver one finished comic panel only, not a reference sheet.\nKeep the face, silhouette, costume, and color logic consistent with the referenced CLE3 character assets.\nRespect the storyboard beat exactly and keep the panel readable inside a serialized web-comic layout.", "style": "dr-slump-toriyama", - "generation_status": "pending", + "generation_status": "generated", "text_strategy": "postprocess_overlay", "ai_score": null, "human_score": null diff --git a/episodes/EP002/panels/text-overlays.json b/episodes/EP002/panels/text-overlays.json index add73ef..06bd913 100644 --- a/episodes/EP002/panels/text-overlays.json +++ b/episodes/EP002/panels/text-overlays.json @@ -34,8 +34,8 @@ "page_number": 2, "source_image_path": "episodes/EP002/panels/assets/p2-1.png", "final_image_path": "episodes/EP002/panels/final/p2-1.svg", - "status": "draft", - "notes": "Auto-extracted from script.md or panel description; review position before approval.", + "status": "needs_review", + "notes": "Failsafe: extracted script dialogue describes a doctor scene and conflicts with this family-dinner panel. Do not render or approve until the EP002 script/storyboard mapping is rebaselined.", "overlays": [ { "kind": "dialogue", @@ -375,6 +375,55 @@ } ] }, + { + "panel_id": "p8-2", + "page_number": 8, + "source_image_path": "episodes/EP002/panels/assets/p8-2.png", + "final_image_path": "episodes/EP002/panels/final/p8-2.svg", + "status": "needs_review", + "notes": "Failsafe: extracted script dialogue describes a neurology examination and conflicts with this family-dinner panel. Do not render or approve until the EP002 script/storyboard mapping is rebaselined.", + "overlays": [ + { + "kind": "dialogue", + "text": "의사: \"신경계도 이상이 없어요. 스트레스성 환각일 수 있습니다.", + "box": { + "x": 0.08, + "y": 0.06, + "w": 0.52, + "h": 0.12 + } + } + ] + }, + { + "panel_id": "p8-3", + "page_number": 8, + "source_image_path": "episodes/EP002/panels/assets/p8-3.png", + "final_image_path": "episodes/EP002/panels/final/p8-3.svg", + "status": "needs_review", + "notes": "Failsafe: extracted script text describes Wang Miao's hospital close-up and a visible countdown, which conflicts with this Zizi family-dinner panel and the no-text image policy. Do not render or approve until the EP002 script/storyboard mapping is rebaselined.", + "overlays": [ + { + "kind": "narration", + "text": "왕먀오(독백): \"...스트레스.\"", + "box": { + "x": 0.1, + "y": 0.08, + "w": 0.8, + "h": 0.12 + } + } + ] + }, + { + "panel_id": "p8-4", + "page_number": 8, + "source_image_path": "episodes/EP002/panels/assets/p8-4.png", + "final_image_path": "episodes/EP002/panels/final/p8-4.svg", + "status": "needs_review", + "notes": "Failsafe: this family-dinner panel shares the page-8 script/storyboard mapping conflict already observed in p8-2 and p8-3. Do not derive, render, or approve an overlay until the EP002 mapping is rebaselined.", + "overlays": [] + }, { "panel_id": "p9-1", "page_number": 9, @@ -395,6 +444,44 @@ } ] }, + { + "panel_id": "p10-1", + "page_number": 10, + "source_image_path": "episodes/EP002/panels/assets/p10-1.png", + "final_image_path": "episodes/EP002/panels/final/p10-1.svg", + "status": "draft", + "notes": "Auto-extracted from script.md or panel description; review position before approval.", + "overlays": [ + { + "kind": "narration", + "text": "왕먀오(독백): \"빨라지고 있다.", + "box": { + "x": 0.1, + "y": 0.08, + "w": 0.8, + "h": 0.12 + } + } + ] + }, + { + "panel_id": "p10-2", + "page_number": 10, + "source_image_path": "episodes/EP002/panels/assets/p10-2.png", + "final_image_path": "episodes/EP002/panels/final/p10-2.svg", + "status": "rendered", + "notes": "No text overlay required.", + "overlays": [] + }, + { + "panel_id": "p10-3", + "page_number": 10, + "source_image_path": "episodes/EP002/panels/assets/p10-3.png", + "final_image_path": "episodes/EP002/panels/final/p10-3.svg", + "status": "rendered", + "notes": "No text overlay required.", + "overlays": [] + }, { "panel_id": "p10-4", "page_number": 10, diff --git a/operations/decision-queue.json b/operations/decision-queue.json new file mode 100644 index 0000000..0b07c0a --- /dev/null +++ b/operations/decision-queue.json @@ -0,0 +1,148 @@ +{ + "version": 1, + "items": [ + { + "owner": "cle3-maintainer", + "status": "resolved", + "waiting_since": "2026-09-03T21:06:29Z", + "waiting_since_basis": "pull_request_created", + "due_at": "2026-09-09T01:11:08Z", + "overdue_action": "담당자에게 기한 초과를 표시하고 해당 결정에 의존하는 작업을 보류한다.", + "id": "pr-39-merge", + "episode_id": "EP002", + "kind": "merge", + "request": "PR #39의 EP002 Phase 4 작업을 main에 병합할지 결정한다. 공개 발행은 별도 승인이다.", + "source_path": ".github/pull_request_template.md", + "pr_number": 39, + "resolution": { + "decided_by": "현재 CLE3 운영자(사용자)", + "decided_at": "2026-09-08T01:45:40Z", + "decision": "사용자가 PR #39 병합 및 현재 Pages 범위 배포를 요청했다. EP002 공개 승인은 별도다.", + "evidence": ["reports/CLE3-PR39-MERGE-AUTHORIZATION-20260908.md"] + }, + "evidence": [ + "episodes/EP002/panels/MANIFEST.md", + "episodes/EP002/decisions/implementation-notes.json" + ] + }, + { + "owner": "cle3-maintainer", + "status": "pending", + "waiting_since": "2026-09-08T01:11:08Z", + "waiting_since_basis": "tracking_started", + "due_at": "2026-09-09T01:11:08Z", + "overdue_action": "p8-2~p8-4 오버레이와 매핑 충돌에 의존하는 생성을 보류하고 독립 검증을 진행한다.", + "id": "ep002-page-mapping", + "episode_id": "EP002", + "kind": "rebaseline", + "request": "EP002 script 15페이지와 storyboard 16페이지 중 재정리의 기준을 결정한다.", + "source_path": "episodes/EP002/discovery/context.json", + "evidence": [ + "episodes/EP002/script/script.md", + "episodes/EP002/storyboard/storyboard.json", + "episodes/EP002/panels/text-overlays.json" + ] + }, + { + "owner": "cle3-maintainer", + "status": "pending", + "waiting_since": "2026-09-08T01:11:08Z", + "waiting_since_basis": "tracking_started", + "due_at": "2026-09-09T01:11:08Z", + "overdue_action": "담당자에게 기한 초과를 표시하고 해당 결정에 의존하는 작업을 보류한다.", + "id": "ep002-story-lock", + "episode_id": "EP002", + "kind": "gate", + "gate_id": "story-lock", + "request": "EP002 Story Lock의 provisional 기준선을 공식 승인할지 결정한다.", + "source_path": "episodes/EP002/approvals/gates.json", + "evidence": [ + "episodes/EP002/discovery/context.json" + ] + }, + { + "owner": "cle3-maintainer", + "status": "pending", + "waiting_since": "2026-09-08T01:11:08Z", + "waiting_since_basis": "tracking_started", + "due_at": "2026-09-09T01:11:08Z", + "overdue_action": "담당자에게 기한 초과를 표시하고 해당 결정에 의존하는 작업을 보류한다.", + "id": "ep002-character-lock", + "episode_id": "EP002", + "kind": "gate", + "gate_id": "character-lock", + "request": "EP002 Character Lock의 provisional 기준선을 공식 승인할지 결정한다.", + "source_path": "episodes/EP002/approvals/gates.json", + "evidence": [ + "episodes/EP002/discovery/context.json" + ] + }, + { + "owner": "cle3-maintainer", + "status": "pending", + "waiting_since": "2026-09-08T01:11:08Z", + "waiting_since_basis": "tracking_started", + "due_at": "2026-09-09T01:11:08Z", + "overdue_action": "담당자에게 기한 초과를 표시하고 해당 결정에 의존하는 작업을 보류한다.", + "id": "ep002-storyboard-lock", + "episode_id": "EP002", + "kind": "gate", + "gate_id": "storyboard-lock", + "request": "페이지 매핑 재정리 후 EP002 Storyboard Lock을 공식 승인할지 결정한다.", + "source_path": "episodes/EP002/approvals/gates.json", + "evidence": [ + "episodes/EP002/discovery/context.json" + ] + }, + { + "owner": "cle3-maintainer", + "status": "pending", + "waiting_since": "2026-09-08T01:11:08Z", + "waiting_since_basis": "tracking_started", + "due_at": "2026-09-09T01:11:08Z", + "overdue_action": "담당자에게 기한 초과를 표시하고 해당 결정에 의존하는 작업을 보류한다.", + "id": "ep002-release", + "episode_id": "EP002", + "kind": "gate", + "gate_id": "release", + "request": "Phase 5 QA와 최종 산출물 검증 통과 후 EP002 공개 발행 여부를 결정한다.", + "source_path": "episodes/EP002/approvals/gates.json", + "evidence": [ + "episodes/EP002/discovery/context.json" + ], + "prerequisite": "phase5_completed" + }, + { + "owner": "cle3-maintainer", + "status": "pending", + "waiting_since": "2026-09-08T01:11:08Z", + "waiting_since_basis": "tracking_started", + "due_at": "2026-09-09T01:11:08Z", + "overdue_action": "기존 패널을 유지하고 후보 선택·기억 승격·release 재판단을 보류한다.", + "id": "ep001-p15-3-tie", + "episode_id": "EP001", + "kind": "candidate", + "request": "EP001 p15-3 동률 후보 중 하나를 선택하거나 기존 패널을 유지할지 결정한다.", + "source_path": "episodes/EP001/panels/convergence/pilot-001/p15-3.json", + "evidence": [ + "episodes/EP001/panels/convergence/pilot-001/p15-3.json" + ] + }, + { + "owner": "cle3-maintainer", + "status": "pending", + "waiting_since": "2026-09-08T01:11:08Z", + "waiting_since_basis": "tracking_started", + "due_at": "2026-09-09T01:11:08Z", + "overdue_action": "기존 패널을 유지하고 후보 선택·기억 승격·release 재판단을 보류한다.", + "id": "ep001-p6-1-tie", + "episode_id": "EP001", + "kind": "candidate", + "request": "EP001 p6-1 동률 후보 중 하나를 선택하거나 기존 패널을 유지할지 결정한다.", + "source_path": "episodes/EP001/panels/convergence/pilot-001/p6-1.json", + "evidence": [ + "episodes/EP001/panels/convergence/pilot-001/p6-1.json" + ] + } + ] +} diff --git a/reports/CLE3-DECISION-OPERATIONS-20260908.md b/reports/CLE3-DECISION-OPERATIONS-20260908.md new file mode 100644 index 0000000..3a30b96 --- /dev/null +++ b/reports/CLE3-DECISION-OPERATIONS-20260908.md @@ -0,0 +1,52 @@ +# CLE3 완료 증거와 결정 대기 개선 + +관측 시각: 2026-09-08T01:23:07Z (10:23 KST) + +## 구현 결과 + +- `operations/decision-queue.json`: EP002 병합·매핑·게이트와 EP001 동률 후보 등 8건에 담당자, 단일 결정, 대기 시작 근거, 기한과 초과 처리를 기록했다. 기존 승인·후보 원본은 변경하지 않았다. +- `config/decision-policy.json`: 현재 사용자 역할을 운영자로 명시하고 미승인 PR 2개 상한을 설정했다. 초기 검토 기한은 9월 9일 10:11 KST이며, Phase 5 이전의 공개 승인 요청은 선행 작업 대기로 구분한다. +- `scripts/decision-status.js`: GitHub 최신 head의 검사·리뷰·충돌 상태와 로컬 QA·승인·파일을 분리해서 보고한다. API 실패·필수 검사 누락·닫힌 PR을 통과나 병합 완료로 추정하지 않는다. +- `scripts/run-panel-jobs.js`: `--respect-review-limit` 및 plan 저장 시 실제 WIP를 검사하고 상한 도달·상태 미확인 시 빈 생성 큐와 종료 코드 2를 반환한다. +- `scripts/release-readiness.js`: Phase 4/5, 승인자·시각·증거, QA 합계, 정확한 최종 파일 목록, 미검토 오버레이를 검사한다. Pages의 포장·업로드 전에 실행하도록 연결했다. +- PR 검증에 큐 검증과 공개 사전조건 검사를 추가하고 Actions 요약에 대기 보고서를 표시한다. PR 템플릿도 검증·병합 결정·공개 상태를 구분한다. +- 제작 진행 때마다 기대값이 바뀌던 runner 테스트를 고정된 큐 상황으로 바꿨다. + +## 실행 검증 + +| 명령 | 실제 결과 | +|---|---| +| `node --test tests/*.test.js` | 33/33 통과 | +| `node scripts/decision-status.js --validate` | 스키마·담당자·기한·근거 검증 통과 | +| `node scripts/validate-episode-governance.js` | 5개 에피소드 통과 | +| `node scripts/validate-episode-output.js EP001 --require-release` | 49패널, QA 43/50, 기존 공개 증거 통과 | +| `node scripts/check-panel-assets.js` | 통과; 기존 EP002 6개 PNG의 1MB 목표 초과 경고 유지 | +| `git diff --check` | 통과 | +| `node scripts/decision-status.js --github --check-start --json` | 실제 GitHub 조회 성공, 미승인 PR 1/2, 종료 코드 0 | + +새 회귀 테스트는 CI 성공과 승인 분리, 필수 검사 누락·skipped·실패, API 오류, +불완전 목록, WIP 2개 보류, 닫힌 PR의 결과 미추정, 근거 없는 결정 완료 차단, +draft/needs_review, QA 목록 불일치, 파일 누락, runner의 plan 미저장을 검사했다. + +## 실제 원격 관측과 CLE5 전달 범위 + +PR #39의 관측 head는 `e1ee73385bb53db87b96c4965b3ad5c240d9aca4`이며 +`validate-assets`는 SUCCESS, mergeable은 MERGEABLE, reviewDecision은 비어 있었다. +[검사 증거](https://github.com/Daegu-Agent-Crew/creative-loop-engineering3/actions/runs/34161900624/job/101865267287). +이 결과는 개선 코드의 원격 CI 결과가 아니라 개선 전 패널 커밋에 대한 관측이다. + +EP002는 QA 파일 없음·Phase 4/5 미완료·사람 공개 승인 대기·미생성 패널·미검토 +오버레이 때문에 공개 차단이다. EP001은 기존 승인과 파일 조건이 유효했다. +신규 공개, 후보 선택, 기억 승격, main 병합은 이번 개선에서 실행하지 않았다. + +CLE5는 이 관측 시각, 명령, 결과, 커밋과 증거 경로를 제작 관측으로 참조할 수 있다. +이것을 CLE5의 승인된 창작 선호로 해석하거나 운영 책임을 CLE3로 넘기지 않는다. + +## 남은 사람 결정 + +현재 운영자가 PR #39 작업 병합 여부를 결정한다. EP002 매핑 기준과 provisional +게이트는 별도 결정이며, 공개 승인은 Phase 5 완료 이후다. EP001 p15-3·p6-1 후보 +동률은 기존 패널을 유지한 채 결정 대기다. 상세 절차는 `docs/DECISION-OPERATIONS.md`. + +공개 검사는 저장된 QA·승인과 구조적 파일 대응을 검사한다. 이미지의 새 시각 평가나 +과거 QA 이후 바이트 변경에 대한 서명·해시 검증은 이 구현 범위에 포함하지 않는다. diff --git a/reports/CLE3-PR39-MERGE-AUTHORIZATION-20260908.md b/reports/CLE3-PR39-MERGE-AUTHORIZATION-20260908.md new file mode 100644 index 0000000..b9cf0ed --- /dev/null +++ b/reports/CLE3-PR39-MERGE-AUTHORIZATION-20260908.md @@ -0,0 +1,10 @@ +# PR #39 병합·배포 요청 근거 + +- 기록 시각: 2026-09-08T01:45:40Z +- 결정자: 현재 CLE3 운영자(이 Codex 대화의 사용자) +- 사용자 요청 원문: “병합 배포까지 진행” +- 결정 범위: PR #39의 제작·운영 개선을 main에 병합하고 현재 GitHub Pages workflow를 실행한다. +- 배포 범위: 기존 Release Approval이 유효한 EP001 공개 자산과 docs. EP002 공개 대상 추가나 미완료 QA의 승인으로 해석하지 않는다. +- 원격 선행 검사: head 1fedca37124104a26b17a63b1d008bcda09720c4의 validate-assets SUCCESS, MERGEABLE 확인. +- 검사 증거: https://github.com/Daegu-Agent-Crew/creative-loop-engineering3/actions/runs/34176630933/job/101907216776 +- 실제 병합·배포 성공은 이후 GitHub mergeCommit 및 Actions 결과로 별도 확인한다. 이 기록은 사용자의 실행 승인이다. diff --git a/schemas/decision-queue.schema.json b/schemas/decision-queue.schema.json new file mode 100644 index 0000000..8b652f0 --- /dev/null +++ b/schemas/decision-queue.schema.json @@ -0,0 +1,46 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CLE3 Decision Waiting Queue", + "type": "object", + "additionalProperties": false, + "required": ["version", "items"], + "properties": { + "version": { "const": 1 }, + "items": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "episode_id", "kind", "owner", "request", "status", "waiting_since", "waiting_since_basis", "due_at", "overdue_action", "source_path", "evidence"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "episode_id": { "type": "string", "pattern": "^EP\\d{3}$" }, + "kind": { "enum": ["merge", "gate", "rebaseline", "candidate"] }, + "owner": { "type": "string", "minLength": 1 }, + "request": { "type": "string", "minLength": 1 }, + "status": { "enum": ["pending", "resolved"] }, + "waiting_since": { "type": "string", "format": "date-time" }, + "waiting_since_basis": { "enum": ["tracking_started", "pull_request_created", "human_request"] }, + "due_at": { "type": "string", "format": "date-time" }, + "overdue_action": { "type": "string", "minLength": 1 }, + "source_path": { "type": "string", "minLength": 1 }, + "evidence": { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 } }, + "pr_number": { "type": "integer", "minimum": 1 }, + "gate_id": { "enum": ["story-lock", "character-lock", "storyboard-lock", "release"] }, + "prerequisite": { "const": "phase5_completed" }, + "resolution": { + "type": "object", + "additionalProperties": false, + "required": ["decided_by", "decided_at", "decision", "evidence"], + "properties": { + "decided_by": { "type": "string", "minLength": 1 }, + "decided_at": { "type": "string", "format": "date-time" }, + "decision": { "type": "string", "minLength": 1 }, + "evidence": { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 } } + } + } + } + } + } + } +} diff --git a/scripts/decision-status.js b/scripts/decision-status.js new file mode 100644 index 0000000..2ae2342 --- /dev/null +++ b/scripts/decision-status.js @@ -0,0 +1,180 @@ +#!/usr/bin/env node +const fs = require('node:fs'); +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); +const { repoFile, releaseReadiness } = require('./release-readiness'); + +const read = (root, file) => JSON.parse(fs.readFileSync(path.join(root, file), 'utf8')); +const timestamp = (value) => typeof value === 'string' && + /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?(?:Z|[+-]\d\d:\d\d)$/.test(value) && Number.isFinite(Date.parse(value)); + +// The queue schema deliberately uses only this small supported JSON Schema subset. +function validateSchema(value, schema, label = 'queue', failures = []) { + const fail = (message) => failures.push(`${label}: ${message}`); + if (schema.type) { + const valid = schema.type === 'array' ? Array.isArray(value) : schema.type === 'object' + ? value !== null && typeof value === 'object' && !Array.isArray(value) + : schema.type === 'integer' ? Number.isInteger(value) : typeof value === schema.type; + if (!valid) { fail(`expected ${schema.type}`); return failures; } + } + if ('const' in schema && value !== schema.const) fail('invalid constant'); + if (schema.enum && !schema.enum.includes(value)) fail('invalid enum'); + if (schema.minLength && value.trim().length < schema.minLength) fail('empty string'); + if (schema.pattern && !new RegExp(schema.pattern).test(value)) fail('invalid pattern'); + if (schema.format === 'date-time' && !timestamp(value)) fail('invalid timestamp'); + if (schema.minimum !== undefined && value < schema.minimum) fail('below minimum'); + if (schema.type === 'array') { + if (value.length < (schema.minItems || 0)) fail('too few items'); + value.forEach((item, i) => validateSchema(item, schema.items, `${label}[${i}]`, failures)); + } + if (schema.type === 'object') { + for (const key of schema.required || []) if (!(key in value)) fail(`missing ${key}`); + for (const [key, item] of Object.entries(value)) { + if (schema.properties?.[key]) validateSchema(item, schema.properties[key], `${label}.${key}`, failures); + else if (schema.additionalProperties === false) fail(`unexpected ${key}`); + } + } + return failures; +} + +function validateQueue(root, queue, policy) { + const failures = validateSchema(queue, read(root, 'schemas/decision-queue.schema.json')); + if (failures.length) return failures; + if (!/^[\w.-]+\/[\w.-]+$/.test(policy.repository || '')) failures.push('policy.repository required'); + if (!Number.isInteger(policy.max_unapproved_prs) || policy.max_unapproved_prs < 1) failures.push('invalid WIP limit'); + if (!Number.isFinite(policy.review_sla_hours) || policy.review_sla_hours <= 0) failures.push('invalid review SLA'); + if (!Array.isArray(policy.required_checks) || !policy.required_checks.length || + policy.required_checks.some((name) => typeof name !== 'string' || !name.trim())) failures.push('required checks missing'); + const ids = new Set(); + const targets = new Set(); + const state = read(root, 'state.json'); + for (const item of queue.items) { + const fail = (message) => failures.push(`${item.id}: ${message}`); + if (ids.has(item.id)) fail('duplicate id'); + ids.add(item.id); + const target = `${item.kind}:${item.source_path}:${item.gate_id || item.pr_number || ''}`; + if (targets.has(target)) fail('duplicate decision target'); + targets.add(target); + if (!policy.owners?.[item.owner]?.trim()) fail('unknown owner'); + if (!state.episodes[item.episode_id]) fail('unknown episode'); + if (Date.parse(item.due_at) < Date.parse(item.waiting_since)) fail('deadline precedes waiting time'); + for (const file of [item.source_path, ...item.evidence, ...(item.resolution?.evidence || [])]) { + if (!repoFile(root, file)) fail(`missing or unsafe evidence: ${file}`); + } + if (item.status === 'resolved' && !item.resolution) fail('resolution requires human decision and evidence'); + if (item.status === 'pending' && item.resolution) fail('pending item cannot have resolution'); + if (item.resolution && Date.parse(item.resolution.decided_at) < Date.parse(item.waiting_since)) fail('decision precedes request'); + if (item.kind === 'merge' && !item.pr_number) fail('PR number required'); + if (item.kind === 'gate') { + if (item.source_path !== `episodes/${item.episode_id}/approvals/gates.json`) fail('gate must reference canonical approvals'); + if (!repoFile(root, item.source_path)) continue; + const gate = read(root, item.source_path).gates?.find((gate) => gate.id === item.gate_id); + if (!gate) fail('unknown gate'); + // A queue entry is never an alternative approval ledger. + if (item.status === 'resolved' && !['approved', 'changes_requested', 'not_applicable'].includes(gate?.status)) { + fail('canonical gate has no human decision'); + } + } + } + return failures; +} + +function getGithub(policy, exec = execFileSync) { + const fields = 'number,state,url,createdAt,headRefOid,mergeable,reviewDecision,statusCheckRollup'; + try { + const prs = JSON.parse(exec('gh', ['pr', 'list', '--repo', policy.repository, '--state', 'open', '--limit', '1000', '--json', fields], { + encoding: 'utf8', timeout: 30000, stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 16 * 1024 * 1024 + })); + if (!Array.isArray(prs) || prs.length >= 1000) throw new Error('incomplete PR inventory'); + return { status: 'available', observed_at: new Date().toISOString(), prs }; + } catch { + // Never turn an API failure or partial inventory into a green status / zero WIP. + return { status: 'unavailable', observed_at: new Date().toISOString(), prs: null }; + } +} + +function validationStatus(pr, requiredChecks) { + if (!pr?.headRefOid || !Array.isArray(pr.statusCheckRollup)) return 'unknown'; + const checks = pr.statusCheckRollup; + const state = (check) => check.__typename === 'StatusContext' || check.context + ? check.state : check.status === 'COMPLETED' ? check.conclusion : 'PENDING'; + if (checks.some((check) => ['FAILURE', 'ERROR', 'CANCELLED', 'TIMED_OUT', 'ACTION_REQUIRED', 'STARTUP_FAILURE', 'STALE'].includes(state(check)))) return 'failed'; + if (!requiredChecks.every((name) => checks.some((check) => (check.name || check.context) === name))) return 'unknown'; + if (!checks.length || checks.some((check) => state(check) !== 'SUCCESS')) return 'pending'; + return 'passed'; +} + +function reviewCapacity(github, policy) { + if (github.status !== 'available' || !Array.isArray(github.prs)) return { status: 'unknown', count: null, limit: policy.max_unapproved_prs, action: 'verify_github_before_generation' }; + const count = github.prs.filter((pr) => pr.state === 'OPEN' && pr.reviewDecision !== 'APPROVED').length; + return { status: count >= policy.max_unapproved_prs ? 'hold' : 'available', count, limit: policy.max_unapproved_prs, + action: count >= policy.max_unapproved_prs ? 'review_existing_work' : 'generation_allowed' }; +} + +function buildReport(root, queue, policy, github = { status: 'not_checked', prs: null }, now = new Date()) { + const state = read(root, 'state.json'); + const releases = Object.fromEntries([...new Set(queue.items.map((item) => item.episode_id))] + .map((id) => [id, releaseReadiness(root, id)])); + const items = queue.items.map((item) => { + const source = item.kind === 'gate' ? read(root, item.source_path).gates.find((gate) => gate.id === item.gate_id) : null; + const prerequisite = item.prerequisite === 'phase5_completed' && state.episodes[item.episode_id]?.phases?.phase5?.status !== 'completed'; + const pr = item.kind === 'merge' ? github.prs?.find((pr) => pr.number === item.pr_number) : null; + let status = item.status; + if (status === 'pending' && source && ['approved', 'changes_requested', 'not_applicable'].includes(source.status)) status = 'source_decided_sync_required'; + if (status === 'pending' && prerequisite) status = 'waiting_prerequisite'; + if (item.kind === 'merge' && item.status === 'pending' && github.status === 'available' && !pr) status = 'not_open_verify_outcome'; + const overdue = status === 'pending' && now.getTime() >= Date.parse(item.due_at); + const validation = item.kind === 'merge' ? validationStatus(pr, policy.required_checks) : null; + return { ...item, owner_label: policy.owners[item.owner], status, source_status: source?.status || null, overdue, + waiting_hours: Math.max(0, Math.floor((now.getTime() - Date.parse(item.waiting_since)) / 3600000)), + ...(item.kind === 'merge' ? { validation, head_sha: pr?.headRefOid || null, + check_evidence: pr?.statusCheckRollup || [], + merge: !pr ? 'unknown' : pr.mergeable === 'CONFLICTING' || pr.reviewDecision === 'CHANGES_REQUESTED' || validation === 'failed' + ? 'blocked' : validation === 'passed' && pr.mergeable === 'MERGEABLE' ? 'human_decision_required' : 'waiting_checks', + human_review: pr?.reviewDecision || 'not_recorded' } : {}) }; + }); + return { observed_at: now.toISOString(), github_status: github.status, github_observed_at: github.observed_at || null, + wip: reviewCapacity(github, policy), releases, items }; +} + +function markdown(report) { + const safe = (s) => String(s ?? '-').replace(/\|/g, '\\|').replace(/\r?\n/g, ' '); + const lines = ['# CLE3 결정 대기', '', `관측 시각: ${report.observed_at}; GitHub: ${report.github_status}`, '', + `신규 생성: ${report.wip.status}; 미승인 PR ${report.wip.count ?? '미확인'}/${report.wip.limit}.`, '', + 'CI 통과는 병합 또는 공개 승인이 아니다. 아래 완료 여부는 원본 게이트·파일과 별도로 읽는다.', '', + '| 항목 | 담당자 | 상태 / 기한 초과 | 요청하는 결정 | 대기 시작 | 결정 기한 | 기한 초과 시 처리 |', + '|---|---|---|---|---|---|---|']; + for (const item of report.items) lines.push(`| ${safe(item.id)} | ${safe(item.owner_label)} | ${safe(item.status)}${item.overdue ? ' / 초과' : ''} | ${safe(item.request)} | ${safe(item.waiting_since)} (${safe(item.waiting_since_basis)}) | ${safe(item.due_at)} | ${safe(item.overdue_action)} |`); + for (const item of report.items.filter((item) => item.kind === 'merge')) { + lines.push('', `PR #${item.pr_number}: 검증 **${item.validation}**, 병합 **${item.merge}**, 사람 리뷰 **${item.human_review}**; SHA ${item.head_sha || '미확인'}.`); + } + for (const [id, release] of Object.entries(report.releases)) { + lines.push('', `${id} 공개 사전조건: **${release.status}** (원본 승인 ${release.approval}).`, + ...(release.blockers.length ? release.blockers.map((reason) => `- ${reason}`) : ['- 기존 승인·QA·산출물 조건 충족. 실제 배포 수행을 의미하지 않음.'])); + } + lines.push('', '증거:', ...report.items.map((item) => `- ${item.id}: ${[item.source_path, ...item.evidence].map((p) => '`' + p + '`').join(', ')}`)); + return lines.join('\n'); +} + +function main(argv = process.argv.slice(2)) { + if (argv.includes('--help')) { + console.log('Usage: node scripts/decision-status.js [--github] [--json] [--validate] [--check-start]\nRead-only. --check-start requires live GitHub and exits 2 on a WIP hold or unavailable inventory.'); + return; + } + for (const arg of argv) if (!['--github', '--json', '--validate', '--check-start'].includes(arg)) throw new Error(`unknown option: ${arg}`); + const root = process.cwd(); + const queue = read(root, 'operations/decision-queue.json'); + const policy = read(root, 'config/decision-policy.json'); + const failures = validateQueue(root, queue, policy); + if (failures.length) throw new Error(failures.join('\n')); + if (argv.includes('--validate')) { console.log('decision queue schema and evidence validation passed'); return; } + const github = argv.includes('--github') || argv.includes('--check-start') ? getGithub(policy) : { status: 'not_checked', prs: null }; + const report = buildReport(root, queue, policy, github); + console.log(argv.includes('--json') ? JSON.stringify(report, null, 2) : markdown(report)); + if (argv.includes('--check-start') && report.wip.status !== 'available') process.exitCode = 2; +} + +if (require.main === module) { + try { main(); } catch (error) { console.error(error.message); process.exitCode = 1; } +} +module.exports = { validateQueue, getGithub, validationStatus, reviewCapacity, buildReport, markdown }; diff --git a/scripts/release-readiness.js b/scripts/release-readiness.js new file mode 100644 index 0000000..b464fdf --- /dev/null +++ b/scripts/release-readiness.js @@ -0,0 +1,80 @@ +const fs = require('node:fs'); +const path = require('node:path'); + +function repoFile(rootDir, relativePath) { + if (typeof relativePath !== 'string' || !relativePath || path.isAbsolute(relativePath)) return false; + const root = fs.realpathSync(rootDir); + const resolved = path.resolve(root, relativePath); + try { + return resolved.startsWith(root + path.sep) && + fs.realpathSync(resolved).startsWith(root + path.sep) && fs.statSync(resolved).isFile(); + } catch { return false; } +} + +// Read-only preconditions; passing this is not a new approval or a deployment. +function releaseReadiness(rootDir, episodeId) { + if (!/^EP\d{3}$/.test(episodeId)) throw new Error('episode must match EP###'); + const blockers = []; + const read = (relativePath) => { + try { return JSON.parse(fs.readFileSync(path.join(rootDir, relativePath), 'utf8')); } + catch { blockers.push(`missing or invalid JSON: ${relativePath}`); return {}; } + }; + const prefix = `episodes/${episodeId}`; + const state = read('state.json').episodes?.[episodeId]; + const approvals = read(`${prefix}/approvals/gates.json`); + const panels = read(`${prefix}/panels/panels.json`); + const overlays = read(`${prefix}/panels/text-overlays.json`); + const qa = read(`${prefix}/qa/qa.json`); + for (const [label, record] of Object.entries({ approvals, panels, overlays, qa })) { + if (record.episode_id !== episodeId) blockers.push(`${label}: episode mismatch`); + } + for (const phase of ['phase4', 'phase5']) { + if (state?.phases?.[phase]?.status !== 'completed') blockers.push(`${phase}: incomplete`); + } + const gates = Array.isArray(approvals.gates) ? approvals.gates : []; + const release = gates.filter((gate) => gate.id === 'release'); + const gate = release[0]; + if (release.length !== 1 || gate?.status !== 'approved') blockers.push('human release approval pending'); + if (!gate?.approved_by?.trim() || !Number.isFinite(Date.parse(gate?.approved_at))) { + blockers.push('release approver and timestamp required'); + } + if (!Array.isArray(gate?.evidence) || !gate.evidence.length || + !gate.evidence.every((p) => repoFile(rootDir, p))) blockers.push('release evidence missing'); + const items = Array.isArray(qa.items) ? qa.items : []; + if (items.length !== 5 || items.some((item) => !Number.isFinite(item.score) || item.score < 0 || item.score > 10) || + items.reduce((sum, item) => sum + item.score, 0) !== qa.overall_score || qa.overall_score < 42) { + blockers.push('QA must have five valid scores totaling at least 42/50'); + } + const sources = Array.isArray(panels.panels) ? panels.panels : []; + const finals = Array.isArray(overlays.panels) ? overlays.panels : []; + const ids = new Set(sources.map((p) => p.panel_id)); + if (!sources.length || ids.size !== sources.length || finals.length !== sources.length || + new Set(finals.map((p) => p.panel_id)).size !== finals.length || finals.some((p) => !ids.has(p.panel_id))) { + blockers.push('panel/overlay coverage mismatch'); + } + for (const p of sources) { + if (!['generated', 'approved', 'selected'].includes(p.generation_status) || !repoFile(rootDir, p.image_path)) { + blockers.push(`${p.panel_id}: source not ready`); + } + } + for (const p of finals) { + // Legacy released episodes also use rendered for text-bearing panels; + // the episode's separate human gate and QA coverage remain mandatory. + if (!['approved', 'embedded_text', 'rendered'].includes(p.status)) { + blockers.push(`${p.panel_id}: text review required (${p.status})`); + } + if (!repoFile(rootDir, p.final_image_path) || !repoFile(rootDir, p.source_image_path)) { + blockers.push(`${p.panel_id}: final/source file missing`); + } + if (sources.find((source) => source.panel_id === p.panel_id)?.image_path !== p.source_image_path) { + blockers.push(`${p.panel_id}: overlay source mismatch`); + } + } + const reviewed = Array.isArray(qa.final_images) ? qa.final_images : []; + if (reviewed.length !== finals.length || new Set(reviewed).size !== reviewed.length || + finals.some((p) => !reviewed.includes(p.final_image_path)) || + reviewed.some((p) => !repoFile(rootDir, p))) blockers.push('QA final image coverage mismatch'); + return { status: blockers.length ? 'blocked' : 'ready', approval: gate?.status || 'missing', blockers }; +} + +module.exports = { repoFile, releaseReadiness }; diff --git a/scripts/run-panel-jobs.js b/scripts/run-panel-jobs.js index aeb9372..7586e4d 100755 --- a/scripts/run-panel-jobs.js +++ b/scripts/run-panel-jobs.js @@ -5,6 +5,7 @@ const path = require('path'); const { resolveReferenceChain } = require('./build-reference-chain'); const { buildCreationContext } = require('./build-creation-context'); const { compileCreationRequestCard } = require('./build-creation-request'); +const { getGithub, reviewCapacity } = require('./decision-status'); function readJson(filePath) { return JSON.parse(fs.readFileSync(filePath, 'utf8')); @@ -33,6 +34,7 @@ function parseArgs(argv) { else if (value === '--max-jobs') args.maxJobs = Number(argv[++index]); else if (value === '--dry-run') args.dryRun = true; else if (value === '--write-plan') args.writePlan = true; + else if (value === '--respect-review-limit') args.respectReviewLimit = true; else if (value === '--panel') args.panelId = argv[++index]; else if (value === '--variants') args.variants = Number(argv[++index]); else if (value === '--max-iterations') args.maxIterations = Number(argv[++index]); @@ -62,7 +64,7 @@ function parseArgs(argv) { } function printHelp() { - console.log(`Usage: node scripts/run-panel-jobs.js [--episode EP002] [--panel p2-3] [--max-jobs 3] [--variants 2] [--max-iterations 2] [--iteration 1] [--diagnosis TEXT] [--dry-run] [--write-plan] + console.log(`Usage: node scripts/run-panel-jobs.js [--episode EP002] [--panel p2-3] [--max-jobs 3] [--variants 2] [--max-iterations 2] [--iteration 1] [--diagnosis TEXT] [--dry-run] [--write-plan] [--respect-review-limit] Select the next runnable page jobs under CLE3 Phase 4 policy. @@ -71,7 +73,9 @@ honors low-performance worker settings and concurrency limits, and writes an operator plan. Image generation itself is still performed by Codex imagegen, one image per request, using the emitted commands. Variants are written under .candidates/ until an evaluator promotes one selected image. --max-jobs is a -legacy option name and caps emitted panel commands, never page groups.`); +legacy option name and caps emitted panel commands, never page groups. +--write-plan always checks live review capacity. --respect-review-limit applies +the same check to a dry run; unknown/held capacity emits no generation commands.`); } function shellQuote(value) { @@ -305,6 +309,15 @@ function main() { } const rootDir = process.cwd(); + if (args.writePlan || args.respectReviewLimit) { + const decisionPolicy = readJson(path.join(rootDir, 'config/decision-policy.json')); + const capacity = reviewCapacity(getGithub(decisionPolicy), decisionPolicy); + if (capacity.status !== 'available') { + console.log(JSON.stringify({ episode_id: args.episode, selected_jobs: [], review_capacity: capacity }, null, 2)); + process.exitCode = 2; + return; + } + } const episodeDir = path.join(rootDir, 'episodes', args.episode, 'panels'); const policy = readJson(path.join(rootDir, 'config', 'panel-generation-policy.json')); const jobsJson = readJson(path.join(episodeDir, 'generation-jobs.json')); diff --git a/scripts/validate-episode-governance.js b/scripts/validate-episode-governance.js index 07110b9..6e30882 100644 --- a/scripts/validate-episode-governance.js +++ b/scripts/validate-episode-governance.js @@ -9,6 +9,7 @@ const { validateEpisodeBibleDelta } = require('./build-creation-context'); const { validateCreationRequestCard } = require('./build-creation-request'); +const { repoFile } = require('./release-readiness'); const rootDir = process.cwd(); const failures = []; @@ -73,8 +74,13 @@ function validateApprovals(episodeId, approvals) { }); gates.forEach((gate) => { requireValue(['pending', 'provisional', 'approved', 'changes_requested', 'not_applicable'].includes(gate.status), `${prefix}: invalid status for ${gate.id}`); + if (gate.status === 'approved') { + requireValue(typeof gate.approved_by === 'string' && Boolean(gate.approved_by.trim()), `${prefix}: ${gate.id} requires an approver`); + requireValue(typeof gate.approved_at === 'string' && Number.isFinite(Date.parse(gate.approved_at)), `${prefix}: ${gate.id} requires an approval timestamp`); + requireValue(Array.isArray(gate.evidence) && gate.evidence.length > 0, `${prefix}: ${gate.id} requires approval evidence`); + } (gate.evidence || []).forEach((evidencePath) => { - requireValue(fs.existsSync(path.join(rootDir, evidencePath)), `${prefix}: missing evidence ${evidencePath}`); + requireValue(repoFile(rootDir, evidencePath), `${prefix}: missing or unsafe evidence ${evidencePath}`); }); }); } diff --git a/scripts/validate-episode-output.js b/scripts/validate-episode-output.js index 89d51fb..7023166 100644 --- a/scripts/validate-episode-output.js +++ b/scripts/validate-episode-output.js @@ -2,6 +2,7 @@ const fs = require('fs'); const path = require('path'); +const { releaseReadiness } = require('./release-readiness'); function readJson(rootDir, relativePath) { return JSON.parse(fs.readFileSync(path.join(rootDir, relativePath), 'utf8')); @@ -24,6 +25,10 @@ function main() { const rootDir = process.cwd(); const episodeId = process.argv[2] || 'EP001'; assert(/^EP\d{3}$/.test(episodeId), 'episode id must match EP###'); + if (process.argv.includes('--require-release')) { + const readiness = releaseReadiness(rootDir, episodeId); + assert(readiness.status === 'ready', `release blocked: ${readiness.blockers.join('; ')}`); + } const prefix = `episodes/${episodeId}`; const overlays = readJson(rootDir, `${prefix}/panels/text-overlays.json`); const panels = readJson(rootDir, `${prefix}/panels/panels.json`); diff --git a/state.json b/state.json index f8f7a17..6375efc 100644 --- a/state.json +++ b/state.json @@ -95,7 +95,7 @@ "phase4": { "status": "active", "prompt_version": "v2-toriyama-internal", - "note": "43/57 패널 생성. text-overlays.json과 최종 SVG 43/43 생성, draft 텍스트 오버레이 18개 시각 검토 대기. generation-jobs의 페이지 7은 4/4 생성 완료 상태다. 공통 Series Bible v1.0과 EP002 delta를 합성해 사용한다. p4-2~p4-4는 생성·1차 시각 검토를 마쳤고, p5-2~p5-4와 p6-1~p6-4는 생성·오버레이·최종 SVG 렌더링까지 완료했다. p7-2는 독백 오버레이 draft, p7-3과 p7-4는 생성 후보 선택 및 SVG 렌더링을 마쳤다." + "note": "49/57 패널 생성. text-overlays.json에는 49개 생성 패널이 기록되고, 최종 SVG는 45/49 생성 상태다. p8-2~p8-4의 가족 식탁 패널은 script/storyboard 매핑 충돌 때문에 오버레이를 needs_review로 보류했으며 SVG는 만들지 않았다. generation-jobs의 페이지 7, 8, 10은 각각 4/4 생성 완료 상태다. p10-1과 p10-4는 draft 오버레이·SVG 렌더링 대기이고, p10-2와 p10-3은 대사 없는 최종 SVG까지 렌더링했다. 공통 Series Bible v1.0과 EP002 delta를 합성해 사용한다. p4-2~p4-4는 생성·1차 시각 검토를 마쳤고, p5-2~p5-4와 p6-1~p6-4는 생성·오버레이·최종 SVG 렌더링까지 완료했다. p7-2는 독백 오버레이 draft, p7-3과 p7-4는 생성 후보 선택 및 SVG 렌더링을 마쳤다." }, "phase5": { "status": "pending" @@ -260,5 +260,5 @@ "previous_score": 45 } ], - "last_updated": "2026-08-18T02:23:13Z" + "last_updated": "2026-09-07T21:03:37Z" } diff --git a/tests/creation-context.test.js b/tests/creation-context.test.js index 704c9a6..213478e 100644 --- a/tests/creation-context.test.js +++ b/tests/creation-context.test.js @@ -119,17 +119,25 @@ test('EP002 inherits the approved series baseline without reusing EP001 panel sc assert.doesNotMatch(request.compiled_prompt, /EP001의 최종 정보/); }); -test('panel runner advances to the next page-grouped batch after page 7 completes', () => { +test('panel runner advances to the next page-grouped batch after page 10 completes', () => { const policy = require('../config/panel-generation-policy.json'); - const jobs = require('../episodes/EP002/panels/generation-jobs.json'); - const panels = require('../episodes/EP002/panels/panels.json'); + const jobs = structuredClone(require('../episodes/EP002/panels/generation-jobs.json')); + const panels = structuredClone(require('../episodes/EP002/panels/panels.json')); + // Fixed scheduling scenario: advancing real production must not rewrite this expectation. + jobs.jobs = jobs.jobs.filter((job) => [10, 11].includes(job.page_number)); + for (const job of jobs.jobs) job.status = job.page_number === 10 ? 'completed' : 'partial'; + for (const panel of panels.panels) { + const pending = ['p11-2', 'p11-4'].includes(panel.panel_id); + panel.generation_status = pending ? 'pending' : 'generated'; + panel.image_path = pending ? `__test_missing_panels__/${panel.panel_id}.png` : 'AGENTS.md'; + } const selected = selectJobs(rootDir, policy, jobs, panels, { policy: { maximum_panel_references: 0 }, anchors: [] }, { maxJobs: 3, variants: 1, maxIterations: 1, iteration: 1, diagnosis: null }); - assert.deepEqual(selected.map((job) => job.job_id), ['EP002-page-08']); - assert.deepEqual(selected[0].panel_ids, ['p8-2', 'p8-3', 'p8-4']); - assert.equal(selected.flatMap((job) => job.commands).length, 3); + assert.deepEqual(selected.map((job) => job.job_id), ['EP002-page-11']); + assert.deepEqual(selected[0].panel_ids, ['p11-2', 'p11-4']); + assert.equal(selected.flatMap((job) => job.commands).length, 2); }); test('creation request validation rejects unapproved Bible and unsafe assets', () => { diff --git a/tests/decision-status.test.js b/tests/decision-status.test.js new file mode 100644 index 0000000..bcf73d7 --- /dev/null +++ b/tests/decision-status.test.js @@ -0,0 +1,178 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const test = require('node:test'); +const { validateQueue, getGithub, validationStatus, reviewCapacity, buildReport } = require('../scripts/decision-status'); +const { releaseReadiness } = require('../scripts/release-readiness'); + +const root = path.resolve(__dirname, '..'); +const read = (file) => JSON.parse(fs.readFileSync(path.join(root, file), 'utf8')); +const policy = read('config/decision-policy.json'); +const queue = read('operations/decision-queue.json'); +const pr = () => ({ number: 39, state: 'OPEN', headRefOid: 'abc123', mergeable: 'MERGEABLE', reviewDecision: '', + statusCheckRollup: [{ name: 'validate-assets', status: 'COMPLETED', conclusion: 'SUCCESS' }] }); + +function queueFixture(t) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cle3-queue-test-')); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const write = (name, value) => { + const target = path.join(dir, name); fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, JSON.stringify(value)); + }; + const common = { episode_id: 'EP002', owner: 'cle3-maintainer', request: '검토 여부 결정', status: 'pending', + waiting_since: '2026-09-08T00:00:00Z', waiting_since_basis: 'tracking_started', due_at: '2026-09-09T00:00:00Z', + overdue_action: '해당 작업 보류', evidence: ['evidence.md'] }; + const data = { version: 1, items: [ + { ...common, id: 'pr-39-merge', kind: 'merge', pr_number: 39, source_path: 'evidence.md' }, + { ...common, id: 'ep002-release', kind: 'gate', gate_id: 'release', prerequisite: 'phase5_completed', source_path: 'episodes/EP002/approvals/gates.json' } + ] }; + write('state.json', { episodes: { EP002: { phases: { phase4: { status: 'active' }, phase5: { status: 'pending' } } } } }); + write('evidence.md', 'fixture evidence'); + write('episodes/EP002/approvals/gates.json', { episode_id: 'EP002', gates: [{ id: 'release', status: 'pending' }] }); + write('schemas/decision-queue.schema.json', read('schemas/decision-queue.schema.json')); + return { root: dir, queue: data }; +} + +test('CI success never implies a merge or release approval', (t) => { + const f = queueFixture(t); + const report = buildReport(f.root, f.queue, policy, { status: 'available', prs: [pr()] }, new Date('2026-09-10T00:00:00Z')); + const merge = report.items.find((item) => item.id === 'pr-39-merge'); + assert.equal(merge.validation, 'passed'); + assert.equal(merge.merge, 'human_decision_required'); + assert.equal(merge.overdue, true); + assert.equal(report.releases.EP002.status, 'blocked'); + const release = report.items.find((item) => item.id === 'ep002-release'); + assert.equal(release.status, 'waiting_prerequisite'); + assert.equal(release.overdue, false); + assert.equal(f.queue.items.find((item) => item.id === 'pr-39-merge').status, 'pending'); +}); + +test('missing, skipped, pending and failed checks are not successful checks', () => { + assert.equal(validationStatus(null, policy.required_checks), 'unknown'); + assert.equal(validationStatus({ ...pr(), statusCheckRollup: [] }, policy.required_checks), 'unknown'); + for (const conclusion of ['SKIPPED', 'NEUTRAL', null]) { + const value = pr(); value.statusCheckRollup[0].conclusion = conclusion; + assert.equal(validationStatus(value, policy.required_checks), 'pending'); + } + const value = pr(); value.statusCheckRollup[0].conclusion = 'FAILURE'; + assert.equal(validationStatus(value, policy.required_checks), 'failed'); + value.statusCheckRollup.push({ name: 'validate-assets', status: 'COMPLETED', conclusion: 'SUCCESS' }); + assert.equal(validationStatus(value, policy.required_checks), 'failed'); +}); + +test('API failures and capped inventory keep WIP unknown; two unapproved PRs hold generation', () => { + const github = getGithub(policy, () => { throw new Error('network unavailable'); }); + assert.equal(reviewCapacity(github, policy).status, 'unknown'); + const capped = getGithub(policy, () => JSON.stringify(Array.from({ length: 1000 }, pr))); + assert.equal(capped.status, 'unavailable'); + assert.equal(reviewCapacity({ status: 'available', prs: [pr(), { ...pr(), number: 40 }] }, policy).status, 'hold'); + assert.equal(reviewCapacity({ status: 'available', prs: [pr(), { ...pr(), number: 40, reviewDecision: 'APPROVED' }] }, policy).status, 'available'); +}); + +test('closed/missing PR is not silently called merged', (t) => { + const f = queueFixture(t); + const report = buildReport(f.root, f.queue, policy, { status: 'available', prs: [] }); + const item = report.items.find((item) => item.kind === 'merge'); + assert.equal(item.status, 'not_open_verify_outcome'); + assert.equal(item.merge, 'unknown'); +}); + +test('queue enforces owners, deadlines, canonical gates, evidence and human resolutions', (t) => { + assert.deepEqual(validateQueue(root, queue, policy), []); + const f = queueFixture(t); + const mutations = [ + (q) => { q.items[0].owner = 'unknown'; }, + (q) => { delete q.items[0].due_at; }, + (q) => { q.items[0].due_at = '2020-01-01T00:00:00Z'; }, + (q) => { q.items[0].evidence = ['../outside']; }, + (q) => { q.items[0].status = 'resolved'; }, + (q) => { q.items.push(structuredClone(q.items[0])); }, + (q) => { q.items.find((i) => i.kind === 'gate').gate_id = 'fake'; }, + (q) => { q.items[0].due_at = 'tomorrow'; }, + (q) => { const i = q.items.find((i) => i.kind === 'gate'); i.status = 'resolved'; i.resolution = { + decided_by: 'human', decided_at: '2026-09-10T00:00:00Z', decision: 'approved', evidence: i.evidence + }; } + ]; + for (const mutate of mutations) { + const changed = structuredClone(f.queue); mutate(changed); + assert.ok(validateQueue(f.root, changed, policy).length > 0); + } +}); + +function releaseFixture(t) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cle3-release-test-')); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const write = (name, value) => { const target = path.join(dir, name); fs.mkdirSync(path.dirname(target), { recursive: true }); fs.writeFileSync(target, JSON.stringify(value)); }; + write('state.json', { episodes: { EP999: { phases: { phase4: { status: 'completed' }, phase5: { status: 'completed' } } } } }); + const prefix = 'episodes/EP999'; + write(`${prefix}/source.png`, 'source'); write(`${prefix}/final.svg`, 'final'); write(`${prefix}/approval.md`, 'human evidence'); + const gate = { id: 'release', status: 'approved', approved_by: 'human', approved_at: '2026-09-08T00:00:00Z', evidence: [`${prefix}/approval.md`] }; + const panel = { panel_id: 'p1-1', generation_status: 'generated', image_path: `${prefix}/source.png` }; + const overlay = { panel_id: 'p1-1', status: 'approved', overlays: [{ text: '승인된 대사' }], source_image_path: panel.image_path, final_image_path: `${prefix}/final.svg` }; + const qa = { episode_id: 'EP999', overall_score: 45, items: Array.from({ length: 5 }, () => ({ score: 9 })), final_images: [overlay.final_image_path] }; + const save = () => { + write(`${prefix}/approvals/gates.json`, { episode_id: 'EP999', gates: [gate] }); + write(`${prefix}/panels/panels.json`, { episode_id: 'EP999', panels: [panel] }); + write(`${prefix}/panels/text-overlays.json`, { episode_id: 'EP999', panels: [overlay] }); + write(`${prefix}/qa/qa.json`, qa); + }; + save(); return { dir, write, gate, panel, overlay, qa, save }; +} + +test('release requires actual human approval even when outputs and CI are complete', (t) => { + const f = releaseFixture(t); + assert.equal(releaseReadiness(f.dir, 'EP999').status, 'ready'); + for (const status of ['pending', 'provisional', 'not_applicable', 'changes_requested']) { + f.gate.status = status; f.save(); + assert.equal(releaseReadiness(f.dir, 'EP999').status, 'blocked'); + } + f.gate.status = 'approved'; f.gate.approved_by = ''; f.save(); + assert.equal(releaseReadiness(f.dir, 'EP999').status, 'blocked'); +}); + +test('unreviewed states cannot pass release; QA must cover exact final files', (t) => { + const f = releaseFixture(t); + for (const status of ['draft', 'needs_review', 'unknown']) { + f.overlay.status = status; f.save(); + assert.equal(releaseReadiness(f.dir, 'EP999').status, 'blocked'); + } + f.overlay.status = 'rendered'; f.save(); + assert.equal(releaseReadiness(f.dir, 'EP999').status, 'ready'); + f.overlay.status = 'approved'; f.qa.final_images = ['episodes/EP999/source.png']; f.save(); + assert.equal(releaseReadiness(f.dir, 'EP999').status, 'blocked'); + f.qa.final_images = [f.overlay.final_image_path]; f.qa.items[0].score = 1; f.save(); + assert.equal(releaseReadiness(f.dir, 'EP999').status, 'blocked'); +}); + +test('release rejects missing final files and out-of-repository evidence', (t) => { + const f = releaseFixture(t); + f.gate.evidence = ['../not-repository']; f.save(); + assert.equal(releaseReadiness(f.dir, 'EP999').status, 'blocked'); + f.gate.evidence = ['episodes/EP999/approval.md']; f.overlay.final_image_path = 'missing.svg'; f.save(); + assert.equal(releaseReadiness(f.dir, 'EP999').status, 'blocked'); +}); + +test('runner WIP hold emits no commands and does not write a plan', (t) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cle3-gh-test-')); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + fs.writeFileSync(path.join(dir, 'gh'), `#!${process.execPath}\nconsole.log(${JSON.stringify(JSON.stringify([pr(), { ...pr(), number: 40 }]))});\n`, { mode: 0o755 }); + const plan = path.join(root, 'episodes/EP002/panels/next-generation-plan.json'); + const before = fs.existsSync(plan) ? fs.readFileSync(plan, 'utf8') : null; + const result = spawnSync(process.execPath, ['scripts/run-panel-jobs.js', '--episode', 'EP002', '--write-plan'], { + cwd: root, encoding: 'utf8', env: { ...process.env, PATH: `${dir}${path.delimiter}${process.env.PATH}` } + }); + assert.equal(result.status, 2, result.stderr); + assert.deepEqual(JSON.parse(result.stdout).selected_jobs, []); + assert.equal(fs.existsSync(plan) ? fs.readFileSync(plan, 'utf8') : null, before); +}); + +test('both PR and Pages workflows execute the release guard', () => { + const deploy = fs.readFileSync(path.join(root, '.github/workflows/deploy.yml'), 'utf8'); + assert.ok(deploy.indexOf('EP001 --require-release') < deploy.indexOf('actions/configure-pages')); + assert.match(deploy, /validate-episode-output\.js EP001 --require-release/); + const ci = fs.readFileSync(path.join(root, '.github/workflows/validate-assets.yml'), 'utf8'); + assert.match(ci, /decision-status\.js --validate/); + assert.match(ci, /validate-episode-output\.js EP001 --require-release/); +});