Docs/Skills and code/skill video-script-production

.claude/skills/video-script-production/scripts/check.js

Code185 lines15.7 KBSkill: video-script-production

What the file says about itself

The gate runner: one command that says whether a script version meets the v6 bar and what to do next. node check.js --dir <scriptDir> --version v7 [--prev v6] [--fix] [--no-selftest] [--facts FACTS.md] Runs, in order: tool integrity (no…

Mentioned on: page 06, page 08, page 09, page 10, page 12, page 13, page in

#!/usr/bin/env node
// The gate runner: one command that says whether a script version meets the v6 bar and what to do next.
//   node check.js --dir <scriptDir> --version v7 [--prev v6] [--fix] [--no-selftest] [--facts FACTS.md]
// Runs, in order: tool integrity (no stray control characters), chain JSON, lint, damage test, ledger check, timecodes, the v6 bar,
// the comparison with the previous version, and reads the manual gates from HANDOFF-<version>.md. Writes <dir>/STATUS-<version>.md and <dir>/lint/.
//   --fix           rewrite stale timecodes in the headings (retime --write) before checking
//   --no-selftest   skip the damage test (use it while drafting; run it before you stop)
// Exit 0 only when every hard gate passes. "BAR MET" additionally needs zero warnings, the bar numbers, and the manual gates marked done.
// Files expected in <dir>: SCRIPT-<version>.md, chain-<version>.json. Optional: HANDOFF-<version>.md, FACTS.md. Parent folder may hold report.md, teardown/ (used only to say which stage is reached).

const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');

const args = {};
for (let i = 2; i < process.argv.length; i++) {
  const a = process.argv[i]; if (!a.startsWith('--')) continue;
  const k = a.slice(2), nxt = process.argv[i + 1];
  if (nxt && !nxt.startsWith('--')) { args[k] = nxt; i++; } else args[k] = true;
}
if (!args.dir || !args.version) { console.error('usage: node check.js --dir <scriptDir> --version v7 [--prev v6] [--fix] [--no-selftest] [--facts FACTS.md]'); process.exit(2); }
const dir = path.resolve(args.dir), V = args.version, P = args.prev && args.prev !== true ? args.prev : null;
// --lang hi: the Hinglish script (lint.js reads lang-hi.json). --english <version>: the English script it must match (gate P1, hinglish-parity.js).
const LANG = args.lang && args.lang !== true ? String(args.lang) : null;
const ENG = args.english && args.english !== true ? String(args.english) : null;
const S = __dirname;
const rules = JSON.parse(fs.readFileSync(path.join(S, 'rules.json'), 'utf8'));
let bar = rules.bar;
const script = path.join(dir, `SCRIPT-${V}.md`), chain = path.join(dir, `chain-${V}.json`), lintDir = path.join(dir, 'lint');
const handoff = path.join(dir, `HANDOFF-${V}.md`);
const facts = args.facts && args.facts !== true ? path.resolve(args.facts) : (fs.existsSync(path.join(dir, 'FACTS.md')) ? path.join(dir, 'FACTS.md') : null);
const parent = path.dirname(dir);
const run = (file, a) => spawnSync(process.execPath, [path.join(S, file), ...a], { encoding: 'utf8', maxBuffer: 1 << 26 });

const gates = []; // {id, name, hard, ok, detail}
const gate = (id, name, hard, ok, detail) => gates.push({ id, name, hard, ok, detail });
const exists = (f) => fs.existsSync(f);

// ---------- stage reached (from the files that exist)
const stage = (() => {
  const s = [];
  if (exists(path.join(parent, 'report.md')) || exists(path.join(parent, 'synthesis.json'))) s.push(['S1', 'research brief from rival videos']);
  if (exists(path.join(parent, 'teardown', 'SYNTHESIS.md'))) s.push(['S2', 'teardown of rival videos']);
  {
    const sourced = (txt) => (txt.match(/\[V(?:-search)?\]|\[CHECK\]/g) || []).length;
    const t = (facts ? fs.readFileSync(facts, 'utf8') : '') + (exists(script) ? fs.readFileSync(script, 'utf8') : '');
    if (sourced(t) >= 5) s.push(['S3', 'fact base / ledger (5 or more sourced rows)']);
  }
  if (exists(chain)) {
    try {
      const c = JSON.parse(fs.readFileSync(chain, 'utf8'));
      if (((c.cdl || {}).candidates || []).some((x) => x.chosen) && c.cdl.question) s.push(['S4', 'core driving loop chosen']);
      if ((c.humans || []).some((h) => h.name) && (c.figures || []).some((f) => f.quote && f.quote.trim() !== 'Say you') && ((c.persona || {}).earnest || []).some((e) => e.quote)) s.push(['S5', 'texture plan declared']);
    } catch { /* reported below */ }
  }
  if (exists(script)) {
    const vo = (fs.readFileSync(script, 'utf8').match(/^\*\*VO:\*\*.*$/gm) || []).join(' ').replace(/\*\*VO:\*\*/g, '');
    if ((vo.match(/\S+/g) || []).length > 600) s.push(['S6', 'voice-over drafted (600+ words)']);
  }
  return s;
})();

// ---------- not started
if (!exists(script) || !exists(chain)) {
  console.log(`Nothing to check yet: ${exists(script) ? '' : script + ' is missing. '}${exists(chain) ? '' : chain + ' is missing.'}`);
  console.log(`Stages reached: ${stage.map((x) => x[0]).join(', ') || 'none'}. Start with: node scaffold.js --dir ${args.dir} --version ${V}${P ? ' --from ' + P : ''}`);
  process.exit(1);
}

// ---------- 1. tool and file integrity
const ctl = (f) => { const t = fs.readFileSync(f, 'utf8'); const bad = []; for (let i = 0; i < t.length; i++) { const c = t.charCodeAt(i); if (c < 32 && c !== 9 && c !== 10 && c !== 13) bad.push(`0x${c.toString(16)} at char ${i}`); } return bad; };
const toolFiles = ['lint.js', 'selftest.js', 'rules.json', 'voice-patterns.json', 'lang-hi.json', 'check.js', 'ledger-check.js', 'retime.js', 'extract-vo.js', 'calibrate.js', 'compare.js', 'scaffold.js'].map((f) => path.join(S, f)).filter(exists);
const ctlBad = [...toolFiles, script, chain].flatMap((f) => ctl(f).map((b) => `${path.basename(f)}: ${b}`));
let chainJson = null, jsonErr = '';
try { chainJson = JSON.parse(fs.readFileSync(chain, 'utf8')); } catch (e) { jsonErr = e.message; }
gate('T1', 'Tool and file integrity: chain is valid JSON, no stray control characters (a swallowed backslash shows up as one)', true, !jsonErr && !ctlBad.length, jsonErr || ctlBad.slice(0, 4).join('; ') || 'ok');

// ---------- 1a. a chain may override the hook length of the bar (hook_sec only), if it says why. rules.json is never touched.
{
  const ov = chainJson && chainJson.bar_overrides;
  if (ov) {
    const { reason, ...nums } = ov;
    const okKeys = Object.keys(nums).every((k) => k === 'hook_sec') && (!nums.hook_sec || (Array.isArray(nums.hook_sec) && nums.hook_sec.length === 2 && nums.hook_sec[0] < nums.hook_sec[1]));
    const okReason = typeof reason === 'string' && reason.trim().length >= 15;
    gate('T4', 'The chain overrides the bar only for the hook length, and gives the reason (a quiet loosening would be gaming the bar)', true, okKeys && okReason, okKeys && okReason ? `hook_sec ${JSON.stringify(nums.hook_sec)}: ${reason.trim().slice(0, 120)}` : (okKeys ? 'bar_overrides.reason is missing or under 15 characters' : 'bar_overrides may set hook_sec only, as [min, max] seconds'));
    if (okKeys && okReason) bar = { ...rules.bar, ...nums };
  }
}

// ---------- 1b. the skill's own documentation still points at files that exist
{
  const skillDir = path.join(S, '..'), docs = [path.join(skillDir, 'SKILL.md')];
  const refDir = path.join(skillDir, 'references');
  if (exists(refDir)) for (const f of fs.readdirSync(refDir)) if (f.endsWith('.md')) docs.push(path.join(refDir, f));
  const missing = new Set();
  for (const f of docs.filter(exists)) for (const m of fs.readFileSync(f, 'utf8').matchAll(/`((?:references|scripts|examples)\/[A-Za-z0-9_.\-\/]+\.(?:md|js|json))`/g)) if (!exists(path.join(skillDir, m[1]))) missing.add(`${path.basename(f)} -> ${m[1]}`);
  gate('T3', 'The skill documentation points only at files that exist (a renamed file would leave a stale instruction)', true, missing.size === 0, [...missing].slice(0, 5).join('; ') || 'ok');
}

// ---------- 2. timecodes
if (args.fix) run('retime.js', ['--script', script, '--write']);
const rt = run('retime.js', ['--script', script, '--check']);
gate('T2', 'Heading timecodes match the word counts (fix with --fix)', false, rt.status === 0, rt.status === 0 ? 'current' : (rt.stdout.split('\n').slice(0, 3).join(' | ')));

// ---------- 3. lint
fs.mkdirSync(lintDir, { recursive: true });
const li = run('lint.js', ['--script', script, '--chain', chain, '--label', V, '--out', lintDir, ...(LANG ? ['--lang', LANG] : [])]);
let L = null; try { L = JSON.parse(fs.readFileSync(path.join(lintDir, `lint-${V}.json`), 'utf8')); } catch { /* handled below */ }
if (!L) gate('L1', 'Lint ran', true, false, /no spoken text/.test(li.stderr) ? 'no voice-over yet: write the **VO:** lines (SKILL.md Step 4)' : (li.stderr || li.stdout).slice(0, 200));
else {
  const sm = L.summary, fails = L.results.filter((r) => r.status === 'FAIL'), warns = L.results.filter((r) => r.status === 'WARN');
  gate('L1', `Lint: no FAIL on ${L.results.length} rules (score ${(sm.score * 100).toFixed(0)}/100)`, true, fails.length === 0, fails.map((r) => `${r.id}: ${r.detail.split('\n')[0]}`).slice(0, 4).join(' | ') || 'ok');
  gate('L2', 'Lint: no WARN (the bar is zero; read each warning, some are false alarms, decide, do not skip)', false, warns.length <= bar.warnings_max, warns.map((r) => `${r.id}: ${r.detail.split('\n')[0]}`).slice(0, 4).join(' | ') || 'ok');
  // ---------- 6. the bar
  const t = sm.texture || {}, v = sm.voice || {};
  const hookBeat = L.results.find((r) => r.id === 'B2');
  const hookSec = hookBeat ? Number((hookBeat.detail.match(/hook (\d+)s/) || [])[1]) : NaN;
  const checks = [
    ...(LANG === 'hi' ? [] : [['contractions per 100 words >= ' + bar.contractions_per_100_min, v.contractions_per_100 >= bar.contractions_per_100_min, v.contractions_per_100]]), // Hinglish has no contractions (lang-hi.json)
    ['quantities per minute <= ' + bar.quantities_per_min_max, sm.quantities_per_min <= bar.quantities_per_min_max, sm.quantities_per_min],
    ['hook ' + bar.hook_sec.join(' to ') + ' s' + (bar !== rules.bar ? ' (chain override)' : ''), Number.isNaN(hookSec) || (hookSec >= bar.hook_sec[0] && hookSec <= bar.hook_sec[1]), Number.isNaN(hookSec) ? 'n/a' : hookSec],
    ['stretch with no person or "you" <= ' + bar.person_gap_sec_max + ' s', t.person_gap_sec == null || t.person_gap_sec <= bar.person_gap_sec_max, t.person_gap_sec == null ? 'n/a (no humans declared)' : t.person_gap_sec],
    ['hard 30 s windows <= ' + bar.hard_share_max * 100 + '%', t.hard_share <= bar.hard_share_max, t.hard_share],
    ['comparison markers ' + bar.figure_markers.join(' to '), t.figure_markers >= bar.figure_markers[0] && t.figure_markers <= bar.figure_markers[1], t.figure_markers],
  ];
  gate('B1', 'The v6 bar numbers: ' + checks.map((c) => `${c[0]} [${c[1] ? 'ok' : 'NO'}: ${c[2]}]`).join('; '), false, checks.every((c) => c[1]), checks.filter((c) => !c[1]).map((c) => c[0] + ' got ' + c[2]).join('; ') || 'ok');
}

// ---------- 4. damage test
if (!args['no-selftest']) {
  const st = run('selftest.js', ['--script', script, '--chain', chain, ...(LANG ? ['--lang', LANG] : [])]);
  const m = (st.stdout.match(/all (\d+) checks behaved(?: \((\d+) skipped)?/) || []);
  gate('D1', 'Damage test: every rule fires when its property is broken', true, st.status === 0, st.status === 0 ? `${m[1]} checks behaved${m[2] ? ', ' + m[2] + ' skipped (sections not declared)' : ''}` : st.stdout.split('\n').filter((l) => /MISS|ERROR|problem/.test(l)).slice(0, 3).join(' | '));
} else gate('D1', 'Damage test (skipped with --no-selftest; run it before you stop)', false, false, 'skipped');

// ---------- 5. ledger
const lc = run('ledger-check.js', ['--script', script, '--chain', chain, ...(facts ? ['--facts', facts] : []), ...(ENG && exists(path.join(dir, `SCRIPT-${ENG}.md`)) ? ['--ledger-from', path.join(dir, `SCRIPT-${ENG}.md`)] : []), '--strict']);
gate('F1', 'Fact ledger: every spoken figure is in the ledger; packaging, ledger, do-not-use and open-items sections exist', true, lc.status === 0, lc.stdout.split('\n').filter((l) => /MISSING|PROBLEM|WARN/.test(l)).slice(0, 4).join(' | ') || 'ok');

// ---------- 7. comparison
let cmp = '';
if (P && L) {
  const prevLint = path.join(lintDir, `lint-${P}.json`), prevScript = path.join(dir, `SCRIPT-${P}.md`), prevChain = path.join(dir, `chain-${P}.json`);
  if (!exists(prevLint) && exists(prevScript)) run('lint.js', ['--script', prevScript, ...(exists(prevChain) ? ['--chain', prevChain] : []), '--label', P, '--out', lintDir]);
  if (exists(prevLint) && exists(prevScript)) { const out = path.join(lintDir, `compare-${P}-${V}.md`); const c = run('compare.js', ['--a', prevLint, '--b', path.join(lintDir, `lint-${V}.json`), '--sa', prevScript, '--sb', script, '--out', out]); cmp = c.status === 0 ? out : ''; }
}

// ---------- 8. manual gates (recorded in HANDOFF-<version>.md)
const H = exists(handoff) ? fs.readFileSync(handoff, 'utf8') : '';
const line = (re) => (H.match(re) || [])[0] || '';
gate('M1', 'Read-aloud pass done (references/read-aloud-checklist.md); record "Read-aloud pass: done" in the hand-off file', false, /^Read-aloud pass:\s*done/mi.test(H), line(/^Read-aloud pass:.*$/mi) || 'not recorded');
gate('M2', 'Reader pass done, or skipped with a reason (it needs subagents, which only the user can authorise)', false, /^Reader pass:\s*(done|skipped)/mi.test(H), line(/^Reader pass:.*$/mi) || 'not recorded');
gate('M3', 'A plain statement of what is unproven, written on the "Unproven:" line of the hand-off file', false, /^Unproven:[ \t]*\S.{15,}/mi.test(H), !H ? 'no hand-off file (scaffold writes one)' : /^Unproven:[ \t]*\S.{15,}/mi.test(H) ? 'written' : 'the "Unproven:" line is empty or too short');

// ---------- 9. parity with the English script (Hinglish and other translations)
if (ENG) {
  const eng = path.join(dir, `SCRIPT-${ENG}.md`);
  if (!exists(eng)) gate('P1', `Parity with ${ENG}`, true, false, `${eng} is missing`);
  else {
    const pr = run('hinglish-parity.js', ['--english', eng, '--hinglish', script]);
    const why = (pr.stdout + pr.stderr).split('\n').filter((l) => /^\s+- /.test(l)).slice(0, 4).join(' | ');
    gate('P1', `Parity with ${ENG}: same beats, paragraphs, digits, question marks, quotes, names and graphics`, true, pr.status === 0, pr.status === 0 ? 'PARITY OK' : (why || 'parity failed; run hinglish-parity.js for the list'));
  }
}

// ---------- report
const hardFail = gates.filter((g) => g.hard && !g.ok), soft = gates.filter((g) => !g.hard && !g.ok);
const verdict = hardFail.length ? 'HARD GATES FAILING' : soft.length ? 'HARD GATES PASS, BAR NOT YET MET' : 'BAR MET';
const next = hardFail.length ? 'Fix the failing hard gates first (references/lint-fixes.md lists the usual causes), then re-run this command.'
  : soft.length ? 'Clear the remaining soft gates: warnings, bar numbers, timecodes (--fix), the read-aloud pass and the reader decision.'
  : 'Write the hand-off message (references/handoff-template.md), run session-log.js, update learnings.md and tools.md, and stop.';
const md = [`# Status: ${V}`, '', `**${verdict}.** ${next}`, '',
  `Stages reached (from files present): ${stage.map((x) => `${x[0]} ${x[1]}`).join('; ') || 'none'}.`, '',
  '| Gate | Kind | Result | Detail |', '|---|---|---|---|',
  ...gates.map((g) => `| ${g.id} ${g.name.split(':')[0]} | ${g.hard ? 'hard' : 'bar'} | ${g.ok ? 'pass' : 'NOT YET'} | ${String(g.detail).replace(/\|/g, '/').slice(0, 300)} |`), '',
  ...(L ? ['## Measures', '', `${L.summary.words} words, ${L.summary.minutes} min, ${L.summary.beats} beats; ${L.summary.quantities} quantities (${L.summary.quantities_per_min}/min); ${JSON.stringify(L.summary.texture)}; voice ${JSON.stringify(L.summary.voice)}`, ''] : []),
  ...(cmp ? [`Comparison with ${P}: ${path.relative(dir, cmp)}`, ''] : []),
  'Judgement the machine cannot make (do these, then record them in the hand-off file): does each care line land; does the persona read as likeable or smug; is every "I" true of whoever records; are the title and the evidence in agreement; would a stranger follow the first two minutes.', ''].join('\n');
fs.writeFileSync(path.join(dir, `STATUS-${V}.md`), md, 'utf8');

console.log(`\n${V}: ${verdict}\n`);
for (const g of gates) console.log(`${g.ok ? ' ok ' : (g.hard ? 'FAIL' : ' -- ')}  ${g.id}  ${g.name.slice(0, 110)}${g.ok ? '' : '\n         ' + String(g.detail).slice(0, 300)}`);
console.log(`\nstages reached: ${stage.map((x) => x[0]).join(' ') || 'none'}   status file: ${path.join(args.dir, `STATUS-${V}.md`)}${cmp ? '\ncomparison: ' + cmp : ''}\nnext: ${next}`);
process.exit(hardFail.length ? 1 : 0);

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.