# Compose Claude Code skills for three checked drafts from one approved source
> Use four separate Claude Code skills to inventory an approved written source, draft LinkedIn, newsletter and ad copy, then check file hashes and claims before human review.
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

AI-generated conceptual illustration by BIG CHANGE; no actual Claude Code interface, model output, reviewed draft or campaign is depicted.
An approved article or transcript can supply several drafts, but each draft needs a clear path back to its source. This guide composes four separate [Claude Code project skills](https://code.claude.com/docs/en/skills) to inventory one owner-approved written source, draft a LinkedIn post, a newsletter section and an ad in separate directories, then check their file contracts with a local Python join. The output is a set of drafts for a named human reviewer, not permission to publish or send them.
BIG CHANGE reviewed the skills, contracts and join code below. An independent reviewer ran 16 local structural checks. We did **not** install or run the skills in Claude Code, pay for a model run, generate a factual channel draft or test a real campaign. The steps below are a reader procedure to test with their own approved source.
## The big change
- **What changed:** Four owned skills now have explicit source, inventory and channel-result contracts, followed by a deterministic join.
- **Why it matters:** Each draft can be traced to source passages while a missing channel stays visible as a blocked slot.
- **What to watch:** File checks cannot establish factual truth, adaptation rights or editorial approval; a human must check every ready draft.
## 1. Confirm access, rights and the source
Use an owned Claude Code repository and an account permitted to process the source. Check the account's model availability, [usage and costs](https://code.claude.com/docs/en/costs), [data policy](https://code.claude.com/docs/en/data-usage) and [tool permissions](https://code.claude.com/docs/en/permissions) before using confidential material. Claude Code's `/usage` command can show the current session's usage; billing depends on the account and plan. No cost or saving was measured for this pipeline. The source must be an actual approved UTF-8 article or checked transcript, with its owner, version and approval recorded. Resolve transcription accuracy, rights for each channel, privacy and any offer with the owner first. A video URL or the empty template below does not satisfy this input.
The four reference skills use Claude Code's documented `.claude/skills/<name>/SKILL.md` location, slash-command invocation and `$ARGUMENTS` substitution. Their `context: fork` setting gives each invocation its own context without the conversation history. The draft skills use `background: true`, which requires Claude Code **v2.1.218 or later**. They use the general-purpose agent, whose file-writing ability and effective permissions follow the user's Claude Code setup. The skill text grants no permissions and cannot enforce a read-only boundary. Background work may overlap when the host allows it; this procedure does not guarantee parallel execution. Inspect existing same-name skills and command discovery before using these names, and verify the command resolves to the project file you installed. See the [skills manual](https://code.claude.com/docs/en/skills) and [subagents manual](https://code.claude.com/docs/en/sub-agents).
## 2. Install the four files and contracts
Create the exact paths in the file list below inside your project. Copy each complete block from the reference files section without changing its contents. The five files under `.claude/repurpose-contracts/` define the input, inventory, blocked-state and channel-result shapes plus the dependency plan. Put `join.py` at the project root. The two templates go under `repurpose-inputs/`; replace their placeholders with real approved content before any run. The output tree is a structural guide only. The complete files are included below for copying.
| File | Purpose |
| --- | --- |
| `.claude/skills/source-inventory/SKILL.md` | Checks approvals and inventories every source line before drafting. |
| `.claude/skills/draft-linkedin/SKILL.md` | Owns only the LinkedIn draft and result. |
| `.claude/skills/draft-newsletter/SKILL.md` | Owns only the newsletter draft and result. |
| `.claude/skills/draft-ad/SKILL.md` | Owns only the ad draft and result, or an explicit offer blocker. |
| `.claude/repurpose-contracts/*.json` | Defines exact fields, hash edges and stage order. |
| `join.py` | Checks structure and provenance, then creates a claim map and review handoff. |
| `repurpose-inputs/approved-source.template.md`, `repurpose-inputs/constraints.template.json` | Empty starting forms, never runnable evidence. |
The constraints require explicit adaptation rights for LinkedIn, newsletter and ad, even if the ad branch will be blocked for lack of an approved offer. `forbiddenClaims` may be an empty list when the owner has no additional prohibitions. An ad needs an approved offer, HTTP(S) destination and call to action; an absent value blocks that branch. Record a real named reviewer. Keep the approved source and constraints unchanged during a run.
## 3. Test each edge, then run the inventory
First test each skill separately on a bounded, real, approved source. Compare its actual files and hashes to the contracts. BIG CHANGE has not performed that Claude Code test. For a full run, create new absolute directories for inputs, run outputs and join outputs. In the repository, invoke the inventory command with three absolute paths:
```prompt
/source-inventory <absolute-path-to-approved-source.md> <absolute-path-to-constraints.json> <absolute-path-to-new-run-directory>
```
Replace every angle-bracket argument with a real path. The skill should write `inventory.json`, containing verbatim, ordered passage ranges covering every line, or `inventory-blocked.json` with the exact missing approval or right. Do not start a draft stage if inventory is blocked. Check the recorded SHA-256 values against both input files and inspect the passage boundaries, including blank lines.
## 4. Draft in three isolated channels
Once `inventory.json` exists and the two inputs still have the recorded hashes, invoke the three separate skills. Use the same absolute run directory for each first argument and three distinct output directories for the second. Each skill reads the approved source, constraints and inventory; none reads a sibling draft. They may be started independently after inventory, subject to your Claude Code host and permissions.
```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
```
Each channel owns `result.json` and, only when ready, `draft.md`. Its result identifies the channel and the SHA-256 of the source, constraints and inventory, then maps material claims or quotations to passage IDs and applicable constraints keys. `ready` means ready for fact review. A missing approved ad offer, destination or call to action must produce a blocked result and no ad draft. A background invocation or a valid JSON file alone does not establish a useful or accurate draft.
## 5. Join the results and make a human decision
After all three stages have returned or definitively failed, run the supplied Python file with the run directory and a **new** join directory. The packet's join uses the Python standard library; check your local Python installation before invoking it.
```bash
python join.py <absolute-run-directory> <absolute-new-join-directory>
```
The join checks the source and constraints hashes, complete inventory line coverage, inventory hash in each channel, stage identity, claim pointers, output ownership and ad prerequisites. It writes `claim-map.md`, `review.md` and `summary.json`. Missing or invalid stages appear as blocked while valid sibling drafts remain visible. A global source mismatch stops the join. It does not verify whether a sentence is true, a quote is fair, a claim has proper legal support, or the owner approved publication. `review.md` starts with `PENDING` decisions.
The named reviewer should open each ready `draft.md`, compare every material statement and quotation with its cited passage and constraints, check ambiguity, channel voice, adaptation rights, the ad destination and offer, then record **accept, revise or reject** for each channel outside the generated pending fields. Publishing, sending, scheduling and placing ads require their own human authorization and platform checks.
## 6. Recover a failed branch without losing good work
If source approval or rights are unclear, resolve the exact gap with the owner and start a fresh run with corrected inputs. Any change to the source or constraints hash requires a new complete inventory. If one channel fails while siblings are valid, inspect that channel's actual files and error first. Copy the unchanged inventory bytes and verified sibling folders into a new run directory, run only the failed stage there, and join into another new directory. The copied results must still match the inventory hash. If a process outcome is uncertain, read its result and hashes before deciding whether to retry. Preserve earlier runs and reviewer edits; never overwrite them with a rerun. Repair an unsupported or missing claim pointer in the owning stage, then repeat the deterministic join to a fresh directory.
This is manually invoked, model-directed composition. It does not provide code-enforced dispatch, automatic retries, fixed concurrency or recovery of external side effects. Start with an already approved written source: this procedure does not acquire or verify a transcript, or create video, audio or visual campaign outputs.
## Complete reference files
Keep the paths and contents together. The code blocks below are complete copies of the independently reviewed packet; they are reference files, not records of a Claude run.
### `.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
```
## Sources & further reading
- [Claude Code skills manual](https://code.claude.com/docs/en/skills) — project skill paths, frontmatter, invocation, argument substitution, fork context and background behavior. Checked 19 October 2026; the manual states no single version for this recipe.
- [Claude Code subagents manual](https://code.claude.com/docs/en/sub-agents) — general-purpose agent, context and permission behavior. Checked 19 October 2026.
- [Claude Code costs](https://code.claude.com/docs/en/costs), [data usage](https://code.claude.com/docs/en/data-usage) and [permissions](https://code.claude.com/docs/en/permissions) — account-dependent usage, data handling and tool controls. Checked 19 October 2026; no pipeline spending was measured.
- The independently reviewed local packet supplied the four skills, five JSON contracts, two input templates, output tree and join script. Its 16 checks used temporary structural fixtures only; no generated prose or factual approval resulted.
- [Related BIG CHANGE guide: parallel document review](https://bigchange.ai/blog/claude-managed-agents-parallel-document-review-guide) covers a different Claude workflow based on document review, rather than Code-local three-channel source adaptation.
## Sources
- [Claude Code skills manual](https://code.claude.com/docs/en/skills) — Project path, invocation, fork context and background behavior; HTTP 200 and hash checked 2026-10-19.
- [Claude Code subagents manual](https://code.claude.com/docs/en/sub-agents) — General-purpose agent and permission behavior; HTTP 200 and hash checked 2026-10-19.
- [Claude Code costs](https://code.claude.com/docs/en/costs) — Account-dependent usage and session usage reporting; checked 2026-10-19.
- [Claude Code data usage](https://code.claude.com/docs/en/data-usage) — Account and organization data policy; checked 2026-10-19.
- [Claude Code permissions](https://code.claude.com/docs/en/permissions) — Actual tool access is configured outside skill prose; checked 2026-10-19.
The BIG CHANGE newsletter
The big picture. At your pace.
Recent stories on AI and robotics, the shifts worth watching and practical ideas to use. Choose a daily briefing, weekly digest or monthly perspective.
Next scheduled send (UTC): . Your first edition arrives at the next scheduled send after you confirm.
Your privacy, your choice.
Necessary storage supports site security and remembers your choices. Optional Google Analytics stays off until you allow it. You can read every story with necessary storage only. Privacy details