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,460 @@
1
+ #!/usr/bin/env python3
2
+ """PostToolUse hook for Task invocations.
3
+
4
+ PostToolUse completes the Run record that PreToolUse stamped. It reads
5
+ the pending-Run sidecar by session id, fills in the end-of-Run fields
6
+ (`ended_ts`, `tokens_out`, `outcome`, `deliverable_path`), writes the
7
+ completed Run to the Ledger, and removes the sidecar.
8
+
9
+ If no sidecar exists for this session — for example, because PreToolUse
10
+ blocked the Task or because the runtime invoked PostToolUse without a
11
+ matching PreToolUse — this hook logs the omission and exits 0. It never
12
+ blocks PostToolUse; the agent runtime has already produced the user-
13
+ visible result.
14
+
15
+ Hook contract:
16
+ - Stdin: agent runtime payload with `tool_name`, `tool_input`,
17
+ `tool_response`, `session_id`
18
+ - Stdout: JSON with `continue: true`
19
+ - Exit: 0 always
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import sys
24
+ from datetime import datetime, timezone
25
+ from pathlib import Path
26
+
27
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
28
+
29
+ import json # noqa: E402
30
+
31
+ from metaensemble.hooks._common import ( # noqa: E402
32
+ db_path_for_state,
33
+ emit,
34
+ jsonl_path_for_state,
35
+ log_error,
36
+ migration_sql,
37
+ project_root_from_payload,
38
+ read_input,
39
+ state_dir,
40
+ state_dir_for_payload,
41
+ )
42
+ from metaensemble.lib.config import load_quality_config # noqa: E402
43
+ from metaensemble.lib.cost_gate import GateState # noqa: E402
44
+ from metaensemble.lib.file_events import ( # noqa: E402
45
+ ActiveDispatch,
46
+ clear_active_dispatch_for_run,
47
+ clear_file_events,
48
+ read_active_dispatch,
49
+ read_file_events,
50
+ write_active_dispatch_by_agent,
51
+ )
52
+ from metaensemble.lib.ledger import Executor, Ledger, Run # noqa: E402
53
+ from metaensemble.lib.manifest import load_manifest # noqa: E402
54
+ from metaensemble.lib.native_state import load_native_rate_limits # noqa: E402
55
+ from metaensemble.lib.quality_gate import build_decision # noqa: E402
56
+ from metaensemble.lib.quality_runners import run_all_axes # noqa: E402
57
+ from metaensemble.lib.recording import ( # noqa: E402
58
+ build_deliverable_ref,
59
+ classify_failure_reason,
60
+ classify_outcome,
61
+ coerce_to_text,
62
+ estimate_tokens,
63
+ extract_deliverable_path,
64
+ )
65
+ from metaensemble.lib.runtime_payload import normalize_model_identity # noqa: E402
66
+ from metaensemble.lib.sidecar import ( # noqa: E402
67
+ PendingRun,
68
+ delete_pending,
69
+ latest_pending_for_session,
70
+ pending_by_tool_use_id,
71
+ )
72
+ from metaensemble.lib.transcript import ( # noqa: E402
73
+ dominant_model,
74
+ transcript_path_for_session,
75
+ walk_transcript,
76
+ )
77
+
78
+
79
+ def _quality_files_from_manifest(manifest_path: str | None) -> list[Path]:
80
+ """Return every file declared in the Manifest's `expected_deliverables`.
81
+ Relevance is decided per axis downstream: the Python runners filter to
82
+ .py internally, and configured axis commands fire on non-Python
83
+ deliverables. Empty list means the quality gate skips."""
84
+ if not manifest_path:
85
+ return []
86
+ try:
87
+ manifest = load_manifest(manifest_path)
88
+ except Exception:
89
+ # Manifest invalid or missing — quality gate cannot evaluate.
90
+ return []
91
+ files: list[Path] = []
92
+ for entry in manifest.get("expected_deliverables", []):
93
+ path = entry.get("path")
94
+ if path:
95
+ files.append(Path(path))
96
+ return files
97
+
98
+
99
+ def _run_quality_gate(
100
+ manifest_path: str | None, project_root: Path
101
+ ) -> tuple[str | None, str | None, str | None]:
102
+ """Run the quality gate on a Run's expected deliverables.
103
+
104
+ Returns `(quality_state, quality_findings_json, summary)` where each
105
+ is None when the gate does not run (no manifest, no evaluable
106
+ deliverables, config load failure, etc.). The summary is the
107
+ one-paragraph English block the hook surfaces to the Coordinator on
108
+ NOTIFY or BLOCK.
109
+ """
110
+ files = _quality_files_from_manifest(manifest_path)
111
+ if not files:
112
+ return None, None, None
113
+ try:
114
+ config = load_quality_config()
115
+ if all(f.suffix != ".py" for f in files) and not config.axis_commands:
116
+ # Nothing the gate can evaluate: no Python deliverables for
117
+ # the built-in runners and no configured axis commands.
118
+ return None, None, None
119
+ axes = run_all_axes(files, config, project_root)
120
+ decision = build_decision(axes)
121
+ except Exception as exc:
122
+ log_error("quality-gate-failed", str(exc), {"manifest_path": manifest_path})
123
+ return None, None, None
124
+
125
+ findings_payload = {
126
+ "axes": [
127
+ {
128
+ "name": a.name,
129
+ "state": a.state.value,
130
+ "findings": list(a.findings),
131
+ "raw": a.raw,
132
+ }
133
+ for a in decision.axes
134
+ ],
135
+ "options": list(decision.options),
136
+ }
137
+ summary = decision.summary if decision.state != GateState.AUTO else None
138
+ return decision.state.value, json.dumps(findings_payload), summary
139
+
140
+
141
+ def finalize_pending(
142
+ pending: PendingRun,
143
+ *,
144
+ run_state_dir: Path,
145
+ project_root: Path,
146
+ response_text: str,
147
+ outcome: str,
148
+ failure_reason: str | None,
149
+ deliverable_path: str | None,
150
+ transcript_path: str | None,
151
+ session_id: str,
152
+ ) -> str | None:
153
+ """Complete a pending Run and write it to the Ledger.
154
+
155
+ Shared by PostToolUse (synchronous runtimes) and SubagentStop (background
156
+ runtimes) so both produce identical Run records. Idempotent at the call
157
+ site: callers invoke it only when a pending sidecar was found, and it
158
+ deletes the sidecar at the end, so a second finalize finds no pending.
159
+ Active-dispatch markers are cleared by run_id across both session and
160
+ agentId indexes, so a completed run cannot keep writes authorized.
161
+ Returns the quality-gate summary to surface, or None.
162
+ """
163
+ ledger = Ledger(
164
+ db_path=db_path_for_state(run_state_dir),
165
+ jsonl_path=jsonl_path_for_state(run_state_dir),
166
+ )
167
+ ledger.initialize(migration_sql())
168
+
169
+ tokens_out = estimate_tokens(response_text)
170
+ ended_ts = datetime.now(timezone.utc).isoformat()
171
+
172
+ quality_state, quality_findings_json, quality_summary = (None, None, None)
173
+ if outcome == "ok":
174
+ quality_state, quality_findings_json, quality_summary = _run_quality_gate(
175
+ pending.manifest_path, project_root
176
+ )
177
+
178
+ harvest = None
179
+ resolved_transcript_path = (
180
+ Path(transcript_path)
181
+ if transcript_path
182
+ else transcript_path_for_session(session_id, cwd=Path.cwd())
183
+ )
184
+ if resolved_transcript_path:
185
+ try:
186
+ harvest = walk_transcript(
187
+ resolved_transcript_path,
188
+ after_ts=pending.started_ts,
189
+ before_ts=ended_ts,
190
+ dispatch_task_id=pending.task_id,
191
+ dispatch_role_id=pending.role_id,
192
+ dispatch_prompt_sha256=(pending.extra or {}).get("tool_prompt_sha256"),
193
+ dispatch_started_ts=pending.started_ts,
194
+ )
195
+ except Exception as exc:
196
+ log_error(
197
+ "post-task-transcript-walk-failed",
198
+ str(exc),
199
+ {"run_id": pending.run_id, "transcript_path": str(resolved_transcript_path)},
200
+ )
201
+ harvest = None
202
+
203
+ event_records = read_file_events(
204
+ run_state_dir,
205
+ run_id=pending.run_id,
206
+ session_id=pending.session_id,
207
+ started_ts=pending.started_ts,
208
+ ended_ts=ended_ts,
209
+ )
210
+
211
+ touched: set[str] = set()
212
+ tool_counts: dict[str, int] = {}
213
+ tool_input_tokens: dict[str, int] = {}
214
+ files_touched_json: str | None = None
215
+ tool_use_json: str | None = None
216
+ cache_read = 0
217
+ cache_create = 0
218
+ runtime_model = None
219
+ model_source = "tier_fallback"
220
+ if harvest is not None:
221
+ if harvest.files_touched:
222
+ touched.update(harvest.files_touched)
223
+ if harvest.tool_use:
224
+ for t in harvest.tool_use:
225
+ tool_counts[t.name] = tool_counts.get(t.name, 0) + t.count
226
+ tool_input_tokens[t.name] = (
227
+ tool_input_tokens.get(t.name, 0) + t.total_input_tokens
228
+ )
229
+ cache_read = harvest.cache_read_tokens
230
+ cache_create = harvest.cache_create_tokens
231
+ runtime_model = normalize_model_identity(dominant_model(harvest))
232
+ if runtime_model:
233
+ model_source = "transcript"
234
+
235
+ if runtime_model is None:
236
+ native = load_native_rate_limits()
237
+ if (
238
+ native is not None
239
+ and native.is_fresh
240
+ and native.model
241
+ and native.session_id
242
+ and native.session_id in {pending.session_id, session_id}
243
+ ):
244
+ runtime_model = normalize_model_identity(native.model)
245
+ model_source = "statusline"
246
+
247
+ # Avoid double-counting tools already captured from the transcript harvest;
248
+ # file events still always contribute to files_touched.
249
+ harvest_has_tools = harvest is not None and bool(harvest.tool_use)
250
+ for event in event_records:
251
+ touched.add(event.rel_path or event.path)
252
+ if not harvest_has_tools:
253
+ tool_counts[event.tool_name] = tool_counts.get(event.tool_name, 0) + 1
254
+
255
+ # Defense-in-depth: only real artifacts are provenance. Drop any path that
256
+ # does not exist at finalization (a denied/attempted write the transcript
257
+ # may still list, or a path parsed from prose), and never record a missing
258
+ # file as a deliverable. The gap is logged as a diagnostic, not a deliverable.
259
+ def _output_exists(pth: str) -> bool:
260
+ candidate = Path(pth)
261
+ return (
262
+ candidate.exists() if candidate.is_absolute()
263
+ else (project_root / pth).exists()
264
+ )
265
+ missing_outputs = sorted(p for p in touched if not _output_exists(p))
266
+ touched = {p for p in touched if _output_exists(p)}
267
+ if deliverable_path and not _output_exists(deliverable_path):
268
+ deliverable_path = None
269
+ if missing_outputs:
270
+ log_error(
271
+ "finalize-missing-outputs",
272
+ "attempted outputs not found on disk; excluded from provenance",
273
+ {"run_id": pending.run_id, "missing": missing_outputs[:10]},
274
+ )
275
+
276
+ if touched:
277
+ files_touched_json = json.dumps(sorted(touched))
278
+ if tool_counts:
279
+ tool_use_json = json.dumps([
280
+ {"name": name, "count": count, "input_tokens": tool_input_tokens.get(name, 0)}
281
+ for name, count in sorted(tool_counts.items())
282
+ ])
283
+
284
+ recorded_model = (
285
+ normalize_model_identity(runtime_model)
286
+ or normalize_model_identity(pending.model_tier)
287
+ or "unknown"
288
+ )
289
+ role = ledger.get_role(pending.role_id)
290
+ role_version = role.version if role else None
291
+
292
+ deliverable_ref = build_deliverable_ref(
293
+ response_text,
294
+ deliverable_path=deliverable_path,
295
+ files_touched=tuple(sorted(touched)),
296
+ )
297
+ deliverable_ref_json = json.dumps(deliverable_ref) if deliverable_ref else None
298
+ review_findings_json = quality_findings_json or None
299
+
300
+ run_record = Run(
301
+ run_id=pending.run_id,
302
+ executor_id=pending.executor_id,
303
+ task_id=pending.task_id,
304
+ model=recorded_model,
305
+ tokens_in=pending.estimated_tokens_in,
306
+ tokens_out=tokens_out,
307
+ window_id=pending.window_id,
308
+ started_ts=pending.started_ts,
309
+ ended_ts=ended_ts,
310
+ outcome=outcome,
311
+ brief_in_path=pending.brief_in_path,
312
+ brief_out_path=None,
313
+ deliverable_path=deliverable_path,
314
+ failure_reason=failure_reason,
315
+ quality_state=quality_state,
316
+ quality_findings_json=quality_findings_json,
317
+ role_version=role_version,
318
+ requested_model_tier=pending.model_tier,
319
+ model_source=model_source,
320
+ deliverable_ref_json=deliverable_ref_json,
321
+ files_touched_json=files_touched_json,
322
+ tool_use_json=tool_use_json,
323
+ review_findings_json=review_findings_json,
324
+ cache_read_tokens=cache_read,
325
+ cache_create_tokens=cache_create,
326
+ )
327
+ ledger.append_run(run_record)
328
+
329
+ existing = ledger.get_executor(pending.executor_id)
330
+ if existing:
331
+ ledger.upsert_executor(
332
+ Executor(
333
+ executor_id=existing.executor_id,
334
+ alias=existing.alias,
335
+ role_id=existing.role_id,
336
+ parent_executor_id=existing.parent_executor_id,
337
+ created_ts=existing.created_ts,
338
+ last_seen_ts=ended_ts,
339
+ status=existing.status,
340
+ )
341
+ )
342
+ if outcome in {"ok", "partial"}:
343
+ ledger.update_task_status(pending.task_id, "done")
344
+
345
+ ledger.close()
346
+ delete_pending(run_state_dir, pending.run_id)
347
+ # Clear EVERY marker pointing at this run (session- and agentId-keyed), so a
348
+ # completed Run can never keep a coordinator/direct write authorized.
349
+ clear_active_dispatch_for_run(pending.run_id, session_id=pending.session_id)
350
+ clear_file_events(run_state_dir, run_id=pending.run_id, events=event_records)
351
+ return quality_summary
352
+
353
+
354
+ def run() -> int:
355
+ payload = read_input()
356
+ if payload.get("tool_name") not in ("Task", "Agent"):
357
+ emit({"continue": True})
358
+ return 0
359
+ tool_response = payload.get("tool_response") or payload.get("tool_output")
360
+
361
+ # --- Background Agent path --------------------------------------------
362
+ # The Agent tool returns a launch stub carrying the runtime agentId before
363
+ # the subagent has done anything. Reconcile it to the pre_task stamp by
364
+ # tool_use_id, record an agentId-keyed active dispatch (so the subagent's
365
+ # writes are authorized and SubagentStop can finalize by agent_id), and
366
+ # DEFER finalization. agentId is absent on synchronous runtimes.
367
+ agent_id = tool_response.get("agentId") if isinstance(tool_response, dict) else None
368
+ if payload.get("tool_name") == "Agent" and agent_id:
369
+ tool_use_id = payload.get("tool_use_id")
370
+ bg_session = payload.get("session_id") or ""
371
+ bg_state_dir = state_dir_for_payload(payload)
372
+ bg_root = project_root_from_payload(payload) or Path.cwd().resolve(strict=False)
373
+ # Cross-project dispatches: the launch payload may lack the original
374
+ # [project:] context, so consult the session marker pre_task wrote
375
+ # (carries the dispatch's true project_root/state_dir) first.
376
+ bg_active = read_active_dispatch(bg_session) if bg_session else None
377
+ if bg_active is not None and bg_state_dir == state_dir():
378
+ bg_state_dir = Path(bg_active.state_dir)
379
+ bg_root = Path(bg_active.project_root)
380
+ pending = pending_by_tool_use_id(bg_state_dir, tool_use_id) if tool_use_id else None
381
+ if pending is not None:
382
+ try:
383
+ write_active_dispatch_by_agent(
384
+ ActiveDispatch(
385
+ session_id=payload.get("session_id") or "",
386
+ run_id=pending.run_id,
387
+ project_root=str(bg_root.resolve(strict=False)),
388
+ state_dir=str(bg_state_dir.resolve(strict=False)),
389
+ started_ts=pending.started_ts,
390
+ agent_id=agent_id,
391
+ tool_use_id=tool_use_id,
392
+ )
393
+ )
394
+ except Exception as exc:
395
+ log_error(
396
+ "post-task-agent-index-failed",
397
+ str(exc),
398
+ {"run_id": pending.run_id, "agent_id": agent_id},
399
+ )
400
+ else:
401
+ log_error(
402
+ "post-task-no-pending-for-tooluse",
403
+ "background launch with no matching pending sidecar",
404
+ {"tool_use_id": tool_use_id, "agent_id": agent_id},
405
+ )
406
+ emit({"continue": True})
407
+ return 0
408
+
409
+ # --- Synchronous path -------------------------------------------------
410
+ session_id = payload.get("session_id") or "unknown-session"
411
+ transcript_path = payload.get("transcript_path")
412
+ run_state_dir = state_dir_for_payload(payload)
413
+ project_root = project_root_from_payload(payload) or Path.cwd().resolve(strict=False)
414
+ active = read_active_dispatch(session_id)
415
+ if active is not None and run_state_dir == state_dir():
416
+ run_state_dir = Path(active.state_dir)
417
+ project_root = Path(active.project_root)
418
+
419
+ pending = latest_pending_for_session(run_state_dir, session_id)
420
+ if pending is None:
421
+ log_error(
422
+ "post-task-no-pending",
423
+ "no pending-Run sidecar matched this session",
424
+ {"session_id": session_id},
425
+ )
426
+ emit({"continue": True})
427
+ return 0
428
+
429
+ try:
430
+ response_text = coerce_to_text(tool_response)
431
+ outcome = classify_outcome(tool_response)
432
+ failure_reason = (
433
+ classify_failure_reason(tool_response) if outcome == "failed" else None
434
+ )
435
+ deliverable_path = extract_deliverable_path(tool_response)
436
+ quality_summary = finalize_pending(
437
+ pending,
438
+ run_state_dir=run_state_dir,
439
+ project_root=project_root,
440
+ response_text=response_text,
441
+ outcome=outcome,
442
+ failure_reason=failure_reason,
443
+ deliverable_path=deliverable_path,
444
+ transcript_path=transcript_path,
445
+ session_id=session_id,
446
+ )
447
+ except Exception as exc:
448
+ log_error("post-task-failed", str(exc), {"run_id": pending.run_id})
449
+ emit({"continue": True})
450
+ return 0
451
+
452
+ if quality_summary:
453
+ emit({"continue": True, "systemMessage": quality_summary})
454
+ else:
455
+ emit({"continue": True})
456
+ return 0
457
+
458
+
459
+ if __name__ == "__main__":
460
+ sys.exit(run())