AI-translated from English; not yet reviewed by a fluent editor.

# Claude Code skills를 조합해 승인된 자료 하나로 검토할 초안 3개 만들기

> 서로 다른 Claude Code skills 4개로 승인된 문서의 목록을 만들고 LinkedIn·뉴스레터·광고 초안을 작성한 다음, 사람이 검토하기 전에 파일 해시와 주장을 확인합니다.

By BIG CHANGE Editorial

Published: 2026-10-19T19:41:34.834Z
Updated: 2026-10-19T19:41:34.834Z
Canonical: https://bigchange.ai/blog/claude-code-three-channel-skill-pipeline-guide

![Conceptual charcoal illustration of three unbranded layout proofs: a social post on a phone, an open newsletter on a wooden stand, and a small ad in a metal holder.](https://bigchange.ai/api/media/file/three-channel-proof-hero-v1.png)
AI-generated conceptual illustration by BIG CHANGE; no actual Claude Code interface, model output, reviewed draft or campaign is depicted.

승인된 기사나 녹취록에서 여러 초안을 만들 수 있지만, 각 초안에는 원문으로 되짚어 갈 명확한 경로가 필요합니다. 이 가이드는 별개의 [Claude Code 프로젝트 skills](https://code.claude.com/docs/en/skills) 4개를 조합합니다. 소유자가 승인한 글의 목록을 만들고, LinkedIn 게시물·뉴스레터 문단·광고를 각각 다른 디렉터리에 작성한 뒤 로컬 Python 결합 처리로 파일 계약을 확인합니다. 결과는 지정된 사람 검토자에게 전달할 초안이며, 게시나 발송 허가는 아닙니다.

BIG CHANGE는 아래 skills와 계약, 결합 코드를 검토했습니다. 독립 검토자는 로컬 구조 검사 16개를 실행했습니다. 다만 다음 작업은 **하지 않았습니다**: Claude Code에 skills를 설치하거나 실행하기, 유료 모델 실행, 실제 사실에 기반한 채널 초안 생성, 실제 캠페인 시험. 아래는 독자가 자신의 승인된 자료로 시험할 절차입니다.

## 큰 변화

- **달라진 점:** 소유한 skills 4개에 자료·목록·채널 결과의 명시적 계약을 두고 결정론적 결합으로 연결합니다.
- **중요한 이유:** 각 초안을 원문 구절에 연결할 수 있으며, 누락된 채널은 차단된 항목으로 계속 표시됩니다.
- **주의할 점:** 파일 검사로 사실의 진실성, 각색 권리나 편집 승인을 증명할 수 없습니다. 준비된 초안은 모두 사람이 확인해야 합니다.

## 1. 접근 권한, 권리와 자료 확인

직접 관리하는 Claude Code 저장소와 자료 처리 허가를 받은 계정을 사용합니다. 기밀 자료를 쓰기 전에 사용 가능한 모델, [사용량과 비용](https://code.claude.com/docs/en/costs), [데이터 정책](https://code.claude.com/docs/en/data-usage) 및 [도구 권한](https://code.claude.com/docs/en/permissions)을 확인합니다. Claude Code의 `/usage` 명령은 현재 세션 사용량을 보여 줄 수 있습니다. 요금은 계정과 요금제에 따라 달라집니다. 이 흐름의 비용이나 절감액은 측정하지 않았습니다. 실제 승인된 UTF-8 기사나 검증된 녹취록을 사용하고 소유자·버전·승인을 기록해야 합니다. 녹취 정확성, 채널별 각색 권리, 개인정보와 광고 제안 조건은 소유자와 먼저 해결하세요. 동영상 URL이나 아래 빈 양식만으로는 입력 요건을 충족하지 못합니다.

참조 skills 4개는 Claude Code 문서에 명시된 `.claude/skills/<name>/SKILL.md` 위치, 슬래시 명령 호출과 `$ARGUMENTS` 치환을 사용합니다. `context: fork` 설정은 각 호출에 대화 이력이 없는 별도 문맥을 제공합니다. 초안 skills는 `background: true`를 사용하므로 Claude Code **v2.1.218 이상**이 필요합니다. 범용 에이전트를 사용하며 파일 쓰기 능력과 실제 권한은 사용자의 Claude Code 설정을 따릅니다. skill 문구 자체는 권한을 부여하거나 읽기 전용 경계를 강제하지 못합니다. 호스트가 허용하면 백그라운드 작업이 겹칠 수 있으나 병렬 실행은 보장되지 않습니다. 이름을 채택하기 전에 같은 이름의 기존 skills와 명령 검색을 살피고, 명령이 설치한 프로젝트 파일을 가리키는지 확인하세요. [skills 설명서](https://code.claude.com/docs/en/skills) 및 [subagents 설명서](https://code.claude.com/docs/en/sub-agents).

## 2. 파일 4개와 계약 설치

프로젝트 안에 아래 목록의 정확한 경로를 만듭니다. 참조 파일 부분의 각 블록 전체를 내용 변경 없이 복사하세요. `.claude/repurpose-contracts/` 아래의 파일 5개는 입력·목록·차단 상태·채널 결과의 형식과 의존 계획을 정의합니다. `join.py`를 프로젝트 루트에 둡니다. 양식 2개는 `repurpose-inputs/` 아래에 두고, 실행 전에 임시 값을 실제 승인된 내용으로 바꿉니다. 출력 트리는 구조 안내일 뿐입니다. 아래에 복사용 전체 파일이 있습니다.

| 파일 | 용도 |
| --- | --- |
| `.claude/skills/source-inventory/SKILL.md` | 승인 여부를 검사하고 초안 전에 모든 원문 줄을 목록화합니다. |
| `.claude/skills/draft-linkedin/SKILL.md` | LinkedIn 초안과 결과만 담당합니다. |
| `.claude/skills/draft-newsletter/SKILL.md` | 뉴스레터 초안과 결과만 담당합니다. |
| `.claude/skills/draft-ad/SKILL.md` | 광고 초안과 결과 또는 명확한 제안 조건 차단 사유만 담당합니다. |
| `.claude/repurpose-contracts/*.json` | 정확한 필드, 해시 관계와 단계 순서를 정의합니다. |
| `join.py` | 구조와 출처를 검사한 뒤 주장 연결표와 검토 인계 자료를 만듭니다. |
| `repurpose-inputs/approved-source.template.md`, `repurpose-inputs/constraints.template.json` | 빈 시작 양식이며 실행 증거가 아닙니다. |

승인된 광고 제안이 없어 광고 분기가 차단되더라도 LinkedIn·뉴스레터·광고의 명시적 각색 권리가 필요합니다. `forbiddenClaims`는 소유자의 추가 금지 사항이 없으면 빈 목록일 수 있습니다. 광고에는 승인된 제안, HTTP(S) 목적지와 행동 유도가 필요하며 하나라도 빠지면 해당 분기가 차단됩니다. 실제 검토자를 이름으로 기록하고 실행 중 승인된 자료와 제약을 바꾸지 마세요.

## 3. 각 의존 관계를 시험한 뒤 목록 실행

먼저 범위가 작고 실제 승인된 자료로 각 skill을 따로 시험합니다. 실제 파일과 해시를 계약과 비교하세요. BIG CHANGE는 이 Claude Code 시험을 수행하지 않았습니다. 전체 실행에는 입력·실행 출력·결합 출력의 새 절대 경로 디렉터리를 만듭니다. 저장소에서 절대 경로 3개로 목록 명령을 호출합니다.

```prompt
/source-inventory <absolute-path-to-approved-source.md> <absolute-path-to-constraints.json> <absolute-path-to-new-run-directory>
```

꺾쇠 안의 모든 인수를 실제 경로로 바꿉니다. skill은 `inventory.json`에 모든 줄을 포괄하는 원문 그대로의 순서 있는 구절 범위를 쓰거나, `inventory-blocked.json`에 정확히 어떤 승인이나 권리가 없는지 써야 합니다. 목록이 차단되면 초안 단계를 시작하지 마세요. 기록된 SHA-256을 입력 파일 2개와 대조하고 빈 줄까지 포함한 구절 경계를 확인합니다.

## 4. 분리된 채널 3개에서 초안 작성

먼저 `inventory.json`이 존재하고 입력 2개의 해시가 기록과 같은지 확인한 뒤 별도 skills 3개를 호출합니다. 첫 인수는 같은 절대 실행 디렉터리, 둘째는 각각 다른 출력 디렉터리입니다. 각 skill은 승인된 자료·제약·목록을 읽고 다른 채널 초안은 읽지 않습니다. 목록 뒤에 독립적으로 시작할 수 있지만 Claude Code 호스트와 권한을 따릅니다.

```prompt
/draft-linkedin <absolute-run-directory> <absolute-run-directory>/linkedin
/draft-newsletter <absolute-run-directory> <absolute-run-directory>/newsletter
/draft-ad <absolute-run-directory> <absolute-run-directory>/ad
```

각 채널은 `result.json` 및 준비된 경우에만 `draft.md`를 담당합니다. 결과는 채널과 자료·제약·목록의 SHA-256을 식별하고 주요 주장이나 인용을 구절 ID 및 해당 제약 키에 연결합니다. `ready`는 사실 검토를 받을 준비가 됐다는 뜻입니다. 승인된 광고 제안·목적지·행동 유도가 없으면 광고 초안 없이 차단 결과를 내야 합니다. 백그라운드 호출이나 올바른 JSON만으로 유용하고 정확한 초안을 입증할 수 없습니다.

## 5. 결과를 결합하고 사람이 결정

세 단계가 모두 반환되거나 확실히 실패한 뒤, 실행 디렉터리와 **새** 결합 디렉터리로 제공된 Python 파일을 실행합니다. Python 표준 라이브러리를 사용하므로 먼저 로컬 설치를 확인하세요.

```bash
python join.py <absolute-run-directory> <absolute-new-join-directory>
```

결합 처리는 자료와 제약 해시, 목록의 전체 줄 포괄 여부, 각 채널의 목록 해시, 단계 식별, 주장 참조, 출력 담당 범위, 광고 전제 조건을 검사합니다. 생성 파일은 `claim-map.md`, `review.md` 및 `summary.json`입니다. 누락되거나 잘못된 단계는 차단으로 표시하고 유효한 다른 초안은 보존합니다. 전체 자료가 다르면 결합을 중지합니다. 문장이 사실인지, 인용이 공정한지, 법적 근거가 적절한지, 소유자가 게시를 승인했는지는 검증하지 않습니다. `review.md`는 처음에 `PENDING` 결정으로 시작합니다.

지정 검토자는 준비된 각 `draft.md`를 열고 모든 주요 주장과 인용을 해당 구절·제약과 대조합니다. 모호함, 채널 어조, 각색 권리, 광고 목적지와 제안을 확인하고, 생성된 보류 필드 외부에 채널별로 **수락, 수정 또는 거절**을 기록합니다. 게시·발송·예약·광고 집행은 별도의 사람 허가와 플랫폼 확인이 필요합니다.

## 6. 유효한 작업을 보존하며 실패한 분기 복구

자료 승인이나 권리가 불명확하면 소유자와 구체적인 빈틈을 해결한 뒤 수정 입력으로 새 실행을 시작합니다. 자료나 제약 해시가 달라지면 전체 목록을 새로 만들어야 합니다. 다른 채널은 유효하고 하나만 실패했다면 먼저 그 실제 파일과 오류를 살핍니다. 바이트가 변하지 않은 목록과 검증된 다른 채널 폴더를 새 실행 디렉터리에 복사하고 실패 단계만 다시 실행한 뒤 또 다른 새 디렉터리로 결합합니다. 복사한 결과도 목록 해시와 일치해야 합니다. 프로세스 결과가 불확실하면 결과와 해시를 읽은 다음 재시도를 결정합니다. 이전 실행과 검토자 수정을 보존하고 재실행으로 덮어쓰지 마세요. 없거나 지원되지 않는 주장 참조는 담당 단계에서 수정하고 새 디렉터리로 결정론적 결합을 반복합니다.

이는 수동 호출하는 모델 주도 조합입니다. 코드로 강제하는 배정, 자동 재시도, 고정 동시 실행 수나 외부 부작용 복구는 제공하지 않습니다. 승인된 글로 시작하세요. 녹취록 취득·검증이나 캠페인 영상·오디오·시각 자료 제작은 이 절차에 포함되지 않습니다.

## 전체 참조 파일

경로와 내용을 함께 보관하세요. 아래 코드 블록은 독립 검토된 패킷 전체를 복사한 참조 파일이며 Claude 실행 기록은 아닙니다.

### `.claude/skills/source-inventory/SKILL.md`

```markdown
---
name: source-inventory
description: Build a bounded passage inventory from one approved owned source for a three-format draft pipeline.
disable-model-invocation: true
context: fork
agent: general-purpose
background: false
---

Input arguments: `$ARGUMENTS[0]` is the approved UTF-8 source Markdown path, `$ARGUMENTS[1]` is the constraints JSON path, and `$ARGUMENTS[2]` is a new run directory. Read only those inputs and the run directory contract. Do not infer inputs from conversation history.

1. Verify both files exist. Read `constraints.json` against `.claude/repurpose-contracts/constraints.schema.json`; require `sourceOwner`, `sourceVersion`, `approvedAt`, `adaptationRights`, `audience`, `permittedChannels`, `reviewer`, `forbiddenClaims`, and `offer`. `adaptationRights` must explicitly mark all three channels `true`; a template or unapproved value does not count. `offer` may be null; that blocks ad copy requiring an offer or destination. If any approval or right is unclear, create only `inventory-blocked.json` under `.claude/repurpose-contracts/inventory-blocked.schema.json` explaining the gap and stop.
2. Record SHA-256 hashes of the exact two input files. Divide the entire approved source into numbered, contiguous, verbatim line ranges with no omitted lines. Give each range a stable `P001`-style ID, start/end line and exact text, including blank lines where they occur in a range. Do not add outside facts, inferred transcript words, or quotations not present in the source.
3. Write `inventory.json` in the run directory according to `.claude/repurpose-contracts/inventory.schema.json` in the reader project. Record the source and constraints paths/hashes, source owner/version/approval, passages and any ambiguous passages requiring human review. Do not edit either input.
4. Return the inventory path and blockers. This stage never drafts, publishes, fetches video, contacts services or approves source rights on behalf of the owner.
```

### `.claude/skills/draft-linkedin/SKILL.md`

```markdown
---
name: draft-linkedin
description: Draft one review-only LinkedIn post from an approved source inventory.
disable-model-invocation: true
context: fork
agent: general-purpose
background: true
---

Inputs: `$ARGUMENTS[0]` is the run directory containing `inventory.json`; `$ARGUMENTS[1]` is the dedicated `linkedin` output directory. Read the source and constraints paths recorded in the inventory and verify their hashes still match. Do not rely on prior conversation or sibling-stage results.

Write only under the dedicated output directory. If required source, permission, audience or voice information is missing or conflicting, write `result.json` with `status: blocked` and exact blockers; do not invent it. Otherwise create `draft.md`: one self-contained LinkedIn post with a factual opening and concrete takeaway, no invented statistics, quotes, benefits, offers or links. Write `result.json` according to `.claude/repurpose-contracts/stage-result.schema.json` in the reader project, with `channel: linkedin`, input hashes, inventory hash, draft path, and every material claim or quote paired with exact passage IDs and any applicable constraints keys. Mark uncertainty in the draft and result. `status: ready` means ready for human fact review, not approved or published. Return the two file paths. Never post or schedule.
```

### `.claude/skills/draft-newsletter/SKILL.md`

```markdown
---
name: draft-newsletter
description: Draft one review-only newsletter section from an approved source inventory.
disable-model-invocation: true
context: fork
agent: general-purpose
background: true
---

Inputs: `$ARGUMENTS[0]` is the run directory containing `inventory.json`; `$ARGUMENTS[1]` is the dedicated `newsletter` output directory. Read the source and constraints paths recorded in the inventory and verify their hashes still match. Do not rely on prior conversation or sibling-stage results.

Write only under the dedicated output directory. If required source, permission, audience or voice information is missing or conflicting, write `result.json` with `status: blocked` and exact blockers. Otherwise create `draft.md`: a descriptive heading, short summary and evidence-linked detail suited to the approved newsletter audience. Add no invented statistics, quotes, benefits or outside news. Write `result.json` according to `.claude/repurpose-contracts/stage-result.schema.json` in the reader project, with `channel: newsletter`, input hashes, inventory hash, draft path, and every material claim or quote paired with exact passage IDs and applicable constraints keys. Mark uncertainty in the draft and result. `status: ready` means ready for human fact review, not approved or sent. Return the two file paths. Never send or schedule.
```

### `.claude/skills/draft-ad/SKILL.md`

```markdown
---
name: draft-ad
description: Draft review-only ad copy from an approved source inventory and approved offer.
disable-model-invocation: true
context: fork
agent: general-purpose
background: true
---

Inputs: `$ARGUMENTS[0]` is the run directory containing `inventory.json`; `$ARGUMENTS[1]` is the dedicated `ad` output directory. Read the source and constraints paths recorded in the inventory and verify their hashes still match. Do not rely on prior conversation or sibling-stage results.

Write only under the dedicated output directory. Require an approved offer, destination URL and call to action in constraints, plus adaptation rights for advertising. If any are absent, write `result.json` with `status: blocked`, the missing exact fields and no draft. Otherwise create `draft.md` with one headline, one body and the approved call to action. No invented metrics, testimonials, urgency, claims, price or performance. Write `result.json` according to `.claude/repurpose-contracts/stage-result.schema.json` in the reader project, with `channel: ad`, input hashes, inventory hash, draft path, and every material claim or quote paired with exact passage IDs and applicable constraints keys. Mark uncertainty in the draft and result. `status: ready` means ready for human fact/legal review, not approved or placed. Return the two file paths. Never upload, buy or schedule ads.
```

### `.claude/repurpose-contracts/constraints.schema.json`

```json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "title": "Reader-approved source constraints",
  "type": "object",
  "required": ["sourceOwner", "sourceVersion", "approvedAt", "adaptationRights", "audience", "permittedChannels", "reviewer", "forbiddenClaims", "voiceRules", "offer"],
  "properties": {
    "sourceOwner": {"type": "string", "minLength": 1},
    "sourceVersion": {"type": "string", "minLength": 1},
    "approvedAt": {"type": "string", "minLength": 1},
    "adaptationRights": {"type": "object", "required": ["linkedin", "newsletter", "ad"], "properties": {"linkedin": {"type": "boolean"}, "newsletter": {"type": "boolean"}, "ad": {"type": "boolean"}}},
    "audience": {"type": "string", "minLength": 1},
    "permittedChannels": {"type": "array", "uniqueItems": true, "items": {"enum": ["linkedin", "newsletter", "ad"]}},
    "reviewer": {"type": "string", "minLength": 1},
    "forbiddenClaims": {"type": "array", "items": {"type": "string"}},
    "voiceRules": {"type": "string", "minLength": 1},
    "offer": {"type": ["object", "null"], "required": ["approvedOffer", "destinationUrl", "callToAction"], "properties": {"approvedOffer": {"type": "string", "minLength": 1}, "destinationUrl": {"type": "string", "format": "uri"}, "callToAction": {"type": "string", "minLength": 1}}}
  }
}
```

### `.claude/repurpose-contracts/inventory.schema.json`

```json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "title": "Approved source inventory",
  "type": "object",
  "additionalProperties": false,
  "required": ["sourcePath", "sourceSha256", "constraintsPath", "constraintsSha256", "sourceOwner", "sourceVersion", "approvedAt", "passages", "ambiguities"],
  "properties": {
    "sourcePath": {"type": "string", "minLength": 1},
    "sourceSha256": {"type": "string", "pattern": "^[a-f0-9]{64}$"},
    "constraintsPath": {"type": "string", "minLength": 1},
    "constraintsSha256": {"type": "string", "pattern": "^[a-f0-9]{64}$"},
    "sourceOwner": {"type": "string", "minLength": 1},
    "sourceVersion": {"type": "string", "minLength": 1},
    "approvedAt": {"type": "string", "minLength": 1},
    "passages": {"type": "array", "minItems": 1, "items": {"type": "object", "additionalProperties": false, "required": ["id", "startLine", "endLine", "text"], "properties": {"id": {"type": "string", "pattern": "^P[0-9]{3,}$"}, "startLine": {"type": "integer", "minimum": 1}, "endLine": {"type": "integer", "minimum": 1}, "text": {"type": "string"}}}},
    "ambiguities": {"type": "array", "items": {"type": "string"}}
  }
}
```

### `.claude/repurpose-contracts/inventory-blocked.schema.json`

```json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "title": "Preflight block before drafting",
  "type": "object",
  "additionalProperties": false,
  "required": ["status", "blockers"],
  "properties": {
    "status": {"const": "blocked"},
    "blockers": {"type": "array", "minItems": 1, "items": {"type": "string", "minLength": 1}}
  }
}
```

### `.claude/repurpose-contracts/stage-result.schema.json`

```json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "title": "One isolated channel result",
  "type": "object",
  "additionalProperties": false,
  "required": ["channel", "status", "sourceSha256", "constraintsSha256", "inventorySha256", "draftFile", "claims", "blockers"],
  "properties": {
    "channel": {"enum": ["linkedin", "newsletter", "ad"]},
    "status": {"enum": ["ready", "blocked"]},
    "sourceSha256": {"type": "string", "pattern": "^[a-f0-9]{64}$"},
    "constraintsSha256": {"type": "string", "pattern": "^[a-f0-9]{64}$"},
    "inventorySha256": {"type": "string", "pattern": "^[a-f0-9]{64}$"},
    "draftFile": {"type": ["string", "null"]},
    "claims": {"type": "array", "items": {"type": "object", "additionalProperties": false, "required": ["text", "passageIds", "constraintKeys", "status"], "properties": {"text": {"type": "string", "minLength": 1}, "passageIds": {"type": "array", "items": {"type": "string", "pattern": "^P[0-9]{3,}$"}}, "constraintKeys": {"type": "array", "items": {"type": "string"}}, "status": {"enum": ["supported", "needs-human-check", "unsupported"]}}}},
    "blockers": {"type": "array", "items": {"type": "string"}}
  }
}
```

### `.claude/repurpose-contracts/pipeline-plan.json`

```json
{
  "purpose": "Compose reader-owned skills into one source-to-three-format review run",
  "execution": "manual Claude Code skill invocation plus deterministic local join",
  "sourceStage": {
    "skill": "source-inventory",
    "inputs": ["approved-source.md", "constraints.json"],
    "success": "inventory.json with source and constraints hashes and complete numbered line coverage",
    "failure": "inventory-blocked.json; stop all channel stages"
  },
  "parallelAfterInventory": [
    {"skill": "draft-linkedin", "reads": ["approved-source.md", "constraints.json", "inventory.json"], "writesOnly": "linkedin/", "contract": "stage-result.schema.json"},
    {"skill": "draft-newsletter", "reads": ["approved-source.md", "constraints.json", "inventory.json"], "writesOnly": "newsletter/", "contract": "stage-result.schema.json"},
    {"skill": "draft-ad", "reads": ["approved-source.md", "constraints.json", "inventory.json"], "writesOnly": "ad/", "contract": "stage-result.schema.json"}
  ],
  "join": {
    "file": "join.py",
    "dependsOn": ["all three stage results or recorded missing/failed slots"],
    "writesOnly": "new join directory",
    "outputs": ["claim-map.md", "review.md", "summary.json"],
    "humanApproval": "pending"
  }
}
```

### `join.py`

```python
"""Deterministic join/check for the proposed local, review-only pipeline.

This does not call a model, generate prose, or approve factual support. Run only
on reader-owned paths after separately invoked stage skills have returned.
"""

import hashlib
import json
import sys
from pathlib import Path
from urllib.parse import urlparse

CHANNELS = ("linkedin", "newsletter", "ad")
HEX = set("0123456789abcdef")


def digest(path):
    return hashlib.sha256(path.read_bytes()).hexdigest()


def valid_hash(value):
    return isinstance(value, str) and len(value) == 64 and set(value) <= HEX


def read_json(path):
    return json.loads(path.read_text(encoding="utf-8"))


def string_list(value):
    return isinstance(value, list) and all(isinstance(v, str) and v.strip() for v in value)


def approved_offer(value):
    return (isinstance(value, dict) and
            all(isinstance(value.get(k), str) and value[k].strip()
                for k in ("approvedOffer", "destinationUrl", "callToAction")) and
            urlparse(value["destinationUrl"]).scheme in ("http", "https") and
            bool(urlparse(value["destinationUrl"]).netloc))


def check_inventory(run):
    inv_path = run / "inventory.json"
    inv = read_json(inv_path)
    expected = {"sourcePath", "sourceSha256", "constraintsPath", "constraintsSha256",
                "sourceOwner", "sourceVersion", "approvedAt", "passages", "ambiguities"}
    if set(inv) != expected:
        raise ValueError("inventory keys differ from contract")
    source, constraints = Path(inv["sourcePath"]), Path(inv["constraintsPath"])
    if not source.is_file() or not constraints.is_file():
        raise ValueError("approved source or constraints missing")
    if digest(source) != inv["sourceSha256"] or digest(constraints) != inv["constraintsSha256"]:
        raise ValueError("approved input changed since inventory")
    rules = read_json(constraints)
    required = ("sourceOwner", "sourceVersion", "approvedAt", "adaptationRights",
                "audience", "permittedChannels", "reviewer", "forbiddenClaims", "voiceRules", "offer")
    if not isinstance(rules, dict) or any(k not in rules for k in required):
        raise ValueError("constraints approval/rights fields incomplete")
    text_fields = ("sourceOwner", "sourceVersion", "approvedAt", "audience", "reviewer", "voiceRules")
    if any(not isinstance(rules[k], str) or not rules[k].strip() for k in text_fields):
        raise ValueError("constraints text fields incomplete")
    if not string_list(rules["forbiddenClaims"]) or not string_list(rules["permittedChannels"]):
        raise ValueError("constraints lists malformed")
    if any("REPLACE_WITH" in rules[k] for k in text_fields):
        raise ValueError("constraints template was not completed")
    if not isinstance(rules["adaptationRights"], dict) or any(rules["adaptationRights"].get(c) is not True for c in CHANNELS):
        raise ValueError("all three adaptation rights must be explicit")
    if not set(CHANNELS).issubset(set(rules["permittedChannels"])):
        raise ValueError("three-channel rights not explicit")
    if any(rules[k] != inv[k] for k in ("sourceOwner", "sourceVersion", "approvedAt")):
        raise ValueError("source identity differs from constraints")
    lines = source.read_text(encoding="utf-8").splitlines()
    if not lines:
        raise ValueError("source is empty")
    next_line, ids = 1, set()
    if not isinstance(inv["passages"], list) or not string_list(inv["ambiguities"]):
        raise ValueError("inventory passages or ambiguities malformed")
    for n, p in enumerate(inv["passages"], 1):
        if set(p) != {"id", "startLine", "endLine", "text"}:
            raise ValueError("passage keys differ from contract")
        if p["id"] != f"P{n:03d}" or p["id"] in ids:
            raise ValueError("passage IDs must be ordered and unique")
        ids.add(p["id"])
        if (type(p["startLine"]) is not int or type(p["endLine"]) is not int or
                p["startLine"] != next_line or p["endLine"] < next_line or p["endLine"] > len(lines)):
            raise ValueError("source line range gap or overlap")
        actual = "\n".join(lines[p["startLine"] - 1:p["endLine"]])
        if p["text"] != actual:
            raise ValueError("passage text differs from approved source")
        next_line = p["endLine"] + 1
    if next_line != len(lines) + 1:
        raise ValueError("inventory omits source lines")
    return inv_path, inv, rules, ids


def check_stage(run, channel, inv, rules, ids, inv_hash):
    folder = run / channel
    path = folder / "result.json"
    if not path.is_file():
        return {"channel": channel, "status": "blocked", "blockers": ["result.json missing"], "claims": []}
    try:
        result = read_json(path)
        required = {"channel", "status", "sourceSha256", "constraintsSha256",
                    "inventorySha256", "draftFile", "claims", "blockers"}
        if set(result) != required or result["channel"] != channel:
            raise ValueError("result keys or channel differ from contract")
        if result["status"] not in ("ready", "blocked"):
            raise ValueError("invalid result status")
        if (result["sourceSha256"] != inv["sourceSha256"] or
                result["constraintsSha256"] != inv["constraintsSha256"] or
                result["inventorySha256"] != inv_hash):
            raise ValueError("result belongs to another source/inventory")
        if not isinstance(result["claims"], list) or not string_list(result["blockers"]):
            raise ValueError("claims/blockers must be arrays")
        if result["status"] == "ready":
            if channel == "ad" and not approved_offer(rules.get("offer")):
                raise ValueError("ad requires approved offer, HTTP(S) destination and call to action")
            if result["draftFile"] != "draft.md" or not result["claims"] or result["blockers"]:
                raise ValueError("ready result lacks draft or claim list")
            draft = folder / "draft.md"
            if not draft.is_file() or not draft.read_text(encoding="utf-8").strip():
                raise ValueError("ready draft missing or empty")
        elif not result["blockers"] or result["draftFile"] is not None:
            raise ValueError("blocked result needs reasons and no draft")
        for claim in result["claims"]:
            if set(claim) != {"text", "passageIds", "constraintKeys", "status"}:
                raise ValueError("claim keys differ from contract")
            if not isinstance(claim["text"], str) or not claim["text"].strip():
                raise ValueError("empty claim")
            if claim["status"] not in ("supported", "needs-human-check", "unsupported"):
                raise ValueError("invalid claim status")
            if not string_list(claim["passageIds"]) or not string_list(claim["constraintKeys"]):
                raise ValueError("claim evidence pointers must be string arrays")
            if not set(claim["passageIds"]).issubset(ids):
                raise ValueError("unknown source passage ID")
            if not claim["passageIds"] and not claim["constraintKeys"]:
                raise ValueError("claim has no evidence pointer")
            if not set(claim["constraintKeys"]).issubset(rules):
                raise ValueError("unknown constraints key")
            if claim["status"] == "unsupported" and result["status"] == "ready":
                raise ValueError("unsupported claim in ready draft")
        return result
    except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError) as exc:
        return {"channel": channel, "status": "blocked", "blockers": [f"invalid result: {exc}"], "claims": []}


def main():
    if len(sys.argv) != 3:
        raise SystemExit("Usage: python join.py RUN_DIRECTORY NEW_JOIN_DIRECTORY")
    run, target = Path(sys.argv[1]).resolve(), Path(sys.argv[2]).resolve()
    if target.exists():
        raise SystemExit("join output already exists; use a new directory to preserve prior review")
    inv_path, inv, rules, ids = check_inventory(run)
    inv_hash = digest(inv_path)
    results = [check_stage(run, channel, inv, rules, ids, inv_hash) for channel in CHANNELS]
    target.mkdir(parents=True, exist_ok=False)
    map_lines = ["# Claim map for human review", "", f"Source SHA-256: {inv['sourceSha256']}",
                 f"Constraints SHA-256: {inv['constraintsSha256']}", f"Inventory SHA-256: {inv_hash}", ""]
    review_lines = ["# Review handoff", "", f"Reviewer: {rules['reviewer']}",
                    "No draft is approved, sent or published by this join.", ""]
    for result in results:
        channel = result["channel"]
        map_lines += [f"## {channel}", ""]
        review_lines += [f"## {channel}: {result['status']}", ""]
        for blocker in result["blockers"]:
            review_lines.append(f"- Blocker: {blocker}")
        for claim in result["claims"]:
            refs = ", ".join(claim["passageIds"] + claim["constraintKeys"])
            map_lines.append(f"- {claim['status']}: {claim['text']} — {refs}")
        map_lines.append("")
        review_lines += ["- Human must compare every claim and quote with the approved inputs.",
                         "- Human must check rights, voice, audience and any offer/destination.",
                         "- Human approval decision: PENDING", ""]
    (target / "claim-map.md").write_text("\n".join(map_lines), encoding="utf-8")
    (target / "review.md").write_text("\n".join(review_lines), encoding="utf-8")
    summary = {"sourceSha256": inv["sourceSha256"], "constraintsSha256": inv["constraintsSha256"],
               "inventorySha256": inv_hash, "channels": {r["channel"]: r["status"] for r in results},
               "humanApproval": "pending"}
    (target / "summary.json").write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8")
    print(json.dumps(summary))


if __name__ == "__main__":
    main()
```

### `repurpose-inputs/approved-source.template.md`

```markdown
# Replace with the owner's approved written source

This template is deliberately empty of publishable facts. Supply the exact approved transcript or article, retain line breaks for passage IDs, and record its owner and version in constraints.json. Do not invoke the pipeline on this template.
```

### `repurpose-inputs/constraints.template.json`

```json
{
  "sourceOwner": "REPLACE_WITH_ACTUAL_OWNER",
  "sourceVersion": "REPLACE_WITH_ACTUAL_VERSION",
  "approvedAt": "REPLACE_WITH_ACTUAL_APPROVAL_DATE",
  "adaptationRights": {"linkedin": false, "newsletter": false, "ad": false},
  "audience": "REPLACE_WITH_APPROVED_AUDIENCE",
  "permittedChannels": ["linkedin", "newsletter", "ad"],
  "reviewer": "REPLACE_WITH_ACTUAL_HUMAN_REVIEWER",
  "forbiddenClaims": [],
  "voiceRules": "REPLACE_WITH_APPROVED_VOICE_RULES",
  "offer": null
}
```

### `expected-output-tree.txt`

```markdown
STRUCTURE ONLY. No Claude run or model output exists in this packet.

<new-run>/
  inventory.json                 from source-inventory skill, or inventory-blocked.json
  linkedin/result.json           ready or blocked; draft.md only when ready
  newsletter/result.json         ready or blocked; draft.md only when ready
  ad/result.json                 ready or blocked; draft.md only when ready
<new-join>/
  claim-map.md                   generated by join.py from stage claims
  review.md                      all three statuses and PENDING human decisions
  summary.json                   hashes, statuses and humanApproval=pending
```

## 출처와 추가 자료

- [Claude Code skills 설명서](https://code.claude.com/docs/en/skills) — 프로젝트 skill 경로, frontmatter, 호출, 인수 치환, 독립 문맥과 백그라운드 동작. 2026년 10월 19일 확인. 이 절차 전체에 단일 버전을 명시하지 않습니다.
- [Claude Code subagents 설명서](https://code.claude.com/docs/en/sub-agents) — 범용 에이전트, 문맥과 권한 동작. 2026년 10월 19일 확인.
- [Claude Code 비용](https://code.claude.com/docs/en/costs), [데이터 사용](https://code.claude.com/docs/en/data-usage) 및 [권한](https://code.claude.com/docs/en/permissions) — 계정별 사용량, 데이터 처리와 도구 제어. 2026년 10월 19일 확인. 흐름 비용은 측정하지 않았습니다.
- 독립 검토된 로컬 패킷은 skills 4개, JSON 계약 5개, 입력 양식 2개, 출력 트리와 결합 스크립트를 제공합니다. 16개 검사는 임시 구조 테스트 데이터만 사용했으며 문구 생성이나 사실 승인을 수행하지 않았습니다.
- [BIG CHANGE 관련 가이드: 병렬 문서 검토](https://bigchange.ai/blog/claude-managed-agents-parallel-document-review-guide)는 Code 로컬에서 자료를 세 채널로 각색하는 것과 달리 문서 검토 중심의 Claude 흐름을 다룹니다.

## Sources

- [Claude Code skills 설명서](https://code.claude.com/docs/en/skills) — 프로젝트 경로, 호출, 독립 문맥과 백그라운드 동작. 2026-10-19 HTTP 200과 해시 확인.
- [Claude Code subagents 설명서](https://code.claude.com/docs/en/sub-agents) — 범용 에이전트와 권한 동작. 2026-10-19 HTTP 200과 해시 확인.
- [Claude Code 비용](https://code.claude.com/docs/en/costs) — 계정별 사용량과 세션 사용량 보고. 2026-10-19 확인.
- [Claude Code 데이터 사용](https://code.claude.com/docs/en/data-usage) — 계정 및 조직 데이터 정책. 2026-10-19 확인.
- [Claude Code 권한](https://code.claude.com/docs/en/permissions) — 실제 도구 접근은 skill 문구 외부에서 설정합니다. 2026-10-19 확인.
