Skip to content

Latest commit

 

History

History
382 lines (243 loc) · 26.7 KB

v0.8.0-improvements-triage-2026-05-20.md

File metadata and controls

382 lines (243 loc) · 26.7 KB

v0.8.0 Improvements Triage

Status: Design-locked 2026-05-20. Q1 and Q4 resolved for v0.8.0. Q2 (event-hook design) and Q3 (richer PluginState) shipped in v0.9.0 — see v0.9.0 design doc. Implementation can proceed on feature/v0.8.0-improvements. Date: 2026-05-20 Target release: v0.8.0 (P0 + P0.5 + selected P2/P3), v0.9.0+ (P1 + deferred items) Source: pluginforge-improvements-brief-2026-05-20.md Reviewers: PluginForge maintainer (Aster), Bibliogon Strategic-Advisor (chat-side), Bibliogon CC agent (insider)

Context

The Bibliogon brief proposes ten improvement areas grouped P0..P3. Per the brief's own "Deliverables order" and the project .claude/rules/ai-workflow.md "Pre-Coding-Reality-Check" rule, this triage:

  1. Records the verified baseline state of PluginForge at HEAD bfacced (v0.7.0).
  2. Decides ACCEPT / EXTEND / DEFER / REJECT per area with rationale anchored in the code.
  3. Surfaces open architecture questions before any per-area Pre-Inspection.
  4. Proposes a sequencing for v0.8.0 and v0.9.0.

No code is written in this pass.

Verified baseline (PluginForge HEAD bfacced)

  • Version: 0.7.0 (pyproject + __version__).
  • Test sweep: poetry run pytest --tb=no -q -> 257 passed, 3 skipped, 96% coverage (pluginforge/manager.py 97%, lifecycle.py 100%, fastapi_ext.py not separately reported but exercised).
  • Public API (pluginforge/__init__.py __all__): BasePlugin, CircularDependencyError, DiscoveryDiff, DiscoveryResult, InvalidPluginNameError, PluginError, PluginManager, PluginState.
  • Already shipped surfaces the brief asks for (or partially asks for):
    • PluginManager.get_last_discovery_result() -> DiscoveryResult | None (manager.py:306)
    • PluginManager.health_check() aggregator over plugin.health() (manager.py:861)
    • BasePlugin.health() default-OK with override hook (base.py:88)
    • PluginManager.reload_plugin(name) with deactivate + re-import + re-init + re-activate (manager.py:875)
    • PluginLifecycle.activate_plugin wraps plugin.activate() in try/except, captures the exception in _last_errors, logs at ERROR, returns False. One broken plugin does not crash startup (lifecycle.py:99-108).
    • DiscoveryResult carries per-plugin PluginState with activation status, errors, and filter reasons (v0.7.0).

These shipped surfaces narrow the scope of three brief items (P1.1, P3 Health-Check, P3 error-recovery) — see per-area triage below.

  • CLAUDE.md drift: says "v0.5.0", "162 tests", "93% coverage". Real values are v0.7.0, 257+3, 96%. CLAUDE.md needs a maintenance pass alongside any v0.8.0 work; not blocking triage.

Triage table

# Area Brief priority Triage Target Notes
P0 Recursion-safe plugin-loading P0 ACCEPT 0.8.0 Q1 resolved: module-level WeakKeyDictionary in fastapi_ext.py. Red-verify canary against unfixed code before applying fix.
P0.5 Single-router-per-plugin convention P0.5 ACCEPT (doc + warning only) 0.8.0 Cheap. Defer hard-fail to 0.9.0+.
P1.1 Lifecycle state visibility P1 EXTEND (narrowed) 0.9.0 get_last_discovery_result() and per-plugin PluginState already exist. Open scope: event hooks, richer state fields.
P1.2 Plugin cross-communication (Service-Registry) P1 DEFER (RFC) 0.9.0 Brief's own insider caveat: lazy-import works for two plugins. Needs an RFC before code.
P2.1 Config schema validation P2 DEFER 0.9.0 Q3 resolved: stay framework-agnostic, no Pydantic dependency in 0.8.0 or 0.9.0.
P2.2 Test-helper library (pluginforge.testing) P2 ACCEPT (scoped) 0.8.0 Ship IsolatedPluginManager + MockPlugin in 0.8.0. Defer the broader fixture catalog to 0.9.0.
P3.1 Unified hot-reload + ZIP path P3 DEFER 0.10.0+ ZIP-install path is Bibliogon-specific. Document the two paths; do not unify yet.
P3.2 Plugin metrics P3 DEFER 0.10.0+ Opt-in metrics need a consumer outside Bibliogon to validate shape.
P3.3 Bidirectional compat matrix P3 DEFER 0.10.0+ Precondition: Bibliogon-side PLUGIN-VERSION-GATING-ENABLE-01 ships first (confirms the existing one-direction gate works in practice).
P3.4 Health-Check API P3 PARTIAL ACCEPT (URL surface only) 0.8.0 stretch BasePlugin.health() already exists. New work is just the canonical FastAPI endpoint.
P3.5 Error-recovery during activate() P3 REJECT (already shipped) n/a lifecycle.py:99-108 already wraps in try/except. Brief author likely working from a pre-v0.6.0 mental model.
P3.6 FilterReason i18n hook P3 ACCEPT 0.8.0 stretch Cheap. Canonical English keys + translator-registration hook.
P3.7 Documentation gaps P3 ACCEPT (staged) 0.8.0 + 0.9.0 C7: consumer guide complete + plugin-author guide skeleton. Full plugin-author depth deferred to 0.9.0.

