metaensemble 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. evals/README.md +147 -0
  2. evals/__init__.py +0 -0
  3. evals/cassettes/README.md +10 -0
  4. evals/cassettes/bootstrap.jsonl +800 -0
  5. evals/configs/default.yaml +59 -0
  6. evals/datasets/__init__.py +0 -0
  7. evals/datasets/suite_a/tasks.yaml +123 -0
  8. evals/datasets/suite_b/items.yaml +90 -0
  9. evals/runners/__init__.py +12 -0
  10. evals/runners/api.py +518 -0
  11. evals/runners/metrics.py +132 -0
  12. metaensemble/__init__.py +13 -0
  13. metaensemble/cli.py +1362 -0
  14. metaensemble/commands/dispatch.md +39 -0
  15. metaensemble/commands/executors.md +12 -0
  16. metaensemble/commands/ledger.md +19 -0
  17. metaensemble/commands/limits.md +12 -0
  18. metaensemble/commands/perf.md +12 -0
  19. metaensemble/commands/relaunch.md +29 -0
  20. metaensemble/commands/standup.md +14 -0
  21. metaensemble/config/budgets.example.yaml +72 -0
  22. metaensemble/config/quality.example.yaml +82 -0
  23. metaensemble/hooks/__init__.py +1 -0
  24. metaensemble/hooks/_common.py +148 -0
  25. metaensemble/hooks/deliverable_sync.py +73 -0
  26. metaensemble/hooks/file_event.py +303 -0
  27. metaensemble/hooks/post_task.py +460 -0
  28. metaensemble/hooks/pre_task.py +548 -0
  29. metaensemble/hooks/session_start.py +212 -0
  30. metaensemble/hooks/session_summary.py +392 -0
  31. metaensemble/hooks/subagent_stop.py +94 -0
  32. metaensemble/lib/__init__.py +1 -0
  33. metaensemble/lib/config.py +414 -0
  34. metaensemble/lib/cost_gate.py +299 -0
  35. metaensemble/lib/dispatch.py +341 -0
  36. metaensemble/lib/doctor.py +1563 -0
  37. metaensemble/lib/file_events.py +395 -0
  38. metaensemble/lib/ids.py +91 -0
  39. metaensemble/lib/installer.py +5018 -0
  40. metaensemble/lib/ledger.py +812 -0
  41. metaensemble/lib/manifest.py +141 -0
  42. metaensemble/lib/native_state.py +463 -0
  43. metaensemble/lib/overlaps.py +155 -0
  44. metaensemble/lib/quality_gate.py +155 -0
  45. metaensemble/lib/quality_runners.py +446 -0
  46. metaensemble/lib/reconcile.py +420 -0
  47. metaensemble/lib/recording.py +422 -0
  48. metaensemble/lib/relaunch.py +174 -0
  49. metaensemble/lib/runtime_payload.py +42 -0
  50. metaensemble/lib/runtime_state.py +308 -0
  51. metaensemble/lib/sidecar.py +166 -0
  52. metaensemble/lib/topology.py +181 -0
  53. metaensemble/lib/transcript.py +432 -0
  54. metaensemble/output-styles/deliverable.md +33 -0
  55. metaensemble/output-styles/wire.md +38 -0
  56. metaensemble/roles/architect.md +52 -0
  57. metaensemble/roles/backend.md +43 -0
  58. metaensemble/roles/code-quality.md +49 -0
  59. metaensemble/roles/data-engineer.md +42 -0
  60. metaensemble/roles/devops.md +42 -0
  61. metaensemble/roles/docs.md +41 -0
  62. metaensemble/roles/frontend.md +42 -0
  63. metaensemble/roles/ml-engineer.md +42 -0
  64. metaensemble/roles/test-engineer.md +42 -0
  65. metaensemble/schemas/brief.schema.json +80 -0
  66. metaensemble/schemas/manifest.schema.json +142 -0
  67. metaensemble/schemas/role.schema.json +84 -0
  68. metaensemble/skills/metaensemble-protocol/SKILL.md +226 -0
  69. metaensemble/state/migrations/001_init.sql +72 -0
  70. metaensemble/state/migrations/002_outcome_extended.sql +86 -0
  71. metaensemble/state/migrations/003_run_provenance.sql +36 -0
  72. metaensemble/statusline/me_status.py +187 -0
  73. metaensemble/tools/__init__.py +7 -0
  74. metaensemble/tools/executors.py +62 -0
  75. metaensemble/tools/ledger.py +121 -0
  76. metaensemble/tools/limits.py +165 -0
  77. metaensemble/tools/perf.py +150 -0
  78. metaensemble/tools/standup.py +177 -0
  79. metaensemble/tools/stats.py +115 -0
  80. metaensemble-0.2.0.dist-info/METADATA +221 -0
  81. metaensemble-0.2.0.dist-info/RECORD +85 -0
  82. metaensemble-0.2.0.dist-info/WHEEL +5 -0
  83. metaensemble-0.2.0.dist-info/entry_points.txt +2 -0
  84. metaensemble-0.2.0.dist-info/licenses/LICENSE +21 -0
  85. metaensemble-0.2.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,39 @@
