Attempt 1 — 2026-05-18 — GREEN (phase-story-executor)¶
Outcome:GREEN. All 11 acceptance criteria satisfied by 16 named
tests (15 unit + 3 fence — overlap on the Plugin surface freeze). Full
make check clean (4236 passed, 33 skipped, 2 xfailed; ruff,
lint-imports 4-contracts-kept, mypy --strict on 149 source files, fence
89 passed).
src/codegenie/plugins/protocols.py — three @runtime_checkable Protocols; Plugin has exactly 4 public members (fence verifies); Adapter ships primitive only; RecipeEngine ships kind + applies + apply. Surface annotations stay strings via from __future__ import annotations.
AC-2
tests/unit/plugins/test_registry.py::test_register_method_returns_plugin_unchanged (register(plugin) is plugin); ::test_all_returns_tuple_not_list (tuple, not list); ::test_get_returns_registered_plugin (round-trip); ::test_resolve_stub_names_s2_04 (NotImplementedError with "S2-04" substring).
AC-3
::test_register_plugin_returns_plugin_unchanged — register_plugin(p, registry=r) is p identity assertion.
AC-4
::test_collision_raises asserts exc.name == PluginId(...) + both _FakePlugin qualnames in message (count == 2). ::test_get_unknown_raises_plugin_not_registered_with_typed_name asserts .name == PluginId(...). src/codegenie/plugins/errors.py ships PluginExtendsCycle + PluginRejected placeholders for the S2-03/S2-04 raise sites.
::test_fresh_registries_are_isolated — both positive control (reg_a.all() == (p,)) AND negative control (reg_b.all() == ()). ::test_register_plugin_into_explicit_registry_does_not_pollute_default is the function-scoped pollution pin. Session-scoped autouse _default_registry_session_guard re-asserts byte-identical equality at teardown (tests/unit/plugins/conftest.py).
AC-7
tests/fence/test_plugin_protocol_frozen.py — three assertions: exact-set equality on dir(Plugin) ∪ __annotations__ minus dunders; "manifest" in Plugin.__annotations__; inspect.isfunction on the three methods. Explicitly does NOT use __abstractmethods__ (the validator-named landmine — Protocol doesn't populate it like ABCs do, hit during the red→green transition on first run).
AC-8
::test_runtime_checkable_protocols_match_fakes — both isinstance(fake, Plugin) is True AND isinstance(object(), Plugin) is False. Catches a trivially-passing always-True mutant.
AC-9
src/codegenie/plugins/resolution.py ships class PluginResolution: ... placeholder; module docstring names S2-04 as the expander.
AC-10
::test_register_plugin_default_singleton_path — register_plugin(p) without registry= kwarg; asserts p in default_registry.all(); cleanup via autouse restore_default_registry.
AC-11
make check clean — ruff check src/codegenie/plugins/, ruff format --check, mypy --strict src/codegenie/ (149 source files), tests/fence/ (89 passed).
Module-docstring rule-of-three observation lifted verbatim from
depgraph/registry.py:30-38 (Phase-3 Design-Patterns critic F1).
Names all four registries; pins the extract trigger ("N=5 OR a new
registry needs only the common surface"); cites this story as the
audit anchor. Kernel-extract still deferred — Rule 2 (resolve-machinery
will dominate LOC once S2-04 ships).
Final on default_registry is intentionally tighter than
probes/registry.py:238's loose binding; ADR-0002 §Consequences row 2
names the posture. Replacement via DI through register_plugin(...,
registry=...) is the only intended path.
Origin-string sidecar (_origins: dict[PluginId, str]) mirrors
indices/registry.py:101-105 so the collision message can name both
call sites without re-introspecting a possibly-mutated existing plugin.
register_plugin is a function call, not a class decorator (Notes
§3) — module docstring carries the rationale (plugins are instances
with composed state, not classes). Three sibling registries use
decorators because they register classes; this one registers
instances.
NO __slots__ on PluginRegistry — precedent probes/registry.py
doesn't use it; profile-driven only.
NO unregister_for_tests helper — the conftest fixture restores
the dict in-place. indices/registry.py:198-208 ships
unregister_for_tests because production code registers into its
singleton in Phase 2; S2-01's production registration doesn't land
until S2-03's loader, so the helper is YAGNI.
No Any in src/codegenie/plugins/ (the test_no_any_in_plugin_surface.py
fence verifies).
No LLM-SDK imports (the test_no_llm_in_transforms.py fence + the
codegenie.plugins must not import LLM SDKs import-linter contract
verify).
No raw str for PluginId in production code; the single boundary
lift lives in the test fixture (tests/fixtures/plugins/fake_plugin.py).
No module-level mutable singletons — default_registry is a Final
instance, not a dict (ADR-0002 §Decision §Option C).
No side effects in constructors / at module import — PluginRegistry.__init__
is two assignments; module top-level only constructs the one
default_registry instance.
No premature kernel-extract across the four registries (Rule 2; the
observation IS the deferral — documented in the module docstring).
@runtime_checkable Protocols don't populate __abstractmethods__.
The story's validation explicitly called this out (Test-Quality F10),
and the first attempt's fence test would have tripped on it had we
followed the validator's call to "use dir(Plugin) - dunders +
__annotations__" naively. The actual landmine: dir(Plugin) ALSO
omits attribute-only annotations (manifest) on every Python version
I tested. The fence test needs the union of dir(Plugin) and
__annotations__.keys() to assert the four-member surface.
Module-docstring narrative strings are grep-able. Phase 2's
test_zero_strategies_registered_in_phase2 does a literal substring
search for @register_dep_graph_strategy across src/codegenie/.
Referencing the decorator name with the @ prefix in narrative prose
(even inside a multi-line docstring) trips it. Two safe avoidances:
drop the @ prefix (the narrative meaning survives), or use a
sufficiently distinct phrasing. Took the first path; relevant for any
future module docstring that names sibling registries' decorators.
Forward-ref Protocol attributes need a concrete shape under
mypy --strict.class PluginManifest: ... with no fields tripped
[attr-defined] on plugin.manifest.name. Solution: stub the field
on the forward-ref class (name: PluginId); S2-02 replaces the stub
with the full Pydantic model. The pattern is local to
TYPE_CHECKING-only blocks — runtime is unaffected.