## 1. Domain model extension (reuse `skills_learning/`) - [x] 1.1 Add `KnowledgeSkill(Skill)` dataclass to `skills_learning/models.py` carrying a `content: str` field plus `to_dict`/`from_dict` mirroring `FlowTemplateSkill`'s shape. The `SkillKind` Literal already includes `"knowledge"` — no discriminator change needed. - [x] 1.2 Confirm `pyproject.toml` already includes `httpx>=0.27.0` and `mcp>=1.27,<2`; no new dependencies required. (If a missing dep is discovered during impl, add it here.) ## 2. Catalog storage & query (capability: skill-catalog; layer: `storage/`) - [x] 2.1 Create `storage/skill_catalog.py` with `SkillCatalogStore(db_path="tasks/skills.sqlite3")` using SQLite. Schema: `subscriptions(id, active, last_synced_version, last_error, last_synced_at)` and `skills(id, subscription_id, name, description, kind, tags_json, version, source, updated_at, content, steps_json, parameters_json)`. Public read API only (`list_skills`, `search_skills`, `get_skill`); write surface is `_apply_sync_*` methods callable only from `api/skill_sync.py`. - [x] 2.2 `list_skills(active_subscriptions: set[str]) -> list[SkillMetadata]` — returns only skills whose `subscription_id ∈ active_subscriptions`, stable-sorted by `name`. - [x] 2.3 `search_skills(query: str, active_subscriptions: set[str]) -> list[SkillMetadata]` — substring match on `name`/`tags`/`description`, ranked by simple relevance (e.g. name-prefix → name-contains → tag/desc). - [x] 2.4 `get_skill(skill_id: str, active_subscriptions: set[str]) -> KnowledgeSkill | FlowTemplateSkill | None` — returns `None` for unknown id **and** for known-but-not-visible id (no existence leak). - [x] 2.5 Enforce sync-only writes: no public `create`/`update`/`remove` methods. The `_apply_sync_upsert` / `_apply_sync_remove` / `_apply_sync_replace_all` methods are prefixed with `_` to signal the contract; only `api/skill_sync.py` imports them. - [x] 2.6 `validate_flow_template_tools(skill: FlowTemplateSkill, registered_tools: set[str]) -> bool` — returns `False` if any `step.tool_name` is not in `registered_tools`; `get_skill` uses this to return `None` (treat as unavailable) when references dangle. - [x] 2.7 `tests/test_skill_catalog.py` — unit tests covering: visibility filtering on list/search/get, not-found vs not-visible indistinguishability, dangling-tool-reference invalidation, stable sort order, round-trip serialization for both `kind` values. ## 3. Subscription sync client (capability: skill-subscription-sync; layer: `api/`) - [x] 3.1 Define `SubscriptionClient` Protocol in `api/skill_sync.py`: `fetch_entitled_skills(subscription_id: str, since_version: int | None = None) -> SyncDelta` where `SyncDelta` carries `skills: list[Skill]`, `removed_ids: list[str]`, `latest_version: int`. Supports both full (since_version=None) and incremental fetch shapes. - [x] 3.2 Implement `HttpSubscriptionClient(base_url, auth_token, timeout)` satisfying the Protocol, using `httpx`. Auth and request/response shape isolated behind this class so the real Subscription Platform API can be wired in by adjusting only this file. - [x] 3.3 Implement `SkillSyncRunner(store, client, subscriptions: list[str], poll_interval)`: a `tick()` method that fetches deltas for each subscription and applies them via `store._apply_sync_*`. Provide both a manual `tick()` API and a `run_forever()` blocking loop; do not auto-start any thread on import. - [x] 3.4 Optional push receiver: add `POST /webhooks/skill-sync` to `api/rest.py` (or a new `api/skill_sync_webhook.py` if rest.py grows too large) that calls `SkillSyncRunner.tick()` immediately. Documented as optional — poll loop remains correct standalone. - [x] 3.5 Query-time revocation re-check (D4): `active_subscriptions` parameter accepted by `list_skills`/`search_skills`/`get_skill` is **re-read from the `subscriptions` table on every call** (caller passes subscription IDs; store checks `active = 1`). Revoking a subscription (sync sets `active = 0`) takes effect on the next query, not just the next sync. - [x] 3.6 Sync failure handling: `SkillSyncRunner.tick()` wraps `client.fetch_entitled_skills` in try/except; on any exception (network/auth/malformed-response), leave `store` unchanged and write `last_error` + `last_synced_at` to the `subscriptions` row. Cache continues serving last-known-good state. - [x] 3.7 `tests/test_skill_sync_client.py` — using a `FakeSubscriptionClient` implementing the Protocol: new/updated/removed delta scenarios, push-triggered immediate `tick()`, query-time revocation before next sync (3.5), failure-preserves-cache (3.6). ## 4. Skill MCP tools (capability: skill-mcp-tools; layer: `api/`) - [x] 4.1 Create `api/skill_catalog_mcp.py` with `register_skill_catalog_tools(server, store, get_active_subscriptions: Callable[[], set[str]], registered_tools: Callable[[], set[str]])`. Wire it into `api/mcp.py:create_mcp_server` with a single call at the end. - [x] 4.2 `resolve_flow_template(skill_id, params: dict)` MCP tool: thin wrapper over `workflow.skill_exec.resolve_skill_steps` (after fetching the skill via `store.get_skill`). Returns the substituted step list. Missing/invalid params surface as semantic errors via 4.3. - [x] 4.3 Register `list_skills`/`search_skills`/`get_skill`/`resolve_flow_template` MCP tools with semantic error translation consistent with `api/errors.py:call_with_semantic_errors`. Distinct error codes for `SkillNotFound`, `SkillNotVisible` (mapped to not-found at the response layer — no existence leak), `InvalidFlowTemplate` (dangling tool reference), `MissingParameter`. - [x] 4.4 Confirm (via test 4.5) that **no** `run_skill_flow` or any other batch-execute MCP tool is registered. Execution stays with the LLM issuing existing device-capability tool calls one at a time, per D5. - [x] 4.5 `tests/test_skill_catalog_mcp.py` — drive `FastMCP` in-memory tool registry: assert `list_skills`/`search_skills`/`get_skill`/`resolve_flow_template` are registered; assert no tool whose name suggests batch execution is registered; exercise each tool against a seeded in-memory `SkillCatalogStore` with mocked `get_active_subscriptions`; cover not-found, not-visible, dangling-tool-reference, and missing-parameter error paths. ## 5. End-to-end validation - [x] 5.1 `tests/test_skill_catalog_e2e.py` — seed a `SkillCatalogStore` via a `FakeSubscriptionClient` with one knowledge skill and one flow-template skill; assert both are listable/searchable/gettable through the MCP tool surface (using the same `register_skill_catalog_tools` registration). - [x] 5.2 Same e2e harness — simulate revocation: remove the skill from the fake client's entitled set; run one sync `tick()` and confirm the skill disappears from `list_skills`/`get_skill`; separately, revoke subscription (`active = 0`) without running sync and confirm the query-time re-check (3.5) hides the skill before the next sync completes. - [x] 5.3 Same e2e harness — call `resolve_flow_template` via MCP, then drive the resulting step sequence through the existing `apex-agent-mvp` device-capability tools (`tap`/`input_text`/etc.) against a mocked device (`tests/fakes.py` or a new fake), confirming each step still flows through the normal Executor Observe-Think-Act loop (no batched execution shortcut).