Per-area triage detail

P0 — Recursion-safe plugin-loading [ACCEPT]

Evidence in PluginForge code:

pluginforge/fastapi_ext.py mount_plugin_routes (line 32-38) iterates plugin.get_routes() and calls app.include_router(router, prefix=prefix) unconditionally. No idempotency check, no _mounted_plugins tracking. The brief's "Path A" diagnosis (include_router accumulating across TestClient lifespans) matches the implementation.

What the brief asks for that I agree with:

  • Idempotent mount_plugin_routes (Path A) as the root-cause fix.
  • A PluginForge-side canary test that mounts N plugins x M routes x K TestClient lifespans and asserts no RecursionError under default sys.setrecursionlimit().
  • Defense-in-depth IsolatedPluginManager test fixture (Path B), shipped alongside in pluginforge.testing (see P2.2).

What requires more investigation before fix-shape lock-in:

  • The brief's hypothesis ("FastAPI's recursive route-resolution exceeds Python's default recursion limit") is plausible but not verified from PluginForge's side. The recursion stack in the Bibliogon traceback (starlette.testclient.TestClient.wait_startup) implicates the TestClient lifespan stack, not the route table lookup. Possible alternate or compounding causes:
    • starlette/anyio task-group recursion across nested lifespan contexts.
    • Each app.include_router call re-running every plugin's startup hook through the lifespan manager.
    • A reference cycle on PluginManager retaining references to lifespan-scoped objects.
  • Before writing code, the canary regression test (deliverable #1) must reproduce the cascade in PluginForge's own test suite. If idempotent mounting closes the cascade, ship Path A. If it does not, the issue is upstream-FastAPI or TestClient lifecycle and Path A alone is insufficient.
  • State-tracking question (Open Question Q1 below): the idempotency check needs to live somewhere. On the FastAPI app (attribute), on the PluginManager (set), or via routes-list inspection per call. Each has trade-offs.

Recommended Pre-Inspection deliverable (when this area opens for code-work):

  1. Reproduce the cascade in tests/test_fastapi_ext_cascade.py (new). Parametrized fixture: N plugins, M routes/plugin, K TestClient lifespan cycles. Without the fix, the test must fail with RecursionError under default recursion limit. This is the canary.
  2. Implement Path A idempotency. Confirm canary passes.
  3. Add IsolatedPluginManager to pluginforge.testing (overlap with P2.2). Document the fixture as the recommended pattern for consumers running long pytest sweeps.
  4. Confirm 257 baseline tests stay green. No public API breakage.

Decision (2026-05-20): Approach Q1 resolved as modified-(b) — module-level WeakKeyDictionary in pluginforge/fastapi_ext.py, NOT on PluginManager (preserves the framework-agnostic posture of the manager; see Q1 decision below for full rationale). Canary threshold: default 20 plugins x 10 routes x 50 lifespans (10,000 includes) for CI runtime; full-scale 50 x 20 x 100 (100,000 includes) lives in the same test file under @pytest.mark.slow and runs when PLUGINFORGE_CANARY_FULL=1. The default smaller threshold MUST reproduce the cascade against unfixed code; if it does not, STOP and escalate before reducing further.


P0.5 — Single-router-per-plugin convention [ACCEPT]

Evidence in PluginForge code: base.py:72 get_routes(self) -> list permits returning multiple routers. Today no warning is emitted when a plugin returns more than one.

Scope:

  • Add a DeprecationWarning in mount_plugin_routes when len(plugin.get_routes()) > 1, with a clear suggestion to use parent_router.include_router(sub_router).
  • Document the convention in the plugin-author guide (part of P3.7).
  • Do not hard-fail. The contract today permits multiple; deprecating it across one full minor (0.8.0 warn, 0.9.0 still warn, 0.10.0 optionally error) gives downstream consumers time to adopt.

Optional, deferred: pluginforge.routing.compose_routers(parent_prefix, *subs) helper. Stretch; ship if cheap, skip if it complicates the routing API surface.

Decision (2026-05-20): Deprecation cycle confirmed: warn in 0.8.0, continue warning in 0.9.0, optionally error in 0.10.0. One full minor between warn and error minimum. Plugin-author convention also documented in the plugin-author guide skeleton (P3.7).


P1.1 — Plugin-lifecycle state visibility [EXTEND]

Evidence in PluginForge code:

  • PluginManager.get_last_discovery_result() -> DiscoveryResult | None already exists (manager.py:306). The brief's request #1 is already shipped.
  • DiscoveryResult.states: dict[str, PluginState] already carries per-plugin activation status, errors, and filter reasons (v0.7.0 shape).
  • PluginManager.get_load_errors() -> dict[str, str] exists (manager.py:718).

What the brief asks for that is genuinely new:

  • Event hooks: on_plugin_activated(callback), on_plugin_deactivated(callback), on_config_refreshed(callback). Polling-to-push migration for consumer hot-reload UX. Useful, but adds an event-bus to the manager — design decision (Q2).
  • Richer PluginState fields: activated_at, last_config_change, source ("entry-point" vs "direct-register"). Cheap additions to the existing dataclass.
  • inspect_plugin(name) -> dict: debug-comprehensive snapshot. Convenience surface; could equally be a third-party helper outside the framework.

Recommendation: v0.9.0 ships (a) richer PluginState fields, (b) inspect_plugin. Event-hook bus is a separate RFC (Q2). All additive, no breaking changes.

Decision (2026-05-20): Q2 resolved as (a) — simple synchronous callbacks. Deferred to v0.9.0. No work in v0.8.0.


P1.2 — Plugin cross-communication (Service-Registry) [DEFER to RFC]

Brief's own insider caveat: "the lazy-import pattern Bibliogon shipped in C3 is good enough for two plugins with a one-way dependency. The Service-Registry's value compounds with N plugins."

PluginForge today provides depends_on (declarative) and pluggy hooks (implicit pub-sub). The brief's Service-Registry is a third coordination primitive sitting between these two. Adding a third primitive without an RFC risks API-surface bloat.

Recommendation: treat as 0.9.0 RFC. Defer to a separate design doc. Open the RFC against Bibliogon Mobile-Sync-Plugin (which the brief flags as the upcoming case that would need it) and validate the shape against that workload.

STOP gate: RFC accepted before any implementation. Out of scope for 0.8.0.


P2.1 — Plugin config schema validation [DEFER]

Evidence in PluginForge code: BasePlugin.config_schema: dict[str, type] | None already exists, validated in PluginLifecycle._validate_config (lifecycle.py:65-85). Today this is a minimal type-check, not a full Pydantic schema.

Question: the brief proposes a pluginforge.schemas.PluginConfigYAML Pydantic schema for the plugin metadata YAML itself (the plugin.yaml file shape), separate from per-plugin app config. Two different things.

Recommendation: defer to 0.9.0 with two prerequisite decisions (Q3):

  1. Does PluginForge take a hard dependency on Pydantic? (Today it has none. Adding one is a non-trivial decision for a single-purpose-library.)
  2. Is the metadata-YAML schema first-party PluginForge concern, or a consumer convention?

Until these resolve, the existing config_schema (type-based) is the supported path.

Decision (2026-05-20): Q3 resolved as (c) — stay framework-agnostic. No Pydantic dependency in v0.8.0 or v0.9.0 unless a second consumer requests it.


P2.2 — Test helper library [ACCEPT (scoped)] {#p2-2}

Recommendation for 0.8.0: ship a minimal pluginforge.testing namespace with:

  • IsolatedPluginManager context manager. Creates a fresh PluginManager + fresh FastAPI app + automatically deactivates plugins on exit. The recursion-safe sibling to P0.
  • MockPlugin builder. Useful for unit-testing both PluginForge itself (the canary fixture uses it) and downstream consumers.
  • A py.typed marker on the package (currently missing; should be there regardless for PEP 561 compliance).

Deferred to 0.9.0:

  • plugin_discovery_smoke() programmatic helper (Bibliogon's script).
  • Larger fixture catalog (isolated_plugin_fixture, class_scoped_test_client).

Insider caveat noted: Bibliogon's BIBLIOGON_TEST=1 sentinel discipline is consumer-side. IsolatedPluginManager should be neutral: do not couple to consumer-app markers.

STOP gate: maintainer confirms pluginforge.testing as the public namespace (vs alternatives like pluginforge.test_helpers, pluginforge.testkit).


P3.1 — Unified hot-reload + ZIP path reconciliation [DEFER]

Evidence: PluginManager.reload_plugin(name) already exists (manager.py:875) for entry-point-installed plugins. The brief acknowledges the ZIP path is Bibliogon-specific.

Recommendation: defer. Document the two installation paths as part of P3.7 (docs); do not unify yet. Wait for a second consumer to hit the same gap before generalizing.


P3.2 — Plugin metric collection [DEFER]

Opt-in only with no overhead by default is correct. But the metric shape depends on what consumers actually need. Wait for a second consumer signal. No work in 0.8.0.


P3.3 — Bidirectional compatibility matrix [DEFER]

Precondition: Bibliogon-side PLUGIN-VERSION-GATING-ENABLE-01 must ship first. That confirms the existing one-direction min_app_version gate works in production. Adding the other direction (min_plugin_versions from the app side) without proving the first direction is premature.

The brief's own insider nuance flags this: for entry-point plugins, pip already adjudicates. The bidirectional case is mostly for ZIP-installed plugins (Bibliogon-specific path). 0.10.0+.


P3.4 — Plugin Health-Check API [PARTIAL ACCEPT]

Evidence:

  • BasePlugin.health() -> dict[str, Any] already exists (base.py:88) with default {"status": "ok"}.
  • PluginManager.health_check() -> dict[str, dict[str, Any]] already aggregates (manager.py:861).

What's genuinely new: the canonical FastAPI URL surface (/api/plugins/health, /api/plugins/{name}/health) and an optional HealthStatus dataclass to formalize the shape.

Recommendation for 0.8.0 stretch:

  • Add HealthStatus dataclass (status literal, message, version, extras) in pluginforge/state.py.
  • Add an optional mount_health_routes(app, prefix="/api/plugins") in pluginforge.fastapi_ext that exposes the canonical endpoints. Consumers opt in by calling it.
  • Keep BasePlugin.health() returning a dict for backward compat; the new HealthStatus is an additionally supported return type, not a forced migration.

Decision (2026-05-20): HealthStatus shape accepted with a name: str field added (populated from BasePlugin.name by the manager, parallel to version). Rationale: the aggregated /api/plugins/health endpoint returns per-plugin entries; each entry must be self-identifying without relying on the dict key from the aggregation response. Self-contained dataclass over positional coupling. Final shape recorded in Q4 decision below. mount_health_routes opt-in pattern confirmed, mirrors mount_plugin_routes.


P3.5 — Plugin error-recovery during activate() [REJECT — already shipped]

Evidence: PluginLifecycle.activate_plugin (lifecycle.py:99-108) already:

  1. Wraps plugin.activate() in try/except Exception.
  2. Captures the exception in _last_errors (retrievable via get_last_error(name)).
  3. Logs at ERROR level: "Failed to activate plugin %s: %s".
  4. Returns False, allowing the manager to continue with other plugins.

A broken plugin does NOT bring down the rest in v0.7.0. The brief's claim ("currently PluginForge bubbles the exception up and the whole app startup crashes") was true in 0.5.x but was fixed in 0.6.0+.

Recommendation: no work. Note in the v0.8.0 changelog that this behavior has been in place since v0.6.0; consumer documentation should reflect this so future briefs do not repeat the misunderstanding.


P3.6 — FilterReason i18n hook [ACCEPT]

Scope:

  • Ship canonical English keys for each FilterReason value in pluginforge.i18n.
  • Register a translator hook: pluginforge.i18n.register_filter_reason_translator(fn). The hook receives (reason, lang) and returns a translated string.
  • Default translator returns the English key.

Cheap. No public API breakage. 0.8.0 stretch.

STOP gate: stretch-only for v0.8.0. If shipped, registration pattern proposal is the module-level function (pluginforge.i18n.register_filter_reason_translator(fn)), consistent with the framework-agnostic posture of pluginforge.i18n. Confirm at implementation time if scope reaches this commit.


P3.7 — Documentation gaps [ACCEPT, staged]

0.8.0 (C7 scope, locked):

  • Consumer-app integration guide — ships complete. "How to wire PluginForge into your FastAPI app." Covers PluginManager construction, lifespan integration, mount_routes, config loading, hot-reload via reload_plugin, surfacing DiscoveryResult in a Settings UI, and the reference to pluginforge.testing for test wiring.
  • Plugin-author guide — ships as a skeleton with explicit TODO markers for sections that need v0.9.0 content. The skeleton has correct headings, one-paragraph summaries per section, and TODO: expand in v0.9.0 markers where content is deferred. Single-router convention (P0.5) gets full content in the skeleton; other sections are stubs.

0.9.0: Plugin-author guide gets full depth on the deferred sections.

Ongoing: the CLAUDE.md drift noted in baseline (v0.5.0 -> v0.7.0, 162 -> 257 tests, 93% -> 96% coverage) gets fixed in C2, not deferred to release.


Architecture decisions (locked 2026-05-20)

The four open questions raised during triage are now resolved. Decisions below are fixed inputs to implementation; rationale is recorded so future readers understand the basis.

Q1 (P0) — Idempotency-tracking state location: modified-(b), module-level in fastapi_ext.py

Decision: state lives as a module-level WeakKeyDictionary in pluginforge/fastapi_ext.py, NOT on PluginManager.

# pluginforge/fastapi_ext.py
from weakref import WeakKeyDictionary

_mounted_plugins: "WeakKeyDictionary[FastAPI, set[str]]" = WeakKeyDictionary()

mount_plugin_routes checks and updates this dict per call. No changes to PluginManager's constructor or attributes.

Rationale: PluginManager is framework-agnostic by design. The v0.6.0 decision around pre_activate_rejected naming and the absence of FastAPI imports in manager.py is a load-bearing invariant. Idempotency-tracking is a FastAPI-extension concern; mount_plugin_routes lives in fastapi_ext.py; the tracking state belongs next to it.

Test isolation: the P2.2 IsolatedPluginManager fixture clears _mounted_plugins on setup. That's cleaner than contaminating the manager with FastAPI knowledge.

STOP condition during implementation: if module-level state creates a testing problem IsolatedPluginManager cannot solve, surface the specific issue before switching to a manager-attribute approach. Do not silently move it onto the manager.

Q2 (P1.1) — Event-hook design: (a), shipped in v0.9.0

Decision: simple synchronous callback registry on PluginManager (manager.on_plugin_activated(cb) etc.). Pluggy hookspecs reserved for the case where a second consumer asks for hook-spec-style observers.

No work in v0.8.0.

Q3 (P2.1) — Pydantic dependency: (c), stay framework-agnostic

Decision: no Pydantic dependency in v0.8.0 or v0.9.0. Metadata-YAML validation stays consumer-side until a second consumer requests a first-party schema. The existing BasePlugin.config_schema type-based validation remains the supported path for per-plugin config validation.

Q4 (P3.4) — HealthStatus shape

Decision: shape accepted with name: str field added.

@dataclass(frozen=True, slots=True)
class HealthStatus:
    status: Literal["ok", "degraded", "error"]
    name: str = ""
    version: str = ""
    message: str | None = None
    extras: dict[str, Any] = field(default_factory=dict)

name and version are populated by the manager from BasePlugin.name / BasePlugin.version so that each entry in the aggregated /api/plugins/health response is self-identifying without relying on the dict-key.

mount_health_routes(app, prefix="/api/plugins") opt-in pattern confirmed, mirrors mount_plugin_routes. Plugins continue to return raw dicts from BasePlugin.health() for backward compat; the route handler wraps the dict into HealthStatus for the response shape.

Scope clarification: Q4 is a v0.8.0 stretch, not core. Ships if C3-C7 scope completes with runway.

Sequencing for v0.8.0 and v0.9.0

v0.8.0 scope (must-have):

  1. P0 Recursion-safe mount_plugin_routes + canary test.
  2. P0.5 Single-router warning + plugin-author convention doc.
  3. P2.2 (scoped) pluginforge.testing.IsolatedPluginManager + MockPlugin + py.typed marker.
  4. P3.7 (partial) Consumer-app integration guide.
  5. CLAUDE.md drift fix (version, test counts, coverage).

v0.8.0 stretch (ship if scope allows):

  • P3.4 Health-check URL surface + HealthStatus dataclass.
  • P3.6 FilterReason i18n hook.

v0.9.0 scope:

  • P1.1 Richer PluginState fields + inspect_plugin. Event hooks if Q2 resolved.
  • P1.2 Service-Registry RFC outcome (if RFC accepted).
  • P2.1 Config-schema decision per Q3.
  • P3.7 Plugin-author guide.

v0.10.0+ deferred:

  • P3.1 unified hot-reload + ZIP path
  • P3.2 plugin metrics
  • P3.3 bidirectional compat matrix (after Bibliogon-side PLUGIN-VERSION-GATING-ENABLE-01)

Stop conditions (carried forward into implementation)

  • C3 canary at the default 20x10x50 threshold does NOT fail-Red against unfixed code — try 30x15x75 or full 50x20x100. If even full-scale does not reproduce, STOP. The hypothesis is wrong or the test setup does not match the real-world cascade. Surface before continuing.
  • C3 idempotent fix passes canary but the methodology is suspect (e.g. requires sys.setrecursionlimit manipulation to trigger) — surface. The canary must reproduce the bug under conditions Bibliogon actually encountered.
  • C3 idempotent mount passes PluginForge canary but Bibliogon's sweep stays red after upgrade — ship Path A anyway (defensible improvement) and reopen P0 root cause as a follow-up.
  • C4 module-level _mounted_plugins creates a test-isolation problem IsolatedPluginManager cannot solve — surface before moving state onto the manager. Q1 decision is locked.
  • C5 py.typed not in the built wheel — fix before C6.
  • Brief item REJECTED here turns out to be wrong — reopen via comment on this doc. P3.5 specifically: if a downstream consumer demonstrates a path where activate() does crash startup despite lifecycle.py:99-108, reopen.

Questions and assumptions

Evidence-based answers taken from PluginForge's own code:

  • P3.5 already shipped: verified by reading pluginforge/lifecycle.py:99-108. No ambiguity.
  • P1.1 first request already shipped: verified by reading pluginforge/manager.py:306-308.
  • P3.4 method already exists: verified by reading pluginforge/base.py:88-96 + manager.py:861-873.

Maintainer-resolved during this design pass (2026-05-20):

  • Q1, Q2, Q3, Q4 above — all resolved in this doc.
  • Single-router deprecation cycle confirmed (warn 0.8.0, continue warning 0.9.0, optionally error 0.10.0).
  • pluginforge.testing as the public namespace (not test_helpers, not testkit).
  • Canary threshold default 20x10x50 with full-scale variant under @pytest.mark.slow.
  • P3.5 REJECT stands; reopen only via issue if a downstream consumer demonstrates a counterexample.
  • CLAUDE.md drift fix lands as C2 on the branch (not deferred to the release commit).
  • Milestone discipline: push through C1-C9 without pause after C3. Bibliogon verifies against the complete v0.8.0 build.

Open assumptions (still conservative, not blocking):

  • The brief's claim that mount_plugin_routes is the root cause of the recursion is plausible from PluginForge's side but unverified until the C3 canary fails-Red against the unfixed code. Treating as a strong hypothesis, not a settled diagnosis. The Red-verification step in C3 is the proof.

Next steps

  1. C1 (this commit) — lock architecture decisions, commit triage doc on feature/v0.8.0-improvements.
  2. C2 — refresh CLAUDE.md to reflect v0.7.0 state (version, test counts, public API).
  3. C3 — canary regression test for recursion cascade. Red-verify against unfixed code. Apply idempotent mount_plugin_routes fix. Green-verify. STOP gate if canary methodology is suspect.
  4. C4 — pluginforge.testing namespace: IsolatedPluginManager, MockPlugin.
  5. C5 — py.typed marker, verify in built wheel.
  6. C6 — single-router-per-plugin deprecation warning.
  7. C7 — consumer-integration guide (complete) + plugin-author guide (skeleton).
  8. C8 — CHANGELOG v0.8.0 entry (append to existing root CHANGELOG.md).
  9. C9 — version bump to 0.8.0.
  10. Final check chain — ruff + format + mypy + codespell + pytest + pre-commit on all files.
  11. Push branch, open PR, maintainer review, merge, release per release-workflow.md.

End of triage.