explainer-video 0.0.0-stage → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/.claude-plugin/plugin.json +12 -0
  2. package/LICENSE +21 -0
  3. package/README.md +154 -2
  4. package/cordis.patch.yml +7 -0
  5. package/lib/index.js +35 -0
  6. package/package.json +56 -4
  7. package/plugin.json +13 -0
  8. package/skills/coursework-author/.synced-from +5 -0
  9. package/skills/coursework-author/SKILL.md +209 -0
  10. package/skills/craft-codeflow-flows/SKILL.md +116 -0
  11. package/skills/craft-codeflow-mine/SKILL.md +207 -0
  12. package/skills/craft-codeflow-plan/SKILL.md +65 -0
  13. package/skills/craft-codeflow-scene/SKILL.md +168 -0
  14. package/skills/craft-video-align/SKILL.md +109 -0
  15. package/skills/craft-video-assemble/SKILL.md +70 -0
  16. package/skills/craft-video-audio/SKILL.md +59 -0
  17. package/skills/craft-video-enrich/SKILL.md +46 -0
  18. package/skills/craft-video-plan/SKILL.md +57 -0
  19. package/skills/craft-video-publish/SKILL.md +79 -0
  20. package/skills/craft-video-render/SKILL.md +68 -0
  21. package/skills/craft-video-scene/SKILL.md +112 -0
  22. package/skills/craft-video-script/SKILL.md +80 -0
  23. package/skills/craft-video-thumbnail/SKILL.md +168 -0
  24. package/skills/explainer-video-from-codebase/FLOWS.md +577 -0
  25. package/skills/explainer-video-from-codebase/SKILL.md +698 -0
  26. package/skills/explainer-video-from-codebase/tools/apply-flows.mjs +531 -0
  27. package/skills/explainer-video-from-codebase/tools/approve-flows.mjs +56 -0
  28. package/skills/explainer-video-from-codebase/tools/codemap-md.mjs +116 -0
  29. package/skills/explainer-video-from-codebase/tools/config/dotenv.mjs +13 -0
  30. package/skills/explainer-video-from-codebase/tools/config/json.mjs +14 -0
  31. package/skills/explainer-video-from-codebase/tools/config/redact.mjs +16 -0
  32. package/skills/explainer-video-from-codebase/tools/config/toml-lite.mjs +13 -0
  33. package/skills/explainer-video-from-codebase/tools/config/yaml-lite.mjs +18 -0
  34. package/skills/explainer-video-from-codebase/tools/excerpt-check.mjs +186 -0
  35. package/skills/explainer-video-from-codebase/tools/excerpt.mjs +116 -0
  36. package/skills/explainer-video-from-codebase/tools/flow-diagram.mjs +168 -0
  37. package/skills/explainer-video-from-codebase/tools/flow-plan.mjs +129 -0
  38. package/skills/explainer-video-from-codebase/tools/flows-md.mjs +87 -0
  39. package/skills/explainer-video-from-codebase/tools/lang/c.mjs +684 -0
  40. package/skills/explainer-video-from-codebase/tools/lang/go.mjs +673 -0
  41. package/skills/explainer-video-from-codebase/tools/lang/python-config.mjs +143 -0
  42. package/skills/explainer-video-from-codebase/tools/lang/python-deps.mjs +62 -0
  43. package/skills/explainer-video-from-codebase/tools/lang/python-stdlib.mjs +24 -0
  44. package/skills/explainer-video-from-codebase/tools/lang/python.mjs +609 -0
  45. package/skills/explainer-video-from-codebase/tools/lang/typescript.mjs +963 -0
  46. package/skills/explainer-video-from-codebase/tools/lib/decompose.mjs +102 -0
  47. package/skills/explainer-video-from-codebase/tools/lib/groups.mjs +77 -0
  48. package/skills/explainer-video-from-codebase/tools/lib/observe-coverage.mjs +68 -0
  49. package/skills/explainer-video-from-codebase/tools/lib/observe-v8.mjs +232 -0
  50. package/skills/explainer-video-from-codebase/tools/lib/observe.mjs +162 -0
  51. package/skills/explainer-video-from-codebase/tools/lib/redact.mjs +28 -0
  52. package/skills/explainer-video-from-codebase/tools/lib/sandbox.mjs +97 -0
  53. package/skills/explainer-video-from-codebase/tools/lib/test-links.mjs +28 -0
  54. package/skills/explainer-video-from-codebase/tools/lib/toml.mjs +129 -0
  55. package/skills/explainer-video-from-codebase/tools/lib/trace-c.mjs +95 -0
  56. package/skills/explainer-video-from-codebase/tools/lib/trace-go.mjs +101 -0
  57. package/skills/explainer-video-from-codebase/tools/lib/trace-js.mjs +136 -0
  58. package/skills/explainer-video-from-codebase/tools/lib/tree-sitter.mjs +19 -0
  59. package/skills/explainer-video-from-codebase/tools/lib/walk.mjs +138 -0
  60. package/skills/explainer-video-from-codebase/tools/mine.mjs +409 -0
  61. package/skills/explainer-video-from-codebase/tools/propose.mjs +127 -0
  62. package/skills/explainer-video-from-codebase/tools/rank.mjs +119 -0
  63. package/skills/explainer-video-from-codebase/tools/ranking-md.mjs +76 -0
  64. package/skills/explainer-video-from-codebase/tools/rules/c-handlers.mjs +53 -0
  65. package/skills/explainer-video-from-codebase/tools/rules/celery-taskiq.mjs +48 -0
  66. package/skills/explainer-video-from-codebase/tools/rules/click-typer.mjs +6 -0
  67. package/skills/explainer-video-from-codebase/tools/rules/express.mjs +72 -0
  68. package/skills/explainer-video-from-codebase/tools/rules/fastapi.mjs +99 -0
  69. package/skills/explainer-video-from-codebase/tools/rules/go-http.mjs +173 -0
  70. package/skills/explainer-video-from-codebase/tools/rules/go-test.mjs +108 -0
  71. package/skills/explainer-video-from-codebase/tools/rules/grpc.mjs +57 -0
  72. package/skills/explainer-video-from-codebase/tools/rules/gtest.mjs +97 -0
  73. package/skills/explainer-video-from-codebase/tools/rules/haystack.mjs +55 -0
  74. package/skills/explainer-video-from-codebase/tools/rules/jest-vitest.mjs +100 -0
  75. package/skills/explainer-video-from-codebase/tools/rules/js-http-client.mjs +111 -0
  76. package/skills/explainer-video-from-codebase/tools/rules/nextjs.mjs +53 -0
  77. package/skills/explainer-video-from-codebase/tools/rules/process-spawn.mjs +49 -0
  78. package/skills/explainer-video-from-codebase/tools/rules/py-http-client.mjs +26 -0
  79. package/skills/explainer-video-from-codebase/tools/rules/pytest.mjs +107 -0
  80. package/skills/explainer-video-from-codebase/tools/rules/react-router.mjs +17 -0
  81. package/skills/explainer-video-from-codebase/tools/rules/registry.mjs +31 -0
  82. package/skills/explainer-video-from-codebase/tools/trace/c/fmtrace-sh +14 -0
  83. package/skills/explainer-video-from-codebase/tools/trace/js/fmtrace-net.cjs +33 -0
  84. package/skills/explainer-video-from-codebase/tools/trace/js/fmtrace-srcmap.cjs +41 -0
  85. package/skills/explainer-video-from-codebase/tools/trace/python/fmtrace_plugin.py +87 -0
  86. package/skills/explainer-video-from-codebase/tools/trace/python/sitecustomize.py +330 -0
  87. package/skills/explainer-video-from-codebase/tools/trace/sandbox/linux-bwrap.sh +73 -0
  88. package/skills/explainer-video-from-codebase/tools/trace/sandbox/macos-nonet.sb +41 -0
  89. package/skills/explainer-video-from-codebase/tools/trace.mjs +577 -0
  90. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-c.txt +21 -0
  91. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-cpp.txt +21 -0
  92. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-go.txt +21 -0
  93. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-javascript.txt +21 -0
  94. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-python.txt +21 -0
  95. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-typescript.txt +21 -0
  96. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/web-tree-sitter.txt +21 -0
  97. package/skills/explainer-video-from-codebase/vendor/tree-sitter/MANIFEST.json +172 -0
  98. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-c.wasm +0 -0
  99. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-cpp.wasm +0 -0
  100. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-go.wasm +0 -0
  101. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-javascript.wasm +0 -0
  102. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-python.wasm +0 -0
  103. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-tsx.wasm +0 -0
  104. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-typescript.wasm +0 -0
  105. package/skills/explainer-video-from-codebase/vendor/tree-sitter/web-tree-sitter.js +4081 -0
  106. package/skills/explainer-video-from-codebase/vendor/tree-sitter/web-tree-sitter.wasm +0 -0
  107. package/skills/explainer-video-from-coursework/ARCHITECTURE.md +61 -0
  108. package/skills/explainer-video-from-coursework/PIPELINE.md +369 -0
  109. package/skills/explainer-video-from-coursework/ROADMAP.md +727 -0
  110. package/skills/explainer-video-from-coursework/SERIES.md +243 -0
  111. package/skills/explainer-video-from-coursework/SETUP.md +105 -0
  112. package/skills/explainer-video-from-coursework/SKILL.md +998 -0
  113. package/skills/explainer-video-from-coursework/scenes/_pt-chart.js +12 -0
  114. package/skills/explainer-video-from-coursework/scenes/_pt-equation.js +10 -0
  115. package/skills/explainer-video-from-coursework/scenes/_pt-hubspoke.js +7 -0
  116. package/skills/explainer-video-from-coursework/scenes/_pt-loopflow.js +7 -0
  117. package/skills/explainer-video-from-coursework/scenes/_pt-sequence.js +12 -0
  118. package/skills/explainer-video-from-coursework/scenes/_pt-timeline.js +11 -0
  119. package/skills/explainer-video-from-coursework/src/scene-code.html +127 -0
  120. package/skills/explainer-video-from-coursework/src/scene-code.js +379 -0
  121. package/skills/explainer-video-from-coursework/src/scene-core.js +1250 -0
  122. package/skills/explainer-video-from-coursework/src/scene-diagram.html +50 -0
  123. package/skills/explainer-video-from-coursework/src/scene-svg.html +50 -0
  124. package/skills/explainer-video-from-coursework/src/thumbnail.html +119 -0
  125. package/skills/explainer-video-from-coursework/src/timeline.js +297 -0
  126. package/skills/explainer-video-from-coursework/src/timing-chart.js +160 -0
  127. package/skills/explainer-video-from-coursework/tools/align.mjs +419 -0
  128. package/skills/explainer-video-from-coursework/tools/apply-enrichment.mjs +206 -0
  129. package/skills/explainer-video-from-coursework/tools/assemble.mjs +223 -0
  130. package/skills/explainer-video-from-coursework/tools/build-audio.mjs +234 -0
  131. package/skills/explainer-video-from-coursework/tools/chromium.mjs +73 -0
  132. package/skills/explainer-video-from-coursework/tools/debug-page.mjs +75 -0
  133. package/skills/explainer-video-from-coursework/tools/env.mjs +30 -0
  134. package/skills/explainer-video-from-coursework/tools/frame-dedup.mjs +131 -0
  135. package/skills/explainer-video-from-coursework/tools/key-source.mjs +151 -0
  136. package/skills/explainer-video-from-coursework/tools/model.mjs +21 -0
  137. package/skills/explainer-video-from-coursework/tools/naming.mjs +38 -0
  138. package/skills/explainer-video-from-coursework/tools/onset-audit.mjs +51 -0
  139. package/skills/explainer-video-from-coursework/tools/pacing-check.mjs +350 -0
  140. package/skills/explainer-video-from-coursework/tools/planner.mjs +616 -0
  141. package/skills/explainer-video-from-coursework/tools/publish.mjs +230 -0
  142. package/skills/explainer-video-from-coursework/tools/render.mjs +205 -0
  143. package/skills/explainer-video-from-coursework/tools/resolve-beats.mjs +239 -0
  144. package/skills/explainer-video-from-coursework/tools/script-check.mjs +174 -0
  145. package/skills/explainer-video-from-coursework/tools/stamp.mjs +71 -0
  146. package/skills/explainer-video-from-coursework/tools/thumbnail.mjs +224 -0
  147. package/skills/explainer-video-from-coursework/tools/timing-report.mjs +299 -0
  148. package/skills/explainer-video-from-coursework/tools/transcribe-gemini.mjs +96 -0
  149. package/skills/explainer-video-from-coursework/tools/tts-gemini.mjs +394 -0
  150. package/skills/explainer-video-from-coursework/tools/verify-audio.mjs +88 -0
  151. package/skills/explainer-video-from-coursework/tools/workdir.mjs +124 -0
  152. package/skills/explainer-video-from-coursework/vendor/plotly-basic.min.js +8 -0
  153. package/skills/explainer-video-from-coursework/vendor/plotly-basic.min.js.LICENSE.txt +16 -0