1
+ ---
2
+ name: dispatch
3
+ description: Plan a Task, compose a Manifest, and spawn one or more Executors. Default is N=1 (Solo). Use --fanout, --consensus, --shadow, or --peer-review for multi-instance patterns.
4
+ ---
5
+
6
+ When the Principal invokes `/dispatch <task description> [flags]`, follow the Coordinator protocol in `metaensemble/skills/metaensemble-protocol/SKILL.md`:
7
+
8
+ 1. **Plan.** Decompose the task description into one or more Tasks. For each Task, pick the Role best suited to execute (the subagent_type) and the model tier the Role declares.
9
+
10
+ 2. **Compose the Manifest** *(when the Task warrants a typed contract)*. Write a YAML Manifest at `.metaensemble/manifests/<manifest_id>.yaml`. The Manifest is mandatory for irreversible Tasks and recommended for any multi-step Task. Include `manifest_id` of the form `hm-<UUIDv7 hex>`, typed `context.files` pointers, explicit `expected_deliverables` with paths and verifiable assertions (`must_export`, `coverage`), and `constraints` for model tier and window budget. When the Task affects shared state, set `peer_review.mandatory_for_reversibility: true`. Validation is automatic: the PreToolUse hook validates the Manifest against `metaensemble/schemas/manifest.schema.json` when you reference it.
11
+
12
+ 3. **Embed markers in the Task prompt.** The PreToolUse hook reads bracket-marker metadata from the prompt and uses it to drive the recording layer and the protocol guard. Markers (all optional):
13
+ - `[manifest: hm-<id>]` — the Coordinator's Manifest for this Task. The hook validates the YAML; if validation fails, the dispatch blocks.
14
+ - `[task: task-<id>]` — explicit Task id. Use to group multiple Executors under one Task (fan-out, consensus, peer review).
15
+ - `[project: /absolute/project/root]` — explicit adopted project root. Include this on every Executor prompt when `/dispatch` is invoked from outside the target project or with `--project <path>`, so hooks write the Run to the intended project's Ledger.
16
+ - `[continuing: <alias>]` — resume a specific Executor by alias (typically used from `/relaunch`).
17
+ - `[fresh]` — force-create a new Executor of this Role rather than reusing the most-recent active one. Required for fan-out, consensus, and the reviewer leg of peer-review dispatches.
18
+ - `[fanout: N]` — declared fanout size when dispatching under `--fanout N`. The hook rejects the dispatch deterministically when `N < 2`; this is the enforceable protocol guard for `--fanout 1` and similar invalid invocations. Required on every Executor prompt in a fan-out batch.
19
+ - `[consensus: N]` — same shape and same guard for `--consensus N`. Required on every Executor prompt in a consensus batch.
20
+
21
+ 4. **Dispatch.** Spawn the Executor(s) via Task invocations, embedding the markers from step 3 in each prompt. The PreToolUse hook runs the cost gate and stamps a pending Run; PostToolUse completes the Run when the Executor returns. The recording layer is automatic — you do not need to write to the Ledger directly.
22
+
23
+ 5. **Respect overlap ownership and report location.** Before composing Briefs, read `<project>/.metaensemble/install-decisions.yaml`. Use its `report_root` for any new MetaEnsemble-authored report Deliverable. For greenfield projects this defaults to `.metaensemble/reports`; existing projects can keep an established convention such as `.claude/reports` when inspection detected one. For every `overlaps.*.action: metaensemble_owned`, do not instruct Executors to maintain the listed project surface. For deliverable/work-record documentation, rely on the Ledger and Deliverables index for structural tracking unless the overlap is `project_owned` or `dual`.
24
+
25
+ 6. **Verify and synthesize without duplicate reports.** After Executors return, confirm declared Deliverables exist at the paths the Manifest declared. Do not write a second Coordinator synthesis file by default; give the synthesis in the Principal-facing response. Write a Coordinator synthesis Markdown file only when the Manifest explicitly declares that synthesis as an expected Deliverable, using `report_root` for greenfield report paths. When multiple Executors ran (fan-out, consensus, peer review), surface dissent explicitly rather than averaging it.
26
+
27
+ ## Multi-instance patterns
28
+
29
+ `--fanout N` — N Executors of one Role with divergent Briefs; explore hypotheses. Generate one shared `task_id` and one Manifest. Spawn N Tasks, each with `[manifest: hm-...] [task: task-...] [fanout: N] [fresh]` plus a divergent prompt body. `N` must be >= 2; the PreToolUse hook rejects `[fanout: 1]` (or any `N < 2`) deterministically before any Executor work begins.
30
+
31
+ `--consensus N` — N Executors of one Role with the same Brief; surface majority and dissent rather than averaging. Same markers as fan-out, identical prompt bodies, with `[consensus: N]` in place of `[fanout: N]`. The same `N >= 2` guard applies.
32
+
33
+ `--shadow tier1,tier2` — two Executors at different model tiers; validate downward tiering. Same markers as fan-out, identical Brief, but each invocation's `subagent_type` may carry tier-specific routing.
34
+
35
+ `--peer-review role1,role2` — one Executor + one or more reviewer Executors of different Roles. Mandatory for irreversible Tasks. Spawn the executor Task with `[manifest: hm-...] [task: task-...]`, then spawn each reviewer Task with `[manifest: hm-...] [task: task-...] [fresh]` and the reviewer's `subagent_type`. The shared `task_id` groups them in the Ledger for later inspection.
36
+
37
+ `--project <path>` — dispatch against an adopted project even when the current Claude session cwd is somewhere else. Resolve `<path>` to an absolute path and include `[project: <absolute-path>]` on every Executor prompt and Manifest path decision. The hooks route pending sidecars, Run rows, file provenance, and quality checks through that project's `.metaensemble/state/`.
38
+
39
+ The full pattern reference is in `ARCHITECTURE.md` §12.
@@ -0,0 +1,12 @@
1
+ ---
2
+ name: executors
3
+ description: List the Executors active in the last 30 days, with their Role, status, and most recent Run.
4
+ ---
5
+
6
+ When the Principal invokes `/executors`, run the runner and relay its output unchanged:
7
+
8
+ ```bash
9
+ "$HOME/.metaensemble/runtime/bin/me-run" executors
10
+ ```
11
+
12
+ The tool reads from the Ledger via `get_active_executors(since=now-30d, limit=200)` and renders a Markdown table. No further interpretation needed; the table is the answer.
@@ -0,0 +1,19 @@
1
+ ---
2
+ name: ledger
3
+ description: Query the Ledger via named subcommands. Subcommands — recent, by-executor, by-task, window. Forbidden — ad-hoc SQL.
4
+ ---
5
+
6
+ When the Principal invokes `/ledger <subcommand> [args]`, run the runner and relay its output unchanged:
7
+
8
+ ```bash
9
+ "$HOME/.metaensemble/runtime/bin/me-run" ledger <subcommand> [args]
10
+ ```
11
+
12
+ Subcommands:
13
+
14
+ - `recent [--limit N]` — most recent Runs across all Executors (default 20)
15
+ - `by-executor <executor_id_or_alias> [--limit N]` — Runs for one Executor; accepts UUID or alias
16
+ - `by-task <task_id> [--limit N]` — Runs for one Task
17
+ - `window <window_id>` — aggregate burn for one 5-hour window bucket
18
+
19
+ Per `PERFORMANCE.md` §3 R1, every subcommand maps to a named query in `metaensemble/lib/ledger.py`. Ad-hoc SQL is forbidden; if the Principal asks a question that no subcommand answers, propose a new named query in `lib/ledger.py` rather than reaching for raw SQL.
@@ -0,0 +1,12 @@
1
+ ---
2
+ name: limits
3
+ description: Show the current 5-hour window's token burn, remaining capacity, and run count.
4
+ ---
5
+
6
+ When the Principal invokes `/limits`, run the runner and relay its output unchanged:
7
+
8
+ ```bash
9
+ "$HOME/.metaensemble/runtime/bin/me-run" limits
10
+ ```
11
+
12
+ The output is Markdown formatted for direct relay. If the Principal asks for a specific historical window, fall back to `/ledger window <window-id>` for arbitrary buckets.
@@ -0,0 +1,12 @@
1
+ ---
2
+ name: perf
3
+ description: Rolling performance metrics over the last 24 hours — Run latency distribution, outcome breakdown, hook error log health.
4
+ ---
5
+
6
+ When the Principal invokes `/perf`, run the runner and relay its output unchanged:
7
+
8
+ ```bash
9
+ "$HOME/.metaensemble/runtime/bin/me-run" perf
10
+ ```
11
+
12
+ This surface lets the Principal see drift against the `PERFORMANCE.md` §3 budgets before it becomes a problem. If the Principal wants to drill into specific hook errors, point them at `.metaensemble/hooks/log.jsonl`.
@@ -0,0 +1,29 @@
1
+ ---
2
+ name: relaunch
3
+ description: Resume a past Executor by alias. Loads its last Brief and Deliverable summary into a fresh Run. Use --full for deep context restore.
4
+ ---
5
+
6
+ When the Principal invokes `/relaunch <alias>` (e.g. `/relaunch arch-7b3`):
7
+
8
+ 1. **Load the relaunch context.** Run the runner and read its output:
9
+
10
+ ```bash
11
+ "$HOME/.metaensemble/runtime/bin/me-run" relaunch <alias>
12
+ ```
13
+
14
+ Add `--full` if the Principal asked for the deep restore. The tool prints a Markdown context block: the Executor's identity, the most recent Brief, and a summary of the most recent Deliverable. If no Executor matches the alias, the tool exits non-zero with an explanatory message; relay it and stop.
15
+
16
+ 2. **Compose the resumption Brief.** Combine the printed context with the new instruction the Principal just gave you. The resumption Brief is the synthesis the next Run will start from.
17
+
18
+ 3. **Dispatch under the same identity.** Spawn the Task and include a continuation marker in the prompt so the recording layer reuses the same Executor row rather than creating a new one:
19
+
20
+ ```
21
+ [continuing: <alias>]
22
+ <the resumption Brief, in plain English>
23
+ ```
24
+
25
+ The PreToolUse hook reads the marker, resolves the existing Executor by alias, and records the new Run under that identity. From this point on, the dispatch proceeds exactly like any other Task.
26
+
27
+ 4. **Synthesize.** After the new Run completes, return the Deliverable to the Principal as you would for any Dispatch.
28
+
29
+ See `ARCHITECTURE.md` §14 for the relaunch contract.
@@ -0,0 +1,14 @@
1
+ ---
2
+ name: standup
3
+ description: Daily-style summary — current window status, last-24h Runs, top token consumers, active Executors over the last 7 days.
4
+ ---
5
+
6
+ When the Principal invokes `/standup`, run the runner and relay its output unchanged:
7
+
8
+ ```bash
9
+ "$HOME/.metaensemble/runtime/bin/me-run" standup
10
+ ```
11
+
12
+ The runner pins one absolute Python interpreter and execs the CLI, so it works regardless of whether the venv is activated in the current shell. It reads from the Ledger via named queries (`get_window_burn`, `get_recent_runs`, `get_active_executors`) and prints a Markdown digest. No further commentary unless the Principal asks for it.
13
+
14
+ If the runner does not exist (`No such file or directory`), MetaEnsemble has not been installed yet; tell the Principal to run `metaensemble user-setup --layout=namespaced` (or `--layout=top-level`).
@@ -0,0 +1,72 @@
1
+ # MetaEnsemble budget configuration — example
2
+ #
3
+ # Copy this file to one of two locations to activate it:
4
+ # - User layer: ~/.metaensemble/budgets.yaml
5
+ # - Project layer: <project>/.metaensemble/budgets.yaml
6
+ #
7
+ # Project values override User values, which override the Core defaults
8
+ # encoded in `metaensemble/lib/config.py`. The defaults shown here match those
9
+ # defaults; you only need to set values that differ from them.
10
+ #
11
+ # See ARCHITECTURE.md §9 (Cost gating) for the design.
12
+ #
13
+ # The cost gate is two-axis:
14
+ # Axis 1 — run size (this dispatch vs the window capacity)
15
+ # Axis 2 — window headroom (how much of the window is left)
16
+ # Either axis can escalate to NOTIFY or BLOCK. Final state is the worst
17
+ # of the two. Irreversibility and novelty are independent hard-blocks
18
+ # on top.
19
+
20
+ thresholds:
21
+ # AXIS 1 — run size, expressed as percentage of WINDOW CAPACITY.
22
+ # "Block any single dispatch that exceeds 40% of my whole window."
23
+ run_soft_pct_of_capacity: 20 # substantive dispatch — NOTIFY at this point
24
+ run_hard_pct_of_capacity: 40 # outsized dispatch — BLOCK for explicit OK
25
+
26
+ # AXIS 2 — window headroom, expressed as percentage of capacity STILL
27
+ # REMAINING. "Warn when only 30% of the window is left" /
28
+ # "Block all dispatches when only 10% is left."
29
+ window_warn_pct_remaining: 30
30
+ window_block_pct_remaining: 10
31
+
32
+ # WINDOW CAPACITY — tokens (input + output) for the user's plan's
33
+ # 5-hour cap.
34
+ #
35
+ # IMPORTANT: the value below is a FALLBACK used only when MetaEnsemble
36
+ # cannot read the runtime's authoritative `rate_limits` field. With
37
+ # the statusline integration installed (which `metaensemble user-setup`
38
+ # registers by default), the cost gate DERIVES capacity from the
39
+ # runtime's own per-session rate-limit feed — Claude Code v2.1.80+
40
+ # exposes the 5-hour window's `used_percentage` to statusline scripts
41
+ # on every refresh, and MetaEnsemble's statusline captures it. The
42
+ # derived capacity tracks plan changes (Pro / Max-5 / Max-20)
43
+ # automatically; the fallback below is only consulted when no fresh
44
+ # native data is available (e.g., before the first session refresh).
45
+ window_capacity_tokens: 88000
46
+
47
+ # Actions matched against the irreversible list always block, regardless
48
+ # of cost, and trigger mandatory peer review per ARCHITECTURE §11.
49
+ # Patterns are matched conservatively; when in doubt, the gate blocks.
50
+ irreversible_actions:
51
+ - "Write to existing files"
52
+ - "Bash matching git push|rm |DROP |DELETE "
53
+ - "any non-localhost network call"
54
+
55
+ # Novelty escalation: the first time the Ledger sees a new Manifest
56
+ # pattern, the gate blocks for Principal review. After the configured
57
+ # number of successful Runs, the pattern de-escalates to notify, then
58
+ # auto. Set `treat_first_run_of_pattern_as_block` to false to disable
59
+ # the novelty escalation entirely.
60
+ novelty:
61
+ treat_first_run_of_pattern_as_block: true
62
+ drop_to_notify_after_n_runs: 2
63
+ drop_to_auto_after_n_runs: 3
64
+
65
+ # Capacity calibration: when true (default), the cost gate prefers the
66
+ # runtime's native `rate_limits` feed over the `window_capacity_tokens`
67
+ # value above. The fallback is used only when no fresh native data is
68
+ # available. Set to false to pin capacity to the manual value (useful
69
+ # for testing or for users who want predictable behavior independent
70
+ # of the runtime's reporting).
71
+ capacity_calibration:
72
+ auto_calibrate_capacity: true
@@ -0,0 +1,82 @@
1
+ # MetaEnsemble quality-gate configuration — example
2
+ #
3
+ # Copy this file to one of two locations to activate it:
4
+ # - User layer: ~/.metaensemble/quality.yaml
5
+ # - Project layer: <project>/.metaensemble/quality.yaml
6
+ #
7
+ # Project values override User values, which override the core defaults
8
+ # encoded in `metaensemble/lib/config.py` (the `_DEFAULT_QUALITY` block). The
9
+ # values shown here match those defaults; you only need to set values
10
+ # you want to differ.
11
+ #
12
+ # See ARCHITECTURE.md §10 (Quality gating) for the design rationale
13
+ # and the industry sources the thresholds anchor on (SonarQube,
14
+ # Snyk, NISTIR 8397, McCabe, DORA).
15
+
16
+ # Axis 1 — Correctness. Runs the project's pytest suite when a dispatch
17
+ # produces Python deliverables and classifies by failure count.
18
+ correctness:
19
+ enabled: true
20
+ notify_failures: 1 # 1+ failing tests → NOTIFY
21
+ block_failures: 3 # 3+ failing tests → BLOCK
22
+
23
+ # Axis 2 — Security. Runs bandit on the changed Python files and
24
+ # classifies by the highest severity finding. Mirrors Snyk's defaults.
25
+ security:
26
+ enabled: true
27
+ notify_severity: medium # one or more medium-severity findings → NOTIFY
28
+ block_severity: high # one or more high or critical findings → BLOCK
29
+
30
+ # Axis 3 — Maintainability. Runs ruff on the changed Python files and
31
+ # classifies by issue count, mapped to SonarQube-style A/B/C/D/E grades
32
+ # (A: 0, B: 1–5, C: 6–15, D: 16–30, E: 30+).
33
+ maintainability:
34
+ enabled: true
35
+ notify_issues: 6 # 6+ ruff issues across changed files → NOTIFY (grade C)
36
+ block_issues: 16 # 16+ → BLOCK (grade D or E)
37
+
38
+ # Axis 4 — Complexity. Runs radon's cyclomatic-complexity check on the
39
+ # changed Python files. Anchors on McCabe's stable 10-and-15 thresholds.
40
+ complexity:
41
+ enabled: true
42
+ notify_above: 10 # any function above McCabe 10 → NOTIFY
43
+ block_above: 15 # any function above McCabe 15 → BLOCK
44
+
45
+ # Axis 5 — Coverage delta. Reads coverage.py's last report from the
46
+ # project's `.coverage` file. The absolute floor mirrors NISTIR 8397's
47
+ # 80% line-coverage recommendation. v0.1 implements the absolute check
48
+ # only; v0.2 will extend with a committed baseline for true delta.
49
+ coverage:
50
+ enabled: true
51
+ notify_drop_pp: 5.0 # 5 percentage-point drop vs baseline → NOTIFY
52
+ block_drop_pp: 5.0 # 5pp drop → BLOCK
53
+ block_absolute_below: 80.0 # coverage below 80% → BLOCK regardless of delta
54
+
55
+ # Axis commands — generalize the gate beyond Python. Each axis can carry
56
+ # a command that runs from the project root whenever a dispatch produces
57
+ # non-Python deliverables, so a JS/TS (or Go, Rust, ...) Deliverable is
58
+ # still checked across the same five axes. Only the exit code is
59
+ # interpreted: exit 0 → AUTO; nonzero → the axis's `state_on_fail`
60
+ # ("notify" by default, or "block"). A command that is not installed or
61
+ # times out skips its axis with a reason — the gate never guesses.
62
+ #
63
+ # Commands run alongside the Python runners: a mixed .py + .ts dispatch
64
+ # records both "correctness" (pytest) and "correctness:cmd" (your
65
+ # command), and the worst state across all axes still decides the gate.
66
+ # Commands are shlex-split and run without a shell — put pipes or `&&`
67
+ # chains in a script and point `cmd` at it.
68
+ #
69
+ # axis_commands:
70
+ # correctness:
71
+ # cmd: "npm test --silent"
72
+ # timeout: 180 # seconds; default 120
73
+ # state_on_fail: block # failing tests should stop the line
74
+ # security:
75
+ # cmd: "npm audit --audit-level=high"
76
+ # maintainability:
77
+ # cmd: "npx eslint ."
78
+ # state_on_fail: notify # the default; shown for completeness
79
+ # complexity:
80
+ # cmd: "npx eslint . --rule '{\"complexity\": [\"error\", 15]}'"
81
+ # coverage:
82
+ # cmd: "npx vitest run --coverage"
@@ -0,0 +1 @@
1
+ """MetaEnsemble lifecycle hooks. See ARCHITECTURE.md §8 and PERFORMANCE.md §3 R7."""
@@ -0,0 +1,148 @@
1
+ """Shared utilities for MetaEnsemble hooks.
2
+
3
+ Per PERFORMANCE.md §3 R7, hooks are fast (<100ms p95), idempotent, and
4
+ free of model calls and network I/O. They share state-directory location
5
+ and error-logging behavior through this module so each hook script stays
6
+ small and single-purpose.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import sys
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+
18
+ # --- State directory resolution --------------------------------------------
19
+
20
+ def state_dir() -> Path:
21
+ """Return the project-level `.metaensemble/state/` directory.
22
+
23
+ Honors the `METAENSEMBLE_STATE_DIR` environment variable for tests and
24
+ custom deployments; otherwise resolves to `<cwd>/.metaensemble/state/`.
25
+ """
26
+ override = os.environ.get("METAENSEMBLE_STATE_DIR")
27
+ if override:
28
+ return Path(override)
29
+ return Path.cwd() / ".metaensemble" / "state"
30
+
31
+
32
+ def project_root_from_payload(payload: dict[str, Any]) -> Path | None:
33
+ """Resolve an explicit `[project: ...]` prompt marker, if present."""
34
+ try:
35
+ from metaensemble.lib.recording import parse_markers
36
+ except Exception:
37
+ return None
38
+ tool_input = payload.get("tool_input")
39
+ prompt = tool_input.get("prompt") if isinstance(tool_input, dict) else None
40
+ markers = parse_markers(prompt if isinstance(prompt, str) else None)
41
+ project_path = markers.get("project_path")
42
+ if not project_path:
43
+ return None
44
+ root = Path(project_path).expanduser()
45
+ if not root.is_absolute():
46
+ root = Path.cwd() / root
47
+ root = root.resolve(strict=False)
48
+ if not (root / ".metaensemble").is_dir():
49
+ return None
50
+ return root
51
+
52
+
53
+ def state_dir_for_payload(payload: dict[str, Any]) -> Path:
54
+ """Return the Ledger state dir for a hook payload.
55
+
56
+ Explicit `[project: ...]` markers take precedence so a dispatch started
57
+ from a different cwd still records into the intended adopted project.
58
+ """
59
+ project_root = project_root_from_payload(payload)
60
+ if project_root is not None:
61
+ return project_root / ".metaensemble" / "state"
62
+ return state_dir()
63
+
64
+
65
+ def db_path() -> Path:
66
+ return state_dir() / "department.db"
67
+
68
+
69
+ def db_path_for_state(run_state_dir: Path) -> Path:
70
+ return Path(run_state_dir) / "department.db"
71
+
72
+
73
+ def jsonl_path() -> Path:
74
+ return state_dir() / "runs.jsonl"
75
+
76
+
77
+ def jsonl_path_for_state(run_state_dir: Path) -> Path:
78
+ return Path(run_state_dir) / "runs.jsonl"
79
+
80
+
81
+ def hooks_log_path() -> Path:
82
+ return state_dir().parent / "hooks" / "log.jsonl"
83
+
84
+
85
+ def migration_sql() -> str:
86
+ """Return the canonical migration script text. Idempotent to apply."""
87
+ here = Path(__file__).resolve().parent.parent
88
+ return (here / "state" / "migrations" / "001_init.sql").read_text()
89
+
90
+
91
+ # --- Stdin / stdout JSON contract -----------------------------------------
92
+
93
+ def read_input() -> dict[str, Any]:
94
+ """Read hook input from stdin as JSON. Returns {} if stdin is empty."""
95
+ raw = sys.stdin.read().strip()
96
+ if not raw:
97
+ return {}
98
+ try:
99
+ return json.loads(raw)
100
+ except json.JSONDecodeError as exc:
101
+ log_error("invalid-stdin-json", str(exc), {"raw": raw[:500]})
102
+ return {}
103
+
104
+
105
+ def emit(payload: dict[str, Any]) -> None:
106
+ """Write a JSON object to stdout. The agent runtime parses it as the hook's response."""
107
+ sys.stdout.write(json.dumps(payload))
108
+ sys.stdout.flush()
109
+
110
+
111
+ # --- Error logging --------------------------------------------------------
112
+
113
+ def log_error(kind: str, message: str, context: dict[str, Any] | None = None) -> None:
114
+ """Append a structured error record to `.metaensemble/hooks/log.jsonl`.
115
+
116
+ Hooks never block on logging failures — if the log write itself fails,
117
+ we silently swallow it, because a stuck hook is worse than a missed
118
+ log entry (PERFORMANCE.md §3 R7).
119
+ """
120
+ try:
121
+ log_file = hooks_log_path()
122
+ log_file.parent.mkdir(parents=True, exist_ok=True)
123
+ record = {
124
+ "ts": datetime.now(timezone.utc).isoformat(),
125
+ "kind": kind,
126
+ "message": message,
127
+ "context": context or {},
128
+ }
129
+ with log_file.open("a") as f:
130
+ f.write(json.dumps(record) + "\n")
131
+ except Exception: # nosec B110
132
+ # Hooks must remain non-blocking when the diagnostic sink itself
133
+ # fails; raising here would turn a missed log line into a user-visible
134
+ # hook failure.
135
+ pass
136
+
137
+
138
+ # --- Window-id derivation -------------------------------------------------
139
+
140
+ def current_window_id(at: datetime | None = None) -> str:
141
+ """Return the 5-hour window bucket for the given moment.
142
+
143
+ Buckets align to 5-hour blocks starting at 00:00 UTC each day.
144
+ Format: `YYYY-MM-DDTHH` where HH is the start of the bucket.
145
+ """
146
+ now = at or datetime.now(timezone.utc)
147
+ bucket_start = (now.hour // 5) * 5
148
+ return f"{now.year:04d}-{now.month:02d}-{now.day:02d}T{bucket_start:02d}"
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env python3
2
+ """PostToolUse hook for Write invocations that produce Deliverables.
3
+
4
+ When a Write call lands a markdown file under a `reports/` directory
5
+ (for greenfield MetaEnsemble projects, normally `.metaensemble/reports/`),
6
+ this hook treats the file as a Deliverable and records the path so the
7
+ Registry view can surface it. The hook is
8
+ deliberately non-blocking; if the write turns out not to be a Deliverable
9
+ or the recording fails, the hook logs and exits 0.
10
+
11
+ Stdin shape:
12
+ {
13
+ "tool_name": "Write",
14
+ "tool_input": { "file_path": "..." },
15
+ "tool_output": { ... }
16
+ }
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
24
+
25
+ from metaensemble.hooks._common import ( # noqa: E402
26
+ emit,
27
+ log_error,
28
+ read_input,
29
+ state_dir,
30
+ )
31
+
32
+
33
+ DELIVERABLE_INDEX_PATH = "deliverables_index.jsonl"
34
+
35
+
36
+ def _is_deliverable(file_path: str) -> bool:
37
+ """A Write counts as a Deliverable if its path lies under reports/ and is markdown."""
38
+ p = Path(file_path)
39
+ if p.suffix.lower() != ".md":
40
+ return False
41
+ return "reports" in p.parts
42
+
43
+
44
+ def run() -> int:
45
+ payload = read_input()
46
+ if payload.get("tool_name") != "Write":
47
+ emit({"continue": True})
48
+ return 0
49
+
50
+ tool_input = payload.get("tool_input") or {}
51
+ file_path = tool_input.get("file_path")
52
+ if not file_path or not _is_deliverable(file_path):
53
+ emit({"continue": True})
54
+ return 0
55
+
56
+ try:
57
+ index_path = state_dir() / DELIVERABLE_INDEX_PATH
58
+ index_path.parent.mkdir(parents=True, exist_ok=True)
59
+ # Append the path; the Registry view de-duplicates on read.
60
+ import json
61
+ from datetime import datetime, timezone
62
+ record = {"ts": datetime.now(timezone.utc).isoformat(), "path": file_path}
63
+ with index_path.open("a") as f:
64
+ f.write(json.dumps(record) + "\n")
65
+ except Exception as exc:
66
+ log_error("deliverable-sync-failed", str(exc), {"file_path": file_path})
67
+
68
+ emit({"continue": True})
69
+ return 0
70
+
71
+
72
+ if __name__ == "__main__":
73
+ sys.exit(run())