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
metaensemble/cli.py ADDED
@@ -0,0 +1,1362 @@
1
+ """MetaEnsemble CLI.
2
+
3
+ Installed by `pyproject.toml` as the `metaensemble` console script. The
4
+ canonical entry point for bootstrapping a project (`metaensemble init`)
5
+ and for invoking the small read-only tools (`metaensemble limits`,
6
+ `metaensemble standup`, etc.) from outside an agent runtime.
7
+
8
+ See ARCHITECTURE.md §4 (Portability) for the bootstrap semantics this CLI
9
+ implements.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import sys
15
+ from pathlib import Path
16
+
17
+
18
+ CORE_DIR = Path(__file__).resolve().parent
19
+ EXPERIMENTAL_NOTICE = (
20
+ "MetaEnsemble v0.2.0 is feedback-first software. It records and gates "
21
+ "local agent work, but its quality-per-token claims are not yet "
22
+ "calibrated empirical guarantees; see docs/SYSTEM-CARD.md."
23
+ )
24
+ LAYOUT_CHOICES = ("namespaced", "top-level")
25
+
26
+
27
+ def _normalize_layout(value: str) -> str:
28
+ return value.strip().lower()
29
+
30
+
31
+ def cmd_init(args: argparse.Namespace) -> int:
32
+ """Initialize `.metaensemble/` in the current working directory."""
33
+ target = Path.cwd() / ".metaensemble"
34
+ if target.exists() and not args.force:
35
+ print(f"{target} already exists; pass --force to reinitialize.", file=sys.stderr)
36
+ return 1
37
+
38
+ # Delegate to the shared idempotent initializer that `apply_install` also
39
+ # uses. It creates every subdirectory we need, the Ledger DB, the
40
+ # JSONL mirror, the starter budgets.yaml, and the project-local
41
+ # `.gitignore` that keeps transient state out of the user's git
42
+ # history.
43
+ from metaensemble.lib.installer import _ensure_project_state
44
+ _ensure_project_state(Path.cwd())
45
+
46
+ state = target / "state"
47
+ budgets_target = target / "budgets.yaml"
48
+ print(f"Initialized MetaEnsemble project state at {target}/")
49
+ print(f" - Ledger database: {state / 'department.db'}")
50
+ print(f" - Append-only mirror: {state / 'runs.jsonl'}")
51
+ print(f" - Budget config: {budgets_target}")
52
+ print(f" - Git ignore: {target / '.gitignore'} (transient state excluded; declarations committable)")
53
+ print(f"\n{EXPERIMENTAL_NOTICE}")
54
+
55
+ if args.pack:
56
+ # Starter packs are planned for a future release; surface what is available now.
57
+ print(f"\nNote: starter pack '{args.pack}' is reserved for a future release. Core ships seven base Roles.")
58
+ print(f" Curated Roles available at: {CORE_DIR / 'roles'}/")
59
+ return 0
60
+
61
+
62
+ def cmd_limits(_: argparse.Namespace) -> int:
63
+ from metaensemble.tools import limits
64
+ return limits.main()
65
+
66
+
67
+ def cmd_standup(_: argparse.Namespace) -> int:
68
+ from metaensemble.tools import standup
69
+ return standup.main()
70
+
71
+
72
+ def cmd_executors(_: argparse.Namespace) -> int:
73
+ from metaensemble.tools import executors
74
+ return executors.main()
75
+
76
+
77
+ def cmd_perf(_: argparse.Namespace) -> int:
78
+ from metaensemble.tools import perf
79
+ return perf.main()
80
+
81
+
82
+ def cmd_stats(_: argparse.Namespace) -> int:
83
+ from metaensemble.tools import stats
84
+ return stats.main()
85
+
86
+
87
+ def cmd_ledger(args: argparse.Namespace) -> int:
88
+ from metaensemble.tools import ledger
89
+ return ledger.main(args.subargs)
90
+
91
+
92
+ def cmd_hook(args: argparse.Namespace) -> int:
93
+ """Invoke a hook script by filename. Used by the resilient launcher.
94
+
95
+ settings.json hook commands installed by `_hook_command` route through
96
+ this entry point as `me-run hook <filename>`. The indirection makes
97
+ the hook commands path-portable: the launcher resolves the Python
98
+ interpreter and PYTHONPATH at execution time, so moving the project
99
+ or recreating the venv does not require a settings.json rewrite.
100
+ """
101
+ import runpy
102
+
103
+ hook_path = CORE_DIR / "hooks" / args.name
104
+ if not hook_path.exists() or not hook_path.is_file():
105
+ print(f"hook script not found: {hook_path}", file=sys.stderr)
106
+ return 1
107
+ # `run_name="__main__"` triggers the script's `if __name__ == "__main__"`
108
+ # guard so the hook's `sys.exit(run())` path fires as if it had been
109
+ # invoked directly. SystemExit propagates; the caller's exit code matches.
110
+ runpy.run_path(str(hook_path), run_name="__main__")
111
+ return 0
112
+
113
+
114
+ def cmd_statusline(_: argparse.Namespace) -> int:
115
+ """Invoke the statusline script through the resilient launcher."""
116
+ import runpy
117
+
118
+ statusline_path = CORE_DIR / "statusline" / "me_status.py"
119
+ if not statusline_path.exists() or not statusline_path.is_file():
120
+ print(f"statusline script not found: {statusline_path}", file=sys.stderr)
121
+ return 1
122
+ runpy.run_path(str(statusline_path), run_name="__main__")
123
+ return 0
124
+
125
+
126
+ def cmd_setup(args: argparse.Namespace, input_fn=input) -> int:
127
+ """Interactive setup wizard.
128
+
129
+ Lists every project Claude Code knows about, prompts the user to
130
+ choose one to adopt, runs `user-setup` if needed (asking for layout
131
+ only when user-level integration hasn't been installed yet), then
132
+ runs `adopt` on the chosen project. Replaces the previous
133
+ cwd-implicit flow with an explicit project picker.
134
+
135
+ `input_fn` is parameterized for tests; production callers use the
136
+ builtin `input`.
137
+ """
138
+ from metaensemble.lib.installer import detect_user_layout, discover_projects
139
+
140
+ print("MetaEnsemble setup wizard\n")
141
+
142
+ # No explicit bootstrap step here: `cmd_user_setup` (invoked below
143
+ # when user-level integration isn't installed) emits the launcher
144
+ # via its `render-launcher` plan action, which is idempotent. If
145
+ # the launcher is already there, that action becomes a no-op.
146
+
147
+ # Step 1 — show the project picker.
148
+ projects = discover_projects()
149
+ if not projects:
150
+ print(
151
+ "No Claude Code projects found on this machine. Open a "
152
+ "Claude Code session in a project directory first, then "
153
+ "re-run `metaensemble setup`.",
154
+ file=sys.stderr,
155
+ )
156
+ return 1
157
+
158
+ print("Known projects (from Claude Code's `~/.claude/projects/`):\n")
159
+ options: list = []
160
+ for i, p in enumerate(projects, start=1):
161
+ available = p.path.exists()
162
+ mark = "[installable]" if available else "[unavailable]"
163
+ if p.has_metaensemble_dir:
164
+ status = "installed"
165
+ elif available:
166
+ status = "not installed"
167
+ else:
168
+ status = "directory missing"
169
+ run_summary = (
170
+ f"{p.run_count} run(s)"
171
+ + (f", last {p.last_run_ts}" if p.last_run_ts else "")
172
+ if p.run_count else "no runs"
173
+ )
174
+ print(f" {mark:<14} {i}. {p.path}")
175
+ print(f" {status} · {run_summary}")
176
+ options.append(p)
177
+ print()
178
+
179
+ # Step 3 — prompt for project choice.
180
+ prompt = f"Which project to adopt? [1-{len(options)}, q to quit]: "
181
+ try:
182
+ choice = input_fn(prompt).strip()
183
+ except (EOFError, KeyboardInterrupt):
184
+ print("\nAborted.", file=sys.stderr)
185
+ return 1
186
+ if choice.lower() in ("q", "quit", ""):
187
+ print("Aborted.")
188
+ return 0
189
+ try:
190
+ idx = int(choice) - 1
191
+ chosen = options[idx]
192
+ if idx < 0 or idx >= len(options):
193
+ raise IndexError
194
+ except (ValueError, IndexError):
195
+ print(f"setup: invalid choice {choice!r}.", file=sys.stderr)
196
+ return 1
197
+
198
+ if not chosen.path.exists():
199
+ print(
200
+ f"setup: {chosen.path} no longer exists on disk; cannot adopt.\n"
201
+ "(Claude Code's records can outlive the directories it saw.)",
202
+ file=sys.stderr,
203
+ )
204
+ return 1
205
+
206
+ # Step 4 — run user-setup if it hasn't been run yet.
207
+ current_layout = detect_user_layout()
208
+ if current_layout is None:
209
+ if args.layout:
210
+ chosen_layout = _normalize_layout(args.layout)
211
+ print(f"User-level integration not installed; using --layout={chosen_layout}.")
212
+ else:
213
+ try:
214
+ layout_input = input_fn(
215
+ "User-level integration not installed.\n"
216
+ " namespaced — slash commands install namespaced "
217
+ "(/metaensemble:dispatch)\n"
218
+ " top-level — slash commands install top-level (/dispatch)\n"
219
+ "Layout [namespaced/top-level, default=namespaced]: "
220
+ ).strip().lower()
221
+ except (EOFError, KeyboardInterrupt):
222
+ print("\nAborted.", file=sys.stderr)
223
+ return 1
224
+ chosen_layout = _normalize_layout(layout_input or "namespaced")
225
+ if chosen_layout not in LAYOUT_CHOICES:
226
+ print(f"setup: invalid layout {chosen_layout!r}.", file=sys.stderr)
227
+ return 1
228
+ print(f"\nRunning user-setup (layout={chosen_layout}) ...")
229
+ rc = cmd_user_setup(argparse.Namespace(layout=chosen_layout, dry_run=False))
230
+ if rc != 0:
231
+ return rc
232
+ else:
233
+ print(f"User-level integration already installed (layout={current_layout.value}).")
234
+
235
+ # Step 5 — adopt the chosen project.
236
+ print(f"\nAdopting {chosen.path} …")
237
+ return cmd_adopt(argparse.Namespace(path=str(chosen.path), dry_run=False))
238
+
239
+
240
+ def cmd_manifest(args: argparse.Namespace) -> int:
241
+ """`metaensemble manifest <subcommand>` — Manifest authoring helpers.
242
+
243
+ Subcommands:
244
+ validate <path> — load + schema-validate the YAML file at <path>
245
+ and report errors with line:column when possible.
246
+ new-id — print a fresh `hm-<UUIDv7>` manifest id to stdout.
247
+ scaffold <task> — write a starter Manifest YAML to stdout (or to
248
+ `-o <path>`) pre-filled with TODO markers in
249
+ every required-but-author-supplied field. The
250
+ output deliberately fails schema validation
251
+ until the author replaces the `TODO:` markers.
252
+ """
253
+ from metaensemble.lib.manifest import load_manifest
254
+
255
+ if args.subcmd == "validate":
256
+ path = Path(args.path)
257
+ if not path.exists():
258
+ print(f"manifest validate: file not found: {path}", file=sys.stderr)
259
+ return 1
260
+ try:
261
+ data = load_manifest(path)
262
+ except Exception as exc:
263
+ # Surface YAML parse errors with line:column and schema
264
+ # violations with the field path so the author can fix the
265
+ # specific line rather than guessing.
266
+ mark = getattr(exc, "problem_mark", None)
267
+ if mark is not None and getattr(exc, "problem", None):
268
+ loc = (
269
+ f" at line {mark.line + 1}, column {mark.column + 1}"
270
+ if mark.line is not None and mark.column is not None
271
+ else ""
272
+ )
273
+ print(f"{path}: YAML error{loc}: {exc.problem}", file=sys.stderr)
274
+ return 1
275
+ message = getattr(exc, "message", None)
276
+ abs_path = getattr(exc, "absolute_path", None)
277
+ if message:
278
+ field_loc = ""
279
+ if abs_path is not None:
280
+ try:
281
+ parts = list(abs_path)
282
+ if parts:
283
+ field_loc = f" at field `{'.'.join(str(p) for p in parts)}`"
284
+ except TypeError:
285
+ pass
286
+ print(f"{path}: schema error{field_loc}: {message}", file=sys.stderr)
287
+ return 1
288
+ print(f"{path}: {type(exc).__name__}: {exc}", file=sys.stderr)
289
+ return 1
290
+ print(f"{path}: valid ({data.get('manifest_id')}, task={data.get('task')!r}, "
291
+ f"deliverables={len(data.get('expected_deliverables', []))})")
292
+ return 0
293
+
294
+ if args.subcmd == "new-id":
295
+ from metaensemble.lib.ids import uuid7
296
+ print(f"hm-{uuid7()}")
297
+ return 0
298
+
299
+ if args.subcmd == "scaffold":
300
+ from metaensemble.lib.manifest import scaffold_manifest
301
+
302
+ # The library renderer owns the starter-YAML shape (TODO markers,
303
+ # task escaping) and pre-fills `context.files` with the project's
304
+ # detected memory surfaces ({path, role: memory}) so the receiving
305
+ # Executor is handed the runtime's memory files instead of
306
+ # re-discovering them.
307
+ body = scaffold_manifest(args.task, project=Path.cwd())
308
+ out_path = getattr(args, "output", None)
309
+ if out_path:
310
+ # SKILL.md advertises writing into `.metaensemble/manifests/`,
311
+ # which the author may not have created yet. Create the parent
312
+ # chain so scaffold succeeds the first time rather than
313
+ # surfacing a FileNotFoundError traceback.
314
+ out = Path(out_path)
315
+ out.parent.mkdir(parents=True, exist_ok=True)
316
+ out.write_text(body)
317
+ print(f"wrote scaffold to {out_path}")
318
+ else:
319
+ sys.stdout.write(body)
320
+ return 0
321
+
322
+ print(f"manifest: unknown subcommand {args.subcmd!r}", file=sys.stderr)
323
+ return 1
324
+
325
+
326
+ def cmd_reconcile(args: argparse.Namespace) -> int:
327
+ """Walk the pending-sidecar directory and reconcile stranded entries.
328
+
329
+ Used after a runtime crash, `kill -9`, or budget-exhaustion exit, where
330
+ the PreToolUse hook stamped a sidecar but PostToolUse never fired. Each
331
+ stranded sidecar is written to the Ledger as a failed Run with a
332
+ `failure_reason` naming the cause, then deleted.
333
+ """
334
+ from datetime import timedelta
335
+
336
+ from metaensemble.hooks._common import db_path, jsonl_path, migration_sql, state_dir
337
+ from metaensemble.lib.ledger import Ledger
338
+ from metaensemble.lib.reconcile import reconcile_stale_pending
339
+
340
+ if args.dry_run:
341
+ # Dry-run path: count sidecars that would be reconciled without
342
+ # writing anything. Useful for `metaensemble reconcile --dry-run`
343
+ # to confirm cleanup before mutating the Ledger.
344
+ from metaensemble.lib.reconcile import _iter_pending
345
+ max_age = timedelta(minutes=max(0, args.older_than_minutes))
346
+ from datetime import datetime, timezone
347
+ cutoff = datetime.now(timezone.utc) - max_age
348
+ eligible: list[str] = []
349
+ for entry, pending in _iter_pending(state_dir()):
350
+ try:
351
+ ts = datetime.fromisoformat(pending.started_ts)
352
+ if ts.tzinfo is None:
353
+ ts = ts.replace(tzinfo=timezone.utc)
354
+ except (TypeError, ValueError):
355
+ ts = datetime.fromtimestamp(entry.stat().st_mtime, tz=timezone.utc)
356
+ if ts <= cutoff:
357
+ eligible.append(pending.run_id)
358
+ print(f"Would reconcile {len(eligible)} pending sidecar(s) older than "
359
+ f"{args.older_than_minutes} minute(s).")
360
+ for run_id in eligible:
361
+ print(f" - {run_id}")
362
+ return 0
363
+
364
+ ledger = Ledger(db_path=db_path(), jsonl_path=jsonl_path())
365
+ ledger.initialize(migration_sql())
366
+ reconciled = reconcile_stale_pending(
367
+ ledger,
368
+ state_dir(),
369
+ max_age=timedelta(minutes=max(0, args.older_than_minutes)),
370
+ )
371
+ ledger.close()
372
+ print(f"Reconciled {len(reconciled)} pending sidecar(s).")
373
+ for r in reconciled:
374
+ print(f" - {r.run_id} task={r.task_id} reason={r.reason}")
375
+ return 0
376
+
377
+
378
+ def cmd_relaunch(args: argparse.Namespace) -> int:
379
+ """Print the relaunch context for an Executor, without dispatching a new Run."""
380
+ from metaensemble.hooks._common import db_path, jsonl_path, migration_sql
381
+ from metaensemble.lib.ledger import Ledger
382
+ from metaensemble.lib.relaunch import prepare_relaunch, render_relaunch_context
383
+
384
+ ledger = Ledger(db_path=db_path(), jsonl_path=jsonl_path())
385
+ ledger.initialize(migration_sql())
386
+ ctx = prepare_relaunch(ledger, args.alias, full=args.full)
387
+ ledger.close()
388
+ if ctx is None:
389
+ print(f"No Executor with alias `{args.alias}`.", file=sys.stderr)
390
+ return 1
391
+ print(render_relaunch_context(ctx))
392
+ return 0
393
+
394
+
395
+ def cmd_inspect(args: argparse.Namespace) -> int:
396
+ """Read-only inventory of the user's and project's existing setup."""
397
+ from metaensemble.lib.installer import survey
398
+ result = survey(write_report=True)
399
+ if result.report_path:
400
+ print(f"Inspection complete. Report written to:\n {result.report_path}")
401
+ print(f"\nFound {len(result.discovered)} existing artifact(s).")
402
+ print(f"Found {len(result.collisions)} collision(s) with MetaEnsemble-shipped items.")
403
+ print(
404
+ "\nReview the report and `.metaensemble/install-decisions.yaml`, then run `metaensemble adopt` "
405
+ "(or `metaensemble setup` for the wizard)."
406
+ )
407
+ return 0
408
+
409
+
410
+ def cmd_user_setup(args: argparse.Namespace) -> int:
411
+ """Install MetaEnsemble's user-level runtime integration.
412
+
413
+ User-level integration is the layout-shaped layer that lives under
414
+ `~/.claude/` (slash commands, output styles, the metaensemble-protocol
415
+ skill, lifecycle hooks, statusline) and `~/.metaensemble/runtime/`
416
+ (the vendored runtime, including `bin/me-run`). It is global across
417
+ every project on this machine — `--layout={namespaced|top-level}`
418
+ decides whether commands install namespaced or top-level, and that
419
+ choice applies to all adopted projects.
420
+
421
+ Does NOT touch any project's `.metaensemble/`. Use `metaensemble
422
+ adopt` to register a specific project after this runs.
423
+ """
424
+ from metaensemble.lib.installer import (
425
+ Layout, apply_install, plan_install, remap_user_scope_backup_paths,
426
+ remediate_stale_managed_symlinks, render_plan, survey,
427
+ )
428
+ from metaensemble.lib.topology import (
429
+ detect_editable_install, editable_install_notice,
430
+ )
431
+ from dataclasses import replace
432
+
433
+ layout = Layout(_normalize_layout(args.layout))
434
+
435
+ # Surface install topology before doing anything else. An editable
436
+ # install (`pip install -e .`) means the runner we are about to pin
437
+ # will resolve `import metaensemble` back to the dev source tree —
438
+ # the install becomes load-bearing on that tree, which contradicts
439
+ # the documented "install once, dev tree dispensable" promise. We
440
+ # do NOT block; users iterating on the source legitimately want
441
+ # this. We just make the state legible.
442
+ topology = detect_editable_install()
443
+ if topology.editable:
444
+ print(editable_install_notice(topology, layout.value))
445
+ print()
446
+
447
+ # The inspection reads `~/.claude/` (user-level inventory) for agent
448
+ # collisions and reads the cwd for project signals. Project signals
449
+ # don't affect user-scope actions, so the cwd binding is harmless
450
+ # here — we filter the plan to user-scope only below.
451
+ survey_result = survey(write_report=False)
452
+ plan = plan_install(survey_result, layout)
453
+ user_plan = replace(plan, actions=plan.user_actions())
454
+ user_plan = remap_user_scope_backup_paths(user_plan)
455
+
456
+ if args.dry_run:
457
+ print(render_plan(user_plan))
458
+ return 0
459
+
460
+ # Upgrade-path remediation: a previous install may have left symlinks
461
+ # for runtime artifacts whose source files have since been renamed or
462
+ # removed (e.g. legacy `window.md` after the limits rename). Clean
463
+ # those up before applying so the new layout lands without colliding.
464
+ stale = remediate_stale_managed_symlinks()
465
+ if stale:
466
+ print(f"Cleaned up {len(stale)} stale managed symlink(s) from a prior install:")
467
+ for path in stale:
468
+ print(f" - {path}")
469
+
470
+ report = apply_install(user_plan, dry_run=False, user_scope_only=True)
471
+ if not report.applied and not report.errors:
472
+ print(
473
+ f"Unchanged. 0 action(s) applied, {len(report.noop)} no-op(s) "
474
+ f"in `{layout.value}` layout."
475
+ )
476
+ else:
477
+ print(
478
+ f"User-level integration applied: {len(report.applied)} action(s) "
479
+ f"in `{layout.value}` layout."
480
+ + (f" {len(report.noop)} no-op(s) skipped." if report.noop else "")
481
+ )
482
+ _print_action_report(
483
+ title="User-level action status",
484
+ applied=report.applied,
485
+ noop=report.noop,
486
+ errors=report.errors,
487
+ )
488
+ if report.errors:
489
+ print(f"\n{len(report.errors)} error(s):", file=sys.stderr)
490
+ for action, msg in report.errors:
491
+ print(f" - {action.description}: {msg}", file=sys.stderr)
492
+ return 1
493
+ print(
494
+ "\nNext: `metaensemble adopt [<project-path>]` registers a project "
495
+ "as a MetaEnsemble consumer. Run from the project root, or pass "
496
+ "the path."
497
+ )
498
+ return 0
499
+
500
+
501
+ def _render_adopt_dry_run(
502
+ *,
503
+ project,
504
+ plan,
505
+ decisions_path,
506
+ decisions_existed: bool,
507
+ project_state_existed: bool,
508
+ ) -> str:
509
+ """Render the project-adoption preview without touching the project.
510
+
511
+ Project adoption has implicit side effects (`.metaensemble/` state,
512
+ `.gitignore`, `active-roles.yaml`) in addition to per-agent install
513
+ actions. The generic install-plan renderer only knows the per-agent
514
+ actions, so using it directly made dry-runs say `Actions: 0` while
515
+ real adopt still initialized project state.
516
+ """
517
+ report_path = project / ".metaensemble" / f"inspection-{plan.timestamp}.md"
518
+ defaults_path = (
519
+ project / ".metaensemble" / f"install-decisions.{plan.timestamp}.yaml"
520
+ if decisions_existed else decisions_path
521
+ )
522
+ state_verb = "refresh/verify" if project_state_existed else "initialize"
523
+
524
+ lines = [
525
+ f"# MetaEnsemble adopt plan — layout `{plan.layout.value}`",
526
+ "",
527
+ f"Timestamp: {plan.timestamp}",
528
+ f"Active Roles: {', '.join(plan.active_roles) or '(none)'}",
529
+ ]
530
+ if plan.inactive_roles:
531
+ lines.append(f"Inactive Roles: {', '.join(plan.inactive_roles)}")
532
+ lines.extend([
533
+ "",
534
+ "## Project setup actions",
535
+ "",
536
+ f"- Would write inspection report: `{report_path}`",
537
+ ])
538
+ if decisions_existed:
539
+ lines.append(f"- Would keep existing decisions file: `{decisions_path}`")
540
+ lines.append(f"- Would write current default decisions for diff: `{defaults_path}`")
541
+ else:
542
+ lines.append(f"- Would write fresh default decisions: `{decisions_path}`")
543
+ lines.extend([
544
+ f"- Would {state_verb} project state under: `{project / '.metaensemble'}`",
545
+ f"- Would ensure Ledger DB exists: `{project / '.metaensemble' / 'state' / 'department.db'}`",
546
+ "- Would ensure root `.gitignore` ignores: `.metaensemble/`",
547
+ f"- Would write active roles: `{project / '.metaensemble' / 'active-roles.yaml'}`",
548
+ "",
549
+ "## Per-agent install actions",
550
+ "",
551
+ f"Actions: {len(plan.actions)}",
552
+ ])
553
+ if plan.actions:
554
+ for i, action in enumerate(plan.actions, 1):
555
+ lines.append("")
556
+ lines.append(f"### {i}. {action.kind}")
557
+ lines.append("")
558
+ lines.append(f"- Description: {action.description}")
559
+ if action.source:
560
+ lines.append(f"- Source: `{action.source}`")
561
+ if action.target:
562
+ lines.append(f"- Target: `{action.target}`")
563
+ if action.backup_path:
564
+ lines.append(f"- Backup: `{action.backup_path}`")
565
+ else:
566
+ lines.append("- None — every per-agent decision is a no-op on disk.")
567
+ return "\n".join(lines)
568
+
569
+
570
+ def _print_action_report(
571
+ *,
572
+ title: str,
573
+ applied,
574
+ noop=None,
575
+ errors=None,
576
+ ) -> None:
577
+ """Verbose-by-default per-action status for install/teardown operations."""
578
+ noop = list(noop or [])
579
+ errors = list(errors or [])
580
+ if not applied and not noop and not errors:
581
+ print(f"{title}: no filesystem actions.")
582
+ return
583
+ print(f"{title}:")
584
+ for action in applied:
585
+ target = f" -> {action.target}" if action.target else ""
586
+ print(f" - OK {action.kind}{target}")
587
+ print(f" {action.description}")
588
+ for action in noop:
589
+ target = f" -> {action.target}" if action.target else ""
590
+ print(f" - SKIP {action.kind}{target}")
591
+ print(" already in desired state")
592
+ for action, msg in errors:
593
+ target = f" -> {action.target}" if action.target else ""
594
+ print(f" - FAIL {action.kind}{target}", file=sys.stderr)
595
+ print(f" {action.description}: {msg}", file=sys.stderr)
596
+
597
+
598
+ def cmd_adopt(args: argparse.Namespace) -> int:
599
+ """Register a project as a MetaEnsemble consumer.
600
+
601
+ Runs the inspection (read-only) on the project, then applies the
602
+ project-scope subset of the install plan: per-project state
603
+ (`<project>/.metaensemble/`), agent conversions and Role
604
+ installations dictated by `install-decisions.yaml`, and the
605
+ managed `.gitignore` block.
606
+
607
+ Requires `metaensemble user-setup` to have run first. The layout
608
+ used by user-setup is detected from `~/.claude/` and reused;
609
+ `adopt` does NOT take a `--layout` flag because layout is a
610
+ user-level decision that applies to every adopted project.
611
+
612
+ The project path defaults to the current working directory; pass
613
+ a path positionally to adopt a different project.
614
+ """
615
+ from dataclasses import replace
616
+
617
+ from metaensemble.lib.installer import (
618
+ apply_install, detect_user_layout, load_decisions,
619
+ plan_install, survey,
620
+ )
621
+
622
+ layout = detect_user_layout()
623
+ if layout is None:
624
+ print(
625
+ "adopt: MetaEnsemble user-level integration is not installed. "
626
+ "Run `metaensemble user-setup --layout={namespaced,top-level}` "
627
+ "first; then re-run adopt.",
628
+ file=sys.stderr,
629
+ )
630
+ return 1
631
+
632
+ project = Path(args.path).resolve() if args.path else Path.cwd()
633
+ if not project.is_dir():
634
+ print(f"adopt: project path is not a directory: {project}", file=sys.stderr)
635
+ return 1
636
+
637
+ # Snapshot pre-adoption state so we can name the side effects honestly.
638
+ # The previous copy ("Unchanged. 0 action(s) applied") was misleading
639
+ # when the per-agent decisions happen to be all-no-op on disk but the
640
+ # project still gets initialized and the inspection re-runs.
641
+ project_dir = project / ".metaensemble"
642
+ decisions_path = project_dir / "install-decisions.yaml"
643
+ project_state_existed = (project_dir / "state").exists()
644
+ decisions_existed = decisions_path.exists()
645
+
646
+ print(f"Adopting project: {project}")
647
+ print(f"User-level layout (inherited): {layout.value}")
648
+
649
+ survey_result = survey(project=project, write_report=not args.dry_run)
650
+ if survey_result.report_path:
651
+ print(f"Inspection report: {survey_result.report_path}")
652
+
653
+ # Honor the Principal's edited decisions when the file is on disk;
654
+ # otherwise use the defaults the inspection just wrote.
655
+ decisions = survey_result.decisions
656
+ if decisions_existed:
657
+ try:
658
+ decisions = load_decisions(decisions_path)
659
+ print(f"Decisions: using existing {decisions_path}")
660
+ # survey() writes install-decisions.<timestamp>.yaml when the
661
+ # main file already exists, so the Principal can diff the
662
+ # current defaults against their edits.
663
+ from metaensemble.lib.installer import _project_metaensemble_dir
664
+ timestamp = survey_result.report_path.stem.replace("inspection-", "") if survey_result.report_path else None
665
+ if timestamp:
666
+ companion = _project_metaensemble_dir(project) / f"install-decisions.{timestamp}.yaml"
667
+ if companion.exists():
668
+ print(f" current defaults written to {companion} for diff")
669
+ except Exception as exc:
670
+ print(
671
+ f"warning: could not read {decisions_path}: {exc}; using defaults",
672
+ file=sys.stderr,
673
+ )
674
+ else:
675
+ if args.dry_run:
676
+ print(f"Decisions: fresh defaults would be written to {decisions_path}")
677
+ else:
678
+ print(f"Decisions: fresh defaults written to {decisions_path}")
679
+
680
+ plan = plan_install(survey_result, layout, project=project, decisions=decisions)
681
+ project_plan = replace(plan, actions=plan.project_actions())
682
+
683
+ if args.dry_run:
684
+ print(_render_adopt_dry_run(
685
+ project=project,
686
+ plan=project_plan,
687
+ decisions_path=decisions_path,
688
+ decisions_existed=decisions_existed,
689
+ project_state_existed=project_state_existed,
690
+ ))
691
+ return 0
692
+
693
+ report = apply_install(project_plan, project=project, dry_run=False)
694
+ state_verb = "initialized" if not project_state_existed else "refreshed"
695
+ print(f"Project state {state_verb}: {project_dir}/")
696
+ if not report.applied and not report.errors:
697
+ print(
698
+ "Install actions: 0 applied "
699
+ "(every per-agent decision is a no-op on disk — nothing to convert)"
700
+ )
701
+ else:
702
+ print(
703
+ f"Install actions: {len(report.applied)} applied"
704
+ + (f", {len(report.noop)} no-op(s) skipped" if report.noop else "")
705
+ )
706
+ _print_action_report(
707
+ title="Per-agent action status",
708
+ applied=report.applied,
709
+ noop=report.noop,
710
+ errors=report.errors,
711
+ )
712
+ if report.errors:
713
+ print(f"\n{len(report.errors)} error(s):", file=sys.stderr)
714
+ for action, msg in report.errors:
715
+ print(f" - {action.description}: {msg}", file=sys.stderr)
716
+ return 1
717
+ if report.backup_root:
718
+ print(f"Backups written to: {report.backup_root}")
719
+ if plan.active_roles:
720
+ print(f"Active Roles ({len(plan.active_roles)}): {', '.join(plan.active_roles)}")
721
+ return 0
722
+
723
+
724
+ def cmd_unadopt(args: argparse.Namespace) -> int:
725
+ """Reverse a project's MetaEnsemble adoption.
726
+
727
+ Walks `<project>/.metaensemble/backups/` and reverses the project-
728
+ scope actions (agent restorations, curated-Role uninstalls,
729
+ `.gitignore` block strip). Leaves user-level integration in
730
+ place — `~/.claude/` symlinks, hooks, statusline, and the launcher
731
+ all survive. Use `metaensemble user-teardown` to remove those.
732
+
733
+ The project path defaults to cwd. Pass `--purge-state` to also
734
+ delete `<project>/.metaensemble/` entirely.
735
+ """
736
+ from metaensemble.lib.installer import uninstall
737
+
738
+ project = Path(args.path).resolve() if args.path else Path.cwd()
739
+ if not project.is_dir():
740
+ print(f"unadopt: project path is not a directory: {project}", file=sys.stderr)
741
+ return 1
742
+
743
+ report = uninstall(
744
+ project=project,
745
+ restore=True,
746
+ purge_project_state_flag=args.purge_state,
747
+ scope="project",
748
+ dry_run=args.dry_run,
749
+ )
750
+ verb = "Would reverse" if args.dry_run else "Reversed"
751
+ print(f"{verb} {len(report.applied)} action(s) at project scope.")
752
+ _print_action_report(
753
+ title="Project teardown action status",
754
+ applied=report.applied,
755
+ noop=report.noop,
756
+ errors=report.errors,
757
+ )
758
+ if report.errors:
759
+ print(f"\n{len(report.errors)} error(s):", file=sys.stderr)
760
+ for action, msg in report.errors:
761
+ print(f" - {action.description}: {msg}", file=sys.stderr)
762
+ return 1
763
+ return 0
764
+
765
+
766
+ def cmd_user_teardown(args: argparse.Namespace) -> int:
767
+ """Reverse user-setup.
768
+
769
+ Walks the user-level backup roots
770
+ (`~/.metaensemble/installs/<timestamp>/plan.json`) and reverses
771
+ every user-scope action — slash command symlinks, output styles,
772
+ the metaensemble-protocol skill, the lifecycle hook entries in
773
+ `settings.json`, and the statusline. The vendored runtime at
774
+ `~/.metaensemble/runtime/` (including its `bin/me-run`) survives
775
+ unless `--purge-state` is set (it is the recovery anchor for the
776
+ next user-setup).
777
+
778
+ Leaves every adopted project's `.metaensemble/` in place. Use
779
+ `metaensemble unadopt` per project to clear those.
780
+ """
781
+ from metaensemble.lib.installer import uninstall
782
+
783
+ report = uninstall(
784
+ restore=True,
785
+ purge_user_state_flag=args.purge_state,
786
+ scope="user",
787
+ dry_run=args.dry_run,
788
+ )
789
+ verb = "Would reverse" if args.dry_run else "Reversed"
790
+ print(f"{verb} {len(report.applied)} action(s) at user scope.")
791
+ _print_action_report(
792
+ title="User teardown action status",
793
+ applied=report.applied,
794
+ noop=report.noop,
795
+ errors=report.errors,
796
+ )
797
+ if report.errors:
798
+ print(f"\n{len(report.errors)} error(s):", file=sys.stderr)
799
+ for action, msg in report.errors:
800
+ print(f" - {action.description}: {msg}", file=sys.stderr)
801
+ return 1
802
+ return 0
803
+
804
+
805
+ def cmd_doctor(args: argparse.Namespace) -> int:
806
+ """Run diagnostic checks; exit non-zero if any check fails."""
807
+ from metaensemble.lib.doctor import render_report, run_doctor
808
+ report = run_doctor(fix=args.fix)
809
+ print(render_report(report))
810
+ return 1 if report.has_failures else 0
811
+
812
+
813
+ def cmd_projects(args: argparse.Namespace) -> int:
814
+ """List every Claude Code project on this machine + MetaEnsemble install status.
815
+
816
+ Useful when you have many projects and want to know which ones already
817
+ have MetaEnsemble configured. Run `metaensemble setup` for the wizard,
818
+ or `cd <path>` and run `metaensemble adopt` to register a specific
819
+ project.
820
+ """
821
+ from metaensemble.lib.installer import (
822
+ discover_projects, prune_missing_projects, render_projects,
823
+ )
824
+ if getattr(args, "prune", False):
825
+ removed = prune_missing_projects()
826
+ print(f"Pruned {len(removed)} stale project registration(s).")
827
+ for path in removed:
828
+ print(f" - {path}")
829
+ projects = discover_projects()
830
+ print(render_projects(projects))
831
+ return 0
832
+
833
+
834
+ def cmd_eval(args: argparse.Namespace) -> int:
835
+ """Run an evaluation cycle in one of three tiers (replay/smoke/full).
836
+
837
+ The harness lives under `evals/`; this CLI command is the thin
838
+ wrapper that ties tier selection to the runner dispatch and writes
839
+ the report to the caller's `evals/reports/<UTC-date>-<tier>.md`.
840
+ The smoke tier runs a live, side-effect-free classification call by default.
841
+ The full tier requires `--allow-live` before it spends money.
842
+ """
843
+ from datetime import datetime, timezone
844
+ from pathlib import Path
845
+ import yaml
846
+ from evals.runners.api import (
847
+ CellSpec,
848
+ TaskSpec,
849
+ Tier,
850
+ assemble_report,
851
+ evaluate_release_gates,
852
+ render_report,
853
+ run_cell_replay,
854
+ run_suite_b_live_claude,
855
+ )
856
+
857
+ package_root = CORE_DIR.parent
858
+ evals_root = package_root / "evals"
859
+ config_path = Path(args.config) if args.config else evals_root / "configs" / "default.yaml"
860
+ config = yaml.safe_load(config_path.read_text())
861
+
862
+ tier = Tier(args.tier)
863
+ suite_a = yaml.safe_load((evals_root / "datasets" / "suite_a" / "tasks.yaml").read_text())
864
+ suite_b = yaml.safe_load((evals_root / "datasets" / "suite_b" / "items.yaml").read_text())
865
+ suite_a_tasks = [
866
+ TaskSpec(id=t["id"], suite="suite_a",
867
+ description=t["description"], acceptance=t.get("acceptance", []))
868
+ for t in suite_a["tasks"]
869
+ ]
870
+ suite_b_tasks = [
871
+ TaskSpec(id=it["id"], suite="suite_b",
872
+ description=it["text"], acceptance=[],
873
+ acceptable_labels=it.get("acceptable_labels", []))
874
+ for it in suite_b["items"]
875
+ ]
876
+
877
+ seeds = args.seeds if args.seeds is not None else (
878
+ 1 if tier == Tier.SMOKE else int(config["cycle"]["seeds"])
879
+ )
880
+ budget_usd = (
881
+ args.budget_usd
882
+ if args.budget_usd is not None
883
+ else float(config["cycle"]["budget_usd"])
884
+ )
885
+ if seeds < 1:
886
+ print("--seeds must be >= 1", file=sys.stderr)
887
+ return 2
888
+ if budget_usd <= 0:
889
+ print("--budget-usd must be > 0", file=sys.stderr)
890
+ return 2
891
+
892
+ if tier == Tier.FULL and not args.allow_live:
893
+ print(
894
+ "Refusing to run the full live tier: pass --allow-live to confirm.\n"
895
+ "The full tier may spend real Claude budget; rerun with explicit "
896
+ "cell/seed/budget values if you want a constrained release check.",
897
+ file=sys.stderr,
898
+ )
899
+ return 2
900
+
901
+ cells = [CellSpec(id=c["id"], kind=c["kind"], dispatch_fn=c["id"])
902
+ for c in config["cells"]]
903
+ cell_filter = args.cells
904
+ if cell_filter is None and tier == Tier.SMOKE:
905
+ cell_filter = "MM_full"
906
+ if cell_filter and cell_filter != "all":
907
+ wanted = {c.strip() for c in cell_filter.split(",") if c.strip()}
908
+ cells = [c for c in cells if c.id in wanted]
909
+ missing = wanted - {c.id for c in cells}
910
+ if missing:
911
+ print(f"Unknown eval cell(s): {', '.join(sorted(missing))}", file=sys.stderr)
912
+ return 2
913
+
914
+ if tier == Tier.REPLAY:
915
+ cassette_dir = evals_root / "cassettes"
916
+ cells_with_outcomes = []
917
+ for cell in cells:
918
+ try:
919
+ outcomes = run_cell_replay(
920
+ cell,
921
+ suite_a_tasks + suite_b_tasks,
922
+ cassette_dir,
923
+ seeds=seeds,
924
+ )
925
+ except FileNotFoundError as exc:
926
+ print(f"replay tier missing cassette: {exc}", file=sys.stderr)
927
+ return 1
928
+ cells_with_outcomes.append((cell, outcomes))
929
+ b4_tokens = next(
930
+ (
931
+ sum(o.tokens_in + o.tokens_out for o in outcomes)
932
+ for cell, outcomes in cells_with_outcomes
933
+ if cell.id == "B4_best_prompt"
934
+ ),
935
+ None,
936
+ )
937
+ baseline_lookup = (
938
+ {
939
+ cell.id: b4_tokens
940
+ for cell, _outcomes in cells_with_outcomes
941
+ if cell.id != "B4_best_prompt" and cell.kind != "baseline"
942
+ }
943
+ if b4_tokens
944
+ else None
945
+ )
946
+ report = assemble_report(
947
+ tier=tier,
948
+ cells_with_outcomes=cells_with_outcomes,
949
+ baseline_total_tokens_lookup=baseline_lookup,
950
+ )
951
+ if (cassette_dir / "bootstrap.jsonl").exists():
952
+ report.notes.append(
953
+ "The bootstrap cassettes verify replay mechanics only; "
954
+ "they are not empirical benchmark evidence."
955
+ )
956
+ else:
957
+ # Smoke and full tiers — live Claude Code calls. Smoke defaults to the
958
+ # MM_full cell and the smoke suite so it exercises MetaEnsemble's dispatch path
959
+ # without mutating the project. Full is allowed only with explicit
960
+ # confirmation; Suite A tasks with deferred fixture SHAs are skipped and
961
+ # named in the report rather than fabricated.
962
+ print(f"=== Eval pre-flight ({tier.value}) ===")
963
+ print(f"Cells : {len(cells)}")
964
+ print(f"Suite A : {0 if tier == Tier.SMOKE else len(suite_a_tasks)} tasks")
965
+ print(f"Suite B : {len(suite_b_tasks)} items (smoke set)")
966
+ print(f"Seeds : {seeds}")
967
+ print(f"Budget : USD {budget_usd:.2f} per run")
968
+ print()
969
+ cells_with_outcomes = []
970
+ for cell in cells:
971
+ outcomes = run_suite_b_live_claude(
972
+ cell,
973
+ suite_b_tasks,
974
+ seeds=seeds,
975
+ budget_usd=budget_usd,
976
+ cwd=Path.cwd(),
977
+ )
978
+ cells_with_outcomes.append((cell, outcomes))
979
+ b4_tokens = next(
980
+ (
981
+ sum(o.tokens_in + o.tokens_out for o in outcomes)
982
+ for cell, outcomes in cells_with_outcomes
983
+ if cell.id == "B4_best_prompt"
984
+ ),
985
+ None,
986
+ )
987
+ baseline_lookup = (
988
+ {
989
+ cell.id: b4_tokens
990
+ for cell, _outcomes in cells_with_outcomes
991
+ if cell.id != "B4_best_prompt" and cell.kind != "baseline"
992
+ }
993
+ if b4_tokens
994
+ else None
995
+ )
996
+ report = assemble_report(
997
+ tier=tier,
998
+ cells_with_outcomes=cells_with_outcomes,
999
+ baseline_total_tokens_lookup=baseline_lookup,
1000
+ )
1001
+ failure_counts: dict[str, int] = {}
1002
+ for cell, outcomes in cells_with_outcomes:
1003
+ for outcome in outcomes:
1004
+ if outcome.passed:
1005
+ continue
1006
+ reason = outcome.failure_reason or "unclassified failure"
1007
+ key = f"{cell.id}: {reason}"
1008
+ failure_counts[key] = failure_counts.get(key, 0) + 1
1009
+ report.notes.append(
1010
+ "Live smoke run: labels were scored against the shipped smoke set. "
1011
+ "This side-effect-free run proves the live eval path and reports "
1012
+ "pass@budget; dispatch/install behavior is tested separately, and "
1013
+ "the smoke set is not an independently labeled calibration set."
1014
+ )
1015
+ if failure_counts:
1016
+ rendered = "; ".join(
1017
+ f"{reason} ({count})"
1018
+ for reason, count in sorted(failure_counts.items())[:5]
1019
+ )
1020
+ report.notes.append(f"Failure reasons: {rendered}.")
1021
+ if tier == Tier.FULL:
1022
+ deferred_suite_a = [
1023
+ t["id"] for t in suite_a["tasks"]
1024
+ if str(t.get("starting_sha", "")).startswith("__DEFERRED__")
1025
+ ]
1026
+ if deferred_suite_a:
1027
+ report.notes.append(
1028
+ "Suite A live tasks skipped because their fixture SHAs are "
1029
+ f"deferred: {', '.join(deferred_suite_a)}."
1030
+ )
1031
+
1032
+ gate_failed = False
1033
+ reporting = config.get("reporting") or {}
1034
+ if tier == Tier.FULL:
1035
+ gate_failed, gate_notes = evaluate_release_gates(
1036
+ report,
1037
+ failed_run_waste_threshold=reporting.get("failed_run_waste_threshold"),
1038
+ overhead_ratio_ceiling=reporting.get("overhead_ratio_ceiling"),
1039
+ )
1040
+ report.notes.extend(gate_notes)
1041
+
1042
+ report_path = Path.cwd() / "evals" / "reports" / (
1043
+ datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + f"-{tier.value}.md"
1044
+ )
1045
+ report_path.parent.mkdir(parents=True, exist_ok=True)
1046
+ report_path.write_text(render_report(report))
1047
+ print(f"Eval report written to {report_path}")
1048
+ if gate_failed:
1049
+ return 1
1050
+ return 0
1051
+
1052
+
1053
+ def cmd_export_agents(args: argparse.Namespace) -> int:
1054
+ """Reverse-convert MetaEnsemble Roles back into Claude Code agent files.
1055
+
1056
+ Recovery escape hatch. Useful when:
1057
+ - A user wants to leave MetaEnsemble but keep the agents they had before.
1058
+ - Project adoption backups are unavailable, so `unadopt` cannot
1059
+ replay converted-agent restores.
1060
+ - A user wants to copy the converted Roles to a *different* machine
1061
+ where they will run as plain Claude Code agents.
1062
+ """
1063
+ from metaensemble.lib.installer import export_agents
1064
+
1065
+ target_dir = Path(args.target_dir).resolve() if args.target_dir else None
1066
+ written = export_agents(
1067
+ target_dir=target_dir,
1068
+ include_user=not args.project_only,
1069
+ include_project=not args.user_only,
1070
+ overwrite=args.overwrite,
1071
+ )
1072
+ if not written:
1073
+ print("No Role files reverse-converted (target may already have these files; pass --overwrite to replace).")
1074
+ return 0
1075
+ print(f"Reverse-converted {len(written)} Role file(s) into Claude Code agents:")
1076
+ for p in written:
1077
+ print(f" {p}")
1078
+ return 0
1079
+
1080
+
1081
+ def main(argv: list[str] | None = None) -> int:
1082
+ from metaensemble import __version__
1083
+
1084
+ parser = argparse.ArgumentParser(
1085
+ prog="metaensemble",
1086
+ description=EXPERIMENTAL_NOTICE,
1087
+ )
1088
+ parser.add_argument("--version", action="version", version=f"metaensemble {__version__}")
1089
+ sub = parser.add_subparsers(dest="cmd", required=True)
1090
+
1091
+ p_init = sub.add_parser("init", help="Initialize MetaEnsemble in the current project")
1092
+ p_init.add_argument("--pack", choices=["ml", "web", "data"], help="Optional starter pack (future release)")
1093
+ p_init.add_argument("--force", action="store_true", help="Reinitialize an existing .metaensemble/")
1094
+ p_init.set_defaults(func=cmd_init)
1095
+
1096
+ p_limits = sub.add_parser("limits", help="Current 5-hour token-limit status")
1097
+ p_limits.set_defaults(func=cmd_limits)
1098
+
1099
+ p_standup = sub.add_parser("standup", help="Daily standup digest")
1100
+ p_standup.set_defaults(func=cmd_standup)
1101
+
1102
+ p_executors = sub.add_parser("executors", help="List active Executors")
1103
+ p_executors.set_defaults(func=cmd_executors)
1104
+
1105
+ p_perf = sub.add_parser("perf", help="Rolling performance metrics")
1106
+ p_perf.set_defaults(func=cmd_perf)
1107
+
1108
+ p_stats = sub.add_parser("stats", help="One-screen Ledger growth and run-mix summary")
1109
+ p_stats.set_defaults(func=cmd_stats)
1110
+
1111
+ p_ledger = sub.add_parser("ledger", help="Query the Ledger (see `ledger --help`)")
1112
+ p_ledger.add_argument("subargs", nargs=argparse.REMAINDER)
1113
+ p_ledger.set_defaults(func=cmd_ledger)
1114
+
1115
+ p_hook = sub.add_parser(
1116
+ "hook",
1117
+ help="Invoke a lifecycle hook script by filename (runtime integration; "
1118
+ "installed in settings.json by `metaensemble user-setup`)",
1119
+ )
1120
+ p_hook.add_argument(
1121
+ "name",
1122
+ help="Hook script filename, e.g. `pre_task.py`",
1123
+ )
1124
+ p_hook.set_defaults(func=cmd_hook)
1125
+
1126
+ p_statusline = sub.add_parser(
1127
+ "statusline",
1128
+ help="Invoke the MetaEnsemble statusline script (runtime integration)",
1129
+ )
1130
+ p_statusline.set_defaults(func=cmd_statusline)
1131
+
1132
+ p_setup = sub.add_parser(
1133
+ "setup",
1134
+ help="Interactive wizard: pick a project to adopt, then run "
1135
+ "user-setup (if needed) and adopt.",
1136
+ )
1137
+ p_setup.add_argument(
1138
+ "--layout", choices=LAYOUT_CHOICES, default=None,
1139
+ help="Skip the layout prompt when user-setup hasn't run yet. "
1140
+ "Has no effect if user-setup is already installed. "
1141
+ "Choices control only slash-command placement.",
1142
+ )
1143
+ p_setup.set_defaults(func=cmd_setup)
1144
+
1145
+ p_manifest = sub.add_parser(
1146
+ "manifest",
1147
+ help="Manifest authoring helpers (subcommands: validate, new-id, scaffold).",
1148
+ )
1149
+ p_manifest_sub = p_manifest.add_subparsers(dest="subcmd", required=True)
1150
+ p_manifest_validate = p_manifest_sub.add_parser(
1151
+ "validate",
1152
+ help="Load + schema-validate a Manifest YAML file.",
1153
+ )
1154
+ p_manifest_validate.add_argument("path", help="Path to the Manifest YAML file.")
1155
+ p_manifest_sub.add_parser(
1156
+ "new-id",
1157
+ help="Print a fresh `hm-<UUIDv7>` Manifest id to stdout.",
1158
+ )
1159
+ p_manifest_scaffold = p_manifest_sub.add_parser(
1160
+ "scaffold",
1161
+ help="Print a starter Manifest YAML to stdout (or write to `-o <path>`).",
1162
+ )
1163
+ p_manifest_scaffold.add_argument(
1164
+ "task", help="kebab-case task identifier for the new Manifest."
1165
+ )
1166
+ p_manifest_scaffold.add_argument(
1167
+ "-o", "--output", default=None,
1168
+ help="Write the scaffold to this path instead of stdout.",
1169
+ )
1170
+ p_manifest.set_defaults(func=cmd_manifest)
1171
+
1172
+ p_reconcile = sub.add_parser(
1173
+ "reconcile",
1174
+ help="Reconcile stranded pending-Run sidecars into the Ledger",
1175
+ )
1176
+ p_reconcile.add_argument(
1177
+ "--older-than-minutes", type=int, default=0,
1178
+ help="Only reconcile sidecars older than N minutes (default: 0 = all).",
1179
+ )
1180
+ p_reconcile.add_argument(
1181
+ "--dry-run", action="store_true",
1182
+ help="Report what would be reconciled without writing to the Ledger.",
1183
+ )
1184
+ p_reconcile.set_defaults(func=cmd_reconcile)
1185
+
1186
+ p_relaunch = sub.add_parser(
1187
+ "relaunch",
1188
+ help="Print the relaunch context for an Executor alias (does not dispatch)",
1189
+ )
1190
+ p_relaunch.add_argument("alias", help="Executor alias, e.g. `arch-7b3`")
1191
+ p_relaunch.add_argument(
1192
+ "--full", action="store_true",
1193
+ help="Load the entire prior Deliverable and every prior Brief",
1194
+ )
1195
+ p_relaunch.set_defaults(func=cmd_relaunch)
1196
+
1197
+ p_inspect = sub.add_parser(
1198
+ "inspect",
1199
+ help="Read-only inventory of existing Claude Code setup; no changes made",
1200
+ )
1201
+ p_inspect.set_defaults(func=cmd_inspect)
1202
+
1203
+ p_user_setup = sub.add_parser(
1204
+ "user-setup",
1205
+ help="Install MetaEnsemble's user-level runtime integration "
1206
+ "(once per machine; sets the layout for every adopted project)",
1207
+ )
1208
+ p_user_setup.add_argument(
1209
+ "--layout", choices=LAYOUT_CHOICES, default="namespaced",
1210
+ help=(
1211
+ "namespaced: slash commands install under /metaensemble:* and "
1212
+ "output styles are prefixed (metaensemble-wire, metaensemble-deliverable). "
1213
+ "top-level: slash commands install directly, e.g. /dispatch, "
1214
+ "and output styles are unprefixed; collisions with user-authored "
1215
+ "files are refused. The choice applies to all adopted projects; "
1216
+ "re-run with a different layout to switch."
1217
+ ),
1218
+ )
1219
+ p_user_setup.add_argument(
1220
+ "--dry-run", action="store_true",
1221
+ help="Print the planned user-level actions without applying any",
1222
+ )
1223
+ p_user_setup.set_defaults(func=cmd_user_setup)
1224
+
1225
+ p_adopt = sub.add_parser(
1226
+ "adopt",
1227
+ help="Register a project as a MetaEnsemble consumer "
1228
+ "(requires user-setup to have run first)",
1229
+ )
1230
+ p_adopt.add_argument(
1231
+ "path", nargs="?", default=None,
1232
+ help="Path to the project to adopt (default: current directory).",
1233
+ )
1234
+ p_adopt.add_argument(
1235
+ "--dry-run", action="store_true",
1236
+ help="Print the planned project-scope actions without applying any",
1237
+ )
1238
+ p_adopt.set_defaults(func=cmd_adopt)
1239
+
1240
+ p_unadopt = sub.add_parser(
1241
+ "unadopt",
1242
+ help="Reverse a project's MetaEnsemble adoption "
1243
+ "(leaves user-level integration intact)",
1244
+ )
1245
+ p_unadopt.add_argument(
1246
+ "path", nargs="?", default=None,
1247
+ help="Path to the project to unadopt (default: current directory).",
1248
+ )
1249
+ p_unadopt.add_argument(
1250
+ "--purge-state", action="store_true",
1251
+ help="Also delete <project>/.metaensemble/ entirely",
1252
+ )
1253
+ p_unadopt.add_argument(
1254
+ "--dry-run", action="store_true",
1255
+ help="Report what would be reversed without making any changes.",
1256
+ )
1257
+ p_unadopt.set_defaults(func=cmd_unadopt)
1258
+
1259
+ p_user_teardown = sub.add_parser(
1260
+ "user-teardown",
1261
+ help="Reverse user-setup (removes commands, hooks, statusline, skill, "
1262
+ "output styles from ~/.claude/)",
1263
+ )
1264
+ p_user_teardown.add_argument(
1265
+ "--purge-state", action="store_true",
1266
+ help="Also delete ~/.metaensemble/ entirely (vendored runtime under runtime/ and runtime-versions/, roles, installs, rate-limit cache)",
1267
+ )
1268
+ p_user_teardown.add_argument(
1269
+ "--dry-run", action="store_true",
1270
+ help="Report what would be reversed without making any changes.",
1271
+ )
1272
+ p_user_teardown.set_defaults(func=cmd_user_teardown)
1273
+
1274
+
1275
+ p_doctor = sub.add_parser(
1276
+ "doctor",
1277
+ help="Diagnose the install and report problems",
1278
+ )
1279
+ p_doctor.add_argument(
1280
+ "--fix", action="store_true",
1281
+ help="Apply safe remediations for fixable check failures. Legacy C1/C6 checks are inert and not affected.",
1282
+ )
1283
+ p_doctor.set_defaults(func=cmd_doctor)
1284
+
1285
+ p_projects = sub.add_parser(
1286
+ "projects",
1287
+ help="List every Claude Code project on this machine + MetaEnsemble install status",
1288
+ )
1289
+ p_projects.add_argument(
1290
+ "--prune", action="store_true",
1291
+ help="Remove Claude Code project registrations whose cwd no longer exists before listing.",
1292
+ )
1293
+ p_projects.set_defaults(func=cmd_projects)
1294
+
1295
+ p_eval = sub.add_parser(
1296
+ "eval",
1297
+ help="Run an evaluation cycle (replay / smoke / full)",
1298
+ )
1299
+ p_eval.add_argument(
1300
+ "--tier", choices=["replay", "smoke", "full"], default="replay",
1301
+ help="Evaluation tier. PR replay is the default; full requires --allow-live.",
1302
+ )
1303
+ p_eval.add_argument(
1304
+ "--config", type=str, default=None,
1305
+ help="Path to a config YAML; default `evals/configs/default.yaml`.",
1306
+ )
1307
+ p_eval.add_argument(
1308
+ "--cells", type=str, default=None,
1309
+ help="Comma-separated cell ids or `all`. Defaults: replay/full all, smoke MM_full.",
1310
+ )
1311
+ p_eval.add_argument(
1312
+ "--seeds", type=int, default=None,
1313
+ help="Override seed count. Defaults: replay/full config value, smoke 1.",
1314
+ )
1315
+ p_eval.add_argument(
1316
+ "--budget-usd", type=float, default=None,
1317
+ help="Override per-run live budget for smoke/full preflight.",
1318
+ )
1319
+ p_eval.add_argument(
1320
+ "--allow-live", action="store_true",
1321
+ help="Required to run the full live tier; otherwise full prints pre-flight only.",
1322
+ )
1323
+ p_eval.set_defaults(func=cmd_eval)
1324
+
1325
+ p_export = sub.add_parser(
1326
+ "export-agents",
1327
+ help="Reverse-convert MetaEnsemble Roles into Claude Code agent files",
1328
+ )
1329
+ p_export.add_argument(
1330
+ "--target-dir", type=str, default=None,
1331
+ help="Directory to write the agents into (default: ~/.claude/agents/).",
1332
+ )
1333
+ p_export.add_argument(
1334
+ "--user-only", action="store_true",
1335
+ help="Only export user-layer Roles from ~/.metaensemble/roles/.",
1336
+ )
1337
+ p_export.add_argument(
1338
+ "--project-only", action="store_true",
1339
+ help="Only export project-layer Roles from <project>/.metaensemble/roles/.",
1340
+ )
1341
+ p_export.add_argument(
1342
+ "--overwrite", action="store_true",
1343
+ help="Replace existing files at the target path. Default: skip existing.",
1344
+ )
1345
+ p_export.set_defaults(func=cmd_export_agents)
1346
+
1347
+ args = parser.parse_args(argv)
1348
+ # One-shot migration of legacy `parallel`/`incorporate` vocabulary in
1349
+ # on-disk state. Idempotent + process-cached, so commands that don't
1350
+ # touch state pay only a registry-walk once per session. See addendum
1351
+ # Addition 1 for the policy and the rewrite scope.
1352
+ if args.cmd not in ("hook",):
1353
+ try:
1354
+ from metaensemble.lib.installer import migrate_vocabulary_state
1355
+ migrate_vocabulary_state()
1356
+ except Exception: # nosec B110 — migration must not break the CLI
1357
+ pass
1358
+ return args.func(args)
1359
+
1360
+
1361
+ if __name__ == "__main__":
1362
+ sys.exit(main())