livepilot 1.27.0 → 1.27.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 (66) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/README.md +26 -7
  3. package/bin/livepilot.js +86 -23
  4. package/installer/install.js +21 -12
  5. package/livepilot/.Codex-plugin/plugin.json +1 -1
  6. package/livepilot/.claude-plugin/plugin.json +1 -1
  7. package/livepilot/skills/livepilot-core/SKILL.md +8 -8
  8. package/livepilot/skills/livepilot-core/references/overview.md +2 -2
  9. package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
  10. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  11. package/m4l_device/livepilot_bridge.js +48 -11
  12. package/mcp_server/__init__.py +1 -1
  13. package/mcp_server/atlas/__init__.py +20 -1
  14. package/mcp_server/atlas/tools.py +83 -22
  15. package/mcp_server/composer/fast/brief_builder.py +6 -2
  16. package/mcp_server/composer/full/apply.py +9 -0
  17. package/mcp_server/composer/full/layer_planner.py +16 -6
  18. package/mcp_server/composer/tools.py +12 -6
  19. package/mcp_server/connection.py +2 -1
  20. package/mcp_server/creative_constraints/engine.py +46 -0
  21. package/mcp_server/experiment/engine.py +10 -3
  22. package/mcp_server/grader/client.py +30 -2
  23. package/mcp_server/grader/tools.py +8 -2
  24. package/mcp_server/hook_hunter/analyzer.py +15 -2
  25. package/mcp_server/m4l_bridge.py +145 -54
  26. package/mcp_server/memory/taste_graph.py +16 -0
  27. package/mcp_server/memory/technique_store.py +23 -3
  28. package/mcp_server/mix_engine/state_builder.py +4 -2
  29. package/mcp_server/musical_intelligence/detectors.py +11 -2
  30. package/mcp_server/musical_intelligence/tools.py +15 -2
  31. package/mcp_server/preview_studio/engine.py +30 -1
  32. package/mcp_server/project_brain/tools.py +56 -52
  33. package/mcp_server/reference_engine/tools.py +22 -2
  34. package/mcp_server/runtime/execution_router.py +6 -0
  35. package/mcp_server/runtime/live_version.py +27 -8
  36. package/mcp_server/runtime/mcp_dispatch.py +22 -0
  37. package/mcp_server/runtime/remote_commands.py +9 -4
  38. package/mcp_server/runtime/safety_kernel.py +11 -0
  39. package/mcp_server/sample_engine/critics.py +7 -3
  40. package/mcp_server/sample_engine/slice_workflow.py +3 -2
  41. package/mcp_server/sample_engine/tools.py +53 -21
  42. package/mcp_server/server.py +1 -1
  43. package/mcp_server/song_brain/builder.py +17 -1
  44. package/mcp_server/sound_design/tools.py +1 -1
  45. package/mcp_server/splice_client/http_bridge.py +43 -1
  46. package/mcp_server/splice_client/models.py +7 -3
  47. package/mcp_server/synthesis_brain/adapters/analog.py +13 -1
  48. package/mcp_server/synthesis_brain/adapters/operator.py +5 -2
  49. package/mcp_server/synthesis_brain/adapters/wavetable.py +4 -4
  50. package/mcp_server/tools/_composition_engine/models.py +6 -0
  51. package/mcp_server/tools/_composition_engine/sections.py +4 -0
  52. package/mcp_server/tools/clips.py +5 -4
  53. package/mcp_server/tools/composition.py +5 -1
  54. package/mcp_server/tools/midi_io.py +40 -1
  55. package/mcp_server/tools/transport.py +1 -1
  56. package/mcp_server/user_corpus/plugin_engine/detector.py +66 -11
  57. package/mcp_server/user_corpus/runner.py +7 -1
  58. package/mcp_server/user_corpus/scanners/amxd.py +24 -13
  59. package/mcp_server/user_corpus/tools.py +45 -9
  60. package/mcp_server/wonder_mode/tools.py +66 -27
  61. package/package.json +1 -1
  62. package/remote_script/LivePilot/__init__.py +21 -8
  63. package/remote_script/LivePilot/server.py +23 -3
  64. package/remote_script/LivePilot/tracks.py +11 -5
  65. package/requirements.txt +1 -1
  66. package/server.json +2 -2
