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
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse hook for Task invocations.
|
|
3
|
+
|
|
4
|
+
The PreToolUse hook is the gatekeeper for every Task tool invocation. Its
|
|
5
|
+
responsibilities (per ARCHITECTURE.md §8):
|
|
6
|
+
|
|
7
|
+
1. Derive identity. From the agent-runtime payload (`tool_input`,
|
|
8
|
+
`session_id`, `cwd`), determine which Role is being dispatched, which
|
|
9
|
+
Executor owns the work, and which Task it belongs to.
|
|
10
|
+
2. Materialize the rows that downstream queries depend on (`roles`,
|
|
11
|
+
`executors`, `tasks`), all idempotent.
|
|
12
|
+
3. Validate the Manifest if one is referenced via the `[manifest: hm-...]`
|
|
13
|
+
prompt marker.
|
|
14
|
+
4. Estimate token cost from the prompt text and run the cost gate.
|
|
15
|
+
5. Stamp a pending-Run sidecar on disk so PostToolUse can complete the
|
|
16
|
+
record.
|
|
17
|
+
6. Either allow the Task or block it through the runtime's native
|
|
18
|
+
permission surface (the runtime ignores stdout JSON on non-zero
|
|
19
|
+
exits, so blocking is a JSON decision, not an exit code).
|
|
20
|
+
|
|
21
|
+
Hook contract:
|
|
22
|
+
- Stdin: agent runtime payload
|
|
23
|
+
- Stdout: JSON. Allow: `continue: true` (NOTIFY adds `systemMessage`).
|
|
24
|
+
Block: the native PreToolUse permission decision
|
|
25
|
+
(`hookSpecificOutput.permissionDecision: "deny"` with
|
|
26
|
+
`permissionDecisionReason`) plus the legacy `decision: "block"` /
|
|
27
|
+
`reason` pair for older runtimes.
|
|
28
|
+
- Exit: 0 on every decided path; non-zero only on unexpected crash
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import sys
|
|
33
|
+
from datetime import datetime, timezone
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
|
|
37
|
+
|
|
38
|
+
from metaensemble.hooks._common import ( # noqa: E402
|
|
39
|
+
current_window_id,
|
|
40
|
+
db_path_for_state,
|
|
41
|
+
emit,
|
|
42
|
+
jsonl_path_for_state,
|
|
43
|
+
log_error,
|
|
44
|
+
migration_sql,
|
|
45
|
+
project_root_from_payload,
|
|
46
|
+
read_input,
|
|
47
|
+
state_dir_for_payload,
|
|
48
|
+
)
|
|
49
|
+
from metaensemble.lib.config import effective_capacity_tokens, load_budget_config # noqa: E402
|
|
50
|
+
from metaensemble.lib.cost_gate import ( # noqa: E402
|
|
51
|
+
CostGateDecision,
|
|
52
|
+
GateState,
|
|
53
|
+
evaluate,
|
|
54
|
+
is_action_irreversible,
|
|
55
|
+
)
|
|
56
|
+
from metaensemble.lib.file_events import ActiveDispatch, write_active_dispatch # noqa: E402
|
|
57
|
+
from metaensemble.lib.ids import uuid7 # noqa: E402
|
|
58
|
+
from metaensemble.lib.ledger import Ledger # noqa: E402
|
|
59
|
+
from metaensemble.lib.manifest import load_manifest # noqa: E402
|
|
60
|
+
from metaensemble.lib.recording import ( # noqa: E402
|
|
61
|
+
ensure_executor,
|
|
62
|
+
ensure_role,
|
|
63
|
+
ensure_task,
|
|
64
|
+
estimate_tokens,
|
|
65
|
+
manifest_path_for,
|
|
66
|
+
parse_markers,
|
|
67
|
+
)
|
|
68
|
+
from metaensemble.lib.sidecar import PendingRun, write_pending # noqa: E402
|
|
69
|
+
from metaensemble.lib.transcript import prompt_fingerprint # noqa: E402
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _explain_manifest_failure(exc: Exception, manifest_id: str, path: Path) -> str:
|
|
73
|
+
"""Render a Coordinator-actionable error message from a validation failure.
|
|
74
|
+
|
|
75
|
+
YAML parser/scanner errors carry a `problem_mark` with a line and column.
|
|
76
|
+
JSON-Schema validation errors carry a `path` and a `message`. We surface
|
|
77
|
+
whichever the underlying exception provides so the Coordinator can fix
|
|
78
|
+
the specific offending line rather than guessing at the source of failure.
|
|
79
|
+
"""
|
|
80
|
+
base = f"Manifest `{manifest_id}` failed validation at `{path}`."
|
|
81
|
+
|
|
82
|
+
# YAML parser/scanner errors.
|
|
83
|
+
mark = getattr(exc, "problem_mark", None)
|
|
84
|
+
problem = getattr(exc, "problem", None)
|
|
85
|
+
if mark is not None and problem:
|
|
86
|
+
line = getattr(mark, "line", None)
|
|
87
|
+
column = getattr(mark, "column", None)
|
|
88
|
+
loc = (
|
|
89
|
+
f" at line {line + 1}, column {column + 1}"
|
|
90
|
+
if line is not None and column is not None
|
|
91
|
+
else ""
|
|
92
|
+
)
|
|
93
|
+
return (
|
|
94
|
+
f"{base}\n"
|
|
95
|
+
f" YAML parser: {problem}{loc}.\n"
|
|
96
|
+
" Common cause: a string containing `:`, `→`, `#`, or a quote was "
|
|
97
|
+
"left unquoted. Wrap the value in double quotes and retry."
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# jsonschema.ValidationError carries `.message` and `.absolute_path`.
|
|
101
|
+
message = getattr(exc, "message", None)
|
|
102
|
+
abs_path = getattr(exc, "absolute_path", None)
|
|
103
|
+
if message is not None:
|
|
104
|
+
field_loc = ""
|
|
105
|
+
if abs_path is not None:
|
|
106
|
+
try:
|
|
107
|
+
parts = list(abs_path)
|
|
108
|
+
if parts:
|
|
109
|
+
field_loc = f" at field `{'.'.join(str(p) for p in parts)}`"
|
|
110
|
+
except TypeError:
|
|
111
|
+
pass
|
|
112
|
+
return (
|
|
113
|
+
f"{base}\n"
|
|
114
|
+
f" Schema: {message}{field_loc}.\n"
|
|
115
|
+
" See the Manifest authoring rules in metaensemble-protocol/SKILL.md. "
|
|
116
|
+
"Non-contract fields belong under `extras` or in the Deliverable, not "
|
|
117
|
+
"as top-level Manifest properties."
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
return f"{base}\n {type(exc).__name__}: {exc}"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _remaining_window(
|
|
124
|
+
ledger: Ledger, window_id: str, capacity_tokens: int,
|
|
125
|
+
) -> tuple[int, int, dict]:
|
|
126
|
+
"""Compute remaining window tokens from BOTH the Ledger AND the runtime.
|
|
127
|
+
|
|
128
|
+
Returns (remaining_tokens, used_tokens, breakdown). The breakdown
|
|
129
|
+
surfaces where the burn came from — useful for the standup digest
|
|
130
|
+
and for the doctor's drift check.
|
|
131
|
+
|
|
132
|
+
Truth comes from the runtime's session jsonls (cross-session,
|
|
133
|
+
captures the main agent's reads/edits and other sessions in the
|
|
134
|
+
same 5-hour bucket). The Ledger is consulted as a fallback when the
|
|
135
|
+
runtime data is unavailable, and as a comparator the doctor uses to
|
|
136
|
+
detect drift.
|
|
137
|
+
"""
|
|
138
|
+
# Local import keeps the hook startup fast when runtime_state isn't
|
|
139
|
+
# needed (e.g., for non-Task tool invocations that short-circuit).
|
|
140
|
+
from metaensemble.lib.runtime_state import get_window_burn as _runtime_burn
|
|
141
|
+
|
|
142
|
+
ledger_burn = ledger.get_window_burn(window_id)
|
|
143
|
+
ledger_used = ledger_burn.total_tokens_in + ledger_burn.total_tokens_out
|
|
144
|
+
|
|
145
|
+
runtime_burn = _runtime_burn(window_id=window_id)
|
|
146
|
+
runtime_used = runtime_burn.input_tokens + runtime_burn.output_tokens
|
|
147
|
+
|
|
148
|
+
# When runtime data exists, it is authoritative. Otherwise fall back
|
|
149
|
+
# to Ledger-only.
|
|
150
|
+
used = max(runtime_used, ledger_used)
|
|
151
|
+
remaining = max(0, capacity_tokens - used)
|
|
152
|
+
breakdown = {
|
|
153
|
+
"ledger_used": ledger_used,
|
|
154
|
+
"runtime_used": runtime_used,
|
|
155
|
+
"runtime_cache_read": runtime_burn.cache_read_tokens,
|
|
156
|
+
"runtime_cache_create": runtime_burn.cache_creation_tokens,
|
|
157
|
+
"runtime_message_count": runtime_burn.message_count,
|
|
158
|
+
}
|
|
159
|
+
return remaining, used, breakdown
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# The same four structured options apply on both NOTIFY and BLOCK so the
|
|
163
|
+
# Principal never has to invent the right intervention under time pressure.
|
|
164
|
+
# What differs between the two states is the *default* action: NOTIFY
|
|
165
|
+
# proceeds in a moment unless intercepted, BLOCK pauses outright until the
|
|
166
|
+
# Principal chooses.
|
|
167
|
+
_DECISION_OPTIONS: tuple[dict, ...] = (
|
|
168
|
+
{"id": 1, "label": "Approve and proceed at current tier"},
|
|
169
|
+
{"id": 2, "label": "Drop the model tier and retry (haiku/sonnet)"},
|
|
170
|
+
{"id": 3, "label": "Split the Task into smaller Manifests"},
|
|
171
|
+
{"id": 4, "label": "Send the Manifest back for revision"},
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _block_payload(stop_reason: str) -> dict:
|
|
176
|
+
"""Build the stdout payload for a blocked dispatch.
|
|
177
|
+
|
|
178
|
+
The runtime only parses stdout JSON on exit 0, so blocking rides the
|
|
179
|
+
JSON decision contract rather than the exit code: the native
|
|
180
|
+
PreToolUse permission decision under `hookSpecificOutput` for
|
|
181
|
+
runtimes that consume the permission surface (a proper denial
|
|
182
|
+
instead of a generic hook error), plus the legacy top-level
|
|
183
|
+
`decision`/`reason` pair for runtimes that predate it. The reason
|
|
184
|
+
text is identical on both channels. `continue` is deliberately
|
|
185
|
+
absent — `continue: false` halts the whole turn, while a denied
|
|
186
|
+
dispatch must leave the Coordinator free to present the structured
|
|
187
|
+
options to the Principal.
|
|
188
|
+
"""
|
|
189
|
+
return {
|
|
190
|
+
"decision": "block",
|
|
191
|
+
"reason": stop_reason,
|
|
192
|
+
"hookSpecificOutput": {
|
|
193
|
+
"hookEventName": "PreToolUse",
|
|
194
|
+
"permissionDecision": "deny",
|
|
195
|
+
"permissionDecisionReason": stop_reason,
|
|
196
|
+
},
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _format_decision_surface(
|
|
201
|
+
decision: CostGateDecision, est_tokens: int, *, blocked: bool
|
|
202
|
+
) -> str:
|
|
203
|
+
"""Render the Principal-facing decision surface for NOTIFY or BLOCK.
|
|
204
|
+
|
|
205
|
+
The diagnosis and option set are the same in both states. The
|
|
206
|
+
header marks block vs notify so the Coordinator can route the
|
|
207
|
+
message correctly, and the closing line states the default action
|
|
208
|
+
so the Principal knows what happens if they take no action.
|
|
209
|
+
"""
|
|
210
|
+
header = (
|
|
211
|
+
"## MetaEnsemble cost gate — block"
|
|
212
|
+
if blocked
|
|
213
|
+
else "## MetaEnsemble cost gate — notify"
|
|
214
|
+
)
|
|
215
|
+
default_line = (
|
|
216
|
+
"Default: paused. Choose an option to proceed."
|
|
217
|
+
if blocked
|
|
218
|
+
else "Default: proceed in a moment. Choose an option to intercept."
|
|
219
|
+
)
|
|
220
|
+
lines = [
|
|
221
|
+
header,
|
|
222
|
+
f"Reason: {decision.reason}",
|
|
223
|
+
f"Estimated tokens: {est_tokens} ({decision.estimated_pct_of_window:.1f}% of window capacity)",
|
|
224
|
+
]
|
|
225
|
+
# When the BLOCK fired because the Manifest pattern is novel, the
|
|
226
|
+
# Principal needs to know that approving is normal first-occurrence
|
|
227
|
+
# behavior, not an override of a real safety concern. The hint also
|
|
228
|
+
# names the de-escalation path so the gate stops feeling adversarial
|
|
229
|
+
# on the second and third dispatches of the same pattern.
|
|
230
|
+
if "novel" in (decision.reason or "").lower():
|
|
231
|
+
lines.extend([
|
|
232
|
+
"",
|
|
233
|
+
"Hint: first occurrence of this Manifest pattern. Pick option 1 to "
|
|
234
|
+
"approve. The gate de-escalates to NOTIFY after 2 successful runs "
|
|
235
|
+
"of the pattern, and to AUTO after 3.",
|
|
236
|
+
])
|
|
237
|
+
lines.extend([
|
|
238
|
+
"",
|
|
239
|
+
"Options:",
|
|
240
|
+
])
|
|
241
|
+
for opt in _DECISION_OPTIONS:
|
|
242
|
+
lines.append(f" {opt['id']}. {opt['label']}")
|
|
243
|
+
lines.extend(["", default_line])
|
|
244
|
+
return "\n".join(lines)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _persist_decision_sentinel(
|
|
248
|
+
session_id: str,
|
|
249
|
+
task_id: str | None,
|
|
250
|
+
manifest_id: str | None,
|
|
251
|
+
decision: CostGateDecision,
|
|
252
|
+
est_tokens: int,
|
|
253
|
+
run_state_dir: Path,
|
|
254
|
+
*,
|
|
255
|
+
blocked: bool,
|
|
256
|
+
) -> None:
|
|
257
|
+
"""Write the structured decision surface to a sentinel file.
|
|
258
|
+
|
|
259
|
+
The agent runtime renders a hook's exit-2 return as a generic
|
|
260
|
+
"hook error" without surfacing the hook's stdout to the dispatching
|
|
261
|
+
agent, and even for NOTIFY the Coordinator may want machine-readable
|
|
262
|
+
options to surface to the Principal in a richer form than a single
|
|
263
|
+
`systemMessage` line. Writing to `<state>/blocks/` or
|
|
264
|
+
`<state>/notifies/` gives the Coordinator a stable place to query
|
|
265
|
+
after a gated dispatch.
|
|
266
|
+
"""
|
|
267
|
+
import json as _json
|
|
268
|
+
bucket = "blocks" if blocked else "notifies"
|
|
269
|
+
out_dir = run_state_dir / bucket
|
|
270
|
+
try:
|
|
271
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
272
|
+
record = {
|
|
273
|
+
"ts": datetime.now(timezone.utc).isoformat(),
|
|
274
|
+
"session_id": session_id,
|
|
275
|
+
"task_id": task_id,
|
|
276
|
+
"manifest_id": manifest_id,
|
|
277
|
+
"state": "block" if blocked else "notify",
|
|
278
|
+
"reason": decision.reason,
|
|
279
|
+
"estimated_tokens": est_tokens,
|
|
280
|
+
"estimated_pct_of_window": decision.estimated_pct_of_window,
|
|
281
|
+
"options": list(_DECISION_OPTIONS),
|
|
282
|
+
"default": "paused" if blocked else "proceed",
|
|
283
|
+
}
|
|
284
|
+
sentinel = out_dir / f"{session_id}-{datetime.now(timezone.utc).strftime('%H%M%S%f')}.json"
|
|
285
|
+
sentinel.write_text(_json.dumps(record, indent=2))
|
|
286
|
+
except Exception: # nosec B110
|
|
287
|
+
# Never block the gate on the sentinel write.
|
|
288
|
+
pass
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _resolve_manifest(
|
|
292
|
+
ledger: Ledger, manifest_id: str | None, session_id: str, run_state_dir: Path
|
|
293
|
+
) -> tuple[str | None, dict | None, str, int | None, int | None]:
|
|
294
|
+
"""Resolve and validate the Manifest referenced by the dispatch prompt.
|
|
295
|
+
|
|
296
|
+
Returns `(manifest_path, manifest_data, task_type, budget_from_manifest,
|
|
297
|
+
early_exit_code)`. `early_exit_code` is non-None when the caller should
|
|
298
|
+
return immediately — the block payload (native permission denial plus
|
|
299
|
+
the legacy decision/reason pair) has already been emitted here.
|
|
300
|
+
"""
|
|
301
|
+
if not manifest_id:
|
|
302
|
+
return None, None, "task", None, None
|
|
303
|
+
|
|
304
|
+
resolved = manifest_path_for(run_state_dir, manifest_id)
|
|
305
|
+
if resolved is None:
|
|
306
|
+
log_error(
|
|
307
|
+
"manifest-not-found",
|
|
308
|
+
f"prompt referenced manifest_id {manifest_id} but no file matched",
|
|
309
|
+
{"manifest_id": manifest_id, "session_id": session_id},
|
|
310
|
+
)
|
|
311
|
+
return None, None, "task", None, None
|
|
312
|
+
|
|
313
|
+
try:
|
|
314
|
+
manifest_data = load_manifest(resolved)
|
|
315
|
+
except Exception as exc:
|
|
316
|
+
log_error(
|
|
317
|
+
"manifest-validation-failed",
|
|
318
|
+
str(exc),
|
|
319
|
+
{"manifest_id": manifest_id, "path": str(resolved)},
|
|
320
|
+
)
|
|
321
|
+
reason = _explain_manifest_failure(exc, manifest_id, resolved)
|
|
322
|
+
emit(_block_payload(reason))
|
|
323
|
+
return None, None, "task", None, 0
|
|
324
|
+
|
|
325
|
+
task_type = manifest_data.get("task", "task")
|
|
326
|
+
constraints = manifest_data.get("constraints") or {}
|
|
327
|
+
budget_value = constraints.get("window_budget")
|
|
328
|
+
budget_from_manifest = (
|
|
329
|
+
budget_value if isinstance(budget_value, int) and budget_value > 0 else None
|
|
330
|
+
)
|
|
331
|
+
return str(resolved), manifest_data, task_type, budget_from_manifest, None
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _count_pattern_runs(ledger: Ledger, role_id: str, task_type: str) -> int:
|
|
335
|
+
"""Count Runs that match this (role_id, task_type) pattern.
|
|
336
|
+
|
|
337
|
+
Used by the novelty check. A pattern is novel when the count is below
|
|
338
|
+
the configured drop-to-auto threshold. Indexed read on
|
|
339
|
+
`idx_runs_executor` (covers role lookup via Executor) and the tasks
|
|
340
|
+
join is bounded by the limit.
|
|
341
|
+
"""
|
|
342
|
+
return ledger.count_pattern_runs(role_id, task_type)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _emit_decision_result(
|
|
346
|
+
decision: CostGateDecision,
|
|
347
|
+
session_id: str,
|
|
348
|
+
task_id: str | None,
|
|
349
|
+
manifest_id: str | None,
|
|
350
|
+
estimated_tokens: int,
|
|
351
|
+
run_state_dir: Path,
|
|
352
|
+
) -> int:
|
|
353
|
+
"""Emit the hook's stdout payload for the cost-gate decision and return
|
|
354
|
+
the exit code. AUTO proceeds silently; NOTIFY proceeds with options
|
|
355
|
+
surfaced; BLOCK pauses with the same options surfaced."""
|
|
356
|
+
if decision.state == GateState.AUTO:
|
|
357
|
+
emit({"continue": True})
|
|
358
|
+
return 0
|
|
359
|
+
blocked = decision.state == GateState.BLOCK
|
|
360
|
+
_persist_decision_sentinel(
|
|
361
|
+
session_id, task_id, manifest_id, decision, estimated_tokens,
|
|
362
|
+
run_state_dir,
|
|
363
|
+
blocked=blocked,
|
|
364
|
+
)
|
|
365
|
+
surface = _format_decision_surface(decision, estimated_tokens, blocked=blocked)
|
|
366
|
+
if blocked:
|
|
367
|
+
emit(_block_payload(surface))
|
|
368
|
+
return 0
|
|
369
|
+
emit({"continue": True, "systemMessage": surface})
|
|
370
|
+
return 0
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def run() -> int:
|
|
374
|
+
payload = read_input()
|
|
375
|
+
tool_name = payload.get("tool_name", "")
|
|
376
|
+
tool_input = payload.get("tool_input", {}) or {}
|
|
377
|
+
|
|
378
|
+
# Only gate subagent dispatches. The runtime has been called this tool
|
|
379
|
+
# both `Task` (older) and `Agent` (current); accept either name. Every
|
|
380
|
+
# other tool passes through cleanly.
|
|
381
|
+
if tool_name not in ("Task", "Agent"):
|
|
382
|
+
emit({"continue": True})
|
|
383
|
+
return 0
|
|
384
|
+
|
|
385
|
+
session_id = payload.get("session_id") or "unknown-session"
|
|
386
|
+
|
|
387
|
+
# Identity derivation.
|
|
388
|
+
subagent_type = tool_input.get("subagent_type") or "general-purpose"
|
|
389
|
+
prompt = tool_input.get("prompt") or ""
|
|
390
|
+
|
|
391
|
+
markers = parse_markers(prompt)
|
|
392
|
+
run_state_dir = state_dir_for_payload(payload)
|
|
393
|
+
project_root = (
|
|
394
|
+
project_root_from_payload(payload) or Path.cwd().resolve(strict=False)
|
|
395
|
+
)
|
|
396
|
+
manifest_id = markers.get("manifest_id")
|
|
397
|
+
continuing_alias = markers.get("continuing_alias")
|
|
398
|
+
explicit_task_id = markers.get("task_id")
|
|
399
|
+
|
|
400
|
+
# Protocol guard: multi-instance patterns (`--fanout N`, `--consensus N`)
|
|
401
|
+
# are only meaningful for N >= 2. The Coordinator surfaces the requested
|
|
402
|
+
# N via `[fanout: N]` / `[consensus: N]` markers in each Executor's
|
|
403
|
+
# prompt; we reject the dispatch deterministically before any model work
|
|
404
|
+
# happens. This is the enforceable line — slash-command files are read
|
|
405
|
+
# by a model and cannot themselves prevent execution.
|
|
406
|
+
for marker_name in ("fanout", "consensus"):
|
|
407
|
+
raw = markers.get(marker_name)
|
|
408
|
+
if raw is None:
|
|
409
|
+
continue
|
|
410
|
+
try:
|
|
411
|
+
n = int(raw)
|
|
412
|
+
except ValueError:
|
|
413
|
+
n = -1
|
|
414
|
+
if n < 2:
|
|
415
|
+
stop_reason = (
|
|
416
|
+
f"MetaEnsemble protocol: --{marker_name} requires N >= 2 "
|
|
417
|
+
f"(got {raw}). The dispatch is rejected before any Executor "
|
|
418
|
+
"work happens."
|
|
419
|
+
)
|
|
420
|
+
emit(_block_payload(stop_reason))
|
|
421
|
+
return 0
|
|
422
|
+
|
|
423
|
+
try:
|
|
424
|
+
ledger = Ledger(
|
|
425
|
+
db_path=db_path_for_state(run_state_dir),
|
|
426
|
+
jsonl_path=jsonl_path_for_state(run_state_dir),
|
|
427
|
+
)
|
|
428
|
+
ledger.initialize(migration_sql())
|
|
429
|
+
|
|
430
|
+
manifest_path, manifest_data, task_type, budget_from_manifest, early = (
|
|
431
|
+
_resolve_manifest(ledger, manifest_id, session_id, run_state_dir)
|
|
432
|
+
)
|
|
433
|
+
if early is not None:
|
|
434
|
+
return early
|
|
435
|
+
|
|
436
|
+
# Role + Executor materialization.
|
|
437
|
+
ensure_role(ledger, subagent_type)
|
|
438
|
+
executor_result = ensure_executor(
|
|
439
|
+
ledger,
|
|
440
|
+
role_id=subagent_type,
|
|
441
|
+
continuing_alias=continuing_alias,
|
|
442
|
+
force_fresh=bool(markers.get("fresh")),
|
|
443
|
+
)
|
|
444
|
+
executor = executor_result.executor
|
|
445
|
+
|
|
446
|
+
# Task identity (the row itself is materialized below, only when the
|
|
447
|
+
# cost gate does not BLOCK — a blocked dispatch never ran and should
|
|
448
|
+
# not leave an orphan `in_progress` row in the Ledger).
|
|
449
|
+
task_id = explicit_task_id or f"task-{uuid7().hex[:12]}"
|
|
450
|
+
|
|
451
|
+
# Cost gate.
|
|
452
|
+
estimated_tokens_in = (
|
|
453
|
+
budget_from_manifest
|
|
454
|
+
if budget_from_manifest is not None
|
|
455
|
+
else estimate_tokens(prompt)
|
|
456
|
+
)
|
|
457
|
+
config = load_budget_config()
|
|
458
|
+
window_id = current_window_id()
|
|
459
|
+
# Auto-calibrated capacity from observed historical peak. The
|
|
460
|
+
# manual `window_capacity_tokens` is the floor; the runtime peak
|
|
461
|
+
# raises the ceiling for users on larger plans.
|
|
462
|
+
capacity = effective_capacity_tokens(config)
|
|
463
|
+
remaining, used, _breakdown = _remaining_window(ledger, window_id, capacity)
|
|
464
|
+
irreversible = is_action_irreversible(tool_name, tool_input, config.irreversible_actions)
|
|
465
|
+
|
|
466
|
+
# Novelty applies only when the Coordinator referenced a Manifest. A
|
|
467
|
+
# Manifest signature is what "pattern" means in the cost-gate sense;
|
|
468
|
+
# auto-discovered Tasks without a Manifest skip the novelty check so
|
|
469
|
+
# ordinary Claude Code subagent dispatches don't trip the block.
|
|
470
|
+
if manifest_data is not None:
|
|
471
|
+
pattern_runs = _count_pattern_runs(ledger, subagent_type, task_type)
|
|
472
|
+
is_novel = (
|
|
473
|
+
config.novelty_block_first_run
|
|
474
|
+
and pattern_runs < config.novelty_drop_to_auto_after
|
|
475
|
+
)
|
|
476
|
+
else:
|
|
477
|
+
is_novel = False
|
|
478
|
+
|
|
479
|
+
decision = evaluate(
|
|
480
|
+
estimated_tokens=estimated_tokens_in,
|
|
481
|
+
remaining_window_tokens=remaining,
|
|
482
|
+
is_irreversible=irreversible,
|
|
483
|
+
is_novel_pattern=is_novel,
|
|
484
|
+
config=config,
|
|
485
|
+
window_capacity_tokens=capacity,
|
|
486
|
+
used_window_tokens=used,
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
# Sidecar written only when proceeding (auto or notify). If the gate
|
|
490
|
+
# blocks, PostToolUse never fires, so there is nothing to complete.
|
|
491
|
+
pending = PendingRun(
|
|
492
|
+
run_id=str(uuid7()),
|
|
493
|
+
session_id=session_id,
|
|
494
|
+
executor_id=executor.executor_id,
|
|
495
|
+
task_id=task_id,
|
|
496
|
+
role_id=subagent_type,
|
|
497
|
+
model_tier=(
|
|
498
|
+
(manifest_data or {}).get("constraints", {}).get("model_tier", "sonnet")
|
|
499
|
+
if manifest_data
|
|
500
|
+
else "sonnet"
|
|
501
|
+
),
|
|
502
|
+
started_ts=datetime.now(timezone.utc).isoformat(),
|
|
503
|
+
window_id=window_id,
|
|
504
|
+
estimated_tokens_in=estimated_tokens_in,
|
|
505
|
+
manifest_id=manifest_id,
|
|
506
|
+
manifest_path=manifest_path,
|
|
507
|
+
extra={
|
|
508
|
+
"transcript_path": payload.get("transcript_path"),
|
|
509
|
+
"tool_prompt_sha256": prompt_fingerprint(prompt),
|
|
510
|
+
# Links this stamp to the PostToolUse(Agent) payload that
|
|
511
|
+
# first exposes the runtime agentId correlation key.
|
|
512
|
+
"tool_use_id": payload.get("tool_use_id"),
|
|
513
|
+
},
|
|
514
|
+
)
|
|
515
|
+
if decision.state != GateState.BLOCK:
|
|
516
|
+
# Materialize the task row now that the dispatch is proceeding.
|
|
517
|
+
ensure_task(ledger, task_id, task_type, manifest_path)
|
|
518
|
+
write_pending(run_state_dir, pending)
|
|
519
|
+
try:
|
|
520
|
+
write_active_dispatch(
|
|
521
|
+
ActiveDispatch(
|
|
522
|
+
session_id=session_id,
|
|
523
|
+
run_id=pending.run_id,
|
|
524
|
+
project_root=str(project_root.resolve(strict=False)),
|
|
525
|
+
state_dir=str(run_state_dir.resolve(strict=False)),
|
|
526
|
+
started_ts=pending.started_ts,
|
|
527
|
+
)
|
|
528
|
+
)
|
|
529
|
+
except Exception as exc:
|
|
530
|
+
log_error(
|
|
531
|
+
"active-dispatch-write-failed",
|
|
532
|
+
str(exc),
|
|
533
|
+
{"session_id": session_id, "run_id": pending.run_id},
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
ledger.close()
|
|
537
|
+
except Exception as exc:
|
|
538
|
+
log_error("pre-task-evaluation-failed", str(exc))
|
|
539
|
+
emit({"continue": True, "systemMessage": "(cost gate unavailable; proceeding)"})
|
|
540
|
+
return 0
|
|
541
|
+
|
|
542
|
+
return _emit_decision_result(
|
|
543
|
+
decision, session_id, task_id, manifest_id, estimated_tokens_in, run_state_dir,
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
if __name__ == "__main__":
|
|
548
|
+
sys.exit(run())
|