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,296 @@
|
|
|
1
|
+
"""Data model for the froid-loop plugin system.
|
|
2
|
+
|
|
3
|
+
A plugin extends the orchestrator without touching the core loop. Every plugin
|
|
4
|
+
is a ``plugin.toml`` manifest (metadata, declarative out-of-process hooks, a
|
|
5
|
+
settings schema, optional in-process Python). Data-only plugins need no code.
|
|
6
|
+
|
|
7
|
+
This module holds the immutable shapes the manifest parses into plus the
|
|
8
|
+
``Plugin`` base class that a trusted in-process plugin subclasses. It mirrors
|
|
9
|
+
the proven dataclass-as-data style of ``engines/plugin.py`` and
|
|
10
|
+
``adapters/profile.py`` — parsing/validation lives in ``manifest.py``,
|
|
11
|
+
discovery in ``loader.py``, trust in ``trust.py`` and aggregation in
|
|
12
|
+
``registry.py``. Nothing here reaches into the run loop; the hook bus that
|
|
13
|
+
calls into a plugin is wired in a later phase.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import TYPE_CHECKING, Any
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from .context import HookContext
|
|
23
|
+
|
|
24
|
+
# The framework's current plugin-API version. A manifest declares the api_version
|
|
25
|
+
# it was written against; the loader compares it against SUPPORTED_API. Bumping
|
|
26
|
+
# this set (never removing without a major) is how the API evolves.
|
|
27
|
+
API_VERSION = 1
|
|
28
|
+
SUPPORTED_API: frozenset[int] = frozenset({1})
|
|
29
|
+
|
|
30
|
+
# Permitted [[settings]] kinds. "select" carries an options list; the rest are
|
|
31
|
+
# scalars. Kept deliberately small — the settings pillar (Phase 1) maps these to
|
|
32
|
+
# widget hints.
|
|
33
|
+
SETTING_TYPES = {"bool", "int", "float", "str", "select"}
|
|
34
|
+
|
|
35
|
+
# Stages at which a plugin-provided workflow may inject an extra agent session
|
|
36
|
+
# (Phase 4). Deliberately small + conservative: all fire *inside* the unit's
|
|
37
|
+
# live worktree with the dev/review work already on disk, so an injected session
|
|
38
|
+
# sees the real tree. post_review_result fires only when the orchestrator review
|
|
39
|
+
# loop actually runs (review.trigger="recommended" skips it on most stories);
|
|
40
|
+
# pre_commit_gate fires unconditionally just before commit on every path —
|
|
41
|
+
# review, skip, and budget-rescue alike — so a gate session evaluates the exact
|
|
42
|
+
# tree about to commit. Other stages either lack a worktree (run-boundary) or
|
|
43
|
+
# run after teardown (post_story restores the main checkout), so a session there
|
|
44
|
+
# would target the wrong tree — they are intentionally excluded.
|
|
45
|
+
WORKFLOW_STAGES = frozenset({"post_dev_phase", "post_review_result", "pre_commit_gate"})
|
|
46
|
+
|
|
47
|
+
# Roles a workflow session may run as — the engine's two adapters. A workflow
|
|
48
|
+
# names one so it reuses that role's resolved adapter config (model, timeout).
|
|
49
|
+
WORKFLOW_ROLES = frozenset({"dev", "review"})
|
|
50
|
+
|
|
51
|
+
# Where a manifest was discovered, in overlay precedence order. Recorded so the
|
|
52
|
+
# loader can treat an api_version mismatch as a hard error for builtins but a
|
|
53
|
+
# skip-with-warning for third-party plugins.
|
|
54
|
+
PLUGIN_SOURCES = ("builtin", "entry_point", "project")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class PluginError(Exception):
|
|
58
|
+
"""Raised on a malformed manifest, an unknown plugin, or an unsupported
|
|
59
|
+
builtin api_version. Mirrors ProfileError."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class HookSpec:
|
|
64
|
+
"""A declarative, out-of-process hook bound to a lifecycle stage.
|
|
65
|
+
|
|
66
|
+
``stage`` is the ``[hooks.<stage>]`` table key. ``cmd`` is a shell command
|
|
67
|
+
template that may use ``{scripts}`` (expanded to the plugin's script dir).
|
|
68
|
+
A ``blocking`` hook's non-zero exit vetoes the unit (defer); a non-blocking
|
|
69
|
+
hook is advisory (logged). Stage names are not validated here — the stage
|
|
70
|
+
map is owned by the hook bus, wired in a later phase.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
stage: str
|
|
74
|
+
cmd: str = ""
|
|
75
|
+
timeout_sec: int = 120
|
|
76
|
+
blocking: bool = False
|
|
77
|
+
# Blocking-hook failure policy. fail_open (default): a hook *error* (timeout,
|
|
78
|
+
# missing interpreter) lets the run survive; only a clean non-zero exit
|
|
79
|
+
# vetoes. fail_closed: any failure vetoes (defer). Consumed by the bus later.
|
|
80
|
+
fail_closed: bool = False
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True)
|
|
84
|
+
class SettingSpec:
|
|
85
|
+
"""One plugin-contributed setting, consumed by the settings pillar.
|
|
86
|
+
|
|
87
|
+
The vocabulary intentionally matches the core settings ``_Field``: a key, a
|
|
88
|
+
kind, a default, help text, and (for ``select``) an options list plus
|
|
89
|
+
optional numeric bounds. Stored under ``[plugins.<name>]`` in policy.toml.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
key: str
|
|
93
|
+
type: str
|
|
94
|
+
default: Any = None
|
|
95
|
+
help: str = ""
|
|
96
|
+
options: tuple[str, ...] = ()
|
|
97
|
+
label: str = ""
|
|
98
|
+
min: int | None = None
|
|
99
|
+
max: int | None = None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass(frozen=True)
|
|
103
|
+
class WorkflowSpec:
|
|
104
|
+
"""A plugin-provided workflow: an extra agent session injected at a lifecycle
|
|
105
|
+
stage (the ``[provides]`` surface, implemented in Phase 4).
|
|
106
|
+
|
|
107
|
+
A workflow is the conservative form of custom orchestration the plan settled
|
|
108
|
+
on — no new pipeline stage, just an extra session run through the engine's
|
|
109
|
+
generic ``_run_session`` path at an allowlisted ``stage`` (see
|
|
110
|
+
WORKFLOW_STAGES). ``role`` selects which adapter runs it (WORKFLOW_ROLES);
|
|
111
|
+
``prompt`` is the agent prompt template, expanding ``{story_key}``,
|
|
112
|
+
``{run_id}`` and ``{scripts}`` (the plugin's script dir). A ``blocking``
|
|
113
|
+
workflow whose session does not complete defers the unit (it routes through
|
|
114
|
+
the engine's existing defer primitive); a non-blocking one is advisory.
|
|
115
|
+
|
|
116
|
+
Settings overlay (manifest value is the default; a setting tunes it per run):
|
|
117
|
+
a plugin's resolved settings can override two of these fields by naming
|
|
118
|
+
convention, read in ``PluginRegistry.workflows_for``/``workflow_stages``:
|
|
119
|
+
|
|
120
|
+
* ``<name>_enabled`` (bool) — when explicitly ``false``, the step is
|
|
121
|
+
dropped entirely (no session injected, the stage falls out of the O(1)
|
|
122
|
+
injection guard if every step there is off).
|
|
123
|
+
* ``<name>_blocking`` (bool) — overrides this spec's ``blocking`` flag,
|
|
124
|
+
flipping the advisory/defer behaviour without editing the manifest.
|
|
125
|
+
|
|
126
|
+
``<name>`` is this workflow's ``name``; declare matching ``[[settings]]`` so
|
|
127
|
+
operators can flip them from ``[plugins.<plugin>]`` in policy.toml. Absent
|
|
128
|
+
settings preserve the manifest values exactly — a plugin that declares none
|
|
129
|
+
is byte-identical to a plugin system without the overlay.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
name: str
|
|
133
|
+
stage: str
|
|
134
|
+
role: str = "dev"
|
|
135
|
+
prompt: str = ""
|
|
136
|
+
blocking: bool = False
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass(frozen=True)
|
|
140
|
+
class PythonSpec:
|
|
141
|
+
"""Optional in-process module. Its presence makes the plugin trust-gated:
|
|
142
|
+
the module is never imported unless the plugin is in ``[plugins] enabled``.
|
|
143
|
+
|
|
144
|
+
``module`` is a plugin-relative file (resolved against the plugin's script
|
|
145
|
+
dir); ``cls`` is the ``Plugin`` subclass to instantiate (defaults to
|
|
146
|
+
``Plugin``).
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
module: str
|
|
150
|
+
cls: str = "Plugin"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@dataclass(frozen=True)
|
|
154
|
+
class PluginManifest:
|
|
155
|
+
"""A parsed ``plugin.toml``. Immutable; the single inter-module contract.
|
|
156
|
+
|
|
157
|
+
``scripts_dir`` is the plugin's on-disk directory (its ``{scripts}`` root),
|
|
158
|
+
set by the loader. ``source`` records discovery origin (see PLUGIN_SOURCES).
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
name: str
|
|
162
|
+
version: str = "0.0.0"
|
|
163
|
+
api_version: int = API_VERSION
|
|
164
|
+
description: str = ""
|
|
165
|
+
author: str = ""
|
|
166
|
+
hooks: tuple[HookSpec, ...] = ()
|
|
167
|
+
settings: tuple[SettingSpec, ...] = ()
|
|
168
|
+
python: PythonSpec | None = None
|
|
169
|
+
# custom orchestration items a plugin contributes: extra agent sessions
|
|
170
|
+
# injected at a lifecycle stage (parsed from [workflows.<name>] tables).
|
|
171
|
+
workflows: tuple[WorkflowSpec, ...] = ()
|
|
172
|
+
# extra gitignored paths to seed into a per-unit worktree, mirroring the
|
|
173
|
+
# engine plugin (seed_files = literal project-relative paths, seed_globs =
|
|
174
|
+
# patterns expanded against the main repo). Consumed by worktree priming.
|
|
175
|
+
seed_files: tuple[str, ...] = ()
|
|
176
|
+
seed_globs: tuple[str, ...] = ()
|
|
177
|
+
# ordering across plugins at a shared stage: lower runs first, then load order.
|
|
178
|
+
priority: int = 0
|
|
179
|
+
scripts_dir: str = ""
|
|
180
|
+
source: str = "project"
|
|
181
|
+
|
|
182
|
+
def render(self, template: str) -> str:
|
|
183
|
+
"""Expand ``{scripts}`` in a command template to this plugin's dir."""
|
|
184
|
+
return template.replace("{scripts}", self.scripts_dir)
|
|
185
|
+
|
|
186
|
+
def hook_for(self, stage: str) -> HookSpec | None:
|
|
187
|
+
for hook in self.hooks:
|
|
188
|
+
if hook.stage == stage:
|
|
189
|
+
return hook
|
|
190
|
+
return None
|
|
191
|
+
|
|
192
|
+
def setting_defaults(self) -> dict[str, Any]:
|
|
193
|
+
return {s.key: s.default for s in self.settings}
|
|
194
|
+
|
|
195
|
+
def workflows_for(self, stage: str) -> tuple[WorkflowSpec, ...]:
|
|
196
|
+
return tuple(w for w in self.workflows if w.stage == stage)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class Plugin:
|
|
200
|
+
"""Base class for a trusted in-process plugin.
|
|
201
|
+
|
|
202
|
+
A ``[python]`` manifest names a subclass; the registry instantiates it with
|
|
203
|
+
the parsed manifest and the plugin's resolved settings (defaults overlaid by
|
|
204
|
+
``[plugins.<name>]`` in policy.toml). Hook-dispatch methods are added when
|
|
205
|
+
the bus is wired in a later phase — for now the base simply carries context,
|
|
206
|
+
so a folder-dropped plugin that is enabled can be constructed and inspected
|
|
207
|
+
without yet being able to affect a run.
|
|
208
|
+
|
|
209
|
+
Subclasses MUST NOT do expensive or side-effecting work in ``__init__``;
|
|
210
|
+
construction happens at registry build time and a raised exception disables
|
|
211
|
+
the instance (failure isolation).
|
|
212
|
+
|
|
213
|
+
Hook dispatch: the bus calls ``hook(stage, ctx)``, which routes to an
|
|
214
|
+
``on_<stage>(ctx)`` method if the subclass defines one (else a no-op). A
|
|
215
|
+
handler observes ``ctx`` (read-only fields), mutates the whitelisted
|
|
216
|
+
``proposed_*`` fields / ``ctx.shared``, and/or calls ``ctx.veto(action,
|
|
217
|
+
reason)``. Set the class attribute ``fail_closed = True`` to make a raised
|
|
218
|
+
exception veto (defer) the unit instead of failing open.
|
|
219
|
+
"""
|
|
220
|
+
|
|
221
|
+
# opt-in: a handler raising disables the instance either way (failure
|
|
222
|
+
# isolation); fail_closed additionally vetoes (defers) the current unit.
|
|
223
|
+
fail_closed: bool = False
|
|
224
|
+
|
|
225
|
+
def __init__(self, manifest: PluginManifest, settings: dict[str, Any]):
|
|
226
|
+
self.manifest = manifest
|
|
227
|
+
self.settings = settings
|
|
228
|
+
|
|
229
|
+
@property
|
|
230
|
+
def name(self) -> str:
|
|
231
|
+
return self.manifest.name
|
|
232
|
+
|
|
233
|
+
def validate(self, policy: Any) -> None:
|
|
234
|
+
"""Self-validate the plugin's resolved settings against the run policy.
|
|
235
|
+
|
|
236
|
+
Called once at registry-build time (before any stage fires). A plugin
|
|
237
|
+
raises ``PluginError`` here to reject an incompatible configuration —
|
|
238
|
+
e.g. a coupling between a plugin setting and a core policy field that a
|
|
239
|
+
flat per-key schema can't express (the engine plugin's
|
|
240
|
+
editor_mode↔scm.isolation coupling). This is a deliberate config
|
|
241
|
+
rejection, not a plugin bug, so the registry lets it propagate and the
|
|
242
|
+
run fails fast rather than being isolated out. The default is a no-op."""
|
|
243
|
+
|
|
244
|
+
def hook(self, stage: str, ctx: "HookContext") -> None:
|
|
245
|
+
"""Route a stage to its ``on_<stage>`` handler, if defined. The bus wraps
|
|
246
|
+
this call for failure isolation, so a subclass handler may raise freely."""
|
|
247
|
+
handler = getattr(self, f"on_{stage}", None)
|
|
248
|
+
if handler is not None:
|
|
249
|
+
handler(ctx)
|
|
250
|
+
|
|
251
|
+
def __repr__(self) -> str: # pragma: no cover - debug aid
|
|
252
|
+
return f"<Plugin {self.manifest.name!r} v{self.manifest.version}>"
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@dataclass(frozen=True)
|
|
256
|
+
class LoadedPlugin:
|
|
257
|
+
"""A manifest plus its trust/resolution outcome, as the registry holds it.
|
|
258
|
+
|
|
259
|
+
``instance`` is a constructed ``Plugin`` for an enabled, trusted ``[python]``
|
|
260
|
+
plugin; ``None`` for a data-only/declarative plugin, an untrusted one (has
|
|
261
|
+
``[python]`` but isn't allowlisted), or one whose construction raised.
|
|
262
|
+
``disabled`` marks an instance that errored and was isolated out.
|
|
263
|
+
"""
|
|
264
|
+
|
|
265
|
+
manifest: PluginManifest
|
|
266
|
+
instance: Plugin | None = None
|
|
267
|
+
trusted: bool = True
|
|
268
|
+
disabled: bool = False
|
|
269
|
+
error: str = ""
|
|
270
|
+
# the plugin's resolved settings: manifest defaults overlaid by the
|
|
271
|
+
# ``[plugins.<name>]`` policy table. The same dict the instance was built
|
|
272
|
+
# with; the bus reads it for the declarative-hook ``FROID_LOOP_SETTING_*`` env.
|
|
273
|
+
settings: dict[str, Any] = field(default_factory=dict)
|
|
274
|
+
|
|
275
|
+
@property
|
|
276
|
+
def name(self) -> str:
|
|
277
|
+
return self.manifest.name
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# re-exported by manifest.py for parser convenience
|
|
281
|
+
__all__ = [
|
|
282
|
+
"API_VERSION",
|
|
283
|
+
"SUPPORTED_API",
|
|
284
|
+
"SETTING_TYPES",
|
|
285
|
+
"WORKFLOW_STAGES",
|
|
286
|
+
"WORKFLOW_ROLES",
|
|
287
|
+
"PLUGIN_SOURCES",
|
|
288
|
+
"PluginError",
|
|
289
|
+
"HookSpec",
|
|
290
|
+
"SettingSpec",
|
|
291
|
+
"WorkflowSpec",
|
|
292
|
+
"PythonSpec",
|
|
293
|
+
"PluginManifest",
|
|
294
|
+
"Plugin",
|
|
295
|
+
"LoadedPlugin",
|
|
296
|
+
]
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""PluginRegistry: the inter-pillar contract.
|
|
2
|
+
|
|
3
|
+
The registry collapses discovery + trust + in-process resolution into one
|
|
4
|
+
read-only object the rest of the system consumes:
|
|
5
|
+
|
|
6
|
+
* the settings pillar reads ``settings_schema()``;
|
|
7
|
+
* the hook bus reads ``hooks_for(stage)``;
|
|
8
|
+
* custom orchestration reads ``provided_workflows()``.
|
|
9
|
+
|
|
10
|
+
Neither consumer reaches into loader internals. Building the registry is the
|
|
11
|
+
single place trust is enforced and failure is isolated:
|
|
12
|
+
|
|
13
|
+
* a plugin with no ``[python]`` loads as a data-only/declarative LoadedPlugin
|
|
14
|
+
(instance None) — its shell hooks are available to the bus;
|
|
15
|
+
* a ``[python]`` plugin is constructed only if it is in ``[plugins] enabled``
|
|
16
|
+
(``trust.require_enabled`` gates ``exec_module``); otherwise it is recorded
|
|
17
|
+
untrusted and its module is never imported;
|
|
18
|
+
* any exception while importing/constructing a trusted instance is caught
|
|
19
|
+
(``except Exception`` — never ``BaseException``, so RunStopped/SIGTERM
|
|
20
|
+
propagate), journalled, and the instance disabled. The run survives.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import importlib.util
|
|
26
|
+
from dataclasses import replace
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from . import trust
|
|
30
|
+
from .loader import load_plugins
|
|
31
|
+
from .model import (
|
|
32
|
+
HookSpec,
|
|
33
|
+
LoadedPlugin,
|
|
34
|
+
Plugin,
|
|
35
|
+
PluginManifest,
|
|
36
|
+
SettingSpec,
|
|
37
|
+
WorkflowSpec,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _resolve_settings(manifest: PluginManifest, policy) -> dict:
|
|
42
|
+
"""Manifest defaults overlaid by the ``[plugins.<name>]`` policy table. The
|
|
43
|
+
single resolved view the instance is built with and the bus reads for env."""
|
|
44
|
+
resolved = dict(manifest.setting_defaults())
|
|
45
|
+
plugins_pol = getattr(policy, "plugins", None) if policy is not None else None
|
|
46
|
+
overrides = getattr(plugins_pol, "settings", {}).get(manifest.name, {}) if plugins_pol else {}
|
|
47
|
+
resolved.update(overrides)
|
|
48
|
+
return resolved
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _instantiate(manifest: PluginManifest, settings: dict) -> Plugin:
|
|
52
|
+
"""Import the plugin's module and construct its Plugin subclass with its
|
|
53
|
+
resolved settings.
|
|
54
|
+
|
|
55
|
+
Caller is responsible for the trust gate; this performs the actual
|
|
56
|
+
``exec_module``. Kept tiny so the registry's try/except wraps exactly the
|
|
57
|
+
import + construct surface.
|
|
58
|
+
"""
|
|
59
|
+
scripts_dir = Path(manifest.scripts_dir)
|
|
60
|
+
module_path = scripts_dir / manifest.python.module # type: ignore[union-attr]
|
|
61
|
+
# `_parse_python` rejects an absolute, parent-climbing or root-naming module at
|
|
62
|
+
# manifest load, but lexically: a project-relative name that is a symlink out of
|
|
63
|
+
# the plugin resolves fine and would be exec'd. Re-check after resolution, since
|
|
64
|
+
# what happens below is arbitrary code execution, not a copy.
|
|
65
|
+
# Strictly below, not merely inside: `is_relative_to` is true for equal paths,
|
|
66
|
+
# so a module resolving to the plugin dir itself would pass an inside-check.
|
|
67
|
+
try:
|
|
68
|
+
resolved, base = module_path.resolve(), scripts_dir.resolve()
|
|
69
|
+
contained = resolved != base and resolved.is_relative_to(base)
|
|
70
|
+
except (OSError, RuntimeError):
|
|
71
|
+
contained = False
|
|
72
|
+
if not contained:
|
|
73
|
+
raise ImportError(f"plugin module escapes its plugin directory: {module_path}")
|
|
74
|
+
if not module_path.is_file():
|
|
75
|
+
raise FileNotFoundError(f"plugin module not found: {module_path}")
|
|
76
|
+
spec = importlib.util.spec_from_file_location(f"froid_loop_plugin_{manifest.name}", module_path)
|
|
77
|
+
if spec is None or spec.loader is None:
|
|
78
|
+
raise ImportError(f"cannot load plugin module: {module_path}")
|
|
79
|
+
module = importlib.util.module_from_spec(spec)
|
|
80
|
+
spec.loader.exec_module(module)
|
|
81
|
+
cls_name = manifest.python.cls # type: ignore[union-attr]
|
|
82
|
+
cls = getattr(module, cls_name, None)
|
|
83
|
+
if cls is None:
|
|
84
|
+
# manifest.python is validated non-None before this loader runs (mirrors
|
|
85
|
+
# the manifest.python.cls access above).
|
|
86
|
+
raise AttributeError(
|
|
87
|
+
f"plugin module {manifest.python.module!r} has no {cls_name!r}" # pyright: ignore[reportOptionalMemberAccess]
|
|
88
|
+
)
|
|
89
|
+
if not (isinstance(cls, type) and issubclass(cls, Plugin)):
|
|
90
|
+
raise TypeError(f"{cls_name!r} must subclass plugins.Plugin")
|
|
91
|
+
return cls(manifest, settings)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _resolve(manifest: PluginManifest, policy, journal) -> LoadedPlugin:
|
|
95
|
+
settings = _resolve_settings(manifest, policy)
|
|
96
|
+
if manifest.python is None:
|
|
97
|
+
if journal is not None:
|
|
98
|
+
journal.append("plugin-loaded", plugin=manifest.name, mode="declarative")
|
|
99
|
+
return LoadedPlugin(manifest=manifest, settings=settings)
|
|
100
|
+
|
|
101
|
+
if not trust.is_enabled(policy, manifest.name):
|
|
102
|
+
if journal is not None:
|
|
103
|
+
journal.append(
|
|
104
|
+
"plugin-untrusted",
|
|
105
|
+
plugin=manifest.name,
|
|
106
|
+
reason="[python] module requires [plugins] enabled",
|
|
107
|
+
)
|
|
108
|
+
return LoadedPlugin(manifest=manifest, trusted=False, settings=settings)
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
instance = _instantiate(manifest, settings)
|
|
112
|
+
except Exception as e: # isolate plugin failures; never BaseException
|
|
113
|
+
if journal is not None:
|
|
114
|
+
journal.append("plugin-error", plugin=manifest.name, error=f"{type(e).__name__}: {e}")
|
|
115
|
+
return LoadedPlugin(manifest=manifest, disabled=True, error=str(e), settings=settings)
|
|
116
|
+
|
|
117
|
+
if journal is not None:
|
|
118
|
+
journal.append("plugin-loaded", plugin=manifest.name, mode="python")
|
|
119
|
+
return LoadedPlugin(manifest=manifest, instance=instance, settings=settings)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class PluginRegistry:
|
|
123
|
+
"""Read-only view over the loaded plugins. Build once per run."""
|
|
124
|
+
|
|
125
|
+
def __init__(self, loaded: list[LoadedPlugin]):
|
|
126
|
+
# stable order: manifest priority then load (discovery/overlay) order.
|
|
127
|
+
self._loaded = sorted(loaded, key=lambda lp: lp.manifest.priority)
|
|
128
|
+
|
|
129
|
+
@classmethod
|
|
130
|
+
def build(cls, project: Path | None = None, policy=None, journal=None) -> PluginRegistry:
|
|
131
|
+
manifests = load_plugins(project, journal=journal)
|
|
132
|
+
loaded = [_resolve(m, policy, journal) for m in manifests.values()]
|
|
133
|
+
return cls(loaded)
|
|
134
|
+
|
|
135
|
+
# ----------------------------------------------------------- consumers
|
|
136
|
+
|
|
137
|
+
def plugins(self) -> list[LoadedPlugin]:
|
|
138
|
+
return list(self._loaded)
|
|
139
|
+
|
|
140
|
+
def get(self, name: str) -> LoadedPlugin | None:
|
|
141
|
+
for lp in self._loaded:
|
|
142
|
+
if lp.manifest.name == name:
|
|
143
|
+
return lp
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
def hooks_for(self, stage: str) -> list[tuple[LoadedPlugin, HookSpec]]:
|
|
147
|
+
"""Every (plugin, hook) bound to ``stage``, in registry order. A
|
|
148
|
+
disabled instance still contributes its *declarative* hooks (those are
|
|
149
|
+
out-of-process and independent of the in-process module that failed)."""
|
|
150
|
+
out: list[tuple[LoadedPlugin, HookSpec]] = []
|
|
151
|
+
for lp in self._loaded:
|
|
152
|
+
hook = lp.manifest.hook_for(stage)
|
|
153
|
+
if hook is not None:
|
|
154
|
+
out.append((lp, hook))
|
|
155
|
+
return out
|
|
156
|
+
|
|
157
|
+
def settings_schema(self) -> list[tuple[str, tuple[SettingSpec, ...]]]:
|
|
158
|
+
"""(plugin name, setting specs) for every plugin that contributes
|
|
159
|
+
settings, in registry order. Consumed by the settings pillar."""
|
|
160
|
+
return [
|
|
161
|
+
(lp.manifest.name, lp.manifest.settings) for lp in self._loaded if lp.manifest.settings
|
|
162
|
+
]
|
|
163
|
+
|
|
164
|
+
def provided_workflows(self) -> dict[str, tuple[str, ...]]:
|
|
165
|
+
"""plugin name -> declared workflow names (for introspection / docs)."""
|
|
166
|
+
return {
|
|
167
|
+
lp.manifest.name: tuple(w.name for w in lp.manifest.workflows)
|
|
168
|
+
for lp in self._loaded
|
|
169
|
+
if lp.manifest.workflows
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
def workflow_stages(self) -> frozenset[str]:
|
|
173
|
+
"""Every stage some loaded plugin binds a *still-enabled* workflow to. The
|
|
174
|
+
engine reads this once to precompute an O(1) injection guard — a run whose
|
|
175
|
+
plugins provide no workflows pays nothing at the per-stage check.
|
|
176
|
+
|
|
177
|
+
A workflow a setting has disabled (``<name>_enabled = false``) is dropped
|
|
178
|
+
here too, so the guard stays exact: when every step at a stage is turned
|
|
179
|
+
off the stage falls out of the set and the engine skips it entirely. This
|
|
180
|
+
mirrors the same skip in ``workflows_for`` (the settings-overlay
|
|
181
|
+
convention; see ``WorkflowSpec``)."""
|
|
182
|
+
return frozenset(
|
|
183
|
+
w.stage
|
|
184
|
+
for lp in self._loaded
|
|
185
|
+
for w in lp.manifest.workflows
|
|
186
|
+
if lp.settings.get(f"{w.name}_enabled") is not False
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
def workflows_for(self, stage: str) -> list[tuple[LoadedPlugin, WorkflowSpec]]:
|
|
190
|
+
"""Every (plugin, workflow) injected at ``stage``, in registry order.
|
|
191
|
+
|
|
192
|
+
Only *active* plugins contribute: a data-only/declarative plugin always,
|
|
193
|
+
an in-process plugin only once enabled+constructed (instance built). An
|
|
194
|
+
un-enabled or errored ``[python]`` plugin is inert — its workflows must
|
|
195
|
+
not fire any more than its module runs (same gate as ``_active_for_seeds``).
|
|
196
|
+
|
|
197
|
+
Settings overlay: a plugin's resolved settings can tune a workflow per
|
|
198
|
+
the ``<workflow-name>_enabled`` / ``<workflow-name>_blocking`` convention
|
|
199
|
+
(see ``WorkflowSpec``). ``<name>_enabled = false`` drops the step;
|
|
200
|
+
``<name>_blocking`` overrides the manifest's ``blocking`` flag. Absent
|
|
201
|
+
settings preserve the manifest values exactly, so a plugin that declares
|
|
202
|
+
no such settings is byte-identical to the pre-overlay behaviour."""
|
|
203
|
+
out: list[tuple[LoadedPlugin, WorkflowSpec]] = []
|
|
204
|
+
for lp in self._active_for_seeds():
|
|
205
|
+
for wf in lp.manifest.workflows_for(stage):
|
|
206
|
+
if lp.settings.get(f"{wf.name}_enabled") is False:
|
|
207
|
+
continue # a setting can disable a step
|
|
208
|
+
blocking = bool(lp.settings.get(f"{wf.name}_blocking", wf.blocking))
|
|
209
|
+
out.append((lp, wf if blocking == wf.blocking else replace(wf, blocking=blocking)))
|
|
210
|
+
return out
|
|
211
|
+
|
|
212
|
+
def instances(self) -> list[Plugin]:
|
|
213
|
+
"""Constructed, trusted, non-disabled in-process plugins."""
|
|
214
|
+
return [lp.instance for lp in self._loaded if lp.instance is not None]
|
|
215
|
+
|
|
216
|
+
def _active_for_seeds(self) -> list[LoadedPlugin]:
|
|
217
|
+
"""Plugins whose declared seeds apply: data-only/declarative plugins
|
|
218
|
+
(always active) and enabled in-process plugins (instance built). An
|
|
219
|
+
un-enabled or errored ``[python]`` plugin is inert — its module never ran,
|
|
220
|
+
so its seeds must not leak into a worktree (e.g. the Unity plugin's skill
|
|
221
|
+
tree when unity isn't enabled)."""
|
|
222
|
+
return [lp for lp in self._loaded if lp.manifest.python is None or lp.instance is not None]
|
|
223
|
+
|
|
224
|
+
def seed_files(self) -> list[str]:
|
|
225
|
+
"""Union of every active plugin's ``seed_files`` (literal project-relative
|
|
226
|
+
paths), order-preserving + deduped. Consumed by worktree provisioning so a
|
|
227
|
+
plugin can prime an isolated checkout with gitignored paths it needs."""
|
|
228
|
+
return list(
|
|
229
|
+
dict.fromkeys(f for lp in self._active_for_seeds() for f in lp.manifest.seed_files)
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
def seed_globs(self) -> list[str]:
|
|
233
|
+
"""Union of every active plugin's ``seed_globs`` (patterns expanded against
|
|
234
|
+
the main repo), order-preserving + deduped."""
|
|
235
|
+
return list(
|
|
236
|
+
dict.fromkeys(g for lp in self._active_for_seeds() for g in lp.manifest.seed_globs)
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
def validate(self, policy) -> None:
|
|
240
|
+
"""Let every constructed in-process plugin self-validate against the run
|
|
241
|
+
policy (``Plugin.validate``). A plugin raises to reject an incompatible
|
|
242
|
+
config; the registry lets it propagate so the run fails fast at startup."""
|
|
243
|
+
for lp in self._loaded:
|
|
244
|
+
if lp.instance is not None:
|
|
245
|
+
lp.instance.validate(policy)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Plugin trust + api_version compatibility.
|
|
2
|
+
|
|
3
|
+
Two tiers, per the design:
|
|
4
|
+
|
|
5
|
+
* Data-only / declarative plugins (no ``[python]``) carry no executable Python
|
|
6
|
+
— they load on folder-drop. Their shell hooks are the same risk surface as
|
|
7
|
+
today's ``engine.toml *_cmd`` and run through the bus like any command.
|
|
8
|
+
* A plugin that declares a ``[python]`` module is **never imported or
|
|
9
|
+
executed** unless its name is in ``policy.toml [plugins] enabled = [...]``.
|
|
10
|
+
Dropping a folder must never auto-run code — the registry calls
|
|
11
|
+
``require_enabled`` before ``exec_module``.
|
|
12
|
+
|
|
13
|
+
Compatibility is separate from trust: a manifest's ``api_version`` must be in
|
|
14
|
+
the framework's ``SUPPORTED_API``. The loader decides the *consequence* of a
|
|
15
|
+
mismatch (hard error for a builtin we ship, skip-with-warning for third-party).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from typing import Protocol
|
|
21
|
+
|
|
22
|
+
from .model import SUPPORTED_API, PluginError, PluginManifest
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _HasPlugins(Protocol):
|
|
26
|
+
plugins: "_PluginsPolicyLike"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class _PluginsPolicyLike(Protocol):
|
|
30
|
+
enabled: tuple[str, ...]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class PluginUntrusted(PluginError):
|
|
34
|
+
"""Raised when in-process Python is requested for a plugin that is not in
|
|
35
|
+
``[plugins] enabled``. Caught by the registry, which records the instance as
|
|
36
|
+
untrusted (not constructed) rather than crashing the run."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def enabled_names(policy: _HasPlugins | None) -> frozenset[str]:
|
|
40
|
+
"""The allowlist from ``[plugins] enabled``; empty when absent."""
|
|
41
|
+
if policy is None:
|
|
42
|
+
return frozenset()
|
|
43
|
+
plugins = getattr(policy, "plugins", None)
|
|
44
|
+
if plugins is None:
|
|
45
|
+
return frozenset()
|
|
46
|
+
return frozenset(getattr(plugins, "enabled", ()) or ())
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def is_enabled(policy: _HasPlugins | None, name: str) -> bool:
|
|
50
|
+
return name in enabled_names(policy)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def require_enabled(policy: _HasPlugins | None, name: str) -> None:
|
|
54
|
+
"""Gate in-process execution. Raises PluginUntrusted if not allowlisted."""
|
|
55
|
+
if not is_enabled(policy, name):
|
|
56
|
+
raise PluginUntrusted(
|
|
57
|
+
f"plugin {name!r} declares an in-process [python] module but is not in "
|
|
58
|
+
f"[plugins] enabled — its code will not run"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def api_supported(api_version: int) -> bool:
|
|
63
|
+
return api_version in SUPPORTED_API
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def check_api(manifest: PluginManifest) -> str | None:
|
|
67
|
+
"""Return None if the manifest's api_version is supported, else a human
|
|
68
|
+
message describing the mismatch. The loader maps this to hard-error vs skip
|
|
69
|
+
based on the manifest's source."""
|
|
70
|
+
if api_supported(manifest.api_version):
|
|
71
|
+
return None
|
|
72
|
+
return (
|
|
73
|
+
f"plugin {manifest.name!r} declares api_version {manifest.api_version}, "
|
|
74
|
+
f"unsupported by this build (supports {sorted(SUPPORTED_API)})"
|
|
75
|
+
)
|