davinci-resolve-mcp 2.51.0 → 2.52.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,50 @@
2
2
 
3
3
  Release history for the DaVinci Resolve MCP Server. The latest release is summarized in the root README; older entries live here to keep the README focused.
4
4
 
5
+ ## What's New in v2.52.1
6
+
7
+ Analysis reuse no longer blocks on missing capabilities when every clip is
8
+ satisfied by an existing report (issue #68). `build_plan` records
9
+ `capability_gaps` from the *requested* options before the per-clip reuse
10
+ decision runs; when a fully-reused plan only re-keys and imports existing
11
+ reports into the current root, no fresh transcription/vision/ffprobe happens,
12
+ so the missing-capability gate must not fire.
13
+
14
+ - **Fixed** the missing-capability gate is now evaluated against the clips that
15
+ still need fresh analysis, not the requested-options gaps. A clip is exempt
16
+ only when it both skips execution and has an existing report path; any clip
17
+ needing fresh work still enforces the gate.
18
+ - **Fixed** extended the exemption to the entry points that short-circuit
19
+ before `execute_plan_async` and were still blocking fully-reused runs: the
20
+ `media_analysis` analyze action, metadata-publish analysis, and batch-job
21
+ creation. The batch CLI `plan` preview no longer prints "Missing tools" for a
22
+ fully-reusable plan.
23
+ - **Changed** the reuse/capability logic is now a shared
24
+ `plan_requires_capabilities()` / `executing_clips()` helper, replacing the
25
+ duplicated inline comprehensions so every gate stays consistent.
26
+ - Includes PR #68 by @diesdaas, which fixed the inner `execute_plan_async` gate
27
+ and isolated the marker-param and host-vision tests from unrelated
28
+ capability/destructive-hook coupling.
29
+
30
+ ## What's New in v2.52.0
31
+
32
+ `edit_engine` tighten now carries audio. Previously `execute_tighten`
33
+ assembled a video-only variant — a speech-driven dead-air cut came out
34
+ silent, with nothing in the preview or readback to warn you (issue #67).
35
+
36
+ - **Fixed** `plan_tighten` mirrors every kept video range onto its linked
37
+ audio track(s) with identical source frames, so the assembled variant
38
+ stays frame-locked and audible. Audio targets the item's detected linked
39
+ audio tracks (same `GetLinkedItems` matching `execute_swap` uses), falling
40
+ back to audio track 1 where a single linked A/V clip's audio lives.
41
+ - **Added** `include_audio` parameter to `plan_tighten` (default `true`).
42
+ Pass `include_audio=false` for the prior video-only assembly.
43
+ - **Added** an `audio_accounting` block to the `execute_tighten` confirm
44
+ preview and readback (planned vs. actual audio/video item counts), so a
45
+ silent variant can no longer ship unnoticed. Old video-only plans
46
+ re-executed against this build still work and are now loudly flagged as
47
+ silent.
48
+
5
49
  ## What's New in v2.51.0
6
50
 
7
51
  CLAP audio embeddings — the final phase of the post-program improvements.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # DaVinci Resolve MCP Server
2
2
 
3
- [![Version](https://img.shields.io/badge/version-2.51.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
3
+ [![Version](https://img.shields.io/badge/version-2.52.1-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
4
4
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
5
5
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
6
6
  [![Tools](https://img.shields.io/badge/MCP%20Tools-34%20(341%20full)-blue.svg)](#server-modes)
package/docs/SKILL.md CHANGED
@@ -722,11 +722,15 @@ clip-count readback plus `brain_edits` rationale rows.
722
722
  `execute_selects(plan_id)` creates a NEW selects timeline from the plan's
723
723
  per-shot source ranges — additive; nothing existing is touched.
724
724
  - `plan_tighten(timeline_name?, target_ratio?, min_pause_seconds?,
725
- handle_seconds?)` — dead-air lifts from transcript-gap evidence for each
726
- timeline item (items without transcripts are reported in `skipped`, never
727
- silently trimmed). `execute_tighten(plan_id)` assembles a tightened
728
- VARIANT timeline from the plan's keep ranges — true partial trims; the
729
- original timeline is never mutated.
725
+ handle_seconds?, include_audio?)` — dead-air lifts from transcript-gap
726
+ evidence for each timeline item (items without transcripts are reported in
727
+ `skipped`, never silently trimmed). `execute_tighten(plan_id)` assembles a
728
+ tightened VARIANT timeline from the plan's keep ranges — true partial
729
+ trims; the original timeline is never mutated. v2.52.0+: kept ranges mirror
730
+ onto each item's linked audio track(s) so the variant is audible (a
731
+ speech-driven cut was previously silent — #67); pass
732
+ `include_audio=false` for a video-only assembly, and the
733
+ `execute_tighten` readback carries an `audio_accounting` block.
730
734
  - `plan_swap(timeline_start_frame | item_name, kind="visual"|"text",
731
735
  limit?)` — alternates for one timeline item via the similarity index,
732
736
  filtered to shots long enough to fill the slot exactly.
package/install.py CHANGED
@@ -35,7 +35,7 @@ from src.utils.update_check import (
35
35
 
36
36
  # ─── Version ──────────────────────────────────────────────────────────────────
37
37
 
38
- VERSION = "2.51.0"
38
+ VERSION = "2.52.1"
39
39
  # Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
40
40
  # Resolve's scripting bridge loads into newer interpreters on recent builds
41
41
  # (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "2.51.0",
3
+ "version": "2.52.1",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
package/src/batch_cli.py CHANGED
@@ -17,6 +17,7 @@ from typing import Any, Dict, List, Optional
17
17
  from src.utils.media_analysis import (
18
18
  build_plan,
19
19
  detect_capabilities,
20
+ plan_requires_capabilities,
20
21
  )
21
22
  from src.utils.media_analysis_jobs import (
22
23
  batch_job_status,
@@ -187,7 +188,7 @@ def _cmd_plan(args: argparse.Namespace) -> int:
187
188
  f"Est. seconds : {plan.get('estimated_seconds_after_reuse', '?')}",
188
189
  json_mode=False,
189
190
  )
190
- if plan.get("capability_gaps"):
191
+ if plan.get("capability_gaps") and plan_requires_capabilities(plan):
191
192
  _emit(f"Missing tools: {plan['capability_gaps']}", json_mode=False)
192
193
  for w in warnings:
193
194
  _emit(f" warning: {w}", json_mode=False)
@@ -80,7 +80,7 @@ if not logging.getLogger().handlers:
80
80
  handlers=[logging.StreamHandler()],
81
81
  )
82
82
 
83
- VERSION = "2.51.0"
83
+ VERSION = "2.52.1"
84
84
  logger = logging.getLogger("davinci-resolve-mcp")
85
85
  logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
86
86
  logger.info(f"Detected platform: {get_platform()}")
package/src/server.py CHANGED
@@ -11,7 +11,7 @@ Usage:
11
11
  python src/server.py --full # Start the 341-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "2.51.0"
14
+ VERSION = "2.52.1"
15
15
 
16
16
  import base64
17
17
  import os
@@ -77,6 +77,7 @@ from src.utils.media_analysis import (
77
77
  execute_plan_async as execute_media_analysis_plan_async,
78
78
  install_guidance as media_analysis_install_guidance,
79
79
  load_report as load_media_analysis_report,
80
+ plan_requires_capabilities as media_analysis_plan_requires_capabilities,
80
81
  query_analysis_index,
81
82
  resolve_output_root as resolve_media_analysis_output_root,
82
83
  short_hash,
@@ -9226,7 +9227,7 @@ async def _publish_clip_metadata_from_analysis(
9226
9227
  )
9227
9228
  if not plan.get("success"):
9228
9229
  return plan
9229
- if plan.get("capability_gaps"):
9230
+ if plan.get("capability_gaps") and media_analysis_plan_requires_capabilities(plan):
9230
9231
  return _media_analysis_missing_capabilities_response(plan, action_label="metadata publish analysis")
9231
9232
 
9232
9233
  manifest = await execute_media_analysis_plan_async(
@@ -16268,7 +16269,11 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
16268
16269
  plan["setup_defaults_applied"] = p.get("_setup_defaults_applied")
16269
16270
  if warnings:
16270
16271
  plan["warnings"] = warnings
16271
- if plan.get("capability_gaps") and not bool(p.get("dry_run", True)):
16272
+ if (
16273
+ plan.get("capability_gaps")
16274
+ and not bool(p.get("dry_run", True))
16275
+ and media_analysis_plan_requires_capabilities(plan)
16276
+ ):
16272
16277
  return _e2_wrap(_media_analysis_missing_capabilities_response(plan))
16273
16278
  if not bool(p.get("dry_run", True)):
16274
16279
  executed = await execute_media_analysis_plan_async(
@@ -16592,6 +16597,15 @@ def _edit_engine_collect_items(tl, *, track_index: Optional[int] = None) -> List
16592
16597
  pass
16593
16598
  except Exception:
16594
16599
  pass
16600
+ # Linked audio track indices so the planner can mirror kept ranges
16601
+ # onto sound (see #67 — tighten variants were coming out silent).
16602
+ try:
16603
+ audio_indices, _audio_note = _edit_engine_linked_audio_tracks(
16604
+ tl, item, row["timeline_start_frame"], row["timeline_end_frame"]
16605
+ )
16606
+ row["audio_track_indices"] = audio_indices
16607
+ except Exception:
16608
+ row["audio_track_indices"] = []
16595
16609
  rows.append(row)
16596
16610
  return rows
16597
16611
 
@@ -16732,10 +16746,13 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
16732
16746
  - execute_selects(plan_id, confirm_token?) — creates a NEW selects timeline
16733
16747
  from the plan's clip in/out ranges. Nothing existing is touched.
16734
16748
  - plan_tighten(timeline_name?, target_ratio?, min_pause_seconds?,
16735
- handle_seconds?) — dead-air lifts from transcript-gap evidence for the
16736
- current (or named) timeline.
16737
- - execute_tighten(plan_id, confirm_token?) duplicates the timeline and
16738
- applies the lifts (ripple) to the DUPLICATE, never the original.
16749
+ handle_seconds?, include_audio?) — dead-air lifts from transcript-gap
16750
+ evidence for the current (or named) timeline. Kept ranges mirror onto the
16751
+ items' linked audio tracks by default so the variant is audible; pass
16752
+ include_audio=false for a video-only (silent) assembly.
16753
+ - execute_tighten(plan_id, confirm_token?) — assembles a tightened VARIANT
16754
+ timeline from the plan's keep ranges (video + mirrored audio), never
16755
+ mutating the original. Readback includes an audio_accounting block.
16739
16756
  - plan_swap(track_index?, timeline_start_frame | item_name, kind?, limit?)
16740
16757
  — alternates for one timeline item via the similarity index.
16741
16758
  - execute_swap(plan_id, alternate_index, confirm_token?) — replaces the
@@ -16807,6 +16824,7 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
16807
16824
  target_ratio=p.get("target_ratio") or p.get("targetRatio"),
16808
16825
  min_pause_seconds=float(p.get("min_pause_seconds") or p.get("minPauseSeconds") or _edit_engine_mod.DEFAULT_MIN_PAUSE_SECONDS),
16809
16826
  handle_seconds=float(p.get("handle_seconds") or p.get("handleSeconds") or _edit_engine_mod.DEFAULT_HANDLE_SECONDS),
16827
+ include_audio=str(p.get("include_audio", p.get("includeAudio", True))).strip().lower() not in {"false", "0", "no", "none", "off"},
16810
16828
  )
16811
16829
 
16812
16830
  if action == "plan_swap":
@@ -16951,6 +16969,8 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
16951
16969
  keep_ranges = plan.get("keep_ranges") or []
16952
16970
  if not keep_ranges:
16953
16971
  return _err("plan has no keep_ranges — re-plan with this version")
16972
+ audio_keep_ranges = sum(1 for r in keep_ranges if str(r.get("track_type", "video")).lower() == "audio")
16973
+ video_keep_ranges = len(keep_ranges) - audio_keep_ranges
16954
16974
  if "confirm_token" not in p and "confirmToken" not in p and _confirm_token_required():
16955
16975
  return _issue_confirm_token(
16956
16976
  action="edit_engine.execute_tighten", params=p,
@@ -16963,6 +16983,13 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
16963
16983
  "timeline_name": plan.get("timeline_name"),
16964
16984
  "lift_count": len(lifts),
16965
16985
  "keep_range_count": len(keep_ranges),
16986
+ "video_keep_range_count": video_keep_ranges,
16987
+ "audio_keep_range_count": audio_keep_ranges,
16988
+ "audio_note": (
16989
+ f"Variant carries audio ({audio_keep_ranges} audio ranges mirror the video cuts)."
16990
+ if audio_keep_ranges
16991
+ else "WARNING: variant is VIDEO-ONLY (silent) — plan has no audio ranges."
16992
+ ),
16966
16993
  "estimated_removed_seconds": sum(l.get("duration_seconds") or 0 for l in lifts),
16967
16994
  },
16968
16995
  )
@@ -17039,6 +17066,23 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
17039
17066
  else None
17040
17067
  ),
17041
17068
  "structural_diff": structural_diff,
17069
+ "audio_accounting": {
17070
+ "planned_audio_ranges": audio_keep_ranges,
17071
+ "planned_video_ranges": video_keep_ranges,
17072
+ "variant_audio_items": sum(
17073
+ 1 for it in (variant.get("items") or [])
17074
+ if (it.get("range") or {}).get("media_type") == 2
17075
+ ),
17076
+ "variant_video_items": sum(
17077
+ 1 for it in (variant.get("items") or [])
17078
+ if (it.get("range") or {}).get("media_type") == 1
17079
+ ),
17080
+ "note": (
17081
+ "Variant carries audio mirrored from the video cuts."
17082
+ if audio_keep_ranges
17083
+ else "Variant is VIDEO-ONLY (silent) — re-plan with include_audio=True for sound."
17084
+ ),
17085
+ },
17042
17086
  },
17043
17087
  "plan_id": plan.get("plan_id"),
17044
17088
  }
@@ -348,13 +348,23 @@ def plan_tighten(
348
348
  target_ratio: Optional[float] = None,
349
349
  min_pause_seconds: float = DEFAULT_MIN_PAUSE_SECONDS,
350
350
  handle_seconds: float = DEFAULT_HANDLE_SECONDS,
351
+ include_audio: bool = True,
351
352
  ) -> Dict[str, Any]:
352
353
  """Propose dead-air lifts for a timeline.
353
354
 
354
355
  `items` rows come from the server (Resolve read): each needs
355
356
  {timeline_start_frame, timeline_end_frame, source_start_frame,
356
- media_ref (clip id / path / hash), item_name?}. Lifts are returned in
357
- timeline frames, latest-first ready.
357
+ media_ref (clip id / path / hash), item_name?}. Optionally each row may
358
+ carry {audio_track_indices: [int, ...]} naming the audio tracks that hold
359
+ the item's linked audio. Lifts are returned in timeline frames, latest-first
360
+ ready.
361
+
362
+ When ``include_audio`` (the default), each kept video range is mirrored onto
363
+ matching audio range(s) so the assembled variant carries sound — a
364
+ speech-driven cut would otherwise come out silent (see #67). Audio is
365
+ mirrored to the item's detected ``audio_track_indices``; absent that, it
366
+ falls back to audio track 1, which is where a single linked A/V clip's audio
367
+ lives.
358
368
  """
359
369
  if not items:
360
370
  return {"success": False, "error": "No timeline items supplied"}
@@ -475,6 +485,11 @@ def plan_tighten(
475
485
  cursor = max(cursor, lift_end_sec)
476
486
  if spec["src_end_sec"] - cursor > 0.05:
477
487
  segments.append((cursor, spec["src_end_sec"]))
488
+ audio_indices: List[int] = []
489
+ if include_audio:
490
+ audio_indices = [int(i) for i in (item.get("audio_track_indices") or []) if int(i) > 0]
491
+ if not audio_indices:
492
+ audio_indices = [1]
478
493
  for seg_start, seg_end in segments:
479
494
  start_frame = int(round(seg_start * clip_fps))
480
495
  end_frame = max(start_frame + 1, int(round(seg_end * clip_fps)) - 1)
@@ -485,23 +500,40 @@ def plan_tighten(
485
500
  "track_type": "video",
486
501
  "track_index": int(item.get("track_index") or 1),
487
502
  })
503
+ # Mirror each kept video range onto its linked audio track(s) with
504
+ # identical source frames so the variant stays frame-locked and
505
+ # audible. mediaType 2 pulls the same media-pool item's audio.
506
+ for audio_index in audio_indices:
507
+ keep_ranges.append({
508
+ "clip_id": spec["resolve_clip_id"],
509
+ "start_frame": start_frame,
510
+ "end_frame": end_frame,
511
+ "track_type": "audio",
512
+ "media_type": 2,
513
+ "track_index": audio_index,
514
+ })
488
515
 
489
516
  removed_frames = sum(l["timeline_end_frame"] - l["timeline_start_frame"] for l in lifts)
517
+ audio_keep_range_count = sum(1 for r in keep_ranges if r.get("track_type") == "audio")
518
+ video_keep_range_count = len(keep_ranges) - audio_keep_range_count
490
519
  plan = save_plan(project_root, {
491
520
  "kind": "tighten",
492
521
  "timeline_name": timeline_name,
493
522
  "timeline_fps": fps,
494
523
  "lifts": lifts,
495
524
  "keep_ranges": keep_ranges,
525
+ "include_audio": bool(include_audio),
496
526
  "skipped": skipped,
497
527
  "summary": (
498
528
  f"{len(lifts)} dead-air lifts, ~{round(removed_frames / fps, 1)}s removed "
499
- f"from '{timeline_name}' (assembled as a tightened variant)"
529
+ f"from '{timeline_name}' (assembled as a tightened variant"
530
+ f"{', video + audio' if include_audio else ', video only'})"
500
531
  ),
501
532
  "settings": {
502
533
  "target_ratio": target_ratio,
503
534
  "min_pause_seconds": min_pause_seconds,
504
535
  "handle_seconds": handle_seconds,
536
+ "include_audio": bool(include_audio),
505
537
  },
506
538
  })
507
539
  return {
@@ -514,11 +546,20 @@ def plan_tighten(
514
546
  "estimated_removed_seconds": round(removed_frames / fps, 2),
515
547
  "lifts": lifts,
516
548
  "keep_range_count": len(keep_ranges),
549
+ "video_keep_range_count": video_keep_range_count,
550
+ "audio_keep_range_count": audio_keep_range_count,
551
+ "include_audio": bool(include_audio),
517
552
  "skipped": skipped,
518
553
  "note": (
519
554
  "Dry-run plan. Execute with edit_engine(action='execute_tighten', "
520
555
  "params={plan_id}) — a tightened VARIANT timeline is assembled from "
521
- "the keep ranges; the original timeline is never mutated."
556
+ "the keep ranges; the original timeline is never mutated. "
557
+ + (
558
+ f"Audio is mirrored onto matching tracks ({audio_keep_range_count} "
559
+ "audio ranges) so the variant is audible."
560
+ if include_audio
561
+ else "include_audio=False: the variant will be VIDEO-ONLY (silent)."
562
+ )
522
563
  ),
523
564
  }
524
565
 
@@ -5117,6 +5117,37 @@ async def _maybe_run_vision_analysis(
5117
5117
  return _vision_analysis(record, motion, options, artifacts, capabilities)
5118
5118
 
5119
5119
 
5120
+ def _clip_is_reused(clip: Any) -> bool:
5121
+ """A clip is satisfied by an existing report and runs no fresh analysis."""
5122
+ return bool(
5123
+ isinstance(clip, dict)
5124
+ and clip.get("skip_execution")
5125
+ and (clip.get("existing_report") or {}).get("path")
5126
+ )
5127
+
5128
+
5129
+ def executing_clips(plan: Dict[str, Any]) -> List[Dict[str, Any]]:
5130
+ """Clips in ``plan`` that still require fresh analysis (not pure reuse)."""
5131
+ return [
5132
+ clip
5133
+ for clip in plan.get("clips", [])
5134
+ if isinstance(clip, dict) and not _clip_is_reused(clip)
5135
+ ]
5136
+
5137
+
5138
+ def plan_requires_capabilities(plan: Dict[str, Any]) -> bool:
5139
+ """True when at least one clip needs fresh analysis.
5140
+
5141
+ ``build_plan`` records ``capability_gaps`` from the *requested* options
5142
+ before the per-clip reuse decision runs. When every clip is satisfied by an
5143
+ existing reusable report, execution only re-keys/imports those reports into
5144
+ the current root and performs no fresh transcription/vision/ffprobe — so the
5145
+ missing-capability gate must not fire. Callers gate with
5146
+ ``plan.get("capability_gaps") and plan_requires_capabilities(plan)``.
5147
+ """
5148
+ return bool(executing_clips(plan))
5149
+
5150
+
5120
5151
  async def execute_plan_async(
5121
5152
  plan: Dict[str, Any],
5122
5153
  params: Optional[Dict[str, Any]] = None,
@@ -5155,7 +5186,8 @@ async def execute_plan_async(
5155
5186
  for clip in blocked
5156
5187
  ],
5157
5188
  }
5158
- if plan.get("capability_gaps"):
5189
+ fresh_clips = executing_clips(plan)
5190
+ if plan.get("capability_gaps") and fresh_clips:
5159
5191
  return {
5160
5192
  "success": False,
5161
5193
  "error": "Cannot execute analysis with missing required capabilities",
@@ -5204,17 +5236,13 @@ async def execute_plan_async(
5204
5236
  and vision_uses_chat_context(options, caps)
5205
5237
  and not _coerce_bool(params.get("confirm_deep") or params.get("confirmDeep"), default=False)
5206
5238
  ):
5207
- executing = [
5208
- clip for clip in plan.get("clips", [])
5209
- if not (clip.get("skip_execution") and (clip.get("existing_report") or {}).get("path"))
5210
- ]
5211
- estimated_frames = sum(int(c.get("analysis_keyframe_budget") or 0) for c in executing)
5239
+ estimated_frames = sum(int(c.get("analysis_keyframe_budget") or 0) for c in fresh_clips)
5212
5240
  return {
5213
5241
  "success": True,
5214
5242
  "status": "confirmation_required",
5215
5243
  "reason": "deep_depth_cost_estimate",
5216
5244
  "estimate": {
5217
- "clip_count": len(executing),
5245
+ "clip_count": len(fresh_clips),
5218
5246
  "estimated_frames": estimated_frames,
5219
5247
  "estimated_vision_tokens": estimated_frames * AVG_VISION_TOKENS_PER_FRAME,
5220
5248
  "tokens_per_frame_assumption": AVG_VISION_TOKENS_PER_FRAME,
@@ -24,6 +24,7 @@ from src.utils.media_analysis import (
24
24
  detect_capabilities,
25
25
  execute_plan,
26
26
  normalize_path,
27
+ plan_requires_capabilities,
27
28
  resolve_output_root,
28
29
  stable_clip_directory,
29
30
  summarize_reports,
@@ -369,7 +370,7 @@ def create_batch_job(
369
370
  )
370
371
  if not plan.get("success"):
371
372
  return plan
372
- if plan.get("capability_gaps"):
373
+ if plan.get("capability_gaps") and plan_requires_capabilities(plan):
373
374
  return {
374
375
  "success": False,
375
376
  "status": "missing_required_capabilities",