Docs/Skills and code/skill video-script-production
.claude/skills/video-script-production/scripts/scaffold.js
What the file says about itself
Starts a script, or the next version of one. node scaffold.js --dir <scriptDir> --version v1 --beats 12 [--title "Video title"] a blank script, chain, fact base and hand-off file node scaffold.js --dir <scriptDir> --version v7 --from v6…
Mentioned on: page 06, page 07, page 09, page 10, page 12, page 13
#!/usr/bin/env node
// Starts a script, or the next version of one.
// node scaffold.js --dir <scriptDir> --version v1 --beats 12 [--title "Video title"] a blank script, chain, fact base and hand-off file
// node scaffold.js --dir <scriptDir> --version v7 --from v6 the next version, copied from the last one
// Writes <dir>/SCRIPT-<version>.md, chain-<version>.json, HANDOFF-<version>.md, and FACTS.md (once per topic). Refuses to overwrite anything.
// The blank script has every section ledger-check.js and lint.js expect, so the first `check.js` run says what is missing, not what is malformed.
const fs = require('fs');
const path = require('path');
const args = {};
for (let i = 2; i < process.argv.length; i += 2) args[process.argv[i].replace(/^--/, '')] = process.argv[i + 1];
if (!args.dir) { console.error('usage: node scaffold.js --dir <scriptDir> --version v1 [--beats 12] [--title "..."] [--from v0]'); process.exit(2); }
const version = args.version || 'v1';
const n = Number(args.beats || 12);
const title = args.title || 'Video title';
const dir = args.dir;
const sp = path.join(dir, `SCRIPT-${version}.md`), cp = path.join(dir, `chain-${version}.json`), hp = path.join(dir, `HANDOFF-${version}.md`), fp = path.join(dir, 'FACTS.md');
if (fs.existsSync(sp) || fs.existsSync(cp)) { console.error('refusing to overwrite an existing script or chain'); process.exit(1); }
fs.mkdirSync(dir, { recursive: true });
const handoff = `# Hand-off: ${title} ${version}
Fill these in as the work is done; \`check.js\` reads the first three lines.
Read-aloud pass: pending
Reader pass: pending (write "done" with the date, or "skipped: <reason>"; readers are subagents, which only the user can authorise)
Unproven:
<!-- one plain sentence on the same line as 'Unproven:': what the linter does not prove, what was not tested, which thresholds are design budgets -->
## Decisions for the user
- Title choice and the title/evidence tension:
- Private people named (and whether to):
- Reader pass:
- Commit / push:
## Open items before filming
(every [CHECK] and [V-search] row from the ledger, plus anything the persona's "I" claims that must be true of whoever records)
## What changed from the previous version
`;
const facts = `# Fact base: ${title}
Built BEFORE any prose (SKILL.md Step 1). The script's own ledger is derived from this. Status of every row: **[V]** checked against a non-YouTube page I opened; **[V-search]** seen only in a search-result summary, page not opened (confirm before filming); **[CHECK]** unverified; **derived** computed from other rows (show the arithmetic); **analysis** the author's reasoning (flagged as such in the voice-over).
## Facts
| Claim | Status | Source (URL, date) | Stake: whose loss, which belief, which contrast |
|---|---|---|---|
## Causal claims the script will lean on (find a source for the LINK, not just the numbers)
| "X caused Y" | Source for the link | Status | If unsupported, what the script says instead |
|---|---|---|---|
## Magnitude cross-check (the same figure in two independent sources)
| Figure | Source A | Source B | Agree? | Use |
|---|---|---|---|---|
## People (research the person before the company)
| Name | Role | Want (quote) | Concrete detail | Stake / what they lost | On-record quotes | Sensitivity (private person? allegations?) |
|---|---|---|---|---|---|---|
## Rival-video claims that could not be verified (go in "Claims you must NOT use")
| Claim | Where it appears | Why it is excluded |
|---|---|---|
`;
if (!fs.existsSync(fp)) fs.writeFileSync(fp, facts, 'utf8');
if (!fs.existsSync(hp)) fs.writeFileSync(hp, handoff, 'utf8');
if (args.from) {
const fs0 = path.join(dir, `SCRIPT-${args.from}.md`), fc0 = path.join(dir, `chain-${args.from}.json`);
if (!fs.existsSync(fs0) || !fs.existsSync(fc0)) { console.error(`cannot find ${fs0} or ${fc0}`); process.exit(1); }
const c = JSON.parse(fs.readFileSync(fc0, 'utf8')); c.version = version;
fs.writeFileSync(sp, fs.readFileSync(fs0, 'utf8').replace(new RegExp(`\\b${args.from}\\b`, 'g'), version), 'utf8');
fs.writeFileSync(cp, JSON.stringify(c, null, 2), 'utf8');
console.log(`wrote ${sp}\nwrote ${cp}\nwrote ${hp}\nNext: edit both files, then node check.js --dir ${dir} --version ${version} --prev ${args.from}. Add a "What changed from ${args.from}" section and record corrections in the ledger.`);
process.exit(0);
}
const heading = (i) => (i === 0 ? '### [B0] Hook (0:00 to 1:50)' : i === n ? `### [B${i}] Close` : `### [B${i}] Beat ${i} title`);
const md = [`# ${title}: video package ${version}`, '',
'## 1. Packaging', '', '**Working title:** ', '', '- Alternates that match the evidence:', '- The one word that outruns the proof (if any), and what not to put in the title:', '', '**Thumbnail:** ', '',
'## 2. The spine, the voice, the cast, the rhythm', '',
'| | |', '|---|---|', '| **Core driving loop** | |', '| **Destination hint (in the hook)** | |', '| **Thesis, one line** | |', '| **Named idea / governing metaphor** | |', '| **Promise ladder** | |', '| **Hero numbers (7 at most), each followed by a landing** | |', '',
'**The voice.** (earnest about ..., aloof about ...)', '', '**The figures.** (one governing metaphor with its limit; glosses by analogy; parables)', '', '**The cast.** (protagonist with a want; counterpart; witness who pays; you)', '', '**The rhythm.** (hard and quiet windows; landings; the close)', '', '**The chain.**', '',
'## 3. Script', '', ...Array.from({ length: n + 1 }, (_, i) => `${heading(i)}\n**VO:** \n`),
'## 4. What changed, and why', '', '## 5. Fact ledger', '', '### Facts', '', '| Claim | Status | Source |', '|---|---|---|', '',
'### Illustrations and figures (none of these is a fact claim)', '', '| Item | Kind | Note |', '|---|---|---|', '',
'## 6. Claims from the YouTube videos you must NOT use', '', '| Claim | Why |', '|---|---|', '',
'## 7. Open items before filming', '', '1. ', ''].join('\n');
const beat = (i) => {
const b = { id: `B${i}`, title: i === 0 ? 'Hook' : i === n ? 'Close' : `Beat ${i}`, arc: [i === 0 ? 'hook' : ''] };
if (i > 0) Object.assign(b, { understand: '', care: '', care_type: 'viewer', care_quote: '', change: '' });
if (i < n) Object.assign(b, { compel: '', compel_quote: '', link_to_next: 'THEREFORE', link_why: '', next_opens_with: '' });
else b.closes_spine = true;
return b;
};
const chain = {
version, title,
cdl: { packaging_question: '', question: '', viewer_prior: '', destination_hint: '', destination_quote: '', question_quote: '', first_payoff_quote: '', withheld: [], closed_in: `B${n - 2}`, candidates: [],
hook_elements: { person_quote: '', rise_quote: '', turn_quote: '', fall_quote: '', viewer_quote: '', learning_quote: '', tease_quote: '', takeaway_quote: '', forward_quotes: ['', '', ''] } },
ladder: [{ rung: 1, promise: '', payoff_beat: '', payoff_quote: '' }, { rung: 2, promise: '', seed_quote: '', payoff_beat: '', payoff_quote: '' }, { rung: 3, promise: '', seed_quote: '', payoff_beat: '', payoff_quote: '' }],
bonuses: [{ what: '', beat: '', quote: '' }],
peak_reveal: { beat: '', quote: '' },
hero_numbers: [{ id: 'N1', beat: '', value: '', stake_quote: '', comparator_quote: '', landing_quote: '' }],
humans: [{ id: 'H1', name: '', role: 'protagonist', aliases: [], tags: [''], want_quote: '', detail_quote: '', stake_quote: '' }, { id: 'H2', name: '', role: 'witness', aliases: [], tags: [''], want_quote: '', detail_quote: '', stake_quote: '', cost_quote: '' }],
audience: { known_terms: [] }, not_cast: [], one_off_names: [], concepts: [], counted_sets: [],
viewer_stakes: [{ beat: 'B0', kind: 'learning', quote: '' }, { beat: '', kind: 'mirror', quote: '' }, { beat: `B${n}`, kind: 'learning', quote: '' }],
persona: { stance: 'earnest about facts and people, aloof about verdicts and heroes', earnest: [{ beat: '', quote: '' }], aloof: [{ beat: '', quote: '' }], sides: [{ subject: '', beat: '', for_quote: '', against_quote: '' }] },
figures: [{ id: 'F1', kind: 'governing', beat: '', source: '', target: '', quote: '', maps: '', touches: [{ beat: '', quote: '' }], breaks: { beat: '', quote: '' } }, { id: 'F2', kind: 'gloss', term: '', beat: '', source: '', quote: '' }, { id: 'F3', kind: 'parable', beat: '', source: '', quote: 'Say you ', end_quote: '' }],
jargon: [],
pulls: [{ id: 'P1', type: 'motif', beat: '', signal_quote: '', touches: [{ beat: '', quote: '' }, { beat: '', quote: '' }], resolve: { beat: '', quote: '' } }],
loops: [{ id: 'L0', kind: 'spine', question: '', opened_in: 'B0', open_quote: '', closed_in: '', close_quote: '' }],
beats: Array.from({ length: n + 1 }, (_, i) => beat(i)),
};
fs.writeFileSync(sp, md, 'utf8');
fs.writeFileSync(cp, JSON.stringify(chain, null, 2), 'utf8');
console.log(`wrote ${sp}\nwrote ${cp}\nwrote ${hp}\n${fs.existsSync(fp) ? 'FACTS.md ready: ' + fp : ''}\nNext: fill FACTS.md (Step 1), then the chain (Steps 2 and 3), then the voice-over (Step 4).`);
Shown as it is in the repository at build time (2026-11-03); the home directory appears as <home>. If this page and the file differ, the file is right. Back to the catalogue.