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,145 @@
|
|
|
1
|
+
"""Discover + load plugin manifests (folder-drop now; entry-points later).
|
|
2
|
+
|
|
3
|
+
Discovery walks three sources in overlay precedence — exactly the
|
|
4
|
+
builtin-then-project pattern of ``load_profiles`` (adapters/profile.py), with an
|
|
5
|
+
entry-point source wedged in the middle as a locked future-additive seam:
|
|
6
|
+
|
|
7
|
+
builtin (froid_loop.data/plugins/*) lowest precedence
|
|
8
|
+
entry_point (froid_loop.plugins group) written, returns nothing today
|
|
9
|
+
project (<project>/.froid-loop/plugins/*) highest precedence (same-name override)
|
|
10
|
+
|
|
11
|
+
Each plugin is a directory holding ``plugin.toml`` plus any helper scripts; the
|
|
12
|
+
directory is its ``{scripts}`` dir. Resolving bundled plugins to a real
|
|
13
|
+
filesystem path assumes a regular (non-zipped) install — the same assumption the
|
|
14
|
+
rest of the package makes for packaged skills/profiles/engines.
|
|
15
|
+
|
|
16
|
+
api_version mismatch handling lives here because it is source-dependent: a
|
|
17
|
+
builtin we ship with the wrong version is a packaging bug (hard error); a
|
|
18
|
+
third-party plugin written for a newer/older API is skipped with a warning so a
|
|
19
|
+
stale drop-in can never take a run down.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import warnings
|
|
25
|
+
from collections.abc import Iterator
|
|
26
|
+
from importlib import resources
|
|
27
|
+
from importlib.resources.abc import Traversable
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
from . import trust
|
|
31
|
+
from .manifest import load_manifest
|
|
32
|
+
from .model import PluginError, PluginManifest
|
|
33
|
+
|
|
34
|
+
PLUGIN_FILE = "plugin.toml"
|
|
35
|
+
USER_PLUGINS_REL = Path(".froid-loop") / "plugins"
|
|
36
|
+
ENTRY_POINT_GROUP = "froid_loop.plugins"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _read_manifest_text(toml: Traversable | Path, source: str) -> str:
|
|
40
|
+
"""Read a plugin manifest as text, converting a read fault into PluginError.
|
|
41
|
+
|
|
42
|
+
Not reachable by widening `load_manifest`'s CONVERSION_FAULTS funnel: that
|
|
43
|
+
funnel wraps the manifest *parse*, and the decode happens in the argument
|
|
44
|
+
expression at both discovery call sites, before `load_manifest` is entered.
|
|
45
|
+
So a non-UTF-8 `plugin.toml` escaped as a raw `UnicodeDecodeError` (a
|
|
46
|
+
ValueError) while every consumer keys its fault handling on PluginError —
|
|
47
|
+
`tui/settings.py`'s `except (PolicyError, PluginError)` degrade let it
|
|
48
|
+
through and took the settings surface down at construction. The sibling
|
|
49
|
+
guard is `adapters/profile.py`'s `_read_profile_text` (#473); both-arms
|
|
50
|
+
precedent is `stories.py`'s manifest read.
|
|
51
|
+
|
|
52
|
+
Takes the packaged built-ins too. They are trusted, but a corrupt package
|
|
53
|
+
is a packaging bug and should say so with a typed error rather than a
|
|
54
|
+
traceback. One helper covers both call shapes: an `importlib.resources`
|
|
55
|
+
Traversable and a `Path` each expose ``read_text(encoding=...)``.
|
|
56
|
+
"""
|
|
57
|
+
try:
|
|
58
|
+
return toml.read_text(encoding="utf-8")
|
|
59
|
+
except UnicodeDecodeError as e:
|
|
60
|
+
raise PluginError(f"plugin {source}: not valid UTF-8: {e}") from e
|
|
61
|
+
except OSError as e:
|
|
62
|
+
# A manifest that is present but cannot be read — permissions, an I/O
|
|
63
|
+
# error, a dead mount. Discovery's `is_file()` rules out ABSENCE and
|
|
64
|
+
# nothing else, so this escaped as a bare OSError.
|
|
65
|
+
raise PluginError(f"plugin {source}: unreadable: {e}") from e
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _discover_builtin() -> Iterator[PluginManifest]:
|
|
69
|
+
packaged = resources.files("froid_loop.data").joinpath("plugins")
|
|
70
|
+
if not packaged.is_dir():
|
|
71
|
+
return
|
|
72
|
+
for entry in sorted(packaged.iterdir(), key=lambda e: e.name):
|
|
73
|
+
toml = entry.joinpath(PLUGIN_FILE)
|
|
74
|
+
if entry.is_dir() and toml.is_file():
|
|
75
|
+
source = f"{entry.name}/{PLUGIN_FILE}"
|
|
76
|
+
yield load_manifest(
|
|
77
|
+
_read_manifest_text(toml, source),
|
|
78
|
+
source,
|
|
79
|
+
str(entry),
|
|
80
|
+
origin="builtin",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _discover_entry_points() -> Iterator[PluginManifest]:
|
|
85
|
+
"""Future-additive seam for ``importlib.metadata`` entry points (group
|
|
86
|
+
``froid_loop.plugins``, the modern selectable API on Python >= 3.11). Locked
|
|
87
|
+
shut for now: folder-drop is the only distribution path, so this yields
|
|
88
|
+
nothing. Wiring it later needs no changes to callers — discovery order and
|
|
89
|
+
overlay precedence already account for this source.
|
|
90
|
+
"""
|
|
91
|
+
return
|
|
92
|
+
yield # pragma: no cover - marks this a generator without emitting
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _discover_project(project: Path) -> Iterator[PluginManifest]:
|
|
96
|
+
user_dir = project / USER_PLUGINS_REL
|
|
97
|
+
if not user_dir.is_dir():
|
|
98
|
+
return
|
|
99
|
+
for entry in sorted(user_dir.iterdir()):
|
|
100
|
+
toml = entry / PLUGIN_FILE
|
|
101
|
+
if entry.is_dir() and toml.is_file():
|
|
102
|
+
yield load_manifest(
|
|
103
|
+
_read_manifest_text(toml, str(toml)), str(toml), str(entry), origin="project"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def discover(project: Path | None = None) -> Iterator[PluginManifest]:
|
|
108
|
+
"""Yield manifests in overlay order (builtin < entry_point < project).
|
|
109
|
+
|
|
110
|
+
Later same-name manifests override earlier ones; ``load_plugins`` collapses
|
|
111
|
+
the stream into a name->manifest dict honoring that precedence.
|
|
112
|
+
"""
|
|
113
|
+
yield from _discover_builtin()
|
|
114
|
+
yield from _discover_entry_points()
|
|
115
|
+
if project is not None:
|
|
116
|
+
yield from _discover_project(project)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def load_plugins(project: Path | None = None, *, journal=None) -> dict[str, PluginManifest]:
|
|
120
|
+
"""Packaged built-ins overlaid by project-local plugins, api-checked.
|
|
121
|
+
|
|
122
|
+
A builtin with an unsupported api_version is a hard error (we shipped it); a
|
|
123
|
+
third-party one is skipped with a warning (and journalled when a journal is
|
|
124
|
+
given) so it can never crash a run.
|
|
125
|
+
"""
|
|
126
|
+
plugins: dict[str, PluginManifest] = {}
|
|
127
|
+
for manifest in discover(project):
|
|
128
|
+
problem = trust.check_api(manifest)
|
|
129
|
+
if problem is not None:
|
|
130
|
+
if manifest.source == "builtin":
|
|
131
|
+
raise PluginError(problem)
|
|
132
|
+
warnings.warn(problem, stacklevel=2)
|
|
133
|
+
if journal is not None:
|
|
134
|
+
journal.append("plugin-skipped", plugin=manifest.name, reason=problem)
|
|
135
|
+
continue
|
|
136
|
+
plugins[manifest.name] = manifest
|
|
137
|
+
return plugins
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def get_plugin(name: str, project: Path | None = None) -> PluginManifest:
|
|
141
|
+
plugins = load_plugins(project)
|
|
142
|
+
manifest = plugins.get(name)
|
|
143
|
+
if manifest is None:
|
|
144
|
+
raise PluginError(f"unknown plugin: {name!r} (available: {sorted(plugins)})")
|
|
145
|
+
return manifest
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""Parse + validate a ``plugin.toml`` into an immutable PluginManifest.
|
|
2
|
+
|
|
3
|
+
Mirrors ``engines/plugin.py`` ``_parse_plugin`` / ``_load_toml``: ``tomllib``
|
|
4
|
+
with ``TOMLDecodeError`` wrapped into a domain error, every field coerced to its
|
|
5
|
+
declared type, project-relative seed paths enforced, and a single ``fail()``
|
|
6
|
+
helper that prefixes the source for actionable messages.
|
|
7
|
+
|
|
8
|
+
Validation here is purely structural — it does not decide trust (``trust.py``)
|
|
9
|
+
or whether an api_version is supported by *this* build (the loader does, so it
|
|
10
|
+
can hard-error on a builtin but skip a third-party plugin). A manifest that
|
|
11
|
+
parses is well-formed, not necessarily loadable.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import tomllib
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from ..platform_util import (
|
|
20
|
+
has_parent_ref,
|
|
21
|
+
is_absolute_path,
|
|
22
|
+
names_tree_root,
|
|
23
|
+
names_win32_alias,
|
|
24
|
+
)
|
|
25
|
+
from .model import (
|
|
26
|
+
SETTING_TYPES,
|
|
27
|
+
WORKFLOW_ROLES,
|
|
28
|
+
WORKFLOW_STAGES,
|
|
29
|
+
HookSpec,
|
|
30
|
+
PluginError,
|
|
31
|
+
PluginManifest,
|
|
32
|
+
PythonSpec,
|
|
33
|
+
SettingSpec,
|
|
34
|
+
WorkflowSpec,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# The CLOSED set of faults a raw coercion over a `tomllib` value can raise — see
|
|
38
|
+
# `adapters/profile.py::CONVERSION_FAULTS` for the nine-type enumeration behind it
|
|
39
|
+
# (the `inf`/`-inf` and oversized-int OverflowError rows are the ones a per-field
|
|
40
|
+
# guard keeps missing). Restated here rather than imported: the plugin layer does
|
|
41
|
+
# not otherwise depend on the adapter layer, and each module's own domain test
|
|
42
|
+
# pins its copy.
|
|
43
|
+
CONVERSION_FAULTS = (AttributeError, OverflowError, TypeError, ValueError)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _str_list(plugin_d: dict, key: str, fail) -> tuple[str, ...]:
|
|
47
|
+
# Shape before entries, the same rule the sibling seed sources apply to their
|
|
48
|
+
# own lists (policy.py `worktree_seed`, adapters/profile.py `str_list`): a
|
|
49
|
+
# bare string iterates into per-character entries that each pass the
|
|
50
|
+
# per-entry guard below, and a scalar raises a bare TypeError out of `loads`
|
|
51
|
+
# where every other malformed value here raises PluginError.
|
|
52
|
+
raw = plugin_d.get(key, ())
|
|
53
|
+
if isinstance(raw, (str, bytes)) or not isinstance(raw, (list, tuple)):
|
|
54
|
+
raise fail(f"[plugin] {key} must be a list of paths: got {raw!r}")
|
|
55
|
+
if not all(isinstance(s, str) for s in raw):
|
|
56
|
+
raise fail(f"[plugin] {key} entries must be strings: got {list(raw)!r}")
|
|
57
|
+
return tuple(raw)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _check_relative_paths(values: tuple[str, ...], label: str, fail) -> None:
|
|
61
|
+
# `names_tree_root` subsumes the emptiness check it replaced: "", ".", "./" and
|
|
62
|
+
# ".\" all name the tree rather than anything in it, and a seed entry that names
|
|
63
|
+
# the tree root makes provision_worktree copy the whole repo into the worktree.
|
|
64
|
+
#
|
|
65
|
+
# The second refusal is a SEPARATE arm rather than a fourth term in the first,
|
|
66
|
+
# because the first one's message is false for what it catches: `NUL` and
|
|
67
|
+
# `skills.` ARE project-relative. What they are not is deterministic — each names
|
|
68
|
+
# a different path on Windows than the string spells, so the same manifest seeds a
|
|
69
|
+
# different file (or a device) depending on where the run happens. One call site
|
|
70
|
+
# guards BOTH `seed_files` and `seed_globs`; see `names_win32_alias`'s docstring
|
|
71
|
+
# for the two rules and their sources.
|
|
72
|
+
for value in values:
|
|
73
|
+
if names_tree_root(value) or is_absolute_path(value) or has_parent_ref(value):
|
|
74
|
+
raise fail(f"{label} entries must be project-relative paths: got {value!r}")
|
|
75
|
+
if names_win32_alias(value):
|
|
76
|
+
raise fail(
|
|
77
|
+
f"{label} entries must not name a Windows device or end a component "
|
|
78
|
+
f"in a period or space: got {value!r}"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _parse_hooks(hooks_d: Any, fail) -> tuple[HookSpec, ...]:
|
|
83
|
+
if not hooks_d:
|
|
84
|
+
return ()
|
|
85
|
+
if not isinstance(hooks_d, dict):
|
|
86
|
+
raise fail("[hooks] must be a table of [hooks.<stage>] tables")
|
|
87
|
+
hooks = []
|
|
88
|
+
for stage, raw in hooks_d.items():
|
|
89
|
+
if not isinstance(raw, dict):
|
|
90
|
+
raise fail(f"[hooks.{stage}] must be a table")
|
|
91
|
+
cmd = str(raw.get("cmd", ""))
|
|
92
|
+
if not cmd:
|
|
93
|
+
raise fail(f"[hooks.{stage}] requires a 'cmd'")
|
|
94
|
+
timeout = int(raw.get("timeout_sec", 120))
|
|
95
|
+
if timeout < 1:
|
|
96
|
+
raise fail(f"[hooks.{stage}] timeout_sec must be >= 1: got {timeout}")
|
|
97
|
+
hooks.append(
|
|
98
|
+
HookSpec(
|
|
99
|
+
stage=str(stage),
|
|
100
|
+
cmd=cmd,
|
|
101
|
+
timeout_sec=timeout,
|
|
102
|
+
blocking=bool(raw.get("blocking", False)),
|
|
103
|
+
fail_closed=bool(raw.get("fail_closed", False)),
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
return tuple(hooks)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _parse_settings(settings_l: Any, fail) -> tuple[SettingSpec, ...]:
|
|
110
|
+
if not settings_l:
|
|
111
|
+
return ()
|
|
112
|
+
if not isinstance(settings_l, list):
|
|
113
|
+
raise fail("[[settings]] must be an array of tables")
|
|
114
|
+
specs: list[SettingSpec] = []
|
|
115
|
+
seen: set[str] = set()
|
|
116
|
+
for raw in settings_l:
|
|
117
|
+
if not isinstance(raw, dict):
|
|
118
|
+
raise fail("each [[settings]] entry must be a table")
|
|
119
|
+
key = str(raw.get("key", "")).strip()
|
|
120
|
+
if not key:
|
|
121
|
+
raise fail("each [[settings]] entry requires a 'key'")
|
|
122
|
+
if key in seen:
|
|
123
|
+
raise fail(f"duplicate setting key: {key!r}")
|
|
124
|
+
seen.add(key)
|
|
125
|
+
kind = str(raw.get("type", "")).strip()
|
|
126
|
+
if kind not in SETTING_TYPES:
|
|
127
|
+
raise fail(f"setting {key!r} type must be one of {sorted(SETTING_TYPES)}: got {kind!r}")
|
|
128
|
+
options = tuple(str(o) for o in raw.get("options", ()))
|
|
129
|
+
if kind == "select" and not options:
|
|
130
|
+
raise fail(f"select setting {key!r} requires a non-empty 'options' list")
|
|
131
|
+
specs.append(
|
|
132
|
+
SettingSpec(
|
|
133
|
+
key=key,
|
|
134
|
+
type=kind,
|
|
135
|
+
default=raw.get("default"),
|
|
136
|
+
help=str(raw.get("help", "")),
|
|
137
|
+
options=options,
|
|
138
|
+
label=str(raw.get("label", "")),
|
|
139
|
+
min=raw.get("min"),
|
|
140
|
+
max=raw.get("max"),
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
return tuple(specs)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _parse_workflows(workflows_d: Any, fail) -> tuple[WorkflowSpec, ...]:
|
|
147
|
+
"""Parse ``[workflows.<name>]`` tables — the ``[provides]`` surface. Each is a
|
|
148
|
+
stage-bound session injection; mirrors ``_parse_hooks`` (name as the table
|
|
149
|
+
key, like a hook's stage). ``stage`` and ``role`` are validated against the
|
|
150
|
+
framework's small allowlists so a typo fails loudly at load rather than
|
|
151
|
+
silently never firing."""
|
|
152
|
+
if not workflows_d:
|
|
153
|
+
return ()
|
|
154
|
+
if not isinstance(workflows_d, dict):
|
|
155
|
+
raise fail("[workflows] must be a table of [workflows.<name>] tables")
|
|
156
|
+
specs: list[WorkflowSpec] = []
|
|
157
|
+
for name, raw in workflows_d.items():
|
|
158
|
+
if not isinstance(raw, dict):
|
|
159
|
+
raise fail(f"[workflows.{name}] must be a table")
|
|
160
|
+
stage = str(raw.get("stage", "")).strip()
|
|
161
|
+
if stage not in WORKFLOW_STAGES:
|
|
162
|
+
raise fail(
|
|
163
|
+
f"[workflows.{name}] stage must be one of {sorted(WORKFLOW_STAGES)}: got {stage!r}"
|
|
164
|
+
)
|
|
165
|
+
role = str(raw.get("role", "dev")).strip() or "dev"
|
|
166
|
+
if role not in WORKFLOW_ROLES:
|
|
167
|
+
raise fail(
|
|
168
|
+
f"[workflows.{name}] role must be one of {sorted(WORKFLOW_ROLES)}: got {role!r}"
|
|
169
|
+
)
|
|
170
|
+
prompt = str(raw.get("prompt", ""))
|
|
171
|
+
if not prompt:
|
|
172
|
+
raise fail(f"[workflows.{name}] requires a 'prompt'")
|
|
173
|
+
specs.append(
|
|
174
|
+
WorkflowSpec(
|
|
175
|
+
name=str(name),
|
|
176
|
+
stage=stage,
|
|
177
|
+
role=role,
|
|
178
|
+
prompt=prompt,
|
|
179
|
+
blocking=bool(raw.get("blocking", False)),
|
|
180
|
+
)
|
|
181
|
+
)
|
|
182
|
+
return tuple(specs)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _parse_python(python_d: Any, fail) -> PythonSpec | None:
|
|
186
|
+
if python_d is None:
|
|
187
|
+
return None
|
|
188
|
+
if not isinstance(python_d, dict):
|
|
189
|
+
raise fail("[python] must be a table")
|
|
190
|
+
# `.strip()` decides only whether a module was given — the authored value is
|
|
191
|
+
# what gets validated and stored. Stripping first silently normalized the
|
|
192
|
+
# trailing-space spelling the alias arm below promises to refuse
|
|
193
|
+
# (`module = "hooks.py "` was trimmed and accepted), making this the one
|
|
194
|
+
# site of seven whose value the family never saw raw.
|
|
195
|
+
module = str(python_d.get("module", ""))
|
|
196
|
+
if not module.strip():
|
|
197
|
+
raise fail("[python] requires a 'module'")
|
|
198
|
+
if names_tree_root(module) or is_absolute_path(module) or has_parent_ref(module):
|
|
199
|
+
raise fail(f"[python] module must be a plugin-relative path: got {module!r}")
|
|
200
|
+
# Separate arm, same reason as `_check_relative_paths`, and it bites harder here:
|
|
201
|
+
# this value is not copied but *imported*, so a module spelled `NUL` or `hooks.`
|
|
202
|
+
# resolves on Windows to something other than the file the manifest names.
|
|
203
|
+
if names_win32_alias(module):
|
|
204
|
+
raise fail(
|
|
205
|
+
"[python] module must not name a Windows device or end a component "
|
|
206
|
+
f"in a period or space: got {module!r}"
|
|
207
|
+
)
|
|
208
|
+
return PythonSpec(module=module, cls=str(python_d.get("class", "Plugin")) or "Plugin")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def parse_manifest(
|
|
212
|
+
doc: dict, source: str, scripts_dir: str, origin: str = "project"
|
|
213
|
+
) -> PluginManifest:
|
|
214
|
+
def fail(msg: str) -> PluginError:
|
|
215
|
+
return PluginError(f"plugin {source}: {msg}")
|
|
216
|
+
|
|
217
|
+
plugin_d = doc.get("plugin")
|
|
218
|
+
if not isinstance(plugin_d, dict):
|
|
219
|
+
raise fail("missing [plugin] table")
|
|
220
|
+
|
|
221
|
+
name = str(plugin_d.get("name", "")).strip()
|
|
222
|
+
if not name:
|
|
223
|
+
raise fail("[plugin] 'name' is required")
|
|
224
|
+
|
|
225
|
+
raw_api = plugin_d.get("api_version")
|
|
226
|
+
if raw_api is None:
|
|
227
|
+
raise fail("[plugin] 'api_version' is required")
|
|
228
|
+
try:
|
|
229
|
+
api_version = int(raw_api)
|
|
230
|
+
except CONVERSION_FAULTS:
|
|
231
|
+
raise fail(f"[plugin] api_version must be an integer: got {raw_api!r}") from None
|
|
232
|
+
|
|
233
|
+
seed_files = _str_list(plugin_d, "seed_files", fail)
|
|
234
|
+
_check_relative_paths(seed_files, "seed_files", fail)
|
|
235
|
+
seed_globs = _str_list(plugin_d, "seed_globs", fail)
|
|
236
|
+
_check_relative_paths(seed_globs, "seed_globs", fail)
|
|
237
|
+
|
|
238
|
+
return PluginManifest(
|
|
239
|
+
name=name,
|
|
240
|
+
version=str(plugin_d.get("version", "0.0.0")),
|
|
241
|
+
api_version=api_version,
|
|
242
|
+
description=str(plugin_d.get("description", "")),
|
|
243
|
+
author=str(plugin_d.get("author", "")),
|
|
244
|
+
hooks=_parse_hooks(doc.get("hooks"), fail),
|
|
245
|
+
settings=_parse_settings(doc.get("settings"), fail),
|
|
246
|
+
python=_parse_python(doc.get("python"), fail),
|
|
247
|
+
workflows=_parse_workflows(doc.get("workflows"), fail),
|
|
248
|
+
seed_files=seed_files,
|
|
249
|
+
seed_globs=seed_globs,
|
|
250
|
+
priority=int(plugin_d.get("priority", 0)),
|
|
251
|
+
scripts_dir=scripts_dir,
|
|
252
|
+
source=origin,
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def load_manifest(
|
|
257
|
+
text: str, source: str, scripts_dir: str, origin: str = "project"
|
|
258
|
+
) -> PluginManifest:
|
|
259
|
+
try:
|
|
260
|
+
doc = tomllib.loads(text)
|
|
261
|
+
except tomllib.TOMLDecodeError as e:
|
|
262
|
+
raise PluginError(f"plugin {source}: invalid TOML: {e}") from e
|
|
263
|
+
try:
|
|
264
|
+
return parse_manifest(doc, source, scripts_dir, origin)
|
|
265
|
+
except PluginError:
|
|
266
|
+
raise # intent: a domain error is never re-wrapped (it is not a CONVERSION_FAULT)
|
|
267
|
+
except CONVERSION_FAULTS as e:
|
|
268
|
+
# A funnel, not per-field guards — the `_load_toml` arm of
|
|
269
|
+
# adapters/profile.py, same reason: `parse_manifest`'s raw conversions
|
|
270
|
+
# (`int()` on `priority`, `api_version` and a hook's `timeout_sec`,
|
|
271
|
+
# iteration over a setting's `options`) raise bare conversion errors on
|
|
272
|
+
# TOML-legal values of the wrong type, and every consumer keys its fault
|
|
273
|
+
# handling on PluginError — the TUI's settings pane reports it beside a
|
|
274
|
+
# PolicyError, and `settings_schema`/`PluginRegistry.build` reach it
|
|
275
|
+
# through `load_plugins`. A bare escape crashed `validate` before any
|
|
276
|
+
# document was printed. The tuple is that module's CLOSED set for the
|
|
277
|
+
# `tomllib` value domain, shared so the two parsers cannot drift apart
|
|
278
|
+
# one exception type at a time.
|
|
279
|
+
raise PluginError(f"plugin {source}: malformed field value: {e}") from e
|