@@ -7,9 +7,12 @@ suggestion, chain building, and device comparison.
7
7
  from __future__ import annotations
8
8
 
9
9
  import json
10
+ import logging
10
11
  import os
11
12
  from typing import Any, Dict, List, Optional
12
13
 
14
+ logger = logging.getLogger(__name__)
15
+
13
16
 
14
17
  class AtlasManager:
15
18
  """In-memory device atlas with indexed lookups."""
@@ -47,10 +50,26 @@ class AtlasManager:
47
50
  dev_category = dev.get("category", "")
48
51
 
49
52
  if dev_id:
53
+ if dev_id in self._by_id and self._by_id[dev_id] is not dev:
54
+ logger.warning(
55
+ "atlas: duplicate device id %r shadows a prior entry "
56
+ "(name=%r); last-wins", dev_id, dev_name,
57
+ )
50
58
  self._by_id[dev_id] = dev
51
59
  if dev_name:
52
- self._by_name[dev_name.lower()] = dev
60
+ name_key = dev_name.lower()
61
+ if name_key in self._by_name and self._by_name[name_key] is not dev:
62
+ logger.warning(
63
+ "atlas: duplicate device name %r shadows a prior entry "
64
+ "(id=%r); last-wins", dev_name, dev_id,
65
+ )
66
+ self._by_name[name_key] = dev
53
67
  if dev_uri:
68
+ if dev_uri in self._by_uri and self._by_uri[dev_uri] is not dev:
69
+ logger.warning(
70
+ "atlas: duplicate device uri %r shadows a prior entry "
71
+ "(id=%r); last-wins", dev_uri, dev_id,
72
+ )
54
73
  self._by_uri[dev_uri] = dev
55
74
 
56
75
  # Category index
