@kolbo/mcp 1.70.1 → 1.70.2

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 (32) hide show
  1. package/README.md +7 -7
  2. package/bin/kolbo-mcp.js +14 -5
  3. package/package.json +3 -2
  4. package/skill/GENERATED.md +4 -5
  5. package/skill/SKILL.md +11 -18
  6. package/skill/VERSION +1 -1
  7. package/skill/assets/filmmaking/continuity-ledger.template.json +27 -0
  8. package/skill/assets/filmmaking/generation-log.template.csv +2 -0
  9. package/skill/assets/filmmaking/production-bible.template.json +97 -0
  10. package/skill/assets/filmmaking/scene-card.template.json +40 -0
  11. package/skill/assets/filmmaking/shot-card.template.json +72 -0
  12. package/skill/references/filmmaking/acting-direction.md +131 -0
  13. package/skill/references/filmmaking/asset-preproduction.md +97 -0
  14. package/skill/references/filmmaking/audio-dialogue-music.md +111 -0
  15. package/skill/references/filmmaking/blocking-continuity.md +125 -0
  16. package/skill/references/filmmaking/cinematography.md +101 -0
  17. package/skill/references/filmmaking/physics-action.md +93 -0
  18. package/skill/references/filmmaking/production-bible.md +108 -0
  19. package/skill/references/filmmaking/prompt-contracts.md +140 -0
  20. package/skill/references/filmmaking/routing.md +105 -0
  21. package/skill/references/filmmaking/scene-engine.md +95 -0
  22. package/skill/references/filmmaking/validation.md +109 -0
  23. package/skill/references/filmmaking/workflows.md +80 -0
  24. package/skill/references/models/gpt-image.md +1 -1
  25. package/skill/references/models/nano-banana.md +1 -1
  26. package/skill/references/models/prompt-copilot.md +0 -1
  27. package/skill/references/models/seedance.md +24 -44
  28. package/skill/references/models/seedance25.md +3 -3
  29. package/skill/references/workflows/filmmaking.md +168 -0
  30. package/skill/scripts/filmmaking/lint_prompt.py +249 -0
  31. package/skill/scripts/filmmaking/validate_film_package.py +435 -0
  32. package/src/install.js +62 -3
