froid-loop 0.11.1__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.
- froid_loop/__init__.py +11 -0
- froid_loop/__main__.py +12 -0
- froid_loop/adapters/__init__.py +3 -0
- froid_loop/adapters/base.py +254 -0
- froid_loop/adapters/entrypoints.py +63 -0
- froid_loop/adapters/env_fault.py +290 -0
- froid_loop/adapters/generic.py +2013 -0
- froid_loop/adapters/mock.py +49 -0
- froid_loop/adapters/multiplexer.py +914 -0
- froid_loop/adapters/opencode_http.py +1687 -0
- froid_loop/adapters/profile.py +650 -0
- froid_loop/adapters/psmux_backend.py +1428 -0
- froid_loop/adapters/registry.py +322 -0
- froid_loop/adapters/tmux_backend.py +35 -0
- froid_loop/adapters/tmux_base.py +630 -0
- froid_loop/checks.py +187 -0
- froid_loop/cli.py +5041 -0
- froid_loop/data/__init__.py +0 -0
- froid_loop/data/froid_loop_hook.py +228 -0
- froid_loop/data/froid_loop_probe_hook.py +88 -0
- froid_loop/data/plugins/example/plugin.toml +21 -0
- froid_loop/data/plugins/tea/plugin.toml +184 -0
- froid_loop/data/plugins/tea/tea_plugin.py +258 -0
- froid_loop/data/plugins/unity/plugin.toml +140 -0
- froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef +16 -0
- froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef.meta +7 -0
- froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs +221 -0
- froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs.meta +11 -0
- froid_loop/data/plugins/unity/unity_assets/_folders/Editor.meta +8 -0
- froid_loop/data/plugins/unity/unity_assets/_folders/FroidLoop.meta +8 -0
- froid_loop/data/plugins/unity/unity_cleanup.py +125 -0
- froid_loop/data/plugins/unity/unity_dialog_probe.py +239 -0
- froid_loop/data/plugins/unity/unity_facts.md +17 -0
- froid_loop/data/plugins/unity/unity_plugin.py +415 -0
- froid_loop/data/plugins/unity/unity_quiesce.py +234 -0
- froid_loop/data/plugins/unity/unity_ready.py +230 -0
- froid_loop/data/plugins/unity/unity_seed_assets.py +298 -0
- froid_loop/data/plugins/unity/unity_setup.py +551 -0
- froid_loop/data/plugins/unity/unity_teardown.py +362 -0
- froid_loop/data/profiles/antigravity.toml +52 -0
- froid_loop/data/profiles/claude.toml +85 -0
- froid_loop/data/profiles/codex.toml +22 -0
- froid_loop/data/profiles/copilot.toml +52 -0
- froid_loop/data/profiles/gemini.toml +26 -0
- froid_loop/data/profiles/opencode.toml +54 -0
- froid_loop/data/settings/core.toml +458 -0
- froid_loop/data/skills/README.md +93 -0
- froid_loop/data/skills/froid-loop-resolve/SKILL.md +288 -0
- froid_loop/data/skills/froid-loop-setup/SKILL.md +161 -0
- froid_loop/data/skills/froid-loop-setup/assets/module-help.csv +3 -0
- froid_loop/data/skills/froid-loop-setup/assets/module.yaml +19 -0
- froid_loop/data/skills/froid-loop-sweep/SKILL.md +100 -0
- froid_loop/data/skills/froid-loop-sweep/automation-mode.md +127 -0
- froid_loop/data/skills/froid-loop-sweep/deferred-work-format.md +302 -0
- froid_loop/data/skills/froid-loop-sweep/migration-mode.md +86 -0
- froid_loop/decisions.py +202 -0
- froid_loop/deferredwork.py +2282 -0
- froid_loop/devcontract.py +892 -0
- froid_loop/diagnostics.py +1104 -0
- froid_loop/documents.py +532 -0
- froid_loop/engine.py +7732 -0
- froid_loop/envvars.py +111 -0
- froid_loop/escalation.py +225 -0
- froid_loop/events.py +266 -0
- froid_loop/fences.py +103 -0
- froid_loop/froidconfig.py +226 -0
- froid_loop/frontmatter.py +526 -0
- froid_loop/gates.py +133 -0
- froid_loop/install.py +2936 -0
- froid_loop/journal.py +178 -0
- froid_loop/machine.py +148 -0
- froid_loop/model.py +898 -0
- froid_loop/operatoractions.py +474 -0
- froid_loop/platform_util.py +1490 -0
- froid_loop/plugins/__init__.py +64 -0
- froid_loop/plugins/bus.py +259 -0
- froid_loop/plugins/context.py +319 -0
- froid_loop/plugins/loader.py +145 -0
- froid_loop/plugins/manifest.py +279 -0
- froid_loop/plugins/model.py +296 -0
- froid_loop/plugins/registry.py +245 -0
- froid_loop/plugins/trust.py +75 -0
- froid_loop/policy.py +1569 -0
- froid_loop/probe.py +1044 -0
- froid_loop/process_host.py +408 -0
- froid_loop/recovery_flow.py +1561 -0
- froid_loop/resolve.py +283 -0
- froid_loop/runs.py +4715 -0
- froid_loop/runsetup.py +1293 -0
- froid_loop/sanitize.py +593 -0
- froid_loop/settings_schema.py +276 -0
- froid_loop/signals.py +160 -0
- froid_loop/sprintstatus.py +609 -0
- froid_loop/statemachine.py +57 -0
- froid_loop/stories.py +615 -0
- froid_loop/stories_engine.py +796 -0
- froid_loop/sweep.py +1892 -0
- froid_loop/tokens.py +196 -0
- froid_loop/tui/__init__.py +11 -0
- froid_loop/tui/app.py +1584 -0
- froid_loop/tui/data.py +840 -0
- froid_loop/tui/launch.py +1003 -0
- froid_loop/tui/screens/__init__.py +1 -0
- froid_loop/tui/screens/dashboard.py +1071 -0
- froid_loop/tui/screens/modals.py +943 -0
- froid_loop/tui/screens/settings_screen.py +477 -0
- froid_loop/tui/settings.py +135 -0
- froid_loop/tui/widgets.py +981 -0
- froid_loop/verify.py +4545 -0
- froid_loop/workspace.py +320 -0
- froid_loop/worktree_flow.py +2301 -0
- froid_loop-0.11.1.dist-info/METADATA +728 -0
- froid_loop-0.11.1.dist-info/RECORD +116 -0
- froid_loop-0.11.1.dist-info/WHEEL +4 -0
- froid_loop-0.11.1.dist-info/entry_points.txt +2 -0
- froid_loop-0.11.1.dist-info/licenses/LICENSE +30 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""froid-loop plugin system.
|
|
2
|
+
|
|
3
|
+
A first-class, general extension layer for the orchestrator: a plugin extends
|
|
4
|
+
the froid_loop without modifying core, ranging from a data-only settings
|
|
5
|
+
contribution to an in-process Python module. Distribution is folder-drop today
|
|
6
|
+
(builtins under ``froid_loop/data/plugins/``, project-local under
|
|
7
|
+
``.froid-loop/plugins/``) with a locked seam for entry-point packaging later.
|
|
8
|
+
|
|
9
|
+
Phase 0 ships the foundation — manifest model, loader, trust gate, registry —
|
|
10
|
+
wired into nothing yet. The hook bus, dynamic settings, and the engine migration
|
|
11
|
+
build on this in later phases.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from .bus import HookBus
|
|
17
|
+
from .context import VETO_ACTIONS, HookContext, Veto
|
|
18
|
+
from .loader import (
|
|
19
|
+
ENTRY_POINT_GROUP,
|
|
20
|
+
USER_PLUGINS_REL,
|
|
21
|
+
discover,
|
|
22
|
+
get_plugin,
|
|
23
|
+
load_plugins,
|
|
24
|
+
)
|
|
25
|
+
from .model import (
|
|
26
|
+
API_VERSION,
|
|
27
|
+
SETTING_TYPES,
|
|
28
|
+
SUPPORTED_API,
|
|
29
|
+
HookSpec,
|
|
30
|
+
LoadedPlugin,
|
|
31
|
+
Plugin,
|
|
32
|
+
PluginError,
|
|
33
|
+
PluginManifest,
|
|
34
|
+
PythonSpec,
|
|
35
|
+
SettingSpec,
|
|
36
|
+
)
|
|
37
|
+
from .registry import PluginRegistry
|
|
38
|
+
from .trust import PluginUntrusted, is_enabled, require_enabled
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"API_VERSION",
|
|
42
|
+
"SUPPORTED_API",
|
|
43
|
+
"SETTING_TYPES",
|
|
44
|
+
"ENTRY_POINT_GROUP",
|
|
45
|
+
"USER_PLUGINS_REL",
|
|
46
|
+
"HookSpec",
|
|
47
|
+
"SettingSpec",
|
|
48
|
+
"PythonSpec",
|
|
49
|
+
"PluginManifest",
|
|
50
|
+
"Plugin",
|
|
51
|
+
"LoadedPlugin",
|
|
52
|
+
"PluginError",
|
|
53
|
+
"PluginUntrusted",
|
|
54
|
+
"PluginRegistry",
|
|
55
|
+
"HookBus",
|
|
56
|
+
"HookContext",
|
|
57
|
+
"Veto",
|
|
58
|
+
"VETO_ACTIONS",
|
|
59
|
+
"discover",
|
|
60
|
+
"load_plugins",
|
|
61
|
+
"get_plugin",
|
|
62
|
+
"is_enabled",
|
|
63
|
+
"require_enabled",
|
|
64
|
+
]
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""HookBus: dispatch lifecycle stages to plugin hooks (declarative + python).
|
|
2
|
+
|
|
3
|
+
The bus is the single fan-out point between the engine's stages and the loaded
|
|
4
|
+
plugins. It enforces the two invariants that keep plugins safe:
|
|
5
|
+
|
|
6
|
+
* **No-op fast path.** ``active(stage)`` is an O(1) set membership test
|
|
7
|
+
precomputed at build time. A run with no plugin bound to a stage never
|
|
8
|
+
builds a context or calls ``emit`` — zero-plugin runs stay byte-identical.
|
|
9
|
+
* **Failure isolation.** Every hook — subprocess or in-process Python — is
|
|
10
|
+
wrapped. A python hook that raises is caught (``except Exception`` only;
|
|
11
|
+
``RunStopped``/SIGTERM as ``BaseException`` propagate), journalled, and the
|
|
12
|
+
offending instance disabled for the rest of the run. A declarative hook that
|
|
13
|
+
errors (timeout, bad interpreter) fails open by default (the run survives);
|
|
14
|
+
``fail_closed`` turns an error into a defer veto.
|
|
15
|
+
|
|
16
|
+
Dispatch order is registry order (manifest ``priority`` then load order).
|
|
17
|
+
Mutations pipeline — a later plugin sees an earlier plugin's edits. Vetoes are
|
|
18
|
+
collected without short-circuit so order can never hide a severer objection;
|
|
19
|
+
the context resolves the most-conservative one.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import subprocess
|
|
27
|
+
from typing import Any, Callable
|
|
28
|
+
|
|
29
|
+
from .context import MUTABLE_FIELDS, VETO_ACTIONS, HookContext, Veto
|
|
30
|
+
from .model import LoadedPlugin
|
|
31
|
+
from .registry import PluginRegistry
|
|
32
|
+
|
|
33
|
+
# (returncode, combined-output) — the declarative-hook transport. Injectable so
|
|
34
|
+
# tests can drive the bus without spawning real subprocesses.
|
|
35
|
+
HookRunner = Callable[..., "tuple[int, str]"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class _HookError(Exception):
|
|
39
|
+
"""A declarative hook could not run to completion (timeout / launch failure),
|
|
40
|
+
as distinct from a clean non-zero exit. Decides fail-open vs fail-closed."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _run_subprocess(
|
|
44
|
+
cmd: str, *, cwd: str | None, env: dict[str, str], timeout: int
|
|
45
|
+
) -> tuple[int, str]:
|
|
46
|
+
"""Default declarative-hook transport: a shell command with the plugin env,
|
|
47
|
+
capturing output. shell=True is intentional (mirrors the deterministic verify
|
|
48
|
+
commands + the legacy engine ``*_cmd`` hooks).
|
|
49
|
+
|
|
50
|
+
Output decodes with ``errors="replace"`` (#383): a hook's output is arbitrary
|
|
51
|
+
operator-tool text whose bytes are not ours to constrain, and a strict decode
|
|
52
|
+
raised ``UnicodeDecodeError`` — a ``ValueError``, named by neither ``except``
|
|
53
|
+
arm below — which escaped ``_HookError``, the bus's designed
|
|
54
|
+
transport-failure channel, and crashed the run, losing even a passing hook's
|
|
55
|
+
exit code. Same fix and reasoning as ``verify.run_verify_commands`` (#378)."""
|
|
56
|
+
try:
|
|
57
|
+
proc = subprocess.run( # nosec B602 - operator-authored plugin command
|
|
58
|
+
cmd,
|
|
59
|
+
shell=True, # portability: operator-authored plugin command — sanctioned shell-out (see plan out-of-scope)
|
|
60
|
+
cwd=cwd,
|
|
61
|
+
env=env,
|
|
62
|
+
capture_output=True,
|
|
63
|
+
text=True,
|
|
64
|
+
errors="replace",
|
|
65
|
+
timeout=timeout,
|
|
66
|
+
)
|
|
67
|
+
except subprocess.TimeoutExpired as e:
|
|
68
|
+
raise _HookError(f"timed out after {timeout}s") from e
|
|
69
|
+
except OSError as e:
|
|
70
|
+
raise _HookError(str(e)) from e
|
|
71
|
+
return proc.returncode, proc.stdout + proc.stderr
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _instance_stages(instance: Any) -> set[str]:
|
|
75
|
+
"""The stages an in-process plugin handles: every ``on_<stage>`` method it
|
|
76
|
+
defines. Lets ``active()`` stay precise — a python plugin only marks the
|
|
77
|
+
stages it actually implements, so the fast path holds for the rest."""
|
|
78
|
+
return {
|
|
79
|
+
name[3:]
|
|
80
|
+
for name in dir(instance)
|
|
81
|
+
if name.startswith("on_") and callable(getattr(instance, name, None))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _hook_env(ctx: HookContext, lp: LoadedPlugin) -> dict[str, str]:
|
|
86
|
+
"""The ``FROID_LOOP_*`` environment a declarative hook reads — the run's
|
|
87
|
+
identity fields plus the plugin's resolved settings as ``FROID_LOOP_SETTING_*``.
|
|
88
|
+
Generalizes ``engine._run_engine_hook``'s env block to any plugin/stage."""
|
|
89
|
+
env = dict(os.environ)
|
|
90
|
+
fields = {
|
|
91
|
+
"FROID_LOOP_STAGE": ctx.stage,
|
|
92
|
+
"FROID_LOOP_RUN_ID": ctx.run_id,
|
|
93
|
+
"FROID_LOOP_RUN_DIR": ctx.run_dir or "",
|
|
94
|
+
"FROID_LOOP_REPO_ROOT": ctx.repo_root or "",
|
|
95
|
+
"FROID_LOOP_WORKTREE": ctx.worktree or ctx.repo_root or "",
|
|
96
|
+
"FROID_LOOP_STORY_KEY": ctx.story_key or "",
|
|
97
|
+
"FROID_LOOP_ROLE": ctx.role or "",
|
|
98
|
+
"FROID_LOOP_PHASE": ctx.phase or "",
|
|
99
|
+
"FROID_LOOP_BRANCH": ctx.branch or "",
|
|
100
|
+
"FROID_LOOP_AGENTS": ",".join(ctx.agents),
|
|
101
|
+
"FROID_LOOP_PLUGIN": lp.name,
|
|
102
|
+
}
|
|
103
|
+
env.update({k: v for k, v in fields.items() if v != ""})
|
|
104
|
+
# the plugin's *resolved* settings (defaults overlaid by [plugins.<name>]),
|
|
105
|
+
# not bare manifest defaults, so a declarative hook sees the operator's config.
|
|
106
|
+
for key, value in lp.settings.items():
|
|
107
|
+
env[f"FROID_LOOP_SETTING_{key.upper()}"] = str(value)
|
|
108
|
+
return env
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _last_json(output: str) -> dict[str, Any] | None:
|
|
112
|
+
"""A declarative hook may emit a JSON object on its last non-empty stdout
|
|
113
|
+
line to mutate the context. Anything else is treated as plain advisory log
|
|
114
|
+
text. Parse failures are ignored (the hook still ran)."""
|
|
115
|
+
for line in reversed(output.splitlines()):
|
|
116
|
+
line = line.strip()
|
|
117
|
+
if not line:
|
|
118
|
+
continue
|
|
119
|
+
if line.startswith("{") and line.endswith("}"):
|
|
120
|
+
try:
|
|
121
|
+
payload = json.loads(line)
|
|
122
|
+
except json.JSONDecodeError:
|
|
123
|
+
return None
|
|
124
|
+
return payload if isinstance(payload, dict) else None
|
|
125
|
+
return None
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class HookBus:
|
|
130
|
+
"""Fan stage emits out to every loaded plugin. Build once per run from the
|
|
131
|
+
registry; share with the engine."""
|
|
132
|
+
|
|
133
|
+
def __init__(
|
|
134
|
+
self,
|
|
135
|
+
registry: PluginRegistry,
|
|
136
|
+
journal: Any = None,
|
|
137
|
+
*,
|
|
138
|
+
runner: HookRunner | None = None,
|
|
139
|
+
):
|
|
140
|
+
self._registry = registry
|
|
141
|
+
self._journal = journal
|
|
142
|
+
self._runner = runner or _run_subprocess
|
|
143
|
+
# plugins disabled mid-run after an in-process hook raised (failure
|
|
144
|
+
# isolation): skipped for every subsequent stage.
|
|
145
|
+
self._disabled: set[str] = set()
|
|
146
|
+
# precompute the active-stage set for the O(1) fast path.
|
|
147
|
+
self._active: set[str] = set()
|
|
148
|
+
self._py_stages: dict[str, set[str]] = {}
|
|
149
|
+
for lp in registry.plugins():
|
|
150
|
+
for hook in lp.manifest.hooks:
|
|
151
|
+
self._active.add(hook.stage)
|
|
152
|
+
if lp.instance is not None:
|
|
153
|
+
stages = _instance_stages(lp.instance)
|
|
154
|
+
if stages:
|
|
155
|
+
self._py_stages[lp.name] = stages
|
|
156
|
+
self._active.update(stages)
|
|
157
|
+
|
|
158
|
+
# ----------------------------------------------------------- fast path
|
|
159
|
+
|
|
160
|
+
def active(self, stage: str) -> bool:
|
|
161
|
+
"""True iff some loaded plugin binds ``stage``. The engine guards every
|
|
162
|
+
emit with this so a zero-plugin run does no work."""
|
|
163
|
+
return stage in self._active
|
|
164
|
+
|
|
165
|
+
def any_active(self) -> bool:
|
|
166
|
+
return bool(self._active)
|
|
167
|
+
|
|
168
|
+
def active_plugins(self) -> list[str]:
|
|
169
|
+
"""Names of plugins that bind at least one stage — for a one-line
|
|
170
|
+
run-start journal entry when (and only when) plugins are live."""
|
|
171
|
+
out: list[str] = []
|
|
172
|
+
for lp in self._registry.plugins():
|
|
173
|
+
if lp.manifest.hooks or lp.name in self._py_stages:
|
|
174
|
+
out.append(lp.name)
|
|
175
|
+
return out
|
|
176
|
+
|
|
177
|
+
# -------------------------------------------------------------- dispatch
|
|
178
|
+
|
|
179
|
+
def emit(self, stage: str, ctx: HookContext) -> HookContext:
|
|
180
|
+
"""Dispatch ``stage`` to every plugin bound to it, in registry order.
|
|
181
|
+
Returns the same context (carrying mutations + collected vetoes). A no-op
|
|
182
|
+
when no plugin binds the stage."""
|
|
183
|
+
if stage not in self._active:
|
|
184
|
+
return ctx
|
|
185
|
+
for lp in self._registry.plugins():
|
|
186
|
+
if lp.name in self._disabled:
|
|
187
|
+
continue
|
|
188
|
+
hook = lp.manifest.hook_for(stage)
|
|
189
|
+
if hook is not None:
|
|
190
|
+
self._dispatch_declarative(lp, hook, ctx)
|
|
191
|
+
if lp.instance is not None and stage in self._py_stages.get(lp.name, ()):
|
|
192
|
+
self._dispatch_python(lp, stage, ctx)
|
|
193
|
+
return ctx
|
|
194
|
+
|
|
195
|
+
def _dispatch_declarative(self, lp: LoadedPlugin, hook: Any, ctx: HookContext) -> None:
|
|
196
|
+
cmd = lp.manifest.render(hook.cmd)
|
|
197
|
+
env = _hook_env(ctx, lp)
|
|
198
|
+
cwd = ctx.worktree or ctx.repo_root or None
|
|
199
|
+
try:
|
|
200
|
+
rc, output = self._runner(cmd, cwd=cwd, env=env, timeout=hook.timeout_sec)
|
|
201
|
+
except _HookError as e:
|
|
202
|
+
self._log("plugin-hook-error", plugin=lp.name, stage=hook.stage, error=str(e))
|
|
203
|
+
if hook.blocking and hook.fail_closed:
|
|
204
|
+
ctx.add_veto(
|
|
205
|
+
Veto("defer", f"plugin {lp.name!r} hook {hook.stage} errored: {e}", lp.name)
|
|
206
|
+
)
|
|
207
|
+
return
|
|
208
|
+
explicit_veto = self._apply_stdout(lp, ctx, output)
|
|
209
|
+
if not hook.blocking:
|
|
210
|
+
self._log("plugin-hook", plugin=lp.name, stage=hook.stage, rc=rc)
|
|
211
|
+
return
|
|
212
|
+
# blocking hook: a non-zero exit vetoes (defer) unless the hook already
|
|
213
|
+
# asked for a specific action via its stdout JSON.
|
|
214
|
+
if rc != 0 and not explicit_veto:
|
|
215
|
+
tail = output.strip()[-500:]
|
|
216
|
+
ctx.add_veto(
|
|
217
|
+
Veto("defer", f"plugin {lp.name!r} hook {hook.stage} exited {rc}: {tail}", lp.name)
|
|
218
|
+
)
|
|
219
|
+
self._log("plugin-hook", plugin=lp.name, stage=hook.stage, rc=rc, blocking=True)
|
|
220
|
+
|
|
221
|
+
def _apply_stdout(self, lp: LoadedPlugin, ctx: HookContext, output: str) -> bool:
|
|
222
|
+
"""Merge a declarative hook's optional stdout-JSON: ``shared`` updates,
|
|
223
|
+
whitelisted ``mutate`` fields, and an explicit ``veto``. Returns whether
|
|
224
|
+
an explicit veto was supplied (so the exit-code path doesn't double-veto)."""
|
|
225
|
+
payload = _last_json(output)
|
|
226
|
+
if not payload:
|
|
227
|
+
return False
|
|
228
|
+
shared = payload.get("shared")
|
|
229
|
+
if isinstance(shared, dict):
|
|
230
|
+
ctx.shared.update(shared)
|
|
231
|
+
mutate = payload.get("mutate")
|
|
232
|
+
if isinstance(mutate, dict):
|
|
233
|
+
for key, value in mutate.items():
|
|
234
|
+
if key in MUTABLE_FIELDS:
|
|
235
|
+
setattr(ctx, key, value)
|
|
236
|
+
veto = payload.get("veto")
|
|
237
|
+
if isinstance(veto, dict) and veto.get("action") in VETO_ACTIONS:
|
|
238
|
+
ctx.add_veto(Veto(veto["action"], str(veto.get("reason", "")), lp.name))
|
|
239
|
+
return True
|
|
240
|
+
return False
|
|
241
|
+
|
|
242
|
+
def _dispatch_python(self, lp: LoadedPlugin, stage: str, ctx: HookContext) -> None:
|
|
243
|
+
instance = lp.instance
|
|
244
|
+
ctx._current_plugin = lp.name
|
|
245
|
+
try:
|
|
246
|
+
instance.hook(stage, ctx) # type: ignore[union-attr]
|
|
247
|
+
except Exception as e: # isolate plugin failures; never BaseException
|
|
248
|
+
self._log("plugin-error", plugin=lp.name, stage=stage, error=f"{type(e).__name__}: {e}")
|
|
249
|
+
# disable the misbehaving instance for the rest of the run; its
|
|
250
|
+
# declarative hooks (if any) keep working — they are out-of-process.
|
|
251
|
+
self._disabled.add(lp.name)
|
|
252
|
+
if getattr(instance, "fail_closed", False):
|
|
253
|
+
ctx.add_veto(Veto("defer", f"plugin {lp.name!r} hook {stage} raised: {e}", lp.name))
|
|
254
|
+
finally:
|
|
255
|
+
ctx._current_plugin = ""
|
|
256
|
+
|
|
257
|
+
def _log(self, kind: str, **fields: Any) -> None:
|
|
258
|
+
if self._journal is not None:
|
|
259
|
+
self._journal.append(kind, **fields)
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""HookContext + Veto: the per-stage object the hook bus hands every plugin.
|
|
2
|
+
|
|
3
|
+
A ``HookContext`` is the shared run context for one lifecycle stage. It carries:
|
|
4
|
+
|
|
5
|
+
* **read-only** facts about where the run is (identity/git fields, the current
|
|
6
|
+
phase/role/attempt, a *copy* of the session result, etc.) — exposed as
|
|
7
|
+
properties with no setter so a plugin can observe but never rewrite history;
|
|
8
|
+
* a **per-stage mutable whitelist** (``proposed_prompt``/``proposed_env`` for a
|
|
9
|
+
session, ``proposed_commit_message`` for a commit, ``proposed_feedback``,
|
|
10
|
+
``proposed_decision``) the engine reads back after dispatch and applies;
|
|
11
|
+
* a free-form ``shared`` dict that persists across stages (the engine backs it
|
|
12
|
+
with ``RunState.plugin_shared`` so it survives pause/resume).
|
|
13
|
+
|
|
14
|
+
Veto is collect-then-resolve-most-conservative: every plugin that objects to a
|
|
15
|
+
stage appends a ``Veto``; the bus never short-circuits, so load order can never
|
|
16
|
+
hide a severer veto. ``resolved_veto()`` returns the single most-conservative one
|
|
17
|
+
(``skip`` < ``defer`` < ``pause``). The engine maps it onto its *existing*
|
|
18
|
+
control flow — there is no new abort path.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import copy
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from typing import TYPE_CHECKING, Any
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
# Type-only, exactly as `model.py` imports `HookContext`: the concrete
|
|
29
|
+
# verifier record type belongs in the signature, but importing it for real
|
|
30
|
+
# would be this package's SECOND core import (after manifest.py ->
|
|
31
|
+
# platform_util) and would point `plugins/` at the engine's I/O layer. There
|
|
32
|
+
# is no cycle today — `verify` reaches deferredwork/froidconfig/frontmatter/
|
|
33
|
+
# model/platform_util/policy/sprintstatus, none of which touch `plugins/` —
|
|
34
|
+
# so this is layering, not a workaround.
|
|
35
|
+
from ..verify import CommandResult
|
|
36
|
+
|
|
37
|
+
# Veto actions, least to most conservative. `skip` drops the current unit and
|
|
38
|
+
# continues the loop; `defer` routes through the engine's defer primitive; `pause`
|
|
39
|
+
# escalates (raises RunPaused). Severity orders a multi-plugin resolve.
|
|
40
|
+
VETO_ACTIONS = ("skip", "defer", "pause")
|
|
41
|
+
_VETO_SEVERITY = {"skip": 1, "defer": 2, "pause": 3}
|
|
42
|
+
|
|
43
|
+
# The mutable whitelist. A declarative hook's stdout `mutate` block and a python
|
|
44
|
+
# hook may only assign these; everything else on the context is read-only.
|
|
45
|
+
MUTABLE_FIELDS = frozenset(
|
|
46
|
+
{
|
|
47
|
+
"proposed_prompt",
|
|
48
|
+
"proposed_env",
|
|
49
|
+
"proposed_feedback",
|
|
50
|
+
"proposed_commit_message",
|
|
51
|
+
"proposed_decision",
|
|
52
|
+
}
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class Veto:
|
|
58
|
+
"""One plugin's objection to a stage. ``action`` is in VETO_ACTIONS;
|
|
59
|
+
``plugin_id`` records who raised it for the journal + escalation message."""
|
|
60
|
+
|
|
61
|
+
action: str
|
|
62
|
+
reason: str = ""
|
|
63
|
+
plugin_id: str = "?"
|
|
64
|
+
|
|
65
|
+
def __post_init__(self) -> None:
|
|
66
|
+
if self.action not in VETO_ACTIONS:
|
|
67
|
+
raise ValueError(f"veto action must be one of {VETO_ACTIONS}: got {self.action!r}")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class HookContext:
|
|
71
|
+
"""Mutable run context for a single stage emit. The bus dispatches it to
|
|
72
|
+
every plugin bound to the stage; the engine applies the whitelisted
|
|
73
|
+
mutations + resolves the veto afterwards."""
|
|
74
|
+
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
stage: str,
|
|
78
|
+
*,
|
|
79
|
+
run_id: str = "",
|
|
80
|
+
story_key: str | None = None,
|
|
81
|
+
epic: int | None = None,
|
|
82
|
+
phase: str | None = None,
|
|
83
|
+
attempt: int | None = None,
|
|
84
|
+
role: str | None = None,
|
|
85
|
+
worktree: str | None = None,
|
|
86
|
+
branch: str | None = None,
|
|
87
|
+
repo_root: str | None = None,
|
|
88
|
+
run_dir: str | None = None,
|
|
89
|
+
agents: tuple[str, ...] = (),
|
|
90
|
+
result_json: dict[str, Any] | None = None,
|
|
91
|
+
session_status: str | None = None,
|
|
92
|
+
verify_reason: str | None = None,
|
|
93
|
+
command_results: tuple[CommandResult, ...] = (),
|
|
94
|
+
verification_stage: str | None = None,
|
|
95
|
+
verification_sequence: int | None = None,
|
|
96
|
+
decision_action: str | None = None,
|
|
97
|
+
settings: dict[str, Any] | None = None,
|
|
98
|
+
shared: dict[str, Any] | None = None,
|
|
99
|
+
proposed_prompt: str | None = None,
|
|
100
|
+
proposed_env: dict[str, str] | None = None,
|
|
101
|
+
proposed_feedback: str | None = None,
|
|
102
|
+
proposed_commit_message: str | None = None,
|
|
103
|
+
proposed_decision: str | None = None,
|
|
104
|
+
):
|
|
105
|
+
self._stage = stage
|
|
106
|
+
self._run_id = run_id
|
|
107
|
+
self._story_key = story_key
|
|
108
|
+
self._epic = epic
|
|
109
|
+
self._phase = phase
|
|
110
|
+
self._attempt = attempt
|
|
111
|
+
self._role = role
|
|
112
|
+
self._worktree = worktree
|
|
113
|
+
self._branch = branch
|
|
114
|
+
self._repo_root = repo_root
|
|
115
|
+
self._run_dir = run_dir
|
|
116
|
+
# the agent ids of the CLIs that run in this unit's worktree (dev + review),
|
|
117
|
+
# for a plugin that routes per-agent config (e.g. the engine's MCP routing).
|
|
118
|
+
self._agents = tuple(agents)
|
|
119
|
+
# A *deep* copy, and the depth is the whole point. `dict()` is shallow, so
|
|
120
|
+
# the nested `escalations` list stayed SHARED with the engine's own
|
|
121
|
+
# `result.result_json` — and both verify legs now emit `post_dev_verify`
|
|
122
|
+
# ahead of their `critical_escalations` audit (dev via `decide_dev`, fix
|
|
123
|
+
# at the reordered call in `_fix_phase`). An in-process plugin holding
|
|
124
|
+
# this context could therefore clear that list and erase a CRITICAL
|
|
125
|
+
# escalation out from under the audit, letting a verify-green repair
|
|
126
|
+
# proceed where the run owed a pause. Copying at all exists to make
|
|
127
|
+
# "plugins observe, cannot alter" true; shallow made it true only of the
|
|
128
|
+
# top level, which is not where escalations live.
|
|
129
|
+
self._result_json = copy.deepcopy(result_json) if result_json is not None else None
|
|
130
|
+
self._session_status = session_status
|
|
131
|
+
self._verify_reason = verify_reason
|
|
132
|
+
# Frozen command-result records with immutable strings. This is an
|
|
133
|
+
# observe-only surface: plugins cannot replace the verifier outcome or
|
|
134
|
+
# modify this tuple, and the engine never reads it back for a decision.
|
|
135
|
+
self._command_results = tuple(command_results)
|
|
136
|
+
# The journal correlation keys for the pass those records came from —
|
|
137
|
+
# `verification_stage` is also the dev-vs-repair discriminator, which
|
|
138
|
+
# neither `stage` (both legs emit `post_dev_verify`) nor `phase` (both
|
|
139
|
+
# are DEV_VERIFY) nor `attempt` (one counter, shared) can supply.
|
|
140
|
+
self._verification_stage = verification_stage
|
|
141
|
+
self._verification_sequence = verification_sequence
|
|
142
|
+
self._decision_action = decision_action
|
|
143
|
+
self._settings = dict(settings) if settings is not None else {}
|
|
144
|
+
# free-form, persisted across stages (engine backs it with plugin_shared)
|
|
145
|
+
self.shared: dict[str, Any] = shared if shared is not None else {}
|
|
146
|
+
# mutable whitelist (plain public attributes; engine reads them back)
|
|
147
|
+
self.proposed_prompt = proposed_prompt
|
|
148
|
+
self.proposed_env = dict(proposed_env) if proposed_env is not None else None
|
|
149
|
+
self.proposed_feedback = proposed_feedback
|
|
150
|
+
self.proposed_commit_message = proposed_commit_message
|
|
151
|
+
self.proposed_decision = proposed_decision
|
|
152
|
+
# veto collection + the plugin currently dispatching (set by the bus so
|
|
153
|
+
# ctx.veto() can attribute the objection without the plugin passing a name)
|
|
154
|
+
self._vetoes: list[Veto] = []
|
|
155
|
+
self._current_plugin = ""
|
|
156
|
+
|
|
157
|
+
# ---------------------------------------------------------- read-only view
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def stage(self) -> str:
|
|
161
|
+
return self._stage
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def run_id(self) -> str:
|
|
165
|
+
return self._run_id
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def story_key(self) -> str | None:
|
|
169
|
+
return self._story_key
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def epic(self) -> int | None:
|
|
173
|
+
return self._epic
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def phase(self) -> str | None:
|
|
177
|
+
return self._phase
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def attempt(self) -> int | None:
|
|
181
|
+
return self._attempt
|
|
182
|
+
|
|
183
|
+
@property
|
|
184
|
+
def role(self) -> str | None:
|
|
185
|
+
return self._role
|
|
186
|
+
|
|
187
|
+
@property
|
|
188
|
+
def worktree(self) -> str | None:
|
|
189
|
+
return self._worktree
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def branch(self) -> str | None:
|
|
193
|
+
return self._branch
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def repo_root(self) -> str | None:
|
|
197
|
+
return self._repo_root
|
|
198
|
+
|
|
199
|
+
@property
|
|
200
|
+
def run_dir(self) -> str | None:
|
|
201
|
+
return self._run_dir
|
|
202
|
+
|
|
203
|
+
@property
|
|
204
|
+
def agents(self) -> tuple[str, ...]:
|
|
205
|
+
return self._agents
|
|
206
|
+
|
|
207
|
+
@property
|
|
208
|
+
def result_json(self) -> dict[str, Any] | None:
|
|
209
|
+
return self._result_json
|
|
210
|
+
|
|
211
|
+
@property
|
|
212
|
+
def session_status(self) -> str | None:
|
|
213
|
+
return self._session_status
|
|
214
|
+
|
|
215
|
+
@property
|
|
216
|
+
def verify_reason(self) -> str | None:
|
|
217
|
+
return self._verify_reason
|
|
218
|
+
|
|
219
|
+
@property
|
|
220
|
+
def command_results(self) -> tuple[CommandResult, ...]:
|
|
221
|
+
"""The verifier ``CommandResult`` records from this attempt's verify pass,
|
|
222
|
+
in the order the commands ran. Read-only observability for
|
|
223
|
+
``post_dev_verify``; nothing here feeds an engine decision.
|
|
224
|
+
|
|
225
|
+
Empty is ambiguous ON ITS OWN and must not be read as "the commands did
|
|
226
|
+
not run" — read it together with :attr:`verification_stage`, which is what
|
|
227
|
+
separates the cases:
|
|
228
|
+
|
|
229
|
+
* ``verification_stage is None`` — no verify pass ran at all. FOUR
|
|
230
|
+
distinct causes reach here and an empty tuple names none of them:
|
|
231
|
+
|
|
232
|
+
1. the session did not complete — ``session_status`` says so;
|
|
233
|
+
2. the dev-artifact gate already failed the attempt — ``verify_reason``;
|
|
234
|
+
3. on the repair leg, the deferral harvest short-circuited ahead of the
|
|
235
|
+
commands — also ``verify_reason``;
|
|
236
|
+
4. the engine variant suppressed the pass for this leg —
|
|
237
|
+
``StoriesEngine`` skips it on a plan-halt leg, which has no
|
|
238
|
+
implementation to build, so nothing on the context marks this one
|
|
239
|
+
apart from a run that simply configured no commands.
|
|
240
|
+
|
|
241
|
+
``session_status`` and ``verify_reason`` separate 1–3; this tuple
|
|
242
|
+
separates none of them, and does not try.
|
|
243
|
+
* ``verification_stage`` set with ``verification_sequence is None`` — the
|
|
244
|
+
pass DID run and executed nothing, because ``[verify] commands`` is
|
|
245
|
+
empty. No journal record exists for it either.
|
|
246
|
+
* ``verification_stage`` set with an int ``verification_sequence`` — those
|
|
247
|
+
commands ran, and each has a matching journal entry (see that property).
|
|
248
|
+
"""
|
|
249
|
+
return self._command_results
|
|
250
|
+
|
|
251
|
+
@property
|
|
252
|
+
def verification_stage(self) -> str | None:
|
|
253
|
+
"""Which leg produced :attr:`command_results` — ``"dev"`` for the initial
|
|
254
|
+
dev verification, ``"fix"`` for a feedback-driven repair one, ``None``
|
|
255
|
+
when no verify pass ran (see :attr:`command_results`).
|
|
256
|
+
|
|
257
|
+
This is the ONLY discriminator between the two. ``stage`` and ``phase``
|
|
258
|
+
are literally identical across them (``post_dev_verify`` from
|
|
259
|
+
``Phase.DEV_VERIFY``), and ``attempt`` is one per-story counter the
|
|
260
|
+
repair leg CONTINUES rather than restarts — so its value orders the two
|
|
261
|
+
but never names either, and a human re-arm reuses the numbers outright.
|
|
262
|
+
"""
|
|
263
|
+
return self._verification_stage
|
|
264
|
+
|
|
265
|
+
@property
|
|
266
|
+
def verification_sequence(self) -> int | None:
|
|
267
|
+
"""This story's 1-based ordinal for the verify pass that produced
|
|
268
|
+
:attr:`command_results`, or ``None`` when the pass recorded nothing.
|
|
269
|
+
|
|
270
|
+
The join key back to the run journal: the ``verify-command-result``
|
|
271
|
+
entries with this ``story_key`` + ``verification_stage`` +
|
|
272
|
+
``verification_sequence`` are exactly these results, one per record,
|
|
273
|
+
ordered by their ``command_index``. Monotonic per story across a
|
|
274
|
+
pause/resume — the sequence is durable, unlike ``attempt``, which a human
|
|
275
|
+
re-arm can reuse.
|
|
276
|
+
"""
|
|
277
|
+
return self._verification_sequence
|
|
278
|
+
|
|
279
|
+
@property
|
|
280
|
+
def decision_action(self) -> str | None:
|
|
281
|
+
return self._decision_action
|
|
282
|
+
|
|
283
|
+
@property
|
|
284
|
+
def settings(self) -> dict[str, Any]:
|
|
285
|
+
return self._settings
|
|
286
|
+
|
|
287
|
+
# ------------------------------------------------------------ veto surface
|
|
288
|
+
|
|
289
|
+
def veto(self, action: str, reason: str = "") -> None:
|
|
290
|
+
"""Plugin-facing: object to this stage. Attributed to the dispatching
|
|
291
|
+
plugin. Multiple plugins (and multiple calls) accumulate — the engine
|
|
292
|
+
resolves the most-conservative one."""
|
|
293
|
+
self._vetoes.append(
|
|
294
|
+
Veto(action=action, reason=reason, plugin_id=self._current_plugin or "?")
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
def add_veto(self, veto: Veto) -> None:
|
|
298
|
+
"""Bus-facing: record a veto synthesized from a declarative hook's exit
|
|
299
|
+
code or stdout (the plugin id is already filled in)."""
|
|
300
|
+
self._vetoes.append(veto)
|
|
301
|
+
|
|
302
|
+
@property
|
|
303
|
+
def vetoed(self) -> bool:
|
|
304
|
+
return bool(self._vetoes)
|
|
305
|
+
|
|
306
|
+
@property
|
|
307
|
+
def vetoes(self) -> tuple[Veto, ...]:
|
|
308
|
+
return tuple(self._vetoes)
|
|
309
|
+
|
|
310
|
+
def resolved_veto(self) -> Veto | None:
|
|
311
|
+
"""The single most-conservative veto (pause > defer > skip), or None.
|
|
312
|
+
Ties resolve to the first raised, so a deterministic plugin order gives a
|
|
313
|
+
deterministic outcome."""
|
|
314
|
+
if not self._vetoes:
|
|
315
|
+
return None
|
|
316
|
+
return max(self._vetoes, key=lambda v: _VETO_SEVERITY[v.action])
|
|
317
|
+
|
|
318
|
+
def __repr__(self) -> str: # pragma: no cover - debug aid
|
|
319
|
+
return f"<HookContext {self._stage!r} story={self._story_key!r} vetoes={len(self._vetoes)}>"
|