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