@@ -109,11 +109,14 @@ def atlas_search(ctx: Context, query: str, category: str = "all", limit: int = 1
109
109
  try:
110
110
  from .overlays import get_overlay_index
111
111
  idx = get_overlay_index()
112
- # Search all non-`packs` namespaces; `packs` is already in the bundled atlas
113
- for ns in idx.list_namespaces():
114
- if ns == "packs":
115
- continue
116
- overlay_results.extend(idx.search(query, namespace=ns, limit=limit))
112
+ # Single pass over the overlay index (namespace=None scans all
113
+ # namespaces at once), then drop `packs` client-side — `packs` is
114
+ # already covered by the bundled atlas. Avoids re-scanning the whole
115
+ # index once per namespace.
116
+ overlay_results = [
117
+ e for e in idx.search(query, namespace=None, limit=limit * 2)
118
+ if e.namespace != "packs"
119
+ ]
117
120
  except Exception: # noqa: BLE001 — never fail atlas_search over an overlay glitch
118
121
  pass
119
122
 
@@ -177,10 +180,13 @@ def atlas_search(ctx: Context, query: str, category: str = "all", limit: int = 1
177
180
 
178
181
 
179
182
  @mcp.tool()
180
- def atlas_device_info(ctx: Context, device_id: str) -> dict:
183
+ def atlas_device_info(ctx: Context, device_id: str, verbose: bool = True) -> dict:
181
184
  """Get complete atlas knowledge about a device — parameters, recipes, pairings, gotchas.
182
185
 
183
186
  device_id: the atlas ID or device name (e.g., "drift", "Compressor", "808_core_kit")
187
+ verbose: when True (default) return the full raw atlas record. Set False for a
188
+ compact summary (capped description, tag/technique counts) — useful
189
+ when you only need to identify the device, not read every recipe.
184
190
  """
185
191
  atlas = _get_atlas()
186
192
  if atlas is None:
@@ -189,7 +195,29 @@ def atlas_device_info(ctx: Context, device_id: str) -> dict:
189
195
  entry = atlas.lookup(device_id)
190
196
  if entry is None:
191
197
  return {"error": f"Device '{device_id}' not found in atlas", "suggestion": "Use atlas_search to find devices"}
192
- return entry
198
+ if verbose:
199
+ return entry
200
+ # Compact mode: the common case doesn't need the full multi-KB record.
201
+ # Truncate the longest free-text fields the way atlas_search already caps
202
+ # sonic_description, and report counts for list-heavy fields so the caller
203
+ # knows to re-request verbose for the full detail.
204
+ summary: dict = {
205
+ "id": entry.get("id", ""),
206
+ "name": entry.get("name", ""),
207
+ "uri": entry.get("uri", ""),
208
+ "category": entry.get("category", ""),
209
+ "enriched": bool(entry.get("enriched", False)),
210
+ "sonic_description": (
211
+ entry.get("sonic_description") or entry.get("description", "")
212
+ )[:_ATLAS_SEARCH_DESCRIPTION_CHAR_CAP],
213
+ "character_tags": (
214
+ entry.get("character_tags") or entry.get("tags", [])
215
+ )[:8],
216
+ "sweet_spot": str(entry.get("sweet_spot", ""))[:_ATLAS_SEARCH_DESCRIPTION_CHAR_CAP],
217
+ }
218
+ summary.update(_surface_enriched_fields(entry))
219
+ summary["verbose_available"] = True
220
+ return summary
193
221
 
194
222
 
195
223
  @mcp.tool()
@@ -258,20 +286,20 @@ def atlas_chain_suggest(ctx: Context, role: str, genre: str = "") -> dict:
258
286
  query_parts.append(genre)
259
287
  query = " ".join(query_parts)
260
288
 
261
- for ns in idx.list_namespaces():
262
- if ns == "packs":
263
- continue
264
- hits = idx.search(query, namespace=ns, limit=5)
265
- for entry in hits:
266
- overlay_suggestions.append({
289
+ hits = [
290
+ e for e in idx.search(query, namespace=None, limit=10)
291
+ if e.namespace != "packs"
292
+ ]
293
+ for entry in hits:
294
+ overlay_suggestions.append({
267
295
  "namespace": entry.namespace,
268
296
  "entity_id": entry.entity_id,
269
297
  "name": entry.name,
270
298
  "description": (entry.description or "")[:120],
271
299
  "tags": list(entry.tags)[:5],
272
- "source": f"user_overlay:{entry.namespace}",
273
- "note": "Load via search_browser or extension_atlas_get; no Live URI.",
274
- })
300
+ "source": f"user_overlay:{entry.namespace}",
301
+ "note": "Load via search_browser or extension_atlas_get; no Live URI.",
302
+ })
275
303
  except Exception: # noqa: BLE001 — never fail chain_suggest over an overlay glitch
276
304
  pass
277
305
 
@@ -662,14 +690,17 @@ def scan_full_library(
662
690
  if not force and os.path.exists(atlas_path):
663
691
  age = time.time() - os.path.getmtime(atlas_path)
664
692
  if age < 86400:
665
- # Reload if not already loaded
666
- import mcp_server.atlas as atlas_mod
667
- if atlas_mod._atlas_instance is None:
668
- atlas_mod._atlas_instance = AtlasManager(atlas_path)
693
+ # Use the public, thread-safe loader rather than poking the
694
+ # legacy `_atlas_instance` cell directly: get_atlas() loads
695
+ # lazily via the Singleton holder and is the only path the rest
696
+ # of the module actually reads back from. The old direct
697
+ # AtlasManager(atlas_path) construction wrote a cell get_atlas()
698
+ # never consults.
699
+ from . import get_atlas
669
700
  return {
670
701
  "status": "already_exists",
671
702
  "age_hours": round(age / 3600, 1),
672
- "device_count": atlas_mod._atlas_instance.device_count,
703
+ "device_count": get_atlas().device_count,
673
704
  "message": "Atlas is recent. Use force=True to rescan.",
674
705
  }
675
706
 
@@ -796,6 +827,34 @@ def _serialize_overlay_entry(entry) -> dict:
796
827
  }
797
828
 
798
829
 
830
+ _OVERLAY_DESCRIPTION_PREVIEW = 240
831
+
832
+
833
+ def _serialize_overlay_entry_lite(entry) -> dict:
834
+ """Lightweight search-result serializer: drops the full YAML `body`.
835
+
836
+ The `body` of an overlay entry can be ~17 KB; inlining it for every one of
837
+ up to `limit` matches makes a single extension_atlas_search return hundreds
838
+ of KB. The search path only needs identity + a description preview so the
839
+ caller can decide which entry to fetch in full via extension_atlas_get.
840
+ """
841
+ description = entry.description or ""
842
+ truncated = len(description) > _OVERLAY_DESCRIPTION_PREVIEW
843
+ return {
844
+ "namespace": entry.namespace,
845
+ "entity_type": entry.entity_type,
846
+ "entity_id": entry.entity_id,
847
+ "name": entry.name,
848
+ "description": (
849
+ description[:_OVERLAY_DESCRIPTION_PREVIEW] + "…"
850
+ if truncated else description
851
+ ),
852
+ "tags": entry.tags,
853
+ "artists": entry.artists,
854
+ "requires_box": entry.requires_box,
855
+ }
856
+
857
+
799
858
  @mcp.tool()
800
859
  def extension_atlas_search(ctx: Context, query: str,
801
860
  namespace: str = "",
@@ -822,7 +881,9 @@ def extension_atlas_search(ctx: Context, query: str,
822
881
  "namespace": namespace or None,
823
882
  "entity_type": entity_type or None,
824
883
  "count": len(matches),
825
- "results": [_serialize_overlay_entry(e) for e in matches],
884
+ "results": [_serialize_overlay_entry_lite(e) for e in matches],
885
+ "note": "Search results omit the full YAML body; call "
886
+ "extension_atlas_get(namespace, entity_id) for the complete entry.",
826
887
  }
827
888
 
828
889
 
@@ -1402,8 +1402,12 @@ def build_creative_brief(
1402
1402
  " — those are filtered out before the brief reaches you.\n"
1403
1403
  "5. **TIER-1: Fire each search in `recommended_searches` BEFORE designing\n"
1404
1404
  " that role.** Each entry gives you a (tool, query) pair — call the named\n"
1405
- " Ableton Knowledge MCP tool. Most queries hit the Live manual or video\n"
1406
- " tutorials; capture 1 useful snippet per role and apply it.\n"
1405
+ " Ableton Knowledge MCP tool with the `mcp__Ableton_Knowledge__` prefix\n"
1406
+ " (e.g. search_live_manual mcp__Ableton_Knowledge__search_live_manual).\n"
1407
+ " Most queries hit the Live manual or video tutorials; capture 1 useful\n"
1408
+ " snippet per role and apply it. The Ableton Knowledge MCP is OPTIONAL —\n"
1409
+ " if any mcp__Ableton_Knowledge__* tool is unavailable or returns nothing,\n"
1410
+ " skip it and proceed using the creative guidance alone.\n"
1407
1411
  "6. **TIER-2: If `reference_artist` is set**, also fire each search in\n"
1408
1412
  " `reference_searches` and design USING that artist's signature techniques.\n"
1409
1413
  "7. For each layer: pick ONE uri from instruments_by_role[role], design\n"
@@ -719,6 +719,15 @@ async def apply_full_plan_v2(ctx: Context, plan: dict) -> dict:
719
719
  continue
720
720
  else:
721
721
  track_index = int(track_index)
722
+ # BUG-FIX (P2 zombie-cleanup): a reused/existing track must also be
723
+ # registered as "applied". Otherwise the postflight zombie-cleanup
724
+ # (which excludes only applied_track_indices) sees a reused track
725
+ # that currently has no session clips / instrument — e.g. one given
726
+ # only arrangement clips — as an empty leftover and DELETES it.
727
+ # Reused tracks must never be eligible for cleanup, and postflight
728
+ # monitoring must be restored on them too.
729
+ if track_index >= 0 and track_index not in applied_track_indices:
730
+ applied_track_indices.append(track_index)
722
731
 
723
732
  # Optional instrument load
724
733
  instrument = track_spec.get("instrument") or {}
@@ -408,12 +408,22 @@ def plan_sections(
408
408
  Returns a list of dicts: {name, bars, layers, start_bar}.
409
409
  """
410
410
  if section_template is None:
411
- raise ValueError(
412
- "plan_sections() requires explicit section_template in v1.24+. "
413
- "SECTION_TEMPLATES was removed — the LLM provides form, not the "
414
- "framework. Pass a section_template list or use the v1.24 full-mode "
415
- "compose flow which is LLM-creative."
416
- )
411
+ # v1.24 removed the built-in SECTION_TEMPLATES registry (form is the
412
+ # LLM's job, not the framework's). But the deterministic scaffolding
413
+ # tools (augment_with_samples, get_composition_plan,
414
+ # propose_composer_branches) and wonder_mode still call this without a
415
+ # template. Degrade to a SINGLE full-length section containing every
416
+ # selected role — this is NOT a form template (no genre-keyed
417
+ # multi-section sequence, no module-level bar-count registry), it just
418
+ # lets those tools emit a working skeleton. Callers that want real form
419
+ # pass an explicit section_template.
420
+ roles = _select_roles(intent)
421
+ return [{
422
+ "name": "Full",
423
+ "bars": max(4, int(intent.duration_bars or 64)),
424
+ "layers": list(roles),
425
+ "start_bar": 0,
426
+ }]
417
427
 
418
428
  template = section_template
419
429
 
@@ -378,12 +378,18 @@ def consult_ableton_knowledge(
378
378
  template, and surfaces sources alongside the answer.
379
379
 
380
380
  Examples:
381
- consult_ableton_knowledge("how do I make my kick punchier?")
382
- plan: [search_live_manual("Saturator"), search_transcripts("kick punch"),
383
- search_videos("kick design tutorial")] + synthesis template
384
- consult_ableton_knowledge("what's the difference between Operator and Wavetable?")
385
- → plan: [search_live_manual("Operator"), search_live_manual("Wavetable"),
386
- search_transcripts("Operator vs Wavetable")]
381
+ consult_ableton_knowledge("what does the Saturator Drive knob do?")
382
+ intent: device
383
+ → plan: [search_live_manual("what does the Saturator Drive knob do?"),
384
+ search_videos("what does the Saturator Drive knob do?"),
385
+ search_transcripts("what does the Saturator Drive knob do?")]
386
+ + synthesis template
387
+ consult_ableton_knowledge("how do I make my kick punchier?", {"current_genre": "techno"})
388
+ → intent: sound_design
389
+ → plan: [search_transcripts("techno how do I make my kick punchier?"),
390
+ search_videos("techno how do I make my kick punchier? tutorial"),
391
+ search_knowledge_base("how do I make my kick punchier?")]
392
+ + synthesis template
387
393
 
388
394
  session_context (optional): {
389
395
  "current_genre": "techno",
@@ -258,7 +258,8 @@ class AbletonConnection:
258
258
  needs_retry = fresh_connect and _is_single_client_state_error(response)
259
259
 
260
260
  if needs_retry:
261
- self.disconnect()
261
+ with self._lock:
262
+ self.disconnect()
262
263
  time.sleep(SINGLE_CLIENT_RETRY_DELAY)
263
264
  with self._lock:
264
265
  self.connect()
@@ -72,9 +72,28 @@ def validate_plan_against_constraints(
72
72
  session_info = session_info or {}
73
73
  violations: list[str] = []
74
74
  warnings: list[str] = []
75
+ unenforced: list[str] = []
75
76
 
76
77
  steps = plan.get("steps", [])
77
78
 
79
+ # Modes that carry no structural step-level rule (taste/heuristic only).
80
+ # They were previously skipped silently — surface them as warnings so a
81
+ # caller can't mistake "no violations" for "validated against this mode".
82
+ advisory_modes = {
83
+ "mood_shift_without_new_fx": (
84
+ "shift the mood with existing tools — adding effects is discouraged "
85
+ "but not auto-detected here"
86
+ ),
87
+ "make_it_stranger_but_keep_the_hook": (
88
+ "push novelty while preserving the hook — not enforceable from plan "
89
+ "steps alone"
90
+ ),
91
+ "club_translation_safe": (
92
+ "keep changes club/DJ-friendly — judged by mix/tempo character, not "
93
+ "individual steps"
94
+ ),
95
+ }
96
+
78
97
  for constraint in constraint_set.constraints:
79
98
  if constraint == "no_new_tracks":
80
99
  for step in steps:
@@ -96,10 +115,37 @@ def validate_plan_against_constraints(
96
115
  if step.get("action", "") in mix_actions:
97
116
  violations.append(f"Step modifies mix ({step['action']}) — violates arrangement_only")
98
117
 
118
+ elif constraint == "use_loaded_devices_only":
119
+ load_actions = {"load_browser_item", "insert_device", "find_and_load_device",
120
+ "load_device_by_uri", "load_sample_to_simpler",
121
+ "replace_simpler_sample", "insert_rack_chain"}
122
+ for step in steps:
123
+ if step.get("action", "") in load_actions:
124
+ violations.append(
125
+ f"Step loads a new device ({step['action']}) — violates use_loaded_devices_only"
126
+ )
127
+
128
+ elif constraint == "performance_safe_creative":
129
+ unsafe_actions = {"create_midi_track", "create_audio_track", "create_return_track",
130
+ "delete_track", "delete_device", "delete_clip", "delete_scene"}
131
+ for step in steps:
132
+ if step.get("action", "") in unsafe_actions:
133
+ violations.append(
134
+ f"Step is unsafe during live performance ({step['action']}) — "
135
+ f"violates performance_safe_creative"
136
+ )
137
+
138
+ elif constraint in advisory_modes:
139
+ unenforced.append(constraint)
140
+ warnings.append(
141
+ f"{constraint} is advisory: {advisory_modes[constraint]}"
142
+ )
143
+
99
144
  return {
100
145
  "valid": len(violations) == 0,
101
146
  "violations": violations,
102
147
  "warnings": warnings,
148
+ "unenforced_constraints": unenforced,
103
149
  "constraint_count": len(constraint_set.constraints),
104
150
  }
105
151
 
@@ -244,12 +244,19 @@ async def run_branch_async(
244
244
  ]
245
245
 
246
246
  steps_executed = sum(1 for r in exec_results if r.ok)
247
+ # Only remote_command steps land on Ableton's linear undo stack. Bridge
248
+ # (M4L/OSC) and mcp_tool mutations do NOT, so issuing one `undo` per
249
+ # successful step over-undoes and walks back unrelated prior user edits.
250
+ # Count undos from remote_command successes alone.
251
+ undo_count = sum(
252
+ 1 for r in exec_results if r.ok and r.backend == "remote_command"
253
+ )
247
254
  branch.executed_at_ms = int(time.time() * 1000)
248
255
  branch.after_snapshot = capture_fn()
249
256
 
250
- # Undo all successful steps back to checkpoint. Undo is a remote_command,
251
- # route it through the normal ableton.send_command path for simplicity.
252
- for _ in range(steps_executed):
257
+ # Undo only the remote_command steps back to checkpoint. Undo is itself a
258
+ # remote_command, routed through the normal ableton.send_command path.
259
+ for _ in range(undo_count):
253
260
  try:
254
261
  ableton.send_command("undo", {})
255
262
  except Exception as exc:
@@ -17,11 +17,14 @@ session state, returning the same shape used by `mcp_server/audit/checks.py`:
17
17
 
18
18
  from __future__ import annotations
19
19
 
20
+ import logging
20
21
  from typing import Any, Callable, Iterable
21
22
 
22
23
  from mcp_server.audit import checks as audit_checks
23
24
  from mcp_server.audit.checks import infer_role
24
25
 
26
+ _logger = logging.getLogger(__name__)
27
+
25
28
 
26
29
  _TRACK_COUNT_WARN = 8
27
30
  _TRACK_COUNT_FAIL = 12
@@ -441,11 +444,21 @@ def _aggregate_per_track(
441
444
  try:
442
445
  result = check_fn(*args)
443
446
  except Exception as exc:
447
+ _logger.warning(
448
+ "grader check %s failed for criterion %r track %r (%s): %s",
449
+ getattr(check_fn, "__name__", repr(check_fn)),
450
+ criterion_id,
451
+ t.get("index"),
452
+ t.get("name"),
453
+ exc,
454
+ exc_info=True,
455
+ )
444
456
  per_track.append({
445
457
  "track_index": t.get("index"),
446
458
  "name": t.get("name"),
447
459
  "role": role,
448
460
  "severity": "n/a",
461
+ "errored": True,
449
462
  "summary": f"check failed: {type(exc).__name__}",
450
463
  "issues": [],
451
464
  "evidence": {},
@@ -459,15 +472,30 @@ def _aggregate_per_track(
459
472
  })
460
473
 
461
474
  actionable = [r for r in per_track if r["severity"] != "n/a"]
475
+ n_errored = sum(1 for r in per_track if r.get("errored"))
462
476
 
463
477
  if not actionable:
478
+ if n_errored:
479
+ # Every checkable track raised — do NOT masquerade an all-error
480
+ # sweep as a benign no-op pass. Surface it as a failure.
481
+ return {
482
+ "id": criterion_id,
483
+ "passed": False,
484
+ "severity": "fail",
485
+ "summary": (
486
+ f"{n_errored} track(s) errored during check; no track "
487
+ "produced a usable result"
488
+ ),
489
+ "issues": [],
490
+ "evidence": {"per_track": per_track, "errored": n_errored},
491
+ }
464
492
  return {
465
493
  "id": criterion_id,
466
494
  "passed": True,
467
495
  "severity": "n/a",
468
496
  "summary": "No checkable tracks (data missing or no applicable role)",
469
497
  "issues": [],
470
- "evidence": {"per_track": per_track},
498
+ "evidence": {"per_track": per_track, "errored": n_errored},
471
499
  }
472
500
 
473
501
  has_fail = any(r["severity"] == "fail" for r in actionable)
@@ -505,7 +533,7 @@ def _aggregate_per_track(
505
533
  "severity": rubric_severity,
506
534
  "summary": summary,
507
535
  "issues": issues,
508
- "evidence": {"per_track": per_track},
536
+ "evidence": {"per_track": per_track, "errored": n_errored},
509
537
  }
510
538
 
511
539
 
@@ -64,9 +64,15 @@ def _build_light_state(ctx: Context) -> dict[str, Any]:
64
64
  info = _safe_call(ableton, "get_track_info", {"track_index": idx})
65
65
  if not info:
66
66
  continue
67
- if info.get("is_foldable") and info.get("is_master"):
67
+ # Skip master/return tracks (real Remote Script field names are
68
+ # is_master_track / is_return_track, not is_master / is_return).
69
+ if info.get("is_master_track") or info.get("is_return_track"):
68
70
  continue
69
- if info.get("is_return"):
71
+ # Skip group containers — foldable group tracks hold no clips or
72
+ # devices of their own, so they inflate the §7.3 track count and
73
+ # (having a mixer volume but no role/ghost tag) trip the
74
+ # buried-track check. Only count real, content-bearing layers.
75
+ if info.get("is_foldable"):
70
76
  continue
71
77
  tracks.append({
72
78
  "index": idx,
@@ -108,8 +108,21 @@ def find_hook_candidates(
108
108
  for c in candidates:
109
109
  # Check if hook is present in payoff sections (via motif locations)
110
110
  if c.hook_type == "melodic" and motif_data:
111
- for motif in motif_data.get("motifs", []):
112
- if motif.get("name", "") in c.hook_id:
111
+ for idx, motif in enumerate(motif_data.get("motifs", [])):
112
+ # BUG-B61 fix: the old test `motif.get("name", "") in c.hook_id`
113
+ # was always True for real motif data, because the engine emits
114
+ # `motif_id` (not `name`) so .get("name","") returned "", and
115
+ # `"" in c.hook_id` is True for every candidate. That boosted
116
+ # every melodic candidate by every motif's recurrence. Rebuild
117
+ # the source motif's hook_id exactly as it was constructed above
118
+ # (motif_id -> name -> idxN fallback) and require an exact match
119
+ # so each candidate is boosted only by its own source motif.
120
+ identifier = (
121
+ motif.get("motif_id")
122
+ or motif.get("name")
123
+ or f"idx{idx}"
124
+ )
125
+ if f"motif_{identifier}" == c.hook_id:
113
126
  # Motif with high recurrence across sections = stronger hook
114
127
  c.memorability = min(1.0, c.memorability + motif.get("recurrence", 0) * 0.2)
115
128