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.
- evals/README.md +147 -0
- evals/__init__.py +0 -0
- evals/cassettes/README.md +10 -0
- evals/cassettes/bootstrap.jsonl +800 -0
- evals/configs/default.yaml +59 -0
- evals/datasets/__init__.py +0 -0
- evals/datasets/suite_a/tasks.yaml +123 -0
- evals/datasets/suite_b/items.yaml +90 -0
- evals/runners/__init__.py +12 -0
- evals/runners/api.py +518 -0
- evals/runners/metrics.py +132 -0
- metaensemble/__init__.py +13 -0
- metaensemble/cli.py +1362 -0
- metaensemble/commands/dispatch.md +39 -0
- metaensemble/commands/executors.md +12 -0
- metaensemble/commands/ledger.md +19 -0
- metaensemble/commands/limits.md +12 -0
- metaensemble/commands/perf.md +12 -0
- metaensemble/commands/relaunch.md +29 -0
- metaensemble/commands/standup.md +14 -0
- metaensemble/config/budgets.example.yaml +72 -0
- metaensemble/config/quality.example.yaml +82 -0
- metaensemble/hooks/__init__.py +1 -0
- metaensemble/hooks/_common.py +148 -0
- metaensemble/hooks/deliverable_sync.py +73 -0
- metaensemble/hooks/file_event.py +303 -0
- metaensemble/hooks/post_task.py +460 -0
- metaensemble/hooks/pre_task.py +548 -0
- metaensemble/hooks/session_start.py +212 -0
- metaensemble/hooks/session_summary.py +392 -0
- metaensemble/hooks/subagent_stop.py +94 -0
- metaensemble/lib/__init__.py +1 -0
- metaensemble/lib/config.py +414 -0
- metaensemble/lib/cost_gate.py +299 -0
- metaensemble/lib/dispatch.py +341 -0
- metaensemble/lib/doctor.py +1563 -0
- metaensemble/lib/file_events.py +395 -0
- metaensemble/lib/ids.py +91 -0
- metaensemble/lib/installer.py +5018 -0
- metaensemble/lib/ledger.py +812 -0
- metaensemble/lib/manifest.py +141 -0
- metaensemble/lib/native_state.py +463 -0
- metaensemble/lib/overlaps.py +155 -0
- metaensemble/lib/quality_gate.py +155 -0
- metaensemble/lib/quality_runners.py +446 -0
- metaensemble/lib/reconcile.py +420 -0
- metaensemble/lib/recording.py +422 -0
- metaensemble/lib/relaunch.py +174 -0
- metaensemble/lib/runtime_payload.py +42 -0
- metaensemble/lib/runtime_state.py +308 -0
- metaensemble/lib/sidecar.py +166 -0
- metaensemble/lib/topology.py +181 -0
- metaensemble/lib/transcript.py +432 -0
- metaensemble/output-styles/deliverable.md +33 -0
- metaensemble/output-styles/wire.md +38 -0
- metaensemble/roles/architect.md +52 -0
- metaensemble/roles/backend.md +43 -0
- metaensemble/roles/code-quality.md +49 -0
- metaensemble/roles/data-engineer.md +42 -0
- metaensemble/roles/devops.md +42 -0
- metaensemble/roles/docs.md +41 -0
- metaensemble/roles/frontend.md +42 -0
- metaensemble/roles/ml-engineer.md +42 -0
- metaensemble/roles/test-engineer.md +42 -0
- metaensemble/schemas/brief.schema.json +80 -0
- metaensemble/schemas/manifest.schema.json +142 -0
- metaensemble/schemas/role.schema.json +84 -0
- metaensemble/skills/metaensemble-protocol/SKILL.md +226 -0
- metaensemble/state/migrations/001_init.sql +72 -0
- metaensemble/state/migrations/002_outcome_extended.sql +86 -0
- metaensemble/state/migrations/003_run_provenance.sql +36 -0
- metaensemble/statusline/me_status.py +187 -0
- metaensemble/tools/__init__.py +7 -0
- metaensemble/tools/executors.py +62 -0
- metaensemble/tools/ledger.py +121 -0
- metaensemble/tools/limits.py +165 -0
- metaensemble/tools/perf.py +150 -0
- metaensemble/tools/standup.py +177 -0
- metaensemble/tools/stats.py +115 -0
- metaensemble-0.2.0.dist-info/METADATA +221 -0
- metaensemble-0.2.0.dist-info/RECORD +85 -0
- metaensemble-0.2.0.dist-info/WHEEL +5 -0
- metaensemble-0.2.0.dist-info/entry_points.txt +2 -0
- metaensemble-0.2.0.dist-info/licenses/LICENSE +21 -0
- 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())
|