@@ -0,0 +1,435 @@
1
+ #!/usr/bin/env python3
2
+ """Validate a Kolbo Filmmaker production package using only the Python stdlib."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Any, Iterable
11
+
12
+
13
+ VALID_MODES = {
14
+ "text-to-video",
15
+ "image-to-video",
16
+ "reference-to-video",
17
+ "first-last-frame",
18
+ "video-to-video",
19
+ }
20
+ VALID_DENSITIES = {"strict", "anchored", "exploratory"}
21
+
22
+
23
+ class Report:
24
+ def __init__(self) -> None:
25
+ self.items: list[dict[str, str]] = []
26
+
27
+ def add(self, level: str, code: str, path: str, message: str) -> None:
28
+ self.items.append(
29
+ {"level": level, "code": code, "path": path, "message": message}
30
+ )
31
+
32
+ def error(self, code: str, path: str, message: str) -> None:
33
+ self.add("error", code, path, message)
34
+
35
+ def warn(self, code: str, path: str, message: str) -> None:
36
+ self.add("warning", code, path, message)
37
+
38
+ @property
39
+ def error_count(self) -> int:
40
+ return sum(item["level"] == "error" for item in self.items)
41
+
42
+ @property
43
+ def warning_count(self) -> int:
44
+ return sum(item["level"] == "warning" for item in self.items)
45
+
46
+
47
+ def load_json(path: Path, report: Report) -> Any | None:
48
+ try:
49
+ return json.loads(path.read_text(encoding="utf-8-sig"))
50
+ except FileNotFoundError:
51
+ report.error("missing_file", str(path), "Required file does not exist.")
52
+ except json.JSONDecodeError as exc:
53
+ report.error(
54
+ "invalid_json",
55
+ str(path),
56
+ f"JSON parse error at line {exc.lineno}, column {exc.colno}: {exc.msg}",
57
+ )
58
+ except OSError as exc:
59
+ report.error("read_failed", str(path), f"Could not read file: {exc}")
60
+ return None
61
+
62
+
63
+ def require_fields(
64
+ obj: Any, fields: Iterable[str], path: str, report: Report
65
+ ) -> None:
66
+ if not isinstance(obj, dict):
67
+ report.error("wrong_type", path, "Expected a JSON object.")
68
+ return
69
+ for field in fields:
70
+ if field not in obj or obj[field] in (None, "", []):
71
+ report.error("missing_field", f"{path}.{field}", "Required field is empty.")
72
+
73
+
74
+ def collect_registry_tags(bible: dict[str, Any]) -> set[str]:
75
+ tags: set[str] = set()
76
+
77
+ def walk(value: Any) -> None:
78
+ if isinstance(value, dict):
79
+ for key, child in value.items():
80
+ if key in {"tag", "canonical_tag"} and isinstance(child, str):
81
+ if child.startswith("@"):
82
+ tags.add(child)
83
+ walk(child)
84
+ elif isinstance(value, list):
85
+ for child in value:
86
+ walk(child)
87
+
88
+ for section in ("characters", "locations", "assets"):
89
+ walk(bible.get(section, []))
90
+ return tags
91
+
92
+
93
+ def json_files(folder: Path) -> list[Path]:
94
+ return sorted(path for path in folder.glob("*.json") if path.is_file())
95
+
96
+
97
+ def validate_bible(bible: Any, path: Path, report: Report) -> tuple[str, set[str]]:
98
+ require_fields(
99
+ bible,
100
+ [
101
+ "schema_version",
102
+ "project_id",
103
+ "title",
104
+ "story",
105
+ "characters",
106
+ "locations",
107
+ "assets",
108
+ "audio_policy",
109
+ ],
110
+ str(path),
111
+ report,
112
+ )
113
+ if not isinstance(bible, dict):
114
+ return "", set()
115
+ project_id = str(bible.get("project_id", ""))
116
+ tags = collect_registry_tags(bible)
117
+ if not tags:
118
+ report.warn(
119
+ "empty_asset_registry",
120
+ str(path),
121
+ "No @ asset tags were found in characters, locations, or assets.",
122
+ )
123
+ for section in ("characters", "locations", "assets"):
124
+ if not isinstance(bible.get(section), list):
125
+ report.error(
126
+ "wrong_type", f"{path}.{section}", "Expected this registry to be a list."
127
+ )
128
+ return project_id, tags
129
+
130
+
131
+ def validate_scene_cards(
132
+ paths: list[Path], project_id: str, registry_tags: set[str], report: Report
133
+ ) -> set[str]:
134
+ scene_ids: set[str] = set()
135
+ for path in paths:
136
+ card = load_json(path, report)
137
+ if card is None:
138
+ continue
139
+ require_fields(
140
+ card,
141
+ ["project_id", "scene_id", "slugline", "story_job", "structure"],
142
+ str(path),
143
+ report,
144
+ )
145
+ if not isinstance(card, dict):
146
+ continue
147
+ scene_id = str(card.get("scene_id", ""))
148
+ if scene_id in scene_ids:
149
+ report.error("duplicate_scene_id", str(path), f"Duplicate scene_id: {scene_id}")
150
+ scene_ids.add(scene_id)
151
+ if project_id and card.get("project_id") != project_id:
152
+ report.error(
153
+ "project_mismatch",
154
+ str(path),
155
+ f"Scene project_id must be {project_id!r}.",
156
+ )
157
+ location_tag = card.get("location_tag")
158
+ if location_tag and location_tag not in registry_tags:
159
+ report.error(
160
+ "unknown_asset_tag",
161
+ f"{path}.location_tag",
162
+ f"Tag is absent from production-bible.json: {location_tag}",
163
+ )
164
+ structure = card.get("structure")
165
+ if isinstance(structure, dict):
166
+ for key in ("goal", "obstacle", "tactic", "reversal", "audience_value_shift"):
167
+ if not structure.get(key):
168
+ report.warn(
169
+ "incomplete_scene_engine",
170
+ f"{path}.structure.{key}",
171
+ "Dramatic structure field is empty.",
172
+ )
173
+ return scene_ids
174
+
175
+
176
+ def validate_timed_blocks(
177
+ blocks: Any, duration: float, path: str, report: Report, label: str
178
+ ) -> None:
179
+ if not isinstance(blocks, list):
180
+ report.error("wrong_type", path, f"{label} must be a list.")
181
+ return
182
+ intervals: list[tuple[float, float, int, dict[str, Any]]] = []
183
+ for index, block in enumerate(blocks):
184
+ item_path = f"{path}[{index}]"
185
+ if not isinstance(block, dict):
186
+ report.error("wrong_type", item_path, "Timed block must be an object.")
187
+ continue
188
+ start = block.get("start_seconds")
189
+ end = block.get("end_seconds")
190
+ if not isinstance(start, (int, float)) or not isinstance(end, (int, float)):
191
+ report.error("invalid_timecode", item_path, "Start and end must be numbers.")
192
+ continue
193
+ if start < 0 or end <= start:
194
+ report.error("invalid_timecode", item_path, "Require 0 <= start < end.")
195
+ if end > duration:
196
+ report.error(
197
+ "timecode_overflow",
198
+ item_path,
199
+ f"End {end}s exceeds shot duration {duration}s.",
200
+ )
201
+ intervals.append((float(start), float(end), index, block))
202
+ if label == "Dialogue":
203
+ ordered = sorted(intervals)
204
+ for previous, current in zip(ordered, ordered[1:]):
205
+ if current[0] < previous[1] and not (
206
+ previous[3].get("allow_overlap") or current[3].get("allow_overlap")
207
+ ):
208
+ report.error(
209
+ "dialogue_overlap",
210
+ path,
211
+ f"Dialogue items {previous[2]} and {current[2]} overlap without allow_overlap.",
212
+ )
213
+
214
+
215
+ def validate_shot_cards(
216
+ paths: list[Path],
217
+ project_id: str,
218
+ scene_ids: set[str],
219
+ registry_tags: set[str],
220
+ report: Report,
221
+ ) -> set[str]:
222
+ shot_ids: set[str] = set()
223
+ for path in paths:
224
+ card = load_json(path, report)
225
+ if card is None:
226
+ continue
227
+ require_fields(
228
+ card,
229
+ [
230
+ "project_id",
231
+ "scene_id",
232
+ "shot_id",
233
+ "editorial_job",
234
+ "generation_mode",
235
+ "control_density",
236
+ "duration_seconds",
237
+ "active_assets",
238
+ "first_frame",
239
+ "final_state",
240
+ ],
241
+ str(path),
242
+ report,
243
+ )
244
+ if not isinstance(card, dict):
245
+ continue
246
+ shot_id = str(card.get("shot_id", ""))
247
+ if shot_id in shot_ids:
248
+ report.error("duplicate_shot_id", str(path), f"Duplicate shot_id: {shot_id}")
249
+ shot_ids.add(shot_id)
250
+ if project_id and card.get("project_id") != project_id:
251
+ report.error(
252
+ "project_mismatch", str(path), f"Shot project_id must be {project_id!r}."
253
+ )
254
+ scene_id = str(card.get("scene_id", ""))
255
+ if scene_ids and scene_id not in scene_ids:
256
+ report.error(
257
+ "unknown_scene_id", str(path), f"No scene card exists for {scene_id!r}."
258
+ )
259
+ mode = card.get("generation_mode")
260
+ if mode not in VALID_MODES:
261
+ report.error(
262
+ "invalid_generation_mode",
263
+ f"{path}.generation_mode",
264
+ f"Use one of: {', '.join(sorted(VALID_MODES))}.",
265
+ )
266
+ density = card.get("control_density")
267
+ if density not in VALID_DENSITIES:
268
+ report.error(
269
+ "invalid_control_density",
270
+ f"{path}.control_density",
271
+ f"Use one of: {', '.join(sorted(VALID_DENSITIES))}.",
272
+ )
273
+ duration = card.get("duration_seconds")
274
+ if not isinstance(duration, (int, float)) or duration <= 0:
275
+ report.error("invalid_duration", f"{path}.duration_seconds", "Use a positive number.")
276
+ duration_value = 0.0
277
+ else:
278
+ duration_value = float(duration)
279
+ if card.get("model") == "seedance-2.5" and duration_value and not 4 <= duration_value <= 30:
280
+ report.error(
281
+ "seedance_duration",
282
+ f"{path}.duration_seconds",
283
+ "Seedance 2.5 adapter expects 4-30 seconds per generation.",
284
+ )
285
+ assets = card.get("active_assets")
286
+ if isinstance(assets, list):
287
+ seen: set[str] = set()
288
+ for index, asset in enumerate(assets):
289
+ tag = asset.get("tag") if isinstance(asset, dict) else None
290
+ if not isinstance(tag, str) or not tag.startswith("@"):
291
+ report.error(
292
+ "invalid_asset_reference",
293
+ f"{path}.active_assets[{index}]",
294
+ "Every active asset needs a @tag.",
295
+ )
296
+ continue
297
+ if tag in seen:
298
+ report.warn(
299
+ "duplicate_asset_reference",
300
+ f"{path}.active_assets[{index}]",
301
+ f"Asset is listed more than once: {tag}",
302
+ )
303
+ seen.add(tag)
304
+ if tag not in registry_tags:
305
+ report.error(
306
+ "unknown_asset_tag",
307
+ f"{path}.active_assets[{index}]",
308
+ f"Tag is absent from production-bible.json: {tag}",
309
+ )
310
+ else:
311
+ report.error("wrong_type", f"{path}.active_assets", "Expected a list.")
312
+ if "action_timing" in card and duration_value:
313
+ validate_timed_blocks(
314
+ card["action_timing"], duration_value, f"{path}.action_timing", report, "Action"
315
+ )
316
+ dialogue = card.get("dialogue", [])
317
+ if dialogue and duration_value:
318
+ validate_timed_blocks(
319
+ dialogue, duration_value, f"{path}.dialogue", report, "Dialogue"
320
+ )
321
+ return shot_ids
322
+
323
+
324
+ def validate_continuity(
325
+ path: Path, project_id: str, shot_ids: set[str], report: Report
326
+ ) -> None:
327
+ if not path.exists():
328
+ report.warn(
329
+ "missing_continuity_ledger",
330
+ str(path),
331
+ "Add continuity-ledger.json before generating a multi-shot sequence.",
332
+ )
333
+ return
334
+ ledger = load_json(path, report)
335
+ if ledger is None:
336
+ return
337
+ require_fields(ledger, ["project_id", "entries"], str(path), report)
338
+ if not isinstance(ledger, dict):
339
+ return
340
+ if project_id and ledger.get("project_id") != project_id:
341
+ report.error(
342
+ "project_mismatch", str(path), f"Ledger project_id must be {project_id!r}."
343
+ )
344
+ entries = ledger.get("entries")
345
+ if not isinstance(entries, list):
346
+ report.error("wrong_type", f"{path}.entries", "Expected a list.")
347
+ return
348
+ ledger_ids: set[str] = set()
349
+ for index, entry in enumerate(entries):
350
+ item_path = f"{path}.entries[{index}]"
351
+ require_fields(entry, ["shot_id", "entering", "leaving", "handoff_proof"], item_path, report)
352
+ if not isinstance(entry, dict):
353
+ continue
354
+ shot_id = str(entry.get("shot_id", ""))
355
+ if shot_id in ledger_ids:
356
+ report.error("duplicate_ledger_entry", item_path, f"Duplicate shot_id: {shot_id}")
357
+ ledger_ids.add(shot_id)
358
+ if shot_ids and shot_id not in shot_ids:
359
+ report.error("unknown_shot_id", item_path, f"No shot card exists for {shot_id!r}.")
360
+ missing = shot_ids - ledger_ids
361
+ for shot_id in sorted(missing):
362
+ report.warn(
363
+ "missing_ledger_entry",
364
+ str(path),
365
+ f"Shot has no explicit entering/leaving handoff: {shot_id}",
366
+ )
367
+
368
+
369
+ def render_text(report: Report, package: Path, counts: dict[str, int]) -> str:
370
+ lines = [f"Kolbo Filmmaker package: {package}"]
371
+ lines.append(
372
+ "Validated "
373
+ + ", ".join(f"{value} {key}" for key, value in counts.items())
374
+ + "."
375
+ )
376
+ for item in report.items:
377
+ lines.append(
378
+ f"[{item['level'].upper()}] {item['code']} - {item['path']}: {item['message']}"
379
+ )
380
+ lines.append(f"Result: {report.error_count} error(s), {report.warning_count} warning(s).")
381
+ return "\n".join(lines)
382
+
383
+
384
+ def main() -> int:
385
+ parser = argparse.ArgumentParser(description=__doc__)
386
+ parser.add_argument("package", type=Path, help="Production package folder")
387
+ parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON")
388
+ args = parser.parse_args()
389
+
390
+ package = args.package.resolve()
391
+ report = Report()
392
+ if not package.is_dir():
393
+ report.error("missing_package", str(package), "Package folder does not exist.")
394
+ counts = {"scene card(s)": 0, "shot card(s)": 0, "registered tag(s)": 0}
395
+ else:
396
+ bible_path = package / "production-bible.json"
397
+ bible = load_json(bible_path, report)
398
+ project_id, tags = validate_bible(bible, bible_path, report) if bible is not None else ("", set())
399
+ scene_paths = json_files(package / "scene-cards")
400
+ shot_paths = json_files(package / "shot-cards")
401
+ if not scene_paths:
402
+ report.warn("no_scene_cards", str(package / "scene-cards"), "No scene cards found.")
403
+ if not shot_paths:
404
+ report.warn("no_shot_cards", str(package / "shot-cards"), "No shot cards found.")
405
+ scene_ids = validate_scene_cards(scene_paths, project_id, tags, report)
406
+ shot_ids = validate_shot_cards(shot_paths, project_id, scene_ids, tags, report)
407
+ validate_continuity(package / "continuity-ledger.json", project_id, shot_ids, report)
408
+ counts = {
409
+ "scene card(s)": len(scene_paths),
410
+ "shot card(s)": len(shot_paths),
411
+ "registered tag(s)": len(tags),
412
+ }
413
+
414
+ if args.json:
415
+ print(
416
+ json.dumps(
417
+ {
418
+ "package": str(package),
419
+ "valid": report.error_count == 0,
420
+ "counts": counts,
421
+ "errors": report.error_count,
422
+ "warnings": report.warning_count,
423
+ "items": report.items,
424
+ },
425
+ indent=2,
426
+ ensure_ascii=False,
427
+ )
428
+ )
429
+ else:
430
+ print(render_text(report, package, counts))
431
+ return 1 if report.error_count else 0
432
+
433
+
434
+ if __name__ == "__main__":
435
+ raise SystemExit(main())
package/src/install.js CHANGED
@@ -16,6 +16,8 @@ const path = require('path');
16
16
  const os = require('os');
17
17
 
18
18
  const KOLBO_ENTRY = { command: 'npx', args: ['-y', '@kolbo/mcp@latest'] };
19
+ const MANAGED_FILE = '.kolbo-managed.json';
20
+ const PACKAGE_VERSION = require('../package.json').version;
19
21
 
20
22
  function targets() {
21
23
  const home = os.homedir();
@@ -78,6 +80,7 @@ function skillTargets() {
78
80
  return [
79
81
  { name: 'Claude Code skill', root: path.join(home, '.claude'), dir: path.join(home, '.claude', 'skills', 'kolbo') },
80
82
  { name: 'Agents skill (Cursor/Codex)', root: path.join(home, '.agents'), dir: path.join(home, '.agents', 'skills', 'kolbo') },
83
+ { name: 'Kolbo Code skill', root: path.join(home, '.kolbo'), dir: path.join(home, '.kolbo', 'skills', 'kolbo') },
81
84
  ];
82
85
  }
83
86
 
@@ -85,15 +88,45 @@ function installSkill(t) {
85
88
  const src = path.join(__dirname, '..', 'skill');
86
89
  if (!fs.existsSync(src)) return { ...t, status: 'skill not bundled' };
87
90
  if (!fs.existsSync(t.root)) return { ...t, status: 'not found' };
91
+ const tmp = `${t.dir}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
88
92
  try {
89
- fs.mkdirSync(t.dir, { recursive: true });
90
- fs.cpSync(src, t.dir, { recursive: true });
93
+ // Replace the generated tree so source deletions propagate too. A simple
94
+ // recursive copy leaves obsolete references behind indefinitely.
95
+ fs.rmSync(tmp, { recursive: true, force: true });
96
+ fs.mkdirSync(path.dirname(t.dir), { recursive: true });
97
+ fs.cpSync(src, tmp, { recursive: true });
98
+ fs.writeFileSync(path.join(tmp, MANAGED_FILE), JSON.stringify({
99
+ source: '@kolbo/mcp',
100
+ skill: 'kolbo',
101
+ version: fs.readFileSync(path.join(src, 'VERSION'), 'utf8').trim(),
102
+ packageVersion: PACKAGE_VERSION,
103
+ }, null, 2) + '\n');
104
+ fs.rmSync(t.dir, { recursive: true, force: true });
105
+ fs.renameSync(tmp, t.dir);
91
106
  return { ...t, status: 'installed' };
92
107
  } catch (e) {
108
+ fs.rmSync(tmp, { recursive: true, force: true });
93
109
  return { ...t, status: `failed — ${e.message}` };
94
110
  }
95
111
  }
96
112
 
113
+ // Local MCP launches refresh only installs created by Kolbo. A manually
114
+ // authored `kolbo` skill without the marker is never overwritten.
115
+ function refreshManagedSkills() {
116
+ return skillTargets().map((t) => {
117
+ const marker = path.join(t.dir, MANAGED_FILE);
118
+ if (!fs.existsSync(marker)) return { ...t, status: 'unmanaged' };
119
+ try {
120
+ const installed = JSON.parse(fs.readFileSync(marker, 'utf8'));
121
+ const skillVersion = fs.readFileSync(path.join(__dirname, '..', 'skill', 'VERSION'), 'utf8').trim();
122
+ if (installed.packageVersion === PACKAGE_VERSION && installed.version === skillVersion) {
123
+ return { ...t, status: 'current' };
124
+ }
125
+ } catch (_) {}
126
+ return installSkill(t);
127
+ });
128
+ }
129
+
97
130
  // Versions before this fix wrote the Claude Code entry into
98
131
  // ~/.claude/settings.json, where Claude Code silently ignores it. Anyone who ran
99
132
  // those is left with a dead entry that makes it look configured. Detect and say
@@ -156,4 +189,30 @@ async function run() {
156
189
  return 0;
157
190
  }
158
191
 
159
- module.exports = { run };
192
+ async function runSkillOnly() {
193
+ const out = (s = '') => process.stdout.write(s + '\n');
194
+ const skills = skillTargets().map(installSkill);
195
+ const ready = skills.filter((s) => s.status === 'installed');
196
+
197
+ out();
198
+ out(' Kolbo Skill — standalone install');
199
+ out(' ────────────────────────────────');
200
+ for (const s of skills) {
201
+ out(` ${s.status === 'installed' ? '✓' : '·'} ${s.name}: ${s.status}`);
202
+ }
203
+ out();
204
+
205
+ if (ready.length === 0) {
206
+ out(' No supported skill directory found (.claude or .agents).');
207
+ out(' Install or open a compatible agent, then run this command again.');
208
+ out();
209
+ return 0;
210
+ }
211
+
212
+ out(' Done — the Kolbo Skill is installed.');
213
+ out(' MCP settings were not changed. Restart your agent to load the skill.');
214
+ out();
215
+ return 0;
216
+ }
217
+
218
+ module.exports = { run, runSkillOnly, refreshManagedSkills, MANAGED_FILE };