metaensemble 0.2.0__py3-none-any.whl

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 (85) hide show
  1. evals/README.md +147 -0
  2. evals/__init__.py +0 -0
  3. evals/cassettes/README.md +10 -0
  4. evals/cassettes/bootstrap.jsonl +800 -0
  5. evals/configs/default.yaml +59 -0
  6. evals/datasets/__init__.py +0 -0
  7. evals/datasets/suite_a/tasks.yaml +123 -0
  8. evals/datasets/suite_b/items.yaml +90 -0
  9. evals/runners/__init__.py +12 -0
  10. evals/runners/api.py +518 -0
  11. evals/runners/metrics.py +132 -0
  12. metaensemble/__init__.py +13 -0
  13. metaensemble/cli.py +1362 -0
  14. metaensemble/commands/dispatch.md +39 -0
  15. metaensemble/commands/executors.md +12 -0
  16. metaensemble/commands/ledger.md +19 -0
  17. metaensemble/commands/limits.md +12 -0
  18. metaensemble/commands/perf.md +12 -0
  19. metaensemble/commands/relaunch.md +29 -0
  20. metaensemble/commands/standup.md +14 -0
  21. metaensemble/config/budgets.example.yaml +72 -0
  22. metaensemble/config/quality.example.yaml +82 -0
  23. metaensemble/hooks/__init__.py +1 -0
  24. metaensemble/hooks/_common.py +148 -0
  25. metaensemble/hooks/deliverable_sync.py +73 -0
  26. metaensemble/hooks/file_event.py +303 -0
  27. metaensemble/hooks/post_task.py +460 -0
  28. metaensemble/hooks/pre_task.py +548 -0
  29. metaensemble/hooks/session_start.py +212 -0
  30. metaensemble/hooks/session_summary.py +392 -0
  31. metaensemble/hooks/subagent_stop.py +94 -0
  32. metaensemble/lib/__init__.py +1 -0
  33. metaensemble/lib/config.py +414 -0
  34. metaensemble/lib/cost_gate.py +299 -0
  35. metaensemble/lib/dispatch.py +341 -0
  36. metaensemble/lib/doctor.py +1563 -0
  37. metaensemble/lib/file_events.py +395 -0
  38. metaensemble/lib/ids.py +91 -0
  39. metaensemble/lib/installer.py +5018 -0
  40. metaensemble/lib/ledger.py +812 -0
  41. metaensemble/lib/manifest.py +141 -0
  42. metaensemble/lib/native_state.py +463 -0
  43. metaensemble/lib/overlaps.py +155 -0
  44. metaensemble/lib/quality_gate.py +155 -0
  45. metaensemble/lib/quality_runners.py +446 -0
  46. metaensemble/lib/reconcile.py +420 -0
  47. metaensemble/lib/recording.py +422 -0
  48. metaensemble/lib/relaunch.py +174 -0
  49. metaensemble/lib/runtime_payload.py +42 -0
  50. metaensemble/lib/runtime_state.py +308 -0
  51. metaensemble/lib/sidecar.py +166 -0
  52. metaensemble/lib/topology.py +181 -0
  53. metaensemble/lib/transcript.py +432 -0
  54. metaensemble/output-styles/deliverable.md +33 -0
  55. metaensemble/output-styles/wire.md +38 -0
  56. metaensemble/roles/architect.md +52 -0
  57. metaensemble/roles/backend.md +43 -0
  58. metaensemble/roles/code-quality.md +49 -0
  59. metaensemble/roles/data-engineer.md +42 -0
  60. metaensemble/roles/devops.md +42 -0
  61. metaensemble/roles/docs.md +41 -0
  62. metaensemble/roles/frontend.md +42 -0
  63. metaensemble/roles/ml-engineer.md +42 -0
  64. metaensemble/roles/test-engineer.md +42 -0
  65. metaensemble/schemas/brief.schema.json +80 -0
  66. metaensemble/schemas/manifest.schema.json +142 -0
  67. metaensemble/schemas/role.schema.json +84 -0
  68. metaensemble/skills/metaensemble-protocol/SKILL.md +226 -0
  69. metaensemble/state/migrations/001_init.sql +72 -0
  70. metaensemble/state/migrations/002_outcome_extended.sql +86 -0
  71. metaensemble/state/migrations/003_run_provenance.sql +36 -0
  72. metaensemble/statusline/me_status.py +187 -0
  73. metaensemble/tools/__init__.py +7 -0
  74. metaensemble/tools/executors.py +62 -0
  75. metaensemble/tools/ledger.py +121 -0
  76. metaensemble/tools/limits.py +165 -0
  77. metaensemble/tools/perf.py +150 -0
  78. metaensemble/tools/standup.py +177 -0
  79. metaensemble/tools/stats.py +115 -0
  80. metaensemble-0.2.0.dist-info/METADATA +221 -0
  81. metaensemble-0.2.0.dist-info/RECORD +85 -0
  82. metaensemble-0.2.0.dist-info/WHEEL +5 -0
  83. metaensemble-0.2.0.dist-info/entry_points.txt +2 -0
  84. metaensemble-0.2.0.dist-info/licenses/LICENSE +21 -0
  85. metaensemble-0.2.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,303 @@