@@ -0,0 +1,616 @@
1
+ #!/usr/bin/env node
2
+ /* =============================================================================
3
+ planner.mjs — turn a directory of source notes into a VIDEO SERIES PLAN.
4
+
5
+ This is the stage that decides HOW MANY videos a body of content needs, in
6
+ what form, in what order — and proves nothing was silently dropped.
7
+
8
+ Usage:
9
+ node tools/planner.mjs <sourceDir> --out out/<name> [--max-min 25] [--title "..."]
10
+
11
+ Pipeline position:
12
+ source notes -> [PLANNER] -> plan.json -> script -> scene -> audio -> video
13
+
14
+ Design rules:
15
+ • Every number is derived and printed, so the plan can be argued with.
16
+ • Coverage is ASSERTED, not assumed: every source section lands on a video
17
+ or is excluded with a stated reason. Orphans are reported as failures.
18
+ • Form is inferred from evidence (code ratio, link ratio, diagram markers)
19
+ and the evidence is emitted alongside the verdict.
20
+ ========================================================================== */
21
+
22
+ import { readdirSync, readFileSync, statSync, mkdirSync, writeFileSync, existsSync } from 'node:fs';
23
+ import { join, relative, extname, basename, sep, dirname, resolve } from 'node:path';
24
+ import { execFileSync } from 'node:child_process';
25
+ import { WPM, EXPANSION } from './model.mjs';
26
+
27
+ /* ---------------- tunable model ---------------- */
28
+ const DEFAULTS = {
29
+ wpm: WPM, // narration words per minute — measured, see model.mjs
30
+ expansion: EXPANSION, // visual/beat overhead on top of pure narration time — measured
31
+ maxMin: 25, // per-video hard cap (aligned to the 30-min transcribe ceiling)
32
+ minMin: 2.5, // below this, try to merge with a sibling
33
+ codeNarration: 0.15, // fraction of code words that get narrated (you show, then explain briefly)
34
+ tableNarration: 0.40, // tables get condensed, not read out
35
+ };
36
+
37
+ /* ---------------- args ---------------- */
38
+ const argv = process.argv.slice(2);
39
+ // The source is the first bare argument — but a flag's *value* is also bare, so
40
+ // skip anything that followed a flag which takes one.
41
+ const VALUED = new Set(['--out', '--max-min', '--min-min', '--wpm', '--expansion', '--title', '--exclude']);
42
+ const sourceDir = argv.find((a, i) => !a.startsWith('--') && !(i > 0 && VALUED.has(argv[i - 1])));
43
+ const flag = (n, d) => {
44
+ const i = argv.indexOf('--' + n);
45
+ return i === -1 ? d : argv[i + 1];
46
+ };
47
+ // Repeatable: --exclude a --exclude b (a published destination usually needs one).
48
+ const flags = (n) => argv.flatMap((a, i) => (a === '--' + n && argv[i + 1] ? [argv[i + 1]] : []));
49
+ if (!sourceDir) {
50
+ console.error('usage: node tools/planner.mjs <sourceDir> --out <outDir> [options]');
51
+ process.exit(1);
52
+ }
53
+ const CFG = {
54
+ ...DEFAULTS,
55
+ wpm: parseFloat(flag('wpm', DEFAULTS.wpm)),
56
+ expansion: parseFloat(flag('expansion', DEFAULTS.expansion)),
57
+ maxMin: parseFloat(flag('max-min', DEFAULTS.maxMin)),
58
+ force: !!flag('force', false), // overwrite a plan whose model differs (see the guard below)
59
+ minMin: parseFloat(flag('min-min', DEFAULTS.minMin)),
60
+ outDir: flag('out', 'out/plan'),
61
+ title: flag('title', basename(sourceDir.replace(/\/+$/, ''))),
62
+ };
63
+ const CAP_SEC = CFG.maxMin * 60;
64
+ const MIN_SEC = CFG.minMin * 60;
65
+ // Pack to slightly under the cap so a heading's own intro prose can still be
66
+ // attached to the video it belongs to without breaching the real limit.
67
+ const PACK_CAP = CAP_SEC * 0.97;
68
+
69
+ /* =============================================================================
70
+ 1. Walk the source tree
71
+ ========================================================================== */
72
+ const EXCLUDE = flags('exclude').map(d => resolve(d));
73
+ function walk(dir, base = dir) {
74
+ const out = [];
75
+ for (const name of readdirSync(dir)) {
76
+ if (name.startsWith('.')) continue;
77
+ const p = join(dir, name);
78
+ if (EXCLUDE.some(x => resolve(p) === x || resolve(p).startsWith(x + sep))) continue;
79
+ const st = statSync(p);
80
+ if (st.isDirectory()) out.push(...walk(p, base));
81
+ else out.push({ path: p, rel: relative(base, p), ext: extname(p).toLowerCase(), bytes: st.size });
82
+ }
83
+ return out;
84
+ }
85
+ // Fail cleanly on a bad path rather than emitting a raw ENOENT stack trace.
86
+ if (!existsSync(sourceDir)) {
87
+ console.error(`[planner] source directory not found: ${sourceDir}`);
88
+ process.exit(1);
89
+ }
90
+ if (!statSync(sourceDir).isDirectory()) {
91
+ console.error(`[planner] not a directory: ${sourceDir}`);
92
+ process.exit(1);
93
+ }
94
+ const files = walk(sourceDir);
95
+ const mdFiles = files.filter(f => f.ext === '.md');
96
+
97
+ /* A destination placed inside the source gets re-ingested on the next plan: the
98
+ sidecars we published (`description.md`, `captions.srt`) read as course notes and
99
+ turn into phantom videos. Detect the tell-tale name and say how to fix it. */
100
+ const sidecars = mdFiles.filter(f => basename(f.path) === 'description.md');
101
+ if (sidecars.length) {
102
+ const dirs = [...new Set(sidecars.map(f => dirname(f.path)))];
103
+ console.error(`\n[planner] ${sidecars.length} of the ${mdFiles.length} input files are `
104
+ + '`description.md` — that is the name this pipeline publishes. They are outputs, not notes.');
105
+ for (const d of dirs.slice(0, 4)) console.error(` ${relative(sourceDir, d) || '.'}/description.md`);
106
+ console.error(' Pass --exclude <that directory> (repeatable) — or --exclude your');
107
+ console.error(' destination — so published sidecars never re-enter the plan.\n');
108
+ }
109
+ const codeFiles = files.filter(f => ['.java', '.py', '.ts', '.js', '.go', '.cpp', '.c', '.rs'].includes(f.ext));
110
+
111
+ // Refuse to plan nothing. An empty or all-code directory used to produce a
112
+ // silent "1 video, 0 min" plan, which reads as success and is useless.
113
+ if (!mdFiles.length) {
114
+ console.error(`[planner] no markdown files under ${sourceDir}`);
115
+ console.error(` found ${files.length} file(s), none of them .md — nothing to plan.`);
116
+ console.error(' (only .md is parsed; .mdx/.rst/.txt/.ipynb are not supported yet)');
117
+ process.exit(1);
118
+ }
119
+
120
+ /* =============================================================================
121
+ 2. Parse markdown into a section list, with code fences respected
122
+ ========================================================================== */
123
+ const LINK_RE = /(https?:\/\/|\[[^\]]*\]\([^)]*\))/;
124
+ // A fence may sit behind a list marker ("5. ```java"). Missing that desynchronises
125
+ // the fence state and silently swallows every later heading in the file.
126
+ const FENCE_RE = /^(?:[-*+]\s+|\d+[.)]\s+)?```/;
127
+ const DIAGRAM_LANG = /^(mermaid|plantuml|graphviz|dot|d2)\b/i;
128
+
129
+ function parseMarkdown(rel, text) {
130
+ const lines = text.split('\n');
131
+ const sections = []; // {level, title, line, endLine, prose, code, diagram, table, link}
132
+ let fence = false;
133
+ let fenceLang = '';
134
+ let cur = { level: 0, title: '(preamble)', line: 0, prose: 0, code: 0, diagram: 0, table: 0, link: 0, codeBlocks: 0 };
135
+ const flush = (endLine) => { cur.endLine = endLine; sections.push(cur); };
136
+
137
+ lines.forEach((raw, i) => {
138
+ const line = raw.trimEnd();
139
+
140
+ if (FENCE_RE.test(line.trim())) {
141
+ if (!fence) {
142
+ fence = true; cur.codeBlocks++;
143
+ fenceLang = line.trim().replace(FENCE_RE, '').trim().split(/\s+/)[0] || '';
144
+ } else fence = false;
145
+ return;
146
+ }
147
+ if (fence) {
148
+ const w = line.split(/\s+/).filter(Boolean).length;
149
+ // A diagram fence is a visual to render, not code to narrate — it must
150
+ // not inflate codeRatio (that was misclassifying mermaid as `code`).
151
+ if (DIAGRAM_LANG.test(fenceLang)) cur.diagram += w; else cur.code += w;
152
+ return;
153
+ }
154
+
155
+ const h = /^(#{1,6})\s+(.*)$/.exec(line);
156
+ if (h) {
157
+ flush(i);
158
+ cur = {
159
+ level: h[1].length,
160
+ title: h[2].replace(/<a\s+name="[^"]*">\s*<\/a>/gi, '').replace(/<[^>]+>/g, '').trim(),
161
+ line: i, prose: 0, code: 0, diagram: 0, table: 0, link: 0, codeBlocks: 0,
162
+ };
163
+ return;
164
+ }
165
+
166
+ const words = line.split(/\s+/).filter(Boolean).length;
167
+ if (!words) return;
168
+ if (LINK_RE.test(line) && words < 14) cur.link += words; // link/list lines aren't narrated verbatim
169
+ else if (/^\s*\|/.test(line)) cur.table += words; // table row
170
+ else cur.prose += words;
171
+ });
172
+ flush(lines.length);
173
+
174
+ // narratable words for a section (cost model)
175
+ const cost = s => s.prose + CFG.codeNarration * s.code + CFG.tableNarration * s.table;
176
+ sections.forEach(s => { s.src = rel; s.narratable = Math.round(cost(s)); });
177
+ return sections.filter(s => s.title !== '(preamble)' || s.prose > 0);
178
+ }
179
+
180
+ /* =============================================================================
181
+ 3. Build a single section list across all markdown, then a tree
182
+ ========================================================================== */
183
+ let allSections = [];
184
+ const parseDiag = [];
185
+ for (const f of mdFiles) {
186
+ const text = readFileSync(f.path, 'utf8');
187
+ const secs = parseMarkdown(f.rel, text);
188
+ secs.forEach(s => { s.file = f.rel; });
189
+ allSections = allSections.concat(secs);
190
+ // Self-check: coverage can only assert over what the parser SAW. If the parser
191
+ // silently swallowed headings, coverage passes vacuously. Compare against the
192
+ // raw count of heading-like lines (the delta is usually code comments in fences).
193
+ const rawHeadings = text.split('\n').filter(l => /^#{1,6}\s/.test(l.trim())).length;
194
+ parseDiag.push({ file: f.rel, rawHeadingLines: rawHeadings, parsedHeadings: secs.filter(s => s.title !== '(preamble)').length });
195
+ }
196
+
197
+ // tree from heading levels. Each FILE gets its own node under the root, so
198
+ // packing can never merge unrelated files into one video.
199
+ const root = { id: 'root', title: CFG.title, level: -1, children: [], own: 0, src: '', isRoot: true };
200
+ function buildTree(secs, parent) {
201
+ const stack = [parent];
202
+ for (const s of secs) {
203
+ while (stack.length && stack[stack.length - 1].level >= s.level) stack.pop();
204
+ const parentNode = stack.length ? stack[stack.length - 1] : parent;
205
+ const n = {
206
+ id: `${s.file}:${s.line}`, title: s.title, level: s.level, src: s.file,
207
+ own: s.narratable, prose: s.prose, code: s.code, diagram: s.diagram, table: s.table, link: s.link,
208
+ children: [], section: s,
209
+ };
210
+ parentNode.children.push(n);
211
+ stack.push(n);
212
+ }
213
+ return parent;
214
+ }
215
+
216
+ const byFile = {};
217
+ allSections.forEach(s => { (byFile[s.file] = byFile[s.file] || []).push(s); });
218
+ for (const f of Object.keys(byFile)) {
219
+ const h1s = byFile[f].filter(s => s.level === 1);
220
+ // A file whose first H1 is just the first of MANY H1s (OOP/README.md) has no
221
+ // real document title. Prefer the containing directory name — "OOP", not "README".
222
+ const dir = f.includes(sep) ? f.split(sep).slice(-2, -1)[0] : null;
223
+ const title = h1s.length === 1 ? h1s[0].title : (dir || basename(f, '.md'));
224
+ const fileNode = {
225
+ id: 'file:' + f, title, level: 0, children: [], own: 0, src: f, isFile: true,
226
+ };
227
+ root.children.push(fileNode);
228
+ buildTree(byFile[f], fileNode);
229
+ }
230
+
231
+ /* path from root down to a node, for lowest-common-ancestor lookups */
232
+ function pathTo(target, node = root, chain = []) {
233
+ const c = chain.concat(node);
234
+ if (node === target) return c;
235
+ for (const k of node.children) {
236
+ const r = pathTo(target, k, c);
237
+ if (r) return r;
238
+ }
239
+ return null;
240
+ }
241
+ /** deepest node that is an ancestor-or-self of every unit */
242
+ function commonAncestor(units) {
243
+ if (!units.length) return root;
244
+ const paths = units.map(u => pathTo(u)).filter(Boolean);
245
+ if (!paths.length) return root;
246
+ let lca = root;
247
+ const min = Math.min(...paths.map(p => p.length));
248
+ for (let i = 0; i < min; i++) {
249
+ const cand = paths[0][i];
250
+ if (paths.every(p => p[i] === cand)) lca = cand; else break;
251
+ }
252
+ return lca;
253
+ }
254
+
255
+ const durOf = node => node.own + node.children.reduce((a, c) => a + durOf(c), 0);
256
+ const secsOf = node => (node.own > 0 || node.diagram > 0 || !node.children.length ? [node] : []).concat(node.children.flatMap(secsOf));
257
+ const toSec = words => (words / CFG.wpm) * 60 * CFG.expansion;
258
+
259
+ /* =============================================================================
260
+ 4. Exclude navigation sections — and nothing else
261
+
262
+ There used to be an "index pointer" inference here: if a section's title
263
+ contained a directory name, and that directory held 2x the words, the section
264
+ was excluded as a mere summary of that source. It was DELETED because:
265
+
266
+ • it silently dropped real content on unfamiliar trees — a section titled
267
+ "☕ Java" matched an unrelated JAVA/ directory elsewhere in the tree;
268
+ • gating it behind a flag only moved the problem onto the user, who cannot
269
+ know which directories are "index targets" in a folder they just pointed us at;
270
+ • measured on the reference tree it changed neither the video count, nor the
271
+ orphan count, nor the longest video — only the total, by 3 minutes in 59.
272
+
273
+ So there is no name-based inference. Navigation headings are the one
274
+ unambiguous case, and that is all we remove. Everything else is planned.
275
+ ========================================================================== */
276
+ const overlaps = [];
277
+ (function detectNav(node) {
278
+ for (const ch of node.children) {
279
+ if (/(table of contents|^contents$|^toc$|^index$)/i.test(ch.title)) {
280
+ ch.excluded = true;
281
+ ch.indexOf = 'TOC';
282
+ overlaps.push({ section: ch.title, src: ch.src, child: '(navigation — not content)', ownWords: durOf(ch), childWords: 0 });
283
+ }
284
+ detectNav(ch);
285
+ }
286
+ })(root);
287
+
288
+ /* =============================================================================
289
+ 5. Pack the tree into videos under the cap
290
+ ========================================================================== */
291
+ const live = node => !node.excluded;
292
+ function packNode(node) {
293
+ const total = durOf(node);
294
+ // seconds vs seconds — comparing words here silently changes the effective cap
295
+ if (toSec(total) <= CAP_SEC && node !== root) return [{ units: [node], words: total }];
296
+
297
+ const out = [];
298
+ let cur = null;
299
+ let carried = 0;
300
+ for (const ch of node.children.filter(live)) {
301
+ for (const v of packNode(ch)) {
302
+ // compare in SECONDS, not words. Also: never glue two different source
303
+ // files into one video — OOP foundations do not belong with the intro.
304
+ const sameFile = cur && cur.units.length && v.units.length && cur.units[0].src === v.units[0].src;
305
+ if (sameFile && toSec(cur.words + v.words) <= PACK_CAP) {
306
+ cur.units.push(...v.units);
307
+ if (v.ownOnly) (cur.ownOnly = cur.ownOnly || []).push(...v.ownOnly); // don't drop container prose
308
+ cur.words += v.words;
309
+ } else {
310
+ cur = { units: [...v.units], ownOnly: v.ownOnly ? [...v.ownOnly] : [], words: v.words };
311
+ out.push(cur);
312
+ }
313
+ }
314
+ }
315
+ // A heading's own prose has to live somewhere. Attach the NODE but keep it in
316
+ // `ownOnly` — expanding it via secsOf() would falsely claim its whole subtree,
317
+ // which belongs to other videos.
318
+ if (node.own > 0 && !node.isRoot) {
319
+ if (out.length) { (out[0].ownOnly = out[0].ownOnly || []).push(node); out[0].words += node.own; }
320
+ else out.push({ units: [], ownOnly: [node], words: node.own });
321
+ }
322
+ return out.length ? out : [{ units: [node], words: total }];
323
+ }
324
+
325
+ let videos = packNode(root);
326
+
327
+ // merge runs that are individually too small, when they share a parent and fit
328
+ for (let i = 0; i < videos.length - 1; i++) {
329
+ const a = videos[i], b = videos[i + 1];
330
+ const pa = a.units[0], pb = b.units[0];
331
+ const sameSrc = pa.src === pb.src;
332
+ if (toSec(a.words) < MIN_SEC && sameSrc && toSec(a.words + b.words) <= CAP_SEC) {
333
+ // preserve ownOnly: container prose attached to either half must not vanish
334
+ videos[i] = {
335
+ units: a.units.concat(b.units),
336
+ ownOnly: (a.ownOnly || []).concat(b.ownOnly || []),
337
+ words: a.words + b.words,
338
+ };
339
+ videos.splice(i + 1, 1);
340
+ i--;
341
+ }
342
+ }
343
+
344
+ /* =============================================================================
345
+ 6. Classify form from evidence
346
+ ========================================================================== */
347
+ function classify(v) {
348
+ const agg = { prose: 0, code: 0, diagram: 0, table: 0, link: 0, text: '' };
349
+ v.units.forEach(u => secsOf(u).forEach(s => {
350
+ agg.prose += s.prose || 0; agg.code += s.code || 0; agg.diagram += s.diagram || 0;
351
+ agg.table += s.table || 0; agg.link += s.link || 0;
352
+ agg.text += ' ' + (s.title || '');
353
+ }));
354
+ const total = agg.prose + agg.code + agg.diagram + agg.table + agg.link || 1;
355
+ const codeRatio = agg.code / total;
356
+ const diagramRatio = agg.diagram / total;
357
+ const linkRatio = agg.link / total;
358
+ const paths = v.units.map(u => u.src).join(' ').toLowerCase();
359
+ const ev = { codeRatio: +codeRatio.toFixed(3), diagramRatio: +diagramRatio.toFixed(3), linkRatio: +linkRatio.toFixed(3) };
360
+
361
+ // the code artefacts that sit alongside this video's sources
362
+ const prefixes = new Set(v.units.map(u => u.src.split(sep).slice(0, -1).join(sep)));
363
+ const artefacts = codeFiles
364
+ .filter(f => Array.from(prefixes).some(p => p && f.rel.startsWith(p + sep)) || (prefixes.has('') && !f.rel.includes(sep)))
365
+ .map(f => f.rel);
366
+
367
+ if (linkRatio > 0.5) return { form: 'resources', why: `linkRatio ${linkRatio.toFixed(2)} — a link list, not narratable`, artefacts, ev };
368
+ if (/violation|rectified|before|after/.test(paths)) return { form: 'refactor', why: 'source pair reads as before/after', artefacts, ev };
369
+ if (diagramRatio > 0.3 || /diagram|uml|notation|mermaid|sequence|state machine/.test(agg.text.toLowerCase()))
370
+ return { form: 'diagram', why: `diagramRatio ${diagramRatio.toFixed(2)} — mermaid/plantuml fences or diagram markers`, artefacts, ev };
371
+ if (/problem|taxonomy|catalog|directory|questions/.test(agg.text.toLowerCase()) && agg.table + agg.link > total * 0.3)
372
+ return { form: 'catalogue', why: 'enumerated problem/table listing', artefacts, ev };
373
+ if (codeRatio > 0.35) return { form: 'code', why: `codeRatio ${codeRatio.toFixed(2)}`, artefacts, ev };
374
+ return { form: 'concept', why: 'prose-dominant explanatory content', artefacts, ev };
375
+ }
376
+
377
+ const cleanTitle = t => t
378
+ .replace(/^[\d.]+\s*/, '')
379
+ .replace(/^Phase\s*\d+\s*:?\s*/i, '')
380
+ .replace(/[📑☕⚙️🐍🐹🎯🔒✅❌]/g, '')
381
+ .replace(/<[^>]+>/g, '')
382
+ .trim();
383
+
384
+ videos.forEach((v, i) => {
385
+ const c = classify(v);
386
+ v.index = i + 1;
387
+ v.form = c.form;
388
+ v.formWhy = c.why;
389
+ v.evidence = c.ev;
390
+ v.artefacts = c.artefacts;
391
+
392
+ // Title from the DEEPEST COMMON ANCESTOR of the units, not from the first one.
393
+ // Otherwise a video holding five principles gets named after whichever came first.
394
+ const lcaNode = commonAncestor(v.units);
395
+ const lcaTitle = cleanTitle(lcaNode.title || '');
396
+ let title;
397
+ if (lcaTitle && !lcaNode.isRoot && lcaTitle.length <= 48) {
398
+ title = lcaTitle; // a tight, meaningful ancestor
399
+ } else {
400
+ // units span a whole file: name the video after its own content range,
401
+ // ignoring container headings that carry almost no narration
402
+ const ts = v.units
403
+ .map(u => ({ t: cleanTitle(u.title), w: u.own }))
404
+ .filter(x => x.t && x.w > 5 && x.t.length <= 56)
405
+ .map(x => x.t.length > 40 ? x.t.slice(0, 38) + '…' : x.t);
406
+ if (!ts.length) title = lcaTitle ? lcaTitle.slice(0, 40) + '…' : `Part ${i + 1}`;
407
+ else if (ts.length === 1) title = ts[0];
408
+ else if (ts.length === 2) title = ts.join(' & ');
409
+ else title = `${ts[0]} → ${ts[ts.length - 1]}`;
410
+ }
411
+ v.title = title;
412
+ v.coveredHeading = lcaNode.isFile ? lcaNode.src : (lcaNode.title || '');
413
+
414
+ v.seconds = Math.round(toSec(v.words));
415
+ v.minutes = +(v.seconds / 60).toFixed(1);
416
+
417
+ const expanded = v.units.flatMap(u => secsOf(u).map(s => ({
418
+ src: s.src, title: s.title, narratable: s.narratable || 0,
419
+ line: s.section ? s.section.line + 1 : null, // 1-based inclusive span
420
+ endLine: s.section ? s.section.endLine : null,
421
+ })));
422
+ const ownOnly = (v.ownOnly || []).map(n => ({
423
+ src: n.src, title: n.title, narratable: n.own,
424
+ line: n.section ? n.section.line + 1 : null,
425
+ endLine: n.section ? n.section.endLine : null,
426
+ }));
427
+ // Section identity is (src, line): a heading's line number is unique within a
428
+ // file, so two same-title headings ("Analogy", "Appendix") stay distinct.
429
+ // Keying on src||title collapsed them — that was the lossy-identity bug.
430
+ const seen = new Set();
431
+ v.sections = expanded.concat(ownOnly).filter(s => {
432
+ const k = s.src + ':' + s.line;
433
+ if (seen.has(k)) return false;
434
+ seen.add(k); return true;
435
+ });
436
+ v.sources = Array.from(new Set(v.sections.map(s => s.src)));
437
+ });
438
+
439
+ /* =============================================================================
440
+ 7. Coverage assertion
441
+ ========================================================================== */
442
+ const covered = new Set();
443
+ videos.forEach(v => v.sections.forEach(s => covered.add(s.src + ':' + s.line)));
444
+ const coverage = allSections.map(s => {
445
+ const line1 = s.line + 1; // v.sections stores 1-based line
446
+ const owner = videos.find(v => v.sections.some(x => x.src === s.src && x.line === line1));
447
+ const idxOwner = overlaps.find(o => o.src === s.src && o.section === s.title);
448
+ return {
449
+ src: s.src, title: s.title, line: line1, narratable: s.narratable,
450
+ video: owner ? owner.index : null,
451
+ excluded: idxOwner ? `index pointer -> ${idxOwner.child}/` : null,
452
+ reason: idxOwner ? 'summary of a dedicated source; that source is the video' : null,
453
+ };
454
+ });
455
+ // Only sections that actually CARRY content can be orphaned. A container
456
+ // heading with no prose of its own is structure, not an uncovered topic.
457
+ const contentCoverage = coverage.filter(c => c.narratable > 0);
458
+ const structuralCoverage = coverage.filter(c => c.narratable === 0);
459
+ const orphans = contentCoverage.filter(c => !c.video && !c.excluded);
460
+
461
+ /* =============================================================================
462
+ 8. Emit
463
+ ========================================================================== */
464
+ const totalSec = videos.reduce((a, v) => a + v.seconds, 0);
465
+ // hard invariant: nothing may exceed the cap
466
+ const capViolations = videos.filter(v => v.seconds > CAP_SEC);
467
+ const plan = {
468
+ generatedFrom: sourceDir,
469
+ model: { wpm: CFG.wpm, expansion: CFG.expansion, maxVideoMin: CFG.maxMin, minVideoMin: CFG.minMin },
470
+ sourceStats: {
471
+ markdownFiles: mdFiles.length,
472
+ codeFiles: codeFiles.length,
473
+ markdownWords: mdFiles.reduce((a, f) => a + readFileSync(f.path, 'utf8').split(/\s+/).length, 0),
474
+ narratableWords: allSections.reduce((a, s) => a + s.narratable, 0),
475
+ },
476
+ totals: {
477
+ videos: videos.length,
478
+ seriesMinutes: +(totalSec / 60).toFixed(1),
479
+ longestVideoMin: Math.max(...videos.map(v => v.minutes)),
480
+ capMinutes: CFG.maxMin,
481
+ rawNarrationMinutes: +(allSections.reduce((a, s) => a + s.narratable, 0) / CFG.wpm).toFixed(1),
482
+ },
483
+ excluded: overlaps,
484
+ parserDiag: parseDiag,
485
+ coverage,
486
+ orphans,
487
+ videos: videos.map(v => ({
488
+ index: v.index, title: v.title, form: v.form, formWhy: v.formWhy,
489
+ minutes: v.minutes, seconds: v.seconds, words: v.words,
490
+ sources: v.sources, artefacts: v.artefacts, evidence: v.evidence,
491
+ sections: v.sections.map(s => `${s.src} :: ${s.title} @L${s.line}`),
492
+ sectionSpans: v.sections.map(s => ({ id: `${s.src} :: ${s.title} @L${s.line}`, src: s.src, title: s.title, line: s.line, endLine: s.endLine })),
493
+ })),
494
+ };
495
+
496
+ /* A plan records the model that produced it, and video INDICES are keyed by that
497
+ split. Re-planning with a different cap produces a different number of videos, so
498
+ any enrichment (titles/skips, keyed by index) silently stops pointing at the video
499
+ it was written for. That is a data-loss hazard, not a cosmetic mismatch, so it has
500
+ to be opted into rather than discovered later. */
501
+ const prevPlanPath = join(CFG.outDir, 'plan.json');
502
+ const enrichedPath = join(CFG.outDir, 'plan.enriched.json');
503
+ if (existsSync(prevPlanPath)) {
504
+ let prev = null;
505
+ try { prev = JSON.parse(readFileSync(prevPlanPath, 'utf8')); } catch { /* unreadable: treat as none */ }
506
+ const prevCap = prev && prev.model ? prev.model.maxVideoMin : null;
507
+ const prevCount = prev && Array.isArray(prev.videos) ? prev.videos.length : null;
508
+ const capChanged = prevCap != null && Math.abs(prevCap - CFG.maxMin) > 1e-9;
509
+ const countChanged = prevCount != null && prevCount !== videos.length;
510
+ if (capChanged || countChanged) {
511
+ const why = [];
512
+ if (capChanged) why.push(`cap ${prevCap} min -> ${CFG.maxMin} min`);
513
+ if (countChanged) why.push(`${prevCount} videos -> ${videos.length} videos`);
514
+ const enriched = existsSync(enrichedPath);
515
+ const msg = `[planner] existing plan in ${CFG.outDir} differs: ${why.join(', ')}`;
516
+ if (enriched) {
517
+ if (!CFG.force) {
518
+ console.error(`\n${msg}`);
519
+ console.error(' plan.enriched.json is present, and enrichment is keyed by video INDEX —');
520
+ console.error(' re-planning would silently repoint every curated title at a different video.');
521
+ console.error(' Re-run with --force to overwrite, or use a different --out to compare side by side.');
522
+ process.exit(4);
523
+ }
524
+ console.error(`\n${msg}`);
525
+ console.error(' --force given: overwriting an enriched plan. Re-run apply-enrichment afterwards.');
526
+ } else if (capChanged) {
527
+ console.error(`\n${msg}`);
528
+ console.error(' (no plan.enriched.json yet, so nothing is lost — overwriting.)');
529
+ }
530
+ }
531
+ }
532
+
533
+ /* Scratch is not a throwaway: one 90-video series carries hundreds of MB of WAVs and
534
+ MP4s. If the scratch root sits inside a git work tree that does not ignore it, the
535
+ next `git add -A` sweeps it in. Verify the ignore rule; append it only if missing. */
536
+ function ensureScratchIgnored(outDir) {
537
+ const target = dirname(outDir); // the `out/` itself, not the per-series dir
538
+ try {
539
+ const root = execFileSync('git', ['-C', target, 'rev-parse', '--show-toplevel'],
540
+ { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
541
+ if (!root) return;
542
+ try {
543
+ execFileSync('git', ['-C', root, 'check-ignore', '-q', target], { stdio: 'ignore' });
544
+ return; // already ignored — stay silent
545
+ } catch { /* not ignored: fall through */ }
546
+ if (target === root) return; // scratch root is the repo root; leave it alone
547
+ const rel = relative(root, target).split(sep).join('/');
548
+ const gi = join(root, '.gitignore');
549
+ const before = existsSync(gi) ? readFileSync(gi, 'utf8') : '';
550
+ const already = before.split('\n').some((l) => {
551
+ const t = l.trim().replace(/\/$/, '');
552
+ return t === rel || t === `/${rel}`;
553
+ });
554
+ if (already) return; // ignored, just not by git's checker
555
+ const pad = !before || before.endsWith('\n') ? '' : '\n';
556
+ writeFileSync(gi, `${before}${pad}\n# explainer-video scratch — grows to hundreds of MB\n/${rel}/\n`);
557
+ console.log(`[gitignore] added /${rel}/ to ${relative(process.cwd(), gi) || '.gitignore'}`
558
+ + ' (scratch is large; it must not be committable)');
559
+ } catch { /* no git, or not a work tree — nothing to ignore */ }
560
+ }
561
+
562
+ mkdirSync(CFG.outDir, { recursive: true });
563
+ ensureScratchIgnored(CFG.outDir);
564
+ writeFileSync(join(CFG.outDir, 'plan.json'), JSON.stringify(plan, null, 2));
565
+
566
+ /* ---- human-readable report ---- */
567
+ const L = [];
568
+ L.push(`# ${CFG.title} — video series plan`, '');
569
+ L.push(`Source: \`${sourceDir}\``);
570
+ L.push(`Markdown: ${plan.sourceStats.markdownFiles} files, ${plan.sourceStats.markdownWords} words · Code: ${plan.sourceStats.codeFiles} files`);
571
+ L.push(`Narratable: ${plan.sourceStats.narratableWords} words → **${plan.totals.rawNarrationMinutes} min** of pure narration`);
572
+ L.push('');
573
+ L.push(`Model: ${CFG.wpm} wpm × ${CFG.expansion} overhead · cap **${CFG.maxMin} min/video**`);
574
+ L.push('');
575
+ L.push(`## Result: ${plan.totals.videos} videos, ${plan.totals.seriesMinutes} min total (longest ${plan.totals.longestVideoMin} min)`, '');
576
+ L.push('| # | Video | Form | Min | Sources | Code |');
577
+ L.push('|---|---|---|---|---|---|');
578
+ videos.forEach(v => {
579
+ L.push(`| ${v.index} | ${v.title} | \`${v.form}\` | ${v.minutes} | ${v.sources.join(', ')} | ${v.artefacts.length} |`);
580
+ });
581
+ L.push('');
582
+ L.push('### Form evidence', '');
583
+ videos.forEach(v => L.push(`- **${v.index}. ${v.title}** → \`${v.form}\` — ${v.formWhy} ${JSON.stringify(v.evidence)}`));
584
+ if (overlaps.length) {
585
+ L.push('', '### Excluded (navigation headings, not content)', '');
586
+ overlaps.forEach(o => L.push(`- \`${o.src}\` :: "${o.section}" (${o.ownWords}w) → ${o.child}`));
587
+ }
588
+ if (capViolations.length) {
589
+ L.push('', `**⚠️ ${capViolations.length} video(s) exceed the ${CFG.maxMin}-min cap:**`);
590
+ capViolations.forEach(v => L.push(`- ${v.index}. ${v.title} — ${v.minutes} min`));
591
+ }
592
+ L.push('', '### Parser self-check (did we actually SEE every heading?)', '');
593
+ parseDiag.forEach(d => {
594
+ const delta = d.rawHeadingLines - d.parsedHeadings;
595
+ L.push(`- \`${d.file}\`: ${d.rawHeadingLines} heading-like lines, ${d.parsedHeadings} parsed${delta ? ` (delta ${delta} — expected: code comments inside fences)` : ''}`);
596
+ });
597
+ L.push('', '### Coverage', '');
598
+ L.push(`${coverage.length} headings · ${contentCoverage.length} carry content · ${structuralCoverage.length} are structural containers`);
599
+ L.push(`${contentCoverage.filter(c => c.video).length} assigned · ${contentCoverage.filter(c => c.excluded).length} excluded as navigation · **${orphans.length} orphaned**`);
600
+ if (orphans.length) {
601
+ L.push('', '**ORPHANS (content nothing covers):**');
602
+ orphans.forEach(o => L.push(`- ⚠️ ${o.src} :: ${o.title} @L${o.line} (${o.narratable} narratable words)`));
603
+ } else {
604
+ L.push('', '✅ No orphans — every content-bearing section is either assigned to a video or is a navigation heading.');
605
+ }
606
+ writeFileSync(join(CFG.outDir, 'plan.md'), L.join('\n') + '\n');
607
+
608
+ /* ---- console summary ----
609
+ Print the WHOLE plan, not a sample. The first 12 lines are the title, stats and the
610
+ table header — i.e. slicing here printed the header and zero video rows, which
611
+ quietly contradicted this tool's own rule that every number is printed so the plan
612
+ can be argued with. A big plan is long; hiding rows is worse than being long. */
613
+ console.log(L.join('\n'));
614
+ console.log(`\n[out] ${join(CFG.outDir, 'plan.json')}`);
615
+ console.log(`[out] ${join(CFG.outDir, 'plan.md')}`);
616
+ if (orphans.length) { console.log(`\n⚠️ ${orphans.length} orphaned section(s)`); process.exitCode = 2; }