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.
Files changed (116) hide show
  1. froid_loop/__init__.py +11 -0
  2. froid_loop/__main__.py +12 -0
  3. froid_loop/adapters/__init__.py +3 -0
  4. froid_loop/adapters/base.py +254 -0
  5. froid_loop/adapters/entrypoints.py +63 -0
  6. froid_loop/adapters/env_fault.py +290 -0
  7. froid_loop/adapters/generic.py +2013 -0
  8. froid_loop/adapters/mock.py +49 -0
  9. froid_loop/adapters/multiplexer.py +914 -0
  10. froid_loop/adapters/opencode_http.py +1687 -0
  11. froid_loop/adapters/profile.py +650 -0
  12. froid_loop/adapters/psmux_backend.py +1428 -0
  13. froid_loop/adapters/registry.py +322 -0
  14. froid_loop/adapters/tmux_backend.py +35 -0
  15. froid_loop/adapters/tmux_base.py +630 -0
  16. froid_loop/checks.py +187 -0
  17. froid_loop/cli.py +5041 -0
  18. froid_loop/data/__init__.py +0 -0
  19. froid_loop/data/froid_loop_hook.py +228 -0
  20. froid_loop/data/froid_loop_probe_hook.py +88 -0
  21. froid_loop/data/plugins/example/plugin.toml +21 -0
  22. froid_loop/data/plugins/tea/plugin.toml +184 -0
  23. froid_loop/data/plugins/tea/tea_plugin.py +258 -0
  24. froid_loop/data/plugins/unity/plugin.toml +140 -0
  25. froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef +16 -0
  26. froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef.meta +7 -0
  27. froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs +221 -0
  28. froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs.meta +11 -0
  29. froid_loop/data/plugins/unity/unity_assets/_folders/Editor.meta +8 -0
  30. froid_loop/data/plugins/unity/unity_assets/_folders/FroidLoop.meta +8 -0
  31. froid_loop/data/plugins/unity/unity_cleanup.py +125 -0
  32. froid_loop/data/plugins/unity/unity_dialog_probe.py +239 -0
  33. froid_loop/data/plugins/unity/unity_facts.md +17 -0
  34. froid_loop/data/plugins/unity/unity_plugin.py +415 -0
  35. froid_loop/data/plugins/unity/unity_quiesce.py +234 -0
  36. froid_loop/data/plugins/unity/unity_ready.py +230 -0
  37. froid_loop/data/plugins/unity/unity_seed_assets.py +298 -0
  38. froid_loop/data/plugins/unity/unity_setup.py +551 -0
  39. froid_loop/data/plugins/unity/unity_teardown.py +362 -0
  40. froid_loop/data/profiles/antigravity.toml +52 -0
  41. froid_loop/data/profiles/claude.toml +85 -0
  42. froid_loop/data/profiles/codex.toml +22 -0
  43. froid_loop/data/profiles/copilot.toml +52 -0
  44. froid_loop/data/profiles/gemini.toml +26 -0
  45. froid_loop/data/profiles/opencode.toml +54 -0
  46. froid_loop/data/settings/core.toml +458 -0
  47. froid_loop/data/skills/README.md +93 -0
  48. froid_loop/data/skills/froid-loop-resolve/SKILL.md +288 -0
  49. froid_loop/data/skills/froid-loop-setup/SKILL.md +161 -0
  50. froid_loop/data/skills/froid-loop-setup/assets/module-help.csv +3 -0
  51. froid_loop/data/skills/froid-loop-setup/assets/module.yaml +19 -0
  52. froid_loop/data/skills/froid-loop-sweep/SKILL.md +100 -0
  53. froid_loop/data/skills/froid-loop-sweep/automation-mode.md +127 -0
  54. froid_loop/data/skills/froid-loop-sweep/deferred-work-format.md +302 -0
  55. froid_loop/data/skills/froid-loop-sweep/migration-mode.md +86 -0
  56. froid_loop/decisions.py +202 -0
  57. froid_loop/deferredwork.py +2282 -0
  58. froid_loop/devcontract.py +892 -0
  59. froid_loop/diagnostics.py +1104 -0
  60. froid_loop/documents.py +532 -0
  61. froid_loop/engine.py +7732 -0
  62. froid_loop/envvars.py +111 -0
  63. froid_loop/escalation.py +225 -0
  64. froid_loop/events.py +266 -0
  65. froid_loop/fences.py +103 -0
  66. froid_loop/froidconfig.py +226 -0
  67. froid_loop/frontmatter.py +526 -0
  68. froid_loop/gates.py +133 -0
  69. froid_loop/install.py +2936 -0
  70. froid_loop/journal.py +178 -0
  71. froid_loop/machine.py +148 -0
  72. froid_loop/model.py +898 -0
  73. froid_loop/operatoractions.py +474 -0
  74. froid_loop/platform_util.py +1490 -0
  75. froid_loop/plugins/__init__.py +64 -0
  76. froid_loop/plugins/bus.py +259 -0
  77. froid_loop/plugins/context.py +319 -0
  78. froid_loop/plugins/loader.py +145 -0
  79. froid_loop/plugins/manifest.py +279 -0
  80. froid_loop/plugins/model.py +296 -0
  81. froid_loop/plugins/registry.py +245 -0
  82. froid_loop/plugins/trust.py +75 -0
  83. froid_loop/policy.py +1569 -0
  84. froid_loop/probe.py +1044 -0
  85. froid_loop/process_host.py +408 -0
  86. froid_loop/recovery_flow.py +1561 -0
  87. froid_loop/resolve.py +283 -0
  88. froid_loop/runs.py +4715 -0
  89. froid_loop/runsetup.py +1293 -0
  90. froid_loop/sanitize.py +593 -0
  91. froid_loop/settings_schema.py +276 -0
  92. froid_loop/signals.py +160 -0
  93. froid_loop/sprintstatus.py +609 -0
  94. froid_loop/statemachine.py +57 -0
  95. froid_loop/stories.py +615 -0
  96. froid_loop/stories_engine.py +796 -0
  97. froid_loop/sweep.py +1892 -0
  98. froid_loop/tokens.py +196 -0
  99. froid_loop/tui/__init__.py +11 -0
  100. froid_loop/tui/app.py +1584 -0
  101. froid_loop/tui/data.py +840 -0
  102. froid_loop/tui/launch.py +1003 -0
  103. froid_loop/tui/screens/__init__.py +1 -0
  104. froid_loop/tui/screens/dashboard.py +1071 -0
  105. froid_loop/tui/screens/modals.py +943 -0
  106. froid_loop/tui/screens/settings_screen.py +477 -0
  107. froid_loop/tui/settings.py +135 -0
  108. froid_loop/tui/widgets.py +981 -0
  109. froid_loop/verify.py +4545 -0
  110. froid_loop/workspace.py +320 -0
  111. froid_loop/worktree_flow.py +2301 -0
  112. froid_loop-0.11.1.dist-info/METADATA +728 -0
  113. froid_loop-0.11.1.dist-info/RECORD +116 -0
  114. froid_loop-0.11.1.dist-info/WHEEL +4 -0
  115. froid_loop-0.11.1.dist-info/entry_points.txt +2 -0
  116. 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
+ )