livepilot 1.9.13 → 1.9.15

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 (105) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/AGENTS.md +3 -3
  3. package/CHANGELOG.md +51 -0
  4. package/CONTRIBUTING.md +1 -1
  5. package/README.md +7 -7
  6. package/bin/livepilot.js +32 -8
  7. package/installer/install.js +21 -2
  8. package/livepilot/.Codex-plugin/plugin.json +2 -2
  9. package/livepilot/.claude-plugin/plugin.json +2 -2
  10. package/livepilot/agents/livepilot-producer/AGENT.md +243 -49
  11. package/livepilot/skills/livepilot-core/SKILL.md +81 -6
  12. package/livepilot/skills/livepilot-core/references/m4l-devices.md +2 -2
  13. package/livepilot/skills/livepilot-core/references/overview.md +3 -3
  14. package/livepilot/skills/livepilot-core/references/sound-design.md +3 -2
  15. package/livepilot/skills/livepilot-release/SKILL.md +13 -13
  16. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  17. package/m4l_device/livepilot_bridge.js +6 -3
  18. package/mcp_server/__init__.py +1 -1
  19. package/mcp_server/curves.py +11 -3
  20. package/mcp_server/evaluation/__init__.py +1 -0
  21. package/mcp_server/evaluation/fabric.py +575 -0
  22. package/mcp_server/evaluation/feature_extractors.py +84 -0
  23. package/mcp_server/evaluation/policy.py +67 -0
  24. package/mcp_server/evaluation/tools.py +53 -0
  25. package/mcp_server/memory/__init__.py +11 -2
  26. package/mcp_server/memory/anti_memory.py +78 -0
  27. package/mcp_server/memory/promotion.py +94 -0
  28. package/mcp_server/memory/session_memory.py +108 -0
  29. package/mcp_server/memory/taste_memory.py +158 -0
  30. package/mcp_server/memory/technique_store.py +2 -1
  31. package/mcp_server/memory/tools.py +112 -0
  32. package/mcp_server/mix_engine/__init__.py +1 -0
  33. package/mcp_server/mix_engine/critics.py +299 -0
  34. package/mcp_server/mix_engine/models.py +152 -0
  35. package/mcp_server/mix_engine/planner.py +103 -0
  36. package/mcp_server/mix_engine/state_builder.py +316 -0
  37. package/mcp_server/mix_engine/tools.py +214 -0
  38. package/mcp_server/performance_engine/__init__.py +1 -0
  39. package/mcp_server/performance_engine/models.py +148 -0
  40. package/mcp_server/performance_engine/planner.py +267 -0
  41. package/mcp_server/performance_engine/safety.py +162 -0
  42. package/mcp_server/performance_engine/tools.py +183 -0
  43. package/mcp_server/project_brain/__init__.py +6 -0
  44. package/mcp_server/project_brain/arrangement_graph.py +64 -0
  45. package/mcp_server/project_brain/automation_graph.py +72 -0
  46. package/mcp_server/project_brain/builder.py +123 -0
  47. package/mcp_server/project_brain/capability_graph.py +64 -0
  48. package/mcp_server/project_brain/models.py +282 -0
  49. package/mcp_server/project_brain/refresh.py +80 -0
  50. package/mcp_server/project_brain/role_graph.py +103 -0
  51. package/mcp_server/project_brain/session_graph.py +51 -0
  52. package/mcp_server/project_brain/tools.py +144 -0
  53. package/mcp_server/reference_engine/__init__.py +1 -0
  54. package/mcp_server/reference_engine/gap_analyzer.py +239 -0
  55. package/mcp_server/reference_engine/models.py +105 -0
  56. package/mcp_server/reference_engine/profile_builder.py +149 -0
  57. package/mcp_server/reference_engine/tactic_router.py +117 -0
  58. package/mcp_server/reference_engine/tools.py +235 -0
  59. package/mcp_server/runtime/__init__.py +1 -0
  60. package/mcp_server/runtime/action_ledger.py +117 -0
  61. package/mcp_server/runtime/action_ledger_models.py +84 -0
  62. package/mcp_server/runtime/action_tools.py +57 -0
  63. package/mcp_server/runtime/capability_state.py +218 -0
  64. package/mcp_server/runtime/safety_kernel.py +339 -0
  65. package/mcp_server/runtime/safety_tools.py +42 -0
  66. package/mcp_server/runtime/tools.py +64 -0
  67. package/mcp_server/server.py +23 -1
  68. package/mcp_server/sound_design/__init__.py +1 -0
  69. package/mcp_server/sound_design/critics.py +297 -0
  70. package/mcp_server/sound_design/models.py +147 -0
  71. package/mcp_server/sound_design/planner.py +104 -0
  72. package/mcp_server/sound_design/tools.py +297 -0
  73. package/mcp_server/tools/_agent_os_engine.py +947 -0
  74. package/mcp_server/tools/_composition_engine.py +1530 -0
  75. package/mcp_server/tools/_conductor.py +199 -0
  76. package/mcp_server/tools/_conductor_budgets.py +222 -0
  77. package/mcp_server/tools/_evaluation_contracts.py +91 -0
  78. package/mcp_server/tools/_form_engine.py +416 -0
  79. package/mcp_server/tools/_motif_engine.py +351 -0
  80. package/mcp_server/tools/_planner_engine.py +516 -0
  81. package/mcp_server/tools/_research_engine.py +542 -0
  82. package/mcp_server/tools/_research_provider.py +185 -0
  83. package/mcp_server/tools/_snapshot_normalizer.py +49 -0
  84. package/mcp_server/tools/agent_os.py +440 -0
  85. package/mcp_server/tools/analyzer.py +18 -0
  86. package/mcp_server/tools/automation.py +25 -10
  87. package/mcp_server/tools/composition.py +563 -0
  88. package/mcp_server/tools/motif.py +104 -0
  89. package/mcp_server/tools/planner.py +144 -0
  90. package/mcp_server/tools/research.py +223 -0
  91. package/mcp_server/tools/tracks.py +18 -3
  92. package/mcp_server/tools/transport.py +10 -2
  93. package/mcp_server/transition_engine/__init__.py +6 -0
  94. package/mcp_server/transition_engine/archetypes.py +167 -0
  95. package/mcp_server/transition_engine/critics.py +340 -0
  96. package/mcp_server/transition_engine/models.py +90 -0
  97. package/mcp_server/transition_engine/tools.py +291 -0
  98. package/mcp_server/translation_engine/__init__.py +5 -0
  99. package/mcp_server/translation_engine/critics.py +297 -0
  100. package/mcp_server/translation_engine/models.py +27 -0
  101. package/mcp_server/translation_engine/tools.py +74 -0
  102. package/package.json +2 -2
  103. package/remote_script/LivePilot/__init__.py +1 -1
  104. package/remote_script/LivePilot/arrangement.py +12 -2
  105. package/requirements.txt +1 -1
