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,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())