1
+ #!/usr/bin/env python3
2
+ """File-tool hook — enforces project boundary and records file provenance.
3
+
4
+ This hook handles Write/Edit/MultiEdit/NotebookEdit. On PreToolUse it blocks
5
+ file writes outside the active MetaEnsemble project root. On PostToolUse it
6
+ records successful file-tool events so the enclosing Task Run can persist
7
+ `files_touched_json` and `tool_use_json` without relying on subagent
8
+ transcript discovery.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import sys
13
+ import json
14
+ from datetime import datetime, timezone
15
+ from pathlib import Path
16
+
17
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
18
+
19
+ from metaensemble.hooks._common import emit, log_error, read_input # noqa: E402
20
+ from metaensemble.lib.file_events import ( # noqa: E402
21
+ FileToolEvent,
22
+ append_file_event,
23
+ is_within,
24
+ nearest_project_root,
25
+ read_active_dispatch,
26
+ read_active_dispatch_by_agent,
27
+ read_active_dispatch_for_project,
28
+ resolve_against_cwd,
29
+ resolve_tool_paths,
30
+ )
31
+ from metaensemble.lib.overlaps import ( # noqa: E402
32
+ protected_overlap_for_path,
33
+ report_root_for_project,
34
+ )
35
+ from metaensemble.lib.recording import coerce_to_text # noqa: E402
36
+ from metaensemble.lib.runtime_state import _encode_cwd_for_runtime # noqa: E402
37
+
38
+
39
+ def _payload_cwd(payload: dict) -> Path:
40
+ raw = payload.get("cwd")
41
+ if isinstance(raw, str) and raw:
42
+ return Path(raw)
43
+ return Path.cwd()
44
+
45
+
46
+ def _active_context(session_id: str, cwd: Path, agent_id: str | None = None):
47
+ # Background path: authorize by the per-dispatch agentId first. This is the
48
+ # only correlation key that survives same-session fan-out.
49
+ if agent_id:
50
+ active = read_active_dispatch_by_agent(agent_id)
51
+ if active is not None:
52
+ return active, Path(active.project_root), Path(active.state_dir)
53
+ # Legacy session/project fallback — synchronous-runtime compatibility only.
54
+ active = read_active_dispatch(session_id) if session_id else None
55
+ if active is not None:
56
+ return active, Path(active.project_root), Path(active.state_dir)
57
+ root = nearest_project_root(cwd)
58
+ if root is None:
59
+ return None, None, None
60
+ active = read_active_dispatch_for_project(root)
61
+ if active is None:
62
+ return None, None, None
63
+ return active, Path(active.project_root), Path(active.state_dir)
64
+
65
+
66
+ def _tool_failed(payload: dict) -> bool:
67
+ response = payload.get("tool_response") or payload.get("tool_output")
68
+ if isinstance(response, dict) and response.get("is_error"):
69
+ return True
70
+ text = coerce_to_text(response).lower()
71
+ return "error:" in text or "failed" in text
72
+
73
+
74
+ def _coerce_content_text(content) -> str:
75
+ if isinstance(content, str):
76
+ return content
77
+ if isinstance(content, list):
78
+ parts: list[str] = []
79
+ for item in content:
80
+ if isinstance(item, dict):
81
+ raw = item.get("text") or item.get("content") or ""
82
+ parts.append(str(raw))
83
+ else:
84
+ parts.append(str(item))
85
+ return "\n".join(parts)
86
+ return str(content or "")
87
+
88
+
89
+ def _recent_user_texts(transcript_path: str | None) -> tuple[str, ...]:
90
+ if not transcript_path:
91
+ return ()
92
+ path = Path(transcript_path)
93
+ try:
94
+ lines = path.read_text().splitlines()
95
+ except OSError:
96
+ return ()
97
+ out: list[str] = []
98
+ for line in reversed(lines[-200:]):
99
+ if not line.strip():
100
+ continue
101
+ try:
102
+ event = json.loads(line)
103
+ except json.JSONDecodeError:
104
+ continue
105
+ if event.get("type") != "user":
106
+ continue
107
+ message = event.get("message")
108
+ if not isinstance(message, dict) or message.get("role") != "user":
109
+ continue
110
+ out.append(_coerce_content_text(message.get("content")))
111
+ if len(out) >= 20:
112
+ break
113
+ return tuple(out)
114
+
115
+
116
+ def _looks_like_dispatch_command(payload: dict) -> bool:
117
+ for text in _recent_user_texts(payload.get("transcript_path")):
118
+ if "<command-name>/dispatch</command-name>" in text:
119
+ return True
120
+ if (
121
+ "ARGUMENTS:" in text
122
+ and "When the Principal invokes `/dispatch" in text
123
+ and "Coordinator protocol" in text
124
+ ):
125
+ return True
126
+ return False
127
+
128
+
129
+ def _is_allowed_coordinator_write(path: Path, root: Path) -> bool:
130
+ try:
131
+ rel = path.relative_to(root.resolve(strict=False))
132
+ except ValueError:
133
+ return False
134
+ parts = rel.parts
135
+ if len(parts) >= 3 and parts[:2] == (".metaensemble", "manifests"):
136
+ return True
137
+ report_root = root / report_root_for_project(root)
138
+ return path.suffix.lower() == ".md" and is_within(
139
+ path.resolve(strict=False),
140
+ report_root.resolve(strict=False),
141
+ )
142
+
143
+
144
+ def _claude_project_state_dirs(cwd: Path, root: Path) -> tuple[Path, ...]:
145
+ """Claude Code runtime state dirs that belong to this active project.
146
+
147
+ The boundary guard should not block Claude Code from updating its own
148
+ per-project transcript/memory state. Scope this carve-out to the current
149
+ cwd and nearest MetaEnsemble root rather than all of `~/.claude/projects`.
150
+ """
151
+ base = Path.home() / ".claude" / "projects"
152
+ dirs = [
153
+ base / _encode_cwd_for_runtime(cwd),
154
+ base / _encode_cwd_for_runtime(root),
155
+ ]
156
+ out: list[Path] = []
157
+ seen: set[str] = set()
158
+ for d in dirs:
159
+ resolved = d.resolve(strict=False)
160
+ key = str(resolved)
161
+ if key not in seen:
162
+ seen.add(key)
163
+ out.append(resolved)
164
+ return tuple(out)
165
+
166
+
167
+ def _is_allowed_claude_project_state_write(path: Path, cwd: Path, root: Path) -> bool:
168
+ resolved = path.resolve(strict=False)
169
+ return any(is_within(resolved, d) for d in _claude_project_state_dirs(cwd, root))
170
+
171
+
172
+ def _emit_boundary_block(raw: str, resolved: Path, root: Path) -> int:
173
+ emit({
174
+ "continue": False,
175
+ "stopReason": (
176
+ "MetaEnsemble project boundary guard blocked a file edit outside "
177
+ f"the active project root.\n\nRequested path: {raw}\n"
178
+ f"Resolved path: {resolved}\nProject root: {root}\n\n"
179
+ "Run MetaEnsemble from the parent project root, or dispatch a task "
180
+ "whose file paths stay inside the installed project."
181
+ ),
182
+ })
183
+ return 2
184
+
185
+
186
+ def _emit_overlap_ownership_block(path: Path, root: Path) -> int:
187
+ surface = protected_overlap_for_path(root, path)
188
+ metaensemble_surface = surface.metaensemble_surface if surface else "MetaEnsemble Ledger"
189
+ emit({
190
+ "continue": False,
191
+ "stopReason": (
192
+ "MetaEnsemble overlap ownership guard blocked a file edit to a "
193
+ "project-maintained work-record surface assigned to MetaEnsemble.\n\n"
194
+ f"Requested path: {path}\n"
195
+ f"Project root: {root}\n"
196
+ f"Overlap category: {surface.category if surface else 'unknown'}\n"
197
+ f"Project surface: {surface.project_surface if surface else path}\n"
198
+ f"MetaEnsemble surface: {metaensemble_surface}\n\n"
199
+ "Change `.metaensemble/install-decisions.yaml` to "
200
+ "`action: project_owned` or `action: dual` for this overlap if "
201
+ "the manual document should still be maintained."
202
+ ),
203
+ })
204
+ return 2
205
+
206
+
207
+ def _emit_direct_dispatch_edit_block(tool_name: str, root: Path) -> int:
208
+ emit({
209
+ "continue": False,
210
+ "stopReason": (
211
+ "MetaEnsemble dispatch protocol blocked a direct file edit. "
212
+ f"`{tool_name}` was invoked while handling `/dispatch`, but no "
213
+ "active Task/Agent Run was present.\n\n"
214
+ f"Project root: {root}\n\n"
215
+ "The Coordinator must spawn an Executor via Task/Agent so the "
216
+ "Run is recorded in the Ledger with files touched and tool use."
217
+ ),
218
+ })
219
+ return 2
220
+
221
+
222
+ def run() -> int:
223
+ payload = read_input()
224
+ tool_name = payload.get("tool_name") or ""
225
+ tool_input = payload.get("tool_input") or {}
226
+ paths = resolve_tool_paths(tool_name, tool_input)
227
+ if not paths:
228
+ emit({"continue": True})
229
+ return 0
230
+
231
+ session_id = payload.get("session_id") or ""
232
+ agent_id = payload.get("agent_id")
233
+ hook_event = payload.get("hook_event_name") or ""
234
+ cwd = _payload_cwd(payload)
235
+ installed_root = nearest_project_root(cwd)
236
+ active, project_root, state_dir = _active_context(session_id, cwd, agent_id)
237
+ if project_root is None or state_dir is None:
238
+ if (
239
+ hook_event == "PreToolUse"
240
+ and installed_root is not None
241
+ and _looks_like_dispatch_command(payload)
242
+ ):
243
+ resolved = [
244
+ resolve_against_cwd(raw, cwd)
245
+ for raw in paths
246
+ ]
247
+ if all(
248
+ _is_allowed_coordinator_write(p, installed_root)
249
+ or _is_allowed_claude_project_state_write(p, cwd, installed_root)
250
+ for p in resolved
251
+ ):
252
+ emit({"continue": True})
253
+ return 0
254
+ return _emit_direct_dispatch_edit_block(tool_name, installed_root)
255
+ emit({"continue": True})
256
+ return 0
257
+
258
+ resolved_paths: list[tuple[str, Path]] = []
259
+ for raw in paths:
260
+ resolved = resolve_against_cwd(raw, cwd)
261
+ if not is_within(resolved, project_root):
262
+ if _is_allowed_claude_project_state_write(resolved, cwd, project_root):
263
+ continue
264
+ return _emit_boundary_block(raw, resolved, project_root)
265
+ if (
266
+ hook_event == "PreToolUse"
267
+ and protected_overlap_for_path(project_root, resolved) is not None
268
+ ):
269
+ return _emit_overlap_ownership_block(resolved, project_root)
270
+ resolved_paths.append((raw, resolved))
271
+
272
+ if hook_event == "PostToolUse" and not _tool_failed(payload):
273
+ for _raw, resolved in resolved_paths:
274
+ try:
275
+ rel = str(resolved.relative_to(project_root.resolve(strict=False)))
276
+ except ValueError:
277
+ rel = None
278
+ try:
279
+ append_file_event(
280
+ state_dir,
281
+ FileToolEvent(
282
+ ts=datetime.now(timezone.utc).isoformat(),
283
+ session_id=session_id,
284
+ run_id=active.run_id if active else None,
285
+ tool_name=tool_name,
286
+ path=str(resolved),
287
+ rel_path=rel,
288
+ cwd=str(cwd),
289
+ ),
290
+ )
291
+ except Exception as exc:
292
+ log_error("file-event-record-failed", str(exc), {
293
+ "tool_name": tool_name,
294
+ "path": str(resolved),
295
+ "session_id": session_id,
296
+ })
297
+
298
+ emit({"continue": True})
299
+ return 0
300
+
301
+
302
+ if __name__ == "__main__":
303
+ sys.exit(run())