@@ -0,0 +1,84 @@
1
+ """Data models for the Action Ledger — semantic move tracking.
2
+
3
+ Classes:
4
+ LedgerEntry — one semantic move with intent, scope, actions, evaluation
5
+ UndoGroup — group of entries sharing an undo scope
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ from dataclasses import dataclass, field
12
+ from typing import Optional
13
+
14
+
15
+ # Module-level counter for auto-generating IDs.
16
+ _counter: int = 0
17
+
18
+
19
+ def _next_id() -> str:
20
+ global _counter
21
+ _counter += 1
22
+ return f"move_{_counter:04d}"
23
+
24
+
25
+ @dataclass
26
+ class LedgerEntry:
27
+ """One semantic move in the session ledger."""
28
+
29
+ engine: str
30
+ move_class: str
31
+ intent: str
32
+ undo_scope: str = "micro"
33
+
34
+ # Auto-generated
35
+ id: str = field(default_factory=_next_id)
36
+ timestamp_ms: int = field(default_factory=lambda: int(time.time() * 1000))
37
+
38
+ # Scope — which tracks / clips / devices are affected
39
+ scope: dict = field(default_factory=dict)
40
+
41
+ # Low-level tool calls within this move
42
+ actions: list[dict] = field(default_factory=list)
43
+
44
+ # Snapshot references
45
+ before_refs: dict = field(default_factory=dict)
46
+ after_refs: dict = field(default_factory=dict)
47
+
48
+ # Evaluation
49
+ evaluation: dict = field(default_factory=dict)
50
+ kept: Optional[bool] = None
51
+ score: float = 0.0
52
+ memory_candidate: bool = False
53
+
54
+ def to_dict(self) -> dict:
55
+ return {
56
+ "id": self.id,
57
+ "timestamp_ms": self.timestamp_ms,
58
+ "engine": self.engine,
59
+ "move_class": self.move_class,
60
+ "intent": self.intent,
61
+ "scope": self.scope,
62
+ "actions": list(self.actions),
63
+ "before_refs": dict(self.before_refs),
64
+ "after_refs": dict(self.after_refs),
65
+ "evaluation": dict(self.evaluation),
66
+ "kept": self.kept,
67
+ "score": self.score,
68
+ "undo_scope": self.undo_scope,
69
+ "memory_candidate": self.memory_candidate,
70
+ }
71
+
72
+
73
+ @dataclass
74
+ class UndoGroup:
75
+ """A group of ledger entries sharing an undo scope."""
76
+
77
+ scope: str
78
+ entry_ids: list[str] = field(default_factory=list)
79
+
80
+ def to_dict(self) -> dict:
81
+ return {
82
+ "scope": self.scope,
83
+ "entry_ids": list(self.entry_ids),
84
+ }
@@ -0,0 +1,57 @@
1
+ """MCP tool wrappers for the Action Ledger.
2
+
3
+ Tools:
4
+ get_action_ledger_summary — recent moves, counts, memory candidates
5
+ get_last_move — most recent semantic move
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from fastmcp import Context
11
+
12
+ from ..server import mcp
13
+ from .action_ledger import SessionLedger
14
+
15
+
16
+ def _get_ledger(ctx: Context) -> SessionLedger:
17
+ """Return the session-scoped ledger singleton."""
18
+ return ctx.lifespan_context.setdefault(
19
+ "action_ledger", SessionLedger()
20
+ )
21
+
22
+
23
+ @mcp.tool()
24
+ def get_action_ledger_summary(
25
+ ctx: Context, limit: int = 10, engine: str = ""
26
+ ) -> dict:
27
+ """Return a summary of recent semantic moves from the action ledger.
28
+
29
+ Includes move count, last move, recent moves (newest first),
30
+ and number of memory promotion candidates.
31
+ """
32
+ ledger = _get_ledger(ctx)
33
+ eng = engine if engine else None
34
+ recent = ledger.get_recent_moves(limit=limit, engine=eng)
35
+ last = ledger.get_last_move()
36
+ candidates = ledger.get_memory_candidates()
37
+
38
+ return {
39
+ "total_moves": len(ledger._entries),
40
+ "memory_candidate_count": len(candidates),
41
+ "last_move": last.to_dict() if last else None,
42
+ "recent_moves": [e.to_dict() for e in recent],
43
+ }
44
+
45
+
46
+ @mcp.tool()
47
+ def get_last_move(ctx: Context) -> dict:
48
+ """Return the most recent semantic move from the action ledger.
49
+
50
+ Returns the full ledger entry including intent, scope, actions,
51
+ evaluation, and undo scope. Returns an empty dict if no moves exist.
52
+ """
53
+ ledger = _get_ledger(ctx)
54
+ entry = ledger.get_last_move()
55
+ if entry is None:
56
+ return {}
57
+ return entry.to_dict()
@@ -0,0 +1,218 @@
1
+ """Capability State v1 — unified runtime capability model.
2
+
3
+ Defines the shared data model that tells engines what can and can't be
4
+ trusted right now. Pure Python, zero I/O — all probing happens in the
5
+ MCP tool wrapper (runtime/tools.py).
6
+
7
+ Design: docs/specs/v2-engine-specs/CAPABILITY_STATE_V1.md
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import time
13
+ from dataclasses import asdict, dataclass, field
14
+ from typing import Optional
15
+
16
+
17
+ # ── Domain Model ────────────────────────────────────────────────────────
18
+
19
+ @dataclass
20
+ class CapabilityDomain:
21
+ """A single capability domain's runtime status."""
22
+
23
+ name: str
24
+ available: bool
25
+ confidence: float # 0.0–1.0
26
+ freshness_ms: Optional[int] = None
27
+ mode: str = "unavailable"
28
+ reasons: list[str] = field(default_factory=list)
29
+
30
+ def __post_init__(self) -> None:
31
+ if not 0.0 <= self.confidence <= 1.0:
32
+ raise ValueError(f"confidence must be 0.0–1.0, got {self.confidence}")
33
+
34
+ def to_dict(self) -> dict:
35
+ return asdict(self)
36
+
37
+
38
+ # ── Capability State ────────────────────────────────────────────────────
39
+
40
+ @dataclass
41
+ class CapabilityState:
42
+ """Snapshot of all capability domains at a point in time."""
43
+
44
+ generated_at_ms: int
45
+ overall_mode: str # normal | measured_degraded | judgment_only | read_only
46
+ domains: dict[str, CapabilityDomain] = field(default_factory=dict)
47
+
48
+ # ── Query helpers ───────────────────────────────────────────────
49
+
50
+ def can_use_measured_evaluation(self) -> bool:
51
+ """True when analyzer data is available and fresh enough to trust."""
52
+ analyzer = self.domains.get("analyzer")
53
+ if analyzer is None:
54
+ return False
55
+ return analyzer.available and analyzer.confidence >= 0.5
56
+
57
+ def can_run_research(self, mode: str = "targeted") -> bool:
58
+ """Check if the requested research mode is available.
59
+
60
+ - 'targeted' — always true if session or memory is up
61
+ - 'deep' — requires web access
62
+ """
63
+ if mode == "targeted":
64
+ session = self.domains.get("session_access")
65
+ memory = self.domains.get("memory")
66
+ if session and session.available:
67
+ return True
68
+ if memory and memory.available:
69
+ return True
70
+ return False
71
+
72
+ if mode == "deep":
73
+ web = self.domains.get("web")
74
+ return web is not None and web.available
75
+
76
+ return False
77
+
78
+ def to_dict(self) -> dict:
79
+ return {
80
+ "capability_state": {
81
+ "generated_at_ms": self.generated_at_ms,
82
+ "overall_mode": self.overall_mode,
83
+ "domains": {
84
+ name: domain.to_dict()
85
+ for name, domain in self.domains.items()
86
+ },
87
+ }
88
+ }
89
+
90
+
91
+ # ── Builder ─────────────────────────────────────────────────────────────
92
+
93
+ def build_capability_state(
94
+ *,
95
+ session_ok: bool = False,
96
+ analyzer_ok: bool = False,
97
+ analyzer_fresh: bool = False,
98
+ memory_ok: bool = False,
99
+ web_ok: bool = False,
100
+ flucoma_ok: bool = False,
101
+ ) -> CapabilityState:
102
+ """Build a CapabilityState from simple boolean probes.
103
+
104
+ Pure function — no I/O. The caller is responsible for probing
105
+ Ableton, the analyzer bridge, memory store, etc.
106
+ """
107
+ domains: dict[str, CapabilityDomain] = {}
108
+
109
+ # ── session_access ──────────────────────────────────────────────
110
+ session_reasons: list[str] = []
111
+ if not session_ok:
112
+ session_reasons.append("session_unreachable")
113
+ domains["session_access"] = CapabilityDomain(
114
+ name="session_access",
115
+ available=session_ok,
116
+ confidence=1.0 if session_ok else 0.0,
117
+ mode="healthy" if session_ok else "unavailable",
118
+ reasons=session_reasons,
119
+ )
120
+
121
+ # ── analyzer ────────────────────────────────────────────────────
122
+ analyzer_reasons: list[str] = []
123
+ if not analyzer_ok:
124
+ analyzer_reasons.append("analyzer_offline")
125
+ elif not analyzer_fresh:
126
+ analyzer_reasons.append("analyzer_stale")
127
+ analyzer_available = analyzer_ok and analyzer_fresh
128
+ if analyzer_available:
129
+ analyzer_conf = 0.9
130
+ analyzer_mode = "measured"
131
+ elif analyzer_ok:
132
+ analyzer_conf = 0.4
133
+ analyzer_mode = "stale"
134
+ else:
135
+ analyzer_conf = 0.0
136
+ analyzer_mode = "unavailable"
137
+ domains["analyzer"] = CapabilityDomain(
138
+ name="analyzer",
139
+ available=analyzer_available,
140
+ confidence=analyzer_conf,
141
+ mode=analyzer_mode,
142
+ reasons=analyzer_reasons,
143
+ )
144
+
145
+ # ── memory ──────────────────────────────────────────────────────
146
+ memory_reasons: list[str] = []
147
+ if not memory_ok:
148
+ memory_reasons.append("memory_unavailable")
149
+ domains["memory"] = CapabilityDomain(
150
+ name="memory",
151
+ available=memory_ok,
152
+ confidence=1.0 if memory_ok else 0.0,
153
+ mode="available" if memory_ok else "unavailable",
154
+ reasons=memory_reasons,
155
+ )
156
+
157
+ # ── web ──────────────────────────────────────────────────────────
158
+ web_reasons: list[str] = []
159
+ if not web_ok:
160
+ web_reasons.append("web_unavailable")
161
+ domains["web"] = CapabilityDomain(
162
+ name="web",
163
+ available=web_ok,
164
+ confidence=0.7 if web_ok else 0.0,
165
+ mode="available" if web_ok else "unavailable",
166
+ reasons=web_reasons,
167
+ )
168
+
169
+ # ── research (composite) ────────────────────────────────────────
170
+ research_reasons: list[str] = []
171
+ research_sources = 0
172
+ if session_ok:
173
+ research_sources += 1
174
+ else:
175
+ research_reasons.append("session_unavailable")
176
+ if memory_ok:
177
+ research_sources += 1
178
+ else:
179
+ research_reasons.append("memory_unavailable")
180
+ if web_ok:
181
+ research_sources += 1
182
+ else:
183
+ research_reasons.append("web_unavailable")
184
+
185
+ if research_sources >= 3:
186
+ research_mode = "full"
187
+ research_conf = 1.0
188
+ elif research_sources >= 1:
189
+ research_mode = "targeted_only"
190
+ research_conf = 0.5 + 0.2 * research_sources
191
+ else:
192
+ research_mode = "unavailable"
193
+ research_conf = 0.0
194
+
195
+ domains["research"] = CapabilityDomain(
196
+ name="research",
197
+ available=research_sources >= 1,
198
+ confidence=round(research_conf, 2),
199
+ mode=research_mode,
200
+ reasons=research_reasons,
201
+ )
202
+
203
+ # ── Overall mode ────────────────────────────────────────────────
204
+ if session_ok and analyzer_ok and analyzer_fresh:
205
+ overall_mode = "normal"
206
+ elif session_ok and not analyzer_ok:
207
+ overall_mode = "measured_degraded"
208
+ elif session_ok:
209
+ # session_ok, analyzer_ok but not fresh
210
+ overall_mode = "judgment_only"
211
+ else:
212
+ overall_mode = "read_only"
213
+
214
+ return CapabilityState(
215
+ generated_at_ms=int(time.time() * 1000),
216
+ overall_mode=overall_mode,
217
+ domains=domains,
218
+ )
@@ -0,0 +1,339 @@
1
+ """Safety Kernel — policy enforcement layer for LivePilot.
2
+
3
+ Pure Python, zero I/O. Validates proposed actions against policies
4
+ before they execute. MCP tool wrappers call ``check_action_safety``
5
+ before executing mutations.
6
+
7
+ Policies
8
+ --------
9
+ * **Blocked** — bulk-destructive operations that are never safe to run
10
+ automatically (delete_all_tracks, clear_all_automation, …).
11
+ * **Confirm required** — single-item destructive ops where the user
12
+ should be prompted before proceeding.
13
+ * **Scope check** — mutations affecting more than 5 tracks at once
14
+ are flagged as *caution*.
15
+ * **Capability gating** — when the runtime is in *read_only* mode,
16
+ all mutations are blocked.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from dataclasses import dataclass, asdict
22
+ from typing import Optional
23
+
24
+
25
+ # ── Data types ─────────────────────────────────────────────────────
26
+
27
+ @dataclass
28
+ class SafetyCheck:
29
+ """Result of evaluating a proposed action against safety policies."""
30
+
31
+ action: str # what was proposed
32
+ allowed: bool # can it proceed?
33
+ risk_level: str # "safe", "caution", "blocked"
34
+ reason: str # why blocked / cautioned
35
+ requires_confirmation: bool # should we ask the user first?
36
+
37
+ def to_dict(self) -> dict:
38
+ return asdict(self)
39
+
40
+
41
+ # ── Policy sets ────────────────────────────────────────────────────
42
+
43
+ BLOCKED_ACTIONS: set[str] = {
44
+ "delete_all_tracks",
45
+ "delete_all_clips",
46
+ "delete_all_scenes",
47
+ "clear_all_automation",
48
+ "reset_all_devices",
49
+ }
50
+
51
+ CONFIRM_REQUIRED_ACTIONS: set[str] = {
52
+ "delete_track",
53
+ "delete_clip",
54
+ "delete_scene",
55
+ "flatten_track",
56
+ "replace_simpler_sample",
57
+ }
58
+
59
+ # Read-only prefixes — any action starting with one of these is a read.
60
+ _READ_ONLY_PREFIXES = (
61
+ "get_",
62
+ "analyze_",
63
+ "identify_",
64
+ "detect_",
65
+ "check_",
66
+ "search_",
67
+ "compare_",
68
+ "read_",
69
+ "classify_",
70
+ "find_",
71
+ "walk_",
72
+ "list_",
73
+ "export_",
74
+ "extract_",
75
+ "build_world_model",
76
+ "compile_goal_vector",
77
+ "evaluate_",
78
+ "memory_list",
79
+ "memory_get",
80
+ "memory_recall",
81
+ )
82
+
83
+ # Explicit safe actions (full list for fast-path lookup).
84
+ SAFE_ACTIONS: set[str] = {
85
+ "get_session_info",
86
+ "get_track_info",
87
+ "get_notes",
88
+ "get_device_parameters",
89
+ "get_master_spectrum",
90
+ "get_master_rms",
91
+ "get_detected_key",
92
+ "get_clip_info",
93
+ "get_scenes_info",
94
+ "get_arrangement_clips",
95
+ "get_arrangement_notes",
96
+ "get_clip_automation",
97
+ "get_automation_recipes",
98
+ "get_browser_tree",
99
+ "get_browser_items",
100
+ "get_return_tracks",
101
+ "get_master_track",
102
+ "get_mix_snapshot",
103
+ "get_track_routing",
104
+ "get_track_meters",
105
+ "get_master_meters",
106
+ "get_device_info",
107
+ "get_device_presets",
108
+ "get_rack_chains",
109
+ "get_freeze_status",
110
+ "get_scene_matrix",
111
+ "get_playing_clips",
112
+ "get_cue_points",
113
+ "get_hidden_parameters",
114
+ "get_automation_state",
115
+ "get_display_values",
116
+ "get_warp_markers",
117
+ "get_clip_file_path",
118
+ "get_simpler_slices",
119
+ "get_spectral_shape",
120
+ "get_mel_spectrum",
121
+ "get_chroma",
122
+ "get_onsets",
123
+ "get_novelty",
124
+ "get_momentary_loudness",
125
+ "get_recent_actions",
126
+ "get_session_diagnostics",
127
+ "get_plugin_parameters",
128
+ "get_plugin_presets",
129
+ "analyze_harmony",
130
+ "analyze_composition",
131
+ "analyze_loudness",
132
+ "analyze_spectrum_offline",
133
+ "analyze_midi_file",
134
+ "analyze_for_automation",
135
+ "analyze_outcomes",
136
+ "identify_scale",
137
+ "detect_theory_issues",
138
+ "compare_to_reference",
139
+ "read_audio_metadata",
140
+ "check_flucoma",
141
+ "search_browser",
142
+ "classify_progression",
143
+ "find_voice_leading_path",
144
+ "walk_device_tree",
145
+ "build_world_model",
146
+ "compile_goal_vector",
147
+ "evaluate_move",
148
+ "evaluate_composition_move",
149
+ "memory_list",
150
+ "memory_get",
151
+ "memory_recall",
152
+ "extract_piano_roll",
153
+ "export_clip_midi",
154
+ "get_section_graph",
155
+ "get_phrase_grid",
156
+ "get_harmony_field",
157
+ "get_transition_analysis",
158
+ "get_section_outcomes",
159
+ "get_motif_graph",
160
+ "get_technique_card",
161
+ "get_emotional_arc",
162
+ "get_style_tactics",
163
+ "research_technique",
164
+ "get_capability_state",
165
+ "get_action_ledger_summary",
166
+ "get_last_move",
167
+ "get_taste_profile",
168
+ "evaluate_with_fabric",
169
+ "get_anti_preferences",
170
+ "get_promotion_candidates",
171
+ "analyze_mix",
172
+ "get_mix_issues",
173
+ "get_mix_summary",
174
+ "get_masking_report",
175
+ "analyze_sound_design",
176
+ "get_sound_design_issues",
177
+ "get_patch_model",
178
+ "analyze_transition",
179
+ "score_transition",
180
+ "build_reference_profile",
181
+ "analyze_reference_gaps",
182
+ "check_translation",
183
+ "get_translation_issues",
184
+ "get_performance_state",
185
+ "get_performance_safe_moves",
186
+ "get_project_brain_summary",
187
+ }
188
+
189
+
190
+ # ── Scope limits per capability mode ──────────────────────────────
191
+
192
+ _MAX_SCOPE: dict[str, dict] = {
193
+ "normal": {"max_tracks": 0}, # 0 = unlimited
194
+ "measured_degraded": {"max_tracks": 3},
195
+ "judgment_only": {"max_tracks": 1},
196
+ "read_only": {"max_tracks": 0}, # mutations blocked entirely
197
+ }
198
+
199
+ _WIDE_SCOPE_THRESHOLD = 5 # tracks
200
+
201
+
202
+ # ── Public API ─────────────────────────────────────────────────────
203
+
204
+ def is_read_only_action(action: str) -> bool:
205
+ """Return True if *action* is a non-mutating read/query."""
206
+ if action in SAFE_ACTIONS:
207
+ return True
208
+ return action.startswith(_READ_ONLY_PREFIXES)
209
+
210
+
211
+ def get_max_safe_scope(capability_mode: str) -> dict:
212
+ """Return the maximum allowed scope for *capability_mode*.
213
+
214
+ Keys:
215
+ max_tracks — 0 means unlimited.
216
+ """
217
+ return dict(_MAX_SCOPE.get(capability_mode, _MAX_SCOPE["normal"]))
218
+
219
+
220
+ def check_action_safety(
221
+ action: str,
222
+ scope: Optional[dict] = None,
223
+ capability_state: Optional[dict] = None,
224
+ ) -> SafetyCheck:
225
+ """Validate a proposed action against safety policies.
226
+
227
+ Parameters
228
+ ----------
229
+ action:
230
+ Tool / command name (e.g. ``"delete_track"``).
231
+ scope:
232
+ Optional dict describing what the action will affect.
233
+ Recognised key: ``track_count`` (int).
234
+ capability_state:
235
+ Optional dict with at least a ``"mode"`` key
236
+ (``"normal"``, ``"read_only"``, …).
237
+
238
+ Returns
239
+ -------
240
+ SafetyCheck
241
+ """
242
+ scope = scope or {}
243
+ capability_state = capability_state or {}
244
+ mode = capability_state.get("mode", "normal")
245
+
246
+ # 1. Blocked actions — always refused.
247
+ if action in BLOCKED_ACTIONS:
248
+ return SafetyCheck(
249
+ action=action,
250
+ allowed=False,
251
+ risk_level="blocked",
252
+ reason=f"'{action}' is a bulk-destructive operation and is blocked by policy.",
253
+ requires_confirmation=False,
254
+ )
255
+
256
+ # 2. Capability gating — read_only blocks all mutations.
257
+ if mode == "read_only" and not is_read_only_action(action):
258
+ return SafetyCheck(
259
+ action=action,
260
+ allowed=False,
261
+ risk_level="blocked",
262
+ reason="System is in read_only mode — mutations are not allowed.",
263
+ requires_confirmation=False,
264
+ )
265
+
266
+ # 3. Scope-based gating for degraded modes.
267
+ track_count = scope.get("track_count", 1)
268
+ max_scope = get_max_safe_scope(mode)
269
+ max_tracks = max_scope["max_tracks"]
270
+ if max_tracks > 0 and track_count > max_tracks and not is_read_only_action(action):
271
+ return SafetyCheck(
272
+ action=action,
273
+ allowed=False,
274
+ risk_level="blocked",
275
+ reason=(
276
+ f"Scope ({track_count} tracks) exceeds limit for "
277
+ f"'{mode}' mode (max {max_tracks})."
278
+ ),
279
+ requires_confirmation=False,
280
+ )
281
+
282
+ # 4. Wide-scope caution (normal mode).
283
+ if track_count > _WIDE_SCOPE_THRESHOLD and not is_read_only_action(action):
284
+ return SafetyCheck(
285
+ action=action,
286
+ allowed=True,
287
+ risk_level="caution",
288
+ reason=(
289
+ f"Action affects {track_count} tracks (> {_WIDE_SCOPE_THRESHOLD}). "
290
+ "Proceed with caution."
291
+ ),
292
+ requires_confirmation=True,
293
+ )
294
+
295
+ # 5. Confirm-required actions.
296
+ if action in CONFIRM_REQUIRED_ACTIONS:
297
+ return SafetyCheck(
298
+ action=action,
299
+ allowed=True,
300
+ risk_level="caution",
301
+ reason=f"'{action}' is destructive — user confirmation recommended.",
302
+ requires_confirmation=True,
303
+ )
304
+
305
+ # 6. Explicitly safe / read-only.
306
+ if is_read_only_action(action):
307
+ return SafetyCheck(
308
+ action=action,
309
+ allowed=True,
310
+ risk_level="safe",
311
+ reason="Read-only action — always safe.",
312
+ requires_confirmation=False,
313
+ )
314
+
315
+ # 7. Default — allow but note it's a mutation.
316
+ return SafetyCheck(
317
+ action=action,
318
+ allowed=True,
319
+ risk_level="safe",
320
+ reason="Action permitted by policy.",
321
+ requires_confirmation=False,
322
+ )
323
+
324
+
325
+ def check_batch_safety(actions: list[dict]) -> list[SafetyCheck]:
326
+ """Check a batch of proposed actions.
327
+
328
+ Each element should be a dict with at least an ``"action"`` key,
329
+ plus optional ``"scope"`` and ``"capability_state"`` keys.
330
+
331
+ Returns one :class:`SafetyCheck` per input, in the same order.
332
+ """
333
+ results: list[SafetyCheck] = []
334
+ for entry in actions:
335
+ action = entry.get("action", "")
336
+ scope = entry.get("scope")
337
+ cap = entry.get("capability_state")
338
+ results.append(check_action_safety(action, scope, cap))
339
+ return results